Make --verify check emptiness and walk disk-to-manifest (#244) - #396
Conversation
#244 named three gaps beyond the manifest-to-disk existence check. ONE IS MOOT. It asked that the citation sidecar be asserted alongside each artifact; #388 deleted all 353 as a broken regex over report prose — exactly what #249 predicted when it said to decide that issue first. EXISTENCE IS NOT NON-EMPTINESS. `.exists()` passes for a zero-byte file, so a truncated write, a process killed mid-flush, or a provider returning an empty body all read as success. The floor is set FROM THE CORPUS rather than guessed: the smallest real report is 20,785 bytes, so 1 KiB leaves a 20x margin and cannot fail on real data. DISK TO MANIFEST. A report with no `ok` row was invisible, while still suppressing the call that would have produced it — resume keys on the artifact existing. Blocking from the start, with one named exception (cellulolysis-deep-research-codex.md, #245) using the same escape idiom as audit_biolink_curies.py's ALLOWED_UNBACKED, so adding one is a reviewed change. It is harmless today only because its `-codex` suffix is not the `-falcon` name resume looks for, which is luck rather than design. Scoped to `.md` deliberately: a `-meta.yaml` from `--dry-run` also lives under research/traits and represents NO research (status: dry-run, cost: None, task_id: None — #246), so counting it would let a plan nobody paid for satisfy an existence check. FOUND WHILE PROBING, and worth more than the feature: the counters were PER ROW. The manifest is append-only and 342 of the 353 artifacts carry two `ok` rows apiece — the original sweep and the re-run after its output was lost — so 700 rows describe 353 files, and a single truncated report was reported as TWO. Both counters now dedupe per artifact, which is how the invariants are phrased and how the adjacent CURIE scan already reported. Extracted ok_outputs / missing_artifacts / undersized_artifacts / orphan_reports as pure functions so the gates are testable, in scan_malformed_curies' style. Eight tests, each making a gate FIRE, on the argument that file already states: "a gate whose corpus is already clean is exactly the kind that can be silently broken". Probed by hand first — an injected orphan and a truncated artifact were both caught, tree restored clean. 548 tests pass - just qc green - ruff clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
The two gates are right in substance, and the per-artifact dedupe is a genuinely good catch — I confirmed it against the tree: 700 ok rows over 353 distinct outputs, 354 .md on disk, smallest real report 20,785 bytes (ecology/biosafety_level_4), and the one orphan is exactly cellulolysis-deep-research-codex.md. The floor and the exception list both check out. One real problem with the orphan gate's scope.
🟡 The orphan gate blocks far wider than its own rationale, and will turn just qc red on documented workflows
orphan_reports globs every *.md under research/traits/, but only run_trait_graph_audit.py ever writes reports/trait_graph_audit_manifest.tsv (I grepped — nothing else touches it). Two documented single-trait paths write reports into that directory and record nothing:
just research-trait <cat> <slug> --provider openai— README:122 documents this exact override;research_trait.py:271writes{slug}-deep-research-openai.md.just research-trait-edison <target>— justfile:429, writes{slug}-edison-{job}.md(research_trait_edison.py:242,261) on a real run.
The first non-falcon research run after this merges leaves the tree failing qc, and the only remedy the design offers is editing KNOWN_ORPHAN_ARTIFACTS — a code change per artifact. That is precisely how the -codex file got there.
What makes this a scoping bug rather than a policy call is your own argument. The stated invariant is that an unrecorded report suppresses a call that was never paid for, because resume keys on the artifact existing. But resume keys on output_path() → {slug}-deep-research-falcon.md. You say this yourself about the exception: it is harmless because -codex is not the name resume looks for. That reasoning generalises — -deep-research-openai.md and -edison-literature.md cannot suppress anything either. The gate currently blocks on files that are structurally incapable of causing the harm it exists to prevent.
Narrow the glob to the resume namespace (*-deep-research-falcon.md, or whatever output_path resolves to) and the gate matches its invariant exactly — and cellulolysis-deep-research-codex.md stops needing an exception at all, which is the better outcome for #245 too. If you'd rather keep the wide net, then the non-resume namespaces need to be reported without failing, not exempted one filename at a time.
🔵 Minor
- justfile:908 — the recipe comment still reads "every manifest
okrow's report is on disk, and no artifact carries a malformed CURIE". It now also checks emptiness and walks disk→manifest. This is the stale-prose-beside-a-changed-gate failuredocs/WORKFLOW_CONVENTIONS.mdnames, andaudit-justfile-pathscan't see it. manifest ok rows with a missing artifact:(line ~261) now counts artifacts, not rows — that dedupe is the PR's own headline finding. The adjacent CURIE line was reworded for exactly this reason ("reports carrying a malformed CURIE: N (M matches; …)"); this one wasn't.undersized[:20]andorphans[:20]print without the... and N moretail thatmissingandbad_curiesboth carry. The totals are on the count line so nothing is truly hidden, but the three blocks now read inconsistently.
Tests, history record (history/infrastructure/verify-artifact-integrity/…) and CI are all in order; the eight new tests each make their gate fire, which is the right bar for a gate that reports zero.
From the dispatched review of this PR, and the finding is in this PR's own new
code.
orphan_reports globbed every `*.md` under research/traits. But the harm it
exists to prevent — a file suppressing a call that was never paid for — needs
resume to be looking for that filename, and resume keys on `output_path()`:
`{slug}-deep-research-{default provider}.md`.
Two documented workflows write outside that namespace and record nothing:
`just research-trait --provider openai` (README) and `just research-trait-edison`
(justfile). The first documented non-falcon run after merge would have turned
`just qc` red, remediable only by adding a filename to a constant.
THE EXCEPTION LIST CONTAINED THE REFUTATION. It justified excluding
cellulolysis-deep-research-codex.md because "-codex is not the name resume looks
for". That generalises to every non-default provider — the gate was blocking on
files structurally incapable of causing the harm.
Narrowed to the resume namespace, so:
- the exception list is GONE entirely, and the codex file needs no naming,
which is a better outcome for #245 too — it is a tidiness question, not an
integrity one;
- re-probed: an injected `-falcon` orphan fires, an `-openai` one does not.
Also took three smaller notes from the same review: the justfile recipe comment
described only the pre-#244 checks; `manifest ok rows with a missing artifact:`
still said "rows" after being deduped to artifacts (this PR's own headline
finding, applied to the wrong line); and two of the four report blocks lacked
the `... and N more` tail the other two carry.
548 tests pass - just qc green - ruff clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five findings from the dispatched review, all taken. WRONG REFERENCE. The severity comment credited #396 with clearing the last PREDICATE_GATE_VIOLATION; it was #392, which added STATE to METPO:2007800's object_types. That comment is the whole justification for ERROR severity, so it is the line a future reader follows. TESTS READ PRODUCTION. audit() hard-wired predicate_gates() with no parameter while --traits-dir, --owl, --baseline and --out were all injectable, so three new tests asserted against a live row this repo has repointed once (#329) and widened once (#392) — the next edit to it would fail tests about unrelated code. mapping_path is threaded through audit() with a --mapping flag for symmetry, and those tests build their own table. THE PARSER WAS A SECOND IMPLEMENTATION, AND THEY HAD ALREADY DIVERGED (#399). This check exists to be a read-time counterpart to a write-time gate, and it restated the writer's cell parsing instead of importing it: - `NONE` (METPO:2000008/2000009 — "no node type satisfies this domain") passed only BY ACCIDENT, because the string "NONE" is not a CausalNodeTypeEnum member; - `*` and `NONE` are OPPOSITE meanings that a raw-string membership test cannot tell apart — _types returns None for one and an EMPTY SET for the other; - no per-name strip, no enum validation, where the writer treats an unknown type name as fatal on the grounds that "a constraint nobody can see is worse than no constraint". Now imports _types and validates against CausalNodeTypeEnum read from the schema. The one remaining asymmetry — an untyped node is admitted here and refused by the writer — is deliberate, unreachable while node_type is required, and now documented and pinned by a test rather than accidental. CONFLICT GUARD. The table is keyed by LABEL: 110 rows collapse to 76 CURIEs and 14 CURIEs carry several rows. Identical gates are fine; disagreeing ones would let the last row silently win, on an ERROR class that cannot be baselined. DOC DRIFT in paragraphs this PR was already rewriting: "two such classes" when there are three, and ENABLES_RANGE_VIOLATION still described as 33 baselined edges when #341/#355 burned it to 0 — so a bare invocation exits 0, not 1. 555 tests pass - just qc green - ruff clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…#398) * Re-test grounded edges against the predicate gate at read time (#393) mappings/predicate_grounding.tsv declares subject_types/object_types per predicate, and ground_causal_predicates consults them ONLY when it first grounds an edge — it returns early on any edge that already carries a predicate_id. A write-time check with no read-time counterpart, so every retyping migration since #351 could move a grounded edge out of range silently. TWO DID: #382 proton_motive_force -> STATE put terminal_oxidases -produces-> proton_motive_force outside METPO:2007800. SHIPPED TO MAIN undetected; found only because I ran this check by hand while fixing #392. #392 phenazine_biosynthesis -> PATHWAY put an encodes edge outside METPO:2007813. Caught in review — by hand. Adds PREDICATE_GATE_VIOLATION to audit_predicate_domains.py, which is already the predicate-vs-node-type audit and carries the ratchet, baseline and severity machinery. ERROR, not WARN. Unlike the other two classes the authority here is LOCAL: the table is a rule this repo sets itself, so a violation is the corpus disagreeing with us, never a judgement call that needs baselining. BOTH ENDS are checked, because a retype can move either — the #392 review had to verify that across produces, encodes, reduces, powers and transports by hand. The wildcard gets its own function and its own test, because comparing `*` as a literal type name is the obvious mistake and fails in the worst direction: my first hand-run reported 3385 violations against a corpus that had ONE. A gate that cries wolf on every wildcard row is one nobody runs twice. Probed before trusting it: temporarily retyping proton_motive_force to QUALITY produced exactly one blocking ERROR naming the edge, the offending type and the gate. Tree restored. Five new tests. Corpus clean at 0 — as is ENABLES_RANGE_VIOLATION, now that #341/#355 burned it down. 553 tests pass - just qc green - ruff clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Import the writer's parser instead of restating it (#399, #398 review) Five findings from the dispatched review, all taken. WRONG REFERENCE. The severity comment credited #396 with clearing the last PREDICATE_GATE_VIOLATION; it was #392, which added STATE to METPO:2007800's object_types. That comment is the whole justification for ERROR severity, so it is the line a future reader follows. TESTS READ PRODUCTION. audit() hard-wired predicate_gates() with no parameter while --traits-dir, --owl, --baseline and --out were all injectable, so three new tests asserted against a live row this repo has repointed once (#329) and widened once (#392) — the next edit to it would fail tests about unrelated code. mapping_path is threaded through audit() with a --mapping flag for symmetry, and those tests build their own table. THE PARSER WAS A SECOND IMPLEMENTATION, AND THEY HAD ALREADY DIVERGED (#399). This check exists to be a read-time counterpart to a write-time gate, and it restated the writer's cell parsing instead of importing it: - `NONE` (METPO:2000008/2000009 — "no node type satisfies this domain") passed only BY ACCIDENT, because the string "NONE" is not a CausalNodeTypeEnum member; - `*` and `NONE` are OPPOSITE meanings that a raw-string membership test cannot tell apart — _types returns None for one and an EMPTY SET for the other; - no per-name strip, no enum validation, where the writer treats an unknown type name as fatal on the grounds that "a constraint nobody can see is worse than no constraint". Now imports _types and validates against CausalNodeTypeEnum read from the schema. The one remaining asymmetry — an untyped node is admitted here and refused by the writer — is deliberate, unreachable while node_type is required, and now documented and pinned by a test rather than accidental. CONFLICT GUARD. The table is keyed by LABEL: 110 rows collapse to 76 CURIEs and 14 CURIEs carry several rows. Identical gates are fine; disagreeing ones would let the last row silently win, on an ERROR class that cannot be baselined. DOC DRIFT in paragraphs this PR was already rewriting: "two such classes" when there are three, and ENABLES_RANGE_VIOLATION still described as 33 baselined edges when #341/#355 burned it to 0 — so a bare invocation exits 0, not 1. 555 tests pass - just qc green - ruff clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Closes #244.
#244 named three gaps beyond the manifest→disk existence check.
One is moot
It asked that the citation sidecar be asserted alongside each artifact. #388 deleted all 353 as a broken regex over report prose — exactly what #249 predicted when it said to decide that issue first.
Existence is not non-emptiness
.exists()passes for a zero-byte file, so a truncated write, a process killed mid-flush, or a provider returning an empty body all read as success.The floor is set from the corpus, not guessed: the smallest real report is 20,785 bytes, so 1 KiB leaves a 20× margin and cannot fail on real data.
Disk → manifest
A report with no
okrow was invisible, while still suppressing the call that would have produced it — resume keys on the artifact existing.Blocking from the start, with one named exception (
cellulolysis-deep-research-codex.md, #245) using the same escape idiom asaudit_biolink_curies.py'sALLOWED_UNBACKED, so adding one is a reviewed change rather than a cell edit. It's harmless today only because its-codexsuffix isn't the-falconname resume looks for — luck, not design.Scoped to
.mddeliberately: a-meta.yamlfrom--dry-runalso lives underresearch/traitsand represents no research at all (status: dry-run,cost: None,task_id: None— #246). Counting it would let a plan nobody paid for satisfy an existence check.Found while probing, and worth more than the feature
The counters were per row. The manifest is append-only, and 342 of the 353 artifacts carry two
okrows apiece — the original sweep and the re-run after its output was lost. 700 rows describe 353 files, so a single truncated report was reported as two.Both counters now dedupe per artifact, which is how the invariants are phrased and how the adjacent CURIE scan already reported.
Testing
ok_outputs/missing_artifacts/undersized_artifacts/orphan_reportsare extracted as pure functions inscan_malformed_curies' style, so the gates can be tested at all. Eight tests, each making a gate fire — on the argument that file already states:Probed by hand before the tests existed: an injected orphan and a truncated artifact were both caught, tree restored clean.
just qcgreen · 548 tests pass (8 new) · ruff clean · history record per #325.