Skip to content

Let the classifier choose collections, and measure what that is worth - #262

Merged
adamjohnwright merged 3 commits into
mainfrom
009-collection-routing-classifier
Sep 19, 2026
Merged

adamjohnwright merged 3 commits into
mainfrom
009-collection-routing-classifier

Conversation

@adamjohnwright

Copy link
Copy Markdown
Contributor

Completes spec 009. The classifier now chooses which Reactome collections to search, and the measurement says what that is actually worth — which is less than the spec claimed, in a way worth reading.

What landed

QueryIntent gains collections; the prompt describes each collection from reactome_descriptions_info rather than a literal list, so a collection added to the bundle cannot be missing from the prompt meant to offer it; generate_answer sets the selection around retrieval.

It rides the selected_collections ContextVar, not config["configurable"] as tasks.md assumed — create_retrieval_chain gives the retriever no path for extra arguments. LangGraph copies the context into spawned tasks, so a value set around the call reaches the retriever inside them. Reset in finally: the graph reuses one asyncio task across turns, and a selection left set would narrow the next question, which nothing downstream could detect because narrowing yields a confident answer from less.

Three measurements

It holds the bar. Sweep is 13/13 with the classifier choosing — the control score — with 5 of 6 retrievals narrowed, routing exactly as the earlier measurement predicted: accession → ewas, both variant questions → disease_variants, summary → summations, broad mechanistic → left open.

It does not reduce the prompt. This corrects the spec.

question docs all docs narrowed chars all chars narrowed
TP53 accession 10 10 2,436 2,436
ABCA1 variants 10 10 6,163 5,846
Selective autophagy 10 10 22,114 21,797

Ten documents either way, context within 2% — the chain caps the fused list at ten however many collections fed it. T003's framing, that context tokens would fall, was measuring the wrong thing.

What it reduces is retrieval work: 1.82s → 1.43s median, three runs per question, timed inside aretrieve_documents. Five collections mean ten sub-retrievals; one means two. About a fifth, or 0.4s against a first token near ten seconds.

The honest case

It did not fix a failing question — the control already passed 13/13. It buys a fifth off retrieval and puts the right documents into a fixed-size context. Only the first of those is measured.

Adversarial review found two things

state["collections"] would raise. BaseState is total=False and the field is new, so a thread resumed from a pre-existing checkpoint has no key — a KeyError on the served path, invisible until someone resumes an old conversation. Now .get(...) or [], pinned by a test that deletes the key and fails against the subscript.

Six questions was a thin base to ship a router on. Probed with nine unseen and deliberately awkward ones — three broad that must not narrow, two cross-collection, four specific. Nine of nine as intended, with both cross-collection questions taking summations alongside their primary. Recorded with its limit: nine classifications, not nine answers.

Also documented: collections is list[str] and not an enum on purpose. A hallucinated name widens to all with a WARNING; constraining the schema would make structured output reject the response, turning a harmless mistake into a failed answer.

Still open

complexes has no guard question (T005), so a classifier that never routes to it would still score full marks.

Checks

501 passed, 1 skipped. ruff, ruff format, mypy clean.

🤖 Generated with Claude Code

adamjohnwright and others added 3 commits September 19, 2026 05:09
Completes spec 009. `QueryIntent` gains a `collections` field, the classifier
prompt describes each collection from `reactome_descriptions_info` rather than
a literal list, and `generate_answer` sets the selection around retrieval.

The selection rides the `selected_collections` ContextVar rather than
`config["configurable"]` as tasks.md assumed: `create_retrieval_chain` gives
the retriever no path for extra arguments. LangGraph copies the context into
the tasks it spawns, so a value set around the call reaches the retriever
inside them. It is reset in `finally`, because the graph reuses one asyncio
task across turns and a selection left set would narrow the next question --
which nothing downstream could detect, since narrowing yields a confident
answer from less.

Three measurements, and the middle one is not the result this feature was
pitched on.

It holds the bar. The sweep is 13/13 with the classifier choosing, which is the
control score, and five of six retrievals were narrowed. The routing is the
mapping the earlier measurement predicted: the accession question to `ewas`,
both variant questions to `disease_variants`, the summary question to
`summations`, and the broad mechanistic one left empty.

It does not reduce the prompt. Ten documents reach the model either way and
context characters are within 2%, because the chain caps the fused list at ten
however many collections fed it. Narrowing changes which documents arrive, not
how many. T003's framing -- that context tokens would fall -- was the wrong
thing to measure, and it is corrected rather than quietly dropped.

What it does reduce is retrieval work: 1.82s to 1.43s median, three runs per
question, timed inside `aretrieve_documents`. Five collections mean ten
sub-retrievals; one means two. About a fifth, or 0.4s against a first token
near ten seconds.

So the honest case is narrower than the spec's: it did not fix a failing
question, the control already passed. It buys a fifth off retrieval and puts
the right documents in a fixed-size context, and only the first of those is
measured.

Tests cover the dangerous directions rather than the happy path: that an
omitted field still means all collections, that a reactome selection never
leaks into the userguide bundle, that the selection does not outlive the
question, and that the sync and async retrieval paths honour the same
selection -- the served path and the measured path are different code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…route right

Two things the first commit did not cover.

`state["collections"]` would raise. BaseState is total=False and the field is
new, so a thread resumed from a checkpoint written before this landed has no
key at all -- a KeyError on the served path, in a state that is invisible until
someone resumes an old conversation. Now `.get(...) or []`, which means every
collection, which is how the graph behaved before routing. Pinned by a test
that deletes the key and fails against the subscript.

And the routing evidence was six questions, all of them ones the sweep already
asks. Probed with nine it has never seen, chosen to be awkward: three broad
questions that must not narrow, two cross-collection ones where a single choice
loses half the answer, four specific ones. Nine of nine as intended -- every
broad question left open, every specific one narrowed to the collection holding
the answer, and both cross-collection questions took `summations` alongside
their primary, which is what the prompt asks for.

Recorded with its limit: that is nine classifications, not nine answers. It
shows the decision is sound, not that the answers are.

Also written down rather than left implicit: `collections` is `list[str]` and
not an enum of the five names on purpose. A hallucinated name widens to all
with a WARNING, which is the direction this feature requires. Constraining the
schema would make structured output reject the response instead, turning a
harmless mistake into a failed answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…sumed

CI's lint failed where mine passed, because CI runs `poetry run mypy` over
everything and I ran `mypy src`. Verifying a narrower scope than the gate is
the mistake; the finding underneath it is real.

`ReactToMeState` subclasses a total=False TypedDict but is itself total=True,
so declaring `collections: list[str]` made it a *required* key. That
invalidated four existing state constructions, and it claimed a guarantee the
runtime does not have -- the previous commit had already established that a
thread resumed from an older checkpoint has no such key, which is why
`generate_answer` reads it with `.get`.

`NotRequired[list[str]]` says what is true: absent and empty both mean every
collection, and the type no longer disagrees with the accessor.

Also replaced a `type: ignore[attr-defined]` in the new test that was hiding a
second error rather than describing one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@adamjohnwright
adamjohnwright merged commit c96dfef into main Sep 19, 2026
10 checks passed
@adamjohnwright
adamjohnwright deleted the 009-collection-routing-classifier branch September 19, 2026 05:32
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