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;