Skip to main content

aprender_contrastive_data/
attestation.rs

1//! Dataset identity attestation and its re-derivation from supplied buffers.
2//!
3//! # Contract: contrastive-pair-protocol-v1.yaml (equation `dataset_attestation`)
4//!
5//! A [`DatasetAttestation`] carries profile, schema version, label map, per-split JSONL
6//! SHA-256, per-split per-class counts, normalization version, the cross-split
7//! exclusion-record digest, and the dataset fingerprint. `from_attested_bytes` re-derives
8//! every one of those from the buffers the caller supplied and fails typed on the first
9//! disagreement — an attestation that is merely *quoted back* proves nothing about the
10//! bytes in hand.
11//!
12//! # The threat this closes
13//!
14//! Row-level checks are not enough. A consumer pointed at an output directory whose
15//! `train.jsonl` came from one preparation and `validation.jsonl` from another would pass
16//! every row-level gate: each file is individually well-formed, each row carries the right
17//! role, each class count is internally consistent. What is broken is *split identity* —
18//! the two files do not describe one dataset — and nothing a row can say detects it. The
19//! attested per-split digests plus the whole-dataset fingerprint are what turn that mixed
20//! directory into a typed [`ContrastiveDataError::SplitHashMismatch`] or
21//! [`ContrastiveDataError::FingerprintMismatch`] instead of a silent success.
22//!
23//! # Order is part of the guarantee
24//!
25//! The ladder runs: parse the attestation -> schema version -> normalization version ->
26//! profile -> role set -> **per-split SHA-256 BEFORE the buffer is parsed** -> the ordinary
27//! ingest gate ladder (which is where per-class counts are checked) -> exclusion digest ->
28//! dataset fingerprint. The split-hash check precedes parsing deliberately: a corrupted
29//! buffer must be reported as "these are not the bytes you attested", not as "row 4 is
30//! malformed". The second diagnosis sends a reader looking for a data-quality problem in a
31//! file that is simply the wrong file.
32//!
33//! No value of type `PreparedDataset<P>` — and therefore no `Split<R>` accessor — exists
34//! until every comparison has passed. Exposure-then-validate would let a caller read rows
35//! out of a dataset that is about to be rejected.
36//!
37//! # There is no schema-version-1 migration, deliberately
38//!
39//! See [`SUPPORTED_DATASET_ATTESTATION_SCHEMA_VERSIONS`].
40
41use std::collections::BTreeMap;
42
43use serde::{Deserialize, Serialize};
44use sha2::{Digest, Sha256};
45
46use crate::error::ContrastiveDataError;
47use crate::hash::{hex, CONTENT_NORMALIZATION_VERSION};
48use crate::ledger::AccessLedger;
49use crate::prepared::{Canonical, Compatibility, DatasetProfile, PreparedDataset};
50use crate::split::{
51    CompatibilityTest, Split, SplitDeclaration, SplitRole, Test, Train, Validation,
52};
53
54/// The attestation schema version this build WRITES.
55pub const DATASET_ATTESTATION_SCHEMA_VERSION: u32 = 2;
56
57/// Every attestation schema version this build ACCEPTS.
58///
59/// # Why there is no version-1 shim
60///
61/// Version 1 predates the cross-split exclusion record. A version-1 artifact therefore
62/// does not say which training rows were removed from the selection pool, and no migration
63/// could supply that: recomputing it from the version-1 splits would produce a value the
64/// original preparation never attested to, and defaulting it to "nothing was excluded"
65/// would assert something that is false for the canonical TweetEval data. Either way the
66/// upgraded record would be an unattested guess wearing an attestation's clothes, which is
67/// strictly worse than a refusal. A version-1 manifest is
68/// [`ContrastiveDataError::UnsupportedSchemaVersion`] and the remedy is to re-prepare.
69pub const SUPPORTED_DATASET_ATTESTATION_SCHEMA_VERSIONS: &[u32] = &[2];
70
71/// What one split's bytes must reproduce.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(deny_unknown_fields)]
74pub struct SplitAttestation {
75    /// Lowercase hex SHA-256 of the split's canonical JSONL bytes.
76    pub sha256: String,
77    /// Per-class row counts, indexed by class label.
78    pub class_counts: Vec<u64>,
79}
80
81/// The identity a prepared dataset attests to.
82///
83/// Field order here IS the serialized field order (`serde_json` emits struct fields in
84/// declaration order) and every map is a `BTreeMap`, so [`DatasetAttestation::to_bytes`]
85/// is canonical: two runs over the same dataset produce byte-identical output.
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(deny_unknown_fields)]
88pub struct DatasetAttestation {
89    /// Attestation schema version. Always [`DATASET_ATTESTATION_SCHEMA_VERSION`] on write.
90    pub schema_version: u32,
91    /// The dataset profile these splits belong to.
92    pub profile: String,
93    /// The declared label map, in label order.
94    pub label_names: Vec<String>,
95    /// The content-normalization version the exclusion record was computed under.
96    pub normalization_version: String,
97    /// Per-split digests and class counts, keyed by split role.
98    pub splits: BTreeMap<String, SplitAttestation>,
99    /// Lowercase hex SHA-256 of the cross-split exclusion record's canonical bytes.
100    pub exclusion_hash: String,
101    /// Lowercase hex dataset fingerprint.
102    pub dataset_fingerprint: String,
103}
104
105impl DatasetAttestation {
106    /// Build the attestation a prepared dataset warrants.
107    pub fn from_prepared<P: AttestedProfile>(dataset: &PreparedDataset<P>) -> Self {
108        P::attest(dataset)
109    }
110
111    /// Canonical, deterministic serialization.
112    ///
113    /// # Errors
114    ///
115    /// [`ContrastiveDataError::Serialization`] if the record cannot be serialized.
116    pub fn to_bytes(&self) -> Result<Vec<u8>, ContrastiveDataError> {
117        serde_json::to_vec(self).map_err(|error| ContrastiveDataError::Serialization {
118            context: "dataset_attestation".to_string(),
119            detail: error.to_string(),
120        })
121    }
122
123    /// Parse an attestation from untrusted bytes.
124    ///
125    /// `deny_unknown_fields` applies: an extra key is a schema change, and a schema change
126    /// that deserializes silently is a data change nobody reviewed.
127    ///
128    /// # Errors
129    ///
130    /// [`ContrastiveDataError::Serialization`] if the bytes are not a well-formed
131    /// attestation.
132    pub fn from_bytes(bytes: &[u8]) -> Result<Self, ContrastiveDataError> {
133        serde_json::from_slice(bytes).map_err(|error| ContrastiveDataError::Serialization {
134            context: "dataset_attestation".to_string(),
135            detail: error.to_string(),
136        })
137    }
138}
139
140/// A profile that can be attested. Implemented only by [`Canonical`] and [`Compatibility`].
141pub trait AttestedProfile: DatasetProfile + Sized {
142    /// Every split role this profile emits. Membership is all that is asked of it, so the
143    /// order is documentary rather than load-bearing.
144    const ROLES: &'static [&'static str];
145
146    /// Derive this profile's attestation from a prepared dataset.
147    fn attest(dataset: &PreparedDataset<Self>) -> DatasetAttestation;
148}
149
150impl AttestedProfile for Canonical {
151    const ROLES: &'static [&'static str] = &["train", "validation", "test"];
152
153    fn attest(dataset: &PreparedDataset<Self>) -> DatasetAttestation {
154        let mut splits = BTreeMap::new();
155        splits.insert(Train::ROLE.to_string(), split_attestation(dataset.train()));
156        splits.insert(
157            Validation::ROLE.to_string(),
158            split_attestation(dataset.validation()),
159        );
160        splits.insert(Test::ROLE.to_string(), split_attestation(dataset.test()));
161        DatasetAttestation {
162            schema_version: DATASET_ATTESTATION_SCHEMA_VERSION,
163            profile: Self::PROFILE.to_string(),
164            label_names: dataset.label_names().to_vec(),
165            normalization_version: CONTENT_NORMALIZATION_VERSION.to_string(),
166            splits,
167            exclusion_hash: hex(&dataset.exclusions().hash()),
168            dataset_fingerprint: dataset.fingerprint().hex(),
169        }
170    }
171}
172
173impl AttestedProfile for Compatibility {
174    const ROLES: &'static [&'static str] = &["train", "compatibility_test"];
175
176    fn attest(dataset: &PreparedDataset<Self>) -> DatasetAttestation {
177        let mut splits = BTreeMap::new();
178        splits.insert(Train::ROLE.to_string(), split_attestation(dataset.train()));
179        splits.insert(
180            CompatibilityTest::ROLE.to_string(),
181            split_attestation(dataset.compatibility_test()),
182        );
183        DatasetAttestation {
184            schema_version: DATASET_ATTESTATION_SCHEMA_VERSION,
185            profile: Self::PROFILE.to_string(),
186            label_names: dataset.label_names().to_vec(),
187            normalization_version: CONTENT_NORMALIZATION_VERSION.to_string(),
188            splits,
189            exclusion_hash: hex(&dataset.exclusions().hash()),
190            dataset_fingerprint: dataset.fingerprint().hex(),
191        }
192    }
193}
194
195/// One split's attested parts, taken from the split's own accessors.
196fn split_attestation<R: SplitRole>(split: &Split<R>) -> SplitAttestation {
197    SplitAttestation {
198        sha256: hex(split.source_hash()),
199        class_counts: split.class_counts().to_vec(),
200    }
201}
202
203/// Gates that do not depend on the buffers: schema version, normalization version,
204/// profile, and the role set.
205fn preflight<P: AttestedProfile>(
206    attestation_bytes: &[u8],
207    buffers: &BTreeMap<String, Vec<u8>>,
208) -> Result<DatasetAttestation, ContrastiveDataError> {
209    let attestation = DatasetAttestation::from_bytes(attestation_bytes)?;
210
211    if !SUPPORTED_DATASET_ATTESTATION_SCHEMA_VERSIONS.contains(&attestation.schema_version) {
212        return Err(ContrastiveDataError::UnsupportedSchemaVersion {
213            field: "dataset_attestation".to_string(),
214            got: attestation.schema_version,
215            supported: DATASET_ATTESTATION_SCHEMA_VERSION,
216        });
217    }
218    if attestation.normalization_version != CONTENT_NORMALIZATION_VERSION {
219        return Err(ContrastiveDataError::UnsupportedNormalizationVersion {
220            got: attestation.normalization_version,
221            supported: CONTENT_NORMALIZATION_VERSION,
222        });
223    }
224    if attestation.profile != P::PROFILE {
225        return Err(ContrastiveDataError::ProfileMismatch {
226            expected: P::PROFILE.to_string(),
227            got: attestation.profile,
228        });
229    }
230    // A role this profile does not emit has no business in either map. Left unchecked it
231    // would be inert today and load-bearing the moment somebody iterated the maps instead
232    // of the profile's own role list.
233    for role in attestation.splits.keys().chain(buffers.keys()) {
234        if !P::ROLES.contains(&role.as_str()) {
235            return Err(ContrastiveDataError::ConflictingSourceRole {
236                declared: P::PROFILE.to_string(),
237                embedded: role.clone(),
238            });
239        }
240    }
241    Ok(attestation)
242}
243
244/// Verify one split's buffer against its attested digest, THEN run the ingest ladder.
245///
246/// The digest comparison is first on purpose — see the module doc.
247fn verified_split<R: SplitRole>(
248    attestation: &DatasetAttestation,
249    buffers: &BTreeMap<String, Vec<u8>>,
250) -> Result<Split<R>, ContrastiveDataError> {
251    let role = R::ROLE;
252    let attested =
253        attestation
254            .splits
255            .get(role)
256            .ok_or_else(|| ContrastiveDataError::MissingSplit {
257                role: role.to_string(),
258            })?;
259    let buffer = buffers
260        .get(role)
261        .ok_or_else(|| ContrastiveDataError::MissingSplit {
262            role: role.to_string(),
263        })?;
264
265    let digest: [u8; 32] = Sha256::digest(buffer).into();
266    let got = hex(&digest);
267    if got != attested.sha256 {
268        return Err(ContrastiveDataError::SplitHashMismatch {
269            split: role.to_string(),
270            expected: attested.sha256.clone(),
271            got,
272        });
273    }
274
275    let decl = SplitDeclaration {
276        // A count that cannot be represented as a `usize` on this target cannot be matched
277        // by any real split, so saturating turns an absurd attested count into a
278        // guaranteed InvalidClassCounts rather than a panic.
279        expected_class_counts: attested
280            .class_counts
281            .iter()
282            .map(|count| usize::try_from(*count).unwrap_or(usize::MAX))
283            .collect(),
284        label_names: attestation.label_names.clone(),
285    };
286    Split::<R>::from_jsonl_bytes(buffer, &decl)
287}
288
289/// The two derived comparisons that can only run once the dataset exists.
290fn check_derived(
291    attestation: &DatasetAttestation,
292    exclusion_hash: &str,
293    dataset_fingerprint: &str,
294) -> Result<(), ContrastiveDataError> {
295    if attestation.exclusion_hash != exclusion_hash {
296        return Err(ContrastiveDataError::ExclusionRecordMismatch {
297            expected: attestation.exclusion_hash.clone(),
298            got: exclusion_hash.to_string(),
299        });
300    }
301    if attestation.dataset_fingerprint != dataset_fingerprint {
302        return Err(ContrastiveDataError::FingerprintMismatch {
303            expected: attestation.dataset_fingerprint.clone(),
304            got: dataset_fingerprint.to_string(),
305        });
306    }
307    Ok(())
308}
309
310/// Copy a staging ledger into the caller's ledger.
311///
312/// Ingest is staged and only committed once every comparison has passed, so a rejected
313/// dataset leaves no access record — the same invariant `from_labeled_rows` upholds by
314/// failing before it records anything.
315fn commit_ledger(staged: &AccessLedger, ledger: &mut AccessLedger) {
316    for record in staged.records() {
317        ledger.record(
318            &record.role,
319            &record.profile,
320            &record.purpose,
321            &record.fingerprint_hex,
322        );
323    }
324}
325
326impl PreparedDataset<Canonical> {
327    /// The canonical profile's attested boundary.
328    ///
329    /// `splits` is keyed by split role — exactly the shape
330    /// [`crate::prepared::PreparedJsonl::as_map`] hands back, so
331    /// `from_labeled_rows -> encode_jsonl -> from_attested_bytes` is a closed round trip
332    /// whose two ends must fingerprint identically.
333    ///
334    /// # Errors
335    ///
336    /// [`ContrastiveDataError::UnsupportedSchemaVersion`],
337    /// [`ContrastiveDataError::UnsupportedNormalizationVersion`],
338    /// [`ContrastiveDataError::ProfileMismatch`],
339    /// [`ContrastiveDataError::ConflictingSourceRole`],
340    /// [`ContrastiveDataError::MissingSplit`],
341    /// [`ContrastiveDataError::SplitHashMismatch`], any gate-ladder variant (including
342    /// [`ContrastiveDataError::InvalidClassCounts`] when the attested per-class counts
343    /// disagree with the split contents), [`ContrastiveDataError::ExclusionRecordMismatch`],
344    /// or [`ContrastiveDataError::FingerprintMismatch`].
345    #[provable_contracts_macros::contract(
346        "contrastive-pair-protocol-v1",
347        equation = "dataset_attestation"
348    )]
349    pub fn from_attested_bytes(
350        attestation_bytes: &[u8],
351        splits: &BTreeMap<String, Vec<u8>>,
352        ledger: &mut AccessLedger,
353    ) -> Result<Self, ContrastiveDataError> {
354        let attestation = preflight::<Canonical>(attestation_bytes, splits)?;
355
356        let train = verified_split::<Train>(&attestation, splits)?;
357        let validation = verified_split::<Validation>(&attestation, splits)?;
358        let test = verified_split::<Test>(&attestation, splits)?;
359
360        let mut staged = AccessLedger::new();
361        let dataset = Self::from_validated_splits(
362            train,
363            validation,
364            test,
365            &attestation.label_names,
366            &mut staged,
367        );
368
369        check_derived(
370            &attestation,
371            &hex(&dataset.exclusions().hash()),
372            &dataset.fingerprint().hex(),
373        )?;
374        commit_ledger(&staged, ledger);
375        Ok(dataset)
376    }
377}
378
379impl PreparedDataset<Compatibility> {
380    /// The compatibility profile's attested boundary.
381    ///
382    /// A canonical attestation fed here is [`ContrastiveDataError::ProfileMismatch`], and
383    /// so is a compatibility attestation fed to the canonical constructor. The profile is
384    /// a type parameter, so the two are separate functions the compiler keeps apart; this
385    /// check is what stops untrusted BYTES from crossing between them (D-19).
386    ///
387    /// # Errors
388    ///
389    /// The same set as the canonical constructor.
390    #[provable_contracts_macros::contract(
391        "contrastive-pair-protocol-v1",
392        equation = "dataset_attestation"
393    )]
394    pub fn from_attested_bytes(
395        attestation_bytes: &[u8],
396        splits: &BTreeMap<String, Vec<u8>>,
397        ledger: &mut AccessLedger,
398    ) -> Result<Self, ContrastiveDataError> {
399        let attestation = preflight::<Compatibility>(attestation_bytes, splits)?;
400
401        let train = verified_split::<Train>(&attestation, splits)?;
402        let compatibility_test = verified_split::<CompatibilityTest>(&attestation, splits)?;
403
404        let mut staged = AccessLedger::new();
405        let dataset = Self::from_validated_splits(
406            train,
407            compatibility_test,
408            &attestation.label_names,
409            &mut staged,
410        );
411
412        check_derived(
413            &attestation,
414            &hex(&dataset.exclusions().hash()),
415            &dataset.fingerprint().hex(),
416        )?;
417        commit_ledger(&staged, ledger);
418        Ok(dataset)
419    }
420}
421
422#[cfg(test)]
423mod attestation_tests {
424    use super::{
425        DatasetAttestation, DATASET_ATTESTATION_SCHEMA_VERSION,
426        SUPPORTED_DATASET_ATTESTATION_SCHEMA_VERSIONS,
427    };
428    use crate::error::ContrastiveDataError;
429    use crate::ledger::AccessLedger;
430    use crate::prepared::{
431        Canonical, CanonicalDeclarations, Compatibility, CompatibilityDeclarations, PreparedDataset,
432    };
433    use crate::schema::LabeledExample;
434    use crate::split::SplitDeclaration;
435    use std::collections::BTreeMap;
436
437    fn label_names() -> Vec<String> {
438        vec![
439            "none".to_string(),
440            "against".to_string(),
441            "favor".to_string(),
442        ]
443    }
444
445    fn row(id: &str, input: &str, label: usize, split: &str) -> LabeledExample {
446        LabeledExample {
447            id: id.to_string(),
448            input: input.to_string(),
449            label,
450            label_text: label_names()[label].clone(),
451            source_split: split.to_string(),
452        }
453    }
454
455    fn decl(counts: Vec<usize>) -> SplitDeclaration {
456        SplitDeclaration {
457            expected_class_counts: counts,
458            label_names: label_names(),
459        }
460    }
461
462    fn train_rows(tag: &str) -> Vec<LabeledExample> {
463        vec![
464            row("train:0", &format!("{tag} alpha post"), 0, "train"),
465            row("train:1", &format!("{tag} beta post"), 1, "train"),
466            row("train:2", &format!("{tag} gamma post"), 2, "train"),
467            row("train:3", &format!("{tag} delta post"), 0, "train"),
468        ]
469    }
470
471    fn validation_rows(tag: &str) -> Vec<LabeledExample> {
472        vec![
473            row(
474                "validation:0",
475                &format!("{tag} epsilon post"),
476                0,
477                "validation",
478            ),
479            row("validation:1", &format!("{tag} zeta post"), 1, "validation"),
480        ]
481    }
482
483    fn test_rows(tag: &str) -> Vec<LabeledExample> {
484        vec![
485            row("test:0", &format!("{tag} eta post"), 2, "test"),
486            row("test:1", &format!("{tag} theta post"), 1, "test"),
487        ]
488    }
489
490    fn canonical_decls() -> CanonicalDeclarations {
491        CanonicalDeclarations {
492            train: decl(vec![2, 1, 1]),
493            validation: decl(vec![1, 1, 0]),
494            test: decl(vec![0, 1, 1]),
495            label_names: label_names(),
496        }
497    }
498
499    /// A canonical dataset plus the attestation and buffers a consumer would be handed.
500    struct Attested {
501        attestation: DatasetAttestation,
502        buffers: BTreeMap<String, Vec<u8>>,
503        fingerprint: String,
504    }
505
506    fn attested_canonical(tag: &str) -> Attested {
507        let mut ledger = AccessLedger::new();
508        let dataset = PreparedDataset::<Canonical>::from_labeled_rows(
509            train_rows(tag),
510            validation_rows(tag),
511            test_rows(tag),
512            &canonical_decls(),
513            &mut ledger,
514        )
515        .expect("valid canonical corpus");
516        let jsonl = dataset.encode_jsonl().expect("encode must succeed");
517        Attested {
518            attestation: DatasetAttestation::from_prepared(&dataset),
519            buffers: jsonl.as_map().clone(),
520            fingerprint: dataset.fingerprint().hex(),
521        }
522    }
523
524    fn attested_compatibility() -> Attested {
525        let mut ledger = AccessLedger::new();
526        let compatibility_rows = vec![
527            row("compatibility_test:0", "eta post", 2, "compatibility_test"),
528            row(
529                "compatibility_test:1",
530                "theta post",
531                1,
532                "compatibility_test",
533            ),
534        ];
535        let dataset = PreparedDataset::<Compatibility>::from_labeled_rows(
536            train_rows("x"),
537            compatibility_rows,
538            &CompatibilityDeclarations {
539                train: decl(vec![2, 1, 1]),
540                compatibility_test: decl(vec![0, 1, 1]),
541                label_names: label_names(),
542            },
543            &mut ledger,
544        )
545        .expect("valid compatibility corpus");
546        let jsonl = dataset.encode_jsonl().expect("encode must succeed");
547        Attested {
548            attestation: DatasetAttestation::from_prepared(&dataset),
549            buffers: jsonl.as_map().clone(),
550            fingerprint: dataset.fingerprint().hex(),
551        }
552    }
553
554    fn bytes_of(attestation: &DatasetAttestation) -> Vec<u8> {
555        attestation.to_bytes().expect("attestation serializes")
556    }
557
558    fn open_canonical(
559        attested: &Attested,
560    ) -> Result<PreparedDataset<Canonical>, ContrastiveDataError> {
561        let mut ledger = AccessLedger::new();
562        PreparedDataset::<Canonical>::from_attested_bytes(
563            &bytes_of(&attested.attestation),
564            &attested.buffers,
565            &mut ledger,
566        )
567    }
568
569    // -----------------------------------------------------------------------------
570    // The happy paths
571    // -----------------------------------------------------------------------------
572
573    #[test]
574    fn attestation_accepts_a_self_consistent_canonical_set() {
575        let attested = attested_canonical("a");
576        let mut ledger = AccessLedger::new();
577        let dataset = PreparedDataset::<Canonical>::from_attested_bytes(
578            &bytes_of(&attested.attestation),
579            &attested.buffers,
580            &mut ledger,
581        )
582        .expect("a self-consistent attested set must be accepted");
583
584        assert_eq!(dataset.fingerprint().hex(), attested.fingerprint);
585        assert_eq!(dataset.train().rows().len(), 4);
586        assert_eq!(ledger.records().len(), 3);
587        assert!(ledger
588            .records()
589            .iter()
590            .all(|record| record.profile == "canonical"));
591    }
592
593    #[test]
594    fn attestation_accepts_a_self_consistent_compatibility_set() {
595        let attested = attested_compatibility();
596        let mut ledger = AccessLedger::new();
597        let dataset = PreparedDataset::<Compatibility>::from_attested_bytes(
598            &bytes_of(&attested.attestation),
599            &attested.buffers,
600            &mut ledger,
601        )
602        .expect("a self-consistent compatibility set must be accepted");
603
604        assert_eq!(dataset.fingerprint().hex(), attested.fingerprint);
605        assert_eq!(dataset.compatibility_test().rows().len(), 2);
606        assert_eq!(ledger.records().len(), 2);
607    }
608
609    /// Checker warning 3 — the cross-path fingerprint reproduction.
610    ///
611    /// The two doors into a split derive `source_hash` DIFFERENTLY: `from_labeled_rows`
612    /// hashes the canonical re-encoding of typed rows, while `from_attested_bytes` lands in
613    /// `Split::from_jsonl_bytes`, which hashes the supplied buffer. This assertion is a
614    /// genuine two-derivation agreement rather than a tautology, and it holds only because
615    /// `encode_jsonl(parse_jsonl_bytes(b)?)? == b` for canonical input.
616    #[test]
617    fn attestation_round_trip_reproduces_the_dataset_fingerprint() {
618        let attested = attested_canonical("round");
619        let reopened = open_canonical(&attested).expect("round trip must succeed");
620        assert_eq!(reopened.fingerprint().hex(), attested.fingerprint);
621        assert_eq!(
622            reopened.exclusions().excluded_train_ids(),
623            Vec::<String>::new().as_slice()
624        );
625    }
626
627    #[test]
628    fn attestation_serialization_is_canonical_and_strict() {
629        let attested = attested_canonical("canon");
630        let first = bytes_of(&attested.attestation);
631        let second = bytes_of(&attested.attestation);
632        assert_eq!(first, second, "serialization must be deterministic");
633
634        let restored = DatasetAttestation::from_bytes(&first).expect("round-trips");
635        assert_eq!(restored, attested.attestation);
636
637        let mut widened = String::from_utf8(first).expect("attestation bytes are UTF-8");
638        widened.pop();
639        widened.push_str(",\"extra\":1}");
640        let err = DatasetAttestation::from_bytes(widened.as_bytes())
641            .expect_err("an unknown field must be rejected");
642        assert!(matches!(err, ContrastiveDataError::Serialization { .. }));
643    }
644
645    /// Vacuity guard for the version rejection below: while the supported set has exactly
646    /// one member, the single `supported` field of `UnsupportedSchemaVersion` can carry it.
647    /// If the set ever grows, this fails and forces the error to be widened rather than
648    /// letting the message quietly under-report.
649    #[test]
650    fn attestation_supported_set_is_exactly_the_writing_version() {
651        assert_eq!(
652            SUPPORTED_DATASET_ATTESTATION_SCHEMA_VERSIONS,
653            &[DATASET_ATTESTATION_SCHEMA_VERSION]
654        );
655    }
656
657    // -----------------------------------------------------------------------------
658    // The nine rejections
659    // -----------------------------------------------------------------------------
660
661    #[test]
662    fn attestation_rejects_a_compatibility_profile_at_the_canonical_door() {
663        let mut attested = attested_canonical("p1");
664        attested.attestation.profile = "compatibility".to_string();
665        match open_canonical(&attested).expect_err("profile mismatch must be rejected") {
666            ContrastiveDataError::ProfileMismatch { expected, got } => {
667                assert_eq!(expected, "canonical");
668                assert_eq!(got, "compatibility");
669            }
670            other => panic!("expected ProfileMismatch, got {other:?}"),
671        }
672    }
673
674    #[test]
675    fn attestation_rejects_a_canonical_profile_at_the_compatibility_door() {
676        let mut attested = attested_compatibility();
677        attested.attestation.profile = "canonical".to_string();
678        let mut ledger = AccessLedger::new();
679        let err = PreparedDataset::<Compatibility>::from_attested_bytes(
680            &bytes_of(&attested.attestation),
681            &attested.buffers,
682            &mut ledger,
683        )
684        .expect_err("profile mismatch must be rejected");
685        match err {
686            ContrastiveDataError::ProfileMismatch { expected, got } => {
687                assert_eq!(expected, "compatibility");
688                assert_eq!(got, "canonical");
689            }
690            other => panic!("expected ProfileMismatch, got {other:?}"),
691        }
692    }
693
694    #[test]
695    fn attestation_rejects_an_unsupported_schema_version() {
696        let mut attested = attested_canonical("v1");
697        attested.attestation.schema_version = 1;
698        let err = open_canonical(&attested).expect_err("version 1 must be refused");
699        let message = err.to_string();
700        match err {
701            ContrastiveDataError::UnsupportedSchemaVersion {
702                field,
703                got,
704                supported,
705            } => {
706                assert_eq!(field, "dataset_attestation");
707                assert_eq!(got, 1);
708                assert_eq!(supported, DATASET_ATTESTATION_SCHEMA_VERSION);
709            }
710            other => panic!("expected UnsupportedSchemaVersion, got {other:?}"),
711        }
712        // The message is read out of the constant, not hardcoded, so widening the
713        // supported set cannot leave this assertion silently describing the old one.
714        for version in SUPPORTED_DATASET_ATTESTATION_SCHEMA_VERSIONS {
715            assert!(
716                message.contains(&version.to_string()),
717                "message must name supported version {version}: {message}"
718            );
719        }
720    }
721
722    #[test]
723    fn attestation_rejects_a_missing_split_buffer() {
724        let mut attested = attested_canonical("miss");
725        attested.buffers.remove("validation");
726        match open_canonical(&attested).expect_err("a missing buffer must be rejected") {
727            ContrastiveDataError::MissingSplit { role } => assert_eq!(role, "validation"),
728            other => panic!("expected MissingSplit, got {other:?}"),
729        }
730    }
731
732    /// The mixed-directory case: every buffer is individually valid, but one came from a
733    /// different preparation. Row-level checks alone accept this.
734    #[test]
735    fn attestation_rejects_a_split_taken_from_another_valid_dataset() {
736        let mut attested = attested_canonical("left");
737        let other = attested_canonical("right");
738        let foreign = other
739            .buffers
740            .get("validation")
741            .expect("the other dataset has a validation split")
742            .clone();
743
744        // The substituted buffer is a WELL-FORMED validation split of the right shape —
745        // parsing it succeeds and every row-level gate passes. Only the attested digest
746        // knows it belongs to a different dataset.
747        assert_ne!(
748            attested.buffers.get("validation"),
749            Some(&foreign),
750            "the fixture must actually differ, or this test proves nothing"
751        );
752        attested.buffers.insert("validation".to_string(), foreign);
753
754        match open_canonical(&attested).expect_err("a mixed directory must be rejected") {
755            ContrastiveDataError::SplitHashMismatch {
756                split,
757                expected,
758                got,
759            } => {
760                assert_eq!(split, "validation");
761                assert_ne!(expected, got);
762            }
763            other => panic!("expected SplitHashMismatch, got {other:?}"),
764        }
765    }
766
767    /// The split-hash check must precede parsing: a buffer that is not even JSON has to be
768    /// diagnosed as "not the bytes you attested", never as "row 0 is malformed".
769    #[test]
770    fn attestation_rejects_corrupt_bytes_before_it_tries_to_parse_them() {
771        let mut attested = attested_canonical("corrupt");
772        attested
773            .buffers
774            .insert("test".to_string(), b"not json at all\n".to_vec());
775        match open_canonical(&attested).expect_err("corrupt bytes must be rejected") {
776            ContrastiveDataError::SplitHashMismatch { split, .. } => assert_eq!(split, "test"),
777            other => panic!("expected SplitHashMismatch before parsing, got {other:?}"),
778        }
779    }
780
781    #[test]
782    fn attestation_rejects_a_fingerprint_that_disagrees_with_the_buffers() {
783        let mut attested = attested_canonical("fp");
784        let forged = "0".repeat(64);
785        attested.attestation.dataset_fingerprint = forged.clone();
786        match open_canonical(&attested).expect_err("fingerprint mismatch must be rejected") {
787            ContrastiveDataError::FingerprintMismatch { expected, got } => {
788                assert_eq!(expected, forged);
789                assert_eq!(got, attested.fingerprint);
790            }
791            other => panic!("expected FingerprintMismatch, got {other:?}"),
792        }
793    }
794
795    #[test]
796    fn attestation_rejects_an_exclusion_hash_that_disagrees_with_the_buffers() {
797        let mut attested = attested_canonical("exc");
798        let forged = "1".repeat(64);
799        attested.attestation.exclusion_hash = forged.clone();
800        match open_canonical(&attested).expect_err("exclusion mismatch must be rejected") {
801            ContrastiveDataError::ExclusionRecordMismatch { expected, got } => {
802                assert_eq!(expected, forged);
803                assert_ne!(got, forged);
804            }
805            other => panic!("expected ExclusionRecordMismatch, got {other:?}"),
806        }
807    }
808
809    #[test]
810    fn attestation_rejects_class_counts_that_disagree_with_the_split_contents() {
811        let mut attested = attested_canonical("counts");
812        attested
813            .attestation
814            .splits
815            .get_mut("train")
816            .expect("the canonical attestation names train")
817            .class_counts = vec![4, 0, 0];
818        match open_canonical(&attested).expect_err("count disagreement must be rejected") {
819            ContrastiveDataError::InvalidClassCounts {
820                split,
821                expected,
822                got,
823            } => {
824                assert_eq!(split, "train");
825                assert_eq!(expected, vec![4, 0, 0]);
826                assert_eq!(got, vec![2, 1, 1]);
827            }
828            other => panic!("expected InvalidClassCounts, got {other:?}"),
829        }
830    }
831
832    // -----------------------------------------------------------------------------
833    // The remaining boundary properties
834    // -----------------------------------------------------------------------------
835
836    #[test]
837    fn attestation_rejects_a_stale_normalization_version() {
838        let mut attested = attested_canonical("norm");
839        attested.attestation.normalization_version = "nfc-trim-ws-v0".to_string();
840        match open_canonical(&attested).expect_err("a stale normalization must be refused") {
841            ContrastiveDataError::UnsupportedNormalizationVersion { got, supported } => {
842                assert_eq!(got, "nfc-trim-ws-v0");
843                assert_eq!(supported, "nfc-trim-ws-v1");
844            }
845            other => panic!("expected UnsupportedNormalizationVersion, got {other:?}"),
846        }
847    }
848
849    #[test]
850    fn attestation_rejects_a_role_outside_the_profile() {
851        let mut attested = attested_canonical("role");
852        let train = attested
853            .attestation
854            .splits
855            .get("train")
856            .expect("train is attested")
857            .clone();
858        attested
859            .attestation
860            .splits
861            .insert("compatibility_test".to_string(), train);
862        match open_canonical(&attested).expect_err("a foreign role must be rejected") {
863            ContrastiveDataError::ConflictingSourceRole { declared, embedded } => {
864                assert_eq!(declared, "canonical");
865                assert_eq!(embedded, "compatibility_test");
866            }
867            other => panic!("expected ConflictingSourceRole, got {other:?}"),
868        }
869    }
870
871    #[test]
872    fn attestation_leaves_no_access_record_when_it_rejects() {
873        let mut attested = attested_canonical("ledger");
874        attested.attestation.dataset_fingerprint = "2".repeat(64);
875        let mut ledger = AccessLedger::new();
876        PreparedDataset::<Canonical>::from_attested_bytes(
877            &bytes_of(&attested.attestation),
878            &attested.buffers,
879            &mut ledger,
880        )
881        .expect_err("a forged fingerprint must be rejected");
882        assert!(
883            ledger.records().is_empty(),
884            "a rejected dataset must leave no access record"
885        );
886    }
887
888    /// The exclusion digest is not decoration: a real cross-split duplicate changes it, so
889    /// an attestation carrying the clean value cannot describe the duplicated buffers.
890    #[test]
891    fn attestation_binds_the_exclusion_record_to_the_buffers() {
892        let clean = attested_canonical("dup");
893        let mut duplicated_validation = validation_rows("dup");
894        duplicated_validation[0].input = "dup alpha post".to_string();
895        let mut ledger = AccessLedger::new();
896        let dirty = PreparedDataset::<Canonical>::from_labeled_rows(
897            train_rows("dup"),
898            duplicated_validation,
899            test_rows("dup"),
900            &canonical_decls(),
901            &mut ledger,
902        )
903        .expect("a cross-split duplicate is not fatal");
904        assert_eq!(dirty.exclusions().groups().len(), 1);
905
906        let dirty_attestation = DatasetAttestation::from_prepared(&dirty);
907        assert_ne!(
908            dirty_attestation.exclusion_hash, clean.attestation.exclusion_hash,
909            "the exclusion digest must move when a duplicate appears"
910        );
911
912        let dirty_buffers = dirty
913            .encode_jsonl()
914            .expect("encode must succeed")
915            .as_map()
916            .clone();
917        let mut ledger = AccessLedger::new();
918        let reopened = PreparedDataset::<Canonical>::from_attested_bytes(
919            &bytes_of(&dirty_attestation),
920            &dirty_buffers,
921            &mut ledger,
922        )
923        .expect("the duplicated dataset attests to itself consistently");
924        assert_eq!(reopened.exclusions().excluded_train_ids(), ["train:0"]);
925    }
926}