axioval-engine 0.3.0

Trusted capability compiler and deterministic source-neutral validation runtime
Documentation
//! Source-neutral envelope-membership evidence.
//!
//! ADR 0004: a service returns what was *measured*; a capability decides what
//! it means. Here the measurement is two sets of objects -- those a model
//! *declares* to be on the building envelope, and those geometry says *are* --
//! and the decision is whether they agree.
//!
//! Two things deliberately do not cross this seam:
//!
//! - **Applicability.** The source provider decided whether a model was worth
//!   checking at all by inspecting its industry domain, and returned an
//!   "irrelevant" flag that the rule then had to interpret. Whether a rule
//!   applies to a model is policy; a service that is asked a question answers
//!   it or reports that it cannot.
//! - **Derivation ambiguity.** The source returned every derivation at once,
//!   each behind an `Option`, leaving the rule to discover that the branch it
//!   wanted was missing. One request now names one derivation, so an
//!   unavailable derivation is an error rather than a silent `None`.

use std::sync::Arc;

use axioval_ir::{Evidence, ObjectId};

use crate::services::reviewable_exact_evidence;

/// Why envelope membership could not be measured.
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
pub enum EnvelopeMembershipError {
    /// The evidence backing the measurement was not exact and reviewable.
    #[error("envelope membership evidence must be exact and reviewable")]
    InexactEvidence,
    /// The adapter cannot derive membership for the requested scope.
    #[error("envelope membership is unavailable for the requested derivation")]
    Unavailable,
    /// The requested derivation is not supported by this source.
    #[error("requested envelope derivation is not supported by this source")]
    UnsupportedDerivation,
}

/// Which spatial extent the envelope is derived from.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[non_exhaustive]
pub enum EnvelopeDerivation {
    /// The spaces a rule selects bound the envelope.
    AllSpaces,
    /// The members of the gross-area groups a rule selects bound the envelope.
    GrossAreaGroups,
}

impl EnvelopeDerivation {
    pub fn as_str(self) -> &'static str {
        match self {
            EnvelopeDerivation::AllSpaces => "all-spaces",
            EnvelopeDerivation::GrossAreaGroups => "gross-area-groups",
        }
    }
}

/// A request for one envelope derivation over one model.
///
/// The bounding objects are the rule's choice, carried in the request like the
/// guard rule's walking surfaces: the request names exactly the objects whose
/// plan region the envelope is derived around, and the service uses those and
/// no others. The host declares no bounding set of its own.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EnvelopeMembershipRequest {
    derivation: EnvelopeDerivation,
    bounding: Vec<ObjectId>,
}

impl EnvelopeMembershipRequest {
    /// Asks for `derivation` around exactly `bounding`, kept in canonical
    /// order. An empty set is kept as stated; a service reports it
    /// unavailable, never an empty envelope.
    pub fn new(derivation: EnvelopeDerivation, mut bounding: Vec<ObjectId>) -> Self {
        bounding.sort();
        bounding.dedup();
        Self {
            derivation,
            bounding,
        }
    }
    pub fn derivation(&self) -> EnvelopeDerivation {
        self.derivation
    }
    /// The objects the envelope is derived around.
    pub fn bounding(&self) -> &[ObjectId] {
        &self.bounding
    }
}

/// Declared and derived envelope membership, with supporting evidence.
#[derive(Clone, Debug, PartialEq)]
pub struct EnvelopeMembershipEvidence {
    request: EnvelopeMembershipRequest,
    declared: Vec<ObjectId>,
    derived: Vec<ObjectId>,
    on_envelope: Vec<ObjectId>,
    undeclared: Vec<ObjectId>,
    evaluated_objects: usize,
    evidence: Evidence,
}

