Skip to content

Resolve EntitySets to leaf members, and emit a bidirectional node↔entity mapping - #75

Merged
adamjohnwright merged 4 commits into
mainfrom
feature/set-resolution
Sep 14, 2026
Merged

adamjohnwright merged 4 commits into
mainfrom
feature/set-resolution

Conversation

@adamjohnwright

Copy link
Copy Markdown
Contributor

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.csv declares source_sets, chosen_members and compartment;
compartment was hardcoded empty and all three were empty in every row of
every pathway measured.

What this adds

  • src/set_resolution.py — recursive closure of an EntitySet to its leaf
    members. 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.csv per pathway — stable_id, uuid, relation, depth, role, reaction_stid, glyph_id, diagram_stid, release. Grouped by
    stable_id it answers Reactome → LNG; grouped by uuid, LNG → Reactome.
    relation is one of self, set_member, complex_component, variant,
    reactionwith no catch-all, so a node whose relation cannot be
    determined surfaces instead of landing in "other".
  • node_exclusions.csv — absence declared with a required reason.
  • src/resolution_validation.py — completeness checks in both
    directions, 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.py exercises 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_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.

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 reason
column 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

adamjohnwright and others added 2 commits September 14, 2026 09:35
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>
Base automatically changed from fix/67-context-export-orphans to main September 14, 2026 16:47
adamjohnwright and others added 2 commits September 14, 2026 16:57
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>
@adamjohnwright
adamjohnwright merged commit 62e306e into main Sep 14, 2026
4 checks passed
@adamjohnwright
adamjohnwright deleted the feature/set-resolution branch September 14, 2026 16:59
@adamjohnwright

Copy link
Copy Markdown
Contributor Author

Green now — ruff and all three test jobs (3.10 / 3.11 / 3.12).

Worth recording what was actually wrong, because it was two failures and only one of them was visible.

ruff — the check that was red

Three findings:

  • l as a comprehension variable, twice. E741 is enforced on purpose here — the ruff config selects E7 precisely so this is caught, since l is indistinguishable from 1 in many fonts. Both now use leaf, the name the surrounding scope already uses for the same thing.
  • typing.Optional imported but unused in set_resolution.py.

mypy — three errors that were never reported

The Tests workflow never ran on this branch. The only check GitHub attached to the head commit was ruff, so the mypy step was never reached and nothing said so. main was clean, so this branch introduced them.

All three were the same mistake: a name already bound as str by a loop over a Dict[..., str], then reassigned from a .get() returning 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 one are different things, and giving them a single name is what made this read as fine.

On merging main

One conflict, and an instructive one: main had already fixed the first of those three, with better names (edge_node, edge_reaction) and a comment explaining that the rebinding was a type error rather than a style preference. I took main's version and dropped mine. The other two were untouched on main and still needed.

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 in this fix — renames and one removed import.


One thing worth a separate look, not fixed here: .github/workflows/ruff.yml runs ruff format src/ bin/ without --check. ruff format rewrites files and exits 0, so that step can never fail — it edits the runner's checkout and throws it away. It reads like a gate and is not one. Making it --check would currently fail on 15 files, so it is a decision rather than a quick fix.

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.

1 participant