Skip to content

fix(rag): behavior-neutral top-5 safety net for clozapine withhold-threshold evidence#236

Closed
BigSimmo wants to merge 2 commits into
mainfrom
claude/clozapine-wcc-rerank
Closed

fix(rag): behavior-neutral top-5 safety net for clozapine withhold-threshold evidence#236
BigSimmo wants to merge 2 commits into
mainfrom
claude/clozapine-wcc-rerank

Conversation

@BigSimmo

@BigSimmo BigSimmo commented Jul 3, 2026

Copy link
Copy Markdown
Owner

Summary

Context update: while this was in flight, PR #227 merged the defer — the golden case clozapine-wcc-abbreviation-threshold has been removed from main's fixture (WCC classification coverage was kept). This branch has been merged up to current main, so it inherits that removal and does NOT re-add the golden case. It does not reverse or contest the defer.

What this PR adds is a standalone, behavior-neutral retrieval safety net, independent of the golden fixture:

  • Investigation found the case's reported 0.67 was corpus-state dependent, not a code gap: the defer branch was built on identical ranking code, yet a live re-index had since floated the answer-bearing chunk into the top-5. The underlying fragility is real, though — the collapsed lexical query for these questions ("clozapine monitoring") pulls many sibling clozapine chunks, and the single structured threshold table that pairs the blood parameter (WBC/ANC/neutrophil) with the withhold/cease/red action is the only top-5 chunk carrying the action, so a small ranking jitter can push it past rank 5 — leaving a top-5 that names the drug but omits the actual threshold.
  • ensureClozapineBloodActionEvidenceRanked: for clozapine blood-action withhold-threshold queries only, when the top-5 doesn't already carry both the blood term and the action, it promotes the best already-retrieved threshold+action chunk from below the cutoff into the last top-5 slot. Pure reordering of the existing candidate set; wired as the final step of prepareCoverageGateResults so no later sort can undo it.
  • A no-op whenever the evidence is already ranked, so it changes nothing on the current corpus — the full quality suite is identical before/after. This is deliberately the safe alternative to the ranking work the defer stepped back from: not a reweight, not governance weighting (consistent with the "no governance weighting in retrieval selection" principle) — just a coverage guarantee that the correct clinical evidence is surfaced. It cannot suppress a correct result.
  • The coverage gate now shares the same query-shape predicate and blood/action patterns (behaviour-identical refactor).

Reviewers: this is offered on its own merits as defence-in-depth for a safety-critical answer path. It's independent of the deferred golden case; accept or decline without affecting the defer.

Verification

  • npm run verify:cheap — 106 files, 984 tests pass (re-run after merging current main)
  • npm run verify:ui — N/A (no UI, routing, styling, or browser behaviour change)
  • npm run verify:release — N/A
  • npm run format:check — clean
  • npm run check:production-readiness — READY (Supabase target confirmed Clinical KB Database)
  • npm run check:deployment-readiness — N/A

Additional (retrieval-quality gate, live Supabase + OpenAI, embedding-skip ~1.0 so negligible cost):

  • npm run eval:retrieval:quality -- --fail-on-threshold green before and after the guard — identical metrics, deterministic clozapine top-5 across runs.
  • New unit tests prove the guard promotes under simulated jitter, is a no-op when the evidence is already in top-5, and is a no-op for non-clozapine queries (tests/retrieval-query-variants.test.ts).

Clinical Governance Preflight

Change touches search/ranking and clinical output.

  • Source-backed claims still require linked source verification before clinical use — answer grounding/citation logic unchanged; this only reorders already-retrieved evidence
  • No patient-identifiable document workflow was introduced or expanded without explicit governance approval — none touched
  • Supabase target remains Clinical KB Database (sjrfecxgysukkwxsowpy) — verified by check:production-readiness; no env/config change
  • Service-role keys and private document access remain server-only — unchanged
  • Demo/synthetic content remains clearly separated from real clinical sources — unchanged
  • Source metadata, review status, and outdated/unknown-source behavior remain conservative — no metadata weighting added; the guard is relevance-first
  • Deployment classification/TGA SaMD impact was checked when clinical decision-support behavior changed — behaviour-neutral on the current corpus; can only make the correct withhold-threshold evidence more reliably surfaced, never suppress it

Notes

🤖 Generated with Claude Code

The golden case clozapine-wcc-abbreviation-threshold currently passes on
main (content_recall@5=1, full eval:retrieval:quality suite green), but the
pass is corpus-state dependent: the collapsed lexical query "clozapine
monitoring" pulls many sibling clozapine chunks, and a small ranking jitter
can float them above the single structured threshold table that pairs the
blood parameter (WBC/ANC/neutrophil) with the withhold/cease/red action,
dropping that one answer-bearing chunk past rank 5. That is the regression
the case was nearly deferred for (claude/defer-clozapine-wcc-golden-case).

