Skip to main content

axioval_engine/
concepts.rs

1//! Binding canonical package concepts to the vocabulary of each source.
2//!
3//! Rule packages name concepts (`axioval:fire.ifc4.wall`), and each concept
4//! lists the names it has in external type systems. Sources expose data in
5//! their own vocabulary (`IfcWall`, `Pset_WallCommon`). Evaluation therefore
6//! translates per source, and only through a name declared for exactly the
7//! type system that source's snapshot declares.
8//!
9//! A concept that cannot bind is never a silent non-match: without this
10//! module a wall selector over an IFC model selected nothing and the rule
11//! reported a clean pass.
12
13use std::{
14    collections::{BTreeMap, BTreeSet},
15    sync::Arc,
16};
17
18use axioval_ir::SourceId;
19use axioval_ir::contract::ExternalName;
20use thiserror::Error;
21
22use crate::session::{SnapshotBoundService, SourceSnapshot};
23
24/// Which catalog a concept belongs to.
25#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
26pub enum ConceptKind {
27    /// An object type such as a wall.
28    ObjectType,
29    /// A property such as a fire rating.
30    Property,
31    /// A property-set qualifier.
32    PropertySet,
33}
34
35impl std::fmt::Display for ConceptKind {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        f.write_str(match self {
38            Self::ObjectType => "object-type",
39            Self::Property => "property",
40            Self::PropertySet => "property-set",
41        })
42    }
43}
44
45/// Why a concept could not be bound for one source.
46#[derive(Clone, Debug, Error, Eq, PartialEq)]
47pub enum BindingError {
48    /// The concept is not declared by any loaded definition package.
49    #[error("unknown {kind} concept `{concept}`")]
50    UnknownConcept {
51        /// Catalog the concept was looked up in.
52        kind: ConceptKind,
53        /// Concept identifier.
54        concept: String,
55    },
56    /// The source declares no type system, so no concept can bind to it.
57    #[error("source `{0}` declares no type system, so package concepts cannot bind to it")]
58    UndeclaredTypeSystem(SourceId),
59    /// The concept has no name in any type system the source declared.
60    #[error("{kind} concept `{concept}` has no external name in type systems {type_systems:?}")]
61    Unbound {
62        /// Catalog the concept belongs to.
63        kind: ConceptKind,
64        /// Concept identifier.
65        concept: String,
66        /// Type systems the source declared.
67        type_systems: Vec<String>,
68    },
69    /// The concept has different names in several declared type systems.
70    ///
71    /// Picking one would decide which vocabulary the source "really" uses,
72    /// which only the source can say, so the binding is refused.
73    #[error("{kind} concept `{concept}` binds ambiguously to {names:?}")]
74    Ambiguous {
75        /// Catalog the concept belongs to.
76        kind: ConceptKind,
77        /// Concept identifier.
78        concept: String,
79        /// Distinct candidate names, sorted.
80        names: Vec<String>,
81    },
82}
83
84/// Canonical concepts of every definition package a ruleset loaded.
85#[derive(Clone, Debug, Default, PartialEq, Eq)]
86pub struct ConceptCatalog {
87    entries: BTreeMap<(ConceptKind, String), Vec<ExternalName>>,
88    /// The ids of the classifications the ruleset derives.
89    classifications: BTreeSet<String>,
90}
91
92impl ConceptCatalog {
93    /// Adds one concept, refusing a second declaration of the same identity.
94    pub(crate) fn insert(
95        &mut self,
96        kind: ConceptKind,
97        concept: &str,
98        names: &[ExternalName],
99    ) -> Result<(), ()> {
100        let key = (kind, concept.to_owned());
101        if self.entries.contains_key(&key) {
102            return Err(());
103        }
104        self.entries.insert(key, names.to_vec());
105        Ok(())
106    }
107
108    /// Declares the classifications the ruleset derives, so references to
109    /// them in the reserved classification set resolve.
110    pub(crate) fn declare_classifications<'a>(&mut self, ids: impl IntoIterator<Item = &'a str>) {
111        self.classifications
112            .extend(ids.into_iter().map(ToOwned::to_owned));
113    }
114
115    /// Whether `name` names something the engine derives in the reserved
116    /// set `set`; `None` when `set` is not a derived set.
117    #[must_use]
118    pub fn derives(&self, set: &str, name: &str) -> Option<bool> {
119        if set == axioval_ir::MEASURED_SET {
120            return Some(crate::measured::parse(name).is_ok());
121        }
122        (set == axioval_ir::CLASSIFICATION_SET).then(|| self.classifications.contains(name))
123    }
124
125    /// Whether a concept of this kind is declared.
126    #[must_use]
127    pub fn contains(&self, kind: ConceptKind, concept: &str) -> bool {
128        self.entries.contains_key(&(kind, concept.to_owned()))
129    }
130
131    fn names(&self, kind: ConceptKind, concept: &str) -> Result<&[ExternalName], BindingError> {
132        self.entries
133            .get(&(kind, concept.to_owned()))
134            .map(Vec::as_slice)
135            .ok_or_else(|| BindingError::UnknownConcept {
136                kind,
137                concept: concept.to_owned(),
138            })
139    }
140}
141
142/// Per-run translation from package concepts to source names.
143///
144/// Only the engine constructs this, from the compiled plan and the evidence
145/// session's declared type systems, and registers it for the duration of one
146/// run. Capabilities find it in the service registry; a capability evaluated
147/// directly by a trusted host without it works in raw source vocabulary.
148#[derive(Clone, Debug)]
149pub struct ConceptBindings {
150    catalog: Arc<ConceptCatalog>,
151    type_systems: BTreeMap<SourceId, Vec<Arc<str>>>,
152}
153
154impl ConceptBindings {
155    pub(crate) fn new(
156        catalog: Arc<ConceptCatalog>,
157        type_systems: BTreeMap<SourceId, Vec<Arc<str>>>,
158    ) -> Self {
159        Self {
160            catalog,
161            type_systems,
162        }
163    }
164
165    /// Source name of an object-type concept.
166    pub fn object_type(&self, concept: &str, source: &SourceId) -> Result<&str, BindingError> {
167        self.bind(ConceptKind::ObjectType, concept, source)
168    }
169
170    /// Source name of a property concept.
171    pub fn property(&self, concept: &str, source: &SourceId) -> Result<&str, BindingError> {
172        self.bind(ConceptKind::Property, concept, source)
173    }
174
175    /// Source name of a property-set concept.
176    pub fn property_set(&self, concept: &str, source: &SourceId) -> Result<&str, BindingError> {
177        self.bind(ConceptKind::PropertySet, concept, source)
178    }
179
180    fn bind(
181        &self,
182        kind: ConceptKind,
183        concept: &str,
184        source: &SourceId,
185    ) -> Result<&str, BindingError> {
186        let names = self.catalog.names(kind, concept)?;
187        let declared = self
188            .type_systems
189            .get(source)
190            .filter(|systems| !systems.is_empty())
191            .ok_or_else(|| BindingError::UndeclaredTypeSystem(source.clone()))?;
192        // The package contract allows at most one name per type system, but a
193        // source may declare several systems, so distinct matches are possible.
194        let mut matched: Vec<&str> = names
195            .iter()
196            .filter(|name| declared.iter().any(|system| **system == *name.type_system))
197            .map(|name| name.name.as_str())
198            .collect();
199        matched.sort_unstable();
200        matched.dedup();
201        match matched.as_slice() {
202            [name] => Ok(name),
203            [] => Err(BindingError::Unbound {
204                kind,
205                concept: concept.to_owned(),
206                type_systems: declared.iter().map(ToString::to_string).collect(),
207            }),
208            _ => Err(BindingError::Ambiguous {
209                kind,
210                concept: concept.to_owned(),
211                names: matched.iter().map(ToString::to_string).collect(),
212            }),
213        }
214    }
215}
216
217/// Failure to answer a type-hierarchy question conclusively.
218#[derive(Clone, Debug, Error, Eq, PartialEq)]
219pub enum TypeHierarchyError {
220    /// The service does not cover this source.
221    #[error("type hierarchy does not cover source `{0}`")]
222    UncoveredSource(SourceId),
223    /// The type is not declared by the source's schema.
224    #[error("type `{0}` is not declared by the source schema")]
225    UnknownType(String),
226}
227
228/// Source-declared subtype relation, used for `includeSubtypes` selection.
229pub trait TypeHierarchyService: Send + Sync {
230    /// Exact source snapshots this hierarchy answers for.
231    fn source_snapshots(&self) -> &[SourceSnapshot];
232    /// Whether `kind` is `ancestor` or one of its subtypes.
233    fn is_a(&self, kind: &str, ancestor: &str) -> Result<bool, TypeHierarchyError>;
234}
235
236/// Cloneable, type-erased type-hierarchy service registered by an adapter.
237///
238/// A handle may hold several services over disjoint sources, as a federated
239/// session does; each question goes to the service covering the source.
240#[derive(Clone)]
241pub struct TypeHierarchyServiceHandle {
242    members: Arc<[Arc<dyn TypeHierarchyService>]>,
243    snapshots: Arc<[SourceSnapshot]>,
244}
245
246impl TypeHierarchyServiceHandle {
247    /// Wraps a trusted hierarchy service.
248    #[must_use]
249    pub fn new(service: Arc<dyn TypeHierarchyService>) -> Self {
250        let snapshots = service.source_snapshots().into();
251        Self {
252            members: Arc::from([service]),
253            snapshots,
254        }
255    }
256
257    /// One handle answering from each of `handles` for its own sources.
258    pub(crate) fn federated(handles: &[&Self]) -> Self {
259        Self {
260            members: handles
261                .iter()
262                .flat_map(|handle| handle.members.iter().cloned())
263                .collect(),
264            snapshots: handles
265                .iter()
266                .flat_map(|handle| handle.snapshots.iter().cloned())
267                .collect(),
268        }
269    }
270
271    /// Answers for one object's source, refusing sources the service does not cover.
272    pub fn is_a(
273        &self,
274        source: &SourceId,
275        kind: &str,
276        ancestor: &str,
277    ) -> Result<bool, TypeHierarchyError> {
278        self.members
279            .iter()
280            .find(|member| {
281                member
282                    .source_snapshots()
283                    .iter()
284                    .any(|snapshot| snapshot.source() == source)
285            })
286            .ok_or_else(|| TypeHierarchyError::UncoveredSource(source.clone()))?
287            .is_a(kind, ancestor)
288    }
289}
290
291impl SnapshotBoundService for TypeHierarchyServiceHandle {
292    fn source_snapshots(&self) -> &[SourceSnapshot] {
293        &self.snapshots
294    }
295}