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}