Add final cycle report and human play CLI
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import jax
|
||||
import jax.numpy as jnp
|
||||
import numpy as np
|
||||
|
||||
from lost_cities_jax.engine import board_score, reset_from_order, step
|
||||
from lost_cities_jax.human_play import (
|
||||
append_human_log,
|
||||
evaluate_agent_policy,
|
||||
human_deck_order,
|
||||
parse_human_action,
|
||||
play_one_game,
|
||||
prompt_human_action,
|
||||
render_public_state,
|
||||
session_human_seats,
|
||||
summarize_logs,
|
||||
)
|
||||
from lost_cities_jax.opponents import discard_only_action
|
||||
from lost_cities_jax.ppo import (
|
||||
ActorCritic,
|
||||
JaxPPOConfig,
|
||||
NetworkConfig,
|
||||
OpponentConfig,
|
||||
PPOHyperConfig,
|
||||
RunConfig,
|
||||
create_train_state,
|
||||
)
|
||||
|
||||
|
||||
def tiny_cfg(tmp_path) -> JaxPPOConfig:
|
||||
return JaxPPOConfig(
|
||||
run=RunConfig(experiment_name="human-play-test", artifact_root=str(tmp_path)),
|
||||
opponent=OpponentConfig(name="discard_only"),
|
||||
network=NetworkConfig(hidden_size=32, num_layers=1),
|
||||
ppo=PPOHyperConfig(batch_games=4, rollout_steps=16, epochs=1, minibatches=2),
|
||||
)
|
||||
|
||||
|
||||
def test_illegal_input_reprompts_with_reason() -> None:
|
||||
state = reset_from_order(jnp.asarray(list(range(60)), dtype=jnp.int8))
|
||||
inputs = iter(["play R2 draw R", "play R2 draw deck"])
|
||||
outputs: list[str] = []
|
||||
|
||||
action = prompt_human_action(
|
||||
state,
|
||||
0,
|
||||
input_fn=lambda _prompt: next(inputs),
|
||||
output_fn=outputs.append,
|
||||
)
|
||||
|
||||
assert action == parse_human_action("play R2 draw deck", state, 0)
|
||||
assert any("Illegal move" in line for line in outputs)
|
||||
assert any("discard pile is empty" in line for line in outputs)
|
||||
|
||||
|
||||
def test_public_renderer_does_not_show_opponent_hand_or_deck_order() -> None:
|
||||
p0 = [0, 3, 4, 5, 6, 7, 8, 9]
|
||||
p1 = [12, 15, 16, 17, 18, 19, 20, 21]
|
||||
rest = [card for card in range(60) if card not in {*p0, *p1}]
|
||||
state = reset_from_order(jnp.asarray(p0 + p1 + rest, dtype=jnp.int8))
|
||||
|
||||
rendered = render_public_state(state, 0)
|
||||
|
||||
assert "R2" in rendered
|
||||
assert "G2" not in rendered
|
||||
assert "G6" not in rendered
|
||||
assert str(p0 + p1 + rest) not in rendered
|
||||
|
||||
|
||||
def test_duplicate_session_swaps_seats() -> None:
|
||||
assert session_human_seats(0, duplicate=True) == [0, 1]
|
||||
assert session_human_seats(1, duplicate=True) == [1, 0]
|
||||
assert session_human_seats(1, duplicate=False) == [1]
|
||||
|
||||
|
||||
def test_human_play_summary_groups_duplicate_sets(tmp_path) -> None:
|
||||
base = {
|
||||
"schema": "lost-cities-jax-human-play-v1",
|
||||
"session_id": "s",
|
||||
"duplicate_set_id": "set-a",
|
||||
"moves": [
|
||||
{"ply": 0, "ai_value": 0.1, "action_text": "play R2 draw deck"},
|
||||
{"ply": 1, "ai_value": 0.6, "action_text": "discard G2 draw deck"},
|
||||
],
|
||||
"ai_opened_colors": 3,
|
||||
"game_length": 44,
|
||||
}
|
||||
append_human_log(tmp_path, {**base, "duplicate_game_index": 1, "human_score_diff": 10})
|
||||
append_human_log(tmp_path, {**base, "duplicate_game_index": 2, "human_score_diff": -4})
|
||||
|
||||
summary = summarize_logs(tmp_path)
|
||||
|
||||
assert summary["games"] == 2
|
||||
assert summary["duplicate_sets"] == 1
|
||||
assert summary["human_duplicate_set_diff_mean"] == 6.0
|
||||
assert summary["ai_opened_colors_mean"] == 3.0
|
||||
assert summary["value_swings_top10"][0]["abs_delta"] == 0.5
|
||||
|
||||
|
||||
def test_human_play_bot_path_matches_direct_engine_for_ten_games(tmp_path) -> None:
|
||||
cfg = tiny_cfg(tmp_path)
|
||||
train_state = create_train_state(cfg, jax.random.PRNGKey(7))
|
||||
agent = (train_state.params, ActorCritic(cfg.network.hidden_size, cfg.network.num_layers))
|
||||
for index in range(10):
|
||||
deck_order = human_deck_order(991, index)
|
||||
record = play_one_game(
|
||||
cfg=cfg,
|
||||
agent=agent,
|
||||
deck_order=deck_order,
|
||||
human_seat=index % 2,
|
||||
session_id=f"test-{index}",
|
||||
duplicate_set_id=None,
|
||||
duplicate_game_index=None,
|
||||
deck_seed=991,
|
||||
deck_index=index,
|
||||
input_fn=lambda _prompt: "",
|
||||
output_fn=lambda _line: None,
|
||||
human_action_fn=_discard_only_human,
|
||||
)
|
||||
direct_scores, direct_actions = _direct_game(cfg, agent, deck_order, index % 2)
|
||||
assert record["actions"] == direct_actions
|
||||
assert record["final_scores"]["p0"] == float(direct_scores[0])
|
||||
assert record["final_scores"]["p1"] == float(direct_scores[1])
|
||||
|
||||
|
||||
def _discard_only_human(state, player: int) -> int:
|
||||
return int(
|
||||
discard_only_action(state, jnp.asarray(player, dtype=jnp.int32), jax.random.PRNGKey(0))
|
||||
)
|
||||
|
||||
|
||||
def _direct_game(
|
||||
cfg, agent, deck_order: list[int], human_seat: int
|
||||
) -> tuple[np.ndarray, list[int]]:
|
||||
params, model = agent
|
||||
state = reset_from_order(jnp.asarray(deck_order, dtype=jnp.int8))
|
||||
actions = []
|
||||
while not bool(state.done):
|
||||
player = int(state.to_move)
|
||||
if player == human_seat:
|
||||
action = _discard_only_human(state, player)
|
||||
else:
|
||||
action = evaluate_agent_policy(cfg, params, model, state, player).action
|
||||
actions.append(action)
|
||||
state, _, _ = step(state, jnp.asarray(action, dtype=jnp.int32))
|
||||
return np.asarray(board_score(state), dtype=np.float32), actions
|
||||
Reference in New Issue
Block a user