Skip to main content

aprender_contrastive_data/
hash.rs

1//! Exact and normalized content hashes plus the dataset fingerprint.
2//!
3//! Two hashes per row, for two different jobs (D-17): the exact SHA-256 over the raw
4//! `input` bytes is identity and provenance; the normalized hash (`nfc-trim-ws-v1` — NFC,
5//! trimmed, internal whitespace collapsed, deliberately NO casefolding) is leakage
6//! detection.
7//!
8//! # This module is a LEAF
9//!
10//! It depends on nothing else in this crate except the error type. In particular it does
11//! not know that typed split roles exist: both fingerprints take **raw parts**
12//! ([`SplitFingerprintInput`], [`DatasetFingerprintInput`]) rather than a typed split, so
13//! the hashing story is complete and testable on its own. The one place the typestate and
14//! the hashes meet is the prepared-dataset constructor, which assembles these inputs from
15//! its own splits.
16//!
17//! # One construction, two domain tags
18//!
19//! A dataset fingerprint and a single-split fingerprint absorb split parts through the
20//! *same* private helper. They differ only in their domain-tag prefix, which is what makes
21//! them differ even for a one-split dataset — so a split fingerprint can never be mistaken
22//! for a dataset fingerprint by a consumer comparing hex strings.
23
24use sha2::{Digest, Sha256};
25
26/// The version tag of the normalization pipeline behind [`normalized_hash`].
27///
28/// Recorded in every manifest that depends on it. Changing any step of the pipeline is a
29/// NEW TAG and a contract change, never an in-place edit: the exclusion record of every
30/// previously produced manifest was computed under the old one.
31pub const CONTENT_NORMALIZATION_VERSION: &str = "nfc-trim-ws-v1";
32
33/// Domain tag for a whole-dataset fingerprint.
34const DATASET_FP_DOMAIN: &[u8] = b"apr-dataset-fp-v1\0";
35
36/// Domain tag for a single-split fingerprint.
37const SPLIT_FP_DOMAIN: &[u8] = b"apr-split-fp-v1\0";
38
39/// SHA-256 over the raw bytes of `input`. Identity and provenance.
40///
41/// This is the digest that appears in fingerprints and attestations, so it must describe
42/// the bytes as stored — no trimming, no normalization, no case folding.
43pub fn exact_hash(input: &str) -> [u8; 32] {
44    Sha256::digest(input.as_bytes()).into()
45}
46
47/// SHA-256 over the `nfc-trim-ws-v1` normalization of `input`. Leakage detection.
48///
49/// The pipeline is exactly: NFC, then trim, then collapse every internal whitespace run to
50/// a single `U+0020`. `split_whitespace` performs the last two steps in one pass and uses
51/// the Unicode `White_Space` property, so a non-breaking space collapses like a plain one.
52///
53/// # There is deliberately NO casefolding (D-17)
54///
55/// Casefolding would collide legitimately distinct short posts — the corpus this protocol
56/// was designed against is social-media length, where `"Yes"` and `"yes"` are routinely
57/// different rows by different authors. A false leakage positive silently REMOVES a
58/// training row and shrinks a class pool, which is a worse outcome than the retweet
59/// variant this normalization is here to catch.
60#[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
72/// Lowercase hex rendering of a digest.
73pub 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            // Writing into a String is infallible; the Result exists only to satisfy the
80            // `Write` trait, and discarding it here keeps the signature total.
81            let _ = write!(out, "{byte:02x}");
82            out
83        })
84}
85
86/// Absorb one length-prefixed field.
87///
88/// Every variable-length field is prefixed with its length so that two different
89/// decompositions of the same concatenated bytes cannot produce the same digest. Without
90/// it, `role="tr"` + `id="ain:0"` and `role="train"` + `id=":0"` would hash identically.
91fn absorb_field(hasher: &mut Sha256, field: &[u8]) {
92    hasher.update((field.len() as u64).to_le_bytes());
93    hasher.update(field);
94}
95
96/// The ONE per-split absorption routine, shared by both fingerprint entry points.
97///
98/// "The same construction with a different domain tag" is a fact about this function
99/// rather than a claim in a comment: `SplitFingerprint::compute` and
100/// `DatasetFingerprint::compute` both call it, and neither has a private copy that could
101/// drift.
102fn 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
121/// Raw parts describing ONE split, in absorption order.
122pub(crate) struct SplitFingerprintInput<'a> {
123    /// The split's role name.
124    pub role: &'a str,
125    /// SHA-256 of the split's canonical JSONL bytes.
126    pub source_hash: &'a [u8; 32],
127    /// Per-class row counts, indexed by class label.
128    pub class_counts: &'a [u64],
129    /// `(id, exact_hash)` pairs, ALREADY sorted ascending by id.
130    pub rows: &'a [(&'a str, [u8; 32])],
131}
132
133/// Raw parts describing a WHOLE dataset.
134pub(crate) struct DatasetFingerprintInput<'a> {
135    /// The dataset profile string.
136    pub profile: &'a str,
137    /// Ordered label names.
138    pub label_names: &'a [String],
139    /// The content-normalization version.
140    pub normalization_version: &'a str,
141    /// Per-split parts, ALREADY ordered by ascending role name.
142    pub splits: &'a [SplitFingerprintInput<'a>],
143}
144
145/// Identity of a WHOLE dataset.
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub struct DatasetFingerprint([u8; 32]);
148
149impl DatasetFingerprint {
150    /// Lowercase hex rendering.
151    pub fn hex(&self) -> String {
152        hex(&self.0)
153    }
154
155    /// Absorb the profile, the ordered label names, the normalization version, and then
156    /// every split's raw parts in ascending role order through [`absorb_split`].
157    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/// Identity of ONE split alone.
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct SplitFingerprint([u8; 32]);
185
186impl SplitFingerprint {
187    /// Lowercase hex rendering.
188    pub fn hex(&self) -> String {
189        hex(&self.0)
190    }
191
192    /// Absorb the SAME raw parts a dataset fingerprint absorbs for this split, under a
193    /// different domain tag.
194    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    /// Half the generated pairs are deliberately EQUAL, because two independently drawn
266    /// strings essentially never collide under SHA-256 and the implication would then be
267    /// vacuously true for every case the property ever saw.
268    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        // Mutating the LAST id keeps the ascending-by-id ordering that `compute`
345        // debug-asserts, so this test exercises identity sensitivity rather than the
346        // caller's ordering obligation.
347        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}