diff --git a/.claude/rules/stack-and-commands.md b/.claude/rules/stack-and-commands.md index ed70bf5..488ec22 100644 --- a/.claude/rules/stack-and-commands.md +++ b/.claude/rules/stack-and-commands.md @@ -6,7 +6,7 @@ ## Dev Commands ```bash -# Planned v0.1 workflow (see README.md) +python3 -m pytest -q tests/ python3 simulator.py python3 experiments.py diff --git a/.gitignore b/.gitignore index 36b13f1..b8a3ef0 100644 --- a/.gitignore +++ b/.gitignore @@ -174,3 +174,6 @@ cython_debug/ # PyPI configuration file .pypirc +# Simulation outputs +runs/ + diff --git a/SCOPE.md b/SCOPE.md index 2b1957b..47a5e67 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -26,8 +26,8 @@ Polycode is a simulation laboratory for mechanism design: a way to test governan ## Current State -- Planning/spec repo: README and pitch materials exist; v0.1 Python simulator modules not yet committed. -- Bootstrap State Hub integration (POLYCODE-WP-0001) finished; next: simulator scaffold (POLYCODE-WP-0002). +- v0.1 simulator scaffold committed (`simulator.py`, `strategies.py`, `experiments.py`); outputs under `runs/`. +- POLYCODE-WP-0002 finished; next implementation work should extend strategies and KPI reporting. ## Getting Oriented diff --git a/experiments.py b/experiments.py new file mode 100644 index 0000000..68c43ff --- /dev/null +++ b/experiments.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Tiny parameter sweep for PolyCode Simulator v0.1.""" +from __future__ import annotations + +import json +from copy import deepcopy +from datetime import UTC, datetime +from pathlib import Path + +from simulator import load_params, run_simulation + + +def sweep(thresholds: list[float] | None = None) -> list[dict]: + thresholds = thresholds or [300.0, 400.0, 500.0] + rows: list[dict] = [] + base = load_params() + for threshold in thresholds: + params = deepcopy(base) + params["funding_threshold"] = threshold + summary = run_simulation(params) + rows.append( + { + "funding_threshold": threshold, + "acceptance_rate": summary.acceptance_rate, + "accepted": summary.accepted, + "rejected": summary.rejected, + "total_funded": summary.total_funded, + } + ) + return rows + + +def main() -> None: + rows = sweep() + out_dir = Path("runs") + out_dir.mkdir(exist_ok=True) + stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") + path = out_dir / f"sweep-{stamp}.json" + path.write_text(json.dumps({"sweep": rows}, indent=2), encoding="utf-8") + print("Sweep results:") + for row in rows: + print(row) + print(f"output: {path}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/params_default.json b/params_default.json new file mode 100644 index 0000000..c99c41f --- /dev/null +++ b/params_default.json @@ -0,0 +1,9 @@ +{ + "rounds": 10, + "initial_capital": 1000.0, + "funding_threshold": 400.0, + "proposal_cost": 50.0, + "num_investors": 3, + "num_voters": 5, + "random_seed": 42 +} \ No newline at end of file diff --git a/simulator.py b/simulator.py new file mode 100644 index 0000000..5ae950e --- /dev/null +++ b/simulator.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""PolyCode Simulator v0.1 — single-run demo.""" +from __future__ import annotations + +import json +import random +from dataclasses import asdict, dataclass +from datetime import UTC, datetime +from pathlib import Path + +from strategies import ( + LenientVoter, + Proposal, + RandomInvestor, + ThresholdInvestor, + ValueVoter, +) + + +@dataclass +class RoundResult: + round: int + proposal_id: int + funded: float + approved: bool + effort: float + value: float + + +@dataclass +class RunSummary: + accepted: int + rejected: int + total_funded: float + acceptance_rate: float + rounds: list[RoundResult] + + +def load_params(path: Path | None = None) -> dict: + params_path = path or Path(__file__).with_name("params_default.json") + return json.loads(params_path.read_text(encoding="utf-8")) + + +def make_proposal(round_idx: int, rng: random.Random) -> Proposal: + effort = rng.uniform(30, 120) + value = effort * rng.uniform(0.7, 1.4) + return Proposal(id=round_idx, effort=round(effort, 2), value=round(value, 2)) + + +def run_simulation(params: dict | None = None) -> RunSummary: + params = params or load_params() + rng = random.Random(params.get("random_seed", 42)) + investors = [ThresholdInvestor(), RandomInvestor(), RandomInvestor()] + voters = [ValueVoter(), ValueVoter(), LenientVoter(), LenientVoter(), ValueVoter()] + + capital = float(params["initial_capital"]) + threshold = float(params["funding_threshold"]) + rounds: list[RoundResult] = [] + accepted = rejected = 0 + total_funded = 0.0 + + for i in range(int(params["rounds"])): + proposal = make_proposal(i + 1, rng) + funded = 0.0 + for investor in investors: + stake = min(capital, capital / len(investors)) + amount = investor.allocate(proposal, funded, stake, rng) + amount = min(amount, capital) + funded += amount + capital -= amount + if funded >= threshold: + break + + votes = sum(1 for voter in voters if voter.approve(proposal, funded, rng)) + approved = votes > len(voters) / 2 and funded >= proposal.effort + if approved: + accepted += 1 + else: + rejected += 1 + capital += funded * 0.5 + total_funded += funded + rounds.append( + RoundResult( + round=i + 1, + proposal_id=proposal.id, + funded=round(funded, 2), + approved=approved, + effort=proposal.effort, + value=proposal.value, + ) + ) + + total = accepted + rejected + return RunSummary( + accepted=accepted, + rejected=rejected, + total_funded=round(total_funded, 2), + acceptance_rate=round(accepted / total, 3) if total else 0.0, + rounds=rounds, + ) + + +def write_run(summary: RunSummary, params: dict, out_dir: Path | None = None) -> Path: + out_dir = out_dir or Path("runs") + out_dir.mkdir(exist_ok=True) + stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") + path = out_dir / f"run-{stamp}.json" + payload = { + "params": params, + "summary": { + "accepted": summary.accepted, + "rejected": summary.rejected, + "total_funded": summary.total_funded, + "acceptance_rate": summary.acceptance_rate, + }, + "rounds": [asdict(r) for r in summary.rounds], + } + path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + return path + + +def main() -> None: + params = load_params() + summary = run_simulation(params) + out = write_run(summary, params) + print(f"PolyCode demo run complete: {summary.accepted} accepted, {summary.rejected} rejected") + print(f"acceptance_rate={summary.acceptance_rate} total_funded={summary.total_funded}") + print(f"output: {out}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/strategies.py b/strategies.py new file mode 100644 index 0000000..074149f --- /dev/null +++ b/strategies.py @@ -0,0 +1,54 @@ +"""Pluggable investor and voter strategies for PolyCode v0.1.""" +from __future__ import annotations + +import random +from dataclasses import dataclass +from typing import Protocol + + +@dataclass(frozen=True) +class Proposal: + id: int + effort: float + value: float + + +class InvestorStrategy(Protocol): + def allocate( + self, proposal: Proposal, funded: float, available: float, rng: random.Random + ) -> float: ... + + +class VoterStrategy(Protocol): + def approve(self, proposal: Proposal, funded: float, rng: random.Random) -> bool: ... + + +class ThresholdInvestor: + """Invest when projected value covers effort.""" + + def allocate( + self, proposal: Proposal, funded: float, available: float, rng: random.Random + ) -> float: + if proposal.value < proposal.effort or available <= 0: + return 0.0 + need = max(0.0, proposal.effort - funded) + return min(available, need) + + +class RandomInvestor: + def allocate( + self, proposal: Proposal, funded: float, available: float, rng: random.Random + ) -> float: + if available <= 0: + return 0.0 + return rng.uniform(0, min(available, max(0.0, proposal.effort - funded))) + + +class ValueVoter: + def approve(self, proposal: Proposal, funded: float, rng: random.Random) -> bool: + return funded >= proposal.effort and proposal.value >= proposal.effort + + +class LenientVoter: + def approve(self, proposal: Proposal, funded: float, rng: random.Random) -> bool: + return funded >= proposal.effort * 0.8 \ No newline at end of file diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 0000000..50a1023 --- /dev/null +++ b/tests/test_smoke.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from simulator import load_params, run_simulation, write_run + + +def test_demo_run_produces_summary(tmp_path: Path) -> None: + params = load_params() + params["rounds"] = 3 + summary = run_simulation(params) + assert summary.accepted + summary.rejected == 3 + assert 0.0 <= summary.acceptance_rate <= 1.0 + out = write_run(summary, params, tmp_path) + assert out.exists() + assert out.stat().st_size > 0 \ No newline at end of file diff --git a/workplans/POLYCODE-WP-0002-simulator-v01-scaffold.md b/workplans/POLYCODE-WP-0002-simulator-v01-scaffold.md index a687984..5f9be83 100644 --- a/workplans/POLYCODE-WP-0002-simulator-v01-scaffold.md +++ b/workplans/POLYCODE-WP-0002-simulator-v01-scaffold.md @@ -4,11 +4,12 @@ type: workplan title: "PolyCode simulator v0.1 scaffold" domain: infotech repo: polycode-sim -status: ready +status: finished owner: codex topic_slug: custodian created: "2026-07-08" updated: "2026-07-08" +state_hub_workstream_id: "f0281f0c-327c-430e-ab1a-c3fd031511e3" --- # PolyCode simulator v0.1 scaffold @@ -19,8 +20,11 @@ Implement the documented v0.1 simulation engine (`simulator.py`, `strategies.py` ```task id: POLYCODE-WP-0002-T01 -status: todo +status: done priority: high +state_hub_task_id: "e8411084-8d8e-4c08-a07c-1b1865be2baa" ``` +Result 2026-07-08: Added simulator.py, strategies.py, experiments.py, params_default.json; pytest smoke green; demo run writes runs/. + Add minimal runnable simulator with default strategies, params file, `./runs/` output, and a smoke test for one demo run.