Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
66 changes: 58 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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

Expand All @@ -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

Expand All @@ -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).
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
130 changes: 84 additions & 46 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
}
Expand All @@ -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 {
Expand All @@ -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])
}
Loading
Loading