tess 1.0.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 <limits>
9#include <span>
10#include <type_traits>
11#include <utility>
12#include <vector>
13
14namespace tess {
15
16// How the unit route cache treats world edits between batches.
17//
18// WholeWorldExact: any content-version change anywhere drops every entry;
19// served routes are always identical to fresh recomputation.
20//
21// ScopedFeasible: entries record the chunks their route crosses and are
22// retired only when one of those chunks changes. Surviving routes are
23// guaranteed LEGAL (every step passable under the current world) with a
24// truthful served cost, and they were optimal when stored — but an edit
25// elsewhere that OPENS a shortcut can leave a served route suboptimal
26// until it is naturally retired. Under blocking-only (graph-monotone)
27// edits surviving routes remain optimal. Only unit-cost models without
28// special transitions qualify for scoped footprints; other models' entries
29// carry whole-world sensitivity and behave as in exact mode. Dense
30// (AlwaysResident) worlds only; sparse worlds fall back to exact behavior.
32enum class UnitRouteStaleness : std::uint8_t {
33 WholeWorldExact,
34 ScopedFeasible,
35};
36
39 // Resident entries INCLUDING scoped-mode tombstones awaiting compaction;
40 // live_entries excludes them.
41 std::size_t entries = 0;
42 std::size_t hits = 0;
43 std::size_t suffix_hits = 0;
44 std::size_t misses = 0;
45 std::size_t path_nodes = 0;
46 std::size_t cap_invalidations = 0;
47 std::size_t oversized_skips = 0;
48 // Whole-cache drops forced by a lookup with a different movement class
49 // than the cache was bound to (see cached_astar_path). Keep one cache per
50 // (world, class) to stay at zero.
51 std::size_t class_rebinds = 0;
52 std::size_t provider_rebinds = 0;
53 // Whole-cache drops forced by a lookup with a different MissingChunkPolicy.
54 // Always zero on a dense world, where the policy cannot change any answer
55 // and the binding is normalized away.
56 std::size_t policy_rebinds = 0;
57 std::size_t live_entries = 0;
58 // Scoped mode: dependency walks performed on first serve after an epoch
59 // change, entries that survived one, and entries retired by one.
60 std::size_t revalidations = 0;
61 std::size_t scoped_survivals = 0;
62 std::size_t retired_entries = 0;
63};
64
67 std::size_t max_entries = 512;
68 std::size_t max_path_nodes = std::size_t{1} << 20U;
69};
70
71// Exact (start, goal) lookups and same-goal suffix lookups are served by two
72// open-addressed flat hash indexes (power-of-two capacity, linear probing)
73// instead of linear scans. The suffix index is populated per stored
74// Found-path node with first-LIVE-write-wins, which preserves the earlier
75// linear-scan determinism: the earliest stored live entry containing a
76// queried suffix node keeps winning (scoped-mode retirement frees a slot's
77// claim; the next store covering that node re-owns it in place). Both
78// indexes are rebuilt from scratch on `invalidate()`/`clear()`.
79// Storage is bounded by entry and path-node caps;
80// an insert that would exceed either cap invalidates the whole cache first
81// (matching the world-change invalidation lifecycle) and counts a cap
82// invalidation in the stats, except a single route larger than the node cap,
83// which is skipped outright (stats().oversized_skips) so it cannot evict
84// resident entries and then violate the cap anyway. A cap of 0 disables
85// storage; it does not mean "unlimited".
86// Stateful-provider bindings include object address plus revision so two live
87// instances cannot alias. Copies/moves of the cache retain that external
88// binding; the provider itself must remain address-stable, and callers
89// must clear bound caches before ending its lifetime.
95 public:
96 static constexpr std::size_t default_max_entries = 512;
97 static constexpr std::size_t default_max_path_nodes = std::size_t{1} << 20u;
98
99 // A cap of 0 disables storage (every request recomputes); a single route
100 // larger than max_path_nodes is skipped without disturbing resident
101 // entries (counted in stats().oversized_skips).
102 void set_caps(UnitRouteCacheLimits limits) noexcept {
103 max_entries_ = limits.max_entries;
104 max_path_nodes_ = limits.max_path_nodes;
105 // The normal over-cap insertion policy invalidates the whole cache. Apply
106 // that same deterministic policy immediately when a caller lowers either
107 // cap below the live footprint; otherwise existing hits could bypass a
108 // newly configured zero/smaller limit indefinitely.
109 if (entries_.size() > max_entries_ || paths_.size() > max_path_nodes_) {
110 invalidate();
111 ++cap_invalidations_;
112 }
113 }
114
115 void reserve_routes(std::size_t route_count) {
116 entries_.reserve(route_count);
117 }
118
119 void reserve_path_nodes(std::size_t node_count) {
120 paths_.reserve(node_count);
121 }
122
123 void clear() noexcept {
124 invalidate();
125 bound_class_ = 0;
126 bound_provider_type_ = 0;
127 bound_provider_instance_ = nullptr;
128 bound_provider_revision_ = 0;
129 // Reset with the other bindings, not just the counter: leaving the
130 // policy bound across a clear() makes the next lookup under the other
131 // policy count a rebind and invalidate an already-empty cache.
132 policy_bound_ = false;
133 bound_policy_ = MissingChunkPolicy::ReportIndeterminate;
134 hits_ = 0;
135 suffix_hits_ = 0;
136 misses_ = 0;
137 cap_invalidations_ = 0;
138 oversized_skips_ = 0;
139 class_rebinds_ = 0;
140 provider_rebinds_ = 0;
141 policy_rebinds_ = 0;
142 revalidations_ = 0;
143 scoped_survivals_ = 0;
144 retired_entries_ = 0;
145 }
146
147 // Entries are keyed on (start, goal) — with staleness carried by the
148 // world fingerprint (exact mode) or per-chunk dependency records (scoped
149 // mode) — and nothing on the movement class, so the cache binds itself to
150 // the class of each cached_astar_path call: a rebind drops every entry
151 // (correct even on misuse) and counts in stats().class_rebinds. One cache
152 // per (world, class) is the PERF contract, not a correctness precondition.
153 void bind_class(std::uintptr_t identity) noexcept {
154 if (bound_class_ == identity) {
155 return;
156 }
157 if (bound_class_ != 0) {
158 invalidate();
159 ++class_rebinds_;
160 }
161 bound_class_ = identity;
162 }
163
164 void bind_provider(std::uintptr_t type_identity,
165 const void* instance_identity,
166 std::uint64_t revision) noexcept {
167 if (bound_provider_type_ == type_identity &&
168 bound_provider_instance_ == instance_identity &&
169 bound_provider_revision_ == revision) {
170 return;
171 }
172 if (bound_provider_type_ != 0) {
173 invalidate();
174 ++provider_rebinds_;
175 }
176 bound_provider_type_ = type_identity;
177 bound_provider_instance_ = instance_identity;
178 bound_provider_revision_ = revision;
179 }
180
181 // Entries key on (start, goal) and carry no policy, so an entry computed
182 // under one MissingChunkPolicy must never be served to a caller who asked
183 // for the other: the two disagree precisely on the terminal status when a
184 // search exhausted the resident set having skipped a non-resident
185 // neighbour -- NoPath under AssumeImpassable, Indeterminate under
186 // Indeterminate. Binding at whole-cache granularity rather than widening
187 // the key keeps Found entries, which are policy-invariant and are the
188 // entire suffix-index substrate, from being duplicated per policy.
189 //
190 // Callers pass the normalized policy: it cannot change any answer on a
191 // dense world, where no chunk can be missing, so cached_astar_path binds
192 // a constant there and a generic caller alternating policies does not
193 // pointlessly drop the cache.
194 void bind_missing_chunk_policy(MissingChunkPolicy policy) noexcept {
195 if (policy_bound_ && bound_policy_ == policy) {
196 return;
197 }
198 if (policy_bound_) {
199 invalidate();
200 ++policy_rebinds_;
201 }
202 policy_bound_ = true;
203 bound_policy_ = policy;
204 }
205
206 void invalidate() noexcept {
207 entries_.clear();
208 paths_.clear();
209 deps_.clear();
210 exact_slots_.clear();
211 suffix_slots_.clear();
212 suffix_count_ = 0;
213 dead_count_ = 0;
214 }
215
216 // Selects the staleness policy. Switching modes drops every entry
217 // unconditionally — entries stored under one mode's semantics are never
218 // served under the other's — independent of any runtime policy flag, and
219 // resets BOTH staleness detectors to uncaptured: a stale fingerprint (or
220 // snapshot) surviving a flip would re-report the other mode's edits on
221 // the first refresh, spuriously advancing invalidation stats and the
222 // deep-clear cadence.
223 void set_staleness(UnitRouteStaleness staleness) noexcept {
224 if (staleness_ == staleness) {
225 return;
226 }
227 invalidate();
228 staleness_ = staleness;
229 has_world_fingerprint_ = false;
230 world_fingerprint_ = 0;
231 content_version_snapshot_.clear();
232 }
233
234 [[nodiscard]] auto staleness() const noexcept -> UnitRouteStaleness {
235 return staleness_;
236 }
237
238 // Total budget for stored dependency pairs (scoped mode), with the same
239 // two-rule lifecycle as the path-node cap: a single route whose collapsed
240 // footprint alone exceeds the budget is skipped without evicting
241 // residents (oversized-skip), and a store whose footprint no longer fits
242 // beside the resident blob invalidates the whole cache first (cap
243 // invalidation). Lowering the budget below the resident blob applies the
244 // same policy immediately, matching set_caps.
245 void set_dependency_cap(std::size_t max_dependency_pairs) noexcept {
246 max_dependency_pairs_ = max_dependency_pairs;
247 if (deps_.size() > max_dependency_pairs_) {
248 invalidate();
249 ++cap_invalidations_;
250 }
251 }
252
253 // Scoped-mode analog of invalidate_if_world_changed, and the single
254 // staleness entry point for both modes: exact mode (and sparse worlds,
255 // which scoped V1 excludes) delegates to the fingerprint drop; scoped
256 // dense mode compares an exact per-content version snapshot — no hashing
257 // in the staleness decision — and on any difference bumps the epoch that
258 // lazy per-entry validation checks against. Returns true when a world
259 // change was detected (entries dropped in exact mode; revalidation armed
260 // in scoped mode).
261 template <typename World>
262 [[nodiscard]] auto refresh_if_world_changed(const World& world) -> bool {
263 if constexpr (!std::is_same_v<typename World::residency_type,
265 return invalidate_if_world_changed(world);
266 } else {
267 if (staleness_ != UnitRouteStaleness::ScopedFeasible) {
268 return invalidate_if_world_changed(world);
269 }
270 if (content_version_snapshot_.size() != World::chunk_count) {
271 content_version_snapshot_.resize(World::chunk_count);
272 for (std::uint64_t i = 0; i < World::chunk_count; ++i) {
273 content_version_snapshot_[i] =
274 world.meta(ChunkKey{i}).content_version;
275 }
276 // Entries stored before the first refresh were stamped with the
277 // current epoch, but edits between their store and this baseline
278 // capture are invisible to the snapshot. Their per-entry
279 // dependency versions ARE store-time-accurate, so forcing them
280 // through one validation walk (by bumping the epoch) catches any
281 // such edit; an empty cache skips the bump, so the common
282 // refresh-before-first-use sequence is unaffected.
283 if (entries_.empty()) {
284 return false;
285 }
286 ++change_epoch_;
287 return true;
288 }
289 // Versions live inside per-chunk meta, not contiguously: compare and
290 // update in one loop rather than gathering for a memcmp.
291 auto changed = false;
292 for (std::uint64_t i = 0; i < World::chunk_count; ++i) {
293 const auto content_version = world.meta(ChunkKey{i}).content_version;
294 if (content_version_snapshot_[i] != content_version) {
295 content_version_snapshot_[i] = content_version;
296 changed = true;
297 }
298 }
299 if (!changed) {
300 return false;
301 }
302 ++change_epoch_;
303 if (change_epoch_ == 0) {
304 invalidate(); // Epoch wrap: practically unreachable; clear anyway.
305 ++change_epoch_;
306 }
307 return true;
308 }
309 }
310
311 void reset_stats() noexcept {
312 hits_ = 0;
313 suffix_hits_ = 0;
314 misses_ = 0;
315 cap_invalidations_ = 0;
316 oversized_skips_ = 0;
317 revalidations_ = 0;
318 scoped_survivals_ = 0;
319 retired_entries_ = 0;
320 }
321
322 // The fingerprint identifies world CONTENT VERSIONS, not a world
323 // instance: two same-shape worlds whose chunks carry identical
324 // content-version counters (e.g. both populated without mark_dirty) alias,
325 // and a cache reused across them would serve one world's routes for the
326 // other. Keep one cache per world; only the sparse path self-identifies its
327 // world (residency_generation is world-monotonic).
328 template <typename World>
329 void capture_world_versions(const World& world) noexcept {
330 world_fingerprint_ = world_content_fingerprint(world);
331 has_world_fingerprint_ = true;
332 }
333
334 template <typename World>
335 [[nodiscard]] auto invalidate_if_world_changed(const World& world) noexcept
336 -> bool {
337 if (!has_world_fingerprint_) {
338 capture_world_versions(world);
339 return false;
340 }
341 const auto current = world_content_fingerprint(world);
342 if (current == world_fingerprint_) {
343 return false;
344 }
345 invalidate();
346 world_fingerprint_ = current;
347 has_world_fingerprint_ = true;
348 return true;
349 }
350
351 [[nodiscard]] auto stats() const noexcept -> UnitRouteCacheStats {
352 return UnitRouteCacheStats{
353 entries_.size(),
354 hits_,
355 suffix_hits_,
356 misses_,
357 paths_.size(),
358 cap_invalidations_,
359 oversized_skips_,
360 class_rebinds_,
361 provider_rebinds_,
362 policy_rebinds_,
363 entries_.size() - dead_count_,
364 revalidations_,
365 scoped_survivals_,
366 retired_entries_,
367 };
368 }
369
370 private:
371 struct Entry {
372 Coord3 start{};
373 Coord3 goal{};
374 PathStatus status = PathStatus::NotComputed;
375 std::uint32_t cost = 0;
376 std::uint32_t cost_scale = 1;
377 std::size_t expanded_nodes = 0;
378 std::size_t reached_nodes = 0;
379 std::size_t path_offset = 0;
380 std::size_t path_size = 0;
381 // Scoped mode only. whole_world marks entries with no sound chunk
382 // footprint (non-Found results, ineligible transition models): they
383 // fail validation on ANY epoch change — an empty dep list must never
384 // read as "depends on nothing".
385 std::size_t dep_offset = 0;
386 std::size_t dep_count = 0;
387 std::uint64_t validated_epoch = 0;
388 bool alive = true;
389 bool whole_world = false;
390 };
391
392 // One collapsed (chunk, captured content version) dependency of a stored
393 // route; validation compares against the chunk's current content version.
394 struct DepPair {
395 std::uint64_t key = 0;
396 ContentVersion content_version{};
397 };
398
399 struct SuffixSlot {
400 std::uint32_t entry_plus_one = 0;
401 std::uint32_t offset = 0;
402 };
403
404 template <typename World, typename Tag>
405 friend auto cached_astar_path(const World& world, PathRequest request,
406 PathScratch& scratch, UnitRouteCache& cache,
407 MissingChunkPolicy policy) -> PathResult;
408
409 template <typename World, typename Tag, typename Provider>
410 friend auto cached_astar_path(const World& world, PathRequest request,
411 PathScratch& scratch, UnitRouteCache& cache,
412 const Provider& provider,
413 MissingChunkPolicy policy) -> PathResult;
414
415 // FNV-style lane combine with one final avalanche: cheap per stored path
416 // node, well distributed for power-of-two linear probing.
417 [[nodiscard]] static auto hash_pair(Coord3 first, Coord3 second) noexcept
418 -> std::uint64_t {
419 auto hash = std::uint64_t{0xcbf29ce484222325ull};
420 hash = (hash ^ static_cast<std::uint64_t>(first.x)) * 0x100000001b3ull;
421 hash = (hash ^ static_cast<std::uint64_t>(first.y)) * 0x100000001b3ull;
422 hash = (hash ^ static_cast<std::uint64_t>(first.z)) * 0x100000001b3ull;
423 hash = (hash ^ static_cast<std::uint64_t>(second.x)) * 0x100000001b3ull;
424 hash = (hash ^ static_cast<std::uint64_t>(second.y)) * 0x100000001b3ull;
425 hash = (hash ^ static_cast<std::uint64_t>(second.z)) * 0x100000001b3ull;
426 hash = (hash ^ (hash >> 30u)) * 0xbf58476d1ce4e5b9ull;
427 hash = (hash ^ (hash >> 27u)) * 0x94d049bb133111ebull;
428 return hash ^ (hash >> 31u);
429 }
430
431 // Dead (retired) occupants do not terminate the probe: at most one LIVE
432 // entry exists per exact key, so skipping tombstones cannot skip a match.
433 [[nodiscard]] auto find(PathRequest request) noexcept -> Entry* {
434 if (exact_slots_.empty()) {
435 return nullptr;
436 }
437 const auto mask = exact_slots_.size() - 1u;
438 auto slot =
439 static_cast<std::size_t>(hash_pair(request.start, request.goal)) & mask;
440 while (exact_slots_[slot] != 0) {
441 auto& entry = entries_[exact_slots_[slot] - 1u];
442 if (entry.alive && entry.start == request.start &&
443 entry.goal == request.goal) {
444 return &entry;
445 }
446 slot = (slot + 1u) & mask;
447 }
448 return nullptr;
449 }
450
451 [[nodiscard]] auto find_suffix(PathRequest request,
452 std::size_t& suffix_offset) noexcept
453 -> Entry* {
454 if (suffix_slots_.empty()) {
455 return nullptr;
456 }
457 const auto mask = suffix_slots_.size() - 1u;
458 auto slot =
459 static_cast<std::size_t>(hash_pair(request.start, request.goal)) & mask;
460 while (suffix_slots_[slot].entry_plus_one != 0) {
461 const auto& candidate = suffix_slots_[slot];
462 auto& entry = entries_[candidate.entry_plus_one - 1u];
463 if (entry.alive && entry.goal == request.goal &&
464 paths_[entry.path_offset + candidate.offset] == request.start) {
465 suffix_offset = candidate.offset;
466 return &entry;
467 }
468 slot = (slot + 1u) & mask;
469 }
470 return nullptr;
471 }
472
473 // Scope-ineligible models take the approved exact lifecycle under scoped
474 // mode: a whole-cache invalidation on the first lookup after each epoch
475 // change, instead of accumulating per-entry tombstones. (Their entries
476 // also carry whole_world as a second line of defense for mixed use.)
477 void sync_ineligible_epoch() noexcept {
478 if (staleness_ != UnitRouteStaleness::ScopedFeasible) {
479 return;
480 }
481 if (ineligible_synced_epoch_ == change_epoch_) {
482 return;
483 }
484 if (!entries_.empty()) {
485 invalidate();
486 }
487 ineligible_synced_epoch_ = change_epoch_;
488 }
489
490 void retire(Entry& entry) noexcept {
491 entry.alive = false;
492 ++dead_count_;
493 ++retired_entries_;
494 // Tombstones hold entries_/paths_ footprint until compaction; past
495 // half-dead the whole cache is dropped (the same deterministic
496 // lifecycle as a cap invalidation). NOTE: invalidate() empties
497 // entries_, so the caller must not touch the entry afterward.
498 if (dead_count_ * 2u > max_entries_ && max_entries_ != 0) {
499 invalidate();
500 }
501 }
502
503 // Scoped-mode serve gate: exact mode always serves; a scoped entry
504 // already validated this epoch serves on one compare; otherwise its
505 // dependency pairs are walked against current content versions —
506 // whole-world entries fail unconditionally — and the entry is either
507 // stamped or retired. Retiring here cannot orphan a better match: store
508 // only runs after a live-match miss, so the retired entry was the only
509 // live occupant for its key. On a false return the entry reference is
510 // dead (and possibly dangling after compaction) — do not touch it.
511 template <typename World>
512 [[nodiscard]] auto validate_for_serve(const World& world,
513 Entry& entry) noexcept -> bool {
514 if (staleness_ != UnitRouteStaleness::ScopedFeasible) {
515 return true;
516 }
517 if (entry.validated_epoch == change_epoch_) {
518 return true;
519 }
520 if (entry.whole_world) {
521 retire(entry);
522 return false;
523 }
524 ++revalidations_;
525 for (std::size_t i = 0; i < entry.dep_count; ++i) {
526 const auto& dep = deps_[entry.dep_offset + i];
527 if (world.meta(ChunkKey{dep.key}).content_version !=
528 dep.content_version) {
529 retire(entry);
530 return false;
531 }
532 }
533 entry.validated_epoch = change_epoch_;
534 ++scoped_survivals_;
535 return true;
536 }
537
538 template <typename World, bool ScopeEligible>
539 void store(const World& world, PathRequest request,
540 const PathResult& result) {
541 using Shape = typename World::shape_type;
542 // Cap value 0 disables storage entirely, matching the portal segment
543 // cache's budget semantics; it does not mean "unlimited".
544 if (max_entries_ == 0 || max_path_nodes_ == 0) {
545 return;
546 }
547 // A single result larger than the node cap can never fit; skip it
548 // instead of invalidating resident entries and then violating the cap.
549 if (result.path.size() > max_path_nodes_) {
550 ++oversized_skips_;
551 return;
552 }
553 // Scoped mode: collapse the route's chunk footprint before touching
554 // storage, so a footprint over the dependency cap is skipped without
555 // evicting residents (the oversized-path rule, applied to deps).
556 // Non-Found results and scope-ineligible models get no footprint —
557 // they carry whole-world sensitivity instead (an empty dep list must
558 // never mean "depends on nothing").
559 const auto scoped =
560 staleness_ == UnitRouteStaleness::ScopedFeasible &&
561 std::is_same_v<typename World::residency_type, AlwaysResident>;
562 const auto scoped_footprint =
563 scoped && ScopeEligible && result.status == PathStatus::Found;
564 dep_scratch_.clear();
565 if (scoped_footprint) {
566 auto previous = std::numeric_limits<std::uint64_t>::max();
567 for (const auto node : result.path) {
568 const auto key = chunk_key<Shape>(tile_key<Shape>(node)).value;
569 if (key != previous) {
570 dep_scratch_.push_back(key);
571 previous = key;
572 }
573 }
574 if (dep_scratch_.size() > max_dependency_pairs_) {
575 ++oversized_skips_;
576 return;
577 }
578 }
579 if (entries_.size() + 1u > max_entries_ ||
580 paths_.size() + result.path.size() > max_path_nodes_ ||
581 deps_.size() + dep_scratch_.size() > max_dependency_pairs_) {
582 invalidate();
583 ++cap_invalidations_;
584 }
585 const auto entry_index = entries_.size();
586 const auto path_offset = paths_.size();
587 const auto dep_offset = deps_.size();
588 paths_.insert(paths_.end(), result.path.begin(), result.path.end());
589 for (const auto key : dep_scratch_) {
590 deps_.push_back(DepPair{key, world.meta(ChunkKey{key}).content_version});
591 }
592 entries_.push_back(Entry{
593 request.start,
594 request.goal,
595 result.status,
596 result.cost,
597 result.cost_scale,
598 result.expanded_nodes,
599 result.reached_nodes,
600 path_offset,
601 result.path.size(),
602 dep_offset,
603 dep_scratch_.size(),
604 change_epoch_,
605 true,
606 scoped && !scoped_footprint,
607 });
608 exact_insert(entry_index);
609 if (result.status == PathStatus::Found) {
610 suffix_insert(entry_index);
611 }
612 }
613
614 void exact_insert(std::size_t entry_index) {
615 if (exact_slots_.size() < (entries_.size() + 1u) * 2u) {
616 grow_exact_index();
617 return;
618 }
619 exact_place(entry_index);
620 }
621
622 void exact_place(std::size_t entry_index) noexcept {
623 const auto mask = exact_slots_.size() - 1u;
624 const auto& entry = entries_[entry_index];
625 auto slot =
626 static_cast<std::size_t>(hash_pair(entry.start, entry.goal)) & mask;
627 while (exact_slots_[slot] != 0) {
628 slot = (slot + 1u) & mask;
629 }
630 exact_slots_[slot] = static_cast<std::uint32_t>(entry_index + 1u);
631 }
632
633 // Index rebuilds drop tombstoned entries' slots (the natural compaction
634 // point); the dead entries themselves stay in entries_ until a full
635 // invalidation reclaims their footprint.
636 void grow_exact_index() {
637 auto capacity = std::size_t{16};
638 while (capacity < (entries_.size() + 1u) * 2u) {
639 capacity *= 2u;
640 }
641 exact_slots_.assign(capacity, 0u);
642 for (std::size_t i = 0; i < entries_.size(); ++i) {
643 if (entries_[i].alive) {
644 exact_place(i);
645 }
646 }
647 }
648
649 // First-LIVE-write-wins per (node, goal): the earliest stored live entry
650 // containing a node keeps serving suffix queries for it, matching the
651 // pre-index linear-scan order. A dead occupant's claim is overwritten in
652 // place so retirement can never suppress suffix reuse permanently.
653 void suffix_insert(std::size_t entry_index) {
654 const auto& entry = entries_[entry_index];
655 if (suffix_slots_.size() < (suffix_count_ + entry.path_size + 1u) * 2u) {
656 grow_suffix_index(entry.path_size);
657 }
658 for (std::size_t i = 0; i < entry.path_size; ++i) {
659 suffix_place(entry_index, i);
660 }
661 }
662
663 void suffix_place(std::size_t entry_index, std::size_t offset) noexcept {
664 const auto mask = suffix_slots_.size() - 1u;
665 const auto& entry = entries_[entry_index];
666 const auto node = paths_[entry.path_offset + offset];
667 auto slot = static_cast<std::size_t>(hash_pair(node, entry.goal)) & mask;
668 while (suffix_slots_[slot].entry_plus_one != 0) {
669 const auto& occupant = suffix_slots_[slot];
670 const auto& occupant_entry = entries_[occupant.entry_plus_one - 1u];
671 if (occupant_entry.goal == entry.goal &&
672 paths_[occupant_entry.path_offset + occupant.offset] == node) {
673 if (occupant_entry.alive) {
674 return; // First live write wins.
675 }
676 // Dead occupant for this (node, goal): reuse its slot in place,
677 // preserving one-slot-per-(node, goal) with no chain growth.
678 suffix_slots_[slot] = SuffixSlot{
679 static_cast<std::uint32_t>(entry_index + 1u),
680 static_cast<std::uint32_t>(offset),
681 };
682 return;
683 }
684 slot = (slot + 1u) & mask;
685 }
686 suffix_slots_[slot] = SuffixSlot{
687 static_cast<std::uint32_t>(entry_index + 1u),
688 static_cast<std::uint32_t>(offset),
689 };
690 ++suffix_count_;
691 }
692
693 void grow_suffix_index(std::size_t additional) {
694 auto capacity = std::size_t{16};
695 while (capacity < (suffix_count_ + additional + 1u) * 2u) {
696 capacity *= 2u;
697 }
698 // Moved, not copied: the old table is only read below, and `assign`
699 // reallocates regardless because `capacity` only ever grows here, so
700 // the copy bought nothing.
701 const auto old_slots = std::move(suffix_slots_);
702 suffix_slots_.assign(capacity, SuffixSlot{});
703 suffix_count_ = 0;
704 for (const auto slot : old_slots) {
705 if (slot.entry_plus_one != 0 &&
706 entries_[slot.entry_plus_one - 1u].alive) {
707 suffix_place(slot.entry_plus_one - 1u, slot.offset);
708 }
709 }
710 }
711
712 [[nodiscard]] auto path_span(const Entry& entry,
713 std::size_t offset = 0) const noexcept
714 -> std::span<const Coord3> {
715 if (entry.path_size <= offset) {
716 return {};
717 }
718 return std::span<const Coord3>{paths_.data() + entry.path_offset + offset,
719 entry.path_size - offset};
720 }
721
722 std::vector<Entry> entries_;
723 std::vector<Coord3> paths_;
724 std::vector<DepPair> deps_;
725 std::vector<std::uint64_t> dep_scratch_;
726 std::vector<std::uint32_t> exact_slots_;
727 std::vector<SuffixSlot> suffix_slots_;
728 // Scoped mode: exact per-chunk content versions as of the last refresh
729 // (never hashed), and the epoch lazy validation stamps against.
730 std::vector<ContentVersion> content_version_snapshot_;
731 std::uint64_t change_epoch_ = 1;
732 std::uint64_t ineligible_synced_epoch_ = 0;
733 std::size_t dead_count_ = 0;
734 std::size_t revalidations_ = 0;
735 std::size_t scoped_survivals_ = 0;
736 std::size_t retired_entries_ = 0;
737 UnitRouteStaleness staleness_ = UnitRouteStaleness::WholeWorldExact;
738 std::size_t suffix_count_ = 0;
739 std::size_t max_entries_ = default_max_entries;
740 std::size_t max_path_nodes_ = default_max_path_nodes;
741 std::size_t max_dependency_pairs_ = default_max_path_nodes / 8u;
742 std::size_t hits_ = 0;
743 std::size_t suffix_hits_ = 0;
744 std::size_t misses_ = 0;
745 std::size_t cap_invalidations_ = 0;
746 std::size_t oversized_skips_ = 0;
747 std::size_t class_rebinds_ = 0;
748 std::size_t provider_rebinds_ = 0;
749 std::size_t policy_rebinds_ = 0;
750 // MissingChunkPolicy the entries were computed under. Unset until the
751 // first lookup binds it; see bind_missing_chunk_policy.
752 bool policy_bound_ = false;
753 MissingChunkPolicy bound_policy_ = MissingChunkPolicy::ReportIndeterminate;
754 // Movement-class identity the entries are bound to (0 = unbound); see
755 // bind_class.
756 std::uintptr_t bound_class_ = 0;
757 std::uintptr_t bound_provider_type_ = 0;
758 const void* bound_provider_instance_ = nullptr;
759 std::uint64_t bound_provider_revision_ = 0;
760 std::uint64_t world_fingerprint_ = 0;
761 bool has_world_fingerprint_ = false;
762
763 template <typename World>
764 [[nodiscard]] static auto world_content_fingerprint(
765 const World& world) noexcept -> std::uint64_t {
766 if constexpr (std::is_same_v<typename World::residency_type,
768 // Dense: fold every chunk's content version in order.
769 auto fingerprint = std::uint64_t{0xcbf29ce484222325ull};
770 for (std::uint64_t i = 0; i < World::chunk_count; ++i) {
771 const auto content_version = world.meta(ChunkKey{i}).content_version;
772 fingerprint ^= i + 0x9e3779b97f4a7c15ull + (fingerprint << 6u) +
773 (fingerprint >> 2u);
774 fingerprint ^= content_version.value;
775 fingerprint *= 0x100000001b3ull;
776 }
777 return fingerprint;
778 } else {
779 // Sparse: fold only the resident set (bounded by resident_count, never
780 // chunk_count; meta()/residency_generation() are called only for keys
781 // from resident_chunk_keys(), so never on a non-resident slot). Each
782 // chunk contributes (key, residency_generation, content version): the
783 // content version catches in-place edits, and residency_generation --
784 // world-monotonic and strictly greater on any rematerialization, so it
785 // changes even
786 // when ensure_resident resets the content version to 0 -- catches
787 // eviction/rematerialization/swap. The
788 // per-key terms combine by a COMMUTATIVE sum, because
789 // resident_chunk_keys() order is not stable (eviction swap-with-last
790 // reorders it); an order- dependent chain would false-invalidate on a
791 // mere reorder.
792 const auto mix = [](std::uint64_t x) noexcept -> std::uint64_t {
793 x = (x ^ (x >> 30u)) * 0xbf58476d1ce4e5b9ull;
794 x = (x ^ (x >> 27u)) * 0x94d049bb133111ebull;
795 return x ^ (x >> 31u);
796 };
797 auto acc = std::uint64_t{0};
798 for (const auto key : world.resident_chunk_keys()) {
799 auto h = mix(key.value);
800 h ^= mix(h + world.residency_generation(key).value);
801 h ^= mix(h + world.meta(key).content_version.value);
802 acc += h;
803 }
804 return mix(acc + static_cast<std::uint64_t>(world.resident_count()) +
805 0x9e3779b97f4a7c15ull);
806 }
807 }
808};
809
810// Cache hits copy the cached route into `scratch.path_` and return a span
811// into that scratch, never into cache-owned storage. Hit and miss results
812// therefore share one lifetime contract: the span is valid until the next
813// path call that uses the same `PathScratch`. Cache-internal storage may
814// reallocate on any later miss without invalidating previously returned
815// spans backed by other scratches.
816//
817// STALENESS DETECTION IS THE CALLER'S JOB, on dense and sparse alike: this
818// function never scans the world's versions itself (that costs
819// O(chunk_count) per call by design), so after any world edit the caller
820// must run cache.refresh_if_world_changed(world) — or the exact-mode
821// invalidate_if_world_changed / invalidate()/clear() — before the next
822// lookup, or a stale route can be served. PathRequestRuntime does this once
823// per batch in prepare_process; direct callers own the same obligation. In
824// ScopedFeasible mode the refresh arms per-entry dependency validation
825// (performed inside this call at serve time) instead of dropping entries.
827template <typename World, typename Tag, typename Provider>
828[[nodiscard]] auto cached_astar_path(const World& world, PathRequest request,
829 PathScratch& scratch,
830 UnitRouteCache& cache,
831 const Provider& provider,
832 MissingChunkPolicy policy) -> PathResult {
833 using Class = movement::movement_class_of<Tag>;
834 using UnitClass = movement::detail::UnitMovementClass<Class>;
836 const auto model = Model{provider};
837 // Bind the cache to this call's movement class (normalized, so a raw tag
838 // and its UnitCostFieldMovement identity share entries): entries key on
839 // (start, goal) only, so a direct caller alternating classes must never be
840 // served the other class's route -- the rebind drops the cache instead.
841 cache.bind_class(detail::tag_identity<movement::movement_class_of<Tag>>());
842 cache.bind_provider(detail::tag_identity<Provider>(),
843 detail::transition_provider_instance_identity(provider),
844 model.revision());
845 // Normalize on dense worlds: no chunk can be missing there, so the policy
846 // cannot change any answer and binding the caller's value would drop the
847 // cache for a generic caller that alternates policies across world types.
848 constexpr bool dense =
849 std::is_same_v<typename World::residency_type, AlwaysResident>;
850 cache.bind_missing_chunk_policy(dense ? MissingChunkPolicy::AssumeImpassable
851 : policy);
852 // The cache stores absolute Coord3 keys and routes only (no residency-slot
853 // state). Correctness on sparse rests entirely on the residency-aware
854 // world_content_fingerprint plus prepare_process invalidating the whole
855 // cache before any serve — sparse worlds are excluded from ScopedFeasible
856 // mode in V1 and always take the exact-fingerprint lifecycle: any evict,
857 // rematerialization, or in-place edit changes the fingerprint and drops the
858 // cache, so a stale route can never be served. A miss runs sparse-native
859 // astar_path.
860 //
861 // Scope eligibility mirrors the suffix-reuse condition: with unit step
862 // cost and no special transitions, every tile an accepted step reads lies
863 // on the stored path, so the path's chunk footprint is the exact
864 // feasibility dependency set. Other models' entries carry whole-world
865 // sensitivity (retired on any epoch change), preserving exact-mode
866 // behavior per entry.
867 constexpr auto scope_eligible =
868 Model::cost_scale == 1 && !Model::has_special_transitions;
869 if constexpr (!scope_eligible) {
870 cache.sync_ineligible_epoch();
871 }
872 if (auto* entry = cache.find(request); entry != nullptr) {
873 if (cache.validate_for_serve(world, *entry)) {
874 ++cache.hits_;
875 const auto cached = cache.path_span(*entry);
876 scratch.path_.assign(cached.begin(), cached.end());
877 return PathResult{
878 entry->status,
879 entry->cost,
880 0,
881 0,
882 std::span<const Coord3>{scratch.path_},
883 entry->cost_scale,
884 };
885 }
886 }
887 if constexpr (scope_eligible) {
888 auto suffix_offset = std::size_t{0};
889 if (auto* entry = cache.find_suffix(request, suffix_offset);
890 entry != nullptr) {
891 if (cache.validate_for_serve(world, *entry)) {
892 ++cache.suffix_hits_;
893 const auto suffix = cache.path_span(*entry, suffix_offset);
894 scratch.path_.assign(suffix.begin(), suffix.end());
895 return PathResult{
896 PathStatus::Found,
897 static_cast<std::uint32_t>(scratch.path_.size() - 1u),
898 0,
899 0,
900 std::span<const Coord3>{scratch.path_},
901 };
902 }
903 }
904 }
905
906 ++cache.misses_;
907 const auto result = [&] {
908 if constexpr (std::is_same_v<Provider, AdjacentTransitions>) {
909 return astar_path<World, Tag>(world, request, scratch, policy);
910 } else {
911 return astar_path<World, Tag, Provider>(world, request, scratch, policy,
912 provider);
913 }
914 }();
915 cache.template store<World, scope_eligible>(world, request, result);
916 return result;
917}
918
919template <typename World, typename Tag>
921[[nodiscard]] auto cached_astar_path(const World& world, PathRequest request,
922 PathScratch& scratch,
923 UnitRouteCache& cache,
924 MissingChunkPolicy policy) -> PathResult {
926 world, request, scratch, cache, AdjacentTransitions{}, policy);
927}
928
929} // namespace tess
Definition path.h:741
friend auto astar_path(const World &world, PathRequest request, PathScratch &scratch, MissingChunkPolicy policy) -> PathResult
friend auto cached_astar_path(const World &world, PathRequest request, PathScratch &scratch, UnitRouteCache &cache, MissingChunkPolicy policy) -> PathResult
Finds a cached empty-provider route or computes and stores one.
Definition route_cache.h:921
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
Definition transition_model.h:380
Definition route_cache.h:94
friend auto cached_astar_path(const World &world, PathRequest request, PathScratch &scratch, UnitRouteCache &cache, MissingChunkPolicy policy) -> PathResult
Finds a cached empty-provider route or computes and stores one.
Definition route_cache.h:921
Definition world.h:22
Supplies no special transitions beyond ordinary face adjacency.
Definition transition_provider.h:132
Definition world.h:18
Definition shape.h:86
Definition metadata_types.h:86
Definition shape.h:46
Specifies inclusive start and goal coordinates for a path query.
Definition request.h:10
Definition path.h:60
Definition shape.h:296
Bounds retained route count and aggregate cached path-node storage.
Definition route_cache.h:66
Snapshot of unit-route cache occupancy, hits, misses, and invalidations.
Definition route_cache.h:38