div18 commited on
Commit ·
04b46cd
1
Parent(s): 5f3b0b8
CLRF
Browse files- .gitattributes +13 -0
- deploy/do/deploy-droplet-one-shot.sh +0 -0
- deploy/do/uninstall-legacy-openenv.sh +0 -0
- deploy/entrypoint.sh +0 -0
- deploy/grafana/provisioning/dashboards/json/antiatropos-overview.json +623 -623
- smoke_test.py +602 -602
- train_test.py +538 -538
- training/__init__.py +27 -27
- training/losses.py +390 -390
- training/trainer.py +559 -559
.gitattributes
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Shell scripts must always use LF (Linux) line endings
|
| 2 |
+
*.sh text eol=lf
|
| 3 |
+
|
| 4 |
+
# Windows-specific scripts
|
| 5 |
+
*.ps1 text eol=crlf
|
| 6 |
+
*.bat text eol=crlf
|
| 7 |
+
*.cmd text eol=crlf
|
| 8 |
+
|
| 9 |
+
# YAML and Docker files should be LF
|
| 10 |
+
*.yml text eol=lf
|
| 11 |
+
*.yaml text eol=lf
|
| 12 |
+
Dockerfile text eol=lf
|
| 13 |
+
.dockerignore text eol=lf
|
deploy/do/deploy-droplet-one-shot.sh
CHANGED
|
File without changes
|
deploy/do/uninstall-legacy-openenv.sh
CHANGED
|
File without changes
|
deploy/entrypoint.sh
CHANGED
|
File without changes
|
deploy/grafana/provisioning/dashboards/json/antiatropos-overview.json
CHANGED
|
@@ -1,647 +1,647 @@
|
|
| 1 |
-
{
|
| 2 |
-
"annotations": {
|
| 3 |
-
"list": [
|
| 4 |
-
{
|
| 5 |
-
"builtIn": 1,
|
| 6 |
-
"datasource": {
|
| 7 |
-
"type": "grafana",
|
| 8 |
-
"uid": "-- Grafana --"
|
| 9 |
-
},
|
| 10 |
-
"enable": true,
|
| 11 |
-
"hide": true,
|
| 12 |
-
"iconColor": "rgba(0, 211, 255, 1)",
|
| 13 |
-
"name": "Annotations & Alerts",
|
| 14 |
-
"type": "dashboard"
|
| 15 |
-
}
|
| 16 |
-
]
|
| 17 |
-
},
|
| 18 |
-
"editable": true,
|
| 19 |
-
"fiscalYearStartMonth": 0,
|
| 20 |
-
"graphTooltip": 1,
|
| 21 |
-
"id": null,
|
| 22 |
-
"links": [],
|
| 23 |
-
"liveNow": false,
|
| 24 |
-
"panels": [
|
| 25 |
-
{
|
| 26 |
-
"datasource": {
|
| 27 |
-
"type": "prometheus",
|
| 28 |
-
"uid": "PBFA97CFB590B2093"
|
| 29 |
-
},
|
| 30 |
-
"fieldConfig": {
|
| 31 |
-
"defaults": {
|
| 32 |
-
"color": {
|
| 33 |
-
"mode": "thresholds"
|
| 34 |
-
},
|
| 35 |
-
"decimals": 3,
|
| 36 |
-
"mappings": [],
|
| 37 |
-
"thresholds": {
|
| 38 |
-
"mode": "absolute",
|
| 39 |
-
"steps": [
|
| 40 |
-
{
|
| 41 |
-
"color": "green",
|
| 42 |
-
"value": null
|
| 43 |
-
},
|
| 44 |
-
{
|
| 45 |
-
"color": "red",
|
| 46 |
-
"value": 0
|
| 47 |
-
}
|
| 48 |
-
]
|
| 49 |
-
},
|
| 50 |
-
"unit": "short"
|
| 51 |
-
},
|
| 52 |
-
"overrides": []
|
| 53 |
-
},
|
| 54 |
-
"gridPos": {
|
| 55 |
-
"h": 4,
|
| 56 |
-
"w": 6,
|
| 57 |
-
"x": 0,
|
| 58 |
-
"y": 0
|
| 59 |
-
},
|
| 60 |
-
"id": 1,
|
| 61 |
-
"options": {
|
| 62 |
-
"colorMode": "value",
|
| 63 |
-
"graphMode": "none",
|
| 64 |
-
"justifyMode": "auto",
|
| 65 |
-
"orientation": "auto",
|
| 66 |
-
"reduceOptions": {
|
| 67 |
-
"calcs": [
|
| 68 |
-
"lastNotNull"
|
| 69 |
-
],
|
| 70 |
-
"fields": "",
|
| 71 |
-
"values": false
|
| 72 |
-
},
|
| 73 |
-
"textMode": "auto"
|
| 74 |
-
},
|
| 75 |
-
"pluginVersion": "12.3.1",
|
| 76 |
-
"targets": [
|
| 77 |
-
{
|
| 78 |
-
"editorMode": "code",
|
| 79 |
"expr": "scalar(avg(last_over_time(antiatropos_reward{mode=~\"live|simulated|hybrid|aws\"}[1m])))",
|
| 80 |
"legendFormat": "reward (all modes)",
|
| 81 |
-
"range": true,
|
| 82 |
-
"refId": "A"
|
| 83 |
-
}
|
| 84 |
-
],
|
| 85 |
-
"title": "Latest Reward",
|
| 86 |
-
"type": "stat"
|
| 87 |
-
},
|
| 88 |
-
{
|
| 89 |
-
"datasource": {
|
| 90 |
-
"type": "prometheus",
|
| 91 |
-
"uid": "PBFA97CFB590B2093"
|
| 92 |
-
},
|
| 93 |
-
"fieldConfig": {
|
| 94 |
-
"defaults": {
|
| 95 |
-
"color": {
|
| 96 |
-
"mode": "thresholds"
|
| 97 |
-
},
|
| 98 |
-
"decimals": 3,
|
| 99 |
-
"mappings": [],
|
| 100 |
-
"thresholds": {
|
| 101 |
-
"mode": "absolute",
|
| 102 |
-
"steps": [
|
| 103 |
-
{
|
| 104 |
-
"color": "green",
|
| 105 |
-
"value": null
|
| 106 |
-
},
|
| 107 |
-
{
|
| 108 |
-
"color": "orange",
|
| 109 |
-
"value": 0.4
|
| 110 |
-
},
|
| 111 |
-
{
|
| 112 |
-
"color": "red",
|
| 113 |
-
"value": 0.8
|
| 114 |
-
}
|
| 115 |
-
]
|
| 116 |
-
},
|
| 117 |
-
"unit": "short"
|
| 118 |
-
},
|
| 119 |
-
"overrides": []
|
| 120 |
-
},
|
| 121 |
-
"gridPos": {
|
| 122 |
-
"h": 4,
|
| 123 |
-
"w": 6,
|
| 124 |
-
"x": 6,
|
| 125 |
-
"y": 0
|
| 126 |
-
},
|
| 127 |
-
"id": 2,
|
| 128 |
-
"options": {
|
| 129 |
-
"colorMode": "value",
|
| 130 |
-
"graphMode": "none",
|
| 131 |
-
"justifyMode": "auto",
|
| 132 |
-
"orientation": "auto",
|
| 133 |
-
"reduceOptions": {
|
| 134 |
-
"calcs": [
|
| 135 |
-
"lastNotNull"
|
| 136 |
-
],
|
| 137 |
-
"fields": "",
|
| 138 |
-
"values": false
|
| 139 |
-
},
|
| 140 |
-
"textMode": "auto"
|
| 141 |
-
},
|
| 142 |
-
"pluginVersion": "12.3.1",
|
| 143 |
-
"targets": [
|
| 144 |
-
{
|
| 145 |
-
"editorMode": "code",
|
| 146 |
"expr": "scalar(avg(last_over_time(antiatropos_total_queue_backlog{mode=~\"live|simulated|hybrid|aws\"}[1m])))",
|
| 147 |
"legendFormat": "queue backlog (all modes)",
|
| 148 |
-
"range": true,
|
| 149 |
-
"refId": "A"
|
| 150 |
-
}
|
| 151 |
-
],
|
| 152 |
-
"title": "Queue Backlog (Norm)",
|
| 153 |
-
"type": "stat"
|
| 154 |
-
},
|
| 155 |
-
{
|
| 156 |
-
"datasource": {
|
| 157 |
-
"type": "prometheus",
|
| 158 |
-
"uid": "PBFA97CFB590B2093"
|
| 159 |
-
},
|
| 160 |
-
"fieldConfig": {
|
| 161 |
-
"defaults": {
|
| 162 |
-
"color": {
|
| 163 |
-
"mode": "thresholds"
|
| 164 |
-
},
|
| 165 |
-
"decimals": 3,
|
| 166 |
-
"mappings": [],
|
| 167 |
-
"thresholds": {
|
| 168 |
-
"mode": "absolute",
|
| 169 |
-
"steps": [
|
| 170 |
-
{
|
| 171 |
-
"color": "green",
|
| 172 |
-
"value": null
|
| 173 |
-
},
|
| 174 |
-
{
|
| 175 |
-
"color": "orange",
|
| 176 |
-
"value": 0.4
|
| 177 |
-
},
|
| 178 |
-
{
|
| 179 |
-
"color": "red",
|
| 180 |
-
"value": 0.8
|
| 181 |
-
}
|
| 182 |
-
]
|
| 183 |
-
},
|
| 184 |
-
"unit": "short"
|
| 185 |
-
},
|
| 186 |
-
"overrides": []
|
| 187 |
-
},
|
| 188 |
-
"gridPos": {
|
| 189 |
-
"h": 4,
|
| 190 |
-
"w": 6,
|
| 191 |
-
"x": 12,
|
| 192 |
-
"y": 0
|
| 193 |
-
},
|
| 194 |
-
"id": 3,
|
| 195 |
-
"options": {
|
| 196 |
-
"colorMode": "value",
|
| 197 |
-
"graphMode": "none",
|
| 198 |
-
"justifyMode": "auto",
|
| 199 |
-
"orientation": "auto",
|
| 200 |
-
"reduceOptions": {
|
| 201 |
-
"calcs": [
|
| 202 |
-
"lastNotNull"
|
| 203 |
-
],
|
| 204 |
-
"fields": "",
|
| 205 |
-
"values": false
|
| 206 |
-
},
|
| 207 |
-
"textMode": "auto"
|
| 208 |
-
},
|
| 209 |
-
"pluginVersion": "12.3.1",
|
| 210 |
-
"targets": [
|
| 211 |
-
{
|
| 212 |
-
"editorMode": "code",
|
| 213 |
"expr": "scalar(avg(last_over_time(antiatropos_average_latency_norm{mode=~\"live|simulated|hybrid|aws\"}[1m])))",
|
| 214 |
"legendFormat": "latency (all modes)",
|
| 215 |
-
"range": true,
|
| 216 |
-
"refId": "A"
|
| 217 |
-
}
|
| 218 |
-
],
|
| 219 |
-
"title": "Latency (Norm)",
|
| 220 |
-
"type": "stat"
|
| 221 |
-
},
|
| 222 |
-
{
|
| 223 |
-
"datasource": {
|
| 224 |
-
"type": "prometheus",
|
| 225 |
-
"uid": "PBFA97CFB590B2093"
|
| 226 |
-
},
|
| 227 |
-
"fieldConfig": {
|
| 228 |
-
"defaults": {
|
| 229 |
-
"color": {
|
| 230 |
-
"mode": "thresholds"
|
| 231 |
-
},
|
| 232 |
-
"decimals": 3,
|
| 233 |
-
"mappings": [],
|
| 234 |
-
"thresholds": {
|
| 235 |
-
"mode": "absolute",
|
| 236 |
-
"steps": [
|
| 237 |
-
{
|
| 238 |
-
"color": "green",
|
| 239 |
-
"value": null
|
| 240 |
-
},
|
| 241 |
-
{
|
| 242 |
-
"color": "orange",
|
| 243 |
-
"value": 100
|
| 244 |
-
},
|
| 245 |
-
{
|
| 246 |
-
"color": "red",
|
| 247 |
-
"value": 1000
|
| 248 |
-
}
|
| 249 |
-
]
|
| 250 |
-
},
|
| 251 |
-
"unit": "short"
|
| 252 |
-
},
|
| 253 |
-
"overrides": []
|
| 254 |
-
},
|
| 255 |
-
"gridPos": {
|
| 256 |
-
"h": 4,
|
| 257 |
-
"w": 6,
|
| 258 |
-
"x": 18,
|
| 259 |
-
"y": 0
|
| 260 |
-
},
|
| 261 |
-
"id": 4,
|
| 262 |
-
"options": {
|
| 263 |
-
"colorMode": "value",
|
| 264 |
-
"graphMode": "none",
|
| 265 |
-
"justifyMode": "auto",
|
| 266 |
-
"orientation": "auto",
|
| 267 |
-
"reduceOptions": {
|
| 268 |
-
"calcs": [
|
| 269 |
-
"lastNotNull"
|
| 270 |
-
],
|
| 271 |
-
"fields": "",
|
| 272 |
-
"values": false
|
| 273 |
-
},
|
| 274 |
-
"textMode": "auto"
|
| 275 |
-
},
|
| 276 |
-
"pluginVersion": "12.3.1",
|
| 277 |
-
"targets": [
|
| 278 |
-
{
|
| 279 |
-
"editorMode": "code",
|
| 280 |
"expr": "scalar(avg(last_over_time(antiatropos_lyapunov_energy{mode=~\"live|simulated|hybrid|aws\"}[1m])))",
|
| 281 |
"legendFormat": "lyapunov energy (all modes)",
|
| 282 |
-
"range": true,
|
| 283 |
-
"refId": "A"
|
| 284 |
-
}
|
| 285 |
-
],
|
| 286 |
-
"title": "Lyapunov Energy",
|
| 287 |
-
"type": "stat"
|
| 288 |
-
},
|
| 289 |
-
{
|
| 290 |
-
"datasource": {
|
| 291 |
-
"type": "prometheus",
|
| 292 |
-
"uid": "PBFA97CFB590B2093"
|
| 293 |
-
},
|
| 294 |
-
"fieldConfig": {
|
| 295 |
-
"defaults": {
|
| 296 |
-
"color": {
|
| 297 |
-
"mode": "palette-classic"
|
| 298 |
-
},
|
| 299 |
-
"custom": {
|
| 300 |
-
"axisBorderShow": false,
|
| 301 |
-
"axisCenteredZero": false,
|
| 302 |
-
"axisColorMode": "text",
|
| 303 |
-
"axisLabel": "",
|
| 304 |
-
"axisPlacement": "auto",
|
| 305 |
-
"barAlignment": 0,
|
| 306 |
-
"drawStyle": "line",
|
| 307 |
-
"fillOpacity": 10,
|
| 308 |
-
"gradientMode": "none",
|
| 309 |
-
"hideFrom": {
|
| 310 |
-
"legend": false,
|
| 311 |
-
"tooltip": false,
|
| 312 |
-
"viz": false
|
| 313 |
-
},
|
| 314 |
-
"insertNulls": false,
|
| 315 |
-
"lineInterpolation": "linear",
|
| 316 |
-
"lineWidth": 2,
|
| 317 |
-
"pointSize": 3,
|
| 318 |
-
"scaleDistribution": {
|
| 319 |
-
"type": "linear"
|
| 320 |
-
},
|
| 321 |
-
"showPoints": "auto",
|
| 322 |
-
"spanNulls": false,
|
| 323 |
-
"stacking": {
|
| 324 |
-
"group": "A",
|
| 325 |
-
"mode": "none"
|
| 326 |
-
},
|
| 327 |
-
"thresholdsStyle": {
|
| 328 |
-
"mode": "off"
|
| 329 |
-
}
|
| 330 |
-
},
|
| 331 |
-
"mappings": [],
|
| 332 |
-
"thresholds": {
|
| 333 |
-
"mode": "absolute",
|
| 334 |
-
"steps": [
|
| 335 |
-
{
|
| 336 |
-
"color": "green",
|
| 337 |
-
"value": null
|
| 338 |
-
},
|
| 339 |
-
{
|
| 340 |
-
"color": "red",
|
| 341 |
-
"value": 80
|
| 342 |
-
}
|
| 343 |
-
]
|
| 344 |
-
},
|
| 345 |
-
"unit": "short"
|
| 346 |
-
},
|
| 347 |
-
"overrides": []
|
| 348 |
-
},
|
| 349 |
-
"gridPos": {
|
| 350 |
-
"h": 8,
|
| 351 |
-
"w": 12,
|
| 352 |
-
"x": 0,
|
| 353 |
-
"y": 4
|
| 354 |
-
},
|
| 355 |
-
"id": 5,
|
| 356 |
-
"options": {
|
| 357 |
-
"legend": {
|
| 358 |
-
"calcs": [],
|
| 359 |
-
"displayMode": "list",
|
| 360 |
-
"placement": "bottom",
|
| 361 |
-
"showLegend": true
|
| 362 |
-
},
|
| 363 |
-
"tooltip": {
|
| 364 |
-
"mode": "single",
|
| 365 |
-
"sort": "none"
|
| 366 |
-
}
|
| 367 |
-
},
|
| 368 |
-
"pluginVersion": "12.3.1",
|
| 369 |
-
"targets": [
|
| 370 |
-
{
|
| 371 |
-
"editorMode": "code",
|
| 372 |
"expr": "antiatropos_reward{mode=~\"live|simulated|hybrid|aws\"}",
|
| 373 |
"legendFormat": "reward {{task_id}} ({{mode}})",
|
| 374 |
-
"range": true,
|
| 375 |
-
"refId": "A"
|
| 376 |
-
},
|
| 377 |
-
{
|
| 378 |
-
"editorMode": "code",
|
| 379 |
"expr": "antiatropos_lyapunov_energy{mode=~\"live|simulated|hybrid|aws\"}",
|
| 380 |
"legendFormat": "lyapunov {{task_id}} ({{mode}})",
|
| 381 |
-
"range": true,
|
| 382 |
-
"refId": "B"
|
| 383 |
-
}
|
| 384 |
-
],
|
| 385 |
-
"title": "Reward and Lyapunov Trend",
|
| 386 |
-
"type": "timeseries"
|
| 387 |
-
},
|
| 388 |
-
{
|
| 389 |
-
"datasource": {
|
| 390 |
-
"type": "prometheus",
|
| 391 |
-
"uid": "PBFA97CFB590B2093"
|
| 392 |
-
},
|
| 393 |
-
"fieldConfig": {
|
| 394 |
-
"defaults": {
|
| 395 |
-
"color": {
|
| 396 |
-
"mode": "palette-classic"
|
| 397 |
-
},
|
| 398 |
-
"custom": {
|
| 399 |
-
"axisBorderShow": false,
|
| 400 |
-
"axisCenteredZero": false,
|
| 401 |
-
"axisColorMode": "text",
|
| 402 |
-
"axisLabel": "",
|
| 403 |
-
"axisPlacement": "auto",
|
| 404 |
-
"barAlignment": 0,
|
| 405 |
-
"drawStyle": "line",
|
| 406 |
-
"fillOpacity": 10,
|
| 407 |
-
"gradientMode": "none",
|
| 408 |
-
"hideFrom": {
|
| 409 |
-
"legend": false,
|
| 410 |
-
"tooltip": false,
|
| 411 |
-
"viz": false
|
| 412 |
-
},
|
| 413 |
-
"insertNulls": false,
|
| 414 |
-
"lineInterpolation": "linear",
|
| 415 |
-
"lineWidth": 2,
|
| 416 |
-
"pointSize": 3,
|
| 417 |
-
"scaleDistribution": {
|
| 418 |
-
"type": "linear"
|
| 419 |
-
},
|
| 420 |
-
"showPoints": "auto",
|
| 421 |
-
"spanNulls": false,
|
| 422 |
-
"stacking": {
|
| 423 |
-
"group": "A",
|
| 424 |
-
"mode": "none"
|
| 425 |
-
},
|
| 426 |
-
"thresholdsStyle": {
|
| 427 |
-
"mode": "off"
|
| 428 |
-
}
|
| 429 |
-
},
|
| 430 |
-
"mappings": [],
|
| 431 |
-
"thresholds": {
|
| 432 |
-
"mode": "absolute",
|
| 433 |
-
"steps": [
|
| 434 |
-
{
|
| 435 |
-
"color": "green",
|
| 436 |
-
"value": null
|
| 437 |
-
},
|
| 438 |
-
{
|
| 439 |
-
"color": "red",
|
| 440 |
-
"value": 80
|
| 441 |
-
}
|
| 442 |
-
]
|
| 443 |
-
},
|
| 444 |
-
"unit": "short"
|
| 445 |
-
},
|
| 446 |
-
"overrides": []
|
| 447 |
-
},
|
| 448 |
-
"gridPos": {
|
| 449 |
-
"h": 8,
|
| 450 |
-
"w": 12,
|
| 451 |
-
"x": 12,
|
| 452 |
-
"y": 4
|
| 453 |
-
},
|
| 454 |
-
"id": 6,
|
| 455 |
-
"options": {
|
| 456 |
-
"legend": {
|
| 457 |
-
"calcs": [],
|
| 458 |
-
"displayMode": "list",
|
| 459 |
-
"placement": "bottom",
|
| 460 |
-
"showLegend": true
|
| 461 |
-
},
|
| 462 |
-
"tooltip": {
|
| 463 |
-
"mode": "single",
|
| 464 |
-
"sort": "none"
|
| 465 |
-
}
|
| 466 |
-
},
|
| 467 |
-
"pluginVersion": "12.3.1",
|
| 468 |
-
"targets": [
|
| 469 |
-
{
|
| 470 |
-
"editorMode": "code",
|
| 471 |
"expr": "antiatropos_total_queue_backlog{mode=~\"live|simulated|hybrid|aws\"}",
|
| 472 |
"legendFormat": "queue {{task_id}} ({{mode}})",
|
| 473 |
-
"range": true,
|
| 474 |
-
"refId": "A"
|
| 475 |
-
},
|
| 476 |
-
{
|
| 477 |
-
"editorMode": "code",
|
| 478 |
"expr": "antiatropos_average_latency_norm{mode=~\"live|simulated|hybrid|aws\"}",
|
| 479 |
"legendFormat": "latency {{task_id}} ({{mode}})",
|
| 480 |
-
"range": true,
|
| 481 |
-
"refId": "B"
|
| 482 |
-
}
|
| 483 |
-
],
|
| 484 |
-
"title": "Queue and Latency Trend",
|
| 485 |
-
"type": "timeseries"
|
| 486 |
-
},
|
| 487 |
-
{
|
| 488 |
-
"datasource": {
|
| 489 |
-
"type": "prometheus",
|
| 490 |
-
"uid": "PBFA97CFB590B2093"
|
| 491 |
-
},
|
| 492 |
-
"fieldConfig": {
|
| 493 |
-
"defaults": {
|
| 494 |
-
"color": {
|
| 495 |
-
"mode": "palette-classic"
|
| 496 |
-
},
|
| 497 |
-
"mappings": [],
|
| 498 |
-
"thresholds": {
|
| 499 |
-
"mode": "absolute",
|
| 500 |
-
"steps": [
|
| 501 |
-
{
|
| 502 |
-
"color": "green",
|
| 503 |
-
"value": null
|
| 504 |
-
},
|
| 505 |
-
{
|
| 506 |
-
"color": "red",
|
| 507 |
-
"value": 80
|
| 508 |
-
}
|
| 509 |
-
]
|
| 510 |
-
},
|
| 511 |
-
"unit": "short"
|
| 512 |
-
},
|
| 513 |
-
"overrides": []
|
| 514 |
-
},
|
| 515 |
-
"gridPos": {
|
| 516 |
-
"h": 8,
|
| 517 |
-
"w": 12,
|
| 518 |
-
"x": 0,
|
| 519 |
-
"y": 12
|
| 520 |
-
},
|
| 521 |
-
"id": 7,
|
| 522 |
-
"options": {
|
| 523 |
-
"legend": {
|
| 524 |
-
"calcs": [],
|
| 525 |
-
"displayMode": "list",
|
| 526 |
-
"placement": "bottom",
|
| 527 |
-
"showLegend": true
|
| 528 |
-
},
|
| 529 |
-
"tooltip": {
|
| 530 |
-
"mode": "single",
|
| 531 |
-
"sort": "none"
|
| 532 |
-
}
|
| 533 |
-
},
|
| 534 |
-
"pluginVersion": "12.3.1",
|
| 535 |
-
"targets": [
|
| 536 |
-
{
|
| 537 |
-
"editorMode": "code",
|
| 538 |
"expr": "sum by (task_id, mode) (rate(antiatropos_steps_total{mode=~\"live|simulated|hybrid|aws\"}[1m]))",
|
| 539 |
"legendFormat": "steps/sec {{task_id}} ({{mode}})",
|
| 540 |
-
"range": true,
|
| 541 |
-
"refId": "A"
|
| 542 |
-
},
|
| 543 |
-
{
|
| 544 |
-
"editorMode": "code",
|
| 545 |
"expr": "sum by (task_id, mode, action_type) (rate(antiatropos_actions_total{mode=~\"live|simulated|hybrid|aws\"}[1m]))",
|
| 546 |
"legendFormat": "actions/sec {{action_type}} ({{task_id}}, {{mode}})",
|
| 547 |
-
"range": true,
|
| 548 |
-
"refId": "B"
|
| 549 |
-
}
|
| 550 |
-
],
|
| 551 |
-
"title": "Action Throughput",
|
| 552 |
-
"type": "timeseries"
|
| 553 |
-
},
|
| 554 |
-
{
|
| 555 |
-
"datasource": {
|
| 556 |
-
"type": "prometheus",
|
| 557 |
-
"uid": "PBFA97CFB590B2093"
|
| 558 |
-
},
|
| 559 |
-
"fieldConfig": {
|
| 560 |
-
"defaults": {
|
| 561 |
-
"color": {
|
| 562 |
-
"mode": "palette-classic"
|
| 563 |
-
},
|
| 564 |
-
"mappings": [],
|
| 565 |
-
"thresholds": {
|
| 566 |
-
"mode": "absolute",
|
| 567 |
-
"steps": [
|
| 568 |
-
{
|
| 569 |
-
"color": "green",
|
| 570 |
-
"value": null
|
| 571 |
-
},
|
| 572 |
-
{
|
| 573 |
-
"color": "red",
|
| 574 |
-
"value": 1
|
| 575 |
-
}
|
| 576 |
-
]
|
| 577 |
-
},
|
| 578 |
-
"unit": "short"
|
| 579 |
-
},
|
| 580 |
-
"overrides": []
|
| 581 |
-
},
|
| 582 |
-
"gridPos": {
|
| 583 |
-
"h": 8,
|
| 584 |
-
"w": 12,
|
| 585 |
-
"x": 12,
|
| 586 |
-
"y": 12
|
| 587 |
-
},
|
| 588 |
-
"id": 8,
|
| 589 |
-
"options": {
|
| 590 |
-
"legend": {
|
| 591 |
-
"calcs": [],
|
| 592 |
-
"displayMode": "list",
|
| 593 |
-
"placement": "bottom",
|
| 594 |
-
"showLegend": true
|
| 595 |
-
},
|
| 596 |
-
"tooltip": {
|
| 597 |
-
"mode": "single",
|
| 598 |
-
"sort": "none"
|
| 599 |
-
}
|
| 600 |
-
},
|
| 601 |
-
"pluginVersion": "12.3.1",
|
| 602 |
-
"targets": [
|
| 603 |
-
{
|
| 604 |
-
"editorMode": "code",
|
| 605 |
"expr": "sum by (mode, error_code) (rate(antiatropos_executor_errors_total{mode=~\"live|simulated|hybrid|aws\"}[5m]))",
|
| 606 |
"legendFormat": "executor errors {{error_code}} ({{mode}})",
|
| 607 |
-
"range": true,
|
| 608 |
-
"refId": "A"
|
| 609 |
-
},
|
| 610 |
-
{
|
| 611 |
-
"editorMode": "code",
|
| 612 |
"expr": "histogram_quantile(0.95, sum(rate(antiatropos_executor_latency_ms_bucket{mode=~\"live|simulated|hybrid|aws\"}[5m])) by (le, mode))",
|
| 613 |
-
"legendFormat": "p95 executor latency {{mode}}",
|
| 614 |
-
"range": true,
|
| 615 |
-
"refId": "B"
|
| 616 |
-
}
|
| 617 |
-
],
|
| 618 |
-
"title": "Executor Reliability",
|
| 619 |
-
"type": "timeseries"
|
| 620 |
-
}
|
| 621 |
-
],
|
| 622 |
-
"refresh": "5s",
|
| 623 |
-
"schemaVersion": 41,
|
| 624 |
-
"style": "dark",
|
| 625 |
-
"tags": [
|
| 626 |
-
"antiatropos",
|
| 627 |
-
"sre"
|
| 628 |
-
],
|
| 629 |
-
"templating": {
|
| 630 |
-
"list": []
|
| 631 |
-
},
|
| 632 |
-
"time": {
|
| 633 |
-
"from": "now-30m",
|
| 634 |
-
"to": "now"
|
| 635 |
-
},
|
| 636 |
-
"timepicker": {},
|
| 637 |
-
"timezone": "browser",
|
| 638 |
-
"title": "AntiAtropos Overview",
|
| 639 |
-
"uid": "antiatropos-overview",
|
| 640 |
"version": 2,
|
| 641 |
-
"weekStart": ""
|
| 642 |
-
}
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"annotations": {
|
| 3 |
+
"list": [
|
| 4 |
+
{
|
| 5 |
+
"builtIn": 1,
|
| 6 |
+
"datasource": {
|
| 7 |
+
"type": "grafana",
|
| 8 |
+
"uid": "-- Grafana --"
|
| 9 |
+
},
|
| 10 |
+
"enable": true,
|
| 11 |
+
"hide": true,
|
| 12 |
+
"iconColor": "rgba(0, 211, 255, 1)",
|
| 13 |
+
"name": "Annotations & Alerts",
|
| 14 |
+
"type": "dashboard"
|
| 15 |
+
}
|
| 16 |
+
]
|
| 17 |
+
},
|
| 18 |
+
"editable": true,
|
| 19 |
+
"fiscalYearStartMonth": 0,
|
| 20 |
+
"graphTooltip": 1,
|
| 21 |
+
"id": null,
|
| 22 |
+
"links": [],
|
| 23 |
+
"liveNow": false,
|
| 24 |
+
"panels": [
|
| 25 |
+
{
|
| 26 |
+
"datasource": {
|
| 27 |
+
"type": "prometheus",
|
| 28 |
+
"uid": "PBFA97CFB590B2093"
|
| 29 |
+
},
|
| 30 |
+
"fieldConfig": {
|
| 31 |
+
"defaults": {
|
| 32 |
+
"color": {
|
| 33 |
+
"mode": "thresholds"
|
| 34 |
+
},
|
| 35 |
+
"decimals": 3,
|
| 36 |
+
"mappings": [],
|
| 37 |
+
"thresholds": {
|
| 38 |
+
"mode": "absolute",
|
| 39 |
+
"steps": [
|
| 40 |
+
{
|
| 41 |
+
"color": "green",
|
| 42 |
+
"value": null
|
| 43 |
+
},
|
| 44 |
+
{
|
| 45 |
+
"color": "red",
|
| 46 |
+
"value": 0
|
| 47 |
+
}
|
| 48 |
+
]
|
| 49 |
+
},
|
| 50 |
+
"unit": "short"
|
| 51 |
+
},
|
| 52 |
+
"overrides": []
|
| 53 |
+
},
|
| 54 |
+
"gridPos": {
|
| 55 |
+
"h": 4,
|
| 56 |
+
"w": 6,
|
| 57 |
+
"x": 0,
|
| 58 |
+
"y": 0
|
| 59 |
+
},
|
| 60 |
+
"id": 1,
|
| 61 |
+
"options": {
|
| 62 |
+
"colorMode": "value",
|
| 63 |
+
"graphMode": "none",
|
| 64 |
+
"justifyMode": "auto",
|
| 65 |
+
"orientation": "auto",
|
| 66 |
+
"reduceOptions": {
|
| 67 |
+
"calcs": [
|
| 68 |
+
"lastNotNull"
|
| 69 |
+
],
|
| 70 |
+
"fields": "",
|
| 71 |
+
"values": false
|
| 72 |
+
},
|
| 73 |
+
"textMode": "auto"
|
| 74 |
+
},
|
| 75 |
+
"pluginVersion": "12.3.1",
|
| 76 |
+
"targets": [
|
| 77 |
+
{
|
| 78 |
+
"editorMode": "code",
|
| 79 |
"expr": "scalar(avg(last_over_time(antiatropos_reward{mode=~\"live|simulated|hybrid|aws\"}[1m])))",
|
| 80 |
"legendFormat": "reward (all modes)",
|
| 81 |
+
"range": true,
|
| 82 |
+
"refId": "A"
|
| 83 |
+
}
|
| 84 |
+
],
|
| 85 |
+
"title": "Latest Reward",
|
| 86 |
+
"type": "stat"
|
| 87 |
+
},
|
| 88 |
+
{
|
| 89 |
+
"datasource": {
|
| 90 |
+
"type": "prometheus",
|
| 91 |
+
"uid": "PBFA97CFB590B2093"
|
| 92 |
+
},
|
| 93 |
+
"fieldConfig": {
|
| 94 |
+
"defaults": {
|
| 95 |
+
"color": {
|
| 96 |
+
"mode": "thresholds"
|
| 97 |
+
},
|
| 98 |
+
"decimals": 3,
|
| 99 |
+
"mappings": [],
|
| 100 |
+
"thresholds": {
|
| 101 |
+
"mode": "absolute",
|
| 102 |
+
"steps": [
|
| 103 |
+
{
|
| 104 |
+
"color": "green",
|
| 105 |
+
"value": null
|
| 106 |
+
},
|
| 107 |
+
{
|
| 108 |
+
"color": "orange",
|
| 109 |
+
"value": 0.4
|
| 110 |
+
},
|
| 111 |
+
{
|
| 112 |
+
"color": "red",
|
| 113 |
+
"value": 0.8
|
| 114 |
+
}
|
| 115 |
+
]
|
| 116 |
+
},
|
| 117 |
+
"unit": "short"
|
| 118 |
+
},
|
| 119 |
+
"overrides": []
|
| 120 |
+
},
|
| 121 |
+
"gridPos": {
|
| 122 |
+
"h": 4,
|
| 123 |
+
"w": 6,
|
| 124 |
+
"x": 6,
|
| 125 |
+
"y": 0
|
| 126 |
+
},
|
| 127 |
+
"id": 2,
|
| 128 |
+
"options": {
|
| 129 |
+
"colorMode": "value",
|
| 130 |
+
"graphMode": "none",
|
| 131 |
+
"justifyMode": "auto",
|
| 132 |
+
"orientation": "auto",
|
| 133 |
+
"reduceOptions": {
|
| 134 |
+
"calcs": [
|
| 135 |
+
"lastNotNull"
|
| 136 |
+
],
|
| 137 |
+
"fields": "",
|
| 138 |
+
"values": false
|
| 139 |
+
},
|
| 140 |
+
"textMode": "auto"
|
| 141 |
+
},
|
| 142 |
+
"pluginVersion": "12.3.1",
|
| 143 |
+
"targets": [
|
| 144 |
+
{
|
| 145 |
+
"editorMode": "code",
|
| 146 |
"expr": "scalar(avg(last_over_time(antiatropos_total_queue_backlog{mode=~\"live|simulated|hybrid|aws\"}[1m])))",
|
| 147 |
"legendFormat": "queue backlog (all modes)",
|
| 148 |
+
"range": true,
|
| 149 |
+
"refId": "A"
|
| 150 |
+
}
|
| 151 |
+
],
|
| 152 |
+
"title": "Queue Backlog (Norm)",
|
| 153 |
+
"type": "stat"
|
| 154 |
+
},
|
| 155 |
+
{
|
| 156 |
+
"datasource": {
|
| 157 |
+
"type": "prometheus",
|
| 158 |
+
"uid": "PBFA97CFB590B2093"
|
| 159 |
+
},
|
| 160 |
+
"fieldConfig": {
|
| 161 |
+
"defaults": {
|
| 162 |
+
"color": {
|
| 163 |
+
"mode": "thresholds"
|
| 164 |
+
},
|
| 165 |
+
"decimals": 3,
|
| 166 |
+
"mappings": [],
|
| 167 |
+
"thresholds": {
|
| 168 |
+
"mode": "absolute",
|
| 169 |
+
"steps": [
|
| 170 |
+
{
|
| 171 |
+
"color": "green",
|
| 172 |
+
"value": null
|
| 173 |
+
},
|
| 174 |
+
{
|
| 175 |
+
"color": "orange",
|
| 176 |
+
"value": 0.4
|
| 177 |
+
},
|
| 178 |
+
{
|
| 179 |
+
"color": "red",
|
| 180 |
+
"value": 0.8
|
| 181 |
+
}
|
| 182 |
+
]
|
| 183 |
+
},
|
| 184 |
+
"unit": "short"
|
| 185 |
+
},
|
| 186 |
+
"overrides": []
|
| 187 |
+
},
|
| 188 |
+
"gridPos": {
|
| 189 |
+
"h": 4,
|
| 190 |
+
"w": 6,
|
| 191 |
+
"x": 12,
|
| 192 |
+
"y": 0
|
| 193 |
+
},
|
| 194 |
+
"id": 3,
|
| 195 |
+
"options": {
|
| 196 |
+
"colorMode": "value",
|
| 197 |
+
"graphMode": "none",
|
| 198 |
+
"justifyMode": "auto",
|
| 199 |
+
"orientation": "auto",
|
| 200 |
+
"reduceOptions": {
|
| 201 |
+
"calcs": [
|
| 202 |
+
"lastNotNull"
|
| 203 |
+
],
|
| 204 |
+
"fields": "",
|
| 205 |
+
"values": false
|
| 206 |
+
},
|
| 207 |
+
"textMode": "auto"
|
| 208 |
+
},
|
| 209 |
+
"pluginVersion": "12.3.1",
|
| 210 |
+
"targets": [
|
| 211 |
+
{
|
| 212 |
+
"editorMode": "code",
|
| 213 |
"expr": "scalar(avg(last_over_time(antiatropos_average_latency_norm{mode=~\"live|simulated|hybrid|aws\"}[1m])))",
|
| 214 |
"legendFormat": "latency (all modes)",
|
| 215 |
+
"range": true,
|
| 216 |
+
"refId": "A"
|
| 217 |
+
}
|
| 218 |
+
],
|
| 219 |
+
"title": "Latency (Norm)",
|
| 220 |
+
"type": "stat"
|
| 221 |
+
},
|
| 222 |
+
{
|
| 223 |
+
"datasource": {
|
| 224 |
+
"type": "prometheus",
|
| 225 |
+
"uid": "PBFA97CFB590B2093"
|
| 226 |
+
},
|
| 227 |
+
"fieldConfig": {
|
| 228 |
+
"defaults": {
|
| 229 |
+
"color": {
|
| 230 |
+
"mode": "thresholds"
|
| 231 |
+
},
|
| 232 |
+
"decimals": 3,
|
| 233 |
+
"mappings": [],
|
| 234 |
+
"thresholds": {
|
| 235 |
+
"mode": "absolute",
|
| 236 |
+
"steps": [
|
| 237 |
+
{
|
| 238 |
+
"color": "green",
|
| 239 |
+
"value": null
|
| 240 |
+
},
|
| 241 |
+
{
|
| 242 |
+
"color": "orange",
|
| 243 |
+
"value": 100
|
| 244 |
+
},
|
| 245 |
+
{
|
| 246 |
+
"color": "red",
|
| 247 |
+
"value": 1000
|
| 248 |
+
}
|
| 249 |
+
]
|
| 250 |
+
},
|
| 251 |
+
"unit": "short"
|
| 252 |
+
},
|
| 253 |
+
"overrides": []
|
| 254 |
+
},
|
| 255 |
+
"gridPos": {
|
| 256 |
+
"h": 4,
|
| 257 |
+
"w": 6,
|
| 258 |
+
"x": 18,
|
| 259 |
+
"y": 0
|
| 260 |
+
},
|
| 261 |
+
"id": 4,
|
| 262 |
+
"options": {
|
| 263 |
+
"colorMode": "value",
|
| 264 |
+
"graphMode": "none",
|
| 265 |
+
"justifyMode": "auto",
|
| 266 |
+
"orientation": "auto",
|
| 267 |
+
"reduceOptions": {
|
| 268 |
+
"calcs": [
|
| 269 |
+
"lastNotNull"
|
| 270 |
+
],
|
| 271 |
+
"fields": "",
|
| 272 |
+
"values": false
|
| 273 |
+
},
|
| 274 |
+
"textMode": "auto"
|
| 275 |
+
},
|
| 276 |
+
"pluginVersion": "12.3.1",
|
| 277 |
+
"targets": [
|
| 278 |
+
{
|
| 279 |
+
"editorMode": "code",
|
| 280 |
"expr": "scalar(avg(last_over_time(antiatropos_lyapunov_energy{mode=~\"live|simulated|hybrid|aws\"}[1m])))",
|
| 281 |
"legendFormat": "lyapunov energy (all modes)",
|
| 282 |
+
"range": true,
|
| 283 |
+
"refId": "A"
|
| 284 |
+
}
|
| 285 |
+
],
|
| 286 |
+
"title": "Lyapunov Energy",
|
| 287 |
+
"type": "stat"
|
| 288 |
+
},
|
| 289 |
+
{
|
| 290 |
+
"datasource": {
|
| 291 |
+
"type": "prometheus",
|
| 292 |
+
"uid": "PBFA97CFB590B2093"
|
| 293 |
+
},
|
| 294 |
+
"fieldConfig": {
|
| 295 |
+
"defaults": {
|
| 296 |
+
"color": {
|
| 297 |
+
"mode": "palette-classic"
|
| 298 |
+
},
|
| 299 |
+
"custom": {
|
| 300 |
+
"axisBorderShow": false,
|
| 301 |
+
"axisCenteredZero": false,
|
| 302 |
+
"axisColorMode": "text",
|
| 303 |
+
"axisLabel": "",
|
| 304 |
+
"axisPlacement": "auto",
|
| 305 |
+
"barAlignment": 0,
|
| 306 |
+
"drawStyle": "line",
|
| 307 |
+
"fillOpacity": 10,
|
| 308 |
+
"gradientMode": "none",
|
| 309 |
+
"hideFrom": {
|
| 310 |
+
"legend": false,
|
| 311 |
+
"tooltip": false,
|
| 312 |
+
"viz": false
|
| 313 |
+
},
|
| 314 |
+
"insertNulls": false,
|
| 315 |
+
"lineInterpolation": "linear",
|
| 316 |
+
"lineWidth": 2,
|
| 317 |
+
"pointSize": 3,
|
| 318 |
+
"scaleDistribution": {
|
| 319 |
+
"type": "linear"
|
| 320 |
+
},
|
| 321 |
+
"showPoints": "auto",
|
| 322 |
+
"spanNulls": false,
|
| 323 |
+
"stacking": {
|
| 324 |
+
"group": "A",
|
| 325 |
+
"mode": "none"
|
| 326 |
+
},
|
| 327 |
+
"thresholdsStyle": {
|
| 328 |
+
"mode": "off"
|
| 329 |
+
}
|
| 330 |
+
},
|
| 331 |
+
"mappings": [],
|
| 332 |
+
"thresholds": {
|
| 333 |
+
"mode": "absolute",
|
| 334 |
+
"steps": [
|
| 335 |
+
{
|
| 336 |
+
"color": "green",
|
| 337 |
+
"value": null
|
| 338 |
+
},
|
| 339 |
+
{
|
| 340 |
+
"color": "red",
|
| 341 |
+
"value": 80
|
| 342 |
+
}
|
| 343 |
+
]
|
| 344 |
+
},
|
| 345 |
+
"unit": "short"
|
| 346 |
+
},
|
| 347 |
+
"overrides": []
|
| 348 |
+
},
|
| 349 |
+
"gridPos": {
|
| 350 |
+
"h": 8,
|
| 351 |
+
"w": 12,
|
| 352 |
+
"x": 0,
|
| 353 |
+
"y": 4
|
| 354 |
+
},
|
| 355 |
+
"id": 5,
|
| 356 |
+
"options": {
|
| 357 |
+
"legend": {
|
| 358 |
+
"calcs": [],
|
| 359 |
+
"displayMode": "list",
|
| 360 |
+
"placement": "bottom",
|
| 361 |
+
"showLegend": true
|
| 362 |
+
},
|
| 363 |
+
"tooltip": {
|
| 364 |
+
"mode": "single",
|
| 365 |
+
"sort": "none"
|
| 366 |
+
}
|
| 367 |
+
},
|
| 368 |
+
"pluginVersion": "12.3.1",
|
| 369 |
+
"targets": [
|
| 370 |
+
{
|
| 371 |
+
"editorMode": "code",
|
| 372 |
"expr": "antiatropos_reward{mode=~\"live|simulated|hybrid|aws\"}",
|
| 373 |
"legendFormat": "reward {{task_id}} ({{mode}})",
|
| 374 |
+
"range": true,
|
| 375 |
+
"refId": "A"
|
| 376 |
+
},
|
| 377 |
+
{
|
| 378 |
+
"editorMode": "code",
|
| 379 |
"expr": "antiatropos_lyapunov_energy{mode=~\"live|simulated|hybrid|aws\"}",
|
| 380 |
"legendFormat": "lyapunov {{task_id}} ({{mode}})",
|
| 381 |
+
"range": true,
|
| 382 |
+
"refId": "B"
|
| 383 |
+
}
|
| 384 |
+
],
|
| 385 |
+
"title": "Reward and Lyapunov Trend",
|
| 386 |
+
"type": "timeseries"
|
| 387 |
+
},
|
| 388 |
+
{
|
| 389 |
+
"datasource": {
|
| 390 |
+
"type": "prometheus",
|
| 391 |
+
"uid": "PBFA97CFB590B2093"
|
| 392 |
+
},
|
| 393 |
+
"fieldConfig": {
|
| 394 |
+
"defaults": {
|
| 395 |
+
"color": {
|
| 396 |
+
"mode": "palette-classic"
|
| 397 |
+
},
|
| 398 |
+
"custom": {
|
| 399 |
+
"axisBorderShow": false,
|
| 400 |
+
"axisCenteredZero": false,
|
| 401 |
+
"axisColorMode": "text",
|
| 402 |
+
"axisLabel": "",
|
| 403 |
+
"axisPlacement": "auto",
|
| 404 |
+
"barAlignment": 0,
|
| 405 |
+
"drawStyle": "line",
|
| 406 |
+
"fillOpacity": 10,
|
| 407 |
+
"gradientMode": "none",
|
| 408 |
+
"hideFrom": {
|
| 409 |
+
"legend": false,
|
| 410 |
+
"tooltip": false,
|
| 411 |
+
"viz": false
|
| 412 |
+
},
|
| 413 |
+
"insertNulls": false,
|
| 414 |
+
"lineInterpolation": "linear",
|
| 415 |
+
"lineWidth": 2,
|
| 416 |
+
"pointSize": 3,
|
| 417 |
+
"scaleDistribution": {
|
| 418 |
+
"type": "linear"
|
| 419 |
+
},
|
| 420 |
+
"showPoints": "auto",
|
| 421 |
+
"spanNulls": false,
|
| 422 |
+
"stacking": {
|
| 423 |
+
"group": "A",
|
| 424 |
+
"mode": "none"
|
| 425 |
+
},
|
| 426 |
+
"thresholdsStyle": {
|
| 427 |
+
"mode": "off"
|
| 428 |
+
}
|
| 429 |
+
},
|
| 430 |
+
"mappings": [],
|
| 431 |
+
"thresholds": {
|
| 432 |
+
"mode": "absolute",
|
| 433 |
+
"steps": [
|
| 434 |
+
{
|
| 435 |
+
"color": "green",
|
| 436 |
+
"value": null
|
| 437 |
+
},
|
| 438 |
+
{
|
| 439 |
+
"color": "red",
|
| 440 |
+
"value": 80
|
| 441 |
+
}
|
| 442 |
+
]
|
| 443 |
+
},
|
| 444 |
+
"unit": "short"
|
| 445 |
+
},
|
| 446 |
+
"overrides": []
|
| 447 |
+
},
|
| 448 |
+
"gridPos": {
|
| 449 |
+
"h": 8,
|
| 450 |
+
"w": 12,
|
| 451 |
+
"x": 12,
|
| 452 |
+
"y": 4
|
| 453 |
+
},
|
| 454 |
+
"id": 6,
|
| 455 |
+
"options": {
|
| 456 |
+
"legend": {
|
| 457 |
+
"calcs": [],
|
| 458 |
+
"displayMode": "list",
|
| 459 |
+
"placement": "bottom",
|
| 460 |
+
"showLegend": true
|
| 461 |
+
},
|
| 462 |
+
"tooltip": {
|
| 463 |
+
"mode": "single",
|
| 464 |
+
"sort": "none"
|
| 465 |
+
}
|
| 466 |
+
},
|
| 467 |
+
"pluginVersion": "12.3.1",
|
| 468 |
+
"targets": [
|
| 469 |
+
{
|
| 470 |
+
"editorMode": "code",
|
| 471 |
"expr": "antiatropos_total_queue_backlog{mode=~\"live|simulated|hybrid|aws\"}",
|
| 472 |
"legendFormat": "queue {{task_id}} ({{mode}})",
|
| 473 |
+
"range": true,
|
| 474 |
+
"refId": "A"
|
| 475 |
+
},
|
| 476 |
+
{
|
| 477 |
+
"editorMode": "code",
|
| 478 |
"expr": "antiatropos_average_latency_norm{mode=~\"live|simulated|hybrid|aws\"}",
|
| 479 |
"legendFormat": "latency {{task_id}} ({{mode}})",
|
| 480 |
+
"range": true,
|
| 481 |
+
"refId": "B"
|
| 482 |
+
}
|
| 483 |
+
],
|
| 484 |
+
"title": "Queue and Latency Trend",
|
| 485 |
+
"type": "timeseries"
|
| 486 |
+
},
|
| 487 |
+
{
|
| 488 |
+
"datasource": {
|
| 489 |
+
"type": "prometheus",
|
| 490 |
+
"uid": "PBFA97CFB590B2093"
|
| 491 |
+
},
|
| 492 |
+
"fieldConfig": {
|
| 493 |
+
"defaults": {
|
| 494 |
+
"color": {
|
| 495 |
+
"mode": "palette-classic"
|
| 496 |
+
},
|
| 497 |
+
"mappings": [],
|
| 498 |
+
"thresholds": {
|
| 499 |
+
"mode": "absolute",
|
| 500 |
+
"steps": [
|
| 501 |
+
{
|
| 502 |
+
"color": "green",
|
| 503 |
+
"value": null
|
| 504 |
+
},
|
| 505 |
+
{
|
| 506 |
+
"color": "red",
|
| 507 |
+
"value": 80
|
| 508 |
+
}
|
| 509 |
+
]
|
| 510 |
+
},
|
| 511 |
+
"unit": "short"
|
| 512 |
+
},
|
| 513 |
+
"overrides": []
|
| 514 |
+
},
|
| 515 |
+
"gridPos": {
|
| 516 |
+
"h": 8,
|
| 517 |
+
"w": 12,
|
| 518 |
+
"x": 0,
|
| 519 |
+
"y": 12
|
| 520 |
+
},
|
| 521 |
+
"id": 7,
|
| 522 |
+
"options": {
|
| 523 |
+
"legend": {
|
| 524 |
+
"calcs": [],
|
| 525 |
+
"displayMode": "list",
|
| 526 |
+
"placement": "bottom",
|
| 527 |
+
"showLegend": true
|
| 528 |
+
},
|
| 529 |
+
"tooltip": {
|
| 530 |
+
"mode": "single",
|
| 531 |
+
"sort": "none"
|
| 532 |
+
}
|
| 533 |
+
},
|
| 534 |
+
"pluginVersion": "12.3.1",
|
| 535 |
+
"targets": [
|
| 536 |
+
{
|
| 537 |
+
"editorMode": "code",
|
| 538 |
"expr": "sum by (task_id, mode) (rate(antiatropos_steps_total{mode=~\"live|simulated|hybrid|aws\"}[1m]))",
|
| 539 |
"legendFormat": "steps/sec {{task_id}} ({{mode}})",
|
| 540 |
+
"range": true,
|
| 541 |
+
"refId": "A"
|
| 542 |
+
},
|
| 543 |
+
{
|
| 544 |
+
"editorMode": "code",
|
| 545 |
"expr": "sum by (task_id, mode, action_type) (rate(antiatropos_actions_total{mode=~\"live|simulated|hybrid|aws\"}[1m]))",
|
| 546 |
"legendFormat": "actions/sec {{action_type}} ({{task_id}}, {{mode}})",
|
| 547 |
+
"range": true,
|
| 548 |
+
"refId": "B"
|
| 549 |
+
}
|
| 550 |
+
],
|
| 551 |
+
"title": "Action Throughput",
|
| 552 |
+
"type": "timeseries"
|
| 553 |
+
},
|
| 554 |
+
{
|
| 555 |
+
"datasource": {
|
| 556 |
+
"type": "prometheus",
|
| 557 |
+
"uid": "PBFA97CFB590B2093"
|
| 558 |
+
},
|
| 559 |
+
"fieldConfig": {
|
| 560 |
+
"defaults": {
|
| 561 |
+
"color": {
|
| 562 |
+
"mode": "palette-classic"
|
| 563 |
+
},
|
| 564 |
+
"mappings": [],
|
| 565 |
+
"thresholds": {
|
| 566 |
+
"mode": "absolute",
|
| 567 |
+
"steps": [
|
| 568 |
+
{
|
| 569 |
+
"color": "green",
|
| 570 |
+
"value": null
|
| 571 |
+
},
|
| 572 |
+
{
|
| 573 |
+
"color": "red",
|
| 574 |
+
"value": 1
|
| 575 |
+
}
|
| 576 |
+
]
|
| 577 |
+
},
|
| 578 |
+
"unit": "short"
|
| 579 |
+
},
|
| 580 |
+
"overrides": []
|
| 581 |
+
},
|
| 582 |
+
"gridPos": {
|
| 583 |
+
"h": 8,
|
| 584 |
+
"w": 12,
|
| 585 |
+
"x": 12,
|
| 586 |
+
"y": 12
|
| 587 |
+
},
|
| 588 |
+
"id": 8,
|
| 589 |
+
"options": {
|
| 590 |
+
"legend": {
|
| 591 |
+
"calcs": [],
|
| 592 |
+
"displayMode": "list",
|
| 593 |
+
"placement": "bottom",
|
| 594 |
+
"showLegend": true
|
| 595 |
+
},
|
| 596 |
+
"tooltip": {
|
| 597 |
+
"mode": "single",
|
| 598 |
+
"sort": "none"
|
| 599 |
+
}
|
| 600 |
+
},
|
| 601 |
+
"pluginVersion": "12.3.1",
|
| 602 |
+
"targets": [
|
| 603 |
+
{
|
| 604 |
+
"editorMode": "code",
|
| 605 |
"expr": "sum by (mode, error_code) (rate(antiatropos_executor_errors_total{mode=~\"live|simulated|hybrid|aws\"}[5m]))",
|
| 606 |
"legendFormat": "executor errors {{error_code}} ({{mode}})",
|
| 607 |
+
"range": true,
|
| 608 |
+
"refId": "A"
|
| 609 |
+
},
|
| 610 |
+
{
|
| 611 |
+
"editorMode": "code",
|
| 612 |
"expr": "histogram_quantile(0.95, sum(rate(antiatropos_executor_latency_ms_bucket{mode=~\"live|simulated|hybrid|aws\"}[5m])) by (le, mode))",
|
| 613 |
+
"legendFormat": "p95 executor latency {{mode}}",
|
| 614 |
+
"range": true,
|
| 615 |
+
"refId": "B"
|
| 616 |
+
}
|
| 617 |
+
],
|
| 618 |
+
"title": "Executor Reliability",
|
| 619 |
+
"type": "timeseries"
|
| 620 |
+
}
|
| 621 |
+
],
|
| 622 |
+
"refresh": "5s",
|
| 623 |
+
"schemaVersion": 41,
|
| 624 |
+
"style": "dark",
|
| 625 |
+
"tags": [
|
| 626 |
+
"antiatropos",
|
| 627 |
+
"sre"
|
| 628 |
+
],
|
| 629 |
+
"templating": {
|
| 630 |
+
"list": []
|
| 631 |
+
},
|
| 632 |
+
"time": {
|
| 633 |
+
"from": "now-30m",
|
| 634 |
+
"to": "now"
|
| 635 |
+
},
|
| 636 |
+
"timepicker": {},
|
| 637 |
+
"timezone": "browser",
|
| 638 |
+
"title": "AntiAtropos Overview",
|
| 639 |
+
"uid": "antiatropos-overview",
|
| 640 |
"version": 2,
|
| 641 |
+
"weekStart": ""
|
| 642 |
+
}
|
| 643 |
+
|
| 644 |
+
|
| 645 |
+
|
| 646 |
+
|
| 647 |
+
|
smoke_test.py
CHANGED
|
@@ -1,602 +1,602 @@
|
|
| 1 |
-
#!/usr/bin/env python3
|
| 2 |
-
"""
|
| 3 |
-
AntiAtropos Local Smoke Test — 5-Node Validation.
|
| 4 |
-
|
| 5 |
-
Validates simulator physics, reward signals, and grading WITHOUT any LLM,
|
| 6 |
-
Colab, or AWS infrastructure. Uses only stdlib + project modules
|
| 7 |
-
(simulator, stability, curriculum have zero external deps).
|
| 8 |
-
|
| 9 |
-
Run from project root:
|
| 10 |
-
python smoke_test.py
|
| 11 |
-
"""
|
| 12 |
-
|
| 13 |
-
import sys
|
| 14 |
-
import os
|
| 15 |
-
import random
|
| 16 |
-
import math
|
| 17 |
-
|
| 18 |
-
# ── Make standalone imports work ──
|
| 19 |
-
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 20 |
-
|
| 21 |
-
from simulator import (
|
| 22 |
-
ClusterSimulator, NodeStatus, DEFAULT_CAPACITY, MAX_CAPACITY,
|
| 23 |
-
VIP_NODE_WEIGHTS, CRITICAL_NODES, COST_PER_CAPACITY_UNIT_PER_HOUR,
|
| 24 |
-
T1_INITIAL_LAMBDA, T2_INITIAL_LAMBDA, T3_INITIAL_LAMBDA,
|
| 25 |
-
)
|
| 26 |
-
from stability import (
|
| 27 |
-
compute_lyapunov, compute_reward, compute_barrier,
|
| 28 |
-
normalize_reward, smooth_sla_penalty, compute_drift,
|
| 29 |
-
)
|
| 30 |
-
from curriculum import CurriculumTracker, CURRICULUM
|
| 31 |
-
|
| 32 |
-
# ── Test harness ─────────────────────────────────────────────────────────────────
|
| 33 |
-
|
| 34 |
-
PASS = "PASS"
|
| 35 |
-
FAIL = "FAIL"
|
| 36 |
-
results: list[tuple[str, str, str]] = [] # (name, status, detail)
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
def record(name: str, status: str, detail: str = "") -> None:
|
| 40 |
-
results.append((name, status, detail))
|
| 41 |
-
icon = "+" if status == PASS else "X"
|
| 42 |
-
msg = f" [{icon}] {name}"
|
| 43 |
-
if detail:
|
| 44 |
-
msg += f" -- {detail}"
|
| 45 |
-
print(msg)
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
def random_action(sim: ClusterSimulator) -> object:
|
| 49 |
-
"""Generate a random valid action."""
|
| 50 |
-
node_ids = [n.node_id for n in sim._nodes]
|
| 51 |
-
action_types = ["SCALE_UP", "SCALE_DOWN", "REROUTE_TRAFFIC", "SHED_LOAD", "NO_OP"]
|
| 52 |
-
|
| 53 |
-
class _A:
|
| 54 |
-
pass
|
| 55 |
-
|
| 56 |
-
a = _A()
|
| 57 |
-
a.action_type = random.choice(action_types)
|
| 58 |
-
a.target_node_id = random.choice(node_ids)
|
| 59 |
-
a.parameter = round(random.random(), 2)
|
| 60 |
-
return a
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
def run_episode(
|
| 64 |
-
sim: ClusterSimulator,
|
| 65 |
-
task_id: str,
|
| 66 |
-
max_steps: int = 60,
|
| 67 |
-
seed: int = 42,
|
| 68 |
-
action_policy: str = "random",
|
| 69 |
-
) -> dict:
|
| 70 |
-
"""
|
| 71 |
-
Run a full episode and collect diagnostics.
|
| 72 |
-
|
| 73 |
-
action_policy: 'random' | 'noop' | 'scale_up_vip'
|
| 74 |
-
"""
|
| 75 |
-
sim.reset(task_id=task_id, seed=seed)
|
| 76 |
-
|
| 77 |
-
rewards_raw: list[float] = []
|
| 78 |
-
rewards_norm: list[float] = []
|
| 79 |
-
lyapunov_history: list[float] = []
|
| 80 |
-
sla_violations = 0
|
| 81 |
-
prev_v = 0.0
|
| 82 |
-
MAX_QUEUE_NORM = 200.0
|
| 83 |
-
MAX_LATENCY_NORM = 1000.0
|
| 84 |
-
ALPHA, BETA, GAMMA, DELTA = 0.002, 0.01, 10.0, 0.005
|
| 85 |
-
|
| 86 |
-
for step in range(1, max_steps + 1):
|
| 87 |
-
# Choose action
|
| 88 |
-
if action_policy == "noop":
|
| 89 |
-
class _A:
|
| 90 |
-
pass
|
| 91 |
-
a = _A()
|
| 92 |
-
a.action_type = "NO_OP"
|
| 93 |
-
a.target_node_id = "node-0"
|
| 94 |
-
a.parameter = 0.0
|
| 95 |
-
elif action_policy == "scale_up_vip":
|
| 96 |
-
class _A:
|
| 97 |
-
pass
|
| 98 |
-
a = _A()
|
| 99 |
-
a.action_type = "SCALE_UP"
|
| 100 |
-
a.target_node_id = "node-0"
|
| 101 |
-
a.parameter = 0.8
|
| 102 |
-
else:
|
| 103 |
-
a = random_action(sim)
|
| 104 |
-
|
| 105 |
-
sim.apply_action(a)
|
| 106 |
-
sim.tick()
|
| 107 |
-
|
| 108 |
-
# Compute reward (mirrors environment.py logic)
|
| 109 |
-
nodes_true = sim.state(for_agent=False)
|
| 110 |
-
current_v = compute_lyapunov(nodes_true)
|
| 111 |
-
|
| 112 |
-
# Avg latency (importance-weighted)
|
| 113 |
-
w_lat = 0.0
|
| 114 |
-
w_sum = 0.0
|
| 115 |
-
for n in nodes_true:
|
| 116 |
-
w = n.get("importance_weight", 1.0)
|
| 117 |
-
lat = MAX_LATENCY_NORM if n["status"] == NodeStatus.FAILED else n["latency_ms"]
|
| 118 |
-
w_lat += w * lat
|
| 119 |
-
w_sum += w
|
| 120 |
-
avg_lat_norm = min(1.0, max(0.0, (w_lat / w_sum / MAX_LATENCY_NORM) if w_sum > 0 else 1.0))
|
| 121 |
-
|
| 122 |
-
# Error rate
|
| 123 |
-
total_in = sum(
|
| 124 |
-
n.get("incoming_request_rate", 0) * n.get("importance_weight", 1.0)
|
| 125 |
-
for n in nodes_true
|
| 126 |
-
)
|
| 127 |
-
total_drop = sum(
|
| 128 |
-
n.get("dropped_requests", 0) * n.get("importance_weight", 1.0)
|
| 129 |
-
for n in nodes_true
|
| 130 |
-
)
|
| 131 |
-
error_rate = min(1.0, total_drop / total_in) if total_in > 0 else 0.0
|
| 132 |
-
|
| 133 |
-
sla_step = smooth_sla_penalty(avg_lat_norm, error_rate)
|
| 134 |
-
if avg_lat_norm > 0.20 or error_rate > 0.05:
|
| 135 |
-
sla_violations += 1
|
| 136 |
-
|
| 137 |
-
# Cost
|
| 138 |
-
total_cap = 0
|
| 139 |
-
for n in nodes_true:
|
| 140 |
-
if n["status"] != NodeStatus.FAILED:
|
| 141 |
-
total_cap += int(n.get("capacity_units", 0)) + int(n.get("pending_capacity_units", 0))
|
| 142 |
-
cost = total_cap * COST_PER_CAPACITY_UNIT_PER_HOUR
|
| 143 |
-
|
| 144 |
-
barrier = compute_barrier(nodes_true)
|
| 145 |
-
raw_r = compute_reward(
|
| 146 |
-
prev_v, current_v, cost, sla_step, ALPHA, BETA, GAMMA, barrier, DELTA
|
| 147 |
-
)
|
| 148 |
-
norm_r = normalize_reward(raw_r)
|
| 149 |
-
|
| 150 |
-
rewards_raw.append(raw_r)
|
| 151 |
-
rewards_norm.append(norm_r)
|
| 152 |
-
lyapunov_history.append(current_v)
|
| 153 |
-
prev_v = current_v
|
| 154 |
-
|
| 155 |
-
return {
|
| 156 |
-
"rewards_raw": rewards_raw,
|
| 157 |
-
"rewards_norm": rewards_norm,
|
| 158 |
-
"lyapunov_history": lyapunov_history,
|
| 159 |
-
"final_state": sim.state(for_agent=False),
|
| 160 |
-
"invalid_count": sim.invalid_action_count,
|
| 161 |
-
"sla_violations": sla_violations,
|
| 162 |
-
}
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 166 |
-
# TEST FUNCTIONS
|
| 167 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 168 |
-
|
| 169 |
-
def test_simulator_node_count():
|
| 170 |
-
"""Simulator creates exactly 10 nodes; node-0 is VIP."""
|
| 171 |
-
print("\n--- Simulator Node Count ---")
|
| 172 |
-
sim = ClusterSimulator(n_nodes=5, task_id="task-1", seed=1)
|
| 173 |
-
nodes = sim.state(for_agent=False)
|
| 174 |
-
|
| 175 |
-
record("10 nodes created",
|
| 176 |
-
PASS if len(nodes) == 10 else FAIL,
|
| 177 |
-
f"got {len(nodes)}")
|
| 178 |
-
|
| 179 |
-
record("node-0 is VIP",
|
| 180 |
-
PASS if nodes[0]["is_vip"] else FAIL,
|
| 181 |
-
f"is_vip={nodes[0]['is_vip']}")
|
| 182 |
-
|
| 183 |
-
record("node-0 weight=4.0",
|
| 184 |
-
PASS if nodes[0]["importance_weight"] == 4.0 else FAIL,
|
| 185 |
-
f"weight={nodes[0]['importance_weight']}")
|
| 186 |
-
|
| 187 |
-
non_vip_weights = [n["importance_weight"] for n in nodes[1:]]
|
| 188 |
-
record("Non-VIP weight=1.0",
|
| 189 |
-
PASS if all(w == 1.0 for w in non_vip_weights) else FAIL,
|
| 190 |
-
f"unique weights={set(non_vip_weights)}")
|
| 191 |
-
|
| 192 |
-
node_ids = [n["node_id"] for n in nodes]
|
| 193 |
-
expected_ids = [f"node-{i}" for i in range(10)]
|
| 194 |
-
record("Node IDs 0-9",
|
| 195 |
-
PASS if node_ids == expected_ids else FAIL,
|
| 196 |
-
f"ids={node_ids}")
|
| 197 |
-
|
| 198 |
-
caps = [n["capacity_units"] for n in nodes]
|
| 199 |
-
record("All nodes at capacity 3",
|
| 200 |
-
PASS if all(c == 3 for c in caps) else FAIL,
|
| 201 |
-
f"caps={caps}")
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
def test_task1_ramp():
|
| 205 |
-
"""Task-1: traffic ramps, queues grow under NO_OP, rewards non-degenerate."""
|
| 206 |
-
print("\n--- Task-1: Linear Ramp (NO_OP policy) ---")
|
| 207 |
-
sim = ClusterSimulator(n_nodes=5, task_id="task-1")
|
| 208 |
-
ep = run_episode(sim, "task-1", max_steps=60, seed=42, action_policy="noop")
|
| 209 |
-
|
| 210 |
-
# Queues should grow (no scaling action taken)
|
| 211 |
-
final_queues = [n["queue_depth"] for n in ep["final_state"]]
|
| 212 |
-
max_q = max(final_queues)
|
| 213 |
-
record("Queues grow under NO_OP",
|
| 214 |
-
PASS if max_q > 0 else FAIL,
|
| 215 |
-
f"max_queue={max_q:.1f}")
|
| 216 |
-
|
| 217 |
-
# Rewards should not all be identical
|
| 218 |
-
unique_raw = len(set(round(r, 6) for r in ep["rewards_raw"]))
|
| 219 |
-
record("Raw rewards vary across steps",
|
| 220 |
-
PASS if unique_raw > 5 else FAIL,
|
| 221 |
-
f"unique values={unique_raw}/{len(ep['rewards_raw'])}")
|
| 222 |
-
|
| 223 |
-
# Normalized rewards in [0, 1]
|
| 224 |
-
all_in_range = all(0.0 <= r <= 1.0 for r in ep["rewards_norm"])
|
| 225 |
-
record("Normalized rewards in [0,1]",
|
| 226 |
-
PASS if all_in_range else FAIL,
|
| 227 |
-
f"min={min(ep['rewards_norm']):.4f} max={max(ep['rewards_norm']):.4f}")
|
| 228 |
-
|
| 229 |
-
# No NaN / inf
|
| 230 |
-
has_nan = any(math.isnan(r) or math.isinf(r) for r in ep["rewards_raw"])
|
| 231 |
-
record("No NaN/inf in raw rewards",
|
| 232 |
-
PASS if not has_nan else FAIL,
|
| 233 |
-
"")
|
| 234 |
-
|
| 235 |
-
# Lyapunov energy should trend upward (system destabilizing under NO_OP)
|
| 236 |
-
v_first5 = sum(ep["lyapunov_history"][:5]) / 5
|
| 237 |
-
v_last5 = sum(ep["lyapunov_history"][-5:]) / 5
|
| 238 |
-
record("Lyapunov energy rises under NO_OP",
|
| 239 |
-
PASS if v_last5 > v_first5 else FAIL,
|
| 240 |
-
f"early_avg={v_first5:.1f} late_avg={v_last5:.1f}")
|
| 241 |
-
|
| 242 |
-
print(f" [i] SLA violations: {ep['sla_violations']}/60")
|
| 243 |
-
print(f" [i] Avg norm reward: {sum(ep['rewards_norm'])/len(ep['rewards_norm']):.4f}")
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
def test_task2_fault():
|
| 247 |
-
"""Task-2: a node fails, queues react, reroute reduces load on failed node."""
|
| 248 |
-
print("\n--- Task-2: Fault Tolerance ---")
|
| 249 |
-
sim = ClusterSimulator(n_nodes=5, task_id="task-2")
|
| 250 |
-
ep = run_episode(sim, "task-2", max_steps=60, seed=42, action_policy="noop")
|
| 251 |
-
|
| 252 |
-
# At least one node should be FAILED by end (scripted failure)
|
| 253 |
-
failed = [n for n in ep["final_state"] if n["status"] == "FAILED"]
|
| 254 |
-
record("Scripted failure occurs",
|
| 255 |
-
PASS if len(failed) >= 1 else FAIL,
|
| 256 |
-
f"failed_nodes={len(failed)}")
|
| 257 |
-
|
| 258 |
-
# node-0 should NOT be the failed one (excluded from failure pool)
|
| 259 |
-
failed_ids = [n["node_id"] for n in failed]
|
| 260 |
-
record("node-0 not in failed set",
|
| 261 |
-
PASS if "node-0" not in failed_ids else FAIL,
|
| 262 |
-
f"failed_ids={failed_ids}")
|
| 263 |
-
|
| 264 |
-
# Rewards may plateau under NO_OP on constant-load tasks.
|
| 265 |
-
# Task-2 has fixed lambda, so steady-state reward has very low variance.
|
| 266 |
-
# This is expected — active policies (scale/reroute) create variation.
|
| 267 |
-
record("Raw rewards produced (may plateau under NO_OP)",
|
| 268 |
-
PASS if len(ep['rewards_raw']) == 60 else FAIL,
|
| 269 |
-
f"steps={len(ep['rewards_raw'])}")
|
| 270 |
-
|
| 271 |
-
# More importantly, normalized rewards should differ from 0.5 midpoint
|
| 272 |
-
# (proving the raw reward signal is non-trivial)
|
| 273 |
-
avg_norm = sum(ep['rewards_norm']) / len(ep['rewards_norm'])
|
| 274 |
-
record("Normalized reward is non-trivial (not stuck at 0.5)",
|
| 275 |
-
PASS if abs(avg_norm - 0.5) > 0.01 else FAIL,
|
| 276 |
-
f"avg_norm={avg_norm:.4f}")
|
| 277 |
-
|
| 278 |
-
# Normalized rewards in [0, 1]
|
| 279 |
-
all_in_range = all(0.0 <= r <= 1.0 for r in ep["rewards_norm"])
|
| 280 |
-
record("Normalized rewards in [0,1]",
|
| 281 |
-
PASS if all_in_range else FAIL,
|
| 282 |
-
f"min={min(ep['rewards_norm']):.4f} max={max(ep['rewards_norm']):.4f}")
|
| 283 |
-
|
| 284 |
-
# No NaN / inf
|
| 285 |
-
has_nan = any(math.isnan(r) or math.isinf(r) for r in ep["rewards_raw"])
|
| 286 |
-
record("No NaN/inf in raw rewards",
|
| 287 |
-
PASS if not has_nan else FAIL, "")
|
| 288 |
-
|
| 289 |
-
# Now test with targeted reroute on the scripted-failed node
|
| 290 |
-
# (NOT all nodes — rerouting everything to node-0 kills it)
|
| 291 |
-
sim2 = ClusterSimulator(n_nodes=5, task_id="task-2", seed=99)
|
| 292 |
-
sim2.reset(task_id="task-2", seed=99)
|
| 293 |
-
scripted_fail_id = None
|
| 294 |
-
for step in range(1, 61):
|
| 295 |
-
sim2.tick()
|
| 296 |
-
# Check if the scripted failure has been assigned
|
| 297 |
-
if sim2._failed_node_id and scripted_fail_id is None:
|
| 298 |
-
scripted_fail_id = sim2._failed_node_id
|
| 299 |
-
# Apply reroute specifically to the failed node
|
| 300 |
-
class _A:
|
| 301 |
-
pass
|
| 302 |
-
a = _A()
|
| 303 |
-
a.action_type = "REROUTE_TRAFFIC"
|
| 304 |
-
a.target_node_id = scripted_fail_id
|
| 305 |
-
a.parameter = 1.0
|
| 306 |
-
sim2.apply_action(a)
|
| 307 |
-
# Tick once more to see the effect
|
| 308 |
-
sim2.tick()
|
| 309 |
-
failed_node = next((n for n in sim2._nodes if n.node_id == scripted_fail_id), None)
|
| 310 |
-
base_share = sim2._t2_init_lambda / sim2._n_nodes
|
| 311 |
-
record("Reroute reduces failed node traffic",
|
| 312 |
-
PASS if failed_node.incoming_request_rate < base_share else FAIL,
|
| 313 |
-
f"node={scripted_fail_id} incoming={failed_node.incoming_request_rate:.1f} base_share={base_share:.1f}")
|
| 314 |
-
break
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
def test_task3_surge():
|
| 318 |
-
"""Task-3: surge hits node-1/node-2, SHED_LOAD on critical nodes rejected."""
|
| 319 |
-
print("\n--- Task-3: Periodic Surge ---")
|
| 320 |
-
sim = ClusterSimulator(n_nodes=5, task_id="task-3")
|
| 321 |
-
ep = run_episode(sim, "task-3", max_steps=60, seed=42, action_policy="noop")
|
| 322 |
-
|
| 323 |
-
# Rewards non-degenerate
|
| 324 |
-
unique_raw = len(set(round(r, 6) for r in ep["rewards_raw"]))
|
| 325 |
-
record("Raw rewards vary",
|
| 326 |
-
PASS if unique_raw > 5 else FAIL,
|
| 327 |
-
f"unique values={unique_raw}/{len(ep['rewards_raw'])}")
|
| 328 |
-
|
| 329 |
-
# Normalized rewards in [0, 1]
|
| 330 |
-
all_in_range = all(0.0 <= r <= 1.0 for r in ep["rewards_norm"])
|
| 331 |
-
record("Normalized rewards in [0,1]",
|
| 332 |
-
PASS if all_in_range else FAIL,
|
| 333 |
-
f"min={min(ep['rewards_norm']):.4f} max={max(ep['rewards_norm']):.4f}")
|
| 334 |
-
|
| 335 |
-
# No NaN / inf
|
| 336 |
-
has_nan = any(math.isnan(r) or math.isinf(r) for r in ep["rewards_raw"])
|
| 337 |
-
record("No NaN/inf in raw rewards",
|
| 338 |
-
PASS if not has_nan else FAIL, "")
|
| 339 |
-
|
| 340 |
-
# Test SHED_LOAD rejection on critical nodes
|
| 341 |
-
sim3 = ClusterSimulator(n_nodes=5, task_id="task-3", seed=7)
|
| 342 |
-
sim3.reset(task_id="task-3", seed=7)
|
| 343 |
-
for critical_id in CRITICAL_NODES:
|
| 344 |
-
class _A:
|
| 345 |
-
pass
|
| 346 |
-
a = _A()
|
| 347 |
-
a.action_type = "SHED_LOAD"
|
| 348 |
-
a.target_node_id = critical_id
|
| 349 |
-
a.parameter = 0.5
|
| 350 |
-
sim3.apply_action(a)
|
| 351 |
-
record("SHED_LOAD on critical nodes rejected",
|
| 352 |
-
PASS if sim3.invalid_action_count == len(CRITICAL_NODES) else FAIL,
|
| 353 |
-
f"invalid_count={sim3.invalid_action_count} expected={len(CRITICAL_NODES)}")
|
| 354 |
-
|
| 355 |
-
# SHED_LOAD on non-critical should be allowed
|
| 356 |
-
class _A2:
|
| 357 |
-
pass
|
| 358 |
-
a2 = _A2()
|
| 359 |
-
a2.action_type = "SHED_LOAD"
|
| 360 |
-
a2.target_node_id = "node-5"
|
| 361 |
-
a2.parameter = 0.5
|
| 362 |
-
sim3.apply_action(a2)
|
| 363 |
-
record("SHED_LOAD on non-critical node allowed",
|
| 364 |
-
PASS if sim3.invalid_action_count == len(CRITICAL_NODES) else FAIL,
|
| 365 |
-
f"invalid_count={sim3.invalid_action_count}")
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
def test_scale_up_down():
|
| 369 |
-
"""SCALE_UP increases capacity after boot delay; SCALE_DOWN decreases it."""
|
| 370 |
-
print("\n--- Scale Up / Scale Down ---")
|
| 371 |
-
sim = ClusterSimulator(n_nodes=5, task_id="task-1", seed=1)
|
| 372 |
-
sim.reset(task_id="task-1", seed=1)
|
| 373 |
-
|
| 374 |
-
# SCALE_UP node-3
|
| 375 |
-
class _A:
|
| 376 |
-
pass
|
| 377 |
-
a = _A()
|
| 378 |
-
a.action_type = "SCALE_UP"
|
| 379 |
-
a.target_node_id = "node-3"
|
| 380 |
-
a.parameter = 1.0 # 1 * MAX_SCALING_STEP=3 → 3 units
|
| 381 |
-
sim.apply_action(a)
|
| 382 |
-
|
| 383 |
-
# Check pending capacity before boot
|
| 384 |
-
node3 = next(n for n in sim._nodes if n.node_id == "node-3")
|
| 385 |
-
record("Pending capacity queued after SCALE_UP",
|
| 386 |
-
PASS if len(node3.pending_capacity_queue) > 0 else FAIL,
|
| 387 |
-
f"pending={len(node3.pending_capacity_queue)}")
|
| 388 |
-
|
| 389 |
-
# Tick through boot delay
|
| 390 |
-
for _ in range(6):
|
| 391 |
-
sim.tick()
|
| 392 |
-
|
| 393 |
-
node3 = next(n for n in sim._nodes if n.node_id == "node-3")
|
| 394 |
-
record("Capacity goes live after boot delay",
|
| 395 |
-
PASS if node3.capacity > DEFAULT_CAPACITY else FAIL,
|
| 396 |
-
f"capacity={node3.capacity}")
|
| 397 |
-
|
| 398 |
-
# SCALE_DOWN
|
| 399 |
-
prev_cap = node3.capacity
|
| 400 |
-
class _A2:
|
| 401 |
-
pass
|
| 402 |
-
a2 = _A2()
|
| 403 |
-
a2.action_type = "SCALE_DOWN"
|
| 404 |
-
a2.target_node_id = "node-3"
|
| 405 |
-
a2.parameter = 0.5
|
| 406 |
-
sim.apply_action(a2)
|
| 407 |
-
record("SCALE_DOWN reduces capacity",
|
| 408 |
-
PASS if node3.capacity < prev_cap else FAIL,
|
| 409 |
-
f"before={prev_cap} after={node3.capacity}")
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
def test_reward_sanity():
|
| 413 |
-
"""Detailed reward component sanity checks."""
|
| 414 |
-
print("\n--- Reward Sanity ---")
|
| 415 |
-
|
| 416 |
-
# Test normalize_reward mapping
|
| 417 |
-
r0 = normalize_reward(0.0)
|
| 418 |
-
record("normalize_reward(0.0) in [0,1]",
|
| 419 |
-
PASS if 0.0 <= r0 <= 1.0 else FAIL,
|
| 420 |
-
f"got {r0:.4f}")
|
| 421 |
-
|
| 422 |
-
r_neg = normalize_reward(-100.0)
|
| 423 |
-
r_pos = normalize_reward(100.0)
|
| 424 |
-
record("More negative raw -> lower normalized",
|
| 425 |
-
PASS if r_neg < r_pos else FAIL,
|
| 426 |
-
f"neg={r_neg:.4f} pos={r_pos:.4f}")
|
| 427 |
-
|
| 428 |
-
# Smooth SLA penalty
|
| 429 |
-
p_safe = smooth_sla_penalty(0.05, 0.01) # well below thresholds
|
| 430 |
-
p_danger = smooth_sla_penalty(0.30, 0.10) # above thresholds
|
| 431 |
-
record("SLA penalty: safe < danger",
|
| 432 |
-
PASS if p_safe < p_danger else FAIL,
|
| 433 |
-
f"safe={p_safe:.4f} danger={p_danger:.4f}")
|
| 434 |
-
|
| 435 |
-
# Barrier function
|
| 436 |
-
nodes_ok = [{"queue_depth": 50.0} for _ in range(10)]
|
| 437 |
-
nodes_bad = [{"queue_depth": 200.0} for _ in range(10)]
|
| 438 |
-
b_ok = compute_barrier(nodes_ok)
|
| 439 |
-
b_bad = compute_barrier(nodes_bad)
|
| 440 |
-
record("Barrier: safe queues < overloaded queues",
|
| 441 |
-
PASS if b_ok < b_bad else FAIL,
|
| 442 |
-
f"ok={b_ok:.1f} bad={b_bad:.1f}")
|
| 443 |
-
record("Barrier is 0 when all below Q_BARRIER_MAX",
|
| 444 |
-
PASS if b_ok == 0.0 else FAIL,
|
| 445 |
-
f"got {b_ok:.1f}")
|
| 446 |
-
|
| 447 |
-
# Lyapunov with VIP weight
|
| 448 |
-
nodes_no_vip = [{"queue_depth": 100.0, "importance_weight": 1.0} for _ in range(10)]
|
| 449 |
-
nodes_with_vip = [{"queue_depth": 100.0, "importance_weight": 4.0}] + \
|
| 450 |
-
[{"queue_depth": 100.0, "importance_weight": 1.0} for _ in range(9)]
|
| 451 |
-
v_no_vip = compute_lyapunov(nodes_no_vip)
|
| 452 |
-
v_with_vip = compute_lyapunov(nodes_with_vip)
|
| 453 |
-
record("VIP weight amplifies Lyapunov energy",
|
| 454 |
-
PASS if v_with_vip > v_no_vip else FAIL,
|
| 455 |
-
f"no_vip={v_no_vip:.1f} with_vip={v_with_vip:.1f}")
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
def test_grader_inline():
|
| 459 |
-
"""Inline grader score validation (mirrors grader.py logic without importing it)."""
|
| 460 |
-
print("\n--- Grader Logic (Inline) ---")
|
| 461 |
-
|
| 462 |
-
# Baseline cost for 10 nodes at capacity 3
|
| 463 |
-
BASELINE = 10 * 3 * 0.05 # 1.50
|
| 464 |
-
MAX_COST = 10 * 5 * 0.05 # 2.50
|
| 465 |
-
COST_K = 3.0
|
| 466 |
-
TARGET_ENERGY = 2000.0
|
| 467 |
-
CURVE_POWER = 2.0
|
| 468 |
-
|
| 469 |
-
# Perfectly provisioned: cost == baseline -> score = 1.0
|
| 470 |
-
over_ratio = max(0.0, (BASELINE - BASELINE) / BASELINE)
|
| 471 |
-
cost_score = max(0.0, min(1.0, math.exp(-COST_K * over_ratio)))
|
| 472 |
-
record("Cost score=1.0 at baseline",
|
| 473 |
-
PASS if abs(cost_score - 1.0) < 1e-6 else FAIL,
|
| 474 |
-
f"got {cost_score:.4f}")
|
| 475 |
-
|
| 476 |
-
# 2x over-provisioned: score should be very low
|
| 477 |
-
over_ratio_2x = max(0.0, (2 * BASELINE - BASELINE) / BASELINE)
|
| 478 |
-
cost_score_2x = max(0.0, min(1.0, math.exp(-COST_K * over_ratio_2x)))
|
| 479 |
-
record("Cost score near 0 at 2x baseline",
|
| 480 |
-
PASS if cost_score_2x < 0.1 else FAIL,
|
| 481 |
-
f"got {cost_score_2x:.4f}")
|
| 482 |
-
|
| 483 |
-
# Stability: low energy -> high score
|
| 484 |
-
low_energy = 100.0
|
| 485 |
-
ratio = low_energy / TARGET_ENERGY
|
| 486 |
-
stab_score = 1.0 / (1.0 + ratio ** CURVE_POWER)
|
| 487 |
-
record("Stability score high at low energy",
|
| 488 |
-
PASS if stab_score > 0.9 else FAIL,
|
| 489 |
-
f"energy={low_energy} score={stab_score:.4f}")
|
| 490 |
-
|
| 491 |
-
# Stability: high energy -> low score
|
| 492 |
-
high_energy = 10000.0
|
| 493 |
-
ratio_h = high_energy / TARGET_ENERGY
|
| 494 |
-
stab_score_h = 1.0 / (1.0 + ratio_h ** CURVE_POWER)
|
| 495 |
-
record("Stability score low at high energy",
|
| 496 |
-
PASS if stab_score_h < 0.1 else FAIL,
|
| 497 |
-
f"energy={high_energy} score={stab_score_h:.4f}")
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
def test_curriculum_tracker():
|
| 501 |
-
"""Curriculum tracker advances stages on passing scores."""
|
| 502 |
-
print("\n--- Curriculum Tracker ---")
|
| 503 |
-
tracker = CurriculumTracker()
|
| 504 |
-
|
| 505 |
-
record("Starts at stage 0",
|
| 506 |
-
PASS if tracker.current_index == 0 else FAIL,
|
| 507 |
-
f"idx={tracker.current_index}")
|
| 508 |
-
|
| 509 |
-
record(f"Total stages = {len(CURRICULUM)}",
|
| 510 |
-
PASS if len(CURRICULUM) == 10 else FAIL,
|
| 511 |
-
f"got {len(CURRICULUM)}")
|
| 512 |
-
|
| 513 |
-
# Pass first stage
|
| 514 |
-
stage0 = tracker.current
|
| 515 |
-
passed = tracker.report_score(0.50) # > 0.40 threshold
|
| 516 |
-
record("Pass stage 0 with score 0.50",
|
| 517 |
-
PASS if passed and tracker.current_index == 1 else FAIL,
|
| 518 |
-
f"passed={passed} idx={tracker.current_index}")
|
| 519 |
-
|
| 520 |
-
# Fail stage 1 (needs 0.50)
|
| 521 |
-
passed2 = tracker.report_score(0.30) # < 0.50
|
| 522 |
-
record("Fail stage 1 with score 0.30",
|
| 523 |
-
PASS if not passed2 else FAIL,
|
| 524 |
-
f"passed={passed2} retries={tracker.current.retries}")
|
| 525 |
-
|
| 526 |
-
# Pass on retry
|
| 527 |
-
passed3 = tracker.report_score(0.60)
|
| 528 |
-
record("Pass stage 1 on retry with score 0.60",
|
| 529 |
-
PASS if passed3 and tracker.current_index == 2 else FAIL,
|
| 530 |
-
f"passed={passed3} idx={tracker.current_index}")
|
| 531 |
-
|
| 532 |
-
# Progress summary doesn't crash
|
| 533 |
-
summary = tracker.progress_summary()
|
| 534 |
-
record("progress_summary() returns string",
|
| 535 |
-
PASS if isinstance(summary, str) and len(summary) > 0 else FAIL,
|
| 536 |
-
f"len={len(summary)}")
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
def test_cascade_and_recovery():
|
| 540 |
-
"""Cascade failure detection and auto-recovery work."""
|
| 541 |
-
print("\n--- Cascade & Recovery ---")
|
| 542 |
-
sim = ClusterSimulator(n_nodes=5, task_id="task-1", seed=1)
|
| 543 |
-
sim.reset(task_id="task-1", seed=1)
|
| 544 |
-
|
| 545 |
-
# Artificially overload a node to trigger failure
|
| 546 |
-
node = sim._nodes[5]
|
| 547 |
-
node.queue_depth = 250.0 # > FATAL_FAIL_THRESHOLD=200
|
| 548 |
-
sim._update_statuses()
|
| 549 |
-
record("Node fails when queue > FATAL_FAIL_THRESHOLD",
|
| 550 |
-
PASS if node.status == NodeStatus.FAILED else FAIL,
|
| 551 |
-
f"status={node.status}")
|
| 552 |
-
|
| 553 |
-
record("Recovery timer set on overload failure",
|
| 554 |
-
PASS if node.recovery_timer > 0 else FAIL,
|
| 555 |
-
f"timer={node.recovery_timer}")
|
| 556 |
-
|
| 557 |
-
# Tick through recovery
|
| 558 |
-
for _ in range(25):
|
| 559 |
-
sim._process_recovery()
|
| 560 |
-
|
| 561 |
-
record("Node recovers after NODE_RECOVERY_TICKS",
|
| 562 |
-
PASS if node.status == NodeStatus.HEALTHY else FAIL,
|
| 563 |
-
f"status={node.status}")
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 567 |
-
|
| 568 |
-
def main():
|
| 569 |
-
print("=" * 60)
|
| 570 |
-
print("AntiAtropos Smoke Test — 5-Node Cluster Validation")
|
| 571 |
-
print("=" * 60)
|
| 572 |
-
|
| 573 |
-
test_simulator_node_count()
|
| 574 |
-
test_task1_ramp()
|
| 575 |
-
test_task2_fault()
|
| 576 |
-
test_task3_surge()
|
| 577 |
-
test_scale_up_down()
|
| 578 |
-
test_reward_sanity()
|
| 579 |
-
test_grader_inline()
|
| 580 |
-
test_curriculum_tracker()
|
| 581 |
-
test_cascade_and_recovery()
|
| 582 |
-
|
| 583 |
-
# ── Summary ──
|
| 584 |
-
passed = sum(1 for _, s, _ in results if s == PASS)
|
| 585 |
-
failed = sum(1 for _, s, _ in results if s == FAIL)
|
| 586 |
-
total = len(results)
|
| 587 |
-
|
| 588 |
-
print("\n" + "=" * 60)
|
| 589 |
-
print(f"RESULTS: {passed}/{total} passed, {failed} failed")
|
| 590 |
-
print("=" * 60)
|
| 591 |
-
|
| 592 |
-
if failed > 0:
|
| 593 |
-
print("\nFailed tests:")
|
| 594 |
-
for name, status, detail in results:
|
| 595 |
-
if status == FAIL:
|
| 596 |
-
print(f" X {name}: {detail}")
|
| 597 |
-
|
| 598 |
-
return 0 if failed == 0 else 1
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
if __name__ == "__main__":
|
| 602 |
-
sys.exit(main())
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
AntiAtropos Local Smoke Test — 5-Node Validation.
|
| 4 |
+
|
| 5 |
+
Validates simulator physics, reward signals, and grading WITHOUT any LLM,
|
| 6 |
+
Colab, or AWS infrastructure. Uses only stdlib + project modules
|
| 7 |
+
(simulator, stability, curriculum have zero external deps).
|
| 8 |
+
|
| 9 |
+
Run from project root:
|
| 10 |
+
python smoke_test.py
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import sys
|
| 14 |
+
import os
|
| 15 |
+
import random
|
| 16 |
+
import math
|
| 17 |
+
|
| 18 |
+
# ── Make standalone imports work ──
|
| 19 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 20 |
+
|
| 21 |
+
from simulator import (
|
| 22 |
+
ClusterSimulator, NodeStatus, DEFAULT_CAPACITY, MAX_CAPACITY,
|
| 23 |
+
VIP_NODE_WEIGHTS, CRITICAL_NODES, COST_PER_CAPACITY_UNIT_PER_HOUR,
|
| 24 |
+
T1_INITIAL_LAMBDA, T2_INITIAL_LAMBDA, T3_INITIAL_LAMBDA,
|
| 25 |
+
)
|
| 26 |
+
from stability import (
|
| 27 |
+
compute_lyapunov, compute_reward, compute_barrier,
|
| 28 |
+
normalize_reward, smooth_sla_penalty, compute_drift,
|
| 29 |
+
)
|
| 30 |
+
from curriculum import CurriculumTracker, CURRICULUM
|
| 31 |
+
|
| 32 |
+
# ── Test harness ─────────────────────────────────────────────────────────────────
|
| 33 |
+
|
| 34 |
+
PASS = "PASS"
|
| 35 |
+
FAIL = "FAIL"
|
| 36 |
+
results: list[tuple[str, str, str]] = [] # (name, status, detail)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def record(name: str, status: str, detail: str = "") -> None:
|
| 40 |
+
results.append((name, status, detail))
|
| 41 |
+
icon = "+" if status == PASS else "X"
|
| 42 |
+
msg = f" [{icon}] {name}"
|
| 43 |
+
if detail:
|
| 44 |
+
msg += f" -- {detail}"
|
| 45 |
+
print(msg)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def random_action(sim: ClusterSimulator) -> object:
|
| 49 |
+
"""Generate a random valid action."""
|
| 50 |
+
node_ids = [n.node_id for n in sim._nodes]
|
| 51 |
+
action_types = ["SCALE_UP", "SCALE_DOWN", "REROUTE_TRAFFIC", "SHED_LOAD", "NO_OP"]
|
| 52 |
+
|
| 53 |
+
class _A:
|
| 54 |
+
pass
|
| 55 |
+
|
| 56 |
+
a = _A()
|
| 57 |
+
a.action_type = random.choice(action_types)
|
| 58 |
+
a.target_node_id = random.choice(node_ids)
|
| 59 |
+
a.parameter = round(random.random(), 2)
|
| 60 |
+
return a
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def run_episode(
|
| 64 |
+
sim: ClusterSimulator,
|
| 65 |
+
task_id: str,
|
| 66 |
+
max_steps: int = 60,
|
| 67 |
+
seed: int = 42,
|
| 68 |
+
action_policy: str = "random",
|
| 69 |
+
) -> dict:
|
| 70 |
+
"""
|
| 71 |
+
Run a full episode and collect diagnostics.
|
| 72 |
+
|
| 73 |
+
action_policy: 'random' | 'noop' | 'scale_up_vip'
|
| 74 |
+
"""
|
| 75 |
+
sim.reset(task_id=task_id, seed=seed)
|
| 76 |
+
|
| 77 |
+
rewards_raw: list[float] = []
|
| 78 |
+
rewards_norm: list[float] = []
|
| 79 |
+
lyapunov_history: list[float] = []
|
| 80 |
+
sla_violations = 0
|
| 81 |
+
prev_v = 0.0
|
| 82 |
+
MAX_QUEUE_NORM = 200.0
|
| 83 |
+
MAX_LATENCY_NORM = 1000.0
|
| 84 |
+
ALPHA, BETA, GAMMA, DELTA = 0.002, 0.01, 10.0, 0.005
|
| 85 |
+
|
| 86 |
+
for step in range(1, max_steps + 1):
|
| 87 |
+
# Choose action
|
| 88 |
+
if action_policy == "noop":
|
| 89 |
+
class _A:
|
| 90 |
+
pass
|
| 91 |
+
a = _A()
|
| 92 |
+
a.action_type = "NO_OP"
|
| 93 |
+
a.target_node_id = "node-0"
|
| 94 |
+
a.parameter = 0.0
|
| 95 |
+
elif action_policy == "scale_up_vip":
|
| 96 |
+
class _A:
|
| 97 |
+
pass
|
| 98 |
+
a = _A()
|
| 99 |
+
a.action_type = "SCALE_UP"
|
| 100 |
+
a.target_node_id = "node-0"
|
| 101 |
+
a.parameter = 0.8
|
| 102 |
+
else:
|
| 103 |
+
a = random_action(sim)
|
| 104 |
+
|
| 105 |
+
sim.apply_action(a)
|
| 106 |
+
sim.tick()
|
| 107 |
+
|
| 108 |
+
# Compute reward (mirrors environment.py logic)
|
| 109 |
+
nodes_true = sim.state(for_agent=False)
|
| 110 |
+
current_v = compute_lyapunov(nodes_true)
|
| 111 |
+
|
| 112 |
+
# Avg latency (importance-weighted)
|
| 113 |
+
w_lat = 0.0
|
| 114 |
+
w_sum = 0.0
|
| 115 |
+
for n in nodes_true:
|
| 116 |
+
w = n.get("importance_weight", 1.0)
|
| 117 |
+
lat = MAX_LATENCY_NORM if n["status"] == NodeStatus.FAILED else n["latency_ms"]
|
| 118 |
+
w_lat += w * lat
|
| 119 |
+
w_sum += w
|
| 120 |
+
avg_lat_norm = min(1.0, max(0.0, (w_lat / w_sum / MAX_LATENCY_NORM) if w_sum > 0 else 1.0))
|
| 121 |
+
|
| 122 |
+
# Error rate
|
| 123 |
+
total_in = sum(
|
| 124 |
+
n.get("incoming_request_rate", 0) * n.get("importance_weight", 1.0)
|
| 125 |
+
for n in nodes_true
|
| 126 |
+
)
|
| 127 |
+
total_drop = sum(
|
| 128 |
+
n.get("dropped_requests", 0) * n.get("importance_weight", 1.0)
|
| 129 |
+
for n in nodes_true
|
| 130 |
+
)
|
| 131 |
+
error_rate = min(1.0, total_drop / total_in) if total_in > 0 else 0.0
|
| 132 |
+
|
| 133 |
+
sla_step = smooth_sla_penalty(avg_lat_norm, error_rate)
|
| 134 |
+
if avg_lat_norm > 0.20 or error_rate > 0.05:
|
| 135 |
+
sla_violations += 1
|
| 136 |
+
|
| 137 |
+
# Cost
|
| 138 |
+
total_cap = 0
|
| 139 |
+
for n in nodes_true:
|
| 140 |
+
if n["status"] != NodeStatus.FAILED:
|
| 141 |
+
total_cap += int(n.get("capacity_units", 0)) + int(n.get("pending_capacity_units", 0))
|
| 142 |
+
cost = total_cap * COST_PER_CAPACITY_UNIT_PER_HOUR
|
| 143 |
+
|
| 144 |
+
barrier = compute_barrier(nodes_true)
|
| 145 |
+
raw_r = compute_reward(
|
| 146 |
+
prev_v, current_v, cost, sla_step, ALPHA, BETA, GAMMA, barrier, DELTA
|
| 147 |
+
)
|
| 148 |
+
norm_r = normalize_reward(raw_r)
|
| 149 |
+
|
| 150 |
+
rewards_raw.append(raw_r)
|
| 151 |
+
rewards_norm.append(norm_r)
|
| 152 |
+
lyapunov_history.append(current_v)
|
| 153 |
+
prev_v = current_v
|
| 154 |
+
|
| 155 |
+
return {
|
| 156 |
+
"rewards_raw": rewards_raw,
|
| 157 |
+
"rewards_norm": rewards_norm,
|
| 158 |
+
"lyapunov_history": lyapunov_history,
|
| 159 |
+
"final_state": sim.state(for_agent=False),
|
| 160 |
+
"invalid_count": sim.invalid_action_count,
|
| 161 |
+
"sla_violations": sla_violations,
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 166 |
+
# TEST FUNCTIONS
|
| 167 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 168 |
+
|
| 169 |
+
def test_simulator_node_count():
|
| 170 |
+
"""Simulator creates exactly 10 nodes; node-0 is VIP."""
|
| 171 |
+
print("\n--- Simulator Node Count ---")
|
| 172 |
+
sim = ClusterSimulator(n_nodes=5, task_id="task-1", seed=1)
|
| 173 |
+
nodes = sim.state(for_agent=False)
|
| 174 |
+
|
| 175 |
+
record("10 nodes created",
|
| 176 |
+
PASS if len(nodes) == 10 else FAIL,
|
| 177 |
+
f"got {len(nodes)}")
|
| 178 |
+
|
| 179 |
+
record("node-0 is VIP",
|
| 180 |
+
PASS if nodes[0]["is_vip"] else FAIL,
|
| 181 |
+
f"is_vip={nodes[0]['is_vip']}")
|
| 182 |
+
|
| 183 |
+
record("node-0 weight=4.0",
|
| 184 |
+
PASS if nodes[0]["importance_weight"] == 4.0 else FAIL,
|
| 185 |
+
f"weight={nodes[0]['importance_weight']}")
|
| 186 |
+
|
| 187 |
+
non_vip_weights = [n["importance_weight"] for n in nodes[1:]]
|
| 188 |
+
record("Non-VIP weight=1.0",
|
| 189 |
+
PASS if all(w == 1.0 for w in non_vip_weights) else FAIL,
|
| 190 |
+
f"unique weights={set(non_vip_weights)}")
|
| 191 |
+
|
| 192 |
+
node_ids = [n["node_id"] for n in nodes]
|
| 193 |
+
expected_ids = [f"node-{i}" for i in range(10)]
|
| 194 |
+
record("Node IDs 0-9",
|
| 195 |
+
PASS if node_ids == expected_ids else FAIL,
|
| 196 |
+
f"ids={node_ids}")
|
| 197 |
+
|
| 198 |
+
caps = [n["capacity_units"] for n in nodes]
|
| 199 |
+
record("All nodes at capacity 3",
|
| 200 |
+
PASS if all(c == 3 for c in caps) else FAIL,
|
| 201 |
+
f"caps={caps}")
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def test_task1_ramp():
|
| 205 |
+
"""Task-1: traffic ramps, queues grow under NO_OP, rewards non-degenerate."""
|
| 206 |
+
print("\n--- Task-1: Linear Ramp (NO_OP policy) ---")
|
| 207 |
+
sim = ClusterSimulator(n_nodes=5, task_id="task-1")
|
| 208 |
+
ep = run_episode(sim, "task-1", max_steps=60, seed=42, action_policy="noop")
|
| 209 |
+
|
| 210 |
+
# Queues should grow (no scaling action taken)
|
| 211 |
+
final_queues = [n["queue_depth"] for n in ep["final_state"]]
|
| 212 |
+
max_q = max(final_queues)
|
| 213 |
+
record("Queues grow under NO_OP",
|
| 214 |
+
PASS if max_q > 0 else FAIL,
|
| 215 |
+
f"max_queue={max_q:.1f}")
|
| 216 |
+
|
| 217 |
+
# Rewards should not all be identical
|
| 218 |
+
unique_raw = len(set(round(r, 6) for r in ep["rewards_raw"]))
|
| 219 |
+
record("Raw rewards vary across steps",
|
| 220 |
+
PASS if unique_raw > 5 else FAIL,
|
| 221 |
+
f"unique values={unique_raw}/{len(ep['rewards_raw'])}")
|
| 222 |
+
|
| 223 |
+
# Normalized rewards in [0, 1]
|
| 224 |
+
all_in_range = all(0.0 <= r <= 1.0 for r in ep["rewards_norm"])
|
| 225 |
+
record("Normalized rewards in [0,1]",
|
| 226 |
+
PASS if all_in_range else FAIL,
|
| 227 |
+
f"min={min(ep['rewards_norm']):.4f} max={max(ep['rewards_norm']):.4f}")
|
| 228 |
+
|
| 229 |
+
# No NaN / inf
|
| 230 |
+
has_nan = any(math.isnan(r) or math.isinf(r) for r in ep["rewards_raw"])
|
| 231 |
+
record("No NaN/inf in raw rewards",
|
| 232 |
+
PASS if not has_nan else FAIL,
|
| 233 |
+
"")
|
| 234 |
+
|
| 235 |
+
# Lyapunov energy should trend upward (system destabilizing under NO_OP)
|
| 236 |
+
v_first5 = sum(ep["lyapunov_history"][:5]) / 5
|
| 237 |
+
v_last5 = sum(ep["lyapunov_history"][-5:]) / 5
|
| 238 |
+
record("Lyapunov energy rises under NO_OP",
|
| 239 |
+
PASS if v_last5 > v_first5 else FAIL,
|
| 240 |
+
f"early_avg={v_first5:.1f} late_avg={v_last5:.1f}")
|
| 241 |
+
|
| 242 |
+
print(f" [i] SLA violations: {ep['sla_violations']}/60")
|
| 243 |
+
print(f" [i] Avg norm reward: {sum(ep['rewards_norm'])/len(ep['rewards_norm']):.4f}")
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def test_task2_fault():
|
| 247 |
+
"""Task-2: a node fails, queues react, reroute reduces load on failed node."""
|
| 248 |
+
print("\n--- Task-2: Fault Tolerance ---")
|
| 249 |
+
sim = ClusterSimulator(n_nodes=5, task_id="task-2")
|
| 250 |
+
ep = run_episode(sim, "task-2", max_steps=60, seed=42, action_policy="noop")
|
| 251 |
+
|
| 252 |
+
# At least one node should be FAILED by end (scripted failure)
|
| 253 |
+
failed = [n for n in ep["final_state"] if n["status"] == "FAILED"]
|
| 254 |
+
record("Scripted failure occurs",
|
| 255 |
+
PASS if len(failed) >= 1 else FAIL,
|
| 256 |
+
f"failed_nodes={len(failed)}")
|
| 257 |
+
|
| 258 |
+
# node-0 should NOT be the failed one (excluded from failure pool)
|
| 259 |
+
failed_ids = [n["node_id"] for n in failed]
|
| 260 |
+
record("node-0 not in failed set",
|
| 261 |
+
PASS if "node-0" not in failed_ids else FAIL,
|
| 262 |
+
f"failed_ids={failed_ids}")
|
| 263 |
+
|
| 264 |
+
# Rewards may plateau under NO_OP on constant-load tasks.
|
| 265 |
+
# Task-2 has fixed lambda, so steady-state reward has very low variance.
|
| 266 |
+
# This is expected — active policies (scale/reroute) create variation.
|
| 267 |
+
record("Raw rewards produced (may plateau under NO_OP)",
|
| 268 |
+
PASS if len(ep['rewards_raw']) == 60 else FAIL,
|
| 269 |
+
f"steps={len(ep['rewards_raw'])}")
|
| 270 |
+
|
| 271 |
+
# More importantly, normalized rewards should differ from 0.5 midpoint
|
| 272 |
+
# (proving the raw reward signal is non-trivial)
|
| 273 |
+
avg_norm = sum(ep['rewards_norm']) / len(ep['rewards_norm'])
|
| 274 |
+
record("Normalized reward is non-trivial (not stuck at 0.5)",
|
| 275 |
+
PASS if abs(avg_norm - 0.5) > 0.01 else FAIL,
|
| 276 |
+
f"avg_norm={avg_norm:.4f}")
|
| 277 |
+
|
| 278 |
+
# Normalized rewards in [0, 1]
|
| 279 |
+
all_in_range = all(0.0 <= r <= 1.0 for r in ep["rewards_norm"])
|
| 280 |
+
record("Normalized rewards in [0,1]",
|
| 281 |
+
PASS if all_in_range else FAIL,
|
| 282 |
+
f"min={min(ep['rewards_norm']):.4f} max={max(ep['rewards_norm']):.4f}")
|
| 283 |
+
|
| 284 |
+
# No NaN / inf
|
| 285 |
+
has_nan = any(math.isnan(r) or math.isinf(r) for r in ep["rewards_raw"])
|
| 286 |
+
record("No NaN/inf in raw rewards",
|
| 287 |
+
PASS if not has_nan else FAIL, "")
|
| 288 |
+
|
| 289 |
+
# Now test with targeted reroute on the scripted-failed node
|
| 290 |
+
# (NOT all nodes — rerouting everything to node-0 kills it)
|
| 291 |
+
sim2 = ClusterSimulator(n_nodes=5, task_id="task-2", seed=99)
|
| 292 |
+
sim2.reset(task_id="task-2", seed=99)
|
| 293 |
+
scripted_fail_id = None
|
| 294 |
+
for step in range(1, 61):
|
| 295 |
+
sim2.tick()
|
| 296 |
+
# Check if the scripted failure has been assigned
|
| 297 |
+
if sim2._failed_node_id and scripted_fail_id is None:
|
| 298 |
+
scripted_fail_id = sim2._failed_node_id
|
| 299 |
+
# Apply reroute specifically to the failed node
|
| 300 |
+
class _A:
|
| 301 |
+
pass
|
| 302 |
+
a = _A()
|
| 303 |
+
a.action_type = "REROUTE_TRAFFIC"
|
| 304 |
+
a.target_node_id = scripted_fail_id
|
| 305 |
+
a.parameter = 1.0
|
| 306 |
+
sim2.apply_action(a)
|
| 307 |
+
# Tick once more to see the effect
|
| 308 |
+
sim2.tick()
|
| 309 |
+
failed_node = next((n for n in sim2._nodes if n.node_id == scripted_fail_id), None)
|
| 310 |
+
base_share = sim2._t2_init_lambda / sim2._n_nodes
|
| 311 |
+
record("Reroute reduces failed node traffic",
|
| 312 |
+
PASS if failed_node.incoming_request_rate < base_share else FAIL,
|
| 313 |
+
f"node={scripted_fail_id} incoming={failed_node.incoming_request_rate:.1f} base_share={base_share:.1f}")
|
| 314 |
+
break
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def test_task3_surge():
|
| 318 |
+
"""Task-3: surge hits node-1/node-2, SHED_LOAD on critical nodes rejected."""
|
| 319 |
+
print("\n--- Task-3: Periodic Surge ---")
|
| 320 |
+
sim = ClusterSimulator(n_nodes=5, task_id="task-3")
|
| 321 |
+
ep = run_episode(sim, "task-3", max_steps=60, seed=42, action_policy="noop")
|
| 322 |
+
|
| 323 |
+
# Rewards non-degenerate
|
| 324 |
+
unique_raw = len(set(round(r, 6) for r in ep["rewards_raw"]))
|
| 325 |
+
record("Raw rewards vary",
|
| 326 |
+
PASS if unique_raw > 5 else FAIL,
|
| 327 |
+
f"unique values={unique_raw}/{len(ep['rewards_raw'])}")
|
| 328 |
+
|
| 329 |
+
# Normalized rewards in [0, 1]
|
| 330 |
+
all_in_range = all(0.0 <= r <= 1.0 for r in ep["rewards_norm"])
|
| 331 |
+
record("Normalized rewards in [0,1]",
|
| 332 |
+
PASS if all_in_range else FAIL,
|
| 333 |
+
f"min={min(ep['rewards_norm']):.4f} max={max(ep['rewards_norm']):.4f}")
|
| 334 |
+
|
| 335 |
+
# No NaN / inf
|
| 336 |
+
has_nan = any(math.isnan(r) or math.isinf(r) for r in ep["rewards_raw"])
|
| 337 |
+
record("No NaN/inf in raw rewards",
|
| 338 |
+
PASS if not has_nan else FAIL, "")
|
| 339 |
+
|
| 340 |
+
# Test SHED_LOAD rejection on critical nodes
|
| 341 |
+
sim3 = ClusterSimulator(n_nodes=5, task_id="task-3", seed=7)
|
| 342 |
+
sim3.reset(task_id="task-3", seed=7)
|
| 343 |
+
for critical_id in CRITICAL_NODES:
|
| 344 |
+
class _A:
|
| 345 |
+
pass
|
| 346 |
+
a = _A()
|
| 347 |
+
a.action_type = "SHED_LOAD"
|
| 348 |
+
a.target_node_id = critical_id
|
| 349 |
+
a.parameter = 0.5
|
| 350 |
+
sim3.apply_action(a)
|
| 351 |
+
record("SHED_LOAD on critical nodes rejected",
|
| 352 |
+
PASS if sim3.invalid_action_count == len(CRITICAL_NODES) else FAIL,
|
| 353 |
+
f"invalid_count={sim3.invalid_action_count} expected={len(CRITICAL_NODES)}")
|
| 354 |
+
|
| 355 |
+
# SHED_LOAD on non-critical should be allowed
|
| 356 |
+
class _A2:
|
| 357 |
+
pass
|
| 358 |
+
a2 = _A2()
|
| 359 |
+
a2.action_type = "SHED_LOAD"
|
| 360 |
+
a2.target_node_id = "node-5"
|
| 361 |
+
a2.parameter = 0.5
|
| 362 |
+
sim3.apply_action(a2)
|
| 363 |
+
record("SHED_LOAD on non-critical node allowed",
|
| 364 |
+
PASS if sim3.invalid_action_count == len(CRITICAL_NODES) else FAIL,
|
| 365 |
+
f"invalid_count={sim3.invalid_action_count}")
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
def test_scale_up_down():
|
| 369 |
+
"""SCALE_UP increases capacity after boot delay; SCALE_DOWN decreases it."""
|
| 370 |
+
print("\n--- Scale Up / Scale Down ---")
|
| 371 |
+
sim = ClusterSimulator(n_nodes=5, task_id="task-1", seed=1)
|
| 372 |
+
sim.reset(task_id="task-1", seed=1)
|
| 373 |
+
|
| 374 |
+
# SCALE_UP node-3
|
| 375 |
+
class _A:
|
| 376 |
+
pass
|
| 377 |
+
a = _A()
|
| 378 |
+
a.action_type = "SCALE_UP"
|
| 379 |
+
a.target_node_id = "node-3"
|
| 380 |
+
a.parameter = 1.0 # 1 * MAX_SCALING_STEP=3 → 3 units
|
| 381 |
+
sim.apply_action(a)
|
| 382 |
+
|
| 383 |
+
# Check pending capacity before boot
|
| 384 |
+
node3 = next(n for n in sim._nodes if n.node_id == "node-3")
|
| 385 |
+
record("Pending capacity queued after SCALE_UP",
|
| 386 |
+
PASS if len(node3.pending_capacity_queue) > 0 else FAIL,
|
| 387 |
+
f"pending={len(node3.pending_capacity_queue)}")
|
| 388 |
+
|
| 389 |
+
# Tick through boot delay
|
| 390 |
+
for _ in range(6):
|
| 391 |
+
sim.tick()
|
| 392 |
+
|
| 393 |
+
node3 = next(n for n in sim._nodes if n.node_id == "node-3")
|
| 394 |
+
record("Capacity goes live after boot delay",
|
| 395 |
+
PASS if node3.capacity > DEFAULT_CAPACITY else FAIL,
|
| 396 |
+
f"capacity={node3.capacity}")
|
| 397 |
+
|
| 398 |
+
# SCALE_DOWN
|
| 399 |
+
prev_cap = node3.capacity
|
| 400 |
+
class _A2:
|
| 401 |
+
pass
|
| 402 |
+
a2 = _A2()
|
| 403 |
+
a2.action_type = "SCALE_DOWN"
|
| 404 |
+
a2.target_node_id = "node-3"
|
| 405 |
+
a2.parameter = 0.5
|
| 406 |
+
sim.apply_action(a2)
|
| 407 |
+
record("SCALE_DOWN reduces capacity",
|
| 408 |
+
PASS if node3.capacity < prev_cap else FAIL,
|
| 409 |
+
f"before={prev_cap} after={node3.capacity}")
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
def test_reward_sanity():
|
| 413 |
+
"""Detailed reward component sanity checks."""
|
| 414 |
+
print("\n--- Reward Sanity ---")
|
| 415 |
+
|
| 416 |
+
# Test normalize_reward mapping
|
| 417 |
+
r0 = normalize_reward(0.0)
|
| 418 |
+
record("normalize_reward(0.0) in [0,1]",
|
| 419 |
+
PASS if 0.0 <= r0 <= 1.0 else FAIL,
|
| 420 |
+
f"got {r0:.4f}")
|
| 421 |
+
|
| 422 |
+
r_neg = normalize_reward(-100.0)
|
| 423 |
+
r_pos = normalize_reward(100.0)
|
| 424 |
+
record("More negative raw -> lower normalized",
|
| 425 |
+
PASS if r_neg < r_pos else FAIL,
|
| 426 |
+
f"neg={r_neg:.4f} pos={r_pos:.4f}")
|
| 427 |
+
|
| 428 |
+
# Smooth SLA penalty
|
| 429 |
+
p_safe = smooth_sla_penalty(0.05, 0.01) # well below thresholds
|
| 430 |
+
p_danger = smooth_sla_penalty(0.30, 0.10) # above thresholds
|
| 431 |
+
record("SLA penalty: safe < danger",
|
| 432 |
+
PASS if p_safe < p_danger else FAIL,
|
| 433 |
+
f"safe={p_safe:.4f} danger={p_danger:.4f}")
|
| 434 |
+
|
| 435 |
+
# Barrier function
|
| 436 |
+
nodes_ok = [{"queue_depth": 50.0} for _ in range(10)]
|
| 437 |
+
nodes_bad = [{"queue_depth": 200.0} for _ in range(10)]
|
| 438 |
+
b_ok = compute_barrier(nodes_ok)
|
| 439 |
+
b_bad = compute_barrier(nodes_bad)
|
| 440 |
+
record("Barrier: safe queues < overloaded queues",
|
| 441 |
+
PASS if b_ok < b_bad else FAIL,
|
| 442 |
+
f"ok={b_ok:.1f} bad={b_bad:.1f}")
|
| 443 |
+
record("Barrier is 0 when all below Q_BARRIER_MAX",
|
| 444 |
+
PASS if b_ok == 0.0 else FAIL,
|
| 445 |
+
f"got {b_ok:.1f}")
|
| 446 |
+
|
| 447 |
+
# Lyapunov with VIP weight
|
| 448 |
+
nodes_no_vip = [{"queue_depth": 100.0, "importance_weight": 1.0} for _ in range(10)]
|
| 449 |
+
nodes_with_vip = [{"queue_depth": 100.0, "importance_weight": 4.0}] + \
|
| 450 |
+
[{"queue_depth": 100.0, "importance_weight": 1.0} for _ in range(9)]
|
| 451 |
+
v_no_vip = compute_lyapunov(nodes_no_vip)
|
| 452 |
+
v_with_vip = compute_lyapunov(nodes_with_vip)
|
| 453 |
+
record("VIP weight amplifies Lyapunov energy",
|
| 454 |
+
PASS if v_with_vip > v_no_vip else FAIL,
|
| 455 |
+
f"no_vip={v_no_vip:.1f} with_vip={v_with_vip:.1f}")
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
def test_grader_inline():
|
| 459 |
+
"""Inline grader score validation (mirrors grader.py logic without importing it)."""
|
| 460 |
+
print("\n--- Grader Logic (Inline) ---")
|
| 461 |
+
|
| 462 |
+
# Baseline cost for 10 nodes at capacity 3
|
| 463 |
+
BASELINE = 10 * 3 * 0.05 # 1.50
|
| 464 |
+
MAX_COST = 10 * 5 * 0.05 # 2.50
|
| 465 |
+
COST_K = 3.0
|
| 466 |
+
TARGET_ENERGY = 2000.0
|
| 467 |
+
CURVE_POWER = 2.0
|
| 468 |
+
|
| 469 |
+
# Perfectly provisioned: cost == baseline -> score = 1.0
|
| 470 |
+
over_ratio = max(0.0, (BASELINE - BASELINE) / BASELINE)
|
| 471 |
+
cost_score = max(0.0, min(1.0, math.exp(-COST_K * over_ratio)))
|
| 472 |
+
record("Cost score=1.0 at baseline",
|
| 473 |
+
PASS if abs(cost_score - 1.0) < 1e-6 else FAIL,
|
| 474 |
+
f"got {cost_score:.4f}")
|
| 475 |
+
|
| 476 |
+
# 2x over-provisioned: score should be very low
|
| 477 |
+
over_ratio_2x = max(0.0, (2 * BASELINE - BASELINE) / BASELINE)
|
| 478 |
+
cost_score_2x = max(0.0, min(1.0, math.exp(-COST_K * over_ratio_2x)))
|
| 479 |
+
record("Cost score near 0 at 2x baseline",
|
| 480 |
+
PASS if cost_score_2x < 0.1 else FAIL,
|
| 481 |
+
f"got {cost_score_2x:.4f}")
|
| 482 |
+
|
| 483 |
+
# Stability: low energy -> high score
|
| 484 |
+
low_energy = 100.0
|
| 485 |
+
ratio = low_energy / TARGET_ENERGY
|
| 486 |
+
stab_score = 1.0 / (1.0 + ratio ** CURVE_POWER)
|
| 487 |
+
record("Stability score high at low energy",
|
| 488 |
+
PASS if stab_score > 0.9 else FAIL,
|
| 489 |
+
f"energy={low_energy} score={stab_score:.4f}")
|
| 490 |
+
|
| 491 |
+
# Stability: high energy -> low score
|
| 492 |
+
high_energy = 10000.0
|
| 493 |
+
ratio_h = high_energy / TARGET_ENERGY
|
| 494 |
+
stab_score_h = 1.0 / (1.0 + ratio_h ** CURVE_POWER)
|
| 495 |
+
record("Stability score low at high energy",
|
| 496 |
+
PASS if stab_score_h < 0.1 else FAIL,
|
| 497 |
+
f"energy={high_energy} score={stab_score_h:.4f}")
|
| 498 |
+
|
| 499 |
+
|
| 500 |
+
def test_curriculum_tracker():
|
| 501 |
+
"""Curriculum tracker advances stages on passing scores."""
|
| 502 |
+
print("\n--- Curriculum Tracker ---")
|
| 503 |
+
tracker = CurriculumTracker()
|
| 504 |
+
|
| 505 |
+
record("Starts at stage 0",
|
| 506 |
+
PASS if tracker.current_index == 0 else FAIL,
|
| 507 |
+
f"idx={tracker.current_index}")
|
| 508 |
+
|
| 509 |
+
record(f"Total stages = {len(CURRICULUM)}",
|
| 510 |
+
PASS if len(CURRICULUM) == 10 else FAIL,
|
| 511 |
+
f"got {len(CURRICULUM)}")
|
| 512 |
+
|
| 513 |
+
# Pass first stage
|
| 514 |
+
stage0 = tracker.current
|
| 515 |
+
passed = tracker.report_score(0.50) # > 0.40 threshold
|
| 516 |
+
record("Pass stage 0 with score 0.50",
|
| 517 |
+
PASS if passed and tracker.current_index == 1 else FAIL,
|
| 518 |
+
f"passed={passed} idx={tracker.current_index}")
|
| 519 |
+
|
| 520 |
+
# Fail stage 1 (needs 0.50)
|
| 521 |
+
passed2 = tracker.report_score(0.30) # < 0.50
|
| 522 |
+
record("Fail stage 1 with score 0.30",
|
| 523 |
+
PASS if not passed2 else FAIL,
|
| 524 |
+
f"passed={passed2} retries={tracker.current.retries}")
|
| 525 |
+
|
| 526 |
+
# Pass on retry
|
| 527 |
+
passed3 = tracker.report_score(0.60)
|
| 528 |
+
record("Pass stage 1 on retry with score 0.60",
|
| 529 |
+
PASS if passed3 and tracker.current_index == 2 else FAIL,
|
| 530 |
+
f"passed={passed3} idx={tracker.current_index}")
|
| 531 |
+
|
| 532 |
+
# Progress summary doesn't crash
|
| 533 |
+
summary = tracker.progress_summary()
|
| 534 |
+
record("progress_summary() returns string",
|
| 535 |
+
PASS if isinstance(summary, str) and len(summary) > 0 else FAIL,
|
| 536 |
+
f"len={len(summary)}")
|
| 537 |
+
|
| 538 |
+
|
| 539 |
+
def test_cascade_and_recovery():
|
| 540 |
+
"""Cascade failure detection and auto-recovery work."""
|
| 541 |
+
print("\n--- Cascade & Recovery ---")
|
| 542 |
+
sim = ClusterSimulator(n_nodes=5, task_id="task-1", seed=1)
|
| 543 |
+
sim.reset(task_id="task-1", seed=1)
|
| 544 |
+
|
| 545 |
+
# Artificially overload a node to trigger failure
|
| 546 |
+
node = sim._nodes[5]
|
| 547 |
+
node.queue_depth = 250.0 # > FATAL_FAIL_THRESHOLD=200
|
| 548 |
+
sim._update_statuses()
|
| 549 |
+
record("Node fails when queue > FATAL_FAIL_THRESHOLD",
|
| 550 |
+
PASS if node.status == NodeStatus.FAILED else FAIL,
|
| 551 |
+
f"status={node.status}")
|
| 552 |
+
|
| 553 |
+
record("Recovery timer set on overload failure",
|
| 554 |
+
PASS if node.recovery_timer > 0 else FAIL,
|
| 555 |
+
f"timer={node.recovery_timer}")
|
| 556 |
+
|
| 557 |
+
# Tick through recovery
|
| 558 |
+
for _ in range(25):
|
| 559 |
+
sim._process_recovery()
|
| 560 |
+
|
| 561 |
+
record("Node recovers after NODE_RECOVERY_TICKS",
|
| 562 |
+
PASS if node.status == NodeStatus.HEALTHY else FAIL,
|
| 563 |
+
f"status={node.status}")
|
| 564 |
+
|
| 565 |
+
|
| 566 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 567 |
+
|
| 568 |
+
def main():
|
| 569 |
+
print("=" * 60)
|
| 570 |
+
print("AntiAtropos Smoke Test — 5-Node Cluster Validation")
|
| 571 |
+
print("=" * 60)
|
| 572 |
+
|
| 573 |
+
test_simulator_node_count()
|
| 574 |
+
test_task1_ramp()
|
| 575 |
+
test_task2_fault()
|
| 576 |
+
test_task3_surge()
|
| 577 |
+
test_scale_up_down()
|
| 578 |
+
test_reward_sanity()
|
| 579 |
+
test_grader_inline()
|
| 580 |
+
test_curriculum_tracker()
|
| 581 |
+
test_cascade_and_recovery()
|
| 582 |
+
|
| 583 |
+
# ── Summary ──
|
| 584 |
+
passed = sum(1 for _, s, _ in results if s == PASS)
|
| 585 |
+
failed = sum(1 for _, s, _ in results if s == FAIL)
|
| 586 |
+
total = len(results)
|
| 587 |
+
|
| 588 |
+
print("\n" + "=" * 60)
|
| 589 |
+
print(f"RESULTS: {passed}/{total} passed, {failed} failed")
|
| 590 |
+
print("=" * 60)
|
| 591 |
+
|
| 592 |
+
if failed > 0:
|
| 593 |
+
print("\nFailed tests:")
|
| 594 |
+
for name, status, detail in results:
|
| 595 |
+
if status == FAIL:
|
| 596 |
+
print(f" X {name}: {detail}")
|
| 597 |
+
|
| 598 |
+
return 0 if failed == 0 else 1
|
| 599 |
+
|
| 600 |
+
|
| 601 |
+
if __name__ == "__main__":
|
| 602 |
+
sys.exit(main())
|
train_test.py
CHANGED
|
@@ -1,538 +1,538 @@
|
|
| 1 |
-
#!/usr/bin/env python3
|
| 2 |
-
"""
|
| 3 |
-
AntiAtropos Training Validation — Local Test.
|
| 4 |
-
|
| 5 |
-
Validates the training pipeline (loss functions, episode collection, trainer)
|
| 6 |
-
using a MockPolicyModel (no GPU needed). Run before going to Colab.
|
| 7 |
-
|
| 8 |
-
Run from project root:
|
| 9 |
-
python train_test.py
|
| 10 |
-
"""
|
| 11 |
-
|
| 12 |
-
import sys
|
| 13 |
-
import os
|
| 14 |
-
import math
|
| 15 |
-
import random
|
| 16 |
-
|
| 17 |
-
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 18 |
-
|
| 19 |
-
from training.losses import (
|
| 20 |
-
compute_returns, compute_gae,
|
| 21 |
-
reinforce_loss, reinforce_baseline_loss,
|
| 22 |
-
grpo_loss, rloo_loss,
|
| 23 |
-
normalize_rewards, compute_reward_stats,
|
| 24 |
-
)
|
| 25 |
-
from training.trainer import (
|
| 26 |
-
SRETrainer, TrainingConfig, EpisodeCollector,
|
| 27 |
-
MockPolicyModel,
|
| 28 |
-
LOSS_REINFORCE, LOSS_REINFORCE_BASELINE, LOSS_GRPO, LOSS_RLOO,
|
| 29 |
-
)
|
| 30 |
-
|
| 31 |
-
PASS = "PASS"
|
| 32 |
-
FAIL = "FAIL"
|
| 33 |
-
results: list[tuple[str, str, str]] = []
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
def record(name: str, status: str, detail: str = "") -> None:
|
| 37 |
-
results.append((name, status, detail))
|
| 38 |
-
icon = "+" if status == PASS else "X"
|
| 39 |
-
msg = f" [{icon}] {name}"
|
| 40 |
-
if detail:
|
| 41 |
-
msg += f" -- {detail}"
|
| 42 |
-
print(msg)
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 46 |
-
# 1. Return Computation
|
| 47 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 48 |
-
|
| 49 |
-
def test_returns():
|
| 50 |
-
print("\n--- Return Computation ---")
|
| 51 |
-
# Simple case: [1, 1, 1] with gamma=0.99
|
| 52 |
-
returns = compute_returns([1.0, 1.0, 1.0], gamma=0.99)
|
| 53 |
-
# G_2 = 1.0, G_1 = 1 + 0.99*1 = 1.99, G_0 = 1 + 0.99*1.99 = 2.9701
|
| 54 |
-
record("compute_returns[0]",
|
| 55 |
-
PASS if abs(returns[0] - 2.9701) < 0.001 else FAIL,
|
| 56 |
-
f"got {returns[0]:.4f} expected 2.9701")
|
| 57 |
-
record("compute_returns[2]",
|
| 58 |
-
PASS if abs(returns[2] - 1.0) < 0.001 else FAIL,
|
| 59 |
-
f"got {returns[2]:.4f} expected 1.0")
|
| 60 |
-
|
| 61 |
-
# Empty rewards
|
| 62 |
-
returns_empty = compute_returns([])
|
| 63 |
-
record("compute_returns handles empty",
|
| 64 |
-
PASS if returns_empty == [] else FAIL,
|
| 65 |
-
f"got {returns_empty}")
|
| 66 |
-
|
| 67 |
-
# Single reward
|
| 68 |
-
returns_single = compute_returns([5.0])
|
| 69 |
-
record("compute_returns single reward",
|
| 70 |
-
PASS if abs(returns_single[0] - 5.0) < 0.001 else FAIL,
|
| 71 |
-
f"got {returns_single[0]:.4f}")
|
| 72 |
-
|
| 73 |
-
# Discount factor = 0 → only immediate reward matters
|
| 74 |
-
returns_0 = compute_returns([1.0, 2.0, 3.0], gamma=0.0)
|
| 75 |
-
record("gamma=0: returns = rewards",
|
| 76 |
-
PASS if returns_0 == [1.0, 2.0, 3.0] else FAIL,
|
| 77 |
-
f"got {returns_0}")
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 81 |
-
# 2. GAE Computation
|
| 82 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 83 |
-
|
| 84 |
-
def test_gae():
|
| 85 |
-
print("\n--- GAE Computation ---")
|
| 86 |
-
# With V=0, GAE reduces to discounted returns
|
| 87 |
-
rewards = [1.0, 1.0, 1.0]
|
| 88 |
-
values = [0.0, 0.0, 0.0]
|
| 89 |
-
gae = compute_gae(rewards, values, gamma=0.99, lam=1.0)
|
| 90 |
-
returns = compute_returns(rewards, gamma=0.99)
|
| 91 |
-
record("GAE with V=0, lam=1 equals returns",
|
| 92 |
-
PASS if all(abs(g - r) < 0.01 for g, r in zip(gae, returns)) else FAIL,
|
| 93 |
-
f"gae={[round(g,2) for g in gae]} returns={[round(r,2) for r in returns]}")
|
| 94 |
-
|
| 95 |
-
# With lam=0, GAE reduces to one-step TD
|
| 96 |
-
gae_td = compute_gae(rewards, values, gamma=0.99, lam=0.0)
|
| 97 |
-
# δ_0 = r_0 + γ*V(s_1) - V(s_0) = 1.0 + 0.99*0 - 0 = 1.0
|
| 98 |
-
record("GAE with lam=0 is one-step TD",
|
| 99 |
-
PASS if abs(gae_td[0] - 1.0) < 0.001 else FAIL,
|
| 100 |
-
f"got {gae_td[0]:.4f} expected 1.0")
|
| 101 |
-
|
| 102 |
-
# With non-zero values, advantage is return minus value
|
| 103 |
-
values2 = [2.0, 1.0, 0.5]
|
| 104 |
-
gae2 = compute_gae([1.0, 1.0, 1.0], values2, gamma=0.99, lam=1.0)
|
| 105 |
-
record("GAE with values produces non-trivial advantages",
|
| 106 |
-
PASS if len(gae2) == 3 and any(abs(g) > 0.1 for g in gae2) else FAIL,
|
| 107 |
-
f"gae={[round(g,3) for g in gae2]}")
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 111 |
-
# 3. REINFORCE Loss
|
| 112 |
-
# ═══════════════════════════════════════════════════════════
|
| 113 |
-
|
| 114 |
-
def test_reinforce():
|
| 115 |
-
print("\n--- REINFORCE Loss ---")
|
| 116 |
-
# Known values: log_probs=[-1, -2, -3], returns=[10, 5, 1]
|
| 117 |
-
# loss = -(1/3) * ((-1)*10 + (-2)*5 + (-3)*1) = -(1/3)*(-10-10-3) = -(1/3)*(-23) = 7.667
|
| 118 |
-
log_probs = [-1.0, -2.0, -3.0]
|
| 119 |
-
returns = [10.0, 5.0, 1.0]
|
| 120 |
-
loss = reinforce_loss(log_probs, returns)
|
| 121 |
-
expected = -((-1.0)*10 + (-2.0)*5 + (-3.0)*1) / 3
|
| 122 |
-
record("REINFORCE loss matches manual calculation",
|
| 123 |
-
PASS if abs(loss - expected) < 0.001 else FAIL,
|
| 124 |
-
f"got {loss:.4f} expected {expected:.4f}")
|
| 125 |
-
|
| 126 |
-
# Higher returns should produce higher loss (more gradient push)
|
| 127 |
-
returns_high = [20.0, 10.0, 2.0]
|
| 128 |
-
loss_high = reinforce_loss(log_probs, returns_high)
|
| 129 |
-
record("Higher returns → higher loss magnitude",
|
| 130 |
-
PASS if abs(loss_high) > abs(loss) else FAIL,
|
| 131 |
-
f"low={abs(loss):.4f} high={abs(loss_high):.4f}")
|
| 132 |
-
|
| 133 |
-
# Empty episode
|
| 134 |
-
loss_empty = reinforce_loss([], [])
|
| 135 |
-
record("REINFORCE handles empty episode",
|
| 136 |
-
PASS if loss_empty == 0.0 else FAIL,
|
| 137 |
-
f"got {loss_empty}")
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 141 |
-
# 4. REINFORCE + Baseline Loss
|
| 142 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 143 |
-
|
| 144 |
-
def test_reinforce_baseline():
|
| 145 |
-
print("\n--- REINFORCE + Baseline Loss ---")
|
| 146 |
-
log_probs = [-1.0, -2.0, -3.0]
|
| 147 |
-
returns = [10.0, 5.0, 1.0]
|
| 148 |
-
|
| 149 |
-
# With baselines=None, uses mean(returns)=5.33 as baseline
|
| 150 |
-
loss_b = reinforce_baseline_loss(log_probs, returns, baselines=None, normalize_advantage=False)
|
| 151 |
-
# advantages = [10-5.33, 5-5.33, 1-5.33] = [4.67, -0.33, -4.33]
|
| 152 |
-
# loss = -(1/3) * ((-1)*4.67 + (-2)*(-0.33) + (-3)*(-4.33))
|
| 153 |
-
# = -(1/3) * (-4.67 + 0.67 + 13.0)
|
| 154 |
-
# = -(1/3) * 9.0 = -3.0
|
| 155 |
-
mean_r = sum(returns) / len(returns)
|
| 156 |
-
advantages = [g - mean_r for g in returns]
|
| 157 |
-
expected = -sum(lp * adv for lp, adv in zip(log_probs, advantages)) / 3
|
| 158 |
-
record("REINFORCE+baseline matches manual calc",
|
| 159 |
-
PASS if abs(loss_b - expected) < 0.01 else FAIL,
|
| 160 |
-
f"got {loss_b:.4f} expected {expected:.4f}")
|
| 161 |
-
|
| 162 |
-
# With normalize_advantage=True, advantages are standardized
|
| 163 |
-
loss_norm = reinforce_baseline_loss(log_probs, returns, baselines=None, normalize_advantage=True)
|
| 164 |
-
record("Normalized advantage produces valid loss",
|
| 165 |
-
PASS if not math.isnan(loss_norm) and not math.isinf(loss_norm) else FAIL,
|
| 166 |
-
f"loss={loss_norm:.4f}")
|
| 167 |
-
|
| 168 |
-
# Baseline should reduce loss magnitude vs vanilla REINFORCE
|
| 169 |
-
loss_vanilla = reinforce_loss(log_probs, returns)
|
| 170 |
-
record("Baseline typically reduces loss magnitude",
|
| 171 |
-
PASS if abs(loss_norm) < abs(loss_vanilla) or True else FAIL,
|
| 172 |
-
f"vanilla={abs(loss_vanilla):.4f} baseline={abs(loss_norm):.4f} (varies)")
|
| 173 |
-
|
| 174 |
-
# Custom baselines
|
| 175 |
-
baselines = [9.0, 4.0, 0.5]
|
| 176 |
-
loss_custom = reinforce_baseline_loss(log_probs, returns, baselines=baselines, normalize_advantage=False)
|
| 177 |
-
advantages_custom = [g - b for g, b in zip(returns, baselines)]
|
| 178 |
-
expected_custom = -sum(lp * adv for lp, adv in zip(log_probs, advantages_custom)) / 3
|
| 179 |
-
record("Custom baselines work correctly",
|
| 180 |
-
PASS if abs(loss_custom - expected_custom) < 0.01 else FAIL,
|
| 181 |
-
f"got {loss_custom:.4f} expected {expected_custom:.4f}")
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 185 |
-
# 5. GRPO Loss
|
| 186 |
-
# ═══════════════════════════════════════════════════════════════════════════════
|
| 187 |
-
|
| 188 |
-
def test_grpo():
|
| 189 |
-
print("\n--- GRPO Loss ---")
|
| 190 |
-
# Group of 3 samples for one state
|
| 191 |
-
log_probs_groups = [[-1.0, -2.0, -1.5]]
|
| 192 |
-
rewards_groups = [[10.0, 5.0, 8.0]]
|
| 193 |
-
|
| 194 |
-
loss = grpo_loss(log_probs_groups, rewards_groups)
|
| 195 |
-
record("GRPO produces valid loss",
|
| 196 |
-
PASS if not math.isnan(loss) and not math.isinf(loss) else FAIL,
|
| 197 |
-
f"loss={loss:.4f}")
|
| 198 |
-
|
| 199 |
-
# The highest-reward sample should get positive advantage,
|
| 200 |
-
# lowest-reward should get negative advantage
|
| 201 |
-
mean_r = sum(rewards_groups[0]) / 3 # 7.67
|
| 202 |
-
std_r = math.sqrt(sum((r - mean_r)**2 for r in rewards_groups[0]) / 3)
|
| 203 |
-
advantages = [(r - mean_r) / (std_r + 1e-8) for r in rewards_groups[0]]
|
| 204 |
-
record("GRPO: highest reward gets positive advantage",
|
| 205 |
-
PASS if advantages[0] > 0 else FAIL,
|
| 206 |
-
f"adv={advantages[0]:.4f}")
|
| 207 |
-
record("GRPO: lowest reward gets negative advantage",
|
| 208 |
-
PASS if advantages[1] < 0 else FAIL,
|
| 209 |
-
f"adv={advantages[1]:.4f}")
|
| 210 |
-
|
| 211 |
-
# Multiple groups
|
| 212 |
-
log_probs_2 = [[-1.0, -2.0], [-1.5, -1.5]]
|
| 213 |
-
rewards_2 = [[10.0, 5.0], [3.0, 7.0]]
|
| 214 |
-
loss_2 = grpo_loss(log_probs_2, rewards_2)
|
| 215 |
-
record("GRPO handles multiple groups",
|
| 216 |
-
PASS if not math.isnan(loss_2) else FAIL,
|
| 217 |
-
f"loss={loss_2:.4f}")
|
| 218 |
-
|
| 219 |
-
# Empty groups
|
| 220 |
-
loss_empty = grpo_loss([], [])
|
| 221 |
-
record("GRPO handles empty input",
|
| 222 |
-
PASS if loss_empty == 0.0 else FAIL,
|
| 223 |
-
f"got {loss_empty}")
|
| 224 |
-
|
| 225 |
-
# Identical rewards → zero advantage → zero loss
|
| 226 |
-
loss_identical = grpo_loss([[-1.0, -2.0, -3.0]], [[5.0, 5.0, 5.0]])
|
| 227 |
-
record("GRPO: identical rewards → near-zero loss",
|
| 228 |
-
PASS if abs(loss_identical) < 1e-4 else FAIL,
|
| 229 |
-
f"loss={loss_identical:.6f}")
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 233 |
-
# 6. RLOO Loss
|
| 234 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 235 |
-
|
| 236 |
-
def test_rloo():
|
| 237 |
-
print("\n--- RLOO Loss ---")
|
| 238 |
-
# Group of 3 samples
|
| 239 |
-
log_probs_groups = [[-1.0, -2.0, -1.5]]
|
| 240 |
-
rewards_groups = [[10.0, 5.0, 8.0]]
|
| 241 |
-
|
| 242 |
-
loss = rloo_loss(log_probs_groups, rewards_groups)
|
| 243 |
-
record("RLOO produces valid loss",
|
| 244 |
-
PASS if not math.isnan(loss) and not math.isinf(loss) else FAIL,
|
| 245 |
-
f"loss={loss:.4f}")
|
| 246 |
-
|
| 247 |
-
# Leave-one-out baselines
|
| 248 |
-
# For r=10: baseline = (5+8)/2 = 6.5, advantage = 10-6.5 = 3.5
|
| 249 |
-
# For r=5: baseline = (10+8)/2 = 9.0, advantage = 5-9.0 = -4.0
|
| 250 |
-
# For r=8: baseline = (10+5)/2 = 7.5, advantage = 8-7.5 = 0.5
|
| 251 |
-
baselines = [6.5, 9.0, 7.5]
|
| 252 |
-
advantages = [10-6.5, 5-9.0, 8-7.5]
|
| 253 |
-
expected = -sum(lp * adv for lp, adv in zip(log_probs_groups[0], advantages)) / 3
|
| 254 |
-
record("RLOO matches manual calculation",
|
| 255 |
-
PASS if abs(loss - expected) < 0.01 else FAIL,
|
| 256 |
-
f"got {loss:.4f} expected {expected:.4f}")
|
| 257 |
-
|
| 258 |
-
# Single sample: falls back to REINFORCE
|
| 259 |
-
loss_single = rloo_loss([[-1.0]], [[5.0]])
|
| 260 |
-
expected_single = -(-1.0) * 5.0 # REINFORCE on one sample
|
| 261 |
-
record("RLOO K=1 falls back to REINFORCE",
|
| 262 |
-
PASS if abs(loss_single - expected_single) < 0.01 else FAIL,
|
| 263 |
-
f"got {loss_single:.4f} expected {expected_single:.4f}")
|
| 264 |
-
|
| 265 |
-
# K=2: simplest meaningful RLOO
|
| 266 |
-
loss_k2 = rloo_loss([[-1.0, -2.0]], [[10.0, 5.0]])
|
| 267 |
-
# baseline for r=10: 5.0, adv=5.0
|
| 268 |
-
# baseline for r=5: 10.0, adv=-5.0
|
| 269 |
-
# loss = -(1/2) * ((-1)*5 + (-2)*(-5)) = -(1/2)*(-5+10) = -2.5
|
| 270 |
-
record("RLOO K=2 produces valid loss",
|
| 271 |
-
PASS if not math.isnan(loss_k2) else FAIL,
|
| 272 |
-
f"loss={loss_k2:.4f}")
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 276 |
-
# 7. Reward Normalization
|
| 277 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 278 |
-
|
| 279 |
-
def test_reward_normalization():
|
| 280 |
-
print("\n--- Reward Normalization ---")
|
| 281 |
-
raw = [-0.5, -1.0, -0.3, -2.0, -0.8]
|
| 282 |
-
mean, var = compute_reward_stats(raw)
|
| 283 |
-
record("Reward stats computed",
|
| 284 |
-
PASS if abs(mean - (-0.92)) < 0.01 else FAIL,
|
| 285 |
-
f"mean={mean:.4f} var={var:.4f}")
|
| 286 |
-
|
| 287 |
-
normed = normalize_rewards(raw, mean, var)
|
| 288 |
-
record("Normalized rewards have near-zero mean",
|
| 289 |
-
PASS if abs(sum(normed)/len(normed)) < 0.01 else FAIL,
|
| 290 |
-
f"mean={sum(normed)/len(normed):.4f}")
|
| 291 |
-
|
| 292 |
-
norm_var = sum((n - sum(normed)/len(normed))**2 for n in normed) / len(normed)
|
| 293 |
-
record("Normalized rewards have near-unit variance",
|
| 294 |
-
PASS if abs(norm_var - 1.0) < 0.01 else FAIL,
|
| 295 |
-
f"var={norm_var:.4f}")
|
| 296 |
-
|
| 297 |
-
# Identity: normalizing with mean=0, var=1 should leave rewards unchanged
|
| 298 |
-
identity = normalize_rewards(raw, 0.0, 1.0)
|
| 299 |
-
record("Identity normalization (mean=0, var=1)",
|
| 300 |
-
PASS if all(abs(a - b) < 0.01 for a, b in zip(raw, identity)) else FAIL,
|
| 301 |
-
f"max_diff={max(abs(a-b) for a,b in zip(raw,identity)):.4f}")
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 305 |
-
# 8. Loss Function Comparison
|
| 306 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 307 |
-
|
| 308 |
-
def test_loss_comparison():
|
| 309 |
-
"""Compare all 4 loss functions on the same episode data."""
|
| 310 |
-
print("\n--- Loss Function Comparison ---")
|
| 311 |
-
log_probs = [-2.0, -1.5, -3.0, -1.0, -2.5]
|
| 312 |
-
returns = [0.8, 0.3, 0.1, 0.5, 0.2]
|
| 313 |
-
|
| 314 |
-
l_reinforce = reinforce_loss(log_probs, returns)
|
| 315 |
-
l_baseline = reinforce_baseline_loss(log_probs, returns, normalize_advantage=True)
|
| 316 |
-
|
| 317 |
-
# GRPO: treat each step as its own "group" of size 1
|
| 318 |
-
# (Not how GRPO is normally used, but tests the pipeline)
|
| 319 |
-
lps_groups = [[lp] for lp in log_probs]
|
| 320 |
-
rs_groups = [[r] for r in returns]
|
| 321 |
-
l_grpo = grpo_loss(lps_groups, rs_groups)
|
| 322 |
-
l_rloo = rloo_loss(lps_groups, rs_groups)
|
| 323 |
-
|
| 324 |
-
record("All 4 losses produce valid values",
|
| 325 |
-
PASS if all(not math.isnan(l) and not math.isinf(l)
|
| 326 |
-
for l in [l_reinforce, l_baseline, l_grpo, l_rloo]) else FAIL,
|
| 327 |
-
f"R={l_reinforce:.4f} RB={l_baseline:.4f} GRPO={l_grpo:.4f} RLOO={l_rloo:.4f}")
|
| 328 |
-
|
| 329 |
-
print(f" [i] REINFORCE: {l_reinforce:.6f}")
|
| 330 |
-
print(f" [i] REINFORCE+baseline: {l_baseline:.6f}")
|
| 331 |
-
print(f" [i] GRPO (K=1): {l_grpo:.6f}")
|
| 332 |
-
print(f" [i] RLOO (K=1): {l_rloo:.6f}")
|
| 333 |
-
|
| 334 |
-
# Now with proper K=4 groups
|
| 335 |
-
log_probs_4 = [[-1.0, -2.0, -1.5, -3.0]]
|
| 336 |
-
rewards_4 = [[0.8, 0.2, 0.5, 0.1]]
|
| 337 |
-
l_grpo_4 = grpo_loss(log_probs_4, rewards_4)
|
| 338 |
-
l_rloo_4 = rloo_loss(log_probs_4, rewards_4)
|
| 339 |
-
record("GRPO/RLOO with K=4 produce valid losses",
|
| 340 |
-
PASS if not math.isnan(l_grpo_4) and not math.isnan(l_rloo_4) else FAIL,
|
| 341 |
-
f"GRPO={l_grpo_4:.4f} RLOO={l_rloo_4:.4f}")
|
| 342 |
-
print(f" [i] GRPO (K=4): {l_grpo_4:.6f}")
|
| 343 |
-
print(f" [i] RLOO (K=4): {l_rloo_4:.6f}")
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 347 |
-
# 9. Episode Collection (with MockPolicyModel)
|
| 348 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 349 |
-
|
| 350 |
-
def test_episode_collection():
|
| 351 |
-
print("\n--- Episode Collection (MockPolicyModel) ---")
|
| 352 |
-
config = TrainingConfig(n_nodes=5, max_steps=30)
|
| 353 |
-
collector = EpisodeCollector(config)
|
| 354 |
-
model = MockPolicyModel(n_nodes=5, seed=42)
|
| 355 |
-
|
| 356 |
-
episode = collector.collect_episode(model, task_id="task-1", seed=42)
|
| 357 |
-
|
| 358 |
-
record("Episode has correct number of steps",
|
| 359 |
-
PASS if len(episode.steps) == 30 else FAIL,
|
| 360 |
-
f"steps={len(episode.steps)}")
|
| 361 |
-
|
| 362 |
-
record("All log probs are valid",
|
| 363 |
-
PASS if all(not math.isnan(s.log_prob) for s in episode.steps) else FAIL,
|
| 364 |
-
f"min_lp={min(s.log_prob for s in episode.steps):.4f}")
|
| 365 |
-
|
| 366 |
-
record("Rewards are finite",
|
| 367 |
-
PASS if all(math.isfinite(s.reward) for s in episode.steps) else FAIL,
|
| 368 |
-
f"min_r={min(s.reward for s in episode.steps):.4f}")
|
| 369 |
-
|
| 370 |
-
record("Normalized rewards in [0,1]",
|
| 371 |
-
PASS if all(0.0 <= s.reward_normalized <= 1.0 for s in episode.steps) else FAIL,
|
| 372 |
-
f"range=[{min(s.reward_normalized for s in episode.steps):.4f}, "
|
| 373 |
-
f"{max(s.reward_normalized for s in episode.steps):.4f}]")
|
| 374 |
-
|
| 375 |
-
record("Total reward is computed",
|
| 376 |
-
PASS if math.isfinite(episode.total_reward) else FAIL,
|
| 377 |
-
f"total={episode.total_reward:.4f}")
|
| 378 |
-
|
| 379 |
-
record("SLA violations tracked",
|
| 380 |
-
PASS if isinstance(episode.sla_violations, int) else FAIL,
|
| 381 |
-
f"violations={episode.sla_violations}")
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 385 |
-
# 10. Full Training Step (per loss function)
|
| 386 |
-
# ═══════════════════════════════════════════════════════════════════════════════
|
| 387 |
-
|
| 388 |
-
def test_training_steps():
|
| 389 |
-
"""Run one training step with each loss function."""
|
| 390 |
-
print("\n--- Full Training Steps ---")
|
| 391 |
-
model = MockPolicyModel(n_nodes=5, seed=42)
|
| 392 |
-
|
| 393 |
-
for loss_name in [LOSS_REINFORCE, LOSS_REINFORCE_BASELINE, LOSS_GRPO, LOSS_RLOO]:
|
| 394 |
-
config = TrainingConfig(
|
| 395 |
-
n_nodes=5,
|
| 396 |
-
max_steps=30,
|
| 397 |
-
loss_fn=loss_name,
|
| 398 |
-
n_samples_per_state=2 if loss_name in (LOSS_GRPO, LOSS_RLOO) else 1,
|
| 399 |
-
)
|
| 400 |
-
trainer = SRETrainer(config)
|
| 401 |
-
metrics = trainer.train_step(model, task_id="task-1", seed=42)
|
| 402 |
-
|
| 403 |
-
record(f"{loss_name}: loss is valid",
|
| 404 |
-
PASS if math.isfinite(metrics["loss"]) else FAIL,
|
| 405 |
-
f"loss={metrics['loss']:.4f}")
|
| 406 |
-
|
| 407 |
-
record(f"{loss_name}: avg_reward is valid",
|
| 408 |
-
PASS if math.isfinite(metrics["avg_reward"]) else FAIL,
|
| 409 |
-
f"avg_reward={metrics['avg_reward']:.4f}")
|
| 410 |
-
|
| 411 |
-
record(f"{loss_name}: episode completed",
|
| 412 |
-
PASS if metrics["episode_length"] > 0 else FAIL,
|
| 413 |
-
f"length={metrics['episode_length']}")
|
| 414 |
-
|
| 415 |
-
# No NaN/inf in running stats
|
| 416 |
-
record(f"{loss_name}: running stats stable",
|
| 417 |
-
PASS if math.isfinite(metrics["reward_mean"]) and math.isfinite(metrics["reward_var"]) else FAIL,
|
| 418 |
-
f"mean={metrics['reward_mean']:.4f} var={metrics['reward_var']:.4f}")
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 422 |
-
# 11. Multi-Episode Stability
|
| 423 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 424 |
-
|
| 425 |
-
def test_multi_episode_stability():
|
| 426 |
-
"""Run multiple episodes and check running stats remain stable."""
|
| 427 |
-
print("\n--- Multi-Episode Stability ---")
|
| 428 |
-
config = TrainingConfig(
|
| 429 |
-
n_nodes=5,
|
| 430 |
-
max_steps=30,
|
| 431 |
-
loss_fn=LOSS_REINFORCE_BASELINE,
|
| 432 |
-
tasks=["task-1", "task-2", "task-3"],
|
| 433 |
-
)
|
| 434 |
-
trainer = SRETrainer(config)
|
| 435 |
-
model = MockPolicyModel(n_nodes=5, seed=42)
|
| 436 |
-
|
| 437 |
-
all_losses = []
|
| 438 |
-
for i in range(5):
|
| 439 |
-
for task in config.tasks:
|
| 440 |
-
metrics = trainer.train_step(model, task_id=task, seed=42 + i)
|
| 441 |
-
all_losses.append(metrics["loss"])
|
| 442 |
-
|
| 443 |
-
# No NaN/inf across 15 episodes
|
| 444 |
-
record("15 episodes: all losses finite",
|
| 445 |
-
PASS if all(math.isfinite(l) for l in all_losses) else FAIL,
|
| 446 |
-
f"n_losses={len(all_losses)}")
|
| 447 |
-
|
| 448 |
-
# Losses should vary (different tasks + domain randomization)
|
| 449 |
-
unique_losses = len(set(round(l, 4) for l in all_losses))
|
| 450 |
-
record("Losses vary across episodes",
|
| 451 |
-
PASS if unique_losses > 3 else FAIL,
|
| 452 |
-
f"unique={unique_losses}/{len(all_losses)}")
|
| 453 |
-
|
| 454 |
-
# Running stats should be non-degenerate
|
| 455 |
-
last_metrics = metrics
|
| 456 |
-
record("Running reward mean is non-zero",
|
| 457 |
-
PASS if abs(last_metrics["reward_mean"]) > 0.001 else FAIL,
|
| 458 |
-
f"mean={last_metrics['reward_mean']:.6f}")
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 462 |
-
# 12. SRE-Specific Edge Cases
|
| 463 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 464 |
-
|
| 465 |
-
def test_sre_edge_cases():
|
| 466 |
-
"""Test edge cases specific to the SRE domain."""
|
| 467 |
-
print("\n--- SRE Edge Cases ---")
|
| 468 |
-
|
| 469 |
-
# Very negative rewards (system crashing)
|
| 470 |
-
log_probs = [-2.0] * 10
|
| 471 |
-
returns_crash = [-100.0] * 10
|
| 472 |
-
loss_crash = reinforce_baseline_loss(log_probs, returns_crash, normalize_advantage=True)
|
| 473 |
-
record("Very negative rewards: loss is finite",
|
| 474 |
-
PASS if math.isfinite(loss_crash) else FAIL,
|
| 475 |
-
f"loss={loss_crash:.4f}")
|
| 476 |
-
|
| 477 |
-
# All-zero returns (perfect episode)
|
| 478 |
-
returns_perfect = [0.0] * 10
|
| 479 |
-
loss_perfect = reinforce_baseline_loss(log_probs, returns_perfect, normalize_advantage=False)
|
| 480 |
-
record("Zero returns: loss is zero (no gradient)",
|
| 481 |
-
PASS if abs(loss_perfect) < 0.001 else FAIL,
|
| 482 |
-
f"loss={loss_perfect:.4f}")
|
| 483 |
-
|
| 484 |
-
# Highly variable rewards within episode (surge task)
|
| 485 |
-
returns_surge = [0.5, 0.5, -10.0, -10.0, 0.5, 0.5, -10.0, 0.5, 0.5, 0.5]
|
| 486 |
-
loss_surge = reinforce_baseline_loss(log_probs, returns_surge, normalize_advantage=True)
|
| 487 |
-
record("High-variance rewards: loss is finite with normalization",
|
| 488 |
-
PASS if math.isfinite(loss_surge) else FAIL,
|
| 489 |
-
f"loss={loss_surge:.4f}")
|
| 490 |
-
|
| 491 |
-
# GRPO with very different rewards in group
|
| 492 |
-
lps = [[-1.0, -2.0, -1.5, -3.0]]
|
| 493 |
-
rs_extreme = [[0.9, 0.8, 0.85, 0.05]] # One bad sample
|
| 494 |
-
loss_extreme = grpo_loss(lps, rs_extreme)
|
| 495 |
-
record("GRPO handles outlier in group",
|
| 496 |
-
PASS if math.isfinite(loss_extreme) else FAIL,
|
| 497 |
-
f"loss={loss_extreme:.4f}")
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 501 |
-
|
| 502 |
-
def main():
|
| 503 |
-
print("=" * 60)
|
| 504 |
-
print("AntiAtropos Training Validation")
|
| 505 |
-
print("=" * 60)
|
| 506 |
-
|
| 507 |
-
test_returns()
|
| 508 |
-
test_gae()
|
| 509 |
-
test_reinforce()
|
| 510 |
-
test_reinforce_baseline()
|
| 511 |
-
test_grpo()
|
| 512 |
-
test_rloo()
|
| 513 |
-
test_reward_normalization()
|
| 514 |
-
test_loss_comparison()
|
| 515 |
-
test_episode_collection()
|
| 516 |
-
test_training_steps()
|
| 517 |
-
test_multi_episode_stability()
|
| 518 |
-
test_sre_edge_cases()
|
| 519 |
-
|
| 520 |
-
passed = sum(1 for _, s, _ in results if s == PASS)
|
| 521 |
-
failed = sum(1 for _, s, _ in results if s == FAIL)
|
| 522 |
-
total = len(results)
|
| 523 |
-
|
| 524 |
-
print("\n" + "=" * 60)
|
| 525 |
-
print(f"RESULTS: {passed}/{total} passed, {failed} failed")
|
| 526 |
-
print("=" * 60)
|
| 527 |
-
|
| 528 |
-
if failed > 0:
|
| 529 |
-
print("\nFailed tests:")
|
| 530 |
-
for name, status, detail in results:
|
| 531 |
-
if status == FAIL:
|
| 532 |
-
print(f" X {name}: {detail}")
|
| 533 |
-
|
| 534 |
-
return 0 if failed == 0 else 1
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
if __name__ == "__main__":
|
| 538 |
-
sys.exit(main())
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
AntiAtropos Training Validation — Local Test.
|
| 4 |
+
|
| 5 |
+
Validates the training pipeline (loss functions, episode collection, trainer)
|
| 6 |
+
using a MockPolicyModel (no GPU needed). Run before going to Colab.
|
| 7 |
+
|
| 8 |
+
Run from project root:
|
| 9 |
+
python train_test.py
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import sys
|
| 13 |
+
import os
|
| 14 |
+
import math
|
| 15 |
+
import random
|
| 16 |
+
|
| 17 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 18 |
+
|
| 19 |
+
from training.losses import (
|
| 20 |
+
compute_returns, compute_gae,
|
| 21 |
+
reinforce_loss, reinforce_baseline_loss,
|
| 22 |
+
grpo_loss, rloo_loss,
|
| 23 |
+
normalize_rewards, compute_reward_stats,
|
| 24 |
+
)
|
| 25 |
+
from training.trainer import (
|
| 26 |
+
SRETrainer, TrainingConfig, EpisodeCollector,
|
| 27 |
+
MockPolicyModel,
|
| 28 |
+
LOSS_REINFORCE, LOSS_REINFORCE_BASELINE, LOSS_GRPO, LOSS_RLOO,
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
PASS = "PASS"
|
| 32 |
+
FAIL = "FAIL"
|
| 33 |
+
results: list[tuple[str, str, str]] = []
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def record(name: str, status: str, detail: str = "") -> None:
|
| 37 |
+
results.append((name, status, detail))
|
| 38 |
+
icon = "+" if status == PASS else "X"
|
| 39 |
+
msg = f" [{icon}] {name}"
|
| 40 |
+
if detail:
|
| 41 |
+
msg += f" -- {detail}"
|
| 42 |
+
print(msg)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 46 |
+
# 1. Return Computation
|
| 47 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 48 |
+
|
| 49 |
+
def test_returns():
|
| 50 |
+
print("\n--- Return Computation ---")
|
| 51 |
+
# Simple case: [1, 1, 1] with gamma=0.99
|
| 52 |
+
returns = compute_returns([1.0, 1.0, 1.0], gamma=0.99)
|
| 53 |
+
# G_2 = 1.0, G_1 = 1 + 0.99*1 = 1.99, G_0 = 1 + 0.99*1.99 = 2.9701
|
| 54 |
+
record("compute_returns[0]",
|
| 55 |
+
PASS if abs(returns[0] - 2.9701) < 0.001 else FAIL,
|
| 56 |
+
f"got {returns[0]:.4f} expected 2.9701")
|
| 57 |
+
record("compute_returns[2]",
|
| 58 |
+
PASS if abs(returns[2] - 1.0) < 0.001 else FAIL,
|
| 59 |
+
f"got {returns[2]:.4f} expected 1.0")
|
| 60 |
+
|
| 61 |
+
# Empty rewards
|
| 62 |
+
returns_empty = compute_returns([])
|
| 63 |
+
record("compute_returns handles empty",
|
| 64 |
+
PASS if returns_empty == [] else FAIL,
|
| 65 |
+
f"got {returns_empty}")
|
| 66 |
+
|
| 67 |
+
# Single reward
|
| 68 |
+
returns_single = compute_returns([5.0])
|
| 69 |
+
record("compute_returns single reward",
|
| 70 |
+
PASS if abs(returns_single[0] - 5.0) < 0.001 else FAIL,
|
| 71 |
+
f"got {returns_single[0]:.4f}")
|
| 72 |
+
|
| 73 |
+
# Discount factor = 0 → only immediate reward matters
|
| 74 |
+
returns_0 = compute_returns([1.0, 2.0, 3.0], gamma=0.0)
|
| 75 |
+
record("gamma=0: returns = rewards",
|
| 76 |
+
PASS if returns_0 == [1.0, 2.0, 3.0] else FAIL,
|
| 77 |
+
f"got {returns_0}")
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 81 |
+
# 2. GAE Computation
|
| 82 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 83 |
+
|
| 84 |
+
def test_gae():
|
| 85 |
+
print("\n--- GAE Computation ---")
|
| 86 |
+
# With V=0, GAE reduces to discounted returns
|
| 87 |
+
rewards = [1.0, 1.0, 1.0]
|
| 88 |
+
values = [0.0, 0.0, 0.0]
|
| 89 |
+
gae = compute_gae(rewards, values, gamma=0.99, lam=1.0)
|
| 90 |
+
returns = compute_returns(rewards, gamma=0.99)
|
| 91 |
+
record("GAE with V=0, lam=1 equals returns",
|
| 92 |
+
PASS if all(abs(g - r) < 0.01 for g, r in zip(gae, returns)) else FAIL,
|
| 93 |
+
f"gae={[round(g,2) for g in gae]} returns={[round(r,2) for r in returns]}")
|
| 94 |
+
|
| 95 |
+
# With lam=0, GAE reduces to one-step TD
|
| 96 |
+
gae_td = compute_gae(rewards, values, gamma=0.99, lam=0.0)
|
| 97 |
+
# δ_0 = r_0 + γ*V(s_1) - V(s_0) = 1.0 + 0.99*0 - 0 = 1.0
|
| 98 |
+
record("GAE with lam=0 is one-step TD",
|
| 99 |
+
PASS if abs(gae_td[0] - 1.0) < 0.001 else FAIL,
|
| 100 |
+
f"got {gae_td[0]:.4f} expected 1.0")
|
| 101 |
+
|
| 102 |
+
# With non-zero values, advantage is return minus value
|
| 103 |
+
values2 = [2.0, 1.0, 0.5]
|
| 104 |
+
gae2 = compute_gae([1.0, 1.0, 1.0], values2, gamma=0.99, lam=1.0)
|
| 105 |
+
record("GAE with values produces non-trivial advantages",
|
| 106 |
+
PASS if len(gae2) == 3 and any(abs(g) > 0.1 for g in gae2) else FAIL,
|
| 107 |
+
f"gae={[round(g,3) for g in gae2]}")
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 111 |
+
# 3. REINFORCE Loss
|
| 112 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 113 |
+
|
| 114 |
+
def test_reinforce():
|
| 115 |
+
print("\n--- REINFORCE Loss ---")
|
| 116 |
+
# Known values: log_probs=[-1, -2, -3], returns=[10, 5, 1]
|
| 117 |
+
# loss = -(1/3) * ((-1)*10 + (-2)*5 + (-3)*1) = -(1/3)*(-10-10-3) = -(1/3)*(-23) = 7.667
|
| 118 |
+
log_probs = [-1.0, -2.0, -3.0]
|
| 119 |
+
returns = [10.0, 5.0, 1.0]
|
| 120 |
+
loss = reinforce_loss(log_probs, returns)
|
| 121 |
+
expected = -((-1.0)*10 + (-2.0)*5 + (-3.0)*1) / 3
|
| 122 |
+
record("REINFORCE loss matches manual calculation",
|
| 123 |
+
PASS if abs(loss - expected) < 0.001 else FAIL,
|
| 124 |
+
f"got {loss:.4f} expected {expected:.4f}")
|
| 125 |
+
|
| 126 |
+
# Higher returns should produce higher loss (more gradient push)
|
| 127 |
+
returns_high = [20.0, 10.0, 2.0]
|
| 128 |
+
loss_high = reinforce_loss(log_probs, returns_high)
|
| 129 |
+
record("Higher returns → higher loss magnitude",
|
| 130 |
+
PASS if abs(loss_high) > abs(loss) else FAIL,
|
| 131 |
+
f"low={abs(loss):.4f} high={abs(loss_high):.4f}")
|
| 132 |
+
|
| 133 |
+
# Empty episode
|
| 134 |
+
loss_empty = reinforce_loss([], [])
|
| 135 |
+
record("REINFORCE handles empty episode",
|
| 136 |
+
PASS if loss_empty == 0.0 else FAIL,
|
| 137 |
+
f"got {loss_empty}")
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 141 |
+
# 4. REINFORCE + Baseline Loss
|
| 142 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 143 |
+
|
| 144 |
+
def test_reinforce_baseline():
|
| 145 |
+
print("\n--- REINFORCE + Baseline Loss ---")
|
| 146 |
+
log_probs = [-1.0, -2.0, -3.0]
|
| 147 |
+
returns = [10.0, 5.0, 1.0]
|
| 148 |
+
|
| 149 |
+
# With baselines=None, uses mean(returns)=5.33 as baseline
|
| 150 |
+
loss_b = reinforce_baseline_loss(log_probs, returns, baselines=None, normalize_advantage=False)
|
| 151 |
+
# advantages = [10-5.33, 5-5.33, 1-5.33] = [4.67, -0.33, -4.33]
|
| 152 |
+
# loss = -(1/3) * ((-1)*4.67 + (-2)*(-0.33) + (-3)*(-4.33))
|
| 153 |
+
# = -(1/3) * (-4.67 + 0.67 + 13.0)
|
| 154 |
+
# = -(1/3) * 9.0 = -3.0
|
| 155 |
+
mean_r = sum(returns) / len(returns)
|
| 156 |
+
advantages = [g - mean_r for g in returns]
|
| 157 |
+
expected = -sum(lp * adv for lp, adv in zip(log_probs, advantages)) / 3
|
| 158 |
+
record("REINFORCE+baseline matches manual calc",
|
| 159 |
+
PASS if abs(loss_b - expected) < 0.01 else FAIL,
|
| 160 |
+
f"got {loss_b:.4f} expected {expected:.4f}")
|
| 161 |
+
|
| 162 |
+
# With normalize_advantage=True, advantages are standardized
|
| 163 |
+
loss_norm = reinforce_baseline_loss(log_probs, returns, baselines=None, normalize_advantage=True)
|
| 164 |
+
record("Normalized advantage produces valid loss",
|
| 165 |
+
PASS if not math.isnan(loss_norm) and not math.isinf(loss_norm) else FAIL,
|
| 166 |
+
f"loss={loss_norm:.4f}")
|
| 167 |
+
|
| 168 |
+
# Baseline should reduce loss magnitude vs vanilla REINFORCE
|
| 169 |
+
loss_vanilla = reinforce_loss(log_probs, returns)
|
| 170 |
+
record("Baseline typically reduces loss magnitude",
|
| 171 |
+
PASS if abs(loss_norm) < abs(loss_vanilla) or True else FAIL,
|
| 172 |
+
f"vanilla={abs(loss_vanilla):.4f} baseline={abs(loss_norm):.4f} (varies)")
|
| 173 |
+
|
| 174 |
+
# Custom baselines
|
| 175 |
+
baselines = [9.0, 4.0, 0.5]
|
| 176 |
+
loss_custom = reinforce_baseline_loss(log_probs, returns, baselines=baselines, normalize_advantage=False)
|
| 177 |
+
advantages_custom = [g - b for g, b in zip(returns, baselines)]
|
| 178 |
+
expected_custom = -sum(lp * adv for lp, adv in zip(log_probs, advantages_custom)) / 3
|
| 179 |
+
record("Custom baselines work correctly",
|
| 180 |
+
PASS if abs(loss_custom - expected_custom) < 0.01 else FAIL,
|
| 181 |
+
f"got {loss_custom:.4f} expected {expected_custom:.4f}")
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 185 |
+
# 5. GRPO Loss
|
| 186 |
+
# ════════════════════════════════════��═══════════════════════════════════════════
|
| 187 |
+
|
| 188 |
+
def test_grpo():
|
| 189 |
+
print("\n--- GRPO Loss ---")
|
| 190 |
+
# Group of 3 samples for one state
|
| 191 |
+
log_probs_groups = [[-1.0, -2.0, -1.5]]
|
| 192 |
+
rewards_groups = [[10.0, 5.0, 8.0]]
|
| 193 |
+
|
| 194 |
+
loss = grpo_loss(log_probs_groups, rewards_groups)
|
| 195 |
+
record("GRPO produces valid loss",
|
| 196 |
+
PASS if not math.isnan(loss) and not math.isinf(loss) else FAIL,
|
| 197 |
+
f"loss={loss:.4f}")
|
| 198 |
+
|
| 199 |
+
# The highest-reward sample should get positive advantage,
|
| 200 |
+
# lowest-reward should get negative advantage
|
| 201 |
+
mean_r = sum(rewards_groups[0]) / 3 # 7.67
|
| 202 |
+
std_r = math.sqrt(sum((r - mean_r)**2 for r in rewards_groups[0]) / 3)
|
| 203 |
+
advantages = [(r - mean_r) / (std_r + 1e-8) for r in rewards_groups[0]]
|
| 204 |
+
record("GRPO: highest reward gets positive advantage",
|
| 205 |
+
PASS if advantages[0] > 0 else FAIL,
|
| 206 |
+
f"adv={advantages[0]:.4f}")
|
| 207 |
+
record("GRPO: lowest reward gets negative advantage",
|
| 208 |
+
PASS if advantages[1] < 0 else FAIL,
|
| 209 |
+
f"adv={advantages[1]:.4f}")
|
| 210 |
+
|
| 211 |
+
# Multiple groups
|
| 212 |
+
log_probs_2 = [[-1.0, -2.0], [-1.5, -1.5]]
|
| 213 |
+
rewards_2 = [[10.0, 5.0], [3.0, 7.0]]
|
| 214 |
+
loss_2 = grpo_loss(log_probs_2, rewards_2)
|
| 215 |
+
record("GRPO handles multiple groups",
|
| 216 |
+
PASS if not math.isnan(loss_2) else FAIL,
|
| 217 |
+
f"loss={loss_2:.4f}")
|
| 218 |
+
|
| 219 |
+
# Empty groups
|
| 220 |
+
loss_empty = grpo_loss([], [])
|
| 221 |
+
record("GRPO handles empty input",
|
| 222 |
+
PASS if loss_empty == 0.0 else FAIL,
|
| 223 |
+
f"got {loss_empty}")
|
| 224 |
+
|
| 225 |
+
# Identical rewards → zero advantage → zero loss
|
| 226 |
+
loss_identical = grpo_loss([[-1.0, -2.0, -3.0]], [[5.0, 5.0, 5.0]])
|
| 227 |
+
record("GRPO: identical rewards → near-zero loss",
|
| 228 |
+
PASS if abs(loss_identical) < 1e-4 else FAIL,
|
| 229 |
+
f"loss={loss_identical:.6f}")
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 233 |
+
# 6. RLOO Loss
|
| 234 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 235 |
+
|
| 236 |
+
def test_rloo():
|
| 237 |
+
print("\n--- RLOO Loss ---")
|
| 238 |
+
# Group of 3 samples
|
| 239 |
+
log_probs_groups = [[-1.0, -2.0, -1.5]]
|
| 240 |
+
rewards_groups = [[10.0, 5.0, 8.0]]
|
| 241 |
+
|
| 242 |
+
loss = rloo_loss(log_probs_groups, rewards_groups)
|
| 243 |
+
record("RLOO produces valid loss",
|
| 244 |
+
PASS if not math.isnan(loss) and not math.isinf(loss) else FAIL,
|
| 245 |
+
f"loss={loss:.4f}")
|
| 246 |
+
|
| 247 |
+
# Leave-one-out baselines
|
| 248 |
+
# For r=10: baseline = (5+8)/2 = 6.5, advantage = 10-6.5 = 3.5
|
| 249 |
+
# For r=5: baseline = (10+8)/2 = 9.0, advantage = 5-9.0 = -4.0
|
| 250 |
+
# For r=8: baseline = (10+5)/2 = 7.5, advantage = 8-7.5 = 0.5
|
| 251 |
+
baselines = [6.5, 9.0, 7.5]
|
| 252 |
+
advantages = [10-6.5, 5-9.0, 8-7.5]
|
| 253 |
+
expected = -sum(lp * adv for lp, adv in zip(log_probs_groups[0], advantages)) / 3
|
| 254 |
+
record("RLOO matches manual calculation",
|
| 255 |
+
PASS if abs(loss - expected) < 0.01 else FAIL,
|
| 256 |
+
f"got {loss:.4f} expected {expected:.4f}")
|
| 257 |
+
|
| 258 |
+
# Single sample: falls back to REINFORCE
|
| 259 |
+
loss_single = rloo_loss([[-1.0]], [[5.0]])
|
| 260 |
+
expected_single = -(-1.0) * 5.0 # REINFORCE on one sample
|
| 261 |
+
record("RLOO K=1 falls back to REINFORCE",
|
| 262 |
+
PASS if abs(loss_single - expected_single) < 0.01 else FAIL,
|
| 263 |
+
f"got {loss_single:.4f} expected {expected_single:.4f}")
|
| 264 |
+
|
| 265 |
+
# K=2: simplest meaningful RLOO
|
| 266 |
+
loss_k2 = rloo_loss([[-1.0, -2.0]], [[10.0, 5.0]])
|
| 267 |
+
# baseline for r=10: 5.0, adv=5.0
|
| 268 |
+
# baseline for r=5: 10.0, adv=-5.0
|
| 269 |
+
# loss = -(1/2) * ((-1)*5 + (-2)*(-5)) = -(1/2)*(-5+10) = -2.5
|
| 270 |
+
record("RLOO K=2 produces valid loss",
|
| 271 |
+
PASS if not math.isnan(loss_k2) else FAIL,
|
| 272 |
+
f"loss={loss_k2:.4f}")
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 276 |
+
# 7. Reward Normalization
|
| 277 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 278 |
+
|
| 279 |
+
def test_reward_normalization():
|
| 280 |
+
print("\n--- Reward Normalization ---")
|
| 281 |
+
raw = [-0.5, -1.0, -0.3, -2.0, -0.8]
|
| 282 |
+
mean, var = compute_reward_stats(raw)
|
| 283 |
+
record("Reward stats computed",
|
| 284 |
+
PASS if abs(mean - (-0.92)) < 0.01 else FAIL,
|
| 285 |
+
f"mean={mean:.4f} var={var:.4f}")
|
| 286 |
+
|
| 287 |
+
normed = normalize_rewards(raw, mean, var)
|
| 288 |
+
record("Normalized rewards have near-zero mean",
|
| 289 |
+
PASS if abs(sum(normed)/len(normed)) < 0.01 else FAIL,
|
| 290 |
+
f"mean={sum(normed)/len(normed):.4f}")
|
| 291 |
+
|
| 292 |
+
norm_var = sum((n - sum(normed)/len(normed))**2 for n in normed) / len(normed)
|
| 293 |
+
record("Normalized rewards have near-unit variance",
|
| 294 |
+
PASS if abs(norm_var - 1.0) < 0.01 else FAIL,
|
| 295 |
+
f"var={norm_var:.4f}")
|
| 296 |
+
|
| 297 |
+
# Identity: normalizing with mean=0, var=1 should leave rewards unchanged
|
| 298 |
+
identity = normalize_rewards(raw, 0.0, 1.0)
|
| 299 |
+
record("Identity normalization (mean=0, var=1)",
|
| 300 |
+
PASS if all(abs(a - b) < 0.01 for a, b in zip(raw, identity)) else FAIL,
|
| 301 |
+
f"max_diff={max(abs(a-b) for a,b in zip(raw,identity)):.4f}")
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 305 |
+
# 8. Loss Function Comparison
|
| 306 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 307 |
+
|
| 308 |
+
def test_loss_comparison():
|
| 309 |
+
"""Compare all 4 loss functions on the same episode data."""
|
| 310 |
+
print("\n--- Loss Function Comparison ---")
|
| 311 |
+
log_probs = [-2.0, -1.5, -3.0, -1.0, -2.5]
|
| 312 |
+
returns = [0.8, 0.3, 0.1, 0.5, 0.2]
|
| 313 |
+
|
| 314 |
+
l_reinforce = reinforce_loss(log_probs, returns)
|
| 315 |
+
l_baseline = reinforce_baseline_loss(log_probs, returns, normalize_advantage=True)
|
| 316 |
+
|
| 317 |
+
# GRPO: treat each step as its own "group" of size 1
|
| 318 |
+
# (Not how GRPO is normally used, but tests the pipeline)
|
| 319 |
+
lps_groups = [[lp] for lp in log_probs]
|
| 320 |
+
rs_groups = [[r] for r in returns]
|
| 321 |
+
l_grpo = grpo_loss(lps_groups, rs_groups)
|
| 322 |
+
l_rloo = rloo_loss(lps_groups, rs_groups)
|
| 323 |
+
|
| 324 |
+
record("All 4 losses produce valid values",
|
| 325 |
+
PASS if all(not math.isnan(l) and not math.isinf(l)
|
| 326 |
+
for l in [l_reinforce, l_baseline, l_grpo, l_rloo]) else FAIL,
|
| 327 |
+
f"R={l_reinforce:.4f} RB={l_baseline:.4f} GRPO={l_grpo:.4f} RLOO={l_rloo:.4f}")
|
| 328 |
+
|
| 329 |
+
print(f" [i] REINFORCE: {l_reinforce:.6f}")
|
| 330 |
+
print(f" [i] REINFORCE+baseline: {l_baseline:.6f}")
|
| 331 |
+
print(f" [i] GRPO (K=1): {l_grpo:.6f}")
|
| 332 |
+
print(f" [i] RLOO (K=1): {l_rloo:.6f}")
|
| 333 |
+
|
| 334 |
+
# Now with proper K=4 groups
|
| 335 |
+
log_probs_4 = [[-1.0, -2.0, -1.5, -3.0]]
|
| 336 |
+
rewards_4 = [[0.8, 0.2, 0.5, 0.1]]
|
| 337 |
+
l_grpo_4 = grpo_loss(log_probs_4, rewards_4)
|
| 338 |
+
l_rloo_4 = rloo_loss(log_probs_4, rewards_4)
|
| 339 |
+
record("GRPO/RLOO with K=4 produce valid losses",
|
| 340 |
+
PASS if not math.isnan(l_grpo_4) and not math.isnan(l_rloo_4) else FAIL,
|
| 341 |
+
f"GRPO={l_grpo_4:.4f} RLOO={l_rloo_4:.4f}")
|
| 342 |
+
print(f" [i] GRPO (K=4): {l_grpo_4:.6f}")
|
| 343 |
+
print(f" [i] RLOO (K=4): {l_rloo_4:.6f}")
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 347 |
+
# 9. Episode Collection (with MockPolicyModel)
|
| 348 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 349 |
+
|
| 350 |
+
def test_episode_collection():
|
| 351 |
+
print("\n--- Episode Collection (MockPolicyModel) ---")
|
| 352 |
+
config = TrainingConfig(n_nodes=5, max_steps=30)
|
| 353 |
+
collector = EpisodeCollector(config)
|
| 354 |
+
model = MockPolicyModel(n_nodes=5, seed=42)
|
| 355 |
+
|
| 356 |
+
episode = collector.collect_episode(model, task_id="task-1", seed=42)
|
| 357 |
+
|
| 358 |
+
record("Episode has correct number of steps",
|
| 359 |
+
PASS if len(episode.steps) == 30 else FAIL,
|
| 360 |
+
f"steps={len(episode.steps)}")
|
| 361 |
+
|
| 362 |
+
record("All log probs are valid",
|
| 363 |
+
PASS if all(not math.isnan(s.log_prob) for s in episode.steps) else FAIL,
|
| 364 |
+
f"min_lp={min(s.log_prob for s in episode.steps):.4f}")
|
| 365 |
+
|
| 366 |
+
record("Rewards are finite",
|
| 367 |
+
PASS if all(math.isfinite(s.reward) for s in episode.steps) else FAIL,
|
| 368 |
+
f"min_r={min(s.reward for s in episode.steps):.4f}")
|
| 369 |
+
|
| 370 |
+
record("Normalized rewards in [0,1]",
|
| 371 |
+
PASS if all(0.0 <= s.reward_normalized <= 1.0 for s in episode.steps) else FAIL,
|
| 372 |
+
f"range=[{min(s.reward_normalized for s in episode.steps):.4f}, "
|
| 373 |
+
f"{max(s.reward_normalized for s in episode.steps):.4f}]")
|
| 374 |
+
|
| 375 |
+
record("Total reward is computed",
|
| 376 |
+
PASS if math.isfinite(episode.total_reward) else FAIL,
|
| 377 |
+
f"total={episode.total_reward:.4f}")
|
| 378 |
+
|
| 379 |
+
record("SLA violations tracked",
|
| 380 |
+
PASS if isinstance(episode.sla_violations, int) else FAIL,
|
| 381 |
+
f"violations={episode.sla_violations}")
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 385 |
+
# 10. Full Training Step (per loss function)
|
| 386 |
+
# ═══���════════════════════════════════════════════════════════════════════════════
|
| 387 |
+
|
| 388 |
+
def test_training_steps():
|
| 389 |
+
"""Run one training step with each loss function."""
|
| 390 |
+
print("\n--- Full Training Steps ---")
|
| 391 |
+
model = MockPolicyModel(n_nodes=5, seed=42)
|
| 392 |
+
|
| 393 |
+
for loss_name in [LOSS_REINFORCE, LOSS_REINFORCE_BASELINE, LOSS_GRPO, LOSS_RLOO]:
|
| 394 |
+
config = TrainingConfig(
|
| 395 |
+
n_nodes=5,
|
| 396 |
+
max_steps=30,
|
| 397 |
+
loss_fn=loss_name,
|
| 398 |
+
n_samples_per_state=2 if loss_name in (LOSS_GRPO, LOSS_RLOO) else 1,
|
| 399 |
+
)
|
| 400 |
+
trainer = SRETrainer(config)
|
| 401 |
+
metrics = trainer.train_step(model, task_id="task-1", seed=42)
|
| 402 |
+
|
| 403 |
+
record(f"{loss_name}: loss is valid",
|
| 404 |
+
PASS if math.isfinite(metrics["loss"]) else FAIL,
|
| 405 |
+
f"loss={metrics['loss']:.4f}")
|
| 406 |
+
|
| 407 |
+
record(f"{loss_name}: avg_reward is valid",
|
| 408 |
+
PASS if math.isfinite(metrics["avg_reward"]) else FAIL,
|
| 409 |
+
f"avg_reward={metrics['avg_reward']:.4f}")
|
| 410 |
+
|
| 411 |
+
record(f"{loss_name}: episode completed",
|
| 412 |
+
PASS if metrics["episode_length"] > 0 else FAIL,
|
| 413 |
+
f"length={metrics['episode_length']}")
|
| 414 |
+
|
| 415 |
+
# No NaN/inf in running stats
|
| 416 |
+
record(f"{loss_name}: running stats stable",
|
| 417 |
+
PASS if math.isfinite(metrics["reward_mean"]) and math.isfinite(metrics["reward_var"]) else FAIL,
|
| 418 |
+
f"mean={metrics['reward_mean']:.4f} var={metrics['reward_var']:.4f}")
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 422 |
+
# 11. Multi-Episode Stability
|
| 423 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 424 |
+
|
| 425 |
+
def test_multi_episode_stability():
|
| 426 |
+
"""Run multiple episodes and check running stats remain stable."""
|
| 427 |
+
print("\n--- Multi-Episode Stability ---")
|
| 428 |
+
config = TrainingConfig(
|
| 429 |
+
n_nodes=5,
|
| 430 |
+
max_steps=30,
|
| 431 |
+
loss_fn=LOSS_REINFORCE_BASELINE,
|
| 432 |
+
tasks=["task-1", "task-2", "task-3"],
|
| 433 |
+
)
|
| 434 |
+
trainer = SRETrainer(config)
|
| 435 |
+
model = MockPolicyModel(n_nodes=5, seed=42)
|
| 436 |
+
|
| 437 |
+
all_losses = []
|
| 438 |
+
for i in range(5):
|
| 439 |
+
for task in config.tasks:
|
| 440 |
+
metrics = trainer.train_step(model, task_id=task, seed=42 + i)
|
| 441 |
+
all_losses.append(metrics["loss"])
|
| 442 |
+
|
| 443 |
+
# No NaN/inf across 15 episodes
|
| 444 |
+
record("15 episodes: all losses finite",
|
| 445 |
+
PASS if all(math.isfinite(l) for l in all_losses) else FAIL,
|
| 446 |
+
f"n_losses={len(all_losses)}")
|
| 447 |
+
|
| 448 |
+
# Losses should vary (different tasks + domain randomization)
|
| 449 |
+
unique_losses = len(set(round(l, 4) for l in all_losses))
|
| 450 |
+
record("Losses vary across episodes",
|
| 451 |
+
PASS if unique_losses > 3 else FAIL,
|
| 452 |
+
f"unique={unique_losses}/{len(all_losses)}")
|
| 453 |
+
|
| 454 |
+
# Running stats should be non-degenerate
|
| 455 |
+
last_metrics = metrics
|
| 456 |
+
record("Running reward mean is non-zero",
|
| 457 |
+
PASS if abs(last_metrics["reward_mean"]) > 0.001 else FAIL,
|
| 458 |
+
f"mean={last_metrics['reward_mean']:.6f}")
|
| 459 |
+
|
| 460 |
+
|
| 461 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 462 |
+
# 12. SRE-Specific Edge Cases
|
| 463 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 464 |
+
|
| 465 |
+
def test_sre_edge_cases():
|
| 466 |
+
"""Test edge cases specific to the SRE domain."""
|
| 467 |
+
print("\n--- SRE Edge Cases ---")
|
| 468 |
+
|
| 469 |
+
# Very negative rewards (system crashing)
|
| 470 |
+
log_probs = [-2.0] * 10
|
| 471 |
+
returns_crash = [-100.0] * 10
|
| 472 |
+
loss_crash = reinforce_baseline_loss(log_probs, returns_crash, normalize_advantage=True)
|
| 473 |
+
record("Very negative rewards: loss is finite",
|
| 474 |
+
PASS if math.isfinite(loss_crash) else FAIL,
|
| 475 |
+
f"loss={loss_crash:.4f}")
|
| 476 |
+
|
| 477 |
+
# All-zero returns (perfect episode)
|
| 478 |
+
returns_perfect = [0.0] * 10
|
| 479 |
+
loss_perfect = reinforce_baseline_loss(log_probs, returns_perfect, normalize_advantage=False)
|
| 480 |
+
record("Zero returns: loss is zero (no gradient)",
|
| 481 |
+
PASS if abs(loss_perfect) < 0.001 else FAIL,
|
| 482 |
+
f"loss={loss_perfect:.4f}")
|
| 483 |
+
|
| 484 |
+
# Highly variable rewards within episode (surge task)
|
| 485 |
+
returns_surge = [0.5, 0.5, -10.0, -10.0, 0.5, 0.5, -10.0, 0.5, 0.5, 0.5]
|
| 486 |
+
loss_surge = reinforce_baseline_loss(log_probs, returns_surge, normalize_advantage=True)
|
| 487 |
+
record("High-variance rewards: loss is finite with normalization",
|
| 488 |
+
PASS if math.isfinite(loss_surge) else FAIL,
|
| 489 |
+
f"loss={loss_surge:.4f}")
|
| 490 |
+
|
| 491 |
+
# GRPO with very different rewards in group
|
| 492 |
+
lps = [[-1.0, -2.0, -1.5, -3.0]]
|
| 493 |
+
rs_extreme = [[0.9, 0.8, 0.85, 0.05]] # One bad sample
|
| 494 |
+
loss_extreme = grpo_loss(lps, rs_extreme)
|
| 495 |
+
record("GRPO handles outlier in group",
|
| 496 |
+
PASS if math.isfinite(loss_extreme) else FAIL,
|
| 497 |
+
f"loss={loss_extreme:.4f}")
|
| 498 |
+
|
| 499 |
+
|
| 500 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 501 |
+
|
| 502 |
+
def main():
|
| 503 |
+
print("=" * 60)
|
| 504 |
+
print("AntiAtropos Training Validation")
|
| 505 |
+
print("=" * 60)
|
| 506 |
+
|
| 507 |
+
test_returns()
|
| 508 |
+
test_gae()
|
| 509 |
+
test_reinforce()
|
| 510 |
+
test_reinforce_baseline()
|
| 511 |
+
test_grpo()
|
| 512 |
+
test_rloo()
|
| 513 |
+
test_reward_normalization()
|
| 514 |
+
test_loss_comparison()
|
| 515 |
+
test_episode_collection()
|
| 516 |
+
test_training_steps()
|
| 517 |
+
test_multi_episode_stability()
|
| 518 |
+
test_sre_edge_cases()
|
| 519 |
+
|
| 520 |
+
passed = sum(1 for _, s, _ in results if s == PASS)
|
| 521 |
+
failed = sum(1 for _, s, _ in results if s == FAIL)
|
| 522 |
+
total = len(results)
|
| 523 |
+
|
| 524 |
+
print("\n" + "=" * 60)
|
| 525 |
+
print(f"RESULTS: {passed}/{total} passed, {failed} failed")
|
| 526 |
+
print("=" * 60)
|
| 527 |
+
|
| 528 |
+
if failed > 0:
|
| 529 |
+
print("\nFailed tests:")
|
| 530 |
+
for name, status, detail in results:
|
| 531 |
+
if status == FAIL:
|
| 532 |
+
print(f" X {name}: {detail}")
|
| 533 |
+
|
| 534 |
+
return 0 if failed == 0 else 1
|
| 535 |
+
|
| 536 |
+
|
| 537 |
+
if __name__ == "__main__":
|
| 538 |
+
sys.exit(main())
|
training/__init__.py
CHANGED
|
@@ -1,27 +1,27 @@
|
|
| 1 |
-
"""AntiAtropos Training Module.
|
| 2 |
-
|
| 3 |
-
RL loss functions and training loop for LLM-based SRE agents.
|
| 4 |
-
Works with the simulator in pure-Python mode (no AWS/GPU needed for validation).
|
| 5 |
-
"""
|
| 6 |
-
|
| 7 |
-
from .losses import (
|
| 8 |
-
compute_returns,
|
| 9 |
-
compute_gae,
|
| 10 |
-
reinforce_loss,
|
| 11 |
-
reinforce_baseline_loss,
|
| 12 |
-
grpo_loss,
|
| 13 |
-
rloo_loss,
|
| 14 |
-
)
|
| 15 |
-
from .trainer import SRETrainer, TrainingConfig, EpisodeCollector
|
| 16 |
-
|
| 17 |
-
__all__ = [
|
| 18 |
-
"compute_returns",
|
| 19 |
-
"compute_gae",
|
| 20 |
-
"reinforce_loss",
|
| 21 |
-
"reinforce_baseline_loss",
|
| 22 |
-
"grpo_loss",
|
| 23 |
-
"rloo_loss",
|
| 24 |
-
"SRETrainer",
|
| 25 |
-
"TrainingConfig",
|
| 26 |
-
"EpisodeCollector",
|
| 27 |
-
]
|
|
|
|
| 1 |
+
"""AntiAtropos Training Module.
|
| 2 |
+
|
| 3 |
+
RL loss functions and training loop for LLM-based SRE agents.
|
| 4 |
+
Works with the simulator in pure-Python mode (no AWS/GPU needed for validation).
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from .losses import (
|
| 8 |
+
compute_returns,
|
| 9 |
+
compute_gae,
|
| 10 |
+
reinforce_loss,
|
| 11 |
+
reinforce_baseline_loss,
|
| 12 |
+
grpo_loss,
|
| 13 |
+
rloo_loss,
|
| 14 |
+
)
|
| 15 |
+
from .trainer import SRETrainer, TrainingConfig, EpisodeCollector
|
| 16 |
+
|
| 17 |
+
__all__ = [
|
| 18 |
+
"compute_returns",
|
| 19 |
+
"compute_gae",
|
| 20 |
+
"reinforce_loss",
|
| 21 |
+
"reinforce_baseline_loss",
|
| 22 |
+
"grpo_loss",
|
| 23 |
+
"rloo_loss",
|
| 24 |
+
"SRETrainer",
|
| 25 |
+
"TrainingConfig",
|
| 26 |
+
"EpisodeCollector",
|
| 27 |
+
]
|
training/losses.py
CHANGED
|
@@ -1,390 +1,390 @@
|
|
| 1 |
-
"""
|
| 2 |
-
AntiAtropos RL Loss Functions.
|
| 3 |
-
|
| 4 |
-
Pure-Python implementations of policy gradient loss functions for LLM-based
|
| 5 |
-
SRE agents. These are mathematically identical to their PyTorch counterparts
|
| 6 |
-
and can be validated locally without GPU.
|
| 7 |
-
|
| 8 |
-
When porting to PyTorch (Colab), simply replace:
|
| 9 |
-
- float ops with torch.tensor ops
|
| 10 |
-
- sum() with torch.sum()
|
| 11 |
-
- log() with torch.log()
|
| 12 |
-
- The gradient flows through log_prob automatically
|
| 13 |
-
|
| 14 |
-
Loss function selection guide for the SRE domain:
|
| 15 |
-
─────────────────────────────────────────────────
|
| 16 |
-
┌───────────────────────┬────────────┬──────────────┬─────────────────────────┐
|
| 17 |
-
│ Method │ Variance │ Needs Value │ Best for │
|
| 18 |
-
├───────────────────────┼────────────┼──────────────┼─────────────────────────┤
|
| 19 |
-
│ REINFORCE │ High │ No │ Quick baseline │
|
| 20 |
-
│ REINFORCE + baseline │ Medium │ Optional │ Most use cases │
|
| 21 |
-
│ GRPO │ Low │ No │ Multi-sample rollouts │
|
| 22 |
-
│ RLOO │ Lowest │ No │ Small groups (K=2-4) │
|
| 23 |
-
└───────────────────────┴────────────┴──────────────┴─────────────────────────┘
|
| 24 |
-
|
| 25 |
-
Recommended starting point: REINFORCE + baseline (simplest, good variance,
|
| 26 |
-
no value head needed). If variance is still too high, switch to GRPO with K=4.
|
| 27 |
-
|
| 28 |
-
For the SRE domain specifically:
|
| 29 |
-
- Episodes are 100 steps long → significant credit assignment challenge
|
| 30 |
-
- Rewards are dense (computed every step) → advantage normalization is key
|
| 31 |
-
- Delayed effects (boot delay = 5 ticks) → GAE helps bridge the gap
|
| 32 |
-
"""
|
| 33 |
-
|
| 34 |
-
from __future__ import annotations
|
| 35 |
-
|
| 36 |
-
import math
|
| 37 |
-
from typing import List, Optional
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 41 |
-
# Return / Advantage computation (shared across all loss functions)
|
| 42 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 43 |
-
|
| 44 |
-
def compute_returns(
|
| 45 |
-
rewards: List[float],
|
| 46 |
-
gamma: float = 0.99,
|
| 47 |
-
) -> List[float]:
|
| 48 |
-
"""
|
| 49 |
-
Compute discounted returns (Monte Carlo) for each timestep.
|
| 50 |
-
|
| 51 |
-
G_t = r_t + γ * r_{t+1} + γ² * r_{t+2} + ... + γ^{T-t} * r_T
|
| 52 |
-
|
| 53 |
-
Args:
|
| 54 |
-
rewards: Per-step rewards [r_0, r_1, ..., r_{T-1}].
|
| 55 |
-
gamma: Discount factor. 0.99 = far-sighted, 0.9 = myopic.
|
| 56 |
-
|
| 57 |
-
Returns:
|
| 58 |
-
List of returns [G_0, G_1, ..., G_{T-1}] same length as rewards.
|
| 59 |
-
"""
|
| 60 |
-
returns: List[float] = []
|
| 61 |
-
g = 0.0
|
| 62 |
-
for r in reversed(rewards):
|
| 63 |
-
g = r + gamma * g
|
| 64 |
-
returns.insert(0, g)
|
| 65 |
-
return returns
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
def compute_gae(
|
| 69 |
-
rewards: List[float],
|
| 70 |
-
values: List[float],
|
| 71 |
-
gamma: float = 0.99,
|
| 72 |
-
lam: float = 0.95,
|
| 73 |
-
) -> List[float]:
|
| 74 |
-
"""
|
| 75 |
-
Generalized Advantage Estimation (GAE).
|
| 76 |
-
|
| 77 |
-
Â_t = Σ_{l=0}^{T-t-1} (γλ)^l δ_{t+l}
|
| 78 |
-
|
| 79 |
-
where δ_t = r_t + γ * V(s_{t+1}) - V(s_t) is the TD error.
|
| 80 |
-
|
| 81 |
-
GAE provides a bias-variance trade-off controlled by λ:
|
| 82 |
-
λ = 0 → one-step TD (low variance, high bias)
|
| 83 |
-
λ = 1 → Monte Carlo returns (high variance, no bias)
|
| 84 |
-
|
| 85 |
-
For SRE: λ=0.95 is a good default. The 5-tick boot delay means
|
| 86 |
-
actions have delayed effects — GAE with λ close to 1 helps propagate
|
| 87 |
-
credit across those gaps.
|
| 88 |
-
|
| 89 |
-
Args:
|
| 90 |
-
rewards: Per-step rewards [r_0, ..., r_{T-1}].
|
| 91 |
-
values: State value estimates [V(s_0), ..., V(s_{T-1})].
|
| 92 |
-
Pass a list of zeros for V=0 baseline (reduces to MC).
|
| 93 |
-
gamma: Discount factor.
|
| 94 |
-
lam: GAE lambda (trade-off parameter).
|
| 95 |
-
|
| 96 |
-
Returns:
|
| 97 |
-
List of GAE advantages [Â_0, ..., Â_{T-1}].
|
| 98 |
-
"""
|
| 99 |
-
assert len(rewards) == len(values), f"len mismatch: rewards={len(rewards)} values={len(values)}"
|
| 100 |
-
advantages: List[float] = []
|
| 101 |
-
gae = 0.0
|
| 102 |
-
for t in reversed(range(len(rewards))):
|
| 103 |
-
if t == len(rewards) - 1:
|
| 104 |
-
next_value = 0.0 # Terminal state has V=0
|
| 105 |
-
else:
|
| 106 |
-
next_value = values[t + 1]
|
| 107 |
-
delta = rewards[t] + gamma * next_value - values[t]
|
| 108 |
-
gae = delta + gamma * lam * gae
|
| 109 |
-
advantages.insert(0, gae)
|
| 110 |
-
return advantages
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 114 |
-
# Loss Functions
|
| 115 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 116 |
-
|
| 117 |
-
def reinforce_loss(
|
| 118 |
-
log_probs: List[float],
|
| 119 |
-
returns: List[float],
|
| 120 |
-
) -> float:
|
| 121 |
-
"""
|
| 122 |
-
Vanilla REINFORCE (Williams, 1992).
|
| 123 |
-
|
| 124 |
-
L = -(1/T) Σ_t log π(a_t | s_t) · G_t
|
| 125 |
-
|
| 126 |
-
The gradient of this loss is an unbiased estimator of the policy gradient:
|
| 127 |
-
∇J(θ) = E[Σ_t ∇log π(a_t|s_t) · G_t]
|
| 128 |
-
|
| 129 |
-
Properties:
|
| 130 |
-
- Unbiased but HIGH variance (no baseline)
|
| 131 |
-
- Simplest possible policy gradient method
|
| 132 |
-
- Good for initial prototyping, upgrade to baseline version ASAP
|
| 133 |
-
|
| 134 |
-
Args:
|
| 135 |
-
log_probs: log π(a_t | s_t) for each timestep.
|
| 136 |
-
returns: Discounted returns G_t for each timestep.
|
| 137 |
-
|
| 138 |
-
Returns:
|
| 139 |
-
Scalar loss (minimize to maximize expected return).
|
| 140 |
-
"""
|
| 141 |
-
assert len(log_probs) == len(returns), f"len mismatch: log_probs={len(log_probs)} returns={len(returns)}"
|
| 142 |
-
t = len(log_probs)
|
| 143 |
-
if t == 0:
|
| 144 |
-
return 0.0
|
| 145 |
-
return -sum(lp * g for lp, g in zip(log_probs, returns)) / t
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
def reinforce_baseline_loss(
|
| 149 |
-
log_probs: List[float],
|
| 150 |
-
returns: List[float],
|
| 151 |
-
baselines: Optional[List[float]] = None,
|
| 152 |
-
normalize_advantage: bool = True,
|
| 153 |
-
eps: float = 1e-8,
|
| 154 |
-
) -> float:
|
| 155 |
-
"""
|
| 156 |
-
REINFORCE with baseline (variance reduction).
|
| 157 |
-
|
| 158 |
-
L = -(1/T) Σ_t log π(a_t | s_t) · Â_t
|
| 159 |
-
|
| 160 |
-
where Â_t = G_t - b_t is the advantage estimate.
|
| 161 |
-
|
| 162 |
-
The baseline b_t does NOT introduce bias (only reduces variance)
|
| 163 |
-
because E[∇log π(a|s) · b(s)] = 0 for any function b(s).
|
| 164 |
-
|
| 165 |
-
Baseline options:
|
| 166 |
-
- None (or zeros): Falls back to vanilla REINFORCE on returns
|
| 167 |
-
- Running mean of returns: Simple, effective, no extra model needed
|
| 168 |
-
- Learned value function: Most powerful, but needs value head
|
| 169 |
-
|
| 170 |
-
For SRE agents on Colab: use running-mean baseline (pass baselines as
|
| 171 |
-
the moving average of returns seen so far). No extra model needed.
|
| 172 |
-
|
| 173 |
-
Advantage normalization: Standardizes  to zero mean and unit variance.
|
| 174 |
-
This is critical for SRE because raw returns can span orders of magnitude
|
| 175 |
-
(0.001 vs 10.0) across episodes. Normalization keeps the learning rate
|
| 176 |
-
well-conditioned.
|
| 177 |
-
|
| 178 |
-
Args:
|
| 179 |
-
log_probs: log π(a_t | s_t) for each timestep.
|
| 180 |
-
returns: Discounted returns G_t for each timestep.
|
| 181 |
-
baselines: Baseline estimates b_t. If None, uses mean(returns).
|
| 182 |
-
normalize_advantage: Whether to standardize advantages (recommended).
|
| 183 |
-
eps: Epsilon for numerical stability in normalization.
|
| 184 |
-
|
| 185 |
-
Returns:
|
| 186 |
-
Scalar loss.
|
| 187 |
-
"""
|
| 188 |
-
assert len(log_probs) == len(returns), f"len mismatch: log_probs={len(log_probs)} returns={len(returns)}"
|
| 189 |
-
t = len(log_probs)
|
| 190 |
-
if t == 0:
|
| 191 |
-
return 0.0
|
| 192 |
-
|
| 193 |
-
# Compute advantages
|
| 194 |
-
if baselines is None:
|
| 195 |
-
# Default baseline = mean of returns (simple but effective)
|
| 196 |
-
baseline_val = sum(returns) / len(returns)
|
| 197 |
-
advantages = [g - baseline_val for g in returns]
|
| 198 |
-
else:
|
| 199 |
-
assert len(baselines) == len(returns), f"len mismatch: baselines={len(baselines)} returns={len(returns)}"
|
| 200 |
-
advantages = [g - b for g, b in zip(returns, baselines)]
|
| 201 |
-
|
| 202 |
-
# Normalize advantages (critical for SRE reward scale)
|
| 203 |
-
if normalize_advantage and len(advantages) > 1:
|
| 204 |
-
mean_adv = sum(advantages) / len(advantages)
|
| 205 |
-
var_adv = sum((a - mean_adv) ** 2 for a in advantages) / len(advantages)
|
| 206 |
-
std_adv = math.sqrt(var_adv) + eps
|
| 207 |
-
advantages = [(a - mean_adv) / std_adv for a in advantages]
|
| 208 |
-
|
| 209 |
-
return -sum(lp * adv for lp, adv in zip(log_probs, advantages)) / t
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
def grpo_loss(
|
| 213 |
-
log_probs_groups: List[List[float]],
|
| 214 |
-
rewards_groups: List[List[float]],
|
| 215 |
-
eps: float = 1e-8,
|
| 216 |
-
) -> float:
|
| 217 |
-
"""
|
| 218 |
-
Group Relative Policy Optimization (GRPO).
|
| 219 |
-
|
| 220 |
-
For each state s, generate K sample actions and compute group-relative
|
| 221 |
-
advantages without needing a value function:
|
| 222 |
-
|
| 223 |
-
Â_k = (r_k - μ_group) / σ_group
|
| 224 |
-
|
| 225 |
-
L = -(1/N) Σ_i (1/K_i) Σ_k log π(a_{i,k} | s_i) · Â_{i,k}
|
| 226 |
-
|
| 227 |
-
where μ_group and σ_group are the mean and std of rewards within group i.
|
| 228 |
-
|
| 229 |
-
This is the method used in DeepSeek-R1. It eliminates the need for a
|
| 230 |
-
value head entirely — the group statistics serve as the baseline.
|
| 231 |
-
|
| 232 |
-
Pros:
|
| 233 |
-
- No value function needed
|
| 234 |
-
- Low variance (group statistics absorb reward scale)
|
| 235 |
-
- Natural normalization
|
| 236 |
-
|
| 237 |
-
Cons:
|
| 238 |
-
- Requires K >= 2 rollouts per state (K * more compute)
|
| 239 |
-
- For K=1, falls back to REINFORCE (no baseline)
|
| 240 |
-
|
| 241 |
-
For SRE on Colab: Use K=4 with QLoRA on a T4. Each "group" is 4
|
| 242 |
-
different actions sampled for the same cluster state.
|
| 243 |
-
|
| 244 |
-
Args:
|
| 245 |
-
log_probs_groups: List of groups, each group is log π(a_k|s) for K samples.
|
| 246 |
-
rewards_groups: List of groups, each group is reward_k for K samples.
|
| 247 |
-
eps: Epsilon for std normalization.
|
| 248 |
-
|
| 249 |
-
Returns:
|
| 250 |
-
Scalar loss.
|
| 251 |
-
"""
|
| 252 |
-
assert len(log_probs_groups) == len(rewards_groups), "group count mismatch"
|
| 253 |
-
if not log_probs_groups:
|
| 254 |
-
return 0.0
|
| 255 |
-
|
| 256 |
-
total_loss = 0.0
|
| 257 |
-
n_groups = 0
|
| 258 |
-
|
| 259 |
-
for log_probs, rewards in zip(log_probs_groups, rewards_groups):
|
| 260 |
-
assert len(log_probs) == len(rewards), f"group size mismatch: {len(log_probs)} vs {len(rewards)}"
|
| 261 |
-
k = len(log_probs)
|
| 262 |
-
if k == 0:
|
| 263 |
-
continue
|
| 264 |
-
|
| 265 |
-
# Group statistics
|
| 266 |
-
mean_r = sum(rewards) / k
|
| 267 |
-
var_r = sum((r - mean_r) ** 2 for r in rewards) / k
|
| 268 |
-
std_r = math.sqrt(var_r) + eps
|
| 269 |
-
|
| 270 |
-
# Normalized advantages
|
| 271 |
-
advantages = [(r - mean_r) / std_r for r in rewards]
|
| 272 |
-
|
| 273 |
-
# Policy gradient for this group
|
| 274 |
-
group_loss = -sum(lp * adv for lp, adv in zip(log_probs, advantages)) / k
|
| 275 |
-
total_loss += group_loss
|
| 276 |
-
n_groups += 1
|
| 277 |
-
|
| 278 |
-
return total_loss / max(1, n_groups)
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
def rloo_loss(
|
| 282 |
-
log_probs_groups: List[List[float]],
|
| 283 |
-
rewards_groups: List[List[float]],
|
| 284 |
-
) -> float:
|
| 285 |
-
"""
|
| 286 |
-
REINFORCE Leave-One-Out (RLOO).
|
| 287 |
-
|
| 288 |
-
Similar to GRPO but uses a leave-one-out baseline instead of group
|
| 289 |
-
statistics. For each sample k in a group of K:
|
| 290 |
-
|
| 291 |
-
b_k = (1/(K-1)) Σ_{j≠k} r_j (leave-one-out mean)
|
| 292 |
-
|
| 293 |
-
Â_k = r_k - b_k
|
| 294 |
-
|
| 295 |
-
L = -(1/N) Σ_i (1/K_i) Σ_k log π(a_{i,k} | s_i) · Â_{i,k}
|
| 296 |
-
|
| 297 |
-
RLOO has lower variance than GRPO for small group sizes (K=2-4) because
|
| 298 |
-
the baseline is computed from the actual other samples rather than a
|
| 299 |
-
statistical estimate. For K >= 8, GRPO and RLOO converge.
|
| 300 |
-
|
| 301 |
-
For SRE on Colab: Best choice when you can only afford K=2-4 rollouts.
|
| 302 |
-
The leave-one-out baseline is surprisingly effective.
|
| 303 |
-
|
| 304 |
-
Args:
|
| 305 |
-
log_probs_groups: List of groups, each group is log π(a_k|s) for K samples.
|
| 306 |
-
rewards_groups: List of groups, each group is reward_k for K samples.
|
| 307 |
-
|
| 308 |
-
Returns:
|
| 309 |
-
Scalar loss.
|
| 310 |
-
"""
|
| 311 |
-
assert len(log_probs_groups) == len(rewards_groups), "group count mismatch"
|
| 312 |
-
if not log_probs_groups:
|
| 313 |
-
return 0.0
|
| 314 |
-
|
| 315 |
-
total_loss = 0.0
|
| 316 |
-
n_groups = 0
|
| 317 |
-
|
| 318 |
-
for log_probs, rewards in zip(log_probs_groups, rewards_groups):
|
| 319 |
-
assert len(log_probs) == len(rewards), f"group size mismatch: {len(log_probs)} vs {len(rewards)}"
|
| 320 |
-
k = len(log_probs)
|
| 321 |
-
if k == 0:
|
| 322 |
-
continue
|
| 323 |
-
|
| 324 |
-
if k == 1:
|
| 325 |
-
# Single sample: no baseline possible, fall back to REINFORCE
|
| 326 |
-
total_loss += -log_probs[0] * rewards[0]
|
| 327 |
-
n_groups += 1
|
| 328 |
-
continue
|
| 329 |
-
|
| 330 |
-
# Leave-one-out baselines
|
| 331 |
-
sum_r = sum(rewards)
|
| 332 |
-
baselines = [(sum_r - r) / (k - 1) for r in rewards]
|
| 333 |
-
advantages = [r - b for r, b in zip(rewards, baselines)]
|
| 334 |
-
|
| 335 |
-
# Policy gradient
|
| 336 |
-
group_loss = -sum(lp * adv for lp, adv in zip(log_probs, advantages)) / k
|
| 337 |
-
total_loss += group_loss
|
| 338 |
-
n_groups += 1
|
| 339 |
-
|
| 340 |
-
return total_loss / max(1, n_groups)
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 344 |
-
# Reward normalization utilities
|
| 345 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 346 |
-
|
| 347 |
-
def normalize_rewards(
|
| 348 |
-
rewards: List[float],
|
| 349 |
-
running_mean: float = 0.0,
|
| 350 |
-
running_var: float = 1.0,
|
| 351 |
-
eps: float = 1e-8,
|
| 352 |
-
) -> List[float]:
|
| 353 |
-
"""
|
| 354 |
-
Normalize rewards using running statistics.
|
| 355 |
-
|
| 356 |
-
For SRE: Raw rewards are always negative (they're penalties). This function
|
| 357 |
-
shifts them to be centered around zero with unit variance, which is
|
| 358 |
-
essential for stable policy gradient updates.
|
| 359 |
-
|
| 360 |
-
On Colab: Maintain a running mean/var across episodes and pass them here.
|
| 361 |
-
Initialize with mean=0, var=1 and update with exponential moving average.
|
| 362 |
-
|
| 363 |
-
running_mean = 0.99 * running_mean + 0.01 * batch_mean
|
| 364 |
-
running_var = 0.99 * running_var + 0.01 * batch_var
|
| 365 |
-
|
| 366 |
-
Args:
|
| 367 |
-
rewards: Raw rewards to normalize.
|
| 368 |
-
running_mean: Running mean estimate across episodes.
|
| 369 |
-
running_var: Running variance estimate across episodes.
|
| 370 |
-
eps: Numerical stability constant.
|
| 371 |
-
|
| 372 |
-
Returns:
|
| 373 |
-
Normalized rewards (zero mean, unit variance relative to running stats).
|
| 374 |
-
"""
|
| 375 |
-
std = math.sqrt(running_var) + eps
|
| 376 |
-
return [(r - running_mean) / std for r in rewards]
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
def compute_reward_stats(rewards: List[float]) -> tuple[float, float]:
|
| 380 |
-
"""
|
| 381 |
-
Compute mean and variance of a reward list.
|
| 382 |
-
|
| 383 |
-
Returns:
|
| 384 |
-
(mean, variance) tuple.
|
| 385 |
-
"""
|
| 386 |
-
if not rewards:
|
| 387 |
-
return 0.0, 1.0
|
| 388 |
-
mean = sum(rewards) / len(rewards)
|
| 389 |
-
var = sum((r - mean) ** 2 for r in rewards) / len(rewards)
|
| 390 |
-
return mean, var
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AntiAtropos RL Loss Functions.
|
| 3 |
+
|
| 4 |
+
Pure-Python implementations of policy gradient loss functions for LLM-based
|
| 5 |
+
SRE agents. These are mathematically identical to their PyTorch counterparts
|
| 6 |
+
and can be validated locally without GPU.
|
| 7 |
+
|
| 8 |
+
When porting to PyTorch (Colab), simply replace:
|
| 9 |
+
- float ops with torch.tensor ops
|
| 10 |
+
- sum() with torch.sum()
|
| 11 |
+
- log() with torch.log()
|
| 12 |
+
- The gradient flows through log_prob automatically
|
| 13 |
+
|
| 14 |
+
Loss function selection guide for the SRE domain:
|
| 15 |
+
─────────────────────────────────────────────────
|
| 16 |
+
┌───────────────────────┬────────────┬──────────────┬─────────────────────────┐
|
| 17 |
+
│ Method │ Variance │ Needs Value │ Best for │
|
| 18 |
+
├───────────────────────┼────────────┼──────────────┼─────────────────────────┤
|
| 19 |
+
│ REINFORCE │ High │ No │ Quick baseline │
|
| 20 |
+
│ REINFORCE + baseline │ Medium │ Optional │ Most use cases │
|
| 21 |
+
│ GRPO │ Low │ No │ Multi-sample rollouts │
|
| 22 |
+
│ RLOO │ Lowest │ No │ Small groups (K=2-4) │
|
| 23 |
+
└───────────────────────┴────────────┴──────────────┴─────────────────────────┘
|
| 24 |
+
|
| 25 |
+
Recommended starting point: REINFORCE + baseline (simplest, good variance,
|
| 26 |
+
no value head needed). If variance is still too high, switch to GRPO with K=4.
|
| 27 |
+
|
| 28 |
+
For the SRE domain specifically:
|
| 29 |
+
- Episodes are 100 steps long → significant credit assignment challenge
|
| 30 |
+
- Rewards are dense (computed every step) → advantage normalization is key
|
| 31 |
+
- Delayed effects (boot delay = 5 ticks) → GAE helps bridge the gap
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
from __future__ import annotations
|
| 35 |
+
|
| 36 |
+
import math
|
| 37 |
+
from typing import List, Optional
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 41 |
+
# Return / Advantage computation (shared across all loss functions)
|
| 42 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 43 |
+
|
| 44 |
+
def compute_returns(
|
| 45 |
+
rewards: List[float],
|
| 46 |
+
gamma: float = 0.99,
|
| 47 |
+
) -> List[float]:
|
| 48 |
+
"""
|
| 49 |
+
Compute discounted returns (Monte Carlo) for each timestep.
|
| 50 |
+
|
| 51 |
+
G_t = r_t + γ * r_{t+1} + γ² * r_{t+2} + ... + γ^{T-t} * r_T
|
| 52 |
+
|
| 53 |
+
Args:
|
| 54 |
+
rewards: Per-step rewards [r_0, r_1, ..., r_{T-1}].
|
| 55 |
+
gamma: Discount factor. 0.99 = far-sighted, 0.9 = myopic.
|
| 56 |
+
|
| 57 |
+
Returns:
|
| 58 |
+
List of returns [G_0, G_1, ..., G_{T-1}] same length as rewards.
|
| 59 |
+
"""
|
| 60 |
+
returns: List[float] = []
|
| 61 |
+
g = 0.0
|
| 62 |
+
for r in reversed(rewards):
|
| 63 |
+
g = r + gamma * g
|
| 64 |
+
returns.insert(0, g)
|
| 65 |
+
return returns
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def compute_gae(
|
| 69 |
+
rewards: List[float],
|
| 70 |
+
values: List[float],
|
| 71 |
+
gamma: float = 0.99,
|
| 72 |
+
lam: float = 0.95,
|
| 73 |
+
) -> List[float]:
|
| 74 |
+
"""
|
| 75 |
+
Generalized Advantage Estimation (GAE).
|
| 76 |
+
|
| 77 |
+
Â_t = Σ_{l=0}^{T-t-1} (γλ)^l δ_{t+l}
|
| 78 |
+
|
| 79 |
+
where δ_t = r_t + γ * V(s_{t+1}) - V(s_t) is the TD error.
|
| 80 |
+
|
| 81 |
+
GAE provides a bias-variance trade-off controlled by λ:
|
| 82 |
+
λ = 0 → one-step TD (low variance, high bias)
|
| 83 |
+
λ = 1 → Monte Carlo returns (high variance, no bias)
|
| 84 |
+
|
| 85 |
+
For SRE: λ=0.95 is a good default. The 5-tick boot delay means
|
| 86 |
+
actions have delayed effects — GAE with λ close to 1 helps propagate
|
| 87 |
+
credit across those gaps.
|
| 88 |
+
|
| 89 |
+
Args:
|
| 90 |
+
rewards: Per-step rewards [r_0, ..., r_{T-1}].
|
| 91 |
+
values: State value estimates [V(s_0), ..., V(s_{T-1})].
|
| 92 |
+
Pass a list of zeros for V=0 baseline (reduces to MC).
|
| 93 |
+
gamma: Discount factor.
|
| 94 |
+
lam: GAE lambda (trade-off parameter).
|
| 95 |
+
|
| 96 |
+
Returns:
|
| 97 |
+
List of GAE advantages [Â_0, ..., Â_{T-1}].
|
| 98 |
+
"""
|
| 99 |
+
assert len(rewards) == len(values), f"len mismatch: rewards={len(rewards)} values={len(values)}"
|
| 100 |
+
advantages: List[float] = []
|
| 101 |
+
gae = 0.0
|
| 102 |
+
for t in reversed(range(len(rewards))):
|
| 103 |
+
if t == len(rewards) - 1:
|
| 104 |
+
next_value = 0.0 # Terminal state has V=0
|
| 105 |
+
else:
|
| 106 |
+
next_value = values[t + 1]
|
| 107 |
+
delta = rewards[t] + gamma * next_value - values[t]
|
| 108 |
+
gae = delta + gamma * lam * gae
|
| 109 |
+
advantages.insert(0, gae)
|
| 110 |
+
return advantages
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 114 |
+
# Loss Functions
|
| 115 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 116 |
+
|
| 117 |
+
def reinforce_loss(
|
| 118 |
+
log_probs: List[float],
|
| 119 |
+
returns: List[float],
|
| 120 |
+
) -> float:
|
| 121 |
+
"""
|
| 122 |
+
Vanilla REINFORCE (Williams, 1992).
|
| 123 |
+
|
| 124 |
+
L = -(1/T) Σ_t log π(a_t | s_t) · G_t
|
| 125 |
+
|
| 126 |
+
The gradient of this loss is an unbiased estimator of the policy gradient:
|
| 127 |
+
∇J(θ) = E[Σ_t ∇log π(a_t|s_t) · G_t]
|
| 128 |
+
|
| 129 |
+
Properties:
|
| 130 |
+
- Unbiased but HIGH variance (no baseline)
|
| 131 |
+
- Simplest possible policy gradient method
|
| 132 |
+
- Good for initial prototyping, upgrade to baseline version ASAP
|
| 133 |
+
|
| 134 |
+
Args:
|
| 135 |
+
log_probs: log π(a_t | s_t) for each timestep.
|
| 136 |
+
returns: Discounted returns G_t for each timestep.
|
| 137 |
+
|
| 138 |
+
Returns:
|
| 139 |
+
Scalar loss (minimize to maximize expected return).
|
| 140 |
+
"""
|
| 141 |
+
assert len(log_probs) == len(returns), f"len mismatch: log_probs={len(log_probs)} returns={len(returns)}"
|
| 142 |
+
t = len(log_probs)
|
| 143 |
+
if t == 0:
|
| 144 |
+
return 0.0
|
| 145 |
+
return -sum(lp * g for lp, g in zip(log_probs, returns)) / t
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def reinforce_baseline_loss(
|
| 149 |
+
log_probs: List[float],
|
| 150 |
+
returns: List[float],
|
| 151 |
+
baselines: Optional[List[float]] = None,
|
| 152 |
+
normalize_advantage: bool = True,
|
| 153 |
+
eps: float = 1e-8,
|
| 154 |
+
) -> float:
|
| 155 |
+
"""
|
| 156 |
+
REINFORCE with baseline (variance reduction).
|
| 157 |
+
|
| 158 |
+
L = -(1/T) Σ_t log π(a_t | s_t) · Â_t
|
| 159 |
+
|
| 160 |
+
where Â_t = G_t - b_t is the advantage estimate.
|
| 161 |
+
|
| 162 |
+
The baseline b_t does NOT introduce bias (only reduces variance)
|
| 163 |
+
because E[∇log π(a|s) · b(s)] = 0 for any function b(s).
|
| 164 |
+
|
| 165 |
+
Baseline options:
|
| 166 |
+
- None (or zeros): Falls back to vanilla REINFORCE on returns
|
| 167 |
+
- Running mean of returns: Simple, effective, no extra model needed
|
| 168 |
+
- Learned value function: Most powerful, but needs value head
|
| 169 |
+
|
| 170 |
+
For SRE agents on Colab: use running-mean baseline (pass baselines as
|
| 171 |
+
the moving average of returns seen so far). No extra model needed.
|
| 172 |
+
|
| 173 |
+
Advantage normalization: Standardizes  to zero mean and unit variance.
|
| 174 |
+
This is critical for SRE because raw returns can span orders of magnitude
|
| 175 |
+
(0.001 vs 10.0) across episodes. Normalization keeps the learning rate
|
| 176 |
+
well-conditioned.
|
| 177 |
+
|
| 178 |
+
Args:
|
| 179 |
+
log_probs: log π(a_t | s_t) for each timestep.
|
| 180 |
+
returns: Discounted returns G_t for each timestep.
|
| 181 |
+
baselines: Baseline estimates b_t. If None, uses mean(returns).
|
| 182 |
+
normalize_advantage: Whether to standardize advantages (recommended).
|
| 183 |
+
eps: Epsilon for numerical stability in normalization.
|
| 184 |
+
|
| 185 |
+
Returns:
|
| 186 |
+
Scalar loss.
|
| 187 |
+
"""
|
| 188 |
+
assert len(log_probs) == len(returns), f"len mismatch: log_probs={len(log_probs)} returns={len(returns)}"
|
| 189 |
+
t = len(log_probs)
|
| 190 |
+
if t == 0:
|
| 191 |
+
return 0.0
|
| 192 |
+
|
| 193 |
+
# Compute advantages
|
| 194 |
+
if baselines is None:
|
| 195 |
+
# Default baseline = mean of returns (simple but effective)
|
| 196 |
+
baseline_val = sum(returns) / len(returns)
|
| 197 |
+
advantages = [g - baseline_val for g in returns]
|
| 198 |
+
else:
|
| 199 |
+
assert len(baselines) == len(returns), f"len mismatch: baselines={len(baselines)} returns={len(returns)}"
|
| 200 |
+
advantages = [g - b for g, b in zip(returns, baselines)]
|
| 201 |
+
|
| 202 |
+
# Normalize advantages (critical for SRE reward scale)
|
| 203 |
+
if normalize_advantage and len(advantages) > 1:
|
| 204 |
+
mean_adv = sum(advantages) / len(advantages)
|
| 205 |
+
var_adv = sum((a - mean_adv) ** 2 for a in advantages) / len(advantages)
|
| 206 |
+
std_adv = math.sqrt(var_adv) + eps
|
| 207 |
+
advantages = [(a - mean_adv) / std_adv for a in advantages]
|
| 208 |
+
|
| 209 |
+
return -sum(lp * adv for lp, adv in zip(log_probs, advantages)) / t
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def grpo_loss(
|
| 213 |
+
log_probs_groups: List[List[float]],
|
| 214 |
+
rewards_groups: List[List[float]],
|
| 215 |
+
eps: float = 1e-8,
|
| 216 |
+
) -> float:
|
| 217 |
+
"""
|
| 218 |
+
Group Relative Policy Optimization (GRPO).
|
| 219 |
+
|
| 220 |
+
For each state s, generate K sample actions and compute group-relative
|
| 221 |
+
advantages without needing a value function:
|
| 222 |
+
|
| 223 |
+
Â_k = (r_k - μ_group) / σ_group
|
| 224 |
+
|
| 225 |
+
L = -(1/N) Σ_i (1/K_i) Σ_k log π(a_{i,k} | s_i) · Â_{i,k}
|
| 226 |
+
|
| 227 |
+
where μ_group and σ_group are the mean and std of rewards within group i.
|
| 228 |
+
|
| 229 |
+
This is the method used in DeepSeek-R1. It eliminates the need for a
|
| 230 |
+
value head entirely — the group statistics serve as the baseline.
|
| 231 |
+
|
| 232 |
+
Pros:
|
| 233 |
+
- No value function needed
|
| 234 |
+
- Low variance (group statistics absorb reward scale)
|
| 235 |
+
- Natural normalization
|
| 236 |
+
|
| 237 |
+
Cons:
|
| 238 |
+
- Requires K >= 2 rollouts per state (K * more compute)
|
| 239 |
+
- For K=1, falls back to REINFORCE (no baseline)
|
| 240 |
+
|
| 241 |
+
For SRE on Colab: Use K=4 with QLoRA on a T4. Each "group" is 4
|
| 242 |
+
different actions sampled for the same cluster state.
|
| 243 |
+
|
| 244 |
+
Args:
|
| 245 |
+
log_probs_groups: List of groups, each group is log π(a_k|s) for K samples.
|
| 246 |
+
rewards_groups: List of groups, each group is reward_k for K samples.
|
| 247 |
+
eps: Epsilon for std normalization.
|
| 248 |
+
|
| 249 |
+
Returns:
|
| 250 |
+
Scalar loss.
|
| 251 |
+
"""
|
| 252 |
+
assert len(log_probs_groups) == len(rewards_groups), "group count mismatch"
|
| 253 |
+
if not log_probs_groups:
|
| 254 |
+
return 0.0
|
| 255 |
+
|
| 256 |
+
total_loss = 0.0
|
| 257 |
+
n_groups = 0
|
| 258 |
+
|
| 259 |
+
for log_probs, rewards in zip(log_probs_groups, rewards_groups):
|
| 260 |
+
assert len(log_probs) == len(rewards), f"group size mismatch: {len(log_probs)} vs {len(rewards)}"
|
| 261 |
+
k = len(log_probs)
|
| 262 |
+
if k == 0:
|
| 263 |
+
continue
|
| 264 |
+
|
| 265 |
+
# Group statistics
|
| 266 |
+
mean_r = sum(rewards) / k
|
| 267 |
+
var_r = sum((r - mean_r) ** 2 for r in rewards) / k
|
| 268 |
+
std_r = math.sqrt(var_r) + eps
|
| 269 |
+
|
| 270 |
+
# Normalized advantages
|
| 271 |
+
advantages = [(r - mean_r) / std_r for r in rewards]
|
| 272 |
+
|
| 273 |
+
# Policy gradient for this group
|
| 274 |
+
group_loss = -sum(lp * adv for lp, adv in zip(log_probs, advantages)) / k
|
| 275 |
+
total_loss += group_loss
|
| 276 |
+
n_groups += 1
|
| 277 |
+
|
| 278 |
+
return total_loss / max(1, n_groups)
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def rloo_loss(
|
| 282 |
+
log_probs_groups: List[List[float]],
|
| 283 |
+
rewards_groups: List[List[float]],
|
| 284 |
+
) -> float:
|
| 285 |
+
"""
|
| 286 |
+
REINFORCE Leave-One-Out (RLOO).
|
| 287 |
+
|
| 288 |
+
Similar to GRPO but uses a leave-one-out baseline instead of group
|
| 289 |
+
statistics. For each sample k in a group of K:
|
| 290 |
+
|
| 291 |
+
b_k = (1/(K-1)) Σ_{j≠k} r_j (leave-one-out mean)
|
| 292 |
+
|
| 293 |
+
Â_k = r_k - b_k
|
| 294 |
+
|
| 295 |
+
L = -(1/N) Σ_i (1/K_i) Σ_k log π(a_{i,k} | s_i) · Â_{i,k}
|
| 296 |
+
|
| 297 |
+
RLOO has lower variance than GRPO for small group sizes (K=2-4) because
|
| 298 |
+
the baseline is computed from the actual other samples rather than a
|
| 299 |
+
statistical estimate. For K >= 8, GRPO and RLOO converge.
|
| 300 |
+
|
| 301 |
+
For SRE on Colab: Best choice when you can only afford K=2-4 rollouts.
|
| 302 |
+
The leave-one-out baseline is surprisingly effective.
|
| 303 |
+
|
| 304 |
+
Args:
|
| 305 |
+
log_probs_groups: List of groups, each group is log π(a_k|s) for K samples.
|
| 306 |
+
rewards_groups: List of groups, each group is reward_k for K samples.
|
| 307 |
+
|
| 308 |
+
Returns:
|
| 309 |
+
Scalar loss.
|
| 310 |
+
"""
|
| 311 |
+
assert len(log_probs_groups) == len(rewards_groups), "group count mismatch"
|
| 312 |
+
if not log_probs_groups:
|
| 313 |
+
return 0.0
|
| 314 |
+
|
| 315 |
+
total_loss = 0.0
|
| 316 |
+
n_groups = 0
|
| 317 |
+
|
| 318 |
+
for log_probs, rewards in zip(log_probs_groups, rewards_groups):
|
| 319 |
+
assert len(log_probs) == len(rewards), f"group size mismatch: {len(log_probs)} vs {len(rewards)}"
|
| 320 |
+
k = len(log_probs)
|
| 321 |
+
if k == 0:
|
| 322 |
+
continue
|
| 323 |
+
|
| 324 |
+
if k == 1:
|
| 325 |
+
# Single sample: no baseline possible, fall back to REINFORCE
|
| 326 |
+
total_loss += -log_probs[0] * rewards[0]
|
| 327 |
+
n_groups += 1
|
| 328 |
+
continue
|
| 329 |
+
|
| 330 |
+
# Leave-one-out baselines
|
| 331 |
+
sum_r = sum(rewards)
|
| 332 |
+
baselines = [(sum_r - r) / (k - 1) for r in rewards]
|
| 333 |
+
advantages = [r - b for r, b in zip(rewards, baselines)]
|
| 334 |
+
|
| 335 |
+
# Policy gradient
|
| 336 |
+
group_loss = -sum(lp * adv for lp, adv in zip(log_probs, advantages)) / k
|
| 337 |
+
total_loss += group_loss
|
| 338 |
+
n_groups += 1
|
| 339 |
+
|
| 340 |
+
return total_loss / max(1, n_groups)
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 344 |
+
# Reward normalization utilities
|
| 345 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 346 |
+
|
| 347 |
+
def normalize_rewards(
|
| 348 |
+
rewards: List[float],
|
| 349 |
+
running_mean: float = 0.0,
|
| 350 |
+
running_var: float = 1.0,
|
| 351 |
+
eps: float = 1e-8,
|
| 352 |
+
) -> List[float]:
|
| 353 |
+
"""
|
| 354 |
+
Normalize rewards using running statistics.
|
| 355 |
+
|
| 356 |
+
For SRE: Raw rewards are always negative (they're penalties). This function
|
| 357 |
+
shifts them to be centered around zero with unit variance, which is
|
| 358 |
+
essential for stable policy gradient updates.
|
| 359 |
+
|
| 360 |
+
On Colab: Maintain a running mean/var across episodes and pass them here.
|
| 361 |
+
Initialize with mean=0, var=1 and update with exponential moving average.
|
| 362 |
+
|
| 363 |
+
running_mean = 0.99 * running_mean + 0.01 * batch_mean
|
| 364 |
+
running_var = 0.99 * running_var + 0.01 * batch_var
|
| 365 |
+
|
| 366 |
+
Args:
|
| 367 |
+
rewards: Raw rewards to normalize.
|
| 368 |
+
running_mean: Running mean estimate across episodes.
|
| 369 |
+
running_var: Running variance estimate across episodes.
|
| 370 |
+
eps: Numerical stability constant.
|
| 371 |
+
|
| 372 |
+
Returns:
|
| 373 |
+
Normalized rewards (zero mean, unit variance relative to running stats).
|
| 374 |
+
"""
|
| 375 |
+
std = math.sqrt(running_var) + eps
|
| 376 |
+
return [(r - running_mean) / std for r in rewards]
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
def compute_reward_stats(rewards: List[float]) -> tuple[float, float]:
|
| 380 |
+
"""
|
| 381 |
+
Compute mean and variance of a reward list.
|
| 382 |
+
|
| 383 |
+
Returns:
|
| 384 |
+
(mean, variance) tuple.
|
| 385 |
+
"""
|
| 386 |
+
if not rewards:
|
| 387 |
+
return 0.0, 1.0
|
| 388 |
+
mean = sum(rewards) / len(rewards)
|
| 389 |
+
var = sum((r - mean) ** 2 for r in rewards) / len(rewards)
|
| 390 |
+
return mean, var
|
training/trainer.py
CHANGED
|
@@ -1,559 +1,559 @@
|
|
| 1 |
-
"""
|
| 2 |
-
AntiAtropos Training Loop.
|
| 3 |
-
|
| 4 |
-
Orchestrates episode collection, reward computation, and loss calculation
|
| 5 |
-
for training LLM-based SRE agents. Works with the local simulator in
|
| 6 |
-
pure-Python mode (no AWS/GPU needed for validation).
|
| 7 |
-
|
| 8 |
-
On Colab: Replace EpisodeCollector's "model" with a real QLoRA-backed
|
| 9 |
-
transformers model. The rest of the pipeline stays the same.
|
| 10 |
-
"""
|
| 11 |
-
|
| 12 |
-
from __future__ import annotations
|
| 13 |
-
|
| 14 |
-
import random
|
| 15 |
-
import math
|
| 16 |
-
from dataclasses import dataclass, field
|
| 17 |
-
from typing import List, Optional, Protocol, Callable
|
| 18 |
-
|
| 19 |
-
import sys
|
| 20 |
-
import os
|
| 21 |
-
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
|
| 22 |
-
|
| 23 |
-
from simulator import ClusterSimulator, NodeStatus, COST_PER_CAPACITY_UNIT_PER_HOUR
|
| 24 |
-
from stability import (
|
| 25 |
-
compute_lyapunov, compute_reward, compute_barrier,
|
| 26 |
-
normalize_reward, smooth_sla_penalty,
|
| 27 |
-
)
|
| 28 |
-
from .losses import (
|
| 29 |
-
compute_returns, compute_gae,
|
| 30 |
-
reinforce_loss, reinforce_baseline_loss,
|
| 31 |
-
grpo_loss, rloo_loss,
|
| 32 |
-
normalize_rewards, compute_reward_stats,
|
| 33 |
-
)
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 37 |
-
# Configuration
|
| 38 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 39 |
-
|
| 40 |
-
LOSS_REINFORCE = "reinforce"
|
| 41 |
-
LOSS_REINFORCE_BASELINE = "reinforce_baseline"
|
| 42 |
-
LOSS_GRPO = "grpo"
|
| 43 |
-
LOSS_RLOO = "rloo"
|
| 44 |
-
|
| 45 |
-
VALID_LOSSES = {LOSS_REINFORCE, LOSS_REINFORCE_BASELINE, LOSS_GRPO, LOSS_RLOO}
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
@dataclass
|
| 49 |
-
class TrainingConfig:
|
| 50 |
-
"""Configuration for the SRE training loop."""
|
| 51 |
-
|
| 52 |
-
# Episode settings
|
| 53 |
-
n_nodes: int = 5
|
| 54 |
-
max_steps: int = 100
|
| 55 |
-
tasks: List[str] = field(default_factory=lambda: ["task-1", "task-2", "task-3"])
|
| 56 |
-
|
| 57 |
-
# Loss function
|
| 58 |
-
loss_fn: str = LOSS_REINFORCE_BASELINE # Recommended starting point
|
| 59 |
-
gamma: float = 0.99 # Discount factor
|
| 60 |
-
gae_lambda: float = 0.95 # GAE lambda (only used with GAE advantages)
|
| 61 |
-
|
| 62 |
-
# GRPO / RLOO settings
|
| 63 |
-
n_samples_per_state: int = 4 # K rollouts per state for GRPO/RLOO
|
| 64 |
-
|
| 65 |
-
# Reward normalization
|
| 66 |
-
normalize_rewards: bool = True
|
| 67 |
-
reward_ema_alpha: float = 0.01 # Exponential moving average update rate
|
| 68 |
-
|
| 69 |
-
# Advantage normalization
|
| 70 |
-
normalize_advantages: bool = True # Standardize advantages (critical for SRE)
|
| 71 |
-
|
| 72 |
-
# Logging
|
| 73 |
-
log_every: int = 10 # Log every N episodes
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 77 |
-
# Model Protocol (abstraction for real LLM or mock)
|
| 78 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 79 |
-
|
| 80 |
-
class PolicyModel(Protocol):
|
| 81 |
-
"""Interface that both real LLMs and mock models must implement."""
|
| 82 |
-
|
| 83 |
-
def get_log_prob(self, prompt: str, action_text: str) -> float:
|
| 84 |
-
"""Return log π(action_text | prompt) under the current policy."""
|
| 85 |
-
...
|
| 86 |
-
|
| 87 |
-
def generate(self, prompt: str) -> str:
|
| 88 |
-
"""Sample an action from the current policy."""
|
| 89 |
-
...
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
class MockPolicyModel:
|
| 93 |
-
"""
|
| 94 |
-
Random policy for local testing. Generates random valid actions
|
| 95 |
-
and returns stochastic log probabilities.
|
| 96 |
-
|
| 97 |
-
This is NOT for training — it's for validating the training pipeline
|
| 98 |
-
(episode collection, reward computation, loss calculation) end-to-end
|
| 99 |
-
before connecting a real model.
|
| 100 |
-
|
| 101 |
-
Unlike a truly uniform policy (which would produce zero gradient with
|
| 102 |
-
advantage normalization), this model returns varying log probs per
|
| 103 |
-
action, simulating a real LLM that prefers some actions over others.
|
| 104 |
-
This ensures the training pipeline produces non-trivial losses.
|
| 105 |
-
"""
|
| 106 |
-
|
| 107 |
-
def __init__(self, n_nodes: int = 5, seed: int = 42):
|
| 108 |
-
self._rng = random.Random(seed)
|
| 109 |
-
self._n_nodes = n_nodes
|
| 110 |
-
# Action-dependent log probs: different action types get different
|
| 111 |
-
# log probs (simulating a real model that has preferences).
|
| 112 |
-
# Base log prob ~ log(1/50) ≈ -3.9, with per-action noise.
|
| 113 |
-
self._n_choices = 5 * n_nodes
|
| 114 |
-
self._base_log_prob = math.log(1.0 / self._n_choices)
|
| 115 |
-
|
| 116 |
-
def get_log_prob(self, prompt: str, action_text: str) -> float:
|
| 117 |
-
"""Return stochastic log probability (varies per action)."""
|
| 118 |
-
# Add Gaussian noise to simulate a real model's varying confidence.
|
| 119 |
-
# std=0.5 produces meaningful variation while staying in a plausible
|
| 120 |
-
# range for LLM token log-probs.
|
| 121 |
-
noise = self._rng.gauss(0, 0.5)
|
| 122 |
-
return self._base_log_prob + noise
|
| 123 |
-
|
| 124 |
-
def generate(self, prompt: str) -> str:
|
| 125 |
-
"""Generate a random valid action as JSON string."""
|
| 126 |
-
import json
|
| 127 |
-
action_types = ["SCALE_UP", "SCALE_DOWN", "REROUTE_TRAFFIC", "SHED_LOAD", "NO_OP"]
|
| 128 |
-
node_id = f"node-{self._rng.randint(0, self._n_nodes - 1)}"
|
| 129 |
-
action_type = self._rng.choice(action_types)
|
| 130 |
-
parameter = round(self._rng.random(), 2)
|
| 131 |
-
return json.dumps({
|
| 132 |
-
"action_type": action_type,
|
| 133 |
-
"target_node_id": node_id,
|
| 134 |
-
"parameter": parameter,
|
| 135 |
-
})
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 139 |
-
# Observation formatting (mirrors inference.py logic)
|
| 140 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 141 |
-
|
| 142 |
-
MAX_QUEUE_NORM = 200.0
|
| 143 |
-
MAX_LATENCY_NORM = 1000.0
|
| 144 |
-
MAX_REQUEST_RATE_NORM = 100.0
|
| 145 |
-
ALPHA, BETA, GAMMA, DELTA = 0.002, 0.01, 10.0, 0.005
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
def format_observation(nodes: List[dict], task_id: str, step: int, max_steps: int) -> str:
|
| 149 |
-
"""
|
| 150 |
-
Format simulator state as a text prompt for the model.
|
| 151 |
-
|
| 152 |
-
This mirrors inference.py's build_user_prompt and observation_for_model.
|
| 153 |
-
"""
|
| 154 |
-
import json
|
| 155 |
-
node_data = []
|
| 156 |
-
for n in nodes:
|
| 157 |
-
node_data.append({
|
| 158 |
-
"node_id": n["node_id"],
|
| 159 |
-
"status": n["status"] if isinstance(n["status"], str) else n["status"].value,
|
| 160 |
-
"is_vip": n.get("is_vip", False),
|
| 161 |
-
"queue_depth": min(1.0, max(0.0, n["queue_depth"] / MAX_QUEUE_NORM)),
|
| 162 |
-
"latency_ms": min(1.0, max(0.0, n["latency_ms"] / MAX_LATENCY_NORM)),
|
| 163 |
-
"cpu_utilization": min(1.0, max(0.0, n.get("cpu_utilization", 0.0))),
|
| 164 |
-
"incoming_request_rate": min(1.0, max(0.0, n["incoming_request_rate"] / MAX_REQUEST_RATE_NORM)),
|
| 165 |
-
})
|
| 166 |
-
obs = {"task_id": task_id, "step": step, "max_steps": max_steps, "nodes": node_data}
|
| 167 |
-
return json.dumps(obs, separators=(",", ":"))
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
def parse_action(action_text: str) -> dict:
|
| 171 |
-
"""Parse model output into an action dict."""
|
| 172 |
-
import json
|
| 173 |
-
try:
|
| 174 |
-
data = json.loads(action_text)
|
| 175 |
-
return {
|
| 176 |
-
"action_type": str(data.get("action_type", "NO_OP")).upper(),
|
| 177 |
-
"target_node_id": str(data.get("target_node_id", "node-0")),
|
| 178 |
-
"parameter": float(data.get("parameter", 0.0)),
|
| 179 |
-
}
|
| 180 |
-
except (json.JSONDecodeError, ValueError):
|
| 181 |
-
return {"action_type": "NO_OP", "target_node_id": "node-0", "parameter": 0.0}
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 185 |
-
# Episode Collection
|
| 186 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 187 |
-
|
| 188 |
-
@dataclass
|
| 189 |
-
class StepRecord:
|
| 190 |
-
"""A single step in an episode."""
|
| 191 |
-
prompt: str # Observation formatted as text
|
| 192 |
-
action_text: str # Model output (JSON string)
|
| 193 |
-
log_prob: float # log π(action | prompt)
|
| 194 |
-
reward: float # Raw reward for this step
|
| 195 |
-
reward_normalized: float # Normalized reward
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
@dataclass
|
| 199 |
-
class EpisodeRecord:
|
| 200 |
-
"""A complete episode trajectory."""
|
| 201 |
-
task_id: str
|
| 202 |
-
steps: List[StepRecord] = field(default_factory=list)
|
| 203 |
-
total_reward: float = 0.0
|
| 204 |
-
total_reward_normalized: float = 0.0
|
| 205 |
-
avg_reward: float = 0.0
|
| 206 |
-
sla_violations: int = 0
|
| 207 |
-
final_lyapunov: float = 0.0
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
class EpisodeCollector:
|
| 211 |
-
"""
|
| 212 |
-
Collects episodes by running the simulator with a policy model.
|
| 213 |
-
|
| 214 |
-
This is the bridge between the simulator (physics) and the training
|
| 215 |
-
pipeline (loss computation). It produces EpisodeRecords that feed
|
| 216 |
-
directly into the loss functions.
|
| 217 |
-
"""
|
| 218 |
-
|
| 219 |
-
def __init__(self, config: TrainingConfig):
|
| 220 |
-
self._config = config
|
| 221 |
-
self._sim = ClusterSimulator(n_nodes=config.n_nodes)
|
| 222 |
-
|
| 223 |
-
def collect_episode(
|
| 224 |
-
self,
|
| 225 |
-
model: PolicyModel,
|
| 226 |
-
task_id: str,
|
| 227 |
-
seed: Optional[int] = None,
|
| 228 |
-
) -> EpisodeRecord:
|
| 229 |
-
"""Run one episode and collect step-level data."""
|
| 230 |
-
cfg = self._config
|
| 231 |
-
self._sim.reset(task_id=task_id, seed=seed)
|
| 232 |
-
|
| 233 |
-
record = EpisodeRecord(task_id=task_id)
|
| 234 |
-
prev_lyapunov = 0.0
|
| 235 |
-
|
| 236 |
-
for step in range(1, cfg.max_steps + 1):
|
| 237 |
-
# 1. Get observation
|
| 238 |
-
nodes_true = self._sim.state(for_agent=False)
|
| 239 |
-
nodes_obs = self._sim.state(for_agent=True)
|
| 240 |
-
prompt = format_observation(nodes_obs, task_id, step, cfg.max_steps)
|
| 241 |
-
|
| 242 |
-
# 2. Get action from model
|
| 243 |
-
action_text = model.generate(prompt)
|
| 244 |
-
log_prob = model.get_log_prob(prompt, action_text)
|
| 245 |
-
|
| 246 |
-
# 3. Apply action
|
| 247 |
-
action = parse_action(action_text)
|
| 248 |
-
class _A:
|
| 249 |
-
pass
|
| 250 |
-
a = _A()
|
| 251 |
-
a.action_type = action["action_type"]
|
| 252 |
-
a.target_node_id = action["target_node_id"]
|
| 253 |
-
a.parameter = action["parameter"]
|
| 254 |
-
self._sim.apply_action(a)
|
| 255 |
-
|
| 256 |
-
# 4. Tick
|
| 257 |
-
self._sim.tick()
|
| 258 |
-
|
| 259 |
-
# 5. Compute reward (mirrors environment.py)
|
| 260 |
-
nodes_true = self._sim.state(for_agent=False)
|
| 261 |
-
current_lyapunov = compute_lyapunov(nodes_true)
|
| 262 |
-
|
| 263 |
-
# Importance-weighted average latency
|
| 264 |
-
w_lat = 0.0
|
| 265 |
-
w_sum = 0.0
|
| 266 |
-
for n in nodes_true:
|
| 267 |
-
w = n.get("importance_weight", 1.0)
|
| 268 |
-
lat = MAX_LATENCY_NORM if n["status"] == NodeStatus.FAILED else n["latency_ms"]
|
| 269 |
-
w_lat += w * lat
|
| 270 |
-
w_sum += w
|
| 271 |
-
avg_lat_norm = min(1.0, max(0.0, (w_lat / w_sum / MAX_LATENCY_NORM) if w_sum > 0 else 1.0))
|
| 272 |
-
|
| 273 |
-
# Error rate
|
| 274 |
-
total_in = sum(n.get("incoming_request_rate", 0) * n.get("importance_weight", 1.0) for n in nodes_true)
|
| 275 |
-
total_drop = sum(n.get("dropped_requests", 0) * n.get("importance_weight", 1.0) for n in nodes_true)
|
| 276 |
-
error_rate = min(1.0, total_drop / total_in) if total_in > 0 else 0.0
|
| 277 |
-
|
| 278 |
-
sla_step = smooth_sla_penalty(avg_lat_norm, error_rate)
|
| 279 |
-
if avg_lat_norm > 0.20 or error_rate > 0.05:
|
| 280 |
-
record.sla_violations += 1
|
| 281 |
-
|
| 282 |
-
# Cost
|
| 283 |
-
total_cap = 0
|
| 284 |
-
for n in nodes_true:
|
| 285 |
-
if n["status"] != NodeStatus.FAILED:
|
| 286 |
-
total_cap += int(n.get("capacity_units", 0)) + int(n.get("pending_capacity_units", 0))
|
| 287 |
-
cost = total_cap * COST_PER_CAPACITY_UNIT_PER_HOUR
|
| 288 |
-
|
| 289 |
-
barrier = compute_barrier(nodes_true)
|
| 290 |
-
raw_reward = compute_reward(
|
| 291 |
-
prev_lyapunov, current_lyapunov, cost, sla_step,
|
| 292 |
-
ALPHA, BETA, GAMMA, barrier, DELTA,
|
| 293 |
-
)
|
| 294 |
-
norm_reward = normalize_reward(raw_reward)
|
| 295 |
-
|
| 296 |
-
record.steps.append(StepRecord(
|
| 297 |
-
prompt=prompt,
|
| 298 |
-
action_text=action_text,
|
| 299 |
-
log_prob=log_prob,
|
| 300 |
-
reward=raw_reward,
|
| 301 |
-
reward_normalized=norm_reward,
|
| 302 |
-
))
|
| 303 |
-
record.total_reward += raw_reward
|
| 304 |
-
record.total_reward_normalized += norm_reward
|
| 305 |
-
prev_lyapunov = current_lyapunov
|
| 306 |
-
|
| 307 |
-
record.avg_reward = record.total_reward / max(1, len(record.steps))
|
| 308 |
-
record.final_lyapunov = prev_lyapunov
|
| 309 |
-
return record
|
| 310 |
-
|
| 311 |
-
def collect_group(
|
| 312 |
-
self,
|
| 313 |
-
model: PolicyModel,
|
| 314 |
-
task_id: str,
|
| 315 |
-
k: int,
|
| 316 |
-
seed: Optional[int] = None,
|
| 317 |
-
) -> List[EpisodeRecord]:
|
| 318 |
-
"""
|
| 319 |
-
Collect K episodes from the same initial state (for GRPO/RLOO).
|
| 320 |
-
|
| 321 |
-
Uses the same seed for all K episodes so they start from the same
|
| 322 |
-
domain randomization, but different model samples produce different
|
| 323 |
-
trajectories.
|
| 324 |
-
"""
|
| 325 |
-
return [self.collect_episode(model, task_id, seed=seed) for _ in range(k)]
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 329 |
-
# Trainer
|
| 330 |
-
# ════════════════════════════════════════════════════════════════════════════════
|
| 331 |
-
|
| 332 |
-
class SRETrainer:
|
| 333 |
-
"""
|
| 334 |
-
Main training orchestrator for AntiAtropos SRE agents.
|
| 335 |
-
|
| 336 |
-
Usage (local validation with MockPolicyModel):
|
| 337 |
-
config = TrainingConfig(loss_fn="reinforce_baseline")
|
| 338 |
-
trainer = SRETrainer(config)
|
| 339 |
-
model = MockPolicyModel()
|
| 340 |
-
metrics = trainer.train_step(model, task_id="task-1", seed=42)
|
| 341 |
-
|
| 342 |
-
Usage (Colab with real model):
|
| 343 |
-
config = TrainingConfig(loss_fn="grpo", n_samples_per_state=4)
|
| 344 |
-
trainer = SRETrainer(config)
|
| 345 |
-
model = QLoRAModel(...) # Your transformers model
|
| 346 |
-
for epoch in range(num_epochs):
|
| 347 |
-
for task in config.tasks:
|
| 348 |
-
metrics = trainer.train_step(model, task_id=task)
|
| 349 |
-
model.update(metrics["loss"]) # Backprop
|
| 350 |
-
"""
|
| 351 |
-
|
| 352 |
-
def __init__(self, config: TrainingConfig):
|
| 353 |
-
assert config.loss_fn in VALID_LOSSES, f"Unknown loss: {config.loss_fn}"
|
| 354 |
-
self._config = config
|
| 355 |
-
self._collector = EpisodeCollector(config)
|
| 356 |
-
self._running_reward_mean = 0.0
|
| 357 |
-
self._running_reward_var = 1.0
|
| 358 |
-
self._episode_count = 0
|
| 359 |
-
|
| 360 |
-
def train_step(
|
| 361 |
-
self,
|
| 362 |
-
model: PolicyModel,
|
| 363 |
-
task_id: str,
|
| 364 |
-
seed: Optional[int] = None,
|
| 365 |
-
) -> dict:
|
| 366 |
-
"""
|
| 367 |
-
Execute one training step: collect episode(s) → compute loss.
|
| 368 |
-
|
| 369 |
-
Returns a metrics dict with:
|
| 370 |
-
- loss: The computed loss value
|
| 371 |
-
- avg_reward: Average raw reward across the episode
|
| 372 |
-
- avg_norm_reward: Average normalized reward
|
| 373 |
-
- episode_length: Number of steps
|
| 374 |
-
- sla_violations: Number of SLA violations
|
| 375 |
-
- final_lyapunov: Lyapunov energy at episode end
|
| 376 |
-
- reward_mean/var: Running reward statistics
|
| 377 |
-
"""
|
| 378 |
-
cfg = self._config
|
| 379 |
-
|
| 380 |
-
if cfg.loss_fn in (LOSS_GRPO, LOSS_RLOO):
|
| 381 |
-
return self._train_step_grouped(model, task_id, seed)
|
| 382 |
-
else:
|
| 383 |
-
return self._train_step_single(model, task_id, seed)
|
| 384 |
-
|
| 385 |
-
def _train_step_single(
|
| 386 |
-
self,
|
| 387 |
-
model: PolicyModel,
|
| 388 |
-
task_id: str,
|
| 389 |
-
seed: Optional[int] = None,
|
| 390 |
-
) -> dict:
|
| 391 |
-
"""Train step for REINFORCE / REINFORCE+baseline."""
|
| 392 |
-
cfg = self._config
|
| 393 |
-
|
| 394 |
-
# 1. Collect episode
|
| 395 |
-
episode = self._collector.collect_episode(model, task_id, seed=seed)
|
| 396 |
-
|
| 397 |
-
# 2. Extract rewards and log probs
|
| 398 |
-
rewards = [s.reward for s in episode.steps]
|
| 399 |
-
log_probs = [s.log_prob for s in episode.steps]
|
| 400 |
-
|
| 401 |
-
# 3. Update running reward stats
|
| 402 |
-
ep_mean, ep_var = compute_reward_stats(rewards)
|
| 403 |
-
self._running_reward_mean = (
|
| 404 |
-
(1 - cfg.reward_ema_alpha) * self._running_reward_mean
|
| 405 |
-
+ cfg.reward_ema_alpha * ep_mean
|
| 406 |
-
)
|
| 407 |
-
self._running_reward_var = (
|
| 408 |
-
(1 - cfg.reward_ema_alpha) * self._running_reward_var
|
| 409 |
-
+ cfg.reward_ema_alpha * ep_var
|
| 410 |
-
)
|
| 411 |
-
|
| 412 |
-
# 4. Optionally normalize rewards
|
| 413 |
-
if cfg.normalize_rewards:
|
| 414 |
-
rewards = normalize_rewards(
|
| 415 |
-
rewards, self._running_reward_mean, self._running_reward_var
|
| 416 |
-
)
|
| 417 |
-
|
| 418 |
-
# 5. Compute returns
|
| 419 |
-
returns = compute_returns(rewards, gamma=cfg.gamma)
|
| 420 |
-
|
| 421 |
-
# 6. Compute loss
|
| 422 |
-
if cfg.loss_fn == LOSS_REINFORCE:
|
| 423 |
-
loss = reinforce_loss(log_probs, returns)
|
| 424 |
-
elif cfg.loss_fn == LOSS_REINFORCE_BASELINE:
|
| 425 |
-
# Use running mean as baseline
|
| 426 |
-
baselines = [self._running_reward_mean] * len(returns)
|
| 427 |
-
loss = reinforce_baseline_loss(
|
| 428 |
-
log_probs, returns, baselines,
|
| 429 |
-
normalize_advantage=cfg.normalize_advantages,
|
| 430 |
-
)
|
| 431 |
-
else:
|
| 432 |
-
raise ValueError(f"Unexpected loss_fn: {cfg.loss_fn}")
|
| 433 |
-
|
| 434 |
-
self._episode_count += 1
|
| 435 |
-
|
| 436 |
-
return {
|
| 437 |
-
"loss": loss,
|
| 438 |
-
"avg_reward": episode.avg_reward,
|
| 439 |
-
"avg_norm_reward": episode.total_reward_normalized / max(1, len(episode.steps)),
|
| 440 |
-
"episode_length": len(episode.steps),
|
| 441 |
-
"sla_violations": episode.sla_violations,
|
| 442 |
-
"final_lyapunov": episode.final_lyapunov,
|
| 443 |
-
"reward_mean": self._running_reward_mean,
|
| 444 |
-
"reward_var": self._running_reward_var,
|
| 445 |
-
"task_id": task_id,
|
| 446 |
-
"episode": episode,
|
| 447 |
-
}
|
| 448 |
-
|
| 449 |
-
def _train_step_grouped(
|
| 450 |
-
self,
|
| 451 |
-
model: PolicyModel,
|
| 452 |
-
task_id: str,
|
| 453 |
-
seed: Optional[int] = None,
|
| 454 |
-
) -> dict:
|
| 455 |
-
"""Train step for GRPO / RLOO."""
|
| 456 |
-
cfg = self._config
|
| 457 |
-
k = cfg.n_samples_per_state
|
| 458 |
-
|
| 459 |
-
# 1. Collect K episodes (same seed → same domain randomization)
|
| 460 |
-
episodes = self._collector.collect_group(model, task_id, k=k, seed=seed)
|
| 461 |
-
|
| 462 |
-
# 2. For each step position, form groups across episodes
|
| 463 |
-
# (assumes all episodes have same length)
|
| 464 |
-
min_len = min(len(ep.steps) for ep in episodes)
|
| 465 |
-
|
| 466 |
-
log_probs_groups = []
|
| 467 |
-
rewards_groups = []
|
| 468 |
-
|
| 469 |
-
for t in range(min_len):
|
| 470 |
-
step_lps = []
|
| 471 |
-
step_rs = []
|
| 472 |
-
for ep in episodes:
|
| 473 |
-
step_lps.append(ep.steps[t].log_prob)
|
| 474 |
-
step_rs.append(ep.steps[t].reward)
|
| 475 |
-
log_probs_groups.append(step_lps)
|
| 476 |
-
rewards_groups.append(step_rs)
|
| 477 |
-
|
| 478 |
-
# 3. Update running stats
|
| 479 |
-
all_rewards = [s.reward for ep in episodes for s in ep.steps]
|
| 480 |
-
ep_mean, ep_var = compute_reward_stats(all_rewards)
|
| 481 |
-
self._running_reward_mean = (
|
| 482 |
-
(1 - cfg.reward_ema_alpha) * self._running_reward_mean
|
| 483 |
-
+ cfg.reward_ema_alpha * ep_mean
|
| 484 |
-
)
|
| 485 |
-
self._running_reward_var = (
|
| 486 |
-
(1 - cfg.reward_ema_alpha) * self._running_reward_var
|
| 487 |
-
+ cfg.reward_ema_alpha * ep_var
|
| 488 |
-
)
|
| 489 |
-
|
| 490 |
-
# 4. Normalize rewards
|
| 491 |
-
if cfg.normalize_rewards:
|
| 492 |
-
rewards_groups = [
|
| 493 |
-
normalize_rewards(rs, self._running_reward_mean, self._running_reward_var)
|
| 494 |
-
for rs in rewards_groups
|
| 495 |
-
]
|
| 496 |
-
|
| 497 |
-
# 5. Compute loss
|
| 498 |
-
if cfg.loss_fn == LOSS_GRPO:
|
| 499 |
-
loss = grpo_loss(log_probs_groups, rewards_groups)
|
| 500 |
-
elif cfg.loss_fn == LOSS_RLOO:
|
| 501 |
-
loss = rloo_loss(log_probs_groups, rewards_groups)
|
| 502 |
-
else:
|
| 503 |
-
raise ValueError(f"Unexpected grouped loss_fn: {cfg.loss_fn}")
|
| 504 |
-
|
| 505 |
-
# 6. Aggregate metrics across episodes
|
| 506 |
-
avg_reward = sum(ep.avg_reward for ep in episodes) / len(episodes)
|
| 507 |
-
avg_norm = sum(
|
| 508 |
-
ep.total_reward_normalized / max(1, len(ep.steps)) for ep in episodes
|
| 509 |
-
) / len(episodes)
|
| 510 |
-
total_sla = sum(ep.sla_violations for ep in episodes)
|
| 511 |
-
avg_lyapunov = sum(ep.final_lyapunov for ep in episodes) / len(episodes)
|
| 512 |
-
|
| 513 |
-
self._episode_count += k
|
| 514 |
-
|
| 515 |
-
return {
|
| 516 |
-
"loss": loss,
|
| 517 |
-
"avg_reward": avg_reward,
|
| 518 |
-
"avg_norm_reward": avg_norm,
|
| 519 |
-
"episode_length": min_len,
|
| 520 |
-
"sla_violations": total_sla,
|
| 521 |
-
"final_lyapunov": avg_lyapunov,
|
| 522 |
-
"reward_mean": self._running_reward_mean,
|
| 523 |
-
"reward_var": self._running_reward_var,
|
| 524 |
-
"task_id": task_id,
|
| 525 |
-
"episodes": episodes,
|
| 526 |
-
}
|
| 527 |
-
|
| 528 |
-
def train_epoch(
|
| 529 |
-
self,
|
| 530 |
-
model: PolicyModel,
|
| 531 |
-
seed: Optional[int] = None,
|
| 532 |
-
) -> List[dict]:
|
| 533 |
-
"""
|
| 534 |
-
Run one training step per task in the curriculum.
|
| 535 |
-
|
| 536 |
-
Returns a list of metrics dicts (one per task).
|
| 537 |
-
"""
|
| 538 |
-
results = []
|
| 539 |
-
for task_id in self._config.tasks:
|
| 540 |
-
step_seed = seed + hash(task_id) % 1000 if seed is not None else None
|
| 541 |
-
metrics = self.train_step(model, task_id, seed=step_seed)
|
| 542 |
-
results.append(metrics)
|
| 543 |
-
if self._episode_count % self._config.log_every == 0:
|
| 544 |
-
self._log_metrics(metrics)
|
| 545 |
-
return results
|
| 546 |
-
|
| 547 |
-
def _log_metrics(self, metrics: dict) -> None:
|
| 548 |
-
"""Print training metrics."""
|
| 549 |
-
print(
|
| 550 |
-
f"[Episode {self._episode_count}] "
|
| 551 |
-
f"task={metrics['task_id']} "
|
| 552 |
-
f"loss={metrics['loss']:.4f} "
|
| 553 |
-
f"avg_reward={metrics['avg_reward']:.4f} "
|
| 554 |
-
f"avg_norm_reward={metrics['avg_norm_reward']:.4f} "
|
| 555 |
-
f"sla_violations={metrics['sla_violations']} "
|
| 556 |
-
f"lyapunov={metrics['final_lyapunov']:.1f} "
|
| 557 |
-
f"reward_mean={metrics['reward_mean']:.4f} "
|
| 558 |
-
f"reward_var={metrics['reward_var']:.4f}"
|
| 559 |
-
)
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AntiAtropos Training Loop.
|
| 3 |
+
|
| 4 |
+
Orchestrates episode collection, reward computation, and loss calculation
|
| 5 |
+
for training LLM-based SRE agents. Works with the local simulator in
|
| 6 |
+
pure-Python mode (no AWS/GPU needed for validation).
|
| 7 |
+
|
| 8 |
+
On Colab: Replace EpisodeCollector's "model" with a real QLoRA-backed
|
| 9 |
+
transformers model. The rest of the pipeline stays the same.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import random
|
| 15 |
+
import math
|
| 16 |
+
from dataclasses import dataclass, field
|
| 17 |
+
from typing import List, Optional, Protocol, Callable
|
| 18 |
+
|
| 19 |
+
import sys
|
| 20 |
+
import os
|
| 21 |
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
|
| 22 |
+
|
| 23 |
+
from simulator import ClusterSimulator, NodeStatus, COST_PER_CAPACITY_UNIT_PER_HOUR
|
| 24 |
+
from stability import (
|
| 25 |
+
compute_lyapunov, compute_reward, compute_barrier,
|
| 26 |
+
normalize_reward, smooth_sla_penalty,
|
| 27 |
+
)
|
| 28 |
+
from .losses import (
|
| 29 |
+
compute_returns, compute_gae,
|
| 30 |
+
reinforce_loss, reinforce_baseline_loss,
|
| 31 |
+
grpo_loss, rloo_loss,
|
| 32 |
+
normalize_rewards, compute_reward_stats,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 37 |
+
# Configuration
|
| 38 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 39 |
+
|
| 40 |
+
LOSS_REINFORCE = "reinforce"
|
| 41 |
+
LOSS_REINFORCE_BASELINE = "reinforce_baseline"
|
| 42 |
+
LOSS_GRPO = "grpo"
|
| 43 |
+
LOSS_RLOO = "rloo"
|
| 44 |
+
|
| 45 |
+
VALID_LOSSES = {LOSS_REINFORCE, LOSS_REINFORCE_BASELINE, LOSS_GRPO, LOSS_RLOO}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@dataclass
|
| 49 |
+
class TrainingConfig:
|
| 50 |
+
"""Configuration for the SRE training loop."""
|
| 51 |
+
|
| 52 |
+
# Episode settings
|
| 53 |
+
n_nodes: int = 5
|
| 54 |
+
max_steps: int = 100
|
| 55 |
+
tasks: List[str] = field(default_factory=lambda: ["task-1", "task-2", "task-3"])
|
| 56 |
+
|
| 57 |
+
# Loss function
|
| 58 |
+
loss_fn: str = LOSS_REINFORCE_BASELINE # Recommended starting point
|
| 59 |
+
gamma: float = 0.99 # Discount factor
|
| 60 |
+
gae_lambda: float = 0.95 # GAE lambda (only used with GAE advantages)
|
| 61 |
+
|
| 62 |
+
# GRPO / RLOO settings
|
| 63 |
+
n_samples_per_state: int = 4 # K rollouts per state for GRPO/RLOO
|
| 64 |
+
|
| 65 |
+
# Reward normalization
|
| 66 |
+
normalize_rewards: bool = True
|
| 67 |
+
reward_ema_alpha: float = 0.01 # Exponential moving average update rate
|
| 68 |
+
|
| 69 |
+
# Advantage normalization
|
| 70 |
+
normalize_advantages: bool = True # Standardize advantages (critical for SRE)
|
| 71 |
+
|
| 72 |
+
# Logging
|
| 73 |
+
log_every: int = 10 # Log every N episodes
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 77 |
+
# Model Protocol (abstraction for real LLM or mock)
|
| 78 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 79 |
+
|
| 80 |
+
class PolicyModel(Protocol):
|
| 81 |
+
"""Interface that both real LLMs and mock models must implement."""
|
| 82 |
+
|
| 83 |
+
def get_log_prob(self, prompt: str, action_text: str) -> float:
|
| 84 |
+
"""Return log π(action_text | prompt) under the current policy."""
|
| 85 |
+
...
|
| 86 |
+
|
| 87 |
+
def generate(self, prompt: str) -> str:
|
| 88 |
+
"""Sample an action from the current policy."""
|
| 89 |
+
...
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class MockPolicyModel:
|
| 93 |
+
"""
|
| 94 |
+
Random policy for local testing. Generates random valid actions
|
| 95 |
+
and returns stochastic log probabilities.
|
| 96 |
+
|
| 97 |
+
This is NOT for training — it's for validating the training pipeline
|
| 98 |
+
(episode collection, reward computation, loss calculation) end-to-end
|
| 99 |
+
before connecting a real model.
|
| 100 |
+
|
| 101 |
+
Unlike a truly uniform policy (which would produce zero gradient with
|
| 102 |
+
advantage normalization), this model returns varying log probs per
|
| 103 |
+
action, simulating a real LLM that prefers some actions over others.
|
| 104 |
+
This ensures the training pipeline produces non-trivial losses.
|
| 105 |
+
"""
|
| 106 |
+
|
| 107 |
+
def __init__(self, n_nodes: int = 5, seed: int = 42):
|
| 108 |
+
self._rng = random.Random(seed)
|
| 109 |
+
self._n_nodes = n_nodes
|
| 110 |
+
# Action-dependent log probs: different action types get different
|
| 111 |
+
# log probs (simulating a real model that has preferences).
|
| 112 |
+
# Base log prob ~ log(1/50) ≈ -3.9, with per-action noise.
|
| 113 |
+
self._n_choices = 5 * n_nodes
|
| 114 |
+
self._base_log_prob = math.log(1.0 / self._n_choices)
|
| 115 |
+
|
| 116 |
+
def get_log_prob(self, prompt: str, action_text: str) -> float:
|
| 117 |
+
"""Return stochastic log probability (varies per action)."""
|
| 118 |
+
# Add Gaussian noise to simulate a real model's varying confidence.
|
| 119 |
+
# std=0.5 produces meaningful variation while staying in a plausible
|
| 120 |
+
# range for LLM token log-probs.
|
| 121 |
+
noise = self._rng.gauss(0, 0.5)
|
| 122 |
+
return self._base_log_prob + noise
|
| 123 |
+
|
| 124 |
+
def generate(self, prompt: str) -> str:
|
| 125 |
+
"""Generate a random valid action as JSON string."""
|
| 126 |
+
import json
|
| 127 |
+
action_types = ["SCALE_UP", "SCALE_DOWN", "REROUTE_TRAFFIC", "SHED_LOAD", "NO_OP"]
|
| 128 |
+
node_id = f"node-{self._rng.randint(0, self._n_nodes - 1)}"
|
| 129 |
+
action_type = self._rng.choice(action_types)
|
| 130 |
+
parameter = round(self._rng.random(), 2)
|
| 131 |
+
return json.dumps({
|
| 132 |
+
"action_type": action_type,
|
| 133 |
+
"target_node_id": node_id,
|
| 134 |
+
"parameter": parameter,
|
| 135 |
+
})
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 139 |
+
# Observation formatting (mirrors inference.py logic)
|
| 140 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 141 |
+
|
| 142 |
+
MAX_QUEUE_NORM = 200.0
|
| 143 |
+
MAX_LATENCY_NORM = 1000.0
|
| 144 |
+
MAX_REQUEST_RATE_NORM = 100.0
|
| 145 |
+
ALPHA, BETA, GAMMA, DELTA = 0.002, 0.01, 10.0, 0.005
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def format_observation(nodes: List[dict], task_id: str, step: int, max_steps: int) -> str:
|
| 149 |
+
"""
|
| 150 |
+
Format simulator state as a text prompt for the model.
|
| 151 |
+
|
| 152 |
+
This mirrors inference.py's build_user_prompt and observation_for_model.
|
| 153 |
+
"""
|
| 154 |
+
import json
|
| 155 |
+
node_data = []
|
| 156 |
+
for n in nodes:
|
| 157 |
+
node_data.append({
|
| 158 |
+
"node_id": n["node_id"],
|
| 159 |
+
"status": n["status"] if isinstance(n["status"], str) else n["status"].value,
|
| 160 |
+
"is_vip": n.get("is_vip", False),
|
| 161 |
+
"queue_depth": min(1.0, max(0.0, n["queue_depth"] / MAX_QUEUE_NORM)),
|
| 162 |
+
"latency_ms": min(1.0, max(0.0, n["latency_ms"] / MAX_LATENCY_NORM)),
|
| 163 |
+
"cpu_utilization": min(1.0, max(0.0, n.get("cpu_utilization", 0.0))),
|
| 164 |
+
"incoming_request_rate": min(1.0, max(0.0, n["incoming_request_rate"] / MAX_REQUEST_RATE_NORM)),
|
| 165 |
+
})
|
| 166 |
+
obs = {"task_id": task_id, "step": step, "max_steps": max_steps, "nodes": node_data}
|
| 167 |
+
return json.dumps(obs, separators=(",", ":"))
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def parse_action(action_text: str) -> dict:
|
| 171 |
+
"""Parse model output into an action dict."""
|
| 172 |
+
import json
|
| 173 |
+
try:
|
| 174 |
+
data = json.loads(action_text)
|
| 175 |
+
return {
|
| 176 |
+
"action_type": str(data.get("action_type", "NO_OP")).upper(),
|
| 177 |
+
"target_node_id": str(data.get("target_node_id", "node-0")),
|
| 178 |
+
"parameter": float(data.get("parameter", 0.0)),
|
| 179 |
+
}
|
| 180 |
+
except (json.JSONDecodeError, ValueError):
|
| 181 |
+
return {"action_type": "NO_OP", "target_node_id": "node-0", "parameter": 0.0}
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 185 |
+
# Episode Collection
|
| 186 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 187 |
+
|
| 188 |
+
@dataclass
|
| 189 |
+
class StepRecord:
|
| 190 |
+
"""A single step in an episode."""
|
| 191 |
+
prompt: str # Observation formatted as text
|
| 192 |
+
action_text: str # Model output (JSON string)
|
| 193 |
+
log_prob: float # log π(action | prompt)
|
| 194 |
+
reward: float # Raw reward for this step
|
| 195 |
+
reward_normalized: float # Normalized reward
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
@dataclass
|
| 199 |
+
class EpisodeRecord:
|
| 200 |
+
"""A complete episode trajectory."""
|
| 201 |
+
task_id: str
|
| 202 |
+
steps: List[StepRecord] = field(default_factory=list)
|
| 203 |
+
total_reward: float = 0.0
|
| 204 |
+
total_reward_normalized: float = 0.0
|
| 205 |
+
avg_reward: float = 0.0
|
| 206 |
+
sla_violations: int = 0
|
| 207 |
+
final_lyapunov: float = 0.0
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
class EpisodeCollector:
|
| 211 |
+
"""
|
| 212 |
+
Collects episodes by running the simulator with a policy model.
|
| 213 |
+
|
| 214 |
+
This is the bridge between the simulator (physics) and the training
|
| 215 |
+
pipeline (loss computation). It produces EpisodeRecords that feed
|
| 216 |
+
directly into the loss functions.
|
| 217 |
+
"""
|
| 218 |
+
|
| 219 |
+
def __init__(self, config: TrainingConfig):
|
| 220 |
+
self._config = config
|
| 221 |
+
self._sim = ClusterSimulator(n_nodes=config.n_nodes)
|
| 222 |
+
|
| 223 |
+
def collect_episode(
|
| 224 |
+
self,
|
| 225 |
+
model: PolicyModel,
|
| 226 |
+
task_id: str,
|
| 227 |
+
seed: Optional[int] = None,
|
| 228 |
+
) -> EpisodeRecord:
|
| 229 |
+
"""Run one episode and collect step-level data."""
|
| 230 |
+
cfg = self._config
|
| 231 |
+
self._sim.reset(task_id=task_id, seed=seed)
|
| 232 |
+
|
| 233 |
+
record = EpisodeRecord(task_id=task_id)
|
| 234 |
+
prev_lyapunov = 0.0
|
| 235 |
+
|
| 236 |
+
for step in range(1, cfg.max_steps + 1):
|
| 237 |
+
# 1. Get observation
|
| 238 |
+
nodes_true = self._sim.state(for_agent=False)
|
| 239 |
+
nodes_obs = self._sim.state(for_agent=True)
|
| 240 |
+
prompt = format_observation(nodes_obs, task_id, step, cfg.max_steps)
|
| 241 |
+
|
| 242 |
+
# 2. Get action from model
|
| 243 |
+
action_text = model.generate(prompt)
|
| 244 |
+
log_prob = model.get_log_prob(prompt, action_text)
|
| 245 |
+
|
| 246 |
+
# 3. Apply action
|
| 247 |
+
action = parse_action(action_text)
|
| 248 |
+
class _A:
|
| 249 |
+
pass
|
| 250 |
+
a = _A()
|
| 251 |
+
a.action_type = action["action_type"]
|
| 252 |
+
a.target_node_id = action["target_node_id"]
|
| 253 |
+
a.parameter = action["parameter"]
|
| 254 |
+
self._sim.apply_action(a)
|
| 255 |
+
|
| 256 |
+
# 4. Tick
|
| 257 |
+
self._sim.tick()
|
| 258 |
+
|
| 259 |
+
# 5. Compute reward (mirrors environment.py)
|
| 260 |
+
nodes_true = self._sim.state(for_agent=False)
|
| 261 |
+
current_lyapunov = compute_lyapunov(nodes_true)
|
| 262 |
+
|
| 263 |
+
# Importance-weighted average latency
|
| 264 |
+
w_lat = 0.0
|
| 265 |
+
w_sum = 0.0
|
| 266 |
+
for n in nodes_true:
|
| 267 |
+
w = n.get("importance_weight", 1.0)
|
| 268 |
+
lat = MAX_LATENCY_NORM if n["status"] == NodeStatus.FAILED else n["latency_ms"]
|
| 269 |
+
w_lat += w * lat
|
| 270 |
+
w_sum += w
|
| 271 |
+
avg_lat_norm = min(1.0, max(0.0, (w_lat / w_sum / MAX_LATENCY_NORM) if w_sum > 0 else 1.0))
|
| 272 |
+
|
| 273 |
+
# Error rate
|
| 274 |
+
total_in = sum(n.get("incoming_request_rate", 0) * n.get("importance_weight", 1.0) for n in nodes_true)
|
| 275 |
+
total_drop = sum(n.get("dropped_requests", 0) * n.get("importance_weight", 1.0) for n in nodes_true)
|
| 276 |
+
error_rate = min(1.0, total_drop / total_in) if total_in > 0 else 0.0
|
| 277 |
+
|
| 278 |
+
sla_step = smooth_sla_penalty(avg_lat_norm, error_rate)
|
| 279 |
+
if avg_lat_norm > 0.20 or error_rate > 0.05:
|
| 280 |
+
record.sla_violations += 1
|
| 281 |
+
|
| 282 |
+
# Cost
|
| 283 |
+
total_cap = 0
|
| 284 |
+
for n in nodes_true:
|
| 285 |
+
if n["status"] != NodeStatus.FAILED:
|
| 286 |
+
total_cap += int(n.get("capacity_units", 0)) + int(n.get("pending_capacity_units", 0))
|
| 287 |
+
cost = total_cap * COST_PER_CAPACITY_UNIT_PER_HOUR
|
| 288 |
+
|
| 289 |
+
barrier = compute_barrier(nodes_true)
|
| 290 |
+
raw_reward = compute_reward(
|
| 291 |
+
prev_lyapunov, current_lyapunov, cost, sla_step,
|
| 292 |
+
ALPHA, BETA, GAMMA, barrier, DELTA,
|
| 293 |
+
)
|
| 294 |
+
norm_reward = normalize_reward(raw_reward)
|
| 295 |
+
|
| 296 |
+
record.steps.append(StepRecord(
|
| 297 |
+
prompt=prompt,
|
| 298 |
+
action_text=action_text,
|
| 299 |
+
log_prob=log_prob,
|
| 300 |
+
reward=raw_reward,
|
| 301 |
+
reward_normalized=norm_reward,
|
| 302 |
+
))
|
| 303 |
+
record.total_reward += raw_reward
|
| 304 |
+
record.total_reward_normalized += norm_reward
|
| 305 |
+
prev_lyapunov = current_lyapunov
|
| 306 |
+
|
| 307 |
+
record.avg_reward = record.total_reward / max(1, len(record.steps))
|
| 308 |
+
record.final_lyapunov = prev_lyapunov
|
| 309 |
+
return record
|
| 310 |
+
|
| 311 |
+
def collect_group(
|
| 312 |
+
self,
|
| 313 |
+
model: PolicyModel,
|
| 314 |
+
task_id: str,
|
| 315 |
+
k: int,
|
| 316 |
+
seed: Optional[int] = None,
|
| 317 |
+
) -> List[EpisodeRecord]:
|
| 318 |
+
"""
|
| 319 |
+
Collect K episodes from the same initial state (for GRPO/RLOO).
|
| 320 |
+
|
| 321 |
+
Uses the same seed for all K episodes so they start from the same
|
| 322 |
+
domain randomization, but different model samples produce different
|
| 323 |
+
trajectories.
|
| 324 |
+
"""
|
| 325 |
+
return [self.collect_episode(model, task_id, seed=seed) for _ in range(k)]
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 329 |
+
# Trainer
|
| 330 |
+
# ════════════════════════════════════════════════════════════════════════════════
|
| 331 |
+
|
| 332 |
+
class SRETrainer:
|
| 333 |
+
"""
|
| 334 |
+
Main training orchestrator for AntiAtropos SRE agents.
|
| 335 |
+
|
| 336 |
+
Usage (local validation with MockPolicyModel):
|
| 337 |
+
config = TrainingConfig(loss_fn="reinforce_baseline")
|
| 338 |
+
trainer = SRETrainer(config)
|
| 339 |
+
model = MockPolicyModel()
|
| 340 |
+
metrics = trainer.train_step(model, task_id="task-1", seed=42)
|
| 341 |
+
|
| 342 |
+
Usage (Colab with real model):
|
| 343 |
+
config = TrainingConfig(loss_fn="grpo", n_samples_per_state=4)
|
| 344 |
+
trainer = SRETrainer(config)
|
| 345 |
+
model = QLoRAModel(...) # Your transformers model
|
| 346 |
+
for epoch in range(num_epochs):
|
| 347 |
+
for task in config.tasks:
|
| 348 |
+
metrics = trainer.train_step(model, task_id=task)
|
| 349 |
+
model.update(metrics["loss"]) # Backprop
|
| 350 |
+
"""
|
| 351 |
+
|
| 352 |
+
def __init__(self, config: TrainingConfig):
|
| 353 |
+
assert config.loss_fn in VALID_LOSSES, f"Unknown loss: {config.loss_fn}"
|
| 354 |
+
self._config = config
|
| 355 |
+
self._collector = EpisodeCollector(config)
|
| 356 |
+
self._running_reward_mean = 0.0
|
| 357 |
+
self._running_reward_var = 1.0
|
| 358 |
+
self._episode_count = 0
|
| 359 |
+
|
| 360 |
+
def train_step(
|
| 361 |
+
self,
|
| 362 |
+
model: PolicyModel,
|
| 363 |
+
task_id: str,
|
| 364 |
+
seed: Optional[int] = None,
|
| 365 |
+
) -> dict:
|
| 366 |
+
"""
|
| 367 |
+
Execute one training step: collect episode(s) → compute loss.
|
| 368 |
+
|
| 369 |
+
Returns a metrics dict with:
|
| 370 |
+
- loss: The computed loss value
|
| 371 |
+
- avg_reward: Average raw reward across the episode
|
| 372 |
+
- avg_norm_reward: Average normalized reward
|
| 373 |
+
- episode_length: Number of steps
|
| 374 |
+
- sla_violations: Number of SLA violations
|
| 375 |
+
- final_lyapunov: Lyapunov energy at episode end
|
| 376 |
+
- reward_mean/var: Running reward statistics
|
| 377 |
+
"""
|
| 378 |
+
cfg = self._config
|
| 379 |
+
|
| 380 |
+
if cfg.loss_fn in (LOSS_GRPO, LOSS_RLOO):
|
| 381 |
+
return self._train_step_grouped(model, task_id, seed)
|
| 382 |
+
else:
|
| 383 |
+
return self._train_step_single(model, task_id, seed)
|
| 384 |
+
|
| 385 |
+
def _train_step_single(
|
| 386 |
+
self,
|
| 387 |
+
model: PolicyModel,
|
| 388 |
+
task_id: str,
|
| 389 |
+
seed: Optional[int] = None,
|
| 390 |
+
) -> dict:
|
| 391 |
+
"""Train step for REINFORCE / REINFORCE+baseline."""
|
| 392 |
+
cfg = self._config
|
| 393 |
+
|
| 394 |
+
# 1. Collect episode
|
| 395 |
+
episode = self._collector.collect_episode(model, task_id, seed=seed)
|
| 396 |
+
|
| 397 |
+
# 2. Extract rewards and log probs
|
| 398 |
+
rewards = [s.reward for s in episode.steps]
|
| 399 |
+
log_probs = [s.log_prob for s in episode.steps]
|
| 400 |
+
|
| 401 |
+
# 3. Update running reward stats
|
| 402 |
+
ep_mean, ep_var = compute_reward_stats(rewards)
|
| 403 |
+
self._running_reward_mean = (
|
| 404 |
+
(1 - cfg.reward_ema_alpha) * self._running_reward_mean
|
| 405 |
+
+ cfg.reward_ema_alpha * ep_mean
|
| 406 |
+
)
|
| 407 |
+
self._running_reward_var = (
|
| 408 |
+
(1 - cfg.reward_ema_alpha) * self._running_reward_var
|
| 409 |
+
+ cfg.reward_ema_alpha * ep_var
|
| 410 |
+
)
|
| 411 |
+
|
| 412 |
+
# 4. Optionally normalize rewards
|
| 413 |
+
if cfg.normalize_rewards:
|
| 414 |
+
rewards = normalize_rewards(
|
| 415 |
+
rewards, self._running_reward_mean, self._running_reward_var
|
| 416 |
+
)
|
| 417 |
+
|
| 418 |
+
# 5. Compute returns
|
| 419 |
+
returns = compute_returns(rewards, gamma=cfg.gamma)
|
| 420 |
+
|
| 421 |
+
# 6. Compute loss
|
| 422 |
+
if cfg.loss_fn == LOSS_REINFORCE:
|
| 423 |
+
loss = reinforce_loss(log_probs, returns)
|
| 424 |
+
elif cfg.loss_fn == LOSS_REINFORCE_BASELINE:
|
| 425 |
+
# Use running mean as baseline
|
| 426 |
+
baselines = [self._running_reward_mean] * len(returns)
|
| 427 |
+
loss = reinforce_baseline_loss(
|
| 428 |
+
log_probs, returns, baselines,
|
| 429 |
+
normalize_advantage=cfg.normalize_advantages,
|
| 430 |
+
)
|
| 431 |
+
else:
|
| 432 |
+
raise ValueError(f"Unexpected loss_fn: {cfg.loss_fn}")
|
| 433 |
+
|
| 434 |
+
self._episode_count += 1
|
| 435 |
+
|
| 436 |
+
return {
|
| 437 |
+
"loss": loss,
|
| 438 |
+
"avg_reward": episode.avg_reward,
|
| 439 |
+
"avg_norm_reward": episode.total_reward_normalized / max(1, len(episode.steps)),
|
| 440 |
+
"episode_length": len(episode.steps),
|
| 441 |
+
"sla_violations": episode.sla_violations,
|
| 442 |
+
"final_lyapunov": episode.final_lyapunov,
|
| 443 |
+
"reward_mean": self._running_reward_mean,
|
| 444 |
+
"reward_var": self._running_reward_var,
|
| 445 |
+
"task_id": task_id,
|
| 446 |
+
"episode": episode,
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
def _train_step_grouped(
|
| 450 |
+
self,
|
| 451 |
+
model: PolicyModel,
|
| 452 |
+
task_id: str,
|
| 453 |
+
seed: Optional[int] = None,
|
| 454 |
+
) -> dict:
|
| 455 |
+
"""Train step for GRPO / RLOO."""
|
| 456 |
+
cfg = self._config
|
| 457 |
+
k = cfg.n_samples_per_state
|
| 458 |
+
|
| 459 |
+
# 1. Collect K episodes (same seed → same domain randomization)
|
| 460 |
+
episodes = self._collector.collect_group(model, task_id, k=k, seed=seed)
|
| 461 |
+
|
| 462 |
+
# 2. For each step position, form groups across episodes
|
| 463 |
+
# (assumes all episodes have same length)
|
| 464 |
+
min_len = min(len(ep.steps) for ep in episodes)
|
| 465 |
+
|
| 466 |
+
log_probs_groups = []
|
| 467 |
+
rewards_groups = []
|
| 468 |
+
|
| 469 |
+
for t in range(min_len):
|
| 470 |
+
step_lps = []
|
| 471 |
+
step_rs = []
|
| 472 |
+
for ep in episodes:
|
| 473 |
+
step_lps.append(ep.steps[t].log_prob)
|
| 474 |
+
step_rs.append(ep.steps[t].reward)
|
| 475 |
+
log_probs_groups.append(step_lps)
|
| 476 |
+
rewards_groups.append(step_rs)
|
| 477 |
+
|
| 478 |
+
# 3. Update running stats
|
| 479 |
+
all_rewards = [s.reward for ep in episodes for s in ep.steps]
|
| 480 |
+
ep_mean, ep_var = compute_reward_stats(all_rewards)
|
| 481 |
+
self._running_reward_mean = (
|
| 482 |
+
(1 - cfg.reward_ema_alpha) * self._running_reward_mean
|
| 483 |
+
+ cfg.reward_ema_alpha * ep_mean
|
| 484 |
+
)
|
| 485 |
+
self._running_reward_var = (
|
| 486 |
+
(1 - cfg.reward_ema_alpha) * self._running_reward_var
|
| 487 |
+
+ cfg.reward_ema_alpha * ep_var
|
| 488 |
+
)
|
| 489 |
+
|
| 490 |
+
# 4. Normalize rewards
|
| 491 |
+
if cfg.normalize_rewards:
|
| 492 |
+
rewards_groups = [
|
| 493 |
+
normalize_rewards(rs, self._running_reward_mean, self._running_reward_var)
|
| 494 |
+
for rs in rewards_groups
|
| 495 |
+
]
|
| 496 |
+
|
| 497 |
+
# 5. Compute loss
|
| 498 |
+
if cfg.loss_fn == LOSS_GRPO:
|
| 499 |
+
loss = grpo_loss(log_probs_groups, rewards_groups)
|
| 500 |
+
elif cfg.loss_fn == LOSS_RLOO:
|
| 501 |
+
loss = rloo_loss(log_probs_groups, rewards_groups)
|
| 502 |
+
else:
|
| 503 |
+
raise ValueError(f"Unexpected grouped loss_fn: {cfg.loss_fn}")
|
| 504 |
+
|
| 505 |
+
# 6. Aggregate metrics across episodes
|
| 506 |
+
avg_reward = sum(ep.avg_reward for ep in episodes) / len(episodes)
|
| 507 |
+
avg_norm = sum(
|
| 508 |
+
ep.total_reward_normalized / max(1, len(ep.steps)) for ep in episodes
|
| 509 |
+
) / len(episodes)
|
| 510 |
+
total_sla = sum(ep.sla_violations for ep in episodes)
|
| 511 |
+
avg_lyapunov = sum(ep.final_lyapunov for ep in episodes) / len(episodes)
|
| 512 |
+
|
| 513 |
+
self._episode_count += k
|
| 514 |
+
|
| 515 |
+
return {
|
| 516 |
+
"loss": loss,
|
| 517 |
+
"avg_reward": avg_reward,
|
| 518 |
+
"avg_norm_reward": avg_norm,
|
| 519 |
+
"episode_length": min_len,
|
| 520 |
+
"sla_violations": total_sla,
|
| 521 |
+
"final_lyapunov": avg_lyapunov,
|
| 522 |
+
"reward_mean": self._running_reward_mean,
|
| 523 |
+
"reward_var": self._running_reward_var,
|
| 524 |
+
"task_id": task_id,
|
| 525 |
+
"episodes": episodes,
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
def train_epoch(
|
| 529 |
+
self,
|
| 530 |
+
model: PolicyModel,
|
| 531 |
+
seed: Optional[int] = None,
|
| 532 |
+
) -> List[dict]:
|
| 533 |
+
"""
|
| 534 |
+
Run one training step per task in the curriculum.
|
| 535 |
+
|
| 536 |
+
Returns a list of metrics dicts (one per task).
|
| 537 |
+
"""
|
| 538 |
+
results = []
|
| 539 |
+
for task_id in self._config.tasks:
|
| 540 |
+
step_seed = seed + hash(task_id) % 1000 if seed is not None else None
|
| 541 |
+
metrics = self.train_step(model, task_id, seed=step_seed)
|
| 542 |
+
results.append(metrics)
|
| 543 |
+
if self._episode_count % self._config.log_every == 0:
|
| 544 |
+
self._log_metrics(metrics)
|
| 545 |
+
return results
|
| 546 |
+
|
| 547 |
+
def _log_metrics(self, metrics: dict) -> None:
|
| 548 |
+
"""Print training metrics."""
|
| 549 |
+
print(
|
| 550 |
+
f"[Episode {self._episode_count}] "
|
| 551 |
+
f"task={metrics['task_id']} "
|
| 552 |
+
f"loss={metrics['loss']:.4f} "
|
| 553 |
+
f"avg_reward={metrics['avg_reward']:.4f} "
|
| 554 |
+
f"avg_norm_reward={metrics['avg_norm_reward']:.4f} "
|
| 555 |
+
f"sla_violations={metrics['sla_violations']} "
|
| 556 |
+
f"lyapunov={metrics['final_lyapunov']:.1f} "
|
| 557 |
+
f"reward_mean={metrics['reward_mean']:.4f} "
|
| 558 |
+
f"reward_var={metrics['reward_var']:.4f}"
|
| 559 |
+
)
|