Skip to content

fix(engine): serialize game-state hash collections deterministically - #6989

Closed
nishu-builder wants to merge 4 commits into
phase-rs:mainfrom
nishu-builder:fix/deterministic-token-creator-serde
Closed

fix(engine): serialize game-state hash collections deterministically#6989
nishu-builder wants to merge 4 commits into
phase-rs:mainfrom
nishu-builder:fix/deterministic-token-creator-serde

Conversation

@nishu-builder

@nishu-builder nishu-builder commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Makes serialized GameState deterministic: hash-backed collections (HashSet/HashMap and im equivalents) reachable from the serialized state graph now emit a canonical sorted order at their owning field/type boundary, so identical states serialize byte-identically across processes. Found by an external deterministic-replay harness: replaying an identical seeded action sequence in a fresh process diverged at one transition with players_who_created_token_this_turn serializing [0,1] in one process and [1,0] in the other. Ordering-semantic collections (libraries, the stack, trigger order) are untouched; numeric-keyed maps keep their exact default wire representation and gain narrow field-owned deserializers because Serde's internal Content buffering otherwise breaks their numeric-key round-trip.

Files changed

  • crates/engine/src/types/deterministic_serde.rs (new)
  • crates/engine/src/types/game_state.rs
  • crates/engine/src/types/{ability,counter,events,identifiers,mod,player,proposed_event,resolution}.rs
  • crates/engine/src/game/{combat,game_object}.rs
  • crates/engine/tests/integration/deterministic_game_state_serde.rs (new)
  • crates/engine/tests/integration/{loop_shortcut,main}.rs
  • crates/engine/Cargo.toml, Cargo.lock (dev-dependency syn for the exhaustive census guard test)

CR references

None — serialization determinism only; no rules behavior changes.

Implementation method (required)

Method: /engine-implementer

Track

Developer

LLM

Model: gpt-5.6-sol
Thinking: high
Tier: Standard

Verification

  • Required checks ran clean, or the exact CI-owned alternative is stated below.

  • Gate A output below is for the current committed head.

  • Final review-impl below is clean for the current committed head.

  • Both anchors cite existing analogous code at the same seam.

  • tilt get uiresource clippy — Tilt unavailable in this worktree; used the documented direct fallback.

  • cargo fmt --all — passed.

  • cargo fmt --all -- --check — passed.

  • git diff --check — passed.

  • cargo clippy -p phase-engine --all-targets -- -D warnings — passed on head df220c4ebae49b87aaf7b003b76fe0f2862b14f8.

  • cargo test -p phase-engine — passed on head df220c4ebae49b87aaf7b003b76fe0f2862b14f8 (full unit + integration suites).

  • Plan verification matrix (16 targeted test invocations incl. populated WaitingFor::DeclareAttackers.attacker_constraints round-trip and adverse-order byte-identity through the real GameState fields) — all passed on head df220c4ebae49b87aaf7b003b76fe0f2862b14f8.

  • Revert probe — with the owning-field serializer reverted, deterministic_game_state_serde production-boundary regression failed 5/5 fresh runs; restored, passed.

  • ./scripts/gen-card-data.sh — passed on head df220c4ebae49b87aaf7b003b76fe0f2862b14f8: generated card data for ~35627 cards.

  • cargo coverage — passed on head df220c4ebae49b87aaf7b003b76fe0f2862b14f8: timeless legal 15123/16179 fully supported (93.5%); vintage legal 29840/32267 fully supported (92.5%).

  • cargo semantic-audit — passed on head df220c4ebae49b87aaf7b003b76fe0f2862b14f8: 32699 cards audited, 295 existing findings.

Gate A

Gate A PASS head=df220c4ebae49b87aaf7b003b76fe0f2862b14f8 base=6d7821dced9623609edea342b47dd9c704ff0b36

