# 7. AI player (`wizard_ai`) The AI package contains everything around a reinforcement-learning player: the agent interface, a feature encoding, a pluggable policy (model) interface, a training environment with a self-play loop, an evaluation tool, and the production websocket service. **The actual learning model is not implemented yet**: `UniformPolicy` is a random placeholder, and until a trained checkpoint exists production uses the rule-based `HeuristicAgent`. ## Package layout ``` wizard_ai/wizard_ai/ ├── __main__.py # CLI: serve | train | evaluate ├── config.py # env vars: SERVER_URL, AI_SHARED_SECRET, AGENT, MODEL_PATH, LOG_LEVEL ├── agents/ │ ├── base.py # Agent interface │ ├── random_agent.py # RandomAgent │ ├── heuristic_agent.py # HeuristicAgent (baseline + production fallback) │ ├── model_agent.py # ModelAgent: Agent backed by a Policy │ └── __init__.py # make_agent(name, model_path) ├── model/ │ ├── encoding.py # encode_observation(), FEATURE_SIZE, ActionSpace │ └── policy.py # Policy interface, Transition, checkpoints, UniformPolicy ├── training/ │ ├── env.py # WizardEnv (multi-agent environment) │ ├── self_play.py # TrainingConfig, SelfPlayTrainer │ └── evaluate.py # evaluate(agents, games) └── service/client.py # AIService: production websocket client ``` ## How the pieces fit together ```mermaid flowchart TB subgraph production SRV[game server] -- "decide (observation JSON)" --> SVC[AIService] SVC -- "Observation.from_dict" --> AG["Agent.act"] AG -- Action --> SVC -- "action JSON" --> SRV end subgraph training ENV["WizardEnv
wraps WizardGame"] -- Observation --> MA[ModelAgent] MA -- "encode_observation + ActionSpace.mask" --> POL["Policy.select"] POL -- "action index" --> MA MA -- Action --> ENV MA -- "records Transition" --> TR[SelfPlayTrainer] ENV -- rewards --> TR TR -- trajectories --> UPD["Policy.update"] TR -- save --> CK[("models/latest/")] end CK -- load_policy --> AG ``` The key idea: **an `Agent` maps an `Observation` to an `Action`**, and the observation is identical in training (straight from `WizardGame`) and in production (JSON from the server, decoded with `Observation.from_dict`). A trained agent can be deployed without any change. ## Agents (`agents/`) ```python class Agent(ABC): name: str def act(self, observation: Observation) -> Action: ... # must return one of observation.legal_actions def reset(self) -> None: ... # optional, called before each game in evaluation ``` | Agent | Behaviour | |-------|-----------| | `RandomAgent` | uniformly random legal action | | `HeuristicAgent` | **Bidding:** estimates expected tricks (wizard ≈ 0.95, trump ≈ 0.35 + 0.05·rank, high side cards 12–13 count less with more opponents), bids the closest legal value. **Playing:** if it still needs tricks, it wins with the cheapest winning card when last to play, otherwise with the strongest (and plays its weakest card if it cannot win); if it needs no more tricks, it throws the strongest card that still loses, else its weakest card. | | `ModelAgent(policy, explore=False, recorder=None)` | encodes the observation, asks the policy for an action index, converts it back to an `Action`; optionally records every decision (used by the trainer) | `make_agent(name, model_path)`: `"random"`, `"heuristic"` or `"model"`. For `"model"` it loads the checkpoint at `model_path` if it exists, otherwise it logs a warning and returns a `HeuristicAgent`. Baseline strength (100 games, `evaluate`): heuristic vs. three random agents → heuristic ≈ +220 average score, 100 % win rate; random agents ≈ −350. ## Encoding (`model/encoding.py`) ### Features — `encode_observation(obs) -> list[float]` (length `FEATURE_SIZE = 312`) Plain Python floats (convert to numpy/torch in the model). Opponents are ordered relative to the observer (next player first) and zero-padded to 5, so **one model works for 3–6 players**. | Index | Size | Feature | |-------|------|---------| | 0–3 | 4 | number of players one-hot (3, 4, 5, 6) | | 4–5 | 2 | phase one-hot (bidding, playing) | | 6 | 1 | round number / 20 | | 7 | 1 | round number / total rounds (game progress) | | 8 | 1 | hidden bids option | | 9–14 | 6 | own seat relative to the dealer, one-hot | | 15–20 | 6 | own position in the current trick (0 = leading), one-hot | | 21–80 | 60 | own hand (card id one-hot) | | 81–140 | 60 | all cards played this round | | 141–200 | 60 | cards in the current trick | | 201–205 | 5 | trump suit one-hot (4 suits + none) | | 206–210 | 5 | lead suit one-hot (4 suits + none) | | 211–270 | 60 | trump card one-hot (none = all zero) | | 271–275 | 5 | own: has bid, bid/20, tricks/20, (bid − tricks)/20, score/100 | | 276–310 | 35 | 5 opponent slots × 7: present, has bid, bid visible, bid/20, tricks/20, (their score − my score)/100, already played in this trick | | 311 | 1 | (sum of known bids − round number)/20 | If you change the encoding, `FEATURE_SIZE` must be updated (a test checks the length for all player counts) and existing checkpoints become incompatible. ### Actions — `ActionSpace` (size 81) | Index | Action | |-------|--------| | 0–20 | `Bid(0)` … `Bid(20)` | | 21–80 | `PlayCard(card id 0…59)` | `ActionSpace.index(action)`, `ActionSpace.action(index)` and `ActionSpace.mask(observation)` (list of 81 booleans, `True` = legal). A model should give illegal actions zero probability using the mask. ## Policy interface (`model/policy.py`) — where the model goes ```python @dataclass class Transition: features: list[float] # encode_observation(obs) mask: list[bool] # ActionSpace.mask(obs) action: int # chosen action index reward: float = 0.0 # scaled score change, attached to the player's last decision of a round done: bool = False # True for the player's last decision of the game class Policy(ABC): type_name: ClassVar[str] # key in the checkpoint metadata def select(self, features, mask, *, explore=False) -> int: ... def update(self, episodes: list[list[Transition]]) -> dict[str, float]: ... def config(self) -> dict: ... # constructor kwargs stored in the checkpoint def save_weights(self, directory: Path): ... # write parameters @classmethod def load(cls, directory: Path, config: dict) -> Policy: ... ``` Checkpoints are **directories**: `policy.save(dir)` writes `dir/policy.json` = `{"type": type_name, "config": {...}}` and calls `save_weights(dir)`. `load_policy(dir)` reads the type, finds the class registered with `@register_policy`, and calls its `load`. ### Implementing the real model — step by step 1. Add `torch` (or another library) to `wizard_ai/requirements.txt`. 2. Create e.g. `wizard_ai/wizard_ai/model/ppo_policy.py`: ```python @register_policy class PPOPolicy(Policy): type_name = "ppo" def __init__(self, hidden: int = 256, lr: float = 3e-4): self.hidden, self.lr = hidden, lr self.net = ... # FEATURE_SIZE -> hidden -> (ActionSpace.SIZE logits, value) def select(self, features, mask, *, explore=False): logits = self.net(torch.tensor(features))[0] logits[~torch.tensor(mask)] = -inf # only legal actions dist = Categorical(logits=logits) return int(dist.sample() if explore else logits.argmax()) def update(self, episodes): # compute returns/advantages from Transition.reward/done, run PPO epochs return {"loss": ..., "mean_episode_reward": ...} def config(self): return {"hidden": self.hidden, "lr": self.lr} def save_weights(self, directory): torch.save(self.net.state_dict(), directory / "weights.pt") @classmethod def load(cls, directory, config): policy = cls(**config) policy.net.load_state_dict(torch.load(directory / "weights.pt")) return policy ``` For PPO you will likely want to store log-probabilities and value estimates per decision; extend `Transition` (or keep them inside the policy keyed by decision order) as needed. 3. Import the module in `model/__init__.py` so `@register_policy` runs. 4. In `__main__.py` (`train` command) replace `UniformPolicy(args.seed)` with your policy (the line is marked `TODO`). 5. Train: `python -m wizard_ai train --episodes 50000 --out models/latest`. 6. Compare: `python -m wizard_ai evaluate --agents model,heuristic,heuristic,heuristic`. 7. Deploy: the checkpoint directory `wizard_ai/models/latest/` is mounted into the container (`compose.yaml`); restart `wizard_ai`. With `AGENT=model` (default) it is loaded automatically. Model size matters: the service answers every AI move in a worker thread, the container is capped at `AI_CPUS` (1 CPU by default) and the server waits at most `AI_DECISION_TIMEOUT` (5 s). If the model is too slow the service automatically answers with the heuristic instead (see below), so measure the decision time before deploying. ## Training (`training/`) ### `WizardEnv` A turn-based multi-agent environment around `WizardGame`: ```python env = WizardEnv(num_players=(3, 4, 5, 6), options=GameOptions(), seed=1) obs = env.reset() # observation of the player to act; samples a player count while obs is not None: result = env.step(action) # action for obs.player result.rewards # per seat: score change / 100 when a round ended, else 0 result.done, result.events obs = result.observation # next player to act (None when the game is over) ``` The sum of all rewards of a seat over a game equals its final score / 100 (tested). ### `SelfPlayTrainer` `TrainingConfig` fields: `episodes`, `player_counts`, `options`, `opponents` (`"self"` = every seat is the learning policy, `"heuristic"` / `"random"` = one learning seat against those), `episodes_per_update`, `checkpoint_dir`, `checkpoint_every`, `log_every`, `seed`. Per episode (= one full game) it: 1. resets the env (random player count), 2. lets a `ModelAgent(policy, explore=True, recorder=…)` play the learning seats, recording one `Transition` per decision, 3. adds each round's reward to that seat's most recent transition, and marks each seat's last transition `done`, 4. collects the trajectories (one list per learning seat and game). Every `episodes_per_update` episodes it calls `policy.update(batch)`, logs the returned metrics, and saves a checkpoint every `checkpoint_every` episodes and at the end. ### `evaluate(agents, games, options=None, seed=None)` Plays `games` games with `len(agents)` players, rotating seats so every agent starts in every position, and returns mean score and win rate per agent (ties count as a win for everyone tied). Seeded for fair comparisons. ## Production service (`service/client.py`) `AIService(server_url, agent, secret, fallback=…, workers=…, max_queue=…, degrade_queue=…).run_forever()`: - connects to `SERVER_URL` with `Authorization: Bearer `, sends `hello`, then answers `decide` requests (protocol in [chapter 5](05-protocol.md#ai-protocol)); - each request runs `agent.act` in a worker thread (`asyncio.to_thread`), so a slow model does not block the websocket; - **bounded work** (`handle_request`): - at most `AI_WORKERS` (default 1) decisions are computed at the same time — agents must be thread safe if you raise it; - at most `AI_MAX_QUEUE` (64) requests may be in progress; more are answered with `error` "overloaded" immediately; - a request whose server `timeout` passed while it waited for a worker is answered with `error` "expired" without computing it; - **degradation**: when `AI_DEGRADE_QUEUE` (8) or more other requests are waiting, or the primary agent's average decision time is more than half of the remaining time, the cheap fallback agent (`HeuristicAgent`) decides instead. `serve` sets the heuristic as fallback automatically unless the primary agent already is the heuristic. The reply's `agent` field says which one decided; - `OMP_NUM_THREADS` / `MKL_NUM_THREADS` are set to `AI_WORKERS` in `compose.yaml`, so numeric libraries don't start a thread per host core; - errors while deciding are reported back as `error` (the server then plays a random move) instead of crashing; - reconnects forever with exponential backoff (1 s … 30 s); logs a hint if the server rejects the secret. ## Command line ```bash cd wizard_ai python -m wizard_ai serve [--server-url …] [--agent model|heuristic|random] [--model-path …] python -m wizard_ai train --episodes 5000 [--players 3,4,5,6] [--opponents self|heuristic|random] [--hidden-bids] [--out models/latest] [--resume] [--seed N] python -m wizard_ai evaluate --agents model,heuristic,random,random --games 200 [--seed N] ``` Environment variables (`config.py`): `SERVER_URL` (default `ws://localhost:8000/internal/ai`; in Docker `ws://wizard_server:8083/internal/ai`), `AI_SHARED_SECRET`, `AGENT` (default `model`), `MODEL_PATH` (default `models/latest`, in Docker `/srv/models/latest`), `LOG_LEVEL`, `AI_WORKERS` (1), `AI_MAX_QUEUE` (64), `AI_DEGRADE_QUEUE` (8). ## Tests (`wizard_ai/tests/test_ai.py`) Feature length and masks for 3–6 players, action-space round trip, all agents play legal full games, env rewards add up to scores, trainer runs and writes a loadable checkpoint, `make_agent` fallback, heuristic beats random, and the service limits (queue full → overloaded, expired requests, degradation to the heuristic when busy or when the model is too slow).