Skip to main content

axioval_engine/
contact.rs

1//! Source-neutral surface-contact evidence.
2//!
3//! ADR 0004: a service returns what was *measured*; a capability decides what
4//! it means. This is the measurement half of the slab-contact decomposition.
5//!
6//! Two things deliberately do **not** cross this seam:
7//!
8//! - **No verdict state.** The source provider returned a four-state enum in
9//!   which `IgnoredSmall` was a threshold decision and `Skipped` a scope
10//!   decision. Both are policy. Scope belongs to the selector, and a small
11//!   contact area is just a small measured number.
12//! - **No rounding.** The source rounded the contact ratio to two decimals
13//!   before the rule compared it, so a value could round *up* across the
14//!   declared minimum and pass. Rounding is a presentation choice and cannot
15//!   be allowed to change a verdict, so the measurement stays exact.
16
17use std::sync::Arc;
18
19use axioval_ir::{Evidence, ObjectId};
20
21use crate::services::reviewable_exact_evidence;
22
23/// Why a contact measurement could not be produced.
24#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
25pub enum ContactError {
26    /// Areas are negative, non-finite, or the contact exceeds the whole.
27    #[error("contact areas must be finite, non-negative and contained")]
28    InvalidAreas,
29    /// The evidence backing the measurement was not exact and reviewable.
30    #[error("contact evidence must be exact and reviewable")]
31    InexactEvidence,
32    /// The adapter cannot measure contact for this object.
33    #[error("contact measurement is unavailable for the requested scope")]
34    Unavailable,
35    /// The object's body has no direction the adapter can orient against.
36    #[error("object body has no checkable orientation")]
37    UncheckableOrientation,
38    /// The measurement names an object the request did not offer as a
39    /// candidate, so it answers a different question.
40    #[error("contact evidence names an object that is not a requested candidate")]
41    UnrequestedCandidate,
42}
43
44/// Which side of the subject the contacting surface must lie on.
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub enum ContactSide {
47    Above,
48    Below,
49}
50
51/// Tolerances describing what counts as touching.
52///
53/// These are measurement inputs, not thresholds: they define contact, rather
54/// than judging whether enough of it exists.
55#[derive(Clone, Copy, Debug, PartialEq)]
56// The shared unit suffix is the point: these are lengths and an area in fixed
57// units, and naming the unit on each field is what stops a millimetre or a
58// square metre being passed where metres are meant.
59#[allow(clippy::struct_field_names)]
60pub struct ContactTolerance {
61    maximum_gap_metres: f64,
62    maximum_intersection_metres: f64,
63    minimum_polygon_area_square_metres: f64,
64}
65
66impl ContactTolerance {
67    pub fn try_new(
68        maximum_gap_metres: f64,
69        maximum_intersection_metres: f64,
70        minimum_polygon_area_square_metres: f64,
71    ) -> Result<Self, ContactError> {
72        let ok = |v: f64| v.is_finite() && v >= 0.0;
73        if !ok(maximum_gap_metres)
74            || !ok(maximum_intersection_metres)
75            || !ok(minimum_polygon_area_square_metres)
76        {
77            return Err(ContactError::InvalidAreas);
78        }
79        Ok(Self {
80            maximum_gap_metres,
81            maximum_intersection_metres,
82            minimum_polygon_area_square_metres,
83        })
84    }
85    pub fn maximum_gap_metres(&self) -> f64 {
86        self.maximum_gap_metres
87    }
88    pub fn maximum_intersection_metres(&self) -> f64 {
89        self.maximum_intersection_metres
90    }
91    pub fn minimum_polygon_area_square_metres(&self) -> f64 {
92        self.minimum_polygon_area_square_metres
93    }
94}
95
96/// A request for the contact measurement of one object.
97///
98/// The request names the candidates the face may rest on. Which objects count
99/// is the rule's scope decision, so an adapter measures exactly these and no
100/// others: an object outside the list neither supports the face nor blocks
101/// the measurement.
102#[derive(Clone, Debug, PartialEq)]
103pub struct ContactRequest {
104    subject: ObjectId,
105    candidates: Vec<ObjectId>,
106    side: ContactSide,
107    tolerance: ContactTolerance,
108}
109
110impl ContactRequest {
111    /// Candidates are sorted and deduplicated, and the subject is removed:
112    /// a face never rests on itself.
113    pub fn new(
114        subject: ObjectId,
115        mut candidates: Vec<ObjectId>,
116        side: ContactSide,
117        tolerance: ContactTolerance,
118    ) -> Self {
119        candidates.sort();
120        candidates.dedup();
121        candidates.retain(|candidate| *candidate != subject);
122        Self {
123            subject,
124            candidates,
125            side,
126            tolerance,
127        }
128    }
129    pub fn subject(&self) -> &ObjectId {
130        &self.subject
131    }
132    /// The objects the face may rest on: sorted, deduplicated, subject excluded.
133    pub fn candidates(&self) -> &[ObjectId] {
134        &self.candidates
135    }
136    /// Whether `object` is one of the requested candidates.
137    pub fn is_candidate(&self, object: &ObjectId) -> bool {
138        self.candidates.binary_search(object).is_ok()
139    }
140    pub fn side(&self) -> ContactSide {
141        self.side
142    }
143    pub fn tolerance(&self) -> ContactTolerance {
144        self.tolerance
145    }
146}
147
148/// How much of a subject's face is in contact, and with what.
149#[derive(Clone, Debug, PartialEq)]
150pub struct ContactEvidence {
151    request: ContactRequest,
152    whole_area_square_metres: f64,
153    contact_area_square_metres: f64,
154    nearest_distance_metres: Option<f64>,
155    touching: Vec<ObjectId>,
156    evidence: Evidence,
157}
158
159impl ContactEvidence {
160    /// Rejects incoherent areas and unreviewable evidence, so an adapter
161    /// cannot launder an estimate into the engine as fact.
162    pub fn try_new(
163        request: ContactRequest,
164        whole_area_square_metres: f64,
165        contact_area_square_metres: f64,
166        nearest_distance_metres: Option<f64>,
167        mut touching: Vec<ObjectId>,
168        evidence: Evidence,
169    ) -> Result<Self, ContactError> {
170        let finite_non_negative = |v: f64| v.is_finite() && v >= 0.0;
171        if !finite_non_negative(whole_area_square_metres)
172            || !finite_non_negative(contact_area_square_metres)
173            || whole_area_square_metres <= 0.0
174            // A face cannot touch over more than its own area; if it appears
175            // to, the measurement is wrong and must not reach a rule.
176            || contact_area_square_metres > whole_area_square_metres
177        {
178            return Err(ContactError::InvalidAreas);
179        }
180        if nearest_distance_metres.is_some_and(|d| !finite_non_negative(d)) {
181            return Err(ContactError::InvalidAreas);
182        }
183        if !reviewable_exact_evidence(&evidence) {
184            return Err(ContactError::InexactEvidence);
185        }
186        touching.sort();
187        touching.dedup();
188        // Only a requested candidate can support the face; anything else
189        // answers a question the rule did not ask.
190        if touching.iter().any(|object| !request.is_candidate(object)) {
191            return Err(ContactError::UnrequestedCandidate);
192        }
193        Ok(Self {
194            request,
195            whole_area_square_metres,
196            contact_area_square_metres,
197            nearest_distance_metres,
198            touching,
199            evidence,
200        })
201    }
202
203    pub fn request(&self) -> &ContactRequest {
204        &self.request
205    }
206    pub fn whole_area_square_metres(&self) -> f64 {
207        self.whole_area_square_metres
208    }
209    pub fn contact_area_square_metres(&self) -> f64 {
210        self.contact_area_square_metres
211    }
212    /// Distance to the nearest candidate when nothing is touching.
213    pub fn nearest_distance_metres(&self) -> Option<f64> {
214        self.nearest_distance_metres
215    }
216    /// The objects found in contact, sorted and deduplicated.
217    pub fn touching(&self) -> &[ObjectId] {
218        &self.touching
219    }
220    pub fn evidence(&self) -> &Evidence {
221        &self.evidence
222    }
223
224    /// Fraction of the face in contact, computed exactly.
225    ///
226    /// The divisor is validated positive in [`Self::try_new`], so this cannot
227    /// divide by zero.
228    pub fn contact_ratio(&self) -> f64 {
229        self.contact_area_square_metres / self.whole_area_square_metres
230    }
231}
232
233/// Measures surface contact between model objects.
234///
235/// ADR 0004: every method returns a measurement. None returns a finding.
236pub trait ContactService: Send + Sync + 'static {
237    fn measure_contact(&self, request: &ContactRequest) -> Result<ContactEvidence, ContactError>;
238}
239
240/// Registry handle for a [`ContactService`].
241#[derive(Clone)]
242pub struct ContactServiceHandle(Arc<dyn ContactService>);
243
244impl ContactServiceHandle {
245    pub fn new(service: Arc<dyn ContactService>) -> Self {
246        Self(service)
247    }
248    pub fn measure_contact(
249        &self,
250        request: &ContactRequest,
251    ) -> Result<ContactEvidence, ContactError> {
252        self.0.measure_contact(request)
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use axioval_ir::SourceId;
260
261    fn id(local: &str) -> ObjectId {
262        ObjectId::new(SourceId::new("cad", "m").unwrap(), local).unwrap()
263    }
264    fn request() -> ContactRequest {
265        ContactRequest::new(
266            id("wall"),
267            vec![id("slab-b"), id("slab-a")],
268            ContactSide::Above,
269            ContactTolerance::try_new(0.01, 0.01, 0.001).unwrap(),
270        )
271    }
272    fn evidence() -> Evidence {
273        Evidence::exact(SourceId::new("cad", "m").unwrap(), "contact:wall")
274    }
275
276    /// Contact larger than the face is physically impossible; accepting it
277    /// would let a ratio above 1.0 satisfy any minimum.
278    #[test]
279    fn contact_exceeding_the_whole_face_is_refused() {
280        assert_eq!(
281            ContactEvidence::try_new(request(), 10.0, 11.0, None, Vec::new(), evidence()),
282            Err(ContactError::InvalidAreas)
283        );
284    }
285
286    #[test]
287    fn zero_or_non_finite_whole_area_is_refused() {
288        for whole in [0.0, f64::NAN, f64::INFINITY, -1.0] {
289            assert_eq!(
290                ContactEvidence::try_new(request(), whole, 0.0, None, Vec::new(), evidence()),
291                Err(ContactError::InvalidAreas)
292            );
293        }
294    }
295
296    /// The ratio is exact. Rounding it here would let a value below a declared
297    /// minimum round up and silently pass.
298    #[test]
299    fn contact_ratio_is_exact_and_unrounded() {
300        let measured =
301            ContactEvidence::try_new(request(), 3.0, 1.0, None, Vec::new(), evidence()).unwrap();
302        assert!((measured.contact_ratio() - 1.0 / 3.0).abs() < f64::EPSILON);
303    }
304
305    #[test]
306    fn touching_objects_are_sorted_and_deduplicated() {
307        let measured = ContactEvidence::try_new(
308            request(),
309            4.0,
310            2.0,
311            None,
312            vec![id("slab-b"), id("slab-a"), id("slab-b")],
313            evidence(),
314        )
315        .unwrap();
316        assert_eq!(measured.touching(), &[id("slab-a"), id("slab-b")]);
317    }
318
319    #[test]
320    fn candidates_are_sorted_deduplicated_and_exclude_the_subject() {
321        let request = ContactRequest::new(
322            id("wall"),
323            vec![id("slab-b"), id("wall"), id("slab-a"), id("slab-b")],
324            ContactSide::Above,
325            ContactTolerance::try_new(0.01, 0.01, 0.001).unwrap(),
326        );
327        assert_eq!(request.candidates(), &[id("slab-a"), id("slab-b")]);
328        assert!(!request.is_candidate(&id("wall")));
329    }
330
331    /// Evidence that the face rests on an object the rule did not offer, the
332    /// subject included, answers a different question and must not reach it.
333    #[test]
334    fn touching_an_unrequested_object_is_refused() {
335        for stray in ["slab-c", "wall"] {
336            assert_eq!(
337                ContactEvidence::try_new(request(), 4.0, 2.0, None, vec![id(stray)], evidence()),
338                Err(ContactError::UnrequestedCandidate)
339            );
340        }
341    }
342}