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        .chunks_exact(3)
163        .map(|t| {
164            let a = mesh.positions[t[0] as usize];
165            let b = mesh.positions[t[1] as usize];
166            let c = mesh.positions[t[2] as usize];
167            a.dot(b.cross(c))
168        })
169        .sum::<f64>()
170        / 6.0
171}
172
173fn options() -> ExecutionOptions {
174    ExecutionOptions::new(Tolerance::METRE)
175}
176
177/// Run an operation, mapping a refusal to a skip rather than a failure.
178fn attempt(
179    provider: &impl MeshBoolean,
180    subject: &TriMesh,
181    tool: &TriMesh,
182    operation: BooleanOperator,
183) -> Result<BooleanOutcome, Outcome> {
184    match provider.boolean(subject, tool, operation, &options()) {
185        Ok(outcome) => Ok(outcome),
186        Err(GeomError::Unsupported { .. }) => Err(Outcome::Skipped {
187            reason: format!("provider does not support {operation:?}"),
188        }),
189        Err(error) => Err(Outcome::Failed {
190            detail: format!("{operation:?} on admissible operands failed: {error}"),
191        }),
192    }
193}
194
195/// Run every conformance obligation against `provider`.
196///
197/// Registration should be gated on the resulting report being conformant.
198#[must_use]
199pub fn run(provider: &impl MeshBoolean) -> ConformanceReport {
200    let mut report = ConformanceReport::default();
201
202    check_disjoint_algebra(provider, &mut report);
203    check_identical_operands(provider, &mut report);
204    check_difference_is_ordered(provider, &mut report);
205    check_empty_result_is_a_value(provider, &mut report);
206    check_inadmissible_operands_rejected(provider, &mut report);
207    check_evidence_is_populated(provider, &mut report);
208    check_determinism(provider, &mut report);
209    check_determinism_is_not_overstated(provider, &mut report);
210    check_declared_granularity_is_honoured(provider, &mut report);
211
212    report
213}
214
215/// Disjoint operands: union adds, intersection empties, difference preserves.
216fn check_disjoint_algebra(provider: &impl MeshBoolean, report: &mut ConformanceReport) {
217    let a = box_at([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]);
218    let b = box_at([9.0, 9.0, 9.0], [10.0, 10.0, 10.0]);
219
220    let outcome = match attempt(provider, &a, &b, BooleanOperator::Union) {
221        Ok(outcome) => outcome,
222        Err(skip_or_fail) => return report.record("disjoint_union_sums_volume", skip_or_fail),
223    };
224    let measured = volume(&outcome.mesh);
225    report.record(
226        "disjoint_union_sums_volume",
227        if (measured - 2.0).abs() < 1e-9 {
228            Outcome::Passed
229        } else {
230            Outcome::Failed {
231                detail: format!("expected volume 2.0 for two disjoint unit cubes, got {measured}"),
232            }
233        },
234    );
235
236    match attempt(provider, &a, &b, BooleanOperator::Difference) {
237        Ok(outcome) => {
238            let measured = volume(&outcome.mesh);
239            report.record(
240                "disjoint_difference_preserves_subject",
241                if (measured - 1.0).abs() < 1e-9 {
242                    Outcome::Passed
243                } else {
244                    Outcome::Failed {
245                        detail: format!("A \\ B must equal A when disjoint, got volume {measured}"),
246                    }
247                },
248            );
249        }
250        Err(skip_or_fail) => {
251            report.record("disjoint_difference_preserves_subject", skip_or_fail);
252        }
253    }
254}
255
256/// `A ∪ A = A` and `A \ A = ∅`.
257fn check_identical_operands(provider: &impl MeshBoolean, report: &mut ConformanceReport) {
258    let a = box_at([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]);
259
260    match attempt(provider, &a, &a, BooleanOperator::Union) {
261        Ok(outcome) => {
262            let measured = volume(&outcome.mesh);
263            report.record(
264                "self_union_is_idempotent",
265                if (measured - 1.0).abs() < 1e-9 {
266                    Outcome::Passed
267                } else {
268                    Outcome::Failed {
269                        detail: format!("A union A must equal A, got volume {measured}"),
270                    }
271                },
272            );
273        }
274        Err(skip_or_fail) => report.record("self_union_is_idempotent", skip_or_fail),
275    }
276
277    match attempt(provider, &a, &a, BooleanOperator::Difference) {
278        Ok(outcome) => {
279            let measured = volume(&outcome.mesh).abs();
280            report.record(
281                "self_difference_annihilates",
282                if measured < 1e-9 {
283                    Outcome::Passed
284                } else {
285                    Outcome::Failed {
286                        detail: format!("A minus A must be empty, got volume {measured}"),
287                    }
288                },
289            );
290        }
291        Err(skip_or_fail) => report.record("self_difference_annihilates", skip_or_fail),
292    }
293}
294
295/// Difference is the only non-commutative operand, and must behave that way.
296fn check_difference_is_ordered(provider: &impl MeshBoolean, report: &mut ConformanceReport) {
297    let outer = box_at([0.0, 0.0, 0.0], [4.0, 4.0, 4.0]);
298    let inner = box_at([1.0, 1.0, 1.0], [2.0, 2.0, 2.0]);
299
300    let forward = attempt(provider, &outer, &inner, BooleanOperator::Difference);
301    let reverse = attempt(provider, &inner, &outer, BooleanOperator::Difference);
302    match (forward, reverse) {
303        (Ok(forward), Ok(reverse)) => {
304            let (big, small) = (volume(&forward.mesh), volume(&reverse.mesh).abs());
305            report.record(
306                "difference_respects_operand_order",
307                if (big - 63.0).abs() < 1e-9 && small < 1e-9 {
308                    Outcome::Passed
309                } else {
310                    Outcome::Failed {
311                        detail: format!(
312                            "outer minus inner should be 63.0 and inner minus outer empty, \
313                             got {big} and {small}"
314                        ),
315                    }
316                },
317            );
318        }
319        (Err(skip_or_fail), _) | (_, Err(skip_or_fail)) => {
320            report.record("difference_respects_operand_order", skip_or_fail);
321        }
322    }
323}
324
325/// An empty result is a value, never an error.
326fn check_empty_result_is_a_value(provider: &impl MeshBoolean, report: &mut ConformanceReport) {
327    let a = box_at([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]);
328    let b = box_at([9.0, 9.0, 9.0], [10.0, 10.0, 10.0]);
329
330    match attempt(provider, &a, &b, BooleanOperator::Intersection) {
331        Ok(outcome) => report.record(
332            "empty_intersection_is_a_value_not_an_error",
333            if outcome.mesh.triangle_count() == 0 {
334                Outcome::Passed
335            } else {
336                Outcome::Failed {
337                    detail: format!(
338                        "disjoint intersection must be empty, got {} triangles",
339                        outcome.mesh.triangle_count()
340                    ),
341                }
342            },
343        ),
344        Err(skip_or_fail) => {
345            report.record("empty_intersection_is_a_value_not_an_error", skip_or_fail);
346        }
347    }
348}
349
350/// Inadmissible operands must be refused, not silently processed.
351fn check_inadmissible_operands_rejected(
352    provider: &impl MeshBoolean,
353    report: &mut ConformanceReport,
354) {
355    let good = box_at([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]);
356    let empty = TriMesh::new(Vec::new(), Vec::new());
357
358    let result = provider.boolean(&good, &empty, BooleanOperator::Union, &options());
359    report.record(
360        "inadmissible_operand_is_refused",
361        match result {
362            Err(GeomError::InvalidInput(_) | GeomError::Degenerate(_)) => Outcome::Passed,
363            Err(GeomError::Unsupported { .. }) => Outcome::Skipped {
364                reason: "provider declined before validating".into(),
365            },
366            Err(other) => Outcome::Failed {
367                detail: format!("expected InvalidInput or Degenerate, got {other:?}"),
368            },
369            Ok(_) => Outcome::Failed {
370                detail: "an empty mesh is not a solid and must be refused".into(),
371            },
372        },
373    );
374}
375
376/// Evidence must describe the actual work, not be left at defaults.
377fn check_evidence_is_populated(provider: &impl MeshBoolean, report: &mut ConformanceReport) {
378    let a = box_at([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]);
379    let b = box_at([9.0, 9.0, 9.0], [10.0, 10.0, 10.0]);
380
381    match attempt(provider, &a, &b, BooleanOperator::Union) {
382        Ok(outcome) => {
383            let evidence = &outcome.evidence;
384            let mut problems = Vec::new();
385            if evidence.subject_triangles != a.triangle_count() {
386                problems.push(format!(
387                    "subject_triangles {} != {}",
388                    evidence.subject_triangles,
389                    a.triangle_count()
390                ));
391            }
392            if evidence.output_triangles != outcome.mesh.triangle_count() {
393                problems.push(format!(
394                    "output_triangles {} != actual {}",
395                    evidence.output_triangles,
396                    outcome.mesh.triangle_count()
397                ));
398            }
399            if evidence.sub_operations == 0 {
400                problems.push("sub_operations must be at least 1".into());
401            }
402            report.record(
403                "evidence_describes_the_work",
404                if problems.is_empty() {
405                    Outcome::Passed
406                } else {
407                    Outcome::Failed {
408                        detail: problems.join("; "),
409                    }
410                },
411            );
412        }
413        Err(skip_or_fail) => report.record("evidence_describes_the_work", skip_or_fail),
414    }
415}
416
417/// The same inputs must produce the same output, every time.
418fn check_determinism(provider: &impl MeshBoolean, report: &mut ConformanceReport) {
419    let a = box_at([0.0, 0.0, 0.0], [4.0, 4.0, 4.0]);
420    let b = box_at([1.0, 1.0, 1.0], [2.0, 2.0, 2.0]);
421
422    let first = attempt(provider, &a, &b, BooleanOperator::Difference);
423    let second = attempt(provider, &a, &b, BooleanOperator::Difference);
424    match (first, second) {
425        (Ok(first), Ok(second)) => report.record(
426            "repeated_calls_are_deterministic",
427            if first.mesh.positions == second.mesh.positions
428                && first.mesh.indices == second.mesh.indices
429            {
430                Outcome::Passed
431            } else {
432                Outcome::Failed {
433                    detail: "identical inputs produced different geometry".into(),
434                }
435            },
436        ),
437        (Err(skip_or_fail), _) | (_, Err(skip_or_fail)) => {
438            report.record("repeated_calls_are_deterministic", skip_or_fail);
439        }
440    }
441}
442
443/// A provider must not declare a reproducibility level it cannot honour.
444///
445/// `check_determinism` above compares two calls in ONE process, so it cannot
446/// see the failure mode that matters here: `std`'s `RandomState` seeds each
447/// `HashMap` per process, so a provider deduping through one is stable within
448/// a run and unstable across runs. Two identical calls agree; a rebuild
449/// disagrees.
450///
451/// So the declaration is checked structurally instead. `Determinism::Bitwise`
452/// is the level that promises cross-process byte equality, and it is exactly
453/// the level a same-process test cannot verify. A provider claiming it must
454/// have earned it by construction — ordered vertex identity, no hash
455/// iteration, no thread-order-dependent accumulation — and the conformance
456/// suite has no way to confirm that from the outside.
457///
458/// Refusing the claim here is the fail-closed choice: an overstated level
459/// makes `Plan::admit` accept a step it should have refused, which is worse
460/// than an understated one that merely refuses work the provider could have
461/// done.
462fn check_determinism_is_not_overstated(
463    provider: &impl MeshBoolean,
464    report: &mut ConformanceReport,
465) {
466    let declared = provider.determinism();
467    report.record(
468        "determinism_declaration_is_verifiable",
469        if declared == Determinism::Bitwise {
470            Outcome::Failed {
471                detail: "provider declares Determinism::Bitwise, which this suite cannot \
472                         verify: cross-process byte equality is not observable from a \
473                         single-process check. Declare the strongest level the provider \
474                         can prove by construction instead"
475                    .into(),
476            }
477        } else {
478            Outcome::Passed
479        },
480    );
481}
482
483/// A pre-cancelled token must stop the operation, if the provider polls at all.
484fn check_declared_granularity_is_honoured(
485    provider: &impl MeshBoolean,
486    report: &mut ConformanceReport,
487) {
488    use axiolid_contracts::{CancellationGranularity, CancellationToken};
489
490    let granularity = provider.cancellation_granularity();
491    if granularity == CancellationGranularity::None {
492        report.record(
493            "declared_cancellation_is_honoured",
494            Outcome::Skipped {
495                reason: "provider declares it does not poll".into(),
496            },
497        );
498        return;
499    }
500
501    let a = box_at([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]);
502    let b = box_at([9.0, 9.0, 9.0], [10.0, 10.0, 10.0]);
503    let token = CancellationToken::new();
504    token.cancel();
505    let cancelled = options().with_cancellation(token);
506
507    let result = provider.boolean(&a, &b, BooleanOperator::Union, &cancelled);
508    report.record(
509        "declared_cancellation_is_honoured",
510        match result {
511            Err(GeomError::Cancelled) => Outcome::Passed,
512            Err(GeomError::Unsupported { .. }) => Outcome::Skipped {
513                reason: "provider declined the operation".into(),
514            },
515            Ok(_) => Outcome::Failed {
516                detail: format!(
517                    "provider declares {granularity:?} polling but ran to completion \
518                     with a pre-cancelled token"
519                ),
520            },
521            Err(other) => Outcome::Failed {
522                detail: format!("expected Cancelled, got {other:?}"),
523            },
524        },
525    );
526}