Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 81 additions & 3 deletions crates/cala-core/src/assets/footprints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@
//! once profiling justifies it). The in-house rep keeps push / value
//! mutation / compact cheap, which matters for the `EvaluateFootprints`
//! inner loop that shrinks support morphologically every frame.
//!
//! Each component also carries a stable `u32` id and a
//! `ComponentClass` tag (design §3.1). Ids are never reused; positions
//! can shift when a component is deprecated, but ids survive so Phase
//! 3 `PipelineMutation`s can refer to components unambiguously across
//! apply cycles.

use crate::config::ComponentClass;

/// Sparse non-negative footprint matrix.
#[derive(Debug, Clone)]
Expand All @@ -18,10 +26,16 @@ pub struct Footprints {
width: usize,
pixels: usize,
components: Vec<Component>,
next_id: u32,
}

#[derive(Debug, Clone)]
struct Component {
/// Stable monotonically-assigned identifier. Never reused once
/// deprecated, never changes through footprint updates.
id: u32,
/// Shape-prior tag (cell / slow-baseline / neuropil).
class: ComponentClass,
/// Pixel indices in positive support, sorted strictly ascending.
support: Vec<u32>,
/// Values aligned with `support`; all entries are `> 0` after
Expand All @@ -45,6 +59,7 @@ impl Footprints {
width,
pixels,
components: Vec::new(),
next_id: 0,
}
}

Expand All @@ -68,15 +83,40 @@ impl Footprints {
self.components.is_empty()
}

/// Append a new component with the given positive support.
/// Append a new component with the given positive support. The
/// component's class defaults to `ComponentClass::Cell`; use
/// [`Self::push_component_classified`] to tag a non-cell class.
///
/// `support` must be sorted strictly ascending (which also forbids
/// duplicates); `values` must have the same length and be strictly
/// positive; pixel indices must be `< pixels()`.
///
/// Returns the component's position index at insertion time.
/// The position may shift later if an earlier component is
/// deprecated; use [`Self::id`] + [`Self::position_of`] when the
/// caller needs id-stable references.
pub fn push_component(&mut self, support: Vec<u32>, values: Vec<f32>) -> usize {
self.push_component_classified(support, values, ComponentClass::Cell);
self.components.len() - 1
}

/// Append a new component tagged with the given class. Returns the
/// stable `u32` id (never reused, never changes).
pub fn push_component_classified(
&mut self,
support: Vec<u32>,
values: Vec<f32>,
class: ComponentClass,
) -> u32 {
validate_component(&support, &values, self.pixels);
let id = self.components.len();
self.components.push(Component { support, values });
let id = self.next_id;
self.next_id = self.next_id.checked_add(1).expect("next_id overflowed u32");
self.components.push(Component {
id,
class,
support,
values,
});
id
}

Expand All @@ -92,6 +132,44 @@ impl Footprints {
&mut self.components[i].values
}

/// Stable id of the component at position `i`.
pub fn id(&self, i: usize) -> u32 {
self.components[i].id
}

/// Class tag of the component at position `i`.
pub fn class(&self, i: usize) -> ComponentClass {
self.components[i].class
}

/// Map a stable id back to its current position, or `None` if it
/// has been deprecated.
pub fn position_of(&self, id: u32) -> Option<usize> {
self.components.iter().position(|c| c.id == id)
}

/// Remove the component with the given id. Returns its position at
/// the time of removal, or `None` if the id is not live.
/// Surviving components keep their ids; their positions shift down
/// past the removed index.
pub fn deprecate_by_id(&mut self, id: u32) -> Option<usize> {
let pos = self.position_of(id)?;
self.components.remove(pos);
Some(pos)
}

/// The next id that will be assigned by a `push_*` call. Primarily
/// used by Phase 3 mutation-apply code to allocate ids consistently
/// across (A, C, W, M, G) in one atomic step.
pub fn next_id(&self) -> u32 {
self.next_id
}

/// Iterator over current ids in position order.
pub fn ids(&self) -> impl Iterator<Item = u32> + '_ {
self.components.iter().map(|c| c.id)
}

