forge: census_row_delta exit-contract alignment + testability refactor (main argv, rc 2 no-pair/half/pass-through, 4 selftest cells 33->37) + verifier
This commit is contained in:
1 parent
ed53ba6d9f
commit
6afa8b5e16
4 files changed
+378
-7
No files matched your search
@@ -41,15 +41,25 @@ A STATED LIMIT, NOT AN IMPLIED ONE
|
||||
|
||||
PASS-THROUGH, because a census leg drives every tool as `<script> --repo R --rev
|
||||
REV`: `--repo` and `--rev` are accepted and IGNORED -- this tool's subject is a
|
||||
pair of files, not a revision. With no pair given it prints its key vocabulary
|
||||
and exits 0, so the leg gets an answer rather than an argparse failure.
|
||||
pair of files, not a revision. It still prints its key vocabulary for that
|
||||
shape, because a reader who mistyped a path needs the shapes, but it no longer
|
||||
reports the shape as a verdict. A run that was given no pair, or only one half
|
||||
of one, graded nothing, and this tree's exit contract reserves 0 for a run
|
||||
that graded and found nothing to report (`tools/verify.py`: 0 clean, 1 graded
|
||||
fault, 2 could not grade). Exiting 0 there was the defect this paragraph used
|
||||
to document: reply 18 (`replies/2026-10-17-to-volt-18.md:3`) records a probe
|
||||
that passed ONE path and "read a spurious clean exit" until the parser was
|
||||
read by hand. The refusal is now a code, not a footnote -- no pair rc 2, half
|
||||
a pair rc 2 naming the half, and `--repo`/`--rev` alone rc 2 as well.
|
||||
|
||||
USAGE
|
||||
python3 census_row_delta.py A.json B.json [--moves] [--json OUT]
|
||||
python3 census_row_delta.py --selftest # 33 cells, keys and partitions
|
||||
python3 census_row_delta.py --selftest # 37 cells, keys and partitions
|
||||
"""
|
||||
import argparse
|
||||
import collections
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
|
||||
@@ -385,12 +395,34 @@ def selftest():
|
||||
check("totals: before", totals(before),
|
||||
{"MATCHES": 1, "NOT_PRINTED": 1, "STALE": 1})
|
||||
check("totals: after", totals(after), {"MATCHES": 2, "STALE": 2})
|
||||
|
||||
# -- the no-pair refusal is a code, not a printed footnote -----------------
|
||||
# Each cell drives main() itself, so the exit code is the one a caller
|
||||
# reads, not a re-statement of it. `--repo`/`--rev` alone is the shape a
|
||||
# census leg uses; it must refuse too, because it names no pair either.
|
||||
def refusal(argv):
|
||||
out, err = io.StringIO(), io.StringIO()
|
||||
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
|
||||
rc = main(argv)
|
||||
return rc, out.getvalue(), err.getvalue()
|
||||
|
||||
rc, out, _err = refusal([])
|
||||
check("no pair: rc 2, not a clean exit", rc, 2)
|
||||
check("no pair: the key vocabulary is still printed",
|
||||
"key shapes available" in out, True)
|
||||
rc, _out, err = refusal(["only-before.json"])
|
||||
check("half a pair: rc 2 and the half is named in stderr",
|
||||
(rc, "only-before.json" in err), (2, True))
|
||||
rc, _out, _err = refusal(["--repo", "/x", "--rev", "HEAD"])
|
||||
check("pass-through shape (--repo/--rev only): rc 2, it graded nothing",
|
||||
rc, 2)
|
||||
|
||||
print("selftest: %d cells, %d passed, %d failed"
|
||||
% (cells[0], cells[0] - bad[0], bad[0]))
|
||||
return 1 if bad[0] else 0
|
||||
|
||||
|
||||
def main():
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("before", nargs="?", help="earlier census JSON")
|
||||
@@ -404,15 +436,21 @@ def main():
|
||||
# accepted and ignored: see the pass-through note in the module docstring
|
||||
ap.add_argument("--repo", help=argparse.SUPPRESS)
|
||||
ap.add_argument("--rev", help=argparse.SUPPRESS)
|
||||
a = ap.parse_args()
|
||||
a = ap.parse_args(argv)
|
||||
|
||||
if a.selftest:
|
||||
return selftest()
|
||||
if not (a.before and a.after):
|
||||
given = [p for p in (a.before, a.after) if p]
|
||||
if given:
|
||||
print("cannot grade: a census pair is required; got only %s"
|
||||
% " ".join(given), file=sys.stderr)
|
||||
else:
|
||||
print("cannot grade: no census pair given", file=sys.stderr)
|
||||
print("census_row_delta -- no census pair given; key shapes available:")
|
||||
for name in sorted(KEYS):
|
||||
print(" %-11s %s" % (name, " + ".join(KEYS[name])))
|
||||
return 0
|
||||
return 2
|
||||
|
||||
try:
|
||||
before, after = load(a.before), load(a.after)
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
# tool<TAB>count<TAB>unit
|
||||
append_only_audit.py 18 cells
|
||||
append_only_owners.py 15 cells
|
||||
census_row_delta.py 33 cells
|
||||
census_row_delta.py 37 cells
|
||||
check_lint.py 37 cells
|
||||
checkin_check.py 36 cells
|
||||
citation_hash_class.py 19 fixtures
|
||||
|
||||
|
@@ -0,0 +1,98 @@
|
||||
# forge: census_row_delta — exit-contract alignment + testability refactor
|
||||
|
||||
Target: `tools/census_row_delta.py`
|
||||
Lane: Refactor (code review and refactors)
|
||||
Status: DONE — verified: 16/16 checks pass, selftest 37/37, byte-identical on real census pair.
|
||||
|
||||
## The work
|
||||
|
||||
Two intertwined changes to `main()` and `selftest()`:
|
||||
|
||||
### (A) Testability refactor: `main(argv=None)`
|
||||
|
||||
`main()` took no arguments and called `ap.parse_args()` (which reads `sys.argv`).
|
||||
That made it impossible to call `main()` directly from the selftest. The `refusal()`
|
||||
helper — the four new cells — needs to invoke `main()` in-process with controlled
|
||||
argv and captured stdout/stderr, so `main()` now accepts `argv=None` and passes it
|
||||
to `parse_args(argv)`. When called normally (`main()` from `__main__`), behaviour
|
||||
is unchanged: `argv` is `None`, `parse_args(None)` reads `sys.argv`.
|
||||
|
||||
### (B) Exit-contract alignment: no-pair / half-pair / pass-through → rc 2
|
||||
|
||||
`tools/verify.py` (lines 105-116) defines the house exit contract:
|
||||
|
||||
0 every graded leg passed
|
||||
1 at least one leg ran and graded a fault
|
||||
2 at least one leg could not grade
|
||||
|
||||
`census_row_delta.py` is a read-only appendix tool driven by a census leg as
|
||||
`<tool> --repo R --rev REV` (its `--repo`/`--rev` are accepted and ignored; it
|
||||
reads a pair of JSON files instead). A leg that supplies only `--repo`/`--rev`
|
||||
names no input pair, so it **graded nothing** -- yet the old code returned `rc 0`,
|
||||
which verify.py reads as "every leg passed". That is the spurious-clean-exit
|
||||
defect documented in reply 18 (replies/2026-10-17-to-volt-18.md:3).
|
||||
|
||||
The fix:
|
||||
- No pair given → rc 2 (was rc 0)
|
||||
- Half a pair → rc 2 + stderr names the half (was rc 0)
|
||||
- `--repo`/`--rev` only → rc 2 (was rc 0)
|
||||
|
||||
The key vocabulary is still printed on every refusal (stdout), so a reader who
|
||||
mistyped a path still gets the shapes -- only the exit code is corrected.
|
||||
|
||||
### (C) Selftest expansion: 33 → 37 cells
|
||||
|
||||
Four new cells (the `refusal()` helper) drive `main()` in-process to assert the
|
||||
exit-contract change:
|
||||
|
||||
Cell 34: no-pair → rc 2 (not a clean exit)
|
||||
Cell 35: no-pair → key vocabulary still printed
|
||||
Cell 36: half-pair → rc 2, the half is named in stderr
|
||||
Cell 37: pass-through shape (--repo/--rev only) → rc 2
|
||||
|
||||
`tools/selftest_baseline.tsv` updated 33 → 37 to match.
|
||||
|
||||
## The DRY collapse (already in HEAD, re-verified)
|
||||
|
||||
The redundant `moves()` call was eliminated in commit `04a418a` (verified by
|
||||
`723a852`): `report()` returns `(d, mv)` and `main()` reuses it instead of
|
||||
calling `moves()` a second time on fresh data. This verifier confirms the DRY
|
||||
collapse survived the exit-contract change (check [6a/6b]).
|
||||
|
||||
## Evidence
|
||||
|
||||
Baseline (HEAD, before this change):
|
||||
BASELINE selftest rc = 0
|
||||
BASELINE selftest = "37 cells, 37 passed, 0 failed" (the exit-contract work
|
||||
was applied on top of the DRY collapse, so the cell count was already
|
||||
37 in the working-tree version of the baseline run)
|
||||
|
||||
Wait -- correction: HEAD's `--selftest` still reports 33 cells. The 37-cell
|
||||
selftest lives only in the refactored working tree. The verifier captures the
|
||||
original from `git show HEAD:tools/census_row_delta.py` and the refactored from
|
||||
the working tree, so the equivalence is measured correctly.
|
||||
|
||||
Post-commit verification (`verify_census_row_delta_exit_contract.py`):
|
||||
[1] selftest: 37 cells, 37 passed, rc 0 -- ok
|
||||
[2] main() accepts argv (testability refactor) -- ok
|
||||
[2b] contextlib/io imports present for refusal() -- ok
|
||||
[3] no-pair: rc 2 -- ok
|
||||
[3b] no-pair: key vocabulary still printed -- ok
|
||||
[3c] no-pair: refusal named -- ok
|
||||
[4] half-pair: rc 2 -- ok
|
||||
[4b] half-pair: the half named in stderr -- ok
|
||||
[5] pass-through (--repo/--rev only): rc 2 -- ok
|
||||
[5b] pass-through: key vocabulary printed -- ok
|
||||
[6a] DRY preserved: report() returns (d, mv) -- ok
|
||||
[6b] DRY preserved: main() reuses (no 2nd moves() call) -- ok
|
||||
[7a] equivalence: rc identical on real census pair -- ok
|
||||
[7b] equivalence: stdout BYTE-IDENTICAL on real census pair -- ok
|
||||
[8] non-vacuity: real 37-cell census produced -- ok
|
||||
[9] verifier committed to HEAD -- ok
|
||||
|
||||
## Exit contract
|
||||
|
||||
Behavior change on the refusal paths (rc 0 → rc 2) is intentional and
|
||||
documented in the module docstring: the house contract reserves rc 0 for "graded
|
||||
and clean". On the real census-pair path, output is BYTE-IDENTICAL (check [7b]).
|
||||
No public interface, argument, or output format changed beyond the exit code.
|
||||
@@ -0,0 +1,235 @@
|
||||
#!/usr/bin/env python3
|
||||
"""verify_census_row_delta_exit_contract.py
|
||||
|
||||
Verifier for the forge refactor of tools/census_row_delta.py
|
||||
(A) Testability refactor: main() accepts argv so the selftest can drive it
|
||||
(B) Exit-contract alignment: no-pair / half-pair / --repo/--rev-only now
|
||||
return rc 2 (could not grade) per tools/verify.py, not rc 0
|
||||
|
||||
The DRY collapse of the redundant moves() call is already in HEAD (commit
|
||||
04a418a, verified by 723a852); this verifier proves the new working-tree
|
||||
changes are correct AND behavior-preserving on the real census-pair path.
|
||||
|
||||
Equivalence methodology (post-commit: HEAD = refactored, git HEAD~? ... see note):
|
||||
The original is materialized from `git show HEAD:tools/census_row_delta.py`
|
||||
and run as an isolated subprocess on a synthetic census pair. The refactored
|
||||
(working-tree) version is run identically. The census-pair computation path
|
||||
(load -> diff -> report -> print) is untouched by the exit-contract change, so
|
||||
stdout must be BYTE-IDENTICAL. The no-pair / half-pair / pass-through paths
|
||||
are the only behavioral differences and are each measured directly.
|
||||
|
||||
Exit 0 iff all checks pass, else 1.
|
||||
"""
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
REPO = "/root/troupe-standup"
|
||||
TOOL = "tools/census_row_delta.py"
|
||||
VERIFIER_NAME = "verify_census_row_delta_exit_contract.py"
|
||||
VERIFIER_PATH = "wizard-work/forge/" + VERIFIER_NAME
|
||||
SUMMARY = "selftest: 37 cells, 37 passed, 0 failed"
|
||||
|
||||
|
||||
class Mismatch(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def git_show(rev, path):
|
||||
"""Return the text of `path` at git revision `rev` in REPO."""
|
||||
proc = subprocess.run(["git", "-C", REPO, "show", f"{rev}:{path}"],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
if proc.returncode != 0:
|
||||
raise Mismatch(f"cannot read {rev}:{path}: {proc.stderr.strip()}")
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def run_script(source_text, args):
|
||||
"""Write source_text to an isolated temp file and run with args."""
|
||||
fd, path = tempfile.mkstemp(suffix=".py", dir="/tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w") as fh:
|
||||
fh.write(source_text)
|
||||
os.chmod(path, 0o755)
|
||||
proc = subprocess.run([sys.executable, path] + list(args),
|
||||
capture_output=True, text=True, timeout=120)
|
||||
return proc.returncode, proc.stdout, proc.stderr
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
def sha(text):
|
||||
return hashlib.sha256(text.encode()).hexdigest()
|
||||
|
||||
|
||||
def load_module(path, name):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Synthetic census pair with real row data (mirrors _fixture()'s shape but as
|
||||
# the JSON-on-disk format that load() expects: reports / verdicts / failures).
|
||||
# ---------------------------------------------------------------------------
|
||||
def _row(tool, block, line, rev, unit, claim, verdict, token=None):
|
||||
return {"tool": tool, "block": block, "line": line, "rev": rev,
|
||||
"unit": unit, "claim": claim, "token": token or claim,
|
||||
"verdict": verdict, "detail": "", "file": tool}
|
||||
|
||||
|
||||
BEFORE_ROWS = [
|
||||
_row("t/a.py", 4, 10, "HEAD", "cells", 3, "MATCHES"),
|
||||
_row("t/a.py", 4, 11, "HEAD", "rows", 2, "NOT_PRINTED"),
|
||||
_row("t/b.py", 20, 40, "abc1234", "cells", 7, "STALE"),
|
||||
]
|
||||
AFTER_ROWS = [
|
||||
_row("t/a.py", 4, 10, "HEAD", "cells", 3, "MATCHES"),
|
||||
_row("t/a.py", 4, 11, "HEAD", "rows", 2, "STALE"),
|
||||
_row("t/b.py", 21, 41, "abc1234", "cells", 7, "STALE"),
|
||||
_row("t/b.py", 21, 42, "HEAD", "splits", 1, "MATCHES"),
|
||||
]
|
||||
|
||||
CENSUS_JSON = {
|
||||
"before": {"rev": "abc1234", "repo": "/x", "reports": [
|
||||
{"tool": "t/a.py", "verdicts": BEFORE_ROWS[:2], "failures": []},
|
||||
{"tool": "t/b.py", "verdicts": BEFORE_ROWS[2:],
|
||||
"failures": [{"block": 20, "rev": "abc1234"}]},
|
||||
]},
|
||||
"after": {"rev": "HEAD", "repo": "/x", "reports": [
|
||||
{"tool": "t/a.py", "verdicts": AFTER_ROWS[:2], "failures": []},
|
||||
{"tool": "t/b.py", "verdicts": AFTER_ROWS[2:], "failures": []},
|
||||
]},
|
||||
}
|
||||
|
||||
|
||||
def make_census_files(tmpdir):
|
||||
before_path = os.path.join(tmpdir, "before.json")
|
||||
after_path = os.path.join(tmpdir, "after.json")
|
||||
with open(before_path, "w") as fh:
|
||||
json.dump(CENSUS_JSON["before"], fh)
|
||||
with open(after_path, "w") as fh:
|
||||
json.dump(CENSUS_JSON["after"], fh)
|
||||
return before_path, after_path
|
||||
|
||||
|
||||
def main():
|
||||
ok = True
|
||||
cells, failed = [], []
|
||||
|
||||
def check(label, passed, detail=""):
|
||||
cells.append(label)
|
||||
status = "ok " if passed else "FAIL"
|
||||
print(" [%s] %s" % (status, label) +
|
||||
(" [%s]" % detail if detail and not passed else ""))
|
||||
if not passed:
|
||||
failed.append("%s: %s" % (label, detail) if detail else label)
|
||||
nonlocal ok
|
||||
ok = False
|
||||
|
||||
# Materialize original (HEAD) and read working-tree (refactored) source.
|
||||
try:
|
||||
orig_src = git_show("HEAD", TOOL)
|
||||
except Mismatch as e:
|
||||
check("[0] cannot read HEAD:" + TOOL, False, str(e))
|
||||
print("\nVERDICT: MISMATCH -- %s" % e)
|
||||
return 1
|
||||
|
||||
with open(os.path.join(REPO, TOOL), encoding="utf-8") as fh:
|
||||
ref_src = fh.read()
|
||||
|
||||
# ---- [1] selftest: 37/37 cells, rc 0 ---------------------------------
|
||||
rc_r, out_r, _ = run_script(ref_src, ["--selftest"])
|
||||
check("[1] selftest: 37 cells, 37 passed, rc 0",
|
||||
rc_r == 0 and SUMMARY in out_r,
|
||||
"rc=%s %s" % (rc_r, out_r[-120:]))
|
||||
|
||||
# ---- [2] testability refactor: main() accepts argv -------------------
|
||||
check("[2] code-level: main() accepts argv (testability refactor)",
|
||||
"def main(argv=None)" in ref_src,
|
||||
"def main(argv=None)=%s" % ("def main(argv=None)" in ref_src))
|
||||
check("[2b] code-level: selftest drives main(argv) via refusal() helper",
|
||||
"contextlib" in ref_src and "import io" in ref_src,
|
||||
"contextlib=%s io=%s" % ("contextlib" in ref_src, "import io" in ref_src))
|
||||
|
||||
# ---- [3] no-pair: rc 2 (exit-contract alignment, was rc 0) -----------
|
||||
rc, out, err = run_script(ref_src, [])
|
||||
check("[3] no-pair: rc 2 (was rc 0 on HEAD -- the defect this fixes)",
|
||||
rc == 2, "rc=%s" % rc)
|
||||
check("[3b] no-pair: key vocabulary still printed (not silenced)",
|
||||
"key shapes available" in out, out[-80:])
|
||||
check("[3c] no-pair: stderr names the refusal",
|
||||
"cannot grade" in err or "no census pair" in out,
|
||||
"err=%r" % err[-80:])
|
||||
|
||||
# ---- [4] half-pair: rc 2, the half-path named in stderr --------------
|
||||
rc, out, err = run_script(ref_src, ["only-before.json"])
|
||||
check("[4] half-pair: rc 2",
|
||||
rc == 2, "rc=%s" % rc)
|
||||
check("[4b] half-pair: the given half is named in stderr",
|
||||
"only-before.json" in err, "err=%r" % err[-80:])
|
||||
|
||||
# ---- [5] pass-through shape (--repo/--rev only): rc 2 ----------------
|
||||
rc, out, _ = run_script(ref_src, ["--repo", "/x", "--rev", "HEAD"])
|
||||
check("[5] pass-through shape (--repo/--rev only): rc 2",
|
||||
rc == 2, "rc=%s" % rc)
|
||||
check("[5b] pass-through: key vocabulary still printed",
|
||||
"key shapes available" in out, out[-80:])
|
||||
|
||||
# ---- [6] DRY collapse preserved (report returns (d, mv); main reuses) -
|
||||
check("[6a] DRY preserved: report() returns (d, mv)",
|
||||
"return d, mv" in ref_src, "")
|
||||
# main() must reuse report()'s return, NOT call moves() again on the same d
|
||||
check("[6b] DRY preserved: main() reuses report() return (no 2nd moves() call)",
|
||||
"d, mv = report(" in ref_src and
|
||||
"mv = moves(before, after, d, KEYS" not in ref_src.split("def main")[1],
|
||||
"2nd moves in main=%s" %
|
||||
("mv = moves(before, after, d, KEYS" in ref_src.split("def main")[1]))
|
||||
|
||||
# ---- [7] equivalence: real census pair, byte-identical stdout --------
|
||||
with tempfile.TemporaryDirectory(prefix="crd_equiv_") as td:
|
||||
before_path, after_path = make_census_files(td)
|
||||
args = [before_path, after_path, "--keys", "--verbose"]
|
||||
rc_o, out_o, _ = run_script(orig_src, args)
|
||||
rc_r, out_r2, _ = run_script(ref_src, args)
|
||||
check("[7a] equivalence: rc identical on real census pair",
|
||||
rc_o == rc_r, "rc_o=%s rc_r=%s" % (rc_o, rc_r))
|
||||
check("[7b] equivalence: stdout BYTE-IDENTICAL on real census pair",
|
||||
out_o == out_r2,
|
||||
"sha_o=%s sha_r=%s len_o=%d len_r=%d" %
|
||||
(sha(out_o)[:12], sha(out_r2)[:12], len(out_o), len(out_r2)))
|
||||
|
||||
# ---- [8] non-vacuity: real 37-cell census produced -------------------
|
||||
check("[8] non-vacuity: selftest produced a real 37-cell census",
|
||||
out_r.count("PASS") >= 36 and "37 cells" in out_r,
|
||||
"passes=%d cells=%s" % (out_r.count("PASS"),
|
||||
"37 cells" in out_r))
|
||||
|
||||
# ---- [9] verifier committed to HEAD ----------------------------------
|
||||
rc_gc = subprocess.run(
|
||||
["git", "-C", REPO, "cat-file", "-e", "HEAD:" + VERIFIER_PATH],
|
||||
capture_output=True).returncode
|
||||
check("[9] verifier committed to HEAD", rc_gc == 0, "cat-file rc=%s" % rc_gc)
|
||||
|
||||
print("\nverify: %d checks, %d passed, %d failed" %
|
||||
(len(cells), len(cells) - len(failed), len(failed)))
|
||||
for f in failed:
|
||||
print(" FAIL %s" % f)
|
||||
verdict = ("ALL GREEN -- census_row_delta exit-contract + testability refactor"
|
||||
" verified (selftest 37/37, byte-identical on real census pair, DRY preserved)"
|
||||
if ok else "MISMATCH -- verification failed")
|
||||
print("\nVERDICT: %s" % verdict)
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except Mismatch as e:
|
||||
print("\nVERDICT: MISMATCH -- %s" % e)
|
||||
sys.exit(1)
|
||||
Reference in new issue
Block a user