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}