Skip to content

Add a ratcheted predicate domain/range audit (#301, #302) - #314

Merged
realmarcin merged 3 commits into
mainfrom
feat/301-predicate-domain-audit
Aug 6, 2026
Merged

Add a ratcheted predicate domain/range audit (#301, #302)#314
realmarcin merged 3 commits into
mainfrom
feat/301-predicate-domain-audit

Conversation

@realmarcin

Copy link
Copy Markdown
Contributor

What

Adds scripts/audit_predicate_domains.py and wires an audit-predicate-domains
recipe into the qc chain. It closes the enforcement gap behind #301/#302:
validate-strict sees predicate_id as a bare string, so a CURIE whose
ontological domain/range no causal-graph node type can satisfy is a false
type entailment that passes every existing gate.

Two defect classes, both derived from the corpus + the vendored ontologies:

The microbe-domain predicate set is computed at run time by walking the
subPropertyOf closure to METPO:2000001 in data/raw/metpo.owl — not a
hand-maintained list — so a new METPO subproperty is covered the moment it's
vendored.

Why a ratchet, not a blocking gate

Neither class is fixable without a per-family biological decision (#301) or an
ontology decision on the enables range (#302, #303). A blocking check would be
un-landable today. So this is the same ratchet as audit-graphs:
conf/predicate_domain_audit_baseline.tsv freezes the 530 known findings; any
new violation fails CI. This makes the invisible 530-edge class visible and
non-growing while the biological/ontology decisions land family by family — burn
the baseline down, then just audit-predicate-domains --fail-on any.

What was checked

Scope / what this does NOT do

It does not change any trait data or the ontology, and does not resolve #303's
donor/acceptor role loss — that needs the #302 decision. This is the
infrastructure step chosen so the backlog is frozen and visible before the
per-family fixes.

🤖 Generated with Claude Code

validate-strict treats predicate_id as a bare string, so a CURIE whose
ontological domain/range no causal node type can satisfy is a false type
entailment that passes every gate. This adds scripts/audit_predicate_domains.py
+ an audit-predicate-domains recipe in the qc chain, flagging two classes:

- MICROBE_DOMAIN_ON_NONORGANISM (#301): predicate transitively
  subPropertyOf METPO:2000001 (microbe domain) on a non-organism causal
  subject. 366 edges. The microbe-domain predicate set is derived at run
  time from the subPropertyOf closure in data/raw/metpo.owl, not a
  hand-maintained list.
- ENABLES_RANGE_ON_TRAIT (#302): enables/RO:0002327 pointed at a TRAIT
  object when its biolink range is 'biological process or activity'. 164
  edges.

Neither is fixable without a per-family biological/ontology decision
(#301, #302, #303), so it ships as a ratchet identical to audit-graphs:
conf/predicate_domain_audit_baseline.tsv freezes the 530 known findings,
any NEW violation fails. Registered in audit-derived-reports so a stale
committed reports/predicate_domain_audit.tsv is caught too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@culturebot-reviewer

culturebot-reviewer Bot commented Aug 6, 2026

Copy link
Copy Markdown

Claude finished @realmarcin's task in 4m 26s —— View job


Review: ratcheted predicate domain/range audit

Verified the substance rather than the description:

  • METPO:2000001 really is rdfs:domain METPO:1000525 / rdfs:range METPO:1000526 (data/raw/metpo.owl:159-161), label "organism interacts with chemical". The central claim holds.
  • CausalNodeTypeEnum (src/traitmech/schema/traitmech.yaml:485) has no organism-ish member — TRAIT, PATHWAY, ENVIRONMENTAL_FACTOR, EXPERIMENTAL_FACTOR, GENE_OR_PROTEIN, CHEMICAL, ORGANELLE, CELLULAR_LOCALIZATION, MOLECULAR_FUNCTION, BIOLOGICAL_PROCESS, STATE, QUALITY, CAPACITY. So flagging unconditionally on pid in microbe_domain is sound today.
  • All 94 rdfs:subPropertyOf in metpo.owl use the rdf:resource attribute form, so the ElementTree walk is not missing any.
  • Corpus predicate_id prefixes are uniformly RO: / METPO: / biolink: / rdfs: — no biolink:enables exists, so hard-coding RO:0002327 isn't leaking a class today.
  • Counts reconcile: 366 + 164 = 530 rows; baseline and report are byte-identical; every baseline _key is unique (no collapsed suppressions).
  • CI: qc, pytest, pr-sanity all green.

No 🔴 or 🟡. Three 🔵s, all optional:

🔵 The inherited range half is unchecked, and the first baseline row shows it.
conf/predicate_domain_audit_baseline.tsv:2data/traits/ecology/biosafety_level_1.yaml, edge low_pathogen_hazard --METPO:2000202(produces)--> bsl1_trait. METPO:2000202 inherits both domain=METPO:1000525 and range=METPO:1000526 from 2000001, and the object is a TRAIT, not a chemical. The audit reports only "subject entails ⊑ microbe". The closure walk in microbe_domain_predicates already has the parent chain in hand, so the range half is nearly free. A curator burning down #301 edge-by-edge from this report will not see the range violation. Declared out of scope in the PR body, so this is a follow-up, not a blocker — but the tool's name promises "domain/range". Fix this →

🔵 _curie silently passes through non-METPO IRIs (scripts/audit_predicate_domains.py:98-101). If METPO ever asserts an OBO-IRI property under 2000001 (e.g. an RO: term), the closure holds http://purl.obolibrary.org/obo/RO_... and never matches the corpus's RO:0002327 CURIE — a silent under-report, in exactly the mechanism the docstring at line 38-41 advertises as future-proof. Not live today (every owl:ObjectProperty rdf:about in metpo.owl is a w3id METPO IRI), but the failure mode is invisible when it arrives.

🔵 tests/test_audit_predicate_domains.py:192-223 is nearly vacuous. The CLEAN constant is defined and never used; the test then writes an inline graph that does produce an ENABLES_RANGE_ON_TRAIT finding, filters that defect out, and asserts the remainder is empty. The non-flagging path it was meant to prove is already covered by test_non_microbe_predicate_not_flagged. Either drop the test or use CLEAN as written.

Two things I'll note in the ratchet's favour, since they're the parts that usually go wrong: _key's leading-token discriminator is subject--predicate_id-->object, which keys each edge independently and survives edits to the human-readable tail — matching audit_causal_graphs._key's contract as claimed; and partition is factored out and directly unit-tested for all three --fail-on modes, including any ignoring the baseline. That's the failure mode that lets a ratchet ship disarmed, and it's covered.

No data/traits/** changes, so no history/ record is expected.

Approving.
branch

@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.

Verified the substance, not just the description: METPO:2000001 really is rdfs:domain METPO:1000525 / rdfs:range METPO:1000526 in data/raw/metpo.owl:159-161; CausalNodeTypeEnum (src/traitmech/schema/traitmech.yaml:485) genuinely has no organism-ish member, so the unconditional flag on pid in microbe_domain is sound; all 94 subPropertyOf assertions use the rdf:resource form the ElementTree walk expects; corpus predicate_id prefixes are uniformly RO:/METPO:/biolink:/rdfs: with no biolink:enables, so hard-coding RO:0002327 leaks nothing today. Counts reconcile (366 + 164 = 530), baseline and report are byte-identical, and every baseline _key is unique — no collapsed suppressions. qc, pytest and pr-sanity are green.

The two parts of a ratchet that usually ship broken are both right here: _key's leading-token discriminator is subject--predicate_id-->object, so each edge keys independently and survives edits to the human-readable tail; and partition is factored out and unit-tested across all three --fail-on modes including 'any' ignoring the baseline.

No blocking findings. Three optional notes, detailed in the review comment:

  1. The inherited range half is unchecked, and the first baseline row shows it — conf/predicate_domain_audit_baseline.tsv:2, edge low_pathogen_hazard --METPO:2000202(produces)--> bsl1_trait. METPO:2000202 inherits range METPO:1000526 (chemical) and the object is a TRAIT, but the report says only 'subject entails microbe'. The closure walk already has the parent chain, so it is nearly free, and a curator burning down #301 from this report will not see it. Declared out of scope in the PR body, so a follow-up.

  2. _curie (scripts/audit_predicate_domains.py:98-101) passes non-METPO IRIs through unchanged. If METPO ever asserts an OBO-IRI property under 2000001, the closure holds the full IRI and never matches the corpus CURIE — a silent under-report in exactly the mechanism the docstring advertises as future-proof. Not live today.

  3. tests/test_audit_predicate_domains.py:192-223 is nearly vacuous: the CLEAN constant is unused, and the test writes a graph that does flag, filters that defect out, then asserts the remainder is empty. The path it meant to prove is already covered by test_non_microbe_predicate_not_flagged.

No data/traits/** changes, so no history/ record is expected.

The CLEAN constant carried an enables->TRAIT edge, so it was not clean;
test_clean_graph_has_no_findings wrote a separate inline graph, produced
an ENABLES_RANGE_ON_TRAIT finding, then filtered that very defect out and
asserted the remainder was empty — a near-vacuous test whose non-flagging
path was already covered elsewhere. Rewrite CLEAN to point enables at a
process and use a non-domain predicate, so audit() genuinely returns [].

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@culturebot-reviewer

culturebot-reviewer Bot commented Aug 6, 2026

Copy link
Copy Markdown

Claude finished @realmarcin's task in 1m 14s —— View job


Re-review: ratcheted predicate domain/range audit

  • Read repo conventions (README / docs)
  • Review new commit 94a3d10c (the clean-graph test fix)
  • Re-verify the audit script, baseline, and justfile wiring
  • Post verdict

Only one commit landed since the last approval, 94a3d10c, and it is exactly the fix for note 3.

The fix is correct. CLEAN now carries chem --RO:0002327--> proc — object is BIOLOGICAL_PROCESS, so the range check does not fire — and proc --METPO:3000000--> tr, which is outside the METPO:2000001 closure in the test OWL ({2000001, 2000202, 9999999}). test_clean_graph_has_no_findings now asserts audit(d, owl) == [] with no filtering. The dead constant and the near-vacuous inline graph are both gone, and the test proves the non-flagging path it was named for.

Nothing else changed. Script, baseline, report and justfile wiring are byte-identical to what I verified last round: METPO:2000001 is rdfs:domain METPO:1000525 / rdfs:range METPO:1000526 (data/raw/metpo.owl:159-161); CausalNodeTypeEnum (src/traitmech/schema/traitmech.yaml:485) has no organism member; all 94 subPropertyOf assertions use the rdf:resource form the ElementTree walk expects; 366 + 164 = 530 rows with unique _keys; partition is unit-tested across all three --fail-on modes including any ignoring the baseline. qc, pytest, pr-sanity and vendored-sync are all green on the new head.

Notes 1 (the inherited range half of the METPO:2000001 closure is still unchecked — conf/predicate_domain_audit_baseline.tsv:2 is the example) and 2 (_curie at scripts/audit_predicate_domains.py:98-101 passes non-METPO IRIs through, so an OBO-IRI subproperty under 2000001 would silently under-report) remain open. Neither is live against the current ontology and both were declared out of scope — follow-ups, not blockers.

No data/traits/** changes, so no history/ record is expected.

No 🔴 or 🟡. Approving.
branch

@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.

Re-review of the delta since the last approval: the only new commit, 94a3d10, is exactly the fix for note 3 and it is correct. CLEAN now carries chem --RO:0002327--> proc (object is BIOLOGICAL_PROCESS, so the range check does not fire) and proc --METPO:3000000--> tr (outside the METPO:2000001 closure in the test OWL), so test_clean_graph_has_no_findings asserts audit() == [] with no filtering. The vacuous inline graph and the dead CLEAN constant are both gone, and the test now proves the non-flagging path it was named for.

Nothing else in the PR changed - the script, baseline, report and justfile wiring are byte-identical to what I verified last time (METPO:2000001 domain/range in data/raw/metpo.owl:159-161, no organism member in CausalNodeTypeEnum, all 94 subPropertyOf assertions in the rdf:resource form the walk expects, 366 + 164 = 530 rows reconciling with unique _keys, and partition unit-tested across all three --fail-on modes). qc, pytest, pr-sanity and vendored-sync are green on the new head.

Notes 1 (the inherited range half of the METPO:2000001 closure is unchecked) and 2 (_curie passes non-METPO IRIs through, so an OBO-IRI subproperty would silently under-report) still stand as optional follow-ups. Neither is live against the current ontology and both were declared out of scope. No blocking findings.

No data/traits/** changes, so no history/ record is expected.

Approving.

The detail column carries a non-ASCII '⊑' (⊑ microbe) on 366 rows, but
the report write, baseline write, and baseline read all used the platform
default (locale) encoding. On a non-UTF-8 locale that raises
UnicodeEncodeError writing the report and UnicodeDecodeError reading the
baseline — a latent crash that only passed CI because the runner is UTF-8.
Pin encoding='utf-8' on all three; the on-disk bytes are unchanged
(report and baseline are byte-identical after regeneration).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@culturebot-reviewer

culturebot-reviewer Bot commented Aug 6, 2026

Copy link
Copy Markdown

Claude finished @realmarcin's task in 2m 32s —— View job


Re-review: ratcheted predicate domain/range audit

  • Read repo conventions (README / docs)
  • Review new commit 6531ba13 (UTF-8 pinning)
  • Re-verify script, baseline, report, justfile wiring
  • Post verdict

One new commit since the last approval, 6531ba13. It pins encoding="utf-8" on the three TSV handles — load_baseline (scripts/audit_predicate_domains.py:226), the report write (:251), and the baseline write (:269). The premise checks out: (U+2291) really is on all 366 MICROBE_DOMAIN_ON_NONORGANISM rows, and both writes and the baseline read were on the platform default. On-disk bytes are unchanged as claimed — reports/predicate_domain_audit.tsv and conf/predicate_domain_audit_baseline.tsv are still the same blob (fcb9727) and still byte-identical to each other, and qc (which diffs the committed report against a fresh generation in audit-derived-reports) is green on this head. pytest, pr-sanity and vendored-sync also green.

Nothing else in the PR moved. The substance I verified in the earlier rounds still holds: METPO:2000001 is rdfs:domain METPO:1000525 / rdfs:range METPO:1000526 (data/raw/metpo.owl:159-161), CausalNodeTypeEnum (src/traitmech/schema/traitmech.yaml:485) has no organism member, all 94 subPropertyOf assertions use the rdf:resource form the ElementTree walk expects, 366 + 164 = 530 rows with unique _keys, and partition is unit-tested across all three --fail-on modes including any ignoring the baseline.

No 🔴 or 🟡. One new 🔵 on this commit, plus the two still-open ones:

🔵 The fix stops one line short of the read that actually dominates. scripts/audit_predicate_domains.py:143 is yaml.safe_load(path.read_text()) — still unpinned, and 357 of the trait YAMLs contain non-ASCII. audit() runs at :248, before the newly-pinned write at :251, so on the exact non-UTF-8 locale the commit message is defending against the script dies here first, and it dies uncaught: the except at :144 catches yaml.YAMLError, not UnicodeDecodeError. (On a permissive 8-bit default like cp1252 it doesn't raise at all — it mojibakes, which would make the generated report differ from the committed one and trip audit-derived-reports as STALE.) requires-python = ">=3.10", so UTF-8 mode is not on by default across the supported range. Not reachable on CI or on any UTF-8 dev box, hence 🔵 and not 🟡 — but it's the same failure mode the commit set out to close, one line above the fix. scripts/audit_causal_graphs.py:131 and its three TSV handles have the identical unpinned pattern, so if you'd rather do it as one sweep than as a one-liner here, that's the reasonable shape. Fix this →

🔵 (carried) The inherited range half of the METPO:2000001 closure is still uncheckedconf/predicate_domain_audit_baseline.tsv:2, edge low_pathogen_hazard --METPO:2000202(produces)--> bsl1_trait, inherits range=METPO:1000526 (chemical) against a TRAIT object, and the report says only "subject entails ⊑ microbe".

🔵 (carried) _curie (:98-101) passes non-METPO IRIs through unchanged — an OBO-IRI subproperty asserted under 2000001 would sit in the closure as a full IRI and never match the corpus CURIE, a silent under-report. Not live against the current ontology.

Both were declared out of scope in the PR body and neither bites today.

No data/traits/** changes, so no history/ record is expected.

Approving.
branch

@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.

Re-review of the delta since the last approval. The only new commit, 6531ba1, pins encoding='utf-8' on load_baseline (scripts/audit_predicate_domains.py:226), the report write (:251) and the baseline write (:269). The premise checks out — U+2291 is on all 366 MICROBE_DOMAIN_ON_NONORGANISM rows and all three handles were on the platform default. On-disk bytes are unchanged as claimed: report and baseline are still the same blob (fcb9727) and still byte-identical to each other, and qc (whose audit-derived-reports step diffs the committed report against a fresh generation) is green on this head, along with pytest, pr-sanity and vendored-sync.

Nothing else moved. The substance verified in earlier rounds still holds: METPO:2000001 domain/range at data/raw/metpo.owl:159-161, no organism member in CausalNodeTypeEnum, all 94 subPropertyOf assertions in the rdf:resource form the walk expects, 366 + 164 = 530 rows with unique _keys, and partition unit-tested across all three --fail-on modes including 'any' ignoring the baseline.

No blocking findings. One new optional note, detailed in the review comment: the fix stops one line short of the read that dominates. scripts/audit_predicate_domains.py:143 is yaml.safe_load(path.read_text()), still unpinned, and 357 trait YAMLs contain non-ASCII. audit() runs at :248, before the newly-pinned write at :251, so on the exact non-UTF-8 locale the commit message defends against the script dies here first — and uncaught, since the except at :144 catches yaml.YAMLError, not UnicodeDecodeError. requires-python is >=3.10, so UTF-8 mode is not on by default across the supported range. Not reachable on CI or any UTF-8 dev box, so a suggestion rather than a defect, but it is the same failure mode the commit set out to close. scripts/audit_causal_graphs.py:131 and its three TSV handles have the identical pattern, so a single sweep is a reasonable alternative to a one-liner here.

The two carried notes still stand as out-of-scope follow-ups: the inherited range half of the METPO:2000001 closure is unchecked (conf/predicate_domain_audit_baseline.tsv:2 is the example), and _curie at :98-101 passes non-METPO IRIs through so an OBO-IRI subproperty would silently under-report. Neither is live against the current ontology.

No data/traits/** changes, so no history/ record is expected.

Approving.

@realmarcin
realmarcin merged commit 79ccf6f into main Aug 6, 2026
5 checks passed
@realmarcin
realmarcin deleted the feat/301-predicate-domain-audit branch August 6, 2026 00:50
realmarcin added a commit that referenced this pull request Aug 6, 2026
… zero (#327, closes #301) (#332)

* Re-ground the last 3 substrate-to-product edges, and harden the gate (#327)

RO:0001001 (derives into) holds between an old and a new material entity
where the new begins to exist as the old ceases and inherits the
significant portion of its matter -- substrate-to-product chemistry.
biolink maps it as biolink:derives_into. That is exactly what these edges
say, so #327 resolves with an upstream term and no minting at all:

  cellulose --is hydrolyzed to--> cellobiose   METPO:2000013 -> RO:0001001
  thiosulfate --derives into--> sulfate        METPO:2007800 -> RO:0001001
  thiosulfate --derives into--> sulfide        METPO:2007800 -> RO:0001001

The first was the defect #327 was filed for: METPO:2000013 reads
agent-to-substrate, so grounding a substrate-to-product edge to it
asserted that cellulose hydrolyses cellobiose. Its label is kept, since
it names the mechanism the general relation does not. The other two had a
CHEMICAL subject on `produces`, and a chemical does not bring another
into existence the way an agent does.

METPO:2007405 (oxidized to, proposals v2) was considered for the sulfate
half and rejected twice over: it fits only the oxidised half, so it would
split a matched pair from one disproportionation reaction, and it is an
unminted placeholder where RO:0001001 is real.

acetyl_phosphate --produces--> ATP was examined and deliberately NOT
moved: ATP's matter comes mostly from ADP, only the phosphoryl group
transfers, so it fails RO:0001001's "inherits the significant portion of
the matter" test. Filed separately rather than forced.

MICROBE_DOMAIN_ON_NONORGANISM: 1 -> 0. #301's 366 edges are fully retired.

So the check is now a HARD GATE. It shipped as a ratchet over 530 findings
(#314) because nothing was fixable without the decisions that have since
landed. `just audit-predicate-domains` now passes --fail-on any, and
conf/predicate_domain_audit_baseline.tsv is deleted rather than kept
empty -- an empty baseline invites a --write-baseline run to refill it.
Canaried: injecting one microbe-domain edge exits 1 with nothing to
forgive it.

The ratchet machinery in the script is deliberately kept, since it is what
makes a future violation class landable the same way this one was.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Ground the second is-hydrolyzed-to edge, and close the gate's back door (PR #332 review)

Three review findings, all valid.

The new `is hydrolyzed to` mapping row also matched
starch_degradation.yaml's `starch --is hydrolyzed to-->
maltooligosaccharides`, a CHEMICAL->CHEMICAL edge with no predicate_id.
Adding the row dropped it from the residual report because the grounding
tool now considered it mapped, but nothing had grounded it -- so it sat
in neither the grounded set nor the backlog. Grounded to RO:0001001; it
is the same substrate-to-product shape as the cellulose edge.

justfile:105 named RO:0002234 (has output) as what the last edge was
re-grounded to. It was RO:0001001 (derives into). The script had it
right, the recipe comment did not.

docs/CURATION_PLAYBOOK.md said "Nothing in `just qc` catches it" and
"#301 tracks the 366 edges that still carry them". Both were true when
written and are now false: the audit is a qc member running --fail-on
any, and the count is 0. Rewritten to describe a mistake the tooling
prevents rather than a backlog to work around, mirroring what #323 did
for the enables paragraph.

Also acted on the 🔵, which was a real hole rather than a nit:
--write-baseline can still recreate the deleted baseline, and a
non-recipe invocation defaulted to --fail-on new, so it would have loaded
that file and passed. Deleting the baseline while leaving the default
permissive only hardened the path that goes through `just`. The default
is now `any`; the ratchet is still available via an explicit
--fail-on new for reintroducing this check over a NEW violation class.

That flip broke test_write_baseline_freezes_then_passes, which had
encoded the old contract -- the test was right to fail. Updated to assert
both halves of the new one, plus a new test_default_fail_on_is_any
pinning that a stray baseline cannot weaken a default run, so the
hardening cannot be undone by an argparse edit.

Canaried both paths: injecting one microbe-domain edge exits 1 via the
recipe AND via a direct no-flag invocation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 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.

Reversing electron edges to enables collapsed the donor/acceptor distinction for 7 nodes

1 participant