Skip to main content

dag_ml_data_core/
relation.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::{DataError, Result};
6use crate::ids::{GroupId, ObservationId, OriginId, RepetitionId, SampleId, SourceId, TargetId};
7
8#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
9pub struct AugmentationMetadata {
10    pub transform_id: String,
11    #[serde(default, skip_serializing_if = "Option::is_none")]
12    pub params_fingerprint: Option<String>,
13    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
14    pub metadata: BTreeMap<String, serde_json::Value>,
15}
16
17impl AugmentationMetadata {
18    pub fn validate(&self) -> Result<()> {
19        if self.transform_id.trim().is_empty() {
20            return Err(DataError::Validation(
21                "augmentation metadata transform_id is empty".to_string(),
22            ));
23        }
24        if let Some(params_fingerprint) = &self.params_fingerprint {
25            if params_fingerprint.len() != 64
26                || !params_fingerprint
27                    .bytes()
28                    .all(|byte| byte.is_ascii_hexdigit())
29            {
30                return Err(DataError::Validation(format!(
31                    "augmentation `{}` params_fingerprint must be a 64-character hex digest",
32                    self.transform_id
33                )));
34            }
35        }
36        for key in self.metadata.keys() {
37            if key.trim().is_empty() {
38                return Err(DataError::Validation(format!(
39                    "augmentation `{}` metadata contains an empty key",
40                    self.transform_id
41                )));
42            }
43        }
44        Ok(())
45    }
46}
47
48#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
49pub struct SampleRelation {
50    pub observation_id: ObservationId,
51    pub sample_id: SampleId,
52    pub source_id: Option<SourceId>,
53    pub target_id: Option<TargetId>,
54    pub group_id: Option<GroupId>,
55    pub origin_id: Option<OriginId>,
56    pub repetition_id: Option<RepetitionId>,
57    #[serde(default)]
58    pub augmented: bool,
59    #[serde(default)]
60    pub excluded: bool,
61    #[serde(default)]
62    pub metadata: BTreeMap<String, serde_json::Value>,
63    // Free-form labels a host can attach to a relation row. Skipped when empty
64    // so existing relation sets keep byte-identical fingerprints; carried onto
65    // `CoordinatorRelation` so `by_tag` branch views filter natively.
66    #[serde(default, skip_serializing_if = "Vec::is_empty")]
67    pub tags: Vec<String>,
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub augmentation: Option<AugmentationMetadata>,
70}
71
72#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
73pub struct SampleRelationTable {
74    pub rows: Vec<SampleRelation>,
75}
76
77#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
78pub struct FoldAssignment {
79    pub fold_id: String,
80    pub train_sample_ids: Vec<SampleId>,
81    pub validation_sample_ids: Vec<SampleId>,
82    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
83    pub metadata: BTreeMap<String, serde_json::Value>,
84}
85
86/// Exhaustive partition-style fold assignments.
87///
88/// Each sample in `sample_ids` must appear in validation exactly once across
89/// the fold set. Repeated or hold-out splitter traces should be represented by
90/// a separate contract, not by weakening this OOF partition invariant.
91#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
92pub struct FoldSet {
93    pub id: String,
94    pub sample_ids: Vec<SampleId>,
95    pub folds: Vec<FoldAssignment>,
96    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
97    pub sample_groups: BTreeMap<SampleId, GroupId>,
98}
99
100impl FoldSet {
101    pub fn validate(&self) -> Result<()> {
102        if self.id.trim().is_empty() {
103            return Err(DataError::Validation("fold set id is empty".to_string()));
104        }
105        if self.sample_ids.is_empty() {
106            return Err(DataError::Validation(
107                "fold set contains no samples".to_string(),
108            ));
109        }
110        if self.folds.is_empty() {
111            return Err(DataError::Validation(
112                "fold set contains no folds".to_string(),
113            ));
114        }
115        let universe = unique_samples("fold set sample_ids", &self.sample_ids)?;
116        if !self.sample_groups.is_empty() {
117            for sample_id in self.sample_groups.keys() {
118                if !universe.contains(sample_id) {
119                    return Err(DataError::Validation(format!(
120                        "sample group map references unknown sample `{sample_id}`"
121                    )));
122                }
123            }
124        }
125
126        let mut fold_ids = BTreeSet::new();
127        let mut validation_counts = self
128            .sample_ids
129            .iter()
130            .cloned()
131            .map(|sample_id| (sample_id, 0usize))
132            .collect::<BTreeMap<_, _>>();
133
134        for fold in &self.folds {
135            if fold.fold_id.trim().is_empty() {
136                return Err(DataError::Validation(
137                    "fold assignment id is empty".to_string(),
138                ));
139            }
140            if !fold_ids.insert(&fold.fold_id) {
141                return Err(DataError::Validation(format!(
142                    "duplicate fold id `{}`",
143                    fold.fold_id
144                )));
145            }
146            let train = unique_samples(
147                &format!("fold `{}` train_sample_ids", fold.fold_id),
148                &fold.train_sample_ids,
149            )?;
150            let validation = unique_samples(
151                &format!("fold `{}` validation_sample_ids", fold.fold_id),
152                &fold.validation_sample_ids,
153            )?;
154            if validation.is_empty() {
155                return Err(DataError::Validation(format!(
156                    "fold `{}` has no validation samples",
157                    fold.fold_id
158                )));
159            }
160            for sample_id in train.union(&validation) {
161                if !universe.contains(sample_id) {
162                    return Err(DataError::Validation(format!(
163                        "fold `{}` references unknown sample `{}`",
164                        fold.fold_id, sample_id
165                    )));
166                }
167            }
168            if let Some(sample_id) = train.intersection(&validation).next() {
169                return Err(DataError::Validation(format!(
170                    "fold `{}` has train/validation overlap at sample `{}`",
171                    fold.fold_id, sample_id
172                )));
173            }
174            for sample_id in &validation {
175                *validation_counts
176                    .get_mut(*sample_id)
177                    .expect("validation sample is in universe") += 1;
178            }
179            validate_group_boundary(&fold.fold_id, &train, &validation, &self.sample_groups)?;
180        }
181
182        for (sample_id, count) in validation_counts {
183            if count != 1 {
184                return Err(DataError::Validation(format!(
185                    "sample `{sample_id}` appears in validation {count} time(s), expected exactly once"
186                )));
187            }
188        }
189        Ok(())
190    }
191}
192
193impl SampleRelationTable {
194    pub fn validate(&self) -> Result<()> {
195        if self.rows.is_empty() {
196            return Err(DataError::Validation(
197                "sample relation table contains no rows".to_string(),
198            ));
199        }
200
201        let mut observation_ids = BTreeSet::new();
202        let mut origin_ids = BTreeSet::new();
203        let mut sample_groups = BTreeMap::<&SampleId, &GroupId>::new();
204        for row in &self.rows {
205            if !observation_ids.insert(&row.observation_id) {
206                return Err(DataError::Validation(format!(
207                    "duplicate observation id `{}`",
208                    row.observation_id
209                )));
210            }
211            if let Some(group_id) = &row.group_id {
212                if let Some(previous_group_id) = sample_groups.insert(&row.sample_id, group_id) {
213                    if previous_group_id != group_id {
214                        return Err(DataError::Validation(format!(
215                            "sample `{}` appears with conflicting groups `{}` and `{}`",
216                            row.sample_id, previous_group_id, group_id
217                        )));
218                    }
219                }
220            }
221            if row.augmented && row.origin_id.is_none() {
222                return Err(DataError::Validation(format!(
223                    "augmented observation `{}` has no origin_id",
224                    row.observation_id
225                )));
226            }
227            if !row.augmented && row.origin_id.is_some() {
228                return Err(DataError::Validation(format!(
229                    "non-augmented observation `{}` declares origin_id",
230                    row.observation_id
231                )));
232            }
233            if let Some(augmentation) = &row.augmentation {
234                augmentation.validate()?;
235                if !row.augmented {
236                    return Err(DataError::Validation(format!(
237                        "non-augmented observation `{}` declares augmentation metadata",
238                        row.observation_id
239                    )));
240                }
241            }
242            if let Some(origin_id) = &row.origin_id {
243                if origin_id.as_str() == row.observation_id.as_str() {
244                    return Err(DataError::Validation(format!(
245                        "observation `{}` cannot be its own origin",
246                        row.observation_id
247                    )));
248                }
249                origin_ids.insert(origin_id);
250            }
251        }
252
253        for origin_id in origin_ids {
254            if !observation_ids
255                .iter()
256                .any(|observation_id| observation_id.as_str() == origin_id.as_str())
257            {
258                return Err(DataError::Validation(format!(
259                    "origin `{origin_id}` is not present as an observation"
260                )));
261            }
262        }
263
264        Ok(())
265    }
266
267    pub fn sample_groups(&self) -> BTreeMap<SampleId, GroupId> {
268        self.rows
269            .iter()
270            .filter_map(|row| {
271                row.group_id
272                    .as_ref()
273                    .map(|group_id| (row.sample_id.clone(), group_id.clone()))
274            })
275            .collect()
276    }
277
278    pub fn validate_fold_set(&self, fold_set: &FoldSet) -> Result<()> {
279        self.validate()?;
280        fold_set.validate()?;
281
282        let relation_sample_ids = self
283            .rows
284            .iter()
285            .map(|row| row.sample_id.clone())
286            .collect::<BTreeSet<_>>();
287        let fold_sample_ids = fold_set.sample_ids.iter().cloned().collect::<BTreeSet<_>>();
288        if let Some(sample_id) = relation_sample_ids.difference(&fold_sample_ids).next() {
289            return Err(DataError::Validation(format!(
290                "fold set `{}` is missing relation sample `{sample_id}`",
291                fold_set.id
292            )));
293        }
294        if let Some(sample_id) = fold_sample_ids.difference(&relation_sample_ids).next() {
295            return Err(DataError::Validation(format!(
296                "fold set `{}` references sample `{sample_id}` absent from sample relations",
297                fold_set.id
298            )));
299        }
300
301        let relation_groups = self.sample_groups();
302        for (sample_id, fold_group_id) in &fold_set.sample_groups {
303            if let Some(relation_group_id) = relation_groups.get(sample_id) {
304                if relation_group_id != fold_group_id {
305                    return Err(DataError::Validation(format!(
306                        "fold set `{}` group `{}` for sample `{sample_id}` conflicts with relation group `{}`",
307                        fold_set.id, fold_group_id, relation_group_id
308                    )));
309                }
310            }
311        }
312
313        for fold in &fold_set.folds {
314            let train = fold.train_sample_ids.iter().collect::<BTreeSet<_>>();
315            let validation = fold.validation_sample_ids.iter().collect::<BTreeSet<_>>();
316            validate_group_boundary(&fold.fold_id, &train, &validation, &relation_groups)?;
317            validate_origin_boundary(&fold.fold_id, &train, &validation, self)?;
318        }
319        Ok(())
320    }
321}
322
323fn unique_samples<'a>(label: &str, samples: &'a [SampleId]) -> Result<BTreeSet<&'a SampleId>> {
324    let mut seen = BTreeSet::new();
325    for sample_id in samples {
326        if !seen.insert(sample_id) {
327            return Err(DataError::Validation(format!(
328                "{label} contains duplicate sample `{sample_id}`"
329            )));
330        }
331    }
332    Ok(seen)
333}
334
335fn validate_group_boundary(
336    fold_id: &str,
337    train: &BTreeSet<&SampleId>,
338    validation: &BTreeSet<&SampleId>,
339    sample_groups: &BTreeMap<SampleId, GroupId>,
340) -> Result<()> {
341    if sample_groups.is_empty() {
342        return Ok(());
343    }
344    let train_groups = train
345        .iter()
346        .filter_map(|sample_id| sample_groups.get(*sample_id))
347        .collect::<BTreeSet<_>>();
348    for sample_id in validation {
349        if let Some(group_id) = sample_groups.get(*sample_id) {
350            if train_groups.contains(group_id) {
351                return Err(DataError::RelationBoundaryViolation {
352                    kind: "group",
353                    detail: format!(
354                        "fold `{fold_id}` leaks group `{group_id}` across train/validation"
355                    ),
356                });
357            }
358        }
359    }
360    Ok(())
361}
362
363fn validate_origin_boundary(
364    fold_id: &str,
365    train: &BTreeSet<&SampleId>,
366    validation: &BTreeSet<&SampleId>,
367    relations: &SampleRelationTable,
368) -> Result<()> {
369    let origin_sample_ids = relations
370        .rows
371        .iter()
372        .map(|row| (row.observation_id.as_str(), &row.sample_id))
373        .collect::<BTreeMap<_, _>>();
374    for row in &relations.rows {
375        let Some(origin_id) = &row.origin_id else {
376            continue;
377        };
378        let Some(origin_sample_id) = origin_sample_ids.get(origin_id.as_str()) else {
379            continue;
380        };
381        if train.contains(&row.sample_id) && validation.contains(origin_sample_id) {
382            return Err(DataError::RelationBoundaryViolation {
383                kind: "origin",
384                detail: format!(
385                    "fold `{fold_id}` leaks augmentation origin `{origin_id}` across train/validation"
386                ),
387            });
388        }
389        if validation.contains(&row.sample_id) && train.contains(origin_sample_id) {
390            return Err(DataError::RelationBoundaryViolation {
391                kind: "origin",
392                detail: format!(
393                    "fold `{fold_id}` leaks augmentation origin `{origin_id}` across train/validation"
394                ),
395            });
396        }
397    }
398    Ok(())
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    fn obs(value: &str) -> ObservationId {
406        ObservationId::new(value).unwrap()
407    }
408
409    fn sample(value: &str) -> SampleId {
410        SampleId::new(value).unwrap()
411    }
412
413    fn origin(value: &str) -> OriginId {
414        OriginId::new(value).unwrap()
415    }
416
417    fn row(observation_id: &str, sample_id: &str) -> SampleRelation {
418        SampleRelation {
419            observation_id: obs(observation_id),
420            sample_id: sample(sample_id),
421            source_id: None,
422            target_id: None,
423            group_id: None,
424            origin_id: None,
425            repetition_id: None,
426            augmented: false,
427            excluded: false,
428            metadata: BTreeMap::new(),
429            tags: Vec::new(),
430            augmentation: None,
431        }
432    }
433
434    fn fold_set() -> FoldSet {
435        FoldSet {
436            id: "cv.group.safe".to_string(),
437            sample_ids: vec![sample("s1"), sample("s2")],
438            folds: vec![
439                FoldAssignment {
440                    fold_id: "fold:0".to_string(),
441                    train_sample_ids: vec![sample("s2")],
442                    validation_sample_ids: vec![sample("s1")],
443                    metadata: BTreeMap::new(),
444                },
445                FoldAssignment {
446                    fold_id: "fold:1".to_string(),
447                    train_sample_ids: vec![sample("s1")],
448                    validation_sample_ids: vec![sample("s2")],
449                    metadata: BTreeMap::new(),
450                },
451            ],
452            sample_groups: BTreeMap::new(),
453        }
454    }
455
456    #[test]
457    fn validates_group_and_origin_relations() {
458        let mut base = row("obs1", "s1");
459        base.group_id = Some(GroupId::new("g1").unwrap());
460        let mut augmented = row("obs1_aug", "s1");
461        augmented.group_id = Some(GroupId::new("g1").unwrap());
462        augmented.origin_id = Some(origin("obs1"));
463        augmented.augmented = true;
464        augmented.augmentation = Some(AugmentationMetadata {
465            transform_id: "snv.augment".to_string(),
466            params_fingerprint: Some("a".repeat(64)),
467            metadata: BTreeMap::new(),
468        });
469
470        let table = SampleRelationTable {
471            rows: vec![base, augmented],
472        };
473
474        table.validate().unwrap();
475        assert_eq!(
476            table.sample_groups().get(&sample("s1")),
477            Some(&GroupId::new("g1").unwrap())
478        );
479    }
480
481    #[test]
482    fn rejects_duplicate_observations() {
483        let table = SampleRelationTable {
484            rows: vec![row("obs1", "s1"), row("obs1", "s2")],
485        };
486
487        assert!(table.validate().is_err());
488    }
489
490    #[test]
491    fn rejects_augmented_rows_without_known_origin() {
492        let mut augmented = row("obs1_aug", "s1");
493        augmented.origin_id = Some(origin("missing"));
494        augmented.augmented = true;
495        let table = SampleRelationTable {
496            rows: vec![augmented],
497        };
498
499        assert!(table.validate().is_err());
500    }
501
502    #[test]
503    fn rejects_invalid_augmentation_metadata() {
504        let mut augmented = row("obs1_aug", "s1");
505        augmented.origin_id = Some(origin("obs1"));
506        augmented.augmented = true;
507        augmented.augmentation = Some(AugmentationMetadata {
508            transform_id: "snv.augment".to_string(),
509            params_fingerprint: Some("not-a-fingerprint".to_string()),
510            metadata: BTreeMap::new(),
511        });
512        let table = SampleRelationTable {
513            rows: vec![row("obs1", "s1"), augmented],
514        };
515
516        assert!(table.validate().is_err());
517    }
518
519    #[test]
520    fn validates_fold_set_against_relation_groups() {
521        let mut s1 = row("obs1", "s1");
522        s1.group_id = Some(GroupId::new("g1").unwrap());
523        let mut s2 = row("obs2", "s2");
524        s2.group_id = Some(GroupId::new("g2").unwrap());
525        let relations = SampleRelationTable { rows: vec![s1, s2] };
526
527        relations.validate_fold_set(&fold_set()).unwrap();
528    }
529
530    #[test]
531    fn rejects_fold_set_with_missing_validation_assignment() {
532        let mut fold_set = fold_set();
533        fold_set.folds = vec![FoldAssignment {
534            fold_id: "fold:0".to_string(),
535            train_sample_ids: vec![sample("s1")],
536            validation_sample_ids: vec![sample("s2")],
537            metadata: BTreeMap::new(),
538        }];
539
540        let error = fold_set.validate().unwrap_err();
541        assert!(error.to_string().contains("expected exactly once"));
542    }
543
544    #[test]
545    fn rejects_fold_set_with_repeated_validation_assignment() {
546        let mut fold_set = fold_set();
547        fold_set.folds = vec![
548            FoldAssignment {
549                fold_id: "fold:0".to_string(),
550                train_sample_ids: vec![sample("s2")],
551                validation_sample_ids: vec![sample("s1")],
552                metadata: BTreeMap::new(),
553            },
554            FoldAssignment {
555                fold_id: "fold:1".to_string(),
556                train_sample_ids: vec![sample("s2")],
557                validation_sample_ids: vec![sample("s1")],
558                metadata: BTreeMap::new(),
559            },
560        ];
561
562        let error = fold_set.validate().unwrap_err();
563        assert!(error.to_string().contains("expected exactly once"));
564    }
565
566    #[test]
567    fn rejects_fold_set_that_splits_relation_group() {
568        let mut s1 = row("obs1", "s1");
569        s1.group_id = Some(GroupId::new("g1").unwrap());
570        let mut s2 = row("obs2", "s2");
571        s2.group_id = Some(GroupId::new("g1").unwrap());
572        let relations = SampleRelationTable { rows: vec![s1, s2] };
573
574        let error = relations.validate_fold_set(&fold_set()).unwrap_err();
575        assert_eq!(error.category(), "data");
576        assert_eq!(error.code(), "relation_boundary_violation");
577        assert_eq!(error.error_code(), 0x0002_0002);
578        assert!(error.to_string().contains("leaks group"));
579    }
580
581    #[test]
582    fn rejects_fold_set_that_splits_augmentation_origin() {
583        let origin_row = row("obs1", "s1");
584        let mut augmented = row("obs1_aug", "s2");
585        augmented.augmented = true;
586        augmented.origin_id = Some(origin("obs1"));
587        let relations = SampleRelationTable {
588            rows: vec![origin_row, augmented],
589        };
590
591        let error = relations.validate_fold_set(&fold_set()).unwrap_err();
592        assert!(error.to_string().contains("augmentation origin"));
593    }
594
595    #[test]
596    fn rejects_fold_set_with_relation_sample_mismatch() {
597        let relations = SampleRelationTable {
598            rows: vec![row("obs1", "s1")],
599        };
600
601        let error = relations.validate_fold_set(&fold_set()).unwrap_err();
602        assert!(error.to_string().contains("absent from sample relations"));
603    }
604
605    #[test]
606    fn grouped_augmented_fixture_validates() {
607        let table: SampleRelationTable = serde_json::from_str(include_str!(
608            "../../../examples/fixtures/oof_campaign/sample_relations_grouped_augmented.json"
609        ))
610        .unwrap();
611
612        table.validate().unwrap();
613        assert_eq!(table.sample_groups().len(), 2);
614    }
615}