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.
+
+
+
| 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 }) => {