axiolid_pointcloud_reconstruction_contract/
conformance.rs

1//! Conformance suite every `PointcloudReconstruction` provider must pass.
2//!
3//! # Why this is library code, not a test file
4//!
5//! A test bound to a concrete provider tests *that provider*, not *the
6//! contract*: a second provider would inherit no obligations at all. This
7//! suite is generic over `impl PointcloudReconstruction` and exported, so an
8//! out-of-tree provider runs the identical checks.
9//!
10//! # What it does and does not prove
11//!
12//! It checks *contract* obligations: refusals are typed, evidence is
13//! populated and self-consistent, empty results are explained, and repeated
14//! calls agree when the provider claims determinism. It does not check that
15//! the reconstructed surface is geometrically faithful — no contract can,
16//! because a point set does not determine a unique surface. A provider
17//! passing this suite is well-behaved, not necessarily accurate.
18//!
19//! # Skips are not passes
20//!
21//! A provider may legitimately refuse work. Such a case is recorded as
22//! [`Outcome::Skipped`] with its reason and reported separately, so a
23//! provider cannot reach "conformant" by refusing everything: a caller can
24//! see exactly what was actually exercised.
25
26use core::fmt;
27
28use axiolid_contracts::{Determinism, ExecutionOptions};
29use axiolid_core::{Point3, Scalar, Tolerance};
30use axiolid_pointcloud::PointCloud;
31
32use crate::{PointcloudReconstruction, Reconstruction, ReconstructionRequest};
33
34/// Result of one conformance check.
35#[derive(Debug, Clone, PartialEq)]
36pub enum Outcome {
37    /// The obligation was met.
38    Passed,
39    /// The obligation was violated, with what went wrong.
40    Failed(String),
41    /// The provider legitimately declined, with its stated reason.
42    ///
43    /// Reported separately from a pass so refusing everything cannot look
44    /// like conformance.
45    Skipped(String),
46}
47
48impl Outcome {
49    /// Whether this outcome blocks conformance.
50    pub fn is_failure(&self) -> bool {
51        matches!(self, Self::Failed(_))
52    }
53}
54
55/// One named check and how it went.
56#[derive(Debug, Clone, PartialEq)]
57pub struct Check {
58    /// What was checked.
59    pub name: &'static str,
60    /// How it went.
61    pub outcome: Outcome,
62}
63
64/// Everything the suite found.
65#[derive(Debug, Clone, PartialEq)]
66pub struct ConformanceReport {
67    /// Every check, in the order run.
68    pub checks: Vec<Check>,
69}
70
71impl ConformanceReport {
72    /// Whether every check passed or was legitimately skipped.
73    pub fn is_conformant(&self) -> bool {
74        !self.checks.iter().any(|check| check.outcome.is_failure())
75    }
76
77    /// Checks that actually exercised the provider.
78    pub fn exercised(&self) -> usize {
79        self.checks
80            .iter()
81            .filter(|check| matches!(check.outcome, Outcome::Passed))
82            .count()
83    }
84
85    /// Checks the provider declined.
86    pub fn skipped(&self) -> usize {
87        self.checks
88            .iter()
89            .filter(|check| matches!(check.outcome, Outcome::Skipped(_)))
90            .count()
91    }
92}
93
94impl fmt::Display for ConformanceReport {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        writeln!(
97            f,
98            "conformance: {} exercised, {} skipped, {} failed",
99            self.exercised(),
100            self.skipped(),
101            self.checks
102                .iter()
103                .filter(|check| check.outcome.is_failure())
104                .count()
105        )?;
106        for check in &self.checks {
107            match &check.outcome {
108                Outcome::Passed => writeln!(f, "  PASS {}", check.name)?,
109                Outcome::Skipped(reason) => writeln!(f, "  SKIP {} ({reason})", check.name)?,
110                Outcome::Failed(detail) => writeln!(f, "  FAIL {} — {detail}", check.name)?,
111            }
112        }
113        Ok(())
114    }
115}
116
117fn options() -> ExecutionOptions {
118    ExecutionOptions::new(Tolerance::METRE)
119}
120
121/// A dense sampling of a sphere: enough points for any method, with normals.
122fn sphere_samples(count: usize) -> PointCloud {
123    let mut points = Vec::with_capacity(count);
124    let mut normals = Vec::with_capacity(count);
125    // Fibonacci sphere: even coverage without clustering at the poles, so a
126    // provider is not tested against an artificially easy distribution.
127    let golden = core::f64::consts::PI * (3.0 - 5.0_f64.sqrt());
128    for i in 0..count {
129        let y = 1.0 - (i as Scalar / (count.max(2) - 1) as Scalar) * 2.0;
130        let radius = (1.0 - y * y).max(0.0).sqrt();
131        let theta = golden * i as Scalar;
132        let direction = Point3::new(theta.cos() * radius, y, theta.sin() * radius);
133        points.push(direction);
134        normals.push(direction);
135    }
136    PointCloud::new(points)
137        .expect("sphere samples are finite")
138        .with_normals(normals)
139        .expect("normals match")
140}
141
142/// Run every contract obligation against a provider.
143pub fn run(provider: &impl PointcloudReconstruction) -> ConformanceReport {
144    let checks = vec![
145        Check {
146            name: "too few points is refused by name, not by empty mesh",
147            outcome: check_too_few(provider),
148        },
149        Check {
150            name: "degenerate extent is refused by name",
151            outcome: check_degenerate(provider),
152        },
153        Check {
154            name: "evidence counts agree with the mesh returned",
155            outcome: check_evidence_consistent(provider),
156        },
157        Check {
158            name: "input point count is reported faithfully",
159            outcome: check_input_count(provider),
160        },
161        Check {
162            name: "a declared-deterministic provider repeats itself",
163            outcome: check_determinism(provider),
164        },
165        Check {
166            name: "minimum_points agrees with actual refusal behaviour",
167            outcome: check_minimum_agrees(provider),
168        },
169    ];
170
171    ConformanceReport { checks }
172}
173
174fn check_too_few(provider: &impl PointcloudReconstruction) -> Outcome {
175    let cloud = PointCloud::new(vec![Point3::ZERO]).expect("one point");
176    match provider.reconstruct(&cloud, &ReconstructionRequest::default(), &options()) {
177        Ok(Reconstruction::Refused(_)) => Outcome::Passed,
178        Ok(Reconstruction::Surface(outcome)) => Outcome::Failed(format!(
179            "a single point produced a surface with {} triangles",
180            outcome.mesh.indices.len() / 3
181        )),
182        Err(error) => Outcome::Failed(format!("errored instead of refusing: {error}")),
183    }
184}
185
186fn check_degenerate(provider: &impl PointcloudReconstruction) -> Outcome {
187    // All points on one line: no surface exists, and a provider that
188    // returns a zero-thickness sheet is misrepresenting the input.
189    let points: Vec<Point3> = (0..64)
190        .map(|i| Point3::new(i as Scalar * 0.1, 0.0, 0.0))
191        .collect();
192    let cloud = PointCloud::new(points).expect("collinear points are finite");
193    match provider.reconstruct(&cloud, &ReconstructionRequest::default(), &options()) {
194        Ok(Reconstruction::Refused(_)) => Outcome::Passed,
195        Ok(Reconstruction::Surface(outcome)) => Outcome::Failed(format!(
196            "collinear points produced a surface with {} triangles",
197            outcome.mesh.indices.len() / 3
198        )),
199        Err(error) => Outcome::Failed(format!("errored instead of refusing: {error}")),
200    }
201}
202
203fn check_evidence_consistent(provider: &impl PointcloudReconstruction) -> Outcome {
204    let cloud = sphere_samples(512);
205    match provider.reconstruct(&cloud, &ReconstructionRequest::default(), &options()) {
206        Ok(Reconstruction::Refused(reason)) => Outcome::Skipped(reason.to_string()),
207        Err(error) => Outcome::Failed(format!("errored on a dense sphere: {error}")),
208        Ok(Reconstruction::Surface(outcome)) => {
209            let actual = outcome.mesh.indices.len() / 3;
210            if outcome.evidence.output_triangles != actual {
211                return Outcome::Failed(format!(
212                    "evidence claims {} triangles, mesh has {actual}",
213                    outcome.evidence.output_triangles
214                ));
215            }
216            if outcome.evidence.used_points > outcome.evidence.input_points {
217                return Outcome::Failed(format!(
218                    "used {} of {} points",
219                    outcome.evidence.used_points, outcome.evidence.input_points
220                ));
221            }
222            if actual > 0 && !outcome.evidence.achieved_edge_length.is_finite() {
223                return Outcome::Failed(
224                    "a surface was produced without reporting its edge length".to_owned(),
225                );
226            }
227            if outcome.evidence.interpolated_triangles > actual {
228                return Outcome::Failed(format!(
229                    "claims {} interpolated triangles of {actual} total",
230                    outcome.evidence.interpolated_triangles
231                ));
232            }
233            Outcome::Passed
234        }
235    }
236}
237
238fn check_input_count(provider: &impl PointcloudReconstruction) -> Outcome {
239    let cloud = sphere_samples(300);
240    match provider.reconstruct(&cloud, &ReconstructionRequest::default(), &options()) {
241        Ok(Reconstruction::Refused(reason)) => Outcome::Skipped(reason.to_string()),
242        Err(error) => Outcome::Failed(format!("errored: {error}")),
243        Ok(outcome) => {
244            let outcome = outcome.outcome().expect("surface");
245            if outcome.evidence.input_points == cloud.len() {
246                Outcome::Passed
247            } else {
248                Outcome::Failed(format!(
249                    "reported {} input points for a cloud of {}",
250                    outcome.evidence.input_points,
251                    cloud.len()
252                ))
253            }
254        }
255    }
256}
257
258fn check_determinism(provider: &impl PointcloudReconstruction) -> Outcome {
259    if provider.determinism() == Determinism::BestEffort {
260        return Outcome::Skipped("provider only claims best-effort determinism".to_owned());
261    }
262    let cloud = sphere_samples(400);
263    let request = ReconstructionRequest::default();
264    let first = provider.reconstruct(&cloud, &request, &options());
265    let second = provider.reconstruct(&cloud, &request, &options());
266    match (first, second) {
267        (Ok(Reconstruction::Surface(a)), Ok(Reconstruction::Surface(b))) => {
268            if a.mesh.positions == b.mesh.positions && a.mesh.indices == b.mesh.indices {
269                Outcome::Passed
270            } else {
271                Outcome::Failed(
272                    "a provider claiming determinism produced two different meshes".to_owned(),
273                )
274            }
275        }
276        (Ok(Reconstruction::Refused(reason)), _) => Outcome::Skipped(reason.to_string()),
277        (Err(error), _) | (_, Err(error)) => Outcome::Failed(format!("errored: {error}")),
278        _ => Outcome::Failed("one call produced a surface and the other refused".to_owned()),
279    }
280}
281
282fn check_minimum_agrees(provider: &impl PointcloudReconstruction) -> Outcome {
283    let minimum = provider.minimum_points();
284    if minimum == 0 {
285        return Outcome::Failed("minimum_points of 0 cannot be honest".to_owned());
286    }
287    // One below the declared minimum must be refused: a provider whose
288    // advertised threshold does not match its behaviour makes the value
289    // useless for pre-flighting.
290    let points: Vec<Point3> = (0..minimum.saturating_sub(1))
291        .map(|i| Point3::new(i as Scalar, (i % 3) as Scalar, (i % 5) as Scalar))
292        .collect();
293    if points.is_empty() {
294        return Outcome::Skipped("minimum is 1; nothing below it to test".to_owned());
295    }
296    let cloud = PointCloud::new(points).expect("finite");
297    match provider.reconstruct(&cloud, &ReconstructionRequest::default(), &options()) {
298        Ok(Reconstruction::Refused(_)) => Outcome::Passed,
299        Ok(Reconstruction::Surface(_)) => Outcome::Failed(format!(
300            "declared minimum_points={minimum} but reconstructed from {}",
301            minimum - 1
302        )),
303        Err(error) => Outcome::Failed(format!("errored instead of refusing: {error}")),
304    }
305}