axiolid_dispatch/
boolean.rs

1//! Provider registration, ordering, fallback, and budget policy for mesh booleans.
2
3use std::sync::Arc;
4
5use axiolid_contracts::{BackendId, ExecutionOptions, GeomError, GeomResult, Operation};
6
7use crate::device::matches_device;
8use axiolid_core::BooleanOperator;
9use axiolid_mesh::TriMesh;
10use axiolid_mesh_boolean_contract::{conformance, BooleanOutcome, MeshBoolean};
11use axiolid_mesh_contracts::SolidRequirements;
12
13#[derive(Debug, Clone)]
14struct RegisteredBoolean {
15    priority: i32,
16    provider: Arc<dyn MeshBoolean>,
17}
18
19/// Ordered executable providers for one narrow operation.
20///
21/// Fallback happens only for `Unsupported` or `Unavailable`; numerical and data
22/// failures are returned immediately rather than hidden by another algorithm.
23#[derive(Debug, Clone, Default)]
24pub struct MeshBooleanRegistry {
25    providers: Vec<RegisteredBoolean>,
26    /// Caller-owned CPU context. Every dispatched provider call runs inside
27    /// its pool, so a provider's internal rayon work is bounded by the
28    /// embedding application's policy rather than the process-global pool.
29    #[cfg(feature = "parallel")]
30    execution: Option<axiolid_backend_cpu::CpuExecution>,
31}
32
33impl MeshBooleanRegistry {
34    /// Empty registry.
35    pub const fn new() -> Self {
36        Self {
37            providers: Vec::new(),
38            #[cfg(feature = "parallel")]
39            execution: None,
40        }
41    }
42
43    /// Scope every dispatched provider call to `execution`'s local pool.
44    ///
45    /// This bounds whatever parallelism a provider already does; it does not
46    /// make a single-threaded provider concurrent. With no provider threading
47    /// it costs nothing but the `install` call.
48    #[cfg(feature = "parallel")]
49    #[must_use]
50    pub fn with_execution(mut self, execution: axiolid_backend_cpu::CpuExecution) -> Self {
51        self.execution = Some(execution);
52        self
53    }
54
55    /// Register an implementation. Higher priorities run first.
56    pub fn register<B>(&mut self, priority: i32, provider: B)
57    where
58        B: MeshBoolean + 'static,
59    {
60        self.register_arc(priority, Arc::new(provider));
61    }
62
63    /// Register only if the provider passes the shared conformance suite.
64    ///
65    /// ADR 0017 ยง6 makes conformance a *precondition* of registration rather
66    /// than a test someone might remember to run. A provider that violates the
67    /// contract is rejected here, with the failing report, instead of being
68    /// discovered later by a caller receiving wrong geometry.
69    ///
70    /// [`Self::register`] remains available for tests and for deliberately
71    /// partial providers; this is the door production code should use.
72    ///
73    /// # Errors
74    ///
75    /// Returns the report when the provider violates any obligation.
76    pub fn register_conformant<B>(
77        &mut self,
78        priority: i32,
79        provider: B,
80    ) -> Result<(), Box<conformance::ConformanceReport>>
81    where
82        B: MeshBoolean + 'static,
83    {
84        let report = conformance::run(&provider);
85        if !report.is_conformant() {
86            return Err(Box::new(report));
87        }
88        self.register_arc(priority, Arc::new(provider));
89        Ok(())
90    }
91
92    /// Register a shared trait object.
93    pub fn register_arc(&mut self, priority: i32, provider: Arc<dyn MeshBoolean>) {
94        self.providers
95            .push(RegisteredBoolean { priority, provider });
96        self.providers
97            .sort_by_key(|entry| std::cmp::Reverse(entry.priority));
98    }
99
100    /// Registered providers in dispatch order.
101    pub fn providers(&self) -> impl Iterator<Item = &dyn MeshBoolean> {
102        self.providers.iter().map(|entry| entry.provider.as_ref())
103    }
104
105    fn dispatch(
106        &self,
107        options: &ExecutionOptions,
108        elements: usize,
109        execute: impl Fn(&dyn MeshBoolean) -> GeomResult<BooleanOutcome> + Sync,
110    ) -> GeomResult<BooleanOutcome> {
111        let mut last_retryable = None;
112        let mut over_budget = None;
113        for entry in &self.providers {
114            let descriptor = entry.provider.descriptor();
115            if !matches_device(options.device(), descriptor.id, descriptor.target) {
116                continue;
117            }
118            // Budget is checked before dispatch, not after: a provider that
119            // cannot fit the caller's memory bound must never get the chance to
120            // allocate. Treated as retryable so a leaner provider can still run.
121            if !entry
122                .provider
123                .scratch_requirement()
124                .fits_budget(options, elements)
125            {
126                over_budget = Some(GeomError::BudgetExceeded { resource: "memory" });
127                continue;
128            }
129            match self.run_scoped(&execute, entry.provider.as_ref()) {
130                Ok(outcome) => return Ok(outcome),
131                Err(error @ (GeomError::Unsupported { .. } | GeomError::Unavailable { .. })) => {
132                    last_retryable = Some(error);
133                }
134                Err(error) => return Err(error),
135            }
136        }
137        Err(last_retryable
138            .or(over_budget)
139            .unwrap_or(GeomError::Unsupported {
140                backend: BackendId::new("mesh-boolean-registry"),
141                operation: Operation::MeshBoolean,
142            }))
143    }
144
145    /// Run one provider call, inside the configured pool when there is one.
146    ///
147    /// `CpuExecution::install` already falls through to a direct call when
148    /// the context was built single-threaded, so a configured context with
149    /// one worker costs nothing extra here.
150    #[cfg(feature = "parallel")]
151    fn run_scoped(
152        &self,
153        execute: &(impl Fn(&dyn MeshBoolean) -> GeomResult<BooleanOutcome> + Sync),
154        provider: &dyn MeshBoolean,
155    ) -> GeomResult<BooleanOutcome> {
156        match &self.execution {
157            Some(execution) => execution.install(|| execute(provider)),
158            None => execute(provider),
159        }
160    }
161
162    #[cfg(not(feature = "parallel"))]
163    fn run_scoped(
164        &self,
165        execute: &impl Fn(&dyn MeshBoolean) -> GeomResult<BooleanOutcome>,
166        provider: &dyn MeshBoolean,
167    ) -> GeomResult<BooleanOutcome> {
168        execute(provider)
169    }
170
171    /// Execute according to device policy with narrow fallback semantics.
172    pub fn boolean(
173        &self,
174        subject: &TriMesh,
175        tool: &TriMesh,
176        operation: BooleanOperator,
177        options: &ExecutionOptions,
178    ) -> GeomResult<BooleanOutcome> {
179        // Admissibility is contract-level and checked before any provider sees
180        // the operands, so dispatch cannot change which inputs are legal.
181        SolidRequirements::Oriented.validate_operands(subject, &[tool])?;
182        self.dispatch(options, subject.triangle_count(), |provider| {
183            provider.boolean(subject, tool, operation, options)
184        })
185    }
186
187    /// Subtract many tools through one provider dispatch.
188    pub fn subtract_many(
189        &self,
190        subject: &TriMesh,
191        tools: &[TriMesh],
192        options: &ExecutionOptions,
193    ) -> GeomResult<BooleanOutcome> {
194        let borrowed: Vec<&TriMesh> = tools.iter().collect();
195        SolidRequirements::Oriented.validate_operands(subject, &borrowed)?;
196        let elements =
197            subject.triangle_count() + tools.iter().map(TriMesh::triangle_count).sum::<usize>();
198        self.dispatch(options, elements, |provider| {
199            provider.subtract_many(subject, tools, options)
200        })
201    }
202
203    /// Union many solids through one provider dispatch.
204    ///
205    /// An empty batch is answered without consulting a provider: the union
206    /// of nothing is nothing, and there is no operand to validate or size a
207    /// budget against.
208    pub fn union_many(
209        &self,
210        solids: &[TriMesh],
211        options: &ExecutionOptions,
212    ) -> GeomResult<BooleanOutcome> {
213        let Some((first, rest)) = solids.split_first() else {
214            return Ok(BooleanOutcome::new(TriMesh::default(), Default::default()));
215        };
216        // Union has no privileged operand, but validation is expressed as
217        // subject-plus-tools. Naming the first solid the subject validates
218        // exactly the same set, in the same way the provider reports it.
219        let borrowed: Vec<&TriMesh> = rest.iter().collect();
220        SolidRequirements::Oriented.validate_operands(first, &borrowed)?;
221        let elements = solids.iter().map(TriMesh::triangle_count).sum::<usize>();
222        self.dispatch(options, elements, |provider| {
223            provider.union_many(solids, options)
224        })
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use axiolid_contracts::{DevicePreference, ExecutionTarget};
231    /// Outward-oriented unit cube: the minimal admissible operand.
232    ///
233    /// Dispatch tests need a mesh that passes contract validation, because
234    /// validation now runs before any provider is consulted.
235    fn admissible_cube() -> TriMesh {
236        let positions = vec![
237            [0.0, 0.0, 0.0].into(),
238            [1.0, 0.0, 0.0].into(),
239            [1.0, 1.0, 0.0].into(),
240            [0.0, 1.0, 0.0].into(),
241            [0.0, 0.0, 1.0].into(),
242            [1.0, 0.0, 1.0].into(),
243            [1.0, 1.0, 1.0].into(),
244            [0.0, 1.0, 1.0].into(),
245        ];
246        let indices = vec![
247            0, 2, 1, 0, 3, 2, 4, 5, 6, 4, 6, 7, 0, 1, 5, 0, 5, 4, 1, 2, 6, 1, 6, 5, 2, 3, 7, 2, 7,
248            6, 3, 0, 4, 3, 4, 7,
249        ];
250        TriMesh::new(positions, indices)
251    }
252
253    use std::sync::{
254        atomic::{AtomicUsize, Ordering},
255        Arc,
256    };
257
258    use axiolid_contracts::{Backend, BackendDescriptor};
259    use axiolid_core::Tolerance;
260    use axiolid_mesh_boolean_contract::BooleanEvidence;
261
262    use super::*;
263
264    #[derive(Debug)]
265    struct EchoBoolean {
266        id: BackendId,
267        target: ExecutionTarget,
268    }
269
270    impl Backend for EchoBoolean {
271        fn descriptor(&self) -> BackendDescriptor {
272            BackendDescriptor {
273                id: self.id,
274                target: self.target,
275            }
276        }
277    }
278
279    impl MeshBoolean for EchoBoolean {
280        fn boolean(
281            &self,
282            subject: &TriMesh,
283            _tool: &TriMesh,
284            _operation: BooleanOperator,
285            _options: &ExecutionOptions,
286        ) -> GeomResult<BooleanOutcome> {
287            Ok(BooleanOutcome::new(
288                subject.clone(),
289                BooleanEvidence::default(),
290            ))
291        }
292    }
293
294    #[derive(Debug, Clone, Copy)]
295    enum ProbeResult {
296        Success,
297        Unsupported,
298        Unavailable,
299        Invalid,
300    }
301
302    #[derive(Debug)]
303    struct ProbeBoolean {
304        id: BackendId,
305        target: ExecutionTarget,
306        result: ProbeResult,
307        calls: Arc<AtomicUsize>,
308    }
309
310    impl Backend for ProbeBoolean {
311        fn descriptor(&self) -> BackendDescriptor {
312            BackendDescriptor::new(self.id, self.target)
313        }
314    }
315
316    impl MeshBoolean for ProbeBoolean {
317        fn boolean(
318            &self,
319            subject: &TriMesh,
320            _tool: &TriMesh,
321            _operation: BooleanOperator,
322            _options: &ExecutionOptions,
323        ) -> GeomResult<BooleanOutcome> {
324            self.calls.fetch_add(1, Ordering::Relaxed);
325            match self.result {
326                ProbeResult::Success => Ok(BooleanOutcome::new(
327                    subject.clone(),
328                    BooleanEvidence::default(),
329                )),
330                ProbeResult::Unsupported => Err(GeomError::Unsupported {
331                    backend: self.id,
332                    operation: Operation::MeshBoolean,
333                }),
334                ProbeResult::Unavailable => Err(GeomError::Unavailable {
335                    backend: self.id,
336                    reason: "probe unavailable".to_owned(),
337                }),
338                ProbeResult::Invalid => {
339                    Err(GeomError::InvalidInput("probe rejected input".to_owned()))
340                }
341            }
342        }
343    }
344
345    #[derive(Debug)]
346    struct BatchBoolean {
347        calls: Arc<AtomicUsize>,
348    }
349
350    impl Backend for BatchBoolean {
351        fn descriptor(&self) -> BackendDescriptor {
352            BackendDescriptor::new(BackendId::new("batch"), ExecutionTarget::OptimizedCpu)
353        }
354    }
355
356    impl MeshBoolean for BatchBoolean {
357        fn boolean(
358            &self,
359            _subject: &TriMesh,
360            _tool: &TriMesh,
361            _operation: BooleanOperator,
362            _options: &ExecutionOptions,
363        ) -> GeomResult<BooleanOutcome> {
364            Err(GeomError::InvalidInput(
365                "batch provider must use its batch override".to_owned(),
366            ))
367        }
368
369        fn subtract_many(
370            &self,
371            subject: &TriMesh,
372            _tools: &[TriMesh],
373            _options: &ExecutionOptions,
374        ) -> GeomResult<BooleanOutcome> {
375            self.calls.fetch_add(1, Ordering::Relaxed);
376            Ok(BooleanOutcome::new(
377                subject.clone(),
378                BooleanEvidence::default(),
379            ))
380        }
381    }
382
383    #[test]
384    fn registry_stores_executable_traits_not_capability_flags() {
385        let mut registry = MeshBooleanRegistry::new();
386        registry.register(
387            10,
388            EchoBoolean {
389                id: BackendId::new("echo"),
390                target: ExecutionTarget::PortableCpu,
391            },
392        );
393        let options = ExecutionOptions::new(Tolerance::METRE);
394        let mesh = admissible_cube();
395        assert_eq!(
396            registry
397                .boolean(&mesh, &mesh, BooleanOperator::Difference, &options)
398                .expect("registered provider executes"),
399            BooleanOutcome::new(mesh, BooleanEvidence::default())
400        );
401    }
402
403    #[test]
404    fn registry_dispatches_batch_subtraction_to_the_provider_override() {
405        let calls = Arc::new(AtomicUsize::new(0));
406        let mut registry = MeshBooleanRegistry::new();
407        registry.register(
408            10,
409            BatchBoolean {
410                calls: calls.clone(),
411            },
412        );
413        let mesh = admissible_cube();
414        let options = ExecutionOptions::new(Tolerance::METRE);
415
416        assert_eq!(
417            registry
418                .subtract_many(&mesh, &[mesh.clone(), mesh.clone()], &options)
419                .expect("batch provider executes"),
420            BooleanOutcome::new(mesh, BooleanEvidence::default())
421        );
422        assert_eq!(calls.load(Ordering::Relaxed), 1);
423    }
424
425    #[test]
426    fn registry_falls_back_only_for_retryable_errors_and_honors_device_policy() {
427        let high_calls = Arc::new(AtomicUsize::new(0));
428        let unsupported_calls = Arc::new(AtomicUsize::new(0));
429        let low_calls = Arc::new(AtomicUsize::new(0));
430        let mut registry = MeshBooleanRegistry::new();
431        registry.register(
432            100,
433            ProbeBoolean {
434                id: BackendId::new("unavailable-gpu"),
435                target: ExecutionTarget::Gpu,
436                result: ProbeResult::Unavailable,
437                calls: high_calls.clone(),
438            },
439        );
440        registry.register(
441            50,
442            ProbeBoolean {
443                id: BackendId::new("unsupported-cpu"),
444                target: ExecutionTarget::OptimizedCpu,
445                result: ProbeResult::Unsupported,
446                calls: unsupported_calls.clone(),
447            },
448        );
449        registry.register(
450            10,
451            ProbeBoolean {
452                id: BackendId::new("portable-fallback"),
453                target: ExecutionTarget::PortableCpu,
454                result: ProbeResult::Success,
455                calls: low_calls.clone(),
456            },
457        );
458        let mesh = admissible_cube();
459        let auto = ExecutionOptions::new(Tolerance::METRE);
460        assert!(registry
461            .boolean(&mesh, &mesh, BooleanOperator::Union, &auto)
462            .is_ok());
463        assert_eq!(high_calls.load(Ordering::Relaxed), 1);
464        assert_eq!(unsupported_calls.load(Ordering::Relaxed), 1);
465        assert_eq!(low_calls.load(Ordering::Relaxed), 1);
466
467        let cpu = auto.clone().with_device(DevicePreference::Cpu);
468        assert!(registry
469            .boolean(&mesh, &mesh, BooleanOperator::Union, &cpu)
470            .is_ok());
471        assert_eq!(high_calls.load(Ordering::Relaxed), 1);
472        assert_eq!(unsupported_calls.load(Ordering::Relaxed), 2);
473        assert_eq!(low_calls.load(Ordering::Relaxed), 2);
474
475        let invalid_calls = Arc::new(AtomicUsize::new(0));
476        let skipped_calls = Arc::new(AtomicUsize::new(0));
477        let mut fail_fast = MeshBooleanRegistry::new();
478        fail_fast.register(
479            100,
480            ProbeBoolean {
481                id: BackendId::new("invalid-input"),
482                target: ExecutionTarget::PortableCpu,
483                result: ProbeResult::Invalid,
484                calls: invalid_calls.clone(),
485            },
486        );
487        fail_fast.register(
488            10,
489            ProbeBoolean {
490                id: BackendId::new("must-not-run"),
491                target: ExecutionTarget::PortableCpu,
492                result: ProbeResult::Success,
493                calls: skipped_calls.clone(),
494            },
495        );
496        assert!(matches!(
497            fail_fast.boolean(&mesh, &mesh, BooleanOperator::Union, &auto),
498            Err(GeomError::InvalidInput(_))
499        ));
500        assert_eq!(invalid_calls.load(Ordering::Relaxed), 1);
501        assert_eq!(skipped_calls.load(Ordering::Relaxed), 0);
502    }
503
504    #[test]
505    fn empty_registry_returns_structured_unsupported_error() {
506        let registry = MeshBooleanRegistry::new();
507        let mesh = admissible_cube();
508        let error = registry
509            .boolean(
510                &mesh,
511                &mesh,
512                BooleanOperator::Union,
513                &ExecutionOptions::new(Tolerance::METRE),
514            )
515            .unwrap_err();
516        assert!(matches!(
517            error,
518            GeomError::Unsupported {
519                backend,
520                operation: Operation::MeshBoolean,
521            } if backend == BackendId::new("mesh-boolean-registry")
522        ));
523    }
524
525    /// Records the rayon pool width it observes while running.
526    ///
527    /// `current_num_threads` reports the pool the call is INSIDE, so a
528    /// provider dispatched through a scoped registry must see the
529    /// configured width rather than the process-global one.
530    #[cfg(feature = "parallel")]
531    #[derive(Debug, Default)]
532    struct PoolWidthBoolean {
533        seen: std::sync::Mutex<Vec<usize>>,
534    }
535
536    #[cfg(feature = "parallel")]
537    impl Backend for PoolWidthBoolean {
538        fn descriptor(&self) -> BackendDescriptor {
539            BackendDescriptor {
540                id: BackendId::new("pool-width"),
541                target: ExecutionTarget::PortableCpu,
542            }
543        }
544    }
545
546    #[cfg(feature = "parallel")]
547    impl MeshBoolean for PoolWidthBoolean {
548        fn boolean(
549            &self,
550            subject: &TriMesh,
551            _tool: &TriMesh,
552            _operation: BooleanOperator,
553            _options: &ExecutionOptions,
554        ) -> GeomResult<BooleanOutcome> {
555            self.seen
556                .lock()
557                .expect("poisoned")
558                .push(rayon::current_num_threads());
559            Ok(BooleanOutcome::new(
560                subject.clone(),
561                BooleanEvidence::default(),
562            ))
563        }
564    }
565
566    /// The configured pool must actually wrap the provider call.
567    ///
568    /// Asserting the OBSERVED width, not just that a context was stored:
569    /// a registry that accepted the context and ignored it would pass any
570    /// weaker check. 3 is chosen to differ from this machine core count.
571    #[test]
572    #[cfg(feature = "parallel")]
573    fn dispatch_runs_inside_the_configured_pool() {
574        use axiolid_backend_cpu::CpuExecutionBuilder;
575        use std::num::NonZeroUsize;
576        use std::sync::Arc;
577
578        let execution = CpuExecutionBuilder::new()
579            .threads(NonZeroUsize::new(3).expect("nonzero"))
580            .build()
581            .expect("cpu execution");
582        let provider = Arc::new(PoolWidthBoolean::default());
583        let mut registry = MeshBooleanRegistry::new().with_execution(execution);
584        registry.register_arc(0, provider.clone());
585
586        let cube = admissible_cube();
587        let options = ExecutionOptions::new(Tolerance::MILLIMETRE);
588        registry
589            .boolean(&cube, &cube, BooleanOperator::Union, &options)
590            .expect("dispatch");
591
592        let widths = provider.seen.lock().expect("poisoned").clone();
593        assert_eq!(
594            widths,
595            vec![3],
596            "provider must run inside the 3-worker pool"
597        );
598    }
599
600    /// Negative control: without `with_execution` the provider sees the
601    /// ambient pool, so the assertion above is testing the scoping and not
602    /// some constant rayon happens to return.
603    #[test]
604    #[cfg(feature = "parallel")]
605    fn an_unscoped_registry_does_not_see_the_configured_width() {
606        use std::sync::Arc;
607
608        let provider = Arc::new(PoolWidthBoolean::default());
609        let mut registry = MeshBooleanRegistry::new();
610        registry.register_arc(0, provider.clone());
611
612        let cube = admissible_cube();
613        let options = ExecutionOptions::new(Tolerance::MILLIMETRE);
614        registry
615            .boolean(&cube, &cube, BooleanOperator::Union, &options)
616            .expect("dispatch");
617
618        let widths = provider.seen.lock().expect("poisoned").clone();
619        assert_eq!(widths.len(), 1, "one dispatch");
620        assert_eq!(
621            widths[0],
622            rayon::current_num_threads(),
623            "unscoped dispatch must observe the ambient pool"
624        );
625    }
626}