Compare commits
10
Commits
598e53ca06
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b60b29d858 | ||
|
|
da88ec3bf4 | ||
|
|
4ac2411fbb | ||
|
|
aa3f263f33 | ||
|
|
efde08eb56 | ||
|
|
11160a6591 | ||
|
|
6c5a95f3b4 | ||
|
|
d05c9fb6a0 | ||
|
|
4f4e3396f4 | ||
|
|
8165c478dc |
@@ -0,0 +1,64 @@
|
|||||||
|
Nash Equilibrium Solver & Simulator - Extended Usage Guide
|
||||||
|
==========================================================
|
||||||
|
|
||||||
|
This tool is a comprehensive command-line engine for solving, inspecting, and simulating normal-form (simultaneous-move) games like the Prisoner's Dilemma, Matching Pennies, or completely custom scenarios.
|
||||||
|
|
||||||
|
0. Environment Setup
|
||||||
|
--------------------
|
||||||
|
Always run the tool using the local virtual environment to ensure dependencies (`numpy`, `matplotlib`, `anthropic`, etc.) are resolved correctly:
|
||||||
|
|
||||||
|
.venv/bin/python main.py [ARGS]
|
||||||
|
|
||||||
|
1. Core Pipeline
|
||||||
|
----------------
|
||||||
|
- Data-Driven Games: Games are defined purely in JSON (players, strategies, payoff tensors). The underlying math generalizes to any number of players (N-player) automatically.
|
||||||
|
- Theoretical Solvers:
|
||||||
|
* Pure Nash Equilibrium: Brute-force evaluates every profile to find states where no player can profitably deviate.
|
||||||
|
* Mixed Nash Equilibrium: Uses support enumeration to find probabilistic equilibria (2-player non-degenerate games only).
|
||||||
|
|
||||||
|
2. Advanced Features & Dynamics
|
||||||
|
-------------------------------
|
||||||
|
- Matrix Reduction (IESDS): Use `--reduce`. It iteratively finds and deletes strictly dominated strategies (moves that are mathematically always worse than another option). This shrinks the game matrix to its "Rationalizable Core", drastically speeding up complex simulations and stripping away noise.
|
||||||
|
- Inspection Mode: Use `--inspect` to bypass simulations. It prints a beautifully formatted, color-coded payoff matrix and automatically scans to identify if players have a dominant strategy.
|
||||||
|
- Evolutionary Simulation: Use `--sim-type evolutionary`. Instead of modeling discrete individual choices, this tracks an infinite population using Replicator Dynamics. Strategies that score above the population average grow in frequency, creating smooth evolutionary trajectories plotted to a PNG.
|
||||||
|
- Pluggable Agent Models: Use `--player <type>` to change how discrete agents play:
|
||||||
|
* `best_response` (default): Chooses the mathematically optimal move against the opponents' historical distribution (fictitious play).
|
||||||
|
* `q_learning`: Decentralized reinforcement learning agents that explore and exploit expected rewards over time.
|
||||||
|
* `regret_matching`: Hart & Mas-Colell algorithm where agents play proportionally to their cumulative regret (converges to Coarse Correlated Equilibrium).
|
||||||
|
* `softmax`: Probabilistic best response using Boltzmann exploration (adds "noise" or temperature to choices).
|
||||||
|
* `llm`: Queries Claude (Anthropic API) dynamically to reason about the game history and pick the next move.
|
||||||
|
- Verbose Debugging: Add `--verbose` to any simulation to output the internal reasoning of agents (e.g., Q-values, expected payoffs, or LLM internal thoughts) round-by-round.
|
||||||
|
|
||||||
|
3. Example Commands
|
||||||
|
-------------------
|
||||||
|
# Inspect a game's properties and dominant strategies (no simulation)
|
||||||
|
.venv/bin/python main.py --game games/pricing.json --inspect
|
||||||
|
|
||||||
|
# Shrink a game using IESDS, then inspect the reduced core
|
||||||
|
.venv/bin/python main.py --game games/pricing.json --reduce --inspect
|
||||||
|
|
||||||
|
# Standard Fictitious Play simulation for 30 rounds
|
||||||
|
.venv/bin/python main.py --game games/pricing.json --rounds 30
|
||||||
|
|
||||||
|
# Simulate Q-learning agents with verbose reasoning logs
|
||||||
|
.venv/bin/python main.py --game games/rock_paper_scissors.json --player q_learning --rounds 100 --verbose
|
||||||
|
|
||||||
|
# Run Evolutionary Replicator Dynamics and output a population chart
|
||||||
|
.venv/bin/python main.py --game games/matching_pennies.json --sim-type evolutionary --rounds 50
|
||||||
|
|
||||||
|
# Play using LLM agents (requires `export ANTHROPIC_API_KEY="sk-..."`)
|
||||||
|
.venv/bin/python main.py --game games/pricing.json --player llm --rounds 5 --verbose
|
||||||
|
|
||||||
|
4. Creating Your Own Games
|
||||||
|
--------------------------
|
||||||
|
Just create a new JSON file in `games/`. The engine handles the rest natively.
|
||||||
|
Example shape:
|
||||||
|
{
|
||||||
|
"players": ["P1", "P2"],
|
||||||
|
"strategies": [["Cooperate", "Defect"], ["Cooperate", "Defect"]],
|
||||||
|
"payoffs": [
|
||||||
|
[[3, 0], [5, 1]], # P1's payoffs (rows=P1, cols=P2)
|
||||||
|
[[3, 5], [0, 1]] # P2's payoffs
|
||||||
|
]
|
||||||
|
}
|
||||||
|
No code changes are needed for 3+ players; just add another dimension to the payoff arrays to match the strategy counts!
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
> Onboarding and operating guide for AI assistants working in this repository. Everything here is derived from the actual source (`nash/`, `main.py`, `tests/`, `games/`) and is intended to be authoritative. When the code and this document disagree, trust the code and update this file.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
This is a **small, dependency-light Python 3.13 command-line tool** that **solves and simulates Nash equilibria for normal-form (simultaneous-move) games**.
|
||||||
|
|
||||||
|
- **Input:** a game is *data*, not code — a JSON file describing players, their strategies, and a payoff tensor per player (`games/*.json`).
|
||||||
|
- **Solve:** find all pure-strategy Nash equilibria (brute force, any number of players) and, for 2-player games, mixed-strategy equilibria (support enumeration).
|
||||||
|
- **Simulate:** run dynamic simulations for N rounds to observe convergence. Supports discrete agent models (best response/fictitious play, regret matching, softmax, Q-learning, or LLM decisions) and population-level evolutionary dynamics (replicator dynamics).
|
||||||
|
- **Inspect & Reduce:** inspect game structures, dominant strategies, and iteratively eliminate strictly dominated strategies (IESDS) to find the rationalizable core.
|
||||||
|
- **Report:** print per-round play, cross-check convergence against the true equilibria, and chart long-run strategy usage or population trajectories to a PNG.
|
||||||
|
|
||||||
|
The design goal is a reusable library (`nash/`) plus a thin CLI (`main.py`). The optional AI translation layer (`nash/translate.py`) is the only networked component and is fully decoupled from solving/simulation.
|
||||||
|
|
||||||
|
**What it is NOT:** there is no web server, no database, no build step, no package publishing, no async, and no framework. It is plain scripts executed through a local virtualenv.
|
||||||
|
|
||||||
|
## Architecture & Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
main.py (argparse CLI + orchestration)
|
||||||
|
│
|
||||||
|
┌───────────────────────────┴───────────────────────────┐
|
||||||
|
│ load_game(args) │
|
||||||
|
│ --game <path> → Game.load_from_json(path) │
|
||||||
|
│ --describe <text> → translate.describe_to_json(text)│ ← only network call
|
||||||
|
│ (validates by constructing Game)│
|
||||||
|
└───────────────────────────┬───────────────────────────┘
|
||||||
|
▼
|
||||||
|
Game (nash/game.py)
|
||||||
|
payoff tensors: one np.ndarray per player
|
||||||
|
│
|
||||||
|
┌───────────────────────────┴───────────────────────────┐
|
||||||
|
│ print_solution(game) │
|
||||||
|
│ solver.find_pure_nash_equilibria(game) [any N] │
|
||||||
|
│ solver.find_mixed_nash_equilibria(game) [2-player] │
|
||||||
|
└───────────────────────────┬───────────────────────────┘
|
||||||
|
▼
|
||||||
|
Simulation(game, rounds, memory, rng).run() (nash/simulation.py)
|
||||||
|
drives BestResponsePlayer per player (nash/players.py)
|
||||||
|
→ self.history, self.payoff_history
|
||||||
|
│
|
||||||
|
┌───────────────────────────┴───────────────────────────┐
|
||||||
|
│ print_comparison(game, simulation, pure_eq) │
|
||||||
|
│ converged_profile() cross-checked vs pure equilibria │
|
||||||
|
│ long-run strategy_frequencies() printed │
|
||||||
|
│ visualize.plot_strategy_frequencies(...) → PNG │
|
||||||
|
└───────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stage details
|
||||||
|
|
||||||
|
1. **Load & Reduce.** `Game.__init__` validates that `len(players) == len(strategies) == len(payoffs)`, converts payoffs to numpy arrays, and checks dimensions. If `--reduce` is passed, `solver.reduce_game(game)` iteratively deletes strictly dominated strategies, updating the payoff tensors and returning a reduced game + reduction logs.
|
||||||
|
2. **Inspect.** If `--inspect` is passed, the CLI prints the game details, payoffs (colored by polarity), and strictly dominant strategies (if any) via `solver.find_dominant_strategies`, then exits.
|
||||||
|
3. **Solve (theoretical).** Pure equilibria: brute-force search over all profiles. Mixed equilibria (2-player only): support enumeration via indifference linear systems.
|
||||||
|
4. **Simulate (dynamic / evolutionary).**
|
||||||
|
- **Agents mode:** runs `Simulation` where each player (selected via `--player`) implements a decision model (`choose(history)`):
|
||||||
|
- `best_response`: fictitious play (memory=on) or best response to previous round (memory=off).
|
||||||
|
- `regret_matching`: chooses strategies based on cumulative positive regrets.
|
||||||
|
- `softmax`: probabilistic best response weighted by temperature.
|
||||||
|
- `q_learning`: uses temporal-difference learning on action-values.
|
||||||
|
- `llm`: queries the Anthropic Claude API for reasoning and strategy index choice.
|
||||||
|
- **Evolutionary mode:** runs `EvolutionarySimulation` simulating replicator dynamics on a population strategy distribution over time.
|
||||||
|
5. **Report.** Agent simulations check for convergence windows and compare convergence to actual Nash equilibria. Evolutionary simulations show final population distributions. Frequencies/trajectories are plotted to a PNG.
|
||||||
|
### Core invariant
|
||||||
|
|
||||||
|
**Games are data, not code.** Adding a 3+ player game requires *no code changes* — only a JSON file whose payoff tensors have the matching shape. The tensor representation (`payoffs[i][profile]`) is what makes the pure solver and the simulation generalize to any N. Do not hardcode scenario details inside `nash/`.
|
||||||
|
|
||||||
|
## Key Directories
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `nash/` | The importable core library (the package). |
|
||||||
|
| `nash/game.py` | `Game` — load/validate games, payoff-tensor indexing, iteration, and utilities. |
|
||||||
|
| `nash/players.py` | `BasePlayer` & implementations (`BestResponsePlayer`, `SoftmaxBestResponsePlayer`, `RegretMatchingPlayer`, `QLearningPlayer`, `LLMPlayer`), plus the `make_player` factory. |
|
||||||
|
| `nash/simulation.py` | `Simulation` (agent-based) and `EvolutionarySimulation` (replicator dynamics). |
|
||||||
|
| `nash/solver.py` | Pure/mixed equilibrium solvers, dominant strategy finder (`find_dominant_strategies`), and game reduction (`reduce_game`). |
|
||||||
|
| `nash/translate.py` | `describe_to_json` — the **only** network/API call (anthropic SDK). Decoupled by design. |
|
||||||
|
| `nash/visualize.py` | Prints CLI outputs, plots agent frequencies (`plot_strategy_frequencies`), and plots evolutionary trajectories (`plot_evolutionary_trajectories`). |
|
||||||
|
| `nash/__init__.py` | Package marker. Imports/exports are performed explicitly in `main.py` (`from nash import solver, visualize`, etc.). |
|
||||||
|
| `main.py` | CLI entry point and full pipeline orchestration. |
|
||||||
|
| `games/` | Sample game JSON files. This is where new scenarios live. |
|
||||||
|
| `tests/` | `pytest` tests for the solver, players, logging, and simulations. |
|
||||||
|
| `.venv/` | Local virtualenv (system Python is externally managed). Ignored by git. |
|
||||||
|
| `requirements.txt` | Runtime + dev dependencies. |
|
||||||
|
| `CLAUDE.md` | Human-facing guidance from the original author; complements this file. |
|
||||||
|
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
### Environment setup
|
||||||
|
|
||||||
|
Dependencies are installed into a local venv because the system Python is externally managed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
.venv/bin/pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
`requirements.txt` currently lists: `numpy`, `matplotlib`, `pytest`, and `anthropic` (the last is **only** needed for `--describe`).
|
||||||
|
|
||||||
|
### Running the tool
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python main.py --game games/pricing.json --rounds 30 --memory on --seed 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Exactly one of `--game` / `--describe` is required (they are a mutually exclusive, required group). See **CLI Reference** below for all flags.
|
||||||
|
|
||||||
|
### Running tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -m pytest tests/ # all tests
|
||||||
|
.venv/bin/python -m pytest tests/test_solver.py::test_pricing_has_one_pure_equilibrium_low_low
|
||||||
|
```
|
||||||
|
|
||||||
|
There is **no build, lint, or format step** in this project. Do not introduce one unless explicitly requested.
|
||||||
|
|
||||||
|
## Code Conventions & Common Patterns
|
||||||
|
|
||||||
|
- **Naming & style:** PEP 8. `snake_case` for functions/attributes, `CamelCase` for classes. **Type hints are not used anywhere in the codebase** — match this bare style; do not sprinkle annotations into existing files inconsistently.
|
||||||
|
- **Output channel:** user-facing output uses `print` (in `main.py` and `nash/visualize.py`). There is no `logging` configured; do not introduce it without reason.
|
||||||
|
- **Error model:** library code raises standard exceptions and lets them propagate to the CLI. There is no custom exception hierarchy.
|
||||||
|
- `Game.__init__` → `ValueError` on length mismatch or payoff-shape mismatch.
|
||||||
|
- `find_mixed_nash_equilibria` → `NotImplementedError` when `num_players != 2` (intentional, see gotchas).
|
||||||
|
- `translate._extract_json` → `ValueError` when no JSON object is found in the model response.
|
||||||
|
- `numpy.linalg.LinAlgError` inside mixed solving is caught internally and converted to "no equilibrium on this support" (`None`).
|
||||||
|
- **RNG reproducibility (critical):** randomness is *injected*, never global. `main.py` creates exactly one `random.Random(args.seed)` and passes it into `Simulation(rng=...)`, which forwards the **same** instance to every `BestResponsePlayer`. Tie-breaking among equally-good best responses uses `rng.choice`, so a fixed `--seed` makes a run fully deterministic end to end. **Always thread `rng`/`random.Random` down the call chain; never call `random.*` module functions directly in library code.**
|
||||||
|
- **N-player support is a hard constraint:** the pure solver and the simulation must keep working for any N players. The mixed solver is **2-player only by deliberate design** (support enumeration; there is no generalized Lemke-Howson past 2 players). Do not "fix" the `NotImplementedError` by silently degrading or returning partial results.
|
||||||
|
- **Memory switch:** `BestResponsePlayer(memory=True)` (default) = fictitious play against the full history; `memory=False` = best response to only the prior round and can cycle forever (e.g. Rock-Paper-Scissors, Matching Pennies). Keep this semantics if you touch `choose`.
|
||||||
|
- **Tie tolerance:** `_best_response` uses `tol=1e-9` when collecting equally-optimal strategies; mixed solving uses `tol=1e-7`. Preserve these tolerances when modifying equilibrium math.
|
||||||
|
- **AI-layer decoupling:** `translate.describe_to_json` is reachable *only* through `main.py --describe`. Before returning, it constructs a `Game` from the parsed JSON, so a malformed translation fails visibly at parse time rather than producing a silently wrong simulation. It never imports `solver` or `simulation`.
|
||||||
|
- **Module structure:** `main.py` wires everything; `nash/` modules expose functions/classes and avoid side effects at import. `visualize.py` sets `matplotlib.use("Agg")` at import so it works headless.
|
||||||
|
|
||||||
|
## Important Files & Public API
|
||||||
|
|
||||||
|
### `nash/game.py` — `Game`
|
||||||
|
|
||||||
|
```python
|
||||||
|
Game(players: list[str], strategies: list[list[str]], payoffs: list) # raises ValueError on mismatch
|
||||||
|
Game.load_from_json(path: str) -> Game
|
||||||
|
game.players # list[str]
|
||||||
|
game.strategies # list[list[str]]
|
||||||
|
game.num_players # int
|
||||||
|
game.payoffs # list[np.ndarray]; payoffs[i].shape == tuple(strategy counts)
|
||||||
|
game.strategy_counts() -> list[int]
|
||||||
|
game.payoff(player_index: int, profile: tuple[int, ...]) -> float # payoffs[player_index][profile]
|
||||||
|
game.all_profiles() -> itertools.product # all strategy-index tuples
|
||||||
|
game.strategy_name(player_index: int, strategy_index: int) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
### `nash/players.py` — Players & Models
|
||||||
|
|
||||||
|
```python
|
||||||
|
BestResponsePlayer(game: Game, player_index: int, memory: bool = True, rng: random.Random | None = None)
|
||||||
|
SoftmaxBestResponsePlayer(game: Game, player_index: int, memory: bool = True, rng: random.Random | None = None, temperature: float = 0.1)
|
||||||
|
RegretMatchingPlayer(game: Game, player_index: int, memory: bool = True, rng: random.Random | None = None)
|
||||||
|
QLearningPlayer(game: Game, player_index: int, memory: bool = True, rng: random.Random | None = None, alpha: float = 0.1, gamma: float = 0.9, epsilon: float = 0.1)
|
||||||
|
LLMPlayer(game: Game, player_index: int, rng: random.Random | None = None, debug_logger: callable | None = None, model: str = "claude-3-5-sonnet-20240620")
|
||||||
|
|
||||||
|
# All players support the common interface:
|
||||||
|
player.choose(history: list[tuple[int, ...]]) -> int # Choose strategy index for this round
|
||||||
|
player.update(profile: tuple[int, ...], payoffs: tuple[float, ...]) # Update internal state (e.g. Q-table, regret) based on outcomes
|
||||||
|
```
|
||||||
|
|
||||||
|
### `nash/simulation.py` — `Simulation` & `EvolutionarySimulation`
|
||||||
|
|
||||||
|
```python
|
||||||
|
Simulation(game: Game, num_rounds: int, memory: bool = True,
|
||||||
|
rng: random.Random | None = None, convergence_window: int = 5,
|
||||||
|
player_kind: str = "best_response", debug_logger: callable | None = None)
|
||||||
|
sim.run() -> list[tuple[int, ...]] # populates .history and .payoff_history
|
||||||
|
sim.history # list[tuple[int, ...]]
|
||||||
|
sim.payoff_history # list[tuple[float, ...]]
|
||||||
|
sim.converged_round() -> int | None # first index of a window-long stable streak
|
||||||
|
sim.converged_profile() -> tuple[int, ...] | None
|
||||||
|
sim.strategy_frequencies() -> list[list[list[float]]] # [player][strategy][round] running frequency
|
||||||
|
|
||||||
|
EvolutionarySimulation(game: Game, num_rounds: int)
|
||||||
|
ev_sim.run() -> list[list[np.ndarray]] # runs replicator dynamics, populates .history
|
||||||
|
ev_sim.populations # list[np.ndarray] representing the population fraction of each strategy per player
|
||||||
|
ev_sim.history # list[list[np.ndarray]] of population trajectories
|
||||||
|
```
|
||||||
|
|
||||||
|
### `nash/solver.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
find_pure_nash_equilibria(game: Game) -> list[tuple[int, ...]] # brute force, any N
|
||||||
|
find_mixed_nash_equilibria(game: Game, tol: float = 1e-7) -> list[tuple[np.ndarray, np.ndarray]]
|
||||||
|
# raises NotImplementedError if game.num_players != 2
|
||||||
|
find_dominant_strategies(game: Game) -> list[int | None] # returns dominant strategy index (or None) for each player
|
||||||
|
reduce_game(game: Game) -> tuple[Game, list[str]] # iteratively eliminates strictly dominated strategies (IESDS), returns (reduced_game, logs)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `nash/translate.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
describe_to_json(description: str, model: str = "claude-sonnet-5") -> dict # calls anthropic
|
||||||
|
save_json(data: dict, path: str) -> None
|
||||||
|
```
|
||||||
|
`describe_to_json` returns the raw `dict` (the same shape `Game` expects). It is only invoked from `main.load_game` when `--describe` is set.
|
||||||
|
|
||||||
|
### `nash/visualize.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
print_round(round_num, game, profile, payoffs) -> None
|
||||||
|
print_history(game, simulation) -> None
|
||||||
|
plot_strategy_frequencies(game, simulation, save_path) -> str # matplotlib Agg, returns save_path
|
||||||
|
plot_evolutionary_trajectories(game, evo_sim, save_path) -> str # matplotlib Agg, returns save_path
|
||||||
|
```
|
||||||
|
|
||||||
|
### `main.py` (entry point)
|
||||||
|
|
||||||
|
```python
|
||||||
|
build_arg_parser() -> argparse.ArgumentParser
|
||||||
|
load_game(args) -> Game
|
||||||
|
print_solution(game) -> tuple[list, list] # (pure_eq, mixed_eq)
|
||||||
|
print_comparison(game, simulation, pure_eq) -> None
|
||||||
|
main() # __main__ entry
|
||||||
|
```
|
||||||
|
|
||||||
|
## Runtime / Tooling Preferences
|
||||||
|
|
||||||
|
- **Python:** 3.13 (from `.venv`). Run everything via `.venv/bin/python` / `.venv/bin/pip`. Never use the system interpreter (externally managed).
|
||||||
|
- **No Node/Bun, no Rust, no build system, no `pyproject.toml`/`setup.cfg`/`tox.ini`.** Plain scripts + venv.
|
||||||
|
- **Dependencies:** `numpy` (payoff math/tensors), `matplotlib` (charts, Agg backend), `pytest` (tests), `anthropic` (only for `--describe`).
|
||||||
|
- **`--describe` prerequisites:** `anthropic` installed **and** `ANTHROPIC_API_KEY` set in the environment. Default model is `"claude-sonnet-5"`.
|
||||||
|
- **Charts:** written headlessly to a PNG (default `strategy_usage.png`, override with `--out`). No interactive display.
|
||||||
|
|
||||||
|
## Testing & QA
|
||||||
|
|
||||||
|
- **Framework:** `pytest`, executed as `.venv/bin/python -m pytest tests/`. No `conftest.py`, no markers, no coverage config.
|
||||||
|
- **Conventions:**
|
||||||
|
- Tests are module-level `test_*` functions.
|
||||||
|
- They load **real JSON fixtures** via `Game.load_from_json("games/<name>.json")` (paths are relative to the repo root / CWD).
|
||||||
|
- Determinism is achieved by injecting `random.Random(<seed>)` into `Simulation(rng=...)` — **mirror this; never rely on global `random`** in tests.
|
||||||
|
- Assertions use exact tuple/profile equality for equilibria and `numpy.allclose(..., atol=...)` for mixed probabilities.
|
||||||
|
- **Current coverage:**
|
||||||
|
- `tests/test_solver.py`:
|
||||||
|
- pure NE brute force: 2-player PD (`pricing`) → `[(1,1)]`; zero-sum with none (`matching_pennies`) → `[]`; 3-player coordination → `(0,0,0)` present.
|
||||||
|
- mixed NE == pure (PD): `(0,1)`, `(0,1)` within `1e-6`.
|
||||||
|
- mixed NE is 50/50 (Matching Pennies) within `1e-6`.
|
||||||
|
- mixed solver rejects >2 players with `pytest.raises(NotImplementedError)`.
|
||||||
|
- `tests/test_simulation.py`:
|
||||||
|
- `pricing` converges to `(Low, Low)` under `memory=True` (seed 1, 10 rounds).
|
||||||
|
- `matching_pennies` long-run frequencies trend toward 0.5 for both players under `memory=True` (seed 7, 300 rounds, tolerance 0.15).
|
||||||
|
- **Gaps to cover when extending:**
|
||||||
|
- No tests for `memory=False` cycling behavior.
|
||||||
|
- No tests for tie-breaking among equally good best responses (depends on seeded `rng`).
|
||||||
|
- No tests for Rock-Paper-Scissors dynamics or its mixed result.
|
||||||
|
- No tests for `print_comparison`'s convergence cross-check / fallback messaging.
|
||||||
|
- `translate.describe_to_json` needs network + API key; do **not** hit it in CI. If you need coverage, test the internal `_extract_json` parser (pure, offline) instead.
|
||||||
|
- **Expectation:** when you change solver/simulation/player behavior, add or update a `test_*` that asserts the *observable contract* (equilibrium profiles, final frequencies within a tolerance, or the raised exception) — not a snapshot of current printed output.
|
||||||
|
|
||||||
|
## JSON Game Schema
|
||||||
|
|
||||||
|
Every game is a JSON object with three top-level keys. The shape of `payoffs` is the only part that must be computed carefully.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"players": ["Company A", "Company B"],
|
||||||
|
"strategies": [["High", "Low"], ["High", "Low"]],
|
||||||
|
"payoffs": [
|
||||||
|
[[8, 2], [10, 5]],
|
||||||
|
[[8, 10], [2, 5]]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rules
|
||||||
|
|
||||||
|
- `players`: ordered list of player-name strings. Its length is `num_players`.
|
||||||
|
- `strategies`: parallel list; `strategies[i]` is the ordered list of player `i`'s strategy names. Order defines the integer indices used everywhere else.
|
||||||
|
- `payoffs`: parallel list; `payoffs[i]` is player `i`'s payoff tensor. For N players it is nested lists with **one dimension per player**, each dimension sized to that player's strategy count. So `payoffs[i].shape == (len(strategies[0]), len(strategies[1]), ...)`.
|
||||||
|
- **Indexing convention (2-player):** `payoffs[i][a][b]` = payoff to player `i` when player 0 plays strategy index `a` and player 1 plays strategy index `b` (rows = player 0, columns = player 1).
|
||||||
|
- A profile is a `tuple` of strategy indices, one per player, used directly to index the tensor: `payoffs[i][profile]`.
|
||||||
|
- `Game.__init__` raises `ValueError` if the lengths don't align or any payoff tensor's shape is wrong — so validate by just constructing the `Game`.
|
||||||
|
|
||||||
|
### 3-player example (proves the "data, not code" invariant)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"players": ["A", "B", "C"],
|
||||||
|
"strategies": [["x", "y"], ["x", "y"], ["x", "y"]],
|
||||||
|
"payoffs": [
|
||||||
|
[[[1,0],[0,0]], [[0,0],[0,0]]],
|
||||||
|
[[[1,0],[0,0]], [[0,0],[0,0]]],
|
||||||
|
[[[1,0],[0,0]], [[0,0],[0,0]]]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Each payoff tensor is shape `(2,2,2)`; `payoffs[0][0][0][0]` is player A's payoff when all three play `x`. No code change is needed to support this — only the file.
|
||||||
|
|
||||||
|
## CLI Reference
|
||||||
|
|
||||||
|
| Flag | Type | Default | Notes |
|
||||||
|
|------|------|---------|-------|
|
||||||
|
| `--game <path>` | str | — | Path to a game JSON file. Mutually exclusive with `--describe`; one of the two is **required**. |
|
||||||
|
| `--describe <text>` | str | — | Plain-English scenario; translated to JSON via the anthropic API. Requires `anthropic` + `ANTHROPIC_API_KEY`. Mutually exclusive with `--game`. |
|
||||||
|
| `--rounds <int>` | int | `50` | Number of simulation rounds. |
|
||||||
|
| `--memory <on|off>` | choice | `on` | `on` = fictitious play (full history); `off` = best response to previous round only. |
|
||||||
|
| `--seed <int>` | int | `None` | Seeds the `random.Random` used for the whole run (reproducibility). `None` → non-deterministic. |
|
||||||
|
| `--out <path>` | str | `strategy_usage.png` | Where the strategy-usage chart PNG is saved. |
|
||||||
|
| `--save-game <path>` | str | `games/generated.json` | Output path for the AI-translated JSON; only used with `--describe`. |
|
||||||
|
| `--inspect` | flag | — | Print game details, payoff matrix, and dominant strategies, then exit. |
|
||||||
|
| `--reduce` | flag | — | Iteratively eliminate strictly dominated strategies (IESDS matrix reduction) before simulation or inspection. |
|
||||||
|
| `--verbose` | flag | — | Print per-round debug data (including agent reasoning and Q-learning updates) during simulation. |
|
||||||
|
| `--player <best_response\|regret_matching\|softmax\|q_learning\|llm>` | choice | `best_response` | Player decision model for simulation (default: `best_response`). |
|
||||||
|
| `--sim-type <agents\|evolutionary>` | choice | `agents` | Type of simulation: `agents` (default, multi-agent model simulation) or `evolutionary` (replicator dynamics). |
|
||||||
|
|
||||||
|
Example runs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python main.py --game games/rock_paper_scissors.json --rounds 100 --memory on --seed 42
|
||||||
|
.venv/bin/python main.py --describe "Two firms choose High or Low price" --save-game games/my_game.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Extension Recipes
|
||||||
|
|
||||||
|
**Add a new game (no code change):** create `games/<name>.json` following the schema above, then run `main.py --game games/<name>.json`. The pure solver and simulation work for any number of players automatically.
|
||||||
|
|
||||||
|
**Add a new player decision rule:** implement a class with the same `choose(history) -> int` interface as `BestResponsePlayer` and pass instances into a `Simulation` — but note `Simulation` currently constructs its own `BestResponsePlayer`s internally, so you would extend `Simulation.__init__` (or its player-factory) to accept pluggable players. Preserve the `rng` injection pattern.
|
||||||
|
|
||||||
|
**Add a new equilibrium algorithm:** put it in `nash/solver.py` as a free function taking a `Game` and returning equilibria in the same shapes as the existing solvers (`list[tuple[int,...]]` for pure, `list[tuple[np.ndarray, np.ndarray]]` for mixed). Wire it into `main.print_solution` if it should be reported. Keep mixed solvers 2-player-only unless you implement a real N-player method.
|
||||||
|
|
||||||
|
**Change reporting/charts:** edit `nash/visualize.py` (print helpers + `plot_strategy_frequencies`) and `main.print_comparison`. Keep the convergence cross-check logic intact — it exists to compensate for the heuristic's inability to detect "fake" convergence in games with no pure equilibrium.
|
||||||
|
|
||||||
|
## Known Limitations & Gotchas
|
||||||
|
|
||||||
|
- **Mixed solver is 2-player only by design.** Calling `find_mixed_nash_equilibria` on `num_players != 2` raises `NotImplementedError`. This is intentional, not a TODO.
|
||||||
|
- **Mixed solver assumes non-degenerate games** (only tries equal-size supports for both players). Degenerate games with unequal support sizes are a known, unhandled limitation.
|
||||||
|
- **Convergence detection is a heuristic.** `converged_round()` only looks for `convergence_window` consecutive identical profiles. In games with no pure equilibrium (Matching Pennies, RPS under `memory=on`), fictitious play can show long streaks that later flip. `main.print_comparison` is the safety net: it cross-checks against the true pure equilibria and, on mismatch, reports long-run frequencies instead of claiming a false equilibrium.
|
||||||
|
- **`memory=off` can cycle forever** and may never produce a stable window; for such games `converged_round()` returns `None`.
|
||||||
|
- **No type hints, no linter, no formatter.** Consistency comes from human/AI discipline, not tooling. Match the existing style.
|
||||||
|
- **Tests load fixtures by relative path** (`games/...`), so run pytest from the repo root.
|
||||||
|
- **`--describe` requires network + API key + the `anthropic` package**; it is the only code path that makes an external call, and its output is always printed and saved before any simulation runs.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
Dependencies are installed in a local virtualenv (`.venv/`) because the system Python is externally managed:
|
||||||
|
|
||||||
|
```
|
||||||
|
python3 -m venv .venv
|
||||||
|
.venv/bin/pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the tool against a game file:
|
||||||
|
|
||||||
|
```
|
||||||
|
.venv/bin/python main.py --game games/pricing.json --rounds 30 --memory on --seed 1
|
||||||
|
```
|
||||||
|
|
||||||
|
- `--game <path>` or `--describe "<text>"` — exactly one is required (mutually exclusive). `--describe` uses the optional AI translation layer and needs `anthropic` installed plus `ANTHROPIC_API_KEY` set.
|
||||||
|
- `--memory on|off` — toggles fictitious play vs. last-round-only best response (default `on`).
|
||||||
|
- `--seed <int>` — seeds the RNG for reproducible runs (useful when debugging).
|
||||||
|
- `--out <path>` — where the strategy-usage chart PNG is saved (default `strategy_usage.png`).
|
||||||
|
- `--inspect` — prints game details, payoff matrix, and dominant strategies, then exits.
|
||||||
|
- `--reduce` — iteratively eliminates strictly dominated strategies (IESDS matrix reduction) before simulation or inspection.
|
||||||
|
- `--verbose` — prints per-round debug data (e.g. agent reasoning) during simulation.
|
||||||
|
- `--player <best_response|regret_matching|softmax|q_learning|llm>` — decision model for simulation (default `best_response`).
|
||||||
|
- `--sim-type <agents|evolutionary>` — type of simulation: `agents` (multi-agent) or `evolutionary` (replicator dynamics) (default `agents`).
|
||||||
|
|
||||||
|
Run tests:
|
||||||
|
|
||||||
|
```
|
||||||
|
.venv/bin/python -m pytest tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
There's no build/lint step — this is a small, dependency-light script project.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
**Games are data, not code.** Every game (player names, strategies, payoffs) lives in a JSON file under `games/`; nothing about a specific scenario is hardcoded in `nash/`. `nash/game.py`'s `Game` class loads this JSON and represents payoffs as one N-dimensional numpy tensor per player (`payoffs[i]` has shape `(num_strategies_p0, num_strategies_p1, ...)`), indexed by a full strategy profile tuple. This tensor representation is what lets the pure-equilibrium finder and the simulation work for any number of players, not just 2 — adding a 3+ player game requires no code changes, just a JSON file with matching-shaped payoff tensors.
|
||||||
|
|
||||||
|
**Solver has game reduction and multiple solving algorithms** (`nash/solver.py`):
|
||||||
|
- `find_pure_nash_equilibria` — brute-force over all strategy profiles, works for any N players.
|
||||||
|
- `find_mixed_nash_equilibria` — support enumeration, **2-player only by design**. It assumes non-degenerate games (only tries equal-size supports for both players); N-player mixed equilibria aren't computed because no Lemke-Howson-style algorithm generalizes past 2 players. Calling it on a game with `num_players != 2` raises `NotImplementedError` deliberately.
|
||||||
|
- `find_dominant_strategies` — finds strictly dominant strategies for each player.
|
||||||
|
- `reduce_game` — performs Iterated Elimination of Strictly Dominated Strategies (IESDS) to reduce the matrix size before solving or simulating.
|
||||||
|
|
||||||
|
**Simulation supports multiple player decision models and evolutionary dynamics** (`nash/simulation.py`, `nash/players.py`):
|
||||||
|
- **Agent Simulation (`--sim-type agents`)**: runs agent-based rounds using decision models like `BestResponsePlayer`, `SoftmaxBestResponsePlayer`, `RegretMatchingPlayer`, `QLearningPlayer`, and `LLMPlayer`.
|
||||||
|
- **LLM Player**: `LLMPlayer` queries the Anthropic Claude API using the `anthropic` SDK to decide its next strategy. It is supplied with the payoff matrix and game history, then returns a JSON object containing its reasoning and its selected strategy index.
|
||||||
|
- **Evolutionary Simulation (`--sim-type evolutionary`)**: runs `EvolutionarySimulation` using Replicator Dynamics. Instead of discrete agent actions, it updates a continuous population strategy distribution for each player over time based on expected payoffs against other populations.
|
||||||
|
**Best-response dynamics has a memory switch** (`nash/players.py`, `BestResponsePlayer`): `memory=False` computes the best response to only the immediately preceding round (can cycle indefinitely in games like Rock-Paper-Scissors or Matching Pennies); `memory=True` is fictitious play — best response to the empirical distribution of the *entire* history so far. Ties in expected payoff are broken via a caller-supplied `random.Random` instance (passed down from `main.py`'s `--seed`), not `random` global state, so runs are reproducible end to end.
|
||||||
|
|
||||||
|
**Convergence detection is a heuristic, and `main.py` compensates for its blind spot.** `Simulation.converged_round()` (`nash/simulation.py`) just checks for N (default 5) consecutive identical profiles — it can't distinguish "the game actually settled" from "fictitious play happens to be mid-streak in a game with no pure equilibrium" (e.g. Matching Pennies, Rock-Paper-Scissors under `memory=on` will show long streaks that eventually flip as the historical average slowly shifts). `main.py.print_comparison` handles this by cross-checking the "converged" profile against `solver.find_pure_nash_equilibria`'s actual output before claiming a real equilibrium was reached; if it doesn't match, it explicitly explains why and falls back to reporting long-run strategy frequencies instead (which is the number that's actually meaningful for games whose only equilibrium is mixed).
|
||||||
|
|
||||||
|
**The AI layer (`nash/translate.py`) is intentionally decoupled from everything else.** `describe_to_json` is the only function in the codebase that makes a network/API call (via the `anthropic` SDK), and it's only reachable through `main.py`'s `--describe` flag. It never touches `solver.py` or `simulation.py` directly — it just produces the same JSON shape a hand-written game file would have, prints/saves it, and hands it to the exact same `Game` constructor and pipeline used for `--game`. This means a bad translation surfaces as visibly wrong JSON before any simulation runs, not as a silently wrong answer.
|
||||||
|
|
||||||
|
## Sample games (`games/`)
|
||||||
|
|
||||||
|
- `pricing.json` — 2-player Prisoner's-Dilemma-shaped game (High/Low pricing), unique pure NE at `(Low, Low)`.
|
||||||
|
- `matching_pennies.json` — 2-player zero-sum game with no pure NE, mixed NE at 50/50.
|
||||||
|
- `rock_paper_scissors.json` — 2-player, 3-strategy zero-sum game, mixed NE at 1/3-1/3-1/3; used to confirm the tool isn't hardcoded to 2-strategy games.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Advanced Game Theory Upgrades Design Document
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Implement Iterated Elimination of Strictly Dominated Strategies (IESDS), an LLM-driven player model, and a full Evolutionary Game Theory (Replicator Dynamics) simulation mode, maintaining the project's dependency constraints and N-player support.
|
||||||
|
|
||||||
|
## 1. Iterated Elimination (IESDS)
|
||||||
|
### Architecture (`nash/solver.py`)
|
||||||
|
- **`reduce_game(game) -> tuple[Game, list[str]]`**:
|
||||||
|
- Iteratively searches for strictly dominated strategies for all players.
|
||||||
|
- When found, it slices the `numpy` payoff tensors (using `np.delete`) to remove the strategy.
|
||||||
|
- Returns a new, reduced `Game` object and a list of string logs describing the eliminations (e.g., `"Player 'Company A' eliminated dominated strategy 'High'"`).
|
||||||
|
- Stops when a full pass over all players yields no new eliminations (the "Rationalizable Core").
|
||||||
|
|
||||||
|
### CLI Integration (`main.py`)
|
||||||
|
- Add `--reduce` flag.
|
||||||
|
- If `--reduce` is passed, `main.py` replaces the loaded game with the reduced game *before* solving or simulating.
|
||||||
|
- If used with `--inspect`, the CLI prints the step-by-step elimination logs, followed by the final reduced payoff matrix.
|
||||||
|
|
||||||
|
## 2. LLM-Driven Player
|
||||||
|
### Architecture (`nash/players.py`)
|
||||||
|
- **`LLMPlayer(BasePlayer)`**:
|
||||||
|
- Requires the `anthropic` client (already a project dependency).
|
||||||
|
- In `choose(history)`, constructs a prompt containing: the game matrix, the player's specific role, and the formatted history of previous rounds.
|
||||||
|
- Requests JSON output containing a `reasoning` string and an integer `strategy_index`.
|
||||||
|
- Parses the index to make the move.
|
||||||
|
- Calls `self.debug_logger` (if provided via `--verbose`) to print the LLM's `reasoning` live to the terminal.
|
||||||
|
- **CLI**: Exposed via `--player llm`.
|
||||||
|
|
||||||
|
## 3. Evolutionary Dynamics (Replicator Dynamics)
|
||||||
|
### Architecture (`nash/simulation.py`)
|
||||||
|
- **`EvolutionarySimulation`**:
|
||||||
|
- A new class parallel to `Simulation`.
|
||||||
|
- State is tracked as a list of population vectors (one probability distribution per player).
|
||||||
|
- Initializes with uniform distribution or slightly randomized weights.
|
||||||
|
- **Math**: Uses discrete-time replicator equations. Let $x_i$ be the proportion playing strategy $i$, $u_i$ the expected payoff of strategy $i$, and $\bar{u}$ the average payoff of the population. The update is: $x_i(t+1) = x_i(t) \times \frac{u_i}{\bar{u}}$ (with appropriate scaling to avoid negative division if payoffs are negative, e.g., by shifting payoffs positively).
|
||||||
|
- **Visualization (`nash/visualize.py`)**:
|
||||||
|
- Add `plot_evolutionary_trajectories(game, evo_sim, save_path)`.
|
||||||
|
- Plots continuous floating-point proportions over rounds, rather than rolling averages of discrete events.
|
||||||
|
|
||||||
|
### CLI Integration
|
||||||
|
- Add `--sim-type {agents, evolutionary}` to `main.py` (default: `agents`).
|
||||||
|
- If `evolutionary`, bypass standard player instantiation, run `EvolutionarySimulation`, and use the new trajectory plotter.
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
1. Load game -> Apply IESDS reduction if `--reduce`.
|
||||||
|
2. Inspect -> Print reduction logs, final matrix, exit.
|
||||||
|
3. Sim Mode -> Switch on `--sim-type`.
|
||||||
|
- `agents`: Runs existing `Simulation`. If `--player llm`, uses Anthropic API to decide moves.
|
||||||
|
- `evolutionary`: Runs `EvolutionarySimulation` with replicator math.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Simulation Upgrades Design Document
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Enhance the Nash equilibrium CLI tool with better debugging, exposed advanced player models, a game inspection mode, and improved terminal UX—all without adding external dependencies like `rich`.
|
||||||
|
|
||||||
|
## Architecture & CLI Interface
|
||||||
|
|
||||||
|
### CLI Additions (`main.py`)
|
||||||
|
1. **`--player <type>`**: Exposes hidden player models. Choices: `best_response` (default), `softmax`, `regret_matching`, `q_learning`.
|
||||||
|
2. **`--inspect`**: A flag to validate a game JSON, print a readable summary (matrix and dominant strategies), and exit without running a simulation.
|
||||||
|
3. **`--verbose`**: A flag to print internal decision-making data per round (e.g., expected payoffs, Q-values).
|
||||||
|
|
||||||
|
### Terminal UX (No Dependencies)
|
||||||
|
- Utilize standard ANSI escape codes for basic coloring (red for errors, green for success, cyan for info) to improve readability.
|
||||||
|
- Implement robust `try/except` blocks in `main.py` around `Game.load_from_json()` to intercept standard `ValueError` exceptions. The CLI will format these into user-friendly, actionable error messages rather than raw stack traces.
|
||||||
|
|
||||||
|
## Component Modifications
|
||||||
|
|
||||||
|
### 1. Game Analysis (`nash/solver.py`)
|
||||||
|
- Add `find_dominant_strategies(game) -> dict[int, int | None]`:
|
||||||
|
- Iterates through each player to determine if a single strategy yields a strictly higher payoff against *all* possible opponent profiles compared to every other strategy.
|
||||||
|
- Returns a mapping of player index to their dominant strategy index (or `None` if none exists).
|
||||||
|
|
||||||
|
### 2. Player Wiring (`nash/simulation.py` & `main.py`)
|
||||||
|
- Update `Simulation.__init__` to accept a `player_kind` string argument.
|
||||||
|
- Use `make_player` from `nash/players.py` instead of hardcoding `BestResponsePlayer`.
|
||||||
|
- Pass hyperparameter defaults to `make_player` (or extract them from `kwargs` if we extend CLI further, but defaults are fine for v1).
|
||||||
|
|
||||||
|
### 3. Verbose Debugging (`nash/players.py`)
|
||||||
|
- Add an optional `debug_logger` callback to `BasePlayer.__init__` (defaulting to a no-op).
|
||||||
|
- `BestResponsePlayer`, `SoftmaxBestResponsePlayer`, `RegretMatchingPlayer`, and `QLearningPlayer` will call `self.debug_logger(round_num, player_name, internal_state_dict)` during `choose()` to expose their math (e.g., Q-values, expected payoffs).
|
||||||
|
- `main.py` will supply a lambda to `Simulation` that formats and prints this dictionary to the terminal if `--verbose` is active.
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
1. **Load**: `main.py` parses args. If invalid JSON, prints clean ANSI error and exits.
|
||||||
|
2. **Inspect Mode**: If `--inspect`, print game matrix, run `find_dominant_strategies`, print results, exit.
|
||||||
|
3. **Simulation**: Passes `--player` choice and `--verbose` logger to `Simulation`.
|
||||||
|
4. **Execution**: During `sim.run()`, players log internal state via callback. Post-simulation cross-checks execute normally.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
- Add tests in `test_solver.py` for `find_dominant_strategies`.
|
||||||
|
- Add tests in `test_simulation.py` to ensure `Simulation` correctly instantiates `q_learning` and other models via the factory.
|
||||||
|
- Ensure malformed JSON triggers the expected UX error path in CLI tests.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import matplotlib
|
||||||
|
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
|
||||||
|
|
||||||
|
def print_round(round_num, game, profile, payoffs):
|
||||||
|
parts = []
|
||||||
|
for player_idx in range(game.num_players):
|
||||||
|
name = game.players[player_idx]
|
||||||
|
strategy = game.strategy_name(player_idx, profile[player_idx])
|
||||||
|
parts.append(f"{name}: {strategy} (payoff {payoffs[player_idx]:g})")
|
||||||
|
print(f"Round {round_num}: " + " | ".join(parts))
|
||||||
|
|
||||||
|
|
||||||
|
def print_history(game, simulation):
|
||||||
|
for round_num, (profile, payoffs) in enumerate(
|
||||||
|
zip(simulation.history, simulation.payoff_history), start=1
|
||||||
|
):
|
||||||
|
print_round(round_num, game, profile, payoffs)
|
||||||
|
|
||||||
|
|
||||||
|
def plot_strategy_frequencies(game, simulation, save_path):
|
||||||
|
frequencies = simulation.strategy_frequencies()
|
||||||
|
num_players = game.num_players
|
||||||
|
rounds = range(1, len(simulation.history) + 1)
|
||||||
|
|
||||||
|
fig, axes = plt.subplots(1, num_players, figsize=(6 * num_players, 4), squeeze=False)
|
||||||
|
for player_idx in range(num_players):
|
||||||
|
ax = axes[0][player_idx]
|
||||||
|
for strategy_idx, name in enumerate(game.strategies[player_idx]):
|
||||||
|
ax.plot(rounds, frequencies[player_idx][strategy_idx], label=name)
|
||||||
|
ax.set_title(game.players[player_idx])
|
||||||
|
ax.set_xlabel("Round")
|
||||||
|
ax.set_ylabel("Strategy usage frequency")
|
||||||
|
ax.set_ylim(-0.05, 1.05)
|
||||||
|
ax.legend()
|
||||||
|
|
||||||
|
fig.tight_layout()
|
||||||
|
fig.savefig(save_path)
|
||||||
|
plt.close(fig)
|
||||||
|
return save_path
|
||||||
|
|
||||||
|
def plot_evolutionary_trajectories(game, evo_sim, save_path):
|
||||||
|
num_players = game.num_players
|
||||||
|
rounds = list(range(1, len(evo_sim.history) + 1))
|
||||||
|
|
||||||
|
fig, axes = plt.subplots(1, num_players, figsize=(6 * num_players, 4), squeeze=False)
|
||||||
|
for player_idx in range(num_players):
|
||||||
|
ax = axes[0][player_idx]
|
||||||
|
for strategy_idx, name in enumerate(game.strategies[player_idx]):
|
||||||
|
probs = [h[player_idx][strategy_idx] for h in evo_sim.history]
|
||||||
|
ax.plot(rounds, probs, label=name)
|
||||||
|
ax.set_title(game.players[player_idx])
|
||||||
|
ax.set_xlabel("Round")
|
||||||
|
ax.set_ylabel("Strategy Probability")
|
||||||
|
ax.set_ylim(-0.05, 1.05)
|
||||||
|
ax.legend()
|
||||||
|
|
||||||
|
fig.tight_layout()
|
||||||
|
fig.savefig(save_path)
|
||||||
|
plt.close(fig)
|
||||||
|
return save_path
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import random
|
||||||
|
from nash.game import Game
|
||||||
|
from nash.simulation import Simulation
|
||||||
|
|
||||||
|
MATCHING_PENNIES = Game.load_from_json("games/matching_pennies.json")
|
||||||
|
|
||||||
|
|
||||||
|
def test_best_response_logging():
|
||||||
|
logs = []
|
||||||
|
|
||||||
|
def debug_logger(player_index, message):
|
||||||
|
logs.append((player_index, message))
|
||||||
|
|
||||||
|
rng = random.Random(42)
|
||||||
|
sim = Simulation(
|
||||||
|
MATCHING_PENNIES,
|
||||||
|
num_rounds=5,
|
||||||
|
player_kind="best_response",
|
||||||
|
debug_logger=debug_logger,
|
||||||
|
rng=rng,
|
||||||
|
)
|
||||||
|
sim.run()
|
||||||
|
|
||||||
|
# Ensure logs were captured
|
||||||
|
assert len(logs) > 0
|
||||||
|
|
||||||
|
# Ensure that each player logs (since num_rounds=5, rounds 1-4 should have history)
|
||||||
|
players_logged = {player_idx for player_idx, _ in logs}
|
||||||
|
assert players_logged == {0, 1}
|
||||||
|
|
||||||
|
# Verify message contents
|
||||||
|
for _, message in logs:
|
||||||
|
assert "expected payoffs:" in message
|
||||||
|
|
||||||
|
|
||||||
|
def test_softmax_logging():
|
||||||
|
logs = []
|
||||||
|
|
||||||
|
def debug_logger(player_index, message):
|
||||||
|
logs.append((player_index, message))
|
||||||
|
|
||||||
|
rng = random.Random(42)
|
||||||
|
sim = Simulation(
|
||||||
|
MATCHING_PENNIES,
|
||||||
|
num_rounds=5,
|
||||||
|
player_kind="softmax",
|
||||||
|
debug_logger=debug_logger,
|
||||||
|
rng=rng,
|
||||||
|
)
|
||||||
|
sim.run()
|
||||||
|
|
||||||
|
assert len(logs) > 0
|
||||||
|
|
||||||
|
players_logged = {player_idx for player_idx, _ in logs}
|
||||||
|
assert players_logged == {0, 1}
|
||||||
|
|
||||||
|
for _, message in logs:
|
||||||
|
assert "expected payoffs:" in message
|
||||||
|
|
||||||
|
|
||||||
|
def test_regret_matching_logging():
|
||||||
|
logs = []
|
||||||
|
|
||||||
|
def debug_logger(player_index, message):
|
||||||
|
logs.append((player_index, message))
|
||||||
|
|
||||||
|
rng = random.Random(42)
|
||||||
|
sim = Simulation(
|
||||||
|
MATCHING_PENNIES,
|
||||||
|
num_rounds=5,
|
||||||
|
player_kind="regret_matching",
|
||||||
|
debug_logger=debug_logger,
|
||||||
|
rng=rng,
|
||||||
|
)
|
||||||
|
sim.run()
|
||||||
|
|
||||||
|
assert len(logs) > 0
|
||||||
|
|
||||||
|
players_logged = {player_idx for player_idx, _ in logs}
|
||||||
|
assert players_logged == {0, 1}
|
||||||
|
|
||||||
|
# Regret matching logs cumulative regret and probabilities on every round
|
||||||
|
# 5 rounds * 2 players = 10 logs
|
||||||
|
assert len(logs) == 10
|
||||||
|
|
||||||
|
for _, message in logs:
|
||||||
|
assert "cumulative regret:" in message
|
||||||
|
assert "probabilities:" in message
|
||||||
|
|
||||||
|
|
||||||
|
def test_q_learning_logging():
|
||||||
|
logs = []
|
||||||
|
|
||||||
|
def debug_logger(player_index, message):
|
||||||
|
logs.append((player_index, message))
|
||||||
|
|
||||||
|
rng = random.Random(42)
|
||||||
|
sim = Simulation(
|
||||||
|
MATCHING_PENNIES,
|
||||||
|
num_rounds=5,
|
||||||
|
player_kind="q_learning",
|
||||||
|
debug_logger=debug_logger,
|
||||||
|
rng=rng,
|
||||||
|
)
|
||||||
|
sim.run()
|
||||||
|
|
||||||
|
assert len(logs) > 0
|
||||||
|
|
||||||
|
players_logged = {player_idx for player_idx, _ in logs}
|
||||||
|
assert players_logged == {0, 1}
|
||||||
|
|
||||||
|
# Q-learning logs Q-values for state on every round
|
||||||
|
assert len(logs) == 10
|
||||||
|
|
||||||
|
for _, message in logs:
|
||||||
|
assert "Q-values for state" in message
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
from nash.game import Game
|
||||||
|
from nash.players import LLMPlayer, make_player, PLAYER_KINDS
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_anthropic():
|
||||||
|
with patch("anthropic.Anthropic") as mock:
|
||||||
|
yield mock
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_player_initialization(mock_anthropic):
|
||||||
|
game = Game(
|
||||||
|
players=["P1", "P2"],
|
||||||
|
strategies=[["Cooperate", "Defect"], ["Cooperate", "Defect"]],
|
||||||
|
payoffs=[
|
||||||
|
[[2.0, 0.0], [3.0, 1.0]],
|
||||||
|
[[2.0, 3.0], [0.0, 1.0]]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
# Test PLAYER_KINDS
|
||||||
|
assert "llm" in PLAYER_KINDS
|
||||||
|
|
||||||
|
# Test make_player
|
||||||
|
player = make_player("llm", game, player_index=0, model="my-test-model")
|
||||||
|
assert isinstance(player, LLMPlayer)
|
||||||
|
assert player.game == game
|
||||||
|
assert player.player_index == 0
|
||||||
|
assert player.model == "my-test-model"
|
||||||
|
mock_anthropic.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_player_choose_clean_json(mock_anthropic):
|
||||||
|
game = Game(
|
||||||
|
players=["P1", "P2"],
|
||||||
|
strategies=[["Cooperate", "Defect"], ["Cooperate", "Defect"]],
|
||||||
|
payoffs=[
|
||||||
|
[[2.0, 0.0], [3.0, 1.0]],
|
||||||
|
[[2.0, 3.0], [0.0, 1.0]]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
logs = []
|
||||||
|
|
||||||
|
def debug_logger(player_index, message):
|
||||||
|
logs.append((player_index, message))
|
||||||
|
|
||||||
|
player = make_player("llm", game, player_index=0, debug_logger=debug_logger)
|
||||||
|
|
||||||
|
mock_client = mock_anthropic.return_value
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_content = MagicMock()
|
||||||
|
mock_content.text = '{"reasoning": "I want to cooperate to maximize payoff.", "strategy_index": 0}'
|
||||||
|
mock_response.content = [mock_content]
|
||||||
|
mock_client.messages.create.return_value = mock_response
|
||||||
|
|
||||||
|
# Choose action with history
|
||||||
|
history = [(0, 1), (1, 0)]
|
||||||
|
action = player.choose(history)
|
||||||
|
|
||||||
|
# Assert return value is strategy_index
|
||||||
|
assert action == 0
|
||||||
|
|
||||||
|
# Assert logs captured reasoning
|
||||||
|
assert len(logs) == 1
|
||||||
|
assert logs[0] == (0, "I want to cooperate to maximize payoff.")
|
||||||
|
|
||||||
|
# Check that client.messages.create was called
|
||||||
|
mock_client.messages.create.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("model_output,expected_idx,expected_reasoning", [
|
||||||
|
(
|
||||||
|
'```json\n{"reasoning": "Markdown fence with json key", "strategy_index": 1}\n```',
|
||||||
|
1,
|
||||||
|
"Markdown fence with json key"
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'```\n{"reasoning": "Markdown fence without json key", "strategy_index": 0}\n```',
|
||||||
|
0,
|
||||||
|
"Markdown fence without json key"
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Here is the response:\n{"reasoning": "Surrounding text", "strategy_index": 1}\nHope this helps!',
|
||||||
|
1,
|
||||||
|
"Surrounding text"
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Some intro...\n```json\n{"reasoning": "Intro and fence and outro", "strategy_index": 0}\n```\nSome outro...',
|
||||||
|
0,
|
||||||
|
"Intro and fence and outro"
|
||||||
|
),
|
||||||
|
])
|
||||||
|
def test_llm_player_resilient_json_parsing(mock_anthropic, model_output, expected_idx, expected_reasoning):
|
||||||
|
game = Game(
|
||||||
|
players=["P1", "P2"],
|
||||||
|
strategies=[["Cooperate", "Defect"], ["Cooperate", "Defect"]],
|
||||||
|
payoffs=[
|
||||||
|
[[2.0, 0.0], [3.0, 1.0]],
|
||||||
|
[[2.0, 3.0], [0.0, 1.0]]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
player = LLMPlayer(game, player_index=0)
|
||||||
|
|
||||||
|
mock_client = mock_anthropic.return_value
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_content = MagicMock()
|
||||||
|
mock_content.text = model_output
|
||||||
|
mock_response.content = [mock_content]
|
||||||
|
mock_client.messages.create.return_value = mock_response
|
||||||
|
|
||||||
|
action = player.choose([])
|
||||||
|
assert action == expected_idx
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_player_invalid_json_raises(mock_anthropic):
|
||||||
|
game = Game(
|
||||||
|
players=["P1", "P2"],
|
||||||
|
strategies=[["Cooperate", "Defect"], ["Cooperate", "Defect"]],
|
||||||
|
payoffs=[
|
||||||
|
[[2.0, 0.0], [3.0, 1.0]],
|
||||||
|
[[2.0, 3.0], [0.0, 1.0]]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
player = LLMPlayer(game, player_index=0)
|
||||||
|
|
||||||
|
mock_client = mock_anthropic.return_value
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_content = MagicMock()
|
||||||
|
mock_content.text = "This is just text with no json braces at all."
|
||||||
|
mock_response.content = [mock_content]
|
||||||
|
mock_client.messages.create.return_value = mock_response
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="No JSON object found in model response"):
|
||||||
|
player.choose([])
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_player_prompt_contents(mock_anthropic):
|
||||||
|
game = Game(
|
||||||
|
players=["P1", "P2"],
|
||||||
|
strategies=[["Cooperate", "Defect"], ["Cooperate", "Defect"]],
|
||||||
|
payoffs=[
|
||||||
|
[[2.0, 0.0], [3.0, 1.0]],
|
||||||
|
[[2.0, 3.0], [0.0, 1.0]]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
player = LLMPlayer(game, player_index=0)
|
||||||
|
|
||||||
|
mock_client = mock_anthropic.return_value
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_content = MagicMock()
|
||||||
|
mock_content.text = '{"reasoning": "strategic choice", "strategy_index": 1}'
|
||||||
|
mock_response.content = [mock_content]
|
||||||
|
mock_client.messages.create.return_value = mock_response
|
||||||
|
|
||||||
|
history = [(0, 1), (1, 0)]
|
||||||
|
player.choose(history)
|
||||||
|
|
||||||
|
# Retrieve the arguments passed to create
|
||||||
|
mock_client.messages.create.assert_called_once()
|
||||||
|
kwargs = mock_client.messages.create.call_args[1]
|
||||||
|
|
||||||
|
# Check messages structure
|
||||||
|
assert "messages" in kwargs
|
||||||
|
messages = kwargs["messages"]
|
||||||
|
assert len(messages) == 1
|
||||||
|
prompt = messages[0]["content"]
|
||||||
|
|
||||||
|
# 1. Player name
|
||||||
|
assert "Player 'P1'" in prompt
|
||||||
|
|
||||||
|
# 2. Strategy options
|
||||||
|
assert "Cooperate" in prompt
|
||||||
|
assert "Defect" in prompt
|
||||||
|
|
||||||
|
# 3. Payoff matrix formatting
|
||||||
|
# Profile: (Cooperate | Cooperate) -> Payoffs: (2.00, 2.00)
|
||||||
|
assert "Profile: (Cooperate | Cooperate) -> Payoffs: (2.00, 2.00)" in prompt
|
||||||
|
assert "Profile: (Defect | Cooperate) -> Payoffs: (3.00, 0.00)" in prompt
|
||||||
|
|
||||||
|
# 4. History of recent rounds
|
||||||
|
# Round 1: P1: Cooperate, P2: Defect
|
||||||
|
assert "Round 1: P1: Cooperate, P2: Defect" in prompt
|
||||||
|
assert "Round 2: P1: Defect, P2: Cooperate" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_player_empty_history(mock_anthropic):
|
||||||
|
game = Game(
|
||||||
|
players=["P1", "P2"],
|
||||||
|
strategies=[["Cooperate", "Defect"], ["Cooperate", "Defect"]],
|
||||||
|
payoffs=[
|
||||||
|
[[2.0, 0.0], [3.0, 1.0]],
|
||||||
|
[[2.0, 3.0], [0.0, 1.0]]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
player = LLMPlayer(game, player_index=0)
|
||||||
|
|
||||||
|
mock_client = mock_anthropic.return_value
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_content = MagicMock()
|
||||||
|
mock_content.text = '{"reasoning": "no history", "strategy_index": 0}'
|
||||||
|
mock_response.content = [mock_content]
|
||||||
|
mock_client.messages.create.return_value = mock_response
|
||||||
|
|
||||||
|
player.choose([])
|
||||||
|
|
||||||
|
kwargs = mock_client.messages.create.call_args[1]
|
||||||
|
prompt = kwargs["messages"][0]["content"]
|
||||||
|
assert "No rounds played yet." in prompt
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import numpy as np
|
||||||
|
import random
|
||||||
|
|
||||||
|
from nash.game import Game
|
||||||
|
from nash.simulation import Simulation, EvolutionarySimulation
|
||||||
|
|
||||||
|
PRICING = Game.load_from_json("games/pricing.json")
|
||||||
|
MATCHING_PENNIES = Game.load_from_json("games/matching_pennies.json")
|
||||||
|
|
||||||
|
|
||||||
|
def test_pricing_simulation_converges_to_low_low_with_memory_on():
|
||||||
|
rng = random.Random(1)
|
||||||
|
simulation = Simulation(PRICING, num_rounds=10, memory=True, rng=rng)
|
||||||
|
simulation.run()
|
||||||
|
|
||||||
|
converged_round = simulation.converged_round()
|
||||||
|
assert converged_round is not None
|
||||||
|
assert simulation.converged_profile() == (1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_matching_pennies_frequencies_trend_toward_fifty_fifty_with_memory_on():
|
||||||
|
rng = random.Random(7)
|
||||||
|
simulation = Simulation(MATCHING_PENNIES, num_rounds=300, memory=True, rng=rng)
|
||||||
|
simulation.run()
|
||||||
|
|
||||||
|
frequencies = simulation.strategy_frequencies()
|
||||||
|
for player_idx in range(2):
|
||||||
|
final_freqs = [frequencies[player_idx][s][-1] for s in range(2)]
|
||||||
|
assert abs(final_freqs[0] - 0.5) < 0.15
|
||||||
|
assert abs(final_freqs[1] - 0.5) < 0.15
|
||||||
|
|
||||||
|
def test_evolutionary_simulation_initialization():
|
||||||
|
# 2 players, pricing game has 2 strategies each
|
||||||
|
sim = EvolutionarySimulation(PRICING, num_rounds=5)
|
||||||
|
assert len(sim.populations) == 2
|
||||||
|
assert np.allclose(sim.populations[0], [0.5, 0.5])
|
||||||
|
assert np.allclose(sim.populations[1], [0.5, 0.5])
|
||||||
|
assert len(sim.history) == 0
|
||||||
|
|
||||||
|
def test_evolutionary_simulation_run_pricing():
|
||||||
|
sim = EvolutionarySimulation(PRICING, num_rounds=5)
|
||||||
|
history = sim.run()
|
||||||
|
assert len(history) == 5
|
||||||
|
assert len(sim.history) == 5
|
||||||
|
for pop_profile in history:
|
||||||
|
assert len(pop_profile) == 2
|
||||||
|
# check each player's population is normalized
|
||||||
|
assert np.allclose(np.sum(pop_profile[0]), 1.0)
|
||||||
|
assert np.allclose(np.sum(pop_profile[1]), 1.0)
|
||||||
|
|
||||||
|
def test_evolutionary_simulation_matching_pennies():
|
||||||
|
# Matching pennies has no pure Nash equilibrium, populations should cycle/fluctuate
|
||||||
|
sim = EvolutionarySimulation(MATCHING_PENNIES, num_rounds=100)
|
||||||
|
sim.run()
|
||||||
|
assert len(sim.history) == 100
|
||||||
|
# Final populations should be valid probability distributions
|
||||||
|
for pop in sim.populations:
|
||||||
|
assert np.allclose(np.sum(pop), 1.0)
|
||||||
|
assert np.all(pop >= 0)
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nash import solver
|
||||||
|
from nash.game import Game
|
||||||
|
|
||||||
|
PRICING = Game.load_from_json("games/pricing.json")
|
||||||
|
MATCHING_PENNIES = Game.load_from_json("games/matching_pennies.json")
|
||||||
|
|
||||||
|
|
||||||
|
def test_pricing_has_one_pure_equilibrium_low_low():
|
||||||
|
equilibria = solver.find_pure_nash_equilibria(PRICING)
|
||||||
|
assert equilibria == [(1, 1)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_matching_pennies_has_no_pure_equilibrium():
|
||||||
|
equilibria = solver.find_pure_nash_equilibria(MATCHING_PENNIES)
|
||||||
|
assert equilibria == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_pricing_mixed_equilibrium_matches_pure_equilibrium():
|
||||||
|
equilibria = solver.find_mixed_nash_equilibria(PRICING)
|
||||||
|
assert len(equilibria) == 1
|
||||||
|
x, y = equilibria[0]
|
||||||
|
assert np.allclose(x, [0, 1], atol=1e-6)
|
||||||
|
assert np.allclose(y, [0, 1], atol=1e-6)
|
||||||
|
|
||||||
|
|
||||||
|
def test_matching_pennies_mixed_equilibrium_is_fifty_fifty():
|
||||||
|
equilibria = solver.find_mixed_nash_equilibria(MATCHING_PENNIES)
|
||||||
|
assert len(equilibria) == 1
|
||||||
|
x, y = equilibria[0]
|
||||||
|
assert np.allclose(x, [0.5, 0.5], atol=1e-6)
|
||||||
|
assert np.allclose(y, [0.5, 0.5], atol=1e-6)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mixed_solver_rejects_more_than_two_players():
|
||||||
|
game = Game(
|
||||||
|
players=["A", "B", "C"],
|
||||||
|
strategies=[["x", "y"], ["x", "y"], ["x", "y"]],
|
||||||
|
payoffs=[np.zeros((2, 2, 2))] * 3,
|
||||||
|
)
|
||||||
|
with pytest.raises(NotImplementedError):
|
||||||
|
solver.find_mixed_nash_equilibria(game)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pure_equilibrium_finder_supports_n_players():
|
||||||
|
# Simple 3-player coordination game: everyone matching strategy 0 is the
|
||||||
|
# unique pure Nash equilibrium (payoff 1 if all match, 0 otherwise).
|
||||||
|
payoff = np.zeros((2, 2, 2))
|
||||||
|
payoff[0, 0, 0] = 1
|
||||||
|
game = Game(
|
||||||
|
players=["A", "B", "C"],
|
||||||
|
strategies=[["x", "y"], ["x", "y"], ["x", "y"]],
|
||||||
|
payoffs=[payoff, payoff, payoff],
|
||||||
|
)
|
||||||
|
equilibria = solver.find_pure_nash_equilibria(game)
|
||||||
|
assert (0, 0, 0) in equilibria
|
||||||
|
|
||||||
|
def test_pricing_has_dominant_strategies():
|
||||||
|
result = solver.find_dominant_strategies(PRICING)
|
||||||
|
# Both companies have "Low" (index 1) as strictly dominant strategy:
|
||||||
|
# Company A: Low beats High vs any B strategy (10>8, 5>2)
|
||||||
|
# Company B: Low beats High vs any A strategy (10>8, 5>2)
|
||||||
|
assert result == {0: 1, 1: 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_matching_pennies_has_no_dominant_strategies():
|
||||||
|
result = solver.find_dominant_strategies(MATCHING_PENNIES)
|
||||||
|
assert result == {0: None, 1: None}
|
||||||
|
|
||||||
|
|
||||||
|
def test_dominant_strategies_supports_n_players():
|
||||||
|
# 3-player game: players 0,1 have strictly dominant strategy 0;
|
||||||
|
# player 2 has no dominant strategy.
|
||||||
|
payoffs_0 = np.zeros((2, 2, 2))
|
||||||
|
payoffs_1 = np.zeros((2, 2, 2))
|
||||||
|
payoffs_2 = np.zeros((2, 2, 2))
|
||||||
|
for profile in np.ndindex(2, 2, 2):
|
||||||
|
payoffs_0[profile] = 10.0 if profile[0] == 0 else 1.0
|
||||||
|
payoffs_1[profile] = 10.0 if profile[1] == 0 else 1.0
|
||||||
|
payoffs_2[profile] = float(profile[0] + profile[1])
|
||||||
|
game = Game(
|
||||||
|
players=["A", "B", "C"],
|
||||||
|
strategies=[["x", "y"], ["x", "y"], ["x", "y"]],
|
||||||
|
payoffs=[payoffs_0, payoffs_1, payoffs_2],
|
||||||
|
)
|
||||||
|
result = solver.find_dominant_strategies(game)
|
||||||
|
assert result[0] == 0
|
||||||
|
assert result[1] == 0
|
||||||
|
assert result[2] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_dominant_strategies_one_strategy_player():
|
||||||
|
# Player 0 has 1 strategy, Player 1 has 2 strategies
|
||||||
|
payoffs_0 = np.array([[5.0, 5.0]]) # 1x2 matrix
|
||||||
|
payoffs_1 = np.array([[10.0, 1.0]]) # 1x2 matrix
|
||||||
|
game = Game(
|
||||||
|
players=["A", "B"],
|
||||||
|
strategies=[["only"], ["x", "y"]],
|
||||||
|
payoffs=[payoffs_0, payoffs_1],
|
||||||
|
)
|
||||||
|
result = solver.find_dominant_strategies(game)
|
||||||
|
# Player 0 has no dominant strategy (None) because they only have 1 strategy.
|
||||||
|
# Player 1 has dominant strategy 0 (10.0 > 1.0).
|
||||||
|
assert result == {0: None, 1: 0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_reduce_game_pricing():
|
||||||
|
reduced, logs = solver.reduce_game(PRICING)
|
||||||
|
assert len(logs) == 2
|
||||||
|
assert "Player 'Company A' eliminated dominated strategy 'High'" in logs[0]
|
||||||
|
assert "Player 'Company B' eliminated dominated strategy 'High'" in logs[1]
|
||||||
|
assert reduced.strategies == [["Low"], ["Low"]]
|
||||||
|
assert np.allclose(reduced.payoffs[0], [[5]])
|
||||||
|
assert np.allclose(reduced.payoffs[1], [[5]])
|
||||||
|
|
||||||
|
|
||||||
|
def test_reduce_game_matching_pennies():
|
||||||
|
reduced, logs = solver.reduce_game(MATCHING_PENNIES)
|
||||||
|
assert len(logs) == 0
|
||||||
|
assert reduced.strategies == MATCHING_PENNIES.strategies
|
||||||
|
assert np.allclose(reduced.payoffs[0], MATCHING_PENNIES.payoffs[0])
|
||||||
|
assert np.allclose(reduced.payoffs[1], MATCHING_PENNIES.payoffs[1])
|
||||||
|
|
||||||
|
|
||||||
|
def test_reduce_game_n_players():
|
||||||
|
payoffs_0 = np.zeros((2, 2, 2))
|
||||||
|
payoffs_1 = np.zeros((2, 2, 2))
|
||||||
|
payoffs_2 = np.zeros((2, 2, 2))
|
||||||
|
payoffs_0[0, :, :] = 1.0
|
||||||
|
payoffs_0[1, :, :] = 2.0
|
||||||
|
payoffs_1[:, 0, :] = 1.0
|
||||||
|
payoffs_1[:, 1, :] = 2.0
|
||||||
|
payoffs_2[:, :, 0] = 1.0
|
||||||
|
payoffs_2[:, :, 1] = 2.0
|
||||||
|
game = Game(
|
||||||
|
players=["P0", "P1", "P2"],
|
||||||
|
strategies=[["A", "B"], ["C", "D"], ["E", "F"]],
|
||||||
|
payoffs=[payoffs_0, payoffs_1, payoffs_2]
|
||||||
|
)
|
||||||
|
reduced, logs = solver.reduce_game(game)
|
||||||
|
assert len(logs) == 3
|
||||||
|
assert reduced.strategies == [["B"], ["D"], ["F"]]
|
||||||
|
assert np.allclose(reduced.payoffs[0], [[[2.0]]])
|
||||||
Reference in New Issue
Block a user