aprender_contrastive_data/
hash.rs1use sha2::{Digest, Sha256};
25
26pub const CONTENT_NORMALIZATION_VERSION: &str = "nfc-trim-ws-v1";
32
33const DATASET_FP_DOMAIN: &[u8] = b"apr-dataset-fp-v1\0";
35
36const SPLIT_FP_DOMAIN: &[u8] = b"apr-split-fp-v1\0";
38
39pub fn exact_hash(input: &str) -> [u8; 32] {
44 Sha256::digest(input.as_bytes()).into()
45}
46
47#[provable_contracts_macros::contract(
61 "contrastive-pair-protocol-v1",
62 equation = "normalized_content_hash"
63)]
64pub fn normalized_hash(input: &str) -> [u8; 32] {
65 use unicode_normalization::UnicodeNormalization;
66
67 let composed: String = input.nfc().collect();
68 let collapsed = composed.split_whitespace().collect::<Vec<_>>().join(" ");
69 Sha256::digest(collapsed.as_bytes()).into()
70}
71
72pub fn hex(digest: &[u8; 32]) -> String {
74 use core::fmt::Write as _;
75
76 digest
77 .iter()
78 .fold(String::with_capacity(64), |mut out, byte| {
79 let _ = write!(out, "{byte:02x}");
82 out
83 })
84}
85
86fn absorb_field(hasher: &mut Sha256, field: &[u8]) {
92 hasher.update((field.len() as u64).to_le_bytes());
93 hasher.update(field);
94}
95
96fn absorb_split(hasher: &mut Sha256, input: &SplitFingerprintInput<'_>) {
103 debug_assert!(
104 input.rows.windows(2).all(|pair| pair[0].0 <= pair[1].0),
105 "SplitFingerprintInput::rows must be sorted ascending by id before hashing"
106 );
107
108 absorb_field(hasher, input.role.as_bytes());
109 absorb_field(hasher, input.source_hash);
110 hasher.update((input.class_counts.len() as u64).to_le_bytes());
111 for count in input.class_counts {
112 hasher.update(count.to_le_bytes());
113 }
114 hasher.update((input.rows.len() as u64).to_le_bytes());
115 for (id, row_hash) in input.rows {
116 absorb_field(hasher, id.as_bytes());
117 absorb_field(hasher, row_hash);
118 }
119}
120
121pub(crate) struct SplitFingerprintInput<'a> {
123 pub role: &'a str,
125 pub source_hash: &'a [u8; 32],
127 pub class_counts: &'a [u64],
129 pub rows: &'a [(&'a str, [u8; 32])],
131}
132
133pub(crate) struct DatasetFingerprintInput<'a> {
135 pub profile: &'a str,
137 pub label_names: &'a [String],
139 pub normalization_version: &'a str,
141 pub splits: &'a [SplitFingerprintInput<'a>],
143}
144
145#[derive(Debug, Clone, PartialEq, Eq)]
147pub struct DatasetFingerprint([u8; 32]);
148
149impl DatasetFingerprint {
150 pub fn hex(&self) -> String {
152 hex(&self.0)
153 }
154
155 pub(crate) fn compute(input: &DatasetFingerprintInput<'_>) -> Self {
158 debug_assert!(
159 input
160 .splits
161 .windows(2)
162 .all(|pair| pair[0].role <= pair[1].role),
163 "DatasetFingerprintInput::splits must be ordered by ascending role name"
164 );
165
166 let mut hasher = Sha256::new();
167 hasher.update(DATASET_FP_DOMAIN);
168 absorb_field(&mut hasher, input.profile.as_bytes());
169 hasher.update((input.label_names.len() as u64).to_le_bytes());
170 for name in input.label_names {
171 absorb_field(&mut hasher, name.as_bytes());
172 }
173 absorb_field(&mut hasher, input.normalization_version.as_bytes());
174 hasher.update((input.splits.len() as u64).to_le_bytes());
175 for split in input.splits {
176 absorb_split(&mut hasher, split);
177 }
178 Self(hasher.finalize().into())
179 }
180}
181
182#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct SplitFingerprint([u8; 32]);
185
186impl SplitFingerprint {
187 pub fn hex(&self) -> String {
189 hex(&self.0)
190 }
191
192 pub(crate) fn compute(input: &SplitFingerprintInput<'_>) -> Self {
195 let mut hasher = Sha256::new();
196 hasher.update(SPLIT_FP_DOMAIN);
197 absorb_split(&mut hasher, input);
198 Self(hasher.finalize().into())
199 }
200}
201
202#[cfg(test)]
203mod hash_tests {
204 use super::{
205 exact_hash, hex, normalized_hash, DatasetFingerprint, DatasetFingerprintInput,
206 SplitFingerprint, SplitFingerprintInput, CONTENT_NORMALIZATION_VERSION,
207 };
208 use proptest::prelude::{prop_assert_eq, proptest, Strategy};
209
210 fn label_names() -> Vec<String> {
211 vec![
212 "none".to_string(),
213 "against".to_string(),
214 "favor".to_string(),
215 ]
216 }
217
218 fn sample_rows() -> Vec<(&'static str, [u8; 32])> {
219 let mut rows = vec![
220 ("train:0", exact_hash("alpha")),
221 ("train:1", exact_hash("beta")),
222 ];
223 rows.sort_by(|left, right| left.0.cmp(right.0));
224 rows
225 }
226
227 #[test]
228 fn hash_content_normalization_version_is_pinned() {
229 assert_eq!(CONTENT_NORMALIZATION_VERSION, "nfc-trim-ws-v1");
230 }
231
232 #[test]
233 fn hash_hex_is_lowercase_and_64_characters() {
234 let rendered = hex(&exact_hash("anything"));
235 assert_eq!(rendered.len(), 64);
236 assert!(rendered.chars().all(|c| c.is_ascii_hexdigit()));
237 assert_eq!(rendered, rendered.to_lowercase());
238 }
239
240 #[test]
241 fn hash_exact_is_byte_sensitive_while_normalized_trims() {
242 assert_ne!(exact_hash("text "), exact_hash("text"));
243 assert_eq!(normalized_hash("text "), normalized_hash("text"));
244 assert_eq!(normalized_hash(" text\n"), normalized_hash("text"));
245 }
246
247 #[test]
248 fn hash_normalized_collapses_unicode_whitespace_runs() {
249 assert_eq!(normalized_hash("a b"), normalized_hash("a b"));
250 assert_eq!(normalized_hash("a\u{00A0}b"), normalized_hash("a b"));
251 assert_eq!(normalized_hash("a\t\nb"), normalized_hash("a b"));
252 }
253
254 #[test]
255 fn hash_normalized_does_not_casefold() {
256 assert_ne!(normalized_hash("Text"), normalized_hash("text"));
257 }
258
259 #[test]
260 fn hash_normalized_applies_nfc() {
261 assert_eq!(normalized_hash("e\u{0301}"), normalized_hash("\u{00E9}"));
262 assert_ne!(exact_hash("e\u{0301}"), exact_hash("\u{00E9}"));
263 }
264
265 fn pair_strategy() -> impl Strategy<Value = (String, String)> {
269 (".{0,24}", ".{0,24}", proptest::bool::ANY).prop_map(|(left, right, identical)| {
270 if identical {
271 (left.clone(), left)
272 } else {
273 (left, right)
274 }
275 })
276 }
277
278 proptest! {
279 #[test]
280 fn hash_exact_collision_implies_normalized_collision((left, right) in pair_strategy()) {
281 if exact_hash(&left) == exact_hash(&right) {
282 prop_assert_eq!(normalized_hash(&left), normalized_hash(&right));
283 }
284 }
285 }
286
287 struct Parts {
288 role: String,
289 source_hash: [u8; 32],
290 class_counts: Vec<u64>,
291 rows: Vec<(&'static str, [u8; 32])>,
292 profile: String,
293 label_names: Vec<String>,
294 normalization_version: String,
295 }
296
297 impl Parts {
298 fn base() -> Self {
299 Self {
300 role: "train".to_string(),
301 source_hash: exact_hash("train-bytes"),
302 class_counts: vec![3, 4, 5],
303 rows: sample_rows(),
304 profile: "canonical".to_string(),
305 label_names: label_names(),
306 normalization_version: CONTENT_NORMALIZATION_VERSION.to_string(),
307 }
308 }
309
310 fn split_input(&self) -> SplitFingerprintInput<'_> {
311 SplitFingerprintInput {
312 role: &self.role,
313 source_hash: &self.source_hash,
314 class_counts: &self.class_counts,
315 rows: &self.rows,
316 }
317 }
318
319 fn dataset_fingerprint(&self) -> DatasetFingerprint {
320 let splits = [self.split_input()];
321 DatasetFingerprint::compute(&DatasetFingerprintInput {
322 profile: &self.profile,
323 label_names: &self.label_names,
324 normalization_version: &self.normalization_version,
325 splits: &splits,
326 })
327 }
328 }
329
330 fn assert_fingerprint_changes(mutate: impl FnOnce(&mut Parts), field: &str) {
331 let base = Parts::base();
332 let baseline = base.dataset_fingerprint();
333 let mut mutated = Parts::base();
334 mutate(&mut mutated);
335 assert_ne!(
336 baseline.hex(),
337 mutated.dataset_fingerprint().hex(),
338 "dataset fingerprint must change when {field} changes"
339 );
340 }
341
342 #[test]
343 fn hash_dataset_fingerprint_is_sensitive_to_a_row_id() {
344 assert_fingerprint_changes(|parts| parts.rows[1].0 = "train:9", "a row id");
348 }
349
350 #[test]
351 fn hash_dataset_fingerprint_is_sensitive_to_a_role() {
352 assert_fingerprint_changes(|parts| parts.role = "test".to_string(), "a split role");
353 }
354
355 #[test]
356 fn hash_dataset_fingerprint_is_sensitive_to_the_profile() {
357 assert_fingerprint_changes(
358 |parts| parts.profile = "compatibility".to_string(),
359 "the profile",
360 );
361 }
362
363 #[test]
364 fn hash_dataset_fingerprint_is_sensitive_to_a_label_name() {
365 assert_fingerprint_changes(
366 |parts| parts.label_names[1] = "opposed".to_string(),
367 "a label name",
368 );
369 }
370
371 #[test]
372 fn hash_dataset_fingerprint_is_sensitive_to_the_normalization_version() {
373 assert_fingerprint_changes(
374 |parts| parts.normalization_version = "nfc-trim-ws-v2".to_string(),
375 "the normalization version",
376 );
377 }
378
379 #[test]
380 fn hash_dataset_fingerprint_is_sensitive_to_a_row_exact_hash() {
381 assert_fingerprint_changes(
382 |parts| parts.rows[0].1 = exact_hash("mutated"),
383 "a row exact hash",
384 );
385 }
386
387 #[test]
388 fn hash_dataset_fingerprint_is_sensitive_to_a_source_hash() {
389 assert_fingerprint_changes(
390 |parts| parts.source_hash = exact_hash("other-bytes"),
391 "a split source hash",
392 );
393 }
394
395 #[test]
396 fn hash_dataset_fingerprint_is_sensitive_to_a_class_count() {
397 assert_fingerprint_changes(|parts| parts.class_counts[2] = 6, "a per-class count");
398 }
399
400 #[test]
401 fn hash_split_and_dataset_fingerprints_differ_for_a_one_split_dataset() {
402 let parts = Parts::base();
403 let split = SplitFingerprint::compute(&parts.split_input());
404 let dataset = parts.dataset_fingerprint();
405 assert_ne!(
406 split.hex(),
407 dataset.hex(),
408 "distinct domain tags must keep a split fingerprint distinguishable from a dataset fingerprint"
409 );
410 }
411
412 #[test]
413 fn hash_split_fingerprint_is_stable_across_two_computations() {
414 let parts = Parts::base();
415 assert_eq!(
416 SplitFingerprint::compute(&parts.split_input()).hex(),
417 SplitFingerprint::compute(&parts.split_input()).hex()
418 );
419 }
420}