Skip to main content

linkrs_core/
columnar.rs

1//! Column-oriented materialized state shared by blocking operators.
2//!
3//! This is the shared columnar abstraction described in the R4 design
4//! (`docs/plan/columnar_materialization_state_design.md`). It replaces the
5//! row-oriented `Vec<Vec<Value>>` buffers used by `MaterializeState`,
6//! `DataCollectState` and `RollUpApplyState` with a column-major layout plus
7//! an optional selection vector.
8//!
9//! Values are stored one `Vec<Value>` per column. This keeps a projected or
10//! filtered subset of columns cache-friendly and removes the row<->column
11//! transposes that the row-oriented buffers forced at every blocking boundary.
12//! The module lives in `linkrs-core` (the bottom of the DAG) so that both
13//! `linkrs-query` and a future `linkrs-storage` columnar rebuild can depend
14//! on it without violating the storage to query dependency direction.
15
16use crate::value::Value;
17use std::collections::hash_map::DefaultHasher;
18use std::hash::{Hash, Hasher};
19use std::sync::Arc;
20
21/// Projected deduplication / grouping key: the key-column subset of a row.
22///
23/// Stored as owned `Value`s (no new dependencies). A future compact
24/// encoding may replace the representation without changing callers.
25pub type RowKey = Vec<Value>;
26
27/// FNV-1a 64-bit constants for columnar row hashing.
28///
29/// Independent copy of the spill partition hash domain parameters so
30/// `linkrs-core` does not depend on `linkrs-query`. The seed matches the
31/// spill side only by convention; the two domains must not be mixed.
32pub const COLUMNAR_HASH_SEED: u64 = 0xdeadbeefcafe;
33
34const FNV1A_64_INIT: u64 = 0xcbf29ce484222325;
35const FNV1A_64_PRIME: u64 = 0x100000001b3;
36
37fn fnv1a_64_update(mut hash: u64, data: &[u8]) -> u64 {
38    for &byte in data {
39        hash ^= byte as u64;
40        hash = hash.wrapping_mul(FNV1A_64_PRIME);
41    }
42    hash
43}
44
45/// Column-name fingerprint: FNV-style hash over column names.
46///
47/// Mirrors the spill-side `schema_fingerprint` contract without creating a
48/// cross-crate dependency. Empty input yields the FNV offset basis.
49pub fn column_name_fingerprint(col_names: &[String]) -> u64 {
50    let mut hash = FNV1A_64_INIT;
51    for name in col_names {
52        hash = fnv1a_64_update(hash, name.as_bytes());
53        hash = fnv1a_64_update(hash, &[0]);
54    }
55    hash
56}
57
58/// One column buffer: a contiguous `Vec<Value>`.
59///
60/// Appending a value is O(1) amortized, and the column is stored contiguously
61/// so sequential access down a single column is cache-friendly.
62#[derive(Debug, Clone, Default)]
63pub struct ColumnBuffer {
64    /// All values for this logical column, in physical row order.
65    pub values: Vec<Value>,
66}
67
68impl ColumnBuffer {
69    /// Create an empty column buffer.
70    pub fn new() -> Self {
71        ColumnBuffer { values: Vec::new() }
72    }
73
74    /// Create an empty column buffer with capacity for `cap` values.
75    pub fn with_capacity(cap: usize) -> Self {
76        ColumnBuffer {
77            values: Vec::with_capacity(cap),
78        }
79    }
80
81    /// Append one value to the column.
82    pub fn push(&mut self, v: Value) {
83        self.values.push(v);
84    }
85
86    /// Number of values currently stored.
87    pub fn len(&self) -> usize {
88        self.values.len()
89    }
90
91    /// Whether the column holds no values.
92    pub fn is_empty(&self) -> bool {
93        self.values.is_empty()
94    }
95
96    /// Remove all values.
97    pub fn clear(&mut self) {
98        self.values.clear();
99    }
100
101    /// Borrow the backing slice.
102    pub fn as_slice(&self) -> &[Value] {
103        &self.values
104    }
105}
106
107/// Selection vector: logical row `i` maps to physical row `indices[i]`.
108///
109/// `None` on a [`MaterializedBatch`] encodes the identity selection (logical
110/// row `i` <-> physical column index `i`); this is the common case and
111/// avoids allocating a vector at all.
112#[derive(Debug, Clone, Default)]
113pub struct SelectionVector {
114    /// Physical row index for each logical row.
115    pub indices: Vec<usize>,
116}
117
118impl SelectionVector {
119    /// Create an empty (non-identity) selection vector.
120    pub fn new() -> Self {
121        SelectionVector {
122            indices: Vec::new(),
123        }
124    }
125
126    /// Create an identity selection of length `len` (`indices[i] == i`).
127    pub fn identity(len: usize) -> Self {
128        SelectionVector {
129            indices: (0..len).collect(),
130        }
131    }
132
133    /// Whether this selection is the identity mapping.
134    pub fn is_identity(&self) -> bool {
135        self.indices.iter().enumerate().all(|(i, &p)| i == p)
136    }
137
138    /// Number of logical rows covered.
139    pub fn len(&self) -> usize {
140        self.indices.len()
141    }
142
143    /// Whether the selection is empty.
144    pub fn is_empty(&self) -> bool {
145        self.indices.is_empty()
146    }
147}
148
149/// Column-oriented materialized batch.
150///
151/// Replaces `Vec<Vec<Value>>` in `MaterializeState` / `DataCollectState` /
152/// `RollUpApplyState`. Logical rows are addressed through `num_rows()` which
153/// honors an optional [`SelectionVector`].
154#[derive(Debug, Clone)]
155pub struct MaterializedBatch {
156    /// One buffer per output column, in schema order.
157    columns: Vec<ColumnBuffer>,
158    /// Physical row count (length of every column buffer).
159    num_rows: usize,
160    /// Optional selection vector; `None` == identity over `0..num_rows`.
161    selection: Option<SelectionVector>,
162    /// Schema fingerprint for spill-compatibility checks.
163    /// Until run files land, this is the hashed column count; the column-name
164    /// fingerprint from `spill.rs` will be threaded through when columnar
165    /// run files are added.
166    schema_fingerprint: u64,
167    /// Optional column names backing the fingerprint. `None` preserves the
168    /// legacy column-count-only fingerprint behavior.
169    schema_names: Option<Arc<[String]>>,
170}
171
172impl MaterializedBatch {
173    /// Create an empty batch with `num_columns` columns.
174    pub fn new(num_columns: usize, schema_fingerprint: u64) -> Self {
175        MaterializedBatch {
176            columns: (0..num_columns).map(|_| ColumnBuffer::new()).collect(),
177            num_rows: 0,
178            selection: None,
179            schema_fingerprint,
180            schema_names: None,
181        }
182    }
183
184    /// Create an empty batch with column names backing the fingerprint.
185    ///
186    /// The fingerprint is derived from the column names so batches with the
187    /// same column count but different schemas no longer collide. When
188    /// `names` is empty the legacy column-count fingerprint applies.
189    pub fn with_schema_names(names: Arc<[String]>) -> Self {
190        let fingerprint = column_name_fingerprint(&names);
191        MaterializedBatch {
192            columns: (0..names.len()).map(|_| ColumnBuffer::new()).collect(),
193            num_rows: 0,
194            selection: None,
195            schema_fingerprint: fingerprint,
196            schema_names: Some(names),
197        }
198    }
199
200    /// Attach column names and recompute the fingerprint from them.
201    pub fn set_schema_names(&mut self, names: Arc<[String]>) {
202        self.schema_fingerprint = column_name_fingerprint(&names);
203        self.schema_names = Some(names);
204    }
205
206    /// Borrow the column names backing the fingerprint, if any.
207    pub fn schema_names(&self) -> Option<&Arc<[String]>> {
208        self.schema_names.as_ref()
209    }
210
211    /// Override the spill-compatibility token (used by run replay checks).
212    pub fn set_fingerprint(&mut self, fingerprint: u64) {
213        self.schema_fingerprint = fingerprint;
214    }
215
216    /// Build a batch from an iterator of rows.
217    ///
218    /// The column count is inferred from the first row; all subsequent rows
219    /// must have the same arity. The schema fingerprint is the hashed column
220    /// count (placeholder until R3 threads the column-name fingerprint).
221    pub fn from_rows(rows: impl IntoIterator<Item = Vec<Value>>) -> Self {
222        let mut batch = MaterializedBatch::new(0, 0);
223        for row in rows {
224            batch.append_row(row);
225        }
226        batch.refresh_fingerprint();
227        batch
228    }
229
230    /// Number of columns in the batch.
231    pub fn num_columns(&self) -> usize {
232        self.columns.len()
233    }
234
235    /// Logical row count (length of the selection, or physical count).
236    pub fn num_rows(&self) -> usize {
237        match &self.selection {
238            Some(sel) => sel.indices.len(),
239            None => self.num_rows,
240        }
241    }
242
243    /// Whether the batch holds no logical rows.
244    pub fn is_empty(&self) -> bool {
245        self.num_rows() == 0
246    }
247
248    /// Schema fingerprint (spill-compatibility token).
249    pub fn schema_fingerprint(&self) -> u64 {
250        self.schema_fingerprint
251    }
252
253    /// Append one row, growing the column set from the first row's arity.
254    ///
255    /// Panics if a non-empty batch receives a row with a different column
256    /// count; blocking operators feed rows of uniform arity so this invariant
257    /// always holds.
258    pub fn append_row(&mut self, row: Vec<Value>) {
259        if self.columns.is_empty() {
260            self.columns = (0..row.len()).map(|_| ColumnBuffer::new()).collect();
261        }
262        assert_eq!(
263            row.len(),
264            self.columns.len(),
265            "MaterializedBatch: row arity {} != column count {}",
266            row.len(),
267            self.columns.len()
268        );
269        for (col, v) in row.into_iter().enumerate() {
270            self.columns[col].push(v);
271        }
272        self.num_rows += 1;
273    }
274
275    /// Append one pre-built column of values.
276    ///
277    /// The column length must match the existing row count (or define it
278    /// when the batch is empty). Column count grows by one.
279    pub fn append_column(&mut self, values: Vec<Value>) {
280        if self.columns.is_empty() {
281            self.columns.push(ColumnBuffer { values });
282            self.num_rows = self.columns[0].len();
283            return;
284        }
285        assert_eq!(
286            values.len(),
287            self.num_rows,
288            "MaterializedBatch: column length {} != row count {}",
289            values.len(),
290            self.num_rows
291        );
292        self.columns.push(ColumnBuffer { values });
293    }
294
295    /// Borrow column `i`.
296    pub fn column(&self, i: usize) -> &ColumnBuffer {
297        &self.columns[i]
298    }
299
300    /// Logical row `i` (after selection) as an owned `Vec<Value>`.
301    pub fn row(&self, i: usize) -> Vec<Value> {
302        match &self.selection {
303            Some(sel) => {
304                let phys = sel.indices[i];
305                self.columns
306                    .iter()
307                    .map(|c| c.values[phys].clone())
308                    .collect()
309            }
310            None => self.columns.iter().map(|c| c.values[i].clone()).collect(),
311        }
312    }
313
314    /// Iterate logical rows as owned `Vec<Value>`.
315    pub fn rows(&self) -> impl Iterator<Item = Vec<Value>> + '_ {
316        let n = self.num_rows();
317        (0..n).map(move |i| self.row(i))
318    }
319
320    /// All logical rows as owned `Vec<Vec<Value>>` (borrow version).
321    pub fn to_rows(&self) -> Vec<Vec<Value>> {
322        self.rows().collect()
323    }
324
325    /// Drain the batch's column buffers and return all logical rows without
326    /// cloning values (transposes column buffers into row vectors). The batch is
327    /// left empty but remains a valid zero-row state, so it can be called
328    /// through a `&mut` reference owned by an operator state struct.
329    pub fn into_rows(&mut self) -> Vec<Vec<Value>> {
330        let logical = self.num_rows();
331        let columns = std::mem::take(&mut self.columns);
332        let mut rows = vec![Vec::with_capacity(columns.len()); logical];
333        match &self.selection {
334            Some(sel) => {
335                for (logical_idx, &phys) in sel.indices.iter().enumerate() {
336                    for col in columns.iter() {
337                        rows[logical_idx].push(col.values[phys].clone());
338                    }
339                }
340            }
341            None => {
342                for col in columns.into_iter() {
343                    for (r, v) in col.values.into_iter().enumerate() {
344                        rows[r].push(v);
345                    }
346                }
347            }
348        }
349        self.num_rows = 0;
350        rows
351    }
352
353    /// Approximate in-memory footprint in bytes, for `MemoryTracker` accounting.
354    ///
355    /// This is a conservative per-value heuristic plus per-column vector
356    /// overhead; it intentionally does not try to measure heap payloads behind
357    /// `Value`'s indirection (the row-oriented path used the same heuristic via
358    /// `MemoryBudget::estimate_row_memory`).
359    pub fn memory_size(&self) -> usize {
360        let values: usize = self.columns.iter().map(|c| c.values.len()).sum();
361        values * std::mem::size_of::<Value>() + self.columns.len() * 24
362    }
363
364    /// Install a selection vector over the physical rows.
365    ///
366    /// The selection length must equal the logical row count (i.e. the value
367    /// returned by `num_rows()` before installing it).
368    pub fn set_selection(&mut self, sel: SelectionVector) {
369        self.selection = Some(sel);
370    }
371
372    /// Clear any installed selection vector, reverting to identity.
373    pub fn clear_selection(&mut self) {
374        self.selection = None;
375    }
376
377    /// Recompute the schema fingerprint from the current column count.
378    ///
379    /// Legacy behavior preserved for batches without column names. Prefer
380    /// [`Self::refresh_fingerprint_from_names`] for new code.
381    pub fn refresh_fingerprint(&mut self) {
382        if let Some(names) = self.schema_names.clone() {
383            self.schema_fingerprint = column_name_fingerprint(&names);
384            return;
385        }
386        let mut h = DefaultHasher::new();
387        self.columns.len().hash(&mut h);
388        self.schema_fingerprint = h.finish();
389    }
390
391    /// Recompute the fingerprint from explicit column names.
392    pub fn refresh_fingerprint_from_names(&mut self, names: &[String]) {
393        self.schema_fingerprint = column_name_fingerprint(names);
394    }
395
396    /// Remove all rows while keeping the column layout and names.
397    pub fn clear(&mut self) {
398        for col in &mut self.columns {
399            col.clear();
400        }
401        self.num_rows = 0;
402        self.selection = None;
403    }
404
405    /// Borrow one column as a value slice.
406    pub fn column_slice(&self, i: usize) -> &[Value] {
407        self.columns[i].as_slice()
408    }
409
410    /// Copy a row range into a new batch (clamped to the logical size).
411    ///
412    /// The selection, if any, is resolved so the output batch is always
413    /// identity-selected. Schema names are carried over.
414    pub fn slice_rows(&self, offset: usize, len: usize) -> MaterializedBatch {
415        let total = self.num_rows();
416        if offset >= total || len == 0 {
417            let mut out = MaterializedBatch::new(self.columns.len(), self.schema_fingerprint);
418            out.schema_names = self.schema_names.clone();
419            return out;
420        }
421        let end = (offset + len).min(total);
422        let mut out = MaterializedBatch::new(0, self.schema_fingerprint);
423        out.schema_names = self.schema_names.clone();
424        out.columns = self.columns.iter().map(|_| ColumnBuffer::new()).collect();
425        match &self.selection {
426            Some(sel) => {
427                for logical in offset..end {
428                    let phys = sel.indices[logical];
429                    for (dst, src) in out.columns.iter_mut().zip(self.columns.iter()) {
430                        dst.push(src.values[phys].clone());
431                    }
432                }
433            }
434            None => {
435                for (dst, src) in out.columns.iter_mut().zip(self.columns.iter()) {
436                    dst.values.extend_from_slice(&src.values[offset..end]);
437                    // `extend_from_slice` clones each `Value`; explicit loop
438                    // avoided for clarity, semantics identical.
439                }
440            }
441        }
442        out.num_rows = end - offset;
443        out
444    }
445
446    /// Reorder/copy rows by physical indices into a new identity batch.
447    pub fn gather(&self, indices: &[usize]) -> MaterializedBatch {
448        let mut out = MaterializedBatch::new(0, self.schema_fingerprint);
449        out.schema_names = self.schema_names.clone();
450        out.columns = self.columns.iter().map(|_| ColumnBuffer::new()).collect();
451        // Resolve through the installed selection so callers pass logical
452        // positions uniformly.
453        for &logical in indices {
454            let phys = match &self.selection {
455                Some(sel) => sel.indices[logical],
456                None => logical,
457            };
458            for (dst, src) in out.columns.iter_mut().zip(self.columns.iter()) {
459                dst.push(src.values[phys].clone());
460            }
461        }
462        out.num_rows = indices.len();
463        out
464    }
465
466    /// Hash each logical row over a column subset (FNV-1a over postcard bytes).
467    ///
468    /// `cols` empty means all columns. Only the equality partition matters:
469    /// rows equal under `Value` equality share a hash; unequal rows may (rarely)
470    /// collide. Null/NaN/mixed-kind handling follows postcard encoding, and
471    /// equivalence with `HashSet<Vec<Value>>` is covered by tests.
472    pub fn hash_rows(&self, cols: &[usize]) -> Vec<u64> {
473        let n = self.num_rows();
474        let mut out = Vec::with_capacity(n);
475        let use_all = cols.is_empty();
476        for logical in 0..n {
477            let phys = match &self.selection {
478                Some(sel) => sel.indices[logical],
479                None => logical,
480            };
481            let mut hash = COLUMNAR_HASH_SEED;
482            if use_all {
483                for col in &self.columns {
484                    let bytes = postcard::to_allocvec(&col.values[phys]).unwrap_or_default();
485                    hash = fnv1a_64_update(hash, &bytes);
486                    hash = fnv1a_64_update(hash, &[0xff]);
487                }
488            } else {
489                for &c in cols {
490                    let bytes =
491                        postcard::to_allocvec(&self.columns[c].values[phys]).unwrap_or_default();
492                    hash = fnv1a_64_update(hash, &bytes);
493                    hash = fnv1a_64_update(hash, &[0xff]);
494                }
495            }
496            out.push(hash);
497        }
498        out
499    }
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505    use crate::value::Value;
506
507    fn row_i64(v: i64) -> Vec<Value> {
508        vec![Value::BigInt(v), Value::string(format!("k{v}"))]
509    }
510
511    #[test]
512    fn row_round_trip_preserves_order_and_arity() {
513        let rows = vec![row_i64(1), row_i64(2), row_i64(3)];
514        let batch = MaterializedBatch::from_rows(rows.clone());
515        assert_eq!(batch.num_rows(), 3);
516        assert_eq!(batch.num_columns(), 2);
517        assert_eq!(batch.to_rows(), rows);
518    }
519
520    #[test]
521    fn column_major_layout_is_contiguous() {
522        let batch = MaterializedBatch::from_rows(vec![row_i64(1), row_i64(2)]);
523        let c0 = batch.column(0).as_slice();
524        assert_eq!(c0, &[Value::BigInt(1), Value::BigInt(2)]);
525        let c1 = batch.column(1).as_slice();
526        assert_eq!(c1, &[Value::string("k1"), Value::string("k2")]);
527    }
528
529    #[test]
530    fn into_rows_moves_without_clone_and_preserves_values() {
531        let rows = vec![row_i64(10), row_i64(20)];
532        let mut batch = MaterializedBatch::from_rows(rows.clone());
533        assert_eq!(batch.into_rows(), rows);
534    }
535
536    #[test]
537    fn empty_batch_is_empty() {
538        let batch = MaterializedBatch::new(2, 0);
539        assert!(batch.is_empty());
540        assert_eq!(batch.num_rows(), 0);
541        assert_eq!(batch.to_rows(), Vec::<Vec<Value>>::new());
542    }
543
544    #[test]
545    fn selection_vector_reorders_logical_rows() {
546        let batch = MaterializedBatch::from_rows(vec![row_i64(1), row_i64(2), row_i64(3)]);
547        let mut batch = batch;
548        batch.set_selection(SelectionVector {
549            indices: vec![2, 0],
550        });
551        assert_eq!(batch.num_rows(), 2);
552        assert_eq!(batch.row(0), row_i64(3));
553        assert_eq!(batch.row(1), row_i64(1));
554        // into_rows honors the selection
555        let out = batch.into_rows();
556        assert_eq!(out, vec![row_i64(3), row_i64(1)]);
557    }
558
559    #[test]
560    fn memory_size_is_positive() {
561        let batch = MaterializedBatch::from_rows(vec![row_i64(1), row_i64(2)]);
562        assert!(batch.memory_size() > 0);
563    }
564
565    #[test]
566    fn fingerprint_is_column_name_sensitive() {
567        let a: Arc<[String]> = Arc::from(vec!["id".to_string(), "name".to_string()]);
568        let b: Arc<[String]> = Arc::from(vec!["id".to_string(), "other".to_string()]);
569        let ba = MaterializedBatch::with_schema_names(Arc::clone(&a));
570        let bb = MaterializedBatch::with_schema_names(Arc::clone(&b));
571        assert_ne!(ba.schema_fingerprint(), bb.schema_fingerprint());
572        assert_eq!(ba.schema_fingerprint(), column_name_fingerprint(&a));
573
574        // Same column count, different names: legacy count-only hashing
575        // would collide, name-based must not.
576        let mut legacy = MaterializedBatch::from_rows(vec![row_i64(1)]);
577        legacy.refresh_fingerprint();
578        let mut named = MaterializedBatch::from_rows(vec![row_i64(1)]);
579        named.set_schema_names(Arc::from(vec!["x".to_string(), "y".to_string()]));
580        named.refresh_fingerprint();
581        assert_eq!(
582            named.schema_fingerprint(),
583            column_name_fingerprint(&["x".to_string(), "y".to_string()])
584        );
585    }
586
587    #[test]
588    fn slice_and_gather_respect_selection() {
589        let mut batch = MaterializedBatch::from_rows(vec![row_i64(1), row_i64(2), row_i64(3)]);
590        batch.set_selection(SelectionVector {
591            indices: vec![2, 0, 1],
592        });
593        let sliced = batch.slice_rows(1, 5);
594        assert_eq!(sliced.to_rows(), vec![row_i64(1), row_i64(2)]);
595        let gathered = batch.gather(&[0, 0, 2]);
596        assert_eq!(gathered.to_rows(), vec![row_i64(3), row_i64(3), row_i64(2)]);
597        let empty = batch.slice_rows(10, 3);
598        assert!(empty.is_empty());
599    }
600
601    #[test]
602    fn append_column_extends_schema() {
603        let mut batch = MaterializedBatch::new(0, 0);
604        batch.append_column(vec![Value::BigInt(1), Value::BigInt(2)]);
605        batch.append_column(vec![Value::string("a"), Value::string("b")]);
606        assert_eq!(batch.num_columns(), 2);
607        assert_eq!(batch.num_rows(), 2);
608        assert_eq!(
609            batch.to_rows(),
610            vec![
611                vec![Value::BigInt(1), Value::string("a")],
612                vec![Value::BigInt(2), Value::string("b")],
613            ]
614        );
615    }
616
617    #[test]
618    fn hash_rows_partition_matches_row_set_equality() {
619        use crate::value::NullType;
620        use std::collections::HashSet;
621        let rows = vec![
622            vec![Value::BigInt(1), Value::string("k")],
623            vec![Value::BigInt(1), Value::string("k")],
624            vec![Value::BigInt(2), Value::string("k")],
625            vec![Value::Null(NullType::Null), Value::string("k")],
626            vec![Value::Null(NullType::Null), Value::string("k")],
627            vec![Value::Double(f64::NAN), Value::Int(1)],
628            vec![Value::Int(1), Value::BigInt(1)],
629        ];
630        let batch = MaterializedBatch::from_rows(rows.clone());
631        let hashes = batch.hash_rows(&[]);
632        // Equal rows share a hash.
633        assert_eq!(hashes[0], hashes[1]);
634        assert_eq!(hashes[3], hashes[4]);
635        // Distinct rows (new test): at least one differs; NaN rows hash
636        // deterministically (same bit pattern -> same hash).
637        assert_ne!(hashes[0], hashes[2]);
638        let dup = MaterializedBatch::from_rows(vec![vec![Value::Double(f64::NAN), Value::Int(1)]]);
639        assert_eq!(dup.hash_rows(&[])[0], hashes[5]);
640        // Column-subset hashing: rows differing only outside `cols` collide.
641        let sub = batch.hash_rows(&[0]);
642        assert_eq!(sub[0], sub[1]);
643        // Reference partition check: HashSet equality classes are respected
644        // (equal HashSet members always share a hash here).
645        let mut set: HashSet<Vec<Value>> = HashSet::new();
646        for (i, row) in rows.iter().enumerate() {
647            if !set.contains(row) {
648                set.insert(row.clone());
649                for (j, other) in rows.iter().enumerate() {
650                    if row == other {
651                        assert_eq!(hashes[i], hashes[j], "equal rows must share hash");
652                    }
653                }
654            }
655        }
656    }
657}