1use 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
54pub const DATASET_ATTESTATION_SCHEMA_VERSION: u32 = 2;
56
57pub const SUPPORTED_DATASET_ATTESTATION_SCHEMA_VERSIONS: &[u32] = &[2];
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(deny_unknown_fields)]
74pub struct SplitAttestation {
75 pub sha256: String,
77 pub class_counts: Vec<u64>,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(deny_unknown_fields)]
88pub struct DatasetAttestation {
89 pub schema_version: u32,
91 pub profile: String,
93 pub label_names: Vec<String>,
95 pub normalization_version: String,
97 pub splits: BTreeMap<String, SplitAttestation>,
99 pub exclusion_hash: String,
101 pub dataset_fingerprint: String,
103}
104
105impl DatasetAttestation {
106 pub fn from_prepared<P: AttestedProfile>(dataset: &PreparedDataset<P>) -> Self {
108 P::attest(dataset)
109 }
110
111 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 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
140pub trait AttestedProfile: DatasetProfile + Sized {
142 const ROLES: &'static [&'static str];
145
146 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
195fn 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
203fn 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 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
244fn 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 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
289fn 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
310fn 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 #[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 #[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 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 #[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 #[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 #[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 #[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 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 #[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 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 #[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 #[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 #[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}