← all tickets

47 — Single-tree MCTS selection replays stale action payloads across determinisations

Status: fixed

Severity: High. Found in review of ticket 45's implementation; verified against the code.

The bug: action_key now merges PlayOperation by card_code (rust/netrunner-core/src/ai/mcts.rs:209), while a Runner-root search reshuffles corp hidden information every iteration (ai/determinise.rs:31–32). During selection, the single-tree engine replays the chosen child's stored Action — original card_index and all — directly via apply_action on the freshly determinised state (ai/mcts.rs:332–335), instead of resolving the current legal action for that key.

A stored PlayOperation { card_index: 0, card_code: "..." } therefore applies whatever card the new determinisation put at HQ index 0. play_operation (engine.rs:446–463) checks clicks, index range, and credits but not card type, so a shuffled asset or agenda can be charged, removed from HQ, logged as played, given on_play hooks, and placed faceup in Archives. This corrupts rollouts and poisons the statistics of a tree edge that claims, by key, to represent a specific operation.

Scope is wider than PlayOperation. The stale-payload replay predates ticket 45 — select has always replayed stored actions across determinisations (corp install_card hand indices, the ticket-43 index keys during opponent-decision descent, discard_card, …). Pre-45 the damage was mostly mis-attributed statistics; ticket 45's code keys turned it into engine-state corruption with a type confusion. Fix the class, not the symptom.

Fix

Mirror mcts2, which already solved this: after UCB1 picks the best child, resolve the action to apply from the current state's legal_actions by matching action_key, not from the stored node (ai/mcts2.rs:313–320 — "Use the current determinisation's action object for this key: same key, payload valid for this state").

In select (ai/mcts.rs):

(the legal set is already computed there for the untried-key check — reuse it).

current legal set and apply the *current* action object. Multiple legal actions sharing one key (duplicate copies of the same operation in HQ) are equivalent by construction — first match is fine.

apply the stored action; count it via ErrorCounters and terminate the descent as the existing Err arm does today. (The single-tree engine has no availability-based subset selection like mcts2; not introducing it here keeps the fix minimal — note the asymmetry in the module docs.)

Hardening (defense in depth)

Add the missing type guard to play_operation in engine.rs: return illegal(...) unless the card at card_index is an operation. legal.rs never generates the action for non-operations, so this only defends against stale replays and bad wire input — which is exactly what just happened. Check whether sibling handlers trust an index the same way (e.g. score/ advance already validate their targets; anything that doesn't gets the same one-line guard).

Definition of done

determinisation's legal set; the stored child Action is used solely for key identity.

index whose determinised HQ has a non-operation at that index; assert the wrong card is not played (pre-fix this corrupts state, post-fix the edge resolves to the correct card by code or the descent terminates).

matching the existing mcts2 comment.

cargo test --workspace pass from rust/; uv run pytest -q green.

Comments

2026-08-02: Filed from maintainer review of ticket 45's implementation. All four claims verified against the code before filing. mcts2 is unaffected (it already resolves by key); the single-tree engine is the only replay site — expansion and rollout both draw actions from the current legal set.

2026-08-02: Fixed.

select (rust/netrunner-core/src/ai/mcts.rs) now builds a (key, &Action) table from legal_actions(&state) once per descent step — the same table that answers the untried-key check — and, after argmax_first picks best_idx, applies the action carrying that child's key in *this* determinisation. The stored child Action is only ever read through action_key. When the chosen child's key is not legal here, the descent is counted in ErrorCounters (as EngineError::Illegal) and terminated; the stale payload is never applied as a fallback. The module docs on select carry a "By-key resolution (ticket 47)" section explaining the reshuffle, the ticket-45 type confusion it caused, and the deliberate asymmetry with mcts2's availability-based subset selection.

Hardening in rust/netrunner-core/src/engine.rs: play_operation rejects a non-operation at card_index. Two siblings trusted an index the same way and got the same one-line guard — play_event (non-event in the grip) and the Corp install_card (non-ICE in the as_ice branch, non-agenda/asset/upgrade in the server branch, mirroring install_runner_card_core's existing else if !card.is_resource() guard). score_agenda and advance_card already validated their targets; the Runner install path already did too.

Tests: select_resolves_stale_play_operation_index_by_key builds a root covering every legal key, rewrites the play_operation:op1 child's payload to a stale card_index: 0 (an agenda in this determinisation), makes it the UCB1 favourite, and asserts the operation at index 1 is what gets played with the agenda untouched — verified to fail against a stored-payload select. select_terminates_when_the_chosen_key_is_not_legal_here covers the no-legal-action arm. Three engine unit tests cover the type guards.

cargo fmt, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace (160, was 155) and uv run pytest -q (134) all green.