Skip to main content

qualia_core_db/modalities/
spatio_temporal.rs

1// Epic 21: Spatio-Temporal Logics
2// Allen's Interval Algebra & RCC8 Spatial Relations
3
4use crate::NQuin;
5
6/// Allen's Interval Algebra operations for temporal reasoning
7pub enum TemporalOp {
8    Before,
9    Meets,
10    Overlaps,
11    Starts,
12    During,
13    Finishes,
14    Equals,
15}
16
17/// RCC8 spatial relations for topological reasoning
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Rcc8Relation {
20    /// Region A is disconnected from Region B
21    Disconnected,
22    /// Region A is externally connected to Region B (touches at boundary)
23    ExternallyConnected,
24    /// Region A is partially overlapping with Region B
25    PartiallyOverlapping,
26    /// Region A is tangentially proper part of Region B (touches boundary)
27    TangentiallyProperPart,
28    /// Region A is tangentially proper part inverse of Region B
29    TangentiallyProperPartInverse,
30    /// Region A is non-tangential proper part of Region B (completely inside)
31    NonTangentialProperPart,
32    /// Region A is non-tangential proper part inverse of Region B
33    NonTangentialProperPartInverse,
34    /// Region A is equal to Region B
35    Equal,
36}
37
38/// Spatial region representation for RCC8 reasoning
39#[derive(Debug, Clone)]
40pub struct SpatialRegion {
41    pub region_id: u64,
42    pub boundary_points: Vec<(f64, f64)>, // Simplified boundary representation
43    pub centroid: (f64, f64),
44    pub area: f64,
45}
46
47impl SpatialRegion {
48    /// Create a new spatial region from boundary points
49    pub fn new(region_id: u64, boundary_points: Vec<(f64, f64)>) -> Self {
50        let centroid = Self::compute_centroid(&boundary_points);
51        let area = Self::compute_area(&boundary_points);
52
53        Self {
54            region_id,
55            boundary_points,
56            centroid,
57            area,
58        }
59    }
60
61    /// Compute centroid of polygon (simplified)
62    fn compute_centroid(points: &[(f64, f64)]) -> (f64, f64) {
63        if points.is_empty() {
64            return (0.0, 0.0);
65        }
66
67        let (sum_x, sum_y) = points
68            .iter()
69            .fold((0.0, 0.0), |(sx, sy), (x, y)| (sx + x, sy + y));
70
71        (sum_x / points.len() as f64, sum_y / points.len() as f64)
72    }
73
74    /// Compute area using shoelace formula (simplified)
75    fn compute_area(points: &[(f64, f64)]) -> f64 {
76        if points.len() < 3 {
77            return 0.0;
78        }
79
80        let mut area = 0.0;
81        for i in 0..points.len() {
82            let j = (i + 1) % points.len();
83            area += points[i].0 * points[j].1;
84            area -= points[j].0 * points[i].1;
85        }
86
87        area.abs() / 2.0
88    }
89
90    /// Check if point is inside region (ray casting algorithm)
91    pub fn contains_point(&self, point: (f64, f64)) -> bool {
92        if self.boundary_points.len() < 3 {
93            return false;
94        }
95
96        let mut inside = false;
97        let (x, y) = point;
98        let n = self.boundary_points.len();
99
100        for i in 0..n {
101            let j = (i + 1) % n;
102            let (xi, yi) = self.boundary_points[i];
103            let (xj, yj) = self.boundary_points[j];
104
105            if ((yi > y) != (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi) {
106                inside = !inside;
107            }
108        }
109
110        inside
111    }
112
113    /// Check if this region intersects with another region
114    pub fn intersects(&self, other: &SpatialRegion) -> bool {
115        // Simplified intersection check using bounding boxes
116        let self_bounds = self.get_bounding_box();
117        let other_bounds = other.get_bounding_box();
118
119        !(self_bounds.0 > other_bounds.1
120            || self_bounds.1 < other_bounds.0
121            || self_bounds.2 > other_bounds.3
122            || self_bounds.3 < other_bounds.2)
123    }
124
125    /// Get bounding box (min_x, max_x, min_y, max_y)
126    fn get_bounding_box(&self) -> (f64, f64, f64, f64) {
127        if self.boundary_points.is_empty() {
128            return (0.0, 0.0, 0.0, 0.0);
129        }
130
131        let (min_x, max_x) = self
132            .boundary_points
133            .iter()
134            .fold((f64::INFINITY, f64::NEG_INFINITY), |(min, max), (x, _)| {
135                (min.min(*x), max.max(*x))
136            });
137
138        let (min_y, max_y) = self
139            .boundary_points
140            .iter()
141            .fold((f64::INFINITY, f64::NEG_INFINITY), |(min, max), (_, y)| {
142                (min.min(*y), max.max(*y))
143            });
144
145        (min_x, max_x, min_y, max_y)
146    }
147}
148
149/// Evaluate RCC8 spatial relation between two regions
150pub fn evaluate_rcc8(region_a: &SpatialRegion, region_b: &SpatialRegion) -> Rcc8Relation {
151    // Check for equality first
152    if region_a.region_id == region_b.region_id {
153        return Rcc8Relation::Equal;
154    }
155
156    // Check if regions intersect
157    let intersects = region_a.intersects(region_b);
158
159    if !intersects {
160        return Rcc8Relation::Disconnected;
161    }
162
163    // Check if one region is completely inside the other
164    let a_inside_b = region_a
165        .boundary_points
166        .iter()
167        .all(|&point| region_b.contains_point(point));
168    let b_inside_a = region_b
169        .boundary_points
170        .iter()
171        .all(|&point| region_a.contains_point(point));
172
173    if a_inside_b && b_inside_a {
174        Rcc8Relation::Equal
175    } else if a_inside_b {
176        // Check if boundaries touch
177        let boundaries_touch = check_boundary_touch(region_a, region_b);
178        if boundaries_touch {
179            Rcc8Relation::TangentiallyProperPart
180        } else {
181            Rcc8Relation::NonTangentialProperPart
182        }
183    } else if b_inside_a {
184        let boundaries_touch = check_boundary_touch(region_a, region_b);
185        if boundaries_touch {
186            Rcc8Relation::TangentiallyProperPartInverse
187        } else {
188            Rcc8Relation::NonTangentialProperPartInverse
189        }
190    } else {
191        // Neither region is fully inside the other; they do intersect (bounding
192        // boxes overlap per the check above).  check_boundary_touch returns true
193        // when a boundary point of one region lies in the *interior* of the other,
194        // which means the interiors genuinely overlap → PartiallyOverlapping.
195        // If no boundary point penetrates the other's interior the regions can
196        // only share a boundary curve without interior overlap → ExternallyConnected.
197        let interior_overlap = check_boundary_touch(region_a, region_b);
198        if interior_overlap {
199            Rcc8Relation::PartiallyOverlapping
200        } else {
201            Rcc8Relation::ExternallyConnected
202        }
203    }
204}
205
206/// Check if two regions touch at their boundaries
207fn check_boundary_touch(region_a: &SpatialRegion, region_b: &SpatialRegion) -> bool {
208    // Simplified boundary touch check
209    // In practice, this would use more sophisticated geometric algorithms
210    for &point_a in &region_a.boundary_points {
211        if region_b.contains_point(point_a) {
212            return true;
213        }
214    }
215
216    for &point_b in &region_b.boundary_points {
217        if region_a.contains_point(point_b) {
218            return true;
219        }
220    }
221
222    false
223}
224
225// ── Zero-heap RCC-8 over bounded boundary-point slices ───────────────────────────
226//
227// A region is represented as N boundary-point quins
228//   (region_id, q_hash("spatial:boundary"), pack_point(x, y))   [metadata = vertex seq]
229// so full-polygon RCC-8 runs allocation-free over caller-supplied stack slices,
230// fitting the 48-byte NQuin model (no Vec, no SpatialRegion on this path).
231
232/// Max vertices per region on the zero-heap RCC-8 path.
233pub const MAX_BOUNDARY_POINTS: usize = 64;
234
235/// Fixed-point scale for packing a vertex coordinate (6 decimal places).
236const POINT_SCALE: f64 = 1_000_000.0;
237const GEO_EPS: f64 = 1e-9;
238
239/// Pack a 2-D vertex into a u64 object field: signed fixed-point x in the high 32
240/// bits, y in the low 32 bits. Handles negative coordinates (lat/long).
241pub fn pack_point(x: f64, y: f64) -> u64 {
242    let xi = (x * POINT_SCALE).round() as i32 as u32 as u64;
243    let yi = (y * POINT_SCALE).round() as i32 as u32 as u64;
244    (xi << 32) | yi
245}
246
247/// Inverse of [`pack_point`].
248pub fn unpack_point(packed: u64) -> (f64, f64) {
249    let xi = (packed >> 32) as u32 as i32;
250    let yi = (packed & 0xFFFF_FFFF) as u32 as i32;
251    (xi as f64 / POINT_SCALE, yi as f64 / POINT_SCALE)
252}
253
254fn bbox(poly: &[(f64, f64)]) -> (f64, f64, f64, f64) {
255    let mut mnx = f64::INFINITY;
256    let mut mxx = f64::NEG_INFINITY;
257    let mut mny = f64::INFINITY;
258    let mut mxy = f64::NEG_INFINITY;
259    for &(x, y) in poly {
260        mnx = mnx.min(x);
261        mxx = mxx.max(x);
262        mny = mny.min(y);
263        mxy = mxy.max(y);
264    }
265    (mnx, mxx, mny, mxy)
266}
267
268fn bbox_overlap(a: &[(f64, f64)], b: &[(f64, f64)]) -> bool {
269    let (amnx, amxx, amny, amxy) = bbox(a);
270    let (bmnx, bmxx, bmny, bmxy) = bbox(b);
271    !(amnx > bmxx || amxx < bmnx || amny > bmxy || amxy < bmny)
272}
273
274/// Ray-casting point-in-polygon (interior, exclusive of the boundary) over a slice.
275fn point_in_interior(p: (f64, f64), poly: &[(f64, f64)]) -> bool {
276    if poly.len() < 3 {
277        return false;
278    }
279    let (x, y) = p;
280    let mut inside = false;
281    let n = poly.len();
282    for i in 0..n {
283        let j = (i + 1) % n;
284        let (xi, yi) = poly[i];
285        let (xj, yj) = poly[j];
286        if ((yi > y) != (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi) {
287            inside = !inside;
288        }
289    }
290    inside
291}
292
293/// True if `p` lies on an edge of `poly` (within epsilon) — i.e. on the boundary.
294fn point_on_boundary(p: (f64, f64), poly: &[(f64, f64)]) -> bool {
295    let n = poly.len();
296    if n < 2 {
297        return false;
298    }
299    for i in 0..n {
300        let a = poly[i];
301        let b = poly[(i + 1) % n];
302        // collinear (cross product ~ 0) and within the segment's bounding box.
303        let cross = (b.0 - a.0) * (p.1 - a.1) - (b.1 - a.1) * (p.0 - a.0);
304        if cross.abs() <= GEO_EPS
305            && p.0 >= a.0.min(b.0) - GEO_EPS
306            && p.0 <= a.0.max(b.0) + GEO_EPS
307            && p.1 >= a.1.min(b.1) - GEO_EPS
308            && p.1 <= a.1.max(b.1) + GEO_EPS
309        {
310            return true;
311        }
312    }
313    false
314}
315
316#[inline]
317fn inside_or_on(p: (f64, f64), poly: &[(f64, f64)]) -> bool {
318    point_in_interior(p, poly) || point_on_boundary(p, poly)
319}
320
321fn all_inside_or_on(pts: &[(f64, f64)], poly: &[(f64, f64)]) -> bool {
322    !pts.is_empty() && pts.iter().all(|&p| inside_or_on(p, poly))
323}
324
325fn any_on_boundary(pts: &[(f64, f64)], poly: &[(f64, f64)]) -> bool {
326    pts.iter().any(|&p| point_on_boundary(p, poly))
327}
328
329fn interiors_overlap(a: &[(f64, f64)], b: &[(f64, f64)]) -> bool {
330    a.iter().any(|&p| point_in_interior(p, b)) || b.iter().any(|&p| point_in_interior(p, a))
331}
332
333/// Zero-heap, full-polygon RCC-8 over two boundary-vertex slices (+ region ids).
334/// Correctly distinguishes tangential (TPP/TPPi) from non-tangential (NTPP/NTPPi)
335/// proper parts via a point-on-boundary test — unlike `evaluate_rcc8`, which
336/// conflated them. Allocation-free.
337pub fn evaluate_rcc8_points(
338    id_a: u64,
339    a: &[(f64, f64)],
340    id_b: u64,
341    b: &[(f64, f64)],
342) -> Rcc8Relation {
343    if id_a == id_b {
344        return Rcc8Relation::Equal;
345    }
346    if !bbox_overlap(a, b) {
347        return Rcc8Relation::Disconnected;
348    }
349    let a_in_b = all_inside_or_on(a, b);
350    let b_in_a = all_inside_or_on(b, a);
351    if a_in_b && b_in_a {
352        return Rcc8Relation::Equal;
353    }
354    if a_in_b {
355        return if any_on_boundary(a, b) {
356            Rcc8Relation::TangentiallyProperPart
357        } else {
358            Rcc8Relation::NonTangentialProperPart
359        };
360    }
361    if b_in_a {
362        return if any_on_boundary(b, a) {
363            Rcc8Relation::TangentiallyProperPartInverse
364        } else {
365            Rcc8Relation::NonTangentialProperPartInverse
366        };
367    }
368    if interiors_overlap(a, b) {
369        Rcc8Relation::PartiallyOverlapping
370    } else {
371        // bounding boxes overlap and a boundary point touches, but interiors do not.
372        Rcc8Relation::ExternallyConnected
373    }
374}
375
376/// Evaluate temporal relation using Allen's Interval Algebra
377pub fn evaluate_temporal(
378    op: TemporalOp,
379    t1_start: i64,
380    t1_end: i64,
381    t2_start: i64,
382    t2_end: i64,
383) -> bool {
384    match op {
385        TemporalOp::Before => t1_end < t2_start,
386        TemporalOp::Meets => t1_end == t2_start,
387        TemporalOp::Overlaps => t1_start < t2_start && t1_end > t2_start && t1_end < t2_end,
388        TemporalOp::Starts => t1_start == t2_start && t1_end < t2_end,
389        TemporalOp::During => t1_start > t2_start && t1_end < t2_end,
390        TemporalOp::Finishes => t1_end == t2_end && t1_start > t2_start,
391        TemporalOp::Equals => t1_start == t2_start && t1_end == t2_end,
392    }
393}
394
395/// Fixed-point scale for encoding centroid and area into the 64-bit object field.
396/// Centroid components use bits [63:48] and [47:32] (16 bits each, ×SPATIAL_SCALE).
397/// Area uses bits [31:0] (32 bits, ×SPATIAL_SCALE).
398/// This preserves three decimal places of precision for component values < 65.535.
399const SPATIAL_SCALE: f64 = 1_000.0;
400
401/// Convert spatial region to NQuin for storage in graph.
402///
403/// The `region_id` is stored directly as the `subject` so that `quin_to_region`
404/// can recover it exactly.  The predicate carries the semantic type stamp.
405pub fn region_to_quin(region: &SpatialRegion, context: u64) -> NQuin {
406    let subject = region.region_id;
407    let predicate = crate::q_hash("has_spatial_region");
408
409    // Pack centroid and area using fixed-point encoding (×SPATIAL_SCALE)
410    // so that fractional values survive the integer round-trip.
411    let cx = (region.centroid.0 * SPATIAL_SCALE).round() as u64;
412    let cy = (region.centroid.1 * SPATIAL_SCALE).round() as u64;
413    let ar = (region.area * SPATIAL_SCALE).round() as u64;
414
415    let object = ((cx & 0xFFFF) << 48) | ((cy & 0xFFFF) << 32) | (ar & 0xFFFF_FFFF);
416
417    let mut quin = NQuin {
418        subject,
419        predicate,
420        object,
421        context,
422        metadata: 0,
423        parity: 0,
424    };
425
426    // Set parity for validation
427    quin.parity = quin.subject ^ quin.predicate ^ quin.object ^ quin.context ^ quin.metadata;
428
429    quin
430}
431
432/// Extract spatial region from NQuin.
433pub fn quin_to_region(quin: &NQuin) -> Option<SpatialRegion> {
434    // Decode fixed-point fields (÷SPATIAL_SCALE) to recover fractional values.
435    let centroid_x = ((quin.object >> 48) & 0xFFFF) as f64 / SPATIAL_SCALE;
436    let centroid_y = ((quin.object >> 32) & 0xFFFF) as f64 / SPATIAL_SCALE;
437    let area = (quin.object & 0xFFFF_FFFF) as f64 / SPATIAL_SCALE;
438
439    // region_id is stored directly in the subject field.
440    let region_id = quin.subject;
441
442    Some(SpatialRegion {
443        region_id,
444        boundary_points: vec![], // Boundary points stored separately in practice
445        centroid: (centroid_x, centroid_y),
446        area,
447    })
448}
449
450// ─── Spatial indexing: BVH / R-tree broad-phase ──────────────────────────────────
451//
452// NOTE: spatio_temporal.rs is now >450 lines and pre-existing — flagged for the deferred
453// library-ization pass (CLAUDE.md §10), not split mid-feature.
454
455/// An axis-aligned bounding box `(min_x, min_y, max_x, max_y)` — a BVH/R-tree node volume.
456pub type Aabb = (f64, f64, f64, f64);
457
458/// The AABB of a polygon's boundary vertices.
459pub fn polygon_aabb(poly: &[(f64, f64)]) -> Aabb {
460    let (mnx, mxx, mny, mxy) = bbox(poly);
461    (mnx, mny, mxx, mxy)
462}
463
464/// Do two AABBs overlap? (the BVH/R-tree broad-phase intersection test)
465#[inline]
466pub fn aabb_overlap(a: Aabb, b: Aabb) -> bool {
467    !(a.0 > b.2 || a.2 < b.0 || a.1 > b.3 || a.3 < b.1)
468}
469
470/// **Spatial-index broad-phase** (BVH / R-tree): from the AABBs of many regions, return the
471/// indices whose box overlaps `query` — pruning the candidates a precise RCC-8 test must then
472/// check. Makes spatial queries scale to massive global entity maps. Zero-heap (caller `out`).
473pub fn spatial_index_query(query: Aabb, region_boxes: &[Aabb], out: &mut [usize]) -> usize {
474    let mut n = 0usize;
475    for (i, &b) in region_boxes.iter().enumerate() {
476        if aabb_overlap(query, b) {
477            if n >= out.len() {
478                break;
479            }
480            out[n] = i;
481            n += 1;
482        }
483    }
484    n
485}
486
487// ─── Minkowski 4-D space-time (relativity-adjusted causal structure) ──────────────
488
489/// The **Minkowski interval** `s² = −c²·Δt² + Δx² + Δy² + Δz²` (signature −+++) between two events.
490/// `s² < 0` timelike, `= 0` lightlike (on the light cone), `> 0` spacelike.
491pub fn minkowski_interval(dt: f64, dx: f64, dy: f64, dz: f64, c: f64) -> f64 {
492    -(c * c) * dt * dt + dx * dx + dy * dy + dz * dz
493}
494
495/// Are two events **causally connectable** — within or on each other's light cone (timelike or
496/// lightlike, `s² ≤ 0`)? The relativity-adjusted "could one have influenced the other?" that bounds
497/// temporal logic in 4-D space-time. Spacelike-separated events have no frame-invariant ordering.
498pub fn causally_connectable(dt: f64, dx: f64, dy: f64, dz: f64, c: f64) -> bool {
499    minkowski_interval(dt, dx, dy, dz, c) <= GEO_EPS
500}
501
502// ─── PDE solver: 1-D heat / diffusion equation (finite differences) ───────────────
503
504/// One explicit finite-difference (FTCS) step of the heat/diffusion PDE `∂u/∂t = α·∂²u/∂x²` over a
505/// 1-D grid: `u_new[i] = u[i] + r·(u[i−1] − 2u[i] + u[i+1])`, `r = α·dt/dx²`. Dirichlet boundaries
506/// (endpoints fixed). Writes into `out`. Numerically stable for `r ≤ 0.5`. Zero-heap.
507pub fn heat_equation_step(u: &[f64], alpha: f64, dt: f64, dx: f64, out: &mut [f64]) -> bool {
508    let n = u.len();
509    if n < 2 || out.len() < n || dx == 0.0 {
510        return false;
511    }
512    let r = alpha * dt / (dx * dx);
513    out[0] = u[0];
514    out[n - 1] = u[n - 1];
515    for i in 1..n - 1 {
516        out[i] = u[i] + r * (u[i - 1] - 2.0 * u[i] + u[i + 1]);
517    }
518    true
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    #[test]
526    fn test_rcc8_basic_relations() {
527        let region_a = SpatialRegion::new(1, vec![(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0)]);
528        let region_b = SpatialRegion::new(2, vec![(1.0, 1.0), (3.0, 1.0), (3.0, 3.0), (1.0, 3.0)]);
529
530        let relation = evaluate_rcc8(&region_a, &region_b);
531        assert_eq!(relation, Rcc8Relation::PartiallyOverlapping);
532    }
533
534    #[test]
535    fn test_rcc8_points_zero_heap() {
536        // Big square A = [0,10]^2; small square B = [3,7]^2 strictly inside A.
537        let big = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)];
538        let small = [(3.0, 3.0), (7.0, 3.0), (7.0, 7.0), (3.0, 7.0)];
539        // B is a NON-tangential proper part of A (strictly inside, no shared boundary).
540        assert_eq!(
541            evaluate_rcc8_points(1, &small, 2, &big),
542            Rcc8Relation::NonTangentialProperPart
543        );
544        assert_eq!(
545            evaluate_rcc8_points(2, &big, 1, &small),
546            Rcc8Relation::NonTangentialProperPartInverse
547        );
548
549        // Disjoint squares → Disconnected.
550        let far = [(20.0, 20.0), (22.0, 20.0), (22.0, 22.0), (20.0, 22.0)];
551        assert_eq!(
552            evaluate_rcc8_points(1, &big, 3, &far),
553            Rcc8Relation::Disconnected
554        );
555
556        // Partially overlapping squares.
557        let a = [(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)];
558        let b = [(2.0, 2.0), (6.0, 2.0), (6.0, 6.0), (2.0, 6.0)];
559        assert_eq!(
560            evaluate_rcc8_points(1, &a, 2, &b),
561            Rcc8Relation::PartiallyOverlapping
562        );
563
564        // Same region id → Equal.
565        assert_eq!(evaluate_rcc8_points(5, &a, 5, &b), Rcc8Relation::Equal);
566
567        // pack/unpack round-trips (incl. negative coords).
568        let (x, y) = unpack_point(pack_point(-45.123456, 170.654321));
569        assert!((x + 45.123456).abs() < 1e-5 && (y - 170.654321).abs() < 1e-5);
570    }
571
572    #[test]
573    fn test_temporal_relations() {
574        assert!(evaluate_temporal(TemporalOp::Before, 0, 10, 15, 25));
575        assert!(evaluate_temporal(TemporalOp::Meets, 0, 10, 10, 20));
576        assert!(evaluate_temporal(TemporalOp::Overlaps, 0, 15, 10, 25));
577        assert!(evaluate_temporal(TemporalOp::During, 5, 15, 0, 25));
578    }
579
580    #[test]
581    fn test_region_quin_conversion() {
582        let region = SpatialRegion::new(42, vec![(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]);
583        let quin = region_to_quin(&region, 123);
584        let extracted = quin_to_region(&quin).unwrap();
585
586        assert_eq!(extracted.region_id, region.region_id);
587        assert_eq!(extracted.centroid, region.centroid);
588        assert_eq!(extracted.area, region.area);
589    }
590
591    #[test]
592    fn spatial_index_broad_phase() {
593        // Three region boxes; a query box that overlaps two of them.
594        let boxes = [
595            (0.0, 0.0, 1.0, 1.0),
596            (2.0, 2.0, 3.0, 3.0),
597            (0.5, 0.5, 2.5, 2.5),
598        ];
599        let query = (0.8, 0.8, 1.2, 1.2);
600        let mut out = [0usize; 8];
601        let n = spatial_index_query(query, &boxes, &mut out);
602        assert_eq!(n, 2, "query overlaps box 0 and box 2, not box 1");
603        assert!(out[..n].contains(&0) && out[..n].contains(&2) && !out[..n].contains(&1));
604        // A polygon's AABB.
605        assert_eq!(
606            polygon_aabb(&[(0.0, 0.0), (2.0, 0.0), (2.0, 1.0)]),
607            (0.0, 0.0, 2.0, 1.0)
608        );
609    }
610
611    #[test]
612    fn minkowski_light_cone_classification() {
613        let c = 1.0;
614        // Timelike (dt dominates) → causally connectable.
615        assert!(minkowski_interval(2.0, 1.0, 0.0, 0.0, c) < 0.0);
616        assert!(causally_connectable(2.0, 1.0, 0.0, 0.0, c));
617        // Lightlike (on the cone) → connectable.
618        assert!(minkowski_interval(1.0, 1.0, 0.0, 0.0, c).abs() < 1e-9);
619        assert!(causally_connectable(1.0, 1.0, 0.0, 0.0, c));
620        // Spacelike (space dominates) → NOT causally orderable.
621        assert!(minkowski_interval(1.0, 3.0, 0.0, 0.0, c) > 0.0);
622        assert!(!causally_connectable(1.0, 3.0, 0.0, 0.0, c));
623    }
624
625    #[test]
626    fn heat_pde_step_diffuses_and_conserves() {
627        // A spike on a 5-cell grid; r = α·dt/dx² = 0.25.
628        let u = [0.0, 0.0, 1.0, 0.0, 0.0];
629        let mut out = [0.0f64; 5];
630        assert!(heat_equation_step(&u, 0.25, 1.0, 1.0, &mut out));
631        // The spike spreads: centre 0.5, neighbours 0.25, fixed boundaries.
632        assert!((out[2] - 0.5).abs() < 1e-9);
633        assert!((out[1] - 0.25).abs() < 1e-9 && (out[3] - 0.25).abs() < 1e-9);
634        assert_eq!(out[0], 0.0);
635        // Interior mass conserved (Dirichlet-0 boundaries).
636        let sum: f64 = out.iter().sum();
637        assert!((sum - 1.0).abs() < 1e-9);
638        // Degenerate input refuses.
639        assert!(!heat_equation_step(&u, 0.25, 1.0, 0.0, &mut out));
640    }
641}