Skip to main content

dag_ml_data_core/
model.rs

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/// Element dtype of an axis coordinate sequence.
70#[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/// Axis coordinate values: either an explicit per-index list or a regular
79/// numeric grid (`value(i) = start + i * step`, count taken from the axis size).
80#[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/// Typed coordinate contract for an axis, so methods can machine-rely on the
88/// coordinate dtype, ordering and (for numeric) regular-grid structure rather
89/// than re-deriving them from untyped JSON.
90///
91/// `datetime` coordinates are canonical RFC 3339 UTC second-precision strings
92/// (`YYYY-MM-DDThh:mm:ssZ`); richer precision / offsets are a future extension.
93#[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                // `ordered` is the declared category order; labels are not compared.
192            }
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                    // The canonical fixed UTC-seconds form makes lexicographic
210                    // order equal to chronological order.
211                    require_strictly_monotonic(axis_name, &stamps, |left, right| {
212                        Some(left.cmp(right))
213                    })?;
214                }
215            }
216        }
217        Ok(())
218    }
219}
220
221/// Strictly monotonic in EITHER direction (ascending or descending); rejects
222/// equal/unorderable neighbours.
223fn 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
256/// Strict canonical RFC 3339 UTC second-precision check: `YYYY-MM-DDThh:mm:ssZ`.
257fn 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    /// The snake_case wire name, for diagnostics.
313    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
325/// Validate that a provider-declared `actual` signal type matches the `expected`
326/// one the plan or bundle records (ADR-06). The `Unknown` policy is the caller's:
327/// pass `allow_unknown = true` at train time (an untagged signal type is
328/// tolerated) and `false` at predict time (a trained pipeline must carry a
329/// concrete signal type, so any difference — including `Unknown` — is refused).
330///
331/// This is a reusable contract helper; `dag-ml-data` does not yet wire it into
332/// materialize because the "expected" side is carried by `dag-ml` lineage, not by
333/// the data envelope. The host bridge calls it with the two sides it owns.
334pub fn require_signal_type_match(
335    expected: SignalKind,
336    actual: SignalKind,
337    allow_unknown: bool,
338) -> Result<()> {
339    // `Unknown` on either side is decided by the caller's policy FIRST: at predict
340    // time (`allow_unknown = false`) any `Unknown` is refused — including when both
341    // sides are `Unknown` — because a trained pipeline must carry a concrete signal
342    // type (ADR-06).
343    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        // ordered categorical keeps the declared order; labels are not compared.
1315        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        // The pre-Phase-C untyped field is removed; deny_unknown_fields makes a
1506        // stale `coordinates` a hard error rather than silently dropped data.
1507        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", // February never has 31 days
1520            "2026-04-31T00:00:00Z", // April has 30 days
1521            "2025-02-29T00:00:00Z", // 2025 is not a leap year
1522            "2026-01-01T00:00:60Z", // leap-second :60 not accepted in v1
1523        ] {
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        // 2024 is a leap year, so Feb 29 is valid.
1531        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        // exact match always passes
1543        assert!(require_signal_type_match(Absorbance, Absorbance, false).is_ok());
1544        // two concrete, different types always mismatch
1545        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        // Unknown actual: tolerated at train (allow_unknown), refused at predict
1551        assert!(require_signal_type_match(Absorbance, Unknown, true).is_ok());
1552        assert!(require_signal_type_match(Absorbance, Unknown, false).is_err());
1553        // Unknown expected behaves symmetrically
1554        assert!(require_signal_type_match(Unknown, Reflectance, true).is_ok());
1555        assert!(require_signal_type_match(Unknown, Reflectance, false).is_err());
1556        // both-Unknown: tolerated at train, refused at predict (ADR-06)
1557        assert!(require_signal_type_match(Unknown, Unknown, true).is_ok());
1558        assert!(require_signal_type_match(Unknown, Unknown, false).is_err());
1559    }
1560}