feat(manifold): merge P3 TS switch — geometric dislike + jolt/OU on the C++ core
This commit is contained in:
commit
a36b58dce8
17 changed files with 578 additions and 524 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// -------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
72
manifold/tests/e2e/geo-dislike.spec.ts
Normal file
72
manifold/tests/e2e/geo-dislike.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue