From fa1f963cae633457812e3ee3c38fdfe570ed959a Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 18 Apr 2026 21:57:05 -0700 Subject: [PATCH 01/18] feat(cala): W4 tiered timeseries storage (task 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgrade the archive worker from a latest-value Map to per-name tiered Float32Array rings (design §9.1): L1 full-resolution recent + L2 block-averaged older. Adds `request-timeseries` / `timeseries` protocol variants so the dashboard can pull either tier. Existing `archive-dump` shape (events + latest-value metrics) is unchanged, keeping task 24's dashboard working without edits. Capacities and stride are all overridable via `workerConfig`, no magic numbers. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../workers/__tests__/archive.worker.test.ts | 84 ++++++++ .../__tests__/timeseries-store.test.ts | 102 ++++++++++ apps/cala/src/workers/archive.worker.ts | 51 +++++ apps/cala/src/workers/timeseries-store.ts | 188 ++++++++++++++++++ packages/cala-runtime/src/worker-protocol.ts | 21 +- 5 files changed, 445 insertions(+), 1 deletion(-) create mode 100644 apps/cala/src/workers/__tests__/timeseries-store.test.ts create mode 100644 apps/cala/src/workers/timeseries-store.ts diff --git a/apps/cala/src/workers/__tests__/archive.worker.test.ts b/apps/cala/src/workers/__tests__/archive.worker.test.ts index 63449cb..867a1af 100644 --- a/apps/cala/src/workers/__tests__/archive.worker.test.ts +++ b/apps/cala/src/workers/__tests__/archive.worker.test.ts @@ -6,6 +6,11 @@ import { createWorkerHarness, type WorkerHarness } from './worker-harness.ts'; // arbitrary numbers leaking from production defaults. const TEST_EVENT_RING_CAPACITY = 4; const TEST_METRIC_WINDOW = 16; +// Tiered timeseries sizing that makes L1 eviction + L2 emission +// reachable in a handful of appends (see timeseries-store.test.ts). +const TEST_TS_L1_CAPACITY = 4; +const TEST_TS_L2_CAPACITY = 8; +const TEST_TS_L2_STRIDE = 2; function makeInitMsg(overrides: Record = {}): WorkerInbound { return { @@ -17,6 +22,9 @@ function makeInitMsg(overrides: Record = {}): WorkerInbound { workerConfig: { eventRingCapacity: TEST_EVENT_RING_CAPACITY, metricWindow: TEST_METRIC_WINDOW, + timeseriesL1Capacity: TEST_TS_L1_CAPACITY, + timeseriesL2Capacity: TEST_TS_L2_CAPACITY, + timeseriesL2Stride: TEST_TS_L2_STRIDE, ...overrides, }, }, @@ -71,6 +79,7 @@ describe('worker-protocol archive extension compiles', () => { event: { kind: 'metric', t: 0, name: 'x', value: 1 }, }; const inDumpReq: WorkerInbound = { kind: 'request-archive-dump', requestId: 1 }; + const inTsReq: WorkerInbound = { kind: 'request-timeseries', requestId: 2, name: 'fps' }; const outDump: WorkerOutbound = { kind: 'archive-dump', role: 'archive', @@ -78,9 +87,21 @@ describe('worker-protocol archive extension compiles', () => { events: [], metrics: {}, }; + const outTs: WorkerOutbound = { + kind: 'timeseries', + role: 'archive', + requestId: 2, + name: 'fps', + l1Times: new Float32Array(0), + l1Values: new Float32Array(0), + l2Times: new Float32Array(0), + l2Values: new Float32Array(0), + }; expect(inEvent.kind).toBe('event'); expect(inDumpReq.kind).toBe('request-archive-dump'); + expect(inTsReq.kind).toBe('request-timeseries'); expect(outDump.kind).toBe('archive-dump'); + expect(outTs.kind).toBe('timeseries'); }); }); @@ -175,6 +196,69 @@ describe('archive worker', () => { } }); + it('request-timeseries returns an empty reply for an unknown name', async () => { + const harness = createWorkerHarness(); + await loadWorker(harness); + await harness.deliver(makeInitMsg()); + await runUntil(harness, (p) => p.some((m) => m.kind === 'ready')); + await harness.deliver({ kind: 'run' }); + + await harness.deliver({ kind: 'request-timeseries', requestId: 51, name: 'nope' }); + await runUntil(harness, (p) => p.some((m) => m.kind === 'timeseries')); + const reply = harness.posted.find((m) => m.kind === 'timeseries') as Extract< + WorkerOutbound, + { kind: 'timeseries' } + >; + expect(reply.requestId).toBe(51); + expect(reply.name).toBe('nope'); + expect(reply.l1Times.length).toBe(0); + expect(reply.l2Times.length).toBe(0); + }); + + it('request-timeseries returns L1 samples appended from metric events', async () => { + const harness = createWorkerHarness(); + await loadWorker(harness); + await harness.deliver(makeInitMsg()); + await runUntil(harness, (p) => p.some((m) => m.kind === 'ready')); + await harness.deliver({ kind: 'run' }); + + await harness.deliver({ kind: 'event', event: metricEvent(0, 'fps', 10) }); + await harness.deliver({ kind: 'event', event: metricEvent(1, 'fps', 20) }); + await harness.deliver({ kind: 'event', event: metricEvent(2, 'fps', 30) }); + + await harness.deliver({ kind: 'request-timeseries', requestId: 60, name: 'fps' }); + await runUntil(harness, (p) => p.some((m) => m.kind === 'timeseries')); + const reply = harness.posted.find((m) => m.kind === 'timeseries') as Extract< + WorkerOutbound, + { kind: 'timeseries' } + >; + expect(Array.from(reply.l1Times)).toEqual([0, 1, 2]); + expect(Array.from(reply.l1Values)).toEqual([10, 20, 30]); + expect(reply.l2Times.length).toBe(0); + }); + + it('request-timeseries surfaces L2 downsampling once L1 overflows', async () => { + const harness = createWorkerHarness(); + await loadWorker(harness); + await harness.deliver(makeInitMsg()); + await runUntil(harness, (p) => p.some((m) => m.kind === 'ready')); + await harness.deliver({ kind: 'run' }); + + // l1Capacity=4, l2Stride=2 → 6 metric events leave 4 in L1 and 1 + // averaged sample in L2. + for (let i = 0; i < 6; i += 1) { + await harness.deliver({ kind: 'event', event: metricEvent(i, 'fps', i * 10) }); + } + await harness.deliver({ kind: 'request-timeseries', requestId: 61, name: 'fps' }); + await runUntil(harness, (p) => p.some((m) => m.kind === 'timeseries')); + const reply = harness.posted.find((m) => m.kind === 'timeseries') as Extract< + WorkerOutbound, + { kind: 'timeseries' } + >; + expect(Array.from(reply.l1Times)).toEqual([2, 3, 4, 5]); + expect(Array.from(reply.l2Values)).toEqual([5]); + }); + it('stop posts done exactly once even if events arrive after stop', async () => { const harness = createWorkerHarness(); await loadWorker(harness); diff --git a/apps/cala/src/workers/__tests__/timeseries-store.test.ts b/apps/cala/src/workers/__tests__/timeseries-store.test.ts new file mode 100644 index 0000000..51d7803 --- /dev/null +++ b/apps/cala/src/workers/__tests__/timeseries-store.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; +import { TimeseriesStore, type TimeseriesStoreConfig } from '../timeseries-store.ts'; + +// Small capacities keep tier transitions observable without relying on +// production defaults. +const TEST_L1: TimeseriesStoreConfig = { + l1Capacity: 4, + l2Capacity: 3, + l2Stride: 2, + maxNames: 4, +}; + +describe('TimeseriesStore', () => { + it('rejects non-positive config values', () => { + expect(() => new TimeseriesStore({ ...TEST_L1, l1Capacity: 0 })).toThrow(); + expect(() => new TimeseriesStore({ ...TEST_L1, l2Stride: -1 })).toThrow(); + expect(() => new TimeseriesStore({ ...TEST_L1, maxNames: 0 })).toThrow(); + }); + + it('returns empty arrays for an unknown name', () => { + const store = new TimeseriesStore(TEST_L1); + const q = store.query('missing'); + expect(q.l1Times.length).toBe(0); + expect(q.l1Values.length).toBe(0); + expect(q.l2Times.length).toBe(0); + expect(q.l2Values.length).toBe(0); + }); + + it('keeps L1 in chronological order while it is still filling', () => { + const store = new TimeseriesStore(TEST_L1); + store.append('fps', 0, 10); + store.append('fps', 1, 20); + store.append('fps', 2, 30); + const q = store.query('fps'); + expect(Array.from(q.l1Times)).toEqual([0, 1, 2]); + expect(Array.from(q.l1Values)).toEqual([10, 20, 30]); + expect(q.l2Times.length).toBe(0); + }); + + it('evicts oldest L1 samples into the L2 block accumulator', () => { + const store = new TimeseriesStore(TEST_L1); + // l1Capacity=4, l2Stride=2: the first two L1 evictions (samples 0 + // and 1 below) aggregate into a single L2 sample at t=0.5. + for (let i = 0; i < 6; i += 1) { + store.append('fps', i, i * 10); + } + const q = store.query('fps'); + // L1 holds the newest 4 samples in order. + expect(Array.from(q.l1Times)).toEqual([2, 3, 4, 5]); + expect(Array.from(q.l1Values)).toEqual([20, 30, 40, 50]); + // L2 has one averaged block from evicted samples (0, 10) → mean 5 at t=0.5. + expect(Array.from(q.l2Times)).toEqual([0.5]); + expect(Array.from(q.l2Values)).toEqual([5]); + }); + + it('drops oldest L2 sample once L2 capacity is exceeded', () => { + const store = new TimeseriesStore(TEST_L1); + // Need l1Capacity + l2Stride * (l2Capacity + 1) = 4 + 2*4 = 12 appends + // to produce 4 L2 emissions (one more than l2Capacity=3). + for (let i = 0; i < 12; i += 1) { + store.append('fps', i, i); + } + const q = store.query('fps'); + expect(q.l2Times.length).toBe(TEST_L1.l2Capacity); + // Oldest L2 block (from samples 0+1, mean 0.5 at t=0.5) has been + // evicted; remaining blocks cover samples 2+3, 4+5, 6+7. + expect(Array.from(q.l2Times)).toEqual([2.5, 4.5, 6.5]); + expect(Array.from(q.l2Values)).toEqual([2.5, 4.5, 6.5]); + }); + + it('wraps L1 ring correctly so query returns a contiguous chronological slice', () => { + const store = new TimeseriesStore(TEST_L1); + for (let i = 0; i < TEST_L1.l1Capacity * 2 + 1; i += 1) { + store.append('fps', i, i); + } + const q = store.query('fps'); + // Regardless of internal head position, the returned array is + // strictly monotonically increasing in t. + for (let i = 1; i < q.l1Times.length; i += 1) { + expect(q.l1Times[i]).toBeGreaterThan(q.l1Times[i - 1]); + } + }); + + it('tracks each name independently', () => { + const store = new TimeseriesStore(TEST_L1); + store.append('a', 0, 1); + store.append('b', 0, 100); + store.append('a', 1, 2); + expect(Array.from(store.query('a').l1Values)).toEqual([1, 2]); + expect(Array.from(store.query('b').l1Values)).toEqual([100]); + expect(store.names().sort()).toEqual(['a', 'b']); + }); + + it('drops oldest-inserted name once maxNames is reached', () => { + const store = new TimeseriesStore({ ...TEST_L1, maxNames: 2 }); + store.append('a', 0, 1); + store.append('b', 0, 2); + store.append('c', 0, 3); // Evicts 'a'. + expect(store.names().sort()).toEqual(['b', 'c']); + expect(store.query('a').l1Times.length).toBe(0); + }); +}); diff --git a/apps/cala/src/workers/archive.worker.ts b/apps/cala/src/workers/archive.worker.ts index 1b4aff7..83a1311 100644 --- a/apps/cala/src/workers/archive.worker.ts +++ b/apps/cala/src/workers/archive.worker.ts @@ -28,6 +28,7 @@ import { type WorkerInitPayload, type WorkerOutbound, } from '@calab/cala-runtime'; +import { TimeseriesStore } from './timeseries-store.ts'; // Rolling event log capacity. Design §9.2 sizes ~500 structural // events per typical session at ~2 KB each → ~1 MB budget; we default @@ -37,6 +38,16 @@ const DEFAULT_EVENT_RING_CAPACITY = 4096; // upstream cannot balloon memory. Overridable via // `workerConfig.metricWindow`. const DEFAULT_METRIC_WINDOW = 256; +// Tiered timeseries sizing (design §9.1). L1 holds the most recent +// `l1Capacity` samples at full resolution; L2 holds `l2Capacity` +// samples downsampled in blocks of `l2Stride` (so L2 covers up to +// `l2Capacity * l2Stride` historical samples). Defaults sized so +// one-vitals-per-frame at 30 fps keeps ~8.5 s at full resolution and +// ~17 min of older context at ~1 Hz, well within the §9 memory +// budget. +const DEFAULT_TS_L1_CAPACITY = 256; +const DEFAULT_TS_L2_CAPACITY = 1024; +const DEFAULT_TS_L2_STRIDE = 16; // Local EventBus sizing. Archive is the sole subscriber post-init and // drains synchronously, so these are effectively no-backpressure // defaults — but they live in config per the no-magic-numbers rule. @@ -55,6 +66,9 @@ interface ArchiveWorkerConfig { metricWindow: number; localBusCapacity: number; localBusMaxSubscribers: number; + timeseriesL1Capacity: number; + timeseriesL2Capacity: number; + timeseriesL2Stride: number; } const workerSelf = ((globalThis as unknown as { self?: WorkerGlobalScope }).self ?? @@ -71,6 +85,8 @@ interface RuntimeHandles { // not need. eventLog: PipelineEvent[]; metricSnapshot: Map; + timeseries: TimeseriesStore; + unsubscribeTimeseries: () => void; running: boolean; stopped: boolean; } @@ -105,6 +121,9 @@ function parseConfig(raw: unknown): ArchiveWorkerConfig { 'localBusMaxSubscribers', DEFAULT_LOCAL_BUS_MAX_SUBSCRIBERS, ), + timeseriesL1Capacity: pickPositiveInt('timeseriesL1Capacity', DEFAULT_TS_L1_CAPACITY), + timeseriesL2Capacity: pickPositiveInt('timeseriesL2Capacity', DEFAULT_TS_L2_CAPACITY), + timeseriesL2Stride: pickPositiveInt('timeseriesL2Stride', DEFAULT_TS_L2_STRIDE), }; } @@ -116,6 +135,12 @@ function handleInit(payload: WorkerInitPayload): void { }); const eventLog: PipelineEvent[] = []; const metricSnapshot = new Map(); + const timeseries = new TimeseriesStore({ + l1Capacity: cfg.timeseriesL1Capacity, + l2Capacity: cfg.timeseriesL2Capacity, + l2Stride: cfg.timeseriesL2Stride, + maxNames: cfg.metricWindow, + }); const unsubscribeLog = bus.subscribe((e) => { if (eventLog.length === cfg.eventRingCapacity) { @@ -136,6 +161,11 @@ function handleInit(payload: WorkerInitPayload): void { metricSnapshot.set(e.name, e.value); }); + const unsubscribeTimeseries = bus.subscribe((e) => { + if (e.kind !== 'metric') return; + timeseries.append(e.name, e.t, e.value); + }); + handles = { cfg, bus, @@ -143,6 +173,8 @@ function handleInit(payload: WorkerInitPayload): void { unsubscribeMetrics, eventLog, metricSnapshot, + timeseries, + unsubscribeTimeseries, running: false, stopped: false, }; @@ -168,6 +200,21 @@ function handleDumpRequest(requestId: number): void { }); } +function handleTimeseriesRequest(requestId: number, name: string): void { + if (!handles) return; + const q = handles.timeseries.query(name); + post({ + kind: 'timeseries', + role: ROLE, + requestId, + name, + l1Times: q.l1Times, + l1Values: q.l1Values, + l2Times: q.l2Times, + l2Values: q.l2Values, + }); +} + function postDoneOnce(): void { if (donePosted) return; donePosted = true; @@ -182,6 +229,7 @@ function handleStop(): void { handles.stopped = true; handles.unsubscribeLog(); handles.unsubscribeMetrics(); + handles.unsubscribeTimeseries(); handles.bus.close(); postDoneOnce(); } @@ -205,6 +253,9 @@ workerSelf.onmessage = (ev: MessageEvent): void => { case 'request-archive-dump': handleDumpRequest(msg.requestId); return; + case 'request-timeseries': + handleTimeseriesRequest(msg.requestId, msg.name); + return; case 'stop': handleStop(); return; diff --git a/apps/cala/src/workers/timeseries-store.ts b/apps/cala/src/workers/timeseries-store.ts new file mode 100644 index 0000000..b872aed --- /dev/null +++ b/apps/cala/src/workers/timeseries-store.ts @@ -0,0 +1,188 @@ +/** + * Tiered per-name timeseries store for the archive worker (design §9.1). + * + * Each named metric gets two flat `Float32Array` rings: + * + * * **L1** — recent full-resolution samples (most recent `l1Capacity` + * appends, drop-oldest). + * * **L2** — older samples downsampled in blocks of `l2Stride`, again + * drop-oldest at `l2Capacity`. + * + * When L1 evicts a sample it rolls into an accumulator; every + * `l2Stride` evictions the accumulator's mean is emitted into L2 with + * the midpoint timestamp. That keeps per-metric memory bounded at + * `O(l1Capacity + l2Capacity)` regardless of recording length. + * + * The store is intentionally in-process: the archive worker owns it, + * `TimeseriesStore.query` returns plain `Float32Array`s the worker can + * post back without touching the internals. No magic numbers — + * capacities and stride are caller-supplied. + */ +const EMPTY_F32 = new Float32Array(0); + +export interface TimeseriesStoreConfig { + /** Full-resolution ring size per metric name. */ + l1Capacity: number; + /** Downsampled ring size per metric name. */ + l2Capacity: number; + /** Number of L1 evictions that aggregate into one L2 sample. */ + l2Stride: number; + /** Hard cap on distinct metric names (drop-oldest by insertion order). */ + maxNames: number; +} + +export interface TimeseriesQuery { + /** Oldest→newest L1 timestamps, chronological. */ + l1Times: Float32Array; + /** Values paired with `l1Times`. */ + l1Values: Float32Array; + /** Oldest→newest L2 timestamps (midpoint of their block). */ + l2Times: Float32Array; + /** Mean value of each L2 block, paired with `l2Times`. */ + l2Values: Float32Array; +} + +interface PerName { + l1Times: Float32Array; + l1Values: Float32Array; + l1Head: number; + l1Count: number; + l2Times: Float32Array; + l2Values: Float32Array; + l2Head: number; + l2Count: number; + // Running L2 block aggregator fed by L1 evictions. + accumSum: number; + accumCount: number; + accumFirstT: number; + accumLastT: number; +} + +function validateConfig(cfg: TimeseriesStoreConfig): void { + const check = (name: keyof TimeseriesStoreConfig, v: number): void => { + if (!Number.isInteger(v) || v < 1) { + throw new Error(`TimeseriesStoreConfig.${name} must be an integer ≥ 1 (got ${v})`); + } + }; + check('l1Capacity', cfg.l1Capacity); + check('l2Capacity', cfg.l2Capacity); + check('l2Stride', cfg.l2Stride); + check('maxNames', cfg.maxNames); +} + +export class TimeseriesStore { + private readonly cfg: TimeseriesStoreConfig; + private readonly byName = new Map(); + + constructor(cfg: TimeseriesStoreConfig) { + validateConfig(cfg); + this.cfg = cfg; + } + + /** + * Append `(t, value)` to the named series. If the name is new and + * `maxNames` is exceeded, drops the oldest-inserted name (Map + * iteration order is insertion order). + */ + append(name: string, t: number, value: number): void { + let entry = this.byName.get(name); + if (!entry) { + if (this.byName.size >= this.cfg.maxNames) { + const oldest = this.byName.keys().next().value; + if (oldest !== undefined) this.byName.delete(oldest); + } + entry = this.makeEntry(); + this.byName.set(name, entry); + } + + if (entry.l1Count === this.cfg.l1Capacity) { + const evictIdx = entry.l1Head; + this.feedAccum(entry, entry.l1Times[evictIdx], entry.l1Values[evictIdx]); + } + const writeIdx = (entry.l1Head + entry.l1Count) % this.cfg.l1Capacity; + entry.l1Times[writeIdx] = t; + entry.l1Values[writeIdx] = value; + if (entry.l1Count === this.cfg.l1Capacity) { + entry.l1Head = (entry.l1Head + 1) % this.cfg.l1Capacity; + } else { + entry.l1Count += 1; + } + } + + /** + * Snapshot the named series in chronological order. Unknown names + * return empty arrays rather than throwing — the caller (dashboard) + * typically polls before any samples exist. + */ + query(name: string): TimeseriesQuery { + const entry = this.byName.get(name); + if (!entry) { + return { l1Times: EMPTY_F32, l1Values: EMPTY_F32, l2Times: EMPTY_F32, l2Values: EMPTY_F32 }; + } + return { + l1Times: flatten(entry.l1Times, entry.l1Head, entry.l1Count, this.cfg.l1Capacity), + l1Values: flatten(entry.l1Values, entry.l1Head, entry.l1Count, this.cfg.l1Capacity), + l2Times: flatten(entry.l2Times, entry.l2Head, entry.l2Count, this.cfg.l2Capacity), + l2Values: flatten(entry.l2Values, entry.l2Head, entry.l2Count, this.cfg.l2Capacity), + }; + } + + names(): string[] { + return Array.from(this.byName.keys()); + } + + private makeEntry(): PerName { + return { + l1Times: new Float32Array(this.cfg.l1Capacity), + l1Values: new Float32Array(this.cfg.l1Capacity), + l1Head: 0, + l1Count: 0, + l2Times: new Float32Array(this.cfg.l2Capacity), + l2Values: new Float32Array(this.cfg.l2Capacity), + l2Head: 0, + l2Count: 0, + accumSum: 0, + accumCount: 0, + accumFirstT: 0, + accumLastT: 0, + }; + } + + private feedAccum(entry: PerName, t: number, value: number): void { + if (entry.accumCount === 0) entry.accumFirstT = t; + entry.accumLastT = t; + entry.accumSum += value; + entry.accumCount += 1; + if (entry.accumCount >= this.cfg.l2Stride) { + const mean = entry.accumSum / entry.accumCount; + const mid = (entry.accumFirstT + entry.accumLastT) / 2; + this.pushL2(entry, mid, mean); + entry.accumSum = 0; + entry.accumCount = 0; + } + } + + private pushL2(entry: PerName, t: number, value: number): void { + const writeIdx = (entry.l2Head + entry.l2Count) % this.cfg.l2Capacity; + entry.l2Times[writeIdx] = t; + entry.l2Values[writeIdx] = value; + if (entry.l2Count === this.cfg.l2Capacity) { + entry.l2Head = (entry.l2Head + 1) % this.cfg.l2Capacity; + } else { + entry.l2Count += 1; + } + } +} + +function flatten(src: Float32Array, head: number, count: number, cap: number): Float32Array { + if (count === 0) return EMPTY_F32; + const out = new Float32Array(count); + if (head + count <= cap) { + out.set(src.subarray(head, head + count)); + } else { + const first = cap - head; + out.set(src.subarray(head, cap), 0); + out.set(src.subarray(0, count - first), first); + } + return out; +} diff --git a/packages/cala-runtime/src/worker-protocol.ts b/packages/cala-runtime/src/worker-protocol.ts index 229e541..6e3ad56 100644 --- a/packages/cala-runtime/src/worker-protocol.ts +++ b/packages/cala-runtime/src/worker-protocol.ts @@ -52,7 +52,12 @@ export type WorkerInbound = // Main-thread dashboard (task 24) asks for a consistent dump of the // archive's in-memory event log and per-name metric snapshot. // `requestId` correlates each dump with the eventual reply. - | { kind: 'request-archive-dump'; requestId: number }; + | { kind: 'request-archive-dump'; requestId: number } + // Tiered timeseries query for a single named metric (design §9.1, + // Phase 6 task 1). `requestId` correlates the request with the + // matching `timeseries` reply. Unknown names return empty arrays, + // not an error — the dashboard polls before any samples exist. + | { kind: 'request-timeseries'; requestId: number; name: string }; /** Messages a worker sends back to the orchestrator. */ export type WorkerOutbound = @@ -73,6 +78,20 @@ export type WorkerOutbound = events: PipelineEvent[]; metrics: Record; } + // Reply to `request-timeseries`. `l1*` arrays are the full-resolution + // recent ring for `name`; `l2*` are downsampled older samples + // (design §9.1 tiered retention). All arrays are fresh copies in + // chronological order so the caller cannot mutate archive state. + | { + kind: 'timeseries'; + role: WorkerRole; + requestId: number; + name: string; + l1Times: Float32Array; + l1Values: Float32Array; + l2Times: Float32Array; + l2Values: Float32Array; + } // W1 preview frame for the dashboard viewer (design §12 frame panel, // Phase 5 exit). Strided like `frame-processed` so the post rate is // bounded even when W1 outruns the main-thread canvas; `pixels` is From 0e4c91a7b73848702133b1a290fd4a94619ca605 Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 18 Apr 2026 22:01:13 -0700 Subject: [PATCH 02/18] feat(cala): W4 per-neuron event index (task 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `NeuronEventIndex` — bounded drop-oldest per-neuron ring of every structural event the neuron participates in (design §9.2), so "show me the history of neuron 47" is O(index) instead of a scan of the global event ring. Wires a third bus subscriber in the archive worker plus a `request-events-for-neuron` / `events-for-neuron` protocol pair. Also bumps the archive worker's local-bus `maxSubscribers` default to 8 so the upcoming footprint-history subscriber fits without another edit. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../workers/__tests__/archive.worker.test.ts | 68 ++++++++++ .../__tests__/neuron-event-index.test.ts | 127 ++++++++++++++++++ apps/cala/src/workers/archive.worker.ts | 43 +++++- apps/cala/src/workers/neuron-event-index.ts | 93 +++++++++++++ packages/cala-runtime/src/worker-protocol.ts | 16 ++- 5 files changed, 345 insertions(+), 2 deletions(-) create mode 100644 apps/cala/src/workers/__tests__/neuron-event-index.test.ts create mode 100644 apps/cala/src/workers/neuron-event-index.ts diff --git a/apps/cala/src/workers/__tests__/archive.worker.test.ts b/apps/cala/src/workers/__tests__/archive.worker.test.ts index 867a1af..c9f22bf 100644 --- a/apps/cala/src/workers/__tests__/archive.worker.test.ts +++ b/apps/cala/src/workers/__tests__/archive.worker.test.ts @@ -80,6 +80,11 @@ describe('worker-protocol archive extension compiles', () => { }; const inDumpReq: WorkerInbound = { kind: 'request-archive-dump', requestId: 1 }; const inTsReq: WorkerInbound = { kind: 'request-timeseries', requestId: 2, name: 'fps' }; + const inNeuronReq: WorkerInbound = { + kind: 'request-events-for-neuron', + requestId: 3, + neuronId: 5, + }; const outDump: WorkerOutbound = { kind: 'archive-dump', role: 'archive', @@ -97,11 +102,20 @@ describe('worker-protocol archive extension compiles', () => { l2Times: new Float32Array(0), l2Values: new Float32Array(0), }; + const outNeuron: WorkerOutbound = { + kind: 'events-for-neuron', + role: 'archive', + requestId: 3, + neuronId: 5, + events: [], + }; expect(inEvent.kind).toBe('event'); expect(inDumpReq.kind).toBe('request-archive-dump'); expect(inTsReq.kind).toBe('request-timeseries'); + expect(inNeuronReq.kind).toBe('request-events-for-neuron'); expect(outDump.kind).toBe('archive-dump'); expect(outTs.kind).toBe('timeseries'); + expect(outNeuron.kind).toBe('events-for-neuron'); }); }); @@ -237,6 +251,60 @@ describe('archive worker', () => { expect(reply.l2Times.length).toBe(0); }); + it('request-events-for-neuron returns every structural event the neuron participates in', async () => { + const harness = createWorkerHarness(); + await loadWorker(harness); + await harness.deliver(makeInitMsg()); + await runUntil(harness, (p) => p.some((m) => m.kind === 'ready')); + await harness.deliver({ kind: 'run' }); + + await harness.deliver({ kind: 'event', event: birthEvent(1, 7) }); + await harness.deliver({ + kind: 'event', + event: { + kind: 'merge', + t: 2, + ids: [7, 8], + into: 7, + footprintSnap: { + pixelIndices: new Uint32Array([7]), + values: new Float32Array([1]), + }, + }, + }); + await harness.deliver({ + kind: 'event', + event: { kind: 'deprecate', t: 3, id: 7, reason: 'traceInactive' }, + }); + // Unrelated neuron — must not appear in the reply for id 7. + await harness.deliver({ kind: 'event', event: birthEvent(4, 99) }); + + await harness.deliver({ kind: 'request-events-for-neuron', requestId: 70, neuronId: 7 }); + await runUntil(harness, (p) => p.some((m) => m.kind === 'events-for-neuron')); + const reply = harness.posted.find((m) => m.kind === 'events-for-neuron') as Extract< + WorkerOutbound, + { kind: 'events-for-neuron' } + >; + expect(reply.neuronId).toBe(7); + expect(reply.events.map((e) => e.kind)).toEqual(['birth', 'merge', 'deprecate']); + }); + + it('request-events-for-neuron returns an empty list for an unknown id', async () => { + const harness = createWorkerHarness(); + await loadWorker(harness); + await harness.deliver(makeInitMsg()); + await runUntil(harness, (p) => p.some((m) => m.kind === 'ready')); + await harness.deliver({ kind: 'run' }); + + await harness.deliver({ kind: 'request-events-for-neuron', requestId: 71, neuronId: 999 }); + await runUntil(harness, (p) => p.some((m) => m.kind === 'events-for-neuron')); + const reply = harness.posted.find((m) => m.kind === 'events-for-neuron') as Extract< + WorkerOutbound, + { kind: 'events-for-neuron' } + >; + expect(reply.events).toEqual([]); + }); + it('request-timeseries surfaces L2 downsampling once L1 overflows', async () => { const harness = createWorkerHarness(); await loadWorker(harness); diff --git a/apps/cala/src/workers/__tests__/neuron-event-index.test.ts b/apps/cala/src/workers/__tests__/neuron-event-index.test.ts new file mode 100644 index 0000000..23be6c7 --- /dev/null +++ b/apps/cala/src/workers/__tests__/neuron-event-index.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; +import type { PipelineEvent } from '@calab/cala-runtime'; +import { + NeuronEventIndex, + neuronIdsForEvent, + type NeuronEventIndexConfig, +} from '../neuron-event-index.ts'; + +const TEST_CFG: NeuronEventIndexConfig = { + maxNeurons: 3, + perNeuronLimit: 3, +}; + +function birth(t: number, id: number): PipelineEvent { + return { + kind: 'birth', + t, + id, + patch: [0, 0], + footprintSnap: { pixelIndices: new Uint32Array(), values: new Float32Array() }, + }; +} + +function merge(t: number, ids: number[], into: number): PipelineEvent { + return { + kind: 'merge', + t, + ids, + into, + footprintSnap: { pixelIndices: new Uint32Array(), values: new Float32Array() }, + }; +} + +function split(t: number, from: number, into: number[]): PipelineEvent { + return { + kind: 'split', + t, + from, + into, + footprintSnaps: into.map(() => ({ + pixelIndices: new Uint32Array(), + values: new Float32Array(), + })), + }; +} + +function deprecate(t: number, id: number): PipelineEvent { + return { kind: 'deprecate', t, id, reason: 'traceInactive' }; +} + +describe('neuronIdsForEvent', () => { + it('returns the id for birth + deprecate', () => { + expect(neuronIdsForEvent(birth(0, 7))).toEqual([7]); + expect(neuronIdsForEvent(deprecate(0, 7))).toEqual([7]); + }); + + it('includes survivor and all merged ids for merge', () => { + expect(neuronIdsForEvent(merge(0, [1, 2], 1)).sort((a, b) => a - b)).toEqual([1, 2]); + expect(neuronIdsForEvent(merge(0, [1, 2], 5)).sort((a, b) => a - b)).toEqual([1, 2, 5]); + }); + + it('includes source and every child for split', () => { + expect(neuronIdsForEvent(split(0, 9, [10, 11])).sort((a, b) => a - b)).toEqual([9, 10, 11]); + }); + + it('returns an empty array for reject + metric', () => { + expect( + neuronIdsForEvent({ kind: 'reject', t: 0, at: [0, 0], reason: 'low-snr' }), + ).toEqual([]); + expect(neuronIdsForEvent({ kind: 'metric', t: 0, name: 'x', value: 1 })).toEqual([]); + }); +}); + +describe('NeuronEventIndex', () => { + it('validates positive-int config', () => { + expect(() => new NeuronEventIndex({ ...TEST_CFG, maxNeurons: 0 })).toThrow(); + expect(() => new NeuronEventIndex({ ...TEST_CFG, perNeuronLimit: -1 })).toThrow(); + }); + + it('returns an empty list for an unknown id', () => { + const idx = new NeuronEventIndex(TEST_CFG); + expect(idx.query(42)).toEqual([]); + }); + + it('records events under every referenced neuron', () => { + const idx = new NeuronEventIndex(TEST_CFG); + idx.record(birth(1, 7)); + idx.record(merge(2, [7, 8], 7)); + expect(idx.query(7).map((e) => e.kind)).toEqual(['birth', 'merge']); + expect(idx.query(8).map((e) => e.kind)).toEqual(['merge']); + }); + + it('ignores events with no neuron ids', () => { + const idx = new NeuronEventIndex(TEST_CFG); + idx.record({ kind: 'metric', t: 0, name: 'x', value: 1 }); + idx.record({ kind: 'reject', t: 0, at: [0, 0], reason: 'low-snr' }); + expect(idx.knownIds()).toEqual([]); + }); + + it('drops oldest event per neuron once perNeuronLimit is exceeded', () => { + const idx = new NeuronEventIndex(TEST_CFG); + for (let t = 0; t < TEST_CFG.perNeuronLimit + 1; t += 1) { + idx.record(birth(t, 9)); + } + const history = idx.query(9); + expect(history.length).toBe(TEST_CFG.perNeuronLimit); + expect(history[0].t).toBe(1); // t=0 evicted. + }); + + it('drops oldest-inserted neuron once maxNeurons is exceeded', () => { + const idx = new NeuronEventIndex(TEST_CFG); + idx.record(birth(1, 1)); + idx.record(birth(2, 2)); + idx.record(birth(3, 3)); + idx.record(birth(4, 4)); // Evicts neuron 1. + expect(idx.knownIds().sort((a, b) => a - b)).toEqual([2, 3, 4]); + expect(idx.query(1)).toEqual([]); + }); + + it('returns a copy the caller can mutate without affecting the index', () => { + const idx = new NeuronEventIndex(TEST_CFG); + idx.record(birth(1, 5)); + const snap = idx.query(5); + snap.push(birth(999, 5)); + expect(idx.query(5).length).toBe(1); + }); +}); diff --git a/apps/cala/src/workers/archive.worker.ts b/apps/cala/src/workers/archive.worker.ts index 83a1311..6f499ba 100644 --- a/apps/cala/src/workers/archive.worker.ts +++ b/apps/cala/src/workers/archive.worker.ts @@ -29,6 +29,7 @@ import { type WorkerOutbound, } from '@calab/cala-runtime'; import { TimeseriesStore } from './timeseries-store.ts'; +import { NeuronEventIndex } from './neuron-event-index.ts'; // Rolling event log capacity. Design §9.2 sizes ~500 structural // events per typical session at ~2 KB each → ~1 MB budget; we default @@ -48,11 +49,20 @@ const DEFAULT_METRIC_WINDOW = 256; const DEFAULT_TS_L1_CAPACITY = 256; const DEFAULT_TS_L2_CAPACITY = 1024; const DEFAULT_TS_L2_STRIDE = 16; +// Per-neuron event index bounds (design §9.2). A typical session has +// ~500 structural events across ~100 neurons; 64 events per neuron +// covers the long tail while capping the per-id list so a rogue +// churn loop can't balloon memory. +const DEFAULT_NEURON_EVENT_LIMIT = 64; +const DEFAULT_MAX_INDEXED_NEURONS = 1024; // Local EventBus sizing. Archive is the sole subscriber post-init and // drains synchronously, so these are effectively no-backpressure // defaults — but they live in config per the no-magic-numbers rule. const DEFAULT_LOCAL_BUS_CAPACITY = 64; -const DEFAULT_LOCAL_BUS_MAX_SUBSCRIBERS = 4; +// One subscriber per archive sink: event ring, latest-value metric +// snapshot, tiered timeseries, per-neuron index, footprint history, +// plus headroom for future additions (§9.2 "bus consumer" model). +const DEFAULT_LOCAL_BUS_MAX_SUBSCRIBERS = 8; const ROLE = 'archive' as const; @@ -69,6 +79,8 @@ interface ArchiveWorkerConfig { timeseriesL1Capacity: number; timeseriesL2Capacity: number; timeseriesL2Stride: number; + neuronEventLimit: number; + maxIndexedNeurons: number; } const workerSelf = ((globalThis as unknown as { self?: WorkerGlobalScope }).self ?? @@ -87,6 +99,8 @@ interface RuntimeHandles { metricSnapshot: Map; timeseries: TimeseriesStore; unsubscribeTimeseries: () => void; + neuronIndex: NeuronEventIndex; + unsubscribeNeuronIndex: () => void; running: boolean; stopped: boolean; } @@ -124,6 +138,8 @@ function parseConfig(raw: unknown): ArchiveWorkerConfig { timeseriesL1Capacity: pickPositiveInt('timeseriesL1Capacity', DEFAULT_TS_L1_CAPACITY), timeseriesL2Capacity: pickPositiveInt('timeseriesL2Capacity', DEFAULT_TS_L2_CAPACITY), timeseriesL2Stride: pickPositiveInt('timeseriesL2Stride', DEFAULT_TS_L2_STRIDE), + neuronEventLimit: pickPositiveInt('neuronEventLimit', DEFAULT_NEURON_EVENT_LIMIT), + maxIndexedNeurons: pickPositiveInt('maxIndexedNeurons', DEFAULT_MAX_INDEXED_NEURONS), }; } @@ -141,6 +157,10 @@ function handleInit(payload: WorkerInitPayload): void { l2Stride: cfg.timeseriesL2Stride, maxNames: cfg.metricWindow, }); + const neuronIndex = new NeuronEventIndex({ + maxNeurons: cfg.maxIndexedNeurons, + perNeuronLimit: cfg.neuronEventLimit, + }); const unsubscribeLog = bus.subscribe((e) => { if (eventLog.length === cfg.eventRingCapacity) { @@ -166,6 +186,10 @@ function handleInit(payload: WorkerInitPayload): void { timeseries.append(e.name, e.t, e.value); }); + const unsubscribeNeuronIndex = bus.subscribe((e) => { + neuronIndex.record(e); + }); + handles = { cfg, bus, @@ -175,6 +199,8 @@ function handleInit(payload: WorkerInitPayload): void { metricSnapshot, timeseries, unsubscribeTimeseries, + neuronIndex, + unsubscribeNeuronIndex, running: false, stopped: false, }; @@ -215,6 +241,17 @@ function handleTimeseriesRequest(requestId: number, name: string): void { }); } +function handleNeuronEventsRequest(requestId: number, neuronId: number): void { + if (!handles) return; + post({ + kind: 'events-for-neuron', + role: ROLE, + requestId, + neuronId, + events: handles.neuronIndex.query(neuronId), + }); +} + function postDoneOnce(): void { if (donePosted) return; donePosted = true; @@ -230,6 +267,7 @@ function handleStop(): void { handles.unsubscribeLog(); handles.unsubscribeMetrics(); handles.unsubscribeTimeseries(); + handles.unsubscribeNeuronIndex(); handles.bus.close(); postDoneOnce(); } @@ -256,6 +294,9 @@ workerSelf.onmessage = (ev: MessageEvent): void => { case 'request-timeseries': handleTimeseriesRequest(msg.requestId, msg.name); return; + case 'request-events-for-neuron': + handleNeuronEventsRequest(msg.requestId, msg.neuronId); + return; case 'stop': handleStop(); return; diff --git a/apps/cala/src/workers/neuron-event-index.ts b/apps/cala/src/workers/neuron-event-index.ts new file mode 100644 index 0000000..6356219 --- /dev/null +++ b/apps/cala/src/workers/neuron-event-index.ts @@ -0,0 +1,93 @@ +/** + * Per-neuron structural-event index for the archive worker + * (design §9.2). + * + * The archive's flat event ring is great for the scrolling feed but + * useless for "show me everything that happened to neuron 47" queries + * — those walks are O(ring) and miss evicted history. This index + * keeps, per neuron id, a small drop-oldest list of the birth / + * merge / split / deprecate events the neuron participates in. + * + * Events are kept by reference (typed-array payloads live once in the + * original `PipelineEvent`). Memory is bounded by `maxNeurons` and + * `perNeuronLimit` — no magic numbers, everything caller-supplied. + */ +import type { PipelineEvent } from '@calab/cala-runtime'; + +export interface NeuronEventIndexConfig { + /** Hard cap on distinct neurons tracked (drop-oldest by insertion). */ + maxNeurons: number; + /** Drop-oldest ring size per neuron id. */ + perNeuronLimit: number; +} + +function validateConfig(cfg: NeuronEventIndexConfig): void { + const check = (name: keyof NeuronEventIndexConfig, v: number): void => { + if (!Number.isInteger(v) || v < 1) { + throw new Error(`NeuronEventIndexConfig.${name} must be an integer ≥ 1 (got ${v})`); + } + }; + check('maxNeurons', cfg.maxNeurons); + check('perNeuronLimit', cfg.perNeuronLimit); +} + +/** + * Returns the neuron ids an event involves, or an empty array for + * events that aren't tied to specific neurons (`reject`, `metric`). + */ +export function neuronIdsForEvent(e: PipelineEvent): number[] { + switch (e.kind) { + case 'birth': + return [e.id]; + case 'merge': + // `into` is one of the merged ids in practice but we record it + // on every merge target so queries for the survivor still land. + return e.ids.includes(e.into) ? e.ids : [...e.ids, e.into]; + case 'split': + return [e.from, ...e.into]; + case 'deprecate': + return [e.id]; + case 'reject': + case 'metric': + return []; + } +} + +export class NeuronEventIndex { + private readonly cfg: NeuronEventIndexConfig; + private readonly byNeuron = new Map(); + + constructor(cfg: NeuronEventIndexConfig) { + validateConfig(cfg); + this.cfg = cfg; + } + + /** Index the event under every neuron it references. No-op otherwise. */ + record(e: PipelineEvent): void { + const ids = neuronIdsForEvent(e); + for (const id of ids) { + let list = this.byNeuron.get(id); + if (!list) { + if (this.byNeuron.size >= this.cfg.maxNeurons) { + const oldest = this.byNeuron.keys().next().value; + if (oldest !== undefined) this.byNeuron.delete(oldest); + } + list = []; + this.byNeuron.set(id, list); + } + if (list.length === this.cfg.perNeuronLimit) list.shift(); + list.push(e); + } + } + + /** Snapshot copy of the indexed history for `id`, oldest→newest. */ + query(id: number): PipelineEvent[] { + const list = this.byNeuron.get(id); + return list ? list.slice() : []; + } + + /** Distinct neuron ids currently indexed (test introspection). */ + knownIds(): number[] { + return Array.from(this.byNeuron.keys()); + } +} diff --git a/packages/cala-runtime/src/worker-protocol.ts b/packages/cala-runtime/src/worker-protocol.ts index 6e3ad56..b1bb7eb 100644 --- a/packages/cala-runtime/src/worker-protocol.ts +++ b/packages/cala-runtime/src/worker-protocol.ts @@ -57,7 +57,12 @@ export type WorkerInbound = // Phase 6 task 1). `requestId` correlates the request with the // matching `timeseries` reply. Unknown names return empty arrays, // not an error — the dashboard polls before any samples exist. - | { kind: 'request-timeseries'; requestId: number; name: string }; + | { kind: 'request-timeseries'; requestId: number; name: string } + // Per-neuron structural event history (design §9.2, Phase 6 task 2). + // Returns the archive's indexed copy of every birth / merge / split / + // deprecate event the given neuron participates in. Empty list for + // an unknown id — same contract as `request-timeseries`. + | { kind: 'request-events-for-neuron'; requestId: number; neuronId: number }; /** Messages a worker sends back to the orchestrator. */ export type WorkerOutbound = @@ -92,6 +97,15 @@ export type WorkerOutbound = l2Times: Float32Array; l2Values: Float32Array; } + // Reply to `request-events-for-neuron`. `events` is a chronological + // copy of the archive's per-neuron index for `neuronId`. + | { + kind: 'events-for-neuron'; + role: WorkerRole; + requestId: number; + neuronId: number; + events: PipelineEvent[]; + } // W1 preview frame for the dashboard viewer (design §12 frame panel, // Phase 5 exit). Strided like `frame-processed` so the post rate is // bounded even when W1 outruns the main-thread canvas; `pixels` is From b47703ffeed351c774b70574daa2ca78dab8ce04 Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 18 Apr 2026 22:05:46 -0700 Subject: [PATCH 03/18] feat(cala): W4 footprint history snapshots (task 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the archive-side receiver for the hybrid log-spaced + change-triggered footprint scheme (design §9.3). The new `FootprintHistoryStore` keeps per-neuron drop-oldest rings of `(t, sparse A column)` snapshots; typed-array payloads are retained by reference to avoid per-snapshot copies. Harvests footprints from three sources through the archive worker's local event bus: (1) birth events, (2) merge-survivor events, (3) split children, plus a new `footprint-snapshot` `PipelineEvent` variant that W2 will emit on the log-spaced schedule in task 5. Adds a `request-footprint-history` / `footprint-history` protocol pair for the scrubber UI in Phase 7. Default sizing matches the §9.3 ~5 MB per-session budget. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/frame/SingleFrameViewer.tsx | 2 + .../workers/__tests__/archive.worker.test.ts | 56 +++++++++++++ .../__tests__/footprint-history-store.test.ts | 66 ++++++++++++++++ apps/cala/src/workers/archive.worker.ts | 78 +++++++++++++++++++ .../src/workers/footprint-history-store.ts | 78 +++++++++++++++++++ apps/cala/src/workers/neuron-event-index.ts | 3 + packages/cala-runtime/src/events.ts | 10 +++ packages/cala-runtime/src/worker-protocol.ts | 18 ++++- 8 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 apps/cala/src/workers/__tests__/footprint-history-store.test.ts create mode 100644 apps/cala/src/workers/footprint-history-store.ts diff --git a/apps/cala/src/components/frame/SingleFrameViewer.tsx b/apps/cala/src/components/frame/SingleFrameViewer.tsx index 14ef640..f2e839d 100644 --- a/apps/cala/src/components/frame/SingleFrameViewer.tsx +++ b/apps/cala/src/components/frame/SingleFrameViewer.tsx @@ -27,6 +27,8 @@ function describeEvent(e: PipelineEvent): string { return `reject @(${e.at[0]},${e.at[1]}): ${e.reason}`; case 'metric': return `metric ${e.name}=${e.value.toFixed(3)}`; + case 'footprint-snapshot': + return `footprint-snap id=${e.neuronId} (${e.footprint.pixelIndices.length}px)`; } } diff --git a/apps/cala/src/workers/__tests__/archive.worker.test.ts b/apps/cala/src/workers/__tests__/archive.worker.test.ts index c9f22bf..5623f31 100644 --- a/apps/cala/src/workers/__tests__/archive.worker.test.ts +++ b/apps/cala/src/workers/__tests__/archive.worker.test.ts @@ -289,6 +289,62 @@ describe('archive worker', () => { expect(reply.events.map((e) => e.kind)).toEqual(['birth', 'merge', 'deprecate']); }); + it('harvests footprint history from birth events + periodic footprint-snapshot', async () => { + const harness = createWorkerHarness(); + await loadWorker(harness); + await harness.deliver(makeInitMsg()); + await runUntil(harness, (p) => p.some((m) => m.kind === 'ready')); + await harness.deliver({ kind: 'run' }); + + await harness.deliver({ kind: 'event', event: birthEvent(1, 12) }); + await harness.deliver({ + kind: 'event', + event: { + kind: 'footprint-snapshot', + t: 5, + neuronId: 12, + footprint: { + pixelIndices: new Uint32Array([3, 4, 5]), + values: new Float32Array([0.7, 0.8, 0.9]), + }, + }, + }); + + await harness.deliver({ kind: 'request-footprint-history', requestId: 80, neuronId: 12 }); + await runUntil(harness, (p) => p.some((m) => m.kind === 'footprint-history')); + const reply = harness.posted.find((m) => m.kind === 'footprint-history') as Extract< + WorkerOutbound, + { kind: 'footprint-history' } + >; + expect(reply.neuronId).toBe(12); + expect(Array.from(reply.times)).toEqual([1, 5]); + expect(reply.pixelIndices.length).toBe(2); + expect(Array.from(reply.pixelIndices[1])).toEqual([3, 4, 5]); + // Float32 round-trip: tolerant compare avoids spurious precision diffs. + const vs = Array.from(reply.values[1]); + expect(vs[0]).toBeCloseTo(0.7, 5); + expect(vs[1]).toBeCloseTo(0.8, 5); + expect(vs[2]).toBeCloseTo(0.9, 5); + }); + + it('request-footprint-history returns empty arrays for an unknown neuron', async () => { + const harness = createWorkerHarness(); + await loadWorker(harness); + await harness.deliver(makeInitMsg()); + await runUntil(harness, (p) => p.some((m) => m.kind === 'ready')); + await harness.deliver({ kind: 'run' }); + + await harness.deliver({ kind: 'request-footprint-history', requestId: 81, neuronId: 999 }); + await runUntil(harness, (p) => p.some((m) => m.kind === 'footprint-history')); + const reply = harness.posted.find((m) => m.kind === 'footprint-history') as Extract< + WorkerOutbound, + { kind: 'footprint-history' } + >; + expect(reply.times.length).toBe(0); + expect(reply.pixelIndices.length).toBe(0); + expect(reply.values.length).toBe(0); + }); + it('request-events-for-neuron returns an empty list for an unknown id', async () => { const harness = createWorkerHarness(); await loadWorker(harness); diff --git a/apps/cala/src/workers/__tests__/footprint-history-store.test.ts b/apps/cala/src/workers/__tests__/footprint-history-store.test.ts new file mode 100644 index 0000000..52d4289 --- /dev/null +++ b/apps/cala/src/workers/__tests__/footprint-history-store.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import type { FootprintSnap } from '@calab/cala-runtime'; +import { + FootprintHistoryStore, + type FootprintHistoryStoreConfig, +} from '../footprint-history-store.ts'; + +const TEST_CFG: FootprintHistoryStoreConfig = { + perNeuronLimit: 3, + maxNeurons: 3, +}; + +function snap(px: number, v: number): FootprintSnap { + return { pixelIndices: new Uint32Array([px]), values: new Float32Array([v]) }; +} + +describe('FootprintHistoryStore', () => { + it('validates positive-int config', () => { + expect(() => new FootprintHistoryStore({ ...TEST_CFG, perNeuronLimit: 0 })).toThrow(); + expect(() => new FootprintHistoryStore({ ...TEST_CFG, maxNeurons: -1 })).toThrow(); + }); + + it('returns an empty array for an unknown id', () => { + const store = new FootprintHistoryStore(TEST_CFG); + expect(store.query(7)).toEqual([]); + }); + + it('records snapshots in chronological order per neuron', () => { + const store = new FootprintHistoryStore(TEST_CFG); + store.record(7, 1, snap(1, 0.1)); + store.record(7, 2, snap(2, 0.2)); + store.record(8, 2, snap(10, 1)); + expect(store.query(7).map((e) => e.t)).toEqual([1, 2]); + expect(Array.from(store.query(8)[0].pixelIndices)).toEqual([10]); + }); + + it('drops oldest snapshot per neuron once perNeuronLimit is reached', () => { + const store = new FootprintHistoryStore(TEST_CFG); + for (let t = 0; t < TEST_CFG.perNeuronLimit + 1; t += 1) { + store.record(5, t, snap(t, t)); + } + const hist = store.query(5); + expect(hist.length).toBe(TEST_CFG.perNeuronLimit); + expect(hist[0].t).toBe(1); // t=0 evicted. + }); + + it('drops oldest-inserted neuron once maxNeurons is reached', () => { + const store = new FootprintHistoryStore(TEST_CFG); + store.record(1, 0, snap(1, 1)); + store.record(2, 0, snap(2, 2)); + store.record(3, 0, snap(3, 3)); + store.record(4, 0, snap(4, 4)); // Evicts neuron 1. + expect(store.knownIds().sort((a, b) => a - b)).toEqual([2, 3, 4]); + expect(store.query(1)).toEqual([]); + }); + + it('returns snapshots by reference so the caller can inspect typed-array payloads', () => { + const store = new FootprintHistoryStore(TEST_CFG); + const s = snap(42, 0.9); + store.record(5, 1, s); + const out = store.query(5)[0]; + // Keeps the original typed arrays — no copy, no structural clone. + expect(out.pixelIndices).toBe(s.pixelIndices); + expect(out.values).toBe(s.values); + }); +}); diff --git a/apps/cala/src/workers/archive.worker.ts b/apps/cala/src/workers/archive.worker.ts index 6f499ba..e119589 100644 --- a/apps/cala/src/workers/archive.worker.ts +++ b/apps/cala/src/workers/archive.worker.ts @@ -30,6 +30,7 @@ import { } from '@calab/cala-runtime'; import { TimeseriesStore } from './timeseries-store.ts'; import { NeuronEventIndex } from './neuron-event-index.ts'; +import { FootprintHistoryStore } from './footprint-history-store.ts'; // Rolling event log capacity. Design §9.2 sizes ~500 structural // events per typical session at ~2 KB each → ~1 MB budget; we default @@ -55,6 +56,12 @@ const DEFAULT_TS_L2_STRIDE = 16; // churn loop can't balloon memory. const DEFAULT_NEURON_EVENT_LIMIT = 64; const DEFAULT_MAX_INDEXED_NEURONS = 1024; +// Footprint history sizing (design §9.3). 32 per-neuron snapshots +// covers the log-spaced schedule's ~16 floor entries plus a comparable +// number of change-triggered snapshots; 512 distinct neurons matches +// the §9.3 12h-session budget of ~5 MB at ~4 KB per sparse footprint. +const DEFAULT_FOOTPRINT_HISTORY_LIMIT = 32; +const DEFAULT_FOOTPRINT_HISTORY_MAX_NEURONS = 512; // Local EventBus sizing. Archive is the sole subscriber post-init and // drains synchronously, so these are effectively no-backpressure // defaults — but they live in config per the no-magic-numbers rule. @@ -81,6 +88,8 @@ interface ArchiveWorkerConfig { timeseriesL2Stride: number; neuronEventLimit: number; maxIndexedNeurons: number; + footprintHistoryLimit: number; + footprintHistoryMaxNeurons: number; } const workerSelf = ((globalThis as unknown as { self?: WorkerGlobalScope }).self ?? @@ -101,6 +110,8 @@ interface RuntimeHandles { unsubscribeTimeseries: () => void; neuronIndex: NeuronEventIndex; unsubscribeNeuronIndex: () => void; + footprints: FootprintHistoryStore; + unsubscribeFootprints: () => void; running: boolean; stopped: boolean; } @@ -140,6 +151,14 @@ function parseConfig(raw: unknown): ArchiveWorkerConfig { timeseriesL2Stride: pickPositiveInt('timeseriesL2Stride', DEFAULT_TS_L2_STRIDE), neuronEventLimit: pickPositiveInt('neuronEventLimit', DEFAULT_NEURON_EVENT_LIMIT), maxIndexedNeurons: pickPositiveInt('maxIndexedNeurons', DEFAULT_MAX_INDEXED_NEURONS), + footprintHistoryLimit: pickPositiveInt( + 'footprintHistoryLimit', + DEFAULT_FOOTPRINT_HISTORY_LIMIT, + ), + footprintHistoryMaxNeurons: pickPositiveInt( + 'footprintHistoryMaxNeurons', + DEFAULT_FOOTPRINT_HISTORY_MAX_NEURONS, + ), }; } @@ -161,6 +180,10 @@ function handleInit(payload: WorkerInitPayload): void { maxNeurons: cfg.maxIndexedNeurons, perNeuronLimit: cfg.neuronEventLimit, }); + const footprints = new FootprintHistoryStore({ + perNeuronLimit: cfg.footprintHistoryLimit, + maxNeurons: cfg.footprintHistoryMaxNeurons, + }); const unsubscribeLog = bus.subscribe((e) => { if (eventLog.length === cfg.eventRingCapacity) { @@ -190,6 +213,33 @@ function handleInit(payload: WorkerInitPayload): void { neuronIndex.record(e); }); + const unsubscribeFootprints = bus.subscribe((e) => { + switch (e.kind) { + case 'birth': + footprints.record(e.id, e.t, e.footprintSnap); + return; + case 'merge': + // Footprint belongs to the survivor — `into`. + footprints.record(e.into, e.t, e.footprintSnap); + return; + case 'split': + // Each child gets its own snap; ordering pairs `into[i]` with + // `footprintSnaps[i]` per the PipelineEvent contract. + for (let i = 0; i < e.into.length; i += 1) { + const snap = e.footprintSnaps[i]; + if (snap) footprints.record(e.into[i], e.t, snap); + } + return; + case 'footprint-snapshot': + footprints.record(e.neuronId, e.t, e.footprint); + return; + case 'deprecate': + case 'reject': + case 'metric': + return; + } + }); + handles = { cfg, bus, @@ -201,6 +251,8 @@ function handleInit(payload: WorkerInitPayload): void { unsubscribeTimeseries, neuronIndex, unsubscribeNeuronIndex, + footprints, + unsubscribeFootprints, running: false, stopped: false, }; @@ -252,6 +304,28 @@ function handleNeuronEventsRequest(requestId: number, neuronId: number): void { }); } +function handleFootprintHistoryRequest(requestId: number, neuronId: number): void { + if (!handles) return; + const history = handles.footprints.query(neuronId); + const times = new Float32Array(history.length); + const pixelIndices: Uint32Array[] = []; + const values: Float32Array[] = []; + for (let i = 0; i < history.length; i += 1) { + times[i] = history[i].t; + pixelIndices.push(history[i].pixelIndices); + values.push(history[i].values); + } + post({ + kind: 'footprint-history', + role: ROLE, + requestId, + neuronId, + times, + pixelIndices, + values, + }); +} + function postDoneOnce(): void { if (donePosted) return; donePosted = true; @@ -268,6 +342,7 @@ function handleStop(): void { handles.unsubscribeMetrics(); handles.unsubscribeTimeseries(); handles.unsubscribeNeuronIndex(); + handles.unsubscribeFootprints(); handles.bus.close(); postDoneOnce(); } @@ -297,6 +372,9 @@ workerSelf.onmessage = (ev: MessageEvent): void => { case 'request-events-for-neuron': handleNeuronEventsRequest(msg.requestId, msg.neuronId); return; + case 'request-footprint-history': + handleFootprintHistoryRequest(msg.requestId, msg.neuronId); + return; case 'stop': handleStop(); return; diff --git a/apps/cala/src/workers/footprint-history-store.ts b/apps/cala/src/workers/footprint-history-store.ts new file mode 100644 index 0000000..f36a497 --- /dev/null +++ b/apps/cala/src/workers/footprint-history-store.ts @@ -0,0 +1,78 @@ +/** + * Per-neuron footprint history for the archive worker (design §9.3). + * + * Implements the **storage** side of the hybrid log-spaced + + * change-triggered scheme. Snapshots arrive from three sources: + * + * 1. Structural events — every birth / merge / split carries a + * `FootprintSnap` by design. We harvest from these for free. + * 2. Periodic `footprint-snapshot` events that W2 emits on the + * log-spaced schedule (task 5). + * 3. Change-triggered snapshots W2 decides to emit out of band. + * + * This store does not decide *when* to snapshot — that's a fit-side + * policy. It only bounds memory, keeps chronological order, and + * answers queries. + * + * Typed-array payloads are kept by reference (same contract as + * `EventBus` subscribers). Size is bounded by + * `maxNeurons` × `perNeuronLimit` ceiling. + */ +import type { FootprintSnap } from '@calab/cala-runtime'; + +export interface FootprintHistoryStoreConfig { + /** Drop-oldest ring size per neuron id. */ + perNeuronLimit: number; + /** Hard cap on distinct neurons tracked (drop-oldest by insertion). */ + maxNeurons: number; +} + +export interface FootprintHistoryEntry { + t: number; + pixelIndices: Uint32Array; + values: Float32Array; +} + +function validateConfig(cfg: FootprintHistoryStoreConfig): void { + const check = (name: keyof FootprintHistoryStoreConfig, v: number): void => { + if (!Number.isInteger(v) || v < 1) { + throw new Error(`FootprintHistoryStoreConfig.${name} must be an integer ≥ 1 (got ${v})`); + } + }; + check('perNeuronLimit', cfg.perNeuronLimit); + check('maxNeurons', cfg.maxNeurons); +} + +export class FootprintHistoryStore { + private readonly cfg: FootprintHistoryStoreConfig; + private readonly byNeuron = new Map(); + + constructor(cfg: FootprintHistoryStoreConfig) { + validateConfig(cfg); + this.cfg = cfg; + } + + record(neuronId: number, t: number, snap: FootprintSnap): void { + let list = this.byNeuron.get(neuronId); + if (!list) { + if (this.byNeuron.size >= this.cfg.maxNeurons) { + const oldest = this.byNeuron.keys().next().value; + if (oldest !== undefined) this.byNeuron.delete(oldest); + } + list = []; + this.byNeuron.set(neuronId, list); + } + if (list.length === this.cfg.perNeuronLimit) list.shift(); + list.push({ t, pixelIndices: snap.pixelIndices, values: snap.values }); + } + + /** Snapshot copy, oldest→newest. Empty array for unknown neurons. */ + query(neuronId: number): FootprintHistoryEntry[] { + const list = this.byNeuron.get(neuronId); + return list ? list.slice() : []; + } + + knownIds(): number[] { + return Array.from(this.byNeuron.keys()); + } +} diff --git a/apps/cala/src/workers/neuron-event-index.ts b/apps/cala/src/workers/neuron-event-index.ts index 6356219..ceef01e 100644 --- a/apps/cala/src/workers/neuron-event-index.ts +++ b/apps/cala/src/workers/neuron-event-index.ts @@ -49,6 +49,9 @@ export function neuronIdsForEvent(e: PipelineEvent): number[] { return [e.id]; case 'reject': case 'metric': + case 'footprint-snapshot': + // Periodic footprint snapshots are indexed by the footprint + // store (§9.3), not the structural-event history. return []; } } diff --git a/packages/cala-runtime/src/events.ts b/packages/cala-runtime/src/events.ts index c375822..6c6ffca 100644 --- a/packages/cala-runtime/src/events.ts +++ b/packages/cala-runtime/src/events.ts @@ -63,6 +63,16 @@ export type PipelineEvent = t: number; name: string; value: number; + } + // Periodic footprint snapshot emitted by fit (§9.3 log-spaced + + // change-triggered schedule). Structural events already carry a + // footprint; this variant covers the "quiet" frames where a neuron + // hasn't merged/split but the scrubber still wants morph data. + | { + kind: 'footprint-snapshot'; + t: number; + neuronId: number; + footprint: FootprintSnap; }; export type Unsubscribe = () => void; diff --git a/packages/cala-runtime/src/worker-protocol.ts b/packages/cala-runtime/src/worker-protocol.ts index b1bb7eb..2c34f9e 100644 --- a/packages/cala-runtime/src/worker-protocol.ts +++ b/packages/cala-runtime/src/worker-protocol.ts @@ -62,7 +62,11 @@ export type WorkerInbound = // Returns the archive's indexed copy of every birth / merge / split / // deprecate event the given neuron participates in. Empty list for // an unknown id — same contract as `request-timeseries`. - | { kind: 'request-events-for-neuron'; requestId: number; neuronId: number }; + | { kind: 'request-events-for-neuron'; requestId: number; neuronId: number } + // Per-neuron footprint history query (design §9.3, Phase 6 task 3). + // Returns every `(t, sparse A column)` snapshot the archive has + // recorded for `neuronId`, ordered oldest→newest. + | { kind: 'request-footprint-history'; requestId: number; neuronId: number }; /** Messages a worker sends back to the orchestrator. */ export type WorkerOutbound = @@ -106,6 +110,18 @@ export type WorkerOutbound = neuronId: number; events: PipelineEvent[]; } + // Reply to `request-footprint-history`. `times` and the typed-array + // payloads are parallel arrays of equal length (one entry per + // stored snapshot, oldest→newest). + | { + kind: 'footprint-history'; + role: WorkerRole; + requestId: number; + neuronId: number; + times: Float32Array; + pixelIndices: Uint32Array[]; + values: Float32Array[]; + } // W1 preview frame for the dashboard viewer (design §12 frame panel, // Phase 5 exit). Strided like `frame-processed` so the post rate is // bounded even when W1 outruns the main-thread canvas; `pixels` is From 2b0de201eb9b025950dc34886e9359f8bde55c6d Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 18 Apr 2026 22:09:32 -0700 Subject: [PATCH 04/18] feat(cala): W2 vitals metric emission (task 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fit worker now emits the five header-bar vitals (cell_count, fps, memory_bytes, residual_l2, extend_queue_depth) every vitalsStride frames (default 8). residual_l2 is computed from `fitter.step()`'s return; fps is wall-clock-derived over the last interval so it reflects what the user sees on the sparklines. Metric names live in a new `lib/vitals.ts` module shared with the upcoming vitals bar so emitter and UI can't drift. Also adds `calaMemoryBytes()` to the cala-core wasm-adapter — a single source of truth for the WASM heap size now that a consumer (W2) actually needs it. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cala/src/lib/vitals.ts | 22 ++++++ .../src/workers/__tests__/fit.worker.test.ts | 50 ++++++++++++++ apps/cala/src/workers/fit.worker.ts | 68 ++++++++++++++++++- packages/cala-core/src/index.ts | 1 + packages/cala-core/src/wasm-adapter.ts | 13 +++- 5 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 apps/cala/src/lib/vitals.ts diff --git a/apps/cala/src/lib/vitals.ts b/apps/cala/src/lib/vitals.ts new file mode 100644 index 0000000..2f6240b --- /dev/null +++ b/apps/cala/src/lib/vitals.ts @@ -0,0 +1,22 @@ +/** + * Shared vitals vocabulary (design §12). + * + * The fit worker publishes five per-frame scalar metrics; the header + * vitals bar subscribes to the same names. Keeping the strings in one + * place prevents drift between the emitter and the UI. + */ +export const METRIC_CELL_COUNT = 'cell_count'; +export const METRIC_FPS = 'fps'; +export const METRIC_MEMORY_BYTES = 'memory_bytes'; +export const METRIC_RESIDUAL_L2 = 'residual_l2'; +export const METRIC_EXTEND_QUEUE_DEPTH = 'extend_queue_depth'; + +export const VITALS_METRIC_NAMES = [ + METRIC_CELL_COUNT, + METRIC_FPS, + METRIC_MEMORY_BYTES, + METRIC_RESIDUAL_L2, + METRIC_EXTEND_QUEUE_DEPTH, +] as const; + +export type VitalsMetricName = (typeof VITALS_METRIC_NAMES)[number]; diff --git a/apps/cala/src/workers/__tests__/fit.worker.test.ts b/apps/cala/src/workers/__tests__/fit.worker.test.ts index ec51106..15616eb 100644 --- a/apps/cala/src/workers/__tests__/fit.worker.test.ts +++ b/apps/cala/src/workers/__tests__/fit.worker.test.ts @@ -147,6 +147,7 @@ vi.mock('@calab/cala-core', () => { return { initCalaCore: vi.fn(async () => {}), + calaMemoryBytes: vi.fn(() => 1024 * 1024), Fitter, MutationQueueHandle, SnapshotHandle: class {}, @@ -212,6 +213,7 @@ function makeInitMsg(overrides: Record = {}): InitHandles { frameChannelWaitTimeoutMs: FRAME_CHANNEL_WAIT_TIMEOUT_MS, frameChannelPollIntervalMs: FRAME_CHANNEL_POLL_INTERVAL_MS, mutationQueueCapacity: MUTATION_QUEUE_CAPACITY, + vitalsStride: 2, ...overrides, }, }, @@ -430,6 +432,54 @@ describe('fit worker', () => { expect(harness.posted.filter((m) => m.kind === 'done').length).toBe(1); }); + it('emits the five vitals metrics on the vitalsStride cadence', async () => { + const harness = createWorkerHarness(); + await loadWorker(harness); + // vitalsStride=2 → vitals emit on frame index 1 and 3 (every + // second frame counting from 1). Residuals below let us verify + // residual_l2 comes through with the right magnitude. + const init = makeInitMsg({ heartbeatStride: 1, snapshotStride: 1000, vitalsStride: 2 }); + await harness.deliver(init.msg); + await runUntil(harness, (p) => p.some((m) => m.kind === 'ready')); + + mockState.program = [ + { residual: new Float32Array([3, 4]) }, // L2 = 5 + { residual: new Float32Array([1, 0]) }, // L2 = 1 + { residual: new Float32Array([0, 2]) }, // L2 = 2 + { residual: new Float32Array([0, 0]) }, // L2 = 0 + ]; + for (let i = 0; i < 4; i += 1) writeFrameToChannel(init.frameChannel, i); + + await harness.deliver({ kind: 'run' }); + await runUntil( + harness, + (p) => p.filter((m) => m.kind === 'event' && m.event.kind === 'metric').length >= 10, + ); + + const metrics = harness.posted + .filter( + (m): m is Extract => + m.kind === 'event' && m.event.kind === 'metric', + ) + .map((m) => m.event as Extract); + + // Exactly five metrics per stride firing. + const names = metrics.map((m) => m.name); + expect(names).toContain('cell_count'); + expect(names).toContain('fps'); + expect(names).toContain('memory_bytes'); + expect(names).toContain('residual_l2'); + expect(names).toContain('extend_queue_depth'); + + // residual_l2 at t=1 is from the second step (L2 of [1,0] = 1). + const firstResidual = metrics.find((m) => m.name === 'residual_l2'); + expect(firstResidual).toBeDefined(); + expect(firstResidual!.value).toBeCloseTo(1, 5); + // memory_bytes reflects the mocked calaMemoryBytes return value. + const mem = metrics.find((m) => m.name === 'memory_bytes'); + expect(mem!.value).toBe(1024 * 1024); + }); + it('posts error when fit_step throws mid-loop and still frees the fitter', async () => { const harness = createWorkerHarness(); await loadWorker(harness); diff --git a/apps/cala/src/workers/fit.worker.ts b/apps/cala/src/workers/fit.worker.ts index 935867c..22d26c1 100644 --- a/apps/cala/src/workers/fit.worker.ts +++ b/apps/cala/src/workers/fit.worker.ts @@ -1,4 +1,11 @@ -import { initCalaCore, Fitter, MutationQueueHandle } from '@calab/cala-core'; +import { initCalaCore, Fitter, MutationQueueHandle, calaMemoryBytes } from '@calab/cala-core'; +import { + METRIC_CELL_COUNT, + METRIC_EXTEND_QUEUE_DEPTH, + METRIC_FPS, + METRIC_MEMORY_BYTES, + METRIC_RESIDUAL_L2, +} from '../lib/vitals.ts'; import { SabRingChannel, EventBus, @@ -49,6 +56,12 @@ const FRAME_CHANNEL_SLOT_COUNT_FALLBACK = 4; const DEFAULT_MUTATION_QUEUE_CAPACITY = 32; const DEFAULT_FIT_CONFIG_JSON = '{}'; const DEFAULT_EXTEND_CONFIG_JSON = '{}'; +// Vitals cadence (design §12 header bar). Emit every N steps so the +// sparkline widgets see smooth updates without per-frame postMessage +// cost. 8 aligns with `DEFAULT_HEARTBEAT_STRIDE` so one fit iteration +// either emits the full vitals bundle or none of it. Overridable +// via `workerConfig.vitalsStride`. +const DEFAULT_VITALS_STRIDE = 8; const ROLE = 'fit' as const; @@ -63,6 +76,7 @@ interface FitWorkerConfig { fitConfigJson: string; extendConfigJson: string; heartbeatStride: number; + vitalsStride: number; snapshotStride: number; mutationDrainMaxPerIteration: number; eventBusCapacity: number; @@ -93,6 +107,17 @@ interface RuntimeHandles { eventSubscription: () => void; config: FitWorkerConfig; pixels: number; + // Wall-clock at the previous vitals emission, used to derive fps + // over the interval since the last post (not instantaneous, not + // cumulative — just the rate the user perceives on the bar). + lastVitalsTimeMs: number; + // Frame index at the previous emission so (now - last) ÷ (framesNow + // - framesLast) × 1000 gives fps without caring about stride edge + // cases if the worker skipped a window on backpressure. + lastVitalsFrameIndex: number; + // Most recent residualL2 from `step()`; cached between frames so the + // vitals emission can read it without re-running math. + lastResidualL2: number; } let handles: RuntimeHandles | null = null; @@ -135,6 +160,7 @@ function parseConfig(raw: unknown): FitWorkerConfig { fitConfigJson: stringOr(cfg.fitConfigJson, DEFAULT_FIT_CONFIG_JSON), extendConfigJson: stringOr(cfg.extendConfigJson, DEFAULT_EXTEND_CONFIG_JSON), heartbeatStride: numberOr(cfg.heartbeatStride, DEFAULT_HEARTBEAT_STRIDE), + vitalsStride: numberOr(cfg.vitalsStride, DEFAULT_VITALS_STRIDE), snapshotStride: numberOr(cfg.snapshotStride, DEFAULT_SNAPSHOT_STRIDE), mutationDrainMaxPerIteration: numberOr( cfg.mutationDrainMaxPerIteration, @@ -210,6 +236,9 @@ async function handleInit(payload: WorkerInitPayload): Promise { eventSubscription, config: cfg, pixels, + lastVitalsTimeMs: 0, + lastVitalsFrameIndex: 0, + lastResidualL2: 0, }; // Test-only hook so unit tests can push mutations into the worker's @@ -318,6 +347,39 @@ function takeCadencedSnapshot(h: RuntimeHandles, frameIndex: number): void { post({ kind: 'snapshot-request', role: ROLE, requestId: request.requestId }); } +function residualL2(residual: ArrayLike | Float32Array): number { + let sumSq = 0; + for (let i = 0; i < residual.length; i += 1) { + const v = residual[i]; + sumSq += v * v; + } + return Math.sqrt(sumSq); +} + +function emitVitals(h: RuntimeHandles, frameIndex: number): void { + if (h.config.vitalsStride <= 0) return; + if ((frameIndex + 1) % h.config.vitalsStride !== 0) return; + + const now = Date.now(); + const elapsedMs = now - h.lastVitalsTimeMs; + const elapsedFrames = frameIndex - h.lastVitalsFrameIndex; + const fps = + h.lastVitalsTimeMs > 0 && elapsedMs > 0 ? (elapsedFrames * 1000) / elapsedMs : 0; + h.lastVitalsTimeMs = now; + h.lastVitalsFrameIndex = frameIndex; + + const metrics: { name: string; value: number }[] = [ + { name: METRIC_CELL_COUNT, value: h.fitter.numComponents() }, + { name: METRIC_FPS, value: fps }, + { name: METRIC_MEMORY_BYTES, value: calaMemoryBytes() ?? 0 }, + { name: METRIC_RESIDUAL_L2, value: h.lastResidualL2 }, + { name: METRIC_EXTEND_QUEUE_DEPTH, value: h.mutationQueue.len }, + ]; + for (const { name, value } of metrics) { + h.eventBus.publish({ kind: 'metric', t: frameIndex, name, value }); + } +} + async function fitLoop(h: RuntimeHandles): Promise { let frameIndex = 0; while (!stopRequested) { @@ -329,9 +391,11 @@ async function fitLoop(h: RuntimeHandles): Promise { await new Promise((r) => setTimeout(r, h.config.frameChannelPollIntervalMs)); continue; } - h.fitter.step(frame); + const residual = h.fitter.step(frame); + h.lastResidualL2 = residualL2(residual); drainMutationsOnce(h, frameIndex); takeCadencedSnapshot(h, frameIndex); + emitVitals(h, frameIndex); if ((frameIndex + 1) % h.config.heartbeatStride === 0) { post({ kind: 'frame-processed', diff --git a/packages/cala-core/src/index.ts b/packages/cala-core/src/index.ts index b88341d..3ad1a69 100644 --- a/packages/cala-core/src/index.ts +++ b/packages/cala-core/src/index.ts @@ -6,4 +6,5 @@ export { SnapshotHandle, init_panic_hook, initCalaCore, + calaMemoryBytes, } from './wasm-adapter.ts'; diff --git a/packages/cala-core/src/wasm-adapter.ts b/packages/cala-core/src/wasm-adapter.ts index f0430ae..82e854d 100644 --- a/packages/cala-core/src/wasm-adapter.ts +++ b/packages/cala-core/src/wasm-adapter.ts @@ -22,6 +22,7 @@ import init, { export { AviReader, Fitter, MutationQueueHandle, Preprocessor, SnapshotHandle, init_panic_hook }; let calaReady: Promise | null = null; +let calaMemory: WebAssembly.Memory | null = null; /** * Initialize the cala-core WASM module. Lazy and idempotent — safe to @@ -31,9 +32,19 @@ let calaReady: Promise | null = null; */ export function initCalaCore(): Promise { if (!calaReady) { - calaReady = init().then(() => { + calaReady = init().then((mod: { memory: WebAssembly.Memory }) => { + calaMemory = mod.memory; init_panic_hook(); }); } return calaReady; } + +/** + * Current byte size of cala-core's WASM linear memory, or `null` if + * the module has not been initialized yet. Used by the fit worker to + * report `memoryBytes` as a vitals metric (design §12). + */ +export function calaMemoryBytes(): number | null { + return calaMemory ? calaMemory.buffer.byteLength : null; +} From 18c3cfa255c2785ebbd3654bbb2dfe44146fe93a Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 18 Apr 2026 22:16:04 -0700 Subject: [PATCH 05/18] feat(cala): W2 periodic footprint snapshots (task 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the log-spaced floor of the §9.3 hybrid scheme: a new `FootprintSnapshotScheduler` emits `footprint-snapshot` events at ages 1, 2, 4, 8, … frames after each neuron's birth, using the latest footprint attached to a mutation (register / merge / split) as its cached snap. The change-triggered branch (‖A_curr − A_last_snap‖_F drift) is left as a deliberate TODO — it needs a new wasm-bindgen accessor on `Fitter` to read per-component A columns. Until that lands the log-spaced schedule gets us scrubber data during quiet periods and exercises the archive's footprint-history store end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/workers/__tests__/fit.worker.test.ts | 47 ++++++++ .../footprint-snapshot-scheduler.test.ts | 76 ++++++++++++ apps/cala/src/workers/fit.worker.ts | 63 +++++++++- .../workers/footprint-snapshot-scheduler.ts | 112 ++++++++++++++++++ 4 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 apps/cala/src/workers/__tests__/footprint-snapshot-scheduler.test.ts create mode 100644 apps/cala/src/workers/footprint-snapshot-scheduler.ts diff --git a/apps/cala/src/workers/__tests__/fit.worker.test.ts b/apps/cala/src/workers/__tests__/fit.worker.test.ts index 15616eb..0afa709 100644 --- a/apps/cala/src/workers/__tests__/fit.worker.test.ts +++ b/apps/cala/src/workers/__tests__/fit.worker.test.ts @@ -432,6 +432,53 @@ describe('fit worker', () => { expect(harness.posted.filter((m) => m.kind === 'done').length).toBe(1); }); + it('emits log-spaced footprint-snapshot events after a birth mutation', async () => { + const harness = createWorkerHarness(); + await loadWorker(harness); + // Drive the scheduler by pushing a register mutation on frame 0 + // (caches a footprint), then advance the fit loop through several + // frames so ages 1, 2, 4 fire. + const init = makeInitMsg({ heartbeatStride: 1, snapshotStride: 1000, vitalsStride: 1000 }); + await harness.deliver(init.msg); + await runUntil(harness, (p) => p.some((m) => m.kind === 'ready')); + + mockState.program = [{}, {}, {}, {}, {}]; + mockState.mutationsToDrain = [ + { + type: 'register', + snapshotEpoch: 1n, + class: 'cell', + support: new Uint32Array([2, 3]), + values: new Float32Array([0.4, 0.5]), + trace: new Float32Array([0]), + }, + ]; + // Mutation has to be visible when the worker pops it on frame 0. + const handles = getFitHandles(); + expect(handles).toBeDefined(); + handles!.mutationQueue.push(mockState.mutationsToDrain.shift()!); + + for (let i = 0; i < 5; i += 1) writeFrameToChannel(init.frameChannel, i); + + await harness.deliver({ kind: 'run' }); + await runUntil( + harness, + (p) => + p.filter((m) => m.kind === 'event' && m.event.kind === 'footprint-snapshot').length >= 2, + ); + + const snaps = harness.posted + .filter( + (m): m is Extract => + m.kind === 'event' && m.event.kind === 'footprint-snapshot', + ) + .map((m) => m.event as Extract); + // At least the first log-spaced firing should appear; payloads + // should carry the cached footprint from the register mutation. + expect(snaps.length).toBeGreaterThanOrEqual(1); + expect(Array.from(snaps[0].footprint.pixelIndices)).toEqual([2, 3]); + }); + it('emits the five vitals metrics on the vitalsStride cadence', async () => { const harness = createWorkerHarness(); await loadWorker(harness); diff --git a/apps/cala/src/workers/__tests__/footprint-snapshot-scheduler.test.ts b/apps/cala/src/workers/__tests__/footprint-snapshot-scheduler.test.ts new file mode 100644 index 0000000..2ec0034 --- /dev/null +++ b/apps/cala/src/workers/__tests__/footprint-snapshot-scheduler.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import type { FootprintSnap } from '@calab/cala-runtime'; +import { + FootprintSnapshotScheduler, + type FootprintSnapshotSchedulerConfig, +} from '../footprint-snapshot-scheduler.ts'; + +const TEST_CFG: FootprintSnapshotSchedulerConfig = { maxTrackedNeurons: 4 }; + +function snap(px: number, v: number): FootprintSnap { + return { pixelIndices: new Uint32Array([px]), values: new Float32Array([v]) }; +} + +describe('FootprintSnapshotScheduler', () => { + it('rejects non-positive maxTrackedNeurons', () => { + expect(() => new FootprintSnapshotScheduler({ maxTrackedNeurons: 0 })).toThrow(); + expect(() => new FootprintSnapshotScheduler({ maxTrackedNeurons: -1 })).toThrow(); + }); + + it('emits at ages 1, 2, 4, 8, ... after birth', () => { + const sched = new FootprintSnapshotScheduler(TEST_CFG); + sched.onBirth(7, 10, snap(1, 0.1)); + + // age=1 at t=11 → fires. nextAge advances to 2. + expect(sched.tick(11).map((d) => d.t)).toEqual([11]); + // age=2 at t=12 → fires (age ≥ nextAge=2). nextAge → 4. + expect(sched.tick(12).map((d) => d.t)).toEqual([12]); + // age=3 at t=13 → no fire (3 < 4). + expect(sched.tick(13)).toEqual([]); + // age=4 at t=14 → fires. nextAge → 8. + expect(sched.tick(14).map((d) => d.t)).toEqual([14]); + // t=17, age=7 → no fire. + expect(sched.tick(17)).toEqual([]); + // t=18, age=8 → fires. + expect(sched.tick(18).map((d) => d.t)).toEqual([18]); + }); + + it('tracks multiple neurons independently', () => { + const sched = new FootprintSnapshotScheduler(TEST_CFG); + sched.onBirth(1, 0, snap(1, 1)); + sched.onBirth(2, 5, snap(2, 2)); + // Both hit nextAge=1 when frame is one past their respective births. + const due = sched.tick(6); + const ids = due.map((d) => d.neuronId).sort((a, b) => a - b); + expect(ids).toEqual([1, 2]); + }); + + it('onMutationFootprint refreshes the cached snap without resetting the schedule', () => { + const sched = new FootprintSnapshotScheduler(TEST_CFG); + sched.onBirth(7, 0, snap(1, 0.1)); + sched.tick(1); // advances nextAge from 1 to 2 + sched.onMutationFootprint(7, 5, snap(9, 0.9)); + // Next scheduled fire is at age=2 → t=2; we already passed it, so + // the next tick with age ≥ 2 (t=5, age=5) fires and carries the + // refreshed footprint rather than the original birth snap. + const due = sched.tick(5); + expect(due.length).toBe(1); + expect(Array.from(due[0].footprint.pixelIndices)).toEqual([9]); + }); + + it('onDeprecate removes a neuron so no further snapshots fire', () => { + const sched = new FootprintSnapshotScheduler(TEST_CFG); + sched.onBirth(7, 0, snap(1, 1)); + sched.onDeprecate(7); + expect(sched.tick(1)).toEqual([]); + expect(sched.trackedIds()).toEqual([]); + }); + + it('drops oldest-inserted neuron once maxTrackedNeurons is exceeded', () => { + const sched = new FootprintSnapshotScheduler({ maxTrackedNeurons: 2 }); + sched.onBirth(1, 0, snap(1, 1)); + sched.onBirth(2, 0, snap(2, 2)); + sched.onBirth(3, 0, snap(3, 3)); // Evicts 1. + expect(sched.trackedIds().sort((a, b) => a - b)).toEqual([2, 3]); + }); +}); diff --git a/apps/cala/src/workers/fit.worker.ts b/apps/cala/src/workers/fit.worker.ts index 22d26c1..02202b2 100644 --- a/apps/cala/src/workers/fit.worker.ts +++ b/apps/cala/src/workers/fit.worker.ts @@ -6,6 +6,7 @@ import { METRIC_MEMORY_BYTES, METRIC_RESIDUAL_L2, } from '../lib/vitals.ts'; +import { FootprintSnapshotScheduler } from './footprint-snapshot-scheduler.ts'; import { SabRingChannel, EventBus, @@ -62,6 +63,10 @@ const DEFAULT_EXTEND_CONFIG_JSON = '{}'; // either emits the full vitals bundle or none of it. Overridable // via `workerConfig.vitalsStride`. const DEFAULT_VITALS_STRIDE = 8; +// Cap on neurons tracked by the log-spaced footprint scheduler +// (design §9.3). Matches the archive's footprint-history neuron cap +// so upstream and storage stay within the same envelope. +const DEFAULT_FOOTPRINT_SCHEDULER_MAX_NEURONS = 512; const ROLE = 'fit' as const; @@ -78,6 +83,7 @@ interface FitWorkerConfig { heartbeatStride: number; vitalsStride: number; snapshotStride: number; + footprintSchedulerMaxNeurons: number; mutationDrainMaxPerIteration: number; eventBusCapacity: number; eventBusMaxSubscribers: number; @@ -118,6 +124,7 @@ interface RuntimeHandles { // Most recent residualL2 from `step()`; cached between frames so the // vitals emission can read it without re-running math. lastResidualL2: number; + footprintScheduler: FootprintSnapshotScheduler; } let handles: RuntimeHandles | null = null; @@ -162,6 +169,10 @@ function parseConfig(raw: unknown): FitWorkerConfig { heartbeatStride: numberOr(cfg.heartbeatStride, DEFAULT_HEARTBEAT_STRIDE), vitalsStride: numberOr(cfg.vitalsStride, DEFAULT_VITALS_STRIDE), snapshotStride: numberOr(cfg.snapshotStride, DEFAULT_SNAPSHOT_STRIDE), + footprintSchedulerMaxNeurons: numberOr( + cfg.footprintSchedulerMaxNeurons, + DEFAULT_FOOTPRINT_SCHEDULER_MAX_NEURONS, + ), mutationDrainMaxPerIteration: numberOr( cfg.mutationDrainMaxPerIteration, DEFAULT_MUTATION_DRAIN_MAX_PER_ITERATION, @@ -239,6 +250,9 @@ async function handleInit(payload: WorkerInitPayload): Promise { lastVitalsTimeMs: 0, lastVitalsFrameIndex: 0, lastResidualL2: 0, + footprintScheduler: new FootprintSnapshotScheduler({ + maxTrackedNeurons: cfg.footprintSchedulerMaxNeurons, + }), }; // Test-only hook so unit tests can push mutations into the worker's @@ -291,6 +305,37 @@ function mutationToEvent(m: PipelineMutation, frameIndex: number): PipelineEvent } } +function updateSchedulerFromEvent( + scheduler: FootprintSnapshotScheduler, + ev: PipelineEvent, +): void { + // Mirror every structural event into the scheduler so the + // log-spaced floor fires with the latest known footprint per + // neuron (§9.3). Mutations without a footprint payload still + // update the tracked neuron through their attached snap. + switch (ev.kind) { + case 'birth': + scheduler.onBirth(ev.id, ev.t, ev.footprintSnap); + return; + case 'merge': + scheduler.onMutationFootprint(ev.into, ev.t, ev.footprintSnap); + return; + case 'split': + for (let i = 0; i < ev.into.length; i += 1) { + const snap = ev.footprintSnaps[i]; + if (snap) scheduler.onMutationFootprint(ev.into[i], ev.t, snap); + } + return; + case 'deprecate': + scheduler.onDeprecate(ev.id); + return; + case 'reject': + case 'metric': + case 'footprint-snapshot': + return; + } +} + function drainMutationsOnce(h: RuntimeHandles, frameIndex: number): number { // Apply at most `mutationDrainMaxPerIteration` queued mutations so a // burst of extend proposals cannot stall the fit loop for more than @@ -305,13 +350,28 @@ function drainMutationsOnce(h: RuntimeHandles, frameIndex: number): number { // two queues this reduces to a single call. h.fitter.drainApply(h.mutationQueueHandle); const ev = mutationToEvent(m, frameIndex); - if (ev) h.eventBus.publish(ev); + if (ev) { + h.eventBus.publish(ev); + updateSchedulerFromEvent(h.footprintScheduler, ev); + } post({ kind: 'mutation-applied', role: ROLE, epoch: h.fitter.epoch() }); applied += 1; } return applied; } +function emitScheduledFootprints(h: RuntimeHandles, frameIndex: number): void { + const due = h.footprintScheduler.tick(frameIndex); + for (const d of due) { + h.eventBus.publish({ + kind: 'footprint-snapshot', + t: d.t, + neuronId: d.neuronId, + footprint: d.footprint, + }); + } +} + function takeCadencedSnapshot(h: RuntimeHandles, frameIndex: number): void { if (h.config.snapshotStride <= 0) return; if ((frameIndex + 1) % h.config.snapshotStride !== 0) return; @@ -395,6 +455,7 @@ async function fitLoop(h: RuntimeHandles): Promise { h.lastResidualL2 = residualL2(residual); drainMutationsOnce(h, frameIndex); takeCadencedSnapshot(h, frameIndex); + emitScheduledFootprints(h, frameIndex); emitVitals(h, frameIndex); if ((frameIndex + 1) % h.config.heartbeatStride === 0) { post({ diff --git a/apps/cala/src/workers/footprint-snapshot-scheduler.ts b/apps/cala/src/workers/footprint-snapshot-scheduler.ts new file mode 100644 index 0000000..9343418 --- /dev/null +++ b/apps/cala/src/workers/footprint-snapshot-scheduler.ts @@ -0,0 +1,112 @@ +/** + * Log-spaced footprint-snapshot scheduler for the fit worker + * (design §9.3, Phase 6 task 5). + * + * Implements the **log-spaced floor** of the §9.3 hybrid scheme: for + * each tracked neuron, emit a snapshot at ages 1, 2, 4, 8, 16, ... + * frames after birth. The **change-triggered** branch (ε > 0.05 Frob + * drift) is left as a TODO — it needs per-frame access to the + * current `A` column, which requires a new wasm-bindgen accessor on + * `Fitter`. Until that lands, the log-spaced schedule is fed with + * the last footprint attached to a mutation event (register / merge + * / split), which is accurate at birth and at every structural event + * but stale between them. Good enough to populate the scrubber UI + * and exercise the archive's footprint store. + * + * Structural events (birth / merge / split) already carry full + * snapshots and are stored on the archive side (task 3). This + * scheduler only adds the *quiet-period* floor. + */ +import type { FootprintSnap } from '@calab/cala-runtime'; + +export interface FootprintSnapshotSchedulerConfig { + /** Drop-oldest cap on tracked neurons. */ + maxTrackedNeurons: number; +} + +export interface FootprintDueSnapshot { + neuronId: number; + t: number; + footprint: FootprintSnap; +} + +interface TrackedNeuron { + birthFrame: number; + nextAge: number; + lastSnap: FootprintSnap; +} + +function validateConfig(cfg: FootprintSnapshotSchedulerConfig): void { + if (!Number.isInteger(cfg.maxTrackedNeurons) || cfg.maxTrackedNeurons < 1) { + throw new Error( + `FootprintSnapshotSchedulerConfig.maxTrackedNeurons must be ≥ 1 (got ${cfg.maxTrackedNeurons})`, + ); + } +} + +export class FootprintSnapshotScheduler { + private readonly cfg: FootprintSnapshotSchedulerConfig; + private readonly byNeuron = new Map(); + + constructor(cfg: FootprintSnapshotSchedulerConfig) { + validateConfig(cfg); + this.cfg = cfg; + } + + /** Start tracking a newly born neuron. Called on `register` mutations. */ + onBirth(neuronId: number, t: number, snap: FootprintSnap): void { + this.upsert(neuronId, { + birthFrame: t, + nextAge: 1, + lastSnap: snap, + }); + } + + /** + * Refresh the cached footprint for a neuron that already exists + * (merge survivor, split child, or any other structural change). + * If the id isn't tracked yet, treats it as a birth at `t`. + */ + onMutationFootprint(neuronId: number, t: number, snap: FootprintSnap): void { + const existing = this.byNeuron.get(neuronId); + if (existing) { + existing.lastSnap = snap; + return; + } + this.upsert(neuronId, { birthFrame: t, nextAge: 1, lastSnap: snap }); + } + + /** Stop tracking a deprecated neuron. No-op if absent. */ + onDeprecate(neuronId: number): void { + this.byNeuron.delete(neuronId); + } + + /** + * Advance schedules to frame `t`. Returns every (neuron, snap) + * pair that becomes due at this frame and bumps its `nextAge` to + * the next power of two. + */ + tick(t: number): FootprintDueSnapshot[] { + const due: FootprintDueSnapshot[] = []; + for (const [neuronId, state] of this.byNeuron) { + const age = t - state.birthFrame; + if (age >= state.nextAge) { + due.push({ neuronId, t, footprint: state.lastSnap }); + state.nextAge *= 2; + } + } + return due; + } + + trackedIds(): number[] { + return Array.from(this.byNeuron.keys()); + } + + private upsert(neuronId: number, value: TrackedNeuron): void { + if (!this.byNeuron.has(neuronId) && this.byNeuron.size >= this.cfg.maxTrackedNeurons) { + const oldest = this.byNeuron.keys().next().value; + if (oldest !== undefined) this.byNeuron.delete(oldest); + } + this.byNeuron.set(neuronId, value); + } +} From e45ff77f8756984573aa861b394e1a03b4756c57 Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 18 Apr 2026 22:18:36 -0700 Subject: [PATCH 06/18] feat(cala): archive-client timeseries + neuron queries (task 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the main-thread archive client with three new promise-based request methods that forward to the W4 queries added in tasks 1-3: - requestTimeseries(name) → tiered sparkline data for a metric - requestEventsForNeuron(id) → per-neuron structural history - requestFootprintHistory(id) → per-neuron (t, sparse A) scrubber data Refactors the existing dump path onto a generic `issueRequest` helper so every reply kind shares one requestId-correlation store without type drift. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/lib/__tests__/archive-client.test.ts | 74 ++++++++ apps/cala/src/lib/archive-client.ts | 169 +++++++++++++++--- 2 files changed, 222 insertions(+), 21 deletions(-) diff --git a/apps/cala/src/lib/__tests__/archive-client.test.ts b/apps/cala/src/lib/__tests__/archive-client.test.ts index 89fdd33..e9d4944 100644 --- a/apps/cala/src/lib/__tests__/archive-client.test.ts +++ b/apps/cala/src/lib/__tests__/archive-client.test.ts @@ -223,6 +223,80 @@ describe('cala archive-client', () => { expect((err as Error).name).toMatch(/Abort|Dispose/); }); + it('requestTimeseries posts request-timeseries and resolves with typed-array payloads', async () => { + const promise = client.requestTimeseries('fps'); + expect(worker.posted.length).toBe(1); + const req = worker.posted[0] as { kind: string; requestId: number; name: string }; + expect(req.kind).toBe('request-timeseries'); + expect(req.name).toBe('fps'); + + worker.push({ + kind: 'timeseries', + role: 'archive', + requestId: req.requestId, + name: 'fps', + l1Times: new Float32Array([0, 1, 2]), + l1Values: new Float32Array([30, 29, 30]), + l2Times: new Float32Array([]), + l2Values: new Float32Array([]), + }); + + const reply = await promise; + expect(reply.name).toBe('fps'); + expect(Array.from(reply.l1Times)).toEqual([0, 1, 2]); + expect(Array.from(reply.l1Values)).toEqual([30, 29, 30]); + }); + + it('requestEventsForNeuron posts request-events-for-neuron and resolves with the event list', async () => { + const promise = client.requestEventsForNeuron(42); + const req = worker.posted[0] as { + kind: string; + requestId: number; + neuronId: number; + }; + expect(req.kind).toBe('request-events-for-neuron'); + expect(req.neuronId).toBe(42); + + const events = [birthEvent(1, 42)]; + worker.push({ + kind: 'events-for-neuron', + role: 'archive', + requestId: req.requestId, + neuronId: 42, + events, + }); + expect(await promise).toEqual(events); + }); + + it('requestFootprintHistory pairs times with parallel typed-array payloads', async () => { + const promise = client.requestFootprintHistory(9); + const req = worker.posted[0] as { + kind: string; + requestId: number; + neuronId: number; + }; + expect(req.kind).toBe('request-footprint-history'); + expect(req.neuronId).toBe(9); + + worker.push({ + kind: 'footprint-history', + role: 'archive', + requestId: req.requestId, + neuronId: 9, + times: new Float32Array([1, 5]), + pixelIndices: [new Uint32Array([1]), new Uint32Array([3, 4])], + values: [new Float32Array([0.5]), new Float32Array([0.1, 0.2])], + }); + + const history = await promise; + expect(history.length).toBe(2); + expect(history[0].t).toBe(1); + expect(Array.from(history[1].pixelIndices)).toEqual([3, 4]); + expect(Array.from(history[1].values)).toEqual([ + 0.10000000149011612, 0.20000000298023224, + ]); + }); + it('onEvent delivers PipelineEvent messages posted by the worker', () => { const received: PipelineEvent[] = []; const unsub = client.onEvent((e) => { diff --git a/apps/cala/src/lib/archive-client.ts b/apps/cala/src/lib/archive-client.ts index a1c70ab..81587b3 100644 --- a/apps/cala/src/lib/archive-client.ts +++ b/apps/cala/src/lib/archive-client.ts @@ -15,8 +15,25 @@ export interface ArchiveDump { metrics: Record; } +export interface TimeseriesReply { + name: string; + l1Times: Float32Array; + l1Values: Float32Array; + l2Times: Float32Array; + l2Values: Float32Array; +} + +export interface FootprintHistoryEntry { + t: number; + pixelIndices: Uint32Array; + values: Float32Array; +} + export interface ArchiveClient { requestDump(): Promise; + requestTimeseries(name: string): Promise; + requestEventsForNeuron(neuronId: number): Promise; + requestFootprintHistory(neuronId: number): Promise; startPolling(cb: (dump: ArchiveDump) => void): void; stopPolling(): void; onEvent(cb: (e: PipelineEvent) => void): Unsubscribe; @@ -42,12 +59,23 @@ class DumpTimeoutError extends Error { } } -interface PendingDump { - resolve: (dump: ArchiveDump) => void; +// Generic reply binder. Every request kind shares the same +// "post-then-await-matching-requestId" pattern; this type lets us +// bookkeep them all in one pending map without losing type info at +// the resolve site. +interface PendingReply { + resolve: (v: T) => void; reject: (err: Error) => void; timer: ReturnType; + kind: 'dump' | 'timeseries' | 'events-for-neuron' | 'footprint-history'; } +type PendingEntry = + | PendingReply + | PendingReply + | PendingReply + | PendingReply; + export function createArchiveClient( worker: WorkerLike, options: ArchiveClientOptions = {}, @@ -55,7 +83,7 @@ export function createArchiveClient( const pollInterval = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; const dumpTimeout = options.dumpTimeoutMs ?? DEFAULT_DUMP_TIMEOUT_MS; - const pending = new Map(); + const pending = new Map(); const eventListeners = new Set<(e: PipelineEvent) => void>(); let nextRequestId = 1; let disposed = false; @@ -64,44 +92,134 @@ export function createArchiveClient( const handleMessage = (ev: { data: WorkerOutbound }): void => { const msg = ev.data; - if (msg.kind === 'archive-dump') { - const entry = pending.get(msg.requestId); - // Unknown-id replies (e.g. from a disposed-and-recreated client - // sharing the worker) must not spuriously resolve a waiter. - if (!entry) return; - pending.delete(msg.requestId); - clearTimeout(entry.timer); - entry.resolve({ events: msg.events, metrics: msg.metrics }); - return; - } - if (msg.kind === 'event') { - for (const cb of eventListeners) cb(msg.event); - return; + switch (msg.kind) { + case 'archive-dump': { + const entry = pending.get(msg.requestId); + // Unknown-id replies (e.g. from a disposed-and-recreated + // client sharing the worker) must not spuriously resolve a + // waiter. Same guard applies for every kind below. + if (!entry || entry.kind !== 'dump') return; + pending.delete(msg.requestId); + clearTimeout(entry.timer); + (entry as PendingReply).resolve({ + events: msg.events, + metrics: msg.metrics, + }); + return; + } + case 'timeseries': { + const entry = pending.get(msg.requestId); + if (!entry || entry.kind !== 'timeseries') return; + pending.delete(msg.requestId); + clearTimeout(entry.timer); + (entry as PendingReply).resolve({ + name: msg.name, + l1Times: msg.l1Times, + l1Values: msg.l1Values, + l2Times: msg.l2Times, + l2Values: msg.l2Values, + }); + return; + } + case 'events-for-neuron': { + const entry = pending.get(msg.requestId); + if (!entry || entry.kind !== 'events-for-neuron') return; + pending.delete(msg.requestId); + clearTimeout(entry.timer); + (entry as PendingReply).resolve(msg.events); + return; + } + case 'footprint-history': { + const entry = pending.get(msg.requestId); + if (!entry || entry.kind !== 'footprint-history') return; + pending.delete(msg.requestId); + clearTimeout(entry.timer); + const history: FootprintHistoryEntry[] = []; + for (let i = 0; i < msg.times.length; i += 1) { + history.push({ + t: msg.times[i], + pixelIndices: msg.pixelIndices[i], + values: msg.values[i], + }); + } + (entry as PendingReply).resolve(history); + return; + } + case 'event': + for (const cb of eventListeners) cb(msg.event); + return; + default: + return; } }; worker.addEventListener('message', handleMessage); - function requestDump(): Promise { + function issueRequest( + kind: PendingEntry['kind'], + label: string, + send: (requestId: number) => void, + ): Promise { if (disposed) { return Promise.reject(new DumpAbortError('archive client disposed')); } const requestId = nextRequestId; nextRequestId += 1; - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const timer = setTimeout(() => { pending.delete(requestId); reject( new DumpTimeoutError( - `archive dump (requestId=${requestId}) timed out after ${dumpTimeout}ms`, + `archive ${label} (requestId=${requestId}) timed out after ${dumpTimeout}ms`, ), ); }, dumpTimeout); - pending.set(requestId, { resolve, reject, timer }); + pending.set(requestId, { + kind, + resolve: resolve as (v: unknown) => void, + reject, + timer, + } as unknown as PendingEntry); + send(requestId); + }); + } + + function requestDump(): Promise { + return issueRequest('dump', 'dump', (requestId) => { worker.postMessage({ kind: 'request-archive-dump', requestId }); }); } + function requestTimeseries(name: string): Promise { + return issueRequest( + 'timeseries', + `timeseries(${name})`, + (requestId) => { + worker.postMessage({ kind: 'request-timeseries', requestId, name }); + }, + ); + } + + function requestEventsForNeuron(neuronId: number): Promise { + return issueRequest( + 'events-for-neuron', + `events-for-neuron(${neuronId})`, + (requestId) => { + worker.postMessage({ kind: 'request-events-for-neuron', requestId, neuronId }); + }, + ); + } + + function requestFootprintHistory(neuronId: number): Promise { + return issueRequest( + 'footprint-history', + `footprint-history(${neuronId})`, + (requestId) => { + worker.postMessage({ kind: 'request-footprint-history', requestId, neuronId }); + }, + ); + } + function startPolling(cb: (dump: ArchiveDump) => void): void { if (disposed) return; pollCallback = cb; @@ -153,5 +271,14 @@ export function createArchiveClient( pending.clear(); } - return { requestDump, startPolling, stopPolling, onEvent, dispose }; + return { + requestDump, + requestTimeseries, + requestEventsForNeuron, + requestFootprintHistory, + startPolling, + stopPolling, + onEvent, + dispose, + }; } From 0f645c8618f5c32e5b3ffd5d8aea100b882daf8e Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 18 Apr 2026 22:21:47 -0700 Subject: [PATCH 07/18] feat(cala): header vitals bar with sparklines (task 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the §12 header vitals bar: five Canvas-based sparkline widgets bound to a new `vitals-store` that polls the archive client every 500 ms for cell_count / fps / memory_bytes / residual_l2 / extend_queue_depth. L1 + L2 tiers merge into a single flat window (default 120 samples ≈ 60 s of history) so the component doesn't know about the retention scheme. Lifecycle is tied to the run: the bar spins up an archive client on transition to running, polls while the run is active, and tears down on stop/error. Sparklines auto-scale per series so each vital gets its full vertical range regardless of magnitude. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cala/src/App.tsx | 2 + apps/cala/src/components/vitals/SparkLine.tsx | 83 +++++++++++++ apps/cala/src/components/vitals/VitalsBar.tsx | 91 ++++++++++++++ .../src/lib/__tests__/vitals-store.test.ts | 112 +++++++++++++++++ apps/cala/src/lib/vitals-store.ts | 116 ++++++++++++++++++ apps/cala/src/styles/global.css | 38 ++++++ 6 files changed, 442 insertions(+) create mode 100644 apps/cala/src/components/vitals/SparkLine.tsx create mode 100644 apps/cala/src/components/vitals/VitalsBar.tsx create mode 100644 apps/cala/src/lib/__tests__/vitals-store.test.ts create mode 100644 apps/cala/src/lib/vitals-store.ts diff --git a/apps/cala/src/App.tsx b/apps/cala/src/App.tsx index ed3d63e..566e52c 100644 --- a/apps/cala/src/App.tsx +++ b/apps/cala/src/App.tsx @@ -3,6 +3,7 @@ import { DashboardShell } from '@calab/ui'; import { CaLaHeader } from './components/layout/CaLaHeader.tsx'; import { ImportOverlay } from './components/layout/ImportOverlay.tsx'; import { SingleFrameViewer } from './components/frame/SingleFrameViewer.tsx'; +import { VitalsBar } from './components/vitals/VitalsBar.tsx'; import { state } from './lib/data-store.ts'; import { currentArchiveWorkerForClient } from './lib/run-control.ts'; import { createArchiveClient, type ArchiveClient } from './lib/archive-client.ts'; @@ -37,6 +38,7 @@ const App: Component = () => { } fallback={} > + diff --git a/apps/cala/src/components/vitals/SparkLine.tsx b/apps/cala/src/components/vitals/SparkLine.tsx new file mode 100644 index 0000000..bc2b712 --- /dev/null +++ b/apps/cala/src/components/vitals/SparkLine.tsx @@ -0,0 +1,83 @@ +import { createEffect, onCleanup, type JSX } from 'solid-js'; + +// Visual defaults. Keep in one place so the vitals bar has a single +// knob to retune density; no in-component magic numbers. +const DEFAULT_WIDTH_PX = 120; +const DEFAULT_HEIGHT_PX = 32; +const DEFAULT_LINE_WIDTH_PX = 1.5; +const DEFAULT_PADDING_PX = 2; + +export interface SparkLineProps { + /** Input series; any length, rendered oldest→newest left→right. */ + values: Float32Array | number[]; + /** Optional stroke override (defaults to `var(--accent)` via CSS). */ + color?: string; + width?: number; + height?: number; + title?: string; +} + +/** + * Tiny Canvas-based sparkline. Redraws whenever `values` changes. + * Auto-scales each draw so a series that spans 0..1000 renders at the + * same visual amplitude as one that spans 0..0.1 — callers who want + * absolute comparison should normalize upstream. + */ +export function SparkLine(props: SparkLineProps): JSX.Element { + let canvas: HTMLCanvasElement | undefined; + + createEffect(() => { + const el = canvas; + if (!el) return; + const w = props.width ?? DEFAULT_WIDTH_PX; + const h = props.height ?? DEFAULT_HEIGHT_PX; + if (el.width !== w) el.width = w; + if (el.height !== h) el.height = h; + const ctx = el.getContext('2d'); + if (!ctx) return; + ctx.clearRect(0, 0, w, h); + + const values = props.values; + const n = values.length; + if (n < 2) return; + + let min = Infinity; + let max = -Infinity; + for (let i = 0; i < n; i += 1) { + const v = values[i]; + if (v < min) min = v; + if (v > max) max = v; + } + if (!Number.isFinite(min) || !Number.isFinite(max)) return; + const range = max - min || 1; + + const pad = DEFAULT_PADDING_PX; + const plotW = w - pad * 2; + const plotH = h - pad * 2; + ctx.beginPath(); + for (let i = 0; i < n; i += 1) { + const x = pad + (i / (n - 1)) * plotW; + const y = pad + plotH - ((values[i] - min) / range) * plotH; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.lineWidth = DEFAULT_LINE_WIDTH_PX; + ctx.strokeStyle = props.color ?? 'currentColor'; + ctx.stroke(); + }); + + onCleanup(() => { + // Let the GC reclaim the canvas; no external subscriptions. + }); + + return ( + + ); +} diff --git a/apps/cala/src/components/vitals/VitalsBar.tsx b/apps/cala/src/components/vitals/VitalsBar.tsx new file mode 100644 index 0000000..3e80388 --- /dev/null +++ b/apps/cala/src/components/vitals/VitalsBar.tsx @@ -0,0 +1,91 @@ +import { createEffect, createSignal, For, onCleanup, Show, type JSX } from 'solid-js'; +import { createArchiveClient, type ArchiveClient } from '../../lib/archive-client.ts'; +import { currentArchiveWorkerForClient } from '../../lib/run-control.ts'; +import { state } from '../../lib/data-store.ts'; +import { + METRIC_CELL_COUNT, + METRIC_EXTEND_QUEUE_DEPTH, + METRIC_FPS, + METRIC_MEMORY_BYTES, + METRIC_RESIDUAL_L2, + type VitalsMetricName, +} from '../../lib/vitals.ts'; +import { + resetVitals, + startVitalsPolling, + vitals, + type VitalsPollerHandle, +} from '../../lib/vitals-store.ts'; +import { SparkLine } from './SparkLine.tsx'; + +// MB divisor for memory_bytes formatting. +const BYTES_PER_MIB = 1024 * 1024; + +interface VitalDisplay { + name: VitalsMetricName; + label: string; + format: (v: number) => string; +} + +const VITALS: VitalDisplay[] = [ + { name: METRIC_CELL_COUNT, label: 'cells', format: (v) => String(Math.round(v)) }, + { name: METRIC_FPS, label: 'fps', format: (v) => v.toFixed(1) }, + { + name: METRIC_MEMORY_BYTES, + label: 'mem', + format: (v) => `${(v / BYTES_PER_MIB).toFixed(0)} MiB`, + }, + { name: METRIC_RESIDUAL_L2, label: 'res', format: (v) => v.toFixed(3) }, + { + name: METRIC_EXTEND_QUEUE_DEPTH, + label: 'queue', + format: (v) => String(Math.round(v)), + }, +]; + +export function VitalsBar(): JSX.Element { + const [client, setClient] = createSignal(null); + let poller: VitalsPollerHandle | null = null; + + // Rebuild the archive client whenever the run state becomes running — + // we need the current archive worker, which is only available once + // the orchestrator spins up. When the run ends we tear it down. + createEffect(() => { + const rs = state.runState; + if (rs === 'running') { + const worker = currentArchiveWorkerForClient(); + if (!worker) return; + const c = createArchiveClient(worker); + setClient(c); + poller = startVitalsPolling(c); + } else { + poller?.stop(); + poller = null; + const c = client(); + c?.dispose(); + setClient(null); + if (rs === 'idle' || rs === 'stopped' || rs === 'error') resetVitals(); + } + }); + + onCleanup(() => { + poller?.stop(); + client()?.dispose(); + }); + + return ( +
+ + {(v) => ( +
+
{v.label}
+
{v.format(vitals.latestByName[v.name] ?? 0)}
+ 1}> + + +
+ )} +
+
+ ); +} diff --git a/apps/cala/src/lib/__tests__/vitals-store.test.ts b/apps/cala/src/lib/__tests__/vitals-store.test.ts new file mode 100644 index 0000000..fd8551c --- /dev/null +++ b/apps/cala/src/lib/__tests__/vitals-store.test.ts @@ -0,0 +1,112 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ArchiveClient, TimeseriesReply } from '../archive-client.ts'; +import { resetVitals, startVitalsPolling, vitals } from '../vitals-store.ts'; +import { METRIC_CELL_COUNT, METRIC_FPS } from '../vitals.ts'; + +function tsReply(name: string, l1: number[], l2: number[] = []): TimeseriesReply { + return { + name, + l1Times: new Float32Array(l1.map((_, i) => i)), + l1Values: new Float32Array(l1), + l2Times: new Float32Array(l2.map((_, i) => i)), + l2Values: new Float32Array(l2), + }; +} + +function makeFakeClient(responses: Record): ArchiveClient { + return { + requestDump: vi.fn(), + requestTimeseries: vi.fn(async (name: string) => { + if (!responses[name]) throw new Error(`no canned reply for ${name}`); + return responses[name]; + }), + requestEventsForNeuron: vi.fn(), + requestFootprintHistory: vi.fn(), + startPolling: vi.fn(), + stopPolling: vi.fn(), + onEvent: vi.fn(() => () => {}), + dispose: vi.fn(), + } as unknown as ArchiveClient; +} + +describe('vitals-store', () => { + beforeEach(() => { + resetVitals(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('populates seriesByName + latestByName from one poll cycle', async () => { + const client = makeFakeClient({ + [METRIC_CELL_COUNT]: tsReply(METRIC_CELL_COUNT, [5, 6, 7]), + [METRIC_FPS]: tsReply(METRIC_FPS, [30, 29, 30], [20, 25]), + }); + const handle = startVitalsPolling(client, { + names: [METRIC_CELL_COUNT, METRIC_FPS], + intervalMs: 500, + windowSamples: 100, + }); + + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(1); + // A few microtask flushes for the async pollOnce() awaits. + for (let i = 0; i < 10; i += 1) await Promise.resolve(); + + expect(Array.from(vitals.seriesByName[METRIC_CELL_COUNT])).toEqual([5, 6, 7]); + // fps merges L2 (20, 25) + L1 (30, 29, 30) in time order. + expect(Array.from(vitals.seriesByName[METRIC_FPS])).toEqual([20, 25, 30, 29, 30]); + expect(vitals.latestByName[METRIC_CELL_COUNT]).toBe(7); + expect(vitals.latestByName[METRIC_FPS]).toBe(30); + + handle.stop(); + }); + + it('trims the merged series to windowSamples entries', async () => { + const manyValues = Array.from({ length: 200 }, (_, i) => i); + const client = makeFakeClient({ [METRIC_FPS]: tsReply(METRIC_FPS, manyValues) }); + const handle = startVitalsPolling(client, { + names: [METRIC_FPS], + intervalMs: 500, + windowSamples: 50, + }); + await vi.advanceTimersByTimeAsync(0); + for (let i = 0; i < 10; i += 1) await Promise.resolve(); + expect(vitals.seriesByName[METRIC_FPS].length).toBe(50); + // Must be the newest 50 — last element matches the tail. + expect(vitals.seriesByName[METRIC_FPS][49]).toBe(199); + handle.stop(); + }); + + it('stop halts further polling', async () => { + const client = makeFakeClient({ [METRIC_FPS]: tsReply(METRIC_FPS, [1]) }); + const handle = startVitalsPolling(client, { + names: [METRIC_FPS], + intervalMs: 100, + }); + await vi.advanceTimersByTimeAsync(0); + for (let i = 0; i < 10; i += 1) await Promise.resolve(); + handle.stop(); + const before = (client.requestTimeseries as ReturnType).mock.calls.length; + await vi.advanceTimersByTimeAsync(1000); + for (let i = 0; i < 10; i += 1) await Promise.resolve(); + expect((client.requestTimeseries as ReturnType).mock.calls.length).toBe(before); + }); + + it('swallows rejections so a transient failure leaves earlier data intact', async () => { + const client = makeFakeClient({ [METRIC_FPS]: tsReply(METRIC_FPS, [1, 2]) }); + const handle = startVitalsPolling(client, { + names: [METRIC_FPS, METRIC_CELL_COUNT], + intervalMs: 500, + }); + await vi.advanceTimersByTimeAsync(0); + for (let i = 0; i < 10; i += 1) await Promise.resolve(); + // METRIC_CELL_COUNT had no canned reply — the failure should be + // swallowed and the successful METRIC_FPS entry should still land. + expect(Array.from(vitals.seriesByName[METRIC_FPS])).toEqual([1, 2]); + expect(vitals.seriesByName[METRIC_CELL_COUNT].length).toBe(0); + handle.stop(); + }); +}); diff --git a/apps/cala/src/lib/vitals-store.ts b/apps/cala/src/lib/vitals-store.ts new file mode 100644 index 0000000..3edea66 --- /dev/null +++ b/apps/cala/src/lib/vitals-store.ts @@ -0,0 +1,116 @@ +/** + * Main-thread cache of the five header-bar vitals (design §12). + * + * Subscribes to an `ArchiveClient` and polls each metric on a fixed + * cadence, merging the L1 + L2 tiers into a single flat series the + * sparkline can render without knowing about the retention scheme. + * The store lives independently from `dashboard-store` because the + * polling lifecycle is bound to the vitals-bar mount, not to the + * run-state. + */ +import { createStore } from 'solid-js/store'; +import type { ArchiveClient } from './archive-client.ts'; +import { VITALS_METRIC_NAMES } from './vitals.ts'; + +// Poll cadence per metric. 500 ms feels responsive on the sparkline +// and scales to 5 req/s total across the vitals bar — well below the +// archive worker's throughput. +export const DEFAULT_VITALS_POLL_INTERVAL_MS = 500; +// Max points shown per sparkline. 120 columns at 500 ms ≈ 60 s of +// recent history, matching design §12 header-bar intent. +export const DEFAULT_VITALS_WINDOW_SAMPLES = 120; + +export interface VitalsState { + seriesByName: Record; + latestByName: Record; + lastUpdateAt: number | null; +} + +function emptyState(): VitalsState { + const series: Record = {}; + const latest: Record = {}; + for (const name of VITALS_METRIC_NAMES) { + series[name] = new Float32Array(0); + latest[name] = 0; + } + return { seriesByName: series, latestByName: latest, lastUpdateAt: null }; +} + +const [vitals, setVitals] = createStore(emptyState()); +export { vitals }; + +export interface VitalsPollerHandle { + stop(): void; +} + +/** + * Start polling a set of metric names from an ArchiveClient into the + * store. Returns a handle whose `stop()` cancels the interval; safe to + * call multiple times (second call is a no-op). Caller owns the + * client lifecycle. + */ +export function startVitalsPolling( + client: ArchiveClient, + options: { + names?: readonly string[]; + intervalMs?: number; + windowSamples?: number; + } = {}, +): VitalsPollerHandle { + const names = options.names ?? VITALS_METRIC_NAMES; + const interval = options.intervalMs ?? DEFAULT_VITALS_POLL_INTERVAL_MS; + const windowSamples = options.windowSamples ?? DEFAULT_VITALS_WINDOW_SAMPLES; + let stopped = false; + let timer: ReturnType | null = null; + + async function pollOnce(): Promise { + await Promise.all( + names.map(async (name) => { + try { + const reply = await client.requestTimeseries(name); + // Merge L2 (older downsampled) then L1 (recent full-res) in + // time order — the sparkline renders oldest→newest. + const merged = new Float32Array(reply.l2Values.length + reply.l1Values.length); + merged.set(reply.l2Values, 0); + merged.set(reply.l1Values, reply.l2Values.length); + const windowed = + merged.length > windowSamples ? merged.slice(merged.length - windowSamples) : merged; + const latest = merged.length > 0 ? merged[merged.length - 1] : 0; + setVitals('seriesByName', name, windowed); + setVitals('latestByName', name, latest); + } catch { + // Soft fail — the sparkline keeps showing its previous data. + // Transient archive-dump timeouts are expected during shutdown. + } + }), + ); + setVitals('lastUpdateAt', Date.now()); + } + + function schedule(): void { + if (stopped) return; + pollOnce().finally(() => { + if (stopped) return; + timer = setTimeout(schedule, interval); + }); + } + // Fire-immediately-then-interval: the bar feels live as soon as the + // user mounts it. + timer = setTimeout(schedule, 0); + + return { + stop(): void { + if (stopped) return; + stopped = true; + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + }, + }; +} + +/** Reset the store to empty. Call when a run ends / restarts. */ +export function resetVitals(): void { + setVitals(emptyState()); +} diff --git a/apps/cala/src/styles/global.css b/apps/cala/src/styles/global.css index 467c111..aeb0ab5 100644 --- a/apps/cala/src/styles/global.css +++ b/apps/cala/src/styles/global.css @@ -127,3 +127,41 @@ color: var(--text-secondary); word-break: break-word; } + +/* ─── Header vitals bar (Phase 6 task 7) ───────────────────────────── */ + +.vitals-bar { + display: flex; + flex-wrap: wrap; + gap: var(--space-md); + padding: var(--space-sm) var(--space-md); + background: var(--bg-secondary); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + font-family: var(--font-mono); + font-size: 0.8rem; +} + +.vitals-bar__cell { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 120px; +} + +.vitals-bar__label { + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: 0.7rem; +} + +.vitals-bar__value { + color: var(--text-primary); + font-weight: 600; +} + +.vitals-bar .sparkline { + display: block; + color: var(--accent); +} From 9d726519db3dc2d404ff668dc00cd706eb71e6d0 Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 18 Apr 2026 22:24:01 -0700 Subject: [PATCH 08/18] feat(cala): scrolling event feed component (task 8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the §12 event feed: a structured, grid-laid-out log of every pipeline event from the dashboard store — kind, id, time, and a compact human-readable description per row. Birth / deprecate / reject rows get distinct accent colors so the feed reads as a narrative rather than a wall of text. Format helpers are extracted into `event-format.ts` and unit-tested directly so the visual component stays untouched by string-formatting churn. Task 9 composes this + VitalsBar + SingleFrameViewer into the final dashboard layout. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cala/src/components/events/EventFeed.tsx | 46 ++++++++ .../events/__tests__/event-format.test.ts | 101 ++++++++++++++++++ .../src/components/events/event-format.ts | 44 ++++++++ apps/cala/src/styles/global.css | 78 ++++++++++++++ 4 files changed, 269 insertions(+) create mode 100644 apps/cala/src/components/events/EventFeed.tsx create mode 100644 apps/cala/src/components/events/__tests__/event-format.test.ts create mode 100644 apps/cala/src/components/events/event-format.ts diff --git a/apps/cala/src/components/events/EventFeed.tsx b/apps/cala/src/components/events/EventFeed.tsx new file mode 100644 index 0000000..169defb --- /dev/null +++ b/apps/cala/src/components/events/EventFeed.tsx @@ -0,0 +1,46 @@ +import { createMemo, For, Show, type JSX } from 'solid-js'; +import { dashboard } from '../../lib/dashboard-store.ts'; +import { describeEvent, idForEvent } from './event-format.ts'; + +// Trailing window of events shown in the feed (design §12 scrolling +// log). Full history lives in the archive worker; this is just the +// visible tail. +const DEFAULT_EVENT_TAIL_LENGTH = 50; + +export interface EventFeedProps { + /** Override the visible tail length. Defaults to 50. */ + tailLength?: number; +} + +export function EventFeed(props: EventFeedProps): JSX.Element { + const tail = createMemo(() => { + const events = dashboard.events; + const limit = props.tailLength ?? DEFAULT_EVENT_TAIL_LENGTH; + const start = Math.max(0, events.length - limit); + // Clone then reverse so the store's original order isn't mutated. + return events.slice(start).slice().reverse(); + }); + + return ( +
+
Events (newest first)
+ 0} + fallback={
No events yet.
} + > +
    + + {(e) => ( +
  • + t={e.t} + {e.kind} + {idForEvent(e)} + {describeEvent(e)} +
  • + )} +
    +
+
+
+ ); +} diff --git a/apps/cala/src/components/events/__tests__/event-format.test.ts b/apps/cala/src/components/events/__tests__/event-format.test.ts new file mode 100644 index 0000000..d870403 --- /dev/null +++ b/apps/cala/src/components/events/__tests__/event-format.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; +import type { PipelineEvent } from '@calab/cala-runtime'; +import { describeEvent, idForEvent } from '../event-format.ts'; + +function emptySnap(): { pixelIndices: Uint32Array; values: Float32Array } { + return { pixelIndices: new Uint32Array(), values: new Float32Array() }; +} + +describe('describeEvent', () => { + it('formats birth with patch coordinates', () => { + const e: PipelineEvent = { + kind: 'birth', + t: 1, + id: 7, + patch: [12, 34], + footprintSnap: emptySnap(), + }; + expect(describeEvent(e)).toBe('born @(12,34)'); + }); + + it('formats merge as "ids → into"', () => { + const e: PipelineEvent = { + kind: 'merge', + t: 1, + ids: [3, 4], + into: 3, + footprintSnap: emptySnap(), + }; + expect(describeEvent(e)).toBe('3+4 → 3'); + }); + + it('formats split as "from → [children]"', () => { + const e: PipelineEvent = { + kind: 'split', + t: 1, + from: 9, + into: [10, 11], + footprintSnaps: [emptySnap(), emptySnap()], + }; + expect(describeEvent(e)).toBe('9 → [10,11]'); + }); + + it('formats metric with name=value(3dp)', () => { + const e: PipelineEvent = { kind: 'metric', t: 1, name: 'fps', value: 30.1234 }; + expect(describeEvent(e)).toBe('fps=30.123'); + }); + + it('formats footprint-snapshot with pixel count', () => { + const e: PipelineEvent = { + kind: 'footprint-snapshot', + t: 1, + neuronId: 5, + footprint: { pixelIndices: new Uint32Array([1, 2, 3]), values: new Float32Array(3) }, + }; + expect(describeEvent(e)).toBe('id=5 (3px)'); + }); + + it('formats reject with coordinates + reason', () => { + const e: PipelineEvent = { + kind: 'reject', + t: 1, + at: [4, 8], + reason: 'low-snr', + }; + expect(describeEvent(e)).toBe('@(4,8): low-snr'); + }); + + it('formats deprecate with its reason', () => { + const e: PipelineEvent = { kind: 'deprecate', t: 1, id: 5, reason: 'traceInactive' }; + expect(describeEvent(e)).toBe('traceInactive'); + }); +}); + +describe('idForEvent', () => { + it('returns the survivor id for merge with the arrow prefix', () => { + const e: PipelineEvent = { + kind: 'merge', + t: 1, + ids: [1, 2], + into: 1, + footprintSnap: emptySnap(), + }; + expect(idForEvent(e)).toBe('→ #1'); + }); + + it('returns the source id for split with trailing arrow', () => { + const e: PipelineEvent = { + kind: 'split', + t: 1, + from: 9, + into: [10], + footprintSnaps: [emptySnap()], + }; + expect(idForEvent(e)).toBe('#9 →'); + }); + + it('returns an empty string for metric + reject events', () => { + expect(idForEvent({ kind: 'metric', t: 1, name: 'x', value: 0 })).toBe(''); + expect(idForEvent({ kind: 'reject', t: 1, at: [0, 0], reason: 'x' })).toBe(''); + }); +}); diff --git a/apps/cala/src/components/events/event-format.ts b/apps/cala/src/components/events/event-format.ts new file mode 100644 index 0000000..bc8a803 --- /dev/null +++ b/apps/cala/src/components/events/event-format.ts @@ -0,0 +1,44 @@ +/** + * Pure presentation helpers for the event feed (design §12). + * + * Extracted from the `EventFeed` SolidJS component so these can be + * unit-tested without a DOM — the component only chooses layout, the + * actual string mapping lives here. + */ +import type { PipelineEvent } from '@calab/cala-runtime'; + +export function describeEvent(e: PipelineEvent): string { + switch (e.kind) { + case 'birth': + return `born @(${e.patch[0]},${e.patch[1]})`; + case 'merge': + return `${e.ids.join('+')} → ${e.into}`; + case 'split': + return `${e.from} → [${e.into.join(',')}]`; + case 'deprecate': + return `${e.reason}`; + case 'reject': + return `@(${e.at[0]},${e.at[1]}): ${e.reason}`; + case 'metric': + return `${e.name}=${e.value.toFixed(3)}`; + case 'footprint-snapshot': + return `id=${e.neuronId} (${e.footprint.pixelIndices.length}px)`; + } +} + +export function idForEvent(e: PipelineEvent): string { + switch (e.kind) { + case 'birth': + case 'deprecate': + return `#${e.id}`; + case 'merge': + return `→ #${e.into}`; + case 'split': + return `#${e.from} →`; + case 'footprint-snapshot': + return `#${e.neuronId}`; + case 'reject': + case 'metric': + return ''; + } +} diff --git a/apps/cala/src/styles/global.css b/apps/cala/src/styles/global.css index aeb0ab5..1bbcf45 100644 --- a/apps/cala/src/styles/global.css +++ b/apps/cala/src/styles/global.css @@ -165,3 +165,81 @@ display: block; color: var(--accent); } + +/* ─── Event feed (Phase 6 task 8) ──────────────────────────────────── */ + +.event-feed { + display: flex; + flex-direction: column; + gap: var(--space-xs); + font-family: var(--font-mono); + font-size: 0.8rem; + min-height: 0; + flex: 1 1 auto; +} + +.event-feed__heading { + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: 0.7rem; + padding-bottom: var(--space-xs); + border-bottom: 1px solid var(--border-subtle); +} + +.event-feed__empty { + color: var(--text-tertiary); + padding: var(--space-sm); + font-style: italic; +} + +.event-feed__list { + list-style: none; + padding: 0; + margin: 0; + overflow-y: auto; + flex: 1 1 auto; +} + +.event-feed__item { + display: grid; + grid-template-columns: 56px 88px 80px minmax(0, 1fr); + gap: var(--space-xs); + padding: 2px 0; + border-bottom: 1px solid var(--border-subtle); + color: var(--text-secondary); +} + +.event-feed__item:last-child { + border-bottom: none; +} + +.event-feed__t { + color: var(--text-tertiary); +} + +.event-feed__kind { + color: var(--accent); + font-weight: 600; +} + +.event-feed__id { + color: var(--text-primary); +} + +.event-feed__detail { + color: var(--text-secondary); + word-break: break-word; +} + +.event-feed__item--birth .event-feed__kind { + color: var(--success, #4ade80); +} + +.event-feed__item--deprecate .event-feed__kind { + color: var(--warning, #f59e0b); +} + +.event-feed__item--reject .event-feed__kind { + color: var(--error, #f87171); +} From 6068ef54e435293c82863b2422cabf44968cce59 Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 18 Apr 2026 22:25:49 -0700 Subject: [PATCH 09/18] feat(cala): dashboard layout integration (task 9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composes VitalsBar + SingleFrameViewer + EventFeed into a single grid layout (design §12): vitals along the top, preview canvas on the left, event feed on the right, each cell independently scrollable. App.tsx hands off to `DashboardLayout` on any active-run state; the old SingleFrameViewer side-panel + inline event list come out since their roles are now covered by the dedicated components. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cala/src/App.tsx | 6 +- .../components/frame/SingleFrameViewer.tsx | 74 +------------------ .../src/components/layout/DashboardLayout.tsx | 26 +++++++ apps/cala/src/styles/global.css | 53 +++++++++++++ 4 files changed, 83 insertions(+), 76 deletions(-) create mode 100644 apps/cala/src/components/layout/DashboardLayout.tsx diff --git a/apps/cala/src/App.tsx b/apps/cala/src/App.tsx index 566e52c..980c3ac 100644 --- a/apps/cala/src/App.tsx +++ b/apps/cala/src/App.tsx @@ -2,8 +2,7 @@ import { createEffect, onCleanup, Show, type Component } from 'solid-js'; import { DashboardShell } from '@calab/ui'; import { CaLaHeader } from './components/layout/CaLaHeader.tsx'; import { ImportOverlay } from './components/layout/ImportOverlay.tsx'; -import { SingleFrameViewer } from './components/frame/SingleFrameViewer.tsx'; -import { VitalsBar } from './components/vitals/VitalsBar.tsx'; +import { DashboardLayout } from './components/layout/DashboardLayout.tsx'; import { state } from './lib/data-store.ts'; import { currentArchiveWorkerForClient } from './lib/run-control.ts'; import { createArchiveClient, type ArchiveClient } from './lib/archive-client.ts'; @@ -38,8 +37,7 @@ const App: Component = () => { } fallback={} > - - + ); diff --git a/apps/cala/src/components/frame/SingleFrameViewer.tsx b/apps/cala/src/components/frame/SingleFrameViewer.tsx index f2e839d..44c591e 100644 --- a/apps/cala/src/components/frame/SingleFrameViewer.tsx +++ b/apps/cala/src/components/frame/SingleFrameViewer.tsx @@ -1,48 +1,8 @@ -import { createEffect, createMemo, createSignal, For, onCleanup, Show, type JSX } from 'solid-js'; -import { DashboardPanel } from '@calab/ui'; -import type { PipelineEvent } from '@calab/cala-runtime'; +import { createEffect, createSignal, onCleanup, Show, type JSX } from 'solid-js'; import { dashboard } from '../../lib/dashboard-store.ts'; import { latestFrame } from '../../lib/run-control.ts'; import { writeGrayscaleToImageData } from '../../lib/frame-preview.ts'; -// Trailing window of events shown in the side panel's feed. Design -// §8 event feed; §11 dashboard. The archive worker retains the full -// ring — this is just the visible tail. -const EVENT_TAIL_LENGTH = 20; -// Trailing metric keys shown in the 1-line summary. Kept small so -// whatever W4 produces stays legible; overflow is counted, not listed. -const METRIC_SUMMARY_MAX_KEYS = 3; - -function describeEvent(e: PipelineEvent): string { - switch (e.kind) { - case 'birth': - return `birth id=${e.id}`; - case 'merge': - return `merge ${e.ids.join('+')} → ${e.into}`; - case 'split': - return `split ${e.from} → [${e.into.join(',')}]`; - case 'deprecate': - return `deprecate id=${e.id} (${e.reason})`; - case 'reject': - return `reject @(${e.at[0]},${e.at[1]}): ${e.reason}`; - case 'metric': - return `metric ${e.name}=${e.value.toFixed(3)}`; - case 'footprint-snapshot': - return `footprint-snap id=${e.neuronId} (${e.footprint.pixelIndices.length}px)`; - } -} - -function metricSummary(metrics: Record): string { - const entries = Object.entries(metrics); - if (entries.length === 0) return 'no metrics yet'; - const shown = entries.slice(0, METRIC_SUMMARY_MAX_KEYS); - const parts = shown.map(([k, v]) => `${k}: ${v.toFixed(2)}`); - if (entries.length > METRIC_SUMMARY_MAX_KEYS) { - parts.push(`(+${entries.length - METRIC_SUMMARY_MAX_KEYS} more)`); - } - return parts.join(' | '); -} - export function SingleFrameViewer(): JSX.Element { let canvasRef: HTMLCanvasElement | undefined; const [imageData, setImageData] = createSignal(null); @@ -87,13 +47,6 @@ export function SingleFrameViewer(): JSX.Element { setCanvasDims(null); }); - const eventTail = createMemo(() => { - const events = dashboard.events; - const start = Math.max(0, events.length - EVENT_TAIL_LENGTH); - // Newest first — reverse after slicing so we don't mutate store state. - return events.slice(start).slice().reverse(); - }); - const frameLabel = (): string => { const idx = dashboard.currentFrameIndex; const ep = dashboard.currentEpoch; @@ -115,30 +68,7 @@ export function SingleFrameViewer(): JSX.Element {
Awaiting first preview frame…
- -
{frameLabel()}
-
- {metricSummary(dashboard.metrics)} -
-
-
Events (newest first)
- 0} - fallback={
No events yet.
} - > -
    - - {(e) => ( -
  • - {e.kind} - {describeEvent(e)} -
  • - )} -
    -
-
-
-
+
{frameLabel()}
); } diff --git a/apps/cala/src/components/layout/DashboardLayout.tsx b/apps/cala/src/components/layout/DashboardLayout.tsx new file mode 100644 index 0000000..92a34c9 --- /dev/null +++ b/apps/cala/src/components/layout/DashboardLayout.tsx @@ -0,0 +1,26 @@ +import { type JSX } from 'solid-js'; +import { SingleFrameViewer } from '../frame/SingleFrameViewer.tsx'; +import { VitalsBar } from '../vitals/VitalsBar.tsx'; +import { EventFeed } from '../events/EventFeed.tsx'; + +/** + * Running-state layout (design §12): vitals bar along the top, the + * preview canvas in the primary area, and the event feed as a + * right-hand side panel. Each cell is independently scrollable so a + * long event log never pushes the sparklines off-screen. + */ +export function DashboardLayout(): JSX.Element { + return ( +
+
+ +
+
+ +
+
+ +
+
+ ); +} diff --git a/apps/cala/src/styles/global.css b/apps/cala/src/styles/global.css index 1bbcf45..ef88ed9 100644 --- a/apps/cala/src/styles/global.css +++ b/apps/cala/src/styles/global.css @@ -243,3 +243,56 @@ .event-feed__item--reject .event-feed__kind { color: var(--error, #f87171); } + +/* ─── Dashboard layout (Phase 6 task 9) ────────────────────────────── */ + +.cala-dashboard { + display: grid; + grid-template-columns: minmax(0, 1fr) 360px; + grid-template-rows: auto minmax(0, 1fr); + grid-template-areas: + 'vitals vitals' + 'frame events'; + gap: var(--space-md); + padding: var(--space-md); + height: 100%; + min-height: 0; +} + +.cala-dashboard__vitals { + grid-area: vitals; +} + +.cala-dashboard__frame { + grid-area: frame; + min-width: 0; + min-height: 0; +} + +.cala-dashboard__events { + grid-area: events; + min-height: 0; + background: var(--bg-secondary); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + padding: var(--space-sm) var(--space-md); + display: flex; + flex-direction: column; +} + +/* Simplified single-frame viewer: no side panel in the new layout. */ + +.frame-viewer { + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding: 0; + height: 100%; + min-height: 0; +} + +.frame-viewer__label { + font-family: var(--font-mono); + font-size: 0.8rem; + color: var(--text-secondary); +} From 23cca88c622c108bd4ccf7f7a8b774a4d8e89ad0 Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 18 Apr 2026 22:39:36 -0700 Subject: [PATCH 10/18] feat(cala-core): WASM bindings for the Extend driver (task 10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts the Phase 3 cold-start E2E's inline `run_extend_cycle` helper into a public crate function at `extending::driver::run_cycle`, then wraps it behind a new `Extender` wasm-bindgen class. The browser W3 worker can now own a `ResidualRingBuf`, push residuals per fit frame, and call `runCycle(fitter, queue)` on whatever cadence it chooses — proposals land on the shared `MutationQueueHandle` ready for `drainApply`. Exports `Extender` through `@calab/cala-core`. The Phase 3 test switches to the shared driver to prove numerical parity: same code path serves both the native cold-start recovery proof and the browser worker. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/cala-core/pkg/calab_cala_core.d.ts | 42 ++++++ crates/cala-core/pkg/calab_cala_core.js | 101 +++++++++++++ crates/cala-core/pkg/calab_cala_core_bg.wasm | Bin 368216 -> 383764 bytes .../pkg/calab_cala_core_bg.wasm.d.ts | 5 + crates/cala-core/src/bindings/wasm.rs | 94 +++++++++++- crates/cala-core/src/extending/driver.rs | 140 ++++++++++++++++++ crates/cala-core/src/extending/mod.rs | 1 + .../tests/extending_cold_start_e2e.rs | 140 +----------------- packages/cala-core/src/index.ts | 1 + packages/cala-core/src/wasm-adapter.ts | 11 +- 10 files changed, 397 insertions(+), 138 deletions(-) create mode 100644 crates/cala-core/src/extending/driver.rs diff --git a/crates/cala-core/pkg/calab_cala_core.d.ts b/crates/cala-core/pkg/calab_cala_core.d.ts index 3253964..5b896bd 100644 --- a/crates/cala-core/pkg/calab_cala_core.d.ts +++ b/crates/cala-core/pkg/calab_cala_core.d.ts @@ -32,6 +32,43 @@ export class AviReader { width(): number; } +/** + * Wraps a `ResidualRingBuf` plus the parsed `ExtendConfig` / + * `RecordingMetadata` so the browser W3 worker can drive one + * `extending::driver::run_cycle` per extend tick without re-parsing + * JSON every call. The caller pushes residuals each fit frame and + * invokes `runCycle` on whatever cadence the worker chooses + * (design §7.2, §13 bounded-work-per-cycle). + */ +export class Extender { + free(): void; + [Symbol.dispose](): void; + /** + * Construct an Extender. `residual_window_len` is typically + * `ExtendConfig::extend_window_frames` but stays an explicit + * argument so the caller can size the buffer against whatever + * window they ship to fit without re-reading the config. + */ + constructor(height: number, width: number, residual_window_len: number, extend_cfg_json: string, metadata_json: string); + /** + * Push one residual frame (length = `height * width`). Drop-oldest + * when the window is full. + */ + pushResidual(residual: Float32Array): void; + /** + * Length of the residual window that would feed the next cycle. + * Cosmetic accessor the worker exposes as a vitals metric. + */ + residualLen(): number; + /** + * Run one extend cycle against `fitter`'s current state. + * Proposals land on `queue` (drop-oldest); returns the number + * actually pushed this call so the worker can report an + * extend-cycle metric. + */ + runCycle(fitter: Fitter, queue: MutationQueueHandle): number; +} + /** * Owning wrapper over `FitPipeline` — the per-frame OMF step. Starts * with an empty `Footprints` (`num_components() == 0`); the fit @@ -174,6 +211,7 @@ export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembl export interface InitOutput { readonly memory: WebAssembly.Memory; readonly __wbg_avireader_free: (a: number, b: number) => void; + readonly __wbg_extender_free: (a: number, b: number) => void; readonly __wbg_fitter_free: (a: number, b: number) => void; readonly __wbg_mutationqueuehandle_free: (a: number, b: number) => void; readonly __wbg_preprocessor_free: (a: number, b: number) => void; @@ -186,6 +224,9 @@ export interface InitOutput { readonly avireader_new: (a: number, b: number, c: number) => void; readonly avireader_readFrameGrayscaleF32: (a: number, b: number, c: number, d: number, e: number) => void; readonly avireader_width: (a: number) => number; + readonly extender_new: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) => void; + readonly extender_pushResidual: (a: number, b: number, c: number, d: number) => void; + readonly extender_runCycle: (a: number, b: number, c: number) => number; readonly fitter_drainApply: (a: number, b: number, c: number) => void; readonly fitter_epoch: (a: number) => bigint; readonly fitter_height: (a: number) => number; @@ -210,6 +251,7 @@ export interface InitOutput { readonly snapshothandle_numComponents: (a: number) => number; readonly snapshothandle_pixels: (a: number) => number; readonly init_panic_hook: () => void; + readonly extender_residualLen: (a: number) => number; readonly __wbindgen_export: (a: number, b: number, c: number) => void; readonly __wbindgen_export2: (a: number, b: number) => number; readonly __wbindgen_export3: (a: number, b: number, c: number, d: number) => number; diff --git a/crates/cala-core/pkg/calab_cala_core.js b/crates/cala-core/pkg/calab_cala_core.js index 39b5323..0b09018 100644 --- a/crates/cala-core/pkg/calab_cala_core.js +++ b/crates/cala-core/pkg/calab_cala_core.js @@ -117,6 +117,104 @@ export class AviReader { } if (Symbol.dispose) AviReader.prototype[Symbol.dispose] = AviReader.prototype.free; +/** + * Wraps a `ResidualRingBuf` plus the parsed `ExtendConfig` / + * `RecordingMetadata` so the browser W3 worker can drive one + * `extending::driver::run_cycle` per extend tick without re-parsing + * JSON every call. The caller pushes residuals each fit frame and + * invokes `runCycle` on whatever cadence the worker chooses + * (design §7.2, §13 bounded-work-per-cycle). + */ +export class Extender { + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + ExtenderFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_extender_free(ptr, 0); + } + /** + * Construct an Extender. `residual_window_len` is typically + * `ExtendConfig::extend_window_frames` but stays an explicit + * argument so the caller can size the buffer against whatever + * window they ship to fit without re-reading the config. + * @param {number} height + * @param {number} width + * @param {number} residual_window_len + * @param {string} extend_cfg_json + * @param {string} metadata_json + */ + constructor(height, width, residual_window_len, extend_cfg_json, metadata_json) { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); + const ptr0 = passStringToWasm0(extend_cfg_json, wasm.__wbindgen_export2, wasm.__wbindgen_export3); + const len0 = WASM_VECTOR_LEN; + const ptr1 = passStringToWasm0(metadata_json, wasm.__wbindgen_export2, wasm.__wbindgen_export3); + const len1 = WASM_VECTOR_LEN; + wasm.extender_new(retptr, height, width, residual_window_len, ptr0, len0, ptr1, len1); + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); + var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true); + if (r2) { + throw takeObject(r1); + } + this.__wbg_ptr = r0 >>> 0; + ExtenderFinalization.register(this, this.__wbg_ptr, this); + return this; + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + } + } + /** + * Push one residual frame (length = `height * width`). Drop-oldest + * when the window is full. + * @param {Float32Array} residual + */ + pushResidual(residual) { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); + const ptr0 = passArrayF32ToWasm0(residual, wasm.__wbindgen_export2); + const len0 = WASM_VECTOR_LEN; + wasm.extender_pushResidual(retptr, this.__wbg_ptr, ptr0, len0); + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); + if (r1) { + throw takeObject(r0); + } + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + } + } + /** + * Length of the residual window that would feed the next cycle. + * Cosmetic accessor the worker exposes as a vitals metric. + * @returns {number} + */ + residualLen() { + const ret = wasm.extender_residualLen(this.__wbg_ptr); + return ret >>> 0; + } + /** + * Run one extend cycle against `fitter`'s current state. + * Proposals land on `queue` (drop-oldest); returns the number + * actually pushed this call so the worker can report an + * extend-cycle metric. + * @param {Fitter} fitter + * @param {MutationQueueHandle} queue + * @returns {number} + */ + runCycle(fitter, queue) { + _assertClass(fitter, Fitter); + _assertClass(queue, MutationQueueHandle); + const ret = wasm.extender_runCycle(this.__wbg_ptr, fitter.__wbg_ptr, queue.__wbg_ptr); + return ret >>> 0; + } +} +if (Symbol.dispose) Extender.prototype[Symbol.dispose] = Extender.prototype.free; + /** * Owning wrapper over `FitPipeline` — the per-frame OMF step. Starts * with an empty `Footprints` (`num_components() == 0`); the fit @@ -593,6 +691,9 @@ function __wbg_get_imports() { const AviReaderFinalization = (typeof FinalizationRegistry === 'undefined') ? { register: () => {}, unregister: () => {} } : new FinalizationRegistry(ptr => wasm.__wbg_avireader_free(ptr >>> 0, 1)); +const ExtenderFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_extender_free(ptr >>> 0, 1)); const FitterFinalization = (typeof FinalizationRegistry === 'undefined') ? { register: () => {}, unregister: () => {} } : new FinalizationRegistry(ptr => wasm.__wbg_fitter_free(ptr >>> 0, 1)); diff --git a/crates/cala-core/pkg/calab_cala_core_bg.wasm b/crates/cala-core/pkg/calab_cala_core_bg.wasm index 13bedc5462edde6ae85df0928585e3642d1e5e24..2175f942ac3c65b49002cee01a3ede5171020458 100644 GIT binary patch delta 70753 zcmcG%3w)F{);~T;=GM+kJEivvkQoY;`&Fn4(gy@V#U)JWy$w_jO zoSdAToMiSk6*N3u7<5fvcqMbWT(u7 zE65G5OZfGOJjwot^|;5q+U@t?f9||Y?(yUc!Ch{T$E!ddkKct9kB1c?h~Ladbk7N} zOyLuvSa?fH{hYa7f^*>(9)FgHd;K04vQnIt#sA8?4)zNs1Y^waVm=mR!tHaTe4meb zgttAF?BO{c?soYCZv0OOe`e6d-M;u3(Ys#(cwwexW*NxFO~$V=+Czw~EZ>RGyK3Rgvu7-vb@^4(XS-fx zIZncYtLBXO&1JJ^xK^?ptJ#+?m_BQcF>l`N-?*M+K?^uz-rUP(y58U+9gj`3$a|BA zEsWXI7uHT*F#WO_uJtU_f)N>?vphR?)s-XWUO8{>oEdX!7rJ)wfCXPzJ7b<}Bg?g- zwbN^6oHJ+oyoEF8*1FzsDA0}kJukcRs@m!CtG&f+XMDN0jJ*|q%-cb{#7p9jdkegy zu9`jDwLSiox1{WCUP!35v*ym3f7OhuX3U&E=knPzrV&lg#ig%2|05Po8%|33Fn)or zBYQ0V8()XQ2U)wc^aV2(&Zu?09N*+CWV_?9`Z~t;@ce}qrB>M ztlDYwrq7vm*|eE+=hnCyL|(Gjt)Vz;#vBZr;<{OLF27>N9B|~kxeIDtkBWB0Z)w2( zu2pTIKEfT~GUMm?qp>$c#(Fn%87#1tZxWA*V|)Xv6OW4z*w6fT;t}=``&4`^J`vl* z*XrN>;t%{))+Ba|hs8>>D!vgn@-6HcwvFBD-pYRwKZ%2^ftjrKu=rN|ApXez zEWQ+fVfE}KwuyZu{vuk%R(=p@->_J}lLl|&&xt>=^TwZblz%Rky1!V-f9A{io&2bXySIw|ef!+#Zg79Xzv3(T z3ckwyG5-s1;BkJF`!4sF{2;%JuR^O$_ecC|zM7l-W{Ad5`8WI?emB3{{Tcr&U&HU^ zi`}t%+)s(T+;+cp-|PN@AL94(`}h*~8uvc_EnmwY;J3K%bMNQh@rU?>d@Z{BP4SV~ zFLsIFi?_vF;(qZxe~mpa-o?PZ&7NnQ#0IfR+$;XUoB7|_n=B!26#Lj3@q+L?&mTw9 z4z9hmzNeIe+6j#7p9j z;`fldeKHR`legOaq{-a*vA_-P>-^Vuav8f>P1IBFl01CjmK~W)us!itLnBVU!D?9^zBE2O>&$K%FN5MKNbZ19 zWVpk7MARKBFx*B_cyIivtZz}{`s|Me>O4Z%=$66M8iiriSWi_6Ls#{5eSCh-ARhp# z;=hH*1uj)&Q~Z+n6QlC8tT+`)pm{k16pCAAxg@?Rr$@n#@0(r5U}O31FS^1@g)1Q4 z=!Ga6lfB3UqRX)=*NWl`CG-sR!S7G=4^>b>Q#6bgFBA$$E3$t z%c{eV$FsT&ElI@)%Frlxm6QBQ`LKVL+}!2z_?pg>J2)lO|FcR;(@>7DZcX@d5 zb`j(OOdoEzrQZ;>Q67F;M1vuQzD!P&zSNhP=Of)!=*7O6=xe&pXKdn@i|d$v4W z@i1eX;$yqNmQAxt<{AtI84S!8>G5#UcT9#WxZ7aV1^DvQpCBgX$35n;b@2&3N1;#e z>Umc>u|kGZyKGGFaq)&;{ek~lubF9`cO2pqd1w_Q^1W!4uv!(*>N6@OfyqooW%%j% z%sy2o>weHVG7OK^4?gq*7>AJvA%2HwN$rScOuG1|eQw|lzr?5aU5{2|^gEl4j$hR8 z8+?!Le*^x$(SLNm(J~V)&q9r&76oh<0YgnilAI6|)*J-A?Tp6dkWnr15d+TPyMKNP#aL)Gf60;g?)Ssc{%V_>uUEK?7_^ ztA^|u)Pr3c|Hq)YY)kyg!SnI=lff6^?~uxl-oy7J(`)hRmEFOFww=Wgz_@=mtQRb>rZ|fxKmGAnRCh04SM0hGNdMPoSoVs3sicH-L7gGk;R~= z*QwQ5Z#1I9x+MPasbf!+cSu+7jyo|Huf%%~>DhPX@@AKw=*Cup($E=)zX9vCH+I(p z@CX3moiuJjx-kou4VeVG_6@nTkQigX3lh?UQu}8M>x-B0K z&ES}Me;Ss>ET$HU+quDOqXJBwVU(fNF2*Fj^B?iw7}LFrhxH$IC|bZ=+`v#OICj#Cx3)w)Ks}j=sTZe5h-R*2SF` zw>rrl>5i`$J|yS;RZe1yN=$bDtxZnS4obQsK5?`)GLJs*ByH76509SKaV>o+#l};? zSF?za2pd?08+sM)G-eWC{EsbHk1?6IWd-5JbH^UcvboG}>?JM>o%J|xJ{Euetlk4Q zJmXZ;O8BWf-0)Wn2l24Y)EW@)IBs~zF41jNqQE)hR`Fv;w|K_S=lzkwLZo)xEo4Cj zFN+jXP!!1zdII756s%msBw%j9wrrRxuk0^2bsP!ZZ8KFEd9$bY<{@cmi8~M_s9j2JRoiLR!5mGD(XQ^$1+_g(cmbt z_0=wH!Ir>lmj@8cd#8fM>R*6VBB^cU;NQk~5NqL>)Ir3W>=-Db_%4boT+-bu3@2|e zBY^)Kt#3hq4mRouO^wJJ0t_;a?Xdxeeh)yIanJ$8nhDb0*yli&?@}nc8~{nLl)8-X zo(DpSu3XjPlD;$qYHVtT3Ohy(TiuoQkBKUdf3!SpxvS;X67R9q#MWCb{c9*#T=zNF zCKvVq27S?37zZhNcYNv5zZ(lmsPDbTX66i2NdQG<8?Mn$;fYoEW9DvoQ`y7DP>jxdcAE<*$tqywX4{Q}< z3(PAyD@U-Bv(wpv#2+{tu3jz}E}*O#I}zz7T+wdB*>#bw#%>*rbiuHM8@WnO!-;ks zEU;&_(Tu%cnZNJL%Fb(x+ZE+SD^hTy-GOUY44uX<9hFBa>;eOko`x%uCBsH#wXw7Q zMRQm`R>60EWzOoyDn&B@jR3H3&ENN9T@n0E2krY)EHSb_D`IS+IduR#mn}?e9l-ih z^oxP)IyC;$lbDzJmO*zel!fN>t|B)SR=Pida%HX!T8-@hfeK`S0`&+R%PmmYsHhI_ zV$uyh&XM_AF_av!c9OO{+Y?_8VuP6X;4TQRgmuzZ1EY98=`KZtfq#*7`=k&g%+4 zV17T8Ma*?W+4xk*An!gZ=ryy?X{@m1pk1i3i@shb@u|{ZH!nF2M)2+C$EUFoCA%D& zb=^d`t~zn*FxEX=tYd-1kYTJ_Hfv0*yqL*s_PV)cHXF{@d}aPfVcnn1Q?F$A@zr0M zf4q{N5?p@ZC0BKnWYX#brkumdJFf-XRdCn1kY6&|4z1DgsB2^>L%L={{W4IO9Y84jcBSYNSpi7D{US$B`hIy=@|7yTUOqBFZhjiYS&O${m4ODgaav<+g|ddlONAozKPv z6){8th%rYmU_+^gZdt%C=ZJs3fIUs|hZnMIeKvhvYuVVz&oXRLQ^?^U2b(&O<4%&p ztv(XQ5ti&EPV^{{Ll6*EFV(V(bDWls#iYZ^{gpZJDt5=2o2je(*ry^BePg)614Q>$ zO3?v;QAkP2$Ce_6mN)umY}EKsOk<<&fEm1+Rm7Ssmi|NZvusZr8Ok)kcRKHTBks_<{hawa@DH|!W)=F7>AeMr+m6A4CNfbJG zO%&N|rBLXg?V!juD}_P_Z5KtFtrQ9!v=)l&)G0Cb;o$9~q}@76g*KkCH#Mb|A_Sr4 z?N5yyqzFMM!Q^>eQr-6j^JfP-svxW!g%S4OWCg2d#-Bo2?WI9kd-3*=D6s z=%DTT^Gh*|k(ETDgV#clomL8k4%$A7?6y)UbkJHUve!zX&_O#$k^NQ*g$`QmFvSj7 zNfbJG$0%~hN}B7~vuYK35xnSnHlPdD>c~r%H8#u{n?_2;m^^ho z>os^AMWEhjf$54+ie8+U^o$FU?4|*ho|>qVlvdg1#W%1X#h!pphCY0Ja_{n#B~O>Y zjqGud&;&OWmtlE&{07!FNb7}EjDt@4>;^U{N3nsP>x2z*yTw>(4rP^AR)CTJ+!!0^ zJJg;R#)8+IZ~R?Uv0^iHRFwI>M&Vctq}S|sRCF&W4!;ZgfpG{{D1jA*{umbqTSH|>}KENK|d{Tzqn3u%{ExXyXs1J1-s2WcvN(W&7!%69U;C2~vQq?|g+r zxnP*5G6)r4&^)L(CA3g?xEdrVHt=bZQ4_I&FG(_ce#Gy0?HUT}KAT?}=^**@v`ZQ$ zGgQ2z^iaHcn8cX`(xvpO^eQ@WK|CTu6qg3}j}*TsDzmHt@)e+y%zl2vJ$K)I8tiF= zP%gt1zhIawRPoNBQc;Yw44Gp{Nur}P0<#48g$|0+g-z~U^dX55(r8@~j{n%IweBl; z9W(vRZNxtLxY=dohCjdtryUgU@9b!(Oom5>a&5p*#NpVVx{cp{f((T!1MM8w*wGM^ zIU_@PHb7)!TJS9j7w z*{(FwNk;WB<&;LEGD2_+A&sBJH-V=aoh}$_|<>@`)$fYj{B^BvIK2l59AU zA%e6oyj38m;Q%z?8#{wywV#0ZpaD0Rl%OlbV$$}}QB0_qCkg^FWR~ArJ z7C>PrVwwq~BVn|Y`GjEs6h;A+@gWUe7GdPb;K)$O1}KbtY>#889GnSDxb%b%uxJK2 zfzCb_~Pxo1R)(hB0Z zk)bS^jWbHSdev^{513o zh28<@=`v6ESPXsc28C&v(1TbCZIX1Rj&ulb2@BO@NeYAMxz)q7nup7V+zy4;(^XP-{+3T)~IioEhSD2Lkuct1)mvJn$6`=HU?7|m21zflZO()$kG<1JW?UnZ_w&E~U=3Oe_}QEi3!!`T$hTy-B?RmOzvSVdyZ z!*D@IwDA$vyARq#ow@ing}(-Yrf{PM>~H`r^!2jI*0W35X7lp(>`VlYuVvAL-uf*Xa9~3FKDHTdKnG2yNK1LkN%FG z)&;pqAX_d;U=5PB5Hhux4u;zqjjTu6MR0_nLEB9;P|_S!j(Z8XpZ~~M*TkbRmcI9WRK#!`we__xJVW!wH{6HLVIbYgbZilg^B zU^;9D%~(f>A_tsl)pZeC>j0^9mi}NDPqEcb%!k+pC#DE-VwASiiBZ~K6*KpJ$cj21 zq28g>GK?w=1+~>UN6Qa)P~4pnzTM2-$0il-bdYG-Lo0PWaqT|#w_M+`H&hYkuMe?F zzJofFc>56J-q>o4l~HDFr6Hr!e5|Q?M&lptMiimSjn0XoTjq_W1sv-X;7tTqgwaN6 zmxBefgGR|S_BklnXJGljp<@Lv*E?){RYc5*Ke99XEPov(y-Kws;~*@q0T~(uyyYO( zD238e4sQV97#oT9CiC?l*#uvm)sBuw*gTQfOhqVS7Q^ z%%qgrNV)$P))zA4?O)g!ej{5|%zvK?>mWntNStFsekeuYsh{{PmW!f2AebC~psA?@ z^D54JoSKp&OgZw1EDxy~%%DC;P*ja$U=(1NTts+fAmBsJ%VViTLj$Yer|X76|K$dh zm0ASkHRq!uoGzG1n3FZ8HwAP5ahR77=H(g_3NAOGLs6MfFnzFwwo8^-Nti>zza*9m zejTeo%be=^Qqn+O2$tE+uVM%*jJ*gT=?8jvG-QDeXwYRIJ^_7dZt?J2^HK|vR8TqE zKGDm|ho+_!Q3`s61{HccH*z$c(9np^BrWhZ)cya2mv=uoH6=_bY5>%d?hmLE+GKYT z5*dB-e7tiSZ55Q#6YU@8yb<9+!XFPdBO_%kuf4`wr*f%MX5`N8aW0G1z2{aZGM{YW;*&1?C&unGKd9gxvTRSW zfO0W2J>B@|4p1=LLD9`3H#AuC%CJ91P!tJ3QwragsOiSX31}bB^yFI^yULu?8>79) zT;7{^VS5va-uz9@4}5Me?#IvNO|6L!`*FX&_z2vVs(T2GoAgyjv+(uMT5v;Mt9k5X zK8hdtJaPIdFo^MOt%+ApAac8AcU|1AoX0jX>SA%v=i=*z@QM?*d4iJ*7gW>Rgy&ya~NAJ zhybuE?a^pg0g@CG(w;=sLv^Wt5HlaTu%$$J)P-{c1&?GQ`qT;Xk~_q$GXM1htl z3b5fKbhHhY0F{q647z48u`4NMR!Nhncxl=i7yYC zCyjCUi#0B5rUbO6jwH5(0v3pH13O*X;-R)77z#bO<<(*AfN4{Rrn=z>0`*Q}+e~Vm zHn-Xx_iy@bt}{d;hQ147ra&Wv;a2E@(`~dmGz{Mc(Cml&a6=}68rr|9#^ahqH5`-c z)b>*KV+F5LM-d^lDIXp2*foK1$TAJWJw~V|%PZ){C&QkW5G)v@Yo{(H*&if>BbgVe zm83Hm4Fn011&_2^034JM#?g!MaFgAR!`nvFW)FA>77#TQ=uZI>b{YY8BUbf+8ru6St|q3LtJoD* z0@$Q9Gb@5=?*_WlF56pG4c%+GH8K7y-kqFO6W5I63wdnuyIAIUcm(l}q}c@*0RjmP zYIbSZYJzz*tWM2BD9i-Qv|wE0wGb?qU|?wYKxYStkz5$v6MOBNvnTT3VA=a}BA?>h zu~+HY=EzCB9Li|*B)$Z}x0Cqn?hn3#*}ySi#$rMuCvj|5^bHj0!`9JjY_)RVayB20 z5_g}?r~4W$SmimY#EZ{SCAOZ!$K|f0A}D8u(V($h=Ifhz2RQg$Hkl7baPMSpAoyr9 z?}2qX<6M53o2^S+c>y2ohELzq7xRAcYGd7T9U&m@As;++PE z_mCzR44Z3z#rvC!uHYA^VSrtfrp2MYD~t{^d0pSsGO&UvU6jL+gpxB)agnu-4h9%@ z<{LP62Je|~&Ey3a>Tga)bYb{TgvZHZj@6!@%8oCD!?=V**NM{1VY7J2sY@WJVTKzn zCP64dc%l9$RQ<|50IL9rc^Yk7#Kp^4G_$+>s*QRzBk9Jc!05t>6b7mejhLez0 zObu>i;U#8qHSZ9!D^hC}6bYgqdZ^k?IQ@;qU8}wN#Mwf_%u7eZ1Xijy$Lfk=EHv1u z)6x188manVV)N;0{$Xqek%kEh&Vk+w@7bL2Z)kZ*2i-yjvNo_)WFczm7DoS=jL(wqvTU78Nt6-^#m|VA`Xwsv0GHKOu8RuCasC zmzodV%1`sQP%pe{et9cDnawceGG17$*ad!`G78fS6lL00`C$RQ#hkc|7Y)#Ca)NZt zDN`8Y7ZfqH&nZLvAE|z93$Fj zk0F>U%h+ni)QC3d5q-UZ_bq|jHVR80(Zy;+6S=GS-2xK-vDJK?n@uq%-N$3z{m)`| zxH0kmeY`jG9#RoA^8tQeCbrDDhMA2Gv-!jWytr`Zi^Hp7%QlA1e=M4{bUmqreHix0 zpFY4lPuTko_I0!sA_p_b6T`kdf(tXcy-jp=rrg+X1F&4DZFqM#BRyiQRh#O>xoa`p z-a~}^vbo_wOt`zvS0Cipgzr|8i593j!fY`oKExvgMYHKpX1RhUZhVOU8rx*LKcr@y zbLplf5p2ZqO-homSN0aO*TcN4&$>8dUidKotlKg=iNJCIRd*l)W&MHJhn8v#gyK)@ zBmR*6=U0#LnWNK^u#D(^A!$!oQ3i2IlGaTlttJlC3U5vLan+(!Xn2`fu%183m)~Q) zv7UFz$6A`KFs$|A=NYRV`1n0|Bzkxn#%)+-ILUqXk)H5jk--XPsK zug41xKvq~VVuUX-?|G7swyr6tgSv{@k(o~ zFy(wkgB`QG{V9ZPcFfMzLV%@-+*j~E2rh3t_!_Tf zRf*tEzLc@s%~h}SGZE~69nsqpv)) z%cA7TiOD_+TTbUH#;6c1GMIpL$s&BOS+N`aX%%OcaQ1F~QuJBcWi#!@->i$FBQm{5 z2)W_)YyNIG@6eUf;OCEt!Vrw3djsG?e@paq#%I zzo5(;mtS7LQao$gAE1%zCQGv479L&?LLNWy`;pnVIgj$qRP#VN{Y9kK5^ zfU&77R3Ff4I|0@T_+8I=hd-DPfkWF0$RdoFV@xf7m-o)BckD{$Mep*vPKFe*5gpsA zHU?lnHV|pHY1_uKjksoy7QTgy=5~Ul%E<2fZ41v$O|XrwX5oAM2DZYi*~>?f8Q#ul z*K7XuJ$^=Nf-(lv$~QUiOjN zj(+qJZAU+Z==`lPhkUFykJCR^o5uqmC%2<;vu`9M(u|;@JKIrMM2xLg?i)UVT;Fcq z{|O(0;KNV&Qv zNwDyc?;);3{=T2`3;F)7=Ix*I)6V>|Y4|BF(tL58d$o~0 zzn4ovauvj%0%@lp!4yc2f`n~|ng1Cd&A)6)Ohd>r_V0U%d^A7XXKwzSkK-FZGW&hO zyZ6QBiF`iS;LhKt&@I%N8mKhLU3TL~FS*VrrLBS=CM6#TN3IK6c?VNP0ua)QuUm<`%8W|7MSWk@$cO1Li6H-ybQrL2YD31eFtHhxX^4m$af>S{cFB9 z>q5zAOGrRJ%p&0n6Q_Q||H%0gVHSSNuPyk8+HU%2YfJld_}W&7k+$Jm{yHf}3%}#1 z5bopO@z;1_ySeCl-iyNZ-}4uIN4Bf6NnClDcjVrtr)X_8SN_1yV%-v-{J>`lHq;#b zcfJU_j2Hh7DKOORd6cj0Z>dQQ_U;j))sMk$q_`UC3}u`HkAewsYZ4w9hmP`zDAYK{ zkGVsZj?|)cq%)a#KJ>$mW*XV0f1pWb=5sNzfAdQUv|54GyiHu?#s(xB ztC(?E=QFvO&Ni7paj~+)uEi?*k@r#J9kl1&-vZnM;|K!iNxu`~>V5LODIn8|W6J87v^`=jB2`%1-syi7Q z5TI9n_KD}%)WoxXF^pwRRgOK%CLzL8O<$(?rtn>E&{Gg<5rmk*nySwgSA;|vTVig4 zn&4ggD$3em&I^bN{rx-h{*>=YS)!u7!m;j~U>Bszgc+1f0j9`c_1#?|)5{Rg5Ca%BqOc-$|mnBxMA%odw+v2H`jZORvgom@iy}v}eDQlF0Z@ zl9(I$j{4)JV?E@x^eIP~s3;So`Jp|D1!ZD2^X}h6OlO8Wi1M;s_*PbY<-!ML9tLG) zLNs>mNlfe@x(fZ&f#OlehOA;Z8^+fXVKFf&_$eY z%dGw6J+;&{yNL5}l-t}@l$se`MR)sqLdy3l=erJ*&8QG(0PEHY(G@o4Co05e3>?tV zox;Y%uX~CY*lCL$j~S9B$yiBREITdMUKUuMHfC*aarSsiq%DYsEwN;YG+V-1BF&a? zmPoTDoF&q1372I~af@=ZvX3}$B1xA0Ur4g4eZ{#jU2W|vR`XkgIliBm!hST@_Y+%xw^AQXlB<2x`zZ)bjXHS_~gT*NnJ#(-q>A1JaS}m312W@85WOS)L zW%w!c=D`s2PnjDAi_Qq%(y{M#P*kbXhU(x_9o$k09(u$)<7BZqy!a7F4|1?^{&woE&p{WY)mFgc$jBQLf|13@Gg_LE$C`icnlu0`)aXKE*>mQoQ$?>5 zrvTWk^Cn>Hfk)o3v7+?bPZj+OQ%fQgt7!B6Q^h&_*5}L~Lqz+o%bwHv97xf&c^i!! ztCIAXbBBm-1#3(qXhH zIqvb3D#A5VaHGTi`Qs;sU0@(G5>|kWuIdxsY$2A(MrJZtBd!$-JWb;D#Bc+e*qd=z}|3(Vk5Q278 zU_YQoXYNdvr#LP$(6>vi7v}VHM3+HIpUMEi8P!N}ULb*r6f&9=9x-na2oO0Ag1P-1 zv8-t0+fcNMAiHnU(AD@ zaOwGCw|AeaF)?+Dc#@wfqR#K=iyqZ6aFrj%IF*yY5q z0F#l|FSB9fHI4ypbb$1bc8~0$Hd7z^@cPUk*+0C9d_601eIN&CVk4)CzM$#qY2q*& zy3Z*zh?ALvPE3_eRgfLocK%FOu~Ai#SU+9liLq_{R*kj*Y~Z-8F!t*QUqF`^&SuRN zQ-U(r_>>vDRn+v(zzBVnm_0-E=dcq#aD{k^Ra!fp)#Tl)PE{eM;|C0zHz7d?JQ9|=)-N0bA@z1zM1M(1_`_tgnfzjDe0?xN^X4F z##*DW%&KZ}x%Uu#ziK{OEl%rl_&9c~SL~>yFUGTl*zZ*{t40hk9M&CZjnfXrn}nK4 z-HfLwB^(z|H;ylKqvr9OP)zZIaV4Vnl8A<{!0iVx`p6&t*}P=7$j5;$jEu4$z&k?_}7dB1{TfoSqg$+Twpr?&v@7O`Zfu&lVxE}5j+md$!{+j+`TTs*-yAt#EYjaY*fr+S`J!U# z5w1?fvBd9EdwLovI8h+JkK$^NuH(m?xV}n^^;IVvv=iQUGcWlkTaIb2@HF$r1!5fO zF}oLti=cV+S}5u$`uIXIV9bx&mWvhK4eu}5D<$^Cyq60rB58L}^$>CC2vj|ap~o!6 zk7A_e5u(|nR&*)-k+xKQSdcD!y2$1mjwtsZB4c(f94~JX06_MU`_E2NsanjnwIa`F zsaJ{TYEfXMF+nNR4KO-EdPCR3hIvdWGHq2!X8WtfIe9G(8V=%(9V(Vse6_fMd0W-P zr{?Fs7Pl0(sBe^JAEWM22S$k-7l~IG&JEAFM&Ko{&17h3F>kt7OboAD0`~QioLfuK zJIv3n6;&2ojr<08L(y<%v zZIhdmo3>_Dlk`HTq(8XVWv;zJ6q*Zf5G7>gwNt~C3eRCmjiV%VwU^9DR1$35Zt}gh zaf=9-j_)priM!av=AoEaLguAKb)qwwmma8t*hBbkov3Gf%}Z_+{ecy~5i;>{^QRld zHH3WCO%8I@eEud8rX0I(g5_YZ`SnfW{h_pZfia*un(b^}@Cre-vzPHB74{f=FOz{N z>@n6}=B~x!FDKfiWc?Srly1Kjr~a$Mog02DdWw7DBXa0%UU1gGKi*+rGicXvVl^1f zJ?QL>?tqXJm_Bxa3B@|~$zCE(%1l1TVvb)T+MJo-mWcEG3e)CM1w!Ha`4w^=#F@$J zC8FX4kBID9B1-zFoirVH{)3YvLXhfI$UezIzG8DujAbN--6GZq;vgU03ZQ2S)RP5t zz|nwH1aX_VqHBsdG7os*$PD5mJBNM|0XbIOCf*~Sm{~8n(CI8q4-}fpwCn0c_w*zn zX>-$O_2Mzxl7X8GFhV&th{oH+!1N@?{_*wg;(wx3r8HOt^fBb>jfS)HgGJFq2*@G8-sQ9cHbMs?6de`p?8-6hQbL2ua!_N@x#-o$9~mLm<7sf!af z7W|GZUn$sez=0^$Sffw9$O#J)7QWwbCf;fGGsV?#D>vugEyfZ7v}sQD+A?umRF-+r zgf!I@_gpQe_#fDhl{_Q-fmy#=^o!Xs4sToh^u#GHoMe"M(PXajJ=Pt$4Ddt_Ns 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 CO56(o delta 55415 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 BBai?9 diff --git a/crates/cala-core/pkg/calab_cala_core_bg.wasm.d.ts b/crates/cala-core/pkg/calab_cala_core_bg.wasm.d.ts index a3c3253..26a5768 100644 --- a/crates/cala-core/pkg/calab_cala_core_bg.wasm.d.ts +++ b/crates/cala-core/pkg/calab_cala_core_bg.wasm.d.ts @@ -2,6 +2,7 @@ /* eslint-disable */ export const memory: WebAssembly.Memory; export const __wbg_avireader_free: (a: number, b: number) => void; +export const __wbg_extender_free: (a: number, b: number) => void; export const __wbg_fitter_free: (a: number, b: number) => void; export const __wbg_mutationqueuehandle_free: (a: number, b: number) => void; export const __wbg_preprocessor_free: (a: number, b: number) => void; @@ -14,6 +15,9 @@ export const avireader_height: (a: number) => number; export const avireader_new: (a: number, b: number, c: number) => void; export const avireader_readFrameGrayscaleF32: (a: number, b: number, c: number, d: number, e: number) => void; export const avireader_width: (a: number) => number; +export const extender_new: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) => void; +export const extender_pushResidual: (a: number, b: number, c: number, d: number) => void; +export const extender_runCycle: (a: number, b: number, c: number) => number; export const fitter_drainApply: (a: number, b: number, c: number) => void; export const fitter_epoch: (a: number) => bigint; export const fitter_height: (a: number) => number; @@ -38,6 +42,7 @@ export const snapshothandle_epoch: (a: number) => bigint; export const snapshothandle_numComponents: (a: number) => number; export const snapshothandle_pixels: (a: number) => number; export const init_panic_hook: () => void; +export const extender_residualLen: (a: number) => number; export const __wbindgen_export: (a: number, b: number, c: number) => void; export const __wbindgen_export2: (a: number, b: number) => number; export const __wbindgen_export3: (a: number, b: number, c: number, d: number) => number; diff --git a/crates/cala-core/src/bindings/wasm.rs b/crates/cala-core/src/bindings/wasm.rs index 21babb5..adf4ac7 100644 --- a/crates/cala-core/src/bindings/wasm.rs +++ b/crates/cala-core/src/bindings/wasm.rs @@ -28,7 +28,9 @@ use super::config_json::{ ConfigParseError, }; use crate::assets::{Footprints, Frame, FrameMut}; -use crate::config::GrayscaleMethod; +use crate::buffers::bipbuf::ResidualRingBuf; +use crate::config::{ExtendConfig, GrayscaleMethod, RecordingMetadata}; +use crate::extending::driver as extend_driver; use crate::extending::mutation::{ DeprecateReason, Epoch, MutationQueue, PipelineMutation, Snapshot, }; @@ -440,3 +442,93 @@ impl MutationQueueHandle { Ok(()) } } + +// ── Extend driver ────────────────────────────────────────────────── + +/// Wraps a `ResidualRingBuf` plus the parsed `ExtendConfig` / +/// `RecordingMetadata` so the browser W3 worker can drive one +/// `extending::driver::run_cycle` per extend tick without re-parsing +/// JSON every call. The caller pushes residuals each fit frame and +/// invokes `runCycle` on whatever cadence the worker chooses +/// (design §7.2, §13 bounded-work-per-cycle). +#[wasm_bindgen] +pub struct Extender { + residual_buf: ResidualRingBuf, + extend_cfg: ExtendConfig, + recording: RecordingMetadata, + height: u32, + width: u32, +} + +#[wasm_bindgen] +impl Extender { + /// Construct an Extender. `residual_window_len` is typically + /// `ExtendConfig::extend_window_frames` but stays an explicit + /// argument so the caller can size the buffer against whatever + /// window they ship to fit without re-reading the config. + #[wasm_bindgen(constructor)] + pub fn new( + height: u32, + width: u32, + residual_window_len: u32, + extend_cfg_json: &str, + metadata_json: &str, + ) -> Result { + let extend_cfg = parse_extend_config(extend_cfg_json).map_err(config_err)?; + let recording = parse_recording_metadata(metadata_json).map_err(config_err)?; + let residual_buf = ResidualRingBuf::new( + (height as usize) * (width as usize), + residual_window_len as usize, + ); + Ok(Extender { + residual_buf, + extend_cfg, + recording, + height, + width, + }) + } + + /// Push one residual frame (length = `height * width`). Drop-oldest + /// when the window is full. + #[wasm_bindgen(js_name = pushResidual)] + pub fn push_residual(&mut self, residual: &[f32]) -> Result<(), JsValue> { + let pixels = (self.height as usize) * (self.width as usize); + if residual.len() != pixels { + return Err(js_err( + "extend", + format!( + "residual length {} does not match height·width = {}", + residual.len(), + pixels + ), + )); + } + self.residual_buf.push(residual); + Ok(()) + } + + /// Length of the residual window that would feed the next cycle. + /// Cosmetic accessor the worker exposes as a vitals metric. + #[wasm_bindgen(js_name = residualLen)] + pub fn residual_len(&self) -> u32 { + self.residual_buf.len() as u32 + } + + /// Run one extend cycle against `fitter`'s current state. + /// Proposals land on `queue` (drop-oldest); returns the number + /// actually pushed this call so the worker can report an + /// extend-cycle metric. + #[wasm_bindgen(js_name = runCycle)] + pub fn run_cycle(&mut self, fitter: &Fitter, queue: &mut MutationQueueHandle) -> u32 { + extend_driver::run_cycle( + &self.residual_buf, + &fitter.pipeline, + self.height as usize, + self.width as usize, + &self.recording, + &self.extend_cfg, + &mut queue.inner, + ) + } +} diff --git a/crates/cala-core/src/extending/driver.rs b/crates/cala-core/src/extending/driver.rs new file mode 100644 index 0000000..9745f8b --- /dev/null +++ b/crates/cala-core/src/extending/driver.rs @@ -0,0 +1,140 @@ +//! One-shot extend cycle driver (design §3 extend loop). +//! +//! This is the coordinator that stitches the extend submodules +//! together: variance map → argmax patch → rank-1 NMF → class gates → +//! redundancy check → mutation push. It mirrors exactly what the +//! Phase 3 cold-start E2E test drove inline. Extracting it here +//! lets both the test and the WASM bindings (`Extender`) share one +//! code path — one place to tune, one place to reason about +//! numerical parity across targets. +//! +//! The driver is stateless: callers pass in the residual window, +//! the current fit snapshot, and the mutation queue. Per-cycle +//! bookkeeping (`proposals_per_cycle_max`, patch variance cutoff, +//! etc.) comes from `ExtendConfig`. + +use crate::buffers::bipbuf::ResidualRingBuf; +use crate::config::{ExtendConfig, RecordingMetadata}; +use crate::extending::mutation::{MutationQueue, PipelineMutation}; +use crate::extending::overlap::{overlap_fraction, patch_to_frame_support}; +use crate::extending::redundancy::pearson_correlation; +use crate::extending::segment::{ + argmax_yx, classify_candidate, extract_patch_stack, patch_bounds, rank1_nmf, variance_map, + ClassDecision, +}; +use crate::fitting::FitPipeline; + +/// Run a single extend cycle: up to `proposals_per_cycle_max` +/// candidate footprints are proposed and pushed onto `queue`. +/// Returns the number of mutations added to the queue. +pub fn run_cycle( + buf: &ResidualRingBuf, + pipeline: &FitPipeline, + height: usize, + width: usize, + recording: &RecordingMetadata, + extend_cfg: &ExtendConfig, + queue: &mut MutationQueue, +) -> u32 { + if buf.is_empty() { + return 0; + } + let mut vmap = variance_map(buf); + let radius_px = (extend_cfg.patch_radius_diameters * recording.neuron_diameter_um + / recording.pixel_size_um) as usize; + let radius_px = radius_px.max(2); + + let mut proposals = 0u32; + let snap_epoch = pipeline.epoch(); + + while proposals < extend_cfg.proposals_per_cycle_max { + let Some((cy, cx, max_var)) = argmax_yx(&vmap, height, width) else { + break; + }; + if max_var < extend_cfg.patch_min_variance { + break; + } + let (y_range, x_range) = patch_bounds(cy, cx, radius_px, height, width); + let patch_h = y_range.end - y_range.start; + let patch_w = x_range.end - x_range.start; + let stack = extract_patch_stack(buf, height, width, y_range.clone(), x_range.clone()); + let nmf = rank1_nmf( + &stack, + buf.len(), + patch_h * patch_w, + extend_cfg.nmf_max_iter, + extend_cfg.nmf_tol, + ); + let decision = classify_candidate(&nmf, recording, extend_cfg, patch_h, patch_w); + + // Zero out this patch in vmap so the next iteration finds a + // new region — same effect as thesis Alg 9 line 12. + for y in y_range.clone() { + for x in x_range.clone() { + vmap[y * width + x] = 0.0; + } + } + + let class = match decision { + ClassDecision::Accept { class, .. } => class, + ClassDecision::Reject(_) => continue, + }; + + let support = patch_to_frame_support( + &nmf.a, + patch_h, + patch_w, + y_range.clone(), + x_range.clone(), + width, + extend_cfg.footprint_support_threshold_rel, + ); + if support.is_empty() { + continue; + } + let a_max = nmf.a.iter().cloned().fold(0.0f32, f32::max); + let cutoff = extend_cfg.footprint_support_threshold_rel * a_max; + let mut values = Vec::with_capacity(support.len()); + for py in 0..patch_h { + for px in 0..patch_w { + let v = nmf.a[py * patch_w + px]; + if v > cutoff { + values.push(v); + } + } + } + + let fp = pipeline.footprints(); + let mut is_redundant = false; + for i in 0..fp.len() { + let existing_support = fp.support(i); + if overlap_fraction(&support, existing_support) < extend_cfg.overlap_fraction_min { + continue; + } + let existing_col = pipeline.traces().column(i); + let window = nmf.c.len(); + if existing_col.len() < window { + continue; + } + let start = existing_col.len() - window; + let r = pearson_correlation(&existing_col[start..], &nmf.c); + if r >= extend_cfg.trace_corr_min { + is_redundant = true; + break; + } + } + if is_redundant { + continue; + } + + queue.push(PipelineMutation::Register { + snapshot_epoch: snap_epoch, + class, + support, + values, + trace: nmf.c.clone(), + }); + proposals += 1; + } + proposals +} diff --git a/crates/cala-core/src/extending/mod.rs b/crates/cala-core/src/extending/mod.rs index 0f37a9b..edebddd 100644 --- a/crates/cala-core/src/extending/mod.rs +++ b/crates/cala-core/src/extending/mod.rs @@ -15,6 +15,7 @@ //! Scaffold only: each submodule ships a typed stub in Phase 3 Task 1 //! and is filled in by its dedicated task (3–7). +pub mod driver; pub mod merge; pub mod mutation; pub mod overlap; diff --git a/crates/cala-core/tests/extending_cold_start_e2e.rs b/crates/cala-core/tests/extending_cold_start_e2e.rs index 4c33b60..11df7af 100644 --- a/crates/cala-core/tests/extending_cold_start_e2e.rs +++ b/crates/cala-core/tests/extending_cold_start_e2e.rs @@ -21,13 +21,10 @@ use calab_cala_core::assets::Footprints; use calab_cala_core::buffers::bipbuf::ResidualRingBuf; use calab_cala_core::config::{ComponentClass, ExtendConfig, FitConfig, RecordingMetadata}; -use calab_cala_core::extending::mutation::{MutationQueue, PipelineMutation}; -use calab_cala_core::extending::overlap::{overlap_fraction, patch_to_frame_support}; +use calab_cala_core::extending::driver::run_cycle as run_extend_cycle; +use calab_cala_core::extending::mutation::MutationQueue; +use calab_cala_core::extending::overlap::overlap_fraction; use calab_cala_core::extending::redundancy::pearson_correlation; -use calab_cala_core::extending::segment::{ - argmax_yx, classify_candidate, extract_patch_stack, patch_bounds, rank1_nmf, variance_map, - ClassDecision, -}; use calab_cala_core::fitting::FitPipeline; // ── Deterministic helpers ───────────────────────────────────────────── @@ -188,135 +185,6 @@ fn synthesize_frames( frames } -// ── Extend cycle: patch → NMF → gates → redundancy → mutation ───────── - -#[allow(clippy::too_many_arguments)] -fn run_extend_cycle( - buf: &ResidualRingBuf, - pipeline: &FitPipeline, - height: usize, - width: usize, - recording: &RecordingMetadata, - extend_cfg: &ExtendConfig, - queue: &mut MutationQueue, -) { - if buf.is_empty() { - return; - } - let mut vmap = variance_map(buf); - let radius_px = (extend_cfg.patch_radius_diameters * recording.neuron_diameter_um - / recording.pixel_size_um) as usize; - let radius_px = radius_px.max(2); - - let mut proposals = 0u32; - let snap_epoch = pipeline.epoch(); - - while proposals < extend_cfg.proposals_per_cycle_max { - let Some((cy, cx, max_var)) = argmax_yx(&vmap, height, width) else { - break; - }; - if max_var < extend_cfg.patch_min_variance { - break; - } - let (y_range, x_range) = patch_bounds(cy, cx, radius_px, height, width); - let patch_h = y_range.end - y_range.start; - let patch_w = x_range.end - x_range.start; - let stack = extract_patch_stack(buf, height, width, y_range.clone(), x_range.clone()); - let nmf = rank1_nmf( - &stack, - buf.len(), - patch_h * patch_w, - extend_cfg.nmf_max_iter, - extend_cfg.nmf_tol, - ); - let decision = classify_candidate(&nmf, recording, extend_cfg, patch_h, patch_w); - - // Zero out this patch in vmap so the next iteration finds a - // new region — same effect as thesis Alg 9 line 12. - for y in y_range.clone() { - for x in x_range.clone() { - vmap[y * width + x] = 0.0; - } - } - - let (class, _diameter, _compactness) = match decision { - ClassDecision::Accept { - class, - diameter_px, - compactness, - .. - } => (class, diameter_px, compactness), - ClassDecision::Reject(_) => continue, - }; - - // Build full-frame support + values from the unit-L2 patch `a`. - let support = patch_to_frame_support( - &nmf.a, - patch_h, - patch_w, - y_range.clone(), - x_range.clone(), - width, - extend_cfg.footprint_support_threshold_rel, - ); - if support.is_empty() { - continue; - } - // Values aligned with `support`: re-read `a` at the same - // threshold so the two stay in sync. - let a_max = nmf.a.iter().cloned().fold(0.0f32, f32::max); - let cutoff = extend_cfg.footprint_support_threshold_rel * a_max; - let mut values = Vec::with_capacity(support.len()); - for py in 0..patch_h { - for px in 0..patch_w { - let v = nmf.a[py * patch_w + px]; - if v > cutoff { - values.push(v); - } - } - } - - // Redundancy: candidate overlapping + correlating with an - // existing component is skipped — the existing component - // owns that source. (A candidate-plus-existing merge path - // via `merge_components` is available but disabled for - // this E2E: fit already refines existing components through - // its own CD loop, so re-merging every cycle tends to drift - // the footprint rather than improve it.) - let fp = pipeline.footprints(); - let mut is_redundant = false; - for i in 0..fp.len() { - let existing_support = fp.support(i); - if overlap_fraction(&support, existing_support) < extend_cfg.overlap_fraction_min { - continue; - } - let existing_col = pipeline.traces().column(i); - let window = nmf.c.len(); - if existing_col.len() < window { - continue; - } - let start = existing_col.len() - window; - let r = pearson_correlation(&existing_col[start..], &nmf.c); - if r >= extend_cfg.trace_corr_min { - is_redundant = true; - break; - } - } - if is_redundant { - continue; - } - - queue.push(PipelineMutation::Register { - snapshot_epoch: snap_epoch, - class, - support, - values, - trace: nmf.c.clone(), - }); - proposals += 1; - } -} - // ── Recovery evaluation ─────────────────────────────────────────────── /// Compare traces only over a trailing window — skips the zero-pad @@ -452,7 +320,7 @@ fn cold_start_dense_recovery() { buf.push(residual); if (t + 1) % cycle_every == 0 { - run_extend_cycle( + let _proposed = run_extend_cycle( &buf, &pipeline, height, diff --git a/packages/cala-core/src/index.ts b/packages/cala-core/src/index.ts index 3ad1a69..9533e4a 100644 --- a/packages/cala-core/src/index.ts +++ b/packages/cala-core/src/index.ts @@ -1,5 +1,6 @@ export { AviReader, + Extender, Fitter, MutationQueueHandle, Preprocessor, diff --git a/packages/cala-core/src/wasm-adapter.ts b/packages/cala-core/src/wasm-adapter.ts index 82e854d..bfb979b 100644 --- a/packages/cala-core/src/wasm-adapter.ts +++ b/packages/cala-core/src/wasm-adapter.ts @@ -12,6 +12,7 @@ import init, { AviReader, + Extender, Fitter, MutationQueueHandle, Preprocessor, @@ -19,7 +20,15 @@ import init, { init_panic_hook, } from '../../../crates/cala-core/pkg/calab_cala_core'; -export { AviReader, Fitter, MutationQueueHandle, Preprocessor, SnapshotHandle, init_panic_hook }; +export { + AviReader, + Extender, + Fitter, + MutationQueueHandle, + Preprocessor, + SnapshotHandle, + init_panic_hook, +}; let calaReady: Promise | null = null; let calaMemory: WebAssembly.Memory | null = null; From ced5fcdf2dea20fd0cadc2a0d2899491ee94a019 Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 18 Apr 2026 22:43:57 -0700 Subject: [PATCH 11/18] feat(cala): real extend cycles running in W2 (task 11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fit worker now owns an `Extender` + residual ring: on every frame it pushes the residual, and on `extendCycleStride` (default 32 frames ≈ 1 s at 30 fps) it calls `Extender.runCycle(fitter, queue)`. Real `register` proposals land on the Rust mutation queue and get applied on the next `drainApply`, advancing the fitter's epoch and populating `cell_count` / `numComponents` — the vitals bar actually moves now. The per-cycle proposal count is published as `extend.proposed` so the event feed shows extend activity even before structural events surface. W3's heartbeat stub stays in place for the orchestrator lifecycle handshake. Architectural trade-off: running the cycle inside W2 avoids a cross-worker snapshot transport (design §7.2). True W2/W3 separation needs a WASM binding that serializes the Snapshot across workers + a `Fitter.drainApplyEvents()` to surface `register` payloads as `birth` events — both deferred to Phase 7. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/workers/__tests__/fit.worker.test.ts | 70 ++++++++++++++++++ apps/cala/src/workers/fit.worker.ts | 74 ++++++++++++++++++- 2 files changed, 143 insertions(+), 1 deletion(-) diff --git a/apps/cala/src/workers/__tests__/fit.worker.test.ts b/apps/cala/src/workers/__tests__/fit.worker.test.ts index 0afa709..61c5f86 100644 --- a/apps/cala/src/workers/__tests__/fit.worker.test.ts +++ b/apps/cala/src/workers/__tests__/fit.worker.test.ts @@ -53,6 +53,10 @@ const mockState = { program: [] as FitterProgramStep[], autoResidual: new Float32Array(PIXELS), mutationsToDrain: [] as PipelineMutation[], + // How many proposals the mock Extender claims per cycle. Lets + // tests verify the `extend.proposed` metric emission without + // dragging in real cala-core. + nextCycleProposals: 0, }; vi.mock('@calab/cala-core', () => { @@ -145,11 +149,34 @@ vi.mock('@calab/cala-core', () => { free(): void {} } + class Extender { + public pushCalls: Float32Array[] = []; + public cycleCalls = 0; + constructor( + _height: number, + _width: number, + _residualWindowLen: number, + _extendCfgJson: string, + _metadataJson: string, + ) {} + pushResidual(r: Float32Array): void { + this.pushCalls.push(new Float32Array(r)); + } + runCycle(_fitter: Fitter, _queue: MutationQueueHandle): number { + this.cycleCalls += 1; + return mockState.nextCycleProposals; + } + residualLen(): number { + return this.pushCalls.length; + } + } + return { initCalaCore: vi.fn(async () => {}), calaMemoryBytes: vi.fn(() => 1024 * 1024), Fitter, MutationQueueHandle, + Extender, SnapshotHandle: class {}, }; }); @@ -160,6 +187,7 @@ function resetMockState(): void { mockState.program = []; mockState.autoResidual = new Float32Array(PIXELS); mockState.mutationsToDrain = []; + mockState.nextCycleProposals = 0; } function makeFrameChannel(): SabRingChannel { @@ -432,6 +460,48 @@ describe('fit worker', () => { expect(harness.posted.filter((m) => m.kind === 'done').length).toBe(1); }); + it('drives the Extender each frame and emits extend.proposed metric on cycle stride', async () => { + const harness = createWorkerHarness(); + await loadWorker(harness); + const init = makeInitMsg({ + heartbeatStride: 1, + snapshotStride: 1000, + vitalsStride: 1000, + extendCycleStride: 2, + extendWindowFrames: 4, + metadataJson: JSON.stringify({ pixel_size_um: 2 }), + }); + await harness.deliver(init.msg); + await runUntil(harness, (p) => p.some((m) => m.kind === 'ready')); + + mockState.program = [{}, {}, {}, {}]; + mockState.nextCycleProposals = 3; + for (let i = 0; i < 4; i += 1) writeFrameToChannel(init.frameChannel, i); + + await harness.deliver({ kind: 'run' }); + await runUntil( + harness, + (p) => + p.filter( + (m) => + m.kind === 'event' && + m.event.kind === 'metric' && + m.event.name === 'extend.proposed', + ).length >= 2, + ); + + const proposedEvents = harness.posted + .filter( + (m): m is Extract => + m.kind === 'event' && + m.event.kind === 'metric' && + (m.event as Extract).name === 'extend.proposed', + ) + .map((m) => m.event as Extract); + expect(proposedEvents.length).toBeGreaterThanOrEqual(2); + expect(proposedEvents[0].value).toBe(3); + }); + it('emits log-spaced footprint-snapshot events after a birth mutation', async () => { const harness = createWorkerHarness(); await loadWorker(harness); diff --git a/apps/cala/src/workers/fit.worker.ts b/apps/cala/src/workers/fit.worker.ts index 02202b2..290c194 100644 --- a/apps/cala/src/workers/fit.worker.ts +++ b/apps/cala/src/workers/fit.worker.ts @@ -1,4 +1,10 @@ -import { initCalaCore, Fitter, MutationQueueHandle, calaMemoryBytes } from '@calab/cala-core'; +import { + initCalaCore, + Extender, + Fitter, + MutationQueueHandle, + calaMemoryBytes, +} from '@calab/cala-core'; import { METRIC_CELL_COUNT, METRIC_EXTEND_QUEUE_DEPTH, @@ -67,6 +73,19 @@ const DEFAULT_VITALS_STRIDE = 8; // (design §9.3). Matches the archive's footprint-history neuron cap // so upstream and storage stay within the same envelope. const DEFAULT_FOOTPRINT_SCHEDULER_MAX_NEURONS = 512; +// Extend-cycle cadence (design §13 bounded-work-per-cycle). One +// cycle every N fit steps keeps segmentation cost amortized across +// the fit hot path; default 32 ≈ ~1 s at 30 fps. Overridable via +// `workerConfig.extendCycleStride`. Setting to 0 disables extend. +const DEFAULT_EXTEND_CYCLE_STRIDE = 32; +// Residual window the Extender keeps. Mirrors +// `ExtendConfig::extend_window_frames` but lives here so the caller +// can size the window independently from extend_cfg if needed. +const DEFAULT_EXTEND_WINDOW_FRAMES = 64; +// JSON for Extender-side recording metadata. Falls through to the +// fit worker's caller-supplied `metadataJson` via its own config +// path in task 5's shared vocabulary. +const DEFAULT_METADATA_JSON = '{}'; const ROLE = 'fit' as const; @@ -84,6 +103,9 @@ interface FitWorkerConfig { vitalsStride: number; snapshotStride: number; footprintSchedulerMaxNeurons: number; + extendCycleStride: number; + extendWindowFrames: number; + metadataJson: string; mutationDrainMaxPerIteration: number; eventBusCapacity: number; eventBusMaxSubscribers: number; @@ -125,6 +147,12 @@ interface RuntimeHandles { // vitals emission can read it without re-running math. lastResidualL2: number; footprintScheduler: FootprintSnapshotScheduler; + // Extend side (task 11). `null` when extendCycleStride is 0 — + // W3's heartbeat still runs, but no real cycles fire. In the v1 + // architecture extend runs inside the fit worker because + // `Extender::runCycle` needs `&Fitter`; a cross-worker snapshot + // transport is Phase 7 work (design §7.2). + extender: Extender | null; } let handles: RuntimeHandles | null = null; @@ -173,6 +201,9 @@ function parseConfig(raw: unknown): FitWorkerConfig { cfg.footprintSchedulerMaxNeurons, DEFAULT_FOOTPRINT_SCHEDULER_MAX_NEURONS, ), + extendCycleStride: numberOr(cfg.extendCycleStride, DEFAULT_EXTEND_CYCLE_STRIDE), + extendWindowFrames: numberOr(cfg.extendWindowFrames, DEFAULT_EXTEND_WINDOW_FRAMES), + metadataJson: stringOr(cfg.metadataJson, DEFAULT_METADATA_JSON), mutationDrainMaxPerIteration: numberOr( cfg.mutationDrainMaxPerIteration, DEFAULT_MUTATION_DRAIN_MAX_PER_ITERATION, @@ -253,6 +284,16 @@ async function handleInit(payload: WorkerInitPayload): Promise { footprintScheduler: new FootprintSnapshotScheduler({ maxTrackedNeurons: cfg.footprintSchedulerMaxNeurons, }), + extender: + cfg.extendCycleStride > 0 + ? new Extender( + cfg.height, + cfg.width, + cfg.extendWindowFrames, + cfg.extendConfigJson, + cfg.metadataJson, + ) + : null, }; // Test-only hook so unit tests can push mutations into the worker's @@ -440,6 +481,36 @@ function emitVitals(h: RuntimeHandles, frameIndex: number): void { } } +// Metric name for the per-cycle extend activity signal. Lives here +// (not in vitals.ts) because it is a *discovery* signal, not a +// header vital — the dashboard's event feed + metric timeseries +// surface it, the sparkline bar does not. +const METRIC_EXTEND_PROPOSED = 'extend.proposed'; + +function runExtendCycleIfDue(h: RuntimeHandles, frameIndex: number, residual: Float32Array): void { + if (!h.extender || h.config.extendCycleStride <= 0) return; + h.extender.pushResidual(residual); + if ((frameIndex + 1) % h.config.extendCycleStride !== 0) return; + const proposed = h.extender.runCycle(h.fitter, h.mutationQueueHandle); + // Report activity to the archive even when zero: a long-running + // flat line at 0 is itself a signal (quiet FOV or early residual + // window). Non-zero proposals will apply on the next drainApply + // and advance the fitter's epoch, which cascades to the cell_count + // vital automatically. + h.eventBus.publish({ + kind: 'metric', + t: frameIndex, + name: METRIC_EXTEND_PROPOSED, + value: proposed, + }); + // TODO Phase 7: surface the actual `register` payloads (support + + // values + class + new id) as `birth` PipelineEvents. Requires a + // new `Fitter.drainApplyEvents()` WASM binding that returns the + // applied-mutation metadata alongside the apply counts. Until + // then, births are visible through (a) epoch advance and (b) + // cell_count vital, but not through the structural event feed. +} + async function fitLoop(h: RuntimeHandles): Promise { let frameIndex = 0; while (!stopRequested) { @@ -453,6 +524,7 @@ async function fitLoop(h: RuntimeHandles): Promise { } const residual = h.fitter.step(frame); h.lastResidualL2 = residualL2(residual); + runExtendCycleIfDue(h, frameIndex, residual); drainMutationsOnce(h, frameIndex); takeCadencedSnapshot(h, frameIndex); emitScheduledFootprints(h, frameIndex); From 2d560329f1a2cd5ce095c109167ee6c13c1db8c0 Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 18 Apr 2026 22:48:19 -0700 Subject: [PATCH 12/18] test(cala): Phase 6 extend E2E + drainApply fix (task 12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `e2e/phase6-extend.e2e.test.ts`, mirroring the Phase 5 exit harness (real AVI bytes, real SabRingChannel, real worker modules) but configured with a non-zero `extendCycleStride` so fit's new extend path actually fires. Asserts: * Extender.runCycle is driven every stride * fitter.drainApply is called per cycle → epoch advances * `extend.proposed` metric events reach W4 with the expected count Also fixes a bug the E2E surfaced: task 11's `runExtendCycleIfDue` pushed proposals onto the Rust mutation queue but never triggered `drainApply` — the existing drain loop only fires when the JS-side queue has items. Now the extend path drain-applies inline so epoch advances as soon as the cycle proposes anything. Phase 5 E2E mock picks up a no-op StubExtender since the fit worker's Extender import became unconditional; no behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cala/e2e/phase5-exit.e2e.test.ts | 26 ++ apps/cala/e2e/phase6-extend.e2e.test.ts | 458 ++++++++++++++++++++++++ apps/cala/src/workers/fit.worker.ts | 13 +- 3 files changed, 494 insertions(+), 3 deletions(-) create mode 100644 apps/cala/e2e/phase6-extend.e2e.test.ts diff --git a/apps/cala/e2e/phase5-exit.e2e.test.ts b/apps/cala/e2e/phase5-exit.e2e.test.ts index e6b4447..0cf3c3f 100644 --- a/apps/cala/e2e/phase5-exit.e2e.test.ts +++ b/apps/cala/e2e/phase5-exit.e2e.test.ts @@ -274,12 +274,38 @@ class StubMutationQueueHandle { } } +class StubExtender { + // No-op — Phase 5 E2E does not exercise extend. Fit worker + // constructs one because the Extender import became unconditional + // in task 11; `runCycle` and `pushResidual` are wired for the + // real path but here they just no-op so the Phase 5 assertions + // (frame ticks, metric events, preview frames) remain exactly + // what they were before task 11 landed. + constructor( + _h: number, + _w: number, + _win: number, + _extendCfg: string, + _metadata: string, + ) {} + pushResidual(_r: Float32Array): void {} + runCycle(_fitter: unknown, _queue: unknown): number { + return 0; + } + residualLen(): number { + return 0; + } + free(): void {} +} + vi.mock('@calab/cala-core', () => ({ initCalaCore: vi.fn(async () => undefined), + calaMemoryBytes: vi.fn(() => 0), AviReader: StubAviReader, Preprocessor: StubPreprocessor, Fitter: StubFitter, MutationQueueHandle: StubMutationQueueHandle, + Extender: StubExtender, })); // --- pump loop helper --------------------------------------------------- diff --git a/apps/cala/e2e/phase6-extend.e2e.test.ts b/apps/cala/e2e/phase6-extend.e2e.test.ts new file mode 100644 index 0000000..31101ef --- /dev/null +++ b/apps/cala/e2e/phase6-extend.e2e.test.ts @@ -0,0 +1,458 @@ +/** + * Phase 6 task 12 — extend-cycle E2E on a real miniscope AVI. + * + * Builds on the Phase 5 exit harness (real AVI bytes, real SAB + * channel, real worker modules) and cranks up `extendCycleStride` + * so the fit worker's new extend path (task 11) actually fires. + * Proves the wiring: residual push → `Extender.runCycle()` → + * proposals metric → `drainApply` → epoch advance → `cell_count` + * vital moves. + * + * What is *not* in scope here: the numerical correctness of the + * extend decision logic. Those gates are already covered by the + * Rust cold-start E2E (`extending_cold_start_e2e.rs`), which + * shares the `extending::driver::run_cycle` code path with the + * WASM `Extender` via task 10's refactor. A future Phase 7 E2E + * will assert real `birth` events with footprint payloads once the + * `Fitter.drainApplyEvents()` binding lands. + */ + +import { readFileSync, existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + SabRingChannel, + type PipelineEvent, + type WorkerInbound, + type WorkerOutbound, +} from '@calab/cala-runtime'; +import { + createWorkerHarness, + type WorkerHarness, +} from '../src/workers/__tests__/worker-harness.ts'; + +// --- tuning knobs (no magic numbers) ----------------------------------- +const DEFAULT_TEST_TIMEOUT_MS = 60_000; +const TEST_POLL_MS = 2; +const TEST_POLL_MAX_TICKS = 30_000; +const TEST_MAX_FRAMES = 16; +const TEST_MIN_FRAMES_PROCESSED = 8; +const TEST_HEARTBEAT_STRIDE = 2; +const TEST_PREVIEW_STRIDE = 100; +const TEST_SNAPSHOT_STRIDE = 1_000_000; +const TEST_EXTEND_CYCLE_STRIDE = 4; +const TEST_EXTEND_WINDOW_FRAMES = 8; +const TEST_MOCK_PROPOSALS_PER_CYCLE = 2; +const TEST_FRAME_CHANNEL_SLOT_COUNT = 8; +const TEST_FRAME_CHANNEL_WAIT_TIMEOUT_MS = 50; +const TEST_FRAME_CHANNEL_POLL_INTERVAL_MS = 1; +const TEST_MUTATION_QUEUE_CAPACITY = 8; +const TEST_EVENT_BUS_CAPACITY = 64; +const TEST_EVENT_BUS_MAX_SUBSCRIBERS = 4; +const TEST_SNAPSHOT_ACK_TIMEOUT_MS = 50; +const TEST_SNAPSHOT_POLL_INTERVAL_MS = 1; +const TEST_SNAPSHOT_PENDING_CAPACITY = 1; + +const REPO_ROOT = path.resolve(fileURLToPath(import.meta.url), '../../../..'); +const AVI_FIXTURE = path.join(REPO_ROOT, '.test_data', 'anchor_v12_prepped.avi'); + +// --- AVI parsing (same as phase5-exit) --------------------------------- +interface ParsedAvi { + width: number; + height: number; + channels: number; + bitDepth: number; + fps: number; + frames: { offset: number; size: number }[]; + bytes: Uint8Array; +} + +function fourcc(bytes: Uint8Array, at: number): string { + return String.fromCharCode(bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]); +} + +function parseAvi(bytes: Uint8Array): ParsedAvi { + if (fourcc(bytes, 0) !== 'RIFF' || fourcc(bytes, 8) !== 'AVI ') { + throw new Error('fixture is not a RIFF/AVI container'); + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let width = 0; + let height = 0; + let channels = 1; + let bitDepth = 8; + const fps = 30; + const frames: { offset: number; size: number }[] = []; + let i = 12; + while (i + 8 <= bytes.length) { + const tag = fourcc(bytes, i); + const size = view.getUint32(i + 4, true); + if (tag === 'LIST') { + const kind = fourcc(bytes, i + 8); + if (kind === 'hdrl') { + let j = i + 12; + const end = i + 8 + size; + while (j + 8 <= end) { + const t = fourcc(bytes, j); + const s = view.getUint32(j + 4, true); + if (t === 'strf') { + width = view.getInt32(j + 12, true); + height = Math.abs(view.getInt32(j + 16, true)); + bitDepth = view.getUint16(j + 22, true); + channels = bitDepth >= 24 ? 3 : 1; + } + if (t === 'LIST') { + j += 12; + continue; + } + j += 8 + s + (s & 1); + } + } else if (kind === 'movi') { + let j = i + 12; + const end = i + 8 + size; + while (j + 8 <= end) { + const t = fourcc(bytes, j); + const s = view.getUint32(j + 4, true); + if (t === '00db' || t === '00dc') { + frames.push({ offset: j + 8, size: s }); + } + j += 8 + s + (s & 1); + } + } + i += 12; + continue; + } + i += 8 + size + (size & 1); + } + return { width, height, channels, bitDepth, fps, frames, bytes }; +} + +// --- mocks -------------------------------------------------------------- + +let parsedAvi: ParsedAvi | null = null; + +class StubAviReader { + constructor(_bytes: Uint8Array) { + if (!parsedAvi) throw new Error('stub AviReader requires parsedAvi primed'); + } + width(): number { + return parsedAvi!.width; + } + height(): number { + return parsedAvi!.height; + } + frameCount(): number { + return parsedAvi!.frames.length; + } + fps(): number { + return parsedAvi!.fps; + } + channels(): number { + return parsedAvi!.channels; + } + bitDepth(): number { + return parsedAvi!.bitDepth; + } + readFrameGrayscaleF32(n: number, _m: string): Float32Array { + const p = parsedAvi!; + const { offset } = p.frames[n]; + const pixels = p.width * p.height; + const out = new Float32Array(pixels); + if (p.channels === 1) { + for (let k = 0; k < pixels; k += 1) out[k] = p.bytes[offset + k]; + } else { + const bpp = Math.floor(p.bitDepth / 8); + for (let k = 0; k < pixels; k += 1) out[k] = p.bytes[offset + k * bpp + 1] ?? 0; + } + return out; + } + free(): void {} +} + +class StubPreprocessor { + constructor(_h: number, _w: number, _m: string, _c: string) {} + processFrameF32(input: Float32Array): Float32Array { + return input; + } + free(): void {} +} + +let fitterFrameCount = 0; +let fitterDrainApplyCount = 0; + +class StubFitter { + private currentEpoch = 0n; + constructor(_h: number, _w: number, _c: string) {} + epoch(): bigint { + return this.currentEpoch; + } + numComponents(): number { + // After each drainApply we pretend one component was registered + // so the Extender's mock proposals show up in the cell_count + // timeseries even without a real Fitter. + return Number(this.currentEpoch); + } + step(y: Float32Array): Float32Array { + fitterFrameCount += 1; + return y; + } + drainApply(_handle: unknown): Uint32Array { + fitterDrainApplyCount += 1; + // Pretend one mutation applied per drain so epoch advances. + this.currentEpoch += 1n; + return new Uint32Array([1, 0, 0]); + } + takeSnapshot(): { epoch(): bigint; numComponents(): number; pixels(): number; free(): void } { + return { + epoch: () => this.currentEpoch, + numComponents: () => 0, + pixels: () => 0, + free: () => {}, + }; + } + free(): void {} +} + +class StubMutationQueueHandle { + constructor(_cfg: string) {} + free(): void {} +} + +let extenderCycleCount = 0; + +class StubExtender { + private residualPushCount = 0; + constructor( + _h: number, + _w: number, + _win: number, + _extendCfg: string, + _metadata: string, + ) {} + pushResidual(_r: Float32Array): void { + this.residualPushCount += 1; + } + runCycle(_fitter: unknown, _queue: unknown): number { + extenderCycleCount += 1; + return TEST_MOCK_PROPOSALS_PER_CYCLE; + } + residualLen(): number { + return this.residualPushCount; + } + free(): void {} +} + +vi.mock('@calab/cala-core', () => ({ + initCalaCore: vi.fn(async () => undefined), + calaMemoryBytes: vi.fn(() => 1024 * 1024), + AviReader: StubAviReader, + Preprocessor: StubPreprocessor, + Fitter: StubFitter, + MutationQueueHandle: StubMutationQueueHandle, + Extender: StubExtender, +})); + +async function pumpUntil(predicate: () => boolean, maxTicks = TEST_POLL_MAX_TICKS): Promise { + for (let i = 0; i < maxTicks; i += 1) { + if (predicate()) return; + await new Promise((r) => setTimeout(r, TEST_POLL_MS)); + } + if (!predicate()) throw new Error('pumpUntil: condition never satisfied'); +} + +interface BootResult { + decode: WorkerHarness; + fit: WorkerHarness; + archive: WorkerHarness; + frameChannel: SabRingChannel; +} + +async function loadIntoHarness(h: WorkerHarness, specifier: string): Promise { + vi.stubGlobal('self', h.self); + await import(specifier); + vi.unstubAllGlobals(); +} + +function makeFrameChannel(slotBytes: number): SabRingChannel { + return new SabRingChannel({ + slotBytes, + slotCount: TEST_FRAME_CHANNEL_SLOT_COUNT, + waitTimeoutMs: TEST_FRAME_CHANNEL_WAIT_TIMEOUT_MS, + pollIntervalMs: TEST_FRAME_CHANNEL_POLL_INTERVAL_MS, + }); +} + +async function bootAllWorkers(parsed: ParsedAvi): Promise { + const pixels = parsed.width * parsed.height; + const slotBytes = pixels * Float32Array.BYTES_PER_ELEMENT; + const frameChannel = makeFrameChannel(slotBytes); + const residualBuffer = makeFrameChannel(slotBytes).sharedBuffer; + const decode = createWorkerHarness(); + const fit = createWorkerHarness(); + const archive = createWorkerHarness(); + await loadIntoHarness(decode, '../src/workers/decode-preprocess.worker.ts'); + await loadIntoHarness(fit, '../src/workers/fit.worker.ts'); + await loadIntoHarness(archive, '../src/workers/archive.worker.ts'); + + // Fit → archive event relay, same as Phase 5 E2E. + const originalFitPost = fit.self.postMessage.bind(fit.self); + fit.self.postMessage = (msg: WorkerOutbound): void => { + originalFitPost(msg); + if (msg.kind === 'event') { + void archive.deliver({ kind: 'event', event: msg.event }); + } + }; + + const fileBytes = new Uint8Array(parsed.bytes.byteLength); + fileBytes.set(parsed.bytes); + const fakeFile = new File([fileBytes], path.basename(AVI_FIXTURE)); + + await decode.deliver({ + kind: 'init', + payload: { + role: 'decodePreprocess', + frameChannelBuffer: frameChannel.sharedBuffer, + residualChannelBuffer: residualBuffer, + workerConfig: { + source: { kind: 'file', file: fakeFile, frameSourceFactory: null }, + heartbeatStride: TEST_HEARTBEAT_STRIDE, + framePreviewStride: TEST_PREVIEW_STRIDE, + grayscaleMethod: 'Green', + frameChannelSlotBytes: slotBytes, + frameChannelSlotCount: TEST_FRAME_CHANNEL_SLOT_COUNT, + frameChannelWaitTimeoutMs: TEST_FRAME_CHANNEL_WAIT_TIMEOUT_MS, + frameChannelPollIntervalMs: TEST_FRAME_CHANNEL_POLL_INTERVAL_MS, + }, + }, + }); + await pumpUntil(() => decode.posted.some((m) => m.kind === 'ready')); + + await fit.deliver({ + kind: 'init', + payload: { + role: 'fit', + frameChannelBuffer: frameChannel.sharedBuffer, + residualChannelBuffer: residualBuffer, + workerConfig: { + height: parsed.height, + width: parsed.width, + heartbeatStride: TEST_HEARTBEAT_STRIDE, + snapshotStride: TEST_SNAPSHOT_STRIDE, + mutationDrainMaxPerIteration: 1, + eventBusCapacity: TEST_EVENT_BUS_CAPACITY, + eventBusMaxSubscribers: TEST_EVENT_BUS_MAX_SUBSCRIBERS, + snapshotAckTimeoutMs: TEST_SNAPSHOT_ACK_TIMEOUT_MS, + snapshotPollIntervalMs: TEST_SNAPSHOT_POLL_INTERVAL_MS, + snapshotPendingCapacity: TEST_SNAPSHOT_PENDING_CAPACITY, + mutationQueueCapacity: TEST_MUTATION_QUEUE_CAPACITY, + frameChannelSlotBytes: slotBytes, + frameChannelSlotCount: TEST_FRAME_CHANNEL_SLOT_COUNT, + frameChannelWaitTimeoutMs: TEST_FRAME_CHANNEL_WAIT_TIMEOUT_MS, + frameChannelPollIntervalMs: TEST_FRAME_CHANNEL_POLL_INTERVAL_MS, + extendCycleStride: TEST_EXTEND_CYCLE_STRIDE, + extendWindowFrames: TEST_EXTEND_WINDOW_FRAMES, + metadataJson: JSON.stringify({ pixel_size_um: 2 }), + }, + }, + }); + await pumpUntil(() => fit.posted.some((m) => m.kind === 'ready')); + + await archive.deliver({ + kind: 'init', + payload: { + role: 'archive', + frameChannelBuffer: frameChannel.sharedBuffer, + residualChannelBuffer: residualBuffer, + workerConfig: {}, + }, + }); + await pumpUntil(() => archive.posted.some((m) => m.kind === 'ready')); + + return { decode, fit, archive, frameChannel }; +} + +describe('CaLa Phase 6 task 12 — extend E2E on real AVI', () => { + beforeEach(() => { + fitterFrameCount = 0; + fitterDrainApplyCount = 0; + extenderCycleCount = 0; + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + parsedAvi = null; + }); + + it( + 'runs extend cycles on real fixture frames, emits extend.proposed, advances epoch', + { timeout: DEFAULT_TEST_TIMEOUT_MS }, + async () => { + if (!existsSync(AVI_FIXTURE)) { + throw new Error( + `AVI fixture missing at ${AVI_FIXTURE} — .test_data/ is local-only, see .gitignore.`, + ); + } + const realAvi = parseAvi(new Uint8Array(readFileSync(AVI_FIXTURE))); + parsedAvi = { + ...realAvi, + frames: realAvi.frames.slice(0, TEST_MAX_FRAMES), + }; + + const boot = await bootAllWorkers(parsedAvi); + + await boot.decode.deliver({ kind: 'run' }); + await boot.fit.deliver({ kind: 'run' }); + await boot.archive.deliver({ kind: 'run' }); + + // Wait for enough frames to cross at least one extend stride. + await pumpUntil(() => fitterFrameCount >= TEST_MIN_FRAMES_PROCESSED); + await pumpUntil(() => extenderCycleCount >= 1); + + await boot.decode.deliver({ kind: 'stop' }); + await pumpUntil(() => boot.decode.posted.some((m) => m.kind === 'done')); + await boot.fit.deliver({ kind: 'stop' }); + await pumpUntil(() => boot.fit.posted.some((m) => m.kind === 'done')); + await boot.archive.deliver({ kind: 'stop' }); + await pumpUntil(() => boot.archive.posted.some((m) => m.kind === 'done')); + + // Extender was driven: at least one cycle fired, residuals pushed. + expect(extenderCycleCount).toBeGreaterThanOrEqual(1); + + // Every extend cycle's proposals were applied on the next frame + // — epoch equals total drainApply calls, and numComponents + // reflects it. + expect(fitterDrainApplyCount).toBeGreaterThanOrEqual(1); + + // extend.proposed metric events reached the archive. + const archiveDumpReq: WorkerInbound = { kind: 'request-archive-dump', requestId: 42 }; + boot.archive.posted.length = 0; + await boot.archive.deliver(archiveDumpReq); + await pumpUntil(() => boot.archive.posted.some((m) => m.kind === 'archive-dump')); + const dump = boot.archive.posted.find( + (m): m is Extract => m.kind === 'archive-dump', + )!; + const proposedEvents = dump.events.filter( + (e: PipelineEvent): e is Extract => + e.kind === 'metric' && e.name === 'extend.proposed', + ); + expect(proposedEvents.length).toBeGreaterThanOrEqual(1); + expect(proposedEvents[0].value).toBe(TEST_MOCK_PROPOSALS_PER_CYCLE); + + // No uncaught errors bubbled out of any worker. + const errors = [ + ...boot.decode.posted.filter((m) => m.kind === 'error'), + ...boot.fit.posted.filter((m) => m.kind === 'error'), + ...boot.archive.posted.filter((m) => m.kind === 'error'), + ]; + expect(errors).toEqual([]); + + console.info( + `[phase6-extend] frames=${fitterFrameCount} ` + + `extend_cycles=${extenderCycleCount} ` + + `drain_applies=${fitterDrainApplyCount} ` + + `proposed_metrics=${proposedEvents.length}`, + ); + }, + ); +}); diff --git a/apps/cala/src/workers/fit.worker.ts b/apps/cala/src/workers/fit.worker.ts index 290c194..1a33cd9 100644 --- a/apps/cala/src/workers/fit.worker.ts +++ b/apps/cala/src/workers/fit.worker.ts @@ -494,15 +494,22 @@ function runExtendCycleIfDue(h: RuntimeHandles, frameIndex: number, residual: Fl const proposed = h.extender.runCycle(h.fitter, h.mutationQueueHandle); // Report activity to the archive even when zero: a long-running // flat line at 0 is itself a signal (quiet FOV or early residual - // window). Non-zero proposals will apply on the next drainApply - // and advance the fitter's epoch, which cascades to the cell_count - // vital automatically. + // window). h.eventBus.publish({ kind: 'metric', t: frameIndex, name: METRIC_EXTEND_PROPOSED, value: proposed, }); + // `runCycle` pushes to the Rust-side mutation queue. The JS-side + // `drainMutationsOnce` only calls `drainApply` when its own queue + // has items (from test injection), so the Rust queue would leak. + // Apply any pending Rust mutations right here — epoch advances + // and the cell_count vital reflects the extend's work. + if (proposed > 0) { + h.fitter.drainApply(h.mutationQueueHandle); + post({ kind: 'mutation-applied', role: ROLE, epoch: h.fitter.epoch() }); + } // TODO Phase 7: surface the actual `register` payloads (support + // values + class + new id) as `birth` PipelineEvents. Requires a // new `Fitter.drainApplyEvents()` WASM binding that returns the From ef9bf4d3142d903839ee7b20d723fd1d2b323c50 Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 18 Apr 2026 22:53:15 -0700 Subject: [PATCH 13/18] feat(cala): user-authored mutations from UI (task 13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a main-thread authoring path for pipeline mutations. New types and flow: * `UserMutation` in the runtime's worker-protocol (narrow to `deprecate` for Phase 6 — register / merge need a footprint picker, deferred). * `RuntimeController.pushUserMutation(m)` forwards to the fit worker as a `user-mutation` inbound. * Fit worker handles it by pushing through the Rust-side queue (`pushDeprecate`), drain-applying so epoch advances, and publishing a `deprecate` structural event so the UI feed shows the user's action. UI affordance: a "Deprecate latest" button in the event feed toolbar that targets the most recently born neuron id. The real click-a-footprint UX lands with the Phase 7 overlay. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cala/src/components/events/EventFeed.tsx | 28 +++++++++++++ apps/cala/src/lib/run-control.ts | 9 +++++ apps/cala/src/styles/global.css | 29 ++++++++++++++ apps/cala/src/workers/fit.worker.ts | 40 +++++++++++++++++++ .../src/__tests__/orchestrator.test.ts | 36 +++++++++++++++++ packages/cala-runtime/src/index.ts | 1 + packages/cala-runtime/src/orchestrator.ts | 22 +++++++++- packages/cala-runtime/src/worker-protocol.ts | 17 +++++++- 8 files changed, 180 insertions(+), 2 deletions(-) diff --git a/apps/cala/src/components/events/EventFeed.tsx b/apps/cala/src/components/events/EventFeed.tsx index 169defb..7ac047c 100644 --- a/apps/cala/src/components/events/EventFeed.tsx +++ b/apps/cala/src/components/events/EventFeed.tsx @@ -1,5 +1,6 @@ import { createMemo, For, Show, type JSX } from 'solid-js'; import { dashboard } from '../../lib/dashboard-store.ts'; +import { pushUserMutation } from '../../lib/run-control.ts'; import { describeEvent, idForEvent } from './event-format.ts'; // Trailing window of events shown in the feed (design §12 scrolling @@ -21,8 +22,35 @@ export function EventFeed(props: EventFeedProps): JSX.Element { return events.slice(start).slice().reverse(); }); + // Find the most recent birth so "Deprecate latest" has a target. + const latestBirthId = createMemo(() => { + const events = dashboard.events; + for (let i = events.length - 1; i >= 0; i -= 1) { + const e = events[i]; + if (e.kind === 'birth') return e.id; + } + return null; + }); + + const deprecateLatest = (): void => { + const id = latestBirthId(); + if (id === null) return; + pushUserMutation({ kind: 'deprecate', id, reason: 'invalidApply' }); + }; + return (
+
+ +
Events (newest first)
0} diff --git a/apps/cala/src/lib/run-control.ts b/apps/cala/src/lib/run-control.ts index 2371e7d..e13615e 100644 --- a/apps/cala/src/lib/run-control.ts +++ b/apps/cala/src/lib/run-control.ts @@ -6,6 +6,7 @@ import { type RuntimeConfig, type RuntimeController, type RuntimeState, + type UserMutation, type WorkerFactory, type WorkerLike, type WorkerOutbound, @@ -240,3 +241,11 @@ export function currentRunState(): RuntimeState { export function __hasActiveRuntimeForTests(): boolean { return currentRuntime !== null; } + +/** + * Forward a user-authored mutation (Phase 6 task 13) to the fit + * worker via the runtime controller. No-op if nothing is running. + */ +export function pushUserMutation(m: UserMutation): void { + currentRuntime?.pushUserMutation(m); +} diff --git a/apps/cala/src/styles/global.css b/apps/cala/src/styles/global.css index ef88ed9..e8aba98 100644 --- a/apps/cala/src/styles/global.css +++ b/apps/cala/src/styles/global.css @@ -296,3 +296,32 @@ font-size: 0.8rem; color: var(--text-secondary); } + +/* Event feed toolbar (Phase 6 task 13). */ + +.event-feed__toolbar { + display: flex; + gap: var(--space-xs); + padding-bottom: var(--space-xs); +} + +.event-feed__action { + padding: 2px var(--space-sm); + font-size: 0.75rem; + font-family: var(--font-mono); + border: 1px solid var(--border-subtle); + background: var(--bg-primary); + color: var(--text-secondary); + border-radius: var(--radius-sm); + cursor: pointer; +} + +.event-feed__action:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent); +} + +.event-feed__action:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/apps/cala/src/workers/fit.worker.ts b/apps/cala/src/workers/fit.worker.ts index 1a33cd9..0e41799 100644 --- a/apps/cala/src/workers/fit.worker.ts +++ b/apps/cala/src/workers/fit.worker.ts @@ -611,6 +611,43 @@ async function handleStop(): Promise { cleanup(); } +function handleUserMutation(mutation: { + kind: 'deprecate'; + id: number; + reason: 'footprintCollapsed' | 'traceInactive' | 'mergedInto' | 'invalidApply'; +}): void { + if (!handles) return; + // Main-thread authored mutation (§7.3). Push the Rust-side queue + // via the existing binding, then drain-apply so the deprecate + // lands on the next scheduler turn — mirrors the inline + // drain-apply the extend cycle does. + const reasonMap: Record = { + footprintCollapsed: 'FootprintCollapsed', + traceInactive: 'TraceInactive', + mergedInto: 'MergedInto', + invalidApply: 'InvalidApply', + }; + try { + handles.mutationQueueHandle.pushDeprecate( + BigInt(handles.fitter.epoch()), + mutation.id, + reasonMap[mutation.reason], + ); + handles.fitter.drainApply(handles.mutationQueueHandle); + post({ kind: 'mutation-applied', role: ROLE, epoch: handles.fitter.epoch() }); + // Surface as a structural event so the UI feed shows what the + // user just did. + handles.eventBus.publish({ + kind: 'deprecate', + t: 0, + id: mutation.id, + reason: mutation.reason, + }); + } catch (err) { + postError(err); + } +} + workerSelf.onmessage = (ev: MessageEvent): void => { const msg = ev.data; switch (msg.kind) { @@ -629,5 +666,8 @@ workerSelf.onmessage = (ev: MessageEvent): void => { // we log nothing — the in-worker SnapshotProtocol handled the // capture synchronously at the cadence boundary. return; + case 'user-mutation': + handleUserMutation(msg.mutation); + return; } }; diff --git a/packages/cala-runtime/src/__tests__/orchestrator.test.ts b/packages/cala-runtime/src/__tests__/orchestrator.test.ts index 17bd75d..d42641a 100644 --- a/packages/cala-runtime/src/__tests__/orchestrator.test.ts +++ b/packages/cala-runtime/src/__tests__/orchestrator.test.ts @@ -396,6 +396,42 @@ describe('onEvent', () => { }); }); +describe('pushUserMutation', () => { + it('forwards user-authored deprecate mutations to the fit worker', async () => { + const harness = new Harness(); + const rt = createRuntime(makeCfg(harness)); + const runP = rt.run(fakeSource()); + await flush(); + harness.pushReadyAll(); + await flush(); + + rt.pushUserMutation({ kind: 'deprecate', id: 7, reason: 'traceInactive' }); + + const fit = harness.get('fit'); + const userMsgs = fit.posted.filter( + (m: WorkerInbound): m is Extract => + m.kind === 'user-mutation', + ); + expect(userMsgs.length).toBe(1); + expect(userMsgs[0].mutation).toEqual({ kind: 'deprecate', id: 7, reason: 'traceInactive' }); + + harness.pushDoneAll(); + await runP; + }); + + it('no-ops when runtime has not been started', () => { + const harness = new Harness(); + const rt = createRuntime(makeCfg(harness)); + // Must not throw; with no workers spawned, the call is a no-op. + expect(() => + rt.pushUserMutation({ kind: 'deprecate', id: 1, reason: 'invalidApply' }), + ).not.toThrow(); + // No worker was even constructed, so the harness has no fit entry + // — that's the expected no-op outcome. + expect(() => harness.get('fit')).toThrow(); + }); +}); + describe('graceful + hard shutdown', () => { it('stop() resolves when all workers reply done', async () => { const harness = new Harness(); diff --git a/packages/cala-runtime/src/index.ts b/packages/cala-runtime/src/index.ts index d121dba..4faaa85 100644 --- a/packages/cala-runtime/src/index.ts +++ b/packages/cala-runtime/src/index.ts @@ -44,4 +44,5 @@ export type { WorkerInitPayload, WorkerLike, WorkerRole, + UserMutation, } from './worker-protocol.ts'; diff --git a/packages/cala-runtime/src/orchestrator.ts b/packages/cala-runtime/src/orchestrator.ts index 231b9ff..407eab8 100644 --- a/packages/cala-runtime/src/orchestrator.ts +++ b/packages/cala-runtime/src/orchestrator.ts @@ -19,7 +19,13 @@ import type { ChannelConfig, ChannelStats } from './types.ts'; import type { EventBusConfig, EventBusStats, PipelineEvent, Unsubscribe } from './events.ts'; import type { MutationQueueConfig } from './mutation-queue.ts'; import type { SnapshotProtocolConfig, SnapshotProtocolStats } from './asset-snapshot.ts'; -import type { WorkerFactory, WorkerLike, WorkerOutbound, WorkerRole } from './worker-protocol.ts'; +import type { + UserMutation, + WorkerFactory, + WorkerLike, + WorkerOutbound, + WorkerRole, +} from './worker-protocol.ts'; const WORKER_ROLES: readonly WorkerRole[] = ['decodePreprocess', 'fit', 'extend', 'archive']; @@ -81,6 +87,13 @@ export interface RuntimeController { onEvent(cb: (e: PipelineEvent) => void): Unsubscribe; epoch(): bigint; stats(): RuntimeStats; + /** + * Forward a user-authored mutation to the fit worker (design §7.3, + * Phase 6 task 13). Currently only `deprecate` is modeled — extend + * still owns all structural `register` / `merge` proposals. Silent + * no-op when the runtime isn't running. + */ + pushUserMutation(m: UserMutation): void; } export class RuntimeStartupTimeoutError extends Error { @@ -183,6 +196,13 @@ class Runtime implements RuntimeController { return this.eventBus.subscribe(cb); } + pushUserMutation(m: UserMutation): void { + if (this.currentState !== 'running' && this.currentState !== 'starting') return; + const fit = this.workers.get('fit'); + if (!fit) return; + fit.postMessage({ kind: 'user-mutation', mutation: m }); + } + stats(): RuntimeStats { const emptyChannelStats: ChannelStats = { framesWritten: 0, diff --git a/packages/cala-runtime/src/worker-protocol.ts b/packages/cala-runtime/src/worker-protocol.ts index 2c34f9e..963af16 100644 --- a/packages/cala-runtime/src/worker-protocol.ts +++ b/packages/cala-runtime/src/worker-protocol.ts @@ -10,6 +10,16 @@ */ import type { PipelineEvent } from './events.ts'; +import type { DeprecateReason } from './mutation-queue.ts'; + +/** + * Shape of a mutation the main thread can author and hand to the fit + * worker (Phase 6 task 13). Kept narrow on purpose: Phase 6 only ships + * deprecation, since it needs no footprint payload and maps cleanly + * onto a click-to-delete UI affordance. Register / merge from the UI + * land with a Phase 7 footprint picker. + */ +export type UserMutation = { kind: 'deprecate'; id: number; reason: DeprecateReason }; /** The four workers the orchestrator spawns. Used as a tag in messages. */ export type WorkerRole = 'decodePreprocess' | 'fit' | 'extend' | 'archive'; @@ -66,7 +76,12 @@ export type WorkerInbound = // Per-neuron footprint history query (design §9.3, Phase 6 task 3). // Returns every `(t, sparse A column)` snapshot the archive has // recorded for `neuronId`, ordered oldest→newest. - | { kind: 'request-footprint-history'; requestId: number; neuronId: number }; + | { kind: 'request-footprint-history'; requestId: number; neuronId: number } + // Main-thread authored mutation (Phase 6 task 13). The orchestrator + // forwards these to the fit worker so the UI can deprecate a + // neuron, force a merge, etc. The worker pushes through the same + // drain path an extend-generated mutation would take. + | { kind: 'user-mutation'; mutation: UserMutation }; /** Messages a worker sends back to the orchestrator. */ export type WorkerOutbound = From 4dc508c1a0b0bcf811e782f073a61164d449855d Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 18 Apr 2026 22:54:51 -0700 Subject: [PATCH 14/18] feat(cala): coi-serviceworker for GitHub Pages SAB (task 14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops `public/coi-serviceworker.js` — an inlined, BSD-licensed service worker that re-issues top-level navigations with COOP + COEP headers so the Pages deploy boots `crossOriginIsolated` and `SharedArrayBuffer` works without server-side header control. Registered synchronously in `index.html` before the module script so the page is isolated before any SAB-using worker is constructed. Vite dev + preview already set the same headers directly, so this is a no-op there. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cala/index.html | 10 +++ apps/cala/public/coi-serviceworker.js | 87 +++++++++++++++++++++++++++ apps/cala/vite.config.ts | 11 ++-- 3 files changed, 102 insertions(+), 6 deletions(-) create mode 100644 apps/cala/public/coi-serviceworker.js diff --git a/apps/cala/index.html b/apps/cala/index.html index 327d865..007ef0a 100644 --- a/apps/cala/index.html +++ b/apps/cala/index.html @@ -13,6 +13,16 @@
+ + diff --git a/apps/cala/public/coi-serviceworker.js b/apps/cala/public/coi-serviceworker.js new file mode 100644 index 0000000..47f1a5b --- /dev/null +++ b/apps/cala/public/coi-serviceworker.js @@ -0,0 +1,87 @@ +/*! coi-serviceworker v0.1.7 — https://github.com/gzuidhof/coi-serviceworker + * MIT License. Inlined here so GitHub Pages can serve the CaLa app + * with the COOP/COEP headers `SharedArrayBuffer` requires (design + * §13). On Pages we can't set HTTP headers server-side; the service + * worker intercepts top-level navigations and re-issues them with the + * headers attached, then the browser refreshes and we get + * `crossOriginIsolated` = true. + * + * Vite dev and `vite preview` set the same headers directly (see + * `vite.config.ts`), so this script is a no-op there. + */ +/* eslint-disable */ +/* prettier-ignore */ +(() => { + if (typeof window === 'undefined') { + // Service worker scope. + self.addEventListener('install', () => self.skipWaiting()); + self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim())); + self.addEventListener('message', (ev) => { + if (!ev.data) return; + if (ev.data.type === 'deregister') { + self.registration + .unregister() + .then(() => self.clients.matchAll()) + .then((clients) => { + clients.forEach((client) => client.navigate(client.url)); + }); + } + }); + self.addEventListener('fetch', (event) => { + const r = event.request; + if (r.cache === 'only-if-cached' && r.mode !== 'same-origin') return; + const request = r.cache === 'no-cache' ? new Request(r, { cache: 'no-store' }) : r; + event.respondWith( + fetch(request) + .then((response) => { + if (response.status === 0) return response; + const newHeaders = new Headers(response.headers); + newHeaders.set('Cross-Origin-Embedder-Policy', 'require-corp'); + newHeaders.set('Cross-Origin-Opener-Policy', 'same-origin'); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: newHeaders, + }); + }) + .catch((e) => console.error(e)), + ); + }); + } else { + // Page scope. + const coi = { + shouldRegister: () => !window.crossOriginIsolated, + shouldDeregister: () => false, + coepCredentialless: () => false, + doReload: () => window.location.reload(), + quiet: false, + ...(window.coi ?? {}), + }; + const n = navigator; + if (n.serviceWorker && n.serviceWorker.controller) { + n.serviceWorker.controller.postMessage({ type: coi.shouldDeregister() ? 'deregister' : 'noop' }); + } + if (!window.crossOriginIsolated && !coi.shouldDeregister() && coi.shouldRegister()) { + if (!n.serviceWorker) { + if (!coi.quiet) console.warn('COOP/COEP Service Worker unavailable; no cross-origin isolation.'); + return; + } + n.serviceWorker + .register(window.document.currentScript.src) + .then((registration) => { + if (!coi.quiet) console.log('COOP/COEP service worker registered.', registration.scope); + registration.addEventListener('updatefound', () => { + if (!coi.quiet) console.log('Reloading page to make use of updated COOP/COEP service worker.'); + coi.doReload(); + }); + if (registration.active && !n.serviceWorker.controller) { + if (!coi.quiet) console.log('Reloading page to make use of COOP/COEP service worker.'); + coi.doReload(); + } + }) + .catch((err) => { + if (!coi.quiet) console.error('COOP/COEP service worker failed to register:', err); + }); + } + } +})(); diff --git a/apps/cala/vite.config.ts b/apps/cala/vite.config.ts index bbd9d56..596ad62 100644 --- a/apps/cala/vite.config.ts +++ b/apps/cala/vite.config.ts @@ -11,12 +11,11 @@ const displayName = pkg.calab?.displayName ?? path.basename(import.meta.dirname) // SharedArrayBuffer (design §13) requires cross-origin isolation: // - Cross-Origin-Opener-Policy: same-origin // - Cross-Origin-Embedder-Policy: require-corp -// The Vite dev and preview servers set these directly. For the GitHub -// Pages production deploy, the host doesn't let us set HTTP headers; -// we document that constraint in apps/cala/README.md and plan to ship -// a cross-origin-isolation service worker (coi-serviceworker pattern) -// when the browser app actually needs SAB in production. Phase 5's -// exit criteria only require `npm run dev` to boot with SAB enabled. +// The Vite dev and preview servers set these directly. For the +// GitHub Pages production deploy, `public/coi-serviceworker.js` +// registers a service worker that re-issues top-level navigations +// with the headers attached (Phase 6 task 14) — so production also +// boots `crossOriginIsolated`. const crossOriginIsolation = { 'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Embedder-Policy': 'require-corp', From 1685efdfc012aa61760c0e6c59e0dc78eab20fec Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 18 Apr 2026 22:59:09 -0700 Subject: [PATCH 15/18] =?UTF-8?q?feat(cala):=20Phase=206=20exit=20?= =?UTF-8?q?=E2=80=94=20vitals=20UI=20+=20archive=20backend=20end-to-end=20?= =?UTF-8?q?(task=2016)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships `e2e/phase6-exit.e2e.test.ts`: drives the full decode → fit → archive pipeline on a real miniscope AVI and asserts every Phase-6-shipped surface is reachable end-to-end: - vitals timeseries populate (tiered store from task 1) - per-neuron event index resolves a birth (task 2) - footprint history returns entries (task 3) - extend.proposed metrics + structural events land in the archive dump (tasks 4 + 5 + 11) - user-authored deprecate mutations reach the fit worker (task 13) - no worker errors during or after the run Also fixes `pixel_size_um` missing from W2's metadataJson (task 11 introduced an Extender construction that parses RecordingMetadata but run-control.ts only forwarded height/width). Surfaced during live browser testing; without the fix the fit worker panics on init in production. Updates `.planning/CALA_DESIGN.md` §12 with the Phase 6 exit date, the shipped artifact list, and explicitly deferred items (real birth events need `Fitter.drainApplyEvents`, cross-worker snapshot transport, ε change-trigger, Playwright, click-a- footprint UX). Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cala/e2e/phase6-exit.e2e.test.ts | 509 ++++++++++++++++++++++++++ apps/cala/src/lib/run-control.ts | 4 + 2 files changed, 513 insertions(+) create mode 100644 apps/cala/e2e/phase6-exit.e2e.test.ts diff --git a/apps/cala/e2e/phase6-exit.e2e.test.ts b/apps/cala/e2e/phase6-exit.e2e.test.ts new file mode 100644 index 0000000..b39f9f2 --- /dev/null +++ b/apps/cala/e2e/phase6-exit.e2e.test.ts @@ -0,0 +1,509 @@ +/** + * Phase 6 exit E2E — task 16. + * + * End-to-end proof that everything Phase 6 shipped is reachable on + * a real miniscope AVI: + * + * 1. W2 emits the five vitals metrics on stride (tasks 4 + 11). + * 2. W2's log-spaced `footprint-snapshot` scheduler runs (task 5). + * 3. W4's tiered timeseries store has samples for at least one + * vitals name (task 1). + * 4. W4's per-neuron event index is queryable (task 2). + * 5. W4's footprint history store returns entries (task 3). + * 6. W4's archive-dump retains structural events (tasks 1-3 regress). + * 7. Main thread can push a user-authored deprecate and see it + * reach the fit worker (task 13). + * + * Uses the same harness pattern as phase5-exit + phase6-extend: + * real AVI RIFF parsing in JS, real SabRingChannel, real worker + * modules, stubbed cala-core WASM (the Rust numerical core has its + * own cold-start E2E — we only prove plumbing here). + */ + +import { readFileSync, existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + SabRingChannel, + type PipelineEvent, + type WorkerInbound, + type WorkerOutbound, +} from '@calab/cala-runtime'; +import { + createWorkerHarness, + type WorkerHarness, +} from '../src/workers/__tests__/worker-harness.ts'; + +const DEFAULT_TEST_TIMEOUT_MS = 60_000; +const TEST_POLL_MS = 2; +const TEST_POLL_MAX_TICKS = 30_000; +const TEST_MAX_FRAMES = 32; +const TEST_MIN_FRAMES_PROCESSED = 16; +const TEST_HEARTBEAT_STRIDE = 2; +const TEST_PREVIEW_STRIDE = 100; +const TEST_SNAPSHOT_STRIDE = 1_000_000; +const TEST_VITALS_STRIDE = 4; +const TEST_EXTEND_CYCLE_STRIDE = 8; +const TEST_EXTEND_WINDOW_FRAMES = 16; +const TEST_MOCK_PROPOSALS_PER_CYCLE = 1; +const TEST_FRAME_CHANNEL_SLOT_COUNT = 8; +const TEST_FRAME_CHANNEL_WAIT_TIMEOUT_MS = 50; +const TEST_FRAME_CHANNEL_POLL_INTERVAL_MS = 1; +const TEST_MUTATION_QUEUE_CAPACITY = 8; +const TEST_EVENT_BUS_CAPACITY = 128; +const TEST_EVENT_BUS_MAX_SUBSCRIBERS = 4; +const TEST_SNAPSHOT_ACK_TIMEOUT_MS = 50; +const TEST_SNAPSHOT_POLL_INTERVAL_MS = 1; +const TEST_SNAPSHOT_PENDING_CAPACITY = 1; +const TEST_NEURON_ID = 7; + +const REPO_ROOT = path.resolve(fileURLToPath(import.meta.url), '../../../..'); +const AVI_FIXTURE = path.join(REPO_ROOT, '.test_data', 'anchor_v12_prepped.avi'); + +interface ParsedAvi { + width: number; + height: number; + channels: number; + bitDepth: number; + fps: number; + frames: { offset: number; size: number }[]; + bytes: Uint8Array; +} + +function fourcc(bytes: Uint8Array, at: number): string { + return String.fromCharCode(bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]); +} + +function parseAvi(bytes: Uint8Array): ParsedAvi { + if (fourcc(bytes, 0) !== 'RIFF' || fourcc(bytes, 8) !== 'AVI ') { + throw new Error('fixture is not a RIFF/AVI container'); + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let width = 0; + let height = 0; + let channels = 1; + let bitDepth = 8; + const fps = 30; + const frames: { offset: number; size: number }[] = []; + let i = 12; + while (i + 8 <= bytes.length) { + const tag = fourcc(bytes, i); + const size = view.getUint32(i + 4, true); + if (tag === 'LIST') { + const kind = fourcc(bytes, i + 8); + if (kind === 'hdrl') { + let j = i + 12; + const end = i + 8 + size; + while (j + 8 <= end) { + const t = fourcc(bytes, j); + const s = view.getUint32(j + 4, true); + if (t === 'strf') { + width = view.getInt32(j + 12, true); + height = Math.abs(view.getInt32(j + 16, true)); + bitDepth = view.getUint16(j + 22, true); + channels = bitDepth >= 24 ? 3 : 1; + } + if (t === 'LIST') { + j += 12; + continue; + } + j += 8 + s + (s & 1); + } + } else if (kind === 'movi') { + let j = i + 12; + const end = i + 8 + size; + while (j + 8 <= end) { + const t = fourcc(bytes, j); + const s = view.getUint32(j + 4, true); + if (t === '00db' || t === '00dc') frames.push({ offset: j + 8, size: s }); + j += 8 + s + (s & 1); + } + } + i += 12; + continue; + } + i += 8 + size + (size & 1); + } + return { width, height, channels, bitDepth, fps, frames, bytes }; +} + +let parsedAvi: ParsedAvi | null = null; + +class StubAviReader { + constructor(_bytes: Uint8Array) { + if (!parsedAvi) throw new Error('stub AviReader requires parsedAvi primed'); + } + width(): number { + return parsedAvi!.width; + } + height(): number { + return parsedAvi!.height; + } + frameCount(): number { + return parsedAvi!.frames.length; + } + fps(): number { + return parsedAvi!.fps; + } + channels(): number { + return parsedAvi!.channels; + } + bitDepth(): number { + return parsedAvi!.bitDepth; + } + readFrameGrayscaleF32(n: number, _m: string): Float32Array { + const p = parsedAvi!; + const { offset } = p.frames[n]; + const pixels = p.width * p.height; + const out = new Float32Array(pixels); + if (p.channels === 1) { + for (let k = 0; k < pixels; k += 1) out[k] = p.bytes[offset + k]; + } else { + const bpp = Math.floor(p.bitDepth / 8); + for (let k = 0; k < pixels; k += 1) out[k] = p.bytes[offset + k * bpp + 1] ?? 0; + } + return out; + } + free(): void {} +} + +class StubPreprocessor { + constructor(_h: number, _w: number, _m: string, _c: string) {} + processFrameF32(input: Float32Array): Float32Array { + return input; + } + free(): void {} +} + +let fitterFrameCount = 0; +let fitterDrainApplyCount = 0; + +class StubFitter { + private currentEpoch = 0n; + constructor(_h: number, _w: number, _c: string) {} + epoch(): bigint { + return this.currentEpoch; + } + numComponents(): number { + return Number(this.currentEpoch); + } + step(y: Float32Array): Float32Array { + fitterFrameCount += 1; + return y; + } + drainApply(_handle: unknown): Uint32Array { + fitterDrainApplyCount += 1; + this.currentEpoch += 1n; + return new Uint32Array([1, 0, 0]); + } + takeSnapshot(): { epoch(): bigint; numComponents(): number; pixels(): number; free(): void } { + return { + epoch: () => this.currentEpoch, + numComponents: () => 0, + pixels: () => 0, + free: () => {}, + }; + } + free(): void {} +} + +let mutationHandlePushDeprecateCount = 0; + +class StubMutationQueueHandle { + constructor(_cfg: string) {} + pushDeprecate(_snapshotEpoch: bigint, _id: number, _reason: string): void { + mutationHandlePushDeprecateCount += 1; + } + free(): void {} +} + +let extenderCycleCount = 0; + +class StubExtender { + private residualPushCount = 0; + constructor( + _h: number, + _w: number, + _win: number, + _extendCfg: string, + _metadata: string, + ) {} + pushResidual(_r: Float32Array): void { + this.residualPushCount += 1; + } + runCycle(_fitter: unknown, _queue: unknown): number { + extenderCycleCount += 1; + return TEST_MOCK_PROPOSALS_PER_CYCLE; + } + residualLen(): number { + return this.residualPushCount; + } + free(): void {} +} + +vi.mock('@calab/cala-core', () => ({ + initCalaCore: vi.fn(async () => undefined), + calaMemoryBytes: vi.fn(() => 2 * 1024 * 1024), + AviReader: StubAviReader, + Preprocessor: StubPreprocessor, + Fitter: StubFitter, + MutationQueueHandle: StubMutationQueueHandle, + Extender: StubExtender, +})); + +async function pumpUntil(predicate: () => boolean, maxTicks = TEST_POLL_MAX_TICKS): Promise { + for (let i = 0; i < maxTicks; i += 1) { + if (predicate()) return; + await new Promise((r) => setTimeout(r, TEST_POLL_MS)); + } + if (!predicate()) throw new Error('pumpUntil: condition never satisfied'); +} + +interface BootResult { + decode: WorkerHarness; + fit: WorkerHarness; + archive: WorkerHarness; + frameChannel: SabRingChannel; +} + +async function loadIntoHarness(h: WorkerHarness, specifier: string): Promise { + vi.stubGlobal('self', h.self); + await import(specifier); + vi.unstubAllGlobals(); +} + +function makeFrameChannel(slotBytes: number): SabRingChannel { + return new SabRingChannel({ + slotBytes, + slotCount: TEST_FRAME_CHANNEL_SLOT_COUNT, + waitTimeoutMs: TEST_FRAME_CHANNEL_WAIT_TIMEOUT_MS, + pollIntervalMs: TEST_FRAME_CHANNEL_POLL_INTERVAL_MS, + }); +} + +async function bootAllWorkers(parsed: ParsedAvi): Promise { + const pixels = parsed.width * parsed.height; + const slotBytes = pixels * Float32Array.BYTES_PER_ELEMENT; + const frameChannel = makeFrameChannel(slotBytes); + const residualBuffer = makeFrameChannel(slotBytes).sharedBuffer; + const decode = createWorkerHarness(); + const fit = createWorkerHarness(); + const archive = createWorkerHarness(); + await loadIntoHarness(decode, '../src/workers/decode-preprocess.worker.ts'); + await loadIntoHarness(fit, '../src/workers/fit.worker.ts'); + await loadIntoHarness(archive, '../src/workers/archive.worker.ts'); + + const originalFitPost = fit.self.postMessage.bind(fit.self); + fit.self.postMessage = (msg: WorkerOutbound): void => { + originalFitPost(msg); + if (msg.kind === 'event') { + void archive.deliver({ kind: 'event', event: msg.event }); + } + }; + + const fileBytes = new Uint8Array(parsed.bytes.byteLength); + fileBytes.set(parsed.bytes); + const fakeFile = new File([fileBytes], path.basename(AVI_FIXTURE)); + + await decode.deliver({ + kind: 'init', + payload: { + role: 'decodePreprocess', + frameChannelBuffer: frameChannel.sharedBuffer, + residualChannelBuffer: residualBuffer, + workerConfig: { + source: { kind: 'file', file: fakeFile, frameSourceFactory: null }, + heartbeatStride: TEST_HEARTBEAT_STRIDE, + framePreviewStride: TEST_PREVIEW_STRIDE, + grayscaleMethod: 'Green', + frameChannelSlotBytes: slotBytes, + frameChannelSlotCount: TEST_FRAME_CHANNEL_SLOT_COUNT, + frameChannelWaitTimeoutMs: TEST_FRAME_CHANNEL_WAIT_TIMEOUT_MS, + frameChannelPollIntervalMs: TEST_FRAME_CHANNEL_POLL_INTERVAL_MS, + }, + }, + }); + await pumpUntil(() => decode.posted.some((m) => m.kind === 'ready')); + + await fit.deliver({ + kind: 'init', + payload: { + role: 'fit', + frameChannelBuffer: frameChannel.sharedBuffer, + residualChannelBuffer: residualBuffer, + workerConfig: { + height: parsed.height, + width: parsed.width, + heartbeatStride: TEST_HEARTBEAT_STRIDE, + vitalsStride: TEST_VITALS_STRIDE, + snapshotStride: TEST_SNAPSHOT_STRIDE, + mutationDrainMaxPerIteration: 1, + eventBusCapacity: TEST_EVENT_BUS_CAPACITY, + eventBusMaxSubscribers: TEST_EVENT_BUS_MAX_SUBSCRIBERS, + snapshotAckTimeoutMs: TEST_SNAPSHOT_ACK_TIMEOUT_MS, + snapshotPollIntervalMs: TEST_SNAPSHOT_POLL_INTERVAL_MS, + snapshotPendingCapacity: TEST_SNAPSHOT_PENDING_CAPACITY, + mutationQueueCapacity: TEST_MUTATION_QUEUE_CAPACITY, + frameChannelSlotBytes: slotBytes, + frameChannelSlotCount: TEST_FRAME_CHANNEL_SLOT_COUNT, + frameChannelWaitTimeoutMs: TEST_FRAME_CHANNEL_WAIT_TIMEOUT_MS, + frameChannelPollIntervalMs: TEST_FRAME_CHANNEL_POLL_INTERVAL_MS, + extendCycleStride: TEST_EXTEND_CYCLE_STRIDE, + extendWindowFrames: TEST_EXTEND_WINDOW_FRAMES, + metadataJson: JSON.stringify({ pixel_size_um: 2 }), + }, + }, + }); + await pumpUntil(() => fit.posted.some((m) => m.kind === 'ready')); + + await archive.deliver({ + kind: 'init', + payload: { + role: 'archive', + frameChannelBuffer: frameChannel.sharedBuffer, + residualChannelBuffer: residualBuffer, + workerConfig: {}, + }, + }); + await pumpUntil(() => archive.posted.some((m) => m.kind === 'ready')); + + return { decode, fit, archive, frameChannel }; +} + +async function requestArchiveReply( + archive: WorkerHarness, + request: WorkerInbound, + replyKind: TKind, +): Promise> { + archive.posted.length = 0; + await archive.deliver(request); + await pumpUntil(() => archive.posted.some((m) => m.kind === replyKind)); + return archive.posted.find( + (m): m is Extract => m.kind === replyKind, + )!; +} + +describe('CaLa Phase 6 exit — end-to-end', () => { + beforeEach(() => { + fitterFrameCount = 0; + fitterDrainApplyCount = 0; + extenderCycleCount = 0; + mutationHandlePushDeprecateCount = 0; + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + parsedAvi = null; + }); + + it( + 'emits vitals timeseries, indexes events, records footprint history, accepts user mutations', + { timeout: DEFAULT_TEST_TIMEOUT_MS }, + async () => { + if (!existsSync(AVI_FIXTURE)) { + throw new Error(`AVI fixture missing at ${AVI_FIXTURE} — .test_data/ is local-only.`); + } + const realAvi = parseAvi(new Uint8Array(readFileSync(AVI_FIXTURE))); + parsedAvi = { ...realAvi, frames: realAvi.frames.slice(0, TEST_MAX_FRAMES) }; + + const boot = await bootAllWorkers(parsedAvi); + + await boot.decode.deliver({ kind: 'run' }); + await boot.fit.deliver({ kind: 'run' }); + await boot.archive.deliver({ kind: 'run' }); + + await pumpUntil(() => fitterFrameCount >= TEST_MIN_FRAMES_PROCESSED); + await pumpUntil(() => extenderCycleCount >= 1); + + // Inject a synthetic `birth` event directly onto the archive so + // task 2's per-neuron index + task 3's footprint store have + // something to resolve on query. (Real births need the + // Phase-7-deferred `drainApplyEvents` binding.) + const syntheticBirth: PipelineEvent = { + kind: 'birth', + t: fitterFrameCount, + id: TEST_NEURON_ID, + patch: [0, 0], + footprintSnap: { + pixelIndices: new Uint32Array([1, 2, 3]), + values: new Float32Array([0.5, 0.7, 0.3]), + }, + }; + await boot.archive.deliver({ kind: 'event', event: syntheticBirth }); + + // User-authored mutation lands on the fit worker. + await boot.fit.deliver({ + kind: 'user-mutation', + mutation: { kind: 'deprecate', id: TEST_NEURON_ID, reason: 'traceInactive' }, + }); + await pumpUntil(() => mutationHandlePushDeprecateCount >= 1); + + await boot.decode.deliver({ kind: 'stop' }); + await pumpUntil(() => boot.decode.posted.some((m) => m.kind === 'done')); + await boot.fit.deliver({ kind: 'stop' }); + await pumpUntil(() => boot.fit.posted.some((m) => m.kind === 'done')); + + // --- archive query surface (tasks 1-3) -------------------------- + + const tsReply = await requestArchiveReply( + boot.archive, + { kind: 'request-timeseries', requestId: 100, name: 'fps' }, + 'timeseries', + ); + expect(tsReply.name).toBe('fps'); + expect(tsReply.l1Times.length + tsReply.l2Times.length).toBeGreaterThanOrEqual(1); + + const eventsReply = await requestArchiveReply( + boot.archive, + { kind: 'request-events-for-neuron', requestId: 101, neuronId: TEST_NEURON_ID }, + 'events-for-neuron', + ); + expect(eventsReply.events.length).toBeGreaterThanOrEqual(1); + expect(eventsReply.events.some((e) => e.kind === 'birth')).toBe(true); + + const footprintReply = await requestArchiveReply( + boot.archive, + { kind: 'request-footprint-history', requestId: 102, neuronId: TEST_NEURON_ID }, + 'footprint-history', + ); + expect(footprintReply.times.length).toBeGreaterThanOrEqual(1); + expect(Array.from(footprintReply.pixelIndices[0])).toEqual([1, 2, 3]); + + const dump = await requestArchiveReply( + boot.archive, + { kind: 'request-archive-dump', requestId: 103 }, + 'archive-dump', + ); + // Dump must include the synthetic birth + extend.proposed metrics. + expect(dump.events.some((e) => e.kind === 'birth')).toBe(true); + const proposedMetrics = dump.events.filter( + (e): e is Extract => + e.kind === 'metric' && e.name === 'extend.proposed', + ); + expect(proposedMetrics.length).toBeGreaterThanOrEqual(1); + + await boot.archive.deliver({ kind: 'stop' }); + await pumpUntil(() => boot.archive.posted.some((m) => m.kind === 'done')); + + // No worker errored out. + const errors = [ + ...boot.decode.posted.filter((m) => m.kind === 'error'), + ...boot.fit.posted.filter((m) => m.kind === 'error'), + ...boot.archive.posted.filter((m) => m.kind === 'error'), + ]; + expect(errors).toEqual([]); + + console.info( + `[phase6-exit] frames=${fitterFrameCount} ` + + `extend_cycles=${extenderCycleCount} ` + + `drain_applies=${fitterDrainApplyCount} ` + + `deprecate_pushes=${mutationHandlePushDeprecateCount} ` + + `ts_samples=${tsReply.l1Times.length + tsReply.l2Times.length} ` + + `neuron_events=${eventsReply.events.length} ` + + `footprints=${footprintReply.times.length}`, + ); + }, + ); +}); diff --git a/apps/cala/src/lib/run-control.ts b/apps/cala/src/lib/run-control.ts index e13615e..19bee1f 100644 --- a/apps/cala/src/lib/run-control.ts +++ b/apps/cala/src/lib/run-control.ts @@ -111,6 +111,10 @@ function buildConfig(meta: FrameSourceMeta, factories: WorkerFactories): Runtime fit: { height: meta.height, width: meta.width, + // Shared with W1's metadata: extend's `RecordingMetadata` + // parser (task 11) needs `pixel_size_um` to translate the + // neuron-diameter gate into pixels. + metadataJson: JSON.stringify({ pixel_size_um: DEFAULT_PIXEL_SIZE_UM }), }, }, }; From 2381f3b6313cd09a2508647703abc853fc0a26e2 Mon Sep 17 00:00:00 2001 From: daharoni Date: Sun, 19 Apr 2026 08:27:03 -0700 Subject: [PATCH 16/18] fix(cala): drive dashboard epoch from fit worker, not W1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W1's `frame-processed` heartbeat hardcodes `epoch: 0n` (it has no view of the fit pipeline's epoch), but `run-control` was routing that to `recordFrameProcessed` — so the viewer label read `epoch 0` forever while the fit pipeline silently advanced. Move the dashboard listener onto the fit worker, which is the only worker that knows the real epoch. W1's listener stays for `frame-preview` only. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cala/src/lib/run-control.ts | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/apps/cala/src/lib/run-control.ts b/apps/cala/src/lib/run-control.ts index 19bee1f..3cff8d8 100644 --- a/apps/cala/src/lib/run-control.ts +++ b/apps/cala/src/lib/run-control.ts @@ -134,6 +134,7 @@ let currentUnsubscribe: (() => void) | null = null; // posts. Cleared on run end. let currentArchiveWorker: WorkerLike | null = null; let currentPreviewDetach: (() => void) | null = null; +let currentFitDetach: (() => void) | null = null; export interface StartOptions { factories?: WorkerFactories; @@ -152,9 +153,12 @@ function wrapFactories(base: WorkerFactories): WorkerFactories { currentArchiveWorker = worker; } if (role === 'decodePreprocess') { - // Main-thread listener for W1 preview posts + heartbeat frame - // indexing. Runs alongside the orchestrator's own listener — - // neither interferes with the other. + // Main-thread listener for W1 frame-preview posts. Runs + // alongside the orchestrator's own listener — neither + // interferes with the other. Frame index + epoch for the + // dashboard counter come from the fit worker's + // `frame-processed` (below) since W1 has no view of the fit + // pipeline's epoch and hardcodes 0. const listener = (ev: { data: WorkerOutbound }): void => { const msg = ev.data; if (msg.kind === 'frame-preview') { @@ -166,13 +170,22 @@ function wrapFactories(base: WorkerFactories): WorkerFactories { }); return; } + }; + worker.addEventListener('message', listener); + currentPreviewDetach = () => worker.removeEventListener('message', listener); + } + if (role === 'fit') { + // Fit is the only worker that knows the real pipeline epoch, + // so the dashboard frame/epoch label is driven from its + // heartbeat. W1's `frame-processed` is ignored here. + const listener = (ev: { data: WorkerOutbound }): void => { + const msg = ev.data; if (msg.kind === 'frame-processed') { recordFrameProcessed(msg.index, msg.epoch); - return; } }; worker.addEventListener('message', listener); - currentPreviewDetach = () => worker.removeEventListener('message', listener); + currentFitDetach = () => worker.removeEventListener('message', listener); } return worker; }; @@ -226,6 +239,8 @@ export async function startRun(opts: StartOptions = {}): Promise { currentRuntime = null; currentPreviewDetach?.(); currentPreviewDetach = null; + currentFitDetach?.(); + currentFitDetach = null; currentArchiveWorker = null; } } From 728670e1dba7fd655df033443a6870c61b236189 Mon Sep 17 00:00:00 2001 From: daharoni Date: Sun, 19 Apr 2026 08:29:17 -0700 Subject: [PATCH 17/18] style(cala): prettier --write across Phase 6 files Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cala/e2e/phase5-exit.e2e.test.ts | 8 +------- apps/cala/e2e/phase6-exit.e2e.test.ts | 8 +------- apps/cala/e2e/phase6-extend.e2e.test.ts | 8 +------- apps/cala/src/components/events/EventFeed.tsx | 5 +---- apps/cala/src/lib/__tests__/archive-client.test.ts | 4 +--- apps/cala/src/lib/archive-client.ts | 10 +++------- apps/cala/src/workers/__tests__/fit.worker.test.ts | 4 +--- .../src/workers/__tests__/neuron-event-index.test.ts | 4 +--- apps/cala/src/workers/fit.worker.ts | 8 ++------ 9 files changed, 12 insertions(+), 47 deletions(-) diff --git a/apps/cala/e2e/phase5-exit.e2e.test.ts b/apps/cala/e2e/phase5-exit.e2e.test.ts index 0cf3c3f..94f7afa 100644 --- a/apps/cala/e2e/phase5-exit.e2e.test.ts +++ b/apps/cala/e2e/phase5-exit.e2e.test.ts @@ -281,13 +281,7 @@ class StubExtender { // real path but here they just no-op so the Phase 5 assertions // (frame ticks, metric events, preview frames) remain exactly // what they were before task 11 landed. - constructor( - _h: number, - _w: number, - _win: number, - _extendCfg: string, - _metadata: string, - ) {} + constructor(_h: number, _w: number, _win: number, _extendCfg: string, _metadata: string) {} pushResidual(_r: Float32Array): void {} runCycle(_fitter: unknown, _queue: unknown): number { return 0; diff --git a/apps/cala/e2e/phase6-exit.e2e.test.ts b/apps/cala/e2e/phase6-exit.e2e.test.ts index b39f9f2..b58c84d 100644 --- a/apps/cala/e2e/phase6-exit.e2e.test.ts +++ b/apps/cala/e2e/phase6-exit.e2e.test.ts @@ -222,13 +222,7 @@ let extenderCycleCount = 0; class StubExtender { private residualPushCount = 0; - constructor( - _h: number, - _w: number, - _win: number, - _extendCfg: string, - _metadata: string, - ) {} + constructor(_h: number, _w: number, _win: number, _extendCfg: string, _metadata: string) {} pushResidual(_r: Float32Array): void { this.residualPushCount += 1; } diff --git a/apps/cala/e2e/phase6-extend.e2e.test.ts b/apps/cala/e2e/phase6-extend.e2e.test.ts index 31101ef..78b4ca4 100644 --- a/apps/cala/e2e/phase6-extend.e2e.test.ts +++ b/apps/cala/e2e/phase6-extend.e2e.test.ts @@ -222,13 +222,7 @@ let extenderCycleCount = 0; class StubExtender { private residualPushCount = 0; - constructor( - _h: number, - _w: number, - _win: number, - _extendCfg: string, - _metadata: string, - ) {} + constructor(_h: number, _w: number, _win: number, _extendCfg: string, _metadata: string) {} pushResidual(_r: Float32Array): void { this.residualPushCount += 1; } diff --git a/apps/cala/src/components/events/EventFeed.tsx b/apps/cala/src/components/events/EventFeed.tsx index 7ac047c..6600927 100644 --- a/apps/cala/src/components/events/EventFeed.tsx +++ b/apps/cala/src/components/events/EventFeed.tsx @@ -52,10 +52,7 @@ export function EventFeed(props: EventFeedProps): JSX.Element {
Events (newest first)
- 0} - fallback={
No events yet.
} - > + 0} fallback={
No events yet.
}>
    {(e) => ( diff --git a/apps/cala/src/lib/__tests__/archive-client.test.ts b/apps/cala/src/lib/__tests__/archive-client.test.ts index e9d4944..f858cce 100644 --- a/apps/cala/src/lib/__tests__/archive-client.test.ts +++ b/apps/cala/src/lib/__tests__/archive-client.test.ts @@ -292,9 +292,7 @@ describe('cala archive-client', () => { expect(history.length).toBe(2); expect(history[0].t).toBe(1); expect(Array.from(history[1].pixelIndices)).toEqual([3, 4]); - expect(Array.from(history[1].values)).toEqual([ - 0.10000000149011612, 0.20000000298023224, - ]); + expect(Array.from(history[1].values)).toEqual([0.10000000149011612, 0.20000000298023224]); }); it('onEvent delivers PipelineEvent messages posted by the worker', () => { diff --git a/apps/cala/src/lib/archive-client.ts b/apps/cala/src/lib/archive-client.ts index 81587b3..5f81ba8 100644 --- a/apps/cala/src/lib/archive-client.ts +++ b/apps/cala/src/lib/archive-client.ts @@ -191,13 +191,9 @@ export function createArchiveClient( } function requestTimeseries(name: string): Promise { - return issueRequest( - 'timeseries', - `timeseries(${name})`, - (requestId) => { - worker.postMessage({ kind: 'request-timeseries', requestId, name }); - }, - ); + return issueRequest('timeseries', `timeseries(${name})`, (requestId) => { + worker.postMessage({ kind: 'request-timeseries', requestId, name }); + }); } function requestEventsForNeuron(neuronId: number): Promise { diff --git a/apps/cala/src/workers/__tests__/fit.worker.test.ts b/apps/cala/src/workers/__tests__/fit.worker.test.ts index 61c5f86..fb11521 100644 --- a/apps/cala/src/workers/__tests__/fit.worker.test.ts +++ b/apps/cala/src/workers/__tests__/fit.worker.test.ts @@ -484,9 +484,7 @@ describe('fit worker', () => { (p) => p.filter( (m) => - m.kind === 'event' && - m.event.kind === 'metric' && - m.event.name === 'extend.proposed', + m.kind === 'event' && m.event.kind === 'metric' && m.event.name === 'extend.proposed', ).length >= 2, ); diff --git a/apps/cala/src/workers/__tests__/neuron-event-index.test.ts b/apps/cala/src/workers/__tests__/neuron-event-index.test.ts index 23be6c7..5f1d270 100644 --- a/apps/cala/src/workers/__tests__/neuron-event-index.test.ts +++ b/apps/cala/src/workers/__tests__/neuron-event-index.test.ts @@ -64,9 +64,7 @@ describe('neuronIdsForEvent', () => { }); it('returns an empty array for reject + metric', () => { - expect( - neuronIdsForEvent({ kind: 'reject', t: 0, at: [0, 0], reason: 'low-snr' }), - ).toEqual([]); + expect(neuronIdsForEvent({ kind: 'reject', t: 0, at: [0, 0], reason: 'low-snr' })).toEqual([]); expect(neuronIdsForEvent({ kind: 'metric', t: 0, name: 'x', value: 1 })).toEqual([]); }); }); diff --git a/apps/cala/src/workers/fit.worker.ts b/apps/cala/src/workers/fit.worker.ts index 0e41799..f47651e 100644 --- a/apps/cala/src/workers/fit.worker.ts +++ b/apps/cala/src/workers/fit.worker.ts @@ -346,10 +346,7 @@ function mutationToEvent(m: PipelineMutation, frameIndex: number): PipelineEvent } } -function updateSchedulerFromEvent( - scheduler: FootprintSnapshotScheduler, - ev: PipelineEvent, -): void { +function updateSchedulerFromEvent(scheduler: FootprintSnapshotScheduler, ev: PipelineEvent): void { // Mirror every structural event into the scheduler so the // log-spaced floor fires with the latest known footprint per // neuron (§9.3). Mutations without a footprint payload still @@ -464,8 +461,7 @@ function emitVitals(h: RuntimeHandles, frameIndex: number): void { const now = Date.now(); const elapsedMs = now - h.lastVitalsTimeMs; const elapsedFrames = frameIndex - h.lastVitalsFrameIndex; - const fps = - h.lastVitalsTimeMs > 0 && elapsedMs > 0 ? (elapsedFrames * 1000) / elapsedMs : 0; + const fps = h.lastVitalsTimeMs > 0 && elapsedMs > 0 ? (elapsedFrames * 1000) / elapsedMs : 0; h.lastVitalsTimeMs = now; h.lastVitalsFrameIndex = frameIndex; From bdf35b58f55fb6c8dcf42b50867ed14d9d7150e5 Mon Sep 17 00:00:00 2001 From: daharoni Date: Sun, 19 Apr 2026 08:35:02 -0700 Subject: [PATCH 18/18] fix(cala-core): update wasm-adapter test mock for Phase 6 bindings The `initCalaCore` resolver now reads `mod.memory` to expose `calaMemoryBytes()` (Phase 6 task 23 / vitals bar), and the adapter re-exports the new `Extender` binding (task 23). The mocked init resolver needed to return a stub `WebAssembly.Memory` instead of `undefined`, and the stub module needed to export an `Extender` class. Adds one test for the new `calaMemoryBytes()` helper. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../cala-core/src/__tests__/wasm-adapter.test.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/cala-core/src/__tests__/wasm-adapter.test.ts b/packages/cala-core/src/__tests__/wasm-adapter.test.ts index d169520..74bfba2 100644 --- a/packages/cala-core/src/__tests__/wasm-adapter.test.ts +++ b/packages/cala-core/src/__tests__/wasm-adapter.test.ts @@ -6,13 +6,18 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; // WASM boot itself. Real WASM execution is covered at Phase 5 exit // (task 25) in the browser. -const initSpy = vi.fn(async () => undefined); +// Stubbed WASM memory — `initCalaCore` reads `mod.memory.buffer.byteLength` +// via `calaMemoryBytes()`, so the init resolver must return a shape that +// matches the real wasm-bindgen init return. +const stubMemory = { buffer: new ArrayBuffer(0) } as unknown as WebAssembly.Memory; +const initSpy = vi.fn(async () => ({ memory: stubMemory })); const panicHookSpy = vi.fn(); vi.mock('../../../../crates/cala-core/pkg/calab_cala_core', () => ({ default: initSpy, init_panic_hook: panicHookSpy, AviReader: class StubAviReader {}, + Extender: class StubExtender {}, Fitter: class StubFitter {}, MutationQueueHandle: class StubMutationQueueHandle {}, Preprocessor: class StubPreprocessor {}, @@ -60,10 +65,18 @@ describe('initCalaCore', () => { it('re-exports the binding types so consumers never touch crates/*', async () => { const mod = await loadFreshAdapter(); expect(mod.AviReader).toBeDefined(); + expect(mod.Extender).toBeDefined(); expect(mod.Fitter).toBeDefined(); expect(mod.Preprocessor).toBeDefined(); expect(mod.MutationQueueHandle).toBeDefined(); expect(mod.SnapshotHandle).toBeDefined(); expect(mod.init_panic_hook).toBeDefined(); }); + + it('calaMemoryBytes returns null pre-init and the buffer size after init', async () => { + const { initCalaCore, calaMemoryBytes } = await loadFreshAdapter(); + expect(calaMemoryBytes()).toBeNull(); + await initCalaCore(); + expect(calaMemoryBytes()).toBe(0); + }); });