Skip to main content

ifc_lite_processing/
pipeline_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//! Structured per-load pipeline diagnostics — the cross-target contract.
6//!
7//! Lives in `ifc-lite-processing` (not the geometry crate) because this is
8//! where the per-phase numbers are actually collected: the phase timers
9//! (`ProcessingStats.{parse,entity_scan,lookup,preprocess,geometry}_time_ms`)
10//! are measured by `processor::process_geometry_*`, and the per-element
11//! production counters (degenerate-backstop drops, CSG failure drains) are
12//! owned by `element::produce_element_meshes`. The geometry crate only knows
13//! router-level CSG/opening data, which it already publishes as
14//! [`ifc_lite_geometry::GeometryDiagnostics`]; this struct composes those
15//! aggregates with the pipeline-level timings and counts.
16//!
17//! Serde contract: `camelCase` renames plus an explicit `schema_version`, the
18//! same pattern as `GeometryDiagnostics` (see its
19//! `serializes_camelcase_keys_matching_the_ts_contract` guard, mirrored
20//! below). The wasm getter (`IfcAPI::getPipelineDiagnostics`) crosses it to
21//! JS via `serde_wasm_bindgen::to_value`, exactly like `diagnoseGeometry`.
22//!
23//! Population:
24//! - wasm: the LIVE channel. Accumulated on the NORMAL load path — every
25//!   `processGeometryBatch*` call folds one [`Self::record_batch`] in (cheap
26//!   counters plus two `js_sys::Date::now()` reads per batch, so it is always
27//!   on). `std::time::Instant` traps on wasm32, so only `phase_ms.geometry_ms`/
28//!   `total_ms` are filled; the scan/prepass phases run in JS workers outside
29//!   the wasm module and stay 0.
30//! - native: [`Self::from_processing_stats`] maps a finished `ProcessingStats`
31//!   (all phase timers available) into this shape. It is provided for a native
32//!   consumer that wants the unified vocabulary, but is NOT wired into a live
33//!   server/CLI pipeline today — those surface the same numbers directly via
34//!   `ProcessingStats` + [`GeometryDiagnostics`]. So the wasm getter is the only
35//!   shipping producer; the native constructor is available API, not a second
36//!   projection kept in lockstep.
37
38use crate::types::response::ProcessingStats;
39use ifc_lite_geometry::{GeometryDiagnostics, RectFastSummary};
40
41/// Version of the `PipelineDiagnostics` wire shape. Bump on any
42/// breaking key change so JS consumers can gate on it.
43pub const PIPELINE_DIAGNOSTICS_SCHEMA_VERSION: u32 = 1;
44
45/// Pipeline phase wall-times in milliseconds. Field names mirror the
46/// `ProcessingStats` timers they are sourced from. On wasm32 only
47/// `geometry_ms` is populated (see the module docs).
48#[derive(Debug, Clone, Copy, Default, serde::Serialize, serde::Deserialize)]
49#[serde(rename_all = "camelCase")]
50pub struct PipelinePhaseTimings {
51    /// Entity scan + prepass span-stash/resolve.
52    pub entity_scan_ms: u64,
53    /// Lookup/style/property resolution.
54    pub lookup_ms: u64,
55    /// Geometry preprocessing (unit scales, RTC detection, site transforms).
56    pub preprocess_ms: u64,
57    /// Whole parse phase (scan + lookup + preprocess).
58    pub parse_ms: u64,
59    /// Per-element geometry extraction (meshing + CSG).
60    pub geometry_ms: u64,
61    /// End-to-end wall time of the pass. On the wasm batch path this is the
62    /// SUM of per-batch geometry wall time (the parse-phase timers live in the
63    /// pre-pass and JS orchestration outside the wasm module), i.e. a lower
64    /// bound on the true end-to-end figure.
65    pub total_ms: u64,
66}
67
68/// Aggregate structured diagnostics for one model load. Counter names are
69/// aligned with the existing [`GeometryDiagnostics`] contract
70/// (`total_csg_failures`, `hosts_with_openings`, `rect_fast`, ...) so a
71/// consumer reading both sees one vocabulary.
72#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
73#[serde(rename_all = "camelCase")]
74pub struct PipelineDiagnostics {
75    /// Wire-shape version ([`PIPELINE_DIAGNOSTICS_SCHEMA_VERSION`]).
76    pub schema_version: u32,
77    /// Geometry passes folded in: one per `processGeometryBatch*` call on
78    /// wasm, exactly 1 for the native single-pass pipeline.
79    pub batches: u64,
80    /// Element jobs submitted to the per-element producer.
81    pub element_count: u64,
82    /// Meshes emitted across all batches.
83    pub mesh_count: u64,
84    /// Triangles emitted across all batches.
85    pub triangle_count: u64,
86    /// Triangles removed by the f32-collapse degenerate-triangle backstop
87    /// (`element::build_mesh_data`). Non-zero means the backstop engaged.
88    pub backstop_count: u64,
89    /// Total CSG boolean failures (same meaning as
90    /// `GeometryDiagnostics::total_csg_failures`).
91    pub total_csg_failures: u64,
92    /// Distinct products with at least one CSG failure.
93    pub products_with_failures: u64,
94    /// Hosts that had openings processed.
95    pub hosts_with_openings: u64,
96    /// Hosts where rect cutters ran clean but the mesh came out unchanged.
97    pub silent_no_ops: u64,
98    /// rect_fast fast-path engagement, summed across batches.
99    pub rect_fast: RectFastSummary,
100    /// CartesianPoints served from the point cache while meshing faceted breps,
101    /// summed across batches. Non-zero proves cross-element point memoization
102    /// fired (the per-worker cache hoist). `hits / (hits + misses)` is the rate.
103    pub point_cache_hits: u64,
104    /// CartesianPoints parsed fresh (point-cache misses) across the faceted-brep
105    /// pass, summed across batches.
106    pub point_cache_misses: u64,
107    /// Wall time (ms) attributed to faceted-brep part meshing, summed across
108    /// batches. Populated only in `observability` native builds; 0 on wasm.
109    pub faceted_brep_ms: u64,
110    /// Phase wall-times (native: full; wasm: geometry only).
111    pub phase_ms: PipelinePhaseTimings,
112}
113
114impl Default for PipelineDiagnostics {
115    fn default() -> Self {
116        Self {
117            schema_version: PIPELINE_DIAGNOSTICS_SCHEMA_VERSION,
118            batches: 0,
119            element_count: 0,
120            mesh_count: 0,
121            triangle_count: 0,
122            backstop_count: 0,
123            total_csg_failures: 0,
124            products_with_failures: 0,
125            hosts_with_openings: 0,
126            silent_no_ops: 0,
127            rect_fast: RectFastSummary::default(),
128            point_cache_hits: 0,
129            point_cache_misses: 0,
130            faceted_brep_ms: 0,
131            phase_ms: PipelinePhaseTimings::default(),
132        }
133    }
134}
135
136impl PipelineDiagnostics {
137    /// Fold one geometry batch into the accumulator (the wasm
138    /// `processGeometryBatch*` path calls this once per batch).
139    /// `geometry_ms` is the batch's wall time; `diag` is the batch's drained
140    /// [`GeometryDiagnostics`]. Summing per-batch aggregates is exact because
141    /// each batch drains its own router (no double counting).
142    #[allow(clippy::too_many_arguments)]
143    pub fn record_batch(
144        &mut self,
145        element_count: u64,
146        mesh_count: u64,
147        triangle_count: u64,
148        backstop_count: u64,
149        point_cache_hits: u64,
150        point_cache_misses: u64,
151        faceted_brep_ms: u64,
152        geometry_ms: u64,
153        diag: &GeometryDiagnostics,
154    ) {
155        self.batches += 1;
156        self.element_count += element_count;
157        self.mesh_count += mesh_count;
158        self.triangle_count += triangle_count;
159        self.backstop_count += backstop_count;
160        self.point_cache_hits += point_cache_hits;
161        self.point_cache_misses += point_cache_misses;
162        self.faceted_brep_ms += faceted_brep_ms;
163        self.total_csg_failures += diag.total_csg_failures;
164        self.products_with_failures += diag.products_with_failures;
165        self.hosts_with_openings += diag.hosts_with_openings;
166        self.silent_no_ops += diag.silent_no_ops;
167        self.rect_fast.fired += diag.rect_fast.fired;
168        self.rect_fast.openings_cut += diag.rect_fast.openings_cut;
169        self.rect_fast.defer_host_not_box += diag.rect_fast.defer_host_not_box;
170        self.rect_fast.defer_not_through += diag.rect_fast.defer_not_through;
171        self.rect_fast.defer_off_face += diag.rect_fast.defer_off_face;
172        self.rect_fast.defer_near_edge += diag.rect_fast.defer_near_edge;
173        self.rect_fast.defer_no_openings += diag.rect_fast.defer_no_openings;
174        self.phase_ms.geometry_ms += geometry_ms;
175        self.phase_ms.total_ms += geometry_ms;
176    }
177
178    /// Map a finished native pass into this shape. The native pipeline is
179    /// single-pass, so `batches` is 1 and every phase timer is available.
180    ///
181    /// Available API for a native consumer that wants the unified diagnostics
182    /// vocabulary; not wired into a live server/CLI pipeline today (see the
183    /// module docs). The wasm getter uses [`Self::record_batch`], not this.
184    pub fn from_processing_stats(stats: &ProcessingStats, element_count: u64) -> Self {
185        let (hosts_with_openings, silent_no_ops, rect_fast) = match &stats.geometry_diagnostics {
186            Some(d) => (d.hosts_with_openings, d.silent_no_ops, d.rect_fast),
187            None => (0, 0, RectFastSummary::default()),
188        };
189        Self {
190            schema_version: PIPELINE_DIAGNOSTICS_SCHEMA_VERSION,
191            batches: 1,
192            element_count,
193            mesh_count: stats.total_meshes as u64,
194            triangle_count: stats.total_triangles as u64,
195            backstop_count: stats.degenerate_triangles_dropped,
196            total_csg_failures: stats.total_csg_failures,
197            products_with_failures: stats.products_with_failures,
198            hosts_with_openings,
199            silent_no_ops,
200            rect_fast,
201            point_cache_hits: stats.point_cache_hits,
202            point_cache_misses: stats.point_cache_misses,
203            faceted_brep_ms: stats.faceted_brep_time_ms,
204            phase_ms: PipelinePhaseTimings {
205                entity_scan_ms: stats.entity_scan_time_ms,
206                lookup_ms: stats.lookup_time_ms,
207                preprocess_ms: stats.preprocess_time_ms,
208                parse_ms: stats.parse_time_ms,
209                geometry_ms: stats.geometry_time_ms,
210                total_ms: stats.total_time_ms,
211            },
212        }
213    }
214
215    /// Whether anything has been recorded — the wasm getter returns
216    /// `undefined` before the first batch so consumers can gate on presence.
217    pub fn is_empty(&self) -> bool {
218        self.batches == 0
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn serializes_camelcase_keys_matching_the_ts_contract() {
228        // Guard the serde rename_all against drift from the TS-side consumer.
229        // The wasm getter uses the same renames via serde-wasm-bindgen, so
230        // this JSON key set is what crosses to JS. Mirrors the
231        // GeometryDiagnostics contract test in the geometry crate.
232        let mut d = PipelineDiagnostics::default();
233        d.record_batch(10, 12, 3000, 2, 40, 8, 5, 42, &GeometryDiagnostics::default());
234        let v = serde_json::to_value(&d).expect("serializes");
235        for key in [
236            "schemaVersion",
237            "batches",
238            "elementCount",
239            "meshCount",
240            "triangleCount",
241            "backstopCount",
242            "totalCsgFailures",
243            "productsWithFailures",
244            "hostsWithOpenings",
245            "silentNoOps",
246            "rectFast",
247            "pointCacheHits",
248            "pointCacheMisses",
249            "facetedBrepMs",
250            "phaseMs",
251        ] {
252            assert!(v.get(key).is_some(), "missing top-level key {key}");
253        }
254        for key in [
255            "entityScanMs",
256            "lookupMs",
257            "preprocessMs",
258            "parseMs",
259            "geometryMs",
260            "totalMs",
261        ] {
262            assert!(v["phaseMs"].get(key).is_some(), "missing phaseMs key {key}");
263        }
264        assert!(v["rectFast"].get("deferHostNotBox").is_some());
265        assert_eq!(v["schemaVersion"], PIPELINE_DIAGNOSTICS_SCHEMA_VERSION);
266    }
267
268    #[test]
269    fn record_batch_accumulates_across_batches() {
270        let mut d = PipelineDiagnostics::default();
271        assert!(d.is_empty());
272        let diag = GeometryDiagnostics {
273            total_csg_failures: 2,
274            hosts_with_openings: 3,
275            ..Default::default()
276        };
277        d.record_batch(10, 12, 3000, 1, 30, 4, 3, 40, &diag);
278        d.record_batch(5, 6, 1500, 0, 12, 1, 2, 10, &GeometryDiagnostics::default());
279        assert!(!d.is_empty());
280        assert_eq!(d.batches, 2);
281        assert_eq!(d.element_count, 15);
282        assert_eq!(d.mesh_count, 18);
283        assert_eq!(d.triangle_count, 4500);
284        assert_eq!(d.backstop_count, 1);
285        assert_eq!(d.total_csg_failures, 2);
286        assert_eq!(d.hosts_with_openings, 3);
287        assert_eq!(d.point_cache_hits, 42);
288        assert_eq!(d.point_cache_misses, 5);
289        assert_eq!(d.faceted_brep_ms, 5);
290        assert_eq!(d.phase_ms.geometry_ms, 50);
291    }
292
293    #[test]
294    fn from_processing_stats_maps_all_phase_timers() {
295        let stats = ProcessingStats {
296            total_meshes: 7,
297            total_triangles: 99,
298            parse_time_ms: 11,
299            entity_scan_time_ms: 5,
300            lookup_time_ms: 2,
301            preprocess_time_ms: 3,
302            geometry_time_ms: 40,
303            total_time_ms: 60,
304            degenerate_triangles_dropped: 4,
305            total_csg_failures: 1,
306            products_with_failures: 1,
307            point_cache_hits: 128,
308            point_cache_misses: 32,
309            faceted_brep_time_ms: 9,
310            ..Default::default()
311        };
312        let d = PipelineDiagnostics::from_processing_stats(&stats, 20);
313        assert_eq!(d.batches, 1);
314        assert_eq!(d.element_count, 20);
315        assert_eq!(d.mesh_count, 7);
316        assert_eq!(d.backstop_count, 4);
317        assert_eq!(d.point_cache_hits, 128);
318        assert_eq!(d.point_cache_misses, 32);
319        assert_eq!(d.faceted_brep_ms, 9);
320        assert_eq!(d.phase_ms.parse_ms, 11);
321        assert_eq!(d.phase_ms.entity_scan_ms, 5);
322        assert_eq!(d.phase_ms.lookup_ms, 2);
323        assert_eq!(d.phase_ms.preprocess_ms, 3);
324        assert_eq!(d.phase_ms.geometry_ms, 40);
325        assert_eq!(d.phase_ms.total_ms, 60);
326    }
327}