479 lines
24 KiB
Markdown
479 lines
24 KiB
Markdown
---
|
||
kind: spec
|
||
stability: aspirational
|
||
layer: behavioural
|
||
---
|
||
|
||
# Manifold Parity Features — Session Presets · Pins · Jolt · OU-Explore · Control Surface
|
||
|
||
*Status: draft — awaiting review. Date: 2026-07-12.*
|
||
*Scope: prescriptive spec for porting five playground features into `manifold/`. Spec only — no
|
||
implementation is authorised by this document; break into ergo issues after sign-off.*
|
||
|
||
**Traces up to:** `playground-2.0-rewrite-plan.md` §2.3 (Feel drawer), §2.6 (pins), §3.8
|
||
(composed-layers presets); `engine-architecture.md` §3.2 (fanout, control-point tri-state), §3.8
|
||
(persist helper); `docs/specs/slp-workshop-firmware.md` §3–4 (Jolt / OU-explore, Part I shipped in
|
||
firmware); `dock-spec.md` (drawer depths, tri-state semantics); BUILD-PLAN locked decisions
|
||
(parity-tested engine; British spelling; the synth is "Powerful Synth Engine", never the forbidden
|
||
string).
|
||
|
||
**Reference implementations:** the playground versions are the behavioural ground truth for
|
||
constants and algorithms — `playground/src/features/session-preset.ts`,
|
||
`stores/session-store.ts` (pins), `ml/jolt.ts`, `output/ou-explore.ts`, `stores/control-store.ts` +
|
||
`features/control-routing.ts`. Where this spec and the playground disagree, this spec wins (each
|
||
divergence is called out and justified inline).
|
||
|
||
---
|
||
|
||
## 0. Principles applied throughout
|
||
|
||
1. **C++ owns gesture math where a C++ class exists.** `nisps::ml::Jolt` and
|
||
`nisps::ml::OUNoise<N>` already exist (`nisps/ml/jolt.hpp`, `nisps/ml/ou_noise.hpp`) but are
|
||
not exposed to WASM. Manifold binds them rather than re-porting to TS — this closes two
|
||
documented `--- C++ GAP ---` items and buys deterministic, firmware-parity noise for free.
|
||
(The playground's TS reimplementations used `Math.random()`; that shortcut is *not* carried
|
||
over.)
|
||
2. **No new mechanisms where an existing one already expresses the idea.** Param pins are the
|
||
existing `off|fixed|live` tri-state + arm mask, not a parallel pin system (§2.2).
|
||
3. **Transparent defaults.** Every default constant appears in this spec as a number with its
|
||
source, and the control surface UI must *show* the derived per-param values live (§5.6) —
|
||
the user should never wonder what an axis is secretly doing.
|
||
4. **One persistence pattern.** All new persisted state goes through a single versioned
|
||
`persist<T>(key, version, migrate)` helper modelled on `settings-store.ts`
|
||
(localStorage, debounced 200 ms), per `engine-architecture.md` §3.8.
|
||
5. **Probe parity.** Each feature activates its currently-inert `window.__nisps` methods
|
||
(`manifold/src/debug/probe.ts`) so Playwright can drive it headlessly. New e2e specs are part
|
||
of each feature's acceptance criteria.
|
||
|
||
---
|
||
|
||
## 1. Session presets (composed layers)
|
||
|
||
### 1.1 Model
|
||
|
||
Adopt the composed-layers model prescribed by `engine-architecture.md` §3.8 rather than the
|
||
playground's monolithic `SessionPresetPayload`. A preset is a bundle of independently optional
|
||
layers:
|
||
|
||
```ts
|
||
interface SessionPresetV1 {
|
||
v: 1;
|
||
id: string; // `preset-${epoch36}`
|
||
name: string;
|
||
createdAt: number;
|
||
layers: {
|
||
control?: ControlLayer; // axes + offsets + presetId (§5)
|
||
pipelines?: PipelineLayer; // input/output pipeline params
|
||
routing?: RoutingLayer; // per-output MFParam state/min/max/curve/mute + backend specs
|
||
mode?: ModeLayer; // modeId + outputMode
|
||
weights?: string; // base64url Float32Array — OPT-IN only
|
||
};
|
||
}
|
||
```
|
||
|
||
- `ControlLayer` = `{ boldness, memory, precision, presetId, offsets }` exactly as §5.4 state.
|
||
- `PipelineLayer` = the configurable params of `engine/input-pipeline.ts` and
|
||
`output-pipeline.ts` (deadzone, zoom, invert, curve, smoothing, momentum; global curve,
|
||
smoothing, slew, freeze).
|
||
- `RoutingLayer` = serialised `MFParam[]` control fields + the per-backend specs already handled
|
||
by the `OutputsBackendConfig` preset bar. The existing backend preset bar becomes a *view onto
|
||
the routing layer* — one storage model, two entry points (see §1.4).
|
||
- `weights` uses the playground's base64url `Float32Array` codec verbatim
|
||
(`session-preset.ts:63–98`): standard base64 with `+/` → `-_`, padding stripped; byte length
|
||
rounded down to a 4-byte boundary on decode; size-mismatch on restore is silently skipped.
|
||
|
||
Restore is best-effort and layer-wise: absent layers leave current state untouched; unknown keys
|
||
inside a layer are ignored (forward compatibility).
|
||
|
||
### 1.2 Persistence
|
||
|
||
- Key: `mf-session`, via the shared `persist<T>` helper, version field `v: 1`, 200 ms debounce.
|
||
- Stored shape: `{ v: 1, presets: SessionPresetV1[], regionPins: RegionPin[] }` (pins ride in
|
||
the same document, §2).
|
||
- Weights are **excluded by default** on save; the save UI offers an explicit
|
||
"include network weights" toggle (default off). Rationale: weight blobs are ~KB-scale ×
|
||
N presets and stale weights surprise users; matches playground `withWeights=false` default.
|
||
|
||
### 1.3 URL sharing
|
||
|
||
Carry over both playground URL forms (`session-preset.ts:249–283`):
|
||
|
||
- **Compact axis form** — `?boldness=0.5&memory=0.5&precision=0.3`: applies only the control
|
||
layer; each value clamped to [0,1].
|
||
- **Full form** — `?session=<base64url(JSON of SessionPresetV1.layers)>`: applied on first load,
|
||
after engine ready, before first render of the dock. Never include weights in a generated
|
||
share URL (URL length); the share button produces the full form minus `weights`.
|
||
|
||
URL params are read once at boot in `App.tsx` (same place the `?debug=1` probe is installed) and
|
||
then stripped from the address bar via `history.replaceState` so a reload doesn't re-apply over
|
||
user changes.
|
||
|
||
### 1.4 UI
|
||
|
||
- **Save/restore/rename/delete** lives in a preset bar at the top of the **route drawer's
|
||
expanded depth**, reusing the exact interaction pattern of
|
||
`dock/OutputsBackendConfig.tsx` (save-as / restore / rename / delete). The existing
|
||
backend-scoped preset bar remains; a session preset is the superset bundle.
|
||
- **Share** button beside the bar copies the URL to the clipboard and flashes confirmation.
|
||
- Restoring a preset that contains a `mode` layer while a different mode is active shows the
|
||
same confirm modal used for net-reset (input-layer reshape) — mode switches discard training
|
||
state and must not be silent.
|
||
|
||
### 1.5 Probe + acceptance
|
||
|
||
- Probe: `saveSessionPreset(name, withWeights?)`, `loadSessionPreset(id)`, `listSessionPresets()`,
|
||
`buildShareUrl()`, `applySession(json)`.
|
||
- e2e: save → mutate axes/routing → restore → assert state round-trips; share URL → fresh page
|
||
load with `?session=` → assert layers applied; weights round-trip when opted in
|
||
(`getWeights()` equality within exact bits).
|
||
|
||
---
|
||
|
||
## 2. Region & param pins
|
||
|
||
### 2.1 Region pins
|
||
|
||
**Type (unchanged from playground `session-store.ts:34–44`):**
|
||
|
||
```ts
|
||
interface RegionPin {
|
||
id: string; // `pin-${epoch36}-${rand}`
|
||
x: number; y: number; // bottom-left corner, [0,1] input space
|
||
width: number; height: number; // [0,1]
|
||
colourSlot: number; // 0..4 → --pin-1..--pin-5 tokens
|
||
createdAt: number;
|
||
}
|
||
```
|
||
|
||
- **Cap: 5 pins, FIFO** (oldest evicted). Colour slot defaults to `pins.length % 5`.
|
||
- **Colour tokens** join `manifold/src/styles/tokens/colors.css` with the playground values:
|
||
fills `--pin-1..5` at 0.25 alpha (orange `255,106,0` / cyan `0,204,255` / purple
|
||
`180,100,255` / green `80,200,120` / tan `255,200,80`), borders same hues at 0.7 alpha,
|
||
2 px logical border width.
|
||
- **Creation gesture:** long-press (600 ms — the constant Manifold.tsx already uses) on the
|
||
Manifold stage pins the *currently visible region* (whole surface when unzoomed; the zoom
|
||
window once input zoom exists in manifold — until then, `{x:0,y:0,w:1,h:1}` is not useful, so
|
||
the long-press instead opens a small radial affordance: "pin region around cursor"
|
||
creating a 0.25×0.25 region centred on the press point, clamped to [0,1]²).
|
||
*Divergence from playground (which pins the zoom window) because manifold has no input zoom
|
||
yet; revisit when the inputs workstream lands zoom.*
|
||
- **Removal:** tap a pin border → small popover with colour dot + "remove"; "clear pins" action
|
||
in the settings drawer.
|
||
- **Rendering:** `Manifold.tsx` already accepts `pins: Pin[]` — extend the `Pin` type in
|
||
`console/types.ts` to the full `RegionPin` shape (it currently has only `x/y/colour`), and
|
||
render rects behind trail/markers, in front of the grid. Long-press must not fire when the
|
||
press initiates a drag > 8 px (existing drag threshold logic) or while `picking` is true
|
||
(explore-and-place anchor picking takes priority).
|
||
- **Persistence:** in the `mf-session` document (§1.2).
|
||
- **Semantics:** purely annotational in v1 (visual memory of "good areas"). They do **not**
|
||
constrain training. A future spec may bind snapshot anchors to pins.
|
||
|
||
### 2.2 Param pins = the existing tri-state (decision, not new code)
|
||
|
||
The playground's separate `ParamPin` list + `paramPinMask()` is **not ported**. Manifold already
|
||
has a strictly more expressive mechanism, per `dock-spec.md` §3.3:
|
||
|
||
- `off` — excluded from model control *and* pinned out of training;
|
||
- `fixed` — held at a static value, pinned out of training;
|
||
- `live` — model-driven, trainable.
|
||
|
||
"Pinning a param" in manifold = setting it to `fixed` (hold current value) via the existing
|
||
`OutputControlRow` segmented control or by clicking the value readout in `OutputStage`.
|
||
|
||
**Gap this spec closes:** the tri-state must actually feed the training mask. Requirement: the
|
||
arm/focus mask sent through `_nisps_ml_feedback_set_focus` (and any `moveWeights`-style
|
||
perturbation mask) is computed as `armed ∧ (state === 'live')` — `off`/`fixed` columns are never
|
||
perturbed and never accumulate gradient. Owner: `dock/output-state.ts` `buildArmMask()`.
|
||
|
||
Probe: `pinParam(i)` / `unpinParam(i)` map to `setParam(i, {state:'fixed'|'live'})`; the inert
|
||
playground-compat methods in `probe.ts` are rewired to these.
|
||
|
||
Acceptance: e2e asserts (a) a `fixed` output's value is bit-stable across training and jolt;
|
||
(b) region pins survive reload; (c) 6th region pin evicts the 1st.
|
||
|
||
---
|
||
|
||
## 3. Jolt
|
||
|
||
### 3.1 What it is
|
||
|
||
A held-gesture weight morph: while held, N randomly-chosen weights each glide (EMA) toward their
|
||
own random target, re-rolling the target on arrival — a continuous, directional "stirring" of
|
||
the network. On release the net freezes where it landed, and the learning rate ramps back up
|
||
from 0 so training doesn't immediately fight the new position. Ported from upstream
|
||
`InterfaceRL`; C++ implementation shipped in firmware (SLP workshop Part I).
|
||
|
||
### 3.2 Binding, not porting
|
||
|
||
Expose the existing `nisps::ml::Jolt` through the C ABI. New bindings in
|
||
`nisps/wasm/bindings.cpp` + `scripts/build-wasm.sh` EXPORTED_FUNCS:
|
||
|
||
```
|
||
_nisps_ml_jolt_press(ml) // picks indices+targets from the MLP's weight count
|
||
_nisps_ml_jolt_step(ml) // one tick: mutates weights in place, then caller reprocesses
|
||
_nisps_ml_jolt_release(ml)
|
||
_nisps_ml_jolt_active(ml) -> i32
|
||
_nisps_ml_jolt_lr_scale(ml) -> f32 // 0 while held; ramps 0→1 after release
|
||
_nisps_ml_jolt_tick_lr_ramp(ml)
|
||
_nisps_ml_jolt_seed(ml, u64)
|
||
```
|
||
|
||
The Jolt instance lives beside the MLP handle inside the WASM module (one per net), seeded from
|
||
the session seed so runs are reproducible. Parity CI: extend `tests/cpp/parity_check.cpp` with a
|
||
golden jolt sequence (seed → press → k steps → weight vector) asserted native-vs-WASM.
|
||
|
||
### 3.3 Constants (defaults — from `nisps/ml/jolt.hpp:42–49`, upstream-verbatim)
|
||
|
||
| Constant | Value | Meaning |
|
||
|---|---|---|
|
||
| `num_weights` | 40 | weights morphed simultaneously (hard cap 64) |
|
||
| `morph_rate` | 0.017 | EMA per tick: `w += 0.017·(target − w)` |
|
||
| `target_min / max` | −1.2 / 0.9 | random target range |
|
||
| `target_epsilon` | 0.05 | re-roll target when within this distance |
|
||
| `lr_ramp_step` | 0.001 | post-release LR ramp per tick (~5 s at 200 Hz) |
|
||
| tick period | 5 ms (200 Hz) | `JOLT_TICK_MS`, matches firmware control rate |
|
||
|
||
### 3.4 Manifold integration
|
||
|
||
- **Driver:** a `setInterval(5 ms)` in `FeedbackController` while active: `jolt_step` →
|
||
`engine.reprocess()` (the spine already re-ticks the last input after weight changes) → bump
|
||
version. LR suppression: training calls multiply their LR by `jolt_lr_scale()`; the ramp is
|
||
ticked from the same driver until it reaches 1.0.
|
||
- **Snapshot before press:** entering jolt pushes an auto-snapshot (tag `before jolt`) so undo
|
||
recovers the pre-jolt net — same behaviour as playground `mode-runtime.ts:551`.
|
||
- **UI:** a press-and-hold **⚡ Jolt** button in the **learn drawer** (condensed depth, beside
|
||
the VerdictCluster-adjacent controls) and keyboard hold `J`. Visual: the Manifold noise ring
|
||
pulses while active. Pointer-cancel/blur must release (never a stuck jolt).
|
||
- **Interlocks:** disabled while `exploring` (explore-and-place owns the net during a scratchpad
|
||
session); `off`/`fixed` outputs are unaffected by definition (§2.2 mask applies only to
|
||
training — jolt perturbs *weights*, so v1 explicitly documents that jolt ignores per-output
|
||
pins; a per-column jolt mask is out of scope).
|
||
|
||
### 3.5 Probe + acceptance
|
||
|
||
Probe: `joltPress()`, `joltRelease()`, `joltActive()`. e2e: press → 200 ms → assert outputs
|
||
changed and `getWeights()` differs; release → assert weights stable; undo restores pre-jolt
|
||
weights; determinism: same seed + same step count ⇒ identical weight vector twice.
|
||
|
||
---
|
||
|
||
## 4. OU-explore noise
|
||
|
||
### 4.1 What it is
|
||
|
||
Ornstein–Uhlenbeck drift added to the *output vector* (not the weights): each output channel
|
||
carries a state that takes small random steps while being pulled back toward zero, producing
|
||
smooth, temporally-correlated wander around whatever the net currently outputs. Learning stays
|
||
live throughout — it is an audition aid, not a training mode. C++ `OUNoise<N>` shipped in
|
||
firmware (SLP workshop Part I).
|
||
|
||
Discrete update per channel (Euler–Maruyama, μ = 0):
|
||
|
||
```
|
||
state += theta · (−state) · dt + noise_scale · N(0,1)
|
||
out = clamp(out + state, 0, 1)
|
||
noise_scale = stationary_std · √(2·theta·dt), stationary_std = intensity · 0.65
|
||
```
|
||
|
||
### 4.2 Binding
|
||
|
||
Instantiate `nisps::ml::OUNoise<126>` (the WASM output width) beside the MLP handle. Bindings:
|
||
|
||
```
|
||
_nisps_ml_ou_set_intensity(ml, f32) // [0,1] → stationary_std = v·0.65; 0 disables
|
||
_nisps_ml_ou_intensity(ml) -> f32
|
||
_nisps_ml_ou_apply(ml, out_ptr, n) // advance state + add + clamp, in place
|
||
_nisps_ml_ou_reset(ml)
|
||
_nisps_ml_ou_set_theta(ml, f32) _nisps_ml_ou_set_dt(ml, f32)
|
||
_nisps_ml_ou_seed(ml, u64)
|
||
```
|
||
|
||
### 4.3 Constants (defaults — `nisps/ml/ou_noise.hpp`, upstream-verbatim)
|
||
|
||
| Constant | Value |
|
||
|---|---|
|
||
| `theta` (mean reversion) | 0.02 |
|
||
| `dt` | 0.001 |
|
||
| `kOUMaxAmplitude` (full-scale stationary std) | 0.65 |
|
||
| default intensity | 0 (**inert** — `apply()` is a no-op, preserving current behaviour/parity) |
|
||
| idle tick period | 30 ms (`EXPLORE_TICK_MS`) |
|
||
|
||
### 4.4 Manifold integration
|
||
|
||
- **Where in the chain:** in the spine, after the TS output-pipeline (curve→smoothing→slew→
|
||
freeze) and before the backend send — i.e. `ou_apply` on the routed buffer. This matches the
|
||
playground ordering (post-pipeline, pre-overrides). `fixed`/`off` outputs are re-asserted
|
||
*after* `ou_apply` so pinned values do not wander (this is the §2.2 guarantee).
|
||
- **Idle ticking:** when intensity > 0 and no input events arrive, a 30 ms interval calls
|
||
`reprocess()` so the wander is audible with a static joystick. Interval torn down and
|
||
`ou_reset()` called when intensity returns to 0.
|
||
- **UI:** an **Explore** slider (0–1) in the **learn drawer** condensed depth, next to the
|
||
existing Noise/Spread knobs; the Manifold noise ring radius reflects
|
||
`noiseCap + ou_intensity·0.65` so the visual language stays truthful.
|
||
- **Interaction with Jolt:** independent and composable (jolt stirs weights, OU wanders
|
||
outputs); both default inert.
|
||
|
||
### 4.5 Probe + acceptance
|
||
|
||
Probe: `setExploreIntensity(v)`, `getExploreIntensity()`. e2e: intensity 0.5 + static input ⇒
|
||
outputs vary over 500 ms with bounded step size; intensity 0 ⇒ outputs bit-stable; `fixed`
|
||
output does not wander; determinism under seeded runs.
|
||
|
||
---
|
||
|
||
## 5. Boldness / Memory / Precision control surface
|
||
|
||
### 5.1 Concept
|
||
|
||
Three compound axes, each a `[0,1]` fader that drives ~5–6 underlying parameters through a
|
||
piecewise-linear lookup table, with per-param trim-pot offsets on top. This is the "Feel"
|
||
surface from `playground-2.0-rewrite-plan.md` §2.3, ported with the playground's tables as the
|
||
**transparent defaults** — reproduced in full below so the mapping is reviewable here, not
|
||
buried in code.
|
||
|
||
### 5.2 Axis tables (defaults — `playground/src/stores/control-store.ts:38–54`, verbatim)
|
||
|
||
Numeric params interpolate linearly between breakpoints; discrete params snap to the upper row
|
||
at t ≥ 0.75.
|
||
|
||
**Boldness** — "how hard do moves hit"
|
||
|
||
| axis | zoom | noiseCap | noiseGrowth | learningRate | weightDecay | noiseDistribution |
|
||
|---|---|---|---|---|---|---|
|
||
| 0.0 | 0.1 | 0.02 | 1.1 | 0.1 | 0.15 | gaussian |
|
||
| 0.5 | 0.5 | 0.12 | 1.5 | 1.0 | 0.06 | gaussian |
|
||
| 1.0 | 1.0 | 0.30 | 2.5 | 3.0 | 0.00 | cauchy |
|
||
|
||
**Memory** — "how much does the net remember"
|
||
|
||
| axis | maxExamples | exampleDecay | memoryWeightDecay | noiseDecay | convergenceThreshold |
|
||
|---|---|---|---|---|---|
|
||
| 0.0 | 5 | 0.3 | 0.20 | 0.85 | 1e-3 |
|
||
| 0.5 | 50 | 0.7 | 0.06 | 0.97 | 1e-5 |
|
||
| 1.0 | 500 | 1.0 | 0.00 | 0.995 | 1e-8 |
|
||
|
||
**Precision** — "how surgical is the gesture"
|
||
|
||
| axis | inputCurve | deadzone | smoothing | slewRate | momentumZoom |
|
||
|---|---|---|---|---|---|
|
||
| 0.0 | 1.0 | 0.00 | 0.00 | 1.0 | off |
|
||
| 0.5 | 1.5 | 0.05 | 0.15 | 0.3 | off |
|
||
| 1.0 | 3.0 | 0.15 | 0.40 | 0.1 | off |
|
||
|
||
Routing targets in manifold: `zoom/deadzone/inputCurve/smoothing/momentumZoom` →
|
||
`engine/input-pipeline` config; `slewRate` → `engine/output-pipeline`; `noiseCap/noiseGrowth/
|
||
noiseDecay/learningRate/weightDecay` → `FeedbackController` + training params;
|
||
`maxExamples/exampleDecay/convergenceThreshold` → `engine/dataset.ts` + training loop.
|
||
`noiseDistribution` and `momentumZoom` are carried in the schema but may no-op until their
|
||
consumers exist in manifold (each no-op must be logged once at boot — no silent dead params).
|
||
`spread` is deliberately **not** axis-driven (stays an independent lab knob, as in playground).
|
||
|
||
### 5.3 Presets (defaults — verbatim)
|
||
|
||
| id | label | boldness | memory | precision |
|
||
|---|---|---|---|---|
|
||
| `default` | Default | 0.5 | 0.5 | 0.3 |
|
||
| `first-touch` | First Touch | 0.2 | 0.7 | 0.6 |
|
||
| `jazz-hands` | Jazz Hands | 0.8 | 0.2 | 0.0 |
|
||
| `sculptor` | Sculptor | 0.3 | 0.9 | 0.8 |
|
||
| `improviser` | Improviser | 0.6 | 0.3 | 0.2 |
|
||
| `microscope` | Microscope | 0.1 | 1.0 | 1.0 |
|
||
|
||
Boot default = the `default` preset (0.5 / 0.5 / 0.3). **This replaces ConsoleApp's current
|
||
ad-hoc `{0.55, 0.4, 0.5}` initial state.** Moving any axis manually clears `presetId` (chips
|
||
show "custom").
|
||
|
||
### 5.4 State + trim-pot offsets
|
||
|
||
```ts
|
||
interface ControlState {
|
||
boldness: number; memory: number; precision: number; // [0,1]
|
||
offsets: Record<'boldness'|'memory'|'precision', Record<string, number>>;
|
||
presetId: string | null;
|
||
}
|
||
```
|
||
|
||
Resolution: interpolate all three tables, merge, then add each axis's offsets to numeric params
|
||
(`resolved[k] = table[k] + offset[k]`), clamp to each param's legal range. Offsets are created
|
||
when the user adjusts a *derived* param directly (§5.6) — the delta from the current table value
|
||
becomes the offset ("trim-pot"). Double-tap on a derived param's readout clears its offset
|
||
("re-link"); a per-axis "re-link all" clears the axis's offset map. A dot marks any param with a
|
||
non-zero offset.
|
||
|
||
### 5.5 Fanout (architecture requirement)
|
||
|
||
Per `engine-architecture.md` §3.2: resolution is a memoised derivation per target param — in
|
||
React terms one `useMemo` producing the resolved record + an effect per target store that
|
||
writes only on change. The playground's `JSON.stringify`-signature-inside-effect pattern
|
||
(`control-routing.ts:34`) is explicitly **not** ported. Resolution runs off the render cycle for
|
||
engine targets (direct setter calls on the engine config objects), with React state only for
|
||
what the UI displays.
|
||
|
||
### 5.6 UI — transparency is the feature
|
||
|
||
- **Placement:** the **learn drawer** gains a top "Feel" section (this keeps manifold at five
|
||
drawers; if the section crowds the condensed depth, promoting Feel to its own drawer is a
|
||
pre-approved fallback — note it in the implementation PR).
|
||
- *Condensed:* three vertical `ControlAxis` faders (the existing primitive, finally wired) +
|
||
six preset chips.
|
||
- *Expanded:* under each fader, the live list of its derived params — name, resolved value
|
||
(table + offset), and a mini trim-pot. This satisfies the "reasonable and transparent
|
||
defaults" requirement: the mapping is always visible in the UI, not just in this document.
|
||
- Axis moves are continuous (no apply button); param writes are rate-limited to one per frame.
|
||
- The existing `preset = 'Sculpt'` placeholder state in ConsoleApp is removed in favour of
|
||
`presetId`.
|
||
|
||
### 5.7 Persistence, probe, acceptance
|
||
|
||
- Persisted as the `control` slice of `mf-session` (also the `control` layer of session
|
||
presets, §1 — one shape, two containers).
|
||
- Probe: `setAxis(name, v)`, `getAxes()`, `resolveControlParams()`, `applyControlPreset(id)`,
|
||
`setAxisOffset(axis, param, v)`.
|
||
- e2e: boldness 0→1 sweeps noiseCap 0.02→0.30 and zoom 0.1→1.0 (assert via
|
||
`resolveControlParams()` + engine config readback); preset chip sets all three axes; manual
|
||
axis move clears presetId; offset survives axis movement (offset is additive, not absolute);
|
||
double-tap re-links; state round-trips through reload and through a session preset.
|
||
|
||
---
|
||
|
||
## 6. Cross-cutting
|
||
|
||
### 6.1 Build & sequencing (proposed ergo breakdown, post-sign-off)
|
||
|
||
1. **W1 — WASM bindings**: jolt + OU C ABI, EXPORTED_FUNCS, parity golden tests. (Blocks W4, W5.)
|
||
2. **W2 — `persist<T>` helper + `mf-session` document** (control slice, pins, presets scaffold).
|
||
3. **W3 — Control surface**: state, tables, fanout, Feel section, presets, offsets. (Needs W2.)
|
||
4. **W4 — Jolt + OU in FeedbackController + learn drawer UI.** (Needs W1.)
|
||
5. **W5 — Region pins** (tokens, gesture, rendering, popover) + tri-state training-mask
|
||
guarantee (§2.2). (Needs W2.)
|
||
6. **W6 — Session presets**: layers, preset bar, URL sharing, probe. (Needs W2, W3.)
|
||
|
||
Each lands with its e2e spec; smoke suite additions run in the same CI gate as the existing
|
||
`tests/e2e/smoke.spec.ts`.
|
||
|
||
### 6.2 Out of scope (explicit)
|
||
|
||
- Snapshot-with-weights / snapshot DAG, A/B compare (separate spec — the biggest remaining
|
||
parity gap, but architecturally entangled with the C++ feedback snapshot lifecycle).
|
||
- 2D heatmap sampler, weight health, gradient flow (blocked on loss-history C API plumbing).
|
||
- Input zoom (inputs workstream) — noted where it interacts with region pins (§2.1).
|
||
- Per-column jolt masking (§3.4).
|
||
|
||
### 6.3 Open questions for review
|
||
|
||
1. §2.1 creation gesture: is the 0.25×0.25 press-centred region an acceptable stand-in until
|
||
input zoom lands, or should region pins wait for the zoom feature?
|
||
2. §5.6: Feel section inside the learn drawer vs a sixth drawer — reviewer's call if condensed
|
||
depth gets crowded.
|
||
3. §1.1 RoutingLayer: should backend connection details (OSC URL, MIDI port id) be included in
|
||
session presets, or excluded as machine-specific? Proposal: exclude port/URL, include
|
||
per-output mappings.
|
||
4. Memory-axis params (`maxExamples`, `exampleDecay`, `convergenceThreshold`): confirm the
|
||
manifold training loop actually consumes these; if any are dead in the current engine, they
|
||
land as logged no-ops (§5.2) with an ergo follow-up.
|
||
|
||
---
|
||
|
||
*Doc-sync rule applies: when implementation diverges from this spec, update the spec in the same
|
||
commit. When a workstream ships and is verified, migrate its section to ONBOARDING.md ("what
|
||
is") and prune it here.*
|