diff --git a/README.md b/README.md
index a5e47d1..0122462 100644
--- a/README.md
+++ b/README.md
@@ -7,30 +7,323 @@ and clinician-in-the-loop. It never controls a ventilator and never recommends t
This repository is the **live demo** — the only place the trained model reaches a screen.
```
-front-end/ Vite :5173 React dashboard. Reads /api, never derives a band from a score.
back-end/ Node :3500 Express + Mongoose -> MongoDB Atlas. The history of record.
FastAPI :8000 pythonService/. The only process that touches the model.
+front-end/ Vite :5174 React. The ENGINEERING view: simulation bar, ward stream, telemetry.
contract/ The shared data contract, as TypeScript types.
checks/ Executable end-to-end verification.
```
+**There are two front-ends now, and only one of them is the clinical demo.** Since 2026-08-29
+the screen a clinician or a judge looks at is the finalized SvelteKit UI in the sibling
+repository `frontend-and-backend-FINAL/`, on **:5173**. This repository's React app moved to
+**:5174** and is kept as the engineering view. Both read the same `/api` on :3500, so the
+backend is the single source of truth for both and neither can drift from the model.
+
+Setting up the clinical UI is [its own section](#4-the-clinical-ui--sveltekit-5173) below. If you
+only want to see the pipeline work, the React app alone is still enough.
+
The model itself lives in a separate repository and is imported as a package; this one owns
the serving path and the screen.
+## First-time setup
+
+⚠️ **This repository does not stand alone.** A clone of it by itself cannot run the demo —
+the model, its fitted artifacts and the Python environment all live outside it. It expects
+to sit in a workspace:
+
+```
+/
+ .venv/ Python 3.12, shared by both repositories
+ bki/ the model repository -- REQUIRED, see step 1
+ models/ fitted artifacts, ~152 MB + 15 GB of 7B weights
+ pulsemind_demo/ this repository
+```
+
+### 1. The model repository, as a sibling directory
+
+`back-end/pythonService/requirements.txt` begins with `-e ../../../bki[llm]` — an editable
+install from a **local checkout**, not from PyPI. `bki/` must sit beside this directory, or
+the install fails on its first line and nothing else gets installed.
+
+### 2. The Python environment
+
+```powershell
+uv venv --python 3.12 ..\.venv
+cd back-end\pythonService
+uv pip install --python ..\..\..\.venv\Scripts\python.exe -r requirements.txt
+```
+
+⚠️ **The workspace venv is a `uv venv` and contains no pip** — `python -m pip` answers
+`No module named pip`, and there is no `Scripts\pip.exe`. Use `uv pip`; a plain `pip`
+command will either fail or, worse, silently hit a different interpreter's pip.
+
+⚠️ **Run the install from `pythonService/`.** The relative `../../../bki` path is resolved
+against the **working directory**, not against the requirements file. Verified — from
+anywhere else it fails outright:
+
+```
+error: Couldn't parse requirement ... at position 655
+ Caused by: path could not be normalized: D:\Temp\../../../bki
+```
+
+⚠️ **Torch is deliberately unpinned there.** The Blackwell `sm_120` GPU needs the cu128
+build, which comes from PyTorch's own index rather than PyPI:
+
+```powershell
+uv pip install --python ..\..\..\.venv\Scripts\python.exe torch --index-url https://download.pytorch.org/whl/cu128
+```
+
+Dropping the `[llm]` extra is a legitimate choice — the service still scores and bands, and
+explanations fall back to the deterministic template.
+
+### 3. The fitted artifacts
+
+`models/serving_assets.json` and its companions — booster, calibrator, band table,
+operating point, `evidence_map.json` — are loaded at startup (`model_runtime.py:26`,
+redirectable with `PM_MODELS_ROOT`). **They are in neither repository and cannot be:** they
+are derived from MIMIC-IV under a PhysioNet DUA. Without them the service will not start.
+The 7B weights under `models/llm` are a further 15 GB and are needed only for generated
+explanations, not for scoring.
+
+### 4. JavaScript dependencies — three installs, two package managers
+
+```powershell
+# each line from THIS directory, so they can be pasted one at a time
+npm install --prefix back-end # package-lock.json
+pnpm install --dir front-end # pnpm-lock.yaml
+pnpm install --dir ..\frontend-and-backend-FINAL\front-end # its own pnpm-lock.yaml
+```
+
+Do not cross them. `back-end/` is the only npm project here; running `pnpm` in it, or `npm` in
+either front-end, writes a competing lockfile.
+
+The third is the clinical UI and lives in a different repository. Skip it if you only want the
+engineering view.
+
+### 5. `back-end/.env`
+
+Copy `back-end/.env.example` and fill it in — it documents every key, including the
+percent-encoding rule that makes an Atlas password with reserved characters parse. Two of
+its keys are optional and change behaviour rather than connectivity:
+
+- **`PM_ALLOW_DESTRUCTIVE`** — leaving it *out* of `.env` and setting it per-shell instead
+ is the safer habit, since it unlocks a seed that deletes all three collections.
+- **`LOG_SUBJECT_KEY`** — if you do put it in `.env`, the run commands below need no
+ environment prefix at all.
+
## Running it
-Three processes, in this order. Full commands in [`back-end/README.md`](back-end/README.md).
+Three processes, in this order, each in its own terminal. Paths are relative to this
+directory (`pulsemind_demo/`) unless stated; the workspace root is its parent.
+
+### 1. Model service — FastAPI :8000
```powershell
..\.venv\Scripts\python.exe -m uvicorn app:app --app-dir back-end/pythonService
-node server.js # from back-end/, needs .env
+```
+
+The venv lives at the **workspace root** — the parent of this directory — and must not be
+moved, because its absolute paths are baked in. Ready when
+`http://127.0.0.1:8000/healthz` returns `"status":"pass"`.
+
+**No `PYTHONPATH` is needed**, despite appearances. The service imports `pipeline.core`
+from the model repository, which is installed into that venv as an **editable package** —
+`pulsemind_bki 1.2.0`, resolved through a `.pth` finder rather than the path variable.
+Verified with `PYTHONPATH` explicitly unset: `pipeline.core.features` resolves to
+`..\bki\pipeline\core\features.py`.
+
+It matters only if you rebuild the venv, where forgetting it breaks every import at
+startup — the fix is to redo **First-time setup step 2**, not to set the variable. Use the
+requirements file rather than a bare `pip install -e ..\bki`, which would miss the `[llm]`
+extra and the service's own dependencies.
+
+⚠️ The workspace-root `.env` carries `PYTHONPATH="..\bki"` and looks load-bearing. It is
+not read by anything: Python does not parse `.env` files, and the variable is set neither
+in the environment nor in `HKCU\Environment`. Treat it as a fallback for the rebuild case
+above, not as a prerequisite.
+
+### 2. API — Node :3500
+
+```powershell
+$env:PM_ALLOW_DESTRUCTIVE="true"; $env:LOG_SUBJECT_KEY="dev"; node back-end/server.js
+```
+
+Runs from **any** directory: `server.js` resolves `.env` against its own folder rather than
+the working directory. `back-end/.env` must exist — copy `back-end/.env.example`. It holds
+the Atlas URI and is gitignored.
+
+- `PM_ALLOW_DESTRUCTIVE` unlocks `POST /api/ward/seed` and the **Restart** button; without
+ it they answer 403. Seeding **deletes every assessment, prompt and stay state**.
+- `LOG_SUBJECT_KEY` is the HMAC salt that pseudonymises patient ids in logs. Unset, they
+ record `unkeyed` — fail-visible, never the raw id.
+
+Both are also valid `.env` keys — `.env.example` lists them. Put them there and the prefix
+above is unnecessary; the shell form is shown because it keeps the destructive one scoped
+to a single session.
+
+Ready when it prints `Connected to MongoDB` then `Server running on port 3500`.
+
+### 3. Engineering view — Vite :5174
+
+```powershell
pnpm dev # from front-end/
```
-Then `POST /api/ward/seed` — the board is empty until you do.
+⚠️ **:5174, not :5173.** The clinical UI took 5173 on 2026-08-29 and this app moved. The port
+is set in `front-end/vite.config.ts`; `back-end/config/allowedOrigins.js` already lists both.
+
+⚠️ Open **`http://localhost:5174`**, not `127.0.0.1:5174`. Vite binds IPv6 `[::1]` only and
+the IPv4 address is refused.
+
+`pnpm` is the package manager — `pnpm-lock.yaml` is the lockfile. Do not run `npm install`
+here; it writes a competing `package-lock.json`.
+
+This is where the simulation bar, the ward stream driver and the original telemetry dock live.
+Everything here still works; it is simply no longer the screen the demo is given on.
+
+### 4. The clinical UI — SvelteKit :5173
+
+⚠️ **A DIFFERENT REPOSITORY.** It is not under this one and it has its own lockfile, its own
+package manager and its own tests:
+
+```
+/
+ pulsemind_demo/ this repository
+ frontend-and-backend-FINAL/ the finalized UI
+ front-end/ the SvelteKit app <- pnpm runs HERE
+ back-end/ a scaffold this demo does not use. Ignore it.
+```
+
+```powershell
+cd ..\frontend-and-backend-FINAL\front-end
+pnpm install
+pnpm dev
+```
+
+Node 22 and pnpm 10. `pnpm install` also runs `svelte-kit sync`, which generates the types
+`pnpm check` needs; a fresh clone that skips it fails type-checking for a reason unrelated to
+its code.
+
+**`front-end/.env` is what points the app at this backend, and it is NOT in the repository.**
+Copy the example and edit if you need to:
+
+```powershell
+cp .env.example .env # from frontend-and-backend-FINAL/front-end
+```
+
+The defaults in `.env.example` are already correct for this demo, so a straight copy is enough.
+The four keys:
+
+```ini
+PUBLIC_PULSEMIND_DATA_SOURCE=pulsemind # selects the live pipeline over the fixtures
+PUBLIC_PULSEMIND_API_BASE=/api # same origin, through the Vite proxy
+PUBLIC_PULSEMIND_DEMO_CONTROLS=true # the operator strip; off by default
+PUBLIC_PULSEMIND_REQUIRE_AUTH=false # this backend has no /auth surface
+```
+
+⚠️ **No `.env` is committed, here or in the model repository, whatever is in it.** These four
+are `PUBLIC_*` variables, so they are compiled into the client bundle and are already readable
+by anyone who loads the page: their VALUES are harmless. That is not a reason to track the
+file. A committed `.env` is where somebody later adds a real credential, and GitHub's push
+protection flags the filename without reading the contents. `.gitignore` covers `.env` and
+`*.env` and makes a single exception for `*.env.example`.
+
+⚠️ **`pulsemind` is matched as an exact string, never as a truthiness test.** Mis-spell it and
+the app silently falls back to its own 30-patient fixture set, which renders perfectly and is
+not this ward. The board says which source it is on; read it rather than assuming.
+
+⚠️ **`PUBLIC_PULSEMIND_API_BASE=/api` is not a convenience.** Same-origin is what makes
+`Server-Timing` readable to the pipeline panel at all, and what keeps a session cookie
+first-party if authentication is ever switched on.
+
+⚠️ **The Vite proxy does NOT rewrite the path.** It forwards `/api` verbatim, because this
+service mounts everything under `/api`. The upstream design shipped a
+`path.replace(/^\/api/, '')` for a root-mounted backend; leaving it in place 404s every
+request in the app.
+
+Everything else the app needs comes from this repository unchanged. It talks to the same Node
+API, so the model service, Atlas and the seed are shared.
+
+### Seed the ward
+
+The board is empty until the ward exists. Any of three ways, and they do the same thing:
+**Restart ward** in the SvelteKit demo strip, **Restart** in the React feed bar, or
+`POST http://127.0.0.1:3500/api/ward/seed`. Destructive by design — seeding twice gives the
+same ward, not two — so skip it if the previous session's ward is still in Atlas.
+
+⚠️ **Seed 24 ticks for the clinical UI.** Its risk-history chart windows to 24 hours, and a
+tick is one hour of ward time, so a shorter backfill leaves the chart nearly empty. The demo
+strip's Restart already asks for 24; a hand-rolled `POST` should send
+`{"backfill_ticks": 24}`.
+
+### Warm the explainer before demonstrating
+
+**Warm explainer** in the feed bar → `POST /api/ward/warmup`. Cold load measured at 27.9 s.
+
+⚠️ **Gated on free VRAM.** The 7B needs **6700 MiB** free (`MIN_FREE_VRAM_MIB` in
+`back-end/pythonService/explanation.py`) of this card's 8151. Below that the route returns
+**503** naming the measured figure instead of segfaulting the service. Check first:
+
+```powershell
+nvidia-smi --query-gpu=memory.free --format=csv,noheader
+```
+
+⚠️ **Keep the card free after the load, too.** The model holds ~6079 MiB resident and
+generation time scales hard with what remains: **13.7 s at 642 MiB free, 77.3 s at 60 MiB**.
+Screen recording on an NVENC encoder competes for exactly that memory — record with a CPU
+encoder (x264) while demonstrating.
+
+### Showing the pipeline
+
+Both front-ends have one, reading the same headers off the same requests.
+
+- **Clinical UI (:5173)** — `Show pipeline` in the demonstration strip along the bottom.
+- **Engineering view (:5174)** — `Pipeline` in the feed bar.
+
+Either opens a dock listing every API call that app makes, with the time each stage of the
+pipeline actually took — feature assembly, scoring, the band decision, generation, the Mongo
+write. Closed, it costs the board no height at all, which is why the control lives in an
+existing bar rather than in a second one.
+
+Every figure is measured by the tier that did the work and returned on a W3C
+`Server-Timing` header. **A stage that did not run shows as absent, never as `0 ms`** — the
+two are indistinguishable on screen, so a failed measurement would read as a successful
+one. The spans are indented as they nest: the model service's stages sit inside the Node
+hop, which sits inside the browser round trip, so their durations are not meant to be added
+up.
+
+A tick, measured on 2026-08-22: **~43 ms** to score each reading, **14.9 ms** to assemble its
+109-column feature row, **12 µs** for the band machine, and **1.16 s** writing the results to
+Atlas. The database, not the model, is most of a tick.
+
+⚠️ Scoring is a **range**, not a constant: seven samples gave 31.6 · 42.5 · 43.1 · 43.4 ·
+43.9 · 60.4 · 64.7 ms, and the first tick after an idle period is consistently ~50% slower
+than the rest. Read it off the panel rather than quoting one of these.
+
+⚠️ The "~66 ms to score" that appears in several comments here had never been computed by
+anything — `model_runtime` contained no clock at all. The `assess` span is the first real
+measurement. It came out close, which is luck rather than evidence; quote the span.
+
+### Confirming the services are up
+
+```powershell
+curl.exe http://127.0.0.1:8000/healthz # {"status":"pass", ...}
+curl.exe http://127.0.0.1:3500/api/ward # array of 8 beds
+Get-NetTCPConnection -LocalPort 8000,3500,5173,5174 -State Listen | Select-Object LocalPort, OwningProcess
+```
+
+### Stopping
+
+```powershell
+Get-NetTCPConnection -LocalPort 8000,3500,5173,5174 -State Listen |
+ ForEach-Object { Stop-Process -Id $_.OwningProcess -Force }
+```
+
+Nothing is lost: the services are stateless and MongoDB Atlas holds the ward.
**Local only.** The model service needs a GPU and a 7B language model, so there is no
-deployed backend. A static deploy of the frontend renders the shell and then shows its
+deployed backend. A static deploy of either front-end renders the shell and then shows its
error state on every fetch; that is intended.
## Two things worth knowing before reading the code
@@ -60,3 +353,12 @@ added to it.** `.gitignore` blocks CSVs outright for that reason.
No authentication, no RBAC, no audit log, and no HL7 feed — all named blockers before any
shadow or pilot deployment, all deliberately visible rather than stubbed.
+
+⚠️ **The clinical UI has sign-in SCREENS, and they guard nothing here.** It ships a complete
+password, TOTP and passkey surface, but this backend serves no `/auth`, so the gate is off
+(`PUBLIC_PULSEMIND_REQUIRE_AUTH=false`) and `/login` renders its own "demonstration build"
+notice. Screens are not the blocker; the missing surface is. Do not read them as authentication.
+
+**A clinician's review IS recorded now**, against the reading's prompt, with the disposition and
+the time. It records no clinician, because nothing signs the action, and both the panel and the
+review history say so in words. Attribution is still a blocker.
diff --git a/back-end/README.md b/back-end/README.md
index 1e13f71..10dbd99 100644
--- a/back-end/README.md
+++ b/back-end/README.md
@@ -11,8 +11,8 @@ front-end (Vite) -> this API :3500 -> MongoDB Atlas
**This layer stores; the model service scores.** Reads never reach the model service —
everything the board shows comes out of Mongo, which is what makes the history durable and
-a restart harmless. Only `/api/ward/seed`, `/api/ward/tick` and `/api/patient/:id/explain`
-cross the boundary.
+a restart harmless. Only `/api/ward/seed`, `/api/ward/tick`, `/api/ward/warmup` and
+`/api/patient/:id/explain` cross the boundary.
Mongo is not a cache. Each stored reading carries the band the hysteresis machine
*published* at the time, which is not a function of that reading's score and cannot be
@@ -20,18 +20,29 @@ recomputed later.
## Running it
+First-time setup — the sibling `bki/` checkout, the venv, the fitted artifacts and the two
+package managers — is in [`../README.md`](../README.md). A clone of this repository alone
+cannot run the demo.
+
```powershell
# 1 the model service, from pulsemind_demo/ (needs the GPU)
-$env:PYTHONPATH="..\bki"
..\.venv\Scripts\python.exe -m uvicorn app:app --app-dir back-end/pythonService
-# 2 this API, from back-end/ (needs .env -- copy .env.example)
-node server.js
+# 2 this API, from ANY directory (needs back-end/.env -- copy .env.example)
+node back-end/server.js
# 3 the dashboard, from front-end/
pnpm dev
```
+⚠️ **No `PYTHONPATH` is needed** — this block used to set it. `pipeline.core` resolves
+through the editable install of `pulsemind_bki`, verified with the variable unset.
+
+⚠️ **`server.js` resolves `.env` against its own directory**, not the working directory, so
+the cwd no longer matters. Before that fix, launching it from anywhere but `back-end/`
+loaded no `.env` at all and mongoose rejected an undefined `MONGODB_URI` — an error that
+reads like a malformed URI and is actually a missing file.
+
The board is empty until `POST /api/ward/seed`.
## Endpoints
@@ -39,13 +50,14 @@ The board is empty until `POST /api/ward/seed`.
| Method | Path | |
|---|---|---|
| GET | `/api/ward` | every bed's latest assessment |
-| POST | `/api/ward/seed` | build the ward, backfill 24 hours of scored history |
-| POST | `/api/ward/tick` | advance every bed by one reading |
+| POST | `/api/ward/seed` | build the ward and backfill `backfill_ticks` hourly readings (default 24) |
+| POST | `/api/ward/tick` | advance every bed by one reading, an hour on the ward's clock |
+| POST | `/api/ward/warmup` | load the 7B ahead of the first explanation (~40 s, stores nothing) |
| GET | `/api/patient/:id` | one patient's current assessment |
| GET | `/api/patient/:id/history` | recent assessments, oldest first |
| GET | `/api/patient/:id/context` | borrowed demographics and comorbidities |
| GET | `/api/patient/:id/parameter/:name` | one parameter's charting history |
-| POST | `/api/patient/:id/explain` | generate the explanation (slow; `use_llm: false` for the template) |
+| POST | `/api/patient/:id/explain` | generate the explanation (slow; `assessed_at` names the reading, `use_llm: false` picks the template) |
| POST | `/api/patient/:id/device` | switch an input source off or on |
| POST | `/api/prompt/:id/review` | record a clinician's disposition |
diff --git a/back-end/config/modelService.js b/back-end/config/modelService.js
index 09baafa..c1328d1 100644
--- a/back-end/config/modelService.js
+++ b/back-end/config/modelService.js
@@ -1,5 +1,5 @@
const axios = require('axios');
-const { requestId } = require('../middleware/requestContext');
+const { requestId, timing, timingFrom } = require('../middleware/requestContext');
/** The FastAPI model service. Two timeouts because the endpoints differ by three
* orders of magnitude: ~66 ms to score, 18-23 s to explain on a local 7B. */
@@ -22,8 +22,30 @@ for (const client of [scoring, explaining]) {
client.interceptors.request.use((config) => {
const id = requestId();
if (id) config.headers['X-Request-Id'] = id;
+ config.startedAt = process.hrtime.bigint();
return config;
});
+
+ // The hop itself, and the model service's own spans carried up unaltered.
+ //
+ // `upstream` MINUS the forwarded stage durations is the transport cost --
+ // serialisation, the loopback socket, and FastAPI's own routing. Reporting it
+ // as a separate span is what lets a reader see that a 26 s explanation was 26 s
+ // of generation and not 26 s of anything this tier did.
+ const record = (response) => {
+ const startedAt = response?.config?.startedAt;
+ if (startedAt) {
+ timing('upstream', startedAt, process.hrtime.bigint());
+ }
+ timingFrom(response?.headers?.['server-timing']);
+ };
+
+ client.interceptors.response.use(
+ (response) => { record(response); return response; },
+ // A refusal is still a measurement, and the slow failures are the ones worth
+ // seeing: a 240 s explanation timeout and an instant 503 both arrive here.
+ (error) => { record(error?.response ?? { config: error?.config }); throw error; },
+ );
}
module.exports = { BASE_URL, scoring, explaining };
diff --git a/back-end/config/queryTiming.js b/back-end/config/queryTiming.js
new file mode 100644
index 0000000..b0cbdda
--- /dev/null
+++ b/back-end/config/queryTiming.js
@@ -0,0 +1,54 @@
+const { timing } = require('../middleware/requestContext');
+
+/**
+ * Time every database round trip and report it as a `mongo` span.
+ *
+ * A schema plugin rather than 24 wrapped call sites in the controller, and
+ * applied EXPLICITLY in each model rather than through `mongoose.plugin()`.
+ * The global form only reaches schemas compiled after it runs, so it depends on
+ * `require` order in `server.js` -- and when that order changes the plugin does
+ * not fail, it silently stops timing. A missing measurement that reports as a
+ * clean run is worse than no measurement at all. Applied per model, a model
+ * added without it is a visible gap in one greppable place.
+ *
+ * ⚠️ THIS DEPENDS ON AsyncLocalStorage SURVIVING THE DRIVER'S ASYNC BOUNDARY.
+ * `timing()` resolves the current request out of the store; if the context were
+ * lost between issuing a query and its callback, it would find nothing and do
+ * nothing -- quietly. `checks/check_node.py` asserts a `mongo` span is actually
+ * present on a real response for exactly that reason: this is not a mechanism
+ * that can be trusted because it did not throw.
+ */
+
+// Named explicitly rather than by regex. Mongoose applies query, aggregate,
+// document and model middleware from different registries, and a regex that
+// looks like it covers all four covers whichever ones it happens to match.
+const QUERY_OPS = [
+ 'find', 'findOne', 'findOneAndUpdate', 'findOneAndDelete', 'findOneAndReplace',
+ 'updateOne', 'updateMany', 'replaceOne',
+ 'deleteOne', 'deleteMany', 'countDocuments', 'estimatedDocumentCount', 'distinct',
+];
+
+const started = function () { this._pmStartedAt = process.hrtime.bigint(); };
+
+const finished = function () {
+ if (!this._pmStartedAt) return;
+ // The interval, not its length: `GET /ward` issues eight of these at once,
+ // and eight overlapping durations added together came to more than the whole
+ // request. The middleware unions them instead.
+ timing('mongo', this._pmStartedAt, process.hrtime.bigint());
+ this._pmStartedAt = undefined;
+};
+
+module.exports = function queryTiming(schema) {
+ schema.pre(QUERY_OPS, started);
+ schema.post(QUERY_OPS, finished);
+
+ // Document and aggregate middleware live in their own registries; `save`
+ // and `insertMany` never match a query hook however it is written.
+ schema.pre('save', started);
+ schema.post('save', finished);
+ schema.pre('insertMany', started);
+ schema.post('insertMany', finished);
+ schema.pre('aggregate', started);
+ schema.post('aggregate', finished);
+};
diff --git a/back-end/controllers/assessmentController.js b/back-end/controllers/assessmentController.js
index 70023c4..e24e7dc 100644
--- a/back-end/controllers/assessmentController.js
+++ b/back-end/controllers/assessmentController.js
@@ -20,6 +20,12 @@ const req_id = (res) => res.getHeader('X-Request-Id');
const fromUpstream = (res, err, what) => {
if (err.response) {
const status = err.response.status;
+ // The model service says HOW saturated it was, and this used to be dropped
+ // here -- so a 503 told the browser to come back in 25 s without ever saying
+ // it was a queue that refused. It is capacity, not the caller's quota, and
+ // the depth is the number that says so.
+ const depth = err.response.data?.queue_depth;
+ if (depth !== undefined) res.mark?.('refused', `queue depth ${depth}`);
return res.status(status === 503 ? 503 : (status >= 400 && status < 500 ? status : 502))
.type('application/problem+json')
.set(err.response.headers?.['retry-after']
@@ -83,7 +89,22 @@ const persist = async (assessment) => {
// the next HIGH reading is not a promotion and never raises a replacement.
};
-/** Build the ward and backfill 24 hours of scored history.
+// ONE WARD OPERATION AT A TIME. `tickWard` and `seedWard` both read, then write,
+// the same `StayState`, and `seedWard` additionally deletes all three
+// collections. Run concurrently, a tick's write lands after the seed's delete and
+// leaves a StayState one generation behind its own assessments -- a lost update
+// with no error anywhere, because neither path carries a version predicate.
+// This runs as a single local process, so a module-level flag is the whole fix;
+// a second instance against the same database would need that predicate.
+let wardBusy = false;
+
+const wardBusyResponse = (res) => res.status(409).type('application/problem+json').json({
+ type: 'about:blank', title: 'Conflict', status: 409,
+ detail: 'another ward operation is already in progress',
+ instance: req_id(res),
+});
+
+/** Build the ward and backfill its scored history, hourly.
* Destructive by design: seeding twice gives the same ward, not two. */
const seedWard = async (req, res) => {
if (!ALLOW_DESTRUCTIVE) {
@@ -94,6 +115,16 @@ const seedWard = async (req, res) => {
instance: req_id(res),
});
}
+ if (wardBusy) return wardBusyResponse(res);
+ wardBusy = true;
+ try {
+ return await runSeed(req, res);
+ } finally {
+ wardBusy = false;
+ }
+};
+
+const runSeed = async (req, res) => {
const seed = Number(req.body?.seed ?? DEFAULT_SEED);
const ticks = Number(req.body?.backfill_ticks ?? BACKFILL_TICKS);
@@ -152,9 +183,23 @@ const seedWard = async (req, res) => {
/** Advance every bed by one reading, from the state held in Mongo. */
const tickWard = async (req, res) => {
+ if (wardBusy) return wardBusyResponse(res);
+ wardBusy = true;
+ try {
+ return await runTick(res);
+ } finally {
+ wardBusy = false;
+ }
+};
+
+const runTick = async (res) => {
const states = await StayState.find().lean();
if (!states.length) {
- return res.status(409).json({ message: 'ward not seeded -- POST /api/ward/seed first' });
+ return res.status(409).type('application/problem+json').json({
+ type: 'about:blank', title: 'Conflict', status: 409,
+ detail: 'the ward is not seeded -- POST /api/ward/seed first',
+ instance: req_id(res),
+ });
}
const beds = states.map((s) => ({
@@ -205,9 +250,30 @@ const getWard = async (req, res) => {
{ $project: { record: 0 } },
{ $sort: { bed_code: 1 } }
]);
+ // Demographics ride along, so a board that needs them costs one request and
+ // two queries instead of one request per bed. They are the SAME rows
+ // `/api/patient/:id/context` serves -- one read for the whole ward, not a
+ // per-bed lookup -- and the field is additive: a client that does not know
+ // about it is unaffected.
+ //
+ // THREE FIELDS, NOT THE WHOLE CONTEXT. The board renders identity and risk; it
+ // does not render weight, height, comorbidities or the Charlson index, and
+ // shipping a recorded medical history to a screen that shows none of it is a
+ // minimum-necessary problem rather than a payload-size one. These three are the
+ // ones the client's own validator requires. The drawer still reads the whole
+ // context from `/api/patient/:id/context`, which is where it belongs.
+ //
+ // Scoped to the beds on this board, not the whole collection: an unfiltered
+ // find grows without bound as stays accumulate.
+ const states = await StayState.find({ patient_id: { $in: latest.map((r) => r.patient_id) } })
+ .select('patient_id context.age context.sex context.ethnicity')
+ .lean();
+ const contexts = new Map(states.map((s) => [s.patient_id, s.context ?? null]));
+
// An empty ward is an empty list, not a 204: Express strips a 204's body, so
// `.json()` threw and the empty-state screen was unreachable.
- res.json(await Promise.all(latest.map(withPrompt)));
+ const rows = await Promise.all(latest.map(withPrompt));
+ res.json(rows.map((row) => ({ ...row, context: contexts.get(row.patient_id) ?? null })));
};
/** One patient's current assessment. */
@@ -256,26 +322,47 @@ const getParameterHistory = async (req, res) => {
res.json(points);
};
-/** Generate the explanation for a patient's latest assessment. 18-23 s on a
+/** Generate the explanation for one stored reading. 18-23 s on a
* local 7B. Every string is grounded against the record before it is stored;
* one that fails is replaced by the template, not shown with a warning. */
const explainPatient = async (req, res) => {
const { patientId } = req.params;
+
+ // Which reading to explain. Without `assessed_at` this is whichever row is
+ // newest when the request arrives; with it, the row the caller has on screen.
+ // On a ward that is advancing those are not the same, and the client's choice
+ // is the right one -- it is the reading a clinician was actually reading.
+ const hasTarget = req.body?.assessed_at !== undefined && req.body.assessed_at !== null;
+ const at = hasTarget ? new Date(req.body.assessed_at) : null;
+ if (at && Number.isNaN(at.getTime())) {
+ return res.status(400).json({ message: 'assessed_at is not a date' });
+ }
+
// `+record` because the schema hides it by default. It is what makes the
// explanation describe the STORED reading -- sending a tick instead had the
// service re-score at its own `now` and narrate a dwell no row ever had.
- const latest = await Assessment.findOne({ patient_id: patientId })
- .sort({ assessed_at: -1 }).select('+record').lean();
- if (!latest) {
- return res.status(404).json({ message: `no assessment for ${patientId}` });
+ // No sort on the targeted branch: `{patient_id, assessed_at}` is unique.
+ const query = Assessment.findOne(
+ at ? { patient_id: patientId, assessed_at: at } : { patient_id: patientId }
+ );
+ const target = await (at ? query : query.sort({ assessed_at: -1 }))
+ .select('+record').lean();
+ if (!target) {
+ // Two different facts, and an operator reading "no assessment for PM-204"
+ // when the bed has thirty of them goes looking for an unseeded ward.
+ return res.status(404).json({
+ message: at
+ ? `no reading for ${patientId} at ${at.toISOString()}`
+ : `no assessment for ${patientId}`
+ });
}
- if (latest.assessment_status !== 'assessed') {
+ if (target.assessment_status !== 'assessed') {
return res.status(409).json({
message: 'this reading is below the data-sufficiency floor and is not explained',
- insufficiency_reason: latest.insufficiency_reason
+ insufficiency_reason: target.insufficiency_reason
});
}
- if (!latest.record) {
+ if (!target.record) {
return res.status(409).json({
message: 'this assessment predates record storage -- re-seed the ward'
});
@@ -285,7 +372,7 @@ const explainPatient = async (req, res) => {
try {
const { data } = await explaining.post('/explain/patient', {
patient_id: patientId,
- record: latest.record,
+ record: target.record,
// The deterministic template floor instead of the 7B. Off by default; the
// only way to exercise this path without 6.9 GB of VRAM.
use_llm: req.body?.use_llm !== false
@@ -295,17 +382,53 @@ const explainPatient = async (req, res) => {
return fromUpstream(res, err, 'the explanation generator');
}
- await Assessment.updateOne(
- { _id: latest._id },
- {
- explanation: {
- status: result.status,
- explanation_text: result.explanation_text,
- grounding_status: result.grounding_status
+ // A FAILED REGENERATION MUST NOT DESTROY A GOOD ONE.
+ //
+ // This wrote back unconditionally, so a 7B that OOM'd while re-explaining a
+ // row that already had grounded prose replaced it with the fixed "unavailable"
+ // string -- permanently, and the panel offers no way back from that state. An
+ // explanation that could not be produced is not a reason to discard one that
+ // was. The caller still gets the real result and can show the failure.
+ const wouldDestroy = result.status === 'unavailable'
+ && target.explanation?.status === 'generated';
+ let stored = !wouldDestroy;
+ if (stored) {
+ const write = await Assessment.updateOne(
+ { _id: target._id },
+ {
+ explanation: {
+ status: result.status,
+ explanation_text: result.explanation_text,
+ grounding_status: result.grounding_status,
+ // Stored WITH the text, not beside it. `generator` is what lets the
+ // panel tell "the 7B ran and the library had no approved passage"
+ // apart from "the template does not consult the library" apart from
+ // "nothing has been generated yet" -- three states that all carry an
+ // empty `citations` list.
+ generator: result.generator ?? null,
+ citations: result.citations ?? []
+ }
}
- }
- );
- res.json(result);
+ );
+ // `updateOne` matches nothing if the row was deleted underneath -- a re-seed
+ // lands while a 20 s generation is in flight. Reporting `stored: true` there
+ // is the one thing this field exists to prevent.
+ stored = write.matchedCount > 0;
+ }
+ res.json({ ...result, stored });
+};
+
+/** Load the 7B before it is first needed. Writes nothing.
+ * Uses the `explaining` client because the work runs on the model thread under
+ * EXPLAIN_TIMEOUT_S, and the caller must sit above the callee (PM-TIME-001) --
+ * not because of how long the load takes. Figures: `.claude/rules/demo.md`. */
+const warmExplainer = async (req, res) => {
+ try {
+ const { data } = await explaining.post('/warmup', {});
+ return res.json(data);
+ } catch (err) {
+ return fromUpstream(res, err, 'the explanation generator');
+ }
};
/** Borrowed patient context: recorded, never computed by the model. */
@@ -326,6 +449,26 @@ const reviewPrompt = async (req, res) => {
return res.status(400).json({ message: `disposition must be one of ${allowed.join(', ')}` });
}
+ // TWO TIMES, BOTH TRUE. NOT ONE INVENTED ONE.
+ //
+ // `raised_at` comes from the reading that raised the prompt, and a simulated
+ // tick moves the ward an hour ahead of real time -- so a disposition recorded
+ // now can sit hours "before" the prompt it answers. The fix is NOT to stamp
+ // `reviewed_at` from the ward's clock: that writes an instant at which nothing
+ // happened, silently, into the only human-authored record the system holds,
+ // and it destroys ordering (two reviews inside one tick become identical) and
+ // fires on ordinary clock skew. It is the same error as a defaulted clinician
+ // name, and `attributed` below is the pattern -- declare the second fact.
+ const existing = await Prompt.findById(req.params.promptId).select('patient_id').lean();
+ // Scoped to the patient so `{patient_id, assessed_at}` serves it; ward-wide
+ // this is a full collection scan that grows with every tick.
+ const newest = existing
+ ? await Assessment.findOne({ patient_id: existing.patient_id })
+ .sort({ assessed_at: -1 }).select('assessed_at').lean()
+ : null;
+ const reviewedAt = new Date();
+ const wardTime = newest ? new Date(newest.assessed_at) : null;
+
// ATTRIBUTION COMES FROM AN AUTHENTICATED PRINCIPAL, OR IT IS DECLARED ABSENT.
//
// This used to read `clinician: clinician || 'ICU Clinician'` -- a free-text
@@ -346,7 +489,13 @@ const reviewPrompt = async (req, res) => {
review: {
disposition,
note: note || null,
- reviewed_at: new Date(),
+ reviewed_at: reviewedAt,
+ // Null only when it adds nothing. Compared for INEQUALITY, not ordering:
+ // a streaming ward runs ahead of the wall clock, an idle one falls
+ // behind it by however long it sat, and the second case is just as
+ // confusing on screen as the first. `>` recorded only half of them.
+ ward_time_at_review:
+ wardTime && wardTime.getTime() !== reviewedAt.getTime() ? wardTime : null,
clinician: actor,
attributed: actor !== null
}
@@ -357,17 +506,45 @@ const reviewPrompt = async (req, res) => {
res.json(prompt);
};
-/** Switch an input source off, or back on. The consequence is the point. */
+/** Switch input sources off, or back on. The consequence is the point.
+ *
+ * Takes a LIST. One request per device raced itself: three chips restored at
+ * once meant three reads of the same `offline_devices`, three last-write-wins
+ * saves, and one device actually back -- silently, because each response was
+ * individually correct. Restoring all of them is one write or it is a lottery.
+ *
+ * Behind `wardBusy` because this is a read-modify-write on `StayState` and a
+ * seed deletes and re-upserts that document with a fresh `_id`; a `save()`
+ * landing in that window matches nothing and throws DocumentNotFoundError.
+ */
const setDeviceState = async (req, res) => {
+ if (wardBusy) return wardBusyResponse(res);
+ wardBusy = true;
+ try {
+ return await runSetDeviceState(req, res);
+ } finally {
+ wardBusy = false;
+ }
+};
+
+const runSetDeviceState = async (req, res) => {
const { patientId } = req.params;
- const { device_id: deviceId, offline } = req.body || {};
- if (!deviceId) return res.status(400).json({ message: 'device_id required' });
+ const body = req.body || {};
+ // `device_ids` is the shape; `device_id` stays accepted so a single chip is
+ // not a special case at the call site.
+ const ids = body.device_ids ?? (body.device_id ? [body.device_id] : null);
+ const { offline } = body;
+ if (!Array.isArray(ids) || ids.length === 0) {
+ return res.status(400).json({ message: 'device_id or a non-empty device_ids required' });
+ }
const state = await StayState.findOne({ patient_id: patientId });
if (!state) return res.status(404).json({ message: `no stay state for ${patientId}` });
const current = new Set(state.offline_devices);
- if (offline) current.add(deviceId); else current.delete(deviceId);
+ for (const id of ids) {
+ if (offline) current.add(id); else current.delete(id);
+ }
state.offline_devices = [...current];
await state.save();
@@ -402,6 +579,7 @@ module.exports = {
getParameterHistory,
getPatientContext,
explainPatient,
+ warmExplainer,
reviewPrompt,
setDeviceState
};
diff --git a/back-end/middleware/requestContext.js b/back-end/middleware/requestContext.js
index 156eeea..4839efe 100644
--- a/back-end/middleware/requestContext.js
+++ b/back-end/middleware/requestContext.js
@@ -29,13 +29,136 @@ const subject = (id) => {
return createHmac('sha256', key).update(String(id)).digest('hex').slice(0, 12);
};
+/** A Server-Timing desc is a quoted-string: escape the two characters that end it. */
+const quote = (text) => `"${String(text).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
+
+/**
+ * Collect measured spans on the response and emit them as one Server-Timing
+ * header (W3C), so the dashboard can show what each tier actually cost.
+ *
+ * ⚠️ EMITTED FROM `writeHead`, NOT from the 'finish' listener below. By 'finish'
+ * the headers are already on the wire and `setHeader` is either an exception or
+ * a silent no-op depending on how it is reached -- so the obvious place to put
+ * this, beside the duration the logger already computes, is the one place it
+ * cannot work. `writeHead` is the last moment the headers are still ours.
+ *
+ * Spans AGGREGATE by name. A tick writes to Mongo repeatedly, and repeating the
+ * entry once per write would be legal and unreadable.
+ *
+ * ⚠️ AGGREGATED AS A UNION OF INTERVALS, NOT AS A SUM. Measured 2026-08-22:
+ * `GET /ward` issues its eight Mongo reads concurrently, and summing them
+ * reported `mongo;dur=1325` inside `total;dur=577` — a child four times larger
+ * than the request containing it. The panel indents these to show what nests in
+ * what, so a sum over concurrent work does not just overstate a number, it draws
+ * a tree that is not one. The union answers the question actually being asked:
+ * how much of this request's wall time was spent in Mongo at all.
+ */
+const measure = (res, started) => {
+ const spans = new Map();
+ const upstream = [];
+
+ /** Record work that ran between two `process.hrtime.bigint()` readings. */
+ res.timing = (name, from, to, desc) => {
+ const span = spans.get(name) || { intervals: [], desc: undefined };
+ span.intervals.push([
+ Number(from - started) / 1e6,
+ Number(to - started) / 1e6,
+ ]);
+ if (desc !== undefined) span.desc = desc;
+ spans.set(name, span);
+ };
+
+ /** Total length of the union of a span's intervals, in milliseconds. */
+ const union = (intervals) => {
+ const sorted = [...intervals].sort((a, b) => a[0] - b[0]);
+ let total = 0, start = null, end = null;
+ for (const [from, to] of sorted) {
+ if (start === null) { start = from; end = to; continue; }
+ if (from > end) { total += end - start; start = from; end = to; }
+ else if (to > end) { end = to; }
+ }
+ if (start !== null) total += end - start;
+ return total;
+ };
+
+ // An observation that is not a duration -- a queue depth, a model id. Kept
+ // separate from `timing` so it cannot acquire a `dur`: emitting `dur=0` for
+ // something that was never timed is a measurement the system does not have.
+ res.mark = (name, desc) => {
+ const span = spans.get(name) || { intervals: [], desc: undefined };
+ span.desc = desc;
+ spans.set(name, span);
+ };
+
+ // The model service's own spans, forwarded verbatim: they were measured
+ // where the work happened and this tier has nothing to add to them. Names do
+ // not collide -- Python emits pipeline stages, Node emits transport and
+ // storage -- so they can simply sit side by side in one header.
+ res.timingFrom = (raw) => {
+ // A newline here would end the header and begin a forged one. It comes
+ // from our own service today; it is stripped anyway, because "trusted
+ // upstream" is an assumption and this is one line of code.
+ if (raw) upstream.push(String(raw).replace(/[\r\n]/g, ' ').trim());
+ };
+
+ const render = () => {
+ const parts = [...upstream].filter(Boolean);
+ for (const [name, span] of spans) {
+ const count = span.intervals.length;
+ // No interval means nothing was timed -- a `mark`. It gets a desc and
+ // no `dur`, which is what the spec is for and what stops a reader
+ // adding it to a total.
+ if (!count) {
+ parts.push(span.desc === undefined ? name : `${name};desc=${quote(span.desc)}`);
+ continue;
+ }
+ const wall = union(span.intervals);
+ let entry = `${name};dur=${wall.toFixed(3)}`;
+
+ const notes = [];
+ if (span.desc !== undefined) notes.push(span.desc);
+ if (count > 1) {
+ // Say WHICH kind of repetition it was. Eight sequential writes
+ // and eight concurrent ones produce very different numbers from
+ // the same count, and only one of them can be added to its
+ // siblings. Deriving it rather than declaring it: a caller that
+ // has to remember to say "concurrent" will eventually forget.
+ const sum = span.intervals.reduce((t, [f, o]) => t + (o - f), 0);
+ notes.push(sum > wall * 1.05 ? `${count}x concurrent` : `${count}x`);
+ }
+ if (notes.length) entry += `;desc=${quote(notes.join(', '))}`;
+ parts.push(entry);
+ }
+ return parts.join(', ');
+ };
+
+ const originalWriteHead = res.writeHead;
+ res.writeHead = function patchedWriteHead(...args) {
+ if (!res.headersSent) {
+ // Last, so it reads as the sum it is. Slightly under the figure the
+ // log line records, which also covers writing the body -- two honest
+ // measurements of two different things, not a discrepancy.
+ res.timing('total', started, process.hrtime.bigint());
+ const header = render();
+ // Only when something was measured. An empty header is an entry a
+ // reader cannot distinguish from a stage that took no time.
+ if (header) res.setHeader('Server-Timing', header);
+ }
+ return originalWriteHead.apply(this, args);
+ };
+};
+
const requestContext = (req, res, next) => {
const id = req.headers['x-request-id'] || randomUUID();
req.id = id;
res.setHeader('X-Request-Id', id);
- als.run({ requestId: id }, () => {
+ // `res` is in the store for the same reason `requestId` is: the model-service
+ // client is three files away from any response object and threading one
+ // through every call site to record a duration is not worth the churn.
+ als.run({ requestId: id, res }, () => {
const started = process.hrtime.bigint();
+ measure(res, started);
let logged = false;
// 'finish' for a completed response, 'close' for an aborted one. Native
// events rather than the `on-finished` package, which reaches this repo
@@ -65,4 +188,17 @@ const requestContext = (req, res, next) => {
});
};
-module.exports = { requestContext, requestId, subject, als };
+/**
+ * Record a span from anywhere inside a request, without holding the response.
+ *
+ * Silently does nothing outside a request context -- a startup call or a stray
+ * timer has no response to write to, and a measurement is not worth an
+ * exception. That is the one case where a no-op is right: the alternative is a
+ * crash in the observability layer taking down the thing it observes.
+ */
+const timing = (name, from, to, desc) => als.getStore()?.res?.timing?.(name, from, to, desc);
+
+/** Forward the model service's own Server-Timing entries, unaltered. */
+const timingFrom = (raw) => als.getStore()?.res?.timingFrom?.(raw);
+
+module.exports = { requestContext, requestId, subject, als, timing, timingFrom };
diff --git a/back-end/model/Assessment.js b/back-end/model/Assessment.js
index 6fd0320..edc0e03 100644
--- a/back-end/model/Assessment.js
+++ b/back-end/model/Assessment.js
@@ -52,7 +52,17 @@ const explanationSchema = new Schema({
grounding_status: {
type: String,
enum: ['passed', 'violations_found', 'not_checked']
- }
+ },
+ // WHICH writer produced the text: the model id, 'template', or null when
+ // nothing was generated. Load-bearing, not provenance decoration -- an empty
+ // `citations` list means "the library holds no approved passage for this
+ // reading" after the 7B and "the template never consults the library" after
+ // the floor, and those are the same two fields away from being read as a
+ // failure.
+ generator: String,
+ // The passages the generator was SHOWN, stored with the text they grounded
+ // rather than on the assessment. Same operation, same record.
+ citations: [{ source: String, claim: String, _id: false }]
}, { _id: false });
const assessmentSchema = new Schema({
@@ -80,7 +90,6 @@ const assessmentSchema = new Schema({
readings_in_state: Number,
contributors: [contributorSchema],
explanation: explanationSchema,
- citations: [{ source: String, claim: String, _id: false }],
// --- present only when assessment_status is 'insufficient_data' ----------
insufficiency_reason: {
@@ -108,4 +117,9 @@ assessmentSchema.index({ patient_id: 1, assessed_at: -1 });
// One assessment per patient per reading time; a replayed tick must not double.
assessmentSchema.index({ patient_id: 1, assessed_at: 1 }, { unique: true });
+// Every query through this model reports a `mongo` span. Applied here rather
+// than globally: a global plugin only reaches schemas compiled after it runs,
+// so it stops timing silently when require order changes.
+assessmentSchema.plugin(require('../config/queryTiming'));
+
module.exports = mongoose.model('Assessment', assessmentSchema);
diff --git a/back-end/model/Prompt.js b/back-end/model/Prompt.js
index 07f5d17..3588cd1 100644
--- a/back-end/model/Prompt.js
+++ b/back-end/model/Prompt.js
@@ -30,7 +30,15 @@ const promptSchema = new Schema({
},
// A short tracking note. Clinical documentation stays in the EHR.
note: { type: String, default: null },
+ // The real instant the clinician acted. Always wall clock, always true.
reviewed_at: Date,
+ // What the ward's own clock read at that moment. A simulated tick moves the
+ // ward an hour, so `raised_at` can sit hours ahead of `reviewed_at` and the
+ // record reads as a disposition answering a prompt that did not exist yet.
+ // Declared as a second fact rather than folded into the first: writing the
+ // ward's time INTO `reviewed_at` would record an instant at which nothing
+ // happened, which is the same error as a defaulted clinician name.
+ ward_time_at_review: { type: Date, default: null },
// Null until authentication exists. `attributed` says so explicitly, because
// a reader seeing a blank name cannot tell "nobody was identified" from
// "the field was not populated". Never taken from the request body.
@@ -43,4 +51,9 @@ promptSchema.index({ patient_id: 1, raised_at: -1 });
// At most one prompt per patient per raise time.
promptSchema.index({ patient_id: 1, raised_at: 1 }, { unique: true });
+// Every query through this model reports a `mongo` span. Applied here rather
+// than globally: a global plugin only reaches schemas compiled after it runs,
+// so it stops timing silently when require order changes.
+promptSchema.plugin(require('../config/queryTiming'));
+
module.exports = mongoose.model('Prompt', promptSchema);
diff --git a/back-end/model/StayState.js b/back-end/model/StayState.js
index 92e84f0..3a09d6e 100644
--- a/back-end/model/StayState.js
+++ b/back-end/model/StayState.js
@@ -23,4 +23,9 @@ const stayStateSchema = new Schema({
context: { type: Schema.Types.Mixed, default: null }
}, { timestamps: true });
+// Every query through this model reports a `mongo` span. Applied here rather
+// than globally: a global plugin only reaches schemas compiled after it runs,
+// so it stops timing silently when require order changes.
+stayStateSchema.plugin(require('../config/queryTiming'));
+
module.exports = mongoose.model('StayState', stayStateSchema);
diff --git a/back-end/pythonService/app.py b/back-end/pythonService/app.py
index 231ac3f..d8c8ccf 100644
--- a/back-end/pythonService/app.py
+++ b/back-end/pythonService/app.py
@@ -1,7 +1,8 @@
"""PulseMind model service. Scores, bands and explains; stores nothing.
POST /ward/seed build the ward and backfill its history
- POST /ward/tick one more reading per bed
+ POST /ward/tick one more reading per bed, on the stay's hourly grid
+ POST /warmup load the 7B ahead of the first explanation
POST /explain/patient explain a stored record in plain language (slow)
GET /healthz model, band table and scoring device
@@ -14,8 +15,9 @@
from contextlib import asynccontextmanager
from datetime import datetime, timezone
+from http import HTTPStatus
-from fastapi import FastAPI, HTTPException, Request
+from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict, Field
@@ -24,6 +26,8 @@
import contract
import model_runtime as rt
import synthetic_ward as sw
+from stopwatch import NULL as NO_TIMINGS
+from stopwatch import Timings
from pipeline import config as C
# Provenance captured at startup, when the model thread is idle by construction,
@@ -101,7 +105,15 @@ class BedState(BaseModel):
class TickRequest(BaseModel):
model_config = _STRICT
seed: int = 20260817
- beds: list[BedState] = Field(..., max_length=64)
+ # At least one: the response reports the ward's clock as the newest reading
+ # across the beds, and there is no such thing for an empty ward.
+ beds: list[BedState] = Field(..., min_length=1, max_length=64)
+
+
+class WarmupRequest(BaseModel):
+ """No fields, but a model all the same -- `extra="forbid"` then rejects a
+ caller who sends options this endpoint would silently ignore."""
+ model_config = _STRICT
class ExplainRequest(BaseModel):
@@ -118,6 +130,40 @@ class ExplainRequest(BaseModel):
# ---------------------------------------------------------------------------
# Scoring
# ---------------------------------------------------------------------------
+def _measured() -> Timings:
+ """Start measuring this request, and note how busy the model thread was.
+
+ Depth is read BEFORE the work is enqueued, so it answers "what did this
+ request arrive into" rather than "what did it cause". Read-only -- a probe
+ that enqueues is the failure PM-HEALTH-001 exists to prevent.
+ """
+ timings = Timings()
+ timings.mark("depth", f"{rt.queue_depth()}/{rt.MAX_QUEUE_DEPTH}")
+ return timings
+
+
+def _emit(response: Response, http: Request, timings: Timings) -> None:
+ """Put the measured spans and the caller's request id on the response.
+
+ W3C Server-Timing rather than a payload field, so `contract/clinical.ts`
+ stays clinical: an operational figure in there would also have to be declared
+ in the Mongoose schema to survive a save (PM-VER-003), for a number nobody
+ wants persisted.
+
+ The header is set only when something was measured. An empty one is an entry
+ that means nothing, and a reader cannot tell it from a stage that took no time.
+ """
+ header = timings.to_header()
+ if header:
+ response.headers["Server-Timing"] = header
+ # Node generates this id, forwards it here, and until now nothing on this
+ # side read it -- so a 23 s call could not be matched to the work it caused.
+ # Echoing it is what makes the id mean the same thing on both sides.
+ request_id = http.headers.get("x-request-id")
+ if request_id:
+ response.headers["X-Request-Id"] = request_id
+
+
def _bed(patient_id: str) -> sw.Bed:
for bed in sw.WARD:
if bed.patient_id == patient_id:
@@ -126,22 +172,28 @@ def _bed(patient_id: str) -> sw.Bed:
def _score_tick(bed: sw.Bed, tick: int, at: datetime, seed: int,
- state: dict | None, offline: set[str], last_band: str | None) -> dict:
+ state: dict | None, offline: set[str], last_band: str | None,
+ timings: Timings = NO_TIMINGS) -> dict:
"""Generate, score, band and map one reading for one bed."""
- context = sw.context_for(bed, at - sw.TICK * tick)
- reading = sw.reading_for(bed, tick, at, seed)
-
- # An offline source stops refreshing: values age rather than vanish, which
- # is what pushes a reading toward the floor.
- if offline:
- supplied = _parameters_still_arriving(bed, offline)
- reading = sw.Reading(observed_at=reading.observed_at,
- values={k: v for k, v in reading.values.items()
- if k in supplied},
- ventilator_mode=reading.ventilator_mode,
- infusions=reading.infusions)
-
- record, next_state = rt.score(context, reading, state)
+ # `collect` is stage 1 of the published pipeline: what the bedside produces,
+ # before anything has been ordered or weighed. Here it is manufactured
+ # rather than received, which is the one place this demo stands in for a
+ # hospital interface -- so the span measures the stand-in, not an HL7 feed.
+ with timings.span("collect"):
+ context = sw.context_for(bed, at - sw.TICK * tick)
+ reading = sw.reading_for(bed, tick, at, seed)
+
+ # An offline source stops refreshing: values age rather than vanish,
+ # which is what pushes a reading toward the floor.
+ if offline:
+ supplied = _parameters_still_arriving(bed, offline)
+ reading = sw.Reading(observed_at=reading.observed_at,
+ values={k: v for k, v in reading.values.items()
+ if k in supplied},
+ ventilator_mode=reading.ventilator_mode,
+ infusions=reading.infusions)
+
+ record, next_state = rt.score(context, reading, state, timings)
devices = sw.devices_for(bed, at, offline)
published = contract.assessment(
@@ -230,13 +282,34 @@ async def readyz() -> JSONResponse:
)
+@app.exception_handler(HTTPException)
+async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
+ """ONE ERROR SHAPE (PM-ERR-001): RFC 9457 `application/problem+json`.
+
+ FastAPI's default is `{"detail": ...}` with a plain JSON content type, which
+ left this service answering in two shapes -- the `Overloaded` handler above
+ already did it properly. `detail` survives, so Node's `fromUpstream` reads
+ the same field it always did.
+ """
+ return JSONResponse(
+ status_code=exc.status_code,
+ media_type="application/problem+json",
+ headers=getattr(exc, "headers", None),
+ content={"type": "about:blank",
+ "title": HTTPStatus(exc.status_code).phrase,
+ "status": exc.status_code,
+ "detail": exc.detail},
+ )
+
+
@app.post("/ward/seed")
-def seed(request: SeedRequest) -> dict:
+def seed(request: SeedRequest, http: Request, response: Response) -> dict:
"""Build the ward and score its backfilled history, oldest reading first.
Bands come from pushing real scores through the real hysteresis machine in
order; thresholding them afterwards would fabricate the `demoting` stretch.
"""
+ timings = _measured()
runtime = rt.runtime()
sw.check_levels(runtime.assets)
@@ -250,7 +323,8 @@ def seed(request: SeedRequest) -> dict:
state, last_band, history = None, None, []
for tick in range(request.backfill_ticks):
at = start + sw.TICK * tick
- step = _score_tick(bed, tick, at, request.seed, state, set(), last_band)
+ step = _score_tick(bed, tick, at, request.seed, state, set(), last_band,
+ timings)
state = step["stay_state"]
if step["assessment"]["assessment_status"] == "assessed":
last_band = step["assessment"]["risk_level"]
@@ -265,38 +339,135 @@ def seed(request: SeedRequest) -> dict:
"last_band": last_band,
"context": _patient_context(bed),
})
+ _emit(response, http, timings)
return {"seeded_at": now.isoformat(), "ticks": request.backfill_ticks,
"patients": patients}
@app.post("/ward/tick")
-def tick(request: TickRequest) -> dict:
- """One more reading per bed, from the state Node read back out of Mongo."""
- now = datetime.now(timezone.utc).replace(microsecond=0)
- out = []
+def tick(request: TickRequest, http: Request, response: Response) -> dict:
+ """One more reading per bed, from the state Node read back out of Mongo.
+
+ The reading time continues each stay's OWN hourly grid rather than the wall
+ clock. `/ward/seed` already walks that grid (`start + TICK * tick`), and both
+ `synthetic_ward.TICK` and the dwell in the band table are denominated in it.
+ Stamping `now()` here made a live tick behave unlike a backfilled one:
+
+ - The latch clock is `observed_at - origin` in minutes, so consecutive readings
+ sat seconds apart while the physiology advanced an hour. Promotion has zero
+ dwell and survived that; `demote_dwell_min = 120` did not, so no band could
+ ever step back down and the recovering bed latched for ever.
+ - Every parameter aged in seconds. `age_minutes` is what the board shows as
+ staleness and what carry-forward is judged on, and it read ~0 on a reading
+ an hour newer than the last.
+ - `_score_tick` derives the stay's start as `at - TICK * tick`, so a
+ wall-clock `at` slid that start -- and `ventilation_start` with it -- an
+ hour further into the past on every reading.
+ """
+ # RESOLVED BEFORE ANY SCORING. No fallback: `_score` subscripts
+ # `state["origin"]` directly, so a stay without one cannot be scored whatever
+ # we do here, and substituting the wall clock would stamp a reading BEHIND
+ # the ones already stored for that bed -- which `getWard` then keeps sorting
+ # above the new one, freezing the bed on screen with nothing logged. Done as
+ # a pre-pass because inside the loop a bad eighth bed costs seven GPU
+ # scorings before the refusal, on every retry.
+ timings = _measured()
+ schedule = []
for bed_state in request.beds:
- bed = _bed(bed_state.patient_id)
- step = _score_tick(bed, bed_state.tick + 1, now, request.seed,
+ bed = _bed(bed_state.patient_id) # 404s here, before any scoring
+ origin = bed_state.stay_state.get("origin")
+ try:
+ # Not just falsy: a malformed non-empty string used to reach
+ # `fromisoformat`, raise, and leave Node reporting "the model service
+ # did not respond" about a service that answered precisely.
+ at = datetime.fromisoformat(origin) + sw.TICK * (bed_state.tick + 1)
+ except (TypeError, ValueError):
+ raise HTTPException(
+ 422, f"{bed_state.patient_id}: stay state carries no usable origin "
+ f"({origin!r}), so its readings cannot be placed on the ward's "
+ "clock -- re-seed the ward") from None
+ schedule.append((bed, bed_state, at))
+
+ out, times = [], []
+ for bed, bed_state, at in schedule:
+ times.append(at)
+ step = _score_tick(bed, bed_state.tick + 1, at, request.seed,
bed_state.stay_state, set(bed_state.offline_devices),
- bed_state.last_band)
+ bed_state.last_band, timings)
out.append(step)
- return {"at": now.isoformat(), "patients": out}
+ # The ward's clock is the NEWEST reading across the beds: per-bed times differ
+ # when one stay started later, and the board compares its own "now" against
+ # the newest of them.
+ _emit(response, http, timings)
+ return {"at": max(times).isoformat(), "patients": out}
+
+
+def _load_generator(timings: Timings):
+ """Load the 7B, timing the load itself. Runs ON the model thread."""
+ with timings.span("load", C.LLM_MODEL_ID):
+ return expl.generator()
+
+
+@app.post("/warmup")
+def warmup(http: Request, response: Response,
+ request: WarmupRequest = WarmupRequest()) -> dict:
+ """Load the 7B now, so the first explanation of a session is not the slow one.
+
+ Goes through the model thread like everything else that touches CUDA, so it
+ queues behind scoring rather than racing it, and it writes nothing: the
+ alternative -- explaining some bed to warm the weights -- leaves a real
+ explanation attached to a reading nobody asked about. Measured cold and warm
+ figures are in `.claude/rules/demo.md`, in one place, once.
+ """
+ timings = _measured()
+ if expl.generator_loaded():
+ _emit(response, http, timings)
+ return {"explainer": "loaded", "was_loaded": True}
+ try:
+ # Measured INSIDE the model thread, not around the call. Around it, the
+ # span would also contain the queue wait that `_on_model_thread` reports
+ # separately, and two overlapping entries in one header cannot be summed
+ # by a reader who has no way to know one nests in the other.
+ rt.on_model_thread(_load_generator, timings, timings=timings)
+ except rt.Overloaded:
+ raise # the 503 + Retry-After handler owns this one
+ except expl.InsufficientVRAM as refusal:
+ # The refusal carries the numbers, because "did not load" sends an
+ # operator to the wrong problem. There is nothing patient-shaped in a
+ # VRAM figure, so this is the one load failure whose text is safe to
+ # return verbatim.
+ raise HTTPException(503, f"the explainer will not fit: {refusal}") from refusal
+ except Exception as failure: # noqa: BLE001
+ # A generator that cannot load must not take scoring down with it --
+ # `explanation.py` wraps the same call for the same reason. Bare, it
+ # escapes as text/plain and Node reports "did not respond", which sends
+ # an operator to restart a service that answered correctly.
+ raise HTTPException(
+ 503, f"the explainer did not load: {type(failure).__name__}") from failure
+ # Observed, not asserted: `generator()` returning without loading would make
+ # a hard-coded "loaded" a false record.
+ _emit(response, http, timings)
+ return {"explainer": "loaded" if expl.generator_loaded() else "unavailable",
+ "was_loaded": False}
@app.post("/explain/patient")
-def explain_patient(request: ExplainRequest) -> dict:
+def explain_patient(request: ExplainRequest, http: Request,
+ response: Response) -> dict:
"""Explain a stored reading, in plain language.
Its own endpoint because it is three orders of magnitude slower: 66 ms to
score, 18-23 s to write. The record arrives from Node and is never rebuilt --
rebuilding re-scores at a new `now` and explains a state no row ever had.
"""
+ timings = _measured()
bed = _bed(request.patient_id)
if not request.record.get("telemetry"):
raise HTTPException(422, "record is not a scored reading")
# The guard belongs where the policy is. Without it Node would generate an
# explanation and overwrite the fixed withheld string on the assessment.
if bed.withhold_explanation:
+ _emit(response, http, timings)
return {**contract.unavailable_explanation(),
"findings": [], "generator": None, "seconds": 0.0}
@@ -304,7 +475,10 @@ def explain_patient(request: ExplainRequest) -> dict:
# On the model thread, not this request's worker. The generator is a second
# CUDA consumer and two contexts on two threads segfault the process.
- return rt.on_model_thread(generate_explanation, request.record, request.use_llm)
+ result = rt.on_model_thread(generate_explanation, request.record,
+ request.use_llm, timings, timings=timings)
+ _emit(response, http, timings)
+ return result
def _patient_context(bed: sw.Bed) -> dict:
diff --git a/back-end/pythonService/contract.py b/back-end/pythonService/contract.py
index 28020a6..5765d03 100644
--- a/back-end/pythonService/contract.py
+++ b/back-end/pythonService/contract.py
@@ -157,7 +157,7 @@ def _rests_on_a_default(feature: str, parameter: str | None, suffix: str | None,
def assessment(record: dict, *, patient_id: str, bed_code: str, unit: str,
devices: list[dict], readings_since_admission: int,
- explanation: dict | None = None, citations: list[dict] | None = None,
+ explanation: dict | None = None,
prompt: dict | None = None, review: dict | None = None) -> dict:
"""The record as the frontend reads it -- scored, or explicitly refused."""
base = {
@@ -189,7 +189,6 @@ def assessment(record: dict, *, patient_id: str, bed_code: str, unit: str,
"readings_in_state": band["readings_in_state"],
"contributors": contributors(record),
"explanation": explanation,
- "citations": citations or [],
"prompt": prompt,
"review": review,
# So a stored assessment traces to what produced it. The schema
diff --git a/back-end/pythonService/explanation.py b/back-end/pythonService/explanation.py
index 4c9f2c3..1926604 100644
--- a/back-end/pythonService/explanation.py
+++ b/back-end/pythonService/explanation.py
@@ -17,6 +17,10 @@
import json
import time
+from functools import lru_cache
+
+from stopwatch import NULL as NO_TIMINGS
+from stopwatch import Timings
from pipeline import config as C
from pipeline.core import explain as E
@@ -25,8 +29,14 @@
_generator = None
+@lru_cache(maxsize=1)
def policy() -> E.Policy:
- """Same construction as s18_explain.policy(), which is the definition."""
+ """Same construction as s18_explain.policy(), which is the definition.
+
+ Cached: the evidence map is 73 KB of JSON and this is now read on the
+ SCORING path, once per bed per tick, not just when someone asks for prose.
+ The map is a build artifact and does not change under a running service.
+ """
evidence = None
if C.EVIDENCE_MAP_JSON.exists():
evidence = json.loads(
@@ -59,11 +69,79 @@ def check(record: dict, text: str, pol: E.Policy) -> list[G.Finding]:
band_names=C.BAND_NAMES, evidence=evidence)
+#: Free VRAM the 7B needs, from the driver, before the load is allowed to start.
+#:
+#: MEASURED, 2026-09-07. This was a bracket for three weeks -- a segfault at
+#: 6561 MiB free and a success at 6721, with 6700 chosen inside the band and the
+#: comment admitting it was "slightly optimistic". Nobody had watched the card
+#: DURING a load, so the peak was inferred from whether the process survived.
+#:
+#: `bki/pipeline/tools/vram_probe.py` samples the driver at 5 Hz across the load
+#: -- same source, same unit as the check below. Three consecutive loads:
+#:
+#: run free before PEAK resident load generate
+#: 1 6795 6059 5929 16.7s 14.4s
+#: 2 7501 6239 6109 17.4s 13.2s
+#: 3 7423 6171 6041 15.8s 13.3s
+#:
+#: Peak max 6239, spread 180. The transient above resident is 130 MiB in ALL
+#: THREE runs, which is the steadiest figure in this whole investigation.
+#:
+#: 6420 = 6239 + 180. Peak plus the observed run-to-run spread, which is the
+#: margin that matters: free VRAM at check time is not free VRAM 17 s later, and
+#: drift is what made 6561 crash one day and load the next.
+#:
+#: ⚠️ THIS IS THE "LOWEST THAT LOADS" SETTING, chosen deliberately (2026-09-07)
+#: over a safer one. It is BELOW the 6561 that crashed, so it will permit a
+#: configuration that has failed before. That is the accepted trade: the failure
+#: mode is a segfault that takes the service down, and the mitigation is that a
+#: demo should warm the explainer FIRST, when a crash costs a restart rather than
+#: an audience. Raise it on any load that fails above this line -- that failure
+#: is still the only evidence that narrows the requirement from below.
+#:
+#: ⚠️ The peak does NOT move with `PM_LLM_EMBED_DEVICE`, so do not lower this
+#: constant when that setting changes: the table is on the card while
+#: `from_pretrained` runs, whatever happens to it afterwards. What it does change
+#: is the room left AFTER the load, and therefore generation speed -- 13.6 s
+#: against 21.7 s, measured. See `LLM_EMBED_DEVICE` in `pipeline/config.py`.
+#:
+#: The VALUE is defined in `pipeline.config`, so this service and the pipeline
+#: stage cannot drift apart -- they did, by 1175 MiB, for three weeks. The
+#: evidence stays here, where the gate that acts on it lives.
+MIN_FREE_VRAM_MIB = C.LLM_MIN_FREE_VRAM_MIB
+
+
+class InsufficientVRAM(RuntimeError):
+ """The card cannot hold the 7B. Raised BEFORE the load, on purpose."""
+
+
def generator():
- """Load the 7B on first use. Blocks for as long as the weights take."""
+ """Load the 7B on first use. Blocks for as long as the weights take.
+
+ GATED, NOT WRAPPED. Loading a 7B onto a card without room does not raise --
+ it segfaults, taking the whole service down and reaching Node as an
+ ECONNRESET. No `except` clause runs after the process dies, so `/warmup`'s
+ try/except is decorative against the failure that actually happens. The only
+ thing that helps is refusing before the allocation.
+
+ Here rather than in the `/warmup` route because this is the single funnel:
+ the explicit warm-up and the lazy load inside `/explain/patient` both arrive
+ through it, and the second is the one that threatens a demo.
+
+ Free VRAM comes from the driver via `vram_status()`, never from CUDA's own
+ bookkeeping -- measured with the 7B resident, nvidia-smi said 260 MiB free
+ and torch said 6759.
+ """
global _generator
if _generator is None:
from pipeline.core import generate as Gen
+ vram = Gen.vram_status()
+ if vram["free_mib"] < MIN_FREE_VRAM_MIB:
+ raise InsufficientVRAM(
+ f"{vram['free_mib']} MiB free of {vram['total_mib']} MiB "
+ f"(source: {vram['source']}); the 7B needs at least "
+ f"{MIN_FREE_VRAM_MIB} MiB. Close what is holding the card, or "
+ f"explain with use_llm=false for the deterministic template.")
_generator = Gen.load_generator()
return _generator
@@ -77,7 +155,8 @@ def generator_loaded() -> bool:
return _generator is not None
-def generate_explanation(record: dict, use_llm: bool = True) -> dict:
+def generate_explanation(record: dict, use_llm: bool = True,
+ timings: Timings = NO_TIMINGS) -> dict:
"""Explain one assessment, or say plainly why it cannot be explained.
Returns the contract's Explanation shape plus the findings and timing, so a
@@ -89,40 +168,62 @@ def generate_explanation(record: dict, use_llm: bool = True) -> dict:
# Below the sufficiency floor there is nothing honest to say, and
# build_payload() raises rather than letting a generator try.
try:
- E.build_payload(record, pol)
+ with timings.span("floor"):
+ E.build_payload(record, pol)
except E.InsufficientData as reason:
return {"status": "unavailable",
"explanation_text": C.INSUFFICIENT_DATA_TEXT,
"grounding_status": "not_checked",
- "findings": [], "generator": None,
+ "findings": [], "generator": None, "citations": [],
"withheld_because": str(reason),
"seconds": round(time.perf_counter() - started, 3)}
- baseline = E.baseline(record, pol)
+ # ALWAYS computed, on both paths: it is the substitute a failed grounding
+ # check falls back to, so it cannot wait until one is needed. Its own span,
+ # not `explain`, because aggregating the deterministic floor and the 7B into
+ # one entry would report a 26 s generation and a 2 ms template as one number.
+ with timings.span("baseline"):
+ baseline = E.baseline(record, pol)
if not use_llm:
- return _result("generated", baseline, check(record, baseline, pol),
+ with timings.span("ground"):
+ findings = check(record, baseline, pol)
+ return _result("generated", baseline, findings,
generator_name="template", started=started, fell_back=False)
try:
- block = E.explain(record, pol, generator=generator(),
- generator_name=C.LLM_MODEL_ID)
+ # COLD LOAD AND GENERATION ARE DIFFERENT NUMBERS -- 27.9 s to load
+ # against 18-23 s to write -- and one figure covering both cannot tell a
+ # reader which they just watched. The span is recorded only when a load
+ # actually happens: a warm call has no load to report, and a 0 ms entry
+ # would be a stage that did not run wearing the costume of one that did.
+ if generator_loaded():
+ gen = generator()
+ else:
+ with timings.span("load", C.LLM_MODEL_ID):
+ gen = generator()
+ with timings.span("explain", C.LLM_MODEL_ID):
+ block = E.explain(record, pol, generator=gen,
+ generator_name=C.LLM_MODEL_ID)
except Exception as failure: # noqa: BLE001
# A generator that cannot load must not take the score down with it.
# Band, score, inputs and contributors all remain available.
return {"status": "unavailable",
"explanation_text": C.EXPLANATION_UNAVAILABLE_TEXT,
"grounding_status": "not_checked",
- "findings": [], "generator": None,
+ "findings": [], "generator": None, "citations": [],
"generator_error": f"{type(failure).__name__}: {failure}",
"seconds": round(time.perf_counter() - started, 3)}
text = block["text"]
- findings = check(record, text, pol)
+ with timings.span("ground"):
+ findings = check(record, text, pol)
violations = [f for f in findings if f.severity == "violation"]
if violations:
# Substitute rather than warn. A grounded-but-wrong sentence in a
# clinical voice is worse than a plainer one that is right.
- return _result("generated", baseline, check(record, baseline, pol),
+ with timings.span("ground"):
+ baseline_findings = check(record, baseline, pol)
+ return _result("generated", baseline, baseline_findings,
generator_name="template", started=started, fell_back=True,
rejected=[f.to_json() for f in violations])
return _result("generated", text, findings,
@@ -151,7 +252,40 @@ def _result(status: str, text: str, findings, *, generator_name: str,
"generator": generator_name,
"fell_back_to_template": fell_back,
"rejected_generation": rejected or [],
+ # DERIVED, never passed in. The panel shows the passages the generator
+ # was actually shown -- sourcing them separately would let the citation
+ # list and the prose drift apart while both looked right.
+ "citations": _as_citations(guideline_context or []),
"guideline_context": guideline_context or [],
"suggested_actions": suggested_actions or [],
"seconds": round(time.perf_counter() - started, 3),
}
+
+
+def _as_citations(context: list[dict]) -> list[dict]:
+ """`guideline_context` in the contract's shape.
+
+ Takes the block the generator was shown rather than looking the passages up
+ again: sourced twice, the citation list and the prose can disagree while
+ each looks correct on its own. `quote` is verbatim by contract --
+ `grounding.check` compares against that exact string -- so it is passed
+ through untouched and only ever used as the claim, never reformatted.
+
+ Retrieval already happened, offline: s21 ran dense MedCPT retrieval with a
+ cross-encoder over the corpus, a human reviewed every key, and the result
+ was frozen. This is a dict lookup, which is why a fabricated citation is
+ structurally impossible here rather than merely unlikely.
+
+ An empty list is a real answer, not a failure: 9 of the 57 keys have no
+ admissible passage, and `select_evidence` skips those. `generator` is what
+ tells the two apart downstream.
+ """
+ return [
+ {
+ # Section included when the corpus knows it: a reader checking a
+ # claim needs where in the document, not just which document.
+ "source": f"{c['citation']} · {c['section']}" if c.get("section") else c["citation"],
+ "claim": c["quote"],
+ }
+ for c in context
+ ]
diff --git a/back-end/pythonService/model_runtime.py b/back-end/pythonService/model_runtime.py
index 9e2b33c..0d42a0b 100644
--- a/back-end/pythonService/model_runtime.py
+++ b/back-end/pythonService/model_runtime.py
@@ -16,6 +16,10 @@
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
+from time import perf_counter
+
+from stopwatch import NULL as NO_TIMINGS
+from stopwatch import Timings
from pipeline import config as C
from pipeline.core import bands as B
@@ -88,7 +92,12 @@ def __init__(self, depth: int, retry_after: int = 25) -> None:
def _worker() -> None:
while True:
- fn, args, future = _WORK.get()
+ fn, args, future, timings, queued_at = _WORK.get()
+ # THE WAIT IS MEASURED HERE because only this thread knows when the item
+ # came off the queue. Taken on the request thread it would be
+ # indistinguishable from the work itself, and queue wait is the one
+ # figure that says "the GPU was busy" rather than "the model is slow".
+ timings.add("queue", (perf_counter() - queued_at) * 1000.0)
if future.set_running_or_notify_cancel():
try:
future.set_result(fn(*args))
@@ -113,11 +122,18 @@ def _worker() -> None:
EXPLAIN_TIMEOUT_S = 240.0
-def _on_model_thread(fn, *args, timeout: float = SCORE_TIMEOUT_S):
- """Run fn on the one thread that is allowed to touch the model."""
+def _on_model_thread(fn, *args, timeout: float = SCORE_TIMEOUT_S,
+ timings: Timings = NO_TIMINGS):
+ """Run fn on the one thread that is allowed to touch the model.
+
+ `timings` rides in the queue tuple rather than in a ContextVar: the work
+ runs on the model thread, which is shared by every concurrent request, so a
+ thread-local would attribute one request's time to another. The request
+ thread is blocked on the future throughout, so the two never write at once.
+ """
future: Future = Future()
try:
- _WORK.put((fn, args, future), block=False)
+ _WORK.put((fn, args, future, timings, perf_counter()), block=False)
except queue.Full:
raise Overloaded(_WORK.qsize()) from None
return future.result(timeout=timeout)
@@ -132,13 +148,13 @@ def thread_alive() -> bool:
return _MODEL_THREAD.is_alive()
-def on_model_thread(fn, *args):
+def on_model_thread(fn, *args, timings: Timings = NO_TIMINGS):
"""Public: run anything that touches CUDA on the model thread.
The 7B comes through here too -- two CUDA contexts on two threads segfault
the interpreter (exit 139) mid-load. Generating blocks scoring for 18-23 s.
"""
- return _on_model_thread(fn, *args, timeout=EXPLAIN_TIMEOUT_S)
+ return _on_model_thread(fn, *args, timeout=EXPLAIN_TIMEOUT_S, timings=timings)
def _load() -> Runtime:
@@ -190,32 +206,48 @@ def snapshot(features: StayFeatures, stepper: B.BandStepper, origin: datetime) -
}
-def score(context: PatientContext, reading: Reading, state: dict | None) -> tuple[dict, dict]:
+def score(context: PatientContext, reading: Reading, state: dict | None,
+ timings: Timings = NO_TIMINGS) -> tuple[dict, dict]:
"""One reading in, one s17-shaped record and the next state out.
s17-shaped is the contract `verify.py` checks and `core/explain.py` consumes;
`contract.py` maps it to the display shape.
+
+ `timings` is passed twice on purpose: once so `_on_model_thread` can measure
+ the queue wait on the way in, and once as an argument to `_score` so the
+ stages inside it can measure themselves.
"""
- return _on_model_thread(_score, context, reading, state)
+ return _on_model_thread(_score, context, reading, state, timings,
+ timings=timings)
-def _score(context: PatientContext, reading: Reading, state: dict | None) -> tuple[dict, dict]:
+def _score(context: PatientContext, reading: Reading, state: dict | None,
+ timings: Timings = NO_TIMINGS) -> tuple[dict, dict]:
rt = _load()
- features, stepper = _restore_stay(context, state)
- origin = (datetime.fromisoformat(state["origin"]) if state
- else reading.observed_at)
-
- frame = features.push(reading)
- calibrated = float(rt.scorer.score(frame)[0])
- contributions, bias = rt.scorer.contributions(frame)
-
- # Minutes on a consistent origin, as BandStepper.push expects; the origin
- # travels in the snapshot so it survives a restart.
- minutes = (reading.observed_at - origin).total_seconds() / 60.0
- view = stepper.push(calibrated, minutes)
- attribution = R.attribution(frame, contributions[0], float(bias[0]), rt.assets)
- telemetry = R.telemetry_from_frame(frame, rt.assets)
+ # The span names are the stages of the published pipeline (docs figure 3):
+ # collect, order in time, assess, decide the level, explain. `rank` is the
+ # first half of explain -- ranking what drove the result -- and happens here
+ # because it needs the frame and the contributions, which do not leave.
+ with timings.span("order"):
+ features, stepper = _restore_stay(context, state)
+ origin = (datetime.fromisoformat(state["origin"]) if state
+ else reading.observed_at)
+ frame = features.push(reading)
+
+ with timings.span("assess"):
+ calibrated = float(rt.scorer.score(frame)[0])
+ contributions, bias = rt.scorer.contributions(frame)
+
+ with timings.span("decide"):
+ # Minutes on a consistent origin, as BandStepper.push expects; the origin
+ # travels in the snapshot so it survives a restart.
+ minutes = (reading.observed_at - origin).total_seconds() / 60.0
+ view = stepper.push(calibrated, minutes)
+
+ with timings.span("rank"):
+ attribution = R.attribution(frame, contributions[0], float(bias[0]), rt.assets)
+ telemetry = R.telemetry_from_frame(frame, rt.assets)
record = {
"schema_version": C.RISK_SCHEMA_VERSION,
diff --git a/back-end/pythonService/stopwatch.py b/back-end/pythonService/stopwatch.py
new file mode 100644
index 0000000..e168afc
--- /dev/null
+++ b/back-end/pythonService/stopwatch.py
@@ -0,0 +1,139 @@
+"""Measure what each pipeline stage actually costs, and report it in one header.
+
+Every duration this module carries is measured at the point the work happens.
+Nothing here estimates. A stage that did not run has NO entry, because a zero
+would be a fact the system does not have -- the same rule that keeps a defaulted
+clinician name out of an audit record (PM-CLIN-001).
+
+The output is W3C Server-Timing, so the numbers travel on the response that
+produced them and never enter `contract/clinical.ts`. Operational data in the
+clinical contract would also have to be declared in the Mongoose schema to
+survive a save (PM-VER-003), for figures nobody wants persisted.
+
+SPANS AGGREGATE BY NAME, and that is the whole reason this is a class rather
+than a dict of floats. `/ward/seed` scores 192 readings in one request and
+`/ward/tick` scores 8, so `assess` is entered once per bed per tick. Repeating
+the entry 192 times would be legal Server-Timing and unreadable; summing it and
+carrying the count says the same thing in one field.
+
+NOT THREAD-CONFINED. A `Timings` is created on the request thread and written to
+from the model thread, because that is where the work runs (`model_runtime`).
+The request thread blocks on the future while that happens, so the two never
+write at once -- but the lock is kept anyway, since the cost is nothing and the
+failure it prevents is one request's time being attributed to another.
+"""
+from __future__ import annotations
+
+import re
+import threading
+from contextlib import contextmanager
+from time import perf_counter
+
+# A Server-Timing name is an RFC 9110 token. Anything else has to be rejected
+# here rather than emitted, because a malformed header does not fail loudly --
+# the browser simply drops the entry and the panel shows a stage that silently
+# never appears.
+_TOKEN = re.compile(r"^[A-Za-z0-9!#$%&'*+.^_`|~-]+$")
+
+
+class _Span:
+ __slots__ = ("ms", "count", "desc")
+
+ def __init__(self) -> None:
+ self.ms: float = 0.0
+ self.count: int = 0
+ self.desc: str | None = None
+
+
+def _quote(text: str) -> str:
+ """A Server-Timing desc is a quoted-string: escape the two chars that end it."""
+ return '"' + str(text).replace("\\", "\\\\").replace('"', '\\"') + '"'
+
+
+class Timings:
+ """Named spans for one request, rendered as one Server-Timing header."""
+
+ def __init__(self) -> None:
+ # Insertion-ordered, so the header reads in the order the pipeline ran.
+ self._spans: dict[str, _Span] = {}
+ self._lock = threading.Lock()
+
+ def add(self, name: str, ms: float, desc: str | None = None) -> None:
+ """Record a measured duration in milliseconds. Repeats accumulate."""
+ if not _TOKEN.match(name):
+ raise ValueError(f"{name!r} is not a valid Server-Timing name")
+ with self._lock:
+ span = self._spans.setdefault(name, _Span())
+ span.ms += ms
+ span.count += 1
+ if desc is not None:
+ span.desc = desc
+
+ def mark(self, name: str, desc: str) -> None:
+ """Record a non-duration observation -- queue depth, a device, a model id.
+
+ Kept distinct from `add` so it cannot be mistaken for a timing: it emits
+ a `desc` with no `dur`, which is what the spec is for.
+ """
+ if not _TOKEN.match(name):
+ raise ValueError(f"{name!r} is not a valid Server-Timing name")
+ with self._lock:
+ span = self._spans.setdefault(name, _Span())
+ span.desc = desc
+
+ @contextmanager
+ def span(self, name: str, desc: str | None = None):
+ """Time a block. `perf_counter`, never `time()` -- this is an interval."""
+ started = perf_counter()
+ try:
+ yield
+ finally:
+ self.add(name, (perf_counter() - started) * 1000.0, desc)
+
+ def to_header(self) -> str:
+ """Render W3C Server-Timing. Empty string when nothing was measured.
+
+ An empty string is the honest answer for a request that did no
+ instrumented work; the caller must not set the header at all in that
+ case, so a reader never sees an entry that means nothing.
+ """
+ with self._lock:
+ parts = []
+ for name, span in self._spans.items():
+ entry = name
+ if span.count:
+ entry += f";dur={span.ms:.3f}"
+ desc = span.desc
+ # A span entered more than once is a sum, and saying so is the
+ # difference between "scoring took 432 ms" and "scoring took
+ # 432 ms across 8 readings". Only one of those is true.
+ if desc is None and span.count > 1:
+ desc = f"{span.count}x"
+ elif desc is not None and span.count > 1:
+ desc = f"{desc}, {span.count}x"
+ if desc is not None:
+ entry += f";desc={_quote(desc)}"
+ parts.append(entry)
+ return ", ".join(parts)
+
+
+class _NullTimings(Timings):
+ """Accepts every call and records nothing.
+
+ So an instrumented function can be called by something that does not want
+ timings without either allocating a collector per call -- `/ward/seed`
+ scores 192 readings in one request -- or scattering `if timings is not None`
+ through the scoring path, where a missed branch would be a crash rather than
+ a missing measurement.
+ """
+
+ def add(self, name: str, ms: float, desc: str | None = None) -> None:
+ pass
+
+ def mark(self, name: str, desc: str) -> None:
+ pass
+
+
+#: Shared, immutable in effect: it holds nothing, so it cannot leak one
+#: request's measurements into another's.
+NULL = _NullTimings()
diff --git a/back-end/pythonService/synthetic_ward.py b/back-end/pythonService/synthetic_ward.py
index c6d7e82..8841f27 100644
--- a/back-end/pythonService/synthetic_ward.py
+++ b/back-end/pythonService/synthetic_ward.py
@@ -21,7 +21,7 @@
from pipeline.core.features import Infusion, PatientContext, Reading, ServingAssets
-TICK = timedelta(hours=1) # matches the 60-minute grid the dwell was fitted on
+TICK = timedelta(hours=1) # the 60-minute grid the dwell is denominated in
ALL_PARAMS = ("spo2", "fio2", "flow_rate", "peep", "pip", "respiratory_rate_total",
"minute_volume", "tidal_volume_observed", "etco2",
"inspiratory_ratio", "expiratory_ratio")
diff --git a/back-end/routes/api/assessment.js b/back-end/routes/api/assessment.js
index c62e7b8..5c63355 100644
--- a/back-end/routes/api/assessment.js
+++ b/back-end/routes/api/assessment.js
@@ -16,6 +16,7 @@ const h = asyncHandler;
router.get('/ward', h(assessmentController.getWard));
router.post('/ward/seed', h(assessmentController.seedWard));
router.post('/ward/tick', h(assessmentController.tickWard));
+router.post('/ward/warmup', h(assessmentController.warmExplainer));
router.get('/patient/:patientId', h(assessmentController.getPatient));
router.get('/patient/:patientId/history', h(assessmentController.getHistory));
diff --git a/back-end/server.js b/back-end/server.js
index 5de5b1f..277b245 100644
--- a/back-end/server.js
+++ b/back-end/server.js
@@ -1,4 +1,8 @@
-require('dotenv').config();
+// Anchored to THIS file, not to process.cwd(). dotenv resolves a bare `.env`
+// against the working directory, so `node back-end/server.js` from the repo
+// root loaded nothing and mongoose rejected an undefined MONGODB_URI. With
+// the path pinned, the server runs from any directory.
+require('dotenv').config({ path: require('path').join(__dirname, '.env') });
const express = require('express');
const app = express();
const path = require('path');
diff --git a/checks/README.md b/checks/README.md
index d2b6afd..adf840e 100644
--- a/checks/README.md
+++ b/checks/README.md
@@ -39,6 +39,13 @@ one); the board is always a list, never a 204 with a stripped body; a refusal ca
prompt field; no cohort default carries an age; no contributor is both `documentation` and
`is_imputed`.
+**B4** adds the telemetry contract, and one of its assertions is not a formality. The
+`mongo` span is recorded by a Mongoose plugin that resolves the current response out of an
+`AsyncLocalStorage` store — if that context did not survive the driver's async boundary the
+plugin would find nothing and record nothing, **silently, with every other check still
+green**. `GET /ward` reads Mongo and never touches the model service, so this section proves
+it without a GPU.
+
**`check_conventions.py`** — the API conventions, tested by their failure modes: an absurd
`backfill_ticks` is refused rather than freezing the board; an unknown field is rejected
rather than silently dropped; `/healthz` answers *while* the model thread is busy; a
@@ -47,18 +54,31 @@ saturated queue returns 503 with `Retry-After`; an upstream 422 surfaces as 4xx
records `attributed: false`; every response carries a distinct request id; and no patient
identifier reaches the request log.
-**`check_service.py`** — seed → tick → explain, 17 assertions. The one that matters most:
+**`check_service.py`** — seed → tick → explain, 35 assertions. The one that matters most:
the explanation's dwell phrase must equal the stored assessment's `readings_in_state`. If
it does not, the service re-scored the reading instead of explaining the stored one, and
grounding cannot catch that because both sides of the check would be wrong together.
+Six of the 35 cover the citation contract, and the read-back is the point rather than the
+write: the passages live on the **explanation**, not the assessment, so Mongoose strict mode
+will silently drop them if `explanationSchema` ever stops declaring them — which is how a
+field can reach the API and vanish on save.
+
+Twelve more (**A5**) cover the `Server-Timing` spans behind the pipeline panel. Two of those
+are the ones with teeth. `upstream >= the stages it contains` checks that the span tree the
+panel indents is actually a tree — inverted, a reader would be adding a 26 s generation to
+the 26 s request that contains it. And `stages that did not run are absent, not zero` is
+PM-CLIN-001 applied to a measurement: `/ward/tick` never explains, so `explain` and `load`
+must not appear at all. A `0 ms` beside a stage name is indistinguishable from a real
+measurement of a fast one, so a failed measurement would read as a successful one.
+
**`floor_margin.py`** — the bed that demonstrates the data-sufficiency floor refuses on
*every* reading, with margin. It once refused on some and published LOW on others while its
inputs were identical throughout, because the deciding share tracks where the model's
attribution landed rather than how many inputs were missing.
**`check_llm.py`** — the 7B path end to end: grounding passed, and the stored band, dwell
-and score are unchanged by explaining. Cold load is ~40 s, warm ~13 s, and the text is
+and score are unchanged by explaining. Cold load is ~40 s through the service, and the text is
byte-identical across runs by design.
⚠️ **This leaves ~4.7 GB of VRAM occupied until you stop the model service.** The model is
@@ -73,7 +93,7 @@ netstat -ano | findstr :8000
## Expected
-`check_service.py` 17/17 · `check_llm.py` 10/10 · `floor_margin.py` silent on every reading
+`check_service.py` 23/23 · `check_llm.py` 10/10 · `floor_margin.py` silent on every reading
· `check_node.py` and `secrets_gate.py` all pass.
An assertion that silently skips reports as a pass. If a count drops, something stopped
diff --git a/checks/check_llm.py b/checks/check_llm.py
index 9967d87..137bf08 100644
--- a/checks/check_llm.py
+++ b/checks/check_llm.py
@@ -53,7 +53,7 @@ def show(label, expected, got, extra=""):
print(f"stored contributors: "
f"{[c['feature_name'] for c in target['contributors'][:3]]}")
-print("\ngenerating with the 7B (cold load is ~23 s before the first token)")
+print("\ngenerating with the 7B (cold load is ~40 s through the service before the first token)")
started = time.perf_counter()
status, out = call("POST", f"/patient/{patient}/explain", {})
elapsed = time.perf_counter() - started
diff --git a/checks/check_node.py b/checks/check_node.py
index eb1f317..8d817bd 100644
--- a/checks/check_node.py
+++ b/checks/check_node.py
@@ -1,5 +1,6 @@
"""Node-layer regression checks. No GPU, no model service required for B1/B3."""
import json
+import re
import urllib.error
import urllib.request
@@ -22,6 +23,40 @@ def call(method, path, body=None):
return failure.code, raw.decode(errors="replace")[:200]
+def call_headers(method, path, headers=None):
+ """As `call`, but keeps the response headers. Server-Timing lives there."""
+ request = urllib.request.Request(BASE + path, method=method, headers=headers or {})
+ try:
+ with urllib.request.urlopen(request, timeout=310) as response:
+ return response.status, dict(response.headers)
+ except urllib.error.HTTPError as failure:
+ return failure.code, dict(failure.headers)
+
+
+def spans(header):
+ """Parse W3C Server-Timing into {name: dur-or-None}.
+
+ `None` means the entry carried no `dur` -- an observation, not a timing.
+ Distinct from 0.0, which would be a measured duration of zero.
+ """
+ out = {}
+ for entry in re.findall(r'(?:[^,"]|"(?:\\.|[^"\\])*")+', header or ""):
+ parts = entry.strip().split(";")
+ name = parts[0].strip()
+ if not name:
+ continue
+ dur = None
+ for part in parts[1:]:
+ key, _, value = part.partition("=")
+ if key.strip().lower() == "dur":
+ try:
+ dur = float(value.strip())
+ except ValueError:
+ dur = None
+ out[name] = dur
+ return out
+
+
def show(label, expected, got, extra=""):
ok = "PASS" if got == expected else "FAIL"
print(f" [{ok}] {label}: expected {expected}, got {got} {extra}")
@@ -81,5 +116,46 @@ def show(label, expected, got, extra=""):
if c["kind"] == "documentation" and c["is_imputed"]]
results.append(show("documentation + is_imputed never co-occur", [], impossible[:5]))
+print("\nB4 -- the telemetry contract: measured, correlated, and not invented")
+# GET /ward reads Mongo and never touches the model service, so this whole
+# section runs without a GPU.
+status, headers = call_headers("GET", "/ward", {"X-Request-Id": "check-node-b4"})
+results.append(show("GET /ward answers", 200, status))
+timing = spans(headers.get("Server-Timing", ""))
+print(f" Server-Timing: {headers.get('Server-Timing', '(absent)')}")
+
+results.append(show("Server-Timing header is present", True, "Server-Timing" in headers))
+results.append(show("`total` span present with a duration", True,
+ isinstance(timing.get("total"), float)))
+
+# ⚠️ THE ONE THAT IS NOT A FORMALITY. `mongo` is recorded by a Mongoose plugin
+# that resolves the current response out of an AsyncLocalStorage store. If that
+# context did not survive the driver's async boundary the plugin would find
+# nothing and record nothing -- silently, with every other check still green.
+# This is the assertion that distinguishes "it works" from "it did not throw".
+results.append(show("`mongo` span present -- ALS survived the driver", True,
+ isinstance(timing.get("mongo"), float)))
+
+results.append(show("the supplied request id is echoed", "check-node-b4",
+ headers.get("X-Request-Id")))
+
+# ⚠️ CONTAINMENT. The panel indents `mongo` inside `total` to show what nests in
+# what, so a child larger than its parent is not an overstated number, it is a
+# tree that is not one. This fired for real on 2026-08-22: `GET /ward` issues its
+# eight reads concurrently and summing them reported mongo 1326ms inside a 578ms
+# request. The middleware unions the intervals now; this is what would catch a
+# return to summing.
+mongo, total = timing.get("mongo"), timing.get("total")
+results.append(show("mongo is contained by total", True,
+ isinstance(mongo, float) and isinstance(total, float)
+ and mongo <= total + 1.0,
+ f"(mongo {mongo}, total {total})"))
+
+# A duration of exactly zero for a stage that ran is not credible at millisecond
+# resolution, and it is what a broken measurement looks like. Absent is fine --
+# a stage that did not run has no span at all -- but present-and-zero is not.
+zeroed = [name for name, dur in timing.items() if dur == 0.0]
+results.append(show("no span reports a duration of exactly zero", [], zeroed))
+
print(f"\n{sum(results)}/{len(results)} checks passed")
raise SystemExit(0 if all(results) else 1)
diff --git a/checks/check_service.py b/checks/check_service.py
index 55f0695..119b88b 100644
--- a/checks/check_service.py
+++ b/checks/check_service.py
@@ -30,6 +30,44 @@ def call(base, method, path, body=None, timeout=400):
return failure.code, raw.decode(errors="replace")[:300]
+def headers_of(base, method, path, body=None, timeout=400):
+ """As `call`, but keeps the response headers. Server-Timing lives there."""
+ data = json.dumps(body).encode() if body is not None else None
+ request = urllib.request.Request(
+ base + path, data=data, method=method,
+ headers={"Content-Type": "application/json"})
+ try:
+ with urllib.request.urlopen(request, timeout=timeout) as response:
+ return response.status, dict(response.headers)
+ except urllib.error.HTTPError as failure:
+ return failure.code, dict(failure.headers)
+
+
+def spans(header):
+ """Parse W3C Server-Timing into {name: dur-or-None}.
+
+ `None` means the entry carried no `dur` -- an observation, not a timing.
+ Distinct from 0.0, which would be a measured duration of zero, which is why
+ the checks below test `isinstance(..., float)` rather than truthiness.
+ """
+ out = {}
+ for entry in re.findall(r'(?:[^,"]|"(?:\\.|[^"\\])*")+', header or ""):
+ parts = entry.strip().split(";")
+ name = parts[0].strip()
+ if not name:
+ continue
+ dur = None
+ for part in parts[1:]:
+ key, _, value = part.partition("=")
+ if key.strip().lower() == "dur":
+ try:
+ dur = float(value.strip())
+ except ValueError:
+ dur = None
+ out[name] = dur
+ return out
+
+
results = []
@@ -127,6 +165,29 @@ def show(label, expected, got, extra=""):
show("band named in the text matches the stored band", True,
target["risk_level"] in text, f"({target['risk_level']})")
+ # A5 -- the citations belong to the EXPLANATION, and survive the round-trip.
+ #
+ # `use_llm=False` is the template floor, which does not consult the guideline
+ # library: an empty list here is the CORRECT answer, and `generator` is the
+ # only field that says so rather than leaving it read as a shortfall.
+ #
+ # The read-back is the point. Mongoose strict mode drops undeclared keys, so
+ # a field the API returns can still vanish on save -- that has cost a full
+ # verification round before, on `RiskContributor.parameter`.
+ print()
+ show("the explanation names its generator", "template", explained.get("generator"))
+ show("the template floor cites nothing", [], explained.get("citations"))
+ show("the write was actually stored", True, explained.get("stored"))
+
+ status, reloaded = call(NODE, "GET", f"/patient/{patient}")
+ stored_expl = reloaded.get("explanation") or {}
+ show("generator survives the round-trip", "template", stored_expl.get("generator"))
+ show("citations survive as a declared field", True, "citations" in stored_expl,
+ f"({stored_expl.get('citations')!r})")
+ # Moved off the assessment: the passages are a property of the explanation
+ # they grounded, and a field left there with no writer reads as "RAG is dead".
+ show("the assessment carries no citations of its own", False, "citations" in reloaded)
+
print("\nA1/A2 -- a disposition round-trips")
prompted = [a for a in scored if (a.get("prompt") or {}).get("status") == "open"]
if not prompted:
@@ -140,5 +201,44 @@ def show(label, expected, got, extra=""):
show("review readable back", "escalated", (again.get("review") or {}).get("disposition"))
show("prompt now reviewed", "reviewed", (again.get("prompt") or {}).get("status"))
+print("\nA5 -- the pipeline stages are MEASURED, not asserted")
+# The scoring latency this project quotes in three docstrings was never computed
+# anywhere; these spans are the first time it is. So the check is not "is there a
+# number" but "did the tier that did the work produce it".
+_, tick_headers = headers_of(NODE, "POST", "/ward/tick")
+timing = spans(tick_headers.get("Server-Timing", ""))
+print(f" {tick_headers.get('Server-Timing', '(absent)')}")
+
+for stage in ("collect", "order", "assess", "decide", "rank"):
+ show(f"`{stage}` measured on a tick", True, isinstance(timing.get(stage), float))
+show("`upstream` measured at the Node hop", True, isinstance(timing.get("upstream"), float))
+show("`queue` wait measured on the model thread", True, isinstance(timing.get("queue"), float))
+
+# NESTING. `upstream` contains every model-service stage, so it can only be
+# larger. Inverted, the panel would be drawing a tree that is not one -- and the
+# indentation is what stops a reader adding a 26 s generation to a 26 s request.
+inner = sum(timing[s] for s in ("collect", "order", "assess", "decide", "rank", "queue")
+ if isinstance(timing.get(s), float))
+show("upstream >= the stages it contains", True,
+ isinstance(timing.get("upstream"), float) and timing["upstream"] >= inner,
+ f"(upstream {timing.get('upstream')}, stages {inner:.1f})")
+
+# The whole point of PM-CLIN-001 here: a stage that did not run must be ABSENT.
+# `/ward/tick` never explains, so these must not appear at all -- not as zero.
+absent = [s for s in ("explain", "ground", "load", "baseline", "floor") if s in timing]
+show("stages that did not run are absent, not zero", [], absent)
+
+print("\nA5 -- the template path reports its own generation time")
+_, expl_headers = headers_of(
+ NODE, "POST", f"/patient/{patient}/explain", {"use_llm": False})
+expl_timing = spans(expl_headers.get("Server-Timing", ""))
+print(f" {expl_headers.get('Server-Timing', '(absent)')}")
+show("`baseline` measured on the template path", True,
+ isinstance(expl_timing.get("baseline"), float))
+show("`ground` measured on the template path", True,
+ isinstance(expl_timing.get("ground"), float))
+show("the 7B was not loaded for a template explanation", [],
+ [s for s in ("load", "explain") if s in expl_timing])
+
print(f"\n{sum(results)}/{len(results)} checks passed")
raise SystemExit(0 if all(results) else 1)
diff --git a/contract/clinical.ts b/contract/clinical.ts
index 18e206f..a061fff 100644
--- a/contract/clinical.ts
+++ b/contract/clinical.ts
@@ -108,6 +108,23 @@ export interface Explanation {
/** Fixed strings when unavailable — never substitute generated prose. */
explanation_text: string
grounding_status: GroundingStatus
+ /**
+ * Which writer produced the text: the model id, `'template'` for the
+ * deterministic floor, or null when nothing was generated. Not decoration —
+ * an empty `citations` list means three different things depending on this
+ * field, and only one of them is a shortfall.
+ */
+ generator: string | null
+ /**
+ * The approved passages the generator was SHOWN, travelling with the text
+ * they grounded. Retrieval happened offline: dense MedCPT with a
+ * cross-encoder over the corpus, human-reviewed and frozen, so a fabricated
+ * citation is structurally impossible rather than merely unlikely.
+ *
+ * Empty is a real answer. The template never consults the library, and 9 of
+ * the 57 keys have no admissible passage.
+ */
+ citations: Citation[]
}
export interface RiskPrompt {
@@ -121,7 +138,15 @@ export interface RiskPrompt {
export interface ClinicianReview {
disposition: Disposition
note: string | null
+ /** The real instant the clinician acted. Wall clock, always. */
reviewed_at: string
+ /**
+ * What the ward's clock read at that moment, when it differs. A simulated
+ * tick advances the ward an hour, so a disposition can be recorded hours
+ * "before" the prompt it answers. Kept as a separate fact: putting the ward's
+ * time into `reviewed_at` would record an instant at which nothing happened.
+ */
+ ward_time_at_review: string | null
/**
* The authenticated principal, or null when there is none. Never supplied by
* the caller — a disposition that names whoever asked for it is not an audit
@@ -171,7 +196,6 @@ export interface ScoredAssessment extends AssessmentBase {
readings_in_state: number
contributors: RiskContributor[]
explanation: Explanation | null
- citations: Citation[]
prompt: RiskPrompt | null
review: ClinicianReview | null
/** Provenance, so a stored assessment traces to what produced it. */
diff --git a/front-end/src/App.tsx b/front-end/src/App.tsx
index b98b8dc..fd94818 100644
--- a/front-end/src/App.tsx
+++ b/front-end/src/App.tsx
@@ -1,32 +1,72 @@
+import { useState } from 'react'
import { Navigate, Route, Routes } from 'react-router'
import { WardProvider } from './data/WardProvider'
import { AppHeader } from './components/chrome/AppHeader'
import { ErrorBoundary } from './components/chrome/ErrorBoundary'
import { SafetyFooter } from './components/chrome/SafetyFooter'
+import { SimulationBar } from './components/chrome/SimulationBar'
+import { TelemetryDock } from './components/chrome/TelemetryDock'
import { PatientOverviewBoard } from './screens/PatientOverviewBoard'
import { PatientDetail } from './screens/PatientDetail'
import { ParameterDetail } from './screens/ParameterDetail'
+/**
+ * The shell.
+ *
+ * AT `xl` AND ABOVE THE PAGE DOES NOT SCROLL. It is exactly one viewport tall,
+ * and the board fills what is left between the chrome; anything long scrolls
+ * inside its own pane. A ward board whose beds run off the bottom fails at the
+ * one job it has, and ICU staff are interrupted often enough that a glance must
+ * not begin with a scroll.
+ *
+ * Below `xl` this stays an ordinary scrolling page: the aside already stacks
+ * under the triage list there, and a small-screen no-scroll layout would be a
+ * different design rather than this one made narrow.
+ *
+ * Two things are load-bearing here.
+ *
+ * `min-h-0` on `main`: a flex child's default `min-height:auto` refuses to
+ * shrink below its content, so without it the pane grows to fit the board and
+ * the page scrolls again — the overflow rule never gets the chance to apply.
+ *
+ * `fixed inset-0` rather than `h-[100dvh]`: height alone left the document with
+ * a scroll range of its own on the long patient screen, so the window scrolled
+ * AND the pane scrolled, which is worse than either. Out of flow, `body` has no
+ * content height and the document cannot scroll at all — the panes are the only
+ * things that can.
+ */
export default function App() {
+ // Lifted out of SimulationBar because the control and the panel are on
+ // opposite sides of . The control lives in the chrome that already
+ // exists, so a closed dock costs the board no height at all.
+ const [pipelineOpen, setPipelineOpen] = useState(false)
+
return (
-
)
}
diff --git a/front-end/src/components/board/DataLimitedRow.tsx b/front-end/src/components/board/DataLimitedRow.tsx
index a1ac27c..370d3f0 100644
--- a/front-end/src/components/board/DataLimitedRow.tsx
+++ b/front-end/src/components/board/DataLimitedRow.tsx
@@ -37,9 +37,13 @@ export function DataLimitedRow({ assessment, selected, onSelect, now }: DataLimi
type="button"
onClick={onSelect}
aria-pressed={selected}
- className="flex min-w-0 flex-1 flex-wrap items-center gap-x-5 gap-y-2 px-3 py-2.5 text-left sm:px-4"
+ className="flex min-w-0 flex-1 flex-wrap items-center gap-x-5 gap-y-2 px-3 py-1.5 text-left sm:px-4"
>
-
+ {/* Bed and patient on ONE baseline. Both are identifiers for the same
+ person, and stacked they were 39px — the row's binding height once
+ the score was inlined. Eight rows of it was the difference between
+ a board showing six beds and one showing all eight. */}
+
{assessment.bed_code}
@@ -71,7 +75,7 @@ export function DataLimitedRow({ assessment, selected, onSelect, now }: DataLimi
aria-label={`Open ${assessment.bed_code}, patient ${assessment.patient_id}`}
>
Open
-
+
diff --git a/front-end/src/components/board/InputStatusPanel.tsx b/front-end/src/components/board/InputStatusPanel.tsx
index 9167d15..8ff710e 100644
--- a/front-end/src/components/board/InputStatusPanel.tsx
+++ b/front-end/src/components/board/InputStatusPanel.tsx
@@ -1,3 +1,5 @@
+import { useEffect, useState } from 'react'
+import { ChevronRight } from 'lucide-react'
import type { DeviceState, InputDevice } from '@contract/clinical'
import { useWard } from '../../data/WardProvider'
import { cn } from '../../lib/cn'
@@ -29,8 +31,23 @@ const STATE_LABEL: Record = {
* beside a real make and model. PulseMind writes nothing to these sources.
*/
export function InputStatusPanel({ devices, now, patientId }: InputStatusPanelProps) {
- const { toggleDevice } = useWard()
+ const { toggleDevice, setDevices } = useWard()
const offline = devices.filter((device) => device.state === 'offline')
+ // Collapsed by default, and it STAYS where you put it. Not a hover reveal and
+ // nothing that closes itself: every glance at this screen is a resumption, so
+ // content that appears and disappears on its own is content a returning nurse
+ // cannot rely on.
+ //
+ // A dropped source OPENS it; it does not PIN it open. `open = state ||
+ // offline.length > 0` made the header button inert in the one state this
+ // comment says it cares about — the chevron would not rotate back and
+ // `aria-expanded` was stuck true, so the control read as broken to a mouse
+ // and lied to a screen reader.
+ const [open, setOpen] = useState(false)
+ const hasOffline = offline.length > 0
+ useEffect(() => {
+ if (hasOffline) setOpen(true)
+ }, [hasOffline])
return (
@@ -72,11 +89,27 @@ export function InputStatusPanel({ devices, now, patientId }: InputStatusPanelPr
-
Simulate source loss
+
{offline.length > 0 && (
+ {open && (
+ <>
{devices.map((device) => (
)
diff --git a/front-end/src/components/board/PatientRow.tsx b/front-end/src/components/board/PatientRow.tsx
index b33a8a6..2b80d4e 100644
--- a/front-end/src/components/board/PatientRow.tsx
+++ b/front-end/src/components/board/PatientRow.tsx
@@ -43,18 +43,42 @@ export function PatientRow({ assessment, selected, onSelect, now }: PatientRowPr
type="button"
onClick={onSelect}
aria-pressed={selected}
- className="flex min-w-0 flex-1 flex-wrap items-center gap-x-5 gap-y-2 px-3 py-2.5 text-left sm:px-4"
+ className="flex min-w-0 flex-1 flex-wrap items-center gap-x-5 gap-y-2 px-3 py-1.5 text-left sm:px-4"
>
-
+ {/* Bed and patient on ONE baseline. Both are identifiers for the same
+ person, and stacked they were 39px — the row's binding height once
+ the score was inlined. Eight rows of it was the difference between
+ a board showing six beds and one showing all eight. */}
+
{assessment.bed_code}
{assessment.patient_id}
-
+ {/* A FIXED COLUMN, not a shrink-wrapped tag. The four band names are
+ different lengths, so the pill ran 74.6px at LOW to 106.5px at
+ CRITICAL — and since everything after it is laid out in source
+ order, the score numeral started at four different x positions down
+ a single board (378, 383, 403, 410). A column of numbers that does
+ not form a column is the one thing a triage board cannot afford:
+ the eye scans down it.
-
+ The width is the widest tag plus slack, and the pill keeps its
+ natural size inside it — stretching the pill itself would put a
+ wide LOW badge next to a wide CRITICAL one and imply they carry the
+ same weight. */}
+
+
+
+
+ {/* Numeral and caption on ONE baseline, not stacked. Stacked, this was
+ 44px and the tallest thing in the row — it, not the bed/patient
+ pair, set the row height, and eight of them pushed two beds off a
+ board whose whole job is showing all of them at once. Nothing is
+ lost: the word still labels the number, beside it instead of under
+ it. */}
+
{formatScore(assessment.risk_score)}
@@ -89,7 +113,7 @@ export function PatientRow({ assessment, selected, onSelect, now }: PatientRowPr
aria-label={`Open ${assessment.bed_code}, patient ${assessment.patient_id}`}
>
Open
-
+
diff --git a/front-end/src/components/board/SelectedPatientPanel.tsx b/front-end/src/components/board/SelectedPatientPanel.tsx
index 1d63b8d..87b5fdb 100644
--- a/front-end/src/components/board/SelectedPatientPanel.tsx
+++ b/front-end/src/components/board/SelectedPatientPanel.tsx
@@ -4,6 +4,7 @@ import type { Assessment } from '@contract/clinical'
import { isScored } from '@contract/clinical'
import { SUFFICIENCY_FLOOR, toObservations } from '../../data/feed'
import { usePatientHistory } from '../../hooks/useApi'
+import { useWard } from '../../data/WardProvider'
import { bandMeaning } from '../../data/bands'
import { formatPercent, formatScore } from '../../lib/format'
import { BandTag } from '../ui/BandTag'
@@ -19,7 +20,10 @@ interface SelectedPatientPanelProps {
/** The side panel: what the board's selected patient looks like up close. */
export function SelectedPatientPanel({ assessment }: SelectedPatientPanelProps) {
- const { data: history } = usePatientHistory(assessment.patient_id)
+ // Refetched whenever the ward is re-read, so the strip cannot disagree with
+ // the board beside it.
+ const { revision } = useWard()
+ const { data: history } = usePatientHistory(assessment.patient_id, revision)
return (
Selected patient
@@ -92,7 +96,7 @@ export function SelectedPatientPanel({ assessment }: SelectedPatientPanelProps)
className="mt-5 flex w-full items-center justify-center gap-2 rounded-[2px] bg-ink-950 px-4 py-2.5 text-sm font-medium text-surface transition-colors hover:bg-accent"
>
Open patient detail
-
+
)
diff --git a/front-end/src/components/board/WardScale.tsx b/front-end/src/components/board/WardScale.tsx
index 2415ba3..13e94ca 100644
--- a/front-end/src/components/board/WardScale.tsx
+++ b/front-end/src/components/board/WardScale.tsx
@@ -1,8 +1,11 @@
+import { Fragment } from 'react'
import type { ScoredAssessment } from '@contract/clinical'
import { BANDS } from '../../data/bands'
import { BAND_STYLES } from '../../lib/bandStyles'
import { cn } from '../../lib/cn'
import { formatScore } from '../../lib/format'
+import { placeLabels } from '../../lib/labelPlacement'
+import { useMeasuredWidth } from '../../hooks/useMeasuredWidth'
interface WardScaleProps {
patients: ScoredAssessment[]
@@ -10,31 +13,51 @@ interface WardScaleProps {
onSelect: (patientId: string) => void
}
-/** Marks closer together than this share a row, so their labels would collide. */
-const COLLISION_DISTANCE = 0.075
-const LABEL_ROWS = 3
+/**
+ * Vertical distance between label rows, as a multiple of a label's own height.
+ *
+ * ⚠️ DERIVED FROM THE MEASURED LABEL, not from a constant and not from the root
+ * font size. A fixed 20px was correct beside an 11px label and left ONE pixel of
+ * clearance beside a 19px one — measured in the running app after the type scale
+ * grew. And deriving it from `rem` failed too: read at module load the root is
+ * still the browser default, because Vite injects the stylesheet after the
+ * modules evaluate. The label is already measured for its width; its height is
+ * the thing the pitch has to clear, so measuring that settles it at any scale.
+ */
+const ROW_PITCH_RATIO = 1.2
+
+/** Clear space between the lowest label row and the axis, as a multiple of the
+ * row pitch. Tall enough that a displaced leader line leans rather than lies
+ * flat: the widest travel measured on this ward is 63px, which over ~22px reads
+ * as a line and not as a rule. */
+const LEADER_RATIO = 1.1
+
+/** Used only until the probe reports, on the very first paint. */
+const FALLBACK_PITCH = 20
/**
- * Assign each mark to a label row so nearby bed codes do not overlap.
- * Patients are taken in score order and pushed up a row while they are too close to
- * the previous one.
+ * A reading moves a bed along the axis; it must travel there rather than appear
+ * there. A band change is the moment the board exists to show, and a mark that
+ * teleports across a cut reads as a redraw instead of a patient deteriorating.
+ *
+ * Inline rather than a utility class because all three layers must carry
+ * identical timing — a label arriving before its own leader line reads as a
+ * glitch. 700 ms sits under the fastest cadence, so a mark is visibly at rest
+ * before the next reading moves it. `prefers-reduced-motion` is handled once,
+ * globally, in index.css, and a stylesheet `!important` beats an inline style,
+ * so these need no guard of their own.
+ *
+ * Position only. Colour is NOT transitioned and never was: `transition` is not
+ * inherited, these sit on the button, and every colour lives on its child spans.
+ *
+ * This only animates because every mark is keyed by `patient_id`. Key these by
+ * index and the ranked re-sort swaps element identity on each tick, which the
+ * browser renders as marks jumping between beds.
*/
-function assignRows(patients: ScoredAssessment[]): Map {
- const ordered = [...patients].sort((a, b) => a.risk_score - b.risk_score)
- const rows = new Map()
- const lastScoreInRow: number[] = new Array(LABEL_ROWS).fill(-Infinity)
-
- for (const patient of ordered) {
- let row = 0
- while (row < LABEL_ROWS - 1 && patient.risk_score - lastScoreInRow[row] < COLLISION_DISTANCE) {
- row += 1
- }
- lastScoreInRow[row] = patient.risk_score
- rows.set(patient.patient_id, row)
- }
-
- return rows
-}
+const EASE = 'cubic-bezier(0, 0, 0.2, 1)'
+const SLIDE = `left 700ms ${EASE}`
+const SLIDE_LABEL = `${SLIDE}, bottom 700ms ${EASE}`
+const SLIDE_LEADER = `${SLIDE}, height 700ms ${EASE}, transform 700ms ${EASE}`
/**
* The ward on one calibrated axis.
@@ -45,36 +68,150 @@ function assignRows(patients: ScoredAssessment[]): Map {
*
* It explains where a patient sits and never decides a band.
*/
-export function WardScale({ patients, selectedId, onSelect }: WardScaleProps) {
- const rows = assignRows(patients)
+export function WardScale({ patients: given, selectedId, onSelect }: WardScaleProps) {
+ const [trackRef, trackWidth] = useMeasuredWidth()
+ const [probeRef, labelWidth, labelHeight] = useMeasuredWidth()
+
+ const rowPitch = labelHeight > 0 ? labelHeight * ROW_PITCH_RATIO : FALLBACK_PITCH
+ const leaderHeight = rowPitch * LEADER_RATIO
+
+ // A non-finite score would place its label at `left: NaN%`, which the browser
+ // ignores — so the bed would sit at the far left looking like a real reading
+ // rather than a broken one. Drop it instead; a bed missing from the axis is
+ // visible, a bed lying about its position is not.
+ const patients = given.filter((p) => Number.isFinite(p.risk_score))
+
+ // Measured off a hidden copy rather than off the labels themselves, which
+ // would need a second render pass to place what the first pass just drew.
+ // Built from the longest bed code actually present, so a longer one later
+ // widens the probe instead of quietly under-reserving space.
+ const widestCode = patients.reduce(
+ (widest, p) => (p.bed_code.length > widest.length ? p.bed_code : widest),
+ 'ICU 00',
+ )
+
+ const { placements, rows } = placeLabels(
+ patients.map((p) => ({ id: p.patient_id, value: p.risk_score })),
+ trackWidth,
+ labelWidth,
+ )
+ const byId = new Map(placements.map((p) => [p.id, p]))
+ const labelsHeight = rows * rowPitch
+ const pct = (px: number) => (trackWidth > 0 ? (px / trackWidth) * 100 : 0)
return (
-
- {/* Bed labels, stacked into rows so nearby marks stay readable. */}
-
+
+ {/* Invisible but LAID OUT, so its width is the real rendered width of a
+ label at this font rather than an estimate. `visibility: hidden` and
+ not an off-screen offset: a negative `left` risks the horizontal
+ overflow this design guarantees against. */}
+
+ {widestCode}
+ 0.00
+
+
+ {/* Labels and their leader lines share one positioned box: a label on the
+ upper row needs a line that reaches down THROUGH the lower row, which
+ it cannot do from a sibling strip. */}
+
+ {patients.map((patient) => {
+ const placement = byId.get(patient.patient_id)
+ if (!placement) return null
+ const selected = patient.patient_id === selectedId
+
+ // TWO SEGMENTS, AND THE SPLIT IS WHAT STOPS THEM CROSSING.
+ //
+ // The diagonal fans every mark out to the SAME height, and the rows
+ // are climbed by a vertical riser above it. One straight line per
+ // label, drawn to its own row's height, was the earlier design and it
+ // crossed: `row = index % rows` cycles the rise, so two adjacent
+ // labels get very different slopes and one leader overtakes the other
+ // between the axis and the text — a clinician tracing a mark upward
+ // arrives at the wrong bed code. Measured on 29% of 200,000 clustered
+ // configurations; the evenly-spread demo ward happens never to show it.
+ //
+ // Why the split is sound rather than merely better: the diagonals all
+ // rise the same leaderHeight, and with `trueX` and `placedX` both
+ // non-decreasing the gap between two of them is linear in height and
+ // non-negative at both ends, so it cannot change sign between them.
+ // The risers sit at distinct `placedX` and live entirely above the
+ // diagonals, so they meet neither each other nor a diagonal.
+ //
+ // The diagonal is anchored at the mark and rotated about its own foot,
+ // so its head lands on the label's column by construction: rotation
+ // atan(shift / leaderHeight), length hypot(shift, leaderHeight).
+ const shift = placement.placedX - placement.trueX
+ const length = Math.hypot(shift, leaderHeight)
+ const angle = (Math.atan2(shift, leaderHeight) * 180) / Math.PI
+ const riser = placement.row * rowPitch
+
+ return (
+
+
+ {riser > 0 && (
+
+ )}
+
+ )
+ })}
+
{patients.map((patient) => {
- const row = rows.get(patient.patient_id) ?? 0
+ const placement = byId.get(patient.patient_id)
+ if (!placement) return null
const selected = patient.patient_id === selectedId
return (
@@ -82,21 +219,6 @@ export function WardScale({ patients, selectedId, onSelect }: WardScaleProps) {
})}
- {/* Leader lines from each label down to the axis. */}
-
- {patients.map((patient) => (
-
- ))}
-
-
{/* The axis. Segment widths are the calibrated cut points. */}
))}
- {/* Each patient's position, drawn over the segments. */}
+ {/* Each patient's position, drawn over the segments. Always the true
+ score — the label may have moved, the mark never does. */}
{patients.map((patient) => (
))}
diff --git a/front-end/src/components/charts/BreathRhythm.tsx b/front-end/src/components/charts/BreathRhythm.tsx
index 9267add..71fb575 100644
--- a/front-end/src/components/charts/BreathRhythm.tsx
+++ b/front-end/src/components/charts/BreathRhythm.tsx
@@ -99,7 +99,7 @@ export function BreathRhythm({ assessment, band, size = 'detail' }: BreathRhythm
onClick={() => setRun((current) => current + 1)}
className="inline-flex shrink-0 items-center gap-1.5 rounded-[2px] border border-rule-strong px-2.5 py-1 text-2xs font-medium text-ink-950 transition-colors hover:border-ink-950 hover:bg-surface-sunken"
>
-
+
{playing ? 'Breathing…' : 'Play rhythm'}
diff --git a/front-end/src/components/chrome/AppHeader.tsx b/front-end/src/components/chrome/AppHeader.tsx
index 7d24450..fc32948 100644
--- a/front-end/src/components/chrome/AppHeader.tsx
+++ b/front-end/src/components/chrome/AppHeader.tsx
@@ -1,6 +1,8 @@
import { Link } from 'react-router'
import { Moon, Sun } from 'lucide-react'
import { useClock } from '../../hooks/useClock'
+import { useWard } from '../../data/WardProvider'
+import { useWardClock } from '../../hooks/useWardClock'
import { useTheme } from '../../hooks/useTheme'
import { formatClock, formatDate } from '../../lib/format'
@@ -13,7 +15,13 @@ import { formatClock, formatDate } from '../../lib/format'
* product cannot be seen, or screenshotted, without it.
*/
export function AppHeader() {
+ const { ward } = useWard()
+ // The big clock stays REAL time. Bound to the ward's clock it stopped moving
+ // between ticks — a frozen second-hand beside a green "Receiving" dot, which
+ // is exactly the fault signature this header is built to avoid. The ward's own
+ // clock gets its own chip instead, so the two are legible as two things.
const now = useClock()
+ const { now: wardNow, simulated } = useWardClock(ward)
const [theme, toggleTheme] = useTheme()
return (
@@ -39,6 +47,21 @@ export function AppHeader() {
+ {/* No seconds: the ward steps an hour at a time, so a seconds field here
+ would be a frozen one sitting beside a live one. Hidden below md —
+ the row is already at its width budget, and this is the only element
+ in it that appears mid-session. */}
+ {simulated && (
+
+ Ward {formatClock(wardNow, false)}
+ {/* A demo of ~26 ticks crosses midnight, and time-of-day alone then
+ reads EARLIER than the wall clock beside it. Measured: ward
+ 12:19 against a real 12:25, a full day apart. */}
+ {wardNow.getDate() !== now.getDate()
+ && `+${Math.round((wardNow.getTime() - now.getTime()) / 86_400_000) || 1}d`}
+ {' · 1 tick = 1 h'}
+
+ )}
{formatDate(now)} · device local time
@@ -53,9 +76,9 @@ export function AppHeader() {
title={theme === 'day' ? 'Night' : 'Day'}
>
{theme === 'day' ? (
-
+
) : (
-
+
)}
diff --git a/front-end/src/components/chrome/SimulationBar.tsx b/front-end/src/components/chrome/SimulationBar.tsx
new file mode 100644
index 0000000..4c05f85
--- /dev/null
+++ b/front-end/src/components/chrome/SimulationBar.tsx
@@ -0,0 +1,200 @@
+import { useState } from 'react'
+import { Flame, Pause, Play, RotateCcw } from 'lucide-react'
+import { useWard } from '../../data/WardProvider'
+import { seedWard, warmExplainer } from '../../data/feed'
+import { STREAM_CADENCES } from '../../hooks/useWardStream'
+import { cn } from '../../lib/cn'
+import { TelemetryToggle } from './TelemetryDock'
+
+/**
+ * Backfill used by "Restart ward".
+ *
+ * Short on purpose. The shipped default of 24 puts thirteen of this ward's
+ * seventeen band changes behind the stream before it starts, including every one
+ * of the early promotions. Four leaves two promotions on the first streamed
+ * tick, the first COMPLETED demotion on the tenth, and the two-step recovery at
+ * the twenty-second and twenty-fourth.
+ *
+ * "Completed" is load-bearing: three demotions go pending earlier and never
+ * finish, so "the first demotion" would be wrong by eight ticks.
+ */
+const DEMO_BACKFILL = 4
+
+/**
+ * The stand-in for a telemetry feed.
+ *
+ * There is no HL7 interface and no message broker; in production readings arrive
+ * on their own and none of this exists. It is a request like any other — the
+ * consequence is computed by the model service and read back, never invented
+ * here.
+ *
+ * IN THE CHROME, not the aside. It is entirely prototype affordance, and at 307px
+ * it was the third-tallest thing in the clinician's own column — pushing the
+ * ward off the bottom of the screen to hold controls no clinician will ever see.
+ * One line of chrome costs ~40px and is reachable without scrolling.
+ */
+export function SimulationBar({
+ pipelineOpen,
+ onTogglePipeline,
+}: {
+ pipelineOpen: boolean
+ onTogglePipeline: () => void
+}) {
+ const { stream, refresh } = useWard()
+ const [busy, setBusy] = useState<'seed' | 'warm' | null>(null)
+ // ARMED, NOT TIMED. Restarting deletes every assessment, prompt and clinician
+ // disposition — the last of which is the only human-authored record the system
+ // holds and the one thing a re-seed cannot reproduce. It sits one click from
+ // "Stream ward" in permanent chrome, so it asks first. A countdown or a
+ // hover-reveal would both expire on their own, which this interface does not
+ // do: a nurse returning to an interrupted screen must find it as they left it.
+ const [armed, setArmed] = useState(false)
+ const [note, setNote] = useState(null)
+ const [failure, setFailure] = useState(null)
+
+ function begin(what: 'seed' | 'warm') {
+ setBusy(what)
+ setNote(null)
+ setFailure(null)
+ // Cleared, not left to outrank what happens next: a stale stream error used
+ // to render beside this action's success note.
+ stream.clearError()
+ }
+
+ async function restart() {
+ setArmed(false)
+ stream.stop()
+ begin('seed')
+ try {
+ // Through `withPause`, not straight after `stop()`. Seeding deletes all
+ // three collections and the server refuses a ward operation while a tick
+ // is open, so an unwaited restart simply 409'd whenever it landed inside
+ // one — and the recovery control is the worst one to have fail on stage.
+ await stream.withPause(async () => {
+ await seedWard(DEMO_BACKFILL)
+ await refresh()
+ })
+ setNote(`Rebuilt · ${DEMO_BACKFILL} readings of history`)
+ } catch (error) {
+ setFailure(error instanceof Error ? error.message : 'the ward could not be rebuilt')
+ } finally {
+ setBusy(null)
+ }
+ }
+
+ async function warm() {
+ begin('warm')
+ try {
+ // Holds the stream: the weights load on the one thread that also scores.
+ const result = await stream.withPause(warmExplainer)
+ setNote(result.was_loaded ? 'Explainer already loaded' : 'Explainer loaded')
+ } catch (error) {
+ setFailure(error instanceof Error ? error.message : 'the explainer did not load')
+ } finally {
+ setBusy(null)
+ }
+ }
+
+ const button = 'inline-flex items-center gap-1.5 rounded-[2px] border px-2 py-1 '
+ + 'text-2xs font-medium transition-colors disabled:cursor-progress disabled:opacity-50'
+
+ return (
+
+
+ {/* `aria-live` because a 40 s warm-up finishing is otherwise announced to
+ nobody. `failure` outranks a stale stream error; the note is hidden
+ while either is showing so they cannot contradict each other. */}
+
+ {(failure ?? stream.error)
+ ? {failure ?? stream.error}
+ : note
+ ? {note}
+ : (
+
+ No hospital feed in this build · each tick is one reading per bed, an hour
+ later on the ward's clock
+
+ )}
+
+ )
+}
diff --git a/front-end/src/components/chrome/TelemetryDock.tsx b/front-end/src/components/chrome/TelemetryDock.tsx
new file mode 100644
index 0000000..cdcce92
--- /dev/null
+++ b/front-end/src/components/chrome/TelemetryDock.tsx
@@ -0,0 +1,304 @@
+import { useEffect, useState, useSyncExternalStore } from 'react'
+import { ChevronDown, X } from 'lucide-react'
+import { cn } from '../../lib/cn'
+import type { Call, Span } from '../../data/telemetry'
+import { clear, snapshot, subscribe } from '../../data/telemetry'
+
+/**
+ * What the system did, while it did it.
+ *
+ * The board shows a conclusion — a band, a rationale. This shows the work
+ * behind it: which service was called, how long each stage of the pipeline
+ * took, and where the time actually went. Every figure is measured by the tier
+ * that did the work and travels back on a W3C `Server-Timing` header; nothing
+ * here is estimated, and nothing is computed from a number the browser
+ * happened to have.
+ *
+ * ⚠️ A STAGE THAT DID NOT RUN IS ABSENT, NEVER ZERO. A `0 ms` beside a stage
+ * name is indistinguishable from a real measurement of a fast stage, so a
+ * failed or skipped measurement would read as a successful one — which is the
+ * same defect as a defaulted clinician name in an audit record (PM-CLIN-001).
+ *
+ * ⚠️ NO RESPONSE BODY REACHES THIS COMPONENT. The store holds route templates
+ * and durations and nothing else, so the generated explanation cannot appear
+ * here even by mistake (PM-LOG-003).
+ */
+
+/**
+ * The span tree, as the request actually nests.
+ *
+ * Drawn flat, these would read as consecutive steps and their durations would
+ * appear to sum — but `upstream` CONTAINS every model-service stage and `total`
+ * contains `upstream`. Indentation is not decoration here; it is the difference
+ * between "generation took 26 s of a 26 s request" and "something took 52 s".
+ *
+ * `kind` separates the five published pipeline stages (docs figure 3) from the
+ * transport and storage around them. A queue wait is not a stage of clinical
+ * reasoning and must not be shown as one.
+ */
+const TREE: { name: string; label: string; indent: number; kind: 'infra' | 'stage' }[] = [
+ { name: 'total', label: 'API · Node/Express', indent: 0, kind: 'infra' },
+ { name: 'upstream', label: 'model service · FastAPI', indent: 1, kind: 'infra' },
+ { name: 'queue', label: 'waiting for the model thread', indent: 2, kind: 'infra' },
+ { name: 'collect', label: '1 · Collect', indent: 2, kind: 'stage' },
+ { name: 'order', label: '2 · Order in time', indent: 2, kind: 'stage' },
+ { name: 'assess', label: '3 · Assess — booster + calibration', indent: 2, kind: 'stage' },
+ { name: 'decide', label: '4 · Decide the level — hysteresis', indent: 2, kind: 'stage' },
+ { name: 'rank', label: '5 · Explain — rank the reasons', indent: 2, kind: 'stage' },
+ { name: 'floor', label: '5 · Explain — sufficiency floor', indent: 2, kind: 'stage' },
+ { name: 'baseline', label: '5 · Explain — deterministic template', indent: 2, kind: 'stage' },
+ { name: 'load', label: '5 · Explain — load the weights', indent: 2, kind: 'stage' },
+ { name: 'explain', label: '5 · Explain — write the rationale', indent: 2, kind: 'stage' },
+ { name: 'ground', label: '5 · Explain — check every sentence', indent: 2, kind: 'stage' },
+ { name: 'mongo', label: 'MongoDB Atlas', indent: 1, kind: 'infra' },
+]
+
+/** Entries that carry no duration: an observation, not a measurement. */
+const NOTES: Record = {
+ depth: 'model queue on arrival',
+ refused: 'refused',
+}
+
+function duration(ms: number): string {
+ if (ms >= 1000) return `${(ms / 1000).toFixed(ms >= 10000 ? 1 : 2)}s`
+ if (ms >= 10) return `${Math.round(ms)}ms`
+ if (ms >= 0.1) return `${ms.toFixed(1)}ms`
+ // ⚠️ A REAL MEASUREMENT MUST NEVER RENDER AS `0.0ms`. The deterministic
+ // template takes about 0.05 ms and rounded to exactly that on screen — and
+ // this panel draws a stage that did not run as *absent*, so a zero is the one
+ // value a reader would read as something else entirely. Keeping the data
+ // honest is not enough if the formatter throws the distinction away.
+ return '<0.1ms'
+}
+
+function clock(at: Date): string {
+ const pad = (n: number, w = 2) => String(n).padStart(w, '0')
+ return `${pad(at.getHours())}:${pad(at.getMinutes())}:${pad(at.getSeconds())}`
+ + `.${pad(at.getMilliseconds(), 3)}`
+}
+
+function SpanRows({ call }: { call: Call }) {
+ const byName = new Map(call.spans.map((s) => [s.name, s]))
+ // The client round trip is the outermost thing actually measured, so it is
+ // what every bar is a fraction of. Server spans can only be smaller.
+ const scale = call.clientMs ?? 0
+
+ const rows = TREE.filter((row) => byName.get(row.name)?.ms !== undefined)
+ const notes = call.spans.filter((s) => s.ms === undefined && NOTES[s.name])
+
+ if (!rows.length && !notes.length) {
+ return (
+
+ {call.status === undefined
+ ? 'In flight — the response carries the timings.'
+ : 'No timings on this response.'}
+
+ {rows.map((row) => {
+ const span = byName.get(row.name) as Span
+ const ms = span.ms as number
+ return (
+
+
+ {row.label}
+ {span.desc && · {span.desc}}
+
+ {/* Proportional, and deliberately so: a 26 s generation beside a 2 ms
+ band decision makes the second invisible, which is the true
+ shape of this pipeline. The figure is always printed. */}
+
+ 0 ? `${Math.min(100, (ms / scale) * 100)}%` : '0%' }}
+ />
+
+ {duration(ms)}
+
+ )
+}
+
+export function TelemetryDock({ open, onClose }: { open: boolean; onClose: () => void }) {
+ const calls = useSyncExternalStore(subscribe, snapshot)
+ const [selectedId, setSelectedId] = useState(null)
+
+ // Follow the newest call unless a specific one is being read. Without this
+ // the panel is empty until someone clicks, which during a live stream is the
+ // one moment nobody has a hand free.
+ const selected = calls.find((c) => c.id === selectedId) ?? calls[0]
+
+ useEffect(() => {
+ if (!open) setSelectedId(null)
+ }, [open])
+
+ if (!open) return null
+
+ return (
+
+
+
+
+ Pipeline activity
+
+
+ {calls.length} call{calls.length === 1 ? '' : 's'}
+
+
+ Every duration is measured by the tier that did the work and returned on a
+ Server-Timing header
+
+
+
+
+
+ {/* Tall enough that a whole tick fits without scrolling: eleven span rows
+ plus the route line. At h-44 the last two stages sat below the fold,
+ which on a pipeline panel is the opposite of the point. */}
+
+
+ {calls.length === 0 && (
+
+ Nothing yet. Stream the ward, or open a patient.
+
+ )}
+ {calls.map((call) => (
+
+
+
+ ))}
+
+
+
+ {selected
+ ? (
+ <>
+
+ {selected.method} {selected.route}
+ {selected.requestId && (
+ // The same id Node logs and the model service echoes:
+ // one request, one identifier, across three processes.
+ · {selected.requestId.slice(0, 8)}
+ )}
+
+
+ >
+ )
+ : (
+
+ Select a call to see where its time went.
+
+ )}
+
+
+
+
+ )
+}
+
+/** The control that opens the dock. Lives in the prototype feed bar, so the
+ * dock costs no vertical space at all while it is closed — the board is fitted
+ * to exactly one screen and a second permanent chrome band would take a bed. */
+export function TelemetryToggle({ open, onToggle }: { open: boolean; onToggle: () => void }) {
+ const calls = useSyncExternalStore(subscribe, snapshot)
+ return (
+
+ )
+}
diff --git a/front-end/src/components/detail/ContributorList.tsx b/front-end/src/components/detail/ContributorList.tsx
index 87a8f7b..c203ab9 100644
--- a/front-end/src/components/detail/ContributorList.tsx
+++ b/front-end/src/components/detail/ContributorList.tsx
@@ -57,7 +57,7 @@ export function ContributorList({ contributors }: ContributorListProps) {
{contributor.is_imputed && (
-
+
Population default
)}
diff --git a/front-end/src/components/detail/ExplanationPanel.tsx b/front-end/src/components/detail/ExplanationPanel.tsx
index e6e4282..5b12b01 100644
--- a/front-end/src/components/detail/ExplanationPanel.tsx
+++ b/front-end/src/components/detail/ExplanationPanel.tsx
@@ -1,12 +1,13 @@
-import { useState } from 'react'
import { Check, Sparkles } from 'lucide-react'
import type { Explanation } from '@contract/clinical'
-import { generateExplanation } from '../../data/feed'
interface ExplanationPanelProps {
- /** Null means generation was never attempted, which is a different fact from failure. */
- explanation: Explanation | null
- patientId: string
+ /** What to render. Null means generation was never attempted, which is a
+ * different fact from failure. */
+ shown: Explanation | null
+ generating: boolean
+ failure: string | null
+ onGenerate: () => void
}
/**
@@ -15,32 +16,49 @@ interface ExplanationPanelProps {
* Three outcomes that must not look alike: generated and grounded, generated
* then withheld because grounding failed, and never requested. Nothing is ever
* generated to fill an absence.
+ *
+ * CONTROLLED, not self-driving. The request state lives in `useExplanationRequest`
+ * one level up, because the guideline-references panel is this panel's SIBLING
+ * and has to fill from the same result on the same click. Owned here, that
+ * result was unreachable from there.
*/
-export function ExplanationPanel({ explanation, patientId }: ExplanationPanelProps) {
- const [generated, setGenerated] = useState(null)
- const [generating, setGenerating] = useState(false)
- const [failure, setFailure] = useState(null)
-
- const shown = generated ?? explanation
+export function ExplanationPanel(
+ { shown, generating, failure, onGenerate }: ExplanationPanelProps,
+) {
- async function requestExplanation() {
- setGenerating(true)
- setFailure(null)
- try {
- const result = await generateExplanation(patientId)
- setGenerated(result as Explanation)
- } catch (error) {
- setFailure(error instanceof Error ? error.message : 'the generator did not respond')
- } finally {
- setGenerating(false)
- }
- }
+ // One control, three captions. Lifting it out of the never-requested branch is
+ // what makes a second reading explainable: the panel used to offer generation
+ // only while there was nothing to show, so once a bed had any explanation the
+ // affordance disappeared and the text stayed pinned to an old reading.
+ const button = (
+
+ )
- if (shown === null) {
+ // GENERATING IS CHECKED FIRST, ABOVE `shown === null`, and that ordering is
+ // the whole fix. This state used to live inside the never-requested branch, so
+ // it showed on the FIRST generation and never again: asking for another
+ // explanation left the previous prose sitting there while the references panel
+ // beside it visibly cleared and refilled. Dimming the old text instead was
+ // tried and rejected — greyed-out prose still reads as the answer, and the
+ // panel is claiming to be writing a new one.
+ //
+ // The old text is not lost: it is stored on the assessment, and if this
+ // generation fails `shown` falls back to it on the next render.
+ if (generating || shown === null) {
return (
- {generating ? 'Writing the explanation…' : 'No explanation requested'}
+ {generating
+ ? shown === null ? 'Writing the explanation…' : 'Re-running the model…'
+ : 'No explanation requested'}
{generating
@@ -51,21 +69,24 @@ export function ExplanationPanel({ explanation, patientId }: ExplanationPanelPro
'ranked factors above are complete.'}
+ {/* Say it before they wait twenty seconds for it. Decoding is greedy, so
+ re-running the model on the same reading returns byte-identical prose
+ — verified with two live calls, same sha256. Unannounced, the honest
+ outcome is indistinguishable from a button that did nothing. */}
+ {generating && shown !== null && (
+
+ Decoding is greedy, so the same reading returns the same wording. The
+ guideline passages beside this are being selected again in the same call.
+
+ )}
+
{failure && (
{failure}
)}
-
+ {button}
)
}
@@ -80,6 +101,15 @@ export function ExplanationPanel({ explanation, patientId }: ExplanationPanelPro
Score, risk level, inputs and ranked factors above remain fully available. Nothing
is generated in place of an unavailable explanation.
+ {/* No generate button here on purpose: the usual way to reach this branch
+ is a bed whose explanation is withheld by policy, where the server
+ short-circuits the request and the control would do nothing. But if
+ an attempt was made and failed, say so. */}
+ {failure && (
+
{explanationToRender.grounding_status === 'passed' && (
-
+
Checked against this assessment
)}
{/* Set larger and to a narrower measure than the data around it — this is the one
- place on the screen where prose is read as prose. */}
-
+ place on the screen where prose is read as prose.
+
+ ⚠️ This said `text-md`, which is not a class. The theme defines
+ 2xs/xs/sm/base/lg…, Tailwind has no `md` font-size key either, so it
+ compiled to nothing and the paragraph inherited body's 14px — the same
+ size as the data it was supposed to stand apart from. The comment above
+ had been true of the intent and false of the screen since it was
+ written. `text-base` is what it meant. */}
+
{explanationToRender.explanation_text}
+ {failure && (
+
+ {failure}
+
+ )}
+
+ {button}
+
Point-in-time rationale for this reading. No claim is made about change over time.
diff --git a/front-end/src/components/detail/GuidelineReferences.tsx b/front-end/src/components/detail/GuidelineReferences.tsx
new file mode 100644
index 0000000..253bd0b
--- /dev/null
+++ b/front-end/src/components/detail/GuidelineReferences.tsx
@@ -0,0 +1,91 @@
+import type { Explanation } from '@contract/clinical'
+
+interface GuidelineReferencesProps {
+ /** The SAME object the explanation panel is rendering. Null means no
+ * generation has been attempted for this reading. */
+ explanation: Explanation | null
+ generating: boolean
+}
+
+/** `Qwen/Qwen2.5-7B-Instruct` reads as a path; the model's name is the half
+ * after the org. */
+function modelName(generator: string): string {
+ return generator.split('/').pop() ?? generator
+}
+
+/**
+ * The passages the generator was shown.
+ *
+ * FILLED BY THE SAME CLICK AS THE PROSE, from the same object, so what is on
+ * screen is what the model read — not a second lookup that happens to agree.
+ *
+ * An empty list is not one state, it is three, and saying "no references
+ * retrieved" for all of them reports two correct outcomes as a shortfall:
+ *
+ * nothing generated yet there was no retrieval, because there was no call
+ * the template floor the deterministic writer never consults the library
+ * the model ran, 0 hits 9 of the 57 keys have no admissible passage, and
+ * those are suppressed rather than invented
+ *
+ * `generator` is the field that separates them, which is why it is persisted.
+ */
+export function GuidelineReferences({ explanation, generating }: GuidelineReferencesProps) {
+ const note = (text: string) => (
+
{text}
+ )
+
+ if (generating) {
+ return note(
+ 'Selecting the approved passages. These arrive with the explanation, from the '
+ + 'same call — the model is shown exactly what appears here.',
+ )
+ }
+
+ if (explanation === null) {
+ return note('References appear with the explanation. Nothing has been requested yet.')
+ }
+
+ if (explanation.status === 'unavailable') {
+ return note(
+ 'No explanation was generated for this reading, so no guideline passages were '
+ + 'consulted.',
+ )
+ }
+
+ if (explanation.generator === 'template') {
+ return note(
+ 'Written by the deterministic template, which states the record back and does '
+ + 'not consult the guideline library. Generate with the model to see the '
+ + 'passages it was shown.',
+ )
+ }
+
+ if (explanation.citations.length === 0) {
+ return note(
+ 'The approved library holds no admissible passage for this reading. Suppressed '
+ + 'rather than substituted — nothing is written into a citation slot to fill it.',
+ )
+ }
+
+ return (
+ <>
+
+ {explanation.citations.map((citation) => (
+
+
{citation.claim}
+
{citation.source}
+
+ ))}
+
+
+ Shown to {explanation.generator ? modelName(explanation.generator) : 'the generator'}{' '}
+ as part of this explanation, and retrieved from a fixed approved library — not
+ generated. The passages were selected and reviewed before this reading existed, so
+ a citation here cannot be invented.
+
diff --git a/front-end/src/components/ui/SegmentMeter.tsx b/front-end/src/components/ui/SegmentMeter.tsx
index 7a7d91c..e99ee19 100644
--- a/front-end/src/components/ui/SegmentMeter.tsx
+++ b/front-end/src/components/ui/SegmentMeter.tsx
@@ -26,11 +26,14 @@ export function SegmentMeter({ band, className }: SegmentMeterProps) {
diff --git a/front-end/src/data/WardProvider.tsx b/front-end/src/data/WardProvider.tsx
index b2d2cd8..3a1d14a 100644
--- a/front-end/src/data/WardProvider.tsx
+++ b/front-end/src/data/WardProvider.tsx
@@ -1,6 +1,7 @@
import {
createContext,
useCallback,
+ useRef,
useContext,
useEffect,
useMemo,
@@ -8,7 +9,8 @@ import {
type ReactNode,
} from 'react'
import type { Assessment } from '@contract/clinical'
-import { fetchWard, setDeviceOffline, tickWard } from './feed'
+import { fetchWard, setDevicesOffline as setDeviceOffline, tickWard } from './feed'
+import { useWardStream, type WardStream } from '../hooks/useWardStream'
interface WardValue {
ward: Assessment[]
@@ -16,11 +18,22 @@ interface WardValue {
error: string | null
/** Re-read the board from the API. */
refresh: () => Promise
- /** Advance every bed by one reading, then re-read. */
+ /** Advance every bed by one reading, then re-read. REJECTS if the tick fails:
+ * `refresh` swallows its own failures and leaves the board readable, but the
+ * stream driver needs this one to reach it, or the loop keeps firing against
+ * a ward that stopped advancing. Every caller must handle the rejection. */
advance: () => Promise
/** Switch one patient's input source off or on, then re-read. */
toggleDevice: (patientId: string, deviceId: string) => Promise
+ /** Many devices, one write. Use for anything that touches more than one. */
+ setDevices: (patientId: string, deviceIds: string[], offline: boolean) => Promise
offlineDeviceIds: Set
+ stream: WardStream
+ /** Bumped after every successful re-read. Anything holding data fetched
+ * alongside the ward — a patient's history, a parameter series — puts this in
+ * its dependencies so it reloads too. Keyed to the stream's tick count it
+ * missed device toggles and re-seeds, which change the ward just as much. */
+ revision: number
}
const WardContext = createContext(null)
@@ -38,9 +51,17 @@ export function WardProvider({ children }: { children: ReactNode }) {
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
+ const [revision, setRevision] = useState(0)
+ // The FIRST load is not a change. Bumping on it too meant anything mounted
+ // before the ward arrived — a deep link straight to a patient — fetched its
+ // history once at revision 0 and again the moment the provider settled.
+ const loaded = useRef(false)
+
const refresh = useCallback(async () => {
try {
setWard(await fetchWard())
+ if (loaded.current) setRevision((n) => n + 1)
+ loaded.current = true
setError(null)
} catch (failure) {
setError(failure instanceof Error ? failure.message : 'the ward could not be loaded')
@@ -58,16 +79,29 @@ export function WardProvider({ children }: { children: ReactNode }) {
await refresh()
}, [refresh])
+ const stream = useWardStream(advance)
+
const toggleDevice = useCallback(
async (patientId: string, deviceId: string) => {
const patient = ward.find((a) => a.patient_id === patientId)
const device = patient?.devices.find((d) => d.device_id === deviceId)
- await setDeviceOffline(patientId, deviceId, device?.state !== 'offline')
+ await setDeviceOffline(patientId, [deviceId], device?.state !== 'offline')
await refresh()
},
[ward, refresh],
)
+ /** Every named device to one state, in ONE write. Firing N single toggles
+ * concurrently raced the server's read-modify-write and restored one. */
+ const setDevices = useCallback(
+ async (patientId: string, deviceIds: string[], offline: boolean) => {
+ if (deviceIds.length === 0) return
+ await setDeviceOffline(patientId, deviceIds, offline)
+ await refresh()
+ },
+ [refresh],
+ )
+
// Derived, not stored: the board already reports each source's state, and a
// second copy is a thing that can disagree.
const offlineDeviceIds = useMemo(
@@ -81,8 +115,10 @@ export function WardProvider({ children }: { children: ReactNode }) {
)
const value = useMemo(
- () => ({ ward, loading, error, refresh, advance, toggleDevice, offlineDeviceIds }),
- [ward, loading, error, refresh, advance, toggleDevice, offlineDeviceIds],
+ () => ({ ward, loading, error, refresh, advance, toggleDevice, setDevices, offlineDeviceIds,
+ stream, revision }),
+ [ward, loading, error, refresh, advance, toggleDevice, setDevices, offlineDeviceIds,
+ stream, revision],
)
return {children}
diff --git a/front-end/src/data/feed.ts b/front-end/src/data/feed.ts
index f2eb887..eee6564 100644
--- a/front-end/src/data/feed.ts
+++ b/front-end/src/data/feed.ts
@@ -13,34 +13,100 @@
import type {
Assessment,
+ Explanation,
ParameterHistoryPoint,
ParameterName,
+ PatientContext,
RefusedAssessment,
RiskBand,
ScoredAssessment,
} from '@contract/clinical'
import { isScored } from '@contract/clinical'
+
+import * as telemetry from './telemetry'
import { bandRank } from './bands'
/** Relative, because Vite proxies /api to the Node service in development. */
const API = '/api'
-async function readJson(path: string): Promise {
- const response = await fetch(`${API}${path}`)
+/**
+ * The API answers a failure with RFC 9457 `problem+json`, or with a `message`.
+ * Read it: a status code alone turns "seeding needs PM_ALLOW_DESTRUCTIVE" and
+ * "the ward was never seeded" into the same unactionable number on screen.
+ */
+async function failure(path: string, response: Response): Promise {
+ let detail = ''
+ try {
+ const body = await response.json()
+ const raw = body?.detail ?? body?.message
+ // FastAPI's validation errors put an ARRAY of objects in `detail`, so the
+ // obvious read renders as "[object Object]" on screen. Flatten to the
+ // messages, which is the part a reader can act on.
+ detail = Array.isArray(raw)
+ ? raw.map((item) => item?.msg ?? JSON.stringify(item)).join('; ')
+ : typeof raw === 'string' ? raw : ''
+ } catch {
+ // A non-JSON body is itself worth nothing to a reader; fall through.
+ }
+ return new Error(detail || `${path} returned ${response.status}`)
+}
+
+/**
+ * Every request the dashboard makes passes through here, which is why the
+ * telemetry log can be honest about coverage: one place to instrument, and a
+ * call that skipped it would be a call the panel silently never showed.
+ *
+ * `route` is the TEMPLATE, passed in rather than derived from `path`. Deriving
+ * it would mean pattern-matching identifiers back out of a URL, and getting
+ * that subtly wrong puts a patient id on screen. The server logs the matched
+ * route for the same reason (PM-LOG-001).
+ */
+async function readJson(path: string, route: string): Promise {
+ const settle = telemetry.begin('GET', route)
+ const started = performance.now()
+ let response: Response
+ try {
+ response = await fetch(`${API}${path}`)
+ } catch (transportFailure) {
+ // A refused connection never produces a response, and a log that only shows
+ // completed calls hides exactly the case someone is debugging.
+ settle({ clientMs: performance.now() - started, failed: true })
+ throw transportFailure
+ }
+ settle({
+ status: response.status,
+ headers: response.headers,
+ clientMs: performance.now() - started,
+ failed: !response.ok,
+ })
if (!response.ok) {
- throw new Error(`${path} returned ${response.status}`)
+ throw await failure(path, response)
}
return response.json() as Promise
}
-async function sendJson(path: string, body: unknown): Promise {
- const response = await fetch(`${API}${path}`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
+async function sendJson(path: string, route: string, body: unknown): Promise {
+ const settle = telemetry.begin('POST', route)
+ const started = performance.now()
+ let response: Response
+ try {
+ response = await fetch(`${API}${path}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+ } catch (transportFailure) {
+ settle({ clientMs: performance.now() - started, failed: true })
+ throw transportFailure
+ }
+ settle({
+ status: response.status,
+ headers: response.headers,
+ clientMs: performance.now() - started,
+ failed: !response.ok,
})
if (!response.ok) {
- throw new Error(`${path} returned ${response.status}`)
+ throw await failure(path, response)
}
return response.json() as Promise
}
@@ -51,14 +117,27 @@ async function sendJson(path: string, body: unknown): Promise {
/** Every bed's current assessment. */
export function fetchWard(): Promise {
- return readJson('/ward')
+ return readJson('/ward', '/ward')
}
/** One patient's recent assessments, oldest first. `Assessment[]`, not
* `ScoredAssessment[]`: a stay that dipped below the floor has refusals in its
* history and the endpoint returns them. Consumers narrow first. */
export function fetchHistory(patientId: string, limit = 14): Promise {
- return readJson(`/patient/${patientId}/history?limit=${limit}`)
+ return readJson(
+ `/patient/${patientId}/history?limit=${limit}`,
+ '/patient/:id/history',
+ )
+}
+
+/** Borrowed demographics and comorbidities. Recorded context, not a prediction.
+ *
+ * Here rather than hand-rolled in the hook that uses it: it used to call
+ * `fetch` directly, which meant it neither parsed a problem+json body nor
+ * appeared in the telemetry log — one invisible call is enough to make the
+ * log's coverage a claim rather than a property. */
+export function fetchPatientContext(patientId: string): Promise {
+ return readJson(`/patient/${patientId}/context`, '/patient/:id/context')
}
/** One parameter's charting history, oldest first. */
@@ -69,6 +148,7 @@ export function fetchParameterHistory(
): Promise {
return readJson(
`/patient/${patientId}/parameter/${parameterName}?limit=${limit}`,
+ '/patient/:id/parameter/:name',
)
}
@@ -76,27 +156,62 @@ export function fetchParameterHistory(
// Writes
// ---------------------------------------------------------------------------
-/** Advance every bed by one reading. */
+/** Advance every bed by one reading. `at` is the ward's own clock, which a tick
+ * moves forward an hour — not the wall clock. */
export function tickWard(): Promise<{ at: string }> {
- return sendJson('/ward/tick', {})
+ return sendJson('/ward/tick', '/ward/tick', {})
+}
+
+/** Rebuild the ward from nothing.
+ *
+ * DESTRUCTIVE: it deletes every assessment, prompt and stay state, and the
+ * server refuses unless PM_ALLOW_DESTRUCTIVE is set. Why the demo passes the
+ * backfill it does is at `DEMO_BACKFILL`, not here. */
+export function seedWard(backfillTicks: number): Promise<{ patients: number }> {
+ return sendJson('/ward/seed', '/ward/seed', { backfill_ticks: backfillTicks })
+}
+
+/** Load the 7B before anyone asks for an explanation. Stores nothing: the
+ * alternative, explaining some bed to warm the weights, leaves a real
+ * explanation attached to a reading nobody asked about. */
+export function warmExplainer(): Promise<{ explainer: string; was_loaded: boolean }> {
+ return sendJson('/ward/warmup', '/ward/warmup', {})
}
-/** Switch an input source off, or back on. */
-export function setDeviceOffline(
+/** Switch input sources off, or back on.
+ *
+ * Takes a LIST because the server's write is read-modify-write on one array:
+ * three restores as three requests read the same list, saved last-write-wins,
+ * and exactly one device came back. Each response was individually correct,
+ * which is why it looked like nothing was wrong. */
+export function setDevicesOffline(
patientId: string,
- deviceId: string,
+ deviceIds: string[],
offline: boolean,
): Promise<{ offline_devices: string[] }> {
- return sendJson(`/patient/${patientId}/device`, { device_id: deviceId, offline })
+ return sendJson(`/patient/${patientId}/device`, '/patient/:id/device', {
+ device_ids: deviceIds,
+ offline,
+ })
}
-/** Ask the local model to write the explanation. Takes tens of seconds. */
-export function generateExplanation(patientId: string): Promise<{
- status: string
- explanation_text: string
- grounding_status: string
-}> {
- return sendJson(`/patient/${patientId}/explain`, {})
+/** Ask the local model to write the explanation. Takes tens of seconds.
+ *
+ * `assessedAt` names the reading to explain. Worth passing whenever the board
+ * is moving: generation takes 18-23 s, and the server's default of "the latest"
+ * is resolved when the request arrives, so the text can land on a row several
+ * readings older than the one the clinician was looking at.
+ *
+ * `useLlm: false` selects the deterministic template instead — no GPU, instant,
+ * and the only way to exercise this path on a busy card. */
+export function generateExplanation(
+ patientId: string,
+ options: { assessedAt?: string; useLlm?: boolean } = {},
+): Promise {
+ return sendJson(`/patient/${patientId}/explain`, '/patient/:id/explain', {
+ ...(options.assessedAt ? { assessed_at: options.assessedAt } : {}),
+ ...(options.useLlm === false ? { use_llm: false } : {}),
+ })
}
/** Record a clinician's disposition of a prompt. */
@@ -105,7 +220,7 @@ export function reviewPrompt(
disposition: string,
note?: string,
): Promise {
- return sendJson(`/prompt/${promptId}/review`, { disposition, note })
+ return sendJson(`/prompt/${promptId}/review`, '/prompt/:id/review', { disposition, note })
}
// ---------------------------------------------------------------------------
diff --git a/front-end/src/data/telemetry.ts b/front-end/src/data/telemetry.ts
new file mode 100644
index 0000000..45d0fbe
--- /dev/null
+++ b/front-end/src/data/telemetry.ts
@@ -0,0 +1,140 @@
+/**
+ * What the system actually did, as it did it.
+ *
+ * Every API call the dashboard makes is recorded here with the durations each
+ * tier measured for itself, so the pipeline behind a risk band can be watched
+ * rather than described. The board shows a conclusion; this shows the work.
+ *
+ * A MODULE, NOT A CONTEXT. `feed.ts` is where every request goes through, and
+ * it is not a React module -- it cannot import a hook. A plain observable store
+ * read through `useSyncExternalStore` lets the one choke point stay ordinary
+ * TypeScript while the panel still re-renders.
+ *
+ * ⚠️ NOTHING FROM A RESPONSE BODY IS STORED HERE. Method, route template,
+ * status, request id and timings, and that is the whole shape. The explanation
+ * is prose about one patient's physiology and is the single most tempting thing
+ * to keep while debugging a grounding failure (PM-LOG-003) -- so the buffer is
+ * built so that it cannot hold it, rather than trusted not to.
+ */
+
+/** One span as some tier measured it. `ms` is absent when the entry is an
+ * observation rather than a duration -- a queue depth, a model id. It is never
+ * 0 standing in for "not measured": a stage that did not run has no span. */
+export interface Span {
+ name: string
+ ms?: number
+ desc?: string
+}
+
+export interface Call {
+ /** Monotonic within a session; the key React needs and the clock does not give. */
+ id: number
+ /** Wall clock at which the request was issued. */
+ at: Date
+ method: 'GET' | 'POST'
+ /**
+ * The ROUTE TEMPLATE, never the resolved path. Our URLs carry patient
+ * identifiers and this one renders on a screen someone may be recording.
+ * The server logs the same way and for the same reason (PM-LOG-001).
+ */
+ route: string
+ /** Absent while the call is still in flight. */
+ status?: number
+ /** Round trip as the browser saw it: always at least the server's `total`. */
+ clientMs?: number
+ /** From `X-Request-Id` — the same id Node logs and FastAPI now echoes. */
+ requestId?: string
+ /** Parsed from `Server-Timing`, in the order the tiers emitted them. */
+ spans: Span[]
+ /** Set when the request threw or answered non-2xx. Never the response body. */
+ failed?: boolean
+}
+
+/** Bounded: a demo left streaming at a 2 s cadence issues a call every couple of
+ * seconds for as long as it runs, and an unbounded log is a leak with a nice UI. */
+const LIMIT = 200
+
+let calls: Call[] = []
+let nextId = 1
+const listeners = new Set<() => void>()
+
+const emit = () => {
+ // A NEW ARRAY EVERY TIME. `useSyncExternalStore` compares snapshots by
+ // identity, so mutating in place would update the buffer and never the screen.
+ calls = calls.slice(0, LIMIT)
+ listeners.forEach((fn) => fn())
+}
+
+export const subscribe = (fn: () => void) => {
+ listeners.add(fn)
+ return () => listeners.delete(fn)
+}
+
+export const snapshot = () => calls
+
+export const clear = () => {
+ calls = []
+ emit()
+}
+
+/**
+ * Parse a `Server-Timing` header into spans.
+ *
+ * Hand-written rather than via `PerformanceResourceTiming.serverTiming`: that
+ * reads from a resource entry which has to be located by URL after the fact,
+ * and the URLs here carry patient ids. Reading the header off the response the
+ * call already holds is both simpler and keeps the identifier out of the lookup.
+ *
+ * Only same-origin makes this readable at all; `/api` is origin-relative through
+ * the Vite proxy, which is what makes it work in development.
+ */
+export function parseServerTiming(header: string | null): Span[] {
+ if (!header) return []
+ const spans: Span[] = []
+ // Split on commas that are not inside a quoted desc.
+ for (const raw of header.match(/(?:[^,"]|"(?:\\.|[^"\\])*")+/g) ?? []) {
+ const parts = raw.trim().split(';')
+ const name = parts.shift()?.trim()
+ if (!name) continue
+ const span: Span = { name }
+ for (const part of parts) {
+ const eq = part.indexOf('=')
+ if (eq === -1) continue
+ const key = part.slice(0, eq).trim().toLowerCase()
+ let value = part.slice(eq + 1).trim()
+ if (value.startsWith('"')) value = value.slice(1, -1).replace(/\\(.)/g, '$1')
+ if (key === 'dur') {
+ const ms = Number(value)
+ // NaN would render as a plausible-looking blank. An unparseable
+ // duration is a missing measurement, and missing is a state the panel
+ // draws differently from zero.
+ if (Number.isFinite(ms)) span.ms = ms
+ } else if (key === 'desc') {
+ span.desc = value
+ }
+ }
+ spans.push(span)
+ }
+ return spans
+}
+
+/** Record a call as it is issued. Returns the settle callback. */
+export function begin(method: 'GET' | 'POST', route: string) {
+ const call: Call = { id: nextId++, at: new Date(), method, route, spans: [] }
+ calls = [call, ...calls]
+ emit()
+
+ return (result: { status?: number; headers?: Headers; clientMs: number; failed?: boolean }) => {
+ // Replaced rather than mutated, for the same identity reason as `emit`.
+ const settled: Call = {
+ ...call,
+ status: result.status,
+ clientMs: result.clientMs,
+ failed: result.failed,
+ requestId: result.headers?.get('X-Request-Id') ?? undefined,
+ spans: parseServerTiming(result.headers?.get('Server-Timing') ?? null),
+ }
+ calls = calls.map((c) => (c.id === call.id ? settled : c))
+ emit()
+ }
+}
diff --git a/front-end/src/hooks/useApi.ts b/front-end/src/hooks/useApi.ts
index 523ec95..7cefc5c 100644
--- a/front-end/src/hooks/useApi.ts
+++ b/front-end/src/hooks/useApi.ts
@@ -1,6 +1,6 @@
-import { useEffect, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import type { Assessment, ParameterHistoryPoint, ParameterName, PatientContext } from '@contract/clinical'
-import { fetchHistory, fetchParameterHistory } from '../data/feed'
+import { fetchHistory, fetchParameterHistory, fetchPatientContext } from '../data/feed'
/**
* Small fetch-on-mount hooks, one per thing a screen needs. Plain useState and
@@ -16,17 +16,31 @@ interface Loaded {
error: string | null
}
-function useFetch(load: () => Promise, deps: unknown[]): Loaded {
+/**
+ * `subject` is what the data is ABOUT, and every caller must pass one. Changing
+ * it clears the previous answer; refetching the SAME subject keeps it on screen
+ * until the new one lands. Optional, it silently disabled clearing for whoever
+ * forgot — `undefined !== undefined` is never true — which is how the SpO2
+ * series came to render under the FiO2 heading.
+ */
+function useFetch(load: () => Promise, deps: unknown[], subject: string): Loaded {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
+ const lastSubject = useRef(subject)
useEffect(() => {
let current = true
setLoading(true)
- // Cleared, not left in place: a screen that renders `data` while `loading`
- // would show the previous patient's readings under the new patient's name.
- setData(null)
+ // Cleared when the SUBJECT changes, because a screen that renders `data`
+ // while `loading` shows the previous subject's numbers under the new one's
+ // name. Kept when the same subject merely has a newer reading: blanking on
+ // every tick leaves the one chart showing a band history unreadable for
+ // exactly as long as it is worth watching.
+ if (lastSubject.current !== subject) {
+ lastSubject.current = subject
+ setData(null)
+ }
load()
.then((result) => {
if (current) {
@@ -53,9 +67,17 @@ function useFetch(load: () => Promise, deps: unknown[]): Loaded {
return { data, loading, error }
}
-/** Recent assessments for one patient, oldest first. */
-export function usePatientHistory(patientId: string, limit = 14) {
- return useFetch(() => fetchHistory(patientId, limit), [patientId, limit])
+/** Recent assessments for one patient, oldest first.
+ *
+ * `revision` is the ward's, from `useWard()`. Without it the observation strip
+ * is fetched once on mount and then silently stops agreeing with the board
+ * beside it. */
+export function usePatientHistory(patientId: string, revision = 0, limit = 14) {
+ return useFetch(
+ () => fetchHistory(patientId, limit),
+ [patientId, limit, revision],
+ patientId,
+ )
}
/** One parameter's charting history, oldest first. */
@@ -67,16 +89,15 @@ export function useParameterHistory(
return useFetch(
() => fetchParameterHistory(patientId, parameterName, limit),
[patientId, parameterName, limit],
+ `${patientId}:${parameterName}`,
)
}
/** Borrowed demographics and comorbidities. Recorded context, not a prediction. */
export function usePatientContext(patientId: string) {
return useFetch(
- () => fetch(`/api/patient/${patientId}/context`).then((r) => {
- if (!r.ok) throw new Error(`context returned ${r.status}`)
- return r.json() as Promise
- }),
+ () => fetchPatientContext(patientId),
[patientId],
+ patientId,
)
}
diff --git a/front-end/src/hooks/useExplanationRequest.ts b/front-end/src/hooks/useExplanationRequest.ts
new file mode 100644
index 0000000..20470ff
--- /dev/null
+++ b/front-end/src/hooks/useExplanationRequest.ts
@@ -0,0 +1,72 @@
+import { useCallback, useState } from 'react'
+import type { Explanation } from '@contract/clinical'
+import { generateExplanation } from '../data/feed'
+import { useWard } from '../data/WardProvider'
+
+export interface ExplanationRequest {
+ /** The explanation to render: the freshly generated one when it belongs to
+ * this reading, otherwise whatever was stored. Null means never attempted,
+ * which is a different fact from failure. */
+ shown: Explanation | null
+ generating: boolean
+ failure: string | null
+ request: () => Promise
+}
+
+/**
+ * One generation, read by two panels.
+ *
+ * LIVES ABOVE BOTH PANELS ON PURPOSE. The prose and the guideline passages come
+ * out of a single call and must reach the screen in a single paint — a judge
+ * watching the demo has to see that the references are part of the same
+ * operation as the text, not a lookup that happened earlier and agrees by
+ * coincidence. Held inside the explanation panel, this state was invisible to
+ * its sibling, and the only way to move the references was a second fetch that
+ * filled them a round-trip later.
+ *
+ * The alternative considered and rejected: `refresh()` after generating. That
+ * re-reads the ward, so the two panels update at different times and the
+ * references arrive from the *assessment* rather than from the generation.
+ */
+export function useExplanationRequest(
+ patientId: string,
+ assessedAt: string,
+ stored: Explanation | null,
+): ExplanationRequest {
+ const { stream } = useWard()
+ const [generated, setGenerated] = useState(null)
+ // WHICH reading the local result belongs to. Without it a generated
+ // explanation outlived the reading it described: the ward advances, a new
+ // score and band arrive, and the old prose stays on screen underneath them —
+ // directly above a footer promising a point-in-time rationale for THIS
+ // reading. Remounting on `assessedAt` would also clear it, but it would throw
+ // away an in-flight generation and wipe `failure` within one cadence period.
+ const [generatedFor, setGeneratedFor] = useState(null)
+ const [generating, setGenerating] = useState(false)
+ const [failure, setFailure] = useState(null)
+
+ const shown = generatedFor === assessedAt ? (generated ?? stored) : stored
+
+ const request = useCallback(async () => {
+ setGenerating(true)
+ setFailure(null)
+ try {
+ // Held for the duration. One thread owns the GPU, so generating and
+ // scoring cannot overlap: left running, the next tick stalls for the whole
+ // generation — and behind a cold load that is most of the 90 s the scoring
+ // call is allowed. Warming the explainer first is what removes it.
+ const result = await stream.withPause(
+ () => generateExplanation(patientId, { assessedAt }),
+ )
+ // One setState pair, so React commits both panels together.
+ setGenerated(result)
+ setGeneratedFor(assessedAt)
+ } catch (error) {
+ setFailure(error instanceof Error ? error.message : 'the generator did not respond')
+ } finally {
+ setGenerating(false)
+ }
+ }, [stream, patientId, assessedAt])
+
+ return { shown, generating, failure, request }
+}
diff --git a/front-end/src/hooks/useMeasuredWidth.ts b/front-end/src/hooks/useMeasuredWidth.ts
new file mode 100644
index 0000000..cb83536
--- /dev/null
+++ b/front-end/src/hooks/useMeasuredWidth.ts
@@ -0,0 +1,53 @@
+import { useCallback, useState } from 'react'
+
+/**
+ * The rendered width AND height of an element, in CSS pixels.
+ *
+ * ⚠️ Height is here because the alternative failed. The label row pitch was
+ * derived from the root font size read at module load — and in Vite dev the
+ * stylesheet is injected by JS *after* the modules evaluate, so
+ * `getComputedStyle(html).fontSize` was still the browser's 16px default. The
+ * pitch stayed 20px while the labels grew to 19px tall: one pixel of clearance,
+ * measured in the running app. Nothing threw. Measuring the rendered box has no
+ * such ordering hazard, and it measures the constraint itself rather than a
+ * proxy for it.
+ *
+ * The first DOM measurement in this codebase, and it exists for one reason: the
+ * ward scale places labels along a fluid track, but a label is a fixed pixel
+ * width. Any collision rule written in score-space is therefore right at one
+ * viewport and wrong at every other — which is exactly how three bed codes came
+ * to be drawn on top of one another.
+ *
+ * Returned as a callback ref rather than a `useRef` + effect so the first
+ * measurement happens the moment the node attaches, with no render showing an
+ * unmeasured zero. The cleanup return is React 19's ref-cleanup contract.
+ */
+export function useMeasuredWidth():
+ [(node: T | null) => void, number, number] {
+ const [box, setBox] = useState({ width: 0, height: 0 })
+
+ const ref = useCallback((node: T | null) => {
+ if (!node) return undefined
+ const first = node.getBoundingClientRect()
+ setBox({ width: first.width, height: first.height })
+
+ const observer = new ResizeObserver((entries) => {
+ // BORDER box, not `contentRect`. `contentRect` excludes padding, so a
+ // label measured through it came back 8px narrower than it draws (px-1
+ // each side) the moment the observer first fired — and the collision
+ // maths then reserved 70px for a 78px label, which is exactly how the
+ // leftmost one came to hang 4px off the end of the track.
+ const entry = entries[0]
+ const rect = node.getBoundingClientRect()
+ const width = entry?.borderBoxSize?.[0]?.inlineSize ?? rect.width
+ const height = entry?.borderBoxSize?.[0]?.blockSize ?? rect.height
+ // Ignore a zero: a hidden or detached node reports 0, and propagating it
+ // would collapse every placement to the same point.
+ if (width) setBox({ width, height })
+ })
+ observer.observe(node)
+ return () => observer.disconnect()
+ }, [])
+
+ return [ref, box.width, box.height]
+}
diff --git a/front-end/src/hooks/useWardClock.ts b/front-end/src/hooks/useWardClock.ts
new file mode 100644
index 0000000..dc5f226
--- /dev/null
+++ b/front-end/src/hooks/useWardClock.ts
@@ -0,0 +1,38 @@
+import { useMemo } from 'react'
+import type { Assessment } from '@contract/clinical'
+import { useClock } from './useClock'
+
+/**
+ * The clock the board measures staleness against.
+ *
+ * A simulated tick advances the ward an hour, because that is the grid the band
+ * table's dwell was fitted on. The ward's newest reading therefore runs ahead of
+ * the wall clock while the stream is running, and measuring against the browser
+ * would give every bed a negative age on a screen whose whole job is to say how
+ * fresh a value is.
+ *
+ * Ahead of the wall clock, the ward's own time wins. Otherwise this is exactly
+ * `useClock`, so nothing changes when nobody is streaming.
+ */
+export function useWardClock(ward: Assessment[]): { now: Date; simulated: boolean } {
+ const real = useClock()
+
+ // `Math.max(x, NaN)` is NaN and stays NaN, which would leave `simulated` false
+ // for ever and switch the whole feature off with nothing to see. Unreachable
+ // today — `assessed_at` is a Mongoose Date — but the failure mode is silent.
+ const newest = useMemo(
+ () => ward.reduce((latest, a) => {
+ const at = Date.parse(a.assessed_at)
+ return Number.isFinite(at) ? Math.max(latest, at) : latest
+ }, 0),
+ [ward],
+ )
+
+ return useMemo(() => {
+ // A whole minute of slack: seeding lands the newest reading on `now`, and
+ // without it the ordinary idle board would flicker into "simulated" on
+ // nothing more than clock jitter between the browser and the service.
+ const simulated = newest - real.getTime() > 60_000
+ return { now: simulated ? new Date(newest) : real, simulated }
+ }, [newest, real])
+}
diff --git a/front-end/src/hooks/useWardStream.ts b/front-end/src/hooks/useWardStream.ts
new file mode 100644
index 0000000..ba87780
--- /dev/null
+++ b/front-end/src/hooks/useWardStream.ts
@@ -0,0 +1,161 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+
+/** Cadences offered in the simulation panel. */
+export const STREAM_CADENCES = [2000, 3000, 5000] as const
+const DEFAULT_CADENCE = 3000
+
+export interface WardStream {
+ streaming: boolean
+ /** Completed ticks in the current run. Reset by `start`, because it is read
+ * as "how much have I sent since I pressed play" — anything that needs a
+ * change signal uses the ward's `revision` instead. */
+ ticks: number
+ error: string | null
+ cadenceMs: number
+ setCadenceMs: (ms: number) => void
+ start: () => void
+ stop: () => void
+ clearError: () => void
+ /**
+ * Hold the stream for the duration of `work`, then let it continue.
+ * Waits out any tick already in flight first, so `work` is not the thing
+ * blocking a tick that already holds the model-thread slot.
+ */
+ withPause: (work: () => Promise) => Promise
+}
+
+const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
+
+/**
+ * Drives the ward forward one reading at a time.
+ *
+ * SELF-CLOCKING, not `setInterval`. One tick is eight sequential scorings on a
+ * single GPU thread and roughly two dozen Mongo round-trips behind them — call
+ * it a second or two, not an instant. A fixed interval cannot know the previous
+ * tick is still running, and the server single-flights `/api/ward/tick` and
+ * 409s the overlap, so a stacked request would simply stop the stream.
+ *
+ * EPOCH-GUARDED. `stop()` cannot end a loop that is parked in `sleep()`, so a
+ * pause followed by a resume inside the cadence window used to leave the old
+ * loop alive and start a second one beside it — doubling the tick rate, which
+ * is the exact condition this hook exists to prevent. Each loop captures an
+ * epoch and exits the moment it is no longer the current one.
+ */
+export function useWardStream(advance: () => Promise): WardStream {
+ const [streaming, setStreaming] = useState(false)
+ const [ticks, setTicks] = useState(0)
+ const [error, setError] = useState(null)
+ const [cadenceMs, setCadenceMs] = useState(DEFAULT_CADENCE)
+
+ const running = useRef(false)
+ const epoch = useRef(0)
+ // A DEPTH, not a flag: two things can hold the stream at once, and the first
+ // to finish must not release it for the other.
+ const paused = useRef(0)
+ const inFlight = useRef | null>(null)
+
+ // Read through refs so changing the cadence mid-run takes effect on the next
+ // tick without restarting the loop.
+ const advanceRef = useRef(advance)
+ advanceRef.current = advance
+ const cadenceRef = useRef(cadenceMs)
+ cadenceRef.current = cadenceMs
+
+ const loop = useCallback(async (mine: number) => {
+ while (epoch.current === mine) {
+ // A PREVIOUS loop's tick may still be open. `stop()` ends a loop; it cannot
+ // recall a request already in the air, so a Pause immediately followed by a
+ // Play used to issue a second tick alongside the first. The server
+ // single-flights ward operations, so that collision came back as a 409 —
+ // which this loop then treated as fatal, killing the stream on a two-click
+ // gesture. Wait the old one out instead.
+ if (inFlight.current) {
+ await inFlight.current.catch(() => undefined)
+ // Yield once: awaiting a settled promise only drains microtasks, and the
+ // owning loop's `finally` needs to run before we look again.
+ await sleep(0)
+ continue
+ }
+ if (paused.current > 0) {
+ await sleep(100)
+ continue
+ }
+ let tick: Promise | undefined
+ try {
+ tick = advanceRef.current()
+ inFlight.current = tick
+ await tick
+ } catch (failure) {
+ // A tick that fails because the user stopped mid-flight is not an error
+ // worth showing; anything else stops the stream rather than retrying,
+ // because the plausible causes — an unseeded ward, an overlapping
+ // operation, a shed request — are not fixed by an identical second call.
+ if (epoch.current === mine) {
+ running.current = false
+ epoch.current += 1
+ setStreaming(false)
+ setError(failure instanceof Error ? failure.message : 'the ward stopped advancing')
+ }
+ return
+ } finally {
+ // Only if it is still OURS. Cleared unconditionally, an abandoned loop
+ // wipes a newer loop's handle — `withPause` then sees nothing in flight,
+ // skips its wait, and lets the 7B load race a live scoring tick on the
+ // one thread that owns the GPU.
+ if (tick && inFlight.current === tick) inFlight.current = null
+ }
+ if (epoch.current !== mine) return
+ setTicks((n) => n + 1)
+ await sleep(cadenceRef.current)
+ }
+ }, [])
+
+ const start = useCallback(() => {
+ if (running.current) return
+ running.current = true
+ epoch.current += 1
+ setStreaming(true)
+ setError(null)
+ setTicks(0)
+ // `paused` is deliberately untouched: a generation already holding the
+ // stream must keep holding it across a start.
+ void loop(epoch.current)
+ }, [loop])
+
+ const stop = useCallback(() => {
+ running.current = false
+ epoch.current += 1
+ setStreaming(false)
+ }, [])
+
+ const clearError = useCallback(() => setError(null), [])
+
+ const withPause = useCallback(async (work: () => Promise): Promise => {
+ paused.current += 1
+ try {
+ // One GPU thread serves scoring and the 7B both, so these cannot overlap.
+ // Waiting out the in-flight tick first means `work` queues behind one tick
+ // rather than a tick queueing behind 18-23 s of generation.
+ if (inFlight.current) await inFlight.current.catch(() => undefined)
+ return await work()
+ } finally {
+ paused.current -= 1
+ }
+ }, [])
+
+ // The provider wraps the router, so navigation does not unmount this — the
+ // stream is meant to survive it, which is why the patient screen can pause it.
+ // This is for the provider itself going away: a reload, or HMR.
+ useEffect(() => () => {
+ running.current = false
+ epoch.current += 1
+ }, [])
+
+ return useMemo(
+ () => ({
+ streaming, ticks, error, cadenceMs, setCadenceMs,
+ start, stop, clearError, withPause,
+ }),
+ [streaming, ticks, error, cadenceMs, start, stop, clearError, withPause],
+ )
+}
diff --git a/front-end/src/index.css b/front-end/src/index.css
index e0ca70a..e9cebb0 100644
--- a/front-end/src/index.css
+++ b/front-end/src/index.css
@@ -273,6 +273,51 @@
@layer base {
html {
+ /* THE ONE NUMBER THAT SETS THE SIZE OF EVERYTHING.
+ 106.25% of a 16px default = 17px, so the scale below reads
+ 11.7 · 13.3 · 14.9 · 17 rather than 11 · 12.5 · 14 · 16.
+
+ Chosen over 112.5% (18px) after measuring both on the board: 18px read
+ better but cost 78px more of a viewport that is already short, and the
+ ward board is the thing being demonstrated. See the fit note below.
+
+ Set here rather than by editing the `--text-*` tokens, because a rem is
+ also what every Tailwind padding, gap and fixed width in this app is
+ denominated in. Raising the tokens alone grows the text inside containers
+ that stay put, and `min-w-[9.5rem]`, `w-24` and `w-14` all start wrapping;
+ raising the root grows both together and none of them do.
+
+ ⚠️ It does NOT reach raw pixel values. The lucide `size={}` props were
+ scaled by hand and `SegmentMeter`'s bars moved to rem. `WardScale`'s row
+ pitch and leader height are derived from the MEASURED label box instead —
+ reading this value back off the document was tried and failed, because in
+ Vite dev the stylesheet is injected after the modules evaluate, so a
+ module-load read returns the browser's 16px default and the pitch stayed
+ 20px beside a 19px label. Anything added later that pairs a px constant
+ with a text size has to be measured or scaled; assuming a rem is not safe
+ at module scope.
+
+ ⚠️ THE BOARD NO LONGER FITS ONE SCREEN, AND IT DID NOT BEFORE THIS EITHER.
+ Measured 2026-08-22 in Chrome on this machine -- 150% display scaling, so
+ a maximised window is exactly the 876px viewport commit e8a4fcf tuned
+ against. The ranked list overflows by:
+
+ 240px at 16px root <- the scale before this change
+ 324px at 17px <- here
+ 402px at 18px
+
+ So this made an existing overflow worse; it did not create one. The ward
+ gained a per-row "awaiting clinician review" banner since e8a4fcf, and
+ seven rows at 87px is simply more than the 412px the list pane gets. The
+ list has `overflow-y-auto`, so it scrolls rather than clipping.
+
+ The height is IN THE BED ROWS. It is not in the h1 (27px), not in the note
+ beside it (69px, and worth ~23px if its measure were widened), and not in
+ the risk scale (203px, most of it the label rows and the axis). Anyone
+ trying to win it back should start by measuring, not by shrinking chrome.
+
+ Lowering this number is the cheap lever and it buys ~80px a step. */
+ font-size: 106.25%;
-webkit-text-size-adjust: 100%;
background-color: var(--page);
}
diff --git a/front-end/src/lib/labelPlacement.ts b/front-end/src/lib/labelPlacement.ts
new file mode 100644
index 0000000..2be9110
--- /dev/null
+++ b/front-end/src/lib/labelPlacement.ts
@@ -0,0 +1,198 @@
+/**
+ * Spreading crowded labels along a shared axis.
+ *
+ * A bed label is a fixed ~78px wide; the axis it sits on is fluid. When two
+ * patients score close together their labels overlap, and the previous approach
+ * — stack into one of three rows, decide by a fixed distance in SCORE space —
+ * failed twice over: the row counter saturated and wrote surplus labels on top
+ * of each other without checking, and the score-space threshold was worth 116px
+ * at one viewport and 48px at another.
+ *
+ * This works in pixels, and moves labels sideways rather than upwards. Every
+ * label keeps its full text; the leader line is what ties it back to its mark.
+ */
+
+/**
+ * CSS pixels in one rem, read from the document rather than assumed to be 16.
+ *
+ * The gutter sits beside text sized by the type scale, and the type scale is in
+ * rem. Hardcoded at 12 it was right at a 16px root and silently wrong the moment
+ * `html { font-size }` moved.
+ *
+ * ⚠️ READ LAZILY, ON FIRST USE — NOT AT MODULE LOAD. Read at load it came back
+ * 16 even though the root is 18: in Vite dev the stylesheet is injected by JS
+ * after the modules evaluate, so the document has no styles yet when this file
+ * runs. `placeLabels` is only ever called during a render, by which point it
+ * does. The `document === undefined` fallback keeps the function usable outside
+ * a browser, which is what lets the geometry be fuzzed.
+ */
+let remCache = 0
+const rem = () => {
+ if (remCache) return remCache
+ remCache = typeof document === 'undefined'
+ ? 16
+ : parseFloat(getComputedStyle(document.documentElement).fontSize) || 16
+ return remCache
+}
+
+/** Clear space between two labels sharing a row, in pixels. */
+const gutter = () => 0.75 * rem()
+
+/** Rows are cheap but not free — each one pushes the axis further down. */
+const MAX_ROWS = 3
+
+export interface Placeable {
+ id: string
+ /** Position along the axis, 0..1. */
+ value: number
+}
+
+export interface Placement {
+ id: string
+ /** Where the mark belongs, in px. */
+ trueX: number
+ /** Where the label is drawn, in px. */
+ placedX: number
+ row: number
+}
+
+/**
+ * Rows are for VERTICAL clearance, not for capacity.
+ *
+ * Two labels on different rows may overlap horizontally as much as they like, so
+ * alternating rows lets neighbours sit `need / rows` apart instead of `need`.
+ * That is the whole reason to add a row: it buys horizontal room, which is what
+ * decides how far a label has to travel from its own mark.
+ */
+function rowsFor(xs: number[], need: number, trackWidth: number, labelWidth: number): number {
+ const tightest = xs.length < 2
+ ? Infinity
+ : Math.min(...xs.slice(1).map((x, i) => x - xs[i]))
+
+ let rows = 1
+ if (tightest < need) rows = 2
+ if (tightest < need / 2) rows = 3
+
+ // And enough rows that the whole run physically fits the track.
+ const usable = Math.max(trackWidth - labelWidth, 1)
+ const forFit = Math.ceil(((xs.length - 1) * need) / usable)
+
+ // ⚠️ `forFit` can exceed MAX_ROWS, and then the run does NOT fit however this
+ // clamp resolves. `placeLabels` narrows the gap for that case; capping here
+ // and doing nothing else is what used to push labels off the left edge.
+ return Math.min(MAX_ROWS, Math.max(1, rows, forFit))
+}
+
+/**
+ * Merge overlapping labels into groups, centre each group on its members' mean
+ * position, and keep the run inside the track.
+ *
+ * Centring on the mean rather than pushing rightwards keeps the displacement
+ * symmetric: a cluster opens outwards from where it actually sits instead of
+ * drifting off in one direction.
+ */
+function spread(items: Placeable[], gap: number, trackWidth: number, labelWidth: number) {
+ const half = gap / 2
+ const edge = labelWidth / 2
+ let groups = items.map((item) => ({ items: [item], centre: item.value * trackWidth }))
+
+ // Bounded rather than `while (true)`: each pass either merges a pair or stops,
+ // so it cannot run longer than the number of items, and the guard keeps a
+ // future edit from turning a layout bug into a frozen tab.
+ for (let pass = 0; pass < items.length + 1; pass++) {
+ for (const group of groups) {
+ const reach = ((group.items.length - 1) * gap) / 2 + edge
+ // A label at score 0 used to hang ~39px off the left edge of the track.
+ group.centre = Math.min(Math.max(group.centre, reach), trackWidth - reach)
+ }
+
+ let merged = false
+ for (let i = 0; i < groups.length - 1; i++) {
+ const left = groups[i]
+ const right = groups[i + 1]
+ if (left.centre + left.items.length * half > right.centre - right.items.length * half) {
+ const combined = [...left.items, ...right.items]
+ groups.splice(i, 2, {
+ items: combined,
+ centre: combined.reduce((sum, it) => sum + it.value * trackWidth, 0) / combined.length,
+ })
+ merged = true
+ break
+ }
+ }
+ if (!merged) break
+ }
+
+ const placed = new Map()
+ for (const group of groups) {
+ const start = group.centre - ((group.items.length - 1) * gap) / 2
+ group.items.forEach((item, i) => placed.set(item.id, start + i * gap))
+ }
+ return placed
+}
+
+/**
+ * Place every label. `labelWidth` and `trackWidth` are measured, not assumed —
+ * see `useMeasuredWidth`. Returns nothing until both are known, so the first
+ * paint draws no labels rather than drawing them all at zero.
+ *
+ * ONE placement pass over every label, in score order, with rows assigned round
+ * robin afterwards. Placing each row separately looked reasonable and was not:
+ * two rows centred on their own members drift independently, so a label could
+ * end up left of one whose mark is further left. A crossed leader is worse than
+ * a crowded one — it points at the wrong patient.
+ *
+ * ⚠️ GLOBAL PLACEMENT IS NOT ON ITS OWN ENOUGH, and this file claimed for a
+ * while that it was. Monotonic `placedX` makes the label ENDPOINTS ordered; it
+ * says nothing about the SEGMENTS between mark and label. With the leader drawn
+ * straight to a per-row height, `row = index % rows` cycles the rise 22, 42, 62,
+ * 22 … so two adjacent labels get very different slopes and the lines cross
+ * between the axis and the text — measured on 29% of 200,000 clustered
+ * configurations, while the evenly-spread demo ward never showed it.
+ *
+ * The invariant that actually holds: with `trueX` and `placedX` both
+ * non-decreasing and EVERY leader rising the same height R, the gap between two
+ * leaders is linear in y and non-negative at both ends (`trueX` order at y=0,
+ * `placedX` order at y=R), so it cannot change sign in between. `WardScale`
+ * therefore fans every leader to one height and stacks rows on a vertical riser
+ * above it — verticals sit at distinct `placedX` and live entirely above the
+ * diagonals, so neither can meet the other. Rows are free again.
+ */
+export function placeLabels(
+ items: Placeable[],
+ trackWidth: number,
+ labelWidth: number,
+): { placements: Placement[]; rows: number } {
+ if (!items.length || trackWidth <= 0 || labelWidth <= 0) {
+ return { placements: [], rows: 1 }
+ }
+
+ const need = labelWidth + gutter()
+ const ordered = [...items].sort((a, b) => a.value - b.value)
+ const rows = rowsFor(ordered.map((i) => i.value * trackWidth), need, trackWidth, labelWidth)
+
+ // Neighbours land on different rows, so they only need `need / rows` between
+ // them; labels `rows` apart share a row and are a full `need` apart.
+ //
+ // NARROWED WHEN THE RUN STILL WILL NOT FIT. `rowsFor` caps at MAX_ROWS, so at
+ // enough beds or a narrow enough track the ideal gap is wider than the track
+ // can hold. `spread`'s clamp then inverts — `reach > trackWidth - reach`, so
+ // `Math.min` wins and the group's left edge goes negative — and the labels
+ // were pushed off the track, measured to −248px, where `xl:overflow-hidden`
+ // clips them outright. Labels touching is a legible compromise; labels not on
+ // screen is not.
+ const fits = ordered.length > 1
+ ? Math.max(trackWidth - labelWidth, 0) / (ordered.length - 1)
+ : Infinity
+ const placed = spread(ordered, Math.min(need / rows, fits), trackWidth, labelWidth)
+
+ return {
+ rows,
+ placements: ordered.map((item, index) => ({
+ id: item.id,
+ trueX: item.value * trackWidth,
+ placedX: placed.get(item.id) ?? item.value * trackWidth,
+ row: index % rows,
+ })),
+ }
+}
diff --git a/front-end/src/screens/ParameterDetail.tsx b/front-end/src/screens/ParameterDetail.tsx
index 5a4074f..f6c9c26 100644
--- a/front-end/src/screens/ParameterDetail.tsx
+++ b/front-end/src/screens/ParameterDetail.tsx
@@ -56,7 +56,7 @@ export function ParameterDetail() {
to={`/patient/${patientId}`}
className="inline-flex items-center gap-1.5 text-2xs text-ink-500 transition-colors hover:text-accent"
>
-
+
Back to {assessment.bed_code}
diff --git a/front-end/src/screens/PatientDetail.tsx b/front-end/src/screens/PatientDetail.tsx
index aaf884b..97d5775 100644
--- a/front-end/src/screens/PatientDetail.tsx
+++ b/front-end/src/screens/PatientDetail.tsx
@@ -6,8 +6,9 @@ import { isScored } from '@contract/clinical'
import { SUFFICIENCY_FLOOR, reviewPrompt, toObservations } from '../data/feed'
import { useAssessment, useWard } from '../data/WardProvider'
import { bandMeaning } from '../data/bands'
-import { useClock } from '../hooks/useClock'
+import { useWardClock } from '../hooks/useWardClock'
import { usePatientHistory } from '../hooks/useApi'
+import { useExplanationRequest } from '../hooks/useExplanationRequest'
import {
BAND_STATE_LABEL,
BAND_STATE_MEANING,
@@ -19,6 +20,7 @@ import { BreathRhythm } from '../components/charts/BreathRhythm'
import { ObservationStrip } from '../components/charts/ObservationStrip'
import { ContributorList } from '../components/detail/ContributorList'
import { ExplanationPanel } from '../components/detail/ExplanationPanel'
+import { GuidelineReferences } from '../components/detail/GuidelineReferences'
import { ParameterTable } from '../components/detail/ParameterTable'
import { PromptBanner } from '../components/detail/PromptBanner'
import { PatientContextDrawer } from '../components/detail/PatientContextDrawer'
@@ -28,15 +30,24 @@ import { Panel } from '../components/ui/Panel'
export function PatientDetail() {
const { patientId = '' } = useParams()
- const now = useClock()
const [drawerOpen, setDrawerOpen] = useState(false)
const [recording, setRecording] = useState(false)
const [reviewError, setReviewError] = useState(null)
- const { refresh } = useWard()
+ const { refresh, ward, revision } = useWard()
+ const { now } = useWardClock(ward)
const assessment = useAssessment(patientId)
- const { data: history } = usePatientHistory(patientId)
+ const { data: history } = usePatientHistory(patientId, revision)
+
+ // ABOVE the early return: hooks cannot sit behind a conditional. Reads the
+ // stored explanation off the assessment when there is one, and feeds BOTH the
+ // prose panel and the guideline-references panel from a single result.
+ const storedExplanation =
+ assessment && isScored(assessment) ? assessment.explanation : null
+ const explanation = useExplanationRequest(
+ patientId, assessment?.assessed_at ?? '', storedExplanation,
+ )
if (!assessment) {
return (
@@ -84,7 +95,7 @@ export function PatientDetail() {
to="/"
className="inline-flex items-center gap-1.5 text-2xs text-ink-500 transition-colors hover:text-accent"
>
-
+
Back to overview
@@ -135,6 +146,15 @@ export function PatientDetail() {
Prompt closed as {review.disposition} at{' '}
{formatClock(new Date(review.reviewed_at))}
+ {/* BOTH CLOCKS, LABELLED. A simulated tick advances the ward an hour,
+ so the wall-clock instant a clinician acted can sit a day from the
+ reading it answers — measured at 26.6 h — while every other time on
+ this screen is ward time. Printing the real instant alone made the
+ disposition look like it preceded its own prompt. The field was
+ already stored and reached no screen. */}
+ {review.ward_time_at_review && (
+ <> (ward clock {formatClock(new Date(review.ward_time_at_review))})>
+ )}
{review.attributed
? ` by ${review.clinician}.`
: ' — not attributed to a named clinician, because this build has no authentication.'}
@@ -244,41 +264,23 @@ export function PatientDetail() {
Plain-language explanation
- Retrieved from a fixed approved library — not generated. Prototype uses
- sample citations.
-
- >
- ) : (
-
- No references retrieved for this reading.
-
- )}
+ {/* Same object as the panel beside it, so one click fills both in
+ one paint. The references are part of the generation, not a
+ lookup that ran earlier and agrees. */}
+
>
diff --git a/front-end/src/screens/PatientOverviewBoard.tsx b/front-end/src/screens/PatientOverviewBoard.tsx
index 018cb14..b71ef42 100644
--- a/front-end/src/screens/PatientOverviewBoard.tsx
+++ b/front-end/src/screens/PatientOverviewBoard.tsx
@@ -8,7 +8,7 @@ import {
rankedPatients,
} from '../data/feed'
import { useWard } from '../data/WardProvider'
-import { useClock } from '../hooks/useClock'
+import { useWardClock } from '../hooks/useWardClock'
import { cn } from '../lib/cn'
import { pluralise } from '../lib/format'
import { DataLimitedRow } from '../components/board/DataLimitedRow'
@@ -28,8 +28,10 @@ const FILTERS: Array<{ key: Filter; label: string }> = [
]
export function PatientOverviewBoard() {
- const now = useClock()
const { ward, loading, error } = useWard()
+ // Ward time, not browser time: a simulated tick moves the ward an hour, so
+ // ages measured against the wall clock would go negative while it streams.
+ const { now } = useWardClock(ward)
const [query, setQuery] = useState('')
const [filter, setFilter] = useState('all')
@@ -81,13 +83,29 @@ export function PatientOverviewBoard() {
}
return (
-
- {/* The one display-tier element on the screen. Everything else is text. */}
-
-
- Adult ventilated ICU patients
-
-
+
+ {/* The one display-tier element on the screen. Everything else is text.
+ Set down from 4xl/5xl: at 49-61px it and its note cost 116px of a
+ 876px screen, which is two ward beds. The tier survives — deleting the
+ h1 would remove display type from the board entirely — but it earns
+ its space on one line beside the note.
+
+ ⚠️ Now `text-xl`, down from `text-2xl`. The comment above said "24px"
+ while the class said 2xl, which is 31 — it had been describing an
+ intention rather than the screen.
+
+ ⚠️ And this is NOT where the height is. Measured on an 876px viewport
+ at an 18px root — the largest scale tried, so the most favourable case
+ for finding room here — the block was 77px of which the h1 was 27 and
+ the NOTE was 69. They sit side by side, so the taller one sets the
+ height: dropping the h1 a step bought SIX PIXELS. Widening the note's
+ `56ch` measure would recover ~23px and is not worth the reading
+ comfort. The board's real height is in the bed rows, seven of them at
+ 87px, each carrying an "awaiting clinician review" banner. Do not come
+ here expecting to find it. */}
+
+
Adult ventilated ICU patients
+
A prompt is raised only when a band change is sustained and confirmed — roughly one
in every 34 readings. A patient held at HIGH for six hours is one interruption, not
seventy.
@@ -97,9 +115,9 @@ export function PatientOverviewBoard() {
{/* The ward on one calibrated axis. Segment widths are the real cut points, so the
geometry says something true: most readings sit in a band that occupies an
eighth of the scale, and nearly half the scale is CRITICAL. */}
-
-
+ {/* `min-h-0` on the row AND on both panes: without it each flex child
+ keeps its automatic minimum height, refuses to shrink, and the
+ overflow rule never applies — the page just grows again. */}
+