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    /// 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