diff --git a/package-lock.json b/package-lock.json index 5fc038c01..6e6237c7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1832,9 +1832,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1851,9 +1848,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1870,9 +1864,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1889,9 +1880,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2519,9 +2507,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2539,9 +2524,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2559,9 +2541,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2579,9 +2558,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/src/lib/clinical-search.ts b/src/lib/clinical-search.ts index 962e61e69..d144e4f9a 100644 --- a/src/lib/clinical-search.ts +++ b/src/lib/clinical-search.ts @@ -24,6 +24,43 @@ export type RagQueryClassification = { needsSynthesis: boolean; }; +// Shared signals for coloured-zone "next step" retrieval guards (ranking source +// evidence here; text fast-path and coverage gates in rag.ts). The zone context +// deliberately requires an explicit coloured-zone reference or "zone criteria" — +// a bare "zone" (clean zone, time zone, zone 3) plus a common word like "review" +// must not qualify as escalation evidence. The action group likewise requires a +// real clinical instruction: bare "review\w*" would let document boilerplate +// ("Review date: ...", "reviewed by ...") satisfy the guard, so "review" only +// counts when attached to a clinical role or urgency (senior clinician review, +// urgent medical officer review) or an escalation/MET phrase. +export const zoneColourAlternatives = "red|amber|yellow|orange|purple|green|blue"; +export const riskZoneContextPattern = new RegExp( + `\\b(?:(?:${zoneColourAlternatives})[\\s-]*zones?|colou?red zones?|zone criteria)\\b`, + "i", +); +export const riskZoneActionPattern = + /\b(?:escalat\w+|urgent\w*|respond\w*|actions?\s+required|call(?:ing)?\s+(?:a\s+)?met\b|met\s+call|(?:senior|immediate|medical|clinical|clinician|nursing|officer)\s+(?:clinician\s+|specialist\s+|nurse\s+|officer\s+)?review\w*)\b/i; + +// The zone colour a query names, if any ("red-zone risk" -> "red"). +export function queriedZoneColour(query: string) { + return query.match(new RegExp(`\\b(${zoneColourAlternatives})[\\s-]*zones?\\b`, "i"))?.[1]?.toLowerCase() ?? null; +} + +// Zone-context patterns scoped to the colour the query names (falling back to +// any colour). `zonePhrasePattern` matches explicit zone phrases; `bareColourPattern` +// is for risk-matrix / flowchart visual units, which store the cell colour as a +// bare token ("... | Red | escalate ..."). Shared by the fast-path guard and +// coverage gate in rag.ts and the ranking source check here so a red-zone +// question is never satisfied (or boosted) by another colour's action evidence. +export function zoneContextPatternsForQuery(query: string) { + const colour = queriedZoneColour(query); + const colourGroup = colour ?? `(?:${zoneColourAlternatives})`; + return { + zonePhrasePattern: new RegExp(`\\b${colourGroup}[\\s-]*zones?\\b|\\bcolou?red zones?\\b|\\bzone criteria\\b`, "i"), + bareColourPattern: new RegExp(`\\b${colourGroup}\\b`, "i"), + }; +} + export const intentSignalWords = { dosing: [ "dose", @@ -1340,10 +1377,41 @@ export function clinicalRankExplanation(query: string, result: SearchResult): Se const riskFlowchartQuery = queryClass === "document_lookup" && /\b(?:flow\s*chart|flowchart|algorithm|pathway)\b/i.test(query) && - /\b(?:risk|red\s*zone|red|next step|step after)\b/i.test(query); - const riskFlowchartSource = - /\b(?:flowchart|flow chart|flow|algorithm|pathway|matrix)\b/.test(haystack) && - /\b(?:risk|red zone|red)\b/.test(haystack); + (/\b(?:risk|next step|step after)\b/i.test(query) || riskZoneContextPattern.test(query)); + // Zone-action evidence ("red zone ... escalate / urgent review") answers a risk + // flowchart question even when the source never uses the word "flowchart" — + // escalation protocols express the flowchart's decision steps as text. Without + // this, the generic penalty demoted the documents that actually contain the + // red-zone next step while unrelated risk-assessment flowcharts kept the boost. + // Both term groups must hit: a bare "zone" or a lone "review" is not evidence. + // The patterns are scoped to the colour the query names, so a red-zone + // question is not boosted by an amber-zone cell's action; risk-matrix / + // flowchart visual units store the cell colour as a bare token + // ("... | Red | escalate ..."), so for those units the colour token counts as + // zone context. + const indexUnitText = result.index_unit + ? `${result.index_unit.title} ${result.index_unit.content}`.toLowerCase() + : ""; + const riskFlowchartEvidenceText = `${haystack} ${indexUnitText}`; + const zonePatterns = zoneContextPatternsForQuery(query); + const zoneCellUnitEvidence = + ["risk_matrix_cell", "flowchart_step", "diagram_decision"].includes(result.index_unit?.unit_type ?? "") && + zonePatterns.bareColourPattern.test(riskFlowchartEvidenceText); + const riskFlowchartZoneActionSource = + (zonePatterns.zonePhrasePattern.test(riskFlowchartEvidenceText) || zoneCellUnitEvidence) && + riskZoneActionPattern.test(riskFlowchartEvidenceText); + const riskFlowchartLexicalSource = + /\b(?:flowchart|flow chart|flow|algorithm|pathway|matrix)\b/.test(riskFlowchartEvidenceText) && + /\b(?:risk|red zone|red)\b/.test(riskFlowchartEvidenceText); + // For next-step/action questions, a flowchart page that names the risk but + // carries no action instruction is exactly the false positive the retrieval + // gates defer past — it must not take the risk-flowchart boost (nor dodge the + // generic penalty) on lexical grounds alone; the action evidence must be on + // the same result. + const nextStepActionQuery = /\b(?:next step|step after|action)\b/i.test(query); + const riskFlowchartSource = nextStepActionQuery + ? riskFlowchartZoneActionSource || (riskFlowchartLexicalSource && riskZoneActionPattern.test(riskFlowchartEvidenceText)) + : riskFlowchartLexicalSource || riskFlowchartZoneActionSource; const riskFlowchartCanonicalTitle = riskFlowchartQuery && /\b(?:flow|flowchart|flow chart|algorithm|pathway|matrix)\b/.test(titleTokenText) && diff --git a/src/lib/rag.ts b/src/lib/rag.ts index 428388f7f..ce6fbaf59 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -24,6 +24,10 @@ import { hasStructuredThresholdEvidence, normalizedClinicalSearchTokens, rankClinicalResults, + queriedZoneColour, + riskZoneActionPattern, + riskZoneContextPattern, + zoneContextPatternsForQuery, } from "@/lib/clinical-search"; import { env, isDemoMode, isLocalNoAuthMode, requestedOpenAIAnswerModels } from "@/lib/env"; import { logger } from "@/lib/logger"; @@ -1955,12 +1959,21 @@ export function buildRetrievalQueryVariants( addVariant("admission discharge"); } if ( - /\b(?:flow\s*chart|flowchart|algorithm|pathway)\b/i.test(query) && + /\b(?:flow\s*chart|flowchart|algorithm|pathway|risk[\s-]*matrix)\b/i.test(query) && /\b(?:risk|red\s*zone|red|urgent|escalat|next step)\b/i.test(query) ) { addVariant("risk flow"); - addVariant("red zone risk flow"); - addVariant("risk flow review urgent escalation"); + // websearch_to_tsquery ANDs every term, so the previous "red zone risk flow" + // and "risk flow review urgent escalation" variants required all terms in one + // chunk and did not reliably contribute candidates to the pool. A " zone" variant retrieves the small, + // precise set of zone-action chunks (escalation protocols, observation and + // response charts, risk-matrix cells) that answer zone / next-step questions. + // Match the zone the query actually names so an amber-zone question does not + // pull red-zone chunks into its candidate pool. + const zoneColour = queriedZoneColour(query); + if (zoneColour) { + addVariant(`${zoneColour} zone`); + } } addVariant(analysis.queryRewrite.searchQuery); @@ -3171,7 +3184,10 @@ export function decideTextFastPath( } if (queryClass === "document_lookup") { - if (isRiskFlowchartNextStepQuery(query) && !hasRiskFlowchartActionEvidence(results)) { + // Flowchart/zone "next step" questions need the zone-action evidence (red + // zone -> escalate / urgent review), not just a lexically matching flowchart + // page; otherwise fall through to structured/vector retrieval. + if (isRiskFlowchartNextStepQuery(query) && !hasRiskFlowchartActionEvidence(query, results)) { return { returnFastPath: false, reason: "risk_flowchart_requires_action_evidence" }; } if (directTitleSupport && strongestScore >= 0.32) { @@ -3269,23 +3285,30 @@ function hasAnyTerm(text: string, pattern: RegExp) { function isRiskFlowchartNextStepQuery(query: string) { return ( - /\b(?:flow\s*chart|flowchart|algorithm|pathway|risk matrix)\b/i.test(query) && - /\b(?:risk|red[\s-]*zone|red)\b/i.test(query) && + /\b(?:flow\s*chart|flowchart|algorithm|pathway|risk[\s-]*matrix)\b/i.test(query) && + riskZoneContextPattern.test(query) && /\b(?:next step|step after|after|action)\b/i.test(query) ); } -function hasRiskFlowchartActionEvidence(results: SearchResult[], limit = 5) { +function hasRiskFlowchartActionEvidence(query: string, results: SearchResult[], limit = 5) { + // A single result must carry BOTH the zone context and the action language + // (escalate / urgent review): scattering the two term groups across different + // results (or their image captions) let unrelated risk-assessment flowcharts + // pass. Deliberately does NOT require a flowchart word in the evidence — the + // escalation protocols that answer a red-zone question express the flowchart's + // decision steps as prose ("has any Purple or Red Zone criteria ... escalate + // for Senior Clinician Review") without ever saying "flowchart". + // + // The shared patterns are scoped to the colour the query names (a red-zone + // question must not fast-path on an amber-zone chunk); for risk-matrix / + // flowchart visual units the bare cell colour token counts as zone context. + const { zonePhrasePattern, bareColourPattern } = zoneContextPatternsForQuery(query); return results.slice(0, limit).some((result) => { const evidenceText = evidenceTextForGate(result); - const hasFlowchartRiskContext = - hasAnyTerm(evidenceText, /\b(?:flow\s*chart|flowchart|algorithm|pathway|matrix)\b/i) && - hasAnyTerm(evidenceText, /\b(?:risk|red[\s-]*zone|red)\b/i); - const hasAction = - hasAnyTerm(evidenceText, /\b(?:escalat(?:e|ion|ed|ing)?|urgent|next step|senior)\b/i) || - (visualEvidenceUnitTypes.has(result.index_unit?.unit_type ?? "") && - hasAnyTerm(evidenceText, /\b(?:escalat(?:e|ion|ed|ing)?|urgent|next step|senior)\b/i)); - return hasFlowchartRiskContext && hasAction; + if (!riskZoneActionPattern.test(evidenceText)) return false; + if (zonePhrasePattern.test(evidenceText)) return true; + return ["risk_matrix_cell", "flowchart_step", "diagram_decision"].includes(result.index_unit?.unit_type ?? "") && bareColourPattern.test(evidenceText); }); } @@ -3480,8 +3503,12 @@ export function evaluateEvidenceCoverageGate( sourceImageSatisfied, }; } - if (/\b(?:flow\s*chart|flowchart|red\s*zone|risk matrix)\b/i.test(query)) { - const accepted = hasRiskFlowchartActionEvidence(results); + // Only zone/next-step flowchart questions need the zone-action evidence + // gate; a plain flowchart document lookup ("which procedure flowchart + // covers X?") falls through to the ordinary title gate below so a direct + // title hit is not rejected for lacking zone evidence. + if (isRiskFlowchartNextStepQuery(query)) { + const accepted = hasRiskFlowchartActionEvidence(query, results); return { accepted, reason: accepted ? "visual_flowchart_risk_gate" : "missing_visual_flowchart_risk_evidence", diff --git a/tests/retrieval-query-variants.test.ts b/tests/retrieval-query-variants.test.ts index 9ada505b2..544cfe0fb 100644 --- a/tests/retrieval-query-variants.test.ts +++ b/tests/retrieval-query-variants.test.ts @@ -252,6 +252,231 @@ describe("retrieval query variants", () => { ).toEqual({ returnFastPath: true, reason: "dose_evidence_text_match" }); }); + it("keeps flowchart zone-action fast paths gated on action evidence", () => { + const query = "In the clinical flowchart, what is the next step after red-zone risk?"; + + // A lexically strong but action-free flowchart page must not short-circuit + // retrieval before the structured/vector layers can surface the zone actions. + expect( + decideTextFastPath( + query, + [ + result({ + content: "Appendix IV: Risk assessment flow chart to identify infection control procedures.", + similarity: 0.82, + }), + ], + "document_lookup", + ), + ).toEqual({ returnFastPath: false, reason: "risk_flowchart_requires_action_evidence" }); + + // An action word alone (review/urgent) without any coloured-zone context on + // the same result must not satisfy the guard either. + expect( + decideTextFastPath( + query, + [ + result({ + content: "Flowchart: review infection-control procedures before the patient proceeds.", + similarity: 0.82, + }), + ], + "document_lookup", + ), + ).toEqual({ returnFastPath: false, reason: "risk_flowchart_requires_action_evidence" }); + + // Document "review" boilerplate (review date / reviewed by) on a zone chunk + // is not an action instruction and must not satisfy the guard. + expect( + decideTextFastPath( + query, + [ + result({ + content: "Red Zone criteria table. Review date: March 2026. Reviewed by the policy committee.", + similarity: 0.82, + }), + ], + "document_lookup", + ), + ).toEqual({ returnFastPath: false, reason: "risk_flowchart_requires_action_evidence" }); + + expect( + decideTextFastPath( + query, + [ + result({ + content: + "If deteriorating (has any Purple or Red Zone criteria on observation chart), escalate for Senior Clinician Review or call a MET.", + similarity: 0.82, + }), + ], + "document_lookup", + ), + ).toEqual({ returnFastPath: true, reason: "strong_document_text_score" }); + }); + + it("applies the zone-action guard to hyphenated risk-matrix queries", () => { + // "risk-matrix" phrasing must trigger the same guard as "risk matrix". + expect( + decideTextFastPath( + "In the risk-matrix, what is the next step after the red zone?", + [ + result({ + content: "Risk-matrix overview of procedural exposure categories.", + similarity: 0.82, + }), + ], + "document_lookup", + ), + ).toEqual({ returnFastPath: false, reason: "risk_flowchart_requires_action_evidence" }); + }); + + it("matches the queried zone colour before fast-pathing", () => { + // A red-zone question must not fast-path on an amber-zone action chunk. + expect( + decideTextFastPath( + "In the clinical flowchart, what is the next step after red-zone risk?", + [ + result({ + content: "If the patient reaches the Amber Zone, escalate for urgent senior review.", + similarity: 0.82, + }), + ], + "document_lookup", + ), + ).toEqual({ returnFastPath: false, reason: "risk_flowchart_requires_action_evidence" }); + + // ...and an amber-zone question (no literal "risk"/"red") still triggers the + // guard and is satisfied by amber-zone action evidence. + expect( + decideTextFastPath( + "In the clinical flowchart, what is the next step after the amber zone?", + [ + result({ + content: "If the patient reaches the Amber Zone, escalate for urgent senior review.", + similarity: 0.82, + }), + ], + "document_lookup", + ), + ).toEqual({ returnFastPath: true, reason: "strong_document_text_score" }); + }); + + it("accepts risk-matrix cell colour tokens as zone context", () => { + // risk_matrix_cell units store the cell colour as a bare token + // ("... | Red | escalate ..."), not as the phrase "red zone". + expect( + decideTextFastPath( + "In the risk matrix flowchart, what action is shown after red-zone risk?", + [ + result({ + content: "Aggression risk matrix | Physical aggression | Recent incident | Red | Escalate to senior clinician urgently", + similarity: 0.82, + index_unit: { + id: "unit-rm", + unit_type: "risk_matrix_cell", + title: "Physical aggression / Recent incident: Red", + content: "Aggression risk matrix | Physical aggression | Recent incident | Red | Escalate to senior clinician urgently", + source_chunk_id: "chunk-1", + source_image_id: "image-1", + page_start: 1, + page_end: 1, + heading_path: ["Risk matrix"], + normalized_terms: ["red", "risk matrix"], + quality_score: 0.9, + extraction_mode: "model_heavy", + }, + }), + ], + "document_lookup", + ), + ).toEqual({ returnFastPath: true, reason: "strong_document_text_score" }); + }); + + it("treats hyphenated risk-matrix queries as flowchart next-step lookups", () => { + expect( + decideTextFastPath( + "In the risk-matrix flowchart, what action is shown after red-zone risk?", + [ + result({ + content: "Aggression risk matrix | Physical aggression | Recent incident | Red | Escalate to senior clinician urgently", + similarity: 0.82, + index_unit: { + id: "unit-rm-hyphen", + unit_type: "risk_matrix_cell", + title: "Physical aggression / Recent incident: Red", + content: "Aggression risk matrix | Physical aggression | Recent incident | Red | Escalate to senior clinician urgently", + source_chunk_id: "chunk-1", + source_image_id: "image-1", + page_start: 1, + page_end: 1, + heading_path: ["Risk matrix"], + normalized_terms: ["red", "risk matrix"], + quality_score: 0.9, + extraction_mode: "model_heavy", + }, + }), + ], + "document_lookup", + ), + ).toEqual({ returnFastPath: true, reason: "strong_document_text_score" }); + }); + + it("requires zone and action evidence on a single result for the flowchart risk gate", () => { + const query = "In the clinical flowchart, what is the next step after red-zone risk?"; + + // Zone words on one result and action words on another (or a flowchart image + // caption) must not satisfy the gate - the answer needs both on one result. + expect( + evaluateEvidenceCoverageGate( + query, + [ + result({ content: "Risk assessment flow chart for infection control procedures.", similarity: 0.9 }), + result({ + id: "chunk-2", + content: "Compliance is monitored by review of clinical incidents.", + similarity: 0.8, + }), + ], + "document_lookup", + ), + ).toMatchObject({ accepted: false, reason: "missing_visual_flowchart_risk_evidence" }); + + expect( + evaluateEvidenceCoverageGate( + query, + [ + result({ + content: + "If deteriorating (has any Purple or Red Zone criteria on observation chart), escalate for Senior Clinician Review.", + similarity: 0.9, + }), + ], + "document_lookup", + ), + ).toMatchObject({ accepted: true, reason: "visual_flowchart_risk_gate" }); + }); + + it("routes plain flowchart document lookups through the ordinary title gate", () => { + // A flowchart mention without zone / next-step intent must not be forced + // through the zone-action gate; a direct title hit uses the title gate. + expect( + evaluateEvidenceCoverageGate( + "Which procedure flowchart covers ECT team coordination?", + [ + result({ + title: "ECT Team Coordination Procedure Flowchart", + file_name: "ect-team-coordination-flowchart.pdf", + content: "Procedure flowchart covering ECT team coordination responsibilities.", + similarity: 0.72, + match_explanation: { titleHit: true, reasons: ["title"] }, + }), + ], + "document_lookup", + ), + ).toMatchObject({ strategy: "document_lookup_fast_path", reason: "document_title_evidence_gate" }); + }); + it("does not fast-path comparison queries before synthesis retrieval", () => { expect( decideTextFastPath( @@ -462,10 +687,44 @@ describe("retrieval query variants", () => { expect(textQuery).toContain("flowchart"); expect(textQuery).toContain("next"); expect(textQuery).toContain("step"); - expect(variants).toEqual(expect.arrayContaining(["risk flow", "red zone risk flow"])); + // "red zone" replaces the old fully-conjunctive variants ("red zone risk flow", + // "risk flow review urgent escalation") which matched 0/2 live chunks under + // websearch_to_tsquery AND semantics and never contributed candidates. + expect(variants).toEqual(expect.arrayContaining(["risk flow", "red zone"])); + expect(variants).not.toContain("red zone risk flow"); expect(variants.length).toBeLessThanOrEqual(4); }); + it("matches the zone variant to the colour the query names", () => { + const query = "In the clinical flowchart, what is the next step after amber-zone risk?"; + const variants = buildRetrievalQueryVariants(query, analyzeClinicalQuery(query)); + + // An amber-zone question must not pull red-zone chunks into its candidate + // pool; the injected variant follows the colour the query names. + expect(variants).toContain("amber zone"); + expect(variants).not.toContain("red zone"); + }); + + it("injects the zone variant for risk-matrix wording too", () => { + const query = "What action is shown for the risk matrix red zone?"; + const variants = buildRetrievalQueryVariants(query, analyzeClinicalQuery(query)); + + // Risk-matrix questions hit the same zone-action evidence (risk_matrix_cell + // units store the colour as a bare token), so they need the precise + // " zone" variant just like flowchart wording does. + expect(variants).toContain("red zone"); + }); + + it("injects zone-colour variant for risk-matrix queries without a flowchart token", () => { + // A query phrased as "risk matrix" (no "flowchart"/"algorithm"/"pathway") must + // still receive the zone-colour variant so recall is not regressed for this + // class of next-step question. + const query = "In the risk matrix, what is the next step after the red zone?"; + const variants = buildRetrievalQueryVariants(query, analyzeClinicalQuery(query)); + + expect(variants).toEqual(expect.arrayContaining(["risk flow", "red zone"])); + }); + it("keeps agitation route queries focused on the canonical agitation and arousal source", () => { const query = "What IM or PO options are listed for agitation?"; const textQuery = buildClinicalTextSearchQuery(query); @@ -618,6 +877,62 @@ describe("retrieval query variants", () => { expect(selected.results[0]?.file_name).toBe("Clinical Risk Flowchart.pdf"); }); + it("does not boost another colour's zone action for a colour-specific query", () => { + const query = "In the clinical flowchart, what is the next step after red-zone risk?"; + const ranked = rankClinicalResults(query, [ + result({ + id: "amber-zone-action", + document_id: "amber-zone-doc", + title: "Deterioration Response Guideline", + file_name: "Deterioration Response Guideline.pdf", + content: "If the patient reaches the Amber Zone, escalate for urgent senior review.", + similarity: 0.62, + }), + ]); + + // Amber-zone action evidence must not take the risk-flowchart boost (or + // dodge the generic penalty) for a red-zone question. + expect(ranked[0]?.score_explanation?.rawPenalty).toBeLessThanOrEqual(-0.18); + }); + + it("penalizes action-free risk flowcharts for next-step queries", () => { + const query = "In the clinical flowchart, what is the next step after red-zone risk?"; + const ranked = rankClinicalResults(query, [ + result({ + id: "action-free-flowchart", + document_id: "action-free-doc", + title: "Infection Control Policy", + file_name: "Infection Control Policy.pdf", + content: "Risk assessment flow chart covering red-zone procedural risk categories.", + similarity: 0.62, + }), + ]); + + // Naming the risk without any action instruction must not earn the + // risk-flowchart boost (or dodge the generic penalty) for a next-step query. + expect(ranked[0]?.score_explanation?.rawPenalty).toBeLessThanOrEqual(-0.18); + }); + + it("treats zone-action escalation text as risk-flowchart evidence even without the word flowchart", () => { + const query = "In the clinical flowchart, what is the next step after red-zone risk?"; + const ranked = rankClinicalResults(query, [ + result({ + id: "zone-action", + document_id: "zone-action-doc", + title: "Recognising and Responding to Acute Deterioration", + file_name: "Recognising and Responding to Acute Deterioration.pdf", + content: + "If deteriorating (has any Purple or Red Zone criteria on observation chart), escalate for Senior Clinician Review or call a MET.", + similarity: 0.62, + }), + ]); + + // Escalation protocols express the flowchart's decision steps as text; they + // must not take the generic risk-flowchart penalty for lacking the literal + // word "flowchart". + expect(ranked[0]?.score_explanation?.rawPenalty).toBe(0); + }); + it("redacts retrieval cache keys while preserving query class and variant uniqueness", () => { const baseArgs = { query: "What ANC threshold should stop clozapine?",