Skip to content

123: two-layer kernel, cost in simulation time, Forecast tab; 124 spec - #107

Merged
IanMayo merged 13 commits into
mainfrom
claude/srd-model-forecast-specs-ws9x3d
Sep 3, 2026
Merged

123: two-layer kernel, cost in simulation time, Forecast tab; 124 spec#107
IanMayo merged 13 commits into
mainfrom
claude/srd-model-forecast-specs-ws9x3d

Conversation

@IanMayo

@IanMayo IanMayo commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

What this changes, and why

Two things the SRD gained today and the tree did not have: a model that propagates rather than translates, and a surface that shows a forecast being made. Feature 123 is built here; feature 124 is specified, and the parts of it that were never actually blocked are built too.

The kernel. shallow-two-layer-v1 joins shift-advect-v1 behind the unchanged model kernel port (Constitution VI): upwind advection, explicit horizontal diffusion and interfacial exchange, with an internal sub-step count derived from a stability condition and a refusal — naming the numbers — past max_sub_steps.

Cost is simulation time (ADR-0043). The kernel reports its work, the configuration declares a rate, and a run occupies the ticks it costs rather than taking a third wall-clock exemption. check-declared-cost fails the build if a component declares cost in any other currency.

Affordability is inverted, and that is the whole of FR-31. A warranted run is held while the standing forecast has more life than the run's cost plus the release margin, and released as headroom decays. Read the obvious way round the loop becalms permanently, because the run that would restore validity is exactly the one never affordable.

#/view/forecast now shows the forecast, which for two rounds it did not. It opened with a gauge about the run loop, a cost, a list of runs, and two regions naming feature 124 for the whole of themselves. Both turned out to be mostly buildable:

  • "What next" draws the forecast's own features in plan — the eddy, the drifting feature and the front across the lead steps, each with its uncertainty widening; the thermocline stated in figures, because a depth has no place in a plan view. ctl/forecast/features is FR-05's entire product and had no consumer at all: a loop test validated its shape and dropped it.
  • "What it is made from" draws the provenance field as a map. A source selector isolates one contribution or shows whichever dominates each cell; a depth control walks the water column; the readout follows the pointer and the arrow keys; a cell opens into its full stacked profile. The analyst has published that field since feature 116. What is genuinely blocked is far narrower than the region: FR-122's per-source rays, because the analysis kernel reports the gain's row sum and discards its columns. That is said inside the region, beneath the reading.

Both regions read the query layer and the wire the way an external client would — an EDR area query for the field, position queries for a column — and the shell holds no private route to the store.

The centre region's first build was a grid of grey buttons over a list of four percentages, and being dull was a correctness problem rather than a matter of taste. The interesting fact in the provenance field is spatial: the measurement share is a footprint, bright where a sensor reached and dark where the analysis had only its background, and it shrinks with depth. Four numbers for one column cannot show a footprint. The palette is computed rather than chosen — four sources, fixed order, never cycled, validated against this shell's own surface #10151b, with the worst adjacent pair at ΔE 8.4 under protanopia, which is over the floor and close enough to it that the per-source hatch, the legend, the words and the printed figures are obligatory rather than decorative.

The instance, opened at the change

https://deepbluecltd.github.io/drogna/instances/claude-srd-model-forecast-specs-ws9x3d/#/view/forecast

Set the clock to ×60 or ×600 in the header. The gauge decays, the timeline fills, a hold appears with its shortfall in ticks, the right region fills with the features and their widening rings, and the centre region comes up as a field: amber where the model made the background ocean, blue where the archive did, and a green patch where the sensors actually reached. Switch to measurement to isolate that footprint, walk the depth row to watch it shrink, and click a cell for its column.

The blog entry

https://github.com/DeepBlueCLtd/drogna/blob/claude/srd-model-forecast-specs-ws9x3d/site/docs/blog/posts/affordable-when-you-do-not-need-it-yet.md

The inverted affordability rule, and the becalm that reading it the obvious way produces.

What was watched failing

Every new check, planted and reverted. The stability refusal with its ceiling disabled; the becalm test against the affordability rule read the obvious way round; check-declared-cost against a planted key in the scheduler's master and again in run.json; the whole-feature decline check against a planted rename; the broker conformance check in loop.test.ts; the once-per-episode hold check (201 against 1); the becalm-on-restart test against the restored throw (expected 3 to be greater than 3); the carry-velocity test against the restored inference (expected 7.0028 to be close to 4); the uncertainty-growth assertion against a carry that does not widen; the features subscription with its payload dropped; the feature restatement removed; the standing declaration removed; the share field asserted to vary, against a slab read off a fixed index (expected 1 to be greater than 1); the depth control asserted to re-read, against depth dropped from the effect's dependencies.

Three of those were checks I had written that could not fail, and three more failed for the wrong reason. Same shape each time — an assertion placed where the thing it tests cannot yet have gone wrong, or measured along the wrong axis:

  • the becalm-on-restart test's first two drafts both passed against unfixed code. One restarted the scheduler during the first run, and a restarted scheduler counts run ids from zero again — so it asked for the id already occupying and was rescued by the collision rather than by anything being right. The other asserted that a request followed the restart, which a becalmed loop also does: it makes exactly one;
  • the carry test asserted to 0.0005 km/day and failed on a lat/lon round-trip while the planted fault went unremarked;
  • the uncertainty-growth assertion gathered every ring in the plot and required the largest to exceed the smallest — true of any two different features at the same lead. Planted against a carry that does not widen at all, it passed.

The depth-control assertion compares the z of the first and last area query rather than counting them, for the same reason: a control that re-rendered without re-reading would grow the count while fetching the same slab for ever.

All of it is in specs/123-forward-step/tasks.md (T049–T094).

Backend changes: what was brought up, and what was seen

Not a backend change in the services//deploy/ sense — drogna V2 runs entirely in the browser.

