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/// because their [`Backing`] is undecided by a degeneracy
111/// ([`RefusalClass::backing`], [`RefusalClass::perturbation_eligible`]). The
112/// rest are honest refusals that a perturbation cannot and must not "fix".
113#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
114#[serde(tag = "class", rename_all = "snake_case")]
115pub enum RefusalClass {
116    /// The assembled shell has open edges / invalid topology (the arrangement
117    /// left a boundary unclosed).
118    DegenerateArrangement { open_edges: u32, issues: u32 },
119    /// Euler characteristic did not yield an integral genus.
120    NonIntegralGenus { shells: u32, euler: i64 },
121    /// The assembled solid's signed volume is not positive.
122    NonPositiveVolume,
123    /// A singular / tangent-node surface intersection the marcher declines.
124    TangentNodeSingularity,
125    /// The FINAL validation of the result found topology issues.
126    InvalidResultTopology { issues: u32 },
127    /// The operation produced no boundary faces while contact / graze
128    /// evidence exists — refusing rather than blessing an empty result.
129    ConservativeEmptyOverlap,
130    /// An iterative lane (edge conformance, an SSI march, a Newton polish) did
131    /// not converge within its budget.
132    NonConvergence { what: String },
133    /// A named deferral: geometry the kernel does not model yet.
134    UnsupportedGeometry { what: String },
135    /// A caller error: bad ids, non-finite parameters, an unusable policy.
136    InvalidInput { what: String },
137    /// The operation, as specified, has no single answer and the input does
138    /// not pick one: a turned face part of which moves into the body and part
139    /// out, a carrier whose side the geometry does not decide, several drops
140    /// of a crossing that all rebuild sound. Refusing rather than choosing;
141    /// `what` is the origin's stable slug.
142    IllPosed { what: String },
143    /// The soundness scan convicted the RESULT a lane built — a face folded
144    /// through itself, two faces crossing, a hole loop that left its face —
145    /// and the repair that owns the defect declined it. Geometry, not
146    /// incidence: `validate()`'s verdict is [`Self::InvalidResultTopology`].
147    UnsoundResult {
148        defect: SoundnessDefect,
149        faces: Vec<u64>,
150    },
151    /// The long tail — an internal consistency check tripped. Curated
152    /// batteries assert zero of these among their expected refusals.
153    Internal { what: String },
154}
155
156/// What a refusal's evidence establishes about the claim every refusal
157/// judges: "this input has a result the kernel can certify". A refusal is
158/// either REFUTED — a witness in the input itself disproves the claim, and no
159/// re-run of the construction can change that — or UNDECIDED — the
160/// construction failed to certify and the input has not been shown to be at
161/// fault. Retry policy reads this, never a list of variants: a refuted claim
162/// is never retried, and only one undecided cause is one a perturbation
163/// addresses ([`UndecidedCause::Degeneracy`]).
164#[derive(Clone, Copy, Debug, Eq, PartialEq)]
165pub enum Backing {
166    /// The input refutes the claim: a bad parameter, geometry the kernel does
167    /// not model, an operation that as specified has no single answer.
168    Refuted,
169    /// The available evidence decides nothing; `cause` names what was missing.
170    Undecided { cause: UndecidedCause },
171}
172
173/// Why an undecided refusal is undecided — what the evidence lacked. The
174/// boolean's Simulation-of-Simplicity retry acts on exactly one of these.
175#[derive(Clone, Copy, Debug, Eq, PartialEq)]
176pub enum UndecidedCause {
177    /// A coincident or near-tangent carrier pair made the exact arrangement
178    /// structurally inconsistent. A rigid perturbation of one operand lifts the
179    /// coincidence, so this is the ONE cause a retry addresses.
180    Degeneracy,
181    /// Contact / graze evidence with no boundary faces. Undecided, but a nudge
182    /// would MANUFACTURE the answer (a sliver, or nothing) rather than reveal
183    /// it, so it is never retried.
184    Contact,
185    /// An iterative lane ran out of budget.
186    Budget,
187    /// An internal consistency check tripped — a defect, kept visible rather
188    /// than retried.
189    Defect,
190}
191
192/// What the soundness scan found in a result it refused.
193#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
194#[serde(rename_all = "snake_case")]
195pub enum SoundnessDefect {
196    /// One face carries two points of its own domain to the same 3D point.
197    Fold,
198    /// Two faces of the shell cross each other.
199    Crossing,
200    /// A hole loop is no longer contained by the face that carries it.
201    HoleBreach,
202}
203
204impl RefusalClass {
205    /// The backing of every class, exhaustively: a variant added without a
206    /// row here does not compile, so no class is retryable or not "by whoever
207    /// remembers to edit the list".
208    pub fn backing(&self) -> Backing {
209        use UndecidedCause::*;
210        match self {
211            Self::DegenerateArrangement { .. }
212            | Self::NonIntegralGenus { .. }
213            | Self::NonPositiveVolume
214            | Self::TangentNodeSingularity
215            | Self::InvalidResultTopology { .. } => Backing::Undecided { cause: Degeneracy },
216            Self::ConservativeEmptyOverlap => Backing::Undecided { cause: Contact },
217            Self::NonConvergence { .. } => Backing::Undecided { cause: Budget },
218            Self::Internal { .. } | Self::UnsoundResult { .. } => {
219                Backing::Undecided { cause: Defect }
220            }
221            Self::UnsupportedGeometry { .. }
222            | Self::InvalidInput { .. }
223            | Self::IllPosed { .. } => Backing::Refuted,
224        }
225    }
226
227    /// Whether the boolean's perturbation retry may act on this refusal: an
228    /// UNDECIDED refusal whose cause is an arrangement degeneracy, read off
229    /// [`RefusalClass::backing`]. The resulting set is pinned by
230    /// `refusal_retry_parity` against the former substring matcher and by
231    /// `backing_reproduces_the_retry_set` below; moving a class between
232    /// backings is a deliberate change.
233    pub fn perturbation_eligible(&self) -> bool {
234        matches!(
235            self.backing(),
236            Backing::Undecided {
237                cause: UndecidedCause::Degeneracy
238            }
239        )
240    }
241
242    /// The snake_case tag serde emits — the name CI baselines and logs use.
243    pub fn tag(&self) -> &'static str {
244        match self {
245            Self::DegenerateArrangement { .. } => "degenerate_arrangement",
246            Self::NonIntegralGenus { .. } => "non_integral_genus",
247            Self::NonPositiveVolume => "non_positive_volume",
248            Self::TangentNodeSingularity => "tangent_node_singularity",
249            Self::InvalidResultTopology { .. } => "invalid_result_topology",
250            Self::ConservativeEmptyOverlap => "conservative_empty_overlap",
251            Self::NonConvergence { .. } => "non_convergence",
252            Self::UnsupportedGeometry { .. } => "unsupported_geometry",
253            Self::InvalidInput { .. } => "invalid_input",
254            Self::IllPosed { .. } => "ill_posed",
255            Self::UnsoundResult { .. } => "unsound_result",
256            Self::Internal { .. } => "internal",
257        }
258    }
259}
260
261impl KernelRefusal {
262    pub fn new(class: RefusalClass, stage: KernelStage, message: impl Into<String>) -> Self {
263        Self {
264            class,
265            stage,
266            message: message.into(),
267        }
268    }
269
270    /// An internal consistency failure. `what` is a short stable slug (the
271    /// check that tripped), the message the full text.
272    pub fn internal(
273        stage: KernelStage,
274        what: impl Into<String>,
275        message: impl Into<String>,
276    ) -> Self {
277        Self::new(RefusalClass::Internal { what: what.into() }, stage, message)
278    }
279
280    /// A caller error.
281    pub fn input(stage: KernelStage, what: impl Into<String>, message: impl Into<String>) -> Self {
282        Self::new(
283            RefusalClass::InvalidInput { what: what.into() },
284            stage,
285            message,
286        )
287    }
288
289    /// A named deferral.
290    pub fn unsupported(
291        stage: KernelStage,
292        what: impl Into<String>,
293        message: impl Into<String>,
294    ) -> Self {
295        Self::new(
296            RefusalClass::UnsupportedGeometry { what: what.into() },
297            stage,
298            message,
299        )
300    }
301
302    /// An operation that, as specified, has no single answer.
303    pub fn ill_posed(
304        stage: KernelStage,
305        what: impl Into<String>,
306        message: impl Into<String>,
307    ) -> Self {
308        Self::new(RefusalClass::IllPosed { what: what.into() }, stage, message)
309    }
310
311    /// A result the soundness scan convicted.
312    pub fn unsound(
313        stage: KernelStage,
314        defect: SoundnessDefect,
315        faces: Vec<u64>,
316        message: impl Into<String>,
317    ) -> Self {
318        Self::new(RefusalClass::UnsoundResult { defect, faces }, stage, message)
319    }
320
321    /// An iterative lane that ran out of budget.
322    pub fn non_convergence(
323        stage: KernelStage,
324        what: impl Into<String>,
325        message: impl Into<String>,
326    ) -> Self {
327        Self::new(
328            RefusalClass::NonConvergence { what: what.into() },
329            stage,
330            message,
331        )
332    }
333
334    /// The same refusal with its message rewritten by `f` — the way a wrapping
335    /// site adds context ("assembly failed: …") WITHOUT changing the class, so
336    /// an inner degeneracy stays retry-eligible through every wrapper exactly
337    /// as the substring matcher saw it in the wrapped text.
338    pub fn with_message(mut self, f: impl FnOnce(&str) -> String) -> Self {
339        self.message = f(&self.message);
340        self
341    }
342}
343
344impl std::fmt::Display for KernelRefusal {
345    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
346        f.write_str(&self.message)
347    }
348}
349
350impl std::error::Error for KernelRefusal {}
351
352/// The lossy exit for stringly callers: the class is dropped, the text kept.
353/// Legal only ABOVE the typed stacks (never inside `csg/` or the healing
354/// modules that mint refusals); each stack that adopts `KernelRefusal`
355/// removes its uses.
356impl From<KernelRefusal> for String {
357    fn from(refusal: KernelRefusal) -> Self {
358        refusal.message
359    }
360}
361
362/// Mechanical conversion of a stringly LOWER-layer error consumed inside a
363/// typed stack (geometry evaluation, tolerance policy checks): the text is
364/// kept and the refusal is classed `Internal` under `what` at `stage`. This is
365/// a constructor at the consuming site, not a blanket `From`.
366pub trait OrRefuse<T> {
367    fn or_refuse(self, stage: KernelStage, what: &'static str) -> Result<T, KernelRefusal>;
368    fn or_input(self, stage: KernelStage, what: &'static str) -> Result<T, KernelRefusal>;
369}
370
371impl<T> OrRefuse<T> for Result<T, String> {
372    fn or_refuse(self, stage: KernelStage, what: &'static str) -> Result<T, KernelRefusal> {
373        self.map_err(|message| KernelRefusal::internal(stage, what, message))
374    }
375
376    fn or_input(self, stage: KernelStage, what: &'static str) -> Result<T, KernelRefusal> {
377        self.map_err(|message| KernelRefusal::input(stage, what, message))
378    }
379}
380
381#[derive(Clone, Debug, Deserialize, Serialize)]
382pub struct KernelOutcome<T> {
383    pub value: T,
384    pub diagnostics: KernelDiagnostics,
385}
386