axiolid_contracts/plan.rs
1//! Reproducible operation plans.
2//!
3//! # Why a plan and not just options
4//!
5//! [`ExecutionOptions`] says how to run one operation. It does not say what
6//! was run, what it produced, or whether re-running it would produce the same
7//! thing. A graph evaluated twice under the same options gave no guarantee of
8//! the same result, and nothing recorded which inputs and budgets produced a
9//! given output.
10//!
11//! A [`Plan`] is that missing artifact: the options, plus the recorded
12//! provenance of every step, plus the outcome. Re-executing a plan against the
13//! same inputs must produce the same result, and the plan itself is the
14//! evidence of what was run.
15//!
16//! # Determinism is requested, not assumed
17//!
18//! [`Determinism`] has always been declarable, but nothing read it: a caller
19//! could ask for [`Determinism::Bitwise`] and receive best-effort output with
20//! no indication the request was ignored. A plan closes that hole. Executing a
21//! plan admits the requested level against what the executing provider
22//! actually guarantees, and refuses when the request cannot be met.
23//!
24//! Refusing is the point. Silently accepting a determinism request a provider
25//! cannot honour is exactly the class of quiet wrongness this kernel exists to
26//! avoid: the caller believes it can hash the result and compare it across
27//! machines, and it cannot.
28//!
29//! # Scope
30//!
31//! A plan is an in-process artifact. It is deliberately not serialised: a wire
32//! format is a compatibility promise, and freezing one before the public API
33//! stabilises would commit to a shape the kernel has not finished learning.
34//! Reproducibility is proved by re-executing the same plan, not by persisting
35//! it. Serialisation can be added without changing this contract.
36
37use crate::capability::{BackendId, Operation};
38use crate::error::{GeomError, GeomResult};
39use crate::execution::{Determinism, ExecutionOptions, ScratchRequirement};
40
41/// One recorded step: what ran, where, and under what guarantee.
42///
43/// Provenance survives across operations by accumulating these in order. A
44/// step records the guarantee the provider actually delivered, not the one the
45/// caller asked for, so a plan that ran at a weaker level than requested is
46/// visible after the fact rather than indistinguishable from one that did not.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct PlanStep {
49 /// Operation performed.
50 pub operation: Operation,
51 /// Backend that performed it.
52 pub backend: BackendId,
53 /// Determinism the backend actually guaranteed for this step.
54 pub guaranteed: Determinism,
55 /// Stable description of the input family this step consumed.
56 pub input: String,
57}
58
59impl PlanStep {
60 /// Record a step.
61 pub fn new(
62 operation: Operation,
63 backend: BackendId,
64 guaranteed: Determinism,
65 input: &str,
66 ) -> Self {
67 Self {
68 operation,
69 backend,
70 guaranteed,
71 input: input.to_owned(),
72 }
73 }
74}
75
76/// A reproducible operation plan.
77///
78/// Carries the options every step runs under and the provenance of the steps
79/// taken so far. Re-executing the same plan against the same inputs must
80/// produce the same result; the recorded steps are the evidence of what ran.
81#[derive(Debug, Clone, PartialEq)]
82pub struct Plan {
83 options: ExecutionOptions,
84 steps: Vec<PlanStep>,
85}
86
87impl Plan {
88 /// Start a plan from the options every step will run under.
89 #[must_use]
90 pub fn new(options: ExecutionOptions) -> Self {
91 Self {
92 options,
93 steps: Vec::new(),
94 }
95 }
96
97 /// Options every step of this plan runs under.
98 #[must_use]
99 pub fn options(&self) -> &ExecutionOptions {
100 &self.options
101 }
102
103 /// Steps recorded so far, in execution order.
104 #[must_use]
105 pub fn steps(&self) -> &[PlanStep] {
106 &self.steps
107 }
108}
109
110impl Plan {
111 /// Admit and record a step, or refuse it.
112 ///
113 /// Two admissions, both fail-closed:
114 ///
115 /// - The provider's `guaranteed` determinism must be at least the level
116 /// the plan requested. A provider that cannot meet the request is
117 /// refused rather than silently downgraded, because the caller has no
118 /// other way to learn the guarantee it relied on was not delivered.
119 /// - The step's scratch requirement must fit the plan's memory budget.
120 /// Exhaustion is a typed `BudgetExceeded`, never a silent truncation to
121 /// a smaller result that still looks plausible.
122 pub fn admit(
123 &mut self,
124 step: PlanStep,
125 scratch: ScratchRequirement,
126 elements: usize,
127 ) -> GeomResult<()> {
128 if !step.guaranteed.satisfies(self.options.determinism()) {
129 return Err(GeomError::BackendContractViolation {
130 backend: step.backend,
131 detail: format!(
132 "plan requires {:?} determinism, backend guarantees only {:?}",
133 self.options.determinism(),
134 step.guaranteed
135 ),
136 });
137 }
138 if !scratch.fits_budget(&self.options, elements) {
139 return Err(GeomError::BudgetExceeded {
140 resource: "plan step scratch memory",
141 });
142 }
143 self.steps.push(step);
144 Ok(())
145 }
146}