Skip to content

Serve two front-ends, and set the VRAM gate from a measurement - #1

Open
UniverseScripts wants to merge 11 commits into
mainfrom
feat/live-model-integration
Open

Serve two front-ends, and set the VRAM gate from a measurement#1
UniverseScripts wants to merge 11 commits into
mainfrom
feat/live-model-integration

Conversation

@UniverseScripts

@UniverseScripts UniverseScripts commented Sep 7, 2026

Copy link
Copy Markdown
Owner

What this branch carries

Two things, both already on upstream:

  1. Two front-ends (c09464f) — the finalized SvelteKit UI takes :5173 as the clinical demo, this repository's React app moves to :5174 as the engineering view. Both read the same /api on :3500, so the backend stays the single source of truth and neither can drift from the model. README updated with the three installs and the two pipeline docks.

  2. The VRAM gate (dc6e292) — MIN_FREE_VRAM_MIB drops 6700 → 6420 and now reads pipeline.config.LLM_MIN_FREE_VRAM_MIB rather than declaring its own value.

Why the gate moved

It was never measured. The old comment bracketed the requirement between a segfault at 6561 MiB free and a success at 6721, and admitted the value chosen inside that band was "slightly optimistic". Nobody had watched the card during a load.

bki/pipeline/tools/vram_probe.py (see UniverseScripts/pulsemind-ai#1) samples the driver at 5 Hz across a load. Six runs peaked at 6056–6239 MiB, spread 183. 6420 = max peak + spread.

One definition, two readers

This service and s19_generate had drifted 1455 MiB apart — the stage still asserted vram_free_gb > 5.5, a level already known to segfault — because each carried its own constant. Only the value moved to config; the evidence table stays here, where the gate that acts on it lives.

⚠️ Lowest-that-loads, chosen deliberately. 6420 sits below the 6561 that segfaulted once, so it will permit a configuration that has failed. Warm the explainer before a demo, when a crash costs a restart rather than an audience.

Verification

Full suite against the live stack, all green:

secrets_gate passed
check_node 18/18
check_conventions 16/16
check_service 35/35
check_llm 10/10 — 7B ran, grounding passed, 0 violations
floor_margin PM-355 refuses on all 13 readings, min margin +0.1316

Also verified in real Chrome on :5173: every board score matches the CLI exactly, a live 7B generate returned byte-identical text, PM-355 renders score unavailable carrying imputed 43.16% / doc 25.53%, a tick advanced the ward clock exactly one hour, and the pipeline dock logs route templates (/patient/:id) rather than resolved paths. Console clean.

🤖 Generated with Claude Code

UniverseScripts and others added 11 commits August 19, 2026 13:51
Adds a demo-only telemetry driver and reworks the board so a clinician does
not scroll to see it. Both were verified against the running stack.

THE WARD CLOCK

A tick now continues each stay's own hourly grid instead of stamping the wall
clock. `/ward/seed` already walked that grid, so only the live tick diverged,
and it diverged in ways that mattered: the latch clock saw seconds between
readings while the physiology advanced an hour, so `demote_dwell_min = 120`
was unreachable and no band could ever step back down. Every parameter also
aged in seconds, and the stay's start slid an hour further into the past on
each reading. 44 ticks of seed and the same span driven through tick now
produce identical trajectories -- 17 band changes, 3 completed demotions.

CONCURRENCY AND RECORDS

- `/api/ward/tick` and `/api/ward/seed` share one guard. Both read then write
  the same StayState with no version predicate, so concurrently a tick's write
  landed after the seed's delete -- a lost update with no error anywhere.
- The stream driver is epoch-guarded and waits out any tick already in flight.
  Pause then Play inside the cadence window used to issue a second tick, take
  a 409, and kill the stream.
- `reviewed_at` stays the real instant and `ward_time_at_review` is recorded
  beside it. Stamping the ward's clock into `reviewed_at` fixed an ordering
  problem by writing a time at which nothing happened, in the only
  human-authored record the system holds.
- A failed regeneration no longer overwrites a good stored explanation with
  the permanent "unavailable" string.
- An explanation is discarded when the reading it describes is superseded, so
  prose cannot sit under a score it does not belong to.

THE SCALE LABELS

Labels overlapped because the row counter saturated and wrote surplus labels
on top of each other unchecked, and because the collision threshold was a
fraction of the score axis while a label is a fixed 78px -- 116px of clearance
at one viewport and 48px at another. Placement is now measured in pixels and
labels move sideways, with the leader lines tying each back to its mark. One
global pass keeps position monotonic in score, so the lines cannot cross.
Verified at seven track widths and ten degenerate inputs.

THE LAYOUT

2107px against an 876px viewport became one screen that does not scroll. The
simulation panel moved out of the clinician's column into the chrome -- 307px
of prototype affordance was pushing the ward off the bottom. Score and bed
identifiers sit on one baseline rather than stacked, taking rows from 64px to
47px. All seven scored beds are visible at once; the data-limited bed keeps
its separate section and needs a short scroll within the list pane.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZbJV3yRcxhRHBzEBannhP
Three small corrections to the simulation controls, each measured rather than
reasoned about.

RESTART ASKS FIRST

Restarting deletes every assessment, prompt and clinician disposition. The last
of those is the only human-authored record the system holds and the one thing a
re-seed cannot reproduce -- and moving the control into permanent chrome had put
it one button away from "Stream ward". It now arms on the first click and acts
on the second, with a Cancel beside it.

Armed state persists rather than timing out. `DESIGN.md` forbids anything that
expires on its own, on the grounds that a nurse returning to an interrupted
screen must find it as they left it.

Measured, because the bar is `flex-wrap` and the shell is `fixed inset-0` with
the chrome `shrink-0` -- any height the bar gains comes straight out of the
board pane. It gains none: 40px in every state at 1920, 1707, 1600, 1440 and
1366, with the visible bed count unchanged and the page still not scrolling.

ONE HISTORY FETCH ON A COLD LOAD, NOT TWO

`revision` bumped on the very first ward load, so a deep link straight to a
patient fetched its history at revision 0 and again the moment the provider
settled. Measured against a production build with the change stashed and
restored: 2 fetches without it, 1 with. (In dev it is 3 either way -- StrictMode
double-invokes the mount effect, which is why the counterfactual was measured on
a build rather than the dev server.)

THE TICK COUNTER IS PER-RUN

It read the pre-restart total over a freshly rebuilt ward. It now resets on
start. Safe because `stream.ticks` has exactly one consumer, the display line
itself; the monotonicity the old comment promised was only needed back when
history keyed off it, and it keys off `revision` now. Verified: second run
starts at 1, and the history strip still refreshes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZbJV3yRcxhRHBzEBannhP
"No references retrieved for this reading" was never a missing RAG branch. Every
piece already existed and was wired -- the evidence map, the policy that loads
it, `select_evidence`, `contract.assessment(citations=)`, the Mongo schema field,
and the panel that renders them. `_score_tick` simply never passed the argument,
so it defaulted to `[]` on every reading.

Retrieval already happened, offline. s21 ran dense MedCPT retrieval with a
cross-encoder over a 189-chunk corpus, produced 57 keys -- 41 accepted, 7 weak,
9 with no admissible passage -- and a human reviewed them before they were
frozen. At serving time this is a dict lookup, which is why `select_evidence`
says "no retrieval happens here and none can". A fabricated citation is
structurally impossible rather than merely unlikely.

Attached at SCORING time, not explanation time: `citations` sits on the
assessment in the contract, and a clinician can read what the guidance says
without first waiting 20 s for prose. They are also the same passages the
generator is shown, so the panel displays what the explanation was grounded
against rather than a second decorative list. `contract.assessment` drops them
on a refused reading, so there is no branch at the call site.

`policy()` is now cached. It reads 73 KB of JSON and this puts it on the scoring
path, once per bed per tick, rather than only when someone asks for prose; the
map is a build artifact and cannot change under a running service.

The panel's caption said "Prototype uses sample citations". That stopped being
true the moment real keys flowed, so it now says what is actually the case.

Verified: 7 of 7 scored beds carry a citation, the refused bed carries none from
the service, and the panel renders real AARC protocol text with its section.
check_service 17/17, check_node 11/11, e2e_stream 10/10, e2e_explain 7/7, and the
board is unchanged -- no page scroll, no label overlaps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZbJV3yRcxhRHBzEBannhP
…ders crossing

The references panel filled at scoring time, so it read as a lookup that had
already happened and agreed with the prose by coincidence. The demo's whole
point is that the passages come out of the same operation as the text.

Citations now derive from `block["guideline_context"]` -- the passages the
generator was actually shown -- and live on the explanation rather than the
assessment, persisted with the text they grounded. `useExplanationRequest` sits
above both sibling panels in `PatientDetail`, so one click fills both: verified
frame-accurate in Chrome, both panels changing on the same rAF tick.

An empty citation list is three states and only one is a shortfall: nothing
generated yet, the template floor which never consults the library, and the 7B
having run against a key with no admissible passage (9 of the 57). `generator`
is what separates them, which is why it is persisted alongside -- saying "no
references retrieved" for all three reported two correct outcomes as a failure.

From the adversarial review, five findings a judge would have seen:

- LEADER LINES CROSSED, and the comment claiming they could not was wrong.
  Monotonic placement orders the label endpoints, not the segments between mark
  and label: `row = index % rows` cycles the rise, so adjacent labels get very
  different slopes and one leader overtakes another. A clinician tracing a mark
  upward arrived at the wrong bed code. Measured 121,014 of 200,000 clustered
  configurations; the evenly-spread demo ward never showed it, which is why the
  earlier check passed. Fixed by fanning every diagonal to one height and
  climbing rows on a vertical riser -- 0 of 200,000 after.
- Labels escaped the track below ~288px because `Math.min(MAX_ROWS, ...)`
  discarded the fit requirement. 85,631 of 200,000, worst 568px off-canvas and
  clipped by `xl:overflow-hidden`. The gap now narrows instead.
- The input-status disclosure was inert whenever a source was dropped -- the one
  state its own comment says it cares about.
- "Restore all" restored one device: N concurrent read-modify-writes over one
  array, each response individually correct. One request now.
- The review timestamp read 26.6 h from the reading it answered.
  `ward_time_at_review` was stored, typed, and rendered nowhere.

Also: `setDeviceState` holds `wardBusy` (a chip toggled during a re-seed threw
DocumentNotFoundError into an unhandled rejection), and `explainPatient` reports
`stored` from `matchedCount` rather than asserting it.

The 7B load is gated on driver-reported free VRAM before the allocation, not
wrapped in a try. It does not raise when it will not fit -- it segfaults, so no
except clause runs and the service dies. `checks/check_service.py` 17 -> 23.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZbJV3yRcxhRHBzEBannhP
…down

`require('dotenv').config()` resolves `.env` against process.cwd(), not against
the script, so `node pulsemind_demo/back-end/server.js` from the workspace root
loaded nothing and mongoose rejected an undefined MONGODB_URI. Pinning the path
to __dirname makes the documented "runs from anywhere" actually true.

The README's four-line run block becomes the full sequence: each service with
its exact command and its ready-signal, then seeding, warming, confirming and
stopping. It records the traps that cost real time -- Vite binds IPv6 [::1]
only so 127.0.0.1:5173 is refused; PM_ALLOW_DESTRUCTIVE gates a seed that
deletes all three collections; LOG_SUBJECT_KEY keys the HMAC that pseudonymises
identifiers in logs, and unset they read 'unkeyed'.

It also states what PYTHONPATH is and is not. `pipeline.core` resolves through
the editable install of pulsemind_bki, not the path variable -- verified with
PYTHONPATH unset -- so the workspace-root .env entry that appears to carry it is
a fallback for a venv rebuild, read by nothing. Python does not parse .env
files and the variable is in neither the environment nor HKCU\Environment.

The VRAM figures are recorded beside the warm-up step, because the gate at
6700 MiB is only half the story: the model holds ~6079 MiB resident and
generation runs 13.7 s at 642 MiB free against 77.3 s at 60 MiB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYGadugJ72Vr9QYaqQDjjC
A clone of pulsemind_demo cannot run the demo, and nothing said so. The
dependency list was there all along -- back-end/pythonService/requirements.txt,
tracked and complete -- but no README referenced it, and installing it is not
sufficient on its own.

Its first line is `-e ../../../bki[llm]`, an editable install from a local
checkout rather than PyPI, so the model repository must sit beside this one or
the install fails on line 1 and nothing else lands. The fitted artifacts are a
harder stop: serving_assets.json and its companions are loaded at startup and
are in neither repository, because they are derived from MIMIC-IV under a
PhysioNet DUA.

Two corrections to commands that were already wrong in the tree:

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. Every documented install
now uses `uv pip`.

The relative bki path is resolved against the WORKING DIRECTORY, not against the
requirements file, so the install only works from pythonService/. Verified by
running it from elsewhere: "path could not be normalized: D:\Temp\../../../bki".

back-end/README.md carried the same $env:PYTHONPATH line and the same stale
"from back-end/" instruction as the file corrected in d7251c2; both are gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYGadugJ72Vr9QYaqQDjjC
The board shows a conclusion. Nothing showed that a real model scored a real
feature row in milliseconds and a real 7B wrote prose in seconds -- which for a
prototype judged on whether the machinery is real is the most valuable thing not
on screen.

Every tier now times its own work and returns it on a W3C Server-Timing header;
feed.ts parses it into a dock opened from the prototype feed bar. Header rather
than a payload field, so contract/clinical.ts stays clinical -- an operational
figure in there would also have to be declared in explanationSchema to survive a
save (PM-VER-003), for numbers nobody wants persisted.

Span names are the published pipeline stages (docs figure 3): collect, order,
assess, decide, then rank/floor/baseline/load/explain/ground for stage 5. queue,
upstream, mongo and total are transport and storage and are drawn as such -- a
queue wait is not a stage of clinical reasoning.

THE "~66 MS TO SCORE" IN THREE DOCSTRINGS HAD NEVER BEEN COMPUTED. model_runtime
contained no clock at all. Measured now: 60.4 ms a reading, 14.9 ms to assemble
the 109-column row, 12 us for the band machine, and 1.16 s writing to Atlas. The
database, not the model, is most of a tick. The editorial figure was close, which
is luck rather than evidence.

Four things that only running it could find:

  - mongo;dur=1325 inside total;dur=577. GET /ward issues its eight reads
    concurrently, and summing them reported a child four times larger than the
    request containing it. The panel INDENTS spans to show what nests in what, so
    a sum over concurrent work does not overstate a number, it draws a tree that
    is not one. Node unions the intervals and derives "8x" vs "8x concurrent"
    from them rather than trusting a caller to say.

  - The header cannot be emitted from the 'finish' listener where the logger
    already computes a duration -- by then the headers are on the wire. It goes
    out from a patched res.writeHead.

  - timings is threaded explicitly down the scoring path. The work runs on the
    shared model daemon thread, so a ContextVar would attribute one request's
    time to another under concurrency.

  - A stage that did not run is ABSENT, never zero (PM-CLIN-001 applied to a
    measurement), and the formatter has to hold the same line: the deterministic
    template runs in ~0.05 ms and printed as `0.0ms`, which is the one value the
    panel uses to mean "did not run". Sub-0.1 ms renders as `<0.1ms`.

X-Request-Id already crossed into FastAPI and was never read there; it is echoed
now, so one id means the same thing in all three processes. usePatientContext
hand-rolled its own fetch, which is closed -- feed.ts is now the only module in
src/ that touches the network, so the log's coverage is a property rather than a
claim. No response body enters the store, so the explanation cannot reach the
panel even by accident (PM-LOG-003), and routes are logged as templates.

check_service 23 -> 35, check_node gains B4. Two of those have teeth: mongo is
recorded by a Mongoose plugin resolving the response out of AsyncLocalStorage,
which would record nothing SILENTLY if that context stopped surviving the
driver's async boundary; and the containment assertion is what caught the sum.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYGadugJ72Vr9QYaqQDjjC
Feedback on the demo video was that the UI is too small to read. text-2xs -- an
11px floor -- was used 101 times across 26 files, and the risk scale and the
triage rows, the two things on screen for nearly the whole demo, are built almost
entirely from it.

One number does it: html { font-size: 106.25% } takes the root to 17px, and every
--text-* token, every rem padding and every fixed width grows together, so
nothing wraps. Raising the tokens alone would have grown text inside containers
that stayed put. 112.5% read better and was measured, but cost 78px more of a
viewport that is already short, and the ward board is the thing being shown.

WHAT THE ROOT DOES NOT REACH, and what that cost:

  - ROW_PITCH was 20px against a label that grew to 19px tall. ONE pixel of
    clearance, and nothing threw. Deriving it from rem failed too: read at module
    load, the root is still the browser's 16px default because Vite injects the
    stylesheet after the modules evaluate. It now comes from the MEASURED label
    box -- the probe that already measures the width -- which has no ordering
    hazard and measures the constraint itself rather than a proxy. 22px against
    18.56px at 18px root, 21 against 17.53 at 17.
  - 22 lucide size={} props, scaled by hand.
  - SegmentMeter's bars, moved to rem.

Fuzzed the label geometry over 200,000 CLUSTERED configurations at two label
widths: 0 crossings, 0 same-row overlaps where the run can fit, 0 escapes. The
first version of that fuzz asserted "0 overlaps" outright and failed 35,775
times -- touching is the documented compromise when a run cannot fit, and the
feasibility test is over the whole run, not one row, because spread() narrows to
a single global gap.

Also fixed, all found by looking:

  - ExplanationPanel set `text-md`, which exists in neither the theme nor
    Tailwind. It compiled to nothing, so the AI rationale had been rendering at
    body size since it was written, directly under a comment claiming it was set
    larger. Now text-base: 17px against a 15.75px body.
  - BandTag was shrink-0 with no fixed width, so the pill ran 74.6px at LOW to
    106.5px at CRITICAL and everything after it shifted -- the score numeral
    started at four different x positions down one board (378, 383, 403, 410). A
    column of numbers that does not form a column is the one thing a triage board
    cannot afford. Fixed 6.75rem column; the pill keeps its natural size inside
    it, since stretching it would put a wide LOW badge beside a wide CRITICAL one
    and imply they carry the same weight.
  - Re-prompting an explanation left the prose static while the references panel
    beside it visibly cleared and refilled. `generating` was checked INSIDE the
    `shown === null` branch, so the in-progress state existed only for the first
    generation. Checked before it now. Dimming the old prose was tried and
    rejected: greyed-out text still reads as the answer while the panel claims to
    be writing a new one.
  - Decoding is greedy, so re-running the model on one reading returns
    byte-identical prose -- verified with two live calls, same sha256, same 609
    characters. The panel says so before anyone waits 20 s for it, because
    otherwise the model reproducing itself exactly is indistinguishable from a
    button that did nothing.

THE BOARD DOES NOT FIT ONE SCREEN AT THE RECORDING VIEWPORT, AND DID NOT BEFORE
THIS. The display runs at 150% scaling, so a maximised window is exactly the
876px viewport commit e8a4fcf tuned against. The ranked list overflows by 240px
at 16px, 323px at 17px, 402px at 18px. This made an existing overflow worse
rather than creating one: the ward gained a per-row "awaiting clinician review"
banner since that fit was tuned, and seven rows at 87px is more than the pane
gets. The height is in the bed rows -- not the h1 (dropping it a step bought six
pixels), not the note beside it, not the risk scale. Measured, and written down
in index.css so the next person does not go looking in the chrome.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYGadugJ72Vr9QYaqQDjjC
…outlier

The previous commit replaced an unsourced "~66 ms to score" with a measured
60.4 ms. That measurement was the FIRST tick after the service started, published
before a second sample existed -- which is the same mistake the number it
replaced represented, made with a real measurement instead of prose.

Seven samples: 31.6 · 42.5 · 43.1 · 43.4 · 43.9 · 60.4 · 64.7 ms per reading.
Steady state is ~43 ms and the first tick after an idle period runs ~50% slower;
both figures above 60 are first-ticks.

The stage-by-stage measurement from 2026-08-10 put the whole deterministic path
-- features, score, calibrate, TreeSHAP, band latch, sufficiency, evidence,
payload, grounding, serialise -- at ~54 ms. The spans corroborate it rather than
contradicting it: order 14.9 + assess ~43 + rank 2.6 lands in the same place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYGadugJ72Vr9QYaqQDjjC
…ves to 5174

The clinical demo is now the SvelteKit app in the sibling `frontend-and-backend-FINAL/`
repository. This repository keeps the React app as the engineering view. Both read the same
`/api` on 3500, so the backend stays the single source of truth and neither can drift from
the model.

`GET /api/ward` now carries each bed's demographics. The clinical UI's validator requires
age, sex and ethnicity on every patient, and fetching them per bed made the primary screen
an N+1. Three fields, not the whole context: the board renders none of weight, height,
comorbidities or the Charlson index, and shipping a recorded medical history to a screen
that does not display it is a minimum-necessary problem rather than a payload-size one. The
lookup is scoped to the beds on the board, because an unfiltered find grows with the
collection. Additive, so the React app is unaffected.

README documents the second UI: where it lives, the three installs, the four `PUBLIC_*`
keys and why `.env` is never committed in either project, and the four traps that cost real
time -- the data-source value is matched as an exact string, `/api` must be same-origin for
`Server-Timing` to be readable at all, the Vite proxy must NOT rewrite the prefix because
this service mounts under it, and the risk chart needs a 24-tick backfill because a tick is
an hour of ward time.

Verified against the live ward: check_node 18/18, check_conventions 16/16, secrets_gate
passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JMYEggLYYY6Dux8i2WXZAs
MIN_FREE_VRAM_MIB drops 6700 -> 6420 and now reads
`pipeline.config.LLM_MIN_FREE_VRAM_MIB` rather than declaring its own value.
This service and the pipeline stage had drifted 1175 MiB apart -- s19 still
asserted `vram_free_gb > 5.5`, below a level already known to segfault -- because
each carried its own constant.

6420 is measured, not bracketed: `bki/pipeline/tools/vram_probe.py` samples the
driver at 5 Hz across a load and six runs peaked at 6056-6239 MiB, spread 183.
The evidence table stays here, where the gate that acts on it lives; only the
value moved.

⚠️ It is the lowest-that-loads setting and sits below the 6561 that segfaulted
once, so warm the explainer before a demo rather than during one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant