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 /// A CLOSED shell's trims enclose a vector area no pcurve fit at the
203 /// refinement floor accounts for (`shell_vector_areas`): two faces that
204 /// share an edge are trimmed at different curves, and the sliver between
205 /// them leaves the body's volume dependent on the integration reference.
206 /// Drawn by the STEP import's closure gate (`io/step_import/builder/closure.rs`).
207 VectorArea,
208}
209
210/// A MEASURED approximation a SUCCESSFUL result carries: the result stands,
211/// and this says by how much, and why, it is not exact. Distinct from a
212/// refusal (the result does not stand) and from a fulfilment (what was done of
213/// what was asked) — the typed-refusal plan's third kind of report.
214///
215/// The one producer today is the STEP import's shell-closure gate
216/// (`io/step_import/builder/closure.rs`): a body whose closed shell's trims
217/// enclose more vector area than the pcurve fit floor accounts for, but less
218/// than the size-relative cap that refuses it, is imported and carries this.
219/// `volume_bound` is what the measurement implies for the body's volume: an
220/// open shell's divergence integral reads `V − R·A/3` about a reference `R`,
221/// so two references inside the body disagree by up to `|A| × diagonal / 3`,
222/// which is the band inside which no oracle can say the volume is wrong.
223#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
224pub struct Approximation {
225 /// Stable code, e.g. `import.shell_closure`.
226 pub code: String,
227 /// The body it is about, by the name the consumer knows it under: the
228 /// import's file-order index until the feature names it, then the scene
229 /// name.
230 pub body: String,
231 /// The measured quantity (for `import.shell_closure`, the shell's vector
232 /// area residual, mm²) and the bar it is read against.
233 pub measured: f64,
234 pub bar: f64,
235 /// The bound the measurement puts on the body's VOLUME, mm³ — the
236 /// reference-lever term above. `None` when the code does not imply one.
237 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub volume_bound: Option<f64>,
239 /// The edges that carry the approximation, worst first, with their origin.
240 #[serde(default, skip_serializing_if = "Vec::is_empty")]
241 pub edges: Vec<OffCarrierEdge>,
242 /// Why a construction REQUEST went unmet (`blend.wall_model`), or why a
243 /// blend wall shipped from a rung that never met its acceptance bars
244 /// (`blend.wall_acceptance`), and the budget it spent. `None` for every
245 /// other code.
246 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub budget: Option<ApproximationBudget>,
248 /// The human text, verbatim.
249 pub message: String,
250}
251
252/// The spent budget of an unmet construction request, typed: why refinement
253/// stopped, and the rounds and stations it used against their limits.
254#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
255pub struct ApproximationBudget {
256 pub reason: BudgetReason,
257 /// The budget steps ATTEMPTED, of the kind `mechanism` names: local
258 /// refinement rounds (acceptance and request share them), or an open
259 /// edge's global station rungs (past the accepted rung for a request,
260 /// past the first rung for an unaccepted one). On `Rejected` this counts
261 /// the refused attempt, one past the rung that ships.
262 pub rounds_used: usize,
263 pub rounds_limit: usize,
264 /// The stations of the rung that SHIPS (a closed chain: the largest count
265 /// of any one segment), against the ceiling — never the refused attempt's.
266 pub stations: usize,
267 pub station_limit: usize,
268 /// What `rounds_used` / `rounds_limit` count.
269 #[serde(default)]
270 pub mechanism: BudgetMechanism,
271 /// What the approximation's `measured` value is the reading OF: the wall
272 /// between the rails (the default, what every report meant before), or
273 /// the support rails, when they are the worse part of a request that
274 /// covers both.
275 #[serde(default)]
276 pub measured_component: MeasuredComponent,
277 /// Intervals the request could NOT read. When non-zero the true worst
278 /// reading is UNKNOWN and `measured` is only the largest readable value;
279 /// no consumer may read it as the worst.
280 #[serde(default, skip_serializing_if = "is_zero")]
281 pub unread: usize,
282}
283
284fn is_zero(count: &usize) -> bool {
285 *count == 0
286}
287
288/// What a request report's `measured` value is the reading of.
289#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
290#[serde(rename_all = "snake_case")]
291pub enum MeasuredComponent {
292 /// The fillet wall between its rails, against the rolling ball's sweep.
293 #[default]
294 Wall,
295 /// The support rails (the shipped support curves), against their exact
296 /// construction: their carriers or the exact ball contacts.
297 Rails,
298}
299
300/// The budget mechanism an [`ApproximationBudget`] counts.
301#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
302#[serde(rename_all = "snake_case")]
303pub enum BudgetMechanism {
304 /// Local refinement rounds inside one rung (a chain's or closed edge's
305 /// wall request).
306 #[default]
307 LocalRounds,
308 /// Whole-march station rungs (an open edge's ladder: the uniform station
309 /// count doubling, each rung with its own local rail refinement, which
310 /// these counts do not include).
311 StationRungs,
312}
313
314/// Why a construction request stopped short of what it asked.
315#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
316#[serde(rename_all = "snake_case")]
317pub enum BudgetReason {
318 /// Every refinement round was spent.
319 RoundsSpent,
320 /// Another round would pass the per-segment station ceiling.
321 StationCeiling,
322 /// A refining station could not be placed coherently (it did not reach
323 /// its retained neighbour's branch, or its continuation did not converge),
324 /// so the last accepted rung stands.
325 Incoherent,
326 /// The refined rung was built and then failed its own acceptance or
327 /// refused, so the last accepted rung stands.
328 Rejected,
329 /// No rung met its acceptance bars; the top rung ships as built
330 /// (`blend.wall_acceptance`, its worst reading against its bar).
331 Unaccepted,
332 /// No rung was accepted and a reading acceptance needs could not be taken
333 /// (non-finite, or no shipped coverage); the top rung ships as built.
334 Unread,
335}
336
337/// One edge whose built curve stands off one of its carriers, as an
338/// [`Approximation`] names it.
339#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
340pub struct OffCarrierEdge {
341 /// The built edge's id in the body.
342 pub edge_id: u64,
343 /// The file's `EDGE_CURVE.edge_geometry` entity, when the edge has one.
344 #[serde(default, skip_serializing_if = "Option::is_none")]
345 pub curve_ref: Option<usize>,
346 /// The `ADVANCED_FACE` and surface entities of the carrier it stands off.
347 pub face_ref: usize,
348 pub surface_ref: usize,
349 /// How far, mm, over the gate's stations (an upper bound: the projector's
350 /// distance never under-reads).
351 pub off_carrier_mm: f64,
352 /// Whose curve stands off: the FILE's, as written, or one an importer
353 /// pass moved (`reconciled`: `reconcile_edges_onto_surfaces` replaced it
354 /// with its projection onto a different carrier).
355 pub origin: EdgeOrigin,
356}
357
358#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
359#[serde(rename_all = "snake_case")]
360pub enum EdgeOrigin {
361 File,
362 Reconciled,
363}
364
365impl RefusalClass {
366 /// The backing of every class, exhaustively: a variant added without a
367 /// row here does not compile, so no class is retryable or not "by whoever
368 /// remembers to edit the list".
369 pub fn backing(&self) -> Backing {
370 use UndecidedCause::*;
371 match self {
372 Self::DegenerateArrangement { .. }
373 | Self::NonIntegralGenus { .. }
374 | Self::NonPositiveVolume
375 | Self::TangentNodeSingularity
376 | Self::InvalidResultTopology { .. } => Backing::Undecided { cause: Degeneracy },
377 Self::ConservativeEmptyOverlap => Backing::Undecided { cause: Contact },
378 Self::NonConvergence { .. } => Backing::Undecided { cause: Budget },
379 Self::Internal { .. } | Self::UnsoundResult { .. } => {
380 Backing::Undecided { cause: Defect }
381 }
382 Self::UnsupportedGeometry { .. }
383 | Self::InvalidInput { .. }
384 | Self::IllPosed { .. } => Backing::Refuted,
385 }
386 }
387
388 /// Whether the boolean's perturbation retry may act on this refusal: an
389 /// UNDECIDED refusal whose cause is an arrangement degeneracy, read off
390 /// [`RefusalClass::backing`]. The resulting set is pinned by
391 /// `refusal_retry_parity` against the former substring matcher and by
392 /// `backing_reproduces_the_retry_set` below; moving a class between
393 /// backings is a deliberate change.
394 pub fn perturbation_eligible(&self) -> bool {
395 matches!(
396 self.backing(),
397 Backing::Undecided {
398 cause: UndecidedCause::Degeneracy
399 }
400 )
401 }
402
403 /// The snake_case tag serde emits — the name CI baselines and logs use.
404 pub fn tag(&self) -> &'static str {
405 match self {
406 Self::DegenerateArrangement { .. } => "degenerate_arrangement",
407 Self::NonIntegralGenus { .. } => "non_integral_genus",
408 Self::NonPositiveVolume => "non_positive_volume",
409 Self::TangentNodeSingularity => "tangent_node_singularity",
410 Self::InvalidResultTopology { .. } => "invalid_result_topology",
411 Self::ConservativeEmptyOverlap => "conservative_empty_overlap",
412 Self::NonConvergence { .. } => "non_convergence",
413 Self::UnsupportedGeometry { .. } => "unsupported_geometry",
414 Self::InvalidInput { .. } => "invalid_input",
415 Self::IllPosed { .. } => "ill_posed",
416 Self::UnsoundResult { .. } => "unsound_result",
417 Self::Internal { .. } => "internal",
418 }
419 }
420}
421
422impl KernelRefusal {
423 pub fn new(class: RefusalClass, stage: KernelStage, message: impl Into<String>) -> Self {
424 Self {
425 class,
426 stage,
427 message: message.into(),
428 }
429 }
430
431 /// An internal consistency failure. `what` is a short stable slug (the
432 /// check that tripped), the message the full text.
433 pub fn internal(
434 stage: KernelStage,
435 what: impl Into<String>,
436 message: impl Into<String>,
437 ) -> Self {
438 Self::new(RefusalClass::Internal { what: what.into() }, stage, message)
439 }
440
441 /// A caller error.
442 pub fn input(stage: KernelStage, what: impl Into<String>, message: impl Into<String>) -> Self {
443 Self::new(
444 RefusalClass::InvalidInput { what: what.into() },
445 stage,
446 message,
447 )
448 }
449
450 /// A named deferral.
451 pub fn unsupported(
452 stage: KernelStage,
453 what: impl Into<String>,
454 message: impl Into<String>,
455 ) -> Self {
456 Self::new(
457 RefusalClass::UnsupportedGeometry { what: what.into() },
458 stage,
459 message,
460 )
461 }
462
463 /// An operation that, as specified, has no single answer.
464 pub fn ill_posed(
465 stage: KernelStage,
466 what: impl Into<String>,
467 message: impl Into<String>,
468 ) -> Self {
469 Self::new(RefusalClass::IllPosed { what: what.into() }, stage, message)
470 }
471
472 /// A result the soundness scan convicted.
473 pub fn unsound(
474 stage: KernelStage,
475 defect: SoundnessDefect,
476 faces: Vec<u64>,
477 message: impl Into<String>,
478 ) -> Self {
479 Self::new(RefusalClass::UnsoundResult { defect, faces }, stage, message)
480 }
481
482 /// An iterative lane that ran out of budget.
483 pub fn non_convergence(
484 stage: KernelStage,
485 what: impl Into<String>,
486 message: impl Into<String>,
487 ) -> Self {
488 Self::new(
489 RefusalClass::NonConvergence { what: what.into() },
490 stage,
491 message,
492 )
493 }
494
495 /// The same refusal with its message rewritten by `f` — the way a wrapping
496 /// site adds context ("assembly failed: …") WITHOUT changing the class, so
497 /// an inner degeneracy stays retry-eligible through every wrapper exactly
498 /// as the substring matcher saw it in the wrapped text.
499 pub fn with_message(mut self, f: impl FnOnce(&str) -> String) -> Self {
500 self.message = f(&self.message);
501 self
502 }
503}
504
505impl std::fmt::Display for KernelRefusal {
506 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
507 f.write_str(&self.message)
508 }
509}
510
511impl std::error::Error for KernelRefusal {}
512
513/// The lossy exit for stringly callers: the class is dropped, the text kept.
514/// Legal only ABOVE the typed stacks (never inside `csg/` or the healing
515/// modules that mint refusals); each stack that adopts `KernelRefusal`
516/// removes its uses.
517impl From<KernelRefusal> for String {
518 fn from(refusal: KernelRefusal) -> Self {
519 refusal.message
520 }
521}
522
523/// Mechanical conversion of a stringly LOWER-layer error consumed inside a
524/// typed stack (geometry evaluation, tolerance policy checks): the text is
525/// kept and the refusal is classed `Internal` under `what` at `stage`. This is
526/// a constructor at the consuming site, not a blanket `From`.
527pub trait OrRefuse<T> {
528 fn or_refuse(self, stage: KernelStage, what: &'static str) -> Result<T, KernelRefusal>;
529 fn or_input(self, stage: KernelStage, what: &'static str) -> Result<T, KernelRefusal>;
530}
531
532impl<T> OrRefuse<T> for Result<T, String> {
533 fn or_refuse(self, stage: KernelStage, what: &'static str) -> Result<T, KernelRefusal> {
534 self.map_err(|message| KernelRefusal::internal(stage, what, message))
535 }
536
537 fn or_input(self, stage: KernelStage, what: &'static str) -> Result<T, KernelRefusal> {
538 self.map_err(|message| KernelRefusal::input(stage, what, message))
539 }
540}
541
542#[derive(Clone, Debug, Deserialize, Serialize)]
543pub struct KernelOutcome<T> {
544 pub value: T,
545 pub diagnostics: KernelDiagnostics,
546}
547