impl EnvelopeMembershipEvidence {
    /// Both sets are sorted and deduplicated so agreement is decided by
    /// content, never by the order an adapter happened to walk the model.
    pub fn try_new(
        request: EnvelopeMembershipRequest,
        mut declared: Vec<ObjectId>,
        mut derived: Vec<ObjectId>,
        evaluated_objects: usize,
        evidence: Evidence,
    ) -> Result<Self, EnvelopeMembershipError> {
        if !reviewable_exact_evidence(&evidence) {
            return Err(EnvelopeMembershipError::InexactEvidence);
        }
        declared.sort();
        declared.dedup();
        derived.sort();
        derived.dedup();
        Ok(Self {
            request,
            declared,
            on_envelope: derived.clone(),
            derived,
            undeclared: Vec::new(),
            evaluated_objects,
            evidence,
        })
    }

    /// Records objects that cannot be compared: the model states neither
    /// external nor internal, or their body could not be measured.
    ///
    /// An unstated declaration is unknown, not internal, and an unmeasured
    /// body is not known to be off the envelope, so these objects leave both
    /// sets: comparing them would report a discrepancy nobody established.
    #[must_use]
    pub fn with_undeclared(mut self, mut undeclared: Vec<ObjectId>) -> Self {
        undeclared.sort();
        undeclared.dedup();
        self.declared = difference(&self.declared, &undeclared);
        self.derived = difference(&self.derived, &undeclared);
        self.undeclared = undeclared;
        self
    }

    pub fn request(&self) -> &EnvelopeMembershipRequest {
        &self.request
    }
    /// Objects the model states are on the envelope.
    pub fn declared(&self) -> &[ObjectId] {
        &self.declared
    }
    /// Objects geometry places on the envelope, less the undeclared ones:
    /// the set compared with [`Self::declared`].
    pub fn derived(&self) -> &[ObjectId] {
        &self.derived
    }
    /// Objects geometry places on the envelope, whatever the model declares
    /// about them: what two derivations are compared by. An object whose body
    /// could not be measured is never in it.
    pub fn on_envelope(&self) -> &[ObjectId] {
        &self.on_envelope
    }
    /// Objects that cannot be compared, excluded from both sets.
    pub fn undeclared(&self) -> &[ObjectId] {
        &self.undeclared
    }
    /// How many objects the derivation considered.
    pub fn evaluated_objects(&self) -> usize {
        self.evaluated_objects
    }
    pub fn evidence(&self) -> &Evidence {
        &self.evidence
    }

    /// Whether the two sets hold exactly the same objects.
    pub fn agrees(&self) -> bool {
        self.declared == self.derived
    }

    /// Declared on the envelope but not derived there.
    pub fn declared_only(&self) -> Vec<ObjectId> {
        difference(&self.declared, &self.derived)
    }

    /// Derived on the envelope but not declared there.
    pub fn derived_only(&self) -> Vec<ObjectId> {
        difference(&self.derived, &self.declared)
    }
}

/// Both inputs are sorted and deduplicated, so a linear merge suffices.
fn difference(left: &[ObjectId], right: &[ObjectId]) -> Vec<ObjectId> {
    let mut out = Vec::new();
    let (mut i, mut j) = (0, 0);
    while i < left.len() {
        match right.get(j) {
            Some(candidate) if candidate < &left[i] => j += 1,
            Some(candidate) if candidate == &left[i] => {
                i += 1;
                j += 1;
            }
            _ => {
                out.push(left[i].clone());
                i += 1;
            }
        }
    }
    out
}

/// Measures which objects form a model's building envelope.
///
/// ADR 0004: every method returns a measurement. None returns a finding.
pub trait EnvelopeMembershipService: Send + Sync + 'static {
    fn measure_envelope_membership(
        &self,
        request: &EnvelopeMembershipRequest,
    ) -> Result<EnvelopeMembershipEvidence, EnvelopeMembershipError>;
}

/// Registry handle for an [`EnvelopeMembershipService`].
#[derive(Clone)]
pub struct EnvelopeMembershipServiceHandle(Arc<dyn EnvelopeMembershipService>);

impl EnvelopeMembershipServiceHandle {
    pub fn new(service: Arc<dyn EnvelopeMembershipService>) -> Self {
        Self(service)
    }
    pub fn measure_envelope_membership(
        &self,
        request: &EnvelopeMembershipRequest,
    ) -> Result<EnvelopeMembershipEvidence, EnvelopeMembershipError> {
        self.0.measure_envelope_membership(request)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use axioval_ir::SourceId;

    fn source() -> SourceId {
        SourceId::new("cad", "m").unwrap()
    }
    fn oid(local: &str) -> ObjectId {
        ObjectId::new(source(), local).unwrap()
    }
    fn build(declared: &[&str], derived: &[&str]) -> EnvelopeMembershipEvidence {
        EnvelopeMembershipEvidence::try_new(
            EnvelopeMembershipRequest::new(EnvelopeDerivation::AllSpaces, vec![oid("s")]),
            declared.iter().map(|s| oid(s)).collect(),
            derived.iter().map(|s| oid(s)).collect(),
            3,
            Evidence::exact(source(), "envelope:all-spaces"),
        )
        .unwrap()
    }

    /// Agreement is a set property. An adapter that reports the same walls in
    /// a different order, or twice, has not found a discrepancy.
    #[test]
    fn agreement_ignores_order_and_duplicates() {
        assert!(build(&["w2", "w1", "w2"], &["w1", "w2"]).agrees());
    }

    #[test]
    fn differences_are_reported_in_both_directions() {
        let measured = build(&["w1", "w2"], &["w2", "w3"]);
        assert!(!measured.agrees());
        assert_eq!(measured.declared_only(), vec![oid("w1")]);
        assert_eq!(measured.derived_only(), vec![oid("w3")]);
    }

    #[test]
    fn empty_sets_agree_and_have_no_differences() {
        let measured = build(&[], &[]);
        assert!(measured.agrees());
        assert!(measured.declared_only().is_empty());
        assert!(measured.derived_only().is_empty());
    }

    /// A model declaring nothing external while geometry finds walls is a real
    /// discrepancy, not an empty comparison.
    #[test]
    fn nothing_declared_against_derived_walls_is_a_difference() {
        let measured = build(&[], &["w1", "w2"]);
        assert!(!measured.agrees());
        assert_eq!(measured.derived_only(), vec![oid("w1"), oid("w2")]);
        assert!(measured.declared_only().is_empty());
    }

    /// An object the model does not declare either way cannot disagree.
    #[test]
    fn undeclared_objects_leave_both_sets() {
        let measured = build(&["w1"], &["w1", "w2"]).with_undeclared(vec![oid("w2")]);
        assert!(measured.agrees());
        assert_eq!(measured.undeclared(), &[oid("w2")]);
        // Geometry still places it on the envelope, whatever it declares.
        assert_eq!(measured.on_envelope(), &[oid("w1"), oid("w2")]);
        let measured = build(&["w3"], &[]).with_undeclared(vec![oid("w3")]);
        assert!(measured.agrees());
        assert!(measured.declared().is_empty());
    }

    /// Bounding sets compare by content, never by the order a rule found them.
    #[test]
    fn bounding_objects_are_canonical() {
        let request = EnvelopeMembershipRequest::new(
            EnvelopeDerivation::AllSpaces,
            vec![oid("s2"), oid("s1"), oid("s2")],
        );
        assert_eq!(request.bounding(), &[oid("s1"), oid("s2")]);
        assert_eq!(
            request,
            EnvelopeMembershipRequest::new(
                EnvelopeDerivation::AllSpaces,
                vec![oid("s1"), oid("s2")]
            )
        );
    }

    #[test]
    fn inexact_evidence_is_refused() {
        let result = EnvelopeMembershipEvidence::try_new(
            EnvelopeMembershipRequest::new(EnvelopeDerivation::AllSpaces, vec![oid("s")]),
            Vec::new(),
            Vec::new(),
            0,
            Evidence {
                source: source(),
                locator: "envelope:estimate".into(),
                exact: false,
            },
        );
        assert_eq!(result, Err(EnvelopeMembershipError::InexactEvidence));
    }
}