vernier_partial/error.rs
1//! Typed errors for the partial wire format and merge surface
2//! (ADR-0031, generalized in ADR-0032).
3//!
4//! These variants live in `vernier-partial` so all three paradigm
5//! crates (`vernier-core`, `vernier-semantic`, `vernier-panoptic`)
6//! share one vocabulary. Each paradigm's top-level error type wraps
7//! [`PartialError`] via a single `From` arm so the FFI can map the
8//! five Python exception classes uniformly.
9//!
10//! The [`PartialFormatErrorKind::tag`] strings are part of the public
11//! Python surface — tests assert against them — and must stay stable
12//! across format-version bumps.
13
14use thiserror::Error;
15
16/// Top-level error returned by the encode/decode and merge surface in
17/// this crate. Each variant maps 1:1 to one of the five Python
18/// exception classes (`PartialFormatMismatch`, `PartialDatasetMismatch`,
19/// `PartialParamsMismatch`, `PartialPartitionOverlap`,
20/// `PartialRankCollision`).
21#[derive(Debug, Clone, PartialEq, Eq, Error)]
22pub enum PartialError {
23 /// Framing or structural check tripped. See
24 /// [`PartialFormatErrorKind`] for which one.
25 #[error("partial wire format rejected: {kind}")]
26 Format {
27 /// Sub-discriminator naming the specific check.
28 kind: PartialFormatErrorKind,
29 },
30
31 /// Partial's `dataset_hash` does not match the receiving rank's
32 /// live dataset. Sampler / config bug; refusing protects the merge
33 /// from silently producing un-reproducible numbers.
34 #[error("partial dataset_hash mismatch: expected {expected:02x?}, got {actual:02x?}")]
35 DatasetMismatch {
36 /// Receiving rank's `dataset_hash`.
37 expected: [u8; 32],
38 /// Partial's declared `dataset_hash`.
39 actual: [u8; 32],
40 },
41
42 /// Partial's `params_hash` does not match the receiving rank's.
43 /// Means params (max_dets / iou_thresholds / use_cats / …) diverged.
44 #[error("partial params_hash mismatch: expected {expected:02x?}, got {actual:02x?}")]
45 ParamsMismatch {
46 /// Receiving rank's `params_hash`.
47 expected: [u8; 32],
48 /// Partial's declared `params_hash`.
49 actual: [u8; 32],
50 },
51
52 /// Two partials cover the same `image_id` — the disjoint-partition
53 /// rule is violated. Names both rank ids and the colliding image
54 /// so the user can fix their `DistributedSampler`.
55 #[error("partials cover image_id={image_id} on both rank {rank_a} and rank {rank_b}")]
56 PartitionOverlap {
57 /// Lower rank id (`min(a, b)`) — sorted for determinism.
58 rank_a: u32,
59 /// Higher rank id.
60 rank_b: u32,
61 /// Image id that appeared in both partials' `seen_images`.
62 image_id: i64,
63 },
64
65 /// Two strict-mode partials declare the same `rank_id`. Strict
66 /// merge requires distinct ids so the cross-rank tiebreak gives a
67 /// total order. Corrected mode tolerates collisions.
68 #[error("partials share rank_id={rank_id} in strict mode")]
69 RankCollision {
70 /// The duplicated rank id.
71 rank_id: u32,
72 },
73}
74
75/// Sub-discriminator for [`PartialError::Format`]. The validation
76/// pipeline runs cheapest-first, so the variant also indicates how
77/// far the validator got before tripping.
78///
79/// **Tag stability is part of the public Python surface.** The
80/// snake_case [`Self::tag`] strings appear on the FFI exception's
81/// `kind` attribute and are asserted against in the parity test
82/// suite. Renames require a coordinated stub + test update.
83#[derive(Debug, Clone, PartialEq, Eq, Error)]
84pub enum PartialFormatErrorKind {
85 /// Length too short to contain even the framing header.
86 #[error("partial too short: observed {observed} bytes, minimum {minimum}")]
87 TooShort {
88 /// Observed length.
89 observed: usize,
90 /// Minimum required.
91 minimum: usize,
92 },
93 /// Magic-bytes prefix did not match `b"VRPS"`.
94 #[error("wrong magic bytes: expected \"VRPS\", got {found:02x?}")]
95 WrongMagic {
96 /// First four bytes found.
97 found: [u8; 4],
98 },
99 /// `format_version` byte did not match the receiving rank's
100 /// compiled-in [`crate::envelope::FORMAT_VERSION`].
101 #[error("wrong format_version: expected {expected}, got {found}")]
102 WrongVersion {
103 /// Compiled-in version.
104 expected: u8,
105 /// Found in the partial.
106 found: u8,
107 },
108 /// CRC32 footer did not match. Truncation, transport corruption,
109 /// or hand-crafted payload.
110 #[error("crc32 footer mismatch")]
111 Crc,
112 /// `paradigm_kind` byte did not match. Means the partial belongs
113 /// to a different paradigm (e.g., a semantic partial loaded by
114 /// an instance evaluator).
115 #[error("paradigm_kind mismatch: expected {expected}, got {found}")]
116 ParadigmMismatch {
117 /// Receiving evaluator's paradigm.
118 expected: u8,
119 /// Partial's declared paradigm.
120 found: u8,
121 },
122 /// `discriminator` did not match within a paradigm. For instance:
123 /// merging a bbox partial into a segm evaluator. Each paradigm
124 /// defines its own discriminator semantics; the value is opaque
125 /// to this crate.
126 ///
127 /// The tag `"kernel_mismatch"` is preserved for backward
128 /// compatibility with ADR-0031 v1 instance partials.
129 #[error("kernel_kind mismatch: expected discriminant {expected}, got {found}")]
130 KernelMismatch {
131 /// Receiving evaluator's discriminator.
132 expected: u32,
133 /// Partial's declared discriminator.
134 found: u32,
135 },
136 /// Header `shape_fingerprint` did not match. Each paradigm
137 /// interprets the four `u32` slots: instance uses
138 /// `(n_categories, n_area_ranges, n_images, retain_iou)`,
139 /// semantic uses `(n_classes, 0, n_images, 0)`, panoptic uses
140 /// `(n_categories, 0, n_images, things_stuff_split)`.
141 ///
142 /// The tag `"grid_mismatch"` is preserved for backward
143 /// compatibility with ADR-0031 v1 instance partials.
144 #[error("shape fingerprint mismatch: {detail}")]
145 GridMismatch {
146 /// Free-form detail naming which slot mismatched.
147 detail: String,
148 },
149 /// `parity_mode` byte did not match.
150 #[error("parity_mode mismatch: expected discriminant {expected}, got {found}")]
151 ParityMismatch {
152 /// Receiving evaluator's parity mode discriminant.
153 expected: u8,
154 /// Partial's declared parity mode discriminant.
155 found: u8,
156 },
157 /// rkyv archive validation refused the payload. Pointer offsets
158 /// out of range, bad enum tag, etc.
159 #[error("rkyv archive validation failed: {detail}")]
160 RkyvDecode {
161 /// rkyv's diagnostic message.
162 detail: String,
163 },
164 /// Catch-all for internal-state failures that need to surface
165 /// through the partial-error vocabulary because they share the
166 /// FFI exception class but aren't framing-related: e.g., a
167 /// background worker that was shut down before the FFI call
168 /// reached it, or an op invoked after `finalize`. Distinct from
169 /// [`Self::RkyvDecode`] so users don't see a misleading
170 /// "rkyv_decode" tag for a non-archive failure.
171 #[error("partial wire-format internal error: {detail}")]
172 Internal {
173 /// Free-form detail. Surfaced on the FFI exception's `kind`
174 /// attribute as the literal `"internal"` tag.
175 detail: String,
176 },
177}
178
179impl PartialFormatErrorKind {
180 /// Stable snake_case identifier for this variant. Surfaced on the
181 /// FFI exception's `kind` attribute. Adding a variant requires
182 /// adding a tag here — the exhaustive match makes the omission a
183 /// compile error.
184 pub fn tag(&self) -> &'static str {
185 match self {
186 Self::TooShort { .. } => "too_short",
187 Self::WrongMagic { .. } => "wrong_magic",
188 Self::WrongVersion { .. } => "wrong_version",
189 Self::Crc => "crc",
190 Self::ParadigmMismatch { .. } => "paradigm_mismatch",
191 Self::KernelMismatch { .. } => "kernel_mismatch",
192 Self::GridMismatch { .. } => "grid_mismatch",
193 Self::ParityMismatch { .. } => "parity_mismatch",
194 Self::RkyvDecode { .. } => "rkyv_decode",
195 Self::Internal { .. } => "internal",
196 }
197 }
198}