Skip to content

World-model demo: a null result, a retracted finding, and step() pinned in three languages - #40

Open
aurascoper wants to merge 30 commits into
mainfrom
feat/hypnagogic-linux
Open

World-model demo: a null result, a retracted finding, and step() pinned in three languages#40
aurascoper wants to merge 30 commits into
mainfrom
feat/hypnagogic-linux

Conversation

@aurascoper

Copy link
Copy Markdown
Owner

Read the verdict before the diff. This branch's headline result is a null, plus a finding from an earlier commit that had to be retracted. A PR titled for the feature would read as a success to anyone who does not open it.

§4 verdict: the task does not discriminate over true dynamics

All three arms solved every trial, so the registered non-discrimination rule governs and this is not reported as an MPPI success.

arm reached mean final distance mean steps
mppi 35/35 0.0490 26.5
pd 35/35 0.0341 17.1
bang-bang 35/35 0.0326 19.7

MPPI is the worst of the three on both metrics. The threshold and the reading rule were committed at 05f688c, before worldmodel.rs existed.

A registered claim failed on held-out data

The binary metric saturated, so a second registration (54c9ce5) measured steps — disclosing that the metric was chosen after seeing it separate, and re-running on data generated by rule.

claim original held-out
C1 ordering pd < bang-bang < mppi holds holds HOLDS
C2 mppi over pd ≥ 25% 54.5% 47.1% HOLDS
C3 bang-bang over pd ≥ 5% 15.0% 3.0% FAILS

C3 is the claim the registration named as the one that could fail. Large effect shrank; small effect evaporated.

A finding was retracted

§6 finding #2 — "bang-bang has the best mean final distance" — does not generalize. On held-out scenarios bang-bang is worst (0.0700 vs PD's 0.0392). It carries an inline correction saying that half must not be cited. Notably it was the unregistered metric that moved most.

What is positive

step() is now pinned against WorldModel/env.py in three languages, bit-for-bit — nothing pinned it in any language before. The Swift test is in NeuralCompose (5705082) and was verified capable of failing before being trusted. Agreement is structural, not sampling luck: both division operands are exactly-representable binary32, and double rounding through binary64 is innocuous at 53 ≥ 2·24+2.

env.py itself is pinned by digest, because three conformant implementations make reference drift less likely to be noticed.

Non-claims

  • Promotes no support-matrix row.
  • Says nothing about EEG, JEPA or latent planning.
  • corner_pn_to_np being solved here is not an explanation of the latent failure — see §9, which also writes out why it is not the cross-harness comparison that was previously retracted.

Verification

364 tests, 20/20 mutants, fmt/clippy/secrets/drift clean, green with LLAMA_CPP_DIR unset. scripts/edit_guard.py now carries the applied-check that three separate tool failures needed.

--mic remains unexercised — the only path with neither fixture nor spy coverage, where a green suite proves nothing.

🤖 Generated with Claude Code

aurascoper and others added 30 commits August 13, 2026 06:14
…anner

A pre-registration, not a result. §6 is empty on purpose and stays empty until
one measured run fills it.

Pinned: MPPI over true dynamics reaches the goal (<0.1) on >=33 of 35 trials,
seven HARD_CASES x five seeds, 50 steps. And the rule that matters more than the
bar: if the PD arm also clears 33/35, the verdict is "the task does not
discriminate over true dynamics" — NOT an MPPI success. The failure mode here is
not MPPI losing; it is MPPI winning against a straw man on a task that was never
hard once the model is exact.

Bang-bang is reported and is never the bar. BaselineWorldModelDemoPlanner is
unit_vector(goal-pos) * maxAccel — constant full thrust, no velocity term. Its
own doc comment and ADR-007 call it "proportional"; that is wrong in the code's
terms, and it overshoots more than a P controller would.

§2 names three separate incomparabilities, because "not comparable to the
ledger" points at the wrong thing. Every ledger node 0-25 is synthetic_1f +
forward_eval.py — a different env, encoder, task and planner. WorldModel's own
README numbers are the same env but a LEARNED LATENT planner, so they are not a
bar either. And the Swift demo has no episode measurements at all.

Also records that Scripts/check_ledger_prefix.py — cited in planning as the
artifact enforcing harness attribution — DOES NOT EXIST. Only
check_ledger_references.py does, and it is an advisory prototype not wired into
CI that checks heading format and reference resolution, nothing about harness.
The separation holds only because these results were never filed in the ledger.

This is committed before worldmodel.rs exists, which is the only thing that
makes §3 a threshold rather than a description of whatever happened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…st use

Stage 4. Two kinds of work under one name, kept apart on purpose: `step()` is a
port with ground truth, the planners are a fresh measurement with none.

## The port

ParticleNavigatorEnv from WorldModel/env.py, bit-exact against a fixture
generated from the Python itself — not from a reimplementation, which would
agree with the port by construction. 260 steps covering 11 x-bounces, 26
y-bounces, 2 corner hits, 11 clamped and 249 unclamped steps, and a meta-test
asserts the fixture reaches all of those rather than trusting that it does.
Nothing pinned this function against Python in ANY language before now; the
Swift twin's tests are four hand-computed literals.

Matched on the first run, including NumPy's NEP 50 promotion: f32 arithmetic
throughout, the speed norm widened to f64 for the division, the scale rounded
back to f32 before the multiply.

A third deliberate divergence: a non-finite action or state returns None. NumPy
propagates NaN into the state; Swift's max(-a, min(a, x)) is worse and silently
turns it into full throttle — a real bug patched in the engine rather than in
step(). Refused at the source here.

## The measurement, and its verdict

Registered at 05f688c BEFORE this code existed: MPPI >=33/35, and if the PD arm
also clears it, report "does not discriminate" rather than an MPPI success.

    arm         reached   mean final distance   mean steps
    mppi          35/35                0.0490         26.5
    pd            35/35                0.0341         17.1
    bang-bang     35/35                0.0326         19.7

Every arm solved every trial, so §4 governs and this is NOT reported as a win.
Byte-identical across runs.

Three findings the plan assumed otherwise:

