Skip to main content

aprender_contrastive_data/
prepared.rs

1//! The attested, profile-parameterized dataset a consumer must present before canonical
2//! splits are exposed.
3//!
4//! The profile is a TYPE PARAMETER, not a runtime field: `PreparedDataset<Canonical>` and
5//! `PreparedDataset<Compatibility>` are distinct types with distinct constructors, and
6//! only the canonical one exposes a validation witness. Selection consumes
7//! `&PreparedDataset<Canonical>`, so a compatibility dataset cannot be passed at all —
8//! which is what makes DATA-06's "cannot be constructed" provable by `trybuild` rather
9//! than merely rejected at runtime.
10//!
11//! # `PreparedDataset<Compatibility>` has no `validation_witness` method
12//!
13//! A compatibility-profile selection run is not rejected at runtime. **It does not
14//! compile.** There is no value of type `PreparedDataset<Compatibility>` that can be
15//! passed where `&PreparedDataset<Canonical>` is expected, and there is no
16//! `validation_witness` to call on it — rustc reports "no method named", which is a
17//! non-compiling program rather than an error value a caller could ignore.
18//!
19//! The profile also selects which splits EXIST, through [`DatasetProfile::Splits`]. That
20//! is stronger than an optional field: a compatibility dataset does not merely leave its
21//! validation split empty, it has no place to put one (D-19).
22//!
23//! # This is where the typestate meets the hashes
24//!
25//! `hash.rs` is a leaf that knows nothing about split roles. The constructors here are the
26//! single point of assembly: they build one `SplitFingerprintInput` per split from that
27//! split's own accessors, hand them to `DatasetFingerprint::compute` in ascending role
28//! order, and build the witness's `SplitFingerprint` from the SAME per-split value that
29//! went into the dataset input. That shared value is what makes the two digests provably
30//! describe the same bytes under different domain tags.
31
32use core::marker::PhantomData;
33use std::collections::BTreeMap;
34
35use crate::dedup::{coalesced_exclusions, ExclusionRecord};
36use crate::error::ContrastiveDataError;
37use crate::hash::{
38    DatasetFingerprint, DatasetFingerprintInput, SplitFingerprint, SplitFingerprintInput,
39    CONTENT_NORMALIZATION_VERSION,
40};
41use crate::ledger::AccessLedger;
42use crate::schema::LabeledExample;
43use crate::split::{
44    CompatibilityTest, Split, SplitDeclaration, SplitRole, Test, Train, Validation,
45};
46
47/// A dataset profile. Implemented only by [`Canonical`] and [`Compatibility`].
48pub trait DatasetProfile {
49    /// The profile string recorded in fingerprints and in the access ledger.
50    const PROFILE: &'static str;
51    /// The splits this profile emits.
52    ///
53    /// This associated type is what makes D-19 structural: the compatibility profile does
54    /// not hold an EMPTY validation slot, it has no slot. An `Option<Split<Validation>>`
55    /// field would have left one, and would have forced an `expect` into every accessor
56    /// on an invariant only the constructor knows.
57    type Splits: core::fmt::Debug + Clone + PartialEq + Eq;
58}
59
60/// The canonical three-split profile: train, validation, test.
61#[derive(Debug, Clone, Copy)]
62pub struct Canonical;
63
64/// The merged SetFit compatibility profile: train and a compatibility test split, and NO
65/// validation split at all (D-19).
66#[derive(Debug, Clone, Copy)]
67pub struct Compatibility;
68
69/// The canonical profile's splits.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct CanonicalSplits {
72    train: Split<Train>,
73    validation: Split<Validation>,
74    test: Split<Test>,
75}
76
77/// The compatibility profile's splits. There is no validation field.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct CompatibilitySplits {
80    train: Split<Train>,
81    compatibility_test: Split<CompatibilityTest>,
82}
83
84impl DatasetProfile for Canonical {
85    const PROFILE: &'static str = "canonical";
86    type Splits = CanonicalSplits;
87}
88
89impl DatasetProfile for Compatibility {
90    const PROFILE: &'static str = "compatibility";
91    type Splits = CompatibilitySplits;
92}
93
94/// Per-split declarations for the canonical profile.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct CanonicalDeclarations {
97    /// Declaration for the training split.
98    pub train: SplitDeclaration,
99    /// Declaration for the validation split.
100    pub validation: SplitDeclaration,
101    /// Declaration for the test split.
102    pub test: SplitDeclaration,
103    /// The shared label map.
104    pub label_names: Vec<String>,
105}
106
107impl CanonicalDeclarations {
108    /// Refuse a declaration whose per-split label maps disagree with the shared one.
109    ///
110    /// Rows are validated against `train`/`validation`/`test`'s own `label_names`, but it
111    /// is `self.label_names` that reaches the dataset fingerprint, the stored map and
112    /// `SelectionPayload::label_names`. Without this check the two can disagree, every
113    /// row still validates, and `Selection::replay` still passes — because replay compares
114    /// the payload against the same divergent map. The manifest then commits a label map
115    /// that contradicts the rows it describes, silently.
116    ///
117    /// # Errors
118    ///
119    /// [`ContrastiveDataError::DeclaredLabelMapMismatch`] naming the first split that
120    /// diverges, in declaration order.
121    pub fn check_label_maps_agree(&self) -> Result<(), ContrastiveDataError> {
122        for (split, decl) in [
123            ("train", &self.train),
124            ("validation", &self.validation),
125            ("test", &self.test),
126        ] {
127            if decl.label_names != self.label_names {
128                return Err(ContrastiveDataError::DeclaredLabelMapMismatch {
129                    split: split.to_string(),
130                    shared: self.label_names.clone(),
131                    got: decl.label_names.clone(),
132                });
133            }
134        }
135        Ok(())
136    }
137}
138
139impl CompatibilityDeclarations {
140    /// Refuse a declaration whose per-split label maps disagree with the shared one.
141    ///
142    /// The compatibility profile's counterpart to
143    /// [`CanonicalDeclarations::check_label_maps_agree`], for the same reason.
144    ///
145    /// # Errors
146    ///
147    /// [`ContrastiveDataError::DeclaredLabelMapMismatch`] naming the first split that
148    /// diverges, in declaration order.
149    pub fn check_label_maps_agree(&self) -> Result<(), ContrastiveDataError> {
150        for (split, decl) in [
151            ("train", &self.train),
152            ("compatibility_test", &self.compatibility_test),
153        ] {
154            if decl.label_names != self.label_names {
155                return Err(ContrastiveDataError::DeclaredLabelMapMismatch {
156                    split: split.to_string(),
157                    shared: self.label_names.clone(),
158                    got: decl.label_names.clone(),
159                });
160            }
161        }
162        Ok(())
163    }
164}
165
166/// Per-split declarations for the compatibility profile.
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct CompatibilityDeclarations {
169    /// Declaration for the training split.
170    pub train: SplitDeclaration,
171    /// Declaration for the merged compatibility test split.
172    pub compatibility_test: SplitDeclaration,
173    /// The shared label map.
174    pub label_names: Vec<String>,
175}
176
177/// Canonical JSONL bytes for every split of a prepared dataset, keyed by role.
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct PreparedJsonl {
180    splits: BTreeMap<String, Vec<u8>>,
181}
182
183impl PreparedJsonl {
184    /// The bytes of one role, if the profile emits it.
185    pub fn get(&self, role: &str) -> Option<&[u8]> {
186        self.splits.get(role).map(Vec::as_slice)
187    }
188
189    /// Every role's bytes, in ascending role order.
190    pub fn as_map(&self) -> &BTreeMap<String, Vec<u8>> {
191        &self.splits
192    }
193}
194
195/// An opaque proof that a canonical validation split exists in THIS dataset.
196///
197/// Constructible only inside this module and obtainable only from
198/// `PreparedDataset<Canonical>`, so a witness cannot describe a dataset other than the one
199/// it was taken from.
200#[derive(Debug)]
201pub struct ValidationWitness<'a> {
202    validation: &'a Split<Validation>,
203    split_fingerprint: SplitFingerprint,
204    dataset_fingerprint: DatasetFingerprint,
205}
206
207impl ValidationWitness<'_> {
208    /// Fingerprint over the VALIDATION SPLIT ALONE.
209    ///
210    /// Deliberately NOT the dataset fingerprint. A selection payload records both a
211    /// dataset fingerprint and a validation fingerprint; if this returned the dataset's
212    /// own value the second field would be a duplicate of the first and the rejection
213    /// tests that distinguish them would collapse into one test.
214    pub fn fingerprint_hex(&self) -> String {
215        self.split_fingerprint.hex()
216    }
217
218    /// The whole dataset's fingerprint — what the access ledger records.
219    pub fn dataset_fingerprint_hex(&self) -> String {
220        self.dataset_fingerprint.hex()
221    }
222
223    /// The validation split this witness proves the existence of.
224    pub fn validation(&self) -> &Split<Validation> {
225        self.validation
226    }
227}
228
229/// A validated, fingerprinted dataset of exactly one profile.
230#[derive(Debug, Clone, PartialEq, Eq)]
231pub struct PreparedDataset<P: DatasetProfile> {
232    splits: P::Splits,
233    exclusions: ExclusionRecord,
234    fingerprint: DatasetFingerprint,
235    /// The declared label map, retained.
236    ///
237    /// Retained rather than reconstructed from row `label_text` values, because a class
238    /// whose split happens to contain no rows would simply vanish from a reconstruction —
239    /// and the selection payload contracts a LABEL MAP, not "the labels that happened to
240    /// appear". The map is already absorbed into `fingerprint`, so retaining it adds no
241    /// new identity, only access to one that was already committed to.
242    label_names: Vec<String>,
243    profile: PhantomData<P>,
244}
245
246impl PreparedDataset<Canonical> {
247    /// Ingest three typed split row sets under the canonical profile.
248    ///
249    /// The CLI has already decoded its dataset-specific source format into typed rows —
250    /// D-05 keeps paired `*_text.txt` / `*_labels.txt` decoding on the CLI side, so this
251    /// crate never sees a dataset-specific format and never touches a byte it was not
252    /// handed.
253    ///
254    /// Ordering matters twice, and both orders are ascending by role name because that is
255    /// what `DatasetFingerprint::compute` debug-asserts: the fingerprint inputs and the
256    /// dedup inputs. The ledger, by contrast, records in INGEST order (train, validation,
257    /// test), because a log of what happened should read in the order it happened.
258    ///
259    /// # Errors
260    ///
261    /// Any gate-ladder variant from the split boundary. Nothing is recorded in the ledger
262    /// on the failing path: a dataset that was rejected was never accessed.
263    #[provable_contracts_macros::contract(
264        "contrastive-pair-protocol-v1",
265        equation = "prepared_dataset_typestate"
266    )]
267    pub fn from_labeled_rows(
268        train: Vec<LabeledExample>,
269        validation: Vec<LabeledExample>,
270        test: Vec<LabeledExample>,
271        decls: &CanonicalDeclarations,
272        ledger: &mut AccessLedger,
273    ) -> Result<Self, ContrastiveDataError> {
274        decls.check_label_maps_agree()?;
275        let train = Split::<Train>::from_rows(train, &decls.train)?;
276        let validation = Split::<Validation>::from_rows(validation, &decls.validation)?;
277        let test = Split::<Test>::from_rows(test, &decls.test)?;
278        Ok(Self::from_validated_splits(
279            train,
280            validation,
281            test,
282            &decls.label_names,
283            ledger,
284        ))
285    }
286
287    /// Assemble from splits that have ALREADY passed the ingest ladder.
288    ///
289    /// The SINGLE assembly point. Both doors land here — `from_labeled_rows`, which
290    /// ingests typed rows, and `attestation`'s `from_attested_bytes`, which ingests
291    /// attested buffers through [`Split::from_jsonl_bytes`] — so the fingerprint, the
292    /// exclusion record and the ledger records cannot differ by which door a caller used.
293    /// Two assembly paths that agree today are two that will disagree eventually.
294    ///
295    /// Infallible: every rejection already happened in the ladder.
296    pub(crate) fn from_validated_splits(
297        train: Split<Train>,
298        validation: Split<Validation>,
299        test: Split<Test>,
300        label_names: &[String],
301        ledger: &mut AccessLedger,
302    ) -> Self {
303        let fingerprint = {
304            let train_pairs = train.exact_hash_pairs();
305            let validation_pairs = validation.exact_hash_pairs();
306            let test_pairs = test.exact_hash_pairs();
307            let splits = [
308                fingerprint_input::<Test>(&test, &test_pairs),
309                fingerprint_input::<Train>(&train, &train_pairs),
310                fingerprint_input::<Validation>(&validation, &validation_pairs),
311            ];
312            DatasetFingerprint::compute(&DatasetFingerprintInput {
313                profile: Canonical::PROFILE,
314                label_names,
315                normalization_version: CONTENT_NORMALIZATION_VERSION,
316                splits: &splits,
317            })
318        };
319
320        let exclusions = coalesced_exclusions(&[
321            (Test::ROLE, test.rows()),
322            (Train::ROLE, train.rows()),
323            (Validation::ROLE, validation.rows()),
324        ]);
325
326        let fingerprint_hex = fingerprint.hex();
327        for role in [Train::ROLE, Validation::ROLE, Test::ROLE] {
328            ledger.record(role, Canonical::PROFILE, "ingest", &fingerprint_hex);
329        }
330
331        Self {
332            splits: CanonicalSplits {
333                train,
334                validation,
335                test,
336            },
337            exclusions,
338            fingerprint,
339            label_names: label_names.to_vec(),
340            profile: PhantomData,
341        }
342    }
343
344    /// The declared label map, in label order.
345    pub fn label_names(&self) -> &[String] {
346        &self.label_names
347    }
348
349    /// The training split — the only selection pool.
350    pub fn train(&self) -> &Split<Train> {
351        &self.splits.train
352    }
353
354    /// The validation split.
355    pub fn validation(&self) -> &Split<Validation> {
356        &self.splits.validation
357    }
358
359    /// The held-out test split.
360    pub fn test(&self) -> &Split<Test> {
361        &self.splits.test
362    }
363
364    /// A proof that this dataset has a validation split.
365    ///
366    /// **This method exists only on the canonical type.** Its absence on
367    /// `PreparedDataset<Compatibility>` is DATA-06's compile-time gate:
368    ///
369    /// ```compile_fail
370    /// use aprender_contrastive_data::prepared::{Compatibility, PreparedDataset};
371    ///
372    /// fn take_witness(dataset: &PreparedDataset<Compatibility>) {
373    ///     let _ = dataset.validation_witness();
374    /// }
375    /// ```
376    ///
377    /// The same call on the canonical type compiles, which is what stops the block above
378    /// from being green for an unrelated reason:
379    ///
380    /// ```
381    /// use aprender_contrastive_data::prepared::{Canonical, PreparedDataset};
382    ///
383    /// fn take_witness(dataset: &PreparedDataset<Canonical>) {
384    ///     let _ = dataset.validation_witness();
385    /// }
386    /// ```
387    pub fn validation_witness(&self) -> ValidationWitness<'_> {
388        ValidationWitness {
389            validation: &self.splits.validation,
390            split_fingerprint: split_fingerprint_of::<Validation>(&self.splits.validation),
391            dataset_fingerprint: self.fingerprint.clone(),
392        }
393    }
394
395    /// What cross-split duplication removed from the training pool.
396    pub fn exclusions(&self) -> &ExclusionRecord {
397        &self.exclusions
398    }
399
400    /// This dataset's identity.
401    pub fn fingerprint(&self) -> &DatasetFingerprint {
402        &self.fingerprint
403    }
404
405    /// Canonical JSONL bytes per split.
406    ///
407    /// # Errors
408    ///
409    /// [`ContrastiveDataError::Serialization`] if a split cannot be re-encoded.
410    pub fn encode_jsonl(&self) -> Result<PreparedJsonl, ContrastiveDataError> {
411        let mut splits = BTreeMap::new();
412        splits.insert(
413            Train::ROLE.to_string(),
414            crate::schema::encode_jsonl(self.splits.train.rows())?,
415        );
416        splits.insert(
417            Validation::ROLE.to_string(),
418            crate::schema::encode_jsonl(self.splits.validation.rows())?,
419        );
420        splits.insert(
421            Test::ROLE.to_string(),
422            crate::schema::encode_jsonl(self.splits.test.rows())?,
423        );
424        Ok(PreparedJsonl { splits })
425    }
426}
427
428impl PreparedDataset<Compatibility> {
429    /// Ingest the compatibility profile's two split row sets.
430    ///
431    /// Selection cannot consume the result:
432    ///
433    /// ```compile_fail
434    /// use aprender_contrastive_data::prepared::{Canonical, Compatibility, PreparedDataset};
435    ///
436    /// fn selection(_dataset: &PreparedDataset<Canonical>) {}
437    ///
438    /// fn call(compatibility: &PreparedDataset<Compatibility>) {
439    ///     selection(compatibility);
440    /// }
441    /// ```
442    ///
443    /// The canonical control, which must compile:
444    ///
445    /// ```
446    /// use aprender_contrastive_data::prepared::{Canonical, PreparedDataset};
447    ///
448    /// fn selection(_dataset: &PreparedDataset<Canonical>) {}
449    ///
450    /// fn call(canonical: &PreparedDataset<Canonical>) {
451    ///     selection(canonical);
452    /// }
453    /// ```
454    ///
455    /// # Errors
456    ///
457    /// Any gate-ladder variant from the split boundary.
458    pub fn from_labeled_rows(
459        train: Vec<LabeledExample>,
460        compatibility_test: Vec<LabeledExample>,
461        decls: &CompatibilityDeclarations,
462        ledger: &mut AccessLedger,
463    ) -> Result<Self, ContrastiveDataError> {
464        decls.check_label_maps_agree()?;
465        let train = Split::<Train>::from_rows(train, &decls.train)?;
466        let compatibility_test =
467            Split::<CompatibilityTest>::from_rows(compatibility_test, &decls.compatibility_test)?;
468        Ok(Self::from_validated_splits(
469            train,
470            compatibility_test,
471            &decls.label_names,
472            ledger,
473        ))
474    }
475
476    /// Assemble from splits that have ALREADY passed the ingest ladder.
477    ///
478    /// The compatibility profile's single assembly point, for the same reason the
479    /// canonical one has exactly one.
480    pub(crate) fn from_validated_splits(
481        train: Split<Train>,
482        compatibility_test: Split<CompatibilityTest>,
483        label_names: &[String],
484        ledger: &mut AccessLedger,
485    ) -> Self {
486        let fingerprint = {
487            let train_pairs = train.exact_hash_pairs();
488            let compatibility_pairs = compatibility_test.exact_hash_pairs();
489            let splits = [
490                fingerprint_input::<CompatibilityTest>(&compatibility_test, &compatibility_pairs),
491                fingerprint_input::<Train>(&train, &train_pairs),
492            ];
493            DatasetFingerprint::compute(&DatasetFingerprintInput {
494                profile: Compatibility::PROFILE,
495                label_names,
496                normalization_version: CONTENT_NORMALIZATION_VERSION,
497                splits: &splits,
498            })
499        };
500
501        let exclusions = coalesced_exclusions(&[
502            (CompatibilityTest::ROLE, compatibility_test.rows()),
503            (Train::ROLE, train.rows()),
504        ]);
505
506        let fingerprint_hex = fingerprint.hex();
507        for role in [Train::ROLE, CompatibilityTest::ROLE] {
508            ledger.record(role, Compatibility::PROFILE, "ingest", &fingerprint_hex);
509        }
510
511        Self {
512            splits: CompatibilitySplits {
513                train,
514                compatibility_test,
515            },
516            exclusions,
517            fingerprint,
518            label_names: label_names.to_vec(),
519            profile: PhantomData,
520        }
521    }
522
523    /// The declared label map, in label order.
524    pub fn label_names(&self) -> &[String] {
525        &self.label_names
526    }
527
528    /// The training split.
529    pub fn train(&self) -> &Split<Train> {
530        &self.splits.train
531    }
532
533    /// The merged compatibility test split.
534    pub fn compatibility_test(&self) -> &Split<CompatibilityTest> {
535        &self.splits.compatibility_test
536    }
537
538    /// What cross-split duplication removed from the training pool.
539    pub fn exclusions(&self) -> &ExclusionRecord {
540        &self.exclusions
541    }
542
543    /// This dataset's identity.
544    pub fn fingerprint(&self) -> &DatasetFingerprint {
545        &self.fingerprint
546    }
547
548    /// Canonical JSONL bytes per split.
549    ///
550    /// # Errors
551    ///
552    /// [`ContrastiveDataError::Serialization`] if a split cannot be re-encoded.
553    pub fn encode_jsonl(&self) -> Result<PreparedJsonl, ContrastiveDataError> {
554        let mut splits = BTreeMap::new();
555        splits.insert(
556            Train::ROLE.to_string(),
557            crate::schema::encode_jsonl(self.splits.train.rows())?,
558        );
559        splits.insert(
560            CompatibilityTest::ROLE.to_string(),
561            crate::schema::encode_jsonl(self.splits.compatibility_test.rows())?,
562        );
563        Ok(PreparedJsonl { splits })
564    }
565}
566
567/// One split's raw parts, in the shape both fingerprints absorb.
568///
569/// `pairs` is passed in rather than built here so the caller controls its lifetime: the
570/// dataset fingerprint needs every split's pairs alive at once.
571fn fingerprint_input<'a, R: SplitRole>(
572    split: &'a Split<R>,
573    pairs: &'a [(&'a str, [u8; 32])],
574) -> SplitFingerprintInput<'a> {
575    SplitFingerprintInput {
576        role: R::ROLE,
577        source_hash: split.source_hash(),
578        class_counts: split.class_counts(),
579        rows: pairs,
580    }
581}
582
583/// The single-split fingerprint of one split, built from THE SAME raw parts that went into
584/// the dataset fingerprint — which is what makes the two digests provably describe the same
585/// bytes under different domain tags rather than merely look related.
586fn split_fingerprint_of<R: SplitRole>(split: &Split<R>) -> SplitFingerprint {
587    let pairs = split.exact_hash_pairs();
588    SplitFingerprint::compute(&fingerprint_input::<R>(split, &pairs))
589}
590
591#[cfg(test)]
592mod prepared_tests {
593    use super::{
594        Canonical, CanonicalDeclarations, Compatibility, CompatibilityDeclarations, PreparedDataset,
595    };
596    use crate::dedup::coalesced_exclusions;
597    use crate::error::ContrastiveDataError;
598    use crate::ledger::AccessLedger;
599    use crate::schema::{encode_jsonl, parse_jsonl_bytes, LabeledExample};
600    use crate::split::SplitDeclaration;
601
602    fn label_names() -> Vec<String> {
603        vec![
604            "none".to_string(),
605            "against".to_string(),
606            "favor".to_string(),
607        ]
608    }
609
610    fn row(id: &str, input: &str, label: usize, split: &str) -> LabeledExample {
611        LabeledExample {
612            id: id.to_string(),
613            input: input.to_string(),
614            label,
615            label_text: label_names()[label].clone(),
616            source_split: split.to_string(),
617        }
618    }
619
620    fn decl(counts: Vec<usize>) -> SplitDeclaration {
621        SplitDeclaration {
622            expected_class_counts: counts,
623            label_names: label_names(),
624        }
625    }
626
627    fn train_rows() -> Vec<LabeledExample> {
628        vec![
629            row("train:0", "alpha post", 0, "train"),
630            row("train:1", "beta post", 1, "train"),
631            row("train:2", "gamma post", 2, "train"),
632            row("train:3", "delta post", 0, "train"),
633        ]
634    }
635
636    fn validation_rows() -> Vec<LabeledExample> {
637        vec![
638            row("validation:0", "epsilon post", 0, "validation"),
639            row("validation:1", "zeta post", 1, "validation"),
640        ]
641    }
642
643    fn test_rows() -> Vec<LabeledExample> {
644        vec![
645            row("test:0", "eta post", 2, "test"),
646            row("test:1", "theta post", 1, "test"),
647        ]
648    }
649
650    fn canonical_decls() -> CanonicalDeclarations {
651        CanonicalDeclarations {
652            train: decl(vec![2, 1, 1]),
653            validation: decl(vec![1, 1, 0]),
654            test: decl(vec![0, 1, 1]),
655            label_names: label_names(),
656        }
657    }
658
659    fn build_canonical(
660        ledger: &mut AccessLedger,
661    ) -> Result<PreparedDataset<Canonical>, ContrastiveDataError> {
662        PreparedDataset::<Canonical>::from_labeled_rows(
663            train_rows(),
664            validation_rows(),
665            test_rows(),
666            &canonical_decls(),
667            ledger,
668        )
669    }
670
671    fn compatibility_rows() -> Vec<LabeledExample> {
672        vec![
673            row("compatibility_test:0", "eta post", 2, "compatibility_test"),
674            row(
675                "compatibility_test:1",
676                "theta post",
677                1,
678                "compatibility_test",
679            ),
680        ]
681    }
682
683    fn compatibility_decls() -> CompatibilityDeclarations {
684        CompatibilityDeclarations {
685            train: decl(vec![2, 1, 1]),
686            compatibility_test: decl(vec![0, 1, 1]),
687            label_names: label_names(),
688        }
689    }
690
691    #[test]
692    fn prepared_canonical_binds_three_splits_and_records_three_accesses() {
693        let mut ledger = AccessLedger::new();
694        let dataset = build_canonical(&mut ledger).expect("valid canonical corpus");
695
696        assert_eq!(dataset.train().rows().len(), 4);
697        assert_eq!(dataset.validation().rows().len(), 2);
698        assert_eq!(dataset.test().rows().len(), 2);
699        assert_eq!(ledger.records().len(), 3);
700        assert!(ledger
701            .records()
702            .iter()
703            .all(|record| record.profile == "canonical"));
704        let roles: Vec<&str> = ledger
705            .records()
706            .iter()
707            .map(|record| record.role.as_str())
708            .collect();
709        assert_eq!(roles, vec!["train", "validation", "test"]);
710        assert!(ledger
711            .records()
712            .iter()
713            .all(|record| record.fingerprint_hex == dataset.fingerprint().hex()));
714    }
715
716    #[test]
717    fn prepared_compatibility_binds_two_splits_and_records_two_accesses() {
718        let mut ledger = AccessLedger::new();
719        let dataset = PreparedDataset::<Compatibility>::from_labeled_rows(
720            train_rows(),
721            compatibility_rows(),
722            &compatibility_decls(),
723            &mut ledger,
724        )
725        .expect("valid compatibility corpus");
726
727        assert_eq!(dataset.train().rows().len(), 4);
728        assert_eq!(dataset.compatibility_test().rows().len(), 2);
729        assert_eq!(ledger.records().len(), 2);
730        assert!(ledger
731            .records()
732            .iter()
733            .all(|record| record.profile == "compatibility"));
734        let roles: Vec<&str> = ledger
735            .records()
736            .iter()
737            .map(|record| record.role.as_str())
738            .collect();
739        assert_eq!(roles, vec!["train", "compatibility_test"]);
740    }
741
742    #[test]
743    fn prepared_witness_describes_this_dataset_and_the_validation_split_separately() {
744        let mut ledger = AccessLedger::new();
745        let dataset = build_canonical(&mut ledger).expect("valid canonical corpus");
746        let witness = dataset.validation_witness();
747
748        assert_eq!(
749            witness.dataset_fingerprint_hex(),
750            dataset.fingerprint().hex()
751        );
752        assert_ne!(
753            witness.fingerprint_hex(),
754            witness.dataset_fingerprint_hex(),
755            "the validation fingerprint must not be a second copy of the dataset fingerprint"
756        );
757        assert_eq!(witness.validation().rows().len(), 2);
758    }
759
760    #[test]
761    fn prepared_the_two_construction_paths_fingerprint_identically() {
762        let mut direct_ledger = AccessLedger::new();
763        let direct = build_canonical(&mut direct_ledger).expect("direct path");
764
765        let reparse = |rows: Vec<LabeledExample>, role: &str| {
766            parse_jsonl_bytes(&encode_jsonl(&rows).expect("encode"), role).expect("parse")
767        };
768        let mut round_ledger = AccessLedger::new();
769        let round_trip = PreparedDataset::<Canonical>::from_labeled_rows(
770            reparse(train_rows(), "train"),
771            reparse(validation_rows(), "validation"),
772            reparse(test_rows(), "test"),
773            &canonical_decls(),
774            &mut round_ledger,
775        )
776        .expect("round-trip path");
777
778        assert_eq!(direct.fingerprint().hex(), round_trip.fingerprint().hex());
779        assert_eq!(direct.exclusions(), round_trip.exclusions());
780    }
781
782    #[test]
783    fn prepared_encode_jsonl_round_trips_into_an_equal_dataset() {
784        let mut ledger = AccessLedger::new();
785        let dataset = build_canonical(&mut ledger).expect("valid canonical corpus");
786        let encoded = dataset.encode_jsonl().expect("encode must succeed");
787
788        assert_eq!(encoded.as_map().len(), 3);
789        let take = |role: &str| {
790            parse_jsonl_bytes(encoded.get(role).expect("role is present"), role).expect("parse")
791        };
792        let mut replay_ledger = AccessLedger::new();
793        let replayed = PreparedDataset::<Canonical>::from_labeled_rows(
794            take("train"),
795            take("validation"),
796            take("test"),
797            &canonical_decls(),
798            &mut replay_ledger,
799        )
800        .expect("replay must succeed");
801
802        assert_eq!(replayed.fingerprint().hex(), dataset.fingerprint().hex());
803        assert_eq!(replayed.exclusions(), dataset.exclusions());
804    }
805
806    /// D-27, half one: prepare-time duplicate CONTENT is excluded and recorded, and the
807    /// construction SUCCEEDS.
808    #[test]
809    fn prepare_time_duplicate_content_is_excluded_not_fatal() {
810        let mut validation = validation_rows();
811        validation[0].input = "alpha post".to_string();
812
813        let mut ledger = AccessLedger::new();
814        let dataset = PreparedDataset::<Canonical>::from_labeled_rows(
815            train_rows(),
816            validation,
817            test_rows(),
818            &canonical_decls(),
819            &mut ledger,
820        )
821        .expect("a cross-split duplicate must NOT be fatal at prepare time");
822
823        assert_eq!(
824            dataset.exclusions().excluded_train_ids(),
825            ["train:0".to_string()]
826        );
827        assert_eq!(dataset.exclusions().groups().len(), 1);
828        assert_eq!(dataset.exclusions().reduced_pools().get(&0), Some(&1));
829    }
830
831    /// D-27, half two: actual split-role SPAN is a typed error.
832    #[test]
833    fn split_role_span_is_fail_closed() {
834        let mut ledger = AccessLedger::new();
835        let err = PreparedDataset::<Canonical>::from_labeled_rows(
836            train_rows(),
837            validation_rows(),
838            compatibility_rows(),
839            &canonical_decls(),
840            &mut ledger,
841        )
842        .expect_err("compatibility rows must not become a canonical test split");
843
844        match err {
845            ContrastiveDataError::SplitRoleMismatch {
846                expected_role,
847                embedded_role,
848            } => {
849                assert_eq!(expected_role, "test");
850                assert_eq!(embedded_role, "compatibility_test");
851            }
852            other => panic!("expected SplitRoleMismatch, got {other:?}"),
853        }
854        assert!(
855            ledger.records().is_empty(),
856            "a rejected dataset must leave no access record"
857        );
858    }
859
860    #[test]
861    fn prepared_runs_dedup_over_its_own_typed_splits() {
862        let mut validation = validation_rows();
863        validation[0].input = "alpha post".to_string();
864        let mut ledger = AccessLedger::new();
865        let dataset = PreparedDataset::<Canonical>::from_labeled_rows(
866            train_rows(),
867            validation.clone(),
868            test_rows(),
869            &canonical_decls(),
870            &mut ledger,
871        )
872        .expect("valid canonical corpus");
873
874        let expected = coalesced_exclusions(&[
875            ("test", &test_rows()),
876            ("train", &train_rows()),
877            ("validation", &validation),
878        ]);
879        assert_eq!(dataset.exclusions(), &expected);
880    }
881
882    #[test]
883    fn prepared_fingerprint_separates_the_two_profiles() {
884        let mut canonical_ledger = AccessLedger::new();
885        let canonical = build_canonical(&mut canonical_ledger).expect("canonical");
886        let mut compatibility_ledger = AccessLedger::new();
887        let compatibility = PreparedDataset::<Compatibility>::from_labeled_rows(
888            train_rows(),
889            compatibility_rows(),
890            &compatibility_decls(),
891            &mut compatibility_ledger,
892        )
893        .expect("compatibility");
894
895        assert_ne!(
896            canonical.fingerprint().hex(),
897            compatibility.fingerprint().hex()
898        );
899    }
900
901    #[test]
902    fn prepared_rejects_a_declaration_whose_counts_disagree() {
903        let mut decls = canonical_decls();
904        decls.validation = decl(vec![2, 0, 0]);
905        let mut ledger = AccessLedger::new();
906        let err = PreparedDataset::<Canonical>::from_labeled_rows(
907            train_rows(),
908            validation_rows(),
909            test_rows(),
910            &decls,
911            &mut ledger,
912        )
913        .expect_err("class-count contract must fail");
914        assert!(matches!(
915            err,
916            ContrastiveDataError::InvalidClassCounts { .. }
917        ));
918    }
919
920    /// A REVERSED shared map is the dangerous case, because nothing else is wrong:
921    /// every row validates against its own split map, the counts agree, and — before
922    /// this gate — `Selection::replay` PASSED, since replay compares the payload
923    /// against the same reversed map the fingerprint committed. The corruption is a
924    /// silent relabelling of every class downstream, with no red anywhere.
925    #[test]
926    fn prepared_refuses_a_shared_label_map_that_contradicts_the_split_maps() {
927        let mut decls = canonical_decls();
928        decls.label_names = vec![
929            "favor".to_string(),
930            "against".to_string(),
931            "none".to_string(),
932        ];
933        let mut ledger = AccessLedger::new();
934        let err = PreparedDataset::<Canonical>::from_labeled_rows(
935            train_rows(),
936            validation_rows(),
937            test_rows(),
938            &decls,
939            &mut ledger,
940        )
941        .expect_err("a shared map contradicting the split maps must be refused");
942        // Names the FIRST divergent split in declaration order, and carries both maps
943        // so the caller can see which one it got wrong.
944        match err {
945            ContrastiveDataError::DeclaredLabelMapMismatch { split, shared, got } => {
946                assert_eq!(split, "train");
947                assert_eq!(shared[0], "favor");
948                assert_eq!(got[0], "none");
949            }
950            other => panic!("expected DeclaredLabelMapMismatch, got {other:?}"),
951        }
952    }
953
954    /// The divergence is caught wherever it sits, not only on the first split, and the
955    /// error names the split that actually diverges.
956    #[test]
957    fn prepared_names_the_split_whose_label_map_diverges() {
958        let mut decls = canonical_decls();
959        decls.test = SplitDeclaration {
960            expected_class_counts: vec![1, 1, 1],
961            label_names: vec![
962                "none".to_string(),
963                "against".to_string(),
964                "FAVOUR".to_string(),
965            ],
966        };
967        let mut ledger = AccessLedger::new();
968        let err = PreparedDataset::<Canonical>::from_labeled_rows(
969            train_rows(),
970            validation_rows(),
971            test_rows(),
972            &decls,
973            &mut ledger,
974        )
975        .expect_err("a divergent test map must be refused");
976        match err {
977            ContrastiveDataError::DeclaredLabelMapMismatch { split, .. } => {
978                assert_eq!(split, "test");
979            }
980            other => panic!("expected DeclaredLabelMapMismatch, got {other:?}"),
981        }
982    }
983
984    /// A shorter shared map is the broken-round-trip variant: `ClassBuckets` sizes from
985    /// the train map while `check_class_balance` sizes from the payload map, so the
986    /// crate would emit a manifest it cannot replay.
987    #[test]
988    fn prepared_refuses_a_shared_label_map_of_a_different_length() {
989        let mut decls = canonical_decls();
990        decls.label_names = vec!["none".to_string(), "against".to_string()];
991        let mut ledger = AccessLedger::new();
992        let err = PreparedDataset::<Canonical>::from_labeled_rows(
993            train_rows(),
994            validation_rows(),
995            test_rows(),
996            &decls,
997            &mut ledger,
998        )
999        .expect_err("a shared map of the wrong arity must be refused");
1000        assert!(matches!(
1001            err,
1002            ContrastiveDataError::DeclaredLabelMapMismatch { .. }
1003        ));
1004    }
1005
1006    /// The compatibility door carries the same four-map hazard and the same gate.
1007    #[test]
1008    fn compatibility_refuses_a_shared_label_map_that_contradicts_the_split_maps() {
1009        let mut decls = compatibility_decls();
1010        decls.compatibility_test = SplitDeclaration {
1011            expected_class_counts: decls.compatibility_test.expected_class_counts.clone(),
1012            label_names: vec![
1013                "favor".to_string(),
1014                "against".to_string(),
1015                "none".to_string(),
1016            ],
1017        };
1018        let mut ledger = AccessLedger::new();
1019        let err = PreparedDataset::<Compatibility>::from_labeled_rows(
1020            train_rows(),
1021            compatibility_rows(),
1022            &decls,
1023            &mut ledger,
1024        )
1025        .expect_err("a divergent compatibility map must be refused");
1026        match err {
1027            ContrastiveDataError::DeclaredLabelMapMismatch { split, .. } => {
1028                assert_eq!(split, "compatibility_test");
1029            }
1030            other => panic!("expected DeclaredLabelMapMismatch, got {other:?}"),
1031        }
1032    }
1033
1034    /// THE CONTROL. The gate must not be satisfiable by refusing everything: the honest
1035    /// corpus, whose four maps agree, still builds.
1036    #[test]
1037    fn prepared_accepts_declarations_whose_label_maps_agree() {
1038        canonical_decls()
1039            .check_label_maps_agree()
1040            .expect("the honest corpus must pass");
1041        compatibility_decls()
1042            .check_label_maps_agree()
1043            .expect("the honest compatibility corpus must pass");
1044        let mut ledger = AccessLedger::new();
1045        build_canonical(&mut ledger).expect("the honest corpus must still build");
1046    }
1047}