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)]
848pub struct DataView {
849 pub sample_ids: Option<Vec<SampleId>>,
850 pub partition: Option<String>,
851 pub fold_id: Option<String>,
852 pub source_ids: Option<Vec<SourceId>>,
853 pub columns: Option<Vec<String>>,
854 #[serde(default = "default_true")]
855 pub include_augmented: bool,
856 #[serde(default)]
857 pub include_excluded: bool,
858 #[serde(default, skip_serializing_if = "Option::is_none")]
859 pub branch_view: Option<crate::coordinator::CoordinatorBranchView>,
860 #[serde(default)]
861 pub extra: BTreeMap<String, serde_json::Value>,
862}
863
864fn default_true() -> bool {
865 true
866}
867
868#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
869pub struct PresenceMask {
870 pub sample_ids: Vec<SampleId>,
871 pub source_id: SourceId,
872 pub present: Vec<bool>,
873}
874
875impl PresenceMask {
876 pub fn validate(&self) -> Result<()> {
877 if self.sample_ids.len() != self.present.len() {
878 return Err(DataError::Validation(format!(
879 "presence mask for `{}` has {} sample ids but {} flags",
880 self.source_id,
881 self.sample_ids.len(),
882 self.present.len()
883 )));
884 }
885 Ok(())
886 }
887}
888
889#[cfg(test)]
890mod tests {
891 use super::*;
892
893 fn sample_axis() -> AxisSpec {
894 AxisSpec {
895 name: "sample".to_string(),
896 kind: AxisKind::Sample,
897 unit: None,
898 size: Some(2),
899 variable: false,
900 coordinate: None,
901 }
902 }
903
904 #[test]
905 fn rejects_representation_without_sample_axis() {
906 let repr = RepresentationSpec {
907 id: RepresentationId::new("tabular").unwrap(),
908 type_id: TypeId::new("table").unwrap(),
909 rank: Some(1),
910 axes: vec![AxisSpec {
911 name: "feature".to_string(),
912 kind: AxisKind::Feature,
913 unit: None,
914 size: Some(3),
915 variable: false,
916 coordinate: None,
917 }],
918 container: "dataframe".to_string(),
919 dtype: Some("float32".to_string()),
920 sparse: false,
921 ragged: false,
922 signal_type: None,
923 };
924
925 assert!(repr.validate().is_err());
926 }
927
928 #[test]
929 fn accepts_sample_major_representation() {
930 let repr = RepresentationSpec {
931 id: RepresentationId::new("tabular").unwrap(),
932 type_id: TypeId::new("table").unwrap(),
933 rank: Some(1),
934 axes: vec![sample_axis()],
935 container: "dataframe".to_string(),
936 dtype: Some("float32".to_string()),
937 sparse: false,
938 ragged: false,
939 signal_type: None,
940 };
941
942 assert!(repr.validate().is_ok());
943 }
944
945 #[test]
946 fn axis_kind_wavenumber_serializes_and_round_trips() {
947 let value = AxisKind::Wavenumber;
948 let json = serde_json::to_string(&value).unwrap();
949 assert_eq!(json, "\"wavenumber\"");
950 let decoded: AxisKind = serde_json::from_str(&json).unwrap();
951 assert_eq!(decoded, value);
952 }
953
954 #[test]
955 fn axis_kind_wavenumber_accepted_in_representation_axis() {
956 let axes = vec![
957 sample_axis(),
958 AxisSpec {
959 name: "wavenumber".to_string(),
960 kind: AxisKind::Wavenumber,
961 unit: Some("cm-1".to_string()),
962 size: Some(1024),
963 variable: false,
964 coordinate: None,
965 },
966 ];
967 let repr = RepresentationSpec {
968 id: RepresentationId::new("ftir_spectrum").unwrap(),
969 type_id: TypeId::new("dense_signal").unwrap(),
970 rank: Some(2),
971 axes,
972 container: "ndarray".to_string(),
973 dtype: Some("float64".to_string()),
974 sparse: false,
975 ragged: false,
976 signal_type: Some(SignalKind::Absorbance),
977 };
978 repr.validate().unwrap();
979 }
980
981 #[test]
982 fn dataset_schema_accepts_optional_nirs4all_integration_contracts() {
983 let source_id = SourceId::new("nir").unwrap();
984 let group_id = GroupId::new("rep.group").unwrap();
985 let representation = RepresentationSpec {
986 id: RepresentationId::new("nir.signal").unwrap(),
987 type_id: TypeId::new("dense_signal").unwrap(),
988 rank: Some(2),
989 axes: vec![
990 sample_axis(),
991 AxisSpec {
992 name: "wavelength".to_string(),
993 kind: AxisKind::Wavelength,
994 unit: Some("nm".to_string()),
995 size: Some(3),
996 variable: false,
997 coordinate: None,
998 },
999 ],
1000 container: "ndarray".to_string(),
1001 dtype: Some("float32".to_string()),
1002 sparse: false,
1003 ragged: false,
1004 signal_type: Some(SignalKind::Reflectance),
1005 };
1006 let schema = DatasetSchema {
1007 dataset_id: "nirs4all-lite-smoke".to_string(),
1008 sample_ids: vec![SampleId::new("s1").unwrap(), SampleId::new("s2").unwrap()],
1009 sources: vec![SourceDescriptor {
1010 id: source_id.clone(),
1011 name: "NIR spectra".to_string(),
1012 type_id: TypeId::new("dense_signal").unwrap(),
1013 modality: "nir".to_string(),
1014 native_representation: representation,
1015 sample_key: "sample_id".to_string(),
1016 granularity: SourceGranularity::PerSampleRepeated,
1017 schema: BTreeMap::new(),
1018 tags: BTreeMap::new(),
1019 shape_contract: Some(ShapeContract {
1020 rank: Some(2),
1021 axis_sizes: BTreeMap::from([(
1022 "wavelength".to_string(),
1023 AxisSizeContract {
1024 exact: Some(3),
1025 min: None,
1026 max: None,
1027 },
1028 )]),
1029 allow_ragged: false,
1030 }),
1031 }],
1032 targets: BTreeMap::new(),
1033 metadata: BTreeMap::new(),
1034 metadata_schema: Some(MetadataSchema {
1035 fields: BTreeMap::from([(
1036 "cultivar".to_string(),
1037 MetadataFieldSpec {
1038 kind: MetadataValueKind::Categorical,
1039 required: true,
1040 unit: None,
1041 allowed_values: vec![serde_json::Value::String("a".to_string())],
1042 description: None,
1043 },
1044 )]),
1045 }),
1046 groups: vec![GroupSpec {
1047 id: group_id.clone(),
1048 kind: GroupKind::RepetitionGroup,
1049 column: "sample_id".to_string(),
1050 source_id: Some(source_id),
1051 strict: true,
1052 metadata: BTreeMap::new(),
1053 }],
1054 folds: vec![FoldSpec {
1055 id: "cv.repetition.safe".to_string(),
1056 group_id: Some(group_id),
1057 split_column: Some("fold_id".to_string()),
1058 metadata: BTreeMap::new(),
1059 }],
1060 };
1061
1062 schema.validate().unwrap();
1063 let json = serde_json::to_value(&schema).unwrap();
1064 assert_eq!(
1065 json["sources"][0]["native_representation"]["signal_type"],
1066 "reflectance"
1067 );
1068 assert_eq!(json["groups"][0]["kind"], "repetition_group");
1069 }
1070
1071 #[test]
1072 fn dataset_schema_refuses_shape_contract_mismatch() {
1073 let representation = RepresentationSpec {
1074 id: RepresentationId::new("nir.signal").unwrap(),
1075 type_id: TypeId::new("dense_signal").unwrap(),
1076 rank: Some(2),
1077 axes: vec![
1078 sample_axis(),
1079 AxisSpec {
1080 name: "wavelength".to_string(),
1081 kind: AxisKind::Wavelength,
1082 unit: Some("nm".to_string()),
1083 size: Some(3),
1084 variable: false,
1085 coordinate: None,
1086 },
1087 ],
1088 container: "ndarray".to_string(),
1089 dtype: Some("float32".to_string()),
1090 sparse: false,
1091 ragged: false,
1092 signal_type: Some(SignalKind::Absorbance),
1093 };
1094 let source = SourceDescriptor {
1095 id: SourceId::new("nir").unwrap(),
1096 name: "NIR spectra".to_string(),
1097 type_id: TypeId::new("dense_signal").unwrap(),
1098 modality: "nir".to_string(),
1099 native_representation: representation,
1100 sample_key: "sample_id".to_string(),
1101 granularity: SourceGranularity::PerSample,
1102 schema: BTreeMap::new(),
1103 tags: BTreeMap::new(),
1104 shape_contract: Some(ShapeContract {
1105 rank: Some(2),
1106 axis_sizes: BTreeMap::from([(
1107 "wavelength".to_string(),
1108 AxisSizeContract {
1109 exact: Some(4),
1110 min: None,
1111 max: None,
1112 },
1113 )]),
1114 allow_ragged: false,
1115 }),
1116 };
1117
1118 assert!(source.validate().is_err());
1119 }
1120
1121 #[test]
1122 fn dataset_schema_refuses_empty_shape_contract() {
1123 let representation = RepresentationSpec {
1124 id: RepresentationId::new("nir.signal").unwrap(),
1125 type_id: TypeId::new("dense_signal").unwrap(),
1126 rank: Some(2),
1127 axes: vec![
1128 sample_axis(),
1129 AxisSpec {
1130 name: "wavelength".to_string(),
1131 kind: AxisKind::Wavelength,
1132 unit: Some("nm".to_string()),
1133 size: Some(3),
1134 variable: false,
1135 coordinate: None,
1136 },
1137 ],
1138 container: "ndarray".to_string(),
1139 dtype: Some("float32".to_string()),
1140 sparse: false,
1141 ragged: false,
1142 signal_type: None,
1143 };
1144 let source = SourceDescriptor {
1145 id: SourceId::new("nir").unwrap(),
1146 name: "NIR spectra".to_string(),
1147 type_id: TypeId::new("dense_signal").unwrap(),
1148 modality: "nir".to_string(),
1149 native_representation: representation,
1150 sample_key: "sample_id".to_string(),
1151 granularity: SourceGranularity::PerSample,
1152 schema: BTreeMap::new(),
1153 tags: BTreeMap::new(),
1154 shape_contract: Some(ShapeContract::default()),
1155 };
1156
1157 assert!(source.validate().is_err());
1158 }
1159
1160 #[test]
1161 fn dataset_schema_refuses_unknown_fold_group() {
1162 let schema = DatasetSchema {
1163 dataset_id: "folds".to_string(),
1164 sample_ids: vec![SampleId::new("s1").unwrap()],
1165 sources: Vec::new(),
1166 targets: BTreeMap::new(),
1167 metadata: BTreeMap::new(),
1168 metadata_schema: None,
1169 groups: Vec::new(),
1170 folds: vec![FoldSpec {
1171 id: "fold.cv".to_string(),
1172 group_id: Some(GroupId::new("missing").unwrap()),
1173 split_column: None,
1174 metadata: BTreeMap::new(),
1175 }],
1176 };
1177
1178 assert!(schema.validate().is_err());
1179 }
1180
1181 #[test]
1182 fn dataset_schema_refuses_empty_fold_declaration() {
1183 let schema = DatasetSchema {
1184 dataset_id: "folds".to_string(),
1185 sample_ids: vec![SampleId::new("s1").unwrap()],
1186 sources: Vec::new(),
1187 targets: BTreeMap::new(),
1188 metadata: BTreeMap::new(),
1189 metadata_schema: None,
1190 groups: Vec::new(),
1191 folds: vec![FoldSpec {
1192 id: "fold.cv".to_string(),
1193 group_id: None,
1194 split_column: None,
1195 metadata: BTreeMap::new(),
1196 }],
1197 };
1198
1199 let error = schema.validate().unwrap_err();
1200 assert!(error
1201 .to_string()
1202 .contains("neither group_id nor split_column"));
1203 }
1204
1205 fn coord(dtype: CoordinateDType, ordered: bool, values: CoordinateValues) -> CoordinateSpec {
1206 CoordinateSpec {
1207 dtype,
1208 ordered,
1209 values,
1210 }
1211 }
1212
1213 fn explicit(values: Vec<serde_json::Value>) -> CoordinateValues {
1214 CoordinateValues::Explicit { values }
1215 }
1216
1217 fn nums(values: &[f64]) -> Vec<serde_json::Value> {
1218 values
1219 .iter()
1220 .map(|value| serde_json::Value::from(*value))
1221 .collect()
1222 }
1223
1224 fn strings(values: &[&str]) -> Vec<serde_json::Value> {
1225 values
1226 .iter()
1227 .map(|value| serde_json::Value::from(*value))
1228 .collect()
1229 }
1230
1231 #[test]
1232 fn numeric_ordered_coordinates_accept_ascending_or_descending() {
1233 let ascending = coord(
1234 CoordinateDType::Numeric,
1235 true,
1236 explicit(nums(&[400.0, 402.0, 404.0])),
1237 );
1238 assert!(ascending.validate("wl", Some(3), false).is_ok());
1239 let descending = coord(
1240 CoordinateDType::Numeric,
1241 true,
1242 explicit(nums(&[404.0, 402.0, 400.0])),
1243 );
1244 assert!(descending.validate("wl", Some(3), false).is_ok());
1245 }
1246
1247 #[test]
1248 fn numeric_ordered_coordinates_reject_non_monotonic_and_duplicates() {
1249 let bumpy = coord(
1250 CoordinateDType::Numeric,
1251 true,
1252 explicit(nums(&[400.0, 404.0, 402.0])),
1253 );
1254 assert!(bumpy.validate("wl", Some(3), false).is_err());
1255 let duplicate = coord(
1256 CoordinateDType::Numeric,
1257 true,
1258 explicit(nums(&[400.0, 400.0])),
1259 );
1260 assert!(duplicate.validate("wl", Some(2), false).is_err());
1261 }
1262
1263 #[test]
1264 fn numeric_coordinates_reject_non_finite_and_non_number() {
1265 let not_finite = coord(
1266 CoordinateDType::Numeric,
1267 false,
1268 explicit(vec![serde_json::Value::from(f64::NAN)]),
1269 );
1270 assert!(not_finite.validate("wl", Some(1), false).is_err());
1271 let text = coord(CoordinateDType::Numeric, false, explicit(strings(&["400"])));
1272 assert!(text.validate("wl", Some(1), false).is_err());
1273 }
1274
1275 #[test]
1276 fn categorical_coordinates_require_unique_non_empty_strings() {
1277 let ok = coord(
1278 CoordinateDType::Categorical,
1279 false,
1280 explicit(strings(&["R", "G", "B"])),
1281 );
1282 assert!(ok.validate("channel", Some(3), false).is_ok());
1283 let duplicate = coord(
1284 CoordinateDType::Categorical,
1285 false,
1286 explicit(strings(&["R", "R"])),
1287 );
1288 assert!(duplicate.validate("channel", Some(2), false).is_err());
1289 let empty = coord(
1290 CoordinateDType::Categorical,
1291 false,
1292 explicit(strings(&[""])),
1293 );
1294 assert!(empty.validate("channel", Some(1), false).is_err());
1295 let numeric_label = coord(CoordinateDType::Categorical, false, explicit(nums(&[1.0])));
1296 assert!(numeric_label.validate("channel", Some(1), false).is_err());
1297 let ordered = coord(
1299 CoordinateDType::Categorical,
1300 true,
1301 explicit(strings(&["Z", "A"])),
1302 );
1303 assert!(ordered.validate("channel", Some(2), false).is_ok());
1304 }
1305
1306 #[test]
1307 fn datetime_coordinates_require_canonical_rfc3339_utc_seconds() {
1308 let ok = coord(
1309 CoordinateDType::Datetime,
1310 false,
1311 explicit(strings(&["2026-05-29T10:00:00Z"])),
1312 );
1313 assert!(ok.validate("time", Some(1), false).is_ok());
1314 for bad in [
1315 "2026-05-29 10:00:00",
1316 "2026-05-29T10:00:00+02:00",
1317 "2026-13-29T10:00:00Z",
1318 ] {
1319 let spec = coord(CoordinateDType::Datetime, false, explicit(strings(&[bad])));
1320 assert!(
1321 spec.validate("time", Some(1), false).is_err(),
1322 "expected reject for {bad}"
1323 );
1324 }
1325 let epoch = coord(
1326 CoordinateDType::Datetime,
1327 false,
1328 explicit(nums(&[1.716976e9])),
1329 );
1330 assert!(epoch.validate("time", Some(1), false).is_err());
1331 }
1332
1333 #[test]
1334 fn datetime_ordered_coordinates_enforce_strict_monotonic() {
1335 let ok = coord(
1336 CoordinateDType::Datetime,
1337 true,
1338 explicit(strings(&["2026-05-29T10:00:00Z", "2026-05-29T10:00:01Z"])),
1339 );
1340 assert!(ok.validate("time", Some(2), false).is_ok());
1341 let stalled = coord(
1342 CoordinateDType::Datetime,
1343 true,
1344 explicit(strings(&["2026-05-29T10:00:01Z", "2026-05-29T10:00:01Z"])),
1345 );
1346 assert!(stalled.validate("time", Some(2), false).is_err());
1347 }
1348
1349 #[test]
1350 fn regular_grid_coordinates_validate_numeric_sized_nonzero_ordered() {
1351 let ok = coord(
1352 CoordinateDType::Numeric,
1353 true,
1354 CoordinateValues::RegularGrid {
1355 start: 400.0,
1356 step: 2.0,
1357 },
1358 );
1359 assert!(ok.validate("wl", Some(100), false).is_ok());
1360 let descending = coord(
1361 CoordinateDType::Numeric,
1362 true,
1363 CoordinateValues::RegularGrid {
1364 start: 400.0,
1365 step: -2.0,
1366 },
1367 );
1368 assert!(descending.validate("wl", Some(100), false).is_ok());
1369 let categorical = coord(
1370 CoordinateDType::Categorical,
1371 true,
1372 CoordinateValues::RegularGrid {
1373 start: 0.0,
1374 step: 1.0,
1375 },
1376 );
1377 assert!(categorical.validate("wl", Some(3), false).is_err());
1378 let no_size = coord(
1379 CoordinateDType::Numeric,
1380 true,
1381 CoordinateValues::RegularGrid {
1382 start: 0.0,
1383 step: 1.0,
1384 },
1385 );
1386 assert!(no_size.validate("wl", None, false).is_err());
1387 let zero_step = coord(
1388 CoordinateDType::Numeric,
1389 true,
1390 CoordinateValues::RegularGrid {
1391 start: 0.0,
1392 step: 0.0,
1393 },
1394 );
1395 assert!(zero_step.validate("wl", Some(3), false).is_err());
1396 let unordered = coord(
1397 CoordinateDType::Numeric,
1398 false,
1399 CoordinateValues::RegularGrid {
1400 start: 0.0,
1401 step: 1.0,
1402 },
1403 );
1404 assert!(unordered.validate("wl", Some(3), false).is_err());
1405 }
1406
1407 #[test]
1408 fn explicit_coordinates_must_match_known_size_and_be_non_empty() {
1409 let wrong_len = coord(CoordinateDType::Numeric, false, explicit(nums(&[1.0, 2.0])));
1410 assert!(wrong_len.validate("wl", Some(3), false).is_err());
1411 let empty = coord(CoordinateDType::Numeric, false, explicit(Vec::new()));
1412 assert!(empty.validate("wl", Some(0), false).is_err());
1413 }
1414
1415 #[test]
1416 fn axis_validate_integrates_coordinate_and_unit_rules() {
1417 let blank_unit = AxisSpec {
1418 name: "wl".to_string(),
1419 kind: AxisKind::Wavenumber,
1420 unit: Some(" ".to_string()),
1421 size: Some(2),
1422 variable: false,
1423 coordinate: None,
1424 };
1425 assert!(blank_unit.validate().is_err());
1426
1427 let variable_with_coordinate = AxisSpec {
1428 name: "wl".to_string(),
1429 kind: AxisKind::Feature,
1430 unit: None,
1431 size: None,
1432 variable: true,
1433 coordinate: Some(coord(
1434 CoordinateDType::Numeric,
1435 false,
1436 explicit(nums(&[1.0])),
1437 )),
1438 };
1439 assert!(variable_with_coordinate.validate().is_err());
1440
1441 let ok = AxisSpec {
1442 name: "wl".to_string(),
1443 kind: AxisKind::Wavenumber,
1444 unit: Some("cm-1".to_string()),
1445 size: Some(3),
1446 variable: false,
1447 coordinate: Some(coord(
1448 CoordinateDType::Numeric,
1449 true,
1450 explicit(nums(&[400.0, 402.0, 404.0])),
1451 )),
1452 };
1453 assert!(ok.validate().is_ok());
1454 }
1455
1456 #[test]
1457 fn coordinate_spec_round_trips_through_json() {
1458 let explicit_spec = coord(
1459 CoordinateDType::Categorical,
1460 true,
1461 explicit(strings(&["R", "G", "B"])),
1462 );
1463 let text = serde_json::to_string(&explicit_spec).unwrap();
1464 assert!(text.contains("\"kind\":\"explicit\""));
1465 assert_eq!(
1466 serde_json::from_str::<CoordinateSpec>(&text).unwrap(),
1467 explicit_spec
1468 );
1469
1470 let grid_spec = coord(
1471 CoordinateDType::Numeric,
1472 true,
1473 CoordinateValues::RegularGrid {
1474 start: 400.0,
1475 step: 2.0,
1476 },
1477 );
1478 let text = serde_json::to_string(&grid_spec).unwrap();
1479 assert!(text.contains("\"kind\":\"regular_grid\""));
1480 assert_eq!(
1481 serde_json::from_str::<CoordinateSpec>(&text).unwrap(),
1482 grid_spec
1483 );
1484 }
1485
1486 #[test]
1487 fn axis_spec_rejects_legacy_coordinates_field() {
1488 let legacy = r#"{"name":"wl","kind":"wavelength","unit":"nm","size":2,"variable":false,"coordinates":[900,1000]}"#;
1491 assert!(serde_json::from_str::<AxisSpec>(legacy).is_err());
1492
1493 let typed = r#"{"name":"wl","kind":"wavelength","unit":"nm","size":2,"variable":false,"coordinate":{"dtype":"numeric","ordered":true,"values":{"kind":"explicit","values":[900,1000]}}}"#;
1494 let axis = serde_json::from_str::<AxisSpec>(typed).unwrap();
1495 assert!(axis.validate().is_ok());
1496 assert!(axis.coordinate.is_some());
1497 }
1498
1499 #[test]
1500 fn datetime_coordinates_reject_impossible_calendar_dates() {
1501 for bad in [
1502 "2026-02-31T00:00:00Z", "2026-04-31T00:00:00Z", "2025-02-29T00:00:00Z", "2026-01-01T00:00:60Z", ] {
1507 let spec = coord(CoordinateDType::Datetime, false, explicit(strings(&[bad])));
1508 assert!(
1509 spec.validate("time", Some(1), false).is_err(),
1510 "expected reject for {bad}"
1511 );
1512 }
1513 let leap_day = coord(
1515 CoordinateDType::Datetime,
1516 false,
1517 explicit(strings(&["2024-02-29T23:59:59Z"])),
1518 );
1519 assert!(leap_day.validate("time", Some(1), false).is_ok());
1520 }
1521
1522 #[test]
1523 fn signal_type_match_honours_caller_unknown_policy() {
1524 use SignalKind::*;
1525 assert!(require_signal_type_match(Absorbance, Absorbance, false).is_ok());
1527 let error = require_signal_type_match(Absorbance, Reflectance, true).unwrap_err();
1529 assert_eq!(error.code(), "signal_type_mismatch");
1530 assert_eq!(error.error_code(), 0x0008_0003);
1531 assert_eq!(error.context()["expected"], serde_json::json!("absorbance"));
1532 assert_eq!(error.context()["actual"], serde_json::json!("reflectance"));
1533 assert!(require_signal_type_match(Absorbance, Unknown, true).is_ok());
1535 assert!(require_signal_type_match(Absorbance, Unknown, false).is_err());
1536 assert!(require_signal_type_match(Unknown, Reflectance, true).is_ok());
1538 assert!(require_signal_type_match(Unknown, Reflectance, false).is_err());
1539 assert!(require_signal_type_match(Unknown, Unknown, true).is_ok());
1541 assert!(require_signal_type_match(Unknown, Unknown, false).is_err());
1542 }
1543}