고속 엔진 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:
1. Add an internal undo stack with `push_action()` / `pop_action()` so Python
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
1. Consider direct NumPy or feature-buffer output for RL pipelines instead of
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.
@@ -43,6 +43,9 @@ cdef class FastGameState:
cdef int* numeric_sums
cdef int* expedition_scores
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 int phase_id
@@ -66,6 +69,9 @@ cdef class FastGameState:
cpdef object apply_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 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 int last_numeric_rank(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 _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 _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 void _tuple_to_undo(self, object data, UndoRecord* undo) except *
cdef void _apply_card_action(self, int action_id) except *
@@ -5,7 +5,7 @@ from collections import Counter
import random
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
@@ -30,6 +30,7 @@ cdef class FastGameState:
self.handshake_counts = NULL
self.numeric_sums = NULL
self.expedition_scores = NULL
self.undo_stack = NULL
def __init__(self, config=None):
config = config or LostCitiesConfig()
@@ -57,6 +58,8 @@ cdef class FastGameState:
free(self.numeric_sums)
if self.expedition_scores != NULL:
free(self.expedition_scores)
if self.undo_stack != NULL:
free(self.undo_stack)
cdef void _configure(self, object config) except *:
self.config = config
@@ -84,6 +87,10 @@ cdef class FastGameState:
self.handshake_counts = <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.undo_stack_capacity = 2 * self.total_cards + 16
self.undo_stack = <UndoRecord*>malloc(
self.undo_stack_capacity * sizeof(UndoRecord)
)
if (
self.deck == NULL
or self.hands == NULL
@@ -95,6 +102,7 @@ cdef class FastGameState:
or self.handshake_counts == NULL
or self.numeric_sums == NULL
or self.expedition_scores == NULL
or self.undo_stack == NULL
):
raise MemoryError()
self._clear()
@@ -114,6 +122,7 @@ cdef class FastGameState:
self.discard_lens[i] = 0
self.total_scores[0] = 0
self.total_scores[1] = 0
self.undo_stack_len = 0
self.current_player = 0
self.phase_id = _phase_card()
self.pending_discarded_color = -1
@@ -469,6 +478,24 @@ cdef class FastGameState:
self._tuple_to_undo(undo, &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):
cdef int color = self._card_color(card)
cdef int rank = self._card_rank(card)
@@ -698,6 +725,37 @@ cdef class FastGameState:
else:
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):
return (
"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))
steps += 1
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()