Skip to content

Commit 93a0460

Browse files
committed
P-037 A18-1: STOP — hard cap reached before the population answer
The A18-1 implementation (extending scripts/p037_a18_decompose.py with --population and --selftest) measures 200 added/changed code lines relative to #374, against the pre-registered cap of 180. The first draft was 208; genuine de-duplication brought it to 200 and no further without golfing or dropping pre-registered checks. Per §H this is a STOP. Neither --selftest nor the population run was executed, so there is no result and no conclusion about the population. The code is committed unrun so the owner can see what the lines are for. The note §I gives the breakdown and three options: - raise the cap; - narrow to per-row L_canonical; - first add per-row B1 evidence. ruff and mypy are clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KmiyfrkaG9sJruTcshM2oq
1 parent 8046688 commit 93a0460

2 files changed

Lines changed: 279 additions & 12 deletions

File tree

‎docs/notes/p037-a18-population-decomposition.md‎

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
# P-037 A18-1: population decomposition (pre-registered)
22

3-
> Status: **PRE-REGISTERED.** Committed before any implementation. The owner
4-
> authorized implementing this gate right after this commit. The boundaries
5-
> below do not move after this SHA.
3+
> Status: **STOP: HARD CAP REACHED BEFORE THE POPULATION ANSWER (§I).**
4+
> Pre-registered in `8046688`; the boundaries did not move. The
5+
> implementation measures 200 added code lines against the 180 cap. Neither
6+
> `--selftest` nor the population run has been executed. There is no result,
7+
> and no conclusion about the population.
68
>
79
> - Base: `3745975`, the head of #374 (A18-0, `RESULT: PASS — A18 8-ROW
810
> DECOMPOSITION HOLDS`, accepted by the owner).
@@ -186,3 +188,53 @@ B1-reconciliation gate needs its own authorization.
186188
- Not touched: the B1 result, `missing_sidecar`, R, A14, `ConsumesParam`,
187189
OwnIR, extractor semantics, the application classifier, Phase C, #368, the
188190
three P-037 classes, production MOS/verdicts, and `own-guarded`.
191+
192+
## I. STOP (MEASURED OBSERVATION): the cap is reached before any answer
193+
194+
§H says that reaching 180 added lines unfinished is a STOP. The
195+
implementation `scripts/p037_a18_decompose.py` measures **200** counted
196+
lines added or changed relative to `#374` (`3745975`), counted like A18-0
197+
(non-blank, non-comment, docstrings included). The first draft measured
198+
**208**.
199+
200+
The reduction to 200 came from genuine de-duplication only, with no
201+
formatting or golfing:
202+
- a shorter docstring;
203+
- an inlined single-use helper;
204+
- a dict lookup instead of a quadratic scan;
205+
- a print that reuses the output dict.
206+
207+
`ruff` and `mypy` are clean. **Neither `--selftest` nor `--population` has
208+
been run**, because a result obtained over the cap would violate the
209+
pre-registration. So this STOP says nothing about the population: no row was
210+
decomposed and no falsifier was exercised.
211+
212+
Where the 200 lines go, roughly:
213+
214+
| part | lines | pre-registered by |
215+
|---|---|---|
216+
| forwarding-site enumeration, local-honesty test, document-wide rewrite | ~40 | §C (`L_canonical` document-wide) |
217+
| forward-closure attribution and the reason rule | ~30 | §C (closure), N4/N5 |
218+
| typed coverage / drift against B1's per-population counts | ~25 | N1, N2, §B.1 |
219+
| frozen-tree / per-document plumbing (the A18-0 main still has its own copy) | ~25 | §B |
220+
| the six falsifiers | ~40 | §E |
221+
| verdict, cross-tab and evidence writer | ~25 | §F, §G |
222+
| docstring, imports, small refactors of A18-0 (`ops_at`) | ~15 | |
223+
224+
**Reading (INFERENCE, for the owner).** Going from eight isolated rows to the
225+
population costs more than the cap assumed, for two reasons:
226+
- document-wide `L_canonical` needs closure attribution, which A18-0 did not;
227+
- B1 committed no per-row values for its 28 `EQUAL` rows, so coverage and
228+
drift must be reconstructed from class counts.
229+
230+
Neither is a new semantic mechanism, but §H reads the overrun as a signal
231+
that the abstraction may be wrong, and that is the owner's call. The honest
232+
options are:
233+
- raise the cap, knowing what the lines are for;
234+
- narrow the gate, e.g. per-row `L_canonical` (A18-0's definition), which
235+
drops closure attribution but can misclassify callers of normalized
236+
wrappers;
237+
- first make B1 commit per-row evidence (a small B1 evidence addition),
238+
which would shrink the coverage/drift part.
239+
240+
Nothing further is done until the owner decides.

‎scripts/p037_a18_decompose.py‎

Lines changed: 224 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#!/usr/bin/env python3
2-
"""P-037 A18-0: the three-way decomposition of the eight B1 UNEXPLAINED rows.
2+
"""P-037 A18: the three-way decomposition, A18-0 (the eight B1 UNEXPLAINED rows)
3+
and A18-1 (`--population`, `--selftest`: docs/notes/p037-a18-population-decomposition.md).
34
45
Contract: docs/notes/p037-a18-legacy-decomposition.md (pre-registered in
56
ccde27c). For exactly the eight rows committed in B1's evidence, from the
@@ -15,16 +16,21 @@
1516
witness.
1617
1718
Run: python scripts/p037_a18_decompose.py --out docs/evidence/p037-a18/eight-rows.json
19+
python scripts/p037_a18_decompose.py --selftest
20+
python scripts/p037_a18_decompose.py --population --out docs/evidence/p037-a18/population.json
1821
"""
1922
from __future__ import annotations
2023

2124
import argparse
25+
import collections
26+
import contextlib
2227
import copy
2328
import json
2429
import re
2530
import subprocess
2631
import sys
2732
import tempfile
33+
from collections.abc import Iterator
2834
from pathlib import Path
2935
from typing import Any
3036

@@ -61,6 +67,11 @@ def names(op: Any, p: str) -> bool:
6167
return p in (op.get("var"), op.get("src"), op.get("result")) or p in (op.get("args") or [])
6268

6369

70+
def ops_at(fn: Any, line: Any, p: str) -> list[tuple[list[Any], int]]:
71+
return [(lst, i) for lst, i in op_sites(fn["body"])
72+
if lst[i].get("line") == line and names(lst[i], p)]
73+
74+
6475
def locate(facts: Any, row: Any) -> tuple[Any, Any, list[tuple[list[Any], int]]]:
6576
"""The row's function, its one forwarding sidecar call, and the legacy ops on
6677
the parameter at that call's statement line."""
@@ -69,10 +80,7 @@ def locate(facts: Any, row: Any) -> tuple[Any, Any, list[tuple[list[Any], int]]]
6980
if any(a["kind"] == "param" and a.get("source_param") == row["ordinal"]
7081
for a in c["args"])]
7182
call = calls[0] if len(calls) == 1 else None
72-
line = call["statement_line"] if call else None
73-
ops = [(lst, i) for lst, i in op_sites(fn["body"])
74-
if lst[i].get("line") == line and names(lst[i], row["param"])]
75-
return fn, call, ops
83+
return fn, call, ops_at(fn, call["statement_line"] if call else None, row["param"])
7684

