Let the census be imported, and pin the three prefix lists to each other - #96
Conversation
`prefix_census.py` had its scan loop and its `json.dump` at module level, so importing it ran the whole census — every record in all nine corpora — and overwrote `_fleet/data/prefix_census.json` as a side effect. I hit this by importing the module to read its prefix list: four minutes later the working tree held a census dated today over a `fleet_data.json`, `subsets_summary.json` and `assets/fleet/**` from the previous run, which would have dated the page to the 21st above a heatmap built from the 20th (#95). The scan now lives in `census()` behind a `__main__` guard. Running the script is unchanged — verified end to end, all nine corpora, well-formed output — but importing it costs 0.046s and writes nothing. That unblocks the thing worth having. The pipeline carries three separately hand-maintained prefix lists that have to agree: `P` in this file decides what is counted, `VOC` in build_data.py decides which vocabularies become heatmap columns, and `PREF` in build_subsets.py decides which cells and edges get clickable record lists. Nothing checked them against each other, and a mismatch is silent both ways — a column whose vocabulary is never counted renders as a stripe of zeros, and a vocabulary counted but missing from `VOC` never reaches the page at all. That is the failure mode behind #84, where TOGO went missing. Four tests pin it: every column is counted, every clickable prefix is counted, columns and cells name the same vocabularies once citation prefixes are set aside, and importing the census module neither scans nor writes. All three invariants hold today; they were untestable until now only because reading `P` cost a scan. The other two lists are read by parsing their literals out of the source rather than importing, because those scripts scan on import too. The parse asserts it found a plausible list, so a reformat fails the test loudly instead of quietly checking nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lings Two defects in the tests I added a commit ago, both found by mutation-testing them rather than by reading them. `test_importing_the_census_module_does_not_scan_or_write` could not fail. It snapshotted the census, then called `importlib.reload` — but `setUp` had already imported the module, so with the guard removed the scan ran *during setUp*, before the snapshot, and the reload rewrote the same bytes. The test compared a corrupted file against itself and passed. Verified: against a copy with the guard replaced by a bare `main()`, the old assertion passed. It now imports in a fresh interpreter via subprocess, with a 60-second timeout that is the real teeth — a scan takes minutes, so an unguarded module fails long before it finishes writing. Against the same mutant this version raises TimeoutExpired and fails, which is the point. The accepted-prefix set was also too loose. It took `P` plus `norm`'s values, which admits the 15 raw spellings `norm` exists to fold away — UniProtKB, IPR, mesh, pubchem.compound, drugbank and the rest. None is ever emitted as a key, so a heatmap column named one of them would have passed the check and rendered as a stripe of zeros: exactly the failure the test is for. It now takes the set the census can actually emit, every alternative in `P` after `norm` is applied, which is 53 rather than 68. All four tests were then mutation-tested, since a test that cannot fail is worse than no test: removing the `__main__` guard fails the import test, adding `UniProtKB` to `VOC` fails the column test (the loose version accepted it), and dropping `CHEBI` from `PREF` fails the symmetry test. Mutations were run against copies in the scratchpad; the repository was untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review passTwo dimensions (refactor behaviour, test quality), findings independently verified by mutation-testing scratch copies. 5 raised, 3 confirmed — and the serious ones were in the tests I had just written, not in the refactor. Confirmed and fixed in
|
| mutation | expected | result |
|---|---|---|
remove the __main__ guard |
import test fails | ✅ TimeoutExpired (old version: passed) |
add UniProtKB to VOC |
column test fails | ✅ fails (loose version: would pass) |
drop CHEBI from PREF |
symmetry test fails | ✅ fails |
Run against copies in the scratchpad; the repository was untouched throughout (git status clean, census byte-identical).
Refuted
- "A fourth list (
build_subsets' ownrx/NORM) stays unguarded" — a request for a test this PR never claimed to write, and the verifier found its supporting reasoning wrong. - "
literal()checks the source literal, not the runtime list" — true and deliberate; both scripts scan on import, which is why the parse asserts it found a plausible list.
Checked myself
- CI runs only
unittest,refresh_manifest --checkandassemble_page --check— nothing imports or execs the census, so the guard breaks no workflow step. - Semantic equivalence of the moved loop, proved by comparing the
for fbody's AST betweenmainandHEAD: identical. The original's odd 12-space indentation happens to be exactly right under the new function.
27 tests pass, --check clean.
`literal()` matched the list with a regex and `eval`'d the match, which sees only the text it matched. `VOC = VOC + ["BOGUSVOC"]` on the line after the literal would leave the test reading a list the module no longer uses, and the length check cannot notice: 22 entries is still more than ten. It now parses the file with `ast`, requires exactly one module-level binding of the name, and reads the value with `literal_eval`, which refuses anything that is not a literal. A second binding or a computed value fails loudly instead of being half-read. Verified against a copy carrying that exact mutation: the test now fails with "VOC is bound 2 times in build_data.py", where the regex version passed. Importing the two scripts would be the real fix, but build_subsets.py scans records at import and builds MECHS at module level, so it is the next #95 and not this commit's business. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Addendum — the final aggregation raised the count to fourMy review comment above reported 3 confirmed. The workflow's final tally is 4 of 5; the fourth is fixed in
VOC=["CHEBI", ...]
VOC = VOC + ["BOGUSVOC"] # the test never sees thisThe runtime It now parses with Full mutation results across the four tests, all against scratch copies:
Every one of the four defects was in the tests I wrote, not in the refactor. That is the right ratio for a PR whose entire value is a guard: the guard has to be the thing under suspicion. One acknowledged limitation, recorded rather than fixed: importing the two scripts would beat parsing them, but 27 tests, |
Closes #95. Unblocks the guard half of #84.
The bug
scripts/fleet/prefix_census.pyhad its scan loop and itsjson.dumpat module level, with no__main__guard. So importing it ran the whole census — every record in all nine corpora — and overwrote_fleet/data/prefix_census.json.I hit this for real while inspecting the prefix list:
Four minutes later the working tree held a census dated the 21st over a
fleet_data.json,subsets_summary.jsonandassets/fleet/**from the 20th — the page would have dated itself to today above a heatmap built from yesterday. Reverted withgit checkout --.Why it was worth stopping to fix
The pipeline carries three independently hand-maintained prefix lists that must agree:
Pprefix_census.pyVOCbuild_data.pyPREF/rx/STRICTbuild_subsets.pyA mismatch is silent in both directions: a column whose vocabulary is never counted renders as a stripe of zeros, and a vocabulary counted but absent from
VOCnever reaches the page. That is the failure mode behind #84 — TOGO is counted by nothing and shown by nothing, and no check noticed.A test would pin this cheaply. It could not be written, because reading
Pcost a four-minute scan and corrupted the data directory. So this defect was the blocker, not merely a wart.What changed
The scan moved into
census()behind a__main__guard.P,rxandnormstay at module level, which is what a test needs.Four tests now pin the invariants — all three hold today:
roots.CITATIONis set aside ✅VOCandPREFare read by parsing their literals out of source rather than importing, since those two scripts also scan on import. The parse asserts it found a plausible list, so a reformat fails the test loudly instead of quietly checking nothing.Canary
The guard could have broken the entry point, so I ran the script for real before committing: 2m48s, all nine corpora, every Mech non-empty, exit 0, well-formed output. Then restored the committed census — that run was a partial refresh and a real one means the whole pipeline. Verified byte-identical to
HEADafterwards.Worth noting from that run: the corpora have moved again since the 20th — CommunityMech 364 → 393, TraitMech 694 → 722, MediaIngredientMech 2951 → 2952. Not folded in here; a refresh is a full-pipeline job.
Checks
27 tests (four added),
assemble_page.py --checkclean, working tree touches only the two source files.🤖 Generated with Claude Code