Skip to main content

axiolid_contracts/
execution.rs

1//! Execution policy passed explicitly to every costly operation.
2
3use axiolid_core::{Scalar, Tolerance};
4
5use crate::cancel::CancellationToken;
6use crate::{BackendId, GeomError, GeomResult, Precision};
7
8/// Reproducibility requirement.
9///
10/// These are three genuinely different contracts, not degrees of one. A backend
11/// that reduces in a deterministic order satisfies [`Self::Topological`] while
12/// still failing [`Self::Bitwise`] against a differently-scheduled backend, so
13/// collapsing them into one flag lets two backends both claim "deterministic"
14/// and still disagree. Ordered weakest to strongest.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
16pub enum Determinism {
17    /// No guarantee. The backend may reorder, re-associate, and reschedule.
18    BestEffort,
19    /// Same connectivity and same output ordering for the same inputs.
20    ///
21    /// Coordinates may differ within tolerance. This is what a clash result or
22    /// a topology commit needs; it does not promise identical floats.
23    Topological,
24    /// Topological determinism plus values within the operation's stated
25    /// numerical error bound.
26    NumericallyBounded,
27    /// Bit-for-bit identical output for the same inputs and options.
28    ///
29    /// The only contract that supports hashing a result or comparing artifacts
30    /// across machines.
31    Bitwise,
32}
33
34impl Determinism {
35    /// Whether this guarantee is at least as strong as `required`.
36    ///
37    /// Strength is the declaration order, so a backend offering
38    /// [`Self::Bitwise`] satisfies a [`Self::Topological`] request but never
39    /// the reverse. Routing must use this instead of equality, or a stronger
40    /// backend gets rejected for being too good.
41    pub const fn satisfies(self, required: Self) -> bool {
42        (self as u8) >= (required as u8)
43    }
44}
45
46/// CPU scheduling preference.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
48pub enum Parallelism {
49    /// One worker.
50    Serial,
51    /// Backend chooses from available parallelism and workload size.
52    Auto,
53    /// Upper bound on worker count. Zero is rejected by the builder.
54    Threads(usize),
55}
56
57impl Parallelism {
58    /// Most worker threads this preference lets an operation run on.
59    ///
60    /// What [`ScratchRequirement::fits_budget`] charges a per-worker scratch
61    /// term against. `Serial` is one worker and `Threads(n)` is `n`. `Auto`
62    /// is the machine's available parallelism, the width of a default
63    /// thread pool, or 1 when it cannot be determined (the same fallback
64    /// such a pool uses). A provider that runs on a wider pool than this
65    /// must check its budget against the width it actually runs on.
66    pub fn worker_bound(self) -> usize {
67        match self {
68            Self::Serial => 1,
69            Self::Threads(workers) => workers,
70            Self::Auto => std::thread::available_parallelism().map_or(1, usize::from),
71        }
72    }
73}
74
75/// Device selection preference. `Auto` is a policy request, not permission to
76/// silently reduce precision.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
78pub enum DevicePreference {
79    /// Select from compatible registered backends.
80    Auto,
81    /// Require a CPU backend.
82    Cpu,
83    /// Require a GPU backend.
84    Gpu,
85    /// Require one named backend.
86    Backend(BackendId),
87}
88
89/// Where an operation's data physically lives.
90///
91/// Device preference says *where to run*; residency says *where the bytes
92/// already are*. Routing needs both: a GPU-resident batch is cheap to run on
93/// the GPU and expensive to run on the CPU, and the reverse holds for a
94/// host-resident one. Without this, a planner cannot see a transfer that
95/// dominates the operation it is scheduling.
96#[non_exhaustive]
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
98pub enum Residency {
99    /// Host (CPU) memory.
100    Host,
101    /// Memory owned by the named device.
102    Device(BackendId),
103    /// Host-visible memory shared with the named device; no copy is needed.
104    Unified(BackendId),
105}
106
107impl Residency {
108    /// Whether `backend` can read this data without a host/device transfer.
109    pub fn is_local_to(self, backend: BackendId) -> bool {
110        match self {
111            Self::Host => false,
112            Self::Device(owner) | Self::Unified(owner) => owner == backend,
113        }
114    }
115
116    /// Whether the host can read this data without a transfer.
117    pub const fn is_host_readable(self) -> bool {
118        matches!(self, Self::Host | Self::Unified(_))
119    }
120}
121
122/// Where an operation's inputs live and where its outputs are wanted.
123///
124/// Kept as a pair because they genuinely differ: a GPU broad phase may consume
125/// device-resident geometry and still have to deliver host-readable results.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
127pub struct DataResidency {
128    input: Residency,
129    output: Residency,
130}
131
132impl DataResidency {
133    /// Both inputs and outputs in host memory. The portable default.
134    pub const HOST: Self = Self {
135        input: Residency::Host,
136        output: Residency::Host,
137    };
138
139    /// Construct an explicit input/output residency plan.
140    pub const fn new(input: Residency, output: Residency) -> Self {
141        Self { input, output }
142    }
143
144    /// Where inputs currently live.
145    pub const fn input(self) -> Residency {
146        self.input
147    }
148
149    /// Where outputs are wanted.
150    pub const fn output(self) -> Residency {
151        self.output
152    }
153
154    /// Whether running on `backend` needs no host/device transfer either way.
155    pub fn is_transfer_free_on(self, backend: BackendId) -> bool {
156        self.input.is_local_to(backend) && self.output.is_local_to(backend)
157    }
158}
159
160/// Scratch memory an operation needs beyond its inputs and outputs.
161///
162/// Declared up front so a caller can budget, pre-reserve, or refuse before any
163/// work starts. `Unbounded` is deliberately representable and deliberately
164/// unpleasant: an operation that cannot bound its scratch must say so rather
165/// than allocating silently in a hot loop.
166#[non_exhaustive]
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
168pub enum ScratchRequirement {
169    /// Operates in place; no scratch beyond inputs and outputs.
170    None,
171    /// At most `bytes` of scratch, independent of input size.
172    Fixed {
173        /// Upper bound in bytes.
174        bytes: usize,
175    },
176    /// At most `bytes_per_element * elements` of scratch.
177    PerElement {
178        /// Upper bound per input element, in bytes.
179        bytes_per_element: usize,
180    },
181    /// Scratch cannot be bounded ahead of time.
182    ///
183    /// Callers must treat this as "may allocate arbitrarily"; a memory budget
184    /// cannot be enforced against it.
185    Unbounded,
186    /// At most `base_bytes + bytes_per_element * elements +
187    /// bytes_per_worker * workers` of scratch, where `workers` is the number
188    /// of threads the operation may run on.
189    ///
190    /// For an operation whose scratch is not purely per element: a fixed
191    /// setup cost dominates small inputs, and a threaded operation pays per
192    /// worker (pool bookkeeping, work stolen onto another thread being live
193    /// at the same time as the rest) no matter how small the input is
194    /// (#226). A [`Self::PerElement`] bound tight enough for large inputs is
195    /// then below the real peak for small ones.
196    Affine {
197        /// Upper bound independent of input size and worker count, in bytes.
198        base_bytes: usize,
199        /// Upper bound per input element, in bytes.
200        bytes_per_element: usize,
201        /// Upper bound per worker thread, in bytes. Zero for an operation
202        /// that never runs on more than the calling thread.
203        bytes_per_worker: usize,
204    },
205}
206
207impl ScratchRequirement {
208    /// Upper bound for `elements` inputs, or `None` when unbounded.
209    ///
210    /// An [`Self::Affine`] requirement with a per-worker term has no bound
211    /// without a worker count, so it reports `None` here; use
212    /// [`Self::upper_bound_bytes_on`].
213    pub const fn upper_bound_bytes(self, elements: usize) -> Option<usize> {
214        match self {
215            Self::Affine {
216                bytes_per_worker, ..
217            } if bytes_per_worker > 0 => None,
218            _ => self.upper_bound_bytes_on(elements, 0),
219        }
220    }
221
222    /// Upper bound for `elements` inputs run on `workers` threads, or `None`
223    /// when unbounded or when the bound overflows `usize`.
224    ///
225    /// Only [`Self::Affine`] depends on `workers`.
226    pub const fn upper_bound_bytes_on(self, elements: usize, workers: usize) -> Option<usize> {
227        match self {
228            Self::None => Some(0),
229            Self::Fixed { bytes } => Some(bytes),
230            Self::PerElement { bytes_per_element } => bytes_per_element.checked_mul(elements),
231            Self::Unbounded => None,
232            Self::Affine {
233                base_bytes,
234                bytes_per_element,
235                bytes_per_worker,
236            } => {
237                let Some(per_elements) = bytes_per_element.checked_mul(elements) else {
238                    return None;
239                };
240                let Some(per_workers) = bytes_per_worker.checked_mul(workers) else {
241                    return None;
242                };
243                let Some(variable) = per_elements.checked_add(per_workers) else {
244                    return None;
245                };
246                base_bytes.checked_add(variable)
247            }
248        }
249    }
250
251    /// Whether this requirement fits `options`' memory budget for `elements`.
252    ///
253    /// An unbounded requirement never fits a declared budget: allowing it would
254    /// make the budget advisory, which is the failure this type exists to stop.
255    /// A per-worker term is charged for [`Parallelism::worker_bound`] of
256    /// `options`' parallelism; a caller that knows the operation runs on a
257    /// different number of threads uses [`Self::fits_budget_on`].
258    ///
259    /// No longer `const`: it reads an [`ExecutionOptions`] that now owns a
260    /// shared cancellation handle, so it cannot be destructured at compile
261    /// time. Budget checks happen once per dispatch, not on a hot path.
262    pub fn fits_budget(self, options: &ExecutionOptions, elements: usize) -> bool {
263        // The worker count is resolved only when a budget is declared, so an
264        // unbudgeted check never asks the machine for its parallelism.
265        match options.memory_budget_bytes() {
266            None => true,
267            Some(_) => self.fits_budget_on(options, elements, options.parallelism().worker_bound()),
268        }
269    }
270
271    /// Whether this requirement fits `options`' memory budget for `elements`
272    /// run on `workers` threads.
273    ///
274    /// For a caller that knows the width the operation actually runs on: a
275    /// dispatcher scoping the call to its own pool, or a provider checking the
276    /// pool it finds itself in.
277    pub fn fits_budget_on(
278        self,
279        options: &ExecutionOptions,
280        elements: usize,
281        workers: usize,
282    ) -> bool {
283        match options.memory_budget_bytes() {
284            None => true,
285            Some(budget) => match self.upper_bound_bytes_on(elements, workers) {
286                Some(needed) => needed <= budget,
287                None => false,
288            },
289        }
290    }
291}
292
293/// Upper bound on how many outputs an operation produces per input element.
294///
295/// Declared so a caller can size a destination buffer *before* the operation
296/// runs. That is what makes a scan-then-scatter implementation possible: with a
297/// per-element bound, exclusive-prefix-summing the per-element counts gives
298/// every worker a disjoint write offset, so no lock, no atomic counter, and no
299/// dynamically growing vector is needed on the hot path.
300#[non_exhaustive]
301#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
302pub enum OutputBound {
303    /// Exactly one output per input. The output index equals the input index.
304    OneToOne,
305    /// At most `max` outputs per input element.
306    AtMost {
307        /// Inclusive upper bound per element.
308        max: usize,
309    },
310    /// The output count cannot be bounded before running.
311    ///
312    /// Callers must fall back to a growable collection. Kept representable and
313    /// deliberately unpleasant so an operation that could declare a bound is
314    /// not tempted to shrug.
315    Unbounded,
316}
317
318impl OutputBound {
319    /// Worst-case total outputs for `elements` inputs, or `None` if unbounded
320    /// or the product would overflow.
321    pub const fn upper_bound(self, elements: usize) -> Option<usize> {
322        match self {
323            Self::OneToOne => Some(elements),
324            Self::AtMost { max } => max.checked_mul(elements),
325            Self::Unbounded => None,
326        }
327    }
328
329    /// Whether a caller can preallocate an exact destination for `elements`.
330    pub const fn is_preallocatable(self, elements: usize) -> bool {
331        self.upper_bound(elements).is_some()
332    }
333
334    /// Exclusive prefix sum of `counts`, plus the total.
335    ///
336    /// This is the scan that turns "how many outputs does each element make?"
337    /// into "where does each element write?". Returns `None` if any count
338    /// exceeds this bound (a provider contract violation) or the total
339    /// overflows. Allocation is the caller's single result buffer; the scan
340    /// itself is a running total with no per-element allocation.
341    pub fn write_offsets(self, counts: &[usize]) -> Option<(Vec<usize>, usize)> {
342        let per_element_max = match self {
343            Self::OneToOne => Some(1),
344            Self::AtMost { max } => Some(max),
345            Self::Unbounded => None,
346        };
347        let mut offsets = Vec::with_capacity(counts.len());
348        let mut running = 0usize;
349        for &count in counts {
350            if let Some(max) = per_element_max {
351                if count > max {
352                    return None;
353                }
354            }
355            offsets.push(running);
356            running = running.checked_add(count)?;
357        }
358        Some((offsets, running))
359    }
360}
361
362/// Operation policy with explicit tolerance, precision, and cancellation.
363///
364/// Deliberately not `Copy`: it carries a shared [`CancellationToken`], and an
365/// implicitly copied cancellation handle is a footgun. Callers clone when they
366/// mean to share the token and construct fresh options when they do not.
367#[derive(Debug, Clone, PartialEq)]
368pub struct ExecutionOptions {
369    tolerance: Tolerance,
370    precision: Precision,
371    determinism: Determinism,
372    parallelism: Parallelism,
373    device: DevicePreference,
374    residency: DataResidency,
375    memory_budget_bytes: Option<usize>,
376    cancellation: Option<CancellationToken>,
377    chord_error: Option<Scalar>,
378}
379
380impl ExecutionOptions {
381    /// Start from the required model-aware tolerance.
382    pub const fn new(tolerance: Tolerance) -> Self {
383        Self {
384            tolerance,
385            precision: Precision::F64,
386            determinism: Determinism::NumericallyBounded,
387            parallelism: Parallelism::Auto,
388            device: DevicePreference::Auto,
389            residency: DataResidency::HOST,
390            memory_budget_bytes: None,
391            cancellation: None,
392            chord_error: None,
393        }
394    }
395
396    /// Replace the tolerance while preserving every other execution policy.
397    ///
398    /// Compilers use this when evaluating geometry in a transformed local
399    /// coordinate system. Cloning first keeps cancellation, budgets,
400    /// determinism, residency, and provider preferences intact.
401    pub fn with_tolerance(mut self, tolerance: Tolerance) -> Self {
402        self.tolerance = tolerance;
403        self
404    }
405
406    /// Attach a cooperative cancellation token.
407    ///
408    /// Absent a token, an operation runs to completion; there is no ambient
409    /// cancellation source. Providers declare how finely they poll via
410    /// [`crate::CancellationGranularity`], so a caller can see the real latency.
411    pub fn with_cancellation(mut self, token: CancellationToken) -> Self {
412        self.cancellation = Some(token);
413        self
414    }
415
416    /// The attached cancellation token, if any.
417    pub fn cancellation(&self) -> Option<&CancellationToken> {
418        self.cancellation.as_ref()
419    }
420
421    /// `Err(GeomError::Cancelled)` if a token is attached and cancelled.
422    ///
423    /// The call providers make at each poll point: `options.check_cancelled()?`.
424    /// Cheap (one relaxed load) and a no-op when no token is attached.
425    pub fn check_cancelled(&self) -> crate::GeomResult<()> {
426        match &self.cancellation {
427            Some(token) => token.check(),
428            None => Ok(()),
429        }
430    }
431
432    /// Set required precision.
433    pub fn with_precision(mut self, precision: Precision) -> Self {
434        self.precision = precision;
435        self
436    }
437
438    /// Set determinism requirement.
439    pub fn with_determinism(mut self, value: Determinism) -> Self {
440        self.determinism = value;
441        self
442    }
443
444    /// Set scheduling preference. Returns `None` for zero explicit threads.
445    pub fn with_parallelism(mut self, value: Parallelism) -> Option<Self> {
446        if matches!(value, Parallelism::Threads(0)) {
447            return None;
448        }
449        self.parallelism = value;
450        Some(self)
451    }
452
453    /// Set device preference.
454    pub fn with_device(mut self, value: DevicePreference) -> Self {
455        self.device = value;
456        self
457    }
458
459    /// Declare where inputs live and where outputs are wanted.
460    pub fn with_residency(mut self, value: DataResidency) -> Self {
461        self.residency = value;
462        self
463    }
464
465    /// Bound how far flattened curves may deviate from the exact curve.
466    ///
467    /// Curved geometry that a provider approximates with straight chords
468    /// (profile arcs, sweep directrices, curved B-rep faces) stays within this
469    /// distance of the exact curve. Without it the chord budget is the linear
470    /// tolerance, which is a coincidence tolerance and coarse for small radii:
471    /// at `Tolerance::MILLIMETRE` a 5 mm arc gets four chords per half turn.
472    /// Quantity take-off wants a tighter budget than display.
473    ///
474    /// Returns `None` for a non-finite or non-positive value. A provider may
475    /// still refuse a budget too fine to meet within its own work limits.
476    pub fn with_chord_error(mut self, value: Scalar) -> Option<Self> {
477        if !(value.is_finite() && value > 0.0) {
478            return None;
479        }
480        self.chord_error = Some(value);
481        Some(self)
482    }
483
484    /// The explicit chord budget, if one was set.
485    ///
486    /// `None` means the provider uses its default, the linear tolerance.
487    pub fn chord_error(&self) -> Option<Scalar> {
488        self.chord_error
489    }
490
491    /// Bound temporary allocation.
492    pub fn with_memory_budget(mut self, bytes: usize) -> Self {
493        self.memory_budget_bytes = Some(bytes);
494        self
495    }
496
497    /// Tolerance.
498    pub fn tolerance(&self) -> Tolerance {
499        self.tolerance
500    }
501
502    /// Precision.
503    pub fn precision(&self) -> Precision {
504        self.precision
505    }
506
507    /// Determinism requirement.
508    pub fn determinism(&self) -> Determinism {
509        self.determinism
510    }
511
512    /// Scheduling preference.
513    pub fn parallelism(&self) -> Parallelism {
514        self.parallelism
515    }
516
517    /// Device preference.
518    pub fn device(&self) -> DevicePreference {
519        self.device
520    }
521
522    /// Optional temporary-memory budget.
523    pub fn memory_budget_bytes(&self) -> Option<usize> {
524        self.memory_budget_bytes
525    }
526
527    /// Where inputs live and where outputs are wanted.
528    pub fn residency(&self) -> DataResidency {
529        self.residency
530    }
531
532    /// Charge `bytes` of scratch against the budget before allocating it.
533    ///
534    /// The budget is only real if something checks it, so this is the single
535    /// enforcement point every hot path routes through. Backends must call it
536    /// *before* the allocation, not after: reporting an overrun once the
537    /// allocation already succeeded defeats the purpose of a budget.
538    pub fn charge_scratch(&self, bytes: usize) -> GeomResult<()> {
539        match self.memory_budget_bytes {
540            Some(budget) if bytes > budget => Err(GeomError::BudgetExceeded { resource: "memory" }),
541            _ => Ok(()),
542        }
543    }
544}