Anchored on

  • crates/engine/src/game/combat.rs:5218 — existing ordered_valid_blocker_ids establishes the deterministic-ordering-at-the-producing-seam precedent (PR fix(engine): stabilize blocker prompt ordering #5771 heritage) that this change extends to serialized state.
  • crates/engine/src/types/counter.rs:212 — existing counter_map_serde module owns a specialized field-boundary wire representation; the new deterministic_serde adapters follow the same field-owned serde-module pattern.

Final review-impl

Final review-impl PASS head=df220c4ebae49b87aaf7b003b76fe0f2862b14f8

Claimed parse impact

None.

Validation Failures

Resolved local environment issue: cargo coverage initially failed to compile due to No space left on device; cleared a generated target cache and reran all affected checks cleanly. Plan-review ran 4 rounds to clean (6 → 1 → 1 → 0 findings, including one executor hard-stop for a numeric-key round-trip contradiction that round 4 resolved); implementation review ran 2 rounds to clean (2 → 0 findings). Contributor-environment note per the engine-implementer skill: pipeline steps ran as isolated fresh contexts (Codex CLI sessions) with artifact-only handoffs rather than spawned Claude subagents.

CI Failures

None.

Summary by CodeRabbit

  • Improvements

    • Game state, combat data, and event serialization now produces stable output regardless of collection insertion order.
    • Numeric map keys are validated more strictly during deserialization, with improved compatibility for existing data formats.
    • State comparison diagnostics now reliably detect reordered array values.
  • Testing

    • Added comprehensive coverage for deterministic serialization, nested collections, ordering, round trips, malformed keys, boundary values, and compatibility with existing data formats.

@github-actions github-actions Bot added the needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps) label Aug 4, 2026
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

The head commit changed during the review from c79a0b5 to 6c511c3.

📝 Walkthrough

Walkthrough

The engine adds deterministic Serde support for unordered collections, validates numeric map keys, applies stable serializers across game state and event types, and adds integration tests for ordering, round trips, persistence forms, and array comparison behavior.

Changes

Deterministic serialization

Layer / File(s) Summary
Serde adapters and ordering contracts
crates/engine/src/types/deterministic_serde.rs, crates/engine/src/types/counter.rs, crates/engine/src/types/identifiers.rs, crates/engine/src/types/events.rs, crates/engine/src/types/proposed_event.rs, crates/engine/Cargo.toml
Adds sorted serializers for standard and im collections, strict numeric-key deserializers, arbitrary-hasher support, ordering derives, and adapter tests.
Game-state serialization wiring
crates/engine/src/types/game_state.rs
Applies deterministic serializers to game-state maps, sets, nested collections, combat prompts, histories, permissions, caches, and progression state.
Domain and event serialization wiring
crates/engine/src/game/combat.rs, crates/engine/src/game/game_object.rs, crates/engine/src/types/ability.rs, crates/engine/src/types/player.rs, crates/engine/src/types/resolution.rs, crates/engine/src/types/proposed_event.rs, crates/engine/src/types/events.rs
Applies deterministic serialization to combat, object, ability, player, resolution, counter, and replacement-tracking collections while retaining existing defaults and deserializers.
Integration validation
crates/engine/tests/integration/deterministic_game_state_serde.rs, crates/engine/tests/integration/loop_shortcut.rs, crates/engine/tests/integration/main.rs
Adds source census, numeric-key, persistence, ordering, round-trip, and byte-stability tests. Serialized array comparisons now treat ordering as significant.

Estimated code review effort: 4 (Complex) | ~60 minutes

Suggested labels: bug

Suggested reviewers: matthewevans

Sequence Diagram(s)

sequenceDiagram
  participant GameState
  participant deterministic_serde
  participant Persistence
  participant IntegrationTests
  GameState->>deterministic_serde: serialize maps and sets
  deterministic_serde->>Persistence: emit sorted serialized state
  Persistence->>deterministic_serde: deserialize numeric-key maps
  deterministic_serde->>GameState: restore validated state
  IntegrationTests->>GameState: compare insertion-order variants
  GameState->>IntegrationTests: return stable serialized bytes
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.27% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: deterministic serialization of engine game-state hash collections.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🧹 Nitpick comments (3)
crates/engine/src/types/deterministic_serde.rs (2)

685-708: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add a key that overflows the target type, not only the u64 domain.

