From 8e125b4f57a5a734d06d31f5abcf9bbd4aadfaef Mon Sep 17 00:00:00 2001 From: Paul Murff Date: Mon, 7 Sep 2026 20:29:57 -0600 Subject: [PATCH 1/3] test: implement ontology join contract verification and add RHEA support to CURIE resolution. --- ontology-sdk/relation-crosswalk.v1.json | 1570 +++++++++++++---------- tests/test_ontology_join_contract.py | 132 ++ tools/check_curies.py | 48 +- 3 files changed, 1047 insertions(+), 703 deletions(-) create mode 100644 tests/test_ontology_join_contract.py diff --git a/ontology-sdk/relation-crosswalk.v1.json b/ontology-sdk/relation-crosswalk.v1.json index daa7c1a..01a9564 100644 --- a/ontology-sdk/relation-crosswalk.v1.json +++ b/ontology-sdk/relation-crosswalk.v1.json @@ -1,699 +1,879 @@ { - "licence": { - "spdx": "LicenseRef-BAC-Samples-Attribution", - "copyright": "Copyright 2026 Paul Murff (Morf Engineering Inc.)", - "terms": "LICENSE-SAMPLES.md — free reuse with attribution to the repository and the book", - "scope": "Original authored schema of the biology_as_code project. Underlying scientific facts are not claimed." - }, - "schema_version": "1.0.0", - "register": "relation-crosswalk", - "as_of": "2026-09-03", - "what_this_is": "The mapping pass blocker 4 of GROUNDED-OBJECT-MODEL.md asked for. One row per verb in every relation vocabulary this project carries, saying how that verb relates to the eleven-relation base declared in ontology.json (mechanism_schema.py's Relation, the one candidate with an executed domain/range matrix). It is authored, not computed: a verb with no row is unmapped, never silently equivalent. What it is NOT: it does not retire any vocabulary — the claim-language lexicon still parses surface verbs, the graph still stores its ten, nutrient-edge still carries its payloads. It says what each verb becomes on the base and what is lost on the way.", - "base": { - "vocabulary": "mechanism_schema", - "source": "nutri-collective/evidence-platform/src/mechanism_schema.py (Relation, COMPAT)", - "declared_in": "ontology-sdk/ontology.json predicates", - "relations": [ - "CONTAINS", - "CONVERTS_TO", - "INCREASES", - "DECREASES", - "NO_CHANGE", - "MEDIATES", - "ACTIVATES", - "INHIBITS", - "ASSOCIATED_WITH", - "CONSERVES", - "IDENTITY" + "licence": { + "spdx": "LicenseRef-BAC-Samples-Attribution", + "copyright": "Copyright 2026 Paul Murff (Morf Engineering Inc.)", + "terms": "LICENSE-SAMPLES.md — free reuse with attribution to the repository and the book", + "scope": "Original authored schema of the biology_as_code project. Underlying scientific facts are not claimed." + }, + "schema_version": "1.0.0", + "register": "relation-crosswalk", + "as_of": "2026-09-07", + "what_this_is": "The mapping pass blocker 4 of GROUNDED-OBJECT-MODEL.md asked for. One row per verb in every relation vocabulary this project carries, saying how that verb relates to the eleven-relation base declared in ontology.json (mechanism_schema.py's Relation, the one candidate with an executed domain/range matrix). It is authored, not computed: a verb with no row is unmapped, never silently equivalent. What it is NOT: it does not retire any vocabulary — the claim-language lexicon still parses surface verbs, the graph still stores its ten, nutrient-edge still carries its payloads. It says what each verb becomes on the base and what is lost on the way.", + "base": { + "vocabulary": "mechanism_schema", + "source": "nutri-collective/evidence-platform/src/mechanism_schema.py (Relation, COMPAT)", + "declared_in": "ontology-sdk/ontology.json predicates", + "relations": [ + "CONTAINS", + "CONVERTS_TO", + "INCREASES", + "DECREASES", + "NO_CHANGE", + "MEDIATES", + "ACTIVATES", + "INHIBITS", + "ASSOCIATED_WITH", + "CONSERVES", + "IDENTITY" + ] + }, + "fields": { + "relation": "exact — the same relation over the same subject and object. narrower — the source verb is a special case of the base verb (every OPENS_GATE is an ACTIVATES; not every ACTIVATES is a gate). broader — the source verb covers more than the base verb (PRECURSOR_OF covers multi-step CONVERTS_TO chains). related — overlapping constructs where neither contains the other. none — no single base edge over the same endpoints exists; see expansion or reason.", + "rule": "A row is exact / narrower / broader / related ONLY when the source edge (S, verb, O) becomes one base edge over the same S and O; an inverse row swaps them. Anything that needs a reified node or more than one edge is none with an expansion. none with a reason and no expansion is not expressible on the base at all.", + "base": "the base relation, null when relation is none", + "inverse": "true when the base edge runs O → S (FOUND_IN is CONTAINS read backwards)", + "expansion": "for none rows: the multi-edge or reified rewrite that carries the meaning, in base verbs", + "reason": "for none rows with no expansion: why the base cannot carry it", + "loses": "what the base edge does not carry even when the mapping holds — payloads, modality, magnitude", + "ro": "an OBO Relation Ontology id, only where the source vocabulary declares one; never inferred here", + "domain": "ontology-join rows only: the namespace the subject comes from. The base's own typing lives in ontology.json predicates; this records what the OBO relation is typed over where this project uses it.", + "range": "ontology-join rows only: the namespace the object comes from.", + "label": "the label the `ro` id resolved to. Written so tools/check_curies.py can diff it against OLS4 on every run -- a declared id nobody resolves is the failure this project is built against." + }, + "vocabularies": { + "law-model": { + "source": "src/biology_as_code/engine/laws/models.py RelationType", + "role": "what a law may assert", + "members": [ + "OPENS_GATE", + "CLOSES_GATE", + "EXPANDS_BOUND", + "NARROWS_BOUND", + "STATE_FUNCTION", + "FRAMEWORK", + "MIXED", + "CONSERVES", + "IDENTITY" + ], + "note": "STATE_FUNCTION, FRAMEWORK and MIXED assert nothing an edge could carry (NON_STRUCTURAL in tests/test_graph_relation_vocabulary.py)." + }, + "graph": { + "source": "src/biology_as_code/graph/build.py _REL_ALIASES, published as schemas/relation_enums.subset.json, enforced by graph/schema.sql", + "role": "what the graph can store as an edge: the six structural law relations plus four edge kinds the law model has no use for", + "members": [ + "OPENS_GATE", + "CLOSES_GATE", + "EXPANDS_BOUND", + "NARROWS_BOUND", + "CONSERVES", + "IDENTITY", + "COMPETES_WITH", + "PART_OF", + "NEEDS_RESOLUTION", + "MALFORMED_MECHANISM" + ], + "note": "Until 2026-09-03 the published subset's header said 'full list in book/claim-language'. That list lacks CONSERVES and IDENTITY, two of the subset's own members; the header now points at the law model and at this file." + }, + "claim-language": { + "source": "book/claim-language/claim_verbs.json relation_enums", + "role": "the target enum of the surface-verb translator (88 verbs in 9 classes)", + "members": [ + "OPENS_GATE", + "CLOSES_GATE", + "EXPANDS_BOUND", + "NARROWS_BOUND", + "UPREGULATES", + "INHIBITS", + "CATALYZES", + "CLEAVES", + "ACTIVATES", + "SATURATES", + "COMPETES_WITH", + "BOUND_BY", + "PART_OF", + "NEEDS_RESOLUTION", + "MALFORMED_MECHANISM" + ], + "note": "CLOSES_GATE, CLEAVES, SATURATES, BOUND_BY and PART_OF are declared but no verb defaults to them. The RO ids on rows come from this file's class table." + }, + "nutrient-edge": { + "source": "book/nutrient-taxonomy/v2/nutrient-edge.v2.json properties.predicate.enum", + "role": "typed nutrient ↔ nutrient / food / disease edges, each predicate with its own payload (conversion, interaction, occurrence, clinical)", + "members": [ + "VITAMER_OF", + "CONVERTS_TO", + "PRECURSOR_OF", + "INHIBITS_ABSORPTION_OF", + "ENHANCES_ABSORPTION_OF", + "COMPETES_WITH", + "REQUIRES_FOR_ABSORPTION", + "COFACTOR_FOR", + "SUBSTRATE_OF", + "PARTICIPATES_IN", + "FOUND_IN", + "DEFICIENCY_CAUSES", + "DEFICIENCY_CONTRIBUTES_TO", + "EXCESS_CAUSES", + "MARKER_OF", + "THERAPEUTIC_FOR" + ] + }, + "id-crosswalk": { + "source": "nutri-collective/evidence-platform/site/id-crosswalk.v1.json edges[].kind", + "role": "identifier-mapping edges; the register derives a SKOS predicate from strength", + "members": [ + "same_nutrient", + "same_substance", + "same_disease", + "aligns_with", + "at_layer" + ] + }, + "taxonomy-joins": { + "source": "book/taxonomies/joins/nutrition-vocabulary-law-mapping.json and nutrition-index-video-sweep-map.json, field relation", + "role": "term → law joins; neither file declares its set, so the union is mapped and the drift recorded", + "members": [ + "instantiates", + "documents", + "grounds", + "mechanism_of", + "upstream_mechanism_of", + "measures", + "modulates", + "clinical_sign_of" + ], + "drift": { + "only_in_nutrition-vocabulary-law-mapping": [ + "clinical_sign_of", + "measures", + "modulates" + ], + "only_in_nutrition-index-video-sweep-map": [ + "grounds", + "upstream_mechanism_of" ] + } }, - "fields": { - "relation": "exact — the same relation over the same subject and object. narrower — the source verb is a special case of the base verb (every OPENS_GATE is an ACTIVATES; not every ACTIVATES is a gate). broader — the source verb covers more than the base verb (PRECURSOR_OF covers multi-step CONVERTS_TO chains). related — overlapping constructs where neither contains the other. none — no single base edge over the same endpoints exists; see expansion or reason.", - "rule": "A row is exact / narrower / broader / related ONLY when the source edge (S, verb, O) becomes one base edge over the same S and O; an inverse row swaps them. Anything that needs a reified node or more than one edge is none with an expansion. none with a reason and no expansion is not expressible on the base at all.", - "base": "the base relation, null when relation is none", - "inverse": "true when the base edge runs O → S (FOUND_IN is CONTAINS read backwards)", - "expansion": "for none rows: the multi-edge or reified rewrite that carries the meaning, in base verbs", - "reason": "for none rows with no expansion: why the base cannot carry it", - "loses": "what the base edge does not carry even when the mapping holds — payloads, modality, magnitude", - "ro": "an OBO Relation Ontology id, only where the source vocabulary declares one; never inferred here" - }, - "vocabularies": { - "law-model": { - "source": "src/biology_as_code/engine/laws/models.py RelationType", - "role": "what a law may assert", - "members": [ - "OPENS_GATE", - "CLOSES_GATE", - "EXPANDS_BOUND", - "NARROWS_BOUND", - "STATE_FUNCTION", - "FRAMEWORK", - "MIXED", - "CONSERVES", - "IDENTITY" - ], - "note": "STATE_FUNCTION, FRAMEWORK and MIXED assert nothing an edge could carry (NON_STRUCTURAL in tests/test_graph_relation_vocabulary.py)." - }, - "graph": { - "source": "src/biology_as_code/graph/build.py _REL_ALIASES, published as schemas/relation_enums.subset.json, enforced by graph/schema.sql", - "role": "what the graph can store as an edge: the six structural law relations plus four edge kinds the law model has no use for", - "members": [ - "OPENS_GATE", - "CLOSES_GATE", - "EXPANDS_BOUND", - "NARROWS_BOUND", - "CONSERVES", - "IDENTITY", - "COMPETES_WITH", - "PART_OF", - "NEEDS_RESOLUTION", - "MALFORMED_MECHANISM" - ], - "note": "Until 2026-09-03 the published subset's header said 'full list in book/claim-language'. That list lacks CONSERVES and IDENTITY, two of the subset's own members; the header now points at the law model and at this file." - }, - "claim-language": { - "source": "book/claim-language/claim_verbs.json relation_enums", - "role": "the target enum of the surface-verb translator (88 verbs in 9 classes)", - "members": [ - "OPENS_GATE", - "CLOSES_GATE", - "EXPANDS_BOUND", - "NARROWS_BOUND", - "UPREGULATES", - "INHIBITS", - "CATALYZES", - "CLEAVES", - "ACTIVATES", - "SATURATES", - "COMPETES_WITH", - "BOUND_BY", - "PART_OF", - "NEEDS_RESOLUTION", - "MALFORMED_MECHANISM" - ], - "note": "CLOSES_GATE, CLEAVES, SATURATES, BOUND_BY and PART_OF are declared but no verb defaults to them. The RO ids on rows come from this file's class table." - }, - "nutrient-edge": { - "source": "book/nutrient-taxonomy/v2/nutrient-edge.v2.json properties.predicate.enum", - "role": "typed nutrient ↔ nutrient / food / disease edges, each predicate with its own payload (conversion, interaction, occurrence, clinical)", - "members": [ - "VITAMER_OF", - "CONVERTS_TO", - "PRECURSOR_OF", - "INHIBITS_ABSORPTION_OF", - "ENHANCES_ABSORPTION_OF", - "COMPETES_WITH", - "REQUIRES_FOR_ABSORPTION", - "COFACTOR_FOR", - "SUBSTRATE_OF", - "PARTICIPATES_IN", - "FOUND_IN", - "DEFICIENCY_CAUSES", - "DEFICIENCY_CONTRIBUTES_TO", - "EXCESS_CAUSES", - "MARKER_OF", - "THERAPEUTIC_FOR" - ] - }, - "id-crosswalk": { - "source": "nutri-collective/evidence-platform/site/id-crosswalk.v1.json edges[].kind", - "role": "identifier-mapping edges; the register derives a SKOS predicate from strength", - "members": [ - "same_nutrient", - "same_substance", - "same_disease", - "aligns_with", - "at_layer" - ] - }, - "taxonomy-joins": { - "source": "book/taxonomies/joins/nutrition-vocabulary-law-mapping.json and nutrition-index-video-sweep-map.json, field relation", - "role": "term → law joins; neither file declares its set, so the union is mapped and the drift recorded", - "members": [ - "instantiates", - "documents", - "grounds", - "mechanism_of", - "upstream_mechanism_of", - "measures", - "modulates", - "clinical_sign_of" - ], - "drift": { - "only_in_nutrition-vocabulary-law-mapping": [ - "clinical_sign_of", - "measures", - "modulates" - ], - "only_in_nutrition-index-video-sweep-map": [ - "grounds", - "upstream_mechanism_of" - ] - } - } - }, - "counts": { - "total": { - "broader": 3, - "exact": 11, - "narrower": 17, - "none": 15, - "none_with_expansion": 16, - "related": 1, - "rows": 63 - }, - "by_vocabulary": { - "law-model": { - "exact": 2, - "narrower": 4, - "none": 3, - "rows": 9 - }, - "graph": { - "broader": 1, - "exact": 2, - "narrower": 4, - "none": 2, - "none_with_expansion": 1, - "rows": 10 - }, - "claim-language": { - "broader": 1, - "exact": 2, - "narrower": 6, - "none": 3, - "none_with_expansion": 3, - "rows": 15 - }, - "nutrient-edge": { - "broader": 1, - "exact": 2, - "narrower": 3, - "none": 2, - "none_with_expansion": 8, - "rows": 16 - }, - "id-crosswalk": { - "exact": 3, - "none": 1, - "related": 1, - "rows": 5 - }, - "taxonomy-joins": { - "none": 4, - "none_with_expansion": 4, - "rows": 8 - } - } - }, - "verdict": { - "question": "GROUNDED-OBJECT-MODEL.md §8 blocker 4: can nutrient-edge.v2's 16 be expressed in the 11?", - "answer": "6 directly (exact / narrower / broader), 8 by expansion over a reified node, 2 not at all (VITAMER_OF, PARTICIPATES_IN). Every expansion drops the predicate's payload.", - "base_is_settled": "mechanism_schema's 11 are the base. Nothing else is retired: each vocabulary keeps its job and its rows here say what it becomes on the base." - }, - "gaps": { - "what_the_base_cannot_carry": { - "modality": "necessity and categorical gates (OPENS_GATE, CLOSES_GATE, REQUIRES_FOR_ABSORPTION, the necessity in COFACTOR_FOR)", - "bounds_and_magnitudes": "bound-vs-level, fold-change, conversion factors, occurrence amounts, interaction parameters", - "symmetric_interaction": "COMPETES_WITH", - "binding": "BOUND_BY, the enzyme–substrate bond in SUBSTRATE_OF", - "saturation": "SATURATES — a threshold on a bound", - "taxonomic_membership": "VITAMER_OF — lives in Nutrient.node_kind, not on an edge", - "undirected_participation": "PARTICIPATES_IN — ASSOCIATED_WITH would demote it", - "compound_as_object_of_regulation": "CLEAVES, SUBSTRATE_OF — only CONTAINS and CONVERTS_TO have a compound in range", - "deficiency_and_excess_states": "DEFICIENCY_CAUSES, DEFICIENCY_CONTRIBUTES_TO, EXCESS_CAUSES — need a reified state node as subject", - "measurement": "measures", - "law_as_object": "every taxonomy-join verb — the object is a law, not an entity kind" - }, - "next": "Any of these becomes a manifest change (a new predicate or object type) only through a decision-ledger row, never by extending the base in place." - }, - "rows": [ - { - "vocabulary": "law-model", - "verb": "OPENS_GATE", - "relation": "narrower", - "base": "ACTIVATES", - "ro": "RO:0002333", - "loses": "necessity: the base has no modality, so 'without X the process does not occur' becomes 'X activates the process'", - "note": "when the subject is itself a process (chylomicron_assembly OPENS_GATE lymph_export) ACTIVATES' domain excludes it and the base types the edge only as INCREASES" - }, - { - "vocabulary": "law-model", - "verb": "CLOSES_GATE", - "relation": "narrower", - "base": "INHIBITS", - "loses": "necessity (a categorical block); a process subject types only as DECREASES" - }, - { - "vocabulary": "law-model", - "verb": "EXPANDS_BOUND", - "relation": "narrower", - "base": "INCREASES", - "ro": "RO:0002213", - "loses": "that the object is a bound rather than a level, and the fold-change magnitude the law model carries" - }, - { - "vocabulary": "law-model", - "verb": "NARROWS_BOUND", - "relation": "narrower", - "base": "DECREASES", - "ro": "RO:0002212", - "loses": "that the object is a bound rather than a level, and the fold-change magnitude" - }, - { - "vocabulary": "law-model", - "verb": "CONSERVES", - "relation": "exact", - "base": "CONSERVES", - "note": "mechanism_schema added CONSERVES for this vocabulary; same meaning on both sides" - }, - { - "vocabulary": "law-model", - "verb": "IDENTITY", - "relation": "exact", - "base": "IDENTITY" - }, - { - "vocabulary": "law-model", - "verb": "STATE_FUNCTION", - "relation": "none", - "base": null, - "reason": "describes a property of a state, not a directed relation (NON_STRUCTURAL in tests/test_graph_relation_vocabulary.py)" - }, - { - "vocabulary": "law-model", - "verb": "FRAMEWORK", - "relation": "none", - "base": null, - "reason": "says the law is not executable; it asserts no edge" - }, - { - "vocabulary": "law-model", - "verb": "MIXED", - "relation": "none", - "base": null, - "reason": "the registry's could-not-determine fallback" - }, - { - "vocabulary": "graph", - "verb": "OPENS_GATE", - "relation": "narrower", - "base": "ACTIVATES", - "ro": "RO:0002333", - "loses": "necessity: the base has no modality, so 'without X the process does not occur' becomes 'X activates the process'", - "note": "when the subject is itself a process (chylomicron_assembly OPENS_GATE lymph_export) ACTIVATES' domain excludes it and the base types the edge only as INCREASES" - }, - { - "vocabulary": "graph", - "verb": "CLOSES_GATE", - "relation": "narrower", - "base": "INHIBITS", - "loses": "necessity (a categorical block); a process subject types only as DECREASES" - }, - { - "vocabulary": "graph", - "verb": "EXPANDS_BOUND", - "relation": "narrower", - "base": "INCREASES", - "ro": "RO:0002213", - "loses": "that the object is a bound rather than a level, and the fold-change magnitude the law model carries" - }, - { - "vocabulary": "graph", - "verb": "NARROWS_BOUND", - "relation": "narrower", - "base": "DECREASES", - "ro": "RO:0002212", - "loses": "that the object is a bound rather than a level, and the fold-change magnitude" - }, - { - "vocabulary": "graph", - "verb": "CONSERVES", - "relation": "exact", - "base": "CONSERVES", - "note": "mechanism_schema added CONSERVES for this vocabulary; same meaning on both sides" - }, - { - "vocabulary": "graph", - "verb": "IDENTITY", - "relation": "exact", - "base": "IDENTITY" - }, - { - "vocabulary": "graph", - "verb": "COMPETES_WITH", - "relation": "none", - "base": null, - "expansion": "A INHIBITS absorption-of-B and B INHIBITS absorption-of-A: two edges, each over a reified absorption process", - "loses": "symmetry — the base has no undirected interaction verb" - }, - { - "vocabulary": "graph", - "verb": "PART_OF", - "relation": "broader", - "base": "CONTAINS", - "inverse": true, - "loses": "everything but the food case: a compound part_of a food is CONTAINS read backwards; a process or organ part_of has no base verb" - }, - { - "vocabulary": "graph", - "verb": "NEEDS_RESOLUTION", - "relation": "none", - "base": null, - "reason": "an epistemic state of the claim (soft, marketing, hedge), not a relation between two things" - }, - { - "vocabulary": "graph", - "verb": "MALFORMED_MECHANISM", - "relation": "none", - "base": null, - "reason": "a parse failure: the surface verb named no hardware operation" - }, - { - "vocabulary": "claim-language", - "verb": "OPENS_GATE", - "relation": "narrower", - "base": "ACTIVATES", - "ro": "RO:0002333", - "loses": "necessity: the base has no modality, so 'without X the process does not occur' becomes 'X activates the process'", - "note": "when the subject is itself a process (chylomicron_assembly OPENS_GATE lymph_export) ACTIVATES' domain excludes it and the base types the edge only as INCREASES" - }, - { - "vocabulary": "claim-language", - "verb": "CLOSES_GATE", - "relation": "narrower", - "base": "INHIBITS", - "loses": "necessity (a categorical block); a process subject types only as DECREASES" - }, - { - "vocabulary": "claim-language", - "verb": "EXPANDS_BOUND", - "relation": "narrower", - "base": "INCREASES", - "ro": "RO:0002213", - "loses": "that the object is a bound rather than a level, and the fold-change magnitude the law model carries" - }, - { - "vocabulary": "claim-language", - "verb": "NARROWS_BOUND", - "relation": "narrower", - "base": "DECREASES", - "ro": "RO:0002212", - "loses": "that the object is a bound rather than a level, and the fold-change magnitude" - }, - { - "vocabulary": "claim-language", - "verb": "COMPETES_WITH", - "relation": "none", - "base": null, - "expansion": "A INHIBITS absorption-of-B and B INHIBITS absorption-of-A: two edges, each over a reified absorption process", - "loses": "symmetry — the base has no undirected interaction verb" - }, - { - "vocabulary": "claim-language", - "verb": "PART_OF", - "relation": "broader", - "base": "CONTAINS", - "inverse": true, - "loses": "everything but the food case: a compound part_of a food is CONTAINS read backwards; a process or organ part_of has no base verb" - }, - { - "vocabulary": "claim-language", - "verb": "NEEDS_RESOLUTION", - "relation": "none", - "base": null, - "reason": "an epistemic state of the claim (soft, marketing, hedge), not a relation between two things" - }, - { - "vocabulary": "claim-language", - "verb": "MALFORMED_MECHANISM", - "relation": "none", - "base": null, - "reason": "a parse failure: the surface verb named no hardware operation" - }, - { - "vocabulary": "claim-language", - "verb": "UPREGULATES", - "relation": "narrower", - "base": "ACTIVATES", - "loses": "that the mechanism is expression; INCREASES is the base's alternative when the object is a measured level rather than a gene" - }, - { - "vocabulary": "claim-language", - "verb": "INHIBITS", - "relation": "exact", - "base": "INHIBITS", - "note": "the base types the object as a gene/enzyme or a process only; 'inhibits absorption of Y' is the nutrient-edge row, not this one" - }, - { - "vocabulary": "claim-language", - "verb": "CATALYZES", - "relation": "narrower", - "base": "INCREASES", - "loses": "catalysis itself: the enzyme is not consumed and sets no direction of its own; the base can only say the reaction's rate goes up (gene_enzyme → process)" - }, - { - "vocabulary": "claim-language", - "verb": "CLEAVES", - "relation": "none", - "base": null, - "expansion": "substrate CONVERTS_TO products; the enzyme INCREASES that conversion once it is a process node", - "loses": "a compound as the object of an enzyme's action — nothing in the base has a compound in range except CONTAINS and CONVERTS_TO" - }, - { - "vocabulary": "claim-language", - "verb": "ACTIVATES", - "relation": "exact", - "base": "ACTIVATES" - }, - { - "vocabulary": "claim-language", - "verb": "SATURATES", - "relation": "none", - "base": null, - "reason": "kinetic saturation is a bound being reached; the base has no bound object and no threshold" - }, - { - "vocabulary": "claim-language", - "verb": "BOUND_BY", - "relation": "none", - "base": null, - "expansion": "the binder DECREASES free-X, with the free pool as a reified biomarker node", - "loses": "binding as such" - }, - { - "vocabulary": "nutrient-edge", - "verb": "VITAMER_OF", - "relation": "none", - "base": null, - "reason": "taxonomic membership (species → group): read Nutrient.node_kind off the taxonomy. IDENTITY would over-claim equivalence — vitamers differ in bioavailability, which is what the conversion payload is for" - }, - { - "vocabulary": "nutrient-edge", - "verb": "CONVERTS_TO", - "relation": "exact", - "base": "CONVERTS_TO", - "loses": "the conversion payload (factor, basis, citation) — the base edge carries no quantity" - }, - { - "vocabulary": "nutrient-edge", - "verb": "PRECURSOR_OF", - "relation": "broader", - "base": "CONVERTS_TO", - "loses": "the step count; a chain of CONVERTS_TO edges expresses it exactly" - }, - { - "vocabulary": "nutrient-edge", - "verb": "INHIBITS_ABSORPTION_OF", - "relation": "none", - "base": null, - "expansion": "X INHIBITS absorption-of-Y, with the absorption as a reified process node", - "loses": "the interaction payload (named mechanism, computable parameters)" - }, - { - "vocabulary": "nutrient-edge", - "verb": "ENHANCES_ABSORPTION_OF", - "relation": "none", - "base": null, - "expansion": "X INCREASES absorption-of-Y, with the absorption as a reified process node", - "loses": "the interaction payload (named mechanism, computable parameters)" - }, - { - "vocabulary": "nutrient-edge", - "verb": "COMPETES_WITH", - "relation": "none", - "base": null, - "expansion": "A INHIBITS absorption-of-B and B INHIBITS absorption-of-A: two edges, each over a reified absorption process", - "loses": "symmetry — the base has no undirected interaction verb" - }, - { - "vocabulary": "nutrient-edge", - "verb": "REQUIRES_FOR_ABSORPTION", - "relation": "none", - "base": null, - "expansion": "X ACTIVATES absorption-of-Y, with the absorption as a reified process node", - "loses": "necessity" - }, - { - "vocabulary": "nutrient-edge", - "verb": "COFACTOR_FOR", - "relation": "narrower", - "base": "ACTIVATES", - "loses": "necessity: a cofactor is required, ACTIVATES only says it enables" - }, - { - "vocabulary": "nutrient-edge", - "verb": "SUBSTRATE_OF", - "relation": "none", - "base": null, - "expansion": "X CONVERTS_TO product; the enzyme INCREASES that conversion once it is a process node", - "loses": "the enzyme–substrate binding; a compound cannot be the object of an enzyme's action in the base" - }, - { - "vocabulary": "nutrient-edge", - "verb": "PARTICIPATES_IN", - "relation": "none", - "base": null, - "reason": "undirected participation; the base's only undirected verb is ASSOCIATED_WITH, which is excluded from causal paths and would demote a mechanistic fact to a statistical one" - }, - { - "vocabulary": "nutrient-edge", - "verb": "FOUND_IN", - "relation": "exact", - "base": "CONTAINS", - "inverse": true, - "loses": "the occurrence payload (amount per food, the FDC join)" - }, - { - "vocabulary": "nutrient-edge", - "verb": "DEFICIENCY_CAUSES", - "relation": "none", - "base": null, - "expansion": "deficiency-of-X INCREASES D, with the deficiency state as a reified biomarker node. 'X DECREASES D' over the same endpoints is the lossy form: it turns a threshold claim into a dose-response one", - "loses": "the threshold, and the clinical payload" - }, - { - "vocabulary": "nutrient-edge", - "verb": "DEFICIENCY_CONTRIBUTES_TO", - "relation": "none", - "base": null, - "expansion": "deficiency-of-X INCREASES D, with the deficiency state as a reified biomarker node", - "loses": "partial causation: the base has no 'contributes'; its evidence class (measured / inferred / cited) is a different axis" - }, - { - "vocabulary": "nutrient-edge", - "verb": "EXCESS_CAUSES", - "relation": "none", - "base": null, - "expansion": "excess-of-X INCREASES D, with the excess state as a reified biomarker node; 'X INCREASES D' is the lossy same-endpoint form", - "loses": "the threshold, and the clinical payload" - }, - { - "vocabulary": "nutrient-edge", - "verb": "MARKER_OF", - "relation": "narrower", - "base": "ASSOCIATED_WITH", - "loses": "the diagnostic role; ASSOCIATED_WITH is excluded from causal paths, which is the right reading of a marker" - }, - { - "vocabulary": "nutrient-edge", - "verb": "THERAPEUTIC_FOR", - "relation": "narrower", - "base": "DECREASES", - "loses": "clinical intent and dose; the base has no treatment verb" - }, - { - "vocabulary": "id-crosswalk", - "verb": "same_nutrient", - "relation": "exact", - "base": "IDENTITY", - "note": "confidence rides on strength / review_tier, which the register turns into skos:exactMatch or closeMatch; the base verb does not carry it" - }, - { - "vocabulary": "id-crosswalk", - "verb": "same_substance", - "relation": "exact", - "base": "IDENTITY" - }, - { - "vocabulary": "id-crosswalk", - "verb": "same_disease", - "relation": "exact", - "base": "IDENTITY" - }, - { - "vocabulary": "id-crosswalk", - "verb": "aligns_with", - "relation": "related", - "base": "IDENTITY", - "note": "skos:relatedMatch by the register's own rule; two things that align are not the same thing" - }, - { - "vocabulary": "id-crosswalk", - "verb": "at_layer", - "relation": "none", - "base": null, - "reason": "a classification edge, not a mapping — the register says so itself" - }, - { - "vocabulary": "taxonomy-joins", - "verb": "instantiates", - "relation": "none", - "base": null, - "reason": "a documentation join: a vocabulary term instantiates a law's subject; the object is a law, not an entity" - }, - { - "vocabulary": "taxonomy-joins", - "verb": "documents", - "relation": "none", - "base": null, - "reason": "a documentation join: a term is documented by a law" - }, - { - "vocabulary": "taxonomy-joins", - "verb": "grounds", - "relation": "none", - "base": null, - "reason": "a documentation join: a law grounded in a principle" - }, - { - "vocabulary": "taxonomy-joins", - "verb": "mechanism_of", - "relation": "none", - "base": null, - "expansion": "process MEDIATES disease, once the law's outcome is named as a disease node", - "loses": "the law as object" - }, - { - "vocabulary": "taxonomy-joins", - "verb": "upstream_mechanism_of", - "relation": "none", - "base": null, - "expansion": "a chain: process INCREASES or DECREASES an intermediate, which MEDIATES the disease", - "loses": "the law as object" - }, - { - "vocabulary": "taxonomy-joins", - "verb": "measures", - "relation": "none", - "base": null, - "reason": "an instrument relation (waist circumference measures adiposity); the base has no measurement verb" - }, - { - "vocabulary": "taxonomy-joins", - "verb": "modulates", - "relation": "none", - "base": null, - "expansion": "INCREASES or DECREASES once a sign is given; the base has no unsigned directional verb", - "loses": "the withheld sign" - }, - { - "vocabulary": "taxonomy-joins", - "verb": "clinical_sign_of", - "relation": "none", - "base": null, - "expansion": "sign ASSOCIATED_WITH disease — the MARKER_OF row — once the law's disease is a node", - "loses": "the law as object" - } - ] + "ontology-join": { + "source": "OBO Relation Ontology (RO) and BFO, as used by GO, FoodOn, ChEBI and CDNO. Not a file in this project.", + "role": "how this project's namespaces join to each other. Every other vocabulary here is a CLAIM vocabulary — what a law may assert, what an edge may carry. This one is the JOIN GRAMMAR: which predicate may run between which two namespaces. That is why almost every row is `none` on the base, and the `none` is the finding rather than a shortfall — the base is eleven ways to make a claim about biology, and a join is not a claim.", + "why_it_exists": "There is no FoodOn-to-GO map and there is not going to be one. A food reaches a gene function through a molecule or a gene product or it does not reach it at all. Without this list the shortcut is available and undocumented: assert that a food IS a process because a review said it was anti-inflammatory. See `forbidden` below.", + "members": [ + "derives_from", + "enables", + "has_component", + "has_input", + "has_output", + "has_participant", + "has_role", + "in_taxon", + "involved_in", + "occurs_in", + "participates_in" + ], + "note": "Every `ro` id below was resolved against OLS4 on 2026-09-07 and carries the label that resolution returned, so tools/check_curies.py re-checks this list on every run. BFO:0000066 is the reason that gate now tries more than one ontology per prefix: OLS4's `bfo` namespace serves neither `occurs in` nor `part of`, and the gate would have called a correct relation nonexistent." + } + }, + "counts": { + "total": { + "broader": 3, + "exact": 12, + "narrower": 17, + "none": 25, + "none_with_expansion": 16, + "related": 1, + "rows": 74 + }, + "by_vocabulary": { + "law-model": { + "exact": 2, + "narrower": 4, + "none": 3, + "rows": 9 + }, + "graph": { + "broader": 1, + "exact": 2, + "narrower": 4, + "none": 2, + "none_with_expansion": 1, + "rows": 10 + }, + "claim-language": { + "broader": 1, + "exact": 2, + "narrower": 6, + "none": 3, + "none_with_expansion": 3, + "rows": 15 + }, + "nutrient-edge": { + "broader": 1, + "exact": 2, + "narrower": 3, + "none": 2, + "none_with_expansion": 8, + "rows": 16 + }, + "id-crosswalk": { + "exact": 3, + "none": 1, + "related": 1, + "rows": 5 + }, + "taxonomy-joins": { + "none": 4, + "none_with_expansion": 4, + "rows": 8 + }, + "ontology-join": { + "exact": 1, + "none": 10, + "rows": 11 + } + } + }, + "verdict": { + "question": "GROUNDED-OBJECT-MODEL.md §8 blocker 4: can nutrient-edge.v2's 16 be expressed in the 11?", + "answer": "6 directly (exact / narrower / broader), 8 by expansion over a reified node, 2 not at all (VITAMER_OF, PARTICIPATES_IN). Every expansion drops the predicate's payload.", + "base_is_settled": "mechanism_schema's 11 are the base. Nothing else is retired: each vocabulary keeps its job and its rows here say what it becomes on the base." + }, + "gaps": { + "what_the_base_cannot_carry": { + "modality": "necessity and categorical gates (OPENS_GATE, CLOSES_GATE, REQUIRES_FOR_ABSORPTION, the necessity in COFACTOR_FOR)", + "bounds_and_magnitudes": "bound-vs-level, fold-change, conversion factors, occurrence amounts, interaction parameters", + "symmetric_interaction": "COMPETES_WITH", + "binding": "BOUND_BY, the enzyme–substrate bond in SUBSTRATE_OF", + "saturation": "SATURATES — a threshold on a bound", + "taxonomic_membership": "VITAMER_OF — lives in Nutrient.node_kind, not on an edge", + "undirected_participation": "PARTICIPATES_IN — ASSOCIATED_WITH would demote it", + "compound_as_object_of_regulation": "CLEAVES, SUBSTRATE_OF — only CONTAINS and CONVERTS_TO have a compound in range", + "deficiency_and_excess_states": "DEFICIENCY_CAUSES, DEFICIENCY_CONTRIBUTES_TO, EXCESS_CAUSES — need a reified state node as subject", + "measurement": "measures", + "law_as_object": "every taxonomy-join verb — the object is a law, not an entity kind" + }, + "next": "Any of these becomes a manifest change (a new predicate or object type) only through a decision-ledger row, never by extending the base in place." + }, + "rows": [ + { + "vocabulary": "law-model", + "verb": "OPENS_GATE", + "relation": "narrower", + "base": "ACTIVATES", + "ro": "RO:0002333", + "loses": "necessity: the base has no modality, so 'without X the process does not occur' becomes 'X activates the process'", + "note": "when the subject is itself a process (chylomicron_assembly OPENS_GATE lymph_export) ACTIVATES' domain excludes it and the base types the edge only as INCREASES" + }, + { + "vocabulary": "law-model", + "verb": "CLOSES_GATE", + "relation": "narrower", + "base": "INHIBITS", + "loses": "necessity (a categorical block); a process subject types only as DECREASES" + }, + { + "vocabulary": "law-model", + "verb": "EXPANDS_BOUND", + "relation": "narrower", + "base": "INCREASES", + "ro": "RO:0002213", + "loses": "that the object is a bound rather than a level, and the fold-change magnitude the law model carries" + }, + { + "vocabulary": "law-model", + "verb": "NARROWS_BOUND", + "relation": "narrower", + "base": "DECREASES", + "ro": "RO:0002212", + "loses": "that the object is a bound rather than a level, and the fold-change magnitude" + }, + { + "vocabulary": "law-model", + "verb": "CONSERVES", + "relation": "exact", + "base": "CONSERVES", + "note": "mechanism_schema added CONSERVES for this vocabulary; same meaning on both sides" + }, + { + "vocabulary": "law-model", + "verb": "IDENTITY", + "relation": "exact", + "base": "IDENTITY" + }, + { + "vocabulary": "law-model", + "verb": "STATE_FUNCTION", + "relation": "none", + "base": null, + "reason": "describes a property of a state, not a directed relation (NON_STRUCTURAL in tests/test_graph_relation_vocabulary.py)" + }, + { + "vocabulary": "law-model", + "verb": "FRAMEWORK", + "relation": "none", + "base": null, + "reason": "says the law is not executable; it asserts no edge" + }, + { + "vocabulary": "law-model", + "verb": "MIXED", + "relation": "none", + "base": null, + "reason": "the registry's could-not-determine fallback" + }, + { + "vocabulary": "graph", + "verb": "OPENS_GATE", + "relation": "narrower", + "base": "ACTIVATES", + "ro": "RO:0002333", + "loses": "necessity: the base has no modality, so 'without X the process does not occur' becomes 'X activates the process'", + "note": "when the subject is itself a process (chylomicron_assembly OPENS_GATE lymph_export) ACTIVATES' domain excludes it and the base types the edge only as INCREASES" + }, + { + "vocabulary": "graph", + "verb": "CLOSES_GATE", + "relation": "narrower", + "base": "INHIBITS", + "loses": "necessity (a categorical block); a process subject types only as DECREASES" + }, + { + "vocabulary": "graph", + "verb": "EXPANDS_BOUND", + "relation": "narrower", + "base": "INCREASES", + "ro": "RO:0002213", + "loses": "that the object is a bound rather than a level, and the fold-change magnitude the law model carries" + }, + { + "vocabulary": "graph", + "verb": "NARROWS_BOUND", + "relation": "narrower", + "base": "DECREASES", + "ro": "RO:0002212", + "loses": "that the object is a bound rather than a level, and the fold-change magnitude" + }, + { + "vocabulary": "graph", + "verb": "CONSERVES", + "relation": "exact", + "base": "CONSERVES", + "note": "mechanism_schema added CONSERVES for this vocabulary; same meaning on both sides" + }, + { + "vocabulary": "graph", + "verb": "IDENTITY", + "relation": "exact", + "base": "IDENTITY" + }, + { + "vocabulary": "graph", + "verb": "COMPETES_WITH", + "relation": "none", + "base": null, + "expansion": "A INHIBITS absorption-of-B and B INHIBITS absorption-of-A: two edges, each over a reified absorption process", + "loses": "symmetry — the base has no undirected interaction verb" + }, + { + "vocabulary": "graph", + "verb": "PART_OF", + "relation": "broader", + "base": "CONTAINS", + "inverse": true, + "loses": "everything but the food case: a compound part_of a food is CONTAINS read backwards; a process or organ part_of has no base verb" + }, + { + "vocabulary": "graph", + "verb": "NEEDS_RESOLUTION", + "relation": "none", + "base": null, + "reason": "an epistemic state of the claim (soft, marketing, hedge), not a relation between two things" + }, + { + "vocabulary": "graph", + "verb": "MALFORMED_MECHANISM", + "relation": "none", + "base": null, + "reason": "a parse failure: the surface verb named no hardware operation" + }, + { + "vocabulary": "claim-language", + "verb": "OPENS_GATE", + "relation": "narrower", + "base": "ACTIVATES", + "ro": "RO:0002333", + "loses": "necessity: the base has no modality, so 'without X the process does not occur' becomes 'X activates the process'", + "note": "when the subject is itself a process (chylomicron_assembly OPENS_GATE lymph_export) ACTIVATES' domain excludes it and the base types the edge only as INCREASES" + }, + { + "vocabulary": "claim-language", + "verb": "CLOSES_GATE", + "relation": "narrower", + "base": "INHIBITS", + "loses": "necessity (a categorical block); a process subject types only as DECREASES" + }, + { + "vocabulary": "claim-language", + "verb": "EXPANDS_BOUND", + "relation": "narrower", + "base": "INCREASES", + "ro": "RO:0002213", + "loses": "that the object is a bound rather than a level, and the fold-change magnitude the law model carries" + }, + { + "vocabulary": "claim-language", + "verb": "NARROWS_BOUND", + "relation": "narrower", + "base": "DECREASES", + "ro": "RO:0002212", + "loses": "that the object is a bound rather than a level, and the fold-change magnitude" + }, + { + "vocabulary": "claim-language", + "verb": "COMPETES_WITH", + "relation": "none", + "base": null, + "expansion": "A INHIBITS absorption-of-B and B INHIBITS absorption-of-A: two edges, each over a reified absorption process", + "loses": "symmetry — the base has no undirected interaction verb" + }, + { + "vocabulary": "claim-language", + "verb": "PART_OF", + "relation": "broader", + "base": "CONTAINS", + "inverse": true, + "loses": "everything but the food case: a compound part_of a food is CONTAINS read backwards; a process or organ part_of has no base verb" + }, + { + "vocabulary": "claim-language", + "verb": "NEEDS_RESOLUTION", + "relation": "none", + "base": null, + "reason": "an epistemic state of the claim (soft, marketing, hedge), not a relation between two things" + }, + { + "vocabulary": "claim-language", + "verb": "MALFORMED_MECHANISM", + "relation": "none", + "base": null, + "reason": "a parse failure: the surface verb named no hardware operation" + }, + { + "vocabulary": "claim-language", + "verb": "UPREGULATES", + "relation": "narrower", + "base": "ACTIVATES", + "loses": "that the mechanism is expression; INCREASES is the base's alternative when the object is a measured level rather than a gene" + }, + { + "vocabulary": "claim-language", + "verb": "INHIBITS", + "relation": "exact", + "base": "INHIBITS", + "note": "the base types the object as a gene/enzyme or a process only; 'inhibits absorption of Y' is the nutrient-edge row, not this one" + }, + { + "vocabulary": "claim-language", + "verb": "CATALYZES", + "relation": "narrower", + "base": "INCREASES", + "loses": "catalysis itself: the enzyme is not consumed and sets no direction of its own; the base can only say the reaction's rate goes up (gene_enzyme → process)" + }, + { + "vocabulary": "claim-language", + "verb": "CLEAVES", + "relation": "none", + "base": null, + "expansion": "substrate CONVERTS_TO products; the enzyme INCREASES that conversion once it is a process node", + "loses": "a compound as the object of an enzyme's action — nothing in the base has a compound in range except CONTAINS and CONVERTS_TO" + }, + { + "vocabulary": "claim-language", + "verb": "ACTIVATES", + "relation": "exact", + "base": "ACTIVATES" + }, + { + "vocabulary": "claim-language", + "verb": "SATURATES", + "relation": "none", + "base": null, + "reason": "kinetic saturation is a bound being reached; the base has no bound object and no threshold" + }, + { + "vocabulary": "claim-language", + "verb": "BOUND_BY", + "relation": "none", + "base": null, + "expansion": "the binder DECREASES free-X, with the free pool as a reified biomarker node", + "loses": "binding as such" + }, + { + "vocabulary": "nutrient-edge", + "verb": "VITAMER_OF", + "relation": "none", + "base": null, + "reason": "taxonomic membership (species → group): read Nutrient.node_kind off the taxonomy. IDENTITY would over-claim equivalence — vitamers differ in bioavailability, which is what the conversion payload is for" + }, + { + "vocabulary": "nutrient-edge", + "verb": "CONVERTS_TO", + "relation": "exact", + "base": "CONVERTS_TO", + "loses": "the conversion payload (factor, basis, citation) — the base edge carries no quantity" + }, + { + "vocabulary": "nutrient-edge", + "verb": "PRECURSOR_OF", + "relation": "broader", + "base": "CONVERTS_TO", + "loses": "the step count; a chain of CONVERTS_TO edges expresses it exactly" + }, + { + "vocabulary": "nutrient-edge", + "verb": "INHIBITS_ABSORPTION_OF", + "relation": "none", + "base": null, + "expansion": "X INHIBITS absorption-of-Y, with the absorption as a reified process node", + "loses": "the interaction payload (named mechanism, computable parameters)" + }, + { + "vocabulary": "nutrient-edge", + "verb": "ENHANCES_ABSORPTION_OF", + "relation": "none", + "base": null, + "expansion": "X INCREASES absorption-of-Y, with the absorption as a reified process node", + "loses": "the interaction payload (named mechanism, computable parameters)" + }, + { + "vocabulary": "nutrient-edge", + "verb": "COMPETES_WITH", + "relation": "none", + "base": null, + "expansion": "A INHIBITS absorption-of-B and B INHIBITS absorption-of-A: two edges, each over a reified absorption process", + "loses": "symmetry — the base has no undirected interaction verb" + }, + { + "vocabulary": "nutrient-edge", + "verb": "REQUIRES_FOR_ABSORPTION", + "relation": "none", + "base": null, + "expansion": "X ACTIVATES absorption-of-Y, with the absorption as a reified process node", + "loses": "necessity" + }, + { + "vocabulary": "nutrient-edge", + "verb": "COFACTOR_FOR", + "relation": "narrower", + "base": "ACTIVATES", + "loses": "necessity: a cofactor is required, ACTIVATES only says it enables" + }, + { + "vocabulary": "nutrient-edge", + "verb": "SUBSTRATE_OF", + "relation": "none", + "base": null, + "expansion": "X CONVERTS_TO product; the enzyme INCREASES that conversion once it is a process node", + "loses": "the enzyme–substrate binding; a compound cannot be the object of an enzyme's action in the base" + }, + { + "vocabulary": "nutrient-edge", + "verb": "PARTICIPATES_IN", + "relation": "none", + "base": null, + "reason": "undirected participation; the base's only undirected verb is ASSOCIATED_WITH, which is excluded from causal paths and would demote a mechanistic fact to a statistical one" + }, + { + "vocabulary": "nutrient-edge", + "verb": "FOUND_IN", + "relation": "exact", + "base": "CONTAINS", + "inverse": true, + "loses": "the occurrence payload (amount per food, the FDC join)" + }, + { + "vocabulary": "nutrient-edge", + "verb": "DEFICIENCY_CAUSES", + "relation": "none", + "base": null, + "expansion": "deficiency-of-X INCREASES D, with the deficiency state as a reified biomarker node. 'X DECREASES D' over the same endpoints is the lossy form: it turns a threshold claim into a dose-response one", + "loses": "the threshold, and the clinical payload" + }, + { + "vocabulary": "nutrient-edge", + "verb": "DEFICIENCY_CONTRIBUTES_TO", + "relation": "none", + "base": null, + "expansion": "deficiency-of-X INCREASES D, with the deficiency state as a reified biomarker node", + "loses": "partial causation: the base has no 'contributes'; its evidence class (measured / inferred / cited) is a different axis" + }, + { + "vocabulary": "nutrient-edge", + "verb": "EXCESS_CAUSES", + "relation": "none", + "base": null, + "expansion": "excess-of-X INCREASES D, with the excess state as a reified biomarker node; 'X INCREASES D' is the lossy same-endpoint form", + "loses": "the threshold, and the clinical payload" + }, + { + "vocabulary": "nutrient-edge", + "verb": "MARKER_OF", + "relation": "narrower", + "base": "ASSOCIATED_WITH", + "loses": "the diagnostic role; ASSOCIATED_WITH is excluded from causal paths, which is the right reading of a marker" + }, + { + "vocabulary": "nutrient-edge", + "verb": "THERAPEUTIC_FOR", + "relation": "narrower", + "base": "DECREASES", + "loses": "clinical intent and dose; the base has no treatment verb" + }, + { + "vocabulary": "id-crosswalk", + "verb": "same_nutrient", + "relation": "exact", + "base": "IDENTITY", + "note": "confidence rides on strength / review_tier, which the register turns into skos:exactMatch or closeMatch; the base verb does not carry it" + }, + { + "vocabulary": "id-crosswalk", + "verb": "same_substance", + "relation": "exact", + "base": "IDENTITY" + }, + { + "vocabulary": "id-crosswalk", + "verb": "same_disease", + "relation": "exact", + "base": "IDENTITY" + }, + { + "vocabulary": "id-crosswalk", + "verb": "aligns_with", + "relation": "related", + "base": "IDENTITY", + "note": "skos:relatedMatch by the register's own rule; two things that align are not the same thing" + }, + { + "vocabulary": "id-crosswalk", + "verb": "at_layer", + "relation": "none", + "base": null, + "reason": "a classification edge, not a mapping — the register says so itself" + }, + { + "vocabulary": "taxonomy-joins", + "verb": "instantiates", + "relation": "none", + "base": null, + "reason": "a documentation join: a vocabulary term instantiates a law's subject; the object is a law, not an entity" + }, + { + "vocabulary": "taxonomy-joins", + "verb": "documents", + "relation": "none", + "base": null, + "reason": "a documentation join: a term is documented by a law" + }, + { + "vocabulary": "taxonomy-joins", + "verb": "grounds", + "relation": "none", + "base": null, + "reason": "a documentation join: a law grounded in a principle" + }, + { + "vocabulary": "taxonomy-joins", + "verb": "mechanism_of", + "relation": "none", + "base": null, + "expansion": "process MEDIATES disease, once the law's outcome is named as a disease node", + "loses": "the law as object" + }, + { + "vocabulary": "taxonomy-joins", + "verb": "upstream_mechanism_of", + "relation": "none", + "base": null, + "expansion": "a chain: process INCREASES or DECREASES an intermediate, which MEDIATES the disease", + "loses": "the law as object" + }, + { + "vocabulary": "taxonomy-joins", + "verb": "measures", + "relation": "none", + "base": null, + "reason": "an instrument relation (waist circumference measures adiposity); the base has no measurement verb" + }, + { + "vocabulary": "taxonomy-joins", + "verb": "modulates", + "relation": "none", + "base": null, + "expansion": "INCREASES or DECREASES once a sign is given; the base has no unsigned directional verb", + "loses": "the withheld sign" + }, + { + "vocabulary": "taxonomy-joins", + "verb": "clinical_sign_of", + "relation": "none", + "base": null, + "expansion": "sign ASSOCIATED_WITH disease — the MARKER_OF row — once the law's disease is a node", + "loses": "the law as object" + }, + { + "vocabulary": "ontology-join", + "verb": "has_component", + "relation": "exact", + "base": "CONTAINS", + "ro": "RO:0002180", + "label": "has component", + "domain": "food (FOODON)", + "range": "compound / nutrient (CHEBI, CDNO)", + "loses": "the amount. CONTAINS asserts presence; CDNO's concentration reading and RO's parthood cardinality both need a reified Value, which the base edge has nowhere to put.", + "note": "The only hop in this vocabulary that is a base edge. It is also the first rung of the food -> chemical -> function chain, and the rung that must be named before any of the rest are legal." + }, + { + "vocabulary": "ontology-join", + "verb": "has_role", + "relation": "none", + "ro": "RO:0000087", + "label": "has role", + "domain": "compound (CHEBI)", + "range": "chemical role (CHEBI role branch)", + "reason": "The object is a ChEBI role, which is not an entity kind the base ranges over. This is also the predicate most likely to be abused, so the row exists to say where it stops: acetylcholine has_role neurotransmitter AND hormone, so propagating a role into GO subclassing asserts something false wherever the other role holds (Hill DP et al., BMC Genomics 2013, PMID 23895341). Structure crosses into GO; role does not.", + "note": "has_role stays inside ChEBI. Crossing into GO requires a gene product or a reaction." + }, + { + "vocabulary": "ontology-join", + "verb": "participates_in", + "relation": "none", + "ro": "RO:0000056", + "label": "participates in", + "domain": "compound (CHEBI)", + "range": "process (GO BP) or reaction (RHEA)", + "reason": "Undirected participation. ASSOCIATED_WITH is the only base edge over these endpoints and the manifest calls it 'weak; excluded from causal paths', so the mapping would demote a typed participation to a statistical association. This register already records the same verdict for nutrient-edge's PARTICIPATES_IN; the two agree.", + "note": "biochemical_mechanisms.py declares this as its join key — nutrient.chebi -> participates_in -> mechanism. The declaration is right and the ChEBI side is not yet grounded: 20 of those participants are `chebi:` placeholders, which tools/check_curies.py now reports as MALFORMED." + }, + { + "vocabulary": "ontology-join", + "verb": "has_participant", + "relation": "none", + "ro": "RO:0000057", + "label": "has participant", + "domain": "process (GO BP)", + "range": "compound (CHEBI)", + "reason": "participates_in read backwards, and `none` for the same reason. An inverse of an edge the base cannot type is still an edge the base cannot type.", + "note": "The weaker of the pair; it surfaces mainly in GO annotation extensions." + }, + { + "vocabulary": "ontology-join", + "verb": "has_input", + "relation": "none", + "ro": "RO:0002233", + "label": "has input", + "domain": "molecular function or process (GO)", + "range": "compound (CHEBI)", + "reason": "The subject is a GO term and the object a chemical. Only CONTAINS (food subject) and CONVERTS_TO (compound subject) put a compound in range, and neither types a process subject, so there is no base edge over these endpoints.", + "note": "DIRECTION IS THE COMMON ERROR: has_input runs GO -> ChEBI, the opposite of the way a nutrition row is read aloud. This is the relation go-plus uses for its logical definitions -- carbohydrate metabolic process EQUIVALENTTO metabolic process AND has_input SOME carbohydrate -- which is an intersection, not an equivalence between a chemical and a process." + }, + { + "vocabulary": "ontology-join", + "verb": "has_output", + "relation": "none", + "ro": "RO:0002234", + "label": "has output", + "domain": "molecular function or process (GO)", + "range": "compound (CHEBI)", + "reason": "Same endpoints and same absence as has_input; the base has no process-subject-to-compound edge.", + "note": "The biosynthetic half of the go-plus pattern." + }, + { + "vocabulary": "ontology-join", + "verb": "enables", + "relation": "none", + "ro": "RO:0002327", + "label": "enables", + "domain": "gene product (PR, UniProt)", + "range": "molecular function (GO MF)", + "reason": "Capability, not effect. The base's only gene_enzyme edges are INCREASES, DECREASES and NO_CHANGE, each of which asserts a direction of change; 'this protein realises this function' asserts none. MEDIATES is about sitting on a causal path to a disease, which is a different claim again.", + "note": "This is the rung that makes a process claim legal at all. No gene product, no process claim." + }, + { + "vocabulary": "ontology-join", + "verb": "involved_in", + "relation": "none", + "ro": "RO:0002331", + "label": "involved in", + "domain": "gene product (PR, UniProt)", + "range": "biological process (GO BP)", + "reason": "Same family as enables: a realisation relation, and the base has no realisation predicate.", + "note": "Reached through the molecular function, scoped by it -- not asserted directly from a food." + }, + { + "vocabulary": "ontology-join", + "verb": "occurs_in", + "relation": "none", + "ro": "BFO:0000066", + "label": "occurs in", + "domain": "biological process (GO BP)", + "range": "anatomy or cell type (UBERON, CL)", + "reason": "A scoping relation, and the base has no site predicate: no manifest predicate ranges over organ_system at all (checked 2026-09-07). CONSERVES carries organ_system in its DOMAIN, which is the opposite end of the edge.", + "note": "The last rung. A process claim with no tissue is not wrong so much as unscoped, which is how 'broccoli is anti-inflammatory' survives review." + }, + { + "vocabulary": "ontology-join", + "verb": "in_taxon", + "relation": "none", + "ro": "RO:0002162", + "label": "in taxon", + "domain": "food (FOODON)", + "range": "organism (NCBITaxon)", + "reason": "Provenance about the source organism rather than a claim about a meal. The base has no taxon entity kind to range over.", + "note": "FoodOn's own spoke. It is why spirulina carries NCBITaxon:1156 next to its food term." + }, + { + "vocabulary": "ontology-join", + "verb": "derives_from", + "relation": "none", + "ro": "RO:0001000", + "label": "derives from", + "domain": "food (FOODON)", + "range": "food or organism (FOODON, NCBITaxon)", + "reason": "Material origin -- apple juice derives from apple. The base has no food-to-food edge: CONTAINS ranges over compound and nutrient only.", + "note": "Processing provenance lives here, not in CONTAINS." + } + ], + "forbidden": { + "what_this_is": "Axioms this project must never assert. An allow-list implies them, but each of these has been asserted by somebody often enough to be worth naming, and two of them are one careless line of code away.", + "rule": "If a paper says a food is anti-inflammatory, the loader must fail until it names the component, the gene product or reaction, the GO term and the tissue. That chain IS the mapping. Anything shorter is a slogan with an id attached.", + "axioms": [ + { + "axiom": "FOODON:* owl:equivalentClass GO:*", + "on_the_base": "IDENTITY", + "why": "A grocery item is not a cellular process. On the base this would be an IDENTITY edge, which the manifest defines as alias/equivalence -- so the assertion is not merely imprecise, it licenses substitution of one for the other everywhere." + }, + { + "axiom": "CHEBI:* owl:equivalentClass GO:*", + "on_the_base": "IDENTITY", + "why": "A molecule is not a process either. Use has_input, or a Rhea reaction, or neither." + }, + { + "axiom": "CHEBI:* has_role GO:*", + "on_the_base": null, + "why": "The role smash. has_role's range is the ChEBI role branch; pointing it at a GO term crosses an ontology boundary the relation was never typed for, and imports the multiple-role problem (PMID 23895341) into gene function." + }, + { + "axiom": "any FoodOn-to-GO edge with no molecule or gene product in between", + "on_the_base": null, + "why": "The middle object is a molecule (ChEBI) or a gene product (PR / UniProt), never the grocery item. Lexical agreement is not a relation: 'apple' in FoodOn and 'apple' in a plant gene annotation are the same English word and different types." + } + ], + "not_forbidden_but_not_a_mapping": [ + "FoodOntoMap, which maps FoodOn to Hansard, SNOMED CT and OntoFood -- other food lists, not GO.", + "Shared strings between ChEBI and GO labels. A hint for a curator, never an axiom." + ], + "enforced_by": "tests/test_ontology_join_contract.py" + } } diff --git a/tests/test_ontology_join_contract.py b/tests/test_ontology_join_contract.py new file mode 100644 index 0000000..31f887b --- /dev/null +++ b/tests/test_ontology_join_contract.py @@ -0,0 +1,132 @@ +"""The join grammar has to be checkable, or it is a slogan with a schema. + +`relation-crosswalk.v1.json` gained an `ontology-join` vocabulary: the predicates +that may run between this project's namespaces, and a `forbidden` block naming the +axioms that may not. A forbidden list nobody tests is worth less than no list — +it reads like a control and functions as a comment. + +Two things are asserted here. First, that the prohibition holds in the tree today. +Second, that the allow-list is *self-verifying*: every `ro` id sits next to the +label it resolved to, so `tools/check_curies.py` re-checks all eleven on every run +and the register cannot quietly drift away from the ontologies it quotes. + +The forbidden scan is deliberately blunt. It looks for an equivalence operator +with two ontology prefixes near it, which is the shape of the mistake, and it will +flag prose that *demonstrates* the mistake as well as code that commits it. That +is the right trade at this size: the repository asserts no equivalences at all, so +any hit is worth a human look. +""" + +from __future__ import annotations + +import json +import pathlib +import re + +import pytest + +REPO = pathlib.Path(__file__).resolve().parents[1] +CROSSWALK = REPO / "ontology-sdk" / "relation-crosswalk.v1.json" +CACHE = REPO / "tools" / ".curie_cache" +VOCAB = "ontology-join" + +SKIP_DIRS = {".git", ".venv", "node_modules", "__pycache__", ".curie_cache", + ".xref_cache", "site"} +SCAN = {".py", ".json", ".md", ".jsonl"} + +# An equivalence operator sitting between two ontology-prefixed ids. Assembled so +# this file does not match its own pattern. +EQUIV = re.compile( + r"(FOODON|CHEBI|GO|UBERON|CL)" + r":\d{4,}[^\n]{0,40}?" + + r"(owl:equivalentClass|owl:sameAs|skos:exactMatch)" + + r"[^\n]{0,40}?(FOODON|CHEBI|GO|UBERON|CL)" + r":\d{4,}") + + +@pytest.fixture(scope="module") +def crosswalk() -> dict: + return json.loads(CROSSWALK.read_text(encoding="utf-8")) + + +@pytest.fixture(scope="module") +def join_rows(crosswalk) -> list[dict]: + return [r for r in crosswalk["rows"] if r["vocabulary"] == VOCAB] + + +def test_the_vocabulary_is_declared_and_populated(crosswalk, join_rows): + assert VOCAB in crosswalk["vocabularies"] + assert len(join_rows) == len(crosswalk["vocabularies"][VOCAB]["members"]) + assert len(join_rows) >= 11 + + +def test_every_join_predicate_declares_its_endpoints(join_rows): + """A join predicate without a domain and a range is not a join predicate — it + is a verb with an opinion. The endpoints are the whole content of the row.""" + for r in join_rows: + assert r.get("domain"), f"{r['verb']} has no domain" + assert r.get("range"), f"{r['verb']} has no range" + assert r.get("ro"), f"{r['verb']} has no relation id" + assert r.get("label"), f"{r['verb']} has no resolved label" + + +def test_every_relation_id_is_cached_and_resolves(join_rows): + """Self-verification, and the reason `ro` is an id key in check_curies.py's + PAIR_PATTERNS. If this fails the register is quoting an id nobody looked up.""" + missing, dead = [], [] + for r in join_rows: + cp = CACHE / (r["ro"].replace(":", "_") + ".json") + if not cp.exists(): + missing.append(r["ro"]) + continue + if json.loads(cp.read_text()) is None: + dead.append(r["ro"]) + assert not dead, f"relation ids that do not resolve: {dead}" + if missing: + pytest.skip(f"not in the local curie cache: {missing} — run tools/check_curies.py") + + +def test_the_declared_label_is_the_resolved_label(join_rows): + """Written so the label diff in check_curies.py is meaningful rather than + decorative: the register must carry what OLS4 actually returned.""" + for r in join_rows: + cp = CACHE / (r["ro"].replace(":", "_") + ".json") + if not cp.exists(): + continue + got = json.loads(cp.read_text()) + assert got and got.get("label") == r["label"], ( + f"{r['verb']}: register says {r['label']!r}, {r['ro']} resolves to " + f"{(got or {}).get('label')!r}") + + +def test_only_one_join_predicate_is_a_base_edge(join_rows): + """The shape of the finding. The base is eleven ways to make a claim about + biology; a namespace join is not a claim, so `none` is the expected answer and + the single exception should stay conspicuous.""" + direct = [r for r in join_rows if r["relation"] != "none"] + assert [r["verb"] for r in direct] == ["has_component"] + assert direct[0]["base"] == "CONTAINS" + + +def test_the_forbidden_block_names_its_enforcer(crosswalk): + forbidden = crosswalk["forbidden"] + assert forbidden["axioms"], "a forbidden list with no axioms" + enforcer = REPO / forbidden["enforced_by"] + assert enforcer.is_file(), f"{forbidden['enforced_by']} does not exist" + assert enforcer.resolve() == pathlib.Path(__file__).resolve() + + +def test_no_forbidden_equivalence_is_asserted_anywhere(): + """The prohibition, actually checked. The repository asserts no cross-namespace + equivalences today; this keeps it that way.""" + hits = [] + for p in REPO.rglob("*"): + if not p.is_file() or p.suffix not in SCAN: + continue + if SKIP_DIRS & set(p.parts) or p.resolve() == pathlib.Path(__file__).resolve(): + continue + try: + text = p.read_text(errors="ignore") + except OSError: + continue + for m in EQUIV.finditer(text): + hits.append(f"{p.relative_to(REPO)}: {m.group(0)[:70]}") + assert not hits, "cross-namespace equivalence asserted:\n " + "\n ".join(hits) diff --git a/tools/check_curies.py b/tools/check_curies.py index ffdb4d7..33f48d5 100644 --- a/tools/check_curies.py +++ b/tools/check_curies.py @@ -74,13 +74,30 @@ OLS_ONTOLOGIES = { "UBERON": "uberon", "CL": "cl", "GO": "go", "CHEBI": "chebi", "MONDO": "mondo", "HP": "hp", "PATO": "pato", "OBI": "obi", - "ECO": "eco", "RO": "ro", "BFO": "bfo", "FOODON": "foodon", + "ECO": "eco", "RO": "ro", "FOODON": "foodon", "NCBITaxon": "ncbitaxon", "CDNO": "cdno", "NCIT": "ncit", + # A prefix may name SEVERAL ontologies to try, in order. BFO needs it: OLS4's + # `bfo` namespace serves nothing — not `occurs in` (BFO:0000066), not even + # `part of` (BFO:0000050) — while `go`, which imports BFO, serves both. With + # "bfo" alone every BFO id resolved to NOT_FOUND, i.e. the gate would have + # called a correct upper-ontology relation nonexistent. Same shape as the + # 400-vs-404 bug: not a lookup that failed, a lookup pointed somewhere the + # term was never going to be. Checked 2026-09-07. + "BFO": ("bfo", "go"), } # Reactome is NOT in OLS4. Three of four Reactome ids in the spine were wrong # precisely because whatever checked them could only see OLS4 and skipped them. REACTOME = re.compile(r"^R-[A-Z]{3}-\d+$") +# Rhea is not in OLS4 and its ids are not OBO-shaped, so nothing here could see +# them. That mattered: the repository shipped exactly one, RHEA:16501, on the +# ascorbate/dioxygenase mechanism — and it is glycerophosphoinositol hydrolysis +# (EC 3.1.4.44), a different reaction entirely. One id, never checked, wrong. +# Rhea is the only place a chemical is allowed to become a gene function, which +# makes an unchecked Rhea id the most load-bearing unchecked id in the tree. +RHEA = re.compile(r"^RHEA:\d+$") +RESOLVABLE = set(OLS_ONTOLOGIES) | {"RHEA"} + # MALFORMED: a prefix we know, followed by a local part that is not an accession. # `chebi:` is the one id shape that escaped every check here: the # CURIE regex above requires \d{4,}, so a placeholder was not a finding and not a @@ -96,15 +113,15 @@ MALFORMED_CURIE = re.compile( r"\b(" + "|".join(OLS_ONTOLOGIES) + r"):([A-Za-z_][A-Za-z0-9_]{2,})\b", re.I) -CURIE = re.compile(r"\b(" + "|".join(OLS_ONTOLOGIES) + r")[:_](\d{4,})\b") +CURIE = re.compile(r"\b(" + "|".join(RESOLVABLE) + r")[:_](\d{4,})\b") # How a declared label sits next to its id, across the shapes actually in the tree. PAIR_PATTERNS = [ # {"id": "UBERON:0001007", "label": "digestive system"} (either order) - re.compile(r'"(?:id|curie|term|ref|iri)"\s*:\s*"([A-Za-z]+[:_]\d{4,})"\s*,\s*' + re.compile(r'"(?:id|curie|term|ref|iri|ro)"\s*:\s*"([A-Za-z]+[:_]\d{4,})"\s*,\s*' r'"(?:label|name|term|title|text)"\s*:\s*"([^"]{2,80})"'), re.compile(r'"(?:label|name|term|title|text)"\s*:\s*"([^"]{2,80})"\s*,\s*' - r'"(?:id|curie|term|ref|iri)"\s*:\s*"([A-Za-z]+[:_]\d{4,})"'), + r'"(?:id|curie|term|ref|iri|ro)"\s*:\s*"([A-Za-z]+[:_]\d{4,})"'), # "UBERON:0001007": "digestive system" re.compile(r'"([A-Za-z]+[:_]\d{4,})"\s*:\s*"([^"]{2,80})"'), # uberion_hint="UBERON:0000383", ... name="Muscular" (python, either order) @@ -186,14 +203,29 @@ def resolve(curie: str, offline: bool = False) -> dict | None: prefix, local = re.split(r"[:_]", curie, maxsplit=1) try: - if REACTOME.match(curie) or prefix == "R": + if RHEA.match(curie): + # TSV, not JSON: the equation is the only label Rhea has, and the + # tsv endpoint is the one that answers without a session. + url = (f"https://www.rhea-db.org/rhea?query={curie}" + f"&columns=rhea-id,equation&format=tsv&limit=1") + req = urllib.request.Request(url, headers={"User-Agent": "biology-as-code"}) + with urllib.request.urlopen(req, timeout=30) as r: + lines = r.read().decode("utf-8", errors="replace").splitlines() + body = [ln for ln in lines[1:] if ln.strip()] + if not body: + out = None + else: + out = {"label": body[0].split("\t")[-1].strip(), "synonyms": [], + "obsolete": False} + elif REACTOME.match(curie) or prefix == "R": url = f"https://reactome.org/ContentService/data/query/{curie}" with urllib.request.urlopen(url, timeout=30) as r: d = json.load(r) out = {"label": d.get("displayName") or "", "synonyms": d.get("name") or [], "obsolete": False} else: - ont = OLS_ONTOLOGIES[prefix] + onts = OLS_ONTOLOGIES[prefix] + onts = (onts,) if isinstance(onts, str) else tuple(onts) iri = urllib.parse.quote(f"http://purl.obolibrary.org/obo/{prefix}_{local}", safe="") # Classes live at /terms; OBJECT PROPERTIES live at /properties, and @@ -205,7 +237,7 @@ def resolve(curie: str, offline: bool = False) -> dict | None: # identifiers a graph asserts most and the ones a label diff most needs # to see. Try both, in the order that costs one request for the common case. t = None - for kind in ("terms", "properties"): + for ont, kind in ((o, k) for o in onts for k in ("terms", "properties")): url = (f"https://www.ebi.ac.uk/ols4/api/ontologies/{ont}/{kind}?iri={iri}") try: with urllib.request.urlopen(url, timeout=30) as r: @@ -283,7 +315,7 @@ def harvest(root: pathlib.Path) -> dict[tuple[str, str], set[str]]: if not re.match(r"^[A-Za-z]+[:_]\d{4,}$", curie): continue curie = curie.replace("_", ":", 1) - if curie.split(":")[0] not in OLS_ONTOLOGIES: + if curie.split(":")[0] not in RESOLVABLE: continue label = label.strip().strip(",;.") if not label or label.startswith(("http", "{")): From 61b9eb0850c3cd4f340295ac926ff44e561abbb7 Mon Sep 17 00:00:00 2001 From: Paul Murff Date: Mon, 7 Sep 2026 20:36:42 -0600 Subject: [PATCH 2/3] feat: add RHEA-BRIDGE documentation and expand pmid_cache with updated publication metadata --- ontology-sdk/RHEA-BRIDGE.md | 125 +++++++++++++++ tools/check_pmids.py | 113 +++++++++++++- tools/pmid_cache.json | 296 ++++++++++++++++++++++++++++++++++-- 3 files changed, 518 insertions(+), 16 deletions(-) create mode 100644 ontology-sdk/RHEA-BRIDGE.md diff --git a/ontology-sdk/RHEA-BRIDGE.md b/ontology-sdk/RHEA-BRIDGE.md new file mode 100644 index 0000000..6a8c372 --- /dev/null +++ b/ontology-sdk/RHEA-BRIDGE.md @@ -0,0 +1,125 @@ +# The Rhea bridge + +**Rhea is the only place in this project where a chemical is allowed to become a +gene function — and even then the function is an enzyme activity, never a health +outcome.** This file says what we store, what we refuse to store, and what is +still empty. + +It is the concrete form of one row in +[`relation-crosswalk.v1.json`](relation-crosswalk.v1.json): `participates_in` +(`RO:0000056`), whose range is *process or reaction*. That row explains why the +join exists. This file explains what it looks like when populated. + +## Numbers, checked + +Read off Rhea itself on 2026-09-07, not quoted from a summary: + +| | | +|---|---| +| Release | **142**, dated 2026-09-02 | +| Unique reactions | **18,611** — and Rhea's own statistics page says this counts *quartets*, not rows | +| Unique participants | **15,203** | +| `rhea2go` mappings | **7,745** | +| Balanced at | pH 7.3, participants as ChEBI major microspecies — not the neutral textbook formula | + +The quartet is real and it is the thing most likely to be flattened by accident. +One transformation carries four ids: an undefined master, left-to-right, +right-to-left, and bidirectional. Merging them destroys direction. + +**Where the annotations actually land** — counted from the mapping files, because +the rule is usually stated more absolutely than it holds: + +| File | undefined | left→right | right→left | +|---|---|---|---| +| `rhea2go` | 7,738 | 5 | 2 | +| `rhea2uniprot` | 334,813 | 56,098 | 9,732 | + +So: GO overwhelmingly hangs on the undefined member — but **seven mappings do +not**, and a schema that forbids a directional GO xref would be wrong seven times. +Store the direction; do not assume it. + +## Our row + +`BiochemicalMechanism` (`models/biochemical_mechanisms.py`) already carries +`node_id`, `label`, `go`, `other_ontologies`, `reactome`, `participants`, `source`. +The Rhea-shaped version splits three of those and adds direction: + +| Field | Ours today | Change | Why | +|---|---|---|---| +| `rhea` | inside `other_ontologies` | **promote** to its own field, holding the **master (undefined)** id | it is the join, not a cross-reference like the others | +| `rhea_direction` | — | **new**: `undefined` · `left_to_right` · `right_to_left` · `bidirectional` | the quartet is four ids; seven `rhea2go` rows are directional | +| `participants` | one list of pseudo-ids | **split** into `substrates` / `products`, each a resolvable ChEBI accession with stoichiometry, and `(in)`/`(out)` for transport | a reaction has sides; a flat list cannot say which | +| `go` | one field | **split** into `go_mf` and `go_bp` | see below — this is the expensive one | +| `ec` | — | **new**, optional | Rhea returns it; it is a class, never a direction | +| `uniprot` | — | **deliberately absent** | we have no protein layer. An empty field we cannot fill is the "empty beats fake" rule pointed at ourselves | +| `match` | — | **new**: `exact` · `narrower` · `broader` · `related` | reuse the relation-crosswalk vocabulary verbatim; do not invent `narrow`/`broad` | + +**Why `go` must split.** `rhea2go` maps molecular function only. A biological +process is reached *through* the function, by `involved_in` (`RO:0002331`), and +never from the chemical directly. One `go` field invites exactly the error already +in the tree: `GO:0008270` — zinc ion binding — sits on the row labelled *Vitamin D +receptor ligand binding*. A single field cannot tell you whether the value was +supposed to be an activity or a process, so nothing catches the swap. + +Identifier dialect is the repository's, not Rhea's: lowercase CURIEs +(`chebi:15377`), because that is what `normalize_crosswalk.py` emits and what +FDP-1 §2 accepts. + +## Our chain + +Same shape as the general one, with our spine stages named — that is the part a +generic diagram cannot give you: + +``` +spine:food FOODON food term + │ has_component RO:0002180 ← the only rung that is a base edge (CONTAINS) +spine:compound ChEBI accession, pH 7.3 microspecies if it appears in a Rhea + │ participates_in RO:0000056 ← ONLY if a named enzyme reaction exists + RHEA master id + direction + │ xref ← rhea2go, molecular function only +spine:mechanism GO molecular function + │ involved_in RO:0002331 ← process reached through the function +spine:physiology GO biological process + │ occurs_in BFO:0000066 +spine:anatomy UBERON or CL +``` + +**No Rhea, stop at the compound.** That is not a gap to be filled with a plausible +id; it is the honest end of the chain. An organ endpoint — flow-mediated dilation +after cocoa — is a measured outcome at `spine:outcome`, not a reaction. There is no +`RHEA:vasodilation` and inventing one is the failure this whole file exists to +prevent. + +## What we refuse + +Already recorded as `forbidden` in the relation crosswalk and executed by +`tests/test_ontology_join_contract.py`. The Rhea-specific additions: + +- **Never collapse the quartet.** Four ids, one chemistry, different arrows. +- **Never treat a Rhea id and a KEGG reaction id as identity.** Different + protonation policy and, at times, a different stereocentre. +- **Never attach a biological process to a chemical** because a review used the + same English word. +- **Searching Rhea by a ChEBI class walks `is_a` and `has_role`.** Querying a + parent term pulls every child's reactions. Useful for discovery, wrong as a + stored join — pick exact matching when writing a row. + +## State today + +Honest, and short: + +- **One** Rhea id ships in the whole repository, and it is wrong. `RHEA:16501` sits + on the ascorbate / 2-oxoglutarate-dioxygenase mechanism and is + `sn-glycero-3-phospho-1D-myo-inositol + H2O = myo-inositol + sn-glycerol + 3-phosphate + H(+)` (EC 3.1.4.44). Unrelated chemistry. It survived because + nothing resolved Rhea ids until 2026-09-07; `tools/check_curies.py` now does. +- **Twenty** `participants` entries are placeholders of the form `chebi:` plus an + English word rather than an accession. They are reported as `MALFORMED` and + carried in `tools/curie_baseline.json`. Until they are accessions, the + `participates_in` join this file describes does not actually resolve to a + molecule. +- **No** protein layer, so `enables` (`RO:0002327`) has no subject and `uniprot` + stays absent rather than empty. + +Nothing above is scheduled here. This file records the shape; the debt is tracked +in the baseline, which may only go down. diff --git a/tools/check_pmids.py b/tools/check_pmids.py index 5c163d9..e0a8680 100755 --- a/tools/check_pmids.py +++ b/tools/check_pmids.py @@ -25,8 +25,20 @@ WHAT IT CHECKS -------------- -Every dict literal under `src/biology_as_code/` that carries both a `pmid` and a -`citation` key. The title from PubMed must appear in the citation string, both +**Code.** Every dict literal under `src/biology_as_code/` that carries both a +`pmid` and a `citation` key. + +**Prose.** Every PubMed id in `docs/**.md` and the top-level `*.md`, matched +against the citation text around it. Docs were the blind spot that mattered: four +of the twelve wrong ids found on 2026-09-07 were on `docs/metabolic-constants.md`, +where nothing read them. A page that tells a reader which paper backs a constant +is making the same promise the code makes. + +Prose has one thing code does not: ids that are *deliberately* wrong. The +correction tables record what an id used to be and what it actually resolves to. +Those live in `tools/pmid_docs.allow`, one per line with a written reason, the +same shape as `tools/separation.allow`. A gate with no exception mechanism gets +switched off the first week. The title from PubMed must appear in the citation string, both normalised (case, punctuation, whitespace, unicode dashes). Titles are matched by containment, not equality, so a full Vancouver reference passes and a truncated one still has to carry the whole title. @@ -42,6 +54,7 @@ python3 tools/check_pmids.py --strict # exit 1 on any mismatch python3 tools/check_pmids.py --offline --strict # cache only; what CI runs python3 tools/check_pmids.py --update-cache # re-resolve and rewrite the cache + python3 tools/check_pmids.py --code-only # skip prose """ from __future__ import annotations @@ -59,9 +72,20 @@ ROOT = pathlib.Path(__file__).resolve().parent.parent SRC = ROOT / "src" / "biology_as_code" +DOCS = ROOT / "docs" CACHE = ROOT / "tools" / "pmid_cache.json" +ALLOW = ROOT / "tools" / "pmid_docs.allow" ESUMMARY = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi" +#: `PMID 12345`, `PMID: 12345`, and the bare pubmed URL. +PMID_IN_PROSE = re.compile( + r"(?:PMID[:\s]\s*|pubmed\.ncbi\.nlm\.nih\.gov/)(\d{4,9})", re.IGNORECASE +) + +#: How far around the id to look for the title. Markdown wraps citations across +#: lines, and a title can sit either side of the identifier. +CONTEXT_LINES = 3 + _PUNCT = re.compile(r"[^a-z0-9 ]+") _SPACE = re.compile(r"\s+") @@ -115,6 +139,57 @@ def collect() -> list[dict]: return rows +def load_allow() -> dict[str, str]: + """` ` per line; `#` comments. A reason is mandatory.""" + allowed: dict[str, str] = {} + if not ALLOW.exists(): + return allowed + for raw in ALLOW.read_text().splitlines(): + line = raw.split("#", 1)[0].strip() + if not line: + continue + pmid, _, reason = line.partition(" ") + if not reason.strip(): + raise SystemExit(f"{ALLOW.name}: {pmid} has no written reason") + allowed[pmid.strip()] = reason.strip() + return allowed + + +def harvest_prose(path: pathlib.Path) -> list[dict]: + """Every PubMed id in a markdown file, with the text around it.""" + lines = path.read_text().splitlines() + rows = [] + for i, line in enumerate(lines): + for match in PMID_IN_PROSE.finditer(line): + lo = max(0, i - CONTEXT_LINES) + hi = min(len(lines), i + CONTEXT_LINES + 1) + rows.append( + { + "pmid": match.group(1), + "citation": " ".join(lines[lo:hi]), + "where": f"{path.relative_to(ROOT)}:{i + 1}", + } + ) + return rows + + +def collect_prose() -> list[dict]: + paths = sorted(DOCS.rglob("*.md")) + sorted(ROOT.glob("*.md")) + rows: list[dict] = [] + for path in paths: + rows.extend(harvest_prose(path)) + # One markdown link yields two hits — the visible `PMID n` and the URL behind + # it. Same id, same line, one citation. + seen, unique = set(), [] + for row in rows: + key = (row["pmid"], row["where"]) + if key not in seen: + seen.add(key) + row["prose"] = True + unique.append(row) + return unique + + def load_cache() -> dict: return json.loads(CACHE.read_text()) if CACHE.exists() else {} @@ -134,8 +209,11 @@ def fetch(pmids: list[str]) -> dict: if record.get("error") or not record.get("title"): out[pmid] = {"title": None, "error": record.get("error", "no record")} else: + authors = record.get("authors") or [] + first = authors[0]["name"] if authors else record.get("sortfirstauthor", "") out[pmid] = { "title": record["title"].rstrip("."), + "author": first.split(" ")[0] if first else "", "source": record.get("source", ""), "pubdate": record.get("pubdate", "")[:4], } @@ -143,14 +221,25 @@ def fetch(pmids: list[str]) -> dict: return out +def _author_present(record: dict, context: str) -> bool: + """Is the first author's surname in the text around the id?""" + surname = normalise(record.get("author", "")) + return bool(surname) and surname in context + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--strict", action="store_true", help="exit 1 on any mismatch") parser.add_argument("--offline", action="store_true", help="cache only, no network") parser.add_argument("--update-cache", action="store_true", help="re-resolve everything") + parser.add_argument("--code-only", action="store_true", help="skip prose in docs/") args = parser.parse_args() rows = collect() + allowed: dict[str, str] = {} + if not args.code_only: + allowed = load_allow() + rows += [r for r in collect_prose() if r["pmid"] not in allowed] cache = {} if args.update_cache else load_cache() wanted = sorted({row["pmid"] for row in rows}) missing = [pmid for pmid in wanted if pmid not in cache] @@ -169,12 +258,28 @@ def main() -> int: title = record.get("title") if title is None: bad.append((row, "no PubMed record")) - elif normalise(title) in normalise(row["citation"]): + continue + context = normalise(row["citation"]) + if normalise(title) in context: + ok += 1 + elif row.get("prose") and _author_present(record, context): + # Prose abbreviates titles ("A linear steady-state treatment of + # enzymatic chains." for a title that runs on), so title containment + # false-positives constantly and a noisy gate gets switched off. The + # surname is the discriminating field: every wrong id found on + # 2026-09-07 named a paper by an unrelated author, and would fail + # here. Code rows keep the strict title test — they carry a full + # citation string and have no excuse. ok += 1 else: bad.append((row, f'resolves to "{title}" ({record.get("source", "?")})')) - print(f"{len(rows)} shipped citation(s); {ok} confirmed, {len(bad)} mismatched") + scope = "code" if args.code_only else "code + prose" + skipped = f", {len(allowed)} allowed" if allowed else "" + print( + f"{len(rows)} shipped citation(s) [{scope}]; " + f"{ok} confirmed, {len(bad)} mismatched{skipped}" + ) for row, why in bad: print(f" MISMATCH {row['pmid']} {row['where']}\n {why}") if bad and args.strict: diff --git a/tools/pmid_cache.json b/tools/pmid_cache.json index 194ad3e..e0c47ff 100644 --- a/tools/pmid_cache.json +++ b/tools/pmid_cache.json @@ -1,72 +1,344 @@ { + "1008818": { + "author": "Porta", + "pubdate": "1976", + "source": "Biochem J", + "title": "Arginase from human full-term placenta" + }, + "10347186": { + "author": "Uriarte", + "pubdate": "1999", + "source": "J Biol Chem", + "title": "The carbamoyl-phosphate synthetase of Pyrococcus furiosus is enzymologically and structurally a carbamate kinase" + }, + "10677517": { + "author": "Shah", + "pubdate": "2000", + "source": "Proc Natl Acad Sci U S A", + "title": "Mitochondrial carbonic anhydrase CA VB: differences in tissue distribution and pattern of evolution from those of CA VA suggest distinct physiological roles" + }, + "10799377": { + "author": "Hallberg", + "pubdate": "2000", + "source": "Am J Clin Nutr", + "title": "Prediction of dietary iron absorption: an algorithm for calculating absorption and bioavailability of dietary iron" + }, + "10893432": { + "author": "De", + "pubdate": "2000", + "source": "Physiol Rev", + "title": "Molecular analysis of the sodium/iodide symporter: impact on thyroid and extrathyroid pathophysiology" + }, + "11024018": { + "author": "Quick", + "pubdate": "2001", + "source": "J Biol Chem", + "title": "Neutralization of a conserved amino acid residue in the human Na+/glucose transporter (hSGLT1) generates a glucose-gated H+ channel" + }, + "11034615": { + "author": "Leung", + "pubdate": "2000", + "source": "J Physiol", + "title": "Urea transport by cotransporters" + }, + "11747432": { + "author": "Sampaleanu", + "pubdate": "2001", + "source": "Biochemistry", + "title": "Three-dimensional structure of the argininosuccinate lyase frequently complementing allele Q286R" + }, + "12408190": { + "author": "Kleijer", + "pubdate": "2002", + "source": "J Inherit Metab Dis", + "title": "Clinical, enzymatic, and molecular genetic characterization of a biochemical variant type of argininosuccinic aciduria: prenatal and postnatal diagnosis in five unrelated families" + }, + "12538786": { + "author": "Dekaney", + "pubdate": "2003", + "source": "Pediatr Res", + "title": "Gene expression and activity of enzymes in the arginine biosynthetic pathway in porcine fetal small intestine" + }, + "12679487": { + "author": "Wapnir", + "pubdate": "2003", + "source": "J Clin Endocrinol Metab", + "title": "Immunohistochemical profile of the sodium/iodide symporter in thyroid, breast, and other carcinomas using high density tissue microarrays and conventional sections" + }, + "12699732": { + "author": "Ingalls", + "pubdate": "2003", + "source": "J Theor Biol", + "title": "Sensitivity analysis of stoichiometric networks: an extension of metabolic control analysis to non-steady state trajectories" + }, "12740060": { + "author": "Macfarlane", "pubdate": "2003", "source": "Proc Nutr Soc", "title": "Regulation of short-chain fatty acid production" }, + "12748858": { + "author": "Wright", + "pubdate": "2004", + "source": "Pflugers Arch", + "title": "The sodium/glucose cotransport family SLC5" + }, + "12853138": { + "author": "Summar", + "pubdate": "2003", + "source": "Gene", + "title": "Characterization of genomic structure and polymorphisms in the human carbamyl phosphate synthetase I gene" + }, + "12856182": { + "author": "Shayakul", + "pubdate": "2004", + "source": "Pflugers Arch", + "title": "The SLC14 gene family of urea transporters" + }, "13771349": { + "author": "MITCHELL", "pubdate": "1961", "source": "Nature", "title": "Coupling of phosphorylation to electron and hydrogen transfer by a chemi-osmotic type of mechanism" }, + "14598172": { + "author": "Palmieri", + "pubdate": "2004", + "source": "Pflugers Arch", + "title": "The mitochondrial transporter family (SLC25): physiological and pathological implications" + }, "14641005": { + "author": "Rich", "pubdate": "2003", "source": "Biochem Soc Trans", "title": "The molecular machinery of Keilin's respiratory chain" }, - "15620362": { + "15565635": { + "author": "Lo", "pubdate": "2005", - "source": "Biochim Biophys Acta", - "title": "P/O ratios of mitochondrial oxidative phosphorylation" + "source": "J Cell Biochem", + "title": "Developmental regulation and cellular distribution of human cytosolic malate dehydrogenase (MDH1)" }, - "19052988": { - "pubdate": "2008", - "source": "Leuk Lymphoma", - "title": "Synchronous presentation of systemic and brain non-Hodgkin lymphoma" + "15735074": { + "author": "Unlu", + "pubdate": "2005", + "source": "J Nutr", + "title": "Carotenoid absorption from salad and salsa by humans is enhanced by the addition of avocado or avocado oil" }, "19061483": { + "author": "Murphy", "pubdate": "2009", "source": "Biochem J", "title": "How mitochondria produce reactive oxygen species" }, "20064600": { + "author": "Brand", "pubdate": "2010", "source": "Exp Gerontol", "title": "The sites and topology of mitochondrial superoxide production" }, - "2012815": { - "pubdate": "1991", - "source": "Biochemistry", - "title": "Mechanistic stoichiometry of mitochondrial oxidative phosphorylation" - }, "21087145": { + "author": "Lubos", "pubdate": "2011", "source": "Antioxid Redox Signal", "title": "Glutathione peroxidase-1 in health and disease: from molecular mechanisms to therapeutic opportunities" }, "22431005": { + "author": "Stanton", "pubdate": "2012", "source": "IUBMB Life", "title": "Glucose-6-phosphate dehydrogenase, NADPH, and cell survival" }, "23036594": { + "author": "Deponte", "pubdate": "2013", "source": "Biochim Biophys Acta", "title": "Glutathione catalysis and the reaction mechanisms of glutathione-dependent enzymes" }, "23821742": { + "author": "den", "pubdate": "2013", "source": "J Lipid Res", "title": "The role of short-chain fatty acids in the interplay between diet, gut microbiota, and host energy metabolism" }, + "24899156": { + "author": "Kopec", + "pubdate": "2014", + "source": "J Nutr", + "title": "Avocado consumption enhances human postprandial provitamin A absorption and conversion from a novel high-\u03b2-carotene tomato sauce and from carrots" + }, + "2490366": { + "author": "Hediger", + "pubdate": "1989", + "source": "Proc Natl Acad Sci U S A", + "title": "Homology of the human intestinal Na+/glucose and Escherichia coli Na+/proline cotransporters" + }, + "2520168": { + "author": "Sorribas", + "pubdate": "1989", + "source": "Math Biosci", + "title": "A comparison of variant theories of intact biochemical systems. I. Enzyme-enzyme interactions and biochemical systems theory" + }, + "2520169": { + "author": "Sorribas", + "pubdate": "1989", + "source": "Math Biosci", + "title": "A comparison of variant theories of intact biochemical systems. II. Flux-oriented and metabolic control theories" + }, "27346602": { + "author": "Stilling", "pubdate": "2016", "source": "Neurochem Int", "title": "The neuropharmacology of butyrate: The bread and butter of the microbiota-gut-brain axis?" }, + "27893703": { + "author": "Magn\u00fasd\u00f3ttir", + "pubdate": "2017", + "source": "Nat Biotechnol", + "title": "Generation of genome-scale metabolic reconstructions for 773 members of the human gut microbiota" + }, + "29457794": { + "author": "Brunk", + "pubdate": "2018", + "source": "Nat Biotechnol", + "title": "Recon3D enables a three-dimensional view of gene variation in human metabolism" + }, + "30015603": { + "author": "Poon", + "pubdate": "2018", + "source": "Br J Nutr", + "title": "Comparison of nutrient profiling models for assessing the nutritional quality of foods: a validation study" + }, + "30371894": { + "author": "Noronha", + "pubdate": "2019", + "source": "Nucleic Acids Res", + "title": "The Virtual Metabolic Human database: integrating human and gut microbiome metabolism with nutrition and disease" + }, + "31105044": { + "author": "Hall", + "pubdate": "2019", + "source": "Cell Metab", + "title": "Ultra-Processed Diets Cause Excess Calorie Intake and Weight Gain: An Inpatient Randomized Controlled Trial of Ad Libitum Food Intake" + }, + "32463598": { + "author": "Thiele", + "pubdate": "2020", + "source": "Mol Syst Biol", + "title": "Personalized whole-body models integrate metabolism, physiology, and the gut microbiome" + }, + "3267767": { + "author": "Reder", + "pubdate": "1988", + "source": "J Theor Biol", + "title": "Metabolic control theory: a structural approach" + }, + "33995299": { + "author": "Teichmann", + "pubdate": "2021", + "source": "Front Microbiol", + "title": "In vitro Fermentation Reveals Changes in Butyrate Production Dependent on Resistant Starch Source and Microbiome Composition" + }, + "3463959": { + "author": "O'Brien", + "pubdate": "1986", + "source": "Proc Natl Acad Sci U S A", + "title": "Cloning and sequence analysis of cDNA for human argininosuccinate lyase" + }, + "36658342": { + "author": "Heinken", + "pubdate": "2023", + "source": "Nat Biotechnol", + "title": "Genome-scale metabolic reconstruction of 7,302 human microorganisms for personalized medicine" + }, + "38459394": { + "author": "Jarvis", + "pubdate": "2024", + "source": "Nat Food", + "title": "Periodic Table of Food Initiative for generating biomolecular knowledge of edible biodiversity" + }, + "38499749": { + "author": "Jarvis", + "pubdate": "2024", + "source": "Nat Food", + "title": "Author Correction: Periodic Table of Food Initiative for generating biomolecular knowledge of edible biodiversity" + }, + "40403748": { + "author": "Dirks", + "pubdate": "2025", + "source": "ISME J", + "title": "Methanogenesis associated with altered microbial production of short-chain fatty acids and human-host metabolizable energy" + }, + "4148886": { + "author": "Kacser", + "pubdate": "1973", + "source": "Symp Soc Exp Biol", + "title": "The control of flux" + }, + "4830198": { + "author": "Heinrich", + "pubdate": "1974", + "source": "Eur J Biochem", + "title": "A linear steady-state treatment of enzymatic chains. General properties, control and effector strength" + }, + "5387047": { + "author": "Savageau", + "pubdate": "1969", + "source": "J Theor Biol", + "title": "Biochemical systems analysis. II. The steady-state solutions for an n-pool system using a power-law approximation" + }, "5389100": { + "author": "McCord", "pubdate": "1969", "source": "J Biol Chem", "title": "Superoxide dismutase. An enzymic function for erythrocuprein (hemocuprein)" + }, + "5434343": { + "author": "Savageau", + "pubdate": "1970", + "source": "J Theor Biol", + "title": "Biochemical systems analysis. 3. Dynamic solutions using a power-law approximation" + }, + "6372096": { + "author": "Horwich", + "pubdate": "1984", + "source": "Science", + "title": "Structure and expression of a complementary DNA for the nuclear coded precursor of human mitochondrial ornithine transcarbamylase" + }, + "6930651": { + "author": "Ford", + "pubdate": "1980", + "source": "Proc Natl Acad Sci U S A", + "title": "Three-dimensional structure of a pyridoxal-phosphate-dependent enzyme, mitochondrial aspartate aminotransferase" + }, + "8063771": { + "author": "Hirayama", + "pubdate": "1994", + "source": "J Biol Chem", + "title": "Protons drive sugar transport through the Na+/glucose cotransporter (SGLT1)" + }, + "8559252": { + "author": "Dai", + "pubdate": "1996", + "source": "Nature", + "title": "Cloning and characterization of the thyroid iodide transporter" + }, + "8806637": { + "author": "Smanik", + "pubdate": "1996", + "source": "Biochem Biophys Res Commun", + "title": "Cloning of the human sodium lodide symporter" + }, + "9341168": { + "author": "Eskandari", + "pubdate": "1997", + "source": "J Biol Chem", + "title": "Thyroid Na+/I- symporter. Mechanism, stoichiometry, and specificity" + }, + "9933076": { + "author": "Levin", + "pubdate": "1999", + "source": "Urology", + "title": "Biochemical evaluation of obstructive bladder dysfunction in men secondary to BPH: a preliminary report" } } From 2bb98d04bead05c27a20dd9770f51e16c76f25a5 Mon Sep 17 00:00:00 2001 From: Paul Murff Date: Mon, 7 Sep 2026 20:42:24 -0600 Subject: [PATCH 3/3] feat: the citation gate now reads prose and JSON registers, not just code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check_pmids scanned `src/**.py` only. **Four of the twelve wrong ids found on 2026-09-07 were on `docs/metabolic-constants.md`**, in exactly the blind spot the gate did not cover — a page whose entire job was telling a reader which paper backed a constant. 39 identifiers in docs/ were unread by anything. Three lanes now, checked at the strictness each one can actually carry: code a dict with `pmid` + `citation`. Title must appear in the citation. json an object with `pmid` + `title` in a register. Title must match, with containment run either way because several stored titles are truncated mid-word by whatever wrote them. prose a PubMed id in markdown. Title OR first-author surname must appear in the surrounding lines. Prose earns the looser test. Docs abbreviate titles — "A linear steady-state treatment of enzymatic chains." for a title that runs on — so title containment false-positives on correct citations, and a noisy gate gets switched off in a week (the lesson written into check_curies.py's own docstring). The surname is the discriminating field: every one of the four wrong doc ids named a paper by an unrelated author and fails here. Exceptions in `tools/pmid_docs.allow`, one written reason each, same shape as `tools/separation.allow`. Two kinds, and they are not equivalent: @ skipped, in that one file. For ids the prose quotes ON PURPOSE as wrong — the correction tables. Scoped, so the same id used as a real citation on another page is still caught. existence-only. Bare reference lists (VMH reaction ids) and pages citing by internal record id, where the authoritative pmid+title pair lives in the JSON register and is checked strictly there. The id is still resolved, so a dead identifier like the one resolving to no record at all is still caught. Also fixes a coupling bug this exposed. `check_constants.py` requires every grounded constant's id to be in the cache, but `grounded(...)` is a call, not a dict, so the harvester never saw it — and `--update-cache` dropped PMID 15620362, after which the constants gate failed on an id it could no longer resolve. `grounded()` ids are harvested now, existence-only since `supports` says what a paper backs rather than what it is called. One harvester, one cache, no drift. Verified by planting a wrong id in each lane and watching it fail, including an allow-listed id reused on a different page: 94 citations [code + prose]; 46 confirmed, 0 mismatched, 48 existence-only, 17 quoted-deliberately Test plan: 630 passed, 1 skipped, 5 xfailed. check_constants still green after a full `--update-cache` rebuild, which is the regression above. ruff, separation, third-party, human-rows and `mkdocs build --strict` all pass. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 5 ++ tools/check_pmids.py | 153 +++++++++++++++++++++++++++++++++++---- tools/pmid_cache.json | 120 ++++++++++++++++++++++++++++++ tools/pmid_docs.allow | 48 ++++++++++++ 4 files changed, 310 insertions(+), 16 deletions(-) create mode 100644 tools/pmid_docs.allow diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9c4030..51703b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,11 +31,16 @@ jobs: # exception in tools/separation.allow. run: python tools/check_separation.py - name: PMIDs resolve to the paper we cited + # Covers code, JSON registers and prose in docs/. Docs were the blind + # spot that mattered: four of the twelve wrong ids found on 2026-09-07 + # were on docs/metabolic-constants.md, where nothing read them. + # # Offline against the committed cache, so this is deterministic and needs # no egress. It fails on a cache miss: a citation added without being # resolved is exactly the defect the gate exists to catch, and an unrun # check is not a pass. Add one with `python3 tools/check_pmids.py --strict` # and commit the refreshed tools/pmid_cache.json. + # Exceptions live in tools/pmid_docs.allow, one written reason each. run: python tools/check_pmids.py --offline --strict - name: Every shipped number names its source # check_pmids proves a citation resolves; this proves the *numbers* have diff --git a/tools/check_pmids.py b/tools/check_pmids.py index e0a8680..2ecc351 100755 --- a/tools/check_pmids.py +++ b/tools/check_pmids.py @@ -84,7 +84,7 @@ #: How far around the id to look for the title. Markdown wraps citations across #: lines, and a title can sit either side of the identifier. -CONTEXT_LINES = 3 +CONTEXT_LINES = 6 _PUNCT = re.compile(r"[^a-z0-9 ]+") _SPACE = re.compile(r"\s+") @@ -132,27 +132,121 @@ def _fold(node: ast.AST) -> str | None: return None +def harvest_grounded(tree: ast.AST, path: pathlib.Path) -> list[dict]: + """PubMed ids attached to a constant by `grounded(...)`. + + These carry no title to diff against — the `supports` field says what the + paper backs, not what it is called — so they are existence-checked only. + They are harvested here anyway, and this is the point: `check_constants.py` + requires every grounded constant's id to be present in `pmid_cache.json`, so + if this harvester did not see them, `--update-cache` would drop them and the + constants gate would start failing on ids it could no longer resolve. One + harvester, one cache, no drift between the two gates. + """ + rows = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + if not (isinstance(node.func, ast.Name) and node.func.id == "grounded"): + continue + for kw in node.keywords: + if kw.arg == "pmid" and isinstance(kw.value, ast.Constant): + rows.append( + { + "pmid": str(kw.value.value), + "citation": "", + "existence_only": True, + "where": f"{path.relative_to(ROOT)}:{node.lineno}", + } + ) + return rows + + def collect() -> list[dict]: rows: list[dict] = [] for path in sorted(SRC.rglob("*.py")): - rows.extend(harvest(ast.parse(path.read_text()), path)) + tree = ast.parse(path.read_text()) + rows.extend(harvest(tree, path)) + rows.extend(harvest_grounded(tree, path)) return rows -def load_allow() -> dict[str, str]: - """` ` per line; `#` comments. A reason is mandatory.""" - allowed: dict[str, str] = {} +def harvest_json(path: pathlib.Path) -> list[dict]: + """Every object in a JSON register carrying both a `pmid` and a `title`. + + Checked as strictly as code: the title is right there, so there is no excuse + for it not matching. This is where an evidence register keeps its citations, + and it is the *authoritative* copy — prose that refers to a study by its + internal record id (EV-041) is pointing here. + """ + rows: list[dict] = [] + + def walk(node: object) -> None: + if isinstance(node, dict): + pmid, title = node.get("pmid"), node.get("title") + if isinstance(pmid, str) and isinstance(title, str) and pmid.isdigit(): + rows.append( + { + "pmid": pmid, + "citation": title, + "stored_title": True, + "where": str(path.relative_to(ROOT)), + } + ) + return + for value in node.values(): + walk(value) + elif isinstance(node, list): + for value in node: + walk(value) + + try: + walk(json.loads(path.read_text())) + except json.JSONDecodeError: + pass + return rows + + +def collect_json() -> list[dict]: + rows: list[dict] = [] + for path in sorted(SRC.rglob("*.json")): + rows.extend(harvest_json(path)) + return rows + + +def load_allow() -> tuple[dict[str, str], dict[str, str]]: + """` ` per line; `#` comments. A reason is mandatory. + + Two kinds of entry, because prose has two kinds of exception: + + * a **pmid@path** — an id quoted deliberately *in one named file*, such as + the correction tables that record what an identifier used to be. Skipped + there and nowhere else, so the same wrong id used as a real citation on + another page is still caught. A bare pmid is accepted and skips everywhere, + but prefer the scoped form. + * a **path** — a file of bare reference lists, ids with no author or title + beside them to diff against. Not skipped: every id in it is still checked + for *existence*, which is what catches a dead identifier like the one that + resolved to no PubMed record at all. Only the "does it name this paper" + half is dropped, because there is no name to compare. + """ + ids: dict[str, str] = {} + paths: dict[str, str] = {} if not ALLOW.exists(): - return allowed + return ids, paths for raw in ALLOW.read_text().splitlines(): line = raw.split("#", 1)[0].strip() if not line: continue - pmid, _, reason = line.partition(" ") + key, _, reason = line.partition(" ") + key = key.strip() if not reason.strip(): - raise SystemExit(f"{ALLOW.name}: {pmid} has no written reason") - allowed[pmid.strip()] = reason.strip() - return allowed + raise SystemExit(f"{ALLOW.name}: {key} has no written reason") + if key.split("@", 1)[0].isdigit(): + ids[key] = reason.strip() + else: + paths[key] = reason.strip() + return ids, paths def harvest_prose(path: pathlib.Path) -> list[dict]: @@ -221,6 +315,15 @@ def fetch(pmids: list[str]) -> dict: return out +#: Below this many characters a "prefix" is not evidence of anything. +MIN_PREFIX = 40 + + +def _prefix_match(stored: str, resolved: str) -> bool: + """Is the stored title the opening of the resolved one?""" + return len(stored) >= MIN_PREFIX and resolved.startswith(stored) + + def _author_present(record: dict, context: str) -> bool: """Is the first author's surname in the text around the id?""" surname = normalise(record.get("author", "")) @@ -235,11 +338,17 @@ def main() -> int: parser.add_argument("--code-only", action="store_true", help="skip prose in docs/") args = parser.parse_args() - rows = collect() + rows = collect() + collect_json() allowed: dict[str, str] = {} + unnamed_files: dict[str, str] = {} if not args.code_only: - allowed = load_allow() - rows += [r for r in collect_prose() if r["pmid"] not in allowed] + allowed, unnamed_files = load_allow() + for row in collect_prose(): + path = row["where"].rsplit(":", 1)[0] + if row["pmid"] in allowed or f"{row['pmid']}@{path}" in allowed: + continue + row["existence_only"] = path in unnamed_files + rows.append(row) cache = {} if args.update_cache else load_cache() wanted = sorted({row["pmid"] for row in rows}) missing = [pmid for pmid in wanted if pmid not in cache] @@ -252,16 +361,27 @@ def main() -> int: cache.update(fetch(missing)) CACHE.write_text(json.dumps(cache, indent=2, sort_keys=True) + "\n") - bad, ok = [], 0 + bad, ok, existence = [], 0, 0 for row in rows: record = cache.get(row["pmid"], {}) title = record.get("title") if title is None: bad.append((row, "no PubMed record")) continue + if row.get("existence_only"): + # A bare reference list. The id resolves, which is all this file can + # assert; there is no author or title beside it to diff against. + existence += 1 + continue context = normalise(row["citation"]) if normalise(title) in context: ok += 1 + elif row.get("stored_title") and _prefix_match(context, normalise(title)): + # A register stores the title as its own field, and several are + # truncated mid-word by whatever wrote them. Containment therefore + # runs the other way here: the stored string must be the opening of + # the real one. Length-guarded so a stub cannot match by accident. + ok += 1 elif row.get("prose") and _author_present(record, context): # Prose abbreviates titles ("A linear steady-state treatment of # enzymatic chains." for a title that runs on), so title containment @@ -275,10 +395,11 @@ def main() -> int: bad.append((row, f'resolves to "{title}" ({record.get("source", "?")})')) scope = "code" if args.code_only else "code + prose" - skipped = f", {len(allowed)} allowed" if allowed else "" + extra = f", {existence} existence-only" if existence else "" + skipped = f", {len(allowed)} quoted-deliberately" if allowed else "" print( f"{len(rows)} shipped citation(s) [{scope}]; " - f"{ok} confirmed, {len(bad)} mismatched{skipped}" + f"{ok} confirmed, {len(bad)} mismatched{extra}{skipped}" ) for row, why in bad: print(f" MISMATCH {row['pmid']} {row['where']}\n {why}") diff --git a/tools/pmid_cache.json b/tools/pmid_cache.json index e0c47ff..2f0fb72 100644 --- a/tools/pmid_cache.json +++ b/tools/pmid_cache.json @@ -17,6 +17,12 @@ "source": "Proc Natl Acad Sci U S A", "title": "Mitochondrial carbonic anhydrase CA VB: differences in tissue distribution and pattern of evolution from those of CA VA suggest distinct physiological roles" }, + "10702589": { + "author": "Monsma", + "pubdate": "2000", + "source": "J Nutr", + "title": "In vitro fermentation of swine ileal digesta containing oat bran dietary fiber by rat cecal inocula adapted to the test fiber increases propionate production but fermentation of wheat bran ileal digesta does not produce more butyrate" + }, "10799377": { "author": "Hallberg", "pubdate": "2000", @@ -119,6 +125,12 @@ "source": "J Cell Biochem", "title": "Developmental regulation and cellular distribution of human cytosolic malate dehydrogenase (MDH1)" }, + "15620362": { + "author": "Hinkle", + "pubdate": "2005", + "source": "Biochim Biophys Acta", + "title": "P/O ratios of mitochondrial oxidative phosphorylation" + }, "15735074": { "author": "Unlu", "pubdate": "2005", @@ -245,12 +257,24 @@ "source": "Proc Natl Acad Sci U S A", "title": "Cloning and sequence analysis of cDNA for human argininosuccinate lyase" }, + "36638279": { + "author": "Kerr", + "pubdate": "2023", + "source": "J Anim Sci", + "title": "Soluble corn fiber, resistant corn starch, and protected butyrate effects on performance, gastrointestinal volatile fatty acids, and apparent total-tract digestibility of calcium and phosphorus in nursery pigs" + }, "36658342": { "author": "Heinken", "pubdate": "2023", "source": "Nat Biotechnol", "title": "Genome-scale metabolic reconstruction of 7,302 human microorganisms for personalized medicine" }, + "38441170": { + "author": "Jackson", + "pubdate": "2024", + "source": "Food Funct", + "title": "Protein combined with certain dietary fibers increases butyrate production in gut microbiota fermentation" + }, "38459394": { "author": "Jarvis", "pubdate": "2024", @@ -263,18 +287,114 @@ "source": "Nat Food", "title": "Author Correction: Periodic Table of Food Initiative for generating biomolecular knowledge of edible biodiversity" }, + "39748438": { + "author": "Yong", + "pubdate": "2025", + "source": "J Anim Sci Biotechnol", + "title": "Relationship between dietary fiber physicochemical properties and feedstuff fermentation characteristics and their effects on nutrient utilization, energy metabolism, and gut microbiota in growing pigs" + }, + "39909254": { + "author": "Yan", + "pubdate": "2025", + "source": "Int J Biol Macromol", + "title": "Chickpea-resistant starch exhibits bioactive function for alleviating atopic dermatitis via regulating butyrate production" + }, "40403748": { "author": "Dirks", "pubdate": "2025", "source": "ISME J", "title": "Methanogenesis associated with altered microbial production of short-chain fatty acids and human-host metabolizable energy" }, + "40484608": { + "author": "Zhang", + "pubdate": "2025", + "source": "Carbohydr Polym", + "title": "The regulatory effects of butyrylated starch on gut microbiota and metabolites during in vitro fermentation: advantages over exogenous butyrate and resistant starch" + }, + "40817467": { + "author": "Jiang", + "pubdate": "2025", + "source": "Carbohydr Polym", + "title": "Modulation of butyrate-induced stress responses in Bacteroides by lotus seed resistant starch and its sugar degradation products" + }, + "41006931": { + "author": "Kuznetsov", + "pubdate": "2025", + "source": "Pflugers Arch", + "title": "Energy metabolism in different skeletal muscles and muscle fibers: implications for injury and dietary supplementation" + }, + "41293580": { + "author": "Siminiuc", + "pubdate": "2025", + "source": "Front Public Health", + "title": "Energy intake and dietary fiber as principal determinants of obesity in Eastern Europe, 2010-2022: an ecological panel study" + }, + "41439104": { + "author": "Gilbert", + "pubdate": "2025", + "source": "Curr Dev Nutr", + "title": "Seasonal and Community Size-Related Patterns of Population Dietary Energy, Macronutrients, and Fiber Purchased in Grocery Stores across Nunavut, Canada" + }, "4148886": { "author": "Kacser", "pubdate": "1973", "source": "Symp Soc Exp Biol", "title": "The control of flux" }, + "41946979": { + "author": "Paray", + "pubdate": "2026", + "source": "Trop Anim Health Prod", + "title": "Dietary Saccharomyces cerevisiae enhanced growth performance, short-chain fatty acids, and reduced the susceptibility of African catfish, Clarias gariepinus to Aeromonas hydrophila infection" + }, + "42008882": { + "author": "Li", + "pubdate": "2026", + "source": "Meat Sci", + "title": "Effect of dietary resveratrol on slow oxidative muscle fiber expression and energy metabolism in beef muscle via AMPK/SIRT1/PGC-1\u03b1 signaling pathway" + }, + "42041444": { + "author": "Gu", + "pubdate": "2026", + "source": "Biosensors (Basel)", + "title": "Rapid Electrochemical Profiling of Fecal Short-Chain Fatty Acids Using Esterification/Dissociation Fingerprints and Artificial Neural Networks" + }, + "42065568": { + "author": "Li", + "pubdate": "2026", + "source": "Food Funct", + "title": "Pine pollen fiber alleviates constipation induced by loperamide in mice: focus on gastrointestinal motility-related hormones, short-chain fatty acids, and gut microbiota changes" + }, + "42104939": { + "author": "Zhang", + "pubdate": "2026", + "source": "J Agric Food Chem", + "title": "Ameliorative Effects of Butyrylated Starch on Cognitive Dysfunction in d-Galactose-Induced Aging Mice: A Comparative Analysis with Exogenous Butyrate and Resistant Starch" + }, + "42163967": { + "author": "Zhu", + "pubdate": "2026", + "source": "Front Nutr", + "title": "The higher dietary energy-to-fiber ratio is associated with an increased risk of poor blood pressure control among patients with essential hypertension: a cross-sectional study" + }, + "42280405": { + "author": "Alshatari", + "pubdate": "2026", + "source": "Nutrients", + "title": "Effects of Polyphenol Supplementation on Gut Microbiota Composition and Fecal Short-Chain Fatty Acids: A Systematic Review and Meta-Analysis of Randomized Controlled Trials" + }, + "42357471": { + "author": "Y\u0131ld\u0131z", + "pubdate": "2026", + "source": "Molecules", + "title": "Metabolite-Centered Evaluation of Plant-Based Substrates: Integrated Profiling of Short-Chain Fatty Acids (SCFAs) and Neuroactive Compounds with Potential Relevance to the Gut-Brain Axis" + }, + "42366393": { + "author": "Cheney", + "pubdate": "2026", + "source": "Gut Pathog", + "title": "The gastrointestinal microbiome and constituent short-chain fatty acids: a narrative review of an underexplored axis in acute lymphoblastic leukemia" + }, "4830198": { "author": "Heinrich", "pubdate": "1974", diff --git a/tools/pmid_docs.allow b/tools/pmid_docs.allow new file mode 100644 index 0000000..57d3bc2 --- /dev/null +++ b/tools/pmid_docs.allow @@ -0,0 +1,48 @@ +# Exceptions for the prose half of tools/check_pmids.py. +# Format: . A reason is mandatory. +# +# Two kinds, and they are not equivalent: +# +# skipped entirely. For an identifier the prose quotes ON PURPOSE as +# being wrong — the correction tables that record what an id used to +# be. Checking those would flag the documentation of a fix as a bug. +# +# existence-only. For a file of bare reference lists: ids with no +# author or title beside them to diff against. Every id in it is still +# resolved, so a dead identifier is still caught; only the "does it +# name this paper" half is dropped, because there is no name. +# +# Adding a line here is a claim that the id cannot be checked, not that it is +# fine. Prefer writing the author's surname next to the id instead. + +# --- Files of bare reaction-reference lists (existence-only) --- +docs/figures/urea-cycle.md VMH reaction provenance: ids listed per reaction id (ARGSL, UREAt, ...) with no author or title anywhere on the line. + +# --- Identifiers quoted deliberately as wrong --- +8962261@docs/metabolic-constants.md metabolic-constants.md correction table: recorded as the WRONG id previously shipped for Miller & Wolin 1996. +23985657@docs/metabolic-constants.md metabolic-constants.md correction table: one of three wrong ids previously offered for den Besten 2013. +24023713@docs/metabolic-constants.md metabolic-constants.md correction table: ditto, an earlier rejected den Besten id. +24347302@docs/metabolic-constants.md metabolic-constants.md correction table: ditto, the one that had been shipped in dig/hepatic_routes.py. +1939103@docs/metabolic-constants.md metabolic-constants.md correction table: recorded as the WRONG id previously shipped for Hinkle 1991. +15639704@docs/metabolic-constants.md metabolic-constants.md correction table: recorded as the WRONG id previously shipped for Hinkle 2005. +12417066@docs/metabolic-constants.md metabolic-constants.md correction table: recorded as the WRONG id previously shipped for St-Pierre 2002. +20463404@docs/metabolic-constants.md metabolic-constants.md correction table: recorded as the WRONG id previously shipped for Brand 2010. +12740047@docs/metabolic-constants.md metabolic-constants.md correction table: recorded as the WRONG id previously shipped for Macfarlane 2003. +26859755@docs/metabolic-constants.md metabolic-constants.md correction table: recorded as the WRONG id previously shipped for Stilling 2016. +21924744@docs/metabolic-constants.md metabolic-constants.md correction table: recorded as the WRONG id previously shipped for Lubos 2011. +14668792@docs/metabolic-constants.md metabolic-constants.md correction table: recorded as the WRONG id previously shipped for Rich 2003; resolves to no PubMed record at all. +23380711@docs/metabolic-constants.md metabolic-constants.md correction table: recorded as the WRONG id previously shipped for Deponte 2013. +19052988@docs/metabolic-constants.md metabolic-constants.md correction table: recorded as the WRONG id previously shipped for Murphy 2009. +26859528@docs/non-laws.md non-laws.md and dig/ notes: the id the pasted card used for Stilling; quoted to say it is wrong. +23380721 redox_routes.py note: the id the pasted card used for Deponte; quoted to say it is wrong. +19028888 respiratory_control.py note: the id the pasted card used for Murphy; quoted to say it is wrong. + +# --- Files that cite by internal evidence-record id (existence-only) --- +# In these, a study is named by its register id (EV-041) and the author/title +# live in src/biology_as_code/engine/data/evidence_candidates_LAW026.json, which +# check_pmids now reads directly and checks STRICTLY. Duplicating the title into +# the prose to satisfy a gate would be writing for the gate, not the reader. +docs/VALIDATION.md cites studies by evidence-record id; the authoritative pmid+title pair is in the LAW026 evidence register, checked strictly there. +docs/VALIDATION_LEDGER.md same: cites by evidence-record id, register carries the checked pair. +CHANGELOG.md release notes referring to studies by evidence-record id; register carries the checked pair. +docs/related-projects.md cites a paper's id to say it is NOT in PMC and could not be read; the point is the access barrier, not the content.