Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- Dockerfile +2 -3
- __init__.py +16 -16
- frontend/css/styles.css +603 -0
- frontend/index.html +15 -0
- frontend/js/actions.js +118 -0
- frontend/js/api.js +45 -0
- frontend/js/app.js +613 -0
- frontend/js/council-narration.js +253 -0
- frontend/js/replay.js +54 -0
- frontend/js/samples.js +99 -0
- frontend/js/state.js +65 -0
- inference.py +53 -14
- notebooks/train_b1_grpo.ipynb +61 -20
- openenv.yaml +7 -7
- pyproject.toml +0 -1
- server/Dockerfile +2 -3
- server/__init__.py +11 -11
- server/app.py +17 -1
- server/requirements.txt +6 -6
- tests/test_inference_agent_dispatch.py +87 -0
- tests/test_training_multi_model_skeleton.py +169 -0
- training/scripts/train_cortex_multi_model.py +500 -0
- uv.lock +0 -2
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 |
-
|
| 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 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 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 |
-
|
| 71 |
-
|
| 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
|
| 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 ``
|
| 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 =
|
| 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(
|
| 237 |
-
" \
|
| 238 |
-
"
|
| 239 |
-
"
|
| 240 |
-
"
|
|
|
|
|
|
|
| 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,
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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"]
|