Skip to main content

teksilo_scene/
index.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Spatial index for [`Scene`](crate::Scene) items.
5//!
6//! [`GridHashIndex`] is the only shipped implementation — a uniform grid
7//! hash. The [`SpatialIndex`] trait is deliberately small — three mutating
8//! operations (`insert`, `remove`, `query`) plus two read methods
9//! (`contains`, `len`) — so an application that needs different behaviour
10//! (e.g. an R-tree) can supply its own implementation in a one-line change
11//! via [`Scene::with_index`](crate::Scene::with_index).
12//!
13//! ## Why grid hash first
14//!
15//! - Cache-friendly: items in the same cell are stored contiguously.
16//! - Insert / remove / move are amortised `O(k)` where `k` is the
17//!   number of cells the item overlaps (typically 1–4 for items
18//!   smaller than the cell size).
19//! - `query(rect)` returns deduplicated candidates from the cells the
20//!   rect overlaps; callers can narrow with a per-item AABB check.
21//! - **Oversized items.** An item whose AABB would bucket into more
22//!   than `MAX_CELLS_PER_ITEM` grid cells (a scene backdrop, a
23//!   full-document canvas rect, or any item at extreme coordinates
24//!   with large bounds — all reachable in production, not exotic) is
25//!   NOT bucketed cell-by-cell at all. It is stored instead in a
26//!   separate `oversized: HashMap<ItemId, Rect>` that `query`
27//!   always scans in full, in addition to the cell lookup, keeping
28//!   an exact AABB-intersection test against `scene_rect` (so it
29//!   contributes no cell-fan-out false positives of its own).
30//!
31//!   This closes what used to be an unconditional, uncapped eager
32//!   allocation: `cells_for_rect` computed
33//!   `(width / cell_size) * (height / cell_size)` cells and reserved
34//!   that many `(i32, i32)` slots *before* the loop that fills them
35//!   ran — no upper bound, and using bare `i32` arithmetic that could
36//!   itself overflow for large extents (debug builds panicked,
37//!   release builds could wrap to a huge or negative `usize`). A
38//!   single 1e6 × 1e6 logical-pixel item at the clamped-minimum
39//!   `cell_size` of 1.0 asked for `(1e6+1)² ≈ 1e12` cells — roughly
40//!   8 TB for the `Vec<(i32, i32)>` alone — before any assertion or
41//!   even the fill loop ran; this was reachable from a single
42//!   `Scene::add_item` call, no adversarial input required. Even at
43//!   the default 256 px `cell_size`, a 1e6-square item alone reserved
44//!   `(1e6 / 256)² ≈ 1.5e7` cells (~122 MB) for that one item. The
45//!   same hazard applied to `query`/`items_in_rect`, since a caller
46//!   can pass an arbitrarily large `scene_rect` too — see `query`'s
47//!   own oversized-span fallback.
48//!
49//!   A custom `SpatialIndex` would still handle non-uniform density
50//!   better — an R-tree, say, for an editor with many overlapping
51//!   items — but none ships; the trait is the place to add one.
52//!
53//! Default `cell_size` is [`DEFAULT_CELL_SIZE`] (`256.0` logical pixels)
54//! — large enough that typical card-sized items (~200 px) bucket into 1–4
55//! cells and small enough that viewport queries (~800–1200 px) hit a
56//! manageable fan-out.
57//!
58//! ## Example
59//!
60//! ```ignore
61//! // ItemId values are obtained from Scene::add_item in real code;
62//! // the example uses the crate-internal constructor for illustration.
63//! use teksilo_scene::{GridHashIndex, SpatialIndex, ItemId};
64//! use teksilo_canvas::Rect;
65//!
66//! let mut index = GridHashIndex::default();
67//! let id = ItemId(1); // in practice: returned by Scene::add_item
68//! index.insert(id, Rect::new(10.0, 10.0, 80.0, 80.0));
69//! assert!(index.contains(id));
70//!
71//! let hits = index.query(Rect::new(0.0, 0.0, 100.0, 100.0));
72//! assert!(hits.contains(&id));
73//!
74//! index.remove(id);
75//! assert!(index.is_empty());
76//! ```
77
78use std::collections::{HashMap, HashSet};
79
80use teksilo_canvas::Rect;
81
82use crate::item::ItemId;
83
84/// A spatial index over [`ItemId`]s keyed by axis-aligned scene
85/// rectangles. Used by [`Scene`](crate::Scene) for `items_in_rect`
86/// queries and by [`SceneView`](crate::SceneView) for viewport
87/// culling.
88pub trait SpatialIndex: Send + std::fmt::Debug {
89    /// Insert or update an item's bounds. Calling `insert` again with
90    /// the same id replaces the previous bounds (re-buckets the
91    /// item). Equivalent to `remove(id); insert(id, bounds);` on
92    /// implementations that need an explicit update path.
93    fn insert(&mut self, id: ItemId, bounds: Rect);
94
95    /// Remove an item. No-op if `id` is not present.
96    fn remove(&mut self, id: ItemId);
97
98    /// Items whose bounds intersect `scene_rect`, in implementation-
99    /// defined order. The result is deduplicated. May include false
100    /// positives (items in cells the rect overlaps but whose bounds
101    /// don't actually intersect) — callers that need exact
102    /// intersection narrow with a per-item check.
103    fn query(&self, scene_rect: Rect) -> Vec<ItemId>;
104
105    /// Whether `id` is currently in the index.
106    fn contains(&self, id: ItemId) -> bool;
107
108    /// Total number of items in the index.
109    fn len(&self) -> usize;
110
111    /// Whether the index is empty.
112    fn is_empty(&self) -> bool {
113        self.len() == 0
114    }
115}
116
117/// Default cell size for [`GridHashIndex`] — 256 logical pixels.
118/// Item-side typical 200 px cards bucket into 1–4 cells; viewport
119/// queries (~800–1200 px) hit a small fan-out.
120pub const DEFAULT_CELL_SIZE: f32 = 256.0;
121
122/// Cap on how many grid cells a single item's AABB may be bucketed
123/// into before it is instead stored in the always-scanned `oversized`
124/// list (see [`GridHashIndex::insert`] and the module doc's
125/// "Oversized items" section).
126///
127/// Chosen as a small constant that keeps the bucketed fast path's
128/// worst-case per-item footprint bounded and independent of the
129/// item's actual size: at `MAX_CELLS_PER_ITEM` cells, the worst case
130/// is `MAX_CELLS_PER_ITEM` entries in `item_cells` (a
131/// `Vec<(i32, i32)>`, 8 bytes per entry) plus up to
132/// `MAX_CELLS_PER_ITEM` distinct single-item buckets in `cells` (a
133/// `HashMap` entry + a `Vec<ItemId>` each, tens of bytes) — on the
134/// order of 40–50 KB for one pathologically-shaped item, versus the
135/// previous unconditional and unbounded reservation described above.
136///
137/// 1024 is generous headroom above typical scene content: at the
138/// default `cell_size` of 256 px that's an ~8192×8192 px square item
139/// before it goes oversized; at the clamped minimum `cell_size` of
140/// 1.0 px (see [`GridHashIndex::new`]) that's a mere ~32×32 px item.
141/// Anything bigger at that cell size is exactly the shape of the bug
142/// this constant fixes: the incident's 1e6 × 1e6 item at
143/// `cell_size: 1.0` (`(1e6+1)² ≈ 1e12` cells, ~8 TB, under the old
144/// code) now falls straight into `oversized` instead.
145const MAX_CELLS_PER_ITEM: u64 = 1024;
146
147/// AABB intersection test used by [`GridHashIndex::query`]'s
148/// oversized-item scan. `mod tests` / `mod proptests` below keep their
149/// own independent copies for brute-force cross-checks — deliberately
150/// not sharing this one, so a bug here couldn't be masked by a test
151/// using the same implementation to verify itself.
152fn rects_intersect(a: Rect, b: Rect) -> bool {
153    a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height
154}
155
156/// Uniform grid spatial hash. Each item is bucketed into every cell
157/// its AABB overlaps; queries union all items from the cells the
158/// query rect overlaps. Items whose AABB would span more than
159/// `MAX_CELLS_PER_ITEM` cells are NOT bucketed — see `oversized`
160/// below and the module doc's "Oversized items" section.
161#[derive(Debug)]
162pub struct GridHashIndex {
163    cell_size: f32,
164    cells: HashMap<(i32, i32), Vec<ItemId>>,
165    /// Reverse lookup so `remove` and `insert` (as update) don't need
166    /// to scan every cell.
167    item_cells: HashMap<ItemId, Vec<(i32, i32)>>,
168    /// Items whose AABB spans more than `MAX_CELLS_PER_ITEM` grid
169    /// cells. Never bucketed into `cells`/`item_cells` — `query`
170    /// always scans this map in full instead, checking a true AABB
171    /// intersection against the query rect. An `ItemId` is present in
172    /// exactly one of `item_cells` or `oversized` at any time, never
173    /// both (`insert` always calls `remove` first).
174    oversized: HashMap<ItemId, Rect>,
175}
176
177impl GridHashIndex {
178    /// Create a grid with `cell_size` logical pixels per cell.
179    /// Clamped to a minimum of 1.0 to avoid pathological huge bucket
180    /// counts.
181    pub fn new(cell_size: f32) -> Self {
182        Self {
183            cell_size: cell_size.max(1.0),
184            cells: HashMap::new(),
185            item_cells: HashMap::new(),
186            oversized: HashMap::new(),
187        }
188    }
189
190    /// The configured cell size in logical pixels.
191    pub fn cell_size(&self) -> f32 {
192        self.cell_size
193    }
194
195    /// Number of cells currently storing at least one item. Useful
196    /// for diagnostics; not part of the public `SpatialIndex` trait.
197    /// Oversized items (see `MAX_CELLS_PER_ITEM`) never occupy a
198    /// cell, so they never contribute to this count.
199    pub fn cell_count(&self) -> usize {
200        self.cells.len()
201    }
202
203    /// Test-only staleness probe: `true` if any bucket in `cells` is
204    /// present but empty. `remove` is supposed to drop a cell entry
205    /// entirely once its last item leaves (see the `items.is_empty()`
206    /// check there) — an empty-but-present bucket is a leak that would
207    /// otherwise only show up as `cell_count()` drifting upward over a
208    /// long-lived scene. Not part of the public API; added for the
209    /// proptest suite below rather than making `cells` `pub`.
210    #[cfg(test)]
211    fn has_empty_bucket(&self) -> bool {
212        self.cells.values().any(|items| items.is_empty())
213    }
214
215    /// Test-only accessor: `true` if `id` is currently stored in the
216    /// `oversized` representation rather than bucketed into `cells`.
217    /// Mirrors `has_empty_bucket` — not part of the public API, added
218    /// so the proptest suite can assert on which representation an
219    /// item landed in without making `oversized` `pub`.
220    #[cfg(test)]
221    fn is_oversized(&self, id: ItemId) -> bool {
222        self.oversized.contains_key(&id)
223    }
224
225    /// The inclusive grid-cell span `[min_x, max_x] × [min_y, max_y]`
226    /// that `r` covers, using the half-open convention: a rect that
227    /// ends exactly on a cell boundary doesn't include the next cell.
228    /// Otherwise an item sitting on a boundary would over-bucket and
229    /// queries would double-count.
230    fn cell_span_for_rect(&self, r: Rect) -> (i32, i32, i32, i32) {
231        let cs = self.cell_size;
232        let min_x = (r.x / cs).floor() as i32;
233        let min_y = (r.y / cs).floor() as i32;
234        // For zero-extent rects, treat as a single point cell.
235        let max_x = if r.width <= 0.0 {
236            min_x
237        } else {
238            ((r.right() - f32::EPSILON) / cs).floor() as i32
239        };
240        let max_y = if r.height <= 0.0 {
241            min_y
242        } else {
243            ((r.bottom() - f32::EPSILON) / cs).floor() as i32
244        };
245        (min_x, min_y, max_x, max_y)
246    }
247
248    /// Number of grid cells the inclusive span `[min_x, max_x] ×
249    /// [min_y, max_y]` covers.
250    ///
251    /// Computed via `i64` subtraction promoted to a saturating `u64`
252    /// multiplication — never the bare `i32 * i32` product the
253    /// original bug used, which could itself overflow for
254    /// large-extent rects (debug: panic; release: wrap, possibly to a
255    /// negative value that then reinterpreted as a huge `usize`). This
256    /// function is pure arithmetic — O(1) and allocation-free — so it
257    /// is always safe to call, even with a span that would be
258    /// catastrophic to actually enumerate.
259    fn cell_span_count(min_x: i32, min_y: i32, max_x: i32, max_y: i32) -> u64 {
260        let width = (i64::from(max_x) - i64::from(min_x) + 1).max(1) as u64;
261        let height = (i64::from(max_y) - i64::from(min_y) + 1).max(1) as u64;
262        width.saturating_mul(height)
263    }
264
265    /// Whether `r`'s grid-cell span exceeds `MAX_CELLS_PER_ITEM` —
266    /// i.e. whether it must go into `oversized` instead of being
267    /// bucketed cell-by-cell. See the module doc's "Oversized items"
268    /// section.
269    fn rect_is_oversized(&self, r: Rect) -> bool {
270        let (min_x, min_y, max_x, max_y) = self.cell_span_for_rect(r);
271        Self::cell_span_count(min_x, min_y, max_x, max_y) > MAX_CELLS_PER_ITEM
272    }
273
274    /// Enumerate every grid cell `r` covers.
275    ///
276    /// Precondition upheld by both call sites — `insert` (only after
277    /// `rect_is_oversized` returns `false`) and `query`'s normal-path
278    /// branch (only after its own span-count check) — is that `r`'s
279    /// span is `<= MAX_CELLS_PER_ITEM` cells, a small constant. The
280    /// `debug_assert!` below exists to catch a future call site that
281    /// forgets that precondition during development/test, rather than
282    /// silently reintroducing the original unbounded-allocation bug in
283    /// release builds.
284    fn cells_for_rect(&self, r: Rect) -> Vec<(i32, i32)> {
285        let (min_x, min_y, max_x, max_y) = self.cell_span_for_rect(r);
286        let count = Self::cell_span_count(min_x, min_y, max_x, max_y);
287        debug_assert!(
288            count <= MAX_CELLS_PER_ITEM,
289            "cells_for_rect called with a span of {count} cells, above MAX_CELLS_PER_ITEM \
290             ({MAX_CELLS_PER_ITEM}) for rect {r:?} — callers must route anything this large \
291             through the `oversized` representation instead of enumerating cells for it",
292        );
293        let mut out = Vec::with_capacity(count as usize);
294        for x in min_x..=max_x {
295            for y in min_y..=max_y {
296                out.push((x, y));
297            }
298        }
299        out
300    }
301}
302
303impl Default for GridHashIndex {
304    fn default() -> Self {
305        Self::new(DEFAULT_CELL_SIZE)
306    }
307}
308
309impl SpatialIndex for GridHashIndex {
310    fn insert(&mut self, id: ItemId, bounds: Rect) {
311        // Re-insert: drop any previous bucketed OR oversized entry
312        // first, so an id can move freely between the two
313        // representations (normal→oversized and oversized→normal) on
314        // a bounds change.
315        self.remove(id);
316        if self.rect_is_oversized(bounds) {
317            self.oversized.insert(id, bounds);
318            return;
319        }
320        let cells = self.cells_for_rect(bounds);
321        for cell in &cells {
322            self.cells.entry(*cell).or_default().push(id);
323        }
324        self.item_cells.insert(id, cells);
325    }
326
327    fn remove(&mut self, id: ItemId) {
328        if self.oversized.remove(&id).is_some() {
329            return;
330        }
331        if let Some(cells) = self.item_cells.remove(&id) {
332            for cell in cells {
333                if let Some(items) = self.cells.get_mut(&cell) {
334                    items.retain(|&i| i != id);
335                    if items.is_empty() {
336                        self.cells.remove(&cell);
337                    }
338                }
339            }
340        }
341    }
342
343    fn query(&self, scene_rect: Rect) -> Vec<ItemId> {
344        let mut seen = HashSet::new();
345        let mut result = Vec::new();
346
347        let (min_x, min_y, max_x, max_y) = self.cell_span_for_rect(scene_rect);
348        let span_count = Self::cell_span_count(min_x, min_y, max_x, max_y);
349
350        if span_count <= MAX_CELLS_PER_ITEM {
351            // Normal path: the query rect itself covers a bounded
352            // number of cells (same cap as a single item), so
353            // enumerating them directly is cheap.
354            for cell in self.cells_for_rect(scene_rect) {
355                if let Some(items) = self.cells.get(&cell) {
356                    for &id in items {
357                        if seen.insert(id) {
358                            result.push(id);
359                        }
360                    }
361                }
362            }
363        } else {
364            // The QUERY rect itself spans more cells than any single
365            // item is allowed to occupy — e.g. a "select everything"
366            // or fit-to-content query over a huge area. Enumerating
367            // min_x..=max_x × min_y..=max_y directly here would hit
368            // the exact unbounded-allocation hazard `MAX_CELLS_PER_ITEM`
369            // exists to close for items, just on the query side
370            // instead. So instead scan the (much smaller) set of
371            // POPULATED cells and keep only the ones inside the span
372            // — O(populated cells) rather than O(cells the rect
373            // covers). Populated-cell count is bounded by
374            // `items_in_the_grid × MAX_CELLS_PER_ITEM`, never by the
375            // query rect's area, so this is always safe. The result is
376            // identical to the normal path's (same set of cells
377            // considered — just discovered from the other direction).
378            for (&(cx, cy), items) in &self.cells {
379                if (min_x..=max_x).contains(&cx) && (min_y..=max_y).contains(&cy) {
380                    for &id in items {
381                        if seen.insert(id) {
382                            result.push(id);
383                        }
384                    }
385                }
386            }
387        }
388
389        // Oversized items are never bucketed into `cells` at all, so
390        // they must always be checked directly — regardless of which
391        // branch above ran — against a true AABB intersection. This
392        // is what keeps the never-under-report invariant for an item
393        // too big to cell-bucket, and (since it's an exact check, not
394        // a cell-fan-out approximation) it never contributes a false
395        // positive of its own.
396        for (&id, &bounds) in &self.oversized {
397            if rects_intersect(bounds, scene_rect) && seen.insert(id) {
398                result.push(id);
399            }
400        }
401
402        // Stable order so query results are deterministic across
403        // runs — useful for tests and reproducible debugging.
404        result.sort_unstable();
405        result
406    }
407
408    fn contains(&self, id: ItemId) -> bool {
409        self.item_cells.contains_key(&id) || self.oversized.contains_key(&id)
410    }
411
412    fn len(&self) -> usize {
413        self.item_cells.len() + self.oversized.len()
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    fn id(n: u64) -> ItemId {
422        // Build a synthetic ItemId for tests. Tests only care about
423        // uniqueness of the underlying integer, not collision-freedom
424        // against the global counter — the index is keyed by ItemId
425        // equality which is just the inner u64.
426        ItemId(n)
427    }
428
429    #[test]
430    fn insert_and_contains() {
431        let mut g = GridHashIndex::default();
432        g.insert(id(1), Rect::new(10.0, 10.0, 50.0, 50.0));
433        assert!(g.contains(id(1)));
434        assert_eq!(g.len(), 1);
435    }
436
437    #[test]
438    fn remove_removes_from_all_cells() {
439        let mut g = GridHashIndex::new(64.0);
440        // 200×200 rect spans multiple cells.
441        g.insert(id(1), Rect::new(0.0, 0.0, 200.0, 200.0));
442        assert!(g.contains(id(1)));
443        let cells_with_id = g.cell_count();
444        assert!(cells_with_id > 1, "expected multi-cell bucketing");
445        g.remove(id(1));
446        assert!(!g.contains(id(1)));
447        assert_eq!(g.len(), 0);
448        assert_eq!(
449            g.cell_count(),
450            0,
451            "all buckets should drop with the only item"
452        );
453    }
454
455    #[test]
456    fn re_insert_replaces_buckets() {
457        // Inserting the same id twice with different bounds must
458        // re-bucket — a stale entry would cause query() to return
459        // hits for the old bounds.
460        let mut g = GridHashIndex::new(64.0);
461        g.insert(id(1), Rect::new(0.0, 0.0, 50.0, 50.0));
462        g.insert(id(1), Rect::new(1000.0, 1000.0, 50.0, 50.0));
463        assert!(g.query(Rect::new(0.0, 0.0, 100.0, 100.0)).is_empty());
464        let hits = g.query(Rect::new(990.0, 990.0, 100.0, 100.0));
465        assert_eq!(hits, vec![id(1)]);
466    }
467
468    #[test]
469    fn query_returns_intersecting_items() {
470        let mut g = GridHashIndex::new(64.0);
471        g.insert(id(1), Rect::new(0.0, 0.0, 50.0, 50.0));
472        g.insert(id(2), Rect::new(200.0, 0.0, 50.0, 50.0));
473        g.insert(id(3), Rect::new(0.0, 200.0, 50.0, 50.0));
474
475        let near_origin = g.query(Rect::new(0.0, 0.0, 100.0, 100.0));
476        assert_eq!(near_origin, vec![id(1)]);
477
478        let far_right = g.query(Rect::new(180.0, 0.0, 100.0, 100.0));
479        assert_eq!(far_right, vec![id(2)]);
480
481        let nothing = g.query(Rect::new(500.0, 500.0, 1.0, 1.0));
482        assert!(nothing.is_empty());
483    }
484
485    #[test]
486    fn query_dedupes_items_spanning_multiple_cells() {
487        // An item that spans multiple cells must appear once per
488        // query, not once per overlapped cell.
489        let mut g = GridHashIndex::new(50.0);
490        g.insert(id(1), Rect::new(0.0, 0.0, 200.0, 200.0));
491        let hits = g.query(Rect::new(0.0, 0.0, 200.0, 200.0));
492        assert_eq!(hits, vec![id(1)]);
493    }
494
495    #[test]
496    fn query_matches_brute_force_on_random_layout() {
497        // Cross-check the index against a brute-force AABB intersect
498        // scan over a deterministic random layout. Pins both the
499        // bucketing math and the query path.
500        use std::collections::BTreeSet;
501        let mut g = GridHashIndex::new(64.0);
502        let mut items: Vec<(ItemId, Rect)> = Vec::new();
503        // Deterministic LCG so the test is reproducible without a
504        // RNG dep.
505        let mut state: u64 = 0xDEAD_BEEF;
506        let mut next = || {
507            state = state
508                .wrapping_mul(6364136223846793005)
509                .wrapping_add(1442695040888963407);
510            (state >> 33) as u32
511        };
512        for n in 0..200 {
513            let x = (next() % 1000) as f32 - 500.0;
514            let y = (next() % 1000) as f32 - 500.0;
515            let w = (next() % 80) as f32 + 5.0;
516            let h = (next() % 80) as f32 + 5.0;
517            let r = Rect::new(x, y, w, h);
518            let id = ItemId(n + 1);
519            items.push((id, r));
520            g.insert(id, r);
521        }
522
523        let queries = [
524            Rect::new(-100.0, -100.0, 200.0, 200.0),
525            Rect::new(0.0, 0.0, 50.0, 50.0),
526            Rect::new(-500.0, -500.0, 1000.0, 1000.0),
527            Rect::new(100.0, 100.0, 30.0, 30.0),
528            Rect::new(2000.0, 2000.0, 10.0, 10.0),
529        ];
530        for q in queries {
531            let from_index: BTreeSet<ItemId> = g.query(q).into_iter().collect();
532            let from_brute: BTreeSet<ItemId> = items
533                .iter()
534                .filter(|(_, r)| rects_intersect(*r, q))
535                .map(|(id, _)| *id)
536                .collect();
537
538            // The trait contract allows the index to return cell
539            // fan-out false positives — items whose cell overlaps
540            // the query rect but whose AABB doesn't actually
541            // intersect. So `from_brute ⊆ from_index`, and after
542            // narrowing the index hits with the same intersect
543            // predicate the result must equal the brute-force set.
544            assert!(
545                from_brute.is_subset(&from_index),
546                "index missed true intersections for query {:?}: missing {:?}",
547                q,
548                from_brute.difference(&from_index).collect::<Vec<_>>()
549            );
550            let narrowed: BTreeSet<ItemId> = from_index
551                .iter()
552                .copied()
553                .filter(|id| {
554                    let r = items
555                        .iter()
556                        .find(|(i, _)| i == id)
557                        .unwrap_or_else(|| {
558                            panic!(
559                                "query returned id {id:?} not present in items — \
560                                 phantom id from GridHashIndex"
561                            )
562                        })
563                        .1;
564                    rects_intersect(r, q)
565                })
566                .collect();
567            assert_eq!(
568                narrowed, from_brute,
569                "narrowed index disagrees with brute-force for query {:?}",
570                q
571            );
572        }
573    }
574
575    #[test]
576    fn cell_size_clamped_to_minimum_one() {
577        let g = GridHashIndex::new(0.0);
578        assert!(g.cell_size() >= 1.0);
579        let g = GridHashIndex::new(-100.0);
580        assert!(g.cell_size() >= 1.0);
581    }
582
583    /// Per-operation cost of the grid, stated as a **ratio** rather than a
584    /// clock.
585    ///
586    /// The claim [`GridHashIndex`] exists to make is that a query costs the
587    /// *bucket*, not the scene: at constant density, ten times the items must
588    /// not cost anything like ten times as much to answer a fixed-size query
589    /// with. That is what a brute-force scan would do and what this replaces.
590    ///
591    /// It used to be a wall-clock bound — `insert_ms < 200`, asserted in a
592    /// **debug** build — which states the machine rather than the algorithm and
593    /// failed a loaded CI host at 437 ms. Its own sibling probes say why:
594    /// "a threshold in wall-clock nanoseconds would be a flake generator; a
595    /// ratio between two measurements taken on the same host in the same run is
596    /// not." See `view::tests::perf_probes`.
597    ///
598    /// ```text
599    /// cargo test --release -p teksilo-scene --lib -- --ignored --nocapture perf_probes
600    /// ```
601    #[test]
602    #[ignore = "timing probe; run in --release"]
603    fn perf_probes_query_cost_follows_the_bucket_not_the_scene() {
604        use std::time::Instant;
605
606        /// `n` 10x10 items on a 20-unit square lattice — the item count grows,
607        /// the density does not. Returns nanoseconds per insert and per query
608        /// of a fixed 100x100 window.
609        fn measure(n: u64) -> (f64, f64, usize) {
610            let side = (n as f64).sqrt().ceil() as u64;
611            // Two lattice steps to a cell, so a 100x100 window spans a handful
612            // of buckets at either scale. The 256-unit default would put the
613            // whole small lattice in ~64 cells and measure nothing.
614            let mut g = GridHashIndex::new(40.0);
615            let start = Instant::now();
616            for i in 0..n {
617                let x = ((i % side) * 20) as f32;
618                let y = ((i / side) * 20) as f32;
619                g.insert(id(i + 1), Rect::new(x, y, 10.0, 10.0));
620            }
621            let insert_ns = start.elapsed().as_nanos() as f64 / n as f64;
622
623            // Query the lattice's **interior** only. A window straddling the
624            // edge sees fewer items, and the small scene has proportionally
625            // more edge — which would make the two scales measure different
626            // neighbourhoods and the ratio meaningless.
627            let reps = 2_000u64;
628            let margin = 200u64;
629            let span = (side * 20).saturating_sub(2 * margin).max(1);
630            let start = Instant::now();
631            let mut hits = 0usize;
632            for r in 0..reps {
633                let x = (margin + (r * 37) % span) as f32;
634                let y = (margin + (r * 53) % span) as f32;
635                hits += g.query(Rect::new(x, y, 100.0, 100.0)).len();
636            }
637            let query_ns = start.elapsed().as_nanos() as f64 / reps as f64;
638            (insert_ns, query_ns, hits / reps as usize)
639        }
640
641        let (small_insert, small_query, small_hits) = measure(10_000);
642        let (large_insert, large_query, large_hits) = measure(100_000);
643        println!(
644            "insert  10000 : {small_insert:>10.1} ns/item | 100000: {large_insert:>10.1} ns/item"
645        );
646        println!(
647            "query   10000 : {small_query:>10.1} ns/call | 100000: {large_query:>10.1} ns/call"
648        );
649        println!("hits per query: {small_hits} @10000 | {large_hits} @100000");
650
651        // The window is the same size at both scales, so it covers the same
652        // patch of lattice and returns the same handful of items. If that
653        // stopped being true the ratio below would be measuring the wrong
654        // thing.
655        assert!(
656            large_hits.abs_diff(small_hits) <= 2,
657            "the probe must query the same-sized neighbourhood at both scales \
658             ({small_hits} vs {large_hits} hits)"
659        );
660
661        let query_growth = large_query / small_query.max(1.0);
662        let insert_growth = large_insert / small_insert.max(1.0);
663        println!("growth 10x items: query {query_growth:>6.2}x | insert {insert_growth:>6.2}x");
664        assert!(
665            query_growth < 3.0,
666            "10x the items must not cost 10x the query — the bucketing is gone \
667             (grew {query_growth:.2}x)"
668        );
669        assert!(
670            insert_growth < 3.0,
671            "an insert touches the cells one rect covers, so its cost must not \
672             follow the scene either (grew {insert_growth:.2}x)"
673        );
674    }
675
676    fn rects_intersect(a: Rect, b: Rect) -> bool {
677        a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height
678    }
679
680    #[test]
681    fn oversized_1e6_extent_at_cell_size_one_never_allocates_the_pathological_cell_count() {
682        // The literal incident input: a 1e6 x 1e6 logical-pixel rect at
683        // cell_size 1.0 used to make `cells_for_rect` reserve
684        // `(1e6+1) * (1e6+1) ~= 1e12` cells — about 8 TB for a
685        // `Vec<(i32, i32)>` alone (and risked an i32*i32 overflow along
686        // the way). It must now be classified oversized and never touch
687        // `cells`/`item_cells` at all.
688        let mut g = GridHashIndex::new(1.0);
689        let item = id(1);
690        g.insert(item, Rect::new(0.0, 0.0, 1_000_000.0, 1_000_000.0));
691        assert!(
692            g.is_oversized(item),
693            "the reboot-inducing input must be classified oversized"
694        );
695        assert!(g.contains(item));
696        assert_eq!(g.len(), 1);
697        assert_eq!(
698            g.cell_count(),
699            0,
700            "an oversized item must not touch the cell buckets"
701        );
702
703        // It must still be findable by a query that truly intersects it.
704        let hits = g.query(Rect::new(500.0, 500.0, 10.0, 10.0));
705        assert_eq!(hits, vec![item]);
706    }
707}
708
709/// Property-based tests for [`GridHashIndex`].
710///
711/// `GridHashIndex` itself is re-exported (`pub use index::{GridHashIndex,
712/// SpatialIndex};` in `lib.rs`), but this suite leans on crate-private
713/// surface a `tests/*.rs` integration file cannot reach — `ItemId`'s
714/// `pub(crate)` constructor, `MAX_CELLS_PER_ITEM`, and the `#[cfg(test)]`
715/// `has_empty_bucket` / `is_oversized` probes — so it lives inline, after
716/// the example-based `mod tests` above, per house style.
717///
718/// The central risk here is coordinate/cell arithmetic: `cells_for_rect`
719/// divides by `cell_size` and floors, so an item and a query rect that
720/// truly intersect could in principle land in disjoint cell sets if
721/// `f32` rounding shifts a boundary coordinate by even one ULP at large
722/// magnitude — that would be a *missed hit* (`query` returning fewer
723/// items than a brute-force scan), which is worse than the documented
724/// over-reporting the trait contract explicitly allows. `cargo-fuzz`
725/// needs nightly + libfuzzer-sys, which isn't assumed here; proptest
726/// with a few hundred to a couple thousand iterations per property
727/// (override via `PROPTEST_CASES=N`) gives the "never misses a hit /
728/// never panics on weird input" coverage a fuzz corpus would, plus
729/// shrinking to a minimal counterexample. Generators are hand-written
730/// per-file (no `prop_compose!`/`Arbitrary`), matching the sibling
731/// `../text-typeset` / `../text-document` proptest suites.
732#[cfg(test)]
733mod proptests {
734    use std::collections::{BTreeSet, HashMap};
735
736    use proptest::prelude::*;
737
738    use super::*;
739
740    fn id(n: u64) -> ItemId {
741        ItemId(n)
742    }
743
744    fn rects_intersect(a: Rect, b: Rect) -> bool {
745        a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height
746    }
747
748    // The previous (incomplete) investigation swept exactly these cell
749    // sizes looking for a boundary/precision bug — keep the same set so
750    // this suite actually covers the suspect region instead of picking
751    // fresh, possibly-safer values.
752    fn arb_cell_size() -> impl Strategy<Value = f32> {
753        prop_oneof![
754            Just(1.0_f32),
755            Just(8.0_f32),
756            Just(50.0_f32),
757            Just(64.0_f32),
758            Just(256.0_f32),
759        ]
760    }
761
762    // A coordinate generator biased three ways at once: (a) exact
763    // multiples of the cell size — sits exactly ON a cell boundary;
764    // (b) a multiple plus a tiny nudge — sits just inside/outside a
765    // boundary, where `floor` is most sensitive to `f32` rounding;
766    // (c) plain large-magnitude values, unrelated to any boundary, at
767    // the same magnitudes the prior investigation swept
768    // (1 / 100 / 5_000 / 100_000 / 1_000_000, both signs). Negative
769    // coordinates are included throughout since the grid has no
770    // origin restriction.
771    fn arb_coord(cell_size: f32) -> impl Strategy<Value = f32> {
772        let cs = cell_size;
773        prop_oneof![
774            3 => (-2000i32..2000i32).prop_map(move |k| k as f32 * cs),
775            3 => (-2000i32..2000i32, -3i32..=3i32)
776                .prop_map(move |(k, e)| k as f32 * cs + e as f32 * 1.0e-3),
777            2 => prop_oneof![
778                Just(1.0_f32),
779                Just(-1.0_f32),
780                Just(100.0_f32),
781                Just(-100.0_f32),
782                Just(5_000.0_f32),
783                Just(-5_000.0_f32),
784                Just(100_000.0_f32),
785                Just(-100_000.0_f32),
786                Just(1_000_000.0_f32),
787                Just(-1_000_000.0_f32),
788            ],
789            2 => -1_000_000.0f32..1_000_000.0f32,
790        ]
791    }
792
793    // Zero and near-zero extents are the "single point" edge case
794    // documented in `cell_span_for_rect`'s width/height <= 0.0 branch;
795    // we also want ordinary and very large extents.
796    /// Cap on how many cells a single generated item may span per axis, for
797    /// the GENERATORS that stress the ordinary bucketed fast path (item
798    /// count vs. query correctness, insertion-order independence, etc.).
799    ///
800    /// Historical note — this constant is the reason the resource-
801    /// exhaustion bug was caught at all: `GridHashIndex::insert` used to
802    /// allocate one `(i32, i32)` per covered cell (see `cells_for_rect`)
803    /// with NO upper bound, so an unconstrained extent combined with the
804    /// smallest `cell_size` this suite generates (1.0) asked for
805    /// `1e6 * 1e6 = 1e12` cells and OOMed the machine before any assertion
806    /// ran — which is how this suite came to take a developer's
807    /// workstation down. That was a REAL BUG in `cells_for_rect`, not
808    /// merely a bad generator: a scene that adds one very large item (a
809    /// backdrop, a full-document canvas rect) with a small `cell_size`
810    /// would hang or OOM in production, no adversarial input required.
811    ///
812    /// `GridHashIndex` now fixes this directly: an item (or a query rect)
813    /// whose span exceeds `MAX_CELLS_PER_ITEM` (1024) is never bucketed
814    /// cell-by-cell — see the module doc's "Oversized items" section and
815    /// the dedicated properties below (10–13) that exercise that path
816    /// specifically, including the exact 1e6-at-`cell_size:1.0` incident
817    /// input. So it would now be SAFE to relax or remove this cap — no
818    /// combination of `cell_size` and extent can OOM or overflow the index
819    /// anymore, per the arithmetic on `MAX_CELLS_PER_ITEM`.
820    ///
821    /// It is kept at 64 anyway, deliberately, for a coverage reason
822    /// unrelated to safety: `arb_extent`'s "a few cells" branch draws
823    /// `(1.0..MAX_CELLS_PER_AXIS)`, and properties 1, 2, 3, 5, 6, 8, 9 lean
824    /// on that branch to stress the NORMAL bucketed path's boundary/
825    /// precision arithmetic (the 2–64-cells-per-axis regime is where an
826    /// off-by-one or an f32 rounding slip in `cells_for_rect` would show
827    /// up). Raising this constant to, say, `1_000_000.0` to match
828    /// `arb_coord`'s large-magnitude branch would make that "a few cells"
829    /// branch draw an almost-always-oversized value instead (a uniform
830    /// draw over `[1, 1e6)` puts less than 0.1% of its mass below 1024),
831    /// silently starving those seven properties of the multi-cell-bucketing
832    /// coverage they exist for. Rather than dilute that shared generator,
833    /// the oversized path gets its own dedicated generators in properties
834    /// 10–13 below, which is why this constant is unchanged.
835    const MAX_CELLS_PER_AXIS: f32 = 64.0;
836
837    /// Extents are generated RELATIVE to `cell_size` so an item never spans
838    /// more than `MAX_CELLS_PER_AXIS` cells. Large absolute *coordinates* are
839    /// still generated by `arb_coord` — those are the interesting case for
840    /// the f32-precision-at-magnitude question, and they are cheap because a
841    /// far-away rect is still only a few cells.
842    fn arb_extent(cell_size: f32) -> impl Strategy<Value = f32> {
843        let cs = cell_size;
844        let max = cs * MAX_CELLS_PER_AXIS;
845        prop_oneof![
846            // Degenerate extents: a zero-size rect must still bucket to
847            // exactly one cell, and a sub-pixel one must not round to zero.
848            Just(0.0_f32),
849            Just(0.001_f32),
850            // Sub-cell, exactly one cell, and a few cells — the boundary
851            // arithmetic in `cells_for_rect` lives here.
852            (0.01f32..1.0f32).prop_map(move |f| f * cs),
853            Just(cs),
854            (1.0f32..MAX_CELLS_PER_AXIS).prop_map(move |f| f * cs),
855            Just(max),
856        ]
857    }
858
859    /// A `cell_size` paired with two rects generated FOR THAT cell size.
860    ///
861    /// Drawing the grid's `cell_size` and a rect's extent from two
862    /// INDEPENDENT `arb_cell_size()` draws is a trap: a rect sized for a
863    /// 256 px grid (extent up to `256 * MAX_CELLS_PER_AXIS`) inserted into a
864    /// 1 px grid spans ~268 million cells and allocates gigabytes. Every
865    /// property that builds a grid and inserts into it must take its rects
866    /// from here so the two stay coupled.
867    fn arb_grid_and_two_rects() -> impl Strategy<Value = (f32, Rect, Rect)> {
868        arb_cell_size().prop_flat_map(|cs| (Just(cs), arb_rect(cs), arb_rect(cs)))
869    }
870
871    fn arb_rect(cell_size: f32) -> impl Strategy<Value = Rect> {
872        (
873            arb_coord(cell_size),
874            arb_coord(cell_size),
875            arb_extent(cell_size),
876            arb_extent(cell_size),
877        )
878            .prop_map(|(x, y, width, height)| Rect::new(x, y, width, height))
879    }
880
881    // (cell_size, items, query_rect) sharing one cell_size so the
882    // boundary-biased coordinates in `items` and `query_rect` actually
883    // land relative to the same grid.
884    fn arb_layout_and_query() -> impl Strategy<Value = (f32, Vec<(u64, Rect)>, Rect)> {
885        arb_cell_size().prop_flat_map(|cs| {
886            (
887                Just(cs),
888                prop::collection::vec((0u64..64, arb_rect(cs)), 0..40),
889                arb_rect(cs),
890            )
891        })
892    }
893
894    fn build_index(
895        cell_size: f32,
896        items: &[(u64, Rect)],
897    ) -> (GridHashIndex, HashMap<ItemId, Rect>) {
898        let mut g = GridHashIndex::new(cell_size);
899        let mut model: HashMap<ItemId, Rect> = HashMap::new();
900        for &(n, r) in items {
901            let i = id(n);
902            g.insert(i, r);
903            model.insert(i, r);
904        }
905        (g, model)
906    }
907
908    // ── 1. query, narrowed by a true intersection test, equals a brute-force scan ──
909    proptest! {
910        #![proptest_config(ProptestConfig { cases: 1024, ..ProptestConfig::default() })]
911        #[test]
912        fn query_narrowed_matches_brute_force((cell_size, items, q) in arb_layout_and_query()) {
913            let (g, model) = build_index(cell_size, &items);
914
915            let from_index: BTreeSet<ItemId> = g.query(q).into_iter().collect();
916            let from_brute: BTreeSet<ItemId> = model
917                .iter()
918                .filter(|(_, r)| rects_intersect(**r, q))
919                .map(|(i, _)| *i)
920                .collect();
921
922            // The index may over-report (cell fan-out false positives)
923            // but must never under-report a true intersection — a
924            // missed hit is a lost click.
925            prop_assert!(
926                from_brute.is_subset(&from_index),
927                "cell_size={} query={:?}: index missed true intersections {:?} (model={:?})",
928                cell_size,
929                q,
930                from_brute.difference(&from_index).collect::<Vec<_>>(),
931                model,
932            );
933
934            let narrowed: BTreeSet<ItemId> = from_index
935                .iter()
936                .copied()
937                .filter(|i| rects_intersect(model[i], q))
938                .collect();
939            prop_assert_eq!(
940                &narrowed, &from_brute,
941                "cell_size={} query={:?}: narrowed index result {:?} != brute force {:?}",
942                cell_size, q, narrowed, from_brute,
943            );
944        }
945    }
946
947    // ── 2. contains/len track a HashMap<ItemId, Rect> model over insert/remove/re-insert ──
948    #[derive(Debug, Clone)]
949    enum Op {
950        Insert(u64, Rect),
951        Remove(u64),
952    }
953
954    fn arb_op(cell_size: f32) -> impl Strategy<Value = Op> {
955        prop_oneof![
956            3 => (0u64..12, arb_rect(cell_size)).prop_map(|(n, r)| Op::Insert(n, r)),
957            1 => (0u64..12).prop_map(Op::Remove),
958        ]
959    }
960
961    proptest! {
962        #![proptest_config(ProptestConfig { cases: 512, ..ProptestConfig::default() })]
963        #[test]
964        fn contains_and_len_track_a_hashmap_model(
965            (cell_size, ops) in arb_cell_size()
966                .prop_flat_map(|cs| (Just(cs), prop::collection::vec(arb_op(cs), 0..80)))
967        ) {
968            let mut g = GridHashIndex::new(cell_size);
969            let mut model: HashMap<ItemId, Rect> = HashMap::new();
970
971            for op in &ops {
972                match *op {
973                    Op::Insert(n, r) => {
974                        g.insert(id(n), r);
975                        model.insert(id(n), r);
976                    }
977                    Op::Remove(n) => {
978                        g.remove(id(n));
979                        model.remove(&id(n));
980                    }
981                }
982                prop_assert_eq!(
983                    g.len(), model.len(),
984                    "after {:?}: index len {} != model len {} (ops so far: {:?})",
985                    op, g.len(), model.len(), ops,
986                );
987            }
988
989            // Full-state check over the bounded id range every op draws
990            // from, since there's no "list all ids" accessor.
991            for n in 0..12u64 {
992                prop_assert_eq!(
993                    g.contains(id(n)), model.contains_key(&id(n)),
994                    "id {} contains()={} but model has_key={} after ops {:?}",
995                    n, g.contains(id(n)), model.contains_key(&id(n)), ops,
996                );
997            }
998        }
999    }
1000
1001    // ── 3. no empty cell buckets linger after any insert/remove sequence ──
1002    proptest! {
1003        #![proptest_config(ProptestConfig { cases: 512, ..ProptestConfig::default() })]
1004        #[test]
1005        fn no_empty_bucket_lingers_after_removals(
1006            (cell_size, ops) in arb_cell_size()
1007                .prop_flat_map(|cs| (Just(cs), prop::collection::vec(arb_op(cs), 0..80)))
1008        ) {
1009            let mut g = GridHashIndex::new(cell_size);
1010            for op in &ops {
1011                match *op {
1012                    Op::Insert(n, r) => g.insert(id(n), r),
1013                    Op::Remove(n) => g.remove(id(n)),
1014                }
1015                prop_assert!(
1016                    !g.has_empty_bucket(),
1017                    "empty bucket left behind after {:?} (ops so far: {:?})",
1018                    op, ops,
1019                );
1020            }
1021        }
1022    }
1023
1024    // ── 4. re-inserting an existing id under a new rect fully relocates it ──
1025    proptest! {
1026        #![proptest_config(ProptestConfig { cases: 512, ..ProptestConfig::default() })]
1027        #[test]
1028        fn reinsert_relocates_with_no_ghost_at_old_rect(
1029            cell_size in arb_cell_size(),
1030            old_n in 0u64..5,
1031            new_n in 0u64..5,
1032        ) {
1033            // Keep old/new rects far enough apart (relative to cell_size)
1034            // that they cannot share a cell, so a stale entry at the old
1035            // location is unambiguously observable via query.
1036            let old_rect = Rect::new(0.0, 0.0, cell_size.min(50.0), cell_size.min(50.0));
1037            let far = cell_size * 10_000.0;
1038            let new_rect = Rect::new(far, far, cell_size.min(50.0), cell_size.min(50.0));
1039
1040            let mut g = GridHashIndex::new(cell_size);
1041            g.insert(id(old_n), old_rect);
1042            g.insert(id(new_n), new_rect);
1043            // Re-insert new_n's id (which may or may not equal old_n) at
1044            // the far location again isn't the point here; instead move
1045            // one id from the old spot to the new spot and check the old
1046            // spot lost it.
1047            let moved = id(old_n);
1048            g.insert(moved, new_rect);
1049
1050            let at_old = g.query(old_rect);
1051            prop_assert!(
1052                !at_old.contains(&moved),
1053                "ghost entry: {:?} still found at the old rect {:?} after re-insert to {:?}",
1054                moved, old_rect, new_rect,
1055            );
1056            let at_new = g.query(new_rect);
1057            prop_assert!(
1058                at_new.contains(&moved),
1059                "{:?} not found at its new rect {:?} after re-insert",
1060                moved, new_rect,
1061            );
1062        }
1063    }
1064
1065    // ── 5. query result set does not depend on insertion order ──
1066    //
1067    // The permutation is produced by the decorate-sort-undecorate trick —
1068    // proptest hands us a `Vec<u32>` of sort keys the same length as the
1069    // (deduped) item list, we zip+sort by key — rather than a hand-rolled
1070    // RNG/shuffle: all the randomness comes from the strategy, and
1071    // shrinking still applies to the keys like any other generated value.
1072    fn arb_order_independence_case()
1073    -> impl Strategy<Value = (f32, Vec<(u64, Rect)>, Rect, Vec<u32>)> {
1074        arb_layout_and_query().prop_flat_map(|(cs, items, q)| {
1075            // Dedup ids first (later duplicate wins, matching HashMap
1076            // insert semantics) so both orderings insert the same final
1077            // id->rect mapping. Done here (inside the strategy) so the
1078            // shuffle-key vector can be sized to match exactly.
1079            let mut model: Vec<(u64, Rect)> = Vec::new();
1080            for &(n, r) in &items {
1081                if let Some(slot) = model.iter_mut().find(|(mn, _)| *mn == n) {
1082                    slot.1 = r;
1083                } else {
1084                    model.push((n, r));
1085                }
1086            }
1087            let n = model.len();
1088            (
1089                Just(cs),
1090                Just(model),
1091                Just(q),
1092                prop::collection::vec(any::<u32>(), n),
1093            )
1094        })
1095    }
1096
1097    proptest! {
1098        #![proptest_config(ProptestConfig { cases: 256, ..ProptestConfig::default() })]
1099        #[test]
1100        fn query_is_independent_of_insertion_order(
1101            (cell_size, model, q, keys) in arb_order_independence_case()
1102        ) {
1103            let forward = model.clone();
1104            let mut decorated: Vec<((u64, Rect), u32)> =
1105                model.iter().copied().zip(keys.iter().copied()).collect();
1106            decorated.sort_by_key(|(_, k)| *k);
1107            let shuffled: Vec<(u64, Rect)> = decorated.into_iter().map(|(item, _)| item).collect();
1108
1109            let mut g_forward = GridHashIndex::new(cell_size);
1110            for &(n, r) in &forward {
1111                g_forward.insert(id(n), r);
1112            }
1113            let mut g_shuffled = GridHashIndex::new(cell_size);
1114            for &(n, r) in &shuffled {
1115                g_shuffled.insert(id(n), r);
1116            }
1117
1118            let forward_hits: BTreeSet<ItemId> = g_forward.query(q).into_iter().collect();
1119            let shuffled_hits: BTreeSet<ItemId> = g_shuffled.query(q).into_iter().collect();
1120            prop_assert_eq!(
1121                &forward_hits, &shuffled_hits,
1122                "insertion order changed query({:?}) result: forward {:?} != shuffled {:?} (items order: {:?} vs {:?})",
1123                q, forward_hits, shuffled_hits, forward, shuffled,
1124            );
1125        }
1126    }
1127
1128    // ── 6. inserting the same id with identical bounds twice is a no-op ──
1129    proptest! {
1130        #![proptest_config(ProptestConfig { cases: 512, ..ProptestConfig::default() })]
1131        #[test]
1132        fn reinserting_identical_bounds_is_idempotent(
1133            n in 0u64..20,
1134            (cell_size, r, q) in arb_grid_and_two_rects(),
1135        ) {
1136            let mut g = GridHashIndex::new(cell_size);
1137            g.insert(id(n), r);
1138            let len_before = g.len();
1139            let cells_before = g.cell_count();
1140            let hits_before: BTreeSet<ItemId> = g.query(q).into_iter().collect();
1141
1142            g.insert(id(n), r);
1143
1144            prop_assert_eq!(g.len(), len_before, "len changed after re-inserting identical bounds for {:?}", id(n));
1145            prop_assert_eq!(
1146                g.cell_count(), cells_before,
1147                "cell_count changed after re-inserting identical bounds for {:?}", id(n)
1148            );
1149            let hits_after: BTreeSet<ItemId> = g.query(q).into_iter().collect();
1150            prop_assert_eq!(
1151                &hits_after, &hits_before,
1152                "query({:?}) changed after re-inserting identical bounds for {:?}: {:?} != {:?}",
1153                q, id(n), hits_after, hits_before,
1154            );
1155        }
1156    }
1157
1158    // ── 7. GridHashIndex::new always clamps cell_size to at least 1.0 ──
1159    proptest! {
1160        #[test]
1161        fn new_clamps_cell_size_to_at_least_one(raw in -1.0e7f32..1.0e7f32) {
1162            let g = GridHashIndex::new(raw);
1163            prop_assert!(
1164                g.cell_size() >= 1.0,
1165                "cell_size({}) = {} is below the documented floor of 1.0",
1166                raw, g.cell_size(),
1167            );
1168            // And it should track the input exactly whenever the input
1169            // already satisfies the floor.
1170            if raw >= 1.0 {
1171                prop_assert_eq!(
1172                    g.cell_size(), raw,
1173                    "cell_size({}) was altered even though it already satisfied the floor", raw,
1174                );
1175            }
1176        }
1177    }
1178
1179    // ── 8. query never panics, whatever rect it is asked about ──
1180    proptest! {
1181        #![proptest_config(ProptestConfig { cases: 1024, ..ProptestConfig::default() })]
1182        #[test]
1183        fn query_never_panics((cell_size, items, q) in arb_layout_and_query()) {
1184            let (g, _model) = build_index(cell_size, &items);
1185            // The property under test is "doesn't panic"; a successful
1186            // return (of any Vec, including empty) is the pass condition.
1187            let _ = g.query(q);
1188        }
1189    }
1190
1191    // ── 9. widening the query rect can only add hits, never drop any ──
1192    proptest! {
1193        #![proptest_config(ProptestConfig { cases: 512, ..ProptestConfig::default() })]
1194        #[test]
1195        fn query_is_monotonic_in_rect_containment(
1196            (cell_size, items, outer) in arb_layout_and_query()
1197                .prop_map(|(cs, items, outer)| (cs, items, outer)),
1198            // Fractions (of outer's width/height) used to carve an inner
1199            // rect that's fully contained in `outer`.
1200            fx in 0.0f32..1.0,
1201            fy in 0.0f32..1.0,
1202            fw in 0.0f32..1.0,
1203            fh in 0.0f32..1.0,
1204        ) {
1205            // Guard against a degenerate outer rect (width/height 0 or
1206            // negative would make "inner ⊆ outer" ill-defined here).
1207            prop_assume!(outer.width > 0.0 && outer.height > 0.0);
1208
1209            let (g, _model) = build_index(cell_size, &items);
1210
1211            let inner_x = outer.x + fx * outer.width;
1212            let inner_y = outer.y + fy * outer.height;
1213            let inner_w = fw * (outer.right() - inner_x);
1214            let inner_h = fh * (outer.bottom() - inner_y);
1215            let inner = Rect::new(inner_x, inner_y, inner_w.max(0.0), inner_h.max(0.0));
1216
1217            let inner_hits: BTreeSet<ItemId> = g.query(inner).into_iter().collect();
1218            let outer_hits: BTreeSet<ItemId> = g.query(outer).into_iter().collect();
1219
1220            prop_assert!(
1221                inner_hits.is_subset(&outer_hits),
1222                "inner rect {:?} (inside outer {:?}) hit {:?} not returned by the wider query: {:?}",
1223                inner, outer,
1224                inner_hits.difference(&outer_hits).collect::<Vec<_>>(),
1225                outer_hits,
1226            );
1227        }
1228    }
1229
1230    // Side length (in logical pixels) of a square rect guaranteed to exceed
1231    // MAX_CELLS_PER_ITEM at the given cell_size, for ANY cell_size this
1232    // suite's arb_cell_size() produces (1.0..=256.0): a
1233    // `sqrt(MAX_CELLS_PER_ITEM)`-cells-per-axis square already sits right at
1234    // the cap, so doubling it clears the cap comfortably regardless of
1235    // f32-rounding at the boundary.
1236    fn oversized_side_for(cell_size: f32) -> f32 {
1237        cell_size * (MAX_CELLS_PER_ITEM as f32).sqrt() * 2.0
1238    }
1239
1240    // ── 10. an oversized item is still found by a query that truly intersects it ──
1241    proptest! {
1242        #![proptest_config(ProptestConfig { cases: 256, ..ProptestConfig::default() })]
1243        #[test]
1244        fn oversized_item_is_found_by_a_query_that_truly_intersects_it(
1245            cell_size in arb_cell_size(),
1246            n in 0u64..8,
1247            // Fractions used to carve a query rect that is a strict
1248            // sub-rect of (hence guaranteed to truly intersect) the
1249            // oversized item's bounds.
1250            fx in 0.0f32..1.0,
1251            fy in 0.0f32..1.0,
1252            fw in 0.01f32..1.0,
1253            fh in 0.01f32..1.0,
1254        ) {
1255            let side = oversized_side_for(cell_size);
1256            let big_rect = Rect::new(0.0, 0.0, side, side);
1257
1258            let mut g = GridHashIndex::new(cell_size);
1259            let item = id(n);
1260            g.insert(item, big_rect);
1261            prop_assert!(
1262                g.is_oversized(item),
1263                "cell_size={} side={}: item should have been classified oversized (cap={})",
1264                cell_size, side, MAX_CELLS_PER_ITEM,
1265            );
1266
1267            // A rect confined to [0, side/2) x [0, side/2) with modest
1268            // width/height is always a strict sub-rect of big_rect, hence
1269            // always truly intersects it.
1270            let qx = fx * side * 0.5;
1271            let qy = fy * side * 0.5;
1272            let qw = (fw * side * 0.25).max(0.01);
1273            let qh = (fh * side * 0.25).max(0.01);
1274            let query_rect = Rect::new(qx, qy, qw, qh);
1275
1276            let hits = g.query(query_rect);
1277            prop_assert!(
1278                hits.contains(&item),
1279                "cell_size={} query={:?}: oversized item {:?} (bounds {:?}) missed by a query \
1280                 that truly intersects it — a lost click through the oversized path",
1281                cell_size, query_rect, item, big_rect,
1282            );
1283        }
1284    }
1285
1286    // ── 11. re-insert moves an item between normal and oversized with no ghost in either representation ──
1287    proptest! {
1288        #![proptest_config(ProptestConfig { cases: 256, ..ProptestConfig::default() })]
1289        #[test]
1290        fn reinsert_moves_between_normal_and_oversized_with_no_ghost(
1291            cell_size in arb_cell_size(),
1292            n in 0u64..8,
1293        ) {
1294            let small_side = cell_size.min(10.0);
1295            let small_rect = Rect::new(0.0, 0.0, small_side, small_side);
1296            let side = oversized_side_for(cell_size);
1297            // Far enough from the origin that big_rect can never overlap
1298            // small_rect, so any hit at the "wrong" location is
1299            // unambiguously a ghost, not a coincidental true intersection.
1300            let far = cell_size * 10_000.0;
1301            let big_rect = Rect::new(far, far, side, side);
1302
1303            let mut g = GridHashIndex::new(cell_size);
1304            let item = id(n);
1305
1306            // 1. Normal path.
1307            g.insert(item, small_rect);
1308            prop_assert!(!g.is_oversized(item), "expected the small rect to bucket normally");
1309            prop_assert!(g.query(small_rect).contains(&item));
1310
1311            // 2. normal -> oversized.
1312            g.insert(item, big_rect);
1313            prop_assert!(
1314                g.is_oversized(item),
1315                "cell_size={} side={}: expected the big rect to be classified oversized",
1316                cell_size, side,
1317            );
1318            prop_assert!(
1319                !g.query(small_rect).contains(&item),
1320                "ghost: {:?} still found at the old (normally-bucketed) rect {:?} after moving \
1321                 to the oversized rect {:?}",
1322                item, small_rect, big_rect,
1323            );
1324            prop_assert!(g.query(big_rect).contains(&item));
1325
1326            // 3. oversized -> normal.
1327            g.insert(item, small_rect);
1328            prop_assert!(
1329                !g.is_oversized(item),
1330                "expected re-insert with a small rect to leave the oversized representation"
1331            );
1332            prop_assert!(
1333                !g.query(big_rect).contains(&item),
1334                "ghost: {:?} still found at the old oversized rect {:?} after moving back to {:?}",
1335                item, big_rect, small_rect,
1336            );
1337            prop_assert!(g.query(small_rect).contains(&item));
1338            prop_assert_eq!(g.len(), 1, "exactly one logical item should exist throughout");
1339        }
1340    }
1341
1342    // ── 12. contains/len still track a HashMap model when oversized items are mixed in ──
1343    fn arb_rect_possibly_oversized(cell_size: f32) -> impl Strategy<Value = Rect> {
1344        prop_oneof![
1345            3 => arb_rect(cell_size),
1346            1 => {
1347                let side = oversized_side_for(cell_size);
1348                (-10i32..10i32, -10i32..10i32).prop_map(move |(kx, ky)| {
1349                    Rect::new(kx as f32 * side, ky as f32 * side, side, side)
1350                })
1351            },
1352        ]
1353    }
1354
1355    fn arb_op_possibly_oversized(cell_size: f32) -> impl Strategy<Value = Op> {
1356        prop_oneof![
1357            3 => (0u64..12, arb_rect_possibly_oversized(cell_size)).prop_map(|(n, r)| Op::Insert(n, r)),
1358            1 => (0u64..12).prop_map(Op::Remove),
1359        ]
1360    }
1361
1362    proptest! {
1363        #![proptest_config(ProptestConfig { cases: 512, ..ProptestConfig::default() })]
1364        #[test]
1365        fn contains_and_len_track_a_hashmap_model_with_oversized_items_mixed_in(
1366            (cell_size, ops) in arb_cell_size()
1367                .prop_flat_map(|cs| (Just(cs), prop::collection::vec(arb_op_possibly_oversized(cs), 0..80)))
1368        ) {
1369            let mut g = GridHashIndex::new(cell_size);
1370            let mut model: HashMap<ItemId, Rect> = HashMap::new();
1371
1372            for op in &ops {
1373                match *op {
1374                    Op::Insert(n, r) => {
1375                        g.insert(id(n), r);
1376                        model.insert(id(n), r);
1377                    }
1378                    Op::Remove(n) => {
1379                        g.remove(id(n));
1380                        model.remove(&id(n));
1381                    }
1382                }
1383                prop_assert_eq!(
1384                    g.len(), model.len(),
1385                    "after {:?} (oversized items mixed in): index len {} != model len {} \
1386                     (ops so far: {:?})",
1387                    op, g.len(), model.len(), ops,
1388                );
1389            }
1390
1391            // Full-state check over the bounded id range every op draws
1392            // from, since there's no "list all ids" accessor.
1393            for n in 0..12u64 {
1394                prop_assert_eq!(
1395                    g.contains(id(n)), model.contains_key(&id(n)),
1396                    "id {} contains()={} but model has_key={} after ops {:?} \
1397                     (oversized items mixed in)",
1398                    n, g.contains(id(n)), model.contains_key(&id(n)), ops,
1399                );
1400            }
1401        }
1402    }
1403
1404    // ── 13. a single item's cell footprint never exceeds MAX_CELLS_PER_ITEM, for any extent ──
1405    //
1406    // This is the property that would have prevented the reboots: it
1407    // exercises the exact incident input (a 1e6 extent at cell_size 1.0,
1408    // via the `Just(1_000_000.0_f32)` branch below) alongside other large
1409    // extents and the suite's usual cell sizes, and checks the observable
1410    // proxy for "did this allocate a pathological number of cells" —
1411    // `cell_count()` staying within the cap regardless of how the item was
1412    // classified.
1413    proptest! {
1414        #![proptest_config(ProptestConfig { cases: 512, ..ProptestConfig::default() })]
1415        #[test]
1416        fn a_single_items_cell_footprint_never_exceeds_the_per_item_cap(
1417            cell_size in arb_cell_size(),
1418            extent in prop_oneof![
1419                Just(1_000_000.0_f32),
1420                Just(500_000.0_f32),
1421                Just(100_000.0_f32),
1422                (1.0f32..MAX_CELLS_PER_ITEM as f32 * 4.0),
1423            ],
1424        ) {
1425            let mut g = GridHashIndex::new(cell_size);
1426            let item = id(1);
1427            g.insert(item, Rect::new(0.0, 0.0, extent, extent));
1428
1429            prop_assert!(
1430                g.cell_count() <= MAX_CELLS_PER_ITEM as usize,
1431                "cell_size={} extent={}: cell_count()={} exceeds the per-item cap {} — the \
1432                 resource-exhaustion bug is back",
1433                cell_size, extent, g.cell_count(), MAX_CELLS_PER_ITEM,
1434            );
1435            prop_assert!(g.contains(item));
1436            prop_assert_eq!(g.len(), 1);
1437        }
1438    }
1439}