Skip to main content

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>;