1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct DetectionKinds {
45 pub exact: bool,
47 pub normalized: bool,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(deny_unknown_fields)]
54pub struct DuplicateGroup {
55 pub members: Vec<(String, String)>,
57 pub detected_by: DetectionKinds,
59 pub label_conflict: bool,
62}
63
64#[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 pub fn excluded_train_ids(&self) -> &[String] {
77 &self.excluded_train_ids
78 }
79
80 pub fn reduced_pools(&self) -> &BTreeMap<usize, u64> {
82 &self.reduced_pools
83 }
84
85 pub fn groups(&self) -> &[DuplicateGroup] {
87 &self.groups
88 }
89
90 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 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
115struct 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
154struct FlatRow<'a> {
156 role: &'a str,
157 id: &'a str,
158 label: usize,
159 exact: [u8; 32],
160 normalized: [u8; 32],
161}
162
163#[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 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
268fn 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 #[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 #[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 #[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 #[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 #[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 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}