feat(manifold): show live model architecture in docks

This commit is contained in:
monkey-w1n5t0n 2026-07-25 15:24:50 +02:00
parent 491820ac79
commit 4db0f498eb
4 changed files with 137 additions and 7 deletions

5
MAP.md
View file

@ -40,7 +40,7 @@ anchor + locked decisions) and the `docs/specs/*-spec.md` set.
`worklet/nisps-processor.ts` (audio), thin WASM wrappers over the core pipelines + curve catalog (the TS `worklet/nisps-processor.ts` (audio), thin WASM wrappers over the core pipelines + curve catalog (the TS
`input-pipeline`/`output-pipeline`/`curves` implementations died at P4), `wasm-worker.ts`, `input-pipeline`/`output-pipeline`/`curves` implementations died at P4), `wasm-worker.ts`,
`spine.ts` (the reactive spine BELOW React — `setInput` derives processed→ml→routed eagerly off-render), `spine.ts` (the reactive spine BELOW React — `setInput` derives processed→ml→routed eagerly off-render),
`engine-api.ts` (`EngineApi` façade incl. `feedback.*` wrappers over the `nisps_ml_feedback_*` C ABI), `engine-api.ts` (`EngineApi` façade incl. live architecture/weight/example metrics and `feedback.*` wrappers over the `nisps_ml_feedback_*` C ABI),
`EngineProvider.tsx`/`useEngine.ts` (React binding via `useSyncExternalStore` version counter). nisps.js is `EngineProvider.tsx`/`useEngine.ts` (React binding via `useSyncExternalStore` version counter). nisps.js is
loaded via fetch+indirect-eval (Emscripten MODULARIZE glue has no ES exports), base-aware via `document.baseURI` loaded via fetch+indirect-eval (Emscripten MODULARIZE glue has no ES exports), base-aware via `document.baseURI`
for the `/next` sub-path. for the `/next` sub-path.
@ -48,7 +48,8 @@ anchor + locked decisions) and the `docs/specs/*-spec.md` set.
- `manifold/src/console/` — the convertible Console: `ConsoleApp`, `CompositeStage` (single-divider convertible - `manifold/src/console/` — the convertible Console: `ConsoleApp`, `CompositeStage` (single-divider convertible
with snap/magnetism/minimap-demotion), `OutputStage`/`SandwichStage`/`ParticleStage`/`Manifold` (canvas, with snap/magnetism/minimap-demotion), `OutputStage`/`SandwichStage`/`ParticleStage`/`Manifold` (canvas,
rect↔circular + feedback markers), `Dock` (top Mode selector + 5 vertically-centred drawers), `Drawers` rect↔circular + feedback markers), `Dock` (top Mode selector + 5 vertically-centred drawers), `Drawers`
(Learning/Inputs/Outputs/Settings/Help), `TrainingHealth` (real per-iteration loss curve from (Learning/Inputs/Outputs/Settings/Help; Learning includes the live model-architecture inspector and
expanded Outputs owns the remaining scroll height), `TrainingHealth` (real per-iteration loss curve from
`nisps_ml_loss_history` + per-layer weight health from `nisps_ml_get_layer_stats`; rendered only at `nisps_ml_loss_history` + per-layer weight health from `nisps_ml_get_layer_stats`; rendered only at
the Learning drawer's `expanded` depth — that IS the advanced-surface flag), `VerdictCluster` the Learning drawer's `expanded` depth — that IS the advanced-surface flag), `VerdictCluster`
(mode-aware), `OutputEditor`/`DualRange`/`CurvePad`, `icons.tsx` (mode-aware), `OutputEditor`/`DualRange`/`CurvePad`, `icons.tsx`

View file

@ -146,7 +146,8 @@ for the narrow pane.
icons, vertically centred macOS-dock style; **BOTTOM** = sandwich toggle. icons, vertically centred macOS-dock style; **BOTTOM** = sandwich toggle.
- Five drawers (`DRAWERS` in `Drawers.tsx`, each has `.render(ctx, depth)` — condensed 360px panel - Five drawers (`DRAWERS` in `Drawers.tsx`, each has `.render(ctx, depth)` — condensed 360px panel
vs expanded 80vw×80vh modal): vs expanded 80vw×80vh modal):
- **learn** — feedback mode (explore-and-place / geometric-dislike) + solo mode + per-output arm. - **learn** — feedback mode (explore-and-place / geometric-dislike) + solo mode + per-output arm,
plus a live model-architecture strip (inputs/outputs highlighted; hidden layers expand on click).
At `expanded` depth ONLY it also renders `TrainingHealth.tsx`: the real per-iteration loss At `expanded` depth ONLY it also renders `TrainingHealth.tsx`: the real per-iteration loss
curve (`EngineApi.lossHistory()` ← `nisps_ml_loss_history``MLPCore::loss_history`) plus curve (`EngineApi.lossHistory()` ← `nisps_ml_loss_history``MLPCore::loss_history`) plus
the per-layer weight-health table (`getLayerStats`). **`depth === 'expanded'` is Manifold's the per-layer weight-health table (`getLayerStats`). **`depth === 'expanded'` is Manifold's
@ -154,7 +155,8 @@ for the narrow pane.
surface there rather than inventing one. The panel renders "no training run yet" when the surface there rather than inventing one. The panel renders "no training run yet" when the
core has no history; it never synthesises a curve. core has no history; it never synthesises a curve.
- **inputs** — enable/configure input sources (XY pad / MIDI / gamepad). - **inputs** — enable/configure input sources (XY pad / MIDI / gamepad).
- **route** (label "Outputs") — per-output control matrix + per-backend config. - **route** (label "Outputs") — per-output control matrix + per-backend config; at expanded depth
the output rows fill the remaining drawer height and scroll independently.
- **settings** — icon style (monochrome/colour), input-map shape (xy/joystick/rect/circular), corner radius. - **settings** — icon style (monochrome/colour), input-map shape (xy/joystick/rect/circular), corner radius.
- **help** — keymap pills + loop explanation. - **help** — keymap pills + loop explanation.
- `src/dock/` holds the output-routing internals used by the `route` drawer: - `src/dock/` holds the output-routing internals used by the `route` drawer:
@ -200,7 +202,7 @@ a setting → `--r-*` tokens.
- `engine-api.ts`**`EngineApi`, the framework-neutral facade** everything in the UI talks to: - `engine-api.ts`**`EngineApi`, the framework-neutral facade** everything in the UI talks to:
`setInput/setInputs`, `getOutputs/routedOutput`, training (`addExample/train/trainAsync/evalLoss`), `setInput/setInputs`, `getOutputs/routedOutput`, training (`addExample/train/trainAsync/evalLoss`),
weights (`getWeights/setWeights/process/randomise`), telemetry weights (`getWeights/setWeights/process/randomise`), telemetry
(`lossHistory/getLayerStats`), `subscribe/version/on`, plus nested `.feedback` and `.audio` (`architecture/weightCount/exampleCount/lossHistory/getLayerStats`), `subscribe/version/on`, plus nested `.feedback` and `.audio`
facades. **`lossHistory()` reads SPINE STATE, not the MLP handle** — an async train runs on the facades. **`lossHistory()` reads SPINE STATE, not the MLP handle** — an async train runs on the
worker's mirror net, so the main handle's own history is empty for those runs; both paths worker's mirror net, so the main handle's own history is empty for those runs; both paths
publish to the spine. publish to the spine.

View file

@ -20,7 +20,7 @@
* Where engine support does not yet exist the UI + state are wired and a TODO * Where engine support does not yet exist the UI + state are wired and a TODO
* references the relevant spec no faked engine behaviour. * references the relevant spec no faked engine behaviour.
*/ */
import type { ReactNode } from 'react'; import { useState, type ReactNode } from 'react';
import { Badge, Button, PillToggle, Slider, Switch } from '../primitives'; import { Badge, Button, PillToggle, Slider, Switch } from '../primitives';
import type { ConsoleCtx, DrawerDepth, DrawerKey, FeedbackModeUI, SoloMode } from './types'; import type { ConsoleCtx, DrawerDepth, DrawerKey, FeedbackModeUI, SoloMode } from './types';
import type { InputMode } from '../inputs'; import type { InputMode } from '../inputs';
@ -31,6 +31,7 @@ import { outputModeDescriptor } from './output-mode';
import { useSettings, unfocusedIconCss } from '../settings/settings-store'; import { useSettings, unfocusedIconCss } from '../settings/settings-store';
import type { UnfocusedIconColour, InputMapMode } from '../settings/settings-store'; import type { UnfocusedIconColour, InputMapMode } from '../settings/settings-store';
import type { ExampleResizePolicy, NetworkResizePolicy } from '../engine/io-reshape'; import type { ExampleResizePolicy, NetworkResizePolicy } from '../engine/io-reshape';
import { useEngine, useEngineVersion } from '../engine';
import { EditorPanel } from '../serial/EditorPanel'; import { EditorPanel } from '../serial/EditorPanel';
import { TrainingHealth } from './TrainingHealth'; import { TrainingHealth } from './TrainingHealth';
import { import {
@ -157,6 +158,119 @@ const SOLO_DESC: Record<SoloMode, string> = {
'dont-care': 'Each example stores a per-output mask so stale labels never pull unarmed outputs.', 'dont-care': 'Each example stores a per-output mask so stale labels never pull unarmed outputs.',
}; };
function ModelArchitecture() {
const engine = useEngine();
useEngineVersion(engine);
const [detailsOpen, setDetailsOpen] = useState(false);
if (!engine) {
return <p style={{ fontSize: 10, color: 'var(--fg-dim)', margin: 0 }}>engine not ready</p>;
}
const arch = engine.architecture;
const hidden = arch.hidden.filter((size) => size > 0);
const layerCount = arch.numLayers || hidden.length + 1;
const sizes = hidden.map((size) => size.toString()).join(' → ');
const stageStyle = (tone: string) => ({
display: 'flex',
flexDirection: 'column' as const,
gap: 2,
minWidth: 0,
padding: '6px 8px',
border: `1px solid ${tone}`,
borderRadius: 'var(--r-1)',
background: 'var(--bg-2)',
});
const labelStyle = {
fontSize: 9,
fontFamily: 'var(--font-mono)',
letterSpacing: '0.08em',
color: 'var(--fg-dim)',
};
const valueStyle = {
fontSize: 12,
fontFamily: 'var(--font-mono)',
fontVariantNumeric: 'tabular-nums',
color: 'var(--fg)',
};
return (
<div data-testid="model-architecture" style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<Chip tone="var(--accent)">
{layerCount} layer{layerCount === 1 ? '' : 's'}
</Chip>
<Chip>{engine.weightCount.toLocaleString()} weights</Chip>
<Chip>{engine.exampleCount} examples</Chip>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto 1fr', gap: 4, alignItems: 'stretch' }}>
<div style={stageStyle('var(--accent-2)')}>
<span style={{ ...labelStyle, color: 'var(--accent-2)' }}>INPUT</span>
<span style={valueStyle}>{arch.inputSize} units</span>
</div>
<div
aria-hidden="true"
style={{ alignSelf: 'center', color: 'var(--fg-dim)', fontFamily: 'var(--font-mono)', fontSize: 11 }}
>
</div>
<div style={stageStyle('var(--accent)')}>
<span style={{ ...labelStyle, color: 'var(--accent)' }}>OUTPUT</span>
<span style={valueStyle}>{arch.outputSize} units</span>
</div>
</div>
<button
type="button"
aria-expanded={detailsOpen}
onClick={() => setDetailsOpen((open) => !open)}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
width: '100%',
padding: '5px 8px',
border: '1px solid var(--line)',
borderRadius: 'var(--r-1)',
background: 'var(--bg-2)',
color: 'var(--fg-mute)',
cursor: 'pointer',
textAlign: 'left',
fontFamily: 'var(--font-mono)',
fontSize: 10,
}}
>
<span style={{ color: 'var(--accent)' }}>{detailsOpen ? '▾' : '▸'}</span>
<span style={{ color: 'var(--fg)' }}>HIDDEN</span>
<span>{hidden.length} layer{hidden.length === 1 ? '' : 's'}</span>
{!detailsOpen && <span style={{ marginLeft: 'auto' }}>{sizes || 'none'}</span>}
</button>
{detailsOpen && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, paddingLeft: 12 }}>
{hidden.length > 0 ? (
hidden.map((size, i) => (
<div
key={i}
style={{
display: 'flex',
justifyContent: 'space-between',
fontSize: 10,
fontFamily: 'var(--font-mono)',
color: 'var(--fg-mute)',
}}
>
<span>HIDDEN {i + 1}</span>
<span style={{ color: 'var(--fg)' }}>{size} units</span>
</div>
))
) : (
<span style={{ fontSize: 10, color: 'var(--fg-dim)' }}>no hidden layers</span>
)}
</div>
)}
</div>
);
}
function LearningDrawer(ctx: ConsoleCtx, depth: DrawerDepth) { function LearningDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
return ( return (
<> <>
@ -227,6 +341,9 @@ function LearningDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
</span> </span>
</div> </div>
<SectionLabel>Current model</SectionLabel>
<ModelArchitecture />
{depth === 'expanded' && ( {depth === 'expanded' && (
<> <>
<SectionLabel>Solo / arm scope</SectionLabel> <SectionLabel>Solo / arm scope</SectionLabel>
@ -646,7 +763,9 @@ function RoutingDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: 4, gap: 4,
maxHeight: expanded ? 460 : 220, flex: expanded ? 1 : undefined,
minHeight: expanded ? 0 : undefined,
maxHeight: expanded ? undefined : 220,
overflow: 'auto', overflow: 'auto',
}} }}
> >

View file

@ -404,6 +404,14 @@ export class EngineApi {
return this.iml.architecture; return this.iml.architecture;
} }
get weightCount(): number {
return this.iml.weightCount;
}
get exampleCount(): number {
return this.iml.exampleCount;
}
// ---- Direct handle access (advanced consumers; spine pipelines, etc.) ---- // ---- Direct handle access (advanced consumers; spine pipelines, etc.) ----
get ml(): WasmIML { get ml(): WasmIML {
return this.iml; return this.iml;