Skip to main content

aprender_contrastive_data/
dedup.rs

1//! Cross-split duplicate coalescing and the deterministic exclusion record.
2//!
3//! Duplicate groups are connected components over the union of exact-hash and
4//! normalized-hash edges. Grouping independently by both keys would double-count — an
5//! exact duplicate is necessarily also a normalized duplicate — and could decrement a
6//! class pool twice.
7//!
8//! Prepare-time duplicate content is excluded and recorded, never fatal (D-18, upheld by
9//! D-27); the typed error fires only when the reduced pool can no longer supply
10//! `shots_per_class`.
11//!
12//! # Why coalescing is not an optimization
13//!
14//! `hash.rs` proves, as a property test, that an exact-hash collision implies a
15//! normalized-hash collision. So the two edge kinds are not independent: every exact
16//! duplicate appears in BOTH groupings. Emitting one group per key would remove the same
17//! training row twice from the same class pool, understating the pool. A pool understated
18//! near the boundary produces a `CrossSplitDuplicateUnderflow` at selection time — a
19//! failure invented by the detector rather than present in the data.
20//!
21//! # Why nothing here returns `Err`
22//!
23//! [`coalesced_exclusions`] returns an [`ExclusionRecord`], not a `Result`, and that is a
24//! decision rather than an omission (D-18, upheld verbatim by D-27). Hard-failing at
25//! SELECTION time would make failures seed-dependent, so a subset of benchmark cells would
26//! die and a completeness gate would reject the run for a reason unrelated to the method.
27//! Hard-failing at PREPARE time would hand upstream data quality a veto over the whole
28//! dataset. The only real failure is a reduced pool that can no longer supply the
29//! requested shots, and that is raised where the shots are known: at selection.
30
31use std::collections::{BTreeMap, BTreeSet};
32
33use serde::{Deserialize, Serialize};
34use sha2::{Digest, Sha256};
35
36use crate::error::ContrastiveDataError;
37use crate::hash::{exact_hash, normalized_hash, CONTENT_NORMALIZATION_VERSION};
38use crate::schema::LabeledExample;
39use crate::split::{SplitRole, Train};
40
41/// Which detection kinds fired inside one duplicate component.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct DetectionKinds {
45    /// At least one pair of members shares an exact content hash.
46    pub exact: bool,
47    /// At least one pair of members shares a normalized content hash.
48    pub normalized: bool,
49}
50
51/// One connected component of duplicate content spanning at least two split roles.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(deny_unknown_fields)]
54pub struct DuplicateGroup {
55    /// `(split_role, id)` members, sorted ascending.
56    pub members: Vec<(String, String)>,
57    /// Which detection kinds fired inside this component.
58    pub detected_by: DetectionKinds,
59    /// True when the members do not all carry the same label — impossible to reconcile
60    /// automatically, and therefore worth surfacing rather than silently excluding.
61    pub label_conflict: bool,
62}
63
64/// The deterministic record of everything cross-split duplication removed.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(deny_unknown_fields)]
67pub struct ExclusionRecord {
68    excluded_train_ids: Vec<String>,
69    groups: Vec<DuplicateGroup>,
70    reduced_pools: BTreeMap<usize, u64>,
71    normalization_version: String,
72}
73
74impl ExclusionRecord {
75    /// Training ids removed from the selection pool, sorted ascending.
76    pub fn excluded_train_ids(&self) -> &[String] {
77        &self.excluded_train_ids
78    }
79
80    /// Remaining training pool size per class label, after exclusion.
81    pub fn reduced_pools(&self) -> &BTreeMap<usize, u64> {
82        &self.reduced_pools
83    }
84
85    /// The duplicate components, sorted deterministically.
86    pub fn groups(&self) -> &[DuplicateGroup] {
87        &self.groups
88    }
89
90    /// Deterministic canonical serialization.
91    ///
92    /// # Errors
93    ///
94    /// [`ContrastiveDataError::Serialization`] if the record cannot be serialized.
95    pub fn to_canonical_bytes(&self) -> Result<Vec<u8>, ContrastiveDataError> {
96        serde_json::to_vec(self).map_err(|error| ContrastiveDataError::Serialization {
97            context: "exclusion_record".to_string(),
98            detail: error.to_string(),
99        })
100    }
101
102    /// SHA-256 of [`Self::to_canonical_bytes`].
103    ///
104    /// Total for the same reason the ledger's hash is: the canonical form is strings,
105    /// integers and booleans, with every map a `BTreeMap<usize, u64>` whose keys
106    /// serialize as strings. `serde_json` has no failure mode to report here.
107    pub fn hash(&self) -> [u8; 32] {
108        let bytes = self
109            .to_canonical_bytes()
110            .expect("ExclusionRecord canonical form is strings, integers and bools");
111        Sha256::digest(bytes).into()
112    }
113}
114
115/// A minimal disjoint-set forest over row ordinals.
116///
117/// Union by size with path halving. The structure is a `Vec`, not a map, so nothing here
118/// depends on hash iteration order (PF-006).
119struct DisjointSet {
120    parent: Vec<usize>,
121    size: Vec<usize>,
122}
123
124impl DisjointSet {
125    fn new(len: usize) -> Self {
126        Self {
127            parent: (0..len).collect(),
128            size: vec![1; len],
129        }
130    }
131
132    fn find(&mut self, mut node: usize) -> usize {
133        while self.parent[node] != node {
134            let grandparent = self.parent[self.parent[node]];
135            self.parent[node] = grandparent;
136            node = grandparent;
137        }
138        node
139    }
140
141    fn union(&mut self, left: usize, right: usize) {
142        let (mut a, mut b) = (self.find(left), self.find(right));
143        if a == b {
144            return;
145        }
146        if self.size[a] < self.size[b] {
147            core::mem::swap(&mut a, &mut b);
148        }
149        self.parent[b] = a;
150        self.size[a] += self.size[b];
151    }
152}
153
154/// One row, flattened across splits, with both of its content hashes.
155struct FlatRow<'a> {
156    role: &'a str,
157    id: &'a str,
158    label: usize,
159    exact: [u8; 32],
160    normalized: [u8; 32],
161}
162
163/// Coalesce cross-split duplicate content into connected components.
164///
165/// Deterministic and total. `splits` is `(role, rows)` for every split of one dataset.
166///
167/// Edges come from two sources — equal exact hashes and equal normalized hashes — and are
168/// merged into ONE disjoint-set forest before any group is emitted. A component that spans
169/// at least two distinct split roles is a duplicate group; a component confined to one role
170/// is a within-split repetition, which is not evaluation leakage and is left alone.
171///
172/// Only TRAIN rows are removed, and only from the selection pool: the evaluation splits
173/// keep every row they arrived with, because shrinking an evaluation split would change
174/// what a reported score means (D-18).
175#[provable_contracts_macros::contract(
176    "contrastive-pair-protocol-v1",
177    equation = "cross_split_exclusion"
178)]
179pub(crate) fn coalesced_exclusions(
180    splits: &[(&'static str, &[LabeledExample])],
181) -> ExclusionRecord {
182    let flat: Vec<FlatRow<'_>> = splits
183        .iter()
184        .flat_map(|(role, rows)| {
185            rows.iter().map(move |row| FlatRow {
186                role,
187                id: row.id.as_str(),
188                label: row.label,
189                exact: exact_hash(&row.input),
190                normalized: normalized_hash(&row.input),
191            })
192        })
193        .collect();
194
195    // Both edge kinds go into ONE forest. Bucketing by hash uses BTreeMap so the union
196    // order — and therefore nothing observable, but also nothing accidental — is fixed.
197    let mut forest = DisjointSet::new(flat.len());
198    for key in [
199        |row: &FlatRow<'_>| row.exact,
200        |row: &FlatRow<'_>| row.normalized,
201    ] {
202        let mut buckets: BTreeMap<[u8; 32], Vec<usize>> = BTreeMap::new();
203        for (index, row) in flat.iter().enumerate() {
204            buckets.entry(key(row)).or_default().push(index);
205        }
206        for members in buckets.values() {
207            for pair in members.windows(2) {
208                forest.union(pair[0], pair[1]);
209            }
210        }
211    }
212
213    let mut components: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
214    for index in 0..flat.len() {
215        let root = forest.find(index);
216        components.entry(root).or_default().push(index);
217    }
218
219    let mut groups: Vec<DuplicateGroup> = Vec::new();
220    let mut excluded_train_ids: BTreeSet<String> = BTreeSet::new();
221    for members in components.values() {
222        let roles: BTreeSet<&str> = members.iter().map(|index| flat[*index].role).collect();
223        if roles.len() < 2 {
224            continue;
225        }
226
227        let detected_by = DetectionKinds {
228            exact: shares_a_key(members, &flat, |row| row.exact),
229            normalized: shares_a_key(members, &flat, |row| row.normalized),
230        };
231        let labels: BTreeSet<usize> = members.iter().map(|index| flat[*index].label).collect();
232        let mut member_pairs: Vec<(String, String)> = members
233            .iter()
234            .map(|index| (flat[*index].role.to_string(), flat[*index].id.to_string()))
235            .collect();
236        member_pairs.sort();
237
238        for index in members {
239            if flat[*index].role == Train::ROLE {
240                excluded_train_ids.insert(flat[*index].id.to_string());
241            }
242        }
243
244        groups.push(DuplicateGroup {
245            members: member_pairs,
246            detected_by,
247            label_conflict: labels.len() > 1,
248        });
249    }
250    groups.sort_by(|left, right| left.members.cmp(&right.members));
251
252    let mut reduced_pools: BTreeMap<usize, u64> = BTreeMap::new();
253    for row in flat.iter().filter(|row| row.role == Train::ROLE) {
254        let entry = reduced_pools.entry(row.label).or_insert(0);
255        if !excluded_train_ids.contains(row.id) {
256            *entry += 1;
257        }
258    }
259
260    ExclusionRecord {
261        excluded_train_ids: excluded_train_ids.into_iter().collect(),
262        groups,
263        reduced_pools,
264        normalization_version: CONTENT_NORMALIZATION_VERSION.to_string(),
265    }
266}
267
268/// True when at least two members of the component share the given hash.
269fn shares_a_key(
270    members: &[usize],
271    flat: &[FlatRow<'_>],
272    key: impl Fn(&FlatRow<'_>) -> [u8; 32],
273) -> bool {
274    let mut seen: BTreeSet<[u8; 32]> = BTreeSet::new();
275    members.iter().any(|index| !seen.insert(key(&flat[*index])))
276}
277
278#[cfg(test)]
279mod dedup_tests {
280    use super::coalesced_exclusions;
281    use crate::hash::CONTENT_NORMALIZATION_VERSION;
282    use crate::schema::LabeledExample;
283
284    fn row(id: &str, input: &str, label: usize, split: &str) -> LabeledExample {
285        LabeledExample {
286            id: id.to_string(),
287            input: input.to_string(),
288            label,
289            label_text: ["none", "against", "favor"][label].to_string(),
290            source_split: split.to_string(),
291        }
292    }
293
294    fn train_base() -> Vec<LabeledExample> {
295        vec![
296            row("train:0", "alpha post", 0, "train"),
297            row("train:1", "beta post", 1, "train"),
298            row("train:2", "gamma post", 2, "train"),
299        ]
300    }
301
302    /// Fixture A — a train row byte-identical to a validation row, same label.
303    #[test]
304    fn dedup_fixture_a_exact_duplicate_is_one_group_and_one_decrement() {
305        let train = train_base();
306        let validation = vec![row("validation:0", "alpha post", 0, "validation")];
307        let record = coalesced_exclusions(&[("train", &train), ("validation", &validation)]);
308
309        assert_eq!(record.excluded_train_ids(), ["train:0".to_string()]);
310        assert_eq!(
311            record.groups().len(),
312            1,
313            "an exact duplicate is also a normalized duplicate; it must not be two groups"
314        );
315        let group = &record.groups()[0];
316        assert!(group.detected_by.exact, "exact edge must be recorded");
317        assert!(
318            group.detected_by.normalized,
319            "an exact duplicate always co-fires the normalized edge"
320        );
321        assert!(!group.label_conflict);
322        assert_eq!(
323            group.members,
324            vec![
325                ("train".to_string(), "train:0".to_string()),
326                ("validation".to_string(), "validation:0".to_string()),
327            ]
328        );
329        assert_eq!(record.reduced_pools().get(&0), Some(&0));
330        assert_eq!(record.reduced_pools().get(&1), Some(&1));
331        assert_eq!(record.reduced_pools().get(&2), Some(&1));
332    }
333
334    /// Fixture B — differs from a test row only by trailing whitespace.
335    #[test]
336    fn dedup_fixture_b_whitespace_variant_is_normalized_only() {
337        let train = train_base();
338        let test = vec![row("test:0", "beta post  ", 1, "test")];
339        let record = coalesced_exclusions(&[("train", &train), ("test", &test)]);
340
341        assert_eq!(record.excluded_train_ids(), ["train:1".to_string()]);
342        assert_eq!(record.groups().len(), 1);
343        let group = &record.groups()[0];
344        assert!(
345            !group.detected_by.exact,
346            "the bytes differ, so no exact edge exists"
347        );
348        assert!(group.detected_by.normalized);
349        assert_eq!(record.reduced_pools().get(&1), Some(&0));
350    }
351
352    /// Fixture C — duplicate content across splits with DIFFERENT labels. Real data has
353    /// none, so this path can only be reached synthetically.
354    #[test]
355    fn dedup_fixture_c_label_conflict_is_flagged() {
356        let train = train_base();
357        let validation = vec![row("validation:0", "gamma post", 0, "validation")];
358        let record = coalesced_exclusions(&[("train", &train), ("validation", &validation)]);
359
360        assert_eq!(record.excluded_train_ids(), ["train:2".to_string()]);
361        assert_eq!(record.groups().len(), 1);
362        assert!(record.groups()[0].label_conflict);
363    }
364
365    /// Fixture D — a three-way chain. `train:0` equals `validation:0` exactly, and
366    /// `validation:0` equals `test:0` only after normalization. Union-find must merge all
367    /// three into ONE component and decrement the train pool exactly once.
368    #[test]
369    fn dedup_fixture_d_three_way_chain_is_one_component() {
370        let train = train_base();
371        let validation = vec![row("validation:0", "alpha post", 0, "validation")];
372        let test = vec![row("test:0", "  alpha   post ", 0, "test")];
373        let record = coalesced_exclusions(&[
374            ("test", &test),
375            ("train", &train),
376            ("validation", &validation),
377        ]);
378
379        assert_eq!(record.groups().len(), 1, "the chain is ONE component");
380        let group = &record.groups()[0];
381        assert_eq!(group.members.len(), 3);
382        assert!(group.detected_by.exact);
383        assert!(group.detected_by.normalized);
384        assert_eq!(record.excluded_train_ids(), ["train:0".to_string()]);
385        assert_eq!(record.reduced_pools().get(&0), Some(&0));
386    }
387
388    #[test]
389    fn dedup_no_duplicates_leaves_the_pools_intact() {
390        let train = train_base();
391        let validation = vec![row("validation:0", "delta post", 0, "validation")];
392        let record = coalesced_exclusions(&[("train", &train), ("validation", &validation)]);
393
394        assert!(record.excluded_train_ids().is_empty());
395        assert!(record.groups().is_empty());
396        assert_eq!(record.reduced_pools().get(&0), Some(&1));
397        assert_eq!(record.reduced_pools().get(&1), Some(&1));
398        assert_eq!(record.reduced_pools().get(&2), Some(&1));
399        assert!(!record.to_canonical_bytes().expect("serializes").is_empty());
400    }
401
402    #[test]
403    fn dedup_records_the_normalization_version() {
404        let train = train_base();
405        let record = coalesced_exclusions(&[("train", &train)]);
406        let json = String::from_utf8(record.to_canonical_bytes().expect("serializes"))
407            .expect("canonical bytes are UTF-8");
408        assert!(json.contains(CONTENT_NORMALIZATION_VERSION));
409    }
410
411    /// A record that reaches a manifest reaches a hash, so its content must not depend on
412    /// the order the caller happened to collect rows in.
413    #[test]
414    fn dedup_is_order_independent_in_both_record_and_hash() {
415        let mut train = train_base();
416        train.push(row("train:3", "alpha post", 0, "train"));
417        let validation = vec![
418            row("validation:0", "alpha post", 0, "validation"),
419            row("validation:1", "beta post ", 1, "validation"),
420        ];
421        let forward = coalesced_exclusions(&[("train", &train), ("validation", &validation)]);
422
423        let mut permuted_train = train.clone();
424        permuted_train.reverse();
425        let mut permuted_validation = validation.clone();
426        permuted_validation.reverse();
427        let backward = coalesced_exclusions(&[
428            ("validation", &permuted_validation),
429            ("train", &permuted_train),
430        ]);
431
432        assert_eq!(forward, backward);
433        assert_eq!(forward.hash(), backward.hash());
434    }
435
436    #[test]
437    fn dedup_hash_changes_when_the_excluded_set_changes() {
438        let train = train_base();
439        let clean = coalesced_exclusions(&[("train", &train)]);
440        let validation = vec![row("validation:0", "alpha post", 0, "validation")];
441        let dirty = coalesced_exclusions(&[("train", &train), ("validation", &validation)]);
442        assert_ne!(clean.hash(), dirty.hash());
443    }
444
445    #[test]
446    fn dedup_within_split_duplicate_content_is_not_a_cross_split_group() {
447        // Two train rows with identical content are NOT evaluation leakage; only content
448        // spanning two split roles is.
449        let train = vec![
450            row("train:0", "alpha post", 0, "train"),
451            row("train:1", "alpha post", 0, "train"),
452        ];
453        let record = coalesced_exclusions(&[("train", &train)]);
454        assert!(record.groups().is_empty());
455        assert!(record.excluded_train_ids().is_empty());
456        assert_eq!(record.reduced_pools().get(&0), Some(&2));
457    }
458}