At this head: 684 app tests, 40 script tests, 22 gates, all six capture proofs, and the Forecast tab measured in a browser at 390px and 360px with the loop warmed — nothing scrolls sideways, every control clears the 44px thumb floor.

Two gaps in the checks, both found the hard way.

pnpm check is not the build. Every check on this branch reported green while every CI run failed — seven of them — because CI runs six capture proofs after the checks and CLAUDE.md said pnpm check was "what CI runs". Corrected in this diff.

And capture:mobile pins the clock to rate 0 before it measures, so every surface drawn from an announcement is absent from every picture it takes. Claiming "the narrow presentation holds" from that proof is an empty claim for anything the loop produces. Measured properly: .forecast-gauge-bar had been scrolling sideways at 17px of content in a 14px box since the day it was written, and this round the region overflowed by exactly two pixels from a width: 100% map with a 1px border under content-box. Both gaps are reported rather than closed — a gate asserting that every capture:* script appears as a CI step, and a proof that warms before measuring, each belong in a change of their own.

Left undone, and why

  • Feature 124's volume and per-source rays. The rays are blocked on the analyst, not on drawing: FR-122 wants one ray per source at a width proportional to its contribution, and the analysis kernel discards the per-source columns before anything could draw them. specs/124-forecast-illustration/tasks.md carries T001–T035; its FR-05 is amended to record that the plan view of the tracks landed here.
  • The shipped forecast horizon is 45 minutes, and the drawing makes that visible. Four steps of fifteen minutes: each feature moves ~0.2 km over the horizon while its uncertainty grows from 24.5 km to 49.1 km, so the claim is some two hundred times wider than the motion it predicts. SRD FR-113 says the purpose is "a falsifiable claim about next week". One of the requirement and the configuration is wrong; establishing which is a requirements decision, and changing steps/step_seconds moves snapshots, costs, validity and much of the suite. Raised, not acted on.
  • The front's published bearing uncertainty is 20–80× its measured error. Over-stated, which is the safe direction for Constitution IX, but nothing the estimator could plausibly publish would falsify it.
  • A restarted scheduler reuses run ids, so a publication can clear the wrong outstanding run by collision. Benign today; not benign once anything correlates a request with the publication that answered it.
  • Leakage tripwire Open question: leakage mask-scoring gate needs a quieter kernel #57 is left at empty-mask, with the measured cause recorded (byte-identical analyses, digest 057e83baae65) and the deeper mechanism stated as unsettled rather than closed by a bound.

🤖 Generated with Claude Code

https://claude.ai/code/session_013Yhrus4EAa9MHMbbSuzMhc

Feature 124 is what §5.20 exists for — the surface that makes assimilation visible — and
it had a section of the SRD and no feature record. This is the record.

One finding is the reason the spec is worth reading before the tasks. **The per-cell
provenance the analyst publishes today cannot draw a ray.** FR-111 makes that provenance
the substrate for the centre region and the depth profile, and FR-122 wants one ray per
contributing source at a width proportional to that source's contribution. Read against
the tree, the two do not meet: the analyst publishes four aggregate shares per cell, and
the analysis kernel computes the per-observation gain and then reports its row sum,
discarding the columns. Three answers were available and the spec records why two were
rejected — drawing four rays would look like per-source attribution while being an
aggregate, and deriving the split in the shell would make AT-07 a test of arithmetic the
shell had just done. The analyst publishes the columns it already computes.

That moves work into a backend component the companion document never mentioned, which is
why it is stated here rather than discovered during implementation.

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

The second half of feature 123: the record landed on the previous branch, this is the
code. A shallow two-layer advection-diffusion step becomes the configured kernel behind
the unchanged model kernel port; a run declares what it costs and spends it in simulation
time; the scheduler holds a warranted run while the standing forecast still has more life
than the run costs; and `#/view/forecast` carries the left region and the timeline.

Watched failing, and reverted each time:

- the stability refusal, with its ceiling disabled: "expected [Function] to throw an
  error", which is what says the refusal is doing the work rather than the configuration
  being unreachable;
- the becalm test, against the affordability rule read the obvious way round — caught at
  the hold, and again at the becalmed instant itself, which is where the fault lives;
- check-declared-cost, against a planted `run_cost_ticks` in the scheduler's own master.

Four things the plan did not have, each recorded in tasks.md rather than left in the diff.
The first blob estimator recovered the eddy 164 km out and the cause was the front's
saturating plateau, not the grid. The committed snapshots did not move at all — this
feature changed the runner and a snapshot carries the generator's eras. One start
condition's legs needed the ticks its run now occupies, and only one. And the first
capture of the running tab showed "no run has been announced yet" on a store holding four
forecasts, because a console opens after the pre-roll.

pnpm check: 655 app tests, 40 script tests, 22 gates, all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Yhrus4EAa9MHMbbSuzMhc
Two independent passes read the diff without having seen it written. Six findings were
real and three of the six were things this session had already looked at and waved
through, which is the whole argument for the skill.

- Stopping the model runner mid-cost becalmed the loop for ever. A run occupies twelve
  ticks between announcement and publication and the scheduler clears its outstanding run
  on a publication alone, so a stop landing in that window meant nothing was ever acted on
  again — through an ordinary operator verb. A run that will not finish now says so as it
  is stopped, and the scheduler releases what it was holding. Watched failing with the
  release removed: "expected +0 to be 1".
- With a zero-cost kernel the standing-forecast gate had disappeared from the cadence
  floor. The short-circuit is gone: the release margin alone is a validity rule.
- Three magnitudes were published under the manifest's own property names at up to sixteen
  times the uncertainty declared beside them. They are quantities a 200 m depth grid
  cannot see; what is measured now has names of its own and the authored quantity is
  declared not recovered, with the reason.
- The front's bearing was printed and never asserted. Averaged in doubled angles over the
  front rather than read off one cell, it is 0.2 to 0.6 degrees against a bound read from
  the authoring jitter — where the old estimator was 39.6 degrees out at the seed the
  console opens on.
