Skip to content

Migrate 164 enables→TRAIT edges onto the v8 predicates (#302, #303) - #323

Merged
realmarcin merged 4 commits into
mainfrom
feat/302-migrate-enables-trait-edges
Aug 6, 2026
Merged

Migrate 164 enables→TRAIT edges onto the v8 predicates (#302, #303)#323
realmarcin merged 4 commits into
mainfrom
feat/302-migrate-enables-trait-edges

Conversation

@realmarcin

@realmarcin realmarcin commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

The corpus half of the #302/#303 fix. #320 proposed the predicates; this adopts them.

What changed

RO:0002327 (enables) has range 'biological process or activity'; a TRAIT node
is a disposition, so all 164 of these edges entailed
trait ⊑ BiologicalProcessOrActivity. They now carry the metpo_traitmech_v8
predicates:

Predicate Edges Direction
METPO:2007700 confers 145 unchanged
METPO:2007701 has electron donor 13 reversed to trait → chemical
METPO:2007702 has electron acceptor 6 reversed to trait → chemical

The reversal restores the donor/acceptor role PR #300 had to collapse onto enables
(#303). The organism-subject problem that forced that collapse doesn't arise here —
these predicates take a causal-node domain, not METPO:2000001's microbe domain (#301).

The mapping rows are gated, unlike the */* used by earlier placeholder rows:
confers is object_types=TRAIT with the nine observed subject types, and the electron
pair is TRAIT → CHEMICAL. That enforces the "a chemical species" clause the declared
OWL range deliberately doesn't — and is exactly why oxygen_preference's
MOLECULAR_FUNCTION node ("O2 as terminal electron acceptor" — the use of O2, not O2)
grounds to confers instead.

Updated after review: the partition is 145 / 13 / 6, not 146/13/5. manganese_oxidation describes O2 as the "terminal oxidant", which names the terminal-acceptor role; the first cut of the rule matched only electron acceptor|terminal electron and dropped that edge to confers. Verified against the pre-migration corpus that widening to oxidant/reductant reclassifies exactly that one edge.

The proof it landed

ENABLES_RANGE_ON_TRAIT is fully burned down, 164 → 0, in the ratcheted audit added
by #314. The predicate-domain baseline drops 530 → 366; the remaining 366 are the
untouched #301 microbe-domain class.

What was checked

  • just validate-strict — 477 files, 0 errors.
  • just qcgreen, every ratchet at blocking: 0.
  • uv run pytest420 passed. Ruff clean.
  • Canaried before the sweep, twice: once on dissimilatory_iron_reduction
    (3 acceptor reversals — the hardest case) and once on biofilm_formation (one of the
    5 records with a discussions: block after curation_history, where a naive
    append-at-EOF would land in the wrong place). Both diffs were inspected line by line
    before applying to the other 126.
  • Evidence-snippet baseline delta explained: 6 rows re-keyed, count unchanged at
    2737. Reversing an edge changes its baseline key (subject->object), so 6
    pre-existing findings re-key; all 6 old keys are present in the previous baseline, so
    these are not new defects. A 7th diff line is byte-identical content that merely moved
    position in the sort.

On the migration script

scripts/migrate_enables_trait_edges.py edits the raw lines rather than
round-tripping through write_validated_trait. That helper's docstring claims a
byte-identical round-trip, but it isn't for hand-edited YAML — it re-wraps long strings
and drops hand-written quoting (filed as #322). Over 128 files that would have buried
164 real changes in reflow churn, which is the unreviewable outcome #301 warns about.
The parsed document is used only to decide; only the subject/object/predicate/
predicate_id lines that change are touched.

The script refuses to write a partial migration if the text matches fewer edges than the
parse found, so a silent under-application isn't possible.

Scope

Closes #302 and #303. Does not touch #301's 366 microbe-domain edges — those need
their own per-predicate-family decisions, and the ratchet keeps them visible and
non-growing meanwhile. The METPO:20077002007702 ids remain placeholders until
upstream mints them; the round-trip plan is in proposals/metpo_traitmech_v8/proposal.md.

🤖 Generated with Claude Code

RO:0002327 (enables) has range 'biological process or activity', and a
TRAIT node is a disposition, so all 164 of these edges entailed
trait ⊑ BiologicalProcessOrActivity. They now carry the predicates
proposed in proposals/metpo_traitmech_v8 (merged in #320):

  confers                METPO:2007700   146 edges, direction unchanged
  has electron donor     METPO:2007701    13 edges, direction reversed
  has electron acceptor  METPO:2007702     5 edges, direction reversed

The 18 electron edges are reversed back to <trait> -> <chemical>, which
restores the donor/acceptor role PR #300 had to collapse onto enables
(#303). The organism-subject problem that forced that collapse does not
arise here: these predicates take a causal-node domain, not
METPO:2000001's microbe domain (#301).

The three mapping rows are GATED rather than the '*'/'*' used by earlier
placeholder rows: confers is object_types=TRAIT with the 9 observed
subject types, and the electron pair is TRAIT -> CHEMICAL. That enforces
the "a chemical species" clause the declared OWL range deliberately does
not, and is why oxygen_preference's MOLECULAR_FUNCTION node ("O2 as
terminal electron acceptor", the USE of O2 rather than O2) grounds to
confers instead.

Applied by scripts/migrate_enables_trait_edges.py, which edits the raw
lines rather than round-tripping through write_validated_trait: that
helper is not byte-identical on hand-edited YAML (#322) and would have
buried 164 real changes in reflow churn across 128 files.

Baseline deltas, both intended and both verified:

- predicate_domain_audit: 530 -> 366. ENABLES_RANGE_ON_TRAIT is fully
  burned down, 164 -> 0. The remaining 366 are the untouched #301
  microbe-domain class.
- evidence_snippet: 6 rows re-keyed, count unchanged at 2737. Reversing
  an edge changes its baseline key (subject->object), so 6 pre-existing
  findings re-key; all 6 old keys are present in the previous baseline.
  No new snippet defects.

validate-strict: 477 files, 0 errors. pages/ regenerated.

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

culturebot-reviewer Bot commented Aug 6, 2026

Copy link
Copy Markdown

Claude finished @realmarcin's task in 6m 53s —— View job


Review

  • Read README.md / docs conventions
  • Review migration script + mapping gating
  • Spot-check ontology grounding on migrated edges
  • Verify causal-graph reachability & baselines
  • Static verification (could not execute just qc — see note)
  • Post verdict

The mechanical part of this is sound. I verified independently:

  • 146 / 13 / 5 METPO:2007700 / 2007701 / 2007702 occurrences in data/traits — matches the claimed partition and the 164 ENABLES_RANGE_ON_TRAIT rows removed from conf/predicate_domain_audit_baseline.tsv and reports/predicate_domain_audit.tsv (0 remaining in both).
  • All 18 reversed edges point at nodes actually typed CHEMICAL, so the TRAIT → CHEMICAL gate on the two electron rows is satisfiable, not aspirational.
  • Reachability is unaffected: audit_causal_graphs.py computes UNREACHABLE_FROM_TRAIT over an undirected adjacency (scripts/audit_causal_graphs.py:32), so flipping edge direction can't island a node.
  • The changed != len(targets) guard in migrate_enables_trait_edges.py:237 is symmetric — it catches over-matching (a same-endpoint sibling edge rewritten by accident) as well as under-matching, which is the failure mode that matters for raw-text editing.
  • All 104 rows of mappings/predicate_grounding.tsv still have exactly 9 fields.
  • All 18 reversed description: strings are direction-neutral prose, so leaving them untouched (unlike Chemical-use predicates are declared with an organism domain but used with TRAIT subjects #295, which rewrote them) is correct here.

Two things I'd want changed.


🟡 docs/CURATION_PLAYBOOK.md:147-169 now tells curators the opposite of what this PR establishes.

That section still reads:

What to use instead is not settled — do not assume enables. […] 164 corpus edges already do this. #302 tracks the decision […]

Until #302 lands, prefer pointing the chemical at the graph's process node

and then shows a worked predicate: enables # RO:0002327 example. After this merges, #302 has landed, the count is 0 not 164, and the interim workaround is no longer the right answer — a curator following the playbook would keep writing enables→process rather than confers/has electron donor|acceptor, and would not learn that the donor/acceptor pair is usable again in the trait → chemical direction. This is the one place the repo documents this convention, so the stale text is load-bearing.

Fix this →


🟡 data/traits/metabolism/manganese_oxidation.yaml:86-89 — the acceptor role this PR exists to restore is dropped on this edge.

- subject: molecular_oxygen        # node_type: CHEMICAL, line 48
  predicate: confers
  object: manganese_oxidation_trait
  description: O2 serves as the direct oxidant enabling multicopper-oxidase-mediated
    Mn(II) oxidation.

The published rule is "a CHEMICAL subject whose label or edge description names an electron donor/acceptor role goes to the role-bearing pair" (proposal.md:195). "Direct oxidant" names the acceptor role — O2 is the terminal acceptor for MCO-mediated Mn(II) oxidation — but ACCEPTOR_RE = electron acceptor|terminal electron (scripts/migrate_enables_trait_edges.py:56) matches on wording rather than role, so it fell through to the catch-all. This is one of only four CHEMICAL subjects that landed on confers; I checked the other three (ectoine, surfactant, membrane_unsaturated_fatty_acids) and they are correctly not electron-role edges, so this is the single miss, not a systematic one.

confers isn't false here, just strictly weaker — which is exactly the loss #303 is about. Flagging it as 🟡 rather than 🔴 for that reason. Note it moves the partition to 145/13/6, so proposals/metpo_traitmech_v8/proposal.md (already merged in #320) needs its appendix and counts updated too; if you'd rather record the judgment call than change the merged partition, say so explicitly in the proposal's "two classification notes a reviewer will want" list rather than leaving it silent.

Fix this →


🔵 The proposal deferred the oxygen_preference.yaml#oxygen_terminal_electron_acceptor retyping question explicitly to this PR — "Retyping that node to CHEMICAL/CHEBI:15379 is defensible but is a corpus change, so it is left for the migration PR rather than assumed here" (proposal.md:250). This PR lets it ground to confers and explains why in the description, but the deferral now has nowhere left to point. Either retype it or close the loop with a line in the proposal / a curation note so the question doesn't resurface.

🔵 Advisory only: no record under history/records/ for a 128-file data/traits/** change. The per-file CurationEvents are there and are good, but history/README.md describes the session-level record as the thing that captures which model/tool/issue drove the change.

🔵 main() returns 1 mid-loop after having already written earlier files under --apply, so a collision detected in file 100 leaves 99 migrated. It didn't fire, and it's a one-shot script — noting it only because the docstring sells the guard as making partial application impossible, and it makes per-file partial application impossible, not repo-wide.


Note on verification: uv/python3 are not runnable in this sandbox, so I could not independently execute just validate-strict, just qc, or uv run pytest. Everything above is static analysis of the diff and the corpus; I'm taking the reported green runs at face value.

Requesting changes on the two 🟡 items.
· feat/302-migrate-enables-trait-edges

@culturebot-reviewer culturebot-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Mechanically sound — 146/13/5 partition verified independently, all 18 reversed edges land on nodes actually typed CHEMICAL, the ENABLES_RANGE_ON_TRAIT ratchet is genuinely at 0, and reachability is undirected so the reversals cannot island a node.

Two 🟡 items:

  1. docs/CURATION_PLAYBOOK.md:147-169 still says the #302 decision "is not settled", cites the now-zero 164-edge count, and gives interim enables→process guidance with a worked example. That is the repo's convention doc; after this merges it actively misdirects curators away from the predicates this PR establishes.

  2. data/traits/metabolism/manganese_oxidation.yaml:86-89molecular_oxygen (CHEMICAL) --confers--> manganese_oxidation_trait, description "O2 serves as the direct oxidant". Under the published rule (a CHEMICAL subject whose label or edge description names an electron donor/acceptor role) this is has electron acceptor, reversed. ACCEPTOR_RE matches wording, not role, so it fell to the catch-all. The other three CHEMICAL→confers subjects (ectoine, surfactant, membrane_unsaturated_fatty_acids) are correctly not electron-role edges, so this is the single miss. confers is weaker rather than false — flagged 🟡, not 🔴 — but the weakening is exactly what #303 is about. Fixing it moves the partition to 145/13/6 and needs the merged v8 proposal appendix updated; recording the judgment call there instead is an acceptable alternative, but leaving it silent is not.

Plus three 🔵s in the PR comment (deferred oxygen_preference retyping, missing history/ record, mid-loop abort in the migration script).

Note: uv/python were not runnable in my sandbox, so validate-strict / qc / pytest were not independently re-run.

Two review findings, both valid.

manganese_oxidation described O2 as the "terminal oxidant", which names
the terminal-electron-acceptor role, but ACCEPTOR_RE matched only
`electron acceptor|terminal electron` so the edge fell to the `confers`
catch-all. That is not false, just strictly weaker — precisely the loss
#303 exists to stop. Widened the rule to `oxidant` (and `reductant`, for
symmetry on the donor side) and reversed that edge onto METPO:2007702.
Verified against the pre-migration corpus that the widened rule
reclassifies exactly one edge and no others, so the partition moves
146/13/5 -> 145/13/6 and nothing else shifts. proposals/metpo_traitmech_v8
counts, appendix and observations cells updated to match.

docs/CURATION_PLAYBOOK.md still told curators the #302 decision "is not
settled", cited the now-zero 164-edge count, and gave a worked `enables`
example — the opposite of what this PR establishes, in the one place the
repo documents the convention. Rewritten to state the settled convention
with a table of the three predicates, the trait -> chemical direction of
the electron pair, the node-type gating, and the placeholder-id caveat.

Also closed the loop the proposal had deferred TO this PR: the
oxygen_preference MOLECULAR_FUNCTION node keeps its type and grounds to
`confers`, and the mapping gate (object_types=CHEMICAL) stops it drifting
onto the electron pair later. Recorded in the proposal's classification
notes rather than left dangling.

Two advisory findings filed rather than fixed: #324 (the script's
mid-loop abort can leave a repo-wide partial migration) and #325 (no
history/records entry for a 128-file change, and nothing enforces one).

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

culturebot-reviewer Bot commented Aug 6, 2026

Copy link
Copy Markdown

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


Review

  • Read README.md / docs conventions
  • Verify the two 🟡 fixes from the prior review landed
  • Re-verify partition counts + mapping gating
  • Check baselines / ratchets / proposal consistency
  • Static verification (could not execute just qc — see note)
  • Post verdict

Both 🟡 items from the previous round are properly resolved, and I re-verified the fixes rather than taking the commit message for it:

  • Playbook (docs/CURATION_PLAYBOOK.md:147-193) — rewritten to the settled convention, with the predicate table, the trait → chemical direction of the electron pair, the node-type gating, and the placeholder-id caveat. I checked the gating claim against the source: mappings/predicate_grounding.tsv:102-104 does gate confers to object_types=TRAIT and the electron pair to TRAIT → CHEMICAL, so the sentence is accurate and not aspirational.
  • manganese_oxidation (data/traits/metabolism/manganese_oxidation.yaml:86-95) — reversed to manganese_oxidation_trait --has electron acceptor (METPO:2007702)--> molecular_oxygen; the object is typed CHEMICAL (line 50), so it satisfies the gate. Corpus partition is now 145 / 13 / 6 = 164 (counted directly), ENABLES_RANGE_ON_TRAIT is 0 in both the baseline and the report, and the regenerated HTML page and per-file CurationEvent both match.

I also checked the "widened rule reclassifies exactly one edge" claim independently, since a regex widening is the kind of change that quietly catches more than intended. It holds: classify() guards on subject_type == "CHEMICAL" and targets_in() only considers edges with a TRAIT object, so the new \boxidant\b/\breductant\b alternatives can't reach non-chemical subjects. The one other corpus edge with a CHEMICAL subject and "reductant" in its description — data/traits/ecology/nitrogen_fixing_symbiosis.yaml:156 — points at nitrogen_fixation, not a TRAIT, so it is correctly out of scope. The three blocked_by_node_type rows that appear in reports/predicate_grounding_residual.tsv are pre-existing hand-written confers edges with non-TRAIT objects; they were ungrounded before and are ungrounded now, so that's the gate reporting more precisely, not a regression.


🟡 The count correction stopped at the tables — five prose sites still say 146/13/5, and one of them contradicts the corrected total in the same document.

abb038bd updated the partition table, the appendix, the subject-type breakdown, the grand total, and both traits_addressed cells in the ROBOT template. It did not update the surrounding prose:

Site Says Should say
proposals/metpo_traitmech_v8/proposal.md:187 Edge partition (146 + 13 + 5 = 164) 145 + 13 + 6
proposals/metpo_traitmech_v8/proposal.md:288 Repoint the 146 confers edges … reverse the 18 electron edges 14519
proposals/metpo_traitmech_v8/proposal.md:99 The 18 electron edges currently read … 19
proposals/metpo_traitmech_v8/proposal.md:93 114 of these 146 subjects 145
mappings/predicate_grounding.tsv:102 the 9 node types actually observed across the 146 migrated edges 145

Line 187 is the sharpest: it sits in the "what was verified" list and asserts a partition that line 254 and line 244's Grand total: 145 + 13 + 6 = 164 both contradict, so the document now states two different partitions about 60 lines apart. Line 288 is the adoption plan someone follows literally when METPO mints the real ids, and it gives the wrong number on both the repoint and the reversal.

Blast radius is limited — metpo_proposal_properties_robot.tsv is the file actually submitted upstream and its cells are correct, and the corpus itself is right. But proposal.md is the human-facing submission, and a self-inconsistent count in it is the kind of thing that costs a round-trip with the METPO maintainers. The subject_types pipe-list on predicate_grounding.tsv:102 does not need changing — CHEMICAL still has 3 edges, so all nine types remain observed; only the number in the note text is stale.

Fix this →


🔵 The PR description's own table still reads 146 / 13 / 5. Worth syncing before merge since it's what lands in the squash commit message.

🔵 The previous 🔵s are all handled: the oxygen_preference deferral is closed in the proposal's classification notes (proposal.md:255-265) rather than left dangling, and #324 / #325 were filed for the mid-loop abort and the missing history/records/ entry. Filing rather than fixing is a reasonable call for both.


Note on verification: python3/uv are not executable in this sandbox, so I could not run just validate-strict, just qc, or uv run pytest. Everything above is static analysis of the diff and the corpus — the counts, node types, gating rows, and baseline row totals I checked directly against the files. I'm taking the reported green runs at face value.

Requesting changes on the one 🟡. The data half of this is sound; it's the prose counts that are now internally inconsistent.
· feat/302-migrate-enables-trait-edges

@realmarcin

Copy link
Copy Markdown
Contributor Author

Both 🟡 findings were valid and are fixed; both 🔵s are addressed or filed.

🟡 The O2 acceptor edge — fixed

You're right, and the diagnosis was exact. manganese_oxidation's node describes O2 as the "terminal oxidant", which names the terminal-acceptor role, but ACCEPTOR_RE matched only electron acceptor|terminal electron — so it matched on wording rather than role and the edge fell to the catch-all.

Widened to \boxidant\b (plus \breductant\b for symmetry on the donor side) and reversed that edge onto METPO:2007702.

I checked the widened rule against the pre-migration corpus rather than assuming: it reclassifies exactly one edge — this one — and leaves the other 163 unchanged. So the partition moves 146/13/5 → 145/13/6 and nothing else shifts, which matches your reading. Proposal counts, the appendix, and both observations cells are updated.

🟡 The playbook — fixed

Fully rewritten. It now states the settled convention with a predicate table, spells out that the electron pair runs trait → chemical and why, documents the node-type gating (including that a MOLECULAR_FUNCTION node is not eligible for the pair), and flags the ids as placeholders. The stale "not settled" text, the 164 count, and the enables worked example are gone.

🔵 The oxygen_preference deferral — loop closed

Good catch that the deferral had nowhere left to point. Resolved rather than re-deferred: the node keeps its MOLECULAR_FUNCTION type and its edge grounds to confers, and mappings/predicate_grounding.tsv gates the electron pair to object_types=CHEMICAL so it cannot drift onto the pair later. Written into the proposal's classification notes, which now also record the oxidant-wording rule and why the partition changed.

🔵 Filed, not fixed

Re-verified

just qc green (every ratchet blocking: 0), just validate-strict 477 files / 0 errors, uv run pytest 420 passed, ruff clean, just verify-proposal and just robot-validate-proposal both still PASS (no UNSAT, +6). Reports, both baselines, and pages/ regenerated after the manganese change.

Understood on the sandbox limits — flagging that the counts above come from my runs.

@culturebot-reviewer culturebot-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Both 🟡 items from the last round are properly resolved, and I re-verified rather than trusting the commit message: the playbook now states the settled convention (and its gating claim matches mappings/predicate_grounding.tsv:102-104), the manganese_oxidation edge is reversed onto METPO:2007702 with a CHEMICAL object, the corpus partition counts 145/13/6 = 164, and ENABLES_RANGE_ON_TRAIT is 0 in both baseline and report. The regex widening is correctly scoped — classify() guards on CHEMICAL subjects and targets_in() only sees TRAIT objects, and the one other CHEMICAL-subject edge mentioning 'reductant' (nitrogen_fixing_symbiosis.yaml:156) points at a non-TRAIT node, so 'exactly one edge reclassified' holds.

One 🟡 remains: the count correction was applied to the tables, appendix, subject-type breakdown and ROBOT template, but not to prose. Five sites still say 146/13/5 — proposal.md:187 ('Edge partition (146 + 13 + 5 = 164)'), :288 ('Repoint the 146 confers edges … reverse the 18 electron edges', now 145 and 19), :99 ('The 18 electron edges', now 19), :93 ('114 of these 146 subjects'), and mappings/predicate_grounding.tsv:102 ('across the 146 migrated edges'). Line 187 contradicts the corrected 'Grand total: 145 + 13 + 6 = 164' about 60 lines below it, so the proposal now asserts two different partitions; line 288 is the adoption plan someone follows literally when METPO mints the ids. The submitted ROBOT TSV is correct, so blast radius is limited to the human-facing proposal — but it's a round-trip with the METPO maintainers if it ships. Note the subject_types pipe-list on that mapping row does NOT need changing; CHEMICAL still has 3 edges so all nine types remain observed.

🔵 The PR description's table still reads 146/13/5, which is what lands in the squash message.

Note: python3/uv are not executable in my sandbox, so validate-strict / qc / pytest were not independently re-run; counts, node types, gating rows and baseline totals were checked directly against the files.

The previous commit corrected the partition tables, the appendix, the
grand total and both ROBOT traits_addressed cells, but not the prose
around them — so proposal.md asserted two different partitions about 60
lines apart. Fixed:

  proposal.md:93    114 of these 146 subjects        -> 145
  proposal.md:99    The 18 electron edges            -> 19  (13 + 6)
  proposal.md:187   Edge partition (146 + 13 + 5)    -> (145 + 13 + 6)
  proposal.md:288   Repoint the 146 ... reverse 18   -> 145 ... 19
  predicate_grounding.tsv:102  across the 146 migrated edges -> 145

Line 288 mattered most: it is the adoption plan someone follows
literally when METPO mints the real ids, and it was wrong on both the
repoint count and the reversal count.

The subject_types pipe-list on the confers mapping row is deliberately
unchanged: CHEMICAL still has 3 edges, so all nine node types remain
observed. Line 254 keeps "146/13/5 to 145/13/6" because that sentence
records the correction itself.

Every remaining count claim cross-checked against the corpus:
confers 145, donor 13, acceptor 6, electron 19, total 164. PR
description synced too, since it becomes the squash commit message.

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

culturebot-reviewer Bot commented Aug 6, 2026

Copy link
Copy Markdown

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


Review

  • Verify the five prose count fixes landed
  • Sweep for any remaining stale counts
  • Re-verify corpus partition against the files
  • Cross-check curation records against what each file actually changed
  • Post verdict

The five prose sites are all corrected, and I re-counted rather than trusting the commit message:

  • proposal.md :93 / :99 / :187 / :288 and predicate_grounding.tsv:102 now read 145 / 19 / 145 + 13 + 6 / 145 … 19 / 145. The only surviving 146 in the proposal is line 254, which is the sentence recording the correction — correctly left alone.
  • Corpus partition counted directly: METPO:2007700 145 across 114 files, METPO:2007701 13 across 13 files, METPO:2007702 6 across 4 files = 164. That matches the appendix table (81+32+12+5+5+4+3+2+1 = 145) and both traits_addressed cells in metpo_proposal_properties_robot.tsv ("145 … across 114 trait records", "13 … 13", "6 … 4"), and the CHEMICAL 4 → 3 correction in the observations cell.
  • ENABLES_RANGE_ON_TRAIT is absent from conf/predicate_domain_audit_baseline.tsv and reports/predicate_domain_audit.tsv — 0 in both.
  • The rewritten playbook section (docs/CURATION_PLAYBOOK.md:147-193) is accurate against the source, including the MOLECULAR_FUNCTION-ineligibility claim.

One new 🟡, introduced by the hand-fix in abb038bd rather than by the sweep.


🟡 data/traits/metabolism/manganese_oxidation.yaml:161-172 — the curation record now undercounts the change to its own file.

The event reads:

changes: Migrated 1 causal edge(s) off enables/RO:0002327 with a TRAIT object (1 to has electron acceptor), issue 302.

But this file migrated two edges, not one. Pre-PR (origin/main) it had RO:0002327 on both line 76 (multicopper_oxidase --enables--> manganese_oxidation_trait) and line 95 (the O2 edge). Both were rewritten; the file today carries METPO:2007700 on the multicopper-oxidase edge (line 81) and METPO:2007702 on the reversed O2 edge (line 95). The original event in 8d754188 said "Migrated 2 … (2 to confers)" and was correct on the count; rewriting it in place for the O2 reclassification dropped the confers edge from the record entirely, so the provenance for that migration no longer exists anywhere in the file.

The correct form is the one the script produced for the other mixed file — dissimilatory_manganese_reduction.yaml:159:

Migrated 2 causal edge(s) off enables/RO:0002327 with a TRAIT object (1 to has electron acceptor, 1 to confers)

I checked every MIGRATE_ENABLES_TRAIT_EDGES event in the corpus; this is the only one whose count disagrees with its file, which fits — it's the only record hand-edited instead of regenerated. Worth noting the divergence cuts both ways: re-running the widened script over origin/main would emit 2 … (1 to confers, 1 to has electron acceptor), so the committed record is also not what the script now reproduces.

Not data corruption — the edges themselves are right and the acceptor reversal is correct — but curation history is the audit trail this repo leans on, and a 128-file migration where one record disclaims half its own change is exactly the thing that misleads later.

Fix this →


🔵 proposals/metpo_traitmech_v8/proposal.md:93 — the denominator fix is right, but the numerator was already wrong before this PR: "114 of these 145 subjects are proteins, processes, or environmental conditions." By the appendix table 130 lines below, BIOLOGICAL_PROCESS + GENE_OR_PROTEIN + ENVIRONMENTAL_FACTOR = 81+32+12 = 125 (113 without environmental factors). 114 is the number of trait records, which is what the ROBOT traits_addressed cell correctly says — it looks like record count leaked into a sentence about subjects. Pre-existing from #320, and the argument it supports holds at any of those numbers, so this is genuinely optional — but you're already in the file correcting counts.


Note on verification: just/uv and script execution are not available to me in this sandbox, so validate-strict, qc, and pytest were not independently re-run. Every count above was derived directly from the files with grep against the checked-out branch and against origin/main.

Requesting changes on the one 🟡. The prose-count sweep itself is complete and correct — this is a separate regression from the earlier hand-fix.

· feat/302-migrate-enables-trait-edges ·

@culturebot-reviewer culturebot-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The five prose count sites are all corrected and I re-counted independently: METPO:2007700 = 145 across 114 files, METPO:2007701 = 13 across 13, METPO:2007702 = 6 across 4, total 164. That matches the appendix table, the grand total, and all three traits_addressed cells in the ROBOT template including the CHEMICAL 4 -> 3 fix. The only remaining 146 in proposal.md is line 254, which records the correction itself and is correctly left alone. ENABLES_RANGE_ON_TRAIT is 0 in both the baseline and the report. The rewritten playbook section checks out against the mapping file.

One new 🟡, introduced by the hand-fix in abb038b rather than by the count sweep:

data/traits/metabolism/manganese_oxidation.yaml:164 — the MIGRATE_ENABLES_TRAIT_EDGES event now reads 'Migrated 1 causal edge(s) ... (1 to has electron acceptor)', but this file migrated two edges. On origin/main both line 76 (multicopper_oxidase --enables--> manganese_oxidation_trait) and line 95 (the O2 edge) carried RO:0002327, and both were rewritten — the file today has METPO:2007700 on the first and METPO:2007702 on the second. The original event in 8d75418 said 'Migrated 2 ... (2 to confers)' and had the count right; rewriting it in place for the O2 reclassification dropped the confers edge from the record entirely, so nothing in the file now documents that migration. The correct form is the one the script emits for the other mixed file, dissimilatory_manganese_reduction.yaml:159: 'Migrated 2 causal edge(s) ... (1 to has electron acceptor, 1 to confers)'. I checked every MIGRATE_ENABLES_TRAIT_EDGES event in the corpus and this is the only one that disagrees with its own file — consistent with it being the only record hand-edited rather than regenerated. Re-running the widened script over origin/main would also emit 2, so the committed record is not what the script reproduces.

Not corrupting — the edges are right and the acceptor reversal is correct — but curation history is the audit trail here, and one record disclaiming half its own change is what misleads later.

🔵 proposal.md:93 — the denominator fix is right but the numerator is stale from #320: '114 of these 145 subjects are proteins, processes, or environmental conditions'. By the appendix table, BIOLOGICAL_PROCESS + GENE_OR_PROTEIN + ENVIRONMENTAL_FACTOR = 81+32+12 = 125 (113 without env). 114 is the record count, which is what the ROBOT traits_addressed cell correctly says. Optional, but you are already in the file.

Note: just/uv and script execution are unavailable in my sandbox, so validate-strict / qc / pytest were not re-run. All counts above were derived from the files directly against this branch and origin/main.

…review)

Hand-editing that file's curation event to describe the O2 reversal
overwrote the half the script had written: the record claimed "Migrated 1
causal edge(s) ... (1 to has electron acceptor)" while the file actually
migrated two — multicopper_oxidase --confers--> the trait as well. A
128-file migration where one record disclaims half its own change is
exactly what misleads a later reader of the audit trail.

Rewritten to the form the script emits for the other mixed file
(dissimilatory_manganese_reduction), so the record now matches both the
file and what a re-run would reproduce, while keeping the explanation of
why the O2 edge was reversed.

Audited every MIGRATE_ENABLES_TRAIT_EDGES event in the corpus against its
own file rather than fixing only the reported one: 0 remaining
disagreements across all 128.

Also fixed the pre-existing numerator at proposal.md:93 (from #320): "114
of these 145 subjects" was the trait-RECORD count leaking into a sentence
about subjects. By the appendix it is 125 (81 BIOLOGICAL_PROCESS + 32
GENE_OR_PROTEIN + 12 ENVIRONMENTAL_FACTOR), now stated with the breakdown
so it cannot drift from the table again.

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

Copy link
Copy Markdown
Contributor Author

Valid catch, and worse than reported — fixed, plus a corpus-wide audit so it can't be hiding elsewhere.

🟡 The manganese_oxidation curation event

You're right. Hand-editing that event to describe the O2 reversal overwrote the half the script had written: it claimed 1 edge / has electron acceptor when the file migrated two — multicopper_oxidase --confers--> manganese_oxidation_trait as well. Your point that the divergence cuts both ways is also right: a re-run of the widened script emits 2 … (1 to has electron acceptor, 1 to confers), so the committed record matched neither the file nor the script.

Rewritten to exactly the form the script emits for the other mixed file (dissimilatory_manganese_reduction), keeping the explanation of why the O2 edge was reversed.

I then audited every MIGRATE_ENABLES_TRAIT_EDGES event in the corpus against its own file rather than trusting that this was the only one — parsing each record's claimed count and comparing it to the actual number of METPO:20077xx edges in that file. Result: 0 disagreements across all 128. Your inference that it was the only hand-edited record holds, but it's now checked rather than assumed.

🔵 The 114/145 numerator — fixed

Good spot, and you're right it predates this PR. 114 is the trait-record count (correct in the ROBOT traits_addressed cell) leaking into a sentence about subjects. It's 125 by the appendix — now written as 125 … (81 BIOLOGICAL_PROCESS + 32 GENE_OR_PROTEIN + 12 ENVIRONMENTAL_FACTOR) so the number carries its own derivation and can't drift from the table again.

Re-verified

just qc green, just verify-proposal PASS, uv run pytest 420 passed, pages/ regenerated for the changed record.

@culturebot-reviewer

culturebot-reviewer Bot commented Aug 6, 2026

Copy link
Copy Markdown

Claude finished @realmarcin's task in 3m 51s —— View job


Review

  • Verify the manganese_oxidation curation-event fix landed
  • Verify the claimed corpus-wide audit of MIGRATE_ENABLES_TRAIT_EDGES events
  • Check the 114 → 125 numerator fix
  • Sweep for regressions in the latest commit
  • Post verdict

The 🟡 is resolved. I re-derived everything rather than reading the commit message.

The event now matches its file. data/traits/metabolism/manganese_oxidation.yaml:164 reads Migrated 2 causal edge(s) … (1 to has electron acceptor, 1 to confers) — the same form the script emits for dissimilatory_manganese_reduction.yaml:159 — and keeps the explanation of why the O2 edge was reversed. The HTML page matches.

The corpus-wide audit claim holds. I parsed all 128 MIGRATE_ENABLES_TRAIT_EDGES events and compared each claimed count against the actual METPO:20077xx occurrences in that file. Every one agrees: 27 files claim >1 (summing to 63) and 101 claim 1, total 164 across 128 files, matching the direct predicate count. The claim labels sum to 145 confers / 13 donor / 6 acceptor, and the corpus has METPO:2007701 ×13 across 13 files and METPO:2007702 ×6 across 4 files — so 145 confers by subtraction, matching the appendix (81+32+12+5+5+4+3+2+1) and all three traits_addressed cells.

Counts are internally consistent now. proposal.md:93 reads 125 with its derivation inline, which is what the appendix table gives; the only surviving 146 in the proposal is line 254, the sentence recording the correction. ENABLES_RANGE_ON_TRAIT is absent from both conf/predicate_domain_audit_baseline.tsv and reports/predicate_domain_audit.tsv. The corpus diff is exactly symmetric — 164 removed predicate:/predicate_id: pairs, 145+13+6 added, and 76 changed subject:/object: lines = 19 reversals, no stray edits.

One thing I went looking for because the reversals could have hidden it, and it's clean: reversing photoorganoheterotrophic_trait --has electron donor--> organic_compound produces a baseline key that collides with the pre-existing uses carbon source edge between the same two nodes, since the evidence-snippet key is graph:subject->object[i] and omits the predicate (same for chemoorganoheterotrophic). That doesn't mask anything — load_baseline/compare store a count per key and flag findings past that count (scripts/audit_evidence_snippets.py:409), so a doubled key is accepted as 2, not 1. Row total unchanged at 2737.


🔵 The two open advisories are both filed rather than fixed, which is the right call for a PR this size: #324 (the mid-loop return 1 leaves a repo-wide partial migration; the docstring's guard is per-file only) and #325 (no history/records/ entry for a 128-file data/traits/** change). Nothing enforces the latter today, so it will need the enforcement question answered separately.

Approving. The migration, the reversals, the gating, the ratchet burn-down, and the audit trail all line up.


Note on verification: uv/python3 execution is not available in this sandbox, so just validate-strict, just qc, and uv run pytest were not independently re-run — I'm taking the reported green runs at face value. Every count above was derived directly from the files on this branch and origin/main.
· feat/302-migrate-enables-trait-edges

@culturebot-reviewer culturebot-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Verified the fix and re-derived every count independently. The manganese_oxidation curation event now reads 'Migrated 2 causal edge(s) ... (1 to has electron acceptor, 1 to confers)', matching both its file and the form the script emits for the other mixed record. I audited all 128 MIGRATE_ENABLES_TRAIT_EDGES events against their own files rather than taking the claim: every per-file claimed total matches its actual METPO:20077xx count, and the per-predicate claim labels sum to 145 confers / 13 donor / 6 acceptor = 164, which matches the corpus (13 donor across 13 files, 6 acceptor across 4 files). The 114 -> 125 numerator fix at proposal.md:93 is right and now carries its own derivation (81+32+12), and the only surviving '146' in the proposal is line 254, the sentence recording the correction. ENABLES_RANGE_ON_TRAIT is absent from both the baseline and the report. The corpus diff is exactly symmetric: 164 removals of predicate/predicate_id pairs, 145+13+6 additions, and 76 subject/object lines = 19 reversals, no stray edits.

One thing I checked because the reversals could have hidden it: reversing photoorganoheterotrophic_trait -> organic_compound collides with the pre-existing 'uses carbon source' edge between the same nodes, since the evidence-snippet baseline key omits the predicate. That is handled correctly -- the baseline stores a count per key (audit_evidence_snippets.py:409), so the duplicate key is counted, not masked, and the row total is unchanged at 2737.

No blocking issues. Remaining items are the already-filed #324 (mid-loop abort) and #325 (no history/records entry for the 128-file change), both advisory.

Note: uv/python execution is unavailable in my sandbox, so validate-strict / qc / pytest were not independently re-run; all counts above came from the files directly.

@realmarcin
realmarcin merged commit 1cd91c4 into main Aug 6, 2026
8 checks passed
@realmarcin
realmarcin deleted the feat/302-migrate-enables-trait-edges branch August 6, 2026 02:28
realmarcin added a commit that referenced this pull request Aug 6, 2026
…or (PR #332 review)

Three review findings, all valid.

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
realmarcin added a commit that referenced this pull request Aug 6, 2026
… zero (#327, closes #301) (#332)

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

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

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

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

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

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

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

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

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

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

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

Three review findings, all valid.

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
realmarcin added a commit that referenced this pull request Aug 6, 2026
* Say what write_validated_trait actually does to formatting (#322)

The comment claimed that re-running the helper over an existing file
"produces a byte-identical diff". Measured against the corpus: it holds
for 127 of 477 files. The other 350 are reformatted, because safe_dump
re-wraps long strings at its own width and drops hand-written quoting.

The claim is not harmless. It is what a bulk-script author reads before
deciding how to rewrite N files, and believing it means burying the real
change in reflow churn across every long string in every file touched --
the difference between a reviewable migration and an unreviewable one.
Both the #323 and #328 migrations edit raw lines instead, and this issue
exists because the claim was checked rather than trusted.

Corrected to state the real behaviour with the measured number, to say
plainly that it matters for BULK use and not for a single record the
helper already owns, and to note that making the claim true would mean
normalising all 477 files once behind a round-trip test -- a large
one-time reformat, which is the part of #322 still open.

Three tests pin the two causes (re-wrapping, dropped quoting) and the
case where the round trip DOES hold, so the claim cannot quietly return.

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

* Bind the round-trip tests to the helper's real options (PR #343 review)

The finding is exact and it is this PR's own thesis one level up. The
tests asserted on yaml.safe_dump against a re-declared DUMP_OPTS dict,
with nothing tying it to the helper. Adding width=4096 to the real
options would have stopped the re-wrapping, falsified both the comment's
stated cause and its 350/127 counts, and left every test passing --
because they were testing PyYAML's defaults, not write_validated_trait.

Fixed structurally rather than by adding another assertion: the options
are now EMIT_OPTS at module scope in write_validated.py and imported by
the tests, so there is no copy to drift. The helper composes them with
its yaml_kwargs exactly as before.

Rewritten to exercise the real thing:

- a hand-edited corpus file written through write_validated_trait to a
  tmp_path comes back with different bytes
- the helper's OWN output round-trips byte-identically, which is the
  boundary that makes it safe for one record and unsafe for a bulk pass
- the documented 127/350 split is asserted against the live corpus, so
  the numbers in the comment cannot go stale as a snapshot -- which is
  how the original claim came to mislead in the first place. It will fail
  loudly when #322's normalisation lands, which is the right moment to
  update them.

Canaried by injecting width=4096 into the REAL options: the split test
fails. The first test still passes there, because quoting differences
alone keep some files reformatted -- worth knowing that the split test is
the one carrying that guarantee.

The corpus walk is lru_cached; three tests need it and each pass parses
all 477 records (20s -> 7s).

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

* Bind the split to the emit path, and stop the docstring over-claiming (PR #343 round 2)

The 🟡 is a fair hit: that test's docstring said "change them so the
reformatting stops and this fails", and I had ALREADY measured that it
does not -- injecting width=4096 leaves it passing, because dropped
quoting alone still reformats some files. I reported that honestly in the
PR comment and then left the docstring asserting the opposite, which is
worse than not having measured it. Corrected to say what is true: this
one demonstrates the behaviour end to end, and the corpus-split test is
what carries the guarantee.

The 🔵 was the same class as the original finding, one step smaller:
_split() rebuilt `yaml.safe_dump(doc, **EMIT_OPTS)` itself, so it bound
to the options but not to how they are COMPOSED with yaml_kwargs. A
change to the composition would have gone unnoticed. Extracted
emit_trait_yaml() as the single emit path, used by write_validated_trait
and imported by the tests, so there is nothing left to re-implement.

Canaried the composition specifically this time, not just the options
dict: injecting width into the composition expression fails the split
test.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
realmarcin added a commit that referenced this pull request Aug 7, 2026
… (#344)

* Normalise the corpus so the round-trip claim becomes enforceable (#322)

write_validated_trait's comment claimed a byte-identical round trip. #343
corrected the claim to match reality -- it held for 127 of 477 records.
This makes the claim TRUE instead, which is the half #322 left open.

350 records are rewritten through the helper's own emission path. No data
changes; only formatting. safe_dump re-wraps long strings at its own
width and drops hand-written quoting, so the diff is entirely those two
things.

Verified three independent ways rather than trusted:

- The normaliser is TWO-PASS (#324's lesson): it computes and
  semantically checks every file before writing any, and aborts on the
  first mismatch rather than leaving the corpus half-normalised. 0
  problems across 477.
- Every rewritten file was re-parsed and compared to its pre-write
  document; a formatting pass that changed data would have failed there.
- Independently, every file's parsed document was compared against
  `git show HEAD:` afterwards. 477 compared, 0 semantic differences.

The strongest evidence is what did NOT change: pages/, reports/ and
conf/ are byte-identical, because every generator and audit parses the
YAML. Identical data in, identical artifacts out.

The tests flip from documenting the gap to enforcing its absence. What
asserted a 127/350 split now asserts that NO record fails to round trip,
and the end-to-end test asserts equality rather than difference.
Canaried by re-quoting a single scalar by hand: the suite fails.

The payoff is that bulk scripts can use this helper again. #323, #328 and
#341 all hand-rolled raw-line editors specifically to avoid the reflow
churn, and that workaround is no longer necessary.

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

* Re-trigger CI

The initial push produced no workflow runs at all despite the diff
matching several paths: filters (data/traits/**, src/traitmech/**.py,
tests/**.py). Close/reopen did not trigger them either. Empty commit to
fire a synchronize event.

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

* Re-trigger CI after the Actions outage

This PR was opened at 19:24Z on 2026-08-06, during a critical GitHub
Actions incident (stspg.io/rcz3fcm83sff) in which webhook triggers were
throttled to ~15% and many push and pull_request events never created
workflow runs. It received none, so it has sat CLEAN-looking and entirely
unverified -- which is the state #346's detector now reports on every
merge to main, and it flagged this PR within a minute of landing.

The incident is resolved. Empty commit to fire a synchronize event now
that they are being delivered again.

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

* Pin the corpus total, and fix prose the flip contradicted (PR #344 review)

Both 🟡 are fair and the first is a regression I introduced when flipping
the test. _split() skips anything that will not parse. The old assertion
was `(len(same), len(changed)) == (127, 350)`, which pinned the total at
477, so a skipped record failed it. `assert not changed` does not -- a
record edited into invalid YAML would drop silently out of the guard and
the test would pass. Now asserts len(same) == len(TRAITS) as well.
Canaried by appending invalid YAML to a record: it fails, and names the
count.

The second is the stale-prose-beside-updated-code mistake I have now made
several times in this session. The module docstring still opened with the
claim being "false for most of the corpus" three lines above saying it is
now true, and test_the_helpers_own_output_round_trips still described the
helper as "unsafe for a bulk rewrite" -- the exact opposite of what this
PR establishes and of write_validated.py's own comment. Rewritten: the
docstring is now past-tense about the pre-#322 state, and that test is
described as what it actually asserts, the emitter's idempotence, which
is a different property from the corpus being in that form.

The 🔵 about a `str` representer emitting block scalars for the ~50
curation-history prose blocks is a good idea and is filed as #347 rather
than taken here: it changes the emitted format, so it would mean
re-normalising all 477 records inside a PR already under review, and the
round-trip test would then enforce whichever form is chosen -- a one-way
decision worth making deliberately.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
realmarcin added a commit that referenced this pull request Aug 7, 2026
…350 round 3)

The 🟡 is a good catch and neatly framed: the failure message told
contributors to set source=local, which the tests added LAST round prove
no longer exempts anything. It also could not be caught by qc, being
prose in a passing gate. Rewritten to name what actually works -- repoint
to a real slot, or MINT it as #342 did -- and to say plainly that
source=local does not exempt and the escape is ALLOWED_UNBACKED, keyed to
the CURIE.

The gate read only the mapping table. That is not the only route into the
corpus: ground_causal_predicates writes from the table, but a curator can
type a predicate_id straight into a record, and #342's whole point is
that the CURIE in the RECORD is what a reader believes. It now checks
both, reporting an example file per offending CURIE so it can be found.

Canaried by typing biolink:not_a_slot into a record: exit 1, CURIE and
file named. The first attempt at that canary was a no-op -- it targeted
RO:0002327 in crispr_cas_system.yaml, which #323 had already migrated
away -- and reported a false pass until I checked the substitution
actually happened.

Also fixed the 9-vs-8 record count in both the v9 row and the mapping
note, and guarded relative_to() against a traits dir outside the repo,
which the new tests hit immediately (the same guard audit_causal_graphs
already carries).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
realmarcin added a commit that referenced this pull request Aug 7, 2026
* Stop the encodes row claiming provenance it does not have (#342)

mappings/predicate_grounding.tsv grounded `encodes` to biolink:encodes
with source=biolink and a note claiming an "exact label match against
biolink slot". The pinned data/raw/biolink-model.yaml (4.4.0) has no such
slot -- the gene-to-product slot is `has gene product` -- so the CURIE
resolved to nothing upstream.

Swept all 13 biolink: CURIEs used in the mapping table against the model.
biolink:encodes is the ONLY one with no backing slot; the other twelve
resolve. That answers the question the issue asked and bounds the problem
to one row.

The row keeps the coinage, because a gene CLUSTER to protein COMPLEX edge
does not fit `has gene product`'s range of gene product mixin --
repointing would trade a missing term for a wrong one. What changes is
the claim: source=local rather than biolink, and predicate_id
skos:closeMatch rather than exactMatch, since there is no upstream term
to be exactly equal to. `local` joins METPO/biolink/RO/RDFS in the
column's vocabulary.

The real defect was not the coinage but that nothing noticed. The signal
had been sitting in reports/biolink_coverage.tsv all along as the only
applied biolink: CURIE with both backing columns empty -- a report nobody
consults is not a check. audit-biolink-curies is that check, and is in qc.

Deliberately narrow: it does NOT require the corpus LABEL to match a slot
name. Most labels are synonyms -- generates, yields, forms and six others
all ground to biolink:produces -- so requiring that would flag correct
rows. It requires only that the CURIE names a real slot, and exempts rows
declaring source=local, which keeps a coinage legitimate while making it
say so.

Canaried by setting the row back to source=biolink: the gate fails and
names it. Six tests, including that a synonym label passes and that a
local row is exempt.

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

* Mint encodes rather than disclaim it in a table nobody reads (PR #350 review)

The 🟡 is right and it changes the fix rather than refining it. source=local
lived in one column of one TSV, ground_causal_predicates discards it
outright, and the unqualified `biolink:encodes` was what got written into
the corpus -- so a reader of a trait record saw an apparently-upstream
term and the disclaimer was nowhere near them. Annotating provenance at a
layer the data does not carry is not fixing the claim.

Minted METPO:2007813 `encodes` into the v9 cohort instead, and migrated
all 10 edges across 8 records. That removes the false claim from the DATA
rather than describing it elsewhere, and puts the term in the same
pending-upstream state as the other 13 v9 predicates instead of a
category of its own.

RO:0002205 (has gene product) was considered and rejected on the same
grounds the coinage existed for: it relates a GENE to a gene product,
while every corpus edge relates a gene CLUSTER or OPERON to a protein
COMPLEX or a biosynthetic PROCESS. Recorded as skos:closeMatch at medium
confidence -- the granularity differs on both sides, and saying `high`
would overstate it.

audit-biolink-curies stays. It now passes with no exemptions at all,
which is a better resting state than passing because one row opted out,
and it still catches the next unbacked CURIE.

Worth recording: the #322 round-trip guard caught this commit. Inserting
curation events as hand-built YAML left 8 records non-canonical, the
guard failed, and re-emitting them through the helper fixed it -- which
is exactly the workflow #322 was for, on the first bulk edit after it
landed.

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

* Key the escape to CURIEs, and gate the report that knew (PR #350 round 2)

Both 🔵s are the PR's own thesis pointed at the PR.

The exemption was `source == "local"`, a free-text cell. A future
unbacked CURIE could have been silenced by typing five characters into a
TSV -- the same shape of failure this gate exists to catch, one level up,
and exactly what the reviewer said. Now an explicit ALLOWED_UNBACKED set
keyed to the CURIE, following conf/id_label_targets.yaml's `exceptions:`
convention. It is EMPTY, because #342's only coinage was minted as
METPO:2007813 rather than exempted, so adding an entry is a code change
someone reviews rather than a cell edit. Two tests: source=local alone no
longer exempts, and an explicitly allowed CURIE does.

reports/biolink_coverage.tsv is now in audit-derived-reports. It had
drifted -- regenerating it moved 549 lines unrelated to this change
(produces METPO:2000202 -> METPO:2007800, defines residual -> applied) --
and it is the report that recorded biolink:encodes as unbacked for
however long that was true. A stale report nobody reads is even less of a
check than a current one, which is the argument this PR is built on.

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

* Check the corpus too, and stop the message contradicting the code (PR #350 round 3)

The 🟡 is a good catch and neatly framed: the failure message told
contributors to set source=local, which the tests added LAST round prove
no longer exempts anything. It also could not be caught by qc, being
prose in a passing gate. Rewritten to name what actually works -- repoint
to a real slot, or MINT it as #342 did -- and to say plainly that
source=local does not exempt and the escape is ALLOWED_UNBACKED, keyed to
the CURIE.

The gate read only the mapping table. That is not the only route into the
corpus: ground_causal_predicates writes from the table, but a curator can
type a predicate_id straight into a record, and #342's whole point is
that the CURIE in the RECORD is what a reader believes. It now checks
both, reporting an example file per offending CURIE so it can be found.

Canaried by typing biolink:not_a_slot into a record: exit 1, CURIE and
file named. The first attempt at that canary was a no-op -- it targeted
RO:0002327 in crispr_cas_system.yaml, which #323 had already migrated
away -- and reported a false pass until I checked the substitution
actually happened.

Also fixed the 9-vs-8 record count in both the v9 row and the mapping
note, and guarded relative_to() against a traits dir outside the repo,
which the new tests hit immediately (the same guard audit_causal_graphs
already carries).

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

* Parse the corpus, and fix the comment I missed a third time (PR #350 round 4)

The 🟡 is the same defect a third time in this PR: I corrected the
remedy in the script's failure message last round and left it in the
justfile recipe comment. As the reviewer notes, qc cannot catch it --
prose in a passing recipe, which is the class this PR was opened over.
Corrected, and while there, the comment now also states that the gate
checks the corpus, which it had not said either.

The corpus sweep regexed YAML in a module that already imports yaml. Now
parses causal_graphs[].edges[].predicate_id properly. That is not just
tidiness: a text scan matches a CURIE quoted inside curation_history
prose, and several records now contain exactly that BECAUSE they describe
this issue -- methanogenesis.yaml and intracellular_inclusion.yaml both
mention biolink:encodes in their history. A regex would have reported
those as live groundings the moment the phrasing shifted. A test pins it.

Switching to parsing broke two of my own tests, correctly: their fixtures
used a flat `edges:` list rather than the real
causal_graphs[].edges[] shape, so they would have passed through the
parsing version unseen and proved nothing. Fixtures rebuilt in the real
structure.

Re-canaried after the change: a CURIE typed into a record still exits 1
and names the file.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

164 enables edges point at TRAIT nodes, violating RO:0002327's range — and the enables mapping row is ungated

1 participant