Skip to content

fix(engine, ai): deterministic ordering for AI decision trajectories (#4878)#5308

Open
andriypolanski wants to merge 10 commits into
phase-rs:mainfrom
andriypolanski:fix/4878-ai-decision-determinism
Open

fix(engine, ai): deterministic ordering for AI decision trajectories (#4878)#5308
andriypolanski wants to merge 10 commits into
phase-rs:mainfrom
andriypolanski:fix/4878-ai-decision-determinism

Conversation

@andriypolanski

@andriypolanski andriypolanski commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Deterministic AI decision ordering (#4878)

Summary

Replace Debug-string and HashMap-iteration-order tie-breaking with a typed, allocation-free GameAction::cmp_stable total order across legal-action enumeration, search scoring, and softmax selection. Fixes cross-process AI trajectory divergence caused by HashSet/HashMap RandomState leaking into decision order.

Fixes #4878.

Root cause — state_clone_for_legality +425

The perf-gate FAIL on the pre-merge baseline was not a clone-storm from cmp_stable sorting. Sorting never calls record_state_clone_for_legality(); clones are recorded only in SimulationFilter::fallback_simulation and PlannerServices::validate_candidates (one clone per candidate that fails structural fast-path).

Factor Evidence
Stale baseline Baseline refreshed at 733a3eea (pre-main-merge). Post-merge HEAD includes ~15 engine commits + card-data regen.
Trajectory shift, not sort overhead 10 cold-process samples on HEAD: state_clone_for_legality median 4838, spread 17 (0.35%). Stable band, wrong center.
#4878-only delta was minimal At same card-data pre-merge: main 4419#4878 refresh 4413 (−6). Ordering fix did not inflate clones.
Post-merge trajectory New baseline 4844 (+425 vs stale 4413) reflects merged engine changes (Kodama defer, Winter Orb, parser statics, etc.) exploring a different AI path with more simulation-filter fallbacks.

Performance metrics

Counter comparison (median-of-K, 3 mirror scenarios, seed 0x9E37_79B9, cap 3000)

counter main branch (M15 baseline) Δ vs main M15 worst (25 runs) band ceiling margin
state_clone_for_legality 4419 4844 +425 (+9.6%) 4856 4997 OK
layers_full_eval 6102 6467 +365 (+6.0%) 6467 6660 OK
restriction_static_mode_gate_scans 25694 29428 +3734 (+14.5%) 29436 30195 OK
auto_tap_source_cache_builds 516 205 −311 213 242 OK
legal_actions_spell_cost_sweeps 516 205 −311 213 242 OK
attackable_player_sweeps 555 557 +2 557 602 OK

Trajectory-dependent counters shifted because deterministic ordering + merged engine fixes change which lines the AI plays. No counter exceeded the 5%+64 FAIL threshold after baseline refresh.

Cross-process stability (state_clone_for_legality, 10 independent cold samples)

stat value
min 4833
median 4838
max 4850
spread 17 (0.35%)

In-process determinism (red-mirror, perf_in_process_jitter_diagnostic)

in-process (winner, turn): run1=(Some(PlayerId(1)), 12) run2=(Some(PlayerId(1)), 12) equal=true
in-process jitter: 0 of 28 counters differ (issue #4878)

Macro trajectory and all 28 perf counters are byte-identical across two sequential in-process runs on HEAD.

M15 reproducibility validation

scripts/validate-ai-perf-reproducibility.sh  →  REPRO VALIDATION PASSED (margin+band)
check result
Baseline refresh (median-of-K, K=5) PASS — written to perf-baseline.json
25 further median-of-K gate runs 25/25 band PASS (0 FAIL)
Margin gate (50% of FAIL headroom) 0 OVER-MARGIN of 28 counters
cargo ai-perf-gate post-refresh 28 PASS, 0 FAIL

CI budget (option c)

metric value
T_build (cold isolated debug) 73s
T_run_max (max of 25 repro runs) 585s
T_run_max × 2.5 + T_build 1536s (~25.6 min)

Borderline over the 25-min CI budget target. Counter gate is stable; if CI times out, fall back to release profile + rust-ai-perf-release cache key (plan option b).

M15 margin table (worst observed across 25 runs)

All 28 counters OK. Key counter:

counter baseline worst ceiling (50%) threshold status
state_clone_for_legality 4844 4856 4997 5150 OK

Full table in target/m15-validation.log.

Correctness tests

test crate status
validated_candidates_are_cmp_stable_sorted engine PASS
softmax_fallback_tiebreak_is_cmp_stable_deterministic phase-ai PASS
score_candidates_non_measurement_order_is_cmp_stable_canonical phase-ai PASS
perf_in_process_jitter_diagnostic phase-ai PASS (0/28 jitter)

Changes

Engine

  • action_stable_order.rs: allocation-free total order over all GameAction variants
  • GameActionKind discriminant + cmp_stable() on GameAction
  • validated_candidate_actions: sort by cmp_stable after filter pipeline
  • Ord derives on payload field types used in action ordering

phase-ai

  • Search/scoring paths: canonical cmp_stable order before softmax and after K-sample merge
  • Softmax degenerate-weight fallback: tie-break by cmp_stable, not input order

Baseline

  • perf-baseline.json refreshed at HEAD (e8ee98a322d3) after M15 margin validation

Risk notes

Test plan

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread crates/engine/src/ai_support/mod.rs Outdated

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR · 4 card(s), 5 signature(s) (baseline: main 57684e586cac)

2 card(s) · ability/ApplyPerpetual · removed: ApplyPerpetual

Examples: Chronicler of Worship, Lumbering Lightshield

2 card(s) · replacement/Moved · added: Moved

Examples: Famished Worldsire, Feasting Hobbit

2 card(s) · replacement/Moved · removed: Moved

Examples: Famished Worldsire, Feasting Hobbit

1 card(s) · ability/RaiseCost · added: RaiseCost (affects=any target, duration=until end of turn, grants=RaiseCost, target=any target)

Examples: Lumbering Lightshield

1 card(s) · ability/ReduceCost · added: ReduceCost (affects=any target, duration=until end of turn, grants=ReduceCost, target=any target)

Examples: Chronicler of Worship

2 card(s) had Oracle-text changes (errata/reprint) — excluded as non-parser.

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

andriypolanski and others added 5 commits July 7, 2026 20:14
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>
@andriypolanski

Copy link
Copy Markdown
Contributor Author

Hi, @matthewevans

this pr is waiting your feedback during long time.

Comment on lines +59 to +65
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a PR that might require more time being spent on it @andriypolanski - I need to ensure there are not negative performance implications.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It also feels like apply_with_probe returning a BTreeSet would prevent this manual sort afterwards.

@matthewevans

Copy link
Copy Markdown
Member

@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 :)

@andriypolanski

andriypolanski commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Hi, @matthewevans
I think that the architectural direction (typed cmp_stable total order replacing Debug string sorts) is sound, and the trajectory stabilizes at the macro level. But shipping without a passing perf gate + M15 validation would violate the project's own enqueue bar — especially for a change that touches AI hot paths and intentionally shifts decision order.

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. :)

@andriypolanski

Copy link
Copy Markdown
Contributor Author

Hi, @matthewevans

I updated pr description, could you please take a look ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

engine: AI decision trajectory not cross-process deterministic (HashSet RandomState iteration order leaks into decisions)

2 participants