고속 엔진 undo stack 추가

This commit is contained in:
2026-05-06 22:02:04 +09:00
parent a349cf34ed
commit 2629fbdd99
4 changed files with 103 additions and 8 deletions
+2 -7
View File
@@ -5,12 +5,7 @@ serious traversal code should use the Cython `fast.pxd` API directly.
Deferred work: Deferred work:
1. Add an internal undo stack with `push_action()` / `pop_action()` so Python 1. Consider direct NumPy or feature-buffer output for RL pipelines instead of
callers can avoid tuple allocation when they need nested search.
2. Keep traversal legal-action generation caller-buffer based. Consider a
reusable Python-wrapper action buffer only if wrapper profiling shows
`legal_actions()` allocation is material.
3. Consider direct NumPy or feature-buffer output for RL pipelines instead of
building Python lists and converting later. building Python lists and converting later.
4. Consider a single contiguous allocation for state arrays after profiling the 2. Consider a single contiguous allocation for state arrays after profiling the
simpler separate-allocation layout. simpler separate-allocation layout.
@@ -43,6 +43,9 @@ cdef class FastGameState:
cdef int* numeric_sums cdef int* numeric_sums
cdef int* expedition_scores cdef int* expedition_scores
cdef int total_scores[2] cdef int total_scores[2]
cdef UndoRecord* undo_stack
cdef int undo_stack_len
cdef int undo_stack_capacity
cdef public int current_player cdef public int current_player
cdef int phase_id cdef int phase_id
@@ -66,6 +69,9 @@ cdef class FastGameState:
cpdef object apply_action_with_undo(self, int action_id) cpdef object apply_action_with_undo(self, int action_id)
cpdef object apply_unified_action_with_undo(self, int action_id) cpdef object apply_unified_action_with_undo(self, int action_id)
cpdef undo_action(self, object undo) cpdef undo_action(self, object undo)
cpdef int push_action(self, int action_id)
cpdef int push_unified_action(self, int action_id)
cpdef int pop_action(self)
cpdef bint can_play_encoded_card(self, int player, int card) cpdef bint can_play_encoded_card(self, int player, int card)
cpdef int last_numeric_rank(self, int player, int color) cpdef int last_numeric_rank(self, int player, int color)
cpdef int expedition_score(self, int player, int color) cpdef int expedition_score(self, int player, int color)
@@ -79,6 +85,9 @@ cdef class FastGameState:
cdef void _fill_undo_c(self, int action_id, UndoRecord* undo) noexcept cdef void _fill_undo_c(self, int action_id, UndoRecord* undo) noexcept
cdef void _apply_action_with_undo_c(self, int action_id, UndoRecord* undo) except * cdef void _apply_action_with_undo_c(self, int action_id, UndoRecord* undo) except *
cdef void _apply_action_unchecked_c(self, int action_id) except * cdef void _apply_action_unchecked_c(self, int action_id) except *
cdef void _ensure_undo_capacity_c(self) except *
cdef int _push_action_c(self, int action_id) except *
cdef int _pop_action_c(self) except *
cdef object _undo_to_tuple(self, UndoRecord* undo) cdef object _undo_to_tuple(self, UndoRecord* undo)
cdef void _tuple_to_undo(self, object data, UndoRecord* undo) except * cdef void _tuple_to_undo(self, object data, UndoRecord* undo) except *
cdef void _apply_card_action(self, int action_id) except * cdef void _apply_card_action(self, int action_id) except *
@@ -5,7 +5,7 @@ from collections import Counter
import random import random
from libc.string cimport memcpy from libc.string cimport memcpy
from libc.stdlib cimport free, malloc from libc.stdlib cimport free, malloc, realloc
from ..game import IllegalMoveError, LostCitiesConfig, config_from_mapping from ..game import IllegalMoveError, LostCitiesConfig, config_from_mapping
@@ -30,6 +30,7 @@ cdef class FastGameState:
self.handshake_counts = NULL self.handshake_counts = NULL
self.numeric_sums = NULL self.numeric_sums = NULL
self.expedition_scores = NULL self.expedition_scores = NULL
self.undo_stack = NULL
def __init__(self, config=None): def __init__(self, config=None):
config = config or LostCitiesConfig() config = config or LostCitiesConfig()
@@ -57,6 +58,8 @@ cdef class FastGameState:
free(self.numeric_sums) free(self.numeric_sums)
if self.expedition_scores != NULL: if self.expedition_scores != NULL:
free(self.expedition_scores) free(self.expedition_scores)
if self.undo_stack != NULL:
free(self.undo_stack)
cdef void _configure(self, object config) except *: cdef void _configure(self, object config) except *:
self.config = config self.config = config
@@ -84,6 +87,10 @@ cdef class FastGameState:
self.handshake_counts = <int*>malloc(2 * self.n_colors * sizeof(int)) self.handshake_counts = <int*>malloc(2 * self.n_colors * sizeof(int))
self.numeric_sums = <int*>malloc(2 * self.n_colors * sizeof(int)) self.numeric_sums = <int*>malloc(2 * self.n_colors * sizeof(int))
self.expedition_scores = <int*>malloc(2 * self.n_colors * sizeof(int)) self.expedition_scores = <int*>malloc(2 * self.n_colors * sizeof(int))
self.undo_stack_capacity = 2 * self.total_cards + 16
self.undo_stack = <UndoRecord*>malloc(
self.undo_stack_capacity * sizeof(UndoRecord)
)
if ( if (
self.deck == NULL self.deck == NULL
or self.hands == NULL or self.hands == NULL
@@ -95,6 +102,7 @@ cdef class FastGameState:
or self.handshake_counts == NULL or self.handshake_counts == NULL
or self.numeric_sums == NULL or self.numeric_sums == NULL
or self.expedition_scores == NULL or self.expedition_scores == NULL
or self.undo_stack == NULL
): ):
raise MemoryError() raise MemoryError()
self._clear() self._clear()
@@ -114,6 +122,7 @@ cdef class FastGameState:
self.discard_lens[i] = 0 self.discard_lens[i] = 0
self.total_scores[0] = 0 self.total_scores[0] = 0
self.total_scores[1] = 0 self.total_scores[1] = 0
self.undo_stack_len = 0
self.current_player = 0 self.current_player = 0
self.phase_id = _phase_card() self.phase_id = _phase_card()
self.pending_discarded_color = -1 self.pending_discarded_color = -1
@@ -469,6 +478,24 @@ cdef class FastGameState:
self._tuple_to_undo(undo, &record) self._tuple_to_undo(undo, &record)
self._undo_action_c(&record) self._undo_action_c(&record)
cpdef int push_action(self, int action_id):
if self.terminal:
raise IllegalMoveError("game is already terminal")
if not self._is_legal_action_c(action_id):
raise IllegalMoveError(
f"illegal action {action_id} in phase {self.phase} "
f"for player {self.current_player}"
)
return self._push_action_c(action_id)
cpdef int push_unified_action(self, int action_id):
return self.push_action(self.from_unified_action(action_id))
cpdef int pop_action(self):
if self.undo_stack_len <= 0:
raise ValueError("undo stack is empty")
return self._pop_action_c()
cpdef bint can_play_encoded_card(self, int player, int card): cpdef bint can_play_encoded_card(self, int player, int card):
cdef int color = self._card_color(card) cdef int color = self._card_color(card)
cdef int rank = self._card_rank(card) cdef int rank = self._card_rank(card)
@@ -698,6 +725,37 @@ cdef class FastGameState:
else: else:
self._apply_draw_action(action_id) self._apply_draw_action(action_id)
cdef void _ensure_undo_capacity_c(self) except *:
cdef int new_capacity
cdef UndoRecord* grown
if self.undo_stack_len < self.undo_stack_capacity:
return
new_capacity = self.undo_stack_capacity * 2
grown = <UndoRecord*>realloc(
self.undo_stack,
new_capacity * sizeof(UndoRecord),
)
if grown == NULL:
raise MemoryError()
self.undo_stack = grown
self.undo_stack_capacity = new_capacity
cdef int _push_action_c(self, int action_id) except *:
self._ensure_undo_capacity_c()
self._apply_action_with_undo_c(
action_id,
&self.undo_stack[self.undo_stack_len],
)
self.undo_stack_len += 1
return self.undo_stack_len
cdef int _pop_action_c(self) except *:
cdef int action_id
self.undo_stack_len -= 1
action_id = self.undo_stack[self.undo_stack_len].action_id
self._undo_action_c(&self.undo_stack[self.undo_stack_len])
return action_id
cdef object _undo_to_tuple(self, UndoRecord* undo): cdef object _undo_to_tuple(self, UndoRecord* undo):
return ( return (
"card" if undo.phase_id == _phase_card() else "draw", "card" if undo.phase_id == _phase_card() else "draw",
@@ -162,3 +162,36 @@ def test_fast_apply_undo_restores_every_legal_action() -> None:
state.apply_unified_action(rng.choice(legal)) state.apply_unified_action(rng.choice(legal))
steps += 1 steps += 1
assert steps < 1000 assert steps < 1000
def test_fast_push_pop_action_restores_nested_sequence() -> None:
config = LostCitiesConfig(
n_colors=3,
n_ranks=5,
min_rank=2,
n_handshakes=1,
hand_size=5,
)
for seed in range(32):
state = FastGameState.new_game(config, seed=seed)
rng = random.Random(seed ^ 0x517ACC)
before = state.to_snapshot()
actions: list[int] = []
for depth in range(20):
if state.terminal:
break
legal = state.unified_legal_actions()
action = rng.choice(legal)
actions.append(action)
assert state.push_unified_action(action) == depth + 1
state.validate_invariants()
for action in reversed(actions):
assert state.pop_action() == state.from_unified_action(action)
state.validate_invariants()
assert state.to_snapshot() == before
with pytest.raises(ValueError, match="undo stack is empty"):
FastGameState.new_game(config, seed=1).pop_action()