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            // A geometry kernel can return -0.0 for an empty intersection.
183            // It compares equal to 0.0 but renders as "-0.0", so a cap with no
184            // coverage would report "-0.0% covered". Normalise at the boundary.
185            covered_area_square_metres: covered_area_square_metres + 0.0,
186            elements,
187        })
188    }
189    pub fn whole_area_square_metres(&self) -> f64 {
190        self.whole_area_square_metres
191    }
192    pub fn covered_area_square_metres(&self) -> f64 {
193        self.covered_area_square_metres
194    }
195    pub fn elements(&self) -> &[ObjectId] {
196        &self.elements
197    }
198    /// Fraction of the cap that is covered, computed exactly.
199    ///
200    /// The divisor is validated positive in [`Self::try_new`].
201    pub fn covered_ratio(&self) -> f64 {
202        self.covered_area_square_metres / self.whole_area_square_metres
203    }
204}
205
206/// Floor area on a storey that belongs to no space.
207#[derive(Clone, Debug, PartialEq)]
208pub struct StoreyResidual {
209    storey: ObjectId,
210    area_square_metres: f64,
211    elements: Vec<ObjectId>,
212}
213
214impl StoreyResidual {
215    pub fn try_new(
216        storey: ObjectId,
217        area_square_metres: f64,
218        mut elements: Vec<ObjectId>,
219    ) -> Result<Self, SpaceError> {
220        if !finite_non_negative(area_square_metres) {
221            return Err(SpaceError::InvalidQuantity);
222        }
223        elements.sort();
224        elements.dedup();
225        Ok(Self {
226            storey,
227            area_square_metres,
228            elements,
229        })
230    }
231    pub fn storey(&self) -> &ObjectId {
232        &self.storey
233    }
234    pub fn area_square_metres(&self) -> f64 {
235        self.area_square_metres
236    }
237    pub fn elements(&self) -> &[ObjectId] {
238        &self.elements
239    }
240}
241
242/// Which horizontal caps a model actually has elements for.
243///
244/// Counts, not a decision about which checks to run: the capability decides
245/// that a cap check without any slab is not worth reporting per space.
246#[derive(Clone, Debug, PartialEq)]
247pub struct SupportCounts {
248    slabs: usize,
249    roofs: usize,
250    buildings: Vec<ObjectId>,
251}
252
253impl SupportCounts {
254    pub fn new(slabs: usize, roofs: usize, mut buildings: Vec<ObjectId>) -> Self {
255        buildings.sort();
256        buildings.dedup();
257        Self {
258            slabs,
259            roofs,
260            buildings,
261        }
262    }
263    pub fn slabs(&self) -> usize {
264        self.slabs
265    }
266    pub fn roofs(&self) -> usize {
267        self.roofs
268    }
269    pub fn buildings(&self) -> &[ObjectId] {
270        &self.buildings
271    }
272}
273
274/// Which horizontal cap of a space is being measured.
275#[derive(Clone, Copy, Debug, PartialEq, Eq)]
276pub enum Cap {
277    Top,
278    Bottom,
279}
280
281/// Measures the geometry a space-validation policy reasons about.
282///
283/// ADR 0004: every method returns a measurement. None returns a finding, and
284/// each aspect fails independently.
285pub trait SpaceService: Send + Sync + 'static {
286    /// Spaces whose body coincides with `space`.
287    fn measure_duplicates(&self, space: &ObjectId) -> Result<Vec<ObjectId>, SpaceError>;
288    /// The clear height of `space`.
289    fn measure_clear_height(&self, space: &ObjectId) -> Result<ClearHeightEvidence, SpaceError>;
290    /// Uncovered runs of the space boundary.
291    fn measure_boundary_gaps(&self, space: &ObjectId) -> Result<Vec<BoundaryGap>, SpaceError>;
292    /// Bodies overlapping `space`.
293    fn measure_overlaps(&self, space: &ObjectId) -> Result<Vec<SpaceOverlap>, SpaceError>;
294    /// Coverage of one horizontal cap of `space`.
295    fn measure_cap_coverage(&self, space: &ObjectId, cap: Cap) -> Result<CapCoverage, SpaceError>;
296    /// Floor area belonging to no space, per storey.
297    fn measure_storey_residuals(&self) -> Result<Vec<StoreyResidual>, SpaceError>;
298    /// Counts of the elements that can form horizontal caps.
299    fn measure_support_counts(&self) -> Result<SupportCounts, SpaceError>;
300    /// Evidence backing this service's measurements.
301    fn evidence(&self) -> Evidence;
302}
303
304/// Registry handle for a [`SpaceService`].
305#[derive(Clone)]
306pub struct SpaceServiceHandle(Arc<dyn SpaceService>);
307
308impl SpaceServiceHandle {
309    pub fn new(service: Arc<dyn SpaceService>) -> Self {
310        Self(service)
311    }
312    pub fn get(&self) -> &dyn SpaceService {
313        self.0.as_ref()
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use axioval_ir::SourceId;
321
322    fn source() -> SourceId {
323        SourceId::new("cad", "m").unwrap()
324    }
325    fn oid(local: &str) -> ObjectId {
326        ObjectId::new(source(), local).unwrap()
327    }
328
329    #[test]
330    fn cap_covered_over_its_own_area_is_refused() {
331        assert_eq!(
332            CapCoverage::try_new(10.0, 11.0, Vec::new()),
333            Err(SpaceError::InvalidQuantity)
334        );
335    }
336
337    #[test]
338    fn zero_cap_area_is_refused_so_the_ratio_cannot_divide_by_zero() {
339        assert_eq!(
340            CapCoverage::try_new(0.0, 0.0, Vec::new()),
341            Err(SpaceError::InvalidQuantity)
342        );
343    }
344
345    /// A geometry kernel can hand back -0.0 for an empty intersection. It
346    /// compares equal to zero but renders as "-0.0", so an uncovered cap would
347    /// be reported as "-0.0% covered".
348    #[test]
349    fn negative_zero_coverage_is_normalised() {
350        let coverage = CapCoverage::try_new(10.0, -0.0, Vec::new()).unwrap();
351        assert_eq!(format!("{:.1}", coverage.covered_ratio() * 100.0), "0.0");
352    }
353
354    #[test]
355    fn cap_ratio_is_exact() {
356        let coverage = CapCoverage::try_new(4.0, 1.0, Vec::new()).unwrap();
357        assert!((coverage.covered_ratio() - 0.25).abs() < f64::EPSILON);
358    }
359
360    #[test]
361    fn element_lists_are_normalised() {
362        let gap = BoundaryGap::try_new(1.0, vec![oid("w2"), oid("w1"), oid("w2")]).unwrap();
363        assert_eq!(gap.elements(), &[oid("w1"), oid("w2")]);
364    }
365
366    #[test]
367    fn non_finite_quantities_are_refused() {
368        assert!(
369            ClearHeightEvidence::try_new(oid("s"), f64::NAN, Evidence::exact(source(), "h"))
370                .is_err()
371        );
372        assert!(BoundaryGap::try_new(f64::INFINITY, Vec::new()).is_err());
373        assert!(SpaceOverlap::try_new(oid("o"), false, -1.0, 1.0, Containment::Partial).is_err());
374        assert!(StoreyResidual::try_new(oid("st"), f64::NAN, Vec::new()).is_err());
375    }
376
377    #[test]
378    fn inexact_evidence_is_refused() {
379        assert_eq!(
380            ClearHeightEvidence::try_new(
381                oid("s"),
382                2.5,
383                Evidence {
384                    source: source(),
385                    locator: "h".into(),
386                    exact: false,
387                },
388            ),
389            Err(SpaceError::InexactEvidence)
390        );
391    }
392}