Skip to main content

axiolid_mesh_boolean_contract/
conformance.rs

1//! Conformance suite every `MeshBoolean` provider must pass (ADR 0017 §6).
2//!
3//! # Why this is library code, not a test file
4//!
5//! Before this existed, all provider tests bound the concrete `BoolmeshBoolean`
6//! type, so they tested *that provider*, not *the contract*. A second provider
7//! inherited zero obligations. This suite is generic over `impl MeshBoolean`
8//! and exported, so an out-of-tree provider can run the identical checks.
9//!
10//! # Usage
11//!
12//! ```no_run
13//! # use axiolid_mesh_boolean_contract::conformance::{self, ConformanceReport};
14//! # fn check(provider: &impl axiolid_mesh_boolean_contract::MeshBoolean) {
15//! let report = conformance::run(provider);
16//! assert!(report.is_conformant(), "{report}");
17//! # }
18//! ```
19//!
20//! # What it does and does not prove
21//!
22//! It checks *contract* obligations: operand algebra, admissibility, evidence,
23//! empty-result handling, and determinism. It does not check that geometry is
24//! numerically correct -- that is what differential testing against
25//! `axiolid-reference`'s oracle is for. A provider passing this suite is
26//! well-behaved, not necessarily accurate.
27//!
28//! # Skips are not passes
29//!
30//! A provider may legitimately refuse work (`Unsupported`). Such a case is
31//! recorded as [`Outcome::Skipped`] with its reason and reported separately,
32//! so a provider cannot reach "conformant" by refusing everything: callers can
33//! see exactly what was actually exercised.
34
35use core::fmt;
36
37use axiolid_core::{BooleanOperator, Tolerance};
38
39use crate::{BooleanOutcome, MeshBoolean};
40use axiolid_contracts::{Determinism, ExecutionOptions, GeomError};
41use axiolid_mesh::TriMesh;
42
43/// Result of one conformance check.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum Outcome {
46    /// The provider satisfied the obligation.
47    Passed,
48    /// The provider declined the input; not a failure, but not proof either.
49    Skipped {
50        /// Why the provider declined.
51        reason: String,
52    },
53    /// The provider violated the contract.
54    Failed {
55        /// What went wrong, in terms of the obligation.
56        detail: String,
57    },
58}
59
60/// One named obligation and how the provider answered it.
61#[derive(Debug, Clone)]
62pub struct Check {
63    /// Stable identifier for the obligation.
64    pub name: &'static str,
65    /// What the provider did.
66    pub outcome: Outcome,
67}
68
69/// Full conformance result for one provider.
70#[derive(Debug, Clone, Default)]
71pub struct ConformanceReport {
72    /// Every obligation, in execution order.
73    pub checks: Vec<Check>,
74}
75
76impl ConformanceReport {
77    /// Whether the provider violated no obligation.
78    ///
79    /// Skips do not fail conformance -- a provider is allowed to decline work.
80    /// Read [`Self::exercised`] to see how much was actually proven.
81    #[must_use]
82    pub fn is_conformant(&self) -> bool {
83        !self
84            .checks
85            .iter()
86            .any(|check| matches!(check.outcome, Outcome::Failed { .. }))
87    }
88
89    /// How many obligations the provider actually satisfied.
90    #[must_use]
91    pub fn exercised(&self) -> usize {
92        self.checks
93            .iter()
94            .filter(|check| check.outcome == Outcome::Passed)
95            .count()
96    }
97
98    /// How many obligations the provider declined.
99    #[must_use]
100    pub fn skipped(&self) -> usize {
101        self.checks
102            .iter()
103            .filter(|check| matches!(check.outcome, Outcome::Skipped { .. }))
104            .count()
105    }
106
107    fn record(&mut self, name: &'static str, outcome: Outcome) {
108        self.checks.push(Check { name, outcome });
109    }
110}
111
112impl fmt::Display for ConformanceReport {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        writeln!(
115            f,
116            "conformance: {} passed, {} skipped, {} failed",
117            self.exercised(),
118            self.skipped(),
119            self.checks.len() - self.exercised() - self.skipped()
120        )?;
121        for check in &self.checks {
122            match &check.outcome {
123                Outcome::Passed => writeln!(f, "  pass  {}", check.name)?,
124                Outcome::Skipped { reason } => {
125                    writeln!(f, "  skip  {} ({reason})", check.name)?;
126                }
127                Outcome::Failed { detail } => {
128                    writeln!(f, "  FAIL  {} -- {detail}", check.name)?;
129                }
130            }
131        }
132        Ok(())
133    }
134}
135
136/// Outward-oriented axis-aligned box.
137#[must_use]
138pub fn box_at(min: [f64; 3], max: [f64; 3]) -> TriMesh {
139    let [x0, y0, z0] = min;
140    let [x1, y1, z1] = max;
141    let positions = vec![
142        [x0, y0, z0].into(),
143        [x1, y0, z0].into(),
144        [x1, y1, z0].into(),
145        [x0, y1, z0].into(),
146        [x0, y0, z1].into(),
147        [x1, y0, z1].into(),
148        [x1, y1, z1].into(),
149        [x0, y1, z1].into(),
150    ];
151    let indices = vec![
152        0, 2, 1, 0, 3, 2, 4, 5, 6, 4, 6, 7, 0, 1, 5, 0, 5, 4, 1, 2, 6, 1, 6, 5, 2, 3, 7, 2, 7, 6,
153        3, 0, 4, 3, 4, 7,
154    ];
155    TriMesh::new(positions, indices)
156}
157
158/// Enclosed volume by the divergence theorem.
159#[must_use]
160pub fn volume(mesh: &TriMesh) -> f64 {
161    mesh.indices
162        .as_chunks::<3>()
163        .0
164        .iter()
165        .map(|t| {
166            let a = mesh.positions[t[0] as usize];
167            let b = mesh.positions[t[1] as usize];
168            let c = mesh.positions[t[2] as usize];
169            a.dot(b.cross(c))
170        })
171        .sum::<f64>()
172        / 6.0
173}
174
175fn options() -> ExecutionOptions {
176    ExecutionOptions::new(Tolerance::METRE)
177}
178
179/// Run an operation, mapping a refusal to a skip rather than a failure.
180fn attempt(
181    provider: &impl MeshBoolean,
182    subject: &TriMesh,
183    tool: &TriMesh,
184    operation: BooleanOperator,
185) -> Result<BooleanOutcome, Outcome> {
186    match provider.boolean(subject, tool, operation, &options()) {
187        Ok(outcome) => Ok(outcome),
188        Err(GeomError::Unsupported { .. }) => Err(Outcome::Skipped {
189            reason: format!("provider does not support {operation:?}"),
190        }),
191        Err(error) => Err(Outcome::Failed {
192            detail: format!("{operation:?} on admissible operands failed: {error}"),
193        }),
194    }
195}
196
197/// Run every conformance obligation against `provider`.
198///
199/// Registration should be gated on the resulting report being conformant.
200#[must_use]
201pub fn run(provider: &impl MeshBoolean) -> ConformanceReport {
202    let mut report = ConformanceReport::default();
203
204    check_disjoint_algebra(provider, &mut report);
205    check_identical_operands(provider, &mut report);
206    check_difference_is_ordered(provider, &mut report);
207    check_empty_result_is_a_value(provider, &mut report);
208    check_inadmissible_operands_rejected(provider, &mut report);
209    check_evidence_is_populated(provider, &mut report);
210    check_determinism(provider, &mut report);
211    check_determinism_is_not_overstated(provider, &mut report);
212    check_declared_granularity_is_honoured(provider, &mut report);
213
214    report
215}
216
217/// Disjoint operands: union adds, intersection empties, difference preserves.
218fn check_disjoint_algebra(provider: &impl MeshBoolean, report: &mut ConformanceReport) {
219    let a = box_at([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]);
220    let b = box_at([9.0, 9.0, 9.0], [10.0, 10.0, 10.0]);
221
222    let outcome = match attempt(provider, &a, &b, BooleanOperator::Union) {
223        Ok(outcome) => outcome,
224        Err(skip_or_fail) => return report.record("disjoint_union_sums_volume", skip_or_fail),
225    };
226    let measured = volume(&outcome.mesh);
227    report.record(
228        "disjoint_union_sums_volume",
229        if (measured - 2.0).abs() < 1e-9 {
230            Outcome::Passed
231        } else {
232            Outcome::Failed {
233                detail: format!("expected volume 2.0 for two disjoint unit cubes, got {measured}"),
234            }
235        },
236    );
237
238    match attempt(provider, &a, &b, BooleanOperator::Difference) {
239        Ok(outcome) => {
240            let measured = volume(&outcome.mesh);
241            report.record(
242                "disjoint_difference_preserves_subject",
243                if (measured - 1.0).abs() < 1e-9 {
244                    Outcome::Passed
245                } else {
246                    Outcome::Failed {
247                        detail: format!("A \\ B must equal A when disjoint, got volume {measured}"),
248                    }
249                },
250            );
251        }
252        Err(skip_or_fail) => {
253            report.record("disjoint_difference_preserves_subject", skip_or_fail);
254        }
255    }
256}
257
258/// `A ∪ A = A` and `A \ A = ∅`.
259fn check_identical_operands(provider: &impl MeshBoolean, report: &mut ConformanceReport) {
260    let a = box_at([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]);
261
262    match attempt(provider, &a, &a, BooleanOperator::Union) {
263        Ok(outcome) => {
264            let measured = volume(&outcome.mesh);
265            report.record(
266                "self_union_is_idempotent",
267                if (measured - 1.0).abs() < 1e-9 {
268                    Outcome::Passed
269                } else {
270                    Outcome::Failed {
271                        detail: format!("A union A must equal A, got volume {measured}"),
272                    }
273                },
274            );
275        }
276        Err(skip_or_fail) => report.record("self_union_is_idempotent", skip_or_fail),
277    }
278
279    match attempt(provider, &a, &a, BooleanOperator::Difference) {
280        Ok(outcome) => {
281            let measured = volume(&outcome.mesh).abs();
282            report.record(
283                "self_difference_annihilates",
284                if measured < 1e-9 {
285                    Outcome::Passed
286                } else {
287                    Outcome::Failed {
288                        detail: format!("A minus A must be empty, got volume {measured}"),
289                    }
290                },
291            );
292        }
293        Err(skip_or_fail) => report.record("self_difference_annihilates", skip_or_fail),
294    }
295}
296
297/// Difference is the only non-commutative operand, and must behave that way.
298fn check_difference_is_ordered(provider: &impl MeshBoolean, report: &mut ConformanceReport) {
299    let outer = box_at([0.0, 0.0, 0.0], [4.0, 4.0, 4.0]);
300    let inner = box_at([1.0, 1.0, 1.0], [2.0, 2.0, 2.0]);
301
302    let forward = attempt(provider, &outer, &inner, BooleanOperator::Difference);
303    let reverse = attempt(provider, &inner, &outer, BooleanOperator::Difference);
304    match (forward, reverse) {
305        (Ok(forward), Ok(reverse)) => {
306            let (big, small) = (volume(&forward.mesh), volume(&reverse.mesh).abs());
307            report.record(
308                "difference_respects_operand_order",
309                if (big - 63.0).abs() < 1e-9 && small < 1e-9 {
310                    Outcome::Passed
311                } else {
312                    Outcome::Failed {
313                        detail: format!(
314                            "outer minus inner should be 63.0 and inner minus outer empty, \
315                             got {big} and {small}"
316                        ),
317                    }
318                },
319            );
320        }
321        (Err(skip_or_fail), _) | (_, Err(skip_or_fail)) => {
322            report.record("difference_respects_operand_order", skip_or_fail);
323        }
324    }
325}
326
327/// An empty result is a value, never an error.
328fn check_empty_result_is_a_value(provider: &impl MeshBoolean, report: &mut ConformanceReport) {
329    let a = box_at([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]);
330    let b = box_at([9.0, 9.0, 9.0], [10.0, 10.0, 10.0]);
331
332    match attempt(provider, &a, &b, BooleanOperator::Intersection) {
333        Ok(outcome) => report.record(
334            "empty_intersection_is_a_value_not_an_error",
335            if outcome.mesh.triangle_count() == 0 {
336                Outcome::Passed
337            } else {
338                Outcome::Failed {
339                    detail: format!(
340                        "disjoint intersection must be empty, got {} triangles",
341                        outcome.mesh.triangle_count()
342                    ),
343                }
344            },
345        ),
346        Err(skip_or_fail) => {
347            report.record("empty_intersection_is_a_value_not_an_error", skip_or_fail);
348        }
349    }
350}
351
352/// Inadmissible operands must be refused, not silently processed.
353fn check_inadmissible_operands_rejected(
354    provider: &impl MeshBoolean,
355    report: &mut ConformanceReport,
356) {
357    let good = box_at([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]);
358    let empty = TriMesh::new(Vec::new(), Vec::new());
359
360    let result = provider.boolean(&good, &empty, BooleanOperator::Union, &options());
361    report.record(
362        "inadmissible_operand_is_refused",
363        match result {
364            Err(GeomError::InvalidInput(_) | GeomError::Degenerate(_)) => Outcome::Passed,
365            Err(GeomError::Unsupported { .. }) => Outcome::Skipped {
366                reason: "provider declined before validating".into(),
367            },
368            Err(other) => Outcome::Failed {
369                detail: format!("expected InvalidInput or Degenerate, got {other:?}"),
370            },
371            Ok(_) => Outcome::Failed {
372                detail: "an empty mesh is not a solid and must be refused".into(),
373            },
374        },
375    );
376}
377
378/// Evidence must describe the actual work, not be left at defaults.
379fn check_evidence_is_populated(provider: &impl MeshBoolean, report: &mut ConformanceReport) {
380    let a = box_at([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]);
381    let b = box_at([9.0, 9.0, 9.0], [10.0, 10.0, 10.0]);
382
383    match attempt(provider, &a, &b, BooleanOperator::Union) {
384        Ok(outcome) => {
385            let evidence = &outcome.evidence;
386            let mut problems = Vec::new();
387            if evidence.subject_triangles != a.triangle_count() {
388                problems.push(format!(
389                    "subject_triangles {} != {}",
390                    evidence.subject_triangles,
391                    a.triangle_count()
392                ));
393            }
394            if evidence.output_triangles != outcome.mesh.triangle_count() {
395                problems.push(format!(
396                    "output_triangles {} != actual {}",
397                    evidence.output_triangles,
398                    outcome.mesh.triangle_count()
399                ));
400            }
401            if evidence.sub_operations == 0 {
402                problems.push("sub_operations must be at least 1".into());
403            }
404            report.record(
405                "evidence_describes_the_work",
406                if problems.is_empty() {
407                    Outcome::Passed
408                } else {
409                    Outcome::Failed {
410                        detail: problems.join("; "),
411                    }
412                },
413            );
414        }
415        Err(skip_or_fail) => report.record("evidence_describes_the_work", skip_or_fail),
416    }
417}
418
419/// The same inputs must produce the same output, every time.
420fn check_determinism(provider: &impl MeshBoolean, report: &mut ConformanceReport) {
421    let a = box_at([0.0, 0.0, 0.0], [4.0, 4.0, 4.0]);
422    let b = box_at([1.0, 1.0, 1.0], [2.0, 2.0, 2.0]);
423
424    let first = attempt(provider, &a, &b, BooleanOperator::Difference);
425    let second = attempt(provider, &a, &b, BooleanOperator::Difference);
426    match (first, second) {
427        (Ok(first), Ok(second)) => report.record(
428            "repeated_calls_are_deterministic",
429            if first.mesh.positions == second.mesh.positions
430                && first.mesh.indices == second.mesh.indices
431            {
432                Outcome::Passed
433            } else {
434                Outcome::Failed {
435                    detail: "identical inputs produced different geometry".into(),
436                }
437            },
438        ),
439        (Err(skip_or_fail), _) | (_, Err(skip_or_fail)) => {
440            report.record("repeated_calls_are_deterministic", skip_or_fail);
441        }
442    }
443}
444
445/// A provider must not declare a reproducibility level it cannot honour.
446///
447/// `check_determinism` above compares two calls in ONE process, so it cannot
448/// see the failure mode that matters here: `std`'s `RandomState` seeds each
449/// `HashMap` per process, so a provider deduping through one is stable within
450/// a run and unstable across runs. Two identical calls agree; a rebuild
451/// disagrees.
452///
453/// So the declaration is checked structurally instead. `Determinism::Bitwise`
454/// is the level that promises cross-process byte equality, and it is exactly
455/// the level a same-process test cannot verify. A provider claiming it must
456/// have earned it by construction — ordered vertex identity, no hash
457/// iteration, no thread-order-dependent accumulation — and the conformance
458/// suite has no way to confirm that from the outside.
459///
460/// Refusing the claim here is the fail-closed choice: an overstated level
461/// makes `Plan::admit` accept a step it should have refused, which is worse
462/// than an understated one that merely refuses work the provider could have
463/// done.
464fn check_determinism_is_not_overstated(
465    provider: &impl MeshBoolean,
466    report: &mut ConformanceReport,
467) {
468    let declared = provider.determinism();
469    report.record(
470        "determinism_declaration_is_verifiable",
471        if declared == Determinism::Bitwise {
472            Outcome::Failed {
473                detail: "provider declares Determinism::Bitwise, which this suite cannot \
474                         verify: cross-process byte equality is not observable from a \
475                         single-process check. Declare the strongest level the provider \
476                         can prove by construction instead"
477                    .into(),
478            }
479        } else {
480            Outcome::Passed
481        },
482    );
483}
484
485/// A pre-cancelled token must stop the operation, if the provider polls at all.
486fn check_declared_granularity_is_honoured(
487    provider: &impl MeshBoolean,
488    report: &mut ConformanceReport,
489) {
490    use axiolid_contracts::{CancellationGranularity, CancellationToken};
491
492    let granularity = provider.cancellation_granularity();
493    if granularity == CancellationGranularity::None {
494        report.record(
495            "declared_cancellation_is_honoured",
496            Outcome::Skipped {
497                reason: "provider declares it does not poll".into(),
498            },
499        );
500        return;
501    }
502
503    let a = box_at([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]);
504    let b = box_at([9.0, 9.0, 9.0], [10.0, 10.0, 10.0]);
505    let token = CancellationToken::new();
506    token.cancel();
507    let cancelled = options().with_cancellation(token);
508
509    let result = provider.boolean(&a, &b, BooleanOperator::Union, &cancelled);
510    report.record(
511        "declared_cancellation_is_honoured",
512        match result {
513            Err(GeomError::Cancelled) => Outcome::Passed,
514            Err(GeomError::Unsupported { .. }) => Outcome::Skipped {
515                reason: "provider declined the operation".into(),
516            },
517            Ok(_) => Outcome::Failed {
518                detail: format!(
519                    "provider declares {granularity:?} polling but ran to completion \
520                     with a pre-cancelled token"
521                ),
522            },
523            Err(other) => Outcome::Failed {
524                detail: format!("expected Cancelled, got {other:?}"),
525            },
526        },
527    );
528}