From 62bc31404ced7c7c2808a3b44551ad3d72b078f7 Mon Sep 17 00:00:00 2001 From: di-omics <255010011+di-omics@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:14:52 -0700 Subject: [PATCH] Add provenance-bound vision release gate --- .github/workflows/ci.yml | 26 +++ README.md | 66 ++++++- package.json | 4 +- prisma/schema.prisma | 130 ++++++++----- prisma/seed.ts | 208 +++++++++++++++++--- scripts/ensure-sqlite.mjs | 12 ++ src/app/imaging-qc/actions.ts | 54 +++--- src/app/imaging-qc/page.tsx | 2 +- src/components/qc/qc-dashboard.tsx | 255 +++++++++++++++++++++++-- src/components/ui/badge.tsx | 7 + src/lib/provenance.test.ts | 172 +++++++++++++++++ src/lib/provenance.ts | 296 +++++++++++++++++++++++++++++ src/lib/queries.ts | 152 +++++++++++++-- src/lib/vision-gate.test.ts | 95 +++++++++ src/lib/vision-gate.ts | 285 +++++++++++++++++++++++++++ 15 files changed, 1612 insertions(+), 152 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 scripts/ensure-sqlite.mjs create mode 100644 src/lib/provenance.test.ts create mode 100644 src/lib/provenance.ts create mode 100644 src/lib/vision-gate.test.ts create mode 100644 src/lib/vision-gate.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c3651e8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm test + - run: npm run lint + - run: npm run typecheck + - run: npm run seed + - run: npm run build diff --git a/README.md b/README.md index 8eea70a..c2f08ea 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,11 @@ SpatialFlow is a clickable demo of an operations platform for a lab that profile human tumor tissue at scale. It runs entirely on procedurally-generated synthetic data: no real backend lab, no real specimens, no real images. +The central idea is that autonomy comes from encoding a domain expert's release and +recovery policy around existing instruments - not from hiding the wet lab behind a +generic "AI-native" label. Every automatic action is bounded; identity, provenance, +and irreversible sample decisions fail closed. + ## What this demonstrates A lab that profiles tumor tissue has to coordinate a long pipeline - accession and @@ -17,8 +22,8 @@ everything for ML. Today that coordination usually lives in spreadsheets. SpatialFlow shows what it looks like when the software layer around that pipeline is automated: a **Command Center** system-of-record with full specimen lineage, a **TMA randomization planner** that spreads cores and scores the batch balance, and -an **Imaging QC** dashboard that auto-scores every core and lets a reviewer approve a -batch in one click. +an **Imaging QC** dashboard that combines synthetic vision metrics with sample +identity, tamper-evident provenance, and explicit recovery decisions. ## Run it @@ -29,6 +34,8 @@ npm run dev # http://localhost:3000 ``` Zero external infrastructure - the data store is a local SQLite file. +The repository is continuously checked on Node 22; `npm test` runs the policy and +provenance unit tests. ## The three modules @@ -42,11 +49,47 @@ Zero external infrastructure - the data store is a local SQLite file. inserts controls, then reports a **batch-balance score** (same-patient spread + indication evenness). Re-randomize for a new layout, Optimize to hill-climb the score live, and export `layout.json` + `picklist.csv`. -3. **Imaging QC** - pick an assay run to see a grid of procedurally-generated core - thumbnails (toggle mIF marker channels), each with an auto-computed PASS/FLAG/FAIL - verdict from synthetic focus / marker-completeness / saturation / tissue-loss / - segmentation metrics. Filter to what needs review, open a core for its metrics and - a manual override, and **Approve batch** to clear the review queue. +3. **Imaging QC & release control** - pick an assay run to see a grid of + procedurally-generated core thumbnails (toggle mIF marker channels). Each + acquisition binds its expected and synthetically observed labels, image manifests, + append-only provenance events, and synthetic focus / marker-completeness / + saturation / tissue-loss / segmentation / fold metrics. The versioned policy + routes each core to `RELEASE`, bounded `RETRY`, or expert `HOLD`; the server blocks + run release while any unresolved recovery remains. + +## Domain-expert vision gate + +[`src/lib/vision-gate.ts`](src/lib/vision-gate.ts) is the inspectable policy boundary +between computer-vision evidence and lab execution. For every acquisition it: + +1. compares expected core/slide identity with observed label evidence; +2. verifies every synthetic image-manifest digest and the acquisition's SHA-256 event + chain; +3. evaluates assay-agnostic image metrics against versioned thresholds; and +4. returns an explicit recovery action, retry budget, and authorization requirement. + +| Finding | Decision | Recovery authority | +| --- | --- | --- | +| All gates pass | `RELEASE` | None | +| Focus/exposure or segmentation failure, first attempt | `RETRY` | One bounded rescan/reprocess | +| Marker dropout | `HOLD` | Expert-authorized restain | +| Tissue loss or fold | `HOLD` | Expert assesses material before recut | +| Identity mismatch or broken provenance | `HOLD` | Quarantine; never relabel automatically | +| Retry budget exhausted | `HOLD` | Expert chooses disposition | + +A domain expert can accept a **metric** exception only with a written rationale. +Identity and provenance failures are deliberately non-overridable. These rules are a +software safety contract for the demo, not a clinically validated QC policy. + +## Provenance model + +Each seeded synthetic acquisition has four append-only events: +`SPECIMEN_ID_BOUND -> TMA_POSITION_BOUND -> IMAGE_ACQUIRED -> VISION_QC_EVALUATED`. +Every event hashes its canonical payload, acquisition identity, prior event hash, +actor, and timestamp. Image rows separately store a digest of their synthetic +manifest (URI, channel, acquisition ID, and capture time). Both are recomputed before +the release policy runs; changing evidence without rebuilding the chain produces a +hold. ## Renaming @@ -68,8 +111,15 @@ be ported to a Python/FastAPI service to sit alongside the scientific stack. [`prisma/seed.ts`](prisma/seed.ts). A "Demo data" badge is shown throughout. - Placeholder tissue/marker images are procedurally-generated gradient tiles - no real histology. +- There is no trained computer-vision model, barcode reader, microscope, robotic + workcell, or LIMS connection in this repository. "Observed" identities, confidence + values, image metrics, image URIs, and recovery scenarios are deterministic demo + fixtures used to exercise orchestration logic. +- Thresholds and recovery rules are engineering examples. They have not been + validated for research, diagnostic, or clinical use. ## Data model `Specimen -> Core -> CorePlacement (TMA) -> Slide -> ImageFile / QCResult`, with -`AssayRun` grouping slides. See [`prisma/schema.prisma`](prisma/schema.prisma). +`AssayRun` grouping slides and `ProvenanceEvent` binding acquisition evidence into a +hash chain. See [`prisma/schema.prisma`](prisma/schema.prisma). diff --git a/package.json b/package.json index f58ffa1..62f1d93 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "build": "prisma generate && next build", "start": "next start", "lint": "next lint", - "seed": "prisma db push --skip-generate && tsx prisma/seed.ts", + "test": "node --import tsx --test src/lib/*.test.ts", + "typecheck": "tsc --noEmit", + "seed": "node scripts/ensure-sqlite.mjs && prisma db push --skip-generate && node --import tsx prisma/seed.ts", "db:studio": "prisma studio", "postinstall": "prisma generate" }, diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e984216..d232c66 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -13,38 +13,40 @@ datasource db { } model Specimen { - id String @id - patientCode String - indication String - sourceDate DateTime - ischemiaMinutes Int - necrosisPct Float - qcStatus String // PASS | FAIL | REVIEW - fitForPurpose Boolean - stage String // ACCESSIONED | CORED | ON_TMA | STAINED | IMAGED | QCD | INGESTED - createdAt DateTime @default(now()) - cores Core[] + id String @id + patientCode String + indication String + sourceDate DateTime + ischemiaMinutes Int + necrosisPct Float + qcStatus String // PASS | FAIL | REVIEW + fitForPurpose Boolean + stage String // ACCESSIONED | CORED | ON_TMA | STAINED | IMAGED | QCD | INGESTED + createdAt DateTime @default(now()) + cores Core[] + provenanceEvents ProvenanceEvent[] } model Core { - id String @id - specimenId String - specimen Specimen @relation(fields: [specimenId], references: [id]) - label String - diameterMm Float - status String // CUT | PLACED | STAINED | IMAGED - placements CorePlacement[] - images ImageFile[] - qcResults QCResult[] + id String @id + specimenId String + specimen Specimen @relation(fields: [specimenId], references: [id]) + label String + diameterMm Float + status String // CUT | PLACED | STAINED | IMAGED + placements CorePlacement[] + images ImageFile[] + qcResults QCResult[] + provenanceEvents ProvenanceEvent[] } model TMA { - id String @id + id String @id name String rows Int cols Int batchId String - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) placements CorePlacement[] slides Slide[] } @@ -62,36 +64,41 @@ model CorePlacement { } model Slide { - id String @id - tmaId String - tma TMA @relation(fields: [tmaId], references: [id]) - assayType String // mIF | H_AND_E | SPATIAL_TX - scanStatus String // PENDING | SCANNING | SCANNED | FAILED - assayRunId String? - assayRun AssayRun? @relation(fields: [assayRunId], references: [id]) - images ImageFile[] - qcResults QCResult[] + id String @id + tmaId String + tma TMA @relation(fields: [tmaId], references: [id]) + assayType String // mIF | H_AND_E | SPATIAL_TX + scanStatus String // PENDING | SCANNING | SCANNED | FAILED + assayRunId String? + assayRun AssayRun? @relation(fields: [assayRunId], references: [id]) + images ImageFile[] + qcResults QCResult[] + provenanceEvents ProvenanceEvent[] } model AssayRun { - id String @id - name String - batchId String - startDate DateTime - endDate DateTime? - status String // PLANNED | RUNNING | DONE - slides Slide[] + id String @id + name String + batchId String + startDate DateTime + endDate DateTime? + status String // PLANNED | RUNNING | DONE + slides Slide[] + provenanceEvents ProvenanceEvent[] } model ImageFile { - id String @id @default(cuid()) - slideId String - slide Slide @relation(fields: [slideId], references: [id]) - coreId String? - core Core? @relation(fields: [coreId], references: [id]) - channel String - uri String - qcMetricsJson String? // JSON blob of per-channel image metrics + id String @id @default(cuid()) + slideId String + slide Slide @relation(fields: [slideId], references: [id]) + coreId String? + core Core? @relation(fields: [coreId], references: [id]) + channel String + uri String + qcMetricsJson String? // JSON blob of per-channel image metrics + acquisitionId String? + capturedAt DateTime? + manifestSha256 String? // SHA-256 of the synthetic image manifest, not image bytes } model QCResult { @@ -105,10 +112,41 @@ model QCResult { saturationPct Float tissueLossPct Float segSanity Float + foldScore Float @default(0) // synthetic probability of folded tissue verdict String // PASS | FLAG | FAIL reviewed Boolean @default(false) overrideVerdict String? // manual override: PASS | FAIL | null note String? failureReason String? + observedCoreId String? + observedSlideId String? + identityConfidence Float? + acquisitionId String? + attempt Int @default(1) + policyVersion String @default("spatial-qc-v1") createdAt DateTime @default(now()) } + +// Append-only, hash-chained evidence for one synthetic acquisition. Event hashes +// are recomputed before a QC result can pass the release gate. +model ProvenanceEvent { + id String @id + specimenId String + specimen Specimen @relation(fields: [specimenId], references: [id]) + coreId String + core Core @relation(fields: [coreId], references: [id]) + slideId String + slide Slide @relation(fields: [slideId], references: [id]) + assayRunId String + assayRun AssayRun @relation(fields: [assayRunId], references: [id]) + sequence Int + eventType String + actor String + occurredAt DateTime + payloadJson String + previousHash String? + eventHash String @unique + + @@unique([assayRunId, slideId, coreId, sequence]) + @@index([coreId, slideId]) +} diff --git a/prisma/seed.ts b/prisma/seed.ts index 258447a..2bd50d2 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -7,6 +7,12 @@ */ import { PrismaClient } from "@prisma/client"; import { buildLayout, optimizeLayout, type PatientCore } from "../src/lib/tma"; +import { + appendProvenanceEvent, + imageManifestSha256, + type ProvenanceEventRecord, +} from "../src/lib/provenance"; +import { VISION_POLICY } from "../src/lib/vision-gate"; const prisma = new PrismaClient(); @@ -88,47 +94,63 @@ type QCMetrics = { saturationPct: number; tissueLossPct: number; segSanity: number; + foldScore: number; verdict: string; failureReason: string | null; }; -// ~80/12/8 PASS/FLAG/FAIL, with metrics consistent with the verdict. +// ~80/12/8 PASS/FLAG/FAIL. A non-pass result degrades the metric that +// corresponds to its synthetic failure reason rather than making every metric +// fail at once. This keeps the recovery-policy demo interpretable. function makeQC(): QCMetrics { const r = rand(); + const result: QCMetrics = { + focusScore: round(randFloat(0.85, 0.99), 2), + markerCompleteness: round(randFloat(0.92, 1.0), 2), + saturationPct: round(randFloat(0.3, 3), 1), + tissueLossPct: round(randFloat(0, 5), 1), + segSanity: round(randFloat(0.85, 0.99), 2), + foldScore: round(randFloat(0.01, 0.18), 2), + verdict: "PASS", + failureReason: null, + }; if (r < 0.8) { - return { - focusScore: round(randFloat(0.85, 0.99), 2), - markerCompleteness: round(randFloat(0.92, 1.0), 2), - saturationPct: round(randFloat(0.3, 3), 1), - tissueLossPct: round(randFloat(0, 5), 1), - segSanity: round(randFloat(0.85, 0.99), 2), - verdict: "PASS", - failureReason: null, - }; - } else if (r < 0.92) { - return { - focusScore: round(randFloat(0.62, 0.85), 2), - markerCompleteness: round(randFloat(0.76, 0.92), 2), - saturationPct: round(randFloat(3, 8), 1), - tissueLossPct: round(randFloat(5, 15), 1), - segSanity: round(randFloat(0.66, 0.85), 2), - verdict: "FLAG", - failureReason: pick(FAILURE_REASONS), - }; + return result; } - return { - focusScore: round(randFloat(0.25, 0.62), 2), - markerCompleteness: round(randFloat(0.4, 0.76), 2), - saturationPct: round(randFloat(8, 24), 1), - tissueLossPct: round(randFloat(15, 45), 1), - segSanity: round(randFloat(0.3, 0.66), 2), - verdict: "FAIL", - failureReason: pick(FAILURE_REASONS), - }; + + const severe = r >= 0.92; + const reason = pick(FAILURE_REASONS); + result.verdict = severe ? "FAIL" : "FLAG"; + result.failureReason = reason; + switch (reason) { + case "Out of focus": + result.focusScore = round(randFloat(severe ? 0.3 : 0.62, severe ? 0.58 : 0.78), 2); + break; + case "Marker dropout": + result.markerCompleteness = round( + randFloat(severe ? 0.42 : 0.72, severe ? 0.68 : 0.88), + 2 + ); + break; + case "Saturation": + result.saturationPct = round(randFloat(severe ? 12 : 6, severe ? 24 : 10), 1); + break; + case "Tissue loss": + result.tissueLossPct = round(randFloat(severe ? 24 : 11, severe ? 48 : 20), 1); + break; + case "Segmentation error": + result.segSanity = round(randFloat(severe ? 0.35 : 0.62, severe ? 0.58 : 0.78), 2); + break; + case "Folded tissue": + result.foldScore = round(randFloat(severe ? 0.62 : 0.3, severe ? 0.92 : 0.52), 2); + break; + } + return result; } async function main() { console.log("Clearing existing data..."); + await prisma.provenanceEvent.deleteMany(); await prisma.qCResult.deleteMany(); await prisma.imageFile.deleteMany(); await prisma.corePlacement.deleteMany(); @@ -346,6 +368,9 @@ async function main() { channel: string; uri: string; qcMetricsJson: string; + acquisitionId: string; + capturedAt: Date; + manifestSha256: string; }[] = []; const qcResults: { coreId: string; @@ -355,36 +380,74 @@ async function main() { saturationPct: number; tissueLossPct: number; segSanity: number; + foldScore: number; verdict: string; reviewed: boolean; overrideVerdict: string | null; note: string | null; failureReason: string | null; + observedCoreId: string; + observedSlideId: string; + identityConfidence: number; + acquisitionId: string; + attempt: number; + policyVersion: string; createdAt: Date; }[] = []; + const provenanceEvents: ProvenanceEventRecord[] = []; for (const run of RUNS) { if (run.status === "PLANNED") continue; const slide = slides.find((s) => s.tmaId === run.tma && s.assayType === run.assay)!; const tmaPlacements = placementsByTma.get(run.tma) ?? []; - for (const p of tmaPlacements) { + for (const [placementIndex, p] of tmaPlacements.entries()) { const coreId = p.coreId!; const qc = makeQC(); const channels = run.assay === "mIF" ? MIF_CHANNELS : [run.assay]; + const acquisitionId = `ACQ-${run.id}-${coreId}`; + const capturedAt = new Date( + daysAgo(run.end ?? run.start).getTime() + placementIndex * 60_000 + ); + // Two explicit synthetic identity exceptions make fail-closed behavior + // visible in the running-run demo. They are not real classifier output. + const observedCoreId = + run.status === "RUNNING" && placementIndex === 0 + ? tmaPlacements[1]?.coreId ?? coreId + : coreId; + const identityConfidence = + run.status === "RUNNING" && placementIndex === 1 + ? 0.94 + : round(randFloat(0.985, 0.999), 3); + const manifestHashes: string[] = []; for (const ch of channels) { + const uri = `synthetic://${run.tma.toLowerCase()}/${run.assay.toLowerCase()}/${coreId}/${ch}.ome.tiff`; + const manifestSha256 = imageManifestSha256({ + acquisitionId, + channel: ch, + uri, + capturedAt, + }); + manifestHashes.push(manifestSha256); images.push({ slideId: slide.id, coreId, channel: ch, - uri: `synthetic://${run.tma.toLowerCase()}/${run.assay.toLowerCase()}/${coreId}/${ch}.ome.tiff`, + uri, qcMetricsJson: JSON.stringify({ meanIntensity: round(randFloat(120, 900), 0), snr: round(randFloat(4, 40), 1), }), + acquisitionId, + capturedAt, + manifestSha256, }); } // DONE runs are fully reviewed; RUNNING runs leave flags/fails to review. const reviewed = run.status === "DONE" ? true : qc.verdict === "PASS"; + const attempt = + run.status === "RUNNING" && qc.verdict !== "PASS" && placementIndex % 3 === 0 + ? 2 + : 1; qcResults.push({ coreId, slideId: slide.id, @@ -393,13 +456,92 @@ async function main() { saturationPct: qc.saturationPct, tissueLossPct: qc.tissueLossPct, segSanity: qc.segSanity, + foldScore: qc.foldScore, verdict: qc.verdict, reviewed, overrideVerdict: null, note: null, failureReason: qc.failureReason, + observedCoreId, + observedSlideId: slide.id, + identityConfidence, + acquisitionId, + attempt, + policyVersion: VISION_POLICY.version, createdAt: daysAgo(run.end ?? run.start), }); + + const specimenId = coreId.slice(0, "SPC-0000".length); + const eventBase = { + specimenId, + coreId, + slideId: slide.id, + assayRunId: run.id, + occurredAt: capturedAt, + }; + const definitions = [ + { + eventType: "SPECIMEN_ID_BOUND", + actor: "accession.station.synthetic", + payload: { specimenId, coreId, evidenceKind: "SYNTHETIC_DEMO" }, + }, + { + eventType: "TMA_POSITION_BOUND", + actor: "tma.planner", + payload: { + tmaId: run.tma, + row: p.row, + col: p.col, + evidenceKind: "SYNTHETIC_DEMO", + }, + }, + { + eventType: "IMAGE_ACQUIRED", + actor: `scanner.${run.assay.toLowerCase()}.synthetic`, + payload: { + acquisitionId, + observedCoreId, + observedSlideId: slide.id, + identityConfidence, + manifestHashes: manifestHashes.sort(), + evidenceKind: "SYNTHETIC_DEMO", + }, + }, + { + eventType: "VISION_QC_EVALUATED", + actor: "vision.synthetic-metrics-v1", + payload: { + metrics: { + focusScore: qc.focusScore, + markerCompleteness: qc.markerCompleteness, + saturationPct: qc.saturationPct, + tissueLossPct: qc.tissueLossPct, + segSanity: qc.segSanity, + foldScore: qc.foldScore, + }, + verdict: qc.verdict, + policyVersion: VISION_POLICY.version, + evidenceKind: "SYNTHETIC_DEMO", + }, + }, + ]; + let previousHash: string | null = null; + for (const [eventIndex, definition] of definitions.entries()) { + const event = appendProvenanceEvent( + { + ...eventBase, + id: `PE-${run.id}-${coreId}-${eventIndex + 1}`, + sequence: eventIndex + 1, + eventType: definition.eventType, + actor: definition.actor, + occurredAt: new Date(capturedAt.getTime() + eventIndex * 1000), + payload: definition.payload, + }, + previousHash + ); + provenanceEvents.push(event); + previousHash = event.eventHash; + } } // Mark cores on scanned/scanning slides as imaged. await prisma.core.updateMany({ @@ -415,6 +557,9 @@ async function main() { for (let i = 0; i < qcResults.length; i += 500) { await prisma.qCResult.createMany({ data: qcResults.slice(i, i + 500) }); } + for (let i = 0; i < provenanceEvents.length; i += 500) { + await prisma.provenanceEvent.createMany({ data: provenanceEvents.slice(i, i + 500) }); + } // ---- summary ------------------------------------------------------------- const counts = { @@ -426,6 +571,7 @@ async function main() { runs: await prisma.assayRun.count(), images: await prisma.imageFile.count(), qc: await prisma.qCResult.count(), + provenanceEvents: await prisma.provenanceEvent.count(), }; console.log("Seed complete:", counts); } diff --git a/scripts/ensure-sqlite.mjs b/scripts/ensure-sqlite.mjs new file mode 100644 index 0000000..7ed87ab --- /dev/null +++ b/scripts/ensure-sqlite.mjs @@ -0,0 +1,12 @@ +import { closeSync, existsSync, openSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +// Prisma 5's macOS schema engine can fail before creating a missing SQLite +// file. Creating an empty file is safe: `prisma db push` remains the only step +// that applies the schema. +const databasePath = fileURLToPath(new URL("../prisma/dev.db", import.meta.url)); + +if (!existsSync(databasePath)) { + closeSync(openSync(databasePath, "wx")); + console.log("Created empty local SQLite database at prisma/dev.db"); +} diff --git a/src/app/imaging-qc/actions.ts b/src/app/imaging-qc/actions.ts index 63ca60f..332ddb3 100644 --- a/src/app/imaging-qc/actions.ts +++ b/src/app/imaging-qc/actions.ts @@ -2,45 +2,49 @@ import { revalidatePath } from "next/cache"; import { prisma } from "@/lib/prisma"; +import { getRunDetail } from "@/lib/queries"; -// Approve a run's review queue: mark everything reviewed, and pass any flags. -// Fails are left as fails (they need a re-cut, not a pass). This updates the -// pass rate shown on the Command Center. +// Release is fail-closed: identity/provenance failures and unresolved recovery +// steps cannot be converted into passes by a batch action. export async function approveRun(runId: string) { - const run = await prisma.assayRun.findUnique({ - where: { id: runId }, - include: { slides: { select: { id: true } } }, - }); - if (!run) return; - const slideIds = run.slides.map((s) => s.id); - - await prisma.qCResult.updateMany({ - where: { slideId: { in: slideIds }, verdict: "FLAG", overrideVerdict: null }, - data: { overrideVerdict: "PASS" }, - }); - await prisma.qCResult.updateMany({ - where: { slideId: { in: slideIds }, verdict: { not: "FAIL" } }, - data: { reviewed: true }, - }); - await prisma.qCResult.updateMany({ - where: { slideId: { in: slideIds } }, - data: { reviewed: true }, - }); - if (run.status !== "DONE") { - await prisma.assayRun.update({ where: { id: runId }, data: { status: "DONE" } }); + const detail = await getRunDetail(runId); + if (!detail) return { ok: false, message: "Assay run not found." }; + const blockers = detail.cores.filter((core) => core.gate.state !== "RELEASE"); + if (blockers.length > 0) { + return { + ok: false, + message: `Release blocked: ${blockers.length} acquisition${blockers.length === 1 ? "" : "s"} require recovery or expert disposition.`, + }; } + await prisma.$transaction([ + prisma.qCResult.updateMany({ + where: { id: { in: detail.cores.map((core) => core.qcId) } }, + data: { reviewed: true }, + }), + prisma.assayRun.update({ where: { id: runId }, data: { status: "DONE" } }), + ]); + revalidatePath("/imaging-qc"); revalidatePath("/command-center"); revalidatePath("/"); + return { ok: true, message: "Run released under the active vision policy." }; } // Manual per-core override from the detail drawer. export async function overrideCore(qcId: string, verdict: "PASS" | "FAIL", note: string) { + const rationale = note.trim(); + if (verdict === "PASS" && rationale.length < 8) { + return { + ok: false, + message: "A pass override requires a concise scientific rationale.", + }; + } await prisma.qCResult.update({ where: { id: qcId }, - data: { overrideVerdict: verdict, reviewed: true, note: note || null }, + data: { overrideVerdict: verdict, reviewed: true, note: rationale || null }, }); revalidatePath("/imaging-qc"); revalidatePath("/command-center"); + return { ok: true, message: "Expert disposition recorded." }; } diff --git a/src/app/imaging-qc/page.tsx b/src/app/imaging-qc/page.tsx index d7f2181..109f698 100644 --- a/src/app/imaging-qc/page.tsx +++ b/src/app/imaging-qc/page.tsx @@ -20,7 +20,7 @@ export default async function ImagingQcPage({ } /> diff --git a/src/components/qc/qc-dashboard.tsx b/src/components/qc/qc-dashboard.tsx index 35d09f3..4b0df9f 100644 --- a/src/components/qc/qc-dashboard.tsx +++ b/src/components/qc/qc-dashboard.tsx @@ -3,7 +3,17 @@ import { useMemo, useState, useTransition } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { CheckCircle2, AlertTriangle, XCircle, Check, Loader2 } from "lucide-react"; +import { + CheckCircle2, + AlertTriangle, + XCircle, + Check, + Loader2, + Fingerprint, + Link2, + RotateCcw, + ShieldCheck, +} from "lucide-react"; import { CoreThumbnail } from "@/components/qc/thumbnail"; import { FailureReasonsChart } from "@/components/charts/charts"; import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card"; @@ -13,6 +23,7 @@ import { Drawer } from "@/components/ui/drawer"; import { ASSAY_LABELS, MIF_CHANNELS, INDICATION_COLORS, type Indication } from "@/lib/constants"; import { cn, formatDate } from "@/lib/utils"; import { approveRun, overrideCore } from "@/app/imaging-qc/actions"; +import { VISION_POLICY } from "@/lib/vision-gate"; type CoreQC = { qcId: string; @@ -26,12 +37,45 @@ type CoreQC = { saturationPct: number; tissueLossPct: number; segSanity: number; + foldScore: number; verdict: string; overrideVerdict: string | null; effectiveVerdict: string; reviewed: boolean; failureReason: string | null; note: string | null; + identity: { + status: "MATCH" | "MISMATCH" | "LOW_CONFIDENCE" | "MISSING"; + expectedCoreId: string; + observedCoreId: string | null; + expectedSlideId: string; + observedSlideId: string | null; + confidence: number | null; + }; + provenance: { + valid: boolean; + chainValid: boolean; + manifestsValid: boolean; + eventCount: number; + head: string | null; + errors: string[]; + acquisitionId: string | null; + capturedAt: string | null; + }; + gate: { + state: "RELEASE" | "RETRY" | "HOLD"; + reasons: string[]; + reasonLabels: string[]; + policyVersion: string; + recovery: { + action: string; + automatic: boolean; + requiresExpert: boolean; + attempt: number; + maxAttempts: number; + instruction: string; + }; + }; }; type Detail = { @@ -46,7 +90,14 @@ type Detail = { endDate: string | null; }; cores: CoreQC[]; - summary: { PASS: number; FLAG: number; FAIL: number; needsReview: number }; + summary: { + PASS: number; + FLAG: number; + FAIL: number; + needsReview: number; + gates: { RELEASE: number; RETRY: number; HOLD: number }; + releaseBlocked: number; + }; failureReasons: { reason: string; count: number }[]; }; @@ -78,6 +129,7 @@ export function QcDashboard({ const [channels, setChannels] = useState(MIF_CHANNELS.map((c) => c.key)); const [onlyReview, setOnlyReview] = useState(false); const [activeQc, setActiveQc] = useState(null); + const [releaseMessage, setReleaseMessage] = useState(null); const [pending, startTransition] = useTransition(); const isMif = detail?.run.assayType === "mIF"; @@ -85,7 +137,9 @@ export function QcDashboard({ const filtered = useMemo(() => { if (!detail) return []; return onlyReview - ? detail.cores.filter((c) => !c.reviewed && c.effectiveVerdict !== "PASS") + ? detail.cores.filter( + (c) => c.gate.state !== "RELEASE" || (!c.reviewed && c.effectiveVerdict !== "PASS") + ) : detail.cores; }, [detail, onlyReview]); @@ -100,8 +154,9 @@ export function QcDashboard({ function handleApprove() { if (!detail) return; startTransition(async () => { - await approveRun(detail.run.id); - router.refresh(); + const result = await approveRun(detail.run.id); + setReleaseMessage(result.message); + if (result.ok) router.refresh(); }); } @@ -164,9 +219,17 @@ export function QcDashboard({ {formatDate(detail.run.startDate)} - @@ -176,9 +239,14 @@ export function QcDashboard({ } label="Fail" value={detail.summary.FAIL} tone="fail" /> + {releaseMessage && ( +

