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