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}