From 9ba8b05370971b839e76d47fde300747831d54cb Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:38:21 +0800 Subject: [PATCH 1/2] fix(differentials): exclude "Urgency: urgent" export artifact from presentations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The differentials export's focused-diagnostic-trap-tables appendix (T_Focused_Diagnostic_Trap_Tables.txt) has no title line, so the parser promoted its first metadata row ("Urgency: urgent") into a presentation titled "Urgency: urgent" (slug urgency-urgent) with detail routes and search visibility once presentations became a searchable domain. Exclude titleless metadata-row entries at three layers sharing one predicate (isDifferentialMetadataArtifactTitle): - buildDifferentialSnapshot drops such entries from presentations (their options remain diagnoses — 4 exist in no other entry) and keeps the artifact title out of diagnosis currentPresentation. - usableDifferentialPresentations() is the single source of truth for the runtime catalogue, seed rows, and stale-row pruning. - Regenerated the snapshot (31 -> 30 presentations). Seeding upserts and never deletes, and auto-seed only fires for zero-row owners, so already-seeded owners keep the stale differential_records row and its registry corpus document; differentials:seed now prunes stale seeded presentations and their corpus documents per owner (live Supabase, operator-run). Tests updated to 30 with regression coverage for the exclusion and the prune helper. Co-Authored-By: Claude Fable 5 --- data/differentials-snapshot.json | 171 +--------------------- docs/process-hardening.md | 5 + scripts/lib/parse-differentials-export.ts | 13 +- scripts/seed-differential-records.ts | 32 +++- src/app/api/differentials/route.ts | 10 +- src/lib/differential-fixtures.ts | 16 +- src/lib/differential-seed.ts | 18 ++- src/lib/differential-snapshot.ts | 9 ++ src/lib/differentials.ts | 7 +- src/lib/registry-corpus.ts | 7 + tests/differentials.test.ts | 58 +++++++- 11 files changed, 169 insertions(+), 177 deletions(-) diff --git a/data/differentials-snapshot.json b/data/differentials-snapshot.json index a680edf12..0b2fa18b6 100644 --- a/data/differentials-snapshot.json +++ b/data/differentials-snapshot.json @@ -1,6 +1,6 @@ { "version": "v10", - "exportedAt": "2026-07-05", + "exportedAt": "2026-07-10", "presentations": [ { "id": "acute-confusion-encephalopathy", @@ -4908,161 +4908,6 @@ "version": "v10 | Local content only", "lastUpdated": "Pending review" } - }, - { - "id": "urgency-urgent", - "title": "Urgency: urgent", - "status": "urgent", - "subtitle": "Distinguishes intrusive/obsessional phenomena from psychotic or violent intent.", - "selectedCount": 4, - "totalCount": 7, - "safetySnapshot": { - "summary": "Distinguishes intrusive/obsessional phenomena from psychotic or violent intent.", - "tags": ["Review must-not-miss causes"] - }, - "criteria": [ - { - "id": "why-it-fits", - "title": "Why it fits", - "tone": "fit" - }, - { - "id": "what-argues-against", - "title": "What argues against", - "tone": "overlap" - }, - { - "id": "must-not-miss", - "title": "Must-not-miss", - "tone": "warning" - }, - { - "id": "bedside-question", - "title": "Bedside question", - "tone": "question" - }, - { - "id": "immediate-action", - "title": "Immediate action", - "tone": "action" - }, - { - "id": "investigations", - "title": "Investigations", - "tone": "test" - }, - { - "id": "mimics-overlap", - "title": "Mimics / overlap", - "tone": "overlap" - } - ], - "candidates": [ - { - "slug": "ocd", - "selected": true, - "comparison": { - "why-it-fits": "Repetitive intrusive harm/contamination/sexual/religious thoughts with rituals, reassurance, avoidance. Ego-dystonic, resisted", - "what-argues-against": "Review locally.", - "must-not-miss": "Functional collapse, hidden shame, severe depression, suicidality.", - "bedside-question": "Distinguishes intrusive/obsessional phenomena from psychotic or violent intent.", - "immediate-action": "Stabilise and reassess.", - "investigations": "Guided by presentation.", - "mimics-overlap": "Review overlap locally." - } - }, - { - "slug": "perinatal-ocd-intrusive-infant-harm-thoughts", - "selected": true, - "comparison": { - "why-it-fits": "Postpartum/antenatal distressing infant-harm images/urges with checking/avoidance. Ego-dystonic, horrifying, preserved insight", - "what-argues-against": "Review locally.", - "must-not-miss": "Failure to feed/care, severe insomnia, concealed risk.", - "bedside-question": "Distinguishes intrusive/obsessional phenomena from psychotic or violent intent.", - "immediate-action": "Stabilise and reassess.", - "investigations": "Guided by presentation.", - "mimics-overlap": "Review overlap locally." - } - }, - { - "slug": "ptsd-intrusive-memories-flashbacks", - "selected": true, - "comparison": { - "why-it-fits": "Repetitive images, body memories, reliving, dissociative episodes linked to trauma", - "what-argues-against": "Review locally.", - "must-not-miss": "Severe dissociation, self-harm, substance use, child-safety impairment.", - "bedside-question": "Distinguishes intrusive/obsessional phenomena from psychotic or violent intent.", - "immediate-action": "Stabilise and reassess.", - "investigations": "Guided by presentation.", - "mimics-overlap": "Review overlap locally." - } - }, - { - "slug": "psychotic-delusion", - "selected": true, - "comparison": { - "why-it-fits": "Fixed bizarre/persecutory belief with impaired reality testing, conviction-based", - "what-argues-against": "Review locally.", - "must-not-miss": "Command phenomena, severe self-neglect, violence, food/fluid refusal.", - "bedside-question": "Distinguishes intrusive/obsessional phenomena from psychotic or violent intent.", - "immediate-action": "Stabilise and reassess.", - "investigations": "Guided by presentation.", - "mimics-overlap": "Review overlap locally." - } - }, - { - "slug": "overvalued-idea", - "selected": false, - "comparison": { - "why-it-fits": "Strongly held but understandable idea around weight/appearance/health/morality. Between obsession and delusion", - "what-argues-against": "Review locally.", - "must-not-miss": "Starvation, self-injury, escalating treatment refusal.", - "bedside-question": "Distinguishes intrusive/obsessional phenomena from psychotic or violent intent.", - "immediate-action": "Stabilise and reassess.", - "investigations": "Guided by presentation.", - "mimics-overlap": "Review overlap locally." - } - }, - { - "slug": "gad-worry-depressive-rumination", - "selected": false, - "comparison": { - "why-it-fits": "Repeated catastrophic thinking, mental rehearsal, hopeless rumination without compulsions", - "what-argues-against": "Review locally.", - "must-not-miss": "Severe insomnia, suicidality, marked psychomotor slowing, functional collapse.", - "bedside-question": "Distinguishes intrusive/obsessional phenomena from psychotic or violent intent.", - "immediate-action": "Stabilise and reassess.", - "investigations": "Guided by presentation.", - "mimics-overlap": "Review overlap locally." - } - }, - { - "slug": "genuine-violent-homicidal-intent", - "selected": false, - "comparison": { - "why-it-fits": "Desire/plan/willingness to harm, ego-syntonic, grievance-based, with preparation", - "what-argues-against": "Review locally.", - "must-not-miss": "Immediate child-safety or public-safety emergency, weapon access, stalking.", - "bedside-question": "Distinguishes intrusive/obsessional phenomena from psychotic or violent intent.", - "immediate-action": "Stabilise and reassess.", - "investigations": "Guided by presentation.", - "mimics-overlap": "Review overlap locally." - } - } - ], - "reviewChecklist": [ - "Stabilise and rule out immediate threats", - "Assess for reversible causes", - "Investigate and treat precipitants", - "Reassess and refine differential", - "Document and handoff" - ], - "highestUrgencyNote": "Safety first. Act early.", - "sourceStatus": { - "label": "Source pending review", - "version": "v10 | Local content only", - "lastUpdated": "Pending review" - } } ], "diagnoses": [ @@ -14658,7 +14503,7 @@ "note": "Desire/plan/willingness to harm, ego-syntonic, grievance-based, with preparation" } ], - "currentPresentation": ["Urgency: urgent"], + "currentPresentation": [], "investigations": [], "immediateActions": [] }, @@ -15095,7 +14940,7 @@ "note": "Severe postpartum depression with guilt, nihilism, deserved-punishment ideas. Mood-congruent psychosis" } ], - "currentPresentation": ["Urgency: urgent", "Focused comparator (perinatal)"], + "currentPresentation": ["Focused comparator (perinatal)"], "investigations": [], "immediateActions": [] }, @@ -18958,7 +18803,6 @@ } ], "currentPresentation": [ - "Urgency: urgent", "Anxiety / Panic / Obsessions / Health Anxiety", "Panic disorder, Generalised anxiety disorder, OCD", "CLINICAL HINGE: Abrupt peak over minutes, recurrent unexpected attacks, anticipatory anxiety or avoidance." @@ -19600,7 +19444,7 @@ "note": "Desire/plan/willingness to harm, ego-syntonic, grievance-based, with preparation" } ], - "currentPresentation": ["Urgency: urgent"], + "currentPresentation": [], "investigations": [], "immediateActions": [] }, @@ -20256,8 +20100,7 @@ "Perinatal Acute Psychiatry", "Postpartum psychosis, Postnatal depression, Perinatal OCD / intrusive infant-harm thoughts", "CLINICAL HINGE: Psychotic or manic syndrome with abrupt change from baseline in the postpartum period.", - "Focused comparator (perinatal)", - "Urgency: urgent" + "Focused comparator (perinatal)" ], "investigations": [], "immediateActions": [] @@ -21453,7 +21296,7 @@ "note": "Desire/plan/willingness to harm, ego-syntonic, grievance-based, with preparation" } ], - "currentPresentation": ["Urgency: urgent"], + "currentPresentation": [], "investigations": [], "immediateActions": [] }, @@ -22930,7 +22773,7 @@ "note": "Desire/plan/willingness to harm, ego-syntonic, grievance-based, with preparation" } ], - "currentPresentation": ["Urgency: urgent"], + "currentPresentation": [], "investigations": [], "immediateActions": [] }, diff --git a/docs/process-hardening.md b/docs/process-hardening.md index f9d9cd98a..0c093f566 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -303,3 +303,8 @@ hybrid:10}`, all 10 forced-embedding vector cases passed (`force_embedding_failu - **Full re-triage of the 2026-07-01 audit's H1–H4 and M1–M17 against current main: all 21 are closed.** The 2026-07-02 remediation branch landed (H1/H2/H4+M8+M16, M1–M12, M14, M15, M17 all carry audit-referencing fixes verified in code, not just comments); PR #278 added the P0 carry-over (including the M9 DELETE TOCTOU re-check); M13's migration (`20260702000000`) is in-tree (live-apply verification needs `npm run check:m13-migration` with live keys). H3 is closed by supersession: PR #118 removed the governance penalties it complained about entirely (measured, "do not reintroduce" comment in `retrieval-selection.ts`); the residual `Math.max` floor only affects intent boosts, and un-flooring it was already tried and withdrawn (compounding across selection passes — audit R1). Do not re-open H3 without the golden retrieval eval. - **New fast-check property suite** (`tests/property-*.test.ts`) pins the text-processing core's clinical invariants over generated inputs: unit-bearing numeric tokens survive sanitization; chunking terminates (including `CHUNK_OVERLAP >= CHUNK_SIZE`, M17) and neither loses nor invents content; table normalization stays rectangular, preserves numeric values, and the low-confidence caveat survives both clipboard export paths (H4). The suite immediately caught a live line-level H2 residue in `stripLowYieldLines` (control markers glued to threshold sentences deleted the whole line), fixed in the same PR. - **Eval debt (blocking merge of the sanitizer fix, not development):** the environment had no live Supabase/OpenAI keys, so `npm run eval:quality -- --rag-only` (answer-path gate for the sanitizer change) could not run. Run it before merging `claude/audit-sweep-property-tests`. No retrieval-selection/ranking files were touched, so the golden retrieval eval is not required for this branch. + +## Differentials export artifact "urgency-urgent" removed — live prune pending (2026-07-11) + +- The differentials export's trap-tables appendix (`T_Focused_Diagnostic_Trap_Tables.txt`) has no title line, so the parser surfaced its first metadata row ("Urgency: urgent") as a presentation titled "Urgency: urgent" (slug `urgency-urgent`) with detail routes and search visibility. Fixed at three layers: `buildDifferentialSnapshot` excludes metadata-title entries from presentations (their 7 options remain diagnoses — 4 exist in no other entry) and keeps the artifact title out of diagnosis `currentPresentation`; `differentialPresentations()` filters artifact titles at runtime (same pattern as the preset/alias artifact filters); the snapshot was regenerated (31 → 30 presentations). +- **Operator follow-up (live Supabase, confirmation-required):** seeding upserts and never deletes, and auto-seed only fires for owners with zero rows, so already-seeded owners keep the stale `differential_records` row (`kind='presentation'`, `slug='urgency-urgent'`) and — where corpus embedding ran — its registry corpus document/chunks in `documents`/`document_chunks`. `npm run differentials:seed -- --owner-id --write` now prunes stale seeded presentations and their corpus documents per owner; run it (or the equivalent targeted deletes) for each seeded owner. diff --git a/scripts/lib/parse-differentials-export.ts b/scripts/lib/parse-differentials-export.ts index 4a5715cac..7bd5cac25 100644 --- a/scripts/lib/parse-differentials-export.ts +++ b/scripts/lib/parse-differentials-export.ts @@ -1,3 +1,4 @@ +import { isDifferentialMetadataArtifactTitle } from "@/lib/differential-snapshot"; import type { DifferentialComparisonCandidate, DifferentialComparisonCriterion, @@ -347,7 +348,10 @@ function buildDiagnosisRecord(option: ParsedOption, presentation: ParsedPresenta likelihood: "possible" as const, note: candidate.summary, })), - currentPresentation: [presentation.title, ...presentation.mustNotMiss.slice(0, 2)], + currentPresentation: [ + ...(isDifferentialMetadataArtifactTitle(presentation.title) ? [] : [presentation.title]), + ...presentation.mustNotMiss.slice(0, 2), + ], investigations: presentation.investigations, immediateActions: presentation.immediateActions, }; @@ -495,7 +499,12 @@ export function buildDifferentialSnapshot(input: { governanceMarkdown: string; }): DifferentialSnapshot { const parsedPresentations = input.entryFiles.map(({ name, content }) => parseEntryFile(content, pathBasename(name))); - const presentations = parsedPresentations.map(buildPresentationWorkflow); + // Titleless entry files (e.g. the focused diagnostic trap tables appendix) + // surface a metadata row as their title; they are not presentations, but + // their options are still real diagnoses — several exist in no other entry. + const presentations = parsedPresentations + .filter((parsed) => !isDifferentialMetadataArtifactTitle(parsed.title)) + .map(buildPresentationWorkflow); const diagnosisMap = new Map(); for (const parsed of parsedPresentations) { diff --git a/scripts/seed-differential-records.ts b/scripts/seed-differential-records.ts index 1fca80e5b..86a956e96 100644 --- a/scripts/seed-differential-records.ts +++ b/scripts/seed-differential-records.ts @@ -3,6 +3,7 @@ import { loadEnvConfig } from "@next/env"; import { confirm } from "./cli-utils"; import { buildDefaultDifferentialRows, loadDifferentialSnapshot } from "@/lib/differential-fixtures"; import type { DifferentialRecordRow } from "@/lib/differential-records"; +import { staleSeededPresentations } from "@/lib/differential-seed"; loadEnvConfig(process.cwd()); @@ -85,7 +86,7 @@ async function main() { const supabase = await loadAdminClient(); const { data: existing, error: existingError } = await supabase .from("differential_records") - .select("kind, slug, source_status, validation_status, last_reviewed_at, review_due_at") + .select("id, kind, slug, source_status, validation_status, last_reviewed_at, review_due_at") .eq("owner_id", args.ownerId); if (existingError) { throw new Error(`Could not read existing governance: ${existingError.message}`); @@ -119,6 +120,35 @@ async function main() { }); if (error) throw new Error(`Upsert failed: ${error.message}`); + // Upserts never delete, so presentation rows the snapshot no longer produces + // (e.g. the removed "urgency-urgent" export artifact) linger for + // already-seeded owners; prune them and their registry corpus documents. + const stale = staleSeededPresentations(existing ?? []); + if (stale.length > 0) { + const { differentialCorpusDocumentId } = await loadRegistryCorpus(); + console.log( + `[differentials:seed] Removing ${stale.length} stale presentation row(s) no longer in the snapshot: ${stale + .map((row) => row.slug) + .join(", ")}`, + ); + const { error: staleCorpusError } = await supabase + .from("documents") + .delete() + .in( + "id", + stale.map((row) => differentialCorpusDocumentId(row.id)), + ); + if (staleCorpusError) throw new Error(`Stale corpus document cleanup failed: ${staleCorpusError.message}`); + const { error: staleRowError } = await supabase + .from("differential_records") + .delete() + .in( + "id", + stale.map((row) => row.id), + ); + if (staleRowError) throw new Error(`Stale presentation row cleanup failed: ${staleRowError.message}`); + } + const { count, error: countError } = await supabase .from("differential_records") .select("id", { count: "exact", head: true }) diff --git a/src/app/api/differentials/route.ts b/src/app/api/differentials/route.ts index d44da8707..1cfd99dc9 100644 --- a/src/app/api/differentials/route.ts +++ b/src/app/api/differentials/route.ts @@ -16,6 +16,7 @@ import { } from "@/lib/differential-records"; import { ensureDifferentialsSeeded, loadDifferentialSnapshot } from "@/lib/differential-seed"; import { + differentialPresentations, differentialRecords, rankDifferentialRecords, rankPresentationWorkflows, @@ -61,11 +62,14 @@ function publicDifferentialPayload(kind: DifferentialRecordKind, q: string | und const snapshot = loadDifferentialSnapshot(); const governance = deriveGovernanceFromSnapshot(snapshot); if (kind === "presentation") { - const ranked = q ? rankPresentationWorkflows(snapshot.presentations, q, limit) : null; + // differentialPresentations() (not snapshot.presentations) so export + // artifacts stay filtered out of the public payload. + const presentations = differentialPresentations(); + const ranked = q ? rankPresentationWorkflows(presentations, q, limit) : null; return { - presentations: ranked ? ranked.map((match) => match.workflow) : snapshot.presentations, + presentations: ranked ? ranked.map((match) => match.workflow) : presentations, matches: ranked ? presentationMatchesPayload(ranked) : undefined, - total: snapshot.presentations.length, + total: presentations.length, governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status }, }; } diff --git a/src/lib/differential-fixtures.ts b/src/lib/differential-fixtures.ts index 01429a9ab..2d55c7b33 100644 --- a/src/lib/differential-fixtures.ts +++ b/src/lib/differential-fixtures.ts @@ -1,7 +1,11 @@ import differentialSnapshot from "../../data/differentials-snapshot.json"; import { diagnosisToRow, presentationToRow, type DifferentialRecordInsert } from "@/lib/differential-records"; -import type { DifferentialSnapshot } from "@/lib/differential-snapshot"; +import { + isDifferentialMetadataArtifactTitle, + type DifferentialPresentationWorkflow, + type DifferentialSnapshot, +} from "@/lib/differential-snapshot"; let cachedSnapshot: DifferentialSnapshot | null = null; @@ -21,9 +25,17 @@ export function loadDifferentialSnapshot(): DifferentialSnapshot { return cachedSnapshot; } +/** The presentations that ship to the app and the seed: excludes export + * artifacts where a titleless entry file surfaced a metadata row (e.g. + * "Urgency: urgent") as its title. Single source of truth for the runtime + * catalogue, the seed rows, and stale-row pruning. */ +export function usableDifferentialPresentations(snapshot: DifferentialSnapshot): DifferentialPresentationWorkflow[] { + return snapshot.presentations.filter((presentation) => !isDifferentialMetadataArtifactTitle(presentation.title)); +} + export function buildDefaultDifferentialRows(ownerId: string): DifferentialRecordInsert[] { const snapshot = loadDifferentialSnapshot(); - const presentationRows = snapshot.presentations.map((presentation) => + const presentationRows = usableDifferentialPresentations(snapshot).map((presentation) => presentationToRow(presentation, ownerId, snapshot), ); const diagnosisRows = snapshot.diagnoses.map((diagnosis) => diagnosisToRow(diagnosis, ownerId, snapshot)); diff --git a/src/lib/differential-seed.ts b/src/lib/differential-seed.ts index 521778143..6a919ee62 100644 --- a/src/lib/differential-seed.ts +++ b/src/lib/differential-seed.ts @@ -1,4 +1,8 @@ -import { buildDefaultDifferentialRows } from "@/lib/differential-fixtures"; +import { + buildDefaultDifferentialRows, + loadDifferentialSnapshot, + usableDifferentialPresentations, +} from "@/lib/differential-fixtures"; import { type DifferentialRecordInsert, type DifferentialRecordRow } from "@/lib/differential-records"; type AdminClient = ReturnType; @@ -30,3 +34,15 @@ export async function ensureDifferentialsSeeded( export function buildDifferentialSeedRows(ownerId: string): DifferentialRecordInsert[] { return buildDefaultDifferentialRows(ownerId); } + +/** Seeded presentation rows whose slug the current snapshot no longer produces + * (e.g. removed export artifacts such as "urgency-urgent"). Seeding upserts + * and never deletes, so these linger for already-seeded owners until pruned. + * Diagnoses are deliberately not pruned: their slugs merge and churn across + * snapshot versions, so set-difference pruning would be unsafe there. */ +export function staleSeededPresentations(rows: Row[]): Row[] { + const validSlugs = new Set( + usableDifferentialPresentations(loadDifferentialSnapshot()).map((presentation) => presentation.id), + ); + return rows.filter((row) => row.kind === "presentation" && !validSlugs.has(row.slug)); +} diff --git a/src/lib/differential-snapshot.ts b/src/lib/differential-snapshot.ts index 173be36c4..7e88dc520 100644 --- a/src/lib/differential-snapshot.ts +++ b/src/lib/differential-snapshot.ts @@ -89,6 +89,15 @@ export type DifferentialSnapshotGovernance = { sourceTitle: string; }; +/** Entry files in the differentials export open with metadata rows ("Urgency: + * urgent", "Axis: mixed", "Population: general"). When a file has no title + * line, the parser surfaces the first metadata row as the entry title; such + * rows are export artifacts, not clinical presentations, and must not become + * workflows, routes, or search results. */ +export function isDifferentialMetadataArtifactTitle(title: string) { + return /^(urgency|axis|population)\s*:/i.test(title.trim()); +} + export type DifferentialSnapshot = { version: string; exportedAt: string; diff --git a/src/lib/differentials.ts b/src/lib/differentials.ts index e215965c8..ec2964d56 100644 --- a/src/lib/differentials.ts +++ b/src/lib/differentials.ts @@ -1,5 +1,5 @@ import { normalizeSearchText, rankCatalogRecords } from "@/lib/catalog-search"; -import { loadDifferentialSnapshot } from "@/lib/differential-fixtures"; +import { loadDifferentialSnapshot, usableDifferentialPresentations } from "@/lib/differential-fixtures"; import type { DifferentialComparisonCandidate, DifferentialComparisonCriterion, @@ -43,7 +43,10 @@ export { loadDifferentialSnapshot } from "@/lib/differential-fixtures"; export const differentialRecords: DifferentialRecord[] = catalog().diagnoses; export function differentialPresentations(): DifferentialPresentationWorkflow[] { - return catalog().presentations; + // The generated snapshot can carry export artifacts where a titleless entry + // file surfaced a metadata row (e.g. "Urgency: urgent") as its title; they + // are not presentations and must not get cards, routes, or search results. + return usableDifferentialPresentations(catalog()); } export function differentialScenarioPresets(): DifferentialScenarioPreset[] { diff --git a/src/lib/registry-corpus.ts b/src/lib/registry-corpus.ts index cec9c29e3..066ae5418 100644 --- a/src/lib/registry-corpus.ts +++ b/src/lib/registry-corpus.ts @@ -99,6 +99,13 @@ function registryDocumentId(entry: RegistryCorpusEntry) { return deterministicUuid(`registry-document:${entry.kind}:${entry.recordId}`); } +/** Corpus document id for a differential record row. Chunks cascade from the + * document, so deleting this id fully removes a pruned record from the + * corpus. Used by the differentials seed CLI when cleaning up stale rows. */ +export function differentialCorpusDocumentId(recordId: string) { + return deterministicUuid(`registry-document:differential:${recordId}`); +} + /** Registry entry metadata. */ function registryEntryMetadata(entry: RegistryCorpusEntry): Record { return { ...registryBaseMetadata(entry), ...entry.metadata }; diff --git a/tests/differentials.test.ts b/tests/differentials.test.ts index be7322124..ea167857d 100644 --- a/tests/differentials.test.ts +++ b/tests/differentials.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vitest"; -import { parseEntryFile, parseScenarioPresets, parseSearchAliases } from "../scripts/lib/parse-differentials-export"; +import { + buildDifferentialSnapshot, + parseEntryFile, + parseScenarioPresets, + parseSearchAliases, +} from "../scripts/lib/parse-differentials-export"; +import { staleSeededPresentations } from "@/lib/differential-seed"; +import { isDifferentialMetadataArtifactTitle } from "@/lib/differential-snapshot"; import { composeDifferentialSearchResults, differentialDiagnosesCards, @@ -108,14 +115,61 @@ OPTIONS: expect(aliases.tags).toBeUndefined(); expect(aliases.delirium).toEqual(["confusion", "fluctuation"]); }); + + it("excludes titleless metadata-row entries from presentations but keeps their diagnoses", () => { + // Mirrors the trap-tables appendix: no title line, so the first metadata + // row ("Urgency: urgent") would otherwise become the presentation title. + const trapEntry = `=== FOCUSED DIAGNOSTIC TRAP TABLES === + +Urgency: urgent +Axis: mixed +Population: general + +PURPOSE: Distinguishes intrusive/obsessional phenomena from psychotic or violent intent. + +OPTIONS: +1. OCD — Repetitive intrusive thoughts with rituals. Red flags: Functional collapse. + +SOURCE: v10`; + const snapshot = buildDifferentialSnapshot({ + entryFiles: [ + { name: "01_Delirium.txt", content: deliriumEntry }, + { name: "T_Focused_Diagnostic_Trap_Tables.txt", content: trapEntry }, + ], + presetsMarkdown: "", + flowsMarkdown: "", + aliasesMarkdown: "", + governanceMarkdown: "", + }); + expect(snapshot.presentations.map((presentation) => presentation.id)).toEqual(["acute-confusion-encephalopathy"]); + const ocd = snapshot.diagnoses.find((diagnosis) => diagnosis.slug === "ocd"); + expect(ocd).toBeDefined(); + // The artifact title must not leak into the kept diagnosis text either. + expect(ocd?.currentPresentation).not.toContain("Urgency: urgent"); + }); }); describe("differential records", () => { it("loads v10 snapshot with presentations and diagnoses", () => { const snapshot = loadDifferentialSnapshot(); - expect(snapshot.presentations).toHaveLength(31); + expect(snapshot.presentations).toHaveLength(30); expect(snapshot.diagnoses.length).toBeGreaterThan(100); expect(differentialRecords.length).toBe(snapshot.diagnoses.length); + // Export artifacts (metadata rows parsed as titles, e.g. "Urgency: urgent") + // must not ship as presentations. + expect( + snapshot.presentations.filter((presentation) => isDifferentialMetadataArtifactTitle(presentation.title)), + ).toEqual([]); + }); + + it("flags stale seeded presentation rows for pruning, leaving diagnoses alone", () => { + const rows = [ + { kind: "presentation", slug: "urgency-urgent" }, + { kind: "presentation", slug: "acute-confusion-encephalopathy" }, + { kind: "diagnosis", slug: "urgency-urgent" }, + ]; + const stale = staleSeededPresentations(rows); + expect(stale).toEqual([{ kind: "presentation", slug: "urgency-urgent" }]); }); it("links cards to routes", () => { From b70cf363a83e82469d4a44c51706909ac6799132 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 11 Jul 2026 22:21:40 +0800 Subject: [PATCH 2/2] fix(differentials): retitle titleless trap-tables entry instead of excluding it Pivots the "Urgency: urgent" artifact fix from exclusion to a root-cause retitle after the #483 invariant (every diagnosis belongs to a presentation, enforced by differential-detail.test.ts) surfaced: the appendix is the sole parent of 4 diagnoses that exist in no other entry (gad-worry-depressive-rumination, overvalued-idea, psychotic-delusion, ptsd-intrusive-memories-flashbacks), so excluding it orphaned them. parseEntryTitle now falls back to the "=== HEADER ===" text when the first line after the header is a metadata row, titling the entry "Focused Diagnostic Trap Tables" (slug focused-diagnostic-trap-tables) instead of "Urgency: urgent". Reverts the runtime/seed exclusion filters added by the earlier approach; the snapshot stays 31 presentations with all diagnoses parented. staleSeededPresentations still prunes the retired "urgency-urgent" slug (and its corpus document) for already-seeded owners, since the slug changed. Co-Authored-By: Claude Fable 5 --- data/differentials-snapshot.json | 171 +++++++++++++++++++++- docs/process-hardening.md | 6 +- scripts/lib/parse-differentials-export.ts | 35 +++-- src/app/api/differentials/route.ts | 10 +- src/lib/differential-fixtures.ts | 16 +- src/lib/differential-seed.ts | 17 +-- src/lib/differential-snapshot.ts | 6 +- src/lib/differentials.ts | 7 +- tests/differentials.test.ts | 36 +++-- 9 files changed, 225 insertions(+), 79 deletions(-) diff --git a/data/differentials-snapshot.json b/data/differentials-snapshot.json index 0b2fa18b6..fe0ba8ec7 100644 --- a/data/differentials-snapshot.json +++ b/data/differentials-snapshot.json @@ -1,6 +1,6 @@ { "version": "v10", - "exportedAt": "2026-07-10", + "exportedAt": "2026-07-11", "presentations": [ { "id": "acute-confusion-encephalopathy", @@ -4908,6 +4908,161 @@ "version": "v10 | Local content only", "lastUpdated": "Pending review" } + }, + { + "id": "focused-diagnostic-trap-tables", + "title": "Focused Diagnostic Trap Tables", + "status": "urgent", + "subtitle": "Distinguishes intrusive/obsessional phenomena from psychotic or violent intent.", + "selectedCount": 4, + "totalCount": 7, + "safetySnapshot": { + "summary": "Distinguishes intrusive/obsessional phenomena from psychotic or violent intent.", + "tags": ["Review must-not-miss causes"] + }, + "criteria": [ + { + "id": "why-it-fits", + "title": "Why it fits", + "tone": "fit" + }, + { + "id": "what-argues-against", + "title": "What argues against", + "tone": "overlap" + }, + { + "id": "must-not-miss", + "title": "Must-not-miss", + "tone": "warning" + }, + { + "id": "bedside-question", + "title": "Bedside question", + "tone": "question" + }, + { + "id": "immediate-action", + "title": "Immediate action", + "tone": "action" + }, + { + "id": "investigations", + "title": "Investigations", + "tone": "test" + }, + { + "id": "mimics-overlap", + "title": "Mimics / overlap", + "tone": "overlap" + } + ], + "candidates": [ + { + "slug": "ocd", + "selected": true, + "comparison": { + "why-it-fits": "Repetitive intrusive harm/contamination/sexual/religious thoughts with rituals, reassurance, avoidance. Ego-dystonic, resisted", + "what-argues-against": "Review locally.", + "must-not-miss": "Functional collapse, hidden shame, severe depression, suicidality.", + "bedside-question": "Distinguishes intrusive/obsessional phenomena from psychotic or violent intent.", + "immediate-action": "Stabilise and reassess.", + "investigations": "Guided by presentation.", + "mimics-overlap": "Review overlap locally." + } + }, + { + "slug": "perinatal-ocd-intrusive-infant-harm-thoughts", + "selected": true, + "comparison": { + "why-it-fits": "Postpartum/antenatal distressing infant-harm images/urges with checking/avoidance. Ego-dystonic, horrifying, preserved insight", + "what-argues-against": "Review locally.", + "must-not-miss": "Failure to feed/care, severe insomnia, concealed risk.", + "bedside-question": "Distinguishes intrusive/obsessional phenomena from psychotic or violent intent.", + "immediate-action": "Stabilise and reassess.", + "investigations": "Guided by presentation.", + "mimics-overlap": "Review overlap locally." + } + }, + { + "slug": "ptsd-intrusive-memories-flashbacks", + "selected": true, + "comparison": { + "why-it-fits": "Repetitive images, body memories, reliving, dissociative episodes linked to trauma", + "what-argues-against": "Review locally.", + "must-not-miss": "Severe dissociation, self-harm, substance use, child-safety impairment.", + "bedside-question": "Distinguishes intrusive/obsessional phenomena from psychotic or violent intent.", + "immediate-action": "Stabilise and reassess.", + "investigations": "Guided by presentation.", + "mimics-overlap": "Review overlap locally." + } + }, + { + "slug": "psychotic-delusion", + "selected": true, + "comparison": { + "why-it-fits": "Fixed bizarre/persecutory belief with impaired reality testing, conviction-based", + "what-argues-against": "Review locally.", + "must-not-miss": "Command phenomena, severe self-neglect, violence, food/fluid refusal.", + "bedside-question": "Distinguishes intrusive/obsessional phenomena from psychotic or violent intent.", + "immediate-action": "Stabilise and reassess.", + "investigations": "Guided by presentation.", + "mimics-overlap": "Review overlap locally." + } + }, + { + "slug": "overvalued-idea", + "selected": false, + "comparison": { + "why-it-fits": "Strongly held but understandable idea around weight/appearance/health/morality. Between obsession and delusion", + "what-argues-against": "Review locally.", + "must-not-miss": "Starvation, self-injury, escalating treatment refusal.", + "bedside-question": "Distinguishes intrusive/obsessional phenomena from psychotic or violent intent.", + "immediate-action": "Stabilise and reassess.", + "investigations": "Guided by presentation.", + "mimics-overlap": "Review overlap locally." + } + }, + { + "slug": "gad-worry-depressive-rumination", + "selected": false, + "comparison": { + "why-it-fits": "Repeated catastrophic thinking, mental rehearsal, hopeless rumination without compulsions", + "what-argues-against": "Review locally.", + "must-not-miss": "Severe insomnia, suicidality, marked psychomotor slowing, functional collapse.", + "bedside-question": "Distinguishes intrusive/obsessional phenomena from psychotic or violent intent.", + "immediate-action": "Stabilise and reassess.", + "investigations": "Guided by presentation.", + "mimics-overlap": "Review overlap locally." + } + }, + { + "slug": "genuine-violent-homicidal-intent", + "selected": false, + "comparison": { + "why-it-fits": "Desire/plan/willingness to harm, ego-syntonic, grievance-based, with preparation", + "what-argues-against": "Review locally.", + "must-not-miss": "Immediate child-safety or public-safety emergency, weapon access, stalking.", + "bedside-question": "Distinguishes intrusive/obsessional phenomena from psychotic or violent intent.", + "immediate-action": "Stabilise and reassess.", + "investigations": "Guided by presentation.", + "mimics-overlap": "Review overlap locally." + } + } + ], + "reviewChecklist": [ + "Stabilise and rule out immediate threats", + "Assess for reversible causes", + "Investigate and treat precipitants", + "Reassess and refine differential", + "Document and handoff" + ], + "highestUrgencyNote": "Safety first. Act early.", + "sourceStatus": { + "label": "Source pending review", + "version": "v10 | Local content only", + "lastUpdated": "Pending review" + } } ], "diagnoses": [ @@ -14503,7 +14658,7 @@ "note": "Desire/plan/willingness to harm, ego-syntonic, grievance-based, with preparation" } ], - "currentPresentation": [], + "currentPresentation": ["Focused Diagnostic Trap Tables"], "investigations": [], "immediateActions": [] }, @@ -14940,7 +15095,7 @@ "note": "Severe postpartum depression with guilt, nihilism, deserved-punishment ideas. Mood-congruent psychosis" } ], - "currentPresentation": ["Focused comparator (perinatal)"], + "currentPresentation": ["Focused Diagnostic Trap Tables", "Focused comparator (perinatal)"], "investigations": [], "immediateActions": [] }, @@ -18803,6 +18958,7 @@ } ], "currentPresentation": [ + "Focused Diagnostic Trap Tables", "Anxiety / Panic / Obsessions / Health Anxiety", "Panic disorder, Generalised anxiety disorder, OCD", "CLINICAL HINGE: Abrupt peak over minutes, recurrent unexpected attacks, anticipatory anxiety or avoidance." @@ -19444,7 +19600,7 @@ "note": "Desire/plan/willingness to harm, ego-syntonic, grievance-based, with preparation" } ], - "currentPresentation": [], + "currentPresentation": ["Focused Diagnostic Trap Tables"], "investigations": [], "immediateActions": [] }, @@ -20100,7 +20256,8 @@ "Perinatal Acute Psychiatry", "Postpartum psychosis, Postnatal depression, Perinatal OCD / intrusive infant-harm thoughts", "CLINICAL HINGE: Psychotic or manic syndrome with abrupt change from baseline in the postpartum period.", - "Focused comparator (perinatal)" + "Focused comparator (perinatal)", + "Focused Diagnostic Trap Tables" ], "investigations": [], "immediateActions": [] @@ -21296,7 +21453,7 @@ "note": "Desire/plan/willingness to harm, ego-syntonic, grievance-based, with preparation" } ], - "currentPresentation": [], + "currentPresentation": ["Focused Diagnostic Trap Tables"], "investigations": [], "immediateActions": [] }, @@ -22773,7 +22930,7 @@ "note": "Desire/plan/willingness to harm, ego-syntonic, grievance-based, with preparation" } ], - "currentPresentation": [], + "currentPresentation": ["Focused Diagnostic Trap Tables"], "investigations": [], "immediateActions": [] }, diff --git a/docs/process-hardening.md b/docs/process-hardening.md index 0c093f566..fba8ec20f 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -304,7 +304,7 @@ hybrid:10}`, all 10 forced-embedding vector cases passed (`force_embedding_failu - **New fast-check property suite** (`tests/property-*.test.ts`) pins the text-processing core's clinical invariants over generated inputs: unit-bearing numeric tokens survive sanitization; chunking terminates (including `CHUNK_OVERLAP >= CHUNK_SIZE`, M17) and neither loses nor invents content; table normalization stays rectangular, preserves numeric values, and the low-confidence caveat survives both clipboard export paths (H4). The suite immediately caught a live line-level H2 residue in `stripLowYieldLines` (control markers glued to threshold sentences deleted the whole line), fixed in the same PR. - **Eval debt (blocking merge of the sanitizer fix, not development):** the environment had no live Supabase/OpenAI keys, so `npm run eval:quality -- --rag-only` (answer-path gate for the sanitizer change) could not run. Run it before merging `claude/audit-sweep-property-tests`. No retrieval-selection/ranking files were touched, so the golden retrieval eval is not required for this branch. -## Differentials export artifact "urgency-urgent" removed — live prune pending (2026-07-11) +## Differentials export "Urgency: urgent" artifact retitled — live prune pending (2026-07-11) -- The differentials export's trap-tables appendix (`T_Focused_Diagnostic_Trap_Tables.txt`) has no title line, so the parser surfaced its first metadata row ("Urgency: urgent") as a presentation titled "Urgency: urgent" (slug `urgency-urgent`) with detail routes and search visibility. Fixed at three layers: `buildDifferentialSnapshot` excludes metadata-title entries from presentations (their 7 options remain diagnoses — 4 exist in no other entry) and keeps the artifact title out of diagnosis `currentPresentation`; `differentialPresentations()` filters artifact titles at runtime (same pattern as the preset/alias artifact filters); the snapshot was regenerated (31 → 30 presentations). -- **Operator follow-up (live Supabase, confirmation-required):** seeding upserts and never deletes, and auto-seed only fires for owners with zero rows, so already-seeded owners keep the stale `differential_records` row (`kind='presentation'`, `slug='urgency-urgent'`) and — where corpus embedding ran — its registry corpus document/chunks in `documents`/`document_chunks`. `npm run differentials:seed -- --owner-id --write` now prunes stale seeded presentations and their corpus documents per owner; run it (or the equivalent targeted deletes) for each seeded owner. +- The differentials export's trap-tables appendix (`T_Focused_Diagnostic_Trap_Tables.txt`) has no title line, so the parser surfaced its first metadata row ("Urgency: urgent") as a presentation titled "Urgency: urgent" (slug `urgency-urgent`) with detail routes and search visibility. It is a legitimate comparison workflow ("Distinguishes intrusive/obsessional phenomena from psychotic or violent intent"), and 4 of its 7 option diagnoses (`gad-worry-depressive-rumination`, `overvalued-idea`, `psychotic-delusion`, `ptsd-intrusive-memories-flashbacks`) exist in no other entry — so excluding it would orphan them and break the #483 invariant (enforced by `differential-detail.test.ts`) that every diagnosis belongs to a presentation. Fixed at the root: `parseEntryTitle` now falls back to the `=== HEADER ===` text when the first line is a metadata row, retitling the entry "Focused Diagnostic Trap Tables" (slug `focused-diagnostic-trap-tables`). Snapshot stays 31 presentations; no orphans. +- **Operator follow-up (live Supabase, confirmation-required):** the retitle changed the slug, and seeding upserts by slug and never deletes, so already-seeded owners keep the stale `differential_records` row (`kind='presentation'`, `slug='urgency-urgent'`) and — where corpus embedding ran — its registry corpus document/chunks in `documents`/`document_chunks`. `npm run differentials:seed -- --owner-id --write` now prunes presentation rows whose slug the current snapshot no longer produces (via `staleSeededPresentations`) and their corpus documents per owner; run it for each seeded owner. diff --git a/scripts/lib/parse-differentials-export.ts b/scripts/lib/parse-differentials-export.ts index 7bd5cac25..313a8621b 100644 --- a/scripts/lib/parse-differentials-export.ts +++ b/scripts/lib/parse-differentials-export.ts @@ -145,14 +145,25 @@ function parseEntryHeader(content: string) { return header ? slugify(header) : "unknown"; } +// ALL-CAPS "=== HEADER ===" text to a human title, e.g. +// "FOCUSED DIAGNOSTIC TRAP TABLES" -> "Focused Diagnostic Trap Tables". +function titleFromHeader(header: string) { + return header.toLowerCase().replace(/\b\w/g, (character) => character.toUpperCase()); +} + function parseEntryTitle(content: string) { + const header = content.match(/^===\s*(.+?)\s*===\s*\n/m)?.[1]?.trim() ?? ""; const afterHeader = content.replace(/^===\s*.+?\s*===\s*\n/m, ""); - return ( - afterHeader - .split("\n") - .find((line) => line.trim()) - ?.trim() ?? "Untitled presentation" - ); + const firstLine = afterHeader + .split("\n") + .find((line) => line.trim()) + ?.trim(); + // Most entries put a title on the line after the header. The focused + // diagnostic trap-tables appendix has none, so the first line is actually a + // metadata row ("Urgency: urgent") — fall back to the header text so the + // entry gets an honest title instead of surfacing the metadata as its title. + if (firstLine && !isDifferentialMetadataArtifactTitle(firstLine)) return firstLine; + return header ? titleFromHeader(header) : (firstLine ?? "Untitled presentation"); } function parseOptionsSection(content: string): ParsedOption[] { @@ -348,10 +359,7 @@ function buildDiagnosisRecord(option: ParsedOption, presentation: ParsedPresenta likelihood: "possible" as const, note: candidate.summary, })), - currentPresentation: [ - ...(isDifferentialMetadataArtifactTitle(presentation.title) ? [] : [presentation.title]), - ...presentation.mustNotMiss.slice(0, 2), - ], + currentPresentation: [presentation.title, ...presentation.mustNotMiss.slice(0, 2)], investigations: presentation.investigations, immediateActions: presentation.immediateActions, }; @@ -499,12 +507,7 @@ export function buildDifferentialSnapshot(input: { governanceMarkdown: string; }): DifferentialSnapshot { const parsedPresentations = input.entryFiles.map(({ name, content }) => parseEntryFile(content, pathBasename(name))); - // Titleless entry files (e.g. the focused diagnostic trap tables appendix) - // surface a metadata row as their title; they are not presentations, but - // their options are still real diagnoses — several exist in no other entry. - const presentations = parsedPresentations - .filter((parsed) => !isDifferentialMetadataArtifactTitle(parsed.title)) - .map(buildPresentationWorkflow); + const presentations = parsedPresentations.map(buildPresentationWorkflow); const diagnosisMap = new Map(); for (const parsed of parsedPresentations) { diff --git a/src/app/api/differentials/route.ts b/src/app/api/differentials/route.ts index d2769b644..28361fec2 100644 --- a/src/app/api/differentials/route.ts +++ b/src/app/api/differentials/route.ts @@ -15,7 +15,6 @@ import { } from "@/lib/differential-records"; import { fetchOwnerDifferentialRowsWithSeed, loadDifferentialSnapshot } from "@/lib/differential-seed"; import { - differentialPresentations, differentialRecords, rankDifferentialRecords, rankPresentationWorkflows, @@ -60,14 +59,11 @@ function publicDifferentialPayload(kind: DifferentialRecordKind, q: string | und const snapshot = loadDifferentialSnapshot(); const governance = deriveGovernanceFromSnapshot(snapshot); if (kind === "presentation") { - // differentialPresentations() (not snapshot.presentations) so export - // artifacts stay filtered out of the public payload. - const presentations = differentialPresentations(); - const ranked = q ? rankPresentationWorkflows(presentations, q, limit) : null; + const ranked = q ? rankPresentationWorkflows(snapshot.presentations, q, limit) : null; return { - presentations: ranked ? ranked.map((match) => match.workflow) : presentations, + presentations: ranked ? ranked.map((match) => match.workflow) : snapshot.presentations, matches: ranked ? presentationMatchesPayload(ranked) : undefined, - total: presentations.length, + total: snapshot.presentations.length, governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status }, }; } diff --git a/src/lib/differential-fixtures.ts b/src/lib/differential-fixtures.ts index 2d55c7b33..01429a9ab 100644 --- a/src/lib/differential-fixtures.ts +++ b/src/lib/differential-fixtures.ts @@ -1,11 +1,7 @@ import differentialSnapshot from "../../data/differentials-snapshot.json"; import { diagnosisToRow, presentationToRow, type DifferentialRecordInsert } from "@/lib/differential-records"; -import { - isDifferentialMetadataArtifactTitle, - type DifferentialPresentationWorkflow, - type DifferentialSnapshot, -} from "@/lib/differential-snapshot"; +import type { DifferentialSnapshot } from "@/lib/differential-snapshot"; let cachedSnapshot: DifferentialSnapshot | null = null; @@ -25,17 +21,9 @@ export function loadDifferentialSnapshot(): DifferentialSnapshot { return cachedSnapshot; } -/** The presentations that ship to the app and the seed: excludes export - * artifacts where a titleless entry file surfaced a metadata row (e.g. - * "Urgency: urgent") as its title. Single source of truth for the runtime - * catalogue, the seed rows, and stale-row pruning. */ -export function usableDifferentialPresentations(snapshot: DifferentialSnapshot): DifferentialPresentationWorkflow[] { - return snapshot.presentations.filter((presentation) => !isDifferentialMetadataArtifactTitle(presentation.title)); -} - export function buildDefaultDifferentialRows(ownerId: string): DifferentialRecordInsert[] { const snapshot = loadDifferentialSnapshot(); - const presentationRows = usableDifferentialPresentations(snapshot).map((presentation) => + const presentationRows = snapshot.presentations.map((presentation) => presentationToRow(presentation, ownerId, snapshot), ); const diagnosisRows = snapshot.diagnoses.map((diagnosis) => diagnosisToRow(diagnosis, ownerId, snapshot)); diff --git a/src/lib/differential-seed.ts b/src/lib/differential-seed.ts index 3ef081b01..8b1c1b9f2 100644 --- a/src/lib/differential-seed.ts +++ b/src/lib/differential-seed.ts @@ -1,8 +1,4 @@ -import { - buildDefaultDifferentialRows, - loadDifferentialSnapshot, - usableDifferentialPresentations, -} from "@/lib/differential-fixtures"; +import { buildDefaultDifferentialRows, loadDifferentialSnapshot } from "@/lib/differential-fixtures"; import { type DifferentialRecordInsert, type DifferentialRecordRow } from "@/lib/differential-records"; import { safeErrorLogDetails } from "@/lib/privacy"; @@ -67,14 +63,13 @@ export function buildDifferentialSeedRows(ownerId: string): DifferentialRecordIn return buildDefaultDifferentialRows(ownerId); } -/** Seeded presentation rows whose slug the current snapshot no longer produces - * (e.g. removed export artifacts such as "urgency-urgent"). Seeding upserts - * and never deletes, so these linger for already-seeded owners until pruned. +/** Seeded presentation rows whose slug the current snapshot no longer produces. + * Retitling the trap-tables appendix changed its slug ("urgency-urgent" -> + * "focused-diagnostic-trap-tables"); seeding upserts by slug and never + * deletes, so the old slug lingers for already-seeded owners until pruned. * Diagnoses are deliberately not pruned: their slugs merge and churn across * snapshot versions, so set-difference pruning would be unsafe there. */ export function staleSeededPresentations(rows: Row[]): Row[] { - const validSlugs = new Set( - usableDifferentialPresentations(loadDifferentialSnapshot()).map((presentation) => presentation.id), - ); + const validSlugs = new Set(loadDifferentialSnapshot().presentations.map((presentation) => presentation.id)); return rows.filter((row) => row.kind === "presentation" && !validSlugs.has(row.slug)); } diff --git a/src/lib/differential-snapshot.ts b/src/lib/differential-snapshot.ts index 7e88dc520..aaa5908a7 100644 --- a/src/lib/differential-snapshot.ts +++ b/src/lib/differential-snapshot.ts @@ -91,9 +91,9 @@ export type DifferentialSnapshotGovernance = { /** Entry files in the differentials export open with metadata rows ("Urgency: * urgent", "Axis: mixed", "Population: general"). When a file has no title - * line, the parser surfaces the first metadata row as the entry title; such - * rows are export artifacts, not clinical presentations, and must not become - * workflows, routes, or search results. */ + * line, the first line after the header is one of these rows rather than a + * real title; the parser detects that with this predicate and falls back to + * the header text so the entry gets an honest title (never a metadata row). */ export function isDifferentialMetadataArtifactTitle(title: string) { return /^(urgency|axis|population)\s*:/i.test(title.trim()); } diff --git a/src/lib/differentials.ts b/src/lib/differentials.ts index 690a3452b..7723e77b7 100644 --- a/src/lib/differentials.ts +++ b/src/lib/differentials.ts @@ -1,6 +1,6 @@ import { normalizeSearchText, rankCatalogRecords } from "@/lib/catalog-search"; import { cleanDifferentialItem, type DifferentialDetailContext } from "@/lib/differential-detail"; -import { loadDifferentialSnapshot, usableDifferentialPresentations } from "@/lib/differential-fixtures"; +import { loadDifferentialSnapshot } from "@/lib/differential-fixtures"; import { deriveGovernanceFromSnapshot } from "@/lib/differential-records"; import type { DifferentialComparisonCandidate, @@ -45,10 +45,7 @@ export { loadDifferentialSnapshot } from "@/lib/differential-fixtures"; export const differentialRecords: DifferentialRecord[] = catalog().diagnoses; export function differentialPresentations(): DifferentialPresentationWorkflow[] { - // The generated snapshot can carry export artifacts where a titleless entry - // file surfaced a metadata row (e.g. "Urgency: urgent") as its title; they - // are not presentations and must not get cards, routes, or search results. - return usableDifferentialPresentations(catalog()); + return catalog().presentations; } export function differentialScenarioPresets(): DifferentialScenarioPreset[] { diff --git a/tests/differentials.test.ts b/tests/differentials.test.ts index 7f3756ce5..f43f61d89 100644 --- a/tests/differentials.test.ts +++ b/tests/differentials.test.ts @@ -136,9 +136,10 @@ OPTIONS: expect(aliases.delirium).toEqual(["confusion", "fluctuation"]); }); - it("excludes titleless metadata-row entries from presentations but keeps their diagnoses", () => { - // Mirrors the trap-tables appendix: no title line, so the first metadata - // row ("Urgency: urgent") would otherwise become the presentation title. + it("titles a titleless entry from its header instead of surfacing a metadata row", () => { + // The trap-tables appendix has no title line, so the first line after the + // header is a metadata row ("Urgency: urgent"). The parser must fall back + // to the header text rather than use the metadata row as the title. const trapEntry = `=== FOCUSED DIAGNOSTIC TRAP TABLES === Urgency: urgent @@ -161,31 +162,40 @@ SOURCE: v10`; aliasesMarkdown: "", governanceMarkdown: "", }); - expect(snapshot.presentations.map((presentation) => presentation.id)).toEqual(["acute-confusion-encephalopathy"]); - const ocd = snapshot.diagnoses.find((diagnosis) => diagnosis.slug === "ocd"); - expect(ocd).toBeDefined(); - // The artifact title must not leak into the kept diagnosis text either. - expect(ocd?.currentPresentation).not.toContain("Urgency: urgent"); + const trap = snapshot.presentations.find((presentation) => presentation.id === "focused-diagnostic-trap-tables"); + expect(trap?.title).toBe("Focused Diagnostic Trap Tables"); + // No presentation may carry a metadata-row title. + expect( + snapshot.presentations.filter((presentation) => isDifferentialMetadataArtifactTitle(presentation.title)), + ).toEqual([]); + // The appendix keeps its options as diagnoses, parented by the retitled entry. + expect(snapshot.diagnoses.find((diagnosis) => diagnosis.slug === "ocd")).toBeDefined(); }); }); describe("differential records", () => { it("loads v10 snapshot with presentations and diagnoses", () => { const snapshot = loadDifferentialSnapshot(); - expect(snapshot.presentations).toHaveLength(30); + expect(snapshot.presentations).toHaveLength(31); expect(snapshot.diagnoses.length).toBeGreaterThan(100); expect(differentialRecords.length).toBe(snapshot.diagnoses.length); - // Export artifacts (metadata rows parsed as titles, e.g. "Urgency: urgent") - // must not ship as presentations. + // No presentation may ship with a metadata-row title (e.g. "Urgency: urgent"), + // and the mis-titled trap-tables appendix now carries its header title. expect( snapshot.presentations.filter((presentation) => isDifferentialMetadataArtifactTitle(presentation.title)), ).toEqual([]); + expect(snapshot.presentations.some((presentation) => presentation.id === "urgency-urgent")).toBe(false); + expect( + snapshot.presentations.find((presentation) => presentation.id === "focused-diagnostic-trap-tables")?.title, + ).toBe("Focused Diagnostic Trap Tables"); }); - it("flags stale seeded presentation rows for pruning, leaving diagnoses alone", () => { + it("flags a retired presentation slug for pruning, leaving diagnoses alone", () => { + // Retitling changed the appendix slug, so the old "urgency-urgent" row is no + // longer produced by the snapshot and must be pruned from seeded owners. const rows = [ { kind: "presentation", slug: "urgency-urgent" }, - { kind: "presentation", slug: "acute-confusion-encephalopathy" }, + { kind: "presentation", slug: "focused-diagnostic-trap-tables" }, { kind: "diagnosis", slug: "urgency-urgent" }, ]; const stale = staleSeededPresentations(rows);