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, ®istries)?;
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}