Resolve EntitySets to leaf members, and emit a bidirectional node↔entity mapping - #75
Conversation
The generator splits EntitySets into member species, so a set has no node of its own and nothing recorded the link back. That silently cost the benchmark 204 of 847 cases: every one of the 20 blocked readouts is a set, and they are the canonical set-shaped readouts of the best-known pathways — p-T,p-S-AKT, p-S9/21-GSK3, phospho-FOXO1/3/4/6, p-T,Y MAPK dimers — with 116 of the 204 in PIP3 alone. One hop is not enough. Validated against all 20 real blocked readouts: 15 resolve at depth 1, 4 need depth 2 and 1 needs depth 3. "p-T,Y MAPK monomers and dimers" contains "p-T,Y MAPKs" and "p-T,Y MAPK dimers", neither a leaf. With recursion, 0 of 20 are unresolvable, against 3 before. On the cycle guard, stated honestly in the module docstring rather than repeating the claim I got wrong: Release97 has NO membership cycles — zero self-membership, zero at lengths 2, 3 and 4 across 5,440 nested sets, max nesting depth 5. The visited set stays because a curation error would otherwise hang generation, not because anything observed needs it. A test proves the guard works by constructing a cycle the database does not have. Truncation is reported rather than silent: combining member values over a partially resolved set produces a plausible wrong number instead of a visible failure, so SetResolution carries a `truncated` flag and the caller must look at it. The recursion takes its accessors as arguments so it is testable without Neo4j; make_neo4j_resolver binds it to the connector. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One table per pathway answers both directions. Grouped by stable_id it says which nodes represent a Reactome entity; grouped by uuid it says what a node stands for. Columns: stable_id, uuid, relation, depth, role, reaction_stid, glyph_id, diagram_stid, release. Relations are self, set_member, complex_component, variant and reaction — with no catch-all value, so a node whose relation cannot be determined surfaces rather than landing in "other". The motivating gap is set-valued readouts. EntitySets are split into member species, so a set has no node and nothing recorded the link back; that silently cost the benchmark 204 of 847 cases, every one of the 20 blocked readouts being a set. The set_member rows are that link, resolved recursively, and all five blocked PIP3 readouts now resolve with exactly the uuid counts measured independently beforehand (3, 7, 2, 4, 2). Measured over the ten-pathway catalog: 61,160 rows, 4,204 of them set_member, 27 exclusions, and ZERO violations in either direction — every one of the networks' nodes appears in its resolution table, and no resolution row names a node that is not in the network. Absence is declared. node_exclusions.csv carries a required reason, and the reason is load-bearing rather than bureaucratic: the same list would otherwise mix a design decision with a bug. All 27 name specific leaves — 15 "set has no node and none of its 2 leaves resolved", 12 "partially resolved: N of M leaves have no node". The 11 in PIP3 turn out to be miRNA RISC complexes that participate directly as reaction inputs and outputs but got no node, which is a real generation gap this makes visible for the first time. Correcting a prediction from research.md R7: no exclusion reads "atomic modifier set". Those sets are kept atomic BY being nodes themselves, so they never reach the exclusion path. R7 counted leaves without nodes and read that as 182 entities needing exclusion; they are correctly represented, and the real residue is these 27. src/resolution_validation.py holds the checks as pure functions over loaded rows — no filesystem, no Neo4j — specifically so the negative control can corrupt the inputs directly. tests/test_resolution_negative_control.py exercises each check twice: silent on a correct mapping, and speaking up on a mapping broken in exactly the way that check exists to catch, including issue #67's shape. That file exists because _decomposed_ids once reported 11 of 11 passing while masking 18 dropped catalysts; a completeness check that cannot fail converts an unknown into a false assurance. 188 tests pass in the no-database tier. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two CI gates were red, and only one of them was visible.
**ruff** (the failing check on the PR): three findings.
- `l` as a comprehension variable, twice. E741 is enforced deliberately --
the repo's ruff config selects E7 precisely so this kind of thing is caught,
since `l` is indistinguishable from `1` in many fonts. Both now use `leaf`,
which is the name the surrounding scope already uses for the same thing.
- `typing.Optional` imported but unused in set_resolution.py.
**mypy**: three errors, none of them reported on the pull request, because the
Tests workflow never ran on this branch -- the only check GitHub attached to
the head commit was ruff. `main` is clean, so this branch introduced them.
All three are the same mistake: a name already bound as `str` by a loop over a
`Dict[..., str]`, then reassigned from a `.get()` that returns `str | None`.
- `node_uuid` in export_node_reaction_context, bound by the registry loop,
reused for a DataFrame lookup returning `Any | None`
- `rid` and `node_str` in export_node_resolution, bound by
`vr_to_reaction.items()` and `uuid_to_str.items()`, reused for lookups that
can miss
Renamed rather than annotated: a value that is always present and a lookup that
may not find anything are different things, and giving them one name is what
made this look fine.
Verified locally with the exact commands CI runs:
ruff check src/ bin/ All checks passed!
mypy src/ Success: no issues found in 12 source files
pytest -m "not database and not integration" 186 passed, 2 skipped
coverage 47.17% (floor 40%)
No behaviour change: renames and one removed import.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One conflict, in export_node_reaction_context: main had already fixed the same
mypy error this branch did, with better names (`edge_node`, `edge_reaction`) and
a comment saying why the rebinding was a type error rather than a style
preference. Took main's version and dropped mine.
The other two fixes on this branch are still needed -- main did not touch
export_node_resolution or the ruff findings.
After the merge, with the exact commands CI runs:
ruff check src/ bin/ All checks passed!
mypy src/ Success: no issues found in 12 source files
pytest -m "not database and not integration" 186 passed, 2 skipped
coverage 47.17% (floor 40%)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Green now — Worth recording what was actually wrong, because it was two failures and only one of them was visible. ruff — the check that was redThree findings:
mypy — three errors that were never reportedThe Tests workflow never ran on this branch. The only check GitHub attached to the head commit was All three were the same mistake: a name already bound as
Renamed rather than annotated: a value that is always present and a lookup that may not find one are different things, and giving them a single name is what made this read as fine. On merging mainOne conflict, and an instructive one: main had already fixed the first of those three, with better names ( Verified locally with the exact commands CI runsNo behaviour change in this fix — renames and one removed import. One thing worth a separate look, not fixed here: |
Stacked on #74 — review that first; this diff is scoped against it.
Adam's requirement: "we need to be able to map every node in the database to
the LNG node perfectly. and same with the other way around."
The gap
EntitySets are split into member species, so a set has no node of its own and
nothing recorded the link back. Measured cost downstream: 204 of 847
benchmark cases silently discarded, every one of the 20 blocked readouts
being a set — and they are the canonical set-shaped readouts of the
best-known pathways (p-T,p-S-AKT, p-S9/21-GSK3, phospho-FOXO, p-T,Y MAPK
dimers), 116 of them in PIP3 alone.
nodes.csvdeclaressource_sets,chosen_membersandcompartment;compartmentwas hardcoded empty and all three were empty in every row ofevery pathway measured.
What this adds
src/set_resolution.py— recursive closure of an EntitySet to its leafmembers. One hop is not enough: validated against all 20 real blocked
readouts, 15 resolve at depth 1, 4 need depth 2 and 1 needs depth 3, and
with recursion 0 of 20 are unresolvable against 3 before.
node_resolution.csvper pathway —stable_id, uuid, relation, depth, role, reaction_stid, glyph_id, diagram_stid, release. Grouped bystable_idit answers Reactome → LNG; grouped byuuid, LNG → Reactome.relationis one ofself,set_member,complex_component,variant,reaction— with no catch-all, so a node whose relation cannot bedetermined surfaces instead of landing in "other".
node_exclusions.csv— absence declared with a required reason.src/resolution_validation.py— completeness checks in bothdirections, as pure functions over loaded rows.
Verification
Over a ten-pathway catalog: 61,160 resolution rows, 4,204 of them
set_member, 27 exclusions, and zero violations in either direction —every network node appears in its resolution table, and no resolution row
names a node absent from the network.
The checks are deliberately free of the filesystem and Neo4j so the negative
control can corrupt the inputs directly.
tests/test_resolution_negative_control.pyexercises each check twice:silent on a correct mapping, and failing on a mapping broken in exactly
the way that check exists to catch, including #67's shape. That file exists
because
_decomposed_idsonce reported 11 of 11 passing while masking 18dropped catalysts — a completeness check that cannot fail converts an unknown
into a false assurance. 188 tests pass in the no-database tier.
On the cycle guard, stated honestly
An earlier draft justified the visited-set by claiming Reactome's membership
graph contains cycles. Measured on Release97 that is false: zero
self-membership, zero cycles at lengths 2, 3 and 4 across 5,440 nested sets,
maximum nesting depth 5. The guard stays because a curation error would
otherwise hang generation, not because anything observed needs it, and a test
proves it works by constructing a cycle the database does not have.
What the exclusions found
All 27 name specific leaves. The 11 in PIP3 are miRNA RISC complexes
that participate directly as reaction inputs and outputs and got no node —
a real generation gap this makes visible for the first time. The
reasoncolumn was written in as a formality and turned out to be the load-bearing
part: it is the only thing separating a design decision from a bug in one
list.
🤖 Generated with Claude Code