use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::error::{DataError, Result};
use crate::ids::{GroupId, RepresentationId, SampleId, SourceId, TargetId, TypeId};
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum AxisKind {
Sample,
Feature,
Processing,
Time,
Height,
Width,
Channel,
Node,
Edge,
Variant,
Token,
Target,
Wavelength,
Wavenumber,
Frequency,
Depth,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AxisSpec {
pub name: String,
pub kind: AxisKind,
pub unit: Option<String>,
pub size: Option<usize>,
#[serde(default)]
pub variable: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub coordinate: Option<CoordinateSpec>,
}
impl AxisSpec {
pub fn validate(&self) -> Result<()> {
if self.name.trim().is_empty() {
return Err(DataError::Validation("axis name is empty".to_string()));
}
if self.variable && self.size.is_some() {
return Err(DataError::Validation(format!(
"axis `{}` cannot be both variable and sized",
self.name
)));
}
if let Some(unit) = &self.unit {
if unit.trim().is_empty() {
return Err(DataError::Validation(format!(
"axis `{}` has an empty unit",
self.name
)));
}
}
if let Some(coordinate) = &self.coordinate {
coordinate.validate(&self.name, self.size, self.variable)?;
}
Ok(())
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CoordinateDType {
Numeric,
Categorical,
Datetime,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CoordinateValues {
Explicit { values: Vec<serde_json::Value> },
RegularGrid { start: f64, step: f64 },
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CoordinateSpec {
pub dtype: CoordinateDType,
#[serde(default)]
pub ordered: bool,
pub values: CoordinateValues,
}
impl CoordinateSpec {
pub fn validate(&self, axis_name: &str, size: Option<usize>, variable: bool) -> Result<()> {
if variable {
return Err(DataError::Validation(format!(
"axis `{axis_name}` cannot carry coordinates while variable"
)));
}
match &self.values {
CoordinateValues::Explicit { values } => {
if values.is_empty() {
return Err(DataError::Validation(format!(
"axis `{axis_name}` has empty explicit coordinates"
)));
}
if let Some(size) = size {
if values.len() != size {
return Err(DataError::Validation(format!(
"axis `{axis_name}` has {} coordinates for size {size}",
values.len()
)));
}
}
self.validate_explicit(axis_name, values)?;
}
CoordinateValues::RegularGrid { start, step } => {
if self.dtype != CoordinateDType::Numeric {
return Err(DataError::Validation(format!(
"axis `{axis_name}` regular-grid coordinates require numeric dtype"
)));
}
if size.is_none() {
return Err(DataError::Validation(format!(
"axis `{axis_name}` regular-grid coordinates require a known axis size"
)));
}
if !start.is_finite() || !step.is_finite() {
return Err(DataError::Validation(format!(
"axis `{axis_name}` regular-grid start/step must be finite"
)));
}
if *step == 0.0 {
return Err(DataError::Validation(format!(
"axis `{axis_name}` regular-grid step must be non-zero"
)));
}
if !self.ordered {
return Err(DataError::Validation(format!(
"axis `{axis_name}` regular-grid coordinates are inherently ordered; set ordered=true"
)));
}
}
}
Ok(())
}
fn validate_explicit(&self, axis_name: &str, values: &[serde_json::Value]) -> Result<()> {
match self.dtype {
CoordinateDType::Numeric => {
let mut numbers = Vec::with_capacity(values.len());
for value in values {
let number = value
.as_f64()
.filter(|number| number.is_finite())
.ok_or_else(|| {
DataError::Validation(format!(
"axis `{axis_name}` numeric coordinate `{value}` is not a finite number"
))
})?;
numbers.push(number);
}
if self.ordered {
require_strictly_monotonic(axis_name, &numbers, |left, right| {
left.partial_cmp(right)
})?;
}
}
CoordinateDType::Categorical => {
let mut seen = BTreeSet::new();
for value in values {
let label = value.as_str().filter(|label| !label.is_empty()).ok_or_else(|| {
DataError::Validation(format!(
"axis `{axis_name}` categorical coordinate `{value}` is not a non-empty string"
))
})?;
if !seen.insert(label) {
return Err(DataError::Validation(format!(
"axis `{axis_name}` categorical coordinate `{label}` is duplicated"
)));
}
}
}
CoordinateDType::Datetime => {
let mut stamps = Vec::with_capacity(values.len());
for value in values {
let stamp = value.as_str().ok_or_else(|| {
DataError::Validation(format!(
"axis `{axis_name}` datetime coordinate `{value}` is not a string"
))
})?;
if !is_rfc3339_utc_seconds(stamp) {
return Err(DataError::Validation(format!(
"axis `{axis_name}` datetime coordinate `{stamp}` is not canonical RFC 3339 UTC seconds (YYYY-MM-DDThh:mm:ssZ)"
)));
}
stamps.push(stamp.to_string());
}
if self.ordered {
require_strictly_monotonic(axis_name, &stamps, |left, right| {
Some(left.cmp(right))
})?;
}
}
}
Ok(())
}
}
fn require_strictly_monotonic<T>(
axis_name: &str,
values: &[T],
compare: impl Fn(&T, &T) -> Option<std::cmp::Ordering>,
) -> Result<()> {
if values.len() < 2 {
return Ok(());
}
let first = compare(&values[1], &values[0]).ok_or_else(|| {
DataError::Validation(format!(
"axis `{axis_name}` ordered coordinates are not comparable"
))
})?;
if first == std::cmp::Ordering::Equal {
return Err(DataError::Validation(format!(
"axis `{axis_name}` ordered coordinates must be strictly monotonic"
)));
}
for window in values.windows(2) {
let ordering = compare(&window[1], &window[0]).ok_or_else(|| {
DataError::Validation(format!(
"axis `{axis_name}` ordered coordinates are not comparable"
))
})?;
if ordering != first {
return Err(DataError::Validation(format!(
"axis `{axis_name}` ordered coordinates must be strictly monotonic"
)));
}
}
Ok(())
}
fn is_rfc3339_utc_seconds(value: &str) -> bool {
let bytes = value.as_bytes();
if bytes.len() != 20 {
return false;
}
let digit_positions = [0, 1, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18];
if digit_positions
.iter()
.any(|position| !bytes[*position].is_ascii_digit())
{
return false;
}
if bytes[4] != b'-'
|| bytes[7] != b'-'
|| bytes[10] != b'T'
|| bytes[13] != b':'
|| bytes[16] != b':'
|| bytes[19] != b'Z'
{
return false;
}
let field = |start: usize, end: usize| value[start..end].parse::<u32>().unwrap_or(u32::MAX);
let year = field(0, 4);
let month = field(5, 7);
let day = field(8, 10);
let hour = field(11, 13);
let minute = field(14, 16);
let second = field(17, 19);
if !(1..=12).contains(&month) || hour > 23 || minute > 59 || second > 59 {
return false;
}
let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
let days_in_month = match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if leap => 29,
2 => 28,
_ => unreachable!("month already validated in 1..=12"),
};
(1..=days_in_month).contains(&day)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum SignalKind {
Absorbance,
Reflectance,
Transmittance,
LogReflectance,
Preprocessed,
Unknown,
}
impl SignalKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Absorbance => "absorbance",
Self::Reflectance => "reflectance",
Self::Transmittance => "transmittance",
Self::LogReflectance => "log_reflectance",
Self::Preprocessed => "preprocessed",
Self::Unknown => "unknown",
}
}
}
pub fn require_signal_type_match(
expected: SignalKind,
actual: SignalKind,
allow_unknown: bool,
) -> Result<()> {
if expected == SignalKind::Unknown || actual == SignalKind::Unknown {
return if allow_unknown {
Ok(())
} else {
Err(DataError::SignalTypeMismatch {
expected: expected.as_str(),
actual: actual.as_str(),
})
};
}
if expected == actual {
return Ok(());
}
Err(DataError::SignalTypeMismatch {
expected: expected.as_str(),
actual: actual.as_str(),
})
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct AxisSizeContract {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exact: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max: Option<usize>,
}
impl AxisSizeContract {
pub fn validate(&self, axis_name: &str) -> Result<()> {
if self.exact.is_none() && self.min.is_none() && self.max.is_none() {
return Err(DataError::Validation(format!(
"shape contract for axis `{axis_name}` does not constrain the size"
)));
}
if let (Some(min), Some(max)) = (self.min, self.max) {
if min > max {
return Err(DataError::Validation(format!(
"shape contract for axis `{axis_name}` has min {min} greater than max {max}"
)));
}
}
if let Some(exact) = self.exact {
if let Some(min) = self.min {
if exact < min {
return Err(DataError::Validation(format!(
"shape contract for axis `{axis_name}` exact size {exact} is below min {min}"
)));
}
}
if let Some(max) = self.max {
if exact > max {
return Err(DataError::Validation(format!(
"shape contract for axis `{axis_name}` exact size {exact} is above max {max}"
)));
}
}
}
Ok(())
}
fn accepts(&self, size: usize) -> bool {
if self.exact.is_some_and(|exact| size != exact) {
return false;
}
if self.min.is_some_and(|min| size < min) {
return false;
}
if self.max.is_some_and(|max| size > max) {
return false;
}
true
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct ShapeContract {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rank: Option<usize>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub axis_sizes: BTreeMap<String, AxisSizeContract>,
#[serde(default)]
pub allow_ragged: bool,
}
impl ShapeContract {
pub fn validate(&self) -> Result<()> {
if self.rank.is_none() && self.axis_sizes.is_empty() {
return Err(DataError::Validation(
"shape contract must constrain rank or at least one axis".to_string(),
));
}
for (axis_name, contract) in &self.axis_sizes {
if axis_name.trim().is_empty() {
return Err(DataError::Validation(
"shape contract contains an empty axis name".to_string(),
));
}
contract.validate(axis_name)?;
}
Ok(())
}
pub fn validate_representation(
&self,
source_id: &SourceId,
representation: &RepresentationSpec,
) -> Result<()> {
self.validate()?;
if let Some(expected_rank) = self.rank {
if representation.rank != Some(expected_rank) {
return Err(DataError::Validation(format!(
"source `{source_id}` shape contract expects rank {expected_rank} but representation `{}` has {:?}",
representation.id, representation.rank
)));
}
}
if representation.ragged && !self.allow_ragged {
return Err(DataError::Validation(format!(
"source `{source_id}` shape contract does not allow ragged representation `{}`",
representation.id
)));
}
for (axis_name, contract) in &self.axis_sizes {
let axis = representation
.axes
.iter()
.find(|axis| axis.name == *axis_name)
.ok_or_else(|| {
DataError::Validation(format!(
"source `{source_id}` shape contract references missing axis `{axis_name}`"
))
})?;
if let Some(size) = axis.size {
if !contract.accepts(size) {
return Err(DataError::Validation(format!(
"source `{source_id}` axis `{axis_name}` size {size} violates shape contract"
)));
}
} else if !axis.variable {
return Err(DataError::Validation(format!(
"source `{source_id}` axis `{axis_name}` has no concrete size for shape contract"
)));
}
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RepresentationSpec {
pub id: RepresentationId,
pub type_id: TypeId,
pub rank: Option<usize>,
pub axes: Vec<AxisSpec>,
pub container: String,
pub dtype: Option<String>,
#[serde(default)]
pub sparse: bool,
#[serde(default)]
pub ragged: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signal_type: Option<SignalKind>,
}
impl RepresentationSpec {
pub fn validate(&self) -> Result<()> {
if self.container.trim().is_empty() {
return Err(DataError::Validation(format!(
"representation `{}` has an empty container",
self.id
)));
}
if self.rank.is_none() && !self.ragged {
return Err(DataError::Validation(format!(
"representation `{}` with no rank must be ragged",
self.id
)));
}
if let Some(rank) = self.rank {
if self.axes.len() != rank {
return Err(DataError::Validation(format!(
"representation `{}` has rank {} but {} axes",
self.id,
rank,
self.axes.len()
)));
}
}
for axis in &self.axes {
axis.validate()?;
}
if self.container != "graph_batch"
&& !self.axes.iter().any(|axis| axis.kind == AxisKind::Sample)
{
return Err(DataError::Validation(format!(
"representation `{}` has no sample axis",
self.id
)));
}
Ok(())
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SourceGranularity {
PerSample,
PerSampleRepeated,
PerSampleSequence,
PerSampleSet,
PerGroup,
PerTarget,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SourceDescriptor {
pub id: SourceId,
pub name: String,
pub type_id: TypeId,
pub modality: String,
pub native_representation: RepresentationSpec,
pub sample_key: String,
pub granularity: SourceGranularity,
#[serde(default)]
pub schema: BTreeMap<String, serde_json::Value>,
#[serde(default)]
pub tags: BTreeMap<String, serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shape_contract: Option<ShapeContract>,
}
impl SourceDescriptor {
pub fn validate(&self) -> Result<()> {
if self.name.trim().is_empty() {
return Err(DataError::Validation(format!(
"source `{}` has an empty name",
self.id
)));
}
if self.sample_key.trim().is_empty() {
return Err(DataError::Validation(format!(
"source `{}` has an empty sample key",
self.id
)));
}
self.native_representation.validate()?;
if let Some(shape_contract) = &self.shape_contract {
shape_contract.validate_representation(&self.id, &self.native_representation)?;
}
Ok(())
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MetadataValueKind {
String,
Number,
Integer,
Boolean,
Date,
Datetime,
Categorical,
Json,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MetadataFieldSpec {
pub kind: MetadataValueKind,
#[serde(default)]
pub required: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unit: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_values: Vec<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
impl MetadataFieldSpec {
pub fn validate(&self, field_name: &str) -> Result<()> {
if self.kind == MetadataValueKind::Categorical && self.allowed_values.is_empty() {
return Err(DataError::Validation(format!(
"metadata field `{field_name}` is categorical but declares no allowed_values"
)));
}
if let Some(unit) = &self.unit {
if unit.trim().is_empty() {
return Err(DataError::Validation(format!(
"metadata field `{field_name}` has an empty unit"
)));
}
}
Ok(())
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct MetadataSchema {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub fields: BTreeMap<String, MetadataFieldSpec>,
}
impl MetadataSchema {
pub fn validate(&self) -> Result<()> {
if self.fields.is_empty() {
return Err(DataError::Validation(
"metadata schema declares no fields".to_string(),
));
}
for (field_name, field) in &self.fields {
if field_name.trim().is_empty() {
return Err(DataError::Validation(
"metadata schema contains an empty field name".to_string(),
));
}
field.validate(field_name)?;
}
Ok(())
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GroupKind {
RepetitionGroup,
Subject,
Batch,
Split,
Custom,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GroupSpec {
pub id: GroupId,
pub kind: GroupKind,
pub column: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_id: Option<SourceId>,
#[serde(default)]
pub strict: bool,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub metadata: BTreeMap<String, serde_json::Value>,
}
impl GroupSpec {
pub fn validate(&self) -> Result<()> {
if self.column.trim().is_empty() {
return Err(DataError::Validation(format!(
"group `{}` has an empty column",
self.id
)));
}
for key in self.metadata.keys() {
if key.trim().is_empty() {
return Err(DataError::Validation(format!(
"group `{}` metadata contains an empty key",
self.id
)));
}
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FoldSpec {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group_id: Option<GroupId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub split_column: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub metadata: BTreeMap<String, serde_json::Value>,
}
impl FoldSpec {
pub fn validate(&self) -> Result<()> {
if self.id.trim().is_empty() {
return Err(DataError::Validation("fold id is empty".to_string()));
}
if self.group_id.is_none() && self.split_column.is_none() {
return Err(DataError::Validation(format!(
"fold `{}` declares neither group_id nor split_column",
self.id
)));
}
if let Some(split_column) = &self.split_column {
if split_column.trim().is_empty() {
return Err(DataError::Validation(format!(
"fold `{}` has an empty split_column",
self.id
)));
}
}
for key in self.metadata.keys() {
if key.trim().is_empty() {
return Err(DataError::Validation(format!(
"fold `{}` metadata contains an empty key",
self.id
)));
}
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct DatasetSchema {
pub dataset_id: String,
pub sample_ids: Vec<SampleId>,
pub sources: Vec<SourceDescriptor>,
#[serde(default)]
pub targets: BTreeMap<TargetId, RepresentationSpec>,
#[serde(default)]
pub metadata: BTreeMap<String, RepresentationSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata_schema: Option<MetadataSchema>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub groups: Vec<GroupSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub folds: Vec<FoldSpec>,
}
impl DatasetSchema {
pub fn validate(&self) -> Result<()> {
if self.dataset_id.trim().is_empty() {
return Err(DataError::Validation(
"dataset id must not be empty".to_string(),
));
}
if self.sample_ids.is_empty() {
return Err(DataError::Validation(
"dataset schema must contain at least one sample".to_string(),
));
}
let unique_samples = self.sample_ids.iter().collect::<BTreeSet<_>>();
if unique_samples.len() != self.sample_ids.len() {
return Err(DataError::Validation(
"dataset schema contains duplicate sample ids".to_string(),
));
}
let mut source_ids = BTreeSet::new();
for source in &self.sources {
if !source_ids.insert(&source.id) {
return Err(DataError::Validation(format!(
"duplicate source id `{}`",
source.id
)));
}
source.validate()?;
}
for target in self.targets.values() {
target.validate()?;
}
for representation in self.metadata.values() {
representation.validate()?;
}
if let Some(metadata_schema) = &self.metadata_schema {
metadata_schema.validate()?;
}
let mut group_ids = BTreeSet::new();
for group in &self.groups {
if !group_ids.insert(&group.id) {
return Err(DataError::Validation(format!(
"duplicate group id `{}`",
group.id
)));
}
if let Some(source_id) = &group.source_id {
if !source_ids.contains(source_id) {
return Err(DataError::Validation(format!(
"group `{}` references unknown source `{source_id}`",
group.id
)));
}
}
group.validate()?;
}
let mut fold_ids = BTreeSet::new();
for fold in &self.folds {
if !fold_ids.insert(&fold.id) {
return Err(DataError::Validation(format!(
"duplicate fold id `{}`",
fold.id
)));
}
if let Some(group_id) = &fold.group_id {
if !group_ids.contains(group_id) {
return Err(DataError::Validation(format!(
"fold `{}` references unknown group `{group_id}`",
fold.id
)));
}
}
fold.validate()?;
}
Ok(())
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct DataView {
pub sample_ids: Option<Vec<SampleId>>,
pub partition: Option<String>,
pub fold_id: Option<String>,
pub source_ids: Option<Vec<SourceId>>,
pub columns: Option<Vec<String>>,
#[serde(default = "default_true")]
pub include_augmented: bool,
#[serde(default)]
pub include_excluded: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch_view: Option<crate::coordinator::CoordinatorBranchView>,
#[serde(default)]
pub extra: BTreeMap<String, serde_json::Value>,
}
fn default_true() -> bool {
true
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct PresenceMask {
pub sample_ids: Vec<SampleId>,
pub source_id: SourceId,
pub present: Vec<bool>,
}
impl PresenceMask {
pub fn validate(&self) -> Result<()> {
if self.sample_ids.len() != self.present.len() {
return Err(DataError::Validation(format!(
"presence mask for `{}` has {} sample ids but {} flags",
self.source_id,
self.sample_ids.len(),
self.present.len()
)));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_axis() -> AxisSpec {
AxisSpec {
name: "sample".to_string(),
kind: AxisKind::Sample,
unit: None,
size: Some(2),
variable: false,
coordinate: None,
}
}
#[test]
fn rejects_representation_without_sample_axis() {
let repr = RepresentationSpec {
id: RepresentationId::new("tabular").unwrap(),
type_id: TypeId::new("table").unwrap(),
rank: Some(1),
axes: vec![AxisSpec {
name: "feature".to_string(),
kind: AxisKind::Feature,
unit: None,
size: Some(3),
variable: false,
coordinate: None,
}],
container: "dataframe".to_string(),
dtype: Some("float32".to_string()),
sparse: false,
ragged: false,
signal_type: None,
};
assert!(repr.validate().is_err());
}
#[test]
fn accepts_sample_major_representation() {
let repr = RepresentationSpec {
id: RepresentationId::new("tabular").unwrap(),
type_id: TypeId::new("table").unwrap(),
rank: Some(1),
axes: vec![sample_axis()],
container: "dataframe".to_string(),
dtype: Some("float32".to_string()),
sparse: false,
ragged: false,
signal_type: None,
};
assert!(repr.validate().is_ok());
}
#[test]
fn axis_kind_wavenumber_serializes_and_round_trips() {
let value = AxisKind::Wavenumber;
let json = serde_json::to_string(&value).unwrap();
assert_eq!(json, "\"wavenumber\"");
let decoded: AxisKind = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, value);
}
#[test]
fn axis_kind_wavenumber_accepted_in_representation_axis() {
let axes = vec![
sample_axis(),
AxisSpec {
name: "wavenumber".to_string(),
kind: AxisKind::Wavenumber,
unit: Some("cm-1".to_string()),
size: Some(1024),
variable: false,
coordinate: None,
},
];
let repr = RepresentationSpec {
id: RepresentationId::new("ftir_spectrum").unwrap(),
type_id: TypeId::new("dense_signal").unwrap(),
rank: Some(2),
axes,
container: "ndarray".to_string(),
dtype: Some("float64".to_string()),
sparse: false,
ragged: false,
signal_type: Some(SignalKind::Absorbance),
};
repr.validate().unwrap();
}
#[test]
fn dataset_schema_accepts_optional_nirs4all_integration_contracts() {
let source_id = SourceId::new("nir").unwrap();
let group_id = GroupId::new("rep.group").unwrap();
let representation = RepresentationSpec {
id: RepresentationId::new("nir.signal").unwrap(),
type_id: TypeId::new("dense_signal").unwrap(),
rank: Some(2),
axes: vec![
sample_axis(),
AxisSpec {
name: "wavelength".to_string(),
kind: AxisKind::Wavelength,
unit: Some("nm".to_string()),
size: Some(3),
variable: false,
coordinate: None,
},
],
container: "ndarray".to_string(),
dtype: Some("float32".to_string()),
sparse: false,
ragged: false,
signal_type: Some(SignalKind::Reflectance),
};
let schema = DatasetSchema {
dataset_id: "nirs4all-lite-smoke".to_string(),
sample_ids: vec![SampleId::new("s1").unwrap(), SampleId::new("s2").unwrap()],
sources: vec![SourceDescriptor {
id: source_id.clone(),
name: "NIR spectra".to_string(),
type_id: TypeId::new("dense_signal").unwrap(),
modality: "nir".to_string(),
native_representation: representation,
sample_key: "sample_id".to_string(),
granularity: SourceGranularity::PerSampleRepeated,
schema: BTreeMap::new(),
tags: BTreeMap::new(),
shape_contract: Some(ShapeContract {
rank: Some(2),
axis_sizes: BTreeMap::from([(
"wavelength".to_string(),
AxisSizeContract {
exact: Some(3),
min: None,
max: None,
},
)]),
allow_ragged: false,
}),
}],
targets: BTreeMap::new(),
metadata: BTreeMap::new(),
metadata_schema: Some(MetadataSchema {
fields: BTreeMap::from([(
"cultivar".to_string(),
MetadataFieldSpec {
kind: MetadataValueKind::Categorical,
required: true,
unit: None,
allowed_values: vec![serde_json::Value::String("a".to_string())],
description: None,
},
)]),
}),
groups: vec![GroupSpec {
id: group_id.clone(),
kind: GroupKind::RepetitionGroup,
column: "sample_id".to_string(),
source_id: Some(source_id),
strict: true,
metadata: BTreeMap::new(),
}],
folds: vec![FoldSpec {
id: "cv.repetition.safe".to_string(),
group_id: Some(group_id),
split_column: Some("fold_id".to_string()),
metadata: BTreeMap::new(),
}],
};
schema.validate().unwrap();
let json = serde_json::to_value(&schema).unwrap();
assert_eq!(
json["sources"][0]["native_representation"]["signal_type"],
"reflectance"
);
assert_eq!(json["groups"][0]["kind"], "repetition_group");
}
#[test]
fn dataset_schema_refuses_shape_contract_mismatch() {
let representation = RepresentationSpec {
id: RepresentationId::new("nir.signal").unwrap(),
type_id: TypeId::new("dense_signal").unwrap(),
rank: Some(2),
axes: vec![
sample_axis(),
AxisSpec {
name: "wavelength".to_string(),
kind: AxisKind::Wavelength,
unit: Some("nm".to_string()),
size: Some(3),
variable: false,
coordinate: None,
},
],
container: "ndarray".to_string(),
dtype: Some("float32".to_string()),
sparse: false,
ragged: false,
signal_type: Some(SignalKind::Absorbance),
};
let source = SourceDescriptor {
id: SourceId::new("nir").unwrap(),
name: "NIR spectra".to_string(),
type_id: TypeId::new("dense_signal").unwrap(),
modality: "nir".to_string(),
native_representation: representation,
sample_key: "sample_id".to_string(),
granularity: SourceGranularity::PerSample,
schema: BTreeMap::new(),
tags: BTreeMap::new(),
shape_contract: Some(ShapeContract {
rank: Some(2),
axis_sizes: BTreeMap::from([(
"wavelength".to_string(),
AxisSizeContract {
exact: Some(4),
min: None,
max: None,
},
)]),
allow_ragged: false,
}),
};
assert!(source.validate().is_err());
}
#[test]
fn dataset_schema_refuses_empty_shape_contract() {
let representation = RepresentationSpec {
id: RepresentationId::new("nir.signal").unwrap(),
type_id: TypeId::new("dense_signal").unwrap(),
rank: Some(2),
axes: vec![
sample_axis(),
AxisSpec {
name: "wavelength".to_string(),
kind: AxisKind::Wavelength,
unit: Some("nm".to_string()),
size: Some(3),
variable: false,
coordinate: None,
},
],
container: "ndarray".to_string(),
dtype: Some("float32".to_string()),
sparse: false,
ragged: false,
signal_type: None,
};
let source = SourceDescriptor {
id: SourceId::new("nir").unwrap(),
name: "NIR spectra".to_string(),
type_id: TypeId::new("dense_signal").unwrap(),
modality: "nir".to_string(),
native_representation: representation,
sample_key: "sample_id".to_string(),
granularity: SourceGranularity::PerSample,
schema: BTreeMap::new(),
tags: BTreeMap::new(),
shape_contract: Some(ShapeContract::default()),
};
assert!(source.validate().is_err());
}
#[test]
fn dataset_schema_refuses_unknown_fold_group() {
let schema = DatasetSchema {
dataset_id: "folds".to_string(),
sample_ids: vec![SampleId::new("s1").unwrap()],
sources: Vec::new(),
targets: BTreeMap::new(),
metadata: BTreeMap::new(),
metadata_schema: None,
groups: Vec::new(),
folds: vec![FoldSpec {
id: "fold.cv".to_string(),
group_id: Some(GroupId::new("missing").unwrap()),
split_column: None,
metadata: BTreeMap::new(),
}],
};
assert!(schema.validate().is_err());
}
#[test]
fn dataset_schema_refuses_empty_fold_declaration() {
let schema = DatasetSchema {
dataset_id: "folds".to_string(),
sample_ids: vec![SampleId::new("s1").unwrap()],
sources: Vec::new(),
targets: BTreeMap::new(),
metadata: BTreeMap::new(),
metadata_schema: None,
groups: Vec::new(),
folds: vec![FoldSpec {
id: "fold.cv".to_string(),
group_id: None,
split_column: None,
metadata: BTreeMap::new(),
}],
};
let error = schema.validate().unwrap_err();
assert!(error
.to_string()
.contains("neither group_id nor split_column"));
}
fn coord(dtype: CoordinateDType, ordered: bool, values: CoordinateValues) -> CoordinateSpec {
CoordinateSpec {
dtype,
ordered,
values,
}
}
fn explicit(values: Vec<serde_json::Value>) -> CoordinateValues {
CoordinateValues::Explicit { values }
}
fn nums(values: &[f64]) -> Vec<serde_json::Value> {
values
.iter()
.map(|value| serde_json::Value::from(*value))
.collect()
}
fn strings(values: &[&str]) -> Vec<serde_json::Value> {
values
.iter()
.map(|value| serde_json::Value::from(*value))
.collect()
}
#[test]
fn numeric_ordered_coordinates_accept_ascending_or_descending() {
let ascending = coord(
CoordinateDType::Numeric,
true,
explicit(nums(&[400.0, 402.0, 404.0])),
);
assert!(ascending.validate("wl", Some(3), false).is_ok());
let descending = coord(
CoordinateDType::Numeric,
true,
explicit(nums(&[404.0, 402.0, 400.0])),
);
assert!(descending.validate("wl", Some(3), false).is_ok());
}
#[test]
fn numeric_ordered_coordinates_reject_non_monotonic_and_duplicates() {
let bumpy = coord(
CoordinateDType::Numeric,
true,
explicit(nums(&[400.0, 404.0, 402.0])),
);
assert!(bumpy.validate("wl", Some(3), false).is_err());
let duplicate = coord(
CoordinateDType::Numeric,
true,
explicit(nums(&[400.0, 400.0])),
);
assert!(duplicate.validate("wl", Some(2), false).is_err());
}
#[test]
fn numeric_coordinates_reject_non_finite_and_non_number() {
let not_finite = coord(
CoordinateDType::Numeric,
false,
explicit(vec![serde_json::Value::from(f64::NAN)]),
);
assert!(not_finite.validate("wl", Some(1), false).is_err());
let text = coord(CoordinateDType::Numeric, false, explicit(strings(&["400"])));
assert!(text.validate("wl", Some(1), false).is_err());
}
#[test]
fn categorical_coordinates_require_unique_non_empty_strings() {
let ok = coord(
CoordinateDType::Categorical,
false,
explicit(strings(&["R", "G", "B"])),
);
assert!(ok.validate("channel", Some(3), false).is_ok());
let duplicate = coord(
CoordinateDType::Categorical,
false,
explicit(strings(&["R", "R"])),
);
assert!(duplicate.validate("channel", Some(2), false).is_err());
let empty = coord(
CoordinateDType::Categorical,
false,
explicit(strings(&[""])),
);
assert!(empty.validate("channel", Some(1), false).is_err());
let numeric_label = coord(CoordinateDType::Categorical, false, explicit(nums(&[1.0])));
assert!(numeric_label.validate("channel", Some(1), false).is_err());
let ordered = coord(
CoordinateDType::Categorical,
true,
explicit(strings(&["Z", "A"])),
);
assert!(ordered.validate("channel", Some(2), false).is_ok());
}
#[test]
fn datetime_coordinates_require_canonical_rfc3339_utc_seconds() {
let ok = coord(
CoordinateDType::Datetime,
false,
explicit(strings(&["2026-05-29T10:00:00Z"])),
);
assert!(ok.validate("time", Some(1), false).is_ok());
for bad in [
"2026-05-29 10:00:00",
"2026-05-29T10:00:00+02:00",
"2026-13-29T10:00:00Z",
] {
let spec = coord(CoordinateDType::Datetime, false, explicit(strings(&[bad])));
assert!(
spec.validate("time", Some(1), false).is_err(),
"expected reject for {bad}"
);
}
let epoch = coord(
CoordinateDType::Datetime,
false,
explicit(nums(&[1.716976e9])),
);
assert!(epoch.validate("time", Some(1), false).is_err());
}
#[test]
fn datetime_ordered_coordinates_enforce_strict_monotonic() {
let ok = coord(
CoordinateDType::Datetime,
true,
explicit(strings(&["2026-05-29T10:00:00Z", "2026-05-29T10:00:01Z"])),
);
assert!(ok.validate("time", Some(2), false).is_ok());
let stalled = coord(
CoordinateDType::Datetime,
true,
explicit(strings(&["2026-05-29T10:00:01Z", "2026-05-29T10:00:01Z"])),
);
assert!(stalled.validate("time", Some(2), false).is_err());
}
#[test]
fn regular_grid_coordinates_validate_numeric_sized_nonzero_ordered() {
let ok = coord(
CoordinateDType::Numeric,
true,
CoordinateValues::RegularGrid {
start: 400.0,
step: 2.0,
},
);
assert!(ok.validate("wl", Some(100), false).is_ok());
let descending = coord(
CoordinateDType::Numeric,
true,
CoordinateValues::RegularGrid {
start: 400.0,
step: -2.0,
},
);
assert!(descending.validate("wl", Some(100), false).is_ok());
let categorical = coord(
CoordinateDType::Categorical,
true,
CoordinateValues::RegularGrid {
start: 0.0,
step: 1.0,
},
);
assert!(categorical.validate("wl", Some(3), false).is_err());
let no_size = coord(
CoordinateDType::Numeric,
true,
CoordinateValues::RegularGrid {
start: 0.0,
step: 1.0,
},
);
assert!(no_size.validate("wl", None, false).is_err());
let zero_step = coord(
CoordinateDType::Numeric,
true,
CoordinateValues::RegularGrid {
start: 0.0,
step: 0.0,
},
);
assert!(zero_step.validate("wl", Some(3), false).is_err());
let unordered = coord(
CoordinateDType::Numeric,
false,
CoordinateValues::RegularGrid {
start: 0.0,
step: 1.0,
},
);
assert!(unordered.validate("wl", Some(3), false).is_err());
}
#[test]
fn explicit_coordinates_must_match_known_size_and_be_non_empty() {
let wrong_len = coord(CoordinateDType::Numeric, false, explicit(nums(&[1.0, 2.0])));
assert!(wrong_len.validate("wl", Some(3), false).is_err());
let empty = coord(CoordinateDType::Numeric, false, explicit(Vec::new()));
assert!(empty.validate("wl", Some(0), false).is_err());
}
#[test]
fn axis_validate_integrates_coordinate_and_unit_rules() {
let blank_unit = AxisSpec {
name: "wl".to_string(),
kind: AxisKind::Wavenumber,
unit: Some(" ".to_string()),
size: Some(2),
variable: false,
coordinate: None,
};
assert!(blank_unit.validate().is_err());
let variable_with_coordinate = AxisSpec {
name: "wl".to_string(),
kind: AxisKind::Feature,
unit: None,
size: None,
variable: true,
coordinate: Some(coord(
CoordinateDType::Numeric,
false,
explicit(nums(&[1.0])),
)),
};
assert!(variable_with_coordinate.validate().is_err());
let ok = AxisSpec {
name: "wl".to_string(),
kind: AxisKind::Wavenumber,
unit: Some("cm-1".to_string()),
size: Some(3),
variable: false,
coordinate: Some(coord(
CoordinateDType::Numeric,
true,
explicit(nums(&[400.0, 402.0, 404.0])),
)),
};
assert!(ok.validate().is_ok());
}
#[test]
fn coordinate_spec_round_trips_through_json() {
let explicit_spec = coord(
CoordinateDType::Categorical,
true,
explicit(strings(&["R", "G", "B"])),
);
let text = serde_json::to_string(&explicit_spec).unwrap();
assert!(text.contains("\"kind\":\"explicit\""));
assert_eq!(
serde_json::from_str::<CoordinateSpec>(&text).unwrap(),
explicit_spec
);
let grid_spec = coord(
CoordinateDType::Numeric,
true,
CoordinateValues::RegularGrid {
start: 400.0,
step: 2.0,
},
);
let text = serde_json::to_string(&grid_spec).unwrap();
assert!(text.contains("\"kind\":\"regular_grid\""));
assert_eq!(
serde_json::from_str::<CoordinateSpec>(&text).unwrap(),
grid_spec
);
}
#[test]
fn axis_spec_rejects_legacy_coordinates_field() {
let legacy = r#"{"name":"wl","kind":"wavelength","unit":"nm","size":2,"variable":false,"coordinates":[900,1000]}"#;
assert!(serde_json::from_str::<AxisSpec>(legacy).is_err());
let typed = r#"{"name":"wl","kind":"wavelength","unit":"nm","size":2,"variable":false,"coordinate":{"dtype":"numeric","ordered":true,"values":{"kind":"explicit","values":[900,1000]}}}"#;
let axis = serde_json::from_str::<AxisSpec>(typed).unwrap();
assert!(axis.validate().is_ok());
assert!(axis.coordinate.is_some());
}
#[test]
fn datetime_coordinates_reject_impossible_calendar_dates() {
for bad in [
"2026-02-31T00:00:00Z", "2026-04-31T00:00:00Z", "2025-02-29T00:00:00Z", "2026-01-01T00:00:60Z", ] {
let spec = coord(CoordinateDType::Datetime, false, explicit(strings(&[bad])));
assert!(
spec.validate("time", Some(1), false).is_err(),
"expected reject for {bad}"
);
}
let leap_day = coord(
CoordinateDType::Datetime,
false,
explicit(strings(&["2024-02-29T23:59:59Z"])),
);
assert!(leap_day.validate("time", Some(1), false).is_ok());
}
#[test]
fn signal_type_match_honours_caller_unknown_policy() {
use SignalKind::*;
assert!(require_signal_type_match(Absorbance, Absorbance, false).is_ok());
let error = require_signal_type_match(Absorbance, Reflectance, true).unwrap_err();
assert_eq!(error.code(), "signal_type_mismatch");
assert_eq!(error.error_code(), 0x0008_0003);
assert_eq!(error.context()["expected"], serde_json::json!("absorbance"));
assert_eq!(error.context()["actual"], serde_json::json!("reflectance"));
assert!(require_signal_type_match(Absorbance, Unknown, true).is_ok());
assert!(require_signal_type_match(Absorbance, Unknown, false).is_err());
assert!(require_signal_type_match(Unknown, Reflectance, true).is_ok());
assert!(require_signal_type_match(Unknown, Reflectance, false).is_err());
assert!(require_signal_type_match(Unknown, Unknown, true).is_ok());
assert!(require_signal_type_match(Unknown, Unknown, false).is_err());
}
}