- MPPI is the WORST of the three arms, on distance and on steps. Uniform
  candidate sampling over a 10-step horizon is weak next to a tuned PD when the
  model is exact and the budget is 4x the minimum.
- "Beating bang-bang is nearly free" is REFUTED. Measured, bang-bang has the
  best mean final distance of the three. It was recorded as an untested
  hypothesis; it is now tested and wrong.
- corner_pn_to_np — the direction WorldModel/README.md records as failing
  completely under every latent configuration, cause unknown — is solved by all
  three arms in 18-28 steps. Per §7 that is NOT an explanation of the latent
  failure, since the predictor is not in the loop. It does establish that the
  geometry alone is not what makes that direction hard.

Nothing is retuned to get a better answer. A discriminating experiment needs a
new registration with its own threshold — shorter budget, actuation noise,
partial observability — not a moved bar.

## One survivor worth the whole exercise

GOAL_TOLERANCE 0.1 -> 1.0 survived the entire suite. It is the definition of
"reached", so §3's bar means nothing without it, and at 1.0 every arm reaches
from anywhere in the arena and the table above still reads 35/35. Same class as
the stalemate_margin survivor. Now pinned, with a test that also proves an
episode is CAPABLE of failing — otherwise "reached" is not a measurement.

20/20 mutants killed, 362 tests, fmt and clippy clean, green with LLAMA_CPP_DIR
unset.

## Also

ADR-004 records a real gap: the six assertion kinds have no slot for "measured
exactly by this software on a synthetic task". Not Observed (this binary is an
agent-reachable channel), not DerivedDeterministically (no store can re-execute
a planner), not HeuristicAnnotation (the number is exact). Filed as
AgentInference, which is honest but under-describes it. Recorded rather than
resolved by stretching a kind — five of the six names are mirrored upstream and
a seventh would have to be agreed there.

Promotes no support-matrix row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…disclosed

§6's binary metric saturated at 35/35 for all three arms. Mean steps did not —
17.1 / 19.7 / 26.5, a 55% spread ordering all three cleanly. So the second
registration measures steps.

CHOOSING A METRIC BECAUSE IT SEPARATED IS SELECTION. §8 says so in its first
sentence rather than presenting steps as if it had been the plan. Disclosed
selection re-run on fresh data is defensible; silent selection is not.

One wrinkle that shapes the design: fresh SEEDS refresh only MPPI. PD and
bang-bang are deterministic, so five more seeds give five identical copies, not
five samples — a seed-based replication would test one third of the claim while
looking like it tested all of it. So §8.1 defines held-out data two ways, both
by rule rather than by inspection: seeds [5..9] for MPPI, and eight fresh
scenarios generated as radius-0.8 antipodal pairs at 45-degree spacing, which
share no member with HARD_CASES (whose corners sit at radius 1.131).

Three claims pinned, one of which can plausibly fail: the ordering replicates;
MPPI exceeds PD by >=25% (observed 55%, conservative on purpose); and
bang-bang exceeds PD by >=5%, which is the tight pair at 15% observed.

§8.3 records WHY the binary metric saturated so nobody rediscovers it: mean
final distances of 0.033-0.049 against a 0.1 bar clear it by 2-3x, and against
an arena diagonal of 2.263 even a tolerance of 1.0 would still read 35/35. That
is the same fact that let the GOAL_TOLERANCE mutant survive — the bar was never
near the achieved values.

§9 records a third ruled-out hypothesis for the latent failure on
corner_pn_to_np, where WorldModel/README.md currently has two. All three arms
solve that direction over true dynamics, so the environment is tractable AND a
two-gain PD is adequate for it: the failure is not in the dynamics and not in
the planner, leaving the representation or the cost computed from it.

§9 also writes down why this is not the comparison that got retracted. The
retracted draft set ParticleNavigatorEnv numbers against ledger node 25, which
measured synthetic_1f + forward_eval.py. This compares NO performance numbers
across harnesses — it asserts only that one environment is solvable, citing the
latent results as the thing being explained rather than as a number to beat.
That distinction is the whole argument, so it is stated rather than left to be
reconstructed.

Committed before the run it governs, same as §3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…is the one flagged

Two of three registered claims replicate. The third does not, and it is the one
§8.2 named as the one that could.

                       original set   held-out   verdict
  C1 pd < bb < mppi          holds      holds     HOLDS
  C2 mppi over pd >=25%      54.5%      47.1%     HOLDS
  C3 bb over pd >= 5%        15.0%       3.0%     FAILS

The tight pair did not survive fresh data: 15.0% on the set the metric was
chosen from, 3.0% on data that did not inform the choice. That is exactly what
disclosing the selection and re-running was for — presented as the plan all
along, a 15% gap would have entered the record as a finding.

A SECOND REVERSAL, unregistered and so reported as an observation only:
bang-bang's mean final distance went from best of three (0.0326) on HARD_CASES
to WORST of three (0.0700) held-out. So §6's finding #2 is set-specific and does
not generalize; §6 now carries an inline correction saying the "best mean final
distance" half must not be cited. What survives is only the narrower claim —
bang-bang reaches the goal on every trial of both sets, so it is not nearly-free
to beat.

What the metric actually buys: steps separates the PLANNER from the CONTROLLERS
robustly (C1, C2, 47-55%) and does NOT reliably separate the two controllers
from each other (C3, 3.0%). So §6's "what would be needed to discriminate" is
answered only partially.

Held-out data is defined by rule, not inspection: eight radius-0.8 antipodal
pairs at 45-degree spacing (no HARD_CASES member — those corners are at radius
1.131), and seeds [5..9]. Both are needed, because seeds alone refresh only
MPPI: PD and bang-bang are deterministic and would return five identical copies.
A test asserts the disjointness rather than trusting the arithmetic.

One process note worth keeping: the first attempt to add this output silently
did nothing. The edit used a string replace with no count assertion, and cargo
fmt had already reflowed the lines it was matching — so the run printed the old
table and looked fine. Same failure shape as a mutation that never applied.

363 tests, 20/20 mutants, all gates clean. Both runs reproducible byte-for-byte.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mismatch means

Two things, both closing a class rather than an instance.

## The applied-check stops being mutation-specific

