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
`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),
`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
loaded via fetch+indirect-eval (Emscripten MODULARIZE glue has no ES exports), base-aware via `document.baseURI`
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
with snap/magnetism/minimap-demotion), `OutputStage`/`SandwichStage`/`ParticleStage`/`Manifold` (canvas,
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
the Learning drawer's `expanded` depth — that IS the advanced-surface flag), `VerdictCluster`
(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.
- Five drawers (`DRAWERS` in `Drawers.tsx`, each has `.render(ctx, depth)` — condensed 360px panel
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
curve (`EngineApi.lossHistory()` ← `nisps_ml_loss_history``MLPCore::loss_history`) plus
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
core has no history; it never synthesises a curve.
- **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.
- **help** — keymap pills + loop explanation.
- `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:
`setInput/setInputs`, `getOutputs/routedOutput`, training (`addExample/train/trainAsync/evalLoss`),
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
worker's mirror net, so the main handle's own history is empty for those runs; both paths
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
* 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 type { ConsoleCtx, DrawerDepth, DrawerKey, FeedbackModeUI, SoloMode } from './types';
import type { InputMode } from '../inputs';
@ -31,6 +31,7 @@ import { outputModeDescriptor } from './output-mode';
import { useSettings, unfocusedIconCss } from '../settings/settings-store';
import type { UnfocusedIconColour, InputMapMode } from '../settings/settings-store';
import type { ExampleResizePolicy, NetworkResizePolicy } from '../engine/io-reshape';
import { useEngine, useEngineVersion } from '../engine';
import { EditorPanel } from '../serial/EditorPanel';
import { TrainingHealth } from './TrainingHealth';
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.',
};
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) {
return (
<>
@ -227,6 +341,9 @@ function LearningDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
</span>
</div>
<SectionLabel>Current model</SectionLabel>
<ModelArchitecture />
{depth === 'expanded' && (
<>
<SectionLabel>Solo / arm scope</SectionLabel>
@ -646,7 +763,9 @@ function RoutingDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
display: 'flex',
flexDirection: 'column',
gap: 4,
maxHeight: expanded ? 460 : 220,
flex: expanded ? 1 : undefined,
minHeight: expanded ? 0 : undefined,
maxHeight: expanded ? undefined : 220,
overflow: 'auto',
}}
>

View file

@ -404,6 +404,14 @@ export class EngineApi {
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.) ----
get ml(): WasmIML {
return this.iml;