Skip to main content

aprender_contrastive_data/
error.rs

1//! The typed failure surface for the contrastive data protocol.
2//!
3//! # Contract: contrastive-pair-protocol-v1.yaml (`OBLIG-CPP-ERROR-TAXONOMY`)
4//!
5//! Every fallible path in this crate returns [`ContrastiveDataError`]. There is no
6//! `unwrap()`, no `panic!` on caller input, and no sentinel return value: a caller that
7//! feeds this crate untrusted bytes from object storage must be able to distinguish
8//! "malformed row 12 of train" from "this dataset does not have enough examples left in
9//! class 2 to supply 64 shots", and both from an internal arithmetic overflow.
10//!
11//! Messages follow the `data_tweeteval.rs` house style — they name the split, the index,
12//! and BOTH the expected and the observed value, because a message that says only
13//! "validation failed" turns a five-second fix into a bisect.
14//!
15//! # This enum is an exhaustive INITIAL design, not a permanently closed one
16//!
17//! Every variant below was derived up-front from DATA-02's failure classes plus the
18//! degenerate-case, version-mismatch, arithmetic, and untrusted-input classes that plans
19//! 02-05, 02-07 and 02-09 need, so that a downstream plan does not have to widen the
20//! error surface mid-wave and force its siblings to rebase. That is a design review, not
21//! a freeze.
22//!
23//! A downstream plan **MAY** add a variant. When it does, two things are mandatory:
24//!
25//! 1. the addition and its reason are recorded in that plan's `SUMMARY.md`, and
26//! 2. `OBLIG-CPP-ERROR-TAXONOMY` in `contracts/contrastive-pair-protocol-v1.yaml` is
27//!    extended to list it.
28//!
29//! The enum is `#[non_exhaustive]` so that adding a variant is not a breaking change for
30//! an external consumer, and so that a `match` in `apr-cli` cannot silently go stale.
31
32/// Every way the contrastive data protocol can refuse to proceed.
33///
34/// Grouped below by the boundary that raises them: split ingest, selection and manifest,
35/// dataset attestation, pair construction, and version/arithmetic/plumbing.
36#[derive(Debug, Clone, PartialEq, thiserror::Error)]
37#[non_exhaustive]
38pub enum ContrastiveDataError {
39    // ---------------------------------------------------------------------------
40    // DATA-02 — split ingest, the bytes -> typed boundary
41    // ---------------------------------------------------------------------------
42    /// A JSONL row did not parse, or parsed into something the schema rejects.
43    #[error("{split} row {index} is malformed: {reason}")]
44    MalformedRow {
45        /// Split role name as declared by the caller (`train`, `validation`, ...).
46        split: String,
47        /// Zero-based row index within the split's buffer.
48        index: usize,
49        /// Why the row was rejected (parser message or schema violation).
50        reason: String,
51    },
52
53    /// A row's bytes are not valid UTF-8, so they were never parsed.
54    #[error("{split} row {index} is not valid UTF-8")]
55    InvalidUtf8 {
56        /// Split role name as declared by the caller.
57        split: String,
58        /// Zero-based row index within the split's buffer.
59        index: usize,
60    },
61
62    /// A row's `input` field is empty or whitespace-only.
63    #[error("{split} row {index} has empty text")]
64    EmptyInput {
65        /// Split role name as declared by the caller.
66        split: String,
67        /// Zero-based row index within the split's buffer.
68        index: usize,
69    },
70
71    /// A row's numeric label is outside the declared label map.
72    #[error("{split} row {index} has unknown label {label}")]
73    UnknownLabel {
74        /// Split role name as declared by the caller.
75        split: String,
76        /// Zero-based row index within the split's buffer.
77        index: usize,
78        /// The out-of-range label value read from the row.
79        label: usize,
80    },
81
82    /// A row's `label_text` disagrees with `label_names[label]`.
83    ///
84    /// This is a distinct failure from [`Self::UnknownLabel`]: the numeric label is in
85    /// range, but the human-readable text contradicts it, which is exactly what a
86    /// tampered or hand-edited mirror looks like.
87    #[error(
88        "{split} row {index} label {label} text mismatch: expected {expected_text:?}, got {got_text:?}"
89    )]
90    LabelTextMismatch {
91        /// Split role name as declared by the caller.
92        split: String,
93        /// Zero-based row index within the split's buffer.
94        index: usize,
95        /// The numeric label carried by the row.
96        label: usize,
97        /// The text the declared label map assigns to `label`.
98        expected_text: String,
99        /// The text the row actually carried.
100        got_text: String,
101    },
102
103    /// The per-class row counts of a split do not match the declaration.
104    #[error("{split} class-count contract failed: expected {expected:?}, got {got:?}")]
105    InvalidClassCounts {
106        /// Split role name as declared by the caller.
107        split: String,
108        /// Per-class counts the declaration requires.
109        expected: Vec<usize>,
110        /// Per-class counts actually observed.
111        got: Vec<usize>,
112    },
113
114    /// A per-split label map disagrees with the declaration's shared label map.
115    ///
116    /// The declaration carries one label map per split AND a shared one. Rows are
117    /// validated against the per-split map, while the shared map is what reaches the
118    /// fingerprint, the stored map and `SelectionPayload::label_names`. If the two
119    /// disagree, every row validates and the manifest still commits a map that
120    /// contradicts them, so the disagreement must be refused before any row is read.
121    #[error(
122        "{split} label map disagrees with the shared label map: shared {shared:?}, {split} {got:?}"
123    )]
124    DeclaredLabelMapMismatch {
125        /// Split role name whose declared map diverges.
126        split: String,
127        /// The shared label map the manifest would commit.
128        shared: Vec<String>,
129        /// The map that split's rows were validated against.
130        got: Vec<String>,
131    },
132
133    /// The same row identifier appears twice inside one split.
134    #[error("{split} contains duplicate id {id:?}")]
135    DuplicateId {
136        /// Split role name as declared by the caller.
137        split: String,
138        /// The identifier that appeared more than once.
139        id: String,
140    },
141
142    /// A dataset profile declared by the caller conflicts with the one in the bytes.
143    #[error("conflicting source role: caller declared {declared:?}, bytes embed {embedded:?}")]
144    ConflictingSourceRole {
145        /// Role the caller asserted when constructing the split.
146        declared: String,
147        /// Role embedded in the row payloads.
148        embedded: String,
149    },
150
151    /// A row's embedded `source_split` is not the role being constructed.
152    ///
153    /// The typestate makes leakage inexpressible for a *library* caller; this variant is
154    /// what stops honest-looking bytes from object storage becoming a `Split<Train>` the
155    /// compiler is perfectly happy with (D-16).
156    #[error("split role mismatch: expected {expected_role:?}, bytes embed {embedded_role:?}")]
157    SplitRoleMismatch {
158        /// Role being constructed.
159        expected_role: String,
160        /// Role found inside the row.
161        embedded_role: String,
162    },
163
164    /// A row's recomputed content hash disagrees with the attested one.
165    #[error("row {id:?} hash mismatch: expected {expected}, got {got}")]
166    RowHashMismatch {
167        /// The row identifier whose hash disagreed.
168        id: String,
169        /// Hex digest recorded in the attestation.
170        expected: String,
171        /// Hex digest recomputed from the supplied bytes.
172        got: String,
173    },
174
175    /// Cross-split duplicate exclusion shrank a class pool below `shots_per_class`.
176    ///
177    /// Duplicate *content* is never fatal at prepare time (D-18, upheld verbatim by
178    /// D-27) — it is excluded and recorded. This variant is the one real failure: after
179    /// exclusion the pool can no longer supply the requested shots.
180    #[error(
181        "class {class_label} pool exhausted after cross-split duplicate exclusion: {pool} rows remain, {shots} shots requested"
182    )]
183    CrossSplitDuplicateUnderflow {
184        /// The class whose pool ran short.
185        class_label: usize,
186        /// Rows remaining in the class pool after exclusion.
187        pool: usize,
188        /// Shots per class the selection requested.
189        shots: usize,
190    },
191
192    // ---------------------------------------------------------------------------
193    // Selection and manifest
194    // ---------------------------------------------------------------------------
195    /// `shots_per_class` is not one of the contracted values.
196    ///
197    /// Checked BEFORE any RNG draw, so an invalid request never consumes an ordinal and
198    /// never produces a partially built selection.
199    #[error("invalid shots_per_class {got}: allowed values are {allowed}")]
200    InvalidShots {
201        /// The requested shots-per-class value.
202        got: usize,
203        /// The contracted set, rendered for the message (e.g. `{8, 16, 32, 64}`).
204        allowed: &'static str,
205    },
206
207    /// A selection manifest's recorded `semantic_hash` does not match its payload.
208    #[error("selection semantic_hash mismatch: expected {expected}, got {got}")]
209    SemanticHashMismatch {
210        /// Digest recorded in the manifest envelope.
211        expected: String,
212        /// Digest recomputed from the canonical payload bytes.
213        got: String,
214    },
215
216    /// Replaying a selection from its manifest did not reproduce the manifest.
217    #[error("selection replay mismatch in field {field:?}")]
218    SelectionReplayMismatch {
219        /// The first field that disagreed (ordering, balance, membership, ...).
220        field: String,
221    },
222
223    /// A pair endpoint names an identifier that is not in the selection.
224    ///
225    /// This is D-27's fail-closed span check for untrusted, replayed pair bytes.
226    #[error("pair endpoint {id:?} is not in the selection (found in {found_in})")]
227    EndpointNotInSelection {
228        /// The offending identifier, named so the failure is diagnosable.
229        id: String,
230        /// Where the identifier WAS found, if anywhere (`validation`, `nowhere`, ...).
231        found_in: String,
232    },
233
234    // ---------------------------------------------------------------------------
235    // Dataset attestation (plan 02-06 boundary)
236    // ---------------------------------------------------------------------------
237    /// The attested dataset profile is not the one the consumer asked for.
238    #[error("dataset profile mismatch: expected {expected:?}, got {got:?}")]
239    ProfileMismatch {
240        /// Profile the consumer requires.
241        expected: String,
242        /// Profile the attestation carries.
243        got: String,
244    },
245
246    /// The attestation names a split role for which no bytes were supplied.
247    #[error("attestation requires split {role:?} but no bytes were supplied for it")]
248    MissingSplit {
249        /// The split role that is absent.
250        role: String,
251    },
252
253    /// A split's recomputed JSONL digest disagrees with the attested one.
254    #[error("{split} split hash mismatch: expected {expected}, got {got}")]
255    SplitHashMismatch {
256        /// Split role name.
257        split: String,
258        /// Digest recorded in the attestation.
259        expected: String,
260        /// Digest recomputed from the supplied buffer.
261        got: String,
262    },
263
264    /// The recomputed dataset fingerprint disagrees with the attested one.
265    #[error("dataset fingerprint mismatch: expected {expected}, got {got}")]
266    FingerprintMismatch {
267        /// Fingerprint recorded in the attestation.
268        expected: String,
269        /// Fingerprint recomputed from the supplied buffers.
270        got: String,
271    },
272
273    /// The recomputed cross-split exclusion record disagrees with the attested one.
274    #[error("exclusion record mismatch: expected {expected}, got {got}")]
275    ExclusionRecordMismatch {
276        /// Exclusion record digest recorded in the attestation.
277        expected: String,
278        /// Exclusion record digest recomputed from the supplied buffers.
279        got: String,
280    },
281
282    // ---------------------------------------------------------------------------
283    // Pair construction
284    // ---------------------------------------------------------------------------
285    /// A pair was requested whose two endpoints are the same selected ordinal.
286    ///
287    /// D-12: unreachable through the sampler, because `CanonicalPair::new` is the sole
288    /// constructor and it rejects equal endpoints. It IS reachable through the untrusted
289    /// pair-ingest boundary, which is why the variant exists.
290    #[error("self-pair rejected: both endpoints are selected ordinal {id}")]
291    SelfPair {
292        /// The selected-example ordinal that appeared on both sides.
293        id: u64,
294    },
295
296    /// The layout admits no pairs of either kind.
297    #[error(
298        "no pair capacity: positive_capacity={positive_capacity}, negative_capacity={negative_capacity}"
299    )]
300    NoPairCapacity {
301        /// Number of distinct same-class unordered pairs available.
302        positive_capacity: u64,
303        /// Number of distinct cross-class unordered pairs available.
304        negative_capacity: u64,
305    },
306
307    /// An effective pair budget of zero was resolved or requested.
308    #[error("pair budget must be greater than zero")]
309    ZeroBudget,
310
311    /// A pair hard cap of zero was configured.
312    ///
313    /// Distinct from [`Self::ZeroBudget`]: a zero cap means no budget can ever be
314    /// satisfied, which is a configuration defect rather than a request defect.
315    #[error("pair hard_cap must be greater than zero")]
316    ZeroHardCap,
317
318    /// An explicit budget above the configured hard cap.
319    ///
320    /// This FAILS rather than silently clamping: the cap exists for DoS control, and a
321    /// user who typed a larger number deserves to be told it was refused, not to receive
322    /// a quietly different dataset (`budget_resolution`).
323    #[error("requested pair budget {budget} exceeds hard_cap {hard_cap}")]
324    BudgetExceedsHardCap {
325        /// Budget the caller requested.
326        budget: u64,
327        /// Configured hard cap.
328        hard_cap: u64,
329    },
330
331    /// A budget exceeding the available UNIQUE pair capacity (D-11).
332    ///
333    /// Reserved for the unique-capacity check. The oversampling strategy draws with
334    /// replacement, so `budget > capacity` is not an error there.
335    #[error("requested pair budget {budget} exceeds unique pair capacity {capacity}")]
336    BudgetExceedsCapacity {
337        /// Budget the caller requested.
338        budget: u64,
339        /// Unique pair capacity available for the strategy.
340        capacity: u64,
341    },
342
343    /// A pair was requested at an ordinal at or beyond the resolved budget.
344    #[error("pair ordinal {ordinal} is out of range for budget {budget}")]
345    OrdinalOutOfRange {
346        /// The requested draw ordinal.
347        ordinal: u64,
348        /// The resolved effective budget.
349        budget: u64,
350    },
351
352    /// A replayed pair record's target disagrees with its endpoints' classes.
353    ///
354    /// The 1.0/0.0 target is DERIVED from endpoint classes at emission and is never
355    /// accepted from caller input; this variant is how that is enforced for bytes that
356    /// claim otherwise.
357    #[error(
358        "pair ({lo:?}, {hi:?}) declares target {declared_target} but its endpoints derive {derived_target}"
359    )]
360    PairTargetMismatch {
361        /// Lower canonical endpoint identifier.
362        lo: String,
363        /// Upper canonical endpoint identifier.
364        hi: String,
365        /// Target the untrusted record carried.
366        declared_target: f32,
367        /// Target derived from the endpoints' classes.
368        derived_target: f32,
369    },
370
371    // ---------------------------------------------------------------------------
372    // Version, arithmetic, plumbing
373    // ---------------------------------------------------------------------------
374    /// A serialized artifact declares a schema version this build does not support.
375    #[error("unsupported schema version for {field}: got {got}, supported {supported}")]
376    UnsupportedSchemaVersion {
377        /// Which artifact or field carried the version (`selection`, `attestation`, ...).
378        field: String,
379        /// Version read from the artifact.
380        got: u32,
381        /// Version this build implements.
382        supported: u32,
383    },
384
385    /// An artifact was produced under a content-normalization pipeline this build does
386    /// not implement.
387    ///
388    /// Distinct from [`Self::UnsupportedSchemaVersion`] because the normalization version
389    /// is a STRING tag rather than an integer, and because it changes what the exclusion
390    /// record MEANS rather than what the artifact's fields are. Silently accepting a
391    /// foreign tag would let an exclusion record computed under different collapsing rules
392    /// be replayed as if it had been computed under these ones (D-17: the normalization is
393    /// contracted and versioned so it cannot drift).
394    #[error("unsupported content normalization version: got {got:?}, supported {supported:?}")]
395    UnsupportedNormalizationVersion {
396        /// Tag read from the artifact.
397        got: String,
398        /// Tag this build implements.
399        supported: &'static str,
400    },
401
402    /// A versioned policy enum value this build does not implement.
403    #[error("unsupported {policy} policy version: got {got}, supported {supported}")]
404    UnsupportedPolicyVersion {
405        /// Which policy (`singleton`, `degenerate`, ...).
406        policy: String,
407        /// Version read from the artifact.
408        got: u32,
409        /// Version this build implements.
410        supported: u32,
411    },
412
413    /// A sampling-algorithm version this build does not implement.
414    ///
415    /// Separate from [`Self::UnsupportedPolicyVersion`] because changing the algorithm
416    /// changes pair IDENTITIES, whereas changing a policy changes which pairs are legal.
417    #[error("unsupported algorithm version: got {got}, supported {supported}")]
418    UnsupportedAlgorithmVersion {
419        /// Version read from the artifact.
420        got: u32,
421        /// Version this build implements.
422        supported: u32,
423    },
424
425    /// A capacity or budget computation overflowed.
426    ///
427    /// Every step of the closed-form capacity math uses checked arithmetic, so a large
428    /// class layout produces this typed error instead of a wrapped, plausible-looking
429    /// capacity that would then silently under-sample.
430    #[error("arithmetic overflow in {operation}")]
431    ArithmeticOverflow {
432        /// The operation that overflowed (`positive_capacity`, `default_budget`, ...).
433        operation: String,
434    },
435
436    /// Canonical serialization or deserialization of an artifact failed.
437    #[error("serialization failed in {context}: {detail}")]
438    Serialization {
439        /// What was being (de)serialized.
440        context: String,
441        /// The underlying serializer message.
442        detail: String,
443    },
444
445    /// A caller-supplied sink or source failed.
446    ///
447    /// The crate performs NO filesystem or network access (D-04). This variant exists
448    /// only so `dump_pairs<W: Write>` can surface the caller's own writer failure as a
449    /// typed error rather than swallowing it.
450    #[error("i/o failed in {context}: {detail}")]
451    Io {
452        /// What was being written or read.
453        context: String,
454        /// The underlying `std::io::Error` message.
455        detail: String,
456    },
457}