ifc_lite_geometry/kernel/budget.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//! Deterministic per-boolean exact-predicate budget (issue #1109).
6//!
7//! Every predicate over an implicit (intersection) point runs a cascade:
8//! `interval filter → fixed-width exact → BigRational` (`predicates.rs`). The
9//! interval filter resolves generic geometry for free, but on genuinely
10//! near-coplanar / grazing operands its sign straddles zero, so the predicate
11//! falls through to the exact tiers — the fixed-width rungs climb to ~1340 bits
12//! and a few spill into BigRational (~5000× the interval tier). Boolean-heavy
13//! CAD models (Tekla half-space end-clips, Revit flush openings) are full of
14//! such faces, so a single hard element runs the exact path on a huge fraction
15//! of the O(intersection-pairs) predicate set. The pure-Rust exact kernel has no
16//! operand cap (the old BSP polygon cap was deleted in #1024), so those elements
17//! grind to completion regardless of cost and stall the geometry stream at 95%
18//! (issue #1109).
19//!
20//! This is a **deterministic** guardrail: it counts **interval-filter failures**
21//! (every predicate that needed the expensive exact tier) per boolean and trips
22//! when the count crosses a cap. The count is a pure function of the (snap-grid,
23//! integer) operands, so the trip point is IDENTICAL on native x86_64 / aarch64
24//! and on wasm32 — the server and the browser client degrade the SAME hard
25//! element to the SAME fallback. That preserves the cross-target parity the
26//! pure-Rust kernel exists to guarantee.
27//!
28//! A wall-clock budget would NOT preserve parity: the fast native server would
29//! finish the exact cut while the slower wasm client tripped the timer, yielding
30//! different geometry for the same model. The exact-evaluation COUNT is the right
31//! metric precisely because it is platform-independent.
32//!
33//! On trip the boolean bails; [`crate::csg`] records `OperandTooLarge` and
34//! returns the host un-cut, which routes void subtraction to the deterministic
35//! #635 AABB box-cut fallback ("a square hole is dramatically less wrong than a
36//! missing void").
37
38use std::cell::Cell;
39use std::marker::PhantomData;
40use std::sync::atomic::{AtomicU64, Ordering};
41
42/// Serialises every test that mutates the process-global `CAP` / `ELEMENT_CAP`
43/// atomics, so cargo's parallel runner can't race them. Shared crate-wide (not
44/// just this module's tests) so cap-mutating tests in OTHER modules — e.g. the
45/// voids-router engulf-suppression regression — take the SAME lock and never
46/// clobber each other's cap while a boolean runs. Test-only.
47#[cfg(test)]
48pub(crate) static GLOBAL_CAP_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
49
50/// Default per-boolean exact-evaluation cap for the interactive profile.
51///
52/// Calibrated against the model corpus: the worst healthy boolean (a dense steel
53/// element) needs ~15k exact evaluations; almost all are well under 5k. This cap
54/// is **33× that worst case**, so it never false-trips a legitimate cut (a false
55/// trip degrades a real cut to an AABB box — wrong geometry — which is worse than
56/// finishing slowly). It only engages on the pathological near-coplanar pattern
57/// of #1109 (Tekla half-space clips / Revit flush cuts), where the exact-tier
58/// count runs orders of magnitude higher and would otherwise never finish. The
59/// coplanar fast path (the companion perf fix) keeps hard-but-sound models well
60/// under this cap; this is the safety net that turns the indefinite 95% hang
61/// into a finite load. `set_cap` (or `IFC_LITE_CSG_BUDGET`) tunes it;
62/// `set_cap(None)` lifts it entirely for the server/offline-export profile,
63/// where "exact but slow" is acceptable.
64pub const DEFAULT_CAP: u64 = 500_000;
65
66/// Default per-ELEMENT exact-evaluation cap (#1109 follow-up).
67///
68/// The per-boolean cap above bounds a SINGLE boolean, but it left two holes that
69/// kept boolean-heavy models stalling at 95% even after the per-boolean budget
70/// shipped:
71/// 1. **Distributed cost.** An element with many openings (a 24-opening slab,
72/// a window-dense facade) runs one boolean PER opening, each well under the
73/// per-boolean cap, so none trips — yet the element's TOTAL exact work is
74/// huge and the geometry batch blows the stream watchdog.
75/// 2. **Overshoot.** A single heavily-fragmented host face retriangulates all
76/// its constraint points in ONE `triangulate` call between two per-triangle
77/// `tripped()` checks, so one boolean ran to ~1.7M escalations (3.3× a 500k
78/// cap) before bailing — seconds of work past the cap.
79///
80/// This cap counts escalations across the WHOLE element (every boolean it runs)
81/// and trips once the element total crosses it, so a hard element degrades as a
82/// UNIT (its remaining cuts bail to the #635 AABB box-cut) instead of grinding.
83/// It is still a deterministic COUNT, so native and wasm degrade the SAME element
84/// identically (parity). Calibrated on the model corpus (the `csg_model_profile`
85/// harness): healthy per-element totals are p50=0, p95≈150, p99≈13k — so this cap
86/// is ~8× the 99th-percentile healthy element and never false-trips a legitimate
87/// cut, while the pathological slabs that hung the stream (one needed 7.7M
88/// escalations → 4 MINUTES) all sit far above it. At the measured ~50-60k
89/// escalations/sec it bounds a hard element to ~2 s of exact work before its
90/// remaining cuts degrade. The per-element cap only engages when [`begin_element`]
91/// is called (the unified batch path); direct kernel / router / server /
92/// offline-export callers that never open an element scope stay unbounded,
93/// exactly as before — so the pinned kernel snapshots are unchanged.
94pub const DEFAULT_ELEMENT_CAP: u64 = 100_000;
95
96/// Global per-boolean cap. `0` ⇒ unbounded (run exact to completion). Any other
97/// value is the per-boolean escalation cap. Read once per boolean into a
98/// thread-local so each rayon worker counts its own operation independently and
99/// deterministically.
100static CAP: AtomicU64 = AtomicU64::new(DEFAULT_CAP);
101
102/// Global per-element cap. `0` ⇒ unbounded. Snapshotted by [`begin_element`].
103static ELEMENT_CAP: AtomicU64 = AtomicU64::new(DEFAULT_ELEMENT_CAP);
104
105/// Highest single-boolean escalation count seen since the last [`reset_peak`].
106/// Diagnostics / cap calibration only — never read on the hot path.
107static PEAK: AtomicU64 = AtomicU64::new(0);
108
109/// Highest single-ELEMENT escalation total seen since the last [`reset_peak`].
110static ELEM_PEAK: AtomicU64 = AtomicU64::new(0);
111
112thread_local! {
113 /// Escalations counted in the current boolean operation.
114 static COUNT: Cell<u64> = const { Cell::new(0) };
115 /// This operation's cap snapshot (`u64::MAX` when unbounded).
116 static OP_CAP: Cell<u64> = const { Cell::new(u64::MAX) };
117 /// Escalations accumulated across the current ELEMENT (all its booleans).
118 /// Reset only by [`begin_element`]; per-boolean [`begin`] does NOT reset it.
119 static ELEM_COUNT: Cell<u64> = const { Cell::new(0) };
120 /// This element's cap snapshot. `u64::MAX` (unbounded) until an element scope
121 /// is opened, so non-batch callers are unaffected.
122 static ELEM_CAP: Cell<u64> = const { Cell::new(u64::MAX) };
123}
124
125/// Effective cap, honouring the `IFC_LITE_CSG_BUDGET` env override (read once):
126/// `0` ⇒ unbounded, any other value ⇒ that cap. Lets the server/CLI and
127/// calibration runs pick a profile without code changes. `set_cap` still wins.
128fn env_cap() -> Option<u64> {
129 use std::sync::OnceLock;
130 static ENV: OnceLock<Option<u64>> = OnceLock::new();
131 *ENV.get_or_init(|| std::env::var("IFC_LITE_CSG_BUDGET").ok().and_then(|v| v.parse::<u64>().ok()))
132}
133
134/// Per-element env override (`IFC_LITE_CSG_ELEMENT_BUDGET`, read once): `0` ⇒
135/// unbounded, any other value ⇒ that cap. For calibration runs.
136fn env_element_cap() -> Option<u64> {
137 use std::sync::OnceLock;
138 static ENV: OnceLock<Option<u64>> = OnceLock::new();
139 *ENV.get_or_init(|| {
140 std::env::var("IFC_LITE_CSG_ELEMENT_BUDGET").ok().and_then(|v| v.parse::<u64>().ok())
141 })
142}
143
144/// Set the global per-boolean escalation cap. `None` = unbounded (exact to
145/// completion — the server/CLI/offline-export profile); `Some(n)` = trip after
146/// `n` BigRational escalations (the interactive viewer/wasm profile). The
147/// default is [`DEFAULT_CAP`], so the viewer is bounded out of the box.
148pub fn set_cap(cap: Option<u64>) {
149 CAP.store(cap.unwrap_or(0), Ordering::Relaxed);
150}
151
152/// Set the global per-element escalation cap (#1109 follow-up). `None` =
153/// unbounded. The default is [`DEFAULT_ELEMENT_CAP`].
154pub fn set_element_cap(cap: Option<u64>) {
155 ELEMENT_CAP.store(cap.unwrap_or(0), Ordering::Relaxed);
156}
157
158/// The active per-boolean cap, as configured (`None` ⇒ unbounded).
159pub fn cap() -> Option<u64> {
160 match CAP.load(Ordering::Relaxed) {
161 0 => None,
162 n => Some(n),
163 }
164}
165
166/// The active per-element cap, as configured (`None` ⇒ unbounded).
167pub fn element_cap() -> Option<u64> {
168 match ELEMENT_CAP.load(Ordering::Relaxed) {
169 0 => None,
170 n => Some(n),
171 }
172}
173
174/// Begin a boolean operation: reset the per-op escalation counter and snapshot
175/// the cap. Call once at every public boolean entry in [`crate::csg`].
176#[inline]
177pub fn begin() {
178 crate::progress::tick();
179 // Fold the just-finished op's count into the global peak (calibration).
180 let prev = COUNT.with(|c| c.get());
181 if prev != 0 {
182 PEAK.fetch_max(prev, Ordering::Relaxed);
183 }
184 let cap = match env_cap() {
185 Some(v) => v, // env override wins (0 = unbounded)
186 None => CAP.load(Ordering::Relaxed),
187 };
188 OP_CAP.with(|c| c.set(if cap == 0 { u64::MAX } else { cap }));
189 COUNT.with(|c| c.set(0));
190}
191
192/// Begin an ELEMENT scope (#1109 follow-up): reset the per-element accumulator
193/// and snapshot the per-element cap, BEFORE the element's booleans run, so every
194/// boolean the element issues accumulates into one budget and a hard element
195/// degrades as a whole. Mesh production opens it through [`enter_element`],
196/// which also restores the enclosing element's scope; call this directly only
197/// where no element can be nested on the same thread.
198///
199/// The per-element cap is unbounded whenever the per-boolean profile is unbounded
200/// (`set_cap(None)` / `IFC_LITE_CSG_BUDGET=0` — the server/offline-export
201/// profile), so the SAME single switch keeps the server exact. Otherwise it is
202/// the configured [`ELEMENT_CAP`] (or its `IFC_LITE_CSG_ELEMENT_BUDGET` override).
203#[inline]
204pub fn begin_element() {
205 // Fold the just-finished element's total into the per-element peak.
206 let prev = ELEM_COUNT.with(|c| c.get());
207 if prev != 0 {
208 ELEM_PEAK.fetch_max(prev, Ordering::Relaxed);
209 }
210 // Per-element cap is unbounded iff the per-boolean profile is unbounded.
211 let boolean_unbounded = match env_cap() {
212 Some(v) => v == 0,
213 None => CAP.load(Ordering::Relaxed) == 0,
214 };
215 let elem_cap = if boolean_unbounded {
216 0 // unbounded (server / offline export)
217 } else {
218 match env_element_cap() {
219 Some(v) => v, // override (0 = unbounded)
220 None => ELEMENT_CAP.load(Ordering::Relaxed),
221 }
222 };
223 ELEM_CAP.with(|c| c.set(if elem_cap == 0 { u64::MAX } else { elem_cap }));
224 ELEM_COUNT.with(|c| c.set(0));
225}
226
227/// An open per-element budget scope. Returned by [`enter_element`]; dropping it
228/// restores every accumulator to the value it had when the scope opened.
229///
230/// A rayon worker blocked on a nested `par_iter` (faceted-brep triangulation)
231/// can steal another element job onto its own thread and run it to completion
232/// before resuming the first. The thread-locals are shared by both, so a bare
233/// [`begin_element`] in the stolen element zeroes the outer element's total and
234/// whether the outer element trips its cap depends on scheduling. Scopes nest
235/// in stack order, so a save on entry and a restore on drop keep each element's
236/// count its own.
237///
238/// The scope is not `Send`, so it cannot be dropped on a thread other than the
239/// one whose counters it saved (#4663):
240///
241/// ```compile_fail
242/// fn assert_send<T: Send>() {}
243/// assert_send::<ifc_lite_geometry::kernel::budget::ElementScope>();
244/// ```
245#[must_use = "dropping the scope immediately restores the enclosing element's budget"]
246pub struct ElementScope {
247 count: u64,
248 op_cap: u64,
249 elem_count: u64,
250 elem_cap: u64,
251 /// Not `Send`: the scope restores the thread-locals of the thread that
252 /// opened it, so it must be dropped there.
253 _thread_bound: PhantomData<*const ()>,
254}
255
256/// Open a per-element budget scope ([`begin_element`]) that restores the
257/// enclosing scope's counters and caps when dropped. Use this, not a bare
258/// [`begin_element`], wherever element meshing can be re-entered on one thread.
259pub fn enter_element() -> ElementScope {
260 let scope = ElementScope {
261 count: COUNT.with(Cell::get),
262 op_cap: OP_CAP.with(Cell::get),
263 elem_count: ELEM_COUNT.with(Cell::get),
264 elem_cap: ELEM_CAP.with(Cell::get),
265 _thread_bound: PhantomData,
266 };
267 begin_element();
268 scope
269}
270
271impl Drop for ElementScope {
272 fn drop(&mut self) {
273 // Fold the scope's last boolean and its element total into the peaks
274 // before the restore overwrites them (`begin` / `begin_element` fold a
275 // finished count only when the next one starts on this thread).
276 let last_op = COUNT.with(Cell::get);
277 if last_op != 0 {
278 PEAK.fetch_max(last_op, Ordering::Relaxed);
279 }
280 let finished = ELEM_COUNT.with(Cell::get);
281 if finished != 0 {
282 ELEM_PEAK.fetch_max(finished, Ordering::Relaxed);
283 }
284 COUNT.with(|c| c.set(self.count));
285 OP_CAP.with(|c| c.set(self.op_cap));
286 ELEM_COUNT.with(|c| c.set(self.elem_count));
287 ELEM_CAP.with(|c| c.set(self.elem_cap));
288 }
289}
290
291/// Highest single-boolean escalation count observed since process start (or the
292/// last [`reset_peak`]). For cap calibration / diagnostics.
293pub fn peak() -> u64 {
294 PEAK.load(Ordering::Relaxed)
295}
296
297/// Highest single-ELEMENT escalation total observed since the last
298/// [`reset_peak`]. For per-element cap calibration / diagnostics.
299pub fn element_peak() -> u64 {
300 // Include the in-flight element so a single-element profile run sees it.
301 ELEM_PEAK.load(Ordering::Relaxed).max(ELEM_COUNT.with(|c| c.get()))
302}
303
304/// Reset the global peak escalation counters (per-boolean and per-element).
305pub fn reset_peak() {
306 PEAK.store(0, Ordering::Relaxed);
307 ELEM_PEAK.store(0, Ordering::Relaxed);
308}
309
310/// Record one exact-tier predicate evaluation (an interval-filter failure).
311/// Called from the `.or_else(|| fixed::…)` arms of [`crate::kernel::predicates`]
312/// — the point where a predicate leaves the cheap interval filter for the
313/// expensive fixed-width / BigRational path.
314///
315/// DELIBERATE EXEMPTION — the cached-λ I512/I1024 tier is NOT counted. The
316/// escalations tallied here all RE-DERIVE the degree-4/7 LPI/TPI lambda inside the
317/// `fixed::indirect_*` / `orient2d_2i|3i` / `cmp_lex` arms of `predicates.rs`, and
318/// that lambda recompute is the dominant per-escalation cost the caps were
319/// calibrated against. The hot re-triangulation / arrangement predicates that run
320/// straight off lambdas ALREADY interned — `retriangulate::orient2d_v` /
321/// `cmp_lex_v` (their `fixed::orient2d_from_lam` / `cmp_lex_from_lam` determinant
322/// branch, retriangulate.rs:51) and `arrangement::orient2d_end` (its cached
323/// interval-lambda branch, arrangement/mod.rs:107) — skip that recompute, so they
324/// are intentionally left OUT of the counter. Folding them in would inflate the
325/// count on cheap cached evaluations and re-calibrate the effective cap, shifting
326/// which elements trip and thereby perturbing valid mesh output / determinism; the
327/// counter is kept as a proxy for the recompute-heavy work only.
328#[inline]
329pub fn note_escalation() {
330 COUNT.with(|c| c.set(c.get().saturating_add(1)));
331 let total = ELEM_COUNT.with(|c| {
332 let next = c.get().saturating_add(1);
333 c.set(next);
334 next
335 });
336 // A long exact-tier stretch inside ONE boolean is still progress.
337 if total & 0xFFF == 0 {
338 crate::progress::tick();
339 }
340}
341
342/// Whether the current boolean OR the current element has exceeded its
343/// escalation budget. Checked at loop boundaries in the arrangement AND inside
344/// the per-point retriangulation loop so the bail is timely and graceful. Once
345/// the per-element budget is blown, every subsequent boolean the element issues
346/// trips immediately (its `COUNT` resets per [`begin`], but `ELEM_COUNT` does
347/// not), so the element's remaining cuts bail to the #635 AABB fallback.
348#[inline]
349pub fn tripped() -> bool {
350 COUNT.with(|c| c.get()) >= OP_CAP.with(|c| c.get())
351 || ELEM_COUNT.with(|c| c.get()) >= ELEM_CAP.with(|c| c.get())
352}
353
354/// Escalations counted so far in the current boolean (diagnostics / cap
355/// calibration).
356#[inline]
357pub fn count() -> u64 {
358 COUNT.with(|c| c.get())
359}
360
361/// Escalations accumulated so far in the current element across all its booleans
362/// (diagnostics / per-element cap calibration).
363#[inline]
364pub fn element_count() -> u64 {
365 ELEM_COUNT.with(|c| c.get())
366}
367
368/// Snapshot / restore the accumulators so a REFERENCE computation (`subtract_many`'s
369/// volume-safe oracle) can't charge or trip the caller's #1109 batch budget (codex P2).
370pub(crate) fn snapshot_counters() -> (u64, u64) {
371 (COUNT.with(|c| c.get()), ELEM_COUNT.with(|c| c.get()))
372}
373pub(crate) fn restore_counters((op, elem): (u64, u64)) {
374 COUNT.with(|c| c.set(op));
375 ELEM_COUNT.with(|c| c.set(elem));
376}
377
378#[cfg(test)]
379#[path = "budget_tests.rs"]
380mod tests;