Skip to content

Give each answer request its own thread, and send the contract's fields - #236

Merged
adamjohnwright merged 10 commits into
mainfrom
fix/answer-thread-isolation
Sep 18, 2026
Merged

adamjohnwright merged 10 commits into
mainfrom
fix/answer-thread-isolation

Conversation

@adamjohnwright

@adamjohnwright adamjohnwright commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to #235, from the adversarial review of that work. #235 merged before the review finished; these are its findings, plus the first real end-to-end run.

Nothing was exposed in production — the website is not calling the endpoint yet.

1. One stranger's question rephrasing another's

The thread id was search-{id(body)}. CPython reuses a freed object's address immediately, and a request body is allocated and discarded per request. Measured over 200 requests through the real endpoint:

requests 200
distinct threads 38
requests on a shared thread 192 / 200
most reused single thread 14 different questions

chat_history is checkpointed state annotated with add_messages, and the rephraser injects it through a MessagesPlaceholder. A shared thread means one asker's question and answer become the context that rephrases the next asker's. Now a uuid4 per request.

The test that could not find the bug: my first version used two requests and passed against the broken code — two sequential bodies usually do land at distinct addresses. It is 50 requests now, verified to fail against the old code.

2. Two contract fields were never implemented

contracts/answer_endpoint.md promises start with release and done with seconds. Neither was sent. release is the mechanism FR-007 names — without it there is nothing to invalidate a cached answer on. It is read from the bundle directory name, the only place it is recorded, per bundle (reactome is at 97 while userguide is at 95) and resolved at startup, since the graph is built from those same bundles.

3. Nothing bounded the stream

FR-006 names timeout; nothing implemented it. The only bound was the LLM client's request_timeout=360.0 — six minutes, per model call, six calls per answer. A stuck upstream could hold a connection for over half an hour and never send done, on a page whose contract is that it must be safe to ignore. Bounded at 120s, stated in the contract.

4. The prose was full of the anchors the contract promised it would not have

Found by running the endpoint end to end against a real graph for the first time. The contract says citations are separate events precisely so the website need not "parse them back out and re-style them" — and then the endpoint returned prose stuffed with <a href=...>, because the react-to-me profile is the chat UI's.

It cannot be fixed a fragment at a time. Measured live, one anchor arrives as twenty-odd fragments:

' <'  'a'  ' href'  '="'  'https'  '://'  'react'  'ome'  '.org'  '/content'  '/detail'  '/R'  '-H'  'SA' ...

A caller rendering incrementally would show that verbatim before it snapped into a link, and the page would be injecting model-generated HTML into its own DOM.

AnchorStripper holds back only what could still become an anchor, so prose like x < y passes straight through. Every case is tested at every split point, plus character-at-a-time and random splits, because a fragment-at-a-time implementation passes a whole-anchor test and fails the real stream.

Verified live: anchors in prose 10 → 0, citations still structured, answer intact.

The anchor's text is kept — removing it would break a sentence with a linked phrase mid-clause — which leaves a trailing pathway title where the model used a link as a citation. Cosmetic, and noted in the contract.

First successful end-to-end run

Real graph, Release97 bundles, over HTTP:

start {"release": 97, "answered": true}
first token 11.8s – 34.5s
done {"state": "answered", "seconds": 19.5–44.2}
citations 12
anchors in prose 0

Answers were biologically correct (CDK5's p35/p39 activators rather than cyclins; ABCA1 efflux to APOA1/ApoE).

Still open, not in this PR

  • FR-008 rate limiting (T017) — not implemented.
  • SC-003 (T014) — endpoint and chat UI agreeing is still untested.
  • MAX_CITATIONS = 12 is binding on every question tried, so callers get the most relevant few, not the full set. Chosen without evidence about what a panel needs.

CI green: ruff, format, mypy (125 files), tests, docker-build.

🤖 Generated with Claude Code

adamjohnwright and others added 10 commits September 17, 2026 21:09
The thread_id was `search-{id(body)}`. CPython reuses the address of a freed
object immediately, so request bodies -- allocated and discarded per request --
collide almost always. Measured over 200 requests through the real endpoint: 38
distinct threads, 192 of the 200 on a shared one, one thread serving 14
different questions.

That matters because `chat_history` is checkpointed state annotated with
`add_messages`, and the rephraser injects it through a MessagesPlaceholder. Two
requests on one thread means one stranger's question and answer become the
context that rephrases the next stranger's question.

The test uses 50 requests deliberately. The two-request version I wrote first
passed against the broken code -- sequential bodies usually do get distinct
addresses -- which would have made it a test that could not find the bug it was
written for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
contracts/answer_endpoint.md promises `start` carrying `release` and `done`
carrying `seconds`. The implementation sent neither. The website codes against
that document, so a promised field we never send is a defect on their side.

`release` is not cosmetic: FR-007 exists so a cached answer can be invalidated
after a release, and without the field there is nothing to invalidate on.

It comes from the bundle directory name, which is the only place it is
recorded. Bundles can differ -- reactome is at 97 while userguide is at 95 -- so
get_release is per-bundle and the endpoint reports reactome's, that being the
pathway content an answer is about. Resolved at startup, because the graph is
built from the same bundles and so startup describes what is actually served.

Refusals carry `seconds` too, so `done` has one shape to parse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
T007-T013, T015, T016 and T018 shipped in #235 but nothing was checked off.
T014a and T014b are the two defects the adversarial review of that PR found.

T014 (SC-003) and T017 (FR-008 rate limiting) remain genuinely open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
FR-006 names timeout alongside error, and nothing implemented it. The only bound
was the LLM client's `request_timeout=360.0` -- six minutes, per model call, and
six calls run around one answer. A stuck upstream could hold a connection for
over half an hour and never send `done`, on a page whose whole contract is that
it must be safe to ignore.

120s is about twice the worst complete answer measured, so it does not cut off
answers that were going to arrive; it converts an unbounded hang into a bounded
one. TimeoutError is logged distinctly from a crash so an operator can tell a
slow upstream from a broken one.

The stand-in hangs for a finite 5s rather than an hour, so a regression fails the
suite in five seconds instead of hanging it. Verified that the assertion catches
an ineffective bound, not merely a missing constant: with the timeout raised
above the hang it reports "stream ran 5.0s; the timeout did not fire".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 120s ceiling is something a caller needs to know, so it belongs in the
contract they code against rather than only in our source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The contract says citations are separate events so the website can style links
itself, and calls out that returning prose with embedded HTML anchors "would
force the search page to parse them back out". The endpoint returned exactly
that: the `react-to-me` profile is the chat UI's, and its prompt asks for inline
links.

Found by running the endpoint end to end against a real graph for the first
time. It cannot be fixed a fragment at a time -- measured live, one anchor
arrives as twenty-odd fragments: ' <', 'a', ' href', '="', 'https', '://',
'react', 'ome', '.org', '/content', '/detail', '/R', '-H', 'SA', ... A caller
rendering incrementally would show that verbatim before it snapped into a link,
and the page would be injecting model-generated HTML into its own DOM.

AnchorStripper holds back only what could still become an anchor, so prose like
"x < y" passes straight through. Every case is tested at every split point, plus
character-at-a-time and random splits, because a fragment-at-a-time
implementation passes a whole-anchor test and fails the real stream.

Verified live: anchors in prose 10 -> 0, with citations still arriving as
structured events and the answer intact.

The anchor's text is kept -- removing it would break a sentence with a linked
phrase mid-clause -- which leaves a trailing pathway title where the model used a
link as a citation. Noted in the contract as cosmetic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
S311 flags random.Random; here it only chooses where to cut a test string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@adamjohnwright
adamjohnwright merged commit 3035f20 into main Sep 18, 2026
10 checks passed
@adamjohnwright
adamjohnwright deleted the fix/answer-thread-isolation branch September 18, 2026 01:37
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