diff --git a/data/differentials-snapshot.json b/data/differentials-snapshot.json index a680edf12..fe0ba8ec7 100644 --- a/data/differentials-snapshot.json +++ b/data/differentials-snapshot.json @@ -1,6 +1,6 @@ { "version": "v10", - "exportedAt": "2026-07-05", + "exportedAt": "2026-07-11", "presentations": [ { "id": "acute-confusion-encephalopathy", @@ -4910,8 +4910,8 @@ } }, { - "id": "urgency-urgent", - "title": "Urgency: urgent", + "id": "focused-diagnostic-trap-tables", + "title": "Focused Diagnostic Trap Tables", "status": "urgent", "subtitle": "Distinguishes intrusive/obsessional phenomena from psychotic or violent intent.", "selectedCount": 4, @@ -14658,7 +14658,7 @@ "note": "Desire/plan/willingness to harm, ego-syntonic, grievance-based, with preparation" } ], - "currentPresentation": ["Urgency: urgent"], + "currentPresentation": ["Focused Diagnostic Trap Tables"], "investigations": [], "immediateActions": [] }, @@ -15095,7 +15095,7 @@ "note": "Severe postpartum depression with guilt, nihilism, deserved-punishment ideas. Mood-congruent psychosis" } ], - "currentPresentation": ["Urgency: urgent", "Focused comparator (perinatal)"], + "currentPresentation": ["Focused Diagnostic Trap Tables", "Focused comparator (perinatal)"], "investigations": [], "immediateActions": [] }, @@ -18958,7 +18958,7 @@ } ], "currentPresentation": [ - "Urgency: urgent", + "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." @@ -19600,7 +19600,7 @@ "note": "Desire/plan/willingness to harm, ego-syntonic, grievance-based, with preparation" } ], - "currentPresentation": ["Urgency: urgent"], + "currentPresentation": ["Focused Diagnostic Trap Tables"], "investigations": [], "immediateActions": [] }, @@ -20257,7 +20257,7 @@ "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 Diagnostic Trap Tables" ], "investigations": [], "immediateActions": [] @@ -21453,7 +21453,7 @@ "note": "Desire/plan/willingness to harm, ego-syntonic, grievance-based, with preparation" } ], - "currentPresentation": ["Urgency: urgent"], + "currentPresentation": ["Focused Diagnostic Trap Tables"], "investigations": [], "immediateActions": [] }, @@ -22930,7 +22930,7 @@ "note": "Desire/plan/willingness to harm, ego-syntonic, grievance-based, with preparation" } ], - "currentPresentation": ["Urgency: urgent"], + "currentPresentation": ["Focused Diagnostic Trap Tables"], "investigations": [], "immediateActions": [] }, diff --git a/docs/process-hardening.md b/docs/process-hardening.md index f9d9cd98a..fba8ec20f 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 "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. 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 4a5715cac..313a8621b 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, @@ -144,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[] { 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/lib/differential-seed.ts b/src/lib/differential-seed.ts index bb80f69f3..8b1c1b9f2 100644 --- a/src/lib/differential-seed.ts +++ b/src/lib/differential-seed.ts @@ -1,4 +1,4 @@ -import { buildDefaultDifferentialRows } 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"; @@ -62,3 +62,14 @@ export async function fetchOwnerDifferentialRowsWithSeed( export function buildDifferentialSeedRows(ownerId: string): DifferentialRecordInsert[] { return buildDefaultDifferentialRows(ownerId); } + +/** 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(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 173be36c4..aaa5908a7 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 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()); +} + export type DifferentialSnapshot = { version: string; exportedAt: string; diff --git a/src/lib/registry-corpus.ts b/src/lib/registry-corpus.ts index 0e2fbcb20..b335cce6a 100644 --- a/src/lib/registry-corpus.ts +++ b/src/lib/registry-corpus.ts @@ -100,6 +100,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 chunk id. */ function registryChunkId(entry: RegistryCorpusEntry) { return deterministicUuid(`registry-chunk:${entry.kind}:${entry.recordId}`); diff --git a/tests/differentials.test.ts b/tests/differentials.test.ts index be87015fe..f43f61d89 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, @@ -128,6 +135,42 @@ OPTIONS: expect(aliases.tags).toBeUndefined(); expect(aliases.delirium).toEqual(["confusion", "fluctuation"]); }); + + 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 +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: "", + }); + 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", () => { @@ -136,6 +179,27 @@ describe("differential records", () => { expect(snapshot.presentations).toHaveLength(31); expect(snapshot.diagnoses.length).toBeGreaterThan(100); expect(differentialRecords.length).toBe(snapshot.diagnoses.length); + // 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 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: "focused-diagnostic-trap-tables" }, + { kind: "diagnosis", slug: "urgency-urgent" }, + ]; + const stale = staleSeededPresentations(rows); + expect(stale).toEqual([{ kind: "presentation", slug: "urgency-urgent" }]); }); it("links cards to routes", () => {