Phase-1 audits (feedback-behaviour, engine-surface, design/Manifold), the judge-panelled rl-feedback-design, dock/backends/inputs specs, the upstream-firmware survey, the Manifold design mirror, and BUILD-PLAN.md (resume anchor + locked decisions).
158 lines
12 KiB
Markdown
158 lines
12 KiB
Markdown
# Findings — Current RL / Feedback Behaviour (Phase-1 Audit)
|
||
|
||
*Read-only audit, 2026-06-27. Citations are `file:line`. "VERIFIED" = read in source; "INFER" = deduced.
|
||
Produced by the Phase-1 research fan-out (workflow `wf_db146f21`), persisted by the orchestrator because a
|
||
hook blocks subagent report-file writes. Adversarially spot-checked against source.*
|
||
|
||
## Executive summary
|
||
|
||
1. The operator's mental model ("negative feedback takes the n-D opposite vector and pushes weights the other
|
||
way — geometric push-away") describes the **firmware `InterfaceRL`** path, NOT what is deployed in the
|
||
browser/aimmersive build.
|
||
2. The **deployed JS engine** and the **`nisps/` C++ core** both implement thumbs-down as
|
||
`move_weights(speed, spread, pinMask)` = per-weight decay (`w *= 1-0.1·spread`) then additive Gaussian
|
||
noise (`stddev = speed · layer_scale`). There is **no opposite vector, no centroid, no geometric direction**
|
||
in deployed/core code (VERIFIED: grep for `centroid|geometric|meanPositive|neg_action` returns nothing in
|
||
`deployments/meml-aimmersive/js/`).
|
||
3. So "push the weights the other way" is, in the deployed system, actually **"jitter the whole network with
|
||
random Gaussian noise"** — undirected diffusion, not directed avoidance.
|
||
4. The true geometric push-away (move output away from k-NN centroid of liked examples) exists only in the
|
||
firmware submodule (`memllib` InterfaceRL; "geo push" commits) and is documented in
|
||
`docs/redesign/feedback-modes-port-spec.md` §1.1. **This is the algorithm Mode 1 must match** (workstream B).
|
||
5. **Why often inaudible (deployed AVOID):** noise added across all 4 layers of `MLP<2,10,14,18,126>`,
|
||
per-layer scaled `(1-spread)+spread/√fan_in`, capped at `noiseCap=0.12`, spread default 0.6 → tiny
|
||
per-step perturbation that the sigmoid output + output smoothing/slew pipeline absorb. One press moves the
|
||
heard 126-vector very little, in a random direction uncorrelated with what was disliked.
|
||
6. **Three FEEDBACK_MODEs already scaffolded** in untracked working files `nisps/ml/feedback.hpp` +
|
||
`tests/cpp/test_mlp_feedback.cpp` (`FeedbackController<MLP_T>`): `Avoid`, `RandomiseOutputs`,
|
||
`RandomiseMlp`. Not yet wired into bindings/TS/UI; no CMake registration.
|
||
7. `RandomiseMlp` = the operator's Mode 2 as an audition: snapshot weights → `draw_weights(spread)` → user
|
||
moves joystick to hear → up commits a +1 example, down/switch restores the snapshot. **This is the seed of
|
||
Mode 2** but the updated spec is stricter (scratchpad NEVER trained; explicit place-at-location; warm-start
|
||
real model to interpolate anchors).
|
||
8. `RandomiseOutputs` = bypass the MLP entirely, hold a static random output vector (focus-aware per-dim),
|
||
re-roll on each down; up commits the held vector as a +1 example.
|
||
9. The operator's "randomise → anchor → train" alternative maps onto `RandomiseMlp` (randomise) + thumbs-up
|
||
`LikeStore` (anchor) + `trainOnCurrent` (train). Missing in deployed build: the randomise-audition-then-anchor
|
||
loop; today thumbs-down only diffuses noise and never anchors.
|
||
10. The full port spec (component, WASM C API, TS FFI, ~22 ctest cases, parity stage) already exists at
|
||
`docs/redesign/feedback-modes-port-spec.md` and matches the untracked `feedback.hpp`.
|
||
11. **Parity/perf constraints:** no heap / `std::array` only, per-instance deterministic `nisps::Rng`
|
||
(no libc `rand()`), no virtual dispatch, `.f` literals, fixed WASM arch `MLP<2,10,14,18,126>`,
|
||
native↔WASM parity within `1e-5`.
|
||
12. **Bottom line:** deployed negative-feedback is undirected Gaussian noise (not geometric push-away), which is
|
||
why it is weak/inaudible; the in-progress `FeedbackController` already implements the seed of the operator's
|
||
randomise-anchor-train idea but is unplumbed past the header.
|
||
|
||
## 1. Current algorithm, precisely, per layer
|
||
|
||
### 1.1 DEPLOYED (browser/aimmersive) — NOT geometric
|
||
Thumbs-down = `move_weights`. Pure-JS reference `deployments/meml-aimmersive/js/nisps/mlp.js:238-265`
|
||
(VERIFIED): per layer/node/weight `j`: `decay = 1-0.1*spread`; `layerScale = (1-spread)+(1/√fanIn)*spread`;
|
||
`node.weights[j] *= decay`; `accum = Σ_{n=0..2}(rand*2-1)`; `node.weights[j] += 3*accum*speed*layerScale`.
|
||
Output-layer nodes with `outputPinMask[ni]` skipped (`mlp.js:248`). WASM wrapper `nisps-wasm.js:389-406` →
|
||
`cwrap('nisps_mlp_move_weights_ex')`. IML `iml.js:166-170` forwards then re-infers — **no dislike-direction
|
||
logic, no example stored on dislike**.
|
||
|
||
**Key finding (VERIFIED, exhaustive grep):** `deployments/meml-aimmersive/js/` has zero matches for
|
||
`centroid|meanPositive|geometric|pushStep|dislikeMultiplier|neg_action|RANDOMISE|FEEDBACK_MODE|explore_active`.
|
||
Deployed negative feedback is purely undirected Gaussian weight diffusion. The disliked 126-vector is never read.
|
||
|
||
### 1.2 `nisps/` C++ core — same move_weights, no geometric push
|
||
`nisps/ml/rl.hpp:63-100` `move_weights_layer` (VERIFIED): `layer_scale = spread_scale(spread,fan_in) =
|
||
(1-spread)+spread/√fan_in` (`init.hpp:31-35`); `noise_stddev = speed*layer_scale`; `decay = 1-0.1*spread`
|
||
(`rl.hpp:49-51`). Bias: `biases[node] += rng.next_float_gaussian(noise_stddev)` unless pinned (`rl.hpp:83`).
|
||
Each weight: `w = w*decay + rng.next_float_gaussian(noise_stddev)` (`rl.hpp:92-97`); RNG advanced even on skip.
|
||
Pin mask only on `is_final_layer` (`rl.hpp:78-79`). `mlp.hpp:312-322` iterates 4 layers, only layer3
|
||
final+masked. `draw_weights` (`mlp.hpp:324-332`→`init.hpp:42-54`): `w = rng.next_float_signed()*spread_scale`,
|
||
biases zeroed. C API `nisps_ml_move_weights`/`nisps_ml_draw_weights` (`bindings.cpp:419-437`). TS
|
||
`wasm-iml.ts:495-508`. Handler `mode-runtime.ts:548-565`: builds pin mask, `moveWeights(cap,spread,pinMask)`,
|
||
`growNoise`, re-`setInput`. **No example stored on thumbs-down** (vs thumbs-up `mode-runtime.ts:523-546`).
|
||
|
||
### 1.3 TRUE geometric push-away (firmware only) — THE MODE-1 TARGET
|
||
Per `feedback-modes-port-spec.md:61-79` (sourced from firmware `InterfaceRL.cpp`), applied in `optimise()` not
|
||
at press time: `pushStep = clamp(|avgRewardNeg|,0.25,1.0)*0.5`; `dir[j] = neg_action[j] -
|
||
meanPositiveAction[j]` (away from k-NN(+) centroid); `target[j] = clamp(neg_action[j] +
|
||
(dir/||dir||)[j]*pushStep/(1+||dir||), 0, 1)`; then train toward `target`. Depends on firmware-only
|
||
`ReplayMemory`. Lives in `src/memllib` submodule (commits `2429bcc` "geo push", `d301cc7` "rlview and dislike";
|
||
upstream `SB2026`; memllib main `e291192`/`abe93ec`). **Workstream A must confirm the newest variant.**
|
||
|
||
## 2. FEEDBACK_MODEs implemented / scaffolded, and where
|
||
|
||
| Mode | Firmware memllib | nisps/ core (untracked) | Deployed JS | Playground TS |
|
||
|---|---|---|---|---|
|
||
| AVOID geometric push | VERIFIED (geo-push commits) | NOT ported | — | — |
|
||
| AVOID = move_weights | — | `feedback.hpp:103-105` | `mlp.js`/`nisps-wasm.js` | `mode-runtime.ts:548` |
|
||
| RANDOMISE_OUTPUTS | VERIFIED (spec §1.2) | `feedback.hpp:106-112,160-181` | — | — |
|
||
| RANDOMISE_MLP | VERIFIED (spec §1.3) | `feedback.hpp:113-119,183-189` | — | — |
|
||
|
||
**Untracked `nisps/ml/feedback.hpp`** (214 lines, VERIFIED): `nisps::ml::FeedbackController<MLP_T>`.
|
||
`enum FeedbackMode {Avoid=0,RandomiseOutputs=1,RandomiseMlp=2}` (`:47-51`); `enum FeedbackAction
|
||
{None,AvoidPerturb,LikeStore,EnterExplore,Reroll,CommitStore,Cancel,Restore}` (`:56-65`). State all
|
||
`std::array` (no heap), own `Rng` seeded separately (`:203-210`). `on_down`: Avoid→move_weights;
|
||
RandOut→enter/roll; RandMlp→enter(snapshot+draw_weights)/cancel. `on_up`: exploring→restore+CommitStore else
|
||
LikeStore. `on_drag`: RandMlp+exploring→restore+Restore else LikeStore. `static_output`: bypass hook only when
|
||
RandomiseOutputs&&exploring. `roll_static_outputs`: focused→`rng_.next_float_uniform()`, unfocused frozen.
|
||
`set_mode` aborts active exploration first.
|
||
|
||
**Untracked `tests/cpp/test_mlp_feedback.cpp`** (437 lines, ~22 NISPS_TEST, VERIFIED):
|
||
`SmallMLP=MLP<2,4,4,4,6,8,32>`; covers all transitions, RNG determinism, focus edge cases, a golden RNG
|
||
stream. **Not registered in `nisps/CMakeLists.txt`** (spec §5.1/§7 lists as TODO).
|
||
|
||
**Not yet wired (gaps):** no `nisps_ml_feedback_*` in `bindings.cpp`; no `FeedbackController` on `MLHandle`;
|
||
no TS FFI in `types.ts`; no UI selector. Firmware parent branch `origin/feat/feedback-explore-modes` wires a
|
||
TR-8S FEEDBACK_MODE selector (`5c2d4d2`) and adds `docs/dislike_system_{analysis,design_space}.md` (4 meanings
|
||
of "dislike"; here/anywhere × point/radius scope matrix — VERIFIED).
|
||
|
||
## 3. Spread / noise / zoom machinery
|
||
- **spread∈[0,1]** (`exploration-store.ts:35,74`, default 0.6): feeds per-layer scale and decay. spread=0:
|
||
uniform weights, big noise, no decay (saturated); spread=1: Xavier, small noise, 10% decay (spread out).
|
||
- **noiseCap∈[floor,1]** (default 0.12): the `speed`/`cap` magnitude for thumbs-down and auto-explore.
|
||
- **growNoise/decayNoise** driven by pointer `pressureForce` (`mode-runtime.ts:491-499,544,562`).
|
||
- **Zoom** scales auto-explore intensity: `scaledIntensity = intensity*(0.3+0.7*zoom)` — an input-pipeline
|
||
gain, not output magnitude.
|
||
- **Auto-explore loop** (`mode-runtime.ts:444-482`): periodic `move_weights`+`growNoise`, default 2000ms, off
|
||
by default — same diffusion as thumbs-down.
|
||
- **Pin mask** `buildPinMask(...)`: freezes final-layer weights for pinned/overridden params — the only
|
||
directional gating today (protects dims, doesn't push them). *Relevant to the SOLO/arm design (B).*
|
||
|
||
## 4. Parity/perf constraints any core change must respect
|
||
No heap in `nisps/` (`std::array`/`FixedBuffer`; `feedback.hpp` obeys); deterministic per-instance `nisps::Rng`,
|
||
no libc `rand()`; no virtual dispatch; `.f` literals, no double promotion, `NISPS_*` mem attrs; fixed WASM arch
|
||
`MLP<2,10,14,18,126>` (snapshot = `weight_count()` floats, compile-time fixed); native↔WASM parity within
|
||
`1e-5`. RandomiseMlp snapshot/restore must round-trip `get_weights`/`set_weights` byte-identically;
|
||
RandomiseOutputs static vectors need same-seed/same-Rng; golden tests pin RNG streams.
|
||
|
||
## 5. WHY the effect is inaudible — the mechanism
|
||
1. **Undirected, not opposite.** Zero-mean Gaussian noise on every weight in all 4 layers; the disliked output
|
||
is never read. Expected output change ≈ 0; only variance moves — wobbles randomly, doesn't move *away*.
|
||
2. **Small magnitude, multiply-attenuated.** `speed=0.12, spread=0.6` → output layer `layer_scale≈0.54`,
|
||
`stddev≈0.065` per weight, squashed by a near-saturated sigmoid, then damped by smoothing→slew→freeze.
|
||
3. **Decay fights accumulation.** `w *= 0.94` each press pulls back toward the basin → equilibrium by design.
|
||
4. **Pinned/override dims frozen** on the final layer → never respond.
|
||
5. **No anchoring.** Thumbs-down stores no example and triggers no training, so there's no learning signal.
|
||
|
||
## 6. Operator's Mode 2 ("explore-and-place") vs what exists
|
||
Already half-built as `RandomiseMlp`:
|
||
- **Randomise** = `RandomiseMlp::on_down` enter → `snapshot + draw_weights(spread)`: whole net re-rolled →
|
||
audibly different; user auditions via joystick.
|
||
- **Anchor** = `on_up` → `restore_after_explore` + `CommitStore`: caller stores +1 example at
|
||
`(current_input, heard_output)`, original net restored.
|
||
- **Train** = existing `trainOnCurrent()` trains restored net toward the anchored example.
|
||
- **Discard** = `on_down` again → `Cancel`, or mode-switch `abort_explore` restores snapshot.
|
||
|
||
**Updated-spec deltas to implement (workstream B):** the scratchpad must be NEVER trained (only randomise +
|
||
undoable nudge); the place step must let the user CHOOSE the input location (press → pick location → release);
|
||
the real model is warm-started to interpolate ALL placed anchors. The existing `RandomiseMlp` is the closest
|
||
prototype but commits at the *current* input position, not a chosen one, and lacks the explicit nudge op.
|
||
|
||
### Key files
|
||
- Deployed: `deployments/meml-aimmersive/js/nisps/{mlp.js,nisps-wasm.js,iml.js}`
|
||
- Core RL: `nisps/ml/{rl.hpp,init.hpp,mlp.hpp}`
|
||
- Core feedback (untracked): `nisps/ml/feedback.hpp`, `tests/cpp/test_mlp_feedback.cpp`
|
||
- WASM API: `nisps/wasm/bindings.cpp:419-437`
|
||
- TS: `playground/src/ml/wasm-iml.ts:488-508`; runtime `playground/src/modes/mode-runtime.ts:444-565`
|
||
- Exploration knobs: `playground/src/stores/exploration-store.ts`
|
||
- Port spec: `docs/redesign/feedback-modes-port-spec.md`
|
||
- Firmware geo-push: `src/memllib` (commits `2429bcc`, `d301cc7`; upstream `SB2026`; main `e291192`/`abe93ec`);
|
||
design docs on `origin/feat/feedback-explore-modes`: `docs/dislike_system_{analysis,design_space}.md`
|