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///
100/// The arrangement is a layout of parallel bands: each band is
101/// `depth_metres` deep and served by an aisle `horizontal_spacing_metres`
102/// wide along one of its long sides. Shelves are stacked in tiers every
103/// `vertical_spacing_metres` from `bottom_elevation_metres` up to
104/// `top_elevation_metres`. In front of every door or opening of the space a
105/// clearance `door_clearance_metres` deep carries no shelving; a door's swing
106/// is not known, so the clearance reaches that far from the opening in
107/// every direction.
108#[derive(Clone, Copy, Debug, PartialEq)]
109// The shared `_metres` suffix is the point: every field is a length in the
110// same unit, and naming it on each one is what stops a millimetre value being
111// passed where metres are meant. Dropping the suffix would trade a real
112// safety property for brevity.
113#[allow(clippy::struct_field_names)]
114pub struct ShelfGeometry {
115    depth_metres: f64,
116    horizontal_spacing_metres: f64,
117    vertical_spacing_metres: f64,
118    bottom_elevation_metres: f64,
119    top_elevation_metres: f64,
120    door_clearance_metres: f64,
121}
122
123impl ShelfGeometry {
124    /// Rejects a physically impossible arrangement.
125    pub fn try_new(
126        depth_metres: f64,
127        horizontal_spacing_metres: f64,
128        vertical_spacing_metres: f64,
129        bottom_elevation_metres: f64,
130        top_elevation_metres: f64,
131        door_clearance_metres: f64,
132    ) -> Result<Self, LinearQuantityError> {
133        let positive = |v: f64| v.is_finite() && v > 0.0;
134        let non_negative = |v: f64| v.is_finite() && v >= 0.0;
135        if !positive(depth_metres)
136            || !positive(horizontal_spacing_metres)
137            || !positive(vertical_spacing_metres)
138            || !non_negative(bottom_elevation_metres)
139            || !non_negative(door_clearance_metres)
140            || !top_elevation_metres.is_finite()
141            || top_elevation_metres <= bottom_elevation_metres
142        {
143            return Err(LinearQuantityError::InvalidGeometry);
144        }
145        Ok(Self {
146            depth_metres,
147            horizontal_spacing_metres,
148            vertical_spacing_metres,
149            bottom_elevation_metres,
150            top_elevation_metres,
151            door_clearance_metres,
152        })
153    }
154    pub fn depth_metres(&self) -> f64 {
155        self.depth_metres
156    }
157    pub fn horizontal_spacing_metres(&self) -> f64 {
158        self.horizontal_spacing_metres
159    }
160    pub fn vertical_spacing_metres(&self) -> f64 {
161        self.vertical_spacing_metres
162    }
163    pub fn bottom_elevation_metres(&self) -> f64 {
164        self.bottom_elevation_metres
165    }
166    pub fn top_elevation_metres(&self) -> f64 {
167        self.top_elevation_metres
168    }
169    pub fn door_clearance_metres(&self) -> f64 {
170        self.door_clearance_metres
171    }
172}
173
174/// What linear quantity is being asked for.
175#[derive(Clone, Copy, Debug, PartialEq)]
176#[non_exhaustive]
177pub enum LinearQuantityKind {
178    /// Total running length of shelving fitting the given arrangement.
179    ShelfRunningLength(ShelfGeometry),
180}
181
182impl LinearQuantityKind {
183    pub fn as_str(self) -> &'static str {
184        match self {
185            LinearQuantityKind::ShelfRunningLength(_) => "shelf-running-length",
186        }
187    }
188}
189
190/// A request for one linear measurement of one object.
191///
192/// The doors and openings of the scope are the rule's selection, carried in
193/// the request: which elements give access to a space is semantic, so the
194/// capability reads it and the adapter only places each one's clearance.
195#[derive(Clone, Debug, PartialEq)]
196pub struct LinearQuantityRequest {
197    scope: ObjectId,
198    kind: LinearQuantityKind,
199    doors: Vec<ObjectId>,
200}
201
202impl LinearQuantityRequest {
203    /// A request for a scope without doors or openings.
204    pub fn new(scope: ObjectId, kind: LinearQuantityKind) -> Self {
205        Self {
206            scope,
207            kind,
208            doors: Vec::new(),
209        }
210    }
211    /// The doors and openings whose clearances carry no shelving, kept
212    /// sorted and once each.
213    #[must_use]
214    pub fn with_doors(mut self, doors: impl IntoIterator<Item = ObjectId>) -> Self {
215        let mut doors: Vec<ObjectId> = doors.into_iter().collect();
216        doors.sort();
217        doors.dedup();
218        self.doors = doors;
219        self
220    }
221    pub fn scope(&self) -> &ObjectId {
222        &self.scope
223    }
224    pub fn kind(&self) -> LinearQuantityKind {
225        self.kind
226    }
227    /// The doors and openings of the scope, in identity order.
228    pub fn doors(&self) -> &[ObjectId] {
229        &self.doors
230    }
231}
232
233/// A measured linear quantity with the evidence that supports it.
234#[derive(Clone, Debug, PartialEq)]
235pub struct LinearQuantityEvidence {
236    request: LinearQuantityRequest,
237    measured: LinearInterval,
238    clear_height: Option<LinearInterval>,
239    evidence: Evidence,
240}
241
242impl LinearQuantityEvidence {
243    /// Rejects evidence that is not exact and reviewable, so an adapter
244    /// cannot launder an estimate into the engine as fact.
245    pub fn try_new(
246        request: LinearQuantityRequest,
247        measured: LinearInterval,
248        evidence: Evidence,
249    ) -> Result<Self, LinearQuantityError> {
250        if !reviewable_exact_evidence(&evidence) {
251            return Err(LinearQuantityError::InexactEvidence);
252        }
253        Ok(Self {
254            request,
255            measured,
256            clear_height: None,
257            evidence,
258        })
259    }
260    /// The clear height of the scope the shelving stands in, so a rule can
261    /// tell a room too low for the arrangement from one too small.
262    #[must_use]
263    pub fn with_clear_height(mut self, clear_height: LinearInterval) -> Self {
264        self.clear_height = Some(clear_height);
265        self
266    }
267    /// The measured clear height, when the adapter reports one.
268    pub fn clear_height(&self) -> Option<LinearInterval> {
269        self.clear_height
270    }
271    pub fn request(&self) -> &LinearQuantityRequest {
272        &self.request
273    }
274    pub fn measured(&self) -> LinearInterval {
275        self.measured
276    }
277    pub fn evidence(&self) -> &Evidence {
278        &self.evidence
279    }
280}
281
282/// Measures linear quantities of model objects.
283///
284/// ADR 0004: every method returns a measurement. None returns a finding.
285pub trait LinearQuantityService: Send + Sync + 'static {
286    fn measure_linear_quantity(
287        &self,
288        request: &LinearQuantityRequest,
289    ) -> Result<LinearQuantityEvidence, LinearQuantityError>;
290}
291
292/// Registry handle for a [`LinearQuantityService`].
293#[derive(Clone)]
294pub struct LinearQuantityServiceHandle(Arc<dyn LinearQuantityService>);
295
296impl LinearQuantityServiceHandle {
297    pub fn new(service: Arc<dyn LinearQuantityService>) -> Self {
298        Self(service)
299    }
300    pub fn measure_linear_quantity(
301        &self,
302        request: &LinearQuantityRequest,
303    ) -> Result<LinearQuantityEvidence, LinearQuantityError> {
304        self.0.measure_linear_quantity(request)
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    #[test]
313    fn doors_are_carried_sorted_and_once() {
314        let source = axioval_ir::SourceId::new("cad", "m").unwrap();
315        let id = |local: &str| ObjectId::new(source.clone(), local).unwrap();
316        let shelf = ShelfGeometry::try_new(0.3, 1.0, 0.4, 0.0, 2.0, 0.9).unwrap();
317        let request =
318            LinearQuantityRequest::new(id("room"), LinearQuantityKind::ShelfRunningLength(shelf))
319                .with_doors([id("d2"), id("d1"), id("d2")]);
320        assert_eq!(request.doors(), [id("d1"), id("d2")]);
321    }
322
323    #[test]
324    fn interval_rejects_inverted_negative_and_non_finite_bounds() {
325        assert!(LinearInterval::try_new(2.0, 1.0).is_err());
326        assert!(LinearInterval::try_new(-1.0, 1.0).is_err());
327        assert!(LinearInterval::try_new(0.0, f64::NAN).is_err());
328        assert!(LinearInterval::try_new(0.0, f64::INFINITY).is_err());
329        assert!(LinearInterval::try_new(0.0, 0.0).is_ok());
330    }
331
332    /// An approximate interval straddling the minimum is neither a pass nor a
333    /// definite failure. Collapsing that to a boolean is how an estimate turns
334    /// into a false verdict.
335    #[test]
336    fn straddling_interval_is_neither_pass_nor_definite_failure() {
337        let straddles = LinearInterval::try_new(9.0, 11.0).unwrap();
338        assert!(!straddles.definitely_at_least(10.0));
339        assert!(!straddles.definitely_below(10.0));
340        assert!(!straddles.is_exact());
341
342        let clears = LinearInterval::exact(10.0).unwrap();
343        assert!(clears.definitely_at_least(10.0));
344        assert!(!clears.definitely_below(10.0));
345        assert!(clears.is_exact());
346    }
347}