Roughly 20k lines were deleted from this repo in the last week and much of the
corpus still described the pre-deletion world in the present tense. Executes
the §8 table: archive the retired, reclassify the executed, prune the stale.
aimmersive-clone-spec -> _archive/ with a deprecated-by note
feedback-modes-port-spec -> plans/, kind: plan, status: executed
manifold-parity-features -> plans/, kind: plan, status: active
playground-2.0-rewrite -> status: superseded
engine-architecture 434 -> ~120 lines; seam + spine kept, rewritten
present-tense against the shipped engine/
MAIN.md six contradicted claims fixed; registry resynced
vcv-module.md pruned to the current 8->16 contract and made the
single .nisps format spec
vcv/NISPS-FORMAT.md DELETED — documented a v1 format that no longer loads
vcv/README.md, BUILDING.md rewritten to the real contract, menu, OSC table
inputs/backends/dock trio grounding sections marked historical, dead cites fixed
Two rows of the §8 table were themselves wrong, corrected here: the deleted
full-state sync lives in backends-spec.md §6.3, not vcv-module.md (which has no
§6.3), and codegen/README.md was already a MAP pointer with no port-solidjs
trigger left to remove.
Beyond the table — found by sweeping every backticked path in the changed docs
against `git ls-files`, which is how these should have been caught before:
manifold/ONBOARDING.md documented a UI that Phase 1 deleted, as if current:
SplitStage, ReadoutStrip, InputMini, BackendAdvanced, AltitudeNav, and a
shot.spec.ts that does not exist. The whole stage table was keyed on a `focus`
axis that no longer exists — selection is now sandwich > particles >
composite. This matters more than the rest: CLAUDE.md tells every agent to
read ONBOARDING.md first for Manifold work, so it was actively teaching a
fiction. Rewritten against ConsoleApp.tsx.
MAP.md claimed the input layer reduces axes to the engine arity with an
"even/odd blend". input-layer.ts says the opposite in its own header: one
dedicated slot per axis, 1:1, into a 32-input over-provisioned head, and
mean-blending was removed deliberately because it diluted every source.
AGENT-REFERENCE.md still promised TS emission "returns at P5" (landed),
per-mode dims "become schema-real at P5" (landed at P5.3), and pointed at
nisps::FixedBuffer (deleted).
Doc-right/code-suspect, filed rather than fixed: VCV computes derivedMean/Std/
Delta and cachedNovelty behind a live context-menu toggle that nothing reads;
vcv/plugin.json points at the MusicallyEmbodiedML org rather than this repo's
origin; and the module defaults to UDP 7001+id%64 while bridge.ts defaults to
9000, so out of the box they do not meet.
Firmware-build docs are deliberately untouched — the PlatformIO migration
lands next and rewrites all of them.
27 KiB
RL / Feedback Learning-Engine Design — Manifold
Design sign-off. Dated 2026-06-27. Supersedes the AVOID-out-of-scope decision in docs/specs/plans/feedback-modes-port-spec.md §2.5/§7 (reclassified kind: plan, status: executed 2026-07-21). Author: lead architect, synthesising a 4-candidate / 4-judge design panel against verified source.
0. Decision summary
| Setting | Default | Also selectable |
|---|---|---|
FEEDBACK_MODE |
Explore and place (Mode 2, positive-only) | Geometric dislike (Mode 1) |
SOLO_MODE |
MaskGradients (final-layer column freeze) | ZeroLoss, DontCareExample |
Winner of the panel: Sift (musician-UX judge topPick, skeptic topPick; 2nd on feasibility and ML). It is the only candidate that does UX design rather than only porting design, and it satisfies the "Mode 2 is NEVER a dislike" requirement at the level of the performer's mental model.
Grafted onto Sift's felt-loop spine:
- Minimal-Divergence — the only candidate whose tree-read is correct (verified below): the feedback C API,
MLHandle.feedback, the0xFEEDBACC0DEsalt,CMakeLists.txt:59registration, and parity Stage 5 ALL already exist. Its append-onlyFeedbackActionenum discipline and controller-returns-action / caller-owns-training boundary contract are kept verbatim. - Anchored-Manifold — the only candidate with correct provenance (the upstream tip is
0a541cc "highlighting", verified; not thee291192/abe93ecthe others cite). Its one-ReplayStore-three-features compression, its bit-identical-frozen-column solo ctest, and its explicit TrainBatch-vs-SGD divergence flag are adopted. - Anchor-First — keep replay/geometric logic OUT of the MLP kernel (only minimal backprop hooks go in), and port the
posMemCount==0cold-start fallback faithfully so dislikes-before-any-like don't destabilise the net.
1. Verified ground truth (read this before building)
All claims below were read from source on 2026-06-27. Where the four candidates disagreed, the verified fact decides.
1.1 The scaffold already exists (Minimal-Divergence was right)
nisps/wasm/bindings.cpp:95-116—struct MLHandlealready holdsnisps::ml::FeedbackController<DefaultMLP> feedback;+feedback_static_scratch{}, constructedfeedback(seed ^ 0xFEEDBACC0DEull).nisps/wasm/bindings.cpp:439-535— the 9nisps_ml_feedback_*C functions (set_mode/get_mode/exploring/learning_paused/set_focus/down/up/drag/static_output) are already wired.nisps/CMakeLists.txt:58-59—test_mlp_rl.cppandtest_mlp_feedback.cppare already registered innisps_core_tests.tests/cpp/parity_check.cpp:86—kVersion = 2ualready;:198-205— Stage 5 (feedback) already exists seedingFeedbackController<ParityMLP> fb(kSeed ^ kFeedbackSalt).
Candidates 2/3/4 proposed adding
MLHandle.feedback, registeringtest_mlp_feedback.cpp, and creating Stage 5. That is phantom work — they already exist. This design edits the existingon_downcases and extends the existing enum/API/parity-stage; it does not re-scaffold them.
1.2 The current feedback.hpp (untracked, verified nisps/ml/feedback.hpp:1-214)
enum FeedbackMode {Avoid=0, RandomiseOutputs=1, RandomiseMlp=2}(:47-51);enum FeedbackAction {None=0,…Restore=7}(:56-65).on_downAvoid case (:103-105) callsmlp.move_weights(speed, spread, pin_mask)— the undirected-diffusion placeholder the requirement now forbids for Mode 1.RandomiseMlpon_down(:113-119): 1st press enters (snapshot_+draw_weights), 2nd press cancels. Snapshot/restore is a byte-exactget_weights/set_weightsround-trip (:186-193).- The controller has its own per-instance
Rng rng_(:210), seeded independently.
1.3 The latest upstream algorithm (Anchored-Manifold's provenance was right)
The actual upstream tip is 0a541cc "highlighting" at /home/w1n5t0n/src/MEMLNaut-NISPS-upstream/src/memllib/examples/InterfaceRL.{hpp,cpp}. Constants verified:
InterfaceRL.hpp:293kGeometricPushScale = 0.5f;:294kMaxDislikeMultiplier = 16;:296kCentroidK = 4.InterfaceRL.cpp:42-66_perform_dislike_action(): nearby negative within Euclidean0.05fofcontrolInput→reward = max(reward-1, -16); elsestoreExperience(-1, controlInput, action); thendislikeMultiplier_ = min(*2, 16).InterfaceRL.cpp:602-627k-NN centroid: average thekCentroidK=4positive memories nearestcontrolInput→meanPositiveAction.InterfaceRL.cpp:664proportional decayreward += 0.0025f * max(|reward|, 1.0f); expired items removed;dislikeMultiplier_halved per expiry (:752-760).InterfaceRL.cpp:713pushStep = clamp(|avgRewardNeg|, 0.25f, 1.0f) * kGeometricPushScale.InterfaceRL.cpp:721-735per negative:dir[j] = neg_action[j] - meanPositiveAction[j];len = ||dir||;useRandom = (len <= 1e-4f);effectivePushStep = pushStep / (1 + len); for eachjgated byactiveDims_:d = useRandom ? (rand()&0xFF/127.5 - 1) : dir[j]/len;target[j] = clamp(neg_action[j] + d*effectivePushStep, 0, 1).InterfaceRL.cpp:742-743negLRRatio = 0.5f - 0.4f*negFraction; train geometric targets atlr*negLRRatio.InterfaceRL.cpp:746cold-start fallback whenposMemCount==0:TrainBatch(tsNegative, lr * 0.1f * avgRewardNeg, …)— negative-LR training, no geometric push.
1.4 The deepest parity hazard (Anchored-Manifold / Anchor-First flagged it)
Upstream optimise() trains with TrainBatch (shuffled, batchSize≈8, separate positive/geometric batches at two distinct learning rates). The nisps MLP::train() (mlp.hpp:256-300) is unshuffled per-sample SGD over the dataset insertion order (its own comment: "TrainBatch shuffles, but we're not implementing batch yet"), has no reward field and no train-toward-arbitrary-targets entry. Reproducing the two-batch / dynamic-negLRRatio dynamics on a non-batch trainer is the real browser != firmware line. We accept behavioural (not bitwise) parity with firmware here, and pin native == WASM at 1e-5. Recorded in ALIGNMENT.md.
1.5 The injection points the masking/solo mechanism needs
mlp.hpp:281— the per-samplestd::array<float,NOut> deriv{}is the exact place to zero the output-error of non-soloed / don't-care dims beforebackprop_(:291). One branch per output dim.rl.hpp:74-99—move_weights_layeralready skips the final-layer weight column + bias whenis_final_layer && output_pin_mask[node] != 0. Solo's column-freeze is the bitwise inverse of this mask. Note the existing rule (:88-96): the RNG is advanced even on skipped weights so the stream is pin-independent — keep that invariant.mode-runtime.ts:548-565thumbsDowncurrently builds a pin mask and callsmoveWeights— no example stored, no training.thumbsUp(:523-546) computes the(features, labels)and callsaddExample+trainOnCurrent.
2. FEEDBACK_MODE = two operator-switchable modes
The selector is exploration-store.ts state feedbackMode: 'explore-and-place' | 'geometric-dislike', surfaced in the learning-behaviour dock panel (§7). British spelling in all copy ("randomise", "auditioning", "centre", "behaviour").
2.1 Mode 1 — "Geometric dislike" (negative example + geometric push-away)
Felt loop: you have a region you like; thumbs-down carves the current sound away from the liked centroid. Directed repulsion, audibly directional — the fix for today's inaudible undirected diffusion (findings-feedback-behaviour.md §5).
Algorithm — port InterfaceRL.cpp (0a541cc) into nisps core, keeping parity. Because nisps has no async optimise() driver, the press-time half (_perform_dislike_action) and the optimise half collapse into one synchronous core call.
On thumbs-down at controlInput x_neg with heard action a_neg:
replay.deepen_or_store_negative(x_neg, a_neg, radius=0.05f)— nearby negative →reward = max(reward-1, -16.f); else storereward=-1.dislikeMultiplier_ = min(*2, 16). (InterfaceRL.cpp:42-66)- If
replay.positive_count() == 0→ cold-start fallback:train_targets(x_neg → a_neg, lr * 0.1f * avgRewardNeg, …)(InterfaceRL.cpp:746). Surface a one-time UI prompt "like a few sounds first to teach the system what to move away from" (closes the cold-start gap all four judges flagged). - Else:
replay.knn_positive_centroid(x_neg, k=4, &mean)(:602-627);pushStep = clamp(|avgRewardNeg|, 0.25f, 1.0f) * 0.5f; per active/soloed dimj:dir[j]=a_neg[j]-mean[j],len=||dir||,effectivePushStep=pushStep/(1+len),d = (len<=1e-4f) ? rng_.next_float_signed() : dir[j]/len,target[j]=clamp(a_neg[j]+d*effectivePushStep, 0, 1); non-active dims keepa_neg[j]. train_targets(x_neg → target, lr * negLRRatio, …)withnegLRRatio = 0.5f - 0.4f*negFraction.replay.decay_negatives()each call:reward += 0.0025f*max(|reward|,1), evictreward > -0.01f.
Determinism carve-out: the upstream useRandom branch uses libc rand()&0xFF; we substitute rng_.next_float_signed() (the controller's own per-instance nisps::Rng). Both native and WASM run the same Rng from the same seed → 1e-5 parity holds. This branch only fires when a disliked action sits exactly on the centroid. Record as the single deliberate firmware divergence in ALIGNMENT.md.
On thumbs-up (Mode 1): replay.store(+1, x, a) so the centroid sees positives; caller also runs the existing addExample+train like today.
Files touched: edit feedback.hpp on_down Avoid case (:103-105) → route to the geometric path; add replay_ member; on_up LikeStore → replay.store(+1,…). New nisps/ml/replay.hpp + nisps/ml/geo_push.hpp (§4). move_weights stays reachable as a legacy Diffuse sub-mode for A/B comparison (cheap insurance, lets the A/B-compare feature contrast geometric vs diffusion — graft from Minimal-Divergence).
2.2 Mode 2 — "Explore and place" (positive-only; NEVER a dislike) — DEFAULT
Felt loop (Sift's north star): "randomise, explore, oh I like that → put it in that corner; meh, randomise…". The performer never reasons about "away from what" — they collect sounds they like. Place-by-corner is the highest audible-effect-per-action gesture in the system: one drop = one heard anchor the real model then interpolates.
Built on the existing RandomiseMlp scaffold (feedback.hpp:113-119,183-201) but to the stricter spec. Lifecycle state machine in the reworked FeedbackController, FeedbackMode::ExploreAndPlace:
- ENTER (verdict-down/explore):
enter_scratchpad(mlp, spread)— snapshot the real trained net intosnapshot_(the set-aside model; existingget_weightsround-trip), setlearning_paused_=true(gatestrainOnCurrent+ auto-explore, mirroring upstreamoptimiseSometimesearly-return), thendraw_weights(spread)→ random scratchpad net. - SCRATCHPAD OPS — exactly two, position-agnostic, never trained, never a dislike:
reroll()=draw_weights(spread)again ("meh, randomise…"). Re-press while exploring re-rolls (returnsReroll); a dedicated cancel gesture / mode-switch aborts. (This changes the old 2nd-press-cancels semantics — deliberate; rewrite the affected ctest.)nudge(speed, spread)= a smallmove_weightson the scratchpad with a templated undo ringscratch_undo_(depthD; WASMD=4, firmwareD=2via template param — Sift's free SRAM tunable).undo_nudge()pops. Reuses the byte-exactget_weights/set_weightsround-trip (graft: no new parity surface). New actionsScratchNudge,ScratchUndo.- The scratchpad is NEVER trained and NEVER reads replay — auditioning is pure forward inference as the user sweeps the joystick.
- PLACE (user likes a sound): press → choose a location in input space → release.
place_begin()freezes the current scratchpad output vector intoplaced_out_(so the heard sound is held while the user aims; emitted via the existingstatic_outputbypass hook so the audition doesn't change). The TS runtime owns the "aim" UI (a JoyMap drag). On release: caller sets the scratchpad input tochosen_input, runsmlp.process(), reads outputs, callsplace_commit(chosen_input, scratch_out_at_chosen)→ appends a positive anchor and returnsAnchorPlaced. The controller does NOT itself run inference (keeps it pure — Minimal-Divergence's boundary contract). - WARM-START INTERPOLATION (explicit "Done"/finalise gesture): restore the set-aside net (
set_weights(snapshot_)), then warm-start it to interpolate all placed anchors. Decision (graft from all four risk-lists): warm-start is ADDITIVE — anchors are added to the existing dataset, NOTclear_examples()+refit — so the user's prior thumbs-up likes are not clobbered. The caller loopsaddExample(input, output)overreplay.anchors()thentrain(lr, maxIter, minErr)(caller owns training). "Warm-start" = begin from the restored real net, not a fresh draw, so prior structure is preserved. ReturnsWarmStarted;learning_paused_flips false. - ABORT (mode-switch / cancel): restore the set-aside net, discard anchors not yet finalised.
Anchor store = the same ReplayStore instance, positives only (§4). Coupling decision (Sift's flag): unifying Mode-1 replay and Mode-2 anchors into one store means a placed anchor also feeds Mode-1's positive centroid. This is a feature (mixed-mode sessions), but expose an operator toggle unifyMemories (default on) so they can be isolated into two ReplayStore instances if surprising.
Why Mode 2 is the default: highest audible-effect-per-action, no negative concept to learn, and it directly realises the operator's stated felt loop. Mode 1 is the precision/sculpting tool for when you already have a liked region.
3. SOLO / arm per output
State: solo_mask_ : std::array<std::uint8_t, kNOut> on the controller (1=armed/soloed, 0=frozen; all-zero / empty ⇒ none soloed ⇒ normal training). API set_solo_mask(span) / clear_solo_mask(), C API nisps_ml_feedback_set_solo(ml, mask, n) (mirrors the existing set_focus at bindings.cpp:485). One operator setting SOLO_MODE {MaskGradients=0, ZeroLoss=1, DontCareExample=2}.
All three flow through one injection point — the per-sample deriv array at mlp.hpp:281, fused per Sift's graft (zeroing the loss on non-soloed outputs is the don't-care mask).
3.1 MaskGradients (DEFAULT) — column-freeze
Predictable: "only this output moves." Derive pin_mask = bitwise-NOT(solo_mask) over outputs. In a new train_masked(lr, max_iter, min_err, out_mask): zero deriv[j] for non-soloed j before backprop_, and freeze hidden layers when any solo is active (skip layer0_/1_/2_.apply_grad; apply only layer3_ for soloed columns). Result: non-soloed final-layer columns AND all hidden weights are bit-identical after training. This is the exact inverse of rl.hpp:74-99's pin gating — minimal new code, reuses proven machinery.
3.2 ZeroLoss (selectable) — expressive-but-bleeds
Zero deriv[j] for non-soloed j but let hidden layers update. The soloed output gets the full network's expressive capacity, but non-soloed outputs can drift via shared hidden features. One-line variant of 3.1 (drop the hidden-layer freeze).
3.3 DontCareExample (selectable) — most faithful to "store with a mask"
Each ReplayStore item carries uint8 mask[NOut]. An example placed while output k is soloed stores mask = {0…1@k…0}; train_masked consumes the per-example mask so other outputs at that input are never pulled toward a stale label. Survives later training. Costs Cap*NOut bytes.
3.4 Honest limit (Sift's correctness graft — into product copy + ALIGNMENT.md)
On a shared-trunk MLP, NO realisation both fully isolates AND stays expressive. MaskGradients is predictable but weak (only the final linear column moves; hidden capacity frozen). ZeroLoss is expressive but bleeds through shared hidden weights. State this to the operator in the dock panel ("solo freezes the rest as far as a shared network allows") rather than overselling "leaves others unchanged". MaskGradients is the safe default because its guarantee is provable (§6.3 bit-identical ctest).
Solo is honoured in both modes: Mode-1 geometric target only pushes soloed dims (unify solo_mask_ with the activeDims_ gate at InterfaceRL.cpp:730 — Anchor-First's graft); Mode-2 warm-start trains anchors only on soloed dims when solo is active.
4. Core component changes (keep replay OUT of the MLP kernel — Anchor-First)
New nisps/ml/replay.hpp
ReplayStore<NIn, NOut, Cap> — fixed std::array ring (no heap; WASM Cap=64, firmware Cap=16-32 via template param — Anchored-Manifold's free SRAM/centroid tradeoff). Item {std::array<float,NIn> input; std::array<float,NOut> action; float reward; std::uint8_t mask[NOut];}. Methods (all deterministic, per-instance nisps::Rng, no libc rand):
deepen_or_store_negative(x, a, radius=0.05f)(InterfaceRL.cpp:42-66)store(reward, x, a, mask)/positive_count()knn_positive_centroid(x, k=4, &mean, &count)— linear scan, fixed top-k insertion (nostd::sort, no heap). Deterministic tie-break by index and fixed accumulation order so native==WASM (the classic float-sum parity trap).decay_negatives()(reward += 0.0025f*max(|reward|,1), evict> -0.01f)anchors()/anchor_count()accessors for Mode-2 warm-start replay.
New nisps/ml/geo_push.hpp
Pure free function compute_push_targets(replay, control_input, solo_mask, rng, &target_buf) implementing InterfaceRL.cpp:602-738 exactly. Writes into a caller-supplied scratch buffer (no heap). Keeps the geometric math out of both MLP and controller.
nisps/ml/mlp.hpp — minimal backprop hooks only
train_targets(span inputs, span targets, span sample_weights, span out_mask, lr)— trains toward computed target vectors (Mode-1 geometric batch; targets are computed, not stored labels). Reusesforward_/backprop_/apply_grad.train_masked(lr, max_iter, min_err, span out_mask)— the solo path: zeroesderiv[j]at:281for maskedj; in MaskGradients freezes hiddenapply_grad.- Factor the inner forward→loss→backprop of
train()into atrain_pair_helper so both reuse the proven backprop (Minimal-Divergence graft). No replay/centroid/anchor logic in MLP.
nisps/ml/feedback.hpp — edits (append-only enum)
- Add
ReplayStore<NIn,NOut> replay_,solo_mask_,scratch_undo_ring (templated depth),placed_out_,SoloModefield. - Edit
on_downAvoid case → geometric push viareplay_+geo_push+train_targets; keep aDiffuselegacy sub-mode. - Edit
RandomiseMlp/ExploreAndPlaceon_downso re-press =Reroll. - Add
nudge/undo_nudge,place_begin/place_commit,finalise/abort,set_solo_mask/clear_solo_mask,set_solo_mode. - Append new
FeedbackActionvalues keeping existing numeric values stable:GeometricPush=8, ScratchNudge=9, ScratchUndo=10, AnchorPlaced=11, WarmStarted=12(Minimal-Divergence's single most parity-safe decision — never renumber the TS↔C++ contract).
nisps/wasm/bindings.cpp — extend the existing block (:439-535)
Add: nisps_ml_feedback_set_solo(ml,mask,n), _set_solo_mode(ml,mode), _nudge(ml,speed,spread), _undo_nudge(ml), _place_begin(ml,current_out), _place_commit(ml,chosen_input,scratch_out), _anchor_count(ml), _get_anchors(ml,buf), _finalise(ml), _abort(ml). Existing _down/_up/_drag/_static_output stay; _down now returns the new action ints. Reuse the existing scratch buffers + guard/cast style.
TS
playground/src/ml/types.ts— add the new_nisps_ml_feedback_*decls; extendFeedbackMode/FeedbackAction/addSoloModeenums (numeric parity with C++).playground/src/ml/wasm-iml.ts— thin wrappers (feedbackNudge,feedbackPlaceCommit,feedbackFinalise,feedbackSetSolo,getAnchors); reusepinMaskBuf(:225); add a small anchor heap buffer.playground/src/modes/mode-runtime.ts— routethumbsDown(:548) per activefeedbackMode; Mode-2 wires press→aim→release toplace_begin/place_commit; a "Done" button →finalise→ loopgetAnchors→addExample→train. GatetrainOnCurrent(:516) and auto-explore onlearning_paused().playground/src/stores/exploration-store.ts— addfeedbackMode,soloMode,soloMask,exploring,anchorCount,unifyMemories+ selectors.
5. Phased integration plan (TS-prototype-first; each step gated on parity-check.sh)
Audible validation is the gate, not green tests. Prototype the arithmetic in pure TS — driven through the EXISTING WasmIML primitives (drawWeights, moveWeights, addExample, train, getWeights/setWeights, setInput/process/getOutputs, verified at wasm-iml.ts:293-476) — before any new C++.
Phase 0 — TS prototype (no new WASM)
A throwaway TS module mirrors ReplayStore + geo_push + train_masked, computed in TS and fed to the real net via existing calls. Oracles:
- Mode 1: like 2-3 sounds in distinct corners; dislike a sound near one like; confirm the heard 126-vector at that input moves audibly AWAY from the liked timbre (not random wobble). A/B against today's
moveWeightsthumbs-down using the existing A/B-compare feature — directionality must be perceptible (Sift's oracle). - Mode 2: randomise → sweep-audition → "like that" → place in a corner → randomise → place opposite corner → Done → sweep the JoyMap and confirm a smooth morph between the two placed timbres, and that re-randomising no longer destroys them.
- Solo: solo output 0, train, confirm via the heatmap / weight-health views that only that param's mapping changed (MaskGradients: bit-stable elsewhere).
- Use
window.__nisps(setInputs/getOutputs/train/thumbsDown/getLayerStats) for scripted Playwright checks.
Gate: felt loops audibly correct → freeze kCentroidK, pushStep, warm-start iteration count, undo depth. Only then crystallise.
Phase 1 — C++ core
Add replay.hpp, geo_push.hpp, the mlp.hpp hooks, the feedback.hpp edits. Build nisps_core_tests (-Wall -Wextra -Werror). Gate: new ctest cases green (§6.1); scripts/parity-check.sh still 1e-5 (existing Stage 5 unchanged at this point).
Phase 2 — C API
Add the new nisps_ml_feedback_* exports. Rebuild WASM (scripts/build-wasm.sh). Gate: parity-check.sh green after Stage-5 extension (§6.2); kVersion bump.
Phase 3 — TS FFI
types.ts decls + enums, wasm-iml.ts wrappers. Gate: bun run typecheck; a TS↔WASM smoke test reproducing a Phase-0 loop through the real FFI matches the TS prototype's outputs; parity-check.sh green.
Phase 4 — UI selector in the learning-behaviour dock panel
exploration-store state + the dock-panel FEEDBACK_MODE selector ("Explore and place" / "Geometric dislike"), SOLO_MODE selector, exploring/anchor indicators, the cold-start prompt, the honest solo-limit copy (British spelling). Gate: bunx playwright test (the e2e felt-loop specs); full scripts/run-all-tests.sh (chokepoint E) green.
Phase 5 — firmware readiness (gated, not on the critical path)
Confirm RP2350 SRAM budget with firmware Cap/undo-depth template params (ReplayStore<2,126,16> + anchors + scratch_undo_ depth 2). Verify chokepoint A (audio correct) / B (no perf regression) before flashing. Re-pin the orphaned memllib 4733ca0 to 0a541cc.
6. Parity + perf contract
Native==WASM within 1e-5 is preserved because every new operation is deterministic f32 arithmetic on the per-instance nisps::Rng (no libc rand() anywhere). Both sides run the same Rng from the same seed (kSeed ^ kFeedbackSalt, matching the MLHandle 0xFEEDBACC0DE salt).
6.1 Host ctest (tests/cpp/test_mlp_feedback.cpp, already registered)
Rewrite the cases that encode the old 2nd-press-cancels state machine (deliberate break). Add: replay dedup/deepen at 0.05; k-NN centroid selection + deterministic tie-break; geometric push direction sign (target moves away from centroid); taper; cold-start posMemCount==0 fallback; solo bit-identical assertion — after train_masked with MaskGradients, non-soloed final-layer columns are == (not 1e-5-near) to pre-train (Anchored-Manifold's stronger-than-parity oracle); scratchpad-never-trained invariant; place→warm-start reachability (both anchors reproduced at their corners); determinism under fixed seed.
6.2 Native↔WASM parity (parity_check.cpp + parity_wasm.mjs, bump kVersion)
Extend Stage 5: (S5a Mode 1) seed ParityMLP+ReplayStore, store 3 fixed positives + 1 negative, run geometric push (centroid→target→train_targets), push 126 post-train probe outputs both sides. (S5b Mode 2) enter scratchpad, reroll×2, nudge, undo, place 2 anchors at fixed inputs, finalise (warm-start at fixed lr/iters/seed), push probe outputs + 12 weight probes. (S5c Solo) set solo_mask={1,0,…}, train_masked, assert non-soloed columns byte-identical pre/post. scripts/parity-check.sh float32-diffs at 1e-5 — new floats covered automatically.
6.3 Perf contract
replay.hpp + anchors + solo_mask_ + scratch_undo_ are all fixed std::array (Cap/depth compile-time, zero heap). Hot loops O(Cap*NOut) over fixed arrays; roll/centroid/train_pair_ marked NISPS_FORCE_INLINE where hot; .f on every literal (0.0025f, 0.25f, 0.5f, 16.f, 0.05f). No virtual dispatch (plain template). Feedback runs on the control core, never the audio ISR (mirrors firmware loopCallback) → chokepoint B structurally satisfied. SRAM budget tuned per-target via Cap/depth template params.
7. UI — learning-behaviour dock panel
FEEDBACK_MODEsegmented control: Explore and place (default) / Geometric dislike. One-line description per mode in the operator's language.SOLO_MODEselector (visible when any output is armed): Mask gradients (default) / Zero loss / Don't-care example, with the honest "as far as a shared network allows" caveat.- Exploring indicator + anchor count during Mode-2 sessions; a Done / finalise button and a cancel gesture; nudge + undo controls.
- Cold-start prompt (Mode 1, zero positives): "Like a few sounds first so the system knows what to move away from."
unifyMemoriestoggle (advanced): share likes/dislikes across modes (default on).- Copy uses British spelling throughout.
8. ALIGNMENT.md updates (same commit as the code)
- RETRACT the
feedback-modes-port-spec.md §2.5/§7"geometric push out of scope / AVOID = move_weights" accepted-divergence note — Mode 1 now ports the latest upstream. Without this, a future session will "rediscover" the geometric push as scope creep. - Record the single deliberate firmware divergence: the
useRandomdegenerate branch usesnisps::Rng, not libcrand()(value generated, never compared; native==WASM holds). - Record the TrainBatch-vs-unshuffled-SGD behavioural divergence: nisps trains per-sample SGD, not shuffled two-LR batches —
browser != firmwarebehaviourally, by design (separate verification targets). - State the shared-trunk solo limit as accepted: no realisation both perfectly isolates and stays expressive.
- Re-pin orphaned memllib
4733ca0→0a541cc.