7785

7886
def decompose(facts: Any, rep: Any, row: Any) -> dict[str, Any]:
@@ -115,7 +123,8 @@ def shape_probe(tree: Path, files: list[str], row: Any, line: int, work: Path) -
115123
facts, rep, _ = b1.run_document(tree, files, work)
116124
finally:
117125
src.write_text(text)
118-
ops = locate(facts, row)[2]
126+
fn = next(f for f in facts["functions"] if f["name"] == row["method"])
127+
ops = ops_at(fn, line, row["param"])
119128
return {"line_before": old.strip(), "line_after": lines[line - 1].strip(),
120129
"op_after": ops[0][0][ops[0][1]]["op"] if len(ops) == 1 else None,
121130
"actual_after": coord(rep, row["method"], "index", row["index"])["legacy"]}
@@ -132,15 +141,221 @@ def witnessed(d: dict[str, Any], row: dict[str, Any]) -> bool:
132141
return False
133142

134143

144+
# ---------------------------------------------------------------------------
145+
# A18-1: the population gate
146+
# ---------------------------------------------------------------------------
147+
148+
LOCAL = {"release": "must", "use": "no"} # a legacy op's own local value
149+
150+
151+
def reason_of(actual: str, canonical: str, mechs: set[str], broken: bool) -> str:
152+
"""The normalization column: only the pre-registered reasons."""
153+
if broken:
154+
return "UNEXPLAINED"
155+
if canonical == actual:
156+
return "EQUAL"
157+
if len(mechs) == 1 and mechs <= {"CONSUMES_PARAM_FOLD", "ARGUMENT_SHAPE_LOSS"}:
158+
return next(iter(mechs))
159+
return "UNEXPLAINED"
160+
161+
162+
def population(facts: Any, rep: Any, rows: list[dict[str, Any]], probe: Any,
163+
rewrite: bool = True) -> list[dict[str, Any]]:
164+
"""One document: normalize every locally non-honest forwarding site of every
165+
comparable row, run the production MOS once, attribute through closures."""
166+
canon = copy.deepcopy(facts)
167+
fns = {f["name"]: f for f in canon["functions"]}
168+
info: dict[tuple[str, int], dict[str, Any]] = {}
169+
for r in rows:
170+
entry: dict[str, Any] = {"broken": False, "sites": []}
171+
fn = fns[r["method"]]
172+
for c in (fn.get("guarded_facts") or {}).get("calls", []):
173+
slots = [a["param"] for a in c["args"]
174+
if a["kind"] == "param" and a.get("source_param") == r["ordinal"]]
175+
if not slots:
176+
continue
177+
ops, callee = ops_at(fn, c["statement_line"], r["param"]), coord(
178+
rep, c["callee"], "ordinal", slots[0])
179+
if len(ops) != 1 or callee is None:
180+
entry["broken"] = True
181+
continue
182+
lst, i = ops[0]
183+
kind, mech = lst[i]["op"], None
184+
if LOCAL.get(kind, callee["legacy"]) != callee["legacy"]:
185+
mech = ("CONSUMES_PARAM_FOLD" if kind == "release" else
186+
"ARGUMENT_SHAPE_LOSS" if probe(r, c["statement_line"]) else "UNKNOWN")
187+
if rewrite: # the A18-0 honest positional forward
188+
lst[i] = {"op": "call", "callee": c["callee"], "sig": c["sig"], "line": c[
189+
"statement_line"], "args": ["_"] * callee["index"] + [r["param"]]}
190+
entry["sites"].append({"callee": (c["callee"], callee["index"]), "op": kind,
191+
"line": c["statement_line"], "mechanism": mech})
192+
info[(r["method"], r["index"])] = entry
193+
after_rep = report(canon)
194+
out = []
195+
for r in rows:
196+
k = (r["method"], r["index"])
197+
seen, stack, mechs = {k}, [k], set()
198+
while stack:
199+
for site in info.get(stack.pop(), {"sites": []})["sites"]:
200+
mechs |= {site["mechanism"]} - {None}
201+
if site["callee"] not in seen:
202+
seen.add(site["callee"])
203+
stack.append(site["callee"])
204+
after = coord(after_rep, r["method"], "index", r["index"])
205+
reason = reason_of(r["actual"], after["legacy"], mechs, info[k]["broken"])
206+
out.append({**r, "canonical": after["legacy"], "guarded_after": after["guarded"],
207+
"semantic_class": after["class"], "normalization_reason": reason,
208+
"closure_mechanisms": sorted(mechs), "sites": info[k]["sites"],
209+
"equal_kind": (("identity" if not mechs else "after_rewrite")
210+
if reason == "EQUAL" else None)})
211+
return out
212+
213+
214+
def coverage(rows: list[dict[str, Any]], expected: dict[str, dict[str, int]],
215+
committed: list[dict[str, Any]]) -> list[tuple[str, str]]:
216+
"""N1 (missing/duplicate) and N2 (drift) against B1's committed evidence."""
217+
bad = []
218+
keys = [(r["doc"], r["method"], r["index"]) for r in rows]
219+
if len(set(keys)) != len(keys):
220+
bad.append(("duplicate", "a comparable row occurs twice"))
221+
got: dict[str, collections.Counter[str]] = collections.defaultdict(collections.Counter)
222+
for r in rows:
223+
got[r["pop"]][r["b1_class"]] += 1
224+
for pop in set(expected) | set(got):
225+
want = {c: n for c, n in expected.get(pop, {}).items() if c != "NO_GUARDED_EVIDENCE"}
226+
if dict(got[pop]) != want:
227+
kind = "drift" if sum(got[pop].values()) == sum(want.values()) else "missing"
228+
bad.append((kind, f"{pop}: {dict(got[pop])} != B1 {want}"))
229+
index = {(r["doc"], r["method"], r["index"]): r for r in rows}
230+
for u in committed:
231+
hit = index.get((u["doc"], u["method"], u["index"]))
232+
if hit is None:
233+
bad.append(("missing", f"committed row absent: {u['doc']} {u['method']}"))
234+
elif (hit["actual"], hit["guarded"]) != (u["legacy"], u["guarded"]):
235+
bad.append(("drift", f"committed row drifted: {u['doc']} {u['method']}"))
236+
return bad
237+
238+
239+
@contextlib.contextmanager
240+
def frozen_tree() -> Iterator[tuple[Path, Path, dict[str, tuple[str, list[str]]]]]:
241+
commit = b1.frozen(b1.POPULATION_COMMIT)
242+
with tempfile.TemporaryDirectory(prefix="p037-a18-") as td:
243+
tree = Path(td) / "tree"
244+
tree.mkdir()
245+
archive = subprocess.run(["git", "archive", commit], cwd=ROOT, capture_output=True,
246+
check=True).stdout
247+
subprocess.run(["tar", "-x", "-C", str(tree)], input=archive, check=True)
248+
yield tree, Path(td), {n: (pop, f) for pop, n, f in b1.documents(tree, commit)}
249+
250+
251+
def comparable(tree: Path, work: Path, name: str, pop: str, files: list[str]
252+
) -> tuple[Any, Any, list[dict[str, Any]], Any]:
253+
facts, rep, _ = b1.run_document(tree, files, work)
254+
rows = [{"doc": name, "pop": pop, "method": s["method"], "index": s["index"],
255+
"ordinal": s["ordinal"], "param": s["param"], "actual": s["legacy"],
256+
"guarded": s["guarded"], "b1_class": s["class"]}
257+
for s in rep["summary"] if s["class"] != "NO_GUARDED_EVIDENCE"]
258+
259+
def probe(r: Any, line: int) -> bool:
260+
p = shape_probe(tree, files, r, line, work)
261+
return bool(p["op_after"] == "release" and p["line_after"] != p["line_before"])
262+
return facts, rep, rows, probe
263+
264+
265+
def selftest() -> int:
266+
fails = 0
267+
268+
def check(label: str, ok: bool) -> None:
269+
nonlocal fails
270+
print(f"{'ok ' if ok else 'MISS'} {label}")
271+
fails += not ok
272+
with frozen_tree() as (tree, work, docs):
273+
facts, rep, rows, probe = comparable(tree, work, "guard-forward-bare",
274+
*docs["guard-forward-bare"])
275+
outer = [r for r in rows if r["method"] == "ShapeForwardBare.Outer"]
276+
on = population(facts, rep, outer, probe)[0]["normalization_reason"]
277+
off = population(facts, rep, outer, probe, rewrite=False)[0]["normalization_reason"]
278+
check(f"F1 a fold row without its rewrite stops being a fold ({on} -> {off})",
279+
on == "CONSUMES_PARAM_FOLD" and off != on)
280+
facts, rep, rows, probe = comparable(tree, work, "arg-cast-and-bang",
281+
*docs["arg-cast-and-bang"])
282+
cast = [r for r in rows if r["method"] == "ShapeCastBang.Cast"]
283+
broken = copy.deepcopy(facts)
284+
fn = next(f for f in broken["functions"] if f["name"] == "ShapeCastBang.Cast")
285+
for c in fn["guarded_facts"]["calls"]:
286+
c["statement_line"] += 100
287+
got = population(broken, rep, cast, probe)[0]["normalization_reason"]
288+
check(f"F2 a broken sidecar-to-forward link is UNEXPLAINED, not a fold ({got})",
289+
got == "UNEXPLAINED")
290+
check("F3 actual=unknown, canonical=must, no mechanism is UNEXPLAINED",
291+
reason_of("unknown", "must", set(), False) == "UNEXPLAINED")
292+
sem = population(facts, rep, cast, probe, rewrite=False)[0]["semantic_class"]
293+
check(f"F4 G=may vs L_canonical=no is semantic UNEXPLAINED ({sem})",
294+
sem == "UNEXPLAINED")
295+
base = population(facts, rep, rows, probe)
296+
want = {"p037-shapes": dict(collections.Counter(r["b1_class"] for r in base))}
297+
check("F5' the coverage check passes on a complete set", not coverage(base, want, []))
298+
check("F5 a deleted comparable row fails coverage", bool(coverage(base[1:], want, [])))
299+
check("F6 a duplicated comparable row fails coverage",
300+
bool(coverage([*base, base[0]], want, [])))
301+
print(f"RESULT: {'all falsifiers fire' if not fails else f'{fails} falsifier(s) missed'}")
302+
return 1 if fails else 0
303+
304+
305+
def population_run(out_path: str) -> int:
306+
ev = json.loads(EVIDENCE.read_text())
307+
expected = {k.split("/", 1)[1]: v for k, v in ev["counts"].items()
308+
if k.startswith("summary/")}
309+
committed = [u for u in ev["unexplained"] if u["level"] == "summary"]
310+
rows: list[dict[str, Any]] = []
311+
with frozen_tree() as (tree, work, docs):
312+
for name, (pop, files) in docs.items():
313+
facts, rep, comp, probe = comparable(tree, work, name, pop, files)
314+
if comp:
315+
rows += population(facts, rep, comp, probe)
316+
sem = collections.Counter(r["semantic_class"] for r in rows)
317+
norm = collections.Counter(r["normalization_reason"] for r in rows)
318+
cross = collections.Counter(f"{r['semantic_class']} x {r['normalization_reason']}"
319+
for r in rows)
320+
cov = coverage(rows, expected, committed)
321+
drift = [msg for kind, msg in cov if kind == "drift"]
322+
moved = [r for r in rows if r["guarded_after"] != r["guarded"]]
323+
verdict = ("VOID — B1 instrument drift" if drift else
324+
"FAIL — A18-1 COVERAGE BROKEN" if cov or moved else
325+
"FAIL — A18-1 NORMALIZATION MODEL INCOMPLETE" if norm["UNEXPLAINED"] else
326+
"FAIL — A18-1 SEMANTIC UNEXPLAINED" if sem["UNEXPLAINED"] else
327+
"PASS — A18 POPULATION DECOMPOSITION HOLDS")
328+
out = {"schema": "p037-a18-population/1", "population_commit": b1.frozen(b1.POPULATION_COMMIT),
329+
"instrument_commit": b1.git("rev-parse", "HEAD").strip(),
330+
"comparable_expected": sum(n for v in expected.values() for c, n in v.items()
331+
if c != "NO_GUARDED_EVIDENCE"),
332+
"covered": len(rows), "coverage_problems": cov, "guarded_moved": len(moved),
333+
"semantic": dict(sem), "normalization": dict(norm),
334+
"equal_kind": dict(collections.Counter(r["equal_kind"] for r in rows
335+
if r["equal_kind"])),
336+
"cross_tab": dict(cross), "rows": rows, "result": verdict}
337+
Path(out_path).parent.mkdir(parents=True, exist_ok=True)
338+
Path(out_path).write_text(json.dumps(out, indent=1) + "\n", encoding="utf-8")
339+
print(json.dumps({k: v for k, v in out.items() if k != "rows"}, indent=1))
340+
print(f"RESULT: {verdict}")
341+
return 0 if verdict.startswith("PASS") else 1
342+
343+
135344
def main(argv: list[str]) -> int:
136345
ap = argparse.ArgumentParser(description=__doc__,
137346
formatter_class=argparse.RawDescriptionHelpFormatter)
138-
ap.add_argument("--out", required=True)
347+
ap.add_argument("--out")
348+
ap.add_argument("--population", action="store_true")
349+
ap.add_argument("--selftest", action="store_true")
139350
args = ap.parse_args(argv)
351+
if args.selftest:
352+
return selftest()
140353
commit = b1.frozen(b1.POPULATION_COMMIT)
141-
if b1.git("status", "--porcelain", "--untracked-files=no").strip():
142-
print("REFUSED: the instrument tree is dirty; commit it first")
354+
if b1.git("status", "--porcelain", "--untracked-files=no").strip() or not args.out:
355+
print("REFUSED: needs --out and a clean instrument tree (commit it first)")
143356
return 2
357+
if args.population:
358+
return population_run(args.out)
144359
b1_rows = [u for u in json.loads(EVIDENCE.read_text())["unexplained"]
145360
if u["level"] == "summary"]
146361
rows: list[dict[str, Any]] = []

0 commit comments

Comments
 (0)