diff --git a/CLAUDE.md b/CLAUDE.md index 0da9623..d05beda 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co | gds-stockflow | `stockflow` | `packages/gds-stockflow/` | | gds-control | `gds_control` | `packages/gds-control/` | | gds-software | `gds_software` | `packages/gds-software/` | +| gds-sim | `gds_sim` | `packages/gds-sim/` | | gds-examples | — | `packages/gds-examples/` | ## Commands @@ -32,12 +33,13 @@ uv run --package gds-stockflow pytest packages/gds-stockflow/tests -v uv run --package gds-control pytest packages/gds-control/tests -v uv run --package gds-software pytest packages/gds-software/tests -v uv run --package gds-examples pytest packages/gds-examples -v +uv run --package gds-sim pytest packages/gds-sim/tests -v # Run a single test uv run --package gds-framework pytest packages/gds-framework/tests/test_blocks.py::TestStackComposition::test_rshift_operator -v # Run all tests across all packages -uv run --package gds-framework pytest packages/gds-framework/tests packages/gds-viz/tests packages/gds-games/tests packages/gds-stockflow/tests packages/gds-control/tests packages/gds-software/tests packages/gds-examples -v +uv run --package gds-framework pytest packages/gds-framework/tests packages/gds-viz/tests packages/gds-games/tests packages/gds-stockflow/tests packages/gds-control/tests packages/gds-software/tests packages/gds-examples packages/gds-sim/tests -v # Lint & format uv run ruff check packages/ @@ -68,6 +70,8 @@ gds-control ← control systems DSL (depends on gds-framework) gds-software ← software architecture DSL (depends on gds-framework) ↑ gds-examples ← tutorials (depends on gds-framework + gds-viz) + +gds-sim ← simulation engine (standalone — no gds-framework dep, only pydantic) ``` ### gds-framework: Two-Layer Design diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index 1b2e690..425552c 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -422,9 +422,15 @@ You have built a complete GDS specification for a thermostat system, progressing From here, explore the [example models](../examples/index.md) or the [Rosetta Stone](rosetta-stone.md) guide to see the same system through different DSL lenses. -## Running Interactively +## Interactive Notebook -The guide includes a [marimo notebook](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/guides/getting_started/notebook.py) for interactive exploration: +/// marimo-embed-file + filepath: packages/gds-examples/guides/getting_started/notebook.py + height: 800px + mode: read +/// + +To run the notebook locally: ```bash uv run marimo run packages/gds-examples/guides/getting_started/notebook.py diff --git a/docs/guides/interoperability.md b/docs/guides/interoperability.md index 134270b..ae08c3e 100644 --- a/docs/guides/interoperability.md +++ b/docs/guides/interoperability.md @@ -232,31 +232,47 @@ This validates GDS as an **interoperability substrate**, not just a modeling fra ## Running the Examples -### Nash equilibrium analysis +### Nash Equilibrium Analysis + +/// marimo-embed-file + filepath: packages/gds-examples/guides/nash_equilibrium/notebook.py + height: 800px + mode: read +/// + +To run locally: ```bash -# Install dependencies uv sync --all-packages --extra nash +cd packages/gds-examples && \ + uv run marimo run guides/nash_equilibrium/notebook.py +``` +```bash # Run tests (22 tests) uv run --package gds-examples pytest \ packages/gds-examples/games/prisoners_dilemma_nash/ -v +``` + +### Evolution of Trust Simulation + +/// marimo-embed-file + filepath: packages/gds-examples/guides/evolution_of_trust/notebook.py + height: 800px + mode: read +/// -# Interactive notebook +To run locally: + +```bash cd packages/gds-examples && \ - uv run marimo run guides/nash_equilibrium/notebook.py + uv run marimo run guides/evolution_of_trust/notebook.py ``` -### Evolution of Trust simulation - ```bash # Run tests (71 tests) uv run --package gds-examples pytest \ packages/gds-examples/games/evolution_of_trust/ -v - -# Interactive notebook (with plotly charts) -cd packages/gds-examples && \ - uv run marimo run guides/evolution_of_trust/notebook.py ``` ### Source files diff --git a/docs/guides/rosetta-stone.md b/docs/guides/rosetta-stone.md index 7a2b9b4..856f556 100644 --- a/docs/guides/rosetta-stone.md +++ b/docs/guides/rosetta-stone.md @@ -313,9 +313,15 @@ from guides.rosetta.comparison import canonical_spectrum_table print(canonical_spectrum_table()) ``` -## Running Interactively +## Interactive Notebook -The guide includes a [marimo notebook](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/guides/rosetta/notebook.py) for interactive exploration with live Mermaid rendering and dropdown selectors: +/// marimo-embed-file + filepath: packages/gds-examples/guides/rosetta/notebook.py + height: 800px + mode: read +/// + +To run the notebook locally: ```bash uv run marimo run packages/gds-examples/guides/rosetta/notebook.py diff --git a/docs/guides/verification.md b/docs/guides/verification.md index e20edba..a9088da 100644 --- a/docs/guides/verification.md +++ b/docs/guides/verification.md @@ -372,9 +372,15 @@ for finding in report.findings: print(f"{finding.check_id}: {finding.message}") ``` -## Running Interactively +## Interactive Notebook -The guide includes a [marimo notebook](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/guides/verification/notebook.py) with interactive dropdowns for selecting broken models and watching checks in real time: +/// marimo-embed-file + filepath: packages/gds-examples/guides/verification/notebook.py + height: 800px + mode: read +/// + +To run the notebook locally: ```bash uv run marimo run packages/gds-examples/guides/verification/notebook.py diff --git a/docs/guides/visualization.md b/docs/guides/visualization.md index 3c95bc0..9694e88 100644 --- a/docs/guides/visualization.md +++ b/docs/guides/visualization.md @@ -203,9 +203,15 @@ mermaid_str = system_to_mermaid(system, theme="dark") # Paste into GitHub markdown, mermaid.live, or mo.mermaid() ``` -## Running Interactively +## Interactive Notebook -The guide includes a [marimo notebook](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/guides/visualization/notebook.py) with interactive dropdowns for selecting views, themes, and models: +/// marimo-embed-file + filepath: packages/gds-examples/guides/visualization/notebook.py + height: 800px + mode: read +/// + +To run the notebook locally: ```bash uv run marimo run packages/gds-examples/guides/visualization/notebook.py diff --git a/mkdocs.yml b/mkdocs.yml index 5301440..ab08042 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -27,6 +27,7 @@ theme: plugins: - search + - marimo - mkdocstrings: handlers: python: @@ -44,55 +45,69 @@ plugins: compile to the same GDS IR, validating it as a universal transition calculus. full_output: llms-full.txt sections: - Framework: - - index.md - - framework/index.md - - framework/quick-reference.md - - framework/getting-started/install.md - - framework/getting-started/quickstart.md + Framework (gds-framework): + - {index.md: "Monorepo landing page — package overview, install, architecture"} + - {framework/index.md: "Core engine — blocks, composition algebra, compiler, verification"} + - {framework/quick-reference.md: "Cheat sheet for types, blocks, composition, and verification"} + - {framework/getting-started/install.md: "Installation for users and developers"} + - {framework/getting-started/quickstart.md: "Build a thermostat spec in 5 minutes"} - framework/guide/*.md - - framework/design/*.md - framework/api/*.md - Visualization: - - viz/index.md - - viz/getting-started.md + Visualization (gds-viz): + - {viz/index.md: "Mermaid diagram renderers for GDS specifications"} + - {viz/getting-started.md: "First diagram in 3 lines of code"} - viz/guide/*.md - viz/api/*.md - Games: - - games/index.md - - games/getting-started.md + Games (gds-games / ogs): + - {games/index.md: "Typed DSL for compositional game theory (Open Games)"} + - {games/getting-started.md: "Model a Prisoner's Dilemma game"} - games/guide/*.md - games/design/*.md - games/api/*.md - Business: - - business/index.md - - business/getting-started.md + Business (gds-business): + - {business/index.md: "Business dynamics DSL — CLD, supply chain, value stream map"} + - {business/getting-started.md: "Model a causal loop diagram"} - business/guide/*.md - business/api/*.md - Stock-Flow: - - stockflow/index.md - - stockflow/getting-started.md + Stock-Flow (gds-stockflow): + - {stockflow/index.md: "Declarative stock-flow DSL over GDS semantics"} + - {stockflow/getting-started.md: "Model an SIR epidemic as stocks and flows"} - stockflow/guide/*.md - stockflow/api/*.md - Control: - - control/index.md - - control/getting-started.md + Control (gds-control): + - {control/index.md: "State-space control systems DSL over GDS semantics"} + - {control/getting-started.md: "Model a PID thermostat controller"} - control/guide/*.md - control/api/*.md - Software: - - software/index.md - - software/getting-started.md + Software (gds-software): + - {software/index.md: "Software architecture DSL — DFD, state machine, C4, ERD, component, dependency"} + - {software/getting-started.md: "Model a data flow diagram"} - software/guide/*.md - software/api/*.md Examples: - - examples/*.md + - {examples/index.md: "Six tutorial models demonstrating every framework feature"} + - {examples/learning-path.md: "Recommended order for working through examples"} + - examples/building-models.md + - examples/feature-matrix.md - examples/examples/*.md Tutorials: - tutorials/*.md Guides: - - guides/*.md + - {guides/getting-started.md: "Build a thermostat model in 5 progressive stages"} + - {guides/choosing-a-dsl.md: "Decision guide for picking the right DSL"} + - {guides/best-practices.md: "Patterns and anti-patterns for GDS modeling"} + - {guides/rosetta-stone.md: "Same problem modeled with stockflow, control, and games"} + - {guides/verification.md: "All 3 verification layers with deliberately broken models"} + - {guides/visualization.md: "6 view types, 5 themes, cross-DSL rendering"} + - guides/real-world-patterns.md + - guides/troubleshooting.md + - guides/interoperability.md + - guides/architecture-milestone-layer0.md + - guides/dsl-roadmap.md + - guides/research-boundaries.md + - guides/view-stratification.md Ecosystem: - - framework/ecosystem.md + - {framework/ecosystem.md: "All packages, their imports, and dependency relationships"} markdown_extensions: - admonition @@ -131,11 +146,6 @@ nav: - Pipeline: framework/guide/pipeline.md - Verification: framework/guide/verification.md - Glossary: framework/guide/glossary.md - - Design: - - GDS Deep Dive: framework/design/gds-deepdive.md - - v0.2 Design: framework/design/v0.2-design.md - - Proposals: - - Entity Redesign: framework/design/proposals/entity-redesign.md - API Reference: - Overview: framework/api/index.md - gds: framework/api/init.md diff --git a/packages/gds-examples/games/prisoners_dilemma_nash/__init__.py b/packages/gds-examples/games/prisoners_dilemma_nash/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/gds-examples/games/prisoners_dilemma_nash/model.py b/packages/gds-examples/games/prisoners_dilemma_nash/model.py new file mode 100644 index 0000000..eb05cc0 --- /dev/null +++ b/packages/gds-examples/games/prisoners_dilemma_nash/model.py @@ -0,0 +1,505 @@ +"""Nash Equilibrium computation for the Prisoner's Dilemma via Nashpy. + +Builds the Prisoner's Dilemma directly from OGS primitives (DecisionGame, +CovariantFunction, composition operators, Pattern metadata), then uses +Nashpy to compute Nash equilibria and verify them against the hand-annotated +terminal conditions. + +Concepts Covered: + - Building a 2-player normal-form game from OGS primitives + - Extracting payoff matrices from PatternIR metadata + - Nashpy integration for Nash equilibrium computation (support enumeration) + - Cross-referencing computed equilibria against declared TerminalConditions + - Dominance and Pareto optimality analysis + +Prerequisites: + - nashpy>=0.0.41 (install via: uv sync --all-packages --extra nash) + +OGS Game Theory Decomposition: + Players: Alice, Bob + Actions: {Cooperate, Defect} + Payoff Matrix: (R, T, S, P) = (3, 5, 0, 1) + Composition: (alice_decision | bob_decision) >> payoff_computation + .feedback([payoff -> decisions]) + +References: + - GitHub issue: https://github.com/BlockScience/gds-core/issues/77 +""" + +import re + +import nashpy as nash +import numpy as np +from numpy.typing import NDArray + +from ogs.dsl.compile import compile_to_ir +from ogs.dsl.composition import ( + FeedbackFlow, + FeedbackLoop, + Flow, + ParallelComposition, + SequentialComposition, +) +from ogs.dsl.games import CovariantFunction, DecisionGame +from ogs.dsl.pattern import ActionSpace, Pattern, PatternInput, TerminalCondition +from ogs.dsl.types import CompositionType, InputType, Signature, port +from ogs.ir.models import PatternIR + +# ====================================================================== +# Payoff parameters — standard PD: T > R > P > S, 2R > T + S +# ====================================================================== + +R = 3 # Reward (mutual cooperation) +T = 5 # Temptation (defect while other cooperates) +S = 0 # Sucker (cooperate while other defects) +P = 1 # Punishment (mutual defection) + +# ====================================================================== +# Atomic Games — OGS primitives +# ====================================================================== + +alice_decision = DecisionGame( + name="Alice Decision", + signature=Signature( + x=(port("Alice Observation"),), + y=(port("Alice Action"),), + r=(port("Alice Payoff"),), + s=(port("Alice Experience"),), + ), + logic=( + "Alice observes her previous round payoff and chooses an action " + "from {Cooperate, Defect}." + ), + tags={"domain": "Alice"}, +) + +bob_decision = DecisionGame( + name="Bob Decision", + signature=Signature( + x=(port("Bob Observation"),), + y=(port("Bob Action"),), + r=(port("Bob Payoff"),), + s=(port("Bob Experience"),), + ), + logic=( + "Bob observes his previous round payoff and chooses an action " + "from {Cooperate, Defect}. Symmetric to Alice." + ), + tags={"domain": "Bob"}, +) + +payoff_computation = CovariantFunction( + name="Payoff Computation", + signature=Signature( + x=(port("Alice Action"), port("Bob Action")), + y=(port("Alice Payoff"), port("Bob Payoff")), + ), + logic=( + "Given both players' actions, look up the payoff matrix: " + "CC=(R,R), CD=(S,T), DC=(T,S), DD=(P,P) where " + f"R={R}, T={T}, S={S}, P={P}." + ), + tags={"domain": "Environment"}, +) + +# ====================================================================== +# Composition — build the game tree +# ====================================================================== + + +def build_game() -> FeedbackLoop: + """Build the Prisoner's Dilemma as an OGS composite game. + + Structure: (Alice | Bob) >> Payoff .feedback([payoff -> decisions]) + """ + decisions = ParallelComposition( + name="Simultaneous Decisions", + left=alice_decision, + right=bob_decision, + ) + + game_round = SequentialComposition( + name="Game Round", + first=decisions, + second=payoff_computation, + wiring=[ + Flow( + source_game=alice_decision, + source_port="Alice Action", + target_game=payoff_computation, + target_port="Alice Action", + ), + Flow( + source_game=bob_decision, + source_port="Bob Action", + target_game=payoff_computation, + target_port="Bob Action", + ), + ], + ) + + return FeedbackLoop( + name="Prisoner's Dilemma", + inner=game_round, + feedback_wiring=[ + FeedbackFlow( + source_game=payoff_computation, + source_port="Alice Payoff", + target_game=alice_decision, + target_port="Alice Payoff", + ), + FeedbackFlow( + source_game=payoff_computation, + source_port="Bob Payoff", + target_game=bob_decision, + target_port="Bob Payoff", + ), + ], + signature=Signature(), + ) + + +# ====================================================================== +# Pattern — top-level specification with metadata +# ====================================================================== + + +def build_pattern() -> Pattern: + """Build the complete OGS Pattern for Prisoner's Dilemma.""" + return Pattern( + name="Prisoners Dilemma Nash", + game=build_game(), + inputs=[ + PatternInput( + name="Payoff Matrix", + input_type=InputType.EXTERNAL_WORLD, + schema_hint=f"(R, T, S, P) = ({R}, {T}, {S}, {P})", + target_game="Payoff Computation", + flow_label="Payoff Matrix", + ), + ], + composition_type=CompositionType.FEEDBACK, + terminal_conditions=[ + TerminalCondition( + name="Mutual Cooperation", + actions={ + "Alice Decision": "Cooperate", + "Bob Decision": "Cooperate", + }, + outcome="Both players cooperate (Pareto optimal)", + description="Pareto optimum but not an equilibrium", + payoff_description=f"R={R} each", + ), + TerminalCondition( + name="Mutual Defection", + actions={ + "Alice Decision": "Defect", + "Bob Decision": "Defect", + }, + outcome="Both players defect (Nash equilibrium)", + description="Dominant strategy equilibrium — suboptimal", + payoff_description=f"P={P} each", + ), + TerminalCondition( + name="Alice Exploits", + actions={ + "Alice Decision": "Defect", + "Bob Decision": "Cooperate", + }, + outcome="Alice defects while Bob cooperates", + description="Alice gets temptation payoff, Bob gets sucker payoff", + payoff_description=f"T={T} for Alice, S={S} for Bob", + ), + TerminalCondition( + name="Bob Exploits", + actions={ + "Alice Decision": "Cooperate", + "Bob Decision": "Defect", + }, + outcome="Bob defects while Alice cooperates", + description="Bob gets temptation payoff, Alice gets sucker payoff", + payoff_description=f"T={T} for Bob, S={S} for Alice", + ), + ], + action_spaces=[ + ActionSpace(game="Alice Decision", actions=["Cooperate", "Defect"]), + ActionSpace(game="Bob Decision", actions=["Cooperate", "Defect"]), + ], + source="dsl", + ) + + +def build_ir() -> PatternIR: + """Compile the Pattern to OGS PatternIR.""" + return compile_to_ir(build_pattern()) + + +# ====================================================================== +# Payoff matrix construction from PatternIR +# ====================================================================== + + +def build_payoff_matrices( + ir: PatternIR, +) -> tuple[NDArray[np.float64], NDArray[np.float64]]: + """Construct payoff matrices from PatternIR metadata. + + Extracts player actions from action_spaces and payoff values from + terminal_conditions to build the bimatrix game representation. + + Args: + ir: Compiled PatternIR with action_spaces and terminal_conditions. + + Returns: + Tuple of (alice_payoffs, bob_payoffs) as 2D numpy arrays. + Rows = Alice's actions, Cols = Bob's actions. + """ + assert ir.action_spaces is not None, "PatternIR must have action_spaces" + assert ir.terminal_conditions is not None, "PatternIR must have terminal_conditions" + + players = {asp.game: asp.actions for asp in ir.action_spaces} + alice_actions = players["Alice Decision"] + bob_actions = players["Bob Decision"] + + n_alice = len(alice_actions) + n_bob = len(bob_actions) + alice_payoffs = np.zeros((n_alice, n_bob)) + bob_payoffs = np.zeros((n_alice, n_bob)) + + for tc in ir.terminal_conditions: + alice_action = tc.actions["Alice Decision"] + bob_action = tc.actions["Bob Decision"] + row = alice_actions.index(alice_action) + col = bob_actions.index(bob_action) + + a_pay, b_pay = _parse_payoff_description(tc.payoff_description) + alice_payoffs[row, col] = a_pay + bob_payoffs[row, col] = b_pay + + return alice_payoffs, bob_payoffs + + +def _parse_payoff_description(desc: str) -> tuple[float, float]: + """Parse payoff_description into (alice_payoff, bob_payoff). + + Handles two formats: + - "X=N each" -> (N, N) + - "X=N for Alice, Y=M for Bob" -> (N, M) + """ + each_match = re.match(r"[A-Z]=(\d+(?:\.\d+)?)\s+each", desc) + if each_match: + val = float(each_match.group(1)) + return val, val + + values: dict[str, float] = {} + for match in re.finditer(r"[A-Z]=(\d+(?:\.\d+)?)\s+for\s+(\w+)", desc): + val = float(match.group(1)) + player = match.group(2) + values[player] = val + + if "Alice" in values and "Bob" in values: + return values["Alice"], values["Bob"] + + raise ValueError(f"Cannot parse payoff_description: {desc!r}") + + +# ====================================================================== +# Nash equilibrium computation +# ====================================================================== + + +def compute_nash_equilibria( + ir: PatternIR, +) -> list[tuple[NDArray[np.float64], NDArray[np.float64]]]: + """Compute all Nash equilibria from a PatternIR using support enumeration. + + Args: + ir: Compiled PatternIR with action_spaces and terminal_conditions. + + Returns: + List of (alice_strategy, bob_strategy) tuples. Each strategy is a + probability distribution over actions. + """ + alice_payoffs, bob_payoffs = build_payoff_matrices(ir) + game = nash.Game(alice_payoffs, bob_payoffs) + return list(game.support_enumeration()) + + +# ====================================================================== +# Verification against declared terminal conditions +# ====================================================================== + + +def verify_terminal_conditions( + ir: PatternIR, + equilibria: list[tuple[NDArray[np.float64], NDArray[np.float64]]], +) -> dict: + """Cross-reference computed equilibria against declared TerminalConditions. + + Args: + ir: Compiled PatternIR. + equilibria: List of computed Nash equilibria. + + Returns: + Dict with declared_equilibria, computed_equilibria, matches, mismatches. + """ + assert ir.action_spaces is not None + assert ir.terminal_conditions is not None + + players = {asp.game: asp.actions for asp in ir.action_spaces} + alice_actions = players["Alice Decision"] + bob_actions = players["Bob Decision"] + + computed_profiles: list[dict[str, str]] = [] + for alice_strat, bob_strat in equilibria: + if _is_pure_strategy(alice_strat) and _is_pure_strategy(bob_strat): + alice_idx = int(np.argmax(alice_strat)) + bob_idx = int(np.argmax(bob_strat)) + computed_profiles.append( + { + "Alice Decision": alice_actions[alice_idx], + "Bob Decision": bob_actions[bob_idx], + } + ) + + declared_ne = [ + tc + for tc in ir.terminal_conditions + if "nash equilibrium" in tc.outcome.lower() + or "nash equilibrium" in tc.description.lower() + ] + + matches = [] + mismatches = [] + for tc in declared_ne: + if tc.actions in computed_profiles: + matches.append(tc) + else: + mismatches.append(tc) + + return { + "declared_equilibria": declared_ne, + "computed_equilibria": computed_profiles, + "matches": matches, + "mismatches": mismatches, + } + + +def _is_pure_strategy(strategy: NDArray[np.float64]) -> bool: + """Check if a strategy is pure (exactly one action with probability 1).""" + return bool(np.isclose(np.max(strategy), 1.0) and np.sum(strategy > 0.5) == 1) + + +# ====================================================================== +# Game analysis — dominance and Pareto optimality +# ====================================================================== + + +def analyze_game(ir: PatternIR) -> dict: + """Full game-theoretic analysis of a PatternIR. + + Computes payoff matrices, Nash equilibria, dominant strategies, + Pareto optimal outcomes, and verification against terminal conditions. + """ + assert ir.action_spaces is not None + assert ir.terminal_conditions is not None + + players = {asp.game: asp.actions for asp in ir.action_spaces} + alice_actions = players["Alice Decision"] + bob_actions = players["Bob Decision"] + + alice_payoffs, bob_payoffs = build_payoff_matrices(ir) + game = nash.Game(alice_payoffs, bob_payoffs) + equilibria = list(game.support_enumeration()) + + alice_dominant = _find_dominant_strategy(alice_payoffs, alice_actions) + bob_dominant = _find_dominant_strategy(bob_payoffs.T, bob_actions) + + pareto_optimal = _find_pareto_optimal( + alice_payoffs, bob_payoffs, alice_actions, bob_actions + ) + + verification = verify_terminal_conditions(ir, equilibria) + + return { + "players": players, + "alice_payoffs": alice_payoffs, + "bob_payoffs": bob_payoffs, + "equilibria": equilibria, + "alice_dominant_strategy": alice_dominant, + "bob_dominant_strategy": bob_dominant, + "pareto_optimal": pareto_optimal, + "verification": verification, + } + + +def _find_dominant_strategy( + payoff_matrix: NDArray[np.float64], actions: list[str] +) -> str | None: + """Find a strictly dominant strategy if one exists.""" + n = payoff_matrix.shape[0] + for i in range(n): + dominates_all = True + for j in range(n): + if i == j: + continue + if not np.all(payoff_matrix[i] > payoff_matrix[j]): + dominates_all = False + break + if dominates_all: + return actions[i] + return None + + +def _find_pareto_optimal( + alice_payoffs: NDArray[np.float64], + bob_payoffs: NDArray[np.float64], + alice_actions: list[str], + bob_actions: list[str], +) -> list[dict]: + """Find all Pareto optimal outcomes.""" + n_alice, n_bob = alice_payoffs.shape + outcomes = [] + for i in range(n_alice): + for j in range(n_bob): + outcomes.append( + { + "alice_action": alice_actions[i], + "bob_action": bob_actions[j], + "alice_payoff": alice_payoffs[i, j], + "bob_payoff": bob_payoffs[i, j], + } + ) + + pareto = [] + for outcome in outcomes: + dominated = False + for other in outcomes: + if other is outcome: + continue + at_least_as_good = ( + other["alice_payoff"] >= outcome["alice_payoff"] + and other["bob_payoff"] >= outcome["bob_payoff"] + ) + strictly_better = ( + other["alice_payoff"] > outcome["alice_payoff"] + or other["bob_payoff"] > outcome["bob_payoff"] + ) + if at_least_as_good and strictly_better: + dominated = True + break + if not dominated: + pareto.append(outcome) + + return pareto + + +# ====================================================================== +# Convenience entry points +# ====================================================================== + + +def run_analysis() -> dict: + """Run the full Nash equilibrium analysis on the Prisoner's Dilemma.""" + ir = build_ir() + return analyze_game(ir) diff --git a/packages/gds-examples/games/prisoners_dilemma_nash/test_model.py b/packages/gds-examples/games/prisoners_dilemma_nash/test_model.py new file mode 100644 index 0000000..ad840f5 --- /dev/null +++ b/packages/gds-examples/games/prisoners_dilemma_nash/test_model.py @@ -0,0 +1,268 @@ +"""Tests for Nash equilibrium computation on the Prisoner's Dilemma.""" + +import numpy as np + +from ogs.ir.models import PatternIR +from prisoners_dilemma_nash.model import ( + P, + R, + S, + T, + analyze_game, + build_ir, + build_payoff_matrices, + compute_nash_equilibria, + verify_terminal_conditions, +) + +# ====================================================================== +# TestPayoffMatrix — matrix construction from PatternIR +# ====================================================================== + + +class TestPayoffMatrix: + """Test payoff matrix extraction from PatternIR metadata.""" + + def setup_method(self): + self.ir = build_ir() + self.alice_payoffs, self.bob_payoffs = build_payoff_matrices(self.ir) + + def test_matrix_shape(self): + assert self.alice_payoffs.shape == (2, 2) + assert self.bob_payoffs.shape == (2, 2) + + def test_alice_payoffs_values(self): + """Alice's payoff matrix: rows=Alice actions, cols=Bob actions. + + (Cooperate, Cooperate) = R=3 + (Cooperate, Defect) = S=0 + (Defect, Cooperate) = T=5 + (Defect, Defect) = P=1 + """ + assert self.alice_payoffs[0, 0] == R # CC + assert self.alice_payoffs[0, 1] == S # CD + assert self.alice_payoffs[1, 0] == T # DC + assert self.alice_payoffs[1, 1] == P # DD + + def test_bob_payoffs_values(self): + """Bob's payoff matrix (symmetric game, transposed perspective). + + (Cooperate, Cooperate) = R=3 + (Cooperate, Defect) = T=5 (Bob defects, gets temptation) + (Defect, Cooperate) = S=0 (Bob cooperates while Alice defects) + (Defect, Defect) = P=1 + """ + assert self.bob_payoffs[0, 0] == R # CC + assert self.bob_payoffs[0, 1] == T # CD (Bob gets T) + assert self.bob_payoffs[1, 0] == S # DC (Bob gets S) + assert self.bob_payoffs[1, 1] == P # DD + + def test_payoff_ordering(self): + """Standard PD requires T > R > P > S.""" + assert T > R > P > S + + def test_cooperation_temptation(self): + """2R > T + S ensures mutual cooperation is socially optimal.""" + assert 2 * R > T + S + + +# ====================================================================== +# TestNashEquilibria — support enumeration finds correct NE +# ====================================================================== + + +class TestNashEquilibria: + """Test Nash equilibrium computation via Nashpy.""" + + def setup_method(self): + self.ir = build_ir() + self.equilibria = compute_nash_equilibria(self.ir) + + def test_exactly_one_equilibrium(self): + """PD has exactly one Nash equilibrium: (Defect, Defect).""" + assert len(self.equilibria) == 1 + + def test_equilibrium_is_pure_strategy(self): + """The NE is a pure strategy (no mixing).""" + alice_strat, bob_strat = self.equilibria[0] + assert np.isclose(np.max(alice_strat), 1.0) + assert np.isclose(np.max(bob_strat), 1.0) + + def test_equilibrium_is_defect_defect(self): + """Both players defect at equilibrium. + + Action order from action_spaces: [Cooperate, Defect] + So index 1 = Defect for both players. + """ + alice_strat, bob_strat = self.equilibria[0] + assert np.argmax(alice_strat) == 1 # Defect + assert np.argmax(bob_strat) == 1 # Defect + + +# ====================================================================== +# TestEquilibriumVerification — computed NE matches declared metadata +# ====================================================================== + + +class TestEquilibriumVerification: + """Test cross-referencing computed equilibria against TerminalConditions.""" + + def setup_method(self): + self.ir = build_ir() + self.equilibria = compute_nash_equilibria(self.ir) + self.verification = verify_terminal_conditions(self.ir, self.equilibria) + + def test_one_declared_equilibrium(self): + """Only 'Mutual Defection' is declared as Nash equilibrium.""" + assert len(self.verification["declared_equilibria"]) == 1 + assert self.verification["declared_equilibria"][0].name == "Mutual Defection" + + def test_one_computed_equilibrium(self): + """Nashpy finds exactly one pure-strategy NE.""" + assert len(self.verification["computed_equilibria"]) == 1 + + def test_computed_matches_declared(self): + """Computed NE matches the declared 'Mutual Defection' terminal condition.""" + assert len(self.verification["matches"]) == 1 + assert self.verification["matches"][0].name == "Mutual Defection" + + def test_no_mismatches(self): + """No declared equilibria fail to match computed ones.""" + assert len(self.verification["mismatches"]) == 0 + + +# ====================================================================== +# TestDominance — Defect is strictly dominant for both players +# ====================================================================== + + +class TestDominance: + """Test dominant strategy analysis.""" + + def setup_method(self): + self.ir = build_ir() + self.analysis = analyze_game(self.ir) + + def test_alice_dominant_strategy(self): + """Defect strictly dominates Cooperate for Alice.""" + assert self.analysis["alice_dominant_strategy"] == "Defect" + + def test_bob_dominant_strategy(self): + """Defect strictly dominates Cooperate for Bob.""" + assert self.analysis["bob_dominant_strategy"] == "Defect" + + +# ====================================================================== +# TestParetoOptimality — Mutual Cooperation is Pareto optimal +# ====================================================================== + + +class TestParetoOptimality: + """Test Pareto optimality analysis.""" + + def setup_method(self): + self.ir = build_ir() + self.analysis = analyze_game(self.ir) + self.pareto = self.analysis["pareto_optimal"] + + def test_mutual_cooperation_is_pareto_optimal(self): + """(Cooperate, Cooperate) with payoff (3,3) is Pareto optimal.""" + cc = [ + o + for o in self.pareto + if o["alice_action"] == "Cooperate" and o["bob_action"] == "Cooperate" + ] + assert len(cc) == 1 + + def test_mutual_defection_is_not_pareto_optimal(self): + """(Defect, Defect) with payoff (1,1) is Pareto dominated by (3,3).""" + dd = [ + o + for o in self.pareto + if o["alice_action"] == "Defect" and o["bob_action"] == "Defect" + ] + assert len(dd) == 0 + + def test_asymmetric_outcomes_are_pareto_optimal(self): + """(Defect, Cooperate) and (Cooperate, Defect) are Pareto optimal.""" + dc = [ + o + for o in self.pareto + if o["alice_action"] == "Defect" and o["bob_action"] == "Cooperate" + ] + cd = [ + o + for o in self.pareto + if o["alice_action"] == "Cooperate" and o["bob_action"] == "Defect" + ] + assert len(dc) == 1 + assert len(cd) == 1 + + def test_three_pareto_optimal_outcomes(self): + """PD has exactly 3 Pareto optimal outcomes (all except DD).""" + assert len(self.pareto) == 3 + + +# ====================================================================== +# TestIntegration — full pipeline from OGS primitives to Nash analysis +# ====================================================================== + + +class TestIntegration: + """Test the full pipeline: build_ir() -> compute -> verify.""" + + def test_full_pipeline(self): + """End-to-end: OGS primitives -> PatternIR -> Nash -> verify.""" + ir = build_ir() + assert isinstance(ir, PatternIR) + + equilibria = compute_nash_equilibria(ir) + assert len(equilibria) == 1 + + verification = verify_terminal_conditions(ir, equilibria) + assert len(verification["matches"]) == 1 + assert len(verification["mismatches"]) == 0 + + def test_analyze_game_returns_complete_results(self): + """analyze_game() returns all expected keys.""" + ir = build_ir() + result = analyze_game(ir) + + expected_keys = { + "players", + "alice_payoffs", + "bob_payoffs", + "equilibria", + "alice_dominant_strategy", + "bob_dominant_strategy", + "pareto_optimal", + "verification", + } + assert set(result.keys()) == expected_keys + + def test_dilemma_structure(self): + """The PD dilemma: NE is not Pareto optimal. + + This is the defining characteristic of the Prisoner's Dilemma: + the Nash equilibrium (Defect, Defect) is Pareto dominated by + (Cooperate, Cooperate). + """ + ir = build_ir() + result = analyze_game(ir) + + # NE is (Defect, Defect) + ne_profiles = result["verification"]["computed_equilibria"] + assert len(ne_profiles) == 1 + assert ne_profiles[0] == { + "Alice Decision": "Defect", + "Bob Decision": "Defect", + } + + # But (Defect, Defect) is NOT Pareto optimal + pareto_actions = [ + (o["alice_action"], o["bob_action"]) for o in result["pareto_optimal"] + ] + assert ("Defect", "Defect") not in pareto_actions + + # While (Cooperate, Cooperate) IS Pareto optimal + assert ("Cooperate", "Cooperate") in pareto_actions diff --git a/packages/gds-examples/guides/evolution_of_trust/notebook.py b/packages/gds-examples/guides/evolution_of_trust/notebook.py index 40b75a8..bb47475 100644 --- a/packages/gds-examples/guides/evolution_of_trust/notebook.py +++ b/packages/gds-examples/guides/evolution_of_trust/notebook.py @@ -388,7 +388,10 @@ def head_to_head_result( line=dict(color=_color_a, width=3), marker=dict(size=8, color=_color_a), fill="tozeroy", - fillcolor=_color_a + "20", + fillcolor=( + f"rgba({int(_color_a[1:3], 16)}, {int(_color_a[3:5], 16)}, " + f"{int(_color_a[5:7], 16)}, 0.13)" + ), ) ) _fig.add_trace( @@ -400,7 +403,10 @@ def head_to_head_result( line=dict(color=_color_b, width=3), marker=dict(size=8, color=_color_b), fill="tozeroy", - fillcolor=_color_b + "20", + fillcolor=( + f"rgba({int(_color_b[1:3], 16)}, {int(_color_b[3:5], 16)}, " + f"{int(_color_b[5:7], 16)}, 0.13)" + ), ) ) _fig.update_layout( @@ -637,7 +643,10 @@ def evolution_result( name=_name, line=dict(width=0.5, color=_c), stackgroup="one", - fillcolor=_c + "CC", + fillcolor=( + f"rgba({int(_c[1:3], 16)}, {int(_c[3:5], 16)}, " + f"{int(_c[5:7], 16)}, 0.8)" + ), hovertemplate=( f"{_name}
Gen %{{x}}: %{{y}} members" ), diff --git a/packages/gds-examples/guides/nash_equilibrium/__init__.py b/packages/gds-examples/guides/nash_equilibrium/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/gds-examples/guides/nash_equilibrium/conftest.py b/packages/gds-examples/guides/nash_equilibrium/conftest.py new file mode 100644 index 0000000..74269f3 --- /dev/null +++ b/packages/gds-examples/guides/nash_equilibrium/conftest.py @@ -0,0 +1,15 @@ +"""Path setup for Nash equilibrium guide tests. + +Adds the games/ directory to sys.path so that imports like +``from prisoners_dilemma_nash.model import ...`` resolve correctly. +""" + +import sys +from pathlib import Path + +_examples_root = Path(__file__).resolve().parent.parent.parent + +for subdir in ("games",): + path = str(_examples_root / subdir) + if path not in sys.path: + sys.path.insert(0, path) diff --git a/packages/gds-examples/guides/nash_equilibrium/notebook.py b/packages/gds-examples/guides/nash_equilibrium/notebook.py new file mode 100644 index 0000000..72a2984 --- /dev/null +++ b/packages/gds-examples/guides/nash_equilibrium/notebook.py @@ -0,0 +1,377 @@ +"""Nash Equilibrium in the Prisoner's Dilemma — Interactive Marimo Notebook. + +Demonstrates the full pipeline: OGS game structure -> payoff matrices -> +Nash equilibrium computation -> dominance and Pareto analysis. + +Run interactively: + uv run marimo edit guides/nash_equilibrium/notebook.py + +Run as read-only app: + uv run marimo run guides/nash_equilibrium/notebook.py +""" + +import marimo + +__generated_with = "0.20.2" +app = marimo.App(width="medium", app_title="Nash Equilibrium: Prisoner's Dilemma") + + +# ── Imports ────────────────────────────────────────────────── + + +@app.cell +def imports(): + import marimo as mo + + return (mo,) + + +# ── Model Imports & Path Setup ─────────────────────────────── + + +@app.cell +def model_setup(): + import sys + from pathlib import Path + + _examples_root = Path(__file__).resolve().parent.parent.parent + _games_path = str(_examples_root / "games") + if _games_path not in sys.path: + sys.path.insert(0, _games_path) + + from prisoners_dilemma_nash.model import ( + P, + R, + S, + T, + analyze_game, + build_ir, + build_payoff_matrices, + compute_nash_equilibria, + verify_terminal_conditions, + ) + + from ogs.viz import ( + architecture_by_domain_to_mermaid, + structural_to_mermaid, + terminal_conditions_to_mermaid, + ) + + ir = build_ir() + + return ( + R, + S, + T, + P, + analyze_game, + architecture_by_domain_to_mermaid, + build_payoff_matrices, + compute_nash_equilibria, + ir, + structural_to_mermaid, + terminal_conditions_to_mermaid, + verify_terminal_conditions, + ) + + +# ── Header ─────────────────────────────────────────────────── + + +@app.cell +def header(mo): + mo.md( + """ + # Nash Equilibrium: Prisoner's Dilemma + + The **Prisoner's Dilemma** is the canonical example of a game where + individually rational decisions lead to a collectively suboptimal outcome. + Two players simultaneously choose to **Cooperate** or **Defect**, and the + payoff structure creates a tension between self-interest and mutual benefit. + + This notebook walks through the full analysis pipeline: + + 1. **Game Structure** — the OGS composition tree and metadata + 2. **Payoff Matrices** — extracted from PatternIR terminal conditions + 3. **Nash Equilibria** — computed via Nashpy support enumeration + 4. **Game Analysis** — dominance, Pareto optimality, and the dilemma itself + """ + ) + return () + + +# ── Game Structure ─────────────────────────────────────────── + + +@app.cell +def game_structure( + mo, + ir, + structural_to_mermaid, + terminal_conditions_to_mermaid, + architecture_by_domain_to_mermaid, +): + _tabs = mo.ui.tabs( + { + "Structural": mo.vstack( + [ + mo.md( + "Full game topology: Alice and Bob make simultaneous " + "decisions, feeding into a payoff computation with " + "feedback loops carrying payoffs back to each player." + ), + mo.mermaid(structural_to_mermaid(ir)), + ] + ), + "Terminal Conditions": mo.vstack( + [ + mo.md( + "State diagram of all possible outcomes. Each terminal " + "state is an action profile with associated payoffs." + ), + mo.mermaid(terminal_conditions_to_mermaid(ir)), + ] + ), + "By Domain": mo.vstack( + [ + mo.md( + "Architecture grouped by domain: **Alice**, **Bob**, and " + "**Environment**. Shows the symmetric structure of the game." + ), + mo.mermaid(architecture_by_domain_to_mermaid(ir)), + ] + ), + } + ) + + mo.vstack( + [ + mo.md( + """\ +--- + +## Game Structure + +The Prisoner's Dilemma is built from OGS primitives: +two `DecisionGame` blocks (Alice, Bob) composed in parallel, +sequenced into a `CovariantFunction` (payoff computation), +with feedback loops carrying payoffs back to the decision nodes. + +``` +(Alice | Bob) >> Payoff .feedback([payoff -> decisions]) +``` +""" + ), + _tabs, + ] + ) + return () + + +# ── Payoff Matrices ────────────────────────────────────────── + + +@app.cell +def payoff_matrices(mo, ir, R, T, S, P, build_payoff_matrices): + _alice_payoffs, _bob_payoffs = build_payoff_matrices(ir) + + mo.vstack( + [ + mo.md( + f"""\ +--- + +## Payoff Matrices + +Extracted from PatternIR terminal conditions. The standard PD +parameters satisfy **T > R > P > S** and **2R > T + S**: + +| Parameter | Value | Meaning | +|-----------|-------|---------| +| R (Reward) | {R} | Mutual cooperation | +| T (Temptation) | {T} | Defect while other cooperates | +| S (Sucker) | {S} | Cooperate while other defects | +| P (Punishment) | {P} | Mutual defection | +""" + ), + mo.md( + "**Alice's Payoffs:**\n\n" + "| | Bob: Coop | Bob: Defect |\n" + "|---|---|---|\n" + f"| **Cooperate** | {_alice_payoffs[0, 0]:.0f} (R) " + f"| {_alice_payoffs[0, 1]:.0f} (S) |\n" + f"| **Defect** | {_alice_payoffs[1, 0]:.0f} (T) " + f"| {_alice_payoffs[1, 1]:.0f} (P) |\n\n" + "**Bob's Payoffs:**\n\n" + "| | Bob: Coop | Bob: Defect |\n" + "|---|---|---|\n" + f"| **Cooperate** | {_bob_payoffs[0, 0]:.0f} (R) " + f"| {_bob_payoffs[0, 1]:.0f} (T) |\n" + f"| **Defect** | {_bob_payoffs[1, 0]:.0f} (S) " + f"| {_bob_payoffs[1, 1]:.0f} (P) |" + ), + ] + ) + return () + + +# ── Nash Equilibria ────────────────────────────────────────── + + +@app.cell +def nash_equilibria(mo, ir, compute_nash_equilibria, verify_terminal_conditions): + import numpy as _np + + equilibria = compute_nash_equilibria(ir) + verification = verify_terminal_conditions(ir, equilibria) + + _actions = ["Cooperate", "Defect"] + _eq_lines = [] + for _i, (_alice_strat, _bob_strat) in enumerate(equilibria): + _alice_action = _actions[int(_np.argmax(_alice_strat))] + _bob_action = _actions[int(_np.argmax(_bob_strat))] + _eq_lines.append( + f"- **NE {_i + 1}:** Alice = {_alice_action}, Bob = {_bob_action}" + ) + + _match_lines = [] + for _m in verification["matches"]: + _match_lines.append(f"- **{_m.name}**: {_m.outcome}") + _mismatch_lines = [] + for _mm in verification["mismatches"]: + _mismatch_lines.append(f"- **{_mm.name}**: {_mm.outcome}") + + _match_text = "\n".join(_match_lines) if _match_lines else "- None" + _mismatch_text = "\n".join(_mismatch_lines) if _mismatch_lines else "- None" + + mo.vstack( + [ + mo.md( + f"""\ +--- + +## Nash Equilibria + +Computed via **Nashpy** support enumeration on the extracted +payoff matrices. + +### Computed Equilibria ({len(equilibria)} found) + +{"\\n".join(_eq_lines)} + +### Verification Against Declared Terminal Conditions + +Cross-referencing computed equilibria against the hand-annotated +terminal conditions in the OGS Pattern: + +**Matches** (declared NE confirmed by computation): + +{_match_text} + +**Mismatches** (declared NE not confirmed): + +{_mismatch_text} +""" + ), + ] + ) + return (equilibria,) + + +# ── Game Analysis ──────────────────────────────────────────── + + +@app.cell +def game_analysis(mo, ir, analyze_game): + analysis = analyze_game(ir) + + _alice_dom = analysis["alice_dominant_strategy"] + _bob_dom = analysis["bob_dominant_strategy"] + _pareto = analysis["pareto_optimal"] + + _pareto_rows = [] + for _o in _pareto: + _pareto_rows.append( + f"| {_o['alice_action']} | {_o['bob_action']} | " + f"{_o['alice_payoff']:.0f} | {_o['bob_payoff']:.0f} |" + ) + _pareto_table = "\n".join(_pareto_rows) + + mo.vstack( + [ + mo.md( + f"""\ +--- + +## Game Analysis + +### Dominant Strategies + +A strategy is **strictly dominant** if it yields a higher payoff +regardless of the opponent's choice. + +| Player | Dominant Strategy | +|--------|-------------------| +| Alice | **{_alice_dom or "None"}** | +| Bob | **{_bob_dom or "None"}** | + +**Defect** strictly dominates for both players: no matter +what the opponent does, defecting always yields a higher +payoff (T > R and P > S). + +### Pareto Optimal Outcomes ({len(_pareto)} of 4) + +An outcome is **Pareto optimal** if no other outcome makes one player +better off without making the other worse off. + +| Alice | Bob | Alice Payoff | Bob Payoff | +|-------|-----|-------------|------------| +{_pareto_table} + +The Nash equilibrium (Defect, Defect) with payoffs (P, P) = (1, 1) +is **not** Pareto optimal — both players could do better with +(Cooperate, Cooperate) yielding (R, R) = (3, 3). +""" + ), + ] + ) + return () + + +# ── The Dilemma ────────────────────────────────────────────── + + +@app.cell +def the_dilemma(mo): + mo.md( + """ + --- + + ## The Dilemma + + The Prisoner's Dilemma is defined by this tension: + + > **The unique Nash equilibrium is not Pareto optimal.** + + Each player's dominant strategy (Defect) leads to a collectively + worse outcome than mutual cooperation. This is the fundamental + problem of non-cooperative game theory: **individual rationality + does not imply collective rationality.** + + | Property | Outcome | + |----------|---------| + | Nash Equilibrium | (Defect, Defect) — payoff (1, 1) | + | Pareto Optimum | (Cooperate, Cooperate) — payoff (3, 3) | + | Dominant Strategy | Defect (for both players) | + + The OGS formalization makes this structure explicit: the game is + **stateless** (h = g, no mechanism layer), all computation lives + in the policy layer, and the feedback loop carries payoff + information — not state updates. + """ + ) + return () + + +if __name__ == "__main__": + app.run() diff --git a/packages/gds-examples/guides/rosetta/notebook.py b/packages/gds-examples/guides/rosetta/notebook.py index 01329c8..124f10d 100644 --- a/packages/gds-examples/guides/rosetta/notebook.py +++ b/packages/gds-examples/guides/rosetta/notebook.py @@ -2,7 +2,7 @@ Compares the same resource-pool scenario across three DSL views (Stock-Flow, Control, Game Theory), showing how they all map to -the GDS canonical form h = f . g. +the GDS canonical form. Run with: marimo run guides/rosetta/notebook.py """ diff --git a/packages/gds-framework/gds/canonical.py b/packages/gds-framework/gds/canonical.py index 85da07b..2a1712d 100644 --- a/packages/gds-framework/gds/canonical.py +++ b/packages/gds-framework/gds/canonical.py @@ -66,9 +66,22 @@ def has_parameters(self) -> bool: def formula(self) -> str: """Render as mathematical formula string.""" + has_f = len(self.mechanism_blocks) > 0 + has_g = len(self.policy_blocks) > 0 + + if has_f and has_g: + decomp = "f ∘ g" + elif has_g: + decomp = "g" + elif has_f: + decomp = "f" + else: + decomp = "id" + if self.has_parameters: - return "h_θ : X → X (h = f_θ ∘ g_θ, θ ∈ Θ)" - return "h : X → X (h = f ∘ g)" + decomp_theta = decomp.replace("f", "f_θ").replace("g", "g_θ") + return f"h_θ : X → X (h = {decomp_theta}, θ ∈ Θ)" + return f"h : X → X (h = {decomp})" def project_canonical(spec: GDSSpec) -> CanonicalGDS: diff --git a/packages/gds-framework/tests/test_v02_features.py b/packages/gds-framework/tests/test_v02_features.py index 44541b9..ee44d60 100644 --- a/packages/gds-framework/tests/test_v02_features.py +++ b/packages/gds-framework/tests/test_v02_features.py @@ -251,15 +251,46 @@ def test_frozen(self): with pytest.raises(ValidationError): c.state_variables = (("x", "y"),) # type: ignore[misc] - def test_formula_without_params(self): + def test_formula_empty(self): + """Empty canonical has h = id (no policy or mechanism blocks).""" c = CanonicalGDS() + assert c.formula() == "h : X → X (h = id)" + + def test_formula_full_decomposition(self): + """With both policy and mechanism blocks, formula is h = f ∘ g.""" + c = CanonicalGDS( + policy_blocks=("p1",), + mechanism_blocks=("m1",), + ) assert c.formula() == "h : X → X (h = f ∘ g)" + def test_formula_policy_only(self): + """With only policy blocks, formula is h = g (no f).""" + c = CanonicalGDS(policy_blocks=("p1",)) + assert c.formula() == "h : X → X (h = g)" + + def test_formula_mechanism_only(self): + """With only mechanism blocks, formula is h = f (no g).""" + c = CanonicalGDS(mechanism_blocks=("m1",)) + assert c.formula() == "h : X → X (h = f)" + def test_formula_with_params(self, float_type): schema = ParameterSchema().add(ParameterDef(name="a", typedef=float_type)) - c = CanonicalGDS(parameter_schema=schema) - assert "θ" in c.formula() - assert "Θ" in c.formula() + c = CanonicalGDS( + parameter_schema=schema, + policy_blocks=("p1",), + mechanism_blocks=("m1",), + ) + assert c.formula() == "h_θ : X → X (h = f_θ ∘ g_θ, θ ∈ Θ)" + + def test_formula_with_params_policy_only(self, float_type): + """Parameterized policy-only formula: h_θ = g_θ.""" + schema = ParameterSchema().add(ParameterDef(name="a", typedef=float_type)) + c = CanonicalGDS( + parameter_schema=schema, + policy_blocks=("p1",), + ) + assert c.formula() == "h_θ : X → X (h = g_θ, θ ∈ Θ)" def test_has_parameters(self, float_type): c_empty = CanonicalGDS() diff --git a/packages/gds-sim/README.md b/packages/gds-sim/README.md new file mode 100644 index 0000000..8c178bf --- /dev/null +++ b/packages/gds-sim/README.md @@ -0,0 +1,3 @@ +# gds-sim + +High-performance simulation engine for the GDS ecosystem. diff --git a/packages/gds-sim/gds_sim/__init__.py b/packages/gds-sim/gds_sim/__init__.py new file mode 100644 index 0000000..dd314cf --- /dev/null +++ b/packages/gds-sim/gds_sim/__init__.py @@ -0,0 +1,16 @@ +"""gds-sim: High-performance simulation engine for the GDS ecosystem.""" + +__version__ = "0.1.0" + +from gds_sim.model import Experiment, Model, Simulation +from gds_sim.results import Results +from gds_sim.types import Hooks, StateUpdateBlock + +__all__ = [ + "Experiment", + "Hooks", + "Model", + "Results", + "Simulation", + "StateUpdateBlock", +] diff --git a/packages/gds-sim/gds_sim/compat.py b/packages/gds-sim/gds_sim/compat.py new file mode 100644 index 0000000..16c9242 --- /dev/null +++ b/packages/gds-sim/gds_sim/compat.py @@ -0,0 +1,67 @@ +"""Auto-detect and wrap cadCAD-style function signatures. + +cadCAD policies have 4 positional args: + (params, substep, state_history, previous_state) -> Signal + +cadCAD state update functions have 5 positional args: + (params, substep, state_history, previous_state, policy_input) -> (key, val) + +gds-sim native signatures: + policy: (state, params, **kw) -> Signal + suf: (state, params, signal=, **kw) -> (key, val) + +Detection runs once at Model construction time — zero cost in the hot loop. +""" + +from __future__ import annotations + +import inspect +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from gds_sim.types import PolicyFn, Signal, SUFn + + +def adapt_policy(fn: PolicyFn) -> PolicyFn: + """Wrap a cadCAD 4-arg policy to the gds-sim signature, or pass through.""" + n = _positional_count(fn) + if n == 4: + + def _wrapped( + state: dict[str, Any], params: dict[str, Any], **kw: Any + ) -> Signal: + return fn(params, kw.get("substep", 0), [], state) + + return _wrapped + return fn + + +def adapt_suf(fn: SUFn) -> SUFn: + """Wrap a cadCAD 5-arg SUF to the gds-sim signature, or pass through.""" + n = _positional_count(fn) + if n == 5: + + def _wrapped( + state: dict[str, Any], + params: dict[str, Any], + *, + signal: dict[str, Any] | None = None, + **kw: Any, + ) -> tuple[str, Any]: + return fn(params, kw.get("substep", 0), [], state, signal or {}) + + return _wrapped + return fn + + +def _positional_count(fn: object) -> int: + """Count positional (POSITIONAL_ONLY + POSITIONAL_OR_KEYWORD) parameters.""" + try: + sig = inspect.signature(fn) # type: ignore[arg-type] + except (ValueError, TypeError): + return 0 + return sum( + 1 + for p in sig.parameters.values() + if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD) + ) diff --git a/packages/gds-sim/gds_sim/engine.py b/packages/gds-sim/gds_sim/engine.py new file mode 100644 index 0000000..6af55ab --- /dev/null +++ b/packages/gds-sim/gds_sim/engine.py @@ -0,0 +1,94 @@ +"""Core execution loop — the hot path. + +Every line in this module matters for performance. +No deepcopy, no function wrapping, no key checks at runtime. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from gds_sim.results import Results + +if TYPE_CHECKING: + from gds_sim.model import Simulation + from gds_sim.types import Params, StateUpdateBlock + + +def execute_simulation(sim: Simulation) -> Results: + """Execute a simulation across all param subsets and runs.""" + results = Results.preallocate(sim) + + for subset_idx, params in enumerate(sim.model._param_subsets): + for run_idx in range(sim.runs): + _execute_single_run(sim, results, params, subset_idx, run_idx) + + return results + + +def _execute_single_run( + sim: Simulation, + results: Results, + params: Params, + subset_idx: int, + run_idx: int, +) -> None: + """Execute a single (subset, run) trajectory.""" + state = dict(sim.model.initial_state) # shallow copy + blocks = sim.model.state_update_blocks + timesteps = sim.timesteps + hooks = sim.hooks + + if hooks.before_run: + hooks.before_run(state, params) + + results.append(state, timestep=0, substep=0, run=run_idx, subset=subset_idx) + + for t in range(1, timesteps + 1): + for s, block in enumerate(blocks): + signal = _execute_policies(block, state, params, t, s) + state = _execute_sufs(block, state, params, signal, t, s) + results.append( + state, timestep=t, substep=s + 1, run=run_idx, subset=subset_idx + ) + + if hooks.after_step and hooks.after_step(state, t) is False: + break + + if hooks.after_run: + hooks.after_run(state, params) + + +def _execute_policies( + block: StateUpdateBlock, + state: dict[str, Any], + params: Params, + t: int, + s: int, +) -> dict[str, Any]: + """Run all policies in a block, aggregating signals via dict.update.""" + policies = block.policies + if not policies: + return {} + signal: dict[str, Any] = {} + for fn in policies.values(): + result = fn(state, params, timestep=t, substep=s) + if result: + signal.update(result) + return signal + + +def _execute_sufs( + block: StateUpdateBlock, + state: dict[str, Any], + params: Params, + signal: dict[str, Any], + t: int, + s: int, +) -> dict[str, Any]: + """Run all SUFs in a block, producing a new state dict.""" + new_state = dict(state) # shallow copy ~10ns + for fn in block.variables.values(): + key, val = fn(state, params, signal=signal, timestep=t, substep=s) + new_state[key] = val + return new_state diff --git a/packages/gds-sim/gds_sim/model.py b/packages/gds-sim/gds_sim/model.py new file mode 100644 index 0000000..5457de6 --- /dev/null +++ b/packages/gds-sim/gds_sim/model.py @@ -0,0 +1,113 @@ +"""Model, Simulation, and Experiment configuration objects.""" + +from __future__ import annotations + +import itertools +from typing import Any, Literal, Self + +from pydantic import BaseModel, ConfigDict, model_validator + +from gds_sim.compat import adapt_policy, adapt_suf +from gds_sim.types import ( + Hooks, + Params, + StateUpdateBlock, +) + + +class Model(BaseModel): + """A simulation model: initial state, update blocks, and parameter space.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + initial_state: dict[str, Any] + state_update_blocks: list[StateUpdateBlock] + params: dict[str, list[Any]] = {} + + # Computed at validation time — not part of the public schema + _param_subsets: list[Params] + _state_keys: list[str] + + @model_validator(mode="before") + @classmethod + def _coerce_blocks(cls, data: Any) -> Any: + """Allow passing plain dicts instead of StateUpdateBlock instances.""" + if isinstance(data, dict) and "state_update_blocks" in data: + blocks = data["state_update_blocks"] + data["state_update_blocks"] = [ + StateUpdateBlock(**b) if isinstance(b, dict) else b for b in blocks + ] + return data + + @model_validator(mode="after") + def _validate_structure(self) -> Self: + # 1. Cache state keys + self._state_keys = list(self.initial_state.keys()) + state_key_set = set(self._state_keys) + + # 2. Verify all SUF keys exist in initial_state + for i, block in enumerate(self.state_update_blocks): + for var_key in block.variables: + if var_key not in state_key_set: + msg = ( + f"State update block {i} references variable " + f"'{var_key}' not found in initial_state. " + f"Available keys: {self._state_keys}" + ) + raise ValueError(msg) + + # 3. Adapt cadCAD-style function signatures + adapted_blocks: list[StateUpdateBlock] = [] + for block in self.state_update_blocks: + new_policies = {k: adapt_policy(fn) for k, fn in block.policies.items()} + new_variables = {k: adapt_suf(fn) for k, fn in block.variables.items()} + adapted_blocks.append( + StateUpdateBlock(policies=new_policies, variables=new_variables) + ) + self.state_update_blocks = adapted_blocks + + # 4. Expand parameter sweep (cartesian product) + if self.params: + keys = list(self.params.keys()) + values = [self.params[k] for k in keys] + self._param_subsets = [ + dict(zip(keys, combo, strict=True)) + for combo in itertools.product(*values) + ] + else: + self._param_subsets = [{}] + + return self + + +class Simulation(BaseModel): + """A runnable simulation: model + execution parameters.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + model: Model + timesteps: int = 100 + runs: int = 1 + history: int | Literal["full"] | None = None + hooks: Hooks = Hooks() + + def run(self) -> Any: + """Execute this simulation and return Results.""" + from gds_sim.engine import execute_simulation + + return execute_simulation(self) + + +class Experiment(BaseModel): + """A collection of simulations, optionally run in parallel.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + simulations: list[Simulation] + processes: int | None = None + + def run(self) -> Any: + """Execute all simulations and return merged Results.""" + from gds_sim.parallel import execute_experiment + + return execute_experiment(self) diff --git a/packages/gds-sim/gds_sim/parallel.py b/packages/gds-sim/gds_sim/parallel.py new file mode 100644 index 0000000..3732c42 --- /dev/null +++ b/packages/gds-sim/gds_sim/parallel.py @@ -0,0 +1,57 @@ +"""Multi-run parallelism via ProcessPoolExecutor.""" + +from __future__ import annotations + +from concurrent.futures import ProcessPoolExecutor +from typing import TYPE_CHECKING + +from gds_sim.engine import execute_simulation +from gds_sim.results import Results + +if TYPE_CHECKING: + from gds_sim.model import Experiment, Simulation + from gds_sim.types import Params + + +def execute_experiment(experiment: Experiment) -> Results: + """Execute all simulations in an experiment, optionally in parallel.""" + all_results: list[Results] = [] + + for sim in experiment.simulations: + n_subsets = len(sim.model._param_subsets) + total_jobs = n_subsets * sim.runs + + if total_jobs <= 1 or experiment.processes == 1: + all_results.append(execute_simulation(sim)) + else: + all_results.append(_parallel_simulation(sim, experiment.processes)) + + return Results.merge(all_results) + + +def _parallel_simulation(sim: Simulation, max_workers: int | None) -> Results: + """Run a simulation's (subset, run) pairs across processes.""" + jobs: list[tuple[Simulation, Params, int, int]] = [ + (sim, params, si, ri) + for si, params in enumerate(sim.model._param_subsets) + for ri in range(sim.runs) + ] + + partial_results: list[Results] = [] + with ProcessPoolExecutor(max_workers=max_workers) as pool: + futures = [pool.submit(_run_single, *job) for job in jobs] + for f in futures: + partial_results.append(f.result()) + + return Results.merge(partial_results) + + +def _run_single( + sim: Simulation, params: Params, subset_idx: int, run_idx: int +) -> Results: + """Execute a single (subset, run) pair — picklable top-level function.""" + from gds_sim.engine import _execute_single_run + + results = Results(list(sim.model._state_keys)) + _execute_single_run(sim, results, params, subset_idx, run_idx) + return results diff --git a/packages/gds-sim/gds_sim/py.typed b/packages/gds-sim/gds_sim/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/packages/gds-sim/gds_sim/results.py b/packages/gds-sim/gds_sim/results.py new file mode 100644 index 0000000..062a77b --- /dev/null +++ b/packages/gds-sim/gds_sim/results.py @@ -0,0 +1,148 @@ +"""Columnar result storage with optional DataFrame conversion.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from gds_sim.model import Simulation + +# Metadata column names +_META_COLS = ("timestep", "substep", "run", "subset") + + +class Results: + """Columnar dict-of-lists result storage. + + Pre-allocates capacity when the total row count is known, + then fills via ``append()``. Converts to pandas DataFrame + or list-of-dicts on demand. + """ + + __slots__ = ("_capacity", "_columns", "_size", "_state_keys") + + def __init__(self, state_keys: list[str], capacity: int = 0) -> None: + self._state_keys = state_keys + self._size = 0 + self._capacity = capacity + + # Build column storage: metadata + state variables + self._columns: dict[str, list[Any]] = {} + all_keys = list(_META_COLS) + state_keys + if capacity > 0: + for k in all_keys: + self._columns[k] = [None] * capacity + else: + for k in all_keys: + self._columns[k] = [] + + # ------------------------------------------------------------------ + # Factory + # ------------------------------------------------------------------ + + @classmethod + def preallocate(cls, sim: Simulation) -> Results: + """Create a Results instance pre-allocated for the given simulation.""" + n_subsets = len(sim.model._param_subsets) + n_blocks = len(sim.model.state_update_blocks) + # Row 0 (initial state) + timesteps * substeps, per run per subset + rows_per_run = 1 + sim.timesteps * max(n_blocks, 1) + capacity = rows_per_run * sim.runs * n_subsets + return cls(list(sim.model._state_keys), capacity) + + # ------------------------------------------------------------------ + # Append + # ------------------------------------------------------------------ + + def append( + self, + state: dict[str, Any], + *, + timestep: int, + substep: int, + run: int, + subset: int, + ) -> None: + """Append a single row (state snapshot + metadata).""" + cols = self._columns + idx = self._size + + if self._capacity > 0 and idx < self._capacity: + # Fast path: fill pre-allocated slots + cols["timestep"][idx] = timestep + cols["substep"][idx] = substep + cols["run"][idx] = run + cols["subset"][idx] = subset + for k in self._state_keys: + cols[k][idx] = state[k] + else: + # Fallback: dynamic append + cols["timestep"].append(timestep) + cols["substep"].append(substep) + cols["run"].append(run) + cols["subset"].append(subset) + for k in self._state_keys: + cols[k].append(state[k]) + + self._size += 1 + + # ------------------------------------------------------------------ + # Conversion + # ------------------------------------------------------------------ + + def to_dataframe(self) -> Any: + """Convert to pandas DataFrame. Requires ``pandas`` installed.""" + try: + import pandas as pd # type: ignore[import-untyped] + except ImportError as exc: # pragma: no cover + raise ImportError( + "pandas is required for to_dataframe(). " + "Install with: pip install gds-sim[pandas]" + ) from exc + + data = self._trimmed_columns() + return pd.DataFrame(data) + + def to_list(self) -> list[dict[str, Any]]: + """Convert to list of row-dicts (cadCAD-compatible format).""" + data = self._trimmed_columns() + keys = list(data.keys()) + n = self._size + return [{k: data[k][i] for k in keys} for i in range(n)] + + def _trimmed_columns(self) -> dict[str, list[Any]]: + """Return columns trimmed to actual size (handles pre-allocation).""" + if self._capacity > 0 and self._size < self._capacity: + return {k: v[: self._size] for k, v in self._columns.items()} + return self._columns + + # ------------------------------------------------------------------ + # Merge + # ------------------------------------------------------------------ + + @classmethod + def merge(cls, results_list: list[Results]) -> Results: + """Merge multiple Results into one.""" + if not results_list: + return cls([]) + if len(results_list) == 1: + return results_list[0] + + state_keys = results_list[0]._state_keys + total = sum(r._size for r in results_list) + merged = cls(state_keys, capacity=total) + + all_keys = list(_META_COLS) + state_keys + offset = 0 + for r in results_list: + trimmed = r._trimmed_columns() + n = r._size + for k in all_keys: + merged._columns[k][offset : offset + n] = trimmed[k] + offset += n + + merged._size = total + return merged + + def __len__(self) -> int: + return self._size diff --git a/packages/gds-sim/gds_sim/types.py b/packages/gds-sim/gds_sim/types.py new file mode 100644 index 0000000..37d97d3 --- /dev/null +++ b/packages/gds-sim/gds_sim/types.py @@ -0,0 +1,63 @@ +"""Type definitions for gds-sim.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict + +# --------------------------------------------------------------------------- +# Core type aliases +# --------------------------------------------------------------------------- + +State = dict[str, Any] +"""Runtime state — plain dict for speed.""" + +Signal = dict[str, Any] +"""Policy output signal dict.""" + +Params = dict[str, Any] +"""Parameter dict for a single subset.""" + +PolicyFn = Callable[..., Signal] +"""Policy function: (state, params, **kw) -> Signal.""" + +SUFn = Callable[..., tuple[str, Any]] +"""State update function: (state, params, signal=, **kw) -> (key, value).""" + +BeforeRunHook = Callable[[State, Params], None] +"""Called before a run starts: (initial_state, params) -> None.""" + +AfterRunHook = Callable[[State, Params], None] +"""Called after a run completes: (final_state, params) -> None.""" + +AfterStepHook = Callable[[State, int], bool | None] +"""Called after each timestep: (state, timestep) -> False to stop early.""" + +HistoryOption = int | Literal["full"] | None +"""State history window: None=off, int=last N, 'full'=all.""" + + +# --------------------------------------------------------------------------- +# Frozen config objects +# --------------------------------------------------------------------------- + + +class StateUpdateBlock(BaseModel): + """A partial state update block: policies produce signals, SUFs update state.""" + + model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True) + + policies: dict[str, PolicyFn] = {} + variables: dict[str, SUFn] + + +class Hooks(BaseModel): + """Lifecycle hooks for a simulation run.""" + + model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True) + + before_run: BeforeRunHook | None = None + after_run: AfterRunHook | None = None + after_step: AfterStepHook | None = None diff --git a/packages/gds-sim/pyproject.toml b/packages/gds-sim/pyproject.toml new file mode 100644 index 0000000..b8a5509 --- /dev/null +++ b/packages/gds-sim/pyproject.toml @@ -0,0 +1,82 @@ +[project] +name = "gds-sim" +dynamic = ["version"] +description = "High-performance simulation engine for the GDS ecosystem" +readme = "README.md" +license = "Apache-2.0" +requires-python = ">=3.12" +authors = [ + { name = "Rohan Mehta", email = "rohan@block.science" }, +] +keywords = [ + "generalized-dynamical-systems", + "simulation", + "cadcad", + "agent-based-modeling", + "gds-framework", +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Scientific/Engineering", + "Topic :: Scientific/Engineering :: Mathematics", + "Typing :: Typed", +] +dependencies = ["pydantic>=2.10"] + +[project.optional-dependencies] +pandas = ["pandas>=2.0"] + +[project.urls] +Homepage = "https://github.com/BlockScience/gds-core" +Repository = "https://github.com/BlockScience/gds-core" +Documentation = "https://blockscience.github.io/gds-core" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.version] +path = "gds_sim/__init__.py" + +[tool.hatch.build.targets.wheel] +packages = ["gds_sim"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "--import-mode=importlib --cov=gds_sim --cov-report=term-missing --no-header -q" + +[tool.coverage.run] +source = ["gds_sim"] +omit = ["gds_sim/__init__.py"] + +[tool.coverage.report] +fail_under = 80 +show_missing = true +exclude_lines = [ + "if TYPE_CHECKING:", + "pragma: no cover", +] + +[tool.mypy] +strict = true + +[tool.ruff] +target-version = "py312" +line-length = 88 + +[tool.ruff.lint] +select = ["E", "W", "F", "I", "UP", "B", "SIM", "TCH", "RUF"] + +[dependency-groups] +dev = [ + "mypy>=1.13", + "pytest>=8.0", + "pytest-cov>=5.0", + "ruff>=0.8", +] diff --git a/packages/gds-sim/tests/conftest.py b/packages/gds-sim/tests/conftest.py new file mode 100644 index 0000000..3f37615 --- /dev/null +++ b/packages/gds-sim/tests/conftest.py @@ -0,0 +1,81 @@ +"""Shared test fixtures.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +import gds_sim + +# ── Helper functions (new-style signatures) ────────────────────────── + + +def policy_growth( + state: dict[str, Any], params: dict[str, Any], **kw: Any +) -> dict[str, Any]: + return {"births": state["population"] * params.get("birth_rate", 0.03)} + + +def policy_death( + state: dict[str, Any], params: dict[str, Any], **kw: Any +) -> dict[str, Any]: + return {"deaths": state["population"] * params.get("death_rate", 0.01)} + + +def suf_population( + state: dict[str, Any], + params: dict[str, Any], + *, + signal: dict[str, Any] | None = None, + **kw: Any, +) -> tuple[str, Any]: + signal = signal or {} + births = signal.get("births", 0) + deaths = signal.get("deaths", 0) + return "population", state["population"] + births - deaths + + +def suf_food( + state: dict[str, Any], + params: dict[str, Any], + *, + signal: dict[str, Any] | None = None, + **kw: Any, +) -> tuple[str, Any]: + consumption = state["population"] * 0.001 + return "food", state["food"] - consumption + + +# ── Fixtures ───────────────────────────────────────────────────────── + + +@pytest.fixture +def simple_model() -> gds_sim.Model: + return gds_sim.Model( + initial_state={"population": 100.0, "food": 50.0}, + state_update_blocks=[ + { + "policies": {"growth": policy_growth, "death": policy_death}, + "variables": {"population": suf_population}, + }, + { + "policies": {}, + "variables": {"food": suf_food}, + }, + ], + ) + + +@pytest.fixture +def sweep_model() -> gds_sim.Model: + return gds_sim.Model( + initial_state={"population": 100.0, "food": 50.0}, + state_update_blocks=[ + { + "policies": {"growth": policy_growth}, + "variables": {"population": suf_population}, + }, + ], + params={"birth_rate": [0.03, 0.05], "death_rate": [0.01]}, + ) diff --git a/packages/gds-sim/tests/test_compat.py b/packages/gds-sim/tests/test_compat.py new file mode 100644 index 0000000..51246ee --- /dev/null +++ b/packages/gds-sim/tests/test_compat.py @@ -0,0 +1,110 @@ +"""Tests for cadCAD signature auto-detection and wrapping.""" + +from __future__ import annotations + +from typing import Any + +import gds_sim +from gds_sim.compat import _positional_count, adapt_policy, adapt_suf + +# ── cadCAD-style functions (4-arg policy, 5-arg SUF) ───────────────── + + +def cadcad_policy( + params: dict[str, Any], + substep: int, + state_history: list[Any], + previous_state: dict[str, Any], +) -> dict[str, Any]: + return {"step_size": params.get("rate", 1)} + + +def cadcad_suf( + params: dict[str, Any], + substep: int, + state_history: list[Any], + previous_state: dict[str, Any], + policy_input: dict[str, Any], +) -> tuple[str, Any]: + return "a", previous_state["a"] + policy_input.get("step_size", 0) + + +# ── New-style functions ────────────────────────────────────────────── + + +def new_policy( + state: dict[str, Any], params: dict[str, Any], **kw: Any +) -> dict[str, Any]: + return {"step_size": params.get("rate", 1)} + + +def new_suf( + state: dict[str, Any], + params: dict[str, Any], + *, + signal: dict[str, Any] | None = None, + **kw: Any, +) -> tuple[str, Any]: + signal = signal or {} + return "a", state["a"] + signal.get("step_size", 0) + + +class TestPositionalCount: + def test_four_arg(self) -> None: + assert _positional_count(cadcad_policy) == 4 + + def test_five_arg(self) -> None: + assert _positional_count(cadcad_suf) == 5 + + def test_two_arg_new_policy(self) -> None: + assert _positional_count(new_policy) == 2 + + def test_two_arg_new_suf(self) -> None: + assert _positional_count(new_suf) == 2 + + +class TestAdaptPolicy: + def test_wraps_cadcad_policy(self) -> None: + adapted = adapt_policy(cadcad_policy) + result = adapted({"a": 1}, {"rate": 5}, timestep=1, substep=0) + assert result == {"step_size": 5} + + def test_passes_through_new_policy(self) -> None: + adapted = adapt_policy(new_policy) + assert adapted is new_policy + + +class TestAdaptSuf: + def test_wraps_cadcad_suf(self) -> None: + adapted = adapt_suf(cadcad_suf) + key, val = adapted( + {"a": 10}, {}, signal={"step_size": 3}, timestep=1, substep=0 + ) + assert key == "a" + assert val == 13 + + def test_passes_through_new_suf(self) -> None: + adapted = adapt_suf(new_suf) + assert adapted is new_suf + + +class TestEndToEnd: + def test_cadcad_functions_in_model(self) -> None: + """cadCAD-style functions should work seamlessly in a Model.""" + model = gds_sim.Model( + initial_state={"a": 1.0, "b": 2.0}, + state_update_blocks=[ + {"policies": {"p": cadcad_policy}, "variables": {"a": cadcad_suf}}, + ], + params={"rate": [1, 2]}, + ) + sim = gds_sim.Simulation(model=model, timesteps=5) + rows = sim.run().to_list() + + # Subset 0: rate=1, a increments by 1 each step + subset0_final = next(r for r in rows if r["subset"] == 0 and r["timestep"] == 5) + assert subset0_final["a"] == 6.0 # 1 + 5*1 + + # Subset 1: rate=2, a increments by 2 each step + subset1_final = next(r for r in rows if r["subset"] == 1 and r["timestep"] == 5) + assert subset1_final["a"] == 11.0 # 1 + 5*2 diff --git a/packages/gds-sim/tests/test_engine.py b/packages/gds-sim/tests/test_engine.py new file mode 100644 index 0000000..7164c4d --- /dev/null +++ b/packages/gds-sim/tests/test_engine.py @@ -0,0 +1,169 @@ +"""Tests for the core execution engine.""" + +from __future__ import annotations + +from typing import Any + +import gds_sim + + +class TestSingleRun: + def test_basic_execution(self, simple_model: gds_sim.Model) -> None: + sim = gds_sim.Simulation(model=simple_model, timesteps=10) + results = sim.run() + # 2 blocks → 2 substeps per timestep, plus initial row + # rows = 1 + 10 * 2 = 21 + assert len(results) == 21 + + def test_initial_state_preserved(self, simple_model: gds_sim.Model) -> None: + sim = gds_sim.Simulation(model=simple_model, timesteps=5) + results = sim.run() + rows = results.to_list() + assert rows[0]["timestep"] == 0 + assert rows[0]["substep"] == 0 + assert rows[0]["population"] == 100.0 + assert rows[0]["food"] == 50.0 + + def test_population_grows(self, simple_model: gds_sim.Model) -> None: + sim = gds_sim.Simulation(model=simple_model, timesteps=10) + rows = sim.run().to_list() + # Population should increase (birth_rate > death_rate by default) + initial_pop = rows[0]["population"] + # Get the last substep of the last timestep (block 1 updates population) + final_pop = next(r for r in rows if r["timestep"] == 10 and r["substep"] == 1)[ + "population" + ] + assert final_pop > initial_pop + + def test_food_decreases(self, simple_model: gds_sim.Model) -> None: + sim = gds_sim.Simulation(model=simple_model, timesteps=10) + rows = sim.run().to_list() + initial_food = rows[0]["food"] + final_food = next(r for r in rows if r["timestep"] == 10 and r["substep"] == 2)[ + "food" + ] + assert final_food < initial_food + + def test_metadata_columns(self, simple_model: gds_sim.Model) -> None: + sim = gds_sim.Simulation(model=simple_model, timesteps=3) + rows = sim.run().to_list() + for row in rows: + assert "timestep" in row + assert "substep" in row + assert "run" in row + assert "subset" in row + + +class TestMultiSubset: + def test_param_sweep_runs_all_subsets(self, sweep_model: gds_sim.Model) -> None: + sim = gds_sim.Simulation(model=sweep_model, timesteps=5) + rows = sim.run().to_list() + subsets = {r["subset"] for r in rows} + assert subsets == {0, 1} + + def test_param_sweep_different_results(self, sweep_model: gds_sim.Model) -> None: + sim = gds_sim.Simulation(model=sweep_model, timesteps=10) + rows = sim.run().to_list() + # Get final population for each subset (different birth rates) + final_subset_0 = next( + r + for r in rows + if r["timestep"] == 10 and r["substep"] == 1 and r["subset"] == 0 + )["population"] + final_subset_1 = next( + r + for r in rows + if r["timestep"] == 10 and r["substep"] == 1 and r["subset"] == 1 + )["population"] + # birth_rate=0.05 should grow faster than birth_rate=0.03 + assert final_subset_1 > final_subset_0 + + +class TestMultiRun: + def test_multiple_runs(self, simple_model: gds_sim.Model) -> None: + sim = gds_sim.Simulation(model=simple_model, timesteps=5, runs=3) + rows = sim.run().to_list() + runs = {r["run"] for r in rows} + assert runs == {0, 1, 2} + + def test_runs_are_independent(self, simple_model: gds_sim.Model) -> None: + sim = gds_sim.Simulation(model=simple_model, timesteps=5, runs=2) + rows = sim.run().to_list() + # Each run starts from the same initial state + run0_initial = next(r for r in rows if r["run"] == 0 and r["timestep"] == 0) + run1_initial = next(r for r in rows if r["run"] == 1 and r["timestep"] == 0) + assert run0_initial["population"] == run1_initial["population"] + + +class TestHooks: + def test_before_run_hook(self, simple_model: gds_sim.Model) -> None: + called: list[bool] = [] + + def before_run(state: dict[str, Any], params: dict[str, Any]) -> None: + called.append(True) + + hooks = gds_sim.Hooks(before_run=before_run) + sim = gds_sim.Simulation(model=simple_model, timesteps=5, hooks=hooks) + sim.run() + assert len(called) == 1 + + def test_after_run_hook(self, simple_model: gds_sim.Model) -> None: + final_states: list[dict[str, Any]] = [] + + def after_run(state: dict[str, Any], params: dict[str, Any]) -> None: + final_states.append(dict(state)) + + hooks = gds_sim.Hooks(after_run=after_run) + sim = gds_sim.Simulation(model=simple_model, timesteps=5, hooks=hooks) + sim.run() + assert len(final_states) == 1 + assert final_states[0]["population"] > 100.0 + + def test_after_step_hook(self, simple_model: gds_sim.Model) -> None: + step_count: list[int] = [] + + def after_step(state: dict[str, Any], t: int) -> None: + step_count.append(t) + + hooks = gds_sim.Hooks(after_step=after_step) + sim = gds_sim.Simulation(model=simple_model, timesteps=10, hooks=hooks) + sim.run() + assert step_count == list(range(1, 11)) + + def test_early_exit(self) -> None: + def suf_x( + state: dict[str, Any], params: dict[str, Any], **kw: Any + ) -> tuple[str, Any]: + return "x", state["x"] + 1 + + def stop_at_5(state: dict[str, Any], t: int) -> bool | None: + if state["x"] >= 5: + return False + return None + + model = gds_sim.Model( + initial_state={"x": 0}, + state_update_blocks=[{"policies": {}, "variables": {"x": suf_x}}], + ) + hooks = gds_sim.Hooks(after_step=stop_at_5) + sim = gds_sim.Simulation(model=model, timesteps=100, hooks=hooks) + rows = sim.run().to_list() + max_x = max(r["x"] for r in rows) + assert max_x == 5 # stopped at 5, not 100 + + +class TestNoPolicy: + def test_block_without_policies(self) -> None: + def suf_x( + state: dict[str, Any], params: dict[str, Any], **kw: Any + ) -> tuple[str, Any]: + return "x", state["x"] + 1 + + model = gds_sim.Model( + initial_state={"x": 0}, + state_update_blocks=[{"policies": {}, "variables": {"x": suf_x}}], + ) + sim = gds_sim.Simulation(model=model, timesteps=5) + rows = sim.run().to_list() + final = rows[-1] + assert final["x"] == 5 diff --git a/packages/gds-sim/tests/test_model.py b/packages/gds-sim/tests/test_model.py new file mode 100644 index 0000000..a7d976e --- /dev/null +++ b/packages/gds-sim/tests/test_model.py @@ -0,0 +1,80 @@ +"""Tests for Model validation, param sweep expansion, and error handling.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +import gds_sim + + +def _noop_suf( + state: dict[str, Any], params: dict[str, Any], **kw: Any +) -> tuple[str, Any]: + return "x", state["x"] + + +class TestModelValidation: + def test_basic_construction(self, simple_model: gds_sim.Model) -> None: + assert simple_model._state_keys == ["population", "food"] + assert len(simple_model.state_update_blocks) == 2 + assert len(simple_model._param_subsets) == 1 # no params → single empty subset + + def test_invalid_suf_key_rejected(self) -> None: + with pytest.raises(ValueError, match="not found in initial_state"): + gds_sim.Model( + initial_state={"x": 1}, + state_update_blocks=[ + {"policies": {}, "variables": {"missing": _noop_suf}} + ], + ) + + def test_param_sweep_expansion(self, sweep_model: gds_sim.Model) -> None: + # birth_rate=[0.03, 0.05] x death_rate=[0.01] -> 2 subsets + assert len(sweep_model._param_subsets) == 2 + assert sweep_model._param_subsets[0] == {"birth_rate": 0.03, "death_rate": 0.01} + assert sweep_model._param_subsets[1] == {"birth_rate": 0.05, "death_rate": 0.01} + + def test_param_sweep_cartesian(self) -> None: + model = gds_sim.Model( + initial_state={"x": 0}, + state_update_blocks=[{"policies": {}, "variables": {"x": _noop_suf}}], + params={"a": [1, 2], "b": [10, 20]}, + ) + assert len(model._param_subsets) == 4 # 2 x 2 + + def test_empty_params_single_subset(self) -> None: + model = gds_sim.Model( + initial_state={"x": 0}, + state_update_blocks=[{"policies": {}, "variables": {"x": _noop_suf}}], + ) + assert model._param_subsets == [{}] + + def test_dict_blocks_coerced(self) -> None: + model = gds_sim.Model( + initial_state={"x": 0}, + state_update_blocks=[{"policies": {}, "variables": {"x": _noop_suf}}], + ) + assert isinstance(model.state_update_blocks[0], gds_sim.StateUpdateBlock) + + +class TestSimulation: + def test_defaults(self, simple_model: gds_sim.Model) -> None: + sim = gds_sim.Simulation(model=simple_model) + assert sim.timesteps == 100 + assert sim.runs == 1 + assert sim.history is None + + def test_custom_timesteps(self, simple_model: gds_sim.Model) -> None: + sim = gds_sim.Simulation(model=simple_model, timesteps=500, runs=3) + assert sim.timesteps == 500 + assert sim.runs == 3 + + +class TestExperiment: + def test_construction(self, simple_model: gds_sim.Model) -> None: + sim = gds_sim.Simulation(model=simple_model, timesteps=10) + exp = gds_sim.Experiment(simulations=[sim]) + assert len(exp.simulations) == 1 + assert exp.processes is None diff --git a/packages/gds-sim/tests/test_parallel.py b/packages/gds-sim/tests/test_parallel.py new file mode 100644 index 0000000..4aaa25f --- /dev/null +++ b/packages/gds-sim/tests/test_parallel.py @@ -0,0 +1,89 @@ +"""Tests for multi-process execution.""" + +from __future__ import annotations + +from typing import Any + +import gds_sim + + +def _policy(state: dict[str, Any], params: dict[str, Any], **kw: Any) -> dict[str, Any]: + return {"delta": params.get("rate", 1)} + + +def _suf( + state: dict[str, Any], + params: dict[str, Any], + *, + signal: dict[str, Any] | None = None, + **kw: Any, +) -> tuple[str, Any]: + signal = signal or {} + return "x", state["x"] + signal.get("delta", 0) + + +class TestExperimentExecution: + def test_single_process(self) -> None: + model = gds_sim.Model( + initial_state={"x": 0.0}, + state_update_blocks=[ + {"policies": {"p": _policy}, "variables": {"x": _suf}} + ], + params={"rate": [1, 2]}, + ) + sim = gds_sim.Simulation(model=model, timesteps=10, runs=1) + exp = gds_sim.Experiment(simulations=[sim], processes=1) + results = exp.run() + rows = results.to_list() + subsets = {r["subset"] for r in rows} + assert subsets == {0, 1} + + def test_matches_sequential(self) -> None: + """Parallel results should match single-process results.""" + model = gds_sim.Model( + initial_state={"x": 0.0}, + state_update_blocks=[ + {"policies": {"p": _policy}, "variables": {"x": _suf}} + ], + params={"rate": [1, 3]}, + ) + sim_seq = gds_sim.Simulation(model=model, timesteps=20, runs=2) + sim_par = gds_sim.Simulation(model=model, timesteps=20, runs=2) + + exp_seq = gds_sim.Experiment(simulations=[sim_seq], processes=1) + exp_par = gds_sim.Experiment(simulations=[sim_par], processes=2) + + rows_seq = exp_seq.run().to_list() + rows_par = exp_par.run().to_list() + + # Same number of rows + assert len(rows_seq) == len(rows_par) + + # Sort both by (subset, run, timestep, substep) for comparison + def sort_key(r: dict[str, Any]) -> tuple[int, ...]: + return (r["subset"], r["run"], r["timestep"], r["substep"]) + + rows_seq.sort(key=sort_key) + rows_par.sort(key=sort_key) + + for s, p in zip(rows_seq, rows_par, strict=True): + assert s["x"] == p["x"], ( + f"Mismatch at t={s['timestep']}: {s['x']} != {p['x']}" + ) + assert s["subset"] == p["subset"] + assert s["run"] == p["run"] + + def test_multiple_simulations(self) -> None: + model1 = gds_sim.Model( + initial_state={"x": 0.0}, + state_update_blocks=[{"policies": {}, "variables": {"x": _suf}}], + ) + model2 = gds_sim.Model( + initial_state={"x": 100.0}, + state_update_blocks=[{"policies": {}, "variables": {"x": _suf}}], + ) + sim1 = gds_sim.Simulation(model=model1, timesteps=5) + sim2 = gds_sim.Simulation(model=model2, timesteps=5) + exp = gds_sim.Experiment(simulations=[sim1, sim2], processes=1) + results = exp.run() + assert len(results) == 12 # (1 + 5) * 2 diff --git a/packages/gds-sim/tests/test_results.py b/packages/gds-sim/tests/test_results.py new file mode 100644 index 0000000..db83595 --- /dev/null +++ b/packages/gds-sim/tests/test_results.py @@ -0,0 +1,84 @@ +"""Tests for columnar result storage.""" + +from __future__ import annotations + +import gds_sim + + +class TestResults: + def test_append_and_len(self) -> None: + r = gds_sim.Results(["x", "y"]) + r.append({"x": 1, "y": 2}, timestep=0, substep=0, run=0, subset=0) + r.append({"x": 3, "y": 4}, timestep=1, substep=1, run=0, subset=0) + assert len(r) == 2 + + def test_to_list(self) -> None: + r = gds_sim.Results(["x"]) + r.append({"x": 10}, timestep=0, substep=0, run=0, subset=0) + r.append({"x": 20}, timestep=1, substep=1, run=0, subset=0) + rows = r.to_list() + assert len(rows) == 2 + assert rows[0] == {"timestep": 0, "substep": 0, "run": 0, "subset": 0, "x": 10} + assert rows[1] == {"timestep": 1, "substep": 1, "run": 0, "subset": 0, "x": 20} + + def test_preallocated(self) -> None: + r = gds_sim.Results(["a", "b"], capacity=5) + for i in range(3): + r.append({"a": i, "b": i * 10}, timestep=i, substep=0, run=0, subset=0) + assert len(r) == 3 + rows = r.to_list() + assert len(rows) == 3 + assert rows[2]["a"] == 2 + + def test_preallocated_overflow_to_append(self) -> None: + r = gds_sim.Results(["x"], capacity=2) + for i in range(4): + r.append({"x": i}, timestep=i, substep=0, run=0, subset=0) + assert len(r) == 4 + + def test_to_dataframe(self) -> None: + import pandas as pd + + r = gds_sim.Results(["val"]) + r.append({"val": 100}, timestep=0, substep=0, run=0, subset=0) + r.append({"val": 200}, timestep=1, substep=1, run=0, subset=0) + df = r.to_dataframe() + assert isinstance(df, pd.DataFrame) + assert len(df) == 2 + assert list(df.columns) == ["timestep", "substep", "run", "subset", "val"] + assert df["val"].tolist() == [100, 200] + + def test_merge(self) -> None: + r1 = gds_sim.Results(["x"]) + r1.append({"x": 1}, timestep=0, substep=0, run=0, subset=0) + + r2 = gds_sim.Results(["x"]) + r2.append({"x": 2}, timestep=0, substep=0, run=1, subset=0) + + merged = gds_sim.Results.merge([r1, r2]) + assert len(merged) == 2 + rows = merged.to_list() + assert rows[0]["x"] == 1 + assert rows[1]["x"] == 2 + + def test_merge_empty(self) -> None: + merged = gds_sim.Results.merge([]) + assert len(merged) == 0 + + def test_merge_single(self) -> None: + r = gds_sim.Results(["x"]) + r.append({"x": 42}, timestep=0, substep=0, run=0, subset=0) + merged = gds_sim.Results.merge([r]) + assert merged is r + + +class TestResultsFromSimulation: + def test_to_dataframe_from_sim(self, simple_model: gds_sim.Model) -> None: + import pandas as pd + + sim = gds_sim.Simulation(model=simple_model, timesteps=5) + df = sim.run().to_dataframe() + assert isinstance(df, pd.DataFrame) + assert "population" in df.columns + assert "food" in df.columns + assert "timestep" in df.columns diff --git a/packages/gds-stockflow/tests/test_canonical_stress.py b/packages/gds-stockflow/tests/test_canonical_stress.py index 93e737f..dd8d5ab 100644 --- a/packages/gds-stockflow/tests/test_canonical_stress.py +++ b/packages/gds-stockflow/tests/test_canonical_stress.py @@ -378,7 +378,7 @@ def test_f_still_exists(self, canonical): assert len(canonical.mechanism_blocks) == 1 def test_formula_is_simple(self, canonical): - assert canonical.formula() == "h : X → X (h = f ∘ g)" + assert canonical.formula() == "h : X → X (h = f)" # ═══════════════════════════════════════════════════════════════ diff --git a/pyproject.toml b/pyproject.toml index 273eeab..dfd7af9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ "gds-software>=0.1.0", "gds-business>=0.1.0", "gds-examples>=0.1.0", + "gds-sim>=0.1.0", ] [project.urls] @@ -56,6 +57,7 @@ gds-control = { workspace = true } gds-software = { workspace = true } gds-business = { workspace = true } gds-examples = { workspace = true } +gds-sim = { workspace = true } [tool.uv.workspace] members = ["packages/*"] @@ -72,7 +74,7 @@ select = ["E", "W", "F", "I", "UP", "B", "SIM", "TCH", "RUF"] "packages/gds-examples/prisoners_dilemma/visualize.py" = ["E501"] [tool.ruff.lint.isort] -known-first-party = ["gds", "gds_viz", "ogs", "stockflow", "gds_control", "gds_software", "gds_business"] +known-first-party = ["gds", "gds_viz", "ogs", "stockflow", "gds_control", "gds_software", "gds_business", "gds_sim"] [dependency-groups] docs = [ @@ -80,4 +82,5 @@ docs = [ "mkdocs-material>=9.5", "mkdocstrings[python]>=0.27", "mkdocs-llmstxt>=0.5", + "mkdocs-marimo>=0.2", ]