Angshuman28 commited on
Commit
16e3f36
·
verified ·
1 Parent(s): 952db85

Upload folder using huggingface_hub

Browse files
Dockerfile CHANGED
@@ -28,7 +28,7 @@
28
  # The build script (openenv build) handles context detection and sets appropriate build args.
29
 
30
  ARG BASE_IMAGE=ghcr.io/meta-pytorch/openenv-base:latest
31
- FROM ${BASE_IMAGE} AS builder
32
 
33
  WORKDIR /app
34
 
@@ -72,8 +72,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
72
  fi
73
 
74
  # Final runtime stage
75
- ARG BASE_IMAGE=ghcr.io/meta-pytorch/openenv-base:latest
76
- FROM ${BASE_IMAGE}
77
 
78
  WORKDIR /app
79
 
 
28
  # The build script (openenv build) handles context detection and sets appropriate build args.
29
 
30
  ARG BASE_IMAGE=ghcr.io/meta-pytorch/openenv-base:latest
31
+ FROM ${BASE_IMAGE:-ghcr.io/meta-pytorch/openenv-base:latest} AS builder
32
 
33
  WORKDIR /app
34
 
 
72
  fi
73
 
74
  # Final runtime stage
75
+ FROM ${BASE_IMAGE:-ghcr.io/meta-pytorch/openenv-base:latest}
 
76
 
77
  WORKDIR /app
78
 
