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    /// The spaces a rule selects bound the envelope.
45    AllSpaces,
46    /// The members of the gross-area groups a rule selects 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///
61/// The bounding objects are the rule's choice, carried in the request like the
62/// guard rule's walking surfaces: the request names exactly the objects whose
63/// plan region the envelope is derived around, and the service uses those and
64/// no others. The host declares no bounding set of its own.
65#[derive(Clone, Debug, PartialEq, Eq)]
66pub struct EnvelopeMembershipRequest {
67    derivation: EnvelopeDerivation,
68    bounding: Vec<ObjectId>,
69}
70
71impl EnvelopeMembershipRequest {
72    /// Asks for `derivation` around exactly `bounding`, kept in canonical
73    /// order. An empty set is kept as stated; a service reports it
74    /// unavailable, never an empty envelope.
75    pub fn new(derivation: EnvelopeDerivation, mut bounding: Vec<ObjectId>) -> Self {
76        bounding.sort();
77        bounding.dedup();
78        Self {
79            derivation,
80            bounding,
81        }
82    }
83    pub fn derivation(&self) -> EnvelopeDerivation {
84        self.derivation
85    }
86    /// The objects the envelope is derived around.
87    pub fn bounding(&self) -> &[ObjectId] {
88        &self.bounding
89    }
90}
91
92/// Declared and derived envelope membership, with supporting evidence.
93#[derive(Clone, Debug, PartialEq)]
94pub struct EnvelopeMembershipEvidence {
95    request: EnvelopeMembershipRequest,
96    declared: Vec<ObjectId>,
97    derived: Vec<ObjectId>,
98    on_envelope: Vec<ObjectId>,
99    undeclared: Vec<ObjectId>,
100    evaluated_objects: usize,
101    evidence: Evidence,
102}
103
104impl EnvelopeMembershipEvidence {
105    /// Both sets are sorted and deduplicated so agreement is decided by
106    /// content, never by the order an adapter happened to walk the model.
107    pub fn try_new(
108        request: EnvelopeMembershipRequest,
109        mut declared: Vec<ObjectId>,
110        mut derived: Vec<ObjectId>,
111        evaluated_objects: usize,
112        evidence: Evidence,
113    ) -> Result<Self, EnvelopeMembershipError> {
114        if !reviewable_exact_evidence(&evidence) {
115            return Err(EnvelopeMembershipError::InexactEvidence);
116        }
117        declared.sort();
118        declared.dedup();
119        derived.sort();
120        derived.dedup();
121        Ok(Self {
122            request,
123            declared,
124            on_envelope: derived.clone(),
125            derived,
126            undeclared: Vec::new(),
127            evaluated_objects,
128            evidence,
129        })
130    }
131
132    /// Records objects that cannot be compared: the model states neither
133    /// external nor internal, or their body could not be measured.
134    ///
135    /// An unstated declaration is unknown, not internal, and an unmeasured
136    /// body is not known to be off the envelope, so these objects leave both
137    /// sets: comparing them would report a discrepancy nobody established.
138    #[must_use]
139    pub fn with_undeclared(mut self, mut undeclared: Vec<ObjectId>) -> Self {
140        undeclared.sort();
141        undeclared.dedup();
142        self.declared = difference(&self.declared, &undeclared);
143        self.derived = difference(&self.derived, &undeclared);
144        self.undeclared = undeclared;
145        self
146    }
147
148    pub fn request(&self) -> &EnvelopeMembershipRequest {
149        &self.request
150    }
151    /// Objects the model states are on the envelope.
152    pub fn declared(&self) -> &[ObjectId] {
153        &self.declared
154    }
155    /// Objects geometry places on the envelope, less the undeclared ones:
156    /// the set compared with [`Self::declared`].
157    pub fn derived(&self) -> &[ObjectId] {
158        &self.derived
159    }
160    /// Objects geometry places on the envelope, whatever the model declares
161    /// about them: what two derivations are compared by. An object whose body
162    /// could not be measured is never in it.
163    pub fn on_envelope(&self) -> &[ObjectId] {
164        &self.on_envelope
165    }
166    /// Objects that cannot be compared, excluded from both sets.
167    pub fn undeclared(&self) -> &[ObjectId] {
168        &self.undeclared
169    }
170    /// How many objects the derivation considered.
171    pub fn evaluated_objects(&self) -> usize {
172        self.evaluated_objects
173    }
174    pub fn evidence(&self) -> &Evidence {
175        &self.evidence
176    }
177
178    /// Whether the two sets hold exactly the same objects.
179    pub fn agrees(&self) -> bool {
180        self.declared == self.derived
181    }
182
183    /// Declared on the envelope but not derived there.
184    pub fn declared_only(&self) -> Vec<ObjectId> {
185        difference(&self.declared, &self.derived)
186    }
187
188    /// Derived on the envelope but not declared there.
189    pub fn derived_only(&self) -> Vec<ObjectId> {
190        difference(&self.derived, &self.declared)
191    }
192}
193
194/// Both inputs are sorted and deduplicated, so a linear merge suffices.
195fn difference(left: &[ObjectId], right: &[ObjectId]) -> Vec<ObjectId> {
196    let mut out = Vec::new();
197    let (mut i, mut j) = (0, 0);
198    while i < left.len() {
199        match right.get(j) {
200            Some(candidate) if candidate < &left[i] => j += 1,
201            Some(candidate) if candidate == &left[i] => {
202                i += 1;
203                j += 1;
204            }
205            _ => {
206                out.push(left[i].clone());
207                i += 1;
208            }
209        }
210    }
211    out
212}
213
214/// Measures which objects form a model's building envelope.
215///
216/// ADR 0004: every method returns a measurement. None returns a finding.
217pub trait EnvelopeMembershipService: Send + Sync + 'static {
218    fn measure_envelope_membership(
219        &self,
220        request: &EnvelopeMembershipRequest,
221    ) -> Result<EnvelopeMembershipEvidence, EnvelopeMembershipError>;
222}
223
224/// Registry handle for an [`EnvelopeMembershipService`].
225#[derive(Clone)]
226pub struct EnvelopeMembershipServiceHandle(Arc<dyn EnvelopeMembershipService>);
227
228impl EnvelopeMembershipServiceHandle {
229    pub fn new(service: Arc<dyn EnvelopeMembershipService>) -> Self {
230        Self(service)
231    }
232    pub fn measure_envelope_membership(
233        &self,
234        request: &EnvelopeMembershipRequest,
235    ) -> Result<EnvelopeMembershipEvidence, EnvelopeMembershipError> {
236        self.0.measure_envelope_membership(request)
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use axioval_ir::SourceId;
244
245    fn source() -> SourceId {
246        SourceId::new("cad", "m").unwrap()
247    }
248    fn oid(local: &str) -> ObjectId {
249        ObjectId::new(source(), local).unwrap()
250    }
251    fn build(declared: &[&str], derived: &[&str]) -> EnvelopeMembershipEvidence {
252        EnvelopeMembershipEvidence::try_new(
253            EnvelopeMembershipRequest::new(EnvelopeDerivation::AllSpaces, vec![oid("s")]),
254            declared.iter().map(|s| oid(s)).collect(),
255            derived.iter().map(|s| oid(s)).collect(),
256            3,
257            Evidence::exact(source(), "envelope:all-spaces"),
258        )
259        .unwrap()
260    }
261
262    /// Agreement is a set property. An adapter that reports the same walls in
263    /// a different order, or twice, has not found a discrepancy.
264    #[test]
265    fn agreement_ignores_order_and_duplicates() {
266        assert!(build(&["w2", "w1", "w2"], &["w1", "w2"]).agrees());
267    }
268
269    #[test]
270    fn differences_are_reported_in_both_directions() {
271        let measured = build(&["w1", "w2"], &["w2", "w3"]);
272        assert!(!measured.agrees());
273        assert_eq!(measured.declared_only(), vec![oid("w1")]);
274        assert_eq!(measured.derived_only(), vec![oid("w3")]);
275    }
276
277    #[test]
278    fn empty_sets_agree_and_have_no_differences() {
279        let measured = build(&[], &[]);
280        assert!(measured.agrees());
281        assert!(measured.declared_only().is_empty());
282        assert!(measured.derived_only().is_empty());
283    }
284
285    /// A model declaring nothing external while geometry finds walls is a real
286    /// discrepancy, not an empty comparison.
287    #[test]
288    fn nothing_declared_against_derived_walls_is_a_difference() {
289        let measured = build(&[], &["w1", "w2"]);
290        assert!(!measured.agrees());
291        assert_eq!(measured.derived_only(), vec![oid("w1"), oid("w2")]);
292        assert!(measured.declared_only().is_empty());
293    }
294
295    /// An object the model does not declare either way cannot disagree.
296    #[test]
297    fn undeclared_objects_leave_both_sets() {
298        let measured = build(&["w1"], &["w1", "w2"]).with_undeclared(vec![oid("w2")]);
299        assert!(measured.agrees());
300        assert_eq!(measured.undeclared(), &[oid("w2")]);
301        // Geometry still places it on the envelope, whatever it declares.
302        assert_eq!(measured.on_envelope(), &[oid("w1"), oid("w2")]);
303        let measured = build(&["w3"], &[]).with_undeclared(vec![oid("w3")]);
304        assert!(measured.agrees());
305        assert!(measured.declared().is_empty());
306    }
307
308    /// Bounding sets compare by content, never by the order a rule found them.
309    #[test]
310    fn bounding_objects_are_canonical() {
311        let request = EnvelopeMembershipRequest::new(
312            EnvelopeDerivation::AllSpaces,
313            vec![oid("s2"), oid("s1"), oid("s2")],
314        );
315        assert_eq!(request.bounding(), &[oid("s1"), oid("s2")]);
316        assert_eq!(
317            request,
318            EnvelopeMembershipRequest::new(
319                EnvelopeDerivation::AllSpaces,
320                vec![oid("s1"), oid("s2")]
321            )
322        );
323    }
324
325    #[test]
326    fn inexact_evidence_is_refused() {
327        let result = EnvelopeMembershipEvidence::try_new(
328            EnvelopeMembershipRequest::new(EnvelopeDerivation::AllSpaces, vec![oid("s")]),
329            Vec::new(),
330            Vec::new(),
331            0,
332            Evidence {
333                source: source(),
334                locator: "envelope:estimate".into(),
335                exact: false,
336            },
337        );
338        assert_eq!(result, Err(EnvelopeMembershipError::InexactEvidence));
339    }
340}