sweep: ECHOES_USAGE -- a refusal that echoes the invocation is not an input refusal (Q97)

This commit is contained in:
volt committed 2026-10-29 21:05:52 +00:00
1 parent 9a5367be0b
commit 58895d9513
6 files changed
+380 -16

No files matched your search

+1 -1
View File
@@ -27,7 +27,7 @@ queue_append_audit.py 25 cells
queue_claim_check.py 24 fixtures
queue_index.py 80 cells
reply_check.py 49 cells
selftest_sweep.py 44 cells
selftest_sweep.py 49 cells
served_drift_check.py 25 cells
tool_map.py 20 cells
verify.py 59 cells
1 # selftest_baseline.tsv -- the cell count each tool's selftest prints, pinned.
27 queue_claim_check.py
28 queue_index.py
29 reply_check.py
30 selftest_sweep.py
31 served_drift_check.py
32 tool_map.py
33 verify.py
+135 -4
View File
@@ -105,6 +105,33 @@ WHAT A REFUSAL IS GRADED ON, FIXED 2026-10-27 (Q93)
table's own comment: the contract keeps "refused, legibly, at the documented
code" and stops keeping "refused for the reason the table remembers".
WHAT A REFUSAL MUST NOT BE, ADDED 2026-10-29 (Q97)
The contract above has one blind spot and it is not theoretical: Python's
`argparse` writes `usage: ...` and `prog: error: unrecognized arguments: ...`
to stderr and exits 2, which satisfies "rc 2, no traceback, a message"
exactly. Every probe invokes its tool with flags, so a tool whose flag is
renamed -- or removed -- never reads the input at all, and an axis that
exists to grade malformed-input handling reads clean. Measured at 9878f12:
renaming `--repo` to `--repo-dir` in `citation_hash_class.py` alone left
`malformed-input: 26 probes, 26 ok` and `verdict: rc 0 -- clean`.
A refusal is now ALSO required to be about the input: a message that leads
with `usage:` or carries an argparse failure shape (`unrecognized
arguments`, `error: argument`, `the following arguments are required`,
`invalid choice:`, `ambiguous option:`, `no such option`) is ECHOES_USAGE --
fatal on both input axes, the read axis included, because that axis has had
the same hole since it was written (it pinned no prose at all). The check is
negative on purpose: requiring the message to name the probe's path would
redden tools that refuse correctly with a boundary line, which is the Q93
defect coming back the other way.
One measurement the filed record does not carry: its mutant renames `--repo`
to `--repo-dir` with the same dest, and that rename is INVISIBLE to
argparse -- a long option may be abbreviated to any unambiguous prefix, so
`--repo` still parses as `--repo-dir` and the tool reads its input
normally (measured: same refusal line, rc 2, flag and renamed flag alike).
The filed mutant therefore stays green for a reason other than the one the
item names. Cell c48 and the verification below use a rename that is not a
prefix (`--tree`), which is the shape that actually reaches the parser.
ONE VERDICT LINE, ADDED 2026-10-27 (Q93)
The leg printed `sweep: 22 tools, 22 OK, 0 fatal` and returned **rc 1**: the
sweep line is about the TOOLS, the exit code is about the RUN, and nothing
@@ -163,7 +190,7 @@ USAGE:
python3 selftest_sweep.py --repo /root/troupe-standup
python3 selftest_sweep.py --repo /root/troupe-standup --write-baseline tools/selftest_baseline.tsv
python3 selftest_sweep.py --tools-dir /tmp/fake --json /tmp/sweep.json
python3 selftest_sweep.py --selftest # 44 cells, parser, sweep, pin and both input axes
python3 selftest_sweep.py --selftest # 49 cells, parser, sweep, pin and both input axes
"""
from __future__ import annotations
@@ -238,9 +265,23 @@ READ_AXIS_UNIT = REFUSAL_RC
# green gate. An ungraded tool may not be reported clean; the tables are the
# gate's only cover, so a gap in them has to fail like a fault in a tool.
FATAL_READ = ("TRACEBACK", "RC_NOT_2", "SILENT", "NO_TOOL", "RC_WRONG",
"UNEXPLAINED", "NO_PROBE")
"UNEXPLAINED", "NO_PROBE", "ECHOES_USAGE")
NO_PROBE = "NO_PROBE" # fatal: a tool with no probe defined
# Q97 (2026-10-29): the shapes argparse prints when it cannot parse the
# INVOCATION. A refusal carrying one of these is the tool telling the operator
# the probe used a flag it does not have -- the input was never read, so the
# probe graded nothing. Checked on both input axes; the usage-line lead is
# checked separately by `echoes_invocation` because argparse always prints one.
ARGV_ECHO_MARKERS = (
"unrecognized arguments",
"error: argument",
"the following arguments are required",
"invalid choice:",
"ambiguous option:",
"no such option",
)
# {nope} = a path that does not exist; {bin} = a file that is not UTF-8;
# {bindir} = a directory holding one non-UTF-8 *.md (added 2026-10-18 with
# checkin_check.py, whose input is a directory of UTF-8 files -- a {bin} file
@@ -737,6 +778,26 @@ def _first_line(text: str) -> str:
return ""
def echoes_invocation(text):
"""True when a rc-2 message is argparse failing to PARSE THE INVOCATION
rather than the tool refusing the input (Q97, 2026-10-29).
Two shapes, both measured from real argparse output rather than invented:
the message's first non-empty line leads with `usage:`, or it carries one
of ARGV_ECHO_MARKERS. The test is negative -- a refusal is red for what it
IS, not for what a table remembers it saying (that was Q93's defect) --
and it is deliberately not "must name the probe's path", because two tools
in this directory refuse correctly with a boundary line that names no
path."""
low = (text or "").lower()
if any(m in low for m in ARGV_ECHO_MARKERS):
return True
for line in (text or "").splitlines():
if line.strip():
return line.strip().lower().startswith("usage:")
return False
def judge_read(rc, text):
"""One probe's verdict. Three classes, in this order on purpose: a
traceback is a defect whatever its exit code, and a silent rc 2 is a
@@ -748,6 +809,10 @@ def judge_read(rc, text):
"detail": _first_line(text) or f"rc {rc}, no output"}
if not (text or "").strip():
return {"verdict": "SILENT", "rc": rc, "detail": "rc 2 with no message"}
if echoes_invocation(text):
return {"verdict": "ECHOES_USAGE", "rc": rc,
"detail": "argparse failure, not an input refusal: "
+ _first_line(text)}
return {"verdict": "OK", "rc": rc, "detail": _first_line(text)}
@@ -826,7 +891,9 @@ def judge_malformed(rc, out, err, expect_rc, need):
A REFUSAL (expect_rc == 2) is graded on the contract, not on prose (Q93):
the documented code, no traceback, one non-empty message on either stream.
Which stream is not part of the contract -- measured 2026-10-27,
Since Q97 (2026-10-29) the message must also NOT be argparse failing to
parse the invocation -- that is ECHOES_USAGE, fatal, because the input was
never read. Which stream is not part of the contract -- measured 2026-10-27,
`tool_map.py` writes its refusal to stdout and `citation_hash_class.py` to
stderr, both at rc 2, both correct. `need` is only consulted where the tool
GRADED the malformed input (rc 0/1): there the word is its verdict."""
@@ -840,6 +907,11 @@ def judge_malformed(rc, out, err, expect_rc, need):
if not text.strip():
return {"verdict": "SILENT", "rc": rc, "detail": "rc %s with no message" % rc}
if expect_rc == REFUSAL_RC:
if echoes_invocation(text):
# Q97: rc 2 from the argument parser is not a refusal of the input.
return {"verdict": "ECHOES_USAGE", "rc": rc, "contract": "refusal",
"detail": "argparse failure, not an input refusal: "
+ _first_line(text)}
# a refusal: the code and a readable line are the whole contract
return {"verdict": "OK", "rc": rc, "contract": "refusal",
"detail": "refusal rc 2: " + _first_line(text)}
@@ -953,8 +1025,10 @@ def run_verdict(tool_fatal, read_fatal, malformed_fatal, pin_rc, pin_fatal):
def selftest():
bad = []
ran = []
def cell(name, ok, detail=""):
ran.append(name)
print(f"{'PASS' if ok else 'FAIL'} {name}"
+ (f" [{detail}]" if (detail and not ok) else ""))
if not ok:
@@ -1228,7 +1302,64 @@ def selftest():
and "pin ungradable" in bad_line,
f"{ok_line} | {red_line} | {bad_line}")
print(f"selftest: 44 cells, {44 - len(bad)} passed, {len(bad)} failed")
# c45-c49: Q97 -- a refusal that is argparse failing to parse the
# INVOCATION is not a refusal of the input. c45/c46 are the judge on both
# axes; c47 is the control (a refusal that names its input stays OK); c48
# is end to end through run_read_probe against a stub whose REAL argparse
# renames the flag the probe passes -- the filed mutant, shrunk to one
# cell; c49 is the summary line naming the fault.
jm = judge_malformed(
2, "", "usage: t.py [-h] --repo-dir REPO_DIT\nt.py: error: "
"unrecognized arguments: --repo /tmp/x\n", 2, None)
cell("c45 argparse usage at rc 2 on the malformed axis -> ECHOES_USAGE, fatal",
jm["verdict"] == "ECHOES_USAGE" and axis_faults([jm]) == [jm],
jm["verdict"])
jr = judge_read(2, "usage: t.py [-h] --queue QUEUE\nt.py: error: the "
"following arguments are required: --queue\n")
cell("c46 argparse usage at rc 2 on the read axis -> ECHOES_USAGE, fatal",
jr["verdict"] == "ECHOES_USAGE" and axis_faults([jr]) == [jr],
jr["verdict"])
jm = judge_malformed(2, "", "cannot read /tmp/nope: [Errno 2] No such file "
"or directory\n", 2, None)
jr = judge_read(2, "cannot read /tmp/nope: [Errno 2] No such file or directory")
cell("c47 a refusal that names its input stays OK on both axes",
jm["verdict"] == "OK" and jr["verdict"] == "OK",
f"{jm['verdict']}/{jr['verdict']}")
with tempfile.TemporaryDirectory() as td:
stub = os.path.join(td, "renamed_flag.py")
with open(stub, "w", encoding="utf-8") as fh:
fh.write("import argparse\n"
"ap = argparse.ArgumentParser()\n"
# deliberately NOT a prefix of --repo: argparse accepts an
# unambiguous abbreviation, so --repo -> --repo-dir still
# parses and reads the input (measured, Q97).
"ap.add_argument('--tree', dest='repo')\n"
"a = ap.parse_args()\n"
"print('cannot read ' + a.repo)\n"
"raise SystemExit(2)\n")
rc_m, out_m, err_m = run_read_probe(stub, ["--repo", "{nope}"], 30)
jm_bad = judge_malformed(rc_m, out_m, err_m, 2, None)
rc_c, out_c, err_c = run_read_probe(stub, ["--tree", "{nope}"], 30)
jm_ok = judge_malformed(rc_c, out_c, err_c, 2, None)
cell("c48 end to end: a renamed flag reads ECHOES_USAGE, its real flag OK",
rc_m == 2 and jm_bad["verdict"] == "ECHOES_USAGE"
and rc_c == 2 and jm_ok["verdict"] == "OK",
f"{rc_m}:{jm_bad['verdict']} / {rc_c}:{jm_ok['verdict']}")
axis_echo = [{"tool": "t.py", "probe": "not-a-repo", "verdict": "ECHOES_USAGE",
"rc": 2, "expect_rc": 2, "detail": "argparse failure"}]
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
erc, ehard, esoft = report_read(axis_echo, quiet=True)
line_e = buf.getvalue()
cell("c49 the summary names ECHOES_USAGE with its probe, and the run is red",
erc == 1 and (ehard, esoft) == (0, 1)
and "malformed-input: 1 probes, 0 ok, 1 fault "
"(ECHOES_USAGE t.py/not-a-repo)" in line_e,
line_e.strip().splitlines()[-1] if line_e.strip() else "no output")
print(f"selftest: {len(ran)} cells, {len(ran) - len(bad)} passed, {len(bad)} failed")
return 1 if bad else 0