iqdb_eval/error.rs
1//! The evaluation-harness domain error.
2//!
3//! [`EvalError`] names every failure mode the harness can surface. It
4//! mirrors [`iqdb_types::IqdbError`]'s shape (non-exhaustive enum, one
5//! variant per failure, [`error_forge::ForgeError`] integration) so the two
6//! errors compose into the same operator-facing structured-error events.
7//!
8//! Unlike `IqdbError`, this type is **not** `Copy` or `Clone`: the `Io`
9//! variant wraps a `std::io::Error` (used by the `.fvecs` / `.ivecs`
10//! loaders) and `std::io::Error` does not implement either trait.
11
12use std::path::PathBuf;
13
14use error_forge::ForgeError;
15use iqdb_types::IqdbError;
16
17/// An error from an `iqdb-eval` measurement or dataset-loading operation.
18///
19/// Each variant identifies one specific failure. The enum is
20/// `#[non_exhaustive]`: future releases may add variants without it being
21/// a breaking change, so a `match` on it must include a wildcard arm.
22///
23/// # Examples
24///
25/// ```
26/// use iqdb_eval::EvalError;
27///
28/// let err = EvalError::DimensionMismatch { expected: 128, found: 64 };
29/// assert_eq!(
30/// err.to_string(),
31/// "vector dimension mismatch: expected 128, found 64",
32/// );
33///
34/// let err = EvalError::KExceedsCorpus { k: 100, corpus_size: 10 };
35/// assert_eq!(
36/// err.to_string(),
37/// "k exceeds corpus size: k=100, corpus_size=10",
38/// );
39/// ```
40#[non_exhaustive]
41#[derive(Debug)]
42pub enum EvalError {
43 /// An OS-level I/O failure occurred while reading a dataset file.
44 /// `path` is the file whose read failed; `source` is the underlying
45 /// `std::io::Error` and is reachable via [`std::error::Error::source`].
46 Io {
47 /// The path whose read or open call failed.
48 path: PathBuf,
49 /// The underlying I/O error.
50 source: std::io::Error,
51 },
52 /// A dataset file was opened successfully but its contents could not
53 /// be parsed (truncated record, invalid header, etc.). `reason` is a
54 /// short static description of the parser check that failed.
55 Parse {
56 /// The path of the file whose contents could not be parsed.
57 path: PathBuf,
58 /// Short static description of which parser check failed.
59 reason: &'static str,
60 },
61 /// A vector did not have the dimensionality the operation required.
62 /// `expected` is what was required; `found` is what was supplied.
63 DimensionMismatch {
64 /// The dimensionality the operation required.
65 expected: usize,
66 /// The dimensionality that was actually supplied.
67 found: usize,
68 },
69 /// Two collections that had to share a length did not. `kind` names
70 /// the pair (e.g. `"queries vs ground_truth"`); `expected` is the
71 /// first collection's length; `found` is the second's.
72 LengthMismatch {
73 /// Short, stable identifier for the collection pair.
74 kind: &'static str,
75 /// The expected length (typically the first collection's `len()`).
76 expected: usize,
77 /// The actual length (typically the second collection's `len()`).
78 found: usize,
79 },
80 /// The requested `k` exceeds the corpus size, so a `k`-nearest result
81 /// cannot be returned.
82 KExceedsCorpus {
83 /// The requested top-k count.
84 k: usize,
85 /// The number of vectors searchable in the index.
86 corpus_size: usize,
87 },
88 /// A required input collection was empty. `kind` names the collection
89 /// (e.g. `"queries"`, `"base"`, `"ground_truth"`).
90 EmptyInput {
91 /// Short, stable identifier for the empty input.
92 kind: &'static str,
93 },
94 /// A nested [`IqdbError`] surfaced from a downstream `IndexCore`
95 /// operation (typically [`iqdb_index::IndexCore::insert`] or
96 /// [`iqdb_index::IndexCore::search`]).
97 Search(IqdbError),
98 /// A `VectorId` shape that the harness cannot project to a `u32`
99 /// row-index was encountered while computing ground truth. The
100 /// convention is documented on [`crate::build_index_from_base`]:
101 /// callers must insert each base row at `VectorId::U64(row_index)`.
102 /// `found` is a short static identifier for the variant that was
103 /// actually returned (for example, `"VectorId::Bytes"`).
104 UnsupportedVectorId {
105 /// Short identifier for the `VectorId` variant that was returned.
106 found: &'static str,
107 },
108}
109
110impl std::fmt::Display for EvalError {
111 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112 match self {
113 Self::Io { path, source } => {
114 write!(f, "I/O error reading {}: {source}", path.display())
115 }
116 Self::Parse { path, reason } => {
117 write!(f, "parse error in {}: {reason}", path.display())
118 }
119 Self::DimensionMismatch { expected, found } => {
120 write!(
121 f,
122 "vector dimension mismatch: expected {expected}, found {found}",
123 )
124 }
125 Self::LengthMismatch {
126 kind,
127 expected,
128 found,
129 } => {
130 write!(
131 f,
132 "length mismatch ({kind}): expected {expected}, found {found}",
133 )
134 }
135 Self::KExceedsCorpus { k, corpus_size } => {
136 write!(f, "k exceeds corpus size: k={k}, corpus_size={corpus_size}")
137 }
138 Self::EmptyInput { kind } => write!(f, "empty input: {kind}"),
139 Self::Search(e) => write!(f, "search failed: {e}"),
140 Self::UnsupportedVectorId { found } => {
141 write!(f, "unsupported VectorId variant for ground truth: {found}")
142 }
143 }
144 }
145}
146
147impl std::error::Error for EvalError {
148 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
149 match self {
150 Self::Io { source, .. } => Some(source),
151 Self::Search(e) => Some(e),
152 _ => None,
153 }
154 }
155}
156
157impl ForgeError for EvalError {
158 fn kind(&self) -> &'static str {
159 match self {
160 Self::Io { .. } => "Io",
161 Self::Parse { .. } => "Parse",
162 Self::DimensionMismatch { .. } => "DimensionMismatch",
163 Self::LengthMismatch { .. } => "LengthMismatch",
164 Self::KExceedsCorpus { .. } => "KExceedsCorpus",
165 Self::EmptyInput { .. } => "EmptyInput",
166 Self::Search(_) => "Search",
167 Self::UnsupportedVectorId { .. } => "UnsupportedVectorId",
168 }
169 }
170
171 fn caption(&self) -> &'static str {
172 match self {
173 Self::Io { .. } => "OS-level I/O failure reading a dataset file",
174 Self::Parse { .. } => "dataset file could not be parsed",
175 Self::DimensionMismatch { .. } => "vector dimension does not match the index",
176 Self::LengthMismatch { .. } => "two collections that must share a length did not",
177 Self::KExceedsCorpus { .. } => "requested top-k exceeds the corpus size",
178 Self::EmptyInput { .. } => "a required input collection was empty",
179 Self::Search(_) => "a downstream index operation returned an error",
180 Self::UnsupportedVectorId { .. } => "ground truth requires VectorId::U64-shaped ids",
181 }
182 }
183}
184
185impl From<IqdbError> for EvalError {
186 fn from(value: IqdbError) -> Self {
187 Self::Search(value)
188 }
189}
190
191/// A specialized [`Result`](core::result::Result) whose error is [`EvalError`].
192///
193/// # Examples
194///
195/// ```
196/// use iqdb_eval::{EvalError, Result};
197///
198/// fn require_non_empty<T>(kind: &'static str, items: &[T]) -> Result<()> {
199/// if items.is_empty() {
200/// return Err(EvalError::EmptyInput { kind });
201/// }
202/// Ok(())
203/// }
204///
205/// assert!(require_non_empty::<u8>("queries", &[]).is_err());
206/// assert!(require_non_empty("queries", &[1u8, 2]).is_ok());
207/// ```
208pub type Result<T> = core::result::Result<T, EvalError>;