Skip to main content

dag_ml_data_core/
fingerprint.rs

1use sha2::{Digest, Sha256};
2
3use crate::error::Result;
4use crate::model::DatasetSchema;
5use crate::plan::DataPlan;
6use crate::relation::{FoldSet, SampleRelationTable};
7
8pub fn schema_fingerprint(schema: &DatasetSchema) -> Result<String> {
9    let mut canonical = schema.clone();
10    canonical.validate()?;
11    canonical.sample_ids.sort();
12    canonical
13        .sources
14        .sort_by(|left, right| left.id.cmp(&right.id));
15    canonical
16        .groups
17        .sort_by(|left, right| left.id.cmp(&right.id));
18    canonical
19        .folds
20        .sort_by(|left, right| left.id.cmp(&right.id));
21
22    let json = serde_json::to_vec(&canonical)?;
23    let digest = Sha256::digest(json);
24    Ok(to_hex(&digest))
25}
26
27pub fn data_plan_fingerprint(plan: &DataPlan) -> Result<String> {
28    plan.validate()?;
29    let json = serde_json::to_vec(plan)?;
30    let digest = Sha256::digest(json);
31    Ok(to_hex(&digest))
32}
33
34pub fn sample_relation_fingerprint(relations: &SampleRelationTable) -> Result<String> {
35    let mut canonical = relations.clone();
36    canonical.validate()?;
37    canonical.rows.sort_by(|left, right| {
38        left.observation_id
39            .cmp(&right.observation_id)
40            .then_with(|| left.sample_id.cmp(&right.sample_id))
41            .then_with(|| left.source_id.cmp(&right.source_id))
42    });
43
44    let json = serde_json::to_vec(&canonical)?;
45    let digest = Sha256::digest(json);
46    Ok(to_hex(&digest))
47}
48
49pub fn fold_set_fingerprint(fold_set: &FoldSet) -> Result<String> {
50    let mut canonical = fold_set.clone();
51    canonical.validate()?;
52    canonical.sample_ids.sort();
53    canonical
54        .folds
55        .sort_by(|left, right| left.fold_id.cmp(&right.fold_id));
56    for fold in &mut canonical.folds {
57        fold.train_sample_ids.sort();
58        fold.validation_sample_ids.sort();
59    }
60
61    let mut value = serde_json::to_value(&canonical)?;
62    remove_empty_fold_set_maps(&mut value);
63    let json = serde_json::to_vec(&value)?;
64    let digest = Sha256::digest(json);
65    Ok(to_hex(&digest))
66}
67
68fn remove_empty_fold_set_maps(value: &mut serde_json::Value) {
69    let Some(object) = value.as_object_mut() else {
70        return;
71    };
72    if object
73        .get("sample_groups")
74        .and_then(serde_json::Value::as_object)
75        .is_some_and(serde_json::Map::is_empty)
76    {
77        object.remove("sample_groups");
78    }
79    let Some(folds) = object
80        .get_mut("folds")
81        .and_then(serde_json::Value::as_array_mut)
82    else {
83        return;
84    };
85    for fold in folds {
86        let Some(fold_object) = fold.as_object_mut() else {
87            continue;
88        };
89        if fold_object
90            .get("metadata")
91            .and_then(serde_json::Value::as_object)
92            .is_some_and(serde_json::Map::is_empty)
93        {
94            fold_object.remove("metadata");
95        }
96    }
97}
98
99fn to_hex(bytes: &[u8]) -> String {
100    let mut out = String::with_capacity(bytes.len() * 2);
101    for byte in bytes {
102        use std::fmt::Write;
103        write!(&mut out, "{byte:02x}").expect("writing to string cannot fail");
104    }
105    out
106}
107
108#[cfg(test)]
109mod tests {
110    use std::collections::BTreeMap;
111
112    use crate::ids::{GroupId, RepresentationId, SampleId, SourceId, TypeId};
113    use crate::model::{
114        AxisKind, AxisSpec, CoordinateDType, CoordinateSpec, CoordinateValues, DatasetSchema,
115        FoldSpec, GroupKind, GroupSpec, RepresentationSpec, SourceDescriptor, SourceGranularity,
116    };
117    use crate::plan::DataPlan;
118    use crate::relation::{FoldAssignment, FoldSet};
119
120    use super::{
121        data_plan_fingerprint, fold_set_fingerprint, sample_relation_fingerprint,
122        schema_fingerprint,
123    };
124
125    const SHARED_FOLD_SET_FINGERPRINT: &str =
126        "54d3185d6c628ef0df848828a8d8ae650222a283a78bbd3ab3bc2256f222c05c";
127
128    fn representation(id: &str) -> RepresentationSpec {
129        RepresentationSpec {
130            id: RepresentationId::new(id).unwrap(),
131            type_id: TypeId::new("table").unwrap(),
132            rank: Some(2),
133            axes: vec![
134                AxisSpec {
135                    name: "sample".to_string(),
136                    kind: AxisKind::Sample,
137                    unit: None,
138                    size: Some(2),
139                    variable: false,
140                    coordinate: None,
141                },
142                AxisSpec {
143                    name: "feature".to_string(),
144                    kind: AxisKind::Feature,
145                    unit: None,
146                    size: Some(1),
147                    variable: false,
148                    coordinate: None,
149                },
150            ],
151            container: "dataframe".to_string(),
152            dtype: Some("float32".to_string()),
153            sparse: false,
154            ragged: false,
155            signal_type: None,
156        }
157    }
158
159    fn source(id: &str) -> SourceDescriptor {
160        SourceDescriptor {
161            id: SourceId::new(id).unwrap(),
162            name: id.to_string(),
163            type_id: TypeId::new("table").unwrap(),
164            modality: "metadata".to_string(),
165            native_representation: representation("tabular"),
166            sample_key: "sample_id".to_string(),
167            granularity: SourceGranularity::PerSample,
168            schema: BTreeMap::new(),
169            tags: BTreeMap::new(),
170            shape_contract: None,
171        }
172    }
173
174    #[test]
175    fn fingerprint_is_independent_of_source_order() {
176        let mut left = DatasetSchema {
177            dataset_id: "d".to_string(),
178            sample_ids: vec![SampleId::new("s2").unwrap(), SampleId::new("s1").unwrap()],
179            sources: vec![source("b"), source("a")],
180            targets: BTreeMap::new(),
181            metadata: BTreeMap::new(),
182            metadata_schema: None,
183            groups: vec![
184                GroupSpec {
185                    id: GroupId::new("g.b").unwrap(),
186                    kind: GroupKind::Batch,
187                    column: "batch_b".to_string(),
188                    source_id: Some(SourceId::new("b").unwrap()),
189                    strict: false,
190                    metadata: BTreeMap::new(),
191                },
192                GroupSpec {
193                    id: GroupId::new("g.a").unwrap(),
194                    kind: GroupKind::RepetitionGroup,
195                    column: "sample_id".to_string(),
196                    source_id: Some(SourceId::new("a").unwrap()),
197                    strict: true,
198                    metadata: BTreeMap::new(),
199                },
200            ],
201            folds: vec![
202                FoldSpec {
203                    id: "fold.b".to_string(),
204                    group_id: Some(GroupId::new("g.b").unwrap()),
205                    split_column: Some("fold_b".to_string()),
206                    metadata: BTreeMap::new(),
207                },
208                FoldSpec {
209                    id: "fold.a".to_string(),
210                    group_id: Some(GroupId::new("g.a").unwrap()),
211                    split_column: Some("fold_a".to_string()),
212                    metadata: BTreeMap::new(),
213                },
214            ],
215        };
216        let mut right = left.clone();
217        right.sources.reverse();
218        right.sample_ids.reverse();
219        right.groups.reverse();
220        right.folds.reverse();
221
222        assert_eq!(
223            schema_fingerprint(&left).unwrap(),
224            schema_fingerprint(&right).unwrap()
225        );
226
227        left.dataset_id = "different".to_string();
228        assert_ne!(
229            schema_fingerprint(&left).unwrap(),
230            schema_fingerprint(&right).unwrap()
231        );
232    }
233
234    #[test]
235    fn data_plan_fingerprint_is_stable() {
236        let plan: DataPlan = serde_json::from_str(include_str!(
237            "../../../examples/fixtures/oof_campaign/expected_data_plan_nir_to_tabular.json"
238        ))
239        .unwrap();
240
241        assert_eq!(
242            data_plan_fingerprint(&plan).unwrap(),
243            data_plan_fingerprint(&plan).unwrap()
244        );
245    }
246
247    #[test]
248    fn sample_relation_fingerprint_is_stable() {
249        let relations: crate::relation::SampleRelationTable = serde_json::from_str(include_str!(
250            "../../../examples/fixtures/oof_campaign/sample_relations_grouped_augmented.json"
251        ))
252        .unwrap();
253
254        assert_eq!(
255            sample_relation_fingerprint(&relations).unwrap(),
256            sample_relation_fingerprint(&relations).unwrap()
257        );
258    }
259
260    #[test]
261    fn empty_tags_keep_relation_fingerprint_byte_identical() {
262        // `tags` is skip-serialized when empty, so attaching an empty Vec to a
263        // row must not change the source-relation fingerprint; a non-empty value
264        // does change it (it changes which samples a `by_tag` view selects).
265        let base: crate::relation::SampleRelationTable = serde_json::from_str(include_str!(
266            "../../../examples/fixtures/oof_campaign/sample_relations_grouped_augmented.json"
267        ))
268        .unwrap();
269
270        let mut explicit_empty = base.clone();
271        explicit_empty.rows[0].tags = Vec::new();
272        assert_eq!(
273            sample_relation_fingerprint(&base).unwrap(),
274            sample_relation_fingerprint(&explicit_empty).unwrap()
275        );
276
277        let mut with_tags = base.clone();
278        with_tags.rows[0].tags = vec!["clean".to_string()];
279        assert_ne!(
280            sample_relation_fingerprint(&base).unwrap(),
281            sample_relation_fingerprint(&with_tags).unwrap()
282        );
283    }
284
285    #[test]
286    fn fold_set_fingerprint_is_independent_of_ordering() {
287        let mut left = FoldSet {
288            id: "cv.partition".to_string(),
289            sample_ids: vec![
290                SampleId::new("s3").unwrap(),
291                SampleId::new("s2").unwrap(),
292                SampleId::new("s1").unwrap(),
293            ],
294            folds: vec![
295                FoldAssignment {
296                    fold_id: "fold1".to_string(),
297                    train_sample_ids: vec![
298                        SampleId::new("s2").unwrap(),
299                        SampleId::new("s1").unwrap(),
300                    ],
301                    validation_sample_ids: vec![SampleId::new("s3").unwrap()],
302                    metadata: BTreeMap::new(),
303                },
304                FoldAssignment {
305                    fold_id: "fold0".to_string(),
306                    train_sample_ids: vec![SampleId::new("s3").unwrap()],
307                    validation_sample_ids: vec![
308                        SampleId::new("s2").unwrap(),
309                        SampleId::new("s1").unwrap(),
310                    ],
311                    metadata: BTreeMap::new(),
312                },
313            ],
314            sample_groups: BTreeMap::new(),
315        };
316        let mut right = left.clone();
317        right.sample_ids.reverse();
318        right.folds.reverse();
319        for fold in &mut right.folds {
320            fold.train_sample_ids.reverse();
321            fold.validation_sample_ids.reverse();
322        }
323
324        assert_eq!(
325            fold_set_fingerprint(&left).unwrap(),
326            fold_set_fingerprint(&right).unwrap()
327        );
328
329        left.id = "cv.partition.changed".to_string();
330        assert_ne!(
331            fold_set_fingerprint(&left).unwrap(),
332            fold_set_fingerprint(&right).unwrap()
333        );
334    }
335
336    fn coordinate_schema(coordinate: Option<CoordinateSpec>) -> DatasetSchema {
337        let mut repr = representation("tabular");
338        // The feature axis (size 1) carries the coordinate.
339        repr.axes[1].coordinate = coordinate;
340        let mut descriptor = source("a");
341        descriptor.native_representation = repr;
342        DatasetSchema {
343            dataset_id: "coords".to_string(),
344            sample_ids: vec![SampleId::new("s1").unwrap()],
345            sources: vec![descriptor],
346            targets: BTreeMap::new(),
347            metadata: BTreeMap::new(),
348            metadata_schema: None,
349            groups: Vec::new(),
350            folds: Vec::new(),
351        }
352    }
353
354    #[test]
355    fn schema_fingerprint_reflects_axis_coordinates() {
356        let explicit = CoordinateSpec {
357            dtype: CoordinateDType::Categorical,
358            ordered: false,
359            values: CoordinateValues::Explicit {
360                values: vec![serde_json::Value::from("R")],
361            },
362        };
363        let grid = CoordinateSpec {
364            dtype: CoordinateDType::Numeric,
365            ordered: true,
366            values: CoordinateValues::RegularGrid {
367                start: 400.0,
368                step: 2.0,
369            },
370        };
371
372        let bare = schema_fingerprint(&coordinate_schema(None)).unwrap();
373        let with_explicit = schema_fingerprint(&coordinate_schema(Some(explicit.clone()))).unwrap();
374        let with_grid = schema_fingerprint(&coordinate_schema(Some(grid))).unwrap();
375
376        // Stable for a fixed coordinate spec.
377        assert_eq!(
378            with_explicit,
379            schema_fingerprint(&coordinate_schema(Some(explicit))).unwrap()
380        );
381        // Coordinates participate in the fingerprint and the two shapes differ.
382        assert_ne!(bare, with_explicit);
383        assert_ne!(bare, with_grid);
384        assert_ne!(with_explicit, with_grid);
385    }
386
387    #[test]
388    fn shared_fold_set_fixture_fingerprint_is_locked() {
389        let fixture = include_str!("../../../examples/fixtures/shared/fold_set_cv_partition.json");
390        let fold_set = serde_json::from_str::<FoldSet>(fixture).unwrap();
391
392        assert_eq!(
393            fold_set_fingerprint(&fold_set).unwrap(),
394            SHARED_FOLD_SET_FINGERPRINT
395        );
396    }
397}