- Scoring every start-condition seed rather than one found two more faults. The drifting
  feature came back 213 km out at one seed: high-passing a warm blob leaves a cold ring and
  the ring won. The ring is excluded out to the width of the filter that made it, and a
  peak that does not clear two standard deviations of the field's own scatter is declined
  with its reason rather than published.
- The timeline separated a forecast from its uncertainty by a string suffix no master
  declares; the monitor computed one streak twice; the gate fixture had already drifted
  inside this branch and its run.json arm had never been planted against; four comments
  described behaviour the code did not have.

And one the review did not find, because the suite did: the becalm fix gave the scheduler
a ninth heartbeat figure against a master that caps eight, and four unrelated tests said
so at once by the scheduler going silent.

pnpm check: 674 app tests, 40 script tests, 22 gates, all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Yhrus4EAa9MHMbbSuzMhc
The first capture was taken before the timeline read the store's inventory, and showed
"no run has been announced yet" on a situation holding three forecasts. This is the same
view after that fix: three entries at ticks 1812, 4470 and 5412, each marked as published
before the console opened and each saying that what asked for it is not recoverable from
a holding. The alt text follows the picture.

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

The second adversarial round, against the fixed tree. The guard is keyed to the commit,
so the fixes were reviewed too — and this round found the one fault in the feature that
would have mislabelled every forecast the harness ever served.

- The two-layer kernel wrote each step after integrating it, while the manifest went on
  declaring `start_offset_seconds: 0`. An EDR query at the initialisation instant was
  answered with the field 900 seconds later, the last step was served 900 seconds early,
  and the run's own features used the other convention on the same tick. Step 0 is now the
  state the run initialises from, and a test holds both kernels to it.
- A held reader's prompt was reported as "released as that headroom decays" and then
  dropped: nothing remembered it, and only the cadence floor ever requests again. It is
  now held as a commitment and released, or declined by the rule that declines it.
- Pressing the button again during a hold published nothing at all — the once-per-episode
  latch is right for the tick-driven floor and wrong for a discrete act by a reader.
- `members_done` was set at publication, so the ensemble face drew five empty pips for the
  whole visible duration of a run whose members were already computed.
- `sub_steps_per_step` reported one for a kernel that declares no work, so one component
  said "costs nothing" and "took one sub-step" about the same run. The master admits null.
- One uncertainty block computed from the eddy was attached to all four features.
- The Forecast tab's deep link did not survive a reload: a run heard live and the same run
  read from the store had different keys.
- The cost was declared against a cell more than twice the size of the one the run is
  handed; a test now compares the declared sub-step count with the reported one.

The leakage tripwire in `advisories.test.ts` fired twice in this branch, in both
directions, which is the best evidence in the feature that it is a real check. The second
flip is recorded as measured — the two runs are made from byte-identical analyses — and
what is not settled is said to be not settled.

pnpm check: 677 app tests, 40 script tests, 22 gates, all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Yhrus4EAa9MHMbbSuzMhc
…how an abandoned run

The third and fourth adversarial rounds. Three of these were claims the tree made and did
not keep, and one was a check that could not fail.

- The front was masked against the eddy and not the drifting feature, while this module,
  its own docstring and its committed master all said "outside both blobs". Measured
  across the shipped seeds, the anchor landed inside the authored drifting feature at
  three of five, and the step taken across "the front" was 16 to 35 per cent larger than
  the quantity its master describes. Neither figure the tests score could see it. The
  order is now eddy, cold blob, then the front against both.
- The declared cost stated four integration steps where the kernel takes three: making
  step 0 the initialisation state did not propagate to the arithmetic. A run costs 9 ticks
  and the basis says why.
- An abandoned run said "occupying 12 tick(s)" for ever. The panel receives the message
  saying the runner gave it up, on a topic it already subscribes to, and dropped it.
- Two checks could not fail. The master-conformance assertions in loop.test.ts live inside
  broker subscription handlers and the broker catches handler faults, so a message its
  master refused made a green test greener; every drive now asserts the fault count is
  zero, watched failing against a planted rename. And "every kind is either estimated or
  named with a reason" was a tautology, because every kind is named on both branches.
- SRD FR-113 and this feature's FR-05 still read as met while the tree declines three of
  the quantities they name. Both are amended in place.

The capture is retaken at head: it shows the 9-tick cost, the 5 km nominal cell, and a run
held for cost — which the entry's demo promises and the previous picture did not contain.

pnpm check: 677 app tests, 40 script tests, 22 gates, all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Yhrus4EAa9MHMbbSuzMhc
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

This branch, running: https://deepbluecltd.github.io/drogna/instances/claude-srd-model-forecast-specs-ws9x3d/

Intro · Background · Data · Operator · Forecast · Map · Messages · Sampling · Courses · Feasibility

Built from dec1253946cce3d3c2c8474e09a09201ea0a4b30, and replaced in place by every further push to this branch. Synthetic throughout, as the header says.

The sixth round, and the first finding is about the checks rather than the code. Every
`pnpm check` on this branch reported green while **every CI run on the pull request
failed** — seven of them, from the first push. CI runs six capture proofs that `check`
does not, they need a build and a browser, and they are the only thing that reads the
shell as a rendered page. CLAUDE.md said `pnpm check` was "what CI runs"; it is not, and
that line is corrected rather than left to catch the next person.

- The Forecast tab did not fit a phone: eleven narrow-presentation failures from one
  declaration. `.forecast-run` was three `max-content` columns and a `minmax(0, 1fr)`,
  which cannot fold — at 360px the button laid out 351px wide inside 320px and took the
  `li`, the `ol`, the region and the panel past the viewport with it. A wrapping flex row
  now, with no breakpoint, so it folds wherever the content stops fitting. Reproduced
  locally at eleven, fixed, re-run at zero.
