Skip to content

Re-test grounded edges against the predicate gate at read time (#393) - #398

Merged
realmarcin merged 2 commits into
mainfrom
feat/393-predicate-gate-audit
Aug 16, 2026
Merged

Re-test grounded edges against the predicate gate at read time (#393)#398
realmarcin merged 2 commits into
mainfrom
feat/393-predicate-gate-audit

Conversation

@realmarcin

Copy link
Copy Markdown
Contributor

Closes #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 anything 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_forceSTATE 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_biosynthesisPATHWAY put an encodes edge outside METPO:2007813. Caught in review — by hand

This is what makes the hand-check unnecessary, and it lands before the next burn-down tranche rather than after.

Design

Added as PREDICATE_GATE_VIOLATION in 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 means the corpus disagrees with us. That's never a judgement call needing a baseline, which is the same argument MICROBE_DOMAIN_ON_NONORGANISM is ERROR on.

Both ends 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. 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

$ # temporarily retype proton_motive_force -> QUALITY
findings: 1  (baselined: 0, new: 1, blocking: 1)
  PREDICATE_GATE_VIOLATION  ph_optimum_high.yaml
  terminal_oxidases--METPO:2007800-->proton_motive_force object_type=QUALITY
  — predicate_grounding.tsv gates METPO:2007800 to object_types=BIOLOGICAL_PROCESS|CHEMICAL|STATE

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 qc green · 553 tests pass · ruff clean · history record per #325.

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>

@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 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 production mappings/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_silent and test_object_out_of_range_is_flagged assert against METPO: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 only test_predicate_gates_reads_both_columns can 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_type is required in the schema and DANGLING_EDGE is at 0 — but it is a deliberate asymmetry that nothing states.
  • The NONE sentinel (NO_NODE_TYPE, METPO:2000008/2000009, documented at docs/CURATION_PLAYBOOK.md:135) works here only by accident: "NONE" is not a CausalNodeTypeEnum member, 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>
@realmarcin
realmarcin merged commit e6a6fe8 into main Aug 16, 2026
5 checks passed
@realmarcin
realmarcin deleted the feat/393-predicate-gate-audit branch August 16, 2026 08:09
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.

Retyping a node can silently violate a grounded predicate's object_types, and nothing detects it

1 participant