diff --git a/docs/business/getting-started.md b/docs/business/getting-started.md index b9b1766..ebf0cf9 100644 --- a/docs/business/getting-started.md +++ b/docs/business/getting-started.md @@ -68,11 +68,11 @@ model = SupplyChainModel( SupplyNode(name="Retailer", initial_inventory=100), ], shipments=[ - Shipment(name="F->D", source_node="Factory", target_node="Distributor"), - Shipment(name="D->R", source_node="Distributor", target_node="Retailer"), + Shipment(name="F->D", source="Factory", target="Distributor"), + Shipment(name="D->R", source="Distributor", target="Retailer"), ], demand_sources=[ - DemandSource(name="Customer", target_node="Retailer"), + DemandSource(name="Customer", target="Retailer"), ], order_policies=[ OrderPolicy(name="Retailer Policy", node="Retailer", inputs=["Retailer"]), diff --git a/docs/business/guide/diagram-types.md b/docs/business/guide/diagram-types.md index 33d9f42..83ebbb4 100644 --- a/docs/business/guide/diagram-types.md +++ b/docs/business/guide/diagram-types.md @@ -103,10 +103,10 @@ model = SupplyChainModel( SupplyNode(name="Retail", initial_inventory=50), ], shipments=[ - Shipment(name="W->R", source_node="Warehouse", target_node="Retail", lead_time=2.0), + Shipment(name="W->R", source="Warehouse", target="Retail", lead_time=2.0), ], demand_sources=[ - DemandSource(name="Customer Demand", target_node="Retail"), + DemandSource(name="Customer Demand", target="Retail"), ], order_policies=[ OrderPolicy(name="Retail Reorder", node="Retail", inputs=["Retail"]), diff --git a/docs/business/guide/verification.md b/docs/business/guide/verification.md index d11b226..80aa31c 100644 --- a/docs/business/guide/verification.md +++ b/docs/business/guide/verification.md @@ -63,8 +63,8 @@ Self-loops (a variable causing itself) are structurally invalid: | ID | Name | Severity | What it checks | |----|------|----------|----------------| | SCN-001 | Network connectivity | WARNING | All nodes reachable via BFS from demand/supply paths | -| SCN-002 | Shipment node validity | ERROR | source_node and target_node exist | -| SCN-003 | Demand target validity | ERROR | target_node exists | +| SCN-002 | Shipment node validity | ERROR | source and target exist | +| SCN-003 | Demand target validity | ERROR | target exists | | SCN-004 | No orphan nodes | WARNING | Every node in at least one shipment or demand | ### SCN-001: Network Connectivity diff --git a/docs/business/index.md b/docs/business/index.md index 04a2058..1fecf79 100644 --- a/docs/business/index.md +++ b/docs/business/index.md @@ -78,10 +78,10 @@ scn = SupplyChainModel( SupplyNode(name="Retailer", initial_inventory=100), ], shipments=[ - Shipment(name="F->R", source_node="Factory", target_node="Retailer"), + Shipment(name="F->R", source="Factory", target="Retailer"), ], demand_sources=[ - DemandSource(name="Customer", target_node="Retailer"), + DemandSource(name="Customer", target="Retailer"), ], order_policies=[ OrderPolicy(name="Reorder", node="Retailer", inputs=["Retailer"]), diff --git a/docs/framework/ecosystem.md b/docs/framework/ecosystem.md index d81aa71..c2f0451 100644 --- a/docs/framework/ecosystem.md +++ b/docs/framework/ecosystem.md @@ -4,12 +4,17 @@ The GDS ecosystem is a family of composable packages for specifying, visualizing ## Packages -| Package | Description | Docs | PyPI | -|---|---|---|---| -| **gds-framework** | Foundation — typed compositional specifications | [Docs](https://blockscience.github.io/gds-framework) | [PyPI](https://pypi.org/project/gds-framework/) | -| **gds-viz** | Mermaid diagram renderers for GDS specifications | [Docs](https://blockscience.github.io/gds-viz) | [PyPI](https://pypi.org/project/gds-viz/) | -| **gds-games** | Typed DSL for compositional game theory | [Docs](https://blockscience.github.io/gds-games) | [PyPI](https://pypi.org/project/gds-games/) | -| **gds-examples** | Six tutorial models demonstrating every framework feature | [Docs](https://blockscience.github.io/gds-examples) | [PyPI](https://pypi.org/project/gds-examples/) | +| Package | Import | Description | +|---|---|---| +| **gds-framework** | `gds` | Core engine — blocks, composition algebra, compiler, verification | +| **gds-viz** | `gds_viz` | Mermaid diagram renderers for GDS specifications | +| **gds-stockflow** | `stockflow` | Declarative stock-flow DSL over GDS semantics | +| **gds-control** | `gds_control` | State-space control DSL over GDS semantics | +| **gds-games** | `ogs` | Typed DSL for compositional game theory (Open Games) | +| **gds-software** | `gds_software` | Software architecture DSL (DFD, state machine, C4, ERD, etc.) | +| **gds-business** | `gds_business` | Business dynamics DSL (CLD, supply chain, value stream map) | +| **gds-sim** | `gds_sim` | Simulation engine (standalone, Pydantic-only) | +| **gds-examples** | — | Tutorial models demonstrating framework features | ## Dependency Graph @@ -17,26 +22,35 @@ The GDS ecosystem is a family of composable packages for specifying, visualizing graph TD F[gds-framework] --> V[gds-viz] F --> G[gds-games] + F --> SF[gds-stockflow] + F --> C[gds-control] + F --> SW[gds-software] + F --> B[gds-business] F --> E[gds-examples] V --> E + G --> E + SF --> E + C --> E + SW --> E + B --> E + SIM[gds-sim] ``` ## Architecture ``` -gds-framework (foundation) -│ -│ Domain-neutral composition algebra, typed spaces, -│ state model, verification engine, flat IR compiler. -│ -├── gds-viz (visualization) -│ └── 6 Mermaid diagram views of GDS specifications -│ -├── gds-games (game theory DSL) -│ └── Open games, pattern composition, verification, reports, CLI -│ -└── gds-examples (tutorials) - └── 6 complete domain models with tests and visualizations +gds-framework ← core engine (no GDS dependencies) + ↑ +gds-viz ← visualization (depends on gds-framework) +gds-games ← game theory DSL (depends on gds-framework) +gds-stockflow ← stock-flow DSL (depends on gds-framework) +gds-control ← control systems DSL (depends on gds-framework) +gds-software ← software architecture DSL (depends on gds-framework) +gds-business ← business dynamics DSL (depends on gds-framework) + ↑ +gds-examples ← tutorials (depends on gds-framework + gds-viz + all DSLs) + +gds-sim ← simulation engine (standalone — no gds-framework dep, only pydantic) ``` ## Links diff --git a/docs/framework/guide/architecture.md b/docs/framework/guide/architecture.md index d22db31..141f403 100644 --- a/docs/framework/guide/architecture.md +++ b/docs/framework/guide/architecture.md @@ -10,6 +10,14 @@ Blocks with bidirectional typed interfaces, composed via four operators (`>>`, ` TypeDef with runtime constraints, typed Spaces, Entities with StateVariables, Block roles (BoundaryAction/Policy/Mechanism/ControlAction), GDSSpec registry, ParameterSchema (Θ), canonical projection (CanonicalGDS), Tagged mixin, semantic verification (SC-001..SC-007), SpecQuery for dependency analysis, and JSON serialization. +### Why Two Layers? + +Layer 0 is domain-neutral by design. It knows about blocks with typed ports, four composition operators, and structural topology — nothing about games, stocks, or controllers. This neutrality is what allows five different DSLs to compile to the same IR. + +Domain judgment enters at Layer 1: when a modeler decides "this is a Mechanism, not a Policy" or "this variable is part of the system state." Layer 0 cannot make these decisions because they require knowledge of the problem being modeled. The three-stage compiler (flatten, wire, extract hierarchy) is pure algebra. The role annotations (BoundaryAction, Policy, Mechanism) are domain commitments. + +This separation means Layer 0 specifications stay verifiable without knowing anything about the domain — they can be composed and checked formally. Layer 1 adds the meaning that makes a specification useful for a particular problem. + ## Foundation + Domain Packages ``` diff --git a/docs/guides/best-practices.md b/docs/guides/best-practices.md index 8875546..5c7073d 100644 --- a/docs/guides/best-practices.md +++ b/docs/guides/best-practices.md @@ -49,6 +49,20 @@ Policy(name="Process", ...) --- +## Modeling Decisions + +Before writing any composition, three choices shape the entire specification: + +**Role assignment.** Which processes become BoundaryActions (exogenous inputs), Policies (decision/observation logic), or Mechanisms (state updates)? This determines the canonical decomposition `h = f . g`. A temperature sensor could be a BoundaryAction (external data arrives) or a Policy (compute reading from state) — the right answer depends on what you want to verify, not on the physics alone. + +**State identification.** Which quantities are state variables and which are derived? An SIR model with three state variables (S, I, R) produces a different canonical form than one that derives R = N - S - I and tracks only two. Finer state identification lets SC-001 catch orphan variables; coarser identification creates fewer obligations. + +**Block granularity.** One large block or several small ones? The algebra composes anything with compatible ports, but finer granularity makes the [hierarchy tree](../framework/guide/composition.md) more informative and gives verification more to check. A single-block model passes all structural checks trivially. + +These are design choices, not discoveries. Different choices lead to different verifiable specifications — neither is "wrong." Start from the question you want to answer ("Does this system avoid write conflicts on state?") and design roles backward from there. + +--- + ## Composition Patterns ### The Three-Tier Pipeline diff --git a/docs/guides/choosing-a-dsl.md b/docs/guides/choosing-a-dsl.md index 3c98a1d..cc0c5b3 100644 --- a/docs/guides/choosing-a-dsl.md +++ b/docs/guides/choosing-a-dsl.md @@ -4,6 +4,16 @@ Five domain-specific languages compile to the same GDS core. This guide helps yo --- +## Starting from the Problem + +The Decision Matrix below is a technical reference — it assumes you already know your primitives. In practice, most modelers start earlier: with a domain question. + +The same system can often be modeled with more than one DSL. An epidemic could be stockflow (if you care about accumulation rates) or raw framework (if you just need a state transition). A supply chain could be stockflow (stocks and flows), CLD (causal influences), or SCN (inventory and topology). The DSL choice depends on **what you want to verify**, not just what domain you are in. + +The natural workflow is: **Problem → What do I want to check? → DSL**. Once you pick a DSL, roles and block structure follow more naturally because the DSL embeds domain conventions about what matters. + +--- + ## Decision Matrix | If your system has... | Use | Package | Why | diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index 425552c..e91ac78 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -425,7 +425,7 @@ From here, explore the [example models](../examples/index.md) or the [Rosetta St ## Interactive Notebook /// marimo-embed-file - filepath: packages/gds-examples/guides/getting_started/notebook.py + filepath: packages/gds-examples/notebooks/getting_started.py height: 800px mode: read /// @@ -433,22 +433,22 @@ From here, explore the [example models](../examples/index.md) or the [Rosetta St To run the notebook locally: ```bash -uv run marimo run packages/gds-examples/guides/getting_started/notebook.py +uv run marimo run packages/gds-examples/notebooks/getting_started.py ``` Run the test suite: ```bash -uv run --package gds-examples pytest packages/gds-examples/guides/getting_started/ -v +uv run --package gds-examples pytest packages/gds-examples/tests/test_getting_started.py -v ``` ## Source Files | File | Purpose | |------|---------| -| [`stage1_minimal.py`](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/guides/getting_started/stage1_minimal.py) | Minimal heater model | -| [`stage2_feedback.py`](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/guides/getting_started/stage2_feedback.py) | Feedback loop with policies | -| [`stage3_dsl.py`](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/guides/getting_started/stage3_dsl.py) | gds-control DSL version | -| [`stage4_verify_viz.py`](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/guides/getting_started/stage4_verify_viz.py) | Verification and visualization | -| [`stage5_query.py`](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/guides/getting_started/stage5_query.py) | SpecQuery API | -| [`notebook.py`](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/guides/getting_started/notebook.py) | Interactive marimo notebook | +| [`stage1_minimal.py`](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/gds_examples/getting_started/stage1_minimal.py) | Minimal heater model | +| [`stage2_feedback.py`](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/gds_examples/getting_started/stage2_feedback.py) | Feedback loop with policies | +| [`stage3_dsl.py`](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/gds_examples/getting_started/stage3_dsl.py) | gds-control DSL version | +| [`stage4_verify_viz.py`](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/gds_examples/getting_started/stage4_verify_viz.py) | Verification and visualization | +| [`stage5_query.py`](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/gds_examples/getting_started/stage5_query.py) | SpecQuery API | +| [`getting_started.py`](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/notebooks/getting_started.py) | Interactive marimo notebook | diff --git a/docs/guides/interoperability.md b/docs/guides/interoperability.md index ae08c3e..c8c0582 100644 --- a/docs/guides/interoperability.md +++ b/docs/guides/interoperability.md @@ -235,7 +235,7 @@ This validates GDS as an **interoperability substrate**, not just a modeling fra ### Nash Equilibrium Analysis /// marimo-embed-file - filepath: packages/gds-examples/guides/nash_equilibrium/notebook.py + filepath: packages/gds-examples/notebooks/nash_equilibrium.py height: 800px mode: read /// @@ -244,8 +244,7 @@ To run locally: ```bash uv sync --all-packages --extra nash -cd packages/gds-examples && \ - uv run marimo run guides/nash_equilibrium/notebook.py +uv run marimo run packages/gds-examples/notebooks/nash_equilibrium.py ``` ```bash @@ -257,7 +256,7 @@ uv run --package gds-examples pytest \ ### Evolution of Trust Simulation /// marimo-embed-file - filepath: packages/gds-examples/guides/evolution_of_trust/notebook.py + filepath: packages/gds-examples/notebooks/evolution_of_trust.py height: 800px mode: read /// @@ -265,8 +264,7 @@ uv run --package gds-examples pytest \ To run locally: ```bash -cd packages/gds-examples && \ - uv run marimo run guides/evolution_of_trust/notebook.py +uv run marimo run packages/gds-examples/notebooks/evolution_of_trust.py ``` ```bash @@ -283,8 +281,8 @@ uv run --package gds-examples pytest \ | `games/evolution_of_trust/model.py` | OGS structure with Nicky Case payoffs | | `games/evolution_of_trust/strategies.py` | 8 strategy implementations | | `games/evolution_of_trust/tournament.py` | Match, tournament, evolutionary dynamics | -| `guides/nash_equilibrium/notebook.py` | Interactive Nash analysis notebook | -| `guides/evolution_of_trust/notebook.py` | Interactive simulation notebook | +| `notebooks/nash_equilibrium.py` | Interactive Nash analysis notebook | +| `notebooks/evolution_of_trust.py` | Interactive simulation notebook | All paths relative to `packages/gds-examples/`. diff --git a/docs/guides/rosetta-stone.md b/docs/guides/rosetta-stone.md index 856f556..e536afe 100644 --- a/docs/guides/rosetta-stone.md +++ b/docs/guides/rosetta-stone.md @@ -308,7 +308,7 @@ h_theta : X -> X where h = f . g This is the "Rosetta Stone" -- the same mathematical structure expressed in different domain languages, all grounded in GDS theory. ```python -from guides.rosetta.comparison import canonical_spectrum_table +from gds_examples.rosetta.comparison import canonical_spectrum_table print(canonical_spectrum_table()) ``` @@ -316,7 +316,7 @@ print(canonical_spectrum_table()) ## Interactive Notebook /// marimo-embed-file - filepath: packages/gds-examples/guides/rosetta/notebook.py + filepath: packages/gds-examples/notebooks/rosetta.py height: 800px mode: read /// @@ -324,21 +324,21 @@ print(canonical_spectrum_table()) To run the notebook locally: ```bash -uv run marimo run packages/gds-examples/guides/rosetta/notebook.py +uv run marimo run packages/gds-examples/notebooks/rosetta.py ``` Run the test suite: ```bash -uv run --package gds-examples pytest packages/gds-examples/guides/rosetta/ -v +uv run --package gds-examples pytest packages/gds-examples/tests/test_rosetta.py -v ``` ## Source Files | File | Purpose | |------|---------| -| [`stockflow_view.py`](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/guides/rosetta/stockflow_view.py) | Stock-flow DSL model | -| [`control_view.py`](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/guides/rosetta/control_view.py) | Control DSL model | -| [`game_view.py`](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/guides/rosetta/game_view.py) | Game theory DSL model | -| [`comparison.py`](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/guides/rosetta/comparison.py) | Cross-domain canonical comparison | -| [`notebook.py`](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/guides/rosetta/notebook.py) | Interactive marimo notebook | +| [`stockflow_view.py`](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/gds_examples/rosetta/stockflow_view.py) | Stock-flow DSL model | +| [`control_view.py`](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/gds_examples/rosetta/control_view.py) | Control DSL model | +| [`game_view.py`](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/gds_examples/rosetta/game_view.py) | Game theory DSL model | +| [`comparison.py`](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/gds_examples/rosetta/comparison.py) | Cross-domain canonical comparison | +| [`rosetta.py`](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/notebooks/rosetta.py) | Interactive marimo notebook | diff --git a/docs/guides/verification.md b/docs/guides/verification.md index a9088da..b72ba81 100644 --- a/docs/guides/verification.md +++ b/docs/guides/verification.md @@ -12,6 +12,12 @@ A hands-on walkthrough of the three verification layers in GDS, using deliberate Each layer operates on a different representation, and the layers are complementary: a model can pass all generic checks but fail semantic checks (and vice versa). +### What Verification Does Not Cover + +All three layers check **structural consistency** — does the model obey the rules of its own declared categories? They do not check whether those categories were chosen well. A stock-flow model where "customer satisfaction" is declared as a Stock will pass every check — whether satisfaction actually accumulates like a stock is a judgment call that no formal check can answer. + +This is the boundary between **verification** (automated, structural) and **validation** (human, domain-specific). Verification asks: "Given the roles and state variables you declared, is the model internally consistent?" Validation asks: "Did you declare the right roles and state variables for this problem?" GDS owns the first question. The modeler owns the second. + --- ## Layer 1: Generic Checks (G-series) @@ -118,7 +124,7 @@ The core workflow: build a broken model, run checks, inspect findings, fix error ```python from gds.verification.engine import verify -from guides.verification.broken_models import ( +from gds_examples.verification.broken_models import ( dangling_wiring_system, fixed_pipeline_system, ) @@ -375,7 +381,7 @@ for finding in report.findings: ## Interactive Notebook /// marimo-embed-file - filepath: packages/gds-examples/guides/verification/notebook.py + filepath: packages/gds-examples/notebooks/verification.py height: 800px mode: read /// @@ -383,20 +389,20 @@ for finding in report.findings: To run the notebook locally: ```bash -uv run marimo run packages/gds-examples/guides/verification/notebook.py +uv run marimo run packages/gds-examples/notebooks/verification.py ``` Run the test suite: ```bash -uv run --package gds-examples pytest packages/gds-examples/guides/verification/ -v +uv run --package gds-examples pytest packages/gds-examples/tests/test_verification_guide.py -v ``` ## Source Files | File | Purpose | |------|---------| -| [`broken_models.py`](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/guides/verification/broken_models.py) | Deliberately broken models for each check | -| [`verification_demo.py`](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/guides/verification/verification_demo.py) | Generic and semantic check demos | -| [`domain_checks_demo.py`](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/guides/verification/domain_checks_demo.py) | StockFlow domain check demos | -| [`notebook.py`](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/guides/verification/notebook.py) | Interactive marimo notebook | +| [`broken_models.py`](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/gds_examples/verification/broken_models.py) | Deliberately broken models for each check | +| [`verification_demo.py`](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/gds_examples/verification/verification_demo.py) | Generic and semantic check demos | +| [`domain_checks_demo.py`](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/gds_examples/verification/domain_checks_demo.py) | StockFlow domain check demos | +| [`notebook.py`](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/notebooks/verification.py) | Interactive marimo notebook | diff --git a/docs/guides/view-stratification.md b/docs/guides/view-stratification.md index da1f84c..ada162e 100644 --- a/docs/guides/view-stratification.md +++ b/docs/guides/view-stratification.md @@ -23,6 +23,10 @@ Domain Model (Pattern, StockFlowModel, ControlModel) | **GDSSpec / CanonicalGDS** | Semantic (GDS theory) | Role classification (Policy/Mechanism/BoundaryAction), state variables, canonical decomposition h = f ∘ g, update map, decision/input ports | | **SystemIR** | Structural (topology) | Block graph, wiring connections, hierarchy tree, composition operators | +### Why Three Representations? + +None of these representations is the system itself — each is a map that hides something by design. Domain IR hides composition structure. GDSSpec hides domain vocabulary. SystemIR hides roles and state. Keeping them separate is not duplication. Each answers a different class of question, and a view that tries to answer one layer's question using another layer's data is working from the wrong map. + ## Authority Rules ### CanonicalGDS is the semantic authority diff --git a/docs/guides/visualization.md b/docs/guides/visualization.md index 9694e88..97bde42 100644 --- a/docs/guides/visualization.md +++ b/docs/guides/visualization.md @@ -206,7 +206,7 @@ mermaid_str = system_to_mermaid(system, theme="dark") ## Interactive Notebook /// marimo-embed-file - filepath: packages/gds-examples/guides/visualization/notebook.py + filepath: packages/gds-examples/notebooks/visualization.py height: 800px mode: read /// @@ -214,20 +214,20 @@ mermaid_str = system_to_mermaid(system, theme="dark") To run the notebook locally: ```bash -uv run marimo run packages/gds-examples/guides/visualization/notebook.py +uv run marimo run packages/gds-examples/notebooks/visualization.py ``` Run the test suite: ```bash -uv run --package gds-examples pytest packages/gds-examples/guides/visualization/ -v +uv run --package gds-examples pytest packages/gds-examples/tests/test_visualization_guide.py -v ``` ## Source Files | File | Purpose | |------|---------| -| [`all_views_demo.py`](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/guides/visualization/all_views_demo.py) | All 6 view types on the SIR model | -| [`theme_customization.py`](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/guides/visualization/theme_customization.py) | 5 built-in theme demos | -| [`cross_dsl_views.py`](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/guides/visualization/cross_dsl_views.py) | Cross-DSL visualization comparison | -| [`notebook.py`](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/guides/visualization/notebook.py) | Interactive marimo notebook | +| [`all_views_demo.py`](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/gds_examples/visualization/all_views_demo.py) | All 6 view types on the SIR model | +| [`theme_customization.py`](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/gds_examples/visualization/theme_customization.py) | 5 built-in theme demos | +| [`cross_dsl_views.py`](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/gds_examples/visualization/cross_dsl_views.py) | Cross-DSL visualization comparison | +| [`visualization.py`](https://github.com/BlockScience/gds-core/blob/main/packages/gds-examples/notebooks/visualization.py) | Interactive marimo notebook | diff --git a/packages/gds-business/gds_business/supplychain/checks.py b/packages/gds-business/gds_business/supplychain/checks.py index 42180c8..f9e2fb1 100644 --- a/packages/gds-business/gds_business/supplychain/checks.py +++ b/packages/gds-business/gds_business/supplychain/checks.py @@ -21,21 +21,21 @@ def check_scn001_network_connectivity(model: SupplyChainModel) -> list[Finding]: # Build undirected adjacency from shipments adj: dict[str, set[str]] = {n.name: set() for n in model.nodes} for s in model.shipments: - if s.source_node in adj and s.target_node in adj: - adj[s.source_node].add(s.target_node) - adj[s.target_node].add(s.source_node) + if s.source in adj and s.target in adj: + adj[s.source].add(s.target) + adj[s.target].add(s.source) # Also connect demand source targets for d in model.demand_sources: - if d.target_node in adj: - adj[d.target_node].add(f"__demand_{d.name}") + if d.target in adj: + adj[d.target].add(f"__demand_{d.name}") # BFS from each demand target reachable: set[str] = set() for d in model.demand_sources: - if d.target_node not in adj: + if d.target not in adj: continue - queue = [d.target_node] + queue = [d.target] visited: set[str] = set() while queue: node = queue.pop(0) @@ -76,32 +76,32 @@ def check_scn001_network_connectivity(model: SupplyChainModel) -> list[Finding]: def check_scn002_shipment_node_validity(model: SupplyChainModel) -> list[Finding]: - """SCN-002: Shipment source_node and target_node exist.""" + """SCN-002: Shipment source and target exist.""" findings: list[Finding] = [] for s in model.shipments: - src_valid = s.source_node in model.node_names + src_valid = s.source in model.node_names findings.append( Finding( check_id="SCN-002", severity=Severity.ERROR, message=( - f"Shipment {s.name!r} source_node {s.source_node!r} " + f"Shipment {s.name!r} source {s.source!r} " f"{'is' if src_valid else 'is NOT'} a declared node" ), - source_elements=[s.name, s.source_node], + source_elements=[s.name, s.source], passed=src_valid, ) ) - tgt_valid = s.target_node in model.node_names + tgt_valid = s.target in model.node_names findings.append( Finding( check_id="SCN-002", severity=Severity.ERROR, message=( - f"Shipment {s.name!r} target_node {s.target_node!r} " + f"Shipment {s.name!r} target {s.target!r} " f"{'is' if tgt_valid else 'is NOT'} a declared node" ), - source_elements=[s.name, s.target_node], + source_elements=[s.name, s.target], passed=tgt_valid, ) ) @@ -109,19 +109,19 @@ def check_scn002_shipment_node_validity(model: SupplyChainModel) -> list[Finding def check_scn003_demand_target_validity(model: SupplyChainModel) -> list[Finding]: - """SCN-003: Demand target_node exists.""" + """SCN-003: Demand target exists.""" findings: list[Finding] = [] for d in model.demand_sources: - valid = d.target_node in model.node_names + valid = d.target in model.node_names findings.append( Finding( check_id="SCN-003", severity=Severity.ERROR, message=( - f"DemandSource {d.name!r} target_node {d.target_node!r} " + f"DemandSource {d.name!r} target {d.target!r} " f"{'is' if valid else 'is NOT'} a declared node" ), - source_elements=[d.name, d.target_node], + source_elements=[d.name, d.target], passed=valid, ) ) @@ -133,10 +133,10 @@ def check_scn004_no_orphan_nodes(model: SupplyChainModel) -> list[Finding]: findings: list[Finding] = [] connected: set[str] = set() for s in model.shipments: - connected.add(s.source_node) - connected.add(s.target_node) + connected.add(s.source) + connected.add(s.target) for d in model.demand_sources: - connected.add(d.target_node) + connected.add(d.target) for node in model.nodes: is_connected = node.name in connected diff --git a/packages/gds-business/gds_business/supplychain/compile.py b/packages/gds-business/gds_business/supplychain/compile.py index 58920f7..c8dbfd5 100644 --- a/packages/gds-business/gds_business/supplychain/compile.py +++ b/packages/gds-business/gds_business/supplychain/compile.py @@ -124,7 +124,7 @@ def _build_policy_block(policy: OrderPolicy, model: SupplyChainModel) -> Policy: # Demand signals targeting this policy's node for d in model.demand_sources: - if d.target_node == policy.node: + if d.target == policy.node: in_ports.append(port(_signal_port_name(d.name))) # Inventory signals from observed nodes @@ -286,7 +286,7 @@ def compile_scn(model: SupplyChainModel) -> GDSSpec: # Demand -> Policy wires for d in model.demand_sources: for p in model.order_policies: - if p.node == d.target_node: + if p.node == d.target: wires.append( Wire(source=d.name, target=p.name, space="SCN DemandSpace") ) diff --git a/packages/gds-business/gds_business/supplychain/elements.py b/packages/gds-business/gds_business/supplychain/elements.py index 196da60..61bf72e 100644 --- a/packages/gds-business/gds_business/supplychain/elements.py +++ b/packages/gds-business/gds_business/supplychain/elements.py @@ -26,8 +26,8 @@ class Shipment(BaseModel, frozen=True): """ name: str - source_node: str - target_node: str + source: str + target: str lead_time: float = 1.0 @@ -38,7 +38,7 @@ class DemandSource(BaseModel, frozen=True): """ name: str - target_node: str + target: str description: str = "" diff --git a/packages/gds-business/gds_business/supplychain/model.py b/packages/gds-business/gds_business/supplychain/model.py index 5dd0bf9..5e1329c 100644 --- a/packages/gds-business/gds_business/supplychain/model.py +++ b/packages/gds-business/gds_business/supplychain/model.py @@ -62,22 +62,20 @@ def _validate_structure(self) -> Self: # 3. Shipment source/target reference declared nodes for s in self.shipments: - if s.source_node not in node_names: + if s.source not in node_names: errors.append( - f"Shipment {s.name!r} source_node {s.source_node!r} " - f"is not a declared node" + f"Shipment {s.name!r} source {s.source!r} is not a declared node" ) - if s.target_node not in node_names: + if s.target not in node_names: errors.append( - f"Shipment {s.name!r} target_node {s.target_node!r} " - f"is not a declared node" + f"Shipment {s.name!r} target {s.target!r} is not a declared node" ) # 4. Demand target references a declared node for d in self.demand_sources: - if d.target_node not in node_names: + if d.target not in node_names: errors.append( - f"DemandSource {d.name!r} target_node {d.target_node!r} " + f"DemandSource {d.name!r} target {d.target!r} " f"is not a declared node" ) diff --git a/packages/gds-business/tests/supplychain/test_checks.py b/packages/gds-business/tests/supplychain/test_checks.py index 2892a45..7090490 100644 --- a/packages/gds-business/tests/supplychain/test_checks.py +++ b/packages/gds-business/tests/supplychain/test_checks.py @@ -21,8 +21,8 @@ def _connected_model() -> SupplyChainModel: return SupplyChainModel( name="Connected", nodes=[SupplyNode(name="A"), SupplyNode(name="B")], - shipments=[Shipment(name="S1", source_node="A", target_node="B")], - demand_sources=[DemandSource(name="D1", target_node="B")], + shipments=[Shipment(name="S1", source="A", target="B")], + demand_sources=[DemandSource(name="D1", target="B")], ) @@ -34,8 +34,8 @@ def _disconnected_model() -> SupplyChainModel: SupplyNode(name="B"), SupplyNode(name="C"), ], - shipments=[Shipment(name="S1", source_node="A", target_node="B")], - demand_sources=[DemandSource(name="D1", target_node="B")], + shipments=[Shipment(name="S1", source="A", target="B")], + demand_sources=[DemandSource(name="D1", target="B")], ) @@ -47,7 +47,7 @@ def _orphan_model() -> SupplyChainModel: SupplyNode(name="B"), SupplyNode(name="C"), ], - shipments=[Shipment(name="S1", source_node="A", target_node="B")], + shipments=[Shipment(name="S1", source="A", target="B")], ) @@ -55,7 +55,7 @@ def _no_shipments_model() -> SupplyChainModel: return SupplyChainModel( name="NoShipments", nodes=[SupplyNode(name="A")], - demand_sources=[DemandSource(name="D1", target_node="A")], + demand_sources=[DemandSource(name="D1", target="A")], ) diff --git a/packages/gds-business/tests/supplychain/test_compile.py b/packages/gds-business/tests/supplychain/test_compile.py index 5700450..f6a71e4 100644 --- a/packages/gds-business/tests/supplychain/test_compile.py +++ b/packages/gds-business/tests/supplychain/test_compile.py @@ -29,12 +29,12 @@ def _beer_game() -> SupplyChainModel: SupplyNode(name="Retailer", initial_inventory=100), ], shipments=[ - Shipment(name="F->D", source_node="Factory", target_node="Distributor"), - Shipment(name="D->W", source_node="Distributor", target_node="Wholesaler"), - Shipment(name="W->R", source_node="Wholesaler", target_node="Retailer"), + Shipment(name="F->D", source="Factory", target="Distributor"), + Shipment(name="D->W", source="Distributor", target="Wholesaler"), + Shipment(name="W->R", source="Wholesaler", target="Retailer"), ], demand_sources=[ - DemandSource(name="Customer Demand", target_node="Retailer"), + DemandSource(name="Customer Demand", target="Retailer"), ], order_policies=[ OrderPolicy(name="Retailer Policy", node="Retailer", inputs=["Retailer"]), @@ -57,8 +57,8 @@ def _simple_model() -> SupplyChainModel: return SupplyChainModel( name="Simple SCN", nodes=[SupplyNode(name="W1"), SupplyNode(name="W2")], - shipments=[Shipment(name="S1", source_node="W1", target_node="W2")], - demand_sources=[DemandSource(name="D1", target_node="W2")], + shipments=[Shipment(name="S1", source="W1", target="W2")], + demand_sources=[DemandSource(name="D1", target="W2")], order_policies=[OrderPolicy(name="OP1", node="W2", inputs=["W1"])], ) diff --git a/packages/gds-business/tests/supplychain/test_elements.py b/packages/gds-business/tests/supplychain/test_elements.py index d8ba39a..bdeedd1 100644 --- a/packages/gds-business/tests/supplychain/test_elements.py +++ b/packages/gds-business/tests/supplychain/test_elements.py @@ -36,31 +36,31 @@ def test_equality(self): class TestShipment: def test_create(self): - s = Shipment(name="S1", source_node="A", target_node="B") - assert s.source_node == "A" - assert s.target_node == "B" + s = Shipment(name="S1", source="A", target="B") + assert s.source == "A" + assert s.target == "B" assert s.lead_time == 1.0 def test_with_lead_time(self): - s = Shipment(name="S1", source_node="A", target_node="B", lead_time=3.0) + s = Shipment(name="S1", source="A", target="B", lead_time=3.0) assert s.lead_time == 3.0 def test_frozen(self): - s = Shipment(name="S1", source_node="A", target_node="B") + s = Shipment(name="S1", source="A", target="B") with pytest.raises(ValidationError): - s.source_node = "C" + s.source = "C" class TestDemandSource: def test_create(self): - d = DemandSource(name="D1", target_node="Retail") + d = DemandSource(name="D1", target="Retail") assert d.name == "D1" - assert d.target_node == "Retail" + assert d.target == "Retail" def test_frozen(self): - d = DemandSource(name="D1", target_node="Retail") + d = DemandSource(name="D1", target="Retail") with pytest.raises(ValidationError): - d.target_node = "Other" + d.target = "Other" class TestOrderPolicy: diff --git a/packages/gds-business/tests/supplychain/test_model.py b/packages/gds-business/tests/supplychain/test_model.py index 2b5be1c..c7587e8 100644 --- a/packages/gds-business/tests/supplychain/test_model.py +++ b/packages/gds-business/tests/supplychain/test_model.py @@ -22,8 +22,8 @@ def test_full_model(self): m = SupplyChainModel( name="test", nodes=[SupplyNode(name="W1"), SupplyNode(name="W2")], - shipments=[Shipment(name="S1", source_node="W1", target_node="W2")], - demand_sources=[DemandSource(name="D1", target_node="W2")], + shipments=[Shipment(name="S1", source="W1", target="W2")], + demand_sources=[DemandSource(name="D1", target="W2")], order_policies=[OrderPolicy(name="OP1", node="W2", inputs=["W1"])], ) assert len(m.shipments) == 1 @@ -42,27 +42,27 @@ def test_duplicate_node_names_fails(self): ) def test_shipment_source_invalid_fails(self): - with pytest.raises(BizValidationError, match="source_node.*not a declared"): + with pytest.raises(BizValidationError, match="source.*not a declared"): SupplyChainModel( name="test", nodes=[SupplyNode(name="W1")], - shipments=[Shipment(name="S1", source_node="Z", target_node="W1")], + shipments=[Shipment(name="S1", source="Z", target="W1")], ) def test_shipment_target_invalid_fails(self): - with pytest.raises(BizValidationError, match="target_node.*not a declared"): + with pytest.raises(BizValidationError, match="target.*not a declared"): SupplyChainModel( name="test", nodes=[SupplyNode(name="W1")], - shipments=[Shipment(name="S1", source_node="W1", target_node="Z")], + shipments=[Shipment(name="S1", source="W1", target="Z")], ) def test_demand_target_invalid_fails(self): - with pytest.raises(BizValidationError, match="target_node.*not a declared"): + with pytest.raises(BizValidationError, match="target.*not a declared"): SupplyChainModel( name="test", nodes=[SupplyNode(name="W1")], - demand_sources=[DemandSource(name="D1", target_node="Z")], + demand_sources=[DemandSource(name="D1", target="Z")], ) def test_order_policy_node_invalid_fails(self): diff --git a/packages/gds-business/tests/test_cross_diagram.py b/packages/gds-business/tests/test_cross_diagram.py index 28f509b..12dfd7f 100644 --- a/packages/gds-business/tests/test_cross_diagram.py +++ b/packages/gds-business/tests/test_cross_diagram.py @@ -51,10 +51,10 @@ def _scn() -> SupplyChainModel: SupplyNode(name="Retailer"), ], shipments=[ - Shipment(name="F->R", source_node="Factory", target_node="Retailer"), + Shipment(name="F->R", source="Factory", target="Retailer"), ], demand_sources=[ - DemandSource(name="Customer", target_node="Retailer"), + DemandSource(name="Customer", target="Retailer"), ], order_policies=[ OrderPolicy(name="Reorder", node="Retailer", inputs=["Retailer"]), diff --git a/packages/gds-control/gds_control/verification/engine.py b/packages/gds-control/gds_control/verification/engine.py index d26be9a..d2bc1ed 100644 --- a/packages/gds-control/gds_control/verification/engine.py +++ b/packages/gds-control/gds_control/verification/engine.py @@ -16,7 +16,7 @@ def verify( model: ControlModel, - cs_checks: list[Callable[[ControlModel], list[Finding]]] | None = None, + domain_checks: list[Callable[[ControlModel], list[Finding]]] | None = None, include_gds_checks: bool = True, ) -> VerificationReport: """Run verification checks on a ControlModel. @@ -26,10 +26,10 @@ def verify( Args: model: The control system model to verify. - cs_checks: Optional subset of CS checks. Defaults to all. + domain_checks: Optional subset of CS checks. Defaults to all. include_gds_checks: Whether to compile and run GDS generic checks. """ - checks = cs_checks or ALL_CS_CHECKS + checks = domain_checks or ALL_CS_CHECKS findings: list[Finding] = [] # Phase 1: CS checks on model diff --git a/packages/gds-examples/guides/__init__.py b/packages/gds-examples/gds_examples/__init__.py similarity index 100% rename from packages/gds-examples/guides/__init__.py rename to packages/gds-examples/gds_examples/__init__.py diff --git a/packages/gds-examples/guides/getting_started/__init__.py b/packages/gds-examples/gds_examples/getting_started/__init__.py similarity index 100% rename from packages/gds-examples/guides/getting_started/__init__.py rename to packages/gds-examples/gds_examples/getting_started/__init__.py diff --git a/packages/gds-examples/guides/getting_started/stage1_minimal.py b/packages/gds-examples/gds_examples/getting_started/stage1_minimal.py similarity index 100% rename from packages/gds-examples/guides/getting_started/stage1_minimal.py rename to packages/gds-examples/gds_examples/getting_started/stage1_minimal.py diff --git a/packages/gds-examples/guides/getting_started/stage2_feedback.py b/packages/gds-examples/gds_examples/getting_started/stage2_feedback.py similarity index 100% rename from packages/gds-examples/guides/getting_started/stage2_feedback.py rename to packages/gds-examples/gds_examples/getting_started/stage2_feedback.py diff --git a/packages/gds-examples/guides/getting_started/stage3_dsl.py b/packages/gds-examples/gds_examples/getting_started/stage3_dsl.py similarity index 100% rename from packages/gds-examples/guides/getting_started/stage3_dsl.py rename to packages/gds-examples/gds_examples/getting_started/stage3_dsl.py diff --git a/packages/gds-examples/guides/getting_started/stage4_verify_viz.py b/packages/gds-examples/gds_examples/getting_started/stage4_verify_viz.py similarity index 98% rename from packages/gds-examples/guides/getting_started/stage4_verify_viz.py rename to packages/gds-examples/gds_examples/getting_started/stage4_verify_viz.py index d571819..b4b8d0f 100644 --- a/packages/gds-examples/guides/getting_started/stage4_verify_viz.py +++ b/packages/gds-examples/gds_examples/getting_started/stage4_verify_viz.py @@ -131,7 +131,7 @@ def generate_canonical_view(spec: GDSSpec) -> str: if __name__ == "__main__": - from guides.getting_started.stage3_dsl import build_spec, build_system + from gds_examples.getting_started.stage3_dsl import build_spec, build_system spec = build_spec() system = build_system() diff --git a/packages/gds-examples/guides/getting_started/stage5_query.py b/packages/gds-examples/gds_examples/getting_started/stage5_query.py similarity index 96% rename from packages/gds-examples/guides/getting_started/stage5_query.py rename to packages/gds-examples/gds_examples/getting_started/stage5_query.py index 1f14b92..d317e09 100644 --- a/packages/gds-examples/guides/getting_started/stage5_query.py +++ b/packages/gds-examples/gds_examples/getting_started/stage5_query.py @@ -22,7 +22,7 @@ def build_query(spec: GDSSpec | None = None) -> SpecQuery: """Create a SpecQuery for the thermostat model.""" if spec is None: - from guides.getting_started.stage3_dsl import build_spec + from gds_examples.getting_started.stage3_dsl import build_spec spec = build_spec() return SpecQuery(spec) @@ -80,7 +80,7 @@ def show_dependency_graph(query: SpecQuery) -> dict[str, set[str]]: if __name__ == "__main__": - from guides.getting_started.stage3_dsl import build_spec + from gds_examples.getting_started.stage3_dsl import build_spec spec = build_spec() query = build_query(spec) diff --git a/packages/gds-examples/guides/evolution_of_trust/__init__.py b/packages/gds-examples/gds_examples/rosetta/__init__.py similarity index 100% rename from packages/gds-examples/guides/evolution_of_trust/__init__.py rename to packages/gds-examples/gds_examples/rosetta/__init__.py diff --git a/packages/gds-examples/guides/rosetta/comparison.py b/packages/gds-examples/gds_examples/rosetta/comparison.py similarity index 93% rename from packages/gds-examples/guides/rosetta/comparison.py rename to packages/gds-examples/gds_examples/rosetta/comparison.py index 040a9e2..373f257 100644 --- a/packages/gds-examples/guides/rosetta/comparison.py +++ b/packages/gds-examples/gds_examples/rosetta/comparison.py @@ -16,9 +16,9 @@ """ from gds.canonical import CanonicalGDS, project_canonical -from guides.rosetta.control_view import build_spec as build_control_spec -from guides.rosetta.game_view import build_spec as build_game_spec -from guides.rosetta.stockflow_view import build_spec as build_sf_spec +from gds_examples.rosetta.control_view import build_spec as build_control_spec +from gds_examples.rosetta.game_view import build_spec as build_game_spec +from gds_examples.rosetta.stockflow_view import build_spec as build_sf_spec def build_all_canonicals() -> dict[str, CanonicalGDS]: diff --git a/packages/gds-examples/guides/rosetta/control_view.py b/packages/gds-examples/gds_examples/rosetta/control_view.py similarity index 100% rename from packages/gds-examples/guides/rosetta/control_view.py rename to packages/gds-examples/gds_examples/rosetta/control_view.py diff --git a/packages/gds-examples/guides/rosetta/game_view.py b/packages/gds-examples/gds_examples/rosetta/game_view.py similarity index 100% rename from packages/gds-examples/guides/rosetta/game_view.py rename to packages/gds-examples/gds_examples/rosetta/game_view.py diff --git a/packages/gds-examples/guides/rosetta/stockflow_view.py b/packages/gds-examples/gds_examples/rosetta/stockflow_view.py similarity index 100% rename from packages/gds-examples/guides/rosetta/stockflow_view.py rename to packages/gds-examples/gds_examples/rosetta/stockflow_view.py diff --git a/packages/gds-examples/guides/nash_equilibrium/__init__.py b/packages/gds-examples/gds_examples/verification/__init__.py similarity index 100% rename from packages/gds-examples/guides/nash_equilibrium/__init__.py rename to packages/gds-examples/gds_examples/verification/__init__.py diff --git a/packages/gds-examples/guides/verification/broken_models.py b/packages/gds-examples/gds_examples/verification/broken_models.py similarity index 100% rename from packages/gds-examples/guides/verification/broken_models.py rename to packages/gds-examples/gds_examples/verification/broken_models.py diff --git a/packages/gds-examples/guides/verification/domain_checks_demo.py b/packages/gds-examples/gds_examples/verification/domain_checks_demo.py similarity index 100% rename from packages/gds-examples/guides/verification/domain_checks_demo.py rename to packages/gds-examples/gds_examples/verification/domain_checks_demo.py diff --git a/packages/gds-examples/guides/verification/verification_demo.py b/packages/gds-examples/gds_examples/verification/verification_demo.py similarity index 99% rename from packages/gds-examples/guides/verification/verification_demo.py rename to packages/gds-examples/gds_examples/verification/verification_demo.py index 8bc9d1f..53ef962 100644 --- a/packages/gds-examples/guides/verification/verification_demo.py +++ b/packages/gds-examples/gds_examples/verification/verification_demo.py @@ -29,7 +29,7 @@ check_completeness, check_determinism, ) -from guides.verification.broken_models import ( +from gds_examples.verification.broken_models import ( covariant_cycle_system, dangling_wiring_system, empty_canonical_spec, diff --git a/packages/gds-examples/guides/rosetta/__init__.py b/packages/gds-examples/gds_examples/visualization/__init__.py similarity index 100% rename from packages/gds-examples/guides/rosetta/__init__.py rename to packages/gds-examples/gds_examples/visualization/__init__.py diff --git a/packages/gds-examples/guides/visualization/all_views_demo.py b/packages/gds-examples/gds_examples/visualization/all_views_demo.py similarity index 100% rename from packages/gds-examples/guides/visualization/all_views_demo.py rename to packages/gds-examples/gds_examples/visualization/all_views_demo.py diff --git a/packages/gds-examples/guides/visualization/cross_dsl_views.py b/packages/gds-examples/gds_examples/visualization/cross_dsl_views.py similarity index 100% rename from packages/gds-examples/guides/visualization/cross_dsl_views.py rename to packages/gds-examples/gds_examples/visualization/cross_dsl_views.py diff --git a/packages/gds-examples/guides/visualization/theme_customization.py b/packages/gds-examples/gds_examples/visualization/theme_customization.py similarity index 100% rename from packages/gds-examples/guides/visualization/theme_customization.py rename to packages/gds-examples/gds_examples/visualization/theme_customization.py diff --git a/packages/gds-examples/guides/evolution_of_trust/conftest.py b/packages/gds-examples/guides/evolution_of_trust/conftest.py deleted file mode 100644 index 34ce60c..0000000 --- a/packages/gds-examples/guides/evolution_of_trust/conftest.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Path setup for Evolution of Trust guide tests. - -Adds the games/ directory to sys.path so that imports like -``from evolution_of_trust.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/getting_started/README.md b/packages/gds-examples/guides/getting_started/README.md deleted file mode 100644 index a00a084..0000000 --- a/packages/gds-examples/guides/getting_started/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# Build Your First Model - -A progressive tutorial that walks through the GDS framework using a -thermostat/heater system as the running example. Each stage builds on the -previous one, introducing new concepts incrementally. - -## Prerequisites - -- Python 3.12+ -- `gds-framework`, `gds-viz`, and `gds-control` installed - (`uv sync --all-packages` from the repo root) - -## Learning Path - -| Stage | File | Concepts | -|-------|------|----------| -| 1 | `stage1_minimal.py` | Entity, BoundaryAction, Mechanism, `>>` composition, GDSSpec | -| 2 | `stage2_feedback.py` | Policy, `.loop()` temporal composition, parameters | -| 3 | `stage3_dsl.py` | gds-control DSL: ControlModel, compile_model, compile_to_system | -| 4 | `stage4_verify_viz.py` | Generic checks (G-001..G-006), semantic checks, Mermaid visualization | -| 5 | `stage5_query.py` | SpecQuery API: parameter influence, entity updates, causal chains | - -## Running - -Run any stage directly to see its output: - -```bash -uv run python -m guides.getting_started.stage1_minimal -``` - -Run the test suite: - -```bash -uv run --package gds-examples pytest packages/gds-examples/guides/getting_started/ -v -``` diff --git a/packages/gds-examples/guides/nash_equilibrium/conftest.py b/packages/gds-examples/guides/nash_equilibrium/conftest.py deleted file mode 100644 index 74269f3..0000000 --- a/packages/gds-examples/guides/nash_equilibrium/conftest.py +++ /dev/null @@ -1,15 +0,0 @@ -"""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/rosetta/README.md b/packages/gds-examples/guides/rosetta/README.md deleted file mode 100644 index e960f4a..0000000 --- a/packages/gds-examples/guides/rosetta/README.md +++ /dev/null @@ -1,65 +0,0 @@ -# Cross-Domain Rosetta Stone - -Three views of the same resource pool problem, each compiled to a GDS canonical form. - -## The Resource Pool Scenario - -A shared resource pool (water reservoir, inventory, commons) that agents interact -with through supply, consumption, or extraction. The same real-world system is -modeled through three different DSL lenses. - -## Views - -| View | Module | DSL | Character | -|------|--------|-----|-----------| -| Stock-Flow | `stockflow_view.py` | `stockflow` | Dynamical -- accumulation via rates | -| Control | `control_view.py` | `gds_control` | Dynamical -- regulation toward setpoint | -| Game Theory | `game_view.py` | `ogs` | Strategic -- stateless extraction game | - -## Canonical Spectrum - -All three views compile to `GDSSpec` and project to the canonical `h = f . g` -decomposition. The comparison table (`comparison.py`) shows how they differ: - -``` -View |X| |U| |g| |f| Form Character ------------------------------------------------------------------------ -Stock-Flow 1 2 3 1 h_theta = f_theta . g_theta Dynamical -Control 1 1 2 1 h_theta = f_theta . g_theta Dynamical -Game Theory 0 1 3 0 h = g Strategic -``` - -Key insight: **the same GDS composition algebra underlies all three**, but each -DSL decomposes the problem differently: - -- **Stock-Flow**: State `X` is the resource level, updated by net flow rates. - Two exogenous parameters (supply rate, consumption rate) drive the dynamics. -- **Control**: State `X` is the resource level, regulated by a feedback controller - that tracks an exogenous reference setpoint. -- **Game Theory**: No state -- pure strategic interaction. Two agents simultaneously - choose extraction amounts; a payoff function determines the outcome. - -## Running - -```bash -# Run all tests -uv run --package gds-examples pytest packages/gds-examples/guides/rosetta/ -v - -# Print the comparison table -uv run --package gds-examples python -m guides.rosetta.comparison -``` - -## Unified Transition Calculus - -The GDS canonical form provides a unified notation for all three: - -``` -h_theta : X -> X where h = f . g -``` - -- When `|f| > 0` and `|g| > 0`: **Dynamical** system (stock-flow, control) -- When `|f| = 0` and `|g| > 0`: **Strategic** system (game theory) -- When `|g| = 0` and `|f| > 0`: **Autonomous** system (no policy) - -This is the "Rosetta Stone" -- the same mathematical structure expressed in -different domain languages, all grounded in GDS theory. diff --git a/packages/gds-examples/guides/visualization/__init__.py b/packages/gds-examples/guides/visualization/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/packages/gds-examples/guides/visualization/conftest.py b/packages/gds-examples/guides/visualization/conftest.py deleted file mode 100644 index e0a8a8d..0000000 --- a/packages/gds-examples/guides/visualization/conftest.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Path setup for visualization guide tests. - -Adds the stockflow/ and control/ directories to sys.path so that -imports like ``from sir_epidemic.model import ...`` and -``from double_integrator.model import ...`` resolve correctly. -""" - -import sys -from pathlib import Path - -_examples_root = Path(__file__).resolve().parent.parent.parent - -for subdir in ("stockflow", "control"): - path = str(_examples_root / subdir) - if path not in sys.path: - sys.path.insert(0, path) diff --git a/packages/gds-examples/guides/evolution_of_trust/notebook.py b/packages/gds-examples/notebooks/evolution_of_trust.py similarity index 98% rename from packages/gds-examples/guides/evolution_of_trust/notebook.py rename to packages/gds-examples/notebooks/evolution_of_trust.py index bb47475..6349e5a 100644 --- a/packages/gds-examples/guides/evolution_of_trust/notebook.py +++ b/packages/gds-examples/notebooks/evolution_of_trust.py @@ -5,11 +5,19 @@ built on an OGS game structure. Run interactively: - uv run marimo edit guides/evolution_of_trust/notebook.py + uv run marimo edit notebooks/evolution_of_trust.py Run as read-only app: - uv run marimo run guides/evolution_of_trust/notebook.py + uv run marimo run notebooks/evolution_of_trust.py """ +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "gds-examples", +# "plotly>=5.0", +# "marimo>=0.20.0", +# ] +# /// import marimo @@ -35,7 +43,7 @@ def model_setup(): import sys from pathlib import Path - _examples_root = Path(__file__).resolve().parent.parent.parent + _examples_root = Path(__file__).resolve().parent.parent _games_path = str(_examples_root / "games") if _games_path not in sys.path: sys.path.insert(0, _games_path) diff --git a/packages/gds-examples/guides/getting_started/notebook.py b/packages/gds-examples/notebooks/getting_started.py similarity index 90% rename from packages/gds-examples/guides/getting_started/notebook.py rename to packages/gds-examples/notebooks/getting_started.py index c73e884..d419f04 100644 --- a/packages/gds-examples/guides/getting_started/notebook.py +++ b/packages/gds-examples/notebooks/getting_started.py @@ -1,8 +1,15 @@ """Interactive Getting Started guide for GDS — marimo notebook. A progressive 5-stage tutorial that teaches GDS fundamentals using a -thermostat control system. Run with: marimo run notebook.py +thermostat control system. Run with: marimo run notebooks/getting_started.py """ +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "gds-examples", +# "marimo>=0.20.0", +# ] +# /// import marimo @@ -84,8 +91,10 @@ def _(mo): @app.cell def _(mo): - from guides.getting_started.stage1_minimal import build_spec as _build_spec_s1 - from guides.getting_started.stage1_minimal import build_system as _build_system_s1 + from gds_examples.getting_started.stage1_minimal import build_spec as _build_spec_s1 + from gds_examples.getting_started.stage1_minimal import ( + build_system as _build_system_s1, + ) _spec_s1 = _build_spec_s1() _system_s1 = _build_system_s1() @@ -146,10 +155,10 @@ def _(mo): @app.cell def _(mo): - from guides.getting_started.stage2_feedback import ( + from gds_examples.getting_started.stage2_feedback import ( build_spec as _build_spec_s2, ) - from guides.getting_started.stage2_feedback import ( + from gds_examples.getting_started.stage2_feedback import ( build_system as _build_system_s2, ) @@ -211,13 +220,13 @@ def _(mo): @app.cell def _(mo): - from guides.getting_started.stage3_dsl import ( + from gds_examples.getting_started.stage3_dsl import ( build_canonical as _build_canonical_s3, ) - from guides.getting_started.stage3_dsl import ( + from gds_examples.getting_started.stage3_dsl import ( build_spec as _build_spec_s3, ) - from guides.getting_started.stage3_dsl import ( + from gds_examples.getting_started.stage3_dsl import ( build_system as _build_system_s3, ) @@ -287,25 +296,25 @@ def _(mo): @app.cell def _(mo): - from guides.getting_started.stage3_dsl import ( + from gds_examples.getting_started.stage3_dsl import ( build_spec as _build_spec_s4, ) - from guides.getting_started.stage3_dsl import ( + from gds_examples.getting_started.stage3_dsl import ( build_system as _build_system_s4, ) - from guides.getting_started.stage4_verify_viz import ( + from gds_examples.getting_started.stage4_verify_viz import ( generate_architecture_view as _gen_arch, ) - from guides.getting_started.stage4_verify_viz import ( + from gds_examples.getting_started.stage4_verify_viz import ( generate_canonical_view as _gen_canon, ) - from guides.getting_started.stage4_verify_viz import ( + from gds_examples.getting_started.stage4_verify_viz import ( generate_structural_view as _gen_struct, ) - from guides.getting_started.stage4_verify_viz import ( + from gds_examples.getting_started.stage4_verify_viz import ( run_generic_checks as _run_generic, ) - from guides.getting_started.stage4_verify_viz import ( + from gds_examples.getting_started.stage4_verify_viz import ( run_semantic_checks as _run_semantic, ) @@ -383,22 +392,22 @@ def _(mo): @app.cell def _(mo): - from guides.getting_started.stage5_query import ( + from gds_examples.getting_started.stage5_query import ( build_query as _build_query, ) - from guides.getting_started.stage5_query import ( + from gds_examples.getting_started.stage5_query import ( show_blocks_by_role as _show_blocks_by_role, ) - from guides.getting_started.stage5_query import ( + from gds_examples.getting_started.stage5_query import ( show_causal_chain as _show_causal_chain, ) - from guides.getting_started.stage5_query import ( + from gds_examples.getting_started.stage5_query import ( show_dependency_graph as _show_dep_graph, ) - from guides.getting_started.stage5_query import ( + from gds_examples.getting_started.stage5_query import ( show_entity_updates as _show_entity_updates, ) - from guides.getting_started.stage5_query import ( + from gds_examples.getting_started.stage5_query import ( show_param_influence as _show_param_influence, ) diff --git a/packages/gds-examples/guides/nash_equilibrium/notebook.py b/packages/gds-examples/notebooks/nash_equilibrium.py similarity index 97% rename from packages/gds-examples/guides/nash_equilibrium/notebook.py rename to packages/gds-examples/notebooks/nash_equilibrium.py index 72a2984..a284d52 100644 --- a/packages/gds-examples/guides/nash_equilibrium/notebook.py +++ b/packages/gds-examples/notebooks/nash_equilibrium.py @@ -4,11 +4,19 @@ Nash equilibrium computation -> dominance and Pareto analysis. Run interactively: - uv run marimo edit guides/nash_equilibrium/notebook.py + uv run marimo edit notebooks/nash_equilibrium.py Run as read-only app: - uv run marimo run guides/nash_equilibrium/notebook.py + uv run marimo run notebooks/nash_equilibrium.py """ +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "gds-examples", +# "nashpy>=0.0.41", +# "marimo>=0.20.0", +# ] +# /// import marimo @@ -34,7 +42,7 @@ def model_setup(): import sys from pathlib import Path - _examples_root = Path(__file__).resolve().parent.parent.parent + _examples_root = Path(__file__).resolve().parent.parent _games_path = str(_examples_root / "games") if _games_path not in sys.path: sys.path.insert(0, _games_path) diff --git a/packages/gds-examples/guides/rosetta/notebook.py b/packages/gds-examples/notebooks/rosetta.py similarity index 96% rename from packages/gds-examples/guides/rosetta/notebook.py rename to packages/gds-examples/notebooks/rosetta.py index 124f10d..10781ba 100644 --- a/packages/gds-examples/guides/rosetta/notebook.py +++ b/packages/gds-examples/notebooks/rosetta.py @@ -4,8 +4,15 @@ (Stock-Flow, Control, Game Theory), showing how they all map to the GDS canonical form. -Run with: marimo run guides/rosetta/notebook.py +Run with: marimo run notebooks/rosetta.py """ +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "gds-examples", +# "marimo>=0.20.0", +# ] +# /// import marimo @@ -322,10 +329,11 @@ def _(): @app.cell def _(): + from gds_examples.rosetta import comparison, game_view + from gds_examples.rosetta import control_view as ctrl_view + from gds_examples.rosetta import stockflow_view as sf_view + import gds_viz - from guides.rosetta import comparison, game_view - from guides.rosetta import control_view as ctrl_view - from guides.rosetta import stockflow_view as sf_view return comparison, ctrl_view, game_view, gds_viz, sf_view diff --git a/packages/gds-examples/guides/verification/notebook.py b/packages/gds-examples/notebooks/verification.py similarity index 96% rename from packages/gds-examples/guides/verification/notebook.py rename to packages/gds-examples/notebooks/verification.py index b201c23..48e92f2 100644 --- a/packages/gds-examples/guides/verification/notebook.py +++ b/packages/gds-examples/notebooks/verification.py @@ -5,11 +5,18 @@ fix-and-reverify workflow in action. Run interactively: - uv run marimo edit guides/verification/notebook.py + uv run marimo edit notebooks/verification.py Run as read-only app: - uv run marimo run guides/verification/notebook.py + uv run marimo run notebooks/verification.py """ +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "gds-examples", +# "marimo>=0.20.0", +# ] +# /// import marimo @@ -88,6 +95,14 @@ def generic_selector(mo): @app.cell def run_generic_check(mo, generic_dropdown): + from gds_examples.verification.broken_models import ( + covariant_cycle_system, + dangling_wiring_system, + direction_contradiction_system, + incomplete_signature_system, + type_mismatch_system, + ) + from gds.verification.engine import verify from gds.verification.generic_checks import ( check_g001_domain_codomain_matching, @@ -97,13 +112,6 @@ def run_generic_check(mo, generic_dropdown): check_g005_sequential_type_compatibility, check_g006_covariant_acyclicity, ) - from guides.verification.broken_models import ( - covariant_cycle_system, - dangling_wiring_system, - direction_contradiction_system, - incomplete_signature_system, - type_mismatch_system, - ) _models = { "dangling": ( @@ -196,14 +204,15 @@ def fix_reverify_header(mo): @app.cell def fix_reverify_demo(mo): - from gds.verification.engine import verify as _verify - from guides.verification.broken_models import ( + from gds_examples.verification.broken_models import ( dangling_wiring_system as _dangling_wiring_system, ) - from guides.verification.broken_models import ( + from gds_examples.verification.broken_models import ( fixed_pipeline_system, ) + from gds.verification.engine import verify as _verify + _broken_report = _verify(_dangling_wiring_system()) _fixed_report = _verify(fixed_pipeline_system()) @@ -289,16 +298,17 @@ def semantic_selector(mo): @app.cell def run_semantic_check(mo, semantic_dropdown): + from gds_examples.verification.broken_models import ( + empty_canonical_spec, + orphan_state_spec, + write_conflict_spec, + ) + from gds.verification.spec_checks import ( check_canonical_wellformedness, check_completeness, check_determinism, ) - from guides.verification.broken_models import ( - empty_canonical_spec, - orphan_state_spec, - write_conflict_spec, - ) _specs = { "orphan": ( @@ -370,7 +380,7 @@ def comparison_header(mo): @app.cell def comparison_demo(mo): - from guides.verification.verification_demo import ( + from gds_examples.verification.verification_demo import ( demo_generic_vs_semantic, ) @@ -444,11 +454,12 @@ def domain_selector(mo): @app.cell def run_domain_check(mo, domain_dropdown): - from guides.verification.domain_checks_demo import ( + from gds_examples.verification.domain_checks_demo import ( cyclic_auxiliary_model, orphan_stock_model, unused_converter_model, ) + from stockflow.verification.checks import ( check_sf001_orphan_stocks, check_sf003_auxiliary_acyclicity, @@ -538,12 +549,13 @@ def combined_selector(mo): @app.cell def run_combined(mo, combined_dropdown): - from guides.verification.domain_checks_demo import ( + from gds_examples.verification.domain_checks_demo import ( good_stockflow_model, ) - from guides.verification.domain_checks_demo import ( + from gds_examples.verification.domain_checks_demo import ( orphan_stock_model as _orphan_stock_model, ) + from stockflow.verification.engine import verify as sf_verify if combined_dropdown.value == "good": diff --git a/packages/gds-examples/guides/visualization/notebook.py b/packages/gds-examples/notebooks/visualization.py similarity index 98% rename from packages/gds-examples/guides/visualization/notebook.py rename to packages/gds-examples/notebooks/visualization.py index e414841..3aa2450 100644 --- a/packages/gds-examples/guides/visualization/notebook.py +++ b/packages/gds-examples/notebooks/visualization.py @@ -5,11 +5,18 @@ as you change selections. Run interactively: - uv run marimo edit guides/visualization/notebook.py + uv run marimo edit notebooks/visualization.py Run as read-only app: - uv run marimo run guides/visualization/notebook.py + uv run marimo run notebooks/visualization.py """ +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "gds-examples", +# "marimo>=0.20.0", +# ] +# /// import marimo @@ -59,7 +66,7 @@ def build_sir(): from pathlib import Path # Add stockflow/ and control/ to path for model imports - _examples_root = Path(__file__).resolve().parent.parent.parent + _examples_root = Path(__file__).resolve().parent.parent for _subdir in ("stockflow", "control"): _path = str(_examples_root / _subdir) if _path not in sys.path: diff --git a/packages/gds-examples/pyproject.toml b/packages/gds-examples/pyproject.toml index b2ca391..89cd0af 100644 --- a/packages/gds-examples/pyproject.toml +++ b/packages/gds-examples/pyproject.toml @@ -74,7 +74,7 @@ only-include = [ "thermostat_dsl", "double_integrator", "software", - "guides", + "gds_examples", ] [tool.pytest.ini_options] @@ -100,7 +100,7 @@ files = [ "thermostat", "thermostat_dsl", "double_integrator", - "guides", + "gds_examples", "software", "visualize_examples.py", ] diff --git a/packages/gds-examples/tests/conftest.py b/packages/gds-examples/tests/conftest.py new file mode 100644 index 0000000..14e93ae --- /dev/null +++ b/packages/gds-examples/tests/conftest.py @@ -0,0 +1,15 @@ +"""Shared test fixtures for guide tests. + +Adds domain example directories (games/, stockflow/, control/) to sys.path +so that tests can import models like ``sir_epidemic.model``. +""" + +import sys +from pathlib import Path + +_examples_root = Path(__file__).resolve().parent.parent + +for _subdir in ("games", "stockflow", "control"): + _path = str(_examples_root / _subdir) + if _path not in sys.path: + sys.path.insert(0, _path) diff --git a/packages/gds-examples/guides/getting_started/test_getting_started.py b/packages/gds-examples/tests/test_getting_started.py similarity index 77% rename from packages/gds-examples/guides/getting_started/test_getting_started.py rename to packages/gds-examples/tests/test_getting_started.py index 587dfe1..f1ccbca 100644 --- a/packages/gds-examples/guides/getting_started/test_getting_started.py +++ b/packages/gds-examples/tests/test_getting_started.py @@ -28,20 +28,20 @@ class TestStage1Minimal: def test_spec_builds(self): - from guides.getting_started.stage1_minimal import build_spec + from gds_examples.getting_started.stage1_minimal import build_spec spec = build_spec() assert spec.name == "Minimal Thermostat" def test_spec_has_one_entity(self): - from guides.getting_started.stage1_minimal import build_spec + from gds_examples.getting_started.stage1_minimal import build_spec spec = build_spec() assert len(spec.entities) == 1 assert "Room" in spec.entities def test_spec_has_two_blocks(self): - from guides.getting_started.stage1_minimal import build_spec + from gds_examples.getting_started.stage1_minimal import build_spec spec = build_spec() assert len(spec.blocks) == 2 @@ -49,39 +49,39 @@ def test_spec_has_two_blocks(self): assert "Update Temperature" in spec.blocks def test_block_roles(self): - from guides.getting_started.stage1_minimal import build_spec + from gds_examples.getting_started.stage1_minimal import build_spec spec = build_spec() assert isinstance(spec.blocks["Heater"], BoundaryAction) assert isinstance(spec.blocks["Update Temperature"], Mechanism) def test_spec_validates(self): - from guides.getting_started.stage1_minimal import build_spec + from gds_examples.getting_started.stage1_minimal import build_spec spec = build_spec() errors = spec.validate_spec() assert errors == [], f"Validation errors: {errors}" def test_system_compiles(self): - from guides.getting_started.stage1_minimal import build_system + from gds_examples.getting_started.stage1_minimal import build_system system = build_system() assert system.name == "Minimal Thermostat" assert len(system.blocks) == 2 def test_system_has_wiring(self): - from guides.getting_started.stage1_minimal import build_system + from gds_examples.getting_started.stage1_minimal import build_system system = build_system() assert len(system.wirings) >= 1 def test_mechanism_updates_room(self): - from guides.getting_started.stage1_minimal import update_temperature + from gds_examples.getting_started.stage1_minimal import update_temperature assert update_temperature.updates == [("Room", "temperature")] def test_heater_has_no_forward_in(self): - from guides.getting_started.stage1_minimal import heater + from gds_examples.getting_started.stage1_minimal import heater assert heater.interface.forward_in == () @@ -93,60 +93,60 @@ def test_heater_has_no_forward_in(self): class TestStage2Feedback: def test_spec_builds(self): - from guides.getting_started.stage2_feedback import build_spec + from gds_examples.getting_started.stage2_feedback import build_spec spec = build_spec() assert spec.name == "Thermostat with Feedback" def test_spec_has_four_blocks(self): - from guides.getting_started.stage2_feedback import build_spec + from gds_examples.getting_started.stage2_feedback import build_spec spec = build_spec() assert len(spec.blocks) == 4 def test_spec_has_policy_blocks(self): - from guides.getting_started.stage2_feedback import build_spec + from gds_examples.getting_started.stage2_feedback import build_spec spec = build_spec() assert isinstance(spec.blocks["Sensor"], Policy) assert isinstance(spec.blocks["Controller"], Policy) def test_spec_has_setpoint_parameter(self): - from guides.getting_started.stage2_feedback import build_spec + from gds_examples.getting_started.stage2_feedback import build_spec spec = build_spec() assert "setpoint" in spec.parameters def test_spec_validates(self): - from guides.getting_started.stage2_feedback import build_spec + from gds_examples.getting_started.stage2_feedback import build_spec spec = build_spec() errors = spec.validate_spec() assert errors == [], f"Validation errors: {errors}" def test_system_compiles(self): - from guides.getting_started.stage2_feedback import build_system + from gds_examples.getting_started.stage2_feedback import build_system system = build_system() assert system.name == "Thermostat with Feedback" assert len(system.blocks) == 4 def test_system_has_temporal_wiring(self): - from guides.getting_started.stage2_feedback import build_system + from gds_examples.getting_started.stage2_feedback import build_system system = build_system() temporal = [w for w in system.wirings if w.is_temporal] assert len(temporal) == 1 def test_temporal_wiring_is_covariant(self): - from guides.getting_started.stage2_feedback import build_system + from gds_examples.getting_started.stage2_feedback import build_system system = build_system() temporal = [w for w in system.wirings if w.is_temporal] assert temporal[0].direction == FlowDirection.COVARIANT def test_temporal_wiring_connects_mechanism_to_sensor(self): - from guides.getting_started.stage2_feedback import build_system + from gds_examples.getting_started.stage2_feedback import build_system system = build_system() temporal = [w for w in system.wirings if w.is_temporal] @@ -154,7 +154,7 @@ def test_temporal_wiring_connects_mechanism_to_sensor(self): assert temporal[0].target == "Sensor" def test_no_feedback_wirings(self): - from guides.getting_started.stage2_feedback import build_system + from gds_examples.getting_started.stage2_feedback import build_system system = build_system() feedback = [w for w in system.wirings if w.is_feedback] @@ -168,61 +168,61 @@ def test_no_feedback_wirings(self): class TestStage3DSL: def test_model_builds(self): - from guides.getting_started.stage3_dsl import build_model + from gds_examples.getting_started.stage3_dsl import build_model model = build_model() assert model.name == "Thermostat DSL" def test_model_has_one_state(self): - from guides.getting_started.stage3_dsl import build_model + from gds_examples.getting_started.stage3_dsl import build_model model = build_model() assert len(model.states) == 1 assert model.states[0].name == "temperature" def test_model_has_one_input(self): - from guides.getting_started.stage3_dsl import build_model + from gds_examples.getting_started.stage3_dsl import build_model model = build_model() assert len(model.inputs) == 1 assert model.inputs[0].name == "heater" def test_model_has_one_sensor(self): - from guides.getting_started.stage3_dsl import build_model + from gds_examples.getting_started.stage3_dsl import build_model model = build_model() assert len(model.sensors) == 1 assert model.sensors[0].name == "temp_sensor" def test_model_has_one_controller(self): - from guides.getting_started.stage3_dsl import build_model + from gds_examples.getting_started.stage3_dsl import build_model model = build_model() assert len(model.controllers) == 1 assert model.controllers[0].name == "thermo" def test_spec_compiles(self): - from guides.getting_started.stage3_dsl import build_spec + from gds_examples.getting_started.stage3_dsl import build_spec spec = build_spec() errors = spec.validate_spec() assert errors == [], f"Validation errors: {errors}" def test_spec_has_one_entity(self): - from guides.getting_started.stage3_dsl import build_spec + from gds_examples.getting_started.stage3_dsl import build_spec spec = build_spec() assert len(spec.entities) == 1 assert "temperature" in spec.entities def test_spec_has_four_blocks(self): - from guides.getting_started.stage3_dsl import build_spec + from gds_examples.getting_started.stage3_dsl import build_spec spec = build_spec() assert len(spec.blocks) == 4 def test_spec_block_roles(self): - from guides.getting_started.stage3_dsl import build_spec + from gds_examples.getting_started.stage3_dsl import build_spec spec = build_spec() boundaries = [b for b in spec.blocks.values() if isinstance(b, BoundaryAction)] @@ -233,20 +233,20 @@ def test_spec_block_roles(self): assert len(mechanisms) == 1 def test_system_compiles(self): - from guides.getting_started.stage3_dsl import build_system + from gds_examples.getting_started.stage3_dsl import build_system system = build_system() assert system.name == "Thermostat DSL" def test_system_has_temporal_loop(self): - from guides.getting_started.stage3_dsl import build_system + from gds_examples.getting_started.stage3_dsl import build_system system = build_system() temporal = [w for w in system.wirings if w.is_temporal] assert len(temporal) == 1 def test_canonical_projection(self): - from guides.getting_started.stage3_dsl import build_canonical + from gds_examples.getting_started.stage3_dsl import build_canonical canonical = build_canonical() assert len(canonical.state_variables) == 1 @@ -262,24 +262,24 @@ def test_canonical_projection(self): class TestStage4VerifyViz: def test_generic_checks_pass(self): - from guides.getting_started.stage3_dsl import build_system - from guides.getting_started.stage4_verify_viz import run_generic_checks + from gds_examples.getting_started.stage3_dsl import build_system + from gds_examples.getting_started.stage4_verify_viz import run_generic_checks system = build_system() report = run_generic_checks(system) assert report.errors == 0, [f.message for f in report.findings if not f.passed] def test_semantic_checks_produce_output(self): - from guides.getting_started.stage3_dsl import build_spec - from guides.getting_started.stage4_verify_viz import run_semantic_checks + from gds_examples.getting_started.stage3_dsl import build_spec + from gds_examples.getting_started.stage4_verify_viz import run_semantic_checks spec = build_spec() results = run_semantic_checks(spec) assert len(results) > 0 def test_semantic_checks_all_pass(self): - from guides.getting_started.stage3_dsl import build_spec - from guides.getting_started.stage4_verify_viz import run_semantic_checks + from gds_examples.getting_started.stage3_dsl import build_spec + from gds_examples.getting_started.stage4_verify_viz import run_semantic_checks spec = build_spec() results = run_semantic_checks(spec) @@ -287,8 +287,8 @@ def test_semantic_checks_all_pass(self): assert failures == [], f"Semantic check failures: {failures}" def test_structural_view_is_mermaid(self): - from guides.getting_started.stage3_dsl import build_system - from guides.getting_started.stage4_verify_viz import ( + from gds_examples.getting_started.stage3_dsl import build_system + from gds_examples.getting_started.stage4_verify_viz import ( generate_structural_view, ) @@ -298,8 +298,8 @@ def test_structural_view_is_mermaid(self): assert "heater" in mermaid.lower() or "temp_sensor" in mermaid.lower() def test_architecture_view_is_mermaid(self): - from guides.getting_started.stage3_dsl import build_spec - from guides.getting_started.stage4_verify_viz import ( + from gds_examples.getting_started.stage3_dsl import build_spec + from gds_examples.getting_started.stage4_verify_viz import ( generate_architecture_view, ) @@ -309,8 +309,8 @@ def test_architecture_view_is_mermaid(self): assert "subgraph" in mermaid def test_canonical_view_is_mermaid(self): - from guides.getting_started.stage3_dsl import build_spec - from guides.getting_started.stage4_verify_viz import ( + from gds_examples.getting_started.stage3_dsl import build_spec + from gds_examples.getting_started.stage4_verify_viz import ( generate_canonical_view, ) @@ -327,13 +327,13 @@ def test_canonical_view_is_mermaid(self): class TestStage5Query: def test_query_builds(self): - from guides.getting_started.stage5_query import build_query + from gds_examples.getting_started.stage5_query import build_query query = build_query() assert query is not None def test_entity_update_map(self): - from guides.getting_started.stage5_query import ( + from gds_examples.getting_started.stage5_query import ( build_query, show_entity_updates, ) @@ -345,7 +345,7 @@ def test_entity_update_map(self): assert len(updates["temperature"]["value"]) == 1 def test_blocks_by_role(self): - from guides.getting_started.stage5_query import ( + from gds_examples.getting_started.stage5_query import ( build_query, show_blocks_by_role, ) @@ -357,7 +357,7 @@ def test_blocks_by_role(self): assert len(by_role["mechanism"]) == 1 def test_causal_chain(self): - from guides.getting_started.stage5_query import ( + from gds_examples.getting_started.stage5_query import ( build_query, show_causal_chain, ) @@ -367,7 +367,7 @@ def test_causal_chain(self): assert "temperature Dynamics" in affecting def test_dependency_graph(self): - from guides.getting_started.stage5_query import ( + from gds_examples.getting_started.stage5_query import ( build_query, show_dependency_graph, ) @@ -388,7 +388,7 @@ class TestCrossStageVerification: the same structural and semantic checks as the DSL model.""" def test_stage1_generic_checks(self): - from guides.getting_started.stage1_minimal import build_system + from gds_examples.getting_started.stage1_minimal import build_system system = build_system() checks = [ @@ -399,21 +399,21 @@ def test_stage1_generic_checks(self): assert report.errors == 0, [f.message for f in report.findings if not f.passed] def test_stage1_completeness(self): - from guides.getting_started.stage1_minimal import build_spec + from gds_examples.getting_started.stage1_minimal import build_spec spec = build_spec() findings = check_completeness(spec) assert all(f.passed for f in findings) def test_stage1_determinism(self): - from guides.getting_started.stage1_minimal import build_spec + from gds_examples.getting_started.stage1_minimal import build_spec spec = build_spec() findings = check_determinism(spec) assert all(f.passed for f in findings) def test_stage2_generic_checks(self): - from guides.getting_started.stage2_feedback import build_system + from gds_examples.getting_started.stage2_feedback import build_system system = build_system() checks = [ @@ -424,14 +424,14 @@ def test_stage2_generic_checks(self): assert report.errors == 0, [f.message for f in report.findings if not f.passed] def test_stage2_completeness(self): - from guides.getting_started.stage2_feedback import build_spec + from gds_examples.getting_started.stage2_feedback import build_spec spec = build_spec() findings = check_completeness(spec) assert all(f.passed for f in findings) def test_stage2_canonical_wellformedness(self): - from guides.getting_started.stage2_feedback import build_spec + from gds_examples.getting_started.stage2_feedback import build_spec spec = build_spec() findings = check_canonical_wellformedness(spec) @@ -442,7 +442,7 @@ def test_stage2_canonical_wellformedness(self): # Marimo Notebook # ══════════════════════════════════════════════════════════════ -_NOTEBOOK = Path(__file__).resolve().parent / "notebook.py" +_NOTEBOOK = Path(__file__).resolve().parent.parent / "notebooks" / "getting_started.py" class TestMarimoNotebook: diff --git a/packages/gds-examples/guides/rosetta/test_rosetta.py b/packages/gds-examples/tests/test_rosetta.py similarity index 95% rename from packages/gds-examples/guides/rosetta/test_rosetta.py rename to packages/gds-examples/tests/test_rosetta.py index 174fbea..bf3d493 100644 --- a/packages/gds-examples/guides/rosetta/test_rosetta.py +++ b/packages/gds-examples/tests/test_rosetta.py @@ -7,56 +7,60 @@ from pathlib import Path -from gds.blocks.roles import BoundaryAction, Mechanism, Policy -from gds.ir.models import FlowDirection -from gds.verification.engine import verify -from gds.verification.generic_checks import ( - check_g001_domain_codomain_matching, - check_g003_direction_consistency, - check_g004_dangling_wirings, - check_g005_sequential_type_compatibility, - check_g006_covariant_acyclicity, +from gds_examples.rosetta.comparison import ( + build_all_canonicals, + canonical_spectrum_table, ) -from gds.verification.spec_checks import ( - check_completeness, - check_determinism, - check_type_safety, -) -from guides.rosetta.comparison import build_all_canonicals, canonical_spectrum_table -from guides.rosetta.control_view import ( +from gds_examples.rosetta.control_view import ( build_canonical as build_control_canonical, ) -from guides.rosetta.control_view import ( +from gds_examples.rosetta.control_view import ( build_model as build_control_model, ) -from guides.rosetta.control_view import ( +from gds_examples.rosetta.control_view import ( build_spec as build_control_spec, ) -from guides.rosetta.control_view import ( +from gds_examples.rosetta.control_view import ( build_system as build_control_system, ) -from guides.rosetta.game_view import ( +from gds_examples.rosetta.game_view import ( build_canonical as build_game_canonical, ) -from guides.rosetta.game_view import ( +from gds_examples.rosetta.game_view import ( build_pattern, ) -from guides.rosetta.game_view import ( +from gds_examples.rosetta.game_view import ( build_spec as build_game_spec, ) -from guides.rosetta.stockflow_view import ( +from gds_examples.rosetta.stockflow_view import ( build_canonical as build_sf_canonical, ) -from guides.rosetta.stockflow_view import ( +from gds_examples.rosetta.stockflow_view import ( build_model as build_sf_model, ) -from guides.rosetta.stockflow_view import ( +from gds_examples.rosetta.stockflow_view import ( build_spec as build_sf_spec, ) -from guides.rosetta.stockflow_view import ( +from gds_examples.rosetta.stockflow_view import ( build_system as build_sf_system, ) +from gds.blocks.roles import BoundaryAction, Mechanism, Policy +from gds.ir.models import FlowDirection +from gds.verification.engine import verify +from gds.verification.generic_checks import ( + check_g001_domain_codomain_matching, + check_g003_direction_consistency, + check_g004_dangling_wirings, + check_g005_sequential_type_compatibility, + check_g006_covariant_acyclicity, +) +from gds.verification.spec_checks import ( + check_completeness, + check_determinism, + check_type_safety, +) + # ── Stock-Flow View ────────────────────────────────────────────── @@ -459,7 +463,7 @@ def test_state_dimensions_differ(self): class TestMarimoNotebook: """Validate the interactive marimo notebook for the Rosetta Stone guide.""" - NOTEBOOK = Path(__file__).resolve().parent / "notebook.py" + NOTEBOOK = Path(__file__).resolve().parent.parent / "notebooks" / "rosetta.py" def test_file_exists(self): assert self.NOTEBOOK.exists(), f"Notebook not found: {self.NOTEBOOK}" diff --git a/packages/gds-examples/guides/verification/test_verification_guide.py b/packages/gds-examples/tests/test_verification_guide.py similarity index 98% rename from packages/gds-examples/guides/verification/test_verification_guide.py rename to packages/gds-examples/tests/test_verification_guide.py index b3ae93e..3ee2776 100644 --- a/packages/gds-examples/guides/verification/test_verification_guide.py +++ b/packages/gds-examples/tests/test_verification_guide.py @@ -11,22 +11,7 @@ import importlib.util from pathlib import Path -from gds.verification.engine import verify -from gds.verification.findings import Severity -from gds.verification.generic_checks import ( - check_g001_domain_codomain_matching, - check_g002_signature_completeness, - check_g003_direction_consistency, - check_g004_dangling_wirings, - check_g005_sequential_type_compatibility, - check_g006_covariant_acyclicity, -) -from gds.verification.spec_checks import ( - check_canonical_wellformedness, - check_completeness, - check_determinism, -) -from guides.verification.broken_models import ( +from gds_examples.verification.broken_models import ( covariant_cycle_system, dangling_wiring_system, direction_contradiction_system, @@ -38,7 +23,7 @@ type_mismatch_system, write_conflict_spec, ) -from guides.verification.domain_checks_demo import ( +from gds_examples.verification.domain_checks_demo import ( cyclic_auxiliary_model, demo_broken_domain_full_verification, demo_cyclic_auxiliaries, @@ -49,7 +34,7 @@ orphan_stock_model, unused_converter_model, ) -from guides.verification.verification_demo import ( +from gds_examples.verification.verification_demo import ( demo_covariant_cycle, demo_dangling_wiring, demo_empty_canonical, @@ -60,6 +45,22 @@ demo_type_mismatch, demo_write_conflict, ) + +from gds.verification.engine import verify +from gds.verification.findings import Severity +from gds.verification.generic_checks import ( + check_g001_domain_codomain_matching, + check_g002_signature_completeness, + check_g003_direction_consistency, + check_g004_dangling_wirings, + check_g005_sequential_type_compatibility, + check_g006_covariant_acyclicity, +) +from gds.verification.spec_checks import ( + check_canonical_wellformedness, + check_completeness, + check_determinism, +) from stockflow.verification.checks import ( check_sf001_orphan_stocks, check_sf003_auxiliary_acyclicity, @@ -402,7 +403,7 @@ def test_severity_levels(self): # Marimo Notebook tests # ══════════════════════════════════════════════════════════════════ -_NOTEBOOK_PATH = Path(__file__).parent / "notebook.py" +_NOTEBOOK_PATH = Path(__file__).parent.parent / "notebooks" / "verification.py" class TestMarimoNotebook: diff --git a/packages/gds-examples/guides/visualization/test_visualization_guide.py b/packages/gds-examples/tests/test_visualization_guide.py similarity index 97% rename from packages/gds-examples/guides/visualization/test_visualization_guide.py rename to packages/gds-examples/tests/test_visualization_guide.py index 6747d38..4429bfe 100644 --- a/packages/gds-examples/guides/visualization/test_visualization_guide.py +++ b/packages/gds-examples/tests/test_visualization_guide.py @@ -9,8 +9,7 @@ from pathlib import Path import pytest - -from guides.visualization.all_views_demo import ( +from gds_examples.visualization.all_views_demo import ( generate_all_views, view_1_structural, view_2_canonical, @@ -19,12 +18,12 @@ view_5_parameter_influence, view_6_traceability, ) -from guides.visualization.cross_dsl_views import ( +from gds_examples.visualization.cross_dsl_views import ( double_integrator_views, generate_cross_dsl_views, sir_views, ) -from guides.visualization.theme_customization import ( +from gds_examples.visualization.theme_customization import ( ALL_THEMES, demo_all_themes, demo_default_vs_dark, @@ -257,7 +256,7 @@ def test_all_themes_produce_nonempty_output(self, theme): # ── Marimo Notebook ────────────────────────────────────────── -_NOTEBOOK_PATH = Path(__file__).parent / "notebook.py" +_NOTEBOOK_PATH = Path(__file__).parent.parent / "notebooks" / "visualization.py" class TestMarimoNotebook: diff --git a/packages/gds-framework/gds/compiler/compile.py b/packages/gds-framework/gds/compiler/compile.py index 2eeb026..4573ec8 100644 --- a/packages/gds-framework/gds/compiler/compile.py +++ b/packages/gds-framework/gds/compiler/compile.py @@ -208,6 +208,7 @@ def _default_block_compiler(block: AtomicBlock) -> BlockIR: """Default block compiler — extracts name and interface slots.""" return BlockIR( name=block.name, + block_type=getattr(block, "kind", ""), signature=( _ports_to_sig(block.interface.forward_in), _ports_to_sig(block.interface.forward_out), diff --git a/packages/gds-framework/gds/helpers.py b/packages/gds-framework/gds/helpers.py index b51c635..bd47e8c 100644 --- a/packages/gds-framework/gds/helpers.py +++ b/packages/gds-framework/gds/helpers.py @@ -7,6 +7,7 @@ from __future__ import annotations +import threading from collections.abc import Callable from typing import Any @@ -168,6 +169,7 @@ def _to_ports(names: list[str] | None) -> tuple[Port, ...]: CheckFn = Callable[[SystemIR], list[Finding]] _CUSTOM_CHECKS: list[CheckFn] = [] +_CUSTOM_CHECKS_LOCK = threading.Lock() def gds_check( @@ -177,7 +179,8 @@ def gds_check( """Decorator that registers a verification check function. Attaches ``check_id`` and ``severity`` as function attributes and - adds it to the module-level custom check registry. + adds it to the module-level custom check registry. Registration is + thread-safe. Usage:: @@ -189,19 +192,25 @@ def check_no_orphan_spaces(system: SystemIR) -> list[Finding]: def decorator(fn: CheckFn) -> CheckFn: fn.check_id = check_id # type: ignore[attr-defined] fn.severity = severity # type: ignore[attr-defined] - _CUSTOM_CHECKS.append(fn) + with _CUSTOM_CHECKS_LOCK: + _CUSTOM_CHECKS.append(fn) return fn return decorator def get_custom_checks() -> list[CheckFn]: - """Return all check functions registered via ``@gds_check``.""" - return list(_CUSTOM_CHECKS) + """Return a snapshot of all check functions registered via ``@gds_check``. + + Returns a copy so callers cannot mutate the internal registry. + """ + with _CUSTOM_CHECKS_LOCK: + return list(_CUSTOM_CHECKS) def all_checks() -> list[CheckFn]: """Return built-in generic checks + all custom-registered checks.""" from gds.verification.engine import ALL_CHECKS - return list(ALL_CHECKS) + list(_CUSTOM_CHECKS) + with _CUSTOM_CHECKS_LOCK: + return list(ALL_CHECKS) + list(_CUSTOM_CHECKS) diff --git a/packages/gds-framework/gds/parameters.py b/packages/gds-framework/gds/parameters.py index 8899fba..07f7883 100644 --- a/packages/gds-framework/gds/parameters.py +++ b/packages/gds-framework/gds/parameters.py @@ -11,9 +11,9 @@ from __future__ import annotations -from typing import Any +from typing import Any, Self -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator from gds.types.typedef import TypeDef # noqa: TC001 @@ -32,13 +32,36 @@ class ParameterDef(BaseModel): description: str = "" bounds: tuple[Any, Any] | None = None + @model_validator(mode="after") + def _validate_bounds(self) -> Self: + """Validate that bounds are comparable and correctly ordered.""" + if self.bounds is None: + return self + low, high = self.bounds + try: + result = low <= high + except TypeError as e: + raise ValueError( + f"ParameterDef '{self.name}': bounds ({low!r}, {high!r}) " + f"are not comparable: {e}" + ) from None + if not result: + raise ValueError( + f"ParameterDef '{self.name}': lower bound {low!r} " + f"exceeds upper bound {high!r}" + ) + return self + def check_value(self, value: Any) -> bool: """Check if a value satisfies this parameter's type and constraints.""" if not self.typedef.check_value(value): return False if self.bounds is not None: - low, high = self.bounds - if not (low <= value <= high): + try: + low, high = self.bounds + if not (low <= value <= high): + return False + except Exception: return False return True diff --git a/packages/gds-framework/gds/types/tokens.py b/packages/gds-framework/gds/types/tokens.py index 82f438d..52660e5 100644 --- a/packages/gds-framework/gds/types/tokens.py +++ b/packages/gds-framework/gds/types/tokens.py @@ -7,18 +7,23 @@ from __future__ import annotations +import unicodedata + def tokenize(signature: str) -> frozenset[str]: """Tokenize a signature string into a normalized frozen set of tokens. Splitting rules (applied in order): - 1. Split on ' + ' (the compound-type joiner). - 2. Split each part on ', ' (comma-space). - 3. Strip whitespace and lowercase each token. - 4. Discard empty strings. + 1. Apply Unicode NFC normalization (so that e.g. é as base+combining + matches precomposed é). + 2. Split on ' + ' (the compound-type joiner). + 3. Split each part on ', ' (comma-space). + 4. Strip whitespace and lowercase each token. + 5. Discard empty strings. """ if not signature: return frozenset() + signature = unicodedata.normalize("NFC", signature) tokens: set[str] = set() for plus_part in signature.split(" + "): for comma_part in plus_part.split(", "): diff --git a/packages/gds-framework/gds/types/typedef.py b/packages/gds-framework/gds/types/typedef.py index 78e56a1..dae0336 100644 --- a/packages/gds-framework/gds/types/typedef.py +++ b/packages/gds-framework/gds/types/typedef.py @@ -32,7 +32,12 @@ def check_value(self, value: Any) -> bool: """Check if a value satisfies this type definition.""" if not isinstance(value, self.python_type): return False - return self.constraint is None or self.constraint(value) + if self.constraint is None: + return True + try: + return bool(self.constraint(value)) + except Exception: + return False # ── Built-in types ────────────────────────────────────────── diff --git a/packages/gds-framework/gds/verification/generic_checks.py b/packages/gds-framework/gds/verification/generic_checks.py index df6cdbd..f616a0c 100644 --- a/packages/gds-framework/gds/verification/generic_checks.py +++ b/packages/gds-framework/gds/verification/generic_checks.py @@ -65,13 +65,20 @@ def check_g001_domain_codomain_matching(system: SystemIR) -> list[Finding]: def check_g002_signature_completeness(system: SystemIR) -> list[Finding]: """G-002: Every block must have at least one non-empty input slot and at least one non-empty output slot. + + BoundaryAction blocks (block_type == "boundary") are exempt from the + input requirement — they have no inputs by design, since they model + exogenous signals entering the system from outside. """ findings = [] for block in system.blocks: fwd_in, fwd_out, bwd_in, bwd_out = block.signature has_input = bool(fwd_in) or bool(bwd_in) has_output = bool(fwd_out) or bool(bwd_out) - has_required = has_input and has_output + + # BoundaryAction blocks have no inputs by design — only check outputs + is_boundary = block.block_type == "boundary" + has_required = has_output if is_boundary else has_input and has_output missing = [] if not has_input: diff --git a/packages/gds-framework/tests/test_cross_dsl_integration.py b/packages/gds-framework/tests/test_cross_dsl_integration.py new file mode 100644 index 0000000..966f4df --- /dev/null +++ b/packages/gds-framework/tests/test_cross_dsl_integration.py @@ -0,0 +1,893 @@ +"""Cross-DSL integration tests — round-trip compilation, canonical projection, +and error condition verification across all five GDS domain DSLs. + +Tests the GDS universal substrate claim: every DSL compiles to GDSSpec, +produces valid SystemIR, passes verification, and yields correct canonical +h = f . g decomposition. + +These tests require all DSL packages to be installed. When run in isolation +(e.g. ``--package gds-framework``), the entire module is skipped. + +Run with: + uv run pytest packages/gds-framework/tests/test_cross_dsl_integration.py -v +""" + +import importlib + +import pytest + +from gds.canonical import project_canonical +from gds.ir.models import SystemIR +from gds.spec import GDSSpec + +# Skip entire module if any DSL package is missing (CI runs per-package) +_REQUIRED = ["stockflow", "gds_control", "ogs", "gds_software", "gds_business"] +_missing = [m for m in _REQUIRED if importlib.util.find_spec(m) is None] +pytestmark = pytest.mark.skipif( + len(_missing) > 0, + reason=f"Cross-DSL tests require all DSL packages; missing: {_missing}", +) + +# ════════════════════════════════════════════════════════════════ +# StockFlow DSL +# ════════════════════════════════════════════════════════════════ + + +class TestStockFlowRoundTrip: + """StockFlow: declare → compile → verify → canonical.""" + + @pytest.fixture + def minimal_model(self): + from stockflow.dsl.elements import Flow, Stock + from stockflow.dsl.model import StockFlowModel + + return StockFlowModel( + name="Minimal SF", + stocks=[Stock(name="Population", initial=100.0)], + flows=[Flow(name="Growth", target="Population")], + ) + + @pytest.fixture + def two_stock_model(self): + from stockflow.dsl.elements import Auxiliary, Converter, Flow, Stock + from stockflow.dsl.model import StockFlowModel + + return StockFlowModel( + name="Two Stock SF", + stocks=[ + Stock(name="Prey", initial=100.0), + Stock(name="Predator", initial=20.0), + ], + flows=[ + Flow(name="Prey Growth", target="Prey"), + Flow(name="Predation", source="Prey", target="Predator"), + Flow(name="Predator Death", source="Predator"), + ], + auxiliaries=[ + Auxiliary(name="Growth Rate", inputs=["Prey"]), + Auxiliary(name="Predation Rate", inputs=["Prey", "Predator"]), + ], + converters=[Converter(name="Base Rate")], + ) + + def test_minimal_compile_to_spec(self, minimal_model): + from stockflow.dsl.compile import compile_model + + spec = compile_model(minimal_model) + assert isinstance(spec, GDSSpec) + assert spec.name == "Minimal SF" + assert len(spec.blocks) > 0 + assert len(spec.entities) == 1 + + def test_minimal_compile_to_system(self, minimal_model): + from stockflow.dsl.compile import compile_to_system + + ir = compile_to_system(minimal_model) + assert isinstance(ir, SystemIR) + assert ir.name == "Minimal SF" + + def test_minimal_canonical(self, minimal_model): + """1 stock → |X|=1, |f|=1, at least 1 policy.""" + from stockflow.dsl.compile import compile_model + + spec = compile_model(minimal_model) + canon = project_canonical(spec) + assert len(canon.state_variables) == 1 + assert len(canon.mechanism_blocks) == 1 + assert len(canon.policy_blocks) >= 1 + + def test_minimal_verify_no_domain_errors(self, minimal_model): + from stockflow.verification.engine import verify + + report = verify(minimal_model, include_gds_checks=False) + errors = [ + f for f in report.findings if not f.passed and f.severity.value == "error" + ] + assert errors == [] + + def test_two_stock_canonical(self, two_stock_model): + """2 stocks → |X|=2, |f|=2, policies include auxiliaries + flows.""" + from stockflow.dsl.compile import compile_model + + spec = compile_model(two_stock_model) + canon = project_canonical(spec) + assert len(canon.state_variables) == 2 + assert len(canon.mechanism_blocks) == 2 + # 2 auxiliaries + 3 flows = 5 policies + assert len(canon.policy_blocks) == 5 + # 1 converter = 1 boundary action + assert len(canon.boundary_blocks) == 1 + + def test_spec_validates(self, minimal_model): + from stockflow.dsl.compile import compile_model + + spec = compile_model(minimal_model) + errors = spec.validate_spec() + assert errors == [] + + +# ════════════════════════════════════════════════════════════════ +# Control DSL +# ════════════════════════════════════════════════════════════════ + + +class TestControlRoundTrip: + """Control: declare → compile → verify → canonical.""" + + @pytest.fixture + def minimal_model(self): + from gds_control.dsl.elements import Controller, Input, Sensor, State + from gds_control.dsl.model import ControlModel + + return ControlModel( + name="Minimal Control", + states=[State(name="x", initial=0.0)], + inputs=[Input(name="r")], + sensors=[Sensor(name="y", observes=["x"])], + controllers=[Controller(name="K", reads=["y", "r"], drives=["x"])], + ) + + @pytest.fixture + def mimo_model(self): + from gds_control.dsl.elements import Controller, Input, Sensor, State + from gds_control.dsl.model import ControlModel + + return ControlModel( + name="MIMO Control", + states=[State(name="x1"), State(name="x2")], + inputs=[Input(name="r1"), Input(name="r2")], + sensors=[ + Sensor(name="y1", observes=["x1"]), + Sensor(name="y2", observes=["x2"]), + ], + controllers=[ + Controller(name="K1", reads=["y1", "r1"], drives=["x1"]), + Controller(name="K2", reads=["y2", "r2"], drives=["x2"]), + ], + ) + + def test_minimal_compile_to_spec(self, minimal_model): + from gds_control.dsl.compile import compile_model + + spec = compile_model(minimal_model) + assert isinstance(spec, GDSSpec) + assert len(spec.entities) == 1 + assert len(spec.blocks) == 4 # input + sensor + controller + state + + def test_minimal_compile_to_system(self, minimal_model): + from gds_control.dsl.compile import compile_to_system + + ir = compile_to_system(minimal_model) + assert isinstance(ir, SystemIR) + temporal = [w for w in ir.wirings if w.is_temporal] + assert len(temporal) == 1 # state → sensor loop + + def test_minimal_canonical(self, minimal_model): + """SISO: |X|=1, |f|=1, g = sensor + controller.""" + from gds_control.dsl.compile import compile_model + + spec = compile_model(minimal_model) + canon = project_canonical(spec) + assert len(canon.state_variables) == 1 + assert len(canon.mechanism_blocks) == 1 + assert len(canon.policy_blocks) == 2 # sensor + controller + assert len(canon.boundary_blocks) == 1 # input + assert len(canon.control_blocks) == 0 # ControlAction unused + + def test_mimo_canonical(self, mimo_model): + """MIMO: |X|=2, |f|=2, g = 2 sensors + 2 controllers.""" + from gds_control.dsl.compile import compile_model + + spec = compile_model(mimo_model) + canon = project_canonical(spec) + assert len(canon.state_variables) == 2 + assert len(canon.mechanism_blocks) == 2 + assert len(canon.policy_blocks) == 4 + assert len(canon.boundary_blocks) == 2 + + def test_verify_no_domain_errors(self, minimal_model): + from gds_control.verification.engine import verify + + report = verify(minimal_model, include_gds_checks=False) + errors = [ + f for f in report.findings if not f.passed and f.severity.value == "error" + ] + assert errors == [] + + def test_spec_validates(self, minimal_model): + from gds_control.dsl.compile import compile_model + + spec = compile_model(minimal_model) + errors = spec.validate_spec() + assert errors == [] + + +# ════════════════════════════════════════════════════════════════ +# OGS (Games) DSL +# ════════════════════════════════════════════════════════════════ + + +class TestOGSRoundTrip: + """OGS: Pattern → compile_pattern_to_spec → canonical.""" + + @pytest.fixture + def single_decision(self): + from gds.types.interface import port + from ogs.dsl.games import DecisionGame + from ogs.dsl.pattern import Pattern + from ogs.dsl.types import Signature + + game = DecisionGame( + name="Player", + signature=Signature( + x=(port("Observation"),), + y=(port("Choice"),), + r=(port("Payoff"),), + ), + ) + return Pattern(name="Single Decision", game=game) + + @pytest.fixture + def sequential_pattern(self): + from gds.types.interface import port + from ogs.dsl.games import CovariantFunction + from ogs.dsl.pattern import Pattern, PatternInput + from ogs.dsl.types import InputType, Signature + + a = CovariantFunction( + name="Observe", + signature=Signature( + x=(port("Raw"),), + y=(port("Processed"),), + ), + ) + b = CovariantFunction( + name="Decide", + signature=Signature( + x=(port("Processed"),), + y=(port("Action"),), + ), + ) + return Pattern( + name="Sequential", + game=a >> b, + inputs=[ + PatternInput( + name="External", + input_type=InputType.SENSOR, + target_game="Observe", + flow_label="Raw", + ), + ], + ) + + def test_single_decision_to_spec(self, single_decision): + from ogs.dsl.spec_bridge import compile_pattern_to_spec + + spec = compile_pattern_to_spec(single_decision) + assert isinstance(spec, GDSSpec) + assert len(spec.blocks) == 1 + + def test_single_decision_canonical(self, single_decision): + """Games are pure policy: f=empty, X=empty, h=g.""" + from ogs.dsl.spec_bridge import compile_pattern_to_spec + + spec = compile_pattern_to_spec(single_decision) + canon = project_canonical(spec) + assert len(canon.state_variables) == 0 + assert len(canon.mechanism_blocks) == 0 + assert len(canon.policy_blocks) == 1 + + def test_sequential_to_spec_with_boundary(self, sequential_pattern): + from ogs.dsl.spec_bridge import compile_pattern_to_spec + + spec = compile_pattern_to_spec(sequential_pattern) + assert len(spec.blocks) == 3 # 2 games + 1 boundary + assert len(spec.entities) == 0 # no state + + def test_sequential_canonical(self, sequential_pattern): + """Sequential pipeline: 2 policies, 1 boundary, no state.""" + from ogs.dsl.spec_bridge import compile_pattern_to_spec + + spec = compile_pattern_to_spec(sequential_pattern) + canon = project_canonical(spec) + assert len(canon.state_variables) == 0 + assert len(canon.mechanism_blocks) == 0 + assert len(canon.policy_blocks) == 2 + assert len(canon.boundary_blocks) == 1 + + def test_pattern_ir_to_system_ir(self, single_decision): + """PatternIR projects to SystemIR for GDS tooling interop.""" + from ogs.dsl.compile import compile_to_ir + + ir = compile_to_ir(single_decision) + system_ir = ir.to_system_ir() + assert isinstance(system_ir, SystemIR) + assert len(system_ir.blocks) >= 1 + + +# ════════════════════════════════════════════════════════════════ +# Software DSL +# ════════════════════════════════════════════════════════════════ + + +class TestSoftwareRoundTrip: + """Software: each diagram type compiles through GDS pipeline.""" + + @pytest.fixture + def dfd_model(self): + from gds_software.dfd.elements import ( + DataFlow, + DataStore, + ExternalEntity, + Process, + ) + from gds_software.dfd.model import DFDModel + + return DFDModel( + name="DFD", + external_entities=[ExternalEntity(name="User")], + processes=[Process(name="Auth")], + data_stores=[DataStore(name="DB")], + data_flows=[ + DataFlow(name="Login", source="User", target="Auth"), + DataFlow(name="Save", source="Auth", target="DB"), + ], + ) + + @pytest.fixture + def sm_model(self): + from gds_software.statemachine.elements import Event, State, Transition + from gds_software.statemachine.model import StateMachineModel + + return StateMachineModel( + name="SM", + states=[State(name="Off", is_initial=True), State(name="On")], + events=[Event(name="Toggle")], + transitions=[ + Transition(name="TurnOn", source="Off", target="On", event="Toggle"), + Transition(name="TurnOff", source="On", target="Off", event="Toggle"), + ], + ) + + @pytest.fixture + def dep_model(self): + from gds_software.dependency.elements import Dep, Module + from gds_software.dependency.model import DependencyModel + + return DependencyModel( + name="Dep", + modules=[Module(name="core", layer=0), Module(name="api", layer=1)], + deps=[Dep(source="api", target="core")], + ) + + def test_dfd_round_trip(self, dfd_model): + spec = dfd_model.compile() + assert isinstance(spec, GDSSpec) + assert len(spec.blocks) > 0 + + ir = dfd_model.compile_system() + assert isinstance(ir, SystemIR) + + canon = project_canonical(spec) + # DFD with DataStore has mechanisms (state) + assert len(canon.mechanism_blocks) > 0 + assert len(canon.boundary_blocks) > 0 # ExternalEntity → BoundaryAction + + def test_sm_round_trip(self, sm_model): + spec = sm_model.compile() + assert isinstance(spec, GDSSpec) + + ir = sm_model.compile_system() + assert isinstance(ir, SystemIR) + + canon = project_canonical(spec) + # State machine has state + assert len(canon.mechanism_blocks) > 0 + + def test_dep_stateless(self, dep_model): + """Dependency graphs are stateless: h = g, no mechanisms.""" + spec = dep_model.compile() + canon = project_canonical(spec) + assert len(canon.mechanism_blocks) == 0 + assert len(canon.policy_blocks) > 0 + + def test_dfd_verify(self, dfd_model): + from gds_software.verification.engine import verify + + report = verify(dfd_model) + assert report.system_name == "DFD" + assert len(report.findings) > 0 + + +# ════════════════════════════════════════════════════════════════ +# Business DSL +# ════════════════════════════════════════════════════════════════ + + +class TestBusinessRoundTrip: + """Business: CLD, SCN, VSM compile through GDS pipeline.""" + + @pytest.fixture + def cld_model(self): + from gds_business.cld.elements import CausalLink, Variable + from gds_business.cld.model import CausalLoopModel + + return CausalLoopModel( + name="Simple CLD", + variables=[Variable(name="A"), Variable(name="B")], + links=[ + CausalLink(source="A", target="B", polarity="+"), + CausalLink(source="B", target="A", polarity="-"), + ], + ) + + @pytest.fixture + def scn_model(self): + from gds_business.supplychain.elements import ( + DemandSource, + OrderPolicy, + Shipment, + SupplyNode, + ) + from gds_business.supplychain.model import SupplyChainModel + + return SupplyChainModel( + name="Simple SCN", + nodes=[ + SupplyNode(name="Factory"), + SupplyNode(name="Retailer"), + ], + shipments=[ + Shipment(name="F->R", source="Factory", target="Retailer"), + ], + demand_sources=[ + DemandSource(name="Customer", target="Retailer"), + ], + order_policies=[ + OrderPolicy(name="Reorder", node="Retailer", inputs=["Retailer"]), + ], + ) + + @pytest.fixture + def vsm_stateless(self): + from gds_business.vsm.elements import ( + Customer, + MaterialFlow, + ProcessStep, + Supplier, + ) + from gds_business.vsm.model import ValueStreamModel + + return ValueStreamModel( + name="Stateless VSM", + steps=[ + ProcessStep(name="Step1", cycle_time=10.0), + ProcessStep(name="Step2", cycle_time=20.0), + ], + suppliers=[Supplier(name="Sup")], + customers=[Customer(name="Cust", takt_time=30.0)], + material_flows=[ + MaterialFlow(source="Sup", target="Step1"), + MaterialFlow(source="Step1", target="Step2"), + MaterialFlow(source="Step2", target="Cust"), + ], + ) + + @pytest.fixture + def vsm_stateful(self): + from gds_business.vsm.elements import ( + InventoryBuffer, + MaterialFlow, + ProcessStep, + Supplier, + ) + from gds_business.vsm.model import ValueStreamModel + + return ValueStreamModel( + name="Stateful VSM", + steps=[ + ProcessStep(name="Cutting", cycle_time=30.0), + ProcessStep(name="Assembly", cycle_time=25.0), + ], + buffers=[ + InventoryBuffer(name="WIP", between=("Cutting", "Assembly")), + ], + suppliers=[Supplier(name="Sup")], + material_flows=[ + MaterialFlow(source="Sup", target="Cutting"), + MaterialFlow(source="Cutting", target="WIP"), + ], + ) + + def test_cld_stateless(self, cld_model): + """CLD: |X|=0, |f|=0, h = g (stateless).""" + spec = cld_model.compile() + assert isinstance(spec, GDSSpec) + + canon = project_canonical(spec) + assert len(canon.state_variables) == 0 + assert len(canon.mechanism_blocks) == 0 + assert len(canon.policy_blocks) > 0 + + def test_cld_system_ir(self, cld_model): + ir = cld_model.compile_system() + assert isinstance(ir, SystemIR) + assert ir.name == "Simple CLD" + + def test_scn_stateful(self, scn_model): + """SCN: |X|>0, |f|>0, h = f . g (stateful).""" + spec = scn_model.compile() + canon = project_canonical(spec) + assert len(canon.state_variables) > 0 + assert len(canon.mechanism_blocks) > 0 + assert len(canon.policy_blocks) > 0 + + def test_vsm_stateless_canonical(self, vsm_stateless): + """VSM without buffers: |X|=0, h = g.""" + spec = vsm_stateless.compile() + canon = project_canonical(spec) + assert len(canon.state_variables) == 0 + assert len(canon.mechanism_blocks) == 0 + + def test_vsm_stateful_canonical(self, vsm_stateful): + """VSM with buffers: |X|>0, h = f . g.""" + spec = vsm_stateful.compile() + canon = project_canonical(spec) + assert len(canon.state_variables) > 0 + assert len(canon.mechanism_blocks) > 0 + + def test_business_verify(self, cld_model, scn_model): + from gds_business.verification.engine import verify + + cld_report = verify(cld_model) + assert cld_report.system_name == "Simple CLD" + + scn_report = verify(scn_model) + assert scn_report.system_name == "Simple SCN" + + +# ════════════════════════════════════════════════════════════════ +# Canonical Spectrum — cross-DSL comparison +# ════════════════════════════════════════════════════════════════ + + +class TestCanonicalSpectrum: + """Validate the canonical h = f . g spectrum across all DSLs. + + The GDS canonical decomposition should reflect each domain's nature: + - Stateless domains (CLD, OGS, dependency): f = empty, h = g + - Stateful domains (stockflow, control, SCN): f non-empty, h = f . g + """ + + def test_stockflow_full_dynamical(self): + """StockFlow is state-dominant: |X|=|stocks|, |f|=|stocks|.""" + from stockflow.dsl.compile import compile_model + from stockflow.dsl.elements import Auxiliary, Flow, Stock + from stockflow.dsl.model import StockFlowModel + + model = StockFlowModel( + name="SIR", + stocks=[ + Stock(name="S", initial=999.0), + Stock(name="I", initial=1.0), + Stock(name="R", initial=0.0), + ], + flows=[ + Flow(name="Infection", source="S", target="I"), + Flow(name="Recovery", source="I", target="R"), + ], + auxiliaries=[ + Auxiliary(name="Infection Rate", inputs=["S", "I"]), + Auxiliary(name="Recovery Rate", inputs=["I"]), + ], + ) + spec = compile_model(model) + canon = project_canonical(spec) + assert len(canon.state_variables) == 3 + assert len(canon.mechanism_blocks) == 3 + + def test_control_full_dynamical(self): + """Control is full dynamical: |X|=|states|, |f|=|states|.""" + from gds_control.dsl.compile import compile_model + from gds_control.dsl.elements import Controller, Input, Sensor, State + from gds_control.dsl.model import ControlModel + + model = ControlModel( + name="SISO", + states=[State(name="x")], + inputs=[Input(name="r")], + sensors=[Sensor(name="y", observes=["x"])], + controllers=[Controller(name="K", reads=["y", "r"], drives=["x"])], + ) + spec = compile_model(model) + canon = project_canonical(spec) + assert len(canon.state_variables) == 1 + assert len(canon.mechanism_blocks) == 1 + + def test_ogs_pure_policy(self): + """OGS is pure policy: f=empty, X=empty, h=g.""" + from gds.types.interface import port + from ogs.dsl.games import DecisionGame + from ogs.dsl.pattern import Pattern + from ogs.dsl.spec_bridge import compile_pattern_to_spec + from ogs.dsl.types import Signature + + game = DecisionGame( + name="Agent", + signature=Signature( + x=(port("Obs"),), + y=(port("Act"),), + r=(port("Pay"),), + ), + ) + spec = compile_pattern_to_spec(Pattern(name="Game", game=game)) + canon = project_canonical(spec) + assert len(canon.state_variables) == 0 + assert len(canon.mechanism_blocks) == 0 + assert len(canon.policy_blocks) == 1 + + def test_dependency_stateless(self): + """Dependency graphs are stateless: h = g.""" + from gds_software.dependency.elements import Dep, Module + from gds_software.dependency.model import DependencyModel + + model = DependencyModel( + name="Dep", + modules=[Module(name="core", layer=0), Module(name="api", layer=1)], + deps=[Dep(source="api", target="core")], + ) + spec = model.compile() + canon = project_canonical(spec) + assert len(canon.state_variables) == 0 + assert len(canon.mechanism_blocks) == 0 + + def test_cld_stateless(self): + """CLD is stateless signal relay: h = g.""" + from gds_business.cld.elements import CausalLink, Variable + from gds_business.cld.model import CausalLoopModel + + model = CausalLoopModel( + name="CLD", + variables=[Variable(name="X"), Variable(name="Y")], + links=[CausalLink(source="X", target="Y", polarity="+")], + ) + spec = model.compile() + canon = project_canonical(spec) + assert len(canon.state_variables) == 0 + assert len(canon.mechanism_blocks) == 0 + + +# ════════════════════════════════════════════════════════════════ +# Error Condition Tests +# ════════════════════════════════════════════════════════════════ + + +class TestStockFlowErrorConditions: + """StockFlow models that trigger specific domain check failures.""" + + def test_orphan_stock_warning(self): + """SF-001: Stock with no flows triggers warning.""" + from stockflow.dsl.elements import Stock + from stockflow.dsl.model import StockFlowModel + from stockflow.verification.engine import verify + + model = StockFlowModel(name="Orphan", stocks=[Stock(name="Lonely")]) + report = verify(model, include_gds_checks=False) + sf001 = [f for f in report.findings if f.check_id == "SF-001" and not f.passed] + assert len(sf001) == 1 + assert "Lonely" in sf001[0].message + + def test_invalid_flow_target_rejected(self): + """Flow referencing non-existent stock fails at construction time.""" + from stockflow.dsl.elements import Flow, Stock + from stockflow.dsl.errors import SFValidationError + from stockflow.dsl.model import StockFlowModel + + with pytest.raises(SFValidationError): + StockFlowModel( + name="Bad", + stocks=[Stock(name="A")], + flows=[Flow(name="F", target="NonExistent")], + ) + + def test_auxiliary_cycle_detected(self): + """SF-003: Cycles in auxiliary dependency graph trigger error.""" + from stockflow.dsl.elements import Auxiliary, Flow, Stock + from stockflow.dsl.model import StockFlowModel + from stockflow.verification.engine import verify + + model = StockFlowModel( + name="Cycle", + stocks=[Stock(name="S")], + flows=[Flow(name="F", target="S")], + auxiliaries=[ + Auxiliary(name="A", inputs=["B"]), + Auxiliary(name="B", inputs=["A"]), + ], + ) + report = verify(model, include_gds_checks=False) + sf003 = [f for f in report.findings if f.check_id == "SF-003" and not f.passed] + assert len(sf003) > 0 + + +class TestControlErrorConditions: + """Control models that trigger specific domain check failures.""" + + def test_undriven_state_warning(self): + """CS-001: State not driven by any controller.""" + from gds_control.dsl.elements import Sensor, State + from gds_control.dsl.model import ControlModel + from gds_control.verification.engine import verify + + model = ControlModel( + name="Undriven", + states=[State(name="x")], + sensors=[Sensor(name="y", observes=["x"])], + ) + report = verify(model, include_gds_checks=False) + cs001 = [f for f in report.findings if f.check_id == "CS-001" and not f.passed] + assert len(cs001) == 1 + + def test_unobserved_state_warning(self): + """CS-002: State not observed by any sensor.""" + from gds_control.dsl.elements import Controller, State + from gds_control.dsl.model import ControlModel + from gds_control.verification.engine import verify + + model = ControlModel( + name="Unobserved", + states=[State(name="x")], + controllers=[Controller(name="K", reads=[], drives=["x"])], + ) + report = verify(model, include_gds_checks=False) + cs002 = [f for f in report.findings if f.check_id == "CS-002" and not f.passed] + assert len(cs002) == 1 + + def test_invalid_sensor_observes_rejected(self): + """Sensor observing non-existent state fails at construction.""" + from gds_control.dsl.elements import Sensor, State + from gds_control.dsl.errors import CSValidationError + from gds_control.dsl.model import ControlModel + + with pytest.raises(CSValidationError): + ControlModel( + name="Bad", + states=[State(name="x")], + sensors=[Sensor(name="y", observes=["nonexistent"])], + ) + + +class TestBusinessErrorConditions: + """Business models that trigger specific domain check failures.""" + + def test_cld_self_loop_rejected_at_construction(self): + """Self-loops are rejected by model_validator at construction time.""" + from gds_business.cld.elements import CausalLink, Variable + from gds_business.cld.model import CausalLoopModel + + with pytest.raises(Exception, match="Self-loop"): + CausalLoopModel( + name="Self Loop", + variables=[Variable(name="X")], + links=[CausalLink(source="X", target="X", polarity="+")], + ) + + def test_cld_unreachable_variable(self): + """CLD-002: Variable not reachable from any other variable.""" + from gds_business.cld.elements import CausalLink, Variable + from gds_business.cld.model import CausalLoopModel + from gds_business.verification.engine import verify + + model = CausalLoopModel( + name="Disconnected", + variables=[ + Variable(name="A"), + Variable(name="B"), + Variable(name="C"), + ], + links=[CausalLink(source="A", target="B", polarity="+")], + ) + report = verify(model, include_gds_checks=False) + cld002 = [ + f for f in report.findings if f.check_id == "CLD-002" and not f.passed + ] + assert len(cld002) > 0 + + +# ════════════════════════════════════════════════════════════════ +# GDS Generic Checks Across DSLs +# ════════════════════════════════════════════════════════════════ + + +class TestGDSGenericChecks: + """Verify GDS G-001..G-006 run on compiled SystemIR from each DSL. + + G-002 (signature completeness) flags BoundaryAction blocks as having + no inputs — this is expected since they are exogenous sources. + """ + + def test_stockflow_gds_checks(self): + from stockflow.dsl.elements import Flow, Stock + from stockflow.dsl.model import StockFlowModel + from stockflow.verification.engine import verify + + model = StockFlowModel( + name="SF", + stocks=[Stock(name="S")], + flows=[Flow(name="F", target="S")], + ) + report = verify(model, include_gds_checks=True) + gds_findings = [f for f in report.findings if f.check_id.startswith("G-")] + assert len(gds_findings) > 0 + + def test_control_gds_checks(self): + from gds_control.dsl.elements import Controller, Input, Sensor, State + from gds_control.dsl.model import ControlModel + from gds_control.verification.engine import verify + + model = ControlModel( + name="CS", + states=[State(name="x")], + inputs=[Input(name="r")], + sensors=[Sensor(name="y", observes=["x"])], + controllers=[Controller(name="K", reads=["y", "r"], drives=["x"])], + ) + report = verify(model, include_gds_checks=True) + gds_findings = [f for f in report.findings if f.check_id.startswith("G-")] + assert len(gds_findings) > 0 + + def test_software_gds_checks(self): + from gds_software.dfd.elements import DataFlow, ExternalEntity, Process + from gds_software.dfd.model import DFDModel + from gds_software.verification.engine import verify + + model = DFDModel( + name="DFD", + external_entities=[ExternalEntity(name="User")], + processes=[Process(name="Auth")], + data_flows=[DataFlow(name="Login", source="User", target="Auth")], + ) + report = verify(model) + gds_findings = [f for f in report.findings if f.check_id.startswith("G-")] + assert len(gds_findings) > 0 + + def test_business_gds_checks(self): + from gds_business.cld.elements import CausalLink, Variable + from gds_business.cld.model import CausalLoopModel + from gds_business.verification.engine import verify + + # Bidirectional CLD — both variables have inputs, so G-002 should pass + model = CausalLoopModel( + name="CLD", + variables=[Variable(name="A"), Variable(name="B")], + links=[ + CausalLink(source="A", target="B", polarity="+"), + CausalLink(source="B", target="A", polarity="-"), + ], + ) + report = verify(model) + gds_findings = [f for f in report.findings if f.check_id.startswith("G-")] + assert len(gds_findings) > 0 + failed_gds = [f for f in gds_findings if not f.passed] + assert len(failed_gds) == 0 diff --git a/packages/gds-framework/tests/test_helpers.py b/packages/gds-framework/tests/test_helpers.py index 4d22e29..d244e88 100644 --- a/packages/gds-framework/tests/test_helpers.py +++ b/packages/gds-framework/tests/test_helpers.py @@ -20,6 +20,7 @@ ) from gds.helpers import ( _CUSTOM_CHECKS, + _CUSTOM_CHECKS_LOCK, all_checks, entity, gds_check, @@ -245,11 +246,13 @@ def test_empty(self): class TestGdsCheck: def setup_method(self): """Clear custom check registry before each test.""" - _CUSTOM_CHECKS.clear() + with _CUSTOM_CHECKS_LOCK: + _CUSTOM_CHECKS.clear() def teardown_method(self): """Clear custom check registry after each test.""" - _CUSTOM_CHECKS.clear() + with _CUSTOM_CHECKS_LOCK: + _CUSTOM_CHECKS.clear() def test_registers_check(self): @gds_check("TEST-001", Severity.WARNING) @@ -309,3 +312,36 @@ def check_b(system: SystemIR) -> list[Finding]: assert len(checks) == 2 assert checks[0] is check_a assert checks[1] is check_b + + def test_concurrent_registration(self): + """Register checks from multiple threads; all must appear.""" + import threading + + num_threads = 20 + barrier = threading.Barrier(num_threads) + errors: list[Exception] = [] + + def register_check(index: int) -> None: + try: + barrier.wait(timeout=5) + + @gds_check(f"THREAD-{index:03d}") + def _check(system: SystemIR) -> list[Finding]: + return [] + except Exception as exc: + errors.append(exc) + + threads = [ + threading.Thread(target=register_check, args=(i,)) + for i in range(num_threads) + ] + for t in threads: + t.start() + for t in threads: + t.join(timeout=10) + + assert not errors, f"Thread errors: {errors}" + checks = get_custom_checks() + assert len(checks) == num_threads + check_ids = {c.check_id for c in checks} # type: ignore[attr-defined] + assert check_ids == {f"THREAD-{i:03d}" for i in range(num_threads)} diff --git a/packages/gds-framework/tests/test_types.py b/packages/gds-framework/tests/test_types.py index 8e914a2..590200c 100644 --- a/packages/gds-framework/tests/test_types.py +++ b/packages/gds-framework/tests/test_types.py @@ -45,6 +45,35 @@ def test_whitespace_stripped(self): def test_case_normalization(self): assert tokenize("TEMPERATURE") == frozenset({"temperature"}) + def test_unicode_nfc_normalization(self): + """NFC and NFD forms of the same character produce identical tokens.""" + import unicodedata + + nfc = unicodedata.normalize("NFC", "Température") # precomposed é + nfd = unicodedata.normalize("NFD", "Température") # base e + combining accent + assert nfc != nfd # different byte sequences + assert tokenize(nfc) == tokenize(nfd) + + def test_unicode_accented_overlap(self): + """Accented tokens match across NFC/NFD encodings in overlap checks.""" + import unicodedata + + nfc = unicodedata.normalize("NFC", "Vélocité") + nfd = unicodedata.normalize("NFD", "Vélocité") + assert tokens_overlap(nfc, nfd) is True + + def test_unicode_accented_subset(self): + """Accented tokens match across NFC/NFD encodings in subset checks.""" + import unicodedata + + nfc = unicodedata.normalize("NFC", "Résistance") + nfd = unicodedata.normalize("NFD", "Résistance + Capacitance") + assert tokens_subset(nfc, nfd) is True + + def test_unicode_plain_ascii_unaffected(self): + """NFC normalization is a no-op for plain ASCII strings.""" + assert tokenize("Temperature") == frozenset({"temperature"}) + # ── tokens_subset() ───────────────────────────────────────── @@ -180,6 +209,34 @@ def test_frozen(self): with pytest.raises(ValidationError): t.name = "Other" # type: ignore[misc] + def test_constraint_exception_returns_false(self): + """Constraint that raises should return False, not propagate.""" + t = TypeDef( + name="Bad", + python_type=float, + constraint=lambda x: 1 / 0, # ZeroDivisionError + ) + assert t.check_value(1.0) is False + + def test_constraint_type_error_returns_false(self): + """Constraint that raises TypeError should return False.""" + t = TypeDef( + name="Bad", + python_type=float, + constraint=lambda x: x > "not a number", # TypeError + ) + assert t.check_value(1.0) is False + + def test_constraint_returns_truthy_non_bool(self): + """Constraint returning truthy non-bool value should work.""" + t = TypeDef( + name="Truthy", + python_type=str, + constraint=lambda x: x, # non-empty string is truthy + ) + assert t.check_value("hello") is True + assert t.check_value("") is False + # ── Built-in types ─────────────────────────────────────────── diff --git a/packages/gds-framework/tests/test_v02_features.py b/packages/gds-framework/tests/test_v02_features.py index ee44d60..688f800 100644 --- a/packages/gds-framework/tests/test_v02_features.py +++ b/packages/gds-framework/tests/test_v02_features.py @@ -123,6 +123,26 @@ def test_check_value_no_bounds(self, float_type): p = ParameterDef(name="rate", typedef=float_type) assert p.check_value(999.0) is True + def test_bounds_inverted_raises(self, float_type): + """Inverted bounds (low > high) should fail at construction.""" + with pytest.raises(ValidationError, match="exceeds upper bound"): + ParameterDef(name="rate", typedef=float_type, bounds=(1.0, 0.0)) + + def test_bounds_non_comparable_raises(self, float_type): + """Non-comparable bounds should fail at construction.""" + with pytest.raises(ValidationError, match="not comparable"): + ParameterDef(name="rate", typedef=float_type, bounds=("a", 1)) + + def test_bounds_equal_is_valid(self, float_type): + """Equal bounds (low == high) should be allowed.""" + p = ParameterDef(name="rate", typedef=float_type, bounds=(0.5, 0.5)) + assert p.bounds == (0.5, 0.5) + + def test_bounds_none_is_valid(self, float_type): + """None bounds should be allowed (no validation).""" + p = ParameterDef(name="rate", typedef=float_type, bounds=None) + assert p.bounds is None + class TestParameterSchema: def test_empty_schema(self): diff --git a/packages/gds-framework/tests/test_verification.py b/packages/gds-framework/tests/test_verification.py index 7c3652a..fbccda0 100644 --- a/packages/gds-framework/tests/test_verification.py +++ b/packages/gds-framework/tests/test_verification.py @@ -82,6 +82,57 @@ def test_missing_output_flags(self): failed = [f for f in findings if not f.passed] assert len(failed) >= 1 + def test_boundary_action_no_inputs_passes(self): + """BoundaryAction blocks have no inputs by design — G-002 should pass.""" + sys = SystemIR( + name="Test", + blocks=[ + BlockIR( + name="Sensor", + block_type="boundary", + signature=("", "Temperature", "", ""), + ), + ], + wirings=[], + ) + findings = check_g002_signature_completeness(sys) + assert len(findings) == 1 + assert findings[0].passed + + def test_boundary_action_no_outputs_still_fails(self): + """BoundaryAction with no outputs should still fail G-002.""" + sys = SystemIR( + name="Test", + blocks=[ + BlockIR( + name="BadBoundary", + block_type="boundary", + signature=("", "", "", ""), + ), + ], + wirings=[], + ) + findings = check_g002_signature_completeness(sys) + assert len(findings) == 1 + assert not findings[0].passed + + def test_non_boundary_no_inputs_still_fails(self): + """Non-boundary blocks without inputs should still fail G-002.""" + sys = SystemIR( + name="Test", + blocks=[ + BlockIR( + name="Orphan", + block_type="policy", + signature=("", "Signal", "", ""), + ), + ], + wirings=[], + ) + findings = check_g002_signature_completeness(sys) + assert len(findings) == 1 + assert not findings[0].passed + # ── G-003: Direction consistency ───────────────────────────── diff --git a/packages/gds-games/ogs/dsl/pattern.py b/packages/gds-games/ogs/dsl/pattern.py index 33b647a..ec81004 100644 --- a/packages/gds-games/ogs/dsl/pattern.py +++ b/packages/gds-games/ogs/dsl/pattern.py @@ -3,6 +3,11 @@ """ from __future__ import annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from gds.ir.models import SystemIR + from gds.spec import GDSSpec from pydantic import BaseModel, Field @@ -140,3 +145,17 @@ def specialize( else self.initializations, source=source if source is not None else self.source, ) + + # ── Compilation ───────────────────────────────────────── + + def compile(self) -> GDSSpec: + """Compile this pattern to a GDS specification.""" + from ogs.dsl.spec_bridge import compile_pattern_to_spec + + return compile_pattern_to_spec(self) + + def compile_system(self) -> SystemIR: + """Compile this pattern to a flat SystemIR for verification + visualization.""" + from ogs.dsl.compile import compile_to_ir + + return compile_to_ir(self).to_system_ir() diff --git a/packages/gds-games/ogs/verification/engine.py b/packages/gds-games/ogs/verification/engine.py index c1b7a07..36667b7 100644 --- a/packages/gds-games/ogs/verification/engine.py +++ b/packages/gds-games/ogs/verification/engine.py @@ -56,14 +56,14 @@ def verify( pattern: PatternIR, - checks: list[Callable[[PatternIR], list[Finding]]] | None = None, + domain_checks: list[Callable[[PatternIR], list[Finding]]] | None = None, include_gds_checks: bool = True, ) -> VerificationReport: """Run verification checks against a PatternIR. Args: pattern: The pattern to verify. - checks: Optional subset of OGS domain checks to run. Defaults to + domain_checks: Optional subset of OGS domain checks to run. Defaults to ``ALL_CHECKS`` (8 OGS-specific checks). include_gds_checks: Run GDS generic checks (G-001..G-006) via ``to_system_ir()`` projection. Defaults to True. @@ -71,7 +71,7 @@ def verify( Returns: A VerificationReport with all findings. """ - checks = checks or ALL_CHECKS + checks = domain_checks or ALL_CHECKS findings: list[Finding] = [] for check_fn in checks: findings.extend(check_fn(pattern)) diff --git a/packages/gds-games/tests/test_pattern_compile.py b/packages/gds-games/tests/test_pattern_compile.py new file mode 100644 index 0000000..6cd8baf --- /dev/null +++ b/packages/gds-games/tests/test_pattern_compile.py @@ -0,0 +1,108 @@ +"""Tests for Pattern.compile() and Pattern.compile_system() convenience methods.""" + +from gds.ir.models import SystemIR +from gds.spec import GDSSpec +from gds.types.interface import port +from ogs.dsl.games import CovariantFunction, DecisionGame +from ogs.dsl.pattern import Pattern, PatternInput +from ogs.dsl.types import CompositionType, InputType, Signature + + +def _sequential_pattern() -> Pattern: + a = CovariantFunction( + name="Transform A", + signature=Signature( + x=(port("Raw Input"),), + y=(port("Intermediate"),), + ), + ) + b = CovariantFunction( + name="Transform B", + signature=Signature( + x=(port("Intermediate"),), + y=(port("Final Output"),), + ), + ) + return Pattern( + name="Simple Sequential", + game=a >> b, + composition_type=CompositionType.SEQUENTIAL, + ) + + +def _pattern_with_inputs() -> Pattern: + agent = DecisionGame( + name="Agent", + signature=Signature( + x=(port("Observation"),), + y=(port("Action"),), + r=(port("Reward"),), + ), + ) + return Pattern( + name="Agent With Input", + game=agent, + inputs=[ + PatternInput( + name="External Signal", + input_type=InputType.EXTERNAL_WORLD, + target_game="Agent", + flow_label="Observation", + ), + ], + composition_type=CompositionType.SEQUENTIAL, + ) + + +class TestPatternCompile: + """Pattern.compile() returns a GDSSpec.""" + + def test_returns_gds_spec(self): + spec = _sequential_pattern().compile() + assert isinstance(spec, GDSSpec) + + def test_spec_name_matches_pattern(self): + spec = _sequential_pattern().compile() + assert spec.name == "Simple Sequential" + + def test_spec_contains_blocks(self): + spec = _sequential_pattern().compile() + assert len(spec.blocks) >= 2 + + def test_compile_with_inputs(self): + spec = _pattern_with_inputs().compile() + assert isinstance(spec, GDSSpec) + assert "External Signal" in spec.blocks + + def test_matches_standalone_function(self): + """compile() produces the same result as calling the standalone function.""" + from ogs.dsl.spec_bridge import compile_pattern_to_spec + + pattern = _sequential_pattern() + assert pattern.compile().name == compile_pattern_to_spec(pattern).name + + +class TestPatternCompileSystem: + """Pattern.compile_system() returns a SystemIR.""" + + def test_returns_system_ir(self): + sir = _sequential_pattern().compile_system() + assert isinstance(sir, SystemIR) + + def test_system_ir_has_blocks(self): + sir = _sequential_pattern().compile_system() + assert len(sir.blocks) >= 2 + + def test_compile_system_with_inputs(self): + sir = _pattern_with_inputs().compile_system() + assert isinstance(sir, SystemIR) + + def test_matches_standalone_pipeline(self): + """compile_system() produces the same result as the standalone pipeline.""" + from ogs.dsl.compile import compile_to_ir + + pattern = _sequential_pattern() + expected = compile_to_ir(pattern).to_system_ir() + result = pattern.compile_system() + assert len(result.blocks) == len(expected.blocks) + assert len(result.wirings) == len(expected.wirings) diff --git a/packages/gds-psuu/README.md b/packages/gds-psuu/README.md new file mode 100644 index 0000000..9cf569f --- /dev/null +++ b/packages/gds-psuu/README.md @@ -0,0 +1,5 @@ +# gds-psuu + +Parameter space search under uncertainty for the GDS ecosystem. + +Built on top of [gds-sim](https://github.com/BlockScience/gds-core), `gds-psuu` provides a search engine for intelligently exploring simulation parameter spaces to optimize KPIs. diff --git a/packages/gds-psuu/gds_psuu/__init__.py b/packages/gds-psuu/gds_psuu/__init__.py new file mode 100644 index 0000000..663a378 --- /dev/null +++ b/packages/gds-psuu/gds_psuu/__init__.py @@ -0,0 +1,39 @@ +"""gds-psuu: Parameter space search under uncertainty for the GDS ecosystem.""" + +__version__ = "0.1.0" + +from gds_psuu.errors import PsuuError, PsuuSearchError, PsuuValidationError +from gds_psuu.evaluation import EvaluationResult, Evaluator +from gds_psuu.kpi import KPI, final_state_mean, final_state_std, time_average +from gds_psuu.optimizers.base import Optimizer +from gds_psuu.optimizers.grid import GridSearchOptimizer +from gds_psuu.optimizers.random import RandomSearchOptimizer +from gds_psuu.results import EvaluationSummary, SweepResults +from gds_psuu.space import Continuous, Discrete, Integer, ParameterSpace +from gds_psuu.sweep import Sweep +from gds_psuu.types import KPIFn, KPIScores, ParamPoint + +__all__ = [ + "KPI", + "Continuous", + "Discrete", + "EvaluationResult", + "EvaluationSummary", + "Evaluator", + "GridSearchOptimizer", + "Integer", + "KPIFn", + "KPIScores", + "Optimizer", + "ParamPoint", + "ParameterSpace", + "PsuuError", + "PsuuSearchError", + "PsuuValidationError", + "RandomSearchOptimizer", + "Sweep", + "SweepResults", + "final_state_mean", + "final_state_std", + "time_average", +] diff --git a/packages/gds-psuu/gds_psuu/errors.py b/packages/gds-psuu/gds_psuu/errors.py new file mode 100644 index 0000000..b39ffad --- /dev/null +++ b/packages/gds-psuu/gds_psuu/errors.py @@ -0,0 +1,13 @@ +"""Exception hierarchy for gds-psuu.""" + + +class PsuuError(Exception): + """Base exception for all gds-psuu errors.""" + + +class PsuuValidationError(PsuuError, ValueError): + """Raised when parameter space or configuration is invalid.""" + + +class PsuuSearchError(PsuuError, RuntimeError): + """Raised when the search/optimization process fails.""" diff --git a/packages/gds-psuu/gds_psuu/evaluation.py b/packages/gds-psuu/gds_psuu/evaluation.py new file mode 100644 index 0000000..06fd5ad --- /dev/null +++ b/packages/gds-psuu/gds_psuu/evaluation.py @@ -0,0 +1,61 @@ +"""Evaluation bridge between parameter points and gds-sim.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from gds_sim import Model, Results, Simulation +from pydantic import BaseModel, ConfigDict + +from gds_psuu.kpi import KPI # noqa: TC001 +from gds_psuu.types import KPIScores, ParamPoint # noqa: TC001 + + +@dataclass(frozen=True) +class EvaluationResult: + """Outcome of evaluating a single parameter point.""" + + params: ParamPoint + scores: KPIScores + results: Results + run_count: int + + +class Evaluator(BaseModel): + """Runs a gds-sim simulation for a given parameter point and scores KPIs.""" + + model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True) + + base_model: Model + kpis: list[KPI] + timesteps: int + runs: int + + def evaluate(self, params: ParamPoint) -> EvaluationResult: + """Evaluate a single parameter point. + + Injects params as singleton lists into the model, runs the simulation, + and computes KPI scores. + """ + # Build params dict: each value as a singleton list for gds-sim + sim_params: dict[str, list[Any]] = {k: [v] for k, v in params.items()} + + # Construct a new Model with the injected params + model = Model( + initial_state=dict(self.base_model.initial_state), + state_update_blocks=list(self.base_model.state_update_blocks), + params=sim_params, + ) + + sim = Simulation(model=model, timesteps=self.timesteps, runs=self.runs) + results = sim.run() + + scores: KPIScores = {kpi.name: kpi.fn(results) for kpi in self.kpis} + + return EvaluationResult( + params=params, + scores=scores, + results=results, + run_count=self.runs, + ) diff --git a/packages/gds-psuu/gds_psuu/kpi.py b/packages/gds-psuu/gds_psuu/kpi.py new file mode 100644 index 0000000..45393c6 --- /dev/null +++ b/packages/gds-psuu/gds_psuu/kpi.py @@ -0,0 +1,117 @@ +"""KPI wrapper and helper functions.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pydantic import BaseModel, ConfigDict + +from gds_psuu.types import KPIFn # noqa: TC001 + +if TYPE_CHECKING: + from gds_sim import Results + + +class KPI(BaseModel): + """Named KPI backed by a scoring function.""" + + model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True) + + name: str + fn: KPIFn + + +def final_state_mean(results: Results, key: str) -> float: + """Mean of a state variable's final-timestep values across all runs. + + Filters to the last timestep (max substep) for each run and averages. + """ + cols = results._trimmed_columns() + timesteps = cols["timestep"] + substeps = cols["substep"] + runs = cols["run"] + values = cols[key] + n = results._size + + # Find max timestep + max_t = 0 + for i in range(n): + t = timesteps[i] + if t > max_t: + max_t = t + + # Find max substep at max timestep + max_s = 0 + for i in range(n): + if timesteps[i] == max_t: + s = substeps[i] + if s > max_s: + max_s = s + + # Collect final values per run + total = 0.0 + count = 0 + seen_runs: set[int] = set() + for i in range(n): + if timesteps[i] == max_t and substeps[i] == max_s: + r = runs[i] + if r not in seen_runs: + seen_runs.add(r) + total += float(values[i]) + count += 1 + + if count == 0: + return 0.0 + return total / count + + +def final_state_std(results: Results, key: str) -> float: + """Std dev of a state variable's final-timestep values across all runs.""" + cols = results._trimmed_columns() + timesteps = cols["timestep"] + substeps = cols["substep"] + runs = cols["run"] + values = cols[key] + n = results._size + + max_t = 0 + for i in range(n): + t = timesteps[i] + if t > max_t: + max_t = t + + max_s = 0 + for i in range(n): + if timesteps[i] == max_t: + s = substeps[i] + if s > max_s: + max_s = s + + finals: list[float] = [] + seen_runs: set[int] = set() + for i in range(n): + if timesteps[i] == max_t and substeps[i] == max_s: + r = runs[i] + if r not in seen_runs: + seen_runs.add(r) + finals.append(float(values[i])) + + if len(finals) < 2: + return 0.0 + + mean = sum(finals) / len(finals) + variance = sum((x - mean) ** 2 for x in finals) / (len(finals) - 1) + return variance**0.5 + + +def time_average(results: Results, key: str) -> float: + """Mean of a state variable across all timesteps, substeps, and runs.""" + cols = results._trimmed_columns() + values = cols[key] + n = results._size + + if n == 0: + return 0.0 + + total = sum(float(v) for v in values) + return total / n diff --git a/packages/gds-psuu/gds_psuu/optimizers/__init__.py b/packages/gds-psuu/gds_psuu/optimizers/__init__.py new file mode 100644 index 0000000..bb91678 --- /dev/null +++ b/packages/gds-psuu/gds_psuu/optimizers/__init__.py @@ -0,0 +1,11 @@ +"""Optimizer implementations for parameter space search.""" + +from gds_psuu.optimizers.base import Optimizer +from gds_psuu.optimizers.grid import GridSearchOptimizer +from gds_psuu.optimizers.random import RandomSearchOptimizer + +__all__ = [ + "GridSearchOptimizer", + "Optimizer", + "RandomSearchOptimizer", +] diff --git a/packages/gds-psuu/gds_psuu/optimizers/base.py b/packages/gds-psuu/gds_psuu/optimizers/base.py new file mode 100644 index 0000000..ca67a22 --- /dev/null +++ b/packages/gds-psuu/gds_psuu/optimizers/base.py @@ -0,0 +1,35 @@ +"""Abstract base class for optimizers.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from gds_psuu.space import ParameterSpace + from gds_psuu.types import KPIScores, ParamPoint + + +class Optimizer(ABC): + """Base class for parameter search optimizers. + + Subclasses implement the suggest/observe loop. The optimizer is stateful + and mutable — it tracks which points have been evaluated and uses that + information to decide what to try next. + """ + + @abstractmethod + def setup(self, space: ParameterSpace, kpi_names: list[str]) -> None: + """Initialize the optimizer with the search space and KPI names.""" + + @abstractmethod + def suggest(self) -> ParamPoint: + """Suggest the next parameter point to evaluate.""" + + @abstractmethod + def observe(self, params: ParamPoint, scores: KPIScores) -> None: + """Record the result of evaluating a parameter point.""" + + @abstractmethod + def is_exhausted(self) -> bool: + """Return True if no more suggestions are available.""" diff --git a/packages/gds-psuu/gds_psuu/optimizers/bayesian.py b/packages/gds-psuu/gds_psuu/optimizers/bayesian.py new file mode 100644 index 0000000..015b4a1 --- /dev/null +++ b/packages/gds-psuu/gds_psuu/optimizers/bayesian.py @@ -0,0 +1,96 @@ +"""Bayesian optimizer — wraps scikit-optimize (optional dependency).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from gds_psuu.errors import PsuuSearchError +from gds_psuu.optimizers.base import Optimizer +from gds_psuu.space import Continuous, Discrete, Integer, ParameterSpace + +if TYPE_CHECKING: + from gds_psuu.types import KPIScores, ParamPoint + +try: + from skopt import Optimizer as SkoptOptimizer # type: ignore[import-untyped] + from skopt.space import Categorical, Real # type: ignore[import-untyped] + from skopt.space import Integer as SkoptInteger + + _HAS_SKOPT = True +except ImportError: # pragma: no cover + _HAS_SKOPT = False + + +class BayesianOptimizer(Optimizer): + """Bayesian optimization using Gaussian process surrogate. + + Requires ``scikit-optimize``. Install with:: + + pip install gds-psuu[bayesian] + + Optimizes a single target KPI (by default the first one registered). + """ + + def __init__( + self, + n_calls: int = 20, + target_kpi: str | None = None, + maximize: bool = True, + seed: int | None = None, + ) -> None: + if not _HAS_SKOPT: # pragma: no cover + raise ImportError( + "scikit-optimize is required for BayesianOptimizer. " + "Install with: pip install gds-psuu[bayesian]" + ) + self._n_calls = n_calls + self._target_kpi = target_kpi + self._maximize = maximize + self._seed = seed + self._optimizer: Any = None + self._param_names: list[str] = [] + self._count: int = 0 + + def setup(self, space: ParameterSpace, kpi_names: list[str]) -> None: + if self._target_kpi is None: + self._target_kpi = kpi_names[0] + elif self._target_kpi not in kpi_names: + raise PsuuSearchError( + f"Target KPI '{self._target_kpi}' not found in {kpi_names}" + ) + + self._param_names = space.dimension_names + dimensions: list[Any] = [] + for dim in space.params.values(): + if isinstance(dim, Continuous): + dimensions.append(Real(dim.min_val, dim.max_val)) + elif isinstance(dim, Integer): + dimensions.append(SkoptInteger(dim.min_val, dim.max_val)) + elif isinstance(dim, Discrete): + dimensions.append(Categorical(list(dim.values))) + + self._optimizer = SkoptOptimizer( + dimensions=dimensions, + random_state=self._seed, + n_initial_points=min(5, self._n_calls), + ) + self._count = 0 + + def suggest(self) -> ParamPoint: + assert self._optimizer is not None, "Call setup() before suggest()" + point = self._optimizer.ask() + return dict(zip(self._param_names, point, strict=True)) + + def observe(self, params: ParamPoint, scores: KPIScores) -> None: + assert self._optimizer is not None + assert self._target_kpi is not None + point = [params[name] for name in self._param_names] + value = scores[self._target_kpi] + # skopt minimizes, so negate if we want to maximize + if self._maximize: + value = -value + self._optimizer.tell(point, value) + self._count += 1 + + def is_exhausted(self) -> bool: + return self._count >= self._n_calls diff --git a/packages/gds-psuu/gds_psuu/optimizers/grid.py b/packages/gds-psuu/gds_psuu/optimizers/grid.py new file mode 100644 index 0000000..96f43e6 --- /dev/null +++ b/packages/gds-psuu/gds_psuu/optimizers/grid.py @@ -0,0 +1,40 @@ +"""Grid search optimizer — exhaustive cartesian product search.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from gds_psuu.optimizers.base import Optimizer + +if TYPE_CHECKING: + from gds_psuu.space import ParameterSpace + from gds_psuu.types import KPIScores, ParamPoint + + +class GridSearchOptimizer(Optimizer): + """Evaluates every point in a regular grid over the parameter space. + + For Continuous dimensions, ``n_steps`` evenly spaced values are used. + For Integer dimensions, all integers in [min, max] are used. + For Discrete dimensions, all values are used. + """ + + def __init__(self, n_steps: int = 5) -> None: + self._n_steps = n_steps + self._grid: list[ParamPoint] = [] + self._cursor: int = 0 + + def setup(self, space: ParameterSpace, kpi_names: list[str]) -> None: + self._grid = space.grid_points(self._n_steps) + self._cursor = 0 + + def suggest(self) -> ParamPoint: + point = self._grid[self._cursor] + self._cursor += 1 + return point + + def observe(self, params: ParamPoint, scores: KPIScores) -> None: + pass # Grid search doesn't adapt + + def is_exhausted(self) -> bool: + return self._cursor >= len(self._grid) diff --git a/packages/gds-psuu/gds_psuu/optimizers/random.py b/packages/gds-psuu/gds_psuu/optimizers/random.py new file mode 100644 index 0000000..fee3dab --- /dev/null +++ b/packages/gds-psuu/gds_psuu/optimizers/random.py @@ -0,0 +1,48 @@ +"""Random search optimizer — uniform random sampling.""" + +from __future__ import annotations + +import random +from typing import TYPE_CHECKING + +from gds_psuu.optimizers.base import Optimizer +from gds_psuu.space import Continuous, Discrete, Integer, ParameterSpace + +if TYPE_CHECKING: + from gds_psuu.types import KPIScores, ParamPoint + + +class RandomSearchOptimizer(Optimizer): + """Samples parameter points uniformly at random. + + Uses stdlib ``random.Random`` for reproducibility — no numpy required. + """ + + def __init__(self, n_samples: int = 20, seed: int | None = None) -> None: + self._n_samples = n_samples + self._rng = random.Random(seed) + self._space: ParameterSpace | None = None + self._count: int = 0 + + def setup(self, space: ParameterSpace, kpi_names: list[str]) -> None: + self._space = space + self._count = 0 + + def suggest(self) -> ParamPoint: + assert self._space is not None, "Call setup() before suggest()" + point: ParamPoint = {} + for name, dim in self._space.params.items(): + if isinstance(dim, Continuous): + point[name] = self._rng.uniform(dim.min_val, dim.max_val) + elif isinstance(dim, Integer): + point[name] = self._rng.randint(dim.min_val, dim.max_val) + elif isinstance(dim, Discrete): + point[name] = self._rng.choice(dim.values) + self._count += 1 + return point + + def observe(self, params: ParamPoint, scores: KPIScores) -> None: + pass # Random search doesn't adapt + + def is_exhausted(self) -> bool: + return self._count >= self._n_samples diff --git a/packages/gds-examples/guides/verification/__init__.py b/packages/gds-psuu/gds_psuu/py.typed similarity index 100% rename from packages/gds-examples/guides/verification/__init__.py rename to packages/gds-psuu/gds_psuu/py.typed diff --git a/packages/gds-psuu/gds_psuu/results.py b/packages/gds-psuu/gds_psuu/results.py new file mode 100644 index 0000000..6709586 --- /dev/null +++ b/packages/gds-psuu/gds_psuu/results.py @@ -0,0 +1,72 @@ +"""Sweep results and summary types.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict + +from gds_psuu.evaluation import EvaluationResult # noqa: TC001 +from gds_psuu.types import KPIScores, ParamPoint # noqa: TC001 + + +class EvaluationSummary(BaseModel): + """Summary of a single evaluation (without raw simulation data).""" + + model_config = ConfigDict(frozen=True) + + params: ParamPoint + scores: KPIScores + + +class SweepResults(BaseModel): + """Container for all evaluation results from a sweep.""" + + model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True) + + evaluations: list[EvaluationResult] + kpi_names: list[str] + optimizer_name: str + + @property + def summaries(self) -> list[EvaluationSummary]: + """Summaries without raw simulation data.""" + return [ + EvaluationSummary(params=e.params, scores=e.scores) + for e in self.evaluations + ] + + def best(self, kpi: str, *, maximize: bool = True) -> EvaluationSummary: + """Return the evaluation with the best score for the given KPI. + + Args: + kpi: Name of the KPI to optimize. + maximize: If True, return the evaluation with the highest score. + """ + if not self.evaluations: + raise ValueError("No evaluations to search") + if kpi not in self.kpi_names: + raise ValueError(f"KPI '{kpi}' not found in {self.kpi_names}") + + best_eval = max( + self.evaluations, + key=lambda e: e.scores[kpi] if maximize else -e.scores[kpi], + ) + return EvaluationSummary(params=best_eval.params, scores=best_eval.scores) + + 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-psuu[pandas]" + ) from exc + + rows: list[dict[str, Any]] = [] + for ev in self.evaluations: + row: dict[str, Any] = dict(ev.params) + row.update(ev.scores) + rows.append(row) + return pd.DataFrame(rows) diff --git a/packages/gds-psuu/gds_psuu/space.py b/packages/gds-psuu/gds_psuu/space.py new file mode 100644 index 0000000..317340e --- /dev/null +++ b/packages/gds-psuu/gds_psuu/space.py @@ -0,0 +1,111 @@ +"""Parameter space definitions for search.""" + +from __future__ import annotations + +import itertools +import math +from typing import TYPE_CHECKING, Any, Self + +from pydantic import BaseModel, ConfigDict, model_validator + +from gds_psuu.errors import PsuuValidationError + +if TYPE_CHECKING: + from gds_psuu.types import ParamPoint + + +class Continuous(BaseModel): + """A continuous parameter dimension with min/max bounds.""" + + model_config = ConfigDict(frozen=True) + + min_val: float + max_val: float + + @model_validator(mode="after") + def _validate_bounds(self) -> Self: + if self.min_val >= self.max_val: + raise PsuuValidationError( + f"min_val ({self.min_val}) must be less than max_val ({self.max_val})" + ) + if not math.isfinite(self.min_val) or not math.isfinite(self.max_val): + raise PsuuValidationError("Bounds must be finite") + return self + + +class Integer(BaseModel): + """An integer parameter dimension with min/max bounds (inclusive).""" + + model_config = ConfigDict(frozen=True) + + min_val: int + max_val: int + + @model_validator(mode="after") + def _validate_bounds(self) -> Self: + if self.min_val >= self.max_val: + raise PsuuValidationError( + f"min_val ({self.min_val}) must be less than max_val ({self.max_val})" + ) + return self + + +class Discrete(BaseModel): + """A discrete parameter dimension with explicit allowed values.""" + + model_config = ConfigDict(frozen=True) + + values: tuple[Any, ...] + + @model_validator(mode="after") + def _validate_values(self) -> Self: + if len(self.values) < 1: + raise PsuuValidationError("Discrete dimension must have at least 1 value") + return self + + +Dimension = Continuous | Integer | Discrete + + +class ParameterSpace(BaseModel): + """Defines the searchable parameter space as a mapping of named dimensions.""" + + model_config = ConfigDict(frozen=True) + + params: dict[str, Dimension] + + @model_validator(mode="after") + def _validate_nonempty(self) -> Self: + if not self.params: + raise PsuuValidationError("ParameterSpace must have at least 1 parameter") + return self + + @property + def dimension_names(self) -> list[str]: + """Ordered list of parameter names.""" + return list(self.params.keys()) + + def grid_points(self, n_steps: int) -> list[ParamPoint]: + """Generate a grid of parameter points. + + For Continuous: ``n_steps`` evenly spaced values between min and max. + For Integer: all integers in [min_val, max_val] (ignores n_steps). + For Discrete: all values. + """ + axes: list[list[Any]] = [] + for dim in self.params.values(): + if isinstance(dim, Continuous): + if n_steps < 2: + raise PsuuValidationError( + "n_steps must be >= 2 for Continuous dimensions" + ) + step = (dim.max_val - dim.min_val) / (n_steps - 1) + axes.append([dim.min_val + i * step for i in range(n_steps)]) + elif isinstance(dim, Integer): + axes.append(list(range(dim.min_val, dim.max_val + 1))) + elif isinstance(dim, Discrete): + axes.append(list(dim.values)) + names = self.dimension_names + return [ + dict(zip(names, combo, strict=True)) for combo in itertools.product(*axes) + ] diff --git a/packages/gds-psuu/gds_psuu/sweep.py b/packages/gds-psuu/gds_psuu/sweep.py new file mode 100644 index 0000000..837bde9 --- /dev/null +++ b/packages/gds-psuu/gds_psuu/sweep.py @@ -0,0 +1,54 @@ +"""Sweep orchestrator — the main entry point for parameter search.""" + +from __future__ import annotations + +from gds_sim import Model # noqa: TC002 +from pydantic import BaseModel, ConfigDict + +from gds_psuu.evaluation import EvaluationResult, Evaluator +from gds_psuu.kpi import KPI # noqa: TC001 +from gds_psuu.optimizers.base import Optimizer # noqa: TC001 +from gds_psuu.results import SweepResults +from gds_psuu.space import ParameterSpace # noqa: TC001 + + +class Sweep(BaseModel): + """Orchestrates parameter space search. + + Drives the optimizer suggest/observe loop, delegating evaluation + to the Evaluator which bridges to gds-sim. + """ + + model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True) + + model: Model + space: ParameterSpace + kpis: list[KPI] + optimizer: Optimizer + timesteps: int = 100 + runs: int = 1 + + def run(self) -> SweepResults: + """Execute the sweep and return results.""" + kpi_names = [k.name for k in self.kpis] + self.optimizer.setup(self.space, kpi_names) + + evaluator = Evaluator( + base_model=self.model, + kpis=list(self.kpis), + timesteps=self.timesteps, + runs=self.runs, + ) + + evaluations: list[EvaluationResult] = [] + while not self.optimizer.is_exhausted(): + params = self.optimizer.suggest() + result = evaluator.evaluate(params) + self.optimizer.observe(params, result.scores) + evaluations.append(result) + + return SweepResults( + evaluations=evaluations, + kpi_names=kpi_names, + optimizer_name=type(self.optimizer).__name__, + ) diff --git a/packages/gds-psuu/gds_psuu/types.py b/packages/gds-psuu/gds_psuu/types.py new file mode 100644 index 0000000..8b97744 --- /dev/null +++ b/packages/gds-psuu/gds_psuu/types.py @@ -0,0 +1,17 @@ +"""Core type aliases for gds-psuu.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from gds_sim import Results + +ParamPoint = dict[str, Any] +"""A single point in parameter space: maps param names to concrete values.""" + +KPIFn = Callable[[Results], float] +"""Computes a scalar KPI score from simulation results (all Monte Carlo runs).""" + +KPIScores = dict[str, float] +"""Maps KPI names to their computed scalar scores.""" diff --git a/packages/gds-psuu/pyproject.toml b/packages/gds-psuu/pyproject.toml new file mode 100644 index 0000000..fbad9ac --- /dev/null +++ b/packages/gds-psuu/pyproject.toml @@ -0,0 +1,86 @@ +[project] +name = "gds-psuu" +dynamic = ["version"] +description = "Parameter space search under uncertainty 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", + "parameter-search", + "optimization", + "monte-carlo", + "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 = [ + "gds-sim>=0.1.0", + "pydantic>=2.10", +] + +[project.optional-dependencies] +pandas = ["pandas>=2.0"] +bayesian = ["scikit-optimize>=0.10"] + +[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_psuu/__init__.py" + +[tool.hatch.build.targets.wheel] +packages = ["gds_psuu"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "--import-mode=importlib --cov=gds_psuu --cov-report=term-missing --no-header -q" + +[tool.coverage.run] +source = ["gds_psuu"] +omit = ["gds_psuu/__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-psuu/tests/conftest.py b/packages/gds-psuu/tests/conftest.py new file mode 100644 index 0000000..8dff5a0 --- /dev/null +++ b/packages/gds-psuu/tests/conftest.py @@ -0,0 +1,56 @@ +"""Shared fixtures for gds-psuu tests.""" + +from __future__ import annotations + +import pytest +from gds_sim import Model, StateUpdateBlock + +from gds_psuu import ( + KPI, + Continuous, + Discrete, + ParameterSpace, + final_state_mean, +) + + +def _growth_policy(state: dict, params: dict, **kw: object) -> dict: + return {"delta": state["population"] * params.get("growth_rate", 0.05)} + + +def _update_population( + state: dict, params: dict, *, signal: dict | None = None, **kw: object +) -> tuple[str, float]: + delta = signal["delta"] if signal else 0.0 + return ("population", state["population"] + delta) + + +@pytest.fixture +def simple_model() -> Model: + """A deterministic growth model with one state variable.""" + return Model( + initial_state={"population": 100.0}, + state_update_blocks=[ + StateUpdateBlock( + policies={"growth": _growth_policy}, + variables={"population": _update_population}, + ) + ], + ) + + +@pytest.fixture +def simple_space() -> ParameterSpace: + """A small parameter space with one continuous and one discrete dim.""" + return ParameterSpace( + params={ + "growth_rate": Continuous(min_val=0.01, max_val=0.1), + "strategy": Discrete(values=("A", "B")), + } + ) + + +@pytest.fixture +def simple_kpi() -> KPI: + """A KPI that computes mean final population.""" + return KPI(name="final_pop", fn=lambda r: final_state_mean(r, "population")) diff --git a/packages/gds-psuu/tests/test_evaluation.py b/packages/gds-psuu/tests/test_evaluation.py new file mode 100644 index 0000000..73eb4f9 --- /dev/null +++ b/packages/gds-psuu/tests/test_evaluation.py @@ -0,0 +1,66 @@ +"""Tests for the evaluation bridge.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from gds_psuu import KPI, Evaluator, final_state_mean + +if TYPE_CHECKING: + from gds_sim import Model + + +class TestEvaluator: + def test_evaluate_returns_result( + self, simple_model: Model, simple_kpi: KPI + ) -> None: + evaluator = Evaluator( + base_model=simple_model, + kpis=[simple_kpi], + timesteps=5, + runs=1, + ) + result = evaluator.evaluate({"growth_rate": 0.05}) + assert result.params == {"growth_rate": 0.05} + assert "final_pop" in result.scores + assert result.run_count == 1 + assert len(result.results) > 0 + + def test_evaluate_different_params( + self, simple_model: Model, simple_kpi: KPI + ) -> None: + evaluator = Evaluator( + base_model=simple_model, + kpis=[simple_kpi], + timesteps=10, + runs=1, + ) + low = evaluator.evaluate({"growth_rate": 0.01}) + high = evaluator.evaluate({"growth_rate": 0.1}) + # Higher growth rate → higher final population + assert high.scores["final_pop"] > low.scores["final_pop"] + + def test_evaluate_multiple_runs(self, simple_model: Model, simple_kpi: KPI) -> None: + evaluator = Evaluator( + base_model=simple_model, + kpis=[simple_kpi], + timesteps=5, + runs=3, + ) + result = evaluator.evaluate({"growth_rate": 0.05}) + assert result.run_count == 3 + + def test_evaluate_multiple_kpis(self, simple_model: Model) -> None: + kpis = [ + KPI(name="final_pop", fn=lambda r: final_state_mean(r, "population")), + KPI(name="avg_pop", fn=lambda r: final_state_mean(r, "population")), + ] + evaluator = Evaluator( + base_model=simple_model, + kpis=kpis, + timesteps=5, + runs=1, + ) + result = evaluator.evaluate({"growth_rate": 0.05}) + assert "final_pop" in result.scores + assert "avg_pop" in result.scores diff --git a/packages/gds-psuu/tests/test_kpi.py b/packages/gds-psuu/tests/test_kpi.py new file mode 100644 index 0000000..8c038a6 --- /dev/null +++ b/packages/gds-psuu/tests/test_kpi.py @@ -0,0 +1,79 @@ +"""Tests for KPI wrapper and helper functions.""" + +from __future__ import annotations + +from gds_sim import Model, Results, Simulation, StateUpdateBlock + +from gds_psuu import KPI, final_state_mean, final_state_std, time_average + + +def _identity_update(state: dict, params: dict, **kw: object) -> tuple[str, float]: + return ("value", state["value"]) + + +def _growing_update(state: dict, params: dict, **kw: object) -> tuple[str, float]: + return ("value", state["value"] + 10.0) + + +def _make_results( + initial: float, updater: object, timesteps: int = 5, runs: int = 1 +) -> Results: + model = Model( + initial_state={"value": initial}, + state_update_blocks=[StateUpdateBlock(variables={"value": updater})], + ) + sim = Simulation(model=model, timesteps=timesteps, runs=runs) + return sim.run() + + +class TestKPI: + def test_kpi_creation(self) -> None: + kpi = KPI(name="test", fn=lambda r: 42.0) + assert kpi.name == "test" + assert kpi.fn(None) == 42.0 # type: ignore[arg-type] + + +class TestFinalStateMean: + def test_constant_value(self) -> None: + results = _make_results(100.0, _identity_update, timesteps=5, runs=1) + assert final_state_mean(results, "value") == 100.0 + + def test_growing_value(self) -> None: + results = _make_results(0.0, _growing_update, timesteps=3, runs=1) + # After 3 timesteps: 0 + 3*10 = 30 + assert final_state_mean(results, "value") == 30.0 + + def test_multiple_runs(self) -> None: + results = _make_results(100.0, _identity_update, timesteps=3, runs=3) + # All runs identical → mean = 100 + assert final_state_mean(results, "value") == 100.0 + + def test_empty_results(self) -> None: + results = Results(state_keys=["value"]) + assert final_state_mean(results, "value") == 0.0 + + +class TestFinalStateStd: + def test_identical_runs(self) -> None: + results = _make_results(100.0, _identity_update, timesteps=3, runs=3) + assert final_state_std(results, "value") == 0.0 + + def test_single_run(self) -> None: + results = _make_results(100.0, _identity_update, timesteps=3, runs=1) + assert final_state_std(results, "value") == 0.0 + + +class TestTimeAverage: + def test_constant(self) -> None: + results = _make_results(100.0, _identity_update, timesteps=3, runs=1) + assert time_average(results, "value") == 100.0 + + def test_growing(self) -> None: + results = _make_results(0.0, _growing_update, timesteps=2, runs=1) + # Rows: t=0 s=0 → 0, t=1 s=1 → 10, t=2 s=1 → 20 + avg = time_average(results, "value") + assert avg == 10.0 # (0 + 10 + 20) / 3 + + def test_empty(self) -> None: + results = Results(state_keys=["value"]) + assert time_average(results, "value") == 0.0 diff --git a/packages/gds-psuu/tests/test_optimizers.py b/packages/gds-psuu/tests/test_optimizers.py new file mode 100644 index 0000000..8609d01 --- /dev/null +++ b/packages/gds-psuu/tests/test_optimizers.py @@ -0,0 +1,117 @@ +"""Tests for optimizer implementations.""" + +from __future__ import annotations + +import pytest + +from gds_psuu import ( + Continuous, + Discrete, + GridSearchOptimizer, + Integer, + ParameterSpace, + RandomSearchOptimizer, +) + + +@pytest.fixture +def continuous_space() -> ParameterSpace: + return ParameterSpace(params={"x": Continuous(min_val=0.0, max_val=1.0)}) + + +@pytest.fixture +def mixed_space() -> ParameterSpace: + return ParameterSpace( + params={ + "x": Continuous(min_val=0.0, max_val=1.0), + "n": Integer(min_val=1, max_val=3), + "mode": Discrete(values=("a", "b")), + } + ) + + +class TestGridSearchOptimizer: + def test_exhaustive(self, continuous_space: ParameterSpace) -> None: + opt = GridSearchOptimizer(n_steps=3) + opt.setup(continuous_space, ["kpi"]) + points = [] + while not opt.is_exhausted(): + points.append(opt.suggest()) + assert len(points) == 3 + assert points[0] == {"x": 0.0} + assert points[1] == {"x": 0.5} + assert points[2] == {"x": 1.0} + + def test_mixed_space(self, mixed_space: ParameterSpace) -> None: + opt = GridSearchOptimizer(n_steps=2) + opt.setup(mixed_space, ["kpi"]) + points = [] + while not opt.is_exhausted(): + p = opt.suggest() + opt.observe(p, {"kpi": 0.0}) + points.append(p) + # 2 continuous * 3 integer * 2 discrete = 12 + assert len(points) == 12 + + def test_observe_is_noop(self, continuous_space: ParameterSpace) -> None: + opt = GridSearchOptimizer(n_steps=2) + opt.setup(continuous_space, ["kpi"]) + p = opt.suggest() + opt.observe(p, {"kpi": 42.0}) # Should not raise + + +class TestRandomSearchOptimizer: + def test_correct_count(self, continuous_space: ParameterSpace) -> None: + opt = RandomSearchOptimizer(n_samples=5, seed=42) + opt.setup(continuous_space, ["kpi"]) + points = [] + while not opt.is_exhausted(): + points.append(opt.suggest()) + assert len(points) == 5 + + def test_deterministic_with_seed(self, continuous_space: ParameterSpace) -> None: + opt1 = RandomSearchOptimizer(n_samples=3, seed=42) + opt1.setup(continuous_space, ["kpi"]) + points1 = [opt1.suggest() for _ in range(3)] + + opt2 = RandomSearchOptimizer(n_samples=3, seed=42) + opt2.setup(continuous_space, ["kpi"]) + points2 = [opt2.suggest() for _ in range(3)] + + assert points1 == points2 + + def test_values_in_bounds(self, continuous_space: ParameterSpace) -> None: + opt = RandomSearchOptimizer(n_samples=50, seed=0) + opt.setup(continuous_space, ["kpi"]) + for _ in range(50): + p = opt.suggest() + assert 0.0 <= p["x"] <= 1.0 + + def test_integer_bounds(self) -> None: + space = ParameterSpace(params={"n": Integer(min_val=1, max_val=5)}) + opt = RandomSearchOptimizer(n_samples=50, seed=0) + opt.setup(space, ["kpi"]) + for _ in range(50): + p = opt.suggest() + assert 1 <= p["n"] <= 5 + assert isinstance(p["n"], int) + + def test_discrete_values(self) -> None: + space = ParameterSpace(params={"mode": Discrete(values=("a", "b", "c"))}) + opt = RandomSearchOptimizer(n_samples=50, seed=0) + opt.setup(space, ["kpi"]) + seen = set() + for _ in range(50): + p = opt.suggest() + assert p["mode"] in ("a", "b", "c") + seen.add(p["mode"]) + assert seen == {"a", "b", "c"} # Very likely with 50 samples + + def test_mixed_space(self, mixed_space: ParameterSpace) -> None: + opt = RandomSearchOptimizer(n_samples=10, seed=0) + opt.setup(mixed_space, ["kpi"]) + for _ in range(10): + p = opt.suggest() + assert "x" in p + assert "n" in p + assert "mode" in p diff --git a/packages/gds-psuu/tests/test_results.py b/packages/gds-psuu/tests/test_results.py new file mode 100644 index 0000000..d0cc922 --- /dev/null +++ b/packages/gds-psuu/tests/test_results.py @@ -0,0 +1,81 @@ +"""Tests for sweep results.""" + +from __future__ import annotations + +import pytest +from gds_sim import Results +from pydantic import ValidationError + +from gds_psuu.evaluation import EvaluationResult +from gds_psuu.results import EvaluationSummary, SweepResults + + +def _make_eval(params: dict, scores: dict) -> EvaluationResult: + return EvaluationResult( + params=params, + scores=scores, + results=Results(state_keys=[]), + run_count=1, + ) + + +class TestSweepResults: + def test_summaries(self) -> None: + evals = [ + _make_eval({"x": 1}, {"kpi": 10.0}), + _make_eval({"x": 2}, {"kpi": 20.0}), + ] + sr = SweepResults(evaluations=evals, kpi_names=["kpi"], optimizer_name="test") + summaries = sr.summaries + assert len(summaries) == 2 + assert summaries[0].params == {"x": 1} + assert summaries[1].scores == {"kpi": 20.0} + + def test_best_maximize(self) -> None: + evals = [ + _make_eval({"x": 1}, {"kpi": 10.0}), + _make_eval({"x": 2}, {"kpi": 30.0}), + _make_eval({"x": 3}, {"kpi": 20.0}), + ] + sr = SweepResults(evaluations=evals, kpi_names=["kpi"], optimizer_name="test") + best = sr.best("kpi", maximize=True) + assert best.params == {"x": 2} + assert best.scores["kpi"] == 30.0 + + def test_best_minimize(self) -> None: + evals = [ + _make_eval({"x": 1}, {"kpi": 10.0}), + _make_eval({"x": 2}, {"kpi": 30.0}), + ] + sr = SweepResults(evaluations=evals, kpi_names=["kpi"], optimizer_name="test") + best = sr.best("kpi", maximize=False) + assert best.params == {"x": 1} + + def test_best_empty(self) -> None: + sr = SweepResults(evaluations=[], kpi_names=["kpi"], optimizer_name="test") + with pytest.raises(ValueError, match="No evaluations"): + sr.best("kpi") + + def test_best_unknown_kpi(self) -> None: + evals = [_make_eval({"x": 1}, {"kpi": 10.0})] + sr = SweepResults(evaluations=evals, kpi_names=["kpi"], optimizer_name="test") + with pytest.raises(ValueError, match="not found"): + sr.best("nonexistent") + + def test_to_dataframe(self) -> None: + pytest.importorskip("pandas") + evals = [ + _make_eval({"x": 1, "y": "a"}, {"kpi": 10.0}), + _make_eval({"x": 2, "y": "b"}, {"kpi": 20.0}), + ] + sr = SweepResults(evaluations=evals, kpi_names=["kpi"], optimizer_name="test") + df = sr.to_dataframe() + assert len(df) == 2 + assert list(df.columns) == ["x", "y", "kpi"] + + +class TestEvaluationSummary: + def test_frozen(self) -> None: + s = EvaluationSummary(params={"x": 1}, scores={"kpi": 10.0}) + with pytest.raises(ValidationError): + s.params = {} # type: ignore[misc] diff --git a/packages/gds-psuu/tests/test_space.py b/packages/gds-psuu/tests/test_space.py new file mode 100644 index 0000000..3496733 --- /dev/null +++ b/packages/gds-psuu/tests/test_space.py @@ -0,0 +1,102 @@ +"""Tests for parameter space definitions.""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from gds_psuu import Continuous, Discrete, Integer, ParameterSpace +from gds_psuu.errors import PsuuValidationError + + +class TestContinuous: + def test_valid(self) -> None: + c = Continuous(min_val=0.0, max_val=1.0) + assert c.min_val == 0.0 + assert c.max_val == 1.0 + + def test_invalid_bounds(self) -> None: + with pytest.raises(ValidationError, match="must be less than"): + Continuous(min_val=1.0, max_val=0.0) + + def test_equal_bounds(self) -> None: + with pytest.raises(ValidationError, match="must be less than"): + Continuous(min_val=1.0, max_val=1.0) + + def test_infinite_bounds(self) -> None: + with pytest.raises(ValidationError, match="finite"): + Continuous(min_val=0.0, max_val=float("inf")) + + def test_frozen(self) -> None: + c = Continuous(min_val=0.0, max_val=1.0) + with pytest.raises(ValidationError): + c.min_val = 0.5 # type: ignore[misc] + + +class TestInteger: + def test_valid(self) -> None: + i = Integer(min_val=1, max_val=10) + assert i.min_val == 1 + assert i.max_val == 10 + + def test_invalid_bounds(self) -> None: + with pytest.raises(ValidationError): + Integer(min_val=10, max_val=1) + + +class TestDiscrete: + def test_valid(self) -> None: + d = Discrete(values=("a", "b", "c")) + assert d.values == ("a", "b", "c") + + def test_single_value(self) -> None: + d = Discrete(values=("only",)) + assert len(d.values) == 1 + + def test_empty_values(self) -> None: + with pytest.raises(ValidationError, match="at least 1"): + Discrete(values=()) + + +class TestParameterSpace: + def test_empty_space(self) -> None: + with pytest.raises(ValidationError, match="at least 1"): + ParameterSpace(params={}) + + def test_dimension_names(self, simple_space: ParameterSpace) -> None: + assert simple_space.dimension_names == ["growth_rate", "strategy"] + + def test_grid_points_continuous(self) -> None: + space = ParameterSpace(params={"x": Continuous(min_val=0.0, max_val=1.0)}) + points = space.grid_points(n_steps=3) + assert len(points) == 3 + assert points[0] == {"x": 0.0} + assert points[1] == {"x": 0.5} + assert points[2] == {"x": 1.0} + + def test_grid_points_integer(self) -> None: + space = ParameterSpace(params={"n": Integer(min_val=1, max_val=3)}) + points = space.grid_points(n_steps=2) + assert points == [{"n": 1}, {"n": 2}, {"n": 3}] + + def test_grid_points_discrete(self) -> None: + space = ParameterSpace(params={"mode": Discrete(values=("fast", "slow"))}) + points = space.grid_points(n_steps=2) + assert points == [{"mode": "fast"}, {"mode": "slow"}] + + def test_grid_cartesian_product(self) -> None: + space = ParameterSpace( + params={ + "x": Continuous(min_val=0.0, max_val=1.0), + "mode": Discrete(values=("a", "b")), + } + ) + points = space.grid_points(n_steps=2) + assert len(points) == 4 # 2 x 2 + assert points[0] == {"x": 0.0, "mode": "a"} + assert points[3] == {"x": 1.0, "mode": "b"} + + def test_grid_n_steps_too_small(self) -> None: + space = ParameterSpace(params={"x": Continuous(min_val=0.0, max_val=1.0)}) + with pytest.raises(PsuuValidationError, match="n_steps must be >= 2"): + space.grid_points(n_steps=1) diff --git a/packages/gds-psuu/tests/test_sweep.py b/packages/gds-psuu/tests/test_sweep.py new file mode 100644 index 0000000..3001242 --- /dev/null +++ b/packages/gds-psuu/tests/test_sweep.py @@ -0,0 +1,126 @@ +"""Tests for the sweep orchestrator.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from gds_psuu import ( + KPI, + Continuous, + Discrete, + GridSearchOptimizer, + ParameterSpace, + RandomSearchOptimizer, + Sweep, + final_state_mean, +) + +if TYPE_CHECKING: + from gds_sim import Model + + +class TestSweep: + def test_grid_sweep(self, simple_model: Model) -> None: + sweep = Sweep( + model=simple_model, + space=ParameterSpace( + params={"growth_rate": Continuous(min_val=0.01, max_val=0.1)} + ), + kpis=[ + KPI( + name="final_pop", + fn=lambda r: final_state_mean(r, "population"), + ) + ], + optimizer=GridSearchOptimizer(n_steps=3), + timesteps=5, + runs=1, + ) + results = sweep.run() + assert len(results.evaluations) == 3 + assert results.kpi_names == ["final_pop"] + assert results.optimizer_name == "GridSearchOptimizer" + + def test_random_sweep(self, simple_model: Model) -> None: + sweep = Sweep( + model=simple_model, + space=ParameterSpace( + params={"growth_rate": Continuous(min_val=0.01, max_val=0.1)} + ), + kpis=[ + KPI( + name="final_pop", + fn=lambda r: final_state_mean(r, "population"), + ) + ], + optimizer=RandomSearchOptimizer(n_samples=5, seed=42), + timesteps=5, + runs=1, + ) + results = sweep.run() + assert len(results.evaluations) == 5 + + def test_best_params(self, simple_model: Model) -> None: + sweep = Sweep( + model=simple_model, + space=ParameterSpace( + params={"growth_rate": Continuous(min_val=0.01, max_val=0.1)} + ), + kpis=[ + KPI( + name="final_pop", + fn=lambda r: final_state_mean(r, "population"), + ) + ], + optimizer=GridSearchOptimizer(n_steps=3), + timesteps=10, + runs=1, + ) + results = sweep.run() + best = results.best("final_pop", maximize=True) + # Highest growth rate → highest population + assert best.params["growth_rate"] == 0.1 + + def test_sweep_with_discrete_params(self, simple_model: Model) -> None: + sweep = Sweep( + model=simple_model, + space=ParameterSpace( + params={ + "growth_rate": Continuous(min_val=0.01, max_val=0.05), + "label": Discrete(values=("x", "y")), + } + ), + kpis=[ + KPI( + name="final_pop", + fn=lambda r: final_state_mean(r, "population"), + ) + ], + optimizer=GridSearchOptimizer(n_steps=2), + timesteps=5, + runs=1, + ) + results = sweep.run() + # 2 continuous * 2 discrete = 4 + assert len(results.evaluations) == 4 + + def test_sweep_multiple_runs(self, simple_model: Model) -> None: + sweep = Sweep( + model=simple_model, + space=ParameterSpace( + params={"growth_rate": Continuous(min_val=0.01, max_val=0.1)} + ), + kpis=[ + KPI( + name="final_pop", + fn=lambda r: final_state_mean(r, "population"), + ) + ], + optimizer=GridSearchOptimizer(n_steps=2), + timesteps=5, + runs=3, + ) + results = sweep.run() + assert len(results.evaluations) == 2 + for ev in results.evaluations: + assert ev.run_count == 3 diff --git a/packages/gds-sim/tests/test_compat_edge_cases.py b/packages/gds-sim/tests/test_compat_edge_cases.py new file mode 100644 index 0000000..c233d13 --- /dev/null +++ b/packages/gds-sim/tests/test_compat_edge_cases.py @@ -0,0 +1,239 @@ +"""Tests for _positional_count edge cases and compat adaptation edge cases.""" + +from __future__ import annotations + +import functools +from typing import Any + +from gds_sim.compat import _positional_count, adapt_policy, adapt_suf + +# -- _positional_count edge cases ------------------------------------------ + + +class TestPositionalCountEdgeCases: + def test_lambda_no_args(self) -> None: + assert _positional_count(lambda: None) == 0 + + def test_lambda_one_arg(self) -> None: + assert _positional_count(lambda x: x) == 1 + + def test_lambda_two_args(self) -> None: + assert _positional_count(lambda x, y: x + y) == 2 + + def test_lambda_with_kwargs_not_counted(self) -> None: + """**kw should not count as positional.""" + assert _positional_count(lambda x, y, **kw: x) == 2 + + def test_lambda_with_default_still_counted(self) -> None: + """Args with defaults are still POSITIONAL_OR_KEYWORD.""" + assert _positional_count(lambda x, y=1: x) == 2 + + def test_builtin_returns_zero(self) -> None: + """Built-in functions cannot be inspected -- should return 0, not crash.""" + assert _positional_count(len) >= 0 # builtins may or may not be inspectable + + def test_partial_reduces_count(self) -> None: + """functools.partial with one positional arg bound.""" + + def f(a: int, b: int, c: int) -> int: + return a + b + c + + p = functools.partial(f, 1) + assert _positional_count(p) == 2 # b, c remain + + def test_partial_all_bound(self) -> None: + def f(a: int, b: int) -> int: + return a + b + + p = functools.partial(f, 1, 2) + assert _positional_count(p) == 0 + + def test_class_callable(self) -> None: + """A class with __call__ -- inspect.signature strips self for instances.""" + + class Adder: + def __call__(self, x: int, y: int) -> int: + return x + y + + assert _positional_count(Adder()) == 2 + + def test_keyword_only_not_counted(self) -> None: + """Keyword-only params (after *) should not be counted.""" + + def f(a: int, *, b: int, c: int) -> int: + return a + b + c + + assert _positional_count(f) == 1 + + def test_none_returns_zero(self) -> None: + """Non-callable should return 0, not crash.""" + assert _positional_count(None) == 0 # type: ignore[arg-type] + + def test_string_returns_zero(self) -> None: + """Non-callable should return 0, not crash.""" + assert _positional_count("not a function") == 0 # type: ignore[arg-type] + + def test_var_positional_not_counted(self) -> None: + """*args should not add to the positional count.""" + + def f(a: int, *args: int) -> int: + return a + sum(args) + + assert _positional_count(f) == 1 + + +# -- adapt_policy edge cases ----------------------------------------------- + + +class TestAdaptPolicyEdgeCases: + def test_three_arg_passes_through(self) -> None: + """Non-4-arg functions should pass through unchanged.""" + + def three_arg(a: Any, b: Any, c: Any) -> dict[str, Any]: + return {"x": 1} + + adapted = adapt_policy(three_arg) + assert adapted is three_arg + + def test_one_arg_passes_through(self) -> None: + def one_arg(state: Any) -> dict[str, Any]: + return {} + + adapted = adapt_policy(one_arg) + assert adapted is one_arg + + def test_zero_arg_passes_through(self) -> None: + def zero_arg() -> dict[str, Any]: + return {} + + adapted = adapt_policy(zero_arg) + assert adapted is zero_arg + + def test_cadcad_policy_forwards_substep_kwarg(self) -> None: + """Wrapped cadCAD policy should forward substep from **kw.""" + received: dict[str, Any] = {} + + def cadcad_policy( + params: Any, substep: int, history: list[Any], state: Any + ) -> dict[str, Any]: + received["substep"] = substep + received["state"] = state + received["params"] = params + return {} + + adapted = adapt_policy(cadcad_policy) + adapted({"x": 1}, {"rate": 2}, substep=7, timestep=3) + assert received["substep"] == 7 + assert received["state"] == {"x": 1} + assert received["params"] == {"rate": 2} + + def test_cadcad_policy_default_substep_zero(self) -> None: + """If substep not in kw, defaults to 0.""" + received: dict[str, Any] = {} + + def cadcad_policy( + params: Any, substep: int, history: list[Any], state: Any + ) -> dict[str, Any]: + received["substep"] = substep + return {} + + adapted = adapt_policy(cadcad_policy) + adapted({"x": 1}, {}, timestep=1) + assert received["substep"] == 0 + + def test_cadcad_policy_receives_empty_history(self) -> None: + """Wrapped cadCAD policy always gets [] for state_history.""" + received_history: list[Any] = [None] # sentinel + + def cadcad_policy( + params: Any, substep: int, history: list[Any], state: Any + ) -> dict[str, Any]: + received_history[0] = history + return {} + + adapted = adapt_policy(cadcad_policy) + adapted({}, {}) + assert received_history[0] == [] + + +# -- adapt_suf edge cases -------------------------------------------------- + + +class TestAdaptSufEdgeCases: + def test_three_arg_passes_through(self) -> None: + def three_arg(a: Any, b: Any, c: Any) -> tuple[str, Any]: + return "x", 1 + + adapted = adapt_suf(three_arg) + assert adapted is three_arg + + def test_cadcad_suf_forwards_substep_kwarg(self) -> None: + received: dict[str, Any] = {} + + def cadcad_suf( + params: Any, + substep: int, + history: list[Any], + state: Any, + policy_input: Any, + ) -> tuple[str, Any]: + received["substep"] = substep + received["policy_input"] = policy_input + return "x", 1 + + adapted = adapt_suf(cadcad_suf) + adapted({"x": 0}, {}, signal={"delta": 5}, substep=3) + assert received["substep"] == 3 + assert received["policy_input"] == {"delta": 5} + + def test_cadcad_suf_none_signal_becomes_empty_dict(self) -> None: + """When signal is None, cadCAD wrapper should pass {} as policy_input.""" + received: dict[str, Any] = {} + + def cadcad_suf( + params: Any, + substep: int, + history: list[Any], + state: Any, + policy_input: Any, + ) -> tuple[str, Any]: + received["policy_input"] = policy_input + return "x", 1 + + adapted = adapt_suf(cadcad_suf) + adapted({"x": 0}, {}, signal=None) + assert received["policy_input"] == {} + + def test_cadcad_suf_default_substep_zero(self) -> None: + received: dict[str, Any] = {} + + def cadcad_suf( + params: Any, + substep: int, + history: list[Any], + state: Any, + policy_input: Any, + ) -> tuple[str, Any]: + received["substep"] = substep + return "x", 1 + + adapted = adapt_suf(cadcad_suf) + adapted({"x": 0}, {}) + assert received["substep"] == 0 + + def test_cadcad_suf_receives_empty_history(self) -> None: + received_history: list[Any] = [None] + + def cadcad_suf( + params: Any, + substep: int, + history: list[Any], + state: Any, + policy_input: Any, + ) -> tuple[str, Any]: + received_history[0] = history + return "x", 1 + + adapted = adapt_suf(cadcad_suf) + adapted({"x": 0}, {}) + assert received_history[0] == [] diff --git a/packages/gds-sim/tests/test_model_edge_cases.py b/packages/gds-sim/tests/test_model_edge_cases.py new file mode 100644 index 0000000..c133c40 --- /dev/null +++ b/packages/gds-sim/tests/test_model_edge_cases.py @@ -0,0 +1,285 @@ +"""Tests for Model/Simulation/Experiment error paths and edge cases.""" + +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"] + + +def _increment_suf( + state: dict[str, Any], params: dict[str, Any], **kw: Any +) -> tuple[str, Any]: + return "x", state["x"] + 1 + + +class TestModelErrorPaths: + def test_suf_references_nonexistent_key(self) -> None: + """SUF referencing a key not in initial_state should raise ValueError.""" + with pytest.raises(ValueError, match="not found in initial_state"): + gds_sim.Model( + initial_state={"x": 1}, + state_update_blocks=[ + {"policies": {}, "variables": {"nonexistent": _noop_suf}} + ], + ) + + def test_suf_references_nonexistent_key_in_second_block(self) -> None: + """Error message should identify block index.""" + with pytest.raises(ValueError, match="State update block 1"): + gds_sim.Model( + initial_state={"x": 1}, + state_update_blocks=[ + {"policies": {}, "variables": {"x": _noop_suf}}, + {"policies": {}, "variables": {"missing": _noop_suf}}, + ], + ) + + def test_error_message_includes_available_keys(self) -> None: + """Error message should list available keys.""" + with pytest.raises(ValueError, match=r"Available keys.*x"): + gds_sim.Model( + initial_state={"x": 1}, + state_update_blocks=[{"policies": {}, "variables": {"y": _noop_suf}}], + ) + + +class TestModelEdgeCases: + def test_single_block_model(self) -> None: + """Model with a single block and single variable.""" + model = gds_sim.Model( + initial_state={"x": 0}, + state_update_blocks=[{"policies": {}, "variables": {"x": _increment_suf}}], + ) + sim = gds_sim.Simulation(model=model, timesteps=3) + rows = sim.run().to_list() + # 1 block = 1 substep per timestep, plus initial row = 1 + 3 = 4 + assert len(rows) == 4 + assert rows[-1]["x"] == 3 + + def test_empty_params_gives_single_empty_subset(self) -> None: + model = gds_sim.Model( + initial_state={"x": 0}, + state_update_blocks=[{"policies": {}, "variables": {"x": _noop_suf}}], + params={}, + ) + assert model._param_subsets == [{}] + + def test_single_param_value_gives_single_subset(self) -> None: + model = gds_sim.Model( + initial_state={"x": 0}, + state_update_blocks=[{"policies": {}, "variables": {"x": _noop_suf}}], + params={"alpha": [0.5]}, + ) + assert len(model._param_subsets) == 1 + assert model._param_subsets[0] == {"alpha": 0.5} + + def test_three_way_param_sweep(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], "c": [100]}, + ) + # 2 x 2 x 1 = 4 subsets + assert len(model._param_subsets) == 4 + + def test_dict_blocks_coerced_to_state_update_block(self) -> None: + """Plain dicts should be coerced to StateUpdateBlock instances.""" + 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) + + def test_state_update_block_already_typed(self) -> None: + """Pre-constructed StateUpdateBlock instances should pass through.""" + block = gds_sim.StateUpdateBlock(variables={"x": _noop_suf}) + model = gds_sim.Model( + initial_state={"x": 0}, + state_update_blocks=[block], + ) + assert len(model.state_update_blocks) == 1 + + def test_multiple_state_variables_single_block(self) -> None: + """Block updating multiple state variables.""" + + def suf_y( + state: dict[str, Any], params: dict[str, Any], **kw: Any + ) -> tuple[str, Any]: + return "y", state["y"] * 2 + + model = gds_sim.Model( + initial_state={"x": 0, "y": 1.0}, + state_update_blocks=[ + {"policies": {}, "variables": {"x": _increment_suf, "y": suf_y}} + ], + ) + sim = gds_sim.Simulation(model=model, timesteps=3) + rows = sim.run().to_list() + final = rows[-1] + assert final["x"] == 3 + assert final["y"] == 8.0 # 1 * 2^3 + + +class TestSimulationEdgeCases: + def test_zero_timesteps(self) -> None: + """Zero timesteps should produce only the initial state row.""" + model = gds_sim.Model( + initial_state={"x": 42}, + state_update_blocks=[{"policies": {}, "variables": {"x": _noop_suf}}], + ) + sim = gds_sim.Simulation(model=model, timesteps=0) + rows = sim.run().to_list() + assert len(rows) == 1 + assert rows[0]["x"] == 42 + assert rows[0]["timestep"] == 0 + + def test_one_timestep(self) -> None: + model = gds_sim.Model( + initial_state={"x": 0}, + state_update_blocks=[{"policies": {}, "variables": {"x": _increment_suf}}], + ) + sim = gds_sim.Simulation(model=model, timesteps=1) + rows = sim.run().to_list() + # initial + 1 timestep * 1 block = 2 + assert len(rows) == 2 + assert rows[-1]["x"] == 1 + + +class TestHooksEdgeCases: + def test_hooks_called_per_run_in_multi_run(self) -> None: + """before_run and after_run should be called once per run.""" + before_count: list[int] = [] + after_count: list[int] = [] + + def before_run(state: dict[str, Any], params: dict[str, Any]) -> None: + before_count.append(1) + + def after_run(state: dict[str, Any], params: dict[str, Any]) -> None: + after_count.append(1) + + model = gds_sim.Model( + initial_state={"x": 0}, + state_update_blocks=[{"policies": {}, "variables": {"x": _noop_suf}}], + ) + hooks = gds_sim.Hooks(before_run=before_run, after_run=after_run) + sim = gds_sim.Simulation(model=model, timesteps=3, runs=3, hooks=hooks) + sim.run() + assert len(before_count) == 3 + assert len(after_count) == 3 + + def test_hooks_called_per_subset(self) -> None: + """Hooks fire once per (subset, run) pair.""" + call_count: list[int] = [] + + def before_run(state: dict[str, Any], params: dict[str, Any]) -> None: + call_count.append(1) + + model = gds_sim.Model( + initial_state={"x": 0}, + state_update_blocks=[{"policies": {}, "variables": {"x": _noop_suf}}], + params={"a": [1, 2, 3]}, + ) + hooks = gds_sim.Hooks(before_run=before_run) + sim = gds_sim.Simulation(model=model, timesteps=2, runs=2, hooks=hooks) + sim.run() + # 3 subsets * 2 runs = 6 + assert len(call_count) == 6 + + def test_early_exit_respects_break(self) -> None: + """after_step returning False at timestep 1 should stop immediately.""" + model = gds_sim.Model( + initial_state={"x": 0}, + state_update_blocks=[{"policies": {}, "variables": {"x": _increment_suf}}], + ) + + def stop_immediately(state: dict[str, Any], t: int) -> bool: + return False + + hooks = gds_sim.Hooks(after_step=stop_immediately) + sim = gds_sim.Simulation(model=model, timesteps=100, hooks=hooks) + rows = sim.run().to_list() + # initial + 1 timestep (then stopped) + assert len(rows) == 2 + assert rows[-1]["x"] == 1 + + def test_after_step_returning_none_continues(self) -> None: + """after_step returning None should NOT stop.""" + model = gds_sim.Model( + initial_state={"x": 0}, + state_update_blocks=[{"policies": {}, "variables": {"x": _increment_suf}}], + ) + + def do_nothing(state: dict[str, Any], t: int) -> None: + pass + + hooks = gds_sim.Hooks(after_step=do_nothing) + sim = gds_sim.Simulation(model=model, timesteps=5, hooks=hooks) + rows = sim.run().to_list() + assert rows[-1]["x"] == 5 + + def test_after_step_returning_true_continues(self) -> None: + """after_step returning True should NOT stop (only False stops).""" + model = gds_sim.Model( + initial_state={"x": 0}, + state_update_blocks=[{"policies": {}, "variables": {"x": _increment_suf}}], + ) + + def keep_going(state: dict[str, Any], t: int) -> bool: + return True + + hooks = gds_sim.Hooks(after_step=keep_going) + sim = gds_sim.Simulation(model=model, timesteps=5, hooks=hooks) + rows = sim.run().to_list() + assert rows[-1]["x"] == 5 + + +class TestExperimentEdgeCases: + def test_single_sim_single_job_sequential(self) -> None: + """Single sim with 1 subset and 1 run should use sequential path.""" + model = gds_sim.Model( + initial_state={"x": 0}, + state_update_blocks=[{"policies": {}, "variables": {"x": _increment_suf}}], + ) + sim = gds_sim.Simulation(model=model, timesteps=5, runs=1) + exp = gds_sim.Experiment(simulations=[sim]) + rows = exp.run().to_list() + assert rows[-1]["x"] == 5 + + def test_experiment_merges_multiple_sims(self) -> None: + """Experiment with two sims should merge results.""" + model1 = gds_sim.Model( + initial_state={"x": 0}, + state_update_blocks=[{"policies": {}, "variables": {"x": _increment_suf}}], + ) + model2 = gds_sim.Model( + initial_state={"x": 100}, + state_update_blocks=[{"policies": {}, "variables": {"x": _increment_suf}}], + ) + sim1 = gds_sim.Simulation(model=model1, timesteps=3) + sim2 = gds_sim.Simulation(model=model2, timesteps=3) + exp = gds_sim.Experiment(simulations=[sim1, sim2], processes=1) + results = exp.run() + # Each sim: 1 + 3 = 4 rows, total 8 + assert len(results) == 8 + + def test_experiment_processes_none_auto(self) -> None: + """processes=None should still work (auto-detect).""" + model = gds_sim.Model( + initial_state={"x": 0}, + state_update_blocks=[{"policies": {}, "variables": {"x": _increment_suf}}], + params={"a": [1, 2]}, + ) + sim = gds_sim.Simulation(model=model, timesteps=3, runs=2) + exp = gds_sim.Experiment(simulations=[sim]) + results = exp.run() + # 2 subsets * 2 runs * (1 + 3) = 16 + assert len(results) == 16 diff --git a/packages/gds-stockflow/stockflow/verification/engine.py b/packages/gds-stockflow/stockflow/verification/engine.py index 1e3bd32..569bdb6 100644 --- a/packages/gds-stockflow/stockflow/verification/engine.py +++ b/packages/gds-stockflow/stockflow/verification/engine.py @@ -16,7 +16,7 @@ def verify( model: StockFlowModel, - sf_checks: list[Callable[[StockFlowModel], list[Finding]]] | None = None, + domain_checks: list[Callable[[StockFlowModel], list[Finding]]] | None = None, include_gds_checks: bool = True, ) -> VerificationReport: """Run verification checks on a StockFlowModel. @@ -26,10 +26,10 @@ def verify( Args: model: The stock-flow model to verify. - sf_checks: Optional subset of SF checks. Defaults to all. + domain_checks: Optional subset of SF checks. Defaults to all. include_gds_checks: Whether to compile and run GDS generic checks. """ - checks = sf_checks or ALL_SF_CHECKS + checks = domain_checks or ALL_SF_CHECKS findings: list[Finding] = [] # Phase 1: SF checks on model diff --git a/packages/gds-stockflow/tests/test_verification.py b/packages/gds-stockflow/tests/test_verification.py index 0d0b9da..9c89f83 100644 --- a/packages/gds-stockflow/tests/test_verification.py +++ b/packages/gds-stockflow/tests/test_verification.py @@ -133,7 +133,7 @@ def test_verify_sf_only(self, good_model): def test_verify_specific_checks(self, good_model): report = verify( good_model, - sf_checks=[check_sf001_orphan_stocks], + domain_checks=[check_sf001_orphan_stocks], include_gds_checks=False, ) assert all(f.check_id == "SF-001" for f in report.findings) diff --git a/pyproject.toml b/pyproject.toml index dfd7af9..6be9865 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ "Topic :: Scientific/Engineering", ] dependencies = [ - "gds-framework>=0.2.0", + "gds-framework>=0.2.3", "gds-viz>=0.1.0", "gds-games>=0.1.0", "gds-stockflow>=0.1.0", @@ -34,6 +34,7 @@ dependencies = [ "gds-business>=0.1.0", "gds-examples>=0.1.0", "gds-sim>=0.1.0", + "gds-psuu>=0.1.0", ] [project.urls] @@ -58,6 +59,7 @@ gds-software = { workspace = true } gds-business = { workspace = true } gds-examples = { workspace = true } gds-sim = { workspace = true } +gds-psuu = { workspace = true } [tool.uv.workspace] members = ["packages/*"] @@ -74,7 +76,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", "gds_sim"] +known-first-party = ["gds", "gds_viz", "ogs", "stockflow", "gds_control", "gds_software", "gds_business", "gds_sim", "gds_psuu"] [dependency-groups] docs = [