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