feat(manifold): dock restructure, settings, backends, shared-core feedback, particle stage
Top Mode selector + 5 centred drawers; Settings (monochrome icons, input-map shape, corner radius); MIDI+OSC backends w/ named presets; rewire feedback to the shared C++ core (explore-and-place); ParticleStage + flow-field; engine base-URL + glue-load fixes. nisps.wasm synced from core build.
This commit is contained in:
parent
22efb1c411
commit
0d8179d6d5
10 changed files with 742 additions and 136 deletions
3
manifold/.gitignore
vendored
3
manifold/.gitignore
vendored
|
|
@ -4,3 +4,6 @@ dist/
|
|||
# Test artifacts
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
|
@ -10,8 +10,10 @@
|
|||
* `useEngineVersion` and re-derive `values` imperatively on render.
|
||||
* - Verdicts wire to the engine: commit → feedback.thumbsUp(); perturb →
|
||||
* feedback.thumbsDown(); reroll → randomise(); each followed by process().
|
||||
* - The default feedback mode is "Explore and place" → randomise_mlp (set on
|
||||
* mount; per docs/redesign/rl-feedback-design.md).
|
||||
* - The default feedback mode is "Explore and place" → the shared C++ core's
|
||||
* FeedbackMode::ExploreAndPlace (set on mount; the controller forwards the
|
||||
* Idle→Exploring→Placing lifecycle to engine.feedback.* — nisps/ml/feedback.hpp,
|
||||
* per docs/redesign/rl-feedback-design.md).
|
||||
* - AltitudeNav switches `focus` via React state (in|split|out|composite), not
|
||||
* by navigating to separate HTML files.
|
||||
* - `c15` is labelled "Powerful Synth Engine" (in model.ts) — "C15" never shows.
|
||||
|
|
@ -28,6 +30,7 @@ import { CompositeStage } from './CompositeStage';
|
|||
import { SplitStage } from './SplitStage';
|
||||
import { OutputStage } from './OutputStage';
|
||||
import { InputMini } from './InputMini';
|
||||
import { ParticleStage } from './ParticleStage';
|
||||
import { Manifold } from './Manifold';
|
||||
import { ReadoutStrip } from './ReadoutStrip';
|
||||
import { VerdictCluster } from './VerdictCluster';
|
||||
|
|
@ -676,7 +679,14 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
|
|||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
{focus === 'composite' ? (
|
||||
{outputMode === 'particles' ? (
|
||||
<ParticleStage
|
||||
pos={pos}
|
||||
onMove={onMove}
|
||||
axes={axes}
|
||||
setAxis={(k, v) => setAxes((s) => ({ ...s, [k]: v }))}
|
||||
/>
|
||||
) : focus === 'composite' ? (
|
||||
<CompositeStage
|
||||
split={split}
|
||||
onSplit={setSplit}
|
||||
|
|
@ -735,7 +745,8 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* corner overlay */}
|
||||
{/* corner overlay — hidden in Particle mode (top axis bar owns that row) */}
|
||||
{outputMode !== 'particles' && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
|
|
@ -747,6 +758,7 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
|
|||
>
|
||||
<strong style={{ color: 'var(--accent)', fontSize: 'var(--fs-md)' }}>MEMLNaut</strong>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<VerdictCluster
|
||||
onPerturb={perturb}
|
||||
|
|
|
|||
127
manifold/src/console/ParticleStage.tsx
Normal file
127
manifold/src/console/ParticleStage.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
/**
|
||||
* ParticleStage — the Particle System output Mode's main view.
|
||||
*
|
||||
* Mirrors the a-immersive playground layout:
|
||||
* • a full-bleed Canvas2D flow-field particle system (the main view), driven
|
||||
* by the live model outputs (first 20) read each animation frame;
|
||||
* • a horizontal macro-axis slider bar across the top (Boldness / Memory /
|
||||
* Precision), the same compound axes the rest of the console uses;
|
||||
* • a small circular pad in the bottom-left corner that drives the 2D input
|
||||
* (engine.setInput) — the "joystick" of the immersive app.
|
||||
*
|
||||
* The canvas animates on its own rAF clock so particles keep flowing between
|
||||
* inferences; only the *field* parameters change when the MLP outputs do.
|
||||
*/
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEngine } from '../engine';
|
||||
import { ControlAxis } from '../primitives/ControlAxis';
|
||||
import { VirtualJoystick } from '../primitives/VirtualJoystick';
|
||||
import { FlowFieldVisualizer } from './flow-field';
|
||||
import type { Axes } from './types';
|
||||
|
||||
export interface ParticleStageProps {
|
||||
pos: [number, number];
|
||||
onMove: (x: number, y: number) => void;
|
||||
axes: Axes;
|
||||
setAxis: (k: keyof Axes, v: number) => void;
|
||||
}
|
||||
|
||||
export function ParticleStage({ pos, onMove, axes, setAxis }: ParticleStageProps) {
|
||||
const engine = useEngine();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const vizRef = useRef<FlowFieldVisualizer | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const viz = new FlowFieldVisualizer(canvas);
|
||||
vizRef.current = viz;
|
||||
|
||||
let raf = 0;
|
||||
const tick = () => {
|
||||
const outputs = engine?.getOutputs();
|
||||
if (outputs) viz.setParams(outputs);
|
||||
viz.draw();
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
|
||||
const ro = new ResizeObserver(() => viz.resize());
|
||||
ro.observe(canvas);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
ro.disconnect();
|
||||
vizRef.current = null;
|
||||
};
|
||||
}, [engine]);
|
||||
|
||||
return (
|
||||
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden', background: '#0d0d0d' }}>
|
||||
{/* Main view — the flow-field particle system */}
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', display: 'block' }}
|
||||
/>
|
||||
|
||||
{/* Top horizontal macro-axis slider bar (Boldness / Memory / Precision) */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 20,
|
||||
display: 'flex',
|
||||
gap: 'var(--sp-2)',
|
||||
padding: 'var(--sp-2) var(--sp-3)',
|
||||
alignItems: 'center',
|
||||
background: 'var(--glass)',
|
||||
backdropFilter: 'blur(14px)',
|
||||
WebkitBackdropFilter: 'blur(14px)',
|
||||
borderBottom: '1px solid var(--line)',
|
||||
}}
|
||||
>
|
||||
<strong
|
||||
style={{
|
||||
color: 'var(--accent)',
|
||||
fontSize: 'var(--fs-md)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
whiteSpace: 'nowrap',
|
||||
paddingRight: 'var(--sp-2)',
|
||||
}}
|
||||
>
|
||||
MEMLNaut
|
||||
</strong>
|
||||
<ControlAxis
|
||||
label="Boldness"
|
||||
endpoints={['Caution', 'Bold']}
|
||||
value={axes.boldness}
|
||||
onChange={(v) => setAxis('boldness', v)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<ControlAxis
|
||||
label="Memory"
|
||||
endpoints={['Amnesia', 'Elephant']}
|
||||
value={axes.memory}
|
||||
onChange={(v) => setAxis('memory', v)}
|
||||
accent="var(--accent-2)"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<ControlAxis
|
||||
label="Precision"
|
||||
endpoints={['Raw', 'Precise']}
|
||||
value={axes.precision}
|
||||
onChange={(v) => setAxis('precision', v)}
|
||||
accent="var(--ok, var(--accent))"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bottom-left circular pad — drives the 2D input */}
|
||||
<div style={{ position: 'absolute', left: 18, bottom: 18, zIndex: 20 }}>
|
||||
<VirtualJoystick size={120} position={pos} onMove={onMove} ariaLabel="particle input pad" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
354
manifold/src/console/flow-field.ts
Normal file
354
manifold/src/console/flow-field.ts
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
/**
|
||||
* flow-field.ts — Canvas2D flow-field particle system, a faithful TypeScript
|
||||
* port of the a-immersive playground visualiser (`js/ui/visualizer.js`).
|
||||
*
|
||||
* Driven by the first 20 model outputs (each ∈ [0,1]); `setParams` maps them to
|
||||
* the visual ranges. The simulation advances on its own clock, so particles keep
|
||||
* flowing between inferences — only the *field* changes when the MLP outputs do.
|
||||
*/
|
||||
|
||||
// Simple value noise (no dependencies) — identical permutation scheme to the
|
||||
// a-immersive port. The table is shuffled once at module load.
|
||||
const PERM = new Uint8Array(512);
|
||||
{
|
||||
const p = new Uint8Array(256);
|
||||
for (let i = 0; i < 256; i++) p[i] = i;
|
||||
for (let i = 255; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[p[i], p[j]] = [p[j], p[i]];
|
||||
}
|
||||
for (let i = 0; i < 512; i++) PERM[i] = p[i & 255];
|
||||
}
|
||||
|
||||
function fade(t: number): number {
|
||||
return t * t * t * (t * (t * 6 - 15) + 10);
|
||||
}
|
||||
function lerp(a: number, b: number, t: number): number {
|
||||
return a + t * (b - a);
|
||||
}
|
||||
|
||||
function grad(hash: number, x: number, y: number): number {
|
||||
const h = hash & 3;
|
||||
const u = h < 2 ? x : y;
|
||||
const v = h < 2 ? y : x;
|
||||
return ((h & 1) ? -u : u) + ((h & 2) ? -v : v);
|
||||
}
|
||||
|
||||
function noise2D(x: number, y: number): number {
|
||||
const X = Math.floor(x) & 255;
|
||||
const Y = Math.floor(y) & 255;
|
||||
const xf = x - Math.floor(x);
|
||||
const yf = y - Math.floor(y);
|
||||
const u = fade(xf);
|
||||
const v = fade(yf);
|
||||
|
||||
const aa = PERM[PERM[X] + Y];
|
||||
const ab = PERM[PERM[X] + Y + 1];
|
||||
const ba = PERM[PERM[X + 1] + Y];
|
||||
const bb = PERM[PERM[X + 1] + Y + 1];
|
||||
|
||||
return lerp(
|
||||
lerp(grad(aa, xf, yf), grad(ba, xf - 1, yf), u),
|
||||
lerp(grad(ab, xf, yf - 1), grad(bb, xf - 1, yf - 1), u),
|
||||
v,
|
||||
);
|
||||
}
|
||||
|
||||
const TWO_PI = Math.PI * 2;
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
id: number;
|
||||
age: number;
|
||||
life: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
}
|
||||
|
||||
interface FlowParams {
|
||||
angleOffset: number;
|
||||
scale: number;
|
||||
speed: number;
|
||||
hueBase: number;
|
||||
hueSpread: number;
|
||||
particleSize: number;
|
||||
fadeRate: number;
|
||||
turbulence: number;
|
||||
attractStrength: number;
|
||||
attractRadius: number;
|
||||
dispersionRate: number;
|
||||
dispersionAmount: number;
|
||||
particleLifetime: number;
|
||||
respawnStyle: number;
|
||||
advectionMode: number;
|
||||
inertia: number;
|
||||
drag: number;
|
||||
repulsorStrength: number;
|
||||
repulsorCount: number;
|
||||
repulsorOrbitRate: number;
|
||||
}
|
||||
|
||||
export class FlowFieldVisualizer {
|
||||
private canvas: HTMLCanvasElement;
|
||||
private ctx: CanvasRenderingContext2D;
|
||||
private particles: Particle[] = [];
|
||||
private numParticles = 400;
|
||||
private time = 0;
|
||||
private width = 0;
|
||||
private height = 0;
|
||||
|
||||
private params: FlowParams = {
|
||||
angleOffset: 0, // p0: flow direction
|
||||
scale: 0.005, // p1: pattern size
|
||||
speed: 2, // p2: particle speed
|
||||
hueBase: 180, // p3: base colour
|
||||
hueSpread: 60, // p4: colour variation
|
||||
particleSize: 3, // p5: dot radius
|
||||
fadeRate: 0.05, // p6: trail length
|
||||
turbulence: 1, // p7: chaos
|
||||
attractStrength: 0.8, // p8: pull toward screen centre
|
||||
attractRadius: 200, // p9: radius where attraction is strongest
|
||||
dispersionRate: 2.0, // p10: speed of outward dispersion pulses
|
||||
dispersionAmount: 1.0, // p11: strength of outward dispersion
|
||||
particleLifetime: 220, // p12: average frames before respawn
|
||||
respawnStyle: 0.0, // p13: 0=random, 1=edge, 2=center-burst
|
||||
advectionMode: 0.0, // p14: flow->orbit->radial blend
|
||||
inertia: 0.2, // p15: velocity memory
|
||||
drag: 0.02, // p16: velocity damping
|
||||
repulsorStrength: 0.0, // p17: repulsor force amount
|
||||
repulsorCount: 0, // p18: number of active repulsors
|
||||
repulsorOrbitRate: 0.8, // p19: repulsor orbital speed
|
||||
};
|
||||
|
||||
constructor(canvas: HTMLCanvasElement) {
|
||||
this.canvas = canvas;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('FlowFieldVisualizer: 2D context unavailable');
|
||||
this.ctx = ctx;
|
||||
this.resize();
|
||||
this.initParticles();
|
||||
}
|
||||
|
||||
resize(): void {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
this.canvas.width = Math.max(1, Math.round(rect.width * dpr));
|
||||
this.canvas.height = Math.max(1, Math.round(rect.height * dpr));
|
||||
this.ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
this.ctx.scale(dpr, dpr);
|
||||
this.width = rect.width;
|
||||
this.height = rect.height;
|
||||
// Clear to background so a resize doesn't leave stale trails.
|
||||
this.ctx.fillStyle = '#0d0d0d';
|
||||
this.ctx.fillRect(0, 0, this.width, this.height);
|
||||
}
|
||||
|
||||
private initParticles(): void {
|
||||
this.particles = [];
|
||||
for (let i = 0; i < this.numParticles; i++) {
|
||||
this.particles.push(this.makeParticle(i));
|
||||
}
|
||||
this.ctx.fillStyle = '#0d0d0d';
|
||||
this.ctx.fillRect(0, 0, this.width, this.height);
|
||||
}
|
||||
|
||||
private makeParticle(id: number): Particle {
|
||||
return {
|
||||
x: Math.random() * this.width,
|
||||
y: Math.random() * this.height,
|
||||
id,
|
||||
age: Math.floor(Math.random() * this.params.particleLifetime),
|
||||
life: this.computeLifetime(),
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
};
|
||||
}
|
||||
|
||||
private computeLifetime(): number {
|
||||
const variance = 0.65 + Math.random() * 0.7;
|
||||
return Math.max(10, Math.floor(this.params.particleLifetime * variance));
|
||||
}
|
||||
|
||||
private respawnParticle(p: Particle): void {
|
||||
const mode = Math.min(2, Math.floor(this.params.respawnStyle * 2.999));
|
||||
const { width, height } = this;
|
||||
|
||||
if (mode === 1) {
|
||||
// Edge respawn
|
||||
const side = Math.floor(Math.random() * 4);
|
||||
if (side === 0) {
|
||||
p.x = Math.random() * width;
|
||||
p.y = 0;
|
||||
}
|
||||
if (side === 1) {
|
||||
p.x = width;
|
||||
p.y = Math.random() * height;
|
||||
}
|
||||
if (side === 2) {
|
||||
p.x = Math.random() * width;
|
||||
p.y = height;
|
||||
}
|
||||
if (side === 3) {
|
||||
p.x = 0;
|
||||
p.y = Math.random() * height;
|
||||
}
|
||||
// Give edge spawns an inward impulse.
|
||||
const towardCenterX = width * 0.5 - p.x;
|
||||
const towardCenterY = height * 0.5 - p.y;
|
||||
const inwardDist = Math.hypot(towardCenterX, towardCenterY) + 1e-6;
|
||||
p.vx = (towardCenterX / inwardDist) * 2.0;
|
||||
p.vy = (towardCenterY / inwardDist) * 2.0;
|
||||
} else if (mode === 2) {
|
||||
// Center-burst respawn
|
||||
const angle = Math.random() * TWO_PI;
|
||||
const r = Math.random() * Math.min(width, height) * 0.08;
|
||||
p.x = width * 0.5 + Math.cos(angle) * r;
|
||||
p.y = height * 0.5 + Math.sin(angle) * r;
|
||||
p.vx = Math.cos(angle) * 2.5;
|
||||
p.vy = Math.sin(angle) * 2.5;
|
||||
} else {
|
||||
// Random respawn
|
||||
p.x = Math.random() * width;
|
||||
p.y = Math.random() * height;
|
||||
p.vx = (Math.random() * 2 - 1) * 0.5;
|
||||
p.vy = (Math.random() * 2 - 1) * 0.5;
|
||||
}
|
||||
|
||||
p.age = 0;
|
||||
p.life = this.computeLifetime();
|
||||
}
|
||||
|
||||
/** Set parameters from the model output (first 20 values, each ∈ [0,1]). */
|
||||
setParams(outputs: ArrayLike<number> | null | undefined): void {
|
||||
if (!outputs || outputs.length < 20) return;
|
||||
this.params.angleOffset = outputs[0] * TWO_PI;
|
||||
this.params.scale = 0.001 + outputs[1] * 0.009;
|
||||
this.params.speed = 0.5 + outputs[2] * 4.5;
|
||||
this.params.hueBase = outputs[3] * 360;
|
||||
this.params.hueSpread = outputs[4] * 120;
|
||||
this.params.particleSize = 1 + outputs[5] * 5;
|
||||
this.params.fadeRate = 0.01 + outputs[6] * 0.14;
|
||||
this.params.turbulence = outputs[7] * 2;
|
||||
this.params.attractStrength = 0.1 + outputs[8] * 2.9;
|
||||
this.params.attractRadius = 40 + outputs[9] * 420;
|
||||
this.params.dispersionRate = 0.2 + outputs[10] * 8;
|
||||
this.params.dispersionAmount = outputs[11] * 3;
|
||||
this.params.particleLifetime = 30 + outputs[12] * 470;
|
||||
this.params.respawnStyle = outputs[13];
|
||||
this.params.advectionMode = outputs[14];
|
||||
this.params.inertia = outputs[15] * 0.98;
|
||||
this.params.drag = outputs[16] * 0.35;
|
||||
this.params.repulsorStrength = outputs[17] * 4.5;
|
||||
this.params.repulsorCount = Math.floor(outputs[18] * 4.999);
|
||||
this.params.repulsorOrbitRate = 0.1 + outputs[19] * 2.9;
|
||||
}
|
||||
|
||||
draw(): void {
|
||||
const { ctx, width, height, params } = this;
|
||||
if (width === 0 || height === 0) return;
|
||||
this.time += 0.003;
|
||||
|
||||
// Fade existing content (creates trails)
|
||||
ctx.fillStyle = `rgba(13, 13, 13, ${params.fadeRate})`;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
for (const p of this.particles) {
|
||||
const cx = width * 0.5;
|
||||
const cy = height * 0.5;
|
||||
|
||||
// Sample flow field
|
||||
const nx = p.x * params.scale;
|
||||
const ny = p.y * params.scale;
|
||||
const angle = noise2D(nx + this.time, ny) * TWO_PI + params.angleOffset;
|
||||
const curl = noise2D(nx + 100, ny + 100 + this.time * 0.5) * params.turbulence;
|
||||
|
||||
// Mix between three advection fields for larger visual mode changes.
|
||||
const flowVx = Math.cos(angle + curl) * params.speed;
|
||||
const flowVy = Math.sin(angle + curl) * params.speed;
|
||||
const fromCenterX = p.x - cx;
|
||||
const fromCenterY = p.y - cy;
|
||||
const centerDist = Math.hypot(fromCenterX, fromCenterY) + 1e-6;
|
||||
const radialX = fromCenterX / centerDist;
|
||||
const radialY = fromCenterY / centerDist;
|
||||
const orbitX = -radialY;
|
||||
const orbitY = radialX;
|
||||
const orbitVx = orbitX * params.speed;
|
||||
const orbitVy = orbitY * params.speed;
|
||||
const radialVx = radialX * params.speed;
|
||||
const radialVy = radialY * params.speed;
|
||||
|
||||
const modeBlend = params.advectionMode * 2;
|
||||
let targetVx: number;
|
||||
let targetVy: number;
|
||||
if (modeBlend < 1) {
|
||||
targetVx = lerp(flowVx, orbitVx, modeBlend);
|
||||
targetVy = lerp(flowVy, orbitVy, modeBlend);
|
||||
} else {
|
||||
targetVx = lerp(orbitVx, radialVx, modeBlend - 1);
|
||||
targetVy = lerp(orbitVy, radialVy, modeBlend - 1);
|
||||
}
|
||||
p.vx = p.vx * params.inertia + targetVx * (1 - params.inertia);
|
||||
p.vy = p.vy * params.inertia + targetVy * (1 - params.inertia);
|
||||
p.vx *= 1 - params.drag;
|
||||
p.vy *= 1 - params.drag;
|
||||
let nextX = p.x + p.vx;
|
||||
let nextY = p.y + p.vy;
|
||||
|
||||
// Central attractor keeps trajectories from sticking to the outer edges.
|
||||
const dx = cx - nextX;
|
||||
const dy = cy - nextY;
|
||||
const dist = Math.hypot(dx, dy) + 1e-6;
|
||||
const nxCenter = dx / dist;
|
||||
const nyCenter = dy / dist;
|
||||
const normalizedDist = Math.min(dist / params.attractRadius, 2);
|
||||
const falloff = 1 / (1 + normalizedDist * normalizedDist);
|
||||
nextX += nxCenter * params.attractStrength * falloff;
|
||||
nextY += nyCenter * params.attractStrength * falloff;
|
||||
|
||||
// Time-varying dispersion pushes particles outward near the centre.
|
||||
const dispersionPulse =
|
||||
0.5 + 0.5 * Math.sin(this.time * params.dispersionRate + p.id * 0.07);
|
||||
const dispersionForce = params.dispersionAmount * dispersionPulse * falloff;
|
||||
nextX -= nxCenter * dispersionForce;
|
||||
nextY -= nyCenter * dispersionForce;
|
||||
|
||||
// Orbiting repulsor points carve dynamic voids and bursts.
|
||||
const repulsorRadius = Math.min(width, height) * 0.28;
|
||||
for (let r = 0; r < params.repulsorCount; r++) {
|
||||
const phase = this.time * params.repulsorOrbitRate + (r / 4) * TWO_PI;
|
||||
const wobble = 0.6 + 0.15 * r;
|
||||
const rx = cx + Math.cos(phase * (1.0 + wobble)) * repulsorRadius;
|
||||
const ry = cy + Math.sin(phase * (1.3 + wobble)) * repulsorRadius;
|
||||
const repulseDx = nextX - rx;
|
||||
const repulseDy = nextY - ry;
|
||||
const distSq = repulseDx * repulseDx + repulseDy * repulseDy + 160;
|
||||
const distInv = 1 / Math.sqrt(distSq);
|
||||
const force = params.repulsorStrength * (650 / distSq);
|
||||
nextX += repulseDx * distInv * force;
|
||||
nextY += repulseDy * distInv * force;
|
||||
}
|
||||
|
||||
p.x = nextX;
|
||||
p.y = nextY;
|
||||
|
||||
// Wrap around edges
|
||||
if (p.x < 0) p.x += width;
|
||||
if (p.x > width) p.x -= width;
|
||||
if (p.y < 0) p.y += height;
|
||||
if (p.y > height) p.y -= height;
|
||||
|
||||
p.age += 1;
|
||||
if (p.age >= p.life) this.respawnParticle(p);
|
||||
|
||||
// Colour based on particle id + hue params
|
||||
const hue = (params.hueBase + (p.id / this.numParticles) * params.hueSpread) % 360;
|
||||
const lightness = 50 + Math.sin(p.id * 0.1 + this.time) * 15;
|
||||
|
||||
ctx.fillStyle = `hsl(${hue}, 75%, ${lightness}%)`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, params.particleSize, 0, TWO_PI);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -35,6 +35,32 @@ export interface EngineFeedbackApi {
|
|||
exploring(): boolean;
|
||||
/** True while the controller has paused learning. */
|
||||
learningPaused(): boolean;
|
||||
|
||||
// ---- ExploreAndPlace lifecycle (shared C++ core; mode 'explore_and_place') --
|
||||
/** Idle→Exploring: snapshot the real net, randomise a scratchpad. */
|
||||
enterExplore(spread?: number): void;
|
||||
/** Exploring→Idle: restore the real net, discard the scratchpad. */
|
||||
exitExplore(): void;
|
||||
/** Exploring scratchpad op: re-randomise (undoable). */
|
||||
reroll(spread?: number): void;
|
||||
/** Exploring scratchpad op: small bounded perturbation (undoable). */
|
||||
nudge(amount?: number): void;
|
||||
/** Exploring scratchpad op: undo the last reroll/nudge. */
|
||||
undo(): void;
|
||||
/** Exploring→Placing: freeze the scratchpad output at its current input. */
|
||||
like(): void;
|
||||
/** Placing→Idle: restore the real net (caller then stores +1 + trains). */
|
||||
commitPlace(): void;
|
||||
/** Placing→Exploring: back out without storing. */
|
||||
cancelPlace(): void;
|
||||
/** True while Placing (the frozen output is held). */
|
||||
placing(): boolean;
|
||||
/** ExploreState int: 0=Idle 1=Exploring 2=Placing. */
|
||||
exploreState(): number;
|
||||
/** Scratchpad undo-ring depth available to pop. */
|
||||
undoDepth(): number;
|
||||
/** The frozen placed / just-committed output (null if none). */
|
||||
placedOutput(): Float32Array | null;
|
||||
}
|
||||
|
||||
export interface EngineAudioApi {
|
||||
|
|
@ -93,6 +119,18 @@ export class EngineApi {
|
|||
setFocus: (mask) => this.iml.feedbackSetFocus(mask),
|
||||
exploring: () => this.iml.feedbackExploring(),
|
||||
learningPaused: () => this.iml.feedbackLearningPaused(),
|
||||
enterExplore: (spread = this.spread_) => this.iml.feedbackEnterExplore(spread),
|
||||
exitExplore: () => this.iml.feedbackExitExplore(),
|
||||
reroll: (spread = this.spread_) => this.iml.feedbackReroll(spread),
|
||||
nudge: (amount = 0.05) => this.iml.feedbackNudge(amount),
|
||||
undo: () => this.iml.feedbackUndo(),
|
||||
like: () => this.iml.feedbackLike(),
|
||||
commitPlace: () => this.iml.feedbackCommitPlace(),
|
||||
cancelPlace: () => this.iml.feedbackCancelPlace(),
|
||||
placing: () => this.iml.feedbackPlacing(),
|
||||
exploreState: () => this.iml.feedbackState(),
|
||||
undoDepth: () => this.iml.feedbackUndoDepth(),
|
||||
placedOutput: () => this.iml.feedbackPlacedOutput(),
|
||||
};
|
||||
|
||||
this.audio = {
|
||||
|
|
|
|||
|
|
@ -76,6 +76,24 @@ export interface NispsModule {
|
|||
// Returns 1 if `out` holds a static-bypass vector (skip process()); else 0.
|
||||
_nisps_ml_feedback_static_output(ml: number, out_ptr: number): number;
|
||||
|
||||
// ExploreAndPlace lifecycle (mode 3). Idle → Exploring → Placing → Idle.
|
||||
// The shared C++ core owns the weight snapshot/scratchpad/undo; the CALLER
|
||||
// owns example-storage + training (read placed_output post-commit, addExample
|
||||
// at the chosen input, then train). See nisps/ml/feedback.hpp.
|
||||
_nisps_ml_feedback_enter_explore(ml: number, spread: number): void;
|
||||
_nisps_ml_feedback_exit_explore(ml: number): void;
|
||||
_nisps_ml_feedback_reroll(ml: number, spread: number): void;
|
||||
_nisps_ml_feedback_nudge(ml: number, amount: number): void;
|
||||
_nisps_ml_feedback_undo(ml: number): void;
|
||||
_nisps_ml_feedback_like(ml: number): void; // Exploring→Placing (freeze output)
|
||||
_nisps_ml_feedback_commit_place(ml: number): void; // Placing→Idle (restore real net)
|
||||
_nisps_ml_feedback_cancel_place(ml: number): void; // Placing→Exploring
|
||||
_nisps_ml_feedback_placing(ml: number): number; // 1 = Placing
|
||||
_nisps_ml_feedback_state(ml: number): number; // 0=Idle 1=Exploring 2=Placing
|
||||
_nisps_ml_feedback_undo_depth(ml: number): number;
|
||||
// Writes placed/committed output (outputSize floats) into out; returns 1 if written.
|
||||
_nisps_ml_feedback_placed_output(ml: number, out_ptr: number): number;
|
||||
|
||||
// Engines.
|
||||
_nisps_engine_create(id_ptr: number, sample_rate: number): number;
|
||||
_nisps_engine_destroy(engine: number): void;
|
||||
|
|
@ -125,18 +143,29 @@ export type EngineId =
|
|||
| 'analysis';
|
||||
|
||||
/** Feedback "Down Action" mode. Mirrors `nisps::ml::FeedbackMode`. */
|
||||
export type FeedbackMode = 'avoid' | 'randomise_outputs' | 'randomise_mlp';
|
||||
export type FeedbackMode = 'avoid' | 'randomise_outputs' | 'randomise_mlp' | 'explore_and_place';
|
||||
|
||||
export const FEEDBACK_MODE_TO_INT: Record<FeedbackMode, number> = {
|
||||
avoid: 0,
|
||||
randomise_outputs: 1,
|
||||
randomise_mlp: 2,
|
||||
explore_and_place: 3,
|
||||
};
|
||||
|
||||
export const FEEDBACK_MODE_FROM_INT: ReadonlyArray<FeedbackMode> = [
|
||||
'avoid',
|
||||
'randomise_outputs',
|
||||
'randomise_mlp',
|
||||
'explore_and_place',
|
||||
];
|
||||
|
||||
/** ExploreAndPlace lifecycle state. Mirrors `nisps::ml::ExploreState`. */
|
||||
export type ExploreState = 'idle' | 'exploring' | 'placing';
|
||||
|
||||
export const EXPLORE_STATE_FROM_INT: ReadonlyArray<ExploreState> = [
|
||||
'idle',
|
||||
'exploring',
|
||||
'placing',
|
||||
];
|
||||
|
||||
/** Message protocol between main thread and `wasm-worker.ts`. */
|
||||
|
|
|
|||
|
|
@ -545,6 +545,83 @@ export class WasmIML {
|
|||
return false;
|
||||
}
|
||||
|
||||
// ---- ExploreAndPlace lifecycle (shared C++ core; mode 'explore_and_place') --
|
||||
// The C++ core owns the weight snapshot / scratchpad / undo ring; THIS class
|
||||
// only forwards calls + republishes weights. Example-storage + training stay
|
||||
// with the caller (FeedbackController.ts), preserving the "caller owns
|
||||
// training" contract.
|
||||
|
||||
/** Idle→Exploring: snapshot the real net, randomise a scratchpad. */
|
||||
feedbackEnterExplore(spread: number): void {
|
||||
this.module._nisps_ml_feedback_enter_explore(this.mlHandle, spread);
|
||||
this.publishWeights_();
|
||||
}
|
||||
|
||||
/** Exploring→Idle: restore the real net, discard the scratchpad. */
|
||||
feedbackExitExplore(): void {
|
||||
this.module._nisps_ml_feedback_exit_explore(this.mlHandle);
|
||||
this.publishWeights_();
|
||||
}
|
||||
|
||||
/** Exploring scratchpad op: re-randomise (undoable). */
|
||||
feedbackReroll(spread: number): void {
|
||||
this.module._nisps_ml_feedback_reroll(this.mlHandle, spread);
|
||||
this.publishWeights_();
|
||||
}
|
||||
|
||||
/** Exploring scratchpad op: small bounded perturbation (undoable). */
|
||||
feedbackNudge(amount: number): void {
|
||||
this.module._nisps_ml_feedback_nudge(this.mlHandle, amount);
|
||||
this.publishWeights_();
|
||||
}
|
||||
|
||||
/** Exploring scratchpad op: undo the last reroll/nudge. */
|
||||
feedbackUndo(): void {
|
||||
this.module._nisps_ml_feedback_undo(this.mlHandle);
|
||||
this.publishWeights_();
|
||||
}
|
||||
|
||||
/** Exploring→Placing: freeze the scratchpad output at its current input. */
|
||||
feedbackLike(): void {
|
||||
this.module._nisps_ml_feedback_like(this.mlHandle);
|
||||
}
|
||||
|
||||
/** Placing→Idle: restore the real net. Caller then stores +1 + trains. */
|
||||
feedbackCommitPlace(): void {
|
||||
this.module._nisps_ml_feedback_commit_place(this.mlHandle);
|
||||
this.publishWeights_();
|
||||
}
|
||||
|
||||
/** Placing→Exploring: back out without storing. */
|
||||
feedbackCancelPlace(): void {
|
||||
this.module._nisps_ml_feedback_cancel_place(this.mlHandle);
|
||||
}
|
||||
|
||||
feedbackPlacing(): boolean {
|
||||
return this.module._nisps_ml_feedback_placing(this.mlHandle) === 1;
|
||||
}
|
||||
|
||||
/** ExploreState: 0=Idle 1=Exploring 2=Placing. */
|
||||
feedbackState(): number {
|
||||
return this.module._nisps_ml_feedback_state(this.mlHandle);
|
||||
}
|
||||
|
||||
feedbackUndoDepth(): number {
|
||||
return this.module._nisps_ml_feedback_undo_depth(this.mlHandle);
|
||||
}
|
||||
|
||||
/**
|
||||
* The frozen placed output (while Placing) or the just-committed output
|
||||
* (after commit_place, until the next explore). Returns null if neither is
|
||||
* available. The caller adds this as the +1 example label at the chosen
|
||||
* input after commit.
|
||||
*/
|
||||
feedbackPlacedOutput(): Float32Array | null {
|
||||
const ok = this.module._nisps_ml_feedback_placed_output(this.mlHandle, this.feedbackBuf.ptr);
|
||||
if (ok !== 1) return null;
|
||||
return new Float32Array(this.feedbackBuf.view.subarray(0, this.arch_.outputSize));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Weights I/O
|
||||
// -------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
*/
|
||||
|
||||
import { SeededRng } from './rng';
|
||||
import type { FeedbackMode } from '../engine/types';
|
||||
|
||||
/** The two product feedback modes (rl-feedback-design §0). */
|
||||
export type ProtoFeedbackMode = 'explore-and-place' | 'geometric-dislike';
|
||||
|
|
@ -66,6 +67,23 @@ export interface ControllerEngine {
|
|||
thumbsUp(): number;
|
||||
thumbsDown(speed?: number, spread?: number, pinMask?: Uint8Array): number;
|
||||
setFocus(mask: Uint8Array | null): void;
|
||||
// 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
|
||||
// browser and on firmware. See nisps/ml/feedback.hpp.
|
||||
setMode(mode: FeedbackMode): void;
|
||||
enterExplore(spread?: number): void;
|
||||
exitExplore(): void;
|
||||
reroll(spread?: number): void;
|
||||
nudge(amount?: number): void;
|
||||
undo(): void;
|
||||
like(): void;
|
||||
commitPlace(): void;
|
||||
cancelPlace(): void;
|
||||
placing(): boolean;
|
||||
exploreState(): number; // 0=Idle 1=Exploring 2=Placing
|
||||
undoDepth(): number;
|
||||
placedOutput(): Float32Array | null;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -109,22 +127,18 @@ export class FeedbackController {
|
|||
private mode: ProtoFeedbackMode = 'explore-and-place';
|
||||
private soloMode: ProtoSoloMode = 'mask-gradients';
|
||||
|
||||
// ---- Mode-2 scratchpad session state -------------------------------
|
||||
/** The set-aside REAL trained net, restored on finalise/cancel. */
|
||||
private snapshot: Float32Array | null = null;
|
||||
// ---- Mode-2 explore-and-place session state ------------------------
|
||||
// The SHARED C++ core (nisps/ml/feedback.hpp, mode 'explore_and_place') now
|
||||
// owns the set-aside real net, the scratchpad, and the bounded undo ring. This
|
||||
// controller is a thin driver: it forwards transitions to `engine.feedback.*`
|
||||
// and tracks only the per-session ANCHOR LIST (multi-anchor warm-start is a
|
||||
// caller-side feature — the core does one place-commit, the caller accumulates
|
||||
// anchors and trains them all on finalise).
|
||||
private exploringFlag = false;
|
||||
/** Undo stack of scratchpad weight snapshots (reroll + nudge are undoable). */
|
||||
private undoStack: Float32Array[] = [];
|
||||
/** Anchors placed this session (positives only — NEVER a dislike). */
|
||||
private anchors: Anchor[] = [];
|
||||
/** True between place() and the manifold location pick. */
|
||||
private pickingFlag = false;
|
||||
/**
|
||||
* The scratchpad output vector frozen at place() time, so the heard sound is
|
||||
* held while the user aims at a location (rl-feedback-design §2.2 step 3,
|
||||
* "place_begin freezes the current scratchpad output"). Copied/owned.
|
||||
*/
|
||||
private placedOutput: Float32Array | null = null;
|
||||
|
||||
// ---- Solo / arm ----------------------------------------------------
|
||||
/** Current arm mask (1=armed/soloed). null ⇒ none armed ⇒ train all. */
|
||||
|
|
@ -160,10 +174,13 @@ export class FeedbackController {
|
|||
|
||||
setMode(mode: ProtoFeedbackMode): void {
|
||||
if (mode === this.mode) return;
|
||||
// Switching mode aborts any active scratchpad session (mirrors the C++
|
||||
// `set_mode` which aborts active exploration first — findings §2).
|
||||
// Switching mode aborts any active scratchpad session. For explore-and-place
|
||||
// the SHARED C++ core owns the scratchpad, so delegate the teardown to it.
|
||||
if (this.exploringFlag) this.cancel();
|
||||
this.mode = mode;
|
||||
// Keep the C++ core's feedback mode in lockstep so the shared explore-and-
|
||||
// place lifecycle is active when this mode is selected.
|
||||
this.engine.feedback.setMode(mode === 'explore-and-place' ? 'explore_and_place' : 'avoid');
|
||||
}
|
||||
|
||||
getMode(): ProtoFeedbackMode {
|
||||
|
|
@ -194,99 +211,58 @@ export class FeedbackController {
|
|||
// Mode 2 — "Explore & place" (DEFAULT, positive-only, NEVER a dislike)
|
||||
// ===================================================================
|
||||
|
||||
// The whole lifecycle below now delegates to the SHARED C++ core
|
||||
// (engine.feedback.*) — there is NO TS scratchpad/snapshot/undo logic any
|
||||
// more. The core owns the set-aside real net, the random scratchpad, and the
|
||||
// bounded undo ring; this controller forwards the transitions and tracks only
|
||||
// the per-session anchor list for the multi-anchor warm-start (caller-owned
|
||||
// training). Behaviour matches nisps/ml/feedback.hpp + its ctest + the parity
|
||||
// gate (native ≡ WASM at 1e-5).
|
||||
|
||||
/**
|
||||
* ENTER explore (rl-feedback-design §2.2 step 1): snapshot the REAL weights,
|
||||
* set them aside, then randomise() into a scratchpad net. Mark exploring.
|
||||
* Idempotent re-entry while already exploring = a re-roll (step 2).
|
||||
* ENTER explore: the core snapshots the REAL net and randomises a scratchpad.
|
||||
* Re-entry while exploring = a re-roll ("meh, randomise…").
|
||||
*/
|
||||
enterExplore(): void {
|
||||
if (this.exploringFlag) {
|
||||
// Re-press while exploring re-rolls ("meh, randomise…" — §2.2 step 2).
|
||||
this.reroll();
|
||||
return;
|
||||
}
|
||||
// Snapshot the real trained net (byte round-trip via get/set weights). This
|
||||
// is the SET-ASIDE net restored on finalise/cancel — it is NOT part of the
|
||||
// scratchpad undo ring (undo stays inside the scratchpad; you leave the
|
||||
// session via cancel/finalise, never by undoing back into the real net).
|
||||
this.snapshot = this.engine.getWeights();
|
||||
this.undoStack = [];
|
||||
this.anchors = [];
|
||||
this.placedOutput = null;
|
||||
this.pickingFlag = false;
|
||||
this.exploringFlag = true;
|
||||
// Randomise into the first scratchpad candidate, then record it as the undo
|
||||
// baseline (the history holds the LIVE candidate AFTER each op).
|
||||
this.engine.randomise(this.spread);
|
||||
this.recordCandidate();
|
||||
this.engine.feedback.enterExplore(this.spread); // core: snapshot + draw scratchpad
|
||||
this.engine.process();
|
||||
}
|
||||
|
||||
/**
|
||||
* SCRATCHPAD OP: re-roll the whole net (§2.2 step 2). Undoable. The scratchpad
|
||||
* is NEVER trained — this only generates a fresh candidate sound to audition.
|
||||
*/
|
||||
/** SCRATCHPAD OP: re-roll the scratchpad (core, undoable). Never trained. */
|
||||
reroll(): void {
|
||||
if (!this.exploringFlag) return;
|
||||
this.engine.randomise(this.spread);
|
||||
this.recordCandidate();
|
||||
this.engine.feedback.reroll(this.spread);
|
||||
this.engine.process();
|
||||
}
|
||||
|
||||
/**
|
||||
* SCRATCHPAD OP: nudge — a small bounded gaussian weight perturbation (§2.2
|
||||
* step 2). Undoable. Deterministic via the seeded RNG (NO Math.random).
|
||||
*
|
||||
* --- C++ GAP -----------------------------------------------------------
|
||||
* The firmware does this with `move_weights(speed, spread)` on its own
|
||||
* `nisps::Rng`. Here we read the weights, add a small seeded gaussian, and
|
||||
* write them back — the TS-achievable equivalent. Becomes
|
||||
* `nisps_ml_feedback_nudge` driving the engine's Rng (rl-feedback-design §4).
|
||||
* ----------------------------------------------------------------------
|
||||
*/
|
||||
/** SCRATCHPAD OP: nudge — small bounded perturbation (core Rng, undoable). */
|
||||
nudge(): void {
|
||||
if (!this.exploringFlag) return;
|
||||
const w = this.engine.getWeights();
|
||||
// Bounded gaussian perturbation. No per-call allocation beyond the weights
|
||||
// buffer the engine already returns (we mutate it in place then write back).
|
||||
for (let i = 0; i < w.length; i++) {
|
||||
w[i] += this.rng.nextGaussian(this.nudgeStddev);
|
||||
}
|
||||
this.engine.setWeights(w);
|
||||
this.engine.feedback.nudge(this.nudgeStddev);
|
||||
this.engine.process();
|
||||
this.recordCandidate();
|
||||
}
|
||||
|
||||
/**
|
||||
* UNDO the last scratchpad op (reroll or nudge). Both are undoable (§2.2). The
|
||||
* undo ring holds the live scratchpad candidate after each op; undo discards
|
||||
* the current candidate and restores the previous one. The baseline (first
|
||||
* candidate after enter) is kept so undo never leaves the scratchpad.
|
||||
*/
|
||||
/** UNDO the last scratchpad op (core bounded undo ring). */
|
||||
undo(): void {
|
||||
if (!this.exploringFlag) return;
|
||||
if (this.undoStack.length <= 1) return; // already at the baseline candidate
|
||||
this.undoStack.pop(); // discard current candidate
|
||||
const prev = this.undoStack[this.undoStack.length - 1];
|
||||
this.engine.setWeights(prev);
|
||||
this.engine.feedback.undo();
|
||||
this.engine.process();
|
||||
}
|
||||
|
||||
/** Record the CURRENT live scratchpad weights as a new undo-ring entry. */
|
||||
private recordCandidate(): void {
|
||||
this.undoStack.push(this.engine.getWeights());
|
||||
// Bound the ring to maxUndo+1 (the +1 is the kept baseline at index 0).
|
||||
if (this.undoStack.length > this.maxUndo + 1) {
|
||||
this.undoStack.splice(1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PLACE begin (§2.2 step 3): the user likes the current candidate. Freeze the
|
||||
* scratchpad output so the heard sound is held while they aim, and enter the
|
||||
* PICK-LOCATION state — the next manifold pointer-down chooses the location.
|
||||
* PLACE begin: the user likes the current candidate. The core freezes the
|
||||
* scratchpad output (held while aiming) and we enter the PICK-LOCATION state.
|
||||
*/
|
||||
place(): void {
|
||||
if (!this.exploringFlag) return;
|
||||
this.placedOutput = new Float32Array(this.engine.getOutputs());
|
||||
this.engine.feedback.like(); // core: Exploring→Placing, freeze output
|
||||
this.pickingFlag = true;
|
||||
}
|
||||
|
||||
|
|
@ -295,64 +271,58 @@ export class FeedbackController {
|
|||
return this.pickingFlag;
|
||||
}
|
||||
|
||||
/** The frozen scratchpad output held during aiming (read-only; may be null). */
|
||||
/** The frozen scratchpad output held during aiming (from the core; may be null). */
|
||||
getPlacedOutput(): Float32Array | null {
|
||||
return this.placedOutput;
|
||||
return this.engine.feedback.placedOutput();
|
||||
}
|
||||
|
||||
/**
|
||||
* PLACE commit (§2.2 step 3): the user picked a location on the manifold. We
|
||||
* move the scratchpad input there, run inference, capture the output the
|
||||
* scratchpad produces AT THAT LOCATION, and store it as a positive anchor.
|
||||
*
|
||||
* Per the spec the captured output is "the output the scratchpad produces at
|
||||
* the chosen location" (getOutputs() after setting the input there) — NOT the
|
||||
* frozen audition vector. The frozen vector only kept the *audio* steady while
|
||||
* aiming. Returns the new anchor count.
|
||||
* PLACE commit: the user picked a location. We capture the output the
|
||||
* scratchpad produces AT THAT LOCATION (the scratchpad net is still live while
|
||||
* Placing), store it as a positive anchor, then commit the place — the core
|
||||
* restores the real net. We immediately re-enter explore so the felt loop
|
||||
* "place → randomise → place again" keeps going; finalise trains all anchors.
|
||||
*/
|
||||
placeCommit(x: number, y: number): number {
|
||||
if (!this.exploringFlag || !this.pickingFlag) return this.anchors.length;
|
||||
// The scratchpad net is still live during Placing — read its output at the
|
||||
// chosen location (per the spec, "the output the scratchpad produces at the
|
||||
// chosen location", not the frozen audition vector).
|
||||
this.engine.setInput(x, y);
|
||||
this.engine.process();
|
||||
const out = new Float32Array(this.engine.getOutputs());
|
||||
// Solo/arm respected at the EXAMPLE level: capture the arm mask so warm-start
|
||||
// only asserts armed outputs ("don't-care on others" — §3.3 approximation).
|
||||
const mask = this.armMask ? new Uint8Array(this.armMask) : null;
|
||||
this.anchors.push({ input: [x, y], output: out, mask });
|
||||
this.pickingFlag = false;
|
||||
this.placedOutput = null;
|
||||
// Commit the place in the core (restores the real net), then re-enter
|
||||
// explore for the next sound in the same session.
|
||||
this.engine.feedback.commitPlace();
|
||||
this.engine.feedback.enterExplore(this.spread);
|
||||
this.engine.process();
|
||||
return this.anchors.length;
|
||||
}
|
||||
|
||||
/** Cancel a pending place() without storing an anchor (back to auditioning). */
|
||||
/** Cancel a pending place() without storing (core: Placing→Exploring). */
|
||||
cancelPlace(): void {
|
||||
if (this.pickingFlag) this.engine.feedback.cancelPlace();
|
||||
this.pickingFlag = false;
|
||||
this.placedOutput = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* RESOLVE / warm-start (§2.2 step 4): restore the set-aside REAL net, then
|
||||
* warm-start it to interpolate ALL placed anchors by re-adding each as an
|
||||
* example and training. ADDITIVE — anchors are added to the existing dataset
|
||||
* (the user's prior thumbs-up likes are NOT clobbered). Exits exploring.
|
||||
* RESOLVE / warm-start: exit explore (the core restores the set-aside REAL
|
||||
* net), then warm-start it to interpolate ALL placed anchors by re-adding each
|
||||
* as an example and training. ADDITIVE — prior likes are not clobbered.
|
||||
*
|
||||
* --- C++ GAP -----------------------------------------------------------
|
||||
* The firmware warm-start trains anchors only on soloed dims via a gradient
|
||||
* column-freeze (`train_masked`). Here we approximate that at the example
|
||||
* level: when an anchor carries an arm mask we still add the FULL output
|
||||
* vector (the engine's addExample takes a full label row), but we forward the
|
||||
* mask to the engine's setFocus so move_weights/training freezes unarmed
|
||||
* final-layer columns. True per-example gradient masking (`train_masked`
|
||||
* consuming `Anchor.mask`) is the C++ step (rl-feedback-design §3.3).
|
||||
* ----------------------------------------------------------------------
|
||||
* NOTE: per-anchor solo masking is still approximated at the example level
|
||||
* (the engine's addExample takes a full label row); we forward the arm mask to
|
||||
* the core's setFocus so training honours soloed columns. True per-example
|
||||
* gradient masking is the future C++ `train_masked` step (rl-feedback §3.3).
|
||||
*/
|
||||
finalise(): number {
|
||||
if (!this.exploringFlag) return 0;
|
||||
if (this.snapshot) {
|
||||
this.engine.setWeights(this.snapshot); // restore the real net (warm start)
|
||||
}
|
||||
if (this.pickingFlag) this.engine.feedback.cancelPlace();
|
||||
this.engine.feedback.exitExplore(); // core: restore the real net (warm start)
|
||||
const placed = this.anchors.length;
|
||||
// Re-assert the arm focus so training honours any soloed columns.
|
||||
this.engine.feedback.setFocus(this.armMask);
|
||||
for (const a of this.anchors) {
|
||||
this.engine.addExample([a.input[0], a.input[1]], Array.from(a.output));
|
||||
|
|
@ -366,24 +336,20 @@ export class FeedbackController {
|
|||
}
|
||||
|
||||
/**
|
||||
* CANCEL / undo whole session (§2.2 step 5): discard scratchpad + anchors,
|
||||
* restore the set-aside real net. No anchor stored.
|
||||
* CANCEL the whole session: the core restores the set-aside real net; we
|
||||
* discard the anchors. No example stored.
|
||||
*/
|
||||
cancel(): void {
|
||||
if (!this.exploringFlag) return;
|
||||
if (this.snapshot) {
|
||||
this.engine.setWeights(this.snapshot);
|
||||
if (this.pickingFlag) this.engine.feedback.cancelPlace();
|
||||
this.engine.feedback.exitExplore(); // core: restore the real net
|
||||
this.engine.process();
|
||||
}
|
||||
this.endSession();
|
||||
}
|
||||
|
||||
private endSession(): void {
|
||||
this.exploringFlag = false;
|
||||
this.pickingFlag = false;
|
||||
this.placedOutput = null;
|
||||
this.snapshot = null;
|
||||
this.undoStack = [];
|
||||
this.anchors = [];
|
||||
}
|
||||
|
||||
|
|
@ -509,8 +475,8 @@ export class FeedbackController {
|
|||
exploring: this.exploringFlag,
|
||||
picking: this.pickingFlag,
|
||||
anchorCount: this.anchors.length,
|
||||
// -1 for the entry-state baseline kept at index 0.
|
||||
undoDepth: Math.max(0, this.undoStack.length - 1),
|
||||
// Scratchpad undo depth now comes from the shared C++ core's undo ring.
|
||||
undoDepth: this.exploringFlag ? this.engine.feedback.undoDepth() : 0,
|
||||
armedCount: armed,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue