Repository navigation
Give each answer request its own thread, and send the contract's fields - #236
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:chat_historyis checkpointed state annotated withadd_messages, and the rephraser injects it through aMessagesPlaceholder. A shared thread means one asker's question and answer become the context that rephrases the next asker's. Now auuid4per 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.mdpromisesstartwithreleaseanddonewithseconds. Neither was sent.releaseis 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 senddone, 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 thereact-to-meprofile is the chat UI's.It cannot be fixed a fragment at a time. Measured live, one anchor arrives as twenty-odd fragments:
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.
AnchorStripperholds back only what could still become an anchor, so prose likex < ypasses 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}done{"state": "answered", "seconds": 19.5–44.2}Answers were biologically correct (CDK5's p35/p39 activators rather than cyclins; ABCA1 efflux to APOA1/ApoE).
Still open, not in this PR
MAX_CITATIONS = 12is 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