- **The refusal nobody heard.** T049 answered one entrance to the cost window and asserted
  the other could not be reached. Restarting the *scheduler* reaches it: a fresh scheduler
  holds nothing, its cadence floor fires at once, and a second analysis arrives at a
  still-occupied runner. The runner refused it by throwing — inside a broker subscription
  handler, which catches handler faults and counts them. So nothing was told, and the
  scheduler waited for a publication that had been refused before any work was done.
  Measured at `loitering`, seed 4242, restart at tick 4420: twenty thousand ticks and
  eleven cadence floors with nothing requested, started or published. It publishes
  `run-failed` for the run it refuses now.
- A feature was carried at a velocity the field is not. `carryVelocity` read
  `parameters.twoLayer !== undefined` as "the two-layer kernel is configured" and the
  runner sets that block whichever kernel is configured, so the branch was taken always:
  with `shift-advect-v1` selected the features drifted at 7 and 3 km/day while the field
  moved at 4 and 2, which on this grid rounds to no displacement at all. The kernel is
  asked now, through an optional port member alongside `subStepsPerStep`.
- The analyst's hook budget, raised on the measurement rather than nudged. The hold moves
  the second cycle off the cadence floor — 3609 ticks to 4429, 28.0s to 38.7s — and the
  budget is in host time. It walked into the ceiling one commit at a time: 52.8s, 57.5s,
  59.96s, timeout. The third of those passed by 36 milliseconds.

Watched failing, and reverted each time:

- the becalm test, against the restored throw: "expected 3 to be greater than 3", four
  cadence intervals of silence;
- the carry test, against the restored inference: "expected 7.0028 to be close to 4".

Both of the first two drafts of the becalm test passed against the unfixed code, and both
are recorded in tasks.md because they are the same mistake twice. One restarted during the
first run — and a restarted scheduler counts run ids from zero, so it asked for the same
id already occupying and was rescued by the collision. The other asserted that a request
followed the restart, which a becalmed loop also does: it makes exactly one. What
separates the two cases is a second request.

The carry test's own first draft asserted to 0.0005 km/day and failed on the lat/lon
round-trip while the planted fault went unremarked — a check failing for the wrong reason,
which is no better than one that cannot fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Yhrus4EAa9MHMbbSuzMhc
Caught by `check-types-drift`, which is what it is for: `restate_every_ticks` lost an
unreachable `default` on a required property in the fifth round and the generated document
was never rebuilt. Boundary shapes are generated, never hand-written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Yhrus4EAa9MHMbbSuzMhc
The narrow-presentation fix landed as a wrapping flex row, which folds correctly and costs
the timeline its column alignment at every width: each row's detail then starts after its
own cause rather than at a shared x, and a list of runs is scanned down its columns.
`minmax(0, max-content)` folds the same way and keeps them — the track stops claiming its
content's width as a floor without ceasing to be a track.

Re-run at zero narrow-presentation failures, as the flex version was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Yhrus4EAa9MHMbbSuzMhc
@IanMayo IanMayo changed the title 124: the illustration surface, specified 123: two-layer kernel, cost in simulation time, Forecast tab; 124 spec Sep 2, 2026

@IanMayo IanMayo left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two independent adversarial passes read this diff without having seen it written, and every finding was verified against head before it was posted. The two serious ones are fixed in this branch; what is inline here is what was deliberately left, with the reason.

The assumptions worth checking first

Ranked by how much breaks if the assumption is wrong.

  1. A run may occupy ticks, and every way that window can strand the loop is enumerated. It was not. The diff answered one entrance — the runner stopped mid-occupancy — and asserted the other could not be reached. Restarting the scheduler reaches it. Fixed: the runner publishes run-failed for the run it refuses instead of throwing into a broker handler that catches and counts.
  2. Features are carried at the velocity the field is carried at. They were not, for shift-advect-v1: the selector was the presence of a parameter block the runner always populates, so the branch was taken always. Fixed: the kernel is asked, through an optional port member alongside subStepsPerStep.
  3. The cost declared at a nominal cell is the cost the run occupies. Holds, and a test compares them — but both figures are 1 and stay 1 for any cell above ~0.57 km. See the check-declared-cost comment.
  4. The scheduler may derive tick length from consecutive clock samples. Sound: remainingValidityTicks() fails open on a missing forecast, a lapsed one, and an underived tick length alike.
  5. The second kernel's distinguishing behaviour is visible at the shipped configuration. Measured at 0.019 °C against 0.35 °C of noise — see the kernel.ts comment.
  6. The uncertainty beside a feature describes that estimator's accuracy. Not for the front's bearing — see the features.ts comment.

Confirmed and fixed in this branch

  • the becalm through a scheduler restart (above);
  • the carry velocity (above);
  • the analyst hook budget, which this feature made 38% dearer — 3609 ticks to 4429, 28.0 s to 38.7 s — walking into its ceiling one commit at a time: 52.8 s, 57.5 s, 59.96 s, timeout;
  • eleven narrow-presentation failures, which CI had been reporting on every push while pnpm check reported green.

Confirmed and deliberately left

Four inline comments: the front's bearing uncertainty, the run-id collision across a scheduler restart, the shipped configuration's physics against its noise, and check-declared-cost's line attribution.

Checked and sound

22/22 gates including all three drift gates, with the generated output a genuine regeneration. pnpm replay-proof holds — AT-04 survives the second kernel and the held publication. No new wall-clock read in operational code and no new harness:allow-wallclock marker. No import across the seam. Math.random() only in test client ids. The published features meet the estimator bounds at four shipped seeds — eddy centre 4.3–30.0 km against radii of 51.8–66.4 km, front anchor 0.4–8.1 km across the authored line against 30 km sharpness — with the fifth seed declining, with its reason on the wire. check-declared-cost fires on a planted violation in a copy of the real tree, not only against its stub fixture, and both arms fire. The monitor's streak refactor is behaviour-preserving. app/public/snapshots/ is untouched and correctly so.

A gap in the gates, which is the most useful thing here

