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
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AlignScope — Topology Graph (D3.js Force-Directed)
|
|
3
|
+
*
|
|
4
|
+
* Renders agents as nodes and their relationships as edges.
|
|
5
|
+
* Coalition clusters form visually via D3 force simulation parameters.
|
|
6
|
+
*
|
|
7
|
+
* Nodes: colored by team (from config), sized by role stability
|
|
8
|
+
* Edges: opacity = reciprocity strength, width = total interactions
|
|
9
|
+
* Defectors: pulsing animation, pushed to periphery
|
|
10
|
+
*
|
|
11
|
+
* All team colors and role shapes are dynamically assigned from config.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const TopologyGraph = {
|
|
15
|
+
svg: null,
|
|
16
|
+
simulation: null,
|
|
17
|
+
config: null,
|
|
18
|
+
width: 0,
|
|
19
|
+
height: 0,
|
|
20
|
+
nodes: [],
|
|
21
|
+
links: [],
|
|
22
|
+
nodeElements: null,
|
|
23
|
+
linkElements: null,
|
|
24
|
+
labelElements: null,
|
|
25
|
+
|
|
26
|
+
// D3 symbol types palette — assigned dynamically to roles
|
|
27
|
+
shapePalette: [
|
|
28
|
+
d3.symbolCircle,
|
|
29
|
+
d3.symbolDiamond,
|
|
30
|
+
d3.symbolSquare,
|
|
31
|
+
d3.symbolTriangle,
|
|
32
|
+
d3.symbolStar,
|
|
33
|
+
d3.symbolCross,
|
|
34
|
+
d3.symbolWye,
|
|
35
|
+
],
|
|
36
|
+
|
|
37
|
+
// Computed mappings (built from config)
|
|
38
|
+
roleShapes: {},
|
|
39
|
+
roleSize: {},
|
|
40
|
+
|
|
41
|
+
init(config) {
|
|
42
|
+
this.config = config;
|
|
43
|
+
|
|
44
|
+
// Build role → shape mapping dynamically from config
|
|
45
|
+
const roles = config.roles || [];
|
|
46
|
+
this.roleShapes = {};
|
|
47
|
+
this.roleSize = {};
|
|
48
|
+
roles.forEach((role, idx) => {
|
|
49
|
+
this.roleShapes[role] = this.shapePalette[idx % this.shapePalette.length];
|
|
50
|
+
this.roleSize[role] = 55 + (idx % 4) * 5; // slight size variation
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const container = document.getElementById('topology-container');
|
|
54
|
+
this.width = container.clientWidth;
|
|
55
|
+
this.height = container.clientHeight;
|
|
56
|
+
|
|
57
|
+
this.svg = d3.select('#topology-svg')
|
|
58
|
+
.attr('width', this.width)
|
|
59
|
+
.attr('height', this.height);
|
|
60
|
+
|
|
61
|
+
// Clear any previous content
|
|
62
|
+
this.svg.selectAll('*').remove();
|
|
63
|
+
|
|
64
|
+
// Create layer groups (links behind nodes)
|
|
65
|
+
this.svg.append('g').attr('class', 'links-layer');
|
|
66
|
+
this.svg.append('g').attr('class', 'nodes-layer');
|
|
67
|
+
this.svg.append('g').attr('class', 'labels-layer');
|
|
68
|
+
|
|
69
|
+
// Initialize force simulation
|
|
70
|
+
this.simulation = d3.forceSimulation()
|
|
71
|
+
.force('charge', d3.forceManyBody()
|
|
72
|
+
.strength(-120)
|
|
73
|
+
.distanceMax(250))
|
|
74
|
+
.force('center', d3.forceCenter(this.width / 2, this.height / 2))
|
|
75
|
+
.force('collision', d3.forceCollide().radius(22))
|
|
76
|
+
.force('x', d3.forceX(this.width / 2).strength(0.03))
|
|
77
|
+
.force('y', d3.forceY(this.height / 2).strength(0.03))
|
|
78
|
+
.alphaDecay(0.01)
|
|
79
|
+
.on('tick', () => this.ticked());
|
|
80
|
+
|
|
81
|
+
this.updateBadge(0);
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Get team color from config or fallback to AlignScope palette.
|
|
86
|
+
*/
|
|
87
|
+
getTeamColor(teamIdx) {
|
|
88
|
+
return AlignScope.getTeamColor(teamIdx);
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get a slightly darker team color for stroke.
|
|
93
|
+
*/
|
|
94
|
+
getTeamStroke(teamIdx) {
|
|
95
|
+
const color = this.getTeamColor(teamIdx);
|
|
96
|
+
// Darken by mixing toward black
|
|
97
|
+
return this._darkenColor(color, 0.2);
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
_darkenColor(hex, amount) {
|
|
101
|
+
// Simple hex darkening
|
|
102
|
+
let c = hex.replace('#', '');
|
|
103
|
+
if (c.length === 3) c = c[0]+c[0]+c[1]+c[1]+c[2]+c[2];
|
|
104
|
+
const r = Math.max(0, Math.round(parseInt(c.substr(0,2), 16) * (1 - amount)));
|
|
105
|
+
const g = Math.max(0, Math.round(parseInt(c.substr(2,2), 16) * (1 - amount)));
|
|
106
|
+
const b = Math.max(0, Math.round(parseInt(c.substr(4,2), 16) * (1 - amount)));
|
|
107
|
+
return `rgb(${r},${g},${b})`;
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
update(data) {
|
|
111
|
+
if (!this.svg) return;
|
|
112
|
+
|
|
113
|
+
const agents = data.agents || [];
|
|
114
|
+
const relationships = data.relationships || [];
|
|
115
|
+
const metrics = data.metrics?.agent_metrics || {};
|
|
116
|
+
const groupBy = this.config?.topology?.groupBy || 'team';
|
|
117
|
+
|
|
118
|
+
// Build node data
|
|
119
|
+
const nodeIds = new Set(agents.map(a => a.agent_id));
|
|
120
|
+
this.nodes = agents.map(a => {
|
|
121
|
+
const existing = this.simulation?.nodes()?.find(n => n.id === a.agent_id);
|
|
122
|
+
const m = metrics[a.agent_id] || metrics[String(a.agent_id)] || {};
|
|
123
|
+
return {
|
|
124
|
+
id: a.agent_id,
|
|
125
|
+
team: a[groupBy] !== undefined ? a[groupBy] : a.team,
|
|
126
|
+
role: a.role,
|
|
127
|
+
isDefector: a.is_defector,
|
|
128
|
+
coalitionId: a.coalition_id,
|
|
129
|
+
roleStability: m.role_stability || 1.0,
|
|
130
|
+
x: existing?.x || this.width / 2 + (Math.random() - 0.5) * 100,
|
|
131
|
+
y: existing?.y || this.height / 2 + (Math.random() - 0.5) * 100,
|
|
132
|
+
vx: existing?.vx || 0,
|
|
133
|
+
vy: existing?.vy || 0,
|
|
134
|
+
};
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Build link data
|
|
138
|
+
this.links = relationships
|
|
139
|
+
.filter(r => r.weight > 0 && nodeIds.has(r.source) && nodeIds.has(r.target))
|
|
140
|
+
.map(r => ({
|
|
141
|
+
source: r.source,
|
|
142
|
+
target: r.target,
|
|
143
|
+
weight: r.weight,
|
|
144
|
+
reciprocity: r.reciprocity,
|
|
145
|
+
sameTeam: r.same_team,
|
|
146
|
+
}));
|
|
147
|
+
|
|
148
|
+
this.render();
|
|
149
|
+
this.updateBadge(agents.length);
|
|
150
|
+
|
|
151
|
+
this.simulation.nodes(this.nodes);
|
|
152
|
+
this.simulation
|
|
153
|
+
.force('link', d3.forceLink(this.links)
|
|
154
|
+
.id(d => d.id)
|
|
155
|
+
.distance(d => {
|
|
156
|
+
const base = 100;
|
|
157
|
+
return Math.max(40, base - d.weight * 5);
|
|
158
|
+
})
|
|
159
|
+
.strength(d => {
|
|
160
|
+
return d.sameTeam ? 0.3 + d.reciprocity * 0.4 : 0.05;
|
|
161
|
+
}))
|
|
162
|
+
.alpha(0.15)
|
|
163
|
+
.restart();
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
render() {
|
|
167
|
+
const linksLayer = this.svg.select('.links-layer');
|
|
168
|
+
const nodesLayer = this.svg.select('.nodes-layer');
|
|
169
|
+
const labelsLayer = this.svg.select('.labels-layer');
|
|
170
|
+
|
|
171
|
+
// --- Links ---
|
|
172
|
+
this.linkElements = linksLayer.selectAll('.link-line')
|
|
173
|
+
.data(this.links, d => `${d.source.id || d.source}-${d.target.id || d.target}`);
|
|
174
|
+
|
|
175
|
+
this.linkElements.exit().remove();
|
|
176
|
+
|
|
177
|
+
const linkEnter = this.linkElements.enter()
|
|
178
|
+
.append('line')
|
|
179
|
+
.attr('class', 'link-line');
|
|
180
|
+
|
|
181
|
+
this.linkElements = linkEnter.merge(this.linkElements)
|
|
182
|
+
.attr('stroke-width', d => Math.max(0.5, Math.min(3, d.weight * 0.3)))
|
|
183
|
+
.attr('stroke-opacity', d => Math.max(0.08, Math.min(0.6, d.reciprocity * 0.8)))
|
|
184
|
+
.attr('stroke', d => d.sameTeam ? 'var(--text-muted)' : 'var(--border-subtle)');
|
|
185
|
+
|
|
186
|
+
// --- Nodes ---
|
|
187
|
+
this.nodeElements = nodesLayer.selectAll('.node-group')
|
|
188
|
+
.data(this.nodes, d => d.id);
|
|
189
|
+
|
|
190
|
+
this.nodeElements.exit().remove();
|
|
191
|
+
|
|
192
|
+
const nodeEnter = this.nodeElements.enter()
|
|
193
|
+
.append('g')
|
|
194
|
+
.attr('class', 'node-group')
|
|
195
|
+
.style('cursor', 'pointer')
|
|
196
|
+
.on('click', (event, d) => {
|
|
197
|
+
AlignScope.selectAgent(d.id);
|
|
198
|
+
this.highlightAgent(d.id);
|
|
199
|
+
})
|
|
200
|
+
.call(d3.drag()
|
|
201
|
+
.on('start', (event, d) => this.dragStarted(event, d))
|
|
202
|
+
.on('drag', (event, d) => this.dragged(event, d))
|
|
203
|
+
.on('end', (event, d) => this.dragEnded(event, d)));
|
|
204
|
+
|
|
205
|
+
nodeEnter.append('path').attr('class', 'node-shape');
|
|
206
|
+
|
|
207
|
+
this.nodeElements = nodeEnter.merge(this.nodeElements);
|
|
208
|
+
|
|
209
|
+
// Update node appearance — colors and shapes from config
|
|
210
|
+
this.nodeElements.select('.node-shape')
|
|
211
|
+
.attr('d', d => {
|
|
212
|
+
const size = this.roleSize[d.role] || 60;
|
|
213
|
+
const scaledSize = size * (0.8 + d.roleStability * 0.6);
|
|
214
|
+
const shape = this.roleShapes[d.role] || d3.symbolCircle;
|
|
215
|
+
return d3.symbol().type(shape).size(scaledSize)();
|
|
216
|
+
})
|
|
217
|
+
.attr('fill', d => {
|
|
218
|
+
if (d.isDefector) return 'var(--color-defection)';
|
|
219
|
+
return this.getTeamColor(d.team);
|
|
220
|
+
})
|
|
221
|
+
.attr('stroke', d => {
|
|
222
|
+
if (d.isDefector) return 'var(--color-defection)';
|
|
223
|
+
return this.getTeamStroke(d.team);
|
|
224
|
+
})
|
|
225
|
+
.attr('stroke-width', 1.5)
|
|
226
|
+
.attr('opacity', d => d.isDefector ? 0.7 : 0.9)
|
|
227
|
+
.classed('node-defector', d => d.isDefector);
|
|
228
|
+
|
|
229
|
+
// --- Labels ---
|
|
230
|
+
this.labelElements = labelsLayer.selectAll('.node-label')
|
|
231
|
+
.data(this.nodes, d => d.id);
|
|
232
|
+
|
|
233
|
+
this.labelElements.exit().remove();
|
|
234
|
+
|
|
235
|
+
const labelEnter = this.labelElements.enter()
|
|
236
|
+
.append('text')
|
|
237
|
+
.attr('class', 'node-label')
|
|
238
|
+
.attr('text-anchor', 'middle')
|
|
239
|
+
.attr('dy', -12);
|
|
240
|
+
|
|
241
|
+
this.labelElements = labelEnter.merge(this.labelElements)
|
|
242
|
+
.text(d => d.id);
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
ticked() {
|
|
246
|
+
if (this.linkElements) {
|
|
247
|
+
this.linkElements
|
|
248
|
+
.attr('x1', d => d.source.x)
|
|
249
|
+
.attr('y1', d => d.source.y)
|
|
250
|
+
.attr('x2', d => d.target.x)
|
|
251
|
+
.attr('y2', d => d.target.y);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (this.nodeElements) {
|
|
255
|
+
this.nodeElements
|
|
256
|
+
.attr('transform', d => {
|
|
257
|
+
d.x = Math.max(20, Math.min(this.width - 20, d.x));
|
|
258
|
+
d.y = Math.max(20, Math.min(this.height - 20, d.y));
|
|
259
|
+
return `translate(${d.x},${d.y})`;
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (this.labelElements) {
|
|
264
|
+
this.labelElements
|
|
265
|
+
.attr('x', d => d.x)
|
|
266
|
+
.attr('y', d => d.y);
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
|
|
270
|
+
dragStarted(event, d) {
|
|
271
|
+
if (!event.active) this.simulation.alphaTarget(0.1).restart();
|
|
272
|
+
d.fx = d.x;
|
|
273
|
+
d.fy = d.y;
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
dragged(event, d) {
|
|
277
|
+
d.fx = event.x;
|
|
278
|
+
d.fy = event.y;
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
dragEnded(event, d) {
|
|
282
|
+
if (!event.active) this.simulation.alphaTarget(0);
|
|
283
|
+
d.fx = null;
|
|
284
|
+
d.fy = null;
|
|
285
|
+
},
|
|
286
|
+
|
|
287
|
+
highlightAgent(agentId) {
|
|
288
|
+
if (!this.nodeElements) return;
|
|
289
|
+
|
|
290
|
+
this.nodeElements.select('.node-shape')
|
|
291
|
+
.attr('stroke-width', d => d.id === agentId ? 3 : 1.5)
|
|
292
|
+
.attr('stroke', d => {
|
|
293
|
+
if (d.id === agentId) return 'var(--text-primary)';
|
|
294
|
+
if (d.isDefector) return 'var(--color-defection)';
|
|
295
|
+
return this.getTeamStroke(d.team);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
if (this.linkElements) {
|
|
299
|
+
this.linkElements
|
|
300
|
+
.attr('stroke-opacity', d => {
|
|
301
|
+
const srcId = d.source.id ?? d.source;
|
|
302
|
+
const tgtId = d.target.id ?? d.target;
|
|
303
|
+
if (srcId === agentId || tgtId === agentId) {
|
|
304
|
+
return 0.8;
|
|
305
|
+
}
|
|
306
|
+
return Math.max(0.05, Math.min(0.3, d.reciprocity * 0.4));
|
|
307
|
+
})
|
|
308
|
+
.attr('stroke-width', d => {
|
|
309
|
+
const srcId = d.source.id ?? d.source;
|
|
310
|
+
const tgtId = d.target.id ?? d.target;
|
|
311
|
+
if (srcId === agentId || tgtId === agentId) {
|
|
312
|
+
return Math.max(1.5, d.weight * 0.5);
|
|
313
|
+
}
|
|
314
|
+
return Math.max(0.5, Math.min(3, d.weight * 0.3));
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
|
|
319
|
+
clearSelection() {
|
|
320
|
+
if (!this.nodeElements) return;
|
|
321
|
+
this.nodeElements.select('.node-shape')
|
|
322
|
+
.attr('stroke-width', 1.5)
|
|
323
|
+
.attr('stroke', d => {
|
|
324
|
+
if (d.isDefector) return 'var(--color-defection)';
|
|
325
|
+
return this.getTeamStroke(d.team);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
if (this.linkElements) {
|
|
329
|
+
this.linkElements
|
|
330
|
+
.attr('stroke-opacity', d => Math.max(0.08, Math.min(0.6, d.reciprocity * 0.8)))
|
|
331
|
+
.attr('stroke-width', d => Math.max(0.5, Math.min(3, d.weight * 0.3)));
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
|
|
335
|
+
updateBadge(count) {
|
|
336
|
+
document.getElementById('topology-badge').textContent =
|
|
337
|
+
`${count} node${count !== 1 ? 's' : ''}`;
|
|
338
|
+
},
|
|
339
|
+
|
|
340
|
+
resize() {
|
|
341
|
+
const container = document.getElementById('topology-container');
|
|
342
|
+
this.width = container.clientWidth;
|
|
343
|
+
this.height = container.clientHeight;
|
|
344
|
+
|
|
345
|
+
if (this.svg) {
|
|
346
|
+
this.svg
|
|
347
|
+
.attr('width', this.width)
|
|
348
|
+
.attr('height', this.height);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (this.simulation) {
|
|
352
|
+
this.simulation
|
|
353
|
+
.force('center', d3.forceCenter(this.width / 2, this.height / 2))
|
|
354
|
+
.force('x', d3.forceX(this.width / 2).strength(0.03))
|
|
355
|
+
.force('y', d3.forceY(this.height / 2).strength(0.03))
|
|
356
|
+
.alpha(0.3)
|
|
357
|
+
.restart();
|
|
358
|
+
}
|
|
359
|
+
},
|
|
360
|
+
|
|
361
|
+
reset() {
|
|
362
|
+
if (this.svg) this.svg.selectAll('.links-layer *, .nodes-layer *, .labels-layer *').remove();
|
|
363
|
+
this.nodes = [];
|
|
364
|
+
this.links = [];
|
|
365
|
+
if (this.simulation) this.simulation.nodes([]);
|
|
366
|
+
this.updateBadge(0);
|
|
367
|
+
},
|
|
368
|
+
};
|
alignscope/adapters.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
from typing import Dict, Any
|
|
2
|
+
|
|
3
|
+
def extract_mpe_state(env, agent_id: str) -> Dict[str, Any]:
|
|
4
|
+
"""
|
|
5
|
+
Extracts continuous (x, y) coordinates from PettingZoo MPE physics engine.
|
|
6
|
+
MPE agents have state.p_pos = [x, y].
|
|
7
|
+
"""
|
|
8
|
+
try:
|
|
9
|
+
# Aces environments often expose the raw wrapped env
|
|
10
|
+
raw_env = getattr(env, "env", env)
|
|
11
|
+
if hasattr(raw_env, "unwrapped"):
|
|
12
|
+
raw_env = raw_env.unwrapped
|
|
13
|
+
|
|
14
|
+
if hasattr(raw_env, "world"):
|
|
15
|
+
# Multi-Particle Environment (MPE)
|
|
16
|
+
for internal_agent in raw_env.world.agents:
|
|
17
|
+
# MPE raw agents are usually just named "agent 0", "adversary 0"
|
|
18
|
+
if agent_id.replace("_", " ") in internal_agent.name or internal_agent.name.replace(" ", "_") in agent_id:
|
|
19
|
+
return {
|
|
20
|
+
"x": float(internal_agent.state.p_pos[0]),
|
|
21
|
+
"y": float(internal_agent.state.p_pos[1])
|
|
22
|
+
}
|
|
23
|
+
except Exception:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
# Fallback if properties not found
|
|
27
|
+
return {"x": 0.0, "y": 0.0}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def extract_kaz_state(env, agent_id: str) -> Dict[str, Any]:
|
|
31
|
+
"""
|
|
32
|
+
Extracts grid coordinates from PettingZoo Knights-Archers-Zombies (KAZ).
|
|
33
|
+
Also accurately identifies the role based on the agent name.
|
|
34
|
+
"""
|
|
35
|
+
role = "agent"
|
|
36
|
+
if "knight" in agent_id.lower():
|
|
37
|
+
role = "knight"
|
|
38
|
+
elif "archer" in agent_id.lower():
|
|
39
|
+
role = "archer"
|
|
40
|
+
elif "zombie" in agent_id.lower():
|
|
41
|
+
role = "zombie"
|
|
42
|
+
|
|
43
|
+
pos = {"x": 0.0, "y": 0.0}
|
|
44
|
+
try:
|
|
45
|
+
raw_env = getattr(env, "env", env)
|
|
46
|
+
if hasattr(raw_env, "unwrapped"):
|
|
47
|
+
raw_env = raw_env.unwrapped
|
|
48
|
+
|
|
49
|
+
# KAZ internal objects are kept in lists like env.knights, env.archers
|
|
50
|
+
collection = []
|
|
51
|
+
if role == "knight" and hasattr(raw_env, "knights"):
|
|
52
|
+
collection = raw_env.knights
|
|
53
|
+
elif role == "archer" and hasattr(raw_env, "archers"):
|
|
54
|
+
collection = raw_env.archers
|
|
55
|
+
elif role == "zombie" and hasattr(raw_env, "zombies"):
|
|
56
|
+
collection = raw_env.zombies
|
|
57
|
+
|
|
58
|
+
# We try to match the index if agent_id ends with a number (e.g., "knight_0")
|
|
59
|
+
try:
|
|
60
|
+
idx = int(agent_id.split("_")[-1])
|
|
61
|
+
if idx < len(collection):
|
|
62
|
+
obj = collection[idx]
|
|
63
|
+
if hasattr(obj, "position"): # Usually [x, y] in KAZ
|
|
64
|
+
pos["x"] = float(obj.position[0])
|
|
65
|
+
pos["y"] = float(obj.position[1])
|
|
66
|
+
except (ValueError, IndexError):
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
"role": role,
|
|
74
|
+
"x": pos["x"],
|
|
75
|
+
"y": pos["y"]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
def extract_smac_state(env, agent_id: str) -> Dict[str, Any]:
|
|
79
|
+
"""
|
|
80
|
+
Extracts unit type, position, health, and shield from SMAC (StarCraft Multi-Agent Challenge).
|
|
81
|
+
|
|
82
|
+
SMAC exposes per-unit data via env.get_unit_by_id() or the internal
|
|
83
|
+
controller's allies/enemies lists.
|
|
84
|
+
"""
|
|
85
|
+
role = "marine" # Default SMAC unit
|
|
86
|
+
pos = {"x": 0.0, "y": 0.0}
|
|
87
|
+
health = 0.0
|
|
88
|
+
shield = 0.0
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
raw_env = getattr(env, "env", env)
|
|
92
|
+
if hasattr(raw_env, "unwrapped"):
|
|
93
|
+
raw_env = raw_env.unwrapped
|
|
94
|
+
|
|
95
|
+
# Extract agent index
|
|
96
|
+
try:
|
|
97
|
+
idx = int(agent_id.split("_")[-1])
|
|
98
|
+
except (ValueError, IndexError):
|
|
99
|
+
idx = 0
|
|
100
|
+
|
|
101
|
+
# SMAC StarCraft2Env exposes allies via controller
|
|
102
|
+
if hasattr(raw_env, "agents") and hasattr(raw_env, "get_unit_by_id"):
|
|
103
|
+
unit = raw_env.get_unit_by_id(idx)
|
|
104
|
+
if unit is not None:
|
|
105
|
+
pos["x"] = float(unit.pos.x) if hasattr(unit, "pos") else 0.0
|
|
106
|
+
pos["y"] = float(unit.pos.y) if hasattr(unit, "pos") else 0.0
|
|
107
|
+
health = float(unit.health) / float(unit.health_max) if hasattr(unit, "health_max") and unit.health_max > 0 else 0.0
|
|
108
|
+
shield = float(unit.shield) / float(unit.shield_max) if hasattr(unit, "shield_max") and unit.shield_max > 0 else 0.0
|
|
109
|
+
|
|
110
|
+
# Infer role from unit type
|
|
111
|
+
unit_type = getattr(unit, "unit_type", None)
|
|
112
|
+
type_map = {
|
|
113
|
+
0: "marine", 1: "marauder", 2: "medivac",
|
|
114
|
+
48: "marine", 49: "marauder", 50: "reaper",
|
|
115
|
+
51: "ghost", 73: "zealot", 74: "stalker",
|
|
116
|
+
75: "sentry", 76: "high_templar", 77: "colossus",
|
|
117
|
+
}
|
|
118
|
+
if unit_type is not None:
|
|
119
|
+
role = type_map.get(unit_type, f"unit_{unit_type}")
|
|
120
|
+
|
|
121
|
+
# Fallback: try the _obs property or controller
|
|
122
|
+
elif hasattr(raw_env, "controller") or hasattr(raw_env, "_sc2_env"):
|
|
123
|
+
# Newer SMAC versions
|
|
124
|
+
sc2_env = getattr(raw_env, "_sc2_env", raw_env)
|
|
125
|
+
if hasattr(sc2_env, "get_ally_units"):
|
|
126
|
+
allies = sc2_env.get_ally_units()
|
|
127
|
+
if idx < len(allies):
|
|
128
|
+
unit = allies[idx]
|
|
129
|
+
pos["x"] = float(getattr(unit, "x", 0))
|
|
130
|
+
pos["y"] = float(getattr(unit, "y", 0))
|
|
131
|
+
health = float(getattr(unit, "health", 0)) / max(float(getattr(unit, "health_max", 1)), 1)
|
|
132
|
+
role = "ally"
|
|
133
|
+
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
"role": role,
|
|
139
|
+
"x": pos["x"],
|
|
140
|
+
"y": pos["y"],
|
|
141
|
+
"health": health,
|
|
142
|
+
"shield": shield,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def try_extract_env_state(env, agent_id: str) -> Dict[str, Any]:
|
|
147
|
+
"""
|
|
148
|
+
Master adapter router. Detects environment type and routes to the correct extractor.
|
|
149
|
+
"""
|
|
150
|
+
env_name = str(env).lower()
|
|
151
|
+
|
|
152
|
+
# Check for MPE
|
|
153
|
+
if "mpe" in env_name or "simple_" in env_name:
|
|
154
|
+
return extract_mpe_state(env, agent_id)
|
|
155
|
+
|
|
156
|
+
# Check for Knights Archers Zombies
|
|
157
|
+
if "knights_archers_zombies" in env_name or "kaz" in env_name:
|
|
158
|
+
return extract_kaz_state(env, agent_id)
|
|
159
|
+
|
|
160
|
+
# Check for SMAC (StarCraft Multi-Agent Challenge)
|
|
161
|
+
if "starcraft" in env_name or "smac" in env_name or "sc2" in env_name:
|
|
162
|
+
return extract_smac_state(env, agent_id)
|
|
163
|
+
|
|
164
|
+
# Check for SMAC by attribute detection (env_name may not contain keywords)
|
|
165
|
+
raw = getattr(env, "unwrapped", env)
|
|
166
|
+
if hasattr(raw, "get_unit_by_id") or hasattr(raw, "_sc2_env"):
|
|
167
|
+
return extract_smac_state(env, agent_id)
|
|
168
|
+
|
|
169
|
+
return {"x": 0.0, "y": 0.0}
|
alignscope/cli.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AlignScope — CLI
|
|
3
|
+
|
|
4
|
+
Commands:
|
|
5
|
+
alignscope start [--port 8000] [--demo] Start the dashboard server
|
|
6
|
+
alignscope patch <framework> Auto-patch a MARL framework
|
|
7
|
+
alignscope share Create a public tunnel (ngrok)
|
|
8
|
+
alignscope version Print version
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@click.group()
|
|
18
|
+
def main():
|
|
19
|
+
"""🔬 AlignScope — MARL Alignment Observability"""
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@main.command()
|
|
24
|
+
@click.option("--port", default=8000, help="Port to serve on")
|
|
25
|
+
@click.option("--host", default="0.0.0.0", help="Host to bind to")
|
|
26
|
+
@click.option("--demo", is_flag=True, help="Run built-in demo simulator")
|
|
27
|
+
def start(port: int, host: str, demo: bool):
|
|
28
|
+
"""Start the AlignScope dashboard server."""
|
|
29
|
+
from alignscope.server import run_server
|
|
30
|
+
|
|
31
|
+
console.print()
|
|
32
|
+
console.print("[bold cyan]🔬 AlignScope[/] starting...", highlight=False)
|
|
33
|
+
console.print()
|
|
34
|
+
|
|
35
|
+
run_server(host=host, port=port, demo=demo)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@main.command()
|
|
39
|
+
@click.argument("framework", type=click.Choice(["rllib", "pettingzoo", "pymarl"]))
|
|
40
|
+
def patch(framework: str):
|
|
41
|
+
"""Auto-patch a MARL framework for zero-code integration."""
|
|
42
|
+
from alignscope.patches import apply_patch
|
|
43
|
+
|
|
44
|
+
console.print(f"\n[bold cyan]🔬 AlignScope[/] patching [bold]{framework}[/]...\n")
|
|
45
|
+
|
|
46
|
+
success = apply_patch(framework)
|
|
47
|
+
|
|
48
|
+
if success:
|
|
49
|
+
console.print(f"[green]✓[/] [bold]{framework}[/] successfully patched!")
|
|
50
|
+
console.print(f" Your training code needs [bold]zero changes[/].")
|
|
51
|
+
console.print(f" Just run [cyan]alignscope start[/] in another terminal,")
|
|
52
|
+
console.print(f" then run your training script as normal.\n")
|
|
53
|
+
else:
|
|
54
|
+
console.print(f"[red]✗[/] Failed to patch {framework}.")
|
|
55
|
+
console.print(f" Is it installed? Try: [cyan]pip install alignscope[{framework}][/]\n")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@main.command()
|
|
59
|
+
@click.option("--port", default=8000, help="Local port to tunnel")
|
|
60
|
+
def share(port: int):
|
|
61
|
+
"""Create a public URL to share your dashboard with teammates."""
|
|
62
|
+
try:
|
|
63
|
+
from pyngrok import ngrok
|
|
64
|
+
|
|
65
|
+
console.print(f"\n[bold cyan]🔬 AlignScope[/] creating public tunnel...\n")
|
|
66
|
+
|
|
67
|
+
tunnel = ngrok.connect(port, "http")
|
|
68
|
+
public_url = tunnel.public_url
|
|
69
|
+
|
|
70
|
+
console.print(f"[green]✓[/] Dashboard is now publicly accessible:")
|
|
71
|
+
console.print(f" [bold link={public_url}]{public_url}[/]")
|
|
72
|
+
console.print(f"\n Share this URL with your team.")
|
|
73
|
+
console.print(f" Press [bold]Ctrl+C[/] to stop sharing.\n")
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
ngrok.get_tunnels()
|
|
77
|
+
input(" Press Enter to stop sharing...")
|
|
78
|
+
except KeyboardInterrupt:
|
|
79
|
+
pass
|
|
80
|
+
finally:
|
|
81
|
+
ngrok.disconnect(tunnel.public_url)
|
|
82
|
+
console.print("\n[yellow]Tunnel closed.[/]\n")
|
|
83
|
+
|
|
84
|
+
except ImportError:
|
|
85
|
+
console.print(
|
|
86
|
+
"\n[red]✗[/] ngrok is not installed.\n"
|
|
87
|
+
" Install it with: [cyan]pip install pyngrok[/]\n"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@main.command()
|
|
92
|
+
def version():
|
|
93
|
+
"""Print AlignScope version."""
|
|
94
|
+
from alignscope import __version__
|
|
95
|
+
console.print(f"AlignScope v{__version__}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
main()
|