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