Three tool failures now, all in the family the tool exists to catch: `git diff
--quiet` blind to an untracked file; `copy2` preserving mtime so a build tested a
stale binary against correct source; and a string replace with no count
assertion, matching nothing because `cargo fmt` had reflowed the lines.

scripts/edit_guard.py holds the guard now: `replace_once` raises unless the
pattern matches EXACTLY once and the file actually changed, and `restore`
rewrites contents so the mtime advances — never copy2, never `git checkout --`.
mutation-campaign.py imports it instead of carrying its own copy.

It has a self-check that proves each guard FIRES: zero matches, two matches, a
no-op replacement, a failed edit leaving the file untouched, and a restore
advancing the mtime. A guard nobody has watched fail is a guess. It earned its
keep immediately — the edit wiring it into the harness was itself performed
through it.

## §10: what a Swift conformance failure means, decided before running it

The interesting outcome is the one most easily explained away after the fact, so
it is pinned now: if ParticleNavigatorEnv.swift does not reproduce env_v1.json,
that is a defect in the macOS app's environment, NOT a fixture problem and NOT a
Rust porting artifact. The fixture comes from env.py itself and the Rust matched
it bit-for-bit first try; a fixture defect would have had to break Rust too.

§10 also names where a mismatch is expected if there is one: env.py computes the
speed norm and divides in float64, while the Swift does the whole clamp in
Float. Mismatches confined to the 11 clamped steps are that difference and are a
real divergence after any saturating manoeuvre. Mismatches elsewhere are
something else. The NaN divergence cannot be what fires — the fixture's action
script has no non-finite value.

A failure is a review note in the NeuralCompose repo, not a gate on this one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…xactly

All 260 steps bit-for-bit, including the 11 clamped ones, so the float64-vs-Float
division that section 10 named as the likely mismatch site is not observable at
these values. The prediction was wrong in the harmless direction: it said where a
failure would be, and there was none.

Three independent implementations now agree exactly on step() — Python, Rust and
Swift. Nothing pinned it in any language before today.

The Swift test was verified capable of failing before it was trusted: scaling
restitution by 1.0001 turns it red in 6 places, restoring turns it green.
Committed on the NeuralCompose side as 5705082.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first write-up said the float64-vs-Float division "is not observable at these
values", which reads as: the types differ and the fixture happens not to reach
where they diverge. That would make 11 clamped steps out of 260 a thin sample —
the same shape as a mutant surviving because the fixture never visits the region
that would kill it.

Checked, and the stronger claim holds. The Swift IS Float throughout, so the type
difference is real. But both operands of the division are exactly-representable
binary32 values, and double rounding through a wider format is innocuous when the
intermediate carries at least 2p+2 bits: binary64 has 53, and 2*24+2 = 50. The
multiply is a single f32-rounded operation on both sides as well.

So the clamp path is safe by the arithmetic rather than by the sampling. Verified
alongside the reasoning: 0 disagreements in 5,000,000 sampled scale computations
and 0 in 1,607,683 full clamp evaluations, including denormal and near-overflow
speeds the arena cannot reach.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t, not surface it

The fixture is generated FROM env.py, so Python was the reference by
construction rather than by test. If env.py changed, the fixture would go stale
and BOTH the Rust and the Swift conformance suites would keep passing against an
obsolete trace with nothing detecting it. Three implementations agreeing makes
that LESS likely to be noticed, not more: the agreement is reassuring exactly
when it has stopped meaning anything.

The fixture now carries `source`: repo, path, fileSha256 and the commit — the
same convention as contracts/provenance/fixtures/evidence-class-names.json.
scripts/check-env-fixture-drift.sh re-reads env.py and compares, and a Rust test
asserts the pin is present and well formed so the script cannot be left checking
a field nobody writes.

The commit is scoped to env.py itself rather than the tree. The sibling checkout
is dirty in Scripts/ and docs/ but not in WorldModel/, and a whole-tree check
reported `commit: null` — technically honest and useless. The pin claims "env.py
as of this commit", so that is what is checked; fileSha256 remains the authority
either way.

Both failure paths verified rather than assumed: an absent checkout fails (it
does not skip), and a one-character change to env.py's restitution fails with
both digests printed and the regeneration command.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`band_power` returned 0.0 for five distinct refusals — sub-second window,
unusable rate, non-finite sample, inverted band, band above Nyquist — and
0.0 is also a value the arithmetic legitimately produces. "Refused" and
"measured no power" were the same value to every caller.

This had to be fixed BEFORE anything was built on top of it, not after. A
downstream gate written against `== 0.0` would work only until someone
correctly refactored this function to return Option, at which point short
windows would yield None, the comparison would stop matching, and fixing
the defect would introduce the fail-open the gate existed to prevent.
Nothing would have caught it.

Some(0.0) stays reachable and now means one specific thing: every
detrended sample was exactly zero, i.e. a constant window — a dead or
railed channel. That is deterministic, not a measure-zero coincidence,
and `a_constant_window_is_exactly_zero_not_merely_small` pins it.

`ElectrodeReport::mains_power` becomes Option for the same reason, found
in the same pass: it was 0.0 when no band was measurable, which reads as
"no line noise here" — the cleanest possible result — for the case where
nothing was looked at. It and `line_hz` are now Some and None together.

Contained: no uniffi export, one production caller. The compiler found a
second consumer in neuralcompose-headless that a grep for `band_power`
had missed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Thresholds and required channels for deciding whether a recorded session
may enter the corpus, with a sha256 beside them. Lands on its own and
lands FIRST: nothing reads this file yet, and no session has been
recorded against it. A predicate written after seeing sessions is
selection, and a pre-registration that arrives with the data it judges is
decorative.

The seal proves the file was not edited. It does not make the numbers
right, and the file says so in its own text: the 0-of-4 prior comes from
83-second block-design runs where the subject sat still on a timer, while
a hypnagogic dialectic session is multi-minute and involves speech, which
moves the jaw and the electrodes. Nothing here is calibrated for that.

So it also registers four NON-DISCRIMINATING outcomes — every session
passing with no session near a bar, every session failing on one
threshold, mains pickup deciding more than a third of verdicts, TP10
never reporting at all — and records that revision means a v2 file with
its own seal and date. This one is never edited to fit data.

