tess 1.0.0
Performance-first tile and path simulation substrate
Loading...
Searching...
No Matches
joint_movement.h
1#pragma once
2
3// Joint movement commit: decide one tick's moves as a set, so that a move
4// into a tile being vacated in the same tick is admissible. The per-agent
5// `commit_movement_intent` validates each destination against current state,
6// which makes chains ("everyone steps forward together"), rotations (a cycle
7// of agents shifts one place), and swaps (the two-agent cycle) unreachable by
8// construction — the front agent's tile is still occupied at the moment the
9// rear agent validates. This header supplies the batch alternative while
10// reusing the per-agent validation for everything that is not occupancy:
11// bounds, passability, adjacency, topology, and reservations behave exactly
12// as they do in `commit_movement_intent`.
13//
14// Whether the two-agent cycle may resolve is a semantic question, not a
15// tuning knob: admitting it means both agents traverse the same edge in
16// opposite directions in one tick, which standard multi-agent path finding
17// forbids because embodied agents cannot pass through each other. It is
18// therefore an explicit `SwapPolicy`, and the default forbids it. Cycles of
19// length three or more involve no shared edge — every member vacates its
20// tile simultaneously — and are always admitted.
21//
22// Determinism matches the per-agent advance: outcomes are deterministic
23// given the caller's agent span order (ECS adapters already require a
24// replay-stable order), and input-order invariance is a documented non-goal,
25// exactly as for `advance_path_agents_with_movement`.
26
27#include <tess/core/shape.h>
28#include <tess/sim/movement.h>
29#include <tess/sim/path_agent.h>
30#include <tess/sim/path_agent_tick.h>
31
32#include <algorithm>
33#include <cstddef>
34#include <cstdint>
35#include <span>
36#include <vector>
37
38namespace tess {
39
40// - Forbid: the standard MAPF constraint and the zero-surprise default; a
41// mutually blocked pair stays Blocked and retries under the usual budget.
42// - Permit: a mutually blocked pair exchanges tiles this tick.
43// - PermitOnDeadlock: the pair exchanges only after both members have been
44// blocked for `JointMoveOptions::deadlock_ticks` consecutive ticks, so
45// ordinary passing traffic never interpenetrates.
47enum class SwapPolicy : std::uint8_t {
48 Forbid,
49 Permit,
50 PermitOnDeadlock,
51};
52
55 SwapPolicy swap_policy = SwapPolicy::Forbid;
62 std::uint32_t deadlock_ticks = 4;
63};
64
67 PathAgentFrameStats frame{};
69 std::size_t chained = 0;
71 std::size_t rotations = 0;
73 std::size_t swaps = 0;
75 std::size_t swaps_denied = 0;
76};
77
78struct JointMoveScratch;
79
80namespace detail {
81
82struct JointMoveScratchAccess;
83
84// The round buffers themselves. They were public members of
85// `JointMoveScratch` under a comment calling them an implementation detail,
86// which left the layout inside the 1.0 promise anyway: a consumer could size,
87// read or overwrite any of them, and nothing but that comment said what the
88// pass would then do. They live here so the promise covers what the type is
89// for — reserving storage — and not how a round is bookkept.
90struct JointMoveScratchState {
91 std::vector<std::uint64_t> occupant_key;
92 std::vector<std::uint32_t> occupant_agent;
93 std::vector<std::uint64_t> claimed;
94 std::vector<Coord3> desired;
95 std::vector<std::uint8_t> state;
96 std::vector<std::uint8_t> failure;
97 std::vector<std::uint32_t> cycle_walk;
98 std::vector<std::uint8_t> on_walk;
99 std::vector<std::uint8_t> walked;
100 std::vector<std::uint32_t> committed;
101 std::vector<Coord3> committed_from;
102
103 void reserve(std::size_t agent_count) {
104 occupant_key.reserve(agent_count);
105 occupant_agent.reserve(agent_count);
106 claimed.reserve(agent_count);
107 desired.reserve(agent_count);
108 state.reserve(agent_count);
109 failure.reserve(agent_count);
110 cycle_walk.reserve(agent_count);
111 on_walk.reserve(agent_count);
112 walked.reserve(agent_count);
113 committed.reserve(agent_count);
114 committed_from.reserve(agent_count);
115 }
116};
117
118} // namespace detail
119
120// Callers reserve once and reuse the object across ticks so the warm path
121// performs no allocation. The same index-pairing caveat as `PathAgentRoutes`
122// applies: the scratch carries no per-agent state between calls, so
123// reordering agents between ticks is safe with respect to this object.
127 void reserve(std::size_t agent_count) { state_.reserve(agent_count); }
128
129 private:
130 friend struct detail::JointMoveScratchAccess;
131
132 detail::JointMoveScratchState state_;
133};
134
135namespace detail {
136
137// The single door onto the buffers. `RegionGraphT` befriends its algorithms
138// directly, and that pattern does not reach here: the PIBT tier lives in a
139// header above this one and its advance is a constrained template, so a
140// matching friend declaration would have to name `PibtRanking` — declared in
141// the higher header — and dropping the constraint would befriend a different
142// template. So the door is a `detail` name instead. Being in `detail` is the
143// boundary (`docs/style.md`: no source-compatibility guarantee), not being
144// unreachable: a consumer who spells this out can still reach the buffers,
145// exactly as it can reach any other internal in a header-only library.
146struct JointMoveScratchAccess {
147 [[nodiscard]] static auto state(JointMoveScratch& scratch) noexcept
148 -> JointMoveScratchState& {
149 return scratch.state_;
150 }
151 [[nodiscard]] static auto state(const JointMoveScratch& scratch) noexcept
152 -> const JointMoveScratchState& {
153 return scratch.state_;
154 }
155};
156
157// Round-local agent classification. Values are ordered so that "settled this
158// round" states compare greater than Pending.
159enum class JointState : std::uint8_t {
160 Inactive, // no goal, no route, or already done this call
161 Pending, // wants to move; admission undecided
162 Admitted, // moves this round
163 Failed, // recorded a movement failure this round
164};
165
166[[nodiscard]] inline auto joint_find_occupant(
167 const JointMoveScratchState& scratch, std::uint64_t key) noexcept
168 -> std::uint32_t {
169 const auto begin = scratch.occupant_key.begin();
170 const auto end = scratch.occupant_key.end();
171 const auto it = std::lower_bound(begin, end, key);
172 if (it == end || *it != key) {
173 return static_cast<std::uint32_t>(-1);
174 }
175 return scratch.occupant_agent[static_cast<std::size_t>(it - begin)];
176}
177
178[[nodiscard]] inline auto joint_claim(JointMoveScratchState& scratch,
179 std::uint64_t key) -> bool {
180 const auto it =
181 std::lower_bound(scratch.claimed.begin(), scratch.claimed.end(), key);
182 if (it != scratch.claimed.end() && *it == key) {
183 return false;
184 }
185 scratch.claimed.insert(it, key);
186 return true;
187}
188
189} // namespace detail
190
191// One admission round per step:
192// 1. Every eligible agent's next route tile is validated exactly as the
193// per-agent commit validates it, minus nothing: an `Occupied` verdict is
194// the only outcome treated further, every other failure is recorded with
195// the per-agent semantics (transient failures block and retain or drop
196// the route, structural failures are terminal).
197// 2. Free destinations are claimed in span order; a destination two agents
198// want goes to the earlier agent and the later one records `Occupied`.
199// 3. Fixpoint: a move whose destination is being vacated by an admitted
200// mover is admitted. This drains queues in one tick.
201// 4. The unresolved remainder is exactly the set of wants-cycles. Cycles of
202// length >= 3 rotate; 2-cycles follow `SwapPolicy`; chains ending at an
203// unadmitted or external occupant record `Occupied`. A cycle refills
204// every tile it vacates, so cycle admission never unlocks further
205// chains -- the fixpoint in step 3 is the only chain pass needed.
206// 5. Admitted moves apply as a set: sources clear, destinations set, and
207// per-move dirty marking matches `commit_movement_intent`.
209template <typename World, typename ClassOrTag, typename OccupancyTag,
210 typename ReservationTag, typename OnCommit>
211 requires std::invocable<OnCommit&, std::size_t, Coord3, Coord3>
212auto advance_path_agents_with_joint_movement(
213 World& world, std::span<PathAgentState> agents,
214 const PathAgentRoutes& routes, JointMoveScratch& scratch_storage,
215 JointMoveOptions options, PathAgentAdvanceOptions advance_options,
216 OnCommit&& on_commit, diagnostics::FlowAccounting* accounting = nullptr)
217 -> JointMoveStats {
218 using Shape = typename World::shape_type;
219 TESS_ASSERT(routes.routes.size() >= agents.size());
220 auto& scratch = detail::JointMoveScratchAccess::state(scratch_storage);
221 JointMoveStats stats;
222 if (advance_options.max_steps == 0) {
223 return stats;
224 }
225 const auto n = agents.size();
226 const auto none = static_cast<std::uint32_t>(-1);
227
228 for (std::size_t step = 0; step < advance_options.max_steps; ++step) {
229 scratch.desired.assign(n, Coord3{});
230 scratch.state.assign(
231 n, static_cast<std::uint8_t>(detail::JointState::Inactive));
232 scratch.failure.assign(n, static_cast<std::uint8_t>(MovementStatus::Moved));
233 scratch.claimed.clear();
234 scratch.committed.clear();
235 scratch.committed_from.clear();
236
237 // Position index over every agent, movers or not: an occupied destination
238 // must distinguish "held by an agent in this batch" from "held by
239 // something else", because only the former can be vacated this tick.
240 scratch.occupant_key.clear();
241 scratch.occupant_agent.clear();
242 for (std::size_t i = 0; i < n; ++i) {
243 scratch.occupant_key.push_back(tile_key<Shape>(agents[i].position).value);
244 scratch.occupant_agent.push_back(static_cast<std::uint32_t>(i));
245 }
246 // Sort both by key, keeping the pairing (indices sorted by key).
247 {
248 auto& keys = scratch.occupant_key;
249 auto& vals = scratch.occupant_agent;
250 // Insertion sort keeps this allocation-free; agent positions are
251 // pairwise distinct so keys are unique.
252 for (std::size_t i = 1; i < keys.size(); ++i) {
253 auto key = keys[i];
254 auto val = vals[i];
255 std::size_t j = i;
256 while (j > 0 && keys[j - 1] > key) {
257 keys[j] = keys[j - 1];
258 vals[j] = vals[j - 1];
259 --j;
260 }
261 keys[j] = key;
262 vals[j] = val;
263 }
264 }
265
266 // 1-2: validate and claim in span order.
267 bool any_pending = false;
268 for (std::size_t i = 0; i < n; ++i) {
269 auto& agent = agents[i];
270 if (!agent.has_goal || agent.last_result != PathStatus::Found) {
271 continue;
272 }
273 const auto& route = routes.routes[i];
274 if (!detail::has_next_step(agent.path_index, route.size())) {
275 continue;
276 }
277 const auto to = route[agent.path_index + 1];
278 const auto verdict =
279 validate_movement_intent<World, ClassOrTag, OccupancyTag,
280 ReservationTag>(
281 world, MovementIntent{agent.position, to, {}});
282 scratch.desired[i] = to;
283 if (verdict.status == MovementStatus::Moved) {
284 if (detail::joint_claim(scratch, tile_key<Shape>(to).value)) {
285 scratch.state[i] =
286 static_cast<std::uint8_t>(detail::JointState::Admitted);
287 } else {
288 scratch.state[i] =
289 static_cast<std::uint8_t>(detail::JointState::Failed);
290 scratch.failure[i] =
291 static_cast<std::uint8_t>(MovementStatus::Occupied);
292 }
293 } else if (verdict.status == MovementStatus::Occupied) {
294 // The per-agent validation reports Occupied before Reserved, but a
295 // reservation must not vanish behind a vacating occupant: joint
296 // admission could otherwise walk an agent onto a tile the
297 // application has flagged do-not-enter. Reserved wins here.
298 if (world.template field<ReservationTag>(to)) {
299 scratch.state[i] =
300 static_cast<std::uint8_t>(detail::JointState::Failed);
301 scratch.failure[i] =
302 static_cast<std::uint8_t>(MovementStatus::Reserved);
303 } else {
304 scratch.state[i] =
305 static_cast<std::uint8_t>(detail::JointState::Pending);
306 any_pending = true;
307 }
308 } else {
309 scratch.state[i] =
310 static_cast<std::uint8_t>(detail::JointState::Failed);
311 scratch.failure[i] = static_cast<std::uint8_t>(verdict.status);
312 }
313 }
314
315 // 3: chains — admit moves into tiles vacated by admitted movers.
316 bool changed = any_pending;
317 while (changed) {
318 changed = false;
319 for (std::size_t i = 0; i < n; ++i) {
320 if (scratch.state[i] !=
321 static_cast<std::uint8_t>(detail::JointState::Pending)) {
322 continue;
323 }
324 const auto key = tile_key<Shape>(scratch.desired[i]).value;
325 const auto occupant = detail::joint_find_occupant(scratch, key);
326 if (occupant == none ||
327 scratch.state[occupant] !=
328 static_cast<std::uint8_t>(detail::JointState::Admitted)) {
329 continue;
330 }
331 if (detail::joint_claim(scratch, key)) {
332 scratch.state[i] =
333 static_cast<std::uint8_t>(detail::JointState::Admitted);
334 ++stats.chained;
335 } else {
336 scratch.state[i] =
337 static_cast<std::uint8_t>(detail::JointState::Failed);
338 scratch.failure[i] =
339 static_cast<std::uint8_t>(MovementStatus::Occupied);
340 }
341 changed = true;
342 }
343 }
344
345 // 4: the unresolved remainder is the wants-cycle set. A cycle is
346 // volume-preserving -- it refills every tile it vacates -- so admitting
347 // one can never free a tile for a trailing chain; every genuinely freed
348 // tile traces back to a move into a free tile, which the fixpoint above
349 // already drained. Chain members walked here therefore wait this tick.
350 scratch.walked.assign(n, 0);
351 for (std::size_t start = 0; start < n; ++start) {
352 if (scratch.state[start] !=
353 static_cast<std::uint8_t>(detail::JointState::Pending) ||
354 scratch.walked[start] != 0) {
355 continue;
356 }
357 scratch.cycle_walk.clear();
358 scratch.on_walk.assign(n, 0);
359 auto at = static_cast<std::uint32_t>(start);
360 while (at != none &&
361 scratch.state[at] ==
362 static_cast<std::uint8_t>(detail::JointState::Pending) &&
363 scratch.on_walk[at] == 0) {
364 scratch.on_walk[at] = 1;
365 scratch.walked[at] = 1;
366 scratch.cycle_walk.push_back(at);
367 at = detail::joint_find_occupant(
368 scratch, tile_key<Shape>(scratch.desired[at]).value);
369 }
370
371 const bool closed = at != none && scratch.on_walk[at] != 0;
372 if (!closed) {
373 continue; // an open chain; settled by the sweep below
374 }
375 std::size_t cycle_begin = scratch.cycle_walk.size();
376 for (std::size_t k = 0; k < scratch.cycle_walk.size(); ++k) {
377 if (scratch.cycle_walk[k] == at) {
378 cycle_begin = k;
379 break;
380 }
381 }
382 const auto cycle_len = scratch.cycle_walk.size() - cycle_begin;
383
384 bool admit = false;
385 if (cycle_len >= 3) {
386 admit = true;
387 ++stats.rotations;
388 } else if (cycle_len == 2) {
389 const auto a = scratch.cycle_walk[cycle_begin];
390 const auto b = scratch.cycle_walk[cycle_begin + 1];
391 switch (options.swap_policy) {
392 case SwapPolicy::Permit:
393 admit = true;
394 break;
395 case SwapPolicy::PermitOnDeadlock:
396 admit = agents[a].blocked_retries >= options.deadlock_ticks &&
397 agents[b].blocked_retries >= options.deadlock_ticks;
398 break;
399 case SwapPolicy::Forbid:
400 admit = false;
401 break;
402 }
403 if (admit) {
404 ++stats.swaps;
405 } else {
406 ++stats.swaps_denied;
407 }
408 }
409
410 for (std::size_t k = cycle_begin; k < scratch.cycle_walk.size(); ++k) {
411 const auto member = scratch.cycle_walk[k];
412 if (admit) {
413 // Cycle destinations are cycle members' current positions, which no
414 // admitted mover can have claimed; the claim still guards the
415 // invariant.
416 (void)detail::joint_claim(
417 scratch, tile_key<Shape>(scratch.desired[member]).value);
418 scratch.state[member] =
419 static_cast<std::uint8_t>(detail::JointState::Admitted);
420 } else {
421 scratch.state[member] =
422 static_cast<std::uint8_t>(detail::JointState::Failed);
423 scratch.failure[member] =
424 static_cast<std::uint8_t>(MovementStatus::Occupied);
425 }
426 }
427 }
428
429 // Whatever is still pending waits on an occupied tile.
430 for (std::size_t i = 0; i < n; ++i) {
431 if (scratch.state[i] ==
432 static_cast<std::uint8_t>(detail::JointState::Pending)) {
433 scratch.state[i] =
434 static_cast<std::uint8_t>(detail::JointState::Failed);
435 scratch.failure[i] =
436 static_cast<std::uint8_t>(MovementStatus::Occupied);
437 }
438 }
439
440 // 5: apply as a set — every source clears before any destination sets, so
441 // a rotated cycle never observes a half-applied state.
442 std::size_t admitted_count = 0;
443 for (std::size_t i = 0; i < n; ++i) {
444 if (scratch.state[i] ==
445 static_cast<std::uint8_t>(detail::JointState::Admitted)) {
446 world.template field<OccupancyTag>(agents[i].position) = false;
447 ++admitted_count;
448 }
449 }
450 for (std::size_t i = 0; i < n; ++i) {
451 switch (static_cast<detail::JointState>(scratch.state[i])) {
452 case detail::JointState::Admitted: {
453 auto& agent = agents[i];
454 const auto from = agent.position;
455 const auto to = scratch.desired[i];
456 world.template field<OccupancyTag>(to) = true;
457 world.template field<ReservationTag>(to) = false;
458 if (advance_options.movement_dirty_mask) {
459 world.mark_dirty(chunk_key<Shape>(chunk_coord<Shape>(from)),
460 advance_options.movement_dirty_mask,
461 Box3{from, Extent3{1, 1, 1}});
462 world.mark_dirty(chunk_key<Shape>(chunk_coord<Shape>(to)),
463 advance_options.movement_dirty_mask,
464 Box3{to, Extent3{1, 1, 1}});
465 }
466 ++agent.path_index;
467 agent.position = to;
468 detail::resume_path_agent(agent);
469 scratch.committed.push_back(static_cast<std::uint32_t>(i));
470 scratch.committed_from.push_back(from);
471 ++stats.frame.advanced;
472 if (agent.position == agent.goal) {
473 arrive_path_agent(agent, accounting);
474 agent.last_result = PathStatus::Found;
475 ++stats.frame.arrived;
476 }
477 break;
478 }
479 case detail::JointState::Failed: {
480 auto& agent = agents[i];
481 const auto status = static_cast<MovementStatus>(scratch.failure[i]);
482 record_movement_failure(stats.frame.movement_failures, status);
483 if (is_transient_movement_failure(status)) {
484 detail::block_path_agent(agent, status);
485 ++stats.frame.blocked_waits;
486 } else {
487 agent.last_result.reset();
488 agent.phase = PathAgentPhase::Unreachable;
489 fail_path_agent_flow(agent, accounting);
490 }
491 break;
492 }
493 case detail::JointState::Inactive:
494 case detail::JointState::Pending:
495 break;
496 }
497 }
498
499 // Observer callbacks fire only after the whole round's world and agent
500 // state has been applied, so every callback observes the final
501 // configuration — including both halves of a swap. An observer that
502 // maintains an injective tile-to-entity mirror must still buffer the
503 // round: applying removals for every reported move before any insertion
504 // is the only order that survives swaps and rotations, since a per-move
505 // upsert collides with a not-yet-processed peer's stale entry. An
506 // observer exception propagates with world and agents fully consistent;
507 // callbacks for the round's later moves are skipped.
508 for (std::size_t k = 0; k < scratch.committed.size(); ++k) {
509 const auto index = static_cast<std::size_t>(scratch.committed[k]);
510 on_commit(index, scratch.committed_from[k], agents[index].position);
511 }
512
513 if (admitted_count == 0) {
514 break; // no motion this round; further rounds cannot differ
515 }
516 }
517 return stats;
518}
519
521template <typename World, typename ClassOrTag, typename OccupancyTag,
522 typename ReservationTag>
523auto advance_path_agents_with_joint_movement(
524 World& world, std::span<PathAgentState> agents,
525 const PathAgentRoutes& routes, JointMoveScratch& scratch,
526 JointMoveOptions options = {}, PathAgentAdvanceOptions advance_options = {},
527 diagnostics::FlowAccounting* accounting = nullptr) -> JointMoveStats {
528 return advance_path_agents_with_joint_movement<World, ClassOrTag,
529 OccupancyTag, ReservationTag>(
530 world, agents, routes, scratch, options, advance_options,
531 [](std::size_t, Coord3, Coord3) {}, accounting);
532}
533
534// Mirrors `tick_weighted_path_agents_with_movement` with the joint advance in
535// place of the per-agent one; see that driver for the planning semantics.
537template <typename World, typename Class, std::uint32_t MaxCost,
538 typename OccupancyTag, typename ReservationTag>
539[[nodiscard]] auto tick_weighted_path_agents_with_joint_movement(
540 PathAgentTickState& state, World& world, std::span<PathAgentState> agents,
541 PathRequestRuntime& runtime, JointMoveScratch& scratch,
542 PathAgentTickOptions options = {}, JointMoveOptions joint_options = {},
543 const RegionGraphT<typename World::residency_type>* graph = nullptr,
544 JointMoveStats* joint_stats = nullptr) -> PathAgentTickStats {
545 PathAgentTickStats stats;
546 stats.tick = advance_sim_tick(state.clock);
547
548 const bool repath_needed = prepare_path_agent_processing(
549 agents, options, stats, state.flow_accounting);
550 state.routes.ensure_size(agents.size());
551 if (state.pathing_dirty || repath_needed) {
552 const auto scope =
553 state.pathing_dirty ? PathSubmitScope::All : PathSubmitScope::NeedsOnly;
554 stats.pathing = process_weighted_path_agents<World, Class, MaxCost>(
555 world, agents, runtime, options.cache_policy, graph, scope,
556 &state.routes, state.flow_accounting);
557 stats.processed_paths = true;
558 state.pathing_dirty = false;
559 }
560
561 auto joint = advance_path_agents_with_joint_movement<
562 World, Class, OccupancyTag, ReservationTag>(
563 world, agents, state.routes, scratch, joint_options,
564 PathAgentAdvanceOptions{options.max_steps, options.movement_dirty_mask},
565 state.flow_accounting);
566 stats.movement = joint.frame;
567 if (joint_stats != nullptr) {
568 *joint_stats = joint;
569 }
570 return stats;
571}
572
573} // namespace tess
Definition world.h:22
Definition shape.h:94
Definition shape.h:46
Definition shape.h:14
Configures cycle admission for one joint movement pass.
Definition joint_movement.h:54
std::uint32_t deadlock_ticks
Definition joint_movement.h:62
Caller-owned workspace for the joint movement pass.
Definition joint_movement.h:125
void reserve(std::size_t agent_count)
Pre-sizes every internal container for agent_count agents.
Definition joint_movement.h:127
Reports joint-admission outcomes alongside the standard movement stats.
Definition joint_movement.h:66
std::size_t swaps
Two-agent cycles exchanged under the active policy.
Definition joint_movement.h:73
std::size_t swaps_denied
Two-agent cycles refused by the active policy.
Definition joint_movement.h:75
std::size_t chained
Moves admitted only because their destination was vacated this tick.
Definition joint_movement.h:69
std::size_t rotations
Cycles of length >= 3 rotated one place.
Definition joint_movement.h:71
Describes an adjacent move and any versions it expects to remain current.
Definition movement.h:41
Configures bounded direct movement and the dirty bits it emits.
Definition path_agent.h:78
Summarizes path submission, results, movement, and failure outcomes.
Definition path_agent.h:50
Owns index-paired route copies retained across scoped processing passes.
Definition path_agent.h:115
Definition shape.h:296
Definition diagnostics.h:505