diff --git a/apps/cala/e2e/phase5-exit.e2e.test.ts b/apps/cala/e2e/phase5-exit.e2e.test.ts index 94f7afa..342c4c0 100644 --- a/apps/cala/e2e/phase5-exit.e2e.test.ts +++ b/apps/cala/e2e/phase5-exit.e2e.test.ts @@ -230,6 +230,14 @@ class StubPreprocessor { // the shape and magnitude of the data W2 and W4 see. return input; } + processFrameF32WithStages(input: Float32Array): Float32Array { + // Identity 3-stage: final || hotPixel || motion all echo input. + const out = new Float32Array(input.length * 3); + out.set(input, 0); + out.set(input, input.length); + out.set(input, input.length * 2); + return out; + } free(): void { // noop } @@ -252,6 +260,24 @@ class StubFitter { drainApply(_handle: unknown): Uint32Array { return new Uint32Array([0, 0, 0]); } + drainApplyEvents(_handle: unknown): { + report: [number, number, number]; + events: Array>; + } { + // Phase 5 fit stub never proposes mutations — extend is a + // heartbeat-only stub in that phase — so this matches `drainApply` + // and emits no structural events. + return { report: [0, 0, 0], events: [] }; + } + reconstructLastFrame(): Float32Array { + return new Float32Array(0); + } + componentIds(): Uint32Array { + return new Uint32Array(0); + } + lastTrace(): Float32Array { + return new Float32Array(0); + } takeSnapshot(): { epoch(): bigint; numComponents(): number; pixels(): number; free(): void } { return { epoch: () => this.currentEpoch, @@ -295,6 +321,8 @@ class StubExtender { vi.mock('@calab/cala-core', () => ({ initCalaCore: vi.fn(async () => undefined), calaMemoryBytes: vi.fn(() => 0), + drainApplyEventsTyped: (fitter: { drainApplyEvents: (q: unknown) => unknown }, queue: unknown) => + fitter.drainApplyEvents(queue), AviReader: StubAviReader, Preprocessor: StubPreprocessor, Fitter: StubFitter, diff --git a/apps/cala/e2e/phase6-exit.e2e.test.ts b/apps/cala/e2e/phase6-exit.e2e.test.ts index b58c84d..7fbb8e5 100644 --- a/apps/cala/e2e/phase6-exit.e2e.test.ts +++ b/apps/cala/e2e/phase6-exit.e2e.test.ts @@ -173,6 +173,13 @@ class StubPreprocessor { processFrameF32(input: Float32Array): Float32Array { return input; } + processFrameF32WithStages(input: Float32Array): Float32Array { + const out = new Float32Array(input.length * 3); + out.set(input, 0); + out.set(input, input.length); + out.set(input, input.length * 2); + return out; + } free(): void {} } @@ -197,6 +204,36 @@ class StubFitter { this.currentEpoch += 1n; return new Uint32Array([1, 0, 0]); } + drainApplyEvents(_handle: unknown): { + report: [number, number, number]; + events: Array>; + } { + fitterDrainApplyCount += 1; + const id = Number(this.currentEpoch); + this.currentEpoch += 1n; + return { + report: [1, 0, 0], + events: [ + { + kind: 'birth', + id, + class: 'cell', + support: [0], + values: [1], + patch: [0, 0], + }, + ], + }; + } + reconstructLastFrame(): Float32Array { + return new Float32Array(0); + } + componentIds(): Uint32Array { + return new Uint32Array(0); + } + lastTrace(): Float32Array { + return new Float32Array(0); + } takeSnapshot(): { epoch(): bigint; numComponents(): number; pixels(): number; free(): void } { return { epoch: () => this.currentEpoch, @@ -239,6 +276,8 @@ class StubExtender { vi.mock('@calab/cala-core', () => ({ initCalaCore: vi.fn(async () => undefined), calaMemoryBytes: vi.fn(() => 2 * 1024 * 1024), + drainApplyEventsTyped: (fitter: { drainApplyEvents: (q: unknown) => unknown }, queue: unknown) => + fitter.drainApplyEvents(queue), AviReader: StubAviReader, Preprocessor: StubPreprocessor, Fitter: StubFitter, diff --git a/apps/cala/e2e/phase6-extend.e2e.test.ts b/apps/cala/e2e/phase6-extend.e2e.test.ts index 78b4ca4..729ee99 100644 --- a/apps/cala/e2e/phase6-extend.e2e.test.ts +++ b/apps/cala/e2e/phase6-extend.e2e.test.ts @@ -174,6 +174,13 @@ class StubPreprocessor { processFrameF32(input: Float32Array): Float32Array { return input; } + processFrameF32WithStages(input: Float32Array): Float32Array { + const out = new Float32Array(input.length * 3); + out.set(input, 0); + out.set(input, input.length); + out.set(input, input.length * 2); + return out; + } free(): void {} } @@ -202,6 +209,39 @@ class StubFitter { this.currentEpoch += 1n; return new Uint32Array([1, 0, 0]); } + drainApplyEvents(_handle: unknown): { + report: [number, number, number]; + events: Array>; + } { + fitterDrainApplyCount += 1; + // Synthesize one birth event per drain — mirrors the behavior + // the Phase 6 test was written against, plus the real Phase 7 + // structural event surface for the bus. + const id = Number(this.currentEpoch); + this.currentEpoch += 1n; + return { + report: [1, 0, 0], + events: [ + { + kind: 'birth', + id, + class: 'cell', + support: [0], + values: [1], + patch: [0, 0], + }, + ], + }; + } + reconstructLastFrame(): Float32Array { + return new Float32Array(0); + } + componentIds(): Uint32Array { + return new Uint32Array(0); + } + lastTrace(): Float32Array { + return new Float32Array(0); + } takeSnapshot(): { epoch(): bigint; numComponents(): number; pixels(): number; free(): void } { return { epoch: () => this.currentEpoch, @@ -239,6 +279,8 @@ class StubExtender { vi.mock('@calab/cala-core', () => ({ initCalaCore: vi.fn(async () => undefined), calaMemoryBytes: vi.fn(() => 1024 * 1024), + drainApplyEventsTyped: (fitter: { drainApplyEvents: (q: unknown) => unknown }, queue: unknown) => + fitter.drainApplyEvents(queue), AviReader: StubAviReader, Preprocessor: StubPreprocessor, Fitter: StubFitter, diff --git a/apps/cala/e2e/phase7-exit.e2e.test.ts b/apps/cala/e2e/phase7-exit.e2e.test.ts new file mode 100644 index 0000000..f57b273 --- /dev/null +++ b/apps/cala/e2e/phase7-exit.e2e.test.ts @@ -0,0 +1,566 @@ +/** + * Phase 7 exit E2E — task 16. + * + * End-to-end proof that every Phase 7 pillar that touches wire + * protocol is reachable on a real miniscope AVI: + * + * 1. `drainApplyEvents` emits real birth events that reach the + * archive worker's event log (T1-T3). + * 2. W1 emits 3-stage preview streams tagged 'raw', 'hotPixel', + * 'motion' (T5). + * 3. W2 emits reconstruction preview tagged 'reconstruction' (T6). + * 4. `trace-sample` events flow into the archive's neuron trace + * store and are queryable via `request-all-traces` (T8). + * 5. `request-all-footprints` returns latest sparse-A per live id + * (T10). + * 6. `buildCalaExportNpz` round-trips the archive reply through + * `parseNpz` with the expected CSC + K×T shapes (T15). + * + * Two-pass + run-mode toggle (originally T13/T14) were explicitly + * descoped to Phase 8 — they need cross-worker Footprints state + * transfer that's out of scope for this phase. + */ + +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'; +// NOTE: `@calab/io` + `../src/lib/export.ts` pull `@calab/cala-core` +// transitively via `avi-uncompressed.ts`. We `vi.mock` that module +// below, and vitest hoists the factory above this file's other +// top-level statements — so importing those two eagerly here +// triggers "Cannot access 'StubAviReader' before initialization" +// against the hoisted factory. Load them via dynamic import inside +// the test body instead, after the mock is in place. +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 = 4; +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; +// Stub trace amplitude each vitals tick so `trace-sample` events +// carry real numbers the archive can route through its per-neuron +// trace store. +const TEST_STUB_TRACE_VALUE = 0.42; + +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, i: number): string { + return String.fromCharCode(bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 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; + } + processFrameF32WithStages(input: Float32Array): Float32Array { + const out = new Float32Array(input.length * 3); + out.set(input, 0); + out.set(input, input.length); + out.set(input, input.length * 2); + return out; + } + free(): void {} +} + +let fitterFrameCount = 0; +let fitterDrainApplyCount = 0; + +class StubFitter { + private currentEpoch = 0n; + private liveIds: number[] = []; + constructor(_h: number, _w: number, _c: string) {} + epoch(): bigint { + return this.currentEpoch; + } + numComponents(): number { + return this.liveIds.length; + } + step(y: Float32Array): Float32Array { + fitterFrameCount += 1; + return y; + } + drainApply(_handle: unknown): Uint32Array { + fitterDrainApplyCount += 1; + this.currentEpoch += 1n; + return new Uint32Array([1, 0, 0]); + } + drainApplyEvents(_handle: unknown): { + report: [number, number, number]; + events: Array>; + } { + fitterDrainApplyCount += 1; + const id = Number(this.currentEpoch); + this.currentEpoch += 1n; + this.liveIds.push(id); + return { + report: [1, 0, 0], + events: [ + { + kind: 'birth', + id, + class: 'cell', + support: [id, id + 1], + values: [0.7, 0.3], + patch: [0, id], + }, + ], + }; + } + reconstructLastFrame(): Float32Array { + // Phase 7 T6: emit a non-empty Float32Array so W2's preview path + // posts a 'reconstruction' stage frame. Shape must match H·W. + if (!parsedAvi) return new Float32Array(0); + const out = new Float32Array(parsedAvi.width * parsedAvi.height); + out.fill(0.1); + return out; + } + componentIds(): Uint32Array { + return Uint32Array.from(this.liveIds); + } + lastTrace(): Float32Array { + const out = new Float32Array(this.liveIds.length); + out.fill(TEST_STUB_TRACE_VALUE); + return out; + } + takeSnapshot(): { epoch(): bigint; numComponents(): number; pixels(): number; free(): void } { + return { + epoch: () => this.currentEpoch, + numComponents: () => this.liveIds.length, + pixels: () => 0, + free: () => {}, + }; + } + free(): void {} +} + +class StubMutationQueueHandle { + constructor(_cfg: string) {} + pushDeprecate(_snapshotEpoch: bigint, _id: number, _reason: string): void {} + 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(() => 3 * 1024 * 1024), + drainApplyEventsTyped: (fitter: { drainApplyEvents: (q: unknown) => unknown }, queue: unknown) => + fitter.drainApplyEvents(queue), + 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 bus event forwarding (orchestrator's job in prod). + 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, + // Phase 7 T6 — fit worker needs its own preview stride to + // emit reconstruction frames. Reusing the same cadence as W1. + framePreviewStride: TEST_PREVIEW_STRIDE, + 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 7 exit — end-to-end', () => { + beforeEach(() => { + fitterFrameCount = 0; + fitterDrainApplyCount = 0; + extenderCycleCount = 0; + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + parsedAvi = null; + }); + + it( + 'emits real births, 4-stage previews, trace samples + exports a valid NPZ', + { 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); + + // --- (T5) W1 3-stage preview streams ------------------------------- + const w1Previews = boot.decode.posted.filter( + (m): m is Extract => m.kind === 'frame-preview', + ); + const stages = new Set(w1Previews.map((p) => p.stage)); + expect(stages.has('raw')).toBe(true); + expect(stages.has('hotPixel')).toBe(true); + expect(stages.has('motion')).toBe(true); + + // --- (T6) W2 reconstruction preview -------------------------------- + const w2Previews = boot.fit.posted.filter( + (m): m is Extract => + m.kind === 'frame-preview' && m.stage === 'reconstruction', + ); + expect(w2Previews.length).toBeGreaterThanOrEqual(1); + + // --- (T1-T3) Real birth events published on the bus ---------------- + const busBirths = boot.fit.posted.filter( + (m): m is Extract => + m.kind === 'event' && m.event.kind === 'birth', + ); + expect(busBirths.length).toBeGreaterThanOrEqual(1); + expect((busBirths[0].event as Extract).id).toBeDefined(); + + 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')); + + // --- (T8) Traces landed in archive trace store --------------------- + const tracesReply = await requestArchiveReply( + boot.archive, + { kind: 'request-all-traces', requestId: 200 }, + 'all-traces', + ); + expect(tracesReply.ids.length).toBeGreaterThanOrEqual(1); + expect(tracesReply.values.length).toBe(tracesReply.ids.length); + // Each traced id has at least one sample at the stubbed amplitude. + for (const vs of tracesReply.values) { + expect(vs.length).toBeGreaterThanOrEqual(1); + expect(vs[0]).toBeCloseTo(TEST_STUB_TRACE_VALUE, 3); + } + + // --- (T10) All live footprints via archive query -------------------- + const footprintsReply = await requestArchiveReply( + boot.archive, + { kind: 'request-all-footprints', requestId: 201 }, + 'all-footprints', + ); + expect(footprintsReply.ids.length).toBeGreaterThanOrEqual(1); + expect(footprintsReply.pixelIndices.length).toBe(footprintsReply.ids.length); + + // --- (T15) Export NPZ round-trips through parseNpz ------------------ + const { buildCalaExportNpz } = await import('../src/lib/export.ts'); + const { parseNpz } = await import('@calab/io'); + const npz = buildCalaExportNpz({ + footprints: footprintsReply, + traces: tracesReply, + meta: { height: parsedAvi.height, width: parsedAvi.width }, + }); + const parsed = parseNpz(npz.buffer as ArrayBuffer); + expect(parsed.arrays.A_data.data.length).toBeGreaterThanOrEqual( + footprintsReply.ids.length, // at least one nnz per id + ); + expect(Array.from(parsed.arrays.A_shape.data)).toEqual([ + parsedAvi.height * parsedAvi.width, + footprintsReply.ids.length, + ]); + expect(parsed.arrays.C.shape[0]).toBe(tracesReply.ids.length); + expect(parsed.arrays.height.data[0]).toBe(parsedAvi.height); + expect(parsed.arrays.width.data[0]).toBe(parsedAvi.width); + + 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( + `[phase7-exit] frames=${fitterFrameCount} ` + + `extend_cycles=${extenderCycleCount} ` + + `drain_applies=${fitterDrainApplyCount} ` + + `w1_previews=${w1Previews.length} ` + + `w2_previews=${w2Previews.length} ` + + `births=${busBirths.length} ` + + `traced_ids=${tracesReply.ids.length} ` + + `footprints=${footprintsReply.ids.length}`, + ); + }, + ); +}); diff --git a/apps/cala/package.json b/apps/cala/package.json index 9cc2a45..78b7f3e 100644 --- a/apps/cala/package.json +++ b/apps/cala/package.json @@ -32,6 +32,7 @@ "@calab/core": "*", "@calab/io": "*", "@calab/ui": "*", - "solid-js": "^1.9.11" + "solid-js": "^1.9.11", + "uplot": "^1.6.32" } } diff --git a/apps/cala/src/components/events/event-format.ts b/apps/cala/src/components/events/event-format.ts index bc8a803..0784c02 100644 --- a/apps/cala/src/components/events/event-format.ts +++ b/apps/cala/src/components/events/event-format.ts @@ -23,6 +23,8 @@ export function describeEvent(e: PipelineEvent): string { return `${e.name}=${e.value.toFixed(3)}`; case 'footprint-snapshot': return `id=${e.neuronId} (${e.footprint.pixelIndices.length}px)`; + case 'trace-sample': + return `${e.ids.length} traces @ t=${e.t}`; } } @@ -39,6 +41,7 @@ export function idForEvent(e: PipelineEvent): string { return `#${e.neuronId}`; case 'reject': case 'metric': + case 'trace-sample': return ''; } } diff --git a/apps/cala/src/components/export/ExportButton.tsx b/apps/cala/src/components/export/ExportButton.tsx new file mode 100644 index 0000000..f7e5767 --- /dev/null +++ b/apps/cala/src/components/export/ExportButton.tsx @@ -0,0 +1,85 @@ +import { createSignal, type JSX } from 'solid-js'; +import { createArchiveClient } from '../../lib/archive-client.ts'; +import { currentArchiveWorkerForClient } from '../../lib/run-control.ts'; +import { state } from '../../lib/data-store.ts'; +import { buildCalaExportNpz, triggerDownload } from '../../lib/export.ts'; + +function exportFilename(baseFileName: string | undefined): string { + const stem = baseFileName?.replace(/\.[^.]+$/, '') ?? 'cala-run'; + const stamp = new Date().toISOString().replace(/[:.]/g, '-').replace(/T/, '_').replace(/Z$/, ''); + return `${stem}_${stamp}.npz`; +} + +/** + * Export flow (design §8 "Export flow", Phase 7 task 15). Pulls the + * latest footprints + traces from the archive worker, packs them + * into a scipy.sparse-CSC-shaped .npz, and triggers a browser + * download. Enabled whenever the archive worker is reachable (while + * the run is active OR after natural completion — see run-control's + * archive-worker retention policy). + */ +export function ExportButton(): JSX.Element { + const [busy, setBusy] = createSignal(false); + const [error, setError] = createSignal(null); + + const canExport = (): boolean => { + if (busy()) return false; + if (currentArchiveWorkerForClient() === null) return false; + return state.runState === 'running' || state.runState === 'stopped'; + }; + + const handleExport = async (): Promise => { + if (busy()) return; + const worker = currentArchiveWorkerForClient(); + if (!worker) { + setError('no active archive worker'); + return; + } + const meta = state.meta; + if (!meta) { + setError('no recording metadata'); + return; + } + setBusy(true); + setError(null); + const client = createArchiveClient(worker); + try { + const [footprints, traces] = await Promise.all([ + client.requestAllFootprints(), + client.requestAllTraces(), + ]); + const npz = buildCalaExportNpz({ + footprints, + traces, + meta: { height: meta.height, width: meta.width }, + }); + triggerDownload(npz, exportFilename(state.file?.name)); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + client.dispose(); + setBusy(false); + } + }; + + return ( + + ); +} diff --git a/apps/cala/src/components/footprints/FootprintsPanel.tsx b/apps/cala/src/components/footprints/FootprintsPanel.tsx new file mode 100644 index 0000000..d7a21de --- /dev/null +++ b/apps/cala/src/components/footprints/FootprintsPanel.tsx @@ -0,0 +1,237 @@ +import { createEffect, createSignal, onCleanup, type JSX } from 'solid-js'; +import { + createArchiveClient, + type ArchiveClient, + type AllFootprintsReply, +} from '../../lib/archive-client.ts'; +import { currentArchiveWorkerForClient } from '../../lib/run-control.ts'; +import { state } from '../../lib/data-store.ts'; +import { maxProjection } from '../../lib/max-projection-store.ts'; +import { setSelectedNeuronId, selectedNeuronId } from '../../lib/selection-store.ts'; + +// Poll cadence for the footprints query. Footprints change at +// mutation-apply cadence (extend cycle = ~1 Hz on the default +// 32-frame stride), so polling faster than that is wasted work. +const DEFAULT_FOOTPRINTS_POLL_INTERVAL_MS = 1000; +// Fraction of a footprint's peak weight a pixel must have to count +// as "interior" for the overlay outline. Matches extend's default +// `footprint_support_threshold_rel` so the drawn boundary lines up +// with the component's effective support. +const DEFAULT_BOUNDARY_THRESHOLD_REL = 0.1; +// Stroke width for the overlay outline. 1px keeps overlap pileup +// readable at 248 neurons; thicker strokes visually merge outlines. +const OVERLAY_STROKE_WIDTH = 1; + +interface FootprintsPollerHandle { + stop: () => void; +} + +function startFootprintsPolling( + client: ArchiveClient, + onReply: (reply: AllFootprintsReply) => void, + intervalMs: number, +): FootprintsPollerHandle { + let timer: ReturnType | null = null; + let stopped = false; + const tick = (): void => { + if (stopped) return; + client + .requestAllFootprints() + .then((reply) => { + if (stopped) return; + onReply(reply); + }) + .catch(() => { + // Cosmetic — next tick retries. + }) + .finally(() => { + if (stopped) return; + timer = setTimeout(tick, intervalMs); + }); + }; + timer = setTimeout(tick, 0); + return { + stop(): void { + stopped = true; + if (timer !== null) clearTimeout(timer); + timer = null; + }, + }; +} + +function colorForId(id: number): string { + const hue = (id * 137.508) % 360; + return `hsl(${hue.toFixed(0)}, 70%, 60%)`; +} + +/** + * Draw `maxProj` into the canvas as grayscale, then overlay each + * footprint's boundary in its per-id color. Re-runs on poll + on + * max-projection update + on selection change. + */ +function renderPanel( + canvas: HTMLCanvasElement, + proj: { width: number; height: number; pixels: Uint8ClampedArray } | null, + footprints: AllFootprintsReply, + selectedId: number | null, +): void { + if (!proj) { + canvas.width = 1; + canvas.height = 1; + return; + } + if (canvas.width !== proj.width || canvas.height !== proj.height) { + canvas.width = proj.width; + canvas.height = proj.height; + } + const ctx = canvas.getContext('2d'); + if (!ctx) return; + // Base layer: grayscale max projection. + const img = ctx.createImageData(proj.width, proj.height); + for (let i = 0; i < proj.pixels.length; i += 1) { + const v = proj.pixels[i]; + const j = i * 4; + img.data[j] = v; + img.data[j + 1] = v; + img.data[j + 2] = v; + img.data[j + 3] = 255; + } + ctx.putImageData(img, 0, 0); + + // Overlay: per-id boundary in color. Compute interior mask, then + // stroke any interior pixel with a non-interior 4-connected + // neighbor. Cheap because supports are sparse. + for (let k = 0; k < footprints.ids.length; k += 1) { + const id = footprints.ids[k]; + const support = footprints.pixelIndices[k]; + const values = footprints.values[k]; + if (support.length === 0) continue; + + // Peak weight for this footprint — threshold applies relative. + let peak = 0; + for (let i = 0; i < values.length; i += 1) { + if (values[i] > peak) peak = values[i]; + } + const cutoff = peak * DEFAULT_BOUNDARY_THRESHOLD_REL; + + // Build a small Set of linear indices belonging to the interior + // for O(1) neighbor checks. + const interior = new Set(); + for (let i = 0; i < support.length; i += 1) { + if (values[i] >= cutoff) interior.add(support[i]); + } + if (interior.size === 0) continue; + + const isSelected = selectedId !== null && selectedId === id; + ctx.fillStyle = colorForId(id); + ctx.strokeStyle = isSelected ? 'white' : colorForId(id); + ctx.lineWidth = isSelected ? OVERLAY_STROKE_WIDTH + 1 : OVERLAY_STROKE_WIDTH; + + for (const idx of interior) { + const y = Math.floor(idx / proj.width); + const x = idx - y * proj.width; + // 4-connected boundary: if any cardinal neighbor is outside + // the interior, this pixel is an outline pixel. + const neighbors = [ + x > 0 ? idx - 1 : -1, + x < proj.width - 1 ? idx + 1 : -1, + y > 0 ? idx - proj.width : -1, + y < proj.height - 1 ? idx + proj.width : -1, + ]; + let onBoundary = false; + for (const n of neighbors) { + if (n < 0 || !interior.has(n)) { + onBoundary = true; + break; + } + } + if (onBoundary) { + ctx.fillRect(x, y, 1, 1); + } + } + } +} + +export function FootprintsPanel(): JSX.Element { + let canvasRef: HTMLCanvasElement | undefined; + const [client, setClient] = createSignal(null); + const [reply, setReply] = createSignal({ + ids: new Uint32Array(0), + pixelIndices: [], + values: [], + }); + let poller: FootprintsPollerHandle | null = null; + + // Map canvas click → nearest footprint → selectedNeuronId. Uses + // a point-in-support test: the first footprint whose sparse + // support contains the clicked pixel wins. + const onCanvasClick = (ev: MouseEvent): void => { + const canvas = canvasRef; + if (!canvas) return; + const proj = maxProjection(); + if (!proj) return; + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + const x = Math.floor((ev.clientX - rect.left) * scaleX); + const y = Math.floor((ev.clientY - rect.top) * scaleY); + if (x < 0 || y < 0 || x >= proj.width || y >= proj.height) return; + const idx = y * proj.width + x; + const r = reply(); + for (let k = 0; k < r.ids.length; k += 1) { + const support = r.pixelIndices[k]; + for (let i = 0; i < support.length; i += 1) { + if (support[i] === idx) { + setSelectedNeuronId(r.ids[k]); + return; + } + } + } + setSelectedNeuronId(null); + }; + + createEffect(() => { + const canvas = canvasRef; + if (!canvas) return; + renderPanel(canvas, maxProjection(), reply(), selectedNeuronId()); + }); + + createEffect(() => { + const rs = state.runState; + if (rs === 'running') { + const worker = currentArchiveWorkerForClient(); + if (!worker) return; + const c = createArchiveClient(worker); + setClient(c); + poller = startFootprintsPolling(c, setReply, DEFAULT_FOOTPRINTS_POLL_INTERVAL_MS); + } else { + poller?.stop(); + poller = null; + const c = client(); + c?.dispose(); + setClient(null); + setReply({ ids: new Uint32Array(0), pixelIndices: [], values: [] }); + } + }); + + onCleanup(() => { + poller?.stop(); + client()?.dispose(); + }); + + return ( +
+
footprints over max-projection
+
+ +
+
+ ); +} diff --git a/apps/cala/src/components/frame/FrameQuad.tsx b/apps/cala/src/components/frame/FrameQuad.tsx new file mode 100644 index 0000000..5a9cc46 --- /dev/null +++ b/apps/cala/src/components/frame/FrameQuad.tsx @@ -0,0 +1,35 @@ +import { type JSX } from 'solid-js'; +import { dashboard } from '../../lib/dashboard-store.ts'; +import { FrameStagePanel } from './FrameStagePanel.tsx'; + +/** + * 4-canvas frame panel (design §8 "Frame panel", Phase 7 task 7). + * Shows the preprocess pipeline's raw → hot-pixel → motion stages + * side-by-side with the fit pipeline's reconstruction `Ãc`, so the + * user can see what fit is seeing and how close the model's guess is + * to the observed frame. + * + * Scrubber is deferred (design §8 notes it as Phase 8 polish — needs + * main-thread frame history per stage, which is a separate data + * plumbing task). + */ +export function FrameQuad(): JSX.Element { + const caption = (): string => { + const idx = dashboard.currentFrameIndex; + const ep = dashboard.currentEpoch; + if (idx === null || ep === null) return 'awaiting frames…'; + return `frame ${idx} · epoch ${ep.toString()}`; + }; + + return ( +
+
+ + + + +
+
{caption()}
+
+ ); +} diff --git a/apps/cala/src/components/frame/FrameStagePanel.tsx b/apps/cala/src/components/frame/FrameStagePanel.tsx new file mode 100644 index 0000000..1583d50 --- /dev/null +++ b/apps/cala/src/components/frame/FrameStagePanel.tsx @@ -0,0 +1,69 @@ +import { createEffect, createSignal, Show, type JSX } from 'solid-js'; +import { latestFrames, type FrameStage, type LatestFramePreview } from '../../lib/run-control.ts'; +import { writeGrayscaleToImageData } from '../../lib/frame-preview.ts'; + +interface FrameStagePanelProps { + stage: FrameStage; + label: string; +} + +/** + * One canvas of the 4-canvas frame panel (design §8, Phase 7 task 7). + * Reads the `latestFrames` signal keyed by `stage` and blits the u8 + * preview into a pre-allocated `ImageData`. Structurally identical to + * `SingleFrameViewer` but scoped to one stage so the quad can compose + * four of them. + */ +export function FrameStagePanel(props: FrameStagePanelProps): JSX.Element { + let canvasRef: HTMLCanvasElement | undefined; + const [imageData, setImageData] = createSignal(null); + const [canvasDims, setCanvasDims] = createSignal<{ width: number; height: number } | null>(null); + + const frame = (): LatestFramePreview | undefined => latestFrames()[props.stage]; + + createEffect(() => { + const f = frame(); + if (!f) return; + const dims = canvasDims(); + if (!dims || dims.width !== f.width || dims.height !== f.height) { + const canvas = canvasRef; + if (!canvas) return; + canvas.width = f.width; + canvas.height = f.height; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + setImageData(ctx.createImageData(f.width, f.height)); + setCanvasDims({ width: f.width, height: f.height }); + } + }); + + createEffect(() => { + const f = frame(); + const img = imageData(); + const canvas = canvasRef; + if (!f || !img || !canvas) return; + if (img.width !== f.width || img.height !== f.height) return; + writeGrayscaleToImageData(f.pixels, img); + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctx.putImageData(img, 0, 0); + }); + + return ( +
+
{props.label}
+
+ + +
awaiting…
+
+
+
+ ); +} diff --git a/apps/cala/src/components/layout/DashboardLayout.tsx b/apps/cala/src/components/layout/DashboardLayout.tsx index 92a34c9..55cb7c7 100644 --- a/apps/cala/src/components/layout/DashboardLayout.tsx +++ b/apps/cala/src/components/layout/DashboardLayout.tsx @@ -1,24 +1,37 @@ import { type JSX } from 'solid-js'; -import { SingleFrameViewer } from '../frame/SingleFrameViewer.tsx'; +import { FrameQuad } from '../frame/FrameQuad.tsx'; import { VitalsBar } from '../vitals/VitalsBar.tsx'; import { EventFeed } from '../events/EventFeed.tsx'; +import { TracesPanel } from '../traces/TracesPanel.tsx'; +import { FootprintsPanel } from '../footprints/FootprintsPanel.tsx'; +import { NeuronZoomPanel } from '../neuron/NeuronZoomPanel.tsx'; +import { ExportButton } from '../export/ExportButton.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. + * 4-canvas frame panel (Phase 7 task 7) 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/neuron/NeuronZoomPanel.tsx b/apps/cala/src/components/neuron/NeuronZoomPanel.tsx new file mode 100644 index 0000000..e7350fc --- /dev/null +++ b/apps/cala/src/components/neuron/NeuronZoomPanel.tsx @@ -0,0 +1,211 @@ +import { createEffect, createSignal, onCleanup, Show, type JSX } from 'solid-js'; +import { + createArchiveClient, + type ArchiveClient, + type AllTracesReply, + type FootprintHistoryEntry, +} from '../../lib/archive-client.ts'; +import { currentArchiveWorkerForClient } from '../../lib/run-control.ts'; +import { state } from '../../lib/data-store.ts'; +import { selectedNeuronId, setSelectedNeuronId } from '../../lib/selection-store.ts'; +import { SparkLine } from '../vitals/SparkLine.tsx'; + +// Poll cadence — this panel is for inspection, not live drilling, +// so a slower tick keeps worker + main-thread overhead low. +const DEFAULT_NEURON_ZOOM_POLL_INTERVAL_MS = 2000; +// Rendered footprint canvas inset (px of padding around the bbox). +const FOOTPRINT_BBOX_PADDING_PX = 2; +// Maximum trace samples to show in the sparkline. Matches the +// traces panel's L1 window so both widgets scroll together. +const DEFAULT_TRACE_WINDOW = 120; + +interface PollerHandle { + stop: () => void; +} + +function startNeuronPolling( + client: ArchiveClient, + id: number, + onReply: (data: { footprint: FootprintHistoryEntry | null; trace: Float32Array | null }) => void, + intervalMs: number, +): PollerHandle { + let timer: ReturnType | null = null; + let stopped = false; + const idFilter = Uint32Array.of(id); + const tick = (): void => { + if (stopped) return; + Promise.all([client.requestFootprintHistory(id), client.requestAllTraces(idFilter)]) + .then(([footprints, traces]: [FootprintHistoryEntry[], AllTracesReply]) => { + if (stopped) return; + const footprint = footprints.length > 0 ? footprints[footprints.length - 1] : null; + const trace = traces.values.length > 0 ? traces.values[0] : null; + onReply({ footprint, trace }); + }) + .catch(() => { + // Cosmetic; next tick retries. + }) + .finally(() => { + if (stopped) return; + timer = setTimeout(tick, intervalMs); + }); + }; + timer = setTimeout(tick, 0); + return { + stop(): void { + stopped = true; + if (timer !== null) clearTimeout(timer); + timer = null; + }, + }; +} + +function renderFootprint( + canvas: HTMLCanvasElement, + footprint: FootprintHistoryEntry | null, + frameWidth: number, +): void { + if (!footprint || footprint.pixelIndices.length === 0) { + canvas.width = 1; + canvas.height = 1; + return; + } + // Compute bbox in frame coords from the sparse support. + let minY = Infinity; + let maxY = -Infinity; + let minX = Infinity; + let maxX = -Infinity; + for (let i = 0; i < footprint.pixelIndices.length; i += 1) { + const idx = footprint.pixelIndices[i]; + const y = Math.floor(idx / frameWidth); + const x = idx - y * frameWidth; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + } + const padded = FOOTPRINT_BBOX_PADDING_PX; + const w = maxX - minX + 1 + padded * 2; + const h = maxY - minY + 1 + padded * 2; + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + // Normalize weights to [0, 1] for rendering. + let peak = 0; + for (let i = 0; i < footprint.values.length; i += 1) { + if (footprint.values[i] > peak) peak = footprint.values[i]; + } + if (peak <= 0) peak = 1; + const img = ctx.createImageData(w, h); + // Fill dark background. + for (let i = 0; i < img.data.length; i += 4) { + img.data[i + 3] = 255; + } + for (let i = 0; i < footprint.pixelIndices.length; i += 1) { + const idx = footprint.pixelIndices[i]; + const y = Math.floor(idx / frameWidth); + const x = idx - y * frameWidth; + const localX = x - minX + padded; + const localY = y - minY + padded; + const v = Math.round((footprint.values[i] / peak) * 255); + const j = (localY * w + localX) * 4; + img.data[j] = v; + img.data[j + 1] = v; + img.data[j + 2] = v; + img.data[j + 3] = 255; + } + ctx.putImageData(img, 0, 0); +} + +export function NeuronZoomPanel(): JSX.Element { + let canvasRef: HTMLCanvasElement | undefined; + const [client, setClient] = createSignal(null); + const [footprint, setFootprint] = createSignal(null); + const [trace, setTrace] = createSignal(null); + let poller: PollerHandle | null = null; + let frameWidth = 0; + + // Read the frame width from the orchestrator's loaded metadata so + // `pixelIndex % width` correctly unwraps to (y, x) for the + // footprint canvas. + createEffect(() => { + frameWidth = state.meta?.width ?? 0; + }); + + createEffect(() => { + const id = selectedNeuronId(); + const rs = state.runState; + const worker = currentArchiveWorkerForClient(); + // Tear down any previous polling on selection change or run-state flip. + poller?.stop(); + poller = null; + client()?.dispose(); + setClient(null); + setFootprint(null); + setTrace(null); + + if (id === null || rs !== 'running' || !worker) return; + const c = createArchiveClient(worker); + setClient(c); + poller = startNeuronPolling( + c, + id, + ({ footprint: fp, trace: tr }) => { + setFootprint(fp); + setTrace(tr ? tr.slice(-DEFAULT_TRACE_WINDOW) : null); + }, + DEFAULT_NEURON_ZOOM_POLL_INTERVAL_MS, + ); + }); + + createEffect(() => { + const canvas = canvasRef; + if (!canvas) return; + renderFootprint(canvas, footprint(), frameWidth); + }); + + onCleanup(() => { + poller?.stop(); + client()?.dispose(); + }); + + return ( + +
+
+ #{selectedNeuronId()} + +
+
+
+ +
+
+ 1} + fallback={collecting trace…} + > + + +
+
+
+
+ ); +} diff --git a/apps/cala/src/components/traces/TracesPanel.tsx b/apps/cala/src/components/traces/TracesPanel.tsx new file mode 100644 index 0000000..ebde36c --- /dev/null +++ b/apps/cala/src/components/traces/TracesPanel.tsx @@ -0,0 +1,194 @@ +import { createEffect, createSignal, onCleanup, type JSX } from 'solid-js'; +import uPlot from 'uplot'; +import 'uplot/dist/uPlot.min.css'; +import { + createArchiveClient, + type ArchiveClient, + type AllTracesReply, +} from '../../lib/archive-client.ts'; +import { currentArchiveWorkerForClient } from '../../lib/run-control.ts'; +import { state } from '../../lib/data-store.ts'; +import { setSelectedNeuronId } from '../../lib/selection-store.ts'; + +// Poll cadence for the traces strip chart. Matches the archive +// dump polling cadence so the chart lags reality by at most one +// interval on either side. +const DEFAULT_TRACES_POLL_INTERVAL_MS = 1000; +// Chart canvas size (uPlot requires explicit dims). Actual CSS +// governs visual size via the wrapper; this is the pixel density. +const DEFAULT_CHART_WIDTH_PX = 640; +const DEFAULT_CHART_HEIGHT_PX = 260; +// Line width + alpha. Strip chart gets busy at ~200 lines so thin +// strokes with transparency keep individual traces legible without +// a heavy UI stroke-per-line cost. +const TRACE_STROKE_WIDTH = 1; +const TRACE_STROKE_ALPHA = 0.6; + +interface TracesPollerHandle { + stop: () => void; +} + +function startTracesPolling( + client: ArchiveClient, + onReply: (reply: AllTracesReply) => void, + intervalMs: number, +): TracesPollerHandle { + let timer: ReturnType | null = null; + let stopped = false; + const tick = (): void => { + if (stopped) return; + client + .requestAllTraces() + .then((reply) => { + if (stopped) return; + onReply(reply); + }) + .catch(() => { + // Polling soft-fails — chart is cosmetic. Next tick retries. + }) + .finally(() => { + if (stopped) return; + timer = setTimeout(tick, intervalMs); + }); + }; + timer = setTimeout(tick, 0); + return { + stop(): void { + stopped = true; + if (timer !== null) clearTimeout(timer); + timer = null; + }, + }; +} + +/** + * Per-id color via a stable hue hash so a given neuron keeps its + * color across polls. HSL with mid saturation + luminance so no + * line disappears on the dark dashboard background. + */ +function colorForId(id: number): string { + const hue = (id * 137.508) % 360; + return `hsla(${hue.toFixed(0)}, 70%, 60%, ${TRACE_STROKE_ALPHA})`; +} + +/** + * Merge per-id (times, values) parallel arrays into uPlot's data + * shape: one shared X axis, one Y array per series padded with + * `null` where that id had no sample. + */ +function buildPlotData(reply: AllTracesReply): { + data: uPlot.AlignedData; + seriesConfig: uPlot.Series[]; +} { + if (reply.ids.length === 0) { + return { + data: [new Float64Array(0)] as unknown as uPlot.AlignedData, + seriesConfig: [{}], + }; + } + // Union of all timestamps across ids — the x-axis. + const tsSet = new Set(); + for (const ts of reply.times) { + for (let i = 0; i < ts.length; i += 1) tsSet.add(ts[i]); + } + const allTs = Array.from(tsSet).sort((a, b) => a - b); + // Per-id lookup for O(1) (t → value) alignment. + const series: (number | null)[][] = []; + for (let k = 0; k < reply.ids.length; k += 1) { + const idx = new Map(); + const ts = reply.times[k]; + const vs = reply.values[k]; + for (let j = 0; j < ts.length; j += 1) idx.set(ts[j], vs[j]); + const col: (number | null)[] = new Array(allTs.length); + for (let i = 0; i < allTs.length; i += 1) { + col[i] = idx.has(allTs[i]) ? (idx.get(allTs[i]) as number) : null; + } + series.push(col); + } + const data: uPlot.AlignedData = [allTs, ...series] as unknown as uPlot.AlignedData; + const seriesConfig: uPlot.Series[] = [ + { label: 't' }, + ...Array.from(reply.ids).map((id) => ({ + label: `#${id}`, + stroke: colorForId(id), + width: TRACE_STROKE_WIDTH, + spanGaps: false, + })), + ]; + return { data, seriesConfig }; +} + +export function TracesPanel(): JSX.Element { + let wrapRef: HTMLDivElement | undefined; + let plot: uPlot | null = null; + const [client, setClient] = createSignal(null); + let poller: TracesPollerHandle | null = null; + // Keep ids in the order uPlot's series array saw them so a click + // on series index i resolves back to the right neuron id. + let seriesIds: number[] = []; + + const renderPlot = (reply: AllTracesReply): void => { + if (!wrapRef) return; + const { data, seriesConfig } = buildPlotData(reply); + seriesIds = Array.from(reply.ids); + if (plot) { + plot.destroy(); + plot = null; + } + const opts: uPlot.Options = { + width: DEFAULT_CHART_WIDTH_PX, + height: DEFAULT_CHART_HEIGHT_PX, + series: seriesConfig, + legend: { show: false }, + scales: { x: { time: false } }, + axes: [{ label: 'frame' }, { label: 'c̃' }], + hooks: { + // Click on the plot — map the nearest point back to the + // selected series index, then to the neuron id. uPlot's + // `series` index 0 is the x-axis, so subtract 1. + setSeries: [ + (_self, seriesIdx): void => { + if (seriesIdx === null || seriesIdx <= 0) return; + const id = seriesIds[seriesIdx - 1]; + if (id !== undefined) setSelectedNeuronId(id); + }, + ], + }, + }; + plot = new uPlot(opts, data, wrapRef); + }; + + createEffect(() => { + const rs = state.runState; + if (rs === 'running') { + const worker = currentArchiveWorkerForClient(); + if (!worker) return; + const c = createArchiveClient(worker); + setClient(c); + poller = startTracesPolling(c, renderPlot, DEFAULT_TRACES_POLL_INTERVAL_MS); + } else { + poller?.stop(); + poller = null; + const c = client(); + c?.dispose(); + setClient(null); + if (plot) { + plot.destroy(); + plot = null; + } + } + }); + + onCleanup(() => { + poller?.stop(); + client()?.dispose(); + if (plot) plot.destroy(); + }); + + return ( +
+
traces
+
+
+ ); +} diff --git a/apps/cala/src/lib/__tests__/export.test.ts b/apps/cala/src/lib/__tests__/export.test.ts new file mode 100644 index 0000000..4f1fdda --- /dev/null +++ b/apps/cala/src/lib/__tests__/export.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import { parseNpz } from '@calab/io'; +import { buildCalaExportNpz } from '../export.ts'; + +describe('buildCalaExportNpz', () => { + it('round-trips through parseNpz with the expected CSC + K×T shapes', () => { + // Two neurons. #3 has support at pixels (0, 1), #7 at pixel 5. + const footprints = { + ids: Uint32Array.of(3, 7), + pixelIndices: [Uint32Array.of(0, 1), Uint32Array.of(5)], + values: [Float32Array.of(0.5, 0.5), Float32Array.of(0.9)], + }; + // Traces sampled at t=10, 20 for #3 and t=20, 30 for #7. + const traces = { + ids: Uint32Array.of(3, 7), + times: [Float32Array.of(10, 20), Float32Array.of(20, 30)], + values: [Float32Array.of(0.1, 0.2), Float32Array.of(0.7, 0.8)], + }; + const meta = { height: 2, width: 4 }; + + const npz = buildCalaExportNpz({ footprints, traces, meta }); + const parsed = parseNpz(npz.buffer as ArrayBuffer); + + // CSC: 3 non-zeros total. indptr = [0, 2, 3]. + const aData = parsed.arrays.A_data.data; + const aIndices = parsed.arrays.A_indices.data; + const aIndptr = parsed.arrays.A_indptr.data; + const aShape = parsed.arrays.A_shape.data; + expect(aData.length).toBe(3); + expect(Array.from(aIndices)).toEqual([0, 1, 5]); + expect(Array.from(aIndptr)).toEqual([0, 2, 3]); + expect(Array.from(aShape)).toEqual([8, 2]); // 2·4 pixels, 2 components + + // Union time axis = [10, 20, 30]. K=2. + const cTimes = parsed.arrays.C_times.data; + expect(Array.from(cTimes)).toEqual([10, 20, 30]); + const cShape = parsed.arrays.C.shape; + expect(cShape).toEqual([2, 3]); + + // Row-major K×T: row 0 = #3's trace at [10, 20, 30]; NaN at 30. + const cFlat = parsed.arrays.C.data; + expect(cFlat[0]).toBeCloseTo(0.1); + expect(cFlat[1]).toBeCloseTo(0.2); + expect(Number.isNaN(cFlat[2])).toBe(true); + // Row 1 = #7's trace: NaN at 10, then samples. + expect(Number.isNaN(cFlat[3])).toBe(true); + expect(cFlat[4]).toBeCloseTo(0.7); + expect(cFlat[5]).toBeCloseTo(0.8); + + expect(Array.from(parsed.arrays.height.data)).toEqual([2]); + expect(Array.from(parsed.arrays.width.data)).toEqual([4]); + expect(Array.from(parsed.arrays.footprint_ids.data)).toEqual([3, 7]); + expect(Array.from(parsed.arrays.C_ids.data)).toEqual([3, 7]); + }); + + it('handles zero footprints / zero traces without crashing', () => { + const footprints = { + ids: new Uint32Array(0), + pixelIndices: [], + values: [], + }; + const traces = { + ids: new Uint32Array(0), + times: [], + values: [], + }; + const meta = { height: 4, width: 4 }; + const npz = buildCalaExportNpz({ footprints, traces, meta }); + const parsed = parseNpz(npz.buffer as ArrayBuffer); + expect(parsed.arrays.A_data.data.length).toBe(0); + expect(Array.from(parsed.arrays.A_indptr.data)).toEqual([0]); + expect(parsed.arrays.C.shape).toEqual([0, 0]); + }); +}); diff --git a/apps/cala/src/lib/archive-client.ts b/apps/cala/src/lib/archive-client.ts index 5f81ba8..e3591f5 100644 --- a/apps/cala/src/lib/archive-client.ts +++ b/apps/cala/src/lib/archive-client.ts @@ -29,11 +29,30 @@ export interface FootprintHistoryEntry { values: Float32Array; } +export interface AllTracesReply { + /** Parallel arrays — `ids[i]`, `times[i]`, `values[i]`. */ + ids: Uint32Array; + /** Per-id time axis in chronological order. */ + times: Float32Array[]; + /** Per-id trace values aligned with `values`. */ + values: Float32Array[]; +} + +export interface AllFootprintsReply { + ids: Uint32Array; + /** Sparse support per id — linear pixel indices. */ + pixelIndices: Uint32Array[]; + /** Per-pixel weight, aligned with `pixelIndices`. */ + values: Float32Array[]; +} + export interface ArchiveClient { requestDump(): Promise; requestTimeseries(name: string): Promise; requestEventsForNeuron(neuronId: number): Promise; requestFootprintHistory(neuronId: number): Promise; + requestAllTraces(idFilter?: Uint32Array): Promise; + requestAllFootprints(): Promise; startPolling(cb: (dump: ArchiveDump) => void): void; stopPolling(): void; onEvent(cb: (e: PipelineEvent) => void): Unsubscribe; @@ -67,14 +86,22 @@ interface PendingReply { resolve: (v: T) => void; reject: (err: Error) => void; timer: ReturnType; - kind: 'dump' | 'timeseries' | 'events-for-neuron' | 'footprint-history'; + kind: + | 'dump' + | 'timeseries' + | 'events-for-neuron' + | 'footprint-history' + | 'all-traces' + | 'all-footprints'; } type PendingEntry = | PendingReply | PendingReply | PendingReply - | PendingReply; + | PendingReply + | PendingReply + | PendingReply; export function createArchiveClient( worker: WorkerLike, @@ -145,6 +172,30 @@ export function createArchiveClient( (entry as PendingReply).resolve(history); return; } + case 'all-traces': { + const entry = pending.get(msg.requestId); + if (!entry || entry.kind !== 'all-traces') return; + pending.delete(msg.requestId); + clearTimeout(entry.timer); + (entry as PendingReply).resolve({ + ids: msg.ids, + times: msg.times, + values: msg.values, + }); + return; + } + case 'all-footprints': { + const entry = pending.get(msg.requestId); + if (!entry || entry.kind !== 'all-footprints') return; + pending.delete(msg.requestId); + clearTimeout(entry.timer); + (entry as PendingReply).resolve({ + ids: msg.ids, + pixelIndices: msg.pixelIndices, + values: msg.values, + }); + return; + } case 'event': for (const cb of eventListeners) cb(msg.event); return; @@ -216,6 +267,18 @@ export function createArchiveClient( ); } + function requestAllTraces(idFilter?: Uint32Array): Promise { + return issueRequest('all-traces', 'all-traces', (requestId) => { + worker.postMessage({ kind: 'request-all-traces', requestId, idFilter }); + }); + } + + function requestAllFootprints(): Promise { + return issueRequest('all-footprints', 'all-footprints', (requestId) => { + worker.postMessage({ kind: 'request-all-footprints', requestId }); + }); + } + function startPolling(cb: (dump: ArchiveDump) => void): void { if (disposed) return; pollCallback = cb; @@ -272,6 +335,8 @@ export function createArchiveClient( requestTimeseries, requestEventsForNeuron, requestFootprintHistory, + requestAllTraces, + requestAllFootprints, startPolling, stopPolling, onEvent, diff --git a/apps/cala/src/lib/export.ts b/apps/cala/src/lib/export.ts new file mode 100644 index 0000000..4beab91 --- /dev/null +++ b/apps/cala/src/lib/export.ts @@ -0,0 +1,113 @@ +import { writeNpz } from '@calab/io'; +import type { AllFootprintsReply, AllTracesReply } from './archive-client.ts'; + +/** + * CaLa export bundle (Phase 7 task 15). Produces an `.npz` in the + * scipy.sparse-CSC convention so `scipy.sparse.csc_matrix` can load + * `A` directly: + * + * A = csc_matrix( + * (npz['A_data'], npz['A_indices'], npz['A_indptr']), + * shape=npz['A_shape'], + * ) + * + * Plus a dense K×T trace matrix aligned on a single time axis, + * padded with `NaN` where a given neuron had no sample at that + * timestamp. + * + * Deferred: events export (JSON-in-NPZ is awkward; for now the + * structural events live in the UI feed and the archive worker's + * in-memory log only). + */ +export interface CalaExportInputs { + footprints: AllFootprintsReply; + traces: AllTracesReply; + meta: { height: number; width: number }; +} + +export function buildCalaExportNpz(inputs: CalaExportInputs): Uint8Array { + const { footprints, traces, meta } = inputs; + const pixels = meta.height * meta.width; + const k = footprints.ids.length; + + // Build A in CSC. Column j is neuron j; `indices[indptr[j]..indptr[j+1]]` + // are the pixel row indices, `data[...]` are the weights. + let totalNnz = 0; + for (let j = 0; j < k; j += 1) totalNnz += footprints.pixelIndices[j].length; + const aData = new Float32Array(totalNnz); + const aIndices = new Uint32Array(totalNnz); + const aIndptr = new Uint32Array(k + 1); + { + let cursor = 0; + for (let j = 0; j < k; j += 1) { + aIndptr[j] = cursor; + const idx = footprints.pixelIndices[j]; + const vals = footprints.values[j]; + aIndices.set(idx, cursor); + aData.set(vals, cursor); + cursor += idx.length; + } + aIndptr[k] = cursor; + } + + // Build dense C aligned on the union of all per-id timestamps. + // Ids in the `traces` reply are parallel to `traces.times` / + // `traces.values` — the footprints' `ids` list can differ (e.g. a + // neuron may have just been born and have no trace samples yet), + // so we re-index C by the trace reply's own id order. + const tUnionSet = new Set(); + for (const ts of traces.times) { + for (let i = 0; i < ts.length; i += 1) tUnionSet.add(ts[i]); + } + const tUnion = Uint32Array.from(Array.from(tUnionSet).sort((a, b) => a - b)); + const cK = traces.ids.length; + const cT = tUnion.length; + // Row-major K×T with NaN sentinel for "no sample at this (id, t)". + const cFlat = new Float32Array(cK * cT); + cFlat.fill(Number.NaN); + // tIndex maps t → column in cFlat. Built once. + const tIndex = new Map(); + for (let i = 0; i < cT; i += 1) tIndex.set(tUnion[i], i); + for (let k2 = 0; k2 < cK; k2 += 1) { + const times = traces.times[k2]; + const values = traces.values[k2]; + for (let i = 0; i < times.length; i += 1) { + const col = tIndex.get(times[i]); + if (col === undefined) continue; + cFlat[k2 * cT + col] = values[i]; + } + } + + const aShape = Uint32Array.of(pixels, k); + const heightArr = Uint32Array.of(meta.height); + const widthArr = Uint32Array.of(meta.width); + + return writeNpz({ + // Footprints, sparse CSC. + A_data: { data: aData, shape: [aData.length] }, + A_indices: { data: aIndices, shape: [aIndices.length] }, + A_indptr: { data: aIndptr, shape: [aIndptr.length] }, + A_shape: { data: aShape, shape: [aShape.length] }, + // Footprint id list (parallel to A's columns). + footprint_ids: { data: footprints.ids, shape: [footprints.ids.length] }, + // Traces, dense K×T with NaN gaps. + C: { data: cFlat, shape: [cK, cT] }, + C_times: { data: tUnion, shape: [cT] }, + C_ids: { data: traces.ids, shape: [cK] }, + // Frame geometry. + height: { data: heightArr, shape: [1] }, + width: { data: widthArr, shape: [1] }, + }); +} + +export function triggerDownload(npz: Uint8Array, filename: string): void { + const blob = new Blob([npz as unknown as BlobPart], { type: 'application/zip' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} diff --git a/apps/cala/src/lib/max-projection-store.ts b/apps/cala/src/lib/max-projection-store.ts new file mode 100644 index 0000000..eb26407 --- /dev/null +++ b/apps/cala/src/lib/max-projection-store.ts @@ -0,0 +1,58 @@ +import { createSignal, type Accessor } from 'solid-js'; +import type { LatestFramePreview } from './run-control.ts'; + +/** + * Main-thread running-max projection of the motion-corrected preview + * stream (design §8 footprints panel, Phase 7 task 10). We accumulate + * here instead of inside the archive worker because W1 already posts + * the motion-corrected frame to the main thread as a `frame-preview` + * message — routing it through archive would add a redundant copy + * across an extra worker boundary. + * + * Shape: same `Uint8ClampedArray` layout as the preview (u8 gray, + * height·width). The footprints panel blits it into an `ImageData` + * and overlays footprint boundaries on top. + */ +interface MaxProjection { + width: number; + height: number; + pixels: Uint8ClampedArray; + frameCount: number; +} + +const [maxProjectionSignal, setMaxProjectionSignal] = createSignal(null); + +export const maxProjection: Accessor = maxProjectionSignal; + +/** + * Fold a new motion-stage preview into the running max. Called by + * `run-control`'s W1 frame-preview listener whenever a `motion` + * stage frame arrives. Dimension changes (new recording) reset the + * buffer; same dims accumulate element-wise max. + */ +export function updateMaxProjection(frame: LatestFramePreview): void { + const cur = maxProjectionSignal(); + if (!cur || cur.width !== frame.width || cur.height !== frame.height) { + setMaxProjectionSignal({ + width: frame.width, + height: frame.height, + pixels: new Uint8ClampedArray(frame.pixels), + frameCount: 1, + }); + return; + } + const next = new Uint8ClampedArray(cur.pixels); + for (let i = 0; i < next.length; i += 1) { + if (frame.pixels[i] > next[i]) next[i] = frame.pixels[i]; + } + setMaxProjectionSignal({ + width: cur.width, + height: cur.height, + pixels: next, + frameCount: cur.frameCount + 1, + }); +} + +export function resetMaxProjection(): void { + setMaxProjectionSignal(null); +} diff --git a/apps/cala/src/lib/run-control.ts b/apps/cala/src/lib/run-control.ts index 3cff8d8..62a9ed2 100644 --- a/apps/cala/src/lib/run-control.ts +++ b/apps/cala/src/lib/run-control.ts @@ -14,6 +14,7 @@ import { } from '@calab/cala-runtime'; import { state, setRunState, setErrorMsg } from './data-store.ts'; import { recordFrameProcessed } from './dashboard-store.ts'; +import { resetMaxProjection, updateMaxProjection } from './max-projection-store.ts'; import { createDecodePreprocessWorker, createFitWorker, @@ -68,12 +69,23 @@ export interface LatestFramePreview { pixels: Uint8ClampedArray; } +export type FrameStage = 'raw' | 'hotPixel' | 'motion' | 'reconstruction'; + +export type LatestFramesByStage = Partial>; + // Signal (not store) because the preview updates every few frames and // fine-grained store reactivity is wasted overhead — the viewer // re-renders the whole canvas per update regardless. -const [latestFrameSignal, setLatestFrameSignal] = createSignal(null); +const [latestFramesSignal, setLatestFramesSignal] = createSignal({}); -export const latestFrame: Accessor = latestFrameSignal; +export const latestFrames: Accessor = latestFramesSignal; + +// Back-compat for callers that only want the final preprocess stage +// (the single-frame viewer reads this; the 4-canvas panel reads +// `latestFrames` directly). Tracks the `motion` stage since that is +// what the Phase 6 viewer always showed — the frame fit sees. +export const latestFrame: Accessor = () => + latestFramesSignal().motion ?? null; function buildConfig(meta: FrameSourceMeta, factories: WorkerFactories): RuntimeConfig { const frameBytes = meta.width * meta.height * BYTES_PER_F32_PIXEL; @@ -111,6 +123,7 @@ function buildConfig(meta: FrameSourceMeta, factories: WorkerFactories): Runtime fit: { height: meta.height, width: meta.width, + framePreviewStride: DEFAULT_FRAME_PREVIEW_STRIDE, // Shared with W1's metadata: extend's `RecordingMetadata` // parser (task 11) needs `pixel_size_um` to translate the // neuron-diameter gate into pixels. @@ -162,12 +175,19 @@ function wrapFactories(base: WorkerFactories): WorkerFactories { const listener = (ev: { data: WorkerOutbound }): void => { const msg = ev.data; if (msg.kind === 'frame-preview') { - setLatestFrameSignal({ + const preview = { index: msg.index, width: msg.width, height: msg.height, pixels: msg.pixels, - }); + }; + setLatestFramesSignal((prev) => ({ + ...prev, + [msg.stage]: preview, + })); + // Running max projection off the motion stage — footprints + // panel (Phase 7 task 10) renders on top of this. + if (msg.stage === 'motion') updateMaxProjection(preview); return; } }; @@ -177,11 +197,28 @@ function wrapFactories(base: WorkerFactories): WorkerFactories { 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. + // heartbeat. W1's `frame-processed` is ignored here. Phase 7 + // task 6 added `frame-preview` posts with `stage: + // 'reconstruction'` — route them into the same `latestFrames` + // signal as W1 so the 4-canvas frame panel can read all four + // stages from one place. const listener = (ev: { data: WorkerOutbound }): void => { const msg = ev.data; if (msg.kind === 'frame-processed') { recordFrameProcessed(msg.index, msg.epoch); + return; + } + if (msg.kind === 'frame-preview') { + setLatestFramesSignal((prev) => ({ + ...prev, + [msg.stage]: { + index: msg.index, + width: msg.width, + height: msg.height, + pixels: msg.pixels, + }, + })); + return; } }; worker.addEventListener('message', listener); @@ -209,6 +246,7 @@ export async function startRun(opts: StartOptions = {}): Promise { setErrorMsg(null); setRunState('starting'); + resetMaxProjection(); const baseFactories = opts.factories ?? defaultWorkerFactories(); const factories = wrapFactories(baseFactories); @@ -241,14 +279,30 @@ export async function startRun(opts: StartOptions = {}): Promise { currentPreviewDetach = null; currentFitDetach?.(); currentFitDetach = null; - currentArchiveWorker = null; + // Intentionally *keep* `currentArchiveWorker` alive after a + // natural run end so the export flow (Phase 7 task 15) can + // still reach the archive worker's queries while the run state + // is `stopped`. The next `startRun` call wipes it via + // `wrapFactories` re-spawning a fresh archive worker, and a + // full teardown is covered by the `stopRun` path + the + // `currentRuntime === null` gate. } } export async function stopRun(): Promise { const rt = currentRuntime; - if (rt === null) return; - await rt.stop(); + if (rt === null) { + // No active run; still clear any lingering post-completion + // archive worker reference so export can't post to a dead + // worker after explicit user teardown. + currentArchiveWorker = null; + return; + } + try { + await rt.stop(); + } finally { + currentArchiveWorker = null; + } } export function currentRunState(): RuntimeState { diff --git a/apps/cala/src/lib/selection-store.ts b/apps/cala/src/lib/selection-store.ts new file mode 100644 index 0000000..4b59320 --- /dev/null +++ b/apps/cala/src/lib/selection-store.ts @@ -0,0 +1,14 @@ +import { createSignal, type Accessor, type Setter } from 'solid-js'; + +/** + * Shared "currently-selected neuron id" signal used by the traces + * panel (T9), footprints panel (T11), and per-neuron zoom panel + * (T12). A single source of truth keeps those three panels in sync + * so a click in any of them drives the others. + * + * `null` = no selection. + */ +const [selectedNeuronIdSignal, setSelectedNeuronIdInner] = createSignal(null); + +export const selectedNeuronId: Accessor = selectedNeuronIdSignal; +export const setSelectedNeuronId: Setter = setSelectedNeuronIdInner; diff --git a/apps/cala/src/styles/global.css b/apps/cala/src/styles/global.css index e8aba98..da2eac8 100644 --- a/apps/cala/src/styles/global.css +++ b/apps/cala/src/styles/global.css @@ -30,6 +30,75 @@ align-items: start; } +/* 4-canvas frame panel (Phase 7 task 7). */ +.frame-quad { + display: flex; + flex-direction: column; + gap: var(--space-sm); + padding: var(--space-md); +} + +.frame-quad__grid { + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr 1fr; + gap: var(--space-md); +} + +.frame-quad__caption { + font-family: var(--font-mono); + font-size: 0.8rem; + color: var(--text-secondary); + padding-top: var(--space-xs); + border-top: 1px solid var(--border-subtle); +} + +.frame-stage { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.frame-stage__label { + font-family: var(--font-body); + font-size: 0.75rem; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.frame-stage__canvas-wrap { + position: relative; + background: var(--bg-secondary); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + padding: var(--space-xs); + display: flex; + align-items: center; + justify-content: center; + min-height: 160px; +} + +.frame-stage__canvas { + image-rendering: pixelated; + max-width: 100%; + height: auto; + background: var(--bg-inset); + display: block; +} + +.frame-stage__placeholder { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-tertiary); + font-family: var(--font-mono); + font-size: 0.75rem; + pointer-events: none; +} + .frame-viewer__canvas-wrap { position: relative; background: var(--bg-secondary); @@ -248,19 +317,55 @@ .cala-dashboard { display: grid; - grid-template-columns: minmax(0, 1fr) 360px; - grid-template-rows: auto minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 360px; + grid-template-rows: auto minmax(0, 1fr) auto; grid-template-areas: - 'vitals vitals' - 'frame events'; + 'vitals vitals vitals' + 'frame footprints events' + 'traces traces events'; gap: var(--space-md); padding: var(--space-md); height: 100%; min-height: 0; } +.cala-dashboard__footprints { + grid-area: footprints; + min-width: 0; + min-height: 0; +} + .cala-dashboard__vitals { grid-area: vitals; + display: flex; + align-items: center; + gap: var(--space-md); +} + +.cala-dashboard__vitals > :first-child { + flex: 1 1 auto; + min-width: 0; +} + +.export-button { + font-family: var(--font-mono); + font-size: 0.8rem; + padding: var(--space-xs) var(--space-md); + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + cursor: pointer; + white-space: nowrap; +} + +.export-button:hover:not(:disabled) { + background: var(--bg-inset); +} + +.export-button:disabled { + opacity: 0.4; + cursor: not-allowed; } .cala-dashboard__frame { @@ -269,6 +374,11 @@ min-height: 0; } +.cala-dashboard__traces { + grid-area: traces; + min-width: 0; +} + .cala-dashboard__events { grid-area: events; min-height: 0; @@ -280,6 +390,134 @@ flex-direction: column; } +.traces-panel { + background: var(--bg-secondary); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + padding: var(--space-sm); + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.traces-panel__header { + font-family: var(--font-body); + font-size: 0.75rem; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.traces-panel__chart { + min-height: 260px; +} + +.footprints-panel { + background: var(--bg-secondary); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + padding: var(--space-sm); + display: flex; + flex-direction: column; + gap: var(--space-xs); + min-height: 0; +} + +.footprints-panel__header { + font-family: var(--font-body); + font-size: 0.75rem; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.footprints-panel__canvas-wrap { + position: relative; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-inset); + border-radius: var(--radius-sm); + min-height: 260px; + padding: var(--space-xs); +} + +.footprints-panel__canvas { + image-rendering: pixelated; + max-width: 100%; + height: auto; + cursor: crosshair; + display: block; +} + +/* Per-neuron zoom panel (Phase 7 task 12). */ +.neuron-zoom { + background: var(--bg-inset); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + padding: var(--space-sm); + display: flex; + flex-direction: column; + gap: var(--space-xs); + margin-bottom: var(--space-sm); +} + +.neuron-zoom__header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.neuron-zoom__id { + font-family: var(--font-mono); + font-size: 0.9rem; + color: var(--text-primary); +} + +.neuron-zoom__close { + background: none; + border: none; + color: var(--text-tertiary); + font-size: 1.1rem; + cursor: pointer; + padding: 0 var(--space-xs); +} + +.neuron-zoom__close:hover { + color: var(--text-primary); +} + +.neuron-zoom__body { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: var(--space-sm); + align-items: center; +} + +.neuron-zoom__footprint-wrap { + display: flex; + align-items: center; + justify-content: center; + min-width: 64px; +} + +.neuron-zoom__footprint { + image-rendering: pixelated; + width: 64px; + height: 64px; + background: black; +} + +.neuron-zoom__trace { + min-width: 0; +} + +.neuron-zoom__empty { + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--text-tertiary); +} + /* Simplified single-frame viewer: no side panel in the new layout. */ .frame-viewer { diff --git a/apps/cala/src/workers/__tests__/decode-preprocess.worker.test.ts b/apps/cala/src/workers/__tests__/decode-preprocess.worker.test.ts index fd8442e..a3f3a7f 100644 --- a/apps/cala/src/workers/__tests__/decode-preprocess.worker.test.ts +++ b/apps/cala/src/workers/__tests__/decode-preprocess.worker.test.ts @@ -18,6 +18,7 @@ interface MockFrameSource extends FrameSource { interface MockPreprocessor { processFrameF32: ReturnType; + processFrameF32WithStages: ReturnType; free: ReturnType; freed: boolean; } @@ -67,6 +68,7 @@ vi.mock('@calab/io', () => ({ vi.mock('@calab/cala-core', () => { class Preprocessor { processFrameF32: ReturnType; + processFrameF32WithStages: ReturnType; free: ReturnType; freed = false; constructor() { @@ -80,6 +82,20 @@ vi.mock('@calab/cala-core', () => { out[0] += 1; return out; }); + this.processFrameF32WithStages = vi.fn((input: Float32Array) => { + if (mockState.preprocessShouldThrow) throw mockState.preprocessShouldThrow; + // Emit [final || hotPixel || motion]. Final echoes + // processFrameF32's +1 adjustment so tests can distinguish the + // hot-path from preview-path writes to the SAB channel. + const out = new Float32Array(input.length * 3); + out.set(input, 0); + out[0] += 1; + // hotPixel: echo raw input untouched + out.set(input, input.length); + // motion: raw + marker to disambiguate in assertions + out.set(input, input.length * 2); + return out; + }); this.free = vi.fn(() => { this.freed = true; }); diff --git a/apps/cala/src/workers/__tests__/fit.worker.test.ts b/apps/cala/src/workers/__tests__/fit.worker.test.ts index fb11521..33397e4 100644 --- a/apps/cala/src/workers/__tests__/fit.worker.test.ts +++ b/apps/cala/src/workers/__tests__/fit.worker.test.ts @@ -59,6 +59,35 @@ const mockState = { nextCycleProposals: 0, }; +function mockMutationToAppliedEvent(m: PipelineMutation, _newId: number): Record { + // Minimal translation matching the Rust `AppliedEvent` wire shape. + // Ids come from the mock fitter's epoch so tests can match on + // deterministic numbers; `values` and `support` come straight from + // the queued mutation. + switch (m.type) { + case 'register': + return { + kind: 'birth', + id: _newId, + class: m.class, + support: Array.from(m.support), + values: Array.from(m.values), + patch: [0, 0], + }; + case 'merge': + return { + kind: 'merge', + ids: [m.mergeIds[0], m.mergeIds[1]], + into: _newId, + class: m.class, + support: Array.from(m.support), + values: Array.from(m.values), + }; + case 'deprecate': + return { kind: 'deprecate', id: m.id, reason: m.reason }; + } +} + vi.mock('@calab/cala-core', () => { class Fitter { stepCalls: Float32Array[] = []; @@ -119,6 +148,43 @@ vi.mock('@calab/cala-core', () => { return new Uint32Array([0, 0, 0]); } + // Phase 7 T2/T3 surface. Returns the same `{ report, events }` + // shape the real WASM binding produces. The mock synthesizes + // `AppliedEvent`s from `mutationsToDrain` so tests can assert on + // structural events without a real WASM pipeline. + drainApplyEvents(): { + report: [number, number, number]; + events: Array>; + } { + this.drainCalls += 1; + this.self.drainCalls = this.drainCalls; + const events: Array> = []; + const next = mockState.mutationsToDrain.shift(); + if (next) { + this.self.mutationApplies.push(next); + this.currentEpoch += 1n; + this.self.epoch = this.currentEpoch; + events.push(mockMutationToAppliedEvent(next, Number(this.currentEpoch) - 1)); + return { report: [1, 0, 0], events }; + } + this.self.epoch = this.currentEpoch; + return { report: [0, 0, 0], events }; + } + + reconstructLastFrame(): Float32Array { + // Empty = no components yet; matches the real Fitter's + // "before first step" behavior so the preview emitter skips + // without breaking. Tests that exercise the preview path + // can override this per-test. + return new Float32Array(0); + } + componentIds(): Uint32Array { + return new Uint32Array(0); + } + lastTrace(): Float32Array { + return new Float32Array(0); + } + takeSnapshot(): { epoch(): bigint; numComponents(): number; pixels(): number; free(): void } { this.snapshotCalls += 1; this.self.snapshotCalls = this.snapshotCalls; @@ -174,6 +240,10 @@ vi.mock('@calab/cala-core', () => { return { initCalaCore: vi.fn(async () => {}), calaMemoryBytes: vi.fn(() => 1024 * 1024), + drainApplyEventsTyped: ( + fitter: { drainApplyEvents: (q: unknown) => unknown }, + queue: unknown, + ) => fitter.drainApplyEvents(queue), Fitter, MutationQueueHandle, Extender, diff --git a/apps/cala/src/workers/archive.worker.ts b/apps/cala/src/workers/archive.worker.ts index e119589..4f20953 100644 --- a/apps/cala/src/workers/archive.worker.ts +++ b/apps/cala/src/workers/archive.worker.ts @@ -31,6 +31,7 @@ import { import { TimeseriesStore } from './timeseries-store.ts'; import { NeuronEventIndex } from './neuron-event-index.ts'; import { FootprintHistoryStore } from './footprint-history-store.ts'; +import { NeuronTraceStore } from './neuron-trace-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 @@ -62,6 +63,12 @@ const DEFAULT_MAX_INDEXED_NEURONS = 1024; // 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; +// Per-neuron trace ring (design §8 traces panel, Phase 7 task 8). +// 256 samples at vitals-stride cadence (~4 Hz) covers ~1 min per +// neuron — enough for the scrolling strip chart without pressure +// on the event bus. +const DEFAULT_TRACE_RING_CAPACITY = 256; +const DEFAULT_TRACE_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. @@ -90,6 +97,8 @@ interface ArchiveWorkerConfig { maxIndexedNeurons: number; footprintHistoryLimit: number; footprintHistoryMaxNeurons: number; + traceRingCapacity: number; + traceMaxNeurons: number; } const workerSelf = ((globalThis as unknown as { self?: WorkerGlobalScope }).self ?? @@ -112,6 +121,8 @@ interface RuntimeHandles { unsubscribeNeuronIndex: () => void; footprints: FootprintHistoryStore; unsubscribeFootprints: () => void; + traces: NeuronTraceStore; + unsubscribeTraces: () => void; running: boolean; stopped: boolean; } @@ -159,6 +170,8 @@ function parseConfig(raw: unknown): ArchiveWorkerConfig { 'footprintHistoryMaxNeurons', DEFAULT_FOOTPRINT_HISTORY_MAX_NEURONS, ), + traceRingCapacity: pickPositiveInt('traceRingCapacity', DEFAULT_TRACE_RING_CAPACITY), + traceMaxNeurons: pickPositiveInt('traceMaxNeurons', DEFAULT_TRACE_MAX_NEURONS), }; } @@ -184,8 +197,17 @@ function handleInit(payload: WorkerInitPayload): void { perNeuronLimit: cfg.footprintHistoryLimit, maxNeurons: cfg.footprintHistoryMaxNeurons, }); + const traces = new NeuronTraceStore({ + capacity: cfg.traceRingCapacity, + maxNeurons: cfg.traceMaxNeurons, + }); const unsubscribeLog = bus.subscribe((e) => { + // Trace samples fire every vitals-stride frame and carry a + // full-K Float32Array — routing them through the structural + // event log would blow the ring and flood the event feed. The + // traces store (below) is their canonical sink. + if (e.kind === 'trace-sample') return; if (eventLog.length === cfg.eventRingCapacity) { eventLog.shift(); } @@ -236,10 +258,16 @@ function handleInit(payload: WorkerInitPayload): void { case 'deprecate': case 'reject': case 'metric': + case 'trace-sample': return; } }); + const unsubscribeTraces = bus.subscribe((e) => { + if (e.kind !== 'trace-sample') return; + traces.append(e.t, e.ids, e.values); + }); + handles = { cfg, bus, @@ -251,6 +279,8 @@ function handleInit(payload: WorkerInitPayload): void { unsubscribeTimeseries, neuronIndex, unsubscribeNeuronIndex, + traces, + unsubscribeTraces, footprints, unsubscribeFootprints, running: false, @@ -326,6 +356,46 @@ function handleFootprintHistoryRequest(requestId: number, neuronId: number): voi }); } +function handleAllTracesRequest(requestId: number, idFilter?: Uint32Array): void { + if (!handles) return; + const filter = idFilter ? Array.from(idFilter) : undefined; + const result = handles.traces.queryAll(filter); + post({ + kind: 'all-traces', + role: ROLE, + requestId, + ids: Uint32Array.from(result.ids), + times: result.times, + values: result.values, + }); +} + +function handleAllFootprintsRequest(requestId: number): void { + if (!handles) return; + // Live = latest structural event is not a deprecate. We also + // require a footprint history entry (otherwise we have nothing to + // send). Order matches `liveIds()` insertion order. + const ids: number[] = []; + const pixelIndicesOut: Uint32Array[] = []; + const valuesOut: Float32Array[] = []; + for (const id of handles.neuronIndex.liveIds()) { + const history = handles.footprints.query(id); + if (history.length === 0) continue; + const latest = history[history.length - 1]; + ids.push(id); + pixelIndicesOut.push(latest.pixelIndices); + valuesOut.push(latest.values); + } + post({ + kind: 'all-footprints', + role: ROLE, + requestId, + ids: Uint32Array.from(ids), + pixelIndices: pixelIndicesOut, + values: valuesOut, + }); +} + function postDoneOnce(): void { if (donePosted) return; donePosted = true; @@ -343,6 +413,7 @@ function handleStop(): void { handles.unsubscribeTimeseries(); handles.unsubscribeNeuronIndex(); handles.unsubscribeFootprints(); + handles.unsubscribeTraces(); handles.bus.close(); postDoneOnce(); } @@ -375,6 +446,12 @@ workerSelf.onmessage = (ev: MessageEvent): void => { case 'request-footprint-history': handleFootprintHistoryRequest(msg.requestId, msg.neuronId); return; + case 'request-all-traces': + handleAllTracesRequest(msg.requestId, msg.idFilter); + return; + case 'request-all-footprints': + handleAllFootprintsRequest(msg.requestId); + return; case 'stop': handleStop(); return; diff --git a/apps/cala/src/workers/decode-preprocess.worker.ts b/apps/cala/src/workers/decode-preprocess.worker.ts index 7508914..1c86f1c 100644 --- a/apps/cala/src/workers/decode-preprocess.worker.ts +++ b/apps/cala/src/workers/decode-preprocess.worker.ts @@ -159,27 +159,61 @@ async function handleInit(payload: WorkerInitPayload): Promise { } async function decodeLoop(h: RuntimeHandles): Promise { + const pixels = h.width * h.height; for (let i = 0; i < h.frameCount; i += 1) { if (stopRequested) return; const frame = await h.frameSource.readFrame(i, h.grayscaleMethod); if (stopRequested) return; - const processed = h.preprocessor.processFrameF32(frame); - // Epoch is fit-owned; W1 tags SAB slots with 0n. Fit does not - // rely on this tag for demux — it advances its own epoch on - // mutation-applied acks (design §7.3). - h.frameChannel.writeSlot(processed, 0n); - if ((i + 1) % h.heartbeatStride === 0) { - post({ kind: 'frame-processed', role: ROLE, index: i, epoch: 0n }); - } - if (h.framePreviewStride > 0 && (i + 1) % h.framePreviewStride === 0) { + const wantPreview = h.framePreviewStride > 0 && (i + 1) % h.framePreviewStride === 0; + if (wantPreview) { + // Preview stride — call the stage-capturing variant so we can + // post raw / hotPixel / motion previews for the 4-canvas panel + // (design §12, Phase 7 task 5). Output layout: + // [final || hotPixel || motion] + const combined = h.preprocessor.processFrameF32WithStages(frame); + const finalFrame = combined.subarray(0, pixels); + const hotPixelFrame = combined.subarray(pixels, 2 * pixels); + const motionFrame = combined.subarray(2 * pixels, 3 * pixels); + // Fit reads the post-preprocess frame from the SAB slot. Write + // `finalFrame` (motion-corrected, post-denoise when on) so the + // hot path sees the same data it always did. + h.frameChannel.writeSlot(finalFrame, 0n); + post({ + kind: 'frame-preview', + role: ROLE, + index: i, + width: h.width, + height: h.height, + stage: 'raw', + pixels: quantizeToU8(frame), + }); post({ kind: 'frame-preview', role: ROLE, index: i, width: h.width, height: h.height, - pixels: quantizeToU8(processed), + stage: 'hotPixel', + pixels: quantizeToU8(hotPixelFrame), }); + post({ + kind: 'frame-preview', + role: ROLE, + index: i, + width: h.width, + height: h.height, + stage: 'motion', + pixels: quantizeToU8(motionFrame), + }); + } else { + const processed = h.preprocessor.processFrameF32(frame); + h.frameChannel.writeSlot(processed, 0n); + } + // Epoch is fit-owned; W1 tags SAB slots with 0n. Fit does not + // rely on this tag for demux — it advances its own epoch on + // mutation-applied acks (design §7.3). + if ((i + 1) % h.heartbeatStride === 0) { + post({ kind: 'frame-processed', role: ROLE, index: i, epoch: 0n }); } } } diff --git a/apps/cala/src/workers/fit.worker.ts b/apps/cala/src/workers/fit.worker.ts index f47651e..bfda911 100644 --- a/apps/cala/src/workers/fit.worker.ts +++ b/apps/cala/src/workers/fit.worker.ts @@ -4,6 +4,8 @@ import { Fitter, MutationQueueHandle, calaMemoryBytes, + drainApplyEventsTyped, + type WasmAppliedEvent, } from '@calab/cala-core'; import { METRIC_CELL_COUNT, @@ -73,6 +75,9 @@ const DEFAULT_VITALS_STRIDE = 8; // (design §9.3). Matches the archive's footprint-history neuron cap // so upstream and storage stay within the same envelope. const DEFAULT_FOOTPRINT_SCHEDULER_MAX_NEURONS = 512; +// Reconstruction preview cadence (design §12 frame panel, Phase 7 +// task 6). 0 disables. Overridable via `workerConfig.framePreviewStride`. +const DEFAULT_FRAME_PREVIEW_STRIDE = 0; // 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 @@ -117,6 +122,7 @@ interface FitWorkerConfig { frameChannelSlotCount: number; frameChannelWaitTimeoutMs: number; frameChannelPollIntervalMs: number; + framePreviewStride: number; } // Route through `self` when present so `vi.stubGlobal('self', harness)` @@ -228,6 +234,7 @@ function parseConfig(raw: unknown): FitWorkerConfig { cfg.frameChannelPollIntervalMs, DEFAULT_FRAME_CHANNEL_POLL_INTERVAL_MS, ), + framePreviewStride: numberOr(cfg.framePreviewStride, DEFAULT_FRAME_PREVIEW_STRIDE), }; } @@ -370,6 +377,7 @@ function updateSchedulerFromEvent(scheduler: FootprintSnapshotScheduler, ev: Pip case 'reject': case 'metric': case 'footprint-snapshot': + case 'trace-sample': return; } } @@ -454,6 +462,46 @@ function residualL2(residual: ArrayLike | Float32Array): number { return Math.sqrt(sumSq); } +function quantizeF32ToU8(frame: Float32Array): Uint8ClampedArray { + // Autoscale to the [0, 255] range so the canvas shows a meaningful + // grayscale regardless of the reconstruction's absolute magnitude. + // Mirrors `quantizeToU8` in `lib/frame-preview.ts` but duplicated + // here to avoid a main-thread import inside the worker bundle. + let min = Infinity; + let max = -Infinity; + for (let i = 0; i < frame.length; i += 1) { + const v = frame[i]; + if (v < min) min = v; + if (v > max) max = v; + } + const out = new Uint8ClampedArray(frame.length); + if (!Number.isFinite(min) || !Number.isFinite(max) || max - min < 1e-12) { + out.fill(128); + return out; + } + const range = max - min; + for (let i = 0; i < frame.length; i += 1) { + out[i] = Math.round(((frame[i] - min) / range) * 255); + } + return out; +} + +function emitReconstructionPreview(h: RuntimeHandles, frameIndex: number): void { + const stride = h.config.framePreviewStride; + if (stride <= 0 || (frameIndex + 1) % stride !== 0) return; + const recon = h.fitter.reconstructLastFrame(); + if (recon.length === 0) return; + post({ + kind: 'frame-preview', + role: ROLE, + index: frameIndex, + width: h.config.width, + height: h.config.height, + stage: 'reconstruction', + pixels: quantizeF32ToU8(recon), + }); +} + function emitVitals(h: RuntimeHandles, frameIndex: number): void { if (h.config.vitalsStride <= 0) return; if ((frameIndex + 1) % h.config.vitalsStride !== 0) return; @@ -475,6 +523,20 @@ function emitVitals(h: RuntimeHandles, frameIndex: number): void { for (const { name, value } of metrics) { h.eventBus.publish({ kind: 'metric', t: frameIndex, name, value }); } + + // Per-neuron trace sample for the traces panel (Phase 7 task 8). + // `componentIds()` and `lastTrace()` are ordered identically by the + // Rust side, so `ids[i]` owns `values[i]` until the next mutation. + const idsArr = h.fitter.componentIds(); + const trace = h.fitter.lastTrace(); + if (idsArr.length > 0 && trace.length === idsArr.length) { + h.eventBus.publish({ + kind: 'trace-sample', + t: frameIndex, + ids: Uint32Array.from(idsArr), + values: trace instanceof Float32Array ? trace : Float32Array.from(trace), + }); + } } // Metric name for the per-cycle extend activity signal. Lives here @@ -483,6 +545,43 @@ function emitVitals(h: RuntimeHandles, frameIndex: number): void { // surface it, the sparkline bar does not. const METRIC_EXTEND_PROPOSED = 'extend.proposed'; +function wasmEventToPipelineEvent(e: WasmAppliedEvent, t: number): PipelineEvent { + switch (e.kind) { + case 'birth': + return { + kind: 'birth', + t, + id: e.id, + patch: e.patch, + footprintSnap: { + pixelIndices: Uint32Array.from(e.support), + values: Float32Array.from(e.values), + }, + }; + case 'merge': + return { + kind: 'merge', + t, + ids: [e.ids[0], e.ids[1]], + into: e.into, + footprintSnap: { + pixelIndices: Uint32Array.from(e.support), + values: Float32Array.from(e.values), + }, + }; + case 'deprecate': + return { kind: 'deprecate', t, id: e.id, reason: e.reason }; + } +} + +function publishAppliedEvents(h: RuntimeHandles, wasmEvents: WasmAppliedEvent[], t: number): void { + for (const we of wasmEvents) { + const ev = wasmEventToPipelineEvent(we, t); + h.eventBus.publish(ev); + updateSchedulerFromEvent(h.footprintScheduler, ev); + } +} + function runExtendCycleIfDue(h: RuntimeHandles, frameIndex: number, residual: Float32Array): void { if (!h.extender || h.config.extendCycleStride <= 0) return; h.extender.pushResidual(residual); @@ -497,21 +596,15 @@ function runExtendCycleIfDue(h: RuntimeHandles, frameIndex: number, residual: Fl 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); + // Apply the queued mutations and surface each one as a real + // structural event on the bus (Phase 7 task 3). Phase 6 used + // `drainApply` + a metric; the event feed had no `birth` rows + // because the mutation payloads never left the Rust side. + const { events } = drainApplyEventsTyped(h.fitter, h.mutationQueueHandle); + publishAppliedEvents(h, events, frameIndex); 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 { @@ -532,6 +625,7 @@ async function fitLoop(h: RuntimeHandles): Promise { takeCadencedSnapshot(h, frameIndex); emitScheduledFootprints(h, frameIndex); emitVitals(h, frameIndex); + emitReconstructionPreview(h, frameIndex); if ((frameIndex + 1) % h.config.heartbeatStride === 0) { post({ kind: 'frame-processed', diff --git a/apps/cala/src/workers/neuron-event-index.ts b/apps/cala/src/workers/neuron-event-index.ts index ceef01e..ebed01c 100644 --- a/apps/cala/src/workers/neuron-event-index.ts +++ b/apps/cala/src/workers/neuron-event-index.ts @@ -50,8 +50,10 @@ export function neuronIdsForEvent(e: PipelineEvent): number[] { case 'reject': case 'metric': case 'footprint-snapshot': - // Periodic footprint snapshots are indexed by the footprint - // store (§9.3), not the structural-event history. + case 'trace-sample': + // Periodic footprint snapshots + per-neuron trace samples are + // indexed by their own stores; they don't belong in the + // structural-event history. return []; } } @@ -93,4 +95,20 @@ export class NeuronEventIndex { knownIds(): number[] { return Array.from(this.byNeuron.keys()); } + + /** + * Subset of `knownIds()` whose latest structural event is not a + * `deprecate` — i.e. the neuron is still "alive" in the fit + * pipeline. Used by the footprints panel (Phase 7 task 10) to + * avoid overlaying stale outlines, and by the export flow to pick + * which components to dump. + */ + liveIds(): number[] { + const out: number[] = []; + for (const [id, list] of this.byNeuron) { + if (list.length === 0) continue; + if (list[list.length - 1].kind !== 'deprecate') out.push(id); + } + return out; + } } diff --git a/apps/cala/src/workers/neuron-trace-store.ts b/apps/cala/src/workers/neuron-trace-store.ts new file mode 100644 index 0000000..cc33b23 --- /dev/null +++ b/apps/cala/src/workers/neuron-trace-store.ts @@ -0,0 +1,129 @@ +/** + * Per-neuron rolling trace buffer (Phase 7 task 8). + * + * Fit emits a `trace-sample` event at vitals cadence carrying the + * current `(ids, values)` vector. This store keeps a bounded ring + * per id so the traces panel (task 9) can read the last N samples + * for each live neuron without re-serializing the whole history. + * + * Why a new store rather than reusing `TimeseriesStore`: the named- + * timeseries store is designed for a handful of `O(1)` metric names + * (cell_count, fps, …) with tiered L1/L2 retention. Traces are + * per-neuron and live-only — we don't need block-averaged history — + * so the shape is a plain drop-oldest ring keyed by neuron id. + */ + +export interface NeuronTraceStoreConfig { + /** Ring size per neuron. Samples past this drop oldest-first. */ + capacity: number; + /** Hard cap on distinct neuron ids (drop-oldest-inserted on overflow). */ + maxNeurons: number; +} + +interface PerNeuron { + times: Float32Array; + values: Float32Array; + head: number; + count: number; +} + +export interface NeuronTraceQuery { + ids: number[]; + /** Per-id arrays in chronological order. Aligned by index to `ids`. */ + times: Float32Array[]; + values: Float32Array[]; +} + +function validateConfig(cfg: NeuronTraceStoreConfig): void { + const check = (name: keyof NeuronTraceStoreConfig, v: number): void => { + if (!Number.isInteger(v) || v < 1) { + throw new Error(`NeuronTraceStoreConfig.${name} must be an integer ≥ 1 (got ${v})`); + } + }; + check('capacity', cfg.capacity); + check('maxNeurons', cfg.maxNeurons); +} + +export class NeuronTraceStore { + private readonly cfg: NeuronTraceStoreConfig; + private readonly byId = new Map(); + + constructor(cfg: NeuronTraceStoreConfig) { + validateConfig(cfg); + this.cfg = cfg; + } + + /** Number of ids currently tracked. */ + get size(): number { + return this.byId.size; + } + + /** + * Append one sample per id from a `trace-sample` event. `ids[i]` + * owns `values[i]`. Ids not present in this call are left untouched + * — callers who need deprecation semantics use the neuron-event + * index, not this store. + */ + append(t: number, ids: ArrayLike, values: ArrayLike): void { + const n = Math.min(ids.length, values.length); + for (let i = 0; i < n; i += 1) { + const id = ids[i]; + let entry = this.byId.get(id); + if (!entry) { + if (this.byId.size >= this.cfg.maxNeurons) { + const oldest = this.byId.keys().next().value; + if (oldest !== undefined) this.byId.delete(oldest); + } + entry = { + times: new Float32Array(this.cfg.capacity), + values: new Float32Array(this.cfg.capacity), + head: 0, + count: 0, + }; + this.byId.set(id, entry); + } + const writeIdx = (entry.head + entry.count) % this.cfg.capacity; + entry.times[writeIdx] = t; + entry.values[writeIdx] = values[i]; + if (entry.count === this.cfg.capacity) { + entry.head = (entry.head + 1) % this.cfg.capacity; + } else { + entry.count += 1; + } + } + } + + /** + * Snapshot the most recent samples for each currently-tracked id + * (or the explicit `ids` filter, if passed). Both arrays in each + * per-id entry are chronological oldest → newest. + */ + queryAll(idFilter?: readonly number[]): NeuronTraceQuery { + const outIds: number[] = []; + const outTimes: Float32Array[] = []; + const outValues: Float32Array[] = []; + const targetIds = idFilter ?? Array.from(this.byId.keys()); + for (const id of targetIds) { + const entry = this.byId.get(id); + if (!entry || entry.count === 0) continue; + outIds.push(id); + outTimes.push(flattenRing(entry.times, entry.head, entry.count, this.cfg.capacity)); + outValues.push(flattenRing(entry.values, entry.head, entry.count, this.cfg.capacity)); + } + return { ids: outIds, times: outTimes, values: outValues }; + } +} + +function flattenRing(buf: Float32Array, head: number, count: number, cap: number): Float32Array { + const out = new Float32Array(count); + if (count === 0) return out; + const tail = (head + count) % cap; + if (tail > head) { + out.set(buf.subarray(head, tail)); + } else { + const firstChunk = buf.subarray(head, cap); + out.set(firstChunk, 0); + out.set(buf.subarray(0, tail), firstChunk.length); + } + return out; +} diff --git a/crates/cala-core/pkg/calab_cala_core.d.ts b/crates/cala-core/pkg/calab_cala_core.d.ts index 5b896bd..9c5a0a3 100644 --- a/crates/cala-core/pkg/calab_cala_core.d.ts +++ b/crates/cala-core/pkg/calab_cala_core.d.ts @@ -77,6 +77,13 @@ export class Extender { export class Fitter { free(): void; [Symbol.dispose](): void; + /** + * Live neuron ids in the same order as `last_trace`'s vector. + * Used by the traces panel (Phase 7 task 8) so per-id timeseries + * samples carry the right id even as mutations insert / remove + * components across cycles. + */ + componentIds(): Uint32Array; /** * Drain every mutation in `queue` and apply in FIFO order. The * returned flat `Uint32Array` carries `[applied, stale, invalid]` @@ -84,6 +91,21 @@ export class Fitter { * metrics. */ drainApply(queue: MutationQueueHandle): Uint32Array; + /** + * Drain + apply like `drainApply`, but also return the per- + * mutation event payloads. Shape: + * + * ```js + * { report: [applied, stale, invalid], events: AppliedEvent[] } + * ``` + * + * Each `AppliedEvent` is a tagged object (`kind: 'birth' | 'merge' + * | 'deprecate'`) carrying the minimal fields the event-feed UI + * needs (§9.2). `support` and `values` come through as plain + * `number[]` — they're small (~50 elements per birth) and cross + * the WASM boundary at extend-cycle cadence, not per frame. + */ + drainApplyEvents(queue: MutationQueueHandle): any; /** * Current asset epoch. Advances once per successful mutation * apply; not touched by per-frame `step` calls. @@ -106,6 +128,15 @@ export class Fitter { * Number of live components in `Ã`. */ numComponents(): number; + /** + * `Ã · c_t` reconstruction of the most recent frame (design §3 + * fit loop). Returns an empty `Float32Array` before the first + * `step()` has landed. Used by W2's preview path (Phase 7 task + * 6) so the dashboard's 4-canvas frame panel can show what the + * model thinks the frame looked like alongside the raw / hot- + * pixel / motion-corrected stages from W1. + */ + reconstructLastFrame(): Float32Array; /** * Run one OMF frame. Returns the residual `R_t` as a new * `Float32Array` so the extend worker can read it. @@ -173,6 +204,17 @@ export class Preprocessor { * containing the cleaned frame. */ processFrameF32(input: Float32Array): Float32Array; + /** + * Same as `processFrameF32` but also returns the post-hot-pixel + * and post-motion intermediate frames, concatenated after the + * final frame. Used by W1's preview path (Phase 7 task 5) so the + * dashboard's 4-canvas frame panel can render raw / hot-pixel / + * motion / reconstruction side by side. + * + * Returned layout (all `pixels` = height·width in length): + * `[final || hot_pixel || motion]` → total length `3·pixels`. + */ + processFrameF32WithStages(input: Float32Array): Float32Array; /** * Convenience: decode raw AVI bytes to grayscale and preprocess * in one call. Avoids a round-trip across the JS boundary for @@ -227,12 +269,15 @@ export interface InitOutput { 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_componentIds: (a: number, b: number) => void; readonly fitter_drainApply: (a: number, b: number, c: number) => void; + readonly fitter_drainApplyEvents: (a: number, b: number, c: number) => void; readonly fitter_epoch: (a: number) => bigint; readonly fitter_height: (a: number) => number; readonly fitter_lastTrace: (a: number, b: number) => void; readonly fitter_new: (a: number, b: number, c: number, d: number, e: number) => void; readonly fitter_numComponents: (a: number) => number; + readonly fitter_reconstructLastFrame: (a: number, b: number) => void; readonly fitter_step: (a: number, b: number, c: number, d: number) => void; readonly fitter_takeSnapshot: (a: number) => number; readonly fitter_width: (a: number) => number; @@ -245,6 +290,7 @@ export interface InitOutput { readonly mutationqueuehandle_pushDeprecate: (a: number, b: number, c: bigint, d: number, e: number, f: number) => void; readonly preprocessor_new: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void; readonly preprocessor_processFrameF32: (a: number, b: number, c: number, d: number) => void; + readonly preprocessor_processFrameF32WithStages: (a: number, b: number, c: number, d: number) => void; readonly preprocessor_processFrameU8: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void; readonly preprocessor_reset: (a: number) => void; readonly snapshothandle_epoch: (a: number) => bigint; @@ -252,9 +298,9 @@ export interface InitOutput { 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; + readonly __wbindgen_export: (a: number, b: number) => number; + readonly __wbindgen_export2: (a: number, b: number, c: number, d: number) => number; + readonly __wbindgen_export3: (a: number, b: number, c: number) => void; readonly __wbindgen_add_to_stack_pointer: (a: number) => number; } diff --git a/crates/cala-core/pkg/calab_cala_core.js b/crates/cala-core/pkg/calab_cala_core.js index 0b09018..e36d137 100644 --- a/crates/cala-core/pkg/calab_cala_core.js +++ b/crates/cala-core/pkg/calab_cala_core.js @@ -61,7 +61,7 @@ export class AviReader { constructor(bytes) { try { const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); - const ptr0 = passArray8ToWasm0(bytes, wasm.__wbindgen_export2); + const ptr0 = passArray8ToWasm0(bytes, wasm.__wbindgen_export); const len0 = WASM_VECTOR_LEN; wasm.avireader_new(retptr, ptr0, len0); var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); @@ -90,7 +90,7 @@ export class AviReader { readFrameGrayscaleF32(n, method) { try { const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); - const ptr0 = passStringToWasm0(method, wasm.__wbindgen_export2, wasm.__wbindgen_export3); + const ptr0 = passStringToWasm0(method, wasm.__wbindgen_export, wasm.__wbindgen_export2); const len0 = WASM_VECTOR_LEN; wasm.avireader_readFrameGrayscaleF32(retptr, this.__wbg_ptr, n, ptr0, len0); var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); @@ -101,7 +101,7 @@ export class AviReader { throw takeObject(r2); } var v2 = getArrayF32FromWasm0(r0, r1).slice(); - wasm.__wbindgen_export(r0, r1 * 4, 4); + wasm.__wbindgen_export3(r0, r1 * 4, 4); return v2; } finally { wasm.__wbindgen_add_to_stack_pointer(16); @@ -150,9 +150,9 @@ export class Extender { 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 ptr0 = passStringToWasm0(extend_cfg_json, wasm.__wbindgen_export, wasm.__wbindgen_export2); const len0 = WASM_VECTOR_LEN; - const ptr1 = passStringToWasm0(metadata_json, wasm.__wbindgen_export2, wasm.__wbindgen_export3); + const ptr1 = passStringToWasm0(metadata_json, wasm.__wbindgen_export, wasm.__wbindgen_export2); 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); @@ -176,7 +176,7 @@ export class Extender { pushResidual(residual) { try { const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); - const ptr0 = passArrayF32ToWasm0(residual, wasm.__wbindgen_export2); + const ptr0 = passArrayF32ToWasm0(residual, wasm.__wbindgen_export); const len0 = WASM_VECTOR_LEN; wasm.extender_pushResidual(retptr, this.__wbg_ptr, ptr0, len0); var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); @@ -231,6 +231,26 @@ export class Fitter { const ptr = this.__destroy_into_raw(); wasm.__wbg_fitter_free(ptr, 0); } + /** + * Live neuron ids in the same order as `last_trace`'s vector. + * Used by the traces panel (Phase 7 task 8) so per-id timeseries + * samples carry the right id even as mutations insert / remove + * components across cycles. + * @returns {Uint32Array} + */ + componentIds() { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); + wasm.fitter_componentIds(retptr, this.__wbg_ptr); + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); + var v1 = getArrayU32FromWasm0(r0, r1).slice(); + wasm.__wbindgen_export3(r0, r1 * 4, 4); + return v1; + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + } + } /** * Drain every mutation in `queue` and apply in FIFO order. The * returned flat `Uint32Array` carries `[applied, stale, invalid]` @@ -247,12 +267,44 @@ export class Fitter { var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); var v1 = getArrayU32FromWasm0(r0, r1).slice(); - wasm.__wbindgen_export(r0, r1 * 4, 4); + wasm.__wbindgen_export3(r0, r1 * 4, 4); return v1; } finally { wasm.__wbindgen_add_to_stack_pointer(16); } } + /** + * Drain + apply like `drainApply`, but also return the per- + * mutation event payloads. Shape: + * + * ```js + * { report: [applied, stale, invalid], events: AppliedEvent[] } + * ``` + * + * Each `AppliedEvent` is a tagged object (`kind: 'birth' | 'merge' + * | 'deprecate'`) carrying the minimal fields the event-feed UI + * needs (§9.2). `support` and `values` come through as plain + * `number[]` — they're small (~50 elements per birth) and cross + * the WASM boundary at extend-cycle cadence, not per frame. + * @param {MutationQueueHandle} queue + * @returns {any} + */ + drainApplyEvents(queue) { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); + _assertClass(queue, MutationQueueHandle); + wasm.fitter_drainApplyEvents(retptr, this.__wbg_ptr, queue.__wbg_ptr); + 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); + } + return takeObject(r0); + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + } + } /** * Current asset epoch. Advances once per successful mutation * apply; not touched by per-frame `step` calls. @@ -281,7 +333,7 @@ export class Fitter { var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); var v1 = getArrayF32FromWasm0(r0, r1).slice(); - wasm.__wbindgen_export(r0, r1 * 4, 4); + wasm.__wbindgen_export3(r0, r1 * 4, 4); return v1; } finally { wasm.__wbindgen_add_to_stack_pointer(16); @@ -299,7 +351,7 @@ export class Fitter { constructor(height, width, cfg_json) { try { const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); - const ptr0 = passStringToWasm0(cfg_json, wasm.__wbindgen_export2, wasm.__wbindgen_export3); + const ptr0 = passStringToWasm0(cfg_json, wasm.__wbindgen_export, wasm.__wbindgen_export2); const len0 = WASM_VECTOR_LEN; wasm.fitter_new(retptr, height, width, ptr0, len0); var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); @@ -323,6 +375,28 @@ export class Fitter { const ret = wasm.fitter_numComponents(this.__wbg_ptr); return ret >>> 0; } + /** + * `Ã · c_t` reconstruction of the most recent frame (design §3 + * fit loop). Returns an empty `Float32Array` before the first + * `step()` has landed. Used by W2's preview path (Phase 7 task + * 6) so the dashboard's 4-canvas frame panel can show what the + * model thinks the frame looked like alongside the raw / hot- + * pixel / motion-corrected stages from W1. + * @returns {Float32Array} + */ + reconstructLastFrame() { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); + wasm.fitter_reconstructLastFrame(retptr, this.__wbg_ptr); + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); + var v1 = getArrayF32FromWasm0(r0, r1).slice(); + wasm.__wbindgen_export3(r0, r1 * 4, 4); + return v1; + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + } + } /** * Run one OMF frame. Returns the residual `R_t` as a new * `Float32Array` so the extend worker can read it. @@ -332,7 +406,7 @@ export class Fitter { step(y) { try { const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); - const ptr0 = passArrayF32ToWasm0(y, wasm.__wbindgen_export2); + const ptr0 = passArrayF32ToWasm0(y, wasm.__wbindgen_export); const len0 = WASM_VECTOR_LEN; wasm.fitter_step(retptr, this.__wbg_ptr, ptr0, len0); var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); @@ -343,7 +417,7 @@ export class Fitter { throw takeObject(r2); } var v2 = getArrayF32FromWasm0(r0, r1).slice(); - wasm.__wbindgen_export(r0, r1 * 4, 4); + wasm.__wbindgen_export3(r0, r1 * 4, 4); return v2; } finally { wasm.__wbindgen_add_to_stack_pointer(16); @@ -429,7 +503,7 @@ export class MutationQueueHandle { constructor(extend_cfg_json) { try { const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); - const ptr0 = passStringToWasm0(extend_cfg_json, wasm.__wbindgen_export2, wasm.__wbindgen_export3); + const ptr0 = passStringToWasm0(extend_cfg_json, wasm.__wbindgen_export, wasm.__wbindgen_export2); const len0 = WASM_VECTOR_LEN; wasm.mutationqueuehandle_new(retptr, ptr0, len0); var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); @@ -457,7 +531,7 @@ export class MutationQueueHandle { pushDeprecate(snapshot_epoch, id, reason) { try { const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); - const ptr0 = passStringToWasm0(reason, wasm.__wbindgen_export2, wasm.__wbindgen_export3); + const ptr0 = passStringToWasm0(reason, wasm.__wbindgen_export, wasm.__wbindgen_export2); const len0 = WASM_VECTOR_LEN; wasm.mutationqueuehandle_pushDeprecate(retptr, this.__wbg_ptr, snapshot_epoch, id, ptr0, len0); var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); @@ -505,9 +579,9 @@ export class Preprocessor { constructor(height, width, metadata_json, cfg_json) { try { const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); - const ptr0 = passStringToWasm0(metadata_json, wasm.__wbindgen_export2, wasm.__wbindgen_export3); + const ptr0 = passStringToWasm0(metadata_json, wasm.__wbindgen_export, wasm.__wbindgen_export2); const len0 = WASM_VECTOR_LEN; - const ptr1 = passStringToWasm0(cfg_json, wasm.__wbindgen_export2, wasm.__wbindgen_export3); + const ptr1 = passStringToWasm0(cfg_json, wasm.__wbindgen_export, wasm.__wbindgen_export2); const len1 = WASM_VECTOR_LEN; wasm.preprocessor_new(retptr, height, width, ptr0, len0, ptr1, len1); var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); @@ -533,7 +607,7 @@ export class Preprocessor { processFrameF32(input) { try { const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); - const ptr0 = passArrayF32ToWasm0(input, wasm.__wbindgen_export2); + const ptr0 = passArrayF32ToWasm0(input, wasm.__wbindgen_export); const len0 = WASM_VECTOR_LEN; wasm.preprocessor_processFrameF32(retptr, this.__wbg_ptr, ptr0, len0); var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); @@ -544,7 +618,39 @@ export class Preprocessor { throw takeObject(r2); } var v2 = getArrayF32FromWasm0(r0, r1).slice(); - wasm.__wbindgen_export(r0, r1 * 4, 4); + wasm.__wbindgen_export3(r0, r1 * 4, 4); + return v2; + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + } + } + /** + * Same as `processFrameF32` but also returns the post-hot-pixel + * and post-motion intermediate frames, concatenated after the + * final frame. Used by W1's preview path (Phase 7 task 5) so the + * dashboard's 4-canvas frame panel can render raw / hot-pixel / + * motion / reconstruction side by side. + * + * Returned layout (all `pixels` = height·width in length): + * `[final || hot_pixel || motion]` → total length `3·pixels`. + * @param {Float32Array} input + * @returns {Float32Array} + */ + processFrameF32WithStages(input) { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); + const ptr0 = passArrayF32ToWasm0(input, wasm.__wbindgen_export); + const len0 = WASM_VECTOR_LEN; + wasm.preprocessor_processFrameF32WithStages(retptr, this.__wbg_ptr, ptr0, len0); + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); + var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true); + var r3 = getDataViewMemory0().getInt32(retptr + 4 * 3, true); + if (r3) { + throw takeObject(r2); + } + var v2 = getArrayF32FromWasm0(r0, r1).slice(); + wasm.__wbindgen_export3(r0, r1 * 4, 4); return v2; } finally { wasm.__wbindgen_add_to_stack_pointer(16); @@ -562,9 +668,9 @@ export class Preprocessor { processFrameU8(input, channels, method) { try { const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); - const ptr0 = passArray8ToWasm0(input, wasm.__wbindgen_export2); + const ptr0 = passArray8ToWasm0(input, wasm.__wbindgen_export); const len0 = WASM_VECTOR_LEN; - const ptr1 = passStringToWasm0(method, wasm.__wbindgen_export2, wasm.__wbindgen_export3); + const ptr1 = passStringToWasm0(method, wasm.__wbindgen_export, wasm.__wbindgen_export2); const len1 = WASM_VECTOR_LEN; wasm.preprocessor_processFrameU8(retptr, this.__wbg_ptr, ptr0, len0, channels, ptr1, len1); var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); @@ -575,7 +681,7 @@ export class Preprocessor { throw takeObject(r2); } var v3 = getArrayF32FromWasm0(r0, r1).slice(); - wasm.__wbindgen_export(r0, r1 * 4, 4); + wasm.__wbindgen_export3(r0, r1 * 4, 4); return v3; } finally { wasm.__wbindgen_add_to_stack_pointer(16); @@ -648,6 +754,13 @@ export function init_panic_hook() { function __wbg_get_imports() { const import0 = { __proto__: null, + __wbg_String_8564e559799eccda: function(arg0, arg1) { + const ret = String(getObject(arg1)); + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, __wbg___wbindgen_throw_6b64449b9b9ed33c: function(arg0, arg1) { throw new Error(getStringFromWasm0(arg0, arg1)); }, @@ -659,25 +772,48 @@ function __wbg_get_imports() { deferred0_1 = arg1; console.error(getStringFromWasm0(arg0, arg1)); } finally { - wasm.__wbindgen_export(deferred0_0, deferred0_1, 1); + wasm.__wbindgen_export3(deferred0_0, deferred0_1, 1); } }, __wbg_new_227d7c05414eb861: function() { const ret = new Error(); return addHeapObject(ret); }, + __wbg_new_682678e2f47e32bc: function() { + const ret = new Array(); + return addHeapObject(ret); + }, + __wbg_new_aa8d0fa9762c29bd: function() { + const ret = new Object(); + return addHeapObject(ret); + }, + __wbg_set_3bf1de9fab0cd644: function(arg0, arg1, arg2) { + getObject(arg0)[arg1 >>> 0] = takeObject(arg2); + }, + __wbg_set_6be42768c690e380: function(arg0, arg1, arg2) { + getObject(arg0)[takeObject(arg1)] = takeObject(arg2); + }, __wbg_stack_3b0d974bbf31e44f: function(arg0, arg1) { const ret = getObject(arg1).stack; - const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_export2, wasm.__wbindgen_export3); + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_export, wasm.__wbindgen_export2); const len1 = WASM_VECTOR_LEN; getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); }, - __wbindgen_cast_0000000000000001: function(arg0, arg1) { + __wbindgen_cast_0000000000000001: function(arg0) { + // Cast intrinsic for `F64 -> Externref`. + const ret = arg0; + return addHeapObject(ret); + }, + __wbindgen_cast_0000000000000002: function(arg0, arg1) { // Cast intrinsic for `Ref(String) -> Externref`. const ret = getStringFromWasm0(arg0, arg1); return addHeapObject(ret); }, + __wbindgen_object_clone_ref: function(arg0) { + const ret = getObject(arg0); + return addHeapObject(ret); + }, __wbindgen_object_drop_ref: function(arg0) { takeObject(arg0); }, diff --git a/crates/cala-core/pkg/calab_cala_core_bg.wasm b/crates/cala-core/pkg/calab_cala_core_bg.wasm index 2175f94..3ebb8f9 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 26a5768..df3513a 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 @@ -18,12 +18,15 @@ 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_componentIds: (a: number, b: number) => void; export const fitter_drainApply: (a: number, b: number, c: number) => void; +export const fitter_drainApplyEvents: (a: number, b: number, c: number) => void; export const fitter_epoch: (a: number) => bigint; export const fitter_height: (a: number) => number; export const fitter_lastTrace: (a: number, b: number) => void; export const fitter_new: (a: number, b: number, c: number, d: number, e: number) => void; export const fitter_numComponents: (a: number) => number; +export const fitter_reconstructLastFrame: (a: number, b: number) => void; export const fitter_step: (a: number, b: number, c: number, d: number) => void; export const fitter_takeSnapshot: (a: number) => number; export const fitter_width: (a: number) => number; @@ -36,6 +39,7 @@ export const mutationqueuehandle_new: (a: number, b: number, c: number) => void; export const mutationqueuehandle_pushDeprecate: (a: number, b: number, c: bigint, d: number, e: number, f: number) => void; export const preprocessor_new: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void; export const preprocessor_processFrameF32: (a: number, b: number, c: number, d: number) => void; +export const preprocessor_processFrameF32WithStages: (a: number, b: number, c: number, d: number) => void; export const preprocessor_processFrameU8: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void; export const preprocessor_reset: (a: number) => void; export const snapshothandle_epoch: (a: number) => bigint; @@ -43,7 +47,7 @@ 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; +export const __wbindgen_export: (a: number, b: number) => number; +export const __wbindgen_export2: (a: number, b: number, c: number, d: number) => number; +export const __wbindgen_export3: (a: number, b: number, c: number) => void; export const __wbindgen_add_to_stack_pointer: (a: number) => number; diff --git a/crates/cala-core/src/bindings/wasm.rs b/crates/cala-core/src/bindings/wasm.rs index adf4ac7..3415fd4 100644 --- a/crates/cala-core/src/bindings/wasm.rs +++ b/crates/cala-core/src/bindings/wasm.rs @@ -34,7 +34,18 @@ use crate::extending::driver as extend_driver; use crate::extending::mutation::{ DeprecateReason, Epoch, MutationQueue, PipelineMutation, Snapshot, }; +use crate::fitting::AppliedEvent; use crate::fitting::FitPipeline; + +// Wire-shape for `Fitter::drainApplyEvents`. Kept private to the +// binding so the rest of the crate stays unaware of wasm-specific +// shapes. Serde is available whenever `jsbindings` is on (see +// Cargo.toml — `jsbindings` implies `serde`). +#[derive(serde::Serialize)] +struct DrainApplyEventsResult { + report: [u32; 3], + events: Vec, +} use crate::io::{decode_grayscale_f32, OwnedAviReader}; use crate::preprocess::PreprocessPipeline; @@ -232,6 +243,56 @@ impl Preprocessor { .map_err(|e| js_err("preprocess", format!("decode: {e:?}")))?; self.process_frame_f32(&gray) } + + /// Same as `processFrameF32` but also returns the post-hot-pixel + /// and post-motion intermediate frames, concatenated after the + /// final frame. Used by W1's preview path (Phase 7 task 5) so the + /// dashboard's 4-canvas frame panel can render raw / hot-pixel / + /// motion / reconstruction side by side. + /// + /// Returned layout (all `pixels` = height·width in length): + /// `[final || hot_pixel || motion]` → total length `3·pixels`. + #[wasm_bindgen(js_name = processFrameF32WithStages)] + pub fn process_frame_f32_with_stages(&mut self, input: &[f32]) -> Result, JsValue> { + let pixels = (self.height as usize) * (self.width as usize); + if input.len() != pixels { + return Err(js_err( + "preprocess", + format!( + "input length {} does not match height·width = {}", + input.len(), + pixels + ), + )); + } + let mut out = vec![0.0f32; pixels]; + let mut hot = vec![0.0f32; pixels]; + let mut motion = vec![0.0f32; pixels]; + { + let input_view = Frame::new(input, self.height as usize, self.width as usize) + .map_err(|e| js_err("preprocess", format!("input shape: {e:?}")))?; + let mut out_view = FrameMut::new(&mut out, self.height as usize, self.width as usize) + .map_err(|e| js_err("preprocess", format!("output shape: {e:?}")))?; + let mut hot_view = FrameMut::new(&mut hot, self.height as usize, self.width as usize) + .map_err(|e| js_err("preprocess", format!("hot shape: {e:?}")))?; + let mut motion_view = + FrameMut::new(&mut motion, self.height as usize, self.width as usize) + .map_err(|e| js_err("preprocess", format!("motion shape: {e:?}")))?; + self.pipeline + .process_frame_with_stages( + input_view, + &mut out_view, + &mut hot_view, + &mut motion_view, + ) + .map_err(|e| js_err("preprocess", format!("{e:?}")))?; + } + let mut combined = Vec::with_capacity(3 * pixels); + combined.extend_from_slice(&out); + combined.extend_from_slice(&hot); + combined.extend_from_slice(&motion); + Ok(combined) + } } // ── Fit ──────────────────────────────────────────────────────────── @@ -315,6 +376,36 @@ impl Fitter { } } + /// Live neuron ids in the same order as `last_trace`'s vector. + /// Used by the traces panel (Phase 7 task 8) so per-id timeseries + /// samples carry the right id even as mutations insert / remove + /// components across cycles. + #[wasm_bindgen(js_name = componentIds)] + pub fn component_ids(&self) -> Vec { + let fp = self.pipeline.footprints(); + (0..fp.len()).map(|i| fp.id(i)).collect() + } + + /// `Ã · c_t` reconstruction of the most recent frame (design §3 + /// fit loop). Returns an empty `Float32Array` before the first + /// `step()` has landed. Used by W2's preview path (Phase 7 task + /// 6) so the dashboard's 4-canvas frame panel can show what the + /// model thinks the frame looked like alongside the raw / hot- + /// pixel / motion-corrected stages from W1. + #[wasm_bindgen(js_name = reconstructLastFrame)] + pub fn reconstruct_last_frame(&self) -> Vec { + let fp = self.pipeline.footprints(); + let Some(c) = self.pipeline.traces().last() else { + return Vec::new(); + }; + if c.len() != fp.len() { + return Vec::new(); + } + let mut out = vec![0.0f32; fp.pixels()]; + fp.reconstruct(c, &mut out); + out + } + /// Drain every mutation in `queue` and apply in FIFO order. The /// returned flat `Uint32Array` carries `[applied, stale, invalid]` /// counts — ready to push to the archive worker for dashboard @@ -325,6 +416,32 @@ impl Fitter { vec![report.applied, report.stale, report.invalid] } + /// Drain + apply like `drainApply`, but also return the per- + /// mutation event payloads. Shape: + /// + /// ```js + /// { report: [applied, stale, invalid], events: AppliedEvent[] } + /// ``` + /// + /// Each `AppliedEvent` is a tagged object (`kind: 'birth' | 'merge' + /// | 'deprecate'`) carrying the minimal fields the event-feed UI + /// needs (§9.2). `support` and `values` come through as plain + /// `number[]` — they're small (~50 elements per birth) and cross + /// the WASM boundary at extend-cycle cadence, not per frame. + #[wasm_bindgen(js_name = drainApplyEvents)] + pub fn drain_apply_events( + &mut self, + queue: &mut MutationQueueHandle, + ) -> Result { + let (report, events) = self.pipeline.drain_apply_events(&mut queue.inner); + let payload = DrainApplyEventsResult { + report: [report.applied, report.stale, report.invalid], + events, + }; + serde_wasm_bindgen::to_value(&payload) + .map_err(|e| js_err("drainApplyEvents serialization", e)) + } + /// Take an extend-visible snapshot of `(Ã, W, M, epoch)` — design /// §7.2. Returned as an opaque handle; Phase 5 only surfaces /// `epoch()` on it, full read accessors are Phase 7 extend work. diff --git a/crates/cala-core/src/config.rs b/crates/cala-core/src/config.rs index d848121..fd7f04b 100644 --- a/crates/cala-core/src/config.rs +++ b/crates/cala-core/src/config.rs @@ -396,6 +396,7 @@ impl FitConfig { /// field was added in Phase 3 without disturbing existing callers. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] pub enum ComponentClass { /// Localized, compact, cell-scale footprint with fast transients. Cell, diff --git a/crates/cala-core/src/extending/mutation.rs b/crates/cala-core/src/extending/mutation.rs index ddc726b..3977cee 100644 --- a/crates/cala-core/src/extending/mutation.rs +++ b/crates/cala-core/src/extending/mutation.rs @@ -65,6 +65,8 @@ impl PipelineMutation { /// Why a component is being deprecated. `'static` so mutations stay /// cheap to clone and transport across channels. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] pub enum DeprecateReason { /// Footprint shrank to empty support during `EvaluateFootprints`. FootprintCollapsed, diff --git a/crates/cala-core/src/fitting/mod.rs b/crates/cala-core/src/fitting/mod.rs index 72a2d6c..5f0daf7 100644 --- a/crates/cala-core/src/fitting/mod.rs +++ b/crates/cala-core/src/fitting/mod.rs @@ -19,7 +19,7 @@ mod throttle; mod trace_bcd; pub use footprints::evaluate_footprints; -pub use pipeline::{ApplyBatchReport, ApplyOutcome, FitPipeline}; +pub use pipeline::{AppliedEvent, ApplyBatchReport, ApplyOutcome, FitPipeline}; pub use residual::evaluate_residual; pub use suff_stats::evaluate_suff_stats; pub use throttle::trace_throttle; diff --git a/crates/cala-core/src/fitting/pipeline.rs b/crates/cala-core/src/fitting/pipeline.rs index beec86e..ac7bd71 100644 --- a/crates/cala-core/src/fitting/pipeline.rs +++ b/crates/cala-core/src/fitting/pipeline.rs @@ -19,7 +19,9 @@ use crate::assets::{Footprints, Groups, SuffStats, Traces}; use crate::config::{ComponentClass, FitConfig}; -use crate::extending::mutation::{Epoch, MutationQueue, PipelineMutation, Snapshot}; +use crate::extending::mutation::{ + DeprecateReason, Epoch, MutationQueue, PipelineMutation, Snapshot, +}; use super::{ evaluate_footprints, evaluate_residual, evaluate_suff_stats, evaluate_traces, trace_throttle, @@ -119,6 +121,36 @@ impl FitPipeline { } } + /// Drain a mutation queue like `drain_apply`, but also return an + /// `AppliedEvent` per successfully-applied mutation. This is what + /// W2 uses to surface real `birth`/`merge`/`deprecate` events on + /// the pipeline bus (Phase 7 task 1) instead of the Phase 6 + /// placeholder metric. + pub fn drain_apply_events( + &mut self, + queue: &mut MutationQueue, + ) -> (ApplyBatchReport, Vec) { + let width = self.fp.width(); + let mut report = ApplyBatchReport::default(); + let mut events = Vec::new(); + for m in queue.drain() { + // Inspect before moving. We clone the small `Vec` / + // `Vec` payloads so we can emit them in the event + // regardless of whether apply succeeds — see below for + // the success gate. + let preview = AppliedEventPreview::from_mutation(&m, self.fp.next_id(), width); + match self.apply_mutation(m) { + ApplyOutcome::Applied { .. } => { + report.applied += 1; + events.push(preview.into_event()); + } + ApplyOutcome::Stale => report.stale += 1, + ApplyOutcome::Invalid(_) => report.invalid += 1, + } + } + (report, events) + } + fn apply_register( &mut self, class: ComponentClass, @@ -291,6 +323,157 @@ pub struct ApplyBatchReport { pub invalid: u32, } +/// Per-mutation payload returned alongside the apply outcome by +/// [`FitPipeline::drain_apply_events`]. Maps 1:1 onto the JS-side +/// `PipelineEvent` birth/merge/deprecate variants (§9.2). Kept in this +/// module (not the runtime `events.ts`) because it is the Rust +/// structural shape — the WASM binding converts to a JS value at the +/// boundary. Serde attrs target the JS event shape directly so +/// `serde_wasm_bindgen::to_value` produces `{kind:"birth", ...}` etc. +#[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", serde(tag = "kind", rename_all = "camelCase"))] +pub enum AppliedEvent { + Birth { + id: u32, + class: ComponentClass, + support: Vec, + values: Vec, + patch: (u32, u32), + }, + Merge { + ids: [u32; 2], + into: u32, + class: ComponentClass, + support: Vec, + values: Vec, + }, + Deprecate { + id: u32, + reason: DeprecateReason, + }, +} + +/// Lightweight projection of a `PipelineMutation` captured *before* +/// `apply_mutation` consumes it. Lets `drain_apply_events` emit a +/// fully-populated `AppliedEvent` without re-reading the mutation +/// after it has been moved into apply. +enum AppliedEventPreview { + Birth { + id: u32, + class: ComponentClass, + support: Vec, + values: Vec, + patch: (u32, u32), + }, + Merge { + ids: [u32; 2], + into: u32, + class: ComponentClass, + support: Vec, + values: Vec, + }, + Deprecate { + id: u32, + reason: DeprecateReason, + }, +} + +impl AppliedEventPreview { + fn from_mutation(m: &PipelineMutation, next_id: u32, width: usize) -> Self { + match m { + PipelineMutation::Register { + class, + support, + values, + .. + } => Self::Birth { + id: next_id, + class: *class, + support: support.clone(), + values: values.clone(), + patch: weighted_centroid(support, values, width), + }, + PipelineMutation::Merge { + merge_ids, + class, + support, + values, + .. + } => Self::Merge { + ids: *merge_ids, + // Merge deprecates both ids and then registers one new + // component — the surviving id is whatever `next_id` + // the registration consumes. `apply_merge` pushes + // after both deprecates, so the id at that moment is + // `next_id` captured here. + into: next_id, + class: *class, + support: support.clone(), + values: values.clone(), + }, + PipelineMutation::Deprecate { id, reason, .. } => Self::Deprecate { + id: *id, + reason: *reason, + }, + } + } + + fn into_event(self) -> AppliedEvent { + match self { + Self::Birth { + id, + class, + support, + values, + patch, + } => AppliedEvent::Birth { + id, + class, + support, + values, + patch, + }, + Self::Merge { + ids, + into, + class, + support, + values, + } => AppliedEvent::Merge { + ids, + into, + class, + support, + values, + }, + Self::Deprecate { id, reason } => AppliedEvent::Deprecate { id, reason }, + } + } +} + +/// Weighted centroid of a sparse footprint in (row, col) frame coords. +/// Used as the `patch` anchor on birth events. Empty support returns +/// `(0, 0)` — caller is expected to filter on support.is_empty(), but +/// we avoid NaN here so event delivery never stalls on degenerate data. +fn weighted_centroid(support: &[u32], values: &[f32], width: usize) -> (u32, u32) { + let w = width as f32; + let mut wsum = 0.0f32; + let mut y_acc = 0.0f32; + let mut x_acc = 0.0f32; + for (idx, v) in support.iter().zip(values.iter()) { + let y = (*idx as f32 / w).floor(); + let x = *idx as f32 - y * w; + y_acc += y * v; + x_acc += x * v; + wsum += v; + } + if wsum <= 0.0 { + return (0, 0); + } + ((y_acc / wsum).round() as u32, (x_acc / wsum).round() as u32) +} + /// Construct the per-frame history vector for a newly registered or /// merged component. Pre-window frames are filled with /// `prewindow_fill` (zero for fresh discoveries, summed source diff --git a/crates/cala-core/src/preprocess/pipeline.rs b/crates/cala-core/src/preprocess/pipeline.rs index fbd660a..ed61864 100644 --- a/crates/cala-core/src/preprocess/pipeline.rs +++ b/crates/cala-core/src/preprocess/pipeline.rs @@ -57,6 +57,48 @@ impl PreprocessPipeline { self.motion.reset(); } + /// Run the full preprocess pipeline, copying two intermediate + /// stages out so the dashboard's 4-canvas frame panel (design + /// §12, Phase 7 task 5) can render them alongside the final + /// motion-corrected frame. Hot path still uses `process_frame`. + /// + /// Outputs written: + /// - `output`: final frame (same as `process_frame`). + /// - `hot_pixel_out`: post hot-pixel median, pre-motion. + /// - `motion_out`: post-motion, pre-denoise. + pub fn process_frame_with_stages( + &mut self, + input: Frame<'_>, + output: &mut FrameMut<'_>, + hot_pixel_out: &mut FrameMut<'_>, + motion_out: &mut FrameMut<'_>, + ) -> Result { + let shift = self.process_frame(input, output)?; + // `buf_a` or `buf_b` hold the intermediate stages depending on + // which opt-in filters fired. Re-run the minimal capture here + // rather than instrumenting the hot path: callers only invoke + // this method at preview-stride cadence so the extra work is + // amortized over many frames. + // + // Simpler: just copy from the internal buffers. `buf_b` holds + // the post-motion frame at the end of `process_frame` (before + // the final denoise copy). After `process_frame`, if denoise + // was off, output == buf_b; if denoise was on, buf_b is still + // the pre-denoise motion-corrected frame. We copy that + // unconditionally. + // + // `buf_a` contains the stage immediately before motion — which + // is either the hot-pixel output (default stack) or a later + // opt-in filter. For the 4-canvas we want the hot-pixel stage; + // the current default stack passes hot-pixel output through + // unchanged to motion input, so re-running the hot-pixel stage + // into `hot_pixel_out` gives the right frame without depending + // on which opt-in filters are enabled. + crate::preprocess::hot_pixel_median_3x3(input, hot_pixel_out)?; + motion_out.pixels_mut().copy_from_slice(&self.buf_b); + Ok(shift) + } + /// Run the full preprocess pipeline on one frame. /// /// Stages marked "opt-in" are skipped (buffer passthrough via diff --git a/crates/cala-core/tests/fitting_apply.rs b/crates/cala-core/tests/fitting_apply.rs index 9d0ae8e..727a4c0 100644 --- a/crates/cala-core/tests/fitting_apply.rs +++ b/crates/cala-core/tests/fitting_apply.rs @@ -3,7 +3,7 @@ use calab_cala_core::assets::Footprints; use calab_cala_core::config::{ComponentClass, FitConfig}; use calab_cala_core::extending::mutation::{DeprecateReason, MutationQueue, PipelineMutation}; -use calab_cala_core::fitting::{ApplyOutcome, FitPipeline}; +use calab_cala_core::fitting::{AppliedEvent, ApplyOutcome, FitPipeline}; const F32_TOL: f32 = 1e-5; @@ -331,3 +331,107 @@ fn step_after_merge_advances_traces_and_suffstats() { assert_eq!(p.traces().k(), 1); assert_eq!(p.traces().len(), 4); } + +// ----- drain_apply_events (Phase 7 task 1) ----- + +#[test] +fn drain_apply_events_emits_birth_for_register() { + let mut p = empty_pipeline(); + let mut q = MutationQueue::new(2); + // Patch centroid: support at (0,0),(0,1),(1,0),(1,1) with equal + // values → weighted centroid ≈ (0.5, 0.5), rounds to (1, 1). + q.push(PipelineMutation::Register { + snapshot_epoch: 0, + class: ComponentClass::Cell, + support: vec![0, 1, 4, 5], + values: vec![1.0, 1.0, 1.0, 1.0], + trace: vec![], + }); + let (report, events) = p.drain_apply_events(&mut q); + assert_eq!(report.applied, 1); + assert_eq!(events.len(), 1); + match &events[0] { + AppliedEvent::Birth { + id, + class, + support, + patch, + .. + } => { + assert_eq!(*id, 0, "first birth takes id 0"); + assert_eq!(*class, ComponentClass::Cell); + assert_eq!(support, &vec![0, 1, 4, 5]); + // width is 4 (empty_pipeline uses Footprints::new(4,4)). + // Rows of linear indices: 0→(0,0), 1→(0,1), 4→(1,0), 5→(1,1). + // Weighted centroid rounds to (1, 1) with equal weights + // (0.5 rounds to 1 per IEEE half-to-even; verified below). + assert_eq!(*patch, (1, 1)); + } + other => panic!("expected Birth, got {other:?}"), + } +} + +#[test] +fn drain_apply_events_emits_deprecate_with_reason() { + let mut p = start_with_two_cells(); + let id_a = p.footprints().id(0); + let mut q = MutationQueue::new(2); + q.push(PipelineMutation::Deprecate { + snapshot_epoch: 0, + id: id_a, + reason: DeprecateReason::TraceInactive, + }); + let (report, events) = p.drain_apply_events(&mut q); + assert_eq!(report.applied, 1); + assert_eq!(events.len(), 1); + match &events[0] { + AppliedEvent::Deprecate { id, reason } => { + assert_eq!(*id, id_a); + assert_eq!(*reason, DeprecateReason::TraceInactive); + } + other => panic!("expected Deprecate, got {other:?}"), + } +} + +#[test] +fn drain_apply_events_emits_merge_with_new_id() { + let mut p = start_with_two_cells(); + let id_a = p.footprints().id(0); + let id_b = p.footprints().id(1); + let next_id_before = p.footprints().next_id(); + let mut q = MutationQueue::new(2); + q.push(PipelineMutation::Merge { + snapshot_epoch: 0, + merge_ids: [id_a, id_b], + class: ComponentClass::Cell, + support: vec![0, 1, 5, 6], + values: vec![0.5, 0.5, 0.5, 0.5], + trace: vec![], + }); + let (report, events) = p.drain_apply_events(&mut q); + assert_eq!(report.applied, 1); + assert_eq!(events.len(), 1); + match &events[0] { + AppliedEvent::Merge { ids, into, .. } => { + assert_eq!(*ids, [id_a, id_b]); + assert_eq!(*into, next_id_before); + } + other => panic!("expected Merge, got {other:?}"), + } +} + +#[test] +fn drain_apply_events_skips_events_for_stale_mutations() { + let mut p = empty_pipeline(); + let mut q = MutationQueue::new(2); + // Unknown id → Stale → no event. + q.push(PipelineMutation::Deprecate { + snapshot_epoch: 0, + id: 42, + reason: DeprecateReason::TraceInactive, + }); + let (report, events) = p.drain_apply_events(&mut q); + assert_eq!(report.applied, 0); + assert_eq!(report.stale, 1); + assert!(events.is_empty()); +} diff --git a/package-lock.json b/package-lock.json index 1ff2018..e5416f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -68,7 +68,8 @@ "@calab/core": "*", "@calab/io": "*", "@calab/ui": "*", - "solid-js": "^1.9.11" + "solid-js": "^1.9.11", + "uplot": "^1.6.32" } }, "apps/carank": { diff --git a/packages/cala-core/src/index.ts b/packages/cala-core/src/index.ts index 9533e4a..057227b 100644 --- a/packages/cala-core/src/index.ts +++ b/packages/cala-core/src/index.ts @@ -8,4 +8,12 @@ export { init_panic_hook, initCalaCore, calaMemoryBytes, + drainApplyEventsTyped, +} from './wasm-adapter.ts'; + +export type { + WasmAppliedEvent, + WasmComponentClass, + WasmDeprecateReason, + WasmDrainApplyEventsResult, } from './wasm-adapter.ts'; diff --git a/packages/cala-core/src/wasm-adapter.ts b/packages/cala-core/src/wasm-adapter.ts index bfb979b..5e83db3 100644 --- a/packages/cala-core/src/wasm-adapter.ts +++ b/packages/cala-core/src/wasm-adapter.ts @@ -57,3 +57,59 @@ export function initCalaCore(): Promise { export function calaMemoryBytes(): number | null { return calaMemory ? calaMemory.buffer.byteLength : null; } + +// ── Phase 7: drainApplyEvents wire shape ────────────────────────────── +// +// Mirrors the Rust `AppliedEvent` tagged union (see +// `crates/cala-core/src/fitting/pipeline.rs`). We duplicate the shape +// here rather than generating it from the `.d.ts` (wasm-bindgen emits +// `any` for `serde_wasm_bindgen` returns) so TS callers get full +// autocomplete + exhaustiveness checking on `kind`. +export type WasmComponentClass = 'cell' | 'slowBaseline' | 'neuropil'; + +export type WasmDeprecateReason = + | 'footprintCollapsed' + | 'traceInactive' + | 'mergedInto' + | 'invalidApply'; + +export type WasmAppliedEvent = + | { + kind: 'birth'; + id: number; + class: WasmComponentClass; + support: number[]; + values: number[]; + patch: [number, number]; + } + | { + kind: 'merge'; + ids: [number, number]; + into: number; + class: WasmComponentClass; + support: number[]; + values: number[]; + } + | { + kind: 'deprecate'; + id: number; + reason: WasmDeprecateReason; + }; + +export interface WasmDrainApplyEventsResult { + /** `[applied, stale, invalid]` — matches `drainApply`'s return. */ + report: [number, number, number]; + events: WasmAppliedEvent[]; +} + +/** + * Typed wrapper around `Fitter.drainApplyEvents`. Centralizing the + * cast keeps callers from repeating `as WasmDrainApplyEventsResult` + * and documents the shape the Rust binding promises. + */ +export function drainApplyEventsTyped( + fitter: Fitter, + queue: MutationQueueHandle, +): WasmDrainApplyEventsResult { + return fitter.drainApplyEvents(queue) as WasmDrainApplyEventsResult; +} diff --git a/packages/cala-runtime/src/events.ts b/packages/cala-runtime/src/events.ts index 6c6ffca..a8f3e07 100644 --- a/packages/cala-runtime/src/events.ts +++ b/packages/cala-runtime/src/events.ts @@ -73,6 +73,17 @@ export type PipelineEvent = t: number; neuronId: number; footprint: FootprintSnap; + } + // Per-neuron trace sample emitted by fit at vitals cadence + // (Phase 7 task 8). `ids[i]` owns `values[i]`; a neuron that's + // missing from this sample has been deprecated since the last + // one. Strided so the main-thread traces panel gets a smooth + // scroll without paying postMessage cost on every frame. + | { + kind: 'trace-sample'; + t: number; + ids: Uint32Array; + values: Float32Array; }; export type Unsubscribe = () => void; diff --git a/packages/cala-runtime/src/worker-protocol.ts b/packages/cala-runtime/src/worker-protocol.ts index 963af16..48daf03 100644 --- a/packages/cala-runtime/src/worker-protocol.ts +++ b/packages/cala-runtime/src/worker-protocol.ts @@ -77,6 +77,20 @@ export type WorkerInbound = // Returns every `(t, sparse A column)` snapshot the archive has // recorded for `neuronId`, ordered oldest→newest. | { kind: 'request-footprint-history'; requestId: number; neuronId: number } + // All live neuron traces (design §8 traces panel, Phase 7 task 8). + // `idFilter`, if present, restricts the reply to the intersection + // with ids the archive has seen. Empty filter (undefined) returns + // every tracked id. Reply is `all-traces`. + | { + kind: 'request-all-traces'; + requestId: number; + idFilter?: Uint32Array; + } + // All live-neuron footprints for the footprints panel overlay + // (design §8, Phase 7 task 10). Returns the most recent sparse + // `A` column snapshot per id for neurons that are not currently + // deprecated. Reply is `all-footprints`. + | { kind: 'request-all-footprints'; requestId: 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 @@ -137,18 +151,49 @@ export type WorkerOutbound = 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 - // an 8-bit grayscale projection of the preprocessed f32 frame - // (post-autoscale) so the main thread can `putImageData` without - // touching the SAB slot the fit worker is still reading. + // Reply to `request-all-traces`. `ids[i]`, `times[i]`, and + // `values[i]` are parallel. Each per-id `times`/`values` pair is + // chronological oldest → newest. Ids not currently tracked are + // omitted from the reply (same empty-means-unknown contract as + // `request-timeseries`). + | { + kind: 'all-traces'; + role: WorkerRole; + requestId: number; + ids: Uint32Array; + times: Float32Array[]; + values: Float32Array[]; + } + // Reply to `request-all-footprints`. `ids[i]` owns + // `pixelIndices[i]` + `values[i]`. Each sparse pair describes the + // footprint's latest snapshot in frame coords (linear index → + // weight). Deprecated neurons are excluded. + | { + kind: 'all-footprints'; + role: WorkerRole; + requestId: number; + ids: Uint32Array; + pixelIndices: Uint32Array[]; + values: Float32Array[]; + } + // W1 + W2 preview frames for the dashboard (design §12 frame + // panel). Strided like `frame-processed` so the post rate is + // bounded even when the producing worker outruns the main-thread + // canvas; `pixels` is an 8-bit grayscale projection of the + // producing stage's f32 frame (post-autoscale). + // + // `stage` disambiguates the four panels (Phase 7 task 5): + // - 'raw' — W1 post-decode, pre-preprocess. + // - 'hotPixel' — W1 post hot-pixel median, pre-motion. + // - 'motion' — W1 post-motion (what fit sees). + // - 'reconstruction' — W2 `Ã · c_t` reconstruction. | { kind: 'frame-preview'; role: WorkerRole; index: number; width: number; height: number; + stage: 'raw' | 'hotPixel' | 'motion' | 'reconstruction'; pixels: Uint8ClampedArray; }; diff --git a/packages/io/src/index.ts b/packages/io/src/index.ts index 3c05c81..14ba61c 100644 --- a/packages/io/src/index.ts +++ b/packages/io/src/index.ts @@ -1,6 +1,8 @@ export { parseNpy } from './npy-parser.ts'; export { writeNpy } from './npy-writer.ts'; export { parseNpz } from './npz-parser.ts'; +export { writeNpz } from './npz-writer.ts'; +export type { NpzWritableArray } from './npz-writer.ts'; export { validateTraceData } from './validation.ts'; export { extractCellTrace, processNpyResult } from './array-utils.ts'; export { rankCellsByActivity, sampleRandomCells } from './cell-ranking.ts'; diff --git a/packages/io/src/npy-writer.ts b/packages/io/src/npy-writer.ts index e9277eb..796db84 100644 --- a/packages/io/src/npy-writer.ts +++ b/packages/io/src/npy-writer.ts @@ -1,18 +1,37 @@ // .npy binary format writer -// Inverse of npy-parser.ts — serializes a Float32Array + shape into .npy format. +// Inverse of npy-parser.ts — serializes a typed array + shape into .npy format. // Reference: https://numpy.org/doc/2.3/reference/generated/numpy.lib.format.html /** - * Write a Float32Array as a .npy binary buffer (version 1.0, little-endian float32). + * NumPy dtype descriptor for the supported typed arrays. Little- + * endian scalar tags — matches what `parseNpy` accepts. + */ +type NpyDtypeDescr = '.npy`. + * @returns Uint8Array containing the complete .npz archive. + */ +export function writeNpz( + arrays: Record, +): Uint8Array { + const entries: Record = {}; + for (const [name, { data, shape }] of Object.entries(arrays)) { + const npy = writeNpy(data, shape); + entries[`${name}.npy`] = new Uint8Array(npy); + } + return zipSync(entries); +}