CB-WP-0030: a control for the wrong-subject family
Some checks failed
ci / check (push) Failing after 4s

Seven claims in this project have been wrong the same way — the arithmetic
right, the subject wrong. Four reached a document or another repo.

ADR-0018 states the rule: a measured claim names the variable it depends
on and shows the measurement moving with it. GameDesign §1.4 adds it as a
fourth admissibility clause; the existing three are untouched.

Honest yield is 3 clear + 1 partial of 7. It misses a count never made and
a claim that is not a number, and D3 names those so nobody stops looking.
The tempting fix — closing facts-check's 62 untagged literals — would have
caught 1 of 7 and is deferred as separate work rather than bundled.

loop-lint gains check_sensitivity_stated over arithmetic register rows,
mutation-proven red on F17 and green when restored, with four self-test
controls. It checks presence, never adequacy, and says so in its output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-07 11:35:41 +02:00
parent ffd2279e16
commit d7279483f6
6 changed files with 503 additions and 6 deletions

View file

@ -306,6 +306,74 @@ def check_gate_registry(root=REPO):
return out
def check_sensitivity_stated(root=REPO):
"""GameDesign §1.4 / ADR-0018 — a finding whose claim is arithmetic
must name the variable it depends on.
**This checks PRESENCE, NEVER ADEQUACY.** It cannot tell whether the
variable named is the right one; that is the judgement the rule exists
to force, and it stays with the author and the reviewer. A green run
here means "somebody wrote a sensitivity line", not "the claim was
verified" -- and if it is ever read as the second, the control has
become a way of not looking.
Seven claims in this project were arithmetically correct about the
wrong subject. Three of them would have been caught by varying
something; this is the half of that a machine can see.
"""
out = []
reg = os.path.join(root, "specs", "FindingRegister.md")
if not os.path.exists(reg):
return out
text = open(reg).read()
try:
block = text.split("<!-- design-register:begin -->")[1] \
.split("<!-- design-register:end -->")[0]
except IndexError:
return out
# Kinds whose claim is a quantity. `inert` and `unplayed` are about
# whether a thing happens at all, which has no denominator to get
# wrong.
ARITHMETIC = {"inconsistent", "degenerate", "underdetermined"}
exempt = set(re.findall(r"^<!-- sensitivity-exempt:\s*(\S+)\s+(.+?)\s*-->$",
text, re.M))
exempt_ids = {e[0] for e in exempt}
for line in block.splitlines():
line = line.strip()
if not line.startswith("|") or line.startswith("|---"):
continue
cells = [c.strip() for c in line.strip("|").split("|")]
if len(cells) != 7 or cells[0] == "id":
continue
fid, kind, state = cells[0], cells[1], cells[2]
# Closed rows are history; the rule binds what is still claimed.
# And a `note` is by definition a finding WITHOUT a reproduction
# (GameDesign §3.1) -- there is no measurement to be sensitive
# about, so requiring one would be asking for a sensitivity
# statement about nothing.
if state in ("withdrawn", "applied", "note") or kind not in ARITHMETIC:
continue
if fid in exempt_ids:
continue
# The prose block for this finding must say what moves it.
body = ""
m = re.search(rf"^- \*\*{re.escape(fid)} [^\n]*(?:\n(?!- \*\*F?U?\d).*)*",
text, re.M)
if m:
body = m.group(0)
if not re.search(r"varie[sd]|varying|sensitivit|moves with|held fixed|"
r"one number|second policy", body, re.I):
out.append(Finding(
"sensitivity", "specs/FindingRegister.md",
f"{fid} ({kind}) states a quantity and names no variable it "
f"depends on (GameDesign §1.4). Say what would move it, or "
f"add `<!-- sensitivity-exempt: {fid} <reason> -->`. "
f"NOTE: this checks presence, not adequacy."))
return out
CHECKS = (
check_loadability,
check_evidence_no_unmeasured,
@ -315,6 +383,7 @@ CHECKS = (
check_review_trail,
check_reporting_tools_self_test,
check_gate_registry,
check_sensitivity_stated,
)
@ -374,6 +443,36 @@ def self_test():
body += f"\n```task\nid: CB-WP-0100-T\nstatus: {t}\npriority: high\n```\n"
with open(os.path.join(tmp, "workplans", "CB-WP-0100-x.md"), "w") as fh:
fh.write(body)
# GameDesign §1.4 / ADR-0018. Four controls, because a check that
# cannot say NO is decoration and one that cannot say YES fires on
# everything.
os.makedirs(os.path.join(tmp, "specs"), exist_ok=True)
def reg(kind, state, prose):
body = ("<!-- design-register:begin -->\n\n"
"| id | kind | state | reproduction | role | raised | owner |\n"
"|---|---|---|---|---|---|---|\n"
f"| F99 | {kind} | {state} | x.rs | counterexample | 2026-01-01 | us |\n"
"\n<!-- design-register:end -->\n\n"
f"- **F99 — a claim.** {prose}\n")
with open(os.path.join(tmp, "specs", "FindingRegister.md"), "w") as fh:
fh.write(body)
reg("degenerate", "raised", "It is 42.")
check("sensitivity: an arithmetic claim with no variable is caught",
len(check_sensitivity_stated(tmp)) == 1)
reg("degenerate", "raised", "It is 42, and it varies with seat count.")
check("sensitivity: naming the variable clears it",
not check_sensitivity_stated(tmp),
"without this it would fire on everything")
reg("inert", "raised", "It is 42.")
check("sensitivity: a non-arithmetic kind is not asked",
not check_sensitivity_stated(tmp),
"`inert` is about whether a thing happens, not how much")
reg("degenerate", "note", "It is 42.")
check("sensitivity: a note has no measurement to be sensitive about",
not check_sensitivity_stated(tmp), "GameDesign §3.1")
wp("ready", ["done", "todo"])
f = check_workplan_lifecycle(tmp)
check("lifecycle detects `ready` after work has started",