diff --git a/manifold/ONBOARDING.md b/manifold/ONBOARDING.md index 664b05c..3a76490 100644 --- a/manifold/ONBOARDING.md +++ b/manifold/ONBOARDING.md @@ -180,10 +180,13 @@ a setting → `--r-*` tokens. - `curves.ts` — math primitives. **Must stay lockstep with C++ `nisps/core/math.hpp`** (golden tests compare WASM vs TS). `sink.ts` — `EngineSink` framework boundary. `types.ts` — C-ABI surface types. - `exploration.ts` — `ExplorationController` adapter for the Jolt press + OU explore gestures - (Learning drawer). Interim TS math in `jolt.ts` / `ou-explore.ts` (ported from the retired - playground), driven via get/set-weights; OU applies through the spine's inert-by-default - `setOutputMorph` hook. The `─── P3 SWAP POINT ───` comment marks where the one-core-engine plan - swaps these for `nisps_ml_jolt_press/release` + `nisps_ml_explore_intensity` WASM bindings. + (Learning drawer). **As of one-core-engine P3 the maths lives in the shared C++/WASM core** — this + class is a thin driver that owns only the control-rate timers and calls `engine.explore.*` + (`joltPress`/`joltStep`/`joltRelease`/`joltActive` → `nisps_ml_jolt_*`; `setExploreIntensity`/ + `exploreApply` → `nisps_ml_explore_*`). The held ~200 Hz driver calls `joltStep()` + `process()`; + the OU walk is the spine's inert-by-default `setOutputMorph` hook, now `exploreApply(routed)` (copy + into the WASM heap → advance+add in the core → copy back). The interim `jolt.ts` / `ou-explore.ts` + TS ports were deleted with the swap. ### Inputs — `src/inputs/` - `input-layer.ts` — composition hub. One rAF loop polls sources, pulls all axes into a vector, @@ -206,16 +209,21 @@ a setting → `--r-*` tokens. for the locked design (adaptive slider viz when >2 dims is still pending). ### Feedback — `src/feedback/` -- `controller.ts` (**~490 lines**) — `FeedbackController`, framework-neutral, owned by ConsoleApp. - Two modes: **geometric-dislike** (default, Mode 1, "Push away" — down carves the current sound - away from what you like, directed repulsion) and **explore-and-place** (Mode 2, positive-only, - selectable — drives the C++ core's snapshot/scratchpad/undo lifecycle; caller accumulates anchors - and trains on finalise with warm-start). Solo/arm via per-output mask. -- `rng.ts` — `SeededRng` (deterministic xorshift32 + gaussian). **Stand-in** until the C++ nudge owns - the stream — not bit-identical to `nisps::Rng`. -- **`--- C++ GAP ---` markers** flag behaviour approximated in TS pending C++ port: true geometric - push (firmware k-NN), feedback nudge → `nisps_ml_feedback_nudge`, loss-history plumbing. Grep for - `C++ GAP` before changing feedback maths. +- `controller.ts` — `FeedbackController`, framework-neutral, owned by ConsoleApp. **As of one-core- + engine P3 it holds NO algorithm approximation** — a thin driver over the shared C++ core. Two modes: + **geometric-dislike** (default, Mode 1, "Push away") — `dislike()` calls `engine.feedback. + dislikeGeometric(heardVec)` (the k-NN centroid push-away in `nisps/ml/geo_push.hpp`; returns + FeedbackAction 14=push / 15=cold-start) then `process()`; `like()` runs the core's `thumbsUp` + (auto-stores the positive centroid in Avoid+Geometric) + `addExample` + `train`. **explore-and- + place** (Mode 2, positive-only) drives the core's snapshot/scratchpad/undo lifecycle; caller + accumulates anchors and trains on finalise with warm-start. Solo/arm via per-output mask. +- **The heard-vector rule:** the geometric dislike trains AWAY from the HEARD (post-pipeline, routed) + output — pass `engine.routedOutput()`, never the raw MLP output, or the cold-start MSE derivative is + zero (inert). The `EngineApi.feedback.thumbsDown` facade + the Mode-1 `dislike()` call site honour this. +- **Cold-start prompt:** a dislike with zero positives returns action 15 → ConsoleApp shows a one-time + "Like a few sounds first…" banner (dismissed on the next like or the dismiss button; rl-feedback §7). +- The interim `rng.ts` (`SeededRng`) and the `C++ GAP` approximation markers are **gone** — the seeded + RNG, geometric push, jolt, and OU all run in the core now. ### Backends — `src/backends/` - `manager.ts` — `BackendManager`, the **single consumer of the engine spine** for output: subscribes, diff --git a/manifold/src/App.tsx b/manifold/src/App.tsx index f8cb9fe..a148069 100644 --- a/manifold/src/App.tsx +++ b/manifold/src/App.tsx @@ -7,10 +7,31 @@ import { useEffect } from 'react'; import { EngineProvider } from './engine/EngineProvider'; +import type { EngineApiOptions } from './engine/engine-api'; import { useEngine } from './engine/useEngine'; import { installDebugProbe } from './debug/probe'; import { ConsoleApp } from './console'; +/** + * Engine options derived from the URL. Under `?debug=1` (the Playwright / + * dev-probe gate) we pin a FIXED RNG seed so the net's initial weights — and + * therefore inference, feedback, and reshape behaviour — are deterministic run + * to run. Production (no debug flag) keeps the time-seeded default, so this + * never changes what a real user hears. + */ +function engineOptions(): EngineApiOptions { + if (typeof window === 'undefined') return {}; + try { + const params = new URLSearchParams(window.location.search); + // Fixed seed + fixed per-tick dt ⇒ deterministic weights AND deterministic + // pipeline smoothing (the pipelines otherwise read performance.now()). + if (params.get('debug') === '1') return { seed: 0xc0ffee, debugClockDt: 1 / 60 }; + } catch { + /* no URL (SSR / sandbox) — fall through */ + } + return {}; +} + function Loading() { return (
}> + }> diff --git a/manifold/src/console/ConsoleApp.tsx b/manifold/src/console/ConsoleApp.tsx index c7fbddb..66617c0 100644 --- a/manifold/src/console/ConsoleApp.tsx +++ b/manifold/src/console/ConsoleApp.tsx @@ -119,6 +119,10 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp const [soloMode, setSoloMode] = useState('mask-gradients'); const [exploring, setExploring] = useState(false); const [learningPaused, setLearningPaused] = useState(false); + // One-time cold-start prompt for geometric dislike: set when a dislike runs + // before any likes exist (core returns GeometricColdStart=15). Dismissed on the + // next like or an explicit dismiss (rl-feedback-design §7). British spelling. + const [coldStart, setColdStart] = useState(false); // Explore-and-place scratchpad session state (workstream B; rl-feedback §2.2). const [picking, setPicking] = useState(false); const [anchorCount, setAnchorCount] = useState(0); @@ -410,6 +414,8 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp const c = controllerRef.current; setFirstSession(false); setBusy(true); + // A like teaches the system what to move away from → dismiss the cold-start prompt. + setColdStart(false); if (feedbackMode === 'explore-and-place') { if (c?.getState().exploring) { // Place the current candidate → next manifold tap chooses the location. @@ -457,9 +463,18 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp forwardVcvFeedback('rand'); } } else { - // Geometric dislike: push the current mapping away from this sound. + // Geometric dislike: push the current mapping away from this sound. Pass the + // HEARD (post-pipeline, routed) vector — NOT the raw MLP output — so the + // core has a non-zero MSE derivative (see engine.feedback.dislikeGeometric). pushSnap('dislike −'); - c?.dislike(pos, engine?.getOutputs() ?? new Float32Array(0), noiseCap, spread ? 1 : 0.6); + const action = c?.dislike( + pos, + engine?.routedOutput() ?? new Float32Array(0), + noiseCap, + spread ? 1 : 0.6, + ); + // GeometricColdStart (15): no positives yet → show the one-time prompt. + if (action === 15) setColdStart(true); pushMarker(pos, 'negative'); // VCV bridged mode: thumbs-down = negative verdict. forwardVcvFeedback('down'); @@ -1033,6 +1048,44 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp picking={picking} /> + {/* Cold-start prompt (geometric dislike, no positives yet; rl-feedback + §7). One-time; dismissed on the next like or the dismiss button. */} + {coldStart && feedbackMode === 'geometric-dislike' && !exploring && ( +
+ + Like a few sounds first so the system knows what to move away from. + + +
+ )} + {/* Exploring-scratchpad banner (workstream B; rl-feedback §2.2 §7). */} {exploring && (
| null): void; exploring(): boolean; + // ---- Geometric dislike (one-core-engine P3; rl-feedback-design §2.1) ---- + /** + * Drive a geometric dislike at the MLP's CURRENT input with `heardVec` as the + * heard (post-pipeline) output. `lr <= 0` uses the core default (small). Runs a + * process() after so outputs reflect the trained net. Returns the FeedbackAction + * int (14=GeometricPush, 15=GeometricColdStart). + */ + dislikeGeometric(heardVec: ReadonlyArray, lr?: number): number; + /** Feed a positive into the k-NN centroid (omit vec → live MLP output). */ + storePositive(vec?: ReadonlyArray): void; + /** Replay-memory sizes (Mode 1). */ + feedbackCounts(): { positive: number; negative: number }; + /** Avoid sub-mode: 0 = Geometric (default), 1 = Diffuse (legacy). */ + setAvoidStyle(style: number): void; train(): number; trainAsync(): Promise; randomise(): void; @@ -154,6 +168,27 @@ function makeProbe(engine: EngineApi): DebugProbe { return engine.feedback.exploring(); }, + dislikeGeometric(heardVec: ReadonlyArray, lr = 0): number { + const a = engine.feedback.dislikeGeometric(Float32Array.from(heardVec), lr); + engine.process(); + return a; + }, + + storePositive(vec?: ReadonlyArray): void { + engine.feedback.storePositive(vec ? Float32Array.from(vec) : undefined); + }, + + feedbackCounts(): { positive: number; negative: number } { + return { + positive: engine.feedback.positiveCount(), + negative: engine.feedback.negativeCount(), + }; + }, + + setAvoidStyle(style: number): void { + engine.feedback.setAvoidStyle(style); + }, + train(): number { const loss = engine.train(); engine.process(); diff --git a/manifold/src/engine/engine-api.ts b/manifold/src/engine/engine-api.ts index 1fd492e..ca09731 100644 --- a/manifold/src/engine/engine-api.ts +++ b/manifold/src/engine/engine-api.ts @@ -61,6 +61,44 @@ export interface EngineFeedbackApi { undoDepth(): number; /** The frozen placed / just-committed output (null if none). */ placedOutput(): Float32Array | null; + + // ---- Geometric dislike (one-core-engine P3; rl-feedback-design §2.1) ---- + /** + * Push the current mapping away from the liked centroid. `heardVec` is the + * post-pipeline (HEARD) output vector — pass what the user hears, NOT the raw + * MLP output, or the cold-start MSE derivative is zero. Returns the + * FeedbackAction int (14=GeometricPush, 15=GeometricColdStart). + */ + dislikeGeometric(heardVec?: Float32Array, lr?: number): number; + /** Feed a positive (like) into the k-NN centroid (null → live MLP output). */ + storePositive(vec?: Float32Array): void; + positiveCount(): number; + negativeCount(): number; + /** Avoid sub-mode: 0 = Geometric (default), 1 = Diffuse (legacy, A/B). */ + setAvoidStyle(style: number): void; +} + +/** + * Exploration gestures backed by the shared C++ core (nisps/ml/{jolt,ou_noise}. + * hpp — the same the firmware ModeBase runs). The ExplorationController owns the + * control-rate drivers; these are the raw per-tick primitives. + */ +export interface EngineExploreApi { + /** Begin a held jolt (continuous weight morph). */ + joltPress(): void; + /** One ~200 Hz morph tick while held (no-op when inactive; mutates weights). */ + joltStep(): void; + /** Release: freeze the weights where they landed (permanent). */ + joltRelease(): void; + joltActive(): boolean; + /** Post-release LR-ramp multiplier (0 held → 1 over ~5 s of ticks). */ + joltLrScale(): number; + joltTickLrRamp(): void; + /** Exploration amount in [0,1]; 0 disables (inert — parity-safe). */ + setExploreIntensity(level: number): void; + exploreIntensity(): number; + /** Advance the OU walk and add it (clamped [0,1]) to `inout` in place. */ + exploreApply(inout: Float32Array): void; } export interface EngineAudioApi { @@ -81,6 +119,12 @@ export interface EngineApiOptions { /** Default RL move speed / spread for thumbsDown. */ noiseCap?: number; spread?: number; + /** + * Pin a deterministic per-tick dt (seconds) on the spine — set under ?debug=1 + * so the timing-driven pipeline smoothing is reproducible in tests. Omit in + * production (real-time wall-clock dt). + */ + debugClockDt?: number; } export class EngineApi { @@ -93,6 +137,7 @@ export class EngineApi { private spread_: number; readonly feedback: EngineFeedbackApi; + readonly explore: EngineExploreApi; readonly audio: EngineAudioApi; private constructor(iml: WasmIML, spine: Spine, host: EngineHost, opts: EngineApiOptions) { @@ -102,6 +147,7 @@ export class EngineApi { this.learningRate = opts.learningRate ?? 1.0; this.noiseCap = opts.noiseCap ?? 0.3; this.spread_ = opts.spread ?? 0.6; + if (opts.debugClockDt !== undefined) this.spine.setFixedDt(opts.debugClockDt); // Wire the spine's backend.send to push routed params into the worklet. const send: BackendSend = (routed) => { @@ -112,7 +158,16 @@ export class EngineApi { this.feedback = { thumbsUp: () => this.iml.feedbackUp(), thumbsDown: (speed = this.noiseCap, spread = this.spread_, pinMask?: Uint8Array) => - this.iml.feedbackDown(speed, spread, this.spine.outputs(), pinMask), + // Pass the HEARD (post-pipeline, routed) vector as the disliked action — + // NOT the raw MLP output. In Avoid+Geometric mode the core trains toward + // it; a raw vector equal to the net's own output gives a zero MSE + // derivative (an inert cold-start). Falls back to raw if not yet routed. + this.iml.feedbackDown( + speed, + spread, + this.spine.routedOutput() ?? this.spine.outputs(), + pinMask, + ), drag: () => this.iml.feedbackDrag(), setMode: (mode) => this.iml.feedbackSetMode(mode), getMode: () => this.iml.feedbackGetMode(), @@ -131,6 +186,24 @@ export class EngineApi { exploreState: () => this.iml.feedbackState(), undoDepth: () => this.iml.feedbackUndoDepth(), placedOutput: () => this.iml.feedbackPlacedOutput(), + dislikeGeometric: (heardVec?: Float32Array, lr = 0) => + this.iml.feedbackDislikeGeometric(heardVec, lr), + storePositive: (vec?: Float32Array) => this.iml.feedbackStorePositive(vec), + positiveCount: () => this.iml.feedbackPositiveCount(), + negativeCount: () => this.iml.feedbackNegativeCount(), + setAvoidStyle: (style) => this.iml.feedbackSetAvoidStyle(style), + }; + + this.explore = { + joltPress: () => this.iml.joltPress(), + joltStep: () => this.iml.joltStep(), + joltRelease: () => this.iml.joltRelease(), + joltActive: () => this.iml.joltActive(), + joltLrScale: () => this.iml.joltLrScale(), + joltTickLrRamp: () => this.iml.joltTickLrRamp(), + setExploreIntensity: (level) => this.iml.setExploreIntensity(level), + exploreIntensity: () => this.iml.exploreIntensity(), + exploreApply: (inout) => this.iml.exploreApply(inout), }; this.audio = { diff --git a/manifold/src/engine/exploration.ts b/manifold/src/engine/exploration.ts index 1e2f327..ad05a4f 100644 --- a/manifold/src/engine/exploration.ts +++ b/manifold/src/engine/exploration.ts @@ -1,6 +1,6 @@ /** - * exploration.ts — ExplorationController: wires the two playground exploration - * gestures onto manifold's EngineApi. + * exploration.ts — ExplorationController: wires the two exploration gestures onto + * manifold's EngineApi, driving the SHARED C++/WASM core. * * • Jolt — hold to continuously morph the network's weights live, release to * freeze (nisps/ml/jolt.hpp; firmware TogB1). @@ -8,38 +8,34 @@ * sound slowly roam so likes/dislikes can steer it (nisps/ml/ * ou_noise.hpp; firmware RVX1 = exploration amount). * - * The gestures the playground drove from `mode-runtime.ts` are re-homed here as a - * plain framework-neutral class (no Solid stores, no React) that the Console owns - * one of, once the engine resolves. The Learning drawer's Jolt button + Explore - * slider call these methods. + * This is a plain framework-neutral class (no Solid stores, no React) that the + * Console owns one of, once the engine resolves. The Learning drawer's Jolt + * button + Explore slider call these methods. * - * ─── P3 SWAP POINT ──────────────────────────────────────────────────────────── - * The maths currently runs in TS (`jolt.ts` / `ou-explore.ts`, ported from the - * retired playground). In §P3 of docs/specs/plans/one-core-engine-refactor.md it - * moves into the C++/WASM core: + * ─── one-core-engine P3 (SWAP DONE) ──────────────────────────────────────────── + * The maths now lives in the C++/WASM core; this controller is a thin driver that + * owns only the control-rate timers (the spine is push-driven — manifold has no + * always-on control loop to hook). Each gesture owns a scoped interval that + * exists ONLY while it is engaged and is torn down on release / at intensity 0: * - * joltPress(count) → nisps_ml_jolt_press - * joltRelease() → nisps_ml_jolt_release - * setExploreIntensity(l) → nisps_ml_explore_intensity + * joltPress() → engine.explore.joltPress (nisps_ml_jolt_press) + * held ~200 Hz driver → engine.explore.joltStep + engine.process + * joltRelease() → engine.explore.joltRelease (nisps_ml_jolt_release) + * setExploreIntensity(l) → engine.explore.setExploreIntensity (…_explore_intensity) + * spine setOutputMorph → engine.explore.exploreApply (…_explore_apply) * - * When that lands, ONLY THIS MODULE changes: `jolt.ts` + `ou-explore.ts` are - * deleted, the per-tick stepping below (getWeights → step → setWeights → process, - * and the OU output-morph) is removed because the core owns the control-rate - * morph + OU advance, and the calls above target the bindings. The UI keeps - * calling the same `joltPress/joltRelease/joltActive/setExploreIntensity/ - * exploreIntensity` surface — no drawer changes. + * The interim TS ports (`jolt.ts` / `ou-explore.ts`) were deleted with this swap; + * the UI keeps calling the same joltPress/joltRelease/joltActive/ + * setExploreIntensity/exploreIntensity surface — no drawer changes. * ────────────────────────────────────────────────────────────────────────────── */ import type { EngineApi } from './engine-api'; -import { Jolt } from './jolt'; -import { OUExplore } from './ou-explore'; -// Control-rate cadences, reproduced from the playground's mode-runtime.ts. -// Manifold has no always-on control-rate loop to hook (the spine is push-driven; -// the input-layer rAF only runs while a poll-based source is active), so — like -// the playground — each gesture owns a scoped interval that exists ONLY while it -// is engaged and is torn down on release / at intensity 0. Not a global timer. +// Control-rate cadences. Manifold has no always-on control-rate loop to hook (the +// spine is push-driven; the input-layer rAF only runs while a poll-based source is +// active), so each gesture owns a scoped interval that exists ONLY while it is +// engaged. Not a global timer. /** ~200Hz — matches the upstream firmware control rate the Jolt constants assume. */ const JOLT_TICK_MS = 5; /** ~33Hz — keeps the OU walk roaming when the input is static. */ @@ -47,8 +43,6 @@ const EXPLORE_TICK_MS = 30; export class ExplorationController { private readonly engine: EngineApi; - private readonly jolt = new Jolt(); - private readonly ou = new OUExplore(); private joltTimer: ReturnType | null = null; private exploreTimer: ReturnType | null = null; @@ -56,24 +50,23 @@ export class ExplorationController { constructor(engine: EngineApi) { this.engine = engine; // Register the OU walk as the spine's post-output morph. It is inert while - // intensity is 0 (apply() early-returns), so the spine stays parity-safe - // until Explore is turned up. Applied on EVERY spine tick (user input or the - // EXPLORE_TICK_MS driver), exactly as the playground applied it in - // recomputeOutputs. In P3 this becomes a core-side step, not a JS morph. - engine.spine.setOutputMorph((routed) => this.ou.apply(routed)); + // intensity is 0 (the core apply() early-returns), so the spine stays + // parity-safe until Explore is turned up. Applied on EVERY spine tick (user + // input or the EXPLORE_TICK_MS driver): copy the routed vector into the WASM + // heap, advance + add the OU offset in the core, copy back. + engine.spine.setOutputMorph((routed) => this.engine.explore.exploreApply(routed)); } // ---- Jolt (held-button continuous weight morph) -------------------------- joltActive(): boolean { - return this.jolt.active(); + return this.engine.explore.joltActive(); } /** Press-and-hold on: begin morphing a scatter of weights toward random targets. */ joltPress(): void { if (!this.engine.getState().ready) return; - const weightCount = this.engine.getWeights().length; - this.jolt.press(weightCount); + this.engine.explore.joltPress(); if (this.joltTimer === null) { this.joltTimer = setInterval(() => this.tickJolt_(), JOLT_TICK_MS); } @@ -81,7 +74,7 @@ export class ExplorationController { /** Release: freeze the weights where they landed (permanent) and stop ticking. */ joltRelease(): void { - this.jolt.release(); + this.engine.explore.joltRelease(); if (this.joltTimer !== null) { clearInterval(this.joltTimer); this.joltTimer = null; @@ -89,41 +82,39 @@ export class ExplorationController { } private tickJolt_(): void { - if (!this.jolt.active()) return; - const w = this.engine.getWeights(); - if (w.length === 0) return; - this.jolt.step(w); - this.engine.setWeights(w); - // Re-run inference so audio + visuals reflect the morphed weights without - // the user having to move the controller. + if (!this.engine.explore.joltActive()) return; + // The core does the get→glide→set of weights in one step; re-run inference so + // audio + visuals reflect the morphed weights without the user having to move + // the controller. + this.engine.explore.joltStep(); this.engine.process(); } // ---- Explore (OU exploration noise on the output) ------------------------ exploreIntensity(): number { - return this.ou.intensity(); + return this.engine.explore.exploreIntensity(); } /** * Set the exploration amount in [0,1]. >0 starts the roaming driver so the - * sound keeps wandering even when the input is static; 0 stops it and resets - * the walk so the output passes through cleanly again. + * sound keeps wandering even when the input is static; 0 stops it and flushes a + * clean output so the passthrough is parity-safe again. */ setExploreIntensity(level: number): void { - this.ou.setIntensity(level); - if (this.ou.enabled() && this.exploreTimer === null) { + this.engine.explore.setExploreIntensity(level); + const enabled = this.engine.explore.exploreIntensity() > 0; + if (enabled && this.exploreTimer === null) { this.exploreTimer = setInterval(() => { if (!this.engine.getState().ready) return; // Re-tick the last input through the spine; the registered output morph - // advances the OU state and reships the routed vector. + // advances the OU state (in the core) and reships the routed vector. this.engine.process(); }, EXPLORE_TICK_MS); - } else if (!this.ou.enabled() && this.exploreTimer !== null) { + } else if (!enabled && this.exploreTimer !== null) { clearInterval(this.exploreTimer); this.exploreTimer = null; - this.ou.reset(); - // Flush the now-clean output (no residual drift) to audio + visuals. + // Flush the now-clean output (intensity 0 → morph is inert) to audio + visuals. this.engine.process(); } } @@ -136,8 +127,7 @@ export class ExplorationController { clearInterval(this.exploreTimer); this.exploreTimer = null; } - this.ou.setIntensity(0); - this.ou.reset(); + this.engine.explore.setExploreIntensity(0); this.engine.spine.setOutputMorph(null); } } diff --git a/manifold/src/engine/index.ts b/manifold/src/engine/index.ts index b42bc8b..7fc8b7a 100644 --- a/manifold/src/engine/index.ts +++ b/manifold/src/engine/index.ts @@ -10,16 +10,15 @@ export type { EngineApiOptions, EngineAudioApi, EngineFeedbackApi, + EngineExploreApi, } from './engine-api'; export { Spine } from './spine'; export type { SpineState, BackendSend } from './spine'; -// Exploration gestures (interim TS shells; maths moves to WASM in P3). +// Exploration gestures (Jolt weight-morph + OU explore) — now backed by the +// shared C++/WASM core (nisps/ml/{jolt,ou_noise}.hpp) via engine.explore. export { ExplorationController } from './exploration'; -export { Jolt, DEFAULT_JOLT_PARAMS } from './jolt'; -export type { JoltParams } from './jolt'; -export { OUExplore, OU_MAX_AMPLITUDE } from './ou-explore'; export { WasmIML } from './wasm-iml'; export type { WasmIMLOptions } from './wasm-iml'; diff --git a/manifold/src/engine/jolt.ts b/manifold/src/engine/jolt.ts deleted file mode 100644 index b5305af..0000000 --- a/manifold/src/engine/jolt.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * jolt.ts — "Jolt": held-button continuous weight morph. - * - * Faithful TS port of `nisps/ml/jolt.hpp` (which itself ports upstream memllib - * InterfaceRL "jolts"). While a button/pedal is held, pick a handful of random - * weights scattered across the whole network and EMA-glide each toward a bounded - * random target; on arrival, re-roll the target so the motion never stops. - * Releasing freezes the weights where they landed (the change is permanent). - * - * This operates on the MLP's FLAT weight buffer (the same buffer reached via - * `EngineApi.getWeights()` / `setWeights()`), so it is architecture-agnostic. - * - * Ported verbatim from the retired `playground/src/ml/jolt.ts` (one-core-engine - * refactor §P1). This is an INTERIM TS shell — in §P3 the maths moves into the - * C++/WASM core (`nisps_ml_jolt_press/release`) and this file is deleted; the - * swap is localised to `exploration.ts` (see the P3 SWAP POINT note there). - * - * Firmware note: the canonical `nisps/ml/jolt.hpp` also owns a post-release - * learning-rate ramp (`lr_scale()` / `tick_lr_ramp()`, the RVX-driven feel). The - * playground TS port never wired that (learning stays caller-driven), so this - * interim shell matches the playground; the full ramp arrives with the P3 WASM - * binding. - * - * RNG note: the C++ owns a per-instance deterministic xoshiro256+ for - * firmware↔browser parity. This is a browser-only exploration aid that never - * round-trips through WASM, so we use `Math.random()` — the exact sequence does - * not need to match firmware. Constants are reproduced verbatim from JoltParams. - * - * DEFAULT STATE IS INERT: a freshly constructed Jolt is inactive and `step()` is - * a no-op until `press()` is called. - */ - -/** Upstream JoltParams defaults (kJolt* constants). */ -export interface JoltParams { - /** kJoltNumWeights — number of weights morphed at once. */ - numWeights: number; - /** kJoltMorphRate — EMA glide per tick (~1s @200Hz). */ - morphRate: number; - /** kJoltWeightMin — lower bound of a random target. */ - targetMin: number; - /** kJoltWeightMax — upper bound of a random target. */ - targetMax: number; - /** kJoltTargetEpsilon — re-roll target once within this distance. */ - targetEpsilon: number; -} - -export const DEFAULT_JOLT_PARAMS: JoltParams = { - numWeights: 40, - morphRate: 0.017, - targetMin: -1.2, - targetMax: 0.9, - targetEpsilon: 0.05, -}; - -export class Jolt { - private params_: JoltParams = { ...DEFAULT_JOLT_PARAMS }; - private active_ = false; - private n_ = 0; - private idx_: Int32Array = new Int32Array(0); - private target_: Float32Array = new Float32Array(0); - - constructor(params?: Partial) { - if (params) this.params_ = { ...this.params_, ...params }; - } - - setParams(p: Partial): void { - this.params_ = { ...this.params_, ...p }; - } - - get params(): Readonly { - return this.params_; - } - - active(): boolean { - return this.active_; - } - - /** - * Begin a jolt over a flat weight buffer of `weightCount` entries: pick - * `numWeights` random global indices and a bounded random target each. - */ - press(weightCount: number): void { - this.active_ = true; - this.n_ = this.params_.numWeights; - if (weightCount <= 0) { - this.n_ = 0; - return; - } - if (this.idx_.length < this.n_) this.idx_ = new Int32Array(this.n_); - if (this.target_.length < this.n_) this.target_ = new Float32Array(this.n_); - for (let i = 0; i < this.n_; ++i) { - this.idx_[i] = Math.floor(Math.random() * weightCount) % weightCount; - this.target_[i] = this.rollTarget_(); - } - } - - /** - * Per control tick while held: EMA-glide each selected weight toward its - * target, re-rolling targets that have been reached. No-op when inactive. - * Mutates `weights` in place. - */ - step(weights: Float32Array): void { - if (!this.active_) return; - const wc = weights.length; - const rate = this.params_.morphRate; - const eps = this.params_.targetEpsilon; - for (let i = 0; i < this.n_; ++i) { - const k = this.idx_[i]!; - if (k < 0 || k >= wc) continue; - let w = weights[k]!; - w += rate * (this.target_[i]! - w); - weights[k] = w; - if (Math.abs(this.target_[i]! - w) < eps) { - this.target_[i] = this.rollTarget_(); - } - } - } - - /** Release: freeze weights where they are (permanent). */ - release(): void { - this.active_ = false; - } - - private rollTarget_(): number { - return ( - this.params_.targetMin + - Math.random() * (this.params_.targetMax - this.params_.targetMin) - ); - } -} diff --git a/manifold/src/engine/ou-explore.ts b/manifold/src/engine/ou-explore.ts deleted file mode 100644 index 811aab2..0000000 --- a/manifold/src/engine/ou-explore.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * ou-explore.ts — Ornstein-Uhlenbeck exploration noise on the output. - * - * Faithful TS port of `nisps/ml/ou_noise.hpp`. Adds a per-output-channel - * Ornstein-Uhlenbeck random walk to the network's action (output) vector before - * it reaches audio. Unlike i.i.d. per-frame noise, an OU process is temporally - * correlated: each output drifts in long, smooth sweeps and is gently pulled back - * toward the mapping output (mean reversion). Because learning stays active while - * the noise roams, "likes" registered during the wander steer the network toward - * sounds the player wants. - * - * Discrete Euler-Maruyama update, per output channel x (mu = 0): - * x += theta * (-x) * dt + noiseScale * N(0,1) - * out = clamp(out + x, 0, 1) - * where, to make the stationary std equal a requested `std`: - * noiseScale = std * sqrt(2 * theta * dt) - * and the exploration knob [0,1] maps std = level * kMaxAmplitude (0.65). - * - * Ported verbatim from the retired `playground/src/output/ou-explore.ts` - * (one-core-engine refactor §P1). This is an INTERIM TS shell — in §P3 the maths - * moves into the C++/WASM core (`nisps_ml_explore_intensity`) and this file is - * deleted; the swap is localised to `exploration.ts` (see its P3 SWAP POINT). - * Firmware maps RVX1 to this exploration amount. - * - * RNG note: the C++ owns a per-instance deterministic xoshiro256+ for - * firmware↔browser parity. This is a browser-only exploration aid that never - * round-trips through WASM, so `Math.random()` is fine. The gaussian is - * reproduced as the same sum-of-three-uniforms shape the C++ `Rng` uses - * (`next_float_gaussian`). - * - * DEFAULT STATE IS INERT: intensity defaults to 0, `enabled()` is false, and - * `apply()` neither advances the RNG nor touches the output — so the output - * passes through bit-identically when intensity is 0 (parity-safe). - */ - -/** Upstream kMaxAmplitude: the exploration knob's full-scale stationary std. */ -export const OU_MAX_AMPLITUDE = 0.65; - -/** Sum-of-three-uniforms gaussian (matches nisps::Rng::next_float_gaussian). */ -function gaussian(stddev = 1): number { - // Three uniforms in [-1, 1): variance 1/3 each ⇒ sum has variance 1. - const a = Math.random() * 2 - 1; - const b = Math.random() * 2 - 1; - const c = Math.random() * 2 - 1; - return (a + b + c) * stddev; -} - -export class OUExplore { - private theta_ = 0.02; - private dt_ = 0.001; - private stationaryStd_ = 0; - private noiseScale_ = 0; - private state_: Float32Array = new Float32Array(0); - - constructor() { - this.recomputeScale_(); - } - - /** - * Exploration amount in [0,1]; 0 disables (inert). Maps to the OU stationary - * std = level * kMaxAmplitude. - */ - setIntensity(level: number): void { - let l = level; - if (l < 0) l = 0; - else if (l > 1) l = 1; - this.stationaryStd_ = l * OU_MAX_AMPLITUDE; - this.recomputeScale_(); - } - - intensity(): number { - return this.stationaryStd_ / OU_MAX_AMPLITUDE; - } - - enabled(): boolean { - return this.stationaryStd_ > 0; - } - - setTheta(theta: number): void { - this.theta_ = theta; - this.recomputeScale_(); - } - - setDt(dt: number): void { - this.dt_ = dt; - this.recomputeScale_(); - } - - /** - * Advance the per-channel OU state and add it (clamped) to `out`, mutating - * `out` in place. No-op (no state advance, no output change) when disabled. - * `out` is the post-inference parameter vector. - */ - apply(out: Float32Array): void { - if (!this.enabled()) return; - const n = out.length; - if (this.state_.length < n) { - // Grow the per-channel state, preserving existing drift. - const next = new Float32Array(n); - next.set(this.state_); - this.state_ = next; - } - const theta = this.theta_; - const dt = this.dt_; - const scale = this.noiseScale_; - for (let i = 0; i < n; ++i) { - // mu = 0: the walk is an offset that mean-reverts to zero, so the - // network's own mapping output stays the anchor. - let s = this.state_[i]!; - s += theta * -s * dt + scale * gaussian(1); - this.state_[i] = s; - let v = out[i]! + s; - if (v < 0) v = 0; - else if (v > 1) v = 1; - out[i] = v; - } - } - - reset(): void { - this.state_.fill(0); - } - - private recomputeScale_(): void { - let k = 2 * this.theta_ * this.dt_; - if (k < 0) k = 0; - this.noiseScale_ = this.stationaryStd_ * Math.sqrt(k); - } -} diff --git a/manifold/src/engine/spine.ts b/manifold/src/engine/spine.ts index 38afc64..518a7ff 100644 --- a/manifold/src/engine/spine.ts +++ b/manifold/src/engine/spine.ts @@ -110,6 +110,17 @@ export class Spine implements EngineSink { private outputMorph: ((routed: Float32Array) => void) | null = null; private lastTickMs = 0; + // Optional fixed per-tick dt (seconds). null ⇒ real wall-clock dt. Set under + // ?debug=1 (see App.tsx) so the input/output pipeline smoothing + momentum are + // deterministic across runs — the pipelines otherwise read performance.now(), + // which makes single-tick EMA lag (and therefore feedback/inference deltas) + // timing-dependent and flaky in tests. Production keeps real-time dt. + private fixedDt: number | null = null; + + /** Pin a deterministic per-tick dt (seconds), or null to use wall-clock. */ + setFixedDt(dt: number | null): void { + this.fixedDt = dt !== null && dt > 0 ? dt : null; + } // ---- EngineSink ---------------------------------------------------- @@ -271,8 +282,10 @@ export class Spine implements EngineSink { return this.setInputs(this.lastRawInputs); } - /** Monotonic per-tick dt in seconds (≈1/60 on the first tick). */ + /** Monotonic per-tick dt in seconds (≈1/60 on the first tick). A pinned + * {@link fixedDt} (debug) overrides the wall-clock delta for determinism. */ private dt_(): number { + if (this.fixedDt !== null) return this.fixedDt; const now = (typeof performance !== 'undefined' ? performance.now() : Date.now()); const dt = this.lastTickMs > 0 ? (now - this.lastTickMs) / 1000 : 1 / 60; this.lastTickMs = now; diff --git a/manifold/src/engine/types.ts b/manifold/src/engine/types.ts index 4b64026..568f11b 100644 --- a/manifold/src/engine/types.ts +++ b/manifold/src/engine/types.ts @@ -100,6 +100,35 @@ export interface NispsModule { // Writes placed/committed output (outputSize floats) into out; returns 1 if written. _nisps_ml_feedback_placed_output(ml: number, out_ptr: number): number; + // Geometric dislike (one-core-engine P3; rl-feedback-design §2.1). The Avoid + // mode's default realisation, ported from firmware InterfaceRL. current_out + // may be null (0) → the MLP's live output is used (zero-derivative caveat: + // pass the HEARD post-pipeline vector for an audible push). lr <= 0 → the + // controller default (1e-3). Returns the FeedbackAction int (14=GeometricPush, + // 15=GeometricColdStart when no positives exist yet). + _nisps_ml_feedback_dislike_geometric(ml: number, current_out_ptr: number, lr: number): number; + // Store a positive (like) into the replay memory so the k-NN centroid sees it. + // current_out may be null (live output used). Caller still runs addExample+train. + _nisps_ml_feedback_store_positive(ml: number, current_out_ptr: number): void; + _nisps_ml_feedback_positive_count(ml: number): number; + _nisps_ml_feedback_negative_count(ml: number): number; + // Avoid sub-mode: 0 = Geometric (default), 1 = Diffuse (legacy move_weights, A/B). + _nisps_ml_feedback_set_avoid_style(ml: number, style: number): void; + + // Jolt (held weight morph) + OU exploration noise (one-core-engine P3.2) — the + // SAME nisps/ml/{jolt,ou_noise}.hpp the firmware ModeBase runs. jolt_step does + // the get→glide→set of weights C-side; explore_apply advances the OU walk and + // adds it (clamped [0,1]) to the first min(n, n_out) floats in place. + _nisps_ml_jolt_press(ml: number): void; + _nisps_ml_jolt_step(ml: number): void; + _nisps_ml_jolt_release(ml: number): void; + _nisps_ml_jolt_active(ml: number): number; // 1 = held/active + _nisps_ml_jolt_lr_scale(ml: number): number; // post-release LR ramp multiplier + _nisps_ml_jolt_tick_lr_ramp(ml: number): void; + _nisps_ml_explore_intensity(ml: number, level: number): void; + _nisps_ml_explore_get_intensity(ml: number): number; + _nisps_ml_explore_apply(ml: number, inout_ptr: number, n: number): void; + // Engines. _nisps_engine_create(id_ptr: number, sample_rate: number): number; _nisps_engine_destroy(engine: number): void; diff --git a/manifold/src/engine/wasm-iml.ts b/manifold/src/engine/wasm-iml.ts index 8c58160..67106d8 100644 --- a/manifold/src/engine/wasm-iml.ts +++ b/manifold/src/engine/wasm-iml.ts @@ -719,6 +719,119 @@ export class WasmIML { return new Float32Array(this.feedbackBuf.view.subarray(0, this.arch_.outputSize)); } + // ------------------------------------------------------------------- + // Geometric dislike (one-core-engine P3; rl-feedback-design §2.1) + // ------------------------------------------------------------------- + + /** + * Geometric dislike: push the current mapping away from the liked centroid. + * `heardVec` is the kDefaultOutputs vector the user is HEARING (post-pipeline — + * with a null/raw vector the cold-start has a zero MSE derivative and is inert). + * `lr <= 0` uses the C++ controller default. Mutates weights → republishes. + * Returns the FeedbackAction int (14=GeometricPush, 15=GeometricColdStart). + */ + feedbackDislikeGeometric(heardVec?: Float32Array, lr = 0): number { + let outPtr = 0; + if (heardVec) { + const n = Math.min(heardVec.length, this.arch_.outputSize); + this.feedbackBuf.view.fill(0); + this.feedbackBuf.view.set(heardVec.subarray(0, n)); + outPtr = this.feedbackBuf.ptr; + } + const action = this.module._nisps_ml_feedback_dislike_geometric(this.mlHandle, outPtr, lr); + this.publishWeights_(); + this.sink.emit('feedback.down', { action }); + this.scheduleSave_(); + return action; + } + + /** + * Feed a positive (like) into the replay memory so the k-NN centroid sees it. + * `vec` is the heard output at the liked input (null → the live MLP output). + * No weight mutation; the caller still runs addExample + train. + */ + feedbackStorePositive(vec?: Float32Array): void { + let outPtr = 0; + if (vec) { + const n = Math.min(vec.length, this.arch_.outputSize); + this.feedbackBuf.view.fill(0); + this.feedbackBuf.view.set(vec.subarray(0, n)); + outPtr = this.feedbackBuf.ptr; + } + this.module._nisps_ml_feedback_store_positive(this.mlHandle, outPtr); + } + + feedbackPositiveCount(): number { + return this.module._nisps_ml_feedback_positive_count(this.mlHandle); + } + + feedbackNegativeCount(): number { + return this.module._nisps_ml_feedback_negative_count(this.mlHandle); + } + + /** Avoid sub-mode: 0 = Geometric (default), 1 = Diffuse (legacy, A/B). */ + feedbackSetAvoidStyle(style: number): void { + this.module._nisps_ml_feedback_set_avoid_style(this.mlHandle, style); + } + + // ------------------------------------------------------------------- + // Jolt (held weight morph) + OU exploration (one-core-engine P3.2). + // The shared nisps/ml/{jolt,ou_noise}.hpp the firmware ModeBase runs. + // ------------------------------------------------------------------- + + /** Begin a jolt over the flat weight buffer (held-button continuous morph). */ + joltPress(): void { + this.module._nisps_ml_jolt_press(this.mlHandle); + } + + /** One ~200 Hz morph tick while held (no-op when inactive). C-side get→glide→ + * set of the flat weights; republish so weight-health views + persistence follow. */ + joltStep(): void { + this.module._nisps_ml_jolt_step(this.mlHandle); + this.publishWeights_(); + this.scheduleSave_(); + } + + /** Release: freeze the weights where they landed (permanent). */ + joltRelease(): void { + this.module._nisps_ml_jolt_release(this.mlHandle); + } + + joltActive(): boolean { + return this.module._nisps_ml_jolt_active(this.mlHandle) === 1; + } + + /** Post-release LR-ramp multiplier (0 while held → 1 over ~5 s of ticks). */ + joltLrScale(): number { + return this.module._nisps_ml_jolt_lr_scale(this.mlHandle); + } + + joltTickLrRamp(): void { + this.module._nisps_ml_jolt_tick_lr_ramp(this.mlHandle); + } + + /** Exploration amount in [0,1]; 0 disables (inert — parity-safe). */ + setExploreIntensity(level: number): void { + this.module._nisps_ml_explore_intensity(this.mlHandle, level); + } + + exploreIntensity(): number { + return this.module._nisps_ml_explore_get_intensity(this.mlHandle); + } + + /** + * Advance the OU walk and add it (clamped to [0,1]) to `inout` IN PLACE. No-op + * at intensity 0. `inout` is the routed (post-pipeline) vector; only the first + * min(inout.length, n_out) values are touched (via the shared feedbackBuf heap). + */ + exploreApply(inout: Float32Array): void { + const n = Math.min(inout.length, this.arch_.outputSize); + if (n <= 0) return; + this.feedbackBuf.view.set(inout.subarray(0, n)); + this.module._nisps_ml_explore_apply(this.mlHandle, this.feedbackBuf.ptr, n); + inout.set(this.feedbackBuf.view.subarray(0, n)); + } + // ------------------------------------------------------------------- // Weights I/O // ------------------------------------------------------------------- diff --git a/manifold/src/feedback/controller.ts b/manifold/src/feedback/controller.ts index a815c81..f1a39d2 100644 --- a/manifold/src/feedback/controller.ts +++ b/manifold/src/feedback/controller.ts @@ -11,21 +11,21 @@ * actions + state into the console context; VerdictCluster + Manifold drive it. * * It talks ONLY to the small primitive surface of EngineApi: - * getWeights / setWeights — snapshot + restore (byte round-trip) - * randomise() — draw_weights, re-roll the whole net * setInput(x,y) / getOutputs() — synchronous forward inference (the spine) * process() — re-run last input after a weight change * addExample([x,y], outVec) — append a training example * train() — SGD over the dataset - * feedback.{setFocus,thumbsDown,thumbsUp} — engine's RL primitives (Mode 1) + * feedback.{setFocus,thumbsUp,dislikeGeometric,storePositive,…} + * — the SHARED C++ core's RL primitives * - * Everything the design plans to push into the C++ core (geometric push-away, - * the scratch-undo ring, the column-freeze gradient mask, the warm-start - * interpolation loop) is implemented here in TS and CLEARLY COMMENTED as the - * approximation it is, with a pointer to where the real core primitive lands. + * As of one-core-engine P3 the geometric push-away (Mode 1) is a C++ core + * primitive (nisps/ml/{geo_push,replay}.hpp + mlp.train_targets), driven from + * here via engine.feedback.dislikeGeometric; the scratch-undo ring + snapshot + * lifecycle (Mode 2) are the shared core too. This controller now holds NO + * algorithm approximation — it is a thin driver over the core primitives plus + * the per-session anchor list for the multi-anchor warm-start. */ -import { SeededRng } from './rng'; import type { FeedbackMode } from '../engine/types'; /** The two product feedback modes (rl-feedback-design §0). */ @@ -65,8 +65,15 @@ export interface ControllerEngine { train(): number; readonly feedback: { thumbsUp(): number; - thumbsDown(speed?: number, spread?: number, pinMask?: Uint8Array): number; setFocus(mask: Uint8Array | null): void; + // Geometric dislike (Mode 1) — the SHARED C++ core (nisps/ml/geo_push.hpp). + // `heardVec` is the post-pipeline (HEARD) output; returns the FeedbackAction + // int (14=GeometricPush, 15=GeometricColdStart). + dislikeGeometric(heardVec?: Float32Array, lr?: number): number; + /** Feed a positive into the k-NN centroid (null → live MLP output). */ + storePositive(vec?: Float32Array): void; + positiveCount(): number; + negativeCount(): number; // ExploreAndPlace lifecycle — the SHARED C++ core (mode 'explore_and_place'). // The controller drives these instead of its own getWeights/setWeights/ // randomise scratchpad logic, so explore-and-place runs identically in the @@ -101,6 +108,10 @@ export interface FeedbackControllerState { undoDepth: number; /** Count of currently-armed (soloed) outputs; 0 ⇒ none armed ⇒ train all. */ armedCount: number; + /** Positives in the C++ core's replay memory (k-NN centroid; Mode 1). */ + positiveCount: number; + /** Negatives in the C++ core's replay memory (Mode 1 dislikes). */ + negativeCount: number; } export interface FeedbackControllerOptions { @@ -119,7 +130,6 @@ export interface FeedbackControllerOptions { export class FeedbackController { private engine: ControllerEngine; - private rng: SeededRng; private spread: number; private nudgeStddev: number; private maxUndo: number; @@ -144,25 +154,12 @@ export class FeedbackController { /** Current arm mask (1=armed/soloed). null ⇒ none armed ⇒ train all. */ private armMask: Uint8Array | null = null; - // ---- Mode-1 dislike memory (TS approximation) ---------------------- - /** - * Disliked (input → output) pairs. The TRUE firmware geometric push (upstream - * 0a541cc, replay-backed) computes a k-NN positive centroid and pushes the - * disliked action away from it, then trains toward that target. We cannot do - * that on the existing primitives without the C++ replay store + train_targets - * hook, so the TS prototype: - * (a) calls the engine's existing feedback.thumbsDown() (AVOID/move_weights) - * as the audible baseline, AND - * (b) records the disliked pair here so subsequent training can bias AWAY - * from it (a coarse example-level approximation — see applyDislikeBias). - * Documented C++ gap: the directed geometric push-away lands in the core as - * `geo_push.hpp` + `replay.hpp` + `mlp.train_targets` (rl-feedback-design §4). - */ - private dislikes: { input: readonly [number, number]; output: Float32Array }[] = []; + // Mode-1 dislike memory now lives entirely in the C++ core's ReplayStore + // (positives + negatives, k-NN centroid), fed via engine.feedback.storePositive + // / dislikeGeometric. No TS-side mirror. constructor(engine: ControllerEngine, opts: FeedbackControllerOptions = {}) { this.engine = engine; - this.rng = new SeededRng(opts.seed ?? 0xfeedbacc); this.spread = opts.spread ?? 0.6; this.nudgeStddev = opts.nudgeStddev ?? 0.05; this.maxUndo = Math.max(1, opts.undoDepth ?? 4); @@ -358,110 +355,58 @@ export class FeedbackController { // =================================================================== /** - * DISLIKE (thumbs-down in Mode 1). Push the current mapping away from the - * disliked sound. + * DISLIKE (thumbs-down in Mode 1). Push the current mapping away from the liked + * centroid — the SHARED C++ core's geometric push (nisps/ml/geo_push.hpp + + * replay.hpp + mlp.train_targets, ported from upstream InterfaceRL 0a541cc). * - * PROTOTYPE: we use the engine's existing feedback.thumbsDown() (AVOID / - * move_weights — undirected Gaussian diffusion, the baseline) as the audible - * effect, AND record the disliked (input → output) so a subsequent like+train - * can bias away from it (applyDislikeBias). + * The core uses the MLP's CURRENT input and the passed HEARD output vector: + * 1. stores the negative (input, a_neg) in the ReplayStore (dedup within 0.05) + * 2. k-NN(k=4) centroid of positives near the input + * 3. target[j] = clamp(a_neg[j] + dir/||dir|| · pushStep/(1+||dir||), 0, 1) + * 4. trains toward that target at lr·negLRRatio + * 5. cold-start fallback (negative-LR) when there are no positives yet. + * Soloed/active dims come from the core's focus mask (set via setArmMask). * - * --- C++ GAP (the real firmware behaviour) ----------------------------- - * The true geometric push-away (upstream 0a541cc, replay-backed, - * InterfaceRL.cpp:602-738) is: - * 1. store the negative (input, action) in a ReplayStore (dedup within 0.05) - * 2. compute the k-NN(k=4) centroid of POSITIVE memories near the input - * 3. target[j] = clamp(neg[j] + dir/||dir|| * pushStep/(1+||dir||), 0, 1) - * where dir[j] = neg[j] - meanPositive[j] (away from the liked centroid) - * 4. train the net toward that computed `target` at lr*negLRRatio - * 5. cold-start fallback when there are no positives yet. - * This needs `replay.hpp`, `geo_push.hpp`, and `mlp.train_targets` (train - * toward arbitrary COMPUTED targets, which the existing train()/addExample() - * cannot do — they only train toward STORED labels). It lands in the C++ core - * in rl-feedback-design Phase 1 (§5). Until then this TS prototype keeps the - * baseline move_weights effect plus example-level bias. - * ---------------------------------------------------------------------- - * - * @param input the control input the disliked sound was heard at - * @param output the heard 126-dim output vector (a_neg) - * @param speed move_weights speed (noise cap) - * @param spread move_weights spread + * @param input the control input the disliked sound was heard at (unused by + * the core — it reads the MLP's live input — kept for the marker / + * call-site symmetry with like()). + * @param output the HEARD (post-pipeline) output vector a_neg. MUST be the + * heard vector, not the raw MLP output, or the cold-start MSE + * derivative is zero (see engine.feedback.dislikeGeometric). + * @param _speed legacy move_weights speed — ignored (geometric path). + * @param _spread legacy move_weights spread — ignored (geometric path). + * @returns the FeedbackAction int (14=GeometricPush, 15=GeometricColdStart). */ dislike( input: readonly [number, number], output: Float32Array, - speed: number, - spread: number, - ): void { - // Record the disliked pair (the firmware ReplayStore negative). Dedup within - // a coarse radius so repeated dislikes near each other don't pile up — a - // cheap stand-in for the firmware `deepen_or_store_negative(radius=0.05)`. - const RADIUS = 0.05; - const near = this.dislikes.find( - (d) => - Math.hypot(d.input[0] - input[0], d.input[1] - input[1]) <= RADIUS, - ); - if (near) { - near.output = new Float32Array(output); - } else { - this.dislikes.push({ input: [input[0], input[1]], output: new Float32Array(output) }); - } - // Audible baseline: the engine's existing AVOID move_weights, focus-gated by - // the arm mask (the only directional gating the primitive offers today). - this.engine.feedback.thumbsDown(speed, spread, this.armMask ?? undefined); + _speed: number, + _spread: number, + ): number { + void input; + const action = this.engine.feedback.dislikeGeometric(output); this.engine.process(); + return action; } /** - * LIKE + train (thumbs-up in Mode 1). Store the current (input → output) as a - * positive example and train. In firmware this also feeds the positive - * centroid (replay.store(+1,…)); here it is a normal addExample + train, with - * an optional bias away from recorded dislikes. + * LIKE + train (thumbs-up in Mode 1). Feeds the positive into the C++ core's + * k-NN centroid (via the core's thumbsUp auto-store, ADR §2.1) AND stores the + * current (input → output) as a positive example, then trains — exactly the two + * effects the design asks for. No TS dislike-bias any more (the core owns the + * geometric push). */ like(input: readonly [number, number], output: Float32Array): void { this.engine.feedback.setFocus(this.armMask); + // The core's thumbsUp, in Avoid+Geometric mode, feeds the positive centroid + // (store_positive with the live MLP output). Keep the mode 'avoid' invariant + // (setMode maps 'geometric-dislike' → 'avoid') so this path is active. + this.engine.feedback.thumbsUp(); this.engine.addExample([input[0], input[1]], Array.from(output)); - this.applyDislikeBias(); this.engine.train(); this.engine.process(); } - /** - * Coarse example-level bias AWAY from disliked sounds (the TS approximation of - * the geometric push). For each recorded dislike we add a "repelled" example: - * an example at the disliked input whose output is nudged away from the - * disliked vector toward the dataset mean. This is a WEAK stand-in — it biases - * the trainer rather than computing a true centroid-relative push. - * - * --- C++ GAP ----------------------------------------------------------- - * Replaced by `geo_push.compute_push_targets` + `train_targets` in the C++ - * core (rl-feedback-design §4). Intentionally conservative here so it never - * destabilises the net before any positives exist (the `posMemCount==0` - * cold-start fallback the design ports faithfully). - * ---------------------------------------------------------------------- - */ - private applyDislikeBias(): void { - // No-op when there are no dislikes; conservative cold-start (do nothing - // destabilising) when there is nothing to push away from yet. - if (this.dislikes.length === 0) return; - for (const d of this.dislikes) { - const out = new Float32Array(d.output.length); - // Push each dim of the disliked output toward its complement (0.5 pivot) — - // a direction-free repulsion stand-in. Respect the arm mask: only move - // armed dims; leave others at the disliked value (don't-care). - for (let j = 0; j < out.length; j++) { - const armed = !this.armMask || this.armMask[j] === 1; - if (armed) { - const v = d.output[j]; - out[j] = Math.max(0, Math.min(1, v + (0.5 - v) * 0.6)); - } else { - out[j] = d.output[j]; - } - } - this.engine.addExample([d.input[0], d.input[1]], Array.from(out)); - } - } - // =================================================================== // State snapshot // =================================================================== @@ -478,6 +423,9 @@ export class FeedbackController { // Scratchpad undo depth now comes from the shared C++ core's undo ring. undoDepth: this.exploringFlag ? this.engine.feedback.undoDepth() : 0, armedCount: armed, + // Replay-memory sizes from the C++ core (cheap int reads). + positiveCount: this.engine.feedback.positiveCount(), + negativeCount: this.engine.feedback.negativeCount(), }; } diff --git a/manifold/src/feedback/index.ts b/manifold/src/feedback/index.ts index fda4a1b..fe203c9 100644 --- a/manifold/src/feedback/index.ts +++ b/manifold/src/feedback/index.ts @@ -1,10 +1,10 @@ /** * The learning-engine behaviour module (workstream B) — the two feedback modes - * plus solo, prototyped in TS on the existing engine primitives. + * plus solo. As of one-core-engine P3 the algorithms (geometric dislike, the + * scratchpad/undo lifecycle, the seeded RNG) live in the shared C++/WASM core; + * the FeedbackController is a thin driver over those primitives. * - * See docs/adr/rl-feedback-design.md for the authoritative design and the - * C++ integration plan. Everything here is the TS-prototype-first layer; the - * controller comments mark each place that becomes a C++ core primitive. + * See docs/adr/rl-feedback-design.md for the authoritative design. */ export { FeedbackController, @@ -15,4 +15,3 @@ export { type FeedbackControllerState, type FeedbackControllerOptions, } from './controller'; -export { SeededRng } from './rng'; diff --git a/manifold/src/feedback/rng.ts b/manifold/src/feedback/rng.ts deleted file mode 100644 index f4454c6..0000000 --- a/manifold/src/feedback/rng.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Deterministic seeded RNG for the feedback controller's hot path. - * - * The rl-feedback-design (§6) mandates: "every new operation is deterministic - * f32 arithmetic on the per-instance `nisps::Rng` (no libc `rand()` anywhere)". - * In the C++ core the controller owns a `nisps::Rng` seeded from - * `kSeed ^ kFeedbackSalt`. This TS prototype mirrors that discipline so that the - * `nudge` perturbation is reproducible run-to-run (no `Math.random` in the - * core path — see the task CONSTRAINTS). - * - * Implementation: a small splitmix64-style integer generator reduced to f32. - * This is NOT bit-identical to the C++ `nisps::Rng` — when the geometric push / - * nudge becomes a C++ core primitive (rl-feedback-design §4), the seeded stream - * must come from `nisps::Rng` so native==WASM parity holds. Here it only needs - * to be deterministic *within* the prototype. - * - * --- C++ GAP ------------------------------------------------------------- - * The true firmware nudge perturbs weights with `move_weights(speed, spread)` - * driven by the controller's `nisps::Rng`. This TS RNG is a stand-in so the - * prototype is reproducible; it will be REPLACED by the engine's own Rng stream - * once `nisps_ml_feedback_nudge` exists (rl-feedback-design §4 "TS"). - * ------------------------------------------------------------------------ - */ - -export class SeededRng { - // 64-bit state held as two 32-bit halves (BigInt would be cleaner but we keep - // to plain number maths to avoid any per-call BigInt allocation in the hot - // nudge loop). - private state: number; - - constructor(seed: number) { - // Fold the seed into a non-zero 32-bit state. - this.state = (seed ^ 0x9e3779b9) >>> 0; - if (this.state === 0) this.state = 0x1234567; - } - - /** Next uniform float in [0, 1). xorshift32 — deterministic, allocation-free. */ - nextFloat(): number { - let x = this.state; - x ^= x << 13; - x >>>= 0; - x ^= x >>> 17; - x ^= x << 5; - x >>>= 0; - this.state = x; - // Map to [0,1) using the top 24 bits for a clean float mantissa. - return (x >>> 8) / 0x01000000; - } - - /** Next uniform float in [-1, 1). */ - nextFloatSigned(): number { - return this.nextFloat() * 2 - 1; - } - - /** - * Approximate gaussian via the sum-of-three-uniforms method the nisps core - * uses (`gen_randn` in MEMORY.md: sum of 3 uniforms). Mean 0, the given - * standard deviation. Allocation-free. - */ - nextGaussian(stddev: number): number { - const u = this.nextFloatSigned() + this.nextFloatSigned() + this.nextFloatSigned(); - return u * stddev; - } -} diff --git a/manifold/tests/e2e/geo-dislike.spec.ts b/manifold/tests/e2e/geo-dislike.spec.ts new file mode 100644 index 0000000..07835fa --- /dev/null +++ b/manifold/tests/e2e/geo-dislike.spec.ts @@ -0,0 +1,72 @@ +/** + * Geometric dislike (feedback Mode 1) — the SHARED C++/WASM core primitive + * (one-core-engine P3; rl-feedback-design §2.1). Drives the probe's read-only + * count accessors + the dislikeGeometric driver. + * + * The core reads the MLP's CURRENT input and the passed HEARD (post-pipeline) + * output vector. With NO positives it runs the cold-start fallback and returns + * FeedbackAction 15 (GeometricColdStart); with positives it computes the k-NN + * centroid push-away target and returns 14 (GeometricPush). + */ +import { test, expect } from '@playwright/test'; +import { loadProbe, settleInputs, countChanged, allWithin } from './helpers'; + +// Fixed by the WASM build (`nisps/wasm/bindings.cpp`: MLP<32,10,14,18,126>). +const N_OUTPUTS = 126; +// A heard vector deliberately distinct from any plausible net output. +const HEARD = new Array(N_OUTPUTS).fill(0.9); + +test.describe('geometric dislike (Mode 1) — core-backed', () => { + test.beforeEach(async ({ page }) => { + await loadProbe(page); + }); + + test('dislike before any likes returns cold-start (15) and stores a negative', async ({ page }) => { + const r = await page.evaluate((heard) => { + const p = window.__nisps!; + p.setFeedbackMode('avoid'); // geometric dislike proto mode maps to core Avoid + p.setAvoidStyle(0); // Geometric (default) + p.setInputs(0.5, 0.5); + const action = p.dislikeGeometric(heard); + return { action, counts: p.feedbackCounts() }; + }, HEARD); + expect(r.action).toBe(15); // GeometricColdStart + expect(r.counts.positive).toBe(0); + expect(r.counts.negative).toBe(1); + }); + + test('two likes then a dislike pushes (14), changes the disliked output, stays bounded', async ({ page }) => { + await page.evaluate(() => { + window.__nisps!.setFeedbackMode('avoid'); + window.__nisps!.setAvoidStyle(0); + }); + + // Like at two distinct inputs — the core's thumbsUp auto-stores a positive + // into the k-NN centroid while in Avoid+Geometric mode (ADR §2.1). + await settleInputs(page, 0.2, 0.3); + await page.evaluate(() => window.__nisps!.thumbsUp()); + await settleInputs(page, 0.8, 0.7); + await page.evaluate(() => window.__nisps!.thumbsUp()); + + // Settle at a third input and capture the heard-≠-output baseline there. + const before = await settleInputs(page, 0.5, 0.5); + // Sanity: the heard vector we will pass genuinely differs from the outputs. + expect(countChanged(before, HEARD, 1e-3)).toBeGreaterThan(0); + + // Dislike at the settled input with an explicit lr for a visibly-audible push. + const res = await page.evaluate((heard) => { + const p = window.__nisps!; + const action = p.dislikeGeometric(heard, 1.0); + return { action, counts: p.feedbackCounts() }; + }, HEARD); + + // Re-settle at the same input; the weights moved, so the output must too. + const after = await settleInputs(page, 0.5, 0.5); + + expect(res.action).toBe(14); // GeometricPush (positives exist → not cold-start) + expect(res.counts.positive).toBeGreaterThanOrEqual(2); + expect(res.counts.negative).toBe(1); + expect(countChanged(before, after, 1e-4)).toBeGreaterThan(0); + expect(allWithin(after, 0, 1)).toBe(true); + }); +}); diff --git a/manifold/tests/e2e/probe-api.spec.ts b/manifold/tests/e2e/probe-api.spec.ts index 07a2d60..e050030 100644 --- a/manifold/tests/e2e/probe-api.spec.ts +++ b/manifold/tests/e2e/probe-api.spec.ts @@ -81,13 +81,36 @@ test.describe('ML engine — debug probe contract', () => { expect(count).toBeGreaterThanOrEqual(0); }); - test('thumbsDown moves weights and changes outputs', async ({ page }) => { - await page.evaluate(() => window.__nisps!.setInputs(0.3, 0.7)); - const before = await getOutputs(page); - await page.evaluate(() => window.__nisps!.thumbsDown()); - await page.evaluate(() => window.__nisps!.setInputs(0.3, 0.7)); - const after = await getOutputs(page); - expect(countChanged(before, after, 1e-4)).toBeGreaterThan(0); + test('thumbsDown returns a finite action; a geometric dislike changes outputs', async ({ page }) => { + // Under the geometric-dislike core (one-core-engine P3) a dislike trains AWAY + // from the HEARD (post-pipeline) vector. Passing the net's OWN output — as the + // bare thumbsDown probe does — is intentionally inert (zero MSE derivative), so + // we assert only its SHAPE there and drive a real, distinct heard vector for + // the behaviour. Everything runs in ONE evaluate so the app's input rAF loop + // cannot drift the input-pipeline EMA between reads (which would make the delta + // timing-dependent). + const r = await page.evaluate((n) => { + const p = window.__nisps!; + p.setFeedbackMode('avoid'); // geometric dislike proto mode maps to core Avoid + p.setAvoidStyle(0); // Geometric (default) + p.setInputs(0.3, 0.7); + // Contract: thumbsDown returns a finite FeedbackAction and never throws. + const action = p.thumbsDown(); + // Behaviour: a dislike with a heard vector DISTINCT from the output trains a + // real push → outputs change deterministically. + const before = Array.from(p.getOutputs()); + const heard = new Array(n).fill(0.9); + p.dislikeGeometric(heard, 1.0); // trains + re-processes at the same input + const after = Array.from(p.getOutputs()); + let changed = 0; + for (let i = 0; i < before.length; ++i) { + if (Math.abs(before[i]! - after[i]!) > 1e-4) ++changed; + } + return { action, changed }; + }, N_OUTPUTS); + expect(typeof r.action).toBe('number'); + expect(Number.isFinite(r.action)).toBe(true); + expect(r.changed).toBeGreaterThan(0); }); test('addExample reports success and bumps the example count', async ({ page }) => {