Skip to main content

gam_solve/
streaming_border.rs

1//! Streaming, deterministic, out-of-core border-Gram accumulation (#973).
2//!
3//! Corpus-scale joint fits cannot hold the activation row set in memory: the
4//! Schur **border Gram** `G = Σ_n x_n x_nᵀ` (with `x_n ∈ ℝ^k` the row's border
5//! coordinates) must be accumulated over fixed-size row **chunks** streamed
6//! from disk shards. Because the methodological program (replicate nulls,
7//! resumable workflows) rests on determinism, the accumulation here is
8//! **bit-reproducible by construction**, not by luck:
9//!
10//! * The chunk partition is a pure function of `(n_rows, chunk_size)` — chunk
11//!   `j` covers rows `[j·chunk_size, min((j+1)·chunk_size, n_rows))`.
12//! * Each within-chunk Gram entry is a [`pairwise_sum`] over the chunk's rows
13//!   (the already-landed deterministic pairwise tree of
14//!   [`gam_linalg::pairwise_reduce`]).
15//! * Cross-chunk reduction follows the **same fixed pairwise tree** (the
16//!   [`StreamingPairwise`](gam_linalg::pairwise_reduce::StreamingPairwise)
17//!   cascade, applied entry-wise to whole chunk Grams): sequential base blocks
18//!   of [`CROSS_CHUNK_BASE`] chunk partials, then power-of-two cascade merges.
19//!   The tree shape depends only on the chunk count — never on values, device
20//!   timing, or thread scheduling. A unit test pins the cross-chunk
21//!   association bit-for-bit to [`pairwise_sum`] over the per-chunk entries.
22//! * Chunks may be **submitted in any order** (e.g. shards finishing on
23//!   different devices at different times): every chunk is keyed by its chunk
24//!   index, the in-order fold frontier advances eagerly, and out-of-order
25//!   arrivals wait in a pending buffer. The final Gram is a pure function of
26//!   the row content alone — identical bits for any submission order.
27//!
28//! All accumulation buffers are **f64** (the mixed-precision policy of #973:
29//! per-row kernels may run f32 upstream, but everything feeding evidence
30//! accumulates in f64 — this module exposes no f32 accumulation path at all).
31//!
32//! The accumulation state — partial Grams (in-order fold forest + pending
33//! out-of-order chunk partials) plus the chunk cursor — serializes to a
34//! [`BorderGramCheckpoint`] and resumes via [`StreamingBorderGram::resume`],
35//! with resume-equals-straight-through guaranteed (and unit-tested) at the
36//! bit level.
37//!
38//! Pure library: no SAE coupling, no flags, no environment variables. Drivers
39//! that also need a right-hand side `Σ_n x_n y_n` stack the response columns
40//! onto the border coordinates (`[X | Y]`) and read the cross block of the
41//! returned Gram; per-row weights `w_n` are pre-scaled into the rows as
42//! `√w_n · x_n` by the caller.
43
44use gam_linalg::pairwise_reduce::{BASE_CHUNK, pairwise_sum};
45use ndarray::{Array2, ArrayView2};
46use serde::{Deserialize, Serialize};
47use std::collections::BTreeMap;
48
49/// Base-block size of the **cross-chunk** pairwise tree, in chunk partials.
50///
51/// Pinned to the landed [`BASE_CHUNK`] of
52/// [`gam_linalg::pairwise_reduce`] so that the entry-wise association order
53/// of the cross-chunk fold is bit-identical to [`pairwise_sum`] over the
54/// per-chunk entry values (unit-tested below). A pure compile-time constant:
55/// the tree shape never depends on tuning, platform, or runtime conditions.
56pub const CROSS_CHUNK_BASE: usize = BASE_CHUNK;
57
58/// Serializable accumulation state of a [`StreamingBorderGram`]: the partial
59/// Grams plus the chunk cursor. Writing this to disk after every accepted
60/// chunk makes a preempted multi-hour pass resumable instead of restartable;
61/// [`StreamingBorderGram::resume`] reconstructs the accumulator with
62/// bit-identical future behavior (resume-equals-straight-through).
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64pub struct BorderGramCheckpoint {
65    /// Border dimension `k` (columns of every submitted chunk).
66    pub border_dim: usize,
67    /// Total row count of the full pass.
68    pub n_rows: usize,
69    /// Fixed chunk size (rows per chunk; the last chunk may be shorter).
70    pub chunk_size: usize,
71    /// Chunk cursor: number of chunks already folded into the in-order
72    /// cascade. Chunk indices `< frontier` are consumed; the next in-order
73    /// fold is chunk `frontier`.
74    pub frontier: usize,
75    /// Sequential partial of the current (unsealed) cross-chunk base block,
76    /// flattened `k·k` row-major. `None` iff `block_len == 0`.
77    pub block_partial: Option<Vec<f64>>,
78    /// Number of chunk partials folded into `block_partial`
79    /// (`0..CROSS_CHUNK_BASE`).
80    pub block_len: usize,
81    /// Completed cascade subtrees: `(weight in chunks, flattened k·k partial)`
82    /// with strictly decreasing power-of-two-multiple-of-base weights, bottom
83    /// to top — exactly the `StreamingPairwise` forest invariant.
84    pub forest: Vec<(usize, Vec<f64>)>,
85    /// Out-of-order chunk partials waiting for the frontier to reach them:
86    /// `(chunk_index, flattened k·k chunk Gram)`, all indices `> frontier`.
87    pub pending: Vec<(usize, Vec<f64>)>,
88}
89
90/// Chunked, out-of-core, bit-reproducible border-Gram accumulator.
91///
92/// Accumulates `G = Σ_n x_n x_nᵀ ∈ ℝ^{k×k}` over `n_rows` rows submitted as
93/// fixed-size chunks (any submission order), with f64 accumulation throughout
94/// and a deterministic pairwise reduction tree whose shape is a pure function
95/// of `(n_rows, chunk_size)`. See the module docs for the determinism
96/// contract.
97pub struct StreamingBorderGram {
98    border_dim: usize,
99    n_rows: usize,
100    chunk_size: usize,
101    /// Next chunk index expected by the in-order cascade fold.
102    frontier: usize,
103    /// Sequential partial of the current cross-chunk base block.
104    block_partial: Option<Vec<f64>>,
105    /// Chunk partials folded into `block_partial` so far.
106    block_len: usize,
107    /// Completed cascade subtrees `(weight in chunks, partial)`.
108    forest: Vec<(usize, Vec<f64>)>,
109    /// Out-of-order chunk partials keyed by chunk index (all `> frontier`).
110    pending: BTreeMap<usize, Vec<f64>>,
111}
112
113/// Entry-wise in-place accumulation `acc[i] += rhs[i]`.
114///
115/// IEEE-754 addition is commutative, so `acc + rhs` and `rhs + acc` are
116/// bit-identical; only the *association grouping* matters for reproducibility,
117/// and that is fixed by the cascade structure of the caller.
118fn add_into(acc: &mut [f64], rhs: &[f64]) {
119    for (a, r) in acc.iter_mut().zip(rhs.iter()) {
120        *a += *r;
121    }
122}
123
124/// Deterministic per-chunk Gram contribution, flattened `k·k` row-major, with
125/// `k = rows.ncols()`. Entry `(a, b)` is the [`pairwise_sum`] of
126/// `x_i[a]·x_i[b]` over the chunk's rows in row order; the symmetric mirror
127/// entry reuses the same products in the same order, so the matrix is bitwise
128/// symmetric.
129///
130/// Exposed as a free function so a **remote producer** (a worker node in the
131/// cross-node reduction, [`crate::cross_node`]) can compute exactly the
132/// partial this accumulator would have computed from the same rows, then ship
133/// the `k·k` partial instead of the rows. Bit-identical by construction to the
134/// in-process path: `StreamingBorderGram::submit_chunk` routes through this
135/// same function.
136pub fn chunk_gram_flat(rows: ArrayView2<'_, f64>) -> Vec<f64> {
137    let k = rows.ncols();
138    let r = rows.nrows();
139    let mut gram = vec![0.0_f64; k * k];
140    let mut products = vec![0.0_f64; r];
141    for a in 0..k {
142        for b in a..k {
143            for (i, p) in products.iter_mut().enumerate() {
144                *p = rows[[i, a]] * rows[[i, b]];
145            }
146            let s = pairwise_sum(&products);
147            gram[a * k + b] = s;
148            gram[b * k + a] = s;
149        }
150    }
151    gram
152}
153
154impl StreamingBorderGram {
155    /// Create an empty accumulator for `n_rows` total rows of border dimension
156    /// `border_dim`, streamed in chunks of `chunk_size` rows.
157    pub fn new(border_dim: usize, n_rows: usize, chunk_size: usize) -> Result<Self, String> {
158        if border_dim == 0 {
159            return Err("StreamingBorderGram: border_dim must be positive".to_string());
160        }
161        if chunk_size == 0 {
162            return Err("StreamingBorderGram: chunk_size must be positive".to_string());
163        }
164        Ok(Self {
165            border_dim,
166            n_rows,
167            chunk_size,
168            frontier: 0,
169            block_partial: None,
170            block_len: 0,
171            forest: Vec::new(),
172            pending: BTreeMap::new(),
173        })
174    }
175
176    /// Total number of chunks of the pass: `ceil(n_rows / chunk_size)`.
177    pub fn n_chunks(&self) -> usize {
178        self.n_rows.div_ceil(self.chunk_size)
179    }
180
181    /// Row range covered by chunk `chunk_index`:
182    /// `[chunk_index·chunk_size, min((chunk_index+1)·chunk_size, n_rows))`.
183    /// A pure function of the partition parameters — the caller slices its
184    /// shard rows with exactly this range.
185    pub fn chunk_rows(&self, chunk_index: usize) -> std::ops::Range<usize> {
186        let lo = chunk_index * self.chunk_size;
187        let hi = ((chunk_index + 1) * self.chunk_size).min(self.n_rows);
188        lo..hi
189    }
190
191    /// Number of chunks already consumed by the in-order cascade (the chunk
192    /// cursor). Pending out-of-order chunks are not counted.
193    pub fn frontier(&self) -> usize {
194        self.frontier
195    }
196
197    /// Serialize the full accumulation state — partial Grams + chunk cursor —
198    /// for checkpointing. [`StreamingBorderGram::resume`] reconstructs an
199    /// accumulator whose future behavior is bit-identical to never having
200    /// stopped.
201    pub fn checkpoint(&self) -> BorderGramCheckpoint {
202        BorderGramCheckpoint {
203            border_dim: self.border_dim,
204            n_rows: self.n_rows,
205            chunk_size: self.chunk_size,
206            frontier: self.frontier,
207            block_partial: self.block_partial.clone(),
208            block_len: self.block_len,
209            forest: self.forest.clone(),
210            pending: self
211                .pending
212                .iter()
213                .map(|(idx, g)| (*idx, g.clone()))
214                .collect(),
215        }
216    }
217
218    /// Reconstruct an accumulator from a checkpoint. Validates the structural
219    /// invariants so a corrupted checkpoint is rejected loudly instead of
220    /// silently producing a wrong (but plausible-looking) Gram.
221    pub fn resume(state: BorderGramCheckpoint) -> Result<Self, String> {
222        if state.border_dim == 0 {
223            return Err("BorderGramCheckpoint: border_dim must be positive".to_string());
224        }
225        if state.chunk_size == 0 {
226            return Err("BorderGramCheckpoint: chunk_size must be positive".to_string());
227        }
228        let kk = state.border_dim * state.border_dim;
229        let n_chunks = state.n_rows.div_ceil(state.chunk_size);
230        if state.frontier > n_chunks {
231            return Err(format!(
232                "BorderGramCheckpoint: frontier {} exceeds n_chunks {n_chunks}",
233                state.frontier
234            ));
235        }
236        if state.block_len >= CROSS_CHUNK_BASE {
237            return Err(format!(
238                "BorderGramCheckpoint: block_len {} must be < CROSS_CHUNK_BASE {CROSS_CHUNK_BASE}",
239                state.block_len
240            ));
241        }
242        if state.block_partial.is_some() != (state.block_len > 0) {
243            return Err(
244                "BorderGramCheckpoint: block_partial presence inconsistent with block_len"
245                    .to_string(),
246            );
247        }
248        if let Some(b) = &state.block_partial {
249            if b.len() != kk {
250                return Err(format!(
251                    "BorderGramCheckpoint: block_partial has len {} but expected {kk}",
252                    b.len()
253                ));
254            }
255        }
256        for (w, g) in &state.forest {
257            if *w == 0 || g.len() != kk {
258                return Err(
259                    "BorderGramCheckpoint: malformed forest partial (zero weight or wrong len)"
260                        .to_string(),
261                );
262            }
263        }
264        let mut pending = BTreeMap::new();
265        for (idx, g) in state.pending {
266            if idx < state.frontier || idx >= n_chunks {
267                return Err(format!(
268                    "BorderGramCheckpoint: pending chunk index {idx} outside (frontier {}, n_chunks {n_chunks})",
269                    state.frontier
270                ));
271            }
272            if g.len() != kk {
273                return Err(format!(
274                    "BorderGramCheckpoint: pending chunk {idx} partial has len {} but expected {kk}",
275                    g.len()
276                ));
277            }
278            if pending.insert(idx, g).is_some() {
279                return Err(format!(
280                    "BorderGramCheckpoint: duplicate pending chunk index {idx}"
281                ));
282            }
283        }
284        Ok(Self {
285            border_dim: state.border_dim,
286            n_rows: state.n_rows,
287            chunk_size: state.chunk_size,
288            frontier: state.frontier,
289            block_partial: state.block_partial,
290            block_len: state.block_len,
291            forest: state.forest,
292            pending,
293        })
294    }
295
296    /// Finish the pass, returning the `k×k` border Gram. Errors if any chunk
297    /// is missing (out-of-order pending chunks the frontier never reached, or
298    /// chunks never submitted). The result is a pure function of the row
299    /// content: identical bits for any submission order and for any
300    /// checkpoint/resume history.
301    pub fn finish(mut self) -> Result<Array2<f64>, String> {
302        let n_chunks = self.n_chunks();
303        if self.frontier != n_chunks {
304            let missing: Vec<usize> = (self.frontier..n_chunks)
305                .filter(|idx| !self.pending.contains_key(idx))
306                .take(8)
307                .collect();
308            return Err(format!(
309                "StreamingBorderGram: finish() before all chunks were submitted \
310                 (frontier {}/{n_chunks}, first missing chunk indices {missing:?})",
311                self.frontier
312            ));
313        }
314        // Seal the trailing (short) base block, exactly like
315        // `StreamingPairwise::finish`.
316        if let Some(tail) = self.block_partial.take() {
317            let w = self.block_len;
318            self.block_len = 0;
319            self.forest.push((w, tail));
320        }
321        // Fold the forest right-to-left: each parent is
322        // combine(left_partial, accumulated_right).
323        let k = self.border_dim;
324        let mut iter = self.forest.into_iter().rev();
325        let flat = match iter.next() {
326            None => vec![0.0_f64; k * k],
327            Some((_, mut acc)) => {
328                for (_, left) in iter {
329                    add_into(&mut acc, &left);
330                }
331                acc
332            }
333        };
334        Array2::from_shape_vec((k, k), flat)
335            .map_err(|e| format!("StreamingBorderGram: Gram reshape failed: {e}"))
336    }
337}
338
339/// Bridges arbitrary-length row batches onto the fixed chunk partition.
340///
341/// A streaming row source (`gam_sae::corpus`) yields batches whose
342/// lengths are set by I/O policy (batch size, shard boundaries) — they do
343/// **not** align with the deterministic chunk partition the accumulation tree
344/// is keyed on. This assembler buffers incoming rows and submits exact chunks
345/// in order, so the resulting Gram is bit-identical to having sliced the
346/// partition directly: the batching of the producer can never leak into the
347/// bits.
348///
349/// Checkpointing is exposed **at chunk granularity only**:
350/// [`ChunkAssembler::checkpoint`] returns `Some` exactly when the internal
351/// buffer is empty (a chunk boundary), because buffered raw rows are not part
352/// of the accumulation state contract — a resumed pass re-reads its row
353/// stream from the checkpointed chunk cursor
354/// ([`StreamingBorderGram::chunk_rows`] of the frontier names the next row).
355pub struct ChunkAssembler {
356    gram: StreamingBorderGram,
357    /// Row-major buffered rows (`buffered_rows × border_dim`), not yet a full
358    /// chunk.
359    buffer: Vec<f64>,
360}
361
362impl ChunkAssembler {
363    /// New assembler over the same partition parameters as
364    /// [`StreamingBorderGram::new`].
365    pub fn new(border_dim: usize, n_rows: usize, chunk_size: usize) -> Result<Self, String> {
366        Ok(Self {
367            gram: StreamingBorderGram::new(border_dim, n_rows, chunk_size)?,
368            buffer: Vec::new(),
369        })
370    }
371
372    /// Serialize the accumulation state — only at a chunk boundary. `None`
373    /// while rows are buffered mid-chunk (checkpoint after the next boundary,
374    /// or size batches to the chunk size for checkpoint-every-batch).
375    pub fn checkpoint(&self) -> Option<BorderGramCheckpoint> {
376        if self.buffer.is_empty() {
377            Some(self.gram.checkpoint())
378        } else {
379            None
380        }
381    }
382
383    /// Resume an assembler at the chunk boundary a checkpoint names. The
384    /// caller re-positions its row stream at row
385    /// `checkpoint.frontier * checkpoint.chunk_size` (the partition is pure,
386    /// so that index is exact) and replays from there.
387    pub fn resume(state: BorderGramCheckpoint) -> Result<Self, String> {
388        let gram = StreamingBorderGram::resume(state)?;
389        Ok(Self {
390            gram,
391            buffer: Vec::new(),
392        })
393    }
394
395    /// Finish the pass. Errors if the stream ended mid-chunk or short of the
396    /// declared row count — a truncated stream is rejected loudly, never
397    /// folded as a silently shorter corpus.
398    pub fn finish(self) -> Result<Array2<f64>, String> {
399        if !self.buffer.is_empty() {
400            let k = self.gram.border_dim;
401            return Err(format!(
402                "ChunkAssembler: stream ended mid-chunk with {} buffered rows \
403                 (declared n_rows = {})",
404                self.buffer.len() / k,
405                self.gram.n_rows
406            ));
407        }
408        self.gram.finish()
409    }
410}
411