Re-test grounded edges against the predicate gate at read time (#393) - #398
Conversation
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>
There was a problem hiding this comment.
The check itself is right, and the wildcard reasoning is worth the separate function. Two things to change before merge, then three optional.
🟡 #396 is the wrong reference (scripts/audit_predicate_domains.py:140)
# clean today (#396 fixed the last one) and must stay clean.
#396 is the artifact-integrity PR — issue #244, run_trait_graph_audit.py, the justfile, its tests. It touched nothing in this area. The last PREDICATE_GATE_VIOLATION was cleared by #392, which added STATE to METPO:2007800's object_types; this PR's own predicate_gates() docstring says so, and the notes cell on that row says so verbatim ("STATE added by TraitMech#392"). The comment is the entire justification for ERROR severity, so it's the one line a future reader will follow.
🟡 audit() hard-wires the gate table (:260)
gates = predicate_gates() takes no parameter, while --traits-dir, --owl, --baseline and --out are all injectable and main() threads args.owl through. Two consequences:
- The three new
audit()-based tests read productionmappings/predicate_grounding.tsv. Every other test in that file builds a fixture (_write_owl(tmp_path)) and passes it explicitly.test_an_in_range_edge_is_silentandtest_object_out_of_range_is_flaggedassert againstMETPO:2007800's live gate — a row this repo has already repointed once (#329) and widened once (#392, two commits ago). The next edit to it fails tests that have nothing to do with the code under test. predicate_gates(mapping_path=...)is parameterized but onlytest_predicate_gates_reads_both_columnscan reach it.
Thread mapping_path through audit(), add a --mapping flag for symmetry with --owl, and give the three tests their own table.
🔵 _gate_admits is a second implementation of _types(), and they already disagree
The stated goal is a read-time counterpart to the write-time gate; parity kept by duplication drifts. Three divergences from ground_causal_predicates._types() plus its check:
actual is None→ this admits; the writer blocks (s_type not in subj_ok). Unreachable today —node_typeis required in the schema andDANGLING_EDGEis at 0 — but it is a deliberate asymmetry that nothing states.- The
NONEsentinel (NO_NODE_TYPE,METPO:2000008/2000009, documented atdocs/CURATION_PLAYBOOK.md:135) works here only by accident:"NONE"is not aCausalNodeTypeEnummember, so the membership test happens to fail. Combined with the above, an edge grounded to those CURIEs with an untyped subject would pass a gate that means "nothing satisfies this". - No per-name
.strip()and no enum validation of the cell; the writer treats an unknown type name as fatal precisely because "a constraint nobody can see is worse than no constraint."
🔵 Keyed by CURIE, gated by label
predicate_gates() keys on target_curie; the writer keys on label. 110 rows collapse to 73 CURIEs. No CURIE carries two different gate rows today — I checked — but nothing detects it if one ever does: the last row silently wins. Since this class is ERROR and unbaselineable, the wrong gate would hard-block CI on a legitimate edge. load_mapping already raises ValueError on the mirror-image conflict (one label, two CURIEs); the same guard here is a few lines.
🔵 Doc drift in the paragraphs this PR rewrites
:8 still says the audit "flags two such classes" and :51 "The two classes are at different stages" — there are now three. Adjacent to that, :61 and :79 still describe ENABLES_RANGE_VIOLATION as 33 baselined edges and claim "a bare invocation therefore exits 1 while the 33 #334 edges stand"; the baseline is header-only and the report is empty, so a bare invocation now exits 0. docs/CURATION_PLAYBOOK.md:301 already records the zero, and :136's "Promote it to ERROR then" condition is met. Pre-existing, but you are rewriting these paragraphs anyway. The playbook's gate section (:135) also still describes enforcement as write-time only — one line there would tell a curator why a retype now trips an ERROR.
History record is present and well-formed; no data/traits/** change, so nothing else was owed. Confirmed independently: CI green on all five checks, both the report and the baseline are empty, and every corpus predicate_id has a row in the gate table.
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>
Closes #393.
mappings/predicate_grounding.tsvdeclaressubject_types/object_typesper predicate, andground_causal_predicatesconsults them only when it first grounds an edge — it returns early on anything that already carries apredicate_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:
proton_motive_force→STATEputterminal_oxidases -produces-> proton_motive_forceoutsideMETPO:2007800. Shipped to main undetected — found only because I ran this check by hand while fixing #392phenazine_biosynthesis→PATHWAYput anencodesedge outsideMETPO:2007813. Caught in review — by handThis is what makes the hand-check unnecessary, and it lands before the next burn-down tranche rather than after.
Design
Added as
PREDICATE_GATE_VIOLATIONinaudit_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 means the corpus disagrees with us. That's never a judgement call needing a baseline, which is the same argument
MICROBE_DOMAIN_ON_NONORGANISMis ERROR on.Both ends checked, because a retype can move either — the #392 review had to verify that across
produces,encodes,reduces,powersandtransportsby hand.The wildcard gets its own function and its own test. Comparing
*as a literal type name is the obvious mistake and it 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.Verified by making it fire
Tree restored. Five new tests: object out of range, subject out of range, in-range silence, wildcard handling, and the table reader.
Corpus is clean at 0 — as is
ENABLES_RANGE_VIOLATION, now that #341/#355 burned it down.just qcgreen · 553 tests pass · ruff clean · history record per #325.