Skip to main content

brep_kernel/csg/
oracle.rs

1//! # Semantic boolean oracle — an INDEPENDENT wrongness detector
2//!
3//! Repair-not-refuse proved the real boolean failures are STRUCTURAL: a result
4//! that passes [`BrepSolid::validate`] (well-formed topology) yet is
5//! geometrically WRONG — a missing face, a mis-selected fragment, an imprint
6//! seam the assembler never welded.  `validate()` cannot see that; a
7//! point-membership cross-check can.
8//!
9//! Two detectors live here, both purely diagnostic (never a hard gate in the
10//! boolean hot path — see the `BREP_DEBUG_BOOL` hook in `boolean.rs`):
11//!
12//! * **Part 1 — semantic cross-check** ([`boolean_semantic_disagreement`]).
13//!   After `A op B -> R`, sample points deterministically across the operands'
14//!   combined box, classify each vs A, B, R with [`SolidClassifier`], and
15//!   compare the CSG-expected membership (`UNION = in_a||in_b`,
16//!   `INTERSECT = in_a&&in_b`, `SUBTRACT = in_a&&!in_b`) against R's own
17//!   verdict.  Points on or near any boundary are SKIPPED (ambiguous), so a
18//!   valid boolean scores ~0 disagreements and a structurally-wrong one scores
19//!   a whole region's worth.
20//!
21//! * **Part 2 — fuse-after** ([`boolean_residual_fusables`]).  Geometry in R
22//!   that is coincident within a weld band yet NOT topologically shared
23//!   (distinct vertices at one point, duplicate coincident edges) — an
24//!   intersector/assembler bug, because the weld should have fused it.
25//!
26//! Reliability is the whole point: a noisy oracle is worse than none.  The
27//! On-skip band is size-coupled and deliberately conservative (a strict
28//! superset of the classifier's `On` verdict via
29//! [`SolidClassifier::within_band`]), and the flag threshold
30//! ([`DISAGREEMENT_THRESHOLD`]) is tuned so every known-good fixture scores
31//! zero.
32
33use crate::classification::{PointClass, SolidClassifier};
34use crate::spatial::Aabb;
35use crate::topology::BrepSolid;
36use crate::{BooleanOperation, KernelTolerances, Vec3};
37use serde::Serialize;
38
39/// A boolean is FLAGGED structurally wrong when its decidable-point
40/// disagreement rate exceeds this.  Tuned from the measured spread (fixed-seed,
41/// so these numbers are stable): every KNOWN-GOOD boolean scores ≤ 0.7% (a
42/// handful of stray points on the fused seam of tangent/glue unions), benign
43/// doubly-curved cases (sphere/torus tubes) top out near 1.7%, while a genuine
44/// structural defect — a mis-selected fragment, a lost overlap region, an
45/// opened shell — flips a whole region and scores ≥ 7%.  The 3% line sits
46/// cleanly in the gap: above the near-boundary noise the On-skip + threshold are
47/// meant to absorb, well below any real wrongness.  One named constant so the
48/// audit, the tests, and the stress binary share a single definition of "wrong".
49pub const DISAGREEMENT_THRESHOLD: f64 = 0.03;
50
51/// Fraction of the combined-box diagonal used as the conservative On-skip band.
52/// Wide enough to swallow the few-micron near-coincidence gaps noisy operands
53/// carry (glue/tangent fixtures) yet a negligible slice of the sampling volume.
54const SKIP_FRACTION: f64 = 1e-4;
55
56/// Fraction of the combined-box diagonal a boundary-focused sample is pushed off
57/// its seed face along the surface normal.  Comfortably larger than
58/// `SKIP_FRACTION` so the jittered point clears the On-skip band and reads a
59/// clean In/Out, yet small enough to sit right against the boolean boundary
60/// where a missing face is most discriminating.
61const JITTER_FRACTION: f64 = 4e-3;
62
63/// Share of the sample budget spent on bulk-volume points; the remainder is
64/// spent near A/B/R faces (the discriminating region for a missing face).
65const BULK_NUMERATOR: usize = 45;
66const BULK_DENOMINATOR: usize = 100;
67
68/// Deterministic `splitmix64` PRNG.  Reproducible, allocation-free, good enough
69/// spread for Monte-Carlo point membership — we deliberately avoid `rand` /
70/// wall-clock / `Math.random` so the audit is bit-stable across runs.
71struct Rng {
72    state: u64,
73}
74
75impl Rng {
76    fn new(seed: u64) -> Self {
77        Self { state: seed }
78    }
79
80    fn next_u64(&mut self) -> u64 {
81        self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
82        let mut z = self.state;
83        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
84        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
85        z ^ (z >> 31)
86    }
87
88    /// Uniform in `[0, 1)`.
89    fn unit(&mut self) -> f64 {
90        (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
91    }
92
93    /// Uniform in `[low, high)`.
94    fn range(&mut self, low: f64, high: f64) -> f64 {
95        low + (high - low) * self.unit()
96    }
97
98    /// A uniformly distributed unit vector on the sphere.
99    fn unit_vector(&mut self) -> Vec3 {
100        let z = self.range(-1.0, 1.0);
101        let angle = self.range(0.0, std::f64::consts::TAU);
102        let radius = (1.0 - z * z).max(0.0).sqrt();
103        Vec3::new(radius * angle.cos(), radius * angle.sin(), z)
104    }
105}
106
107/// One point where the result's membership contradicts the CSG expectation.
108#[derive(Clone, Copy, Debug, Serialize)]
109pub struct SemanticDisagreement {
110    pub point: Vec3,
111    /// CSG-expected membership of `point` in the result.
112    pub expected_in: bool,
113    /// The result solid's own In/Out verdict at `point`.
114    pub result_in: bool,
115    pub in_a: bool,
116    pub in_b: bool,
117}
118
119/// Outcome of [`boolean_semantic_disagreement`].
120#[derive(Clone, Debug, Serialize)]
121pub struct OracleReport {
122    /// Total points drawn.
123    pub sampled: usize,
124    /// Points skipped as On/near a boundary of A, B or R (ambiguous), or
125    /// because a classifier could not decide them.
126    pub on_skipped: usize,
127    /// Decidable points actually compared (`sampled - on_skipped`).
128    pub considered: usize,
129    /// Every decidable point whose result membership contradicts CSG.
130    pub disagreements: Vec<SemanticDisagreement>,
131    /// `disagreements.len() / considered` (0 when nothing was decidable).
132    pub disagreement_rate: f64,
133}
134
135impl OracleReport {
136    /// Whether the disagreement rate crosses [`DISAGREEMENT_THRESHOLD`].
137    pub fn is_flagged(&self) -> bool {
138        self.disagreement_rate > DISAGREEMENT_THRESHOLD
139    }
140
141    /// A representative disagreeing point, if any.
142    pub fn sample_disagreement(&self) -> Option<SemanticDisagreement> {
143        self.disagreements.first().copied()
144    }
145}
146
147fn faces_of(solid: &BrepSolid) -> impl Iterator<Item = &crate::topology::FaceRecord> {
148    solid.shells.iter().flat_map(|shell| shell.faces.iter())
149}
150
151fn combined_bounds(first: &BrepSolid, second: &BrepSolid) -> Result<Aabb, String> {
152    let mut bounds = Aabb::empty();
153    for face in faces_of(first).chain(faces_of(second)) {
154        bounds.include(Aabb::from_surface_controls(&face.surface)?);
155    }
156    Ok(bounds)
157}
158
159/// Expected CSG membership of a point given its In-ness in each operand.
160fn expected_membership(operation: BooleanOperation, in_a: bool, in_b: bool) -> bool {
161    match operation {
162        BooleanOperation::Union => in_a || in_b,
163        BooleanOperation::Intersect => in_a && in_b,
164        BooleanOperation::Subtract => in_a && !in_b,
165    }
166}
167
168/// Part 1 — the semantic cross-check.  Sample points across the combined box of
169/// `first ∪ second`, classify each versus the two operands and the result, and
170/// flag every decidable point where the result membership disagrees with the
171/// CSG expectation for `operation`.  See the module docs for the sampling /
172/// On-skip / threshold design.
173pub fn boolean_semantic_disagreement(
174    first: &BrepSolid,
175    second: &BrepSolid,
176    operation: BooleanOperation,
177    result: &BrepSolid,
178    samples: usize,
179) -> Result<OracleReport, String> {
180    let policy = KernelTolerances::for_pair(first, second, 1e-7);
181    let model = policy.model;
182
183    let bounds = combined_bounds(first, second)?;
184    let diagonal = bounds.diagonal();
185    if !diagonal.is_finite() || diagonal <= 0.0 || samples == 0 {
186        return Ok(OracleReport {
187            sampled: 0,
188            on_skipped: 0,
189            considered: 0,
190            disagreements: Vec::new(),
191            disagreement_rate: 0.0,
192        });
193    }
194
195    let classifier_a = SolidClassifier::new(first, model)?;
196    let classifier_b = SolidClassifier::new(second, model)?;
197    let classifier_r = SolidClassifier::new(result, model)?;
198
199    let skip_band = (model * 10.0).max(diagonal * SKIP_FRACTION);
200    let jitter = diagonal * JITTER_FRACTION;
201
202    // Flat face list for boundary-focused sampling — union of A, B and R faces
203    // so every boolean boundary (operand carriers AND the result's own seams)
204    // gets probed from both sides.
205    let boundary_faces: Vec<&crate::topology::FaceRecord> = faces_of(first)
206        .chain(faces_of(second))
207        .chain(faces_of(result))
208        .collect();
209
210    let bulk_count = samples * BULK_NUMERATOR / BULK_DENOMINATOR;
211
212    let mut rng = Rng::new(0x0B00_00AC_1E00_5EED);
213    let mut report = OracleReport {
214        sampled: 0,
215        on_skipped: 0,
216        considered: 0,
217        disagreements: Vec::new(),
218        disagreement_rate: 0.0,
219    };
220
221    for index in 0..samples {
222        let point = if index < bulk_count || boundary_faces.is_empty() {
223            Vec3::new(
224                rng.range(bounds.minimum.x, bounds.maximum.x),
225                rng.range(bounds.minimum.y, bounds.maximum.y),
226                rng.range(bounds.minimum.z, bounds.maximum.z),
227            )
228        } else {
229            boundary_sample(&mut rng, &boundary_faces, jitter)
230        };
231        report.sampled += 1;
232
233        // On-skip: drop any point on or near a boundary of A, B or R. The
234        // carrier-proximity probe is a strict superset of the classifier's On
235        // verdict, so a kept point is guaranteed a clean In/Out below.
236        let near = classifier_a.within_band(point, skip_band)?
237            || classifier_b.within_band(point, skip_band)?
238            || classifier_r.within_band(point, skip_band)?;
239        if near {
240            report.on_skipped += 1;
241            continue;
242        }
243
244        let (Ok(ca), Ok(cb), Ok(cr)) = (
245            classifier_a.classify(point),
246            classifier_b.classify(point),
247            classifier_r.classify(point),
248        ) else {
249            // A point the ray caster could not resolve cleanly — treat as
250            // ambiguous rather than let it fabricate a disagreement.
251            report.on_skipped += 1;
252            continue;
253        };
254        // Every kept point cleared the On-skip band, so On should not occur;
255        // if it somehow does, skip it (never fabricate a disagreement).
256        if ca.class == PointClass::On || cb.class == PointClass::On || cr.class == PointClass::On {
257            report.on_skipped += 1;
258            continue;
259        }
260
261        let in_a = ca.class == PointClass::In;
262        let in_b = cb.class == PointClass::In;
263        let result_in = cr.class == PointClass::In;
264        let expected_in = expected_membership(operation, in_a, in_b);
265        report.considered += 1;
266        if expected_in != result_in {
267            report.disagreements.push(SemanticDisagreement {
268                point,
269                expected_in,
270                result_in,
271                in_a,
272                in_b,
273            });
274        }
275    }
276
277    report.disagreement_rate = if report.considered == 0 {
278        0.0
279    } else {
280        report.disagreements.len() as f64 / report.considered as f64
281    };
282    Ok(report)
283}
284
285/// Expected CSG membership of a point given its In-ness in each of the N
286/// operands (`in_operands[k]` = point is In operand k).  Mirrors
287/// [`expected_membership`] generalized to N solids:
288/// `Union` = In ANY, `Intersect` = In ALL, `Subtract` = In operand 0 AND Out of
289/// every other.
290fn expected_membership_nary(operation: BooleanOperation, in_operands: &[bool]) -> bool {
291    match operation {
292        BooleanOperation::Union => in_operands.iter().any(|&inside| inside),
293        BooleanOperation::Intersect => in_operands.iter().all(|&inside| inside),
294        BooleanOperation::Subtract => {
295            in_operands[0] && in_operands[1..].iter().all(|&inside| !inside)
296        }
297    }
298}
299
300/// N-ary semantic cross-check — the correctness gate for
301/// [`crate::boolean_operation_nary`].  Sample points across the combined box of
302/// all `operands`, classify each versus every operand and the result, and flag
303/// every decidable point where the result membership disagrees with the n-ary
304/// CSG expectation (`Union` = In any, `Intersect` = In all, `Subtract` = In
305/// operand 0 and Out of the rest).  Points on or near ANY operand or result
306/// boundary are skipped, so a correct n-ary boolean scores ~0.
307pub fn boolean_semantic_disagreement_nary(
308    operands: &[BrepSolid],
309    operation: BooleanOperation,
310    result: &BrepSolid,
311    samples: usize,
312) -> Result<OracleReport, String> {
313    if operands.is_empty() {
314        return Err("boolean_semantic_disagreement_nary: no operands".into());
315    }
316    let model = KernelTolerances::for_solid(&operands[0], 1e-7).model;
317
318    let mut bounds = Aabb::empty();
319    for operand in operands {
320        for face in faces_of(operand) {
321            bounds.include(Aabb::from_surface_controls(&face.surface)?);
322        }
323    }
324    let diagonal = bounds.diagonal();
325    if !diagonal.is_finite() || diagonal <= 0.0 || samples == 0 {
326        return Ok(OracleReport {
327            sampled: 0,
328            on_skipped: 0,
329            considered: 0,
330            disagreements: Vec::new(),
331            disagreement_rate: 0.0,
332        });
333    }
334
335    let classifiers = operands
336        .iter()
337        .map(|operand| SolidClassifier::new(operand, model))
338        .collect::<Result<Vec<_>, _>>()?;
339    let classifier_r = SolidClassifier::new(result, model)?;
340
341    let skip_band = (model * 10.0).max(diagonal * SKIP_FRACTION);
342    let jitter = diagonal * JITTER_FRACTION;
343
344    let boundary_faces: Vec<&crate::topology::FaceRecord> = operands
345        .iter()
346        .flat_map(faces_of)
347        .chain(faces_of(result))
348        .collect();
349
350    let bulk_count = samples * BULK_NUMERATOR / BULK_DENOMINATOR;
351
352    let mut rng = Rng::new(0x0B00_00AC_1E00_5EED);
353    let mut report = OracleReport {
354        sampled: 0,
355        on_skipped: 0,
356        considered: 0,
357        disagreements: Vec::new(),
358        disagreement_rate: 0.0,
359    };
360
361    for index in 0..samples {
362        let point = if index < bulk_count || boundary_faces.is_empty() {
363            Vec3::new(
364                rng.range(bounds.minimum.x, bounds.maximum.x),
365                rng.range(bounds.minimum.y, bounds.maximum.y),
366                rng.range(bounds.minimum.z, bounds.maximum.z),
367            )
368        } else {
369            boundary_sample(&mut rng, &boundary_faces, jitter)
370        };
371        report.sampled += 1;
372
373        // On-skip: drop any point near any operand's OR the result's boundary.
374        let mut near = classifier_r.within_band(point, skip_band)?;
375        for classifier in &classifiers {
376            near = near || classifier.within_band(point, skip_band)?;
377        }
378        if near {
379            report.on_skipped += 1;
380            continue;
381        }
382
383        let Ok(cr) = classifier_r.classify(point) else {
384            report.on_skipped += 1;
385            continue;
386        };
387        if cr.class == PointClass::On {
388            report.on_skipped += 1;
389            continue;
390        }
391        let mut in_operands = Vec::with_capacity(classifiers.len());
392        let mut ambiguous = false;
393        for classifier in &classifiers {
394            match classifier.classify(point) {
395                Ok(classification) if classification.class != PointClass::On => {
396                    in_operands.push(classification.class == PointClass::In);
397                }
398                _ => {
399                    ambiguous = true;
400                    break;
401                }
402            }
403        }
404        if ambiguous {
405            report.on_skipped += 1;
406            continue;
407        }
408
409        let result_in = cr.class == PointClass::In;
410        let expected_in = expected_membership_nary(operation, &in_operands);
411        report.considered += 1;
412        if expected_in != result_in {
413            report.disagreements.push(SemanticDisagreement {
414                point,
415                expected_in,
416                result_in,
417                // in_a/in_b carry the first two operands' membership for a
418                // readable sample; the full vector is summarized by the counts.
419                in_a: in_operands[0],
420                in_b: *in_operands.get(1).unwrap_or(&false),
421            });
422        }
423    }
424
425    report.disagreement_rate = if report.considered == 0 {
426        0.0
427    } else {
428        report.disagreements.len() as f64 / report.considered as f64
429    };
430    Ok(report)
431}
432
433/// Draw a boundary-focused sample: pick a face, a random point on its carrier,
434/// and push it off the surface along the (randomly signed) normal by `jitter`.
435fn boundary_sample(rng: &mut Rng, faces: &[&crate::topology::FaceRecord], jitter: f64) -> Vec3 {
436    let face = faces[(rng.next_u64() as usize) % faces.len()];
437    let (Ok([u0, u1]), Ok([v0, v1])) = (face.surface.domain_u(), face.surface.domain_v()) else {
438        return Vec3::default();
439    };
440    let u = rng.range(u0, u1);
441    let v = rng.range(v0, v1);
442    let base = match face.surface.evaluate(u, v) {
443        Ok(point) => point,
444        Err(_) => return Vec3::default(),
445    };
446    let direction = match face.surface.normal(u, v) {
447        Ok(normal) if normal.length() > 1e-9 => normal,
448        _ => rng.unit_vector(),
449    };
450    let sign = if rng.unit() < 0.5 { -1.0 } else { 1.0 };
451    base.add(direction.scale(sign * jitter))
452}
453
454/// A single residual fusable — coincident geometry the assembler left unwelded.
455#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
456#[serde(rename_all = "snake_case")]
457pub enum ResidualKind {
458    /// Two distinct vertex records at (within tolerance) the same point.
459    CoincidentVertices,
460    /// Two distinct edges tracing the same curve (coincident endpoints AND
461    /// midpoint) that should be one shared edge.
462    DuplicateEdge,
463}
464
465/// One [`boolean_residual_fusables`] finding.
466#[derive(Clone, Debug, Serialize)]
467pub struct ResidualFusable {
468    pub kind: ResidualKind,
469    pub detail: String,
470    pub point: Vec3,
471}
472
473/// Part 2 — the fuse-after detector.  Any geometry in `result` that is
474/// coincident within `tol` yet NOT topologically shared is an intersector /
475/// assembler bug: the weld pass should have fused it.  Reports distinct
476/// vertices at one point and duplicate coincident edges.  Purely diagnostic.
477pub fn boolean_residual_fusables(result: &BrepSolid, tol: f64) -> Vec<ResidualFusable> {
478    let mut findings = Vec::new();
479
480    // Distinct vertices that coincide within `tol` — a weld that never fired.
481    let vertices = &result.vertices;
482    for i in 0..vertices.len() {
483        for j in (i + 1)..vertices.len() {
484            let gap = vertices[i].point.sub(vertices[j].point).length();
485            if gap <= tol {
486                findings.push(ResidualFusable {
487                    kind: ResidualKind::CoincidentVertices,
488                    detail: format!(
489                        "vertices {} and {} coincide within {gap:.3e}",
490                        vertices[i].id, vertices[j].id
491                    ),
492                    point: vertices[i].point,
493                });
494            }
495        }
496    }
497
498    // Distinct edges tracing the same curve (both endpoints AND the midpoint
499    // coincide) — a duplicated seam the assembler should have shared. The
500    // midpoint test rejects a genuine two-edge bigon whose endpoints match but
501    // whose interiors diverge.
502    let edges = &result.edges;
503    let endpoint = |edge: &crate::topology::EdgeRecord| -> Result<(Vec3, Vec3, Vec3), String> {
504        let start = edge.curve.evaluate(edge.t0)?;
505        let end = edge.curve.evaluate(edge.t1)?;
506        let mid = edge.curve.evaluate((edge.t0 + edge.t1) * 0.5)?;
507        Ok((start, mid, end))
508    };
509    for i in 0..edges.len() {
510        let Ok((si, mi, ei)) = endpoint(&edges[i]) else {
511            continue;
512        };
513        for j in (i + 1)..edges.len() {
514            let Ok((sj, mj, ej)) = endpoint(&edges[j]) else {
515                continue;
516            };
517            let endpoints_match = (si.sub(sj).length() <= tol && ei.sub(ej).length() <= tol)
518                || (si.sub(ej).length() <= tol && ei.sub(sj).length() <= tol);
519            if endpoints_match && mi.sub(mj).length() <= tol {
520                findings.push(ResidualFusable {
521                    kind: ResidualKind::DuplicateEdge,
522                    detail: format!(
523                        "edges {} and {} trace the same curve within {tol:.3e}",
524                        edges[i].id, edges[j].id
525                    ),
526                    point: mi,
527                });
528            }
529        }
530    }
531
532    findings
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538    use crate::topology::VertexRecord;
539    use crate::{boolean_operation, make_box_brep, make_cylinder_brep, BooleanOptions};
540
541    fn cube() -> BrepSolid {
542        make_box_brep(Vec3::new(-5.0, -5.0, -5.0), 10.0, 10.0, 10.0).unwrap()
543    }
544
545    fn cylinder() -> BrepSolid {
546        // A through-boss centred on the cube's Z axis: radius 3 (< the cube's
547        // half-width 5, so no side tangency) that pokes out both caps
548        // (z in [-8, 8] vs the cube's [-5, 5]). Clean for union/subtract/intersect.
549        make_cylinder_brep(
550            Vec3::new(0.0, 0.0, -8.0),
551            Vec3::new(0.0, 0.0, 1.0),
552            3.0,
553            16.0,
554        )
555        .unwrap()
556    }
557
558    #[test]
559    fn union_of_cube_and_cylinder_agrees() {
560        let a = cube();
561        let b = cylinder();
562        let result =
563            boolean_operation(&a, &b, BooleanOperation::Union, &BooleanOptions::default()).unwrap();
564        let report =
565            boolean_semantic_disagreement(&a, &b, BooleanOperation::Union, &result, 300).unwrap();
566        assert!(
567            report.considered > 50,
568            "too few decidable points: {report:?}"
569        );
570        assert!(
571            !report.is_flagged(),
572            "valid union flagged: rate {} sample {:?}",
573            report.disagreement_rate,
574            report.sample_disagreement()
575        );
576    }
577
578    #[test]
579    fn subtract_of_cube_and_cylinder_agrees() {
580        let a = cube();
581        let b = cylinder();
582        let result = boolean_operation(
583            &a,
584            &b,
585            BooleanOperation::Subtract,
586            &BooleanOptions::default(),
587        )
588        .unwrap();
589        let report =
590            boolean_semantic_disagreement(&a, &b, BooleanOperation::Subtract, &result, 300)
591                .unwrap();
592        assert!(!report.is_flagged(), "valid subtract flagged: {report:?}");
593    }
594
595    #[test]
596    fn clean_result_has_no_residual_fusables() {
597        let cube = cube();
598        assert!(boolean_residual_fusables(&cube, 1e-5).is_empty());
599    }
600
601    #[test]
602    fn duplicated_vertex_is_flagged_as_residual() {
603        let mut cube = cube();
604        let seed = cube.vertices[0].clone();
605        cube.vertices.push(VertexRecord {
606            id: 9999,
607            point: seed.point,
608        });
609        let fusables = boolean_residual_fusables(&cube, 1e-5);
610        assert!(
611            fusables
612                .iter()
613                .any(|f| f.kind == ResidualKind::CoincidentVertices),
614            "duplicated vertex not detected: {fusables:?}"
615        );
616    }
617}