Skip to main content

dag_ml_core/
relation.rs

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    // Metadata + tags carried so a `by_metadata` / `by_tag` branch view selector
59    // can match natively in the data provider. Skipped when empty so relation
60    // sets without them keep byte-identical fingerprints (existing fixtures and
61    // contracts stay unaffected). A non-empty value changes the replay
62    // fingerprint because it changes which samples a branch view selects.
63    #[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    // Skipped when false so `excluded=false` relation sets keep byte-identical
192    // fingerprints (and existing fixtures/contracts stay unaffected); an
193    // `excluded=true` row correctly changes the replay fingerprint because it
194    // changes the training set.
195    #[serde(default, skip_serializing_if = "is_false")]
196    excluded: bool,
197    // Skipped when empty so relations without metadata/tags keep byte-identical
198    // fingerprints; a non-empty value correctly changes the replay fingerprint
199    // because it changes which samples a `by_metadata` / `by_tag` branch view
200    // selects.
201    #[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    /// Samples excluded from training. Exclusion is sample-local: a sample is
431    /// excluded if ANY of its relation rows carries `excluded == true`, so a
432    /// multi-source / repetition sample can never train through a sibling
433    /// non-excluded row. These samples are still validated and predicted.
434    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        // excluded=false is byte-identical to the default (skip_serializing_if):
786        // existing fixtures and contracts stay unaffected.
787        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        // excluded=true changes the training set, so it MUST change the
795        // replay fingerprint (else a different exclusion mask could false-hit a
796        // cached bundle).
797        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        // Empty metadata/tags are skip-serialized, so an explicit empty value is
812        // byte-identical to the default: existing fixtures/contracts unaffected.
813        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        // Metadata changes which samples a `by_metadata` view selects, so it
822        // MUST change the replay fingerprint.
823        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        // Same for tags (used by `by_tag` views).
833        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}