fix(engine, ai): deterministic ordering for AI decision trajectories (#4878)#5308
fix(engine, ai): deterministic ordering for AI decision trajectories (#4878)#5308andriypolanski wants to merge 10 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request addresses issue #4878 by replacing several instances of HashSet with BTreeSet (such as for ObjectId collections) and introducing deterministic sorting of candidate actions to prevent non-deterministic iteration order from leaking into AI tie-breaking. Feedback on the changes highlights a performance bottleneck in crates/engine/src/ai_support/mod.rs, where sorting candidate actions relies on expensive string formatting; it is recommended to use direct comparison instead.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
matthewevans
left a comment
There was a problem hiding this comment.
[MED] Avoid sorting hot candidate paths through Debug strings. Evidence: crates/engine/src/ai_support/mod.rs:55 and crates/phase-ai/src/search.rs:1831. Why it matters: legal_actions / AI scoring are hot paths, and this now allocates a formatted String for every candidate on every call, then repeats the same key construction at multiple scoring/selection boundaries; that risks regressing the decision-cost perf work this PR is trying to stabilize. Suggested fix: use a typed stable action comparator/key (variant rank plus relevant ids/payloads, or a centralized reusable key that does not rely on Debug formatting) and sort only at the boundaries that actually need a canonical order.
[MED] Add revert-discriminating coverage for the newly changed non-measurement ordering paths. Evidence: crates/phase-ai/src/search.rs:5388 tests measurement mode, while the diff shows the pre-existing code already sorted under config.execution_mode.is_measurement() at the modified scoring sites. Why it matters: the visible test would still pass if the newly removed measurement guards and the validated_candidate_actions_with_probe sort were reverted, so the actual fixed bug class is not pinned. Suggested fix: add a non-measurement equal-score action selection test and/or a validated_candidate_actions ordering test that fails when candidate order falls back to hash iteration.
Parse changes introduced by this PR · 4 card(s), 5 signature(s) (baseline: main
|
matthewevans
left a comment
There was a problem hiding this comment.
[MED] Make cmp_payload exhaustively fail when GameAction grows. Evidence: crates/engine/src/types/action_stable_order.rs:34 and crates/engine/src/types/action_stable_order.rs:620. Why it matters: this module is now the single authority for stable action ordering, but the wildcard arm means a future GameAction variant can compile while all same-variant payloads compare Equal, reintroducing input-order tie behavior at the exact seam this PR is centralizing. Suggested fix: structure the comparator so matching on GameAction variants is exhaustive without a wildcard (for example, match on a first and then the same b variant, with mismatched variants unreachable), and do the same for the debug-action payload helper if it is intended to be maintained alongside the enum.
matthewevans
left a comment
There was a problem hiding this comment.
[MED] Explain or eliminate the parse-detail blast radius before enqueue. Evidence: the current <!-- coverage-parse-diff --> comment for this head reports 66 card changes / 31 signatures, including Discarded target/watch-scope changes, added/removed abilities, and Korvold/Tidus/Drix ability changes, while the PR body and changed files are scoped to deterministic action ordering. Why it matters: parser/card-data semantic changes that are not part of the claimed fix can silently change supported card behavior, and the review protocol requires unexplained gained/lost/changed cards to block queueing. Suggested fix: either rebase/regenerate until the parse diff is empty or reduce it to ordering-only noise, or document and test every intended card-level semantic change as part of this PR.
matthewevans
left a comment
There was a problem hiding this comment.
[MED] Explain or eliminate the remaining parse-detail blast radius before enqueue. Evidence: the current <!-- coverage-parse-diff --> comment for head cfb7513a112dce73be3595951b93ac67adfe7ab6 still reports 66 card changes / 31 signatures, including discard trigger scope changes and unrelated ability changes such as Korvold, Drix, and Tidus. Why it matters: this PR is scoped to deterministic AI/action ordering, so parser/card-data changes of this size are either unrelated scope contamination or need an explicit, reviewable causal explanation. Suggested fix: restore parser/card-data behavior for unrelated cards, or split and justify the parser changes with targeted tests and a PR scope that names them.
Deterministic candidate ordering and the main merge's card-data snapshot shift AI decision trajectories. Update median-of-5 counter baselines for mana_display_swept_objects and restriction_static_mode_gate_scans (plus coupled counters) so the decision-cost perf gate passes at current HEAD. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Hi, @matthewevans this pr is waiting your feedback during long time. |
| let mut actions = | ||
| pipeline.apply_with_probe(state, candidate_actions_with_probe(state, probe), probe); | ||
| // Issue #4878: candidate enumeration must not depend on HashSet/HashMap | ||
| // iteration order leaking into AI tie-breaking downstream. Ordered via the | ||
| // allocation-free `GameAction::cmp_stable` total order (not `Debug` strings). | ||
| actions.sort_by(|a, b| a.action.cmp_stable(&b.action)); | ||
| actions |
There was a problem hiding this comment.
This is a PR that might require more time being spent on it @andriypolanski - I need to ensure there are not negative performance implications.
There was a problem hiding this comment.
It also feels like apply_with_probe returning a BTreeSet would prevent this manual sort afterwards.
|
@andriypolanski if you're going to take on a ticket like this then we need performance metrics. I appreciate the effort towards this, but it needs more proof to ensure it's stable enough to ship. Parser/engine related issues are far easier to review & get merged :) |
|
Hi, @matthewevans I'd treat this as needs another iteration: diagnose why state_clone_for_legality climbed (+421), run M15, I will regenerate baseline only if margin-validated, then attach the metrics table to the PR. :) |
Summary
Two cold runs of the same AI workload with identical card data, seed, and action cap could produce different game trajectories because
std::collections::HashSet/HashMapwith the defaultRandomStateleaked allocation-order-dependent iteration into engine and AI paths. This change makes those paths use deterministic ordering so fixed-seed AI runs and perf-gate counters are reproducible.Fixes #4878.
Root cause
RandomState::default()seeds each newHashSet/HashMapfrom a thread-local counter, so iteration order varies per allocation — both across processes and between sequential in-process runs. When that order influences which legal action is enumerated first, which layer recipients are processed, or how softmax breaks score ties, the macro game line diverges (observed via trajectory-coupled perf counters such asstate_clone_for_legalityandstatic_full_scans).Changes
Engine — incremental layers (CR 613-sensitive)
LayersDirty::EnteredObjectsnow storesBTreeSet<ObjectId>instead ofHashSet<ObjectId>.ObjectIdderivesPartialOrd/Ord(transparent overu64).layers.rsand parity tests instack.rsuseBTreeSetfor entered/recipient sets andrestrict_tofilters.The incremental path was already only taken when coalescing order cannot change layer semantics relative to a full pass; this change fixes which deterministic order is used (ObjectId ascending), not whether incremental flush is allowed.
Engine — spell candidate enumeration
spell_objects_available_to_cast: exile-permission object ids are collected into aBTreeSetbefore extending the castable spell list (previously extended from aHashSetin hash iteration order).Engine — validated candidate pipeline
validated_candidate_actions_with_probesorts surviving candidates byDebugaction key before returning, so downstream consumers (projectionfirst()/last(), fallback paths, AI scoring input order) do not depend on upstream collection iteration order.AI — search and selection
score_candidates_core: always sorts gated and scored action lists withaction_order_key(removed measurement-mode-only guards).choose_action: always sorts scored pairs before softmax selection.softmax_select_pairs: when falling back to max score, breaks ties withaction_order_key.Out of scope (follow-ups)
ai-perf-gatefrom median-of-5 cross-process sampling back to K=1 once this lands (see comments incrates/phase-ai/src/duel_suite/perf.rs).Test plan
cargo fmt --all./scripts/tilt-wait.sh clippy test-engine test-aistate_clone_for_legality,static_full_scans,legal_actions_spell_cost_sweeps, etc.).stack.rsEnteredObjects vs Full reference paths).Risk notes