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, and how finely to sample an edge.
39///
40/// Measurement inputs, not thresholds: these bound the search, they do not
41/// judge what is found.
42#[derive(Clone, Copy, Debug, PartialEq)]
43pub struct GuardSearch {
44    candidate_radius_metres: f64,
45    sample_spacing_metres: f64,
46}
47
48impl GuardSearch {
49    pub fn try_new(
50        candidate_radius_metres: f64,
51        sample_spacing_metres: f64,
52    ) -> Result<Self, GuardError> {
53        let positive = |v: f64| v.is_finite() && v > 0.0;
54        if !positive(candidate_radius_metres) || !positive(sample_spacing_metres) {
55            return Err(GuardError::InvalidSearch);
56        }
57        Ok(Self {
58            candidate_radius_metres,
59            sample_spacing_metres,
60        })
61    }
62    pub fn candidate_radius_metres(&self) -> f64 {
63        self.candidate_radius_metres
64    }
65    pub fn sample_spacing_metres(&self) -> f64 {
66        self.sample_spacing_metres
67    }
68}
69
70/// An element near an exposed edge, with the geometry a policy needs.
71#[derive(Clone, Debug, PartialEq)]
72pub struct GuardCandidate {
73    element: ObjectId,
74    horizontal_gap_metres: f64,
75    /// Height of the candidate's top above the walking surface. Negative when
76    /// the candidate lies below it, which is how a landing is distinguished
77    /// from a barrier.
78    top_offset_metres: f64,
79    /// Portion of the edge this candidate covers, normalised to `[0, 1]`.
80    edge_interval: [f64; 2],
81    landing_width_metres: f64,
82    /// Top of a curb beneath the candidate, when one exists.
83    curb_top_offset_metres: Option<f64>,
84}
85
86impl GuardCandidate {
87    pub fn try_new(
88        element: ObjectId,
89        horizontal_gap_metres: f64,
90        top_offset_metres: f64,
91        edge_interval: [f64; 2],
92        landing_width_metres: f64,
93        curb_top_offset_metres: Option<f64>,
94    ) -> Result<Self, GuardError> {
95        let finite = |v: f64| v.is_finite();
96        if !finite(horizontal_gap_metres)
97            || !finite(top_offset_metres)
98            || !finite(landing_width_metres)
99            || horizontal_gap_metres < 0.0
100            || landing_width_metres < 0.0
101            || curb_top_offset_metres.is_some_and(|v| !v.is_finite())
102        {
103            return Err(GuardError::InvalidQuantity);
104        }
105        // A coverage interval outside [0, 1] or running backwards cannot be
106        // unioned with its neighbours, and would silently distort coverage.
107        let [start, end] = edge_interval;
108        if !finite(start) || !finite(end) || start < 0.0 || end > 1.0 || start > end {
109            return Err(GuardError::InvalidQuantity);
110        }
111        Ok(Self {
112            element,
113            horizontal_gap_metres,
114            top_offset_metres,
115            edge_interval,
116            landing_width_metres,
117            curb_top_offset_metres,
118        })
119    }
120
121    pub fn element(&self) -> &ObjectId {
122        &self.element
123    }
124    pub fn horizontal_gap_metres(&self) -> f64 {
125        self.horizontal_gap_metres
126    }
127    pub fn top_offset_metres(&self) -> f64 {
128        self.top_offset_metres
129    }
130    pub fn edge_interval(&self) -> [f64; 2] {
131        self.edge_interval
132    }
133    pub fn landing_width_metres(&self) -> f64 {
134        self.landing_width_metres
135    }
136    pub fn curb_top_offset_metres(&self) -> Option<f64> {
137        self.curb_top_offset_metres
138    }
139}
140
141/// An object next to a barrier that could be climbed to defeat it.
142#[derive(Clone, Debug, PartialEq)]
143pub struct ClimbableCandidate {
144    element: ObjectId,
145    barrier: ObjectId,
146    distance_to_barrier_metres: f64,
147    top_offset_metres: f64,
148    minimum_side_length_metres: f64,
149}
150
151impl ClimbableCandidate {
152    pub fn try_new(
153        element: ObjectId,
154        barrier: ObjectId,
155        distance_to_barrier_metres: f64,
156        top_offset_metres: f64,
157        minimum_side_length_metres: f64,
158    ) -> Result<Self, GuardError> {
159        if !distance_to_barrier_metres.is_finite()
160            || !top_offset_metres.is_finite()
161            || !minimum_side_length_metres.is_finite()
162            || distance_to_barrier_metres < 0.0
163            || minimum_side_length_metres < 0.0
164        {
165            return Err(GuardError::InvalidQuantity);
166        }
167        Ok(Self {
168            element,
169            barrier,
170            distance_to_barrier_metres,
171            top_offset_metres,
172            minimum_side_length_metres,
173        })
174    }
175    pub fn element(&self) -> &ObjectId {
176        &self.element
177    }
178    pub fn barrier(&self) -> &ObjectId {
179        &self.barrier
180    }
181    pub fn distance_to_barrier_metres(&self) -> f64 {
182        self.distance_to_barrier_metres
183    }
184    pub fn top_offset_metres(&self) -> f64 {
185        self.top_offset_metres
186    }
187    pub fn minimum_side_length_metres(&self) -> f64 {
188        self.minimum_side_length_metres
189    }
190}
191
192/// One exposed edge of a walking surface, and what sits near it.
193#[derive(Clone, Debug, PartialEq)]
194pub struct GuardEdge {
195    surface: ObjectId,
196    barriers: Vec<GuardCandidate>,
197    landings: Vec<GuardCandidate>,
198    climbables: Vec<ClimbableCandidate>,
199}
200
201impl GuardEdge {
202    pub fn new(
203        surface: ObjectId,
204        barriers: Vec<GuardCandidate>,
205        landings: Vec<GuardCandidate>,
206        climbables: Vec<ClimbableCandidate>,
207    ) -> Self {
208        Self {
209            surface,
210            barriers,
211            landings,
212            climbables,
213        }
214    }
215    pub fn surface(&self) -> &ObjectId {
216        &self.surface
217    }
218    pub fn barriers(&self) -> &[GuardCandidate] {
219        &self.barriers
220    }
221    pub fn landings(&self) -> &[GuardCandidate] {
222        &self.landings
223    }
224    pub fn climbables(&self) -> &[ClimbableCandidate] {
225        &self.climbables
226    }
227
228    /// Fraction of this edge covered by candidates whose gap is within
229    /// `maximum_gap_metres`, unioning overlapping intervals.
230    ///
231    /// Union rather than sum: two barriers covering the same half of an edge
232    /// guard half of it, not all of it. Summing would let overlapping rails
233    /// hide an unguarded run.
234    pub fn covered_fraction(candidates: &[GuardCandidate], maximum_gap_metres: f64) -> f64 {
235        let mut intervals: Vec<[f64; 2]> = candidates
236            .iter()
237            .filter(|candidate| candidate.horizontal_gap_metres() <= maximum_gap_metres)
238            .map(GuardCandidate::edge_interval)
239            .collect();
240        intervals.sort_by(|a, b| a[0].total_cmp(&b[0]));
241        let mut covered = 0.0;
242        let mut cursor = f64::NEG_INFINITY;
243        for [start, end] in intervals {
244            let from = start.max(cursor);
245            if end > from {
246                covered += end - from;
247                cursor = end;
248            }
249        }
250        covered
251    }
252}
253
254/// Measured fall-protection geometry for a model.
255#[derive(Clone, Debug, PartialEq)]
256pub struct GuardEvidence {
257    edges: Vec<GuardEdge>,
258    evaluated_surfaces: usize,
259    evidence: Evidence,
260}
261
262impl GuardEvidence {
263    pub fn try_new(
264        edges: Vec<GuardEdge>,
265        evaluated_surfaces: usize,
266        evidence: Evidence,
267    ) -> Result<Self, GuardError> {
268        if !reviewable_exact_evidence(&evidence) {
269            return Err(GuardError::InexactEvidence);
270        }
271        Ok(Self {
272            edges,
273            evaluated_surfaces,
274            evidence,
275        })
276    }
277    pub fn edges(&self) -> &[GuardEdge] {
278        &self.edges
279    }
280    /// How many walking surfaces the measurement considered.
281    pub fn evaluated_surfaces(&self) -> usize {
282        self.evaluated_surfaces
283    }
284    pub fn evidence(&self) -> &Evidence {
285        &self.evidence
286    }
287}
288
289/// Measures exposed edges and the elements that could guard them.
290///
291/// ADR 0004: every method returns a measurement. None returns a finding.
292pub trait GuardService: Send + Sync + 'static {
293    fn measure_guard_edges(&self, search: GuardSearch) -> Result<GuardEvidence, GuardError>;
294}
295
296/// Registry handle for a [`GuardService`].
297#[derive(Clone)]
298pub struct GuardServiceHandle(Arc<dyn GuardService>);
299
300impl GuardServiceHandle {
301    pub fn new(service: Arc<dyn GuardService>) -> Self {
302        Self(service)
303    }
304    pub fn measure_guard_edges(&self, search: GuardSearch) -> Result<GuardEvidence, GuardError> {
305        self.0.measure_guard_edges(search)
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use axioval_ir::SourceId;
313
314    fn oid(local: &str) -> ObjectId {
315        ObjectId::new(SourceId::new("cad", "m").unwrap(), local).unwrap()
316    }
317    fn candidate(gap: f64, interval: [f64; 2]) -> GuardCandidate {
318        GuardCandidate::try_new(oid("rail"), gap, 1.1, interval, 0.0, None).unwrap()
319    }
320
321    /// Two rails guarding the same half of an edge guard half of it. Summing
322    /// would report full coverage and hide the unguarded run.
323    #[test]
324    fn overlapping_candidates_are_unioned_not_summed() {
325        let covered = GuardEdge::covered_fraction(
326            &[candidate(0.0, [0.0, 0.5]), candidate(0.0, [0.25, 0.5])],
327            0.1,
328        );
329        assert!((covered - 0.5).abs() < 1.0e-9, "{covered}");
330    }
331
332    #[test]
333    fn candidates_beyond_the_gap_do_not_count_as_coverage() {
334        let covered = GuardEdge::covered_fraction(&[candidate(0.9, [0.0, 1.0])], 0.1);
335        assert!(covered.abs() < 1.0e-9, "{covered}");
336    }
337
338    #[test]
339    fn disjoint_candidates_accumulate() {
340        let covered = GuardEdge::covered_fraction(
341            &[candidate(0.0, [0.0, 0.25]), candidate(0.0, [0.75, 1.0])],
342            0.1,
343        );
344        assert!((covered - 0.5).abs() < 1.0e-9, "{covered}");
345    }
346
347    #[test]
348    fn malformed_intervals_are_refused() {
349        for interval in [[0.5, 0.25], [-0.1, 0.5], [0.0, 1.5], [f64::NAN, 1.0]] {
350            assert_eq!(
351                GuardCandidate::try_new(oid("r"), 0.0, 1.0, interval, 0.0, None),
352                Err(GuardError::InvalidQuantity)
353            );
354        }
355    }
356
357    #[test]
358    fn non_positive_search_radii_are_refused() {
359        assert_eq!(
360            GuardSearch::try_new(0.0, 0.1),
361            Err(GuardError::InvalidSearch)
362        );
363        assert_eq!(
364            GuardSearch::try_new(1.0, 0.0),
365            Err(GuardError::InvalidSearch)
366        );
367        assert!(GuardSearch::try_new(1.0, 0.1).is_ok());
368    }
369}