requiredChannels is TP10 alone. That is the only channel with a
replicated validated alpha contrast; TP9 is a replicated null with
healthy contact, AF7 has failed three sessions running, AF8 is
amplitude-confounded. Requiring all four would fail every session for
reasons already known.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ssion record

EEG reached the turn log as three bare numbers per channel, no raw signal
was persisted on Linux at all, and ADR-004's ProvenanceEnvelope was
applied nowhere in the EEG path. This makes the app a corpus instrument:
EEG as recorded provenance, typed at capture time, so a later reader can
ask why a session was excluded instead of re-deriving it.

THE GATE. `eeg_reading_for_turn` returns Result<_, EegRefusal> with seven
stable kebab-case ids. The two band refusals are deliberately not merged:
`band-not-measurable` (band_power returned None — the instrument could not
look) and `band-exactly-zero` (it looked at a channel that is not alive).
Only the second is evidence about the electrode. The lag precondition is
asserted even though band_power's one-second floor subsumes it, because
that floor lives in another crate. This is the trap the Swift path has —
JEPATransition guards only isFinite and FeatureExtractor:117 reports zero
on a short window — and it is not ported here.

THREE TIERS, not two. Raw frames are `observed`; rms and mains power are
`derivedDeterministically` with an input ref; status and verdict are
`heuristicAnnotation` -> NeverIngestible. An earlier two-way split typed
rms as `observed`, which it is not: it is a named transform over samples.
The only observed thing is the raw frame, persisted here for the first
time, so an input reference finally exists. mainsPower sits on the derived
tier rather than beside the verdict — bundling a power figure with an
interpretation collapses two epistemic classes into the looser one.

The naming rule decided the annotation tier: `status` reads healthy /
saturated / dead, which are state words, and channel_health.rs says the
thresholds behind them are not physiologically validated. verify_turn_log
enforces the kinds, because convention would not survive a refactor.

THE INPUT REF names the window, not the file. A capture's payload digest
only exists at finish(), while these envelopes are written during the
session — so the ref digests the samples actually consumed, with the
capture as locator. That is computable at turn time and the stronger
claim: it makes the derivation reproducible rather than attributed.

ABSENCE CARRIES ITS REASON. channelHealth: null and
channelHealthAbsentReason are written as a pair, mutually exclusive by
construction and checked by the verifier. Before this, "no reading" and "a
dead electrode" produced identical records, which is what forced
eligibility to be a human verdict reconstructed afterwards.

Logging the electrode verdict does not overturn the decision that kept it
out. That reason was about the advice SENTENCE — a record should not tell
a future reader to reseat an electrode reseated months ago — and it still
holds. The record carries the verdict enum, never advice().

Turn line schema v2 -> v3.

RAW CAPTURE via the existing CaptureRecorder, appended as frames arrive:
measured 766 B/line at 32 lines/s = 24.5 KB/s, ~29 MB per 20-minute
session. Fine on disk, not fine buffered in memory. A failed write
publishes no manifest, because a manifest over a truncated payload reads
as corruption rather than as the write error it is.

SESSION SIDECAR, not CaptureManifest — that is a uniffi::Record consumed
by the iOS and Android shells and these are Linux host facts. Power,
governor and rfkill are `observed` from /sys; board id and preset are
`externalClaim` from flags, absent by default, because this binary cannot
see the board and defaulting to Muse S would be inventing a measurement.
There is deliberately no `address` field: it is not in sysfs, and the
first version had one that was null in every run.

THE BACKEND IS ASSERTED, not announced. Startup compared a hardcoded
constant in a format string — a claim about the code, not the run. It now
reads Embedder::backend_id(), which derives the answer from the outcome,
and refuses to start on anything but CPU. Exactly one process may hold a
Vulkan context and it is llama-server.

Also adds --verify-capture: verify_capture has existed since M4 with no
Linux caller, and writing 88 MB/hour of corpus nothing on this platform
can check is the same gap this work closes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Prosody::volume and Prosody::pre_utterance_delay were consumed by nothing.
Both speakers took them and dropped them silently, so two of the five
dimensions the Swift models simply did not happen on Linux.

pre_utterance_delay is the cadence one. loops.rs records the contemplative
voices carrying over a second of it, and on a fixed-voice engine that is
not decoration: two neural voices have no midpoint, so Prosody::blend can
only take the heavier one, and PACING is the channel tension stays audible
through. Discarding it removed the one prosodic dimension Kokoro had left.

Now: volume maps to espeak's -a, anchored so 1.0 is espeak's own default
of 100 rather than its maximum — the voices sit at 0.6-0.9 and the point
of the dimension is that a receding pole is quieter than a present one, so
an offset that doubled everything would flatten it. Kokoro has no volume
parameter, so speak.py gains KOKORO_VOLUME as a sample scale, clamped at
2.0 because above that a scale stops being volume and starts being
clipping. Verified: KOKORO_VOLUME=0.5 halves peak amplitude exactly
(0.667 -> 0.333).

pre_utterance_delay is honoured by both speakers, clamped at 5 s so a
stray blended value cannot hang a session. PrintSpeaker deliberately does
not pause — a dry run that sleeps while printing text buys nothing.

pitch_multiplier remains unmapped under Kokoro. That is a property of the
engine, which cannot pitch-shift, and is recorded as a real loss rather
than left to be discovered.

Tests cover the mapping AND that the flag reaches the argv — the bug here
was a sound mapping that nothing ever called, which a mapping-only test
would have passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Records the ten decisions behind the typed EEG path and the ceilings that
are not to be quietly crossed: the montage is one derivation and the
corpus must never be described as multichannel; the pad question is
unresolved and the mains check's PASS path is evidenced only by the
synthetic fixture; hand-engineered band features and a JEPA are
alternatives rather than complements, which is why the band energies here
are a gate whose values are discarded.

It also corrects a piece of reasoning rather than inheriting it. "An exact
0.0 over real EEG is a measure-zero event" is true of a computation over
signal and false of a function that short-circuits to 0.0 by design — it
became true only after band_power returned Option. And the reachable case
is not a coincidence at all: exact zero means a constant window, which is
deterministic. A reader relying on the original argument would draw the
wrong conclusion.

