Import documentation: Shayan1/Prod/docs/DOCS-2.md

Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
arsam
2026-09-18 23:14:57 +03:30
parent 11160a6591
commit efde08eb56
+337
View File
@@ -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.