1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4
5use crate::campaign::stable_json_fingerprint;
6use crate::error::{DagMlError, Result};
7use crate::fold::FoldSet;
8use crate::ids::{GroupId, ObservationId, SampleId, TargetId};
9use crate::policy::{LeakageUnitPolicy, SplitUnit};
10
11#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum FoldPartition {
14 Train,
15 Validation,
16}
17
18#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum EntityUnitLevel {
21 PhysicalSample,
22 SourceSample,
23 #[default]
24 Observation,
25 Combo,
26}
27
28#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
29pub struct SampleRelation {
30 #[serde(default)]
31 pub unit_level: EntityUnitLevel,
32 #[serde(default)]
33 pub unit_id: Option<String>,
34 pub observation_id: ObservationId,
35 pub sample_id: SampleId,
36 #[serde(default)]
37 pub source_id: Option<String>,
38 #[serde(default)]
39 pub rep_id: Option<String>,
40 #[serde(default)]
41 pub target_id: Option<TargetId>,
42 #[serde(default)]
43 pub group_id: Option<GroupId>,
44 #[serde(default)]
45 pub origin_sample_id: Option<SampleId>,
46 #[serde(default)]
47 pub derived_unit_id: Option<String>,
48 #[serde(default)]
49 pub component_observation_ids: Vec<ObservationId>,
50 #[serde(default)]
51 pub sample_influence_weight: Option<f64>,
52 #[serde(default)]
53 pub quality_flag: Option<String>,
54 #[serde(default)]
55 pub is_augmented: bool,
56 #[serde(default, skip_serializing_if = "is_false")]
57 pub excluded: bool,
58 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
64 pub metadata: BTreeMap<String, serde_json::Value>,
65 #[serde(default, skip_serializing_if = "Vec::is_empty")]
66 pub tags: Vec<String>,
67}
68
69fn is_false(value: &bool) -> bool {
70 !*value
71}
72
73impl SampleRelation {
74 pub fn new(observation_id: ObservationId, sample_id: SampleId) -> Self {
75 Self {
76 unit_level: EntityUnitLevel::Observation,
77 unit_id: None,
78 observation_id,
79 sample_id,
80 source_id: None,
81 rep_id: None,
82 target_id: None,
83 group_id: None,
84 origin_sample_id: None,
85 derived_unit_id: None,
86 component_observation_ids: Vec::new(),
87 sample_influence_weight: None,
88 quality_flag: None,
89 is_augmented: false,
90 excluded: false,
91 metadata: BTreeMap::new(),
92 tags: Vec::new(),
93 }
94 }
95
96 pub fn effective_unit_id(&self) -> Result<String> {
97 if let Some(unit_id) = non_empty_optional("unit_id", &self.observation_id, &self.unit_id)? {
98 return Ok(unit_id.to_string());
99 }
100
101 match self.unit_level {
102 EntityUnitLevel::PhysicalSample => Ok(self.sample_id.to_string()),
103 EntityUnitLevel::SourceSample => {
104 let source_id =
105 non_empty_optional("source_id", &self.observation_id, &self.source_id)?
106 .ok_or_else(|| {
107 DagMlError::CampaignValidation(format!(
108 "source-sample relation `{}` requires source_id",
109 self.observation_id
110 ))
111 })?;
112 Ok(format!("{}::{source_id}", self.sample_id))
113 }
114 EntityUnitLevel::Observation => Ok(self.observation_id.to_string()),
115 EntityUnitLevel::Combo => {
116 let derived_unit_id = non_empty_optional(
117 "derived_unit_id",
118 &self.observation_id,
119 &self.derived_unit_id,
120 )?
121 .ok_or_else(|| {
122 DagMlError::CampaignValidation(format!(
123 "combo relation `{}` requires derived_unit_id",
124 self.observation_id
125 ))
126 })?;
127 Ok(derived_unit_id.to_string())
128 }
129 }
130 }
131
132 fn validate(&self) -> Result<()> {
133 non_empty_optional("unit_id", &self.observation_id, &self.unit_id)?;
134 non_empty_optional("source_id", &self.observation_id, &self.source_id)?;
135 non_empty_optional(
136 "derived_unit_id",
137 &self.observation_id,
138 &self.derived_unit_id,
139 )?;
140 non_empty_optional("quality_flag", &self.observation_id, &self.quality_flag)?;
141 validate_optional_identifier("rep_id", &self.observation_id, &self.rep_id)?;
142
143 if let Some(weight) = self.sample_influence_weight {
144 if !weight.is_finite() || weight <= 0.0 {
145 return Err(DagMlError::CampaignValidation(format!(
146 "relation `{}` has invalid sample_influence_weight",
147 self.observation_id
148 )));
149 }
150 }
151
152 if self.unit_level != EntityUnitLevel::Combo && !self.component_observation_ids.is_empty() {
153 return Err(DagMlError::CampaignValidation(format!(
154 "relation `{}` has component_observation_ids but is not a combo",
155 self.observation_id
156 )));
157 }
158
159 self.effective_unit_id()?;
160 Ok(())
161 }
162}
163
164#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
165pub struct SampleRelationSet {
166 #[serde(default)]
167 pub records: Vec<SampleRelation>,
168}
169
170pub fn relation_set_fingerprint(relations: &SampleRelationSet) -> Result<String> {
171 relations.fingerprint()
172}
173
174#[derive(Clone, Debug, Serialize)]
175struct CanonicalRelationRecord {
176 effective_unit_id: String,
177 unit_level: EntityUnitLevel,
178 unit_id: Option<String>,
179 observation_id: ObservationId,
180 sample_id: SampleId,
181 source_id: Option<String>,
182 rep_id: Option<String>,
183 target_id: Option<TargetId>,
184 group_id: Option<GroupId>,
185 origin_sample_id: Option<SampleId>,
186 derived_unit_id: Option<String>,
187 component_observation_ids: Vec<ObservationId>,
188 sample_influence_weight: Option<f64>,
189 quality_flag: Option<String>,
190 is_augmented: bool,
191 #[serde(default, skip_serializing_if = "is_false")]
196 excluded: bool,
197 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
202 metadata: BTreeMap<String, serde_json::Value>,
203 #[serde(skip_serializing_if = "Vec::is_empty")]
204 tags: Vec<String>,
205}
206
207impl SampleRelationSet {
208 pub fn validate(&self) -> Result<()> {
209 let mut observations = BTreeSet::new();
210 let mut observation_samples = BTreeMap::<ObservationId, SampleId>::new();
211 let mut unit_ids = BTreeMap::<String, ObservationId>::new();
212 let mut sample_targets = BTreeMap::<SampleId, TargetId>::new();
213 let mut sample_groups = BTreeMap::<SampleId, GroupId>::new();
214 for record in &self.records {
215 record.validate()?;
216 if !observations.insert(&record.observation_id) {
217 return Err(DagMlError::CampaignValidation(format!(
218 "duplicate observation relation `{}`",
219 record.observation_id
220 )));
221 }
222 observation_samples.insert(record.observation_id.clone(), record.sample_id.clone());
223 let effective_unit_id = record.effective_unit_id()?;
224 if let Some(previous) =
225 unit_ids.insert(effective_unit_id.clone(), record.observation_id.clone())
226 {
227 return Err(DagMlError::CampaignValidation(format!(
228 "relations `{previous}` and `{}` share effective unit id `{effective_unit_id}`",
229 record.observation_id
230 )));
231 }
232 if let Some(target_id) = &record.target_id {
233 if let Some(previous) = sample_targets.get(&record.sample_id) {
234 if previous != target_id {
235 return Err(DagMlError::CampaignValidation(format!(
236 "sample `{}` maps to multiple targets",
237 record.sample_id
238 )));
239 }
240 } else {
241 sample_targets.insert(record.sample_id.clone(), target_id.clone());
242 }
243 }
244 if let Some(group_id) = &record.group_id {
245 if let Some(previous) = sample_groups.get(&record.sample_id) {
246 if previous != group_id {
247 return Err(DagMlError::CampaignValidation(format!(
248 "sample `{}` maps to multiple groups",
249 record.sample_id
250 )));
251 }
252 } else {
253 sample_groups.insert(record.sample_id.clone(), group_id.clone());
254 }
255 }
256 }
257 for record in &self.records {
258 validate_combo_record(record, &observation_samples)?;
259 }
260 Ok(())
261 }
262
263 pub fn fingerprint(&self) -> Result<String> {
264 self.validate()?;
265 let mut canonical = self
266 .records
267 .iter()
268 .map(|record| {
269 let effective_unit_id = record.effective_unit_id()?;
270 Ok(CanonicalRelationRecord {
271 effective_unit_id,
272 unit_level: record.unit_level,
273 unit_id: record.unit_id.clone(),
274 observation_id: record.observation_id.clone(),
275 sample_id: record.sample_id.clone(),
276 source_id: record.source_id.clone(),
277 rep_id: record.rep_id.clone(),
278 target_id: record.target_id.clone(),
279 group_id: record.group_id.clone(),
280 origin_sample_id: record.origin_sample_id.clone(),
281 derived_unit_id: record.derived_unit_id.clone(),
282 component_observation_ids: record.component_observation_ids.clone(),
283 sample_influence_weight: record.sample_influence_weight,
284 quality_flag: record.quality_flag.clone(),
285 is_augmented: record.is_augmented,
286 excluded: record.excluded,
287 metadata: record.metadata.clone(),
288 tags: record.tags.clone(),
289 })
290 })
291 .collect::<Result<Vec<_>>>()?;
292 canonical.sort_by(|left, right| {
293 (
294 left.effective_unit_id.as_str(),
295 left.observation_id.as_str(),
296 left.sample_id.as_str(),
297 )
298 .cmp(&(
299 right.effective_unit_id.as_str(),
300 right.observation_id.as_str(),
301 right.sample_id.as_str(),
302 ))
303 });
304 stable_json_fingerprint(&canonical)
305 }
306
307 pub fn validate_against_fold_set(
308 &self,
309 fold_set: &FoldSet,
310 policy: &LeakageUnitPolicy,
311 ) -> Result<()> {
312 self.validate()?;
313 fold_set.validate()?;
314 policy.validate()?;
315
316 let universe = fold_set.sample_ids.iter().collect::<BTreeSet<_>>();
317 for record in &self.records {
318 if !universe.contains(&record.sample_id) {
319 return Err(DagMlError::CampaignValidation(format!(
320 "relation `{}` references sample `{}` outside fold set",
321 record.observation_id, record.sample_id
322 )));
323 }
324 if let Some(origin_sample_id) = &record.origin_sample_id {
325 if !universe.contains(origin_sample_id) {
326 return Err(DagMlError::CampaignValidation(format!(
327 "relation `{}` references origin sample `{}` outside fold set",
328 record.observation_id, origin_sample_id
329 )));
330 }
331 }
332 if policy.require_group_ids && record.group_id.is_none() {
333 return Err(DagMlError::CampaignValidation(format!(
334 "relation `{}` is missing required group id",
335 record.observation_id
336 )));
337 }
338 }
339
340 let sample_to_target = self.sample_targets();
341 let sample_to_group = self.sample_groups();
342 validate_fold_set_groups_match_relations(fold_set, &sample_to_group)?;
343
344 for fold in &fold_set.folds {
345 let partitions = fold
346 .train_sample_ids
347 .iter()
348 .map(|sample_id| (sample_id, FoldPartition::Train))
349 .chain(
350 fold.validation_sample_ids
351 .iter()
352 .map(|sample_id| (sample_id, FoldPartition::Validation)),
353 )
354 .collect::<BTreeMap<_, _>>();
355
356 if policy.forbid_origin_cross_fold {
357 for record in &self.records {
358 if let Some(origin_sample_id) = &record.origin_sample_id {
359 let sample_partition =
360 partitions.get(&record.sample_id).ok_or_else(|| {
361 DagMlError::CampaignValidation(format!(
362 "fold `{}` does not contain sample `{}`",
363 fold.fold_id, record.sample_id
364 ))
365 })?;
366 let origin_partition =
367 partitions.get(origin_sample_id).ok_or_else(|| {
368 DagMlError::CampaignValidation(format!(
369 "fold `{}` does not contain origin sample `{}`",
370 fold.fold_id, origin_sample_id
371 ))
372 })?;
373 if sample_partition != origin_partition {
374 return Err(DagMlError::CampaignValidation(format!(
375 "fold `{}` leaks origin sample `{}` into {:?} sample `{}`",
376 fold.fold_id, origin_sample_id, sample_partition, record.sample_id
377 )));
378 }
379 }
380 }
381 }
382
383 match policy.split_unit {
384 SplitUnit::PhysicalSample | SplitUnit::Observation | SplitUnit::Sample => {}
385 SplitUnit::Target => validate_unit_partitions(
386 &fold.fold_id.to_string(),
387 "target",
388 &partitions,
389 &sample_to_target,
390 )?,
391 SplitUnit::Group => validate_unit_partitions(
392 &fold.fold_id.to_string(),
393 "group",
394 &partitions,
395 &sample_to_group,
396 )?,
397 }
398 }
399 Ok(())
400 }
401
402 pub fn sample_for_observation(&self, observation_id: &ObservationId) -> Option<&SampleId> {
403 self.records
404 .iter()
405 .find(|record| &record.observation_id == observation_id)
406 .map(|record| &record.sample_id)
407 }
408
409 pub fn target_for_sample(&self, sample_id: &SampleId) -> Option<&TargetId> {
410 self.records
411 .iter()
412 .find(|record| &record.sample_id == sample_id)
413 .and_then(|record| record.target_id.as_ref())
414 }
415
416 pub fn group_for_sample(&self, sample_id: &SampleId) -> Option<&GroupId> {
417 self.records
418 .iter()
419 .find(|record| &record.sample_id == sample_id)
420 .and_then(|record| record.group_id.as_ref())
421 }
422
423 pub fn observation_count_for_sample(&self, sample_id: &SampleId) -> usize {
424 self.records
425 .iter()
426 .filter(|record| &record.sample_id == sample_id)
427 .count()
428 }
429
430 pub fn excluded_sample_ids(&self) -> BTreeSet<SampleId> {
435 self.records
436 .iter()
437 .filter(|record| record.excluded)
438 .map(|record| record.sample_id.clone())
439 .collect()
440 }
441
442 pub fn sample_targets(&self) -> BTreeMap<SampleId, TargetId> {
443 self.records
444 .iter()
445 .filter_map(|record| {
446 record
447 .target_id
448 .as_ref()
449 .map(|target_id| (record.sample_id.clone(), target_id.clone()))
450 })
451 .collect()
452 }
453
454 pub fn sample_groups(&self) -> BTreeMap<SampleId, GroupId> {
455 self.records
456 .iter()
457 .filter_map(|record| {
458 record
459 .group_id
460 .as_ref()
461 .map(|group_id| (record.sample_id.clone(), group_id.clone()))
462 })
463 .collect()
464 }
465}
466
467fn non_empty_optional<'a>(
468 field: &str,
469 observation_id: &ObservationId,
470 value: &'a Option<String>,
471) -> Result<Option<&'a str>> {
472 if let Some(value) = value.as_deref() {
473 if value.trim().is_empty() {
474 return Err(DagMlError::CampaignValidation(format!(
475 "relation `{observation_id}` has empty {field}"
476 )));
477 }
478 Ok(Some(value))
479 } else {
480 Ok(None)
481 }
482}
483
484fn validate_optional_identifier(
485 field: &str,
486 observation_id: &ObservationId,
487 value: &Option<String>,
488) -> Result<()> {
489 let Some(value) = non_empty_optional(field, observation_id, value)? else {
490 return Ok(());
491 };
492 if value.len() > 128
493 || !value
494 .bytes()
495 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b':'))
496 {
497 return Err(DagMlError::CampaignValidation(format!(
498 "relation `{observation_id}` has invalid {field}"
499 )));
500 }
501 Ok(())
502}
503
504fn validate_combo_record(
505 record: &SampleRelation,
506 observation_samples: &BTreeMap<ObservationId, SampleId>,
507) -> Result<()> {
508 if record.unit_level != EntityUnitLevel::Combo {
509 return Ok(());
510 }
511 if record.component_observation_ids.is_empty() {
512 return Err(DagMlError::CampaignValidation(format!(
513 "combo relation `{}` has no component observations",
514 record.observation_id
515 )));
516 }
517 if record.derived_unit_id.is_none() {
518 return Err(DagMlError::CampaignValidation(format!(
519 "combo relation `{}` requires derived_unit_id",
520 record.observation_id
521 )));
522 }
523 if let Some(origin_sample_id) = &record.origin_sample_id {
524 if origin_sample_id != &record.sample_id {
525 return Err(DagMlError::CampaignValidation(format!(
526 "combo relation `{}` origin sample `{}` differs from sample `{}`",
527 record.observation_id, origin_sample_id, record.sample_id
528 )));
529 }
530 }
531
532 let mut components = BTreeSet::new();
533 for component_observation_id in &record.component_observation_ids {
534 if component_observation_id == &record.observation_id {
535 return Err(DagMlError::CampaignValidation(format!(
536 "combo relation `{}` cannot list itself as a component",
537 record.observation_id
538 )));
539 }
540 if !components.insert(component_observation_id) {
541 return Err(DagMlError::CampaignValidation(format!(
542 "combo relation `{}` repeats component observation `{}`",
543 record.observation_id, component_observation_id
544 )));
545 }
546 let component_sample = observation_samples
547 .get(component_observation_id)
548 .ok_or_else(|| {
549 DagMlError::CampaignValidation(format!(
550 "combo relation `{}` references missing component observation `{}`",
551 record.observation_id, component_observation_id
552 ))
553 })?;
554 if component_sample != &record.sample_id {
555 return Err(DagMlError::CampaignValidation(format!(
556 "combo relation `{}` component observation `{}` belongs to sample `{}` not `{}`",
557 record.observation_id, component_observation_id, component_sample, record.sample_id
558 )));
559 }
560 }
561 Ok(())
562}
563
564fn validate_fold_set_groups_match_relations(
565 fold_set: &FoldSet,
566 sample_to_group: &BTreeMap<SampleId, GroupId>,
567) -> Result<()> {
568 for (sample_id, fold_group) in &fold_set.sample_groups {
569 if let Some(relation_group) = sample_to_group.get(sample_id) {
570 if relation_group != fold_group {
571 return Err(DagMlError::CampaignValidation(format!(
572 "sample `{sample_id}` has group `{relation_group}` in relations but `{fold_group}` in fold set"
573 )));
574 }
575 }
576 }
577 Ok(())
578}
579
580fn validate_unit_partitions<Unit: Ord + std::fmt::Display>(
581 fold_id: &str,
582 label: &str,
583 partitions: &BTreeMap<&SampleId, FoldPartition>,
584 sample_units: &BTreeMap<SampleId, Unit>,
585) -> Result<()> {
586 let mut unit_partitions = BTreeMap::<&Unit, FoldPartition>::new();
587 for (sample_id, partition) in partitions {
588 let Some(unit) = sample_units.get(*sample_id) else {
589 return Err(DagMlError::CampaignValidation(format!(
590 "fold `{fold_id}` sample `{sample_id}` is missing {label} id"
591 )));
592 };
593 if let Some(previous) = unit_partitions.insert(unit, *partition) {
594 if previous != *partition {
595 return Err(DagMlError::CampaignValidation(format!(
596 "fold `{fold_id}` leaks {label} `{unit}` across train/validation"
597 )));
598 }
599 }
600 }
601 Ok(())
602}
603
604#[cfg(test)]
605mod tests {
606 use super::*;
607 use crate::fold::{FoldAssignment, FoldPartitionMode};
608
609 fn sid(value: &str) -> SampleId {
610 SampleId::new(value).unwrap()
611 }
612
613 fn oid(value: &str) -> ObservationId {
614 ObservationId::new(value).unwrap()
615 }
616
617 fn tid(value: &str) -> TargetId {
618 TargetId::new(value).unwrap()
619 }
620
621 fn gid(value: &str) -> GroupId {
622 GroupId::new(value).unwrap()
623 }
624
625 fn fold_set() -> FoldSet {
626 FoldSet {
627 id: "outer".to_string(),
628 sample_ids: vec![sid("s1"), sid("s2"), sid("s3"), sid("s4")],
629 folds: vec![
630 FoldAssignment {
631 fold_id: crate::ids::FoldId::new("fold:0").unwrap(),
632 train_sample_ids: vec![sid("s3"), sid("s4")],
633 validation_sample_ids: vec![sid("s1"), sid("s2")],
634 metadata: BTreeMap::new(),
635 },
636 FoldAssignment {
637 fold_id: crate::ids::FoldId::new("fold:1").unwrap(),
638 train_sample_ids: vec![sid("s1"), sid("s2")],
639 validation_sample_ids: vec![sid("s3"), sid("s4")],
640 metadata: BTreeMap::new(),
641 },
642 ],
643 sample_groups: BTreeMap::new(),
644 partition_mode: FoldPartitionMode::Partition,
645 }
646 }
647
648 fn relation(observation: &str, sample: &str, target: &str, group: &str) -> SampleRelation {
649 let mut relation = SampleRelation::new(oid(observation), sid(sample));
650 relation.target_id = Some(tid(target));
651 relation.group_id = Some(gid(group));
652 relation
653 }
654
655 fn source_relation(observation: &str, sample: &str, source: &str, rep: &str) -> SampleRelation {
656 let mut relation = relation(observation, sample, "target:sample", "group:sample");
657 relation.source_id = Some(source.to_string());
658 relation.rep_id = Some(rep.to_string());
659 relation
660 }
661
662 #[test]
663 fn repeated_observations_validate_at_sample_split_unit() {
664 let relations = SampleRelationSet {
665 records: vec![
666 relation("obs:1a", "s1", "t1", "g1"),
667 relation("obs:1b", "s1", "t1", "g1"),
668 relation("obs:2a", "s2", "t2", "g2"),
669 relation("obs:3a", "s3", "t3", "g3"),
670 relation("obs:4a", "s4", "t4", "g4"),
671 ],
672 };
673
674 relations
675 .validate_against_fold_set(&fold_set(), &LeakageUnitPolicy::default())
676 .unwrap();
677 }
678
679 #[test]
680 fn repeated_observations_validate_at_physical_sample_split_unit() {
681 let relations = SampleRelationSet {
682 records: vec![
683 relation("obs:1a", "s1", "t1", "g1"),
684 relation("obs:1b", "s1", "t1", "g1"),
685 relation("obs:2a", "s2", "t2", "g2"),
686 relation("obs:3a", "s3", "t3", "g3"),
687 relation("obs:4a", "s4", "t4", "g4"),
688 ],
689 };
690 let policy = LeakageUnitPolicy {
691 split_unit: SplitUnit::PhysicalSample,
692 ..LeakageUnitPolicy::default()
693 };
694
695 relations
696 .validate_against_fold_set(&fold_set(), &policy)
697 .unwrap();
698 }
699
700 #[test]
701 fn asymmetric_multisource_repetitions_and_combo_validate_as_relations() {
702 let mut combo = relation(
703 "obs:s1.combo.a0.b0.c0",
704 "s1",
705 "target:sample",
706 "group:sample",
707 );
708 combo.unit_level = EntityUnitLevel::Combo;
709 combo.source_id = Some("combo".to_string());
710 combo.derived_unit_id = Some("combo:s1:a0:b0:c0".to_string());
711 combo.origin_sample_id = Some(sid("s1"));
712 combo.component_observation_ids =
713 vec![oid("obs:s1.A.0"), oid("obs:s1.B.0"), oid("obs:s1.C.0")];
714 combo.sample_influence_weight = Some(1.0);
715 combo.quality_flag = Some("ok".to_string());
716
717 let relations = SampleRelationSet {
718 records: vec![
719 source_relation("obs:s1.A.0", "s1", "A", "rep:0"),
720 source_relation("obs:s1.A.1", "s1", "A", "rep:1"),
721 source_relation("obs:s1.B.0", "s1", "B", "rep:0"),
722 source_relation("obs:s1.B.1", "s1", "B", "rep:1"),
723 source_relation("obs:s1.B.2", "s1", "B", "rep:2"),
724 source_relation("obs:s1.C.0", "s1", "C", "rep:0"),
725 source_relation("obs:s1.C.1", "s1", "C", "rep:1"),
726 combo,
727 ],
728 };
729
730 relations.validate().unwrap();
731 assert_eq!(
732 relations.sample_for_observation(&oid("obs:s1.combo.a0.b0.c0")),
733 Some(&sid("s1"))
734 );
735 }
736
737 #[test]
738 fn combo_components_cannot_cross_sample_boundary() {
739 let mut combo = relation("obs:s1.combo", "s1", "target:sample", "group:sample");
740 combo.unit_level = EntityUnitLevel::Combo;
741 combo.derived_unit_id = Some("combo:s1".to_string());
742 combo.component_observation_ids = vec![oid("obs:s1.A.0"), oid("obs:s2.B.0")];
743
744 let relations = SampleRelationSet {
745 records: vec![
746 source_relation("obs:s1.A.0", "s1", "A", "rep:0"),
747 source_relation("obs:s2.B.0", "s2", "B", "rep:0"),
748 combo,
749 ],
750 };
751
752 assert!(relations.validate().is_err());
753 }
754
755 #[test]
756 fn relation_fingerprint_is_order_stable_and_provenance_sensitive() {
757 let left = SampleRelationSet {
758 records: vec![
759 source_relation("obs:s1.A.0", "s1", "A", "rep:0"),
760 source_relation("obs:s1.B.0", "s1", "B", "rep:0"),
761 ],
762 };
763 let right = SampleRelationSet {
764 records: vec![
765 source_relation("obs:s1.B.0", "s1", "B", "rep:0"),
766 source_relation("obs:s1.A.0", "s1", "A", "rep:0"),
767 ],
768 };
769 assert_eq!(left.fingerprint().unwrap(), right.fingerprint().unwrap());
770
771 let mut changed = left.clone();
772 changed.records[0].rep_id = Some("rep:1".to_string());
773 assert_ne!(left.fingerprint().unwrap(), changed.fingerprint().unwrap());
774 }
775
776 #[test]
777 fn excluded_bit_changes_fingerprint_but_only_when_true() {
778 let base = SampleRelationSet {
779 records: vec![
780 source_relation("obs:s1.A.0", "s1", "A", "rep:0"),
781 source_relation("obs:s2.A.0", "s2", "A", "rep:0"),
782 ],
783 };
784
785 let mut explicit_false = base.clone();
788 explicit_false.records[0].excluded = false;
789 assert_eq!(
790 base.fingerprint().unwrap(),
791 explicit_false.fingerprint().unwrap()
792 );
793
794 let mut excluded = base.clone();
798 excluded.records[0].excluded = true;
799 assert_ne!(base.fingerprint().unwrap(), excluded.fingerprint().unwrap());
800 }
801
802 #[test]
803 fn metadata_and_tags_change_fingerprint_but_only_when_non_empty() {
804 let base = SampleRelationSet {
805 records: vec![
806 source_relation("obs:s1.A.0", "s1", "A", "rep:0"),
807 source_relation("obs:s2.A.0", "s2", "A", "rep:0"),
808 ],
809 };
810
811 let mut explicit_empty = base.clone();
814 explicit_empty.records[0].metadata = BTreeMap::new();
815 explicit_empty.records[0].tags = Vec::new();
816 assert_eq!(
817 base.fingerprint().unwrap(),
818 explicit_empty.fingerprint().unwrap()
819 );
820
821 let mut with_metadata = base.clone();
824 with_metadata.records[0]
825 .metadata
826 .insert("group".to_string(), serde_json::json!("A"));
827 assert_ne!(
828 base.fingerprint().unwrap(),
829 with_metadata.fingerprint().unwrap()
830 );
831
832 let mut with_tags = base.clone();
834 with_tags.records[0].tags = vec!["clean".to_string()];
835 assert_ne!(
836 base.fingerprint().unwrap(),
837 with_tags.fingerprint().unwrap()
838 );
839 }
840
841 #[test]
842 fn old_relation_json_defaults_to_observation_unit() {
843 let relation: SampleRelation = serde_json::from_value(serde_json::json!({
844 "observation_id": "obs:legacy",
845 "sample_id": "s1",
846 "target_id": "t1",
847 "group_id": "g1",
848 "source_id": "legacy",
849 "is_augmented": false
850 }))
851 .unwrap();
852
853 assert_eq!(relation.unit_level, EntityUnitLevel::Observation);
854 assert!(relation.rep_id.is_none());
855 assert!(relation.component_observation_ids.is_empty());
856 SampleRelationSet {
857 records: vec![relation],
858 }
859 .validate()
860 .unwrap();
861 }
862
863 #[test]
864 fn relation_validation_rejects_invalid_new_fields() {
865 let mut invalid_rep = source_relation("obs:s1.A.0", "s1", "A", "rep/0");
866 assert!(invalid_rep.validate().is_err());
867
868 invalid_rep.rep_id = Some("rep:0".to_string());
869 invalid_rep.sample_influence_weight = Some(0.0);
870 assert!(invalid_rep.validate().is_err());
871
872 invalid_rep.sample_influence_weight = Some(1.0);
873 invalid_rep.quality_flag = Some(" ".to_string());
874 assert!(invalid_rep.validate().is_err());
875 }
876
877 #[test]
878 fn target_split_refuses_shared_target_across_fold_boundary() {
879 let relations = SampleRelationSet {
880 records: vec![
881 relation("obs:1", "s1", "same_target", "g1"),
882 relation("obs:2", "s2", "t2", "g2"),
883 relation("obs:3", "s3", "same_target", "g3"),
884 relation("obs:4", "s4", "t4", "g4"),
885 ],
886 };
887 let policy = LeakageUnitPolicy {
888 split_unit: SplitUnit::Target,
889 ..LeakageUnitPolicy::default()
890 };
891
892 assert!(relations
893 .validate_against_fold_set(&fold_set(), &policy)
894 .is_err());
895 }
896
897 #[test]
898 fn augmentation_origin_cannot_cross_train_validation_boundary() {
899 let mut generated = relation("obs:aug", "s3", "t3", "g3");
900 generated.origin_sample_id = Some(sid("s1"));
901 generated.is_augmented = true;
902 let relations = SampleRelationSet {
903 records: vec![
904 relation("obs:1", "s1", "t1", "g1"),
905 relation("obs:2", "s2", "t2", "g2"),
906 generated,
907 relation("obs:4", "s4", "t4", "g4"),
908 ],
909 };
910
911 assert!(relations
912 .validate_against_fold_set(&fold_set(), &LeakageUnitPolicy::default())
913 .is_err());
914 }
915}