Non-claims: promotes no support-matrix row, and no EEG has been recorded
from a human under this code. Every measurement so far comes from
fixture-eeg-server, which carries no physiology.

The mutation campaign's three EEG mutants targeted pre-refactor code
shapes; replace_once raised on them rather than reporting false survivors,
which is the guard working. Updated, and twelve added for the new logic —
the zero rejection, the two reasons collapsing into one, the lag bound,
band_power reverting to its sentinel, each tier's assertion kind, the
verifier's kind check, and the window digest ignoring its samples.

One mutant survived the first run: deleting the NonFiniteRms guard changed
nothing, because the band gate now catches every non-finite INPUT first.
The guard was not redundant, only unreachable by the case its own test
used — it is still reachable by overflow, where samples are individually
finite but their squares are not. Added that test; without it the guard
would be deleted by anyone tidying up on the evidence that nothing depends
on it. 29/29 now killed, canary green.

Also fixes this script's header, which still said ADR-003. The provenance
ADR is 004; ADR-003 is embedding candidate selection on an unmerged
branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
--verify-capture and --eligibility both dispatch before the loop and exit,
but the usage text still described the precedence as "--verify-log, then
--world-model-demo" — the wording from before those flags existed. Both
were added in the same change that left this line alone.

Also states that none of the standalone modes needs llama-server, a model,
a microphone or a headband, which is the property that makes them useful
for inspecting a session afterwards and was not written down anywhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Running a session meant three terminals and an argument list to remember,
because every component deliberately refuses to start the others —
turn.sh:55, preflight(), the EEG connect all say "start it first". That
discipline is why no launcher existed. This inverts it in exactly one
place rather than spreading process management through the codebase.

A .desktop entry with Terminal=true (resolved by xdg-terminal-exec, so no
terminal is spawned and no ptyxis single-instance race) plus a supervisor
that preflights, brings the stack up, hands over the session, and reaps.

THE FINDING THIS EXISTS FOR. /usr/local/bin/llama-server is rpath-linked
to llama.cpp/build-cpu/bin. ggml backends are separate .so files loaded at
runtime from the library directory, and there is no libggml-vulkan.so in
build-cpu/bin — so that binary cannot use the GPU however it is invoked.
-ngl 99 is accepted, ignored, and one line of warning scrolls past while
inference runs on CPU. build-vulkan/bin/llama-server works: measured this
session at 6561 t/s pp32 on the 890M, uma:1, KHR_coopmat. The iGPU was
reachable the whole time and the wrong binary was on PATH. The launcher
prefers the working one and says which it chose.

THE BACKEND IS REPORTED AS ATTAINED, NEVER AS REQUESTED — the rule
Embedder::backend_id() already follows. Detection is deliberately NOT a
log parse: build 10188's llama-server prints nothing about its backend at
default verbosity (llama-bench prints the ggml_vulkan device line, the
server does not), and an early version of this script grepped for a string
that is never written and reported CPU for a working Vulkan session. It
now uses `fuser -v /dev/dri/renderD128` — the repo's own check from
spoken-loop/README.md:63, automated — as the positive signal, "no usable
GPU found" as the negative one, and reports UNPROVEN when neither fires.

ADOPTION. A healthy server already on the port is used and left running;
only what we started is killed. Clicking the icon never kills a server you
were using, and the launcher says it left it alone.

STUB BUILDS ARE REFUSED UP FRONT. cargo build/test without LLAMA_CPP_DIR
produces a binary that looks identical, starts fine, brings the whole
stack up and dies at the embedder minutes later — and the documented
`cargo test --workspace` produces exactly that. A stub links no libllama,
so ldd separates them with no guessing. Skipped for mirror mode, which
needs no embedder and runs fine on a stub.

Everything is checked before anything is started, so a missing model never
leaves an orphan. Teardown is a trap on EXIT INT TERM HUP and is
idempotent. The fixture refuses to start if 8788 is already held, rather
than running a session against whatever is on that port
(linux-headless-runtime.md:189-193).

Promotes no support-matrix row: ReleaseSupported means signed packaging
with install, upgrade, removal and acceptance gates, and this is a shell
script. tools/README.md's "None is a product surface" gets the carve-out
stated rather than quietly broken.

Not a GUI, deliberately. A native toolkit would put the app process on the
render node — a second Vulkan context on the same iGPU doing inference,
which is the ceiling the code asserts at startup — and would be the fourth
UI surface neuralcompose-headless/src/main.rs:1-5 refuses by name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
--mic printed "speak now, then press Enter" and blocked on stdin. For a
hypnagogic session, which is the point of this application and which you
take lying down with your eyes closed, pressing Enter every turn defeats
the exercise.

The mic now stays open and cuts utterances on silence. --push-to-talk
keeps the old behaviour for when a keypress is genuinely wanted, because a
keypress is deterministic and a voice gate is not.

PORTED, NOT INVENTED. The constants and the shape come from
tools/spoken-loop/converse.py, which solved this on this machine already,
and its notes are carried over because each is a measured failure rather
than a preference:

  * The mic emits frequent full-scale transients — measured over 3 s,
    median 1801, max 32768. A 1 s MEDIAN caught enough spikes to report a
    floor of 6262, putting the gate at 57% of full scale and out of reach
    of ordinary speech: the loop listened and never answered. Hence a low
    percentile, with GATE_MAX as the backstop when even that is fooled.
  * FLOOR_MIN is the other direction: a silent room calibrates to nearly
    nothing and every rustle becomes an utterance.
  * Onset needs consecutive frames over the gate. One is not enough here —
    a single transient would open an utterance made entirely of a click.

The decision half is pure and in the lib with those tests; the microphone,
the subprocess and the buffer are in the binary. arecord -t raw rather
than pw-record, for converse.py's reason: pw-record writes a WAV header
and a level-detecting loop needs a bare sample stream. Same audio path,
through PipeWire's ALSA layer.