The malformed list exercises syntax rejection and u64 overflow. It never reaches the narrowing branch of NumericMapKey for PlayerId at Lines 29-33, which is the only guard that rejects a player key above 255. Add a PlayerId-keyed fixture with key "256" and assert the error. Without it, replacing u8::try_from with a truncating cast breaks no test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/types/deterministic_serde.rs` around lines 685 - 708, The
malformed-key coverage in the deterministic serde tests does not exercise the
narrowing guard in NumericMapKey for PlayerId. Add a PlayerId-keyed fixture
using the string key "256" and assert that deserializing it returns an error,
while preserving the existing malformed-key assertions.

Source: Path instructions


326-344: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Delegate im_hash_map to the existing SortedImHashMap wrapper.

The body of im_hash_map is identical to SortedImHashMap::serialize at Lines 414-426. Reuse the wrapper so one implementation owns the sort-then-emit sequence. The same duplication exists in hash_map_of_hash_set and hash_map_of_hash_map, which repeat the SortedHashMap::serialize body with a different value wrapper; a value-adapter generic over the wrapper would collapse those too.

♻️ Proposed delegation
 where
     K: Clone + Eq + Hash + Ord + Serialize,
     V: Clone + Serialize,
     H: BuildHasher,
     S: Serializer,
 {
-    let mut entries: Vec<_> = values.iter().collect();
-    entries.sort_unstable_by_key(|(key, _)| *key);
-
-    let mut map = serializer.serialize_map(Some(entries.len()))?;
-    for (key, value) in entries {
-        map.serialize_entry(key, value)?;
-    }
-    map.end()
+    SortedImHashMap(values).serialize(serializer)
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/types/deterministic_serde.rs` around lines 326 - 344,
Update im_hash_map to delegate serialization to the existing SortedImHashMap
wrapper instead of duplicating its sort-and-emit logic. Also consolidate
hash_map_of_hash_set and hash_map_of_hash_map around SortedHashMap::serialize
using a value adapter generic over the wrapper, while preserving their existing
value serialization behavior.

Source: Coding guidelines

crates/engine/tests/integration/deterministic_game_state_serde.rs (1)

2105-2128: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

Consider tracking the pinned tuple-key serialization failure.

This test pins existing behavior: a nonempty protection_start_exempt_attachments makes the whole GameState fail to serialize with "key must be a string". Pinning it is the right scope decision for this PR, and the census at line 1116 stops the field from adopting a generic adapter.

The underlying defect remains: any game state that populates this map cannot be saved. GameObject.protection_start_exempt_attachments uses a (u64, u64, ObjectId) key, which JSON cannot represent as an object key. A dedicated string-key adapter, in the style of tuple_key_map in crates/engine/src/types/game_state.rs, would resolve it.

Do you want me to open an issue to track this separately?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/tests/integration/deterministic_game_state_serde.rs` around
lines 2105 - 2128, The test currently pins the known serialization failure for
populated GameObject.protection_start_exempt_attachments; preserve this
regression coverage and its exact "key must be a string" assertion. Do not add a
serialization fix or generic adapter in this change; track the dedicated
string-key adapter for the tuple-key map separately, following tuple_key_map as
the eventual implementation reference.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/engine/src/types/deterministic_serde.rs`:
- Around line 151-160: In the map deserializer’s visit_map method, clamp the
untrusted access.size_hint() to a safe maximum before passing it to
HashMap::with_capacity. Preserve the existing zero fallback when no hint is
available and keep the subsequent entry-reading logic unchanged.

---

Nitpick comments:
In `@crates/engine/src/types/deterministic_serde.rs`:
- Around line 685-708: The malformed-key coverage in the deterministic serde
tests does not exercise the narrowing guard in NumericMapKey for PlayerId. Add a
PlayerId-keyed fixture using the string key "256" and assert that deserializing
it returns an error, while preserving the existing malformed-key assertions.
- Around line 326-344: Update im_hash_map to delegate serialization to the
existing SortedImHashMap wrapper instead of duplicating its sort-and-emit logic.
Also consolidate hash_map_of_hash_set and hash_map_of_hash_map around
SortedHashMap::serialize using a value adapter generic over the wrapper, while
preserving their existing value serialization behavior.

In `@crates/engine/tests/integration/deterministic_game_state_serde.rs`:
- Around line 2105-2128: The test currently pins the known serialization failure
for populated GameObject.protection_start_exempt_attachments; preserve this
regression coverage and its exact "key must be a string" assertion. Do not add a
serialization fix or generic adapter in this change; track the dedicated
string-key adapter for the tuple-key map separately, following tuple_key_map as
the eventual implementation reference.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b90ed091-1479-432c-ad4c-fe8ea7d43f4d

📥 Commits

