div18 commited on
Commit
04b46cd
·
1 Parent(s): 5f3b0b8
.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
+ )