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