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 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 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 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 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 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 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 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 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 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 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 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 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}