feat(manifold): merge P3 TS switch — geometric dislike + jolt/OU on the C++ core

This commit is contained in:
monkey-w1n5t0n 2026-07-14 04:55:25 +02:00
commit a36b58dce8
17 changed files with 578 additions and 524 deletions

View file

@ -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,

View file

@ -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 (
<div
@ -52,7 +73,7 @@ function ProbeInstaller() {
export function App() {
return (
<EngineProvider fallback={<Loading />}>
<EngineProvider options={engineOptions()} fallback={<Loading />}>
<ProbeInstaller />
<ConsoleApp focus="composite" />
</EngineProvider>

View file

@ -119,6 +119,10 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
const [soloMode, setSoloMode] = useState<SoloMode>('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 && (
<div
style={{
position: 'absolute',
top: 12,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 31,
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '8px 14px',
background: 'var(--glass)',
backdropFilter: 'blur(14px)',
WebkitBackdropFilter: 'blur(14px)',
border: '1px solid var(--accent)',
borderRadius: 'var(--r-pill)',
fontFamily: 'var(--font-mono)',
fontSize: 'var(--fs-xs)',
color: 'var(--accent)',
}}
>
<span style={{ fontWeight: 600 }}>
Like a few sounds first so the system knows what to move away from.
</span>
<button
type="button"
onClick={() => setColdStart(false)}
title="Dismiss"
style={pillBtn('var(--fg-mute)')}
>
dismiss
</button>
</div>
)}
{/* Exploring-scratchpad banner (workstream B; rl-feedback §2.2 §7). */}
{exploring && (
<div

View file

@ -44,6 +44,20 @@ export interface DebugProbe {
getFeedbackMode(): FeedbackMode | null;
setFocus(mask: ReadonlyArray<number> | 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<number>, lr?: number): number;
/** Feed a positive into the k-NN centroid (omit vec → live MLP output). */
storePositive(vec?: ReadonlyArray<number>): 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<number>;
randomise(): void;
@ -154,6 +168,27 @@ function makeProbe(engine: EngineApi): DebugProbe {
return engine.feedback.exploring();
},
dislikeGeometric(heardVec: ReadonlyArray<number>, lr = 0): number {
const a = engine.feedback.dislikeGeometric(Float32Array.from(heardVec), lr);
engine.process();
return a;
},
storePositive(vec?: ReadonlyArray<number>): 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();

View file

@ -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 = {

View file

@ -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<typeof setInterval> | null = null;
private exploreTimer: ReturnType<typeof setInterval> | 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);
}
}

View file

@ -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';

View file

@ -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
* firmwarebrowser 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<JoltParams>) {
if (params) this.params_ = { ...this.params_, ...params };
}
setParams(p: Partial<JoltParams>): void {
this.params_ = { ...this.params_, ...p };
}
get params(): Readonly<JoltParams> {
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)
);
}
}

View file

@ -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
* firmwarebrowser 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);
}
}

View file

@ -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;

View file

@ -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;

View file

@ -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 getglide
* 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
// -------------------------------------------------------------------

View file

@ -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(),
};
}

View file

@ -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';

View file

@ -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;
}
}

View file

@ -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);
});
});

View file

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