tess 0.4.0
Performance-first tile and path simulation substrate
Loading...
Searching...
No Matches
portal_segment_cache.h
1#pragma once
2
3#include <tess/core/tag_identity.h>
4#include <tess/path/path.h>
5
6#include <algorithm>
7#include <cstddef>
8#include <cstdint>
9#include <span>
10#include <stdexcept>
11#include <type_traits>
12#include <utility>
13#include <vector>
14
15namespace tess {
16
18struct SegmentHit {
19 bool found = false;
20 PathStatus status = PathStatus::NoPath;
21 std::uint32_t cost = 0;
22};
23
26 std::size_t entries = 0;
27 std::size_t path_nodes = 0;
28 std::size_t sweeps = 0;
29 std::size_t evictions = 0;
30 std::size_t stale_rejections = 0;
31 // Whole-cache drops when for_class() binds a different movement class.
32 std::size_t class_rebinds = 0;
33};
34
35// Cached segment paths are only handed out by appending into caller-owned
36// storage. The cache never returns pointers or spans into its own path
37// storage, so later `store()` growth cannot invalidate a previous lookup.
38//
39// Storage is bounded by a segment budget (default 256 entries). When a store
40// reaches the budget it first sweeps stale entries in one compaction pass
41// (rebuilding both the entry list and the path-node append arena, so stale
42// path storage is reclaimed), then evicts the oldest live entries in
43// insertion order if the sweep alone cannot make room. A zero budget stores
44// nothing. Entries belong to one movement class: `for_class()` safely clears
45// the cache when that class changes. Keep one cache per (world, class) to avoid
46// that conservative whole-cache fallback on the hot path.
49 public:
50 static constexpr std::size_t default_segment_budget = 256;
51
52 void set_segment_budget(std::size_t budget) noexcept {
53 budget_ = budget;
54 if (entries_.size() > budget_) {
55 evict_oldest(entries_.size() - budget_);
56 }
57 }
58
59 [[nodiscard]] auto segment_budget() const noexcept -> std::size_t {
60 return budget_;
61 }
62
63 void reserve_segments(std::size_t count) { entries_.reserve(count); }
64
65 void reserve_path_nodes(std::size_t count) { paths_.reserve(count); }
66
67 void clear() noexcept {
68 clear_storage();
69 bound_class_ = 0;
70 }
71
72 void reset_stats() noexcept {
73 sweeps_ = 0;
74 evictions_ = 0;
75 stale_rejections_ = 0;
76 class_rebinds_ = 0;
77 }
78
79 [[nodiscard]] auto stats() const noexcept -> PortalSegmentCacheStats {
81 entries_.size(), paths_.size(), sweeps_,
82 evictions_, stale_rejections_, class_rebinds_,
83 };
84 }
85
86 // A lightweight class-bound view. Each operation compares one precomputed
87 // type token (no hashing or key growth), which also keeps an older view safe
88 // if another class was bound in between. Rebinding clears entries because
89 // their keys contain no movement-class data.
90 template <typename Class>
91 class ClassView {
92 public:
93 template <typename World>
94 [[nodiscard]] auto lookup_append(const World& world, PathRequest request,
95 std::vector<Coord3>& out_path)
96 -> SegmentHit {
97 cache_->bind_class(identity_);
98 return cache_->lookup_append(world, request, out_path);
99 }
100
101 template <typename World>
102 void store(const World& world, PathRequest request, PathResult result) {
103 cache_->bind_class(identity_);
104 cache_->store(world, request, result);
105 }
106
107 private:
108 friend class WeightedPortalSegmentCache;
109 ClassView(WeightedPortalSegmentCache& cache,
110 std::uintptr_t identity) noexcept
111 : cache_(&cache), identity_(identity) {}
112
113 WeightedPortalSegmentCache* cache_;
114 std::uintptr_t identity_;
115 };
116
117 template <typename ClassOrTag>
118 [[nodiscard]] auto for_class() noexcept
119 -> ClassView<movement::movement_class_of<ClassOrTag>> {
120 using Class = movement::movement_class_of<ClassOrTag>;
121 const auto identity = detail::tag_identity<Class>();
122 bind_class(identity);
123 return ClassView<Class>{*this, identity};
124 }
125
126 // One compaction pass keeping only entries whose chunk-version
127 // dependencies still validate against `world`. Rebuilds the path-node
128 // arena so storage held by dropped entries is reclaimed.
129 template <typename World>
130 void sweep_stale(const World& world) {
131 compact(
132 [&](const Entry& entry) { return entry.dependencies.is_valid(world); });
133 ++sweeps_;
134 }
135
136 [[nodiscard]] auto size() const noexcept -> std::size_t {
137 return entries_.size();
138 }
139
140 private:
141 // Appends the cached path for `request` into `out_path` on a hit. When
142 // `out_path` already ends with the segment start (stitching consecutive
143 // segments), the shared junction node is appended only once. Misses and
144 // stale entries leave `out_path` untouched.
145 template <typename World>
146 [[nodiscard]] auto lookup_append(const World& world, PathRequest request,
147 std::vector<Coord3>& out_path)
148 -> SegmentHit {
149 const auto* entry = find(world, request);
150 if (entry == nullptr) {
151 return SegmentHit{};
152 }
153 const auto cached = path(*entry);
154 const auto stitch = !out_path.empty() && !cached.empty() &&
155 out_path.back() == cached.front();
156 out_path.insert(out_path.end(),
157 cached.begin() + (stitch ? std::ptrdiff_t{1} : 0),
158 cached.end());
159 return SegmentHit{true, entry->status, entry->cost};
160 }
161
162 template <typename World>
163 void store(const World& world, PathRequest request, PathResult result) {
164 using Shape = World::shape_type;
165
166 if (budget_ == 0 || result.status != PathStatus::Found) {
167 return;
168 }
169 auto pending_stale_rejections = std::size_t{0};
170 if (find(world, request, &pending_stale_rejections) != nullptr) {
171 stale_rejections_ += pending_stale_rejections;
172 return;
173 }
174
175 // Construct every potentially allocating per-entry dependency before
176 // changing live cache storage. A failed capture therefore cannot publish a
177 // route with only a prefix of its invalidation dependencies.
178 auto entry = Entry{};
179 entry.request = request;
180 entry.status = result.status;
181 entry.cost = result.cost;
182 entry.path_size = result.path.size();
183 for (const auto coord : result.path) {
184 entry.dependencies.add_chunk(world,
185 chunk_key<Shape>(tile_key<Shape>(coord)));
186 }
187
188 if (entries_.size() >= budget_) {
189 // The transactional compaction reserves room for this entry as part of
190 // its temporary representation. Once it commits, eviction and append are
191 // allocation-free and cannot strand the cache between states.
192 compact(
193 [&](const Entry& current) {
194 return current.dependencies.is_valid(world);
195 },
196 1, result.path.size());
197 ++sweeps_;
198 if (entries_.size() >= budget_) {
199 evict_oldest(entries_.size() - budget_ + 1);
200 }
201 } else {
202 reserve_append_capacity(1, result.path.size());
203 }
204
205 entry.path_offset = paths_.size();
206 for (const auto coord : result.path) {
207 paths_.push_back(coord);
208 }
209 entries_.push_back(std::move(entry));
210 stale_rejections_ += pending_stale_rejections;
211 }
212
213 struct Entry {
214 PathRequest request{};
215 PathStatus status = PathStatus::NoPath;
216 std::uint32_t cost = 0;
217 std::size_t path_offset = 0;
218 std::size_t path_size = 0;
219 ChunkVersionDependencies dependencies{};
220 };
221 static_assert(std::is_nothrow_move_constructible_v<Entry>);
222 static_assert(std::is_nothrow_move_assignable_v<Entry>);
223 static_assert(std::is_nothrow_copy_constructible_v<Coord3>);
224
225 template <typename World>
226 [[nodiscard]] auto find(const World& world, PathRequest request,
227 std::size_t* pending_stale_rejections =
228 nullptr) noexcept -> const Entry* {
229 for (const auto& entry : entries_) {
230 if (entry.request.start != request.start ||
231 entry.request.goal != request.goal) {
232 continue;
233 }
234 if (!entry.dependencies.is_valid(world)) {
235 if (pending_stale_rejections != nullptr) {
236 ++*pending_stale_rejections;
237 } else {
238 ++stale_rejections_;
239 }
240 continue;
241 }
242 return &entry;
243 }
244 return nullptr;
245 }
246
247 [[nodiscard]] auto path(const Entry& entry) const noexcept
248 -> std::span<const Coord3> {
249 return std::span<const Coord3>{paths_.data() + entry.path_offset,
250 entry.path_size};
251 }
252
253 // Drops the `count` oldest entries (insertion order) via compaction so
254 // their path-node storage is reclaimed with them.
255 void evict_oldest(std::size_t count) noexcept {
256 const auto evicted = count < entries_.size() ? count : entries_.size();
257 if (evicted == 0) {
258 return;
259 }
260 const auto first_path = evicted == entries_.size()
261 ? paths_.size()
262 : entries_[evicted].path_offset;
263 std::move(paths_.begin() + static_cast<std::ptrdiff_t>(first_path),
264 paths_.end(), paths_.begin());
265 paths_.erase(paths_.end() - static_cast<std::ptrdiff_t>(first_path),
266 paths_.end());
267 entries_.erase(entries_.begin(),
268 entries_.begin() + static_cast<std::ptrdiff_t>(evicted));
269 for (auto& entry : entries_) {
270 entry.path_offset -= first_path;
271 }
272 evictions_ += evicted;
273 }
274
275 void clear_storage() noexcept {
276 entries_.clear();
277 paths_.clear();
278 }
279
280 void bind_class(std::uintptr_t identity) noexcept {
281 if (bound_class_ == identity) {
282 return;
283 }
284 if (bound_class_ != 0) {
285 clear_storage();
286 ++class_rebinds_;
287 }
288 bound_class_ = identity;
289 }
290
291 void reserve_append_capacity(std::size_t additional_entries,
292 std::size_t additional_path_nodes) {
293 if (additional_entries > entries_.max_size() - entries_.size() ||
294 additional_path_nodes > paths_.max_size() - paths_.size()) {
295 throw std::length_error{"portal segment cache capacity overflow"};
296 }
297 entries_.reserve(entries_.size() + additional_entries);
298 paths_.reserve(paths_.size() + additional_path_nodes);
299 }
300
301 template <typename Keep>
302 void compact(Keep keep, std::size_t additional_entries = 0,
303 std::size_t additional_path_nodes = 0) {
304 compact_entries_.clear();
305 compact_paths_.clear();
306 compact_indices_.clear();
307
308 auto kept_path_nodes = std::size_t{0};
309 for (std::size_t index = 0; index < entries_.size(); ++index) {
310 const auto& entry = entries_[index];
311 if (!keep(entry)) {
312 continue;
313 }
314 if (entry.path_size > compact_paths_.max_size() - kept_path_nodes) {
315 throw std::length_error{"portal segment path count exceeds max_size"};
316 }
317 kept_path_nodes += entry.path_size;
318 compact_indices_.push_back(index);
319 }
320
321 if (additional_entries >
322 compact_entries_.max_size() - compact_indices_.size() ||
323 additional_path_nodes > compact_paths_.max_size() - kept_path_nodes) {
324 throw std::length_error{"portal segment cache capacity overflow"};
325 }
326 compact_entries_.reserve(compact_indices_.size() + additional_entries);
327 compact_paths_.reserve(kept_path_nodes + additional_path_nodes);
328
329 // Everything below is non-throwing: capacities are fixed, Coord3 copies
330 // and Entry moves are noexcept, and every source range was validated above.
331 // Only now is it safe to move dependencies out of the live entries.
332 for (const auto index : compact_indices_) {
333 auto& entry = entries_[index];
334 const auto offset = compact_paths_.size();
335 for (std::size_t path_index = 0; path_index < entry.path_size;
336 ++path_index) {
337 compact_paths_.push_back(paths_[entry.path_offset + path_index]);
338 }
339 entry.path_offset = offset;
340 compact_entries_.push_back(std::move(entry));
341 }
342 entries_.swap(compact_entries_);
343 paths_.swap(compact_paths_);
344 }
345
346 std::vector<Entry> entries_;
347 std::vector<Coord3> paths_;
348 std::vector<Entry> compact_entries_;
349 std::vector<Coord3> compact_paths_;
350 std::vector<std::size_t> compact_indices_;
351 std::size_t budget_ = default_segment_budget;
352 std::size_t sweeps_ = 0;
353 std::size_t evictions_ = 0;
354 std::size_t stale_rejections_ = 0;
355 std::size_t class_rebinds_ = 0;
356 std::uintptr_t bound_class_ = 0;
357};
358
359template <typename World, typename PassableTag, typename CostTag>
362 PathRequest request,
363 std::span<const Coord3> waypoints,
364 PathScratch& scratch,
367 -> PathResult {
368 using Shape = World::shape_type;
369 // Caches weighted portal segments keyed by chunk topology and tracks
370 // chunk-version dependencies; dense-only until sparse topology (Slice 4) and
371 // the sparse route-cache slice. Its per-segment weighted A* already runs
372 // natively on sparse worlds via weighted_astar_path.
373 static_assert(
374 std::is_same_v<typename World::residency_type, AlwaysResident>,
375 "build_weighted_portal_route_product (segment cache) is dense-only; it "
376 "needs sparse topology and route-cache support from a later slice.");
377
378 std::vector<Coord3> stash;
379 const auto source = product.stash_if_owned(waypoints, stash);
380
381 product.clear();
382 product.request_ = request;
383 product.waypoints_.assign(source.begin(), source.end());
384 auto class_cache =
385 cache
386 .template for_class<movement::LegacyWeighted<PassableTag, CostTag>>();
387
388 auto from = request.start;
389 auto total_cost = std::uint32_t{0};
390 auto total_expanded = std::size_t{0};
391 auto total_reached = std::size_t{0};
392 auto append_path = [&](std::span<const Coord3> path) {
393 for (std::size_t i = product.path_.empty() ? 0u : 1u; i < path.size();
394 ++i) {
395 product.path_.push_back(path[i]);
396 }
397 };
398 auto append_segment = [&](PathRequest segment_request) {
399 if (const auto hit =
400 class_cache.lookup_append(world, segment_request, product.path_);
401 hit.found) {
402 total_cost = detail::saturating_add(total_cost, hit.cost);
403 return true;
404 }
405
406 const auto result = weighted_astar_path<World, PassableTag, CostTag>(
407 world, segment_request, scratch);
408 class_cache.store(world, segment_request, result);
409 total_expanded += result.expanded_nodes;
410 total_reached += result.reached_nodes;
411 if (result.status != PathStatus::Found) {
412 product.path_.clear();
413 product.status_ = result.status;
414 product.expanded_nodes_ = total_expanded;
415 product.reached_nodes_ = total_reached;
416 // Same failure-dependency contract as build_weighted_route_product;
417 // the failing segment's endpoints are the offending tiles.
418 detail::capture_failure_dependencies<Shape>(
419 world, segment_request, result.status, product.dependencies_);
420 return false;
421 }
422 total_cost = detail::saturating_add(total_cost, result.cost);
423 append_path(result.path.span());
424 return true;
425 };
426
427 for (const auto waypoint : source) {
428 if (!append_segment(PathRequest{from, waypoint})) {
429 return PathResult{product.status_, 0, total_expanded, total_reached,
430 product.path_};
431 }
432 from = waypoint;
433 }
434 if (!append_segment(PathRequest{from, request.goal})) {
435 return PathResult{product.status_, 0, total_expanded, total_reached,
436 product.path_};
437 }
438
439 product.status_ = PathStatus::Found;
440 product.cost_ = total_cost;
441 product.expanded_nodes_ = total_expanded;
442 product.reached_nodes_ = total_reached;
443 for (const auto coord : product.path_) {
444 const auto key = tile_key<Shape>(coord);
445 product.dependencies_.add_chunk(world, chunk_key<Shape>(key));
446 }
447 return PathResult{product.status_, product.cost_, product.expanded_nodes_,
448 product.reached_nodes_, product.path_};
449}
450
451} // namespace tess
Definition path.h:535
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:1397
Definition portal_segment_cache.h:91
Bounded, movement-class-safe cache of verified weighted path segments.
Definition portal_segment_cache.h:48
Definition world.h:22
Specifies inclusive start and goal coordinates for a path query.
Definition path.h:54
Definition path.h:63
Snapshot of weighted portal-segment cache occupancy and lifecycle counts.
Definition portal_segment_cache.h:25
Result metadata for one movement-class-bound segment-cache lookup.
Definition portal_segment_cache.h:18
Definition shape.h:225