tess 1.0.0
Performance-first tile and path simulation substrate
Loading...
Searching...
No Matches
portal_segment_cache.h
1#pragma once
2
3#include <tess/core/capacity.h>
4#include <tess/core/config.h>
5#include <tess/core/fail_fast.h>
6#include <tess/core/tag_identity.h>
7#include <tess/path/path.h>
8
9#include <algorithm>
10#include <cstddef>
11#include <cstdint>
12#include <limits>
13#include <span>
14#include <stdexcept>
15#include <type_traits>
16#include <utility>
17#include <vector>
18
19namespace tess {
20
22struct SegmentHit {
23 bool found = false;
24 PathStatus status = PathStatus::NotComputed;
25 std::uint32_t cost = 0;
26};
27
30 std::size_t entries = 0;
31 std::size_t path_nodes = 0;
32 std::size_t sweeps = 0;
33 std::size_t evictions = 0;
34 std::size_t stale_rejections = 0;
35 // Whole-cache drops when for_class() binds a different movement class.
36 std::size_t class_rebinds = 0;
37};
38
40enum class PortalSegmentStoreStatus : std::uint8_t {
41 Completed,
42 CapacityExceeded,
43};
44
45// Cached segment paths are only handed out by appending into caller-owned
46// storage. The cache never returns pointers or spans into its own path
47// storage, so later `store()` growth cannot invalidate a previous lookup.
48//
49// Storage is bounded by a segment budget (default 256 entries). When a store
50// reaches the budget it first sweeps stale entries in one compaction pass
51// (rebuilding both the entry list and the path-node append arena, so stale
52// path storage is reclaimed), then evicts the oldest live entries in
53// insertion order if the sweep alone cannot make room. A zero budget stores
54// nothing. Entries belong to one movement class: `for_class()` safely clears
55// the cache when that class changes. Keep one cache per (world, class) to avoid
56// that conservative whole-cache fallback on the hot path.
62 public:
63 static constexpr std::size_t default_segment_budget = 256;
64
65 void set_segment_budget(std::size_t budget) noexcept {
66 budget_ = budget;
67 if (entries_.size() > budget_) {
68 evict_oldest(entries_.size() - budget_);
69 }
70 }
71
72 [[nodiscard]] auto segment_budget() const noexcept -> std::size_t {
73 return budget_;
74 }
75
76 [[nodiscard]] auto reserve_segments_checked(std::size_t count)
77 -> ReserveStatus {
78 if (count > detail::effective_capacity_limit(entries_.max_size())) {
79 return ReserveStatus::CapacityExceeded;
80 }
81 entries_.reserve(count);
82 return ReserveStatus::Reserved;
83 }
84
85 void reserve_segments(std::size_t count) {
86 if (reserve_segments_checked(count) != ReserveStatus::Reserved) {
87 capacity_failure("portal segment cache entry capacity exceeded");
88 }
89 }
90
91 [[nodiscard]] auto reserve_path_nodes_checked(std::size_t count)
92 -> ReserveStatus {
93 if (count > detail::effective_capacity_limit(paths_.max_size())) {
94 return ReserveStatus::CapacityExceeded;
95 }
96 paths_.reserve(count);
97 return ReserveStatus::Reserved;
98 }
99
100 void reserve_path_nodes(std::size_t count) {
101 if (reserve_path_nodes_checked(count) != ReserveStatus::Reserved) {
102 capacity_failure("portal segment cache path capacity exceeded");
103 }
104 }
105
106 void clear() noexcept {
107 clear_storage();
108 bound_class_ = 0;
109 }
110
111 void reset_stats() noexcept {
112 sweeps_ = 0;
113 evictions_ = 0;
114 stale_rejections_ = 0;
115 class_rebinds_ = 0;
116 }
117
118 [[nodiscard]] auto stats() const noexcept -> PortalSegmentCacheStats {
120 entries_.size(), paths_.size(), sweeps_,
121 evictions_, stale_rejections_, class_rebinds_,
122 };
123 }
124
125 // A lightweight class-bound view. Each operation compares one precomputed
126 // type token (no hashing or key growth), which also keeps an older view safe
127 // if another class was bound in between. Rebinding clears entries because
128 // their keys contain no movement-class data.
129 template <typename Class>
130 class ClassView {
131 public:
132 template <typename World>
133 [[nodiscard]] auto lookup_append(const World& world, PathRequest request,
134 std::vector<Coord3>& out_path)
135 -> SegmentHit {
136 cache_->bind_class(identity_);
137 return cache_->lookup_append(world, request, out_path);
138 }
139
140 template <typename World>
141 void store(const World& world, PathRequest request, PathResult result) {
142 if (store_checked(world, request, result) !=
143 PortalSegmentStoreStatus::Completed) {
144 capacity_failure("portal segment cache capacity exceeded");
145 }
146 }
147
148 template <typename World>
149 [[nodiscard]] auto store_checked(const World& world, PathRequest request,
150 PathResult result)
151 -> PortalSegmentStoreStatus {
152 return cache_->store_checked_for_class(identity_, world, request, result);
153 }
154
155 private:
156 friend class WeightedPortalSegmentCache;
157 ClassView(WeightedPortalSegmentCache& cache,
158 std::uintptr_t identity) noexcept
159 : cache_(&cache), identity_(identity) {}
160
161 WeightedPortalSegmentCache* cache_;
162 std::uintptr_t identity_;
163 };
164
165 template <typename ClassOrTag>
166 [[nodiscard]] auto for_class() noexcept
167 -> ClassView<movement::movement_class_of<ClassOrTag>> {
168 using Class = movement::movement_class_of<ClassOrTag>;
169 const auto identity = detail::tag_identity<Class>();
170 bind_class(identity);
171 return ClassView<Class>{*this, identity};
172 }
173
174 // One compaction pass keeping only entries whose content-version
175 // dependencies still validate against `world`. Rebuilds the path-node
176 // arena so storage held by dropped entries is reclaimed.
177 template <typename World>
178 void sweep_stale(const World& world) {
179 if (compact_checked([&](const Entry& entry) {
180 return entry.dependencies.is_valid(world);
181 }) != PortalSegmentStoreStatus::Completed) {
182 capacity_failure("portal segment cache compaction capacity exceeded");
183 }
184 ++sweeps_;
185 }
186
187 [[nodiscard]] auto size() const noexcept -> std::size_t {
188 return entries_.size();
189 }
190
191 private:
192 // Appends the cached path for `request` into `out_path` on a hit. When
193 // `out_path` already ends with the segment start (stitching consecutive
194 // segments), the shared junction node is appended only once. Misses and
195 // stale entries leave `out_path` untouched.
196 template <typename World>
197 [[nodiscard]] auto lookup_append(const World& world, PathRequest request,
198 std::vector<Coord3>& out_path)
199 -> SegmentHit {
200 const auto* entry = find(world, request);
201 if (entry == nullptr) {
202 return SegmentHit{};
203 }
204 const auto cached = path(*entry);
205 const auto stitch = !out_path.empty() && !cached.empty() &&
206 out_path.back() == cached.front();
207 out_path.insert(out_path.end(),
208 cached.begin() + (stitch ? std::ptrdiff_t{1} : 0),
209 cached.end());
210 return SegmentHit{true, entry->status, entry->cost};
211 }
212
213 template <typename World>
214 void store(const World& world, PathRequest request, PathResult result) {
215 if (store_checked(world, request, result) !=
216 PortalSegmentStoreStatus::Completed) {
217 capacity_failure("portal segment cache capacity exceeded");
218 }
219 }
220
221 template <typename World>
222 [[nodiscard]] auto store_checked(const World& world, PathRequest request,
223 PathResult result)
224 -> PortalSegmentStoreStatus {
225 using Shape = World::shape_type;
226
227 if (budget_ == 0 || result.status != PathStatus::Found) {
228 return PortalSegmentStoreStatus::Completed;
229 }
230 auto pending_stale_rejections = std::size_t{0};
231 if (find(world, request, &pending_stale_rejections) != nullptr) {
232 stale_rejections_ += pending_stale_rejections;
233 return PortalSegmentStoreStatus::Completed;
234 }
235 // Reject what is decidable in constant time before capturing
236 // dependencies, so a store that cannot possibly fit still reports a status
237 // instead of allocating on its way to one.
238 if (store_capacity_precheck(result.path.size()) !=
239 PortalSegmentStoreStatus::Completed) {
240 return PortalSegmentStoreStatus::CapacityExceeded;
241 }
242
243 // Construct every potentially allocating per-entry dependency before
244 // changing live cache storage. A failed capture therefore cannot publish a
245 // route with only a prefix of its invalidation dependencies.
246 auto entry = Entry{};
247 entry.request = request;
248 entry.status = result.status;
249 entry.cost = result.cost;
250 entry.path_size = result.path.size();
251 for (const auto coord : result.path) {
252 entry.dependencies.add_chunk(world,
253 chunk_key<Shape>(tile_key<Shape>(coord)));
254 }
255
256 if (entries_.size() >= budget_) {
257 // The transactional compaction reserves room for this entry as part of
258 // its temporary representation. Once it commits, eviction and append are
259 // allocation-free and cannot strand the cache between states.
260 const auto compacted = compact_checked(
261 [&](const Entry& current) {
262 return current.dependencies.is_valid(world);
263 },
264 1, result.path.size());
265 if (compacted != PortalSegmentStoreStatus::Completed) {
266 return compacted;
267 }
268 ++sweeps_;
269 if (entries_.size() >= budget_) {
270 evict_oldest(entries_.size() - budget_ + 1);
271 }
272 } else {
273 if (reserve_append_capacity_checked(1, result.path.size()) !=
274 PortalSegmentStoreStatus::Completed) {
275 return PortalSegmentStoreStatus::CapacityExceeded;
276 }
277 }
278
279 entry.path_offset = paths_.size();
280 for (const auto coord : result.path) {
281 paths_.push_back(coord);
282 }
283 entries_.push_back(std::move(entry));
284 stale_rejections_ += pending_stale_rejections;
285 return PortalSegmentStoreStatus::Completed;
286 }
287
288 template <typename World>
289 [[nodiscard]] auto store_checked_for_class(std::uintptr_t identity,
290 const World& world,
291 PathRequest request,
292 PathResult result)
293 -> PortalSegmentStoreStatus {
294 const auto entry_limit =
295 detail::effective_capacity_limit(entries_.max_size());
296 const auto path_limit = detail::effective_capacity_limit(paths_.max_size());
297 if (budget_ != 0 && result.status == PathStatus::Found &&
298 bound_class_ != identity &&
299 (entry_limit == 0 || result.path.size() > path_limit)) {
300 return PortalSegmentStoreStatus::CapacityExceeded;
301 }
302 bind_class(identity);
303 return store_checked(world, request, result);
304 }
305
306 struct Entry {
307 PathRequest request{};
308 PathStatus status = PathStatus::NotComputed;
309 std::uint32_t cost = 0;
310 std::size_t path_offset = 0;
311 std::size_t path_size = 0;
312 ContentVersionDependencies dependencies{};
313 };
314 static_assert(std::is_nothrow_move_constructible_v<Entry>);
315 static_assert(std::is_nothrow_move_assignable_v<Entry>);
316 static_assert(std::is_nothrow_copy_constructible_v<Coord3>);
317
318 template <typename World>
319 [[nodiscard]] auto find(const World& world, PathRequest request,
320 std::size_t* pending_stale_rejections =
321 nullptr) noexcept -> const Entry* {
322 for (const auto& entry : entries_) {
323 if (entry.request.start != request.start ||
324 entry.request.goal != request.goal) {
325 continue;
326 }
327 if (!entry.dependencies.is_valid(world)) {
328 if (pending_stale_rejections != nullptr) {
329 ++*pending_stale_rejections;
330 } else {
331 ++stale_rejections_;
332 }
333 continue;
334 }
335 return &entry;
336 }
337 return nullptr;
338 }
339
340 [[nodiscard]] auto path(const Entry& entry) const noexcept
341 -> std::span<const Coord3> {
342 return std::span<const Coord3>{paths_.data() + entry.path_offset,
343 entry.path_size};
344 }
345
346 // Drops the `count` oldest entries (insertion order) via compaction so
347 // their path-node storage is reclaimed with them.
348 void evict_oldest(std::size_t count) noexcept {
349 const auto evicted = count < entries_.size() ? count : entries_.size();
350 if (evicted == 0) {
351 return;
352 }
353 const auto first_path = evicted == entries_.size()
354 ? paths_.size()
355 : entries_[evicted].path_offset;
356 std::move(paths_.begin() + static_cast<std::ptrdiff_t>(first_path),
357 paths_.end(), paths_.begin());
358 paths_.erase(paths_.end() - static_cast<std::ptrdiff_t>(first_path),
359 paths_.end());
360 entries_.erase(entries_.begin(),
361 entries_.begin() + static_cast<std::ptrdiff_t>(evicted));
362 for (auto& entry : entries_) {
363 entry.path_offset -= first_path;
364 }
365 evictions_ += evicted;
366 }
367
368 void clear_storage() noexcept {
369 entries_.clear();
370 paths_.clear();
371 }
372
373 void bind_class(std::uintptr_t identity) noexcept {
374 if (bound_class_ == identity) {
375 return;
376 }
377 if (bound_class_ != 0) {
378 clear_storage();
379 ++class_rebinds_;
380 }
381 bound_class_ = identity;
382 }
383
384 [[nodiscard]] auto reserve_append_capacity_checked(
385 std::size_t additional_entries, std::size_t additional_path_nodes)
386 -> PortalSegmentStoreStatus {
387 const auto entry_limit =
388 detail::effective_capacity_limit(entries_.max_size());
389 const auto path_limit = detail::effective_capacity_limit(paths_.max_size());
390 if (entries_.size() > entry_limit || paths_.size() > path_limit ||
391 additional_entries > entry_limit - entries_.size() ||
392 additional_path_nodes > path_limit - paths_.size()) {
393 return PortalSegmentStoreStatus::CapacityExceeded;
394 }
395 entries_.reserve(entries_.size() + additional_entries);
396 paths_.reserve(paths_.size() + additional_path_nodes);
397 return PortalSegmentStoreStatus::Completed;
398 }
399
400 // Constant-time capacity rejection, run before the per-entry dependency
401 // capture allocates. Below budget this is the exact bound
402 // reserve_append_capacity_checked will apply, so nothing is lost. At budget
403 // the exact bound depends on how many entries survive compaction, which
404 // costs a full dependency-validity sweep; compact_checked already performs
405 // that sweep, so this only rejects what holds for every possible kept set
406 // and leaves compact_checked as the authority. Deliberately conservative:
407 // never reject a store that compact_checked would have accepted.
408 [[nodiscard]] auto store_capacity_precheck(std::size_t path_nodes) const
409 -> PortalSegmentStoreStatus {
410 if (entries_.size() < budget_) {
411 const auto entry_limit =
412 detail::effective_capacity_limit(entries_.max_size());
413 const auto path_limit =
414 detail::effective_capacity_limit(paths_.max_size());
415 if (entries_.size() >= entry_limit || paths_.size() > path_limit ||
416 path_nodes > path_limit - paths_.size()) {
417 return PortalSegmentStoreStatus::CapacityExceeded;
418 }
419 return PortalSegmentStoreStatus::Completed;
420 }
421 const auto compact_entry_limit =
422 detail::effective_capacity_limit(compact_entries_.max_size());
423 const auto compact_path_limit =
424 detail::effective_capacity_limit(compact_paths_.max_size());
425 // A zero entry ceiling leaves no room for the one appended entry whatever
426 // survives, and a path longer than the whole compaction arena cannot fit
427 // beside any kept set.
428 if (compact_entry_limit == 0 || path_nodes > compact_path_limit) {
429 return PortalSegmentStoreStatus::CapacityExceeded;
430 }
431 return PortalSegmentStoreStatus::Completed;
432 }
433
434 template <typename Keep>
435 [[nodiscard]] auto compact_checked(Keep keep,
436 std::size_t additional_entries = 0,
437 std::size_t additional_path_nodes = 0)
438 -> PortalSegmentStoreStatus {
439 compact_entries_.clear();
440 compact_paths_.clear();
441 compact_indices_.clear();
442
443 const auto entry_limit =
444 detail::effective_capacity_limit(compact_entries_.max_size());
445 const auto index_limit =
446 detail::effective_capacity_limit(compact_indices_.max_size());
447 const auto path_limit =
448 detail::effective_capacity_limit(compact_paths_.max_size());
449 auto kept_path_nodes = std::size_t{0};
450 for (std::size_t index = 0; index < entries_.size(); ++index) {
451 const auto& entry = entries_[index];
452 if (!keep(entry)) {
453 continue;
454 }
455 if (compact_indices_.size() >= index_limit ||
456 kept_path_nodes > path_limit ||
457 entry.path_size > path_limit - kept_path_nodes) {
458 return PortalSegmentStoreStatus::CapacityExceeded;
459 }
460 kept_path_nodes += entry.path_size;
461 compact_indices_.push_back(index);
462 }
463
464 if (compact_indices_.size() > entry_limit || kept_path_nodes > path_limit ||
465 additional_entries > entry_limit - compact_indices_.size() ||
466 additional_path_nodes > path_limit - kept_path_nodes) {
467 return PortalSegmentStoreStatus::CapacityExceeded;
468 }
469 compact_entries_.reserve(compact_indices_.size() + additional_entries);
470 compact_paths_.reserve(kept_path_nodes + additional_path_nodes);
471
472 // Everything below is non-throwing: capacities are fixed, Coord3 copies
473 // and Entry moves are noexcept, and every source range was validated above.
474 // Only now is it safe to move dependencies out of the live entries.
475 for (const auto index : compact_indices_) {
476 auto& entry = entries_[index];
477 const auto offset = compact_paths_.size();
478 for (std::size_t path_index = 0; path_index < entry.path_size;
479 ++path_index) {
480 compact_paths_.push_back(paths_[entry.path_offset + path_index]);
481 }
482 entry.path_offset = offset;
483 compact_entries_.push_back(std::move(entry));
484 }
485 entries_.swap(compact_entries_);
486 paths_.swap(compact_paths_);
487 return PortalSegmentStoreStatus::Completed;
488 }
489
490 [[noreturn]] static void capacity_failure(const char* message) {
491#if TESS_HAS_EXCEPTIONS
492 throw std::length_error{message};
493#else
494 detail::fail_fast(message);
495#endif
496 }
497
498 std::vector<Entry> entries_;
499 std::vector<Coord3> paths_;
500 std::vector<Entry> compact_entries_;
501 std::vector<Coord3> compact_paths_;
502 std::vector<std::size_t> compact_indices_;
503 std::size_t budget_ = default_segment_budget;
504 std::size_t sweeps_ = 0;
505 std::size_t evictions_ = 0;
506 std::size_t stale_rejections_ = 0;
507 std::size_t class_rebinds_ = 0;
508 std::uintptr_t bound_class_ = 0;
509};
510
511template <typename World, typename Class>
514 const World& world, PathRequest request, std::span<const Coord3> waypoints,
517 using Shape = World::shape_type;
518 // Caches weighted portal segments keyed by chunk topology and tracks content
519 // versions. The portal topology is dense-only; direct weighted A* runs
520 // natively on sparse worlds.
521 static_assert(
522 std::is_same_v<typename World::residency_type, AlwaysResident>,
523 "build_weighted_portal_route_product requires an AlwaysResidentWorld; "
524 "use weighted_astar_path for sparse worlds.");
525
526 std::vector<Coord3> stash;
527 const auto source = product.stash_if_owned(waypoints, stash);
528
529 product.clear();
530 product.request_ = request;
531 product.waypoints_.assign(source.begin(), source.end());
532 auto class_cache = cache.template for_class<Class>();
533
534 auto from = request.start;
535 auto total_cost = std::uint64_t{0};
536 auto total_expanded = std::size_t{0};
537 auto total_reached = std::size_t{0};
538 auto append_path = [&](std::span<const Coord3> path) {
539 for (std::size_t i = product.path_.empty() ? 0u : 1u; i < path.size();
540 ++i) {
541 product.path_.push_back(path[i]);
542 }
543 };
544 auto append_segment = [&](PathRequest segment_request) {
545 if (const auto hit =
546 class_cache.lookup_append(world, segment_request, product.path_);
547 hit.found) {
548 total_cost += hit.cost;
549 if (total_cost >= std::numeric_limits<std::uint32_t>::max()) {
550 product.path_.clear();
551 product.status_ = PathStatus::CostOverflow;
552 product.expanded_nodes_ = total_expanded;
553 product.reached_nodes_ = total_reached;
554 detail::capture_failure_dependencies<Shape>(
555 world, request, product.status_, product.dependencies_);
556 return false;
557 }
558 return true;
559 }
560
561 const auto result =
562 weighted_astar_path<World, Class>(world, segment_request, scratch);
563 class_cache.store(world, segment_request, result);
564 total_expanded += result.expanded_nodes;
565 total_reached += result.reached_nodes;
566 if (result.status != PathStatus::Found) {
567 product.path_.clear();
568 product.status_ = result.status;
569 product.expanded_nodes_ = total_expanded;
570 product.reached_nodes_ = total_reached;
571 // Same failure-dependency contract as build_weighted_route_product;
572 // the failing segment's endpoints are the offending tiles.
573 detail::capture_failure_dependencies<Shape>(
574 world, segment_request, result.status, product.dependencies_);
575 return false;
576 }
577 total_cost += result.cost;
578 if (total_cost >= std::numeric_limits<std::uint32_t>::max()) {
579 product.path_.clear();
580 product.status_ = PathStatus::CostOverflow;
581 product.expanded_nodes_ = total_expanded;
582 product.reached_nodes_ = total_reached;
583 detail::capture_failure_dependencies<Shape>(
584 world, request, product.status_, product.dependencies_);
585 return false;
586 }
587 append_path(result.path.span());
588 return true;
589 };
590
591 for (const auto waypoint : source) {
592 if (!append_segment(PathRequest{from, waypoint})) {
593 return PathResult{product.status_, 0, total_expanded, total_reached,
594 product.path_};
595 }
596 from = waypoint;
597 }
598 if (!append_segment(PathRequest{from, request.goal})) {
599 return PathResult{product.status_, 0, total_expanded, total_reached,
600 product.path_};
601 }
602
603 product.status_ = PathStatus::Found;
604 product.cost_ = static_cast<std::uint32_t>(total_cost);
605 product.expanded_nodes_ = total_expanded;
606 product.reached_nodes_ = total_reached;
607 for (const auto coord : product.path_) {
608 const auto key = tile_key<Shape>(coord);
609 product.dependencies_.add_chunk(world, chunk_key<Shape>(key));
610 }
611 return PathResult{product.status_, product.cost_, product.expanded_nodes_,
612 product.reached_nodes_, product.path_};
613}
614
615} // namespace tess
Definition path.h:741
friend auto build_weighted_portal_route_product(const World &world, PathRequest request, std::span< const Coord3 > waypoints, PathScratch &scratch, WeightedPortalRouteProduct &product) -> PathResult
Definition path.h:1938
Definition portal_segment_cache.h:130
Definition portal_segment_cache.h:61
Definition world.h:22
Specifies inclusive start and goal coordinates for a path query.
Definition request.h:10
Definition path.h:60
Snapshot of weighted portal-segment cache occupancy and lifecycle counts.
Definition portal_segment_cache.h:29
Result metadata for one movement-class-bound segment-cache lookup.
Definition portal_segment_cache.h:22
Definition shape.h:296