2026-07-31 10:24:39 +02:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""The fact registry: a number lives in one place, and copies are checked.
|
|
|
|
|
|
|
|
|
|
CB-WP-0004 T04. Duplicated-fact drift (DFD) is the fourth error class on
|
|
|
|
|
record and the only one with no executable gate. Two instances:
|
|
|
|
|
|
|
|
|
|
* `specs/MetricsAndScenarios.md` §1a inlined a copy of the price sheet;
|
|
|
|
|
the real sheet changed and the copy went stale within the hour.
|
|
|
|
|
* the acceptance figure moved $92.21 -> $92.87 -> $93.32 -> $93.15 and
|
|
|
|
|
each move had to be chased by hand across a survey, a workplan and an
|
|
|
|
|
evidence file.
|
|
|
|
|
|
|
|
|
|
No positive control catches DFD — both copies are internally consistent —
|
|
|
|
|
and re-derivation does not either, because the copy reproduces whatever it
|
|
|
|
|
was copied from. It is caught only by reading a copy against its source.
|
|
|
|
|
That is what this does.
|
|
|
|
|
|
|
|
|
|
## The trap, and how it is avoided
|
|
|
|
|
|
|
|
|
|
A hand-maintained registry is just another copy that drifts. So the
|
|
|
|
|
registry is **generated from the instruments** (`make facts-gen`), never
|
|
|
|
|
edited: every value here is produced by cb-cost, dep-weight or
|
|
|
|
|
rule-coverage on the current tree. `--check` re-runs the instruments and
|
|
|
|
|
fails if the committed registry disagrees with them, so a stale registry
|
|
|
|
|
cannot silently certify stale artifacts.
|
|
|
|
|
|
|
|
|
|
## How an artifact quotes a fact
|
|
|
|
|
|
|
|
|
|
Tag the occurrence with an HTML comment naming the registry key:
|
|
|
|
|
|
|
|
|
|
The benchmark to beat is **$93.15**. <!-- fact:pinned_total -->
|
|
|
|
|
|
|
|
|
|
`make facts-check` re-reads every tagged occurrence and fails when the
|
|
|
|
|
text disagrees with the registry. Tagging is opt-in, so the gate also
|
|
|
|
|
reports **untagged copies** — literal occurrences of a registry value in
|
|
|
|
|
artifacts that did not declare them — which is the drift surface that is
|
|
|
|
|
not yet covered.
|
|
|
|
|
|
|
|
|
|
Usage:
|
|
|
|
|
python3 tools/facts.py --gen # regenerate facts.toml from instruments
|
|
|
|
|
python3 tools/facts.py --check # gate: registry vs instruments vs artifacts
|
|
|
|
|
python3 tools/facts.py --self-test
|
|
|
|
|
"""
|
|
|
|
|
import datetime
|
|
|
|
|
import glob
|
|
|
|
|
import importlib.util
|
|
|
|
|
import os
|
|
|
|
|
import re
|
|
|
|
|
import subprocess
|
|
|
|
|
import sys
|
|
|
|
|
|
|
|
|
|
from repo import ROOT, enter_root
|
|
|
|
|
|
|
|
|
|
REGISTRY = os.path.join(ROOT, "facts.toml")
|
|
|
|
|
PIN = "fc76445" # CB-WP-0001 acceptance pin (specs/CostAccounting.md §7)
|
|
|
|
|
TAG_RE = re.compile(r"<!--\s*fact:([a-z0-9_]+)\s*-->")
|
|
|
|
|
# Artifact kinds that quote measured numbers. `history/` is excluded from
|
|
|
|
|
# the untagged sweep: it narrates corrections and legitimately states
|
|
|
|
|
# superseded values.
|
|
|
|
|
ARTIFACT_DIRS = ("specs", "evidence", "research", "decisions", "workplans")
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
import tomllib
|
|
|
|
|
except ModuleNotFoundError: # pragma: no cover
|
|
|
|
|
print("ERROR: needs Python 3.11+ for tomllib", file=sys.stderr)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _load(script):
|
|
|
|
|
spec = importlib.util.spec_from_file_location(
|
|
|
|
|
script.replace("-", "_").replace(".py", ""),
|
|
|
|
|
os.path.join(ROOT, "tools", script))
|
|
|
|
|
mod = importlib.util.module_from_spec(spec)
|
|
|
|
|
spec.loader.exec_module(mod)
|
|
|
|
|
return mod
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------- generate
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def measure():
|
|
|
|
|
"""Every registry value, produced by the instrument that owns it.
|
|
|
|
|
|
|
|
|
|
Each entry is (value, format, instrument). The format string is how the
|
|
|
|
|
value appears in prose, so the check compares like with like rather
|
|
|
|
|
than re-deriving a rendering in two places.
|
|
|
|
|
"""
|
|
|
|
|
facts = {}
|
|
|
|
|
|
|
|
|
|
cb = _load("cb-cost.py")
|
|
|
|
|
rep = cb.collect("-home-worsch-clay-borg", PIN)
|
|
|
|
|
facts["pinned_total"] = (rep["total"], "${:,.2f}",
|
|
|
|
|
f"tools/cb-cost.py --pin {PIN}")
|
|
|
|
|
facts["pinned_main"] = (rep["main_total"], "${:,.2f}",
|
|
|
|
|
f"tools/cb-cost.py --pin {PIN}")
|
|
|
|
|
facts["pinned_subagent"] = (rep["subagent_total"], "${:,.2f}",
|
|
|
|
|
f"tools/cb-cost.py --pin {PIN}")
|
|
|
|
|
facts["pinned_responses"] = (rep["responses"], "{:,}",
|
|
|
|
|
f"tools/cb-cost.py --pin {PIN}")
|
|
|
|
|
mix = rep["tool_mix"]
|
|
|
|
|
facts["pinned_mechanical_cost"] = (mix["mechanical_cost"], "${:,.2f}",
|
|
|
|
|
f"tools/cb-cost.py --pin {PIN}")
|
|
|
|
|
facts["pinned_mechanical_turns"] = (mix["mechanical_turns"], "{:,}",
|
|
|
|
|
f"tools/cb-cost.py --pin {PIN}")
|
|
|
|
|
facts["pinned_mechanical_share"] = (
|
|
|
|
|
round(100 * mix["mechanical_cost"] / rep["total"]), "{:d}%",
|
|
|
|
|
f"tools/cb-cost.py --pin {PIN}")
|
|
|
|
|
|
|
|
|
|
dw = _load("dep-weight.py")
|
|
|
|
|
for label, key in (("shipped-runtime", "am4a_loc"), ("dev-toolchain", "am4b_loc")):
|
|
|
|
|
found = dw.crates(dw.CONFIGS[label])
|
|
|
|
|
total = sum(dw.source_lines(n, v) for n, v in sorted(found.items()))
|
|
|
|
|
facts[key] = (total, "{:,}", "tools/dep-weight.py")
|
|
|
|
|
facts["am4a_target"] = (dw.TARGETS["shipped-runtime"], "{:,}",
|
|
|
|
|
"tools/dep-weight.py TARGETS")
|
|
|
|
|
facts["am4b_target"] = (dw.TARGETS["dev-toolchain"], "{:,}",
|
|
|
|
|
"tools/dep-weight.py TARGETS")
|
|
|
|
|
|
|
|
|
|
rc = _load("rule-coverage.py")
|
|
|
|
|
rules = rc.parse_rules(open(os.path.join(ROOT, "specs/GroundRules.md")).read())
|
|
|
|
|
paths = sorted(glob.glob(os.path.join(ROOT, "scenarios/ground/*.yaml")))
|
|
|
|
|
covered = set()
|
|
|
|
|
for p in paths:
|
|
|
|
|
covered |= rc.parse_covers(open(p).read())
|
|
|
|
|
code_ids = rc.parse_code_ids(open(os.path.join(ROOT, rc.AGGREGATE)).read())
|
|
|
|
|
hit = set(rules) & covered
|
|
|
|
|
facts["gr_rules"] = (len(rules), "{:,}", "tools/rule-coverage.py")
|
|
|
|
|
facts["gr_covered"] = (len(hit), "{:,}", "tools/rule-coverage.py")
|
|
|
|
|
facts["gr_linked"] = (len(hit & code_ids), "{:,}", "tools/rule-coverage.py")
|
|
|
|
|
facts["gr_scenarios"] = (len(paths), "{:,}", "tools/rule-coverage.py")
|
CB-WP-0005 T01: spec->code link over every numbered spec and every crate
AGGREGATE becomes a list of source roots and rule patterns become
per-spec, so the link runs over every numbered spec x every crate rather
than GroundRules.md x games/ground/src/lib.rs.
The prediction held on the first run:
AM-1b kernel spec->code link: 15/18 (83%) across 10 source files
unlinked: K10 K14 K18
Kernel rules are link-only by design, and the output says so: they are
kernel invariants with no aggregate, setup preset or command vocabulary,
so scenarios/kernel/*.yaml with covers: [K11] would be a tag in a
directory the runner cannot dispatch. Claiming scenario coverage for them
is the inflation this gate exists to prevent.
Per ADR-0005 §5 the kernel arm reports without feeding the exit code
until 2026-08-31, then binds — the date in the tool, not in prose, with
days remaining printed every run, because open-ended "gate it later" is
how AM-4's targets went unratified for four workplans. The self-test
asserts the gate returns 0 before that date and 2 after.
The zero-rules positive control is replicated on the new denominator: a
kernel regex that stops matching aborts rather than printing 0/0 as
though it were 100%.
The self-test passed while the tool was completely broken. A print(
inside say() became say(), so every real `make coverage` died with
RecursionError while --self-test reported all-ok — it only ever called
kernel_arm(quiet=True) and never executed the reporting path. The control
named the behaviour and did not assert it, which is precisely what this
workplan is about. Fixed by exercising the loud path and asserting it
prints, then verified by re-breaking say() and confirming both new checks
go red. Seventh instance of the harness-does-nothing shape, in the tool
written to find that shape.
Also caught by its own gate: a self-test label that printed "0 K-ids"
beside a passing ">5" assertion, because the detail string rebuilt the
pattern with different escaping. A label that contradicts its own check
is worse than no label.
k_rules, k_linked and k_unlinked are registered facts under facts-check.
A limit of that checker is recorded rather than patched: it is
line-based, so a tagged value that prose-wraps fails.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 16:54:07 +02:00
|
|
|
|
|
|
|
|
# CB-WP-0005 T01: the kernel denominator, so its numbers are under the
|
|
|
|
|
# DFD gate from the day they first exist rather than after they drift.
|
|
|
|
|
k_rules = rc.parse_rules(
|
|
|
|
|
open(os.path.join(ROOT, "specs/GameKernel.md")).read(), r"\*\*(K\d+)\*\*")
|
|
|
|
|
k_named = rc.code_ids_over(rc.source_files(), r"\bK\d+\b")
|
|
|
|
|
facts["k_rules"] = (len(k_rules), "{:,}", "tools/rule-coverage.py")
|
|
|
|
|
facts["k_linked"] = (len(set(k_rules) & k_named), "{:,}",
|
|
|
|
|
"tools/rule-coverage.py")
|
CB-WP-0005 T02: M-D1-MUT — 4 of 14 acceptance rows are enforced
For each acceptance row in GameKernel §5, invert the property and require
the verifying command to go red. adapted:mutation-testing, with the
denominator changed from source lines to acceptance rows.
M-D1-MUT: 4/14 rows enforced
PARTIAL 2 (AM-7, AM-8 — some clauses live, some inert)
unmutatable 8 (no property to invert, reason stated per row)
SURVIVED 0
Two corrections to our own numbers. There are 14 rows, not the twelve
ADR-0005 and CB-WP-0005 both asserted — AM-4 splits into a/b/c. And the
prediction of 9-of-12 (75%) becomes >=10 of 14; measured 4 (29%), badly
unmet. No target moved in this commit.
The second correction matters more. My first run reported two SURVIVED
rows and both were my own no-op mutations: `pub struct NullRng;` ->
`pub struct NullRng {}` is semantically identical, and renaming
max_age_days does nothing because CA-17 reads it with a default of 90.
Both would have been published as "this row asserts nothing" — a false
accusation against code that is fine. Replaced with real inversions (a
per-construction counter in the ChaCha seed; reverting AC-9's output
resolution to the first-wins bug it was fixed for), after which both go
red. T08 asks whether writing a weak mutation is the new grep. It is,
demonstrably, on the first attempt.
The finding is larger than the workplan assumed. 8 of 14 rows are
unmutatable — AM-2, AM-3, AM-4c, AM-5, AM-6, AM-9, AM-10, AM-11 have no
instrument at all. AM-6 is the sharpest: nothing in the workspace
compares any number to 100,000 events/s, the headline throughput claim.
The problem is not three unimplemented rules, it is that more than half
the acceptance table has nothing behind it.
Harness controls: a stale find-string reports HARNESS-BROKEN rather than
scoring the baseline as the mutant; a red baseline reports inconclusive
rather than red; the tree is restored in a finally and the restoration is
verified. Not in `make all` — it rebuilds the workspace once per row.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 17:27:23 +02:00
|
|
|
mc = _load("mutation-check.py")
|
|
|
|
|
mrows = mc.rows()
|
|
|
|
|
facts["am_rows"] = (len(mrows), "{:,}", "tools/mutation-check.py")
|
|
|
|
|
facts["am_unmutatable"] = (sum(1 for r in mrows if r.unmutatable), "{:,}",
|
|
|
|
|
"tools/mutation-check.py")
|
CB-WP-0006 T07: implement K18, amend K14
Two rules, two different answers, which is the point of a task phrased
"implement, or amend and say why".
K18 is implemented. "Criterion benches driving the same scenario format at
scale" was false — the bench hardcoded its commands and never touched
ScenarioFile, while MetricsAndScenarios §3 pointed at a benchmarks/
directory containing only baselines/. benchmarks/synthetic-3p.yaml now
holds the workload and both the bench and bench_shape read it: the
workload is data, not code.
A second defect surfaced while fixing the first. After the bench switched
to the file, bench_shape still hardcoded the same sequence, so the
workload existed twice — deleting end_round from the YAML broke bench-test
while bench_shape kept passing. Duplicated-fact drift in executable form.
Both now read the same include_str! and deleting a command breaks both.
Explicitly not claimed: this does not unblock AM-3. AM-3's baseline is a
declarative game object — moves, turn order, rules. synthetic-3p.yaml is a
command list; the rules live in games/ground. Marking it as AM-3's subject
would compare a script to a game definition, which is the category error
AM-3 is blocked on. The file says so in its own header, where the next
person will be tempted.
K14 is amended. CommitWindow had zero non-test users and GROUND enforces
the same contract inline. Wiring GROUND through it was rejected: it would
change the serialized shape of `selections`, which four scenario files
assert by dot-path and every state hash depends on, for the sole benefit
of making a sentence literally true.
The deciding argument is INTENT's, not convenience: abstractions are
extracted from working games rather than invented in isolation, and no
concept becomes canonical until it survives a second concrete use.
CommitWindow was invented before any game needed it and has survived none.
Imposing it on GROUND would manufacture the first use rather than discover
it. So K14 states what is actually guaranteed, CommitWindow is marked
provisional in the source, and it carries a delete-by date of 2026-12-31.
Kernel spec->code link 16/18 -> 18/18, stated with the caveat the gate
prints every run: that is about names, not assertions.
Two self-tests broke and both broke correctly. rule-coverage's gate test
hardcoded "unlinked rules exist today" and failed when the last one was
linked; it now computes that and asserts the gate fails iff rules are
unlinked. facts' text check rejected k_unlinked once it became
legitimately empty; empty now renders as "(none)" and the check
distinguishes absent from empty.
M-D1-MUT: 8 of 14, unchanged — K14 and K18 are kernel rules, not
acceptance rows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 12:47:16 +02:00
|
|
|
# "(none)" rather than "" — an empty rendered value cannot be tagged in
|
|
|
|
|
# prose, and reads as a malformed fact rather than a true one.
|
CB-WP-0005 T01: spec->code link over every numbered spec and every crate
AGGREGATE becomes a list of source roots and rule patterns become
per-spec, so the link runs over every numbered spec x every crate rather
than GroundRules.md x games/ground/src/lib.rs.
The prediction held on the first run:
AM-1b kernel spec->code link: 15/18 (83%) across 10 source files
unlinked: K10 K14 K18
Kernel rules are link-only by design, and the output says so: they are
kernel invariants with no aggregate, setup preset or command vocabulary,
so scenarios/kernel/*.yaml with covers: [K11] would be a tag in a
directory the runner cannot dispatch. Claiming scenario coverage for them
is the inflation this gate exists to prevent.
Per ADR-0005 §5 the kernel arm reports without feeding the exit code
until 2026-08-31, then binds — the date in the tool, not in prose, with
days remaining printed every run, because open-ended "gate it later" is
how AM-4's targets went unratified for four workplans. The self-test
asserts the gate returns 0 before that date and 2 after.
The zero-rules positive control is replicated on the new denominator: a
kernel regex that stops matching aborts rather than printing 0/0 as
though it were 100%.
The self-test passed while the tool was completely broken. A print(
inside say() became say(), so every real `make coverage` died with
RecursionError while --self-test reported all-ok — it only ever called
kernel_arm(quiet=True) and never executed the reporting path. The control
named the behaviour and did not assert it, which is precisely what this
workplan is about. Fixed by exercising the loud path and asserting it
prints, then verified by re-breaking say() and confirming both new checks
go red. Seventh instance of the harness-does-nothing shape, in the tool
written to find that shape.
Also caught by its own gate: a self-test label that printed "0 K-ids"
beside a passing ">5" assertion, because the detail string rebuilt the
pattern with different escaping. A label that contradicts its own check
is worse than no label.
k_rules, k_linked and k_unlinked are registered facts under facts-check.
A limit of that checker is recorded rather than patched: it is
line-based, so a tagged value that prose-wraps fails.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 16:54:07 +02:00
|
|
|
facts["k_unlinked"] = (
|
CB-WP-0006 T07: implement K18, amend K14
Two rules, two different answers, which is the point of a task phrased
"implement, or amend and say why".
K18 is implemented. "Criterion benches driving the same scenario format at
scale" was false — the bench hardcoded its commands and never touched
ScenarioFile, while MetricsAndScenarios §3 pointed at a benchmarks/
directory containing only baselines/. benchmarks/synthetic-3p.yaml now
holds the workload and both the bench and bench_shape read it: the
workload is data, not code.
A second defect surfaced while fixing the first. After the bench switched
to the file, bench_shape still hardcoded the same sequence, so the
workload existed twice — deleting end_round from the YAML broke bench-test
while bench_shape kept passing. Duplicated-fact drift in executable form.
Both now read the same include_str! and deleting a command breaks both.
Explicitly not claimed: this does not unblock AM-3. AM-3's baseline is a
declarative game object — moves, turn order, rules. synthetic-3p.yaml is a
command list; the rules live in games/ground. Marking it as AM-3's subject
would compare a script to a game definition, which is the category error
AM-3 is blocked on. The file says so in its own header, where the next
person will be tempted.
K14 is amended. CommitWindow had zero non-test users and GROUND enforces
the same contract inline. Wiring GROUND through it was rejected: it would
change the serialized shape of `selections`, which four scenario files
assert by dot-path and every state hash depends on, for the sole benefit
of making a sentence literally true.
The deciding argument is INTENT's, not convenience: abstractions are
extracted from working games rather than invented in isolation, and no
concept becomes canonical until it survives a second concrete use.
CommitWindow was invented before any game needed it and has survived none.
Imposing it on GROUND would manufacture the first use rather than discover
it. So K14 states what is actually guaranteed, CommitWindow is marked
provisional in the source, and it carries a delete-by date of 2026-12-31.
Kernel spec->code link 16/18 -> 18/18, stated with the caveat the gate
prints every run: that is about names, not assertions.
Two self-tests broke and both broke correctly. rule-coverage's gate test
hardcoded "unlinked rules exist today" and failed when the last one was
linked; it now computes that and asserts the gate fails iff rules are
unlinked. facts' text check rejected k_unlinked once it became
legitimately empty; empty now renders as "(none)" and the check
distinguishes absent from empty.
M-D1-MUT: 8 of 14, unchanged — K14 and K18 are kernel rules, not
acceptance rows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 12:47:16 +02:00
|
|
|
" ".join(r for r in k_rules if r not in k_named) or "(none)", "{}",
|
CB-WP-0005 T01: spec->code link over every numbered spec and every crate
AGGREGATE becomes a list of source roots and rule patterns become
per-spec, so the link runs over every numbered spec x every crate rather
than GroundRules.md x games/ground/src/lib.rs.
The prediction held on the first run:
AM-1b kernel spec->code link: 15/18 (83%) across 10 source files
unlinked: K10 K14 K18
Kernel rules are link-only by design, and the output says so: they are
kernel invariants with no aggregate, setup preset or command vocabulary,
so scenarios/kernel/*.yaml with covers: [K11] would be a tag in a
directory the runner cannot dispatch. Claiming scenario coverage for them
is the inflation this gate exists to prevent.
Per ADR-0005 §5 the kernel arm reports without feeding the exit code
until 2026-08-31, then binds — the date in the tool, not in prose, with
days remaining printed every run, because open-ended "gate it later" is
how AM-4's targets went unratified for four workplans. The self-test
asserts the gate returns 0 before that date and 2 after.
The zero-rules positive control is replicated on the new denominator: a
kernel regex that stops matching aborts rather than printing 0/0 as
though it were 100%.
The self-test passed while the tool was completely broken. A print(
inside say() became say(), so every real `make coverage` died with
RecursionError while --self-test reported all-ok — it only ever called
kernel_arm(quiet=True) and never executed the reporting path. The control
named the behaviour and did not assert it, which is precisely what this
workplan is about. Fixed by exercising the loud path and asserting it
prints, then verified by re-breaking say() and confirming both new checks
go red. Seventh instance of the harness-does-nothing shape, in the tool
written to find that shape.
Also caught by its own gate: a self-test label that printed "0 K-ids"
beside a passing ">5" assertion, because the detail string rebuilt the
pattern with different escaping. A label that contradicts its own check
is worse than no label.
k_rules, k_linked and k_unlinked are registered facts under facts-check.
A limit of that checker is recorded rather than patched: it is
line-based, so a tagged value that prose-wraps fails.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 16:54:07 +02:00
|
|
|
"tools/rule-coverage.py")
|
2026-07-31 10:24:39 +02:00
|
|
|
return facts
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def render(value, fmt):
|
|
|
|
|
return fmt.format(value)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def generate():
|
|
|
|
|
facts = measure()
|
|
|
|
|
lines = [
|
|
|
|
|
"# GENERATED — do not edit. `make facts-gen` rewrites this file.",
|
|
|
|
|
"#",
|
|
|
|
|
"# The single source of fact for numbers that appear in more than",
|
|
|
|
|
"# one artifact (InnerLoop v1.2). Every value here is produced by",
|
|
|
|
|
"# the instrument named in its `by` field, on the current tree.",
|
|
|
|
|
"# `make facts-check` fails if this file disagrees with the",
|
|
|
|
|
"# instruments, or if a tagged artifact disagrees with this file.",
|
|
|
|
|
"",
|
|
|
|
|
f'generated = "{datetime.date.today().isoformat()}"',
|
|
|
|
|
f'pin = "{PIN}"',
|
|
|
|
|
"",
|
|
|
|
|
]
|
|
|
|
|
for key in sorted(facts):
|
|
|
|
|
value, fmt, by = facts[key]
|
|
|
|
|
lines += [
|
|
|
|
|
f"[{key}]",
|
|
|
|
|
f"value = {value!r}",
|
|
|
|
|
f'text = "{render(value, fmt)}"',
|
|
|
|
|
f'fmt = "{fmt}"',
|
|
|
|
|
f'by = "{by}"',
|
|
|
|
|
"",
|
|
|
|
|
]
|
|
|
|
|
with open(REGISTRY, "w") as fh:
|
|
|
|
|
fh.write("\n".join(lines))
|
|
|
|
|
print(f"wrote {os.path.relpath(REGISTRY, ROOT)} — {len(facts)} facts")
|
|
|
|
|
for key in sorted(facts):
|
|
|
|
|
print(f" {key:<26} {facts[key][1].format(facts[key][0]):>12}"
|
|
|
|
|
f" {facts[key][2]}")
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------ check
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_registry():
|
|
|
|
|
if not os.path.isfile(REGISTRY):
|
|
|
|
|
return None
|
|
|
|
|
with open(REGISTRY, "rb") as fh:
|
|
|
|
|
return tomllib.load(fh)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def artifacts():
|
|
|
|
|
for d in ARTIFACT_DIRS:
|
|
|
|
|
base = os.path.join(ROOT, d)
|
|
|
|
|
if not os.path.isdir(base):
|
|
|
|
|
continue
|
|
|
|
|
for path in sorted(glob.glob(os.path.join(base, "**", "*.md"),
|
|
|
|
|
recursive=True)):
|
|
|
|
|
yield os.path.relpath(path, ROOT)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def tagged_occurrences(text):
|
|
|
|
|
"""(key, line_no, the text of that line) for each fact tag."""
|
|
|
|
|
out = []
|
|
|
|
|
for i, line in enumerate(text.splitlines(), 1):
|
|
|
|
|
for m in TAG_RE.finditer(line):
|
|
|
|
|
out.append((m.group(1), i, line))
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def check():
|
|
|
|
|
reg = load_registry()
|
|
|
|
|
if reg is None:
|
|
|
|
|
print(f"ERROR — {os.path.relpath(REGISTRY, ROOT)} missing; "
|
|
|
|
|
f"run `make facts-gen`", file=sys.stderr)
|
|
|
|
|
return 1
|
|
|
|
|
keys = {k: v for k, v in reg.items() if isinstance(v, dict)}
|
|
|
|
|
|
|
|
|
|
findings = []
|
|
|
|
|
|
|
|
|
|
# 1. The registry must still agree with the instruments. Without this
|
|
|
|
|
# a stale registry would happily certify stale artifacts — the
|
|
|
|
|
# trap this task was warned about.
|
|
|
|
|
live = measure()
|
|
|
|
|
for key, (value, fmt, _by) in sorted(live.items()):
|
|
|
|
|
if key not in keys:
|
|
|
|
|
findings.append(f"registry missing {key} (instrument says "
|
|
|
|
|
f"{render(value, fmt)}) — run `make facts-gen`")
|
|
|
|
|
continue
|
|
|
|
|
if render(value, fmt) != keys[key]["text"]:
|
|
|
|
|
findings.append(
|
|
|
|
|
f"registry stale: {key} = {keys[key]['text']} but "
|
|
|
|
|
f"{keys[key]['by']} now measures {render(value, fmt)} "
|
|
|
|
|
f"— run `make facts-gen`")
|
|
|
|
|
for key in sorted(set(keys) - set(live)):
|
|
|
|
|
findings.append(f"registry has {key}, no instrument produces it "
|
|
|
|
|
f"— hand-edited?")
|
|
|
|
|
|
|
|
|
|
# 2. Every tagged occurrence must state the registry value.
|
|
|
|
|
tagged = 0
|
|
|
|
|
for rel in artifacts():
|
|
|
|
|
text = open(os.path.join(ROOT, rel)).read()
|
|
|
|
|
for key, lineno, line in tagged_occurrences(text):
|
|
|
|
|
tagged += 1
|
|
|
|
|
if key not in keys:
|
|
|
|
|
findings.append(f"{rel}:{lineno} tags unknown fact {key!r}")
|
|
|
|
|
continue
|
|
|
|
|
if keys[key]["text"] not in line:
|
|
|
|
|
findings.append(
|
|
|
|
|
f"{rel}:{lineno} claims fact:{key} but does not state "
|
|
|
|
|
f"{keys[key]['text']} | {line.strip()[:70]}")
|
|
|
|
|
|
|
|
|
|
# 3. Positive control. A gate that checked nothing would pass silently
|
|
|
|
|
# — the harness-does-nothing class, in the tool meant to close DFD.
|
|
|
|
|
if tagged == 0:
|
|
|
|
|
print("ERROR — no fact tags found in any artifact; the check verified "
|
|
|
|
|
"nothing. Tag at least one occurrence, or delete this gate.",
|
|
|
|
|
file=sys.stderr)
|
|
|
|
|
return 1
|
|
|
|
|
|
|
|
|
|
# 4. Report untagged copies: the drift surface still uncovered. This
|
|
|
|
|
# reports and does not fail — a number can legitimately recur (a
|
|
|
|
|
# round figure, a year), and failing on that would make the gate
|
|
|
|
|
# something people route around.
|
|
|
|
|
untagged = {}
|
|
|
|
|
for rel in artifacts():
|
|
|
|
|
text = open(os.path.join(ROOT, rel)).read()
|
|
|
|
|
declared = {k for k, _, _ in tagged_occurrences(text)}
|
|
|
|
|
for i, line in enumerate(text.splitlines(), 1):
|
|
|
|
|
if TAG_RE.search(line):
|
|
|
|
|
continue
|
|
|
|
|
for key, spec in keys.items():
|
|
|
|
|
if key in declared:
|
|
|
|
|
continue
|
|
|
|
|
# Only distinctive values: a bare "58" is everywhere.
|
|
|
|
|
if len(spec["text"]) < 5:
|
|
|
|
|
continue
|
|
|
|
|
if spec["text"] in line:
|
|
|
|
|
untagged.setdefault(key, []).append(f"{rel}:{i}")
|
|
|
|
|
|
|
|
|
|
print("facts-check — single source of fact (DFD gate)")
|
|
|
|
|
print(f" registry {len(keys)} facts, generated {reg.get('generated')}, "
|
|
|
|
|
f"pin {reg.get('pin')}")
|
|
|
|
|
print(f" tagged {tagged} occurrence(s) checked against the registry")
|
|
|
|
|
if untagged:
|
|
|
|
|
n = sum(len(v) for v in untagged.values())
|
|
|
|
|
print(f" untagged {n} literal copy/copies, not covered by the gate:")
|
|
|
|
|
for key in sorted(untagged):
|
|
|
|
|
print(f" {key:<26} {' '.join(untagged[key][:6])}"
|
|
|
|
|
+ (" …" if len(untagged[key]) > 6 else ""))
|
|
|
|
|
print(" NOTE: reported, not failed — tag them to bring them under "
|
|
|
|
|
"the gate")
|
|
|
|
|
|
|
|
|
|
if findings:
|
|
|
|
|
print(f"\n{len(findings)} finding(s):", file=sys.stderr)
|
|
|
|
|
for f in findings:
|
|
|
|
|
print(f" [facts] {f}", file=sys.stderr)
|
|
|
|
|
return 1
|
|
|
|
|
print(" no findings")
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------- self-test
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def self_test():
|
|
|
|
|
"""Each assertion pins a failure this gate must detect.
|
|
|
|
|
|
|
|
|
|
The class being closed is DFD, so the assertions are about *copies*:
|
|
|
|
|
a copy that disagrees must fail, a copy that agrees must pass, and a
|
|
|
|
|
run that checked no copies at all must not report success.
|
|
|
|
|
"""
|
|
|
|
|
results = []
|
|
|
|
|
|
|
|
|
|
def check_(name, ok, detail=""):
|
|
|
|
|
results.append((name, ok, detail))
|
|
|
|
|
|
|
|
|
|
check_("tag is recognised in prose",
|
|
|
|
|
tagged_occurrences("cost was **$93.15** <!-- fact:pinned_total -->")
|
|
|
|
|
== [("pinned_total", 1, "cost was **$93.15** <!-- fact:pinned_total -->")])
|
|
|
|
|
check_("untagged prose yields no occurrence",
|
|
|
|
|
tagged_occurrences("cost was $93.15") == [])
|
|
|
|
|
check_("tag with spaces is recognised",
|
|
|
|
|
[k for k, _, _ in tagged_occurrences("x <!-- fact:am4a_loc -->")]
|
|
|
|
|
== ["am4a_loc"])
|
|
|
|
|
check_("a malformed tag is not silently accepted",
|
|
|
|
|
tagged_occurrences("<!-- facts:pinned_total -->") == [])
|
|
|
|
|
|
|
|
|
|
# The DFD detection itself: agreeing and disagreeing copies.
|
|
|
|
|
spec = {"text": "$93.15"}
|
|
|
|
|
agree = "the benchmark is **$93.15** <!-- fact:pinned_total -->"
|
|
|
|
|
drift = "the benchmark is **$92.87** <!-- fact:pinned_total -->"
|
|
|
|
|
check_("an agreeing copy passes", spec["text"] in agree)
|
|
|
|
|
check_("a drifted copy is detected", spec["text"] not in drift,
|
|
|
|
|
"this is the whole class: both lines are internally consistent")
|
|
|
|
|
|
|
|
|
|
# Registry must exist, be complete, and match the instruments.
|
|
|
|
|
reg = load_registry()
|
|
|
|
|
check_("registry exists", reg is not None)
|
|
|
|
|
if reg:
|
|
|
|
|
keys = {k: v for k, v in reg.items() if isinstance(v, dict)}
|
|
|
|
|
check_("registry is non-empty", len(keys) >= 8, f"{len(keys)} facts")
|
|
|
|
|
check_("every fact records the instrument that produced it",
|
|
|
|
|
all(v.get("by") for v in keys.values()))
|
|
|
|
|
check_("every fact records both a value and its rendered text",
|
CB-WP-0006 T07: implement K18, amend K14
Two rules, two different answers, which is the point of a task phrased
"implement, or amend and say why".
K18 is implemented. "Criterion benches driving the same scenario format at
scale" was false — the bench hardcoded its commands and never touched
ScenarioFile, while MetricsAndScenarios §3 pointed at a benchmarks/
directory containing only baselines/. benchmarks/synthetic-3p.yaml now
holds the workload and both the bench and bench_shape read it: the
workload is data, not code.
A second defect surfaced while fixing the first. After the bench switched
to the file, bench_shape still hardcoded the same sequence, so the
workload existed twice — deleting end_round from the YAML broke bench-test
while bench_shape kept passing. Duplicated-fact drift in executable form.
Both now read the same include_str! and deleting a command breaks both.
Explicitly not claimed: this does not unblock AM-3. AM-3's baseline is a
declarative game object — moves, turn order, rules. synthetic-3p.yaml is a
command list; the rules live in games/ground. Marking it as AM-3's subject
would compare a script to a game definition, which is the category error
AM-3 is blocked on. The file says so in its own header, where the next
person will be tempted.
K14 is amended. CommitWindow had zero non-test users and GROUND enforces
the same contract inline. Wiring GROUND through it was rejected: it would
change the serialized shape of `selections`, which four scenario files
assert by dot-path and every state hash depends on, for the sole benefit
of making a sentence literally true.
The deciding argument is INTENT's, not convenience: abstractions are
extracted from working games rather than invented in isolation, and no
concept becomes canonical until it survives a second concrete use.
CommitWindow was invented before any game needed it and has survived none.
Imposing it on GROUND would manufacture the first use rather than discover
it. So K14 states what is actually guaranteed, CommitWindow is marked
provisional in the source, and it carries a delete-by date of 2026-12-31.
Kernel spec->code link 16/18 -> 18/18, stated with the caveat the gate
prints every run: that is about names, not assertions.
Two self-tests broke and both broke correctly. rule-coverage's gate test
hardcoded "unlinked rules exist today" and failed when the last one was
linked; it now computes that and asserts the gate fails iff rules are
unlinked. facts' text check rejected k_unlinked once it became
legitimately empty; empty now renders as "(none)" and the check
distinguishes absent from empty.
M-D1-MUT: 8 of 14, unchanged — K14 and K18 are kernel rules, not
acceptance rows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 12:47:16 +02:00
|
|
|
all("value" in v and v.get("text") is not None
|
|
|
|
|
for v in keys.values()),
|
|
|
|
|
"a legitimately empty value is still a fact — it must not be "
|
|
|
|
|
"reported as malformed")
|
2026-07-31 10:24:39 +02:00
|
|
|
check_("registry is generated, not hand-written",
|
|
|
|
|
open(REGISTRY).read().startswith("# GENERATED"))
|
|
|
|
|
|
|
|
|
|
# At least one artifact must actually be tagged, or the gate is inert.
|
|
|
|
|
total = sum(len(tagged_occurrences(open(os.path.join(ROOT, r)).read()))
|
|
|
|
|
for r in artifacts())
|
|
|
|
|
check_("at least one artifact is tagged (gate is not inert)", total > 0,
|
|
|
|
|
f"{total} tagged occurrence(s)")
|
|
|
|
|
|
|
|
|
|
print("facts self-test (positive control)")
|
|
|
|
|
ok = True
|
|
|
|
|
for name, passed, det in results:
|
|
|
|
|
print(f" [{'ok ' if passed else 'FAIL'}] {name}"
|
|
|
|
|
+ (f" — {det}" if det else ""))
|
|
|
|
|
ok &= passed
|
|
|
|
|
return 0 if ok else 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
|
enter_root()
|
|
|
|
|
if "--self-test" in sys.argv:
|
|
|
|
|
return self_test()
|
|
|
|
|
if "--gen" in sys.argv:
|
|
|
|
|
return generate()
|
|
|
|
|
return check()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
try:
|
|
|
|
|
sys.exit(main())
|
|
|
|
|
except subprocess.CalledProcessError as e:
|
|
|
|
|
print(f"ERROR — {e}", file=sys.stderr)
|
|
|
|
|
sys.exit(1)
|