Skip to main content

axioval_engine/
envelope_membership.rs

1//! Source-neutral envelope-membership evidence.
2//!
3//! ADR 0004: a service returns what was *measured*; a capability decides what
4//! it means. Here the measurement is two sets of objects -- those a model
5//! *declares* to be on the building envelope, and those geometry says *are* --
6//! and the decision is whether they agree.
7//!
8//! Two things deliberately do not cross this seam:
9//!
10//! - **Applicability.** The source provider decided whether a model was worth
11//!   checking at all by inspecting its industry domain, and returned an
12//!   "irrelevant" flag that the rule then had to interpret. Whether a rule
13//!   applies to a model is policy; a service that is asked a question answers
14//!   it or reports that it cannot.
15//! - **Derivation ambiguity.** The source returned every derivation at once,
16//!   each behind an `Option`, leaving the rule to discover that the branch it
17//!   wanted was missing. One request now names one derivation, so an
18//!   unavailable derivation is an error rather than a silent `None`.
19
20use std::sync::Arc;
21
22use axioval_ir::{Evidence, ObjectId};
23
24use crate::services::reviewable_exact_evidence;
25
26/// Why envelope membership could not be measured.
27#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
28pub enum EnvelopeMembershipError {
29    /// The evidence backing the measurement was not exact and reviewable.
30    #[error("envelope membership evidence must be exact and reviewable")]
31    InexactEvidence,
32    /// The adapter cannot derive membership for the requested scope.
33    #[error("envelope membership is unavailable for the requested derivation")]
34    Unavailable,
35    /// The requested derivation is not supported by this source.
36    #[error("requested envelope derivation is not supported by this source")]
37    UnsupportedDerivation,
38}
39
40/// Which spatial extent the envelope is derived from.
41#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
42#[non_exhaustive]
43pub enum EnvelopeDerivation {
44    /// Every space in the model bounds the envelope.
45    AllSpaces,
46    /// Only spaces belonging to gross-area groups bound the envelope.
47    GrossAreaGroups,
48}
49
50impl EnvelopeDerivation {
51    pub fn as_str(self) -> &'static str {
52        match self {
53            EnvelopeDerivation::AllSpaces => "all-spaces",
54            EnvelopeDerivation::GrossAreaGroups => "gross-area-groups",
55        }
56    }
57}
58
59/// A request for one envelope derivation over one model.
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61pub struct EnvelopeMembershipRequest {
62    derivation: EnvelopeDerivation,
63}
64
65impl EnvelopeMembershipRequest {
66    pub fn new(derivation: EnvelopeDerivation) -> Self {
67        Self { derivation }
68    }
69    pub fn derivation(&self) -> EnvelopeDerivation {
70        self.derivation
71    }
72}
73
74/// Declared and derived envelope membership, with supporting evidence.
75#[derive(Clone, Debug, PartialEq)]
76pub struct EnvelopeMembershipEvidence {
77    request: EnvelopeMembershipRequest,
78    declared: Vec<ObjectId>,
79    derived: Vec<ObjectId>,
80    evaluated_objects: usize,
81    evidence: Evidence,
82}
83
84impl EnvelopeMembershipEvidence {
85    /// Both sets are sorted and deduplicated so agreement is decided by
86    /// content, never by the order an adapter happened to walk the model.
87    pub fn try_new(
88        request: EnvelopeMembershipRequest,
89        mut declared: Vec<ObjectId>,
90        mut derived: Vec<ObjectId>,
91        evaluated_objects: usize,
92        evidence: Evidence,
93    ) -> Result<Self, EnvelopeMembershipError> {
94        if !reviewable_exact_evidence(&evidence) {
95            return Err(EnvelopeMembershipError::InexactEvidence);
96        }
97        declared.sort();
98        declared.dedup();
99        derived.sort();
100        derived.dedup();
101        Ok(Self {
102            request,
103            declared,
104            derived,
105            evaluated_objects,
106            evidence,
107        })
108    }
109
110    pub fn request(&self) -> EnvelopeMembershipRequest {
111        self.request
112    }
113    /// Objects the model states are on the envelope.
114    pub fn declared(&self) -> &[ObjectId] {
115        &self.declared
116    }
117    /// Objects geometry places on the envelope.
118    pub fn derived(&self) -> &[ObjectId] {
119        &self.derived
120    }
121    /// How many objects the derivation considered.
122    pub fn evaluated_objects(&self) -> usize {
123        self.evaluated_objects
124    }
125    pub fn evidence(&self) -> &Evidence {
126        &self.evidence
127    }
128
129    /// Whether the two sets hold exactly the same objects.
130    pub fn agrees(&self) -> bool {
131        self.declared == self.derived
132    }
133
134    /// Declared on the envelope but not derived there.
135    pub fn declared_only(&self) -> Vec<ObjectId> {
136        difference(&self.declared, &self.derived)
137    }
138
139    /// Derived on the envelope but not declared there.
140    pub fn derived_only(&self) -> Vec<ObjectId> {
141        difference(&self.derived, &self.declared)
142    }
143}
144
145/// Both inputs are sorted and deduplicated, so a linear merge suffices.
146fn difference(left: &[ObjectId], right: &[ObjectId]) -> Vec<ObjectId> {
147    let mut out = Vec::new();
148    let (mut i, mut j) = (0, 0);
149    while i < left.len() {
150        match right.get(j) {
151            Some(candidate) if candidate < &left[i] => j += 1,
152            Some(candidate) if candidate == &left[i] => {
153                i += 1;
154                j += 1;
155            }
156            _ => {
157                out.push(left[i].clone());
158                i += 1;
159            }
160        }
161    }
162    out
163}
164
165/// Measures which objects form a model's building envelope.
166///
167/// ADR 0004: every method returns a measurement. None returns a finding.
168pub trait EnvelopeMembershipService: Send + Sync + 'static {
169    fn measure_envelope_membership(
170        &self,
171        request: &EnvelopeMembershipRequest,
172    ) -> Result<EnvelopeMembershipEvidence, EnvelopeMembershipError>;
173}
174
175/// Registry handle for an [`EnvelopeMembershipService`].
176#[derive(Clone)]
177pub struct EnvelopeMembershipServiceHandle(Arc<dyn EnvelopeMembershipService>);
178
179impl EnvelopeMembershipServiceHandle {
180    pub fn new(service: Arc<dyn EnvelopeMembershipService>) -> Self {
181        Self(service)
182    }
183    pub fn measure_envelope_membership(
184        &self,
185        request: &EnvelopeMembershipRequest,
186    ) -> Result<EnvelopeMembershipEvidence, EnvelopeMembershipError> {
187        self.0.measure_envelope_membership(request)
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use axioval_ir::SourceId;
195
196    fn source() -> SourceId {
197        SourceId::new("cad", "m").unwrap()
198    }
199    fn oid(local: &str) -> ObjectId {
200        ObjectId::new(source(), local).unwrap()
201    }
202    fn build(declared: &[&str], derived: &[&str]) -> EnvelopeMembershipEvidence {
203        EnvelopeMembershipEvidence::try_new(
204            EnvelopeMembershipRequest::new(EnvelopeDerivation::AllSpaces),
205            declared.iter().map(|s| oid(s)).collect(),
206            derived.iter().map(|s| oid(s)).collect(),
207            3,
208            Evidence::exact(source(), "envelope:all-spaces"),
209        )
210        .unwrap()
211    }
212
213    /// Agreement is a set property. An adapter that reports the same walls in
214    /// a different order, or twice, has not found a discrepancy.
215    #[test]
216    fn agreement_ignores_order_and_duplicates() {
217        assert!(build(&["w2", "w1", "w2"], &["w1", "w2"]).agrees());
218    }
219
220    #[test]
221    fn differences_are_reported_in_both_directions() {
222        let measured = build(&["w1", "w2"], &["w2", "w3"]);
223        assert!(!measured.agrees());
224        assert_eq!(measured.declared_only(), vec![oid("w1")]);
225        assert_eq!(measured.derived_only(), vec![oid("w3")]);
226    }
227
228    #[test]
229    fn empty_sets_agree_and_have_no_differences() {
230        let measured = build(&[], &[]);
231        assert!(measured.agrees());
232        assert!(measured.declared_only().is_empty());
233        assert!(measured.derived_only().is_empty());
234    }
235
236    /// A model declaring nothing external while geometry finds walls is a real
237    /// discrepancy, not an empty comparison.
238    #[test]
239    fn nothing_declared_against_derived_walls_is_a_difference() {
240        let measured = build(&[], &["w1", "w2"]);
241        assert!(!measured.agrees());
242        assert_eq!(measured.derived_only(), vec![oid("w1"), oid("w2")]);
243        assert!(measured.declared_only().is_empty());
244    }
245
246    #[test]
247    fn inexact_evidence_is_refused() {
248        let result = EnvelopeMembershipEvidence::try_new(
249            EnvelopeMembershipRequest::new(EnvelopeDerivation::AllSpaces),
250            Vec::new(),
251            Vec::new(),
252            0,
253            Evidence {
254                source: source(),
255                locator: "envelope:estimate".into(),
256                exact: false,
257            },
258        );
259        assert_eq!(result, Err(EnvelopeMembershipError::InexactEvidence));
260    }
261}