Skip to main content

axioval_engine/
derived_relationships.rs

1//! Relationships derived from geometry rather than stated by a source.
2//!
3//! A model often leaves out relationships a check needs: which space a
4//! component stands in, which spaces a door connects, which larger space a
5//! room belongs to. A geometry provider can derive them. It answers the same
6//! [`RelationshipSelectionRequest`]s as the semantic relationship service, so
7//! every capability that takes a `relationship` or a `path` can use a derived
8//! relationship without change: the session routes each request whose
9//! identity starts with [`DERIVED_RELATIONSHIP_PREFIX`] to the registered
10//! [`DerivedRelationshipServiceHandle`] (see
11//! [`crate::EvidenceSession::with_derived_relationships`]).
12//!
13//! The identity names the derivation and fixes its tolerances, so two rules
14//! asking with different tolerances ask different questions, and the evidence
15//! of every answer names the derivation it came from. Parameters follow the
16//! name as `;key=value` pairs in metres (or a plain ratio); an omitted
17//! parameter takes its documented default, and an unknown or repeated key, a
18//! negative, non-finite or out-of-range value is an invalid request.
19//!
20//! Edges always run from a subject to a space (a level, for `spans-level`),
21//! so `forward` from an element reaches its spaces and `backward` from a
22//! space reaches its subjects. `intersects` is symmetric: it runs both ways
23//! between two bodies sharing volume, so either direction reaches the same
24//! objects.
25
26use std::fmt;
27use std::sync::Arc;
28
29use axioval_ir::ObjectId;
30
31use crate::relationships::{
32    CompleteRelationshipSelection, RelationshipQuery, RelationshipSelectionError,
33    RelationshipSelectionRequest, RelationshipSelectionService, RelationshipSelectionServiceHandle,
34    SemanticRelationship, validate_selection,
35};
36use crate::session::{SnapshotBoundService, SourceSnapshot};
37
38/// Every derived relationship identity starts with this prefix.
39pub const DERIVED_RELATIONSHIP_PREFIX: &str = "axioval:derived.";
40
41/// A derived relationship and the tolerances it was asked with.
42#[derive(Clone, Copy, Debug, PartialEq)]
43pub enum Derivation {
44    /// `axioval:derived.contained-in-space`: an element to the spaces whose
45    /// body contains its reference point, or, when none does, to the one
46    /// nearest space whose surface lies within `horizontal` metres in plan
47    /// and `vertical` metres in elevation of it. Both default to zero, which
48    /// asks for containment alone.
49    ContainedInSpace {
50        /// Largest plan offset to a space's nearest surface point, in metres.
51        horizontal_metres: f64,
52        /// Largest elevation offset to that point, in metres.
53        vertical_metres: f64,
54    },
55    /// `axioval:derived.adjacent-space`: a door, window or opening to the
56    /// spaces a probe first enters on each side of it within `reach` metres
57    /// (default 1). A side that enters no space is outside.
58    AdjacentSpace {
59        /// How far each probe sweeps from the element's face, in metres.
60        reach_metres: f64,
61    },
62    /// `axioval:derived.overlapping-group-space`: a space to every larger
63    /// space whose footprint covers at least `ratio` of its own (default
64    /// 0.5) and whose vertical extent lies within `vertical` metres of its
65    /// own (default 0, touching or overlapping).
66    OverlappingGroupSpace {
67        /// Smallest share of the space's footprint the group must cover.
68        minimum_ratio: f64,
69        /// Largest vertical gap between the two extents, in metres.
70        vertical_metres: f64,
71    },
72    /// `axioval:derived.spans-level`: a space to every level (storey)
73    /// whose height band its vertical extent reaches at least `overlap`
74    /// metres into (default 1), or at least half its own height, so a space
75    /// counts in its own storey and a two-storey atrium in both. A level's
76    /// band runs from its elevation up to the next level's.
77    SpansLevel {
78        /// Smallest vertical overlap with a level's band, in metres.
79        overlap_metres: f64,
80    },
81    /// `axioval:derived.intersects`: an element to every object of the
82    /// request's universe whose body shares volume with its own, such as a
83    /// duct to the beam or wall it passes through with no void modelled.
84    /// It takes no parameters and runs both ways.
85    ///
86    /// Bodies that only touch share no volume. A pair the geometry cannot
87    /// decide (an unmeasured body, a tessellation within its chord
88    /// deviation of the other body, two open surfaces) refuses the answer.
89    Intersects,
90}
91
92const CONTAINED_IN_SPACE: &str = "contained-in-space";
93const ADJACENT_SPACE: &str = "adjacent-space";
94const OVERLAPPING_GROUP_SPACE: &str = "overlapping-group-space";
95const SPANS_LEVEL: &str = "spans-level";
96const INTERSECTS: &str = "intersects";
97
98impl Derivation {
99    /// The derivation a relationship identity names, `None` for an identity
100    /// outside [`DERIVED_RELATIONSHIP_PREFIX`].
101    ///
102    /// # Errors
103    ///
104    /// [`RelationshipSelectionError::InvalidRequest`] for an unknown
105    /// derivation or a malformed, unknown or repeated parameter.
106    pub fn parse(
107        relationship: &SemanticRelationship,
108    ) -> Result<Option<Self>, RelationshipSelectionError> {
109        let Some(rest) = relationship
110            .as_str()
111            .strip_prefix(DERIVED_RELATIONSHIP_PREFIX)
112        else {
113            return Ok(None);
114        };
115        let mut parts = rest.split(';');
116        let name = parts.next().unwrap_or_default();
117        let mut parameters: Vec<(&str, f64)> = Vec::new();
118        for part in parts {
119            let (key, value) = part
120                .split_once('=')
121                .ok_or(RelationshipSelectionError::InvalidRequest)?;
122            let value: f64 = value
123                .trim()
124                .parse()
125                .map_err(|_| RelationshipSelectionError::InvalidRequest)?;
126            let key = key.trim();
127            if !value.is_finite() || value < 0.0 || parameters.iter().any(|(seen, _)| *seen == key)
128            {
129                return Err(RelationshipSelectionError::InvalidRequest);
130            }
131            parameters.push((key, value));
132        }
133        let allowed: &[&str] = match name {
134            CONTAINED_IN_SPACE => &["horizontal", "vertical"],
135            ADJACENT_SPACE => &["reach"],
136            OVERLAPPING_GROUP_SPACE => &["ratio", "vertical"],
137            SPANS_LEVEL => &["overlap"],
138            INTERSECTS => &[],
139            _ => return Err(RelationshipSelectionError::InvalidRequest),
140        };
141        if parameters.iter().any(|(key, _)| !allowed.contains(key)) {
142            return Err(RelationshipSelectionError::InvalidRequest);
143        }
144        let get = |key: &str, default: f64| {
145            parameters
146                .iter()
147                .find(|(seen, _)| *seen == key)
148                .map_or(default, |(_, value)| *value)
149        };
150        let derivation = match name {
151            CONTAINED_IN_SPACE => Self::ContainedInSpace {
152                horizontal_metres: get("horizontal", 0.0),
153                vertical_metres: get("vertical", 0.0),
154            },
155            ADJACENT_SPACE => Self::AdjacentSpace {
156                reach_metres: get("reach", 1.0),
157            },
158            SPANS_LEVEL => Self::SpansLevel {
159                overlap_metres: get("overlap", 1.0),
160            },
161            INTERSECTS => Self::Intersects,
162            _ => Self::OverlappingGroupSpace {
163                minimum_ratio: get("ratio", 0.5),
164                vertical_metres: get("vertical", 0.0),
165            },
166        };
167        match derivation {
168            Self::AdjacentSpace { reach_metres } if reach_metres <= 0.0 => {
169                Err(RelationshipSelectionError::InvalidRequest)
170            }
171            Self::SpansLevel { overlap_metres } if overlap_metres <= 0.0 => {
172                Err(RelationshipSelectionError::InvalidRequest)
173            }
174            Self::OverlappingGroupSpace { minimum_ratio, .. }
175                if minimum_ratio <= 0.0 || minimum_ratio > 1.0 =>
176            {
177                Err(RelationshipSelectionError::InvalidRequest)
178            }
179            derivation => Ok(Some(derivation)),
180        }
181    }
182
183    /// The derivation's name, the identity without parameters, such as
184    /// `axioval:derived.contained-in-space`. Every evidence locator of an
185    /// answer starts with it.
186    #[must_use]
187    pub fn name(&self) -> String {
188        let name = match self {
189            Self::ContainedInSpace { .. } => CONTAINED_IN_SPACE,
190            Self::AdjacentSpace { .. } => ADJACENT_SPACE,
191            Self::OverlappingGroupSpace { .. } => OVERLAPPING_GROUP_SPACE,
192            Self::SpansLevel { .. } => SPANS_LEVEL,
193            Self::Intersects => INTERSECTS,
194        };
195        format!("{DERIVED_RELATIONSHIP_PREFIX}{name}")
196    }
197}
198
199/// The canonical identity: the name and every parameter, defaults included.
200impl fmt::Display for Derivation {
201    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202        write!(f, "{}", self.name())?;
203        match self {
204            Self::ContainedInSpace {
205                horizontal_metres,
206                vertical_metres,
207            } => write!(
208                f,
209                ";horizontal={horizontal_metres};vertical={vertical_metres}"
210            ),
211            Self::AdjacentSpace { reach_metres } => write!(f, ";reach={reach_metres}"),
212            Self::OverlappingGroupSpace {
213                minimum_ratio,
214                vertical_metres,
215            } => write!(f, ";ratio={minimum_ratio};vertical={vertical_metres}"),
216            Self::SpansLevel { overlap_metres } => write!(f, ";overlap={overlap_metres}"),
217            Self::Intersects => Ok(()),
218        }
219    }
220}
221
222const SAME_LEVEL: &str = "same-level";
223
224/// `axioval:derived.same-level`: levels of different sources that stand for
225/// one federated level, such as the architecture model's and the MEP
226/// model's storeys at one elevation.
227///
228/// Unlike a [`Derivation`] it needs no geometry: a capability matches two
229/// containers it has already climbed to by what their sources state, their
230/// elevation or their name, so it is never routed to a geometry provider.
231/// Parameters follow the name as for a derivation: `by` is `elevation` (the
232/// default) or `name`, and `tolerance` (metres, default 0, elevation only)
233/// is the largest elevation difference of one level. A container is always
234/// on its own level; two containers of one source never share one.
235#[derive(Clone, Copy, Debug, PartialEq)]
236pub enum LevelMatch {
237    /// Elevations at most `tolerance_metres` apart.
238    Elevation {
239        /// Largest elevation difference of one level, in metres.
240        tolerance_metres: f64,
241    },
242    /// Names that are equal as stated.
243    Name,
244}
245
246/// What a level states that [`LevelMatch`] compares.
247#[derive(Clone, Debug, Default, PartialEq)]
248pub struct LevelFacts {
249    /// The level's elevation in metres, when stated.
250    pub elevation_metres: Option<f64>,
251    /// The level's name, when stated.
252    pub name: Option<String>,
253}
254
255impl LevelMatch {
256    /// The level match a relationship identity names, `None` for any other
257    /// identity.
258    ///
259    /// # Errors
260    ///
261    /// [`RelationshipSelectionError::InvalidRequest`] for a malformed,
262    /// unknown or repeated parameter, a negative or non-finite tolerance, or
263    /// a tolerance with `by=name`.
264    pub fn parse(relationship: &str) -> Result<Option<Self>, RelationshipSelectionError> {
265        let Some(rest) = relationship.strip_prefix(DERIVED_RELATIONSHIP_PREFIX) else {
266            return Ok(None);
267        };
268        let mut parts = rest.split(';');
269        if parts.next() != Some(SAME_LEVEL) {
270            return Ok(None);
271        }
272        let (mut by, mut tolerance) = (None, None);
273        for part in parts {
274            let (key, value) = part
275                .split_once('=')
276                .ok_or(RelationshipSelectionError::InvalidRequest)?;
277            match (key.trim(), value.trim()) {
278                ("by", value) if by.is_none() => by = Some(value.to_owned()),
279                ("tolerance", value) if tolerance.is_none() => {
280                    let metres: f64 = value
281                        .parse()
282                        .map_err(|_| RelationshipSelectionError::InvalidRequest)?;
283                    if !metres.is_finite() || metres < 0.0 {
284                        return Err(RelationshipSelectionError::InvalidRequest);
285                    }
286                    tolerance = Some(metres);
287                }
288                _ => return Err(RelationshipSelectionError::InvalidRequest),
289            }
290        }
291        match (by.as_deref(), tolerance) {
292            (None | Some("elevation"), tolerance) => Ok(Some(Self::Elevation {
293                tolerance_metres: tolerance.unwrap_or(0.0),
294            })),
295            (Some("name"), None) => Ok(Some(Self::Name)),
296            _ => Err(RelationshipSelectionError::InvalidRequest),
297        }
298    }
299
300    /// Whether two levels of different sources are one level: `None` when
301    /// either does not state what the match compares.
302    ///
303    /// Elevations compare with the rounding of one unit conversion allowed
304    /// beyond the tolerance, so levels stated as `3000 mm` and `3 m` match
305    /// with none.
306    #[must_use]
307    pub fn same(&self, left: &LevelFacts, right: &LevelFacts) -> Option<bool> {
308        match self {
309            Self::Elevation { tolerance_metres } => {
310                let (left, right) = (left.elevation_metres?, right.elevation_metres?);
311                if !left.is_finite() || !right.is_finite() {
312                    return None;
313                }
314                let rounding = 1e-9 * left.abs().max(right.abs()).max(1.0);
315                Some((left - right).abs() <= tolerance_metres + rounding)
316            }
317            Self::Name => Some(left.name.as_ref()? == right.name.as_ref()?),
318        }
319    }
320}
321
322/// The canonical identity: the name and every parameter, defaults included.
323impl fmt::Display for LevelMatch {
324    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
325        write!(f, "{DERIVED_RELATIONSHIP_PREFIX}{SAME_LEVEL}")?;
326        match self {
327            Self::Elevation { tolerance_metres } => {
328                write!(f, ";by=elevation;tolerance={tolerance_metres}")
329            }
330            Self::Name => write!(f, ";by=name"),
331        }
332    }
333}
334
335/// The face of a door, window or opening an `adjacent-space` probe starts
336/// from: along the element's through-thickness normal (`+`) or against it
337/// (`-`).
338#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
339pub enum AdjacentSide {
340    /// Along the normal, written `+`.
341    Positive,
342    /// Against the normal, written `-`.
343    Negative,
344}
345
346impl AdjacentSide {
347    /// The sign the evidence writes for this side.
348    #[must_use]
349    pub fn symbol(self) -> char {
350        match self {
351            Self::Positive => '+',
352            Self::Negative => '-',
353        }
354    }
355
356    /// The other face.
357    #[must_use]
358    pub fn opposite(self) -> Self {
359        match self {
360            Self::Positive => Self::Negative,
361            Self::Negative => Self::Positive,
362        }
363    }
364
365    fn from_symbol(symbol: char) -> Option<Self> {
366        match symbol {
367            '+' => Some(Self::Positive),
368            '-' => Some(Self::Negative),
369            _ => None,
370        }
371    }
372}
373
374impl fmt::Display for AdjacentSide {
375    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
376        write!(f, "{}", self.symbol())
377    }
378}
379
380/// The side an `axioval:derived.adjacent-space` evidence locator records.
381///
382/// A provider of the adjacency derivation cites every face it probed, in
383/// one of two forms after the canonical identity (the derivation's name and
384/// its `;key=value` tolerances) and a colon:
385///
386/// - `{subject}->{space}:side={+|-}…`: the probe from that face of
387///   `subject` first entered `space`;
388/// - `{subject}:side={+|-}…:outside…`: the probe from that face entered no
389///   space within reach.
390///
391/// Free detail (a normal, a distance) may follow the sign. With `space`,
392/// this returns the side of the edge from `subject` to `space`; without, a
393/// side `subject` records as outside. `None` when the locator is no such
394/// record. Capabilities use it to tell a door with a space on each face from
395/// one with two spaces on the same face.
396#[must_use]
397pub fn adjacent_side(
398    locator: &str,
399    subject: &ObjectId,
400    space: Option<&ObjectId>,
401) -> Option<AdjacentSide> {
402    let rest = locator
403        .strip_prefix(DERIVED_RELATIONSHIP_PREFIX)?
404        .strip_prefix(ADJACENT_SPACE)?;
405    // Tolerances are `;key=number` pairs and never hold a colon.
406    let (tolerances, record) = rest.split_once(':')?;
407    if !(tolerances.is_empty() || tolerances.starts_with(';')) {
408        return None;
409    }
410    let head = match space {
411        Some(space) => format!("{subject}->{space}:side="),
412        None => format!("{subject}:side="),
413    };
414    let tail = record.strip_prefix(&head)?;
415    let side = AdjacentSide::from_symbol(tail.chars().next()?)?;
416    if space.is_none() && !tail.contains(":outside") {
417        return None;
418    }
419    Some(side)
420}
421
422/// Trusted provider of relationships derived from geometry.
423///
424/// It answers exactly what a [`RelationshipSelectionService`] answers for a
425/// derived identity: the complete selection within the request's universe,
426/// with exact evidence, or a refusal. An undecided case (an unmeasured body,
427/// a tessellation near a boundary it decides) refuses the whole answer.
428pub trait DerivedRelationshipService: Send + Sync {
429    /// Exact source snapshots used to construct this service.
430    fn source_snapshots(&self) -> &[SourceSnapshot] {
431        &[]
432    }
433    /// Derives the selection `request` asks for under `derivation`.
434    ///
435    /// # Errors
436    ///
437    /// A refusal when the answer cannot be decided exactly.
438    fn derive(
439        &self,
440        derivation: &Derivation,
441        request: &RelationshipSelectionRequest,
442    ) -> Result<CompleteRelationshipSelection, RelationshipSelectionError>;
443}
444
445/// Cloneable, type-erased derived-relationship service.
446#[derive(Clone)]
447pub struct DerivedRelationshipServiceHandle(Arc<dyn DerivedRelationshipService>);
448
449impl DerivedRelationshipServiceHandle {
450    /// Wraps a trusted derived-relationship service.
451    #[must_use]
452    pub fn new(service: Arc<dyn DerivedRelationshipService>) -> Self {
453        Self(service)
454    }
455
456    /// Derives a selection and validates it as the relationship handle does,
457    /// and that every evidence locator names the derivation.
458    ///
459    /// # Errors
460    ///
461    /// [`RelationshipSelectionError::InvalidRequest`] for an identity that
462    /// names no derivation, the provider's refusal, or a response that is
463    /// not bound to the request or does not cite its derivation.
464    pub fn select(
465        &self,
466        request: &RelationshipSelectionRequest,
467    ) -> Result<CompleteRelationshipSelection, RelationshipSelectionError> {
468        let derivation = Derivation::parse(request.query().relationship())?
469            .ok_or(RelationshipSelectionError::InvalidRequest)?;
470        let selection = validate_selection(request, self.0.derive(&derivation, request)?)?;
471        let name = derivation.name();
472        if selection
473            .evidence()
474            .iter()
475            .any(|item| !item.locator.starts_with(&name))
476        {
477            return Err(RelationshipSelectionError::InexactEvidence);
478        }
479        Ok(selection)
480    }
481}
482
483impl SnapshotBoundService for DerivedRelationshipServiceHandle {
484    fn source_snapshots(&self) -> &[SourceSnapshot] {
485        self.0.source_snapshots()
486    }
487}
488
489/// Routes derived identities to the derived service, the rest to the
490/// semantic service the source registered.
491pub(crate) struct RoutedRelationships {
492    pub(crate) semantic: Option<RelationshipSelectionServiceHandle>,
493    pub(crate) derived: DerivedRelationshipServiceHandle,
494    pub(crate) snapshots: Vec<SourceSnapshot>,
495}
496
497impl RelationshipSelectionService for RoutedRelationships {
498    fn source_snapshots(&self) -> &[SourceSnapshot] {
499        &self.snapshots
500    }
501
502    fn select(
503        &self,
504        request: &RelationshipSelectionRequest,
505    ) -> Result<CompleteRelationshipSelection, RelationshipSelectionError> {
506        if is_derived(request.query()) {
507            return self.derived.select(request);
508        }
509        match &self.semantic {
510            Some(semantic) => semantic.select(request),
511            None => Err(RelationshipSelectionError::Unavailable(
512                "no semantic relationship service is registered".into(),
513            )),
514        }
515    }
516}
517
518fn is_derived(query: &RelationshipQuery) -> bool {
519    query
520        .relationship()
521        .as_str()
522        .starts_with(DERIVED_RELATIONSHIP_PREFIX)
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    fn parse(identity: &str) -> Result<Option<Derivation>, RelationshipSelectionError> {
530        Derivation::parse(&SemanticRelationship::try_new(identity).unwrap())
531    }
532
533    #[test]
534    fn identities_name_the_derivation_and_fix_its_tolerances() {
535        assert_eq!(parse("IfcRelAggregates"), Ok(None));
536        assert_eq!(
537            parse("axioval:derived.contained-in-space"),
538            Ok(Some(Derivation::ContainedInSpace {
539                horizontal_metres: 0.0,
540                vertical_metres: 0.0
541            }))
542        );
543        let nearest = parse("axioval:derived.contained-in-space;vertical=0.5;horizontal=0.25")
544            .unwrap()
545            .unwrap();
546        assert_eq!(
547            nearest.to_string(),
548            "axioval:derived.contained-in-space;horizontal=0.25;vertical=0.5"
549        );
550        assert_eq!(
551            parse("axioval:derived.adjacent-space")
552                .unwrap()
553                .unwrap()
554                .to_string(),
555            "axioval:derived.adjacent-space;reach=1"
556        );
557        assert_eq!(
558            parse("axioval:derived.overlapping-group-space;ratio=0.9")
559                .unwrap()
560                .unwrap()
561                .to_string(),
562            "axioval:derived.overlapping-group-space;ratio=0.9;vertical=0"
563        );
564        assert_eq!(
565            parse("axioval:derived.spans-level"),
566            Ok(Some(Derivation::SpansLevel {
567                overlap_metres: 1.0
568            }))
569        );
570        assert_eq!(
571            parse("axioval:derived.spans-level;overlap=0.5")
572                .unwrap()
573                .unwrap()
574                .to_string(),
575            "axioval:derived.spans-level;overlap=0.5"
576        );
577        assert_eq!(
578            parse("axioval:derived.spans-level;overlap=0"),
579            Err(RelationshipSelectionError::InvalidRequest)
580        );
581        assert_eq!(
582            parse("axioval:derived.spans-level;reach=1"),
583            Err(RelationshipSelectionError::InvalidRequest)
584        );
585        let intersects = parse("axioval:derived.intersects").unwrap().unwrap();
586        assert_eq!(intersects, Derivation::Intersects);
587        assert_eq!(intersects.to_string(), "axioval:derived.intersects");
588        assert_eq!(intersects.name(), "axioval:derived.intersects");
589        assert_eq!(
590            parse("axioval:derived.intersects;depth=0.1"),
591            Err(RelationshipSelectionError::InvalidRequest)
592        );
593    }
594
595    #[test]
596    fn adjacency_locators_record_the_side_of_each_space_and_each_outside_face() {
597        let source = axioval_ir::SourceId::new("ifc-step", "model.ifc").unwrap();
598        let door = ObjectId::new(source.clone(), "#76").unwrap();
599        let room = ObjectId::new(source.clone(), "#16").unwrap();
600        let other = ObjectId::new(source, "#26").unwrap();
601        let identity = "axioval:derived.adjacent-space;reach=1";
602        // The forms the geometry adapter writes.
603        let edge = format!("{identity}:{door}->{room}:side=+(1.000000,0.000000):entered=0.000000");
604        let outside = format!("{identity}:{door}:side=-(1.000000,0.000000):outside:reach=1");
605        assert_eq!(
606            adjacent_side(&edge, &door, Some(&room)),
607            Some(AdjacentSide::Positive)
608        );
609        assert_eq!(adjacent_side(&edge, &door, Some(&other)), None);
610        assert_eq!(adjacent_side(&edge, &door, None), None);
611        assert_eq!(
612            adjacent_side(&outside, &door, None),
613            Some(AdjacentSide::Negative)
614        );
615        assert_eq!(adjacent_side(&outside, &door, Some(&room)), None);
616        assert_eq!(adjacent_side(&outside, &room, None), None);
617        // Another derivation, or a scan locator, records no side.
618        let contained = format!(
619            "axioval:derived.contained-in-space;horizontal=0;vertical=0:{door}->{room}:side=+"
620        );
621        assert_eq!(adjacent_side(&contained, &door, Some(&room)), None);
622        let scan = format!("{identity}:derived-from:{door}:2 space(s)");
623        assert_eq!(adjacent_side(&scan, &door, None), None);
624        assert_eq!(AdjacentSide::Positive.opposite(), AdjacentSide::Negative);
625    }
626
627    #[test]
628    fn a_malformed_identity_is_an_invalid_request() {
629        for identity in [
630            "axioval:derived.",
631            "axioval:derived.nearest-thing",
632            "axioval:derived.adjacent-space;reach",
633            "axioval:derived.adjacent-space;reach=0",
634            "axioval:derived.adjacent-space;reach=-1",
635            "axioval:derived.adjacent-space;reach=inf",
636            "axioval:derived.adjacent-space;ratio=0.5",
637            "axioval:derived.contained-in-space;vertical=1;vertical=2",
638            "axioval:derived.overlapping-group-space;ratio=1.5",
639            "axioval:derived.overlapping-group-space;ratio=0",
640        ] {
641            assert_eq!(
642                parse(identity),
643                Err(RelationshipSelectionError::InvalidRequest),
644                "{identity}"
645            );
646        }
647    }
648}