ifc_lite_geometry/router/
diagnostics.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Diagnostics / telemetry layer: opening-classification + host-opening + CSG
6//! failure accumulators drained by the wasm bindings and tests.
7
8use super::GeometryRouter;
9use crate::BoolFailure;
10use rustc_hash::FxHashMap;
11
12pub(crate) use super::diagnostics_recording::ClassificationKind;
13pub use super::diagnostics_recording::{
14    ClassificationStats, HostOpeningDiagnostic, OpeningDiagnostic, OpeningKindDiag,
15};
16
17impl GeometryRouter {
18    /// Internal: tag the per-host diagnostic with the failure summary for
19    /// this host. Drained from `ClippingProcessor::take_failures` after
20    /// `apply_void_context` finishes.
21    ///
22    /// Kept here (rather than alongside the rest of the recording API in
23    /// `diagnostics_recording.rs`) because its `BoolFailureReason` match is
24    /// the part of this file most likely to need a new arm alongside a new
25    /// failure reason — keeping it next to `HostOpeningDiagnostic` above
26    /// avoids that touching two files.
27    pub(crate) fn record_host_failure_summary(&self, host_id: u32, failures: &[BoolFailure]) {
28        if failures.is_empty() {
29            return;
30        }
31        let mut log = self.host_opening_diagnostics.borrow_mut();
32        let entry = log.entry(host_id).or_default();
33        entry.csg_failure_count += failures.len();
34        if entry.first_failure_label.is_none() {
35            // Short label for at-a-glance grouping, from the ONE home shared
36            // with the wasm console + native tracing summaries; a second copy
37            // of this match lived here, held in lockstep by prose alone.
38            let label = failures[0].reason.label();
39            entry.first_failure_label = Some(label.to_string());
40        }
41    }
42}
43
44// ───────────────────────── Public diagnostics contract ─────────────────────
45// A serializable, wasm-free aggregate of the CSG / opening diagnostics computed
46// during a geometry pass. Built by `aggregate_diagnostics` from drained router
47// data. Today it is wired on the wasm/viewer path (the @ifc-lite/geometry
48// `complete` event); native / server `ProcessingStats` parity reuses this same
49// wasm-free aggregator and is a follow-up. camelCase JSON for the TS contract.
50
51/// Opening-classifier outcome counts (rectangular / diagonal / non-rectangular).
52#[derive(Debug, Clone, Copy, Default, serde::Serialize, serde::Deserialize)]
53#[serde(rename_all = "camelCase")]
54pub struct ClassificationSummary {
55    pub rectangular: u64,
56    pub diagonal: u64,
57    pub non_rectangular: u64,
58    pub total: u64,
59}
60
61/// One CSG failure reason and its occurrence count this pass. `reason` is one of
62/// the stable [`crate::diagnostics::BoolFailureReason::label`] strings.
63#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
64#[serde(rename_all = "camelCase")]
65pub struct ReasonCount {
66    pub reason: String,
67    pub count: u64,
68}
69
70/// rect_fast fast-path engagement counters (perf observability).
71#[derive(Debug, Clone, Copy, Default, serde::Serialize, serde::Deserialize)]
72#[serde(rename_all = "camelCase")]
73pub struct RectFastSummary {
74    pub fired: u64,
75    pub openings_cut: u64,
76    pub defer_host_not_box: u64,
77    pub defer_not_through: u64,
78    pub defer_off_face: u64,
79    pub defer_near_edge: u64,
80    pub defer_no_openings: u64,
81    pub defer_too_many_openings: u64,
82}
83
84/// Axis-aligned bounding box of a worst-failing host's mesh, world coords
85/// (post void-subtraction when a cut ran). Mirrors the `{min, max}` shape the
86/// rest of the geometry contract already uses for AABBs (see
87/// `packages/geometry/src/types.ts` `MeshData.localBounds`), so TS consumers
88/// don't need a second bbox convention.
89#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
90#[serde(rename_all = "camelCase")]
91pub struct HostBbox {
92    pub min: [f32; 3],
93    pub max: [f32; 3],
94}
95
96/// One of the worst-failing host elements (bounded top-N, opt-in detail).
97#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
98#[serde(rename_all = "camelCase")]
99pub struct WorstHost {
100    pub product_id: u32,
101    pub ifc_type: String,
102    pub openings: u64,
103    pub csg_failures: u64,
104    /// SKIPPED WHEN `None`, like the two fields below. The TypeScript mirrors
105    /// declare these as `field?: T`, which means the key is ABSENT; a plain
106    /// `Option` writes an explicit `null` through `serde_json` (the server
107    /// response path), and a consumer that guards with `!== undefined` then
108    /// throws on it. `serde_wasm_bindgen` writes `None` as `undefined`, so the
109    /// wasm boundary hid the difference. Deserialization is unaffected: a
110    /// missing key and an explicit `null` both read back as `None`.
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub first_failure_label: Option<String>,
113    /// World-space AABB of the host mesh, when captured by
114    /// `record_host_cut_effect` (opt-in per-product detail, #C1). `None` when
115    /// no void cut touched this host (e.g. the failure came from a
116    /// non-router CSG path).
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub bbox: Option<HostBbox>,
119    /// Final triangle count of the host's mesh: post-cut (`tris_after`) when a
120    /// void subtraction ran, falling back to the pre-cut count
121    /// (`tris_before`) when it didn't (the un-cut host is what actually
122    /// renders in that case). `None` when neither was captured.
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub triangle_count: Option<u64>,
125}
126
127/// Compatibility handshake for the [`GeometryDiagnostics`] contract, serialized
128/// as `schemaVersion`. DISTINCT from the viewer cache `FORMAT_VERSION` (an
129/// invalidation token): this is a promise consumers can gate on.
130///
131/// Bump discipline: bump on any field rename, field removal, or
132/// count-semantics change. Also bump when a field is additive but its absence
133/// is otherwise indistinguishable from a real zero, so consumers have
134/// something to gate on (`#[serde(default)]` deserializes both cases to 0).
135/// A purely additive optional field that no consumer gates on does NOT bump.
136///
137/// Changelog:
138/// - 1: initial versioned contract (the #1439 shape; a deserialized 0 means a
139///   pre-versioned producer).
140/// - 2: removed the permanently-dead `guard_saved` signal — every producer
141///   passed `false`, so `OpeningDiagnostic.guard_saved` and the
142///   `floor_opening_guard_saved` counter were always 0. Field removal, not
143///   just a rename, hence the bump.
144/// - 3: added `total_unsupported_items` / `unsupported_items_by_type` (previously uncounted drops).
145pub const GEOMETRY_DIAGNOSTICS_SCHEMA_VERSION: u32 = 3;
146
147/// Aggregate CSG / opening diagnostics for one geometry pass — the public
148/// diagnostics contract. Built by [`aggregate_diagnostics`] from drained router
149/// data and serialized to the @ifc-lite/geometry `complete` event, and reused
150/// verbatim by the native `ProcessingStats` path
151/// (`rust/processing/src/processor/mod.rs` populates `geometry_diagnostics`).
152/// wasm-free (serde only).
153#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
154#[serde(rename_all = "camelCase")]
155pub struct GeometryDiagnostics {
156    /// Contract version ([`GEOMETRY_DIAGNOSTICS_SCHEMA_VERSION`]); serialized
157    /// unconditionally so consumers can gate on it. Deserializes to 0 when the
158    /// producer predates versioning.
159    #[serde(default)]
160    pub schema_version: u32,
161    /// Total CSG boolean failures (un-cut openings, emptied hosts, fallbacks).
162    pub total_csg_failures: u64,
163    /// Distinct products (host elements) with at least one failure.
164    pub products_with_failures: u64,
165    /// Hosts that had openings processed.
166    pub hosts_with_openings: u64,
167    /// Opening-classifier outcome counts.
168    pub classification: ClassificationSummary,
169    /// Failure counts by stable reason label, sorted desc by count.
170    pub failures_by_reason: Vec<ReasonCount>,
171    /// Hosts where rectangular cutters ran but the triangle count was unchanged
172    /// (cut attempted, geometry not modified) — the highest-signal "looks wrong
173    /// but did not error" indicator.
174    pub silent_no_ops: u64,
175    /// rect_fast fast-path engagement.
176    pub rect_fast: RectFastSummary,
177    /// Bounded top-N worst-failing hosts (opt-in per-product detail).
178    pub worst_hosts: Vec<WorstHost>,
179    /// Refs the content-hash pass refused (`u32::MAX`, #3421/#3752). Additive.
180    #[serde(default)]
181    pub oversized_ref_drops: u64,
182    /// Items dropped: no processor, or the processor errored. Additive.
183    ///
184    /// Counted once per `IfcRepresentationMap` source PER ROUTER, not once per
185    /// `IfcMappedItem` occurrence. That is a model-wide "once" on the wasm path,
186    /// which walks a whole batch with one router. It is NOT model-wide on the
187    /// native pool: `process_entity_job` builds a fresh router per element, and a
188    /// source whose items all drop is refused by the shared mapped-item cache
189    /// (empty meshes are never published), so every element that instantiates it
190    /// walks it again and contributes its own count. Read the number as a lower
191    /// bound on affected types, never as a count of distinct sources or elements.
192    #[serde(default)]
193    pub total_unsupported_items: u64,
194    /// [`Self::total_unsupported_items`] broken down by IFC type, count-desc.
195    #[serde(default)]
196    pub unsupported_items_by_type: Vec<ReasonCount>,
197}
198
199impl Default for GeometryDiagnostics {
200    fn default() -> Self {
201        Self {
202            schema_version: GEOMETRY_DIAGNOSTICS_SCHEMA_VERSION,
203            total_csg_failures: 0,
204            products_with_failures: 0,
205            hosts_with_openings: 0,
206            classification: ClassificationSummary::default(),
207            failures_by_reason: Vec::new(),
208            silent_no_ops: 0,
209            rect_fast: RectFastSummary::default(),
210            worst_hosts: Vec::new(),
211            oversized_ref_drops: 0,
212            total_unsupported_items: 0,
213            unsupported_items_by_type: Vec::new(),
214        }
215    }
216}
217
218impl GeometryDiagnostics {
219    /// Whether any CSG failure, silent no-op, or dropped item was recorded.
220    pub fn has_issues(&self) -> bool {
221        self.total_csg_failures > 0 || self.silent_no_ops > 0 || self.total_unsupported_items > 0
222    }
223
224    /// Whether nothing diagnostic-worthy happened this pass — no openings
225    /// classified, no failures, no silent no-ops, no rect_fast activity. Callers
226    /// skip attaching an all-zero object so a consumer can gate on presence
227    /// (`if event.diagnostics`) as well as on counts.
228    pub fn is_empty(&self) -> bool {
229        self.total_csg_failures == 0
230            && self.hosts_with_openings == 0
231            && self.classification.total == 0
232            && self.silent_no_ops == 0
233            && self.rect_fast.fired == 0
234            && self.oversized_ref_drops == 0
235            && self.total_unsupported_items == 0
236    }
237}
238
239/// Build a [`GeometryDiagnostics`] from drained router data. wasm-free so both
240/// the wasm/viewer path and a future native path can produce the same contract.
241/// The caller owns draining: the router accessors are destructive (`mem::take`),
242/// so drain once and pass the results here — do not double-`take`.
243pub fn aggregate_diagnostics(
244    classification: ClassificationStats,
245    csg_failures: &FxHashMap<u32, Vec<BoolFailure>>,
246    host_diags: &FxHashMap<u32, HostOpeningDiagnostic>,
247    rect_fast: crate::rect_fast::RectFastStats,
248    worst_hosts_limit: usize,
249    oversized_ref_drops: u64,
250    unsupported_items: &FxHashMap<String, u64>,
251) -> GeometryDiagnostics {
252    let total_csg_failures = csg_failures.values().map(Vec::len).sum::<usize>() as u64;
253    let products_with_failures = count_attributed_products(csg_failures);
254    let hosts_with_openings = host_diags.len() as u64;
255
256    let classification = ClassificationSummary {
257        rectangular: classification.rectangular as u64,
258        diagonal: classification.diagonal as u64,
259        non_rectangular: classification.non_rectangular as u64,
260        total: (classification.rectangular
261            + classification.diagonal
262            + classification.non_rectangular) as u64,
263    };
264
265    let mut by_reason: FxHashMap<&'static str, u64> = FxHashMap::default();
266    for fails in csg_failures.values() {
267        for f in fails {
268            *by_reason.entry(f.reason.label()).or_insert(0) += 1;
269        }
270    }
271    let mut failures_by_reason: Vec<ReasonCount> = by_reason
272        .into_iter()
273        .map(|(reason, count)| ReasonCount { reason: reason.to_string(), count })
274        .collect();
275    failures_by_reason
276        .sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.reason.cmp(&b.reason)));
277
278    let (total_unsupported_items, unsupported_items_by_type) =
279        unsupported_items::summarize(unsupported_items);
280
281    let silent_no_ops = host_diags
282        .values()
283        .filter(|hd| {
284            // A TRUE silent no-op: rect cutters ran, the triangle count was
285            // unchanged, AND the kernel recorded no failure. A host that already
286            // failed is a loud failure, not a silent one — excluding it keeps this
287            // the precise "ran clean but produced no change" signal (so it is not
288            // double-reported alongside total_csg_failures).
289            matches!((hd.tris_before, hd.tris_after), (Some(b), Some(a)) if b == a)
290                && hd.rect_boxes_processed > 0
291                && hd.csg_failure_count == 0
292        })
293        .count() as u64;
294
295    let mut worst: Vec<(&u32, &HostOpeningDiagnostic)> = host_diags
296        .iter()
297        .filter(|(_, hd)| hd.csg_failure_count > 0)
298        .collect();
299    worst.sort_by(|a, b| {
300        b.1.csg_failure_count.cmp(&a.1.csg_failure_count).then_with(|| a.0.cmp(b.0))
301    });
302    let worst_hosts: Vec<WorstHost> = worst
303        .into_iter()
304        .take(worst_hosts_limit)
305        .map(|(pid, hd)| WorstHost {
306            product_id: *pid,
307            ifc_type: hd.host_type.clone(),
308            openings: hd.openings.len() as u64,
309            csg_failures: hd.csg_failure_count as u64,
310            first_failure_label: hd.first_failure_label.clone(),
311            bbox: hd.host_bounds.map(|(min, max)| HostBbox {
312                min: [min.0, min.1, min.2],
313                max: [max.0, max.1, max.2],
314            }),
315            triangle_count: hd.tris_after.or(hd.tris_before).map(|t| t as u64),
316        })
317        .collect();
318
319    GeometryDiagnostics {
320        schema_version: GEOMETRY_DIAGNOSTICS_SCHEMA_VERSION,
321        total_csg_failures,
322        products_with_failures,
323        hosts_with_openings,
324        classification,
325        failures_by_reason,
326        silent_no_ops,
327        rect_fast: RectFastSummary {
328            fired: rect_fast.fired,
329            openings_cut: rect_fast.openings_cut,
330            defer_host_not_box: rect_fast.defer_host_not_box,
331            defer_not_through: rect_fast.defer_not_through,
332            defer_off_face: rect_fast.defer_off_face,
333            defer_near_edge: rect_fast.defer_near_edge,
334            defer_no_openings: rect_fast.defer_no_openings,
335            defer_too_many_openings: rect_fast.defer_too_many_openings,
336        },
337        worst_hosts,
338        oversized_ref_drops,
339        total_unsupported_items,
340        unsupported_items_by_type,
341    }
342}
343
344mod attribution;
345mod processor_failures;
346mod unsupported_items;
347pub use unsupported_items::format_unsupported_breakdown;
348pub(crate) use unsupported_items::UnsupportedItemState;
349
350#[cfg(test)]
351mod unsupported_items_tests;
352
353pub use attribution::{count_attributed_products, UNATTRIBUTED_PRODUCT_ID};
354
355#[cfg(test)]
356mod diagnostics_contract_tests;