Skip to main content

csp_solver/
error.rs

1//! Unified error family for the solver's public API surface.
2//!
3//! Reconciled from grand-audit Pass 2 prototype 14 (`api-error-taxonomy`)
4//! onto the Pass-3 composed tree (Pass 3 `py-module-reconciliation`, T9).
5//!
6//! Before this, the crate had two independent, incomplete error shapes:
7//! `Unsatisfiable` (a bare unit struct, `lib.rs`) and `AssignmentError`
8//! (`builder/assignment.rs`, five variants scoped to the assignment-problem
9//! builder only, and — per the Pass-1 `rust-cop-builder` finding —
10//! conflating budget-exhaustion with genuine infeasibility). Every
11//! downstream binding (`py/`, wasm's `isomorphic.rs`) re-derived its own ad
12//! hoc mapping of "something went wrong" onto a bare
13//! `PyRuntimeError`/`JsError` string, which is exactly the "silent/conflated
14//! handling" the audit's fail-explicit precept bans (Pass-1 ledger R15, B0).
15//!
16//! `CspError` is the one family every layer should map 1:1 rather than
17//! reinvent: PyO3 via `create_exception!` (`py/errors.rs`), wasm via a typed
18//! `WasmCspError` that stamps a `.code` onto a genuine `Error` instance
19//! (`wasm/src/errors.rs`, not reconciled in this pass — see report), and the
20//! FastAPI JSON error envelope (`web/api/src/app/core/errors.py`, likewise
21//! not reconciled here) via the *Python* exception classes that `py/`
22//! raises. `code()` is the one string all downstream layers agree on.
23
24use std::fmt;
25
26/// Unified error family for `Csp`/Sudoku/Futoshiki operations exposed
27/// across the PyO3 and wasm boundaries.
28///
29/// Deliberately flat (no nested `Box<dyn Error>` source chaining) — every
30/// downstream binding needs to pattern-match this by value to pick an
31/// exception class / HTTP status, and a flat enum is the cheapest thing
32/// that supports that without reflection.
33#[derive(Debug, Clone, PartialEq)]
34pub enum CspError {
35    /// No solution exists under the current constraints/propagation.
36    /// Maps from the crate's existing [`crate::Unsatisfiable`] marker (kept
37    /// as the internal propagation-layer type; this is the binding-facing
38    /// supertype).
39    Unsatisfiable,
40    /// The search aborted after [`crate::SolveConfig::node_budget`] nodes;
41    /// any solutions returned are best-so-far, not verified optimal or
42    /// exhaustive. Distinct from `Unsatisfiable` — conflating the two was
43    /// Pass-1 finding R8/F12 (`AssignmentBuilder` and `SudokuCSP` both did
44    /// this).
45    BudgetExceeded,
46    /// A caller-supplied argument was structurally invalid: an
47    /// out-of-range position, a malformed numeric key, an unknown enum
48    /// value, mismatched dimensions, etc. Carries a human-readable detail
49    /// string (never leaked Rust-internal panic/debug detail — callers
50    /// construct this variant explicitly, it is never built from a caught
51    /// panic).
52    InvalidInput {
53        /// What was wrong, safe to surface to an API client verbatim.
54        detail: String,
55    },
56    /// A wall-clock deadline elapsed before the search produced a result.
57    /// Distinct from `BudgetExceeded` (a node-count budget, checked at the
58    /// same cadence but triggered by *count* not *time*). Forward-declared
59    /// for the wall-clock `time_budget` deferred item (synthesis-pass1 §3.1
60    /// N11) — the cooperative cancellation flag a caller's HTTP-layer
61    /// timeout should set, rather than the FastAPI `asyncio.wait_for`
62    /// theater that today cancels only the awaiting coroutine, never the
63    /// search (Pass-1 `fastapi-service` F3 / `pyo3-boundary`).
64    Timeout,
65}
66
67impl fmt::Display for CspError {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        match self {
70            Self::Unsatisfiable => write!(f, "no solution exists under the given constraints"),
71            Self::BudgetExceeded => {
72                write!(
73                    f,
74                    "search exceeded its node budget before finding a solution"
75                )
76            }
77            Self::InvalidInput { detail } => write!(f, "invalid input: {detail}"),
78            Self::Timeout => write!(f, "search exceeded its wall-clock deadline"),
79        }
80    }
81}
82
83impl std::error::Error for CspError {}
84
85impl CspError {
86    /// Stable machine-readable discriminant. This exact string is what
87    /// `py/errors.rs`'s exception class *names* are derived from, what a
88    /// wasm `CspJsError.code` would carry across that boundary, and what a
89    /// FastAPI JSON error envelope's `error.code` field would be — the
90    /// single vocabulary every layer of the taxonomy shares.
91    pub const fn code(&self) -> &'static str {
92        match self {
93            Self::Unsatisfiable => "UNSATISFIABLE",
94            Self::BudgetExceeded => "BUDGET_EXCEEDED",
95            Self::InvalidInput { .. } => "INVALID_INPUT",
96            Self::Timeout => "TIMEOUT",
97        }
98    }
99
100    /// Convenience constructor — the one place `format!` call sites
101    /// collapse into, instead of each binding hand-rolling its own
102    /// `PyValueError::new_err(format!(...))` / `JsError::new(&format!(...))`.
103    pub fn invalid_input(detail: impl Into<String>) -> Self {
104        Self::InvalidInput {
105            detail: detail.into(),
106        }
107    }
108}
109
110/// The propagation layer's existing marker type converts losslessly — every
111/// current `?`-using call site (`Csp::propagate`, `Csp::propagate_with`)
112/// keeps compiling unchanged; only the bindings need to switch their
113/// `map_err` target.
114impl From<crate::Unsatisfiable> for CspError {
115    fn from(_: crate::Unsatisfiable) -> Self {
116        CspError::Unsatisfiable
117    }
118}
119
120/// `AssignmentBuilder`'s error family converts too, so `py/`/wasm call
121/// sites that touch the assignment/COP path get the same typed exceptions
122/// as the Sudoku path for free. Note this conversion is itself evidence of
123/// Pass-1 finding R8: `Infeasible` collapses onto `Unsatisfiable` here
124/// because `AssignmentError` has no distinct budget-exhaustion variant yet
125/// — fixing that is a separate, already ledgered item, not silently patched
126/// over by this mapping.
127impl From<crate::AssignmentError> for CspError {
128    fn from(e: crate::AssignmentError) -> Self {
129        match e {
130            crate::AssignmentError::Infeasible => CspError::Unsatisfiable,
131            other => CspError::invalid_input(other.to_string()),
132        }
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn every_variant_has_a_stable_code() {
142        assert_eq!(CspError::Unsatisfiable.code(), "UNSATISFIABLE");
143        assert_eq!(CspError::BudgetExceeded.code(), "BUDGET_EXCEEDED");
144        assert_eq!(CspError::invalid_input("x").code(), "INVALID_INPUT");
145        assert_eq!(CspError::Timeout.code(), "TIMEOUT");
146    }
147
148    #[test]
149    fn unsatisfiable_marker_converts() {
150        let e: CspError = crate::Unsatisfiable.into();
151        assert_eq!(e, CspError::Unsatisfiable);
152    }
153}