Skip to main content

dag_ml_data_core/
buffer.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4
5use crate::content_hash::StreamingHasher;
6use crate::coordinator::CoordinatorRelationSet;
7use crate::error::{DataError, Result};
8use crate::handle::{CoordinatorFeatureBlock, CoordinatorFeatureBlockF64, CoordinatorFeatureTable};
9use crate::ids::{ObservationId, RepresentationId, SourceId};
10
11pub const NUMERIC_FEATURE_BUFFER_MANIFEST_SCHEMA_VERSION: u32 = 1;
12
13#[derive(Clone, Debug, PartialEq)]
14pub struct NumericFeatureBuffer {
15    pub feature_set_id: String,
16    pub representation_id: RepresentationId,
17    pub feature_names: Vec<String>,
18    pub observation_ids: Vec<ObservationId>,
19    columns: Vec<Vec<Option<f64>>>,
20    row_index_by_observation: BTreeMap<ObservationId, usize>,
21}
22
23#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
24pub struct NumericFeatureBufferManifest {
25    pub schema_version: u32,
26    pub feature_set_id: String,
27    pub representation_id: RepresentationId,
28    pub feature_names: Vec<String>,
29    pub observation_ids: Vec<ObservationId>,
30    pub row_count: usize,
31    pub feature_count: usize,
32    pub value_count: usize,
33    pub estimated_value_bytes: usize,
34    pub buffer_fingerprint: String,
35}
36
37#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
38pub struct NumericFeatureBufferBinding {
39    pub feature_set_id: String,
40    pub representation_id: RepresentationId,
41    pub source_ids: Vec<SourceId>,
42    pub row_count: usize,
43    pub feature_count: usize,
44    pub buffer_fingerprint: String,
45}
46
47#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
48pub struct NumericFeatureMatrixF64 {
49    pub feature_set_id: String,
50    pub representation_id: RepresentationId,
51    pub feature_names: Vec<String>,
52    pub observation_ids: Vec<ObservationId>,
53    pub values: Vec<f64>,
54    #[serde(default)]
55    pub validity_mask: Option<Vec<bool>>,
56}
57
58#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
59pub struct NumericFeatureMatrixF64Columnar {
60    pub feature_set_id: String,
61    pub representation_id: RepresentationId,
62    pub feature_names: Vec<String>,
63    pub observation_ids: Vec<ObservationId>,
64    pub columns: Vec<Vec<f64>>,
65    #[serde(default)]
66    pub validity_masks: Option<Vec<Vec<bool>>>,
67}
68
69#[derive(Clone, Debug, Default, PartialEq)]
70pub struct NumericFeatureBufferStore {
71    buffers: BTreeMap<String, NumericFeatureBuffer>,
72}
73
74#[derive(Clone, Debug, Default, PartialEq)]
75pub struct NumericFeatureBufferArena {
76    store: NumericFeatureBufferStore,
77    data_bindings: BTreeMap<u64, BTreeMap<String, NumericFeatureBufferBinding>>,
78}
79
80impl NumericFeatureBuffer {
81    pub fn from_feature_table(table: CoordinatorFeatureTable) -> Result<Self> {
82        table.validate()?;
83        let row_count = table.rows.len();
84        let mut observation_ids = Vec::with_capacity(row_count);
85        let mut columns = (0..table.feature_names.len())
86            .map(|_| Vec::with_capacity(row_count))
87            .collect::<Vec<_>>();
88        let mut row_index_by_observation = BTreeMap::new();
89
90        for (row_idx, row) in table.rows.into_iter().enumerate() {
91            if row_index_by_observation
92                .insert(row.observation_id.clone(), row_idx)
93                .is_some()
94            {
95                return Err(DataError::Validation(format!(
96                    "feature table `{}` contains duplicate observation `{}`",
97                    table.feature_set_id, row.observation_id
98                )));
99            }
100            observation_ids.push(row.observation_id.clone());
101            for (feature_idx, value) in row.values.into_iter().enumerate() {
102                let feature_name = &table.feature_names[feature_idx];
103                columns[feature_idx].push(numeric_feature_value(
104                    &table.feature_set_id,
105                    &row.observation_id,
106                    feature_name,
107                    value,
108                )?);
109            }
110        }
111
112        Ok(Self {
113            feature_set_id: table.feature_set_id,
114            representation_id: table.representation_id,
115            feature_names: table.feature_names,
116            observation_ids,
117            columns,
118            row_index_by_observation,
119        })
120    }
121
122    pub fn from_f64_matrix(matrix: NumericFeatureMatrixF64) -> Result<Self> {
123        matrix.validate()?;
124        let row_count = matrix.observation_ids.len();
125        let feature_count = matrix.feature_names.len();
126        let mut columns = (0..feature_count)
127            .map(|_| Vec::with_capacity(row_count))
128            .collect::<Vec<_>>();
129        for row_idx in 0..row_count {
130            for (feature_idx, column) in columns.iter_mut().enumerate() {
131                let flat_idx = row_idx * feature_count + feature_idx;
132                let is_valid = matrix
133                    .validity_mask
134                    .as_ref()
135                    .map(|mask| mask[flat_idx])
136                    .unwrap_or(true);
137                column.push(is_valid.then_some(matrix.values[flat_idx]));
138            }
139        }
140        let row_index_by_observation = matrix
141            .observation_ids
142            .iter()
143            .cloned()
144            .enumerate()
145            .map(|(idx, observation_id)| (observation_id, idx))
146            .collect();
147
148        Ok(Self {
149            feature_set_id: matrix.feature_set_id,
150            representation_id: matrix.representation_id,
151            feature_names: matrix.feature_names,
152            observation_ids: matrix.observation_ids,
153            columns,
154            row_index_by_observation,
155        })
156    }
157
158    pub fn from_f64_column_matrix(matrix: NumericFeatureMatrixF64Columnar) -> Result<Self> {
159        matrix.validate()?;
160        let row_count = matrix.observation_ids.len();
161        let mut columns = Vec::with_capacity(matrix.feature_names.len());
162        for (feature_idx, column_values) in matrix.columns.into_iter().enumerate() {
163            let mask = matrix
164                .validity_masks
165                .as_ref()
166                .map(|masks| masks[feature_idx].as_slice());
167            let mut column = Vec::with_capacity(row_count);
168            for (row_idx, value) in column_values.into_iter().enumerate() {
169                let is_valid = mask.map(|mask| mask[row_idx]).unwrap_or(true);
170                column.push(is_valid.then_some(value));
171            }
172            columns.push(column);
173        }
174        let row_index_by_observation = matrix
175            .observation_ids
176            .iter()
177            .cloned()
178            .enumerate()
179            .map(|(idx, observation_id)| (observation_id, idx))
180            .collect();
181
182        Ok(Self {
183            feature_set_id: matrix.feature_set_id,
184            representation_id: matrix.representation_id,
185            feature_names: matrix.feature_names,
186            observation_ids: matrix.observation_ids,
187            columns,
188            row_index_by_observation,
189        })
190    }
191
192    pub fn row_count(&self) -> usize {
193        self.observation_ids.len()
194    }
195
196    pub fn feature_count(&self) -> usize {
197        self.feature_names.len()
198    }
199
200    pub fn value_count(&self) -> usize {
201        self.row_count() * self.feature_count()
202    }
203
204    pub fn estimated_value_bytes(&self) -> usize {
205        self.value_count() * std::mem::size_of::<f64>()
206    }
207
208    pub fn contains_observation(&self, observation_id: &ObservationId) -> bool {
209        self.row_index_by_observation.contains_key(observation_id)
210    }
211
212    /// Reproducibility fingerprint of the buffer's logical content as a 64-char
213    /// lowercase hex SHA-256.
214    ///
215    /// The preimage is fed to the hasher incrementally — there is NO
216    /// `O(n_cells)` intermediate byte vector or per-cell boxed value — so a
217    /// multi-million-cell matrix costs one fixed scratch buffer, not a
218    /// transient copy of the whole payload (the old `serde_json::to_vec` path).
219    ///
220    /// Preimage layout (every multi-byte field little-endian; every
221    /// variable-length field/collection length-prefixed so framing is
222    /// unambiguous):
223    ///
224    /// ```text
225    /// domain tag  b"dag-ml-data.numeric-feature-buffer.v2\0"   (fixed literal)
226    /// feature_set_id        : str   (u64 byte-len, then UTF-8)
227    /// representation_id     : str   (u64 byte-len, then UTF-8)
228    /// feature_names         : str[] (u64 count, then each str)
229    /// observation_ids       : str[] (u64 count, then each str)
230    /// n_rows                : u64   (fixed-width LE)
231    /// n_cols                : u64   (fixed-width LE)
232    /// cells row-major over (row, col):
233    ///   each cell           : 1 presence byte (1=Some, 0=None) + 8 LE f64 bytes
234    /// ```
235    ///
236    /// Rationale for the load-bearing choices:
237    /// * **Shape is hashed explicitly** as fixed-width `n_rows`/`n_cols`, and the
238    ///   cell stream is emitted in one fixed traversal, so a `2x3` and a `3x2`
239    ///   buffer with identical flat values produce different digests.
240    /// * **Storage is column-major** (`columns[feature][row]`); we deliberately
241    ///   traverse **row-major** over `(row, col)` and document that fixed order
242    ///   here. The constructors normalize both row-major and column-major typed
243    ///   inputs into the same `columns` storage, so the same logical matrix
244    ///   always streams in the same order regardless of input layout.
245    /// * **Masked cells** emit a `0` presence byte then 8 zero value bytes, a
246    ///   fixed 9-byte stride. The presence byte alone separates `None` from
247    ///   `Some(0.0)` (whose value bytes are also all zero), so a mask flip is
248    ///   never indistinguishable from a real value.
249    pub fn fingerprint(&self) -> Result<String> {
250        let mut hasher = StreamingHasher::new(b"dag-ml-data.numeric-feature-buffer.v2\0");
251        hasher.absorb_str(&self.feature_set_id);
252        hasher.absorb_str(self.representation_id.as_str());
253        hasher.absorb_str_collection(self.feature_names.iter().map(String::as_str));
254        hasher.absorb_str_collection(self.observation_ids.iter().map(ObservationId::as_str));
255        let n_rows = self.row_count();
256        let n_cols = self.feature_count();
257        hasher.absorb_len(n_rows);
258        hasher.absorb_len(n_cols);
259        for row in 0..n_rows {
260            for column in &self.columns {
261                hasher.absorb_cell(column[row]);
262            }
263        }
264        Ok(hasher.finalize_hex())
265    }
266
267    pub fn manifest(&self) -> Result<NumericFeatureBufferManifest> {
268        Ok(NumericFeatureBufferManifest {
269            schema_version: NUMERIC_FEATURE_BUFFER_MANIFEST_SCHEMA_VERSION,
270            feature_set_id: self.feature_set_id.clone(),
271            representation_id: self.representation_id.clone(),
272            feature_names: self.feature_names.clone(),
273            observation_ids: self.observation_ids.clone(),
274            row_count: self.row_count(),
275            feature_count: self.feature_count(),
276            value_count: self.value_count(),
277            estimated_value_bytes: self.estimated_value_bytes(),
278            buffer_fingerprint: self.fingerprint()?,
279        })
280    }
281
282    /// Returns the buffer's contents as a `NumericFeatureMatrixF64Columnar`,
283    /// suitable for binary persistence or pass-through to other typed-input
284    /// constructors. The output is the inverse of
285    /// `NumericFeatureBuffer::from_f64_column_matrix`: a round-trip through
286    /// this method and the constructor preserves the buffer fingerprint.
287    pub fn to_f64_column_matrix(&self) -> NumericFeatureMatrixF64Columnar {
288        let row_count = self.row_count();
289        let mut columns = Vec::with_capacity(self.columns.len());
290        let mut masks = Vec::with_capacity(self.columns.len());
291        let mut any_null = false;
292        for column in &self.columns {
293            let mut values = Vec::with_capacity(row_count);
294            let mut mask = Vec::with_capacity(row_count);
295            for cell in column {
296                match cell {
297                    Some(value) => {
298                        values.push(*value);
299                        mask.push(true);
300                    }
301                    None => {
302                        values.push(0.0);
303                        mask.push(false);
304                        any_null = true;
305                    }
306                }
307            }
308            columns.push(values);
309            masks.push(mask);
310        }
311        NumericFeatureMatrixF64Columnar {
312            feature_set_id: self.feature_set_id.clone(),
313            representation_id: self.representation_id.clone(),
314            feature_names: self.feature_names.clone(),
315            observation_ids: self.observation_ids.clone(),
316            columns,
317            validity_masks: any_null.then_some(masks),
318        }
319    }
320
321    pub fn selected_indices(&self, columns: Option<&[String]>) -> Result<Vec<usize>> {
322        let index_by_name = self
323            .feature_names
324            .iter()
325            .enumerate()
326            .map(|(idx, name)| (name, idx))
327            .collect::<BTreeMap<_, _>>();
328        let indices = if let Some(columns) = columns {
329            let mut seen = BTreeSet::new();
330            columns
331                .iter()
332                .map(|column| {
333                    if !seen.insert(column) {
334                        return Err(DataError::Validation(format!(
335                            "feature table `{}` selected duplicate feature column `{}`",
336                            self.feature_set_id, column
337                        )));
338                    }
339                    index_by_name.get(column).copied().ok_or_else(|| {
340                        DataError::Validation(format!(
341                            "feature table `{}` has no feature column `{}`",
342                            self.feature_set_id, column
343                        ))
344                    })
345                })
346                .collect::<Result<Vec<_>>>()?
347        } else {
348            (0..self.feature_names.len()).collect()
349        };
350        if indices.is_empty() {
351            return Err(DataError::Validation(format!(
352                "feature table `{}` selected no feature columns",
353                self.feature_set_id
354            )));
355        }
356        Ok(indices)
357    }
358
359    pub fn project_relations(
360        &self,
361        relations: &CoordinatorRelationSet,
362        source_id: Option<&SourceId>,
363        columns: Option<&[String]>,
364    ) -> Result<CoordinatorFeatureBlock> {
365        relations.validate()?;
366        let selected_indices = self.selected_indices(columns)?;
367        let mut observation_ids = Vec::with_capacity(relations.records.len());
368        let mut sample_ids = Vec::with_capacity(relations.records.len());
369        let mut values = Vec::with_capacity(relations.records.len());
370
371        for relation in relations.records.iter().filter(|relation| {
372            source_id
373                .map(|source_id| relation.source_id.as_ref() == Some(source_id))
374                .unwrap_or(true)
375        }) {
376            let row_idx = self
377                .row_index_by_observation
378                .get(&relation.observation_id)
379                .ok_or_else(|| {
380                    DataError::Validation(format!(
381                        "feature table `{}` has no row for observation `{}`",
382                        self.feature_set_id, relation.observation_id
383                    ))
384                })?;
385            observation_ids.push(relation.observation_id.clone());
386            sample_ids.push(relation.sample_id.clone());
387            values.push(
388                selected_indices
389                    .iter()
390                    .map(|feature_idx| {
391                        self.columns[*feature_idx][*row_idx]
392                            .map_or(serde_json::Value::Null, serde_json::Value::from)
393                    })
394                    .collect(),
395            );
396        }
397
398        Ok(CoordinatorFeatureBlock {
399            feature_set_id: self.feature_set_id.clone(),
400            representation_id: self.representation_id.clone(),
401            feature_names: selected_indices
402                .iter()
403                .map(|idx| self.feature_names[*idx].clone())
404                .collect(),
405            observation_ids,
406            sample_ids,
407            values,
408        })
409    }
410
411    /// Like [`Self::project_relations`] but flattens the cells row-major
412    /// straight into one `Vec<f64>` — no `rows × cols` boxed
413    /// [`serde_json::Value`]s. This is the hot path for large numeric blocks
414    /// crossing a typed-array binding boundary. Masked (invalid) cells are an
415    /// error: the typed projection has no `Null` to project them into.
416    pub fn project_relations_f64(
417        &self,
418        relations: &CoordinatorRelationSet,
419        source_id: Option<&SourceId>,
420        columns: Option<&[String]>,
421    ) -> Result<CoordinatorFeatureBlockF64> {
422        relations.validate()?;
423        let selected_indices = self.selected_indices(columns)?;
424        let mut observation_ids = Vec::with_capacity(relations.records.len());
425        let mut sample_ids = Vec::with_capacity(relations.records.len());
426        let mut values = Vec::with_capacity(relations.records.len() * selected_indices.len());
427
428        for relation in relations.records.iter().filter(|relation| {
429            source_id
430                .map(|source_id| relation.source_id.as_ref() == Some(source_id))
431                .unwrap_or(true)
432        }) {
433            let row_idx = self
434                .row_index_by_observation
435                .get(&relation.observation_id)
436                .ok_or_else(|| {
437                    DataError::Validation(format!(
438                        "feature table `{}` has no row for observation `{}`",
439                        self.feature_set_id, relation.observation_id
440                    ))
441                })?;
442            for feature_idx in &selected_indices {
443                values.push(self.columns[*feature_idx][*row_idx].ok_or_else(|| {
444                    DataError::Validation(format!(
445                        "feature table `{}` observation `{}` feature `{}` is masked; the typed f64 projection requires fully numeric values",
446                        self.feature_set_id,
447                        relation.observation_id,
448                        self.feature_names[*feature_idx]
449                    ))
450                })?);
451            }
452            observation_ids.push(relation.observation_id.clone());
453            sample_ids.push(relation.sample_id.clone());
454        }
455
456        Ok(CoordinatorFeatureBlockF64 {
457            feature_set_id: self.feature_set_id.clone(),
458            representation_id: self.representation_id.clone(),
459            feature_names: selected_indices
460                .iter()
461                .map(|idx| self.feature_names[*idx].clone())
462                .collect(),
463            observation_ids,
464            sample_ids,
465            values,
466        })
467    }
468
469    fn binding_for_sources(
470        &self,
471        source_ids: Vec<SourceId>,
472    ) -> Result<NumericFeatureBufferBinding> {
473        Ok(NumericFeatureBufferBinding {
474            feature_set_id: self.feature_set_id.clone(),
475            representation_id: self.representation_id.clone(),
476            source_ids,
477            row_count: self.row_count(),
478            feature_count: self.feature_count(),
479            buffer_fingerprint: self.fingerprint()?,
480        })
481    }
482}
483
484impl NumericFeatureMatrixF64 {
485    pub fn validate(&self) -> Result<()> {
486        validate_feature_shape(
487            &self.feature_set_id,
488            &self.feature_names,
489            &self.observation_ids,
490        )?;
491        let expected_values = self.feature_names.len() * self.observation_ids.len();
492        if self.values.len() != expected_values {
493            return Err(DataError::Validation(format!(
494                "f64 feature matrix `{}` has {} values for {} observations x {} features",
495                self.feature_set_id,
496                self.values.len(),
497                self.observation_ids.len(),
498                self.feature_names.len()
499            )));
500        }
501        if let Some(validity_mask) = &self.validity_mask {
502            if validity_mask.len() != expected_values {
503                return Err(DataError::Validation(format!(
504                    "f64 feature matrix `{}` validity_mask has {} values for {} observations x {} features",
505                    self.feature_set_id,
506                    validity_mask.len(),
507                    self.observation_ids.len(),
508                    self.feature_names.len()
509                )));
510            }
511        }
512        for (idx, value) in self.values.iter().enumerate() {
513            let is_valid = self
514                .validity_mask
515                .as_ref()
516                .map(|mask| mask[idx])
517                .unwrap_or(true);
518            if is_valid && !value.is_finite() {
519                return Err(DataError::Validation(format!(
520                    "f64 feature matrix `{}` value {} is not finite",
521                    self.feature_set_id, idx
522                )));
523            }
524        }
525        Ok(())
526    }
527}
528
529impl NumericFeatureMatrixF64Columnar {
530    pub fn validate(&self) -> Result<()> {
531        validate_feature_shape(
532            &self.feature_set_id,
533            &self.feature_names,
534            &self.observation_ids,
535        )?;
536        if self.columns.len() != self.feature_names.len() {
537            return Err(DataError::Validation(format!(
538                "f64 columnar feature matrix `{}` has {} columns for {} features",
539                self.feature_set_id,
540                self.columns.len(),
541                self.feature_names.len()
542            )));
543        }
544        if let Some(validity_masks) = &self.validity_masks {
545            if validity_masks.len() != self.feature_names.len() {
546                return Err(DataError::Validation(format!(
547                    "f64 columnar feature matrix `{}` has {} validity_masks for {} features",
548                    self.feature_set_id,
549                    validity_masks.len(),
550                    self.feature_names.len()
551                )));
552            }
553        }
554        let row_count = self.observation_ids.len();
555        for (feature_idx, column) in self.columns.iter().enumerate() {
556            if column.len() != row_count {
557                return Err(DataError::Validation(format!(
558                    "f64 columnar feature matrix `{}` column {} has {} values for {} observations",
559                    self.feature_set_id,
560                    feature_idx,
561                    column.len(),
562                    row_count
563                )));
564            }
565            let mask = self
566                .validity_masks
567                .as_ref()
568                .map(|masks| masks[feature_idx].as_slice());
569            if let Some(mask) = mask {
570                if mask.len() != row_count {
571                    return Err(DataError::Validation(format!(
572                        "f64 columnar feature matrix `{}` column {} validity_mask has {} values for {} observations",
573                        self.feature_set_id,
574                        feature_idx,
575                        mask.len(),
576                        row_count
577                    )));
578                }
579            }
580            for (row_idx, value) in column.iter().enumerate() {
581                let is_valid = mask.map(|mask| mask[row_idx]).unwrap_or(true);
582                if is_valid && !value.is_finite() {
583                    return Err(DataError::Validation(format!(
584                        "f64 columnar feature matrix `{}` column {} row {} is not finite",
585                        self.feature_set_id, feature_idx, row_idx
586                    )));
587                }
588            }
589        }
590        Ok(())
591    }
592}
593
594impl NumericFeatureBufferStore {
595    pub fn new(buffers: BTreeMap<String, NumericFeatureBuffer>) -> Result<Self> {
596        for (feature_set_id, buffer) in &buffers {
597            if feature_set_id != &buffer.feature_set_id {
598                return Err(DataError::Validation(format!(
599                    "feature buffer store key `{feature_set_id}` does not match buffer feature_set_id `{}`",
600                    buffer.feature_set_id
601                )));
602            }
603        }
604        Ok(Self { buffers })
605    }
606
607    pub fn from_feature_tables(tables: Vec<CoordinatorFeatureTable>) -> Result<Self> {
608        let mut buffers = BTreeMap::new();
609        for table in tables {
610            let feature_set_id = table.feature_set_id.clone();
611            let buffer = NumericFeatureBuffer::from_feature_table(table)?;
612            if buffers.insert(feature_set_id.clone(), buffer).is_some() {
613                return Err(DataError::Validation(format!(
614                    "duplicate feature table `{feature_set_id}`"
615                )));
616            }
617        }
618        Self::new(buffers)
619    }
620
621    pub fn from_f64_matrices(matrices: Vec<NumericFeatureMatrixF64>) -> Result<Self> {
622        let mut buffers = BTreeMap::new();
623        for matrix in matrices {
624            let feature_set_id = matrix.feature_set_id.clone();
625            let buffer = NumericFeatureBuffer::from_f64_matrix(matrix)?;
626            if buffers.insert(feature_set_id.clone(), buffer).is_some() {
627                return Err(DataError::Validation(format!(
628                    "duplicate f64 feature matrix `{feature_set_id}`"
629                )));
630            }
631        }
632        Self::new(buffers)
633    }
634
635    pub fn from_f64_column_matrices(
636        matrices: Vec<NumericFeatureMatrixF64Columnar>,
637    ) -> Result<Self> {
638        let mut buffers = BTreeMap::new();
639        for matrix in matrices {
640            let feature_set_id = matrix.feature_set_id.clone();
641            let buffer = NumericFeatureBuffer::from_f64_column_matrix(matrix)?;
642            if buffers.insert(feature_set_id.clone(), buffer).is_some() {
643                return Err(DataError::Validation(format!(
644                    "duplicate f64 columnar feature matrix `{feature_set_id}`"
645                )));
646            }
647        }
648        Self::new(buffers)
649    }
650
651    pub fn is_empty(&self) -> bool {
652        self.buffers.is_empty()
653    }
654
655    pub fn len(&self) -> usize {
656        self.buffers.len()
657    }
658
659    pub fn get(&self, feature_set_id: &str) -> Option<&NumericFeatureBuffer> {
660        self.buffers.get(feature_set_id)
661    }
662
663    /// Deterministic iteration over `(feature_set_id, buffer)` pairs in
664    /// ascending `feature_set_id` order. Used by the file-store serializer
665    /// to produce byte-identical output for byte-identical stores.
666    pub fn iter(&self) -> impl Iterator<Item = (&String, &NumericFeatureBuffer)> {
667        self.buffers.iter()
668    }
669
670    pub fn manifests(&self) -> Result<Vec<NumericFeatureBufferManifest>> {
671        self.buffers
672            .values()
673            .map(NumericFeatureBuffer::manifest)
674            .collect()
675    }
676
677    pub fn bindings_for_relations(
678        &self,
679        relations: &CoordinatorRelationSet,
680        representation_id: &RepresentationId,
681    ) -> Result<Vec<NumericFeatureBufferBinding>> {
682        relations.validate()?;
683        let source_ids = relations
684            .records
685            .iter()
686            .filter_map(|relation| relation.source_id.as_ref())
687            .collect::<BTreeSet<_>>();
688
689        let mut bindings = Vec::new();
690        for buffer in self.buffers.values() {
691            if &buffer.representation_id != representation_id {
692                continue;
693            }
694            let mut covered_sources = Vec::new();
695            if source_ids.is_empty() {
696                if relations
697                    .records
698                    .iter()
699                    .all(|relation| buffer.contains_observation(&relation.observation_id))
700                {
701                    bindings.push(buffer.binding_for_sources(Vec::new())?);
702                }
703                continue;
704            }
705            for source_id in &source_ids {
706                let source_records = relations
707                    .records
708                    .iter()
709                    .filter(|relation| relation.source_id.as_ref() == Some(*source_id));
710                if source_records
711                    .clone()
712                    .all(|relation| buffer.contains_observation(&relation.observation_id))
713                {
714                    covered_sources.push((*source_id).clone());
715                }
716            }
717            if !covered_sources.is_empty() {
718                bindings.push(buffer.binding_for_sources(covered_sources)?);
719            }
720        }
721        Ok(bindings)
722    }
723
724    pub fn project_relations(
725        &self,
726        feature_set_id: &str,
727        relations: &CoordinatorRelationSet,
728        source_id: Option<&SourceId>,
729        columns: Option<&[String]>,
730    ) -> Result<CoordinatorFeatureBlock> {
731        let buffer = self.buffers.get(feature_set_id).ok_or_else(|| {
732            DataError::Validation(format!("unknown feature buffer `{feature_set_id}`"))
733        })?;
734        buffer.project_relations(relations, source_id, columns)
735    }
736
737    pub fn project_relations_f64(
738        &self,
739        feature_set_id: &str,
740        relations: &CoordinatorRelationSet,
741        source_id: Option<&SourceId>,
742        columns: Option<&[String]>,
743    ) -> Result<CoordinatorFeatureBlockF64> {
744        let buffer = self.buffers.get(feature_set_id).ok_or_else(|| {
745            DataError::Validation(format!("unknown feature buffer `{feature_set_id}`"))
746        })?;
747        buffer.project_relations_f64(relations, source_id, columns)
748    }
749}
750
751impl NumericFeatureBufferArena {
752    pub fn new(store: NumericFeatureBufferStore) -> Self {
753        Self {
754            store,
755            data_bindings: BTreeMap::new(),
756        }
757    }
758
759    pub fn manifests(&self) -> Result<Vec<NumericFeatureBufferManifest>> {
760        self.store.manifests()
761    }
762
763    pub fn bind_data_handle(
764        &mut self,
765        data_handle: u64,
766        relations: &CoordinatorRelationSet,
767        representation_id: &RepresentationId,
768    ) -> Result<Vec<NumericFeatureBufferBinding>> {
769        let bindings = self
770            .store
771            .bindings_for_relations(relations, representation_id)?;
772        self.data_bindings.insert(
773            data_handle,
774            bindings
775                .iter()
776                .cloned()
777                .map(|binding| (binding.feature_set_id.clone(), binding))
778                .collect(),
779        );
780        Ok(bindings)
781    }
782
783    pub fn release_data_handle(&mut self, data_handle: u64) -> bool {
784        self.data_bindings.remove(&data_handle).is_some()
785    }
786
787    pub fn bindings_for_data_handle(
788        &self,
789        data_handle: u64,
790    ) -> Result<Vec<NumericFeatureBufferBinding>> {
791        let bindings = self.data_bindings.get(&data_handle).ok_or_else(|| {
792            DataError::Validation(format!(
793                "data handle `{data_handle}` has no feature buffer bindings"
794            ))
795        })?;
796        Ok(bindings.values().cloned().collect())
797    }
798
799    pub fn project_bound_relations(
800        &self,
801        data_handle: u64,
802        feature_set_id: &str,
803        relations: &CoordinatorRelationSet,
804        source_id: Option<&SourceId>,
805        columns: Option<&[String]>,
806    ) -> Result<CoordinatorFeatureBlock> {
807        self.validate_bound_sources(data_handle, feature_set_id, relations, source_id)?;
808        self.store
809            .project_relations(feature_set_id, relations, source_id, columns)
810    }
811
812    pub fn project_bound_relations_f64(
813        &self,
814        data_handle: u64,
815        feature_set_id: &str,
816        relations: &CoordinatorRelationSet,
817        source_id: Option<&SourceId>,
818        columns: Option<&[String]>,
819    ) -> Result<CoordinatorFeatureBlockF64> {
820        self.validate_bound_sources(data_handle, feature_set_id, relations, source_id)?;
821        self.store
822            .project_relations_f64(feature_set_id, relations, source_id, columns)
823    }
824
825    fn validate_bound_sources(
826        &self,
827        data_handle: u64,
828        feature_set_id: &str,
829        relations: &CoordinatorRelationSet,
830        source_id: Option<&SourceId>,
831    ) -> Result<()> {
832        relations.validate()?;
833        let binding = self
834            .data_bindings
835            .get(&data_handle)
836            .and_then(|bindings| bindings.get(feature_set_id))
837            .ok_or_else(|| {
838                DataError::Validation(format!(
839                    "feature buffer `{feature_set_id}` is not bound to data handle `{data_handle}`"
840                ))
841            })?;
842        let relation_source_ids = relations
843            .records
844            .iter()
845            .filter_map(|relation| relation.source_id.as_ref())
846            .cloned()
847            .collect::<BTreeSet<_>>();
848        let required_source_ids = if let Some(source_id) = source_id {
849            if relation_source_ids.is_empty() || !relation_source_ids.contains(source_id) {
850                return Err(DataError::Validation(format!(
851                    "feature buffer `{feature_set_id}` source `{source_id}` is not present in view for data handle `{data_handle}`"
852                )));
853            }
854            vec![source_id.clone()]
855        } else {
856            relation_source_ids.into_iter().collect::<Vec<_>>()
857        };
858        for source_id in &required_source_ids {
859            if !binding.source_ids.contains(source_id) {
860                return Err(DataError::Validation(format!(
861                    "feature buffer `{feature_set_id}` is not bound to source `{source_id}` for data handle `{data_handle}`"
862                )));
863            }
864        }
865        Ok(())
866    }
867}
868
869fn numeric_feature_value(
870    feature_set_id: &str,
871    observation_id: &ObservationId,
872    feature_name: &str,
873    value: serde_json::Value,
874) -> Result<Option<f64>> {
875    match value {
876        serde_json::Value::Null => Ok(None),
877        serde_json::Value::Number(number) => number.as_f64().map(Some).ok_or_else(|| {
878            DataError::Validation(format!(
879                "feature table `{feature_set_id}` row `{observation_id}` feature `{feature_name}` contains a non-f64 numeric value"
880            ))
881        }),
882        _ => Err(DataError::Validation(format!(
883            "feature table `{feature_set_id}` row `{observation_id}` feature `{feature_name}` must be numeric or null"
884        ))),
885    }
886}
887
888fn validate_feature_shape(
889    feature_set_id: &str,
890    feature_names: &[String],
891    observation_ids: &[ObservationId],
892) -> Result<()> {
893    if feature_set_id.trim().is_empty() {
894        return Err(DataError::Validation("feature_set_id is empty".to_string()));
895    }
896    if feature_names.is_empty() {
897        return Err(DataError::Validation(format!(
898            "feature matrix `{feature_set_id}` contains no features"
899        )));
900    }
901    let mut seen_features = BTreeSet::new();
902    for feature_name in feature_names {
903        if feature_name.trim().is_empty() {
904            return Err(DataError::Validation(format!(
905                "feature matrix `{feature_set_id}` contains an empty feature name"
906            )));
907        }
908        if !seen_features.insert(feature_name) {
909            return Err(DataError::Validation(format!(
910                "feature matrix `{feature_set_id}` contains duplicate feature `{feature_name}`"
911            )));
912        }
913    }
914    if observation_ids.is_empty() {
915        return Err(DataError::Validation(format!(
916            "feature matrix `{feature_set_id}` contains no observations"
917        )));
918    }
919    let mut seen_observations = BTreeSet::new();
920    for observation_id in observation_ids {
921        if !seen_observations.insert(observation_id) {
922            return Err(DataError::Validation(format!(
923                "feature matrix `{feature_set_id}` contains duplicate observation `{observation_id}`"
924            )));
925        }
926    }
927    Ok(())
928}
929
930#[cfg(test)]
931mod tests {
932    use super::*;
933    use crate::coordinator::CoordinatorRelation;
934    use crate::handle::CoordinatorFeatureRow;
935    use crate::ids::{SampleId, TargetId};
936
937    fn oid(value: &str) -> ObservationId {
938        ObservationId::new(value).unwrap()
939    }
940
941    fn sid(value: &str) -> SampleId {
942        SampleId::new(value).unwrap()
943    }
944
945    fn source(value: &str) -> SourceId {
946        SourceId::new(value).unwrap()
947    }
948
949    fn table() -> CoordinatorFeatureTable {
950        CoordinatorFeatureTable {
951            feature_set_id: "x".to_string(),
952            representation_id: RepresentationId::new("tabular_numeric").unwrap(),
953            feature_names: vec!["f0".to_string(), "f1".to_string()],
954            rows: vec![
955                CoordinatorFeatureRow {
956                    observation_id: oid("obs.s1.nir"),
957                    values: vec![serde_json::json!(1.0), serde_json::json!(10.0)],
958                },
959                CoordinatorFeatureRow {
960                    observation_id: oid("obs.s1.chem"),
961                    values: vec![serde_json::json!(2.0), serde_json::json!(20.0)],
962                },
963                CoordinatorFeatureRow {
964                    observation_id: oid("obs.s2.nir"),
965                    values: vec![serde_json::json!(3.0), serde_json::Value::Null],
966                },
967            ],
968        }
969    }
970
971    fn f64_matrix() -> NumericFeatureMatrixF64 {
972        NumericFeatureMatrixF64 {
973            feature_set_id: "x".to_string(),
974            representation_id: RepresentationId::new("tabular_numeric").unwrap(),
975            feature_names: vec!["f0".to_string(), "f1".to_string()],
976            observation_ids: vec![oid("obs.s1.nir"), oid("obs.s1.chem"), oid("obs.s2.nir")],
977            values: vec![1.0, 10.0, 2.0, 20.0, 3.0, 0.0],
978            validity_mask: Some(vec![true, true, true, true, true, false]),
979        }
980    }
981
982    fn f64_column_matrix() -> NumericFeatureMatrixF64Columnar {
983        NumericFeatureMatrixF64Columnar {
984            feature_set_id: "x".to_string(),
985            representation_id: RepresentationId::new("tabular_numeric").unwrap(),
986            feature_names: vec!["f0".to_string(), "f1".to_string()],
987            observation_ids: vec![oid("obs.s1.nir"), oid("obs.s1.chem"), oid("obs.s2.nir")],
988            columns: vec![vec![1.0, 2.0, 3.0], vec![10.0, 20.0, 0.0]],
989            validity_masks: Some(vec![vec![true, true, true], vec![true, true, false]]),
990        }
991    }
992
993    fn relations() -> CoordinatorRelationSet {
994        CoordinatorRelationSet {
995            records: vec![
996                relation("obs.s2.nir", "S2", "nir"),
997                relation("obs.s1.nir", "S1", "nir"),
998                relation("obs.s1.chem", "S1", "chem"),
999            ],
1000        }
1001    }
1002
1003    fn relation(observation_id: &str, sample_id: &str, source_id: &str) -> CoordinatorRelation {
1004        CoordinatorRelation {
1005            observation_id: oid(observation_id),
1006            sample_id: sid(sample_id),
1007            target_id: Some(TargetId::new("y").unwrap()),
1008            group_id: None,
1009            origin_sample_id: None,
1010            source_id: Some(source(source_id)),
1011            is_augmented: false,
1012            excluded: false,
1013            metadata: BTreeMap::new(),
1014            tags: Vec::new(),
1015        }
1016    }
1017
1018    #[test]
1019    fn projects_view_relations_from_columnar_numeric_buffer() {
1020        let buffer = NumericFeatureBuffer::from_feature_table(table()).unwrap();
1021        assert_eq!(buffer.row_count(), 3);
1022        assert_eq!(buffer.feature_count(), 2);
1023        assert_eq!(buffer.value_count(), 6);
1024        let manifest = buffer.manifest().unwrap();
1025        assert_eq!(
1026            manifest.schema_version,
1027            NUMERIC_FEATURE_BUFFER_MANIFEST_SCHEMA_VERSION
1028        );
1029        assert_eq!(manifest.row_count, 3);
1030        assert_eq!(manifest.feature_count, 2);
1031        assert_eq!(manifest.value_count, 6);
1032        assert_eq!(manifest.estimated_value_bytes, 48);
1033        assert_eq!(manifest.buffer_fingerprint.len(), 64);
1034
1035        let block = buffer
1036            .project_relations(
1037                &relations(),
1038                Some(&source("nir")),
1039                Some(&["f1".to_string()]),
1040            )
1041            .unwrap();
1042
1043        assert_eq!(block.feature_set_id, "x");
1044        assert_eq!(block.feature_names, vec!["f1".to_string()]);
1045        assert_eq!(
1046            block.observation_ids,
1047            vec![oid("obs.s2.nir"), oid("obs.s1.nir")]
1048        );
1049        assert_eq!(block.sample_ids, vec![sid("S2"), sid("S1")]);
1050        assert_eq!(
1051            block.values,
1052            vec![vec![serde_json::Value::Null], vec![serde_json::json!(10.0)]]
1053        );
1054    }
1055
1056    #[test]
1057    fn typed_f64_projection_matches_boxed_projection() {
1058        // Unmasked buffer: project_relations_f64 must yield the boxed block's
1059        // rows concatenated row-major, with identical ids/names/ordering.
1060        let mut matrix = f64_matrix();
1061        matrix.validity_mask = None;
1062        matrix.values = vec![1.0, 10.0, 2.0, 20.0, 3.0, 30.0];
1063        let buffer = NumericFeatureBuffer::from_f64_matrix(matrix).unwrap();
1064
1065        let boxed = buffer.project_relations(&relations(), None, None).unwrap();
1066        let typed = buffer
1067            .project_relations_f64(&relations(), None, None)
1068            .unwrap();
1069
1070        assert_eq!(typed.feature_set_id, boxed.feature_set_id);
1071        assert_eq!(typed.representation_id, boxed.representation_id);
1072        assert_eq!(typed.feature_names, boxed.feature_names);
1073        assert_eq!(typed.observation_ids, boxed.observation_ids);
1074        assert_eq!(typed.sample_ids, boxed.sample_ids);
1075        let expected: Vec<f64> = boxed
1076            .values
1077            .iter()
1078            .flat_map(|row| row.iter().map(|v| v.as_f64().unwrap()))
1079            .collect();
1080        assert_eq!(typed.values, expected);
1081    }
1082
1083    #[test]
1084    fn typed_f64_projection_rejects_masked_cells() {
1085        // f64_matrix() masks obs.s2.nir/f1 — the boxed path projects Null, the
1086        // typed path must refuse (it has no Null to project into).
1087        let buffer = NumericFeatureBuffer::from_f64_matrix(f64_matrix()).unwrap();
1088        let error = buffer
1089            .project_relations_f64(&relations(), None, None)
1090            .unwrap_err();
1091        assert!(format!("{error}").contains("is masked"));
1092    }
1093
1094    #[test]
1095    fn rejects_duplicate_selected_columns() {
1096        let buffer = NumericFeatureBuffer::from_feature_table(table()).unwrap();
1097        let error = buffer
1098            .selected_indices(Some(&["f0".to_string(), "f0".to_string()]))
1099            .unwrap_err();
1100        assert!(format!("{error}").contains("duplicate feature column"));
1101    }
1102
1103    #[test]
1104    fn rejects_missing_observation_in_projection() {
1105        let buffer = NumericFeatureBuffer::from_feature_table(table()).unwrap();
1106        let missing = CoordinatorRelationSet {
1107            records: vec![relation("obs.missing", "S9", "nir")],
1108        };
1109        let error = buffer.project_relations(&missing, None, None).unwrap_err();
1110        assert!(format!("{error}").contains("has no row for observation"));
1111    }
1112
1113    #[test]
1114    fn builds_columnar_buffer_from_row_major_f64_matrix() {
1115        let buffer = NumericFeatureBuffer::from_f64_matrix(f64_matrix()).unwrap();
1116        assert_eq!(buffer.row_count(), 3);
1117        assert_eq!(buffer.feature_count(), 2);
1118
1119        let block = buffer
1120            .project_relations(&relations(), Some(&source("nir")), None)
1121            .unwrap();
1122
1123        assert_eq!(
1124            block.observation_ids,
1125            vec![oid("obs.s2.nir"), oid("obs.s1.nir")]
1126        );
1127        assert_eq!(
1128            block.values,
1129            vec![
1130                vec![serde_json::json!(3.0), serde_json::Value::Null],
1131                vec![serde_json::json!(1.0), serde_json::json!(10.0)],
1132            ]
1133        );
1134    }
1135
1136    #[test]
1137    fn rejects_malformed_f64_matrix_shape() {
1138        let mut matrix = f64_matrix();
1139        matrix.values.pop();
1140        let error = NumericFeatureBuffer::from_f64_matrix(matrix).unwrap_err();
1141        assert!(format!("{error}").contains("has 5 values"));
1142
1143        let mut matrix = f64_matrix();
1144        matrix.validity_mask = Some(vec![true]);
1145        let error = NumericFeatureBuffer::from_f64_matrix(matrix).unwrap_err();
1146        assert!(format!("{error}").contains("validity_mask has 1 values"));
1147
1148        let mut matrix = f64_matrix();
1149        matrix.values[0] = f64::NAN;
1150        let error = NumericFeatureBuffer::from_f64_matrix(matrix).unwrap_err();
1151        assert!(format!("{error}").contains("value 0 is not finite"));
1152
1153        let mut matrix = f64_matrix();
1154        matrix.values[5] = f64::NAN;
1155        assert!(NumericFeatureBuffer::from_f64_matrix(matrix).is_ok());
1156    }
1157
1158    #[test]
1159    fn store_manifests_and_projects_by_feature_set_id() {
1160        let store = NumericFeatureBufferStore::from_feature_tables(vec![table()]).unwrap();
1161        assert_eq!(store.len(), 1);
1162        assert!(!store.is_empty());
1163
1164        let manifests = store.manifests().unwrap();
1165        assert_eq!(manifests.len(), 1);
1166        assert_eq!(manifests[0].feature_set_id, "x");
1167        assert_eq!(manifests[0].feature_names, vec!["f0", "f1"]);
1168
1169        let block = store
1170            .project_relations("x", &relations(), Some(&source("chem")), None)
1171            .unwrap();
1172        assert_eq!(block.observation_ids, vec![oid("obs.s1.chem")]);
1173        assert_eq!(
1174            block.values,
1175            vec![vec![serde_json::json!(2.0), serde_json::json!(20.0)]]
1176        );
1177    }
1178
1179    #[test]
1180    fn store_derives_source_bindings_from_relation_coverage() {
1181        let store = NumericFeatureBufferStore::from_feature_tables(vec![table()]).unwrap();
1182        let bindings = store
1183            .bindings_for_relations(
1184                &relations(),
1185                &RepresentationId::new("tabular_numeric").unwrap(),
1186            )
1187            .unwrap();
1188
1189        assert_eq!(bindings.len(), 1);
1190        assert_eq!(bindings[0].feature_set_id, "x");
1191        assert_eq!(bindings[0].source_ids, vec![source("chem"), source("nir")]);
1192        assert_eq!(bindings[0].row_count, 3);
1193        assert_eq!(bindings[0].feature_count, 2);
1194        assert_eq!(bindings[0].buffer_fingerprint.len(), 64);
1195
1196        let wrong_representation = store
1197            .bindings_for_relations(
1198                &relations(),
1199                &RepresentationId::new("dense_signal").unwrap(),
1200            )
1201            .unwrap();
1202        assert!(wrong_representation.is_empty());
1203    }
1204
1205    #[test]
1206    fn store_accepts_typed_f64_matrices() {
1207        let store = NumericFeatureBufferStore::from_f64_matrices(vec![f64_matrix()]).unwrap();
1208        let manifests = store.manifests().unwrap();
1209
1210        assert_eq!(manifests.len(), 1);
1211        assert_eq!(manifests[0].feature_set_id, "x");
1212        assert_eq!(manifests[0].value_count, 6);
1213
1214        let error = NumericFeatureBufferStore::from_f64_matrices(vec![f64_matrix(), f64_matrix()])
1215            .unwrap_err();
1216        assert!(format!("{error}").contains("duplicate f64 feature matrix"));
1217    }
1218
1219    #[test]
1220    fn arena_binds_projects_and_releases_data_handle_buffers() {
1221        let store = NumericFeatureBufferStore::from_feature_tables(vec![table()]).unwrap();
1222        let mut arena = NumericFeatureBufferArena::new(store);
1223        let bindings = arena
1224            .bind_data_handle(
1225                7,
1226                &relations(),
1227                &RepresentationId::new("tabular_numeric").unwrap(),
1228            )
1229            .unwrap();
1230
1231        assert_eq!(bindings.len(), 1);
1232        assert_eq!(arena.bindings_for_data_handle(7).unwrap(), bindings);
1233
1234        let block = arena
1235            .project_bound_relations(7, "x", &relations(), Some(&source("nir")), None)
1236            .unwrap();
1237        assert_eq!(
1238            block.observation_ids,
1239            vec![oid("obs.s2.nir"), oid("obs.s1.nir")]
1240        );
1241
1242        let error = arena
1243            .project_bound_relations(8, "x", &relations(), Some(&source("nir")), None)
1244            .unwrap_err();
1245        assert!(format!("{error}").contains("not bound to data handle"));
1246
1247        assert!(arena.release_data_handle(7));
1248        let error = arena.bindings_for_data_handle(7).unwrap_err();
1249        assert!(format!("{error}").contains("no feature buffer bindings"));
1250    }
1251
1252    #[test]
1253    fn store_refuses_duplicate_feature_sets() {
1254        let error =
1255            NumericFeatureBufferStore::from_feature_tables(vec![table(), table()]).unwrap_err();
1256        assert!(format!("{error}").contains("duplicate feature table"));
1257    }
1258
1259    #[test]
1260    fn builds_columnar_buffer_from_column_major_f64_matrix() {
1261        let buffer = NumericFeatureBuffer::from_f64_column_matrix(f64_column_matrix()).unwrap();
1262        assert_eq!(buffer.row_count(), 3);
1263        assert_eq!(buffer.feature_count(), 2);
1264
1265        let block = buffer
1266            .project_relations(&relations(), Some(&source("nir")), None)
1267            .unwrap();
1268
1269        assert_eq!(
1270            block.observation_ids,
1271            vec![oid("obs.s2.nir"), oid("obs.s1.nir")]
1272        );
1273        assert_eq!(
1274            block.values,
1275            vec![
1276                vec![serde_json::json!(3.0), serde_json::Value::Null],
1277                vec![serde_json::json!(1.0), serde_json::json!(10.0)],
1278            ]
1279        );
1280    }
1281
1282    #[test]
1283    fn column_major_and_row_major_produce_identical_buffer_fingerprints() {
1284        let row_major = NumericFeatureBuffer::from_f64_matrix(f64_matrix()).unwrap();
1285        let columnar = NumericFeatureBuffer::from_f64_column_matrix(f64_column_matrix()).unwrap();
1286        assert_eq!(
1287            row_major.fingerprint().unwrap(),
1288            columnar.fingerprint().unwrap()
1289        );
1290    }
1291
1292    /// Builds an `rows x cols` buffer whose flat row-major values are
1293    /// `0,1,2,...`, with generated feature / observation ids, no masking.
1294    fn dense_buffer(rows: usize, cols: usize) -> NumericFeatureBuffer {
1295        let columns = (0..cols)
1296            .map(|c| (0..rows).map(|r| (r * cols + c) as f64).collect::<Vec<_>>())
1297            .collect::<Vec<_>>();
1298        let matrix = NumericFeatureMatrixF64Columnar {
1299            feature_set_id: "x".to_string(),
1300            representation_id: RepresentationId::new("tabular_numeric").unwrap(),
1301            feature_names: (0..cols).map(|c| format!("f{c}")).collect(),
1302            observation_ids: (0..rows).map(|r| oid(&format!("obs.{r}"))).collect(),
1303            columns,
1304            validity_masks: None,
1305        };
1306        NumericFeatureBuffer::from_f64_column_matrix(matrix).unwrap()
1307    }
1308
1309    #[test]
1310    fn fingerprint_is_64_lowercase_hex() {
1311        let fp = NumericFeatureBuffer::from_feature_table(table())
1312            .unwrap()
1313            .fingerprint()
1314            .unwrap();
1315        assert_eq!(fp.len(), 64);
1316        assert!(fp
1317            .chars()
1318            .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()));
1319    }
1320
1321    #[test]
1322    fn fingerprint_is_deterministic_across_calls_and_clone() {
1323        let buffer = NumericFeatureBuffer::from_feature_table(table()).unwrap();
1324        let once = buffer.fingerprint().unwrap();
1325        let twice = buffer.fingerprint().unwrap();
1326        assert_eq!(once, twice, "same buffer hashed twice must match");
1327        let clone = buffer.clone();
1328        assert_eq!(
1329            once,
1330            clone.fingerprint().unwrap(),
1331            "a clone must fingerprint identically"
1332        );
1333    }
1334
1335    #[test]
1336    fn fingerprint_changes_when_a_single_cell_flips() {
1337        let baseline = f64_column_matrix();
1338        let base_fp = NumericFeatureBuffer::from_f64_column_matrix(baseline.clone())
1339            .unwrap()
1340            .fingerprint()
1341            .unwrap();
1342        let mut flipped = baseline;
1343        flipped.columns[0][0] += 1.0;
1344        let flipped_fp = NumericFeatureBuffer::from_f64_column_matrix(flipped)
1345            .unwrap()
1346            .fingerprint()
1347            .unwrap();
1348        assert_ne!(base_fp, flipped_fp);
1349    }
1350
1351    #[test]
1352    fn fingerprint_changes_when_a_feature_is_renamed() {
1353        let baseline = f64_column_matrix();
1354        let base_fp = NumericFeatureBuffer::from_f64_column_matrix(baseline.clone())
1355            .unwrap()
1356            .fingerprint()
1357            .unwrap();
1358        let mut renamed = baseline;
1359        renamed.feature_names[0] = "f0_renamed".to_string();
1360        let renamed_fp = NumericFeatureBuffer::from_f64_column_matrix(renamed)
1361            .unwrap()
1362            .fingerprint()
1363            .unwrap();
1364        assert_ne!(base_fp, renamed_fp);
1365    }
1366
1367    #[test]
1368    fn fingerprint_changes_when_observation_ids_are_reordered() {
1369        // Reorder rows AND their values together so the buffer stays a valid
1370        // permutation of the same logical rows; only row order changes.
1371        let baseline = f64_column_matrix();
1372        let base_fp = NumericFeatureBuffer::from_f64_column_matrix(baseline.clone())
1373            .unwrap()
1374            .fingerprint()
1375            .unwrap();
1376        let mut reordered = baseline.clone();
1377        reordered.observation_ids.swap(0, 2);
1378        for column in &mut reordered.columns {
1379            column.swap(0, 2);
1380        }
1381        if let Some(masks) = reordered.validity_masks.as_mut() {
1382            for mask in masks {
1383                mask.swap(0, 2);
1384            }
1385        }
1386        let reordered_fp = NumericFeatureBuffer::from_f64_column_matrix(reordered)
1387            .unwrap()
1388            .fingerprint()
1389            .unwrap();
1390        assert_ne!(base_fp, reordered_fp);
1391    }
1392
1393    #[test]
1394    fn fingerprint_distinguishes_transposed_shapes_with_identical_flat_values() {
1395        // 2x3 and 3x2 share the flat row-major value sequence 0..6 but differ in
1396        // shape. At the buffer level this is over-determined (the feature-name
1397        // and observation-id collection counts also differ); the focused proof
1398        // that the explicit n_rows/n_cols framing alone is load-bearing is
1399        // `shape_framing_alone_changes_digest`, which holds the cell stream
1400        // constant and varies only the shape integers.
1401        let two_by_three = dense_buffer(2, 3).fingerprint().unwrap();
1402        let three_by_two = dense_buffer(3, 2).fingerprint().unwrap();
1403        assert_ne!(two_by_three, three_by_two);
1404    }
1405
1406    #[test]
1407    fn shape_framing_alone_changes_digest() {
1408        // Reproduce just the shape + cell portion of the buffer preimage with
1409        // the SAME six cells but transposed (n_rows, n_cols), proving the shape
1410        // integers are what separate 2x3 from 3x2 — independent of names/ids.
1411        let cells: [Option<f64>; 6] = [
1412            Some(0.0),
1413            Some(1.0),
1414            Some(2.0),
1415            Some(3.0),
1416            Some(4.0),
1417            Some(5.0),
1418        ];
1419        let digest = |rows: u64, cols: u64| {
1420            let mut hasher = StreamingHasher::new(b"shape-probe\0");
1421            hasher.absorb_u64(rows);
1422            hasher.absorb_u64(cols);
1423            for cell in cells {
1424                hasher.absorb_cell(cell);
1425            }
1426            hasher.finalize_hex()
1427        };
1428        assert_ne!(digest(2, 3), digest(3, 2));
1429    }
1430
1431    #[test]
1432    fn fingerprint_distinguishes_masked_cell_from_real_zero() {
1433        // None vs Some(0.0): the value bytes are identical (all zero); only the
1434        // presence byte separates them.
1435        let masked = NumericFeatureMatrixF64Columnar {
1436            feature_set_id: "x".to_string(),
1437            representation_id: RepresentationId::new("tabular_numeric").unwrap(),
1438            feature_names: vec!["f0".to_string()],
1439            observation_ids: vec![oid("obs.0")],
1440            columns: vec![vec![0.0]],
1441            validity_masks: Some(vec![vec![false]]),
1442        };
1443        let real_zero = NumericFeatureMatrixF64Columnar {
1444            feature_set_id: "x".to_string(),
1445            representation_id: RepresentationId::new("tabular_numeric").unwrap(),
1446            feature_names: vec!["f0".to_string()],
1447            observation_ids: vec![oid("obs.0")],
1448            columns: vec![vec![0.0]],
1449            validity_masks: None,
1450        };
1451        let masked_fp = NumericFeatureBuffer::from_f64_column_matrix(masked)
1452            .unwrap()
1453            .fingerprint()
1454            .unwrap();
1455        let zero_fp = NumericFeatureBuffer::from_f64_column_matrix(real_zero)
1456            .unwrap()
1457            .fingerprint()
1458            .unwrap();
1459        assert_ne!(masked_fp, zero_fp);
1460    }
1461
1462    #[test]
1463    fn fingerprint_changes_when_representation_changes() {
1464        let baseline = f64_column_matrix();
1465        let base_fp = NumericFeatureBuffer::from_f64_column_matrix(baseline.clone())
1466            .unwrap()
1467            .fingerprint()
1468            .unwrap();
1469        let mut other = baseline;
1470        other.representation_id = RepresentationId::new("dense_signal").unwrap();
1471        let other_fp = NumericFeatureBuffer::from_f64_column_matrix(other)
1472            .unwrap()
1473            .fingerprint()
1474            .unwrap();
1475        assert_ne!(base_fp, other_fp);
1476    }
1477
1478    #[test]
1479    #[ignore = "perf sanity probe; run with --release --ignored --nocapture"]
1480    fn fingerprint_large_buffer_under_500ms() {
1481        // The historical serde_json path cost ~2.8 s on a 3021x1050 matrix in a
1482        // WASM context (a ~31 MB transient allocation). Optimized native must be
1483        // far under 500 ms with the streamed hash. The 500 ms budget is asserted
1484        // only in optimized builds: an unoptimized `cargo test` runs the sha2
1485        // compression with overflow checks and no SIMD, so its wall time is not
1486        // representative of "native" performance and would be a false failure.
1487        // The number is always printed.
1488        let rows = 3021usize;
1489        let cols = 1050usize;
1490        let columns = (0..cols)
1491            .map(|c| {
1492                (0..rows)
1493                    .map(|r| (r as f64) * 0.5 + (c as f64))
1494                    .collect::<Vec<_>>()
1495            })
1496            .collect::<Vec<_>>();
1497        let matrix = NumericFeatureMatrixF64Columnar {
1498            feature_set_id: "big".to_string(),
1499            representation_id: RepresentationId::new("tabular_numeric").unwrap(),
1500            feature_names: (0..cols).map(|c| format!("f{c}")).collect(),
1501            observation_ids: (0..rows).map(|r| oid(&format!("obs.{r}"))).collect(),
1502            columns,
1503            validity_masks: None,
1504        };
1505        let buffer = NumericFeatureBuffer::from_f64_column_matrix(matrix).unwrap();
1506        let start = std::time::Instant::now();
1507        let fp = buffer.fingerprint().unwrap();
1508        let elapsed = start.elapsed();
1509        println!(
1510            "fingerprint({rows}x{cols}) = {:.3} ms (fp={fp})",
1511            elapsed.as_secs_f64() * 1e3
1512        );
1513        assert_eq!(fp.len(), 64);
1514        if !cfg!(debug_assertions) {
1515            assert!(
1516                elapsed.as_millis() < 500,
1517                "fingerprint took {} ms (>= 500 ms budget)",
1518                elapsed.as_millis()
1519            );
1520        }
1521    }
1522
1523    #[test]
1524    fn rejects_malformed_columnar_f64_matrix_shape() {
1525        let mut matrix = f64_column_matrix();
1526        matrix.columns[0].pop();
1527        let error = NumericFeatureBuffer::from_f64_column_matrix(matrix).unwrap_err();
1528        assert!(format!("{error}").contains("column 0 has 2 values"));
1529
1530        let mut matrix = f64_column_matrix();
1531        matrix.columns.pop();
1532        let error = NumericFeatureBuffer::from_f64_column_matrix(matrix).unwrap_err();
1533        assert!(format!("{error}").contains("has 1 columns for 2 features"));
1534
1535        let mut matrix = f64_column_matrix();
1536        matrix.validity_masks = Some(vec![vec![true, true, true]]);
1537        let error = NumericFeatureBuffer::from_f64_column_matrix(matrix).unwrap_err();
1538        assert!(format!("{error}").contains("has 1 validity_masks for 2 features"));
1539
1540        let mut matrix = f64_column_matrix();
1541        if let Some(masks) = matrix.validity_masks.as_mut() {
1542            masks[0].pop();
1543        }
1544        let error = NumericFeatureBuffer::from_f64_column_matrix(matrix).unwrap_err();
1545        assert!(format!("{error}").contains("column 0 validity_mask has 2 values"));
1546
1547        let mut matrix = f64_column_matrix();
1548        matrix.columns[0][0] = f64::NAN;
1549        let error = NumericFeatureBuffer::from_f64_column_matrix(matrix).unwrap_err();
1550        assert!(format!("{error}").contains("column 0 row 0 is not finite"));
1551
1552        let mut matrix = f64_column_matrix();
1553        matrix.columns[1][2] = f64::NAN;
1554        assert!(NumericFeatureBuffer::from_f64_column_matrix(matrix).is_ok());
1555    }
1556
1557    #[test]
1558    fn store_accepts_typed_f64_column_matrices() {
1559        let store =
1560            NumericFeatureBufferStore::from_f64_column_matrices(vec![f64_column_matrix()]).unwrap();
1561        let manifests = store.manifests().unwrap();
1562
1563        assert_eq!(manifests.len(), 1);
1564        assert_eq!(manifests[0].feature_set_id, "x");
1565        assert_eq!(manifests[0].value_count, 6);
1566
1567        let error = NumericFeatureBufferStore::from_f64_column_matrices(vec![
1568            f64_column_matrix(),
1569            f64_column_matrix(),
1570        ])
1571        .unwrap_err();
1572        assert!(format!("{error}").contains("duplicate f64 columnar feature matrix"));
1573    }
1574}