feat(manifold): geometric dislike + jolt/OU via shared core (one-core-engine P3)
TS half of P3.1/P3.2/P3.3: wire the new WASM exports and delete the TS approximations now that geometric-dislike, jolt, OU, and the seeded RNG live in the C++/WASM core. - types.ts/wasm-iml.ts: bind + wrap dislike_geometric, store_positive, positive/negative_count, set_avoid_style, jolt_press/step/release/active/ lr_scale/tick_lr_ramp, explore_intensity/get/apply. Weight-mutating wrappers republish weights (version bump); explore_apply reuses feedbackBuf. - engine-api: extend .feedback (dislikeGeometric/storePositive/counts/ setAvoidStyle) + new .explore facade. thumbsDown now passes the HEARD (routed) vector, not the raw MLP output (raw == net output => inert cold-start). - feedback/controller.ts: delete dislikes[] + applyDislikeBias + both C++ GAP blocks + the SeededRng field; dislike() -> core dislikeGeometric (returns action, 15 => cold-start prompt); like() feeds the centroid via the core thumbsUp; getState() exposes positive/negative counts. Delete feedback/rng.ts. - engine/exploration.ts: execute the P3 SWAP POINT -> drive engine.explore.*; delete engine/jolt.ts + ou-explore.ts. UI surface unchanged. - ConsoleApp: one-time cold-start banner (British spelling), routed heard vector at the dislike call site. - App.tsx/spine.ts: under ?debug=1 pin a fixed seed + fixed per-tick dt so the probe/e2e are deterministic (production keeps time-seeded, real-time dt). - tests: new geo-dislike.spec.ts; probe gains dislikeGeometric/storePositive/ feedbackCounts/setAvoidStyle; the thumbsDown probe test now drives a real distinct-heard-vector dislike (bare thumbsDown on the net's own output is correctly inert under the geometric core). 27 e2e + 9 unit green. Docs: manifold/ONBOARDING.md engine+feedback sections synced.
This commit is contained in:
parent
9490e20a7a
commit
7fd0dda3a2
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
|
- `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.
|
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
|
- `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
|
(Learning drawer). **As of one-core-engine P3 the maths lives in the shared C++/WASM core** — this
|
||||||
playground), driven via get/set-weights; OU applies through the spine's inert-by-default
|
class is a thin driver that owns only the control-rate timers and calls `engine.explore.*`
|
||||||
`setOutputMorph` hook. The `─── P3 SWAP POINT ───` comment marks where the one-core-engine plan
|
(`joltPress`/`joltStep`/`joltRelease`/`joltActive` → `nisps_ml_jolt_*`; `setExploreIntensity`/
|
||||||
swaps these for `nisps_ml_jolt_press/release` + `nisps_ml_explore_intensity` WASM bindings.
|
`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/`
|
### Inputs — `src/inputs/`
|
||||||
- `input-layer.ts` — composition hub. One rAF loop polls sources, pulls all axes into a vector,
|
- `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).
|
for the locked design (adaptive slider viz when >2 dims is still pending).
|
||||||
|
|
||||||
### Feedback — `src/feedback/`
|
### Feedback — `src/feedback/`
|
||||||
- `controller.ts` (**~490 lines**) — `FeedbackController`, framework-neutral, owned by ConsoleApp.
|
- `controller.ts` — `FeedbackController`, framework-neutral, owned by ConsoleApp. **As of one-core-
|
||||||
Two modes: **geometric-dislike** (default, Mode 1, "Push away" — down carves the current sound
|
engine P3 it holds NO algorithm approximation** — a thin driver over the shared C++ core. Two modes:
|
||||||
away from what you like, directed repulsion) and **explore-and-place** (Mode 2, positive-only,
|
**geometric-dislike** (default, Mode 1, "Push away") — `dislike()` calls `engine.feedback.
|
||||||
selectable — drives the C++ core's snapshot/scratchpad/undo lifecycle; caller accumulates anchors
|
dislikeGeometric(heardVec)` (the k-NN centroid push-away in `nisps/ml/geo_push.hpp`; returns
|
||||||
and trains on finalise with warm-start). Solo/arm via per-output mask.
|
FeedbackAction 14=push / 15=cold-start) then `process()`; `like()` runs the core's `thumbsUp`
|
||||||
- `rng.ts` — `SeededRng` (deterministic xorshift32 + gaussian). **Stand-in** until the C++ nudge owns
|
(auto-stores the positive centroid in Avoid+Geometric) + `addExample` + `train`. **explore-and-
|
||||||
the stream — not bit-identical to `nisps::Rng`.
|
place** (Mode 2, positive-only) drives the core's snapshot/scratchpad/undo lifecycle; caller
|
||||||
- **`--- C++ GAP ---` markers** flag behaviour approximated in TS pending C++ port: true geometric
|
accumulates anchors and trains on finalise with warm-start. Solo/arm via per-output mask.
|
||||||
push (firmware k-NN), feedback nudge → `nisps_ml_feedback_nudge`, loss-history plumbing. Grep for
|
- **The heard-vector rule:** the geometric dislike trains AWAY from the HEARD (post-pipeline, routed)
|
||||||
`C++ GAP` before changing feedback maths.
|
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/`
|
### Backends — `src/backends/`
|
||||||
- `manager.ts` — `BackendManager`, the **single consumer of the engine spine** for output: subscribes,
|
- `manager.ts` — `BackendManager`, the **single consumer of the engine spine** for output: subscribes,
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,31 @@
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { EngineProvider } from './engine/EngineProvider';
|
import { EngineProvider } from './engine/EngineProvider';
|
||||||
|
import type { EngineApiOptions } from './engine/engine-api';
|
||||||
import { useEngine } from './engine/useEngine';
|
import { useEngine } from './engine/useEngine';
|
||||||
import { installDebugProbe } from './debug/probe';
|
import { installDebugProbe } from './debug/probe';
|
||||||
import { ConsoleApp } from './console';
|
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() {
|
function Loading() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -52,7 +73,7 @@ function ProbeInstaller() {
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<EngineProvider fallback={<Loading />}>
|
<EngineProvider options={engineOptions()} fallback={<Loading />}>
|
||||||
<ProbeInstaller />
|
<ProbeInstaller />
|
||||||
<ConsoleApp focus="composite" />
|
<ConsoleApp focus="composite" />
|
||||||
</EngineProvider>
|
</EngineProvider>
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,10 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
|
||||||
const [soloMode, setSoloMode] = useState<SoloMode>('mask-gradients');
|
const [soloMode, setSoloMode] = useState<SoloMode>('mask-gradients');
|
||||||
const [exploring, setExploring] = useState(false);
|
const [exploring, setExploring] = useState(false);
|
||||||
const [learningPaused, setLearningPaused] = 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).
|
// Explore-and-place scratchpad session state (workstream B; rl-feedback §2.2).
|
||||||
const [picking, setPicking] = useState(false);
|
const [picking, setPicking] = useState(false);
|
||||||
const [anchorCount, setAnchorCount] = useState(0);
|
const [anchorCount, setAnchorCount] = useState(0);
|
||||||
|
|
@ -410,6 +414,8 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
|
||||||
const c = controllerRef.current;
|
const c = controllerRef.current;
|
||||||
setFirstSession(false);
|
setFirstSession(false);
|
||||||
setBusy(true);
|
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 (feedbackMode === 'explore-and-place') {
|
||||||
if (c?.getState().exploring) {
|
if (c?.getState().exploring) {
|
||||||
// Place the current candidate → next manifold tap chooses the location.
|
// Place the current candidate → next manifold tap chooses the location.
|
||||||
|
|
@ -457,9 +463,18 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
|
||||||
forwardVcvFeedback('rand');
|
forwardVcvFeedback('rand');
|
||||||
}
|
}
|
||||||
} else {
|
} 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 −');
|
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');
|
pushMarker(pos, 'negative');
|
||||||
// VCV bridged mode: thumbs-down = negative verdict.
|
// VCV bridged mode: thumbs-down = negative verdict.
|
||||||
forwardVcvFeedback('down');
|
forwardVcvFeedback('down');
|
||||||
|
|
@ -1033,6 +1048,44 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
|
||||||
picking={picking}
|
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-scratchpad banner (workstream B; rl-feedback §2.2 §7). */}
|
||||||
{exploring && (
|
{exploring && (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,20 @@ export interface DebugProbe {
|
||||||
getFeedbackMode(): FeedbackMode | null;
|
getFeedbackMode(): FeedbackMode | null;
|
||||||
setFocus(mask: ReadonlyArray<number> | null): void;
|
setFocus(mask: ReadonlyArray<number> | null): void;
|
||||||
exploring(): boolean;
|
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;
|
train(): number;
|
||||||
trainAsync(): Promise<number>;
|
trainAsync(): Promise<number>;
|
||||||
randomise(): void;
|
randomise(): void;
|
||||||
|
|
@ -154,6 +168,27 @@ function makeProbe(engine: EngineApi): DebugProbe {
|
||||||
return engine.feedback.exploring();
|
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 {
|
train(): number {
|
||||||
const loss = engine.train();
|
const loss = engine.train();
|
||||||
engine.process();
|
engine.process();
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,44 @@ export interface EngineFeedbackApi {
|
||||||
undoDepth(): number;
|
undoDepth(): number;
|
||||||
/** The frozen placed / just-committed output (null if none). */
|
/** The frozen placed / just-committed output (null if none). */
|
||||||
placedOutput(): Float32Array | null;
|
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 {
|
export interface EngineAudioApi {
|
||||||
|
|
@ -81,6 +119,12 @@ export interface EngineApiOptions {
|
||||||
/** Default RL move speed / spread for thumbsDown. */
|
/** Default RL move speed / spread for thumbsDown. */
|
||||||
noiseCap?: number;
|
noiseCap?: number;
|
||||||
spread?: 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 {
|
export class EngineApi {
|
||||||
|
|
@ -93,6 +137,7 @@ export class EngineApi {
|
||||||
private spread_: number;
|
private spread_: number;
|
||||||
|
|
||||||
readonly feedback: EngineFeedbackApi;
|
readonly feedback: EngineFeedbackApi;
|
||||||
|
readonly explore: EngineExploreApi;
|
||||||
readonly audio: EngineAudioApi;
|
readonly audio: EngineAudioApi;
|
||||||
|
|
||||||
private constructor(iml: WasmIML, spine: Spine, host: EngineHost, opts: EngineApiOptions) {
|
private constructor(iml: WasmIML, spine: Spine, host: EngineHost, opts: EngineApiOptions) {
|
||||||
|
|
@ -102,6 +147,7 @@ export class EngineApi {
|
||||||
this.learningRate = opts.learningRate ?? 1.0;
|
this.learningRate = opts.learningRate ?? 1.0;
|
||||||
this.noiseCap = opts.noiseCap ?? 0.3;
|
this.noiseCap = opts.noiseCap ?? 0.3;
|
||||||
this.spread_ = opts.spread ?? 0.6;
|
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.
|
// Wire the spine's backend.send to push routed params into the worklet.
|
||||||
const send: BackendSend = (routed) => {
|
const send: BackendSend = (routed) => {
|
||||||
|
|
@ -112,7 +158,16 @@ export class EngineApi {
|
||||||
this.feedback = {
|
this.feedback = {
|
||||||
thumbsUp: () => this.iml.feedbackUp(),
|
thumbsUp: () => this.iml.feedbackUp(),
|
||||||
thumbsDown: (speed = this.noiseCap, spread = this.spread_, pinMask?: Uint8Array) =>
|
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(),
|
drag: () => this.iml.feedbackDrag(),
|
||||||
setMode: (mode) => this.iml.feedbackSetMode(mode),
|
setMode: (mode) => this.iml.feedbackSetMode(mode),
|
||||||
getMode: () => this.iml.feedbackGetMode(),
|
getMode: () => this.iml.feedbackGetMode(),
|
||||||
|
|
@ -131,6 +186,24 @@ export class EngineApi {
|
||||||
exploreState: () => this.iml.feedbackState(),
|
exploreState: () => this.iml.feedbackState(),
|
||||||
undoDepth: () => this.iml.feedbackUndoDepth(),
|
undoDepth: () => this.iml.feedbackUndoDepth(),
|
||||||
placedOutput: () => this.iml.feedbackPlacedOutput(),
|
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 = {
|
this.audio = {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/**
|
/**
|
||||||
* exploration.ts — ExplorationController: wires the two playground exploration
|
* exploration.ts — ExplorationController: wires the two exploration gestures onto
|
||||||
* gestures onto manifold's EngineApi.
|
* manifold's EngineApi, driving the SHARED C++/WASM core.
|
||||||
*
|
*
|
||||||
* • Jolt — hold to continuously morph the network's weights live, release to
|
* • Jolt — hold to continuously morph the network's weights live, release to
|
||||||
* freeze (nisps/ml/jolt.hpp; firmware TogB1).
|
* freeze (nisps/ml/jolt.hpp; firmware TogB1).
|
||||||
|
|
@ -8,38 +8,34 @@
|
||||||
* sound slowly roam so likes/dislikes can steer it (nisps/ml/
|
* sound slowly roam so likes/dislikes can steer it (nisps/ml/
|
||||||
* ou_noise.hpp; firmware RVX1 = exploration amount).
|
* ou_noise.hpp; firmware RVX1 = exploration amount).
|
||||||
*
|
*
|
||||||
* The gestures the playground drove from `mode-runtime.ts` are re-homed here as a
|
* This is a plain framework-neutral class (no Solid stores, no React) that the
|
||||||
* plain framework-neutral class (no Solid stores, no React) that the Console owns
|
* Console owns one of, once the engine resolves. The Learning drawer's Jolt
|
||||||
* one of, once the engine resolves. The Learning drawer's Jolt button + Explore
|
* button + Explore slider call these methods.
|
||||||
* slider call these methods.
|
|
||||||
*
|
*
|
||||||
* ─── P3 SWAP POINT ────────────────────────────────────────────────────────────
|
* ─── one-core-engine P3 (SWAP DONE) ────────────────────────────────────────────
|
||||||
* The maths currently runs in TS (`jolt.ts` / `ou-explore.ts`, ported from the
|
* The maths now lives in the C++/WASM core; this controller is a thin driver that
|
||||||
* retired playground). In §P3 of docs/specs/plans/one-core-engine-refactor.md it
|
* owns only the control-rate timers (the spine is push-driven — manifold has no
|
||||||
* moves into the C++/WASM core:
|
* 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
|
* joltPress() → engine.explore.joltPress (nisps_ml_jolt_press)
|
||||||
* joltRelease() → nisps_ml_jolt_release
|
* held ~200 Hz driver → engine.explore.joltStep + engine.process
|
||||||
* setExploreIntensity(l) → nisps_ml_explore_intensity
|
* 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
|
* The interim TS ports (`jolt.ts` / `ou-explore.ts`) were deleted with this swap;
|
||||||
* deleted, the per-tick stepping below (getWeights → step → setWeights → process,
|
* the UI keeps calling the same joltPress/joltRelease/joltActive/
|
||||||
* and the OU output-morph) is removed because the core owns the control-rate
|
* setExploreIntensity/exploreIntensity surface — no drawer changes.
|
||||||
* 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.
|
|
||||||
* ──────────────────────────────────────────────────────────────────────────────
|
* ──────────────────────────────────────────────────────────────────────────────
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { EngineApi } from './engine-api';
|
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.
|
// Control-rate cadences. Manifold has no always-on control-rate loop to hook (the
|
||||||
// Manifold has no always-on control-rate loop to hook (the spine is push-driven;
|
// spine is push-driven; the input-layer rAF only runs while a poll-based source is
|
||||||
// the input-layer rAF only runs while a poll-based source is active), so — like
|
// active), so each gesture owns a scoped interval that exists ONLY while it is
|
||||||
// the playground — each gesture owns a scoped interval that exists ONLY while it
|
// engaged. Not a global timer.
|
||||||
// is engaged and is torn down on release / at intensity 0. Not a global timer.
|
|
||||||
/** ~200Hz — matches the upstream firmware control rate the Jolt constants assume. */
|
/** ~200Hz — matches the upstream firmware control rate the Jolt constants assume. */
|
||||||
const JOLT_TICK_MS = 5;
|
const JOLT_TICK_MS = 5;
|
||||||
/** ~33Hz — keeps the OU walk roaming when the input is static. */
|
/** ~33Hz — keeps the OU walk roaming when the input is static. */
|
||||||
|
|
@ -47,8 +43,6 @@ const EXPLORE_TICK_MS = 30;
|
||||||
|
|
||||||
export class ExplorationController {
|
export class ExplorationController {
|
||||||
private readonly engine: EngineApi;
|
private readonly engine: EngineApi;
|
||||||
private readonly jolt = new Jolt();
|
|
||||||
private readonly ou = new OUExplore();
|
|
||||||
|
|
||||||
private joltTimer: ReturnType<typeof setInterval> | null = null;
|
private joltTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
private exploreTimer: ReturnType<typeof setInterval> | null = null;
|
private exploreTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
@ -56,24 +50,23 @@ export class ExplorationController {
|
||||||
constructor(engine: EngineApi) {
|
constructor(engine: EngineApi) {
|
||||||
this.engine = engine;
|
this.engine = engine;
|
||||||
// Register the OU walk as the spine's post-output morph. It is inert while
|
// 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
|
// intensity is 0 (the core apply() early-returns), so the spine stays
|
||||||
// until Explore is turned up. Applied on EVERY spine tick (user input or the
|
// parity-safe until Explore is turned up. Applied on EVERY spine tick (user
|
||||||
// EXPLORE_TICK_MS driver), exactly as the playground applied it in
|
// input or the EXPLORE_TICK_MS driver): copy the routed vector into the WASM
|
||||||
// recomputeOutputs. In P3 this becomes a core-side step, not a JS morph.
|
// heap, advance + add the OU offset in the core, copy back.
|
||||||
engine.spine.setOutputMorph((routed) => this.ou.apply(routed));
|
engine.spine.setOutputMorph((routed) => this.engine.explore.exploreApply(routed));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Jolt (held-button continuous weight morph) --------------------------
|
// ---- Jolt (held-button continuous weight morph) --------------------------
|
||||||
|
|
||||||
joltActive(): boolean {
|
joltActive(): boolean {
|
||||||
return this.jolt.active();
|
return this.engine.explore.joltActive();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Press-and-hold on: begin morphing a scatter of weights toward random targets. */
|
/** Press-and-hold on: begin morphing a scatter of weights toward random targets. */
|
||||||
joltPress(): void {
|
joltPress(): void {
|
||||||
if (!this.engine.getState().ready) return;
|
if (!this.engine.getState().ready) return;
|
||||||
const weightCount = this.engine.getWeights().length;
|
this.engine.explore.joltPress();
|
||||||
this.jolt.press(weightCount);
|
|
||||||
if (this.joltTimer === null) {
|
if (this.joltTimer === null) {
|
||||||
this.joltTimer = setInterval(() => this.tickJolt_(), JOLT_TICK_MS);
|
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. */
|
/** Release: freeze the weights where they landed (permanent) and stop ticking. */
|
||||||
joltRelease(): void {
|
joltRelease(): void {
|
||||||
this.jolt.release();
|
this.engine.explore.joltRelease();
|
||||||
if (this.joltTimer !== null) {
|
if (this.joltTimer !== null) {
|
||||||
clearInterval(this.joltTimer);
|
clearInterval(this.joltTimer);
|
||||||
this.joltTimer = null;
|
this.joltTimer = null;
|
||||||
|
|
@ -89,41 +82,39 @@ export class ExplorationController {
|
||||||
}
|
}
|
||||||
|
|
||||||
private tickJolt_(): void {
|
private tickJolt_(): void {
|
||||||
if (!this.jolt.active()) return;
|
if (!this.engine.explore.joltActive()) return;
|
||||||
const w = this.engine.getWeights();
|
// The core does the get→glide→set of weights in one step; re-run inference so
|
||||||
if (w.length === 0) return;
|
// audio + visuals reflect the morphed weights without the user having to move
|
||||||
this.jolt.step(w);
|
// the controller.
|
||||||
this.engine.setWeights(w);
|
this.engine.explore.joltStep();
|
||||||
// Re-run inference so audio + visuals reflect the morphed weights without
|
|
||||||
// the user having to move the controller.
|
|
||||||
this.engine.process();
|
this.engine.process();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Explore (OU exploration noise on the output) ------------------------
|
// ---- Explore (OU exploration noise on the output) ------------------------
|
||||||
|
|
||||||
exploreIntensity(): number {
|
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
|
* 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
|
* sound keeps wandering even when the input is static; 0 stops it and flushes a
|
||||||
* the walk so the output passes through cleanly again.
|
* clean output so the passthrough is parity-safe again.
|
||||||
*/
|
*/
|
||||||
setExploreIntensity(level: number): void {
|
setExploreIntensity(level: number): void {
|
||||||
this.ou.setIntensity(level);
|
this.engine.explore.setExploreIntensity(level);
|
||||||
if (this.ou.enabled() && this.exploreTimer === null) {
|
const enabled = this.engine.explore.exploreIntensity() > 0;
|
||||||
|
if (enabled && this.exploreTimer === null) {
|
||||||
this.exploreTimer = setInterval(() => {
|
this.exploreTimer = setInterval(() => {
|
||||||
if (!this.engine.getState().ready) return;
|
if (!this.engine.getState().ready) return;
|
||||||
// Re-tick the last input through the spine; the registered output morph
|
// 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();
|
this.engine.process();
|
||||||
}, EXPLORE_TICK_MS);
|
}, EXPLORE_TICK_MS);
|
||||||
} else if (!this.ou.enabled() && this.exploreTimer !== null) {
|
} else if (!enabled && this.exploreTimer !== null) {
|
||||||
clearInterval(this.exploreTimer);
|
clearInterval(this.exploreTimer);
|
||||||
this.exploreTimer = null;
|
this.exploreTimer = null;
|
||||||
this.ou.reset();
|
// Flush the now-clean output (intensity 0 → morph is inert) to audio + visuals.
|
||||||
// Flush the now-clean output (no residual drift) to audio + visuals.
|
|
||||||
this.engine.process();
|
this.engine.process();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -136,8 +127,7 @@ export class ExplorationController {
|
||||||
clearInterval(this.exploreTimer);
|
clearInterval(this.exploreTimer);
|
||||||
this.exploreTimer = null;
|
this.exploreTimer = null;
|
||||||
}
|
}
|
||||||
this.ou.setIntensity(0);
|
this.engine.explore.setExploreIntensity(0);
|
||||||
this.ou.reset();
|
|
||||||
this.engine.spine.setOutputMorph(null);
|
this.engine.spine.setOutputMorph(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,15 @@ export type {
|
||||||
EngineApiOptions,
|
EngineApiOptions,
|
||||||
EngineAudioApi,
|
EngineAudioApi,
|
||||||
EngineFeedbackApi,
|
EngineFeedbackApi,
|
||||||
|
EngineExploreApi,
|
||||||
} from './engine-api';
|
} from './engine-api';
|
||||||
|
|
||||||
export { Spine } from './spine';
|
export { Spine } from './spine';
|
||||||
export type { SpineState, BackendSend } 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 { 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 { WasmIML } from './wasm-iml';
|
||||||
export type { WasmIMLOptions } 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 outputMorph: ((routed: Float32Array) => void) | null = null;
|
||||||
|
|
||||||
private lastTickMs = 0;
|
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 ----------------------------------------------------
|
// ---- EngineSink ----------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -271,8 +282,10 @@ export class Spine implements EngineSink {
|
||||||
return this.setInputs(this.lastRawInputs);
|
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 {
|
private dt_(): number {
|
||||||
|
if (this.fixedDt !== null) return this.fixedDt;
|
||||||
const now = (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
const now = (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
||||||
const dt = this.lastTickMs > 0 ? (now - this.lastTickMs) / 1000 : 1 / 60;
|
const dt = this.lastTickMs > 0 ? (now - this.lastTickMs) / 1000 : 1 / 60;
|
||||||
this.lastTickMs = now;
|
this.lastTickMs = now;
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,35 @@ export interface NispsModule {
|
||||||
// Writes placed/committed output (outputSize floats) into out; returns 1 if written.
|
// Writes placed/committed output (outputSize floats) into out; returns 1 if written.
|
||||||
_nisps_ml_feedback_placed_output(ml: number, out_ptr: number): number;
|
_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.
|
// Engines.
|
||||||
_nisps_engine_create(id_ptr: number, sample_rate: number): number;
|
_nisps_engine_create(id_ptr: number, sample_rate: number): number;
|
||||||
_nisps_engine_destroy(engine: number): void;
|
_nisps_engine_destroy(engine: number): void;
|
||||||
|
|
|
||||||
|
|
@ -719,6 +719,119 @@ export class WasmIML {
|
||||||
return new Float32Array(this.feedbackBuf.view.subarray(0, this.arch_.outputSize));
|
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
|
// Weights I/O
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -11,21 +11,21 @@
|
||||||
* actions + state into the console context; VerdictCluster + Manifold drive it.
|
* actions + state into the console context; VerdictCluster + Manifold drive it.
|
||||||
*
|
*
|
||||||
* It talks ONLY to the small primitive surface of EngineApi:
|
* 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)
|
* setInput(x,y) / getOutputs() — synchronous forward inference (the spine)
|
||||||
* process() — re-run last input after a weight change
|
* process() — re-run last input after a weight change
|
||||||
* addExample([x,y], outVec) — append a training example
|
* addExample([x,y], outVec) — append a training example
|
||||||
* train() — SGD over the dataset
|
* 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,
|
* As of one-core-engine P3 the geometric push-away (Mode 1) is a C++ core
|
||||||
* the scratch-undo ring, the column-freeze gradient mask, the warm-start
|
* primitive (nisps/ml/{geo_push,replay}.hpp + mlp.train_targets), driven from
|
||||||
* interpolation loop) is implemented here in TS and CLEARLY COMMENTED as the
|
* here via engine.feedback.dislikeGeometric; the scratch-undo ring + snapshot
|
||||||
* approximation it is, with a pointer to where the real core primitive lands.
|
* 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';
|
import type { FeedbackMode } from '../engine/types';
|
||||||
|
|
||||||
/** The two product feedback modes (rl-feedback-design §0). */
|
/** The two product feedback modes (rl-feedback-design §0). */
|
||||||
|
|
@ -65,8 +65,15 @@ export interface ControllerEngine {
|
||||||
train(): number;
|
train(): number;
|
||||||
readonly feedback: {
|
readonly feedback: {
|
||||||
thumbsUp(): number;
|
thumbsUp(): number;
|
||||||
thumbsDown(speed?: number, spread?: number, pinMask?: Uint8Array): number;
|
|
||||||
setFocus(mask: Uint8Array | null): void;
|
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').
|
// ExploreAndPlace lifecycle — the SHARED C++ core (mode 'explore_and_place').
|
||||||
// The controller drives these instead of its own getWeights/setWeights/
|
// The controller drives these instead of its own getWeights/setWeights/
|
||||||
// randomise scratchpad logic, so explore-and-place runs identically in the
|
// randomise scratchpad logic, so explore-and-place runs identically in the
|
||||||
|
|
@ -101,6 +108,10 @@ export interface FeedbackControllerState {
|
||||||
undoDepth: number;
|
undoDepth: number;
|
||||||
/** Count of currently-armed (soloed) outputs; 0 ⇒ none armed ⇒ train all. */
|
/** Count of currently-armed (soloed) outputs; 0 ⇒ none armed ⇒ train all. */
|
||||||
armedCount: number;
|
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 {
|
export interface FeedbackControllerOptions {
|
||||||
|
|
@ -119,7 +130,6 @@ export interface FeedbackControllerOptions {
|
||||||
|
|
||||||
export class FeedbackController {
|
export class FeedbackController {
|
||||||
private engine: ControllerEngine;
|
private engine: ControllerEngine;
|
||||||
private rng: SeededRng;
|
|
||||||
private spread: number;
|
private spread: number;
|
||||||
private nudgeStddev: number;
|
private nudgeStddev: number;
|
||||||
private maxUndo: number;
|
private maxUndo: number;
|
||||||
|
|
@ -144,25 +154,12 @@ export class FeedbackController {
|
||||||
/** Current arm mask (1=armed/soloed). null ⇒ none armed ⇒ train all. */
|
/** Current arm mask (1=armed/soloed). null ⇒ none armed ⇒ train all. */
|
||||||
private armMask: Uint8Array | null = null;
|
private armMask: Uint8Array | null = null;
|
||||||
|
|
||||||
// ---- Mode-1 dislike memory (TS approximation) ----------------------
|
// Mode-1 dislike memory now lives entirely in the C++ core's ReplayStore
|
||||||
/**
|
// (positives + negatives, k-NN centroid), fed via engine.feedback.storePositive
|
||||||
* Disliked (input → output) pairs. The TRUE firmware geometric push (upstream
|
// / dislikeGeometric. No TS-side mirror.
|
||||||
* 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 }[] = [];
|
|
||||||
|
|
||||||
constructor(engine: ControllerEngine, opts: FeedbackControllerOptions = {}) {
|
constructor(engine: ControllerEngine, opts: FeedbackControllerOptions = {}) {
|
||||||
this.engine = engine;
|
this.engine = engine;
|
||||||
this.rng = new SeededRng(opts.seed ?? 0xfeedbacc);
|
|
||||||
this.spread = opts.spread ?? 0.6;
|
this.spread = opts.spread ?? 0.6;
|
||||||
this.nudgeStddev = opts.nudgeStddev ?? 0.05;
|
this.nudgeStddev = opts.nudgeStddev ?? 0.05;
|
||||||
this.maxUndo = Math.max(1, opts.undoDepth ?? 4);
|
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
|
* DISLIKE (thumbs-down in Mode 1). Push the current mapping away from the liked
|
||||||
* disliked sound.
|
* 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 /
|
* The core uses the MLP's CURRENT input and the passed HEARD output vector:
|
||||||
* move_weights — undirected Gaussian diffusion, the baseline) as the audible
|
* 1. stores the negative (input, a_neg) in the ReplayStore (dedup within 0.05)
|
||||||
* effect, AND record the disliked (input → output) so a subsequent like+train
|
* 2. k-NN(k=4) centroid of positives near the input
|
||||||
* can bias away from it (applyDislikeBias).
|
* 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) -----------------------------
|
* @param input the control input the disliked sound was heard at (unused by
|
||||||
* The true geometric push-away (upstream 0a541cc, replay-backed,
|
* the core — it reads the MLP's live input — kept for the marker /
|
||||||
* InterfaceRL.cpp:602-738) is:
|
* call-site symmetry with like()).
|
||||||
* 1. store the negative (input, action) in a ReplayStore (dedup within 0.05)
|
* @param output the HEARD (post-pipeline) output vector a_neg. MUST be the
|
||||||
* 2. compute the k-NN(k=4) centroid of POSITIVE memories near the input
|
* heard vector, not the raw MLP output, or the cold-start MSE
|
||||||
* 3. target[j] = clamp(neg[j] + dir/||dir|| * pushStep/(1+||dir||), 0, 1)
|
* derivative is zero (see engine.feedback.dislikeGeometric).
|
||||||
* where dir[j] = neg[j] - meanPositive[j] (away from the liked centroid)
|
* @param _speed legacy move_weights speed — ignored (geometric path).
|
||||||
* 4. train the net toward that computed `target` at lr*negLRRatio
|
* @param _spread legacy move_weights spread — ignored (geometric path).
|
||||||
* 5. cold-start fallback when there are no positives yet.
|
* @returns the FeedbackAction int (14=GeometricPush, 15=GeometricColdStart).
|
||||||
* 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
|
|
||||||
*/
|
*/
|
||||||
dislike(
|
dislike(
|
||||||
input: readonly [number, number],
|
input: readonly [number, number],
|
||||||
output: Float32Array,
|
output: Float32Array,
|
||||||
speed: number,
|
_speed: number,
|
||||||
spread: number,
|
_spread: number,
|
||||||
): void {
|
): number {
|
||||||
// Record the disliked pair (the firmware ReplayStore negative). Dedup within
|
void input;
|
||||||
// a coarse radius so repeated dislikes near each other don't pile up — a
|
const action = this.engine.feedback.dislikeGeometric(output);
|
||||||
// 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);
|
|
||||||
this.engine.process();
|
this.engine.process();
|
||||||
|
return action;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LIKE + train (thumbs-up in Mode 1). Store the current (input → output) as a
|
* LIKE + train (thumbs-up in Mode 1). Feeds the positive into the C++ core's
|
||||||
* positive example and train. In firmware this also feeds the positive
|
* k-NN centroid (via the core's thumbsUp auto-store, ADR §2.1) AND stores the
|
||||||
* centroid (replay.store(+1,…)); here it is a normal addExample + train, with
|
* current (input → output) as a positive example, then trains — exactly the two
|
||||||
* an optional bias away from recorded dislikes.
|
* 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 {
|
like(input: readonly [number, number], output: Float32Array): void {
|
||||||
this.engine.feedback.setFocus(this.armMask);
|
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.engine.addExample([input[0], input[1]], Array.from(output));
|
||||||
this.applyDislikeBias();
|
|
||||||
this.engine.train();
|
this.engine.train();
|
||||||
this.engine.process();
|
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
|
// State snapshot
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|
@ -478,6 +423,9 @@ export class FeedbackController {
|
||||||
// Scratchpad undo depth now comes from the shared C++ core's undo ring.
|
// Scratchpad undo depth now comes from the shared C++ core's undo ring.
|
||||||
undoDepth: this.exploringFlag ? this.engine.feedback.undoDepth() : 0,
|
undoDepth: this.exploringFlag ? this.engine.feedback.undoDepth() : 0,
|
||||||
armedCount: armed,
|
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
|
* 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
|
* See docs/adr/rl-feedback-design.md for the authoritative design.
|
||||||
* C++ integration plan. Everything here is the TS-prototype-first layer; the
|
|
||||||
* controller comments mark each place that becomes a C++ core primitive.
|
|
||||||
*/
|
*/
|
||||||
export {
|
export {
|
||||||
FeedbackController,
|
FeedbackController,
|
||||||
|
|
@ -15,4 +15,3 @@ export {
|
||||||
type FeedbackControllerState,
|
type FeedbackControllerState,
|
||||||
type FeedbackControllerOptions,
|
type FeedbackControllerOptions,
|
||||||
} from './controller';
|
} 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);
|
expect(count).toBeGreaterThanOrEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('thumbsDown moves weights and changes outputs', async ({ page }) => {
|
test('thumbsDown returns a finite action; a geometric dislike changes outputs', async ({ page }) => {
|
||||||
await page.evaluate(() => window.__nisps!.setInputs(0.3, 0.7));
|
// Under the geometric-dislike core (one-core-engine P3) a dislike trains AWAY
|
||||||
const before = await getOutputs(page);
|
// from the HEARD (post-pipeline) vector. Passing the net's OWN output — as the
|
||||||
await page.evaluate(() => window.__nisps!.thumbsDown());
|
// bare thumbsDown probe does — is intentionally inert (zero MSE derivative), so
|
||||||
await page.evaluate(() => window.__nisps!.setInputs(0.3, 0.7));
|
// we assert only its SHAPE there and drive a real, distinct heard vector for
|
||||||
const after = await getOutputs(page);
|
// the behaviour. Everything runs in ONE evaluate so the app's input rAF loop
|
||||||
expect(countChanged(before, after, 1e-4)).toBeGreaterThan(0);
|
// 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 }) => {
|
test('addExample reports success and bumps the example count', async ({ page }) => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue