Skip to main content

axioval_engine/
session.rs

1use std::{
2    any::Any,
3    collections::{BTreeMap, BTreeSet},
4    sync::Arc,
5};
6
7use axioval_ir::{Discipline, Project, SourceId};
8use thiserror::Error;
9
10use crate::derived_relationships::{DerivedRelationshipServiceHandle, RoutedRelationships};
11use crate::discipline_map::{DisciplineMap, DisciplineOrigin, Mapping, UnmappedReason};
12use crate::source_metadata::SourceMetadata;
13use crate::{RelationshipSelectionServiceHandle, ServiceRegistry, ServiceRegistryError};
14
15/// Trusted service that declares the immutable source snapshots it can resolve.
16///
17/// Session registration validates these identities against the session before
18/// exposing the service to evaluation. A service may cover a subset of a
19/// multi-source session, but every declared binding must match exactly.
20pub trait SnapshotBoundService: Any + Send + Sync {
21    /// Exact source snapshots used to construct this service.
22    fn source_snapshots(&self) -> &[SourceSnapshot];
23}
24
25/// Immutable identity of one source revision in an evidence session.
26#[derive(Clone, Debug, Eq, PartialEq)]
27pub struct SourceSnapshot {
28    source: SourceId,
29    revision: Arc<str>,
30    fingerprint: Arc<str>,
31    schema: Option<Arc<str>>,
32    type_systems: Vec<Arc<str>>,
33}
34
35impl SourceSnapshot {
36    /// Creates an exact source snapshot identity.
37    pub fn try_new(
38        source: SourceId,
39        revision: impl Into<Arc<str>>,
40        fingerprint: impl Into<Arc<str>>,
41    ) -> Result<Self, EvidenceSessionError> {
42        let revision = revision.into();
43        let fingerprint = fingerprint.into();
44        if revision.trim().is_empty() || fingerprint.trim().is_empty() {
45            return Err(EvidenceSessionError::InvalidSnapshotIdentity);
46        }
47        Ok(Self {
48            source,
49            revision,
50            fingerprint,
51            schema: None,
52            type_systems: Vec::new(),
53        })
54    }
55    /// Declares one type system this source's vocabulary uses.
56    ///
57    /// Package concepts bind to source data only through an external name in
58    /// a declared type system. A source usually speaks one release-bound
59    /// system (an IFC4 model: IFC4 entities and its property templates) and
60    /// may add a project namespace for custom property sets. A source that
61    /// declares none cannot bind any concept, so package rules over it are
62    /// not evaluated rather than passed. Declaring a system twice is a no-op.
63    pub fn with_type_system(
64        mut self,
65        type_system: impl Into<Arc<str>>,
66    ) -> Result<Self, EvidenceSessionError> {
67        let type_system = type_system.into();
68        if type_system.trim().is_empty() {
69            return Err(EvidenceSessionError::InvalidSnapshotIdentity);
70        }
71        if let Err(index) = self.type_systems.binary_search(&type_system) {
72            self.type_systems.insert(index, type_system);
73        }
74        Ok(self)
75    }
76    /// Declared type systems for concept binding, sorted and unique.
77    pub fn type_systems(&self) -> &[Arc<str>] {
78        &self.type_systems
79    }
80    /// Binds a source-declared semantic schema to the immutable snapshot.
81    pub fn with_schema(
82        mut self,
83        schema: impl Into<Arc<str>>,
84    ) -> Result<Self, EvidenceSessionError> {
85        let schema = schema.into();
86        if schema.trim().is_empty() {
87            return Err(EvidenceSessionError::InvalidSnapshotIdentity);
88        }
89        self.schema = Some(schema);
90        Ok(self)
91    }
92    /// Stable source identity.
93    pub fn source(&self) -> &SourceId {
94        &self.source
95    }
96    /// Adapter-defined immutable revision.
97    pub fn revision(&self) -> &str {
98        &self.revision
99    }
100    /// Content fingerprint, including its algorithm when applicable.
101    pub fn fingerprint(&self) -> &str {
102        &self.fingerprint
103    }
104    /// Source-declared semantic schema, when the adapter has one.
105    pub fn schema(&self) -> Option<&str> {
106        self.schema.as_deref()
107    }
108}
109
110/// The disciplines a run's sources play, as the session declared them.
111///
112/// The engine constructs this from the evidence session and registers it for
113/// the duration of one run, replacing any host-registered copy.
114/// Capabilities find it in the service registry. A source without an entry
115/// declares no discipline; that is unknown, never "no discipline matches".
116#[derive(Clone, Debug, Default)]
117pub struct SourceDisciplines {
118    disciplines: BTreeMap<SourceId, Discipline>,
119    origins: BTreeMap<SourceId, DisciplineOrigin>,
120}
121
122impl SourceDisciplines {
123    /// Declares `disciplines`, by source.
124    ///
125    /// Capability tests construct it; a run always uses the runtime's own.
126    pub fn new(disciplines: impl IntoIterator<Item = (SourceId, Discipline)>) -> Self {
127        Self {
128            disciplines: disciplines.into_iter().collect(),
129            origins: BTreeMap::new(),
130        }
131    }
132
133    pub(crate) fn with_origins(
134        disciplines: BTreeMap<SourceId, Discipline>,
135        origins: BTreeMap<SourceId, DisciplineOrigin>,
136    ) -> Self {
137        Self {
138            disciplines,
139            origins,
140        }
141    }
142
143    /// The discipline declared for `source`, if any.
144    #[must_use]
145    pub fn of(&self, source: &SourceId) -> Option<&Discipline> {
146        self.disciplines.get(source)
147    }
148
149    /// Where `source`'s discipline came from, if it has one.
150    #[must_use]
151    pub fn origin(&self, source: &SourceId) -> Option<&DisciplineOrigin> {
152        self.of(source)?;
153        Some(
154            self.origins
155                .get(source)
156                .unwrap_or(&DisciplineOrigin::Declared),
157        )
158    }
159}
160
161/// Every source a run checks.
162///
163/// The runtime registers it for the duration of one run, replacing any
164/// host-registered copy: from the session's snapshots in
165/// [`crate::Runtime::run_session`], from the sources the project's objects
166/// name in [`crate::Runtime::run`]. It lists a source even when that source
167/// contributes no object, so a capability judging each source as a whole
168/// ("the model contains a building") reports an empty source instead of
169/// never seeing it.
170#[derive(Clone, Debug, Default, Eq, PartialEq)]
171pub struct SessionSources(BTreeSet<SourceId>);
172
173impl SessionSources {
174    /// Lists `sources`; order and repetition do not matter.
175    ///
176    /// Capability tests construct it; a run always uses the runtime's own.
177    pub fn new(sources: impl IntoIterator<Item = SourceId>) -> Self {
178        Self(sources.into_iter().collect())
179    }
180
181    /// Every source, sorted.
182    pub fn iter(&self) -> impl ExactSizeIterator<Item = &SourceId> {
183        self.0.iter()
184    }
185
186    /// Whether the run checks `source`.
187    #[must_use]
188    pub fn contains(&self, source: &SourceId) -> bool {
189        self.0.contains(source)
190    }
191}
192
193/// Invalid project/source snapshot binding.
194#[derive(Clone, Debug, Error, Eq, PartialEq)]
195pub enum EvidenceSessionError {
196    /// Snapshot revision or fingerprint is empty.
197    #[error("source snapshot revision and fingerprint must be non-empty")]
198    InvalidSnapshotIdentity,
199    /// Two snapshot declarations name the same source.
200    #[error("duplicate source snapshot: {0}")]
201    DuplicateSource(SourceId),
202    /// A project source has no immutable snapshot declaration.
203    #[error("project source has no snapshot declaration: {0}")]
204    MissingSource(SourceId),
205    /// A service declared no immutable source binding.
206    #[error("evidence service has no source snapshot binding")]
207    UnboundService,
208    /// A service declared the same source binding more than once.
209    #[error("evidence service has duplicate source snapshot binding: {0}")]
210    DuplicateServiceSource(SourceId),
211    /// A service source is absent from the session or has a different identity.
212    #[error("evidence service snapshot does not match the session: {0}")]
213    ServiceSnapshotMismatch(SourceId),
214    /// A discipline was declared for a source the session does not hold.
215    #[error("discipline declared for a source outside the session: {0}")]
216    UnknownSource(SourceId),
217    /// A source's discipline was declared twice.
218    #[error("source already declares a discipline: {0}")]
219    DuplicateDiscipline(SourceId),
220    /// A source metadata field was stated twice with different values.
221    #[error("source `{0}` already states its {1} differently")]
222    ConflictingMetadata(SourceId, &'static str),
223    /// A federated member holds a service the engine cannot route by source.
224    ///
225    /// Federation composes the semantic services adapters register; a host
226    /// service (geometry, derived relationships) is registered on the
227    /// federated session instead, bound to every snapshot it was built from.
228    #[error("evidence session holds a service that cannot be federated")]
229    UnfederableService,
230    /// Typed service registration failed.
231    #[error(transparent)]
232    ServiceRegistry(#[from] ServiceRegistryError),
233}
234
235/// Immutable project snapshot bound to the exact host services that produced
236/// and can resolve its evidence.
237///
238/// Adapters build a session once per source snapshot. Runtime evaluation then
239/// consumes the project and services as one unit, preventing accidental use of
240/// a resolver from a different model revision.
241pub struct EvidenceSession {
242    project: Arc<Project>,
243    snapshots: BTreeMap<SourceId, SourceSnapshot>,
244    disciplines: BTreeMap<SourceId, Discipline>,
245    /// Where each mapped discipline came from; a discipline without an
246    /// entry was declared.
247    origins: BTreeMap<SourceId, DisciplineOrigin>,
248    /// Why a discipline map left a source without a discipline.
249    unmapped: BTreeMap<SourceId, UnmappedReason>,
250    metadata: BTreeMap<SourceId, SourceMetadata>,
251    services: ServiceRegistry,
252}
253
254impl EvidenceSession {
255    /// Starts a session after proving every project source has exactly one snapshot.
256    ///
257    /// A snapshot need not contribute an object: a model holding no objects
258    /// (only presentation data, or nothing at all) is an empty source and
259    /// still part of the session. Capabilities that judge each source as a
260    /// whole find it through [`SessionSources`], so an empty source is
261    /// reported rather than skipped.
262    ///
263    /// # Errors
264    ///
265    /// Returns an error when two snapshots name the same source, or an
266    /// object's source has no snapshot.
267    pub fn try_new(
268        project: Project,
269        snapshots: impl IntoIterator<Item = SourceSnapshot>,
270    ) -> Result<Self, EvidenceSessionError> {
271        let project_sources = project
272            .objects()
273            .map(|object| object.id.source.clone())
274            .collect::<BTreeSet<_>>();
275        let mut indexed = BTreeMap::new();
276        for snapshot in snapshots {
277            let source = snapshot.source.clone();
278            if indexed.insert(source.clone(), snapshot).is_some() {
279                return Err(EvidenceSessionError::DuplicateSource(source));
280            }
281        }
282        if let Some(source) = project_sources
283            .difference(&indexed.keys().cloned().collect())
284            .next()
285        {
286            return Err(EvidenceSessionError::MissingSource(source.clone()));
287        }
288        Ok(Self {
289            project: Arc::new(project),
290            snapshots: indexed,
291            disciplines: BTreeMap::new(),
292            origins: BTreeMap::new(),
293            unmapped: BTreeMap::new(),
294            metadata: BTreeMap::new(),
295            services: ServiceRegistry::new(),
296        })
297    }
298
299    /// Combines sessions over disjoint sources into one session.
300    ///
301    /// The project holds every member's objects under their own
302    /// source-qualified identities, and every snapshot and declared
303    /// discipline is kept, including a member whose source holds no objects,
304    /// which stays in the session as an empty source. Each semantic service the members registered
305    /// (property resolution, relationship selection, type hierarchy, object
306    /// frames, classifications, integrity, resource objects) becomes one
307    /// service bound to the
308    /// snapshots of the members that had it, answering each request from
309    /// the member that owns the request's source; a source no member
310    /// covers is refused, never answered empty. A relationship request is
311    /// answered from its anchor's member over the part of the candidate
312    /// universe in that member's sources, since a member cannot relate
313    /// objects it does not hold.
314    ///
315    /// One member is returned unchanged. Federate before registering host
316    /// services such as geometry: they are built over the federated
317    /// project and bound to all its snapshots.
318    ///
319    /// # Errors
320    ///
321    /// Returns an error when two members hold the same source, or a member
322    /// holds a service other than the semantic ones above.
323    pub fn federate(
324        members: impl IntoIterator<Item = EvidenceSession>,
325    ) -> Result<Self, EvidenceSessionError> {
326        let mut members: Vec<Self> = members.into_iter().collect();
327        if members.len() == 1 {
328            return Ok(members.remove(0));
329        }
330        for member in &members {
331            if member.services.len() > crate::federation::routed(&member.services) {
332                return Err(EvidenceSessionError::UnfederableService);
333            }
334        }
335        let objects = members
336            .iter()
337            .flat_map(|member| member.project.objects().cloned())
338            .collect();
339        let snapshots: Vec<SourceSnapshot> = members
340            .iter()
341            .flat_map(|member| member.snapshots.values().cloned())
342            .collect();
343        let project = Project::new(objects).map_err(|error| match error {
344            // Objects of one source only ever come from its own member, so a
345            // duplicate object means two members hold the same source.
346            axioval_ir::IrError::DuplicateObject(object) => {
347                EvidenceSessionError::DuplicateSource(object.source)
348            }
349            _ => EvidenceSessionError::InvalidSnapshotIdentity,
350        })?;
351        let mut federated = Self::try_new(project, snapshots)?;
352        for member in &members {
353            federated.disciplines.extend(
354                member
355                    .disciplines
356                    .iter()
357                    .map(|(source, discipline)| (source.clone(), discipline.clone())),
358            );
359            federated
360                .origins
361                .extend(member.origins.iter().map(|(s, o)| (s.clone(), o.clone())));
362            federated
363                .unmapped
364                .extend(member.unmapped.iter().map(|(s, u)| (s.clone(), u.clone())));
365            federated.metadata.extend(
366                member
367                    .metadata
368                    .iter()
369                    .map(|(source, metadata)| (source.clone(), metadata.clone())),
370            );
371        }
372        let registries: Vec<&ServiceRegistry> =
373            members.iter().map(|member| &member.services).collect();
374        crate::federation::register(&mut federated.services, &registries)?;
375        Ok(federated)
376    }
377
378    /// Declares the discipline `source` plays in this check.
379    ///
380    /// A discipline is a host declaration, not part of the snapshot
381    /// identity: services bound to a snapshot stay valid whatever role the
382    /// source plays. Capabilities read it through
383    /// [`crate::SourceDisciplines`]; the `discipline` selector matches on it.
384    ///
385    /// # Errors
386    ///
387    /// Returns an error when the session holds no such source or it already
388    /// declares a discipline.
389    pub fn with_discipline(
390        mut self,
391        source: &SourceId,
392        discipline: Discipline,
393    ) -> Result<Self, EvidenceSessionError> {
394        if !self.snapshots.contains_key(source) {
395            return Err(EvidenceSessionError::UnknownSource(source.clone()));
396        }
397        if self.disciplines.contains_key(source) {
398            return Err(EvidenceSessionError::DuplicateDiscipline(source.clone()));
399        }
400        self.disciplines.insert(source.clone(), discipline);
401        Ok(self)
402    }
403
404    /// The discipline declared for `source`, if any.
405    #[must_use]
406    pub fn discipline(&self, source: &SourceId) -> Option<&Discipline> {
407        self.disciplines.get(source)
408    }
409
410    /// Every declared discipline, by source.
411    #[must_use]
412    pub fn disciplines(&self) -> &BTreeMap<SourceId, Discipline> {
413        &self.disciplines
414    }
415
416    /// Assigns disciplines from source metadata to the sources that declare
417    /// none.
418    ///
419    /// Each such source takes the discipline of the first rule of `map`
420    /// matching one of its field's values, and the session records the rule
421    /// and the value ([`Self::discipline_origin`]); the `discipline` selector
422    /// cites them. A declared discipline is never replaced. A source no rule
423    /// matches keeps none, as does one where a rule reads a field it never
424    /// stated, since that rule might have matched ([`Self::unmapped`]). Map
425    /// after stating metadata and declaring disciplines: a discipline
426    /// declared afterwards for a mapped source is a duplicate.
427    #[must_use]
428    pub fn with_discipline_map(mut self, map: &DisciplineMap) -> Self {
429        if map.is_empty() {
430            return self;
431        }
432        let index = self.metadata_index();
433        for source in self.snapshots.keys() {
434            if self.disciplines.contains_key(source) {
435                continue;
436            }
437            match map.decide(source, &index) {
438                Mapping::Assigned { rule, value } => {
439                    let rule = &map.rules()[rule];
440                    self.disciplines
441                        .insert(source.clone(), rule.discipline().clone());
442                    self.origins.insert(
443                        source.clone(),
444                        DisciplineOrigin::Mapped {
445                            rule: rule.to_string(),
446                            field: rule.field(),
447                            value,
448                        },
449                    );
450                    self.unmapped.remove(source);
451                }
452                Mapping::Unread { rule } => {
453                    self.unmapped.insert(
454                        source.clone(),
455                        UnmappedReason::Unread(map.rules()[rule].to_string()),
456                    );
457                }
458                Mapping::Unmatched => {
459                    self.unmapped
460                        .insert(source.clone(), UnmappedReason::NoMatch);
461                }
462            }
463        }
464        self
465    }
466
467    /// Where `source`'s discipline came from, if it has one.
468    #[must_use]
469    pub fn discipline_origin(&self, source: &SourceId) -> Option<&DisciplineOrigin> {
470        self.disciplines.get(source)?;
471        Some(
472            self.origins
473                .get(source)
474                .unwrap_or(&DisciplineOrigin::Declared),
475        )
476    }
477
478    /// Why a discipline map left `source` without a discipline, if one did.
479    #[must_use]
480    pub fn unmapped(&self, source: &SourceId) -> Option<&UnmappedReason> {
481        self.unmapped.get(source)
482    }
483
484    /// States what is known about `source` as a whole: the adapter the
485    /// applications and project it read, the host the file name.
486    ///
487    /// Statements add up field by field; stating a field again with the same
488    /// values is a no-op. Capabilities read the metadata through
489    /// [`crate::SourceMetadataIndex`]; the `source` selector matches on it.
490    ///
491    /// # Errors
492    ///
493    /// Returns an error when the session holds no such source, or a field is
494    /// already stated with other values.
495    pub fn with_source_metadata(
496        mut self,
497        source: &SourceId,
498        metadata: SourceMetadata,
499    ) -> Result<Self, EvidenceSessionError> {
500        if !self.snapshots.contains_key(source) {
501            return Err(EvidenceSessionError::UnknownSource(source.clone()));
502        }
503        let held = self.metadata.remove(source).unwrap_or_default();
504        let merged = held.merged(metadata).map_err(|field| {
505            EvidenceSessionError::ConflictingMetadata(source.clone(), field.as_str())
506        })?;
507        self.metadata.insert(source.clone(), merged);
508        Ok(self)
509    }
510
511    /// Everything stated about `source` as a whole, if anything.
512    #[must_use]
513    pub fn source_metadata(&self, source: &SourceId) -> Option<&SourceMetadata> {
514        self.metadata.get(source)
515    }
516
517    /// Every source's discipline and its origin, as a run reads them.
518    pub(crate) fn source_disciplines(&self) -> SourceDisciplines {
519        SourceDisciplines::with_origins(self.disciplines.clone(), self.origins.clone())
520    }
521
522    /// Every source's metadata as a run reads it: the schema comes from the
523    /// snapshot unless stated.
524    pub(crate) fn metadata_index(&self) -> crate::SourceMetadataIndex {
525        crate::SourceMetadataIndex::new(self.snapshots.values().map(|snapshot| {
526            let mut metadata = self
527                .metadata
528                .get(&snapshot.source)
529                .cloned()
530                .unwrap_or_default();
531            if let (None, Some(schema)) = (
532                metadata.values(axioval_ir::contract::SourceField::Schema),
533                snapshot.schema(),
534            ) {
535                metadata = metadata.with(axioval_ir::contract::SourceField::Schema, [schema]);
536            }
537            (snapshot.source.clone(), metadata)
538        }))
539    }
540
541    /// Registers one non-replaceable typed evidence service.
542    pub fn with_service<T: SnapshotBoundService>(
543        mut self,
544        service: T,
545    ) -> Result<Self, EvidenceSessionError> {
546        self.check_bindings(service.source_snapshots())?;
547        self.services.register(service)?;
548        Ok(self)
549    }
550
551    /// Registers a host service that records no snapshot of its own.
552    ///
553    /// Some services are built by the host from data it read alongside the
554    /// session, such as geometry meshed from the same file, and carry only a
555    /// source identity. The host states which of this session's snapshots
556    /// the service was built from, and the same checks as
557    /// [`Self::with_service`] apply: at least one binding, no source twice,
558    /// and every binding equal to the session's snapshot for that source.
559    /// A service built from another revision of a source is refused.
560    ///
561    /// # Errors
562    ///
563    /// Returns an error when a binding is missing, repeated or stale, or a
564    /// service of this type is already registered.
565    pub fn with_host_service<T: std::any::Any + Send + Sync>(
566        mut self,
567        service: T,
568        built_from: &[SourceSnapshot],
569    ) -> Result<Self, EvidenceSessionError> {
570        self.check_bindings(built_from)?;
571        self.services.register(service)?;
572        Ok(self)
573    }
574
575    /// Registers a derived-relationship service and routes the session's
576    /// relationship selection through it.
577    ///
578    /// Afterwards the session's [`RelationshipSelectionServiceHandle`]
579    /// answers identities starting with
580    /// [`crate::DERIVED_RELATIONSHIP_PREFIX`] from `service` and every other
581    /// identity from the semantic service registered before, so capabilities
582    /// taking a `relationship` or `path` use derived relationships unchanged.
583    /// Register the semantic service first; one registered afterwards is a
584    /// duplicate. `built_from` is checked as in [`Self::with_host_service`].
585    ///
586    /// # Errors
587    ///
588    /// Returns an error when a binding is missing, repeated or stale, or a
589    /// derived-relationship service is already registered.
590    pub fn with_derived_relationships(
591        mut self,
592        service: DerivedRelationshipServiceHandle,
593        built_from: &[SourceSnapshot],
594    ) -> Result<Self, EvidenceSessionError> {
595        self.check_bindings(built_from)?;
596        let semantic = self
597            .services
598            .get::<RelationshipSelectionServiceHandle>()
599            .cloned();
600        self.services.register(service.clone())?;
601        let snapshots = semantic.as_ref().map_or_else(
602            || built_from.to_vec(),
603            |semantic| semantic.source_snapshots().to_vec(),
604        );
605        self.services
606            .replace(RelationshipSelectionServiceHandle::new(Arc::new(
607                RoutedRelationships {
608                    semantic,
609                    derived: service,
610                    snapshots,
611                },
612            )));
613        Ok(self)
614    }
615
616    fn check_bindings(&self, bindings: &[SourceSnapshot]) -> Result<(), EvidenceSessionError> {
617        if bindings.is_empty() {
618            return Err(EvidenceSessionError::UnboundService);
619        }
620        let mut sources = BTreeSet::new();
621        for binding in bindings {
622            if !sources.insert(binding.source.clone()) {
623                return Err(EvidenceSessionError::DuplicateServiceSource(
624                    binding.source.clone(),
625                ));
626            }
627            if self.snapshots.get(&binding.source) != Some(binding) {
628                return Err(EvidenceSessionError::ServiceSnapshotMismatch(
629                    binding.source.clone(),
630                ));
631            }
632        }
633        Ok(())
634    }
635
636    /// Returns the immutable project snapshot.
637    #[must_use]
638    pub fn project(&self) -> &Project {
639        &self.project
640    }
641
642    /// Returns all immutable source identities bound to the project.
643    pub fn snapshots(&self) -> impl ExactSizeIterator<Item = &SourceSnapshot> {
644        self.snapshots.values()
645    }
646
647    /// Returns one source snapshot identity.
648    #[must_use]
649    pub fn snapshot(&self, source: &SourceId) -> Option<&SourceSnapshot> {
650        self.snapshots.get(source)
651    }
652
653    /// Returns all services bound to this snapshot.
654    #[must_use]
655    pub fn services(&self) -> &ServiceRegistry {
656        &self.services
657    }
658
659    /// Returns one typed service bound to this snapshot.
660    #[must_use]
661    pub fn service<T: Any + Send + Sync>(&self) -> Option<&T> {
662        self.services.get::<T>()
663    }
664}