Skip to main content

axioval_engine/
guard.rs

1//! Source-neutral fall-protection evidence.
2//!
3//! ADR 0004: a service returns what was *measured*; a capability decides what
4//! it means. Here the measurement is the set of exposed horizontal edges in a
5//! model, together with the candidate barriers, landings and climbable objects
6//! near each one -- and how near.
7//!
8//! The search radii travel with the request. Broad-phase distances and
9//! sampling density decide *which* candidates are worth measuring, so they are
10//! measurement inputs; the heights, gaps and widths that decide whether an
11//! edge is adequately guarded stay with the capability. Without this
12//! separation an adapter has to read the rule declaration to size its search,
13//! which is how policy leaked behind the seam in the first place.
14
15use std::sync::Arc;
16
17use axioval_ir::{Evidence, ObjectId};
18
19use crate::services::reviewable_exact_evidence;
20
21/// Why fall-protection geometry could not be measured.
22#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
23pub enum GuardError {
24    /// A reported quantity is non-finite, or an interval is malformed.
25    #[error("guard quantities must be finite and intervals ordered within [0, 1]")]
26    InvalidQuantity,
27    /// The evidence backing the measurement was not exact and reviewable.
28    #[error("guard evidence must be exact and reviewable")]
29    InexactEvidence,
30    /// The adapter cannot measure fall protection for this model.
31    #[error("guard measurement is unavailable")]
32    Unavailable,
33    /// The requested search radii are not usable.
34    #[error("guard search radii must be finite and positive")]
35    InvalidSearch,
36}
37
38/// How far to look for candidates, how finely to sample an edge, which
39/// walking surfaces to measure, and which objects may play each role.
40///
41/// Measurement inputs, not thresholds: these bound the search, they do not
42/// judge what is found. The surfaces come from the rule's selection, so which
43/// edges are checked for fall protection is stated in the ruleset rather than
44/// guessed by a host.
45///
46/// The candidate sets work the same way. A mesh does not say whether a body
47/// is a railing or a cupboard, so without them any nearby body counts as a
48/// barrier, landing or climbing aid. A set that is present restricts that role
49/// to its members; an absent set leaves the role open to every body.
50#[derive(Clone, Debug, PartialEq)]
51pub struct GuardSearch {
52    candidate_radius_metres: f64,
53    sample_spacing_metres: f64,
54    surfaces: Vec<ObjectId>,
55    barriers: Option<Vec<ObjectId>>,
56    landings: Option<Vec<ObjectId>>,
57    climbables: Option<Vec<ObjectId>>,
58}
59
60/// Sorts and deduplicates a candidate set so requests compare canonically.
61fn canonical(mut objects: Vec<ObjectId>) -> Vec<ObjectId> {
62    objects.sort();
63    objects.dedup();
64    objects
65}
66
67/// Whether `object` may play a role restricted to `set`.
68fn admits(set: Option<&[ObjectId]>, object: &ObjectId) -> bool {
69    set.is_none_or(|set| set.binary_search(object).is_ok())
70}
71
72impl GuardSearch {
73    pub fn try_new(
74        candidate_radius_metres: f64,
75        sample_spacing_metres: f64,
76    ) -> Result<Self, GuardError> {
77        let positive = |v: f64| v.is_finite() && v > 0.0;
78        if !positive(candidate_radius_metres) || !positive(sample_spacing_metres) {
79            return Err(GuardError::InvalidSearch);
80        }
81        Ok(Self {
82            candidate_radius_metres,
83            sample_spacing_metres,
84            surfaces: Vec::new(),
85            barriers: None,
86            landings: None,
87            climbables: None,
88        })
89    }
90    /// Asks for these walking surfaces to be measured, in canonical order.
91    #[must_use]
92    pub fn with_surfaces(mut self, surfaces: Vec<ObjectId>) -> Self {
93        self.surfaces = canonical(surfaces);
94        self
95    }
96    /// Restricts barriers to these objects. An empty set admits none.
97    #[must_use]
98    pub fn with_barrier_candidates(mut self, barriers: Vec<ObjectId>) -> Self {
99        self.barriers = Some(canonical(barriers));
100        self
101    }
102    /// Restricts landings to these objects. An empty set admits none.
103    #[must_use]
104    pub fn with_landing_candidates(mut self, landings: Vec<ObjectId>) -> Self {
105        self.landings = Some(canonical(landings));
106        self
107    }
108    /// Restricts climbing aids to these objects. An empty set admits none.
109    #[must_use]
110    pub fn with_climbable_candidates(mut self, climbables: Vec<ObjectId>) -> Self {
111        self.climbables = Some(canonical(climbables));
112        self
113    }
114    /// Walking surfaces the caller asks to be measured.
115    pub fn surfaces(&self) -> &[ObjectId] {
116        &self.surfaces
117    }
118    /// Objects allowed to count as barriers, or `None` when any body may.
119    pub fn barrier_candidates(&self) -> Option<&[ObjectId]> {
120        self.barriers.as_deref()
121    }
122    /// Objects allowed to count as landings, or `None` when any body may.
123    pub fn landing_candidates(&self) -> Option<&[ObjectId]> {
124        self.landings.as_deref()
125    }
126    /// Objects allowed to count as climbing aids, or `None` when any body may.
127    pub fn climbable_candidates(&self) -> Option<&[ObjectId]> {
128        self.climbables.as_deref()
129    }
130    /// Whether `object` may be reported as a barrier.
131    pub fn admits_barrier(&self, object: &ObjectId) -> bool {
132        admits(self.barrier_candidates(), object)
133    }
134    /// Whether `object` may be reported as a landing.
135    pub fn admits_landing(&self, object: &ObjectId) -> bool {
136        admits(self.landing_candidates(), object)
137    }
138    /// Whether `object` may be reported as a climbing aid.
139    pub fn admits_climbable(&self, object: &ObjectId) -> bool {
140        admits(self.climbable_candidates(), object)
141    }
142    pub fn candidate_radius_metres(&self) -> f64 {
143        self.candidate_radius_metres
144    }
145    pub fn sample_spacing_metres(&self) -> f64 {
146        self.sample_spacing_metres
147    }
148}
149
150/// An element near an exposed edge, with the geometry a policy needs.
151#[derive(Clone, Debug, PartialEq)]
152pub struct GuardCandidate {
153    element: ObjectId,
154    horizontal_gap_metres: f64,
155    /// Height of the candidate's top above the walking surface. Negative when
156    /// the candidate lies below it, which is how a landing is distinguished
157    /// from a barrier.
158    top_offset_metres: f64,
159    /// Portion of the edge this candidate covers, normalised to `[0, 1]`.
160    edge_interval: [f64; 2],
161    landing_width_metres: f64,
162    /// Top of a curb beneath the candidate, when one exists.
163    curb_top_offset_metres: Option<f64>,
164}
165
166impl GuardCandidate {
167    pub fn try_new(
168        element: ObjectId,
169        horizontal_gap_metres: f64,
170        top_offset_metres: f64,
171        edge_interval: [f64; 2],
172        landing_width_metres: f64,
173        curb_top_offset_metres: Option<f64>,
174    ) -> Result<Self, GuardError> {
175        let finite = |v: f64| v.is_finite();
176        if !finite(horizontal_gap_metres)
177            || !finite(top_offset_metres)
178            || !finite(landing_width_metres)
179            || horizontal_gap_metres < 0.0
180            || landing_width_metres < 0.0
181            || curb_top_offset_metres.is_some_and(|v| !v.is_finite())
182        {
183            return Err(GuardError::InvalidQuantity);
184        }
185        // A coverage interval outside [0, 1] or running backwards cannot be
186        // unioned with its neighbours, and would silently distort coverage.
187        let [start, end] = edge_interval;
188        if !finite(start) || !finite(end) || start < 0.0 || end > 1.0 || start > end {
189            return Err(GuardError::InvalidQuantity);
190        }
191        Ok(Self {
192            element,
193            horizontal_gap_metres,
194            top_offset_metres,
195            edge_interval,
196            landing_width_metres,
197            curb_top_offset_metres,
198        })
199    }
200
201    pub fn element(&self) -> &ObjectId {
202        &self.element
203    }
204    pub fn horizontal_gap_metres(&self) -> f64 {
205        self.horizontal_gap_metres
206    }
207    pub fn top_offset_metres(&self) -> f64 {
208        self.top_offset_metres
209    }
210    pub fn edge_interval(&self) -> [f64; 2] {
211        self.edge_interval
212    }
213    pub fn landing_width_metres(&self) -> f64 {
214        self.landing_width_metres
215    }
216    pub fn curb_top_offset_metres(&self) -> Option<f64> {
217        self.curb_top_offset_metres
218    }
219}
220
221/// An object next to a barrier that could be climbed to defeat it.
222#[derive(Clone, Debug, PartialEq)]
223pub struct ClimbableCandidate {
224    element: ObjectId,
225    barrier: ObjectId,
226    distance_to_barrier_metres: f64,
227    top_offset_metres: f64,
228    minimum_side_length_metres: f64,
229}
230
231impl ClimbableCandidate {
232    pub fn try_new(
233        element: ObjectId,
234        barrier: ObjectId,
235        distance_to_barrier_metres: f64,
236        top_offset_metres: f64,
237        minimum_side_length_metres: f64,
238    ) -> Result<Self, GuardError> {
239        if !distance_to_barrier_metres.is_finite()
240            || !top_offset_metres.is_finite()
241            || !minimum_side_length_metres.is_finite()
242            || distance_to_barrier_metres < 0.0
243            || minimum_side_length_metres < 0.0
244        {
245            return Err(GuardError::InvalidQuantity);
246        }
247        Ok(Self {
248            element,
249            barrier,
250            distance_to_barrier_metres,
251            top_offset_metres,
252            minimum_side_length_metres,
253        })
254    }
255    pub fn element(&self) -> &ObjectId {
256        &self.element
257    }
258    pub fn barrier(&self) -> &ObjectId {
259        &self.barrier
260    }
261    pub fn distance_to_barrier_metres(&self) -> f64 {
262        self.distance_to_barrier_metres
263    }
264    pub fn top_offset_metres(&self) -> f64 {
265        self.top_offset_metres
266    }
267    pub fn minimum_side_length_metres(&self) -> f64 {
268        self.minimum_side_length_metres
269    }
270}
271
272/// One exposed edge of a walking surface, and what sits near it.
273#[derive(Clone, Debug, PartialEq)]
274pub struct GuardEdge {
275    surface: ObjectId,
276    barriers: Vec<GuardCandidate>,
277    landings: Vec<GuardCandidate>,
278    climbables: Vec<ClimbableCandidate>,
279}
280
281impl GuardEdge {
282    pub fn new(
283        surface: ObjectId,
284        barriers: Vec<GuardCandidate>,
285        landings: Vec<GuardCandidate>,
286        climbables: Vec<ClimbableCandidate>,
287    ) -> Self {
288        Self {
289            surface,
290            barriers,
291            landings,
292            climbables,
293        }
294    }
295    pub fn surface(&self) -> &ObjectId {
296        &self.surface
297    }
298    pub fn barriers(&self) -> &[GuardCandidate] {
299        &self.barriers
300    }
301    pub fn landings(&self) -> &[GuardCandidate] {
302        &self.landings
303    }
304    pub fn climbables(&self) -> &[ClimbableCandidate] {
305        &self.climbables
306    }
307
308    /// Fraction of this edge covered by candidates whose gap is within
309    /// `maximum_gap_metres`, unioning overlapping intervals.
310    ///
311    /// Union rather than sum: two barriers covering the same half of an edge
312    /// guard half of it, not all of it. Summing would let overlapping rails
313    /// hide an unguarded run.
314    pub fn covered_fraction(candidates: &[GuardCandidate], maximum_gap_metres: f64) -> f64 {
315        let mut intervals: Vec<[f64; 2]> = candidates
316            .iter()
317            .filter(|candidate| candidate.horizontal_gap_metres() <= maximum_gap_metres)
318            .map(GuardCandidate::edge_interval)
319            .collect();
320        intervals.sort_by(|a, b| a[0].total_cmp(&b[0]));
321        let mut covered = 0.0;
322        let mut cursor = f64::NEG_INFINITY;
323        for [start, end] in intervals {
324            let from = start.max(cursor);
325            if end > from {
326                covered += end - from;
327                cursor = end;
328            }
329        }
330        covered
331    }
332}
333
334/// Measured fall-protection geometry for a model.
335#[derive(Clone, Debug, PartialEq)]
336pub struct GuardEvidence {
337    edges: Vec<GuardEdge>,
338    evaluated_surfaces: usize,
339    evidence: Evidence,
340}
341
342impl GuardEvidence {
343    pub fn try_new(
344        edges: Vec<GuardEdge>,
345        evaluated_surfaces: usize,
346        evidence: Evidence,
347    ) -> Result<Self, GuardError> {
348        if !reviewable_exact_evidence(&evidence) {
349            return Err(GuardError::InexactEvidence);
350        }
351        Ok(Self {
352            edges,
353            evaluated_surfaces,
354            evidence,
355        })
356    }
357    pub fn edges(&self) -> &[GuardEdge] {
358        &self.edges
359    }
360    /// How many walking surfaces the measurement considered.
361    pub fn evaluated_surfaces(&self) -> usize {
362        self.evaluated_surfaces
363    }
364    pub fn evidence(&self) -> &Evidence {
365        &self.evidence
366    }
367}
368
369/// Measures exposed edges and the elements that could guard them.
370///
371/// ADR 0004: every method returns a measurement. None returns a finding.
372pub trait GuardService: Send + Sync + 'static {
373    fn measure_guard_edges(&self, search: GuardSearch) -> Result<GuardEvidence, GuardError>;
374}
375
376/// Registry handle for a [`GuardService`].
377#[derive(Clone)]
378pub struct GuardServiceHandle(Arc<dyn GuardService>);
379
380impl GuardServiceHandle {
381    pub fn new(service: Arc<dyn GuardService>) -> Self {
382        Self(service)
383    }
384    pub fn measure_guard_edges(&self, search: GuardSearch) -> Result<GuardEvidence, GuardError> {
385        self.0.measure_guard_edges(search)
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use axioval_ir::SourceId;
393
394    fn oid(local: &str) -> ObjectId {
395        ObjectId::new(SourceId::new("cad", "m").unwrap(), local).unwrap()
396    }
397    fn candidate(gap: f64, interval: [f64; 2]) -> GuardCandidate {
398        GuardCandidate::try_new(oid("rail"), gap, 1.1, interval, 0.0, None).unwrap()
399    }
400
401    /// Two rails guarding the same half of an edge guard half of it. Summing
402    /// would report full coverage and hide the unguarded run.
403    #[test]
404    fn overlapping_candidates_are_unioned_not_summed() {
405        let covered = GuardEdge::covered_fraction(
406            &[candidate(0.0, [0.0, 0.5]), candidate(0.0, [0.25, 0.5])],
407            0.1,
408        );
409        assert!((covered - 0.5).abs() < 1.0e-9, "{covered}");
410    }
411
412    #[test]
413    fn candidates_beyond_the_gap_do_not_count_as_coverage() {
414        let covered = GuardEdge::covered_fraction(&[candidate(0.9, [0.0, 1.0])], 0.1);
415        assert!(covered.abs() < 1.0e-9, "{covered}");
416    }
417
418    #[test]
419    fn disjoint_candidates_accumulate() {
420        let covered = GuardEdge::covered_fraction(
421            &[candidate(0.0, [0.0, 0.25]), candidate(0.0, [0.75, 1.0])],
422            0.1,
423        );
424        assert!((covered - 0.5).abs() < 1.0e-9, "{covered}");
425    }
426
427    #[test]
428    fn malformed_intervals_are_refused() {
429        for interval in [[0.5, 0.25], [-0.1, 0.5], [0.0, 1.5], [f64::NAN, 1.0]] {
430            assert_eq!(
431                GuardCandidate::try_new(oid("r"), 0.0, 1.0, interval, 0.0, None),
432                Err(GuardError::InvalidQuantity)
433            );
434        }
435    }
436
437    #[test]
438    fn non_positive_search_radii_are_refused() {
439        assert_eq!(
440            GuardSearch::try_new(0.0, 0.1),
441            Err(GuardError::InvalidSearch)
442        );
443        assert_eq!(
444            GuardSearch::try_new(1.0, 0.0),
445            Err(GuardError::InvalidSearch)
446        );
447        assert!(GuardSearch::try_new(1.0, 0.1).is_ok());
448    }
449
450    /// An absent set admits every body; a present one only its members, and
451    /// an empty one none at all.
452    #[test]
453    fn candidate_sets_restrict_only_their_own_role() {
454        let open = GuardSearch::try_new(1.0, 0.1).unwrap();
455        assert!(open.admits_barrier(&oid("cupboard")));
456        assert!(open.admits_landing(&oid("cupboard")));
457        assert!(open.admits_climbable(&oid("cupboard")));
458
459        let restricted = open
460            .with_barrier_candidates(vec![oid("rail"), oid("rail")])
461            .with_landing_candidates(Vec::new());
462        assert_eq!(restricted.barrier_candidates(), Some(&[oid("rail")][..]));
463        assert!(restricted.admits_barrier(&oid("rail")));
464        assert!(!restricted.admits_barrier(&oid("cupboard")));
465        assert!(!restricted.admits_landing(&oid("terrace")));
466        assert!(restricted.admits_climbable(&oid("cupboard")));
467    }
468}