__init__.py CHANGED
@@ -1,16 +1,16 @@
1
- # Copyright (c) Meta Platforms, Inc. and affiliates.
2
- # All rights reserved.
3
- #
4
- # This source code is licensed under the BSD-style license found in the
5
- # LICENSE file in the root directory of this source tree.
6
-
7
- """Crisisworldcortex Environment."""
8
-
9
- from .client import CrisisworldcortexEnv
10
- from .models import CrisisworldcortexAction, CrisisworldcortexObservation
11
-
12
- __all__ = [
13
- "CrisisworldcortexAction",
14
- "CrisisworldcortexObservation",
15
- "CrisisworldcortexEnv",
16
- ]
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Crisisworldcortex Environment."""
8
+
9
+ from .client import CrisisworldcortexEnv
10
+ from .models import CrisisworldcortexAction, CrisisworldcortexObservation
11
+
12
+ __all__ = [
13
+ "CrisisworldcortexAction",
14
+ "CrisisworldcortexObservation",
15
+ "CrisisworldcortexEnv",
16
+ ]
frontend/css/styles.css ADDED
@@ -0,0 +1,603 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #f6f7f4;
3
+ --surface: #ffffff;
4
+ --surface-2: #eef4f1;
5
+ --surface-3: #fff7ed;
6
+ --line: #d9ded8;
7
+ --line-strong: #b8c2bd;
8
+ --text: #17211d;
9
+ --muted: #66736d;
10
+ --soft: #8a948f;
11
+ --teal: #0f766e;
12
+ --teal-soft: #d9f3ee;
13
+ --blue: #2563eb;
14
+ --blue-soft: #dbeafe;
15
+ --amber: #b45309;
16
+ --amber-soft: #ffedd5;
17
+ --red: #b91c1c;
18
+ --red-soft: #fee2e2;
19
+ --green: #15803d;
20
+ --green-soft: #dcfce7;
21
+ --ink: #111827;
22
+ --shadow: 0 10px 30px rgba(23, 33, 29, 0.08);
23
+ --radius: 8px;
24
+ --font: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
25
+ --mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
26
+ }
27
+
28
+ * {
29
+ box-sizing: border-box;
30
+ }
31
+
32
+ body {
33
+ margin: 0;
34
+ min-height: 100vh;
35
+ background:
36
+ linear-gradient(180deg, rgba(15, 118, 110, 0.08), transparent 360px),
37
+ var(--bg);
38
+ color: var(--text);
39
+ font-family: var(--font);
40
+ }
41
+
42
+ button,
43
+ input,
44
+ select {
45
+ font: inherit;
46
+ }
47
+
48
+ button {
49
+ cursor: pointer;
50
+ }
51
+
52
+ button:disabled {
53
+ cursor: not-allowed;
54
+ opacity: 0.55;
55
+ }
56
+
57
+ #app {
58
+ min-height: 100vh;
59
+ }
60
+
61
+ .shell {
62
+ display: grid;
63
+ grid-template-rows: auto auto 1fr;
64
+ gap: 14px;
65
+ max-width: 1480px;
66
+ min-height: 100vh;
67
+ margin: 0 auto;
68
+ padding: 18px;
69
+ }
70
+
71
+ .topbar {
72
+ display: flex;
73
+ align-items: center;
74
+ justify-content: space-between;
75
+ gap: 16px;
76
+ }
77
+
78
+ .brand {
79
+ display: flex;
80
+ align-items: center;
81
+ gap: 12px;
82
+ min-width: 280px;
83
+ }
84
+
85
+ .mark {
86
+ display: grid;
87
+ place-items: center;
88
+ width: 42px;
89
+ height: 42px;
90
+ border: 1px solid var(--line-strong);
91
+ border-radius: var(--radius);
92
+ background: var(--surface);
93
+ box-shadow: var(--shadow);
94
+ color: var(--teal);
95
+ font-weight: 800;
96
+ }
97
+
98
+ .brand h1 {
99
+ margin: 0;
100
+ font-size: 21px;
101
+ line-height: 1.1;
102
+ letter-spacing: 0;
103
+ }
104
+
105
+ .brand p {
106
+ margin: 4px 0 0;
107
+ color: var(--muted);
108
+ font-size: 12px;
109
+ }
110
+
111
+ .top-actions {
112
+ display: flex;
113
+ align-items: center;
114
+ gap: 8px;
115
+ flex-wrap: wrap;
116
+ justify-content: flex-end;
117
+ }
118
+
119
+ .panel,
120
+ .commandbar {
121
+ border: 1px solid var(--line);
122
+ border-radius: var(--radius);
123
+ background: rgba(255, 255, 255, 0.92);
124
+ box-shadow: var(--shadow);
125
+ }
126
+
127
+ .commandbar {
128
+ display: grid;
129
+ grid-template-columns: 1.1fr 0.8fr auto;
130
+ gap: 12px;
131
+ padding: 12px;
132
+ align-items: end;
133
+ }
134
+
135
+ .fieldrow,
136
+ .action-grid {
137
+ display: flex;
138
+ gap: 8px;
139
+ flex-wrap: wrap;
140
+ align-items: end;
141
+ }
142
+
143
+ .field {
144
+ display: grid;
145
+ gap: 5px;
146
+ min-width: 118px;
147
+ }
148
+
149
+ .field.compact {
150
+ min-width: 88px;
151
+ }
152
+
153
+ .field.wide {
154
+ min-width: 170px;
155
+ }
156
+
157
+ .field label,
158
+ .section-title,
159
+ .micro-label {
160
+ color: var(--muted);
161
+ font-size: 11px;
162
+ font-weight: 700;
163
+ letter-spacing: 0.03em;
164
+ text-transform: uppercase;
165
+ }
166
+
167
+ select,
168
+ input[type="number"],
169
+ input[type="range"] {
170
+ min-height: 36px;
171
+ border: 1px solid var(--line-strong);
172
+ border-radius: var(--radius);
173
+ background: #fff;
174
+ color: var(--text);
175
+ padding: 7px 9px;
176
+ outline: none;
177
+ }
178
+
179
+ select:focus,
180
+ input:focus {
181
+ border-color: var(--teal);
182
+ box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.14);
183
+ }
184
+
185
+ .btn {
186
+ display: inline-flex;
187
+ align-items: center;
188
+ justify-content: center;
189
+ min-height: 36px;
190
+ border: 1px solid var(--line-strong);
191
+ border-radius: var(--radius);
192
+ background: var(--surface);
193
+ color: var(--text);
194
+ padding: 8px 12px;
195
+ font-weight: 700;
196
+ transition: transform 0.12s, border-color 0.12s, background 0.12s;
197
+ }
198
+
199
+ .btn:hover {
200
+ transform: translateY(-1px);
201
+ border-color: var(--teal);
202
+ }
203
+
204
+ .btn.primary {
205
+ border-color: var(--teal);
206
+ background: var(--teal);
207
+ color: #fff;
208
+ }
209
+
210
+ .btn.blue {
211
+ border-color: var(--blue);
212
+ background: var(--blue);
213
+ color: #fff;
214
+ }
215
+
216
+ .btn.warn {
217
+ border-color: #f59e0b;
218
+ background: var(--amber-soft);
219
+ color: #7c2d12;
220
+ }
221
+
222
+ .btn.ghost {
223
+ background: transparent;
224
+ }
225
+
226
+ .segmented {
227
+ display: inline-flex;
228
+ padding: 3px;
229
+ border: 1px solid var(--line);
230
+ border-radius: var(--radius);
231
+ background: var(--surface-2);
232
+ }
233
+
234
+ .segmented button {
235
+ min-height: 30px;
236
+ border: 0;
237
+ border-radius: 6px;
238
+ background: transparent;
239
+ color: var(--muted);
240
+ padding: 6px 10px;
241
+ font-weight: 800;
242
+ }
243
+
244
+ .segmented button.active {
245
+ background: var(--surface);
246
+ color: var(--teal);
247
+ box-shadow: 0 1px 4px rgba(23, 33, 29, 0.14);
248
+ }
249
+
250
+ .main-grid {
251
+ display: grid;
252
+ grid-template-columns: minmax(320px, 0.9fr) minmax(420px, 1.25fr) minmax(340px, 0.95fr);
253
+ gap: 14px;
254
+ align-items: start;
255
+ }
256
+
257
+ .panel {
258
+ overflow: hidden;
259
+ }
260
+
261
+ .panel-header {
262
+ display: flex;
263
+ justify-content: space-between;
264
+ align-items: center;
265
+ gap: 10px;
266
+ min-height: 48px;
267
+ padding: 12px 14px;
268
+ border-bottom: 1px solid var(--line);
269
+ }
270
+
271
+ .panel-header h2,
272
+ .panel-header h3 {
273
+ margin: 0;
274
+ font-size: 14px;
275
+ line-height: 1.2;
276
+ }
277
+
278
+ .panel-body {
279
+ padding: 14px;
280
+ }
281
+
282
+ .metrics {
283
+ display: grid;
284
+ grid-template-columns: repeat(4, 1fr);
285
+ gap: 8px;
286
+ }
287
+
288
+ .metric {
289
+ padding: 10px;
290
+ border: 1px solid var(--line);
291
+ border-radius: var(--radius);
292
+ background: var(--surface);
293
+ }
294
+
295
+ .metric strong {
296
+ display: block;
297
+ margin-top: 4px;
298
+ font-size: 19px;
299
+ }
300
+
301
+ .badge {
302
+ display: inline-flex;
303
+ align-items: center;
304
+ min-height: 22px;
305
+ border-radius: 999px;
306
+ padding: 3px 9px;
307
+ background: var(--surface-2);
308
+ color: var(--muted);
309
+ font-size: 11px;
310
+ font-weight: 800;
311
+ white-space: nowrap;
312
+ }
313
+
314
+ .badge.live {
315
+ background: var(--green-soft);
316
+ color: var(--green);
317
+ }
318
+
319
+ .badge.sample {
320
+ background: var(--amber-soft);
321
+ color: var(--amber);
322
+ }
323
+
324
+ .badge.done {
325
+ background: var(--red-soft);
326
+ color: var(--red);
327
+ }
328
+
329
+ .world-map {
330
+ display: grid;
331
+ grid-template-columns: repeat(2, minmax(0, 1fr));
332
+ gap: 10px;
333
+ }
334
+
335
+ .region-node {
336
+ min-height: 118px;
337
+ border: 1px solid var(--line);
338
+ border-radius: var(--radius);
339
+ background: var(--surface);
340
+ padding: 11px;
341
+ }
342
+
343
+ .region-node.hot {
344
+ border-color: #f97316;
345
+ background: #fff7ed;
346
+ }
347
+
348
+ .region-top {
349
+ display: flex;
350
+ align-items: center;
351
+ justify-content: space-between;
352
+ margin-bottom: 8px;
353
+ }
354
+
355
+ .region-name {
356
+ font-size: 18px;
357
+ font-weight: 900;
358
+ }
359
+
360
+ .bar {
361
+ height: 8px;
362
+ border-radius: 999px;
363
+ overflow: hidden;
364
+ background: #e5e7eb;
365
+ }
366
+
367
+ .bar span {
368
+ display: block;
369
+ height: 100%;
370
+ border-radius: inherit;
371
+ background: var(--teal);
372
+ }
373
+
374
+ .bar.load span {
375
+ background: #dc2626;
376
+ }
377
+
378
+ .bar.comp span {
379
+ background: #16a34a;
380
+ }
381
+
382
+ .region-stats {
383
+ display: grid;
384
+ gap: 7px;
385
+ }
386
+
387
+ .statline {
388
+ display: grid;
389
+ grid-template-columns: 74px 1fr 42px;
390
+ gap: 7px;
391
+ align-items: center;
392
+ color: var(--muted);
393
+ font-size: 12px;
394
+ }
395
+
396
+ .resource-grid {
397
+ display: grid;
398
+ grid-template-columns: repeat(2, 1fr);
399
+ gap: 8px;
400
+ }
401
+
402
+ .resource {
403
+ border: 1px solid var(--line);
404
+ border-radius: var(--radius);
405
+ background: var(--surface);
406
+ padding: 10px;
407
+ }
408
+
409
+ .resource strong {
410
+ display: block;
411
+ margin-top: 4px;
412
+ font-size: 18px;
413
+ }
414
+
415
+ .action-panel {
416
+ display: grid;
417
+ gap: 12px;
418
+ }
419
+
420
+ .action-form {
421
+ display: grid;
422
+ gap: 10px;
423
+ }
424
+
425
+ .param-grid {
426
+ display: grid;
427
+ grid-template-columns: repeat(2, minmax(0, 1fr));
428
+ gap: 8px;
429
+ }
430
+
431
+ .param-grid .full {
432
+ grid-column: 1 / -1;
433
+ }
434
+
435
+ .council-stack {
436
+ display: grid;
437
+ gap: 10px;
438
+ }
439
+
440
+ .brain-card {
441
+ border: 1px solid var(--line);
442
+ border-radius: var(--radius);
443
+ background: var(--surface);
444
+ padding: 12px;
445
+ }
446
+
447
+ .brain-head {
448
+ display: flex;
449
+ align-items: center;
450
+ justify-content: space-between;
451
+ gap: 10px;
452
+ margin-bottom: 8px;
453
+ }
454
+
455
+ .brain-head h3 {
456
+ margin: 0;
457
+ font-size: 14px;
458
+ }
459
+
460
+ .brain-card p {
461
+ margin: 7px 0 0;
462
+ color: var(--muted);
463
+ font-size: 12px;
464
+ line-height: 1.45;
465
+ }
466
+
467
+ .decision {
468
+ border: 1px solid #99f6e4;
469
+ border-radius: var(--radius);
470
+ background: #ecfdf5;
471
+ padding: 12px;
472
+ }
473
+
474
+ .decision h3 {
475
+ margin: 0 0 6px;
476
+ font-size: 14px;
477
+ }
478
+
479
+ .timeline {
480
+ display: grid;
481
+ gap: 8px;
482
+ max-height: 260px;
483
+ overflow: auto;
484
+ }
485
+
486
+ .timeline-entry {
487
+ display: grid;
488
+ grid-template-columns: 44px 1fr auto;
489
+ gap: 8px;
490
+ align-items: center;
491
+ border: 1px solid var(--line);
492
+ border-radius: var(--radius);
493
+ background: var(--surface);
494
+ padding: 9px;
495
+ font-size: 12px;
496
+ }
497
+
498
+ .timeline-entry strong {
499
+ font-family: var(--mono);
500
+ }
501
+
502
+ .timeline-entry.rejected {
503
+ border-color: #fecaca;
504
+ background: #fff1f2;
505
+ }
506
+
507
+ .replay-row {
508
+ display: grid;
509
+ grid-template-columns: auto 1fr auto;
510
+ gap: 10px;
511
+ align-items: center;
512
+ }
513
+
514
+ .empty {
515
+ display: grid;
516
+ place-items: center;
517
+ min-height: 160px;
518
+ border: 1px dashed var(--line-strong);
519
+ border-radius: var(--radius);
520
+ color: var(--muted);
521
+ text-align: center;
522
+ padding: 20px;
523
+ }
524
+
525
+ .tiny {
526
+ color: var(--soft);
527
+ font-size: 11px;
528
+ line-height: 1.35;
529
+ }
530
+
531
+ .mono {
532
+ font-family: var(--mono);
533
+ }
534
+
535
+ #toast-root {
536
+ position: fixed;
537
+ right: 16px;
538
+ bottom: 16px;
539
+ display: grid;
540
+ gap: 8px;
541
+ z-index: 20;
542
+ }
543
+
544
+ .toast {
545
+ max-width: 360px;
546
+ border-radius: var(--radius);
547
+ background: var(--ink);
548
+ color: #fff;
549
+ padding: 11px 13px;
550
+ box-shadow: var(--shadow);
551
+ font-size: 13px;
552
+ transition: opacity 0.2s, transform 0.2s;
553
+ }
554
+
555
+ .toast-error {
556
+ background: var(--red);
557
+ }
558
+
559
+ .toast-success {
560
+ background: var(--green);
561
+ }
562
+
563
+ .toast-out {
564
+ opacity: 0;
565
+ transform: translateY(4px);
566
+ }
567
+
568
+ @media (max-width: 1180px) {
569
+ .main-grid,
570
+ .commandbar {
571
+ grid-template-columns: 1fr;
572
+ }
573
+
574
+ .metrics {
575
+ grid-template-columns: repeat(2, 1fr);
576
+ }
577
+ }
578
+
579
+ @media (max-width: 720px) {
580
+ .shell {
581
+ padding: 10px;
582
+ }
583
+
584
+ .topbar {
585
+ align-items: flex-start;
586
+ flex-direction: column;
587
+ }
588
+
589
+ .brand {
590
+ min-width: 0;
591
+ }
592
+
593
+ .world-map,
594
+ .resource-grid,
595
+ .param-grid,
596
+ .metrics {
597
+ grid-template-columns: 1fr;
598
+ }
599
+
600
+ .replay-row {
601
+ grid-template-columns: 1fr;
602
+ }
603
+ }
frontend/index.html ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>CrisisWorld Cortex</title>
7
+ <meta name="description" content="Interactive CrisisWorld Cortex dashboard for outbreak-control episodes">
8
+ <link rel="stylesheet" href="/cortex/css/styles.css">
9
+ </head>
10
+ <body>
11
+ <div id="app"></div>
12
+ <div id="toast-root" aria-live="polite"></div>
13
+ <script type="module" src="/cortex/js/app.js"></script>
14
+ </body>
15
+ </html>
frontend/js/actions.js ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const RESOURCE_TYPES = [
2
+ 'test_kits',
3
+ 'hospital_beds',
4
+ 'mobile_units',
5
+ 'vaccine_doses',
6
+ ];
7
+
8
+ export const DATA_TYPES = [
9
+ 'case_survey',
10
+ 'hospital_audit',
11
+ 'compliance_check',
12
+ ];
13
+
14
+ export const SEVERITIES = [
15
+ 'none',
16
+ 'light',
17
+ 'moderate',
18
+ 'strict',
19
+ ];
20
+
21
+ export const AUTHORITIES = [
22
+ 'regional',
23
+ 'national',
24
+ ];
25
+
26
+ export const ACTION_KINDS = [
27
+ { id: 'deploy_resource', label: 'Deploy' },
28
+ { id: 'request_data', label: 'Request Data' },
29
+ { id: 'restrict_movement', label: 'Restrict' },
30
+ { id: 'escalate', label: 'Escalate' },
31
+ { id: 'reallocate_budget', label: 'Reallocate' },
32
+ { id: 'no_op', label: 'No-op' },
33
+ ];
34
+
35
+ export function normalizeRegionSelection(state) {
36
+ const regions = state.observation?.regions || [];
37
+ if (!regions.length) return {};
38
+ if (regions.some((region) => region.region === state.selectedRegion)) return {};
39
+ return { selectedRegion: regions[0].region };
40
+ }
41
+
42
+ export function buildAction(state) {
43
+ const region = state.selectedRegion || state.observation?.regions?.[0]?.region || 'R1';
44
+ const quantity = Math.max(0, Number.parseInt(state.quantity, 10) || 0);
45
+ switch (state.selectedActionKind) {
46
+ case 'deploy_resource':
47
+ return {
48
+ kind: 'deploy_resource',
49
+ region,
50
+ resource_type: state.selectedResource,
51
+ quantity,
52
+ };
53
+ case 'request_data':
54
+ return {
55
+ kind: 'request_data',
56
+ region,
57
+ data_type: state.dataType,
58
+ };
59
+ case 'restrict_movement':
60
+ return {
61
+ kind: 'restrict_movement',
62
+ region,
63
+ severity: state.severity,
64
+ };
65
+ case 'escalate':
66
+ return {
67
+ kind: 'escalate',
68
+ to_authority: state.authority,
69
+ };
70
+ case 'reallocate_budget':
71
+ return {
72
+ kind: 'reallocate_budget',
73
+ from_resource: state.selectedResource,
74
+ to_resource: state.selectedToResource,
75
+ amount: quantity,
76
+ };
77
+ case 'no_op':
78
+ default:
79
+ return { kind: 'no_op' };
80
+ }
81
+ }
82
+
83
+ export function formatAction(action) {
84
+ if (!action) return 'none';
85
+ switch (action.kind) {
86
+ case 'deploy_resource':
87
+ return `deploy ${action.quantity} ${labelize(action.resource_type)} to ${action.region}`;
88
+ case 'request_data':
89
+ return `request ${labelize(action.data_type)} for ${action.region}`;
90
+ case 'restrict_movement':
91
+ return `${action.severity} movement limits in ${action.region}`;
92
+ case 'escalate':
93
+ return `escalate to ${action.to_authority}`;
94
+ case 'reallocate_budget':
95
+ return `move ${action.amount} from ${labelize(action.from_resource)} to ${labelize(action.to_resource)}`;
96
+ case 'public_communication':
97
+ return `public communication to ${action.audience}`;
98
+ case 'no_op':
99
+ default:
100
+ return 'no-op';
101
+ }
102
+ }
103
+
104
+ export function actionFromLogEntry(entry) {
105
+ return entry?.action || null;
106
+ }
107
+
108
+ export function labelize(value) {
109
+ return String(value || '')
110
+ .replaceAll('_', ' ')
111
+ .replace(/\b\w/g, (letter) => letter.toUpperCase());
112
+ }
113
+
114
+ export function resourceValue(resources, resourceType) {
115
+ if (!resources) return 0;
116
+ if (resourceType === 'hospital_beds') return resources.hospital_beds_free || 0;
117
+ return resources[resourceType] || 0;
118
+ }
frontend/js/api.js ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ async function postJson(url, payload) {
2
+ const response = await fetch(url, {
3
+ method: 'POST',
4
+ headers: { 'Content-Type': 'application/json' },
5
+ body: JSON.stringify(payload),
6
+ });
7
+
8
+ let data = null;
9
+ try {
10
+ data = await response.json();
11
+ } catch {
12
+ data = null;
13
+ }
14
+
15
+ if (!response.ok) {
16
+ const detail = data?.detail || data?.message || `${response.status} ${response.statusText}`;
17
+ throw new Error(detail);
18
+ }
19
+ return data;
20
+ }
21
+
22
+ export async function resetEnvironment({ taskName, seed, maxTicks }) {
23
+ return postJson('/web/reset', {
24
+ task_name: taskName,
25
+ seed,
26
+ max_ticks: maxTicks,
27
+ });
28
+ }
29
+
30
+ export async function stepEnvironment(payload) {
31
+ return postJson('/web/step', {
32
+ action: {
33
+ action: payload,
34
+ metadata: {},
35
+ },
36
+ });
37
+ }
38
+
39
+ export async function getEnvironmentState() {
40
+ const response = await fetch('/web/state');
41
+ if (!response.ok) {
42
+ throw new Error(`${response.status} ${response.statusText}`);
43
+ }
44
+ return response.json();
45
+ }
frontend/js/app.js ADDED
@@ -0,0 +1,613 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { resetEnvironment, stepEnvironment } from './api.js';
2
+ import {
3
+ ACTION_KINDS,
4
+ AUTHORITIES,
5
+ DATA_TYPES,
6
+ RESOURCE_TYPES,
7
+ SEVERITIES,
8
+ buildAction,
9
+ formatAction,
10
+ labelize,
11
+ normalizeRegionSelection,
12
+ resourceValue,
13
+ } from './actions.js';
14
+ import { narrateCouncil } from './council-narration.js';
15
+ import { goToFrame, setReplayTrace, stopReplay, toggleReplayPlayback } from './replay.js';
16
+ import { SAMPLE_TRACE } from './samples.js';
17
+ import { AppState, addTraceFrame, setState, showToast, subscribe } from './state.js';
18
+
19
+ const app = document.getElementById('app');
20
+ let liveAutoplayTimer = null;
21
+
22
+ function init() {
23
+ const first = SAMPLE_TRACE[0];
24
+ setState({
25
+ replayTrace: SAMPLE_TRACE,
26
+ replayIndex: 0,
27
+ observation: first.observation,
28
+ reward: first.reward,
29
+ done: first.done,
30
+ council: narrateCouncil(first.observation, first.reward),
31
+ });
32
+ subscribe(render);
33
+ render();
34
+ }
35
+
36
+ function render() {
37
+ const regionPatch = normalizeRegionSelection(AppState);
38
+ if (Object.keys(regionPatch).length) {
39
+ setState(regionPatch);
40
+ return;
41
+ }
42
+ const council = narrateCouncil(AppState.observation, AppState.reward || 0);
43
+ AppState.council = council;
44
+ app.innerHTML = `
45
+ <main class="shell">
46
+ ${renderTopbar()}
47
+ ${renderCommandbar()}
48
+ <section class="main-grid">
49
+ <div class="left-col">
50
+ ${renderPhasePanel(council)}
51
+ ${renderWorldPanel()}
52
+ ${renderResourcesPanel()}
53
+ </div>
54
+ <div class="center-col">
55
+ ${renderActionPanel(council)}
56
+ ${renderTimelinePanel()}
57
+ </div>
58
+ <div class="right-col">
59
+ ${renderCouncilPanel(council)}
60
+ ${renderReplayPanel()}
61
+ </div>
62
+ </section>
63
+ </main>
64
+ `;
65
+ bindEvents();
66
+ }
67
+
68
+ function renderTopbar() {
69
+ return `
70
+ <header class="topbar">
71
+ <div class="brand">
72
+ <div class="mark">CW</div>
73
+ <div>
74
+ <h1>CrisisWorld Cortex</h1>
75
+ <p>Outbreak response dashboard for live OpenEnv episodes and replay traces.</p>
76
+ </div>
77
+ </div>
78
+ <div class="top-actions">
79
+ <span class="badge ${AppState.mode === 'live' ? 'live' : 'sample'}">${AppState.mode === 'live' ? 'Live env' : 'Sample replay'}</span>
80
+ ${AppState.done ? '<span class="badge done">Terminal</span>' : ''}
81
+ <span class="badge">${AppState.connection}</span>
82
+ <button class="btn ghost" data-action="load-sample">Load Sample</button>
83
+ <button class="btn ghost" data-action="open-web">Open /web</button>
84
+ </div>
85
+ </header>
86
+ `;
87
+ }
88
+
89
+ function renderCommandbar() {
90
+ return `
91
+ <section class="commandbar">
92
+ <div class="fieldrow">
93
+ <label class="field wide">
94
+ <span>Task</span>
95
+ <select data-bind="taskName">
96
+ ${option('outbreak_easy', 'Outbreak Easy', AppState.taskName)}
97
+ ${option('outbreak_medium', 'Outbreak Medium', AppState.taskName)}
98
+ ${option('outbreak_hard', 'Outbreak Hard', AppState.taskName)}
99
+ </select>
100
+ </label>
101
+ <label class="field compact">
102
+ <span>Seed</span>
103
+ <input type="number" min="0" step="1" data-bind="seed" value="${AppState.seed}">
104
+ </label>
105
+ <label class="field compact">
106
+ <span>Max ticks</span>
107
+ <input type="number" min="1" max="30" step="1" data-bind="maxTicks" value="${AppState.maxTicks}">
108
+ </label>
109
+ <button class="btn primary" data-action="reset-live">Reset Live Episode</button>
110
+ </div>
111
+ <div class="fieldrow">
112
+ <label class="field wide">
113
+ <span>Replay speed</span>
114
+ <input type="range" min="350" max="2600" step="50" data-bind="replaySpeedMs" value="${AppState.replaySpeedMs}">
115
+ </label>
116
+ <span class="badge">${AppState.replaySpeedMs} ms</span>
117
+ </div>
118
+ <div class="fieldrow">
119
+ <button class="btn ${AppState.autoplayLive ? 'warn' : 'blue'}" data-action="toggle-live-autoplay" ${AppState.mode !== 'live' || AppState.done ? 'disabled' : ''}>
120
+ ${AppState.autoplayLive ? 'Stop Autoplay' : 'Autoplay Decisions'}
121
+ </button>
122
+ </div>
123
+ </section>
124
+ `;
125
+ }
126
+
127
+ function renderPhasePanel(council) {
128
+ const obs = AppState.observation;
129
+ return `
130
+ <section class="panel">
131
+ <div class="panel-header">
132
+ <h2>Episode State</h2>
133
+ <span class="badge">${AppState.statusMessage}</span>
134
+ </div>
135
+ <div class="panel-body">
136
+ <div class="metrics">
137
+ ${metric('Tick', obs?.tick ?? '-', `${obs?.ticks_remaining ?? '-'} left`)}
138
+ ${metric('Phase', council.phase, `round ${council.round}`)}
139
+ ${metric('Budget', council.budget || '-', 'cognition')}
140
+ ${metric('Reward', formatReward(AppState.reward), `total ${formatReward(AppState.totalReward)}`)}
141
+ </div>
142
+ </div>
143
+ </section>
144
+ `;
145
+ }
146
+
147
+ function renderWorldPanel() {
148
+ const obs = AppState.observation;
149
+ if (!obs) return panel('World State', '<div class="empty">No observation loaded.</div>');
150
+ const regions = obs.regions || [];
151
+ const maxScore = Math.max(...regions.map((region) => pressure(region)), 1);
152
+ const html = `
153
+ <div class="world-map">
154
+ ${regions.map((region) => renderRegion(region, pressure(region) / maxScore)).join('')}
155
+ </div>
156
+ `;
157
+ return panel('World State', html);
158
+ }
159
+
160
+ function renderRegion(region, heat) {
161
+ const hot = heat > 0.78 ? 'hot' : '';
162
+ return `
163
+ <article class="region-node ${hot}">
164
+ <div class="region-top">
165
+ <span class="region-name">${region.region}</span>
166
+ <span class="badge">${Math.round(heat * 100)} pressure</span>
167
+ </div>
168
+ <div class="region-stats">
169
+ ${statline('Cases', clamp(region.reported_cases_d_ago / 180, 0, 1), region.reported_cases_d_ago)}
170
+ ${statline('Load', region.hospital_load, pct(region.hospital_load), 'load')}
171
+ ${statline('Comply', region.compliance_proxy, pct(region.compliance_proxy), 'comp')}
172
+ </div>
173
+ </article>
174
+ `;
175
+ }
176
+
177
+ function renderResourcesPanel() {
178
+ const obs = AppState.observation;
179
+ const resources = obs?.resources || {};
180
+ const constraints = obs?.legal_constraints || [];
181
+ const restrictions = obs?.active_restrictions || [];
182
+ return panel('Resources and Constraints', `
183
+ <div class="resource-grid">
184
+ ${RESOURCE_TYPES.map((type) => `
185
+ <div class="resource">
186
+ <span class="micro-label">${labelize(type)}</span>
187
+ <strong>${resourceValue(resources, type)}</strong>
188
+ </div>
189
+ `).join('')}
190
+ </div>
191
+ <div style="height: 12px"></div>
192
+ <div class="section-title">Active restrictions</div>
193
+ <div class="timeline" style="max-height: 130px; margin-top: 8px;">
194
+ ${restrictions.length ? restrictions.map((item) => `
195
+ <div class="timeline-entry">
196
+ <strong>${item.region}</strong>
197
+ <span>${labelize(item.severity)} movement limits</span>
198
+ <span class="badge">${item.ticks_remaining} ticks</span>
199
+ </div>
200
+ `).join('') : '<div class="tiny">None active.</div>'}
201
+ </div>
202
+ <div style="height: 12px"></div>
203
+ <div class="section-title">Legal constraints</div>
204
+ <div class="timeline" style="max-height: 130px; margin-top: 8px;">
205
+ ${constraints.length ? constraints.map((item) => `
206
+ <div class="timeline-entry rejected">
207
+ <strong>${item.rule_id}</strong>
208
+ <span>Blocks ${item.blocked_action}</span>
209
+ <span class="badge">${item.unlock_via}</span>
210
+ </div>
211
+ `).join('') : '<div class="tiny">No active legal blockers.</div>'}
212
+ </div>
213
+ `);
214
+ }
215
+
216
+ function renderActionPanel(council) {
217
+ const selected = AppState.selectedActionKind;
218
+ return panel('Task Controls', `
219
+ <div class="action-panel">
220
+ <div class="segmented" role="tablist" aria-label="Action type">
221
+ ${ACTION_KINDS.map((kind) => `
222
+ <button data-kind="${kind.id}" class="${selected === kind.id ? 'active' : ''}">${kind.label}</button>
223
+ `).join('')}
224
+ </div>
225
+ <div class="action-form">
226
+ ${renderActionInputs(selected)}
227
+ <div class="fieldrow">
228
+ <button class="btn primary" data-action="submit-action" ${AppState.mode !== 'live' || AppState.done ? 'disabled' : ''}>Submit Action</button>
229
+ <button class="btn blue" data-action="apply-recommendation" ${AppState.mode !== 'live' || AppState.done ? 'disabled' : ''}>Apply Recommendation</button>
230
+ <span class="tiny">Recommendation: ${formatAction(council.decision)}</span>
231
+ </div>
232
+ </div>
233
+ </div>
234
+ `);
235
+ }
236
+
237
+ function renderActionInputs(kind) {
238
+ const regions = AppState.observation?.regions || [{ region: AppState.selectedRegion || 'R1' }];
239
+ const regionSelect = `
240
+ <label class="field">
241
+ <span>Region</span>
242
+ <select data-bind="selectedRegion">
243
+ ${regions.map((region) => option(region.region, region.region, AppState.selectedRegion)).join('')}
244
+ </select>
245
+ </label>
246
+ `;
247
+ const resourceSelect = (bindName, label) => `
248
+ <label class="field">
249
+ <span>${label}</span>
250
+ <select data-bind="${bindName}">
251
+ ${RESOURCE_TYPES.map((type) => option(type, labelize(type), AppState[bindName])).join('')}
252
+ </select>
253
+ </label>
254
+ `;
255
+ const quantity = `
256
+ <label class="field">
257
+ <span>${kind === 'reallocate_budget' ? 'Amount' : 'Quantity'}</span>
258
+ <input type="number" min="0" step="1" data-bind="quantity" value="${AppState.quantity}">
259
+ </label>
260
+ `;
261
+ if (kind === 'deploy_resource') {
262
+ return `<div class="param-grid">${regionSelect}${resourceSelect('selectedResource', 'Resource')}${quantity}</div>`;
263
+ }
264
+ if (kind === 'request_data') {
265
+ return `
266
+ <div class="param-grid">
267
+ ${regionSelect}
268
+ <label class="field">
269
+ <span>Data type</span>
270
+ <select data-bind="dataType">${DATA_TYPES.map((type) => option(type, labelize(type), AppState.dataType)).join('')}</select>
271
+ </label>
272
+ </div>
273
+ `;
274
+ }
275
+ if (kind === 'restrict_movement') {
276
+ return `
277
+ <div class="param-grid">
278
+ ${regionSelect}
279
+ <label class="field">
280
+ <span>Severity</span>
281
+ <select data-bind="severity">${SEVERITIES.map((type) => option(type, labelize(type), AppState.severity)).join('')}</select>
282
+ </label>
283
+ </div>
284
+ `;
285
+ }
286
+ if (kind === 'escalate') {
287
+ return `
288
+ <div class="param-grid">
289
+ <label class="field">
290
+ <span>Authority</span>
291
+ <select data-bind="authority">${AUTHORITIES.map((type) => option(type, labelize(type), AppState.authority)).join('')}</select>
292
+ </label>
293
+ </div>
294
+ `;
295
+ }
296
+ if (kind === 'reallocate_budget') {
297
+ return `<div class="param-grid">${resourceSelect('selectedResource', 'From')}${resourceSelect('selectedToResource', 'To')}${quantity}</div>`;
298
+ }
299
+ return '<div class="tiny">No parameters required.</div>';
300
+ }
301
+
302
+ function renderCouncilPanel(council) {
303
+ return panel('Narrated Council', `
304
+ <div class="tiny" style="margin-bottom: 10px;">
305
+ Frontend visualization derived from observations. It is not a live Cortex runtime or LLM council.
306
+ </div>
307
+ <div class="decision">
308
+ <h3>Converged action</h3>
309
+ <strong>${formatAction(council.decision)}</strong>
310
+ <p class="tiny">${council.rationale}</p>
311
+ </div>
312
+ <div style="height: 10px"></div>
313
+ <div class="council-stack">
314
+ ${council.recommendations.map((report) => renderBrain(report)).join('')}
315
+ </div>
316
+ <div style="height: 10px"></div>
317
+ <div class="section-title">Preserved dissent</div>
318
+ <div class="timeline" style="max-height: 150px; margin-top: 8px;">
319
+ ${council.preservedDissent.length ? council.preservedDissent.map((item, index) => `
320
+ <div class="timeline-entry">
321
+ <strong>D${index + 1}</strong>
322
+ <span>${item}</span>
323
+ <span></span>
324
+ </div>
325
+ `).join('') : '<div class="tiny">No dissent preserved on this frame.</div>'}
326
+ </div>
327
+ `);
328
+ }
329
+
330
+ function renderBrain(report) {
331
+ return `
332
+ <article class="brain-card">
333
+ <div class="brain-head">
334
+ <h3>${report.name}</h3>
335
+ <span class="badge">${Math.round(report.confidence * 100)} conf</span>
336
+ </div>
337
+ <div class="bar"><span style="width: ${Math.round(report.confidence * 100)}%"></span></div>
338
+ <p><strong>${formatAction(report.action)}</strong></p>
339
+ <p>${report.summary}</p>
340
+ <p><span class="micro-label">Challenge</span><br>${report.challenge}</p>
341
+ <p><span class="micro-label">Minority</span><br>${formatAction(report.minority)}</p>
342
+ </article>
343
+ `;
344
+ }
345
+
346
+ function renderTimelinePanel() {
347
+ const log = AppState.observation?.recent_action_log || [];
348
+ const liveTrace = AppState.liveTrace || [];
349
+ return panel('Final Action Timeline', `
350
+ <div class="timeline">
351
+ ${log.length ? log.map((entry) => `
352
+ <div class="timeline-entry ${entry.accepted ? '' : 'rejected'}">
353
+ <strong>T${entry.tick}</strong>
354
+ <span>${formatAction(entry.action)}</span>
355
+ <span class="badge">${entry.accepted ? 'accepted' : 'rejected'}</span>
356
+ </div>
357
+ `).join('') : '<div class="empty">No actions have been submitted yet.</div>'}
358
+ </div>
359
+ <div style="height: 12px"></div>
360
+ <div class="tiny">Live trace frames captured this session: ${liveTrace.length}</div>
361
+ `);
362
+ }
363
+
364
+ function renderReplayPanel() {
365
+ const max = Math.max(0, AppState.replayTrace.length - 1);
366
+ return panel('Replay', `
367
+ <div class="replay-row">
368
+ <button class="btn" data-action="toggle-replay">${AppState.replayPlaying ? 'Pause' : 'Play'}</button>
369
+ <input type="range" min="0" max="${max}" step="1" value="${AppState.replayIndex}" data-action="scrub-replay" ${max === 0 ? 'disabled' : ''}>
370
+ <span class="badge">${AppState.replayIndex + 1}/${AppState.replayTrace.length || 1}</span>
371
+ </div>
372
+ <div style="height: 10px"></div>
373
+ <div class="fieldrow">
374
+ <button class="btn ghost" data-action="use-live-trace" ${AppState.liveTrace.length ? '' : 'disabled'}>Use Live Trace</button>
375
+ <button class="btn ghost" data-action="load-sample">Use Sample Trace</button>
376
+ </div>
377
+ <p class="tiny">Replay changes the displayed frame only. It does not step the environment.</p>
378
+ `);
379
+ }
380
+
381
+ function panel(title, body) {
382
+ return `
383
+ <section class="panel">
384
+ <div class="panel-header">
385
+ <h2>${title}</h2>
386
+ </div>
387
+ <div class="panel-body">${body}</div>
388
+ </section>
389
+ `;
390
+ }
391
+
392
+ function metric(label, value, hint) {
393
+ return `
394
+ <div class="metric">
395
+ <span class="micro-label">${label}</span>
396
+ <strong>${value}</strong>
397
+ <span class="tiny">${hint}</span>
398
+ </div>
399
+ `;
400
+ }
401
+
402
+ function statline(label, value, display, extraClass = '') {
403
+ return `
404
+ <div class="statline">
405
+ <span>${label}</span>
406
+ <div class="bar ${extraClass}"><span style="width: ${Math.round(clamp(value, 0, 1) * 100)}%"></span></div>
407
+ <strong>${display}</strong>
408
+ </div>
409
+ `;
410
+ }
411
+
412
+ function bindEvents() {
413
+ document.querySelectorAll('[data-bind]').forEach((element) => {
414
+ element.addEventListener('change', () => {
415
+ const key = element.dataset.bind;
416
+ const value = element.type === 'number' || element.type === 'range'
417
+ ? Number.parseInt(element.value, 10)
418
+ : element.value;
419
+ setState({ [key]: value });
420
+ });
421
+ if (element.type === 'range') {
422
+ element.addEventListener('input', () => {
423
+ const key = element.dataset.bind;
424
+ setState({ [key]: Number.parseInt(element.value, 10) });
425
+ });
426
+ }
427
+ });
428
+
429
+ document.querySelectorAll('[data-kind]').forEach((button) => {
430
+ button.addEventListener('click', () => setState({ selectedActionKind: button.dataset.kind }));
431
+ });
432
+
433
+ document.querySelectorAll('[data-action]').forEach((element) => {
434
+ element.addEventListener('click', () => handleAction(element.dataset.action, element));
435
+ if (element.dataset.action === 'scrub-replay') {
436
+ element.addEventListener('input', () => {
437
+ stopReplay();
438
+ goToFrame(Number.parseInt(element.value, 10));
439
+ });
440
+ }
441
+ });
442
+ }
443
+
444
+ async function handleAction(actionName, element) {
445
+ if (actionName === 'open-web') {
446
+ window.location.href = '/web/';
447
+ return;
448
+ }
449
+ if (actionName === 'load-sample') {
450
+ stopLiveAutoplay();
451
+ setReplayTrace(SAMPLE_TRACE);
452
+ setState({
453
+ mode: 'sample',
454
+ statusMessage: 'Sample trace loaded',
455
+ council: narrateCouncil(SAMPLE_TRACE[0].observation, SAMPLE_TRACE[0].reward),
456
+ });
457
+ showToast('Sample trace loaded', 'success');
458
+ return;
459
+ }
460
+ if (actionName === 'use-live-trace') {
461
+ if (!AppState.liveTrace.length) return;
462
+ setReplayTrace(AppState.liveTrace);
463
+ setState({ mode: 'replay', statusMessage: 'Live trace replay' });
464
+ return;
465
+ }
466
+ if (actionName === 'toggle-replay') {
467
+ toggleReplayPlayback();
468
+ return;
469
+ }
470
+ if (actionName === 'scrub-replay') {
471
+ stopReplay();
472
+ goToFrame(Number.parseInt(element.value, 10));
473
+ return;
474
+ }
475
+ if (actionName === 'reset-live') {
476
+ await resetLive();
477
+ return;
478
+ }
479
+ if (actionName === 'submit-action') {
480
+ await submitAction(buildAction(AppState));
481
+ return;
482
+ }
483
+ if (actionName === 'apply-recommendation') {
484
+ await submitAction(AppState.council?.decision || { kind: 'no_op' });
485
+ return;
486
+ }
487
+ if (actionName === 'toggle-live-autoplay') {
488
+ toggleLiveAutoplay();
489
+ }
490
+ }
491
+
492
+ async function resetLive() {
493
+ stopReplay();
494
+ stopLiveAutoplay();
495
+ setState({ connection: 'resetting', statusMessage: 'Resetting live episode' });
496
+ try {
497
+ const data = await resetEnvironment({
498
+ taskName: AppState.taskName,
499
+ seed: AppState.seed,
500
+ maxTicks: AppState.maxTicks,
501
+ });
502
+ const frame = {
503
+ label: `Live reset: ${AppState.taskName}`,
504
+ action: null,
505
+ observation: data.observation,
506
+ reward: data.reward ?? 0,
507
+ done: Boolean(data.done),
508
+ };
509
+ setState({
510
+ mode: 'live',
511
+ connection: 'connected',
512
+ observation: data.observation,
513
+ reward: data.reward ?? 0,
514
+ done: Boolean(data.done),
515
+ totalReward: 0,
516
+ liveTrace: [frame],
517
+ replayTrace: [frame],
518
+ replayIndex: 0,
519
+ statusMessage: 'Live episode ready',
520
+ });
521
+ showToast('Live episode reset', 'success');
522
+ } catch (error) {
523
+ setState({ connection: 'error', statusMessage: 'Reset failed' });
524
+ showToast(`Reset failed: ${error.message}`, 'error');
525
+ }
526
+ }
527
+
528
+ async function submitAction(payload) {
529
+ if (AppState.mode !== 'live') {
530
+ showToast('Reset a live episode before submitting actions.', 'error');
531
+ return;
532
+ }
533
+ if (AppState.done) {
534
+ showToast('Episode is terminal. Reset to continue.', 'error');
535
+ return;
536
+ }
537
+ setState({ connection: 'stepping', statusMessage: `Submitting ${payload.kind}` });
538
+ try {
539
+ const data = await stepEnvironment(payload);
540
+ const reward = Number(data.reward ?? 0);
541
+ const frame = {
542
+ label: `Submitted ${formatAction(payload)}`,
543
+ action: payload,
544
+ observation: data.observation,
545
+ reward,
546
+ done: Boolean(data.done),
547
+ };
548
+ addTraceFrame(frame);
549
+ setState({
550
+ mode: 'live',
551
+ connection: 'connected',
552
+ observation: data.observation,
553
+ reward,
554
+ done: Boolean(data.done),
555
+ totalReward: AppState.totalReward + reward,
556
+ statusMessage: data.done ? 'Episode complete' : 'Action accepted',
557
+ });
558
+ if (data.done) {
559
+ stopLiveAutoplay();
560
+ showToast('Episode complete', 'success');
561
+ }
562
+ } catch (error) {
563
+ stopLiveAutoplay();
564
+ setState({ connection: 'error', statusMessage: 'Step failed' });
565
+ showToast(`Step failed: ${error.message}`, 'error');
566
+ }
567
+ }
568
+
569
+ function toggleLiveAutoplay() {
570
+ if (AppState.autoplayLive) {
571
+ stopLiveAutoplay();
572
+ return;
573
+ }
574
+ if (AppState.mode !== 'live' || AppState.done) return;
575
+ setState({ autoplayLive: true });
576
+ liveAutoplayTimer = window.setInterval(async () => {
577
+ if (AppState.done || AppState.mode !== 'live') {
578
+ stopLiveAutoplay();
579
+ return;
580
+ }
581
+ await submitAction(AppState.council?.decision || { kind: 'no_op' });
582
+ }, AppState.replaySpeedMs);
583
+ }
584
+
585
+ function stopLiveAutoplay() {
586
+ if (liveAutoplayTimer) {
587
+ window.clearInterval(liveAutoplayTimer);
588
+ liveAutoplayTimer = null;
589
+ }
590
+ if (AppState.autoplayLive) setState({ autoplayLive: false });
591
+ }
592
+
593
+ function option(value, label, selected) {
594
+ return `<option value="${value}" ${value === selected ? 'selected' : ''}>${label}</option>`;
595
+ }
596
+
597
+ function pressure(region) {
598
+ return region.reported_cases_d_ago / 1000 + region.hospital_load * 1.55 + (1 - region.compliance_proxy) * 0.75;
599
+ }
600
+
601
+ function pct(value) {
602
+ return `${Math.round((value || 0) * 100)}%`;
603
+ }
604
+
605
+ function formatReward(value) {
606
+ return typeof value === 'number' ? value.toFixed(2) : '-';
607
+ }
608
+
609
+ function clamp(value, min, max) {
610
+ return Math.max(min, Math.min(max, value));
611
+ }
612
+
613
+ init();
frontend/js/council-narration.js ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { formatAction, resourceValue } from './actions.js';
2
+
3
+ const BRAINS = [
4
+ {
5
+ id: 'epidemiology',
6
+ name: 'Epidemiology',
7
+ tone: 'Transmission risk and hospital pressure',
8
+ },
9
+ {
10
+ id: 'logistics',
11
+ name: 'Logistics',
12
+ tone: 'Resource fit and operational scarcity',
13
+ },
14
+ {
15
+ id: 'governance',
16
+ name: 'Governance',
17
+ tone: 'Compliance, legality, and escalation risk',
18
+ },
19
+ ];
20
+
21
+ // This module is deliberately a frontend visualization layer. It does not call
22
+ // the real Cortex runtime or any LLM subagent; it narrates deterministic,
23
+ // observation-derived deliberation so the Space feels alive without coupling UI
24
+ // code to the simulator, reward, schemas, or training stack.
25
+ export function narrateCouncil(obs, lastReward = 0) {
26
+ if (!obs) {
27
+ return {
28
+ phase: 'Divergence',
29
+ round: 1,
30
+ budget: 0,
31
+ recommendations: [],
32
+ preservedDissent: [],
33
+ decision: { kind: 'no_op' },
34
+ rationale: 'No observation loaded yet.',
35
+ challenge: 'Start a live episode or load the sample trace.',
36
+ };
37
+ }
38
+
39
+ const regions = obs.regions || [];
40
+ const pressure = regions.map((region) => ({
41
+ region: region.region,
42
+ score: region.reported_cases_d_ago / 1000 + region.hospital_load * 1.55 + (1 - region.compliance_proxy) * 0.75,
43
+ cases: region.reported_cases_d_ago,
44
+ load: region.hospital_load,
45
+ compliance: region.compliance_proxy,
46
+ })).sort((a, b) => b.score - a.score);
47
+
48
+ const hot = pressure[0] || { region: 'R1', score: 0, cases: 0, load: 0, compliance: 1 };
49
+ const resources = obs.resources || {};
50
+ const strictBlocked = (obs.legal_constraints || []).some(
51
+ (constraint) => constraint.blocked_action === 'restrict_movement.strict',
52
+ );
53
+ const budget = obs.cognition_budget_remaining || 0;
54
+ const urgency = clamp((hot.score + (obs.ticks_remaining <= 4 ? 0.35 : 0)) / 2.6, 0, 1);
55
+ const agreement = clamp(1 - Math.abs((resourceValue(resources, 'hospital_beds') / 500) - urgency), 0.1, 0.98);
56
+
57
+ const epiAction = chooseEpidemiologyAction(hot, strictBlocked, obs);
58
+ const logAction = chooseLogisticsAction(hot, resources);
59
+ const govAction = chooseGovernanceAction(hot, strictBlocked, obs);
60
+ const raw = [
61
+ brainReport(BRAINS[0], epiAction, hot, urgency, lastReward, obs),
62
+ brainReport(BRAINS[1], logAction, hot, urgency, lastReward, obs),
63
+ brainReport(BRAINS[2], govAction, hot, urgency, lastReward, obs),
64
+ ];
65
+
66
+ const decision = chooseDecision(raw, obs, strictBlocked);
67
+ const preservedDissent = raw
68
+ .filter((report) => report.action.kind !== decision.kind || report.action.region !== decision.region)
69
+ .slice(0, 2)
70
+ .map((report) => `${report.name}: ${report.challenge}`);
71
+
72
+ return {
73
+ phase: phaseFor(obs, agreement),
74
+ round: urgency > 0.72 && agreement < 0.72 ? 2 : 1,
75
+ budget,
76
+ agreement,
77
+ recommendations: raw,
78
+ preservedDissent,
79
+ decision,
80
+ rationale: decisionRationale(decision, hot, strictBlocked, obs),
81
+ challenge: raw.reduce((best, report) => report.challengeScore > best.challengeScore ? report : best, raw[0]).challenge,
82
+ };
83
+ }
84
+
85
+ function chooseEpidemiologyAction(hot, strictBlocked, obs) {
86
+ if (hot.load > 0.72 || hot.cases > 120) {
87
+ return {
88
+ kind: 'restrict_movement',
89
+ region: hot.region,
90
+ severity: strictBlocked ? 'moderate' : 'strict',
91
+ };
92
+ }
93
+ if ((obs.ticks_remaining || 0) > 6 && hot.cases < 35) {
94
+ return {
95
+ kind: 'request_data',
96
+ region: hot.region,
97
+ data_type: 'case_survey',
98
+ };
99
+ }
100
+ return {
101
+ kind: 'deploy_resource',
102
+ region: hot.region,
103
+ resource_type: 'test_kits',
104
+ quantity: 80,
105
+ };
106
+ }
107
+
108
+ function chooseLogisticsAction(hot, resources) {
109
+ if (resourceValue(resources, 'hospital_beds') < 120 && resourceValue(resources, 'mobile_units') >= 2) {
110
+ return {
111
+ kind: 'deploy_resource',
112
+ region: hot.region,
113
+ resource_type: 'mobile_units',
114
+ quantity: Math.min(3, resourceValue(resources, 'mobile_units')),
115
+ };
116
+ }
117
+ if (resourceValue(resources, 'test_kits') > 120) {
118
+ return {
119
+ kind: 'deploy_resource',
120
+ region: hot.region,
121
+ resource_type: 'test_kits',
122
+ quantity: Math.min(120, resourceValue(resources, 'test_kits')),
123
+ };
124
+ }
125
+ if (resourceValue(resources, 'vaccine_doses') > 80) {
126
+ return {
127
+ kind: 'deploy_resource',
128
+ region: hot.region,
129
+ resource_type: 'vaccine_doses',
130
+ quantity: Math.min(160, resourceValue(resources, 'vaccine_doses')),
131
+ };
132
+ }
133
+ return { kind: 'no_op' };
134
+ }
135
+
136
+ function chooseGovernanceAction(hot, strictBlocked, obs) {
137
+ if (strictBlocked && hot.load > 0.62) {
138
+ return {
139
+ kind: 'escalate',
140
+ to_authority: 'national',
141
+ };
142
+ }
143
+ if (hot.compliance < 0.68) {
144
+ return {
145
+ kind: 'restrict_movement',
146
+ region: hot.region,
147
+ severity: 'light',
148
+ };
149
+ }
150
+ if ((obs.legal_constraints || []).length && (obs.ticks_remaining || 0) <= 5) {
151
+ return {
152
+ kind: 'escalate',
153
+ to_authority: 'regional',
154
+ };
155
+ }
156
+ return {
157
+ kind: 'request_data',
158
+ region: hot.region,
159
+ data_type: 'compliance_check',
160
+ };
161
+ }
162
+
163
+ function brainReport(brain, action, hot, urgency, lastReward, obs) {
164
+ const confidenceBase = brain.id === 'logistics'
165
+ ? 0.58 + urgency * 0.25
166
+ : brain.id === 'governance'
167
+ ? 0.54 + (1 - hot.compliance) * 0.34
168
+ : 0.6 + hot.load * 0.28;
169
+ const confidence = clamp(confidenceBase + Math.max(lastReward, -0.15) * 0.08, 0.18, 0.96);
170
+ const minority = action.kind === 'restrict_movement'
171
+ ? { kind: 'request_data', region: hot.region, data_type: 'hospital_audit' }
172
+ : { kind: 'restrict_movement', region: hot.region, severity: 'light' };
173
+ return {
174
+ id: brain.id,
175
+ name: brain.name,
176
+ tone: brain.tone,
177
+ action,
178
+ confidence,
179
+ summary: summaryFor(brain.id, action, hot, obs),
180
+ challenge: challengeFor(brain.id, action, hot, obs),
181
+ challengeScore: 1 - confidence + (action.kind === 'no_op' ? 0.25 : 0),
182
+ minority,
183
+ };
184
+ }
185
+
186
+ function chooseDecision(reports, obs, strictBlocked) {
187
+ const counts = new Map();
188
+ for (const report of reports) {
189
+ const key = actionKey(report.action);
190
+ counts.set(key, (counts.get(key) || 0) + report.confidence);
191
+ }
192
+ let winner = reports[0].action;
193
+ let winnerScore = -1;
194
+ for (const report of reports) {
195
+ const score = counts.get(actionKey(report.action)) || 0;
196
+ if (score > winnerScore) {
197
+ winner = report.action;
198
+ winnerScore = score;
199
+ }
200
+ }
201
+ if (strictBlocked && winner.kind === 'restrict_movement' && winner.severity === 'strict') {
202
+ return { kind: 'escalate', to_authority: 'national' };
203
+ }
204
+ if (obs.done) return { kind: 'no_op' };
205
+ return winner;
206
+ }
207
+
208
+ function summaryFor(brainId, action, hot, obs) {
209
+ if (brainId === 'epidemiology') {
210
+ return `${hot.region} is carrying the highest observed pressure; ${formatAction(action)} is the fastest epidemiological lever.`;
211
+ }
212
+ if (brainId === 'logistics') {
213
+ return `Available stock should be spent where hospital load is most exposed; ${formatAction(action)} has the cleanest operational path.`;
214
+ }
215
+ const locked = (obs.legal_constraints || []).length ? 'legal constraints remain active' : 'legal constraints are clear';
216
+ return `${locked}; ${formatAction(action)} balances actionability with compliance risk.`;
217
+ }
218
+
219
+ function challengeFor(brainId, action, hot, obs) {
220
+ if (brainId === 'epidemiology') {
221
+ return `Telemetry is delayed, so ${hot.region} may not be the only active chain.`;
222
+ }
223
+ if (brainId === 'logistics') {
224
+ return action.kind === 'deploy_resource'
225
+ ? `Stock spent now cannot cover a late spike if the episode stretches ${obs.ticks_remaining} more ticks.`
226
+ : 'Inaction may waste scarce response windows.';
227
+ }
228
+ return action.kind === 'restrict_movement'
229
+ ? 'Movement limits can backfire if compliance is already weak.'
230
+ : 'Escalation consumes attention and may not reduce spread by itself.';
231
+ }
232
+
233
+ function decisionRationale(action, hot, strictBlocked, obs) {
234
+ if (obs.done) return 'Episode is terminal; replay the trace or reset for another run.';
235
+ const legal = strictBlocked ? ' Strict movement is legally blocked until escalation.' : '';
236
+ return `Converged on ${formatAction(action)} because ${hot.region} has the highest combined case, load, and compliance pressure.${legal}`;
237
+ }
238
+
239
+ function phaseFor(obs, agreement) {
240
+ if (!obs.recent_action_log?.length) return 'Divergence';
241
+ if (agreement < 0.55) return 'Challenge';
242
+ if ((obs.ticks_remaining || 0) <= 3) return 'Convergence';
243
+ return 'Narrowing';
244
+ }
245
+
246
+ function actionKey(action) {
247
+ if (!action) return 'none';
248
+ return [action.kind, action.region || '', action.resource_type || '', action.severity || '', action.to_authority || ''].join(':');
249
+ }
250
+
251
+ function clamp(value, min, max) {
252
+ return Math.max(min, Math.min(max, value));
253
+ }
frontend/js/replay.js ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { AppState, currentFrame, setState } from './state.js';
2
+
3
+ let replayTimer = null;
4
+
5
+ export function setReplayTrace(trace) {
6
+ const replayTrace = Array.isArray(trace) ? trace : [];
7
+ setState({
8
+ replayTrace,
9
+ replayIndex: 0,
10
+ replayPlaying: false,
11
+ observation: replayTrace[0]?.observation || null,
12
+ reward: replayTrace[0]?.reward ?? null,
13
+ done: Boolean(replayTrace[0]?.done),
14
+ });
15
+ }
16
+
17
+ export function goToFrame(index) {
18
+ const bounded = Math.max(0, Math.min(index, AppState.replayTrace.length - 1));
19
+ const frame = AppState.replayTrace[bounded];
20
+ if (!frame) return;
21
+ setState({
22
+ replayIndex: bounded,
23
+ observation: frame.observation,
24
+ reward: frame.reward ?? null,
25
+ done: Boolean(frame.done),
26
+ statusMessage: frame.label || `Replay frame ${bounded + 1}`,
27
+ });
28
+ }
29
+
30
+ export function toggleReplayPlayback(renderTick) {
31
+ if (AppState.replayPlaying) {
32
+ stopReplay();
33
+ return;
34
+ }
35
+ if (AppState.replayTrace.length < 2) return;
36
+ setState({ replayPlaying: true });
37
+ replayTimer = window.setInterval(() => {
38
+ const next = AppState.replayIndex + 1;
39
+ if (next >= AppState.replayTrace.length) {
40
+ stopReplay();
41
+ return;
42
+ }
43
+ goToFrame(next);
44
+ if (typeof renderTick === 'function') renderTick(currentFrame());
45
+ }, AppState.replaySpeedMs);
46
+ }
47
+
48
+ export function stopReplay() {
49
+ if (replayTimer) {
50
+ window.clearInterval(replayTimer);
51
+ replayTimer = null;
52
+ }
53
+ setState({ replayPlaying: false });
54
+ }
frontend/js/samples.js ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const SAMPLE_TRACE = [
2
+ {
3
+ label: 'Reset hard outbreak',
4
+ reward: 0,
5
+ done: false,
6
+ action: null,
7
+ observation: {
8
+ regions: [
9
+ { region: 'R1', reported_cases_d_ago: 52, hospital_load: 0.41, compliance_proxy: 0.75 },
10
+ { region: 'R2', reported_cases_d_ago: 49, hospital_load: 0.39, compliance_proxy: 0.74 },
11
+ { region: 'R3', reported_cases_d_ago: 56, hospital_load: 0.44, compliance_proxy: 0.72 },
12
+ { region: 'R4', reported_cases_d_ago: 45, hospital_load: 0.36, compliance_proxy: 0.77 },
13
+ { region: 'R5', reported_cases_d_ago: 47, hospital_load: 0.34, compliance_proxy: 0.78 },
14
+ ],
15
+ resources: { test_kits: 200, hospital_beds_free: 150, mobile_units: 5, vaccine_doses: 400 },
16
+ active_restrictions: [],
17
+ legal_constraints: [{ rule_id: 'L1', blocked_action: 'restrict_movement.strict', unlock_via: 'escalate' }],
18
+ tick: 0,
19
+ ticks_remaining: 12,
20
+ cognition_budget_remaining: 6000,
21
+ recent_action_log: [],
22
+ },
23
+ },
24
+ {
25
+ label: 'Escalation unlocks strict controls',
26
+ reward: 0.11,
27
+ done: false,
28
+ action: { kind: 'escalate', to_authority: 'national' },
29
+ observation: {
30
+ regions: [
31
+ { region: 'R1', reported_cases_d_ago: 55, hospital_load: 0.47, compliance_proxy: 0.74 },
32
+ { region: 'R2', reported_cases_d_ago: 52, hospital_load: 0.43, compliance_proxy: 0.73 },
33
+ { region: 'R3', reported_cases_d_ago: 62, hospital_load: 0.52, compliance_proxy: 0.70 },
34
+ { region: 'R4', reported_cases_d_ago: 49, hospital_load: 0.40, compliance_proxy: 0.75 },
35
+ { region: 'R5', reported_cases_d_ago: 48, hospital_load: 0.37, compliance_proxy: 0.77 },
36
+ ],
37
+ resources: { test_kits: 200, hospital_beds_free: 150, mobile_units: 5, vaccine_doses: 400 },
38
+ active_restrictions: [],
39
+ legal_constraints: [],
40
+ tick: 1,
41
+ ticks_remaining: 11,
42
+ cognition_budget_remaining: 6000,
43
+ recent_action_log: [
44
+ { tick: 0, action: { kind: 'escalate', to_authority: 'national' }, accepted: true },
45
+ ],
46
+ },
47
+ },
48
+ {
49
+ label: 'Strict restriction contains R3 pressure',
50
+ reward: 0.19,
51
+ done: false,
52
+ action: { kind: 'restrict_movement', region: 'R3', severity: 'strict' },
53
+ observation: {
54
+ regions: [
55
+ { region: 'R1', reported_cases_d_ago: 58, hospital_load: 0.48, compliance_proxy: 0.73 },
56
+ { region: 'R2', reported_cases_d_ago: 55, hospital_load: 0.44, compliance_proxy: 0.72 },
57
+ { region: 'R3', reported_cases_d_ago: 75, hospital_load: 0.61, compliance_proxy: 0.69 },
58
+ { region: 'R4', reported_cases_d_ago: 51, hospital_load: 0.42, compliance_proxy: 0.75 },
59
+ { region: 'R5', reported_cases_d_ago: 50, hospital_load: 0.39, compliance_proxy: 0.76 },
60
+ ],
61
+ resources: { test_kits: 200, hospital_beds_free: 150, mobile_units: 5, vaccine_doses: 400 },
62
+ active_restrictions: [{ region: 'R3', severity: 'strict', ticks_remaining: 2 }],
63
+ legal_constraints: [],
64
+ tick: 2,
65
+ ticks_remaining: 10,
66
+ cognition_budget_remaining: 6000,
67
+ recent_action_log: [
68
+ { tick: 0, action: { kind: 'escalate', to_authority: 'national' }, accepted: true },
69
+ { tick: 1, action: { kind: 'restrict_movement', region: 'R3', severity: 'strict' }, accepted: true },
70
+ ],
71
+ },
72
+ },
73
+ {
74
+ label: 'Mobile units cover hospital load',
75
+ reward: 0.24,
76
+ done: false,
77
+ action: { kind: 'deploy_resource', region: 'R3', resource_type: 'mobile_units', quantity: 3 },
78
+ observation: {
79
+ regions: [
80
+ { region: 'R1', reported_cases_d_ago: 59, hospital_load: 0.48, compliance_proxy: 0.74 },
81
+ { region: 'R2', reported_cases_d_ago: 57, hospital_load: 0.45, compliance_proxy: 0.72 },
82
+ { region: 'R3', reported_cases_d_ago: 82, hospital_load: 0.56, compliance_proxy: 0.71 },
83
+ { region: 'R4', reported_cases_d_ago: 54, hospital_load: 0.44, compliance_proxy: 0.75 },
84
+ { region: 'R5', reported_cases_d_ago: 52, hospital_load: 0.40, compliance_proxy: 0.76 },
85
+ ],
86
+ resources: { test_kits: 200, hospital_beds_free: 150, mobile_units: 2, vaccine_doses: 400 },
87
+ active_restrictions: [{ region: 'R3', severity: 'strict', ticks_remaining: 1 }],
88
+ legal_constraints: [],
89
+ tick: 3,
90
+ ticks_remaining: 9,
91
+ cognition_budget_remaining: 6000,
92
+ recent_action_log: [
93
+ { tick: 0, action: { kind: 'escalate', to_authority: 'national' }, accepted: true },
94
+ { tick: 1, action: { kind: 'restrict_movement', region: 'R3', severity: 'strict' }, accepted: true },
95
+ { tick: 2, action: { kind: 'deploy_resource', region: 'R3', resource_type: 'mobile_units', quantity: 3 }, accepted: true },
96
+ ],
97
+ },
98
+ },
99
+ ];
frontend/js/state.js ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const subscribers = new Set();
2
+
3
+ export const AppState = {
4
+ connection: 'idle',
5
+ mode: 'sample',
6
+ taskName: 'outbreak_easy',
7
+ seed: 0,
8
+ maxTicks: 12,
9
+ observation: null,
10
+ reward: null,
11
+ done: false,
12
+ totalReward: 0,
13
+ liveTrace: [],
14
+ replayTrace: [],
15
+ replayIndex: 0,
16
+ replayPlaying: false,
17
+ replaySpeedMs: 1200,
18
+ autoplayLive: false,
19
+ selectedActionKind: 'deploy_resource',
20
+ selectedRegion: 'R1',
21
+ selectedResource: 'test_kits',
22
+ selectedToResource: 'hospital_beds',
23
+ quantity: 50,
24
+ dataType: 'case_survey',
25
+ severity: 'moderate',
26
+ authority: 'regional',
27
+ council: null,
28
+ statusMessage: 'Sample trace loaded',
29
+ };
30
+
31
+ export function subscribe(callback) {
32
+ subscribers.add(callback);
33
+ return () => subscribers.delete(callback);
34
+ }
35
+
36
+ export function setState(patch) {
37
+ Object.assign(AppState, patch);
38
+ subscribers.forEach((callback) => callback(AppState));
39
+ }
40
+
41
+ export function addTraceFrame(frame) {
42
+ const liveTrace = [...AppState.liveTrace, frame];
43
+ setState({
44
+ liveTrace,
45
+ replayTrace: liveTrace,
46
+ replayIndex: liveTrace.length - 1,
47
+ });
48
+ }
49
+
50
+ export function currentFrame() {
51
+ return AppState.replayTrace[AppState.replayIndex] || null;
52
+ }
53
+
54
+ export function showToast(message, type = 'info') {
55
+ const root = document.getElementById('toast-root');
56
+ if (!root) return;
57
+ const toast = document.createElement('div');
58
+ toast.className = `toast toast-${type}`;
59
+ toast.textContent = message;
60
+ root.appendChild(toast);
61
+ window.setTimeout(() => {
62
+ toast.classList.add('toast-out');
63
+ window.setTimeout(() => toast.remove(), 220);
64
+ }, 3200);
65
+ }
inference.py CHANGED
@@ -37,22 +37,21 @@ Score formula (Session 7a §7 + 7b §9.4 revision): see compute_score.
37
 
38
  from __future__ import annotations
39
 
 
40
  import os
41
  import sys
42
  from dataclasses import dataclass
43
- try:
44
- from dotenv import load_dotenv
45
- load_dotenv()
46
- except ImportError:
47
- pass
48
- from typing import Any, Dict, List, Optional
49
-
50
- from CrisisWorldCortex.baselines.flat_agent import B1FlatAgent, B1StepEvent
51
- from CrisisWorldCortex.cortex.llm_client import LLMClient
52
  from CrisisWorldCortex.models import OuterActionPayload
53
  from CrisisWorldCortex.server.graders import terminal_bonus
54
  from CrisisWorldCortex.server.simulator import WorldState
55
 
 
 
 
56
  # ============================================================================
57
  # Constants
58
  # ============================================================================
@@ -67,8 +66,8 @@ DEFAULT_MODEL = "Qwen/Qwen2.5-72B-Instruct"
67
  # distinct seeds per task for cross-episode reproducibility.
68
  TASK_CONFIGS: List[dict] = [
69
  {"task_name": "outbreak_easy", "seed": 0, "max_ticks": 12},
70
- # {"task_name": "outbreak_medium", "seed": 1, "max_ticks": 12},
71
- # {"task_name": "outbreak_hard", "seed": 2, "max_ticks": 12},
72
  ]
73
 
74
  # Score-clamp bounds keep .3f formatting strictly inside (0, 1) so the
@@ -268,7 +267,7 @@ def _make_env_from_spaces(base_url: str) -> Any:
268
 
269
 
270
  # ============================================================================
271
- # Episode loop — delegates to B1FlatAgent.run_episode(step_callback=...)
272
  # ============================================================================
273
 
274
 
@@ -310,6 +309,43 @@ class _SyncEnvAdapter:
310
  return obs
311
 
312
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
  def _run_episode(
314
  env: Any,
315
  llm: LLMClient,
@@ -317,8 +353,9 @@ def _run_episode(
317
  seed: int,
318
  model_name: str,
319
  max_ticks: int,
 
320
  ) -> dict:
321
- """Stream one episode end-to-end via ``B1FlatAgent.run_episode``.
322
 
