Skip to content

env-generator: analytic form 2 domes the thermocline; 48x40x26 grid (#113) - #114

Merged
IanMayo merged 12 commits into
mainfrom
claude/feature-124-status-kqrmek
Sep 5, 2026
Merged

IanMayo merged 12 commits into
mainfrom
claude/feature-124-status-kqrmek

Conversation

@IanMayo

@IanMayo IanMayo commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

What this changes, and why

Feature 124's volume drew the thermocline as a surface and found a plane — 7,679 of 7,680 columns at one depth. It captioned that count rather than calling the result doming, and the measurement became issue #113, which offered three options: raise the depth resolution, amend FR-120, or leave it stated.

The measurement ruled out the first on its own terms. Sampled over the same columns at 200, 100, 50, 40, 25 and 10 m spacing, the surface has one distinct depth at every one of them; at 20 m the winning level pair beats the runner-up by 13.7–17.3% in every column in the domain. thermoclineAnomalyT(parameters, depthM) took no longitude and no latitude — the layer was one depth everywhere and there was nothing to resolve. FR-120's rationale described something the harness did not contain, and no drawing at any spacing could have shown it.

So this takes a fourth option the issue did not list. Analytic form 2 displaces the layer by displacement_m_per_c times the eddy and moving anomalies evaluated at its nominal depth — warm water presses an isopycnal down, cool water lets it rise. The front is deliberately excluded: its anomaly is a tanh and saturates rather than decaying, so as a displacement it tilts the whole domain, and the estimators find that. ADR-0044 carries the reasoning, the measurements, and the costs that were rejected.

The depth axis moves to carry the result. Depth multiplies against the horizontal and the horizontal was generous — 5.3 km cells against a 64 km eddy — so half of it pays for four times the depth: 48 × 40 × 26, layer-to-spacing ratio 0.15 → 0.75, committed artefacts 27.7 → 30.4 MB. Keeping 96 × 80 and adding depth alone was measured at 115.7 MB with returning alone at 49 MB, and the snapshot codec already gzips with a byte-plane shuffle, so that was a floor rather than a starting point.

FR-120 is not amended. The requirement described the ocean correctly and the harness did not contain it.

This branch also completes T012 — the sound-speed relations move to app/src/seam/ocean-relations.ts so the volume's parameter control can offer sound speed without a second implementation (ADR-0005) — and fixes a scheduler fault the grid change exposed, where a replayed start condition measured its cadence from a request nobody answered.

The instance, opened at the change

https://deepbluecltd.github.io/drogna/instances/claude-feature-124-status-kqrmek/#/view/forecast

Run the clock forward until an analysis lands, pick a column on the share map, then press show the volume. The parameter chooser defaults to sound speed, derived per cell from the temperature and salinity the same query returns.

Two things worth looking at rather than reading about: the caption under the volume, which counts what it found and names what it cannot tell; and the level stack's transparency, which is now derived from the number of levels rather than typed — at 26 levels the old constant left the box 99.4% opaque and the surface invisible inside it.

The blog entry

https://github.com/DeepBlueCLtd/drogna/blob/claude/feature-124-status-kqrmek/site/docs/blog/posts/the-drawing-was-honest-and-the-ocean-was-flat.md

What was watched failing

Every check added or changed here was planted against, watched caught, and reverted.

  • The doming test (generator.test.ts) reads its bound off the manifest — predicted relief over depth spacing — and refuses a bound with no teeth. Planted displacement_m_per_c: 0, and separately a 6-level axis; both failed at that guard.
  • The form-version refusal (manifest-world.test.ts). Removed the check; the foreign-form test failed. It accepts the shipped manifest too, so a guard refusing everything would fail as well.
  • The manifest implementation path (ocean-relations.test.ts). Planted the stale analytic.ts#soundSpeedMs path back; the test failed on it and the other four still passed.
  • The scheduler's interval fix. Disabled the assignment; the new test failed naming the 555-tick difference. An earlier draft of that test passed with the fix disabled — it ticked short of both floors — and the corrected tick is recorded in the test.
  • The volume's caption (volume-panel.test.tsx). Planted an off-by-one distinct count, and separately removed the empty-surface guard; both caught.
  • The zoom's window. Planted a zoom that ignores its view; the corrected span assertion failed it.

Three rounds of adversarial review ran against this branch, each handing the diff to subagents with no sight of the work. They found eleven faults between them, all fixed here, and the ones worth naming are: an AT-06 regression publishing the drifting feature 64.9 km from truth on returning's own seed; deck.gl hoisted into every view's first load (719 kB gzipped against 522); the volume 99.4% opaque at 26 levels with its surface invisible inside it; clicking into the volume, which FR-121 rules out in as many words, opening a column ~180 km from the one pointed at; a manifest master prescribing arithmetic the generator does not implement; and — last round — picking a column in the volume leaving the share map lighting a different square, with the wrong latitude and longitude printed beneath it.

Five checks in this branch were found unable to fail and were replaced rather than kept:

  • capture:forecast computed three shape figures and logged them. Asserting them was the obvious fix and is also wrong: the drawing reads a served analysis, and assimilation scatter gives four distinct depths whether the layer is displaced or not (the truth field gives three against one). The shape check belongs in generator.test.ts over the manifest; the capture now asserts only what a picture can honestly be asked for, and says why.
  • departure.test.ts compared a counter against the bounds of the loop that incremented it — true for every input. It counts drifts now.
  • The analytic_form_version bump was credited in the ADR with protecting a form-1 reader, and nothing performed that protection.
  • forecast.test.tsx's zoom test carried const spanBefore = 1; expect(spanBefore).toBe(1) and a finest > 0 that is positive by construction, under a comment claiming the field is "thinned by two" — false since the grid halved, and this was the one test that ran the shipped grid and could have caught it. My first replacement compared the same DOM query with itself; it now compares the zoomed longitude span against the unzoomed one.
  • The volume's caption called an empty surface "level to within one 40 m level", a statement about a field it had placed nothing in.

Verified at this head: 24/24 gates; vitest run --root app 792/792; tsc and eslint clean; check-snapshot-drift clean; pnpm replay-proof held, all 10 marked byte-identity tests; all seven capture proofs CI runs; build back to 522.95 kB gzipped with the deck.gl chunk split out.

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

Not a backend change in the template's sense (no services/, deploy/ or proxy/), but this is a data change and every stored field moves. pnpm snapshots rebuilt all four artefacts under check-snapshot-drift; pnpm replay-proof held, all 10 marked byte-identity tests reproducing their runs; all seven capture proofs pass.

One cost to know about: the app suite is roughly twice as long, from the 26-level grid. vite.config.ts's hook budget asks to be re-derived rather than nudged when the loop gets dearer, so it was: analyst.test.ts went 48.9 s → 51.9 s, +6%, because halving the horizontal to buy depth leaves only 8% more cells. The 120 s budget stands, measured rather than assumed.

Left undone, and why

FR-120's third verb. The requirement says the thermocline "domes, tilts and breaks". Form 2 delivers the doming and the breaking, both local, and not a domain-wide tilt — and the two attempts at one failed for the same reason, which is why ADR-0044 records them rather than leaving an issue saying "next time". The front's saturating step lost the drifting feature on returning's own seed. A smooth basin-scale ramp gives exactly the shape wanted (modal share 95% → 41%) and puts seed 1180003's front anchor 74 km across against a 30 km bound, at every tilt tried. The estimators locate features by horizontal temperature structure, and a layer whose depth varies horizontally is horizontal temperature structure — so broad shape and bounds derived from the authoring jitter compete for one signal. Resolving it needs an estimator that reads depth structure, bounds that allow a sloping layer, or a decision that the demo prefers shape to scores. None is a data commit's to take.

The drawing cannot demonstrate the fix, and says so. The volume reads a served analysis, and assimilation scatter moves a weakly-held column's steepest pair on its own: the distinct-depth count is four with the displacement and four without. Only over the truth field does it separate the two forms, three against one. So the caption states where each column's gradient sits and states what it cannot tell from that; the proof of the shape is a unit test over the manifest, not a picture.

T010 — the features carried with depth through the volume — remains unticked. Its arithmetic (featureReach, FEATURE_SIGMAS) shipped early and has no caller; tasks.md records that as a cost, and its calibration already needed re-measuring once when the axis moved.

The zoom apparatus on the share map is inert. MAP.maxCells is 48 and the field is now exactly 48 wide, so coming closer gives bigger squares and not more of them. The machinery is unchanged and correct — it is the general rule and starts working again the moment the field outgrows the panel — and the notes say so rather than promising a resolution already on screen.

Two commit subjects run to 77 and 78 characters against the ~72 CLAUDE.md asks for. Rewriting pushed history to save six characters is the worse trade.

The last correctness pass had not returned when this was opened. Its findings will go on this pull request as review comments rather than another round before it — the rounds were converging on smaller findings while the record of them grew, which is the loop this repository's own history warns about.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AoDxPmvGhBF3wCSbzhPjNp


Generated by Claude Code

T009's surface needs a shape the harness does not publish. `forecast-features`
carries a thermocline, and it is a domain mean — "where the domain-mean profile
falls fastest" — which is one number for the whole grid, and one number is a
flat plane. FR-120 asks for a surface and says why in the requirement: the
thermocline domes, tilts and breaks, and shape is the answer a sonar user is
asking for. A plane drawn where a surface was promised claims the field is flat,
which is the one thing the drawing exists to disprove.

The shape is real and is in the served field: the seeded eddy and front perturb
temperature horizontally, so the depth at which a column falls fastest moves
from column to column even though the analytic thermocline term is horizontally
uniform. Nothing here invents it — the values come from area queries the shell
makes through the query layer, one per level, as the Map's volume already does.

The pick is the harness's own rather than a second opinion about it:
`model-runner/features.ts` defines the published estimate as the steepest level
pair reported at its midpoint, carrying the pair's separation and declining
where the profile does not fall. This applies that definition per column.

Held to the backend by calling it. The first draft of that test transcribed
`features.ts#thermocline` into the test body and compared this module against
the copy — which holds two copies together, says nothing about the backend, and
would pass after the estimator it names had changed. It imports and calls
`estimateFeatures` now; tests are exempt from the import boundary and this is
the case that exemption is for. It compares on the domain mean because the mean
of the per-column picks is a different quantity: argmax does not commute with
averaging, and asserting it did would be asserting something false.

That comparison first failed by 2.4e-7 on the drop with depth and thickness
already exact — float32 epsilon, because the estimator reads a Float32Array and
the test fed doubles. Fixed by reading the levels back out of the packed array
rather than by loosening the bound, which would have sized a tolerance for a
storage artefact and hidden a real disagreement behind it later. It is also the
truer input: holdings are stored drogna-f32-v1.

Watched failing: reporting the shallower end of the pair instead of its midpoint
gives "the volume and the run disagree about where the thermocline is: expected
200 to be 300", and a planted hole in a profile is watched not to move the pick.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AoDxPmvGhBF3wCSbzhPjNp
T008, T009 and T011. The field as six semi-transparent levels of the analysis,
each a genuine EDR area query, with the thermocline drawn as a surface through
them and the column selection shared with the share map beside it.

Reused rather than rebuilt. `map/cube.ts` carries the lon/lat/depth triple into
the space an OrbitView rotates and carries a clicked point back out, tested as a
round trip; the Data tab's volume imports the same module. `lazy.tsx` withholds
deck.gl for the reason `data/lazy.tsx` records — about a third of the bundle,
and this is the tab's only surface that needs it — so opening Forecast costs
what it always cost.

T011 says the volume's grid must not be a second selection. It is rendered
inside `ColumnProvenance`, where `column` and `readColumn` already live, so
there is one reader and two drawings of it. Driven live: clicking the volume
opened the column at 46.22°N, -10.90°E in the profile beneath.

**The surface FR-120 asks for is a plane on this configuration, and the drawing
says so.** The harness publishes a domain-mean thermocline — one number, hence a
plane — so the surface is derived per column by the run's own definition. Driven
against `arriving`: 7,679 of 7,680 columns place it at 100 m and one at 300 m.
The cause is the profile, not the pick: pair gradients run 4.49, 1.64, 0.92,
0.51, 0.29 °C per 100 m, so the shallowest pair wins by nearly three to one
everywhere, and at 200 m spacing the authored layer is an order of magnitude
too thin to resolve — which `features.ts` already says of its own estimate. The
caption states the distinct-depth count it found and calls the flatness a fact
about the grid rather than about the ocean, because a picture captioned as a
doming surface over two distinct depths is the false illumination this region
exists against. Raised as #113 with the measurement and the options; the depth
axis is a data change touching every holding.

The camera is framed for this canvas rather than inherited: the Data tab's zoom
put the cube's own edges outside a 260 px viewport, and a rotating drawing whose
frame is off-screen has lost the thing that says which way is down.

T012 stays unticked with its reason. The parameter control offers what an
analysis carries — temperature and salinity — and does not default to sound
speed, which is derived rather than served; ADR-0005 makes that derivation the
single implementation in drogna, so a second one here is ruled out by a decision
already taken. The three routes are recorded in tasks.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AoDxPmvGhBF3wCSbzhPjNp
T008, T009 and T011. The volume is deck.gl on the Map's own `cube.ts` frame,
withheld by `lazy.tsx` on the Data tab's measured precedent, and rendered inside
`ColumnProvenance` so a click goes to the selection the region already has —
T011 asks for one selection and this is what that means in code. Driven live: a
click in the volume opened the column at 46.22°N, -10.90°E in the profile.

The thermocline surface is derived per column, and comes out flat. Measured on
`arriving`: 7,679 of 7,680 columns place it at 100 m and one at 300 m, because
the domain-mean gradients run 4.49, 1.64, 0.92, 0.51, 0.29 degC per 100 m and
the shallowest pair wins by nearly three to one everywhere. At 200 m spacing the
authored thermocline is an order of magnitude thinner than the grid resolves,
which `features.ts` already says of its own estimate. FR-120's doming is not
visible at this resolution by any means. The caption states the distinct-depth
count it found rather than claiming shape; #113 carries the evidence and the
options.

T010's arithmetic. The features holding publishes the eddy, the front and the
drifting feature as centre, radius and anomaly — all horizontal, no vertical
extent anywhere, and the estimator takes no manifest on purpose. So the
dimension is read off the field they are in, which is what T010 asks for: at
each level the feature's own cell against that level's mean, drawn while it
clears the level's own spread. Measured: the eddy runs 2.06, 2.32, 0.18, 1.09,
0.03 and -0.65 sigma down the six levels, so two sigma separates its real extent
from the field's wiggle with the nearest false positive at 1.09.

The floor is the level's spread rather than a temperature, because the field
collapses from 1.13 degC at the surface to 0.23 degC at 800 m and one threshold
in degrees would be two different claims. The feature's own cell is excluded
from that spread: a feature must not be measured against a yardstick it moved.

Watched failing, and the second one is the reason to plant rather than assume: a
fixed 0.5 degC floor planted in place of the spread **passed all ten tests** —
on that fixture both rules give the same answer at every level, so nothing held
the design its docstring argues for. The case that parts them is a deep feature
weak in degrees and strong against a quiet field, and it is a test now. Also
watched: reporting the shallower end of a pair instead of its midpoint fails the
backend binding.

One fixture was wrong rather than the code: a level named "indistinguishable"
had its other cells within 0.01 degC, so a 0.02 residual cleared two sigma of
almost nothing. Real water still scatters at depth.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AoDxPmvGhBF3wCSbzhPjNp
…e (T012)

ADR-0005 says sound speed is derived at the point of use, never stored, and that
there is exactly one implementation in drogna. The volume's parameter control needed
it, and a panel may not import the backend (Constitution XI), which left three ways
to satisfy the request: a second implementation in the panel, which the ADR forbids;
serving it derived from the query layer, which is a backend change nothing else
wanted; or moving the relation to where both halves can reach it. The relation is a
function of three numbers and knows nothing about the ocean, so the seam is where it
belonged. `analytic.ts` re-exports all five symbols, so every backend caller —
advisories, telemetry, monitor, generator, sensors, runner — is unchanged.

The snapshots move because `SOUND_SPEED.implementation` is a string in every
manifest and the path in it changed. Read rather than regenerated blind: the
differences are that path (43 to 53 characters, fourteen occurrences, and the
140-byte length prefix that follows from it), the code revision, and nothing else.
Zero of 2,382 digests changed, so no field bytes moved.

`ocean-relations.test.ts` carries the anchor assertions here from
`analytic.test.ts` unchanged — a test reading the re-export holds the re-export, not
the relation — and adds one the move showed was missing: the implementation path a
manifest hands a client had never been checked to name a file that exists. Planted a
stale path back into `SOUND_SPEED.implementation`, watched that test fail on it, and
reverted; the other four still passed, which is the point of it being its own test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AoDxPmvGhBF3wCSbzhPjNp
…id (#113)

FR-120 asks for the thermocline as a surface and gives its reason in the requirement:
the layer domes, tilts and breaks, and shape is what a sonar user is asking for. Feature
124 drew that surface and captioned what it found rather than what it wanted — 7,679 of
7,680 columns at one depth — and that sentence is what made this findable.

The depth axis looked like the whole answer and was not. Sampled over the same 7,680
columns at 200, 100, 50, 40, 25 and 10 m spacing, the surface has exactly one distinct
depth at every one of them; at 20 m the winning level pair beats the runner-up by 13.7%
to 17.3% in every column in the domain. `thermoclineAnomalyT(parameters, depthM)` took
no position: the layer was one depth everywhere and there was nothing there to resolve.

Form 2 gives it one. `thermoclineDepthAt` displaces the layer by
`displacement_m_per_c` times the eddy, front and moving anomalies evaluated at its
nominal depth — warm water presses an isopycnal down, cool water lets it rise — and the
three thermocline terms take that local depth as a required argument, undefaulted so a
caller cannot silently get the flat field back. The anomalies are read at the nominal
depth rather than at the depth being sampled: a layer's depth is a property of the
place, and reading it at `depthM` would put the layer somewhere different for every
sample down one column. ADR-0044 has the reasoning and the costs that were rejected.

The grid becomes 48x40x26. Depth multiplies against the horizontal and the horizontal
was generous — 5.3 km cells against a 64 km eddy — so half of it pays for four times the
depth: the layer-to-spacing ratio the manifest publishes goes 0.15 to 0.75, the eddy
keeps six cells across its radius, and the committed artefacts go 27.7 MB to 30.4 MB.
Keeping 96x80 and adding depth alone was measured at 115.7 MB with `returning` at 49 MB,
and the codec already gzips with a byte-plane shuffle, so that was a floor.

Two watched checks, both seen to fail before being trusted. The new doming test in
`generator.test.ts` reads its bound off the manifest — the predicted relief over the
depth spacing — and refuses to run on a bound with no teeth; planting
`displacement_m_per_c: 0` and, separately, a 6-level axis both failed it at that guard,
and it passes on neither by accident. The analyst's and the runner's manifests carried
`analytic_form_version: 1` as a literal while spreading the env-generator's manifest
wholesale, so the bump made them wrong: form 2 parameters under a form 1 label. Both now
read the number from the document they spread.

Also: the caption printed max minus min and called the result doming, which read "16
distinct depths spanning 840 m" against a served analysis whose layer moves 80 m — true
of two columns and false of the field, the same fault as form 1's from the other side.
It now reports the depths holding 90% of the columns and says so. `capture:forecast`
gains a second shot, of the volume with the surface in it, and refuses if the surface
placed nothing; `departure.test.ts` counts its mismatches instead of running a million
`expect` calls, which is what timed it out when the axis grew.

FR-120 is not amended. The requirement described the ocean correctly and the harness did
not contain it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AoDxPmvGhBF3wCSbzhPjNp
Two independent passes on the pushed head, then this. Each finding below was reproduced
before it was fixed and, where it was a check, watched failing after.

**The front does not displace the layer, and the coefficient is 20 m/°C.** All three
features moved it in the pushed version, and the front's anomaly is a `tanh` — it
saturates rather than decaying, so it held one half of the domain permanently deep and
the other shallow, out to the corners. That is a blob the size of the domain, and the
drifting-feature estimator found it: AT-06 seed 1180004, which is `returning`'s own seed,
published a centre 64.9 km from the authored one against a 40 km bound, where form 1 had
honestly declined. Dropping the front leaves the estimator better than form 1 on every
one of the five seeds, 1180004 at 1.9 km. The front's anchor recovery agreed
independently, 74–97 km across against a 30 km bound at every grid size and 1 km without
it. 30 m/°C put that back over its bound, so the coefficient is where the estimators
still hold. ADR-0044 carries both tables.

**A replayed `returning` turned its loop 555 ticks early.** The scheduler set
`lastRequestTick` only from its own requests, so a start condition whose artefact carries
the forecast eras — the model runner held back for the whole pre-roll, every request
orphaned — measured the minimum interval from a request nobody answered. It now takes the
tick from `valid_time.start_sim_time` of any standing run it hears, which the announcement
already carries. Watched failing with the assignment disabled.

**The volume was 99.4% opaque.** Per-level alpha was 46, "thin enough to see through a
stack of six"; transmittance compounds, and twenty-six levels let 0.6% through. The stack's
transmittance is now the constant and the level's share follows from the count — which
reproduces 46 at six levels exactly.

**deck.gl was in every view's first load.** `ColumnProvenance` imported a `const` from the
module `lazy.tsx` defers, which Vite warns about and which hoisted the whole stack into
`main`: 719 kB gzipped against 522. It took the Data tab's and the Map's own deferrals with
it. The parameter vocabulary moved to `volume.ts`, the pure half; measured back to 522 kB
with the deck.gl chunk separate again.

**Clicking inside the volume was ruled out by FR-121 and was happening.** `onClick` read
`info.coordinate`, the ground-plane unprojection, so a click on a deep cell opened a column
about 180 km away and a click on empty canvas opened one outside the domain, clearing the
reader's rays and numbers. There is now a pickable grid of squares on the surface plane —
which is what FR-06 asks for — and a click that hits no square selects nothing.

**The capture's shape check could not fail.** It computed three figures and logged them.
It now asserts the layer takes more than one depth, which is exactly what form 1 could not
do, and that the caption states a measured figure beside its count.

**The caption branched on a count outliers inflate.** Twenty-one depths of which one holds
99% is a plane, and `distinct <= 2` called it doming — while the honest figure was computed
three lines above and printed but not consulted. It branches on the span most of the field
is in, and states the modal share, because the doming form 2 produces is local: on the
shipped configuration the field is level to within one 40 m level, and the drawing says so
rather than being made to look otherwise.

**`analytic_form_version` protected nothing.** ADR-0044 credited it with stopping a form 1
reader, and `manifest-world.ts` cast without looking — `depth_m + undefined * anomaly` is
NaN at every point, with no reason anywhere. It refuses in both directions now, watched
failing.

Also: a published refusal reason claiming "the grid resolves hundreds" of metres, which
crosses the wire and is 40 m; six comments and three pieces of user-facing prose still
describing the 96×80×6 field, including the zoom's own premise, now that a 48-wide field
fits a 48-cell panel whole; T009's record arguing against the change it sits in; a
docstring saying the parameter does not default to sound speed beside the line defaulting
it to sound speed; `FEATURE_SIGMAS` calibrated on the six-level axis, re-measured on this
one (the reach is contiguous now rather than on-off-on); the volume capture shipped with no
provenance sidecar; and an unreachable `'reason' in estimate` guard.

Not fixed, and said rather than left: two commit subjects on this branch run to 77 and 78
characters against the ~72 the file asks for, and rewriting pushed history to save six
characters is the worse trade.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AoDxPmvGhBF3wCSbzhPjNp
FR-120 asks for a thermocline that "domes, tilts and breaks". Form 2 delivers the doming
and the breaking, both local, and not the tilt — and the two attempts at one are worth
more on the record than an issue saying "next time", because they failed for the same
reason and it is a structural one.

The front's saturating step is already recorded. A smooth basin-scale ramp is the obvious
alternative — it is what sets thermocline depth at basin scale, and it has no localised
extremum for a blob estimator to catch on. It gives exactly the shape wanted: four
distinct depths, the commonest three spanning 80 m, the modal share down from 95% to 41%.
It also puts seed 1180003's front anchor 74 km across against a 30 km bound, at every tilt
tried down to 10 m per degree.

The estimators locate all three features by horizontal temperature structure, and a layer
whose depth varies horizontally *is* horizontal temperature structure near that layer. So
broad layer shape and bounds derived from the authoring jitter compete for one signal.
Resolving it needs an estimator that reads depth structure, bounds that allow a sloping
layer, or a decision that the demo prefers shape to scores — none of them this change's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AoDxPmvGhBF3wCSbzhPjNp
A second adversarial round on the pushed head. The first finding is the one that mattered.

**The master prescribed arithmetic the code does not run.** `manifest.schema.json` said the
layer's depth is `depth_m` plus the coefficient times "the eddy, front and moving
temperature anomalies", and `thermoclineDepthAt` sums the eddy and the moving feature
only — deliberately, with ADR-0044's measurement behind it. Principle III makes that
document the contract a V3 backend generates from, so a reader following it reconstructs a
layer tilted 33 m across the domain where this harness authored a flat far field: 16.7 m of
error at every corner, 42% of the depth spacing. The description now states the rule and
says why the front is excluded. No gate can see this class of fault — nothing checks a
schema's prose against behaviour — which is worth knowing about the gates rather than
about this change.

**The ADR's decisive table did not reproduce.** It was measured with a standalone probe,
quoted a seed (1180005) that exists nowhere in this repository, omitted seed 1234, and its
figures did not match what `features.test.ts` prints. Re-measured by running that file
under each variant, so the table is the test's own output. It corrects the story as well as
the numbers: the 64.9 km mis-location was at a coefficient of 30, not the shipped 20, and
at 20 the front-in-the-displacement variant *declines* on 1180004 exactly as form 1 does.
Excluding the front is still right — it is the only variant that finds that feature at all
— and 20 is still the coefficient, because at 30 and 40 seed 1180003's front anchor leaves
its bound with the front already excluded. The layer's shape and the front's position are
read off one signal, which is the tension the ADR's last section is about, in miniature.

**`departure.test.ts` gained an assertion that could not fail.** It compared a counter
against the bounds of the loop that incremented it — the loop's own arithmetic restated,
true for every input including the empty one. It counts drifts now, uncapped, with the
examples capped instead, and refuses a brief with nothing to compare.

**The volume had no component test.** `volume.ts` was covered thoroughly and the component
consuming it only by a capture proof, which measures one built configuration. Four tests
now hold what a capture never reaches: a refused level, a 200 that is not the master's
shape, a parameter the field cannot supply, and the caption's figures — computed from the
fixture through the same pure function rather than transcribed, so they move when it does.
Planted a wrong count and watched it caught. deck.gl is not exercised and the file says so.

Also: the Suspense placeholder wore the loaded component's test marker, so a proof could
not tell a chunk still downloading from a query the server declined; and two specs plus two
comments still quoted 96×80×6 as the tree's own figure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AoDxPmvGhBF3wCSbzhPjNp
`vite.config.ts` asks the next author to re-derive `hookTimeout` when the loop gets
dearer rather than nudge it, so here is the derivation. Raising the depth axis from 6
levels to 26 looked like it would be expensive for `analyst.test.ts`, the file the
ceiling is about; the horizontal was halved to pay for the depth, so the analysis grid
carries 8% more cells and the file went from 48.9s to 51.9s on this machine. Doubled for
a runner, 104s against a 120s budget. The number stands, measured rather than assumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AoDxPmvGhBF3wCSbzhPjNp
A third adversarial round, on the pushed head. One of these is a bug a reader would meet.

**Picking a column in the volume left the share map lighting a different square.**
`onPickColumn` moved the rays, the profile, the numbers and the volume's own marker, and
never touched `cursor` — which is the only thing the share map's highlight and its
coordinate readout are drawn from. So a reader who opened square A on the map and then
clicked column B in the volume got B's provenance under A's highlight, with A's latitude
and longitude printed beneath it. `Volume.tsx`'s header states the invariant this breaks in
as many words, and every other route into `column` already kept it. The cursor now follows
the pick, and clears rather than snapping where the pick falls outside a zoomed window: a
snap would light a square the reader did not choose, which is the same fault one cell wide.

**Two claims that outran their evidence.** The "still to come" paragraph described a
volume, a surface and a caption in the present tense on *both* branches, including the one
that renders before any analysis is announced — where the reader is told there is no
provenance to read and then given a description of things not on the page. The sentence it
replaced named an absence, which is what made "said on both branches" safe; the comment
saying so was not revisited when the sentence changed kind. And with nothing placed, the
caption read "0 of 1920 columns have one, over 0 distinct depths — so it is level to within
one 40 m level", a statement about a field it had placed nothing in. It now says there is
no thermocline to place, which is a different fact from one at depth nought; planted the
guard away and watched the new test catch it.

**And a vacuous assertion written while removing a vacuous assertion.** `forecast.test.tsx`
carried `const spanBefore = 1; expect(spanBefore).toBe(1)` and a `finest > 0` that is
positive by construction, under a comment claiming the field is "thinned by two" — false
since the grid halved. The first replacement compared the same DOM query with itself. It
now compares the zoomed longitude span against the unzoomed one captured before the
keypress, and a planted zoom that ignores its window fails it.

**The relations barrel is gone.** `analytic.ts` re-exported the five seam symbols so the
eight backend call sites would not have to change, and that cost the move most of its
point: every consumer still read `from '../env-generator/analytic.js'`, so `monitor.ts` and
`sensors.ts` attributed the relation to the analytic-ocean module that the seam file says
it is not, and `grep ocean-relations` found no backend consumer of something the whole
backend uses. Eight one-line edits buy a tree where the owner is visible at the point of
use.

Also: T032 records the second blog entry the rule asks for and this line did not
anticipate, and T012's "none of the 2,382 field digests moved" now says which commit it is
about — true of the seam move, and not of the branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AoDxPmvGhBF3wCSbzhPjNp
…uld not fail

The last adversarial pass, on the pushed head. One performance fault a reader would feel,
and three faults of a class this branch keeps producing.

**The layer stack was rebuilt on every render.** `layers` was the one derived value in
`Volume.tsx` not memoised, and `data` identity is deck.gl's full-invalidation trigger: each
render handed all 28 layers fresh arrays, so `getPolygon` — four projections a datum — and
`getFillColor` re-ran over everything. On the shipped grid that is 49,920 level objects,
1,920 picker objects and roughly 215,000 projections. Renders are cheap to cause:
`ColumnProvenance` sets the cursor from `onMouseEnter` on up to 1,920 share-map cells, so
crossing one cell with the pointer rebuilt the stack, and with no pointer at all
`ForecastPanel` re-renders on every forecast indicator — which the monitor publishes beside
every scored residual, so the rebuild ran at observation cadence with the volume open.
`cellBounds` is stabilised with it, or the memo's dependency list would be a lie in the
direction that costs most. Nothing caught this: the capture opens the volume and never
moves the pointer, and jsdom has no WebGL, which `volume-panel.test.tsx` says outright.

**The caption's shape branch attributed what the branch beside it disclaims.** It said the
layer is "displaced where a feature acts on it: a warm one presses it down beneath itself",
while the level branch said the drawing cannot tell a displaced layer from an unsure column
— and the level branch is right: over a served analysis the distinct-depth count is four
with the displacement and four without. Fixing one arm and leaving the other making the
claim is the same fault reachable by a different configuration.

**And the capture's replacement assertion could not fail either.** "Either branch names a
measured figure" is satisfied whenever the caption renders, because both arms always emit
one — the second uncheckable assertion in that block, written while removing the first. It
now holds the caption's column count against the share map's cell count over the same
served grid; planted an off-by-one and watched it caught.

**A factual error in the blog entry.** It said letting the front displace the layer made the
harness "stop finding a drifting eddy it had found before". ADR-0044's own table says seed
1180004 is declined under analytic form 1 *and* under eddy+front+moving — nothing was lost
that had succeeded. The entry now says what happened: it stayed missing until the front came
out of the displacement.

Also recorded rather than left for someone to rediscover: AT-06's thermocline check scores
the domain-mean pick against the layer's *far-field* depth, and passes only because the two
displacing features cover little of the domain — widen one, or raise the coefficient, and it
fails for a reason that is not the estimator's. Nothing clamps the displaced layer to the
water column either; a clamp would hide a configuration error rather than report it, so the
gap is named instead. And T036's zoom record goes to past tense, since #113 spent the
horizontal it was written about.

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

github-actions Bot commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

This branch, running: https://deepbluecltd.github.io/drogna/instances/claude-feature-124-status-kqrmek/

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

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

…ble volume

Three things a reader reported, and one the narrow proof caught while fixing them.

**The volume could not be found.** Its control sat at the foot of the region — after the
map, the readout, the rays and the profile, 858px down a 1,065px column and behind 34
chips, below the fold of a 1,000px window. Tolerable at the six depth chips the grid used
to have; #113 took the axis to 26 and the chooser to four wrapped rows. A reader opened the
Forecast tab, saw the share map and reported "I just see a heatmap". The control now sits
with the other three control groups at 290px, and the drawing follows it rather than
staying at the foot — a chip that reveals something six hundred pixels below itself reads
as a chip that did nothing. Neither proof could see this: `capture:forecast` finds the
control by its accessible name, which says nothing about where it is, and the narrow proof
measures this region with the volume closed.

**The depth list is a drop-down.** Twenty-six chips were the tallest thing in the region
before any drawing, and twenty-six tab stops. A `select` is one, with the platform's own
keyboard and touch handling, and it names the depth it is on where the chips could only
show it by which was lit. Four tests drove those chips and now drive the chooser.

**The run list is in the left column.** "Runs, in simulation time" was a full-width section
under the grid, and the region beside it is 1,105px tall, so the list sat near y=1240 and
was easily pushed out of sight. "Why now" is short and leaves that column empty, which is
the space the list now takes: one grid cell holding two regions rather than a fourth track,
because `auto-fit` would have put the list beside "Why now" instead of beneath it.

**And the volume had no field in it.** Holding the stack's transmittance constant is the
physically right rule — halve the interval, double the slabs, and the water is as clear as
it was — and at 26 levels it gives alpha 12, which is a wireframe box with no parameter
visible. Rendered the shipped column at 12, 24, 60, 90 and 140 and looked at each: 60 is
still nearly black, 140 is a solid, 90 shows the field grading warm at the surface to blue
at depth with the thermocline still crossing it as a bright band. The band survives because
the surface is drawn brighter than any level, not because the stack is see-through — which
at 26 levels it is not, and the note says so rather than glossing it.

**The narrow proof caught the layout change**, which is what it is for: moving the run list
into a narrower column overflowed every run button by 7px at phone-landscape, 254px in 247.
`auto` grid maxima do not help — an `auto` track's maximum *is* max-content, and the tick,
mark and cause are already wider than the cell before the detail is placed. The detail now
spans the row beneath them, which holds at any width rather than at one breakpoint and
keeps the columns aligned down the list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AoDxPmvGhBF3wCSbzhPjNp
@IanMayo
IanMayo merged commit 5fa5917 into main Sep 5, 2026
2 of 3 checks passed
@IanMayo
IanMayo deleted the claude/feature-124-status-kqrmek branch September 5, 2026 05:58
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