/// Compute `Aᵀy` — one inner product per column over its support.
/// Returns a dense length-`k` vector (`k = len()`).
pub fn aty(&self, y: &[f32]) -> Vec<f32> {
Expand Down
60 changes: 60 additions & 0 deletions crates/cala-core/src/assets/suff_stats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,64 @@ impl SuffStats {
pub fn m_at(&self, i: usize, j: usize) -> f32 {
self.m[self.m_idx(i, j)]
}

/// Grow `k` by 1, appending a zero column to `W` (per pixel) and
/// a zero row + column to `M`. Used by Phase 3 apply when a new
/// component is registered (merge or fresh discovery).
pub fn insert_empty_component(&mut self) {
let new_k = self
.k
.checked_add(1)
.expect("SuffStats k overflowed usize on insert");
let mut new_w = Vec::with_capacity(self.pixels * new_k);
for p in 0..self.pixels {
let row_start = p * self.k;
new_w.extend_from_slice(&self.w[row_start..row_start + self.k]);
new_w.push(0.0);
}
let mut new_m = vec![0.0f32; new_k * new_k];
for i in 0..self.k {
for j in 0..self.k {
new_m[i * new_k + j] = self.m[i * self.k + j];
}
}
self.k = new_k;
self.w = new_w;
self.m = new_m;
}

/// Remove the component at position `pos` — drops a column from
/// `W` and a row + column from `M`. Panics on out-of-range index.
pub fn remove_component(&mut self, pos: usize) {
assert!(
pos < self.k,
"remove_component pos {pos} out of range (k = {})",
self.k
);
let new_k = self.k - 1;
if new_k == 0 {
self.k = 0;
self.w = Vec::new();
self.m = Vec::new();
return;
}
let mut new_w = Vec::with_capacity(self.pixels * new_k);
for p in 0..self.pixels {
let row_start = p * self.k;
new_w.extend_from_slice(&self.w[row_start..row_start + pos]);
new_w.extend_from_slice(&self.w[row_start + pos + 1..row_start + self.k]);
}
let mut new_m = Vec::with_capacity(new_k * new_k);
for i in 0..self.k {
if i == pos {
continue;
}
let row_start = i * self.k;
new_m.extend_from_slice(&self.m[row_start..row_start + pos]);
new_m.extend_from_slice(&self.m[row_start + pos + 1..row_start + self.k]);
}
self.k = new_k;
self.w = new_w;
self.m = new_m;
}
}
59 changes: 59 additions & 0 deletions crates/cala-core/src/assets/traces.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,63 @@ impl Traces {
pub fn as_matrix(&self) -> &[f32] {
&self.data
}

/// Append a new trace column initialized from `history`. `history`
/// must have length `frames()` — one value per past frame. Used
/// by Phase 3 apply when a new component is registered: fresh
/// discoveries pass all-zeros, merges pass the sum of the two
/// deprecated components' histories.
pub fn insert_component_with_history(&mut self, history: &[f32]) {
assert_eq!(
history.len(),
self.frames,
"history length {} must equal frames {}",
history.len(),
self.frames
);
let new_k = self
.k
.checked_add(1)
.expect("Traces k overflowed usize on insert");
let mut new_data = Vec::with_capacity(self.frames * new_k);
for t in 0..self.frames {
let row_start = t * self.k;
new_data.extend_from_slice(&self.data[row_start..row_start + self.k]);
new_data.push(history[t]);
}
self.k = new_k;
self.data = new_data;
}

/// Drop the column at `pos` from every past frame's trace.
/// Component positions to the right shift down by one.
pub fn remove_component(&mut self, pos: usize) {
assert!(
pos < self.k,
"remove_component pos {pos} out of range (k = {})",
self.k
);
let new_k = self.k - 1;
if new_k == 0 {
self.k = 0;
self.data = Vec::new();
return;
}
let mut new_data = Vec::with_capacity(self.frames * new_k);
for t in 0..self.frames {
let row_start = t * self.k;
new_data.extend_from_slice(&self.data[row_start..row_start + pos]);
new_data.extend_from_slice(&self.data[row_start + pos + 1..row_start + self.k]);
}
self.k = new_k;
self.data = new_data;
}

/// Column `i`'s values across all frames, in push order.
pub fn column(&self, i: usize) -> Vec<f32> {
assert!(i < self.k, "column {i} out of range (k = {})", self.k);
(0..self.frames)
.map(|t| self.data[t * self.k + i])
.collect()
}
}
141 changes: 141 additions & 0 deletions crates/cala-core/src/buffers/bipbuf.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
//! Residual ring buffer for the extend loop.
//!
//! "Bip-buffer" in the sense of design §5: a single `Vec<f32>` sized
//! `2 × capacity × frame_len` where every push writes the frame into
//! *both* the primary slot and its mirror at offset `capacity`. The
//! mirror guarantees the most recent `capacity` frames are always
//! readable as a single contiguous `&[f32]` slice regardless of how
//! many times the head pointer has wrapped — no `VecDeque`-style
//! two-slice splitting, no per-cycle copy to a scratch window.
//!
//! Invariants:
//! - Each frame is exactly `frame_len` pixels.
//! - `len()` counts frames currently in the window, saturating at
//! `capacity`. Oldest-to-newest order over `window()`.
//! - `window().len() == len() * frame_len`. Memory is contiguous.

/// Residual ring buffer with an O(1) contiguous window slice.
#[derive(Debug)]
pub struct ResidualRingBuf {
frame_len: usize,
capacity: usize,
/// Mirrored storage: `2 * capacity * frame_len` f32s. Primary
/// region is `[0, capacity * frame_len)`; mirror is
/// `[capacity * frame_len, 2 * capacity * frame_len)`.
storage: Vec<f32>,
/// Slot (0..capacity) of the next frame to write. Once the
/// buffer is full, this is also the slot of the *oldest* frame.
head: usize,
/// Frames currently in the window, clamped to `capacity`.
count: usize,
}

impl ResidualRingBuf {
/// Allocate a ring holding up to `capacity` frames of `frame_len`
/// pixels each. Panics on zero for either argument.
pub fn new(frame_len: usize, capacity: usize) -> Self {
assert!(frame_len > 0, "frame_len must be positive (got 0)");
assert!(capacity > 0, "capacity must be positive (got 0)");
let total = capacity
.checked_mul(frame_len)
.and_then(|n| n.checked_mul(2))
.expect("2 * capacity * frame_len overflowed usize");
Self {
frame_len,
capacity,
storage: vec![0.0f32; total],
head: 0,
count: 0,
}
}

pub fn frame_len(&self) -> usize {
self.frame_len
}

pub fn capacity(&self) -> usize {
self.capacity
}

/// Number of frames currently in the window (0..=capacity).
pub fn len(&self) -> usize {
self.count
}

pub fn is_empty(&self) -> bool {
self.count == 0
}

pub fn is_full(&self) -> bool {
self.count == self.capacity
}

/// Push `frame` as the newest entry, dropping the oldest when full.
pub fn push(&mut self, frame: &[f32]) {
assert_eq!(
frame.len(),
self.frame_len,
"frame length {} must equal frame_len {}",
frame.len(),
self.frame_len
);
let primary_start = self.head * self.frame_len;
let mirror_start = (self.head + self.capacity) * self.frame_len;
let end = self.frame_len;
self.storage[primary_start..primary_start + end].copy_from_slice(frame);
self.storage[mirror_start..mirror_start + end].copy_from_slice(frame);

self.head = (self.head + 1) % self.capacity;
if self.count < self.capacity {
self.count += 1;
}
}

/// Contiguous slice over the most recent `len()` frames in push
/// order: oldest at pixel 0, newest at pixel
/// `(len() - 1) * frame_len`.
pub fn window(&self) -> &[f32] {
if self.count == 0 {
return &self.storage[0..0];
}
if self.count < self.capacity {
// Never wrapped. Slots 0..count hold the frames in push order
// in the primary region.
&self.storage[0..self.count * self.frame_len]
} else {
// Full. `head` is the oldest-frame slot. The mirror
// guarantees `[head, head + capacity)` lives in one
// contiguous memory range.
let start = self.head * self.frame_len;
let end = start + self.capacity * self.frame_len;
&self.storage[start..end]
}
}

/// Slice for the `i`-th frame in the window
/// (0 = oldest, `len() - 1` = newest).
pub fn frame(&self, i: usize) -> &[f32] {
assert!(
i < self.count,
"frame index {i} out of range (len = {})",
self.count
);
let window = self.window();
&window[i * self.frame_len..(i + 1) * self.frame_len]
}

/// Most-recently-pushed frame, or `None` if the buffer is empty.
pub fn latest(&self) -> Option<&[f32]> {
if self.count == 0 {
None
} else {
Some(self.frame(self.count - 1))
}
}

/// Drop all frames. Storage capacity is preserved.
pub fn clear(&mut self) {
self.head = 0;
self.count = 0;
}
}
Loading
Loading