{releaseMessage}

+ )} + + {/* Toolbar */}
@@ -254,6 +322,16 @@ export function QcDashboard({ {!c.reviewed && c.effectiveVerdict !== "PASS" && ( )} + {c.gate.state !== "RELEASE" && ( + + {c.gate.state} + + )}
{c.specimenId} @@ -305,6 +383,64 @@ export function QcDashboard({ ); } +function GateSummary({ detail }: { detail: Detail }) { + const tiles = [ + { + state: "RELEASE" as const, + label: "Release-ready", + value: detail.summary.gates.RELEASE, + icon: , + className: "text-pass", + }, + { + state: "RETRY" as const, + label: "Bounded retry", + value: detail.summary.gates.RETRY, + icon: , + className: "text-review", + }, + { + state: "HOLD" as const, + label: "Expert hold", + value: detail.summary.gates.HOLD, + icon: , + className: "text-fail", + }, + ]; + return ( + + +
+
+ Autonomous release gate + + Versioned policy combines sample identity, hash-chained provenance, and vision metrics. + +
+ spatial-qc-v1 +
+
+ +
+ {tiles.map((tile) => ( +
+
+ {tile.icon} + {tile.label} +
+
{tile.value}
+
+ ))} +
+

+ Identity and provenance failures always fail closed. Only metric exceptions can be + accepted by a domain expert, and a written rationale is required. +

+
+
+ ); +} + function SummaryTile({ icon, label, @@ -343,21 +479,24 @@ function CoreDetail({ onSaved: () => void; }) { const [note, setNote] = useState(core.note ?? ""); + const [saveMessage, setSaveMessage] = useState(null); const [pending, startTransition] = useTransition(); function save(verdict: "PASS" | "FAIL") { startTransition(async () => { - await overrideCore(core.qcId, verdict, note); - onSaved(); + const result = await overrideCore(core.qcId, verdict, note); + setSaveMessage(result.message); + if (result.ok) onSaved(); }); } const metrics = [ - { label: "Focus score", value: core.focusScore, kind: "unit" as const, good: core.focusScore >= 0.8 }, - { label: "Marker completeness", value: core.markerCompleteness, kind: "unit" as const, good: core.markerCompleteness >= 0.9 }, - { label: "Segmentation sanity", value: core.segSanity, kind: "unit" as const, good: core.segSanity >= 0.8 }, - { label: "Saturation", value: core.saturationPct, kind: "pct" as const, good: core.saturationPct < 5 }, - { label: "Tissue loss", value: core.tissueLossPct, kind: "pct" as const, good: core.tissueLossPct < 10 }, + { label: "Focus score", value: core.focusScore, kind: "unit" as const, good: core.focusScore >= VISION_POLICY.thresholds.focusScoreMin }, + { label: "Marker completeness", value: core.markerCompleteness, kind: "unit" as const, good: core.markerCompleteness >= VISION_POLICY.thresholds.markerCompletenessMin }, + { label: "Segmentation sanity", value: core.segSanity, kind: "unit" as const, good: core.segSanity >= VISION_POLICY.thresholds.segSanityMin }, + { label: "Saturation", value: core.saturationPct, kind: "pct" as const, good: core.saturationPct <= VISION_POLICY.thresholds.saturationPctMax }, + { label: "Tissue loss", value: core.tissueLossPct, kind: "pct" as const, good: core.tissueLossPct <= VISION_POLICY.thresholds.tissueLossPctMax }, + { label: "Fold probability", value: core.foldScore, kind: "unit" as const, good: core.foldScore <= VISION_POLICY.thresholds.foldScoreMax }, ]; return ( @@ -379,6 +518,75 @@ function CoreDetail({ )}
+
+
+
+ Release policy +
+ +
+ {core.gate.reasonLabels.length > 0 && ( +
    + {core.gate.reasonLabels.map((reason) => ( +
  • - {reason}
  • + ))} +
+ )} +
+
+ Recovery: {core.gate.recovery.action.replaceAll("_", " ")} + + attempt {core.gate.recovery.attempt}/{core.gate.recovery.maxAttempts} + +
+

{core.gate.recovery.instruction}

+
+ + {core.gate.recovery.requiresExpert + ? "expert authorization required" + : core.gate.recovery.automatic + ? "bounded automatic action" + : "no automatic action"} + +
+
+
+ +
+
+
+ + Identity + + +
+
+
Expected: {core.identity.expectedCoreId}
+
Observed: {core.identity.observedCoreId ?? "none"}
+
Confidence: {core.identity.confidence === null ? "none" : `${(core.identity.confidence * 100).toFixed(1)}%`}
+
+
+
+
+ + Provenance + + + {core.provenance.valid ? "Verified" : "Invalid"} + +
+
+
{core.provenance.eventCount} hash-chained events
+
+ head {core.provenance.head?.slice(0, 12) ?? "none"} +
+
+ {core.provenance.acquisitionId ?? "no acquisition id"} +
+
+
+
+
{metrics.map((m) => ( @@ -386,24 +594,33 @@ function CoreDetail({
-
Manual override
+
Domain-expert disposition