From 38a7d620b26bf8fa75617bdc37e2b1183563fb2c Mon Sep 17 00:00:00 2001 From: daharoni Date: Sun, 19 Apr 2026 08:53:58 -0700 Subject: [PATCH 01/13] feat(cala-core): FitPipeline::drain_apply_events returns structural events (T1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `drain_apply` kept only aggregate counts — Phase 6 had to surface extend activity as a `extend.proposed` metric because the per- mutation identities never left the Rust side. Phase 7 wants real `birth`/`merge`/`deprecate` events in the event feed, so the fit pipeline now also exposes `drain_apply_events` which returns one `AppliedEvent` per successfully-applied mutation: - Birth: newly-assigned id + class + support/values + weighted- centroid patch coords. - Merge: pair of deprecated ids + new id + class + support/values. - Deprecate: id + reason. Stale/invalid rejections are reflected in the `ApplyBatchReport` but produce no event. `drain_apply` stays on the surface for callers that only need counts (tests, metrics). Next task wires this through the WASM binding. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/cala-core/src/fitting/mod.rs | 2 +- crates/cala-core/src/fitting/pipeline.rs | 182 ++++++++++++++++++++++- crates/cala-core/tests/fitting_apply.rs | 106 ++++++++++++- 3 files changed, 287 insertions(+), 3 deletions(-) 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..0d360ec 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,154 @@ 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. +#[derive(Debug, Clone)] +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/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()); +} From 74f8f75b3a695a776478c16ae38170f1f06ac115 Mon Sep 17 00:00:00 2001 From: daharoni Date: Sun, 19 Apr 2026 09:00:05 -0700 Subject: [PATCH 02/13] feat(cala-core): WASM drainApplyEvents binding (T2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Fitter.drainApplyEvents(queue)` now exists alongside `drainApply`. Returns `{ report: [applied, stale, invalid], events: AppliedEvent[] }` via serde-wasm-bindgen — the event shape mirrors the JS `PipelineEvent` birth/merge/deprecate variants with `kind` as the discriminator. Changes: - AppliedEvent / DeprecateReason / ComponentClass gain conditional serde derives with `rename_all = "camelCase"` so the wire shape matches the existing TS union types. - `packages/cala-core` adapter exports a typed `WasmAppliedEvent` union + `drainApplyEventsTyped` wrapper so callers don't repeat the `as` cast that wasm-bindgen's `any` return forces. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/cala-core/pkg/calab_cala_core.d.ts | 22 +++- crates/cala-core/pkg/calab_cala_core.js | 108 ++++++++++++++---- crates/cala-core/pkg/calab_cala_core_bg.wasm | Bin 383764 -> 394782 bytes .../pkg/calab_cala_core_bg.wasm.d.ts | 7 +- crates/cala-core/src/bindings/wasm.rs | 37 ++++++ crates/cala-core/src/config.rs | 1 + crates/cala-core/src/extending/mutation.rs | 2 + crates/cala-core/src/fitting/pipeline.rs | 5 +- packages/cala-core/src/index.ts | 8 ++ packages/cala-core/src/wasm-adapter.ts | 56 +++++++++ 10 files changed, 216 insertions(+), 30 deletions(-) diff --git a/crates/cala-core/pkg/calab_cala_core.d.ts b/crates/cala-core/pkg/calab_cala_core.d.ts index 5b896bd..93a336c 100644 --- a/crates/cala-core/pkg/calab_cala_core.d.ts +++ b/crates/cala-core/pkg/calab_cala_core.d.ts @@ -84,6 +84,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. @@ -228,6 +243,7 @@ export interface InitOutput { readonly extender_pushResidual: (a: number, b: number, c: number, d: number) => void; readonly extender_runCycle: (a: number, b: number, c: number) => number; readonly fitter_drainApply: (a: number, b: number, c: number) => void; + readonly fitter_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; @@ -252,9 +268,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..820b254 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); @@ -247,12 +247,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 +313,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 +331,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); @@ -332,7 +364,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 +375,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 +461,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 +489,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 +537,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 +565,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 +576,7 @@ 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); @@ -562,9 +594,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 +607,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 +680,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 +698,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 2175f942ac3c65b49002cee01a3ede5171020458..93f3847a9ab3a307dd64e8ec81cd5562e9c43f79 100644 GIT binary patch delta 60433 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 void; export const extender_runCycle: (a: number, b: number, c: number) => number; export const fitter_drainApply: (a: number, b: number, c: number) => void; +export const fitter_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; @@ -43,7 +44,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..dc8974b 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; @@ -325,6 +336,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/pipeline.rs b/crates/cala-core/src/fitting/pipeline.rs index 0d360ec..ac7bd71 100644 --- a/crates/cala-core/src/fitting/pipeline.rs +++ b/crates/cala-core/src/fitting/pipeline.rs @@ -328,8 +328,11 @@ pub struct ApplyBatchReport { /// `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. +/// 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, 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; +} From a378652824e2ef084ae2d2dcfb5849af26c53827 Mon Sep 17 00:00:00 2001 From: daharoni Date: Sun, 19 Apr 2026 09:08:24 -0700 Subject: [PATCH 03/13] feat(cala): W2 publishes real birth/merge events on the bus (T3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the Phase 6 `extend.proposed` metric-only emission with a real `drainApplyEvents` call. Every successfully-applied mutation now surfaces as a `birth`/`merge`/`deprecate` `PipelineEvent` on the event bus — archive worker picks them up through the existing subscriber path, so the event feed finally shows `born @(y,x)` rows end-to-end. The `extend.proposed` metric stays (still useful as a flat-line = quiet-FOV signal), but the JS-side placeholder `mutationToEvent` that Phase 6 used to fake ids + `patch: [0,0]` is no longer the source of structural events for the extend path. Test stubs (`fit.worker.test.ts` + phase5/6 E2Es) pick up the new `drainApplyEvents` surface + `drainApplyEventsTyped` adapter export so they continue to cover the fit loop without real WASM. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cala/e2e/phase5-exit.e2e.test.ts | 11 ++++ apps/cala/e2e/phase6-exit.e2e.test.ts | 23 ++++++++ apps/cala/e2e/phase6-extend.e2e.test.ts | 26 +++++++++ .../src/workers/__tests__/fit.worker.test.ts | 56 ++++++++++++++++++ apps/cala/src/workers/fit.worker.ts | 57 +++++++++++++++---- 5 files changed, 161 insertions(+), 12 deletions(-) diff --git a/apps/cala/e2e/phase5-exit.e2e.test.ts b/apps/cala/e2e/phase5-exit.e2e.test.ts index 94f7afa..06572cd 100644 --- a/apps/cala/e2e/phase5-exit.e2e.test.ts +++ b/apps/cala/e2e/phase5-exit.e2e.test.ts @@ -252,6 +252,15 @@ 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: [] }; + } takeSnapshot(): { epoch(): bigint; numComponents(): number; pixels(): number; free(): void } { return { epoch: () => this.currentEpoch, @@ -295,6 +304,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..de3c7ca 100644 --- a/apps/cala/e2e/phase6-exit.e2e.test.ts +++ b/apps/cala/e2e/phase6-exit.e2e.test.ts @@ -197,6 +197,27 @@ 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], + }, + ], + }; + } takeSnapshot(): { epoch(): bigint; numComponents(): number; pixels(): number; free(): void } { return { epoch: () => this.currentEpoch, @@ -239,6 +260,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..38bc746 100644 --- a/apps/cala/e2e/phase6-extend.e2e.test.ts +++ b/apps/cala/e2e/phase6-extend.e2e.test.ts @@ -202,6 +202,30 @@ 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], + }, + ], + }; + } takeSnapshot(): { epoch(): bigint; numComponents(): number; pixels(): number; free(): void } { return { epoch: () => this.currentEpoch, @@ -239,6 +263,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/src/workers/__tests__/fit.worker.test.ts b/apps/cala/src/workers/__tests__/fit.worker.test.ts index fb11521..44027bf 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,29 @@ 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 }; + } + takeSnapshot(): { epoch(): bigint; numComponents(): number; pixels(): number; free(): void } { this.snapshotCalls += 1; this.self.snapshotCalls = this.snapshotCalls; @@ -174,6 +226,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/fit.worker.ts b/apps/cala/src/workers/fit.worker.ts index f47651e..8f7a5c3 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, @@ -483,6 +485,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 +536,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 { From 401ec330b2ccc595ceb477123da3efc8aeb438ac Mon Sep 17 00:00:00 2001 From: daharoni Date: Sun, 19 Apr 2026 09:23:19 -0700 Subject: [PATCH 04/13] feat(cala): W1 emits 3-stage preview streams (T5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On preview-stride frames, W1 now calls the new `Preprocessor.processFrameF32WithStages` WASM binding and posts three `frame-preview` messages tagged with `stage`: 'raw' (decoded grayscale), 'hotPixel' (post hot-pixel median), 'motion' (post-motion, what fit sees). The fourth canvas (`reconstruction`, Ãc) lands in T6 from W2. Changes: - New Rust `PreprocessPipeline::process_frame_with_stages` that emits the hot-pixel + motion-corrected intermediates alongside the final frame. The hot path (`process_frame`) is unchanged — the stage-capture cost only pays on preview-stride frames. - WASM `Preprocessor.processFrameF32WithStages` returning a flat `[final || hot || motion]` buffer (3·pixels), sliced on the JS side into `subarray` views — no copy on the JS boundary. - `WorkerOutbound.frame-preview` gains a `stage` field so the dashboard wiring can route each stream to its own canvas. - `run-control` exposes `latestFrames: Accessor>>`; the legacy `latestFrame` accessor keeps pointing at the `motion` stage for the existing SingleFrameViewer. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cala/e2e/phase5-exit.e2e.test.ts | 8 +++ apps/cala/e2e/phase6-exit.e2e.test.ts | 7 +++ apps/cala/e2e/phase6-extend.e2e.test.ts | 7 +++ apps/cala/src/lib/run-control.ts | 30 +++++++--- .../decode-preprocess.worker.test.ts | 16 ++++++ .../src/workers/decode-preprocess.worker.ts | 54 ++++++++++++++---- crates/cala-core/pkg/calab_cala_core.d.ts | 12 ++++ crates/cala-core/pkg/calab_cala_core.js | 32 +++++++++++ crates/cala-core/pkg/calab_cala_core_bg.wasm | Bin 394782 -> 396273 bytes .../pkg/calab_cala_core_bg.wasm.d.ts | 1 + crates/cala-core/src/bindings/wasm.rs | 50 ++++++++++++++++ crates/cala-core/src/preprocess/pipeline.rs | 42 ++++++++++++++ packages/cala-runtime/src/worker-protocol.ts | 18 ++++-- 13 files changed, 253 insertions(+), 24 deletions(-) diff --git a/apps/cala/e2e/phase5-exit.e2e.test.ts b/apps/cala/e2e/phase5-exit.e2e.test.ts index 06572cd..aa374d9 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 } diff --git a/apps/cala/e2e/phase6-exit.e2e.test.ts b/apps/cala/e2e/phase6-exit.e2e.test.ts index de3c7ca..87d5c56 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 {} } diff --git a/apps/cala/e2e/phase6-extend.e2e.test.ts b/apps/cala/e2e/phase6-extend.e2e.test.ts index 38bc746..67cdb21 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 {} } diff --git a/apps/cala/src/lib/run-control.ts b/apps/cala/src/lib/run-control.ts index 3cff8d8..9e25927 100644 --- a/apps/cala/src/lib/run-control.ts +++ b/apps/cala/src/lib/run-control.ts @@ -68,12 +68,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 latestFrames: Accessor = latestFramesSignal; -export const latestFrame: Accessor = latestFrameSignal; +// 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; @@ -162,12 +173,15 @@ function wrapFactories(base: WorkerFactories): WorkerFactories { const listener = (ev: { data: WorkerOutbound }): void => { const msg = ev.data; if (msg.kind === 'frame-preview') { - setLatestFrameSignal({ - index: msg.index, - width: msg.width, - height: msg.height, - pixels: msg.pixels, - }); + setLatestFramesSignal((prev) => ({ + ...prev, + [msg.stage]: { + index: msg.index, + width: msg.width, + height: msg.height, + pixels: msg.pixels, + }, + })); return; } }; 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/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/crates/cala-core/pkg/calab_cala_core.d.ts b/crates/cala-core/pkg/calab_cala_core.d.ts index 93a336c..9a6698c 100644 --- a/crates/cala-core/pkg/calab_cala_core.d.ts +++ b/crates/cala-core/pkg/calab_cala_core.d.ts @@ -188,6 +188,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 @@ -261,6 +272,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; diff --git a/crates/cala-core/pkg/calab_cala_core.js b/crates/cala-core/pkg/calab_cala_core.js index 820b254..a605d9f 100644 --- a/crates/cala-core/pkg/calab_cala_core.js +++ b/crates/cala-core/pkg/calab_cala_core.js @@ -582,6 +582,38 @@ export class Preprocessor { 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); + } + } /** * Convenience: decode raw AVI bytes to grayscale and preprocess * in one call. Avoids a round-trip across the JS boundary for diff --git a/crates/cala-core/pkg/calab_cala_core_bg.wasm b/crates/cala-core/pkg/calab_cala_core_bg.wasm index 93f3847a9ab3a307dd64e8ec81cd5562e9c43f79..3e5bd0d364053fbc8e162631357cdcebf15068fd 100644 GIT binary patch delta 48417 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 delta 47423 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 jqVJ{|d9u5or4Ophp^`lLF?4AeD{-pMcb{G 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; diff --git a/crates/cala-core/src/bindings/wasm.rs b/crates/cala-core/src/bindings/wasm.rs index dc8974b..2bc33c0 100644 --- a/crates/cala-core/src/bindings/wasm.rs +++ b/crates/cala-core/src/bindings/wasm.rs @@ -243,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 ──────────────────────────────────────────────────────────── 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/packages/cala-runtime/src/worker-protocol.ts b/packages/cala-runtime/src/worker-protocol.ts index 963af16..68370e3 100644 --- a/packages/cala-runtime/src/worker-protocol.ts +++ b/packages/cala-runtime/src/worker-protocol.ts @@ -137,18 +137,24 @@ 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. + // 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; }; From 9cf418d7df06f650b99e47d8cd2db4ad9f009e10 Mon Sep 17 00:00:00 2001 From: daharoni Date: Sun, 19 Apr 2026 09:26:36 -0700 Subject: [PATCH 05/13] =?UTF-8?q?feat(cala):=20W2=20emits=20=C3=83c=20reco?= =?UTF-8?q?nstruction=20preview=20(T6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New WASM binding `Fitter.reconstructLastFrame()` returns `A · c_t` as a `Float32Array`. Fit worker emits a `frame-preview` with `stage: 'reconstruction'` at `framePreviewStride` cadence (default 2 to match W1's preview cadence via `run-control`). `run-control` now also listens to fit's `frame-preview` posts and routes them into the same `latestFrames` signal that W1 posts land in, so the 4-canvas panel (T7) can read all four stages uniformly. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cala/e2e/phase5-exit.e2e.test.ts | 3 ++ apps/cala/e2e/phase6-exit.e2e.test.ts | 3 ++ apps/cala/e2e/phase6-extend.e2e.test.ts | 3 ++ apps/cala/src/lib/run-control.ts | 20 +++++++- .../src/workers/__tests__/fit.worker.test.ts | 8 +++ apps/cala/src/workers/fit.worker.ts | 46 ++++++++++++++++++ crates/cala-core/pkg/calab_cala_core.d.ts | 10 ++++ crates/cala-core/pkg/calab_cala_core.js | 22 +++++++++ crates/cala-core/pkg/calab_cala_core_bg.wasm | Bin 396273 -> 396842 bytes .../pkg/calab_cala_core_bg.wasm.d.ts | 1 + crates/cala-core/src/bindings/wasm.rs | 20 ++++++++ 11 files changed, 135 insertions(+), 1 deletion(-) diff --git a/apps/cala/e2e/phase5-exit.e2e.test.ts b/apps/cala/e2e/phase5-exit.e2e.test.ts index aa374d9..70361ac 100644 --- a/apps/cala/e2e/phase5-exit.e2e.test.ts +++ b/apps/cala/e2e/phase5-exit.e2e.test.ts @@ -269,6 +269,9 @@ class StubFitter { // and emits no structural events. return { report: [0, 0, 0], events: [] }; } + reconstructLastFrame(): Float32Array { + return new Float32Array(0); + } takeSnapshot(): { epoch(): bigint; numComponents(): number; pixels(): number; free(): void } { return { epoch: () => this.currentEpoch, diff --git a/apps/cala/e2e/phase6-exit.e2e.test.ts b/apps/cala/e2e/phase6-exit.e2e.test.ts index 87d5c56..d88748d 100644 --- a/apps/cala/e2e/phase6-exit.e2e.test.ts +++ b/apps/cala/e2e/phase6-exit.e2e.test.ts @@ -225,6 +225,9 @@ class StubFitter { ], }; } + reconstructLastFrame(): Float32Array { + return new Float32Array(0); + } takeSnapshot(): { epoch(): bigint; numComponents(): number; pixels(): number; free(): void } { return { epoch: () => this.currentEpoch, diff --git a/apps/cala/e2e/phase6-extend.e2e.test.ts b/apps/cala/e2e/phase6-extend.e2e.test.ts index 67cdb21..64ad8f2 100644 --- a/apps/cala/e2e/phase6-extend.e2e.test.ts +++ b/apps/cala/e2e/phase6-extend.e2e.test.ts @@ -233,6 +233,9 @@ class StubFitter { ], }; } + reconstructLastFrame(): Float32Array { + return new Float32Array(0); + } takeSnapshot(): { epoch(): bigint; numComponents(): number; pixels(): number; free(): void } { return { epoch: () => this.currentEpoch, diff --git a/apps/cala/src/lib/run-control.ts b/apps/cala/src/lib/run-control.ts index 9e25927..f53c61b 100644 --- a/apps/cala/src/lib/run-control.ts +++ b/apps/cala/src/lib/run-control.ts @@ -122,6 +122,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. @@ -191,11 +192,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); diff --git a/apps/cala/src/workers/__tests__/fit.worker.test.ts b/apps/cala/src/workers/__tests__/fit.worker.test.ts index 44027bf..04509c7 100644 --- a/apps/cala/src/workers/__tests__/fit.worker.test.ts +++ b/apps/cala/src/workers/__tests__/fit.worker.test.ts @@ -171,6 +171,14 @@ vi.mock('@calab/cala-core', () => { 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); + } + takeSnapshot(): { epoch(): bigint; numComponents(): number; pixels(): number; free(): void } { this.snapshotCalls += 1; this.self.snapshotCalls = this.snapshotCalls; diff --git a/apps/cala/src/workers/fit.worker.ts b/apps/cala/src/workers/fit.worker.ts index 8f7a5c3..64d0dca 100644 --- a/apps/cala/src/workers/fit.worker.ts +++ b/apps/cala/src/workers/fit.worker.ts @@ -75,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 @@ -119,6 +122,7 @@ interface FitWorkerConfig { frameChannelSlotCount: number; frameChannelWaitTimeoutMs: number; frameChannelPollIntervalMs: number; + framePreviewStride: number; } // Route through `self` when present so `vi.stubGlobal('self', harness)` @@ -230,6 +234,7 @@ function parseConfig(raw: unknown): FitWorkerConfig { cfg.frameChannelPollIntervalMs, DEFAULT_FRAME_CHANNEL_POLL_INTERVAL_MS, ), + framePreviewStride: numberOr(cfg.framePreviewStride, DEFAULT_FRAME_PREVIEW_STRIDE), }; } @@ -456,6 +461,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; @@ -565,6 +610,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/crates/cala-core/pkg/calab_cala_core.d.ts b/crates/cala-core/pkg/calab_cala_core.d.ts index 9a6698c..de88500 100644 --- a/crates/cala-core/pkg/calab_cala_core.d.ts +++ b/crates/cala-core/pkg/calab_cala_core.d.ts @@ -121,6 +121,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. @@ -260,6 +269,7 @@ export interface InitOutput { 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; diff --git a/crates/cala-core/pkg/calab_cala_core.js b/crates/cala-core/pkg/calab_cala_core.js index a605d9f..d821654 100644 --- a/crates/cala-core/pkg/calab_cala_core.js +++ b/crates/cala-core/pkg/calab_cala_core.js @@ -355,6 +355,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. diff --git a/crates/cala-core/pkg/calab_cala_core_bg.wasm b/crates/cala-core/pkg/calab_cala_core_bg.wasm index 3e5bd0d364053fbc8e162631357cdcebf15068fd..704fbfd3962db51c0fa918ac8539fa1644abf4af 100644 GIT binary patch delta 28346 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 H-}(Olmdd#G delta 27506 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 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 5c227e0..88ee072 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 @@ -25,6 +25,7 @@ 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; diff --git a/crates/cala-core/src/bindings/wasm.rs b/crates/cala-core/src/bindings/wasm.rs index 2bc33c0..6026c06 100644 --- a/crates/cala-core/src/bindings/wasm.rs +++ b/crates/cala-core/src/bindings/wasm.rs @@ -376,6 +376,26 @@ impl Fitter { } } + /// `Ã · 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 From e13fce4964e9eead54796b6b93214bbd6ca1a03b Mon Sep 17 00:00:00 2001 From: daharoni Date: Sun, 19 Apr 2026 09:29:54 -0700 Subject: [PATCH 06/13] feat(cala): 4-canvas FrameQuad replaces SingleFrameViewer (T7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `FrameQuad` component composes four `FrameStagePanel` canvases in a 2×2 grid (design §8 "Frame panel"): raw · hot-pixel motion-corrected · reconstruction (Ãc) Each panel reads a stage from the `latestFrames` signal populated by W1 (raw / hot-pixel / motion) and W2 (reconstruction). Caption shows the current frame index + epoch from the dashboard store. Scrubber is deferred to a later polish task — needs main-thread frame history per stage, which is a separate data-plumbing change. `SingleFrameViewer` stays in the tree as a back-compat surface (reads `latestFrame` = motion stage) but is no longer rendered by `DashboardLayout`. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cala/src/components/frame/FrameQuad.tsx | 35 ++++++++++ .../src/components/frame/FrameStagePanel.tsx | 69 +++++++++++++++++++ .../src/components/layout/DashboardLayout.tsx | 11 +-- apps/cala/src/styles/global.css | 69 +++++++++++++++++++ 4 files changed, 179 insertions(+), 5 deletions(-) create mode 100644 apps/cala/src/components/frame/FrameQuad.tsx create mode 100644 apps/cala/src/components/frame/FrameStagePanel.tsx 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..a235b44 100644 --- a/apps/cala/src/components/layout/DashboardLayout.tsx +++ b/apps/cala/src/components/layout/DashboardLayout.tsx @@ -1,13 +1,14 @@ 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'; /** * 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 ( @@ -16,7 +17,7 @@ export function DashboardLayout(): JSX.Element {
- +
diff --git a/apps/cala/src/styles/global.css b/apps/cala/src/styles/global.css index e8aba98..ca5415d 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); From 276472582208c8d1209da489e0e6a6a2c78261c6 Mon Sep 17 00:00:00 2001 From: daharoni Date: Sun, 19 Apr 2026 09:43:26 -0700 Subject: [PATCH 07/13] feat(cala): archive per-neuron trace store + requestAllTraces (T8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New bus event `trace-sample` carries `(t, ids, values)` — fit emits one per vitals-stride frame after the per-metric emissions. Archive subscribes, routes samples into a new `NeuronTraceStore` (drop-oldest ring per neuron id), and exposes them through a new `request-all-traces` query. Supporting bits: - New WASM binding `Fitter.componentIds()` so samples carry the correct ids even when extend inserts / removes components across cycles. - Trace samples are *excluded* from the event-log ring and the neuron-event index so they don't flood the feed or the structural-history queries — the traces store is their sole sink. - `archive-client` gains a typed `requestAllTraces(idFilter?)` call, routing a new `all-traces` reply with parallel `ids[] / times[][] / values[][]` arrays back to callers. T9 wires the TracesPanel uPlot chart on top. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cala/e2e/phase5-exit.e2e.test.ts | 6 + apps/cala/e2e/phase6-exit.e2e.test.ts | 6 + apps/cala/e2e/phase6-extend.e2e.test.ts | 6 + .../src/components/events/event-format.ts | 3 + apps/cala/src/lib/archive-client.ts | 34 ++++- .../src/workers/__tests__/fit.worker.test.ts | 6 + apps/cala/src/workers/archive.worker.ts | 48 +++++++ apps/cala/src/workers/fit.worker.ts | 15 ++ apps/cala/src/workers/neuron-event-index.ts | 6 +- apps/cala/src/workers/neuron-trace-store.ts | 129 ++++++++++++++++++ crates/cala-core/pkg/calab_cala_core.d.ts | 8 ++ crates/cala-core/pkg/calab_cala_core.js | 20 +++ crates/cala-core/pkg/calab_cala_core_bg.wasm | Bin 396842 -> 397305 bytes .../pkg/calab_cala_core_bg.wasm.d.ts | 1 + crates/cala-core/src/bindings/wasm.rs | 10 ++ packages/cala-runtime/src/events.ts | 11 ++ packages/cala-runtime/src/worker-protocol.ts | 22 +++ 17 files changed, 327 insertions(+), 4 deletions(-) create mode 100644 apps/cala/src/workers/neuron-trace-store.ts diff --git a/apps/cala/e2e/phase5-exit.e2e.test.ts b/apps/cala/e2e/phase5-exit.e2e.test.ts index 70361ac..342c4c0 100644 --- a/apps/cala/e2e/phase5-exit.e2e.test.ts +++ b/apps/cala/e2e/phase5-exit.e2e.test.ts @@ -272,6 +272,12 @@ class StubFitter { 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, diff --git a/apps/cala/e2e/phase6-exit.e2e.test.ts b/apps/cala/e2e/phase6-exit.e2e.test.ts index d88748d..7fbb8e5 100644 --- a/apps/cala/e2e/phase6-exit.e2e.test.ts +++ b/apps/cala/e2e/phase6-exit.e2e.test.ts @@ -228,6 +228,12 @@ class StubFitter { 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, diff --git a/apps/cala/e2e/phase6-extend.e2e.test.ts b/apps/cala/e2e/phase6-extend.e2e.test.ts index 64ad8f2..729ee99 100644 --- a/apps/cala/e2e/phase6-extend.e2e.test.ts +++ b/apps/cala/e2e/phase6-extend.e2e.test.ts @@ -236,6 +236,12 @@ class StubFitter { 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, 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/lib/archive-client.ts b/apps/cala/src/lib/archive-client.ts index 5f81ba8..bf132a7 100644 --- a/apps/cala/src/lib/archive-client.ts +++ b/apps/cala/src/lib/archive-client.ts @@ -29,11 +29,21 @@ 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 `times`. */ + values: Float32Array[]; +} + export interface ArchiveClient { requestDump(): Promise; requestTimeseries(name: string): Promise; requestEventsForNeuron(neuronId: number): Promise; requestFootprintHistory(neuronId: number): Promise; + requestAllTraces(idFilter?: Uint32Array): Promise; startPolling(cb: (dump: ArchiveDump) => void): void; stopPolling(): void; onEvent(cb: (e: PipelineEvent) => void): Unsubscribe; @@ -67,14 +77,15 @@ 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'; } type PendingEntry = | PendingReply | PendingReply | PendingReply - | PendingReply; + | PendingReply + | PendingReply; export function createArchiveClient( worker: WorkerLike, @@ -145,6 +156,18 @@ 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 'event': for (const cb of eventListeners) cb(msg.event); return; @@ -216,6 +239,12 @@ export function createArchiveClient( ); } + function requestAllTraces(idFilter?: Uint32Array): Promise { + return issueRequest('all-traces', 'all-traces', (requestId) => { + worker.postMessage({ kind: 'request-all-traces', requestId, idFilter }); + }); + } + function startPolling(cb: (dump: ArchiveDump) => void): void { if (disposed) return; pollCallback = cb; @@ -272,6 +301,7 @@ export function createArchiveClient( requestTimeseries, requestEventsForNeuron, requestFootprintHistory, + requestAllTraces, startPolling, stopPolling, onEvent, diff --git a/apps/cala/src/workers/__tests__/fit.worker.test.ts b/apps/cala/src/workers/__tests__/fit.worker.test.ts index 04509c7..33397e4 100644 --- a/apps/cala/src/workers/__tests__/fit.worker.test.ts +++ b/apps/cala/src/workers/__tests__/fit.worker.test.ts @@ -178,6 +178,12 @@ vi.mock('@calab/cala-core', () => { // 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; diff --git a/apps/cala/src/workers/archive.worker.ts b/apps/cala/src/workers/archive.worker.ts index e119589..ecd4b83 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,20 @@ 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 postDoneOnce(): void { if (donePosted) return; donePosted = true; @@ -343,6 +387,7 @@ function handleStop(): void { handles.unsubscribeTimeseries(); handles.unsubscribeNeuronIndex(); handles.unsubscribeFootprints(); + handles.unsubscribeTraces(); handles.bus.close(); postDoneOnce(); } @@ -375,6 +420,9 @@ 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 'stop': handleStop(); return; diff --git a/apps/cala/src/workers/fit.worker.ts b/apps/cala/src/workers/fit.worker.ts index 64d0dca..bfda911 100644 --- a/apps/cala/src/workers/fit.worker.ts +++ b/apps/cala/src/workers/fit.worker.ts @@ -377,6 +377,7 @@ function updateSchedulerFromEvent(scheduler: FootprintSnapshotScheduler, ev: Pip case 'reject': case 'metric': case 'footprint-snapshot': + case 'trace-sample': return; } } @@ -522,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 diff --git a/apps/cala/src/workers/neuron-event-index.ts b/apps/cala/src/workers/neuron-event-index.ts index ceef01e..9cd8ea0 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 []; } } 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 de88500..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]` @@ -262,6 +269,7 @@ 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; diff --git a/crates/cala-core/pkg/calab_cala_core.js b/crates/cala-core/pkg/calab_cala_core.js index d821654..e36d137 100644 --- a/crates/cala-core/pkg/calab_cala_core.js +++ b/crates/cala-core/pkg/calab_cala_core.js @@ -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]` diff --git a/crates/cala-core/pkg/calab_cala_core_bg.wasm b/crates/cala-core/pkg/calab_cala_core_bg.wasm index 704fbfd3962db51c0fa918ac8539fa1644abf4af..3ebb8f9aa84432f80843883effa868d6053af4e6 100644 GIT binary patch delta 27667 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 v!QH+y_-BFI+!_3_Ag>Gb)g{%ui 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; diff --git a/crates/cala-core/src/bindings/wasm.rs b/crates/cala-core/src/bindings/wasm.rs index 6026c06..3415fd4 100644 --- a/crates/cala-core/src/bindings/wasm.rs +++ b/crates/cala-core/src/bindings/wasm.rs @@ -376,6 +376,16 @@ 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 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 68370e3..0d6f516 100644 --- a/packages/cala-runtime/src/worker-protocol.ts +++ b/packages/cala-runtime/src/worker-protocol.ts @@ -77,6 +77,15 @@ 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; + } // 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,6 +146,19 @@ export type WorkerOutbound = pixelIndices: Uint32Array[]; values: Float32Array[]; } + // 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[]; + } // 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 From 9f0ae523785c29d2d519a53556919a893c598ea4 Mon Sep 17 00:00:00 2001 From: daharoni Date: Sun, 19 Apr 2026 09:46:05 -0700 Subject: [PATCH 08/13] feat(cala): TracesPanel uPlot strip chart (T9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `uplot` dependency and a `TracesPanel` component that polls `requestAllTraces` every second, merges per-id time/value arrays into a single aligned uPlot frame, and renders one stroke per neuron with a stable hue-hash color. Clicking a trace selects that neuron via a new shared `selectedNeuronId` signal — `FootprintsPanel` (T11) and the per-neuron zoom (T12) will read it to stay in sync. Dashboard grid gains a `traces` row under the frame panel so the chart shares the main column with the 4-canvas viewer. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cala/package.json | 3 +- .../src/components/layout/DashboardLayout.tsx | 4 + .../src/components/traces/TracesPanel.tsx | 194 ++++++++++++++++++ apps/cala/src/lib/selection-store.ts | 14 ++ apps/cala/src/styles/global.css | 32 ++- package-lock.json | 3 +- 6 files changed, 246 insertions(+), 4 deletions(-) create mode 100644 apps/cala/src/components/traces/TracesPanel.tsx create mode 100644 apps/cala/src/lib/selection-store.ts 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/layout/DashboardLayout.tsx b/apps/cala/src/components/layout/DashboardLayout.tsx index a235b44..c0e7a7a 100644 --- a/apps/cala/src/components/layout/DashboardLayout.tsx +++ b/apps/cala/src/components/layout/DashboardLayout.tsx @@ -2,6 +2,7 @@ import { type JSX } from 'solid-js'; import { FrameQuad } from '../frame/FrameQuad.tsx'; import { VitalsBar } from '../vitals/VitalsBar.tsx'; import { EventFeed } from '../events/EventFeed.tsx'; +import { TracesPanel } from '../traces/TracesPanel.tsx'; /** * Running-state layout (design §12): vitals bar along the top, the @@ -19,6 +20,9 @@ export function DashboardLayout(): JSX.Element {
+
+ +
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/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 ca5415d..908d56e 100644 --- a/apps/cala/src/styles/global.css +++ b/apps/cala/src/styles/global.css @@ -318,10 +318,11 @@ .cala-dashboard { display: grid; grid-template-columns: minmax(0, 1fr) 360px; - grid-template-rows: auto minmax(0, 1fr); + grid-template-rows: auto minmax(0, 1fr) auto; grid-template-areas: 'vitals vitals' - 'frame events'; + 'frame events' + 'traces events'; gap: var(--space-md); padding: var(--space-md); height: 100%; @@ -338,6 +339,11 @@ min-height: 0; } +.cala-dashboard__traces { + grid-area: traces; + min-width: 0; +} + .cala-dashboard__events { grid-area: events; min-height: 0; @@ -349,6 +355,28 @@ 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; +} + /* Simplified single-frame viewer: no side panel in the new layout. */ .frame-viewer { 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": { From d948292dc9f182bee4f80993a1c1b34cc15b679b Mon Sep 17 00:00:00 2001 From: daharoni Date: Sun, 19 Apr 2026 09:49:51 -0700 Subject: [PATCH 09/13] feat(cala): max-projection store + archive requestAllFootprints (T10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two complementary pieces feeding the upcoming footprints panel: 1. **Main-thread running max projection**. W1 already posts the motion-corrected frame as a `frame-preview` for the 4-canvas viewer; `run-control` now folds each motion frame into a shared `maxProjection` signal (element-wise max over the u8 preview). Resets when a new run starts. Kept main-thread — W1 already owns the data and no archive round-trip is needed. 2. **Archive `request-all-footprints`**. Returns the most recent sparse `A`-column snapshot per *live* neuron. Live = latest structural event is not a deprecate, via a new `NeuronEventIndex.liveIds()` helper. Client gets a typed `AllFootprintsReply` with parallel `ids / pixelIndices / values` arrays. T11 wires both into the `FootprintsPanel` overlay. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cala/src/lib/archive-client.ts | 41 +++++++++++++- apps/cala/src/lib/max-projection-store.ts | 58 ++++++++++++++++++++ apps/cala/src/lib/run-control.ts | 18 ++++-- apps/cala/src/workers/archive.worker.ts | 29 ++++++++++ apps/cala/src/workers/neuron-event-index.ts | 16 ++++++ packages/cala-runtime/src/worker-protocol.ts | 17 ++++++ 6 files changed, 170 insertions(+), 9 deletions(-) create mode 100644 apps/cala/src/lib/max-projection-store.ts diff --git a/apps/cala/src/lib/archive-client.ts b/apps/cala/src/lib/archive-client.ts index bf132a7..e3591f5 100644 --- a/apps/cala/src/lib/archive-client.ts +++ b/apps/cala/src/lib/archive-client.ts @@ -34,7 +34,15 @@ export interface AllTracesReply { ids: Uint32Array; /** Per-id time axis in chronological order. */ times: Float32Array[]; - /** Per-id trace values aligned with `times`. */ + /** 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[]; } @@ -44,6 +52,7 @@ export interface ArchiveClient { 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; @@ -77,7 +86,13 @@ interface PendingReply { resolve: (v: T) => void; reject: (err: Error) => void; timer: ReturnType; - kind: 'dump' | 'timeseries' | 'events-for-neuron' | 'footprint-history' | 'all-traces'; + kind: + | 'dump' + | 'timeseries' + | 'events-for-neuron' + | 'footprint-history' + | 'all-traces' + | 'all-footprints'; } type PendingEntry = @@ -85,7 +100,8 @@ type PendingEntry = | PendingReply | PendingReply | PendingReply - | PendingReply; + | PendingReply + | PendingReply; export function createArchiveClient( worker: WorkerLike, @@ -168,6 +184,18 @@ export function createArchiveClient( }); 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; @@ -245,6 +273,12 @@ export function createArchiveClient( }); } + 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; @@ -302,6 +336,7 @@ export function createArchiveClient( requestEventsForNeuron, requestFootprintHistory, requestAllTraces, + requestAllFootprints, startPolling, stopPolling, onEvent, 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 f53c61b..709fa56 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, @@ -174,15 +175,19 @@ function wrapFactories(base: WorkerFactories): WorkerFactories { const listener = (ev: { data: WorkerOutbound }): void => { const msg = ev.data; if (msg.kind === 'frame-preview') { + const preview = { + index: msg.index, + width: msg.width, + height: msg.height, + pixels: msg.pixels, + }; setLatestFramesSignal((prev) => ({ ...prev, - [msg.stage]: { - index: msg.index, - width: msg.width, - height: msg.height, - pixels: msg.pixels, - }, + [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; } }; @@ -241,6 +246,7 @@ export async function startRun(opts: StartOptions = {}): Promise { setErrorMsg(null); setRunState('starting'); + resetMaxProjection(); const baseFactories = opts.factories ?? defaultWorkerFactories(); const factories = wrapFactories(baseFactories); diff --git a/apps/cala/src/workers/archive.worker.ts b/apps/cala/src/workers/archive.worker.ts index ecd4b83..4f20953 100644 --- a/apps/cala/src/workers/archive.worker.ts +++ b/apps/cala/src/workers/archive.worker.ts @@ -370,6 +370,32 @@ function handleAllTracesRequest(requestId: number, idFilter?: Uint32Array): void }); } +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; @@ -423,6 +449,9 @@ workerSelf.onmessage = (ev: MessageEvent): void => { 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/neuron-event-index.ts b/apps/cala/src/workers/neuron-event-index.ts index 9cd8ea0..ebed01c 100644 --- a/apps/cala/src/workers/neuron-event-index.ts +++ b/apps/cala/src/workers/neuron-event-index.ts @@ -95,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/packages/cala-runtime/src/worker-protocol.ts b/packages/cala-runtime/src/worker-protocol.ts index 0d6f516..48daf03 100644 --- a/packages/cala-runtime/src/worker-protocol.ts +++ b/packages/cala-runtime/src/worker-protocol.ts @@ -86,6 +86,11 @@ export type WorkerInbound = 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 @@ -159,6 +164,18 @@ export type WorkerOutbound = 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 From 29ab6bbeb701d1e841fd60a09be6bd31378a166b Mon Sep 17 00:00:00 2001 From: daharoni Date: Sun, 19 Apr 2026 09:51:29 -0700 Subject: [PATCH 10/13] feat(cala): FootprintsPanel canvas with max-projection overlay (T11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New component blits the running max projection into a canvas and strokes each live footprint's 4-connected boundary in its per-id color. Clicking a boundary pixel (or interior pixel — hit-test looks up the sparse support) calls `setSelectedNeuronId`; the selected id gets a thicker white outline so it pops against the color wall. Dashboard grid gains a third column: frame quad | footprints | events, with the traces strip chart spanning the two left columns underneath. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/footprints/FootprintsPanel.tsx | 237 ++++++++++++++++++ .../src/components/layout/DashboardLayout.tsx | 4 + apps/cala/src/styles/global.css | 52 +++- 3 files changed, 289 insertions(+), 4 deletions(-) create mode 100644 apps/cala/src/components/footprints/FootprintsPanel.tsx 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/layout/DashboardLayout.tsx b/apps/cala/src/components/layout/DashboardLayout.tsx index c0e7a7a..10a2dca 100644 --- a/apps/cala/src/components/layout/DashboardLayout.tsx +++ b/apps/cala/src/components/layout/DashboardLayout.tsx @@ -3,6 +3,7 @@ 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'; /** * Running-state layout (design §12): vitals bar along the top, the @@ -20,6 +21,9 @@ export function DashboardLayout(): JSX.Element {
+
+ +
diff --git a/apps/cala/src/styles/global.css b/apps/cala/src/styles/global.css index 908d56e..bc3c1f7 100644 --- a/apps/cala/src/styles/global.css +++ b/apps/cala/src/styles/global.css @@ -317,18 +317,24 @@ .cala-dashboard { display: grid; - grid-template-columns: minmax(0, 1fr) 360px; + 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' - 'traces 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; } @@ -377,6 +383,44 @@ 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; +} + /* Simplified single-frame viewer: no side panel in the new layout. */ .frame-viewer { From 77d1b5476926b89bc0a8e20f70253391f01e1c66 Mon Sep 17 00:00:00 2001 From: daharoni Date: Sun, 19 Apr 2026 09:53:34 -0700 Subject: [PATCH 11/13] feat(cala): per-neuron zoom panel (T12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `NeuronZoomPanel` opens above the event feed whenever `selectedNeuronId` is non-null. Polls `requestFootprintHistory` + `requestAllTraces({idFilter: [id]})` every 2s and renders: - bbox-cropped grayscale footprint shape (bbox + 2px padding) - sparkline of the neuron's most recent trace samples (reuses the existing vitals `SparkLine` for visual consistency) Click-through wiring: traces panel → `setSelectedNeuronId`, footprints panel → `setSelectedNeuronId`, zoom's × button clears it. No modal overlay — the panel renders inline in the events column so the event feed just shrinks while a neuron is being inspected. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/layout/DashboardLayout.tsx | 2 + .../src/components/neuron/NeuronZoomPanel.tsx | 211 ++++++++++++++++++ apps/cala/src/styles/global.css | 68 ++++++ 3 files changed, 281 insertions(+) create mode 100644 apps/cala/src/components/neuron/NeuronZoomPanel.tsx diff --git a/apps/cala/src/components/layout/DashboardLayout.tsx b/apps/cala/src/components/layout/DashboardLayout.tsx index 10a2dca..7ca3961 100644 --- a/apps/cala/src/components/layout/DashboardLayout.tsx +++ b/apps/cala/src/components/layout/DashboardLayout.tsx @@ -4,6 +4,7 @@ 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'; /** * Running-state layout (design §12): vitals bar along the top, the @@ -28,6 +29,7 @@ export function DashboardLayout(): JSX.Element {
+
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/styles/global.css b/apps/cala/src/styles/global.css index bc3c1f7..63af2dc 100644 --- a/apps/cala/src/styles/global.css +++ b/apps/cala/src/styles/global.css @@ -421,6 +421,74 @@ 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 { From 557ed869a545b1ef4370dc01e3469fc899d554b2 Mon Sep 17 00:00:00 2001 From: daharoni Date: Sun, 19 Apr 2026 11:23:19 -0700 Subject: [PATCH 12/13] feat(cala): NPZ export flow (T15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls the current footprints + traces from the archive worker and packs them into a scipy.sparse-CSC-shaped `.npz`: - `A_data` / `A_indices` / `A_indptr` / `A_shape`: footprint matrix as CSC — `scipy.sparse.csc_matrix((data, indices, indptr), shape)` loads it directly. - `footprint_ids`: parallel id vector. - `C` (K×T), `C_times`, `C_ids`: dense trace matrix padded with NaN on the union time axis. - `height` / `width`: frame geometry. Events are intentionally omitted from the NPZ for now — JSON-in-zip is awkward; the structural event log lives in the UI feed only. Supporting bits: - `@calab/io` `writeNpy` now dispatches on dtype (Float32 / Uint32 / Int32), and a new `writeNpz(arrays)` helper uses `fflate.zipSync` as the inverse of the existing `parseNpz`. - `run-control` keeps the archive worker reference alive after natural run completion so export still works in the `stopped` state; `stopRun` clears it explicitly. - New `ExportButton` lives next to the vitals bar. Disabled when no archive worker is available; shows an "Exporting…" indicator while polling + zipping. Two-pass (T13) + run-mode toggle (T14) from the original Phase 7 task list were descoped to Phase 8 — full implementation requires cross-worker `Footprints` state transfer that's non-trivial. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/export/ExportButton.tsx | 85 +++++++++++++ .../src/components/layout/DashboardLayout.tsx | 2 + apps/cala/src/lib/__tests__/export.test.ts | 74 ++++++++++++ apps/cala/src/lib/export.ts | 113 ++++++++++++++++++ apps/cala/src/lib/run-control.ts | 22 +++- apps/cala/src/styles/global.css | 29 +++++ packages/io/src/index.ts | 2 + packages/io/src/npy-writer.ts | 29 ++++- packages/io/src/npz-writer.ts | 30 +++++ 9 files changed, 378 insertions(+), 8 deletions(-) create mode 100644 apps/cala/src/components/export/ExportButton.tsx create mode 100644 apps/cala/src/lib/__tests__/export.test.ts create mode 100644 apps/cala/src/lib/export.ts create mode 100644 packages/io/src/npz-writer.ts 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/layout/DashboardLayout.tsx b/apps/cala/src/components/layout/DashboardLayout.tsx index 7ca3961..55cb7c7 100644 --- a/apps/cala/src/components/layout/DashboardLayout.tsx +++ b/apps/cala/src/components/layout/DashboardLayout.tsx @@ -5,6 +5,7 @@ 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 @@ -18,6 +19,7 @@ export function DashboardLayout(): JSX.Element {
+
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/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/run-control.ts b/apps/cala/src/lib/run-control.ts index 709fa56..62a9ed2 100644 --- a/apps/cala/src/lib/run-control.ts +++ b/apps/cala/src/lib/run-control.ts @@ -279,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/styles/global.css b/apps/cala/src/styles/global.css index 63af2dc..da2eac8 100644 --- a/apps/cala/src/styles/global.css +++ b/apps/cala/src/styles/global.css @@ -337,6 +337,35 @@ .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 { 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); +} From 531c2daccbd329d0ff989c1c3b6e6ef314500103 Mon Sep 17 00:00:00 2001 From: daharoni Date: Sun, 19 Apr 2026 11:29:40 -0700 Subject: [PATCH 13/13] feat(cala): Phase 7 exit E2E + design doc update (T16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end test `apps/cala/e2e/phase7-exit.e2e.test.ts` drives the real W1/W2/W4 workers against the test AVI and asserts every Phase 7 wire-protocol deliverable lands intact: - real `birth` events on the bus via `drainApplyEvents` (T1-T3) - 3-stage W1 preview streams: raw / hotPixel / motion (T5) - W2 reconstruction preview frames (T6) - `request-all-traces` returns per-id trace samples (T8) - `request-all-footprints` returns live-id sparse-A (T10) - `buildCalaExportNpz` round-trips through `parseNpz` with the expected CSC + K×T shapes (T15) Stub Fitter now returns real ids from `componentIds`, populates `lastTrace` with a fixed amplitude, and emits a non-empty reconstruction frame so the preview-stride path actually posts. Design doc §12 updated with the Phase 7 exit status, explicit deferrals (two-pass, scrubber, events-in-NPZ, Playwright, extend tuning), and the callouts from the live-testing session (loose redundancy gate + missing SlowBaseline seeding together drive the +4 cells per cycle behavior on real recordings). Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cala/e2e/phase7-exit.e2e.test.ts | 566 ++++++++++++++++++++++++++ 1 file changed, 566 insertions(+) create mode 100644 apps/cala/e2e/phase7-exit.e2e.test.ts 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}`, + ); + }, + ); +});