rms accumulates in f64 because a frame of near-full-scale i16 squares sums
past i32 in 1600 samples, and a wrapped total would read as near-silence —
the loudest possible input reported as the quietest. Pinned by a test.

Calibrating on no audio returns None rather than a default: a gate nobody
chose is how a loop ends up listening forever.

Verified end to end against the real microphone — calibrated to gate 240,
picked up speech played through the speakers, cut on silence, transcribed,
replied. No keypress.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A dialectical turn generates two candidates, scores them, and speaks ONE.
So a listener hears a reply, and the disagreement the whole engine exists
to stage happens silently — visible only afterwards in the turn log. That
is not a dialectic to anyone in the room.

With --voice-both each pole says its piece in its OWN fixed voice, and
only then does the turn resolve.

Each position is spoken unblended. The blend below it carries how close
the competition was, which is a property of the resolution; a position is
just itself, and blending would smear the one signal fixed voices exist to
carry — a listener tracks which position is speaking BY VOICE.

Deliberately after the competition resolves, so
`nothing_is_spoken_before_the_competition_resolves` still holds: the turn
is decided before anything is heard, and what changes is how much of the
decision is audible.

A plain win is not repeated — it was already said as one of the positions,
and saying it again would end every turn in an echo. A synthesis IS spoken
afterwards, because its text is neither candidate's and has not been heard.

Off by default. It changes what a turn sounds like, and the Swift
conformance story is about what a turn decides.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s Ctrl-C

A hypnagogic session has no natural turn count — you stop when you drift
off — and a fixed one either cuts it short or leaves the loop talking to
an empty room. `--turns 0` runs open-ended.

ENDED BY VOICE, because the whole point is not touching anything. "stop",
"goodbye", "that's all" and a few more, matched against the WHOLE
utterance and never searched for inside it: "I want to stop
procrastinating" contains "stop" and is emphatically not a request to end
the session. Everything a hypnagogic session is for happens in long
rambling sentences, which is exactly where a substring match does the most
damage. Whisper's capitalisation and punctuation are normalised away
first, so a stop phrase the user actually said cannot fail to register
because of a full stop. The false-positive cases are their own test.

The turn that ends the session is still logged — it was a real turn.

THE LOG IS NOW WRITTEN TURN BY TURN. It used to accumulate in memory and
be written after the loop, which is fine for `--turns 8` and loses
everything for a session that runs an hour and ends the ordinary way, with
Ctrl-C. A payload with no manifest is the `.partial` case the capture side
already defines and is recoverable; a payload that never existed is not.
Verified: an interrupted session kept all four of its turns.

The manifest still cannot precede its payload — more strongly than before,
since the payload is now written first by construction rather than by call
order.

LAUNCHER DEFAULTS follow: hands-free, open-ended, both poles voiced. That
is what the app is for, and it is what clicking the icon should give you.
Preflight checks arecord (or pw-record under PUSH_TO_TALK — different
tools, so each is checked for what it uses) and the whisper model, before
anything starts.

Recorded in the launcher README, because it is a real limit: a live
microphone is not a deterministic fixture. In a noisy room the gate
triggers on ambient sound and whisper transcribes something from it —
observed during verification, where playback in the room produced turns
about "(upbeat music)". The gate is the only guard and MIC_GATE sets it by
hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A reflective turn makes three generate calls — both poles plus the Witness
— so generation cost is tripled before you hear anything, and --voice-both
doubles the speech on top. On the 8B that compounded to about 20 s between
you stopping and the loop answering, which changes what the app IS. A
hypnagogic loop you wait 20 s for is a different thing from one that
answers in 10, whatever the prose quality.

Measured on the 890M via Vulkan, 60 max_tokens, thinking off:

  Qwen3-0.6B Q8   load 2.6s   0.41 s/generate   turn ~7 s
  Qwen3-1.7B Q8   load 6.1s   1.39 s/generate   turn ~10 s   <- new default
  Qwen3-8B  Q6    load 6.5s   4.35 s/generate   turn ~19 s

Both poles stay distinguishable at every size, which is the property that
actually had to survive — a dialectic whose halves blur into each other is
not faster, it is broken. Checked on the same prompt at each size and
recorded in the README so the claim is inspectable rather than asserted.

1.7B roughly halves the turn against the 8B and still writes in sentences.
0.6B is thinner but coherent and the poles still differ, so it is a real
option when immediacy matters more than prose.

The README also records the other three levers in order of what they buy —
MODE=focused drops the Witness for a third off generation without touching
either pole; VOICE_BOTH=0 halves the TTS at the cost of the thing that
makes the dialectic audible; TTS=espeak is near-instant and sounds it —
and the exact fetch command, including that Qwen publishes only Q8_0 for
1.7B so a Q4_K_M URL 404s.

Verified end to end: model load plus two full reflective turns in 10 s
wall clock, log verified clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… was wrong

VOICE_BOTH shipped as 1, so every session spoke both poles in two different
voices without anyone asking for that per-session. The capability was
requested; making it the default was my judgement, and it was the wrong
one for this application.

This is a hypnagogic loop — you use it drifting toward sleep. Two voices
taking opposing positions at you is ACTIVATING: it invites you to follow
an argument, which is close to the opposite of sleep onset. The voices are
also deliberately dissimilar, different speakers and different genders,
with a test asserting no two poles share one, precisely so a listener can
track which position is speaking. Good for legibility, bad for drifting
off. The Swift competes internally and speaks once, and that shape had a
reason I overrode without weighing it.

"The dialectic should be audible" is a debugging argument, not a sleep
argument.

Nothing is lost by the default: the competition still runs every turn,
both poles are generated and scored, and the turn log records both. What
the default decides is only how much of it you hear. VOICE_BOTH=1 when you
want the argument rather than its conclusion.

Verified: default speaks am_michael alone; VOICE_BOTH=1 speaks am_michael
then bf_emma.

The turn-cost table was restated for one utterance rather than two, since
it had silently assumed the old default — ~8 s per turn on the 1.7B now,
~10 s with both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…says so

Port of Sources/BCICloudBridge/ClaudeCLIGenerator.swift: drive a Claude
model through the local `claude` CLI in headless mode, on the user's own
subscription. No API key, no HTTP client in this binary — the process
boundary is the credential boundary, which is why the Swift chose it.

