Skip to content

Commit 366e8df

Browse files
committed
modernized pycid
1 parent ab936c4 commit 366e8df

File tree

17 files changed

+1573
-29
lines changed

17 files changed

+1573
-29
lines changed

docker/Dockerfile.pycid

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,16 @@ FROM thrones-base:latest
55

66
WORKDIR /app/plugins/pycid
77

8-
# Install PyCID-specific dependencies
9-
# pycid 0.7.3 uses nashpy (not pygambit) and has get_all_pure_ne/get_all_pure_spe
10-
# pycid 0.8.x requires pygambit which has API incompatibilities
11-
RUN pip install --no-cache-dir \
12-
pgmpy==0.1.17 \
13-
matplotlib \
14-
networkx \
15-
nashpy \
16-
numpy
17-
RUN pip install --no-cache-dir --no-deps pycid==0.7.3
8+
# Install build tools for pygambit compilation and git for cloning
9+
RUN apt-get update && apt-get install -y --no-install-recommends \
10+
g++ \
11+
git \
12+
&& rm -rf /var/lib/apt/lists/*
13+
14+
# Install pycid from elazarg fork (Python 3.10+, pygambit 16.5.0, pgmpy 1.0.0)
15+
RUN git clone --depth 1 https://github.com/elazarg/pycid.git /tmp/pycid && \
16+
pip install --no-cache-dir /tmp/pycid && \
17+
rm -rf /tmp/pycid
1818

1919
# Copy plugin code
2020
COPY plugins/pycid/pycid_plugin/ ./pycid_plugin/
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
"""PyCID plugin service - MAID Nash equilibrium analysis."""
2+
3+
# Compatibility shim for pgmpy 0.1.17 which uses deprecated np.product
4+
# (removed in numpy 2.0). Must be applied before importing pgmpy.
5+
import numpy as np
6+
7+
if not hasattr(np, "product"):
8+
np.product = np.prod
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
"""PyCID plugin service entrypoint.
2+
3+
Run with: python -m pycid_plugin --port=PORT
4+
Implements the plugin HTTP contract (API v1).
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import argparse
10+
import logging
11+
import threading
12+
import uuid
13+
from enum import Enum
14+
from typing import Any
15+
16+
import uvicorn
17+
from fastapi import FastAPI, HTTPException
18+
from pydantic import BaseModel
19+
20+
from pycid_plugin.nash import run_maid_nash
21+
from pycid_plugin.spe import run_maid_spe
22+
from pycid_plugin.verify_profile import run_verify_profile
23+
from pycid_plugin.convert import convert_maid_to_efg
24+
25+
logging.basicConfig(
26+
level=logging.INFO,
27+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
28+
datefmt="%H:%M:%S",
29+
)
30+
logger = logging.getLogger("pycid_plugin")
31+
32+
PLUGIN_VERSION = "0.1.0"
33+
API_VERSION = 1
34+
35+
# ---------------------------------------------------------------------------
36+
# Analysis registry
37+
# ---------------------------------------------------------------------------
38+
39+
ANALYSES = {
40+
"MAID Nash Equilibrium": {
41+
"name": "MAID Nash Equilibrium",
42+
"description": "Computes pure-strategy Nash equilibria for Multi-Agent Influence Diagrams using PyCID.",
43+
"applicable_to": ["maid"],
44+
"continuous": True,
45+
"config_schema": {}, # PyCID only supports pure NE enumeration
46+
"run": run_maid_nash,
47+
},
48+
"MAID Subgame Perfect Equilibrium": {
49+
"name": "MAID Subgame Perfect Equilibrium",
50+
"description": "Computes pure-strategy subgame perfect equilibria (SPE) for MAIDs.",
51+
"applicable_to": ["maid"],
52+
"continuous": True,
53+
"config_schema": {}, # PyCID only supports pure SPE enumeration
54+
"run": run_maid_spe,
55+
},
56+
"MAID Verify Profile": {
57+
"name": "MAID Verify Profile",
58+
"description": "Check if a strategy profile is a Nash equilibrium for the MAID",
59+
"applicable_to": ["maid"],
60+
"continuous": False,
61+
"config_schema": {
62+
"profile": {
63+
"type": "object",
64+
"description": "Strategy profile: {agent: {decision: action}}",
65+
},
66+
},
67+
"run": run_verify_profile,
68+
},
69+
}
70+
71+
# ---------------------------------------------------------------------------
72+
# Task state
73+
# ---------------------------------------------------------------------------
74+
75+
76+
class TaskStatus(str, Enum):
77+
QUEUED = "queued"
78+
RUNNING = "running"
79+
DONE = "done"
80+
FAILED = "failed"
81+
CANCELLED = "cancelled"
82+
83+
84+
class TaskState:
85+
def __init__(self) -> None:
86+
self.status: TaskStatus = TaskStatus.QUEUED
87+
self.result: dict[str, Any] | None = None
88+
self.error: dict[str, Any] | None = None
89+
self.cancelled = threading.Event()
90+
91+
def to_dict(self, task_id: str) -> dict[str, Any]:
92+
d: dict[str, Any] = {"task_id": task_id, "status": self.status.value}
93+
if self.result is not None:
94+
d["result"] = self.result
95+
if self.error is not None:
96+
d["error"] = self.error
97+
return d
98+
99+
100+
_tasks: dict[str, TaskState] = {}
101+
_tasks_lock = threading.Lock()
102+
103+
# ---------------------------------------------------------------------------
104+
# Request / response models
105+
# ---------------------------------------------------------------------------
106+
107+
108+
class AnalyzeRequest(BaseModel):
109+
analysis: str
110+
game: dict[str, Any]
111+
config: dict[str, Any] = {}
112+
113+
114+
class ConvertRequest(BaseModel):
115+
game: dict[str, Any]
116+
117+
118+
# ---------------------------------------------------------------------------
119+
# FastAPI app
120+
# ---------------------------------------------------------------------------
121+
122+
app = FastAPI(title="PyCID Plugin", version=PLUGIN_VERSION)
123+
124+
125+
@app.get("/health")
126+
def health() -> dict:
127+
return {
128+
"status": "ok",
129+
"api_version": API_VERSION,
130+
"plugin_version": PLUGIN_VERSION,
131+
}
132+
133+
134+
@app.get("/info")
135+
def info() -> dict:
136+
analyses_info = []
137+
for a in ANALYSES.values():
138+
analyses_info.append(
139+
{
140+
"name": a["name"],
141+
"description": a["description"],
142+
"applicable_to": a["applicable_to"],
143+
"continuous": a["continuous"],
144+
"config_schema": a["config_schema"],
145+
}
146+
)
147+
return {
148+
"api_version": API_VERSION,
149+
"plugin_version": PLUGIN_VERSION,
150+
"analyses": analyses_info,
151+
"conversions": [
152+
{"source": "maid", "target": "extensive"},
153+
],
154+
}
155+
156+
157+
@app.post("/convert/{source}-to-{target}")
158+
def convert_endpoint(source: str, target: str, req: ConvertRequest) -> dict:
159+
"""Convert a game from one format to another."""
160+
if source == "maid" and target == "extensive":
161+
try:
162+
result = convert_maid_to_efg(req.game)
163+
return {"game": result}
164+
except ValueError as e:
165+
raise HTTPException(
166+
status_code=400,
167+
detail={
168+
"error": {
169+
"code": "CONVERSION_ERROR",
170+
"message": str(e),
171+
}
172+
},
173+
)
174+
except Exception as e:
175+
logger.exception("Conversion %s-to-%s failed", source, target)
176+
raise HTTPException(
177+
status_code=500,
178+
detail={
179+
"error": {
180+
"code": "INTERNAL",
181+
"message": f"Conversion failed: {e}",
182+
}
183+
},
184+
)
185+
186+
raise HTTPException(
187+
status_code=400,
188+
detail={
189+
"error": {
190+
"code": "UNSUPPORTED_CONVERSION",
191+
"message": f"Unsupported conversion: {source} to {target}",
192+
}
193+
},
194+
)
195+
196+
197+
@app.post("/analyze")
198+
def analyze(req: AnalyzeRequest) -> dict:
199+
analysis_entry = ANALYSES.get(req.analysis)
200+
if analysis_entry is None:
201+
raise HTTPException(
202+
status_code=400,
203+
detail={
204+
"error": {
205+
"code": "UNSUPPORTED_ANALYSIS",
206+
"message": f"Unknown analysis: {req.analysis}. Available: {list(ANALYSES.keys())}",
207+
}
208+
},
209+
)
210+
211+
game_format = req.game.get("format_name", "")
212+
if game_format not in analysis_entry["applicable_to"]:
213+
raise HTTPException(
214+
status_code=400,
215+
detail={
216+
"error": {
217+
"code": "INVALID_GAME",
218+
"message": f"Game format '{game_format}' not supported by {req.analysis}",
219+
}
220+
},
221+
)
222+
223+
task_id = f"p-{uuid.uuid4().hex[:8]}"
224+
task = TaskState()
225+
226+
with _tasks_lock:
227+
_tasks[task_id] = task
228+
229+
def _run() -> None:
230+
task.status = TaskStatus.RUNNING
231+
try:
232+
if task.cancelled.is_set():
233+
task.status = TaskStatus.CANCELLED
234+
return
235+
236+
result = analysis_entry["run"](req.game, req.config)
237+
238+
if task.cancelled.is_set():
239+
task.status = TaskStatus.CANCELLED
240+
return
241+
242+
task.result = result
243+
task.status = TaskStatus.DONE
244+
except ValueError as e:
245+
task.error = {"code": "INVALID_CONFIG", "message": str(e), "details": {}}
246+
task.status = TaskStatus.FAILED
247+
except Exception as e:
248+
logger.exception("Analysis %s failed", req.analysis)
249+
task.error = {"code": "INTERNAL", "message": str(e), "details": {}}
250+
task.status = TaskStatus.FAILED
251+
252+
thread = threading.Thread(target=_run, daemon=True)
253+
thread.start()
254+
255+
return {"task_id": task_id, "status": "queued"}
256+
257+
258+
@app.get("/tasks/{task_id}")
259+
def get_task(task_id: str) -> dict:
260+
with _tasks_lock:
261+
task = _tasks.get(task_id)
262+
if task is None:
263+
raise HTTPException(status_code=404, detail=f"Task not found: {task_id}")
264+
return task.to_dict(task_id)
265+
266+
267+
@app.post("/cancel/{task_id}")
268+
def cancel_task(task_id: str) -> dict:
269+
with _tasks_lock:
270+
task = _tasks.get(task_id)
271+
if task is None:
272+
raise HTTPException(status_code=404, detail=f"Task not found: {task_id}")
273+
task.cancelled.set()
274+
return {"task_id": task_id, "cancelled": True}
275+
276+
277+
# ---------------------------------------------------------------------------
278+
# CLI entrypoint
279+
# ---------------------------------------------------------------------------
280+
281+
282+
def main() -> None:
283+
parser = argparse.ArgumentParser(description="PyCID plugin service")
284+
parser.add_argument("--port", type=int, required=True, help="Port to listen on")
285+
parser.add_argument("--host", default="127.0.0.1", help="Host to bind to")
286+
args = parser.parse_args()
287+
288+
logger.info("Starting PyCID plugin on %s:%d", args.host, args.port)
289+
uvicorn.run(app, host=args.host, port=args.port, log_level="info")
290+
291+
292+
if __name__ == "__main__":
293+
main()

0 commit comments

Comments
 (0)