Skip to main content

axioval_engine/
relationships.rs

1//! Exact source-neutral relationship-selection host-service contracts.
2
3use std::sync::Arc;
4
5use axioval_ir::{Evidence, ObjectId};
6use thiserror::Error;
7
8use crate::session::{SnapshotBoundService, SourceSnapshot};
9
10/// Failure to select comparison candidates conclusively.
11#[derive(Clone, Debug, Error, PartialEq, Eq)]
12pub enum RelationshipSelectionError {
13    /// The requested relationship or candidate universe is malformed.
14    #[error("relationship selection request is invalid")]
15    InvalidRequest,
16    /// The candidate universe or response contains the same object more than once.
17    #[error("relationship selection contains a duplicate candidate")]
18    DuplicateCandidate,
19    /// A response repeats one evidence locator.
20    #[error("relationship selection contains duplicate evidence")]
21    DuplicateEvidence,
22    /// Returned data belongs to another request or escapes its candidate universe.
23    #[error("relationship selection response does not match its request")]
24    ResponseRequestMismatch,
25    /// A conclusive selection lacks exact, reviewable completeness evidence.
26    #[error("relationship selection evidence is not exact and reviewable")]
27    InexactEvidence,
28    /// The source cannot currently provide a conclusive selection.
29    #[error("relationship selection unavailable: {0}")]
30    Unavailable(String),
31}
32
33/// A host-registered semantic relationship or grouping identity.
34#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
35pub struct SemanticRelationship(String);
36
37impl SemanticRelationship {
38    /// Creates a non-empty source-neutral relationship identity.
39    pub fn try_new(value: impl Into<String>) -> Result<Self, RelationshipSelectionError> {
40        let value = value.into();
41        if value.trim().is_empty() {
42            return Err(RelationshipSelectionError::InvalidRequest);
43        }
44        Ok(Self(value))
45    }
46
47    /// Returns the declared semantic identity.
48    #[must_use]
49    pub fn as_str(&self) -> &str {
50        &self.0
51    }
52}
53
54/// Direction used when traversing a directed semantic relationship.
55#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
56pub enum TraversalDirection {
57    /// Follow edges from source to target.
58    Forward,
59    /// Follow edges from target to source.
60    Backward,
61    /// Follow edges in either direction.
62    Either,
63}
64
65/// Source-neutral relationship operation used to select candidates.
66#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
67pub enum RelationshipQuery {
68    /// Select members sharing at least one complete semantic group with the anchor.
69    SharedGroup {
70        /// Host-registered grouping identity such as a spatial or assembly context.
71        relationship: SemanticRelationship,
72    },
73    /// Traverse a directed semantic relationship from the anchor.
74    Related {
75        /// Host-registered relationship identity.
76        relationship: SemanticRelationship,
77        /// Requested traversal direction.
78        direction: TraversalDirection,
79        /// Whether traversal continues beyond immediate neighbors.
80        follow_chain: bool,
81    },
82}
83
84impl RelationshipQuery {
85    /// The relationship or grouping identity the query names.
86    #[must_use]
87    pub fn relationship(&self) -> &SemanticRelationship {
88        match self {
89            Self::SharedGroup { relationship } | Self::Related { relationship, .. } => relationship,
90        }
91    }
92}
93
94/// What a relationship service does with an instance whose required end is absent.
95///
96/// A source can carry relationship instances that omit an end the schema
97/// requires, for example a virtual space boundary with no bounding element.
98/// Such an instance contributes no edge through the absent end, so treating it
99/// as a complete answer would silently read "not related" into a gap. The
100/// default refuses; a rule opts into skipping explicitly, and the service then
101/// names every skipped instance it passed over in the selection's evidence.
102#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
103pub enum AbsentEndPolicy {
104    /// Refuse the whole answer while any instance of the type lacks a required end.
105    #[default]
106    Refuse,
107    /// Answer from the edges that exist and cite each skipped instance.
108    Skip,
109}
110
111/// Request for relationship-selected objects within a caller-bound universe.
112#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
113pub struct RelationshipSelectionRequest {
114    anchor: ObjectId,
115    candidate_universe: Vec<ObjectId>,
116    query: RelationshipQuery,
117    absent_ends: AbsentEndPolicy,
118}
119
120impl RelationshipSelectionRequest {
121    /// Creates a request with a canonical, duplicate-free candidate universe.
122    pub fn try_new(
123        anchor: ObjectId,
124        mut candidate_universe: Vec<ObjectId>,
125        query: RelationshipQuery,
126    ) -> Result<Self, RelationshipSelectionError> {
127        candidate_universe.sort();
128        if candidate_universe.windows(2).any(|pair| pair[0] == pair[1]) {
129            return Err(RelationshipSelectionError::DuplicateCandidate);
130        }
131        Ok(Self {
132            anchor,
133            candidate_universe,
134            query,
135            absent_ends: AbsentEndPolicy::default(),
136        })
137    }
138
139    /// Sets how instances with an absent required end are treated.
140    #[must_use]
141    pub fn with_absent_ends(mut self, policy: AbsentEndPolicy) -> Self {
142        self.absent_ends = policy;
143        self
144    }
145
146    /// How instances with an absent required end are treated.
147    #[must_use]
148    pub fn absent_ends(&self) -> AbsentEndPolicy {
149        self.absent_ends
150    }
151
152    /// Anchor whose relationships determine the selection.
153    #[must_use]
154    pub fn anchor(&self) -> &ObjectId {
155        &self.anchor
156    }
157
158    /// Complete caller-approved universe from which candidates may be returned.
159    #[must_use]
160    pub fn candidate_universe(&self) -> &[ObjectId] {
161        &self.candidate_universe
162    }
163
164    /// Requested relationship operation.
165    #[must_use]
166    pub fn query(&self) -> &RelationshipQuery {
167        &self.query
168    }
169
170    fn contains_candidate(&self, candidate: &ObjectId) -> bool {
171        self.candidate_universe.binary_search(candidate).is_ok()
172    }
173}
174
175/// Complete exact candidate selection bound to the request that produced it.
176#[derive(Clone, Debug, PartialEq)]
177pub struct CompleteRelationshipSelection {
178    request: RelationshipSelectionRequest,
179    candidates: Vec<ObjectId>,
180    evidence: Vec<Evidence>,
181}
182
183impl CompleteRelationshipSelection {
184    /// Creates a request-bound complete selection with canonical candidate ordering.
185    pub fn try_new(
186        request: RelationshipSelectionRequest,
187        mut candidates: Vec<ObjectId>,
188        mut evidence: Vec<Evidence>,
189    ) -> Result<Self, RelationshipSelectionError> {
190        candidates.sort();
191        if candidates.windows(2).any(|pair| pair[0] == pair[1]) {
192            return Err(RelationshipSelectionError::DuplicateCandidate);
193        }
194        if candidates
195            .iter()
196            .any(|candidate| !request.contains_candidate(candidate))
197        {
198            return Err(RelationshipSelectionError::ResponseRequestMismatch);
199        }
200        if evidence.is_empty() || evidence.iter().any(|item| !reviewable(item)) {
201            return Err(RelationshipSelectionError::InexactEvidence);
202        }
203        evidence.sort_by(|left, right| {
204            (&left.source, &left.locator).cmp(&(&right.source, &right.locator))
205        });
206        if evidence.windows(2).any(|pair| pair[0] == pair[1]) {
207            return Err(RelationshipSelectionError::DuplicateEvidence);
208        }
209        Ok(Self {
210            request,
211            candidates,
212            evidence,
213        })
214    }
215
216    /// Complete request, including anchor, universe, and query.
217    #[must_use]
218    pub fn request(&self) -> &RelationshipSelectionRequest {
219        &self.request
220    }
221
222    /// Canonically ordered selected candidates.
223    #[must_use]
224    pub fn candidates(&self) -> &[ObjectId] {
225        &self.candidates
226    }
227
228    /// Exact reviewable evidence proving the selection is complete.
229    #[must_use]
230    pub fn evidence(&self) -> &[Evidence] {
231        &self.evidence
232    }
233}
234
235/// Trusted adapter seam for complete relationship-based candidate selection.
236pub trait RelationshipSelectionService: Send + Sync {
237    /// Exact source snapshots used to construct this service.
238    ///
239    /// The default is intentionally unbound for services used only through a
240    /// raw [`crate::ServiceRegistry`]; an [`crate::EvidenceSession`] rejects it.
241    fn source_snapshots(&self) -> &[SourceSnapshot] {
242        &[]
243    }
244    /// Selects candidates or reports why the result is not conclusive.
245    fn select(
246        &self,
247        request: &RelationshipSelectionRequest,
248    ) -> Result<CompleteRelationshipSelection, RelationshipSelectionError>;
249}
250
251/// Cloneable, type-erased relationship service registered by the host.
252#[derive(Clone)]
253pub struct RelationshipSelectionServiceHandle(Arc<dyn RelationshipSelectionService>);
254
255impl RelationshipSelectionServiceHandle {
256    /// Wraps a trusted relationship-selection service.
257    #[must_use]
258    pub fn new(service: Arc<dyn RelationshipSelectionService>) -> Self {
259        Self(service)
260    }
261
262    /// Selects and validates complete request binding and evidence exactness.
263    pub fn select(
264        &self,
265        request: &RelationshipSelectionRequest,
266    ) -> Result<CompleteRelationshipSelection, RelationshipSelectionError> {
267        validate_selection(request, self.0.select(request)?)
268    }
269}
270
271/// Checks that `selection` answers exactly `request`, stays inside its
272/// universe in canonical order, and carries exact reviewable evidence.
273pub(crate) fn validate_selection(
274    request: &RelationshipSelectionRequest,
275    selection: CompleteRelationshipSelection,
276) -> Result<CompleteRelationshipSelection, RelationshipSelectionError> {
277    if selection.request() != request
278        || selection
279            .candidates()
280            .iter()
281            .any(|candidate| !request.contains_candidate(candidate))
282    {
283        return Err(RelationshipSelectionError::ResponseRequestMismatch);
284    }
285    if selection
286        .candidates()
287        .windows(2)
288        .any(|pair| pair[0] >= pair[1])
289    {
290        return Err(RelationshipSelectionError::DuplicateCandidate);
291    }
292    if selection.evidence().is_empty() || selection.evidence().iter().any(|item| !reviewable(item))
293    {
294        return Err(RelationshipSelectionError::InexactEvidence);
295    }
296    Ok(selection)
297}
298
299impl SnapshotBoundService for RelationshipSelectionServiceHandle {
300    fn source_snapshots(&self) -> &[SourceSnapshot] {
301        self.0.source_snapshots()
302    }
303}
304
305fn reviewable(evidence: &Evidence) -> bool {
306    evidence.exact && !evidence.locator.trim().is_empty()
307}