There is no gate for what CI's capture proofs check, and no gate that the two are the same set. This branch shipped seven consecutive red CI runs while pnpm check reported green on every commit, because check does not run them and CLAUDE.md said it did. The line is corrected in this diff, but a corrected sentence is weaker than a check: a gate asserting that every capture:* script in package.json appears as a step in ci.yml — the same shape as check-gate-registry's "an unregistered gate is a gate that never runs" — would make the drift impossible instead of merely documented. Not added here, because a gate is a line appended to a registry and belongs in a change of its own.

Reviewed with two passes on Claude Opus 5, one for correctness and one for simplicity and the record, with every finding verified against head before posting.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The front's published bearing uncertainty is 20–80× its measured error, and nothing could falsify it.

Measured on the published ctl/forecast/features message at four shipped start-condition seeds, scored against the manifest:

seed bearing error published uncertainty.bearing_degrees
1180001 0.5° 38.3°
1180002 2.6° 40.5°
1180003 0.2° 41.6°
1180004 0.9° 37.5°

Mechanism: uncertaintyFor(front.anomalyStepC, cellKm) gives the front a scale of one grid cell, and bearingDegrees = atan2(positionKm, max(radiusKm, 1e-6)). Once analysisErrorC ≳ anomalyStepC the positionKm clamp binds and the ratio approaches 1, so the figure saturates at 45° at lead 0 and up to 63° at lead 3. On a bearing folded into 0–180°, ±40° is close to a uniform claim.

This is an over-stated uncertainty, which is the safe direction for Constitution IX — it is not the "soften a bound until it passes" fault. But FR-113's stated purpose is that a forecast makes a falsifiable claim about next week, and this quantity does not: nothing the estimator could plausibly publish would falsify it.

The real accuracy comes from averaging thousands of gradient cells in doubled angles — the finding T052 was written for — and the uncertainty model knows nothing about that averaging. Left unfixed deliberately: the fix is a model of that averaging (the standard error of a circular mean over N cells), and choosing it is a judgement about what the published figure is for, which belongs with a reader rather than with me at the end of a long branch.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A restarted scheduler counts run ids from zero again, so a publication can clear the wrong outstanding run.

Found while writing the becalm-on-restart test, not by looking for it — and it is the reason two drafts of that test passed against unfixed code.

inFlight is cleared by identity: if (published.run_id === this.inFlight) this.inFlight = undefined. A fresh scheduler restarts its counter, so it issues …-run-0 again. If the run still occupying its cost is …-run-0 — which it is whenever the restart lands during the first run — then that older run's publication matches the new scheduler's outstanding id and clears it. The loop keeps turning, but for the wrong reason: two different runs shared a name.

It is currently benign, because the only path that reaches it now ends with the loop turning anyway. It stops being benign the moment anything downstream correlates a request with the publication that answered it — which is exactly what the Forecast timeline does, keyed on run:${id}, and what feature 124's provenance work will do.

Not fixed here: the fix is a scheduler-lifetime discriminator in the run id (or seeding the counter from what the store already holds), and it changes an id that appears on the wire, in the store, and in every committed snapshot. That is a change worth making on its own, against its own drift-gate run, rather than folded into this branch's sixth round.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The physics this ADR was written for is 5% of one noise draw, and nothing on disk says so.

Measured over a whole run at the shipped configuration — 3 integration steps, 2700 s, noise off, on the 96×80×6 grid:

  • maximum change to the temperature field: 0.019 °C, against a model-noise standard deviation of 0.35 °C per step and a 2.5 °C blob;
  • the eddy moves 0.22 km — 4% of one cell;
  • the layers' shear over the run: ~0.06 cells.

The published forecast is, to a first approximation, the analysis plus noise. kernel.test.ts's shear assertion is real, but it passes on a toy grid with twice the velocities and ten times the relative displacement, so it does not establish that the distinguishing behaviour is visible at the configuration that ships.

Related, and in the same measurement: the scheme's own numerical diffusivity is uΔx(1−C)/2 = 196 m²/s east and 97 m²/s north here, against a configured horizontal_diffusivity_m2_per_s of 45. The explicit term is a minority contributor by 2–4×, and the master describes it as "explicit horizontal diffusivity, applied per layer" without saying so — a reader tuning that number is tuning the smaller half.

Neither is a fault in this diff: the kernel is correct, the configuration is the one that was asked for, and ADR-0042's argument for a second implementation stands. Both are facts about the shipped configuration that a reader of the ADR would reasonably expect to find stated and will not.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lineOf(lines, key) returns the first line in the whole file containing "key".

So where a key name appears under two components, the finding points a reader at the wrong one. The gate still fires correctly — this is only the line number it reports — but a gate that names the wrong place is a gate someone will argue with, and both arms were watched failing on the strength of finding the right one.

Minor, and not fixed here because the fixture that would prove the fix needs two components sharing a key name, which the real tree does not currently have.

Two smaller notes in the same class, from the same pass:

  • carryFeatures carries eddy, moving and front through three near-identical spread-with-IIFE blocks, and featureStep builds four near-identical objects each closed with as ForecastFeaturesFeature. Those four casts defeat the generated discriminated union: a misspelled parameter key would compile, and only loop.test.ts's runtime validation would catch it.
  • sub_steps_per_step is 1 at both the nominal and the real cell and stays 1 for any cell above ~0.57 km, so the check comparing declared against reported has a reachable failing state only after a ~10× grid refinement. tasks.md records this honestly as headroom — worth knowing that it is not what would have caught the 11 km → 5 km nominal-cell correction the record credits it near.

Generated by Claude Code

Raised by a reader looking at the tab rather than at the diff: it had no graphic of a
forecast anywhere. A gauge about the run loop, a cost, a list of runs, two paragraphs naming
feature 124. Meanwhile `ctl/forecast/features` — FR-05's entire product, four features per
lead step each with an uncertainty that grows — was published on every run and consumed by
nothing, so the feature's output was unverifiable by anything a reader could open and would
have stayed so until 124 landed, which is blocked on an analyst change.

