Serve two front-ends, and set the VRAM gate from a measurement - #1
Open
UniverseScripts wants to merge 11 commits into
Open
Serve two front-ends, and set the VRAM gate from a measurement#1UniverseScripts wants to merge 11 commits into
UniverseScripts wants to merge 11 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this branch carries
Two things, both already on
upstream:Two front-ends (
c09464f) — the finalized SvelteKit UI takes:5173as the clinical demo, this repository's React app moves to:5174as the engineering view. Both read the same/apion: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.The VRAM gate (
dc6e292) —MIN_FREE_VRAM_MIBdrops 6700 → 6420 and now readspipeline.config.LLM_MIN_FREE_VRAM_MIBrather 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_generatehad drifted 1455 MiB apart — the stage still assertedvram_free_gb > 5.5, a level already known to segfault — because each carried its own constant. Only the value moved toconfig; the evidence table stays here, where the gate that acts on it lives.Verification
Full suite against the live stack, all green:
secrets_gatecheck_nodecheck_conventionscheck_servicecheck_llmfloor_marginAlso verified in real Chrome on
:5173: every board score matches the CLI exactly, a live 7B generate returned byte-identical text, PM-355 rendersscore unavailablecarrying 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