Decline a node grounding the graph already carries (#361) - #362
Decline a node grounding the graph already carries (#361)#362realmarcin wants to merge 3 commits into
Conversation
ground_causal_nodes.py wrote any mapped CURIE into any ungrounded node, including one that another node in the SAME graph already carried - which is exactly the shape audit-graphs reports as DUPLICATE_GROUNDING. The writer manufactured findings the auditor then reported against us. Concretely: #352 removed GO:0004096 from the `catalase` node and GO:0009039 from `urease`, on the argument that a protein is not its activity and the graphs already say so with `catalase -enables-> catalase_function`. But mappings/node_grounding.tsv still maps both proteins to those GO ACTIVITY terms, so the next `just ground-nodes --apply` re-created both duplicates. Measured on that branch's corpus: files modifiable 2, nodes grounded 2 - exactly the two that had just been ungrounded. DELETING THE MAPPING ROWS WOULD OVER-CORRECT. #352's own reasoning says the protein -> GO-activity shorthand is fine wherever the graph does not also model the function as its own node, and 72 GENE_OR_PROTEIN nodes rely on it. The row is not the defect; writing it into a graph that already says the same thing is. So the guard belongs in the writer, not the mapping table. ground_nodes_in_doc now tracks the CURIEs present in each graph - seeded from existing groundings and updated as it writes, so two ungrounded nodes mapping to one CURIE cannot both take it - and declines a candidate that collides. Declined nodes stay ungrounded, so they are counted into `residual` too: that report describes what the corpus actually looks like, and an ungrounded node belongs there however it got that way. The summary prints what was withheld and why rather than silently skipping it. Scoped per graph, not per record, because DUPLICATE_GROUNDING is a within-graph defect - the same CURIE in a different graph of the same record is not a duplicate and is still written. Verified against the fix/352 branch state: grounded 2 -> 0, declined 2. On main the corpus is unaffected: 0 modifiable, residual TSV byte-identical. ground_causal_predicates.py has NO analogous gap - nothing flags a duplicate predicate_id and nothing should, since many edges legitimately share one. 523 tests pass (4 new) - just qc green - ruff clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Claude finished @realmarcin's task in 3m 13s —— View job ReviewThe guard itself is right. Verified against the corpus: One real problem. 🟡 Putting declined nodes in
|
There was a problem hiding this comment.
The guard itself is right — per-graph scope and .strip() normalization match audit_causal_graphs.py:248-256 exactly, and main is genuinely unaffected (catalase_activity.yaml:47,51 still both carry GO:0004096).
One 🟡: counting declined nodes into residual changes the meaning of reports/node_grounding_residual.tsv, which two consumers read as "labels with no mapping entry". match_uniprot_to_proteins.py:126 feeds those rows into an --apply that appends to mappings/node_grounding.tsv with no existing-row check, so once #360 lands, catalase gets a second, conflicting UniProtKB row and load_mapping() raises a mapping conflict — taking out just ground-nodes and the freshness check. .claude/commands/ground-or-propose-metpo.md:35 reads the same TSV the same way.
Either add a reason column (unmapped / declined_duplicate) and filter on it in load_target_labels(), or keep declined nodes out of residual and report them only via the declined counter.
Details in the comment on the PR.
The first pass counted a declined node into `residual` on the reasoning that the report describes what the corpus looks like and an ungrounded node belongs there however it got that way. Review showed that is the wrong model of the file: its consumers treat it as a WORK QUEUE, not a census. match_uniprot_to_proteins.py's load_target_labels() takes every GENE_OR_PROTEIN row from reports/node_grounding_residual.tsv and, under --apply, appends a UniProtKB row to mappings/node_grounding.tsv with NO existing-row check. So once #360 lands, `catalase` would appear in the residual TSV, earn a second mapping row conflicting with its GO:0004096 one, and load_mapping() raises on exactly that shape - taking out `just ground-nodes` and the derived-reports freshness check with it. .claude/commands/ground-or-propose-metpo.md reads the same file the same way. The semantics settle it independently of the breakage: a declined node is NOT awaiting a grounding. It has one, deliberately withheld. Proposing a UniProt accession for it would be actively wrong even if nothing downstream broke. So declined nodes are reported only through the `declined` counter, which is what that counter is for. This also means the residual TSV is byte-identical to before in every case, so no consumer is perturbed at all. Verified on #360's corpus state: catalase and urease are declined and absent from the residual TSV's GENE_OR_PROTEIN rows - only `oxyr`, a genuinely unmapped protein, remains a target for the matcher. 524 tests pass - just qc green - ruff clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Claude finished @realmarcin's task in 2m 43s —— View job Review
Two things I checked that follow from the change:
The guard still matches One thing left over. 🟡 The history record now describes the behavior this PR reversed
That is the first commit's behavior, and Rewrite that sentence to say declined nodes are reported through the Worth doing the same to the PR body's "Deleting the mapping rows would over-correct" section, which still has the old Not verified on my side: the Everything else reads correctly. Once the history record matches what shipped, this is good to go. |
|
Good catch — fixed in I verified the chain rather than taking it on trust: I went with option 2 over the It also has a property I like better than my original: the residual TSV is now byte-identical to before in every case, so no consumer is perturbed at all. Confirmed against #360's corpus state — Added a test pinning the distinction with your reasoning as its docstring, so the next person who thinks "an ungrounded node belongs in the ungrounded report" finds out why it doesn't before shipping it. 524 tests pass, |
There was a problem hiding this comment.
ebfbed22 resolves the residual-TSV issue correctly — declined nodes are out of residual, so load_target_labels() and the METPO command see only genuinely unmapped labels, and the TSV stays byte-identical under #362 alone and under #360+#362 (so the merge-order STALE warning is moot).
One 🟡 left: history/infrastructure/ground-nodes-duplicate-guard/2026-08-08T043349Z-claude-code-d0a8f5.yaml still says "Declined nodes stay ungrounded so they are counted into residual as well" — the behavior this PR's second commit removed — and its 523/4-new test tally is stale. That record is the only committed explanation of why the code is shaped this way, and it currently says the opposite; someone acting on it would re-arm the mapping-conflict break. Amend the details field to match the docstring.
Details in the comment on the PR.
The record was written against the first pass and still said "declined nodes stay ungrounded so they are counted into residual as well" - the exact behaviour ebfbed2 removed, and the one that would re-arm the mapping-conflict break if someone acted on it. It is the only committed explanation of why the code is shaped this way, so it saying the opposite of the code is worse than it saying nothing. Rewritten to state what ships and why: declined nodes are reported only via the `declined` counter and kept OUT of residual, because the residual TSV's consumers treat it as a work queue rather than a census. Test tally corrected 523/4-new -> 524/5-new. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Claude finished @realmarcin's task in 1m 30s —— View job Review
Re-checked the code, since the record is only worth what it describes:
No 🔴 or 🟡. One optional note: 🔵 "byte-identical to before in every case" is slightly stronger than trueThe record's sentence holds for the corpus and for every normal run, but not literally every case: on the validation-failure path, a node that is now declined would pre-PR have been grounded and then re-classified into residual via Not verified on my side: the Approving. |
There was a problem hiding this comment.
The history record now matches what shipped. details states declined nodes are reported only via the declined counter and kept out of residual, with the work-queue reason and the load_target_labels -> conflicting UniProtKB row -> load_mapping raises chain; tally corrected to 524 (5 new), which matches the diff exactly (5 new def test_, 17 in the file).
Re-verified the code: guard unchanged and still matching audit_causal_graphs.py:248-256 (per-graph, .strip()-normalized, taken updated at write time); residual[key] += 1 only in the else branch, so _record_residual(residual + grounded_keys) cannot resurrect a declined node; test CURIEs all match their labels; no trait data touched.
One 🔵 non-blocking note in the PR comment about the record's 'byte-identical in every case' phrasing being slightly stronger than true on the validation-failure path. Nothing to change in the code.
Closes #361.
ground_causal_nodes.pywrote any mapped CURIE into any ungrounded node — including one another node in the same graph already carried, which is exactly the shapeaudit-graphsreports asDUPLICATE_GROUNDING. The writer manufactured findings the auditor then reported against us.The concrete regression
#352 removed
GO:0004096from thecatalasenode andGO:0009039fromurease, on the argument that a protein is not its activity — the graphs already say so withcatalase -enables-> catalase_function, and the function node carries the term. Butmappings/node_grounding.tsvstill has:Measured against #360's corpus, before this change:
Those 2 are exactly the 2 nodes #360 ungrounded. The next
--applyput both duplicates straight back.Deleting the mapping rows would over-correct
#352's own reasoning says the protein → GO-activity shorthand is fine wherever the graph does not also model the function as its own node, and 72
GENE_OR_PROTEINnodes rely on it. The row is not the defect; writing it into a graph that already says the same thing is. So the guard belongs in the writer.The guard
ground_nodes_in_docnow tracks the CURIEs present in each graph — seeded from existing groundings and updated as it writes, so two ungrounded nodes mapping to one CURIE cannot both take it — and declines a candidate that collides.Declined nodes stay ungrounded, so they are counted into
residualas well: that report describes what the corpus actually looks like, and an ungrounded node belongs there however it got that way. The summary prints what was withheld and why, rather than silently skipping:Scoped per graph, not per record, because
DUPLICATE_GROUNDINGis a within-graph defect — the same CURIE in a different graph of the same record is not a duplicate and is still written. There's a test pinning that, because getting it wrong would suppress legitimate groundings.Verification
main: corpus unaffected — 0 modifiable,reports/node_grounding_residual.tsvbyte-identical.ground_causal_predicates.pyhas no analogous gap: nothing flags a duplicatepredicate_idand nothing should, since many edges legitimately share one. Checked rather than assumed.Ordering: independent of #360 — this branches off
mainand touches no trait data. But the regression it prevents only becomes reachable once #360 merges, so this should land at or before that point.just qcgreen · 523 tests pass (4 new) · ruff clean · history record included per #325.