diff --git a/apps/cala/e2e/phase5-exit.e2e.test.ts b/apps/cala/e2e/phase5-exit.e2e.test.ts index e6b4447..94f7afa 100644 --- a/apps/cala/e2e/phase5-exit.e2e.test.ts +++ b/apps/cala/e2e/phase5-exit.e2e.test.ts @@ -274,12 +274,32 @@ 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-exit.e2e.test.ts b/apps/cala/e2e/phase6-exit.e2e.test.ts new file mode 100644 index 0000000..b58c84d --- /dev/null +++ b/apps/cala/e2e/phase6-exit.e2e.test.ts @@ -0,0 +1,503 @@ +/** + * 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/e2e/phase6-extend.e2e.test.ts b/apps/cala/e2e/phase6-extend.e2e.test.ts new file mode 100644 index 0000000..78b4ca4 --- /dev/null +++ b/apps/cala/e2e/phase6-extend.e2e.test.ts @@ -0,0 +1,452 @@ +/** + * 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/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/src/App.tsx b/apps/cala/src/App.tsx index ed3d63e..980c3ac 100644 --- a/apps/cala/src/App.tsx +++ b/apps/cala/src/App.tsx @@ -2,7 +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 { 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'; @@ -37,7 +37,7 @@ const App: Component = () => { } fallback={} > - + ); diff --git a/apps/cala/src/components/events/EventFeed.tsx b/apps/cala/src/components/events/EventFeed.tsx new file mode 100644 index 0000000..6600927 --- /dev/null +++ b/apps/cala/src/components/events/EventFeed.tsx @@ -0,0 +1,71 @@ +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 +// 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(); + }); + + // 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} 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/components/frame/SingleFrameViewer.tsx b/apps/cala/src/components/frame/SingleFrameViewer.tsx index 14ef640..44c591e 100644 --- a/apps/cala/src/components/frame/SingleFrameViewer.tsx +++ b/apps/cala/src/components/frame/SingleFrameViewer.tsx @@ -1,46 +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)}`; - } -} - -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); @@ -85,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; @@ -113,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/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__/archive-client.test.ts b/apps/cala/src/lib/__tests__/archive-client.test.ts index 89fdd33..f858cce 100644 --- a/apps/cala/src/lib/__tests__/archive-client.test.ts +++ b/apps/cala/src/lib/__tests__/archive-client.test.ts @@ -223,6 +223,78 @@ 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/__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/archive-client.ts b/apps/cala/src/lib/archive-client.ts index a1c70ab..5f81ba8 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,130 @@ 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 +267,14 @@ export function createArchiveClient( pending.clear(); } - return { requestDump, startPolling, stopPolling, onEvent, dispose }; + return { + requestDump, + requestTimeseries, + requestEventsForNeuron, + requestFootprintHistory, + startPolling, + stopPolling, + onEvent, + dispose, + }; } diff --git a/apps/cala/src/lib/run-control.ts b/apps/cala/src/lib/run-control.ts index 2371e7d..3cff8d8 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, @@ -110,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 }), }, }, }; @@ -129,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; @@ -147,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') { @@ -161,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; }; @@ -221,6 +239,8 @@ export async function startRun(opts: StartOptions = {}): Promise { currentRuntime = null; currentPreviewDetach?.(); currentPreviewDetach = null; + currentFitDetach?.(); + currentFitDetach = null; currentArchiveWorker = null; } } @@ -240,3 +260,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/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/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/styles/global.css b/apps/cala/src/styles/global.css index 467c111..e8aba98 100644 --- a/apps/cala/src/styles/global.css +++ b/apps/cala/src/styles/global.css @@ -127,3 +127,201 @@ 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); +} + +/* ─── 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); +} + +/* ─── 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); +} + +/* 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/__tests__/archive.worker.test.ts b/apps/cala/src/workers/__tests__/archive.worker.test.ts index 63449cb..5623f31 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,12 @@ 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 inNeuronReq: WorkerInbound = { + kind: 'request-events-for-neuron', + requestId: 3, + neuronId: 5, + }; const outDump: WorkerOutbound = { kind: 'archive-dump', role: 'archive', @@ -78,9 +92,30 @@ 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), + }; + 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'); }); }); @@ -175,6 +210,179 @@ 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-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('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); + 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); + 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__/fit.worker.test.ts b/apps/cala/src/workers/__tests__/fit.worker.test.ts index ec51106..fb11521 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,10 +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 {}, }; }); @@ -159,6 +187,7 @@ function resetMockState(): void { mockState.program = []; mockState.autoResidual = new Float32Array(PIXELS); mockState.mutationsToDrain = []; + mockState.nextCycleProposals = 0; } function makeFrameChannel(): SabRingChannel { @@ -212,6 +241,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 +460,141 @@ 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); + // 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); + // 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/__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/__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/__tests__/neuron-event-index.test.ts b/apps/cala/src/workers/__tests__/neuron-event-index.test.ts new file mode 100644 index 0000000..5f1d270 --- /dev/null +++ b/apps/cala/src/workers/__tests__/neuron-event-index.test.ts @@ -0,0 +1,125 @@ +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/__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..e119589 100644 --- a/apps/cala/src/workers/archive.worker.ts +++ b/apps/cala/src/workers/archive.worker.ts @@ -28,6 +28,9 @@ import { type WorkerInitPayload, type WorkerOutbound, } 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 @@ -37,11 +40,36 @@ 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; +// 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; +// 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. 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; @@ -55,6 +83,13 @@ interface ArchiveWorkerConfig { metricWindow: number; localBusCapacity: number; localBusMaxSubscribers: number; + timeseriesL1Capacity: number; + timeseriesL2Capacity: number; + timeseriesL2Stride: number; + neuronEventLimit: number; + maxIndexedNeurons: number; + footprintHistoryLimit: number; + footprintHistoryMaxNeurons: number; } const workerSelf = ((globalThis as unknown as { self?: WorkerGlobalScope }).self ?? @@ -71,6 +106,12 @@ interface RuntimeHandles { // not need. eventLog: PipelineEvent[]; metricSnapshot: Map; + timeseries: TimeseriesStore; + unsubscribeTimeseries: () => void; + neuronIndex: NeuronEventIndex; + unsubscribeNeuronIndex: () => void; + footprints: FootprintHistoryStore; + unsubscribeFootprints: () => void; running: boolean; stopped: boolean; } @@ -105,6 +146,19 @@ 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), + 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, + ), }; } @@ -116,6 +170,20 @@ 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 neuronIndex = new NeuronEventIndex({ + 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) { @@ -136,6 +204,42 @@ 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); + }); + + const unsubscribeNeuronIndex = bus.subscribe((e) => { + 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, @@ -143,6 +247,12 @@ function handleInit(payload: WorkerInitPayload): void { unsubscribeMetrics, eventLog, metricSnapshot, + timeseries, + unsubscribeTimeseries, + neuronIndex, + unsubscribeNeuronIndex, + footprints, + unsubscribeFootprints, running: false, stopped: false, }; @@ -168,6 +278,54 @@ 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 handleNeuronEventsRequest(requestId: number, neuronId: number): void { + if (!handles) return; + post({ + kind: 'events-for-neuron', + role: ROLE, + requestId, + neuronId, + events: handles.neuronIndex.query(neuronId), + }); +} + +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; @@ -182,6 +340,9 @@ function handleStop(): void { handles.stopped = true; handles.unsubscribeLog(); handles.unsubscribeMetrics(); + handles.unsubscribeTimeseries(); + handles.unsubscribeNeuronIndex(); + handles.unsubscribeFootprints(); handles.bus.close(); postDoneOnce(); } @@ -205,6 +366,15 @@ workerSelf.onmessage = (ev: MessageEvent): void => { case 'request-archive-dump': handleDumpRequest(msg.requestId); return; + case 'request-timeseries': + handleTimeseriesRequest(msg.requestId, msg.name); + return; + 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/fit.worker.ts b/apps/cala/src/workers/fit.worker.ts index 935867c..f47651e 100644 --- a/apps/cala/src/workers/fit.worker.ts +++ b/apps/cala/src/workers/fit.worker.ts @@ -1,4 +1,18 @@ -import { initCalaCore, Fitter, MutationQueueHandle } from '@calab/cala-core'; +import { + initCalaCore, + Extender, + 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 { FootprintSnapshotScheduler } from './footprint-snapshot-scheduler.ts'; import { SabRingChannel, EventBus, @@ -49,6 +63,29 @@ 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; +// 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; +// 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; @@ -63,7 +100,12 @@ interface FitWorkerConfig { fitConfigJson: string; extendConfigJson: string; heartbeatStride: number; + vitalsStride: number; snapshotStride: number; + footprintSchedulerMaxNeurons: number; + extendCycleStride: number; + extendWindowFrames: number; + metadataJson: string; mutationDrainMaxPerIteration: number; eventBusCapacity: number; eventBusMaxSubscribers: number; @@ -93,6 +135,24 @@ 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; + 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; @@ -135,7 +195,15 @@ 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), + footprintSchedulerMaxNeurons: numberOr( + 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, @@ -210,6 +278,22 @@ async function handleInit(payload: WorkerInitPayload): Promise { eventSubscription, config: cfg, pixels, + lastVitalsTimeMs: 0, + lastVitalsFrameIndex: 0, + lastResidualL2: 0, + 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 @@ -262,6 +346,34 @@ 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 @@ -276,13 +388,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; @@ -318,6 +445,75 @@ 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 }); + } +} + +// 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). + 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 + // 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) { @@ -329,9 +525,13 @@ 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); + runExtendCycleIfDue(h, frameIndex, residual); drainMutationsOnce(h, frameIndex); takeCadencedSnapshot(h, frameIndex); + emitScheduledFootprints(h, frameIndex); + emitVitals(h, frameIndex); if ((frameIndex + 1) % h.config.heartbeatStride === 0) { post({ kind: 'frame-processed', @@ -407,6 +607,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) { @@ -425,5 +662,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/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/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); + } +} 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..ceef01e --- /dev/null +++ b/apps/cala/src/workers/neuron-event-index.ts @@ -0,0 +1,96 @@ +/** + * 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': + case 'footprint-snapshot': + // Periodic footprint snapshots are indexed by the footprint + // store (§9.3), not the structural-event history. + 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/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/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', 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 13bedc5..2175f94 100644 Binary files a/crates/cala-core/pkg/calab_cala_core_bg.wasm and b/crates/cala-core/pkg/calab_cala_core_bg.wasm differ 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/__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); + }); }); diff --git a/packages/cala-core/src/index.ts b/packages/cala-core/src/index.ts index b88341d..9533e4a 100644 --- a/packages/cala-core/src/index.ts +++ b/packages/cala-core/src/index.ts @@ -1,9 +1,11 @@ export { AviReader, + Extender, Fitter, MutationQueueHandle, Preprocessor, 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..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,9 +20,18 @@ 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; /** * Initialize the cala-core WASM module. Lazy and idempotent — safe to @@ -31,9 +41,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; +} 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/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/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 229e541..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'; @@ -52,7 +62,26 @@ 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 } + // 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 } + // 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 } + // 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 = @@ -73,6 +102,41 @@ 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; + } + // 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[]; + } + // 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