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",
]