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    #[test]
584    fn perf_microbench_insert_query() {
585        // Not a strict bound — just a smoke test that 1000 inserts
586        // followed by 1000 queries run in sub-millisecond time on
587        // any reasonable hardware. If this regresses to seconds,
588        // something has gone catastrophically wrong with the
589        // bucketing math.
590        use std::time::Instant;
591        let mut g = GridHashIndex::default();
592        let start = Instant::now();
593        for n in 0..1000u64 {
594            let x = ((n * 37) % 5000) as f32;
595            let y = ((n * 53) % 5000) as f32;
596            g.insert(ItemId(n + 1), Rect::new(x, y, 40.0, 40.0));
597        }
598        let insert_ms = start.elapsed().as_millis();
599        let start = Instant::now();
600        let mut total = 0;
601        for n in 0..1000u64 {
602            let x = ((n * 7) % 5000) as f32;
603            let y = ((n * 11) % 5000) as f32;
604            total += g.query(Rect::new(x, y, 100.0, 100.0)).len();
605        }
606        let query_ms = start.elapsed().as_millis();
607        // Loose bound (debug builds): both should easily finish
608        // under 100 ms each on any developer laptop.
609        assert!(
610            insert_ms < 200,
611            "1000 inserts took {} ms — perf regression?",
612            insert_ms
613        );
614        assert!(
615            query_ms < 200,
616            "1000 queries took {} ms — perf regression?",
617            query_ms
618        );
619        // Sanity: queries actually returned something.
620        assert!(total > 0);
621    }
622
623    fn rects_intersect(a: Rect, b: Rect) -> bool {
624        a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height
625    }
626
627    #[test]
628    fn oversized_1e6_extent_at_cell_size_one_never_allocates_the_pathological_cell_count() {
629        // The literal incident input: a 1e6 x 1e6 logical-pixel rect at
630        // cell_size 1.0 used to make `cells_for_rect` reserve
631        // `(1e6+1) * (1e6+1) ~= 1e12` cells — about 8 TB for a
632        // `Vec<(i32, i32)>` alone (and risked an i32*i32 overflow along
633        // the way). It must now be classified oversized and never touch
634        // `cells`/`item_cells` at all.
635        let mut g = GridHashIndex::new(1.0);
636        let item = id(1);
637        g.insert(item, Rect::new(0.0, 0.0, 1_000_000.0, 1_000_000.0));
638        assert!(
639            g.is_oversized(item),
640            "the reboot-inducing input must be classified oversized"
641        );
642        assert!(g.contains(item));
643        assert_eq!(g.len(), 1);
644        assert_eq!(
645            g.cell_count(),
646            0,
647            "an oversized item must not touch the cell buckets"
648        );
649
650        // It must still be findable by a query that truly intersects it.
651        let hits = g.query(Rect::new(500.0, 500.0, 10.0, 10.0));
652        assert_eq!(hits, vec![item]);
653    }
654}
655
656/// Property-based tests for [`GridHashIndex`].
657///
658/// `GridHashIndex` is `pub(crate)` (see `pub(crate) mod index;` in
659/// `lib.rs`), so a `tests/*.rs` integration file cannot reach it —
660/// this suite lives inline, after the example-based `mod tests` above,
661/// per house style.
662///
663/// The central risk here is coordinate/cell arithmetic: `cells_for_rect`
664/// divides by `cell_size` and floors, so an item and a query rect that
665/// truly intersect could in principle land in disjoint cell sets if
666/// `f32` rounding shifts a boundary coordinate by even one ULP at large
667/// magnitude — that would be a *missed hit* (`query` returning fewer
668/// items than a brute-force scan), which is worse than the documented
669/// over-reporting the trait contract explicitly allows. `cargo-fuzz`
670/// needs nightly + libfuzzer-sys, which isn't assumed here; proptest
671/// with a few hundred to a couple thousand iterations per property
672/// (override via `PROPTEST_CASES=N`) gives the "never misses a hit /
673/// never panics on weird input" coverage a fuzz corpus would, plus
674/// shrinking to a minimal counterexample. Generators are hand-written
675/// per-file (no `prop_compose!`/`Arbitrary`), matching the sibling
676/// `../text-typeset` / `../text-document` proptest suites.
677#[cfg(test)]
678mod proptests {
679    use std::collections::{BTreeSet, HashMap};
680
681    use proptest::prelude::*;
682
683    use super::*;
684
685    fn id(n: u64) -> ItemId {
686        ItemId(n)
687    }
688
689    fn rects_intersect(a: Rect, b: Rect) -> bool {
690        a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height
691    }
692
693    // The previous (incomplete) investigation swept exactly these cell
694    // sizes looking for a boundary/precision bug — keep the same set so
695    // this suite actually covers the suspect region instead of picking
696    // fresh, possibly-safer values.
697    fn arb_cell_size() -> impl Strategy<Value = f32> {
698        prop_oneof![
699            Just(1.0_f32),
700            Just(8.0_f32),
701            Just(50.0_f32),
702            Just(64.0_f32),
703            Just(256.0_f32),
704        ]
705    }
706
707    // A coordinate generator biased three ways at once: (a) exact
708    // multiples of the cell size — sits exactly ON a cell boundary;
709    // (b) a multiple plus a tiny nudge — sits just inside/outside a
710    // boundary, where `floor` is most sensitive to `f32` rounding;
711    // (c) plain large-magnitude values, unrelated to any boundary, at
712    // the same magnitudes the prior investigation swept
713    // (1 / 100 / 5_000 / 100_000 / 1_000_000, both signs). Negative
714    // coordinates are included throughout since the grid has no
715    // origin restriction.
716    fn arb_coord(cell_size: f32) -> impl Strategy<Value = f32> {
717        let cs = cell_size;
718        prop_oneof![
719            3 => (-2000i32..2000i32).prop_map(move |k| k as f32 * cs),
720            3 => (-2000i32..2000i32, -3i32..=3i32)
721                .prop_map(move |(k, e)| k as f32 * cs + e as f32 * 1.0e-3),
722            2 => prop_oneof![
723                Just(1.0_f32),
724                Just(-1.0_f32),
725                Just(100.0_f32),
726                Just(-100.0_f32),
727                Just(5_000.0_f32),
728                Just(-5_000.0_f32),
729                Just(100_000.0_f32),
730                Just(-100_000.0_f32),
731                Just(1_000_000.0_f32),
732                Just(-1_000_000.0_f32),
733            ],
734            2 => -1_000_000.0f32..1_000_000.0f32,
735        ]
736    }
737
738    // Zero and near-zero extents are the "single point" edge case
739    // documented in `cells_for_rect`'s width/height <= 0.0 branch; we
740    // also want ordinary and very large extents.
741    /// Cap on how many cells a single generated item may span per axis, for
742    /// the GENERATORS that stress the ordinary bucketed fast path (item
743    /// count vs. query correctness, insertion-order independence, etc.).
744    ///
745    /// Historical note — this constant is the reason the resource-
746    /// exhaustion bug was caught at all: `GridHashIndex::insert` used to
747    /// allocate one `(i32, i32)` per covered cell (see `cells_for_rect`)
748    /// with NO upper bound, so an unconstrained extent combined with the
749    /// smallest `cell_size` this suite generates (1.0) asked for
750    /// `1e6 * 1e6 = 1e12` cells and OOMed the machine before any assertion
751    /// ran — which is how this suite came to take a developer's
752    /// workstation down. That was a REAL BUG in `cells_for_rect`, not
753    /// merely a bad generator: a scene that adds one very large item (a
754    /// backdrop, a full-document canvas rect) with a small `cell_size`
755    /// would hang or OOM in production, no adversarial input required.
756    ///
757    /// `GridHashIndex` now fixes this directly: an item (or a query rect)
758    /// whose span exceeds `MAX_CELLS_PER_ITEM` (1024) is never bucketed
759    /// cell-by-cell — see the module doc's "Oversized items" section and
760    /// the dedicated properties below (10–13) that exercise that path
761    /// specifically, including the exact 1e6-at-`cell_size:1.0` incident
762    /// input. So it would now be SAFE to relax or remove this cap — no
763    /// combination of `cell_size` and extent can OOM or overflow the index
764    /// anymore, per the arithmetic on `MAX_CELLS_PER_ITEM`.
765    ///
766    /// It is kept at 64 anyway, deliberately, for a coverage reason
767    /// unrelated to safety: `arb_extent`'s "a few cells" branch draws
768    /// `(1.0..MAX_CELLS_PER_AXIS)`, and properties 1, 2, 4, 5, 6, 8, 9 lean
769    /// on that branch to stress the NORMAL bucketed path's boundary/
770    /// precision arithmetic (the 2–64-cells-per-axis regime is where an
771    /// off-by-one or an f32 rounding slip in `cells_for_rect` would show
772    /// up). Raising this constant to, say, `1_000_000.0` to match
773    /// `arb_coord`'s large-magnitude branch would make that "a few cells"
774    /// branch draw an almost-always-oversized value instead (a uniform
775    /// draw over `[1, 1e6)` puts less than 0.1% of its mass below 1024),
776    /// silently starving those seven properties of the multi-cell-bucketing
777    /// coverage they exist for. Rather than dilute that shared generator,
778    /// the oversized path gets its own dedicated generators in properties
779    /// 10–13 below, which is why this constant is unchanged.
780    const MAX_CELLS_PER_AXIS: f32 = 64.0;
781
782    /// Extents are generated RELATIVE to `cell_size` so an item never spans
783    /// more than `MAX_CELLS_PER_AXIS` cells. Large absolute *coordinates* are
784    /// still generated by `arb_coord` — those are the interesting case for
785    /// the f32-precision-at-magnitude question, and they are cheap because a
786    /// far-away rect is still only a few cells.
787    fn arb_extent(cell_size: f32) -> impl Strategy<Value = f32> {
788        let cs = cell_size;
789        let max = cs * MAX_CELLS_PER_AXIS;
790        prop_oneof![
791            // Degenerate extents: a zero-size rect must still bucket to
792            // exactly one cell, and a sub-pixel one must not round to zero.
793            Just(0.0_f32),
794            Just(0.001_f32),
795            // Sub-cell, exactly one cell, and a few cells — the boundary
796            // arithmetic in `cells_for_rect` lives here.
797            (0.01f32..1.0f32).prop_map(move |f| f * cs),
798            Just(cs),
799            (1.0f32..MAX_CELLS_PER_AXIS).prop_map(move |f| f * cs),
800            Just(max),
801        ]
802    }
803
804    /// A `cell_size` paired with two rects generated FOR THAT cell size.
805    ///
806    /// Drawing the grid's `cell_size` and a rect's extent from two
807    /// INDEPENDENT `arb_cell_size()` draws is a trap: a rect sized for a
808    /// 256 px grid (extent up to `256 * MAX_CELLS_PER_AXIS`) inserted into a
809    /// 1 px grid spans ~268 million cells and allocates gigabytes. Every
810    /// property that builds a grid and inserts into it must take its rects
811    /// from here so the two stay coupled.
812    fn arb_grid_and_two_rects() -> impl Strategy<Value = (f32, Rect, Rect)> {
813        arb_cell_size().prop_flat_map(|cs| (Just(cs), arb_rect(cs), arb_rect(cs)))
814    }
815
816    fn arb_rect(cell_size: f32) -> impl Strategy<Value = Rect> {
817        (
818            arb_coord(cell_size),
819            arb_coord(cell_size),
820            arb_extent(cell_size),
821            arb_extent(cell_size),
822        )
823            .prop_map(|(x, y, width, height)| Rect::new(x, y, width, height))
824    }
825
826    // (cell_size, items, query_rect) sharing one cell_size so the
827    // boundary-biased coordinates in `items` and `query_rect` actually
828    // land relative to the same grid.
829    fn arb_layout_and_query() -> impl Strategy<Value = (f32, Vec<(u64, Rect)>, Rect)> {
830        arb_cell_size().prop_flat_map(|cs| {
831            (
832                Just(cs),
833                prop::collection::vec((0u64..64, arb_rect(cs)), 0..40),
834                arb_rect(cs),
835            )
836        })
837    }
838
839    fn build_index(
840        cell_size: f32,
841        items: &[(u64, Rect)],
842    ) -> (GridHashIndex, HashMap<ItemId, Rect>) {
843        let mut g = GridHashIndex::new(cell_size);
844        let mut model: HashMap<ItemId, Rect> = HashMap::new();
845        for &(n, r) in items {
846            let i = id(n);
847            g.insert(i, r);
848            model.insert(i, r);
849        }
850        (g, model)
851    }
852
853    // ── 1. query, narrowed by a true intersection test, equals a brute-force scan ──
854    proptest! {
855        #![proptest_config(ProptestConfig { cases: 1024, ..ProptestConfig::default() })]
856        #[test]
857        fn query_narrowed_matches_brute_force((cell_size, items, q) in arb_layout_and_query()) {
858            let (g, model) = build_index(cell_size, &items);
859
860            let from_index: BTreeSet<ItemId> = g.query(q).into_iter().collect();
861            let from_brute: BTreeSet<ItemId> = model
862                .iter()
863                .filter(|(_, r)| rects_intersect(**r, q))
864                .map(|(i, _)| *i)
865                .collect();
866
867            // The index may over-report (cell fan-out false positives)
868            // but must never under-report a true intersection — a
869            // missed hit is a lost click.
870            prop_assert!(
871                from_brute.is_subset(&from_index),
872                "cell_size={} query={:?}: index missed true intersections {:?} (model={:?})",
873                cell_size,
874                q,
875                from_brute.difference(&from_index).collect::<Vec<_>>(),
876                model,
877            );
878
879            let narrowed: BTreeSet<ItemId> = from_index
880                .iter()
881                .copied()
882                .filter(|i| rects_intersect(model[i], q))
883                .collect();
884            prop_assert_eq!(
885                &narrowed, &from_brute,
886                "cell_size={} query={:?}: narrowed index result {:?} != brute force {:?}",
887                cell_size, q, narrowed, from_brute,
888            );
889        }
890    }
891
892    // ── 2. contains/len track a HashMap<ItemId, Rect> model over insert/remove/re-insert ──
893    #[derive(Debug, Clone)]
894    enum Op {
895        Insert(u64, Rect),
896        Remove(u64),
897    }
898
899    fn arb_op(cell_size: f32) -> impl Strategy<Value = Op> {
900        prop_oneof![
901            3 => (0u64..12, arb_rect(cell_size)).prop_map(|(n, r)| Op::Insert(n, r)),
902            1 => (0u64..12).prop_map(Op::Remove),
903        ]
904    }
905
906    proptest! {
907        #![proptest_config(ProptestConfig { cases: 512, ..ProptestConfig::default() })]
908        #[test]
909        fn contains_and_len_track_a_hashmap_model(
910            (cell_size, ops) in arb_cell_size()
911                .prop_flat_map(|cs| (Just(cs), prop::collection::vec(arb_op(cs), 0..80)))
912        ) {
913            let mut g = GridHashIndex::new(cell_size);
914            let mut model: HashMap<ItemId, Rect> = HashMap::new();
915
916            for op in &ops {
917                match *op {
918                    Op::Insert(n, r) => {
919                        g.insert(id(n), r);
920                        model.insert(id(n), r);
921                    }
922                    Op::Remove(n) => {
923                        g.remove(id(n));
924                        model.remove(&id(n));
925                    }
926                }
927                prop_assert_eq!(
928                    g.len(), model.len(),
929                    "after {:?}: index len {} != model len {} (ops so far: {:?})",
930                    op, g.len(), model.len(), ops,
931                );
932            }
933
934            // Full-state check over the bounded id range every op draws
935            // from, since there's no "list all ids" accessor.
936            for n in 0..12u64 {
937                prop_assert_eq!(
938                    g.contains(id(n)), model.contains_key(&id(n)),
939                    "id {} contains()={} but model has_key={} after ops {:?}",
940                    n, g.contains(id(n)), model.contains_key(&id(n)), ops,
941                );
942            }
943        }
944    }
945
946    // ── 3. no empty cell buckets linger after any insert/remove sequence ──
947    proptest! {
948        #![proptest_config(ProptestConfig { cases: 512, ..ProptestConfig::default() })]
949        #[test]
950        fn no_empty_bucket_lingers_after_removals(
951            (cell_size, ops) in arb_cell_size()
952                .prop_flat_map(|cs| (Just(cs), prop::collection::vec(arb_op(cs), 0..80)))
953        ) {
954            let mut g = GridHashIndex::new(cell_size);
955            for op in &ops {
956                match *op {
957                    Op::Insert(n, r) => g.insert(id(n), r),
958                    Op::Remove(n) => g.remove(id(n)),
959                }
960                prop_assert!(
961                    !g.has_empty_bucket(),
962                    "empty bucket left behind after {:?} (ops so far: {:?})",
963                    op, ops,
964                );
965            }
966        }
967    }
968
969    // ── 4. re-inserting an existing id under a new rect fully relocates it ──
970    proptest! {
971        #![proptest_config(ProptestConfig { cases: 512, ..ProptestConfig::default() })]
972        #[test]
973        fn reinsert_relocates_with_no_ghost_at_old_rect(
974            cell_size in arb_cell_size(),
975            old_n in 0u64..5,
976            new_n in 0u64..5,
977        ) {
978            // Keep old/new rects far enough apart (relative to cell_size)
979            // that they cannot share a cell, so a stale entry at the old
980            // location is unambiguously observable via query.
981            let old_rect = Rect::new(0.0, 0.0, cell_size.min(50.0), cell_size.min(50.0));
982            let far = cell_size * 10_000.0;
983            let new_rect = Rect::new(far, far, cell_size.min(50.0), cell_size.min(50.0));
984
985            let mut g = GridHashIndex::new(cell_size);
986            g.insert(id(old_n), old_rect);
987            g.insert(id(new_n), new_rect);
988            // Re-insert new_n's id (which may or may not equal old_n) at
989            // the far location again isn't the point here; instead move
990            // one id from the old spot to the new spot and check the old
991            // spot lost it.
992            let moved = id(old_n);
993            g.insert(moved, new_rect);
994
995            let at_old = g.query(old_rect);
996            prop_assert!(
997                !at_old.contains(&moved),
998                "ghost entry: {:?} still found at the old rect {:?} after re-insert to {:?}",
999                moved, old_rect, new_rect,
1000            );
1001            let at_new = g.query(new_rect);
1002            prop_assert!(
1003                at_new.contains(&moved),
1004                "{:?} not found at its new rect {:?} after re-insert",
1005                moved, new_rect,
1006            );
1007        }
1008    }
1009
1010    // ── 5. query result set does not depend on insertion order ──
1011    //
1012    // The permutation is produced by the decorate-sort-undecorate trick —
1013    // proptest hands us a `Vec<u32>` of sort keys the same length as the
1014    // (deduped) item list, we zip+sort by key — rather than a hand-rolled
1015    // RNG/shuffle: all the randomness comes from the strategy, and
1016    // shrinking still applies to the keys like any other generated value.
1017    fn arb_order_independence_case()
1018    -> impl Strategy<Value = (f32, Vec<(u64, Rect)>, Rect, Vec<u32>)> {
1019        arb_layout_and_query().prop_flat_map(|(cs, items, q)| {
1020            // Dedup ids first (later duplicate wins, matching HashMap
1021            // insert semantics) so both orderings insert the same final
1022            // id->rect mapping. Done here (inside the strategy) so the
1023            // shuffle-key vector can be sized to match exactly.
1024            let mut model: Vec<(u64, Rect)> = Vec::new();
1025            for &(n, r) in &items {
1026                if let Some(slot) = model.iter_mut().find(|(mn, _)| *mn == n) {
1027                    slot.1 = r;
1028                } else {
1029                    model.push((n, r));
1030                }
1031            }
1032            let n = model.len();
1033            (
1034                Just(cs),
1035                Just(model),
1036                Just(q),
1037                prop::collection::vec(any::<u32>(), n),
1038            )
1039        })
1040    }
1041
1042    proptest! {
1043        #![proptest_config(ProptestConfig { cases: 256, ..ProptestConfig::default() })]
1044        #[test]
1045        fn query_is_independent_of_insertion_order(
1046            (cell_size, model, q, keys) in arb_order_independence_case()
1047        ) {
1048            let forward = model.clone();
1049            let mut decorated: Vec<((u64, Rect), u32)> =
1050                model.iter().copied().zip(keys.iter().copied()).collect();
1051            decorated.sort_by_key(|(_, k)| *k);
1052            let shuffled: Vec<(u64, Rect)> = decorated.into_iter().map(|(item, _)| item).collect();
1053
1054            let mut g_forward = GridHashIndex::new(cell_size);
1055            for &(n, r) in &forward {
1056                g_forward.insert(id(n), r);
1057            }
1058            let mut g_shuffled = GridHashIndex::new(cell_size);
1059            for &(n, r) in &shuffled {
1060                g_shuffled.insert(id(n), r);
1061            }
1062
1063            let forward_hits: BTreeSet<ItemId> = g_forward.query(q).into_iter().collect();
1064            let shuffled_hits: BTreeSet<ItemId> = g_shuffled.query(q).into_iter().collect();
1065            prop_assert_eq!(
1066                &forward_hits, &shuffled_hits,
1067                "insertion order changed query({:?}) result: forward {:?} != shuffled {:?} (items order: {:?} vs {:?})",
1068                q, forward_hits, shuffled_hits, forward, shuffled,
1069            );
1070        }
1071    }
1072
1073    // ── 6. inserting the same id with identical bounds twice is a no-op ──
1074    proptest! {
1075        #![proptest_config(ProptestConfig { cases: 512, ..ProptestConfig::default() })]
1076        #[test]
1077        fn reinserting_identical_bounds_is_idempotent(
1078            n in 0u64..20,
1079            (cell_size, r, q) in arb_grid_and_two_rects(),
1080        ) {
1081            let mut g = GridHashIndex::new(cell_size);
1082            g.insert(id(n), r);
1083            let len_before = g.len();
1084            let cells_before = g.cell_count();
1085            let hits_before: BTreeSet<ItemId> = g.query(q).into_iter().collect();
1086
1087            g.insert(id(n), r);
1088
1089            prop_assert_eq!(g.len(), len_before, "len changed after re-inserting identical bounds for {:?}", id(n));
1090            prop_assert_eq!(
1091                g.cell_count(), cells_before,
1092                "cell_count changed after re-inserting identical bounds for {:?}", id(n)
1093            );
1094            let hits_after: BTreeSet<ItemId> = g.query(q).into_iter().collect();
1095            prop_assert_eq!(
1096                &hits_after, &hits_before,
1097                "query({:?}) changed after re-inserting identical bounds for {:?}: {:?} != {:?}",
1098                q, id(n), hits_after, hits_before,
1099            );
1100        }
1101    }
1102
1103    // ── 7. GridHashIndex::new always clamps cell_size to at least 1.0 ──
1104    proptest! {
1105        #[test]
1106        fn new_clamps_cell_size_to_at_least_one(raw in -1.0e7f32..1.0e7f32) {
1107            let g = GridHashIndex::new(raw);
1108            prop_assert!(
1109                g.cell_size() >= 1.0,
1110                "cell_size({}) = {} is below the documented floor of 1.0",
1111                raw, g.cell_size(),
1112            );
1113            // And it should track the input exactly whenever the input
1114            // already satisfies the floor.
1115            if raw >= 1.0 {
1116                prop_assert_eq!(
1117                    g.cell_size(), raw,
1118                    "cell_size({}) was altered even though it already satisfied the floor", raw,
1119                );
1120            }
1121        }
1122    }
1123
1124    // ── 8. query never panics, whatever rect it is asked about ──
1125    proptest! {
1126        #![proptest_config(ProptestConfig { cases: 1024, ..ProptestConfig::default() })]
1127        #[test]
1128        fn query_never_panics((cell_size, items, q) in arb_layout_and_query()) {
1129            let (g, _model) = build_index(cell_size, &items);
1130            // The property under test is "doesn't panic"; a successful
1131            // return (of any Vec, including empty) is the pass condition.
1132            let _ = g.query(q);
1133        }
1134    }
1135
1136    // ── 9. widening the query rect can only add hits, never drop any ──
1137    proptest! {
1138        #![proptest_config(ProptestConfig { cases: 512, ..ProptestConfig::default() })]
1139        #[test]
1140        fn query_is_monotonic_in_rect_containment(
1141            (cell_size, items, outer) in arb_layout_and_query()
1142                .prop_map(|(cs, items, outer)| (cs, items, outer)),
1143            // Fractions (of outer's width/height) used to carve an inner
1144            // rect that's fully contained in `outer`.
1145            fx in 0.0f32..1.0,
1146            fy in 0.0f32..1.0,
1147            fw in 0.0f32..1.0,
1148            fh in 0.0f32..1.0,
1149        ) {
1150            // Guard against a degenerate outer rect (width/height 0 or
1151            // negative would make "inner ⊆ outer" ill-defined here).
1152            prop_assume!(outer.width > 0.0 && outer.height > 0.0);
1153
1154            let (g, _model) = build_index(cell_size, &items);
1155
1156            let inner_x = outer.x + fx * outer.width;
1157            let inner_y = outer.y + fy * outer.height;
1158            let inner_w = fw * (outer.right() - inner_x);
1159            let inner_h = fh * (outer.bottom() - inner_y);
1160            let inner = Rect::new(inner_x, inner_y, inner_w.max(0.0), inner_h.max(0.0));
1161
1162            let inner_hits: BTreeSet<ItemId> = g.query(inner).into_iter().collect();
1163            let outer_hits: BTreeSet<ItemId> = g.query(outer).into_iter().collect();
1164
1165            prop_assert!(
1166                inner_hits.is_subset(&outer_hits),
1167                "inner rect {:?} (inside outer {:?}) hit {:?} not returned by the wider query: {:?}",
1168                inner, outer,
1169                inner_hits.difference(&outer_hits).collect::<Vec<_>>(),
1170                outer_hits,
1171            );
1172        }
1173    }
1174
1175    // Side length (in logical pixels) of a square rect guaranteed to exceed
1176    // MAX_CELLS_PER_ITEM at the given cell_size, for ANY cell_size this
1177    // suite's arb_cell_size() produces (1.0..=256.0): a
1178    // `sqrt(MAX_CELLS_PER_ITEM)`-cells-per-axis square already sits right at
1179    // the cap, so doubling it clears the cap comfortably regardless of
1180    // f32-rounding at the boundary.
1181    fn oversized_side_for(cell_size: f32) -> f32 {
1182        cell_size * (MAX_CELLS_PER_ITEM as f32).sqrt() * 2.0
1183    }
1184
1185    // ── 10. an oversized item is still found by a query that truly intersects it ──
1186    proptest! {
1187        #![proptest_config(ProptestConfig { cases: 256, ..ProptestConfig::default() })]
1188        #[test]
1189        fn oversized_item_is_found_by_a_query_that_truly_intersects_it(
1190            cell_size in arb_cell_size(),
1191            n in 0u64..8,
1192            // Fractions used to carve a query rect that is a strict
1193            // sub-rect of (hence guaranteed to truly intersect) the
1194            // oversized item's bounds.
1195            fx in 0.0f32..1.0,
1196            fy in 0.0f32..1.0,
1197            fw in 0.01f32..1.0,
1198            fh in 0.01f32..1.0,
1199        ) {
1200            let side = oversized_side_for(cell_size);
1201            let big_rect = Rect::new(0.0, 0.0, side, side);
1202
1203            let mut g = GridHashIndex::new(cell_size);
1204            let item = id(n);
1205            g.insert(item, big_rect);
1206            prop_assert!(
1207                g.is_oversized(item),
1208                "cell_size={} side={}: item should have been classified oversized (cap={})",
1209                cell_size, side, MAX_CELLS_PER_ITEM,
1210            );
1211
1212            // A rect confined to [0, side/2) x [0, side/2) with modest
1213            // width/height is always a strict sub-rect of big_rect, hence
1214            // always truly intersects it.
1215            let qx = fx * side * 0.5;
1216            let qy = fy * side * 0.5;
1217            let qw = (fw * side * 0.25).max(0.01);
1218            let qh = (fh * side * 0.25).max(0.01);
1219            let query_rect = Rect::new(qx, qy, qw, qh);
1220
1221            let hits = g.query(query_rect);
1222            prop_assert!(
1223                hits.contains(&item),
1224                "cell_size={} query={:?}: oversized item {:?} (bounds {:?}) missed by a query \
1225                 that truly intersects it — a lost click through the oversized path",
1226                cell_size, query_rect, item, big_rect,
1227            );
1228        }
1229    }
1230
1231    // ── 11. re-insert moves an item between normal and oversized with no ghost in either representation ──
1232    proptest! {
1233        #![proptest_config(ProptestConfig { cases: 256, ..ProptestConfig::default() })]
1234        #[test]
1235        fn reinsert_moves_between_normal_and_oversized_with_no_ghost(
1236            cell_size in arb_cell_size(),
1237            n in 0u64..8,
1238        ) {
1239            let small_side = cell_size.min(10.0);
1240            let small_rect = Rect::new(0.0, 0.0, small_side, small_side);
1241            let side = oversized_side_for(cell_size);
1242            // Far enough from the origin that big_rect can never overlap
1243            // small_rect, so any hit at the "wrong" location is
1244            // unambiguously a ghost, not a coincidental true intersection.
1245            let far = cell_size * 10_000.0;
1246            let big_rect = Rect::new(far, far, side, side);
1247
1248            let mut g = GridHashIndex::new(cell_size);
1249            let item = id(n);
1250
1251            // 1. Normal path.
1252            g.insert(item, small_rect);
1253            prop_assert!(!g.is_oversized(item), "expected the small rect to bucket normally");
1254            prop_assert!(g.query(small_rect).contains(&item));
1255
1256            // 2. normal -> oversized.
1257            g.insert(item, big_rect);
1258            prop_assert!(
1259                g.is_oversized(item),
1260                "cell_size={} side={}: expected the big rect to be classified oversized",
1261                cell_size, side,
1262            );
1263            prop_assert!(
1264                !g.query(small_rect).contains(&item),
1265                "ghost: {:?} still found at the old (normally-bucketed) rect {:?} after moving \
1266                 to the oversized rect {:?}",
1267                item, small_rect, big_rect,
1268            );
1269            prop_assert!(g.query(big_rect).contains(&item));
1270
1271            // 3. oversized -> normal.
1272            g.insert(item, small_rect);
1273            prop_assert!(
1274                !g.is_oversized(item),
1275                "expected re-insert with a small rect to leave the oversized representation"
1276            );
1277            prop_assert!(
1278                !g.query(big_rect).contains(&item),
1279                "ghost: {:?} still found at the old oversized rect {:?} after moving back to {:?}",
1280                item, big_rect, small_rect,
1281            );
1282            prop_assert!(g.query(small_rect).contains(&item));
1283            prop_assert_eq!(g.len(), 1, "exactly one logical item should exist throughout");
1284        }
1285    }
1286
1287    // ── 12. contains/len still track a HashMap model when oversized items are mixed in ──
1288    fn arb_rect_possibly_oversized(cell_size: f32) -> impl Strategy<Value = Rect> {
1289        prop_oneof![
1290            3 => arb_rect(cell_size),
1291            1 => {
1292                let side = oversized_side_for(cell_size);
1293                (-10i32..10i32, -10i32..10i32).prop_map(move |(kx, ky)| {
1294                    Rect::new(kx as f32 * side, ky as f32 * side, side, side)
1295                })
1296            },
1297        ]
1298    }
1299
1300    fn arb_op_possibly_oversized(cell_size: f32) -> impl Strategy<Value = Op> {
1301        prop_oneof![
1302            3 => (0u64..12, arb_rect_possibly_oversized(cell_size)).prop_map(|(n, r)| Op::Insert(n, r)),
1303            1 => (0u64..12).prop_map(Op::Remove),
1304        ]
1305    }
1306
1307    proptest! {
1308        #![proptest_config(ProptestConfig { cases: 512, ..ProptestConfig::default() })]
1309        #[test]
1310        fn contains_and_len_track_a_hashmap_model_with_oversized_items_mixed_in(
1311            (cell_size, ops) in arb_cell_size()
1312                .prop_flat_map(|cs| (Just(cs), prop::collection::vec(arb_op_possibly_oversized(cs), 0..80)))
1313        ) {
1314            let mut g = GridHashIndex::new(cell_size);
1315            let mut model: HashMap<ItemId, Rect> = HashMap::new();
1316
1317            for op in &ops {
1318                match *op {
1319                    Op::Insert(n, r) => {
1320                        g.insert(id(n), r);
1321                        model.insert(id(n), r);
1322                    }
1323                    Op::Remove(n) => {
1324                        g.remove(id(n));
1325                        model.remove(&id(n));
1326                    }
1327                }
1328                prop_assert_eq!(
1329                    g.len(), model.len(),
1330                    "after {:?} (oversized items mixed in): index len {} != model len {} \
1331                     (ops so far: {:?})",
1332                    op, g.len(), model.len(), ops,
1333                );
1334            }
1335
1336            // Full-state check over the bounded id range every op draws
1337            // from, since there's no "list all ids" accessor.
1338            for n in 0..12u64 {
1339                prop_assert_eq!(
1340                    g.contains(id(n)), model.contains_key(&id(n)),
1341                    "id {} contains()={} but model has_key={} after ops {:?} \
1342                     (oversized items mixed in)",
1343                    n, g.contains(id(n)), model.contains_key(&id(n)), ops,
1344                );
1345            }
1346        }
1347    }
1348
1349    // ── 13. a single item's cell footprint never exceeds MAX_CELLS_PER_ITEM, for any extent ──
1350    //
1351    // This is the property that would have prevented the reboots: it
1352    // exercises the exact incident input (a 1e6 extent at cell_size 1.0,
1353    // via the `Just(1_000_000.0_f32)` branch below) alongside other large
1354    // extents and the suite's usual cell sizes, and checks the observable
1355    // proxy for "did this allocate a pathological number of cells" —
1356    // `cell_count()` staying within the cap regardless of how the item was
1357    // classified.
1358    proptest! {
1359        #![proptest_config(ProptestConfig { cases: 512, ..ProptestConfig::default() })]
1360        #[test]
1361        fn a_single_items_cell_footprint_never_exceeds_the_per_item_cap(
1362            cell_size in arb_cell_size(),
1363            extent in prop_oneof![
1364                Just(1_000_000.0_f32),
1365                Just(500_000.0_f32),
1366                Just(100_000.0_f32),
1367                (1.0f32..MAX_CELLS_PER_ITEM as f32 * 4.0),
1368            ],
1369        ) {
1370            let mut g = GridHashIndex::new(cell_size);
1371            let item = id(1);
1372            g.insert(item, Rect::new(0.0, 0.0, extent, extent));
1373
1374            prop_assert!(
1375                g.cell_count() <= MAX_CELLS_PER_ITEM as usize,
1376                "cell_size={} extent={}: cell_count()={} exceeds the per-item cap {} — the \
1377                 resource-exhaustion bug is back",
1378                cell_size, extent, g.cell_count(), MAX_CELLS_PER_ITEM,
1379            );
1380            prop_assert!(g.contains(item));
1381            prop_assert_eq!(g.len(), 1);
1382        }
1383    }
1384}