The measured forecasts finally reach a human: SBC quadrant cards, a hand-rolled SVG chart, the exception queue and a per-series drill-down. Behind them, two contracts — payloads served revision-keyed and immutable behind the ownership check, and narratives that fail closed if they quote a number the series JSON cannot back.
M3 produces measured forecasts that nothing renders. M4 is the screen — and the two contracts that make the screen safe to trust: how a payload is served and cached, and what a generated sentence is allowed to say.
Every payload read carries ?v={job_id} and comes back
private, max-age=86400, immutable with the revision as its ETag. Nothing is ever
invalidated because nothing is ever mutated.
One sentence per series, generated with the series JSON in the prompt and then checked: every numeric token must be traceable to that series' own numbers. A single stray figure fails the sentence closed to a deterministic template.
SBC quadrant cards, a hand-rolled SVG chart for the featured series, the demo's exception queue running off real payloads, the full series table, and a keyboard-accessible drill-down drawer.
The payload path answers from the cheapest layer that is allowed to answer. The narrative path always answers, but never with a number it cannot prove. Hover any node to isolate its edges.
404, never 403 — existence itself is not disclosed.If-None-Match returns 304 with the same cache headers and no body — before any storage is touched.public; the client is always told private.waitUntil, so the fill never delays the response.sku, customer and v. Loads that one series out of forecasts.json and answers no-store.forecasts.json, series.json, dq_report.json — written once by M3, never mutated, only replaced by a new revision.LLM_MODE=off|stub skips it entirely and the template answers instead.
The edge cache is keyed by URL, and the URL carries no identity — only a dataset id and a
revision. Checking the session and the ownership before touching
caches.default is what keeps a shared edge object from ever becoming a cross-tenant
read. It is a latency optimisation that had to be placed exactly once, correctly.
Forecast payloads are large, read repeatedly and never change. The contract makes all three facts explicit instead of hoping a default gets it right.
private so no shared proxy keeps a copy; the edge copy is
stored public deliberately, because reaching it already required proving ownership.The plan allows a generated sentence per series. It also states the rule that makes that safe: every number in it must be traceable to a number the user can see. The Worker enforces it as code, not as a prompt instruction.
6), the interval level (80), the candidate count and count − 1 (“beat N candidates”), and the rounded 6-month total.{sku} · {customer}: {cls} demand; {champion} won the backtest (backtest MASE {mase}, beat {n} candidates) and projects {total} units over the next 6 months.
source: "template" · numbers_checked: true — the user still gets a sentence
The KV key is narr:{user}:{dataset}:{sha256(sku, customer, revision, prompt version)}
with a 30-day TTL, so the same series never costs two completions and a re-forecast produces a new
key rather than a stale sentence. Bumping PROMPT_VERSION retires every cached narrative
at once. LLM_MODE=off or stub skips OpenAI entirely and answers from the
template, which is what preview environments run.
The demo's forecast-cycle layout, ported to run entirely off the M3 payloads — no derived state on the server, no second source of truth.
| kind | fires when | severity |
|---|---|---|
| quiet | Silent for more than 3× the typical order gap, and a 3-month moving average of zero. | high |
| dying | Lumpy history, but every forecast month is ≤ 0.5 units — reads dead, not quiet. | medium |
| thin | A named baseline stood in for a fitted model; the reason is printed verbatim. | medium |
| season | Next month forecast above 2.5× the typical order size. | medium |
| fill | Invoiced below 80% of ordered across the history — a fill-rate problem, not a forecasting one. Inert unless an invoiced column was mapped. | low |
| quadrant cards | Four SBC counts, asserted by the gate to equal both the payload summary and the per-series classes. |
| featured chart | Hand-rolled SVG: monthly history bars, then the forecast mean inside its 80% band. 136 lines, no charting dependency. |
| exception queue | The demo's queue, running off real payloads instead of generated demo data. |
| series table | Every series with its pattern, champion, backtest score or fallback reason, and horizon total. |
| drill-down drawer | Keyboard-accessible dialog: champion, backtest MASE, the candidate list it beat, the interval method, and the narrative. Escape closes it. |
| invoiced history | series.json carries invoiced alongside ordered, which is what makes the fill-rate rule possible at all. |
scripts/qa/m4.spec.ts is the plan's M4 gate: four Playwright assertions against a real
local stack in real Chromium, each one comparing what a human sees against what the API returned.
Each of the four cards is read from the DOM and compared against both
summary.quadrants and an independent count of the per-series cls values
— so a rendering bug and a summary bug cannot cancel out.
drawer-champion must equal the series' champion exactly, drawer-mase must
equal mase.toFixed(2), the dialog must contain “Backtest MASE”, and Escape
must close it — the accessibility path, not just the visual one.
The spec re-implements the traceability rule independently and applies it to the sentence that actually rendered. A regression in the Worker's checker therefore cannot hide behind the Worker's own checker.
First read is 200 and must carry an ETag; the same request with
If-None-Match must come back 304. The caching contract is asserted over HTTP rather
than assumed from the header string.
scripts/qa/m4.spec.ts (the plan's M4 gate) PASSES 4/4 against a real local stack
in real Chromium — quadrant counts equal the API payload, drawer shows champion+backtest, narrative
numbers all exist in the series JSON, revalidation returns 304. Visual pass done (fixed a
champion-name overflow found by screenshot review). Benchmark after the engine fix:
median champion MASE 0.745 (band ≤0.9) vs seasonal-naive 1.043.
Every spec test was green while a long champion name was overflowing its box in the drawer —
toHaveText passes whether or not the glyphs fit. The defect was found by looking at a
screenshot and fixed before merge. See finding R2.
22 files, +2,208 −34. Roughly half is the screen itself; the two contracts are 180 lines together.
Short-history batteries drop season-12 models, and an unscored seasonal-naive reports null instead of 0.00. See finding R1.
Two defects fixed inside the milestone — one a fabricated number, one only a screenshot could catch — plus the five decisions that hold the screen together. Click any row to expand.
The measurements M3 produced now reach a human: a quadrant overview, a chart, a queue of things worth acting on, and a per-series drawer that names the winning model and the score it won with. Both of the milestone's contracts exist to stop the screen from saying something the data does not support — the cache never serves another tenant's payload, and the narrative never quotes a number it cannot prove. The milestone also went back into the engine to delete a flattering number it had inherited.