`FeatureTracks.tsx` is its first consumer: a plan view in the right region drawing the eddy,
the drifting feature and the front across the lead steps with their uncertainty widening, the
thermocline stated in figures because a depth has no place in a plan view, and the quantities
the run would not claim listed in its own words. It is an SVG rather than a canvas so that a
test and a screen reader can read it, and the same claim is printed beneath it so the region
is legible with the picture removed.

The runner now restates the standing forecast's features on the cadence it already restates
cost on. Published on the run alone, a console mounting afterwards — which every console does
— had nothing to draw until the next run: 1800 ticks, half an hour at the default rate. The
argument is the cost statement's own: a declaration published once, before the shell had
mounted, is a fact no listener can learn. Nothing is recomputed.

Watched failing, and reverted each time:

- the growth assertion, against a carry whose uncertainty does not widen (`root = 1`):
  "tracks-feature tracks-eddy: the ring at the last lead is not wider than at the first";
- the subscription, with the payload heard and dropped: no plan view drawn;
- the restatement, removed: a console opening after a run learns nothing.

A fifth check that could not fail, caught before it was trusted. The growth assertion first
gathered every ring in the plot and required the largest to exceed the smallest — true of any
two different features at the same lead, since an eddy and a front have different strengths.
Planted against the non-widening carry, it passed. It compares within one feature across its
own lead steps now.

And a real fault that CI structurally cannot see. `capture:mobile` pins the clock to rate 0
before it measures, so every surface drawn from an announcement is absent from every picture
it takes — including this drawing, which makes "the narrow presentation holds" an empty claim
when made from that proof. Measured with the loop warmed and then pinned, at 390px and 360px:
the drawing folds, and `.forecast-gauge-bar` scrolled sideways at 17px of content in a 14px
box, and had since the day it was written. Its threshold mark hung 3px past both edges of a
14px bar. The gap in the proof is reported rather than closed: it changes a shared capture
script every view depends on.

pnpm check: 683 app tests, 40 script tests, 22 gates. All six capture proofs green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Yhrus4EAa9MHMbbSuzMhc
IanMayo pushed a commit that referenced this pull request Sep 2, 2026
Two independent adversarial passes and five verifiers ran against the first
draft. Four findings were refuted and dropped; the rest are corrected here.

Counts. The declined table held 19 rows against a claimed 20, and the
record-lag column held 5 lines against a claimed 4. The errors cancelled,
so the headline survived wrong terms. The partition is now stated so it can
be audited: 70 = 37 + 20 + 2 + 11.

pnpm replay-proof selects tests with `vitest run -t replay`, and the
generator's AT-04 byte-identity test matches neither its own name nor its
describe block: 7 tests run, 623 skip, and that one is skipped. The draft
asserted the script "runs every byte-identity test" and told a reader to
tick 101 T037 and 107 T607 on it. Those lines are now real work — fix the
selector, watch the hole fail, then tick.

check-snapshot-drift gives the forecast kernel no cover. All four artefacts
hold only archive and nowcast eras, both authored by env-generator, so a
planted kernel fault shifting every forecast temperature by 5 C and tripling
its spread leaves the gate exiting 0 while the kernel runs 125 times and has
its output discarded. The draft sent a reviewer to read a snapshot diff that
cannot exist.

111 T070 was listed as an unwritten gate. The tree declines it, with the
reason rewritten once the explainers existed and two narrower checks built
instead, one watched failing. Read from the checkbox, not the paragraph
under it. It moves to the declined table.

Also: 123 has 11 ticked lines on main, not none, and 83 on the PR's current
tip, not 79; only P2 waits on #107, since it touches no specs outside 123
and 124; SS5.20 spans FR-106 to FR-140; the deferral chain is two
independent deferrals, not one; FR-35 is named as T006's residue, the one
of its four amendments with no marker; and the claude.ai artifact link is
removed, being unresolvable for any other reader and checked by no gate.

Refuted and dropped: that issue #61 was claimed discharged (the wording was
already hedged), that FR-35's gap was a defect rather than the anticipated
residue, that the NetCDF decision already exists (neither disposition is on
disk), and that 104 T307 and 103 T208 are stale declines (T307's consumer
still sends no Accept header; 107 delivered no rate control).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KCv1sDnpZZXoXJ2w8osB3s
IanMayo pushed a commit that referenced this pull request Sep 2, 2026
A second pair of adversarial passes ran against the corrected file. Two of
their findings did not survive verification and were dropped; the rest are
fixed here.

The worst was a miscitation the first correction introduced. The 113 T006
row named docs/v2/plan.md:61 as the second stale section reference. That
line is a table row about the gh-pages estate, and the only mention of
5.11 in that file, at line 178, is correct: feature 112 did take 5.11.
Following the instruction would have corrupted a right line and left the
real one, specs/113-operator-flowchart/plan.md:61, untouched. The row now
names the right path and warns off the wrong one.

The proposed replay-proof remedy was itself unsound. Vitest exits 0 when
the selector matches nothing -- checked, 630 skipped, exit 0 -- so the
suggested tag reproduces the fault it fixes. The row now asks for explicit
file names plus an assertion on the number of tests actually run.

The density row quoted 240x168 from the task line. That figure is in no
source file: app/src/panels/operator/layout.ts:73-86 sets 208x116 and
derives the expanded face as 450x464, so the decision would have been made
against roughly a quarter of the real area. The row now says to take the
numbers off disk, and to correct the line.

Also: the parallelism claim carved out P5's forecast eras, which is
discharged in scheduler.ts and collides with the 251 lines #107 adds there;
the SS5.20 quotation replaced with what srd.md:1171-1172 actually says;
106 T506 and T507 given back the revisit triggers their glosses dropped;
the self-check grep recipe made to print a total rather than per-file
counts; the run.json and advisories.test.ts line numbers corrected; the
third leakage fact noted as sitting inside a guard; and the forecast view
described as arriving with #107 rather than present now.

Refuted and dropped: that ClockStrip.tsx arrived with feature 105 -- the
clone is shallow and 4dd407c is a graft boundary, so --diff-filter=A
misattributes; specs/101-foundations-shell/plan.md:19 places it in 101 --
and that the ComposerPane fetch is at line 73. Both are recorded in the
closing section, because a finding is a claim about the tree too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KCv1sDnpZZXoXJ2w8osB3s
The centre band was a stub naming feature 124 for the whole of itself, and most of it did not
need to be. The analyst has published a full-grid provenance field since feature 116 — four
shares per cell (archive, departure, measurement, model), every depth, summing to one, under
its own EDR collection — which is the substrate FR-111 names for this region and the depth
profile. What is genuinely blocked is narrower: FR-122's per-source rays, because the
analysis kernel reports the gain's row sum and discards its columns. The rays wait; the
shares did not have to.

`ColumnProvenance.tsx` reads a column the reader picks, one EDR position query per depth,
through the same path an external client takes — the shell holds no private route to the
store, and a surface that bypassed the query layer would be evidence of nothing. What is
still 124's is said inside the region, beneath the reading.

Three faults found by building it, none of them in the drawing:

- **The shell had no master for `ctl/analysis/published` at all.** Not a wrong mapping — an
  absence, so `drawable` refused every analysis announcement the shell ever received. The
  config master's own description says what that means: "every crossing has a master or is a
  finding". Nothing had been reading the topic to notice.
- **A cycle's collections are a standing fact and were announced as an event**, so a console
  mounting after a cycle — which every console does — had no collection to name for up to a
  whole cadence, on the region whose subject is what a cell's value was made from. Measured
  in a built instance: three cadences of warming and the chooser never drew a square.
- **The grid was read once, on mount**, so a console mounting while the store was still empty
  never got one. It has a second chance now, on an announcement, guarded so it can fire at
  most once and can never become a poll.

The first fix for the second fault was wrong, and the way it was wrong is the finding.
Restating `analysis_published` re-ran the loop: that topic looks like an announcement and is
a trigger — the model runner starts a forecast on it, which is feature 116's whole design,
and the planner re-plans on it. Ten tests failed across seven files, replay determinism among
them, two timing out with the runner refusing runs it had just been asked to repeat. Nothing
in the code marked it as a command; it was learned by breaking it. A declaration commands
nothing, so it has its own topic — `ctl/analysis/standing`, the same message under the same
master, no subscriber that acts. That is the separation the runner already keeps between
`run_started` and `run_cost`.

Watched failing, and reverted each time:

- the chooser, with the click marking a square and asking nothing: "picking a square asked
  the query layer nothing";
- the declaration, removed: "the analysis was never restated, so the chooser stayed undrawn".

And two smaller corrections. The region reported one absence for two — "no analysis announced
yet" whether it lacked the analysis or the grid — which sent two rounds of measurement in the
wrong direction; a surface that states the wrong reason is worse than one that states none.
And the chooser's squares were 38px wide inside a 360px phone, the width being the constraint
where the height was already 44: `capture:mobile` enforces that floor on tabs and operator
controls and would not have failed it, but the number is a claim about thumbs rather than
about which selectors a check walks.

pnpm check: 685 app tests, 40 script tests, 22 gates. All six capture proofs green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Yhrus4EAa9MHMbbSuzMhc
IanMayo pushed a commit that referenced this pull request Sep 2, 2026
Review of the gate found that it repeated, in its own implementation, the
class of fault it exists to catch. Each is fixed and each is now held by a
planted fixture.

A marker written as a trailing comment marked the wrong test. Reading only
the line below a marker meant `it('replays byte-identically', ...) //
AT-04: byte-identity` recorded the *next* test as the proof's, and let its
own drop out of the sweep unreported -- the original fault's exact shape,
reintroduced by the fix for it. A marker now marks the test on its own line
when it trails one.

The sweep saw only `it(` with the name on the same line. vitest exports
`test` as well, and a multi-line call is ordinary formatting; a
byte-identity test written either way was invisible. The asymmetry is what
made it dangerous: a *marker* above an unrecognised form was reported, but
a missing marker on one was not, and the missing marker is the case the
sweep is for.

Prose quoting a test name was treated as a test. This repository's
docblocks quote test names as a habit -- check-replay-markers' own header
does -- and both escapes were wrong: rewording the prose, or marking it and
adding a test that does not exist to the proof's expected set. Comment
lines are now skipped, using the isCommentLine that scripts/gates/lib.ts
already exported for this.

Two marked tests sharing a leaf title in one file are now refused. The
proof keys a pass by (file, title) because vitest's title carries no
ancestor describe, so one passing could not be told from the other being
skipped.

The proof's own verification had no test at all, which review named as the
deepest finding: deleting the missing-test check left pnpm check, pnpm test
and pnpm gates green while the proof printed "held" over a file vitest had
failed to collect. It moves to scripts/replay-verify.ts and is exercised by
scripts/tests/replay-proof.test.ts -- a skipped marked test, a failed one,
an uncollected file, and a same-named pass in another file that must not
stand in for it.

Record corrections from the same review. CLAUDE.md said pnpm check is "what
CI runs", which this branch made false by adding a CI step; it now says
most of what CI runs, and names the proof beside it. The backlog said the
new gate's test sits "beside the other 21" -- 18, since check-schema-masters
and check-snapshot-drift have none, which is worth saying in a document
about not trusting an unwatched check. And #107 adds 244 lines to
scheduler.ts, not 251, which was the changed-line total.

The CI comment now names the duplication it accepts rather than only its
duration: the App tests step already runs these files once.