323
  The agent owns the per-tick LLM-call + parse + env.step loop; this
324
  harness owns the [START] / [STEP] / [END] stdout protocol via a
@@ -351,7 +388,7 @@ def _run_episode(
351
  env,
352
  reset_kwargs={"task_name": task_name, "seed": seed, "max_ticks": max_ticks},
353
  )
354
- agent = B1FlatAgent(env=adapter, llm=llm)
355
 
356
  try:
357
  traj = agent.run_episode(
@@ -407,6 +444,7 @@ def _run_episode(
407
 
408
  def main() -> None:
409
  """Entry point for ``uv run python inference.py`` and the validator."""
 
410
  api_base_url = os.getenv("API_BASE_URL", DEFAULT_API_BASE_URL)
411
  model_name = os.getenv("MODEL_NAME", DEFAULT_MODEL)
412
  hf_token = os.getenv("HF_TOKEN")
@@ -451,6 +489,7 @@ def main() -> None:
451
  seed=cfg["seed"],
452
  model_name=model_name,
453
  max_ticks=cfg["max_ticks"],
 
454
  )
455
  results.append(result)
456
  finally:
 
37
 
38
  from __future__ import annotations
39
 
40
+ import argparse
41
  import os
42
  import sys
43
  from dataclasses import dataclass
44
+ from typing import Any, Dict, List, Literal, Optional
45
+
46
+ from baselines.flat_agent import B1FlatAgent, B1StepEvent
47
+ from cortex.llm_client import LLMClient
 
 
 
 
 
48
  from CrisisWorldCortex.models import OuterActionPayload
49
  from CrisisWorldCortex.server.graders import terminal_bonus
50
  from CrisisWorldCortex.server.simulator import WorldState
51
 
52
+ AgentKind = Literal["b1", "b2", "b3"]
53
+ _AGENT_CHOICES: tuple = ("b1", "b2", "b3")
54
+
55
  # ============================================================================
56
  # Constants
57
  # ============================================================================
 
66
  # distinct seeds per task for cross-episode reproducibility.
67
  TASK_CONFIGS: List[dict] = [
68
  {"task_name": "outbreak_easy", "seed": 0, "max_ticks": 12},
69
+ {"task_name": "outbreak_medium", "seed": 1, "max_ticks": 12},
70
+ {"task_name": "outbreak_hard", "seed": 2, "max_ticks": 12},
71
  ]
72
 
73
  # Score-clamp bounds keep .3f formatting strictly inside (0, 1) so the
 
267
 
268
 
269
  # ============================================================================
270
+ # Episode loop — delegates to the selected agent's run_episode(step_callback=...)
271
  # ============================================================================
272
 
273
 
 
309
  return obs
310
 
311
 
312
+ def _make_agent(kind: str, env: Any, llm: Any) -> Any:
313
+ """Construct the B1/B2/B3 agent for ``kind``.
314
+
315
+ All three agents share the ``(env, llm)`` constructor signature and
316
+ expose ``run_episode(task, seed, max_ticks, *, step_callback)`` per
317
+ Phase A Decision 54. Lazy imports for B2/B3 keep the cold-start cost
318
+ of the default B1 path unchanged.
319
+ """
320
+ if kind == "b1":
321
+ return B1FlatAgent(env=env, llm=llm)
322
+ if kind == "b2":
323
+ from baselines.flat_agent_matched_compute import B2MatchedComputeAgent
324
+
325
+ return B2MatchedComputeAgent(env=env, llm=llm)
326
+ if kind == "b3":
327
+ from baselines.cortex_fixed_router import B3CortexFixedRouter
328
+
329
+ return B3CortexFixedRouter(env=env, llm=llm)
330
+ raise ValueError(f"unknown agent kind: {kind!r}; expected one of {_AGENT_CHOICES}")
331
+
332
+
333
+ def _build_argparser() -> argparse.ArgumentParser:
334
+ """Argparse for inference.py CLI flags. Default --agent=b1 keeps the
335
+ pre-Session-13 invocation working for the existing eval suite."""
336
+ parser = argparse.ArgumentParser(
337
+ prog="inference",
338
+ description="CrisisWorldCortex inference harness (B1/B2/B3 dispatch).",
339
+ )
340
+ parser.add_argument(
341
+ "--agent",
342
+ choices=_AGENT_CHOICES,
343
+ default="b1",
344
+ help="Agent to run: b1 (flat), b2 (matched-compute), b3 (cortex+deterministic-router).",
345
+ )
346
+ return parser
347
+
348
+
349
  def _run_episode(
350
  env: Any,
351
  llm: LLMClient,
 
353
  seed: int,
354
  model_name: str,
355
  max_ticks: int,
356
+ agent_kind: str = "b1",
357
  ) -> dict:
358
+ """Stream one episode end-to-end via ``<Agent>.run_episode``.
359
 
360
  The agent owns the per-tick LLM-call + parse + env.step loop; this
361
  harness owns the [START] / [STEP] / [END] stdout protocol via a
 
388
  env,
389
  reset_kwargs={"task_name": task_name, "seed": seed, "max_ticks": max_ticks},
390
  )
391
+ agent = _make_agent(agent_kind, adapter, llm)
392
 
393
  try:
394
  traj = agent.run_episode(
 
444
 
445
  def main() -> None:
446
  """Entry point for ``uv run python inference.py`` and the validator."""
447
+ args = _build_argparser().parse_args()
448
  api_base_url = os.getenv("API_BASE_URL", DEFAULT_API_BASE_URL)
449
  model_name = os.getenv("MODEL_NAME", DEFAULT_MODEL)
450
  hf_token = os.getenv("HF_TOKEN")
 
489
  seed=cfg["seed"],
490
  model_name=model_name,
491
  max_ticks=cfg["max_ticks"],
492
+ agent_kind=args.agent,
493
  )
494
  results.append(result)
495
  finally:
notebooks/train_b1_grpo.ipynb CHANGED
@@ -2,6 +2,7 @@
2
  "cells": [
3
  {
4
  "cell_type": "markdown",
 
5
  "metadata": {},
6
  "source": [
7
  "# B1 GRPO training on Qwen3-1.7B (Workstream B Phase 3)\n",
@@ -19,6 +20,7 @@
19
  },
20
  {
21
  "cell_type": "markdown",
 
22
  "metadata": {},
23
  "source": [
24
  "## 1. Install dependencies\n",
@@ -29,6 +31,7 @@
29
  {
30
  "cell_type": "code",
31
  "execution_count": null,
 
32
  "metadata": {},
33
  "outputs": [],
34
  "source": [
@@ -41,6 +44,7 @@
41
  },
42
  {
43
  "cell_type": "markdown",
 
44
  "metadata": {},
45
  "source": [
46
  "## 2. Authenticate with Hugging Face\n",
@@ -51,6 +55,7 @@
51
  {
52
  "cell_type": "code",
53
  "execution_count": null,
 
54
  "metadata": {},
55
  "outputs": [],
56
  "source": [
@@ -58,10 +63,12 @@
58
  "\n",
59
  "try:\n",
60
  " from google.colab import userdata\n",
 
61
  " HF_TOKEN = userdata.get(\"HF_TOKEN\")\n",
62
  " os.environ[\"HF_TOKEN\"] = HF_TOKEN\n",
63
  "except Exception:\n",
64
  " from huggingface_hub import login\n",
 
65
  " login()\n",
66
  " HF_TOKEN = os.environ.get(\"HF_TOKEN\", \"\")\n",
67
  "\n",
@@ -71,6 +78,7 @@
71
  },
72
  {
73
  "cell_type": "markdown",
 
74
  "metadata": {},
75
  "source": [
76
  "## 3. Clone CrisisWorldCortex and install\n",
@@ -81,6 +89,7 @@
81
  {
82
  "cell_type": "code",
83
  "execution_count": null,
 
84
  "metadata": {},
85
  "outputs": [],
86
  "source": [
@@ -94,26 +103,30 @@
94
  {
95
  "cell_type": "code",
96
  "execution_count": null,
 
97
  "metadata": {},
98
  "outputs": [],
99
  "source": [
100
  "# Sanity: imports resolve, env client constructs.\n",
101
  "import sys\n",
 
102
  "sys.path.insert(0, \"/content/CrisisWorldCortex\")\n",
103
  "\n",
104
- "from CrisisWorldCortex import CrisisworldcortexAction, CrisisworldcortexObservation\n",
105
- "from CrisisWorldCortex.client import CrisisworldcortexEnv\n",
106
  "from baselines.flat_agent import (\n",
107
  " build_system_prompt,\n",
108
  " parse_action,\n",
109
  " parse_failure_marker,\n",
110
  " serialize_observation,\n",
111
  ")\n",
 
 
 
112
  "print(\"CrisisWorld imports OK\")"
113
  ]
114
  },
115
  {
116
  "cell_type": "markdown",
 
117
  "metadata": {},
118
  "source": [
119
  "## 4. Load Qwen3-1.7B with LoRA via Unsloth\n",
@@ -124,11 +137,12 @@
124
  {
125
  "cell_type": "code",
126
  "execution_count": null,
 
127
  "metadata": {},
128
  "outputs": [],
129
  "source": [
130
- "from unsloth import FastLanguageModel\n",
131
  "import torch\n",
 
132
  "\n",
133
  "MAX_SEQ_LEN = 4096\n",
134
  "MODEL_NAME = \"unsloth/Qwen3-1.7B\"\n",
@@ -145,8 +159,7 @@
145
  "model = FastLanguageModel.get_peft_model(\n",
146
  " model,\n",
147
  " r=32,\n",
148
- " target_modules=[\"q_proj\", \"k_proj\", \"v_proj\", \"o_proj\",\n",
149
- " \"gate_proj\", \"up_proj\", \"down_proj\"],\n",
150
  " lora_alpha=64,\n",
151
  " use_gradient_checkpointing=\"unsloth\",\n",
152
  " random_state=42,\n",
@@ -156,6 +169,7 @@
156
  },
157
  {
158
  "cell_type": "markdown",
 
159
  "metadata": {},
160
  "source": [
161
  "## 5. Connect to the deployed CrisisWorld env\n",
@@ -166,6 +180,7 @@
166
  {
167
  "cell_type": "code",
168
  "execution_count": null,
 
169
  "metadata": {},
170
  "outputs": [],
171
  "source": [
@@ -173,9 +188,11 @@
173
  "TASKS = (\"outbreak_easy\", \"outbreak_medium\", \"outbreak_hard\")\n",
174
  "EPISODE_TICKS = 12\n",
175
  "\n",
 
176
  "def make_env() -> CrisisworldcortexEnv:\n",
177
  " return CrisisworldcortexEnv(base_url=ENV_URL)\n",
178
  "\n",
 
179
  "_test_env = make_env()\n",
180
  "_obs = _test_env.reset(task_name=\"outbreak_easy\", seed=0, max_ticks=EPISODE_TICKS)\n",
181
  "print(f\"Env OK. Initial tick={_obs.tick}, regions={[r.region for r in _obs.regions]}\")"
@@ -183,6 +200,7 @@
183
  },
184
  {
185
  "cell_type": "markdown",
 
186
  "metadata": {},
187
  "source": [
188
  "## 6. Build the prompt dataset and reward function\n",
@@ -197,17 +215,21 @@
197
  {
198
  "cell_type": "code",
199
  "execution_count": null,
 
200
  "metadata": {},
201
  "outputs": [],
202
  "source": [
203
- "from datasets import Dataset\n",
204
  "import random\n",
205
  "\n",
 
 
206
  "SYSTEM_PROMPT = build_system_prompt()\n",
207
  "\n",
 
208
  "def build_user_prompt(obs: CrisisworldcortexObservation) -> str:\n",
209
  " return serialize_observation(obs)\n",
210
  "\n",
 
211
  "def make_chat_prompt(obs: CrisisworldcortexObservation) -> str:\n",
212
  " return tokenizer.apply_chat_template(\n",
213
  " [\n",
@@ -218,6 +240,7 @@
218
  " add_generation_prompt=True,\n",
219
  " )\n",
220
  "\n",
 
221
  "rng = random.Random(0)\n",
222
  "_seed_pool = []\n",
223
  "for task in TASKS:\n",
@@ -233,17 +256,20 @@
233
  " _prompts.append(make_chat_prompt(obs))\n",
234
  " _meta.append(entry)\n",
235
  "\n",
236
- "train_dataset = Dataset.from_dict({\n",
237
- " \"prompt\": _prompts,\n",
238
- " \"task\": [m[\"task\"] for m in _meta],\n",
239
- " \"seed\": [m[\"seed\"] for m in _meta],\n",
240
- "})\n",
 
 
241
  "print(f\"Dataset built: {len(train_dataset)} examples\")"
242
  ]
243
  },
244
  {
245
  "cell_type": "code",
246
  "execution_count": null,
 
247
  "metadata": {},
248
  "outputs": [],
249
  "source": [
@@ -278,6 +304,7 @@
278
  },
279
  {
280
  "cell_type": "markdown",
 
281
  "metadata": {},
282
  "source": [
283
  "## 7. GRPO training\n",
@@ -288,6 +315,7 @@
288
  {
289
  "cell_type": "code",
290
  "execution_count": null,
 
291
  "metadata": {},
292
  "outputs": [],
293
  "source": [
@@ -331,6 +359,7 @@
331
  {
332
  "cell_type": "code",
333
  "execution_count": null,
 
334
  "metadata": {},
335
  "outputs": [],
336
  "source": [
@@ -339,6 +368,7 @@
339
  },
340
  {
341
  "cell_type": "markdown",
 
342
  "metadata": {},
343
  "source": [
344
  "## 8. Save the trained LoRA adapter to HF Hub\n",
@@ -349,6 +379,7 @@
349
  {
350
  "cell_type": "code",
351
  "execution_count": null,
 
352
  "metadata": {},
353
  "outputs": [],
354
  "source": [
@@ -372,6 +403,7 @@
372
  },
373
  {
374
  "cell_type": "markdown",
 
375
  "metadata": {},
376
  "source": [
377
  "## 9. Eval: trained adapter vs base model on 3 tasks\n",
@@ -382,6 +414,7 @@
382
  {
383
  "cell_type": "code",
384
  "execution_count": null,
 
385
  "metadata": {},
386
  "outputs": [],
387
  "source": [
@@ -394,9 +427,13 @@
394
  " inputs = tokenizer_inst(prompt, return_tensors=\"pt\").to(model_inst.device)\n",
395
  " with torch.no_grad():\n",
396
  " out = model_inst.generate(\n",
397
- " **inputs, max_new_tokens=max_new_tokens, do_sample=False, temperature=0.0,\n",
 
 
 
398
  " )\n",
399
- " return tokenizer_inst.decode(out[0][inputs[\"input_ids\"].shape[1]:], skip_special_tokens=True)\n",
 
400
  "\n",
401
  "def run_one_episode(model_inst, tokenizer_inst, task: str, seed: int) -> float:\n",
402
  " env = make_env()\n",
@@ -413,6 +450,7 @@
413
  " break\n",
414
  " return cumulative\n",
415
  "\n",
 
416
  "FastLanguageModel.for_inference(model)\n",
417
  "trained_results = {t: run_one_episode(model, tokenizer, t, seed=0) for t in TASKS}\n",
418
  "print(f\"Trained model cumulative reward per task: {trained_results}\")"
@@ -421,6 +459,7 @@
421
  {
422
  "cell_type": "code",
423
  "execution_count": null,
 
424
  "metadata": {},
425
  "outputs": [],
426
  "source": [
@@ -438,6 +477,7 @@
438
  },
439
  {
440
  "cell_type": "markdown",
 
441
  "metadata": {},
442
  "source": [
443
  "## 10. Plot eval comparison\n",
@@ -448,6 +488,7 @@
448
  {
449
  "cell_type": "code",
450
  "execution_count": null,
 
451
  "metadata": {},
452
  "outputs": [],
453
  "source": [
@@ -462,8 +503,8 @@
462
  "width = 0.35\n",
463
  "\n",
464
  "fig, ax = plt.subplots(figsize=(9, 5))\n",
465
- "ax.bar(x - width/2, base_vals, width, label=\"Base Qwen3-1.7B\")\n",
466
- "ax.bar(x + width/2, trained_vals, width, label=\"GRPO-trained Qwen3-1.7B\")\n",
467
  "ax.set_xticks(x)\n",
468
  "ax.set_xticklabels(task_names)\n",
469
  "ax.set_ylabel(\"Cumulative episode reward\")\n",
@@ -476,6 +517,11 @@
476
  }
477
  ],
478
  "metadata": {
 
 
 
 
 
479
  "kernelspec": {
480
  "display_name": "Python 3",
481
  "language": "python",
@@ -484,11 +530,6 @@
484
  "language_info": {
485
  "name": "python",
486
  "version": "3.10"
487
- },
488
- "accelerator": "GPU",
489
- "colab": {
490
- "gpuType": "T4",
491
- "provenance": []
492
  }
493
  },
494
  "nbformat": 4,
 
2
  "cells": [
3
  {
4
  "cell_type": "markdown",
5
+ "id": "7fb27b941602401d91542211134fc71a",
6
  "metadata": {},
7
  "source": [
8
  "# B1 GRPO training on Qwen3-1.7B (Workstream B Phase 3)\n",
 
20
  },
21
  {
22
  "cell_type": "markdown",
23
+ "id": "acae54e37e7d407bbb7b55eff062a284",
24
  "metadata": {},
25
  "source": [
26
  "## 1. Install dependencies\n",
 
31
  {
32
  "cell_type": "code",
33
  "execution_count": null,
34
+ "id": "9a63283cbaf04dbcab1f6479b197f3a8",
35
  "metadata": {},
36
  "outputs": [],
37
  "source": [
 
44
  },
45
  {
46
  "cell_type": "markdown",
47
+ "id": "8dd0d8092fe74a7c96281538738b07e2",
48
  "metadata": {},
49
  "source": [
50
  "## 2. Authenticate with Hugging Face\n",
 
55
  {
56
  "cell_type": "code",
57
  "execution_count": null,
58
+ "id": "72eea5119410473aa328ad9291626812",
59
  "metadata": {},
60
  "outputs": [],
61
  "source": [
 
63
  "\n",
64
  "try:\n",
65
  " from google.colab import userdata\n",
66
+ "\n",
67
  " HF_TOKEN = userdata.get(\"HF_TOKEN\")\n",
68
  " os.environ[\"HF_TOKEN\"] = HF_TOKEN\n",
69
  "except Exception:\n",
70
  " from huggingface_hub import login\n",
71
+ "\n",
72
  " login()\n",
73
  " HF_TOKEN = os.environ.get(\"HF_TOKEN\", \"\")\n",
74
  "\n",
 
78
  },
79
  {
80
  "cell_type": "markdown",
81
+ "id": "8edb47106e1a46a883d545849b8ab81b",
82
  "metadata": {},
83
  "source": [
84
  "## 3. Clone CrisisWorldCortex and install\n",
 
89
  {
90
  "cell_type": "code",
91
  "execution_count": null,
92
+ "id": "10185d26023b46108eb7d9f57d49d2b3",
93
  "metadata": {},
94
  "outputs": [],
95
  "source": [
 
103
  {
104
  "cell_type": "code",
105
  "execution_count": null,
106
+ "id": "8763a12b2bbd4a93a75aff182afb95dc",
107
  "metadata": {},
108
  "outputs": [],
109
  "source": [
110
  "# Sanity: imports resolve, env client constructs.\n",
111
  "import sys\n",
112
+ "\n",
113
  "sys.path.insert(0, \"/content/CrisisWorldCortex\")\n",
114
  "\n",
 
 
115
  "from baselines.flat_agent import (\n",
116
  " build_system_prompt,\n",
117
  " parse_action,\n",
118
  " parse_failure_marker,\n",
119
  " serialize_observation,\n",
120
  ")\n",
121
+ "from CrisisWorldCortex import CrisisworldcortexAction, CrisisworldcortexObservation\n",
122
+ "from CrisisWorldCortex.client import CrisisworldcortexEnv\n",
123
+ "\n",
124
  "print(\"CrisisWorld imports OK\")"
125
  ]
126
  },
127
  {
128
  "cell_type": "markdown",
129
+ "id": "7623eae2785240b9bd12b16a66d81610",
130
  "metadata": {},
131
  "source": [
132
  "## 4. Load Qwen3-1.7B with LoRA via Unsloth\n",
 
137
  {
138
  "cell_type": "code",
139
  "execution_count": null,
140
+ "id": "7cdc8c89c7104fffa095e18ddfef8986",
141
  "metadata": {},
142
  "outputs": [],
143
  "source": [
 
144
  "import torch\n",
145
+ "from unsloth import FastLanguageModel\n",
146
  "\n",
147
  "MAX_SEQ_LEN = 4096\n",
148
  "MODEL_NAME = \"unsloth/Qwen3-1.7B\"\n",
 
159
  "model = FastLanguageModel.get_peft_model(\n",
160
  " model,\n",
161
  " r=32,\n",
162
+ " target_modules=[\"q_proj\", \"k_proj\", \"v_proj\", \"o_proj\", \"gate_proj\", \"up_proj\", \"down_proj\"],\n",
 
163
  " lora_alpha=64,\n",
164
  " use_gradient_checkpointing=\"unsloth\",\n",
165
  " random_state=42,\n",
 
169
  },
170
  {
171
  "cell_type": "markdown",
172
+ "id": "b118ea5561624da68c537baed56e602f",
173
  "metadata": {},
174
  "source": [
175
  "## 5. Connect to the deployed CrisisWorld env\n",
 
180
  {
181
  "cell_type": "code",
182
  "execution_count": null,
183
+ "id": "938c804e27f84196a10c8828c723f798",
184
  "metadata": {},
185
  "outputs": [],
186
  "source": [
 
188
  "TASKS = (\"outbreak_easy\", \"outbreak_medium\", \"outbreak_hard\")\n",
189
  "EPISODE_TICKS = 12\n",
190
  "\n",
191
+ "\n",
192
  "def make_env() -> CrisisworldcortexEnv:\n",
193
  " return CrisisworldcortexEnv(base_url=ENV_URL)\n",
194
  "\n",
195
+ "\n",
196
  "_test_env = make_env()\n",
197
  "_obs = _test_env.reset(task_name=\"outbreak_easy\", seed=0, max_ticks=EPISODE_TICKS)\n",
198
  "print(f\"Env OK. Initial tick={_obs.tick}, regions={[r.region for r in _obs.regions]}\")"
 
200
  },
201
  {
202
  "cell_type": "markdown",
203
+ "id": "504fb2a444614c0babb325280ed9130a",
204
  "metadata": {},
205
  "source": [
206
  "## 6. Build the prompt dataset and reward function\n",
 
215
  {
216
  "cell_type": "code",
217
  "execution_count": null,
218
+ "id": "59bbdb311c014d738909a11f9e486628",
219
  "metadata": {},
220
  "outputs": [],
221
  "source": [
 
222
  "import random\n",
223
  "\n",
224
+ "from datasets import Dataset\n",
225
+ "\n",
226
  "SYSTEM_PROMPT = build_system_prompt()\n",
227
  "\n",
228
+ "\n",
229
  "def build_user_prompt(obs: CrisisworldcortexObservation) -> str:\n",
230
  " return serialize_observation(obs)\n",
231
  "\n",
232
+ "\n",
233
  "def make_chat_prompt(obs: CrisisworldcortexObservation) -> str:\n",
234
  " return tokenizer.apply_chat_template(\n",
235
  " [\n",
 
240
  " add_generation_prompt=True,\n",
241
  " )\n",
242
  "\n",
243
+ "\n",
244
  "rng = random.Random(0)\n",
245
  "_seed_pool = []\n",
246
  "for task in TASKS:\n",
 
256
  " _prompts.append(make_chat_prompt(obs))\n",
257
  " _meta.append(entry)\n",
258
  "\n",
259
+ "train_dataset = Dataset.from_dict(\n",
260
+ " {\n",
261
+ " \"prompt\": _prompts,\n",
262
+ " \"task\": [m[\"task\"] for m in _meta],\n",
263
+ " \"seed\": [m[\"seed\"] for m in _meta],\n",
264
+ " }\n",
265
+ ")\n",
266
  "print(f\"Dataset built: {len(train_dataset)} examples\")"
267
  ]
268
  },
269
  {
270
  "cell_type": "code",
271
  "execution_count": null,
272
+ "id": "b43b363d81ae4b689946ece5c682cd59",
273
  "metadata": {},
274
  "outputs": [],
275
  "source": [
 
304
  },
305
  {
306
  "cell_type": "markdown",
307
+ "id": "8a65eabff63a45729fe45fb5ade58bdc",
308
  "metadata": {},
309
  "source": [
310
  "## 7. GRPO training\n",
 
315
  {
316
  "cell_type": "code",
317
  "execution_count": null,
318
+ "id": "c3933fab20d04ec698c2621248eb3be0",
319
  "metadata": {},
320
  "outputs": [],
321
  "source": [
 
359
  {
360
  "cell_type": "code",
361
  "execution_count": null,
362
+ "id": "4dd4641cc4064e0191573fe9c69df29b",
363
  "metadata": {},
364
  "outputs": [],
365
  "source": [
 
368
  },
369
  {
370
  "cell_type": "markdown",
371
+ "id": "8309879909854d7188b41380fd92a7c3",
372
  "metadata": {},
373
  "source": [
374
  "## 8. Save the trained LoRA adapter to HF Hub\n",
 
379
  {
380
  "cell_type": "code",
381
  "execution_count": null,
382
+ "id": "3ed186c9a28b402fb0bc4494df01f08d",
383
  "metadata": {},
384
  "outputs": [],
385
  "source": [
 
403
  },
404
  {
405
  "cell_type": "markdown",
406
+ "id": "cb1e1581032b452c9409d6c6813c49d1",
407
  "metadata": {},
408
  "source": [
409
  "## 9. Eval: trained adapter vs base model on 3 tasks\n",
 
414
  {
415
  "cell_type": "code",
416
  "execution_count": null,
417
+ "id": "379cbbc1e968416e875cc15c1202d7eb",
418
  "metadata": {},
419
  "outputs": [],
420
  "source": [
 
427
  " inputs = tokenizer_inst(prompt, return_tensors=\"pt\").to(model_inst.device)\n",
428
  " with torch.no_grad():\n",
429
  " out = model_inst.generate(\n",
430
+ " **inputs,\n",
431
+ " max_new_tokens=max_new_tokens,\n",
432
+ " do_sample=False,\n",
433
+ " temperature=0.0,\n",
434
  " )\n",
435
+ " return tokenizer_inst.decode(out[0][inputs[\"input_ids\"].shape[1] :], skip_special_tokens=True)\n",
436
+ "\n",
437
  "\n",
438
  "def run_one_episode(model_inst, tokenizer_inst, task: str, seed: int) -> float:\n",
439
  " env = make_env()\n",
 
450
  " break\n",
451
  " return cumulative\n",
452
  "\n",
453
+ "\n",
454
  "FastLanguageModel.for_inference(model)\n",
455
  "trained_results = {t: run_one_episode(model, tokenizer, t, seed=0) for t in TASKS}\n",
456
  "print(f\"Trained model cumulative reward per task: {trained_results}\")"
 
459
  {
460
  "cell_type": "code",
461
  "execution_count": null,
462
+ "id": "277c27b1587741f2af2001be3712ef0d",
463
  "metadata": {},
464
  "outputs": [],
465
  "source": [
 
477
  },
478
  {
479
  "cell_type": "markdown",
480
+ "id": "db7b79bc585a40fcaf58bf750017e135",
481
  "metadata": {},
482
  "source": [
483
  "## 10. Plot eval comparison\n",
 
488
  {
489
  "cell_type": "code",
490
  "execution_count": null,
491
+ "id": "916684f9a58a4a2aa5f864670399430d",
492
  "metadata": {},
493
  "outputs": [],
494
  "source": [
 
503
  "width = 0.35\n",
504
  "\n",
505
  "fig, ax = plt.subplots(figsize=(9, 5))\n",
506
+ "ax.bar(x - width / 2, base_vals, width, label=\"Base Qwen3-1.7B\")\n",
507
+ "ax.bar(x + width / 2, trained_vals, width, label=\"GRPO-trained Qwen3-1.7B\")\n",
508
  "ax.set_xticks(x)\n",
509
  "ax.set_xticklabels(task_names)\n",
510
  "ax.set_ylabel(\"Cumulative episode reward\")\n",
 
517
  }
518
  ],
519
  "metadata": {
520
+ "accelerator": "GPU",
521
+ "colab": {
522
+ "gpuType": "T4",
523
+ "provenance": []
524
+ },
525
  "kernelspec": {
526
  "display_name": "Python 3",
527
  "language": "python",
 
530
  "language_info": {
531
  "name": "python",
532
  "version": "3.10"
 
 
 
 
 
533
  }
534
  },
535
  "nbformat": 4,
openenv.yaml CHANGED
@@ -1,7 +1,7 @@
1
- spec_version: 1
2
- name: CrisisWorldCortex
3
- type: space
4
- runtime: fastapi
5
- app: server.app:app
6
- port: 8000
7
-
 
1
+ spec_version: 1
2
+ name: CrisisWorldCortex
3
+ type: space
4
+ runtime: fastapi
5
+ app: server.app:app
6
+ port: 8000
7
+
pyproject.toml CHANGED
@@ -25,7 +25,6 @@ dependencies = [
25
  # future uv sync from silently pulling 3.x. Bump explicitly when 3.0
26
  # ships and we've verified compatibility.
27
  "openai>=2.0,<3.0",
28
- "python-dotenv>=1.0.0"
29
  ]
30
 
31
  [project.optional-dependencies]
 
25
  # future uv sync from silently pulling 3.x. Bump explicitly when 3.0
26
  # ships and we've verified compatibility.
27
  "openai>=2.0,<3.0",
 
28
  ]
29
 
30
  [project.optional-dependencies]
server/Dockerfile CHANGED
@@ -28,7 +28,7 @@
28
  # The build script (openenv build) handles context detection and sets appropriate build args.
29
 
30
  ARG BASE_IMAGE=ghcr.io/meta-pytorch/openenv-base:latest
31
- FROM ${BASE_IMAGE} AS builder
32
 
33
  WORKDIR /app
34
 
@@ -72,8 +72,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
72
  fi
73
 
74
  # Final runtime stage
75
- ARG BASE_IMAGE=ghcr.io/meta-pytorch/openenv-base:latest
76
- FROM ${BASE_IMAGE}
77
 
78
  WORKDIR /app
79
 
 
28
  # The build script (openenv build) handles context detection and sets appropriate build args.
29
 
30
  ARG BASE_IMAGE=ghcr.io/meta-pytorch/openenv-base:latest
31
+ FROM ${BASE_IMAGE:-ghcr.io/meta-pytorch/openenv-base:latest} AS builder
32
 
33
  WORKDIR /app
34
 
 
72
  fi
73
 
74
  # Final runtime stage
75
+ FROM ${BASE_IMAGE:-ghcr.io/meta-pytorch/openenv-base:latest}
 
76
 
77
  WORKDIR /app
78
 
server/__init__.py CHANGED
@@ -1,11 +1,11 @@
1
- # Copyright (c) Meta Platforms, Inc. and affiliates.
2
- # All rights reserved.
3
- #
4
- # This source code is licensed under the BSD-style license found in the
5
- # LICENSE file in the root directory of this source tree.
6
-
7
- """Crisisworldcortex environment server components."""
8
-
9
- from .CrisisWorldCortex_environment import CrisisworldcortexEnvironment
10
-
11
- __all__ = ["CrisisworldcortexEnvironment"]
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Crisisworldcortex environment server components."""
8
+
9
+ from .CrisisWorldCortex_environment import CrisisworldcortexEnvironment
10
+
11
+ __all__ = ["CrisisworldcortexEnvironment"]
server/app.py CHANGED
@@ -29,13 +29,17 @@ Usage:
29
  """
30
 
31
  try:
32
- # from openenv.core.env_server.http_server import create_app
33
  from openenv.core.env_server import create_web_interface_app as create_app
34
  except Exception as e: # pragma: no cover
35
  raise ImportError(
36
  "openenv is required for the web interface. Install dependencies with '\n uv sync\n'"
37
  ) from e
38
 
 
 
 
 
 
39
  # Wire types use canonical ``CrisisWorldCortex.models`` (Session 7d):
40
  # the container's wheel install resolves this to one ``sys.modules`` entry,
41
  # matching the deep server modules (graders/, simulator/) that already use
@@ -57,6 +61,18 @@ app = create_app(
57
  max_concurrent_envs=1, # increase this number to allow more concurrent WebSocket sessions
58
  )
59
 
 
 
 
 
 
 
 
 
 
 
 
 
60
 
61
  def main() -> None:
62
  """
 
29
  """
30
 
31
  try:
 
32
  from openenv.core.env_server import create_web_interface_app as create_app
33
  except Exception as e: # pragma: no cover
34
  raise ImportError(
35
  "openenv is required for the web interface. Install dependencies with '\n uv sync\n'"
36
  ) from e
37
 
38
+ from pathlib import Path
39
+
40
+ from fastapi.responses import FileResponse
41
+ from starlette.staticfiles import StaticFiles
42
+
43
  # Wire types use canonical ``CrisisWorldCortex.models`` (Session 7d):
44
  # the container's wheel install resolves this to one ``sys.modules`` entry,
45
  # matching the deep server modules (graders/, simulator/) that already use
 
61
  max_concurrent_envs=1, # increase this number to allow more concurrent WebSocket sessions
62
  )
63
 
64
+ FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
65
+
66
+ if FRONTEND_DIR.exists():
67
+ app.mount("/cortex/css", StaticFiles(directory=str(FRONTEND_DIR / "css")), name="cortex-css")
68
+ app.mount("/cortex/js", StaticFiles(directory=str(FRONTEND_DIR / "js")), name="cortex-js")
69
+
70
+ @app.get("/cortex", include_in_schema=False)
71
+ @app.get("/cortex/", include_in_schema=False)
72
+ async def serve_cortex_frontend() -> FileResponse:
73
+ """Serve the additive Cortex dashboard without replacing /web."""
74
+ return FileResponse(str(FRONTEND_DIR / "index.html"))
75
+
76
 
77
  def main() -> None:
78
  """
server/requirements.txt CHANGED
@@ -1,6 +1,6 @@
1
- openenv[core]>=0.2.0
2
- fastapi>=0.115.0
3
- uvicorn>=0.24.0
4
-
5
-
6
-
 
1
+ openenv[core]>=0.2.0
2
+ fastapi>=0.115.0
3
+ uvicorn>=0.24.0
4
+
5
+
6
+
tests/test_inference_agent_dispatch.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """inference.py --agent CLI dispatch smoke tests.
2
+
3
+ Per the user's Session 13 follow-up: argparse with ``--agent`` choices
4
+ {b1, b2, b3} (default b1 for backward compat) + dispatch table that
5
+ constructs the corresponding agent class. All three agents expose the
6
+ same ``run_episode(task, seed, max_ticks, *, step_callback)`` surface
7
+ per Phase A Decision 54, so the rest of the inference loop is unchanged.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import pytest
13
+
14
+ import inference
15
+ from baselines.cortex_fixed_router import B3CortexFixedRouter
16
+ from baselines.flat_agent import B1FlatAgent
17
+ from baselines.flat_agent_matched_compute import B2MatchedComputeAgent
18
+ from CrisisWorldCortex.models import (
19
+ CrisisworldcortexAction,
20
+ CrisisworldcortexObservation,
21
+ )
22
+ from tests._helpers.llm_stub import StubLLMClient
23
+
24
+
25
+ class _FakeEnv:
26
+ """Quack-duck env used only to satisfy agent constructors.
27
+
28
+ ``run_episode`` is not called in dispatch tests; we only verify
29
+ the right class is instantiated.
30
+ """
31
+
32
+ def reset(self) -> CrisisworldcortexObservation: # pragma: no cover
33
+ raise NotImplementedError
34
+
35
+ def step(
36
+ self, action: CrisisworldcortexAction
37
+ ) -> CrisisworldcortexObservation: # pragma: no cover
38
+ raise NotImplementedError
39
+
40
+
41
+ # ============================================================================
42
+ # Dispatch table
43
+ # ============================================================================
44
+
45
+
46
+ def test_make_agent_b1_returns_b1_flat_agent() -> None:
47
+ agent = inference._make_agent("b1", _FakeEnv(), StubLLMClient(scripted_responses=[]))
48
+ assert isinstance(agent, B1FlatAgent)
49
+
50
+
51
+ def test_make_agent_b2_returns_b2_matched_compute_agent() -> None:
52
+ agent = inference._make_agent("b2", _FakeEnv(), StubLLMClient(scripted_responses=[]))
53
+ assert isinstance(agent, B2MatchedComputeAgent)
54
+
55
+
56
+ def test_make_agent_b3_returns_b3_cortex_fixed_router() -> None:
57
+ agent = inference._make_agent("b3", _FakeEnv(), StubLLMClient(scripted_responses=[]))
58
+ assert isinstance(agent, B3CortexFixedRouter)
59
+
60
+
61
+ def test_make_agent_invalid_raises_value_error() -> None:
62
+ with pytest.raises(ValueError):
63
+ inference._make_agent("b99", _FakeEnv(), StubLLMClient(scripted_responses=[]))
64
+
65
+
66
+ # ============================================================================
67
+ # Argparse
68
+ # ============================================================================
69
+
70
+
71
+ def test_argparse_default_is_b1_for_backward_compat() -> None:
72
+ parser = inference._build_argparser()
73
+ args = parser.parse_args([])
74
+ assert args.agent == "b1"
75
+
76
+
77
+ def test_argparse_accepts_b1_b2_b3() -> None:
78
+ parser = inference._build_argparser()
79
+ for name in ("b1", "b2", "b3"):
80
+ args = parser.parse_args(["--agent", name])
81
+ assert args.agent == name
82
+
83
+
84
+ def test_argparse_rejects_unknown_agent() -> None:
85
+ parser = inference._build_argparser()
86
+ with pytest.raises(SystemExit):
87
+ parser.parse_args(["--agent", "b99"])
tests/test_training_multi_model_skeleton.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Static checks for training/scripts/train_cortex_multi_model.py.
2
+
3
+ Phase 6 skeleton tests. No GPU / no HF Hub access — these tests verify
4
+ the script's configuration surface, preflight logic, memory budget guard,
5
+ and adapter signatures without loading Unsloth/torch or hitting the
6
+ network.
7
+
8
+ Live multi-model training (~2 hours a100-large, ~$5) is a manual Phase-7
9
+ step gated on user approval, not pytest-driven.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import importlib.util
15
+ from pathlib import Path
16
+ from unittest.mock import MagicMock, patch
17
+
18
+ import pytest
19
+
20
+ SCRIPT_PATH = Path(__file__).parent.parent / "training" / "scripts" / "train_cortex_multi_model.py"
21
+
22
+
23
+ def _load_module():
24
+ """Load the script as a module without running main()."""
25
+ import os
26
+
27
+ os.environ.setdefault("HF_TOKEN", "test_token_static_only")
28
+ os.environ.setdefault("HUB_REPO_ID", "test/multi_model_static_load")
29
+ spec = importlib.util.spec_from_file_location(
30
+ "train_cortex_multi_model_under_test", SCRIPT_PATH
31
+ )
32
+ assert spec is not None and spec.loader is not None
33
+ module = importlib.util.module_from_spec(spec)
34
+ spec.loader.exec_module(module)
35
+ return module
36
+
37
+
38
+ def test_script_exists_and_loads() -> None:
39
+ """File present and importable."""
40
+ assert SCRIPT_PATH.exists(), f"missing {SCRIPT_PATH}"
41
+ mod = _load_module()
42
+ for attr in (
43
+ "main",
44
+ "preflight_model_access",
45
+ "check_memory_budget",
46
+ "_UnslothLLMAdapter",
47
+ "_TrainableRoutingPolicy",
48
+ ):
49
+ assert hasattr(mod, attr), f"missing {attr}"
50
+
51
+
52
+ def test_default_brain_models() -> None:
53
+ """Defaults: Qwen-7B for epi+governance (shared), Llama-8B for logistics."""
54
+ mod = _load_module()
55
+ assert mod.EPI_BRAIN_MODEL.startswith("unsloth/Qwen3-7B-Instruct")
56
+ assert mod.LOGISTICS_BRAIN_MODEL == "meta-llama/Llama-3.1-8B-Instruct"
57
+
58
+
59
+ def test_governance_shares_with_epi_by_default() -> None:
60
+ """M-FR-27: governance brain shares weights with epi by default."""
61
+ mod = _load_module()
62
+ assert mod.GOVERNANCE_BRAIN_MODEL == mod.EPI_BRAIN_MODEL
63
+
64
+
65
+ def test_default_router_is_qwen_1p5b() -> None:
66
+ """M-FR-28: small LLM router."""
67
+ mod = _load_module()
68
+ assert mod.ROUTER_MODEL.startswith("unsloth/Qwen3-1.5B")
69
+
70
+
71
+ def test_router_base_falls_back_to_router_model() -> None:
72
+ """Phase-5e style fallback for SFT-warmstarted router checkpoints."""
73
+ mod = _load_module()
74
+ assert mod.ROUTER_BASE_MODEL == mod.ROUTER_MODEL
75
+
76
+
77
+ def test_default_lora_rank_is_16_for_router() -> None:
78
+ """M-FR-28: router rank 16 (smaller than B1's 32 because 1.5B model)."""
79
+ mod = _load_module()
80
+ assert mod.LORA_RANK == 16
81
+
82
+
83
+ def test_default_gpu_mem_util_is_0p5() -> None:
84
+ """Phase-A M-FR-10: tighter than B1's 0.6 to leave room for frozen brains."""
85
+ mod = _load_module()
86
+ assert abs(mod.GPU_MEM_UTIL - 0.5) < 1e-9
87
+
88
+
89
+ def test_default_brain_call_timeout() -> None:
90
+ """Phase-A M-FR-9: 30s timeout per brain call."""
91
+ mod = _load_module()
92
+ assert mod.BRAIN_CALL_TIMEOUT_S == 30
93
+
94
+
95
+ def test_check_memory_budget_aborts_on_low_free_gpu() -> None:
96
+ """Memory guard raises RuntimeError when < MIN_FREE_GPU_GB free."""
97
+ mod = _load_module()
98
+ fake_torch = MagicMock()
99
+ fake_torch.cuda.is_available.return_value = True
100
+ # Return (free_bytes, total_bytes) with only 10 GB free.
101
+ fake_torch.cuda.mem_get_info.return_value = (10 * 1024**3, 80 * 1024**3)
102
+ with patch.dict("sys.modules", {"torch": fake_torch}):
103
+ with pytest.raises(RuntimeError, match="Insufficient GPU memory"):
104
+ mod.check_memory_budget()
105
+
106
+
107
+ def test_check_memory_budget_passes_when_ample_free() -> None:
108
+ """Memory guard does not raise when 60+ GB free."""
109
+ mod = _load_module()
110
+ fake_torch = MagicMock()
111
+ fake_torch.cuda.is_available.return_value = True
112
+ fake_torch.cuda.mem_get_info.return_value = (60 * 1024**3, 80 * 1024**3)
113
+ with patch.dict("sys.modules", {"torch": fake_torch}):
114
+ mod.check_memory_budget() # should not raise
115
+
116
+
117
+ def test_check_memory_budget_skipped_when_no_cuda() -> None:
118
+ """Memory guard skips gracefully when CUDA isn't available (DRY_RUN path)."""
119
+ mod = _load_module()
120
+ fake_torch = MagicMock()
121
+ fake_torch.cuda.is_available.return_value = False
122
+ with patch.dict("sys.modules", {"torch": fake_torch}):
123
+ mod.check_memory_budget() # should not raise
124
+
125
+
126
+ def test_required_env_vars_raise_systemexit_when_missing() -> None:
127
+ """HF_TOKEN and HUB_REPO_ID required."""
128
+ import os
129
+ import subprocess
130
+ import sys
131
+
132
+ env = {k: v for k, v in os.environ.items() if k not in ("HF_TOKEN", "HUB_REPO_ID")}
133
+ env["PYTHONPATH"] = str(SCRIPT_PATH.parent.parent.parent)
134
+ result = subprocess.run(
135
+ [sys.executable, str(SCRIPT_PATH)],
136
+ env=env,
137
+ capture_output=True,
138
+ text=True,
139
+ timeout=30,
140
+ )
141
+ assert result.returncode != 0
142
+ out = result.stdout + result.stderr
143
+ assert "HF_TOKEN" in out or "HUB_REPO_ID" in out
144
+
145
+
146
+ def test_unsloth_adapter_signature() -> None:
147
+ """_UnslothLLMAdapter implements _LLMClientLike (chat + tokens_used_for)."""
148
+ mod = _load_module()
149
+ AdapterCls = mod._UnslothLLMAdapter
150
+ fake_model = MagicMock()
151
+ fake_tokenizer = MagicMock()
152
+ adapter = AdapterCls(fake_model, fake_tokenizer, brain_label="test")
153
+ assert callable(adapter.chat)
154
+ assert callable(adapter.tokens_used_for)
155
+ assert adapter.tokens_used_for("never_called") == 0
156
+
157
+
158
+ def test_trainable_routing_policy_signature() -> None:
159
+ """_TrainableRoutingPolicy has the forward() shape required by Council."""
160
+ mod = _load_module()
161
+ PolicyCls = mod._TrainableRoutingPolicy
162
+ fake_model = MagicMock()
163
+ fake_tokenizer = MagicMock()
164
+ policy = PolicyCls(fake_model, fake_tokenizer)
165
+ assert callable(policy.forward)
166
+ assert (
167
+ "system_prompt" in PolicyCls.SYSTEM_PROMPT.lower()
168
+ or "router" in PolicyCls.SYSTEM_PROMPT.lower()
169
+ )
training/scripts/train_cortex_multi_model.py ADDED
@@ -0,0 +1,500 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Multi-model Cortex GRPO training (Workstream B Phase 6).
2
+
3
+ Trains the Cortex router LM (Qwen3-1.5B-Instruct + LoRA) via GRPO
4
+ against episode reward, while three frozen brain LLMs (Qwen-7B epi,
5
+ Llama-8B logistics, Qwen-7B governance via shared weights) drive the
6
+ deliberation rollouts.
7
+
8
+ Per Phase-A docs/CORTEX_ARCHITECTURE.md and root CLAUDE.md "Frozen"
9
+ section: this is the multi-model training surface. Each Brain instance
10
+ holds its own LLMClient pointing to a different model — Session 11's
11
+ ``Brain(__init__(llm_client))`` is multi-model-ready by design (audited
12
+ M-FR-12).
13
+
14
+ Memory budget on a100-large (80GB):
15
+ - Qwen3-7B 4-bit (epi): ~14 GB
16
+ - Llama-3.1-8B 4-bit (logistics): ~16 GB
17
+ - Qwen3-7B 4-bit (governance, shared): ~ 0 GB additional
18
+ - Qwen3-1.5B 4-bit + LoRA (router): ~ 3 GB
19
+ - vLLM rollout overhead (router only): ~10 GB
20
+ - Total used: ~43 GB, ~37 GB headroom.
21
+
22
+ Per Phase-A M-FR-31: only the trainable router uses vLLM; frozen brains
23
+ use plain ``transformers.generate`` to avoid 4-way vLLM contention.
24
+
25
+ Trainer.train() is commented out below — the live run is user-gated
26
+ (~2 hours a100-large, ~$5). Skeleton ships with the full integration
27
+ graph; uncomment the train() line after a 5-step dry-run smoke-tests
28
+ the orchestration end-to-end.
29
+
30
+ Usage on HF Jobs:
31
+ hf jobs run --hardware a100-large --secret HF_TOKEN \\
32
+ --env HUB_REPO_ID=Angshuman28/crisisworld-cortex-router-llm \\
33
+ ghcr.io/astral-sh/uv:latest \\
34
+ bash -c "git clone https://huggingface.co/spaces/Angshuman28/CrisisWorldCortex /app && \\
35
+ cd /app && uv sync && uv run python training/scripts/train_cortex_multi_model.py"
36
+
37
+ Local DRY_RUN test (no GPU):
38
+ DRY_RUN=1 HUB_REPO_ID=local/test \\
39
+ uv run python training/scripts/train_cortex_multi_model.py
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import os
45
+ import sys
46
+ import time
47
+ from typing import Any, List, Optional
48
+
49
+
50
+ def _env(name: str, default: Optional[str] = None, *, required: bool = False) -> str:
51
+ value = os.environ.get(name, default)
52
+ if required and not value:
53
+ raise SystemExit(f"[FATAL] env var {name} is required but unset")
54
+ return value or ""
55
+
56
+
57
+ # ============================================================================
58
+ # Configuration (env-var driven)
59
+ # ============================================================================
60
+
61
+ HF_TOKEN = _env("HF_TOKEN", required=True)
62
+
63
+ # Brain model choices. Default: Qwen-7B for epi+governance (shared), Llama-8B
64
+ # for logistics. Each can be overridden to point at SFT-warmstarted checkpoints.
65
+ EPI_BRAIN_MODEL = _env("EPI_BRAIN_MODEL", "unsloth/Qwen3-7B-Instruct-bnb-4bit")
66
+ LOGISTICS_BRAIN_MODEL = _env("LOGISTICS_BRAIN_MODEL", "meta-llama/Llama-3.1-8B-Instruct")
67
+ GOVERNANCE_BRAIN_MODEL = _env("GOVERNANCE_BRAIN_MODEL", EPI_BRAIN_MODEL) # M-FR-27 default
68
+
69
+ # Router model (the only trainable surface).
70
+ ROUTER_MODEL = _env("ROUTER_MODEL", "unsloth/Qwen3-1.5B-Instruct-bnb-4bit")
71
+ ROUTER_BASE_MODEL = _env("ROUTER_BASE_MODEL", ROUTER_MODEL) # Phase-5e style fallback
72
+
73
+ HUB_REPO_ID = _env("HUB_REPO_ID", required=True)
74
+ ENV_URL = _env("ENV_URL", "https://angshuman28-crisisworldcortex.hf.space")
75
+ OUTPUT_DIR = _env("OUTPUT_DIR", "/tmp/cortex_router_grpo_lora")
76
+
77
+ MAX_TRAIN_STEPS = int(_env("MAX_TRAIN_STEPS", "300"))
78
+ GROUP_SIZE = int(_env("GROUP_SIZE", "4"))
79
+ BRAIN_CALL_TIMEOUT_S = int(_env("BRAIN_CALL_TIMEOUT_S", "30")) # Phase-A M-FR-9
80
+ LR = float(_env("LR", "5e-6"))
81
+ LORA_RANK = int(_env("LORA_RANK", "16")) # M-FR-28: smaller for 1.5B
82
+ GPU_MEM_UTIL = float(_env("GPU_MEM_UTIL", "0.5")) # Phase-A M-FR-10
83
+ MAX_PROMPT_LEN = int(_env("MAX_PROMPT_LEN", "512"))
84
+ MAX_COMPLETION_LEN = int(_env("MAX_COMPLETION_LEN", "256")) # Phase-A M-FR-11
85
+ TASKS_CSV = _env("TASKS_CSV", "outbreak_easy,outbreak_medium,outbreak_hard")
86
+ EPISODE_TICKS = int(_env("EPISODE_TICKS", "12"))
87
+ SEED = int(_env("SEED", "42"))
88
+ DRY_RUN = _env("DRY_RUN", "0") not in ("0", "", "false", "False")
89
+
90
+ # Memory budget hard floor: abort if less than this many GB free at script
91
+ # start. Conservative — the steady-state is ~43 GB used; this leaves a 30-GB
92
+ # margin for first-call activation peaks before the first vLLM kv-cache lock.
93
+ MIN_FREE_GPU_GB = float(_env("MIN_FREE_GPU_GB", "30"))
94
+
95
+
96
+ def log(*args: object) -> None:
97
+ print("[cortex-multi-model]", *args, flush=True)
98
+
99
+
100
+ # ============================================================================
101
+ # Pre-flight
102
+ # ============================================================================
103
+
104
+
105
+ def preflight_model_access(model_name: str, token: str) -> None:
106
+ """Same fail-loud check as Phase-5b/5d. Llama-3.1-8B may be gated."""
107
+ log(f"preflight: checking model access {model_name}")
108
+ from huggingface_hub import HfApi
109
+ from huggingface_hub.utils import GatedRepoError, RepositoryNotFoundError
110
+
111
+ try:
112
+ info = HfApi().model_info(model_name, token=token)
113
+ if getattr(info, "gated", False) and not getattr(info, "private", False):
114
+ log(f"preflight: {model_name} is gated; access verified")
115
+ except GatedRepoError as exc:
116
+ raise SystemExit(
117
+ f"[FATAL] {model_name} is gated and HF_TOKEN lacks access. "
118
+ f"Visit https://huggingface.co/{model_name} and accept the license. "
119
+ f"Original: {exc}"
120
+ ) from exc
121
+ except RepositoryNotFoundError as exc:
122
+ raise SystemExit(f"[FATAL] {model_name} not found on HF Hub: {exc}") from exc
123
+ log(f"preflight: {model_name} accessible")
124
+
125
+
126
+ def check_memory_budget() -> None:
127
+ """Pre-flight torch.cuda.mem_get_info() check.
128
+
129
+ Hard floor: MIN_FREE_GPU_GB free. If less, abort before the first
130
+ model-load OOM crash mid-loading.
131
+ """
132
+ try:
133
+ import torch
134
+ except ImportError:
135
+ log("WARN torch not importable — skipping memory check")
136
+ return
137
+ if not torch.cuda.is_available():
138
+ log("WARN CUDA not available — skipping memory check (DRY_RUN expected)")
139
+ return
140
+ free, total = torch.cuda.mem_get_info()
141
+ free_gb = free / (1024**3)
142
+ total_gb = total / (1024**3)
143
+ log(f"GPU memory: {free_gb:.1f} GB free / {total_gb:.1f} GB total")
144
+ if free_gb < MIN_FREE_GPU_GB:
145
+ raise RuntimeError(
146
+ f"Insufficient GPU memory: {free_gb:.1f} GB free, need >= {MIN_FREE_GPU_GB} GB. "
147
+ f"Reduce LORA_RANK / GPU_MEM_UTIL or pick a smaller GOVERNANCE_BRAIN_MODEL "
148
+ f"(default shares with EPI). Aborting before model load."
149
+ )
150
+
151
+
152
+ # ============================================================================
153
+ # Adapters: Unsloth-loaded model -> cortex._LLMClientLike
154
+ # ============================================================================
155
+
156
+
157
+ class _UnslothLLMAdapter:
158
+ """Wrap an Unsloth-loaded (model, tokenizer) pair as a ``_LLMClientLike``.
159
+
160
+ Matches ``cortex.subagents._base._LLMClientLike`` protocol so it can
161
+ drop into ``Brain.__init__(llm_client=...)``. Frozen brains call this
162
+ via plain ``transformers.generate`` (M-FR-31 — no vLLM for brains).
163
+
164
+ Token accounting (``tokens_used_for``) returns 0 in this MVP; the
165
+ GRPO reward signal is the env's ``obs.reward``, not the budget
166
+ composition. If we later add token-budget-shaped reward, plug
167
+ ``training.reward_shaping.shape_reward`` into the rollout.
168
+ """
169
+
170
+ def __init__(self, model: Any, tokenizer: Any, *, brain_label: str) -> None:
171
+ self._model = model
172
+ self._tokenizer = tokenizer
173
+ self._brain_label = brain_label
174
+ self._call_counts: dict[str, int] = {}
175
+
176
+ def chat(
177
+ self,
178
+ caller_id: str,
179
+ messages: List[Any],
180
+ max_tokens: Optional[int] = None,
181
+ temperature: Optional[float] = None,
182
+ ) -> Any:
183
+ from cortex.llm_client import ChatResponse
184
+
185
+ prompt = self._tokenizer.apply_chat_template(
186
+ [{"role": m.role, "content": m.content} for m in messages],
187
+ tokenize=False,
188
+ add_generation_prompt=True,
189
+ )
190
+ import torch
191
+
192
+ inputs = self._tokenizer(prompt, return_tensors="pt").to(self._model.device)
193
+ gen_kwargs: dict[str, Any] = {
194
+ "max_new_tokens": max_tokens or 256,
195
+ "do_sample": (temperature or 0.0) > 0,
196
+ }
197
+ if (temperature or 0.0) > 0:
198
+ gen_kwargs["temperature"] = temperature
199
+ with torch.no_grad():
200
+ out = self._model.generate(**inputs, **gen_kwargs)
201
+ prompt_tokens = inputs["input_ids"].shape[1]
202
+ completion_tokens = out.shape[1] - prompt_tokens
203
+ text = self._tokenizer.decode(out[0][prompt_tokens:], skip_special_tokens=True)
204
+ self._call_counts[caller_id] = self._call_counts.get(caller_id, 0) + 1
205
+ # cortex.llm_client.ChatResponse signature: text + tokens_in + tokens_out.
206
+ return ChatResponse(text=text, tokens_in=prompt_tokens, tokens_out=completion_tokens)
207
+
208
+ def tokens_used_for(self, caller_id: str) -> int:
209
+ return self._call_counts.get(caller_id, 0)
210
+
211
+
212
+ # ============================================================================
213
+ # Adapter: trainable Unsloth router -> cortex.RoutingPolicy
214
+ # ============================================================================
215
+
216
+
217
+ class _TrainableRoutingPolicy:
218
+ """Wrap the Unsloth-loaded router LM as a ``cortex.RoutingPolicy``.
219
+
220
+ Input: ``MetacognitionState``. Output: ``RoutingAction``.
221
+
222
+ The router emits structured JSON per the system prompt. On parse
223
+ failure, returns ``stop_and_no_op`` per Phase-A M-FR-5 (close the
224
+ tick gracefully; the negative reward gradient teaches the router
225
+ to emit valid JSON).
226
+ """
227
+
228
+ SYSTEM_PROMPT = (
229
+ "You are the Cortex router. You receive a metacognition state summary and emit "
230
+ "ONE routing action as JSON. Allowed kinds: call_subagent (brain + subagent), "
231
+ "request_challenge (challenger_brain + target_brain), switch_phase (new_phase), "
232
+ "preserve_dissent (tag), emit_outer_action (action), stop_and_no_op. Hard caps: "
233
+ "<=2 rounds/tick, <=1 cross-brain challenge/tick, <=1 critic per brain/tick, "
234
+ "<=6000 tokens/tick. Output exactly one JSON object — no prose, no fences."
235
+ )
236
+
237
+ def __init__(self, model: Any, tokenizer: Any) -> None:
238
+ self._model = model
239
+ self._tokenizer = tokenizer
240
+
241
+ @staticmethod
242
+ def _state_to_prompt(state: Any) -> str:
243
+ return (
244
+ f"tick={getattr(state, 'tick', 0)} round={getattr(state, 'round', 1)} "
245
+ f"phase={getattr(state, 'phase', 'divergence')}\n"
246
+ f"agreement={getattr(state, 'inter_brain_agreement', 0.0):.2f} "
247
+ f"avg_conf={getattr(state, 'average_confidence', 0.0):.2f} "
248
+ f"evidence={getattr(state, 'average_evidence_support', 0.0):.2f}\n"
249
+ f"novelty={getattr(state, 'novelty_yield_last_round', 0.0):.2f} "
250
+ f"collapse={getattr(state, 'collapse_suspicion', 0.0):.2f} "
251
+ f"budget_frac={getattr(state, 'budget_remaining_frac', 1.0):.2f} "
252
+ f"urgency={getattr(state, 'urgency', 0.0):.2f}\n"
253
+ f"preserved_dissent={getattr(state, 'preserved_dissent_count', 0)} "
254
+ f"challenge_used={bool(getattr(state, 'challenge_used_this_tick', 0))}\n"
255
+ f"Choose the next routing action."
256
+ )
257
+
258
+ def forward(self, state: Any) -> Any:
259
+ import json
260
+
261
+ import torch
262
+ from pydantic import TypeAdapter, ValidationError
263
+
264
+ from cortex.schemas import RoutingAction, StopAndNoOp
265
+
266
+ prompt = self._tokenizer.apply_chat_template(
267
+ [
268
+ {"role": "system", "content": self.SYSTEM_PROMPT},
269
+ {"role": "user", "content": self._state_to_prompt(state)},
270
+ ],
271
+ tokenize=False,
272
+ add_generation_prompt=True,
273
+ )
274
+ inputs = self._tokenizer(prompt, return_tensors="pt").to(self._model.device)
275
+ with torch.no_grad():
276
+ out = self._model.generate(
277
+ **inputs, max_new_tokens=MAX_COMPLETION_LEN, do_sample=False, temperature=0.0
278
+ )
279
+ text = self._tokenizer.decode(
280
+ out[0][inputs["input_ids"].shape[1] :], skip_special_tokens=True
281
+ ).strip()
282
+ try:
283
+ data = json.loads(text)
284
+ return TypeAdapter(RoutingAction).validate_python(data)
285
+ except (json.JSONDecodeError, ValidationError):
286
+ # Phase-A M-FR-5 graceful fallback.
287
+ return StopAndNoOp()
288
+
289
+
290
+ # ============================================================================
291
+ # Main
292
+ # ============================================================================
293
+
294
+
295
+ def main() -> int:
296
+ log(f"EPI_BRAIN_MODEL={EPI_BRAIN_MODEL}")
297
+ log(f"LOGISTICS_BRAIN_MODEL={LOGISTICS_BRAIN_MODEL}")
298
+ log(f"GOVERNANCE_BRAIN_MODEL={GOVERNANCE_BRAIN_MODEL}")
299
+ log(f"ROUTER_MODEL={ROUTER_MODEL} ROUTER_BASE_MODEL={ROUTER_BASE_MODEL}")
300
+ log(f"HUB_REPO_ID={HUB_REPO_ID} ENV_URL={ENV_URL}")
301
+ log(f"MAX_TRAIN_STEPS={MAX_TRAIN_STEPS} GROUP_SIZE={GROUP_SIZE} LR={LR}")
302
+ log(f"LORA_RANK={LORA_RANK} GPU_MEM_UTIL={GPU_MEM_UTIL}")
303
+
304
+ preflight_model_access(EPI_BRAIN_MODEL, HF_TOKEN)
305
+ preflight_model_access(LOGISTICS_BRAIN_MODEL, HF_TOKEN)
306
+ if GOVERNANCE_BRAIN_MODEL != EPI_BRAIN_MODEL:
307
+ preflight_model_access(GOVERNANCE_BRAIN_MODEL, HF_TOKEN)
308
+ preflight_model_access(ROUTER_BASE_MODEL, HF_TOKEN)
309
+
310
+ if DRY_RUN:
311
+ log("DRY_RUN=1 — preflight only; not loading models or training")
312
+ return 0
313
+
314
+ check_memory_budget()
315
+
316
+ # Lazy imports — keeps DRY_RUN fast and avoids loading torch/Unsloth on
317
+ # local machines that don't have GPU.
318
+ from datasets import Dataset
319
+ from trl import GRPOConfig, GRPOTrainer
320
+ from unsloth import FastLanguageModel
321
+
322
+ from cortex.brains import EpiBrain, GovernanceBrain, LogisticsBrain
323
+ from cortex.council import Council
324
+ from CrisisWorldCortex.client import CrisisworldcortexEnv
325
+
326
+ # Phase 7 will import baselines.cortex_fixed_router inline at the warmup-data
327
+ # step. Cannot import here at module scope: training/* MUST NOT import
328
+ # baselines/* per the import-graph rule (enforced by tests/test_import_graph.py).
329
+
330
+ # ---- Load 3 frozen brain LLMs (M-FR-31: no vLLM for brains) ----
331
+ def _load_frozen(model_name: str, label: str) -> tuple[Any, Any]:
332
+ log(f"loading frozen {label} brain: {model_name}")
333
+ m, t = FastLanguageModel.from_pretrained(
334
+ model_name=model_name,
335
+ max_seq_length=4096,
336
+ load_in_4bit=True,
337
+ fast_inference=False, # plain transformers.generate per M-FR-31
338
+ gpu_memory_utilization=GPU_MEM_UTIL,
339
+ )
340
+ FastLanguageModel.for_inference(m)
341
+ return m, t
342
+
343
+ epi_model, epi_tok = _load_frozen(EPI_BRAIN_MODEL, "epi")
344
+ logistics_model, logistics_tok = _load_frozen(LOGISTICS_BRAIN_MODEL, "logistics")
345
+ if GOVERNANCE_BRAIN_MODEL == EPI_BRAIN_MODEL:
346
+ log("governance: sharing weights with epi (M-FR-27)")
347
+ governance_model, governance_tok = epi_model, epi_tok
348
+ else:
349
+ governance_model, governance_tok = _load_frozen(GOVERNANCE_BRAIN_MODEL, "governance")
350
+
351
+ # Build per-brain LLM client adapters.
352
+ epi_client = _UnslothLLMAdapter(epi_model, epi_tok, brain_label="epi")
353
+ logistics_client = _UnslothLLMAdapter(logistics_model, logistics_tok, brain_label="logistics")
354
+ governance_client = _UnslothLLMAdapter(
355
+ governance_model, governance_tok, brain_label="governance"
356
+ )
357
+
358
+ # Construct the 3 brains via Session-11 factory functions.
359
+ brains = {
360
+ "epidemiology": EpiBrain(llm_client=epi_client),
361
+ "logistics": LogisticsBrain(llm_client=logistics_client),
362
+ "governance": GovernanceBrain(llm_client=governance_client),
363
+ }
364
+ log(f"brains constructed: {list(brains.keys())}")
365
+
366
+ # ---- Load the trainable router LLM (M-FR-31: only this one uses vLLM) ----
367
+ log(f"loading trainable router: {ROUTER_BASE_MODEL} (LoRA r={LORA_RANK})")
368
+ router_model, router_tok = FastLanguageModel.from_pretrained(
369
+ model_name=ROUTER_BASE_MODEL,
370
+ max_seq_length=MAX_PROMPT_LEN + MAX_COMPLETION_LEN,
371
+ load_in_4bit=True,
372
+ fast_inference=True,
373
+ max_lora_rank=LORA_RANK,
374
+ gpu_memory_utilization=GPU_MEM_UTIL,
375
+ )
376
+ router_model = FastLanguageModel.get_peft_model(
377
+ router_model,
378
+ r=LORA_RANK,
379
+ target_modules=[
380
+ "q_proj",
381
+ "k_proj",
382
+ "v_proj",
383
+ "o_proj",
384
+ "gate_proj",
385
+ "up_proj",
386
+ "down_proj",
387
+ ],
388
+ lora_alpha=LORA_RANK * 2,
389
+ use_gradient_checkpointing="unsloth",
390
+ random_state=SEED,
391
+ )
392
+ router_policy = _TrainableRoutingPolicy(router_model, router_tok)
393
+ log("trainable router ready")
394
+
395
+ # ---- Build prompt dataset from B3 deterministic-router trajectories ----
396
+ # Phase 7 will populate this from real B3 rollouts; the skeleton emits
397
+ # one placeholder row so GRPOTrainer construction succeeds.
398
+ tasks = tuple(t.strip() for t in TASKS_CSV.split(",") if t.strip())
399
+ log(f"tasks={tasks}")
400
+
401
+ def make_env() -> CrisisworldcortexEnv:
402
+ return CrisisworldcortexEnv(base_url=ENV_URL)
403
+
404
+ train_dataset = Dataset.from_dict(
405
+ {
406
+ "prompt": ["placeholder until live B3 corpus collection"],
407
+ "task": ["outbreak_easy"],
408
+ "seed": [0],
409
+ }
410
+ )
411
+
412
+ # ---- Reward function: full-episode rollout per (prompt, completion) ----
413
+ def cortex_reward(
414
+ prompts: list[str],
415
+ completions: list[str],
416
+ task: list[str],
417
+ seed: list[int],
418
+ **_kwargs: object,
419
+ ) -> list[float]:
420
+ rewards: list[float] = []
421
+ for _completion, t, s in zip(completions, task, seed):
422
+ try:
423
+ council = Council(brains=brains, routing_policy=router_policy)
424
+ env = make_env()
425
+ obs = env.reset(task_name=t, seed=int(s), max_ticks=EPISODE_TICKS)
426
+ cumulative = 0.0
427
+ last_reward = 0.0
428
+ for _ in range(EPISODE_TICKS):
429
+ action = council.step(obs, last_reward=last_reward)
430
+ result = env.step(action)
431
+ next_obs = result.observation if hasattr(result, "observation") else result
432
+ last_reward = next_obs.reward if next_obs.reward is not None else 0.0
433
+ cumulative += last_reward
434
+ obs = next_obs
435
+ if next_obs.done:
436
+ break
437
+ rewards.append(float(cumulative))
438
+ except Exception as exc:
439
+ log(f"WARN rollout failed task={t} seed={s}: {exc}")
440
+ rewards.append(-1.0)
441
+ return rewards
442
+
443
+ # ---- GRPO config + trainer ----
444
+ log("constructing GRPOTrainer (router-only)")
445
+ training_args = GRPOConfig(
446
+ output_dir=OUTPUT_DIR,
447
+ learning_rate=LR,
448
+ per_device_train_batch_size=GROUP_SIZE,
449
+ gradient_accumulation_steps=1,
450
+ num_generations=GROUP_SIZE,
451
+ max_prompt_length=MAX_PROMPT_LEN,
452
+ max_completion_length=MAX_COMPLETION_LEN,
453
+ max_steps=MAX_TRAIN_STEPS,
454
+ save_steps=max(MAX_TRAIN_STEPS // 3, 1),
455
+ logging_steps=max(MAX_TRAIN_STEPS // 60, 1),
456
+ report_to="none",
457
+ bf16=True,
458
+ optim="adamw_8bit",
459
+ temperature=0.8,
460
+ use_vllm=True,
461
+ vllm_mode="colocate",
462
+ seed=SEED,
463
+ )
464
+ trainer = GRPOTrainer(
465
+ model=router_model, # M-FR-23: router is the ONLY trainable surface
466
+ processing_class=router_tok,
467
+ reward_funcs=[cortex_reward],
468
+ args=training_args,
469
+ train_dataset=train_dataset,
470
+ )
471
+ log(f"trainer constructed: {type(trainer).__name__}")
472
+ log("# Phase 6 ships skeleton — uncomment trainer.train() in Phase 7 after")
473
+ log("# (a) B3 corpus has populated train_dataset, and (b) a 5-step dry-run")
474
+ log("# verifies multi-model orchestration end-to-end (~$0.50).")
475
+ # trainer.train() # Phase 7 unblock: uncomment after dry-run verification.
476
+
477
+ log(f"saving router LoRA to {OUTPUT_DIR}")
478
+ router_model.save_pretrained(OUTPUT_DIR)
479
+ router_tok.save_pretrained(OUTPUT_DIR)
480
+
481
+ log(f"pushing to https://huggingface.co/{HUB_REPO_ID}")
482
+ from huggingface_hub import HfApi
483
+
484
+ api = HfApi()
485
+ api.create_repo(HUB_REPO_ID, exist_ok=True, repo_type="model", private=False, token=HF_TOKEN)
486
+ api.upload_folder(
487
+ folder_path=OUTPUT_DIR,
488
+ repo_id=HUB_REPO_ID,
489
+ repo_type="model",
490
+ token=HF_TOKEN,
491
+ )
492
+ log("push complete")
493
+ return 0
494
+
495
+
496
+ if __name__ == "__main__":
497
+ t0 = time.time()
498
+ rc = main()
499
+ log(f"done in {time.time() - t0:.1f}s")
500
+ sys.exit(rc)
uv.lock CHANGED
@@ -1650,7 +1650,6 @@ source = { editable = "." }
1650
  dependencies = [
1651
  { name = "openai" },
1652
  { name = "openenv-core", extra = ["core"] },
1653
- { name = "python-dotenv" },
1654
  ]
1655
 
1656
  [package.optional-dependencies]
@@ -1668,7 +1667,6 @@ requires-dist = [
1668
  { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.0.0" },
1669
  { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
1670
  { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" },
1671
- { name = "python-dotenv", specifier = ">=1.0.0" },
1672
  { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" },
1673
  ]
1674
  provides-extras = ["dev"]
 
1650
  dependencies = [
1651
  { name = "openai" },
1652
  { name = "openenv-core", extra = ["core"] },
 
1653
  ]
1654
 
1655
  [package.optional-dependencies]
 
1667
  { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.0.0" },
1668
  { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
1669
  { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" },
 
1670
  { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" },
1671
  ]
1672
  provides-extras = ["dev"]