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 #[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#[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}