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/// One connected region of storey floor that belongs to no space.
207///
208/// Each region is measured on its own, so a rule can judge a large hole apart
209/// from small shafts on the same storey: a storey total would hide which one
210/// misses the allowance.
211#[derive(Clone, Debug, PartialEq)]
212pub struct UnallocatedRegion {
213    storey: ObjectId,
214    area_square_metres: f64,
215    elements: Vec<ObjectId>,
216    floor_area_square_metres: Option<f64>,
217}
218
219impl UnallocatedRegion {
220    /// A region of `area_square_metres` on `storey`, surrounded by
221    /// `elements` (the bodies whose footprint meets its boundary).
222    pub fn try_new(
223        storey: ObjectId,
224        area_square_metres: f64,
225        mut elements: Vec<ObjectId>,
226    ) -> Result<Self, SpaceError> {
227        if !finite_non_negative(area_square_metres) {
228            return Err(SpaceError::InvalidQuantity);
229        }
230        elements.sort();
231        elements.dedup();
232        Ok(Self {
233            storey,
234            area_square_metres,
235            elements,
236            floor_area_square_metres: None,
237        })
238    }
239    /// States the storey's gross floor area the region is part of, so a
240    /// rule can judge the region's share of it. A gross area smaller than
241    /// the region, negative or non-finite is refused.
242    pub fn with_floor_area(mut self, square_metres: f64) -> Result<Self, SpaceError> {
243        if !finite_non_negative(square_metres) || square_metres < self.area_square_metres {
244            return Err(SpaceError::InvalidQuantity);
245        }
246        self.floor_area_square_metres = Some(square_metres);
247        Ok(self)
248    }
249    pub fn storey(&self) -> &ObjectId {
250        &self.storey
251    }
252    pub fn area_square_metres(&self) -> f64 {
253        self.area_square_metres
254    }
255    /// The storey's gross floor area, when the service states it.
256    pub fn floor_area_square_metres(&self) -> Option<f64> {
257        self.floor_area_square_metres
258    }
259    /// The elements surrounding the region, in canonical order.
260    pub fn elements(&self) -> &[ObjectId] {
261        &self.elements
262    }
263}
264
265/// Which horizontal caps a model actually has elements for.
266///
267/// Counts, not a decision about which checks to run: the capability decides
268/// that a cap check without any slab is not worth reporting per space.
269#[derive(Clone, Debug, PartialEq)]
270pub struct SupportCounts {
271    slabs: usize,
272    roofs: usize,
273    buildings: Vec<ObjectId>,
274}
275
276impl SupportCounts {
277    pub fn new(slabs: usize, roofs: usize, mut buildings: Vec<ObjectId>) -> Self {
278        buildings.sort();
279        buildings.dedup();
280        Self {
281            slabs,
282            roofs,
283            buildings,
284        }
285    }
286    pub fn slabs(&self) -> usize {
287        self.slabs
288    }
289    pub fn roofs(&self) -> usize {
290        self.roofs
291    }
292    pub fn buildings(&self) -> &[ObjectId] {
293        &self.buildings
294    }
295}
296
297/// Which horizontal cap of a space is being measured.
298#[derive(Clone, Copy, Debug, PartialEq, Eq)]
299pub enum Cap {
300    Top,
301    Bottom,
302}
303
304/// A request for the coverage of one cap of a space.
305///
306/// Which elements may form the cap is a policy choice, so a rule can state it:
307/// [`Self::with_elements`] carries the rule's selection, and the service then
308/// considers exactly those elements. Without it the service falls back to the
309/// cap elements its host declared (slabs for the bottom, slabs and roofs for
310/// the top).
311#[derive(Clone, Debug, PartialEq, Eq)]
312pub struct CapRequest {
313    cap: Cap,
314    elements: Option<Vec<ObjectId>>,
315}
316
317impl CapRequest {
318    /// Asks for `cap`, bounded by the host-declared cap elements.
319    #[must_use]
320    pub fn new(cap: Cap) -> Self {
321        Self {
322            cap,
323            elements: None,
324        }
325    }
326    /// Asks for `cap` bounded by exactly these elements, in canonical order.
327    #[must_use]
328    pub fn with_elements(mut self, mut elements: Vec<ObjectId>) -> Self {
329        elements.sort();
330        elements.dedup();
331        self.elements = Some(elements);
332        self
333    }
334    pub fn cap(&self) -> Cap {
335        self.cap
336    }
337    /// The elements the caller chose, or `None` to use the host's declaration.
338    pub fn elements(&self) -> Option<&[ObjectId]> {
339        self.elements.as_deref()
340    }
341}
342
343/// A request for the uncovered runs of a space's boundary.
344///
345/// Which elements bound a space is a policy choice: [`Self::with_elements`]
346/// carries the rule's selection, and the service then counts exactly those
347/// elements as covering. Without it the service counts every body that is
348/// not a space.
349#[derive(Clone, Debug, Default, PartialEq, Eq)]
350pub struct BoundaryRequest {
351    elements: Option<Vec<ObjectId>>,
352}
353
354impl BoundaryRequest {
355    /// Asks for the gaps no body other than a space covers.
356    #[must_use]
357    pub fn new() -> Self {
358        Self::default()
359    }
360    /// Asks for the gaps none of these elements covers, in canonical order.
361    #[must_use]
362    pub fn with_elements(mut self, elements: Vec<ObjectId>) -> Self {
363        self.elements = Some(canonical(elements));
364        self
365    }
366    /// The elements the caller chose, or `None` for the host's default.
367    pub fn elements(&self) -> Option<&[ObjectId]> {
368        self.elements.as_deref()
369    }
370}
371
372/// A request for the bodies a space overlaps.
373///
374/// Which elements a space must not intersect is a policy choice:
375/// [`Self::with_elements`] carries the rule's selection, and the service then
376/// measures overlaps with exactly those elements. Without it every other body
377/// is measured.
378#[derive(Clone, Debug, Default, PartialEq, Eq)]
379pub struct OverlapRequest {
380    elements: Option<Vec<ObjectId>>,
381}
382
383impl OverlapRequest {
384    /// Asks for overlaps with every other body.
385    #[must_use]
386    pub fn new() -> Self {
387        Self::default()
388    }
389    /// Asks for overlaps with exactly these elements, in canonical order.
390    #[must_use]
391    pub fn with_elements(mut self, elements: Vec<ObjectId>) -> Self {
392        self.elements = Some(canonical(elements));
393        self
394    }
395    /// The elements the caller chose, or `None` for every other body.
396    pub fn elements(&self) -> Option<&[ObjectId]> {
397        self.elements.as_deref()
398    }
399}
400
401fn canonical(mut elements: Vec<ObjectId>) -> Vec<ObjectId> {
402    elements.sort();
403    elements.dedup();
404    elements
405}
406
407/// Measures the geometry a space-validation policy reasons about.
408///
409/// ADR 0004: every method returns a measurement. None returns a finding, and
410/// each aspect fails independently.
411pub trait SpaceService: Send + Sync + 'static {
412    /// Spaces whose body coincides with `space`.
413    fn measure_duplicates(&self, space: &ObjectId) -> Result<Vec<ObjectId>, SpaceError>;
414    /// The clear height of `space`.
415    fn measure_clear_height(&self, space: &ObjectId) -> Result<ClearHeightEvidence, SpaceError>;
416    /// Uncovered runs of the space boundary, covered only by the elements
417    /// `request` names or, when it names none, by any body but a space.
418    fn measure_boundary_gaps(
419        &self,
420        space: &ObjectId,
421        request: &BoundaryRequest,
422    ) -> Result<Vec<BoundaryGap>, SpaceError>;
423    /// Bodies overlapping `space`: the elements `request` names or, when it
424    /// names none, every other body.
425    fn measure_overlaps(
426        &self,
427        space: &ObjectId,
428        request: &OverlapRequest,
429    ) -> Result<Vec<SpaceOverlap>, SpaceError>;
430    /// Coverage of one horizontal cap of `space`, by the elements `request`
431    /// names or, when it names none, by the host-declared cap elements.
432    fn measure_cap_coverage(
433        &self,
434        space: &ObjectId,
435        request: &CapRequest,
436    ) -> Result<CapCoverage, SpaceError>;
437    /// Floor area belonging to no space, one entry per connected region of
438    /// each storey.
439    fn measure_unallocated_regions(&self) -> Result<Vec<UnallocatedRegion>, SpaceError>;
440    /// Counts of the elements that can form horizontal caps.
441    fn measure_support_counts(&self) -> Result<SupportCounts, SpaceError>;
442    /// Evidence backing this service's measurements.
443    fn evidence(&self) -> Evidence;
444}
445
446/// Registry handle for a [`SpaceService`].
447#[derive(Clone)]
448pub struct SpaceServiceHandle(Arc<dyn SpaceService>);
449
450impl SpaceServiceHandle {
451    pub fn new(service: Arc<dyn SpaceService>) -> Self {
452        Self(service)
453    }
454    pub fn get(&self) -> &dyn SpaceService {
455        self.0.as_ref()
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use axioval_ir::SourceId;
463
464    fn source() -> SourceId {
465        SourceId::new("cad", "m").unwrap()
466    }
467    fn oid(local: &str) -> ObjectId {
468        ObjectId::new(source(), local).unwrap()
469    }
470
471    #[test]
472    fn cap_covered_over_its_own_area_is_refused() {
473        assert_eq!(
474            CapCoverage::try_new(10.0, 11.0, Vec::new()),
475            Err(SpaceError::InvalidQuantity)
476        );
477    }
478
479    #[test]
480    fn zero_cap_area_is_refused_so_the_ratio_cannot_divide_by_zero() {
481        assert_eq!(
482            CapCoverage::try_new(0.0, 0.0, Vec::new()),
483            Err(SpaceError::InvalidQuantity)
484        );
485    }
486
487    /// A geometry kernel can hand back -0.0 for an empty intersection. It
488    /// compares equal to zero but renders as "-0.0", so an uncovered cap would
489    /// be reported as "-0.0% covered".
490    #[test]
491    fn negative_zero_coverage_is_normalised() {
492        let coverage = CapCoverage::try_new(10.0, -0.0, Vec::new()).unwrap();
493        assert_eq!(format!("{:.1}", coverage.covered_ratio() * 100.0), "0.0");
494    }
495
496    #[test]
497    fn cap_request_elements_are_canonical_and_absent_by_default() {
498        assert_eq!(CapRequest::new(Cap::Top).elements(), None);
499        let request =
500            CapRequest::new(Cap::Bottom).with_elements(vec![oid("b"), oid("a"), oid("b")]);
501        assert_eq!(request.cap(), Cap::Bottom);
502        assert_eq!(request.elements(), Some(&[oid("a"), oid("b")][..]));
503        // An explicit empty selection is a statement, not an absence.
504        assert_eq!(
505            CapRequest::new(Cap::Top)
506                .with_elements(Vec::new())
507                .elements(),
508            Some(&[][..])
509        );
510    }
511
512    #[test]
513    fn boundary_and_overlap_requests_are_canonical_and_absent_by_default() {
514        assert_eq!(BoundaryRequest::new().elements(), None);
515        assert_eq!(OverlapRequest::new().elements(), None);
516        let ids = vec![oid("b"), oid("a"), oid("b")];
517        assert_eq!(
518            BoundaryRequest::new().with_elements(ids.clone()).elements(),
519            Some(&[oid("a"), oid("b")][..])
520        );
521        assert_eq!(
522            OverlapRequest::new().with_elements(ids).elements(),
523            Some(&[oid("a"), oid("b")][..])
524        );
525    }
526
527    #[test]
528    fn cap_ratio_is_exact() {
529        let coverage = CapCoverage::try_new(4.0, 1.0, Vec::new()).unwrap();
530        assert!((coverage.covered_ratio() - 0.25).abs() < f64::EPSILON);
531    }
532
533    #[test]
534    fn element_lists_are_normalised() {
535        let gap = BoundaryGap::try_new(1.0, vec![oid("w2"), oid("w1"), oid("w2")]).unwrap();
536        assert_eq!(gap.elements(), &[oid("w1"), oid("w2")]);
537    }
538
539    #[test]
540    fn non_finite_quantities_are_refused() {
541        assert!(
542            ClearHeightEvidence::try_new(oid("s"), f64::NAN, Evidence::exact(source(), "h"))
543                .is_err()
544        );
545        assert!(BoundaryGap::try_new(f64::INFINITY, Vec::new()).is_err());
546        assert!(SpaceOverlap::try_new(oid("o"), false, -1.0, 1.0, Containment::Partial).is_err());
547        assert!(UnallocatedRegion::try_new(oid("st"), f64::NAN, Vec::new()).is_err());
548        let region = UnallocatedRegion::try_new(oid("st"), 5.0, Vec::new()).unwrap();
549        assert_eq!(region.floor_area_square_metres(), None);
550        // A gross area smaller than the region cannot hold it.
551        assert!(region.clone().with_floor_area(4.0).is_err());
552        assert!(region.clone().with_floor_area(f64::INFINITY).is_err());
553        assert_eq!(
554            region
555                .with_floor_area(100.0)
556                .unwrap()
557                .floor_area_square_metres(),
558            Some(100.0)
559        );
560    }
561
562    #[test]
563    fn inexact_evidence_is_refused() {
564        assert_eq!(
565            ClearHeightEvidence::try_new(
566                oid("s"),
567                2.5,
568                Evidence {
569                    source: source(),
570                    locator: "h".into(),
571                    exact: false,
572                },
573            ),
574            Err(SpaceError::InexactEvidence)
575        );
576    }
577}