Add a narrowly scoped, safety-critical top-5 guarantee: for clozapine
blood-action withhold-threshold queries only, when the top-5 does not
already carry both the blood term and the action, promote the best
already-retrieved threshold+action chunk from below the cutoff into the last
top-5 slot. Pure reordering of the existing candidate set; a no-op whenever
the evidence is already ranked, so it changes nothing on the current corpus.
The coverage gate now shares the same query-shape predicate and term/action
patterns (behaviour-identical refactor).

Validated:
- unit: promotes under simulated jitter, no-op when already covered, no-op
  for non-clozapine queries (tests/retrieval-query-variants.test.ts)
- live: eval:retrieval:quality --fail-on-threshold green before and after
  (25 cases, content_recall@5=1, 0 failures, identical clozapine top-5);
  anc/cbc/wcc cases deterministic across runs
- typecheck, lint, prettier clean

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f381f4c217

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/lib/rag.ts
Comment on lines +3549 to +3550
const topText = topEvidenceText(results, limit);
if (clozapineBloodTermPattern.test(topText) && clozapineWithholdActionPattern.test(topText)) return results;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require the ranked evidence in a single top-five result

This early no-op checks the concatenated top-five text, so unrelated top-five chunks can satisfy the blood term and action separately (for example one generic clozapine/ANC monitoring chunk plus another chunk mentioning a red/stop action) while the actual structured threshold+action table remains at rank 6. In that scenario this new safety net returns without promotion even though no top-five result carries the answer-bearing evidence, leaving the clozapine withhold threshold fragile in exactly the jitter case this change is meant to guard against; the check should use the same per-result structured threshold predicate used for promoted candidates.

Useful? React with 👍 / 👎.

Comment thread src/lib/rag.ts
// they must agree on what counts as blood evidence and a withhold/cease action, or the gate could
// accept a top-5 the net believes is uncovered (or vice versa). None carry the /g flag, so `.test`
// is stateless and safe to reuse.
const clozapineBloodTermPattern = /\b(?:anc|fbc|wbc|wcc|neutrophil|neutrophils|full blood|white cell)\b/i;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include CBC-only blood-threshold phrasing

When the user asks this same clozapine withhold question using only CBC / complete blood count or white blood cell count rather than ANC/FBC/WBC/WCC, this predicate returns false because those blood terms are not in the shared pattern. The added CBC test is masked because it also contains neutrophil, so those common phrasings still skip the new promotion and the clozapine-specific coverage gate, leaving the answer vulnerable to the same rank-jitter failure.

Useful? React with 👍 / 👎.

Comment thread src/lib/rag.ts
// Final step, after all reranking: guarantee the clozapine withhold-threshold table is in the
// top-5 the coverage gate evaluates and the answer layer sees. Runs last so no later sort can
// undo it; a no-op for every other query and whenever the evidence is already ranked.
results = ensureClozapineBloodActionEvidenceRanked(args.query, results);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Select enough candidates before applying the safety net

This call runs after selectRankedRetrievalResults has already truncated to the caller's topK, but the API accepts topK values down to 1 and a caller asking for 5 results is exactly asking for the top five. In that case a threshold table at rank 6 is discarded before the safety net runs, so results.length <= limit makes the helper no-op and the rank-jitter case this change is intended to fix still reaches the coverage gate without the answer-bearing table; fetch/select at least one candidate beyond the cutoff for this query shape before applying the promotion.

Useful? React with 👍 / 👎.

@BigSimmo BigSimmo changed the title fix(rag): guarantee clozapine withhold-threshold table ranks into top-5 fix(rag): behavior-neutral top-5 safety net for clozapine withhold-threshold evidence Jul 3, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 188f7a947a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/lib/rag.ts
Comment on lines +3527 to +3529
if (!hasStructuredThresholdEvidence(result)) return false;
const text = evidenceTextForGate(result);
return clozapineBloodTermPattern.test(text) && clozapineWithholdActionPattern.test(text);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require real table evidence before promoting clozapine chunks

For clozapine withhold-threshold queries, this treats hasStructuredThresholdEvidence as if it only means table facts / threshold units / table crops, but that helper also returns true from a broad keyword fallback such as anc, withhold, or stop. When the top 5 lacks the answer table and a below-cutoff prose chunk says something like “monitor ANC” and “stop clozapine” without a numeric cutoff/table row, the new safety net can promote that non-answer chunk, after which the coverage gate accepts and skips vector retrieval even though the actual threshold is still absent.

Useful? React with 👍 / 👎.

Comment thread src/lib/rag.ts
// accept a top-5 the net believes is uncovered (or vice versa). None carry the /g flag, so `.test`
// is stateless and safe to reuse.
const clozapineBloodTermPattern = /\b(?:anc|fbc|wbc|wcc|neutrophil|neutrophils|full blood|white cell)\b/i;
const clozapineWithholdQueryPattern = /\b(?:withhold|withheld|withholding|cease|ceased|stop|stopped)\b/i;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Route ceased queries through the coverage gate

Because this new query-shape pattern now includes ceased, a query like “when should clozapine be ceased based on WBC?” is supposed to receive the clozapine safety net, but decideTextFastPath still only blocks early table-threshold fast paths for cease/stop/withhold forms and not ceased. If the initial top results contain any strong structured-threshold-looking chunk, that phrasing can return before prepareCoverageGateResults runs, so the new promotion and stricter clozapine coverage gate are bypassed for one of the phrasings added here.

Useful? React with 👍 / 👎.

Comment thread src/lib/rag.ts
// clozapine?" (any phrasing/abbreviation). Shared by the coverage gate and the top-5 safety net.
export function isClozapineBloodActionThresholdQuery(query: string) {
return (
/\bclozapine\b/i.test(query) && clozapineBloodTermPattern.test(query) && clozapineWithholdQueryPattern.test(query)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply the safety net to typo-corrected clozapine queries

This predicate checks only the raw query text for clozapine, but the search pipeline already typo-corrects clozapin to clozapine when building/ranking candidates, and the eval corpus includes the withhold-threshold phrasing “What ANC or FBC cut off means clozapin should be withheld?”. For that realistic typo, the same rank-jitter scenario still skips the new promotion entirely even though the candidate set is otherwise the clozapine monitoring set, leaving the safety net absent for an existing supported query form.

Useful? React with 👍 / 👎.

@BigSimmo
BigSimmo enabled auto-merge (squash) July 3, 2026 09:38
@BigSimmo
BigSimmo disabled auto-merge July 3, 2026 09:38
@BigSimmo

BigSimmo commented Jul 3, 2026

Copy link
Copy Markdown
Owner Author

Closing in favour of the deferral that landed on main via #227.

Why closing: this PR existed to make the golden case clozapine-wcc-abbreviation-threshold rank robustly. #227 deliberately removed that case from the fixture (keeping WCC classification coverage), so:

  • There's no failing test left to fix.
  • With the golden case gone, eval:retrieval:quality no longer measures whether the clozapine withhold-threshold evidence stays in top-5 — the guard's only regression harness was intentionally removed. It's a no-op on the current corpus, so it can't be demonstrated to help, only shown not to hurt; landing it would be an unfalsifiable, behavior-neutral addition to a safety-critical hot path, against the repo's "defaults unchanged until eval-gated" stance.
  • It isn't an urgent safety gap: the coverage gate already fails-closed for this query shape (top-5 lacking blood+action evidence → gate rejects → falls through to fuller retrieval), so the guard was defence-in-depth, not the only protection.

Preserved for future revival (branch claude/clozapine-wcc-rerank kept, not deleted):

  • Guard commit: f381f4c (ensureClozapineBloodActionEvidenceRanked in src/lib/rag.ts + unit tests in tests/retrieval-query-variants.test.ts).
  • Branch HEAD (merged up to current main): 188f7a9.

Key finding worth keeping: the reported 0.67 for this case was corpus-state dependent, not a code gap — the defer branch ran on identical ranking code yet the case passed deterministically on the live corpus at the time I measured it; a live re-index had moved the answer-bearing structured-threshold table into the top-5. Root fragility: the collapsed lexical query "clozapine monitoring" pulls many sibling clozapine chunks, and the one structured table pairing the blood parameter (WBC/ANC/neutrophil) with the withhold/cease/red action is the only top-5 chunk carrying the action, so ranking jitter can push it past rank 5.

If a real, reproducible clozapine withhold-threshold miss ever appears in production, revive this deliberately with that failure as the gate. Note the token-append lever (adding wbc/neutrophil to the collapsed query) is unsafe: the RPC uses websearch_to_tsquery (AND) with OR-relax fallback, so it can over-constrain → empty → OR-relax → precision collapse. Prefer the reorder-only guard here.

@BigSimmo BigSimmo closed this Jul 3, 2026
@BigSimmo
BigSimmo deleted the claude/clozapine-wcc-rerank branch July 5, 2026 11:43
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