Reviewing files that changed from the base of the PR and between cdb99ba and df220c4.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (16)
  • crates/engine/Cargo.toml
  • crates/engine/src/game/combat.rs
  • crates/engine/src/game/game_object.rs
  • crates/engine/src/types/ability.rs
  • crates/engine/src/types/counter.rs
  • crates/engine/src/types/deterministic_serde.rs
  • crates/engine/src/types/events.rs
  • crates/engine/src/types/game_state.rs
  • crates/engine/src/types/identifiers.rs
  • crates/engine/src/types/mod.rs
  • crates/engine/src/types/player.rs
  • crates/engine/src/types/proposed_event.rs
  • crates/engine/src/types/resolution.rs
  • crates/engine/tests/integration/deterministic_game_state_serde.rs
  • crates/engine/tests/integration/loop_shortcut.rs
  • crates/engine/tests/integration/main.rs

Comment thread crates/engine/src/types/deterministic_serde.rs
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

Generated for head 7a01cbba992249e4c87803232d28190c0f3fb067.

Parse changes introduced by this PR

✓ No card-parse changes detected.

@matthewevans matthewevans self-assigned this Aug 4, 2026

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

Two merge-blocking issues remain on this head:

  1. crates/engine/src/serialization/deterministic_serde.rs:151-160 passes the untrusted MapAccess::size_hint() directly to HashMap::with_capacity. A malicious or custom deserializer can advertise an enormous hint and force unreasonable preallocation; the JSON path happens to report None today, but that does not make the generic Deserialize implementation safe. Avoid or cap the preallocation, and add an adversarial MapAccess size-hint regression.

  2. The strict numeric-key tests at crates/engine/src/serialization/deterministic_serde.rs:676-708 cover only ObjectId, while PlayerId converts through u8::try_from at :29-33. Add a HashMap<PlayerId, _> fixture that rejects the key "256", so the actual narrow-ID boundary is covered.

@matthewevans matthewevans removed their assignment Aug 4, 2026
@nishu-builder

Copy link
Copy Markdown
Contributor Author

Both findings addressed in 7a01cbb.

  1. Untrusted size_hint preallocation — the strict numeric-key map deserializer no longer passes MapAccess::size_hint() to HashMap::with_capacity unclamped; the hint is capped by a cautious constant bound at the allocation site (serde's own cautious-size-hint approach). Adversarial regression added: a custom MapAccess advertising an enormous hint deserializes a small map correctly without the advertised preallocation (types::deterministic_serde::tests, includes the capped-path reach check).

  2. PlayerId narrow-key boundary — added a HashMap<PlayerId, _> strict-key fixture asserting key "256" is rejected with the strict numeric-key error, paired with a valid PlayerId key deserializing in the same test as the positive reach-guard, so the u8::try_from boundary is covered directly rather than only via ObjectId.

Verification on head 7a01cbba992249e4c87803232d28190c0f3fb067:

  • cargo fmt --all -- --check — passed.
  • cargo clippy -p phase-engine --all-targets -- -D warnings — passed.
  • cargo test -p phase-engine types::deterministic_serde::tests:: — 6 passed (both new regressions included).
  • cargo test -p phase-engine --test integration deterministic_game_state_serde:: — 7 passed, 0 failed.
  • cargo test -p phase-engine — full suite passed, 0 failed (largest bucket 4484 integration tests).
  • Gate A PASS head=7a01cbba992249e4c87803232d28190c0f3fb067 base=6d7821dced9623609edea342b47dd9c704ff0b36
  • Final review-impl PASS head=7a01cbba992249e4c87803232d28190c0f3fb067 (fresh-context review of the fix delta against both findings before push).

Model: gpt-5.6-sol

@matthewevans

Copy link
Copy Markdown
Member

This PR is closed as out of policy. It was created on 2026-08-04, after the 2026-07-24 declaration cutoff. The policy revision in force requires the canonical Tier: Frontier line; this PR declares Tier: Standard.

@matthewevans

Copy link
Copy Markdown
Member

Re-opening this @nishu-builder - this shouldn't have been closed. Mistake by the review bot.

@matthewevans matthewevans reopened this Aug 5, 2026
@matthewevans

Copy link
Copy Markdown
Member

Closing as redundant: the current PR head has no delta from its base (GitHub reports no changed files), and the equivalent deterministic serialization change has already landed in #7008 (f725442bc852fe8a506392bbae26ae9303aa2f53).

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

Labels

needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants