Skip to content

Make --verify check emptiness and walk disk-to-manifest (#244) - #396

Merged
realmarcin merged 2 commits into
mainfrom
fix/244-verify-emptiness-and-orphans
Aug 16, 2026
Merged

Make --verify check emptiness and walk disk-to-manifest (#244)#396
realmarcin merged 2 commits into
mainfrom
fix/244-verify-emptiness-and-orphans

Conversation

@realmarcin

Copy link
Copy Markdown
Contributor

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 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 rather than a cell edit. It's harmless today only because its -codex suffix isn't the -falcon name resume looks for — luck, not design.

Scoped to .md deliberately: a -meta.yaml from --dry-run also lives under research/traits and 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 ok rows 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_reports are extracted as pure functions in scan_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:

A gate whose corpus is already clean is exactly the kind that can be silently broken — a mistyped pattern would keep reporting zero forever. These tests are what distinguishes "found nothing" from "cannot find anything".

Probed by hand before the tests existed: an injected orphan and a truncated artifact were both caught, tree restored clean.

manifest ok rows with a missing artifact: 0
ok artifacts below 1024 bytes: 0
reports on disk with no ok manifest row: 0 (1 known, excluded)
reports carrying a malformed CURIE: 0 (0 matches; scanned 354 artifacts)

just qc green · 548 tests pass (8 new) · ruff clean · history record per #325.

#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>

@culturebot-reviewer culturebot-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:271 writes {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 ok row'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 failure docs/WORKFLOW_CONVENTIONS.md names, and audit-justfile-paths can'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] and orphans[:20] print without the ... and N more tail that missing and bad_curies both 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>
@realmarcin
realmarcin merged commit e06da05 into main Aug 16, 2026
5 checks passed
@realmarcin
realmarcin deleted the fix/244-verify-emptiness-and-orphans branch August 16, 2026 04:40
realmarcin added a commit that referenced this pull request Aug 16, 2026
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>
realmarcin added a commit that referenced this pull request Aug 16, 2026
…#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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

--verify checks report existence only: not sidecars, not emptiness, not disk-to-manifest

1 participant