Opt-in at every layer and never a default: --generator claude, or
GENERATOR=claude in the launcher. The local path is byte-for-byte
unchanged in behaviour.

One deliberate divergence from the Swift: --tools "". `claude -p` is
Claude Code, and without that flag a text-generation seam gets a shell in
whatever directory it was spawned in. The flag did not exist when the
Swift was written. The subprocess also runs in the session temp dir, so
no CLAUDE.md beside the session can be folded into a hypnagogic prompt.
Asserted by its own test, because an argv edit could drop it and every
other test would still pass.

The turn log could not tell a local session from a cloud one. Now every
line carries a textGenerator ResourceRef in provenance.inputs, with the
id in `locator` — and the same id is folded into parametersDigest. Both,
not either: the digest makes them different METHODS rather than the same
method with a note, and the readable locator is because eligibility here
is a query over recorded annotations (ADR-005 §9), and a query cannot
recompute a hash it has no candidate for. No schema bump; `inputs`
already meant exactly this and was empty only because the candidate
embeddings are deliberately not logged.

Ceilings, stated in ADR-006 rather than left to be discovered:

  * Per-role temperature is GONE on this path. `claude -p` exposes
    neither temperature nor max_tokens, so the 0.45 coherence pole and
    the 1.0 displacement pole differ ONLY by their system prompts. The
    competition still runs and both candidates are still recorded; they
    are just sampled identically.
  * Slower, not faster: ~5.5 s per generate call against the local
    1.7B's 1.39 s, three calls to a reflective turn.
  * Real money, unpredictably: two consecutive IDENTICAL calls measured
    $0.0106 and $0.2383. The CLI sends a ~34-40k-token prefix of its own
    and it is not stable between invocations (34,061 then 39,654), so a
    cache hit is luck rather than something you can arrange.
  * Promotes no support-matrix row. A session generated off-device
    exercises no llama.cpp path at all, so it is RuntimeSmokeValidated
    evidence for nothing.

Verified on this machine: the refusal fires with the CLI absent from
PATH, before anything starts; a mirror turn on claude-sonnet-5 ran end
to end; a logged reflective session (claude-haiku-4-5, to keep the check
cheap) verified clean with every line naming claude-cli:<model>; the
local path re-run alongside still names llama-server, because a check
that reported the cloud generator in both cases would have proved
nothing. The digest test asserts the two DIFFER, not merely that each is
stable.

Mutation campaign: six new mutants over this boundary, all killed —
including tools re-enabled, --system-prompt downgraded to
--append-system-prompt, an error envelope read as a reply, and the
generator sealed but not readable. Also repaired one PRE-EXISTING stale
mutant: `gate accepts an exactly-zero band` was written against
`Some(p) if p == 0.0 =>`, which eeg.rs has not said for some time, so it
matched nothing and reported "stale" instead of testing the gate. A
stale pattern is a check that is not running. 35/35 now, canary green.

Not verified: this generator together with --speak, --mic or an EEG
source. Those seams are unchanged by it, but no run has exercised them
together.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VcdghEvpF1rEgh6RCcaVvF
tools/eeg-viz/plot_quality.py renders a session's recorded artifacts as
one figure: raw samples from <id>.eeg.jsonl on the wire-time axis, and
the turn log's channelHealth records (rms, mains power, status, verdict)
as markers at each window's lastSourceTimestamp. It computes nothing --
no spectra, no second opinion -- so a disagreement between the plot and
a logged verdict is a detectable bug in one of them. Self-check included;
verified against session-1786697740, where the recorded RMS values
corroborate the drawn traces (TP9 +/-20 uV vs rms 14.1 = 20/sqrt(2)).

tools/rocm-probes/torch_gemm_probe.py is the standing ROCm-torch probe:
JACC's 249/249 on gfx1150 is evidence AGAINST torch there (JACC.BLAS
bypasses the rocBLAS torch requires; Ubuntu ships no gfx1150 Tensile
targets), with HSA_OVERRIDE_GFX_VERSION=11.0.0 onto the shipped gfx1100
targets as the live hypothesis. Exit codes split the failure modes
(2 no device / 3 elementwise wrong / 4 gemm wrong, elementwise fine),
asserted against a CPU reference because the override's dangerous
outcome is silently wrong numbers. Placed in tools/, not the .evidence
captures dir; a real run's output earns a manifest line there later.
Smoke-tested: exits 2 under a CPU-only torch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pyvista 3D sibling of plot_quality.py, third axis frequency rather than
channel (channel is categorical; 2x2 subplots in frozen wire order).
This view computes -- scipy spectrograms over the recorded samples --
and its arbiter is the recorded verdict: turn-log channelHealth markers
overlay each surface, a mains verdict draws its recorded mainsLineHz
line, and a MainsPickup with no visible ridge is a detectable bug. The
figure labels computed vs recorded and stamps both input digests;
contract 256 Hz is printed beside the rate measured from recorded
timestamps. Verified on session-1786697740: one clean ridge per channel
at 8/10/12/14 Hz, all markers green ok. Review tool; live fitting is a
different program.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ion workspace

workspace3d.py gains --export (spectrogram grid + recorded verdicts +
session id + time range + source digests, one neutral npz) and --view,
which renders such an artifact and computes nothing. The direct-render
CLI is unchanged.

tools/workspace/workspace.py is the one command:
uv run tools/workspace/workspace.py <session-id> locates or triggers the
three repo exports (spectrogram here, latents in eeg-lejepa, provenance
neighbourhood in neural-memory-server; the eeg.jsonl -> contract CSV
step is mechanical transcription, digest-recorded in manifest.json) and
renders three deliberately unlinked panels -- the only shared axis is
session time. It depends on pyvista+numpy alone and computes nothing.

The safeguard that matters: it stamps all three artifact digests and
REFUSES to render when session ids or time ranges disagree -- a
coherent picture assembled across sessions is the most convincing
failure available. Proven live: WRONG-SESSION latents -> exit 1 naming
the mismatch, no PNG. An impossible check (no wire-to-UTC anchor
without a capture manifest) renders as an explicit 'unverified:'
banner, never silently passed. Epistemics on-figure: the latent panel
is derived from the EEG panel, agreement is arithmetic, not evidence;
nothing records causation between a spectral event and a banked claim;
navigation aid, no finding licensed by what it shows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… vs refuse in the viewer

