tess 0.4.0
Performance-first tile and path simulation substrate
Loading...
Searching...
No Matches
route_cache.h
1#pragma once
2
3#include <tess/core/tag_identity.h>
4#include <tess/path/path.h>
5
6#include <cstddef>
7#include <cstdint>
8#include <span>
9#include <vector>
10
11namespace tess {
12
15 std::size_t entries = 0;
16 std::size_t hits = 0;
17 std::size_t suffix_hits = 0;
18 std::size_t misses = 0;
19 std::size_t path_nodes = 0;
20 std::size_t cap_invalidations = 0;
21 std::size_t oversized_skips = 0;
22 // Whole-cache drops forced by a lookup with a different movement class
23 // than the cache was bound to (see cached_astar_path). Keep one cache per
24 // (world, class) to stay at zero.
25 std::size_t class_rebinds = 0;
26};
27
28// Exact (start, goal) lookups and same-goal suffix lookups are served by two
29// open-addressed flat hash indexes (power-of-two capacity, linear probing)
30// instead of linear scans. The suffix index is populated per stored
31// Found-path node with first-write-wins, which preserves the earlier
32// linear-scan determinism: the earliest stored entry containing a queried
33// suffix node keeps winning. Both indexes are rebuilt from scratch on
34// `invalidate()`/`clear()`. Storage is bounded by entry and path-node caps;
35// an insert that would exceed either cap invalidates the whole cache first
36// (matching the world-change invalidation lifecycle) and counts a cap
37// invalidation in the stats, except a single route larger than the node cap,
38// which is skipped outright (stats().oversized_skips) so it cannot evict
39// resident entries and then violate the cap anyway. A cap of 0 disables
40// storage; it does not mean "unlimited".
43 public:
44 static constexpr std::size_t default_max_entries = 512;
45 static constexpr std::size_t default_max_path_nodes = std::size_t{1} << 20u;
46
47 // A cap of 0 disables storage (every request recomputes); a single route
48 // larger than max_path_nodes is skipped without disturbing resident
49 // entries (counted in stats().oversized_skips).
50 void set_caps(std::size_t max_entries, std::size_t max_path_nodes) noexcept {
51 max_entries_ = max_entries;
52 max_path_nodes_ = max_path_nodes;
53 // The normal over-cap insertion policy invalidates the whole cache. Apply
54 // that same deterministic policy immediately when a caller lowers either
55 // cap below the live footprint; otherwise existing hits could bypass a
56 // newly configured zero/smaller limit indefinitely.
57 if (entries_.size() > max_entries_ || paths_.size() > max_path_nodes_) {
58 invalidate();
59 ++cap_invalidations_;
60 }
61 }
62
63 void reserve_routes(std::size_t route_count) {
64 entries_.reserve(route_count);
65 }
66
67 void reserve_path_nodes(std::size_t node_count) {
68 paths_.reserve(node_count);
69 }
70
71 void clear() noexcept {
72 invalidate();
73 bound_class_ = 0;
74 hits_ = 0;
75 suffix_hits_ = 0;
76 misses_ = 0;
77 cap_invalidations_ = 0;
78 oversized_skips_ = 0;
79 }
80
81 // Entries are keyed on (start, goal) plus the world fingerprint and
82 // nothing on the movement class, so the cache binds itself to the class
83 // of each cached_astar_path call: a rebind drops every entry (correct
84 // even on misuse) and counts in stats().class_rebinds. One cache per
85 // (world, class) is the PERF contract, not a correctness precondition.
86 void bind_class(std::uintptr_t identity) noexcept {
87 if (bound_class_ == identity) {
88 return;
89 }
90 if (bound_class_ != 0) {
91 invalidate();
92 ++class_rebinds_;
93 }
94 bound_class_ = identity;
95 }
96
97 void invalidate() noexcept {
98 entries_.clear();
99 paths_.clear();
100 exact_slots_.clear();
101 suffix_slots_.clear();
102 suffix_count_ = 0;
103 }
104
105 void reset_stats() noexcept {
106 hits_ = 0;
107 suffix_hits_ = 0;
108 misses_ = 0;
109 cap_invalidations_ = 0;
110 oversized_skips_ = 0;
111 }
112
113 // The fingerprint identifies world CONTENT VERSIONS, not a world
114 // instance: two same-shape worlds whose chunks carry identical version
115 // counters (e.g. both populated without mark_dirty) alias, and a cache
116 // reused across them would serve one world's routes for the other. Keep
117 // one cache per world; only the sparse path self-identifies its world
118 // (residency_generation is world-monotonic).
119 template <typename World>
120 void capture_world_versions(const World& world) noexcept {
121 world_fingerprint_ = world_version_fingerprint(world);
122 has_world_fingerprint_ = true;
123 }
124
125 template <typename World>
126 [[nodiscard]] auto invalidate_if_world_changed(const World& world) noexcept
127 -> bool {
128 if (!has_world_fingerprint_) {
129 capture_world_versions(world);
130 return false;
131 }
132 const auto current = world_version_fingerprint(world);
133 if (current == world_fingerprint_) {
134 return false;
135 }
136 invalidate();
137 world_fingerprint_ = current;
138 has_world_fingerprint_ = true;
139 return true;
140 }
141
142 [[nodiscard]] auto stats() const noexcept -> RouteCacheStats {
143 return RouteCacheStats{
144 entries_.size(), hits_, suffix_hits_,
145 misses_, paths_.size(), cap_invalidations_,
146 oversized_skips_, class_rebinds_,
147 };
148 }
149
150 private:
151 struct Entry {
152 Coord3 start{};
153 Coord3 goal{};
154 PathStatus status = PathStatus::NoPath;
155 std::uint32_t cost = 0;
156 std::size_t expanded_nodes = 0;
157 std::size_t reached_nodes = 0;
158 std::size_t path_offset = 0;
159 std::size_t path_size = 0;
160 };
161
162 struct SuffixSlot {
163 std::uint32_t entry_plus_one = 0;
164 std::uint32_t offset = 0;
165 };
166
167 template <typename World, typename Tag>
168 friend auto cached_astar_path(const World& world, PathRequest request,
169 PathScratch& scratch, RouteCacheScratch& cache)
170 -> PathResult;
171
172 // FNV-style lane combine with one final avalanche: cheap per stored path
173 // node, well distributed for power-of-two linear probing.
174 [[nodiscard]] static auto hash_pair(Coord3 first, Coord3 second) noexcept
175 -> std::uint64_t {
176 auto hash = std::uint64_t{0xcbf29ce484222325ull};
177 hash = (hash ^ static_cast<std::uint64_t>(first.x)) * 0x100000001b3ull;
178 hash = (hash ^ static_cast<std::uint64_t>(first.y)) * 0x100000001b3ull;
179 hash = (hash ^ static_cast<std::uint64_t>(first.z)) * 0x100000001b3ull;
180 hash = (hash ^ static_cast<std::uint64_t>(second.x)) * 0x100000001b3ull;
181 hash = (hash ^ static_cast<std::uint64_t>(second.y)) * 0x100000001b3ull;
182 hash = (hash ^ static_cast<std::uint64_t>(second.z)) * 0x100000001b3ull;
183 hash = (hash ^ (hash >> 30u)) * 0xbf58476d1ce4e5b9ull;
184 hash = (hash ^ (hash >> 27u)) * 0x94d049bb133111ebull;
185 return hash ^ (hash >> 31u);
186 }
187
188 [[nodiscard]] auto find(PathRequest request) const noexcept -> const Entry* {
189 if (exact_slots_.empty()) {
190 return nullptr;
191 }
192 const auto mask = exact_slots_.size() - 1u;
193 auto slot =
194 static_cast<std::size_t>(hash_pair(request.start, request.goal)) & mask;
195 while (exact_slots_[slot] != 0) {
196 const auto& entry = entries_[exact_slots_[slot] - 1u];
197 if (entry.start == request.start && entry.goal == request.goal) {
198 return &entry;
199 }
200 slot = (slot + 1u) & mask;
201 }
202 return nullptr;
203 }
204
205 [[nodiscard]] auto find_suffix(PathRequest request,
206 std::size_t& suffix_offset) const noexcept
207 -> const Entry* {
208 if (suffix_slots_.empty()) {
209 return nullptr;
210 }
211 const auto mask = suffix_slots_.size() - 1u;
212 auto slot =
213 static_cast<std::size_t>(hash_pair(request.start, request.goal)) & mask;
214 while (suffix_slots_[slot].entry_plus_one != 0) {
215 const auto& candidate = suffix_slots_[slot];
216 const auto& entry = entries_[candidate.entry_plus_one - 1u];
217 if (entry.goal == request.goal &&
218 paths_[entry.path_offset + candidate.offset] == request.start) {
219 suffix_offset = candidate.offset;
220 return &entry;
221 }
222 slot = (slot + 1u) & mask;
223 }
224 return nullptr;
225 }
226
227 void store(PathRequest request, const PathResult& result) {
228 // Cap value 0 disables storage entirely, matching the portal segment
229 // cache's budget semantics; it does not mean "unlimited".
230 if (max_entries_ == 0 || max_path_nodes_ == 0) {
231 return;
232 }
233 // A single result larger than the node cap can never fit; skip it
234 // instead of invalidating resident entries and then violating the cap.
235 if (result.path.size() > max_path_nodes_) {
236 ++oversized_skips_;
237 return;
238 }
239 if (entries_.size() + 1u > max_entries_ ||
240 paths_.size() + result.path.size() > max_path_nodes_) {
241 invalidate();
242 ++cap_invalidations_;
243 }
244 const auto entry_index = entries_.size();
245 const auto path_offset = paths_.size();
246 paths_.insert(paths_.end(), result.path.begin(), result.path.end());
247 entries_.push_back(Entry{
248 request.start,
249 request.goal,
250 result.status,
251 result.cost,
252 result.expanded_nodes,
253 result.reached_nodes,
254 path_offset,
255 result.path.size(),
256 });
257 exact_insert(entry_index);
258 if (result.status == PathStatus::Found) {
259 suffix_insert(entry_index);
260 }
261 }
262
263 void exact_insert(std::size_t entry_index) {
264 if (exact_slots_.size() < (entries_.size() + 1u) * 2u) {
265 grow_exact_index();
266 return;
267 }
268 exact_place(entry_index);
269 }
270
271 void exact_place(std::size_t entry_index) noexcept {
272 const auto mask = exact_slots_.size() - 1u;
273 const auto& entry = entries_[entry_index];
274 auto slot =
275 static_cast<std::size_t>(hash_pair(entry.start, entry.goal)) & mask;
276 while (exact_slots_[slot] != 0) {
277 slot = (slot + 1u) & mask;
278 }
279 exact_slots_[slot] = static_cast<std::uint32_t>(entry_index + 1u);
280 }
281
282 void grow_exact_index() {
283 auto capacity = std::size_t{16};
284 while (capacity < (entries_.size() + 1u) * 2u) {
285 capacity *= 2u;
286 }
287 exact_slots_.assign(capacity, 0u);
288 for (std::size_t i = 0; i < entries_.size(); ++i) {
289 exact_place(i);
290 }
291 }
292
293 // First-write-wins per (node, goal): the earliest stored entry containing
294 // a node keeps serving suffix queries for it, matching the pre-index
295 // linear-scan order.
296 void suffix_insert(std::size_t entry_index) {
297 const auto& entry = entries_[entry_index];
298 if (suffix_slots_.size() < (suffix_count_ + entry.path_size + 1u) * 2u) {
299 grow_suffix_index(entry.path_size);
300 }
301 for (std::size_t i = 0; i < entry.path_size; ++i) {
302 suffix_place(entry_index, i);
303 }
304 }
305
306 void suffix_place(std::size_t entry_index, std::size_t offset) noexcept {
307 const auto mask = suffix_slots_.size() - 1u;
308 const auto& entry = entries_[entry_index];
309 const auto node = paths_[entry.path_offset + offset];
310 auto slot = static_cast<std::size_t>(hash_pair(node, entry.goal)) & mask;
311 while (suffix_slots_[slot].entry_plus_one != 0) {
312 const auto& occupant = suffix_slots_[slot];
313 const auto& occupant_entry = entries_[occupant.entry_plus_one - 1u];
314 if (occupant_entry.goal == entry.goal &&
315 paths_[occupant_entry.path_offset + occupant.offset] == node) {
316 return; // First write wins.
317 }
318 slot = (slot + 1u) & mask;
319 }
320 suffix_slots_[slot] = SuffixSlot{
321 static_cast<std::uint32_t>(entry_index + 1u),
322 static_cast<std::uint32_t>(offset),
323 };
324 ++suffix_count_;
325 }
326
327 void grow_suffix_index(std::size_t additional) {
328 auto capacity = std::size_t{16};
329 while (capacity < (suffix_count_ + additional + 1u) * 2u) {
330 capacity *= 2u;
331 }
332 const auto old_slots = suffix_slots_;
333 suffix_slots_.assign(capacity, SuffixSlot{});
334 suffix_count_ = 0;
335 for (const auto slot : old_slots) {
336 if (slot.entry_plus_one != 0) {
337 suffix_place(slot.entry_plus_one - 1u, slot.offset);
338 }
339 }
340 }
341
342 [[nodiscard]] auto path_span(const Entry& entry,
343 std::size_t offset = 0) const noexcept
344 -> std::span<const Coord3> {
345 if (entry.path_size <= offset) {
346 return {};
347 }
348 return std::span<const Coord3>{paths_.data() + entry.path_offset + offset,
349 entry.path_size - offset};
350 }
351
352 std::vector<Entry> entries_;
353 std::vector<Coord3> paths_;
354 std::vector<std::uint32_t> exact_slots_;
355 std::vector<SuffixSlot> suffix_slots_;
356 std::size_t suffix_count_ = 0;
357 std::size_t max_entries_ = default_max_entries;
358 std::size_t max_path_nodes_ = default_max_path_nodes;
359 std::size_t hits_ = 0;
360 std::size_t suffix_hits_ = 0;
361 std::size_t misses_ = 0;
362 std::size_t cap_invalidations_ = 0;
363 std::size_t oversized_skips_ = 0;
364 std::size_t class_rebinds_ = 0;
365 // Movement-class identity the entries are bound to (0 = unbound); see
366 // bind_class.
367 std::uintptr_t bound_class_ = 0;
368 std::uint64_t world_fingerprint_ = 0;
369 bool has_world_fingerprint_ = false;
370
371 template <typename World>
372 [[nodiscard]] static auto world_version_fingerprint(
373 const World& world) noexcept -> std::uint64_t {
374 if constexpr (std::is_same_v<typename World::residency_type,
376 // Dense: fold every chunk's content version (meta().version) in order.
377 auto fingerprint = std::uint64_t{0xcbf29ce484222325ull};
378 for (std::uint64_t i = 0; i < World::chunk_count; ++i) {
379 const auto version = world.meta(ChunkKey{i}).version;
380 fingerprint ^= i + 0x9e3779b97f4a7c15ull + (fingerprint << 6u) +
381 (fingerprint >> 2u);
382 fingerprint ^= version;
383 fingerprint *= 0x100000001b3ull;
384 }
385 return fingerprint;
386 } else {
387 // Sparse: fold only the resident set (bounded by resident_count, never
388 // chunk_count; meta()/residency_generation() are called only for keys
389 // from resident_chunk_keys(), so never on a non-resident slot). Each
390 // chunk contributes (key, residency_generation, content version):
391 // version catches in-place edits, and residency_generation -- world-
392 // monotonic and strictly greater on any reload, so it changes even when
393 // ensure_resident resets version to 0 -- catches evict/reload/swap. The
394 // per-key terms combine by a COMMUTATIVE sum, because
395 // resident_chunk_keys() order is not stable (eviction swap-with-last
396 // reorders it); an order- dependent chain would false-invalidate on a
397 // mere reorder.
398 const auto mix = [](std::uint64_t x) noexcept -> std::uint64_t {
399 x = (x ^ (x >> 30u)) * 0xbf58476d1ce4e5b9ull;
400 x = (x ^ (x >> 27u)) * 0x94d049bb133111ebull;
401 return x ^ (x >> 31u);
402 };
403 auto acc = std::uint64_t{0};
404 for (const auto key : world.resident_chunk_keys()) {
405 auto h = mix(key.value);
406 h ^= mix(h + world.residency_generation(key));
407 h ^= mix(h + static_cast<std::uint64_t>(world.meta(key).version));
408 acc += h;
409 }
410 return mix(acc + static_cast<std::uint64_t>(world.resident_count()) +
411 0x9e3779b97f4a7c15ull);
412 }
413 }
414};
415
416// Cache hits copy the cached route into `scratch.path_` and return a span
417// into that scratch, never into cache-owned storage. Hit and miss results
418// therefore share one lifetime contract: the span is valid until the next
419// path call that uses the same `PathScratch`. Cache-internal storage may
420// reallocate on any later miss without invalidating previously returned
421// spans backed by other scratches.
422//
423// STALENESS IS THE CALLER'S JOB, on dense and sparse alike: this function
424// never checks the world fingerprint (that costs O(chunk_count) per call by
425// design), so after any world edit the caller must run
426// cache.invalidate_if_world_changed(world) -- or invalidate()/clear() --
427// before the next lookup, or a stale route can be served. PathRequestRuntime
428// does this once per batch in prepare_process; direct callers own the same
429// obligation.
431template <typename World, typename Tag>
432auto cached_astar_path(const World& world, PathRequest request,
433 PathScratch& scratch, RouteCacheScratch& cache)
434 -> PathResult {
435 // Bind the cache to this call's movement class (normalized, so a raw tag
436 // and its WalkableField identity share entries): entries key on
437 // (start, goal) only, so a direct caller alternating classes must never be
438 // served the other class's route -- the rebind drops the cache instead.
439 cache.bind_class(detail::tag_identity<movement::movement_class_of<Tag>>());
440 // The cache stores absolute Coord3 keys and routes only (no residency-slot
441 // state). Correctness on sparse rests entirely on the residency-aware
442 // world_version_fingerprint plus prepare_process invalidating the whole cache
443 // before any serve: any evict, reload, or in-place edit changes the
444 // fingerprint and drops the cache, so a stale route can never be served. A
445 // miss runs sparse-native astar_path.
446 if (const auto* entry = cache.find(request); entry != nullptr) {
447 ++cache.hits_;
448 const auto cached = cache.path_span(*entry);
449 scratch.path_.assign(cached.begin(), cached.end());
450 return PathResult{
451 entry->status,
452 entry->cost,
453 0,
454 0,
455 std::span<const Coord3>{scratch.path_},
456 };
457 }
458 auto suffix_offset = std::size_t{0};
459 if (const auto* entry = cache.find_suffix(request, suffix_offset);
460 entry != nullptr) {
461 ++cache.suffix_hits_;
462 const auto suffix = cache.path_span(*entry, suffix_offset);
463 scratch.path_.assign(suffix.begin(), suffix.end());
464 return PathResult{
465 PathStatus::Found,
466 static_cast<std::uint32_t>(scratch.path_.size() - 1u),
467 0,
468 0,
469 std::span<const Coord3>{scratch.path_},
470 };
471 }
472
473 ++cache.misses_;
474 const auto result = astar_path<World, Tag>(world, request, scratch);
475 cache.store(request, result);
476 return result;
477}
478
479} // namespace tess
Definition path.h:535
friend auto cached_astar_path(const World &world, PathRequest request, PathScratch &scratch, RouteCacheScratch &cache) -> PathResult
Runs unit A* with exact and same-goal suffix reuse from caller-owned cache.
Definition route_cache.h:432
friend auto astar_path(const World &world, PathRequest request, PathScratch &scratch, MissingChunkPolicy policy) -> PathResult
constexpr auto size() const noexcept -> std::size_t
Definition path_view.h:41
constexpr auto begin() const noexcept
Definition path_view.h:62
constexpr auto end() const noexcept
Definition path_view.h:65
Bounded scratch cache for exact and same-goal suffix unit routes.
Definition route_cache.h:42
friend auto cached_astar_path(const World &world, PathRequest request, PathScratch &scratch, RouteCacheScratch &cache) -> PathResult
Runs unit A* with exact and same-goal suffix reuse from caller-owned cache.
Definition route_cache.h:432
Definition world.h:22
Definition world.h:18
Definition shape.h:67
Definition shape.h:30
Specifies inclusive start and goal coordinates for a path query.
Definition path.h:54
Definition path.h:63
Snapshot of unit-route cache occupancy, hits, misses, and invalidations.
Definition route_cache.h:14
Definition version.h:15