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