1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::{DataError, Result};
6use crate::ids::{GroupId, RepresentationId, SampleId, SourceId, TargetId, TypeId};
7
8#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10#[non_exhaustive]
11pub enum AxisKind {
12 Sample,
13 Feature,
14 Processing,
15 Time,
16 Height,
17 Width,
18 Channel,
19 Node,
20 Edge,
21 Variant,
22 Token,
23 Target,
24 Wavelength,
25 Wavenumber,
26 Frequency,
27 Depth,
28}
29
30#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
31#[serde(deny_unknown_fields)]
32pub struct AxisSpec {
33 pub name: String,
34 pub kind: AxisKind,
35 pub unit: Option<String>,
36 pub size: Option<usize>,
37 #[serde(default)]
38 pub variable: bool,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub coordinate: Option<CoordinateSpec>,
41}
42
43impl AxisSpec {
44 pub fn validate(&self) -> Result<()> {
45 if self.name.trim().is_empty() {
46 return Err(DataError::Validation("axis name is empty".to_string()));
47 }
48 if self.variable && self.size.is_some() {
49 return Err(DataError::Validation(format!(
50 "axis `{}` cannot be both variable and sized",
51 self.name
52 )));
53 }
54 if let Some(unit) = &self.unit {
55 if unit.trim().is_empty() {
56 return Err(DataError::Validation(format!(
57 "axis `{}` has an empty unit",
58 self.name
59 )));
60 }
61 }
62 if let Some(coordinate) = &self.coordinate {
63 coordinate.validate(&self.name, self.size, self.variable)?;
64 }
65 Ok(())
66 }
67}
68
69#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub enum CoordinateDType {
73 Numeric,
74 Categorical,
75 Datetime,
76}
77
78#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
81#[serde(tag = "kind", rename_all = "snake_case")]
82pub enum CoordinateValues {
83 Explicit { values: Vec<serde_json::Value> },
84 RegularGrid { start: f64, step: f64 },
85}
86
87#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
94pub struct CoordinateSpec {
95 pub dtype: CoordinateDType,
96 #[serde(default)]
97 pub ordered: bool,
98 pub values: CoordinateValues,
99}
100
101impl CoordinateSpec {
102 pub fn validate(&self, axis_name: &str, size: Option<usize>, variable: bool) -> Result<()> {
103 if variable {
104 return Err(DataError::Validation(format!(
105 "axis `{axis_name}` cannot carry coordinates while variable"
106 )));
107 }
108 match &self.values {
109 CoordinateValues::Explicit { values } => {
110 if values.is_empty() {
111 return Err(DataError::Validation(format!(
112 "axis `{axis_name}` has empty explicit coordinates"
113 )));
114 }
115 if let Some(size) = size {
116 if values.len() != size {
117 return Err(DataError::Validation(format!(
118 "axis `{axis_name}` has {} coordinates for size {size}",
119 values.len()
120 )));
121 }
122 }
123 self.validate_explicit(axis_name, values)?;
124 }
125 CoordinateValues::RegularGrid { start, step } => {
126 if self.dtype != CoordinateDType::Numeric {
127 return Err(DataError::Validation(format!(
128 "axis `{axis_name}` regular-grid coordinates require numeric dtype"
129 )));
130 }
131 if size.is_none() {
132 return Err(DataError::Validation(format!(
133 "axis `{axis_name}` regular-grid coordinates require a known axis size"
134 )));
135 }
136 if !start.is_finite() || !step.is_finite() {
137 return Err(DataError::Validation(format!(
138 "axis `{axis_name}` regular-grid start/step must be finite"
139 )));
140 }
141 if *step == 0.0 {
142 return Err(DataError::Validation(format!(
143 "axis `{axis_name}` regular-grid step must be non-zero"
144 )));
145 }
146 if !self.ordered {
147 return Err(DataError::Validation(format!(
148 "axis `{axis_name}` regular-grid coordinates are inherently ordered; set ordered=true"
149 )));
150 }
151 }
152 }
153 Ok(())
154 }
155
156 fn validate_explicit(&self, axis_name: &str, values: &[serde_json::Value]) -> Result<()> {
157 match self.dtype {
158 CoordinateDType::Numeric => {
159 let mut numbers = Vec::with_capacity(values.len());
160 for value in values {
161 let number = value
162 .as_f64()
163 .filter(|number| number.is_finite())
164 .ok_or_else(|| {
165 DataError::Validation(format!(
166 "axis `{axis_name}` numeric coordinate `{value}` is not a finite number"
167 ))
168 })?;
169 numbers.push(number);
170 }
171 if self.ordered {
172 require_strictly_monotonic(axis_name, &numbers, |left, right| {
173 left.partial_cmp(right)
174 })?;
175 }
176 }
177 CoordinateDType::Categorical => {
178 let mut seen = BTreeSet::new();
179 for value in values {
180 let label = value.as_str().filter(|label| !label.is_empty()).ok_or_else(|| {
181 DataError::Validation(format!(
182 "axis `{axis_name}` categorical coordinate `{value}` is not a non-empty string"
183 ))
184 })?;
185 if !seen.insert(label) {
186 return Err(DataError::Validation(format!(
187 "axis `{axis_name}` categorical coordinate `{label}` is duplicated"
188 )));
189 }
190 }
191 }
193 CoordinateDType::Datetime => {
194 let mut stamps = Vec::with_capacity(values.len());
195 for value in values {
196 let stamp = value.as_str().ok_or_else(|| {
197 DataError::Validation(format!(
198 "axis `{axis_name}` datetime coordinate `{value}` is not a string"
199 ))
200 })?;
201 if !is_rfc3339_utc_seconds(stamp) {
202 return Err(DataError::Validation(format!(
203 "axis `{axis_name}` datetime coordinate `{stamp}` is not canonical RFC 3339 UTC seconds (YYYY-MM-DDThh:mm:ssZ)"
204 )));
205 }
206 stamps.push(stamp.to_string());
207 }
208 if self.ordered {
209 require_strictly_monotonic(axis_name, &stamps, |left, right| {
212 Some(left.cmp(right))
213 })?;
214 }
215 }
216 }
217 Ok(())
218 }
219}
220
221fn require_strictly_monotonic<T>(
224 axis_name: &str,
225 values: &[T],
226 compare: impl Fn(&T, &T) -> Option<std::cmp::Ordering>,
227) -> Result<()> {
228 if values.len() < 2 {
229 return Ok(());
230 }
231 let first = compare(&values[1], &values[0]).ok_or_else(|| {
232 DataError::Validation(format!(
233 "axis `{axis_name}` ordered coordinates are not comparable"
234 ))
235 })?;
236 if first == std::cmp::Ordering::Equal {
237 return Err(DataError::Validation(format!(
238 "axis `{axis_name}` ordered coordinates must be strictly monotonic"
239 )));
240 }
241 for window in values.windows(2) {
242 let ordering = compare(&window[1], &window[0]).ok_or_else(|| {
243 DataError::Validation(format!(
244 "axis `{axis_name}` ordered coordinates are not comparable"
245 ))
246 })?;
247 if ordering != first {
248 return Err(DataError::Validation(format!(
249 "axis `{axis_name}` ordered coordinates must be strictly monotonic"
250 )));
251 }
252 }
253 Ok(())
254}
255
256fn is_rfc3339_utc_seconds(value: &str) -> bool {
258 let bytes = value.as_bytes();
259 if bytes.len() != 20 {
260 return false;
261 }
262 let digit_positions = [0, 1, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18];
263 if digit_positions
264 .iter()
265 .any(|position| !bytes[*position].is_ascii_digit())
266 {
267 return false;
268 }
269 if bytes[4] != b'-'
270 || bytes[7] != b'-'
271 || bytes[10] != b'T'
272 || bytes[13] != b':'
273 || bytes[16] != b':'
274 || bytes[19] != b'Z'
275 {
276 return false;
277 }
278 let field = |start: usize, end: usize| value[start..end].parse::<u32>().unwrap_or(u32::MAX);
279 let year = field(0, 4);
280 let month = field(5, 7);
281 let day = field(8, 10);
282 let hour = field(11, 13);
283 let minute = field(14, 16);
284 let second = field(17, 19);
285 if !(1..=12).contains(&month) || hour > 23 || minute > 59 || second > 59 {
286 return false;
287 }
288 let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
289 let days_in_month = match month {
290 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
291 4 | 6 | 9 | 11 => 30,
292 2 if leap => 29,
293 2 => 28,
294 _ => unreachable!("month already validated in 1..=12"),
295 };
296 (1..=days_in_month).contains(&day)
297}
298
299#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
300#[serde(rename_all = "snake_case")]
301#[non_exhaustive]
302pub enum SignalKind {
303 Absorbance,
304 Reflectance,
305 Transmittance,
306 LogReflectance,
307 Preprocessed,
308 Unknown,
309}
310
311impl SignalKind {
312 pub fn as_str(self) -> &'static str {
314 match self {
315 Self::Absorbance => "absorbance",
316 Self::Reflectance => "reflectance",
317 Self::Transmittance => "transmittance",
318 Self::LogReflectance => "log_reflectance",
319 Self::Preprocessed => "preprocessed",
320 Self::Unknown => "unknown",
321 }
322 }
323}
324
325pub fn require_signal_type_match(
335 expected: SignalKind,
336 actual: SignalKind,
337 allow_unknown: bool,
338) -> Result<()> {
339 if expected == SignalKind::Unknown || actual == SignalKind::Unknown {
344 return if allow_unknown {
345 Ok(())
346 } else {
347 Err(DataError::SignalTypeMismatch {
348 expected: expected.as_str(),
349 actual: actual.as_str(),
350 })
351 };
352 }
353 if expected == actual {
354 return Ok(());
355 }
356 Err(DataError::SignalTypeMismatch {
357 expected: expected.as_str(),
358 actual: actual.as_str(),
359 })
360}
361
362#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
363pub struct AxisSizeContract {
364 #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub exact: Option<usize>,
366 #[serde(default, skip_serializing_if = "Option::is_none")]
367 pub min: Option<usize>,
368 #[serde(default, skip_serializing_if = "Option::is_none")]
369 pub max: Option<usize>,
370}
371
372impl AxisSizeContract {
373 pub fn validate(&self, axis_name: &str) -> Result<()> {
374 if self.exact.is_none() && self.min.is_none() && self.max.is_none() {
375 return Err(DataError::Validation(format!(
376 "shape contract for axis `{axis_name}` does not constrain the size"
377 )));
378 }
379 if let (Some(min), Some(max)) = (self.min, self.max) {
380 if min > max {
381 return Err(DataError::Validation(format!(
382 "shape contract for axis `{axis_name}` has min {min} greater than max {max}"
383 )));
384 }
385 }
386 if let Some(exact) = self.exact {
387 if let Some(min) = self.min {
388 if exact < min {
389 return Err(DataError::Validation(format!(
390 "shape contract for axis `{axis_name}` exact size {exact} is below min {min}"
391 )));
392 }
393 }
394 if let Some(max) = self.max {
395 if exact > max {
396 return Err(DataError::Validation(format!(
397 "shape contract for axis `{axis_name}` exact size {exact} is above max {max}"
398 )));
399 }
400 }
401 }
402 Ok(())
403 }
404
405 fn accepts(&self, size: usize) -> bool {
406 if self.exact.is_some_and(|exact| size != exact) {
407 return false;
408 }
409 if self.min.is_some_and(|min| size < min) {
410 return false;
411 }
412 if self.max.is_some_and(|max| size > max) {
413 return false;
414 }
415 true
416 }
417}
418
419#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
420pub struct ShapeContract {
421 #[serde(default, skip_serializing_if = "Option::is_none")]
422 pub rank: Option<usize>,
423 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
424 pub axis_sizes: BTreeMap<String, AxisSizeContract>,
425 #[serde(default)]
426 pub allow_ragged: bool,
427}
428
429impl ShapeContract {
430 pub fn validate(&self) -> Result<()> {
431 if self.rank.is_none() && self.axis_sizes.is_empty() {
432 return Err(DataError::Validation(
433 "shape contract must constrain rank or at least one axis".to_string(),
434 ));
435 }
436 for (axis_name, contract) in &self.axis_sizes {
437 if axis_name.trim().is_empty() {
438 return Err(DataError::Validation(
439 "shape contract contains an empty axis name".to_string(),
440 ));
441 }
442 contract.validate(axis_name)?;
443 }
444 Ok(())
445 }
446
447 pub fn validate_representation(
448 &self,
449 source_id: &SourceId,
450 representation: &RepresentationSpec,
451 ) -> Result<()> {
452 self.validate()?;
453 if let Some(expected_rank) = self.rank {
454 if representation.rank != Some(expected_rank) {
455 return Err(DataError::Validation(format!(
456 "source `{source_id}` shape contract expects rank {expected_rank} but representation `{}` has {:?}",
457 representation.id, representation.rank
458 )));
459 }
460 }
461 if representation.ragged && !self.allow_ragged {
462 return Err(DataError::Validation(format!(
463 "source `{source_id}` shape contract does not allow ragged representation `{}`",
464 representation.id
465 )));
466 }
467 for (axis_name, contract) in &self.axis_sizes {
468 let axis = representation
469 .axes
470 .iter()
471 .find(|axis| axis.name == *axis_name)
472 .ok_or_else(|| {
473 DataError::Validation(format!(
474 "source `{source_id}` shape contract references missing axis `{axis_name}`"
475 ))
476 })?;
477 if let Some(size) = axis.size {
478 if !contract.accepts(size) {
479 return Err(DataError::Validation(format!(
480 "source `{source_id}` axis `{axis_name}` size {size} violates shape contract"
481 )));
482 }
483 } else if !axis.variable {
484 return Err(DataError::Validation(format!(
485 "source `{source_id}` axis `{axis_name}` has no concrete size for shape contract"
486 )));
487 }
488 }
489 Ok(())
490 }
491}
492
493#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
494pub struct RepresentationSpec {
495 pub id: RepresentationId,
496 pub type_id: TypeId,
497 pub rank: Option<usize>,
498 pub axes: Vec<AxisSpec>,
499 pub container: String,
500 pub dtype: Option<String>,
501 #[serde(default)]
502 pub sparse: bool,
503 #[serde(default)]
504 pub ragged: bool,
505 #[serde(default, skip_serializing_if = "Option::is_none")]
506 pub signal_type: Option<SignalKind>,
507}
508
509impl RepresentationSpec {
510 pub fn validate(&self) -> Result<()> {
511 if self.container.trim().is_empty() {
512 return Err(DataError::Validation(format!(
513 "representation `{}` has an empty container",
514 self.id
515 )));
516 }
517 if self.rank.is_none() && !self.ragged {
518 return Err(DataError::Validation(format!(
519 "representation `{}` with no rank must be ragged",
520 self.id
521 )));
522 }
523 if let Some(rank) = self.rank {
524 if self.axes.len() != rank {
525 return Err(DataError::Validation(format!(
526 "representation `{}` has rank {} but {} axes",
527 self.id,
528 rank,
529 self.axes.len()
530 )));
531 }
532 }
533 for axis in &self.axes {
534 axis.validate()?;
535 }
536 if self.container != "graph_batch"
537 && !self.axes.iter().any(|axis| axis.kind == AxisKind::Sample)
538 {
539 return Err(DataError::Validation(format!(
540 "representation `{}` has no sample axis",
541 self.id
542 )));
543 }
544 Ok(())
545 }
546}
547
548#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
549#[serde(rename_all = "snake_case")]
550pub enum SourceGranularity {
551 PerSample,
552 PerSampleRepeated,
553 PerSampleSequence,
554 PerSampleSet,
555 PerGroup,
556 PerTarget,
557}
558
559#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
560pub struct SourceDescriptor {
561 pub id: SourceId,
562 pub name: String,
563 pub type_id: TypeId,
564 pub modality: String,
565 pub native_representation: RepresentationSpec,
566 pub sample_key: String,
567 pub granularity: SourceGranularity,
568 #[serde(default)]
569 pub schema: BTreeMap<String, serde_json::Value>,
570 #[serde(default)]
571 pub tags: BTreeMap<String, serde_json::Value>,
572 #[serde(default, skip_serializing_if = "Option::is_none")]
573 pub shape_contract: Option<ShapeContract>,
574}
575
576impl SourceDescriptor {
577 pub fn validate(&self) -> Result<()> {
578 if self.name.trim().is_empty() {
579 return Err(DataError::Validation(format!(
580 "source `{}` has an empty name",
581 self.id
582 )));
583 }
584 if self.sample_key.trim().is_empty() {
585 return Err(DataError::Validation(format!(
586 "source `{}` has an empty sample key",
587 self.id
588 )));
589 }
590 self.native_representation.validate()?;
591 if let Some(shape_contract) = &self.shape_contract {
592 shape_contract.validate_representation(&self.id, &self.native_representation)?;
593 }
594 Ok(())
595 }
596}
597
598#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
599#[serde(rename_all = "snake_case")]
600pub enum MetadataValueKind {
601 String,
602 Number,
603 Integer,
604 Boolean,
605 Date,
606 Datetime,
607 Categorical,
608 Json,
609}
610
611#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
612pub struct MetadataFieldSpec {
613 pub kind: MetadataValueKind,
614 #[serde(default)]
615 pub required: bool,
616 #[serde(default, skip_serializing_if = "Option::is_none")]
617 pub unit: Option<String>,
618 #[serde(default, skip_serializing_if = "Vec::is_empty")]
619 pub allowed_values: Vec<serde_json::Value>,
620 #[serde(default, skip_serializing_if = "Option::is_none")]
621 pub description: Option<String>,
622}
623
624impl MetadataFieldSpec {
625 pub fn validate(&self, field_name: &str) -> Result<()> {
626 if self.kind == MetadataValueKind::Categorical && self.allowed_values.is_empty() {
627 return Err(DataError::Validation(format!(
628 "metadata field `{field_name}` is categorical but declares no allowed_values"
629 )));
630 }
631 if let Some(unit) = &self.unit {
632 if unit.trim().is_empty() {
633 return Err(DataError::Validation(format!(
634 "metadata field `{field_name}` has an empty unit"
635 )));
636 }
637 }
638 Ok(())
639 }
640}
641
642#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
643pub struct MetadataSchema {
644 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
645 pub fields: BTreeMap<String, MetadataFieldSpec>,
646}
647
648impl MetadataSchema {
649 pub fn validate(&self) -> Result<()> {
650 if self.fields.is_empty() {
651 return Err(DataError::Validation(
652 "metadata schema declares no fields".to_string(),
653 ));
654 }
655 for (field_name, field) in &self.fields {
656 if field_name.trim().is_empty() {
657 return Err(DataError::Validation(
658 "metadata schema contains an empty field name".to_string(),
659 ));
660 }
661 field.validate(field_name)?;
662 }
663 Ok(())
664 }
665}
666
667#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
668#[serde(rename_all = "snake_case")]
669pub enum GroupKind {
670 RepetitionGroup,
671 Subject,
672 Batch,
673 Split,
674 Custom,
675}
676
677#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
678pub struct GroupSpec {
679 pub id: GroupId,
680 pub kind: GroupKind,
681 pub column: String,
682 #[serde(default, skip_serializing_if = "Option::is_none")]
683 pub source_id: Option<SourceId>,
684 #[serde(default)]
685 pub strict: bool,
686 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
687 pub metadata: BTreeMap<String, serde_json::Value>,
688}
689
690impl GroupSpec {
691 pub fn validate(&self) -> Result<()> {
692 if self.column.trim().is_empty() {
693 return Err(DataError::Validation(format!(
694 "group `{}` has an empty column",
695 self.id
696 )));
697 }
698 for key in self.metadata.keys() {
699 if key.trim().is_empty() {
700 return Err(DataError::Validation(format!(
701 "group `{}` metadata contains an empty key",
702 self.id
703 )));
704 }
705 }
706 Ok(())
707 }
708}
709
710#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
711pub struct FoldSpec {
712 pub id: String,
713 #[serde(default, skip_serializing_if = "Option::is_none")]
714 pub group_id: Option<GroupId>,
715 #[serde(default, skip_serializing_if = "Option::is_none")]
716 pub split_column: Option<String>,
717 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
718 pub metadata: BTreeMap<String, serde_json::Value>,
719}
720
721impl FoldSpec {
722 pub fn validate(&self) -> Result<()> {
723 if self.id.trim().is_empty() {
724 return Err(DataError::Validation("fold id is empty".to_string()));
725 }
726 if self.group_id.is_none() && self.split_column.is_none() {
727 return Err(DataError::Validation(format!(
728 "fold `{}` declares neither group_id nor split_column",
729 self.id
730 )));
731 }
732 if let Some(split_column) = &self.split_column {
733 if split_column.trim().is_empty() {
734 return Err(DataError::Validation(format!(
735 "fold `{}` has an empty split_column",
736 self.id
737 )));
738 }
739 }
740 for key in self.metadata.keys() {
741 if key.trim().is_empty() {
742 return Err(DataError::Validation(format!(
743 "fold `{}` metadata contains an empty key",
744 self.id
745 )));
746 }
747 }
748 Ok(())
749 }
750}
751
752#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
753pub struct DatasetSchema {
754 pub dataset_id: String,
755 pub sample_ids: Vec<SampleId>,
756 pub sources: Vec<SourceDescriptor>,
757 #[serde(default)]
758 pub targets: BTreeMap<TargetId, RepresentationSpec>,
759 #[serde(default)]
760 pub metadata: BTreeMap<String, RepresentationSpec>,
761 #[serde(default, skip_serializing_if = "Option::is_none")]
762 pub metadata_schema: Option<MetadataSchema>,
763 #[serde(default, skip_serializing_if = "Vec::is_empty")]
764 pub groups: Vec<GroupSpec>,
765 #[serde(default, skip_serializing_if = "Vec::is_empty")]
766 pub folds: Vec<FoldSpec>,
767}
768
769impl DatasetSchema {
770 pub fn validate(&self) -> Result<()> {
771 if self.dataset_id.trim().is_empty() {
772 return Err(DataError::Validation(
773 "dataset id must not be empty".to_string(),
774 ));
775 }
776 if self.sample_ids.is_empty() {
777 return Err(DataError::Validation(
778 "dataset schema must contain at least one sample".to_string(),
779 ));
780 }
781 let unique_samples = self.sample_ids.iter().collect::<BTreeSet<_>>();
782 if unique_samples.len() != self.sample_ids.len() {
783 return Err(DataError::Validation(
784 "dataset schema contains duplicate sample ids".to_string(),
785 ));
786 }
787
788 let mut source_ids = BTreeSet::new();
789 for source in &self.sources {
790 if !source_ids.insert(&source.id) {
791 return Err(DataError::Validation(format!(
792 "duplicate source id `{}`",
793 source.id
794 )));
795 }
796 source.validate()?;
797 }
798 for target in self.targets.values() {
799 target.validate()?;
800 }
801 for representation in self.metadata.values() {
802 representation.validate()?;
803 }
804 if let Some(metadata_schema) = &self.metadata_schema {
805 metadata_schema.validate()?;
806 }
807 let mut group_ids = BTreeSet::new();
808 for group in &self.groups {
809 if !group_ids.insert(&group.id) {
810 return Err(DataError::Validation(format!(
811 "duplicate group id `{}`",
812 group.id
813 )));
814 }
815 if let Some(source_id) = &group.source_id {
816 if !source_ids.contains(source_id) {
817 return Err(DataError::Validation(format!(
818 "group `{}` references unknown source `{source_id}`",
819 group.id
820 )));
821 }
822 }
823 group.validate()?;
824 }
825 let mut fold_ids = BTreeSet::new();
826 for fold in &self.folds {
827 if !fold_ids.insert(&fold.id) {
828 return Err(DataError::Validation(format!(
829 "duplicate fold id `{}`",
830 fold.id
831 )));
832 }
833 if let Some(group_id) = &fold.group_id {
834 if !group_ids.contains(group_id) {
835 return Err(DataError::Validation(format!(
836 "fold `{}` references unknown group `{group_id}`",
837 fold.id
838 )));
839 }
840 }
841 fold.validate()?;
842 }
843 Ok(())
844 }
845}
846
847#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
848#[serde(deny_unknown_fields)]
849pub struct DataView {
850 pub sample_ids: Option<Vec<SampleId>>,
851 pub partition: Option<String>,
852 pub fold_id: Option<String>,
853 pub source_ids: Option<Vec<SourceId>>,
854 pub columns: Option<Vec<String>>,
855 #[serde(default = "default_true")]
856 pub include_augmented: bool,
857 #[serde(default)]
858 pub include_excluded: bool,
859 #[serde(default, skip_serializing_if = "Option::is_none")]
860 pub branch_view: Option<crate::coordinator::CoordinatorBranchView>,
861 #[serde(default)]
862 pub extra: BTreeMap<String, serde_json::Value>,
863}
864
865fn default_true() -> bool {
866 true
867}
868
869#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
870pub struct PresenceMask {
871 pub sample_ids: Vec<SampleId>,
872 pub source_id: SourceId,
873 pub present: Vec<bool>,
874}
875
876impl PresenceMask {
877 pub fn validate(&self) -> Result<()> {
878 if self.sample_ids.len() != self.present.len() {
879 return Err(DataError::Validation(format!(
880 "presence mask for `{}` has {} sample ids but {} flags",
881 self.source_id,
882 self.sample_ids.len(),
883 self.present.len()
884 )));
885 }
886 Ok(())
887 }
888}
889
890#[cfg(test)]
891mod tests {
892 use super::*;
893
894 fn sample_axis() -> AxisSpec {
895 AxisSpec {
896 name: "sample".to_string(),
897 kind: AxisKind::Sample,
898 unit: None,
899 size: Some(2),
900 variable: false,
901 coordinate: None,
902 }
903 }
904
905 #[test]
906 fn rejects_representation_without_sample_axis() {
907 let repr = RepresentationSpec {
908 id: RepresentationId::new("tabular").unwrap(),
909 type_id: TypeId::new("table").unwrap(),
910 rank: Some(1),
911 axes: vec![AxisSpec {
912 name: "feature".to_string(),
913 kind: AxisKind::Feature,
914 unit: None,
915 size: Some(3),
916 variable: false,
917 coordinate: None,
918 }],
919 container: "dataframe".to_string(),
920 dtype: Some("float32".to_string()),
921 sparse: false,
922 ragged: false,
923 signal_type: None,
924 };
925
926 assert!(repr.validate().is_err());
927 }
928
929 #[test]
930 fn accepts_sample_major_representation() {
931 let repr = RepresentationSpec {
932 id: RepresentationId::new("tabular").unwrap(),
933 type_id: TypeId::new("table").unwrap(),
934 rank: Some(1),
935 axes: vec![sample_axis()],
936 container: "dataframe".to_string(),
937 dtype: Some("float32".to_string()),
938 sparse: false,
939 ragged: false,
940 signal_type: None,
941 };
942
943 assert!(repr.validate().is_ok());
944 }
945
946 #[test]
947 fn axis_kind_wavenumber_serializes_and_round_trips() {
948 let value = AxisKind::Wavenumber;
949 let json = serde_json::to_string(&value).unwrap();
950 assert_eq!(json, "\"wavenumber\"");
951 let decoded: AxisKind = serde_json::from_str(&json).unwrap();
952 assert_eq!(decoded, value);
953 }
954
955 #[test]
956 fn axis_kind_wavenumber_accepted_in_representation_axis() {
957 let axes = vec![
958 sample_axis(),
959 AxisSpec {
960 name: "wavenumber".to_string(),
961 kind: AxisKind::Wavenumber,
962 unit: Some("cm-1".to_string()),
963 size: Some(1024),
964 variable: false,
965 coordinate: None,
966 },
967 ];
968 let repr = RepresentationSpec {
969 id: RepresentationId::new("ftir_spectrum").unwrap(),
970 type_id: TypeId::new("dense_signal").unwrap(),
971 rank: Some(2),
972 axes,
973 container: "ndarray".to_string(),
974 dtype: Some("float64".to_string()),
975 sparse: false,
976 ragged: false,
977 signal_type: Some(SignalKind::Absorbance),
978 };
979 repr.validate().unwrap();
980 }
981
982 #[test]
983 fn dataset_schema_accepts_optional_nirs4all_integration_contracts() {
984 let source_id = SourceId::new("nir").unwrap();
985 let group_id = GroupId::new("rep.group").unwrap();
986 let representation = RepresentationSpec {
987 id: RepresentationId::new("nir.signal").unwrap(),
988 type_id: TypeId::new("dense_signal").unwrap(),
989 rank: Some(2),
990 axes: vec![
991 sample_axis(),
992 AxisSpec {
993 name: "wavelength".to_string(),
994 kind: AxisKind::Wavelength,
995 unit: Some("nm".to_string()),
996 size: Some(3),
997 variable: false,
998 coordinate: None,
999 },
1000 ],
1001 container: "ndarray".to_string(),
1002 dtype: Some("float32".to_string()),
1003 sparse: false,
1004 ragged: false,
1005 signal_type: Some(SignalKind::Reflectance),
1006 };
1007 let schema = DatasetSchema {
1008 dataset_id: "nirs4all-core-smoke".to_string(),
1009 sample_ids: vec![SampleId::new("s1").unwrap(), SampleId::new("s2").unwrap()],
1010 sources: vec![SourceDescriptor {
1011 id: source_id.clone(),
1012 name: "NIR spectra".to_string(),
1013 type_id: TypeId::new("dense_signal").unwrap(),
1014 modality: "nir".to_string(),
1015 native_representation: representation,
1016 sample_key: "sample_id".to_string(),
1017 granularity: SourceGranularity::PerSampleRepeated,
1018 schema: BTreeMap::new(),
1019 tags: BTreeMap::new(),
1020 shape_contract: Some(ShapeContract {
1021 rank: Some(2),
1022 axis_sizes: BTreeMap::from([(
1023 "wavelength".to_string(),
1024 AxisSizeContract {
1025 exact: Some(3),
1026 min: None,
1027 max: None,
1028 },
1029 )]),
1030 allow_ragged: false,
1031 }),
1032 }],
1033 targets: BTreeMap::new(),
1034 metadata: BTreeMap::new(),
1035 metadata_schema: Some(MetadataSchema {
1036 fields: BTreeMap::from([(
1037 "cultivar".to_string(),
1038 MetadataFieldSpec {
1039 kind: MetadataValueKind::Categorical,
1040 required: true,
1041 unit: None,
1042 allowed_values: vec![serde_json::Value::String("a".to_string())],
1043 description: None,
1044 },
1045 )]),
1046 }),
1047 groups: vec![GroupSpec {
1048 id: group_id.clone(),
1049 kind: GroupKind::RepetitionGroup,
1050 column: "sample_id".to_string(),
1051 source_id: Some(source_id),
1052 strict: true,
1053 metadata: BTreeMap::new(),
1054 }],
1055 folds: vec![FoldSpec {
1056 id: "cv.repetition.safe".to_string(),
1057 group_id: Some(group_id),
1058 split_column: Some("fold_id".to_string()),
1059 metadata: BTreeMap::new(),
1060 }],
1061 };
1062
1063 schema.validate().unwrap();
1064 let json = serde_json::to_value(&schema).unwrap();
1065 assert_eq!(
1066 json["sources"][0]["native_representation"]["signal_type"],
1067 "reflectance"
1068 );
1069 assert_eq!(json["groups"][0]["kind"], "repetition_group");
1070 }
1071
1072 #[test]
1073 fn dataset_schema_refuses_shape_contract_mismatch() {
1074 let representation = RepresentationSpec {
1075 id: RepresentationId::new("nir.signal").unwrap(),
1076 type_id: TypeId::new("dense_signal").unwrap(),
1077 rank: Some(2),
1078 axes: vec![
1079 sample_axis(),
1080 AxisSpec {
1081 name: "wavelength".to_string(),
1082 kind: AxisKind::Wavelength,
1083 unit: Some("nm".to_string()),
1084 size: Some(3),
1085 variable: false,
1086 coordinate: None,
1087 },
1088 ],
1089 container: "ndarray".to_string(),
1090 dtype: Some("float32".to_string()),
1091 sparse: false,
1092 ragged: false,
1093 signal_type: Some(SignalKind::Absorbance),
1094 };
1095 let source = SourceDescriptor {
1096 id: SourceId::new("nir").unwrap(),
1097 name: "NIR spectra".to_string(),
1098 type_id: TypeId::new("dense_signal").unwrap(),
1099 modality: "nir".to_string(),
1100 native_representation: representation,
1101 sample_key: "sample_id".to_string(),
1102 granularity: SourceGranularity::PerSample,
1103 schema: BTreeMap::new(),
1104 tags: BTreeMap::new(),
1105 shape_contract: Some(ShapeContract {
1106 rank: Some(2),
1107 axis_sizes: BTreeMap::from([(
1108 "wavelength".to_string(),
1109 AxisSizeContract {
1110 exact: Some(4),
1111 min: None,
1112 max: None,
1113 },
1114 )]),
1115 allow_ragged: false,
1116 }),
1117 };
1118
1119 assert!(source.validate().is_err());
1120 }
1121
1122 #[test]
1123 fn dataset_schema_refuses_empty_shape_contract() {
1124 let representation = RepresentationSpec {
1125 id: RepresentationId::new("nir.signal").unwrap(),
1126 type_id: TypeId::new("dense_signal").unwrap(),
1127 rank: Some(2),
1128 axes: vec![
1129 sample_axis(),
1130 AxisSpec {
1131 name: "wavelength".to_string(),
1132 kind: AxisKind::Wavelength,
1133 unit: Some("nm".to_string()),
1134 size: Some(3),
1135 variable: false,
1136 coordinate: None,
1137 },
1138 ],
1139 container: "ndarray".to_string(),
1140 dtype: Some("float32".to_string()),
1141 sparse: false,
1142 ragged: false,
1143 signal_type: None,
1144 };
1145 let source = SourceDescriptor {
1146 id: SourceId::new("nir").unwrap(),
1147 name: "NIR spectra".to_string(),
1148 type_id: TypeId::new("dense_signal").unwrap(),
1149 modality: "nir".to_string(),
1150 native_representation: representation,
1151 sample_key: "sample_id".to_string(),
1152 granularity: SourceGranularity::PerSample,
1153 schema: BTreeMap::new(),
1154 tags: BTreeMap::new(),
1155 shape_contract: Some(ShapeContract::default()),
1156 };
1157
1158 assert!(source.validate().is_err());
1159 }
1160
1161 #[test]
1162 fn dataset_schema_refuses_unknown_fold_group() {
1163 let schema = DatasetSchema {
1164 dataset_id: "folds".to_string(),
1165 sample_ids: vec![SampleId::new("s1").unwrap()],
1166 sources: Vec::new(),
1167 targets: BTreeMap::new(),
1168 metadata: BTreeMap::new(),
1169 metadata_schema: None,
1170 groups: Vec::new(),
1171 folds: vec![FoldSpec {
1172 id: "fold.cv".to_string(),
1173 group_id: Some(GroupId::new("missing").unwrap()),
1174 split_column: None,
1175 metadata: BTreeMap::new(),
1176 }],
1177 };
1178
1179 assert!(schema.validate().is_err());
1180 }
1181
1182 #[test]
1183 fn dataset_schema_refuses_empty_fold_declaration() {
1184 let schema = DatasetSchema {
1185 dataset_id: "folds".to_string(),
1186 sample_ids: vec![SampleId::new("s1").unwrap()],
1187 sources: Vec::new(),
1188 targets: BTreeMap::new(),
1189 metadata: BTreeMap::new(),
1190 metadata_schema: None,
1191 groups: Vec::new(),
1192 folds: vec![FoldSpec {
1193 id: "fold.cv".to_string(),
1194 group_id: None,
1195 split_column: None,
1196 metadata: BTreeMap::new(),
1197 }],
1198 };
1199
1200 let error = schema.validate().unwrap_err();
1201 assert!(error
1202 .to_string()
1203 .contains("neither group_id nor split_column"));
1204 }
1205
1206 fn coord(dtype: CoordinateDType, ordered: bool, values: CoordinateValues) -> CoordinateSpec {
1207 CoordinateSpec {
1208 dtype,
1209 ordered,
1210 values,
1211 }
1212 }
1213
1214 fn explicit(values: Vec<serde_json::Value>) -> CoordinateValues {
1215 CoordinateValues::Explicit { values }
1216 }
1217
1218 fn nums(values: &[f64]) -> Vec<serde_json::Value> {
1219 values
1220 .iter()
1221 .map(|value| serde_json::Value::from(*value))
1222 .collect()
1223 }
1224
1225 fn strings(values: &[&str]) -> Vec<serde_json::Value> {
1226 values
1227 .iter()
1228 .map(|value| serde_json::Value::from(*value))
1229 .collect()
1230 }
1231
1232 #[test]
1233 fn numeric_ordered_coordinates_accept_ascending_or_descending() {
1234 let ascending = coord(
1235 CoordinateDType::Numeric,
1236 true,
1237 explicit(nums(&[400.0, 402.0, 404.0])),
1238 );
1239 assert!(ascending.validate("wl", Some(3), false).is_ok());
1240 let descending = coord(
1241 CoordinateDType::Numeric,
1242 true,
1243 explicit(nums(&[404.0, 402.0, 400.0])),
1244 );
1245 assert!(descending.validate("wl", Some(3), false).is_ok());
1246 }
1247
1248 #[test]
1249 fn numeric_ordered_coordinates_reject_non_monotonic_and_duplicates() {
1250 let bumpy = coord(
1251 CoordinateDType::Numeric,
1252 true,
1253 explicit(nums(&[400.0, 404.0, 402.0])),
1254 );
1255 assert!(bumpy.validate("wl", Some(3), false).is_err());
1256 let duplicate = coord(
1257 CoordinateDType::Numeric,
1258 true,
1259 explicit(nums(&[400.0, 400.0])),
1260 );
1261 assert!(duplicate.validate("wl", Some(2), false).is_err());
1262 }
1263
1264 #[test]
1265 fn numeric_coordinates_reject_non_finite_and_non_number() {
1266 let not_finite = coord(
1267 CoordinateDType::Numeric,
1268 false,
1269 explicit(vec![serde_json::Value::from(f64::NAN)]),
1270 );
1271 assert!(not_finite.validate("wl", Some(1), false).is_err());
1272 let text = coord(CoordinateDType::Numeric, false, explicit(strings(&["400"])));
1273 assert!(text.validate("wl", Some(1), false).is_err());
1274 }
1275
1276 #[test]
1277 fn categorical_coordinates_require_unique_non_empty_strings() {
1278 let ok = coord(
1279 CoordinateDType::Categorical,
1280 false,
1281 explicit(strings(&["R", "G", "B"])),
1282 );
1283 assert!(ok.validate("channel", Some(3), false).is_ok());
1284 let duplicate = coord(
1285 CoordinateDType::Categorical,
1286 false,
1287 explicit(strings(&["R", "R"])),
1288 );
1289 assert!(duplicate.validate("channel", Some(2), false).is_err());
1290 let empty = coord(
1291 CoordinateDType::Categorical,
1292 false,
1293 explicit(strings(&[""])),
1294 );
1295 assert!(empty.validate("channel", Some(1), false).is_err());
1296 let numeric_label = coord(CoordinateDType::Categorical, false, explicit(nums(&[1.0])));
1297 assert!(numeric_label.validate("channel", Some(1), false).is_err());
1298 let ordered = coord(
1300 CoordinateDType::Categorical,
1301 true,
1302 explicit(strings(&["Z", "A"])),
1303 );
1304 assert!(ordered.validate("channel", Some(2), false).is_ok());
1305 }
1306
1307 #[test]
1308 fn datetime_coordinates_require_canonical_rfc3339_utc_seconds() {
1309 let ok = coord(
1310 CoordinateDType::Datetime,
1311 false,
1312 explicit(strings(&["2026-05-29T10:00:00Z"])),
1313 );
1314 assert!(ok.validate("time", Some(1), false).is_ok());
1315 for bad in [
1316 "2026-05-29 10:00:00",
1317 "2026-05-29T10:00:00+02:00",
1318 "2026-13-29T10:00:00Z",
1319 ] {
1320 let spec = coord(CoordinateDType::Datetime, false, explicit(strings(&[bad])));
1321 assert!(
1322 spec.validate("time", Some(1), false).is_err(),
1323 "expected reject for {bad}"
1324 );
1325 }
1326 let epoch = coord(
1327 CoordinateDType::Datetime,
1328 false,
1329 explicit(nums(&[1.716976e9])),
1330 );
1331 assert!(epoch.validate("time", Some(1), false).is_err());
1332 }
1333
1334 #[test]
1335 fn datetime_ordered_coordinates_enforce_strict_monotonic() {
1336 let ok = coord(
1337 CoordinateDType::Datetime,
1338 true,
1339 explicit(strings(&["2026-05-29T10:00:00Z", "2026-05-29T10:00:01Z"])),
1340 );
1341 assert!(ok.validate("time", Some(2), false).is_ok());
1342 let stalled = coord(
1343 CoordinateDType::Datetime,
1344 true,
1345 explicit(strings(&["2026-05-29T10:00:01Z", "2026-05-29T10:00:01Z"])),
1346 );
1347 assert!(stalled.validate("time", Some(2), false).is_err());
1348 }
1349
1350 #[test]
1351 fn regular_grid_coordinates_validate_numeric_sized_nonzero_ordered() {
1352 let ok = coord(
1353 CoordinateDType::Numeric,
1354 true,
1355 CoordinateValues::RegularGrid {
1356 start: 400.0,
1357 step: 2.0,
1358 },
1359 );
1360 assert!(ok.validate("wl", Some(100), false).is_ok());
1361 let descending = coord(
1362 CoordinateDType::Numeric,
1363 true,
1364 CoordinateValues::RegularGrid {
1365 start: 400.0,
1366 step: -2.0,
1367 },
1368 );
1369 assert!(descending.validate("wl", Some(100), false).is_ok());
1370 let categorical = coord(
1371 CoordinateDType::Categorical,
1372 true,
1373 CoordinateValues::RegularGrid {
1374 start: 0.0,
1375 step: 1.0,
1376 },
1377 );
1378 assert!(categorical.validate("wl", Some(3), false).is_err());
1379 let no_size = coord(
1380 CoordinateDType::Numeric,
1381 true,
1382 CoordinateValues::RegularGrid {
1383 start: 0.0,
1384 step: 1.0,
1385 },
1386 );
1387 assert!(no_size.validate("wl", None, false).is_err());
1388 let zero_step = coord(
1389 CoordinateDType::Numeric,
1390 true,
1391 CoordinateValues::RegularGrid {
1392 start: 0.0,
1393 step: 0.0,
1394 },
1395 );
1396 assert!(zero_step.validate("wl", Some(3), false).is_err());
1397 let unordered = coord(
1398 CoordinateDType::Numeric,
1399 false,
1400 CoordinateValues::RegularGrid {
1401 start: 0.0,
1402 step: 1.0,
1403 },
1404 );
1405 assert!(unordered.validate("wl", Some(3), false).is_err());
1406 }
1407
1408 #[test]
1409 fn explicit_coordinates_must_match_known_size_and_be_non_empty() {
1410 let wrong_len = coord(CoordinateDType::Numeric, false, explicit(nums(&[1.0, 2.0])));
1411 assert!(wrong_len.validate("wl", Some(3), false).is_err());
1412 let empty = coord(CoordinateDType::Numeric, false, explicit(Vec::new()));
1413 assert!(empty.validate("wl", Some(0), false).is_err());
1414 }
1415
1416 #[test]
1417 fn axis_validate_integrates_coordinate_and_unit_rules() {
1418 let blank_unit = AxisSpec {
1419 name: "wl".to_string(),
1420 kind: AxisKind::Wavenumber,
1421 unit: Some(" ".to_string()),
1422 size: Some(2),
1423 variable: false,
1424 coordinate: None,
1425 };
1426 assert!(blank_unit.validate().is_err());
1427
1428 let variable_with_coordinate = AxisSpec {
1429 name: "wl".to_string(),
1430 kind: AxisKind::Feature,
1431 unit: None,
1432 size: None,
1433 variable: true,
1434 coordinate: Some(coord(
1435 CoordinateDType::Numeric,
1436 false,
1437 explicit(nums(&[1.0])),
1438 )),
1439 };
1440 assert!(variable_with_coordinate.validate().is_err());
1441
1442 let ok = AxisSpec {
1443 name: "wl".to_string(),
1444 kind: AxisKind::Wavenumber,
1445 unit: Some("cm-1".to_string()),
1446 size: Some(3),
1447 variable: false,
1448 coordinate: Some(coord(
1449 CoordinateDType::Numeric,
1450 true,
1451 explicit(nums(&[400.0, 402.0, 404.0])),
1452 )),
1453 };
1454 assert!(ok.validate().is_ok());
1455 }
1456
1457 #[test]
1458 fn coordinate_spec_round_trips_through_json() {
1459 let explicit_spec = coord(
1460 CoordinateDType::Categorical,
1461 true,
1462 explicit(strings(&["R", "G", "B"])),
1463 );
1464 let text = serde_json::to_string(&explicit_spec).unwrap();
1465 assert!(text.contains("\"kind\":\"explicit\""));
1466 assert_eq!(
1467 serde_json::from_str::<CoordinateSpec>(&text).unwrap(),
1468 explicit_spec
1469 );
1470
1471 let grid_spec = coord(
1472 CoordinateDType::Numeric,
1473 true,
1474 CoordinateValues::RegularGrid {
1475 start: 400.0,
1476 step: 2.0,
1477 },
1478 );
1479 let text = serde_json::to_string(&grid_spec).unwrap();
1480 assert!(text.contains("\"kind\":\"regular_grid\""));
1481 assert_eq!(
1482 serde_json::from_str::<CoordinateSpec>(&text).unwrap(),
1483 grid_spec
1484 );
1485 }
1486
1487 #[test]
1488 fn axis_spec_rejects_legacy_coordinates_field() {
1489 let legacy = r#"{"name":"wl","kind":"wavelength","unit":"nm","size":2,"variable":false,"coordinates":[900,1000]}"#;
1492 assert!(serde_json::from_str::<AxisSpec>(legacy).is_err());
1493
1494 let typed = r#"{"name":"wl","kind":"wavelength","unit":"nm","size":2,"variable":false,"coordinate":{"dtype":"numeric","ordered":true,"values":{"kind":"explicit","values":[900,1000]}}}"#;
1495 let axis = serde_json::from_str::<AxisSpec>(typed).unwrap();
1496 assert!(axis.validate().is_ok());
1497 assert!(axis.coordinate.is_some());
1498 }
1499
1500 #[test]
1501 fn datetime_coordinates_reject_impossible_calendar_dates() {
1502 for bad in [
1503 "2026-02-31T00:00:00Z", "2026-04-31T00:00:00Z", "2025-02-29T00:00:00Z", "2026-01-01T00:00:60Z", ] {
1508 let spec = coord(CoordinateDType::Datetime, false, explicit(strings(&[bad])));
1509 assert!(
1510 spec.validate("time", Some(1), false).is_err(),
1511 "expected reject for {bad}"
1512 );
1513 }
1514 let leap_day = coord(
1516 CoordinateDType::Datetime,
1517 false,
1518 explicit(strings(&["2024-02-29T23:59:59Z"])),
1519 );
1520 assert!(leap_day.validate("time", Some(1), false).is_ok());
1521 }
1522
1523 #[test]
1524 fn signal_type_match_honours_caller_unknown_policy() {
1525 use SignalKind::*;
1526 assert!(require_signal_type_match(Absorbance, Absorbance, false).is_ok());
1528 let error = require_signal_type_match(Absorbance, Reflectance, true).unwrap_err();
1530 assert_eq!(error.code(), "signal_type_mismatch");
1531 assert_eq!(error.error_code(), 0x0008_0003);
1532 assert_eq!(error.context()["expected"], serde_json::json!("absorbance"));
1533 assert_eq!(error.context()["actual"], serde_json::json!("reflectance"));
1534 assert!(require_signal_type_match(Absorbance, Unknown, true).is_ok());
1536 assert!(require_signal_type_match(Absorbance, Unknown, false).is_err());
1537 assert!(require_signal_type_match(Unknown, Reflectance, true).is_ok());
1539 assert!(require_signal_type_match(Unknown, Reflectance, false).is_err());
1540 assert!(require_signal_type_match(Unknown, Unknown, true).is_ok());
1542 assert!(require_signal_type_match(Unknown, Unknown, false).is_err());
1543 }
1544}