Skip to main content

axioval_engine/
space.rs

1//! Source-neutral space-validation evidence.
2//!
3//! ADR 0004: a service returns what was *measured*; a capability decides what
4//! it means. A space is validated from several independent measurements --
5//! clear height, duplicate bodies, boundary gaps, overlaps, and cap coverage --
6//! and each is requested separately.
7//!
8//! That separation is the fix. The source bundled all seven aspects into one
9//! fact struct, each behind a `SpaceValidationBranch<T>` that was `Option` by
10//! another name, so a rule asking for one aspect discovered only at use-time
11//! that a *different* aspect was missing, and every aspect failed together.
12//! One request per aspect makes an unavailable measurement explicit and local.
13
14use std::sync::Arc;
15
16use axioval_ir::{Evidence, ObjectId};
17
18use crate::services::reviewable_exact_evidence;
19
20/// Why a space measurement could not be produced.
21#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
22pub enum SpaceError {
23    /// A reported quantity is negative, non-finite, or incoherent.
24    #[error("space quantities must be finite and non-negative")]
25    InvalidQuantity,
26    /// The evidence backing the measurement was not exact and reviewable.
27    #[error("space evidence must be exact and reviewable")]
28    InexactEvidence,
29    /// The adapter cannot measure this aspect for this space.
30    #[error("space measurement is unavailable for the requested aspect")]
31    Unavailable,
32}
33
34fn finite_non_negative(value: f64) -> bool {
35    value.is_finite() && value >= 0.0
36}
37
38/// The clear height of a space, in metres.
39#[derive(Clone, Debug, PartialEq)]
40pub struct ClearHeightEvidence {
41    space: ObjectId,
42    metres: f64,
43    evidence: Evidence,
44}
45
46impl ClearHeightEvidence {
47    pub fn try_new(space: ObjectId, metres: f64, evidence: Evidence) -> Result<Self, SpaceError> {
48        if !finite_non_negative(metres) {
49            return Err(SpaceError::InvalidQuantity);
50        }
51        if !reviewable_exact_evidence(&evidence) {
52            return Err(SpaceError::InexactEvidence);
53        }
54        Ok(Self {
55            space,
56            metres,
57            evidence,
58        })
59    }
60    pub fn space(&self) -> &ObjectId {
61        &self.space
62    }
63    pub fn metres(&self) -> f64 {
64        self.metres
65    }
66    pub fn evidence(&self) -> &Evidence {
67        &self.evidence
68    }
69}
70
71/// A contiguous run of space boundary that no element covers.
72#[derive(Clone, Debug, PartialEq)]
73pub struct BoundaryGap {
74    length_metres: f64,
75    elements: Vec<ObjectId>,
76}
77
78impl BoundaryGap {
79    pub fn try_new(length_metres: f64, mut elements: Vec<ObjectId>) -> Result<Self, SpaceError> {
80        if !finite_non_negative(length_metres) {
81            return Err(SpaceError::InvalidQuantity);
82        }
83        elements.sort();
84        elements.dedup();
85        Ok(Self {
86            length_metres,
87            elements,
88        })
89    }
90    pub fn length_metres(&self) -> f64 {
91        self.length_metres
92    }
93    pub fn elements(&self) -> &[ObjectId] {
94        &self.elements
95    }
96}
97
98/// How one body sits inside another.
99#[derive(Clone, Copy, Debug, PartialEq, Eq)]
100pub enum Containment {
101    /// Neither body contains the other; they merely overlap.
102    Partial,
103    /// The measured space lies inside the other body.
104    SubjectInsideOther,
105    /// The other body lies inside the measured space.
106    OtherInsideSubject,
107}
108
109/// A measured overlap between a space and another body.
110#[derive(Clone, Debug, PartialEq)]
111pub struct SpaceOverlap {
112    other: ObjectId,
113    other_is_space: bool,
114    area_square_metres: f64,
115    height_metres: f64,
116    containment: Containment,
117}
118
119impl SpaceOverlap {
120    pub fn try_new(
121        other: ObjectId,
122        other_is_space: bool,
123        area_square_metres: f64,
124        height_metres: f64,
125        containment: Containment,
126    ) -> Result<Self, SpaceError> {
127        if !finite_non_negative(area_square_metres) || !finite_non_negative(height_metres) {
128            return Err(SpaceError::InvalidQuantity);
129        }
130        Ok(Self {
131            other,
132            other_is_space,
133            area_square_metres,
134            height_metres,
135            containment,
136        })
137    }
138    pub fn other(&self) -> &ObjectId {
139        &self.other
140    }
141    /// Whether the overlapping body is itself a space.
142    pub fn other_is_space(&self) -> bool {
143        self.other_is_space
144    }
145    pub fn area_square_metres(&self) -> f64 {
146        self.area_square_metres
147    }
148    pub fn height_metres(&self) -> f64 {
149        self.height_metres
150    }
151    pub fn containment(&self) -> Containment {
152        self.containment
153    }
154}
155
156/// How much of a space's horizontal cap is covered by elements.
157#[derive(Clone, Debug, PartialEq)]
158pub struct CapCoverage {
159    whole_area_square_metres: f64,
160    covered_area_square_metres: f64,
161    elements: Vec<ObjectId>,
162}
163
164impl CapCoverage {
165    pub fn try_new(
166        whole_area_square_metres: f64,
167        covered_area_square_metres: f64,
168        mut elements: Vec<ObjectId>,
169    ) -> Result<Self, SpaceError> {
170        if !finite_non_negative(whole_area_square_metres)
171            || !finite_non_negative(covered_area_square_metres)
172            || whole_area_square_metres <= 0.0
173            // A cap cannot be covered over more than its own area.
174            || covered_area_square_metres > whole_area_square_metres
175        {
176            return Err(SpaceError::InvalidQuantity);
177        }
178        elements.sort();
179        elements.dedup();
180        Ok(Self {
181            whole_area_square_metres,
182            covered_area_square_metres,
183            elements,
184        })
185    }
186    pub fn whole_area_square_metres(&self) -> f64 {
187        self.whole_area_square_metres
188    }
189    pub fn covered_area_square_metres(&self) -> f64 {
190        self.covered_area_square_metres
191    }
192    pub fn elements(&self) -> &[ObjectId] {
193        &self.elements
194    }
195    /// Fraction of the cap that is covered, computed exactly.
196    ///
197    /// The divisor is validated positive in [`Self::try_new`].
198    pub fn covered_ratio(&self) -> f64 {
199        self.covered_area_square_metres / self.whole_area_square_metres
200    }
201}
202
203/// Floor area on a storey that belongs to no space.
204#[derive(Clone, Debug, PartialEq)]
205pub struct StoreyResidual {
206    storey: ObjectId,
207    area_square_metres: f64,
208    elements: Vec<ObjectId>,
209}
210
211impl StoreyResidual {
212    pub fn try_new(
213        storey: ObjectId,
214        area_square_metres: f64,
215        mut elements: Vec<ObjectId>,
216    ) -> Result<Self, SpaceError> {
217        if !finite_non_negative(area_square_metres) {
218            return Err(SpaceError::InvalidQuantity);
219        }
220        elements.sort();
221        elements.dedup();
222        Ok(Self {
223            storey,
224            area_square_metres,
225            elements,
226        })
227    }
228    pub fn storey(&self) -> &ObjectId {
229        &self.storey
230    }
231    pub fn area_square_metres(&self) -> f64 {
232        self.area_square_metres
233    }
234    pub fn elements(&self) -> &[ObjectId] {
235        &self.elements
236    }
237}
238
239/// Which horizontal caps a model actually has elements for.
240///
241/// Counts, not a decision about which checks to run: the capability decides
242/// that a cap check without any slab is not worth reporting per space.
243#[derive(Clone, Debug, PartialEq)]
244pub struct SupportCounts {
245    slabs: usize,
246    roofs: usize,
247    buildings: Vec<ObjectId>,
248}
249
250impl SupportCounts {
251    pub fn new(slabs: usize, roofs: usize, mut buildings: Vec<ObjectId>) -> Self {
252        buildings.sort();
253        buildings.dedup();
254        Self {
255            slabs,
256            roofs,
257            buildings,
258        }
259    }
260    pub fn slabs(&self) -> usize {
261        self.slabs
262    }
263    pub fn roofs(&self) -> usize {
264        self.roofs
265    }
266    pub fn buildings(&self) -> &[ObjectId] {
267        &self.buildings
268    }
269}
270
271/// Which horizontal cap of a space is being measured.
272#[derive(Clone, Copy, Debug, PartialEq, Eq)]
273pub enum Cap {
274    Top,
275    Bottom,
276}
277
278/// Measures the geometry a space-validation policy reasons about.
279///
280/// ADR 0004: every method returns a measurement. None returns a finding, and
281/// each aspect fails independently.
282pub trait SpaceService: Send + Sync + 'static {
283    /// Spaces whose body coincides with `space`.
284    fn measure_duplicates(&self, space: &ObjectId) -> Result<Vec<ObjectId>, SpaceError>;
285    /// The clear height of `space`.
286    fn measure_clear_height(&self, space: &ObjectId) -> Result<ClearHeightEvidence, SpaceError>;
287    /// Uncovered runs of the space boundary.
288    fn measure_boundary_gaps(&self, space: &ObjectId) -> Result<Vec<BoundaryGap>, SpaceError>;
289    /// Bodies overlapping `space`.
290    fn measure_overlaps(&self, space: &ObjectId) -> Result<Vec<SpaceOverlap>, SpaceError>;
291    /// Coverage of one horizontal cap of `space`.
292    fn measure_cap_coverage(&self, space: &ObjectId, cap: Cap) -> Result<CapCoverage, SpaceError>;
293    /// Floor area belonging to no space, per storey.
294    fn measure_storey_residuals(&self) -> Result<Vec<StoreyResidual>, SpaceError>;
295    /// Counts of the elements that can form horizontal caps.
296    fn measure_support_counts(&self) -> Result<SupportCounts, SpaceError>;
297    /// Evidence backing this service's measurements.
298    fn evidence(&self) -> Evidence;
299}
300
301/// Registry handle for a [`SpaceService`].
302#[derive(Clone)]
303pub struct SpaceServiceHandle(Arc<dyn SpaceService>);
304
305impl SpaceServiceHandle {
306    pub fn new(service: Arc<dyn SpaceService>) -> Self {
307        Self(service)
308    }
309    pub fn get(&self) -> &dyn SpaceService {
310        self.0.as_ref()
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use axioval_ir::SourceId;
318
319    fn source() -> SourceId {
320        SourceId::new("cad", "m").unwrap()
321    }
322    fn oid(local: &str) -> ObjectId {
323        ObjectId::new(source(), local).unwrap()
324    }
325
326    #[test]
327    fn cap_covered_over_its_own_area_is_refused() {
328        assert_eq!(
329            CapCoverage::try_new(10.0, 11.0, Vec::new()),
330            Err(SpaceError::InvalidQuantity)
331        );
332    }
333
334    #[test]
335    fn zero_cap_area_is_refused_so_the_ratio_cannot_divide_by_zero() {
336        assert_eq!(
337            CapCoverage::try_new(0.0, 0.0, Vec::new()),
338            Err(SpaceError::InvalidQuantity)
339        );
340    }
341
342    #[test]
343    fn cap_ratio_is_exact() {
344        let coverage = CapCoverage::try_new(4.0, 1.0, Vec::new()).unwrap();
345        assert!((coverage.covered_ratio() - 0.25).abs() < f64::EPSILON);
346    }
347
348    #[test]
349    fn element_lists_are_normalised() {
350        let gap = BoundaryGap::try_new(1.0, vec![oid("w2"), oid("w1"), oid("w2")]).unwrap();
351        assert_eq!(gap.elements(), &[oid("w1"), oid("w2")]);
352    }
353
354    #[test]
355    fn non_finite_quantities_are_refused() {
356        assert!(
357            ClearHeightEvidence::try_new(oid("s"), f64::NAN, Evidence::exact(source(), "h"))
358                .is_err()
359        );
360        assert!(BoundaryGap::try_new(f64::INFINITY, Vec::new()).is_err());
361        assert!(SpaceOverlap::try_new(oid("o"), false, -1.0, 1.0, Containment::Partial).is_err());
362        assert!(StoreyResidual::try_new(oid("st"), f64::NAN, Vec::new()).is_err());
363    }
364
365    #[test]
366    fn inexact_evidence_is_refused() {
367        assert_eq!(
368            ClearHeightEvidence::try_new(
369                oid("s"),
370                2.5,
371                Evidence {
372                    source: source(),
373                    locator: "h".into(),
374                    exact: false,
375                },
376            ),
377            Err(SpaceError::InexactEvidence)
378        );
379    }
380}