1use std::sync::Arc;
16
17use axioval_ir::{Evidence, ObjectId};
18
19use crate::services::reviewable_exact_evidence;
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
23pub enum GuardError {
24 #[error("guard quantities must be finite and intervals ordered within [0, 1]")]
26 InvalidQuantity,
27 #[error("guard evidence must be exact and reviewable")]
29 InexactEvidence,
30 #[error("guard measurement is unavailable")]
32 Unavailable,
33 #[error("guard search radii must be finite and positive")]
35 InvalidSearch,
36}
37
38#[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
60fn canonical(mut objects: Vec<ObjectId>) -> Vec<ObjectId> {
62 objects.sort();
63 objects.dedup();
64 objects
65}
66
67fn 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 #[must_use]
92 pub fn with_surfaces(mut self, surfaces: Vec<ObjectId>) -> Self {
93 self.surfaces = canonical(surfaces);
94 self
95 }
96 #[must_use]
98 pub fn with_barrier_candidates(mut self, barriers: Vec<ObjectId>) -> Self {
99 self.barriers = Some(canonical(barriers));
100 self
101 }
102 #[must_use]
104 pub fn with_landing_candidates(mut self, landings: Vec<ObjectId>) -> Self {
105 self.landings = Some(canonical(landings));
106 self
107 }
108 #[must_use]
110 pub fn with_climbable_candidates(mut self, climbables: Vec<ObjectId>) -> Self {
111 self.climbables = Some(canonical(climbables));
112 self
113 }
114 pub fn surfaces(&self) -> &[ObjectId] {
116 &self.surfaces
117 }
118 pub fn barrier_candidates(&self) -> Option<&[ObjectId]> {
120 self.barriers.as_deref()
121 }
122 pub fn landing_candidates(&self) -> Option<&[ObjectId]> {
124 self.landings.as_deref()
125 }
126 pub fn climbable_candidates(&self) -> Option<&[ObjectId]> {
128 self.climbables.as_deref()
129 }
130 pub fn admits_barrier(&self, object: &ObjectId) -> bool {
132 admits(self.barrier_candidates(), object)
133 }
134 pub fn admits_landing(&self, object: &ObjectId) -> bool {
136 admits(self.landing_candidates(), object)
137 }
138 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#[derive(Clone, Debug, PartialEq)]
152pub struct GuardCandidate {
153 element: ObjectId,
154 horizontal_gap_metres: f64,
155 top_offset_metres: f64,
159 edge_interval: [f64; 2],
161 landing_width_metres: f64,
162 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 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#[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#[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 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#[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 pub fn evaluated_surfaces(&self) -> usize {
362 self.evaluated_surfaces
363 }
364 pub fn evidence(&self) -> &Evidence {
365 &self.evidence
366 }
367}
368
369pub trait GuardService: Send + Sync + 'static {
373 fn measure_guard_edges(&self, search: GuardSearch) -> Result<GuardEvidence, GuardError>;
374}
375
376#[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 #[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 #[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}