From 219f2cc88038ea3ef4d4946abdb0c918ab44afff Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 18 Apr 2026 10:39:29 -0700 Subject: [PATCH 01/17] feat(cala-core): add WASM bindings surface (task 12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 opener. Adds the `bindings/` module on top of the pure-Rust core: a natively-testable JSON config bridge and the `#[wasm_bindgen]` veneer (`AviReader`, `Preprocessor`, `Fitter`, `MutationQueueHandle`, `SnapshotHandle`) that apps/cala workers will consume. - Config types (`PreprocessConfig`, `FitConfig`, `ExtendConfig`, `RecordingMetadata`, grayscale/motion enums, `ComponentClass`) pick up cfg-gated `serde` derives. New `serde` feature that `jsbindings` and `pybindings` both enable — single source of truth per tuning knob, no JS-side magic numbers. - `io::OwnedAviReader` owning variant of `AviUncompressedReader` so WASM can hold the file bytes across the JS/WASM boundary. Shared grayscale decode helper keeps borrowed and owning paths byte- identical. - 9 new config-JSON roundtrip tests + 5 owned-AVI-reader parity tests, all written before the binding code (§4.1). Compiles on both `x86_64-unknown-linux-gnu` and `wasm32-unknown-unknown --features jsbindings`; clippy + rustfmt clean. Total suite: 347 native tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/cala-core/Cargo.toml | 10 +- crates/cala-core/src/bindings/config_json.rs | 68 +++ crates/cala-core/src/bindings/mod.rs | 13 + crates/cala-core/src/bindings/wasm.rs | 442 ++++++++++++++++++ crates/cala-core/src/config.rs | 17 + crates/cala-core/src/io/avi_uncompressed.rs | 198 +++++++- crates/cala-core/src/io/mod.rs | 3 +- crates/cala-core/src/lib.rs | 1 + .../cala-core/tests/bindings_config_json.rs | 124 +++++ crates/cala-core/tests/io_avi_uncompressed.rs | 147 +++++- 10 files changed, 996 insertions(+), 27 deletions(-) create mode 100644 crates/cala-core/src/bindings/config_json.rs create mode 100644 crates/cala-core/src/bindings/mod.rs create mode 100644 crates/cala-core/src/bindings/wasm.rs create mode 100644 crates/cala-core/tests/bindings_config_json.rs diff --git a/crates/cala-core/Cargo.toml b/crates/cala-core/Cargo.toml index 1838071..e77cb7a 100644 --- a/crates/cala-core/Cargo.toml +++ b/crates/cala-core/Cargo.toml @@ -29,8 +29,14 @@ required-features = ["native-cli"] [features] default = ["jsbindings"] -jsbindings = ["dep:wasm-bindgen", "dep:console_error_panic_hook", "dep:serde", "dep:serde-wasm-bindgen"] -pybindings = ["dep:pyo3", "dep:numpy", "dep:serde", "dep:serde_json"] +# Pulls `serde` + `serde_json` in so the binding layer can round-trip +# config structs as JSON at the JS / Python boundary. `jsbindings` and +# `pybindings` both enable this so the same JSON config surface is the +# single source of truth across targets (mirrors the +# no-hardcoded-magic-numbers rule — every tuning knob is overridable). +serde = ["dep:serde", "dep:serde_json"] +jsbindings = ["serde", "dep:wasm-bindgen", "dep:console_error_panic_hook", "dep:serde-wasm-bindgen"] +pybindings = ["serde", "dep:pyo3", "dep:numpy"] # Native-only dev tooling (CLI test harness for Phase 1). Gated so WASM # and PyO3 builds don't try to compile the binary. native-cli = [] diff --git a/crates/cala-core/src/bindings/config_json.rs b/crates/cala-core/src/bindings/config_json.rs new file mode 100644 index 0000000..edf407a --- /dev/null +++ b/crates/cala-core/src/bindings/config_json.rs @@ -0,0 +1,68 @@ +//! JSON round-trip for config structs at the binding boundary. +//! +//! Every tuning knob that matters to the algorithm lives in a config +//! struct (`PreprocessConfig`, `FitConfig`, `ExtendConfig`, +//! `RecordingMetadata`) with a `DEFAULT_*` constant per field. JS / +//! Python callers hand us a JSON string with only the fields they +//! want to override; everything else falls back to the `Default` +//! impl. This is how we enforce the "no magic numbers in the +//! binding" rule — there is no parallel set of defaults to drift +//! apart. +//! +//! The module is natively testable (see `tests/bindings_config_json.rs`). + +use crate::config::{ExtendConfig, FitConfig, PreprocessConfig, RecordingMetadata}; + +/// A JSON parse failure at a binding entry point. Carries the config +/// family that failed (`"preprocess"`, `"fit"`, …) and the serde +/// error message so callers can surface actionable diagnostics. +#[derive(Debug, Clone)] +pub struct ConfigParseError { + pub kind: &'static str, + pub message: String, +} + +impl std::fmt::Display for ConfigParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "cala-core {} config parse error: {}", + self.kind, self.message + ) + } +} + +impl std::error::Error for ConfigParseError {} + +fn parse( + kind: &'static str, + json: &str, +) -> Result { + serde_json::from_str(json).map_err(|e| ConfigParseError { + kind, + message: e.to_string(), + }) +} + +/// Parse a `PreprocessConfig` from JSON. Unspecified fields take +/// their `DEFAULT_*` value via `#[serde(default)]`. +pub fn parse_preprocess_config(json: &str) -> Result { + parse("preprocess", json) +} + +/// Parse a `FitConfig` from JSON. Unspecified fields take defaults. +pub fn parse_fit_config(json: &str) -> Result { + parse("fit", json) +} + +/// Parse an `ExtendConfig` from JSON. Unspecified fields take defaults. +pub fn parse_extend_config(json: &str) -> Result { + parse("extend", json) +} + +/// Parse a `RecordingMetadata` from JSON. `pixel_size_um` is required +/// (no sensible default). `neuron_diameter_um` falls back to +/// `DEFAULT_NEURON_DIAMETER_UM` when omitted. +pub fn parse_recording_metadata(json: &str) -> Result { + parse("recording", json) +} diff --git a/crates/cala-core/src/bindings/mod.rs b/crates/cala-core/src/bindings/mod.rs new file mode 100644 index 0000000..0fcf5a6 --- /dev/null +++ b/crates/cala-core/src/bindings/mod.rs @@ -0,0 +1,13 @@ +//! Target-specific bindings on top of the pure-Rust numerical core. +//! +//! Each module here is a thin marshalling layer — **no algorithmic +//! logic lives in `bindings/`**. Config flows across the boundary as +//! JSON strings matching the config structs' `serde` shape, so the +//! same parse path is natively testable (§4.1) without needing a WASM +//! runtime stood up. + +#[cfg(feature = "serde")] +pub mod config_json; + +#[cfg(feature = "jsbindings")] +pub mod wasm; diff --git a/crates/cala-core/src/bindings/wasm.rs b/crates/cala-core/src/bindings/wasm.rs new file mode 100644 index 0000000..21babb5 --- /dev/null +++ b/crates/cala-core/src/bindings/wasm.rs @@ -0,0 +1,442 @@ +//! `#[wasm_bindgen]` surface for `apps/cala` (browser) workers. +//! +//! Nothing algorithmic lives here. Each type is a thin wrapper that: +//! 1. parses a JSON config string through `bindings::config_json`, +//! so every tuning knob stays overridable from JS (no +//! hard-coded magic numbers); +//! 2. delegates to the owning numerical core types +//! (`PreprocessPipeline`, `FitPipeline`, `OwnedAviReader`, +//! `MutationQueue`). +//! +//! Error surface: parse / shape / pipeline failures are converted to +//! `JsValue` strings. Callers see `Promise` rejections with readable +//! messages rather than opaque WASM unreachable traps. +//! +//! The binding types are intentionally conservative: +//! - Float32Array in, Float32Array out (no serialized numeric data). +//! - Config is always a JSON string so there is a single source of +//! truth per tuning parameter — the `DEFAULT_*` constant in +//! `crate::config`. +//! - Asset-touching bindings expose opaque handles (`Fitter`, +//! `SnapshotHandle`, `MutationQueueHandle`) so JS cannot reach +//! into interior structure. + +use wasm_bindgen::prelude::*; + +use super::config_json::{ + parse_extend_config, parse_fit_config, parse_preprocess_config, parse_recording_metadata, + ConfigParseError, +}; +use crate::assets::{Footprints, Frame, FrameMut}; +use crate::config::GrayscaleMethod; +use crate::extending::mutation::{ + DeprecateReason, Epoch, MutationQueue, PipelineMutation, Snapshot, +}; +use crate::fitting::FitPipeline; +use crate::io::{decode_grayscale_f32, OwnedAviReader}; +use crate::preprocess::PreprocessPipeline; + +// ── Small error conversion helpers ───────────────────────────────── + +fn js_err(kind: &str, e: T) -> JsValue { + JsValue::from_str(&format!("cala-core {kind}: {e}")) +} + +fn config_err(e: ConfigParseError) -> JsValue { + JsValue::from_str(&e.to_string()) +} + +fn str_to_grayscale_method(s: &str) -> Result { + match s { + "Green" => Ok(GrayscaleMethod::Green), + "Luminance" => Ok(GrayscaleMethod::Luminance), + other => Err(js_err( + "grayscale", + format!("unknown GrayscaleMethod '{other}' (expected 'Green' or 'Luminance')"), + )), + } +} + +// ── Init ─────────────────────────────────────────────────────────── + +/// Install the console panic hook. Call once, early, from each +/// worker so `panic!` surfaces in the browser console instead of +/// appearing as a WASM trap. +#[wasm_bindgen] +pub fn init_panic_hook() { + console_error_panic_hook::set_once(); +} + +// ── AVI reader ───────────────────────────────────────────────────── + +/// Owning wrapper over `OwnedAviReader`. Parses the RIFF container +/// once in `new`, caches the frame index, then decodes individual +/// frames directly from the held buffer without re-walking the +/// container. Safe to construct from a `File.slice()` `ArrayBuffer` +/// handed across the JS ↔ WASM boundary. +#[wasm_bindgen] +pub struct AviReader { + inner: OwnedAviReader, +} + +#[wasm_bindgen] +impl AviReader { + /// Parse an AVI. `bytes` is copied into WASM memory once; frame + /// reads are zero-copy slices into that owned buffer. + #[wasm_bindgen(constructor)] + pub fn new(bytes: &[u8]) -> Result { + OwnedAviReader::new(bytes.to_vec()) + .map(|inner| AviReader { inner }) + .map_err(|e| js_err("avi", format!("{e:?}"))) + } + + #[wasm_bindgen(js_name = width)] + pub fn width(&self) -> u32 { + self.inner.width() + } + + #[wasm_bindgen(js_name = height)] + pub fn height(&self) -> u32 { + self.inner.height() + } + + #[wasm_bindgen(js_name = frameCount)] + pub fn frame_count(&self) -> u32 { + self.inner.frame_count() + } + + #[wasm_bindgen(js_name = fps)] + pub fn fps(&self) -> f32 { + self.inner.fps() + } + + #[wasm_bindgen(js_name = channels)] + pub fn channels(&self) -> u8 { + self.inner.channels() + } + + #[wasm_bindgen(js_name = bitDepth)] + pub fn bit_depth(&self) -> u16 { + self.inner.bit_depth() + } + + /// Decode one frame into a new `Float32Array`. + /// + /// `method` picks the 24-bit → grayscale reduction: + /// `"Green"` (default on miniscope raw) or `"Luminance"` (Rec.601). + /// Ignored for 8-bit streams. + #[wasm_bindgen(js_name = readFrameGrayscaleF32)] + pub fn read_frame_grayscale_f32(&self, n: u32, method: &str) -> Result, JsValue> { + let m = str_to_grayscale_method(method)?; + let pixels = self.inner.width() as usize * self.inner.height() as usize; + let mut out = vec![0.0f32; pixels]; + self.inner + .read_frame_grayscale_f32(n, &mut out, m) + .map_err(|e| js_err("avi", format!("{e:?}")))?; + Ok(out) + } +} + +// ── Preprocess ───────────────────────────────────────────────────── + +/// Owning wrapper over `PreprocessPipeline` (hot-pixel → [opt butter] +/// → [opt band] → motion → [opt denoise]). All knobs come from the +/// `cfg_json` string — see `PreprocessConfig`'s `serde` shape. +#[wasm_bindgen] +pub struct Preprocessor { + pipeline: PreprocessPipeline, + height: u32, + width: u32, +} + +#[wasm_bindgen] +impl Preprocessor { + /// Construct a preprocessor. + /// + /// - `height`, `width`: frame dimensions (must match all frames + /// pushed through `process_frame_*`). + /// - `metadata_json`: JSON matching `RecordingMetadata`'s serde + /// shape, e.g. `{"pixel_size_um":2.0}`. + /// - `cfg_json`: JSON matching `PreprocessConfig`'s serde shape; + /// `"{}"` applies every `DEFAULT_*` value. + #[wasm_bindgen(constructor)] + pub fn new( + height: u32, + width: u32, + metadata_json: &str, + cfg_json: &str, + ) -> Result { + let metadata = parse_recording_metadata(metadata_json).map_err(config_err)?; + let cfg = parse_preprocess_config(cfg_json).map_err(config_err)?; + let pipeline = PreprocessPipeline::new(height as usize, width as usize, &metadata, cfg); + Ok(Preprocessor { + pipeline, + height, + width, + }) + } + + /// Reset motion anchors. The next `process_frame_*` call behaves + /// as a first-frame (no global anchor contribution yet). + #[wasm_bindgen(js_name = reset)] + pub fn reset(&mut self) { + self.pipeline.reset(); + } + + /// Run one preprocess step on an `f32` grayscale frame + /// (`height × width`, row-major). Returns a new `Float32Array` + /// containing the cleaned frame. + #[wasm_bindgen(js_name = processFrameF32)] + pub fn process_frame_f32(&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 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 output_view = + FrameMut::new(&mut out, self.height as usize, self.width as usize) + .map_err(|e| js_err("preprocess", format!("output shape: {e:?}")))?; + self.pipeline + .process_frame(input_view, &mut output_view) + .map_err(|e| js_err("preprocess", format!("{e:?}")))?; + } + Ok(out) + } + + /// Convenience: decode raw AVI bytes to grayscale and preprocess + /// in one call. Avoids a round-trip across the JS boundary for + /// the intermediate f32 buffer. + #[wasm_bindgen(js_name = processFrameU8)] + pub fn process_frame_u8( + &mut self, + input: &[u8], + channels: u8, + method: &str, + ) -> Result, JsValue> { + let pixels = (self.height as usize) * (self.width as usize); + let m = str_to_grayscale_method(method)?; + let mut gray = vec![0.0f32; pixels]; + decode_grayscale_f32(input, pixels, channels, &mut gray, m) + .map_err(|e| js_err("preprocess", format!("decode: {e:?}")))?; + self.process_frame_f32(&gray) + } +} + +// ── Fit ──────────────────────────────────────────────────────────── + +/// Owning wrapper over `FitPipeline` — the per-frame OMF step. Starts +/// with an empty `Footprints` (`num_components() == 0`); the fit +/// worker grows the model by draining the `MutationQueueHandle`. +#[wasm_bindgen] +pub struct Fitter { + pipeline: FitPipeline, + height: u32, + width: u32, +} + +#[wasm_bindgen] +impl Fitter { + /// Construct a fitter for a fixed-shape frame stream. + /// + /// `cfg_json` parses against `FitConfig`'s serde shape. `"{}"` + /// means every `DEFAULT_*` value applies. + #[wasm_bindgen(constructor)] + pub fn new(height: u32, width: u32, cfg_json: &str) -> Result { + let cfg = parse_fit_config(cfg_json).map_err(config_err)?; + let footprints = Footprints::new(height as usize, width as usize); + let pipeline = FitPipeline::new(footprints, cfg); + Ok(Fitter { + pipeline, + height, + width, + }) + } + + /// Current asset epoch. Advances once per successful mutation + /// apply; not touched by per-frame `step` calls. + #[wasm_bindgen(js_name = epoch)] + pub fn epoch(&self) -> u64 { + self.pipeline.epoch() + } + + /// Number of live components in `Ã`. + #[wasm_bindgen(js_name = numComponents)] + pub fn num_components(&self) -> u32 { + self.pipeline.footprints().len() as u32 + } + + #[wasm_bindgen(js_name = height)] + pub fn height(&self) -> u32 { + self.height + } + + #[wasm_bindgen(js_name = width)] + pub fn width(&self) -> u32 { + self.width + } + + /// Run one OMF frame. Returns the residual `R_t` as a new + /// `Float32Array` so the extend worker can read it. + #[wasm_bindgen(js_name = step)] + pub fn step(&mut self, y: &[f32]) -> Result, JsValue> { + let pixels = (self.height as usize) * (self.width as usize); + if y.len() != pixels { + return Err(js_err( + "fit", + format!( + "frame length {} does not match height·width = {}", + y.len(), + pixels + ), + )); + } + Ok(self.pipeline.step(y).to_vec()) + } + + /// Latest trace vector `c_t` (length = `num_components()`), or an + /// empty `Float32Array` before the first `step()` has landed. + #[wasm_bindgen(js_name = lastTrace)] + pub fn last_trace(&self) -> Vec { + match self.pipeline.traces().last() { + Some(c) => c.to_vec(), + None => Vec::new(), + } + } + + /// 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 + /// metrics. + #[wasm_bindgen(js_name = drainApply)] + pub fn drain_apply(&mut self, queue: &mut MutationQueueHandle) -> Vec { + let report = self.pipeline.drain_apply(&mut queue.inner); + vec![report.applied, report.stale, report.invalid] + } + + /// 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. + #[wasm_bindgen(js_name = takeSnapshot)] + pub fn take_snapshot(&self) -> SnapshotHandle { + SnapshotHandle { + inner: self.pipeline.snapshot(), + } + } +} + +// ── Snapshot ─────────────────────────────────────────────────────── + +/// Opaque handle to a `Snapshot`. Only `epoch` is surfaced in Phase 5; +/// full extend-side access lands with the real extend worker. +#[wasm_bindgen] +pub struct SnapshotHandle { + inner: Snapshot, +} + +#[wasm_bindgen] +impl SnapshotHandle { + #[wasm_bindgen(js_name = epoch)] + pub fn epoch(&self) -> u64 { + self.inner.epoch + } + + #[wasm_bindgen(js_name = numComponents)] + pub fn num_components(&self) -> u32 { + self.inner.footprints.len() as u32 + } + + #[wasm_bindgen(js_name = pixels)] + pub fn pixels(&self) -> u32 { + self.inner.footprints.pixels() as u32 + } +} + +// ── Mutation queue ───────────────────────────────────────────────── + +/// Opaque handle to a `MutationQueue`. Extend pushes; fit drains via +/// `Fitter::drain_apply`. Construction reads `mutation_queue_capacity` +/// from `ExtendConfig`'s JSON (default 32 per design §7.3). +#[wasm_bindgen] +pub struct MutationQueueHandle { + inner: MutationQueue, +} + +#[wasm_bindgen] +impl MutationQueueHandle { + /// Construct a queue whose capacity comes from `extend_cfg_json`'s + /// `mutation_queue_capacity` field. JS callers pass the same JSON + /// used to build the `ExtendConfig` — single source of truth. + #[wasm_bindgen(constructor)] + pub fn new(extend_cfg_json: &str) -> Result { + let cfg = parse_extend_config(extend_cfg_json).map_err(config_err)?; + Ok(MutationQueueHandle { + inner: MutationQueue::new(cfg.mutation_queue_capacity), + }) + } + + #[wasm_bindgen(js_name = capacity)] + pub fn capacity(&self) -> u32 { + self.inner.capacity() as u32 + } + + #[wasm_bindgen(js_name = len)] + pub fn len(&self) -> u32 { + self.inner.len() as u32 + } + + #[wasm_bindgen(js_name = isEmpty)] + pub fn is_empty(&self) -> bool { + self.inner.is_empty() + } + + #[wasm_bindgen(js_name = isFull)] + pub fn is_full(&self) -> bool { + self.inner.is_full() + } + + #[wasm_bindgen(js_name = drops)] + pub fn drops(&self) -> u64 { + self.inner.drops() + } + + /// Enqueue a deprecate mutation. Phase 5 exposes deprecate as the + /// minimal push surface — register / merge pushes light up in + /// Phase 7 when extend actually generates them. `reason` takes + /// the serde-variant string (`"FootprintCollapsed"`, etc). + #[wasm_bindgen(js_name = pushDeprecate)] + pub fn push_deprecate( + &mut self, + snapshot_epoch: u64, + id: u32, + reason: &str, + ) -> Result<(), JsValue> { + let reason = match reason { + "FootprintCollapsed" => DeprecateReason::FootprintCollapsed, + "TraceInactive" => DeprecateReason::TraceInactive, + "MergedInto" => DeprecateReason::MergedInto, + "InvalidApply" => DeprecateReason::InvalidApply, + other => { + return Err(js_err( + "mutation", + format!("unknown DeprecateReason '{other}'"), + )) + } + }; + self.inner.push(PipelineMutation::Deprecate { + snapshot_epoch: snapshot_epoch as Epoch, + id, + reason, + }); + Ok(()) + } +} diff --git a/crates/cala-core/src/config.rs b/crates/cala-core/src/config.rs index 07fcc7d..d848121 100644 --- a/crates/cala-core/src/config.rs +++ b/crates/cala-core/src/config.rs @@ -77,6 +77,7 @@ pub const DEFAULT_DENOISE_MEDIAN_KSIZE: usize = 1; /// is the pragmatic default. `Luminance` is there for recordings that /// carry meaningful information across all three channels. #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum GrayscaleMethod { /// Take the green channel as the grayscale value. Single-channel /// (already grayscale) inputs are passed through unchanged. @@ -101,6 +102,7 @@ pub const DEFAULT_GRAYSCALE_METHOD: GrayscaleMethod = GrayscaleMethod::Green; /// sharper peaks on clean signals but breaks down when most bins /// carry only noise — it amplifies that noise. Kept for back-compat. #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum MotionCorrelation { /// FFT cross-correlation: `F · conj(G)`. Peak stays dominated by /// real coherent structure, works on diffuse miniscope data. @@ -127,6 +129,7 @@ pub const DEFAULT_MOTION_CORRELATION: MotionCorrelation = MotionCorrelation::Cro /// on each axis. Tighter when the peak is sharp and Gaussian-shaped, /// but over-trusts the two immediate neighbors when they carry noise. #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum MotionSubpixel { Centroid, Parabolic, @@ -146,14 +149,21 @@ pub const DEFAULT_MOTION_SUBPIXEL_RADIUS: usize = 2; /// Required: `pixel_size_um`. Every other field has a documented default /// that can be overridden with `with_*` builder methods. #[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct RecordingMetadata { /// Physical size of one image pixel in micrometers. pub pixel_size_um: f32, /// Typical neuron cell-body diameter in micrometers. Used for /// downstream cutoff derivations. + #[cfg_attr(feature = "serde", serde(default = "default_neuron_diameter_um"))] pub neuron_diameter_um: f32, } +#[cfg(feature = "serde")] +fn default_neuron_diameter_um() -> f32 { + DEFAULT_NEURON_DIAMETER_UM +} + impl RecordingMetadata { /// Construct metadata with the given pixel size and the default /// neuron diameter (`DEFAULT_NEURON_DIAMETER_UM`). Override the @@ -175,6 +185,8 @@ impl RecordingMetadata { /// overridable; `PreprocessConfig::default()` reads each field's value /// from its `DEFAULT_*` constant so defaults stay in one place. #[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(default))] pub struct PreprocessConfig { /// Butterworth high-pass cutoff period, as a multiple of the neuron /// diameter in pixels. See `high_pass_cutoff_cycles_per_pixel` for @@ -326,6 +338,8 @@ pub const DEFAULT_SNR_C0: f32 = 0.0; /// Per-frame tuning for the OMF fit loop. #[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(default))] pub struct FitConfig { /// Relative tolerance for `EvaluateTraces` BCD convergence. pub trace_tol: f32, @@ -381,6 +395,7 @@ impl FitConfig { /// (design §3.1). Phase 2 footprints are implicitly `Cell` — the class /// 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))] pub enum ComponentClass { /// Localized, compact, cell-scale footprint with fast transients. Cell, @@ -495,6 +510,8 @@ pub const DEFAULT_PROPOSALS_PER_CYCLE_MAX: u32 = 4; /// `DEFAULT_*` constant via `ExtendConfig::default()`; algorithm code /// never reads the constants directly. #[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(default))] pub struct ExtendConfig { /// Number of recent residual frames retained for extend search. pub extend_window_frames: u32, diff --git a/crates/cala-core/src/io/avi_uncompressed.rs b/crates/cala-core/src/io/avi_uncompressed.rs index 34bbbf7..c06401b 100644 --- a/crates/cala-core/src/io/avi_uncompressed.rs +++ b/crates/cala-core/src/io/avi_uncompressed.rs @@ -239,33 +239,185 @@ impl<'a> AviUncompressedReader<'a> { method: GrayscaleMethod, ) -> Result<(), AviError> { let pixels = (self.width as usize) * (self.height as usize); - if output.len() != pixels { - return Err(AviError::OutputLengthMismatch { - expected: pixels, - actual: output.len(), - }); - } let bytes = self.frame_bytes(n)?; - match self.channels { - 1 => { - for (i, &b) in bytes.iter().enumerate() { - output[i] = b as f32; - } + decode_grayscale_f32(bytes, pixels, self.channels, output, method) + } + + /// Byte offsets of each frame's pixel data. Exposed so owning + /// wrappers (see `OwnedAviReader`) can cache the index without + /// re-parsing the container on every frame read. + pub fn frame_offsets(&self) -> &[usize] { + &self.frame_offsets + } + + /// Size in bytes of one frame's pixel block. + pub fn frame_byte_size(&self) -> usize { + self.frame_byte_size + } +} + +/// Shared decode helper used by both the borrowed and owning readers. +/// Writes `output` (length = `pixels`) from the raw frame bytes, +/// reducing 24-bit color to grayscale via `method`. Rejects any +/// channel count other than 1 or 3. +pub(crate) fn decode_grayscale_f32( + bytes: &[u8], + pixels: usize, + channels: u8, + output: &mut [f32], + method: GrayscaleMethod, +) -> Result<(), AviError> { + if output.len() != pixels { + return Err(AviError::OutputLengthMismatch { + expected: pixels, + actual: output.len(), + }); + } + match channels { + 1 => { + if bytes.len() < pixels { + return Err(AviError::Truncated("frame data")); } - 3 => { - for i in 0..pixels { - let b = bytes[i * 3] as f32; - let g = bytes[i * 3 + 1] as f32; - let r = bytes[i * 3 + 2] as f32; - output[i] = match method { - GrayscaleMethod::Green => g, - GrayscaleMethod::Luminance => 0.299 * r + 0.587 * g + 0.114 * b, - }; - } + for (i, &b) in bytes[..pixels].iter().enumerate() { + output[i] = b as f32; } - _ => return Err(AviError::Unsupported("channel count")), } - Ok(()) + 3 => { + if bytes.len() < pixels * 3 { + return Err(AviError::Truncated("frame data")); + } + for i in 0..pixels { + let b = bytes[i * 3] as f32; + let g = bytes[i * 3 + 1] as f32; + let r = bytes[i * 3 + 2] as f32; + output[i] = match method { + GrayscaleMethod::Green => g, + GrayscaleMethod::Luminance => 0.299 * r + 0.587 * g + 0.114 * b, + }; + } + } + _ => return Err(AviError::Unsupported("channel count")), + } + Ok(()) +} + +/// Owning counterpart to `AviUncompressedReader` for callers that +/// need to hold the AVI bytes across the WASM / PyO3 boundary. Parses +/// the RIFF container once on construction, caches the frame index, +/// then decodes frames directly from the owned buffer without +/// re-walking the container. +/// +/// This type carries no `'a` lifetime — it owns `Vec` internally — +/// so it can be stored inside a `#[wasm_bindgen]` struct or returned +/// from a PyO3 extension function without lifetime friction. +#[derive(Debug, Clone)] +pub struct OwnedAviReader { + bytes: Vec, + width: u32, + height: u32, + frame_count: u32, + micro_sec_per_frame: u32, + bit_depth: u16, + channels: u8, + frame_byte_size: usize, + frame_offsets: Vec, +} + +impl OwnedAviReader { + /// Parse an AVI from the given owned byte buffer. Walks the RIFF + /// container once and caches the frame offset index. + pub fn new(bytes: Vec) -> Result { + let ( + width, + height, + frame_count, + micro_sec_per_frame, + bit_depth, + channels, + frame_byte_size, + frame_offsets, + ) = { + let reader = AviUncompressedReader::new(&bytes)?; + ( + reader.width(), + reader.height(), + reader.frame_count(), + reader.micro_sec_per_frame, + reader.bit_depth(), + reader.channels(), + reader.frame_byte_size(), + reader.frame_offsets().to_vec(), + ) + }; + Ok(Self { + bytes, + width, + height, + frame_count, + micro_sec_per_frame, + bit_depth, + channels, + frame_byte_size, + frame_offsets, + }) + } + + pub fn width(&self) -> u32 { + self.width + } + + pub fn height(&self) -> u32 { + self.height + } + + pub fn frame_count(&self) -> u32 { + self.frame_count + } + + pub fn fps(&self) -> f32 { + if self.micro_sec_per_frame == 0 { + 0.0 + } else { + 1_000_000.0 / self.micro_sec_per_frame as f32 + } + } + + pub fn bit_depth(&self) -> u16 { + self.bit_depth + } + + pub fn channels(&self) -> u8 { + self.channels + } + + /// Raw pixel bytes for frame `n`. Aliases the owned buffer — no + /// allocation. + pub fn frame_bytes(&self, n: u32) -> Result<&[u8], AviError> { + if n >= self.frame_count { + return Err(AviError::FrameOutOfRange(n)); + } + let offset = self.frame_offsets[n as usize]; + let end = offset + .checked_add(self.frame_byte_size) + .ok_or(AviError::Truncated("frame end"))?; + if end > self.bytes.len() { + return Err(AviError::Truncated("frame data")); + } + Ok(&self.bytes[offset..end]) + } + + /// Decode frame `n` into an `f32` grayscale buffer. Shares the + /// exact decode path with `AviUncompressedReader`, so bytes-in / + /// pixels-out parity is guaranteed. + pub fn read_frame_grayscale_f32( + &self, + n: u32, + output: &mut [f32], + method: GrayscaleMethod, + ) -> Result<(), AviError> { + let pixels = (self.width as usize) * (self.height as usize); + let bytes = self.frame_bytes(n)?; + decode_grayscale_f32(bytes, pixels, self.channels, output, method) } } diff --git a/crates/cala-core/src/io/mod.rs b/crates/cala-core/src/io/mod.rs index 941371e..83a75bf 100644 --- a/crates/cala-core/src/io/mod.rs +++ b/crates/cala-core/src/io/mod.rs @@ -8,5 +8,6 @@ mod avi_uncompressed; mod avi_writer; -pub use avi_uncompressed::{AviError, AviUncompressedReader}; +pub(crate) use avi_uncompressed::decode_grayscale_f32; +pub use avi_uncompressed::{AviError, AviUncompressedReader, OwnedAviReader}; pub use avi_writer::write_uncompressed_avi_8bit; diff --git a/crates/cala-core/src/lib.rs b/crates/cala-core/src/lib.rs index e3f9aef..94e1a4f 100644 --- a/crates/cala-core/src/lib.rs +++ b/crates/cala-core/src/lib.rs @@ -7,6 +7,7 @@ #![deny(unsafe_op_in_unsafe_fn)] pub mod assets; +pub mod bindings; pub mod buffers; pub mod config; pub mod extending; diff --git a/crates/cala-core/tests/bindings_config_json.rs b/crates/cala-core/tests/bindings_config_json.rs new file mode 100644 index 0000000..6379fa3 --- /dev/null +++ b/crates/cala-core/tests/bindings_config_json.rs @@ -0,0 +1,124 @@ +//! Tests for the JSON config surface used by the WASM / PyO3 bindings +//! (design §4.1, test-first). These exercise the shape and override +//! semantics of every config struct that crosses the binding boundary +//! so that JS / Python can trust "only specify what I want to change". +//! +//! The binding wrappers (`bindings/wasm.rs`, PyO3 equivalent) forward +//! their JSON strings through the `bindings::config_json` helpers — +//! fixing any defect here catches regressions at both targets at once. + +use calab_cala_core::bindings::config_json::{ + parse_extend_config, parse_fit_config, parse_preprocess_config, parse_recording_metadata, +}; +use calab_cala_core::config::{ + ExtendConfig, FitConfig, GrayscaleMethod, MotionCorrelation, MotionSubpixel, PreprocessConfig, + RecordingMetadata, +}; + +#[test] +fn empty_preprocess_json_returns_defaults() { + // `{}` should decode to the same value as `PreprocessConfig::default()`. + // That's the contract the binding layer depends on: JS can send + // `JSON.stringify({})` and get defaults. + let parsed = parse_preprocess_config("{}").expect("empty JSON must parse"); + assert_eq!(parsed, PreprocessConfig::default()); +} + +#[test] +fn preprocess_override_only_touches_named_fields() { + let parsed = parse_preprocess_config(r#"{"high_pass_enabled":true,"motion_max_shift_px":48}"#) + .expect("override JSON must parse"); + let defaults = PreprocessConfig::default(); + // Overridden fields reflect the JSON. + assert!(parsed.high_pass_enabled); + assert_eq!(parsed.motion_max_shift_px, 48); + // Untouched fields retain defaults — no hidden drift. + assert_eq!(parsed.band_enabled, defaults.band_enabled); + assert_eq!(parsed.motion_corr_crop_frac, defaults.motion_corr_crop_frac); + assert_eq!(parsed.motion_correlation, defaults.motion_correlation); +} + +#[test] +fn preprocess_enums_round_trip_as_tagged_strings() { + // Serde's default enum tagging for unit variants is `"Variant"` — + // verify that so JS can send `{"motion_correlation":"Phase"}`. + let parsed = + parse_preprocess_config(r#"{"motion_correlation":"Phase","motion_subpixel":"Parabolic"}"#) + .expect("enum JSON must parse"); + assert_eq!(parsed.motion_correlation, MotionCorrelation::Phase); + assert_eq!(parsed.motion_subpixel, MotionSubpixel::Parabolic); +} + +#[test] +fn preprocess_round_trip_preserves_full_config() { + let original = PreprocessConfig::default() + .with_high_pass_enabled(true) + .with_band_enabled(true) + .with_motion_corr_crop_frac(0.8) + .with_motion_subpixel_radius(3); + let json = serde_json::to_string(&original).unwrap(); + let round_trip = parse_preprocess_config(&json).unwrap(); + assert_eq!(round_trip, original); +} + +#[test] +fn fit_json_defaults_and_override() { + let empty = parse_fit_config("{}").unwrap(); + assert_eq!(empty, FitConfig::default()); + + let parsed = parse_fit_config(r#"{"trace_max_iter":40,"snr_c0":0.5}"#).unwrap(); + assert_eq!(parsed.trace_max_iter, 40); + assert!((parsed.snr_c0 - 0.5).abs() < 1e-7); + assert_eq!(parsed.trace_tol, FitConfig::default().trace_tol); +} + +#[test] +fn extend_json_defaults_and_override() { + let empty = parse_extend_config("{}").unwrap(); + assert_eq!(empty, ExtendConfig::default()); + + let parsed = parse_extend_config( + r#"{"mutation_queue_capacity":64,"proposals_per_cycle_max":2,"trace_corr_min":0.9}"#, + ) + .unwrap(); + assert_eq!(parsed.mutation_queue_capacity, 64); + assert_eq!(parsed.proposals_per_cycle_max, 2); + assert!((parsed.trace_corr_min - 0.9).abs() < 1e-7); +} + +#[test] +fn recording_metadata_requires_pixel_size() { + // `pixel_size_um` has no sensible default — omitting it must fail + // rather than silently default to zero. + let err = parse_recording_metadata("{}").unwrap_err(); + assert_eq!(err.kind, "recording"); + // Parsing an explicit value succeeds; neuron diameter falls back + // to DEFAULT_NEURON_DIAMETER_UM when omitted. + let parsed = parse_recording_metadata(r#"{"pixel_size_um":2.0}"#).unwrap(); + assert!((parsed.pixel_size_um - 2.0).abs() < 1e-7); + assert_eq!( + parsed.neuron_diameter_um, + RecordingMetadata::new(2.0).neuron_diameter_um + ); +} + +#[test] +fn malformed_json_returns_error_tagged_with_config_kind() { + let err = parse_preprocess_config("not-json").unwrap_err(); + assert_eq!(err.kind, "preprocess"); + assert!( + !err.message.is_empty(), + "error message must carry serde's diagnostic" + ); +} + +#[test] +fn grayscale_method_round_trips_for_avi_reader() { + // `GrayscaleMethod` flows through the WASM AviReader binding — keep + // its serialized shape stable so JS can pass `"Green"` / `"Luminance"`. + for m in [GrayscaleMethod::Green, GrayscaleMethod::Luminance] { + let json = serde_json::to_string(&m).unwrap(); + let back: GrayscaleMethod = serde_json::from_str(&json).unwrap(); + assert_eq!(m, back); + } +} diff --git a/crates/cala-core/tests/io_avi_uncompressed.rs b/crates/cala-core/tests/io_avi_uncompressed.rs index 1c8886f..015c34d 100644 --- a/crates/cala-core/tests/io_avi_uncompressed.rs +++ b/crates/cala-core/tests/io_avi_uncompressed.rs @@ -5,7 +5,7 @@ //! specific metadata and decoded samples. No external sample files. use calab_cala_core::config::GrayscaleMethod; -use calab_cala_core::io::{AviError, AviUncompressedReader}; +use calab_cala_core::io::{AviError, AviUncompressedReader, OwnedAviReader}; // ---- Synthetic AVI builder ---- // @@ -363,3 +363,148 @@ fn truncated_buffer_errors() { AviError::Truncated(_) | AviError::BadHeader(_) | AviError::NotAvi )); } + +// ---- OwnedAviReader parity tests ---- +// +// The owning reader is what the WASM binding holds (JS can't hand us +// a borrowed slice across the boundary). Its decode path must match +// the borrowed reader byte-for-byte or the WASM app would diverge +// from the Rust native path silently. + +#[test] +fn owned_reader_metadata_matches_borrowed_reader() { + let opts = AviOpts { + width: 3, + height: 2, + fps: 20, + bit_depth: 8, + include_idx: false, + }; + let frames = vec![ + vec![0u8, 10, 20, 30, 40, 50], + vec![60u8, 70, 80, 90, 100, 110], + ]; + let bytes = build_avi(&opts, &frames); + + // Capture the borrowed reader's metadata, drop it, then move the + // byte buffer into the owning reader. + let (b_w, b_h, b_count, b_depth, b_channels, b_fps) = { + let borrowed = AviUncompressedReader::new(&bytes).unwrap(); + ( + borrowed.width(), + borrowed.height(), + borrowed.frame_count(), + borrowed.bit_depth(), + borrowed.channels(), + borrowed.fps(), + ) + }; + let owned = OwnedAviReader::new(bytes).unwrap(); + assert_eq!(owned.width(), b_w); + assert_eq!(owned.height(), b_h); + assert_eq!(owned.frame_count(), b_count); + assert_eq!(owned.bit_depth(), b_depth); + assert_eq!(owned.channels(), b_channels); + assert!((owned.fps() - b_fps).abs() < 1e-3); +} + +#[test] +fn owned_reader_frame_bytes_match_borrowed_reader() { + let opts = AviOpts { + width: 4, + height: 3, + fps: 30, + bit_depth: 24, + include_idx: true, + }; + // 4×3 = 12 px × 3 channels = 36 bytes per frame. + let frames = vec![ + (0u8..36u8).collect::>(), + (36u8..72u8).collect::>(), + (100u8..136u8).collect::>(), + ]; + let bytes = build_avi(&opts, &frames); + + let expected_frames: Vec> = { + let borrowed = AviUncompressedReader::new(&bytes).unwrap(); + (0..borrowed.frame_count()) + .map(|n| borrowed.frame_bytes(n).unwrap().to_vec()) + .collect() + }; + let owned = OwnedAviReader::new(bytes).unwrap(); + for (n, expected) in expected_frames.iter().enumerate() { + let b = owned.frame_bytes(n as u32).unwrap(); + assert_eq!(b, &expected[..], "frame {n} raw bytes must match"); + } +} + +#[test] +fn owned_reader_grayscale_decode_matches_borrowed() { + let opts = AviOpts { + width: 2, + height: 2, + fps: 30, + bit_depth: 24, + include_idx: false, + }; + // Two BGR frames with known R/G/B values. + let frames = vec![ + vec![10u8, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120], + vec![5u8, 15, 25, 35, 45, 55, 65, 75, 85, 95, 105, 115], + ]; + let bytes = build_avi(&opts, &frames); + + let pixels = 4; + // Compute borrowed-reader outputs up front, then move the buffer + // into the owning reader — this sidesteps the borrow vs. move + // conflict without cloning. + let mut expected: Vec<(u32, GrayscaleMethod, Vec)> = Vec::new(); + { + let borrowed = AviUncompressedReader::new(&bytes).unwrap(); + for method in [GrayscaleMethod::Green, GrayscaleMethod::Luminance] { + for n in 0..2u32 { + let mut buf = vec![0.0f32; pixels]; + borrowed + .read_frame_grayscale_f32(n, &mut buf, method) + .unwrap(); + expected.push((n, method, buf)); + } + } + } + let owned = OwnedAviReader::new(bytes).unwrap(); + for (n, method, expected_px) in expected { + let mut got = vec![0.0f32; pixels]; + owned.read_frame_grayscale_f32(n, &mut got, method).unwrap(); + assert_eq!( + got, expected_px, + "frame {n} method {method:?} must decode identically" + ); + } +} + +#[test] +fn owned_reader_rejects_out_of_range_frame() { + let opts = AviOpts { + width: 2, + height: 2, + fps: 30, + bit_depth: 8, + include_idx: false, + }; + let frames = vec![vec![1u8, 2, 3, 4]]; + let bytes = build_avi(&opts, &frames); + let owned = OwnedAviReader::new(bytes).unwrap(); + match owned.frame_bytes(5) { + Err(AviError::FrameOutOfRange(n)) => assert_eq!(n, 5), + other => panic!("expected FrameOutOfRange, got {other:?}"), + } +} + +#[test] +fn owned_reader_surfaces_parse_errors() { + // A buffer that is obviously not an AVI — owned reader should + // bubble up the same error the borrowed reader does. + let bytes = b"NOT_AN_AVI_FILE".to_vec(); + let err = OwnedAviReader::new(bytes).unwrap_err(); + assert_eq!(err, AviError::NotAvi); +} From 5a6bae6227c78807e5e63abaea921d1ea8f6c729 Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 18 Apr 2026 10:46:44 -0700 Subject: [PATCH 02/17] feat(cala-runtime): scaffold package + SAB ring channel (task 15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 runtime package — per CALA_DESIGN §7 — lands its first piece: the SAB-backed single-producer/single-consumer ring channel used for frame data between the decoder, fit, and extend workers. - Package scaffolding: package.json, tsconfig.json, vitest.config.ts, README, barrel index.ts (follows packages/compute + packages/io structure). - `src/channel.ts`: SabRingChannel with writeSlot / readSlot / tryWrite / waitRead / stats. Every tuning knob (slot bytes, slot count, timeouts) is a ChannelConfig field — no literals in the channel hot path. - Tests written first per §4.1: FIFO order, ring wrap, tryWrite backpressure, byte-level payload parity, config validation. - Mutation queue / snapshot / event-bus / orchestrator modules left as TODO stubs documenting the full §7 surface; they land in tasks 16–18. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cala-runtime/README.md | 22 ++ packages/cala-runtime/package.json | 15 ++ .../src/__tests__/channel.test.ts | 244 +++++++++++++++++ packages/cala-runtime/src/channel.ts | 245 ++++++++++++++++++ packages/cala-runtime/src/index.ts | 4 + packages/cala-runtime/src/types.ts | 48 ++++ packages/cala-runtime/tsconfig.json | 18 ++ packages/cala-runtime/vitest.config.ts | 14 + 8 files changed, 610 insertions(+) create mode 100644 packages/cala-runtime/README.md create mode 100644 packages/cala-runtime/package.json create mode 100644 packages/cala-runtime/src/__tests__/channel.test.ts create mode 100644 packages/cala-runtime/src/channel.ts create mode 100644 packages/cala-runtime/src/index.ts create mode 100644 packages/cala-runtime/src/types.ts create mode 100644 packages/cala-runtime/tsconfig.json create mode 100644 packages/cala-runtime/vitest.config.ts diff --git a/packages/cala-runtime/README.md b/packages/cala-runtime/README.md new file mode 100644 index 0000000..be67cf0 --- /dev/null +++ b/packages/cala-runtime/README.md @@ -0,0 +1,22 @@ +# @calab/cala-runtime + +Browser-side orchestration primitives for the CaLa streaming demixing +pipeline. Workers (decoder, fit, extend, archive) import channel and +protocol types from here; numerics live in `@calab/core` (and the +`cala-core` WASM build). + +Reference: `.planning/CALA_DESIGN.md §7` — worker topology, channel +design, mutation queue protocol, asset snapshot protocol. + +## Module map + +- `channel.ts` — SAB-backed single-producer/single-consumer ring for + frame data (decoder → fit, fit → extend). [landed, task 15] +- `mutation-queue.ts` — bounded drop-oldest ring (extend → fit). + [later task 16] +- `asset-snapshot.ts` — copy-on-write snapshot of `A, W, M` at an + epoch boundary. [later task 17] +- `events.ts` — event bus consumed by the archive worker. + [later task 17] +- `orchestrator.ts` — spawns workers, wires channels, tracks epochs, + owns two-pass toggle. [later task 18] diff --git a/packages/cala-runtime/package.json b/packages/cala-runtime/package.json new file mode 100644 index 0000000..c06f2e3 --- /dev/null +++ b/packages/cala-runtime/package.json @@ -0,0 +1,15 @@ +{ + "name": "@calab/cala-runtime", + "private": true, + "version": "0.0.1", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@calab/core": "*" + } +} diff --git a/packages/cala-runtime/src/__tests__/channel.test.ts b/packages/cala-runtime/src/__tests__/channel.test.ts new file mode 100644 index 0000000..57ceac5 --- /dev/null +++ b/packages/cala-runtime/src/__tests__/channel.test.ts @@ -0,0 +1,244 @@ +import { describe, it, expect } from 'vitest'; +import { SabRingChannel, ChannelTimeoutError } from '../channel.ts'; +import type { ChannelConfig } from '../types.ts'; + +const BASE_CFG: ChannelConfig = { + slotBytes: 64, + slotCount: 4, + waitTimeoutMs: 50, + pollIntervalMs: 1, +}; + +function makePayload(size: number, seed: number): Uint8Array { + const buf = new Uint8Array(size); + for (let i = 0; i < size; i++) { + buf[i] = (seed + i) & 0xff; + } + return buf; +} + +describe('SabRingChannel config validation', () => { + it('rejects non-positive slotBytes', () => { + expect(() => new SabRingChannel({ ...BASE_CFG, slotBytes: 0 })).toThrow(/slotBytes/); + expect(() => new SabRingChannel({ ...BASE_CFG, slotBytes: -8 })).toThrow(/slotBytes/); + }); + + it('rejects non-positive slotCount', () => { + expect(() => new SabRingChannel({ ...BASE_CFG, slotCount: 0 })).toThrow(/slotCount/); + expect(() => new SabRingChannel({ ...BASE_CFG, slotCount: -1 })).toThrow(/slotCount/); + }); + + it('rejects non-integer sizes', () => { + expect(() => new SabRingChannel({ ...BASE_CFG, slotBytes: 3.5 })).toThrow(/slotBytes/); + expect(() => new SabRingChannel({ ...BASE_CFG, slotCount: 2.2 })).toThrow(/slotCount/); + }); + + it('rejects negative waitTimeoutMs', () => { + expect(() => new SabRingChannel({ ...BASE_CFG, waitTimeoutMs: -1 })).toThrow(/waitTimeoutMs/); + }); + + it('rejects non-positive pollIntervalMs', () => { + expect(() => new SabRingChannel({ ...BASE_CFG, pollIntervalMs: 0 })).toThrow(/pollIntervalMs/); + }); +}); + +describe('SabRingChannel writeSlot + readSlot FIFO', () => { + it('reads frames back in write order with matching epochs', () => { + const ch = new SabRingChannel(BASE_CFG); + const frames = [ + { data: makePayload(16, 1), epoch: 10n }, + { data: makePayload(24, 50), epoch: 11n }, + { data: makePayload(8, 100), epoch: 12n }, + ]; + for (const f of frames) ch.writeSlot(f.data, f.epoch); + + for (const expected of frames) { + const got = ch.readSlot(); + expect(got).not.toBeNull(); + expect(got!.epoch).toBe(expected.epoch); + expect(got!.data.length).toBe(expected.data.length); + expect(Array.from(got!.data)).toEqual(Array.from(expected.data)); + } + expect(ch.readSlot()).toBeNull(); + }); + + it('rejects payloads larger than slotBytes', () => { + const ch = new SabRingChannel({ ...BASE_CFG, slotBytes: 32 }); + expect(() => ch.writeSlot(makePayload(33, 0), 1n)).toThrow(/exceeds slotBytes/); + }); + + it('supports Float32Array payloads with byte-level parity', () => { + const ch = new SabRingChannel({ ...BASE_CFG, slotBytes: 64 }); + const f32 = new Float32Array([1.5, -2.25, 3.125, 0.5]); + ch.writeSlot(f32, 42n); + + const got = ch.readSlot(); + expect(got).not.toBeNull(); + expect(got!.epoch).toBe(42n); + const roundTrip = new Float32Array( + got!.data.buffer, + got!.data.byteOffset, + got!.data.byteLength / 4, + ); + expect(Array.from(roundTrip)).toEqual(Array.from(f32)); + }); +}); + +describe('SabRingChannel ring wrap', () => { + it('wraps correctly past slotCount boundary', () => { + const cfg: ChannelConfig = { ...BASE_CFG, slotCount: 3, slotBytes: 16 }; + const ch = new SabRingChannel(cfg); + + // Write + read enough to cross the ring boundary multiple times. + const totalFrames = cfg.slotCount * 4 + 1; + for (let i = 0; i < totalFrames; i++) { + ch.writeSlot(makePayload(16, i), BigInt(i)); + const got = ch.readSlot(); + expect(got).not.toBeNull(); + expect(got!.epoch).toBe(BigInt(i)); + expect(Array.from(got!.data)).toEqual(Array.from(makePayload(16, i))); + } + expect(ch.readSlot()).toBeNull(); + }); + + it('preserves FIFO order across a full-fill wrap', () => { + const cfg: ChannelConfig = { ...BASE_CFG, slotCount: 3, slotBytes: 8 }; + const ch = new SabRingChannel(cfg); + + // Fill, drain, refill — exercises indices crossing slotCount. + for (let round = 0; round < 4; round++) { + for (let i = 0; i < cfg.slotCount; i++) { + const seed = round * 100 + i; + ch.writeSlot(makePayload(8, seed), BigInt(seed)); + } + for (let i = 0; i < cfg.slotCount; i++) { + const seed = round * 100 + i; + const got = ch.readSlot(); + expect(got!.epoch).toBe(BigInt(seed)); + expect(Array.from(got!.data)).toEqual(Array.from(makePayload(8, seed))); + } + } + }); +}); + +describe('SabRingChannel tryWrite backpressure', () => { + it('returns false when ring is full and does NOT increment dropCount', () => { + const cfg: ChannelConfig = { ...BASE_CFG, slotCount: 2, slotBytes: 16 }; + const ch = new SabRingChannel(cfg); + + expect(ch.tryWrite(makePayload(8, 1), 1n)).toBe(true); + expect(ch.tryWrite(makePayload(8, 2), 2n)).toBe(true); + // Ring is full — third write must fail. + expect(ch.tryWrite(makePayload(8, 3), 3n)).toBe(false); + + const stats = ch.stats(); + expect(stats.framesWritten).toBe(2); + // The channel does NOT drop frames on backpressure — mutation queue does. + expect(stats.dropCount).toBe(0); + expect(stats.inFlight).toBe(2); + expect(stats.capacity).toBe(cfg.slotCount); + }); + + it('allows writes again after consumer drains', () => { + const cfg: ChannelConfig = { ...BASE_CFG, slotCount: 2, slotBytes: 16 }; + const ch = new SabRingChannel(cfg); + + ch.writeSlot(makePayload(8, 1), 1n); + ch.writeSlot(makePayload(8, 2), 2n); + expect(ch.tryWrite(makePayload(8, 3), 3n)).toBe(false); + + ch.readSlot(); + expect(ch.tryWrite(makePayload(8, 3), 3n)).toBe(true); + }); +}); + +describe('SabRingChannel writeSlot blocking semantics', () => { + it('throws ChannelTimeoutError when ring stays full past waitTimeoutMs', () => { + const cfg: ChannelConfig = { + ...BASE_CFG, + slotCount: 2, + slotBytes: 16, + waitTimeoutMs: 10, + pollIntervalMs: 1, + }; + const ch = new SabRingChannel(cfg); + + ch.writeSlot(makePayload(8, 1), 1n); + ch.writeSlot(makePayload(8, 2), 2n); + + const start = Date.now(); + expect(() => ch.writeSlot(makePayload(8, 3), 3n)).toThrow(ChannelTimeoutError); + const elapsed = Date.now() - start; + // Should have waited at least the configured timeout. + expect(elapsed).toBeGreaterThanOrEqual(cfg.waitTimeoutMs - 2); + }); + + it('waitRead throws ChannelTimeoutError when ring stays empty', () => { + const cfg: ChannelConfig = { ...BASE_CFG, waitTimeoutMs: 10, pollIntervalMs: 1 }; + const ch = new SabRingChannel(cfg); + + const start = Date.now(); + expect(() => ch.waitRead()).toThrow(ChannelTimeoutError); + const elapsed = Date.now() - start; + expect(elapsed).toBeGreaterThanOrEqual(cfg.waitTimeoutMs - 2); + }); + + it('waitRead returns immediately when a slot is available', () => { + const ch = new SabRingChannel(BASE_CFG); + ch.writeSlot(makePayload(16, 7), 77n); + const got = ch.waitRead(); + expect(got.epoch).toBe(77n); + expect(Array.from(got.data)).toEqual(Array.from(makePayload(16, 7))); + }); +}); + +describe('SabRingChannel byte-level payload parity', () => { + it('written payload bytes are byte-identical to read bytes for every slot in a full fill', () => { + const cfg: ChannelConfig = { ...BASE_CFG, slotCount: 8, slotBytes: 256 }; + const ch = new SabRingChannel(cfg); + + const payloads: Uint8Array[] = []; + for (let i = 0; i < cfg.slotCount; i++) { + const p = new Uint8Array(cfg.slotBytes); + for (let j = 0; j < cfg.slotBytes; j++) { + p[j] = (i * 31 + j * 7) & 0xff; + } + payloads.push(p); + ch.writeSlot(p, BigInt(i)); + } + + for (let i = 0; i < cfg.slotCount; i++) { + const got = ch.readSlot(); + expect(got).not.toBeNull(); + expect(got!.data.byteLength).toBe(cfg.slotBytes); + // Byte-exact comparison — no serialization allowed. + for (let j = 0; j < cfg.slotBytes; j++) { + expect(got!.data[j]).toBe(payloads[i][j]); + } + } + }); +}); + +describe('SabRingChannel stats reporting', () => { + it('reports running counters correctly', () => { + const ch = new SabRingChannel(BASE_CFG); + expect(ch.stats().framesWritten).toBe(0); + expect(ch.stats().framesRead).toBe(0); + expect(ch.stats().inFlight).toBe(0); + expect(ch.stats().capacity).toBe(BASE_CFG.slotCount); + + ch.writeSlot(makePayload(8, 0), 0n); + ch.writeSlot(makePayload(8, 0), 1n); + expect(ch.stats().framesWritten).toBe(2); + expect(ch.stats().inFlight).toBe(2); + + ch.readSlot(); + expect(ch.stats().framesRead).toBe(1); + expect(ch.stats().inFlight).toBe(1); + }); +}); + +// TODO(task 18): real cross-worker backpressure test lands with the +// orchestrator. The timeout-based blocking tests above validate the +// semantic in a single-threaded harness; they cannot prove that an +// Atomics.wake from a sibling worker correctly unblocks the producer. diff --git a/packages/cala-runtime/src/channel.ts b/packages/cala-runtime/src/channel.ts new file mode 100644 index 0000000..a1bd432 --- /dev/null +++ b/packages/cala-runtime/src/channel.ts @@ -0,0 +1,245 @@ +import type { ChannelConfig, ChannelSlot, ChannelStats } from './types.ts'; + +const HEADER_I32_COUNT = 4; +const HEADER_WRITE_IDX = 0; +const HEADER_READ_IDX = 1; +const HEADER_FRAMES_WRITTEN = 2; +const HEADER_FRAMES_READ = 3; + +const SLOT_HEADER_I32_COUNT = 3; +const SLOT_EPOCH_LO = 0; +const SLOT_EPOCH_HI = 1; +const SLOT_LENGTH = 2; + +const BYTES_PER_I32 = 4; +const HEADER_BYTES = HEADER_I32_COUNT * BYTES_PER_I32; +const SLOT_HEADER_BYTES = SLOT_HEADER_I32_COUNT * BYTES_PER_I32; + +const U32_MASK = 0xffffffff; +const EPOCH_LO_MASK = 0xffffffffn; +const EPOCH_HI_SHIFT = 32n; + +export class ChannelTimeoutError extends Error { + constructor(message: string) { + super(message); + this.name = 'ChannelTimeoutError'; + } +} + +function validateConfig(cfg: ChannelConfig): void { + if (!Number.isInteger(cfg.slotBytes) || cfg.slotBytes <= 0) { + throw new Error(`ChannelConfig.slotBytes must be a positive integer (got ${cfg.slotBytes})`); + } + if (!Number.isInteger(cfg.slotCount) || cfg.slotCount <= 0) { + throw new Error(`ChannelConfig.slotCount must be a positive integer (got ${cfg.slotCount})`); + } + if (!Number.isFinite(cfg.waitTimeoutMs) || cfg.waitTimeoutMs < 0) { + throw new Error( + `ChannelConfig.waitTimeoutMs must be a non-negative number (got ${cfg.waitTimeoutMs})`, + ); + } + if (!Number.isFinite(cfg.pollIntervalMs) || cfg.pollIntervalMs <= 0) { + throw new Error( + `ChannelConfig.pollIntervalMs must be a positive number (got ${cfg.pollIntervalMs})`, + ); + } +} + +function computeByteLength(cfg: ChannelConfig): number { + const slotStride = SLOT_HEADER_BYTES + cfg.slotBytes; + return HEADER_BYTES + slotStride * cfg.slotCount; +} + +function coerceToUint8(data: Uint8Array | Float32Array | ArrayBufferView): Uint8Array { + if (data instanceof Uint8Array) return data; + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); +} + +export class SabRingChannel { + private readonly cfg: ChannelConfig; + private readonly buffer: SharedArrayBuffer | ArrayBuffer; + private readonly header: Int32Array; + private readonly slotStrideBytes: number; + private readonly payloadView: Uint8Array; + private readonly canAtomicWait: boolean; + + constructor(cfg: ChannelConfig) { + validateConfig(cfg); + this.cfg = cfg; + this.slotStrideBytes = SLOT_HEADER_BYTES + cfg.slotBytes; + + const required = computeByteLength(cfg); + if (cfg.sharedBuffer) { + if (cfg.sharedBuffer.byteLength < required) { + throw new Error( + `ChannelConfig.sharedBuffer byteLength ${cfg.sharedBuffer.byteLength} < required ${required}`, + ); + } + this.buffer = cfg.sharedBuffer; + } else { + this.buffer = + typeof SharedArrayBuffer !== 'undefined' + ? new SharedArrayBuffer(required) + : new ArrayBuffer(required); + } + + this.header = new Int32Array(this.buffer, 0, HEADER_I32_COUNT); + this.payloadView = new Uint8Array(this.buffer, HEADER_BYTES, required - HEADER_BYTES); + this.canAtomicWait = this.buffer instanceof SharedArrayBuffer; + } + + get sharedBuffer(): SharedArrayBuffer | ArrayBuffer { + return this.buffer; + } + + tryWrite(data: Uint8Array | Float32Array, epoch: bigint): boolean { + const payload = coerceToUint8(data); + if (payload.byteLength > this.cfg.slotBytes) { + throw new Error( + `payload byteLength ${payload.byteLength} exceeds slotBytes ${this.cfg.slotBytes}`, + ); + } + + const writeIdx = Atomics.load(this.header, HEADER_WRITE_IDX) >>> 0; + const readIdx = Atomics.load(this.header, HEADER_READ_IDX) >>> 0; + if (((writeIdx - readIdx) & U32_MASK) >= this.cfg.slotCount) { + return false; + } + + this.writeIntoSlot(writeIdx, payload, epoch); + const nextWrite = (writeIdx + 1) & U32_MASK; + Atomics.store(this.header, HEADER_WRITE_IDX, nextWrite | 0); + const nextFramesWritten = (Atomics.load(this.header, HEADER_FRAMES_WRITTEN) + 1) | 0; + Atomics.store(this.header, HEADER_FRAMES_WRITTEN, nextFramesWritten); + if (this.canAtomicWait) { + Atomics.notify(this.header, HEADER_WRITE_IDX); + } + return true; + } + + writeSlot(data: Uint8Array | Float32Array, epoch: bigint): void { + if (this.tryWrite(data, epoch)) return; + + const deadline = Date.now() + this.cfg.waitTimeoutMs; + while (Date.now() < deadline) { + const readIdx = Atomics.load(this.header, HEADER_READ_IDX) >>> 0; + const writeIdx = Atomics.load(this.header, HEADER_WRITE_IDX) >>> 0; + if (((writeIdx - readIdx) & U32_MASK) < this.cfg.slotCount) { + if (this.tryWrite(data, epoch)) return; + continue; + } + if (this.canAtomicWait) { + const remaining = Math.max(0, deadline - Date.now()); + const timeout = Math.min(remaining, this.cfg.pollIntervalMs); + Atomics.wait(this.header, HEADER_READ_IDX, readIdx | 0, timeout); + } else { + this.busyWaitMs(this.cfg.pollIntervalMs); + } + } + + throw new ChannelTimeoutError( + `SabRingChannel.writeSlot: ring full for ${this.cfg.waitTimeoutMs}ms`, + ); + } + + readSlot(): ChannelSlot | null { + const writeIdx = Atomics.load(this.header, HEADER_WRITE_IDX) >>> 0; + const readIdx = Atomics.load(this.header, HEADER_READ_IDX) >>> 0; + if (writeIdx === readIdx) return null; + + const slot = this.readFromSlot(readIdx); + const nextRead = (readIdx + 1) & U32_MASK; + Atomics.store(this.header, HEADER_READ_IDX, nextRead | 0); + const nextFramesRead = (Atomics.load(this.header, HEADER_FRAMES_READ) + 1) | 0; + Atomics.store(this.header, HEADER_FRAMES_READ, nextFramesRead); + if (this.canAtomicWait) { + Atomics.notify(this.header, HEADER_READ_IDX); + } + return slot; + } + + waitRead(): ChannelSlot { + const immediate = this.readSlot(); + if (immediate !== null) return immediate; + + const deadline = Date.now() + this.cfg.waitTimeoutMs; + while (Date.now() < deadline) { + const writeIdx = Atomics.load(this.header, HEADER_WRITE_IDX) >>> 0; + const readIdx = Atomics.load(this.header, HEADER_READ_IDX) >>> 0; + if (writeIdx !== readIdx) { + const slot = this.readSlot(); + if (slot !== null) return slot; + continue; + } + if (this.canAtomicWait) { + const remaining = Math.max(0, deadline - Date.now()); + const timeout = Math.min(remaining, this.cfg.pollIntervalMs); + Atomics.wait(this.header, HEADER_WRITE_IDX, writeIdx | 0, timeout); + } else { + this.busyWaitMs(this.cfg.pollIntervalMs); + } + } + + throw new ChannelTimeoutError( + `SabRingChannel.waitRead: ring empty for ${this.cfg.waitTimeoutMs}ms`, + ); + } + + stats(): ChannelStats { + const framesWritten = Atomics.load(this.header, HEADER_FRAMES_WRITTEN) >>> 0; + const framesRead = Atomics.load(this.header, HEADER_FRAMES_READ) >>> 0; + const writeIdx = Atomics.load(this.header, HEADER_WRITE_IDX) >>> 0; + const readIdx = Atomics.load(this.header, HEADER_READ_IDX) >>> 0; + return { + framesWritten, + framesRead, + dropCount: 0, + capacity: this.cfg.slotCount, + inFlight: (writeIdx - readIdx) & U32_MASK, + }; + } + + private writeIntoSlot(writeIdx: number, payload: Uint8Array, epoch: bigint): void { + const slotIndex = writeIdx % this.cfg.slotCount; + const slotOffset = slotIndex * this.slotStrideBytes; + const slotHeader = new Int32Array( + this.buffer, + HEADER_BYTES + slotOffset, + SLOT_HEADER_I32_COUNT, + ); + const epochLo = Number(epoch & EPOCH_LO_MASK) | 0; + const epochHi = Number((epoch >> EPOCH_HI_SHIFT) & EPOCH_LO_MASK) | 0; + slotHeader[SLOT_EPOCH_LO] = epochLo; + slotHeader[SLOT_EPOCH_HI] = epochHi; + slotHeader[SLOT_LENGTH] = payload.byteLength | 0; + + const payloadStart = slotOffset + SLOT_HEADER_BYTES; + this.payloadView.set(payload, payloadStart); + } + + private readFromSlot(readIdx: number): ChannelSlot { + const slotIndex = readIdx % this.cfg.slotCount; + const slotOffset = slotIndex * this.slotStrideBytes; + const slotHeader = new Int32Array( + this.buffer, + HEADER_BYTES + slotOffset, + SLOT_HEADER_I32_COUNT, + ); + const epochLo = BigInt(slotHeader[SLOT_EPOCH_LO] >>> 0); + const epochHi = BigInt(slotHeader[SLOT_EPOCH_HI] >>> 0); + const epoch = (epochHi << EPOCH_HI_SHIFT) | epochLo; + const length = slotHeader[SLOT_LENGTH] >>> 0; + + const payloadStart = HEADER_BYTES + slotOffset + SLOT_HEADER_BYTES; + const copy = new Uint8Array(length); + copy.set(new Uint8Array(this.buffer, payloadStart, length)); + return { data: copy, epoch }; + } + + private busyWaitMs(ms: number): void { + const until = Date.now() + ms; + while (Date.now() < until) { + // spin — only reached when SAB is unavailable (non-worker env fallback) + } + } +} diff --git a/packages/cala-runtime/src/index.ts b/packages/cala-runtime/src/index.ts new file mode 100644 index 0000000..39950e0 --- /dev/null +++ b/packages/cala-runtime/src/index.ts @@ -0,0 +1,4 @@ +export { SabRingChannel, ChannelTimeoutError } from './channel.ts'; +export type { ChannelConfig, ChannelStats, ChannelSlot } from './types.ts'; +// Surface stubs for modules that land in later tasks — see types.ts TODOs. +export type { MutationQueue, Snapshot, PipelineEvent, Orchestrator, Todo } from './types.ts'; diff --git a/packages/cala-runtime/src/types.ts b/packages/cala-runtime/src/types.ts new file mode 100644 index 0000000..8a7f1e2 --- /dev/null +++ b/packages/cala-runtime/src/types.ts @@ -0,0 +1,48 @@ +/** + * Shared wire types for the CaLa browser runtime. + * + * This file enumerates the full §7 surface area so the module layout is + * visible even for pieces that land in later tasks. See + * `.planning/CALA_DESIGN.md §7` for the authoritative description. + */ + +export interface ChannelConfig { + slotBytes: number; + slotCount: number; + waitTimeoutMs: number; + pollIntervalMs: number; + sharedBuffer?: SharedArrayBuffer | ArrayBuffer; +} + +export interface ChannelStats { + framesWritten: number; + framesRead: number; + dropCount: number; + capacity: number; + inFlight: number; +} + +export interface ChannelSlot { + data: Uint8Array; + epoch: bigint; +} + +// TODO(task 16): MutationQueue surface — bounded drop-oldest ring used by +// the extend worker to publish PipelineMutation records to the fit worker. +// See CALA_DESIGN §7.3. +export type MutationQueue = Todo<'MutationQueue'>; + +// TODO(task 17): Snapshot surface — copy-on-write asset view protocol that +// gives the extend worker a consistent `A, W, M` at an epoch boundary. +// See CALA_DESIGN §7.2. +export type Snapshot = Todo<'Snapshot'>; + +// TODO(task 17): PipelineEvent surface — compact event records emitted by +// fit for the archive worker. See CALA_DESIGN §9.2. +export type PipelineEvent = Todo<'PipelineEvent'>; + +// TODO(task 18): Orchestrator surface — creates workers, wires channels, +// tracks epochs, owns two-pass toggle. See CALA_DESIGN §7. +export type Orchestrator = Todo<'Orchestrator'>; + +export type Todo = { readonly __todo: K }; diff --git a/packages/cala-runtime/tsconfig.json b/packages/cala-runtime/tsconfig.json new file mode 100644 index 0000000..ad3ec92 --- /dev/null +++ b/packages/cala-runtime/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "rootDir": "src", + "outDir": "dist", + "baseUrl": ".", + "paths": { + "@calab/core": ["../core/src/index.ts"], + "@calab/core/*": ["../core/src/*"] + } + }, + "include": ["src"], + "references": [{ "path": "../core" }] +} diff --git a/packages/cala-runtime/vitest.config.ts b/packages/cala-runtime/vitest.config.ts new file mode 100644 index 0000000..df1207f --- /dev/null +++ b/packages/cala-runtime/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config'; +import path from 'node:path'; + +export default defineConfig({ + resolve: { + alias: { + '@calab/core': path.resolve(__dirname, '../core/src'), + '@calab/cala-runtime': path.resolve(__dirname, 'src'), + }, + }, + test: { + include: ['src/**/*.test.ts'], + }, +}); From 92312416330cc876339eedf208cf741c28ead8c0 Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 18 Apr 2026 10:47:50 -0700 Subject: [PATCH 03/17] feat(cala-core): add @calab/cala-core WASM adapter package (task 13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns the Rust WASM surface from task 12 into a consumable workspace package. Mirrors @calab/core's wrapping of crates/solver — same lazy init pattern, same deep-import block, same test shape. - New workspace package `@calab/cala-core` with wasm-adapter.ts. Lazy, idempotent `initCalaCore()` boots the WASM module once and installs the panic hook on first call. Re-exports AviReader, Preprocessor, Fitter, MutationQueueHandle, SnapshotHandle. - Root `build:wasm` now runs both solver and cala-core builds via `build:wasm:solver` and `build:wasm:cala` sub-scripts. - ESLint `no-restricted-imports` extended to block `**/crates/cala-core/pkg/*` imports anywhere but the adapter, same guardrail applied to the solver pkg. - `.prettierignore` updated to skip `crates/cala-core/pkg/` and `crates/cala-core/target/`, matching the solver exemptions. - 4 adapter tests (module-level singleton via `vi.resetModules()`): idempotent init, single-shot panic hook install, concurrent-caller promise sharing, public re-export surface. Real WASM execution is exercised at Phase 5 exit in the browser (task 25). Co-Authored-By: Claude Opus 4.7 (1M context) --- .prettierignore | 2 + eslint.config.js | 5 ++ package-lock.json | 29 +++++++- package.json | 4 +- packages/cala-core/README.md | 29 ++++++++ packages/cala-core/package.json | 13 ++++ .../src/__tests__/wasm-adapter.test.ts | 69 +++++++++++++++++++ packages/cala-core/src/index.ts | 9 +++ packages/cala-core/src/wasm-adapter.ts | 39 +++++++++++ packages/cala-core/tsconfig.json | 13 ++++ packages/cala-core/vitest.config.ts | 13 ++++ 11 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 packages/cala-core/README.md create mode 100644 packages/cala-core/package.json create mode 100644 packages/cala-core/src/__tests__/wasm-adapter.test.ts create mode 100644 packages/cala-core/src/index.ts create mode 100644 packages/cala-core/src/wasm-adapter.ts create mode 100644 packages/cala-core/tsconfig.json create mode 100644 packages/cala-core/vitest.config.ts diff --git a/.prettierignore b/.prettierignore index 0c2ee8f..a0d5527 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,6 +3,8 @@ apps/*/dist/ packages/*/dist/ crates/solver/pkg/ crates/solver/target/ +crates/cala-core/pkg/ +crates/cala-core/target/ .planning/ package-lock.json python/.venv/ diff --git a/eslint.config.js b/eslint.config.js index 954ca20..e16b709 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -73,6 +73,7 @@ export default tseslint.config( files: ['apps/**/*.{ts,tsx}', 'packages/**/*.ts'], ignores: [ 'packages/core/src/wasm-adapter.ts', + 'packages/cala-core/src/wasm-adapter.ts', 'packages/community/src/supabase.ts', 'packages/community/src/auth.ts', 'packages/community/src/submission-service.ts', @@ -86,6 +87,10 @@ export default tseslint.config( group: ['**/crates/solver/pkg/*'], message: 'Import from @calab/core instead of the WASM pkg directly.', }, + { + group: ['**/crates/cala-core/pkg/*'], + message: 'Import from @calab/cala-core instead of the WASM pkg directly.', + }, { group: ['@supabase/supabase-js'], message: 'Import from @calab/community instead of @supabase/supabase-js directly.', diff --git a/package-lock.json b/package-lock.json index 2f04f82..84e283c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -412,6 +412,14 @@ "node": ">=6.9.0" } }, + "node_modules/@calab/cala-core": { + "resolved": "packages/cala-core", + "link": true + }, + "node_modules/@calab/cala-runtime": { + "resolved": "packages/cala-runtime", + "link": true + }, "node_modules/@calab/community": { "resolved": "packages/community", "link": true @@ -4581,11 +4589,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/cala-core": { + "name": "@calab/cala-core", + "version": "0.0.1" + }, + "packages/cala-runtime": { + "name": "@calab/cala-runtime", + "version": "0.0.1", + "dependencies": { + "@calab/core": "*" + } + }, "packages/community": { "name": "@calab/community", "version": "0.0.1", "dependencies": { "@supabase/supabase-js": "^2.95.3" + }, + "peerDependencies": { + "solid-js": "^1.9.0" } }, "packages/compute": { @@ -4606,6 +4628,7 @@ "name": "@calab/io", "version": "0.0.1", "dependencies": { + "@calab/compute": "*", "@calab/core": "*", "fflate": "^0.8.0", "valibot": "^1.2.0" @@ -4623,8 +4646,12 @@ "name": "@calab/ui", "version": "0.0.1", "dependencies": { + "@calab/community": "*", + "@calab/compute": "*", "@calab/tutorials": "*", - "solid-js": "^1.9.11" + "@dschz/solid-uplot": "*", + "solid-js": "^1.9.11", + "uplot": "*" } } } diff --git a/package.json b/package.json index ca94ffb..cc06837 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,9 @@ "build": "npm run build:wasm && npm run build:apps", "build:apps": "node scripts/build-apps.mjs", "build:pages": "CALAB_PAGES=1 npm run build && node scripts/combine-dist.mjs", - "build:wasm": "cd crates/solver && wasm-pack build --target web --release", + "build:wasm": "npm run build:wasm:solver && npm run build:wasm:cala", + "build:wasm:solver": "cd crates/solver && wasm-pack build --target web --release", + "build:wasm:cala": "cd crates/cala-core && wasm-pack build --target web --release", "test": "npm run test --workspaces --if-present", "test:watch": "npm run test:watch -w apps/catune", "lint": "eslint apps/ packages/ scripts/", diff --git a/packages/cala-core/README.md b/packages/cala-core/README.md new file mode 100644 index 0000000..ed6302c --- /dev/null +++ b/packages/cala-core/README.md @@ -0,0 +1,29 @@ +# @calab/cala-core + +Adapter package for the `crates/cala-core` WASM build. + +## What this is + +A thin, lazily-initialized JS facade over `crates/cala-core/pkg/` (produced by `wasm-pack build --target web`). Exports the `AviReader`, `Preprocessor`, `Fitter`, `MutationQueueHandle`, and `SnapshotHandle` bindings plus an `initCalaCore()` helper that guarantees the WASM module boots exactly once per worker. + +Mirrors the pattern `@calab/core` uses to front the `crates/solver` WASM module. Keeping the two adapters structurally identical makes it obvious which Rust crate each type comes from and prevents cross-contamination of init promises. + +## Rule + +Never import from `crates/cala-core/pkg/` directly — always go through `@calab/cala-core`. The ESLint `no-restricted-imports` rule enforces this at the workspace level. + +## Building + +``` +npm run build:wasm:cala # wraps wasm-pack build in crates/cala-core +``` + +`npm run build:wasm` (root) builds both the solver and cala-core artifacts. + +## Tests + +``` +npm test -w packages/cala-core +``` + +Tests mock the WASM pkg so they run in Node without needing the artifact loaded — they verify init-promise idempotency, the single-shot panic-hook install, and the public re-export surface. Real WASM execution is covered in the Phase 5 exit E2E (apps/cala browser run). diff --git a/packages/cala-core/package.json b/packages/cala-core/package.json new file mode 100644 index 0000000..7e28f8e --- /dev/null +++ b/packages/cala-core/package.json @@ -0,0 +1,13 @@ +{ + "name": "@calab/cala-core", + "private": true, + "version": "0.0.1", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": {} +} diff --git a/packages/cala-core/src/__tests__/wasm-adapter.test.ts b/packages/cala-core/src/__tests__/wasm-adapter.test.ts new file mode 100644 index 0000000..d169520 --- /dev/null +++ b/packages/cala-core/src/__tests__/wasm-adapter.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// We mock the cala-core WASM pkg at the module-resolution level so the +// test suite doesn't need the WASM artifact to be loadable in Node — +// we're only exercising the idempotent init-promise plumbing, not the +// WASM boot itself. Real WASM execution is covered at Phase 5 exit +// (task 25) in the browser. + +const initSpy = vi.fn(async () => undefined); +const panicHookSpy = vi.fn(); + +vi.mock('../../../../crates/cala-core/pkg/calab_cala_core', () => ({ + default: initSpy, + init_panic_hook: panicHookSpy, + AviReader: class StubAviReader {}, + Fitter: class StubFitter {}, + MutationQueueHandle: class StubMutationQueueHandle {}, + Preprocessor: class StubPreprocessor {}, + SnapshotHandle: class StubSnapshotHandle {}, +})); + +// Helper: return a fresh copy of the adapter with a clean module state. +// `vi.resetModules()` drops the in-module `calaReady` singleton so each +// test starts with init never having been called yet. +async function loadFreshAdapter(): Promise { + vi.resetModules(); + initSpy.mockClear(); + panicHookSpy.mockClear(); + return import('../wasm-adapter.ts'); +} + +describe('initCalaCore', () => { + beforeEach(() => { + initSpy.mockClear(); + panicHookSpy.mockClear(); + }); + + it('calls init exactly once even when called multiple times', async () => { + const { initCalaCore } = await loadFreshAdapter(); + await initCalaCore(); + await initCalaCore(); + await initCalaCore(); + expect(initSpy).toHaveBeenCalledTimes(1); + }); + + it('installs the panic hook after init resolves', async () => { + const { initCalaCore } = await loadFreshAdapter(); + await initCalaCore(); + expect(panicHookSpy).toHaveBeenCalledTimes(1); + }); + + it('concurrent callers share one init promise', async () => { + const { initCalaCore } = await loadFreshAdapter(); + const [a, b, c] = await Promise.all([initCalaCore(), initCalaCore(), initCalaCore()]); + expect(a).toBe(b); + expect(b).toBe(c); + expect(initSpy).toHaveBeenCalledTimes(1); + }); + + it('re-exports the binding types so consumers never touch crates/*', async () => { + const mod = await loadFreshAdapter(); + expect(mod.AviReader).toBeDefined(); + expect(mod.Fitter).toBeDefined(); + expect(mod.Preprocessor).toBeDefined(); + expect(mod.MutationQueueHandle).toBeDefined(); + expect(mod.SnapshotHandle).toBeDefined(); + expect(mod.init_panic_hook).toBeDefined(); + }); +}); diff --git a/packages/cala-core/src/index.ts b/packages/cala-core/src/index.ts new file mode 100644 index 0000000..b88341d --- /dev/null +++ b/packages/cala-core/src/index.ts @@ -0,0 +1,9 @@ +export { + AviReader, + Fitter, + MutationQueueHandle, + Preprocessor, + SnapshotHandle, + init_panic_hook, + initCalaCore, +} from './wasm-adapter.ts'; diff --git a/packages/cala-core/src/wasm-adapter.ts b/packages/cala-core/src/wasm-adapter.ts new file mode 100644 index 0000000..f0430ae --- /dev/null +++ b/packages/cala-core/src/wasm-adapter.ts @@ -0,0 +1,39 @@ +/** + * Single import point for the cala-core WASM module. + * + * Rule: no other file should import from `crates/cala-core/pkg/` directly. + * This adapter provides lazy, idempotent initialization and re-exports + * the binding types so consumers never deal with raw WASM init. Mirrors + * `@calab/core`'s `wasm-adapter.ts` for the solver — keeping the two + * adapters structurally identical makes it obvious where each type + * comes from (solver vs cala-core) and avoids cross-contamination of + * init promises. + */ + +import init, { + AviReader, + Fitter, + MutationQueueHandle, + Preprocessor, + SnapshotHandle, + init_panic_hook, +} from '../../../crates/cala-core/pkg/calab_cala_core'; + +export { AviReader, Fitter, MutationQueueHandle, Preprocessor, SnapshotHandle, init_panic_hook }; + +let calaReady: Promise | null = null; + +/** + * Initialize the cala-core WASM module. Lazy and idempotent — safe to + * call from multiple sites (worker boot paths, tests). Only the first + * call triggers actual initialization. The installed panic hook + * surfaces Rust panics as console errors instead of opaque WASM traps. + */ +export function initCalaCore(): Promise { + if (!calaReady) { + calaReady = init().then(() => { + init_panic_hook(); + }); + } + return calaReady; +} diff --git a/packages/cala-core/tsconfig.json b/packages/cala-core/tsconfig.json new file mode 100644 index 0000000..d9479a5 --- /dev/null +++ b/packages/cala-core/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "rootDir": "src", + "outDir": "dist", + "baseUrl": "." + }, + "include": ["src"] +} diff --git a/packages/cala-core/vitest.config.ts b/packages/cala-core/vitest.config.ts new file mode 100644 index 0000000..15c5154 --- /dev/null +++ b/packages/cala-core/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; +import path from 'node:path'; + +export default defineConfig({ + resolve: { + alias: { + '@calab/cala-core': path.resolve(__dirname, 'src'), + }, + }, + test: { + include: ['src/**/*.test.ts'], + }, +}); From 2ba5f84467b7e40389c3ba55352866b803e5c20c Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 18 Apr 2026 10:52:12 -0700 Subject: [PATCH 04/17] feat(io): add FrameSource + uncompressed-AVI reader (task 14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 decoder worker needs random-access grayscale frames from a user-dropped file. This lands the abstraction and the one format we ship in v1. - `FrameSource` interface (design §10): `meta()`, `readFrame(n, method)`, `close()`, plus `FrameOutOfRangeError` and `FrameSourceParseError`. TIFF / compressed AVI / MP4 decoders plug in here without the pipeline caring which parser produced a frame. - `openAviUncompressed(File)` and `openAviUncompressedFromBytes(Uint8Array)` implementations — thin JS veneer over `@calab/cala-core`'s WASM `AviReader`. Parses the RIFF container once on open (O(frame_count) scan), then O(1) random-access reads. Reads the full file in v1; `File.slice()` streaming for huge files is deferred to a future `avi-uncompressed-streaming.ts` that reuses the same contract. - 9 new tests (mock `@calab/cala-core`): meta forwarding, readFrame argument forwarding, FrameOutOfRangeError for invalid indices, constructor/read WASM error wrapping, close() idempotency + free lifecycle, and `initCalaCore()` await on the File path. Real WASM round-trip lands at Phase 5 exit in the browser (task 25). - `@calab/io` picks up `@calab/cala-core` as a workspace dep. Vitest alias updated so the mock resolves cleanly in Node. Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 1 + packages/io/package.json | 1 + .../io/src/__tests__/avi-uncompressed.test.ts | 187 ++++++++++++++++++ packages/io/src/avi-uncompressed.ts | 92 +++++++++ packages/io/src/frame-source.ts | 68 +++++++ packages/io/src/index.ts | 13 ++ packages/io/vitest.config.ts | 1 + 7 files changed, 363 insertions(+) create mode 100644 packages/io/src/__tests__/avi-uncompressed.test.ts create mode 100644 packages/io/src/avi-uncompressed.ts create mode 100644 packages/io/src/frame-source.ts diff --git a/package-lock.json b/package-lock.json index 84e283c..8f16419 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4628,6 +4628,7 @@ "name": "@calab/io", "version": "0.0.1", "dependencies": { + "@calab/cala-core": "*", "@calab/compute": "*", "@calab/core": "*", "fflate": "^0.8.0", diff --git a/packages/io/package.json b/packages/io/package.json index 52c8017..711f068 100644 --- a/packages/io/package.json +++ b/packages/io/package.json @@ -10,6 +10,7 @@ "test:watch": "vitest" }, "dependencies": { + "@calab/cala-core": "*", "@calab/compute": "*", "@calab/core": "*", "fflate": "^0.8.0", diff --git a/packages/io/src/__tests__/avi-uncompressed.test.ts b/packages/io/src/__tests__/avi-uncompressed.test.ts new file mode 100644 index 0000000..147ecb1 --- /dev/null +++ b/packages/io/src/__tests__/avi-uncompressed.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { FrameOutOfRangeError, FrameSourceParseError } from '../frame-source.ts'; + +// We mock `@calab/cala-core` so the test suite runs in Node without +// needing the WASM artifact loaded. The contract we're exercising is +// the TS wrapper: error shapes, meta forwarding, argument forwarding, +// and close() lifecycle. The real WASM execution is covered in the +// Phase 5 exit browser E2E (task 25). + +interface StubAviReaderState { + width: number; + height: number; + frameCount: number; + fps: number; + channels: number; + bitDepth: number; + freed: boolean; + readCalls: Array<{ n: number; method: string }>; + /** If set, `new AviReader(...)` throws this. */ + constructorThrow?: unknown; + /** If set, `readFrameGrayscaleF32` throws this. */ + readThrow?: unknown; +} + +const state: StubAviReaderState = { + width: 4, + height: 3, + frameCount: 5, + fps: 30, + channels: 1, + bitDepth: 8, + freed: false, + readCalls: [], +}; + +class StubAviReader { + constructor(_bytes: Uint8Array) { + if (state.constructorThrow !== undefined) { + throw state.constructorThrow; + } + } + width() { + return state.width; + } + height() { + return state.height; + } + frameCount() { + return state.frameCount; + } + fps() { + return state.fps; + } + channels() { + return state.channels; + } + bitDepth() { + return state.bitDepth; + } + readFrameGrayscaleF32(n: number, method: string): Float32Array { + state.readCalls.push({ n, method }); + if (state.readThrow !== undefined) { + throw state.readThrow; + } + const out = new Float32Array(state.width * state.height); + // Deterministic payload so tests can assert the call delegated. + for (let i = 0; i < out.length; i++) { + out[i] = n * 100 + i; + } + return out; + } + free() { + state.freed = true; + } +} + +const initSpy = vi.fn(async () => undefined); + +vi.mock('@calab/cala-core', () => ({ + AviReader: StubAviReader, + initCalaCore: initSpy, +})); + +// Import after the mock is registered. +const { openAviUncompressed, openAviUncompressedFromBytes } = + await import('../avi-uncompressed.ts'); + +function resetState() { + state.width = 4; + state.height = 3; + state.frameCount = 5; + state.fps = 30; + state.channels = 1; + state.bitDepth = 8; + state.freed = false; + state.readCalls = []; + state.constructorThrow = undefined; + state.readThrow = undefined; + initSpy.mockClear(); +} + +describe('openAviUncompressedFromBytes', () => { + beforeEach(resetState); + + it('forwards metadata from the WASM reader', () => { + state.width = 256; + state.height = 128; + state.frameCount = 300; + state.fps = 20; + state.channels = 3; + state.bitDepth = 24; + const source = openAviUncompressedFromBytes(new Uint8Array([1, 2, 3])); + expect(source.meta()).toEqual({ + width: 256, + height: 128, + frameCount: 300, + fps: 20, + channels: 3, + bitDepth: 24, + }); + }); + + it('delegates readFrame to the WASM reader with the requested method', async () => { + const source = openAviUncompressedFromBytes(new Uint8Array([1])); + const frame0 = await source.readFrame(0); + const frame2 = await source.readFrame(2, 'Luminance'); + expect(state.readCalls).toEqual([ + { n: 0, method: 'Green' }, + { n: 2, method: 'Luminance' }, + ]); + expect(frame0.length).toBe(state.width * state.height); + expect(frame2[0]).toBe(200); // n=2, i=0 → 2*100+0 + }); + + it('throws FrameOutOfRangeError for negative or too-large indices', async () => { + const source = openAviUncompressedFromBytes(new Uint8Array([1])); + await expect(source.readFrame(-1)).rejects.toBeInstanceOf(FrameOutOfRangeError); + await expect(source.readFrame(state.frameCount)).rejects.toBeInstanceOf(FrameOutOfRangeError); + await expect(source.readFrame(1.5)).rejects.toBeInstanceOf(FrameOutOfRangeError); + }); + + it('throws FrameSourceParseError when the WASM reader refuses the buffer', () => { + state.constructorThrow = 'cala-core avi: {Truncated("top-level chunk")}'; + expect(() => openAviUncompressedFromBytes(new Uint8Array([0]))).toThrow(FrameSourceParseError); + }); + + it('wraps read-side WASM errors as FrameSourceParseError', async () => { + const source = openAviUncompressedFromBytes(new Uint8Array([1])); + state.readThrow = new Error('decode blew up'); + await expect(source.readFrame(0)).rejects.toBeInstanceOf(FrameSourceParseError); + }); + + it('close() frees the underlying WASM handle and blocks further reads', async () => { + const source = openAviUncompressedFromBytes(new Uint8Array([1])); + source.close(); + expect(state.freed).toBe(true); + await expect(source.readFrame(0)).rejects.toThrow(/closed/); + }); + + it('close() is idempotent — second call is a no-op', () => { + const source = openAviUncompressedFromBytes(new Uint8Array([1])); + source.close(); + state.freed = false; // Reset flag; second close must not set it again. + source.close(); + expect(state.freed).toBe(false); + }); +}); + +describe('openAviUncompressed', () => { + beforeEach(resetState); + + it('awaits initCalaCore before constructing the reader', async () => { + const file = new File([new Uint8Array([1, 2, 3, 4])], 'test.avi'); + const source = await openAviUncompressed(file); + expect(initSpy).toHaveBeenCalledTimes(1); + expect(source.meta().width).toBe(state.width); + source.close(); + }); + + it('reads the full file contents through File.arrayBuffer()', async () => { + const bytes = new Uint8Array([10, 20, 30, 40, 50]); + const file = new File([bytes], 'test.avi'); + const source = await openAviUncompressed(file); + expect(source.meta()).toBeDefined(); + source.close(); + }); +}); diff --git a/packages/io/src/avi-uncompressed.ts b/packages/io/src/avi-uncompressed.ts new file mode 100644 index 0000000..df3b3b5 --- /dev/null +++ b/packages/io/src/avi-uncompressed.ts @@ -0,0 +1,92 @@ +/** + * Uncompressed AVI `FrameSource` (Phase 1 input path per design §11). + * + * Thin JS veneer over `@calab/cala-core`'s `AviReader` — the RIFF + * container parse, frame index, and grayscale decode all live in + * Rust/WASM. The TS side just owns the byte buffer's lifetime and + * bridges the `FrameSource` contract. + * + * Phase 5 reads the entire file into memory up-front (miniscope + * recordings are typically in the low-hundreds-of-MB range; this + * fits browser memory budgets). Streaming via `File.slice()` for + * bigger files is a post-Phase-5 optimization; when it lands it + * lives in a new `avi-uncompressed-streaming.ts` module and reuses + * the same `FrameSource` contract so the decoder worker doesn't + * need to change. + */ + +import { AviReader, initCalaCore } from '@calab/cala-core'; +import { + FrameOutOfRangeError, + FrameSourceParseError, + type FrameSource, + type FrameSourceMeta, + type GrayscaleMethod, +} from './frame-source.ts'; + +/** + * Open an uncompressed AVI as a `FrameSource`. Parses the RIFF + * container once on construction; random-access reads are O(1) + * thereafter. + */ +export async function openAviUncompressed(file: File): Promise { + await initCalaCore(); + const bytes = new Uint8Array(await file.arrayBuffer()); + return openAviUncompressedFromBytes(bytes); +} + +/** + * Variant that takes the byte buffer directly. Useful for tests and + * for the decoder worker when it reads from a handle that is not a + * `File` (e.g. `fetch` result or a preloaded buffer). + */ +export function openAviUncompressedFromBytes(bytes: Uint8Array): FrameSource { + let reader: AviReader | null; + try { + reader = new AviReader(bytes); + } catch (e) { + throw new FrameSourceParseError('avi-uncompressed', stringifyError(e)); + } + const meta: FrameSourceMeta = { + width: reader.width(), + height: reader.height(), + frameCount: reader.frameCount(), + fps: reader.fps(), + channels: reader.channels(), + bitDepth: reader.bitDepth(), + }; + + const source: FrameSource = { + meta: () => meta, + async readFrame(n: number, method: GrayscaleMethod = 'Green') { + if (reader === null) { + throw new Error('FrameSource has been closed'); + } + if (!Number.isInteger(n) || n < 0 || n >= meta.frameCount) { + throw new FrameOutOfRangeError(n, meta.frameCount); + } + try { + return reader.readFrameGrayscaleF32(n, method); + } catch (e) { + throw new FrameSourceParseError('avi-uncompressed', stringifyError(e)); + } + }, + close() { + if (reader !== null) { + reader.free(); + reader = null; + } + }, + }; + return source; +} + +function stringifyError(e: unknown): string { + if (e instanceof Error) return e.message; + if (typeof e === 'string') return e; + try { + return JSON.stringify(e); + } catch { + return String(e); + } +} diff --git a/packages/io/src/frame-source.ts b/packages/io/src/frame-source.ts new file mode 100644 index 0000000..0727262 --- /dev/null +++ b/packages/io/src/frame-source.ts @@ -0,0 +1,68 @@ +/** + * `FrameSource` is the extension point the CaLa decoder worker reads + * from (design §10). Phase 5 ships one concrete implementation — + * uncompressed AVI (`avi-uncompressed.ts`). Post-v1 formats (TIFF, + * compressed AVI via WebCodecs, MP4/HEVC) plug in here without the + * pipeline caring which parser produced a frame. + */ + +export type GrayscaleMethod = 'Green' | 'Luminance'; + +/** Structural properties of a recording that don't vary per frame. */ +export interface FrameSourceMeta { + /** Frame width in pixels. */ + width: number; + /** Frame height in pixels. */ + height: number; + /** Total number of frames in the source. */ + frameCount: number; + /** Frames per second declared by the container, or `0` if unknown. */ + fps: number; + /** Channel count per pixel (1 for grayscale, 3 for BGR, etc). */ + channels: number; + /** Container-reported bit depth (8 or 24 for Phase 1 AVI). */ + bitDepth: number; +} + +/** + * Provides random-access reads of grayscale frames. Implementations + * own the underlying buffer / handle and free it on `close()`. Callers + * must treat the returned `Float32Array` as read-only and not alias + * its storage across reads — some implementations reuse scratch + * memory and will overwrite on the next call. + */ +export interface FrameSource { + meta(): FrameSourceMeta; + /** + * Decode frame `n` to an `f32` grayscale buffer of length + * `width·height`. `method` picks the 24-bit→grayscale reduction + * (ignored for 8-bit streams). Defaults to `'Green'` — the + * pragmatic choice for miniscope recorders where the real signal + * lives on the green channel. + */ + readFrame(n: number, method?: GrayscaleMethod): Promise; + /** Release any underlying resources (WASM handles, file buffers). */ + close(): void; +} + +/** Surfaced when a frame index is outside `[0, frameCount)`. */ +export class FrameOutOfRangeError extends Error { + constructor( + public readonly index: number, + public readonly frameCount: number, + ) { + super(`frame index ${index} out of range [0, ${frameCount})`); + this.name = 'FrameOutOfRangeError'; + } +} + +/** Surfaced when the source could not be parsed or opened. */ +export class FrameSourceParseError extends Error { + constructor( + public readonly format: string, + message: string, + ) { + super(`${format}: ${message}`); + this.name = 'FrameSourceParseError'; + } +} diff --git a/packages/io/src/index.ts b/packages/io/src/index.ts index 7ae75ae..3c05c81 100644 --- a/packages/io/src/index.ts +++ b/packages/io/src/index.ts @@ -17,3 +17,16 @@ export { stopBridgeHeartbeat, } from './bridge.ts'; export type { BridgeMetadata, BridgeConfig, BridgeProgress } from './bridge.ts'; + +// CaLa frame sources (design §10): generic random-access frame input +// the decoder worker reads from. Phase 5 ships `avi-uncompressed`; +// TIFF / compressed AVI / MP4 implementations plug into the same +// `FrameSource` contract later. +export { + FrameOutOfRangeError, + FrameSourceParseError, + type FrameSource, + type FrameSourceMeta, + type GrayscaleMethod, +} from './frame-source.ts'; +export { openAviUncompressed, openAviUncompressedFromBytes } from './avi-uncompressed.ts'; diff --git a/packages/io/vitest.config.ts b/packages/io/vitest.config.ts index 70f75bc..115a2d2 100644 --- a/packages/io/vitest.config.ts +++ b/packages/io/vitest.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ resolve: { alias: { '@calab/core': path.resolve(__dirname, '../core/src'), + '@calab/cala-core': path.resolve(__dirname, '../cala-core/src'), }, }, test: { From 3a9048d9de783cb22c0b5b6891f712f29519d918 Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 18 Apr 2026 10:52:38 -0700 Subject: [PATCH 05/17] feat(cala-runtime): add mutation queue TS port (task 16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports crates/cala-core/src/extending/mutation.rs to TypeScript for the runtime orchestration layer. Single-threaded VecDeque-equivalent semantics; cross-worker SAB backing lands with the orchestrator in task 18. - MutationQueue with drop-oldest overflow, FIFO drain, bigint drops counter matching the Rust u64 — all parameters sourced from MutationQueueConfig, no magic numbers in the class body. - PipelineMutation discriminated union (register / merge / deprecate) mirroring the Rust variants field-for-field. DeprecateReason and ComponentClass string unions for the corresponding Rust enums. - Tests written first (§4.1): capacity assertion, FIFO, drop-oldest on overflow, drainAll, snapshotEpoch helper, DeprecateReason round- trip, and one byte-for-byte Rust-parity test mirroring a scenario from tests/extending_mutation.rs. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cala-runtime/README.md | 3 +- .../src/__tests__/mutation-queue.test.ts | 254 ++++++++++++++++++ packages/cala-runtime/src/index.ts | 10 +- packages/cala-runtime/src/mutation-queue.ts | 120 +++++++++ packages/cala-runtime/src/types.ts | 17 +- 5 files changed, 398 insertions(+), 6 deletions(-) create mode 100644 packages/cala-runtime/src/__tests__/mutation-queue.test.ts create mode 100644 packages/cala-runtime/src/mutation-queue.ts diff --git a/packages/cala-runtime/README.md b/packages/cala-runtime/README.md index be67cf0..103a9b7 100644 --- a/packages/cala-runtime/README.md +++ b/packages/cala-runtime/README.md @@ -13,7 +13,8 @@ design, mutation queue protocol, asset snapshot protocol. - `channel.ts` — SAB-backed single-producer/single-consumer ring for frame data (decoder → fit, fit → extend). [landed, task 15] - `mutation-queue.ts` — bounded drop-oldest ring (extend → fit). - [later task 16] + [landed, task 16] Single-threaded TS port of the Rust `MutationQueue`; + cross-worker SAB-backed version lands with the orchestrator (task 18). - `asset-snapshot.ts` — copy-on-write snapshot of `A, W, M` at an epoch boundary. [later task 17] - `events.ts` — event bus consumed by the archive worker. diff --git a/packages/cala-runtime/src/__tests__/mutation-queue.test.ts b/packages/cala-runtime/src/__tests__/mutation-queue.test.ts new file mode 100644 index 0000000..d381ff7 --- /dev/null +++ b/packages/cala-runtime/src/__tests__/mutation-queue.test.ts @@ -0,0 +1,254 @@ +import { describe, it, expect } from 'vitest'; +import { + MutationQueue, + snapshotEpoch, + type PipelineMutation, + type MutationQueueConfig, +} from '../mutation-queue.ts'; + +const CAP_SMALL: MutationQueueConfig = { capacity: 2 }; +const CAP_MED: MutationQueueConfig = { capacity: 4 }; +const CAP_LARGE: MutationQueueConfig = { capacity: 8 }; +const CAP_OVERFLOW: MutationQueueConfig = { capacity: 4 }; + +function dep(id: number, epoch: bigint): PipelineMutation { + return { + type: 'deprecate', + snapshotEpoch: epoch, + id, + reason: 'traceInactive', + }; +} + +function reg(epoch: bigint): PipelineMutation { + return { + type: 'register', + snapshotEpoch: epoch, + class: 'cell', + support: new Uint32Array([0, 1]), + values: new Float32Array([0.5, 0.5]), + trace: new Float32Array([0.0, 1.0, 2.0]), + }; +} + +function merge(epoch: bigint, a: number, b: number): PipelineMutation { + return { + type: 'merge', + snapshotEpoch: epoch, + mergeIds: [a, b], + class: 'neuropil', + support: new Uint32Array([2, 3]), + values: new Float32Array([0.5, 0.5]), + trace: new Float32Array([1.0, 1.0, 1.0, 1.0, 1.0]), + }; +} + +describe('MutationQueue config validation', () => { + it('throws RangeError when capacity is 0', () => { + expect(() => new MutationQueue({ capacity: 0 })).toThrow(RangeError); + expect(() => new MutationQueue({ capacity: 0 })).toThrow(/capacity must be/); + }); + + it('throws RangeError when capacity is negative', () => { + expect(() => new MutationQueue({ capacity: -1 })).toThrow(RangeError); + }); + + it('throws RangeError when capacity is not an integer', () => { + expect(() => new MutationQueue({ capacity: 1.5 })).toThrow(RangeError); + }); +}); + +describe('MutationQueue initial state', () => { + it('starts empty with configured capacity and zero drops', () => { + const q = new MutationQueue(CAP_MED); + expect(q.isEmpty).toBe(true); + expect(q.isFull).toBe(false); + expect(q.len).toBe(0); + expect(q.capacity).toBe(CAP_MED.capacity); + expect(q.drops).toBe(0n); + expect(q.pop()).toBeNull(); + }); +}); + +describe('MutationQueue push / pop FIFO', () => { + it('push then pop returns the same element', () => { + const q = new MutationQueue(CAP_MED); + const m = dep(42, 7n); + q.push(m); + const out = q.pop(); + expect(out).toBe(m); + expect(q.isEmpty).toBe(true); + }); + + it('push N, pop N preserves FIFO order (deprecate variant)', () => { + const q = new MutationQueue(CAP_MED); + q.push(dep(1, 10n)); + q.push(dep(2, 11n)); + q.push(dep(3, 12n)); + expect(q.len).toBe(3); + expect(q.pop()!.snapshotEpoch).toBe(10n); + expect(q.pop()!.snapshotEpoch).toBe(11n); + expect(q.pop()!.snapshotEpoch).toBe(12n); + expect(q.pop()).toBeNull(); + expect(q.drops).toBe(0n); + }); + + it('preserves FIFO across all three variants interleaved', () => { + const q = new MutationQueue(CAP_LARGE); + const a = reg(1n); + const b = merge(2n, 0, 1); + const c = dep(5, 3n); + q.push(a); + q.push(b); + q.push(c); + expect(q.pop()).toBe(a); + expect(q.pop()).toBe(b); + expect(q.pop()).toBe(c); + }); +}); + +describe('MutationQueue drop-oldest overflow', () => { + it('drops oldest element when pushing onto full queue', () => { + const q = new MutationQueue(CAP_SMALL); + q.push(dep(1, 1n)); + q.push(dep(2, 2n)); + expect(q.isFull).toBe(true); + q.push(dep(3, 3n)); + expect(q.drops).toBe(1n); + expect(q.len).toBe(CAP_SMALL.capacity); + + const first = q.pop()!; + expect(first.type).toBe('deprecate'); + if (first.type === 'deprecate') { + expect(first.id).toBe(2); + } + }); + + it('increments drops once per overflow push', () => { + const q = new MutationQueue(CAP_SMALL); + q.push(dep(1, 1n)); + q.push(dep(2, 2n)); + q.push(dep(3, 3n)); + q.push(dep(4, 4n)); + q.push(dep(5, 5n)); + expect(q.drops).toBe(3n); + expect(q.len).toBe(CAP_SMALL.capacity); + }); + + it('does not increment drops when queue has room', () => { + const q = new MutationQueue(CAP_MED); + q.push(dep(1, 1n)); + q.push(dep(2, 2n)); + expect(q.drops).toBe(0n); + }); +}); + +describe('MutationQueue drainAll', () => { + it('returns all elements in FIFO order and empties the queue', () => { + const q = new MutationQueue(CAP_LARGE); + for (let i = 0; i < 5; i++) { + q.push(dep(i, BigInt(i))); + } + const drained = q.drainAll(); + expect(drained.length).toBe(5); + drained.forEach((m, i) => { + expect(m.snapshotEpoch).toBe(BigInt(i)); + }); + expect(q.isEmpty).toBe(true); + expect(q.drops).toBe(0n); + }); + + it('preserves drops counter across drainAll', () => { + const q = new MutationQueue(CAP_SMALL); + q.push(dep(1, 1n)); + q.push(dep(2, 2n)); + q.push(dep(3, 3n)); + q.drainAll(); + expect(q.drops).toBe(1n); + expect(q.isEmpty).toBe(true); + q.push(dep(4, 4n)); + q.push(dep(5, 5n)); + q.push(dep(6, 6n)); + expect(q.drops).toBe(2n); + }); +}); + +describe('snapshotEpoch helper', () => { + it('extracts epoch from register variant', () => { + const m: PipelineMutation = { + type: 'register', + snapshotEpoch: 42n, + class: 'cell', + support: new Uint32Array([0, 1]), + values: new Float32Array([0.5, 0.5]), + trace: new Float32Array([0.0, 1.0, 2.0]), + }; + expect(snapshotEpoch(m)).toBe(42n); + }); + + it('extracts epoch from merge variant', () => { + const m: PipelineMutation = { + type: 'merge', + snapshotEpoch: 7n, + mergeIds: [3, 4], + class: 'neuropil', + support: new Uint32Array([2, 3]), + values: new Float32Array([0.5, 0.5]), + trace: new Float32Array(5).fill(1.0), + }; + expect(snapshotEpoch(m)).toBe(7n); + }); + + it('extracts epoch from deprecate variant', () => { + const m: PipelineMutation = { + type: 'deprecate', + snapshotEpoch: 100n, + id: 2, + reason: 'footprintCollapsed', + }; + expect(snapshotEpoch(m)).toBe(100n); + }); +}); + +describe('DeprecateReason round-trip', () => { + it('all four reasons flow through the queue unchanged', () => { + const reasons = ['footprintCollapsed', 'traceInactive', 'mergedInto', 'invalidApply'] as const; + const q = new MutationQueue(CAP_LARGE); + for (const reason of reasons) { + q.push({ + type: 'deprecate', + snapshotEpoch: 1n, + id: 0, + reason, + }); + } + const drained = q.drainAll(); + expect(drained.length).toBe(reasons.length); + drained.forEach((m, i) => { + expect(m.type).toBe('deprecate'); + if (m.type === 'deprecate') { + expect(m.reason).toBe(reasons[i]); + } + }); + }); +}); + +// Mirrors Rust test: mutation_queue_handles_many_overflows +// (crates/cala-core/tests/extending_mutation.rs). +describe('Rust parity: many overflows', () => { + it('1000 pushes into capacity-4 queue leaves last 4, drops = 996', () => { + const q = new MutationQueue(CAP_OVERFLOW); + const total = 1000; + for (let i = 0; i < total; i++) { + q.push(dep(i, BigInt(i))); + } + expect(q.len).toBe(CAP_OVERFLOW.capacity); + expect(q.drops).toBe(BigInt(total - CAP_OVERFLOW.capacity)); + + const ids = q.drainAll().map((m) => { + if (m.type !== 'deprecate') throw new Error('expected deprecate'); + return m.id; + }); + expect(ids).toEqual([996, 997, 998, 999]); + }); +}); diff --git a/packages/cala-runtime/src/index.ts b/packages/cala-runtime/src/index.ts index 39950e0..ed7c84e 100644 --- a/packages/cala-runtime/src/index.ts +++ b/packages/cala-runtime/src/index.ts @@ -1,4 +1,12 @@ export { SabRingChannel, ChannelTimeoutError } from './channel.ts'; export type { ChannelConfig, ChannelStats, ChannelSlot } from './types.ts'; +export { MutationQueue, snapshotEpoch } from './mutation-queue.ts'; +export type { + PipelineMutation, + DeprecateReason, + ComponentClass, + Epoch, + MutationQueueConfig, +} from './mutation-queue.ts'; // Surface stubs for modules that land in later tasks — see types.ts TODOs. -export type { MutationQueue, Snapshot, PipelineEvent, Orchestrator, Todo } from './types.ts'; +export type { Snapshot, PipelineEvent, Orchestrator, Todo } from './types.ts'; diff --git a/packages/cala-runtime/src/mutation-queue.ts b/packages/cala-runtime/src/mutation-queue.ts new file mode 100644 index 0000000..4a3b868 --- /dev/null +++ b/packages/cala-runtime/src/mutation-queue.ts @@ -0,0 +1,120 @@ +/** + * Bounded FIFO mutation queue with drop-oldest backpressure + * (design §7.3, Phase 3 Task 9 / Phase 5 Task 16). + * + * TypeScript port of `crates/cala-core/src/extending/mutation.rs`. Single- + * threaded harness stand-in for the real SAB ring that lands with the + * orchestrator in Task 18 — semantics (FIFO, drop-oldest, epoch tagging, + * drops counter) match the Rust source of truth field-for-field so fit- + * side apply logic can be exercised without workers. + */ + +/** Monotonic asset-state counter incremented by every mutation apply. */ +export type Epoch = bigint; + +/** Mirrors `crate::config::ComponentClass`. */ +export type ComponentClass = 'cell' | 'slowBaseline' | 'neuropil'; + +/** Mirrors `crate::extending::mutation::DeprecateReason` (all four variants). */ +export type DeprecateReason = + | 'footprintCollapsed' + | 'traceInactive' + | 'mergedInto' + | 'invalidApply'; + +/** + * One self-contained change to the model state. Carries its own snapshot + * epoch so fit can decide whether to apply or discard (Task 10). Mirrors + * the Rust enum variants `Register`, `Merge`, `Deprecate`. + */ +export type PipelineMutation = + | { + type: 'register'; + snapshotEpoch: Epoch; + class: ComponentClass; + support: Uint32Array; + values: Float32Array; + trace: Float32Array; + } + | { + type: 'merge'; + snapshotEpoch: Epoch; + mergeIds: [number, number]; + class: ComponentClass; + support: Uint32Array; + values: Float32Array; + trace: Float32Array; + } + | { + type: 'deprecate'; + snapshotEpoch: Epoch; + id: number; + reason: DeprecateReason; + }; + +/** Config for {@link MutationQueue}. Capacity is required and must be ≥ 1. */ +export interface MutationQueueConfig { + capacity: number; +} + +/** Extracts the snapshot epoch from any mutation variant. */ +export function snapshotEpoch(m: PipelineMutation): Epoch { + return m.snapshotEpoch; +} + +export class MutationQueue { + private readonly cap: number; + private readonly buf: PipelineMutation[] = []; + private dropCount = 0n; + + constructor(cfg: MutationQueueConfig) { + if (!Number.isInteger(cfg.capacity) || cfg.capacity < 1) { + throw new RangeError(`capacity must be ≥ 1 (got ${cfg.capacity})`); + } + this.cap = cfg.capacity; + } + + get capacity(): number { + return this.cap; + } + + get len(): number { + return this.buf.length; + } + + get isEmpty(): boolean { + return this.buf.length === 0; + } + + get isFull(): boolean { + return this.buf.length === this.cap; + } + + /** Total mutations dropped due to overflow since construction. */ + get drops(): bigint { + return this.dropCount; + } + + /** + * Append a mutation. When the queue is at capacity, the oldest entry + * is evicted and {@link drops} advances by 1 — matches Rust + * `pop_front` + `saturating_add(1)` + `push_back`. + */ + push(m: PipelineMutation): void { + if (this.buf.length === this.cap) { + this.buf.shift(); + this.dropCount += 1n; + } + this.buf.push(m); + } + + /** Pop the oldest queued mutation, or `null` when empty. */ + pop(): PipelineMutation | null { + return this.buf.shift() ?? null; + } + + /** Drain the queue in FIFO order. Does not reset the drops counter. */ + drainAll(): PipelineMutation[] { + return this.buf.splice(0, this.buf.length); + } +} diff --git a/packages/cala-runtime/src/types.ts b/packages/cala-runtime/src/types.ts index 8a7f1e2..c7d5a97 100644 --- a/packages/cala-runtime/src/types.ts +++ b/packages/cala-runtime/src/types.ts @@ -27,10 +27,19 @@ export interface ChannelSlot { epoch: bigint; } -// TODO(task 16): MutationQueue surface — bounded drop-oldest ring used by -// the extend worker to publish PipelineMutation records to the fit worker. -// See CALA_DESIGN §7.3. -export type MutationQueue = Todo<'MutationQueue'>; +// MutationQueue surface — bounded drop-oldest ring used by the extend +// worker to publish PipelineMutation records to the fit worker. Single- +// threaded for now; cross-worker SAB backing lands with the orchestrator +// in task 18. See CALA_DESIGN §7.3. +export { + MutationQueue, + snapshotEpoch, + type PipelineMutation, + type DeprecateReason, + type ComponentClass, + type Epoch, + type MutationQueueConfig, +} from './mutation-queue.ts'; // TODO(task 17): Snapshot surface — copy-on-write asset view protocol that // gives the extend worker a consistent `A, W, M` at an epoch boundary. From 7ba7889dc14728d46715bcb47826d377741c32c6 Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 18 Apr 2026 10:58:29 -0700 Subject: [PATCH 06/17] feat(cala-runtime): add asset snapshot protocol + event bus (task 17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 runtime gains the two cross-worker coordination surfaces from design §7.2 and §9.2: - `SnapshotProtocol` handles extend→fit snapshot requests with correlation ids, fit-side polling / ack publication, and ack-timeout diagnostics. Single-threaded in-memory transport for now; SAB-backed swap lands with the orchestrator in task 18. - `EventBus` carries the six `PipelineEvent` variants (birth, merge, split, deprecate, reject, metric) plus `FootprintSnap` payloads (sparse pixel_idx / value pairs, design §9.3) from fit to archive. Drop-oldest under pressure, drops counter for dashboard metrics. - All tuning knobs (ring capacities, timeouts, subscriber caps) live on the config structs — no literals in the class bodies. - Tests written first (§4.1): round-trip + timeout + capacity for the snapshot protocol; publish/subscribe/unsubscribe/drop-oldest/close + FootprintSnap byte parity for the event bus. Orchestrator module stub untouched — it lands in task 18 wiring all three channels (SAB frames, mutation queue, snapshot protocol, events) to real workers. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cala-runtime/README.md | 11 +- .../src/__tests__/asset-snapshot.test.ts | 202 ++++++++++++++ .../cala-runtime/src/__tests__/events.test.ts | 248 ++++++++++++++++++ packages/cala-runtime/src/asset-snapshot.ts | 178 +++++++++++++ packages/cala-runtime/src/events.ts | 181 +++++++++++++ packages/cala-runtime/src/index.ts | 17 +- packages/cala-runtime/src/types.ts | 31 ++- 7 files changed, 855 insertions(+), 13 deletions(-) create mode 100644 packages/cala-runtime/src/__tests__/asset-snapshot.test.ts create mode 100644 packages/cala-runtime/src/__tests__/events.test.ts create mode 100644 packages/cala-runtime/src/asset-snapshot.ts create mode 100644 packages/cala-runtime/src/events.ts diff --git a/packages/cala-runtime/README.md b/packages/cala-runtime/README.md index 103a9b7..a5dd115 100644 --- a/packages/cala-runtime/README.md +++ b/packages/cala-runtime/README.md @@ -15,9 +15,12 @@ design, mutation queue protocol, asset snapshot protocol. - `mutation-queue.ts` — bounded drop-oldest ring (extend → fit). [landed, task 16] Single-threaded TS port of the Rust `MutationQueue`; cross-worker SAB-backed version lands with the orchestrator (task 18). -- `asset-snapshot.ts` — copy-on-write snapshot of `A, W, M` at an - epoch boundary. [later task 17] -- `events.ts` — event bus consumed by the archive worker. - [later task 17] +- `asset-snapshot.ts` — extend↔fit snapshot request/ack protocol with + correlation ids and ack-timeout diagnostics. [landed, task 17] + Single-threaded in-memory transport; cross-worker SAB-backed version + lands with the orchestrator (task 18). +- `events.ts` — `PipelineEvent` bus (birth / merge / split / deprecate + / reject / metric) with drop-oldest backpressure, consumed by the + archive worker. [landed, task 17] - `orchestrator.ts` — spawns workers, wires channels, tracks epochs, owns two-pass toggle. [later task 18] diff --git a/packages/cala-runtime/src/__tests__/asset-snapshot.test.ts b/packages/cala-runtime/src/__tests__/asset-snapshot.test.ts new file mode 100644 index 0000000..6c26554 --- /dev/null +++ b/packages/cala-runtime/src/__tests__/asset-snapshot.test.ts @@ -0,0 +1,202 @@ +import { describe, it, expect } from 'vitest'; +import { + SnapshotProtocol, + SnapshotTimeoutError, + SnapshotCapacityError, + type SnapshotAck, + type SnapshotProtocolConfig, +} from '../asset-snapshot.ts'; + +const BASE_CFG: SnapshotProtocolConfig = { + ackTimeoutMs: 50, + pendingCapacity: 1, + pollIntervalMs: 1, +}; + +function fulfil(p: SnapshotProtocol, epoch: bigint, numComponents: number, pixels: number): void { + const req = p.pollRequest(); + expect(req).not.toBeNull(); + p.publishAck({ + requestId: req!.requestId, + epoch, + numComponents, + pixels, + }); +} + +describe('SnapshotProtocol config validation', () => { + it('rejects non-positive ackTimeoutMs', () => { + expect(() => new SnapshotProtocol({ ...BASE_CFG, ackTimeoutMs: 0 })).toThrow(/ackTimeoutMs/); + expect(() => new SnapshotProtocol({ ...BASE_CFG, ackTimeoutMs: -1 })).toThrow(/ackTimeoutMs/); + }); + + it('rejects non-positive pendingCapacity', () => { + expect(() => new SnapshotProtocol({ ...BASE_CFG, pendingCapacity: 0 })).toThrow( + /pendingCapacity/, + ); + expect(() => new SnapshotProtocol({ ...BASE_CFG, pendingCapacity: -2 })).toThrow( + /pendingCapacity/, + ); + }); + + it('rejects non-integer pendingCapacity', () => { + expect(() => new SnapshotProtocol({ ...BASE_CFG, pendingCapacity: 1.5 })).toThrow( + /pendingCapacity/, + ); + }); + + it('rejects non-positive pollIntervalMs', () => { + expect(() => new SnapshotProtocol({ ...BASE_CFG, pollIntervalMs: 0 })).toThrow( + /pollIntervalMs/, + ); + }); +}); + +describe('SnapshotProtocol request / ack round-trip', () => { + it('extend sees fit-published ack with matching correlation id', async () => { + const p = new SnapshotProtocol(BASE_CFG); + const pending = p.requestSnapshot(); + + const req = p.pollRequest(); + expect(req).not.toBeNull(); + const ack: SnapshotAck = { + requestId: req!.requestId, + epoch: 7n, + numComponents: 3, + pixels: 64, + }; + p.publishAck(ack); + + const got = await pending; + expect(got.requestId).toBe(req!.requestId); + expect(got.epoch).toBe(7n); + expect(got.numComponents).toBe(3); + expect(got.pixels).toBe(64); + }); + + it('correlation id is unique per request and preserved through the round-trip', async () => { + const p = new SnapshotProtocol({ ...BASE_CFG, pendingCapacity: 4 }); + const a = p.requestSnapshot(); + const b = p.requestSnapshot(); + const c = p.requestSnapshot(); + + const reqs = [p.pollRequest()!, p.pollRequest()!, p.pollRequest()!]; + const ids = reqs.map((r) => r.requestId); + // Correlation ids are unique. + expect(new Set(ids).size).toBe(ids.length); + + // Fit services them in a non-FIFO order to prove correlation-id binding. + p.publishAck({ requestId: reqs[1].requestId, epoch: 11n, numComponents: 1, pixels: 8 }); + p.publishAck({ requestId: reqs[0].requestId, epoch: 10n, numComponents: 1, pixels: 8 }); + p.publishAck({ requestId: reqs[2].requestId, epoch: 12n, numComponents: 1, pixels: 8 }); + + const [ra, rb, rc] = await Promise.all([a, b, c]); + expect(ra.requestId).toBe(reqs[0].requestId); + expect(ra.epoch).toBe(10n); + expect(rb.requestId).toBe(reqs[1].requestId); + expect(rb.epoch).toBe(11n); + expect(rc.requestId).toBe(reqs[2].requestId); + expect(rc.epoch).toBe(12n); + }); +}); + +describe('SnapshotProtocol FIFO polling', () => { + it('pollRequest returns requests in the order they were issued', async () => { + const p = new SnapshotProtocol({ ...BASE_CFG, pendingCapacity: 3 }); + const promises = [p.requestSnapshot(), p.requestSnapshot(), p.requestSnapshot()]; + + const r1 = p.pollRequest()!; + const r2 = p.pollRequest()!; + const r3 = p.pollRequest()!; + expect(r1.requestId < r2.requestId).toBe(true); + expect(r2.requestId < r3.requestId).toBe(true); + expect(p.pollRequest()).toBeNull(); + + p.publishAck({ requestId: r1.requestId, epoch: 1n, numComponents: 0, pixels: 0 }); + p.publishAck({ requestId: r2.requestId, epoch: 2n, numComponents: 0, pixels: 0 }); + p.publishAck({ requestId: r3.requestId, epoch: 3n, numComponents: 0, pixels: 0 }); + await Promise.all(promises); + }); +}); + +describe('SnapshotProtocol ack timeout', () => { + it('rejects with SnapshotTimeoutError after ackTimeoutMs elapses with no ack', async () => { + const p = new SnapshotProtocol({ ...BASE_CFG, ackTimeoutMs: 15 }); + const start = Date.now(); + await expect(p.requestSnapshot()).rejects.toBeInstanceOf(SnapshotTimeoutError); + const elapsed = Date.now() - start; + expect(elapsed).toBeGreaterThanOrEqual(10); + expect(p.stats().timedOut).toBe(1n); + }); + + it('late ack after timeout does not resolve the original request', async () => { + const p = new SnapshotProtocol({ ...BASE_CFG, ackTimeoutMs: 10 }); + const pending = p.requestSnapshot(); + const req = p.pollRequest()!; + + await expect(pending).rejects.toBeInstanceOf(SnapshotTimeoutError); + + // Publishing late must be a safe no-op (no crash, no spurious fulfillment). + expect(() => + p.publishAck({ + requestId: req.requestId, + epoch: 99n, + numComponents: 0, + pixels: 0, + }), + ).not.toThrow(); + + expect(p.stats().timedOut).toBe(1n); + expect(p.stats().fulfilled).toBe(0n); + }); +}); + +describe('SnapshotProtocol pendingCapacity', () => { + it('rejects requestSnapshot with SnapshotCapacityError past pendingCapacity', async () => { + const p = new SnapshotProtocol({ ...BASE_CFG, pendingCapacity: 2, ackTimeoutMs: 1000 }); + const a = p.requestSnapshot(); + const b = p.requestSnapshot(); + await expect(p.requestSnapshot()).rejects.toBeInstanceOf(SnapshotCapacityError); + + // Drain to avoid hanging timeouts. + fulfil(p, 1n, 0, 0); + fulfil(p, 2n, 0, 0); + await Promise.all([a, b]); + }); + + it('allows a new request once an in-flight one is acked', async () => { + const p = new SnapshotProtocol({ ...BASE_CFG, pendingCapacity: 1, ackTimeoutMs: 1000 }); + const a = p.requestSnapshot(); + fulfil(p, 5n, 2, 10); + await a; + + const b = p.requestSnapshot(); + fulfil(p, 6n, 2, 10); + const rb = await b; + expect(rb.epoch).toBe(6n); + }); +}); + +describe('SnapshotProtocol stats', () => { + it('issued / fulfilled / timedOut counters increase monotonically', async () => { + const p = new SnapshotProtocol({ ...BASE_CFG, pendingCapacity: 2, ackTimeoutMs: 1000 }); + expect(p.stats()).toEqual({ issued: 0n, fulfilled: 0n, timedOut: 0n }); + + const a = p.requestSnapshot(); + expect(p.stats().issued).toBe(1n); + fulfil(p, 1n, 0, 0); + await a; + expect(p.stats().fulfilled).toBe(1n); + expect(p.stats().timedOut).toBe(0n); + + const b = p.requestSnapshot(); + expect(p.stats().issued).toBe(2n); + fulfil(p, 2n, 0, 0); + await b; + expect(p.stats().fulfilled).toBe(2n); + + const tp = new SnapshotProtocol({ ...BASE_CFG, ackTimeoutMs: 5 }); + await expect(tp.requestSnapshot()).rejects.toBeInstanceOf(SnapshotTimeoutError); + expect(tp.stats()).toEqual({ issued: 1n, fulfilled: 0n, timedOut: 1n }); + }); +}); diff --git a/packages/cala-runtime/src/__tests__/events.test.ts b/packages/cala-runtime/src/__tests__/events.test.ts new file mode 100644 index 0000000..3b44b74 --- /dev/null +++ b/packages/cala-runtime/src/__tests__/events.test.ts @@ -0,0 +1,248 @@ +import { describe, it, expect } from 'vitest'; +import { + EventBus, + EventBusSubscriberError, + type EventBusConfig, + type FootprintSnap, + type PipelineEvent, +} from '../events.ts'; + +const BASE_CFG: EventBusConfig = { + capacity: 4, + maxSubscribers: 4, +}; + +function snap(seed: number): FootprintSnap { + return { + pixelIndices: new Uint32Array([seed, seed + 1, seed + 2]), + values: new Float32Array([seed * 0.5, seed * 0.25, seed * 0.125]), + }; +} + +describe('EventBus config validation', () => { + it('rejects non-positive capacity', () => { + expect(() => new EventBus({ ...BASE_CFG, capacity: 0 })).toThrow(/capacity/); + expect(() => new EventBus({ ...BASE_CFG, capacity: -1 })).toThrow(/capacity/); + }); + + it('rejects non-integer capacity', () => { + expect(() => new EventBus({ ...BASE_CFG, capacity: 2.5 })).toThrow(/capacity/); + }); + + it('rejects non-positive maxSubscribers', () => { + expect(() => new EventBus({ ...BASE_CFG, maxSubscribers: 0 })).toThrow(/maxSubscribers/); + expect(() => new EventBus({ ...BASE_CFG, maxSubscribers: -2 })).toThrow(/maxSubscribers/); + }); +}); + +describe('EventBus publish / subscribe for all 6 PipelineEvent kinds', () => { + it('delivers each kind unchanged to a subscriber', () => { + const bus = new EventBus(BASE_CFG); + const received: PipelineEvent[] = []; + bus.subscribe((e) => received.push(e)); + + const birth: PipelineEvent = { + kind: 'birth', + t: 10, + id: 1, + patch: [128, 76], + footprintSnap: snap(1), + }; + const merge: PipelineEvent = { + kind: 'merge', + t: 12, + ids: [2, 3], + into: 4, + footprintSnap: snap(2), + }; + const split: PipelineEvent = { + kind: 'split', + t: 13, + from: 4, + into: [5, 6], + footprintSnaps: [snap(3), snap(4)], + }; + const deprecate: PipelineEvent = { + kind: 'deprecate', + t: 14, + id: 5, + reason: 'traceInactive', + }; + const reject: PipelineEvent = { + kind: 'reject', + t: 15, + at: [64, 32], + reason: 'snr_below_threshold', + }; + const metric: PipelineEvent = { + kind: 'metric', + t: 16, + name: 'residual_l2', + value: 0.0123, + }; + + const all: PipelineEvent[] = [birth, merge, split, deprecate, reject, metric]; + for (const e of all) bus.publish(e); + + expect(received.length).toBe(all.length); + for (let i = 0; i < all.length; i++) { + expect(received[i]).toBe(all[i]); + } + }); +}); + +describe('EventBus multi-subscriber fan-out', () => { + it('every subscriber receives every published event', () => { + const bus = new EventBus(BASE_CFG); + const a: PipelineEvent[] = []; + const b: PipelineEvent[] = []; + const c: PipelineEvent[] = []; + bus.subscribe((e) => a.push(e)); + bus.subscribe((e) => b.push(e)); + bus.subscribe((e) => c.push(e)); + + const e1: PipelineEvent = { kind: 'metric', t: 1, name: 'fps', value: 60 }; + const e2: PipelineEvent = { kind: 'metric', t: 2, name: 'fps', value: 59 }; + bus.publish(e1); + bus.publish(e2); + + expect(a).toEqual([e1, e2]); + expect(b).toEqual([e1, e2]); + expect(c).toEqual([e1, e2]); + }); + + it('unsubscribe stops further delivery to that subscriber only', () => { + const bus = new EventBus(BASE_CFG); + const a: PipelineEvent[] = []; + const b: PipelineEvent[] = []; + const unsubA = bus.subscribe((e) => a.push(e)); + bus.subscribe((e) => b.push(e)); + + const e1: PipelineEvent = { kind: 'metric', t: 1, name: 'fps', value: 30 }; + bus.publish(e1); + unsubA(); + const e2: PipelineEvent = { kind: 'metric', t: 2, name: 'fps', value: 30 }; + bus.publish(e2); + + expect(a).toEqual([e1]); + expect(b).toEqual([e1, e2]); + }); + + it('throws EventBusSubscriberError past maxSubscribers', () => { + const bus = new EventBus({ ...BASE_CFG, maxSubscribers: 2 }); + bus.subscribe(() => {}); + bus.subscribe(() => {}); + expect(() => bus.subscribe(() => {})).toThrow(EventBusSubscriberError); + }); + + it('unsubscribing frees a slot', () => { + const bus = new EventBus({ ...BASE_CFG, maxSubscribers: 2 }); + const u1 = bus.subscribe(() => {}); + bus.subscribe(() => {}); + expect(() => bus.subscribe(() => {})).toThrow(EventBusSubscriberError); + u1(); + expect(() => bus.subscribe(() => {})).not.toThrow(); + }); + + it('calling an unsubscribe twice is a safe no-op', () => { + const bus = new EventBus(BASE_CFG); + const u = bus.subscribe(() => {}); + u(); + expect(() => u()).not.toThrow(); + }); +}); + +describe('EventBus drop-oldest under pressure', () => { + it('drops oldest events when no subscriber drains, drops counter increments', () => { + const cfg: EventBusConfig = { capacity: 4, maxSubscribers: 4 }; + const bus = new EventBus(cfg); + + const total = cfg.capacity + 3; + for (let i = 0; i < total; i++) { + bus.publish({ kind: 'metric', t: i, name: 'fps', value: i }); + } + expect(bus.stats().drops).toBe(3n); + + // Subscribing after the drops does NOT replay buffered events — + // hot stream semantics, history isn't recoverable. We still check + // that drops is queryable and monotonic. + bus.subscribe(() => {}); + expect(bus.stats().drops).toBe(3n); + bus.publish({ kind: 'metric', t: 100, name: 'fps', value: 100 }); + expect(bus.stats().drops).toBe(3n); + }); + + it('published counter increments on every publish regardless of drops', () => { + const bus = new EventBus({ capacity: 2, maxSubscribers: 4 }); + for (let i = 0; i < 5; i++) { + bus.publish({ kind: 'metric', t: i, name: 'fps', value: i }); + } + expect(bus.stats().published).toBe(5n); + expect(bus.stats().drops).toBe(3n); + }); +}); + +describe('EventBus close()', () => { + it('drops all subscribers and subsequent publish is a no-op', () => { + const bus = new EventBus(BASE_CFG); + const received: PipelineEvent[] = []; + bus.subscribe((e) => received.push(e)); + + bus.publish({ kind: 'metric', t: 1, name: 'fps', value: 60 }); + expect(received.length).toBe(1); + + bus.close(); + bus.publish({ kind: 'metric', t: 2, name: 'fps', value: 60 }); + expect(received.length).toBe(1); + }); + + it('publish after close does not count toward published or drops', () => { + const bus = new EventBus(BASE_CFG); + bus.publish({ kind: 'metric', t: 1, name: 'fps', value: 60 }); + const published = bus.stats().published; + bus.close(); + bus.publish({ kind: 'metric', t: 2, name: 'fps', value: 60 }); + expect(bus.stats().published).toBe(published); + }); + + it('re-subscribing after close throws', () => { + const bus = new EventBus(BASE_CFG); + bus.close(); + expect(() => bus.subscribe(() => {})).toThrow(EventBusSubscriberError); + }); +}); + +describe('FootprintSnap byte parity', () => { + it('pixelIndices and values arrive byte-exact at the subscriber', () => { + const bus = new EventBus(BASE_CFG); + const pixels = new Uint32Array([3, 11, 42, 99, 1024]); + const values = new Float32Array([-1.5, 0.0, 0.25, 3.125, -128.5]); + const e: PipelineEvent = { + kind: 'birth', + t: 5, + id: 7, + patch: [8, 8], + footprintSnap: { pixelIndices: pixels, values }, + }; + + let got: PipelineEvent | null = null; + bus.subscribe((ev) => { + got = ev; + }); + bus.publish(e); + + expect(got).not.toBeNull(); + const ev = got as unknown as Extract; + const gotIdx = ev.footprintSnap.pixelIndices; + const gotVals = ev.footprintSnap.values; + + expect(gotIdx.length).toBe(pixels.length); + for (let i = 0; i < pixels.length; i++) { + expect(gotIdx[i]).toBe(pixels[i]); + } + expect(gotVals.length).toBe(values.length); + for (let i = 0; i < values.length; i++) { + expect(gotVals[i]).toBe(values[i]); + } + }); +}); diff --git a/packages/cala-runtime/src/asset-snapshot.ts b/packages/cala-runtime/src/asset-snapshot.ts new file mode 100644 index 0000000..b397aae --- /dev/null +++ b/packages/cala-runtime/src/asset-snapshot.ts @@ -0,0 +1,178 @@ +/** + * Asset snapshot protocol (design §7.2, Phase 5 Task 17). + * + * Extend requests a consistent view of `(Ã, W, M, epoch)`; fit + * publishes it at the next frame boundary with the captured epoch + * stamped into the ack. Each request carries a correlation id so fit + * can service requests out-of-order (useful when a later request + * happens to coincide with a frame boundary sooner than an earlier + * one). + * + * This module is the TS control-layer plumbing only. The real + * SAB-backed request / ack transport lands with the orchestrator in + * Task 18 — the public API here is stable across that swap. + */ + +// TODO(task 18): swap in SAB-backed transport. The public shape +// (`requestSnapshot` / `pollRequest` / `publishAck` / `stats`) stays +// identical; internals become two Atomics-backed control slots. + +/** Payload the extend side receives when fit acks a snapshot request. */ +export interface SnapshotAck { + requestId: number; + epoch: bigint; + numComponents: number; + pixels: number; +} + +/** Metadata the fit side reads off a pending snapshot request. */ +export interface SnapshotRequest { + requestId: number; +} + +/** Running counters surfaced to dashboard metrics. */ +export interface SnapshotProtocolStats { + issued: bigint; + fulfilled: bigint; + timedOut: bigint; +} + +export interface SnapshotProtocolConfig { + /** How long extend waits for fit's ack before giving up. */ + ackTimeoutMs: number; + /** + * How many in-flight snapshot requests are allowed at once. Design + * §7.2 says extend proceeds one snapshot at a time, so the typical + * value is 1; keep it configurable per project convention so tests + * and two-pass mode can raise it. + */ + pendingCapacity: number; + /** Internal timeout-sweep granularity. Must be ≤ ackTimeoutMs. */ + pollIntervalMs: number; +} + +export class SnapshotTimeoutError extends Error { + constructor(message: string) { + super(message); + this.name = 'SnapshotTimeoutError'; + } +} + +export class SnapshotCapacityError extends Error { + constructor(message: string) { + super(message); + this.name = 'SnapshotCapacityError'; + } +} + +interface PendingEntry { + requestId: number; + issuedAtMs: number; + resolve: (ack: SnapshotAck) => void; + reject: (err: Error) => void; + timer: ReturnType; +} + +function validateConfig(cfg: SnapshotProtocolConfig): void { + if (!Number.isFinite(cfg.ackTimeoutMs) || cfg.ackTimeoutMs <= 0) { + throw new Error( + `SnapshotProtocolConfig.ackTimeoutMs must be a positive number (got ${cfg.ackTimeoutMs})`, + ); + } + if (!Number.isInteger(cfg.pendingCapacity) || cfg.pendingCapacity < 1) { + throw new Error( + `SnapshotProtocolConfig.pendingCapacity must be an integer ≥ 1 (got ${cfg.pendingCapacity})`, + ); + } + if (!Number.isFinite(cfg.pollIntervalMs) || cfg.pollIntervalMs <= 0) { + throw new Error( + `SnapshotProtocolConfig.pollIntervalMs must be a positive number (got ${cfg.pollIntervalMs})`, + ); + } +} + +export class SnapshotProtocol { + private readonly cfg: SnapshotProtocolConfig; + private readonly pending = new Map(); + private readonly queue: SnapshotRequest[] = []; + private nextId = 1; + private issuedCount = 0n; + private fulfilledCount = 0n; + private timedOutCount = 0n; + + constructor(cfg: SnapshotProtocolConfig) { + validateConfig(cfg); + this.cfg = cfg; + } + + /** + * Extend side. Resolves with a {@link SnapshotAck} once fit has + * published one; rejects with {@link SnapshotTimeoutError} if no ack + * arrives within `ackTimeoutMs`; rejects with + * {@link SnapshotCapacityError} if `pendingCapacity` would be + * exceeded. + */ + requestSnapshot(): Promise { + if (this.pending.size >= this.cfg.pendingCapacity) { + return Promise.reject( + new SnapshotCapacityError( + `pendingCapacity ${this.cfg.pendingCapacity} exceeded (${this.pending.size} in flight)`, + ), + ); + } + + const requestId = this.nextId++; + this.issuedCount += 1n; + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const entry = this.pending.get(requestId); + if (!entry) return; + this.pending.delete(requestId); + this.timedOutCount += 1n; + entry.reject( + new SnapshotTimeoutError( + `snapshot request ${requestId} timed out after ${this.cfg.ackTimeoutMs}ms`, + ), + ); + }, this.cfg.ackTimeoutMs); + + this.pending.set(requestId, { + requestId, + issuedAtMs: Date.now(), + resolve, + reject, + timer, + }); + this.queue.push({ requestId }); + }); + } + + /** Fit side. Returns the oldest pending request, or `null` if none. */ + pollRequest(): SnapshotRequest | null { + return this.queue.shift() ?? null; + } + + /** + * Fit side. Publishes the result of a snapshot capture. A late ack + * for a request that has already timed out is silently dropped — + * matches the real SAB transport where fit has no way to observe + * extend's timeout. + */ + publishAck(ack: SnapshotAck): void { + const entry = this.pending.get(ack.requestId); + if (!entry) return; + clearTimeout(entry.timer); + this.pending.delete(ack.requestId); + this.fulfilledCount += 1n; + entry.resolve(ack); + } + + stats(): SnapshotProtocolStats { + return { + issued: this.issuedCount, + fulfilled: this.fulfilledCount, + timedOut: this.timedOutCount, + }; + } +} diff --git a/packages/cala-runtime/src/events.ts b/packages/cala-runtime/src/events.ts new file mode 100644 index 0000000..c375822 --- /dev/null +++ b/packages/cala-runtime/src/events.ts @@ -0,0 +1,181 @@ +/** + * Pipeline event bus (design §9.2, Phase 5 Task 17). + * + * Fit publishes compact `PipelineEvent` records; the archive worker + * (and any UI-side debug sinks) subscribes. Drop-oldest under + * pressure — archive is cosmetic, never functional, per §9.2. + * + * This is an in-process fan-out for now. The fit→archive boundary + * crosses workers in Task 18; that swap replaces the internal ring + * with a SAB-backed transport while keeping this public API stable. + */ + +import type { DeprecateReason } from './mutation-queue.ts'; + +/** + * Sparse footprint payload attached to structural events (design §9.3): + * `(pixel_idx, value)` pairs. Typed arrays travel by reference here; + * the SAB-backed transport in Task 18 will copy them into the event + * ring for cross-worker delivery. + */ +export interface FootprintSnap { + pixelIndices: Uint32Array; + values: Float32Array; +} + +/** Tagged union of every event variant the fit worker emits. */ +export type PipelineEvent = + | { + kind: 'birth'; + t: number; + id: number; + patch: [number, number]; + footprintSnap: FootprintSnap; + } + | { + kind: 'merge'; + t: number; + ids: number[]; + into: number; + footprintSnap: FootprintSnap; + } + | { + kind: 'split'; + t: number; + from: number; + into: number[]; + footprintSnaps: FootprintSnap[]; + } + | { + kind: 'deprecate'; + t: number; + id: number; + reason: DeprecateReason; + } + | { + kind: 'reject'; + t: number; + at: [number, number]; + reason: string; + } + | { + kind: 'metric'; + t: number; + name: string; + value: number; + }; + +export type Unsubscribe = () => void; + +export interface EventBusConfig { + /** Drop-oldest ring size. Events past this are discarded + counted. */ + capacity: number; + /** Hard cap on concurrent subscribers. */ + maxSubscribers: number; +} + +export interface EventBusStats { + published: bigint; + delivered: bigint; + drops: bigint; + subscribers: number; +} + +export class EventBusSubscriberError extends Error { + constructor(message: string) { + super(message); + this.name = 'EventBusSubscriberError'; + } +} + +function validateConfig(cfg: EventBusConfig): void { + if (!Number.isInteger(cfg.capacity) || cfg.capacity < 1) { + throw new Error(`EventBusConfig.capacity must be an integer ≥ 1 (got ${cfg.capacity})`); + } + if (!Number.isInteger(cfg.maxSubscribers) || cfg.maxSubscribers < 1) { + throw new Error( + `EventBusConfig.maxSubscribers must be an integer ≥ 1 (got ${cfg.maxSubscribers})`, + ); + } +} + +type Listener = (e: PipelineEvent) => void; + +export class EventBus { + private readonly cfg: EventBusConfig; + private readonly subscribers = new Set(); + private readonly buffer: PipelineEvent[] = []; + private publishedCount = 0n; + private deliveredCount = 0n; + private dropCount = 0n; + private closed = false; + + constructor(cfg: EventBusConfig) { + validateConfig(cfg); + this.cfg = cfg; + } + + /** + * Fit side. Fan-out to all subscribers synchronously. If no + * subscriber drains, the internal ring fills and starts dropping + * oldest. Once closed, `publish` is a no-op. + */ + publish(e: PipelineEvent): void { + if (this.closed) return; + this.publishedCount += 1n; + + if (this.subscribers.size > 0) { + // Hot stream: live subscribers get the event directly; no + // buffering needed. Drop counter stays untouched. + for (const cb of this.subscribers) { + cb(e); + this.deliveredCount += 1n; + } + return; + } + + // No subscribers yet — buffer into the drop-oldest ring so + // `stats().drops` reflects backpressure. + if (this.buffer.length === this.cfg.capacity) { + this.buffer.shift(); + this.dropCount += 1n; + } + this.buffer.push(e); + } + + /** + * Archive / main-thread side. Callback is invoked for every future + * `publish`. Buffered events (from before any subscriber existed) + * are NOT replayed — the bus is a hot stream per §9.2. Returns an + * unsubscribe handle that is safe to call more than once. + */ + subscribe(cb: Listener): Unsubscribe { + if (this.closed) { + throw new EventBusSubscriberError('cannot subscribe to a closed EventBus'); + } + if (this.subscribers.size >= this.cfg.maxSubscribers) { + throw new EventBusSubscriberError( + `maxSubscribers ${this.cfg.maxSubscribers} reached (${this.subscribers.size} active)`, + ); + } + this.subscribers.add(cb); + return () => { + this.subscribers.delete(cb); + }; + } + + stats(): EventBusStats { + return { + published: this.publishedCount, + delivered: this.deliveredCount, + drops: this.dropCount, + subscribers: this.subscribers.size, + }; + } + + /** Drops all subscribers and renders further `publish` calls inert. */ + close(): void { + this.closed = true; + this.subscribers.clear(); + } +} diff --git a/packages/cala-runtime/src/index.ts b/packages/cala-runtime/src/index.ts index ed7c84e..4f3e098 100644 --- a/packages/cala-runtime/src/index.ts +++ b/packages/cala-runtime/src/index.ts @@ -8,5 +8,20 @@ export type { Epoch, MutationQueueConfig, } from './mutation-queue.ts'; +export { SnapshotProtocol, SnapshotTimeoutError, SnapshotCapacityError } from './asset-snapshot.ts'; +export type { + SnapshotAck, + SnapshotRequest, + SnapshotProtocolConfig, + SnapshotProtocolStats, +} from './asset-snapshot.ts'; +export { EventBus, EventBusSubscriberError } from './events.ts'; +export type { + PipelineEvent, + FootprintSnap, + EventBusConfig, + EventBusStats, + Unsubscribe, +} from './events.ts'; // Surface stubs for modules that land in later tasks — see types.ts TODOs. -export type { Snapshot, PipelineEvent, Orchestrator, Todo } from './types.ts'; +export type { Orchestrator, Todo } from './types.ts'; diff --git a/packages/cala-runtime/src/types.ts b/packages/cala-runtime/src/types.ts index c7d5a97..0ff62e7 100644 --- a/packages/cala-runtime/src/types.ts +++ b/packages/cala-runtime/src/types.ts @@ -41,14 +41,29 @@ export { type MutationQueueConfig, } from './mutation-queue.ts'; -// TODO(task 17): Snapshot surface — copy-on-write asset view protocol that -// gives the extend worker a consistent `A, W, M` at an epoch boundary. -// See CALA_DESIGN §7.2. -export type Snapshot = Todo<'Snapshot'>; - -// TODO(task 17): PipelineEvent surface — compact event records emitted by -// fit for the archive worker. See CALA_DESIGN §9.2. -export type PipelineEvent = Todo<'PipelineEvent'>; +// Snapshot protocol surface — extend→fit control channel for +// consistent views of `(Ã, W, M, epoch)`. See CALA_DESIGN §7.2. +export { + SnapshotProtocol, + SnapshotTimeoutError, + SnapshotCapacityError, + type SnapshotAck, + type SnapshotRequest, + type SnapshotProtocolConfig, + type SnapshotProtocolStats, +} from './asset-snapshot.ts'; + +// PipelineEvent surface — compact event records emitted by fit for +// the archive worker. See CALA_DESIGN §9.2. +export { + EventBus, + EventBusSubscriberError, + type PipelineEvent, + type FootprintSnap, + type EventBusConfig, + type EventBusStats, + type Unsubscribe, +} from './events.ts'; // TODO(task 18): Orchestrator surface — creates workers, wires channels, // tracks epochs, owns two-pass toggle. See CALA_DESIGN §7. From 6855dbd914ccf15614dd28d714fc494218884259 Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 18 Apr 2026 13:00:55 -0700 Subject: [PATCH 07/17] feat(cala): scaffold apps/cala with WASM + COOP/COEP headers (task 19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copies the app-template structure into apps/cala and wires in every dependency the CaLa workers will need: @calab/{cala-core, cala-runtime, compute, core, io, ui} aliases in vite and tsconfig, vite-plugin-wasm on both the main and worker plugin chains. - vite.config.ts sets `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: require-corp` on the dev and preview servers so SharedArrayBuffer is available for the SAB-backed runtime channels landed in tasks 15-17. Addresses design §13's "test COOP/COEP early before it blocks UI work." - `scripts/verify-sab.mjs` — smoke check that boots Vite, fetches `/`, and asserts both headers are set to the expected values. Runnable via `npm run verify-sab -w apps/cala`. All configurable values (timeouts, header names, expected values) are const-named at the top — no literals scattered through the fetch path. - README calls out the GitHub Pages limitation: the host does not support custom response headers, so the production SAB story is a coi-serviceworker deliverable for Phase 6+. Phase 5 exit only requires local dev end-to-end. - `apps/cala` added to the root `typecheck` target; eslint rule for Node globals in scripts now covers `apps/*/scripts/**/*`. `npm run dev -w apps/cala` boots the placeholder shell; `npm run verify-sab -w apps/cala` confirms SAB headers are live; `npm run build -w apps/cala` produces a 172 KB (45 KB gzipped) bundle. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cala/README.md | 58 +++++++++++++++++++ apps/cala/index.html | 18 ++++++ apps/cala/package.json | 34 +++++++++++ apps/cala/scripts/verify-sab.mjs | 98 ++++++++++++++++++++++++++++++++ apps/cala/src/App.tsx | 18 ++++++ apps/cala/src/index.tsx | 6 ++ apps/cala/src/styles/global.css | 1 + apps/cala/src/vite-env.d.ts | 1 + apps/cala/tsconfig.json | 32 +++++++++++ apps/cala/vite.config.ts | 56 ++++++++++++++++++ eslint.config.js | 4 +- package-lock.json | 16 ++++++ package.json | 2 +- 13 files changed, 341 insertions(+), 3 deletions(-) create mode 100644 apps/cala/README.md create mode 100644 apps/cala/index.html create mode 100644 apps/cala/package.json create mode 100644 apps/cala/scripts/verify-sab.mjs create mode 100644 apps/cala/src/App.tsx create mode 100644 apps/cala/src/index.tsx create mode 100644 apps/cala/src/styles/global.css create mode 100644 apps/cala/src/vite-env.d.ts create mode 100644 apps/cala/tsconfig.json create mode 100644 apps/cala/vite.config.ts diff --git a/apps/cala/README.md b/apps/cala/README.md new file mode 100644 index 0000000..9e0a147 --- /dev/null +++ b/apps/cala/README.md @@ -0,0 +1,58 @@ +# CaLa + +Streaming calcium imaging demixing. Browser-native OMF pipeline port of Raymond Chang's [`cala`](https://github.com/raymondchang-ucla/cala) reference — streaming preprocess + fit + extend loops, backed by the Rust numerical core in `crates/cala-core`. + +## Status + +**Coming soon** — scaffolded in Phase 5, functional build lands at Phase 5 exit (task 25). See `.planning/CALA_DESIGN.md` for the full design. + +## Dev + +``` +npm run dev -w apps/cala # starts Vite with COOP/COEP headers set +npm run verify-sab -w apps/cala # boots Vite, asserts SAB headers live +``` + +`SharedArrayBuffer` (used by the worker runtime for SAB-backed channels, mutation queue, and event bus) needs cross-origin isolation: + +- `Cross-Origin-Opener-Policy: same-origin` +- `Cross-Origin-Embedder-Policy: require-corp` + +The Vite dev server and preview server set these headers via `vite.config.ts`. If `SharedArrayBuffer` is undefined in the page, inspect response headers — the most common cause is serving through a proxy that strips them. + +## Production deploy (GitHub Pages) + +GitHub Pages does not support custom response headers. That means **`SharedArrayBuffer` won't work on the production Pages deploy as-is**. Two paths are available when the app goes live: + +1. **Cross-origin-isolation service worker** (`coi-serviceworker` pattern) — the service worker intercepts `fetch` and injects the COOP/COEP headers. Works on GitHub Pages without host changes. Planned for Phase 6+ when SAB-using UI code actually ships to production. +2. **Alternative host** (Netlify, Cloudflare Pages) that honors a `_headers` file or equivalent. Requires deployment pipeline changes in `scripts/combine-dist.mjs` + `.github/workflows/deploy.yml`. + +Phase 5 exit (task 25) only requires local dev to work end-to-end. The production SAB story is a separate deliverable that doesn't block Phase 5. + +## Layout + +``` +apps/cala/ +├── index.html +├── package.json # @calab/* workspace deps +├── vite.config.ts # path aliases, WASM plugin, COOP/COEP headers +├── tsconfig.json +├── scripts/ +│ └── verify-sab.mjs # smoke check for COOP/COEP header delivery +└── src/ + ├── App.tsx # placeholder shell — components land in tasks 20-24 + ├── index.tsx + ├── styles/global.css + └── vite-env.d.ts +``` + +Per-task layout expansions: + +| Task | Adds | +| ---- | -------------------------------------------------------------------------------------------------------------------- | +| 20 | `lib/data-store.ts`, `lib/run-control.ts`, `components/layout/ImportOverlay.tsx`, `components/layout/CaLaHeader.tsx` | +| 21 | `workers/decode-preprocess.worker.ts` | +| 22 | `workers/fit.worker.ts` | +| 23 | `workers/extend.worker.ts`, `workers/archive.worker.ts` | +| 24 | `components/frame/SingleFrameViewer.tsx`, `lib/archive-client.ts`, `lib/dashboard-store.ts` | +| 25 | Phase 5 exit E2E on a real AVI | diff --git a/apps/cala/index.html b/apps/cala/index.html new file mode 100644 index 0000000..327d865 --- /dev/null +++ b/apps/cala/index.html @@ -0,0 +1,18 @@ + + + + + + CaLa — Streaming Calcium Demixing + + + + + +
+ + + diff --git a/apps/cala/package.json b/apps/cala/package.json new file mode 100644 index 0000000..204a3ce --- /dev/null +++ b/apps/cala/package.json @@ -0,0 +1,34 @@ +{ + "name": "cala", + "private": true, + "version": "0.0.1", + "type": "module", + "calab": { + "displayName": "CaLa", + "description": "Streaming calcium imaging demixing", + "longDescription": "Stream calcium imaging recordings through a fully online demixing pipeline — preprocess, fit, and extend loops run continuously from the first frame. Watch demixing happen in real time in the browser.", + "features": [ + "Browser-native streaming OMF on local AVI files", + "Four-worker runtime with SAB-backed channels", + "Rust/WASM numerical core, SolidJS dashboard", + "Two-pass mode for refined replay" + ], + "status": "coming-soon", + "hidden": true + }, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "verify-sab": "node scripts/verify-sab.mjs" + }, + "dependencies": { + "@calab/cala-core": "*", + "@calab/cala-runtime": "*", + "@calab/compute": "*", + "@calab/core": "*", + "@calab/io": "*", + "@calab/ui": "*", + "solid-js": "^1.9.11" + } +} diff --git a/apps/cala/scripts/verify-sab.mjs b/apps/cala/scripts/verify-sab.mjs new file mode 100644 index 0000000..fe8d82b --- /dev/null +++ b/apps/cala/scripts/verify-sab.mjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node +// Boot the Vite dev server, verify that SharedArrayBuffer is usable in +// the served page (the COOP/COEP headers are working), then shut the +// server down and exit 0. Used as a smoke check for design §13's +// requirement that cross-origin isolation is in place before any +// SAB-using worker code lands. +// +// No-deps implementation: start Vite, fetch `/`, check the response +// headers for `cross-origin-opener-policy: same-origin` and +// `cross-origin-embedder-policy: require-corp`. We don't evaluate the +// page — just assert the headers the browser needs are present. + +import { spawn } from 'node:child_process'; +import { resolve } from 'node:path'; + +const HEADER_COOP = 'cross-origin-opener-policy'; +const HEADER_COEP = 'cross-origin-embedder-policy'; +const EXPECTED_COOP = 'same-origin'; +const EXPECTED_COEP = 'require-corp'; +const STARTUP_TIMEOUT_MS = 15_000; +const FETCH_TIMEOUT_MS = 5_000; + +const appDir = resolve(import.meta.dirname, '..'); + +function parseVitePort(stdoutLine) { + // Vite prints: " ➜ Local: http://localhost:5173/" + const match = stdoutLine.match(/http:\/\/localhost:(\d+)/); + return match ? Number(match[1]) : null; +} + +const vite = spawn('npx', ['vite', '--port', '0'], { + cwd: appDir, + stdio: ['ignore', 'pipe', 'pipe'], +}); + +let port = null; +const startup = new Promise((resolvePort, rejectPort) => { + const to = setTimeout( + () => rejectPort(new Error(`vite did not print a local URL within ${STARTUP_TIMEOUT_MS} ms`)), + STARTUP_TIMEOUT_MS, + ); + vite.stdout.on('data', (chunk) => { + const text = chunk.toString(); + process.stdout.write(`[vite] ${text}`); + for (const line of text.split('\n')) { + const p = parseVitePort(line); + if (p !== null && port === null) { + port = p; + clearTimeout(to); + resolvePort(p); + } + } + }); + vite.stderr.on('data', (chunk) => process.stderr.write(`[vite-err] ${chunk}`)); + vite.on('exit', (code) => { + if (port === null) { + clearTimeout(to); + rejectPort(new Error(`vite exited with code ${code} before reporting a port`)); + } + }); +}); + +function shutdown(code) { + vite.kill('SIGTERM'); + process.exit(code); +} + +try { + await startup; + // Small delay: the URL is logged right before the server accepts + // connections. A one-shot timeout avoids racing that window. + await new Promise((r) => setTimeout(r, 250)); + + const controller = new AbortController(); + const ft = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + const res = await fetch(`http://localhost:${port}/`, { signal: controller.signal }); + clearTimeout(ft); + + const coop = res.headers.get(HEADER_COOP); + const coep = res.headers.get(HEADER_COEP); + + if (coop !== EXPECTED_COOP) { + console.error(`[verify-sab] expected ${HEADER_COOP}=${EXPECTED_COOP}, got ${coop}`); + shutdown(1); + } + if (coep !== EXPECTED_COEP) { + console.error(`[verify-sab] expected ${HEADER_COEP}=${EXPECTED_COEP}, got ${coep}`); + shutdown(1); + } + + console.log( + '[verify-sab] COOP/COEP headers present on dev server — SharedArrayBuffer will be available.', + ); + shutdown(0); +} catch (e) { + console.error('[verify-sab] failed:', e); + shutdown(1); +} diff --git a/apps/cala/src/App.tsx b/apps/cala/src/App.tsx new file mode 100644 index 0000000..227bdfc --- /dev/null +++ b/apps/cala/src/App.tsx @@ -0,0 +1,18 @@ +import type { Component } from 'solid-js'; +import { DashboardShell, CompactHeader } from '@calab/ui'; + +const App: Component = () => { + return ( + }> +
+

CaLa — streaming calcium imaging demixing.

+

+ Shell scaffolded in Phase 5, task 19. File drop, run control, and workers land in + subsequent tasks. +

+
+
+ ); +}; + +export default App; diff --git a/apps/cala/src/index.tsx b/apps/cala/src/index.tsx new file mode 100644 index 0000000..e6b6577 --- /dev/null +++ b/apps/cala/src/index.tsx @@ -0,0 +1,6 @@ +import { render } from 'solid-js/web'; +import App from './App.tsx'; +import '@calab/ui/styles/base.css'; +import './styles/global.css'; + +render(() => , document.getElementById('root')!); diff --git a/apps/cala/src/styles/global.css b/apps/cala/src/styles/global.css new file mode 100644 index 0000000..09af588 --- /dev/null +++ b/apps/cala/src/styles/global.css @@ -0,0 +1 @@ +/* CaLa app-specific styles. Design tokens inherit from @calab/ui. */ diff --git a/apps/cala/src/vite-env.d.ts b/apps/cala/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/apps/cala/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/cala/tsconfig.json b/apps/cala/tsconfig.json new file mode 100644 index 0000000..a2a3938 --- /dev/null +++ b/apps/cala/tsconfig.json @@ -0,0 +1,32 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", + "baseUrl": ".", + "paths": { + "@calab/cala-core": ["../../packages/cala-core/src/index.ts"], + "@calab/cala-core/*": ["../../packages/cala-core/src/*"], + "@calab/cala-runtime": ["../../packages/cala-runtime/src/index.ts"], + "@calab/cala-runtime/*": ["../../packages/cala-runtime/src/*"], + "@calab/compute": ["../../packages/compute/src/index.ts"], + "@calab/compute/*": ["../../packages/compute/src/*"], + "@calab/core": ["../../packages/core/src/index.ts"], + "@calab/core/*": ["../../packages/core/src/*"], + "@calab/io": ["../../packages/io/src/index.ts"], + "@calab/io/*": ["../../packages/io/src/*"], + "@calab/ui": ["../../packages/ui/src/index.ts"], + "@calab/ui/*": ["../../packages/ui/src/*"] + } + }, + "include": ["src"], + "references": [ + { "path": "../../packages/cala-core" }, + { "path": "../../packages/cala-runtime" }, + { "path": "../../packages/compute" }, + { "path": "../../packages/core" }, + { "path": "../../packages/io" }, + { "path": "../../packages/ui" } + ] +} diff --git a/apps/cala/vite.config.ts b/apps/cala/vite.config.ts new file mode 100644 index 0000000..bbd9d56 --- /dev/null +++ b/apps/cala/vite.config.ts @@ -0,0 +1,56 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { defineConfig } from 'vite'; +import solidPlugin from 'vite-plugin-solid'; +import wasm from 'vite-plugin-wasm'; + +const repoRoot = path.resolve(import.meta.dirname, '../..'); +const pkg = JSON.parse(readFileSync(path.resolve(import.meta.dirname, 'package.json'), 'utf-8')); +const displayName = pkg.calab?.displayName ?? path.basename(import.meta.dirname); + +// SharedArrayBuffer (design §13) requires cross-origin isolation: +// - Cross-Origin-Opener-Policy: same-origin +// - Cross-Origin-Embedder-Policy: require-corp +// The Vite dev and preview servers set these directly. For the GitHub +// Pages production deploy, the host doesn't let us set HTTP headers; +// we document that constraint in apps/cala/README.md and plan to ship +// a cross-origin-isolation service worker (coi-serviceworker pattern) +// when the browser app actually needs SAB in production. Phase 5's +// exit criteria only require `npm run dev` to boot with SAB enabled. +const crossOriginIsolation = { + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', +}; + +export default defineConfig({ + resolve: { + alias: { + '@calab/cala-core': path.resolve(repoRoot, 'packages/cala-core/src'), + '@calab/cala-runtime': path.resolve(repoRoot, 'packages/cala-runtime/src'), + '@calab/compute': path.resolve(repoRoot, 'packages/compute/src'), + '@calab/core': path.resolve(repoRoot, 'packages/core/src'), + '@calab/io': path.resolve(repoRoot, 'packages/io/src'), + '@calab/ui': path.resolve(repoRoot, 'packages/ui/src'), + }, + }, + envDir: repoRoot, + base: process.env.GITHUB_ACTIONS + ? `/CaLab/${displayName}/` + : process.env.CALAB_PAGES + ? `/${displayName}/` + : '/', + server: { + headers: crossOriginIsolation, + }, + preview: { + headers: crossOriginIsolation, + }, + plugins: [solidPlugin(), wasm()], + worker: { + plugins: () => [wasm()], + format: 'es', + }, + build: { + target: 'esnext', + }, +}); diff --git a/eslint.config.js b/eslint.config.js index e16b709..55f2664 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -57,9 +57,9 @@ export default tseslint.config( }, }, - // Node globals for build scripts + // Node globals for build scripts (root-level + per-app). { - files: ['scripts/**/*.{js,mjs,cjs,ts}'], + files: ['scripts/**/*.{js,mjs,cjs,ts}', 'apps/*/scripts/**/*.{js,mjs,cjs,ts}'], languageOptions: { globals: { ...globals.node, diff --git a/package-lock.json b/package-lock.json index 8f16419..1ff2018 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,6 +59,18 @@ "uplot": "^1.6.32" } }, + "apps/cala": { + "version": "0.0.1", + "dependencies": { + "@calab/cala-core": "*", + "@calab/cala-runtime": "*", + "@calab/compute": "*", + "@calab/core": "*", + "@calab/io": "*", + "@calab/ui": "*", + "solid-js": "^1.9.11" + } + }, "apps/carank": { "version": "0.0.1", "dependencies": { @@ -2452,6 +2464,10 @@ "resolved": "apps/cadecon", "link": true }, + "node_modules/cala": { + "resolved": "apps/cala", + "link": true + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", diff --git a/package.json b/package.json index cc06837..c89b11f 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "test:watch": "npm run test:watch -w apps/catune", "lint": "eslint apps/ packages/ scripts/", "lint:fix": "eslint --fix apps/ packages/ scripts/", - "typecheck": "tsc -b apps/catune apps/carank apps/admin apps/cadecon apps/_template", + "typecheck": "tsc -b apps/catune apps/carank apps/admin apps/cadecon apps/cala apps/_template", "format": "prettier --write .", "format:check": "prettier --check ." }, From ef710b719d593d1cc4f785ab1d046777bbb62ed2 Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 18 Apr 2026 13:08:37 -0700 Subject: [PATCH 08/17] feat(cala-runtime): add runtime orchestrator (task 18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ties channels, mutation queue, snapshot protocol, and event bus together into a single RuntimeController the app layer drives. - `createRuntime(cfg)` spawns four workers via caller-provided factories (decode-preprocess, fit, extend, archive), wires SAB channels between them, and waits for all four to ack `ready` within `startupTimeoutMs`. - Epoch tracker owned by the orchestrator — increments only on mutation-applied acks from fit, matching the Rust `FitPipeline::epoch` semantics (frame-processed does not bump). - Lifecycle states `idle -> starting -> running -> stopping -> stopped` with `error` as the terminal state on any spawn / timeout / worker-crash; `onStatus` + `onEvent` subscription APIs. - Stats aggregator pulls every drop counter (frame channel full, mutation queue overflow, event bus drop-oldest, snapshot ack timeouts) from the underlying modules so the dashboard can render them. - `worker-protocol.ts` codifies the orchestrator-worker message union so workers in tasks 21-23 import the types directly. - Tests (section 4.1) use a fake-worker harness — no real Worker instances — covering ready-handshake timeout, lifecycle transitions, epoch monotonicity + exact-once-per-mutation semantics, stats aggregation, graceful + hard shutdown paths, and onEvent subscription. Phase 5 runtime surface is now complete; workers plug into this API in tasks 21-23. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cala-runtime/README.md | 18 +- .../src/__tests__/orchestrator.test.ts | 498 ++++++++++++++++++ packages/cala-runtime/src/index.ts | 24 +- packages/cala-runtime/src/orchestrator.ts | 456 ++++++++++++++++ packages/cala-runtime/src/types.ts | 26 +- packages/cala-runtime/src/worker-protocol.ts | 71 +++ 6 files changed, 1081 insertions(+), 12 deletions(-) create mode 100644 packages/cala-runtime/src/__tests__/orchestrator.test.ts create mode 100644 packages/cala-runtime/src/orchestrator.ts create mode 100644 packages/cala-runtime/src/worker-protocol.ts diff --git a/packages/cala-runtime/README.md b/packages/cala-runtime/README.md index a5dd115..e23b002 100644 --- a/packages/cala-runtime/README.md +++ b/packages/cala-runtime/README.md @@ -13,14 +13,20 @@ design, mutation queue protocol, asset snapshot protocol. - `channel.ts` — SAB-backed single-producer/single-consumer ring for frame data (decoder → fit, fit → extend). [landed, task 15] - `mutation-queue.ts` — bounded drop-oldest ring (extend → fit). - [landed, task 16] Single-threaded TS port of the Rust `MutationQueue`; - cross-worker SAB-backed version lands with the orchestrator (task 18). + [landed, task 16] Single-threaded TS port of the Rust `MutationQueue`. - `asset-snapshot.ts` — extend↔fit snapshot request/ack protocol with correlation ids and ack-timeout diagnostics. [landed, task 17] - Single-threaded in-memory transport; cross-worker SAB-backed version - lands with the orchestrator (task 18). - `events.ts` — `PipelineEvent` bus (birth / merge / split / deprecate / reject / metric) with drop-oldest backpressure, consumed by the archive worker. [landed, task 17] -- `orchestrator.ts` — spawns workers, wires channels, tracks epochs, - owns two-pass toggle. [later task 18] +- `worker-protocol.ts` — orchestrator↔worker message union imported by + the four worker bootstraps (tasks 21-23). [landed, task 18] +- `orchestrator.ts` — `createRuntime(cfg)` spawns the four workers via + caller-provided factories, wires channels, owns the epoch counter, + and exposes `RuntimeController` (run/stop/state/onStatus/onEvent/ + epoch/stats) to the app layer. Two-pass replay is scaffolded in the + config shape but deferred to Phase 7. [landed, task 18] + +Phase 5 runtime surface is complete: the four-worker bootstrap and the +`apps/cala` run-control layer in tasks 20-23 consume this package +unchanged. diff --git a/packages/cala-runtime/src/__tests__/orchestrator.test.ts b/packages/cala-runtime/src/__tests__/orchestrator.test.ts new file mode 100644 index 0000000..17bd75d --- /dev/null +++ b/packages/cala-runtime/src/__tests__/orchestrator.test.ts @@ -0,0 +1,498 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + createRuntime, + RuntimeStartupTimeoutError, + RuntimeShutdownTimeoutError, + type RuntimeConfig, + type RuntimeController, + type RuntimeSource, + type RuntimeState, + type RuntimeStatus, +} from '../orchestrator.ts'; +import type { PipelineEvent, EventBusConfig } from '../events.ts'; +import type { ChannelConfig } from '../types.ts'; +import type { MutationQueueConfig } from '../mutation-queue.ts'; +import type { SnapshotProtocolConfig } from '../asset-snapshot.ts'; +import type { + WorkerFactory, + WorkerInbound, + WorkerLike, + WorkerOutbound, + WorkerRole, +} from '../worker-protocol.ts'; + +// --------------------------------------------------------------------------- +// Fake-worker harness. Captures every postMessage the orchestrator sends and +// exposes `push()` so tests script-drive inbound messages without ever +// spinning a real `Worker`. +// --------------------------------------------------------------------------- +class FakeWorker implements WorkerLike { + public readonly posted: WorkerInbound[] = []; + public terminated = false; + private readonly listeners = new Set<(ev: { data: WorkerOutbound }) => void>(); + + constructor(public readonly role: WorkerRole) {} + + postMessage(message: WorkerInbound): void { + this.posted.push(message); + } + + addEventListener(_type: 'message', listener: (ev: { data: WorkerOutbound }) => void): void { + this.listeners.add(listener); + } + + removeEventListener(_type: 'message', listener: (ev: { data: WorkerOutbound }) => void): void { + this.listeners.delete(listener); + } + + terminate(): void { + this.terminated = true; + this.listeners.clear(); + } + + push(msg: WorkerOutbound): void { + for (const l of [...this.listeners]) l({ data: msg }); + } +} + +class Harness { + readonly workers = new Map(); + + factories(): Record { + const make = + (role: WorkerRole): WorkerFactory => + () => { + const w = new FakeWorker(role); + this.workers.set(role, w); + return w; + }; + return { + decodePreprocess: make('decodePreprocess'), + fit: make('fit'), + extend: make('extend'), + archive: make('archive'), + }; + } + + get(role: WorkerRole): FakeWorker { + const w = this.workers.get(role); + if (!w) throw new Error(`worker ${role} not spawned`); + return w; + } + + pushReadyAll(): void { + for (const [role, worker] of this.workers) { + worker.push({ kind: 'ready', role }); + } + } + + pushDoneAll(): void { + for (const [role, worker] of this.workers) { + worker.push({ kind: 'done', role }); + } + } +} + +const FRAME_CHANNEL: ChannelConfig = { + slotBytes: 64, + slotCount: 4, + waitTimeoutMs: 50, + pollIntervalMs: 1, +}; +const RESIDUAL_CHANNEL: ChannelConfig = { + slotBytes: 64, + slotCount: 4, + waitTimeoutMs: 50, + pollIntervalMs: 1, +}; +const MUTATION_QUEUE: MutationQueueConfig = { capacity: 8 }; +const SNAPSHOT_PROTOCOL: SnapshotProtocolConfig = { + ackTimeoutMs: 100, + pendingCapacity: 1, + pollIntervalMs: 1, +}; +const EVENT_BUS: EventBusConfig = { capacity: 16, maxSubscribers: 4 }; + +function makeCfg(harness: Harness, overrides?: Partial): RuntimeConfig { + return { + workerFactories: harness.factories(), + frameChannel: FRAME_CHANNEL, + residualChannel: RESIDUAL_CHANNEL, + mutationQueue: MUTATION_QUEUE, + snapshotProtocol: SNAPSHOT_PROTOCOL, + eventBus: EVENT_BUS, + startupTimeoutMs: 50, + shutdownTimeoutMs: 50, + ...overrides, + }; +} + +function fakeSource(): RuntimeSource { + return { + kind: 'file', + file: new File([new Uint8Array(4)], 'fake.avi'), + frameSourceFactory: async () => null, + }; +} + +// Flushes pending microtasks so the orchestrator can observe queued +// ready-handshake + transition side-effects before the test continues. +async function flush(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe('createRuntime config validation', () => { + it('rejects non-function workerFactories entries', () => { + const harness = new Harness(); + const base = makeCfg(harness); + expect(() => + createRuntime({ + ...base, + workerFactories: { + ...base.workerFactories, + fit: undefined as unknown as WorkerFactory, + }, + }), + ).toThrow(/workerFactories\.fit/); + }); + + it('rejects non-positive startupTimeoutMs', () => { + const harness = new Harness(); + expect(() => createRuntime(makeCfg(harness, { startupTimeoutMs: 0 }))).toThrow( + /startupTimeoutMs/, + ); + }); + + it('rejects non-positive shutdownTimeoutMs', () => { + const harness = new Harness(); + expect(() => createRuntime(makeCfg(harness, { shutdownTimeoutMs: -1 }))).toThrow( + /shutdownTimeoutMs/, + ); + }); +}); + +describe('startup handshake', () => { + it('posts init to all four workers on run()', async () => { + const harness = new Harness(); + const rt = createRuntime(makeCfg(harness)); + const runP = rt.run(fakeSource()); + + // Each worker was spawned and received an init message. + await flush(); + for (const role of ['decodePreprocess', 'fit', 'extend', 'archive'] as const) { + const w = harness.get(role); + expect(w.posted.length).toBeGreaterThanOrEqual(1); + expect(w.posted[0].kind).toBe('init'); + } + + harness.pushReadyAll(); + await flush(); + // All four received `run` after readies. + for (const role of ['decodePreprocess', 'fit', 'extend', 'archive'] as const) { + expect(harness.get(role).posted.some((m) => m.kind === 'run')).toBe(true); + } + + harness.pushDoneAll(); + await runP; + }); + + it('rejects with RuntimeStartupTimeoutError when any worker never acks ready', async () => { + const harness = new Harness(); + const rt = createRuntime(makeCfg(harness, { startupTimeoutMs: 20 })); + + const runP = rt.run(fakeSource()); + // Only three workers reply ready; the fourth (extend) stays silent. + await flush(); + harness.get('decodePreprocess').push({ kind: 'ready', role: 'decodePreprocess' }); + harness.get('fit').push({ kind: 'ready', role: 'fit' }); + harness.get('archive').push({ kind: 'ready', role: 'archive' }); + + await expect(runP).rejects.toBeInstanceOf(RuntimeStartupTimeoutError); + expect(rt.state()).toBe('error'); + // Every spawned worker was hard-terminated on the failure path. + for (const w of harness.workers.values()) { + expect(w.terminated).toBe(true); + } + }); +}); + +describe('lifecycle transitions', () => { + it('idle → starting → running → stopping → stopped, observed by onStatus', async () => { + const harness = new Harness(); + const rt = createRuntime(makeCfg(harness)); + const states: RuntimeState[] = []; + rt.onStatus((s) => { + if (states[states.length - 1] !== s.state) states.push(s.state); + }); + + expect(rt.state()).toBe('idle'); + const runP = rt.run(fakeSource()); + await flush(); + expect(rt.state()).toBe('starting'); + + harness.pushReadyAll(); + await flush(); + expect(rt.state()).toBe('running'); + + const stopP = rt.stop(); + expect(rt.state()).toBe('stopping'); + + harness.pushDoneAll(); + await stopP; + await runP; + expect(rt.state()).toBe('stopped'); + + expect(states).toEqual(['starting', 'running', 'stopping', 'stopped']); + }); +}); + +describe('epoch tracking', () => { + async function bootRunning(): Promise<{ rt: RuntimeController; harness: Harness }> { + const harness = new Harness(); + const rt = createRuntime(makeCfg(harness)); + const runP = rt.run(fakeSource()); + await flush(); + harness.pushReadyAll(); + await flush(); + // Attach the run promise to rt so it can be awaited via stop() later. + (rt as unknown as { __runP: Promise }).__runP = runP; + return { rt, harness }; + } + + it('starts at 0n', async () => { + const { rt, harness } = await bootRunning(); + expect(rt.epoch()).toBe(0n); + harness.pushDoneAll(); + await (rt as unknown as { __runP: Promise }).__runP; + }); + + it('frame-processed does NOT advance epoch', async () => { + const { rt, harness } = await bootRunning(); + const fit = harness.get('fit'); + fit.push({ kind: 'frame-processed', role: 'fit', index: 0, epoch: 0n }); + fit.push({ kind: 'frame-processed', role: 'fit', index: 1, epoch: 0n }); + fit.push({ kind: 'frame-processed', role: 'fit', index: 2, epoch: 0n }); + expect(rt.epoch()).toBe(0n); + expect(rt.stats().framesProcessed).toBe(3); + harness.pushDoneAll(); + await (rt as unknown as { __runP: Promise }).__runP; + }); + + it('advances exactly once per mutation-applied', async () => { + const { rt, harness } = await bootRunning(); + const fit = harness.get('fit'); + fit.push({ kind: 'mutation-applied', role: 'fit', epoch: 1n }); + expect(rt.epoch()).toBe(1n); + fit.push({ kind: 'mutation-applied', role: 'fit', epoch: 2n }); + expect(rt.epoch()).toBe(2n); + fit.push({ kind: 'mutation-applied', role: 'fit', epoch: 3n }); + expect(rt.epoch()).toBe(3n); + expect(rt.stats().mutationsApplied).toBe(3n); + harness.pushDoneAll(); + await (rt as unknown as { __runP: Promise }).__runP; + }); + + it('is monotonic — out-of-order replay never decrements', async () => { + const { rt, harness } = await bootRunning(); + const fit = harness.get('fit'); + fit.push({ kind: 'mutation-applied', role: 'fit', epoch: 5n }); + expect(rt.epoch()).toBe(5n); + // A stale / replayed ack with an older epoch must not roll back. + fit.push({ kind: 'mutation-applied', role: 'fit', epoch: 2n }); + expect(rt.epoch()).toBe(5n); + fit.push({ kind: 'mutation-applied', role: 'fit', epoch: 6n }); + expect(rt.epoch()).toBe(6n); + harness.pushDoneAll(); + await (rt as unknown as { __runP: Promise }).__runP; + }); + + it('frames and mutations interleave without corrupting epoch', async () => { + const { rt, harness } = await bootRunning(); + const fit = harness.get('fit'); + // §7.3 atomicity: between residual write and next frame, a + // mutation may be applied. Test that interleaving keeps both + // counters sane. + fit.push({ kind: 'frame-processed', role: 'fit', index: 0, epoch: 0n }); + fit.push({ kind: 'mutation-applied', role: 'fit', epoch: 1n }); + fit.push({ kind: 'frame-processed', role: 'fit', index: 1, epoch: 1n }); + fit.push({ kind: 'frame-processed', role: 'fit', index: 2, epoch: 1n }); + fit.push({ kind: 'mutation-applied', role: 'fit', epoch: 2n }); + fit.push({ kind: 'frame-processed', role: 'fit', index: 3, epoch: 2n }); + + expect(rt.epoch()).toBe(2n); + expect(rt.stats().framesProcessed).toBe(4); + expect(rt.stats().mutationsApplied).toBe(2n); + harness.pushDoneAll(); + await (rt as unknown as { __runP: Promise }).__runP; + }); +}); + +describe('stats aggregator', () => { + it('exposes every drop counter from the underlying modules', async () => { + const harness = new Harness(); + const rt = createRuntime(makeCfg(harness)); + const runP = rt.run(fakeSource()); + await flush(); + harness.pushReadyAll(); + await flush(); + + const s = rt.stats(); + expect(s.frameChannel.capacity).toBe(FRAME_CHANNEL.slotCount); + expect(s.residualChannel.capacity).toBe(RESIDUAL_CHANNEL.slotCount); + expect(s.mutationQueueCapacity).toBe(MUTATION_QUEUE.capacity); + expect(s.mutationQueueDrops).toBe(0n); + expect(s.eventBus.drops).toBe(0n); + expect(s.eventBus.published).toBe(0n); + expect(s.snapshotProtocol.issued).toBe(0n); + expect(s.snapshotProtocol.fulfilled).toBe(0n); + expect(s.snapshotProtocol.timedOut).toBe(0n); + expect(s.framesProcessed).toBe(0); + expect(s.mutationsApplied).toBe(0n); + expect(s.epoch).toBe(0n); + + harness.pushDoneAll(); + await runP; + }); +}); + +describe('onEvent', () => { + it('forwards worker-emitted PipelineEvents to subscribers', async () => { + const harness = new Harness(); + const rt = createRuntime(makeCfg(harness)); + const events: PipelineEvent[] = []; + const unsub = rt.onEvent((e) => events.push(e)); + + const runP = rt.run(fakeSource()); + await flush(); + harness.pushReadyAll(); + await flush(); + + const birth: PipelineEvent = { + kind: 'birth', + t: 5, + id: 1, + patch: [0, 0], + footprintSnap: { + pixelIndices: new Uint32Array([0, 1]), + values: new Float32Array([0.5, 0.5]), + }, + }; + const metric: PipelineEvent = { kind: 'metric', t: 6, name: 'fps', value: 60 }; + + harness.get('fit').push({ kind: 'event', role: 'fit', event: birth }); + harness.get('fit').push({ kind: 'event', role: 'fit', event: metric }); + + expect(events.length).toBe(2); + expect(events[0]).toBe(birth); + expect(events[1]).toBe(metric); + + unsub(); + harness.get('fit').push({ kind: 'event', role: 'fit', event: metric }); + expect(events.length).toBe(2); + + harness.pushDoneAll(); + await runP; + }); +}); + +describe('graceful + hard shutdown', () => { + it('stop() resolves when all workers reply done', async () => { + const harness = new Harness(); + const rt = createRuntime(makeCfg(harness)); + const runP = rt.run(fakeSource()); + await flush(); + harness.pushReadyAll(); + await flush(); + + const stopP = rt.stop(); + // `stop` was posted to every worker. + for (const role of ['decodePreprocess', 'fit', 'extend', 'archive'] as const) { + expect(harness.get(role).posted.some((m) => m.kind === 'stop')).toBe(true); + } + + harness.pushDoneAll(); + await stopP; + await runP; + expect(rt.state()).toBe('stopped'); + }); + + it('hard-terminates after shutdownTimeoutMs if no worker replies done', async () => { + const harness = new Harness(); + const rt = createRuntime(makeCfg(harness, { shutdownTimeoutMs: 15 })); + const runP = rt.run(fakeSource()); + await flush(); + harness.pushReadyAll(); + await flush(); + + const stopP = rt.stop(); + await expect(stopP).rejects.toBeInstanceOf(RuntimeShutdownTimeoutError); + await expect(runP).rejects.toBeInstanceOf(RuntimeShutdownTimeoutError); + for (const w of harness.workers.values()) { + expect(w.terminated).toBe(true); + } + expect(rt.state()).toBe('error'); + }); +}); + +describe('twoPassMode flag', () => { + it('round-trips through config', () => { + const harness = new Harness(); + const cfg = makeCfg(harness, { twoPassMode: true }); + expect(cfg.twoPassMode).toBe(true); + // Construction accepts the flag even though pass-2 is deferred. + const rt = createRuntime(cfg); + expect(rt.state()).toBe('idle'); + }); +}); + +describe('onStatus emits frame + epoch updates', () => { + it('delivers incrementally updating framesProcessed and epoch', async () => { + const harness = new Harness(); + const rt = createRuntime(makeCfg(harness)); + const statuses: RuntimeStatus[] = []; + rt.onStatus((s) => statuses.push({ ...s })); + + const runP = rt.run(fakeSource()); + await flush(); + harness.pushReadyAll(); + await flush(); + + const fit = harness.get('fit'); + fit.push({ kind: 'frame-processed', role: 'fit', index: 0, epoch: 0n }); + fit.push({ kind: 'mutation-applied', role: 'fit', epoch: 1n }); + + const lastFrame = statuses.findLast((s) => s.framesProcessed === 1); + expect(lastFrame).toBeDefined(); + const lastEpoch = statuses.findLast((s) => s.epoch === 1n); + expect(lastEpoch).toBeDefined(); + + harness.pushDoneAll(); + await runP; + }); +}); + +describe('spurious run() guard', () => { + it('rejects concurrent run() while already running', async () => { + const harness = new Harness(); + const rt = createRuntime(makeCfg(harness)); + const runP = rt.run(fakeSource()); + await flush(); + harness.pushReadyAll(); + await flush(); + + await expect(rt.run(fakeSource())).rejects.toThrow(/run\(\) called from state 'running'/); + + harness.pushDoneAll(); + await runP; + }); +}); + +// vi.useRealTimers() guard so leaked timers from one test don't bleed +// into the next suite's budget. +beforeEach(() => { + vi.useRealTimers(); +}); +afterEach(() => { + vi.useRealTimers(); +}); diff --git a/packages/cala-runtime/src/index.ts b/packages/cala-runtime/src/index.ts index 4f3e098..d121dba 100644 --- a/packages/cala-runtime/src/index.ts +++ b/packages/cala-runtime/src/index.ts @@ -23,5 +23,25 @@ export type { EventBusStats, Unsubscribe, } from './events.ts'; -// Surface stubs for modules that land in later tasks — see types.ts TODOs. -export type { Orchestrator, Todo } from './types.ts'; +export { + createRuntime, + RuntimeStartupTimeoutError, + RuntimeShutdownTimeoutError, + RuntimeWorkerError, +} from './orchestrator.ts'; +export type { + RuntimeConfig, + RuntimeController, + RuntimeSource, + RuntimeState, + RuntimeStatus, + RuntimeStats, +} from './orchestrator.ts'; +export type { + WorkerFactory, + WorkerInbound, + WorkerOutbound, + WorkerInitPayload, + WorkerLike, + WorkerRole, +} from './worker-protocol.ts'; diff --git a/packages/cala-runtime/src/orchestrator.ts b/packages/cala-runtime/src/orchestrator.ts new file mode 100644 index 0000000..0b908e6 --- /dev/null +++ b/packages/cala-runtime/src/orchestrator.ts @@ -0,0 +1,456 @@ +/** + * Runtime orchestrator (design §7, Phase 5 Task 18). + * + * Ties channels, mutation queue, snapshot protocol, and event bus + * together into a single `RuntimeController` that `apps/cala` drives. + * Workers are spawned via caller-provided factories — keeps the + * orchestrator harness-testable without real `Worker` instances. + * + * Epoch semantics mirror `crates/cala-core/src/fitting/pipeline.rs`: + * the counter advances only when fit acks a mutation-apply, not on + * every frame. + */ + +import { SabRingChannel } from './channel.ts'; +import { MutationQueue } from './mutation-queue.ts'; +import { SnapshotProtocol } from './asset-snapshot.ts'; +import { EventBus } from './events.ts'; +import type { ChannelConfig, ChannelStats } from './types.ts'; +import type { EventBusConfig, EventBusStats, PipelineEvent, Unsubscribe } from './events.ts'; +import type { MutationQueueConfig } from './mutation-queue.ts'; +import type { SnapshotProtocolConfig, SnapshotProtocolStats } from './asset-snapshot.ts'; +import type { WorkerFactory, WorkerLike, WorkerOutbound, WorkerRole } from './worker-protocol.ts'; + +const WORKER_ROLES: readonly WorkerRole[] = ['decodePreprocess', 'fit', 'extend', 'archive']; + +export type RuntimeState = 'idle' | 'starting' | 'running' | 'stopping' | 'stopped' | 'error'; + +export interface RuntimeStatus { + state: RuntimeState; + epoch: bigint; + framesProcessed: number; + error?: string; +} + +export interface RuntimeStats { + frameChannel: ChannelStats; + residualChannel: ChannelStats; + mutationQueueDrops: bigint; + mutationQueueCapacity: number; + eventBus: EventBusStats; + snapshotProtocol: SnapshotProtocolStats; + epoch: bigint; + framesProcessed: number; + mutationsApplied: bigint; +} + +/** + * Opaque handle the runtime forwards to the decoder worker on init. + * The runtime does not read from it — it just wires `source.file` and + * `source.frameSourceFactory` through to W1, which owns decoding. + */ +export interface RuntimeSource { + kind: 'file'; + file: File; + frameSourceFactory: unknown; +} + +export interface RuntimeConfig { + workerFactories: Record; + frameChannel: ChannelConfig; + residualChannel: ChannelConfig; + mutationQueue: MutationQueueConfig; + snapshotProtocol: SnapshotProtocolConfig; + eventBus: EventBusConfig; + startupTimeoutMs: number; + shutdownTimeoutMs: number; + twoPassMode?: boolean; + /** + * Role-specific opaque config forwarded verbatim in each worker's + * `init` message. Everything numerical lives here so the + * orchestrator itself stays free of tuning literals. + */ + workerConfigs?: Partial>; +} + +export interface RuntimeController { + run(source: RuntimeSource): Promise; + stop(): Promise; + state(): RuntimeState; + onStatus(cb: (s: RuntimeStatus) => void): Unsubscribe; + onEvent(cb: (e: PipelineEvent) => void): Unsubscribe; + epoch(): bigint; + stats(): RuntimeStats; +} + +export class RuntimeStartupTimeoutError extends Error { + constructor(message: string) { + super(message); + this.name = 'RuntimeStartupTimeoutError'; + } +} + +export class RuntimeShutdownTimeoutError extends Error { + constructor(message: string) { + super(message); + this.name = 'RuntimeShutdownTimeoutError'; + } +} + +export class RuntimeWorkerError extends Error { + constructor( + public readonly role: WorkerRole, + message: string, + ) { + super(`[${role}] ${message}`); + this.name = 'RuntimeWorkerError'; + } +} + +function validateConfig(cfg: RuntimeConfig): void { + for (const role of WORKER_ROLES) { + if (typeof cfg.workerFactories[role] !== 'function') { + throw new Error(`RuntimeConfig.workerFactories.${role} must be a function`); + } + } + if (!Number.isFinite(cfg.startupTimeoutMs) || cfg.startupTimeoutMs <= 0) { + throw new Error( + `RuntimeConfig.startupTimeoutMs must be a positive number (got ${cfg.startupTimeoutMs})`, + ); + } + if (!Number.isFinite(cfg.shutdownTimeoutMs) || cfg.shutdownTimeoutMs <= 0) { + throw new Error( + `RuntimeConfig.shutdownTimeoutMs must be a positive number (got ${cfg.shutdownTimeoutMs})`, + ); + } +} + +type StatusListener = (s: RuntimeStatus) => void; + +class Runtime implements RuntimeController { + private readonly cfg: RuntimeConfig; + private readonly statusListeners = new Set(); + private readonly eventBus: EventBus; + private readonly mutationQueue: MutationQueue; + private readonly snapshotProtocol: SnapshotProtocol; + + private frameChannel: SabRingChannel | null = null; + private residualChannel: SabRingChannel | null = null; + private workers = new Map(); + private workerListeners = new Map void>(); + + private currentState: RuntimeState = 'idle'; + private currentEpoch = 0n; + private frames = 0; + private mutationsAppliedCount = 0n; + private lastError?: string; + + private runDeferred: { + resolve: () => void; + reject: (err: Error) => void; + } | null = null; + private workersDoneCount = 0; + private stopDeferred: { + resolve: () => void; + reject: (err: Error) => void; + } | null = null; + private stopHardTimer: ReturnType | null = null; + + constructor(cfg: RuntimeConfig) { + validateConfig(cfg); + this.cfg = cfg; + this.eventBus = new EventBus(cfg.eventBus); + this.mutationQueue = new MutationQueue(cfg.mutationQueue); + this.snapshotProtocol = new SnapshotProtocol(cfg.snapshotProtocol); + } + + state(): RuntimeState { + return this.currentState; + } + + epoch(): bigint { + return this.currentEpoch; + } + + onStatus(cb: StatusListener): Unsubscribe { + this.statusListeners.add(cb); + return () => { + this.statusListeners.delete(cb); + }; + } + + onEvent(cb: (e: PipelineEvent) => void): Unsubscribe { + return this.eventBus.subscribe(cb); + } + + stats(): RuntimeStats { + const emptyChannelStats: ChannelStats = { + framesWritten: 0, + framesRead: 0, + dropCount: 0, + capacity: 0, + inFlight: 0, + }; + return { + frameChannel: this.frameChannel?.stats() ?? emptyChannelStats, + residualChannel: this.residualChannel?.stats() ?? emptyChannelStats, + mutationQueueDrops: this.mutationQueue.drops, + mutationQueueCapacity: this.mutationQueue.capacity, + eventBus: this.eventBus.stats(), + snapshotProtocol: this.snapshotProtocol.stats(), + epoch: this.currentEpoch, + framesProcessed: this.frames, + mutationsApplied: this.mutationsAppliedCount, + }; + } + + async run(source: RuntimeSource): Promise { + if (this.currentState !== 'idle' && this.currentState !== 'stopped') { + throw new Error(`run() called from state '${this.currentState}'`); + } + + this.resetPerRunState(); + this.transition('starting'); + + try { + this.frameChannel = new SabRingChannel(this.cfg.frameChannel); + this.residualChannel = new SabRingChannel(this.cfg.residualChannel); + await this.spawnAndHandshake(source); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + this.lastError = msg; + this.hardTerminateAll(); + this.transition('error'); + throw err; + } + + this.transition('running'); + for (const worker of this.workers.values()) { + worker.postMessage({ kind: 'run' }); + } + + return new Promise((resolve, reject) => { + this.runDeferred = { resolve, reject }; + }); + // TODO(phase 7): two-pass replay. When `cfg.twoPassMode` is set, + // after the first pass resolves we re-open the file, seed fit + // with the pass-1 `A`, and rerun with extend disabled. + } + + async stop(): Promise { + if ( + this.currentState === 'idle' || + this.currentState === 'stopped' || + this.currentState === 'error' + ) { + return; + } + if (this.stopDeferred !== null) { + return new Promise((resolve, reject) => { + const prev = this.stopDeferred!; + this.stopDeferred = { + resolve: () => { + prev.resolve(); + resolve(); + }, + reject: (err) => { + prev.reject(err); + reject(err); + }, + }; + }); + } + + this.transition('stopping'); + for (const worker of this.workers.values()) { + worker.postMessage({ kind: 'stop' }); + } + + return new Promise((resolve, reject) => { + this.stopDeferred = { resolve, reject }; + this.stopHardTimer = setTimeout(() => { + this.hardTerminateAll(); + const err = new RuntimeShutdownTimeoutError( + `workers did not exit within ${this.cfg.shutdownTimeoutMs}ms`, + ); + this.lastError = err.message; + this.transition('error'); + const deferred = this.stopDeferred; + this.stopDeferred = null; + deferred?.reject(err); + this.failRun(err); + }, this.cfg.shutdownTimeoutMs); + }); + } + + private resetPerRunState(): void { + this.currentEpoch = 0n; + this.frames = 0; + this.mutationsAppliedCount = 0n; + this.workersDoneCount = 0; + this.lastError = undefined; + } + + private async spawnAndHandshake(source: RuntimeSource): Promise { + const pending = new Set(WORKER_ROLES); + let resolveReady: () => void; + let rejectReady: (err: Error) => void; + const readyPromise = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; + }); + + for (const role of WORKER_ROLES) { + const worker = this.cfg.workerFactories[role](); + const listener = (ev: { data: WorkerOutbound }): void => { + const msg = ev.data; + if (msg.kind === 'ready' && pending.has(role)) { + pending.delete(role); + if (pending.size === 0) resolveReady(); + return; + } + this.handleWorkerMessage(role, msg); + }; + worker.addEventListener('message', listener); + this.workers.set(role, worker); + this.workerListeners.set(role, listener); + worker.postMessage({ + kind: 'init', + payload: { + role, + frameChannelBuffer: this.frameChannel!.sharedBuffer, + residualChannelBuffer: this.residualChannel!.sharedBuffer, + workerConfig: this.buildWorkerConfig(role, source), + }, + }); + } + + const timeoutId = setTimeout(() => { + if (pending.size === 0) return; + rejectReady( + new RuntimeStartupTimeoutError( + `workers [${[...pending].join(', ')}] did not signal ready within ${this.cfg.startupTimeoutMs}ms`, + ), + ); + }, this.cfg.startupTimeoutMs); + + try { + await readyPromise; + } finally { + clearTimeout(timeoutId); + } + } + + private buildWorkerConfig(role: WorkerRole, source: RuntimeSource): unknown { + const override = this.cfg.workerConfigs?.[role]; + if (role === 'decodePreprocess') { + return { source, ...(override as object | undefined) }; + } + return override ?? null; + } + + private handleWorkerMessage(role: WorkerRole, msg: WorkerOutbound): void { + switch (msg.kind) { + case 'ready': + // Late ready (after handshake) is ignored — already handled. + return; + case 'frame-processed': + this.frames += 1; + this.emitStatus(); + return; + case 'mutation-applied': + if (msg.epoch < this.currentEpoch) return; // enforce monotonicity + this.currentEpoch = msg.epoch; + this.mutationsAppliedCount += 1n; + this.emitStatus(); + return; + case 'snapshot-request': { + const fit = this.workers.get('fit'); + if (!fit) return; + fit.postMessage({ + kind: 'snapshot-ack', + requestId: msg.requestId, + epoch: this.currentEpoch, + numComponents: 0, + pixels: 0, + }); + return; + } + case 'event': + this.eventBus.publish(msg.event); + return; + case 'error': { + const err = new RuntimeWorkerError(msg.role, msg.message); + this.lastError = err.message; + this.hardTerminateAll(); + this.transition('error'); + this.failRun(err); + return; + } + case 'done': + this.workersDoneCount += 1; + if (this.workersDoneCount < WORKER_ROLES.length) return; + if (this.stopDeferred !== null) { + if (this.stopHardTimer !== null) { + clearTimeout(this.stopHardTimer); + this.stopHardTimer = null; + } + this.transition('stopped'); + const deferred = this.stopDeferred; + this.stopDeferred = null; + deferred.resolve(); + this.resolveRun(); + } else { + this.transition('stopped'); + this.resolveRun(); + } + return; + } + } + + private emitStatus(): void { + const status: RuntimeStatus = { + state: this.currentState, + epoch: this.currentEpoch, + framesProcessed: this.frames, + error: this.lastError, + }; + for (const cb of this.statusListeners) cb(status); + } + + private transition(next: RuntimeState): void { + if (this.currentState === next) return; + this.currentState = next; + this.emitStatus(); + } + + private resolveRun(): void { + const deferred = this.runDeferred; + this.runDeferred = null; + deferred?.resolve(); + } + + private failRun(err: Error): void { + const deferred = this.runDeferred; + this.runDeferred = null; + deferred?.reject(err); + } + + private hardTerminateAll(): void { + for (const [role, worker] of this.workers) { + const listener = this.workerListeners.get(role); + if (listener) worker.removeEventListener('message', listener); + try { + worker.terminate(); + } catch { + // best-effort — terminate() can throw on already-dead harness workers + } + } + this.workers.clear(); + this.workerListeners.clear(); + } +} + +export function createRuntime(cfg: RuntimeConfig): RuntimeController { + return new Runtime(cfg); +} diff --git a/packages/cala-runtime/src/types.ts b/packages/cala-runtime/src/types.ts index 0ff62e7..c367185 100644 --- a/packages/cala-runtime/src/types.ts +++ b/packages/cala-runtime/src/types.ts @@ -65,8 +65,26 @@ export { type Unsubscribe, } from './events.ts'; -// TODO(task 18): Orchestrator surface — creates workers, wires channels, -// tracks epochs, owns two-pass toggle. See CALA_DESIGN §7. -export type Orchestrator = Todo<'Orchestrator'>; +// Orchestrator surface — creates workers, wires channels, tracks +// epochs, owns two-pass toggle. See CALA_DESIGN §7. +export { + createRuntime, + RuntimeStartupTimeoutError, + RuntimeShutdownTimeoutError, + RuntimeWorkerError, + type RuntimeConfig, + type RuntimeController, + type RuntimeSource, + type RuntimeState, + type RuntimeStatus, + type RuntimeStats, +} from './orchestrator.ts'; -export type Todo = { readonly __todo: K }; +export type { + WorkerFactory, + WorkerInbound, + WorkerOutbound, + WorkerInitPayload, + WorkerLike, + WorkerRole, +} from './worker-protocol.ts'; diff --git a/packages/cala-runtime/src/worker-protocol.ts b/packages/cala-runtime/src/worker-protocol.ts new file mode 100644 index 0000000..4ef832d --- /dev/null +++ b/packages/cala-runtime/src/worker-protocol.ts @@ -0,0 +1,71 @@ +/** + * Orchestrator ↔ worker message protocol (design §7, Phase 5 Task 18). + * + * The four workers (W1 decode+preprocess, W2 fit, W3 extend, W4 + * archive) never talk to each other directly — they exchange data + * through SAB channels and exchange control messages with the + * orchestrator through `postMessage`. This module codifies the exact + * shape of those control messages so worker authors (Phase 5 tasks + * 21-23) and the orchestrator stay in lockstep. + */ + +import type { PipelineEvent } from './events.ts'; + +/** The four workers the orchestrator spawns. Used as a tag in messages. */ +export type WorkerRole = 'decodePreprocess' | 'fit' | 'extend' | 'archive'; + +/** + * SAB handles and per-worker config posted with `init`. The decoder + * worker additionally receives the caller-provided frame source (see + * `RuntimeSource`) so it can open the input without touching + * `@calab/io` from the runtime package. + */ +export interface WorkerInitPayload { + role: WorkerRole; + frameChannelBuffer: SharedArrayBuffer | ArrayBuffer; + residualChannelBuffer: SharedArrayBuffer | ArrayBuffer; + /** + * Opaque, role-specific config bag the orchestrator forwards + * untouched. Kept permissive so worker tasks can extend their own + * config without coupling the runtime package to numerical details. + */ + workerConfig: unknown; +} + +/** Messages the orchestrator sends to a worker. */ +export type WorkerInbound = + | { kind: 'init'; payload: WorkerInitPayload } + | { kind: 'run' } + | { kind: 'stop' } + | { + kind: 'snapshot-ack'; + requestId: number; + epoch: bigint; + numComponents: number; + pixels: number; + }; + +/** Messages a worker sends back to the orchestrator. */ +export type WorkerOutbound = + | { kind: 'ready'; role: WorkerRole } + | { kind: 'frame-processed'; role: WorkerRole; index: number; epoch: bigint } + | { kind: 'mutation-applied'; role: WorkerRole; epoch: bigint } + | { kind: 'snapshot-request'; role: WorkerRole; requestId: number } + | { kind: 'event'; role: WorkerRole; event: PipelineEvent } + | { kind: 'error'; role: WorkerRole; message: string } + | { kind: 'done'; role: WorkerRole }; + +/** + * Minimal structural subtype of the DOM `Worker` that the orchestrator + * actually needs. Keeping it narrow lets tests substitute a fake + * harness without stubbing transferables / `onerror` / etc. + */ +export interface WorkerLike { + postMessage(message: WorkerInbound): void; + addEventListener(type: 'message', listener: (ev: { data: WorkerOutbound }) => void): void; + removeEventListener(type: 'message', listener: (ev: { data: WorkerOutbound }) => void): void; + terminate(): void; +} + +/** Caller-provided factory invoked once per `run()`. */ +export type WorkerFactory = () => WorkerLike; From bdc3c99787ac7d7ab08b955e6871918d58aff5cc Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 18 Apr 2026 13:23:49 -0700 Subject: [PATCH 09/17] feat(cala): add decode+preprocess worker (task 21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire W1 — opens an AVI via @calab/io, builds a Preprocessor from @calab/cala-core, runs the decode→preprocess→SAB-write loop, and handles init/run/stop lifecycle with throttled frame-processed heartbeats. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../decode-preprocess.worker.test.ts | 303 ++++++++++++++++++ .../src/workers/__tests__/worker-harness.ts | 33 ++ .../src/workers/decode-preprocess.worker.ts | 230 +++++++++++++ apps/cala/src/workers/index.ts | 7 + 4 files changed, 573 insertions(+) create mode 100644 apps/cala/src/workers/__tests__/decode-preprocess.worker.test.ts create mode 100644 apps/cala/src/workers/__tests__/worker-harness.ts create mode 100644 apps/cala/src/workers/decode-preprocess.worker.ts create mode 100644 apps/cala/src/workers/index.ts diff --git a/apps/cala/src/workers/__tests__/decode-preprocess.worker.test.ts b/apps/cala/src/workers/__tests__/decode-preprocess.worker.test.ts new file mode 100644 index 0000000..fd8442e --- /dev/null +++ b/apps/cala/src/workers/__tests__/decode-preprocess.worker.test.ts @@ -0,0 +1,303 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { WorkerInbound, WorkerOutbound } from '@calab/cala-runtime'; +import { SabRingChannel } from '@calab/cala-runtime'; +import type { FrameSource, FrameSourceMeta } from '@calab/io'; +import { createWorkerHarness, type WorkerHarness } from './worker-harness.ts'; + +const FRAME_CHANNEL_SLOT_BYTES = 256; +const FRAME_CHANNEL_SLOT_COUNT = 64; +const FRAME_CHANNEL_WAIT_TIMEOUT_MS = 50; +const FRAME_CHANNEL_POLL_INTERVAL_MS = 1; + +// Shared mock state so tests can script the decoder and preprocessor +// without re-importing the module under test each run. +interface MockFrameSource extends FrameSource { + readFrameCalls: number[]; + closed: boolean; +} + +interface MockPreprocessor { + processFrameF32: ReturnType; + free: ReturnType; + freed: boolean; +} + +const mockState = { + openShouldThrow: null as Error | null, + preprocessShouldThrow: null as Error | null, + constructPreprocessorShouldThrow: null as Error | null, + meta: { + width: 4, + height: 4, + frameCount: 5, + fps: 30, + channels: 1, + bitDepth: 8, + } satisfies FrameSourceMeta, + frameSource: null as MockFrameSource | null, + preprocessor: null as MockPreprocessor | null, + processFrameDelayMs: 0, +}; + +vi.mock('@calab/io', () => ({ + openAviUncompressed: vi.fn(async (_file: File): Promise => { + if (mockState.openShouldThrow) throw mockState.openShouldThrow; + const src: MockFrameSource = { + readFrameCalls: [], + closed: false, + meta: () => mockState.meta, + async readFrame(n: number) { + src.readFrameCalls.push(n); + if (mockState.processFrameDelayMs > 0) { + await new Promise((r) => setTimeout(r, mockState.processFrameDelayMs)); + } + const out = new Float32Array(mockState.meta.width * mockState.meta.height); + out[0] = n; + return out; + }, + close() { + src.closed = true; + }, + }; + mockState.frameSource = src; + return src; + }), +})); + +vi.mock('@calab/cala-core', () => { + class Preprocessor { + processFrameF32: ReturnType; + free: ReturnType; + freed = false; + constructor() { + if (mockState.constructPreprocessorShouldThrow) { + throw mockState.constructPreprocessorShouldThrow; + } + this.processFrameF32 = vi.fn((input: Float32Array) => { + if (mockState.preprocessShouldThrow) throw mockState.preprocessShouldThrow; + const out = new Float32Array(input.length); + out.set(input); + out[0] += 1; + return out; + }); + this.free = vi.fn(() => { + this.freed = true; + }); + const self = this as unknown as MockPreprocessor; + mockState.preprocessor = self; + } + } + return { + initCalaCore: vi.fn(async () => {}), + Preprocessor, + }; +}); + +function resetMockState(): void { + mockState.openShouldThrow = null; + mockState.preprocessShouldThrow = null; + mockState.constructPreprocessorShouldThrow = null; + mockState.frameSource = null; + mockState.preprocessor = null; + mockState.processFrameDelayMs = 0; + mockState.meta = { + width: 4, + height: 4, + frameCount: 5, + fps: 30, + channels: 1, + bitDepth: 8, + }; +} + +function makeFrameChannel(): SabRingChannel { + return new SabRingChannel({ + slotBytes: FRAME_CHANNEL_SLOT_BYTES, + slotCount: FRAME_CHANNEL_SLOT_COUNT, + waitTimeoutMs: FRAME_CHANNEL_WAIT_TIMEOUT_MS, + pollIntervalMs: FRAME_CHANNEL_POLL_INTERVAL_MS, + }); +} + +function makeResidualBuffer(): SharedArrayBuffer | ArrayBuffer { + return makeFrameChannel().sharedBuffer; +} + +function makeInitMsg(overrides: Record = {}): WorkerInbound { + const frameChannel = makeFrameChannel(); + return { + kind: 'init', + payload: { + role: 'decodePreprocess', + frameChannelBuffer: frameChannel.sharedBuffer, + residualChannelBuffer: makeResidualBuffer(), + workerConfig: { + source: { + kind: 'file', + file: new File([new Uint8Array(4)], 'fake.avi'), + frameSourceFactory: null, + }, + heartbeatStride: 2, + metadataJson: '{"pixel_size_um":2.0}', + preprocessConfigJson: '{}', + grayscaleMethod: 'Green', + frameChannelSlotBytes: FRAME_CHANNEL_SLOT_BYTES, + frameChannelSlotCount: FRAME_CHANNEL_SLOT_COUNT, + frameChannelWaitTimeoutMs: FRAME_CHANNEL_WAIT_TIMEOUT_MS, + frameChannelPollIntervalMs: FRAME_CHANNEL_POLL_INTERVAL_MS, + ...overrides, + }, + }, + }; +} + +async function runUntil( + harness: WorkerHarness, + predicate: (posted: WorkerOutbound[]) => boolean, + maxTicks = 1000, +): Promise { + for (let i = 0; i < maxTicks; i += 1) { + if (predicate(harness.posted)) return; + // Yield a macrotask so setTimeout-backed mocks can fire. + await new Promise((r) => setTimeout(r, 0)); + } + if (!predicate(harness.posted)) { + throw new Error('runUntil timed out'); + } +} + +async function loadWorker(harness: WorkerHarness): Promise { + vi.stubGlobal('self', harness.self); + await import('../decode-preprocess.worker.ts'); +} + +describe('decode-preprocess worker', () => { + beforeEach(() => { + resetMockState(); + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('responds to init with ready after opening source and building preprocessor', async () => { + const harness = createWorkerHarness(); + await loadWorker(harness); + await harness.deliver(makeInitMsg()); + await runUntil(harness, (p) => p.some((m) => m.kind === 'ready')); + const ready = harness.posted.find((m) => m.kind === 'ready'); + expect(ready).toEqual({ kind: 'ready', role: 'decodePreprocess' }); + expect(mockState.frameSource).not.toBeNull(); + expect(mockState.preprocessor).not.toBeNull(); + }); + + it('posts error when openAviUncompressed fails during init', async () => { + mockState.openShouldThrow = new Error('bad avi header'); + const harness = createWorkerHarness(); + await loadWorker(harness); + await harness.deliver(makeInitMsg()); + await runUntil(harness, (p) => p.some((m) => m.kind === 'error')); + const err = harness.posted.find((m) => m.kind === 'error'); + expect(err).toMatchObject({ kind: 'error', role: 'decodePreprocess' }); + expect((err as { message: string }).message).toMatch(/bad avi header/); + expect(harness.posted.some((m) => m.kind === 'ready')).toBe(false); + }); + + it('posts error when Preprocessor constructor rejects config JSON', async () => { + mockState.constructPreprocessorShouldThrow = new Error('preprocess cfg parse'); + const harness = createWorkerHarness(); + await loadWorker(harness); + await harness.deliver(makeInitMsg({ preprocessConfigJson: '{invalid}' })); + await runUntil(harness, (p) => p.some((m) => m.kind === 'error')); + const err = harness.posted.find((m) => m.kind === 'error'); + expect((err as { message: string }).message).toMatch(/preprocess cfg parse/); + }); + + it('run drives decode→preprocess loop and emits throttled frame-processed heartbeats', async () => { + mockState.meta = { ...mockState.meta, frameCount: 6 }; + const harness = createWorkerHarness(); + await loadWorker(harness); + await harness.deliver(makeInitMsg({ heartbeatStride: 3 })); + await runUntil(harness, (p) => p.some((m) => m.kind === 'ready')); + + await harness.deliver({ kind: 'run' }); + await runUntil(harness, (p) => p.some((m) => m.kind === 'done')); + + expect(mockState.frameSource!.readFrameCalls).toEqual([0, 1, 2, 3, 4, 5]); + expect(mockState.preprocessor!.processFrameF32).toHaveBeenCalledTimes(6); + + const heartbeats = harness.posted.filter((m) => m.kind === 'frame-processed'); + // With stride=3 over 6 frames, beats fire after frames 2 and 5 (0-indexed). + expect(heartbeats.length).toBe(2); + const last = heartbeats[heartbeats.length - 1]; + expect(last).toMatchObject({ kind: 'frame-processed', role: 'decodePreprocess', index: 5 }); + + expect(harness.posted.some((m) => m.kind === 'done')).toBe(true); + }); + + it('stop cooperatively aborts the loop and signals done without completing all frames', async () => { + mockState.meta = { ...mockState.meta, frameCount: 50 }; + mockState.processFrameDelayMs = 1; + const harness = createWorkerHarness(); + await loadWorker(harness); + await harness.deliver(makeInitMsg({ heartbeatStride: 1 })); + await runUntil(harness, (p) => p.some((m) => m.kind === 'ready')); + + await harness.deliver({ kind: 'run' }); + // Let a few frames land before stopping. + await runUntil(harness, (p) => p.filter((m) => m.kind === 'frame-processed').length >= 1); + await harness.deliver({ kind: 'stop' }); + + await runUntil(harness, (p) => p.some((m) => m.kind === 'done')); + expect(mockState.frameSource!.readFrameCalls.length).toBeLessThan(50); + expect(mockState.frameSource!.closed).toBe(true); + expect(mockState.preprocessor!.freed).toBe(true); + }); + + it('posts error when preprocess throws mid-loop and stops processing further frames', async () => { + mockState.meta = { ...mockState.meta, frameCount: 4 }; + mockState.preprocessShouldThrow = new Error('nan in frame'); + const harness = createWorkerHarness(); + await loadWorker(harness); + await harness.deliver(makeInitMsg({ heartbeatStride: 1 })); + await runUntil(harness, (p) => p.some((m) => m.kind === 'ready')); + + await harness.deliver({ kind: 'run' }); + await runUntil(harness, (p) => p.some((m) => m.kind === 'error')); + const err = harness.posted.find((m) => m.kind === 'error'); + expect((err as { message: string }).message).toMatch(/nan in frame/); + // Loop exits after error → only one readFrame call should have happened. + expect(mockState.frameSource!.readFrameCalls.length).toBeLessThanOrEqual(1); + }); + + it('writes preprocessed frames into the SAB frame channel', async () => { + mockState.meta = { ...mockState.meta, frameCount: 2 }; + const harness = createWorkerHarness(); + await loadWorker(harness); + const initMsg = makeInitMsg({ heartbeatStride: 1 }); + await harness.deliver(initMsg); + await runUntil(harness, (p) => p.some((m) => m.kind === 'ready')); + + await harness.deliver({ kind: 'run' }); + await runUntil(harness, (p) => p.some((m) => m.kind === 'done')); + + const readerChannel = new SabRingChannel({ + slotBytes: FRAME_CHANNEL_SLOT_BYTES, + slotCount: FRAME_CHANNEL_SLOT_COUNT, + waitTimeoutMs: FRAME_CHANNEL_WAIT_TIMEOUT_MS, + pollIntervalMs: FRAME_CHANNEL_POLL_INTERVAL_MS, + sharedBuffer: + initMsg.kind === 'init' ? initMsg.payload.frameChannelBuffer : makeResidualBuffer(), + }); + const slot0 = readerChannel.readSlot(); + const slot1 = readerChannel.readSlot(); + expect(slot0).not.toBeNull(); + expect(slot1).not.toBeNull(); + const view0 = new Float32Array(slot0!.data.buffer, slot0!.data.byteOffset, 16); + const view1 = new Float32Array(slot1!.data.buffer, slot1!.data.byteOffset, 16); + // Mock preprocessor: out[0] = input[0] + 1; input[0] = frameIndex. + expect(view0[0]).toBe(1); + expect(view1[0]).toBe(2); + }); +}); diff --git a/apps/cala/src/workers/__tests__/worker-harness.ts b/apps/cala/src/workers/__tests__/worker-harness.ts new file mode 100644 index 0000000..b170d0c --- /dev/null +++ b/apps/cala/src/workers/__tests__/worker-harness.ts @@ -0,0 +1,33 @@ +import type { WorkerInbound, WorkerOutbound } from '@calab/cala-runtime'; + +export interface WorkerSelf { + postMessage(msg: WorkerOutbound): void; + onmessage: ((ev: MessageEvent) => void) | null; +} + +export interface WorkerHarness { + self: WorkerSelf; + posted: WorkerOutbound[]; + deliver(msg: WorkerInbound): Promise; +} + +export function createWorkerHarness(): WorkerHarness { + const posted: WorkerOutbound[] = []; + const self: WorkerSelf = { + postMessage: (msg) => { + posted.push(msg); + }, + onmessage: null, + }; + return { + self, + posted, + async deliver(msg) { + const handler = self.onmessage; + if (!handler) throw new Error('onmessage not installed'); + handler({ data: msg } as MessageEvent); + await Promise.resolve(); + await Promise.resolve(); + }, + }; +} diff --git a/apps/cala/src/workers/decode-preprocess.worker.ts b/apps/cala/src/workers/decode-preprocess.worker.ts new file mode 100644 index 0000000..0234eff --- /dev/null +++ b/apps/cala/src/workers/decode-preprocess.worker.ts @@ -0,0 +1,230 @@ +import { initCalaCore, Preprocessor } from '@calab/cala-core'; +import { openAviUncompressed } from '@calab/io'; +import type { FrameSource, GrayscaleMethod } from '@calab/io'; +import { + SabRingChannel, + type WorkerInbound, + type WorkerInitPayload, + type WorkerOutbound, + type ChannelConfig, +} from '@calab/cala-runtime'; + +// Heartbeat cadence: post a `frame-processed` beat every N frames so +// the orchestrator can update status without being spammed every frame. +// Overridable via `workerConfig.heartbeatStride` (design §7.1, no magic +// numbers rule: every tuning knob lives in config or in a named const). +const DEFAULT_HEARTBEAT_STRIDE = 8; +const DEFAULT_GRAYSCALE_METHOD: GrayscaleMethod = 'Green'; +const DEFAULT_METADATA_JSON = '{}'; +const DEFAULT_PREPROCESS_CONFIG_JSON = '{}'; +const DEFAULT_FRAME_CHANNEL_WAIT_TIMEOUT_MS = 1000; +const DEFAULT_FRAME_CHANNEL_POLL_INTERVAL_MS = 1; +// Slot count the orchestrator sized the SAB channel with. The worker +// does not allocate — it only needs slotCount for the view. +const FRAME_CHANNEL_SLOT_COUNT_FALLBACK = 4; + +const ROLE = 'decodePreprocess' as const; + +interface WorkerGlobalScope { + postMessage(msg: WorkerOutbound): void; + onmessage: ((ev: MessageEvent) => void) | null; +} + +interface DecodePreprocessWorkerConfig { + source: { kind: 'file'; file: File }; + heartbeatStride?: number; + metadataJson?: string; + preprocessConfigJson?: string; + grayscaleMethod?: GrayscaleMethod; + frameChannelSlotBytes?: number; + frameChannelSlotCount?: number; + frameChannelWaitTimeoutMs?: number; + frameChannelPollIntervalMs?: number; +} + +// Route through `self` when present so `vi.stubGlobal('self', harness)` +// picks us up; falls back to `globalThis` for environments that don't +// alias them (older node test harnesses). +const workerSelf = ((globalThis as unknown as { self?: WorkerGlobalScope }).self ?? + (globalThis as unknown as WorkerGlobalScope)) as WorkerGlobalScope; + +interface RuntimeHandles { + frameSource: FrameSource; + preprocessor: Preprocessor; + frameChannel: SabRingChannel; + heartbeatStride: number; + grayscaleMethod: GrayscaleMethod; + frameCount: number; +} + +let handles: RuntimeHandles | null = null; +let running = false; +let stopRequested = false; +let donePosted = false; +let loopPromise: Promise | null = null; + +function post(msg: WorkerOutbound): void { + workerSelf.postMessage(msg); +} + +function postError(err: unknown): void { + const message = err instanceof Error ? err.message : String(err); + post({ kind: 'error', role: ROLE, message }); +} + +function asRecord(value: unknown): Record { + return typeof value === 'object' && value !== null ? (value as Record) : {}; +} + +function parseConfig(raw: unknown): DecodePreprocessWorkerConfig { + const cfg = asRecord(raw); + const source = asRecord(cfg.source); + const file = source.file; + if (!(file instanceof File)) { + throw new Error('workerConfig.source.file must be a File'); + } + return { + source: { kind: 'file', file }, + heartbeatStride: + typeof cfg.heartbeatStride === 'number' ? cfg.heartbeatStride : undefined, + metadataJson: typeof cfg.metadataJson === 'string' ? cfg.metadataJson : undefined, + preprocessConfigJson: + typeof cfg.preprocessConfigJson === 'string' ? cfg.preprocessConfigJson : undefined, + grayscaleMethod: + cfg.grayscaleMethod === 'Green' || cfg.grayscaleMethod === 'Luminance' + ? cfg.grayscaleMethod + : undefined, + frameChannelSlotBytes: + typeof cfg.frameChannelSlotBytes === 'number' ? cfg.frameChannelSlotBytes : undefined, + frameChannelSlotCount: + typeof cfg.frameChannelSlotCount === 'number' ? cfg.frameChannelSlotCount : undefined, + frameChannelWaitTimeoutMs: + typeof cfg.frameChannelWaitTimeoutMs === 'number' + ? cfg.frameChannelWaitTimeoutMs + : undefined, + frameChannelPollIntervalMs: + typeof cfg.frameChannelPollIntervalMs === 'number' + ? cfg.frameChannelPollIntervalMs + : undefined, + }; +} + +async function handleInit(payload: WorkerInitPayload): Promise { + await initCalaCore(); + const cfg = parseConfig(payload.workerConfig); + + const frameSource = await openAviUncompressed(cfg.source.file); + const meta = frameSource.meta(); + const pixels = meta.width * meta.height; + const defaultSlotBytes = pixels * Float32Array.BYTES_PER_ELEMENT; + + const preprocessor = new Preprocessor( + meta.height, + meta.width, + cfg.metadataJson ?? DEFAULT_METADATA_JSON, + cfg.preprocessConfigJson ?? DEFAULT_PREPROCESS_CONFIG_JSON, + ); + + const channelCfg: ChannelConfig = { + slotBytes: cfg.frameChannelSlotBytes ?? defaultSlotBytes, + slotCount: cfg.frameChannelSlotCount ?? FRAME_CHANNEL_SLOT_COUNT_FALLBACK, + waitTimeoutMs: cfg.frameChannelWaitTimeoutMs ?? DEFAULT_FRAME_CHANNEL_WAIT_TIMEOUT_MS, + pollIntervalMs: cfg.frameChannelPollIntervalMs ?? DEFAULT_FRAME_CHANNEL_POLL_INTERVAL_MS, + sharedBuffer: payload.frameChannelBuffer, + }; + const frameChannel = new SabRingChannel(channelCfg); + + handles = { + frameSource, + preprocessor, + frameChannel, + heartbeatStride: cfg.heartbeatStride ?? DEFAULT_HEARTBEAT_STRIDE, + grayscaleMethod: cfg.grayscaleMethod ?? DEFAULT_GRAYSCALE_METHOD, + frameCount: meta.frameCount, + }; + + post({ kind: 'ready', role: ROLE }); +} + +async function decodeLoop(h: RuntimeHandles): Promise { + 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 }); + } + } +} + +function cleanup(): void { + if (!handles) return; + try { + handles.frameSource.close(); + } catch { + // close is best-effort; already-closed sources throw in some impls + } + try { + handles.preprocessor.free(); + } catch { + // free is best-effort — wasm may already be torn down + } + handles = null; +} + +function postDoneOnce(): void { + if (donePosted) return; + donePosted = true; + post({ kind: 'done', role: ROLE }); +} + +async function handleRun(): Promise { + if (!handles) { + postError(new Error("'run' received before successful 'init'")); + return; + } + if (running) return; + running = true; + stopRequested = false; + donePosted = false; + const h = handles; + try { + await decodeLoop(h); + postDoneOnce(); + } catch (err) { + postError(err); + } finally { + running = false; + cleanup(); + } +} + +async function handleStop(): Promise { + stopRequested = true; + if (loopPromise) await loopPromise; + postDoneOnce(); + cleanup(); +} + +workerSelf.onmessage = (ev: MessageEvent): void => { + const msg = ev.data; + switch (msg.kind) { + case 'init': + handleInit(msg.payload).catch(postError); + return; + case 'run': + loopPromise = handleRun(); + return; + case 'stop': + handleStop().catch(postError); + return; + case 'snapshot-ack': + // W1 has no snapshot participation — ignored. + return; + } +}; diff --git a/apps/cala/src/workers/index.ts b/apps/cala/src/workers/index.ts new file mode 100644 index 0000000..4edb847 --- /dev/null +++ b/apps/cala/src/workers/index.ts @@ -0,0 +1,7 @@ +import type { WorkerLike } from '@calab/cala-runtime'; + +export function createDecodePreprocessWorker(): WorkerLike { + return new Worker(new URL('./decode-preprocess.worker.ts', import.meta.url), { + type: 'module', + }) as unknown as WorkerLike; +} From 9c4dace9e9dd0ba9081b667a4b4e243c652a708c Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 18 Apr 2026 13:24:15 -0700 Subject: [PATCH 10/17] feat(cala): add file drop + run control (task 20) Wires apps/cala's file-drop UI and run-control lifecycle: adds a createStore data-store, a RuntimeController wrapper with stub factories, ImportOverlay (.avi drag-drop + metadata + start button), and a CaLaHeader run-state pill. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cala/package.json | 2 + apps/cala/src/App.tsx | 21 ++- .../cala/src/components/layout/CaLaHeader.tsx | 61 ++++++ .../src/components/layout/ImportOverlay.tsx | 160 ++++++++++++++++ .../cala/src/lib/__tests__/data-store.test.ts | 77 ++++++++ .../src/lib/__tests__/run-control.test.ts | 175 ++++++++++++++++++ apps/cala/src/lib/data-store.ts | 44 +++++ apps/cala/src/lib/run-control.ts | 157 ++++++++++++++++ apps/cala/src/styles/global.css | 20 ++ apps/cala/vitest.config.ts | 10 + 10 files changed, 717 insertions(+), 10 deletions(-) create mode 100644 apps/cala/src/components/layout/CaLaHeader.tsx create mode 100644 apps/cala/src/components/layout/ImportOverlay.tsx create mode 100644 apps/cala/src/lib/__tests__/data-store.test.ts create mode 100644 apps/cala/src/lib/__tests__/run-control.test.ts create mode 100644 apps/cala/src/lib/data-store.ts create mode 100644 apps/cala/src/lib/run-control.ts create mode 100644 apps/cala/vitest.config.ts diff --git a/apps/cala/package.json b/apps/cala/package.json index 204a3ce..b202709 100644 --- a/apps/cala/package.json +++ b/apps/cala/package.json @@ -20,6 +20,8 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", "verify-sab": "node scripts/verify-sab.mjs" }, "dependencies": { diff --git a/apps/cala/src/App.tsx b/apps/cala/src/App.tsx index 227bdfc..28cf3da 100644 --- a/apps/cala/src/App.tsx +++ b/apps/cala/src/App.tsx @@ -1,16 +1,17 @@ -import type { Component } from 'solid-js'; -import { DashboardShell, CompactHeader } from '@calab/ui'; +import { Show, type Component } from 'solid-js'; +import { DashboardShell } from '@calab/ui'; +import { CaLaHeader } from './components/layout/CaLaHeader.tsx'; +import { ImportOverlay } from './components/layout/ImportOverlay.tsx'; +import { state } from './lib/data-store.ts'; const App: Component = () => { return ( - }> -
-

CaLa — streaming calcium imaging demixing.

-

- Shell scaffolded in Phase 5, task 19. File drop, run control, and workers land in - subsequent tasks. -

-
+ }> + }> +
+

Run control wired in task 20. Frame viewer lands in task 24.

+
+
); }; diff --git a/apps/cala/src/components/layout/CaLaHeader.tsx b/apps/cala/src/components/layout/CaLaHeader.tsx new file mode 100644 index 0000000..a36a601 --- /dev/null +++ b/apps/cala/src/components/layout/CaLaHeader.tsx @@ -0,0 +1,61 @@ +import type { JSX } from 'solid-js'; +import { CompactHeader } from '@calab/ui'; +import { state } from '../../lib/data-store.ts'; +import type { RuntimeState } from '@calab/cala-runtime'; + +const STATE_COLORS: Record = { + idle: 'var(--text-tertiary)', + starting: 'var(--warning)', + running: 'var(--success)', + stopping: 'var(--warning)', + stopped: 'var(--text-tertiary)', + error: 'var(--error)', +}; + +const STATE_LABELS: Record = { + idle: 'Idle', + starting: 'Starting', + running: 'Running', + stopping: 'Stopping', + stopped: 'Stopped', + error: 'Error', +}; + +export function CaLaHeader(): JSX.Element { + const version = `CaLab ${import.meta.env.VITE_APP_VERSION || 'dev'}`; + + const indicator = (): JSX.Element => { + const rs = state.runState; + return ( + + + {STATE_LABELS[rs]} + + ); + }; + + return ; +} diff --git a/apps/cala/src/components/layout/ImportOverlay.tsx b/apps/cala/src/components/layout/ImportOverlay.tsx new file mode 100644 index 0000000..350214f --- /dev/null +++ b/apps/cala/src/components/layout/ImportOverlay.tsx @@ -0,0 +1,160 @@ +import { createSignal, Show, type JSX } from 'solid-js'; +import { openAviUncompressed } from '@calab/io'; +import { state, setFile } from '../../lib/data-store.ts'; +import { startRun } from '../../lib/run-control.ts'; + +const ACCEPT_EXT = '.avi'; + +function formatBytes(bytes: number): string { + const mb = bytes / (1024 * 1024); + return mb >= 1 ? `${mb.toFixed(1)} MB` : `${(bytes / 1024).toFixed(1)} KB`; +} + +export function ImportOverlay(): JSX.Element { + const [isDragging, setIsDragging] = createSignal(false); + const [localError, setLocalError] = createSignal(null); + let inputRef: HTMLInputElement | undefined; + + const handleFile = async (file: File): Promise => { + const ext = file.name.split('.').pop()?.toLowerCase(); + if (ext !== 'avi') { + setLocalError(`Unsupported file format: .${ext ?? 'unknown'}. Please use .avi files.`); + return; + } + setLocalError(null); + try { + const source = await openAviUncompressed(file); + const meta = source.meta(); + source.close(); + setFile(file, meta); + } catch (err) { + setLocalError(err instanceof Error ? err.message : 'Unknown error opening AVI'); + } + }; + + const handleDrop = (e: DragEvent): void => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + const file = e.dataTransfer?.files[0]; + if (file) void handleFile(file); + }; + + const handleDragOver = (e: DragEvent): void => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + }; + + const handleDragLeave = (e: DragEvent): void => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + }; + + const handleClick = (): void => inputRef?.click(); + + const handleInputChange = (e: Event): void => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (file) void handleFile(file); + }; + + const handleStart = (): void => { + setLocalError(null); + startRun().catch((err) => { + setLocalError(err instanceof Error ? err.message : String(err)); + }); + }; + + const canStart = (): boolean => state.file !== null && state.runState === 'idle'; + + return ( +
+
+

CaLa

+ CaLab {import.meta.env.VITE_APP_VERSION || 'dev'} +

Streaming calcium-imaging demixing

+
+ +
+
+
+ + + + + +
+

+ Drop an .avi recording here +

+

or click to browse

+ +
+ + + {(file) => ( +

+ Loaded {file().name} ({formatBytes(file().size)}) +

+ )} +
+
+ + + {(meta) => ( +
+ + {meta().width} × {meta().height} + + · + {meta().frameCount.toLocaleString()} frames + 0}> + · + {meta().fps} fps + +
+ )} +
+ + +
+ ! + {localError()} +
+
+ + +
+ +
+
+
+ ); +} diff --git a/apps/cala/src/lib/__tests__/data-store.test.ts b/apps/cala/src/lib/__tests__/data-store.test.ts new file mode 100644 index 0000000..363dd47 --- /dev/null +++ b/apps/cala/src/lib/__tests__/data-store.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import type { FrameSourceMeta } from '@calab/io'; +import { + state, + setFile, + clearFile, + setRunState, + setErrorMsg, + __resetStoreForTests, +} from '../data-store.ts'; + +function makeMeta(overrides: Partial = {}): FrameSourceMeta { + return { + width: 256, + height: 256, + frameCount: 1000, + fps: 30, + channels: 1, + bitDepth: 8, + ...overrides, + }; +} + +function makeFile(name = 'test.avi'): File { + return new File([new Uint8Array(4)], name); +} + +describe('cala data-store', () => { + beforeEach(() => { + __resetStoreForTests(); + }); + + it('initial state has no file, no meta, idle run, no error', () => { + expect(state.file).toBeNull(); + expect(state.meta).toBeNull(); + expect(state.runState).toBe('idle'); + expect(state.errorMsg).toBeNull(); + }); + + it('setFile stores file and meta and clears any error', () => { + setErrorMsg('prior'); + const f = makeFile('rec.avi'); + const m = makeMeta({ width: 640, height: 480 }); + setFile(f, m); + expect(state.file).toBe(f); + expect(state.meta).toEqual(m); + expect(state.errorMsg).toBeNull(); + }); + + it('clearFile resets all fields to initial state', () => { + setFile(makeFile(), makeMeta()); + setRunState('running'); + setErrorMsg('boom'); + clearFile(); + expect(state.file).toBeNull(); + expect(state.meta).toBeNull(); + expect(state.runState).toBe('idle'); + expect(state.errorMsg).toBeNull(); + }); + + it('setRunState drives all runtime state transitions', () => { + const ordered = ['idle', 'starting', 'running', 'stopping', 'stopped'] as const; + for (const s of ordered) { + setRunState(s); + expect(state.runState).toBe(s); + } + setRunState('error'); + expect(state.runState).toBe('error'); + }); + + it('setErrorMsg stores the message and can be cleared with null', () => { + setErrorMsg('decode failed'); + expect(state.errorMsg).toBe('decode failed'); + setErrorMsg(null); + expect(state.errorMsg).toBeNull(); + }); +}); diff --git a/apps/cala/src/lib/__tests__/run-control.test.ts b/apps/cala/src/lib/__tests__/run-control.test.ts new file mode 100644 index 0000000..2df8604 --- /dev/null +++ b/apps/cala/src/lib/__tests__/run-control.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import type { + WorkerFactory, + WorkerInbound, + WorkerLike, + WorkerOutbound, + WorkerRole, +} from '@calab/cala-runtime'; +import type { FrameSourceMeta } from '@calab/io'; +import { state, setFile, __resetStoreForTests } from '../data-store.ts'; +import { + startRun, + stopRun, + __hasActiveRuntimeForTests, + type WorkerFactories, +} from '../run-control.ts'; + +const WORKER_ROLES: readonly WorkerRole[] = ['decodePreprocess', 'fit', 'extend', 'archive']; + +class FakeWorker implements WorkerLike { + public readonly posted: WorkerInbound[] = []; + public terminated = false; + private readonly listeners = new Set<(ev: { data: WorkerOutbound }) => void>(); + constructor(public readonly role: WorkerRole) {} + postMessage(message: WorkerInbound): void { + this.posted.push(message); + } + addEventListener(_type: 'message', listener: (ev: { data: WorkerOutbound }) => void): void { + this.listeners.add(listener); + } + removeEventListener(_type: 'message', listener: (ev: { data: WorkerOutbound }) => void): void { + this.listeners.delete(listener); + } + terminate(): void { + this.terminated = true; + this.listeners.clear(); + } + push(msg: WorkerOutbound): void { + for (const l of [...this.listeners]) l({ data: msg }); + } +} + +class Harness { + readonly workers = new Map(); + factories(): WorkerFactories { + const make = + (role: WorkerRole): WorkerFactory => + () => { + const w = new FakeWorker(role); + this.workers.set(role, w); + return w; + }; + return { + decodePreprocess: make('decodePreprocess'), + fit: make('fit'), + extend: make('extend'), + archive: make('archive'), + }; + } + get(role: WorkerRole): FakeWorker { + const w = this.workers.get(role); + if (!w) throw new Error(`worker ${role} not spawned`); + return w; + } + pushReadyAll(): void { + for (const [role, w] of this.workers) w.push({ kind: 'ready', role }); + } + pushDoneAll(): void { + for (const [role, w] of this.workers) w.push({ kind: 'done', role }); + } +} + +function makeMeta(overrides: Partial = {}): FrameSourceMeta { + return { + width: 64, + height: 64, + frameCount: 10, + fps: 30, + channels: 1, + bitDepth: 8, + ...overrides, + }; +} + +function seedFile(meta: FrameSourceMeta = makeMeta()): void { + setFile(new File([new Uint8Array(4)], 'fake.avi'), meta); +} + +async function flush(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +describe('cala run-control', () => { + beforeEach(() => { + __resetStoreForTests(); + }); + + afterEach(async () => { + if (__hasActiveRuntimeForTests()) { + try { + await stopRun(); + } catch { + // test cleanup — ignore + } + } + }); + + it('startRun rejects when no file is loaded', async () => { + await expect(startRun()).rejects.toThrow(/no file/); + expect(state.runState).toBe('idle'); + }); + + it('drives idle → starting → running → stopping → stopped across the lifecycle', async () => { + seedFile(); + const harness = new Harness(); + + const runP = startRun({ factories: harness.factories() }); + await flush(); + expect(state.runState).toBe('starting'); + + harness.pushReadyAll(); + await flush(); + expect(state.runState).toBe('running'); + + const stopP = stopRun(); + await flush(); + expect(state.runState).toBe('stopping'); + + harness.pushDoneAll(); + await Promise.all([stopP, runP]); + expect(state.runState).toBe('stopped'); + expect(__hasActiveRuntimeForTests()).toBe(false); + }); + + it('posts init to all four workers on start', async () => { + seedFile(); + const harness = new Harness(); + const runP = startRun({ factories: harness.factories() }); + await flush(); + for (const role of WORKER_ROLES) { + const w = harness.get(role); + expect(w.posted.length).toBeGreaterThanOrEqual(1); + expect(w.posted[0].kind).toBe('init'); + } + harness.pushReadyAll(); + await flush(); + harness.pushDoneAll(); + await stopRun(); + await runP.catch(() => {}); + }); + + it('surfaces worker errors to the store and transitions to error', async () => { + seedFile(); + const harness = new Harness(); + const runP = startRun({ factories: harness.factories() }); + await flush(); + + harness.pushReadyAll(); + await flush(); + expect(state.runState).toBe('running'); + + harness.get('fit').push({ kind: 'error', role: 'fit', message: 'boom' }); + await expect(runP).rejects.toThrow(); + expect(state.runState).toBe('error'); + expect(state.errorMsg).toContain('boom'); + expect(__hasActiveRuntimeForTests()).toBe(false); + }); + + it('stopRun is a no-op when no run is active', async () => { + await expect(stopRun()).resolves.toBeUndefined(); + expect(state.runState).toBe('idle'); + }); +}); diff --git a/apps/cala/src/lib/data-store.ts b/apps/cala/src/lib/data-store.ts new file mode 100644 index 0000000..3af2adb --- /dev/null +++ b/apps/cala/src/lib/data-store.ts @@ -0,0 +1,44 @@ +import { createStore } from 'solid-js/store'; +import type { FrameSourceMeta } from '@calab/io'; +import type { RuntimeState } from '@calab/cala-runtime'; + +export interface CaLaStoreState { + file: File | null; + meta: FrameSourceMeta | null; + runState: RuntimeState; + errorMsg: string | null; +} + +const INITIAL_STATE: CaLaStoreState = { + file: null, + meta: null, + runState: 'idle', + errorMsg: null, +}; + +const [state, setState] = createStore({ ...INITIAL_STATE }); + +export { state }; + +export function setFile(file: File, meta: FrameSourceMeta): void { + setState({ file, meta, errorMsg: null }); +} + +export function clearFile(): void { + setState({ ...INITIAL_STATE }); +} + +export function setRunState(runState: RuntimeState): void { + setState('runState', runState); +} + +export function setErrorMsg(errorMsg: string | null): void { + setState('errorMsg', errorMsg); +} + +// Test-only reset so tests don't bleed state across cases. The module- +// level store is a singleton by design (the UI reads from it), so tests +// call this in beforeEach. +export function __resetStoreForTests(): void { + setState({ ...INITIAL_STATE }); +} diff --git a/apps/cala/src/lib/run-control.ts b/apps/cala/src/lib/run-control.ts new file mode 100644 index 0000000..039825c --- /dev/null +++ b/apps/cala/src/lib/run-control.ts @@ -0,0 +1,157 @@ +import { openAviUncompressed } from '@calab/io'; +import type { FrameSource, FrameSourceMeta } from '@calab/io'; +import { + createRuntime, + type RuntimeConfig, + type RuntimeController, + type RuntimeState, + type WorkerFactory, + type WorkerLike, + type WorkerRole, +} from '@calab/cala-runtime'; +import { state, setRunState, setErrorMsg } from './data-store.ts'; + +// Ring / queue sizing defaults (design §7.1, §7.3, §13). +// Kept in one place so future tuning passes have a single knob per +// parameter. Values err conservative: depths large enough that a brief +// scheduler hiccup on any single worker doesn't instantly overflow. +const DEFAULT_FRAME_CHANNEL_SLOTS = 4; +const DEFAULT_RESIDUAL_CHANNEL_SLOTS = 4; +const DEFAULT_CHANNEL_WAIT_TIMEOUT_MS = 50; +const DEFAULT_CHANNEL_POLL_INTERVAL_MS = 1; +const DEFAULT_MUTATION_QUEUE_CAPACITY = 32; +const DEFAULT_SNAPSHOT_ACK_TIMEOUT_MS = 1000; +const DEFAULT_SNAPSHOT_PENDING_CAPACITY = 2; +const DEFAULT_SNAPSHOT_POLL_INTERVAL_MS = 5; +const DEFAULT_EVENT_BUS_CAPACITY = 1024; +const DEFAULT_EVENT_BUS_MAX_SUBSCRIBERS = 8; +const DEFAULT_STARTUP_TIMEOUT_MS = 5000; +const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5000; +// f32 grayscale → 4 bytes per pixel. +const BYTES_PER_F32_PIXEL = 4; + +export type WorkerFactories = Record; + +function noopWorker(): WorkerLike { + // Stub worker for tests and for wiring in the UI before the real + // workers land (tasks 21-23). Never signals ready, never signals + // done — the real factories override this at call-site. + return { + postMessage: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + terminate: () => {}, + }; +} + +function defaultWorkerFactories(): WorkerFactories { + return { + decodePreprocess: noopWorker, + fit: noopWorker, + extend: noopWorker, + archive: noopWorker, + }; +} + +function buildConfig(meta: FrameSourceMeta, factories: WorkerFactories): RuntimeConfig { + const frameBytes = meta.width * meta.height * BYTES_PER_F32_PIXEL; + return { + workerFactories: factories, + frameChannel: { + slotBytes: frameBytes, + slotCount: DEFAULT_FRAME_CHANNEL_SLOTS, + waitTimeoutMs: DEFAULT_CHANNEL_WAIT_TIMEOUT_MS, + pollIntervalMs: DEFAULT_CHANNEL_POLL_INTERVAL_MS, + }, + residualChannel: { + slotBytes: frameBytes, + slotCount: DEFAULT_RESIDUAL_CHANNEL_SLOTS, + waitTimeoutMs: DEFAULT_CHANNEL_WAIT_TIMEOUT_MS, + pollIntervalMs: DEFAULT_CHANNEL_POLL_INTERVAL_MS, + }, + mutationQueue: { capacity: DEFAULT_MUTATION_QUEUE_CAPACITY }, + snapshotProtocol: { + ackTimeoutMs: DEFAULT_SNAPSHOT_ACK_TIMEOUT_MS, + pendingCapacity: DEFAULT_SNAPSHOT_PENDING_CAPACITY, + pollIntervalMs: DEFAULT_SNAPSHOT_POLL_INTERVAL_MS, + }, + eventBus: { + capacity: DEFAULT_EVENT_BUS_CAPACITY, + maxSubscribers: DEFAULT_EVENT_BUS_MAX_SUBSCRIBERS, + }, + startupTimeoutMs: DEFAULT_STARTUP_TIMEOUT_MS, + shutdownTimeoutMs: DEFAULT_SHUTDOWN_TIMEOUT_MS, + }; +} + +// Opaque thunk the runtime passes to the decoder worker (design §7). +// `RuntimeConfig.sources.frameSourceFactory` is typed `unknown`, so we +// build it here as a plain function that returns a `FrameSource` and +// cast to `unknown` at the `RuntimeSource` boundary. +type FrameSourceFactory = (file: File) => Promise; +const frameSourceFactory: FrameSourceFactory = openAviUncompressed; + +let currentRuntime: RuntimeController | null = null; +let currentUnsubscribe: (() => void) | null = null; + +export interface StartOptions { + factories?: WorkerFactories; +} + +export async function startRun(opts: StartOptions = {}): Promise { + if (currentRuntime !== null) { + throw new Error('run already in progress'); + } + const file = state.file; + const meta = state.meta; + if (file === null || meta === null) { + throw new Error('no file loaded'); + } + + setErrorMsg(null); + setRunState('starting'); + + const factories = opts.factories ?? defaultWorkerFactories(); + const cfg = buildConfig(meta, factories); + const rt = createRuntime(cfg); + currentRuntime = rt; + currentUnsubscribe = rt.onStatus((status) => { + setRunState(status.state); + if (status.error !== undefined) setErrorMsg(status.error); + }); + + const source = { + kind: 'file' as const, + file, + frameSourceFactory: frameSourceFactory as unknown, + }; + + try { + await rt.run(source); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setErrorMsg(msg); + setRunState('error'); + throw err; + } finally { + currentUnsubscribe?.(); + currentUnsubscribe = null; + currentRuntime = null; + } +} + +export async function stopRun(): Promise { + const rt = currentRuntime; + if (rt === null) return; + await rt.stop(); +} + +export function currentRunState(): RuntimeState { + return state.runState; +} + +// Test-only hook so the lifecycle test can inspect whether the +// runtime handle has been released. +export function __hasActiveRuntimeForTests(): boolean { + return currentRuntime !== null; +} diff --git a/apps/cala/src/styles/global.css b/apps/cala/src/styles/global.css index 09af588..9719a19 100644 --- a/apps/cala/src/styles/global.css +++ b/apps/cala/src/styles/global.css @@ -1 +1,21 @@ /* CaLa app-specific styles. Design tokens inherit from @calab/ui. */ + +.info-summary { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: var(--space-sm); + background: var(--bg-secondary); + border-radius: var(--radius-sm); + padding: var(--space-sm) var(--space-md); + margin-bottom: var(--space-md); + font-size: 0.9rem; + font-family: var(--font-mono); + color: var(--text-secondary); + border: 1px solid var(--border-subtle); +} + +.info-summary__sep { + color: var(--text-tertiary); +} diff --git a/apps/cala/vitest.config.ts b/apps/cala/vitest.config.ts new file mode 100644 index 0000000..82cd5e2 --- /dev/null +++ b/apps/cala/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; +import solidPlugin from 'vite-plugin-solid'; + +export default defineConfig({ + plugins: [solidPlugin()], + test: { + passWithNoTests: false, + environmentMatchGlobs: [['src/lib/__tests__/**', 'node']], + }, +}); From 6ecd2c923b5f3ff7e1e00927ec1471f6fc34f6c7 Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 18 Apr 2026 14:35:18 -0700 Subject: [PATCH 11/17] feat(cala): add extend stub + archive worker (task 23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend ships as a heartbeat-only stub so the orchestrator's 4-worker lifecycle is exercisable end-to-end; the real snapshot/segmentation loop lands post-Phase 5. Archive is full: drop-oldest event log + per-name metric snapshot, served via new request-archive-dump / archive-dump protocol variants (design §9.2, §10). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../workers/__tests__/archive.worker.test.ts | 202 ++++++++++++++++ .../workers/__tests__/extend.worker.test.ts | 150 ++++++++++++ apps/cala/src/workers/archive.worker.ts | 215 ++++++++++++++++++ apps/cala/src/workers/extend.worker.ts | 204 +++++++++++++++++ apps/cala/src/workers/index.ts | 12 + packages/cala-runtime/src/worker-protocol.ts | 23 +- 6 files changed, 804 insertions(+), 2 deletions(-) create mode 100644 apps/cala/src/workers/__tests__/archive.worker.test.ts create mode 100644 apps/cala/src/workers/__tests__/extend.worker.test.ts create mode 100644 apps/cala/src/workers/archive.worker.ts create mode 100644 apps/cala/src/workers/extend.worker.ts diff --git a/apps/cala/src/workers/__tests__/archive.worker.test.ts b/apps/cala/src/workers/__tests__/archive.worker.test.ts new file mode 100644 index 0000000..438e547 --- /dev/null +++ b/apps/cala/src/workers/__tests__/archive.worker.test.ts @@ -0,0 +1,202 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { + PipelineEvent, + WorkerInbound, + WorkerOutbound, +} from '@calab/cala-runtime'; +import { createWorkerHarness, type WorkerHarness } from './worker-harness.ts'; + +// Small capacities keep drop-oldest behaviour observable in-test without +// arbitrary numbers leaking from production defaults. +const TEST_EVENT_RING_CAPACITY = 4; +const TEST_METRIC_WINDOW = 16; + +function makeInitMsg(overrides: Record = {}): WorkerInbound { + return { + kind: 'init', + payload: { + role: 'archive', + frameChannelBuffer: new ArrayBuffer(8), + residualChannelBuffer: new ArrayBuffer(8), + workerConfig: { + eventRingCapacity: TEST_EVENT_RING_CAPACITY, + metricWindow: TEST_METRIC_WINDOW, + ...overrides, + }, + }, + }; +} + +function metricEvent(t: number, name: string, value: number): PipelineEvent { + return { kind: 'metric', t, name, value }; +} + +function birthEvent(t: number, id: number): PipelineEvent { + return { + kind: 'birth', + t, + id, + patch: [0, 0], + footprintSnap: { + pixelIndices: new Uint32Array([id]), + values: new Float32Array([1]), + }, + }; +} + +async function runUntil( + harness: WorkerHarness, + predicate: (posted: WorkerOutbound[]) => boolean, + maxTicks = 1000, +): Promise { + for (let i = 0; i < maxTicks; i += 1) { + if (predicate(harness.posted)) return; + await new Promise((r) => setTimeout(r, 0)); + } + if (!predicate(harness.posted)) { + throw new Error('runUntil timed out'); + } +} + +async function loadWorker(harness: WorkerHarness): Promise { + vi.stubGlobal('self', harness.self); + await import('../archive.worker.ts'); +} + +// Type-level guard for the protocol extension this task adds. +// Failure to compile here means the inbound `event` / +// `request-archive-dump` or outbound `archive-dump` variants +// regressed — breaking the archive worker contract with the +// orchestrator and task 24's dashboard client. +describe('worker-protocol archive extension compiles', () => { + it('accepts the new inbound and outbound variants', () => { + const inEvent: WorkerInbound = { + kind: 'event', + event: { kind: 'metric', t: 0, name: 'x', value: 1 }, + }; + const inDumpReq: WorkerInbound = { kind: 'request-archive-dump', requestId: 1 }; + const outDump: WorkerOutbound = { + kind: 'archive-dump', + role: 'archive', + requestId: 1, + events: [], + metrics: {}, + }; + expect(inEvent.kind).toBe('event'); + expect(inDumpReq.kind).toBe('request-archive-dump'); + expect(outDump.kind).toBe('archive-dump'); + }); +}); + +describe('archive worker', () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('responds to init with ready', async () => { + const harness = createWorkerHarness(); + await loadWorker(harness); + await harness.deliver(makeInitMsg()); + await runUntil(harness, (p) => p.some((m) => m.kind === 'ready')); + const ready = harness.posted.find((m) => m.kind === 'ready'); + expect(ready).toEqual({ kind: 'ready', role: 'archive' }); + }); + + it('appends events to the log and drops oldest once capacity is reached', async () => { + const harness = createWorkerHarness(); + await loadWorker(harness); + await harness.deliver(makeInitMsg()); + await runUntil(harness, (p) => p.some((m) => m.kind === 'ready')); + await harness.deliver({ kind: 'run' }); + + // Push capacity + 2 events; the first two should be dropped. + for (let i = 0; i < TEST_EVENT_RING_CAPACITY + 2; i += 1) { + await harness.deliver({ kind: 'event', event: birthEvent(i, i) }); + } + + await harness.deliver({ kind: 'request-archive-dump', requestId: 7 }); + await runUntil(harness, (p) => p.some((m) => m.kind === 'archive-dump')); + const dump = harness.posted.find((m) => m.kind === 'archive-dump') as Extract< + WorkerOutbound, + { kind: 'archive-dump' } + >; + expect(dump.requestId).toBe(7); + expect(dump.events.length).toBe(TEST_EVENT_RING_CAPACITY); + // Oldest two (ids 0, 1) should have been evicted; newest ids should remain. + const ids = dump.events + .filter((e): e is Extract => e.kind === 'birth') + .map((e) => e.id); + expect(ids).toEqual([2, 3, 4, 5]); + }); + + it('updates the per-name metric snapshot from metric events', async () => { + const harness = createWorkerHarness(); + await loadWorker(harness); + await harness.deliver(makeInitMsg()); + await runUntil(harness, (p) => p.some((m) => m.kind === 'ready')); + await harness.deliver({ kind: 'run' }); + + await harness.deliver({ kind: 'event', event: metricEvent(1, 'residual_l2', 0.5) }); + await harness.deliver({ kind: 'event', event: metricEvent(2, 'cell_count', 12) }); + // Overwrite is last-writer-wins for a given name. + await harness.deliver({ kind: 'event', event: metricEvent(3, 'residual_l2', 0.2) }); + + await harness.deliver({ kind: 'request-archive-dump', requestId: 42 }); + await runUntil(harness, (p) => p.some((m) => m.kind === 'archive-dump')); + const dump = harness.posted.find((m) => m.kind === 'archive-dump') as Extract< + WorkerOutbound, + { kind: 'archive-dump' } + >; + expect(dump.metrics).toEqual({ residual_l2: 0.2, cell_count: 12 }); + }); + + it('request-archive-dump returns the right shape and correlates requestId', async () => { + const harness = createWorkerHarness(); + await loadWorker(harness); + await harness.deliver(makeInitMsg()); + await runUntil(harness, (p) => p.some((m) => m.kind === 'ready')); + await harness.deliver({ kind: 'run' }); + + await harness.deliver({ kind: 'event', event: birthEvent(1, 1) }); + await harness.deliver({ kind: 'event', event: metricEvent(2, 'fps', 30) }); + + await harness.deliver({ kind: 'request-archive-dump', requestId: 101 }); + await harness.deliver({ kind: 'request-archive-dump', requestId: 202 }); + await runUntil( + harness, + (p) => p.filter((m) => m.kind === 'archive-dump').length >= 2, + ); + const dumps = harness.posted.filter( + (m): m is Extract => + m.kind === 'archive-dump', + ); + expect(dumps.map((d) => d.requestId)).toEqual([101, 202]); + for (const d of dumps) { + expect(d.role).toBe('archive'); + expect(Array.isArray(d.events)).toBe(true); + expect(d.events.length).toBe(2); + expect(d.metrics.fps).toBe(30); + } + }); + + it('stop posts done exactly once even if events arrive after stop', async () => { + const harness = createWorkerHarness(); + await loadWorker(harness); + await harness.deliver(makeInitMsg()); + await runUntil(harness, (p) => p.some((m) => m.kind === 'ready')); + await harness.deliver({ kind: 'run' }); + + await harness.deliver({ kind: 'event', event: metricEvent(1, 'a', 1) }); + await harness.deliver({ kind: 'stop' }); + await runUntil(harness, (p) => p.some((m) => m.kind === 'done')); + + // Post-stop events must not mutate state or trigger further `done`. + await harness.deliver({ kind: 'event', event: metricEvent(2, 'a', 99) }); + const doneCount = harness.posted.filter((m) => m.kind === 'done').length; + expect(doneCount).toBe(1); + }); +}); diff --git a/apps/cala/src/workers/__tests__/extend.worker.test.ts b/apps/cala/src/workers/__tests__/extend.worker.test.ts new file mode 100644 index 0000000..f90ab1e --- /dev/null +++ b/apps/cala/src/workers/__tests__/extend.worker.test.ts @@ -0,0 +1,150 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { WorkerInbound, WorkerOutbound } from '@calab/cala-runtime'; +import { createWorkerHarness, type WorkerHarness } from './worker-harness.ts'; + +// Tiny stride so tests can observe heartbeats without waiting real time. +const TEST_HEARTBEAT_STRIDE_MS = 5; +const TEST_TICK_INTERVAL_MS = 1; + +function makeInitMsg(overrides: Record = {}): WorkerInbound { + return { + kind: 'init', + payload: { + role: 'extend', + frameChannelBuffer: new ArrayBuffer(8), + residualChannelBuffer: new ArrayBuffer(8), + workerConfig: { + heartbeatStrideMs: TEST_HEARTBEAT_STRIDE_MS, + tickIntervalMs: TEST_TICK_INTERVAL_MS, + ...overrides, + }, + }, + }; +} + +async function runUntil( + harness: WorkerHarness, + predicate: (posted: WorkerOutbound[]) => boolean, + maxTicks = 1000, +): Promise { + for (let i = 0; i < maxTicks; i += 1) { + if (predicate(harness.posted)) return; + await new Promise((r) => setTimeout(r, 0)); + } + if (!predicate(harness.posted)) { + throw new Error('runUntil timed out'); + } +} + +async function loadWorker(harness: WorkerHarness): Promise { + vi.stubGlobal('self', harness.self); + await import('../extend.worker.ts'); +} + +describe('extend worker (stub)', () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('responds to init with ready', async () => { + const harness = createWorkerHarness(); + await loadWorker(harness); + await harness.deliver(makeInitMsg()); + await runUntil(harness, (p) => p.some((m) => m.kind === 'ready')); + const ready = harness.posted.find((m) => m.kind === 'ready'); + expect(ready).toEqual({ kind: 'ready', role: 'extend' }); + }); + + it('stop before run posts done without emitting any heartbeat', async () => { + const harness = createWorkerHarness(); + await loadWorker(harness); + await harness.deliver(makeInitMsg()); + await runUntil(harness, (p) => p.some((m) => m.kind === 'ready')); + await harness.deliver({ kind: 'stop' }); + await runUntil(harness, (p) => p.some((m) => m.kind === 'done')); + expect(harness.posted.some((m) => m.kind === 'frame-processed')).toBe(false); + expect(harness.posted.some((m) => m.kind === 'event')).toBe(false); + // `done` is posted exactly once for the stop path. + expect(harness.posted.filter((m) => m.kind === 'done').length).toBe(1); + }); + + it('run emits frame-processed heartbeats and a bus event after a new snapshot ack', async () => { + const harness = createWorkerHarness(); + await loadWorker(harness); + await harness.deliver(makeInitMsg()); + await runUntil(harness, (p) => p.some((m) => m.kind === 'ready')); + await harness.deliver({ kind: 'run' }); + + // Heartbeats fire first; bus event only fires once a new snapshot + // is observed (design §7.2 — extend advances epoch on ack). + await runUntil(harness, (p) => p.some((m) => m.kind === 'frame-processed')); + await harness.deliver({ + kind: 'snapshot-ack', + requestId: 1, + epoch: 3n, + numComponents: 0, + pixels: 0, + }); + + await runUntil( + harness, + (p) => + p.some((m) => m.kind === 'frame-processed') && p.some((m) => m.kind === 'event'), + ); + + const heartbeat = harness.posted.find((m) => m.kind === 'frame-processed'); + expect(heartbeat).toMatchObject({ kind: 'frame-processed', role: 'extend' }); + + const eventMsg = harness.posted.find( + (m): m is Extract => m.kind === 'event', + ); + expect(eventMsg?.role).toBe('extend'); + expect(eventMsg?.event.kind).toBe('metric'); + if (eventMsg?.event.kind === 'metric') { + expect(eventMsg.event.name).toBe('extend.heartbeat'); + } + + await harness.deliver({ kind: 'stop' }); + await runUntil(harness, (p) => p.some((m) => m.kind === 'done')); + }); + + it('snapshot-ack advances lastObservedEpoch reported in subsequent heartbeats', async () => { + const harness = createWorkerHarness(); + await loadWorker(harness); + await harness.deliver(makeInitMsg()); + await runUntil(harness, (p) => p.some((m) => m.kind === 'ready')); + await harness.deliver({ kind: 'run' }); + + await runUntil(harness, (p) => p.some((m) => m.kind === 'frame-processed')); + const beforeAck = harness.posted + .filter( + (m): m is Extract => + m.kind === 'frame-processed', + ) + .pop(); + expect(beforeAck?.epoch).toBe(0n); + + await harness.deliver({ + kind: 'snapshot-ack', + requestId: 1, + epoch: 5n, + numComponents: 0, + pixels: 0, + }); + + await runUntil(harness, (p) => { + const beats = p.filter( + (m): m is Extract => + m.kind === 'frame-processed', + ); + return beats.some((b) => b.epoch === 5n); + }); + + await harness.deliver({ kind: 'stop' }); + await runUntil(harness, (p) => p.some((m) => m.kind === 'done')); + }); +}); diff --git a/apps/cala/src/workers/archive.worker.ts b/apps/cala/src/workers/archive.worker.ts new file mode 100644 index 0000000..1b4aff7 --- /dev/null +++ b/apps/cala/src/workers/archive.worker.ts @@ -0,0 +1,215 @@ +/** + * W4 — archive worker (design §9, §10). + * + * Subscribes to the pipeline event bus, maintains: + * + * 1. A rolling drop-oldest ring of raw `PipelineEvent`s (for the + * dashboard event feed + export). Capacity-bounded per §9.2. + * 2. A per-name metric snapshot — the latest value for each + * `{kind:'metric', name, value}` stream. §9.1 describes the full + * tiered timeseries; this stub ships the "latest value" surface + * the task 24 dashboard needs, and keeps the door open for + * per-name ring buffers without changing the public reply shape. + * + * The worker does not compute — it only stores and answers queries. + * + * Event transport: the orchestrator forwards every fit-emitted + * `PipelineEvent` via the `{ kind: 'event', event }` inbound variant + * (worker-protocol.ts). We fan those out through a local `EventBus` + * so the log-append callback and the metric-snapshot callback each + * subscribe independently — matching the "bus consumer" model in + * design §9.2 and making future additional subscribers a one-liner. + */ + +import { + EventBus, + type PipelineEvent, + type WorkerInbound, + type WorkerInitPayload, + type WorkerOutbound, +} from '@calab/cala-runtime'; + +// Rolling event log capacity. Design §9.2 sizes ~500 structural +// events per typical session at ~2 KB each → ~1 MB budget; we default +// generously but tuneable via `workerConfig.eventRingCapacity`. +const DEFAULT_EVENT_RING_CAPACITY = 4096; +// Metric-snapshot entry cap. Bounds the per-name map so a misbehaving +// upstream cannot balloon memory. Overridable via +// `workerConfig.metricWindow`. +const DEFAULT_METRIC_WINDOW = 256; +// Local EventBus sizing. Archive is the sole subscriber post-init and +// drains synchronously, so these are effectively no-backpressure +// defaults — but they live in config per the no-magic-numbers rule. +const DEFAULT_LOCAL_BUS_CAPACITY = 64; +const DEFAULT_LOCAL_BUS_MAX_SUBSCRIBERS = 4; + +const ROLE = 'archive' as const; + +interface WorkerGlobalScope { + postMessage(msg: WorkerOutbound): void; + onmessage: ((ev: MessageEvent) => void) | null; +} + +interface ArchiveWorkerConfig { + eventRingCapacity: number; + metricWindow: number; + localBusCapacity: number; + localBusMaxSubscribers: number; +} + +const workerSelf = ((globalThis as unknown as { self?: WorkerGlobalScope }).self ?? + (globalThis as unknown as WorkerGlobalScope)) as WorkerGlobalScope; + +interface RuntimeHandles { + cfg: ArchiveWorkerConfig; + bus: EventBus; + unsubscribeLog: () => void; + unsubscribeMetrics: () => void; + // Drop-oldest ring. Array-backed because `PipelineEvent` carries + // typed-array payloads that we keep by reference — flattening into a + // single `Uint8Array` would force serialization the dashboard does + // not need. + eventLog: PipelineEvent[]; + metricSnapshot: Map; + running: boolean; + stopped: boolean; +} + +let handles: RuntimeHandles | null = null; +let donePosted = false; + +function post(msg: WorkerOutbound): void { + workerSelf.postMessage(msg); +} + +function postError(err: unknown): void { + const message = err instanceof Error ? err.message : String(err); + post({ kind: 'error', role: ROLE, message }); +} + +function asRecord(value: unknown): Record { + return typeof value === 'object' && value !== null ? (value as Record) : {}; +} + +function parseConfig(raw: unknown): ArchiveWorkerConfig { + const cfg = asRecord(raw); + const pickPositiveInt = (key: string, fallback: number): number => { + const v = cfg[key]; + return typeof v === 'number' && Number.isInteger(v) && v > 0 ? v : fallback; + }; + return { + eventRingCapacity: pickPositiveInt('eventRingCapacity', DEFAULT_EVENT_RING_CAPACITY), + metricWindow: pickPositiveInt('metricWindow', DEFAULT_METRIC_WINDOW), + localBusCapacity: pickPositiveInt('localBusCapacity', DEFAULT_LOCAL_BUS_CAPACITY), + localBusMaxSubscribers: pickPositiveInt( + 'localBusMaxSubscribers', + DEFAULT_LOCAL_BUS_MAX_SUBSCRIBERS, + ), + }; +} + +function handleInit(payload: WorkerInitPayload): void { + const cfg = parseConfig(payload.workerConfig); + const bus = new EventBus({ + capacity: cfg.localBusCapacity, + maxSubscribers: cfg.localBusMaxSubscribers, + }); + const eventLog: PipelineEvent[] = []; + const metricSnapshot = new Map(); + + const unsubscribeLog = bus.subscribe((e) => { + if (eventLog.length === cfg.eventRingCapacity) { + eventLog.shift(); + } + eventLog.push(e); + }); + + const unsubscribeMetrics = bus.subscribe((e) => { + if (e.kind !== 'metric') return; + // Last-writer-wins on name. When we exceed the metric window, we + // drop the *oldest-inserted* name — Map iteration order gives us + // insertion order for free. + if (!metricSnapshot.has(e.name) && metricSnapshot.size >= cfg.metricWindow) { + const oldest = metricSnapshot.keys().next().value; + if (oldest !== undefined) metricSnapshot.delete(oldest); + } + metricSnapshot.set(e.name, e.value); + }); + + handles = { + cfg, + bus, + unsubscribeLog, + unsubscribeMetrics, + eventLog, + metricSnapshot, + running: false, + stopped: false, + }; + post({ kind: 'ready', role: ROLE }); +} + +function handleEvent(event: PipelineEvent): void { + if (!handles || handles.stopped) return; + handles.bus.publish(event); +} + +function handleDumpRequest(requestId: number): void { + if (!handles) return; + post({ + kind: 'archive-dump', + role: ROLE, + requestId, + // Copy so the caller can't mutate archive-internal state via the + // returned reference; the typed-array payloads inside each event + // remain by-reference (same contract as EventBus subscribers). + events: handles.eventLog.slice(), + metrics: Object.fromEntries(handles.metricSnapshot), + }); +} + +function postDoneOnce(): void { + if (donePosted) return; + donePosted = true; + post({ kind: 'done', role: ROLE }); +} + +function handleStop(): void { + if (!handles) { + postDoneOnce(); + return; + } + handles.stopped = true; + handles.unsubscribeLog(); + handles.unsubscribeMetrics(); + handles.bus.close(); + postDoneOnce(); +} + +workerSelf.onmessage = (ev: MessageEvent): void => { + const msg = ev.data; + switch (msg.kind) { + case 'init': + try { + handleInit(msg.payload); + } catch (err) { + postError(err); + } + return; + case 'run': + if (handles) handles.running = true; + return; + case 'event': + handleEvent(msg.event); + return; + case 'request-archive-dump': + handleDumpRequest(msg.requestId); + return; + case 'stop': + handleStop(); + return; + case 'snapshot-ack': + // Archive does not participate in the snapshot protocol. + return; + } +}; diff --git a/apps/cala/src/workers/extend.worker.ts b/apps/cala/src/workers/extend.worker.ts new file mode 100644 index 0000000..2f73d70 --- /dev/null +++ b/apps/cala/src/workers/extend.worker.ts @@ -0,0 +1,204 @@ +/** + * W3 — extend worker (STUB for Phase 5, Task 23). + * + * The real extend loop (snapshot request → segmentation → mutation + * publish against the `(Ã, W, M)` view it snapshotted) lands in a + * follow-on phase. Here we ship just enough to exercise the + * orchestrator's 4-worker lifecycle (design §7) in the Phase 5 exit + * E2E: a heartbeat tick that observes snapshot-ack epoch advances and + * surfaces one metric event + one `frame-processed` message per + * stride. + * + * Explicitly NOT in this stub: reading preprocessed frames, any trace + * maths, any `cala-core` WASM call, any mutation publish. Keeping the + * surface minimal here prevents half-baked fit-adjacent code from + * calcifying. + */ + +import type { + WorkerInbound, + WorkerInitPayload, + WorkerOutbound, + PipelineEvent, +} from '@calab/cala-runtime'; + +// Heartbeat cadence in ms. Rationale: extend's real cycle is "next +// frame boundary after snapshot", not a wall-clock tick; but until +// that logic lands we need a deterministic lifecycle pulse for the +// orchestrator's readiness/done handshake. Overridable via +// `workerConfig.heartbeatStrideMs` (no-magic-numbers rule). +const DEFAULT_HEARTBEAT_STRIDE_MS = 500; +// Inner tick granularity: how often the loop wakes to re-check stop +// and accumulate time toward the next heartbeat. Short enough that +// `stop` feels prompt, long enough not to burn CPU in the stub. +const DEFAULT_TICK_INTERVAL_MS = 10; + +const ROLE = 'extend' as const; + +interface WorkerGlobalScope { + postMessage(msg: WorkerOutbound): void; + onmessage: ((ev: MessageEvent) => void) | null; +} + +interface ExtendWorkerConfig { + heartbeatStrideMs: number; + tickIntervalMs: number; +} + +const workerSelf = ((globalThis as unknown as { self?: WorkerGlobalScope }).self ?? + (globalThis as unknown as WorkerGlobalScope)) as WorkerGlobalScope; + +interface RuntimeHandles { + cfg: ExtendWorkerConfig; + tickCount: number; + lastObservedEpoch: bigint; + // Epoch last published from a snapshot ack that we have not yet + // reflected in a heartbeat. Treated as a single-slot latch so + // heartbeats always surface the most recent ack. + pendingAckEpoch: bigint | null; +} + +let handles: RuntimeHandles | null = null; +let running = false; +let stopRequested = false; +let donePosted = false; +let loopPromise: Promise | null = null; + +function post(msg: WorkerOutbound): void { + workerSelf.postMessage(msg); +} + +function postError(err: unknown): void { + const message = err instanceof Error ? err.message : String(err); + post({ kind: 'error', role: ROLE, message }); +} + +function asRecord(value: unknown): Record { + return typeof value === 'object' && value !== null ? (value as Record) : {}; +} + +function parseConfig(raw: unknown): ExtendWorkerConfig { + const cfg = asRecord(raw); + return { + heartbeatStrideMs: + typeof cfg.heartbeatStrideMs === 'number' && cfg.heartbeatStrideMs > 0 + ? cfg.heartbeatStrideMs + : DEFAULT_HEARTBEAT_STRIDE_MS, + tickIntervalMs: + typeof cfg.tickIntervalMs === 'number' && cfg.tickIntervalMs > 0 + ? cfg.tickIntervalMs + : DEFAULT_TICK_INTERVAL_MS, + }; +} + +function handleInit(payload: WorkerInitPayload): void { + const cfg = parseConfig(payload.workerConfig); + handles = { + cfg, + tickCount: 0, + lastObservedEpoch: 0n, + pendingAckEpoch: null, + }; + post({ kind: 'ready', role: ROLE }); +} + +async function sleep(ms: number): Promise { + await new Promise((r) => setTimeout(r, ms)); +} + +async function heartbeatLoop(h: RuntimeHandles): Promise { + let sinceLastBeatMs = 0; + while (!stopRequested) { + await sleep(h.cfg.tickIntervalMs); + if (stopRequested) return; + sinceLastBeatMs += h.cfg.tickIntervalMs; + if (sinceLastBeatMs < h.cfg.heartbeatStrideMs) continue; + sinceLastBeatMs = 0; + h.tickCount += 1; + + // Latch consumption: if a snapshot ack arrived since the previous + // heartbeat, advance epoch and publish the corresponding metric + // event. Otherwise just emit the frame-processed beat. + const newlyObserved = h.pendingAckEpoch; + if (newlyObserved !== null && newlyObserved > h.lastObservedEpoch) { + h.lastObservedEpoch = newlyObserved; + h.pendingAckEpoch = null; + const metric: PipelineEvent = { + kind: 'metric', + t: h.tickCount, + name: 'extend.heartbeat', + value: h.tickCount, + }; + post({ kind: 'event', role: ROLE, event: metric }); + } + + post({ + kind: 'frame-processed', + role: ROLE, + index: h.tickCount, + epoch: h.lastObservedEpoch, + }); + } +} + +function postDoneOnce(): void { + if (donePosted) return; + donePosted = true; + post({ kind: 'done', role: ROLE }); +} + +async function handleRun(): Promise { + if (!handles) { + postError(new Error("'run' received before successful 'init'")); + return; + } + if (running) return; + running = true; + stopRequested = false; + donePosted = false; + const h = handles; + try { + await heartbeatLoop(h); + postDoneOnce(); + } catch (err) { + postError(err); + } finally { + running = false; + } +} + +async function handleStop(): Promise { + stopRequested = true; + if (loopPromise) await loopPromise; + postDoneOnce(); +} + +workerSelf.onmessage = (ev: MessageEvent): void => { + const msg = ev.data; + switch (msg.kind) { + case 'init': + try { + handleInit(msg.payload); + } catch (err) { + postError(err); + } + return; + case 'run': + loopPromise = handleRun(); + return; + case 'stop': + handleStop().catch(postError); + return; + case 'snapshot-ack': + if (handles) { + // Latch — real extend would gate segmentation on this; stub + // just stamps the epoch onto subsequent heartbeats. + handles.pendingAckEpoch = msg.epoch; + } + return; + case 'event': + case 'request-archive-dump': + // Extend never consumes these — archive-targeted messages. + return; + } +}; diff --git a/apps/cala/src/workers/index.ts b/apps/cala/src/workers/index.ts index 4edb847..223720c 100644 --- a/apps/cala/src/workers/index.ts +++ b/apps/cala/src/workers/index.ts @@ -5,3 +5,15 @@ export function createDecodePreprocessWorker(): WorkerLike { type: 'module', }) as unknown as WorkerLike; } + +export function createExtendWorker(): WorkerLike { + return new Worker(new URL('./extend.worker.ts', import.meta.url), { + type: 'module', + }) as unknown as WorkerLike; +} + +export function createArchiveWorker(): WorkerLike { + return new Worker(new URL('./archive.worker.ts', import.meta.url), { + type: 'module', + }) as unknown as WorkerLike; +} diff --git a/packages/cala-runtime/src/worker-protocol.ts b/packages/cala-runtime/src/worker-protocol.ts index 4ef832d..830e018 100644 --- a/packages/cala-runtime/src/worker-protocol.ts +++ b/packages/cala-runtime/src/worker-protocol.ts @@ -43,7 +43,16 @@ export type WorkerInbound = epoch: bigint; numComponents: number; pixels: number; - }; + } + // Orchestrator forwards each fit-emitted `PipelineEvent` to the + // archive worker (design §9.2). The archive-worker side replays + // these onto its local `EventBus` so log append + metric snapshot + // share one subscription path. + | { kind: 'event'; event: PipelineEvent } + // Main-thread dashboard (task 24) asks for a consistent dump of the + // archive's in-memory event log and per-name metric snapshot. + // `requestId` correlates each dump with the eventual reply. + | { kind: 'request-archive-dump'; requestId: number }; /** Messages a worker sends back to the orchestrator. */ export type WorkerOutbound = @@ -53,7 +62,17 @@ export type WorkerOutbound = | { kind: 'snapshot-request'; role: WorkerRole; requestId: number } | { kind: 'event'; role: WorkerRole; event: PipelineEvent } | { kind: 'error'; role: WorkerRole; message: string } - | { kind: 'done'; role: WorkerRole }; + | { kind: 'done'; role: WorkerRole } + // Archive worker reply to `request-archive-dump`. `events` is a + // snapshot of the rolling log (oldest→newest); `metrics` is the + // current per-name scalar snapshot (design §9.1 / §10). + | { + kind: 'archive-dump'; + role: WorkerRole; + requestId: number; + events: PipelineEvent[]; + metrics: Record; + }; /** * Minimal structural subtype of the DOM `Worker` that the orchestrator From ed8c3fb7974caf2b6e35862baa8788a2d36f5dcb Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 18 Apr 2026 14:36:17 -0700 Subject: [PATCH 12/17] feat(cala): add fit worker (task 22) Wires W2 to Fitter + frame channel consumer + MutationQueue/Handle + SnapshotProtocol + EventBus; every stride (heartbeat / snapshot / mutation-drain cap / event-bus capacity) is a FitConfig-overridable DEFAULT_* constant per the no-magic-numbers rule. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/workers/__tests__/fit.worker.test.ts | 451 ++++++++++++++++++ apps/cala/src/workers/fit.worker.ts | 429 +++++++++++++++++ apps/cala/src/workers/index.ts | 6 + 3 files changed, 886 insertions(+) create mode 100644 apps/cala/src/workers/__tests__/fit.worker.test.ts create mode 100644 apps/cala/src/workers/fit.worker.ts diff --git a/apps/cala/src/workers/__tests__/fit.worker.test.ts b/apps/cala/src/workers/__tests__/fit.worker.test.ts new file mode 100644 index 0000000..ec51106 --- /dev/null +++ b/apps/cala/src/workers/__tests__/fit.worker.test.ts @@ -0,0 +1,451 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { + PipelineEvent, + PipelineMutation, + WorkerInbound, + WorkerOutbound, +} from '@calab/cala-runtime'; +import { MutationQueue, SabRingChannel } from '@calab/cala-runtime'; +import { createWorkerHarness, type WorkerHarness } from './worker-harness.ts'; + +interface FitTestHandles { + mutationQueue: MutationQueue; +} + +function getFitHandles(): FitTestHandles | undefined { + return (globalThis as { __calaFitHandles?: FitTestHandles }).__calaFitHandles; +} + +const FRAME_CHANNEL_SLOT_BYTES = 256; +const FRAME_CHANNEL_SLOT_COUNT = 64; +const FRAME_CHANNEL_WAIT_TIMEOUT_MS = 50; +const FRAME_CHANNEL_POLL_INTERVAL_MS = 1; +const PIXELS = 16; +const MUTATION_QUEUE_CAPACITY = 4; +const EVENT_BUS_CAPACITY = 16; +const EVENT_BUS_MAX_SUBSCRIBERS = 4; +const SNAPSHOT_ACK_TIMEOUT_MS = 50; +const SNAPSHOT_POLL_INTERVAL_MS = 1; +const SNAPSHOT_PENDING_CAPACITY = 1; + +// Scripted Fitter behaviour. Each `step` call pops a program entry +// and lets the test assert per-frame outputs without reimplementing +// the WASM surface. +interface FitterProgramStep { + throwMsg?: string; + events?: PipelineEvent[]; + residual?: Float32Array; +} + +interface MockFitter { + stepCalls: Float32Array[]; + drainCalls: number; + snapshotCalls: number; + freed: boolean; + epoch: bigint; + mutationApplies: PipelineMutation[]; + eventsEmitted: PipelineEvent[]; +} + +const mockState = { + constructFitterShouldThrow: null as Error | null, + fitter: null as MockFitter | null, + program: [] as FitterProgramStep[], + autoResidual: new Float32Array(PIXELS), + mutationsToDrain: [] as PipelineMutation[], +}; + +vi.mock('@calab/cala-core', () => { + class Fitter { + stepCalls: Float32Array[] = []; + drainCalls = 0; + snapshotCalls = 0; + freed = false; + private currentEpoch = 0n; + private self: MockFitter; + + constructor(_height: number, _width: number, _cfgJson: string) { + if (mockState.constructFitterShouldThrow) { + throw mockState.constructFitterShouldThrow; + } + this.self = { + stepCalls: this.stepCalls, + drainCalls: 0, + snapshotCalls: 0, + freed: false, + epoch: 0n, + mutationApplies: [], + eventsEmitted: [], + }; + mockState.fitter = this.self; + } + + epoch(): bigint { + return this.currentEpoch; + } + + numComponents(): number { + return 0; + } + + step(y: Float32Array): Float32Array { + const copy = new Float32Array(y); + this.stepCalls.push(copy); + this.self.stepCalls = this.stepCalls; + const program = mockState.program.shift(); + if (program?.throwMsg) throw new Error(program.throwMsg); + return program?.residual ?? mockState.autoResidual; + } + + // Stand-in for the wider fit_step surface (births / merges / + // deprecates / metrics). The real WASM `Fitter.drainApply` pulls + // one mutation at a time in FIFO order from its handle; we mirror + // that cadence here so epoch advances once per worker pop. + drainApply(): Uint32Array { + this.drainCalls += 1; + this.self.drainCalls = this.drainCalls; + const next = mockState.mutationsToDrain.shift(); + if (next) { + this.self.mutationApplies.push(next); + this.currentEpoch += 1n; + this.self.epoch = this.currentEpoch; + return new Uint32Array([1, 0, 0]); + } + this.self.epoch = this.currentEpoch; + return new Uint32Array([0, 0, 0]); + } + + takeSnapshot(): { epoch(): bigint; numComponents(): number; pixels(): number; free(): void } { + this.snapshotCalls += 1; + this.self.snapshotCalls = this.snapshotCalls; + const ep = this.currentEpoch; + return { + epoch: () => ep, + numComponents: () => 0, + pixels: () => PIXELS, + free: () => {}, + }; + } + + free(): void { + this.freed = true; + this.self.freed = true; + } + } + + class MutationQueueHandle { + private ms: PipelineMutation[] = []; + constructor(_extendCfgJson: string) {} + push(m: PipelineMutation): void { + this.ms.push(m); + } + drainAll(): PipelineMutation[] { + return this.ms.splice(0, this.ms.length); + } + free(): void {} + } + + return { + initCalaCore: vi.fn(async () => {}), + Fitter, + MutationQueueHandle, + SnapshotHandle: class {}, + }; +}); + +function resetMockState(): void { + mockState.constructFitterShouldThrow = null; + mockState.fitter = null; + mockState.program = []; + mockState.autoResidual = new Float32Array(PIXELS); + mockState.mutationsToDrain = []; +} + +function makeFrameChannel(): SabRingChannel { + return new SabRingChannel({ + slotBytes: FRAME_CHANNEL_SLOT_BYTES, + slotCount: FRAME_CHANNEL_SLOT_COUNT, + waitTimeoutMs: FRAME_CHANNEL_WAIT_TIMEOUT_MS, + pollIntervalMs: FRAME_CHANNEL_POLL_INTERVAL_MS, + }); +} + +function makeResidualChannel(): SabRingChannel { + return new SabRingChannel({ + slotBytes: FRAME_CHANNEL_SLOT_BYTES, + slotCount: FRAME_CHANNEL_SLOT_COUNT, + waitTimeoutMs: FRAME_CHANNEL_WAIT_TIMEOUT_MS, + pollIntervalMs: FRAME_CHANNEL_POLL_INTERVAL_MS, + }); +} + +interface InitHandles { + msg: WorkerInbound; + frameChannel: SabRingChannel; + residualChannel: SabRingChannel; +} + +function makeInitMsg(overrides: Record = {}): InitHandles { + const frameChannel = makeFrameChannel(); + const residualChannel = makeResidualChannel(); + const msg: WorkerInbound = { + kind: 'init', + payload: { + role: 'fit', + frameChannelBuffer: frameChannel.sharedBuffer, + residualChannelBuffer: residualChannel.sharedBuffer, + workerConfig: { + height: 4, + width: 4, + fitConfigJson: '{}', + extendConfigJson: '{}', + heartbeatStride: 2, + snapshotStride: 2, + mutationDrainMaxPerIteration: 8, + eventBusCapacity: EVENT_BUS_CAPACITY, + eventBusMaxSubscribers: EVENT_BUS_MAX_SUBSCRIBERS, + snapshotAckTimeoutMs: SNAPSHOT_ACK_TIMEOUT_MS, + snapshotPollIntervalMs: SNAPSHOT_POLL_INTERVAL_MS, + snapshotPendingCapacity: SNAPSHOT_PENDING_CAPACITY, + frameChannelSlotBytes: FRAME_CHANNEL_SLOT_BYTES, + frameChannelSlotCount: FRAME_CHANNEL_SLOT_COUNT, + frameChannelWaitTimeoutMs: FRAME_CHANNEL_WAIT_TIMEOUT_MS, + frameChannelPollIntervalMs: FRAME_CHANNEL_POLL_INTERVAL_MS, + mutationQueueCapacity: MUTATION_QUEUE_CAPACITY, + ...overrides, + }, + }, + }; + return { msg, frameChannel, residualChannel }; +} + +async function runUntil( + harness: WorkerHarness, + predicate: (posted: WorkerOutbound[]) => boolean, + maxTicks = 2000, +): Promise { + for (let i = 0; i < maxTicks; i += 1) { + if (predicate(harness.posted)) return; + await new Promise((r) => setTimeout(r, 0)); + } + if (!predicate(harness.posted)) { + throw new Error('runUntil timed out'); + } +} + +async function loadWorker(harness: WorkerHarness): Promise { + vi.stubGlobal('self', harness.self); + await import('../fit.worker.ts'); +} + +function writeFrameToChannel(channel: SabRingChannel, value: number): void { + const payload = new Float32Array(PIXELS); + payload[0] = value; + channel.writeSlot(payload, 0n); +} + +describe('fit worker', () => { + beforeEach(() => { + resetMockState(); + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('responds to init with ready after binding fitter, channel, mutation queue, snapshot + event handles', async () => { + const harness = createWorkerHarness(); + await loadWorker(harness); + await harness.deliver(makeInitMsg().msg); + await runUntil(harness, (p) => p.some((m) => m.kind === 'ready')); + const ready = harness.posted.find((m) => m.kind === 'ready'); + expect(ready).toEqual({ kind: 'ready', role: 'fit' }); + expect(mockState.fitter).not.toBeNull(); + }); + + it('posts error when Fitter constructor rejects fit config JSON', async () => { + mockState.constructFitterShouldThrow = new Error('fit cfg parse'); + const harness = createWorkerHarness(); + await loadWorker(harness); + await harness.deliver(makeInitMsg({ fitConfigJson: '{invalid}' }).msg); + await runUntil(harness, (p) => p.some((m) => m.kind === 'error')); + const err = harness.posted.find((m) => m.kind === 'error'); + expect(err).toMatchObject({ kind: 'error', role: 'fit' }); + expect((err as { message: string }).message).toMatch(/fit cfg parse/); + expect(harness.posted.some((m) => m.kind === 'ready')).toBe(false); + }); + + it('run drives fit step per frame and emits throttled frame-processed heartbeats', async () => { + const harness = createWorkerHarness(); + await loadWorker(harness); + const init = makeInitMsg({ heartbeatStride: 2, snapshotStride: 1000 }); + await harness.deliver(init.msg); + await runUntil(harness, (p) => p.some((m) => m.kind === 'ready')); + + // Prime the channel with 4 frames, then close it by delivering 'stop' + // once the worker has drained them. The fit worker's read loop yields + // between frames so we can feed it between ticks. + for (let i = 0; i < 4; i += 1) { + writeFrameToChannel(init.frameChannel, i); + } + await harness.deliver({ kind: 'run' }); + await runUntil(harness, (p) => p.filter((m) => m.kind === 'frame-processed').length >= 2); + await harness.deliver({ kind: 'stop' }); + await runUntil(harness, (p) => p.some((m) => m.kind === 'done')); + + expect(mockState.fitter!.stepCalls.length).toBeGreaterThanOrEqual(4); + const heartbeats = harness.posted.filter((m) => m.kind === 'frame-processed'); + // heartbeatStride = 2 → beat after frames at indices 1 and 3. + expect(heartbeats.length).toBeGreaterThanOrEqual(2); + expect(heartbeats[0]).toMatchObject({ kind: 'frame-processed', role: 'fit', index: 1 }); + }); + + it('emits a birth pipeline event on the bus when a register mutation is drained', async () => { + const harness = createWorkerHarness(); + await loadWorker(harness); + const init = makeInitMsg({ heartbeatStride: 1, snapshotStride: 1000 }); + await harness.deliver(init.msg); + await runUntil(harness, (p) => p.some((m) => m.kind === 'ready')); + + const fitHandles = getFitHandles(); + expect(fitHandles).toBeDefined(); + fitHandles!.mutationQueue.push({ + type: 'register', + snapshotEpoch: 0n, + class: 'cell', + support: new Uint32Array([1, 2]), + values: new Float32Array([0.9, 0.6]), + trace: new Float32Array([0.1, 0.2]), + }); + writeFrameToChannel(init.frameChannel, 0); + + await harness.deliver({ kind: 'run' }); + await runUntil(harness, (p) => p.some((m) => m.kind === 'event' && m.event.kind === 'birth')); + await harness.deliver({ kind: 'stop' }); + await runUntil(harness, (p) => p.some((m) => m.kind === 'done')); + + const eventMsg = harness.posted.find( + (m): m is Extract => + m.kind === 'event' && m.event.kind === 'birth', + ); + expect(eventMsg).toBeDefined(); + expect(eventMsg!.role).toBe('fit'); + expect(eventMsg!.event.kind).toBe('birth'); + }); + + it('drains the mutation queue each iteration and posts mutation-applied with monotonic epoch', async () => { + const harness = createWorkerHarness(); + await loadWorker(harness); + const init = makeInitMsg({ heartbeatStride: 100, snapshotStride: 1000 }); + await harness.deliver(init.msg); + await runUntil(harness, (p) => p.some((m) => m.kind === 'ready')); + + const fitHandles = getFitHandles(); + expect(fitHandles).toBeDefined(); + // One mutation per frame, drained into the WASM-side handle so + // each applied mutation bumps the fitter's epoch. + mockState.mutationsToDrain = [ + { type: 'deprecate', snapshotEpoch: 0n, id: 7, reason: 'traceInactive' }, + { type: 'deprecate', snapshotEpoch: 1n, id: 9, reason: 'mergedInto' }, + ]; + fitHandles!.mutationQueue.push({ + type: 'deprecate', + snapshotEpoch: 0n, + id: 7, + reason: 'traceInactive', + }); + fitHandles!.mutationQueue.push({ + type: 'deprecate', + snapshotEpoch: 1n, + id: 9, + reason: 'mergedInto', + }); + writeFrameToChannel(init.frameChannel, 0); + + await harness.deliver({ kind: 'run' }); + await runUntil(harness, (p) => p.filter((m) => m.kind === 'mutation-applied').length >= 2); + await harness.deliver({ kind: 'stop' }); + await runUntil(harness, (p) => p.some((m) => m.kind === 'done')); + + const applied = harness.posted.filter( + (m): m is Extract => + m.kind === 'mutation-applied', + ); + expect(applied.length).toBeGreaterThanOrEqual(2); + expect(applied[0].epoch).toBe(1n); + expect(applied[1].epoch).toBe(2n); + }); + + it('takes a snapshot every snapshot_stride frames and posts snapshot-request with the captured epoch', async () => { + const harness = createWorkerHarness(); + await loadWorker(harness); + const init = makeInitMsg({ heartbeatStride: 100, snapshotStride: 2 }); + await harness.deliver(init.msg); + await runUntil(harness, (p) => p.some((m) => m.kind === 'ready')); + + // Seed a mutation so epoch advances before the first snapshot. + mockState.mutationsToDrain = [ + { type: 'deprecate', snapshotEpoch: 0n, id: 1, reason: 'traceInactive' }, + ]; + for (let i = 0; i < 4; i += 1) writeFrameToChannel(init.frameChannel, i); + + await harness.deliver({ kind: 'run' }); + await runUntil(harness, (p) => p.filter((m) => m.kind === 'snapshot-request').length >= 2); + await harness.deliver({ kind: 'stop' }); + await runUntil(harness, (p) => p.some((m) => m.kind === 'done')); + + const snaps = harness.posted.filter( + (m): m is Extract => + m.kind === 'snapshot-request', + ); + expect(snaps.length).toBeGreaterThanOrEqual(2); + // Snapshot cadence monotonic: each ack should carry a non-decreasing requestId. + for (let i = 1; i < snaps.length; i += 1) { + expect(snaps[i].requestId).toBeGreaterThan(snaps[i - 1].requestId); + } + expect(mockState.fitter!.snapshotCalls).toBeGreaterThanOrEqual(2); + }); + + it('stop mid-loop halts further fit_step calls, posts done, frees the fitter exactly once', async () => { + const harness = createWorkerHarness(); + await loadWorker(harness); + const init = makeInitMsg({ heartbeatStride: 1, snapshotStride: 1000 }); + await harness.deliver(init.msg); + await runUntil(harness, (p) => p.some((m) => m.kind === 'ready')); + + for (let i = 0; i < 3; i += 1) writeFrameToChannel(init.frameChannel, i); + + await harness.deliver({ kind: 'run' }); + await runUntil(harness, (p) => p.filter((m) => m.kind === 'frame-processed').length >= 1); + await harness.deliver({ kind: 'stop' }); + await runUntil(harness, (p) => p.some((m) => m.kind === 'done')); + + const stepsAtStop = mockState.fitter!.stepCalls.length; + // After 'done', no more fit work should happen. + await new Promise((r) => setTimeout(r, 20)); + expect(mockState.fitter!.stepCalls.length).toBe(stepsAtStop); + expect(mockState.fitter!.freed).toBe(true); + // free posted exactly once: counting 'done' messages stays at 1. + expect(harness.posted.filter((m) => m.kind === 'done').length).toBe(1); + }); + + it('posts error when fit_step throws mid-loop and still frees the fitter', async () => { + const harness = createWorkerHarness(); + await loadWorker(harness); + const init = makeInitMsg({ heartbeatStride: 1, snapshotStride: 1000 }); + await harness.deliver(init.msg); + await runUntil(harness, (p) => p.some((m) => m.kind === 'ready')); + + mockState.program = [{}, { throwMsg: 'nan trace' }]; + writeFrameToChannel(init.frameChannel, 0); + writeFrameToChannel(init.frameChannel, 1); + + await harness.deliver({ kind: 'run' }); + await runUntil(harness, (p) => p.some((m) => m.kind === 'error')); + + const err = harness.posted.find((m) => m.kind === 'error'); + expect((err as { message: string }).message).toMatch(/nan trace/); + expect(mockState.fitter!.freed).toBe(true); + }); +}); diff --git a/apps/cala/src/workers/fit.worker.ts b/apps/cala/src/workers/fit.worker.ts new file mode 100644 index 0000000..935867c --- /dev/null +++ b/apps/cala/src/workers/fit.worker.ts @@ -0,0 +1,429 @@ +import { initCalaCore, Fitter, MutationQueueHandle } from '@calab/cala-core'; +import { + SabRingChannel, + EventBus, + SnapshotProtocol, + MutationQueue, + type ChannelConfig, + type PipelineEvent, + type PipelineMutation, + type WorkerInbound, + type WorkerInitPayload, + type WorkerOutbound, +} from '@calab/cala-runtime'; + +// Heartbeat cadence: post a `frame-processed` beat every N fit steps. +// Mirrors W1's DEFAULT_HEARTBEAT_STRIDE so the orchestrator sees +// equal-frequency beats from both sides of the frame channel. +// Overridable via `workerConfig.heartbeatStride` (design §7.1). +const DEFAULT_HEARTBEAT_STRIDE = 8; +// Snapshot cadence: fit takes a COW snapshot every N frames so the +// extend worker has a consistent view of `(Ã, W, M, epoch)` to work +// against (design §7.2). 16 frames ≈ half-second at 30 fps — fresh +// enough for extend's tens-of-frames-per-cycle, infrequent enough to +// keep fit's hot path free of per-frame `takeSnapshot()` cost. +const DEFAULT_SNAPSHOT_STRIDE = 16; +// Upper bound on mutations drained per loop iteration. The underlying +// WASM `drainApply` already pulls everything, but we re-queue any +// oversubscribed work so a runaway extend burst can't stall fit for +// more than one frame. Matches `DEFAULT_PROPOSALS_PER_CYCLE_MAX` in +// `crate::config` (design §13 dense-scene risk mitigation). +const DEFAULT_MUTATION_DRAIN_MAX_PER_ITERATION = 4; +// Event bus capacity for the in-worker publisher. Matches the +// archive-worker expectation from §9.2: 2 KB per event × 16 events is +// one cycle of headroom; real backpressure lives in the SAB transport +// that replaces this in later tasks. +const DEFAULT_EVENT_BUS_CAPACITY = 256; +const DEFAULT_EVENT_BUS_MAX_SUBSCRIBERS = 4; +// Snapshot protocol defaults for the in-worker stand-in. These mirror +// the orchestrator-side defaults — swap to the SAB transport later +// keeps the same knob names. +const DEFAULT_SNAPSHOT_ACK_TIMEOUT_MS = 500; +const DEFAULT_SNAPSHOT_POLL_INTERVAL_MS = 2; +const DEFAULT_SNAPSHOT_PENDING_CAPACITY = 1; +const DEFAULT_FRAME_CHANNEL_WAIT_TIMEOUT_MS = 1000; +const DEFAULT_FRAME_CHANNEL_POLL_INTERVAL_MS = 1; +const FRAME_CHANNEL_SLOT_COUNT_FALLBACK = 4; +// Mutation queue capacity: mirrors `DEFAULT_MUTATION_QUEUE_CAPACITY` +// in `crate::config` (design §7.3, 32 slots, drop-oldest). +const DEFAULT_MUTATION_QUEUE_CAPACITY = 32; +const DEFAULT_FIT_CONFIG_JSON = '{}'; +const DEFAULT_EXTEND_CONFIG_JSON = '{}'; + +const ROLE = 'fit' as const; + +interface WorkerGlobalScope { + postMessage(msg: WorkerOutbound): void; + onmessage: ((ev: MessageEvent) => void) | null; +} + +interface FitWorkerConfig { + height: number; + width: number; + fitConfigJson: string; + extendConfigJson: string; + heartbeatStride: number; + snapshotStride: number; + mutationDrainMaxPerIteration: number; + eventBusCapacity: number; + eventBusMaxSubscribers: number; + snapshotAckTimeoutMs: number; + snapshotPollIntervalMs: number; + snapshotPendingCapacity: number; + mutationQueueCapacity: number; + frameChannelSlotBytes?: number; + frameChannelSlotCount: number; + frameChannelWaitTimeoutMs: number; + frameChannelPollIntervalMs: number; +} + +// Route through `self` when present so `vi.stubGlobal('self', harness)` +// picks us up; falls back to `globalThis` in environments that don't +// alias them. +const workerSelf = ((globalThis as unknown as { self?: WorkerGlobalScope }).self ?? + (globalThis as unknown as WorkerGlobalScope)) as WorkerGlobalScope; + +interface RuntimeHandles { + fitter: Fitter; + frameChannel: SabRingChannel; + mutationQueue: MutationQueue; + mutationQueueHandle: MutationQueueHandle; + snapshotProtocol: SnapshotProtocol; + eventBus: EventBus; + eventSubscription: () => void; + config: FitWorkerConfig; + pixels: number; +} + +let handles: RuntimeHandles | null = null; +let running = false; +let stopRequested = false; +let donePosted = false; +let loopPromise: Promise | null = null; + +function post(msg: WorkerOutbound): void { + workerSelf.postMessage(msg); +} + +function postError(err: unknown): void { + const message = err instanceof Error ? err.message : String(err); + post({ kind: 'error', role: ROLE, message }); +} + +function asRecord(value: unknown): Record { + return typeof value === 'object' && value !== null ? (value as Record) : {}; +} + +function numberOr(v: unknown, fallback: number): number { + return typeof v === 'number' && Number.isFinite(v) ? v : fallback; +} + +function stringOr(v: unknown, fallback: string): string { + return typeof v === 'string' ? v : fallback; +} + +function parseConfig(raw: unknown): FitWorkerConfig { + const cfg = asRecord(raw); + const height = numberOr(cfg.height, 0); + const width = numberOr(cfg.width, 0); + if (height <= 0 || width <= 0) { + throw new Error('workerConfig.height and workerConfig.width must be positive'); + } + return { + height, + width, + fitConfigJson: stringOr(cfg.fitConfigJson, DEFAULT_FIT_CONFIG_JSON), + extendConfigJson: stringOr(cfg.extendConfigJson, DEFAULT_EXTEND_CONFIG_JSON), + heartbeatStride: numberOr(cfg.heartbeatStride, DEFAULT_HEARTBEAT_STRIDE), + snapshotStride: numberOr(cfg.snapshotStride, DEFAULT_SNAPSHOT_STRIDE), + mutationDrainMaxPerIteration: numberOr( + cfg.mutationDrainMaxPerIteration, + DEFAULT_MUTATION_DRAIN_MAX_PER_ITERATION, + ), + eventBusCapacity: numberOr(cfg.eventBusCapacity, DEFAULT_EVENT_BUS_CAPACITY), + eventBusMaxSubscribers: numberOr(cfg.eventBusMaxSubscribers, DEFAULT_EVENT_BUS_MAX_SUBSCRIBERS), + snapshotAckTimeoutMs: numberOr(cfg.snapshotAckTimeoutMs, DEFAULT_SNAPSHOT_ACK_TIMEOUT_MS), + snapshotPollIntervalMs: numberOr(cfg.snapshotPollIntervalMs, DEFAULT_SNAPSHOT_POLL_INTERVAL_MS), + snapshotPendingCapacity: numberOr( + cfg.snapshotPendingCapacity, + DEFAULT_SNAPSHOT_PENDING_CAPACITY, + ), + mutationQueueCapacity: numberOr(cfg.mutationQueueCapacity, DEFAULT_MUTATION_QUEUE_CAPACITY), + frameChannelSlotBytes: + typeof cfg.frameChannelSlotBytes === 'number' ? cfg.frameChannelSlotBytes : undefined, + frameChannelSlotCount: numberOr(cfg.frameChannelSlotCount, FRAME_CHANNEL_SLOT_COUNT_FALLBACK), + frameChannelWaitTimeoutMs: numberOr( + cfg.frameChannelWaitTimeoutMs, + DEFAULT_FRAME_CHANNEL_WAIT_TIMEOUT_MS, + ), + frameChannelPollIntervalMs: numberOr( + cfg.frameChannelPollIntervalMs, + DEFAULT_FRAME_CHANNEL_POLL_INTERVAL_MS, + ), + }; +} + +async function handleInit(payload: WorkerInitPayload): Promise { + await initCalaCore(); + const cfg = parseConfig(payload.workerConfig); + + const pixels = cfg.height * cfg.width; + const fitter = new Fitter(cfg.height, cfg.width, cfg.fitConfigJson); + const mutationQueueHandle = new MutationQueueHandle(cfg.extendConfigJson); + const mutationQueue = new MutationQueue({ capacity: cfg.mutationQueueCapacity }); + + const channelCfg: ChannelConfig = { + slotBytes: cfg.frameChannelSlotBytes ?? pixels * Float32Array.BYTES_PER_ELEMENT, + slotCount: cfg.frameChannelSlotCount, + waitTimeoutMs: cfg.frameChannelWaitTimeoutMs, + pollIntervalMs: cfg.frameChannelPollIntervalMs, + sharedBuffer: payload.frameChannelBuffer, + }; + const frameChannel = new SabRingChannel(channelCfg); + + const snapshotProtocol = new SnapshotProtocol({ + ackTimeoutMs: cfg.snapshotAckTimeoutMs, + pollIntervalMs: cfg.snapshotPollIntervalMs, + pendingCapacity: cfg.snapshotPendingCapacity, + }); + + const eventBus = new EventBus({ + capacity: cfg.eventBusCapacity, + maxSubscribers: cfg.eventBusMaxSubscribers, + }); + // Forwarding subscription: every PipelineEvent published on the + // in-worker bus is relayed across postMessage as a `'event'` + // outbound. The SAB-backed transport in later tasks replaces this + // `subscribe` bridge with a zero-copy ring without touching + // numerics callers. + const eventSubscription = eventBus.subscribe((event: PipelineEvent) => { + post({ kind: 'event', role: ROLE, event }); + }); + + handles = { + fitter, + frameChannel, + mutationQueue, + mutationQueueHandle, + snapshotProtocol, + eventBus, + eventSubscription, + config: cfg, + pixels, + }; + + // Test-only hook so unit tests can push mutations into the worker's + // MutationQueue without standing up a full extend worker. Mirrors + // the SAB-backed producer side of §7.3 — real extend worker pushes + // via SAB, tests push via this handle. No production consumer reads + // this field. + (globalThis as { __calaFitHandles?: { mutationQueue: MutationQueue } }).__calaFitHandles = { + mutationQueue, + }; + + post({ kind: 'ready', role: ROLE }); +} + +function readNextFrame(h: RuntimeHandles): Float32Array | null { + const slot = h.frameChannel.readSlot(); + if (slot === null) return null; + // Slot payload is u8; reinterpret as f32 without copy. Slot.data is + // already an owned copy so we can alias it safely. + return new Float32Array(slot.data.buffer, slot.data.byteOffset, h.pixels); +} + +function mutationToEvent(m: PipelineMutation, frameIndex: number): PipelineEvent | null { + // Translate each applied mutation into the structural event the + // archive worker logs (§9.2). `register` → birth, `merge` → merge, + // `deprecate` → deprecate. Reject / split / metric events come + // from other sources (extend quality-gate fails, user overrides). + switch (m.type) { + case 'register': + return { + kind: 'birth', + t: frameIndex, + // Real id assignment happens inside fit's apply. Until the + // WASM surface surfaces it (later task), report the + // snapshot epoch as a stable per-mutation correlation id. + id: Number(m.snapshotEpoch), + patch: [0, 0], + footprintSnap: { pixelIndices: m.support, values: m.values }, + }; + case 'merge': + return { + kind: 'merge', + t: frameIndex, + ids: [m.mergeIds[0], m.mergeIds[1]], + into: m.mergeIds[0], + footprintSnap: { pixelIndices: m.support, values: m.values }, + }; + case 'deprecate': + return { kind: 'deprecate', t: frameIndex, id: m.id, reason: m.reason }; + } +} + +function drainMutationsOnce(h: RuntimeHandles, frameIndex: number): number { + // Apply at most `mutationDrainMaxPerIteration` queued mutations so a + // burst of extend proposals cannot stall the fit loop for more than + // one frame's worth of apply cost (design §13 dense-scene risk). + const cap = h.config.mutationDrainMaxPerIteration; + let applied = 0; + while (applied < cap) { + const m = h.mutationQueue.pop(); + if (m === null) break; + // Keep the WASM side in sync. In Phase 5 `drainApply` consumes + // the Rust-side queue handle; once the SAB transport merges the + // two queues this reduces to a single call. + h.fitter.drainApply(h.mutationQueueHandle); + const ev = mutationToEvent(m, frameIndex); + if (ev) h.eventBus.publish(ev); + post({ kind: 'mutation-applied', role: ROLE, epoch: h.fitter.epoch() }); + applied += 1; + } + return applied; +} + +function takeCadencedSnapshot(h: RuntimeHandles, frameIndex: number): void { + if (h.config.snapshotStride <= 0) return; + if ((frameIndex + 1) % h.config.snapshotStride !== 0) return; + // Request + publish in one shot: extend's in-worker stand-in hasn't + // been wired yet, so fit serves a self-issued request. When the + // real cross-worker transport lands, the request comes from extend + // and this block calls only `publishAck`. + const requestPromise = h.snapshotProtocol.requestSnapshot().catch(() => { + // Capacity/timeout is a soft failure here — extend retries on + // its own cadence per §7.2. + }); + const request = h.snapshotProtocol.pollRequest(); + if (!request) { + // Another snapshot is already in flight; skip to avoid piling up. + return; + } + const handle = h.fitter.takeSnapshot(); + const ackEpoch = handle.epoch(); + const ackNumComponents = handle.numComponents(); + const ackPixels = handle.pixels(); + try { + handle.free(); + } catch { + // free() is best-effort — WASM may already be torn down + } + h.snapshotProtocol.publishAck({ + requestId: request.requestId, + epoch: BigInt(ackEpoch), + numComponents: ackNumComponents, + pixels: ackPixels, + }); + void requestPromise; + post({ kind: 'snapshot-request', role: ROLE, requestId: request.requestId }); +} + +async function fitLoop(h: RuntimeHandles): Promise { + let frameIndex = 0; + while (!stopRequested) { + const frame = readNextFrame(h); + if (frame === null) { + // No frame queued. Yield so the harness / decoder can push more + // work without spinning the CPU. A microtask is enough — we're + // inside a worker event loop, not a hard-spin context. + await new Promise((r) => setTimeout(r, h.config.frameChannelPollIntervalMs)); + continue; + } + h.fitter.step(frame); + drainMutationsOnce(h, frameIndex); + takeCadencedSnapshot(h, frameIndex); + if ((frameIndex + 1) % h.config.heartbeatStride === 0) { + post({ + kind: 'frame-processed', + role: ROLE, + index: frameIndex, + epoch: h.fitter.epoch(), + }); + } + frameIndex += 1; + // Cooperative yield so stop() and new channel writes land + // promptly in tests without racing the loop. + await Promise.resolve(); + } +} + +function cleanup(): void { + if (!handles) return; + try { + handles.eventSubscription(); + } catch { + // unsubscribe is best-effort + } + try { + handles.eventBus.close(); + } catch { + // close is idempotent but defensive + } + try { + handles.fitter.free(); + } catch { + // free is best-effort — wasm may already be torn down + } + try { + handles.mutationQueueHandle.free(); + } catch { + // free is best-effort + } + delete (globalThis as { __calaFitHandles?: unknown }).__calaFitHandles; + handles = null; +} + +function postDoneOnce(): void { + if (donePosted) return; + donePosted = true; + post({ kind: 'done', role: ROLE }); +} + +async function handleRun(): Promise { + if (!handles) { + postError(new Error("'run' received before successful 'init'")); + return; + } + if (running) return; + running = true; + stopRequested = false; + donePosted = false; + const h = handles; + try { + await fitLoop(h); + postDoneOnce(); + } catch (err) { + postError(err); + } finally { + running = false; + cleanup(); + } +} + +async function handleStop(): Promise { + stopRequested = true; + if (loopPromise) await loopPromise; + postDoneOnce(); + cleanup(); +} + +workerSelf.onmessage = (ev: MessageEvent): void => { + const msg = ev.data; + switch (msg.kind) { + case 'init': + handleInit(msg.payload).catch(postError); + return; + case 'run': + loopPromise = handleRun(); + return; + case 'stop': + handleStop().catch(postError); + return; + case 'snapshot-ack': + // Ack of an upstream snapshot-request. In Phase 5 the + // orchestrator forwards snapshot-ack back to fit for bookkeeping; + // we log nothing — the in-worker SnapshotProtocol handled the + // capture synchronously at the cadence boundary. + return; + } +}; diff --git a/apps/cala/src/workers/index.ts b/apps/cala/src/workers/index.ts index 223720c..636bd70 100644 --- a/apps/cala/src/workers/index.ts +++ b/apps/cala/src/workers/index.ts @@ -6,6 +6,12 @@ export function createDecodePreprocessWorker(): WorkerLike { }) as unknown as WorkerLike; } +export function createFitWorker(): WorkerLike { + return new Worker(new URL('./fit.worker.ts', import.meta.url), { + type: 'module', + }) as unknown as WorkerLike; +} + export function createExtendWorker(): WorkerLike { return new Worker(new URL('./extend.worker.ts', import.meta.url), { type: 'module', From 754b89bc3954d9aa65404d46180d8c0884e75039 Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 18 Apr 2026 14:58:26 -0700 Subject: [PATCH 13/17] feat(cala): add single-frame viewer + archive client (task 24) Wires the dashboard to real worker output: ArchiveClient + dashboard store + SingleFrameViewer replace the task-20 placeholder, and run-control now spawns real workers and forwards W1 preview frames. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cala/src/App.tsx | 28 +- .../components/frame/SingleFrameViewer.tsx | 142 ++++++++++ .../src/lib/__tests__/archive-client.test.ts | 244 ++++++++++++++++++ .../src/lib/__tests__/dashboard-store.test.ts | 94 +++++++ .../src/lib/__tests__/frame-preview.test.ts | 39 +++ apps/cala/src/lib/archive-client.ts | 157 +++++++++++ apps/cala/src/lib/dashboard-store.ts | 57 ++++ apps/cala/src/lib/frame-preview.ts | 54 ++++ apps/cala/src/lib/run-control.ts | 112 ++++++-- apps/cala/src/styles/global.css | 108 ++++++++ .../src/workers/decode-preprocess.worker.ts | 32 ++- packages/cala-runtime/src/worker-protocol.ts | 14 + 12 files changed, 1056 insertions(+), 25 deletions(-) create mode 100644 apps/cala/src/components/frame/SingleFrameViewer.tsx create mode 100644 apps/cala/src/lib/__tests__/archive-client.test.ts create mode 100644 apps/cala/src/lib/__tests__/dashboard-store.test.ts create mode 100644 apps/cala/src/lib/__tests__/frame-preview.test.ts create mode 100644 apps/cala/src/lib/archive-client.ts create mode 100644 apps/cala/src/lib/dashboard-store.ts create mode 100644 apps/cala/src/lib/frame-preview.ts diff --git a/apps/cala/src/App.tsx b/apps/cala/src/App.tsx index 28cf3da..1ea06c4 100644 --- a/apps/cala/src/App.tsx +++ b/apps/cala/src/App.tsx @@ -1,16 +1,36 @@ -import { Show, type Component } from 'solid-js'; +import { createEffect, onCleanup, Show, type Component } from 'solid-js'; import { DashboardShell } from '@calab/ui'; import { CaLaHeader } from './components/layout/CaLaHeader.tsx'; import { ImportOverlay } from './components/layout/ImportOverlay.tsx'; +import { SingleFrameViewer } from './components/frame/SingleFrameViewer.tsx'; import { state } from './lib/data-store.ts'; +import { currentArchiveWorkerForClient } from './lib/run-control.ts'; +import { createArchiveClient, type ArchiveClient } from './lib/archive-client.ts'; +import { applyDump, resetDashboard } from './lib/dashboard-store.ts'; const App: Component = () => { + // Dashboard feeding: while a run is active, poll the archive worker + // for its rolling event/metric snapshot. Lifecycle is tied to the + // run (via runState transitions) so we tear down cleanly between + // imports. + createEffect(() => { + const rs = state.runState; + const worker = currentArchiveWorkerForClient(); + if (rs !== 'running' || worker === null) return; + const client: ArchiveClient = createArchiveClient(worker); + client.startPolling((dump) => { + applyDump(dump); + }); + onCleanup(() => { + client.dispose(); + resetDashboard(); + }); + }); + return ( }> }> -
-

Run control wired in task 20. Frame viewer lands in task 24.

-
+
); diff --git a/apps/cala/src/components/frame/SingleFrameViewer.tsx b/apps/cala/src/components/frame/SingleFrameViewer.tsx new file mode 100644 index 0000000..14ef640 --- /dev/null +++ b/apps/cala/src/components/frame/SingleFrameViewer.tsx @@ -0,0 +1,142 @@ +import { createEffect, createMemo, createSignal, For, onCleanup, Show, type JSX } from 'solid-js'; +import { DashboardPanel } from '@calab/ui'; +import type { PipelineEvent } from '@calab/cala-runtime'; +import { dashboard } from '../../lib/dashboard-store.ts'; +import { latestFrame } from '../../lib/run-control.ts'; +import { writeGrayscaleToImageData } from '../../lib/frame-preview.ts'; + +// Trailing window of events shown in the side panel's feed. Design +// §8 event feed; §11 dashboard. The archive worker retains the full +// ring — this is just the visible tail. +const EVENT_TAIL_LENGTH = 20; +// Trailing metric keys shown in the 1-line summary. Kept small so +// whatever W4 produces stays legible; overflow is counted, not listed. +const METRIC_SUMMARY_MAX_KEYS = 3; + +function describeEvent(e: PipelineEvent): string { + switch (e.kind) { + case 'birth': + return `birth id=${e.id}`; + case 'merge': + return `merge ${e.ids.join('+')} → ${e.into}`; + case 'split': + return `split ${e.from} → [${e.into.join(',')}]`; + case 'deprecate': + return `deprecate id=${e.id} (${e.reason})`; + case 'reject': + return `reject @(${e.at[0]},${e.at[1]}): ${e.reason}`; + case 'metric': + return `metric ${e.name}=${e.value.toFixed(3)}`; + } +} + +function metricSummary(metrics: Record): string { + const entries = Object.entries(metrics); + if (entries.length === 0) return 'no metrics yet'; + const shown = entries.slice(0, METRIC_SUMMARY_MAX_KEYS); + const parts = shown.map(([k, v]) => `${k}: ${v.toFixed(2)}`); + if (entries.length > METRIC_SUMMARY_MAX_KEYS) { + parts.push(`(+${entries.length - METRIC_SUMMARY_MAX_KEYS} more)`); + } + return parts.join(' | '); +} + +export function SingleFrameViewer(): JSX.Element { + let canvasRef: HTMLCanvasElement | undefined; + const [imageData, setImageData] = createSignal(null); + const [canvasDims, setCanvasDims] = createSignal<{ width: number; height: number } | null>(null); + + // Pre-allocate ImageData whenever the frame dimensions change. The + // viewer hot path reuses this buffer — allocation only happens on + // dim change, which in practice is once per run. + createEffect(() => { + const f = latestFrame(); + 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 }); + } + }); + + // Render pass: copy the latest u8 frame into the pre-allocated + // ImageData and blit with putImageData. Pure DOM work — no solid + // reactivity inside the hot loop. + createEffect(() => { + const f = latestFrame(); + 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); + }); + + onCleanup(() => { + setImageData(null); + setCanvasDims(null); + }); + + const eventTail = createMemo(() => { + const events = dashboard.events; + const start = Math.max(0, events.length - EVENT_TAIL_LENGTH); + // Newest first — reverse after slicing so we don't mutate store state. + return events.slice(start).slice().reverse(); + }); + + const frameLabel = (): string => { + const idx = dashboard.currentFrameIndex; + const ep = dashboard.currentEpoch; + if (idx === null || ep === null) return 'awaiting frames…'; + return `frame ${idx} · epoch ${ep.toString()}`; + }; + + return ( +
+
+ + +
Awaiting first preview frame…
+
+
+ +
{frameLabel()}
+
+ {metricSummary(dashboard.metrics)} +
+
+
Events (newest first)
+ 0} + fallback={
No events yet.
} + > +
    + + {(e) => ( +
  • + {e.kind} + {describeEvent(e)} +
  • + )} +
    +
+
+
+
+
+ ); +} diff --git a/apps/cala/src/lib/__tests__/archive-client.test.ts b/apps/cala/src/lib/__tests__/archive-client.test.ts new file mode 100644 index 0000000..89fdd33 --- /dev/null +++ b/apps/cala/src/lib/__tests__/archive-client.test.ts @@ -0,0 +1,244 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type { PipelineEvent, WorkerInbound, WorkerLike, WorkerOutbound } from '@calab/cala-runtime'; +import { + createArchiveClient, + DEFAULT_DUMP_TIMEOUT_MS, + DEFAULT_POLL_INTERVAL_MS, + type ArchiveClient, +} from '../archive-client.ts'; + +class FakeWorker implements WorkerLike { + public readonly posted: WorkerInbound[] = []; + public terminated = false; + private readonly listeners = new Set<(ev: { data: WorkerOutbound }) => void>(); + + postMessage(message: WorkerInbound): void { + this.posted.push(message); + } + + addEventListener(_type: 'message', listener: (ev: { data: WorkerOutbound }) => void): void { + this.listeners.add(listener); + } + + removeEventListener(_type: 'message', listener: (ev: { data: WorkerOutbound }) => void): void { + this.listeners.delete(listener); + } + + terminate(): void { + this.terminated = true; + this.listeners.clear(); + } + + push(msg: WorkerOutbound): void { + for (const l of [...this.listeners]) l({ data: msg }); + } + + listenerCount(): number { + return this.listeners.size; + } +} + +function metricEvent(t: number, name: string, value: number): PipelineEvent { + return { kind: 'metric', t, name, value }; +} + +function birthEvent(t: number, id: number): PipelineEvent { + return { + kind: 'birth', + t, + id, + patch: [0, 0], + footprintSnap: { + pixelIndices: new Uint32Array([id]), + values: new Float32Array([1]), + }, + }; +} + +describe('cala archive-client', () => { + let worker: FakeWorker; + let client: ArchiveClient; + + beforeEach(() => { + worker = new FakeWorker(); + client = createArchiveClient(worker); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + client.dispose(); + }); + + it('requestDump posts request-archive-dump and resolves with matching requestId reply', async () => { + const promise = client.requestDump(); + + // First posted message should be a request-archive-dump with a numeric requestId. + expect(worker.posted.length).toBe(1); + const req = worker.posted[0]; + expect(req.kind).toBe('request-archive-dump'); + const requestId = (req as { requestId: number }).requestId; + expect(Number.isFinite(requestId)).toBe(true); + + const events = [birthEvent(1, 1), metricEvent(2, 'residual', 0.5)]; + worker.push({ + kind: 'archive-dump', + role: 'archive', + requestId, + events, + metrics: { residual: 0.5 }, + }); + + const dump = await promise; + expect(dump.events).toEqual(events); + expect(dump.metrics).toEqual({ residual: 0.5 }); + }); + + it('correlates concurrent requests via requestId', async () => { + const p1 = client.requestDump(); + const p2 = client.requestDump(); + const p3 = client.requestDump(); + + expect(worker.posted.length).toBe(3); + const ids = worker.posted.map((m) => (m as { requestId: number }).requestId); + expect(new Set(ids).size).toBe(3); // monotonic, distinct + + // Resolve in reverse order — each promise must get its own reply. + worker.push({ + kind: 'archive-dump', + role: 'archive', + requestId: ids[2], + events: [metricEvent(3, 'three', 3)], + metrics: { three: 3 }, + }); + worker.push({ + kind: 'archive-dump', + role: 'archive', + requestId: ids[0], + events: [metricEvent(1, 'one', 1)], + metrics: { one: 1 }, + }); + worker.push({ + kind: 'archive-dump', + role: 'archive', + requestId: ids[1], + events: [metricEvent(2, 'two', 2)], + metrics: { two: 2 }, + }); + + const [d1, d2, d3] = await Promise.all([p1, p2, p3]); + expect(d1.metrics).toEqual({ one: 1 }); + expect(d2.metrics).toEqual({ two: 2 }); + expect(d3.metrics).toEqual({ three: 3 }); + }); + + it('rejects the pending dump when no reply arrives before DEFAULT_DUMP_TIMEOUT_MS', async () => { + const promise = client.requestDump(); + // Attach rejection handler synchronously so the eventual rejection + // after advanceTimersByTime has a listener — avoids unhandled-rejection noise. + const caught = promise.catch((err: unknown) => err); + vi.advanceTimersByTime(DEFAULT_DUMP_TIMEOUT_MS + 1); + const err = await caught; + expect(err).toBeInstanceOf(Error); + expect((err as Error).name).toMatch(/Abort|Timeout/); + }); + + it('ignores archive-dump replies with unknown requestId', async () => { + const promise = client.requestDump(); + const requestId = (worker.posted[0] as { requestId: number }).requestId; + + // Stray reply with an unknown id — must not resolve the pending promise. + worker.push({ + kind: 'archive-dump', + role: 'archive', + requestId: requestId + 9999, + events: [], + metrics: {}, + }); + + // Resolve with the real id — promise should still resolve cleanly. + worker.push({ + kind: 'archive-dump', + role: 'archive', + requestId, + events: [birthEvent(4, 4)], + metrics: { real: 1 }, + }); + + const dump = await promise; + expect(dump.metrics).toEqual({ real: 1 }); + }); + + it('startPolling invokes the callback at DEFAULT_POLL_INTERVAL_MS cadence; stopPolling halts', async () => { + const received: number[] = []; + client.startPolling((dump) => { + received.push(dump.events.length); + }); + + // First tick: driver posts a request immediately on start. + await vi.advanceTimersByTimeAsync(0); + expect(worker.posted.length).toBe(1); + let reqId = (worker.posted[0] as { requestId: number }).requestId; + worker.push({ + kind: 'archive-dump', + role: 'archive', + requestId: reqId, + events: [birthEvent(1, 1)], + metrics: {}, + }); + await vi.advanceTimersByTimeAsync(0); + expect(received.length).toBe(1); + + // Second tick at the poll interval. + await vi.advanceTimersByTimeAsync(DEFAULT_POLL_INTERVAL_MS); + expect(worker.posted.length).toBe(2); + reqId = (worker.posted[1] as { requestId: number }).requestId; + worker.push({ + kind: 'archive-dump', + role: 'archive', + requestId: reqId, + events: [birthEvent(2, 2), birthEvent(3, 3)], + metrics: {}, + }); + await vi.advanceTimersByTimeAsync(0); + expect(received.length).toBe(2); + expect(received[1]).toBe(2); + + client.stopPolling(); + await vi.advanceTimersByTimeAsync(DEFAULT_POLL_INTERVAL_MS * 3); + // No new posts after stopPolling. + expect(worker.posted.length).toBe(2); + }); + + it('dispose removes listeners and rejects any in-flight requestDump', async () => { + const p = client.requestDump(); + const caught = p.catch((err: unknown) => err); + expect(worker.listenerCount()).toBeGreaterThan(0); + + client.dispose(); + expect(worker.listenerCount()).toBe(0); + + const err = await caught; + expect(err).toBeInstanceOf(Error); + expect((err as Error).name).toMatch(/Abort|Dispose/); + }); + + it('onEvent delivers PipelineEvent messages posted by the worker', () => { + const received: PipelineEvent[] = []; + const unsub = client.onEvent((e) => { + received.push(e); + }); + + const e1 = birthEvent(7, 7); + worker.push({ kind: 'event', role: 'archive', event: e1 }); + expect(received).toEqual([e1]); + + unsub(); + worker.push({ + kind: 'event', + role: 'archive', + event: metricEvent(8, 'after-unsub', 0), + }); + expect(received.length).toBe(1); + }); +}); diff --git a/apps/cala/src/lib/__tests__/dashboard-store.test.ts b/apps/cala/src/lib/__tests__/dashboard-store.test.ts new file mode 100644 index 0000000..6514929 --- /dev/null +++ b/apps/cala/src/lib/__tests__/dashboard-store.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import type { PipelineEvent } from '@calab/cala-runtime'; +import { + dashboard, + applyDump, + recordFrameProcessed, + resetDashboard, + DEFAULT_EVENT_WINDOW, +} from '../dashboard-store.ts'; + +function birthEvent(t: number, id: number): PipelineEvent { + return { + kind: 'birth', + t, + id, + patch: [0, 0], + footprintSnap: { + pixelIndices: new Uint32Array([id]), + values: new Float32Array([1]), + }, + }; +} + +function metricEvent(t: number, name: string, value: number): PipelineEvent { + return { kind: 'metric', t, name, value }; +} + +describe('cala dashboard-store', () => { + beforeEach(() => { + resetDashboard(); + }); + + it('applyDump replaces metrics and appends events with window trimming', () => { + // Seed with a first dump of 3 events. + applyDump({ + events: [birthEvent(1, 1), birthEvent(2, 2), birthEvent(3, 3)], + metrics: { residual: 0.1, traces: 3 }, + }); + expect(dashboard.events.length).toBe(3); + expect(dashboard.metrics).toEqual({ residual: 0.1, traces: 3 }); + expect(dashboard.lastDumpAt).not.toBeNull(); + + // Oversized dump should be trimmed to DEFAULT_EVENT_WINDOW, keeping + // the most recent events (from the tail). + const big: PipelineEvent[] = []; + for (let i = 0; i < DEFAULT_EVENT_WINDOW + 50; i += 1) { + big.push(metricEvent(i, `m_${i}`, i)); + } + applyDump({ events: big, metrics: { residual: 0.2 } }); + expect(dashboard.events.length).toBe(DEFAULT_EVENT_WINDOW); + expect(dashboard.metrics).toEqual({ residual: 0.2 }); + + // Tail should be the newest event from the dump, not an older one. + const last = dashboard.events[dashboard.events.length - 1]; + expect(last.kind).toBe('metric'); + expect((last as { t: number }).t).toBe(DEFAULT_EVENT_WINDOW + 49); + }); + + it('recordFrameProcessed updates currentFrameIndex and currentEpoch atomically', () => { + recordFrameProcessed(42, 7n); + expect(dashboard.currentFrameIndex).toBe(42); + expect(dashboard.currentEpoch).toBe(7n); + + recordFrameProcessed(100, 12n); + expect(dashboard.currentFrameIndex).toBe(100); + expect(dashboard.currentEpoch).toBe(12n); + }); + + it('resetDashboard clears events, metrics, timestamps, and frame state', () => { + applyDump({ events: [birthEvent(1, 1)], metrics: { foo: 1 } }); + recordFrameProcessed(5, 3n); + expect(dashboard.events.length).toBeGreaterThan(0); + expect(dashboard.currentFrameIndex).not.toBeNull(); + + resetDashboard(); + expect(dashboard.events.length).toBe(0); + expect(dashboard.metrics).toEqual({}); + expect(dashboard.lastDumpAt).toBeNull(); + expect(dashboard.currentFrameIndex).toBeNull(); + expect(dashboard.currentEpoch).toBeNull(); + }); + + it('interleaved applyDump + recordFrameProcessed do not corrupt each other', () => { + recordFrameProcessed(1, 1n); + applyDump({ events: [birthEvent(1, 1)], metrics: { a: 1 } }); + recordFrameProcessed(2, 2n); + applyDump({ events: [birthEvent(2, 2)], metrics: { a: 2 } }); + + expect(dashboard.currentFrameIndex).toBe(2); + expect(dashboard.currentEpoch).toBe(2n); + expect(dashboard.events.length).toBe(1); // latest dump + expect(dashboard.metrics).toEqual({ a: 2 }); + }); +}); diff --git a/apps/cala/src/lib/__tests__/frame-preview.test.ts b/apps/cala/src/lib/__tests__/frame-preview.test.ts new file mode 100644 index 0000000..b93ed8e --- /dev/null +++ b/apps/cala/src/lib/__tests__/frame-preview.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { quantizeToU8, writeGrayscaleToImageData } from '../frame-preview.ts'; + +describe('frame-preview helpers', () => { + it('quantizeToU8 linearly autoscales min→0, max→255', () => { + const frame = new Float32Array([1.5, 2.5, 3.5, 4.5]); + const out = quantizeToU8(frame); + expect(out[0]).toBe(0); + expect(out[3]).toBe(255); + // Middle values land inside the range. + expect(out[1]).toBeGreaterThan(0); + expect(out[1]).toBeLessThan(255); + }); + + it('quantizeToU8 returns mid-gray for flat frames', () => { + const frame = new Float32Array([2, 2, 2, 2]); + const out = quantizeToU8(frame); + expect([...out]).toEqual([128, 128, 128, 128]); + }); + + it('quantizeToU8 handles empty frames without crashing', () => { + const out = quantizeToU8(new Float32Array(0)); + expect(out.length).toBe(0); + }); + + it('writeGrayscaleToImageData expands gray → RGBA with opaque alpha', () => { + const pixels = new Uint8ClampedArray([10, 200, 50, 255]); + // Minimal ImageData stand-in that matches the interface the helper + // uses — avoids needing a real canvas in node env. + const rgba = new Uint8ClampedArray(pixels.length * 4); + const imageData = { data: rgba } as unknown as ImageData; + writeGrayscaleToImageData(pixels, imageData); + // Spot-check the four channels of the second pixel (value 200). + expect(rgba[4]).toBe(200); + expect(rgba[5]).toBe(200); + expect(rgba[6]).toBe(200); + expect(rgba[7]).toBe(255); + }); +}); diff --git a/apps/cala/src/lib/archive-client.ts b/apps/cala/src/lib/archive-client.ts new file mode 100644 index 0000000..a1c70ab --- /dev/null +++ b/apps/cala/src/lib/archive-client.ts @@ -0,0 +1,157 @@ +import type { PipelineEvent, WorkerLike, WorkerOutbound, Unsubscribe } from '@calab/cala-runtime'; + +// Polling cadence for the dashboard's periodic dump (design §10). One +// pull per second is fast enough that the UI feels live and slow +// enough that the worker spends >99% of its time in the event bus. +export const DEFAULT_POLL_INTERVAL_MS = 1000; + +// Maximum wait for a single archive-dump reply. Sized well above the +// polling cadence so transient worker-side stalls don't spuriously +// time out in normal operation. +export const DEFAULT_DUMP_TIMEOUT_MS = 5000; + +export interface ArchiveDump { + events: PipelineEvent[]; + metrics: Record; +} + +export interface ArchiveClient { + requestDump(): Promise; + startPolling(cb: (dump: ArchiveDump) => void): void; + stopPolling(): void; + onEvent(cb: (e: PipelineEvent) => void): Unsubscribe; + dispose(): void; +} + +export interface ArchiveClientOptions { + pollIntervalMs?: number; + dumpTimeoutMs?: number; +} + +class DumpAbortError extends Error { + constructor(message: string) { + super(message); + this.name = 'DumpAbortError'; + } +} + +class DumpTimeoutError extends Error { + constructor(message: string) { + super(message); + this.name = 'DumpTimeoutError'; + } +} + +interface PendingDump { + resolve: (dump: ArchiveDump) => void; + reject: (err: Error) => void; + timer: ReturnType; +} + +export function createArchiveClient( + worker: WorkerLike, + options: ArchiveClientOptions = {}, +): ArchiveClient { + const pollInterval = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + const dumpTimeout = options.dumpTimeoutMs ?? DEFAULT_DUMP_TIMEOUT_MS; + + const pending = new Map(); + const eventListeners = new Set<(e: PipelineEvent) => void>(); + let nextRequestId = 1; + let disposed = false; + let pollTimer: ReturnType | null = null; + let pollCallback: ((dump: ArchiveDump) => void) | null = null; + + const handleMessage = (ev: { data: WorkerOutbound }): void => { + const msg = ev.data; + if (msg.kind === 'archive-dump') { + const entry = pending.get(msg.requestId); + // Unknown-id replies (e.g. from a disposed-and-recreated client + // sharing the worker) must not spuriously resolve a waiter. + if (!entry) return; + pending.delete(msg.requestId); + clearTimeout(entry.timer); + entry.resolve({ events: msg.events, metrics: msg.metrics }); + return; + } + if (msg.kind === 'event') { + for (const cb of eventListeners) cb(msg.event); + return; + } + }; + + worker.addEventListener('message', handleMessage); + + function requestDump(): Promise { + if (disposed) { + return Promise.reject(new DumpAbortError('archive client disposed')); + } + const requestId = nextRequestId; + nextRequestId += 1; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(requestId); + reject( + new DumpTimeoutError( + `archive dump (requestId=${requestId}) timed out after ${dumpTimeout}ms`, + ), + ); + }, dumpTimeout); + pending.set(requestId, { resolve, reject, timer }); + worker.postMessage({ kind: 'request-archive-dump', requestId }); + }); + } + + function startPolling(cb: (dump: ArchiveDump) => void): void { + if (disposed) return; + pollCallback = cb; + const tick = (): void => { + if (disposed || pollCallback === null) return; + requestDump() + .then((dump) => { + if (!disposed && pollCallback !== null) pollCallback(dump); + }) + .catch(() => { + // Polling soft-fails per design §10 — dashboard is cosmetic. + // A miss at one tick is recovered by the next. + }) + .finally(() => { + if (disposed || pollCallback === null) return; + pollTimer = setTimeout(tick, pollInterval); + }); + }; + // Fire-immediately-then-interval: the dashboard feels live from + // the moment the run starts rather than waiting one full period. + pollTimer = setTimeout(tick, 0); + } + + function stopPolling(): void { + pollCallback = null; + if (pollTimer !== null) { + clearTimeout(pollTimer); + pollTimer = null; + } + } + + function onEvent(cb: (e: PipelineEvent) => void): Unsubscribe { + eventListeners.add(cb); + return () => { + eventListeners.delete(cb); + }; + } + + function dispose(): void { + if (disposed) return; + disposed = true; + stopPolling(); + worker.removeEventListener('message', handleMessage); + eventListeners.clear(); + for (const [, entry] of pending) { + clearTimeout(entry.timer); + entry.reject(new DumpAbortError('archive client disposed')); + } + pending.clear(); + } + + return { requestDump, startPolling, stopPolling, onEvent, dispose }; +} diff --git a/apps/cala/src/lib/dashboard-store.ts b/apps/cala/src/lib/dashboard-store.ts new file mode 100644 index 0000000..0e53384 --- /dev/null +++ b/apps/cala/src/lib/dashboard-store.ts @@ -0,0 +1,57 @@ +import { createStore } from 'solid-js/store'; +import type { PipelineEvent } from '@calab/cala-runtime'; + +// Rolling window for the dashboard event log (design §9.2, §10). Kept +// much smaller than the archive worker's ring — the dashboard only +// shows a recent tail, full history stays in W4. +export const DEFAULT_EVENT_WINDOW = 500; + +export interface DashboardState { + events: PipelineEvent[]; + metrics: Record; + lastDumpAt: number | null; + currentFrameIndex: number | null; + currentEpoch: bigint | null; +} + +export interface ArchiveDump { + events: PipelineEvent[]; + metrics: Record; +} + +function emptyState(): DashboardState { + return { + events: [], + metrics: {}, + lastDumpAt: null, + currentFrameIndex: null, + currentEpoch: null, + }; +} + +const [dashboard, setDashboard] = createStore(emptyState()); + +export { dashboard }; + +export function applyDump(dump: ArchiveDump, nowMs?: number): void { + // Keep only the tail when an oversized dump arrives — the dashboard + // view only ever renders the most recent slice, and holding the full + // archive ring on the main thread would defeat the purpose of W4. + const trimmed = + dump.events.length > DEFAULT_EVENT_WINDOW + ? dump.events.slice(dump.events.length - DEFAULT_EVENT_WINDOW) + : dump.events.slice(); + setDashboard({ + events: trimmed, + metrics: { ...dump.metrics }, + lastDumpAt: nowMs ?? Date.now(), + }); +} + +export function recordFrameProcessed(index: number, epoch: bigint): void { + setDashboard({ currentFrameIndex: index, currentEpoch: epoch }); +} + +export function resetDashboard(): void { + setDashboard(emptyState()); +} diff --git a/apps/cala/src/lib/frame-preview.ts b/apps/cala/src/lib/frame-preview.ts new file mode 100644 index 0000000..bc9f29e --- /dev/null +++ b/apps/cala/src/lib/frame-preview.ts @@ -0,0 +1,54 @@ +// Pure helpers shared between W1 (post side) and SingleFrameViewer +// (render side). Kept in lib/ so both the worker and the component can +// import it without either depending on the other. + +const U8_MAX = 255; +const U8_MID = 128; + +/** + * Linear autoscale of a grayscale f32 frame into u8. The dashboard + * preview is cosmetic (design §12 frame panel) and a fixed scale would + * clip the preprocessed frame which carries both DC-subtracted baseline + * and residual high-frequency content. + */ +export function quantizeToU8(frame: Float32Array): Uint8ClampedArray { + const out = new Uint8ClampedArray(frame.length); + if (frame.length === 0) return out; + let min = frame[0]; + let max = frame[0]; + for (let k = 1; k < frame.length; k += 1) { + const v = frame[k]; + if (v < min) min = v; + if (v > max) max = v; + } + const span = max - min; + if (span <= 0) { + // Flat frame — render mid-gray so the user still sees something. + out.fill(U8_MID); + return out; + } + const scale = U8_MAX / span; + for (let k = 0; k < frame.length; k += 1) { + out[k] = (frame[k] - min) * scale; + } + return out; +} + +/** + * Copy a u8 grayscale plane into the RGBA byte layout expected by + * `ImageData`. `imageData` must already be sized `width × height`; the + * alpha channel is set to opaque. + */ +export function writeGrayscaleToImageData(pixels: Uint8ClampedArray, imageData: ImageData): void { + const rgba = imageData.data; + // 4 bytes per pixel (RGBA). The loop is the tight hot path of the + // viewer — branch-free, typed-array-only. + for (let i = 0; i < pixels.length; i += 1) { + const g = pixels[i]; + const off = i << 2; + rgba[off] = g; + rgba[off + 1] = g; + rgba[off + 2] = g; + rgba[off + 3] = U8_MAX; + } +} diff --git a/apps/cala/src/lib/run-control.ts b/apps/cala/src/lib/run-control.ts index 039825c..728dc00 100644 --- a/apps/cala/src/lib/run-control.ts +++ b/apps/cala/src/lib/run-control.ts @@ -1,3 +1,4 @@ +import { createSignal, type Accessor } from 'solid-js'; import { openAviUncompressed } from '@calab/io'; import type { FrameSource, FrameSourceMeta } from '@calab/io'; import { @@ -7,9 +8,17 @@ import { type RuntimeState, type WorkerFactory, type WorkerLike, + type WorkerOutbound, type WorkerRole, } from '@calab/cala-runtime'; import { state, setRunState, setErrorMsg } from './data-store.ts'; +import { recordFrameProcessed } from './dashboard-store.ts'; +import { + createDecodePreprocessWorker, + createFitWorker, + createExtendWorker, + createArchiveWorker, +} from '../workers/index.ts'; // Ring / queue sizing defaults (design §7.1, §7.3, §13). // Kept in one place so future tuning passes have a single knob per @@ -29,30 +38,38 @@ const DEFAULT_STARTUP_TIMEOUT_MS = 5000; const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5000; // f32 grayscale → 4 bytes per pixel. const BYTES_PER_F32_PIXEL = 4; +// W1 preview cadence (design §12 frame panel). Strided so the canvas +// updates a few times per second even on a fast pipeline, without the +// main thread paying postMessage cost on every decode. +const DEFAULT_FRAME_PREVIEW_STRIDE = 2; export type WorkerFactories = Record; -function noopWorker(): WorkerLike { - // Stub worker for tests and for wiring in the UI before the real - // workers land (tasks 21-23). Never signals ready, never signals - // done — the real factories override this at call-site. +function defaultWorkerFactories(): WorkerFactories { + // Real worker factories now that tasks 21-23 landed. Tests still + // override this by passing an explicit `factories` to `startRun`. return { - postMessage: () => {}, - addEventListener: () => {}, - removeEventListener: () => {}, - terminate: () => {}, + decodePreprocess: createDecodePreprocessWorker, + fit: createFitWorker, + extend: createExtendWorker, + archive: createArchiveWorker, }; } -function defaultWorkerFactories(): WorkerFactories { - return { - decodePreprocess: noopWorker, - fit: noopWorker, - extend: noopWorker, - archive: noopWorker, - }; +export interface LatestFramePreview { + index: number; + width: number; + height: number; + pixels: Uint8ClampedArray; } +// 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); + +export const latestFrame: Accessor = latestFrameSignal; + function buildConfig(meta: FrameSourceMeta, factories: WorkerFactories): RuntimeConfig { const frameBytes = meta.width * meta.height * BYTES_PER_F32_PIXEL; return { @@ -81,6 +98,15 @@ function buildConfig(meta: FrameSourceMeta, factories: WorkerFactories): Runtime }, startupTimeoutMs: DEFAULT_STARTUP_TIMEOUT_MS, shutdownTimeoutMs: DEFAULT_SHUTDOWN_TIMEOUT_MS, + workerConfigs: { + decodePreprocess: { + framePreviewStride: DEFAULT_FRAME_PREVIEW_STRIDE, + }, + fit: { + height: meta.height, + width: meta.width, + }, + }, }; } @@ -93,11 +119,61 @@ const frameSourceFactory: FrameSourceFactory = openAviUncompressed; let currentRuntime: RuntimeController | null = null; let currentUnsubscribe: (() => void) | null = null; +// Captured per-run so the main thread can construct an ArchiveClient +// against the archive worker and so we can read W1's frame-preview +// posts. Cleared on run end. +let currentArchiveWorker: WorkerLike | null = null; +let currentPreviewDetach: (() => void) | null = null; export interface StartOptions { factories?: WorkerFactories; } +export function currentArchiveWorkerForClient(): WorkerLike | null { + return currentArchiveWorker; +} + +function wrapFactories(base: WorkerFactories): WorkerFactories { + const wrap = + (role: WorkerRole, inner: WorkerFactory): WorkerFactory => + () => { + const worker = inner(); + if (role === 'archive') { + currentArchiveWorker = worker; + } + if (role === 'decodePreprocess') { + // Main-thread listener for W1 preview posts + heartbeat frame + // indexing. Runs alongside the orchestrator's own listener — + // neither interferes with the other. + 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, + }); + return; + } + if (msg.kind === 'frame-processed') { + recordFrameProcessed(msg.index, msg.epoch); + return; + } + }; + worker.addEventListener('message', listener); + currentPreviewDetach = () => worker.removeEventListener('message', listener); + } + return worker; + }; + return { + decodePreprocess: wrap('decodePreprocess', base.decodePreprocess), + fit: wrap('fit', base.fit), + extend: wrap('extend', base.extend), + archive: wrap('archive', base.archive), + }; +} + export async function startRun(opts: StartOptions = {}): Promise { if (currentRuntime !== null) { throw new Error('run already in progress'); @@ -111,7 +187,8 @@ export async function startRun(opts: StartOptions = {}): Promise { setErrorMsg(null); setRunState('starting'); - const factories = opts.factories ?? defaultWorkerFactories(); + const baseFactories = opts.factories ?? defaultWorkerFactories(); + const factories = wrapFactories(baseFactories); const cfg = buildConfig(meta, factories); const rt = createRuntime(cfg); currentRuntime = rt; @@ -137,6 +214,9 @@ export async function startRun(opts: StartOptions = {}): Promise { currentUnsubscribe?.(); currentUnsubscribe = null; currentRuntime = null; + currentPreviewDetach?.(); + currentPreviewDetach = null; + currentArchiveWorker = null; } } diff --git a/apps/cala/src/styles/global.css b/apps/cala/src/styles/global.css index 9719a19..467c111 100644 --- a/apps/cala/src/styles/global.css +++ b/apps/cala/src/styles/global.css @@ -19,3 +19,111 @@ .info-summary__sep { color: var(--text-tertiary); } + +/* ─── Single-frame viewer (Phase 5 task 24) ────────────────────────── */ + +.frame-viewer { + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; + gap: var(--space-md); + padding: var(--space-md); + align-items: start; +} + +.frame-viewer__canvas-wrap { + position: relative; + background: var(--bg-secondary); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + padding: var(--space-sm); + display: flex; + align-items: center; + justify-content: center; + min-height: 320px; +} + +.frame-viewer__canvas { + image-rendering: pixelated; + max-width: 100%; + height: auto; + background: var(--bg-inset); + display: block; +} + +.frame-viewer__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.85rem; + pointer-events: none; +} + +.frame-viewer__side { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.frame-viewer__stat { + font-family: var(--font-mono); + font-size: 0.85rem; + color: var(--text-secondary); +} + +.frame-viewer__stat--frame { + color: var(--text-primary); + font-weight: var(--font-weight-medium); +} + +.frame-viewer__events-heading { + font-family: var(--font-body); + font-size: 0.8rem; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-top: var(--space-sm); + margin-bottom: var(--space-xs); +} + +.frame-viewer__events-empty { + font-family: var(--font-mono); + font-size: 0.8rem; + color: var(--text-tertiary); +} + +.frame-viewer__events-list { + list-style: none; + padding: 0; + margin: 0; + max-height: 360px; + overflow-y: auto; + font-family: var(--font-mono); + font-size: 0.8rem; +} + +.frame-viewer__events-item { + display: flex; + gap: var(--space-xs); + padding: 2px 0; + border-bottom: 1px solid var(--border-subtle); + color: var(--text-secondary); +} + +.frame-viewer__events-item:last-child { + border-bottom: none; +} + +.frame-viewer__events-kind { + color: var(--accent); + min-width: 72px; + flex-shrink: 0; +} + +.frame-viewer__events-detail { + color: var(--text-secondary); + word-break: break-word; +} diff --git a/apps/cala/src/workers/decode-preprocess.worker.ts b/apps/cala/src/workers/decode-preprocess.worker.ts index 0234eff..7508914 100644 --- a/apps/cala/src/workers/decode-preprocess.worker.ts +++ b/apps/cala/src/workers/decode-preprocess.worker.ts @@ -8,12 +8,18 @@ import { type WorkerOutbound, type ChannelConfig, } from '@calab/cala-runtime'; +import { quantizeToU8 } from '../lib/frame-preview.ts'; // Heartbeat cadence: post a `frame-processed` beat every N frames so // the orchestrator can update status without being spammed every frame. // Overridable via `workerConfig.heartbeatStride` (design §7.1, no magic // numbers rule: every tuning knob lives in config or in a named const). const DEFAULT_HEARTBEAT_STRIDE = 8; +// Preview cadence for the dashboard's SingleFrameViewer (design §12, +// Phase 5). The preview is a u8 grayscale snapshot of the processed +// frame — cheap to post, cheap to render with putImageData. Disabled +// (stride ≤ 0) unless the app explicitly opts in through workerConfig. +const DEFAULT_FRAME_PREVIEW_STRIDE = 0; const DEFAULT_GRAYSCALE_METHOD: GrayscaleMethod = 'Green'; const DEFAULT_METADATA_JSON = '{}'; const DEFAULT_PREPROCESS_CONFIG_JSON = '{}'; @@ -33,6 +39,7 @@ interface WorkerGlobalScope { interface DecodePreprocessWorkerConfig { source: { kind: 'file'; file: File }; heartbeatStride?: number; + framePreviewStride?: number; metadataJson?: string; preprocessConfigJson?: string; grayscaleMethod?: GrayscaleMethod; @@ -53,8 +60,11 @@ interface RuntimeHandles { preprocessor: Preprocessor; frameChannel: SabRingChannel; heartbeatStride: number; + framePreviewStride: number; grayscaleMethod: GrayscaleMethod; frameCount: number; + width: number; + height: number; } let handles: RuntimeHandles | null = null; @@ -85,8 +95,9 @@ function parseConfig(raw: unknown): DecodePreprocessWorkerConfig { } return { source: { kind: 'file', file }, - heartbeatStride: - typeof cfg.heartbeatStride === 'number' ? cfg.heartbeatStride : undefined, + heartbeatStride: typeof cfg.heartbeatStride === 'number' ? cfg.heartbeatStride : undefined, + framePreviewStride: + typeof cfg.framePreviewStride === 'number' ? cfg.framePreviewStride : undefined, metadataJson: typeof cfg.metadataJson === 'string' ? cfg.metadataJson : undefined, preprocessConfigJson: typeof cfg.preprocessConfigJson === 'string' ? cfg.preprocessConfigJson : undefined, @@ -99,9 +110,7 @@ function parseConfig(raw: unknown): DecodePreprocessWorkerConfig { frameChannelSlotCount: typeof cfg.frameChannelSlotCount === 'number' ? cfg.frameChannelSlotCount : undefined, frameChannelWaitTimeoutMs: - typeof cfg.frameChannelWaitTimeoutMs === 'number' - ? cfg.frameChannelWaitTimeoutMs - : undefined, + typeof cfg.frameChannelWaitTimeoutMs === 'number' ? cfg.frameChannelWaitTimeoutMs : undefined, frameChannelPollIntervalMs: typeof cfg.frameChannelPollIntervalMs === 'number' ? cfg.frameChannelPollIntervalMs @@ -139,8 +148,11 @@ async function handleInit(payload: WorkerInitPayload): Promise { preprocessor, frameChannel, heartbeatStride: cfg.heartbeatStride ?? DEFAULT_HEARTBEAT_STRIDE, + framePreviewStride: cfg.framePreviewStride ?? DEFAULT_FRAME_PREVIEW_STRIDE, grayscaleMethod: cfg.grayscaleMethod ?? DEFAULT_GRAYSCALE_METHOD, frameCount: meta.frameCount, + width: meta.width, + height: meta.height, }; post({ kind: 'ready', role: ROLE }); @@ -159,6 +171,16 @@ async function decodeLoop(h: RuntimeHandles): Promise { 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) { + post({ + kind: 'frame-preview', + role: ROLE, + index: i, + width: h.width, + height: h.height, + pixels: quantizeToU8(processed), + }); + } } } diff --git a/packages/cala-runtime/src/worker-protocol.ts b/packages/cala-runtime/src/worker-protocol.ts index 830e018..229e541 100644 --- a/packages/cala-runtime/src/worker-protocol.ts +++ b/packages/cala-runtime/src/worker-protocol.ts @@ -72,6 +72,20 @@ export type WorkerOutbound = requestId: number; events: PipelineEvent[]; metrics: Record; + } + // 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. + | { + kind: 'frame-preview'; + role: WorkerRole; + index: number; + width: number; + height: number; + pixels: Uint8ClampedArray; }; /** From a858dae0255ea9c5943509186090e241f8c2b0ca Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 18 Apr 2026 15:14:47 -0700 Subject: [PATCH 14/17] =?UTF-8?q?feat(cala):=20Phase=205=20exit=20?= =?UTF-8?q?=E2=80=94=20E2E=20on=20real=20AVI=20(task=2025)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Path B (Node vitest harness) — Playwright + chromium downloads were blocked by the task 25 sandbox, so the E2E pipes real AVI bytes through the real W1/W2/W4 worker modules via the existing WorkerHarness pattern instead of a real browser. Fixture: .test_data/anchor_v12_prepped.avi (448x288, 10 frames run). Observed: 5 decode heartbeats, 5 fit heartbeats, 2 preview frames, 2 archived metric events, 0 worker errors, 18 ms end-to-end. Opt-in via `npm run test:e2e:cala`; default `npm test` continues to pass on 401 tests across 45 files. Real browser E2E + real WASM + coi-serviceworker remain Phase 6+. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cala/README.md | 10 +- apps/cala/e2e/phase5-exit.e2e.test.ts | 612 ++++++++++++++++++++++++++ apps/cala/package.json | 1 + apps/cala/tsconfig.json | 2 +- apps/cala/vitest.config.ts | 5 + apps/cala/vitest.e2e.config.ts | 28 ++ package.json | 1 + 7 files changed, 655 insertions(+), 4 deletions(-) create mode 100644 apps/cala/e2e/phase5-exit.e2e.test.ts create mode 100644 apps/cala/vitest.e2e.config.ts diff --git a/apps/cala/README.md b/apps/cala/README.md index 9e0a147..2aab002 100644 --- a/apps/cala/README.md +++ b/apps/cala/README.md @@ -4,15 +4,19 @@ Streaming calcium imaging demixing. Browser-native OMF pipeline port of Raymond ## Status -**Coming soon** — scaffolded in Phase 5, functional build lands at Phase 5 exit (task 25). See `.planning/CALA_DESIGN.md` for the full design. +**Phase 5 exit complete, 2026-04-18.** End-to-end W1→W2→W4 pipeline runs on a real uncompressed 8-bit miniscope AVI. The production runtime is browser-only; see `.planning/CALA_DESIGN.md` for the full phase ledger and what's deferred to Phase 6+. ## Dev ``` -npm run dev -w apps/cala # starts Vite with COOP/COEP headers set +npm run dev -w apps/cala # starts Vite with COOP/COEP headers set npm run verify-sab -w apps/cala # boots Vite, asserts SAB headers live +npm run test:e2e -w apps/cala # Phase 5 exit E2E on a real AVI fixture +npm run test:e2e:cala # same, from repo root ``` +The E2E fixture lives under `.test_data/` (gitignored — local-only). The Phase 5 exit spec reads `.test_data/anchor_v12_prepped.avi` by default; if you don't have that file the test throws with a clear message. See `apps/cala/e2e/phase5-exit.e2e.test.ts` for the full harness — it pipes real AVI bytes through the real W1, W2, and W4 worker modules wired by the real SAB channel, with the WASM numerical core stubbed (Rust/WASM correctness is covered by the Phase 3 exit in `crates/cala-core`). + `SharedArrayBuffer` (used by the worker runtime for SAB-backed channels, mutation queue, and event bus) needs cross-origin isolation: - `Cross-Origin-Opener-Policy: same-origin` @@ -55,4 +59,4 @@ Per-task layout expansions: | 22 | `workers/fit.worker.ts` | | 23 | `workers/extend.worker.ts`, `workers/archive.worker.ts` | | 24 | `components/frame/SingleFrameViewer.tsx`, `lib/archive-client.ts`, `lib/dashboard-store.ts` | -| 25 | Phase 5 exit E2E on a real AVI | +| 25 | `e2e/phase5-exit.e2e.test.ts`, `vitest.e2e.config.ts`, `test:e2e` scripts | diff --git a/apps/cala/e2e/phase5-exit.e2e.test.ts b/apps/cala/e2e/phase5-exit.e2e.test.ts new file mode 100644 index 0000000..e6b4447 --- /dev/null +++ b/apps/cala/e2e/phase5-exit.e2e.test.ts @@ -0,0 +1,612 @@ +/** + * Phase 5 exit E2E — task 25. + * + * Drives the full W1 → W2 → W4 pipeline on a real uncompressed 8-bit + * miniscope AVI from `.test_data/`, proving the TS worker graph built in + * tasks 20-24 handles real recordings end-to-end. + * + * Harness strategy (Path B from task 25). Playwright could not be + * installed in the sandbox that authored task 25, so we replace the + * browser with vitest + in-process `WorkerHarness` shims (same pattern + * as the existing unit tests) and replace the native Worker boundary + * with direct `harness.deliver()` calls. What is *not* mocked: + * + * - Real bytes from `.test_data/*.avi` (RIFF container parsed in JS). + * - Real `SabRingChannel` (`@calab/cala-runtime`) moving frames from + * W1 to W2. This is the SAB transport that design §7.1 specifies; + * the browser E2E would exercise the same channel module. + * - Real `decode-preprocess.worker.ts`, `fit.worker.ts`, + * `archive.worker.ts` modules — every branch you see exercised here + * is production code. + * - Real `PipelineEvent` relay from W2 to W4, mirroring the path the + * orchestrator wires in `packages/cala-runtime/orchestrator.ts`. + * + * What IS stubbed: + * + * - `@calab/cala-core` WASM. The Rust numerical core has its own + * Phase 3 exit (task 11) running cold-start OMF on synthetic data, + * so we intentionally don't re-prove WASM correctness here. The + * stub AviReader parses real AVI RIFF bytes in JS so the frames + * flowing through the pipeline are genuine. + * - Native `Worker` + `postMessage`. Replaced by the same + * `WorkerHarness` the unit tests use. + * + * The browser path (real Web Workers + real WASM + real SAB) remains a + * Phase 6+ deliverable; see `.planning/CALA_DESIGN.md` for status. + */ + +import { readFileSync, existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + SabRingChannel, + type PipelineEvent, + type WorkerInbound, + type WorkerOutbound, +} from '@calab/cala-runtime'; +import { + createWorkerHarness, + type WorkerHarness, +} from '../src/workers/__tests__/worker-harness.ts'; + +// --- tuning knobs (no magic numbers per user rule) ---------------------- +const DEFAULT_TEST_TIMEOUT_MS = 60_000; +const TEST_POLL_MS = 2; +const TEST_POLL_MAX_TICKS = 30_000; +const TEST_MAX_FRAMES = 32; // cap frames pushed so the test completes fast +const TEST_MIN_FRAMES_PROCESSED = 8; +const TEST_MIN_METRIC_EVENTS = 2; +const TEST_HEARTBEAT_STRIDE = 2; +const TEST_PREVIEW_STRIDE = 4; +const TEST_FIT_METRIC_STRIDE = 4; +const TEST_SNAPSHOT_STRIDE = 1_000_000; // effectively disabled in this test +const TEST_FRAME_CHANNEL_SLOT_COUNT = 8; +const TEST_FRAME_CHANNEL_WAIT_TIMEOUT_MS = 50; +const TEST_FRAME_CHANNEL_POLL_INTERVAL_MS = 1; +const TEST_MUTATION_QUEUE_CAPACITY = 8; +const TEST_EVENT_BUS_CAPACITY = 64; +const TEST_EVENT_BUS_MAX_SUBSCRIBERS = 4; +const TEST_SNAPSHOT_ACK_TIMEOUT_MS = 50; +const TEST_SNAPSHOT_POLL_INTERVAL_MS = 1; +const TEST_SNAPSHOT_PENDING_CAPACITY = 1; + +// AVI fixture. Picks the smallest real miniscope AVI that ships with +// the repo's .test_data/. Chosen for speed: task 25 only needs the +// first few dozen frames to exercise every worker. +const REPO_ROOT = path.resolve(fileURLToPath(import.meta.url), '../../../..'); +const AVI_FIXTURE = path.join(REPO_ROOT, '.test_data', 'anchor_v12_prepped.avi'); + +// --- minimal JS-side AVI RIFF parser ------------------------------------ +// Mirrors `.test_data/avi_stats.py` — RIFF/AVI/hdrl-walk to find width + +// height, then RIFF/movi walk to enumerate per-frame byte ranges. + +interface ParsedAvi { + width: number; + height: number; + channels: number; + bitDepth: number; + fps: number; + frames: { offset: number; size: number }[]; + bytes: Uint8Array; +} + +function fourcc(bytes: Uint8Array, at: number): string { + return String.fromCharCode(bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]); +} + +function parseAvi(bytes: Uint8Array): ParsedAvi { + if (fourcc(bytes, 0) !== 'RIFF' || fourcc(bytes, 8) !== 'AVI ') { + throw new Error('fixture is not a RIFF/AVI container'); + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let width = 0; + let height = 0; + let channels = 1; + let bitDepth = 8; + const fps = 30; + const frames: { offset: number; size: number }[] = []; + let i = 12; + while (i + 8 <= bytes.length) { + const tag = fourcc(bytes, i); + const size = view.getUint32(i + 4, true); + if (tag === 'LIST') { + const kind = fourcc(bytes, i + 8); + if (kind === 'hdrl') { + let j = i + 12; + const end = i + 8 + size; + while (j + 8 <= end) { + const t = fourcc(bytes, j); + const s = view.getUint32(j + 4, true); + if (t === 'strf') { + // BITMAPINFOHEADER: width @+12 (i32), height @+16 (i32), + // bitCount @+22 (u16). + 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 }; +} + +// --- @calab/cala-core stubs -------------------------------------------- +// The mock AviReader reads real bytes off the parsed AVI. The mock +// Preprocessor and Fitter are lightweight — Preprocessor is a copy, +// Fitter emits one `metric` event every TEST_FIT_METRIC_STRIDE frames +// so W4 can archive real structural activity end-to-end. + +interface MockAviReader { + width(): number; + height(): number; + frameCount(): number; + fps(): number; + channels(): number; + bitDepth(): number; + readFrameGrayscaleF32(n: number, method: string): Float32Array; + free(): void; +} + +let parsedAvi: ParsedAvi | null = null; + +function setParsedAvi(p: ParsedAvi | null): void { + parsedAvi = p; +} + +class StubAviReader implements MockAviReader { + 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, _method: string): Float32Array { + const p = parsedAvi!; + const { offset } = p.frames[n]; + const pixels = p.width * p.height; + const out = new Float32Array(pixels); + // For 8-bit monochrome, each frame byte is already one pixel; for + // 24-bit BGR (common for miniscope raw), we take the green plane as + // a close stand-in for Chang's "Green" method. Either way the data + // on the f32 output is a direct real-bytes transform of the fixture. + if (p.channels === 1) { + for (let k = 0; k < pixels; k += 1) { + out[k] = p.bytes[offset + k]; + } + } else { + const bytesPerPx = Math.floor(p.bitDepth / 8); + for (let k = 0; k < pixels; k += 1) { + out[k] = p.bytes[offset + k * bytesPerPx + 1] ?? 0; + } + } + return out; + } + free(): void { + // noop — stub owns no resources. + } +} + +class StubPreprocessor { + constructor(_h: number, _w: number, _meta: string, _cfg: string) {} + processFrameF32(input: Float32Array): Float32Array { + // Identity preprocess — keeps the pipeline numerically honest about + // the shape and magnitude of the data W2 and W4 see. + return input; + } + free(): void { + // noop + } +} + +let fitterFrameCount = 0; +class StubFitter { + private currentEpoch = 0n; + constructor(_h: number, _w: number, _cfg: string) {} + epoch(): bigint { + return this.currentEpoch; + } + numComponents(): number { + return 0; + } + step(y: Float32Array): Float32Array { + fitterFrameCount += 1; + return y; + } + drainApply(_handle: unknown): Uint32Array { + return new Uint32Array([0, 0, 0]); + } + takeSnapshot(): { epoch(): bigint; numComponents(): number; pixels(): number; free(): void } { + return { + epoch: () => this.currentEpoch, + numComponents: () => 0, + pixels: () => 0, + free: () => { + /* noop */ + }, + }; + } + free(): void { + // noop + } +} + +class StubMutationQueueHandle { + constructor(_cfg: string) {} + free(): void { + // noop + } +} + +vi.mock('@calab/cala-core', () => ({ + initCalaCore: vi.fn(async () => undefined), + AviReader: StubAviReader, + Preprocessor: StubPreprocessor, + Fitter: StubFitter, + MutationQueueHandle: StubMutationQueueHandle, +})); + +// --- pump loop helper --------------------------------------------------- + +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'); + } +} + +// --- orchestrator-lite -------------------------------------------------- +// Minimal in-process replacement for `packages/cala-runtime/orchestrator.ts` +// that lets us load the three real worker modules in sequence under +// isolated vitest globals. The real orchestrator would spawn Web +// Workers; we use harness shims that forward onmessage calls instead. + +interface BootResult { + decode: WorkerHarness; + fit: WorkerHarness; + archive: WorkerHarness; + frameChannel: SabRingChannel; +} + +async function loadDecodeWorkerIntoHarness(h: WorkerHarness): Promise { + vi.stubGlobal('self', h.self); + await import('../src/workers/decode-preprocess.worker.ts'); + vi.unstubAllGlobals(); +} + +async function loadFitWorkerIntoHarness(h: WorkerHarness): Promise { + vi.stubGlobal('self', h.self); + // Shim: the fit worker emits a `metric` event every + // TEST_FIT_METRIC_STRIDE frames by monkeypatching the StubFitter + // step so the archive has something structural to count. We wrap + // StubFitter.step here rather than in the class definition so each + // test's stride is isolated. + const originalStep = StubFitter.prototype.step; + const stride = TEST_FIT_METRIC_STRIDE; + StubFitter.prototype.step = function wrappedStep(y: Float32Array): Float32Array { + const out = originalStep.call(this, y); + if (fitterFrameCount % stride === 0) { + // Fit worker publishes events through its EventBus. The fit + // module holds a reference to the bus in module-scope `handles`; + // to keep the boundary clean we publish through the same + // mechanism the real worker uses — post an `event` outbound + // directly from step's side effect, picked up by the test's + // relay into W4. + ( + globalThis as { __calaPhase5ExitTestMetricTick?: () => void } + ).__calaPhase5ExitTestMetricTick?.(); + } + return out; + }; + await import('../src/workers/fit.worker.ts'); + vi.unstubAllGlobals(); +} + +async function loadArchiveWorkerIntoHarness(h: WorkerHarness): Promise { + vi.stubGlobal('self', h.self); + await import('../src/workers/archive.worker.ts'); + 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, + }); +} + +function makeResidualBuffer(slotBytes: number): SharedArrayBuffer | ArrayBuffer { + return makeFrameChannel(slotBytes).sharedBuffer; +} + +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 = makeResidualBuffer(slotBytes); + + const decode = createWorkerHarness(); + const fit = createWorkerHarness(); + const archive = createWorkerHarness(); + + await loadDecodeWorkerIntoHarness(decode); + await loadFitWorkerIntoHarness(fit); + await loadArchiveWorkerIntoHarness(archive); + + // Relay: fit posts `event` outbounds (from its EventBus subscribe); + // the orchestrator would forward those into W4. Here we patch the + // harness's postMessage to mirror that fan-out. + 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 }); + } + }; + + // Drive init. Decode reads the fixture bytes; file.arrayBuffer() + // needs the real `File` polyfill in node 20 (available by default). + // `new File([Uint8Array], ...)` is typed against `BlobPart` which + // narrows to ArrayBuffer-backed views; copying through a fresh + // Uint8Array sidesteps the lib.dom typing without + // changing bytes on the wire. + const fileBytes = new Uint8Array(parsed.bytes.byteLength); + fileBytes.set(parsed.bytes); + const fakeFile = new File([fileBytes], path.basename(AVI_FIXTURE)); + const initDecode: WorkerInbound = { + 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 decode.deliver(initDecode); + await pumpUntil(() => decode.posted.some((m) => m.kind === 'ready')); + + const initFit: WorkerInbound = { + kind: 'init', + payload: { + role: 'fit', + frameChannelBuffer: frameChannel.sharedBuffer, + residualChannelBuffer: residualBuffer, + workerConfig: { + height: parsed.height, + width: parsed.width, + heartbeatStride: TEST_HEARTBEAT_STRIDE, + snapshotStride: TEST_SNAPSHOT_STRIDE, + mutationDrainMaxPerIteration: 1, + eventBusCapacity: TEST_EVENT_BUS_CAPACITY, + eventBusMaxSubscribers: TEST_EVENT_BUS_MAX_SUBSCRIBERS, + snapshotAckTimeoutMs: TEST_SNAPSHOT_ACK_TIMEOUT_MS, + snapshotPollIntervalMs: TEST_SNAPSHOT_POLL_INTERVAL_MS, + snapshotPendingCapacity: TEST_SNAPSHOT_PENDING_CAPACITY, + mutationQueueCapacity: TEST_MUTATION_QUEUE_CAPACITY, + frameChannelSlotBytes: slotBytes, + frameChannelSlotCount: TEST_FRAME_CHANNEL_SLOT_COUNT, + frameChannelWaitTimeoutMs: TEST_FRAME_CHANNEL_WAIT_TIMEOUT_MS, + frameChannelPollIntervalMs: TEST_FRAME_CHANNEL_POLL_INTERVAL_MS, + }, + }, + }; + await fit.deliver(initFit); + await pumpUntil(() => fit.posted.some((m) => m.kind === 'ready')); + + const initArchive: WorkerInbound = { + kind: 'init', + payload: { + role: 'archive', + frameChannelBuffer: frameChannel.sharedBuffer, + residualChannelBuffer: residualBuffer, + workerConfig: {}, + }, + }; + await archive.deliver(initArchive); + await pumpUntil(() => archive.posted.some((m) => m.kind === 'ready')); + + return { decode, fit, archive, frameChannel }; +} + +// --- the test itself ---------------------------------------------------- + +describe('CaLa Phase 5 exit — E2E on real AVI', () => { + beforeEach(() => { + fitterFrameCount = 0; + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + setParsedAvi(null); + delete (globalThis as { __calaPhase5ExitTestMetricTick?: unknown }) + .__calaPhase5ExitTestMetricTick; + }); + + it( + 'pipes a real miniscope AVI from W1 through W2 into W4 with frame ticks + metric events', + { timeout: DEFAULT_TEST_TIMEOUT_MS }, + async () => { + if (!existsSync(AVI_FIXTURE)) { + throw new Error( + `AVI fixture missing at ${AVI_FIXTURE}. This E2E test requires the .test_data/ checkout — see .gitignore; fixtures are local-only.`, + ); + } + + const rawBytes = readFileSync(AVI_FIXTURE); + const realAvi = parseAvi(new Uint8Array(rawBytes)); + + // Clamp the fixture frame count so the E2E stays fast; slicing + // the `frames` index is enough — StubAviReader walks that array. + const clamped: ParsedAvi = { + ...realAvi, + frames: realAvi.frames.slice(0, TEST_MAX_FRAMES), + }; + setParsedAvi(clamped); + + expect(clamped.width).toBeGreaterThan(0); + expect(clamped.height).toBeGreaterThan(0); + expect(clamped.frames.length).toBeGreaterThanOrEqual(TEST_MIN_FRAMES_PROCESSED); + + const boot = await bootAllWorkers(clamped); + + // Metric-tick hook: the StubFitter monkey-patch in + // loadFitWorkerIntoHarness calls this every TEST_FIT_METRIC_STRIDE + // frames. We relay into W4 through the same `event` inbound the + // orchestrator would emit. Using a post-boot subscription (vs. + // module-scope) keeps the per-run counters isolated. + let metricSeq = 0; + ( + globalThis as { __calaPhase5ExitTestMetricTick?: () => void } + ).__calaPhase5ExitTestMetricTick = (): void => { + metricSeq += 1; + const ev: PipelineEvent = { + kind: 'metric', + t: metricSeq, + name: 'residual_norm', + value: metricSeq * 0.1, + }; + void boot.archive.deliver({ kind: 'event', event: ev }); + }; + + const startedAt = Date.now(); + + // Fire both run loops. The decode worker drains frames as they + // decode; the fit worker spins, waiting on the SAB channel. + await boot.decode.deliver({ kind: 'run' }); + await boot.fit.deliver({ kind: 'run' }); + await boot.archive.deliver({ kind: 'run' }); + + // Wait until decode has posted at least the minimum heartbeat + // count (frame-processed outbounds are emitted every + // TEST_HEARTBEAT_STRIDE frames). + const minHeartbeats = Math.max( + 1, + Math.floor(TEST_MIN_FRAMES_PROCESSED / TEST_HEARTBEAT_STRIDE), + ); + await pumpUntil( + () => + boot.decode.posted.filter((m) => m.kind === 'frame-processed').length >= minHeartbeats, + ); + + // Stop decode first (EOF path), then fit and archive. + await boot.decode.deliver({ kind: 'stop' }); + await pumpUntil(() => boot.decode.posted.some((m) => m.kind === 'done')); + await boot.fit.deliver({ kind: 'stop' }); + await pumpUntil(() => boot.fit.posted.some((m) => m.kind === 'done')); + await boot.archive.deliver({ kind: 'stop' }); + await pumpUntil(() => boot.archive.posted.some((m) => m.kind === 'done')); + + const elapsedMs = Date.now() - startedAt; + + // --- assertions --------------------------------------------------- + + // W1 saw at least TEST_MIN_FRAMES_PROCESSED frames from the real + // AVI and emitted the expected number of heartbeats. + const decodeHeartbeats = boot.decode.posted.filter( + (m): m is Extract => + m.kind === 'frame-processed', + ); + expect(decodeHeartbeats.length).toBeGreaterThanOrEqual(minHeartbeats); + + // W1 also sent at least one preview frame carrying real pixel + // counts — the single-frame viewer wiring built in task 24. + const previews = boot.decode.posted.filter( + (m): m is Extract => m.kind === 'frame-preview', + ); + expect(previews.length).toBeGreaterThanOrEqual(1); + expect(previews[0].width).toBe(clamped.width); + expect(previews[0].height).toBe(clamped.height); + expect(previews[0].pixels.length).toBe(clamped.width * clamped.height); + + // W2 processed real frames (fitter was invoked) and emitted its + // own heartbeats across the SAB channel boundary. + expect(fitterFrameCount).toBeGreaterThanOrEqual(TEST_MIN_FRAMES_PROCESSED); + const fitHeartbeats = boot.fit.posted.filter((m) => m.kind === 'frame-processed'); + expect(fitHeartbeats.length).toBeGreaterThanOrEqual(1); + + // W4 has at least TEST_MIN_METRIC_EVENTS metric events in its + // archive dump (proves the event bus + archive relay round-trip). + const archiveDumpReq: WorkerInbound = { kind: 'request-archive-dump', requestId: 1 }; + boot.archive.posted.length = 0; // clear before probing + await boot.archive.deliver(archiveDumpReq); + await pumpUntil(() => boot.archive.posted.some((m) => m.kind === 'archive-dump')); + const dump = boot.archive.posted.find( + (m): m is Extract => m.kind === 'archive-dump', + ); + expect(dump).toBeDefined(); + const metricEvents = dump!.events.filter((e) => e.kind === 'metric'); + expect(metricEvents.length).toBeGreaterThanOrEqual(TEST_MIN_METRIC_EVENTS); + + // No uncaught errors bubbled up from any worker. + const workerErrors = [ + ...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(workerErrors).toEqual([]); + + // Summary for the commit-body observability. + console.info( + `[phase5-exit] fixture=${path.basename(AVI_FIXTURE)} ` + + `dims=${clamped.width}x${clamped.height} ` + + `frames_run=${fitterFrameCount} ` + + `decode_heartbeats=${decodeHeartbeats.length} ` + + `fit_heartbeats=${fitHeartbeats.length} ` + + `preview_frames=${previews.length} ` + + `metric_events=${metricEvents.length} ` + + `elapsed_ms=${elapsedMs}`, + ); + }, + ); +}); diff --git a/apps/cala/package.json b/apps/cala/package.json index b202709..9cc2a45 100644 --- a/apps/cala/package.json +++ b/apps/cala/package.json @@ -22,6 +22,7 @@ "preview": "vite preview", "test": "vitest run", "test:watch": "vitest", + "test:e2e": "vitest run --config ./vitest.e2e.config.ts", "verify-sab": "node scripts/verify-sab.mjs" }, "dependencies": { diff --git a/apps/cala/tsconfig.json b/apps/cala/tsconfig.json index a2a3938..7349d9b 100644 --- a/apps/cala/tsconfig.json +++ b/apps/cala/tsconfig.json @@ -20,7 +20,7 @@ "@calab/ui/*": ["../../packages/ui/src/*"] } }, - "include": ["src"], + "include": ["src", "e2e"], "references": [ { "path": "../../packages/cala-core" }, { "path": "../../packages/cala-runtime" }, diff --git a/apps/cala/vitest.config.ts b/apps/cala/vitest.config.ts index 82cd5e2..9a365f0 100644 --- a/apps/cala/vitest.config.ts +++ b/apps/cala/vitest.config.ts @@ -5,6 +5,11 @@ export default defineConfig({ plugins: [solidPlugin()], test: { passWithNoTests: false, + // Keep E2E opt-in: the Phase 5 exit spec lives under `e2e/` and + // reads real AVI bytes from `.test_data/`, which is not in CI's + // checkout. Run explicitly via `npm run test:e2e -w apps/cala` + // (or `npm run test:e2e:cala` from the repo root). + exclude: ['**/node_modules/**', 'e2e/**'], environmentMatchGlobs: [['src/lib/__tests__/**', 'node']], }, }); diff --git a/apps/cala/vitest.e2e.config.ts b/apps/cala/vitest.e2e.config.ts new file mode 100644 index 0000000..589b4f9 --- /dev/null +++ b/apps/cala/vitest.e2e.config.ts @@ -0,0 +1,28 @@ +/** + * Separate vitest config for the Phase 5 exit E2E. Kept opt-in (not + * picked up by the default `npm test` / `vitest run`) because the spec + * reads a real AVI from `.test_data/` which is a local-only, gitignored + * directory. CI and a clean checkout wouldn't have it, and the unit + * suite should not require it. + * + * Run explicitly via: + * npm run test:e2e -w apps/cala + * npm run test:e2e:cala # from repo root + */ + +import { defineConfig } from 'vitest/config'; +import solidPlugin from 'vite-plugin-solid'; + +export default defineConfig({ + plugins: [solidPlugin()], + test: { + include: ['e2e/**/*.e2e.test.ts'], + environment: 'node', + // E2E reads a real AVI byte stream and pumps it through all four + // workers — default 5s per-test timeout is too tight once the + // fixture grows. The spec itself also sets a per-test timeout. + testTimeout: 60_000, + hookTimeout: 30_000, + passWithNoTests: false, + }, +}); diff --git a/package.json b/package.json index c89b11f..dda3869 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "build:wasm:cala": "cd crates/cala-core && wasm-pack build --target web --release", "test": "npm run test --workspaces --if-present", "test:watch": "npm run test:watch -w apps/catune", + "test:e2e:cala": "npm run test:e2e -w apps/cala", "lint": "eslint apps/ packages/ scripts/", "lint:fix": "eslint --fix apps/ packages/ scripts/", "typecheck": "tsc -b apps/catune apps/carank apps/admin apps/cadecon apps/cala apps/_template", From 1326ff19e0cb2cc383040238c410b30797b82a41 Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 18 Apr 2026 16:12:49 -0700 Subject: [PATCH 15/17] fix(cala): end-to-end live-browser wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven fixes surfaced during the first real in-browser run: - App: gate viewer on runState (not file presence) so the ImportOverlay + Start button stay visible until a run begins. - ImportOverlay: surface state.errorMsg alongside local errors so failures in the starting→error transition don't get lost on remount. - run-control: send a default RecordingMetadata JSON (pixel_size_um = 2.0 µm, standard UCLA miniscope) — Rust side requires the field. - orchestrator: strip non-clonable source.frameSourceFactory from the worker init config; postMessage structured-clone would crash. - orchestrator: forward fit/extend 'event' outbound messages to the archive worker — archive never heard them otherwise. - orchestrator: mirror snapshot-ack to extend (not just fit) so the extend stub's snapshot-latch actually fires. - extend: emit a metric on any pending ack, not only on strictly advancing epoch — live runs with no mutations keep epoch at 0. All 50 apps/cala + 79 cala-runtime tests still green. typecheck + lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cala/src/App.tsx | 9 +++++++- .../src/components/layout/ImportOverlay.tsx | 12 +++++----- apps/cala/src/lib/run-control.ts | 5 +++++ apps/cala/src/workers/extend.worker.ts | 12 +++++----- packages/cala-runtime/src/orchestrator.ts | 22 ++++++++++++++----- 5 files changed, 43 insertions(+), 17 deletions(-) diff --git a/apps/cala/src/App.tsx b/apps/cala/src/App.tsx index 1ea06c4..ed3d63e 100644 --- a/apps/cala/src/App.tsx +++ b/apps/cala/src/App.tsx @@ -29,7 +29,14 @@ const App: Component = () => { return ( }> - }> + } + > diff --git a/apps/cala/src/components/layout/ImportOverlay.tsx b/apps/cala/src/components/layout/ImportOverlay.tsx index 350214f..371bd35 100644 --- a/apps/cala/src/components/layout/ImportOverlay.tsx +++ b/apps/cala/src/components/layout/ImportOverlay.tsx @@ -139,11 +139,13 @@ export function ImportOverlay(): JSX.Element { )} - -
- ! - {localError()} -
+ + {(msg) => ( +
+ ! + {msg()} +
+ )}
diff --git a/apps/cala/src/lib/run-control.ts b/apps/cala/src/lib/run-control.ts index 728dc00..2371e7d 100644 --- a/apps/cala/src/lib/run-control.ts +++ b/apps/cala/src/lib/run-control.ts @@ -42,6 +42,10 @@ const BYTES_PER_F32_PIXEL = 4; // updates a few times per second even on a fast pipeline, without the // main thread paying postMessage cost on every decode. const DEFAULT_FRAME_PREVIEW_STRIDE = 2; +// Standard UCLA miniscope V3/V4 pixel size. Override by exposing a +// `pixelSizeUm` setting in the UI when the app gains recording-specific +// metadata (Phase 6+). +const DEFAULT_PIXEL_SIZE_UM = 2.0; export type WorkerFactories = Record; @@ -101,6 +105,7 @@ function buildConfig(meta: FrameSourceMeta, factories: WorkerFactories): Runtime workerConfigs: { decodePreprocess: { framePreviewStride: DEFAULT_FRAME_PREVIEW_STRIDE, + metadataJson: JSON.stringify({ pixel_size_um: DEFAULT_PIXEL_SIZE_UM }), }, fit: { height: meta.height, diff --git a/apps/cala/src/workers/extend.worker.ts b/apps/cala/src/workers/extend.worker.ts index 2f73d70..3cf5d9e 100644 --- a/apps/cala/src/workers/extend.worker.ts +++ b/apps/cala/src/workers/extend.worker.ts @@ -117,11 +117,13 @@ async function heartbeatLoop(h: RuntimeHandles): Promise { h.tickCount += 1; // Latch consumption: if a snapshot ack arrived since the previous - // heartbeat, advance epoch and publish the corresponding metric - // event. Otherwise just emit the frame-processed beat. + // heartbeat, publish the corresponding metric event. We emit on + // any pending ack (not just monotone-advance) so unchanging-epoch + // live runs still produce a visible heartbeat signal for the + // archive. Track lastObservedEpoch for the frame-processed beat. const newlyObserved = h.pendingAckEpoch; - if (newlyObserved !== null && newlyObserved > h.lastObservedEpoch) { - h.lastObservedEpoch = newlyObserved; + if (newlyObserved !== null) { + if (newlyObserved > h.lastObservedEpoch) h.lastObservedEpoch = newlyObserved; h.pendingAckEpoch = null; const metric: PipelineEvent = { kind: 'metric', @@ -191,8 +193,6 @@ workerSelf.onmessage = (ev: MessageEvent): void => { return; case 'snapshot-ack': if (handles) { - // Latch — real extend would gate segmentation on this; stub - // just stamps the epoch onto subsequent heartbeats. handles.pendingAckEpoch = msg.epoch; } return; diff --git a/packages/cala-runtime/src/orchestrator.ts b/packages/cala-runtime/src/orchestrator.ts index 0b908e6..231b9ff 100644 --- a/packages/cala-runtime/src/orchestrator.ts +++ b/packages/cala-runtime/src/orchestrator.ts @@ -344,7 +344,10 @@ class Runtime implements RuntimeController { private buildWorkerConfig(role: WorkerRole, source: RuntimeSource): unknown { const override = this.cfg.workerConfigs?.[role]; if (role === 'decodePreprocess') { - return { source, ...(override as object | undefined) }; + // Structured-clone only the clonable fields of the source — + // frameSourceFactory is an in-process hook, not a transferable. + const clonable = { kind: source.kind, file: source.file }; + return { source: clonable, ...(override as object | undefined) }; } return override ?? null; } @@ -367,18 +370,27 @@ class Runtime implements RuntimeController { case 'snapshot-request': { const fit = this.workers.get('fit'); if (!fit) return; - fit.postMessage({ - kind: 'snapshot-ack', + const ack = { + kind: 'snapshot-ack' as const, requestId: msg.requestId, epoch: this.currentEpoch, numComponents: 0, pixels: 0, - }); + }; + fit.postMessage(ack); + // Extend is the snapshot consumer (design §7.2) — mirror the + // ack so its epoch latch advances and its heartbeat can emit + // the matching metric event. + const extend = this.workers.get('extend'); + extend?.postMessage(ack); return; } - case 'event': + case 'event': { this.eventBus.publish(msg.event); + const archive = this.workers.get('archive'); + archive?.postMessage({ kind: 'event', event: msg.event }); return; + } case 'error': { const err = new RuntimeWorkerError(msg.role, msg.message); this.lastError = err.message; From d7ff83702d4461c4e872da073fbe0f350940435a Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 18 Apr 2026 21:36:16 -0700 Subject: [PATCH 16/17] fix(ci): gate native-cli clippy + prettier-format task 23 tests - crates/cala-core/src/io/mod.rs: cfg-gate decode_grayscale_f32 re-export to match its only caller (wasm bindings). Keeps --features native-cli -D warnings clean. - crates/cala-core/tests/bindings_config_json.rs: #![cfg(feature = "serde")] so the test file compiles out under native-cli builds that don't enable the config_json module. - apps/cala/src/workers/__tests__/{archive,extend}.worker.test.ts: prettier --write (flagged by format:check in CI). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/workers/__tests__/archive.worker.test.ts | 14 +++----------- .../src/workers/__tests__/extend.worker.test.ts | 3 +-- crates/cala-core/src/io/mod.rs | 1 + crates/cala-core/tests/bindings_config_json.rs | 2 ++ 4 files changed, 7 insertions(+), 13 deletions(-) diff --git a/apps/cala/src/workers/__tests__/archive.worker.test.ts b/apps/cala/src/workers/__tests__/archive.worker.test.ts index 438e547..63449cb 100644 --- a/apps/cala/src/workers/__tests__/archive.worker.test.ts +++ b/apps/cala/src/workers/__tests__/archive.worker.test.ts @@ -1,9 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { - PipelineEvent, - WorkerInbound, - WorkerOutbound, -} from '@calab/cala-runtime'; +import type { PipelineEvent, WorkerInbound, WorkerOutbound } from '@calab/cala-runtime'; import { createWorkerHarness, type WorkerHarness } from './worker-harness.ts'; // Small capacities keep drop-oldest behaviour observable in-test without @@ -166,13 +162,9 @@ describe('archive worker', () => { await harness.deliver({ kind: 'request-archive-dump', requestId: 101 }); await harness.deliver({ kind: 'request-archive-dump', requestId: 202 }); - await runUntil( - harness, - (p) => p.filter((m) => m.kind === 'archive-dump').length >= 2, - ); + await runUntil(harness, (p) => p.filter((m) => m.kind === 'archive-dump').length >= 2); const dumps = harness.posted.filter( - (m): m is Extract => - m.kind === 'archive-dump', + (m): m is Extract => m.kind === 'archive-dump', ); expect(dumps.map((d) => d.requestId)).toEqual([101, 202]); for (const d of dumps) { diff --git a/apps/cala/src/workers/__tests__/extend.worker.test.ts b/apps/cala/src/workers/__tests__/extend.worker.test.ts index f90ab1e..f83e86a 100644 --- a/apps/cala/src/workers/__tests__/extend.worker.test.ts +++ b/apps/cala/src/workers/__tests__/extend.worker.test.ts @@ -92,8 +92,7 @@ describe('extend worker (stub)', () => { await runUntil( harness, - (p) => - p.some((m) => m.kind === 'frame-processed') && p.some((m) => m.kind === 'event'), + (p) => p.some((m) => m.kind === 'frame-processed') && p.some((m) => m.kind === 'event'), ); const heartbeat = harness.posted.find((m) => m.kind === 'frame-processed'); diff --git a/crates/cala-core/src/io/mod.rs b/crates/cala-core/src/io/mod.rs index 83a75bf..990b09a 100644 --- a/crates/cala-core/src/io/mod.rs +++ b/crates/cala-core/src/io/mod.rs @@ -8,6 +8,7 @@ mod avi_uncompressed; mod avi_writer; +#[cfg(feature = "jsbindings")] pub(crate) use avi_uncompressed::decode_grayscale_f32; pub use avi_uncompressed::{AviError, AviUncompressedReader, OwnedAviReader}; pub use avi_writer::write_uncompressed_avi_8bit; diff --git a/crates/cala-core/tests/bindings_config_json.rs b/crates/cala-core/tests/bindings_config_json.rs index 6379fa3..a2ba6b7 100644 --- a/crates/cala-core/tests/bindings_config_json.rs +++ b/crates/cala-core/tests/bindings_config_json.rs @@ -7,6 +7,8 @@ //! their JSON strings through the `bindings::config_json` helpers — //! fixing any defect here catches regressions at both targets at once. +#![cfg(feature = "serde")] + use calab_cala_core::bindings::config_json::{ parse_extend_config, parse_fit_config, parse_preprocess_config, parse_recording_metadata, }; From e3268e4e3cf4a3b1adde1482136ace8f9f746d21 Mon Sep 17 00:00:00 2001 From: daharoni Date: Sat, 18 Apr 2026 21:39:55 -0700 Subject: [PATCH 17/17] fix(ci): commit cala-core wasm-pack artifacts for typecheck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the existing `crates/solver/pkg/` convention — built `.d.ts`, `.js`, `.wasm`, `.wasm.d.ts`, and `package.json` land in the repo so typecheck resolves the `@calab/cala-core` wasm-adapter import without needing `wasm-pack` in the CI `check` job. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/cala-core/pkg/README.md | 23 + crates/cala-core/pkg/calab_cala_core.d.ts | 239 +++++ crates/cala-core/pkg/calab_cala_core.js | 862 ++++++++++++++++++ crates/cala-core/pkg/calab_cala_core_bg.wasm | Bin 0 -> 368216 bytes .../pkg/calab_cala_core_bg.wasm.d.ts | 44 + crates/cala-core/pkg/package.json | 16 + 6 files changed, 1184 insertions(+) create mode 100644 crates/cala-core/pkg/README.md create mode 100644 crates/cala-core/pkg/calab_cala_core.d.ts create mode 100644 crates/cala-core/pkg/calab_cala_core.js create mode 100644 crates/cala-core/pkg/calab_cala_core_bg.wasm create mode 100644 crates/cala-core/pkg/calab_cala_core_bg.wasm.d.ts create mode 100644 crates/cala-core/pkg/package.json diff --git a/crates/cala-core/pkg/README.md b/crates/cala-core/pkg/README.md new file mode 100644 index 0000000..521ad06 --- /dev/null +++ b/crates/cala-core/pkg/README.md @@ -0,0 +1,23 @@ +# calab-cala-core + +Numerical core for **CaLa** — CaLab's streaming calcium imaging demixing pipeline. See `.planning/CALA_DESIGN.md` (repo root) for the full design. + +This crate is the single source of truth for all CaLa numerics. It compiles to: + +- **WASM** (`--features jsbindings`) for the browser app at `apps/cala/`. +- **Python extension** (`--features pybindings`) via PyO3, consumed by `python/calab/cala/`. + +## Status + +Phase 1 (preprocess + assets scaffold) is in progress. The crate is intentionally empty until each module lands with tests-first. + +## Build + +``` +# Browser (WASM) feature surface — matches CI +cargo check --no-default-features --features jsbindings +cargo test --no-default-features --features jsbindings + +# Python (PyO3) feature surface — requires python dev headers +cargo check --no-default-features --features pybindings +``` diff --git a/crates/cala-core/pkg/calab_cala_core.d.ts b/crates/cala-core/pkg/calab_cala_core.d.ts new file mode 100644 index 0000000..3253964 --- /dev/null +++ b/crates/cala-core/pkg/calab_cala_core.d.ts @@ -0,0 +1,239 @@ +/* tslint:disable */ +/* eslint-disable */ + +/** + * Owning wrapper over `OwnedAviReader`. Parses the RIFF container + * once in `new`, caches the frame index, then decodes individual + * frames directly from the held buffer without re-walking the + * container. Safe to construct from a `File.slice()` `ArrayBuffer` + * handed across the JS ↔ WASM boundary. + */ +export class AviReader { + free(): void; + [Symbol.dispose](): void; + bitDepth(): number; + channels(): number; + fps(): number; + frameCount(): number; + height(): number; + /** + * Parse an AVI. `bytes` is copied into WASM memory once; frame + * reads are zero-copy slices into that owned buffer. + */ + constructor(bytes: Uint8Array); + /** + * Decode one frame into a new `Float32Array`. + * + * `method` picks the 24-bit → grayscale reduction: + * `"Green"` (default on miniscope raw) or `"Luminance"` (Rec.601). + * Ignored for 8-bit streams. + */ + readFrameGrayscaleF32(n: number, method: string): Float32Array; + width(): number; +} + +/** + * Owning wrapper over `FitPipeline` — the per-frame OMF step. Starts + * with an empty `Footprints` (`num_components() == 0`); the fit + * worker grows the model by draining the `MutationQueueHandle`. + */ +export class Fitter { + free(): void; + [Symbol.dispose](): void; + /** + * 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 + * metrics. + */ + drainApply(queue: MutationQueueHandle): Uint32Array; + /** + * Current asset epoch. Advances once per successful mutation + * apply; not touched by per-frame `step` calls. + */ + epoch(): bigint; + height(): number; + /** + * Latest trace vector `c_t` (length = `num_components()`), or an + * empty `Float32Array` before the first `step()` has landed. + */ + lastTrace(): Float32Array; + /** + * Construct a fitter for a fixed-shape frame stream. + * + * `cfg_json` parses against `FitConfig`'s serde shape. `"{}"` + * means every `DEFAULT_*` value applies. + */ + constructor(height: number, width: number, cfg_json: string); + /** + * Number of live components in `Ã`. + */ + numComponents(): number; + /** + * Run one OMF frame. Returns the residual `R_t` as a new + * `Float32Array` so the extend worker can read it. + */ + step(y: Float32Array): Float32Array; + /** + * 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. + */ + takeSnapshot(): SnapshotHandle; + width(): number; +} + +/** + * Opaque handle to a `MutationQueue`. Extend pushes; fit drains via + * `Fitter::drain_apply`. Construction reads `mutation_queue_capacity` + * from `ExtendConfig`'s JSON (default 32 per design §7.3). + */ +export class MutationQueueHandle { + free(): void; + [Symbol.dispose](): void; + capacity(): number; + drops(): bigint; + isEmpty(): boolean; + isFull(): boolean; + len(): number; + /** + * Construct a queue whose capacity comes from `extend_cfg_json`'s + * `mutation_queue_capacity` field. JS callers pass the same JSON + * used to build the `ExtendConfig` — single source of truth. + */ + constructor(extend_cfg_json: string); + /** + * Enqueue a deprecate mutation. Phase 5 exposes deprecate as the + * minimal push surface — register / merge pushes light up in + * Phase 7 when extend actually generates them. `reason` takes + * the serde-variant string (`"FootprintCollapsed"`, etc). + */ + pushDeprecate(snapshot_epoch: bigint, id: number, reason: string): void; +} + +/** + * Owning wrapper over `PreprocessPipeline` (hot-pixel → [opt butter] + * → [opt band] → motion → [opt denoise]). All knobs come from the + * `cfg_json` string — see `PreprocessConfig`'s `serde` shape. + */ +export class Preprocessor { + free(): void; + [Symbol.dispose](): void; + /** + * Construct a preprocessor. + * + * - `height`, `width`: frame dimensions (must match all frames + * pushed through `process_frame_*`). + * - `metadata_json`: JSON matching `RecordingMetadata`'s serde + * shape, e.g. `{"pixel_size_um":2.0}`. + * - `cfg_json`: JSON matching `PreprocessConfig`'s serde shape; + * `"{}"` applies every `DEFAULT_*` value. + */ + constructor(height: number, width: number, metadata_json: string, cfg_json: string); + /** + * Run one preprocess step on an `f32` grayscale frame + * (`height × width`, row-major). Returns a new `Float32Array` + * containing the cleaned frame. + */ + processFrameF32(input: Float32Array): Float32Array; + /** + * Convenience: decode raw AVI bytes to grayscale and preprocess + * in one call. Avoids a round-trip across the JS boundary for + * the intermediate f32 buffer. + */ + processFrameU8(input: Uint8Array, channels: number, method: string): Float32Array; + /** + * Reset motion anchors. The next `process_frame_*` call behaves + * as a first-frame (no global anchor contribution yet). + */ + reset(): void; +} + +/** + * Opaque handle to a `Snapshot`. Only `epoch` is surfaced in Phase 5; + * full extend-side access lands with the real extend worker. + */ +export class SnapshotHandle { + private constructor(); + free(): void; + [Symbol.dispose](): void; + epoch(): bigint; + numComponents(): number; + pixels(): number; +} + +/** + * Install the console panic hook. Call once, early, from each + * worker so `panic!` surfaces in the browser console instead of + * appearing as a WASM trap. + */ +export function init_panic_hook(): void; + +export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; + +export interface InitOutput { + readonly memory: WebAssembly.Memory; + readonly __wbg_avireader_free: (a: number, b: number) => void; + readonly __wbg_fitter_free: (a: number, b: number) => void; + readonly __wbg_mutationqueuehandle_free: (a: number, b: number) => void; + readonly __wbg_preprocessor_free: (a: number, b: number) => void; + readonly __wbg_snapshothandle_free: (a: number, b: number) => void; + readonly avireader_bitDepth: (a: number) => number; + readonly avireader_channels: (a: number) => number; + readonly avireader_fps: (a: number) => number; + readonly avireader_frameCount: (a: number) => number; + readonly avireader_height: (a: number) => number; + readonly avireader_new: (a: number, b: number, c: number) => void; + readonly avireader_readFrameGrayscaleF32: (a: number, b: number, c: number, d: number, e: number) => void; + readonly avireader_width: (a: number) => number; + readonly fitter_drainApply: (a: number, b: number, c: number) => void; + readonly fitter_epoch: (a: number) => bigint; + readonly fitter_height: (a: number) => number; + readonly fitter_lastTrace: (a: number, b: number) => void; + readonly fitter_new: (a: number, b: number, c: number, d: number, e: number) => void; + readonly fitter_numComponents: (a: number) => number; + readonly fitter_step: (a: number, b: number, c: number, d: number) => void; + readonly fitter_takeSnapshot: (a: number) => number; + readonly fitter_width: (a: number) => number; + readonly mutationqueuehandle_capacity: (a: number) => number; + readonly mutationqueuehandle_drops: (a: number) => bigint; + readonly mutationqueuehandle_isEmpty: (a: number) => number; + readonly mutationqueuehandle_isFull: (a: number) => number; + readonly mutationqueuehandle_len: (a: number) => number; + readonly mutationqueuehandle_new: (a: number, b: number, c: number) => void; + 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_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; + readonly snapshothandle_numComponents: (a: number) => number; + readonly snapshothandle_pixels: (a: number) => number; + readonly init_panic_hook: () => void; + 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_add_to_stack_pointer: (a: number) => number; +} + +export type SyncInitInput = BufferSource | WebAssembly.Module; + +/** + * Instantiates the given `module`, which can either be bytes or + * a precompiled `WebAssembly.Module`. + * + * @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated. + * + * @returns {InitOutput} + */ +export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput; + +/** + * If `module_or_path` is {RequestInfo} or {URL}, makes a request and + * for everything else, calls `WebAssembly.instantiate` directly. + * + * @param {{ module_or_path: InitInput | Promise }} module_or_path - Passing `InitInput` directly is deprecated. + * + * @returns {Promise} + */ +export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise } | InitInput | Promise): Promise; diff --git a/crates/cala-core/pkg/calab_cala_core.js b/crates/cala-core/pkg/calab_cala_core.js new file mode 100644 index 0000000..39b5323 --- /dev/null +++ b/crates/cala-core/pkg/calab_cala_core.js @@ -0,0 +1,862 @@ +/* @ts-self-types="./calab_cala_core.d.ts" */ + +/** + * Owning wrapper over `OwnedAviReader`. Parses the RIFF container + * once in `new`, caches the frame index, then decodes individual + * frames directly from the held buffer without re-walking the + * container. Safe to construct from a `File.slice()` `ArrayBuffer` + * handed across the JS ↔ WASM boundary. + */ +export class AviReader { + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + AviReaderFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_avireader_free(ptr, 0); + } + /** + * @returns {number} + */ + bitDepth() { + const ret = wasm.avireader_bitDepth(this.__wbg_ptr); + return ret; + } + /** + * @returns {number} + */ + channels() { + const ret = wasm.avireader_channels(this.__wbg_ptr); + return ret; + } + /** + * @returns {number} + */ + fps() { + const ret = wasm.avireader_fps(this.__wbg_ptr); + return ret; + } + /** + * @returns {number} + */ + frameCount() { + const ret = wasm.avireader_frameCount(this.__wbg_ptr); + return ret >>> 0; + } + /** + * @returns {number} + */ + height() { + const ret = wasm.avireader_height(this.__wbg_ptr); + return ret >>> 0; + } + /** + * Parse an AVI. `bytes` is copied into WASM memory once; frame + * reads are zero-copy slices into that owned buffer. + * @param {Uint8Array} bytes + */ + constructor(bytes) { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); + const ptr0 = passArray8ToWasm0(bytes, wasm.__wbindgen_export2); + const len0 = WASM_VECTOR_LEN; + wasm.avireader_new(retptr, 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); + if (r2) { + throw takeObject(r1); + } + this.__wbg_ptr = r0 >>> 0; + AviReaderFinalization.register(this, this.__wbg_ptr, this); + return this; + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + } + } + /** + * Decode one frame into a new `Float32Array`. + * + * `method` picks the 24-bit → grayscale reduction: + * `"Green"` (default on miniscope raw) or `"Luminance"` (Rec.601). + * Ignored for 8-bit streams. + * @param {number} n + * @param {string} method + * @returns {Float32Array} + */ + readFrameGrayscaleF32(n, method) { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); + const ptr0 = passStringToWasm0(method, wasm.__wbindgen_export2, wasm.__wbindgen_export3); + const len0 = WASM_VECTOR_LEN; + wasm.avireader_readFrameGrayscaleF32(retptr, this.__wbg_ptr, n, 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_export(r0, r1 * 4, 4); + return v2; + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + } + } + /** + * @returns {number} + */ + width() { + const ret = wasm.avireader_width(this.__wbg_ptr); + return ret >>> 0; + } +} +if (Symbol.dispose) AviReader.prototype[Symbol.dispose] = AviReader.prototype.free; + +/** + * Owning wrapper over `FitPipeline` — the per-frame OMF step. Starts + * with an empty `Footprints` (`num_components() == 0`); the fit + * worker grows the model by draining the `MutationQueueHandle`. + */ +export class Fitter { + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + FitterFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_fitter_free(ptr, 0); + } + /** + * 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 + * metrics. + * @param {MutationQueueHandle} queue + * @returns {Uint32Array} + */ + drainApply(queue) { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); + _assertClass(queue, MutationQueueHandle); + wasm.fitter_drainApply(retptr, this.__wbg_ptr, queue.__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_export(r0, r1 * 4, 4); + return v1; + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + } + } + /** + * Current asset epoch. Advances once per successful mutation + * apply; not touched by per-frame `step` calls. + * @returns {bigint} + */ + epoch() { + const ret = wasm.fitter_epoch(this.__wbg_ptr); + return BigInt.asUintN(64, ret); + } + /** + * @returns {number} + */ + height() { + const ret = wasm.fitter_height(this.__wbg_ptr); + return ret >>> 0; + } + /** + * Latest trace vector `c_t` (length = `num_components()`), or an + * empty `Float32Array` before the first `step()` has landed. + * @returns {Float32Array} + */ + lastTrace() { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); + wasm.fitter_lastTrace(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_export(r0, r1 * 4, 4); + return v1; + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + } + } + /** + * Construct a fitter for a fixed-shape frame stream. + * + * `cfg_json` parses against `FitConfig`'s serde shape. `"{}"` + * means every `DEFAULT_*` value applies. + * @param {number} height + * @param {number} width + * @param {string} cfg_json + */ + 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 len0 = WASM_VECTOR_LEN; + wasm.fitter_new(retptr, height, width, 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); + if (r2) { + throw takeObject(r1); + } + this.__wbg_ptr = r0 >>> 0; + FitterFinalization.register(this, this.__wbg_ptr, this); + return this; + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + } + } + /** + * Number of live components in `Ã`. + * @returns {number} + */ + numComponents() { + const ret = wasm.fitter_numComponents(this.__wbg_ptr); + return ret >>> 0; + } + /** + * Run one OMF frame. Returns the residual `R_t` as a new + * `Float32Array` so the extend worker can read it. + * @param {Float32Array} y + * @returns {Float32Array} + */ + step(y) { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); + const ptr0 = passArrayF32ToWasm0(y, wasm.__wbindgen_export2); + const len0 = WASM_VECTOR_LEN; + wasm.fitter_step(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_export(r0, r1 * 4, 4); + return v2; + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + } + } + /** + * 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. + * @returns {SnapshotHandle} + */ + takeSnapshot() { + const ret = wasm.fitter_takeSnapshot(this.__wbg_ptr); + return SnapshotHandle.__wrap(ret); + } + /** + * @returns {number} + */ + width() { + const ret = wasm.fitter_width(this.__wbg_ptr); + return ret >>> 0; + } +} +if (Symbol.dispose) Fitter.prototype[Symbol.dispose] = Fitter.prototype.free; + +/** + * Opaque handle to a `MutationQueue`. Extend pushes; fit drains via + * `Fitter::drain_apply`. Construction reads `mutation_queue_capacity` + * from `ExtendConfig`'s JSON (default 32 per design §7.3). + */ +export class MutationQueueHandle { + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + MutationQueueHandleFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_mutationqueuehandle_free(ptr, 0); + } + /** + * @returns {number} + */ + capacity() { + const ret = wasm.mutationqueuehandle_capacity(this.__wbg_ptr); + return ret >>> 0; + } + /** + * @returns {bigint} + */ + drops() { + const ret = wasm.mutationqueuehandle_drops(this.__wbg_ptr); + return BigInt.asUintN(64, ret); + } + /** + * @returns {boolean} + */ + isEmpty() { + const ret = wasm.mutationqueuehandle_isEmpty(this.__wbg_ptr); + return ret !== 0; + } + /** + * @returns {boolean} + */ + isFull() { + const ret = wasm.mutationqueuehandle_isFull(this.__wbg_ptr); + return ret !== 0; + } + /** + * @returns {number} + */ + len() { + const ret = wasm.mutationqueuehandle_len(this.__wbg_ptr); + return ret >>> 0; + } + /** + * Construct a queue whose capacity comes from `extend_cfg_json`'s + * `mutation_queue_capacity` field. JS callers pass the same JSON + * used to build the `ExtendConfig` — single source of truth. + * @param {string} extend_cfg_json + */ + 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 len0 = WASM_VECTOR_LEN; + wasm.mutationqueuehandle_new(retptr, 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); + if (r2) { + throw takeObject(r1); + } + this.__wbg_ptr = r0 >>> 0; + MutationQueueHandleFinalization.register(this, this.__wbg_ptr, this); + return this; + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + } + } + /** + * Enqueue a deprecate mutation. Phase 5 exposes deprecate as the + * minimal push surface — register / merge pushes light up in + * Phase 7 when extend actually generates them. `reason` takes + * the serde-variant string (`"FootprintCollapsed"`, etc). + * @param {bigint} snapshot_epoch + * @param {number} id + * @param {string} reason + */ + 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 len0 = WASM_VECTOR_LEN; + wasm.mutationqueuehandle_pushDeprecate(retptr, this.__wbg_ptr, snapshot_epoch, id, ptr0, len0); + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); + if (r1) { + throw takeObject(r0); + } + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + } + } +} +if (Symbol.dispose) MutationQueueHandle.prototype[Symbol.dispose] = MutationQueueHandle.prototype.free; + +/** + * Owning wrapper over `PreprocessPipeline` (hot-pixel → [opt butter] + * → [opt band] → motion → [opt denoise]). All knobs come from the + * `cfg_json` string — see `PreprocessConfig`'s `serde` shape. + */ +export class Preprocessor { + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + PreprocessorFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_preprocessor_free(ptr, 0); + } + /** + * Construct a preprocessor. + * + * - `height`, `width`: frame dimensions (must match all frames + * pushed through `process_frame_*`). + * - `metadata_json`: JSON matching `RecordingMetadata`'s serde + * shape, e.g. `{"pixel_size_um":2.0}`. + * - `cfg_json`: JSON matching `PreprocessConfig`'s serde shape; + * `"{}"` applies every `DEFAULT_*` value. + * @param {number} height + * @param {number} width + * @param {string} metadata_json + * @param {string} cfg_json + */ + 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 len0 = WASM_VECTOR_LEN; + const ptr1 = passStringToWasm0(cfg_json, wasm.__wbindgen_export2, wasm.__wbindgen_export3); + const len1 = WASM_VECTOR_LEN; + wasm.preprocessor_new(retptr, height, width, ptr0, len0, ptr1, len1); + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); + var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true); + if (r2) { + throw takeObject(r1); + } + this.__wbg_ptr = r0 >>> 0; + PreprocessorFinalization.register(this, this.__wbg_ptr, this); + return this; + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + } + } + /** + * Run one preprocess step on an `f32` grayscale frame + * (`height × width`, row-major). Returns a new `Float32Array` + * containing the cleaned frame. + * @param {Float32Array} input + * @returns {Float32Array} + */ + processFrameF32(input) { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); + const ptr0 = passArrayF32ToWasm0(input, wasm.__wbindgen_export2); + const len0 = WASM_VECTOR_LEN; + wasm.preprocessor_processFrameF32(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_export(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 + * the intermediate f32 buffer. + * @param {Uint8Array} input + * @param {number} channels + * @param {string} method + * @returns {Float32Array} + */ + processFrameU8(input, channels, method) { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); + const ptr0 = passArray8ToWasm0(input, wasm.__wbindgen_export2); + const len0 = WASM_VECTOR_LEN; + const ptr1 = passStringToWasm0(method, wasm.__wbindgen_export2, wasm.__wbindgen_export3); + 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); + 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 v3 = getArrayF32FromWasm0(r0, r1).slice(); + wasm.__wbindgen_export(r0, r1 * 4, 4); + return v3; + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + } + } + /** + * Reset motion anchors. The next `process_frame_*` call behaves + * as a first-frame (no global anchor contribution yet). + */ + reset() { + wasm.preprocessor_reset(this.__wbg_ptr); + } +} +if (Symbol.dispose) Preprocessor.prototype[Symbol.dispose] = Preprocessor.prototype.free; + +/** + * Opaque handle to a `Snapshot`. Only `epoch` is surfaced in Phase 5; + * full extend-side access lands with the real extend worker. + */ +export class SnapshotHandle { + static __wrap(ptr) { + ptr = ptr >>> 0; + const obj = Object.create(SnapshotHandle.prototype); + obj.__wbg_ptr = ptr; + SnapshotHandleFinalization.register(obj, obj.__wbg_ptr, obj); + return obj; + } + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + SnapshotHandleFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_snapshothandle_free(ptr, 0); + } + /** + * @returns {bigint} + */ + epoch() { + const ret = wasm.snapshothandle_epoch(this.__wbg_ptr); + return BigInt.asUintN(64, ret); + } + /** + * @returns {number} + */ + numComponents() { + const ret = wasm.snapshothandle_numComponents(this.__wbg_ptr); + return ret >>> 0; + } + /** + * @returns {number} + */ + pixels() { + const ret = wasm.snapshothandle_pixels(this.__wbg_ptr); + return ret >>> 0; + } +} +if (Symbol.dispose) SnapshotHandle.prototype[Symbol.dispose] = SnapshotHandle.prototype.free; + +/** + * Install the console panic hook. Call once, early, from each + * worker so `panic!` surfaces in the browser console instead of + * appearing as a WASM trap. + */ +export function init_panic_hook() { + wasm.init_panic_hook(); +} +function __wbg_get_imports() { + const import0 = { + __proto__: null, + __wbg___wbindgen_throw_6b64449b9b9ed33c: function(arg0, arg1) { + throw new Error(getStringFromWasm0(arg0, arg1)); + }, + __wbg_error_a6fa202b58aa1cd3: function(arg0, arg1) { + let deferred0_0; + let deferred0_1; + try { + deferred0_0 = arg0; + deferred0_1 = arg1; + console.error(getStringFromWasm0(arg0, arg1)); + } finally { + wasm.__wbindgen_export(deferred0_0, deferred0_1, 1); + } + }, + __wbg_new_227d7c05414eb861: function() { + const ret = new Error(); + return addHeapObject(ret); + }, + __wbg_stack_3b0d974bbf31e44f: function(arg0, arg1) { + const ret = getObject(arg1).stack; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_export2, wasm.__wbindgen_export3); + 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) { + // Cast intrinsic for `Ref(String) -> Externref`. + const ret = getStringFromWasm0(arg0, arg1); + return addHeapObject(ret); + }, + __wbindgen_object_drop_ref: function(arg0) { + takeObject(arg0); + }, + }; + return { + __proto__: null, + "./calab_cala_core_bg.js": import0, + }; +} + +const AviReaderFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_avireader_free(ptr >>> 0, 1)); +const FitterFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_fitter_free(ptr >>> 0, 1)); +const MutationQueueHandleFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_mutationqueuehandle_free(ptr >>> 0, 1)); +const PreprocessorFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_preprocessor_free(ptr >>> 0, 1)); +const SnapshotHandleFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_snapshothandle_free(ptr >>> 0, 1)); + +function addHeapObject(obj) { + if (heap_next === heap.length) heap.push(heap.length + 1); + const idx = heap_next; + heap_next = heap[idx]; + + heap[idx] = obj; + return idx; +} + +function _assertClass(instance, klass) { + if (!(instance instanceof klass)) { + throw new Error(`expected instance of ${klass.name}`); + } +} + +function dropObject(idx) { + if (idx < 1028) return; + heap[idx] = heap_next; + heap_next = idx; +} + +function getArrayF32FromWasm0(ptr, len) { + ptr = ptr >>> 0; + return getFloat32ArrayMemory0().subarray(ptr / 4, ptr / 4 + len); +} + +function getArrayU32FromWasm0(ptr, len) { + ptr = ptr >>> 0; + return getUint32ArrayMemory0().subarray(ptr / 4, ptr / 4 + len); +} + +let cachedDataViewMemory0 = null; +function getDataViewMemory0() { + if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) { + cachedDataViewMemory0 = new DataView(wasm.memory.buffer); + } + return cachedDataViewMemory0; +} + +let cachedFloat32ArrayMemory0 = null; +function getFloat32ArrayMemory0() { + if (cachedFloat32ArrayMemory0 === null || cachedFloat32ArrayMemory0.byteLength === 0) { + cachedFloat32ArrayMemory0 = new Float32Array(wasm.memory.buffer); + } + return cachedFloat32ArrayMemory0; +} + +function getStringFromWasm0(ptr, len) { + ptr = ptr >>> 0; + return decodeText(ptr, len); +} + +let cachedUint32ArrayMemory0 = null; +function getUint32ArrayMemory0() { + if (cachedUint32ArrayMemory0 === null || cachedUint32ArrayMemory0.byteLength === 0) { + cachedUint32ArrayMemory0 = new Uint32Array(wasm.memory.buffer); + } + return cachedUint32ArrayMemory0; +} + +let cachedUint8ArrayMemory0 = null; +function getUint8ArrayMemory0() { + if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) { + cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer); + } + return cachedUint8ArrayMemory0; +} + +function getObject(idx) { return heap[idx]; } + +let heap = new Array(1024).fill(undefined); +heap.push(undefined, null, true, false); + +let heap_next = heap.length; + +function passArray8ToWasm0(arg, malloc) { + const ptr = malloc(arg.length * 1, 1) >>> 0; + getUint8ArrayMemory0().set(arg, ptr / 1); + WASM_VECTOR_LEN = arg.length; + return ptr; +} + +function passArrayF32ToWasm0(arg, malloc) { + const ptr = malloc(arg.length * 4, 4) >>> 0; + getFloat32ArrayMemory0().set(arg, ptr / 4); + WASM_VECTOR_LEN = arg.length; + return ptr; +} + +function passStringToWasm0(arg, malloc, realloc) { + if (realloc === undefined) { + const buf = cachedTextEncoder.encode(arg); + const ptr = malloc(buf.length, 1) >>> 0; + getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf); + WASM_VECTOR_LEN = buf.length; + return ptr; + } + + let len = arg.length; + let ptr = malloc(len, 1) >>> 0; + + const mem = getUint8ArrayMemory0(); + + let offset = 0; + + for (; offset < len; offset++) { + const code = arg.charCodeAt(offset); + if (code > 0x7F) break; + mem[ptr + offset] = code; + } + if (offset !== len) { + if (offset !== 0) { + arg = arg.slice(offset); + } + ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0; + const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len); + const ret = cachedTextEncoder.encodeInto(arg, view); + + offset += ret.written; + ptr = realloc(ptr, len, offset, 1) >>> 0; + } + + WASM_VECTOR_LEN = offset; + return ptr; +} + +function takeObject(idx) { + const ret = getObject(idx); + dropObject(idx); + return ret; +} + +let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); +cachedTextDecoder.decode(); +const MAX_SAFARI_DECODE_BYTES = 2146435072; +let numBytesDecoded = 0; +function decodeText(ptr, len) { + numBytesDecoded += len; + if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) { + cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); + cachedTextDecoder.decode(); + numBytesDecoded = len; + } + return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len)); +} + +const cachedTextEncoder = new TextEncoder(); + +if (!('encodeInto' in cachedTextEncoder)) { + cachedTextEncoder.encodeInto = function (arg, view) { + const buf = cachedTextEncoder.encode(arg); + view.set(buf); + return { + read: arg.length, + written: buf.length + }; + }; +} + +let WASM_VECTOR_LEN = 0; + +let wasmModule, wasm; +function __wbg_finalize_init(instance, module) { + wasm = instance.exports; + wasmModule = module; + cachedDataViewMemory0 = null; + cachedFloat32ArrayMemory0 = null; + cachedUint32ArrayMemory0 = null; + cachedUint8ArrayMemory0 = null; + return wasm; +} + +async function __wbg_load(module, imports) { + if (typeof Response === 'function' && module instanceof Response) { + if (typeof WebAssembly.instantiateStreaming === 'function') { + try { + return await WebAssembly.instantiateStreaming(module, imports); + } catch (e) { + const validResponse = module.ok && expectedResponseType(module.type); + + if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') { + console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e); + + } else { throw e; } + } + } + + const bytes = await module.arrayBuffer(); + return await WebAssembly.instantiate(bytes, imports); + } else { + const instance = await WebAssembly.instantiate(module, imports); + + if (instance instanceof WebAssembly.Instance) { + return { instance, module }; + } else { + return instance; + } + } + + function expectedResponseType(type) { + switch (type) { + case 'basic': case 'cors': case 'default': return true; + } + return false; + } +} + +function initSync(module) { + if (wasm !== undefined) return wasm; + + + if (module !== undefined) { + if (Object.getPrototypeOf(module) === Object.prototype) { + ({module} = module) + } else { + console.warn('using deprecated parameters for `initSync()`; pass a single object instead') + } + } + + const imports = __wbg_get_imports(); + if (!(module instanceof WebAssembly.Module)) { + module = new WebAssembly.Module(module); + } + const instance = new WebAssembly.Instance(module, imports); + return __wbg_finalize_init(instance, module); +} + +async function __wbg_init(module_or_path) { + if (wasm !== undefined) return wasm; + + + if (module_or_path !== undefined) { + if (Object.getPrototypeOf(module_or_path) === Object.prototype) { + ({module_or_path} = module_or_path) + } else { + console.warn('using deprecated parameters for the initialization function; pass a single object instead') + } + } + + if (module_or_path === undefined) { + module_or_path = new URL('calab_cala_core_bg.wasm', import.meta.url); + } + const imports = __wbg_get_imports(); + + if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) { + module_or_path = fetch(module_or_path); + } + + const { instance, module } = await __wbg_load(await module_or_path, imports); + + return __wbg_finalize_init(instance, module); +} + +export { initSync, __wbg_init as default }; diff --git a/crates/cala-core/pkg/calab_cala_core_bg.wasm b/crates/cala-core/pkg/calab_cala_core_bg.wasm new file mode 100644 index 0000000000000000000000000000000000000000..13bedc5462edde6ae85df0928585e3642d1e5e24 GIT binary patch literal 368216 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 D+bp?; literal 0 HcmV?d00001 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 new file mode 100644 index 0000000..a3c3253 --- /dev/null +++ b/crates/cala-core/pkg/calab_cala_core_bg.wasm.d.ts @@ -0,0 +1,44 @@ +/* tslint:disable */ +/* eslint-disable */ +export const memory: WebAssembly.Memory; +export const __wbg_avireader_free: (a: number, b: number) => void; +export const __wbg_fitter_free: (a: number, b: number) => void; +export const __wbg_mutationqueuehandle_free: (a: number, b: number) => void; +export const __wbg_preprocessor_free: (a: number, b: number) => void; +export const __wbg_snapshothandle_free: (a: number, b: number) => void; +export const avireader_bitDepth: (a: number) => number; +export const avireader_channels: (a: number) => number; +export const avireader_fps: (a: number) => number; +export const avireader_frameCount: (a: number) => number; +export const avireader_height: (a: number) => number; +export const avireader_new: (a: number, b: number, c: number) => void; +export const avireader_readFrameGrayscaleF32: (a: number, b: number, c: number, d: number, e: number) => void; +export const avireader_width: (a: number) => number; +export const fitter_drainApply: (a: number, b: number, c: number) => void; +export const fitter_epoch: (a: number) => bigint; +export const fitter_height: (a: number) => number; +export const fitter_lastTrace: (a: number, b: number) => void; +export const fitter_new: (a: number, b: number, c: number, d: number, e: number) => void; +export const fitter_numComponents: (a: number) => number; +export const fitter_step: (a: number, b: number, c: number, d: number) => void; +export const fitter_takeSnapshot: (a: number) => number; +export const fitter_width: (a: number) => number; +export const mutationqueuehandle_capacity: (a: number) => number; +export const mutationqueuehandle_drops: (a: number) => bigint; +export const mutationqueuehandle_isEmpty: (a: number) => number; +export const mutationqueuehandle_isFull: (a: number) => number; +export const mutationqueuehandle_len: (a: number) => number; +export const mutationqueuehandle_new: (a: number, b: number, c: number) => void; +export const mutationqueuehandle_pushDeprecate: (a: number, b: number, c: bigint, d: number, e: number, f: number) => void; +export const preprocessor_new: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void; +export const preprocessor_processFrameF32: (a: number, b: number, c: number, d: number) => void; +export const preprocessor_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; +export const snapshothandle_numComponents: (a: number) => number; +export const snapshothandle_pixels: (a: number) => number; +export const init_panic_hook: () => void; +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_add_to_stack_pointer: (a: number) => number; diff --git a/crates/cala-core/pkg/package.json b/crates/cala-core/pkg/package.json new file mode 100644 index 0000000..10cfb5f --- /dev/null +++ b/crates/cala-core/pkg/package.json @@ -0,0 +1,16 @@ +{ + "name": "calab-cala-core", + "type": "module", + "description": "Numerical core for CaLa — streaming calcium imaging demixing pipeline", + "version": "0.1.0", + "files": [ + "calab_cala_core_bg.wasm", + "calab_cala_core.js", + "calab_cala_core.d.ts" + ], + "main": "calab_cala_core.js", + "types": "calab_cala_core.d.ts", + "sideEffects": [ + "./snippets/*" + ] +} \ No newline at end of file