Verified in pieces after two OOM kills of the combined run: typecheck,
lint, 630 app tests, 50 gate tests, 22 gates, build, and the proof holding
over 9 marked tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KCv1sDnpZZXoXJ2w8osB3s
The centre band's first build answered "what is a cell's value made from" with a grid of grey
buttons over a list of four percentages. It was honest and it was dull, and dull was a
correctness problem rather than a matter of taste: the interesting fact in the provenance
field is spatial. The measurement share is a footprint — bright where a sensor reached, dark
where the analysis had only its background — and it shrinks with depth. Four numbers for one
column cannot show a footprint.

So the region reads a slab through an EDR *area* query and draws it: a source selector that
isolates one contribution or shows whichever dominates, a depth control that walks the water
column, a readout that follows the pointer and the arrow keys, and a cell that opens into its
full profile. One area query for a field is the query the standard has for exactly this; the
old version issued six position queries about one column and never asked about anywhere else.

The palette is computed rather than chosen. Four sources, fixed order, never cycled, validated
against this shell's own surface #10151b: lightness band, chroma floor, CVD separation,
normal-vision floor and contrast all pass, with the worst adjacent pair at ΔE 8.4 under
protanopia — over the floor, and close enough to it that secondary encoding is obligatory
rather than optional. Hence the per-source hatch, the legend, the readout in words and the
printed percentages: with the colour removed the region still reads.

Three things the first draft got wrong, each measured in a browser rather than reasoned about:

- the hatch was coarser than the data. Tiled at four user units where one unit is one cell,
  each tile spanned four cells, so the map came out scratched with long diagonals crossing
  cell boundaries — a picture of the pattern rather than of the data. At 0.5 units each cell
  carries two strokes of its own, and each tile holds a wash of the hue as well as a line;
- it opened on the measurement share, which is one bright patch in a black rectangle: a true
  picture and a poor invitation. It opens on the strongest source, where every cell has one;
- the map was `width: 100%` with a 1px border under content-box, so the region overflowed by
  exactly two pixels at both phone widths, and the narrowest chip was 39px wide against a
  44px thumb floor that min-height alone does not cover.

Watched failing, and reverted each time:

- the field is asserted to vary, planted against a slab read off a fixed index: "every cell in
  the field was drawn at one value: expected 1 to be greater than 1";
- the depth control is asserted to re-read, planted by dropping depth from the effect's
  dependencies: "changing depth asked the query layer nothing". That assertion compares the z
  of the first and last area query rather than counting them, because a control that
  re-rendered without re-reading would grow the count while fetching the same slab for ever.

And the contrast suite caught a stale exemption: `.forecast-column-bar` was excused as
text-free and no longer exists. The check that keeps that list honest is the reason the
exemption did not outlive its rule.

pnpm check: 684 app tests, 40 script tests, 22 gates. All six capture proofs green, and the
region measured at 390px and 360px with the loop warmed: nothing scrolls sideways, every
control clears 44px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Yhrus4EAa9MHMbbSuzMhc
Three conflicts, all in files two branches append to, and one semantic conflict the textual
merge could not see.

- `scripts/gates.registry` and its test: both branches appended a gate. Both are kept —
  `check-replay-markers` from main, `check-declared-cost` from here. The registry is
  append-only precisely so this resolves by keeping both rather than choosing.
- `CLAUDE.md`: both branches were correcting the *same* false sentence, which said `pnpm
  check` was "what CI runs". Main added `pnpm replay-proof`; this branch added the six
  capture proofs. Neither correction was complete, so the merged text states the whole of
  it — CI runs seven more things than `check` does — with each one's reason for being out:
  the proof re-runs the marked tests' files, and the captures need a build and a browser.

And the semantic one, which is the useful part. Main's new `check-replay-markers` gate
failed on this branch's `kernel.test.ts`: the two-layer kernel's determinism test makes a
byte-identity claim, cites AT-04 in its own name, and carried no marker — so it sat outside
the AT-04 proof with nothing said. That is exactly the silence the gate was written for, and
it found it in code written on another branch while it was being written. Marked, and the
proof now holds over ten tests rather than nine.

pnpm check on the merge: 684 app tests, 51 script tests, 23 gates. `pnpm replay-proof` holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Yhrus4EAa9MHMbbSuzMhc
@IanMayo
IanMayo merged commit 8e0503d into main Sep 3, 2026
3 checks passed
@IanMayo
IanMayo deleted the claude/srd-model-forecast-specs-ws9x3d branch September 3, 2026 11:33
IanMayo pushed a commit that referenced this pull request Sep 4, 2026
`stage()` recorded the run before three decline paths, so a window declined for
want of measurements, for a missing holding, or at the staging bound claimed the
run as staged. The model runner's restatement of that same run was then dropped
as a duplicate and the run got no bundle at all — the "zero staged bundles
against a live run's five" outcome FR-125-05 exists to prevent, one configuration
edit away. The field is named for what was staged; now it holds it.

The module header still described the prompt as staging over the release the
packager last heard. It reads the store now, because since the forecast eras
there may have been no release to hear: an artefact that carries them holds the
model runner back for the whole pre-roll, and `returning`'s script prompts for a
package inside it. The deleted `lastPublished` field's documentation went with
it.

`isoPlusSeconds` gains the test its own justification asks for. The whole reason
it exists as one shared function is that the copies it replaced disagreed below
the second, and they agreed only because every simulated instant lands on a whole
second at `tick_interval_us: 1000000` — the regime every other test runs in, so
the one property the consolidation claims to establish was the one nothing
checked. Watched failing against the truncating spelling it replaced.

"The store held eight forecasts" was a miscount repeated in four places: the
`instance` era carries a forecast and its spread field per run, so eight holdings
are four forecasts.

And the backlog's prose is brought to the count the previous commit retook. It
still opened by arguing from sixty-five, still said the figures would go false
"the moment #107 merges" after #107 had merged, and still described feature 124
as unstarted at 35 lines and outside the count when it is merged, 8 ticked, 28
unticked, and the bulk of what the count holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MqE2kzNL54yxWLwVYD4SK9
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.

2 participants