Skip to main content

aprender_contrastive_data/
select.rs

1//! Balanced few-shot selection and the ordered selected-ID manifest model.
2//!
3//! Partial Fisher-Yates over sorted per-class buckets, with the swap index for step *i*
4//! drawn at ordinal *i* in that class's domain. The output order IS the draw order, which
5//! is what makes the ordered manifest statable as a contract equation rather than as an
6//! implementation detail.
7//!
8//! # One dataset value in, one selection out
9//!
10//! [`FewShotSelector::select`] takes a single `&PreparedDataset<Canonical>`. That one
11//! argument supplies the training pool, the validation witness, the exclusion record and
12//! the fingerprint, so there is no signature into which a caller could feed a training
13//! split from one dataset and a validation split from another. Witness mixing is not
14//! rejected here; it is unrepresentable. A compatibility dataset is a different type and
15//! cannot be passed at all (D-19).
16//!
17//! # Every selected row carries its LABEL
18//!
19//! [`SelectedExample`] holds `(id, label, exact_hash, normalized_hash)` and [`SelectedId`]
20//! is an opaque ordinal into one [`Selection`]. Downstream pair construction therefore
21//! derives its targets from real per-row labels rather than from class-size totals, which
22//! would silently mislabel any layout the totals happen to be symmetric in.
23//!
24//! # Determinism, and what it does and does not survive
25//!
26//! The ordered selection is a pure function of `(post-exclusion sorted pools, root_seed,
27//! shots_per_class)`. It survives thread count, iteration order and permuted ingest order,
28//! because the buckets sort before any draw and draw *i* is a pure function of *i*.
29//!
30//! The `semantic_hash` deliberately does NOT survive permuted ingest order, and that is
31//! correct rather than a gap: the payload embeds `dataset_fingerprint`, which is partly a
32//! digest of the split's canonical JSONL bytes *in ingest order*. A permuted file is
33//! different bytes and therefore a different dataset for provenance purposes. The
34//! permutation test below asserts both halves — same selection, different fingerprint —
35//! so neither can regress unnoticed.
36
37use core::num::NonZeroU64;
38use std::collections::{BTreeMap, BTreeSet};
39
40use sha2::{Digest, Sha256};
41
42use crate::buckets::ClassBuckets;
43use crate::error::ContrastiveDataError;
44use crate::hash::{hex, CONTENT_NORMALIZATION_VERSION};
45use crate::ledger::AccessLedger;
46use crate::manifest::{
47    SelectedExampleRecord, SelectionManifest, SelectionPayload, SELECTION_SCHEMA_VERSION,
48    SUPPORTED_SELECTION_SCHEMA_VERSIONS,
49};
50use crate::prepared::{Canonical, DatasetProfile, PreparedDataset};
51use crate::rng::{bounded, derive_key, domains};
52use crate::split::{SplitRole, Train};
53
54/// The selection-algorithm version. A change here changes selected IDENTITIES, which is
55/// why it is versioned separately from the manifest schema.
56pub const SELECTION_ALGORITHM_VERSION: u32 = 1;
57
58/// The contracted shot counts.
59const ALLOWED_SHOTS: [u32; 4] = [8, 16, 32, 64];
60
61/// Rendered form of [`ALLOWED_SHOTS`] for the typed error.
62const ALLOWED_SHOTS_TEXT: &str = "{8, 16, 32, 64}";
63
64/// The access-ledger purpose recorded by a selection.
65const SELECT_PURPOSE: &str = "select";
66
67/// The access-ledger purpose recorded by a replay.
68const REPLAY_PURPOSE: &str = "select-replay";
69
70/// What a caller asks a selection for.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub struct SelectionConfig {
73    /// The root seed. Every draw key is derived from this plus a domain string.
74    pub root_seed: u64,
75    /// Shots per class — one of 8, 16, 32, 64.
76    pub shots_per_class: u32,
77}
78
79/// One selected row, with its LABEL and both content hashes.
80#[derive(Clone, Debug, PartialEq, Eq)]
81pub struct SelectedExample {
82    /// The row identifier.
83    pub id: String,
84    /// The row's class label.
85    pub label: usize,
86    /// Exact SHA-256 of the raw input bytes.
87    pub exact_hash: [u8; 32],
88    /// `nfc-trim-ws-v1` normalized content hash.
89    pub normalized_hash: [u8; 32],
90}
91
92/// An opaque ordinal into ONE [`Selection`].
93///
94/// The field is private and the constructor is private to this module, so a value of this
95/// type can only have come from a `Selection`'s own accessors. That is what makes pair
96/// endpoints structurally non-leaky downstream: there is no way to name a row that was
97/// never selected.
98///
99/// An ordinal taken from one selection and applied to another is a programming error, not
100/// a data error; the accessors panic with a named message rather than returning a
101/// plausible row from the wrong selection.
102#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
103pub struct SelectedId(u32);
104
105impl SelectedId {
106    /// The zero-based position of this row in its selection's ordered list.
107    ///
108    /// Exposed so pair bytes can be encoded and hashed by ORDINAL rather than by
109    /// variable-length identifier — the pair manifest already commits the selection's
110    /// semantic hash, so the ordinals are unambiguous inside it.
111    ///
112    /// This is an accessor, not a constructor: reading the number cannot mint a
113    /// `SelectedId`, so the type remains proof of membership in the selection that
114    /// produced it.
115    pub fn ordinal(self) -> u32 {
116        self.0
117    }
118}
119
120/// A completed few-shot selection.
121#[derive(Debug, Clone)]
122pub struct Selection {
123    ordered: Vec<SelectedExample>,
124    by_id: BTreeMap<String, SelectedId>,
125    by_class: BTreeMap<usize, Vec<SelectedId>>,
126    class_sizes: Vec<(usize, u64)>,
127    payload: SelectionPayload,
128    semantic_hash: [u8; 32],
129    ledger_hash: [u8; 32],
130}
131
132/// Balanced few-shot selection over one canonical prepared dataset.
133#[derive(Debug, Clone, Copy)]
134pub struct FewShotSelector;
135
136impl FewShotSelector {
137    /// Select `shots_per_class` examples from every declared class.
138    ///
139    /// Fail-closed first: an invalid shot count and an exhausted class pool are both
140    /// rejected BEFORE any draw, so an invalid request never consumes an ordinal and never
141    /// leaves a partially built selection behind.
142    ///
143    /// Then, per class in ASCENDING label order, a partial Fisher-Yates over that class's
144    /// sorted post-exclusion pool: for step `i`, swap index
145    /// `j = i + bounded(key, 0, i, pool_len - i)` where `key = derive_key(root_seed,
146    /// "select/{label}")`. The first `shots_per_class` slots IN DRAW ORDER are that class's
147    /// ordered selection, and the classes concatenated in ascending label order ARE the
148    /// manifest order DATA-03 contracts.
149    ///
150    /// Each row's two hashes are copied from the split that already computed them — never
151    /// re-derived here, so there is exactly one source of truth for a row's identity.
152    ///
153    /// # Errors
154    ///
155    /// [`ContrastiveDataError::InvalidShots`] for a shot count outside `{8, 16, 32, 64}`;
156    /// [`ContrastiveDataError::CrossSplitDuplicateUnderflow`] when a class's post-exclusion
157    /// pool cannot supply the requested shots (D-27's pool-exhaustion half);
158    /// [`ContrastiveDataError::Serialization`] if the canonical payload cannot be built.
159    #[provable_contracts_macros::contract(
160        "contrastive-pair-protocol-v1",
161        equation = "few_shot_selection"
162    )]
163    pub fn select(
164        dataset: &PreparedDataset<Canonical>,
165        cfg: &SelectionConfig,
166        ledger: &mut AccessLedger,
167    ) -> Result<Selection, ContrastiveDataError> {
168        let ordered = compute_ordered(dataset, cfg.root_seed, cfg.shots_per_class)?;
169
170        let dataset_fingerprint = dataset.fingerprint().hex();
171        ledger.record(
172            Train::ROLE,
173            Canonical::PROFILE,
174            SELECT_PURPOSE,
175            &dataset_fingerprint,
176        );
177        let ledger_hash = ledger.ledger_hash();
178
179        let payload = SelectionPayload {
180            schema_version: SELECTION_SCHEMA_VERSION,
181            algorithm_version: SELECTION_ALGORITHM_VERSION,
182            profile: Canonical::PROFILE.to_string(),
183            dataset_fingerprint,
184            validation_fingerprint: dataset.validation_witness().fingerprint_hex(),
185            label_names: dataset.label_names().to_vec(),
186            normalization_version: CONTENT_NORMALIZATION_VERSION.to_string(),
187            root_seed: cfg.root_seed,
188            shots_per_class: cfg.shots_per_class,
189            ordered_examples: ordered
190                .iter()
191                .map(SelectedExampleRecord::from_example)
192                .collect(),
193            exclusions: dataset.exclusions().clone(),
194            access_ledger: ledger.records().to_vec(),
195            ledger_hash: hex(&ledger_hash),
196        };
197
198        Selection::assemble(ordered, payload, ledger_hash)
199    }
200}
201
202/// The pure selection: no ledger, no payload, no hashing.
203///
204/// Split out because `Selection::replay` must recompute exactly this and compare, and it
205/// cannot do so through `select` — `select` appends to the ledger, and a replay that
206/// appended twice would produce a payload that disagrees with the manifest it is
207/// validating.
208pub(crate) fn compute_ordered(
209    dataset: &PreparedDataset<Canonical>,
210    root_seed: u64,
211    shots_per_class: u32,
212) -> Result<Vec<SelectedExample>, ContrastiveDataError> {
213    if !ALLOWED_SHOTS.contains(&shots_per_class) {
214        return Err(ContrastiveDataError::InvalidShots {
215            got: shots_per_class as usize,
216            allowed: ALLOWED_SHOTS_TEXT,
217        });
218    }
219    let shots = shots_per_class as usize;
220
221    let buckets = ClassBuckets::from_prepared(dataset);
222    for (label, pool) in buckets.class_sizes() {
223        if (pool as usize) < shots {
224            return Err(ContrastiveDataError::CrossSplitDuplicateUnderflow {
225                class_label: label,
226                pool: pool as usize,
227                shots,
228            });
229        }
230    }
231
232    let train = dataset.train();
233    let labels = buckets.labels();
234    let mut ordered = Vec::with_capacity(shots.saturating_mul(labels.len()));
235
236    for label in labels {
237        let key = derive_key(root_seed, &domains::select(label));
238        let mut work: Vec<&str> = buckets.ids(label).iter().map(String::as_str).collect();
239        let pool_len = work.len();
240
241        for i in 0..shots {
242            // Unreachable given the fail-closed check above (`pool_len >= shots > i`), but
243            // typed rather than asserted so the invariant cannot be broken silently by a
244            // future edit to the pre-check.
245            let remaining = NonZeroU64::new((pool_len - i) as u64).ok_or_else(|| {
246                ContrastiveDataError::ArithmeticOverflow {
247                    operation: format!("selection_remaining_pool/class-{label}"),
248                }
249            })?;
250            let j = i + bounded(&key, 0, i as u64, remaining) as usize;
251            work.swap(i, j);
252        }
253
254        for id in work.into_iter().take(shots) {
255            // Also unreachable: the bucket was built from this very split's rows.
256            let missing = || ContrastiveDataError::SelectionReplayMismatch {
257                field: format!("row_hashes/{id}"),
258            };
259            let exact_hash = *train.exact_hash_of(id).ok_or_else(missing)?;
260            let normalized_hash = *train.normalized_hash_of(id).ok_or_else(missing)?;
261            ordered.push(SelectedExample {
262                id: id.to_string(),
263                label,
264                exact_hash,
265                normalized_hash,
266            });
267        }
268    }
269
270    Ok(ordered)
271}
272
273impl Selection {
274    /// Build the value from an ordered list and the payload it is attested by.
275    pub(crate) fn assemble(
276        ordered: Vec<SelectedExample>,
277        payload: SelectionPayload,
278        ledger_hash: [u8; 32],
279    ) -> Result<Self, ContrastiveDataError> {
280        let semantic_hash: [u8; 32] = Sha256::digest(payload.to_canonical_bytes()?).into();
281
282        let mut by_id = BTreeMap::new();
283        let mut by_class: BTreeMap<usize, Vec<SelectedId>> = BTreeMap::new();
284        for (ordinal, example) in ordered.iter().enumerate() {
285            let selected = SelectedId(ordinal as u32);
286            by_id.insert(example.id.clone(), selected);
287            by_class.entry(example.label).or_default().push(selected);
288        }
289        let class_sizes = by_class
290            .iter()
291            .map(|(label, ids)| (*label, ids.len() as u64))
292            .collect();
293
294        Ok(Self {
295            ordered,
296            by_id,
297            by_class,
298            class_sizes,
299            payload,
300            semantic_hash,
301            ledger_hash,
302        })
303    }
304
305    /// The ordered selected examples: classes ascending, draw order within a class.
306    pub fn examples(&self) -> &[SelectedExample] {
307        &self.ordered
308    }
309
310    /// The ordered selected ids — the list DATA-03 contracts and Phase 5 replays.
311    pub fn ordered_ids(&self) -> Vec<&str> {
312        self.ordered.iter().map(|row| row.id.as_str()).collect()
313    }
314
315    /// How many rows were selected in total.
316    pub fn len(&self) -> usize {
317        self.ordered.len()
318    }
319
320    /// Whether the selection is empty. Never true for a selection this crate produced —
321    /// `shots_per_class` is at least 8 — but required beside `len`.
322    pub fn is_empty(&self) -> bool {
323        self.ordered.is_empty()
324    }
325
326    /// `SHA-256` of the canonical payload bytes.
327    pub fn semantic_hash(&self) -> [u8; 32] {
328        self.semantic_hash
329    }
330
331    /// The access-ledger hash as of the moment this selection was built — including the
332    /// selection's own record.
333    pub fn ledger_hash(&self) -> [u8; 32] {
334        self.ledger_hash
335    }
336
337    /// The payload this selection is attested by. Retained, never rebuilt.
338    pub fn payload(&self) -> &SelectionPayload {
339        &self.payload
340    }
341
342    /// Hex fingerprint of the whole dataset the selection came from.
343    pub fn dataset_fingerprint_hex(&self) -> &str {
344        &self.payload.dataset_fingerprint
345    }
346
347    /// Hex fingerprint of the validation split alone.
348    pub fn validation_fingerprint_hex(&self) -> &str {
349        &self.payload.validation_fingerprint
350    }
351
352    /// The root seed this selection was drawn from.
353    pub fn root_seed(&self) -> u64 {
354        self.payload.root_seed
355    }
356
357    /// Shots per class.
358    pub fn shots_per_class(&self) -> u32 {
359        self.payload.shots_per_class
360    }
361
362    /// SELECTED rows per class, ascending by label. Every entry equals
363    /// `shots_per_class`; the vector exists so a consumer can read the class set without
364    /// walking the ordered list.
365    pub fn class_sizes(&self) -> &[(usize, u64)] {
366        &self.class_sizes
367    }
368
369    /// The ordinal of a selected id, or `None` when the id was not selected.
370    ///
371    /// This is the ONLY way to obtain a [`SelectedId`], which is what makes the type a
372    /// proof of membership.
373    pub fn selected_id(&self, id: &str) -> Option<SelectedId> {
374        self.by_id.get(id).copied()
375    }
376
377    /// The id behind an ordinal.
378    ///
379    /// # Panics
380    ///
381    /// If `selected` came from a DIFFERENT selection and is out of range here.
382    pub fn id_of(&self, selected: SelectedId) -> &str {
383        self.example_of(selected).id.as_str()
384    }
385
386    /// The class label behind an ordinal.
387    ///
388    /// # Panics
389    ///
390    /// If `selected` came from a DIFFERENT selection and is out of range here.
391    pub fn label_of(&self, selected: SelectedId) -> usize {
392        self.example_of(selected).label
393    }
394
395    /// The full row behind an ordinal.
396    ///
397    /// # Panics
398    ///
399    /// If `selected` came from a DIFFERENT selection and is out of range here. A
400    /// `SelectedId` is proof of membership in the selection that produced it, not in every
401    /// selection, and returning a plausible row from the wrong one would be worse than a
402    /// named panic.
403    pub fn example_of(&self, selected: SelectedId) -> &SelectedExample {
404        self.ordered
405            .get(selected.0 as usize)
406            .expect("SelectedId ordinals are produced only by the Selection they index")
407    }
408
409    /// Every selected ordinal of one class, ascending. An unknown label yields an empty
410    /// slice.
411    pub fn ids_in_class(&self, label: usize) -> &[SelectedId] {
412        self.by_class.get(&label).map_or(&[], Vec::as_slice)
413    }
414
415    /// STRICT replay: the sole sanctioned path from manifest bytes back to a `Selection`.
416    ///
417    /// Consumes a CANONICAL prepared dataset. A compatibility dataset is not rejected here
418    /// — it cannot be passed, because it is a different type (D-19).
419    ///
420    /// The ladder, in order, each step with its own typed error:
421    ///
422    /// 1. supported `schema_version`, then `algorithm_version` equal to this build's
423    ///    ([`ContrastiveDataError::UnsupportedSchemaVersion`] /
424    ///    [`ContrastiveDataError::UnsupportedAlgorithmVersion`]). A different algorithm
425    ///    version is refused, never silently skipped past the recomputation below;
426    /// 2. profile, dataset fingerprint, validation fingerprint, exclusion record;
427    /// 3. membership of every id in the post-exclusion train pool, then id uniqueness,
428    ///    then per-class balance, then class-ascending ordering, then both per-row hashes;
429    /// 4. `semantic_hash`;
430    /// 5. a full deterministic RECOMPUTATION of the ordered list.
431    ///
432    /// # Why step 4 hashes the manifest's OWN payload bytes
433    ///
434    /// The payload embeds the access ledger and its hash as they stood when `select`
435    /// finished. Replay appends its own record, so by the time this check runs the LIVE
436    /// ledger has diverged from the recorded one by construction. A rule that rebuilt the
437    /// payload from the live ledger could therefore never pass — it would reject every
438    /// honest manifest and take the CLI's replay path down with it. The digest is
439    /// recomputed over `manifest.payload.to_canonical_bytes()` and nothing else.
440    ///
441    /// # Why step 5 exists at all
442    ///
443    /// Steps 1-4 accept any manifest that is internally consistent. A hand-edited manifest
444    /// with recomputed hashes IS internally consistent. Step 5 is what makes the artifact
445    /// evidence: a selection no seed could have produced is refused even when every digest
446    /// in it agrees.
447    ///
448    /// # Errors
449    ///
450    /// The variant named by whichever step disagrees first; see the ladder above.
451    #[provable_contracts_macros::contract(
452        "contrastive-pair-protocol-v1",
453        equation = "selection_replay"
454    )]
455    pub fn replay(
456        manifest: &SelectionManifest,
457        dataset: &PreparedDataset<Canonical>,
458        ledger: &mut AccessLedger,
459    ) -> Result<Self, ContrastiveDataError> {
460        let payload = &manifest.payload;
461        check_versions(payload)?;
462        check_provenance(payload, dataset)?;
463
464        let buckets = ClassBuckets::from_prepared(dataset);
465        check_membership(payload, dataset, &buckets)?;
466        check_uniqueness(payload)?;
467        check_class_balance(payload)?;
468        check_class_ordering(payload)?;
469        let recorded = rebuild_examples(payload, dataset)?;
470
471        manifest.verify_digest()?;
472
473        let recomputed = compute_ordered(dataset, payload.root_seed, payload.shots_per_class)?;
474        if recomputed != recorded {
475            return Err(ContrastiveDataError::SelectionReplayMismatch {
476                field: "ordered_examples".to_string(),
477            });
478        }
479
480        let ledger_hash = digest_from_hex(&payload.ledger_hash).ok_or_else(|| {
481            ContrastiveDataError::SelectionReplayMismatch {
482                field: "ledger_hash".to_string(),
483            }
484        })?;
485        // The payload carries BOTH the persisted records and their digest, and nothing
486        // above compares the two. Without this, a manifest whose `ledger_hash` does not
487        // describe its own `access_ledger` replays clean and hands the unearned digest to
488        // the returned `Selection` — where `SelectionManifest::from_selection` then treats
489        // it as the ledger this selection was actually taken under.
490        let mut persisted = AccessLedger::new();
491        for record in &payload.access_ledger {
492            persisted.record(
493                &record.role,
494                &record.profile,
495                &record.purpose,
496                &record.fingerprint_hex,
497            );
498        }
499        if persisted.ledger_hash() != ledger_hash {
500            return Err(ContrastiveDataError::SelectionReplayMismatch {
501                field: "access_ledger".to_string(),
502            });
503        }
504
505        ledger.record(
506            Train::ROLE,
507            Canonical::PROFILE,
508            REPLAY_PURPOSE,
509            &payload.dataset_fingerprint,
510        );
511        Self::assemble(recorded, payload.clone(), ledger_hash)
512    }
513}
514
515/// Parse a 64-character LOWERCASE hex digest.
516///
517/// Uppercase is refused rather than accepted case-insensitively: every producer in this
518/// crate renders through [`hex`], which is lowercase, so an uppercase digest cannot have
519/// come from an honest writer. Accepting it would admit a manifest whose bytes differ from
520/// the canonical form while its parsed value looks identical.
521fn digest_from_hex(text: &str) -> Option<[u8; 32]> {
522    let bytes = text.as_bytes();
523    if bytes.len() != 64 {
524        return None;
525    }
526    let nibble = |byte: u8| -> Option<u32> {
527        if byte.is_ascii_uppercase() {
528            return None;
529        }
530        char::from(byte).to_digit(16)
531    };
532    let mut digest = [0_u8; 32];
533    for (index, slot) in digest.iter_mut().enumerate() {
534        let high = nibble(bytes[index * 2])?;
535        let low = nibble(bytes[index * 2 + 1])?;
536        *slot = (high * 16 + low) as u8;
537    }
538    Some(digest)
539}
540
541/// Step 1: versions.
542fn check_versions(payload: &SelectionPayload) -> Result<(), ContrastiveDataError> {
543    if !SUPPORTED_SELECTION_SCHEMA_VERSIONS.contains(&payload.schema_version) {
544        return Err(ContrastiveDataError::UnsupportedSchemaVersion {
545            field: "selection".to_string(),
546            got: payload.schema_version,
547            supported: SELECTION_SCHEMA_VERSION,
548        });
549    }
550    if payload.algorithm_version != SELECTION_ALGORITHM_VERSION {
551        return Err(ContrastiveDataError::UnsupportedAlgorithmVersion {
552            got: payload.algorithm_version,
553            supported: SELECTION_ALGORITHM_VERSION,
554        });
555    }
556    Ok(())
557}
558
559/// Step 2: profile, both fingerprints, exclusion record.
560fn check_provenance(
561    payload: &SelectionPayload,
562    dataset: &PreparedDataset<Canonical>,
563) -> Result<(), ContrastiveDataError> {
564    if payload.profile != Canonical::PROFILE {
565        return Err(ContrastiveDataError::ProfileMismatch {
566            expected: Canonical::PROFILE.to_string(),
567            got: payload.profile.clone(),
568        });
569    }
570    let dataset_fingerprint = dataset.fingerprint().hex();
571    if payload.dataset_fingerprint != dataset_fingerprint {
572        return Err(ContrastiveDataError::FingerprintMismatch {
573            expected: payload.dataset_fingerprint.clone(),
574            got: dataset_fingerprint,
575        });
576    }
577    let validation_fingerprint = dataset.validation_witness().fingerprint_hex();
578    if payload.validation_fingerprint != validation_fingerprint {
579        return Err(ContrastiveDataError::FingerprintMismatch {
580            expected: payload.validation_fingerprint.clone(),
581            got: validation_fingerprint,
582        });
583    }
584    // The label MAP and the normalization tag are payload fields in their own right: the
585    // dataset fingerprint is computed from the DATASET, so matching it says nothing about
586    // what this payload wrote down. Without these two comparisons a manifest could carry
587    // renamed classes — or claim a normalization version this build does not implement —
588    // and still survive the full recomputation below, because neither value reaches the
589    // draw. Every downstream consumer reads its class names from the payload, so a
590    // silently wrong map is a mislabeled benchmark rather than a cosmetic defect.
591    if payload.label_names.as_slice() != dataset.label_names() {
592        return Err(ContrastiveDataError::SelectionReplayMismatch {
593            field: "label_names".to_string(),
594        });
595    }
596    if payload.normalization_version != CONTENT_NORMALIZATION_VERSION {
597        return Err(ContrastiveDataError::UnsupportedNormalizationVersion {
598            got: payload.normalization_version.clone(),
599            supported: CONTENT_NORMALIZATION_VERSION,
600        });
601    }
602    let recorded = hex(&payload.exclusions.hash());
603    let actual = hex(&dataset.exclusions().hash());
604    if recorded != actual {
605        return Err(ContrastiveDataError::ExclusionRecordMismatch {
606            expected: recorded,
607            got: actual,
608        });
609    }
610    Ok(())
611}
612
613/// Where an id lives, when it is not in the post-exclusion training pool.
614fn locate(dataset: &PreparedDataset<Canonical>, id: &str) -> &'static str {
615    if dataset.train().exact_hash_of(id).is_some() {
616        return "train, but excluded from the selection pool";
617    }
618    if dataset.validation().exact_hash_of(id).is_some() {
619        return "validation";
620    }
621    if dataset.test().exact_hash_of(id).is_some() {
622        return "test";
623    }
624    "nowhere"
625}
626
627/// Step 3a: every recorded id is in the post-exclusion training pool.
628fn check_membership(
629    payload: &SelectionPayload,
630    dataset: &PreparedDataset<Canonical>,
631    buckets: &ClassBuckets,
632) -> Result<(), ContrastiveDataError> {
633    let pool: BTreeSet<&str> = buckets
634        .labels()
635        .into_iter()
636        .flat_map(|label| buckets.ids(label).iter().map(String::as_str))
637        .collect();
638    for record in &payload.ordered_examples {
639        if !pool.contains(record.id.as_str()) {
640            return Err(ContrastiveDataError::EndpointNotInSelection {
641                id: record.id.clone(),
642                found_in: locate(dataset, &record.id).to_string(),
643            });
644        }
645    }
646    Ok(())
647}
648
649/// Step 3b: no repeated id.
650fn check_uniqueness(payload: &SelectionPayload) -> Result<(), ContrastiveDataError> {
651    let mut seen: BTreeSet<&str> = BTreeSet::new();
652    for record in &payload.ordered_examples {
653        if !seen.insert(record.id.as_str()) {
654            return Err(ContrastiveDataError::DuplicateId {
655                split: "selection".to_string(),
656                id: record.id.clone(),
657            });
658        }
659    }
660    Ok(())
661}
662
663/// Step 3c: exactly `shots_per_class` rows of every declared class.
664fn check_class_balance(payload: &SelectionPayload) -> Result<(), ContrastiveDataError> {
665    let mut counts: BTreeMap<usize, usize> = BTreeMap::new();
666    for record in &payload.ordered_examples {
667        *counts.entry(record.label).or_default() += 1;
668    }
669    let classes = payload.label_names.len();
670    let got: Vec<usize> = (0..classes)
671        .map(|label| counts.get(&label).copied().unwrap_or(0))
672        .collect();
673    let expected = vec![payload.shots_per_class as usize; classes];
674    if got != expected || counts.len() != classes {
675        return Err(ContrastiveDataError::InvalidClassCounts {
676            split: "selection".to_string(),
677            expected,
678            got,
679        });
680    }
681    Ok(())
682}
683
684/// Step 3d: labels never decrease, i.e. the classes are concatenated ascending.
685fn check_class_ordering(payload: &SelectionPayload) -> Result<(), ContrastiveDataError> {
686    let out_of_order = payload
687        .ordered_examples
688        .windows(2)
689        .any(|pair| pair[1].label < pair[0].label);
690    if out_of_order {
691        return Err(ContrastiveDataError::SelectionReplayMismatch {
692            field: "class_order".to_string(),
693        });
694    }
695    Ok(())
696}
697
698/// Step 3e: both recorded hashes equal the split's own, and the typed list is rebuilt.
699fn rebuild_examples(
700    payload: &SelectionPayload,
701    dataset: &PreparedDataset<Canonical>,
702) -> Result<Vec<SelectedExample>, ContrastiveDataError> {
703    let train = dataset.train();
704    let mut rebuilt = Vec::with_capacity(payload.ordered_examples.len());
705    for record in &payload.ordered_examples {
706        let exact_hash = compare_row_hash(
707            &record.id,
708            &record.exact_hash,
709            train.exact_hash_of(&record.id),
710        )?;
711        let normalized_hash = compare_row_hash(
712            &record.id,
713            &record.normalized_hash,
714            train.normalized_hash_of(&record.id),
715        )?;
716        rebuilt.push(SelectedExample {
717            id: record.id.clone(),
718            label: record.label,
719            exact_hash,
720            normalized_hash,
721        });
722    }
723    Ok(rebuilt)
724}
725
726/// One recorded hex digest against the split's own value.
727fn compare_row_hash(
728    id: &str,
729    recorded_hex: &str,
730    actual: Option<&[u8; 32]>,
731) -> Result<[u8; 32], ContrastiveDataError> {
732    // `actual` is always `Some` here: membership was checked before this step runs.
733    let actual = actual
734        .copied()
735        .ok_or_else(|| ContrastiveDataError::RowHashMismatch {
736            id: id.to_string(),
737            expected: recorded_hex.to_string(),
738            got: "row absent from the training split".to_string(),
739        })?;
740    if hex(&actual) == recorded_hex {
741        return Ok(actual);
742    }
743    Err(ContrastiveDataError::RowHashMismatch {
744        id: id.to_string(),
745        expected: recorded_hex.to_string(),
746        got: hex(&actual),
747    })
748}
749
750#[cfg(test)]
751pub(crate) mod test_corpus {
752    //! A synthetic three-class corpus shared by every test in this crate that needs a
753    //! prepared dataset.
754    //!
755    //! Training rows are emitted INTERLEAVED by class and with ids that descend within a
756    //! class, so ingest order is neither grouped nor sorted. A bucketing implementation
757    //! that merely preserved ingest order would produce different selections, and the
758    //! sorting assertions would otherwise be vacuous.
759
760    use crate::ledger::AccessLedger;
761    use crate::prepared::{Canonical, CanonicalDeclarations, PreparedDataset};
762    use crate::schema::LabeledExample;
763    use crate::select::{FewShotSelector, Selection, SelectionConfig};
764    use crate::split::SplitDeclaration;
765
766    pub(crate) const LABEL_TEXTS: [&str; 3] = ["none", "against", "favor"];
767
768    /// The ten contracted benchmark seeds.
769    ///
770    /// Cited verbatim from `BENCHMARK_SEEDS` in
771    /// `crates/apr-cli/src/commands/data_tweeteval.rs`. Note that **42 is not among them**.
772    ///
773    /// Cited by SYMBOL, not by line: the original citation pinned a line number that was
774    /// already wrong when written and has drifted twice since. A reference that rots
775    /// silently is worse than a slightly less precise one.
776    pub(crate) const CONTRACTED_SEEDS: [u64; 10] = [13, 17, 23, 29, 31, 37, 41, 43, 47, 53];
777
778    pub(crate) fn label_names() -> Vec<String> {
779        LABEL_TEXTS.iter().map(|name| (*name).to_string()).collect()
780    }
781
782    fn row(id: String, input: String, label: usize, split: &str) -> LabeledExample {
783        LabeledExample {
784            id,
785            input,
786            label,
787            label_text: LABEL_TEXTS[label].to_string(),
788            source_split: split.to_string(),
789        }
790    }
791
792    fn train_row(label: usize, index: usize) -> LabeledExample {
793        row(
794            format!("train:{label}-{index:03}"),
795            format!("training post for class {label} item {index}"),
796            label,
797            "train",
798        )
799    }
800
801    /// `(train, validation, test)` rows for a corpus with `per_class` training rows in
802    /// each of the three classes.
803    pub(crate) fn rows(
804        per_class: usize,
805    ) -> (
806        Vec<LabeledExample>,
807        Vec<LabeledExample>,
808        Vec<LabeledExample>,
809    ) {
810        let mut train = Vec::with_capacity(per_class * 3);
811        for step in 0..per_class {
812            let index = per_class - 1 - step;
813            for label in 0..3 {
814                train.push(train_row(label, index));
815            }
816        }
817        let validation = (0..3)
818            .map(|label| {
819                row(
820                    format!("validation:{label}"),
821                    format!("validation post for class {label}"),
822                    label,
823                    "validation",
824                )
825            })
826            .collect();
827        let test = (0..3)
828            .map(|label| {
829                row(
830                    format!("test:{label}"),
831                    format!("held-out post for class {label}"),
832                    label,
833                    "test",
834                )
835            })
836            .collect();
837        (train, validation, test)
838    }
839
840    pub(crate) fn declarations(train_counts: Vec<usize>) -> CanonicalDeclarations {
841        let decl = |counts: Vec<usize>| SplitDeclaration {
842            expected_class_counts: counts,
843            label_names: label_names(),
844        };
845        CanonicalDeclarations {
846            train: decl(train_counts),
847            validation: decl(vec![1, 1, 1]),
848            test: decl(vec![1, 1, 1]),
849            label_names: label_names(),
850        }
851    }
852
853    pub(crate) fn build(
854        train: Vec<LabeledExample>,
855        validation: Vec<LabeledExample>,
856        test: Vec<LabeledExample>,
857        decls: &CanonicalDeclarations,
858        ledger: &mut AccessLedger,
859    ) -> PreparedDataset<Canonical> {
860        PreparedDataset::<Canonical>::from_labeled_rows(train, validation, test, decls, ledger)
861            .expect("the synthetic corpus must be valid")
862    }
863
864    /// The standard corpus: `per_class` rows per class, no duplicates, no exclusions.
865    pub(crate) fn dataset(
866        per_class: usize,
867        ledger: &mut AccessLedger,
868    ) -> PreparedDataset<Canonical> {
869        let (train, validation, test) = rows(per_class);
870        build(
871            train,
872            validation,
873            test,
874            &declarations(vec![per_class; 3]),
875            ledger,
876        )
877    }
878
879    /// The same corpus with the validation class-0 row duplicating a training class-0
880    /// row's content, so exactly one training id is excluded.
881    pub(crate) fn dataset_with_cross_split_duplicate(
882        per_class: usize,
883        ledger: &mut AccessLedger,
884    ) -> PreparedDataset<Canonical> {
885        let (train, mut validation, test) = rows(per_class);
886        validation[0].input = train_row(0, 0).input;
887        build(
888            train,
889            validation,
890            test,
891            &declarations(vec![per_class; 3]),
892            ledger,
893        )
894    }
895
896    /// The same corpus with class 2 declared but absent from the training split.
897    pub(crate) fn dataset_with_empty_class(
898        per_class: usize,
899        ledger: &mut AccessLedger,
900    ) -> PreparedDataset<Canonical> {
901        let (train, validation, test) = rows(per_class);
902        let train = train.into_iter().filter(|row| row.label != 2).collect();
903        build(
904            train,
905            validation,
906            test,
907            &declarations(vec![per_class, per_class, 0]),
908            ledger,
909        )
910    }
911
912    pub(crate) fn select(
913        dataset: &PreparedDataset<Canonical>,
914        root_seed: u64,
915        shots_per_class: u32,
916        ledger: &mut AccessLedger,
917    ) -> Selection {
918        FewShotSelector::select(
919            dataset,
920            &SelectionConfig {
921                root_seed,
922                shots_per_class,
923            },
924            ledger,
925        )
926        .expect("the synthetic corpus must support this selection")
927    }
928
929    /// A fresh dataset AND a fresh ledger, so two selections are comparable: a ledger that
930    /// already carried a previous selection's record would produce a different payload.
931    pub(crate) fn fresh_selection(
932        per_class: usize,
933        root_seed: u64,
934        shots_per_class: u32,
935    ) -> (Selection, AccessLedger) {
936        let mut ledger = AccessLedger::new();
937        let prepared = dataset(per_class, &mut ledger);
938        let selection = select(&prepared, root_seed, shots_per_class, &mut ledger);
939        (selection, ledger)
940    }
941}
942
943#[cfg(test)]
944mod select_tests {
945    use super::test_corpus::{self, CONTRACTED_SEEDS};
946    use super::{FewShotSelector, SelectionConfig};
947    use crate::error::ContrastiveDataError;
948    use crate::ledger::AccessLedger;
949    use crate::prepared::PreparedDataset;
950    use proptest::prelude::{prop_assert_eq, proptest};
951
952    fn ordered(selection: &super::Selection) -> Vec<(String, usize)> {
953        selection
954            .examples()
955            .iter()
956            .map(|row| (row.id.clone(), row.label))
957            .collect()
958    }
959
960    #[test]
961    fn select_returns_exactly_shots_per_class_with_labels_and_hashes() {
962        let mut ledger = AccessLedger::new();
963        let dataset = test_corpus::dataset(12, &mut ledger);
964        let selection = test_corpus::select(&dataset, 13, 8, &mut ledger);
965
966        assert_eq!(selection.len(), 24);
967        assert!(!selection.is_empty());
968        assert_eq!(selection.class_sizes(), [(0, 8), (1, 8), (2, 8)]);
969
970        let ids = selection.ordered_ids();
971        let mut unique = ids.clone();
972        unique.sort_unstable();
973        unique.dedup();
974        assert_eq!(unique.len(), 24, "every selected id must be distinct");
975
976        for row in selection.examples() {
977            assert!(
978                row.id.starts_with(&format!("train:{}-", row.label)),
979                "row {row:?} must come from its own class's training pool"
980            );
981            assert_eq!(
982                Some(&row.exact_hash),
983                dataset.train().exact_hash_of(&row.id),
984                "hashes are COPIED from the split, never re-derived"
985            );
986            assert_eq!(
987                Some(&row.normalized_hash),
988                dataset.train().normalized_hash_of(&row.id)
989            );
990        }
991    }
992
993    #[test]
994    fn select_replays_identically_across_two_calls() {
995        let (first, _) = test_corpus::fresh_selection(12, 13, 8);
996        let (second, _) = test_corpus::fresh_selection(12, 13, 8);
997        assert_eq!(ordered(&first), ordered(&second));
998        assert_eq!(first.semantic_hash(), second.semantic_hash());
999    }
1000
1001    /// DATA-03's headline evidence, stated directly rather than only through the goldens:
1002    /// each of the TEN contracted seeds replays exactly.
1003    #[test]
1004    fn select_replays_identically_for_every_contracted_seed() {
1005        assert_eq!(CONTRACTED_SEEDS.len(), 10);
1006        assert!(
1007            !CONTRACTED_SEEDS.contains(&42),
1008            "42 is not a contracted seed — see data_tweeteval.rs:45"
1009        );
1010        let mut seen = Vec::new();
1011        for seed in CONTRACTED_SEEDS {
1012            let (first, _) = test_corpus::fresh_selection(12, seed, 8);
1013            let (second, _) = test_corpus::fresh_selection(12, seed, 8);
1014            assert_eq!(ordered(&first), ordered(&second), "seed {seed}");
1015            assert_eq!(first.semantic_hash(), second.semantic_hash(), "seed {seed}");
1016            seen.push(first.ordered_ids().join(","));
1017        }
1018        let mut distinct = seen.clone();
1019        distinct.sort_unstable();
1020        distinct.dedup();
1021        assert_eq!(
1022            distinct.len(),
1023            seen.len(),
1024            "different seeds must produce different orderings"
1025        );
1026    }
1027
1028    #[test]
1029    fn select_supports_all_four_contracted_shot_counts() {
1030        for shots in [8_u32, 16, 32, 64] {
1031            let (selection, _) = test_corpus::fresh_selection(64, 13, shots);
1032            assert_eq!(selection.len() as u32, shots * 3, "shots {shots}");
1033            assert!(selection
1034                .class_sizes()
1035                .iter()
1036                .all(|(_, size)| *size == u64::from(shots)));
1037        }
1038    }
1039
1040    /// The ordered SELECTION survives a permuted ingest order; the dataset fingerprint,
1041    /// and therefore the semantic hash, deliberately does NOT. Both halves are asserted so
1042    /// neither can regress unnoticed.
1043    #[test]
1044    fn select_is_invariant_under_permuted_ingest_order() {
1045        let (train, validation, test) = test_corpus::rows(12);
1046        let decls = test_corpus::declarations(vec![12; 3]);
1047
1048        let mut ledger_a = AccessLedger::new();
1049        let straight = test_corpus::build(
1050            train.clone(),
1051            validation.clone(),
1052            test.clone(),
1053            &decls,
1054            &mut ledger_a,
1055        );
1056        let selection_a = test_corpus::select(&straight, 29, 8, &mut ledger_a);
1057
1058        let mut reversed = train;
1059        reversed.reverse();
1060        let mut ledger_b = AccessLedger::new();
1061        let permuted = test_corpus::build(reversed, validation, test, &decls, &mut ledger_b);
1062        let selection_b = test_corpus::select(&permuted, 29, 8, &mut ledger_b);
1063
1064        assert_eq!(
1065            ordered(&selection_a),
1066            ordered(&selection_b),
1067            "buckets sort before any draw, so ingest order cannot reach the selection"
1068        );
1069        assert_ne!(
1070            straight.fingerprint().hex(),
1071            permuted.fingerprint().hex(),
1072            "a permuted file IS different bytes; provenance must say so"
1073        );
1074        assert_ne!(selection_a.semantic_hash(), selection_b.semantic_hash());
1075    }
1076
1077    #[test]
1078    fn select_rejects_an_invalid_shot_count_before_any_draw() {
1079        let mut ledger = AccessLedger::new();
1080        let dataset = test_corpus::dataset(12, &mut ledger);
1081        let before = ledger.records().len();
1082
1083        for shots in [0_u32, 1, 7, 9, 63, 65, 128] {
1084            let err = FewShotSelector::select(
1085                &dataset,
1086                &SelectionConfig {
1087                    root_seed: 13,
1088                    shots_per_class: shots,
1089                },
1090                &mut ledger,
1091            )
1092            .expect_err("an uncontracted shot count must be refused");
1093            match err {
1094                ContrastiveDataError::InvalidShots { got, allowed } => {
1095                    assert_eq!(got, shots as usize);
1096                    assert_eq!(allowed, "{8, 16, 32, 64}");
1097                }
1098                other => panic!("expected InvalidShots, got {other:?}"),
1099            }
1100        }
1101        assert_eq!(
1102            ledger.records().len(),
1103            before,
1104            "a refused request must not touch the ledger"
1105        );
1106    }
1107
1108    /// D-27's pool-exhaustion half.
1109    #[test]
1110    fn select_rejects_a_class_pool_smaller_than_shots() {
1111        let mut ledger = AccessLedger::new();
1112        let dataset = test_corpus::dataset_with_empty_class(8, &mut ledger);
1113        let err = FewShotSelector::select(
1114            &dataset,
1115            &SelectionConfig {
1116                root_seed: 13,
1117                shots_per_class: 8,
1118            },
1119            &mut ledger,
1120        )
1121        .expect_err("an exhausted class pool must be refused");
1122        match err {
1123            ContrastiveDataError::CrossSplitDuplicateUnderflow {
1124                class_label,
1125                pool,
1126                shots,
1127            } => {
1128                assert_eq!((class_label, pool, shots), (2, 0, 8));
1129                let message = ContrastiveDataError::CrossSplitDuplicateUnderflow {
1130                    class_label,
1131                    pool,
1132                    shots,
1133                }
1134                .to_string();
1135                assert!(message.contains("class 2"), "{message}");
1136                assert!(message.contains("0 rows remain"), "{message}");
1137                assert!(message.contains("8 shots"), "{message}");
1138            }
1139            other => panic!("expected CrossSplitDuplicateUnderflow, got {other:?}"),
1140        }
1141    }
1142
1143    #[test]
1144    fn select_excludes_cross_split_duplicates_from_the_pool() {
1145        let mut ledger = AccessLedger::new();
1146        let dataset = test_corpus::dataset_with_cross_split_duplicate(12, &mut ledger);
1147        let excluded = dataset.exclusions().excluded_train_ids().to_vec();
1148        assert_eq!(
1149            excluded.len(),
1150            1,
1151            "the fixture must exclude exactly one row"
1152        );
1153
1154        let selection = test_corpus::select(&dataset, 13, 8, &mut ledger);
1155        for id in &excluded {
1156            assert!(
1157                selection.selected_id(id).is_none(),
1158                "excluded id {id:?} must be unselectable"
1159            );
1160        }
1161    }
1162
1163    #[test]
1164    fn select_selected_ids_round_trip_and_labels_agree() {
1165        let mut ledger = AccessLedger::new();
1166        let dataset = test_corpus::dataset(12, &mut ledger);
1167        let selection = test_corpus::select(&dataset, 37, 8, &mut ledger);
1168
1169        for row in selection.examples() {
1170            let selected = selection
1171                .selected_id(&row.id)
1172                .expect("every selected row resolves to an ordinal");
1173            assert_eq!(selection.id_of(selected), row.id);
1174            assert_eq!(selection.label_of(selected), row.label);
1175            assert_eq!(selection.example_of(selected), row);
1176        }
1177        assert!(selection.selected_id("train:0-999").is_none());
1178        assert!(selection.selected_id("validation:0").is_none());
1179    }
1180
1181    #[test]
1182    fn select_ids_in_class_concatenate_to_the_full_ordered_list() {
1183        let mut ledger = AccessLedger::new();
1184        let dataset = test_corpus::dataset(12, &mut ledger);
1185        let selection = test_corpus::select(&dataset, 41, 8, &mut ledger);
1186
1187        let mut rebuilt = Vec::new();
1188        for (label, _) in selection.class_sizes() {
1189            let ordinals = selection.ids_in_class(*label);
1190            assert!(
1191                ordinals.windows(2).all(|pair| pair[0] < pair[1]),
1192                "class {label} ordinals must ascend"
1193            );
1194            for selected in ordinals {
1195                assert_eq!(selection.label_of(*selected), *label);
1196                rebuilt.push(selection.id_of(*selected).to_string());
1197            }
1198        }
1199        let expected: Vec<String> = selection
1200            .ordered_ids()
1201            .into_iter()
1202            .map(str::to_string)
1203            .collect();
1204        assert_eq!(rebuilt, expected);
1205        assert!(selection.ids_in_class(99).is_empty());
1206    }
1207
1208    /// D-19's evidence trail: a selection can only be produced from a dataset that HAS a
1209    /// validation witness, and the ledger records the profile it ran under.
1210    #[test]
1211    fn select_appends_one_access_record_naming_the_selection() {
1212        let mut ledger = AccessLedger::new();
1213        let dataset = test_corpus::dataset(12, &mut ledger);
1214        let ingest_records = ledger.records().len();
1215        let selection = test_corpus::select(&dataset, 43, 8, &mut ledger);
1216
1217        assert_eq!(ledger.records().len(), ingest_records + 1);
1218        let record = ledger.records().last().expect("a record was just appended");
1219        assert!(record.purpose.contains("select"));
1220        assert_eq!(record.role, "train", "selection reads ONLY the train pool");
1221        assert_eq!(record.profile, "canonical");
1222        assert_eq!(record.fingerprint_hex, dataset.fingerprint().hex());
1223        assert_eq!(
1224            record.fingerprint_hex,
1225            dataset.validation_witness().dataset_fingerprint_hex(),
1226            "reachable only from a dataset that has a validation witness (D-19)"
1227        );
1228        assert_eq!(selection.dataset_fingerprint_hex(), record.fingerprint_hex);
1229    }
1230
1231    #[test]
1232    fn select_ledger_hash_matches_the_live_ledger_immediately_after() {
1233        let mut ledger = AccessLedger::new();
1234        let dataset = test_corpus::dataset(12, &mut ledger);
1235        let selection = test_corpus::select(&dataset, 47, 8, &mut ledger);
1236        assert_eq!(selection.ledger_hash(), ledger.ledger_hash());
1237
1238        ledger.record("train", "canonical", "unrelated", "aa");
1239        assert_ne!(
1240            selection.ledger_hash(),
1241            ledger.ledger_hash(),
1242            "the retained hash describes the ledger AS OF selection, not the live one"
1243        );
1244    }
1245
1246    /// Typed by construction rather than asserted: `select` takes exactly one dataset
1247    /// value, so there is no argument list into which a foreign validation split could be
1248    /// substituted.
1249    #[test]
1250    fn select_takes_exactly_one_dataset_value() {
1251        fn signature_check(
1252            dataset: &PreparedDataset<crate::prepared::Canonical>,
1253            cfg: &SelectionConfig,
1254            ledger: &mut AccessLedger,
1255        ) -> Result<super::Selection, ContrastiveDataError> {
1256            FewShotSelector::select(dataset, cfg, ledger)
1257        }
1258        let mut ledger = AccessLedger::new();
1259        let dataset = test_corpus::dataset(12, &mut ledger);
1260        let selection = signature_check(
1261            &dataset,
1262            &SelectionConfig {
1263                root_seed: 53,
1264                shots_per_class: 8,
1265            },
1266            &mut ledger,
1267        )
1268        .expect("selection succeeds");
1269        assert_eq!(selection.len(), 24);
1270    }
1271
1272    /// `SelectedId::ordinal` IS the position in the ordered list (plan 02-08 triage).
1273    ///
1274    /// `cargo mutants` found `SelectedId::ordinal -> 0` and `-> 1` both surviving. The
1275    /// ordinal is what the pair wire format encodes and what `SelfPair` names, so a
1276    /// constant ordinal would make every pair a self-pair on paper while the identifiers
1277    /// stayed correct — and nothing asserted the accessor.
1278    #[test]
1279    fn selected_id_ordinals_are_the_positions_in_the_ordered_list() {
1280        let mut ledger = AccessLedger::new();
1281        let dataset = test_corpus::dataset(12, &mut ledger);
1282        let selection = test_corpus::select(&dataset, 13, 8, &mut ledger);
1283        assert_eq!(
1284            selection.len(),
1285            24,
1286            "pin the population before relating over it"
1287        );
1288
1289        let mut seen = std::collections::BTreeSet::new();
1290        for (index, row) in selection.examples().iter().enumerate() {
1291            let selected = selection
1292                .selected_id(&row.id)
1293                .expect("every selected example resolves to an ordinal");
1294            assert_eq!(
1295                selected.ordinal() as usize,
1296                index,
1297                "the ordinal of {} must be its position",
1298                row.id
1299            );
1300            assert_eq!(selection.id_of(selected), row.id, "and it must round-trip");
1301            assert!(seen.insert(selected.ordinal()), "ordinals must be distinct");
1302        }
1303        assert_eq!(seen.len(), 24);
1304    }
1305
1306    /// The validation fingerprint is the witness digest, not a placeholder (02-08 triage).
1307    ///
1308    /// `cargo mutants` found `validation_fingerprint_hex -> ""` and `-> "xyzzy"` surviving.
1309    /// That field is what a replay compares to prove the manifest describes a dataset with
1310    /// THIS validation split (D-19); an empty string would have compared equal to another
1311    /// empty string and the isolation evidence would have been two placeholders agreeing.
1312    #[test]
1313    fn the_validation_fingerprint_is_the_witness_digest_and_differs_from_the_dataset_one() {
1314        let mut ledger = AccessLedger::new();
1315        let dataset = test_corpus::dataset(12, &mut ledger);
1316        let selection = test_corpus::select(&dataset, 13, 8, &mut ledger);
1317
1318        let expected = dataset.validation_witness().fingerprint_hex();
1319        assert_eq!(selection.validation_fingerprint_hex(), expected);
1320        assert_eq!(expected.len(), 64, "SHA-256 rendered as lowercase hex");
1321        assert!(
1322            expected
1323                .chars()
1324                .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)),
1325            "not lowercase hex: {expected}"
1326        );
1327        // Different domain tags over the same bytes, so a swap of the two fields is
1328        // detectable rather than invisible.
1329        assert_ne!(
1330            selection.validation_fingerprint_hex(),
1331            selection.dataset_fingerprint_hex()
1332        );
1333    }
1334
1335    proptest! {
1336        /// Determinism over the seed space, not only over the ten contracted seeds.
1337        #[test]
1338        fn select_is_deterministic_for_any_seed(seed in 0_u64..u64::MAX) {
1339            let (first, _) = test_corpus::fresh_selection(12, seed, 8);
1340            let (second, _) = test_corpus::fresh_selection(12, seed, 8);
1341            prop_assert_eq!(first.ordered_ids(), second.ordered_ids());
1342            prop_assert_eq!(first.semantic_hash(), second.semantic_hash());
1343        }
1344    }
1345}
1346
1347#[cfg(test)]
1348mod manifest_replay_tests {
1349    //! Strict-replay evidence: one round trip, one pre-populated-ledger case, and the
1350    //! TWELVE distinct rejections of the validation ladder.
1351    //!
1352    //! Twelve rather than eleven because `dataset_fingerprint` and `validation_fingerprint`
1353    //! hold different values — the whole-dataset digest and the validation split's own —
1354    //! so tampering with each is a genuinely different test rather than the same one twice.
1355    //!
1356    //! Every negative constructs a `SelectionManifest` VALUE directly rather than parsing
1357    //! one, because `from_bytes` verifies the digest and would reject most of these before
1358    //! `replay` ever ran. Replay must defend itself against a manifest that is already in
1359    //! memory.
1360
1361    use super::test_corpus;
1362    use super::{Selection, SelectionConfig};
1363    use crate::error::ContrastiveDataError;
1364    use crate::ledger::AccessLedger;
1365    use crate::manifest::{SelectedExampleRecord, SelectionManifest};
1366    use crate::prepared::{Canonical, PreparedDataset};
1367
1368    struct Fixture {
1369        dataset: PreparedDataset<Canonical>,
1370        ledger: AccessLedger,
1371        manifest: SelectionManifest,
1372        selection: Selection,
1373    }
1374
1375    fn fixture() -> Fixture {
1376        let mut ledger = AccessLedger::new();
1377        let dataset = test_corpus::dataset(12, &mut ledger);
1378        let selection = test_corpus::select(&dataset, 31, 8, &mut ledger);
1379        let manifest =
1380            SelectionManifest::from_selection(&selection, &ledger).expect("the wrap succeeds");
1381        Fixture {
1382            dataset,
1383            ledger,
1384            manifest,
1385            selection,
1386        }
1387    }
1388
1389    /// Recompute the envelope digest so a tampered payload is INTERNALLY CONSISTENT.
1390    fn reseal(manifest: &mut SelectionManifest) {
1391        use sha2::{Digest, Sha256};
1392        let digest: [u8; 32] = Sha256::digest(
1393            manifest
1394                .payload
1395                .to_canonical_bytes()
1396                .expect("payload serializes"),
1397        )
1398        .into();
1399        manifest.semantic_hash = crate::hash::hex(&digest);
1400    }
1401
1402    fn reject(
1403        mutate: impl FnOnce(&mut SelectionManifest, &PreparedDataset<Canonical>),
1404    ) -> ContrastiveDataError {
1405        let mut fixture = fixture();
1406        mutate(&mut fixture.manifest, &fixture.dataset);
1407        Selection::replay(&fixture.manifest, &fixture.dataset, &mut fixture.ledger)
1408            .expect_err("the tampered manifest must be refused")
1409    }
1410
1411    #[test]
1412    fn manifest_replay_round_trips_through_the_file_form() {
1413        let mut fixture = fixture();
1414        let bytes = fixture.manifest.to_file_bytes().expect("file bytes");
1415        let parsed = SelectionManifest::from_bytes(&bytes).expect("the digest verifies");
1416
1417        let replayed = Selection::replay(&parsed, &fixture.dataset, &mut fixture.ledger)
1418            .expect("an honest manifest replays");
1419
1420        assert_eq!(replayed.examples(), fixture.selection.examples());
1421        assert_eq!(replayed.semantic_hash(), fixture.selection.semantic_hash());
1422        assert_eq!(replayed.ledger_hash(), fixture.selection.ledger_hash());
1423        assert_eq!(replayed.ordered_ids(), fixture.selection.ordered_ids());
1424    }
1425
1426    /// Checker warning 2, stated as a test: replay appends its OWN record, so the live
1427    /// ledger has already diverged from the recorded one. A digest rule that rebuilt the
1428    /// payload from the live ledger could never pass this.
1429    #[test]
1430    fn manifest_replay_succeeds_against_a_ledger_that_has_already_moved_on() {
1431        let mut fixture = fixture();
1432        fixture
1433            .ledger
1434            .record("train", "canonical", "unrelated-later-work", "aa");
1435        assert_ne!(
1436            crate::hash::hex(&fixture.ledger.ledger_hash()),
1437            fixture.manifest.payload.ledger_hash,
1438            "the fixture must actually have diverged, or this test is vacuous"
1439        );
1440        let before = fixture.ledger.records().len();
1441
1442        let replayed = Selection::replay(&fixture.manifest, &fixture.dataset, &mut fixture.ledger)
1443            .expect("replay must not depend on the live ledger");
1444
1445        assert_eq!(replayed.ordered_ids(), fixture.selection.ordered_ids());
1446        assert_eq!(fixture.ledger.records().len(), before + 1);
1447        assert_eq!(
1448            fixture
1449                .ledger
1450                .records()
1451                .last()
1452                .expect("a record was appended")
1453                .purpose,
1454            "select-replay"
1455        );
1456    }
1457
1458    #[test]
1459    fn manifest_replay_rejects_a_compatibility_profile() {
1460        let err = reject(|manifest, _| manifest.payload.profile = "compatibility".to_string());
1461        match err {
1462            ContrastiveDataError::ProfileMismatch { expected, got } => {
1463                assert_eq!(expected, "canonical");
1464                assert_eq!(got, "compatibility");
1465            }
1466            other => panic!("expected ProfileMismatch, got {other:?}"),
1467        }
1468    }
1469
1470    #[test]
1471    fn manifest_replay_rejects_an_altered_dataset_fingerprint() {
1472        let err = reject(|manifest, _| manifest.payload.dataset_fingerprint = "ab".repeat(32));
1473        match err {
1474            ContrastiveDataError::FingerprintMismatch { expected, got } => {
1475                assert_eq!(expected, "ab".repeat(32));
1476                assert_ne!(got, expected);
1477            }
1478            other => panic!("expected FingerprintMismatch, got {other:?}"),
1479        }
1480    }
1481
1482    #[test]
1483    fn manifest_replay_rejects_an_altered_validation_fingerprint() {
1484        let err = reject(|manifest, dataset| {
1485            // Pin that the two fields really are different values, so this test cannot be
1486            // an accidental duplicate of the one above.
1487            assert_ne!(
1488                manifest.payload.dataset_fingerprint,
1489                manifest.payload.validation_fingerprint
1490            );
1491            assert_eq!(
1492                manifest.payload.dataset_fingerprint,
1493                dataset.fingerprint().hex()
1494            );
1495            manifest.payload.validation_fingerprint = "cd".repeat(32);
1496        });
1497        match err {
1498            ContrastiveDataError::FingerprintMismatch { expected, got } => {
1499                assert_eq!(expected, "cd".repeat(32));
1500                assert_ne!(got, expected);
1501            }
1502            other => panic!("expected FingerprintMismatch, got {other:?}"),
1503        }
1504    }
1505
1506    /// A renamed CLASS survives every fingerprint, so only a direct comparison catches it.
1507    ///
1508    /// The dataset fingerprint is computed from the DATASET, not from the payload, and the
1509    /// numeric labels the recomputation compares are unchanged — so before the
1510    /// `label_names` rung existed this manifest replayed clean and every downstream reader
1511    /// of `Selection::payload().label_names` got the wrong class names.
1512    #[test]
1513    fn manifest_replay_rejects_a_renamed_label() {
1514        let err = reject(|manifest, dataset| {
1515            assert_eq!(
1516                manifest.payload.label_names,
1517                dataset.label_names().to_vec(),
1518                "the fixture must start in agreement, or this test proves nothing"
1519            );
1520            manifest.payload.label_names[1] = "opposed".to_string();
1521            reseal(manifest);
1522        });
1523        match err {
1524            ContrastiveDataError::SelectionReplayMismatch { field } => {
1525                assert_eq!(field, "label_names");
1526            }
1527            other => panic!("expected SelectionReplayMismatch on label_names, got {other:?}"),
1528        }
1529    }
1530
1531    #[test]
1532    fn manifest_replay_rejects_an_unsupported_normalization_version() {
1533        let err = reject(|manifest, _| {
1534            manifest.payload.normalization_version = "nfc-trim-ws-v2".to_string();
1535            reseal(manifest);
1536        });
1537        match err {
1538            ContrastiveDataError::UnsupportedNormalizationVersion { got, supported } => {
1539                assert_eq!(got, "nfc-trim-ws-v2");
1540                assert_eq!(supported, crate::hash::CONTENT_NORMALIZATION_VERSION);
1541            }
1542            other => panic!("expected UnsupportedNormalizationVersion, got {other:?}"),
1543        }
1544    }
1545
1546    /// The payload carries the persisted records AND their digest; they must agree.
1547    #[test]
1548    fn manifest_replay_rejects_a_ledger_hash_that_does_not_describe_its_own_records() {
1549        let err = reject(|manifest, _| {
1550            manifest
1551                .payload
1552                .access_ledger
1553                .push(crate::ledger::AccessRecord {
1554                    role: "train".to_string(),
1555                    profile: "canonical".to_string(),
1556                    purpose: "fabricated".to_string(),
1557                    fingerprint_hex: "aa".repeat(32),
1558                });
1559            // `ledger_hash` is deliberately LEFT ALONE, which is exactly the tampering the
1560            // rung exists for: the envelope digest is resealed, so every other check passes.
1561            reseal(manifest);
1562        });
1563        match err {
1564            ContrastiveDataError::SelectionReplayMismatch { field } => {
1565                assert_eq!(field, "access_ledger");
1566            }
1567            other => panic!("expected SelectionReplayMismatch on access_ledger, got {other:?}"),
1568        }
1569    }
1570
1571    /// Every producer renders through `hex`, which is lowercase; an uppercase digest cannot
1572    /// have come from one, so it is refused rather than parsed case-insensitively.
1573    #[test]
1574    fn manifest_replay_rejects_an_uppercase_ledger_hash() {
1575        let err = reject(|manifest, _| {
1576            manifest.payload.ledger_hash = manifest.payload.ledger_hash.to_uppercase();
1577            reseal(manifest);
1578        });
1579        match err {
1580            ContrastiveDataError::SelectionReplayMismatch { field } => {
1581                assert_eq!(field, "ledger_hash");
1582            }
1583            other => panic!("expected SelectionReplayMismatch on ledger_hash, got {other:?}"),
1584        }
1585    }
1586
1587    #[test]
1588    fn manifest_replay_rejects_an_altered_exclusion_record() {
1589        let err = reject(|manifest, _| {
1590            let mut other_ledger = AccessLedger::new();
1591            let other = test_corpus::dataset_with_cross_split_duplicate(12, &mut other_ledger);
1592            assert_ne!(
1593                other.exclusions(),
1594                &manifest.payload.exclusions,
1595                "the substituted record must actually differ"
1596            );
1597            manifest.payload.exclusions = other.exclusions().clone();
1598        });
1599        match err {
1600            ContrastiveDataError::ExclusionRecordMismatch { expected, got } => {
1601                assert_ne!(expected, got);
1602            }
1603            other => panic!("expected ExclusionRecordMismatch, got {other:?}"),
1604        }
1605    }
1606
1607    #[test]
1608    fn manifest_replay_rejects_an_id_outside_the_selection_pool() {
1609        let err = reject(|manifest, dataset| {
1610            let row = dataset.validation().rows()[0].clone();
1611            manifest.payload.ordered_examples[0] = SelectedExampleRecord {
1612                id: row.id,
1613                label: 0,
1614                exact_hash: manifest.payload.ordered_examples[0].exact_hash.clone(),
1615                normalized_hash: manifest.payload.ordered_examples[0].normalized_hash.clone(),
1616            };
1617        });
1618        match err {
1619            ContrastiveDataError::EndpointNotInSelection { id, found_in } => {
1620                assert_eq!(id, "validation:0");
1621                assert_eq!(found_in, "validation");
1622            }
1623            other => panic!("expected EndpointNotInSelection, got {other:?}"),
1624        }
1625    }
1626
1627    #[test]
1628    fn manifest_replay_rejects_a_duplicated_id() {
1629        let err = reject(|manifest, _| {
1630            manifest.payload.ordered_examples[1] = manifest.payload.ordered_examples[0].clone();
1631        });
1632        match err {
1633            ContrastiveDataError::DuplicateId { split, id } => {
1634                assert_eq!(split, "selection");
1635                assert!(id.starts_with("train:0-"), "{id}");
1636            }
1637            other => panic!("expected DuplicateId, got {other:?}"),
1638        }
1639    }
1640
1641    #[test]
1642    fn manifest_replay_rejects_an_unbalanced_class() {
1643        let err = reject(|manifest, _| {
1644            manifest.payload.ordered_examples.remove(0);
1645        });
1646        match err {
1647            ContrastiveDataError::InvalidClassCounts {
1648                split,
1649                expected,
1650                got,
1651            } => {
1652                assert_eq!(split, "selection");
1653                assert_eq!(expected, vec![8, 8, 8]);
1654                assert_eq!(got, vec![7, 8, 8]);
1655            }
1656            other => panic!("expected InvalidClassCounts, got {other:?}"),
1657        }
1658    }
1659
1660    #[test]
1661    fn manifest_replay_rejects_examples_swapped_across_classes() {
1662        let err = reject(|manifest, _| {
1663            let rows = &mut manifest.payload.ordered_examples;
1664            assert_eq!((rows[0].label, rows[8].label), (0, 1));
1665            rows.swap(0, 8);
1666        });
1667        match err {
1668            ContrastiveDataError::SelectionReplayMismatch { field } => {
1669                assert_eq!(field, "class_order");
1670            }
1671            other => panic!("expected SelectionReplayMismatch, got {other:?}"),
1672        }
1673    }
1674
1675    #[test]
1676    fn manifest_replay_rejects_a_tampered_row_hash() {
1677        let err = reject(|manifest, _| {
1678            manifest.payload.ordered_examples[3].exact_hash = "0".repeat(64);
1679        });
1680        match err {
1681            ContrastiveDataError::RowHashMismatch { id, expected, got } => {
1682                assert!(id.starts_with("train:0-"), "{id}");
1683                assert_eq!(expected, "0".repeat(64));
1684                assert_ne!(got, expected);
1685            }
1686            other => panic!("expected RowHashMismatch, got {other:?}"),
1687        }
1688    }
1689
1690    #[test]
1691    fn manifest_replay_rejects_an_unsupported_schema_version() {
1692        let err = reject(|manifest, _| manifest.payload.schema_version = 99);
1693        match err {
1694            ContrastiveDataError::UnsupportedSchemaVersion {
1695                field,
1696                got,
1697                supported,
1698            } => {
1699                assert_eq!(field, "selection");
1700                assert_eq!((got, supported), (99, 1));
1701            }
1702            other => panic!("expected UnsupportedSchemaVersion, got {other:?}"),
1703        }
1704    }
1705
1706    #[test]
1707    fn manifest_replay_rejects_an_unsupported_algorithm_version() {
1708        let err = reject(|manifest, _| manifest.payload.algorithm_version = 99);
1709        match err {
1710            ContrastiveDataError::UnsupportedAlgorithmVersion { got, supported } => {
1711                assert_eq!((got, supported), (99, 1));
1712            }
1713            other => panic!("expected UnsupportedAlgorithmVersion, got {other:?}"),
1714        }
1715    }
1716
1717    /// The case a membership-plus-hash check alone would have ACCEPTED, and the whole
1718    /// reason replay recomputes the selection rather than merely auditing the manifest.
1719    ///
1720    /// Every earlier rung passes: the substituted row is in the pool, unique, correctly
1721    /// labelled, correctly hashed, the class counts and ordering are untouched, and the
1722    /// envelope digest is recomputed so the manifest is internally consistent. It is
1723    /// simply not a selection any seed could have produced.
1724    #[test]
1725    fn manifest_replay_rejects_a_consistent_but_unreachable_ordered_list() {
1726        let err = reject(|manifest, dataset| {
1727            let selected: Vec<&str> = manifest
1728                .payload
1729                .ordered_examples
1730                .iter()
1731                .map(|row| row.id.as_str())
1732                .collect();
1733            let substitute = dataset
1734                .train()
1735                .rows()
1736                .iter()
1737                .find(|row| row.label == 0 && !selected.contains(&row.id.as_str()))
1738                .expect("the pool is larger than the selection")
1739                .clone();
1740            manifest.payload.ordered_examples[0] = SelectedExampleRecord {
1741                exact_hash: crate::hash::hex(
1742                    dataset
1743                        .train()
1744                        .exact_hash_of(&substitute.id)
1745                        .expect("the row is in the split"),
1746                ),
1747                normalized_hash: crate::hash::hex(
1748                    dataset
1749                        .train()
1750                        .normalized_hash_of(&substitute.id)
1751                        .expect("the row is in the split"),
1752                ),
1753                id: substitute.id,
1754                label: 0,
1755            };
1756            reseal(manifest);
1757            manifest
1758                .verify_digest()
1759                .expect("the forgery is internally consistent — that is the point");
1760        });
1761        match err {
1762            ContrastiveDataError::SelectionReplayMismatch { field } => {
1763                assert_eq!(field, "ordered_examples");
1764            }
1765            other => panic!("expected SelectionReplayMismatch, got {other:?}"),
1766        }
1767    }
1768
1769    /// The digest rung itself, reached only when every structural rung has passed.
1770    #[test]
1771    fn manifest_replay_rejects_a_digest_that_disagrees_with_its_payload() {
1772        let err = reject(|manifest, _| manifest.semantic_hash = "f".repeat(64));
1773        match err {
1774            ContrastiveDataError::SemanticHashMismatch { expected, got } => {
1775                assert_eq!(expected, "f".repeat(64));
1776                assert_ne!(got, expected);
1777            }
1778            other => panic!("expected SemanticHashMismatch, got {other:?}"),
1779        }
1780    }
1781
1782    /// A replayed selection is a full-fidelity `Selection`, not a shell.
1783    #[test]
1784    fn manifest_replay_returns_a_usable_selection() {
1785        let mut fixture = fixture();
1786        let replayed = Selection::replay(&fixture.manifest, &fixture.dataset, &mut fixture.ledger)
1787            .expect("an honest manifest replays");
1788
1789        assert_eq!(replayed.class_sizes(), fixture.selection.class_sizes());
1790        assert_eq!(replayed.root_seed(), 31);
1791        assert_eq!(replayed.shots_per_class(), 8);
1792        let first = replayed.ordered_ids()[0].to_string();
1793        let selected = replayed
1794            .selected_id(&first)
1795            .expect("the replayed selection indexes its own rows");
1796        assert_eq!(replayed.id_of(selected), first);
1797        assert_eq!(replayed.label_of(selected), 0);
1798
1799        // And it is the SAME selection a fresh run produces from the same inputs.
1800        let mut fresh_ledger = AccessLedger::new();
1801        let fresh_dataset = test_corpus::dataset(12, &mut fresh_ledger);
1802        let fresh = super::FewShotSelector::select(
1803            &fresh_dataset,
1804            &SelectionConfig {
1805                root_seed: 31,
1806                shots_per_class: 8,
1807            },
1808            &mut fresh_ledger,
1809        )
1810        .expect("a fresh selection succeeds");
1811        assert_eq!(fresh.examples(), replayed.examples());
1812        assert_eq!(fresh.semantic_hash(), replayed.semantic_hash());
1813    }
1814}