World-model demo: a null result, a retracted finding, and step() pinned in three languages - #40
Open
aurascoper wants to merge 30 commits into
Open
World-model demo: a null result, a retracted finding, and step() pinned in three languages#40aurascoper wants to merge 30 commits into
aurascoper wants to merge 30 commits into
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
MPPI is the worst of the three on both metrics. The threshold and the reading rule were committed at
05f688c, beforeworldmodel.rsexisted.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.pd < bang-bang < mppiC3 is the claim the registration named as the one that could fail. Large effect shrank; small effect evaporated.
A finding was retracted
§6finding #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 againstWorldModel/env.pyin 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.pyitself is pinned by digest, because three conformant implementations make reference drift less likely to be noticed.Non-claims
corner_pn_to_npbeing 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_DIRunset.scripts/edit_guard.pynow carries the applied-check that three separate tool failures needed.--micremains unexercised — the only path with neither fixture nor spy coverage, where a green suite proves nothing.🤖 Generated with Claude Code