Skip to main content

axioval_engine/
linear_quantity.rs

1//! Source-neutral linear-quantity evidence.
2//!
3//! ADR 0004: a service returns what was *measured*; a capability decides what
4//! it means. This is the measurement half of the shelf-capacity decomposition:
5//! the adapter reports how many running metres of shelving a space contains,
6//! and never whether that satisfies a requirement.
7//!
8//! The quantity is an interval rather than a scalar so an adapter can report
9//! honest bounds when its measurement is approximate. A capability that needs
10//! exactness asks for it explicitly via [`LinearInterval::is_exact`].
11
12use std::sync::Arc;
13
14use axioval_ir::{Evidence, ObjectId};
15
16use crate::services::reviewable_exact_evidence;
17
18/// Why a linear quantity could not be produced.
19#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
20pub enum LinearQuantityError {
21    /// The bounds are negative, non-finite, or inverted.
22    #[error("linear interval must be finite, non-negative and ordered")]
23    InvalidInterval,
24    /// The evidence backing the measurement was not exact and reviewable.
25    #[error("linear quantity evidence must be exact and reviewable")]
26    InexactEvidence,
27    /// The adapter cannot measure this quantity for this object.
28    #[error("linear quantity is unavailable for the requested scope")]
29    Unavailable,
30    /// The requested arrangement is not physically realisable.
31    #[error("shelf geometry must be positive, finite and ordered")]
32    InvalidGeometry,
33}
34
35/// A measured length, in metres, bounded below and above.
36#[derive(Clone, Copy, Debug, PartialEq)]
37pub struct LinearInterval {
38    lower_metres: f64,
39    upper_metres: f64,
40}
41
42impl LinearInterval {
43    /// Bounds for an approximate measurement.
44    pub fn try_new(lower: f64, upper: f64) -> Result<Self, LinearQuantityError> {
45        let valid = |v: f64| v.is_finite() && v >= 0.0;
46        if !valid(lower) || !valid(upper) || lower > upper {
47            return Err(LinearQuantityError::InvalidInterval);
48        }
49        Ok(Self {
50            lower_metres: lower,
51            upper_metres: upper,
52        })
53    }
54
55    /// A measurement the adapter can vouch for exactly.
56    pub fn exact(metres: f64) -> Result<Self, LinearQuantityError> {
57        Self::try_new(metres, metres)
58    }
59
60    pub fn lower_metres(&self) -> f64 {
61        self.lower_metres
62    }
63
64    pub fn upper_metres(&self) -> f64 {
65        self.upper_metres
66    }
67
68    /// True when the interval collapses to a single value.
69    ///
70    /// Exact bit-equality is the intended test: the bounds are only equal when
71    /// an adapter constructed them from one measurement via [`Self::exact`].
72    /// A tolerance here would let a genuine range masquerade as exact.
73    #[allow(clippy::float_cmp)]
74    pub fn is_exact(&self) -> bool {
75        self.lower_metres == self.upper_metres
76    }
77
78    /// Whether the whole interval clears `minimum`.
79    ///
80    /// Comparison lives with the caller, but the *interval* semantics live
81    /// here: a partially-clearing interval is not a pass, and saying so once
82    /// stops each capability inventing its own rounding.
83    pub fn definitely_at_least(&self, minimum: f64) -> bool {
84        self.lower_metres >= minimum
85    }
86
87    /// Whether no part of the interval clears `minimum`.
88    pub fn definitely_below(&self, minimum: f64) -> bool {
89        self.upper_metres < minimum
90    }
91}
92
93/// The physical shelving arrangement whose run length is being measured.
94///
95/// These are *geometry inputs*, not thresholds: they describe the shelf being
96/// measured, so they belong to the measurement request. The minimum a space
97/// must provide is policy and stays with the capability. Keeping the two apart
98/// is what stops a threshold drifting back behind the evidence seam.
99#[derive(Clone, Copy, Debug, PartialEq)]
100// The shared `_metres` suffix is the point: every field is a length in the
101// same unit, and naming it on each one is what stops a millimetre value being
102// passed where metres are meant. Dropping the suffix would trade a real
103// safety property for brevity.
104#[allow(clippy::struct_field_names)]
105pub struct ShelfGeometry {
106    depth_metres: f64,
107    horizontal_spacing_metres: f64,
108    vertical_spacing_metres: f64,
109    bottom_elevation_metres: f64,
110    top_elevation_metres: f64,
111    door_clearance_metres: f64,
112}
113
114impl ShelfGeometry {
115    /// Rejects a physically impossible arrangement.
116    pub fn try_new(
117        depth_metres: f64,
118        horizontal_spacing_metres: f64,
119        vertical_spacing_metres: f64,
120        bottom_elevation_metres: f64,
121        top_elevation_metres: f64,
122        door_clearance_metres: f64,
123    ) -> Result<Self, LinearQuantityError> {
124        let positive = |v: f64| v.is_finite() && v > 0.0;
125        let non_negative = |v: f64| v.is_finite() && v >= 0.0;
126        if !positive(depth_metres)
127            || !positive(horizontal_spacing_metres)
128            || !positive(vertical_spacing_metres)
129            || !non_negative(bottom_elevation_metres)
130            || !non_negative(door_clearance_metres)
131            || !top_elevation_metres.is_finite()
132            || top_elevation_metres <= bottom_elevation_metres
133        {
134            return Err(LinearQuantityError::InvalidGeometry);
135        }
136        Ok(Self {
137            depth_metres,
138            horizontal_spacing_metres,
139            vertical_spacing_metres,
140            bottom_elevation_metres,
141            top_elevation_metres,
142            door_clearance_metres,
143        })
144    }
145    pub fn depth_metres(&self) -> f64 {
146        self.depth_metres
147    }
148    pub fn horizontal_spacing_metres(&self) -> f64 {
149        self.horizontal_spacing_metres
150    }
151    pub fn vertical_spacing_metres(&self) -> f64 {
152        self.vertical_spacing_metres
153    }
154    pub fn bottom_elevation_metres(&self) -> f64 {
155        self.bottom_elevation_metres
156    }
157    pub fn top_elevation_metres(&self) -> f64 {
158        self.top_elevation_metres
159    }
160    pub fn door_clearance_metres(&self) -> f64 {
161        self.door_clearance_metres
162    }
163}
164
165/// What linear quantity is being asked for.
166#[derive(Clone, Copy, Debug, PartialEq)]
167#[non_exhaustive]
168pub enum LinearQuantityKind {
169    /// Total running length of shelving fitting the given arrangement.
170    ShelfRunningLength(ShelfGeometry),
171}
172
173impl LinearQuantityKind {
174    pub fn as_str(self) -> &'static str {
175        match self {
176            LinearQuantityKind::ShelfRunningLength(_) => "shelf-running-length",
177        }
178    }
179}
180
181/// A request for one linear measurement of one object.
182#[derive(Clone, Debug, PartialEq)]
183pub struct LinearQuantityRequest {
184    scope: ObjectId,
185    kind: LinearQuantityKind,
186}
187
188impl LinearQuantityRequest {
189    pub fn new(scope: ObjectId, kind: LinearQuantityKind) -> Self {
190        Self { scope, kind }
191    }
192    pub fn scope(&self) -> &ObjectId {
193        &self.scope
194    }
195    pub fn kind(&self) -> LinearQuantityKind {
196        self.kind
197    }
198}
199
200/// A measured linear quantity with the evidence that supports it.
201#[derive(Clone, Debug, PartialEq)]
202pub struct LinearQuantityEvidence {
203    request: LinearQuantityRequest,
204    measured: LinearInterval,
205    evidence: Evidence,
206}
207
208impl LinearQuantityEvidence {
209    /// Rejects evidence that is not exact and reviewable, so an adapter
210    /// cannot launder an estimate into the engine as fact.
211    pub fn try_new(
212        request: LinearQuantityRequest,
213        measured: LinearInterval,
214        evidence: Evidence,
215    ) -> Result<Self, LinearQuantityError> {
216        if !reviewable_exact_evidence(&evidence) {
217            return Err(LinearQuantityError::InexactEvidence);
218        }
219        Ok(Self {
220            request,
221            measured,
222            evidence,
223        })
224    }
225    pub fn request(&self) -> &LinearQuantityRequest {
226        &self.request
227    }
228    pub fn measured(&self) -> LinearInterval {
229        self.measured
230    }
231    pub fn evidence(&self) -> &Evidence {
232        &self.evidence
233    }
234}
235
236/// Measures linear quantities of model objects.
237///
238/// ADR 0004: every method returns a measurement. None returns a finding.
239pub trait LinearQuantityService: Send + Sync + 'static {
240    fn measure_linear_quantity(
241        &self,
242        request: &LinearQuantityRequest,
243    ) -> Result<LinearQuantityEvidence, LinearQuantityError>;
244}
245
246/// Registry handle for a [`LinearQuantityService`].
247#[derive(Clone)]
248pub struct LinearQuantityServiceHandle(Arc<dyn LinearQuantityService>);
249
250impl LinearQuantityServiceHandle {
251    pub fn new(service: Arc<dyn LinearQuantityService>) -> Self {
252        Self(service)
253    }
254    pub fn measure_linear_quantity(
255        &self,
256        request: &LinearQuantityRequest,
257    ) -> Result<LinearQuantityEvidence, LinearQuantityError> {
258        self.0.measure_linear_quantity(request)
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    #[test]
267    fn interval_rejects_inverted_negative_and_non_finite_bounds() {
268        assert!(LinearInterval::try_new(2.0, 1.0).is_err());
269        assert!(LinearInterval::try_new(-1.0, 1.0).is_err());
270        assert!(LinearInterval::try_new(0.0, f64::NAN).is_err());
271        assert!(LinearInterval::try_new(0.0, f64::INFINITY).is_err());
272        assert!(LinearInterval::try_new(0.0, 0.0).is_ok());
273    }
274
275    /// An approximate interval straddling the minimum is neither a pass nor a
276    /// definite failure. Collapsing that to a boolean is how an estimate turns
277    /// into a false verdict.
278    #[test]
279    fn straddling_interval_is_neither_pass_nor_definite_failure() {
280        let straddles = LinearInterval::try_new(9.0, 11.0).unwrap();
281        assert!(!straddles.definitely_at_least(10.0));
282        assert!(!straddles.definitely_below(10.0));
283        assert!(!straddles.is_exact());
284
285        let clears = LinearInterval::exact(10.0).unwrap();
286        assert!(clears.definitely_at_least(10.0));
287        assert!(!clears.definitely_below(10.0));
288        assert!(clears.is_exact());
289    }
290}