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