use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::error::{DataError, Result};
use crate::ids::{GroupId, ObservationId, OriginId, RepetitionId, SampleId, SourceId, TargetId};
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct AugmentationMetadata {
pub transform_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub params_fingerprint: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub metadata: BTreeMap<String, serde_json::Value>,
}
impl AugmentationMetadata {
pub fn validate(&self) -> Result<()> {
if self.transform_id.trim().is_empty() {
return Err(DataError::Validation(
"augmentation metadata transform_id is empty".to_string(),
));
}
if let Some(params_fingerprint) = &self.params_fingerprint {
if params_fingerprint.len() != 64
|| !params_fingerprint
.bytes()
.all(|byte| byte.is_ascii_hexdigit())
{
return Err(DataError::Validation(format!(
"augmentation `{}` params_fingerprint must be a 64-character hex digest",
self.transform_id
)));
}
}
for key in self.metadata.keys() {
if key.trim().is_empty() {
return Err(DataError::Validation(format!(
"augmentation `{}` metadata contains an empty key",
self.transform_id
)));
}
}
Ok(())
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct SampleRelation {
pub observation_id: ObservationId,
pub sample_id: SampleId,
pub source_id: Option<SourceId>,
pub target_id: Option<TargetId>,
pub group_id: Option<GroupId>,
pub origin_id: Option<OriginId>,
pub repetition_id: Option<RepetitionId>,
#[serde(default)]
pub augmented: bool,
#[serde(default)]
pub excluded: bool,
#[serde(default)]
pub metadata: BTreeMap<String, serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub augmentation: Option<AugmentationMetadata>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct SampleRelationTable {
pub rows: Vec<SampleRelation>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct FoldAssignment {
pub fold_id: String,
pub train_sample_ids: Vec<SampleId>,
pub validation_sample_ids: Vec<SampleId>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub metadata: BTreeMap<String, serde_json::Value>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct FoldSet {
pub id: String,
pub sample_ids: Vec<SampleId>,
pub folds: Vec<FoldAssignment>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub sample_groups: BTreeMap<SampleId, GroupId>,
}
impl FoldSet {
pub fn validate(&self) -> Result<()> {
if self.id.trim().is_empty() {
return Err(DataError::Validation("fold set id is empty".to_string()));
}
if self.sample_ids.is_empty() {
return Err(DataError::Validation(
"fold set contains no samples".to_string(),
));
}
if self.folds.is_empty() {
return Err(DataError::Validation(
"fold set contains no folds".to_string(),
));
}
let universe = unique_samples("fold set sample_ids", &self.sample_ids)?;
if !self.sample_groups.is_empty() {
for sample_id in self.sample_groups.keys() {
if !universe.contains(sample_id) {
return Err(DataError::Validation(format!(
"sample group map references unknown sample `{sample_id}`"
)));
}
}
}
let mut fold_ids = BTreeSet::new();
let mut validation_counts = self
.sample_ids
.iter()
.cloned()
.map(|sample_id| (sample_id, 0usize))
.collect::<BTreeMap<_, _>>();
for fold in &self.folds {
if fold.fold_id.trim().is_empty() {
return Err(DataError::Validation(
"fold assignment id is empty".to_string(),
));
}
if !fold_ids.insert(&fold.fold_id) {
return Err(DataError::Validation(format!(
"duplicate fold id `{}`",
fold.fold_id
)));
}
let train = unique_samples(
&format!("fold `{}` train_sample_ids", fold.fold_id),
&fold.train_sample_ids,
)?;
let validation = unique_samples(
&format!("fold `{}` validation_sample_ids", fold.fold_id),
&fold.validation_sample_ids,
)?;
if validation.is_empty() {
return Err(DataError::Validation(format!(
"fold `{}` has no validation samples",
fold.fold_id
)));
}
for sample_id in train.union(&validation) {
if !universe.contains(sample_id) {
return Err(DataError::Validation(format!(
"fold `{}` references unknown sample `{}`",
fold.fold_id, sample_id
)));
}
}
if let Some(sample_id) = train.intersection(&validation).next() {
return Err(DataError::Validation(format!(
"fold `{}` has train/validation overlap at sample `{}`",
fold.fold_id, sample_id
)));
}
for sample_id in &validation {
*validation_counts
.get_mut(*sample_id)
.expect("validation sample is in universe") += 1;
}
validate_group_boundary(&fold.fold_id, &train, &validation, &self.sample_groups)?;
}
for (sample_id, count) in validation_counts {
if count != 1 {
return Err(DataError::Validation(format!(
"sample `{sample_id}` appears in validation {count} time(s), expected exactly once"
)));
}
}
Ok(())
}
}
impl SampleRelationTable {
pub fn validate(&self) -> Result<()> {
if self.rows.is_empty() {
return Err(DataError::Validation(
"sample relation table contains no rows".to_string(),
));
}
let mut observation_ids = BTreeSet::new();
let mut origin_ids = BTreeSet::new();
let mut sample_groups = BTreeMap::<&SampleId, &GroupId>::new();
for row in &self.rows {
if !observation_ids.insert(&row.observation_id) {
return Err(DataError::Validation(format!(
"duplicate observation id `{}`",
row.observation_id
)));
}
if let Some(group_id) = &row.group_id {
if let Some(previous_group_id) = sample_groups.insert(&row.sample_id, group_id) {
if previous_group_id != group_id {
return Err(DataError::Validation(format!(
"sample `{}` appears with conflicting groups `{}` and `{}`",
row.sample_id, previous_group_id, group_id
)));
}
}
}
if row.augmented && row.origin_id.is_none() {
return Err(DataError::Validation(format!(
"augmented observation `{}` has no origin_id",
row.observation_id
)));
}
if !row.augmented && row.origin_id.is_some() {
return Err(DataError::Validation(format!(
"non-augmented observation `{}` declares origin_id",
row.observation_id
)));
}
if let Some(augmentation) = &row.augmentation {
augmentation.validate()?;
if !row.augmented {
return Err(DataError::Validation(format!(
"non-augmented observation `{}` declares augmentation metadata",
row.observation_id
)));
}
}
if let Some(origin_id) = &row.origin_id {
if origin_id.as_str() == row.observation_id.as_str() {
return Err(DataError::Validation(format!(
"observation `{}` cannot be its own origin",
row.observation_id
)));
}
origin_ids.insert(origin_id);
}
}
for origin_id in origin_ids {
if !observation_ids
.iter()
.any(|observation_id| observation_id.as_str() == origin_id.as_str())
{
return Err(DataError::Validation(format!(
"origin `{origin_id}` is not present as an observation"
)));
}
}
Ok(())
}
pub fn sample_groups(&self) -> BTreeMap<SampleId, GroupId> {
self.rows
.iter()
.filter_map(|row| {
row.group_id
.as_ref()
.map(|group_id| (row.sample_id.clone(), group_id.clone()))
})
.collect()
}
pub fn validate_fold_set(&self, fold_set: &FoldSet) -> Result<()> {
self.validate()?;
fold_set.validate()?;
let relation_sample_ids = self
.rows
.iter()
.map(|row| row.sample_id.clone())
.collect::<BTreeSet<_>>();
let fold_sample_ids = fold_set.sample_ids.iter().cloned().collect::<BTreeSet<_>>();
if let Some(sample_id) = relation_sample_ids.difference(&fold_sample_ids).next() {
return Err(DataError::Validation(format!(
"fold set `{}` is missing relation sample `{sample_id}`",
fold_set.id
)));
}
if let Some(sample_id) = fold_sample_ids.difference(&relation_sample_ids).next() {
return Err(DataError::Validation(format!(
"fold set `{}` references sample `{sample_id}` absent from sample relations",
fold_set.id
)));
}
let relation_groups = self.sample_groups();
for (sample_id, fold_group_id) in &fold_set.sample_groups {
if let Some(relation_group_id) = relation_groups.get(sample_id) {
if relation_group_id != fold_group_id {
return Err(DataError::Validation(format!(
"fold set `{}` group `{}` for sample `{sample_id}` conflicts with relation group `{}`",
fold_set.id, fold_group_id, relation_group_id
)));
}
}
}
for fold in &fold_set.folds {
let train = fold.train_sample_ids.iter().collect::<BTreeSet<_>>();
let validation = fold.validation_sample_ids.iter().collect::<BTreeSet<_>>();
validate_group_boundary(&fold.fold_id, &train, &validation, &relation_groups)?;
validate_origin_boundary(&fold.fold_id, &train, &validation, self)?;
}
Ok(())
}
}
fn unique_samples<'a>(label: &str, samples: &'a [SampleId]) -> Result<BTreeSet<&'a SampleId>> {
let mut seen = BTreeSet::new();
for sample_id in samples {
if !seen.insert(sample_id) {
return Err(DataError::Validation(format!(
"{label} contains duplicate sample `{sample_id}`"
)));
}
}
Ok(seen)
}
fn validate_group_boundary(
fold_id: &str,
train: &BTreeSet<&SampleId>,
validation: &BTreeSet<&SampleId>,
sample_groups: &BTreeMap<SampleId, GroupId>,
) -> Result<()> {
if sample_groups.is_empty() {
return Ok(());
}
let train_groups = train
.iter()
.filter_map(|sample_id| sample_groups.get(*sample_id))
.collect::<BTreeSet<_>>();
for sample_id in validation {
if let Some(group_id) = sample_groups.get(*sample_id) {
if train_groups.contains(group_id) {
return Err(DataError::RelationBoundaryViolation {
kind: "group",
detail: format!(
"fold `{fold_id}` leaks group `{group_id}` across train/validation"
),
});
}
}
}
Ok(())
}
fn validate_origin_boundary(
fold_id: &str,
train: &BTreeSet<&SampleId>,
validation: &BTreeSet<&SampleId>,
relations: &SampleRelationTable,
) -> Result<()> {
let origin_sample_ids = relations
.rows
.iter()
.map(|row| (row.observation_id.as_str(), &row.sample_id))
.collect::<BTreeMap<_, _>>();
for row in &relations.rows {
let Some(origin_id) = &row.origin_id else {
continue;
};
let Some(origin_sample_id) = origin_sample_ids.get(origin_id.as_str()) else {
continue;
};
if train.contains(&row.sample_id) && validation.contains(origin_sample_id) {
return Err(DataError::RelationBoundaryViolation {
kind: "origin",
detail: format!(
"fold `{fold_id}` leaks augmentation origin `{origin_id}` across train/validation"
),
});
}
if validation.contains(&row.sample_id) && train.contains(origin_sample_id) {
return Err(DataError::RelationBoundaryViolation {
kind: "origin",
detail: format!(
"fold `{fold_id}` leaks augmentation origin `{origin_id}` across train/validation"
),
});
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn obs(value: &str) -> ObservationId {
ObservationId::new(value).unwrap()
}
fn sample(value: &str) -> SampleId {
SampleId::new(value).unwrap()
}
fn origin(value: &str) -> OriginId {
OriginId::new(value).unwrap()
}
fn row(observation_id: &str, sample_id: &str) -> SampleRelation {
SampleRelation {
observation_id: obs(observation_id),
sample_id: sample(sample_id),
source_id: None,
target_id: None,
group_id: None,
origin_id: None,
repetition_id: None,
augmented: false,
excluded: false,
metadata: BTreeMap::new(),
augmentation: None,
}
}
fn fold_set() -> FoldSet {
FoldSet {
id: "cv.group.safe".to_string(),
sample_ids: vec![sample("s1"), sample("s2")],
folds: vec![
FoldAssignment {
fold_id: "fold:0".to_string(),
train_sample_ids: vec![sample("s2")],
validation_sample_ids: vec![sample("s1")],
metadata: BTreeMap::new(),
},
FoldAssignment {
fold_id: "fold:1".to_string(),
train_sample_ids: vec![sample("s1")],
validation_sample_ids: vec![sample("s2")],
metadata: BTreeMap::new(),
},
],
sample_groups: BTreeMap::new(),
}
}
#[test]
fn validates_group_and_origin_relations() {
let mut base = row("obs1", "s1");
base.group_id = Some(GroupId::new("g1").unwrap());
let mut augmented = row("obs1_aug", "s1");
augmented.group_id = Some(GroupId::new("g1").unwrap());
augmented.origin_id = Some(origin("obs1"));
augmented.augmented = true;
augmented.augmentation = Some(AugmentationMetadata {
transform_id: "snv.augment".to_string(),
params_fingerprint: Some("a".repeat(64)),
metadata: BTreeMap::new(),
});
let table = SampleRelationTable {
rows: vec![base, augmented],
};
table.validate().unwrap();
assert_eq!(
table.sample_groups().get(&sample("s1")),
Some(&GroupId::new("g1").unwrap())
);
}
#[test]
fn rejects_duplicate_observations() {
let table = SampleRelationTable {
rows: vec![row("obs1", "s1"), row("obs1", "s2")],
};
assert!(table.validate().is_err());
}
#[test]
fn rejects_augmented_rows_without_known_origin() {
let mut augmented = row("obs1_aug", "s1");
augmented.origin_id = Some(origin("missing"));
augmented.augmented = true;
let table = SampleRelationTable {
rows: vec![augmented],
};
assert!(table.validate().is_err());
}
#[test]
fn rejects_invalid_augmentation_metadata() {
let mut augmented = row("obs1_aug", "s1");
augmented.origin_id = Some(origin("obs1"));
augmented.augmented = true;
augmented.augmentation = Some(AugmentationMetadata {
transform_id: "snv.augment".to_string(),
params_fingerprint: Some("not-a-fingerprint".to_string()),
metadata: BTreeMap::new(),
});
let table = SampleRelationTable {
rows: vec![row("obs1", "s1"), augmented],
};
assert!(table.validate().is_err());
}
#[test]
fn validates_fold_set_against_relation_groups() {
let mut s1 = row("obs1", "s1");
s1.group_id = Some(GroupId::new("g1").unwrap());
let mut s2 = row("obs2", "s2");
s2.group_id = Some(GroupId::new("g2").unwrap());
let relations = SampleRelationTable { rows: vec![s1, s2] };
relations.validate_fold_set(&fold_set()).unwrap();
}
#[test]
fn rejects_fold_set_with_missing_validation_assignment() {
let mut fold_set = fold_set();
fold_set.folds = vec![FoldAssignment {
fold_id: "fold:0".to_string(),
train_sample_ids: vec![sample("s1")],
validation_sample_ids: vec![sample("s2")],
metadata: BTreeMap::new(),
}];
let error = fold_set.validate().unwrap_err();
assert!(error.to_string().contains("expected exactly once"));
}
#[test]
fn rejects_fold_set_with_repeated_validation_assignment() {
let mut fold_set = fold_set();
fold_set.folds = vec![
FoldAssignment {
fold_id: "fold:0".to_string(),
train_sample_ids: vec![sample("s2")],
validation_sample_ids: vec![sample("s1")],
metadata: BTreeMap::new(),
},
FoldAssignment {
fold_id: "fold:1".to_string(),
train_sample_ids: vec![sample("s2")],
validation_sample_ids: vec![sample("s1")],
metadata: BTreeMap::new(),
},
];
let error = fold_set.validate().unwrap_err();
assert!(error.to_string().contains("expected exactly once"));
}
#[test]
fn rejects_fold_set_that_splits_relation_group() {
let mut s1 = row("obs1", "s1");
s1.group_id = Some(GroupId::new("g1").unwrap());
let mut s2 = row("obs2", "s2");
s2.group_id = Some(GroupId::new("g1").unwrap());
let relations = SampleRelationTable { rows: vec![s1, s2] };
let error = relations.validate_fold_set(&fold_set()).unwrap_err();
assert_eq!(error.category(), "data");
assert_eq!(error.code(), "relation_boundary_violation");
assert_eq!(error.error_code(), 0x0002_0002);
assert!(error.to_string().contains("leaks group"));
}
#[test]
fn rejects_fold_set_that_splits_augmentation_origin() {
let origin_row = row("obs1", "s1");
let mut augmented = row("obs1_aug", "s2");
augmented.augmented = true;
augmented.origin_id = Some(origin("obs1"));
let relations = SampleRelationTable {
rows: vec![origin_row, augmented],
};
let error = relations.validate_fold_set(&fold_set()).unwrap_err();
assert!(error.to_string().contains("augmentation origin"));
}
#[test]
fn rejects_fold_set_with_relation_sample_mismatch() {
let relations = SampleRelationTable {
rows: vec![row("obs1", "s1")],
};
let error = relations.validate_fold_set(&fold_set()).unwrap_err();
assert!(error.to_string().contains("absent from sample relations"));
}
#[test]
fn grouped_augmented_fixture_validates() {
let table: SampleRelationTable = serde_json::from_str(include_str!(
"../../../examples/fixtures/oof_campaign/sample_relations_grouped_augmented.json"
))
.unwrap();
table.validate().unwrap();
assert_eq!(table.sample_groups().len(), 2);
}
}