alignscope 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- alignscope/__init__.py +150 -0
- alignscope/_frontend/css/style.css +663 -0
- alignscope/_frontend/index.html +169 -0
- alignscope/_frontend/js/app.js +360 -0
- alignscope/_frontend/js/metrics.js +220 -0
- alignscope/_frontend/js/timeline.js +494 -0
- alignscope/_frontend/js/topology.js +368 -0
- alignscope/adapters.py +169 -0
- alignscope/cli.py +99 -0
- alignscope/detector.py +242 -0
- alignscope/integrations/__init__.py +28 -0
- alignscope/integrations/mlflow_bridge.py +70 -0
- alignscope/integrations/wandb_bridge.py +81 -0
- alignscope/metrics.py +383 -0
- alignscope/patches/__init__.py +50 -0
- alignscope/patches/pettingzoo.py +332 -0
- alignscope/patches/pymarl.py +277 -0
- alignscope/patches/rllib.py +170 -0
- alignscope/sdk.py +606 -0
- alignscope/server.py +298 -0
- alignscope/simulator.py +493 -0
- alignscope-0.1.0.dist-info/METADATA +183 -0
- alignscope-0.1.0.dist-info/RECORD +26 -0
- alignscope-0.1.0.dist-info/WHEEL +4 -0
- alignscope-0.1.0.dist-info/entry_points.txt +2 -0
- alignscope-0.1.0.dist-info/licenses/LICENSE +21 -0
alignscope/server.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AlignScope — Dashboard Server
|
|
3
|
+
|
|
4
|
+
Serves the live dashboard frontend and handles data ingestion from:
|
|
5
|
+
1. Built-in demo simulator (--demo mode)
|
|
6
|
+
2. SDK clients via WebSocket (/ws/sdk)
|
|
7
|
+
3. REST API posts (/api/log) for Tier 4 integration
|
|
8
|
+
|
|
9
|
+
Refactored from the original backend/main.py to work as part of
|
|
10
|
+
the installable package.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
from typing import Set
|
|
17
|
+
|
|
18
|
+
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
|
|
19
|
+
from fastapi.staticfiles import StaticFiles
|
|
20
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
21
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
22
|
+
|
|
23
|
+
from alignscope.simulator import MARLSimulator, SimulatorConfig
|
|
24
|
+
from alignscope.metrics import AlignmentMetrics
|
|
25
|
+
from alignscope.detector import DefectionDetector
|
|
26
|
+
|
|
27
|
+
# Locate frontend files
|
|
28
|
+
_PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
29
|
+
_FRONTEND_CANDIDATES = [
|
|
30
|
+
os.path.join(_PACKAGE_DIR, "_frontend"), # Installed via pip (bundled)
|
|
31
|
+
os.path.join(os.path.dirname(_PACKAGE_DIR), "frontend"), # Dev mode (source tree)
|
|
32
|
+
]
|
|
33
|
+
FRONTEND_DIR = next((d for d in _FRONTEND_CANDIDATES if os.path.isdir(d)), None)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def create_app(demo: bool = False) -> FastAPI:
|
|
37
|
+
"""Create the FastAPI application."""
|
|
38
|
+
app = FastAPI(title="AlignScope", version="0.1.0")
|
|
39
|
+
|
|
40
|
+
# CORS — allow any origin for SDK clients
|
|
41
|
+
app.add_middleware(
|
|
42
|
+
CORSMiddleware,
|
|
43
|
+
allow_origins=["*"],
|
|
44
|
+
allow_methods=["*"],
|
|
45
|
+
allow_headers=["*"],
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Shared state for SDK data ingestion
|
|
49
|
+
# Each connected frontend gets its own queue (broadcast pattern)
|
|
50
|
+
app.state.frontend_queues: Set[asyncio.Queue] = set()
|
|
51
|
+
app.state.sdk_config = None
|
|
52
|
+
app.state.demo_mode = demo
|
|
53
|
+
|
|
54
|
+
# Serve frontend static files
|
|
55
|
+
if FRONTEND_DIR:
|
|
56
|
+
css_dir = os.path.join(FRONTEND_DIR, "css")
|
|
57
|
+
js_dir = os.path.join(FRONTEND_DIR, "js")
|
|
58
|
+
if os.path.isdir(css_dir):
|
|
59
|
+
app.mount("/css", StaticFiles(directory=css_dir), name="css")
|
|
60
|
+
if os.path.isdir(js_dir):
|
|
61
|
+
app.mount("/js", StaticFiles(directory=js_dir), name="js")
|
|
62
|
+
|
|
63
|
+
@app.get("/")
|
|
64
|
+
async def root():
|
|
65
|
+
if FRONTEND_DIR:
|
|
66
|
+
return FileResponse(os.path.join(FRONTEND_DIR, "index.html"))
|
|
67
|
+
return JSONResponse({"error": "Frontend not found"}, status_code=404)
|
|
68
|
+
|
|
69
|
+
# ============================================================
|
|
70
|
+
# REST API — Tier 4 (Any language)
|
|
71
|
+
# ============================================================
|
|
72
|
+
|
|
73
|
+
@app.get("/api/config")
|
|
74
|
+
async def get_config():
|
|
75
|
+
"""Return current environment schema for discovery."""
|
|
76
|
+
if app.state.sdk_config:
|
|
77
|
+
return app.state.sdk_config
|
|
78
|
+
# Return demo config as default
|
|
79
|
+
sim = MARLSimulator()
|
|
80
|
+
return sim.get_config_payload()
|
|
81
|
+
|
|
82
|
+
@app.post("/api/log")
|
|
83
|
+
async def api_log(request: Request):
|
|
84
|
+
"""
|
|
85
|
+
REST endpoint for Tier 4 integration.
|
|
86
|
+
Accepts JSON payloads from any language/framework.
|
|
87
|
+
|
|
88
|
+
curl -X POST http://localhost:8000/api/log \\
|
|
89
|
+
-H "Content-Type: application/json" \\
|
|
90
|
+
-d '{"step": 100, "agents": [...]}'
|
|
91
|
+
"""
|
|
92
|
+
body = await request.json()
|
|
93
|
+
payload = {"type": "tick", "data": body} if "type" not in body else body
|
|
94
|
+
_broadcast(app, payload)
|
|
95
|
+
return {"status": "ok", "step": body.get("step")}
|
|
96
|
+
|
|
97
|
+
@app.post("/api/config")
|
|
98
|
+
async def set_config(request: Request):
|
|
99
|
+
"""Set the environment config from an external source."""
|
|
100
|
+
body = await request.json()
|
|
101
|
+
app.state.sdk_config = body
|
|
102
|
+
return {"status": "ok"}
|
|
103
|
+
|
|
104
|
+
# ============================================================
|
|
105
|
+
# WebSocket — SDK ingestion endpoint
|
|
106
|
+
# ============================================================
|
|
107
|
+
|
|
108
|
+
@app.websocket("/ws/sdk")
|
|
109
|
+
async def ws_sdk_endpoint(websocket: WebSocket):
|
|
110
|
+
"""Receives data from the AlignScope SDK (Python client)."""
|
|
111
|
+
await websocket.accept()
|
|
112
|
+
print("[SERVER] SDK client connected to /ws/sdk")
|
|
113
|
+
try:
|
|
114
|
+
while True:
|
|
115
|
+
data = await websocket.receive_text()
|
|
116
|
+
payload = json.loads(data)
|
|
117
|
+
if payload.get("type") == "config":
|
|
118
|
+
app.state.sdk_config = payload.get("data")
|
|
119
|
+
# Broadcast config to all frontends
|
|
120
|
+
_broadcast(app, {"type": "config", "data": app.state.sdk_config})
|
|
121
|
+
print("[SERVER] SDK config received and broadcast")
|
|
122
|
+
elif payload.get("type") == "tick":
|
|
123
|
+
_broadcast(app, payload)
|
|
124
|
+
except WebSocketDisconnect:
|
|
125
|
+
print("[SERVER] SDK client disconnected from /ws/sdk")
|
|
126
|
+
|
|
127
|
+
# ============================================================
|
|
128
|
+
# WebSocket — Dashboard frontend connection
|
|
129
|
+
# ============================================================
|
|
130
|
+
|
|
131
|
+
@app.websocket("/ws")
|
|
132
|
+
async def websocket_endpoint(websocket: WebSocket):
|
|
133
|
+
await websocket.accept()
|
|
134
|
+
print(f"[SERVER] Frontend connected to /ws (demo_mode={app.state.demo_mode})")
|
|
135
|
+
|
|
136
|
+
if app.state.demo_mode:
|
|
137
|
+
await _run_demo_simulation(websocket)
|
|
138
|
+
else:
|
|
139
|
+
await _run_sdk_relay(websocket, app)
|
|
140
|
+
|
|
141
|
+
return app
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _broadcast(app: FastAPI, payload: dict):
|
|
145
|
+
"""Broadcast a payload to all connected frontend queues."""
|
|
146
|
+
for q in app.state.frontend_queues:
|
|
147
|
+
try:
|
|
148
|
+
q.put_nowait(payload)
|
|
149
|
+
except asyncio.QueueFull:
|
|
150
|
+
pass # Drop if a frontend is too slow
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
async def _run_demo_simulation(websocket: WebSocket):
|
|
154
|
+
"""Run the built-in demo simulator and stream to frontend."""
|
|
155
|
+
sim = MARLSimulator(SimulatorConfig(seed=42))
|
|
156
|
+
metrics_engine = AlignmentMetrics()
|
|
157
|
+
detector = DefectionDetector()
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
# Send config
|
|
161
|
+
await websocket.send_json({
|
|
162
|
+
"type": "config",
|
|
163
|
+
"data": sim.get_config_payload(),
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
while not sim.is_finished:
|
|
167
|
+
# Check for client commands
|
|
168
|
+
try:
|
|
169
|
+
msg = await asyncio.wait_for(websocket.receive_text(), timeout=0.01)
|
|
170
|
+
client_data = json.loads(msg)
|
|
171
|
+
if client_data.get("action") == "restart":
|
|
172
|
+
sim = MARLSimulator(SimulatorConfig(seed=None))
|
|
173
|
+
metrics_engine = AlignmentMetrics()
|
|
174
|
+
detector = DefectionDetector()
|
|
175
|
+
await websocket.send_json({
|
|
176
|
+
"type": "config",
|
|
177
|
+
"data": sim.get_config_payload(),
|
|
178
|
+
})
|
|
179
|
+
await websocket.send_json({"type": "restart"})
|
|
180
|
+
continue
|
|
181
|
+
except asyncio.TimeoutError:
|
|
182
|
+
pass
|
|
183
|
+
|
|
184
|
+
# Advance simulation
|
|
185
|
+
tick_data = sim.step()
|
|
186
|
+
tick_metrics = metrics_engine.update(tick_data)
|
|
187
|
+
events = detector.analyze(tick_data, tick_metrics)
|
|
188
|
+
relationships = sim.get_agent_relationships()
|
|
189
|
+
|
|
190
|
+
payload = {
|
|
191
|
+
"type": "tick",
|
|
192
|
+
"data": {
|
|
193
|
+
"tick": tick_data["tick"],
|
|
194
|
+
"agents": tick_data["agents"],
|
|
195
|
+
"objectives": tick_data["objectives"],
|
|
196
|
+
"team_scores": tick_data["team_scores"],
|
|
197
|
+
"metrics": {
|
|
198
|
+
"agent_metrics": tick_metrics["agent_metrics"],
|
|
199
|
+
"pair_metrics": tick_metrics["pair_metrics"],
|
|
200
|
+
"team_metrics": {
|
|
201
|
+
str(k): v for k, v in tick_metrics["team_metrics"].items()
|
|
202
|
+
},
|
|
203
|
+
"overall_alignment_score": tick_metrics["overall_alignment_score"],
|
|
204
|
+
},
|
|
205
|
+
"relationships": relationships,
|
|
206
|
+
"events": [
|
|
207
|
+
{
|
|
208
|
+
"tick": e["tick"],
|
|
209
|
+
"type": e["type"],
|
|
210
|
+
"agent_id": e.get("agent_id"),
|
|
211
|
+
"team": e.get("team"),
|
|
212
|
+
"severity": e.get("severity", 0.5),
|
|
213
|
+
"description": e["description"],
|
|
214
|
+
}
|
|
215
|
+
for e in events
|
|
216
|
+
],
|
|
217
|
+
},
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
await websocket.send_json(payload)
|
|
221
|
+
await asyncio.sleep(0.05)
|
|
222
|
+
|
|
223
|
+
await websocket.send_json({
|
|
224
|
+
"type": "episode_complete",
|
|
225
|
+
"data": {
|
|
226
|
+
"total_ticks": sim.tick,
|
|
227
|
+
"defection_summary": detector.get_summary(),
|
|
228
|
+
},
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
except WebSocketDisconnect:
|
|
232
|
+
pass
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
async def _run_sdk_relay(websocket: WebSocket, app: FastAPI):
|
|
236
|
+
"""Relay data from SDK clients to the dashboard frontend.
|
|
237
|
+
|
|
238
|
+
Each frontend connection gets its own asyncio.Queue. The SDK
|
|
239
|
+
ingestion endpoint broadcasts to ALL queues, so every browser
|
|
240
|
+
tab/window receives every tick.
|
|
241
|
+
"""
|
|
242
|
+
# Create a dedicated queue for this frontend connection
|
|
243
|
+
my_queue: asyncio.Queue = asyncio.Queue(maxsize=1000)
|
|
244
|
+
app.state.frontend_queues.add(my_queue)
|
|
245
|
+
print(f"[SERVER] Frontend relay started (total clients: {len(app.state.frontend_queues)})")
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
# Send config if available
|
|
249
|
+
if app.state.sdk_config:
|
|
250
|
+
await websocket.send_json({
|
|
251
|
+
"type": "config",
|
|
252
|
+
"data": app.state.sdk_config,
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
while True:
|
|
256
|
+
# Check for client commands (non-blocking)
|
|
257
|
+
try:
|
|
258
|
+
msg = await asyncio.wait_for(websocket.receive_text(), timeout=0.01)
|
|
259
|
+
client_data = json.loads(msg)
|
|
260
|
+
if client_data.get("action") == "restart":
|
|
261
|
+
await websocket.send_json({"type": "restart"})
|
|
262
|
+
continue
|
|
263
|
+
except asyncio.TimeoutError:
|
|
264
|
+
pass
|
|
265
|
+
|
|
266
|
+
# Check for SDK data from this connection's queue
|
|
267
|
+
try:
|
|
268
|
+
payload = my_queue.get_nowait()
|
|
269
|
+
await websocket.send_json(payload)
|
|
270
|
+
except asyncio.QueueEmpty:
|
|
271
|
+
await asyncio.sleep(0.05)
|
|
272
|
+
|
|
273
|
+
except WebSocketDisconnect:
|
|
274
|
+
pass
|
|
275
|
+
finally:
|
|
276
|
+
app.state.frontend_queues.discard(my_queue)
|
|
277
|
+
print(f"[SERVER] Frontend relay stopped (total clients: {len(app.state.frontend_queues)})")
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def run_server(host: str = "0.0.0.0", port: int = 8000, demo: bool = False):
|
|
281
|
+
"""Start the AlignScope dashboard server."""
|
|
282
|
+
import uvicorn
|
|
283
|
+
|
|
284
|
+
app = create_app(demo=demo)
|
|
285
|
+
|
|
286
|
+
if not FRONTEND_DIR:
|
|
287
|
+
print("⚠️ Frontend files not found. Dashboard UI will not be available.")
|
|
288
|
+
print(" Run from the project root or install via pip.")
|
|
289
|
+
|
|
290
|
+
mode = "demo" if demo else "SDK"
|
|
291
|
+
print(f"\n🔬 AlignScope Dashboard")
|
|
292
|
+
print(f" Mode: {mode}")
|
|
293
|
+
print(f" Dashboard: http://localhost:{port}")
|
|
294
|
+
print(f" REST API: http://localhost:{port}/api/log")
|
|
295
|
+
print(f" WebSocket: ws://localhost:{port}/ws/sdk")
|
|
296
|
+
print()
|
|
297
|
+
|
|
298
|
+
uvicorn.run(app, host=host, port=port, log_level="info")
|