The session banner gains one closing line, in three states so absence
stays informative: a finished capture prints the exact
tools/workspace/workspace.py command that opens it; EEG attached
without a capture says so (splitting 'capture failed to finish' from
'run without --log' -- absent and failed must not read the same); no
EEG prints nothing, so the missing line is itself the answer. Print,
not spawn: spawning would put a GL surface, uv, and three repo
checkouts inside the exit path of a binary whose support-matrix rows
are about llama.cpp backends, and would let a successful session's
exit status depend on a display tool.

The viewer now separates the two failures it used to lump: ABSENCE
DEGRADES (missing capture -> the spectrogram and latent panels render
as labeled holes with the reason; the graph panel still draws, only
present digests are stamped), while MISMATCH REFUSES (session ids or
time ranges disagreeing among the artifacts that DO exist stop
everything, because that is the case that produces a coherent wrong
picture). Absent is not inconsistent, and the viewer is the last place
that distinction should collapse. Also: SESSION_ID may be the literal
'latest' (newest capture, else newest turn log; resolved id printed) --
a mistyped id being the most likely way to ask for one session's EEG
against another's turns. Self-check asserts all three behaviors;
degrade path verified live on a real turns-only session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… the view

Found by running the printed command against a real one-turn fixture
session: ~304 captured samples are enough for a spectrogram but under
one 1024-sample latent window, and the failed latent export exited the
whole viewer. That failure is an ABSENCE for its panel -- the artifact
cannot be produced, and the reason belongs on the figure -- not a
mismatch, which remains the only thing that refuses. run() now returns
the exporter's last stderr line as the reason and ensure_artifacts
marks the panel absent with it; verified live: the session renders
spectrogram + honest-empty graph with the latents hole reading
'export failed: recording has 304 samples, need >= 1024 for one window'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…idge stream

tools/eeg-viz/live.py is a read-only second subscriber to the muse
bridge's broadcast (gathered with return_exceptions=True over a client
snapshot, so viewers coming and going cannot disturb a session's feed).

The scene is a 3D electrode network: four spheres at head positions,
pulsing with live RMS -- except saturation, which is a DISTINCT state
(red SAT, |sample| > 800 uV) because railing must never read as 'large
but fine', and dead (grey, RMS < 1 uV); thresholds mirror
nc_eeg_lejepa quality.py, reused not invented. Edges carry 1-s Pearson
correlation AFTER common-average-referencing: all four Muse channels
share the FPz reference, so raw broadband correlation would render
every tube warm and call it connectivity; even post-CAR, AF7-AF8 >
TP9-TP10 is geometry, not finding (stated on-figure). Self-check
discriminates both directions: shared-sine pair r=0.98 AND independent
pair r=-0.46 (the -1/(n-1) CAR bias), so an always-1 stub fails.

--status-json is the agent-facing tap: ~1.5 s collection, one JSON
object (per-channel rms/sat/dead, CAR'd pairwise correlation,
thresholds), with its caveats in-band: an instantaneous window may
disagree with turn-log verdicts computed over other windows at other
times -- both can be right. BOUNDARY stated precisely in the
docstring: eeg.rs enforces no-EEG-in-the-dialectic for the paths it
covers; these taps are outside them, and on the agent side the rule is
maintained by convention. Polling agents are told to rate-limit.

First live run caught TP9+AF7 railing at +/-1000 uV mid-session,
matching the core's own recorded saturated/mains-pickup verdicts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…launcher waits on the port

Two halves of the same race, found across five live launch attempts:

The bridge's single BrainFlow scan gives up after ~20s, and the process
exits with it -- so 'press the button after launching' had a window
narrower than a person fumbling for a headband. bridge.py now retries
the full scan (MUSE_ATTEMPTS, default 4), printing between attempts
that pressing the button is what the wait is for.

The launcher's sleep-2-then-check was tuned for a fixture that binds
immediately; the muse bridge opens :8788 only AFTER BrainFlow connects.
Replace the fixed sleep with a wait on the port itself (~100s, matched
to the retry budget), dying with the bridge's log and a plain question
-- powered on, charged, unpaired? -- when the window closes empty.

Also learned and folded in: BlueZ device listings are CACHE, not live
advertisement (a dozing Muse still 'appears'); BrainFlow name-discovery
missed a band that bluetoothctl's live scan saw, so MUSE_MAC pass-through
is the reliable path on Linux. Session 6 connected on the first scan
with the band awake -- the race, not the radio, was the failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ke the median

The muse bridge stamps a drained batch with one clock read, so
intra-batch timestamp diffs are microseconds and the median of small
diffs lands on the burst cluster: a real 256 Hz capture estimated at
292,654 Hz, which collapsed the exported spectrogram to a single time
column and blew the workspace axes into the hundreds of millions. The
fixture server synthesizes per-sample stamps, which is why no fixture
session ever showed it; lejepa's compute_actual_sample_rate records
the inverse trap from 2026-08-07 (median over ALL deltas including
gaps).

actual_rate_hz is now samples-over-elapsed-span with gaps > 1 s
excluded -- indifferent to how timestamps cluster within the span --
and the self-check gains a burst-pattern case: batch-collapsed stamps
must still estimate the true rate within 5%. Both real Muse session
workspaces rebuilt correctly under the fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pv.set_scale stretches actors, and the grid axes then label SCALED
coordinates as if they were seconds: a 5.7 s synthetic capture read as
604 s, and in hindsight session-1786883941's axis read 615 where the
true range was 874 s. The underlying artifacts and digests were always
correct; only the labels lied -- which for a navigation aid is the
worst kind of wrong.

The x-stretch now lives in the grid data itself, the axis title names
the display factor, and the panel title carries the true wire-time
range. Verdict markers and channel labels move to the same scaled
coordinates. Found by running a synthetic session through the full
pipeline and reading the render against the known capture length.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant