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}