Skip to main content

brep_kernel/props/
diagnostics.rs

1use serde::{Deserialize, Serialize};
2use std::collections::BTreeMap;
3
4#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
5#[serde(rename_all = "lowercase")]
6pub enum DiagnosticSeverity {
7    Info,
8    Warning,
9    Degraded,
10    Error,
11}
12
13#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
14#[serde(rename_all = "snake_case")]
15pub enum KernelStage {
16    Collect,
17    Classify,
18    Intersect,
19    Refine,
20    Fragment,
21    Select,
22    Sew,
23    Validate,
24    Export,
25}
26
27#[derive(Clone, Debug, Deserialize, Serialize)]
28pub struct DiagnosticEvent {
29    pub severity: DiagnosticSeverity,
30    pub stage: KernelStage,
31    /// Stable machine-readable identifier suitable for tests and UI routing.
32    pub code: String,
33    pub message: String,
34}
35
36#[derive(Clone, Debug, Default, Deserialize, Serialize)]
37pub struct KernelDiagnostics {
38    pub events: Vec<DiagnosticEvent>,
39    pub counters: BTreeMap<String, u64>,
40    pub measurements: BTreeMap<String, f64>,
41}
42
43impl KernelDiagnostics {
44    pub fn event(
45        &mut self,
46        severity: DiagnosticSeverity,
47        stage: KernelStage,
48        code: impl Into<String>,
49        message: impl Into<String>,
50    ) {
51        self.events.push(DiagnosticEvent {
52            severity,
53            stage,
54            code: code.into(),
55            message: message.into(),
56        });
57    }
58
59    pub fn count(&mut self, code: impl Into<String>) {
60        *self.counters.entry(code.into()).or_default() += 1;
61    }
62
63    pub fn count_n(&mut self, code: impl Into<String>, amount: u64) {
64        *self.counters.entry(code.into()).or_default() += amount;
65    }
66
67    pub fn measure_max(&mut self, code: impl Into<String>, value: f64) {
68        let entry = self.measurements.entry(code.into()).or_insert(value);
69        *entry = entry.max(value);
70    }
71
72    pub fn worst(&self) -> Option<DiagnosticSeverity> {
73        self.events.iter().map(|event| event.severity).max()
74    }
75
76    pub fn shippable(&self) -> bool {
77        self.events
78            .iter()
79            .all(|event| event.severity < DiagnosticSeverity::Error)
80    }
81}
82
83/// A kernel REFUSAL: the fail-safe contract's carrier. The kernel never
84/// degrades a result; where it cannot certify one it refuses, and this is the
85/// refusal — a closed [`RefusalClass`] that consumers dispatch on (the boolean's
86/// perturbation-retry gate, the CI baseline's per-class diff), the stage that
87/// minted it, and the human text, which `Display` prints verbatim so every
88/// message a user or a test saw before typing is unchanged.
89///
90/// There is deliberately no `From<String>` for this type: a refusal is minted
91/// at its origin with a class, never re-derived from prose. The only string
92/// conversion is the lossy exit (`From<KernelRefusal> for String`) that
93/// stringly callers above the boolean stack still use; those callers are
94/// retired stack by stack (offset/fillet/heal, then the feature pipeline).
95#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
96pub struct KernelRefusal {
97    #[serde(flatten)]
98    pub class: RefusalClass,
99    pub stage: KernelStage,
100    /// The human text, verbatim. `Display` prints exactly this.
101    pub message: String,
102}
103
104/// The closed set of refusal classes. Dispatchers match on it exhaustively;
105/// adding a variant is a deliberate taxonomy change, never a message edit.
106///
107/// The first five are ARRANGEMENT DEGENERACIES — a coincident or near-tangent
108/// carrier pair made the exact arrangement structurally inconsistent — and are
109/// the only classes the boolean's Simulation-of-Simplicity retry may act on
110/// ([`RefusalClass::perturbation_eligible`]). The rest are honest refusals that
111/// a perturbation cannot and must not "fix".
112#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
113#[serde(tag = "class", rename_all = "snake_case")]
114pub enum RefusalClass {
115    /// The assembled shell has open edges / invalid topology (the arrangement
116    /// left a boundary unclosed).
117    DegenerateArrangement { open_edges: u32, issues: u32 },
118    /// Euler characteristic did not yield an integral genus.
119    NonIntegralGenus { shells: u32, euler: i64 },
120    /// The assembled solid's signed volume is not positive.
121    NonPositiveVolume,
122    /// A singular / tangent-node surface intersection the marcher declines.
123    TangentNodeSingularity,
124    /// The FINAL validation of the result found topology issues.
125    InvalidResultTopology { issues: u32 },
126    /// The operation produced no boundary faces while contact / graze
127    /// evidence exists — refusing rather than blessing an empty result.
128    ConservativeEmptyOverlap,
129    /// An iterative lane (edge conformance, an SSI march, a Newton polish) did
130    /// not converge within its budget.
131    NonConvergence { what: String },
132    /// A named deferral: geometry the kernel does not model yet.
133    UnsupportedGeometry { what: String },
134    /// A caller error: bad ids, non-finite parameters, an unusable policy.
135    InvalidInput { what: String },
136    /// The long tail — an internal consistency check tripped. Curated
137    /// batteries assert zero of these among their expected refusals.
138    Internal { what: String },
139}
140
141impl RefusalClass {
142    /// Whether the boolean's perturbation retry may act on this refusal. The
143    /// set is pinned by `refusal_retry_parity` against the former substring
144    /// matcher; widening or narrowing it is a deliberate change.
145    pub fn perturbation_eligible(&self) -> bool {
146        matches!(
147            self,
148            Self::DegenerateArrangement { .. }
149                | Self::NonIntegralGenus { .. }
150                | Self::NonPositiveVolume
151                | Self::TangentNodeSingularity
152                | Self::InvalidResultTopology { .. }
153        )
154    }
155
156    /// The snake_case tag serde emits — the name CI baselines and logs use.
157    pub fn tag(&self) -> &'static str {
158        match self {
159            Self::DegenerateArrangement { .. } => "degenerate_arrangement",
160            Self::NonIntegralGenus { .. } => "non_integral_genus",
161            Self::NonPositiveVolume => "non_positive_volume",
162            Self::TangentNodeSingularity => "tangent_node_singularity",
163            Self::InvalidResultTopology { .. } => "invalid_result_topology",
164            Self::ConservativeEmptyOverlap => "conservative_empty_overlap",
165            Self::NonConvergence { .. } => "non_convergence",
166            Self::UnsupportedGeometry { .. } => "unsupported_geometry",
167            Self::InvalidInput { .. } => "invalid_input",
168            Self::Internal { .. } => "internal",
169        }
170    }
171}
172
173impl KernelRefusal {
174    pub fn new(class: RefusalClass, stage: KernelStage, message: impl Into<String>) -> Self {
175        Self {
176            class,
177            stage,
178            message: message.into(),
179        }
180    }
181
182    /// An internal consistency failure. `what` is a short stable slug (the
183    /// check that tripped), the message the full text.
184    pub fn internal(
185        stage: KernelStage,
186        what: impl Into<String>,
187        message: impl Into<String>,
188    ) -> Self {
189        Self::new(RefusalClass::Internal { what: what.into() }, stage, message)
190    }
191
192    /// A caller error.
193    pub fn input(stage: KernelStage, what: impl Into<String>, message: impl Into<String>) -> Self {
194        Self::new(
195            RefusalClass::InvalidInput { what: what.into() },
196            stage,
197            message,
198        )
199    }
200
201    /// A named deferral.
202    pub fn unsupported(
203        stage: KernelStage,
204        what: impl Into<String>,
205        message: impl Into<String>,
206    ) -> Self {
207        Self::new(
208            RefusalClass::UnsupportedGeometry { what: what.into() },
209            stage,
210            message,
211        )
212    }
213
214    /// An iterative lane that ran out of budget.
215    pub fn non_convergence(
216        stage: KernelStage,
217        what: impl Into<String>,
218        message: impl Into<String>,
219    ) -> Self {
220        Self::new(
221            RefusalClass::NonConvergence { what: what.into() },
222            stage,
223            message,
224        )
225    }
226
227    /// The same refusal with its message rewritten by `f` — the way a wrapping
228    /// site adds context ("assembly failed: …") WITHOUT changing the class, so
229    /// an inner degeneracy stays retry-eligible through every wrapper exactly
230    /// as the substring matcher saw it in the wrapped text.
231    pub fn with_message(mut self, f: impl FnOnce(&str) -> String) -> Self {
232        self.message = f(&self.message);
233        self
234    }
235}
236
237impl std::fmt::Display for KernelRefusal {
238    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239        f.write_str(&self.message)
240    }
241}
242
243impl std::error::Error for KernelRefusal {}
244
245/// The lossy exit for stringly callers: the class is dropped, the text kept.
246/// Legal only ABOVE the typed stacks (never inside `csg/` or the healing
247/// modules that mint refusals); each stack that adopts `KernelRefusal`
248/// removes its uses.
249impl From<KernelRefusal> for String {
250    fn from(refusal: KernelRefusal) -> Self {
251        refusal.message
252    }
253}
254
255/// Mechanical conversion of a stringly LOWER-layer error consumed inside a
256/// typed stack (geometry evaluation, tolerance policy checks): the text is
257/// kept and the refusal is classed `Internal` under `what` at `stage`. This is
258/// a constructor at the consuming site, not a blanket `From`.
259pub trait OrRefuse<T> {
260    fn or_refuse(self, stage: KernelStage, what: &'static str) -> Result<T, KernelRefusal>;
261    fn or_input(self, stage: KernelStage, what: &'static str) -> Result<T, KernelRefusal>;
262}
263
264impl<T> OrRefuse<T> for Result<T, String> {
265    fn or_refuse(self, stage: KernelStage, what: &'static str) -> Result<T, KernelRefusal> {
266        self.map_err(|message| KernelRefusal::internal(stage, what, message))
267    }
268
269    fn or_input(self, stage: KernelStage, what: &'static str) -> Result<T, KernelRefusal> {
270        self.map_err(|message| KernelRefusal::input(stage, what, message))
271    }
272}
273
274#[derive(Clone, Debug, Deserialize, Serialize)]
275pub struct KernelOutcome<T> {
276    pub value: T,
277    pub diagnostics: KernelDiagnostics,
278}
279
280// BREP private tests: 05fb87b52e128ef2