srinjoyd commited on
Commit
7039fde
·
1 Parent(s): 1eed1a8

update files

Browse files
README.md CHANGED
@@ -7,168 +7,293 @@ sdk: docker
7
  app_port: 8000
8
  pinned: false
9
  ---
10
- # Important Links
11
- 1) [BLOG.md](https://huggingface.co/spaces/Meta-HF-hackathon/updated-policy/blob/main/BLOG.md)
12
- 2) Logger (Used HF Jobs, attaching logs and code used to run them)
13
- 3) [Environment link](https://huggingface.co/spaces/Meta-HF-hackathon/updated-policy/)
14
- # 🚨 SRE Incident Response Simulator
15
 
16
- An OpenEnv environment where AI agents must diagnose and remediate production incidents across a simulated microservices architecture.
17
 
18
- ## Why This Environment Matters
19
 
20
- This is a **POMDP** (Partially Observable Markov Decision Process). The agent never sees the root cause — it sees _symptoms_: climbing memory metrics, cascading error logs, firing alerts. It must gather evidence, form hypotheses, and act — exactly like a real SRE at 3 AM.
21
 
22
- | Dimension | Detail |
23
- |-----------|--------|
24
- | **Observation** | Alerts, metric timeseries, structured logs, dependency graphs, deploy history |
25
- | **Action space** | 10 hierarchical action types × 7 target services = rich combinatorics |
26
- | **Difficulty** | Easy (single-service leak) Medium (cascading failure) → Hard (distributed deadlock) |
27
- | **Reward** | Oracle-shaped per-step signal for training + oracle-independent grader for evaluation |
28
- | **Realism** | Reactive simulation — memory climbs over time, cascades propagate, restarts don't fix root causes |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
  ---
31
 
32
- ## Architecture
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
  ```
35
- ┌──────────────────────────────────────────────────────────────────
36
- SIMULATED INFRASTRUCTURE
37
- │ │
38
- │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
39
- │ API GW │────►│ Auth │────►│ Orders │────►│ Payment │ │
40
- └────┬────┘ └─────────┘ └────┬────┘ └────┬────┘
41
- │ │ │ │ │
42
- │ ▼ ▼ ▼ │
43
- │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
44
- │ │ Cache │ │ DB │ │ Queue │ │
45
- │ └─────────┘ └─────────┘ └─────────┘ │
46
- └──────────────────────────────────────────────────────────────────┘
47
  ```
48
 
49
- 7 services with reactive metrics, logs, alerts, and dependency-aware cascade propagation.
50
-
51
  ---
52
 
53
- ## Action Space (Hierarchical)
54
 
55
- ### Level 1: Action Type
56
 
57
  | Action | Category | Description |
58
- |--------|----------|------------|
59
- | `view_alerts` | Diagnostic | See all firing alerts |
60
- | `query_logs` | Diagnostic | Query service logs (with level/keyword filters) |
61
- | `check_metrics` | Diagnostic | Get 30-minute metric timeseries |
62
- | `check_dependencies` | Diagnostic | View upstream/downstream dependency map |
63
- | `check_deploy_history` | Diagnostic | Recent deploys for a service |
64
- | `run_health_check` | Diagnostic | Ping a service for status |
65
- | `restart_service` | Remediation | Restart (fixes symptoms temporarily, not root cause) |
66
- | `rollback_deploy` | Remediation | Rollback to previous deploy version |
67
- | `scale_service` | Remediation | Scale replicas up/down |
68
- | `declare_root_cause` | Terminal | Submit diagnosis — ends episode |
69
-
70
- ### Level 2: Target Service + Parameters
71
- Targeted actions require `target_service` from: `api_gateway`, `auth`, `orders`, `payment`, `cache`, `database`, `queue`.
72
-
73
- ### Action Masking
74
- The observation includes `valid_actions[]` — illegal actions (e.g., rollback on a service with no deploy history) are rejected with a penalty.
 
 
 
 
 
 
 
 
 
75
 
76
  ---
77
 
78
- ## Observation Space (POMDP)
79
 
80
- The agent **never** sees: `fault_type`, `is_bad` deploy flag, or internal simulation state.
81
 
82
  It **does** see:
83
- - **Incident summary** and severity
84
- - **Service statuses** (healthy/degraded/down)
85
- - **Active alert count**
86
- - **Action result** (data from the last action: logs, metrics, alerts, etc.)
87
- - **Valid actions** (action mask)
88
- - **Time elapsed / budget** (SLA pressure)
89
- - **Cumulative reward** and step count
 
 
90
 
91
  ---
92
 
93
- ## Tasks
94
 
95
- | Task | Description | Difficulty | Root Cause |
96
- |------|-------------|-----------|------------|
97
- | `memory_leak` | Orders service OOM from bad deploy | Easy | Rollback orders deploy v2.3.1 |
98
- | `cascading_failure` | Auth config change cascading to API GW + orders | Medium | Rollback auth deploy, restart dependents |
99
- | `distributed_deadlock` | Payment retry change creates circular wait | Hard | Rollback payment, scale queue, restart orders |
 
 
 
 
 
 
 
 
 
100
 
101
  ---
102
 
103
- ## Reward Design (Two-Layer)
104
-
105
- ### Layer 1: Per-Step Training Rewards (Oracle-Shaped)
106
- These rewards peek at hidden state to guide RL training:
107
-
108
- | Action Category | Condition | Reward |
109
- |----------------|-----------|--------|
110
- | Diagnostic | Investigating involved service | +0.15 |
111
- | Diagnostic | Investigating uninvolved service | +0.05 |
112
- | Any | Repeating a previous action | -0.05 |
113
- | Remediation | Correct target (root cause service) | +0.30 |
114
- | Remediation | Helpful (affected, not root cause) | +0.10 |
115
- | Remediation | Harmful (healthy service) | -0.15 |
116
- | Declaration | Correct root cause | +0.40 |
117
- | Declaration | Wrong root cause | -0.20 |
118
- | Any | Per-step efficiency penalty | -0.02 |
119
- | Completion | All services healthy | +0.20 |
120
- | Completion | Time budget exceeded | -0.10 |
121
-
122
- ### Layer 2: Evaluation Grader (Oracle-Independent)
123
- The grader scores only the trajectory — no hidden state access:
124
-
125
- | Criterion | Weight | What it measures |
126
- |-----------|--------|-----------------|
127
- | Root cause accuracy | 40% | Did the agent declare the correct root cause? |
128
- | Remediation quality | 30% | Did the agent take the right fix actions? |
129
- | Diagnostic efficiency | 20% | Fewer steps to diagnosis = better |
130
- | Service restoration | 10% | Are all services healthy at episode end? |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
 
132
  ---
133
 
134
- ## Quick Start
135
 
136
- ### Local Development
137
 
138
  ```bash
139
- # Install dependencies
140
- cd incident_env
141
  pip install -e .
 
 
142
 
143
- # Start server
144
- uvicorn incident_env.server.app:app --host 0.0.0.0 --port 8000
145
-
146
- # Test endpoints
147
  curl http://localhost:8000/health
148
- curl -X POST http://localhost:8000/reset -H "Content-Type: application/json" -d '{"task_name": "memory_leak"}'
149
- curl -X POST http://localhost:8000/step -H "Content-Type: application/json" -d '{"action_type": "view_alerts"}'
 
 
 
 
150
  ```
151
 
152
- ### Run Inference
153
 
154
  ```bash
155
- export OPENAI_API_KEY=sk-...
156
- export MODEL_NAME=gpt-4o-mini
157
  export ENV_BASE_URL=http://localhost:8000
 
 
 
 
158
 
159
- python inference.py
 
 
 
 
160
  ```
161
 
162
  ### Docker
163
 
164
  ```bash
165
- docker build -t incident-env -f server/Dockerfile .
166
  docker run -p 8000:8000 incident-env
167
  ```
168
 
169
  ---
170
 
171
- ## Example Agent Interaction
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
 
173
  ```
174
  Agent: POST /reset {"task_name": "memory_leak"}
@@ -176,21 +301,33 @@ Agent: POST /reset {"task_name": "memory_leak"}
176
  → Services: orders=degraded, rest=healthy
177
 
178
  Agent: POST /step {"action_type": "view_alerts"}
179
- → 3 alerts: orders HighMemoryUsage (critical), orders HighErrorRate, orders HighLatencyP99
180
  → reward = +0.13
181
 
182
  Agent: POST /step {"action_type": "check_metrics", "target_service": "orders"}
183
- → 30 data points: memory climbing from 35% → 78% over 20 minutes
184
  → reward = +0.13
185
 
186
  Agent: POST /step {"action_type": "check_deploy_history", "target_service": "orders"}
187
- 2 deploys: v2.3.1 (20 min ago, "batch order processing") and v1.2.0
188
  → reward = +0.13
189
 
190
  Agent: POST /step {"action_type": "rollback_deploy", "target_service": "orders"}
191
- → "Rolled back orders from v2.3.1 to v1.2.0 — service recovering"
192
  → reward = +0.28
193
 
194
- Agent: POST /step {"action_type": "declare_root_cause", "parameters": {"root_cause": "memory leak in orders caused by bad deploy v2.3.1"}}
 
195
  → Episode done. Final grade: 0.97
196
  ```
 
 
 
 
 
 
 
 
 
 
 
 
7
  app_port: 8000
8
  pinned: false
9
  ---
 
 
 
 
 
10
 
11
+ # 🚨 SRE Triage Bot OpenEnv Incident Response Simulator
12
 
13
+ > An OpenEnv environment + a four-stage GRPO pipeline that turns **Qwen2.5-7B-Instruct** into a working SRE triage agent. Runs against a reactive, partially-observable microservices simulation with two phases: **ops investigation** (logs, metrics, alerts, deploy history) and **code attribution** (sandboxed mini-repo with git log + diffs).
14
 
15
+ ---
16
 
17
+ ## 🔗 Important Links
18
+
19
+ | Resource | Link |
20
+ | --- | --- |
21
+ | 📝 **Blog post (full write-up)** | [`BLOG.md`](https://huggingface.co/spaces/Meta-HF-hackathon/updated-policy/blob/main/BLOG.md) |
22
+ | 🛰️ **Live environment (HF Space)** | [Meta-HF-hackathon/updated-policy](https://huggingface.co/spaces/Meta-HF-hackathon/updated-policy/) |
23
+ | 🧠 **Merged model (deployable)** | [`Yaswanth-Bolla/qwen-merged`](https://huggingface.co/Yaswanth-Bolla/qwen-merged) |
24
+ | 🧩 **LoRA adapter (post-GRPO)** | [`daemongg/qwen2.5-7b-sre-grpo`](https://huggingface.co/daemongg/qwen2.5-7b-sre-grpo) |
25
+ | 🏗️ **Base model** | [`Qwen/Qwen2.5-7B-Instruct`](https://huggingface.co/Qwen/Qwen2.5-7B-Instruct) |
26
+ | 📒 **Training logs + scripts** | [`./logger/`](./logger/) |
27
+ | 📊 **Ablation results** | [`./ablation.md`](./ablation.md) |
28
+
29
+ > ⚠️ **Note on training infrastructure.** We ran the full pipeline (SFT, GRPO, merge) on **HuggingFace Jobs** (A100-40GB) instead of a Colab notebook — Colab's free + Pro tiers OOM'd on the 7B base + reference model + GRPO group buffers. The **complete training logs and the exact scripts we executed** are committed under [`./logger/`](./logger/) (`sft_finetune.log`, `grpo_finetune.log`, `merge.log`, `trajectory.log`, `ablation.log`, plus the `.py` scripts that produced them) so the run is reproducible end-to-end.
30
+
31
+ ---
32
+
33
+ ## 🎯 What this submission delivers
34
+
35
+ - A novel **two-phase POMDP** environment with hierarchical, masked actions (10 ops actions + 7 code actions).
36
+ - A **two-layer reward** — dense oracle-shaped per-step signal for training, oracle-independent grader for evaluation.
37
+ - A **counterfactual cross-phase reward** (`r_cross`) that makes joint training meaningful.
38
+ - A four-pool **curriculum** (A → B → C, with held-out D) executed via on-policy **GRPO** with a variance gate and `r_cross` warmup.
39
+ - **Real measured improvement**: mean cumulative reward **≈1.59 (RL) vs ≈0.49 (base)** at less than half the steps. See `BLOG.md` §7 and `ablation.md`.
40
 
41
  ---
42
 
43
+ ## 📐 Environment at a glance
44
+
45
+ A **Partially-Observable Markov Decision Process** over a reactive microservices simulator. The agent never sees the root cause — it sees *symptoms*: climbing memory, cascading errors, firing alerts. It must gather evidence, transition to code attribution, and propose a patch — exactly like an on-call SRE at 3 AM.
46
+
47
+ | Dimension | Detail |
48
+ |---|---|
49
+ | **Observation** | Alerts · metric timeseries · structured logs · dependency graphs · deploy history · sandboxed repo tree + git log |
50
+ | **Action space** | Phase 1: 10 ops actions × 7 services. Phase 2: 5 code-exploration + 2 terminal actions. |
51
+ | **Difficulty** | Easy (single-service leak) → Medium (cascade) → Hard (distributed deadlock) → 5 research tasks → 2 held-out compounds |
52
+ | **Reward** | Oracle-shaped per-step signal for training + oracle-independent grader for eval + counterfactual `r_cross` |
53
+ | **Realism** | Reactive simulation — memory climbs, cascades propagate, restarts don't fix root causes |
54
+
55
+ ### Topology
56
 
57
  ```
58
+ ┌─────────┐ ┌─────┐ ┌────────┐ ┌─────────┐
59
+ API GW ──►│Auth │──► │ Orders │──►│ Payment │
60
+ └────┬────┘ └─────┘ └───┬────┘ └────┬────┘
61
+ ▼ ▼ ▼
62
+ ─────────┐ ┌─────────┐ ┌─────────┐
63
+ Cache │ │ DB │ Queue │
64
+ └─────────┘ └─────────┘ └─────────┘
 
 
 
 
 
65
  ```
66
 
 
 
67
  ---
68
 
69
+ ## 🔧 Action space (hierarchical + masked)
70
 
71
+ ### Phase 1 ops investigation
72
 
73
  | Action | Category | Description |
74
+ |---|---|---|
75
+ | `view_alerts` | diagnostic | List firing alerts |
76
+ | `query_logs` | diagnostic | Service logs (level/keyword filters) |
77
+ | `check_metrics` | diagnostic | 30-min metric time series |
78
+ | `check_dependencies` | diagnostic | Up/downstream dependency map |
79
+ | `check_deploy_history` | diagnostic | Recent deploys per service |
80
+ | `run_health_check` | diagnostic | Ping a service |
81
+ | `restart_service` | remediation | Temporary fix |
82
+ | `rollback_deploy` | remediation | Real fix if root cause |
83
+ | `scale_service` | remediation | More replicas |
84
+ | `declare_root_cause` | terminal | Diagnosis string |
85
+ | `transition_to_phase2` | control | Hand off to code attribution |
86
+
87
+ ### Phase 2 code attribution
88
+
89
+ | Action | What it returns |
90
+ |---|---|
91
+ | `list_dir` | Files + subdirs at relative path |
92
+ | `read_file` | Up to 64 KB of file contents |
93
+ | `search_code` | grep across the tree (≤50 hits) |
94
+ | `get_git_log` | Commit metadata for a path |
95
+ | `get_file_diff` | Unified diff for `(commit_sha, path)` |
96
+ | `propose_patch` | Terminal — submit a unified diff |
97
+ | `declare_no_change` | Terminal — for spurious-issue scenarios |
98
+
99
+ > **Action masking:** every observation includes `valid_actions[]`. Illegal actions (e.g. rollback on a service with no deploy history) cost `-0.05` and are recorded for analysis.
100
 
101
  ---
102
 
103
+ ## 👁️ Observation space (POMDP)
104
 
105
+ The agent **never** sees: `fault_type`, `is_bad` deploy flag, internal simulation state.
106
 
107
  It **does** see:
108
+
109
+ - Incident summary + severity (`SEV1` / `SEV2` / `SEV3`)
110
+ - Service statuses (`healthy` / `degraded` / `down`)
111
+ - Active alert count
112
+ - Action result (data from the most recent action)
113
+ - `valid_actions[]` (action mask)
114
+ - Time elapsed / budget (SLA pressure)
115
+ - Cumulative reward and step count
116
+ - `current_phase` ∈ {1, 2}
117
 
118
  ---
119
 
120
+ ## 📋 Tasks (10 scenarios, 4 pools)
121
 
122
+ | Task | Difficulty | Hidden lesson |
123
+ |---|---|---|
124
+ | `memory_leak` | easy | Single service, noisy metric restart only buys minutes |
125
+ | `cascading_failure` | medium | Loud services aren't the cause walk the dep graph |
126
+ | `distributed_deadlock` | hard | Three remediation actions in a specific order |
127
+ | `aliased_fault` | research | Symptoms alias across fault families |
128
+ | `severity_inversion` | research | SEV1 page, two-line code fix |
129
+ | `confidence_inversion` | research | Loud alerts on the wrong service |
130
+ | `info_ordering` | research | Decisive evidence shows up *late* |
131
+ | `circuit_breaker_noop` | research | Spurious issue — `declare_no_change` is correct |
132
+ | `heldout_aliased_severity` | held-out | Compound; never seen during training |
133
+ | `heldout_confidence_ordering` | held-out | Compound; never seen during training |
134
+
135
+ Pools: **A** (`p1_only`), **B** (`p2_only` with oracle handoff), **C** (`joint` with `r_cross`), **D** (held-out generalisation).
136
 
137
  ---
138
 
139
+ ## 🎁 Reward design (two layers)
140
+
141
+ ### Layer 1 — per-step shaped reward (training only)
142
+
143
+ | Action | Condition | Reward |
144
+ |---|---|---|
145
+ | Diagnostic | involved service | +0.15 |
146
+ | Diagnostic | uninvolved service | +0.05 |
147
+ | Any | repeat | 0.05 |
148
+ | Remediation | correct target (root cause svc) | +0.30 |
149
+ | Remediation | helpful (affected, not root) | +0.10 |
150
+ | Remediation | harmful (healthy svc) | 0.15 |
151
+ | Declaration | correct root cause | +0.40 |
152
+ | Declaration | wrong root cause | 0.20 |
153
+ | Any | per-step efficiency cost | 0.02 |
154
+ | Completion | all services healthy | +0.20 |
155
+ | Completion | budget exceeded | 0.10 |
156
+
157
+ ### Layer 2 — oracle-independent grader (evaluation)
158
+
159
+ | Component | Weight | Measures |
160
+ |---|---|---|
161
+ | `p1_rca` | 25 % | Did the agent declare the correct root cause? |
162
+ | `p1_efficiency` | 15 % | Fewer steps to declare = better |
163
+ | `patch_quality` | 35 % | File overlap (Jaccard) + AST hunk similarity + syntax validity |
164
+ | `no_change_detection` | 25 % | Correct `declare_no_change` on spurious-issue scenarios |
165
+ | `p2_efficiency` | 25 % | Phase-2 step efficiency (replaces `no_change` slot when valid issue) |
166
+
167
+ Plus the counterfactual cross-phase reward:
168
+
169
+ ```
170
+ r_cross(τ) = max(0, r_code(τ_2 | context(τ_1)) − r_code(τ_2 | ∅))
171
+ ```
172
+
173
+ ---
174
+
175
+ ## 📈 Headline result
176
+
177
+ | Model | Mean cumulative reward (≈30 steps) | Steps to plateau | σ at plateau |
178
+ |---|---|---|---|
179
+ | Base (Qwen2.5-7B-Instruct) | ~0.20 | never within 60 | wide |
180
+ | SFT (LoRA) | ~0.95 | ~50 | medium |
181
+ | **Post-trained (GRPO + merge)** | **~1.59** | **~25** | **tight** |
182
+
183
+ Full plots, ablations, and component breakdown in [`BLOG.md`](./BLOG.md) §7–8.
184
 
185
  ---
186
 
187
+ ## 🚀 Quick start
188
 
189
+ ### Run the environment locally
190
 
191
  ```bash
 
 
192
  pip install -e .
193
+ uvicorn server.app:app --host 0.0.0.0 --port 8000
194
+ ```
195
 
196
+ ```bash
 
 
 
197
  curl http://localhost:8000/health
198
+ curl -X POST http://localhost:8000/reset \
199
+ -H "Content-Type: application/json" \
200
+ -d '{"task_name": "memory_leak"}'
201
+ curl -X POST http://localhost:8000/step \
202
+ -H "Content-Type: application/json" \
203
+ -d '{"action_type": "view_alerts"}'
204
  ```
205
 
206
+ ### Run the trained agent
207
 
208
  ```bash
 
 
209
  export ENV_BASE_URL=http://localhost:8000
210
+ python inference.py --model Yaswanth-Bolla/qwen-merged
211
+ ```
212
+
213
+ ### Run the agent against a real GitHub issue + repo
214
 
215
+ ```bash
216
+ python inference_agent.py \
217
+ --model Yaswanth-Bolla/qwen-merged \
218
+ --repo /path/to/cloned/repo \
219
+ --issue https://github.com/owner/repo/issues/42
220
  ```
221
 
222
  ### Docker
223
 
224
  ```bash
225
+ docker build -t incident-env .
226
  docker run -p 8000:8000 incident-env
227
  ```
228
 
229
  ---
230
 
231
+ ## 🏋️ Reproducing the training run
232
+
233
+ We ran every stage on **HuggingFace Jobs** (A100-40GB) — see [`./logger/`](./logger/) for the exact scripts and their full stdout.
234
+
235
+ ```bash
236
+ # Stage 1 — collect baseline trajectories (HF Inference API)
237
+ python sre_finetune_collector.py # → sre_*_dataset.jsonl
238
+
239
+ # Stage 2 — LoRA SFT via TRL
240
+ python sft.py \
241
+ --model_name_or_path Qwen/Qwen2.5-7B-Instruct \
242
+ --dataset_name <your-sft-dataset> \
243
+ --use_peft --lora_r 32 --lora_alpha 16 \
244
+ --learning_rate 2e-4 --num_train_epochs 1 \
245
+ --packing --eos_token '<|im_end|>' \
246
+ --output_dir Qwen2.5-7B-SRE-SFT --push_to_hub
247
+
248
+ # Stage 3+4 — online GRPO (Pool A → B → C)
249
+ python training/grpo_train.py \
250
+ --model <your-sft-checkpoint> \
251
+ --stages 2 3 4 \
252
+ --group_size 4 --episodes_per_task 64 \
253
+ --use_lora --lora_r 16 --lora_alpha 32 \
254
+ --push_to_hub daemongg/qwen2.5-7b-sre-grpo
255
+
256
+ # Stage 5 — merge LoRA into base
257
+ python merge.py
258
+ ```
259
+
260
+ Logs from these exact runs:
261
+
262
+ | Stage | Log |
263
+ |---|---|
264
+ | Trajectory collection | [`logger/trajectory.log`](./logger/trajectory.log) |
265
+ | SFT | [`logger/sft_finetune.log`](./logger/sft_finetune.log) |
266
+ | GRPO | [`logger/grpo_finetune.log`](./logger/grpo_finetune.log) |
267
+ | Merge | [`logger/merge.log`](./logger/merge.log) |
268
+ | Ablations | [`logger/ablation.log`](./logger/ablation.log) |
269
+
270
+ ---
271
+
272
+ ## 🗂️ Repository layout
273
+
274
+ ```
275
+ .
276
+ ├── BLOG.md # Full write-up (start here)
277
+ ├── README.md # This file
278
+ ├── ablation.md # Ablation results table
279
+ ├── openenv.yaml # OpenEnv spec
280
+ ├── server/ # FastAPI server + IncidentEnvironment + CodeWorkspace
281
+ ├── scenarios/ # 10 scenarios, code-context registry, P2 grader
282
+ ├── simulation/ # Reactive infra: services, metrics, logs, alerts
283
+ ├── snapshots/ # 8 mini-repo snapshots for Phase 2 (tree + git log + diffs)
284
+ ├── training/ # GRPO trainer, curriculum, variance gate, segment-GRPO loss
285
+ ├── sft.py # TRL SFTTrainer entry point
286
+ ├── merge.py # peft.merge_and_unload + push_to_hub
287
+ ├── inference.py # Run any LLM against the env
288
+ ├── inference_agent.py # Run the trained agent against a real repo + GitHub issue
289
+ ├── sre_finetune_collector.py # Stage-1 trajectory collector
290
+ ├── assets/ # Diagrams + result figures (referenced from BLOG.md)
291
+ └── logger/ # ★ Full HF Jobs logs + the scripts that produced them
292
+ ```
293
+
294
+ ---
295
+
296
+ ## 💬 Example interaction
297
 
298
  ```
299
  Agent: POST /reset {"task_name": "memory_leak"}
 
301
  → Services: orders=degraded, rest=healthy
302
 
303
  Agent: POST /step {"action_type": "view_alerts"}
304
+ → 3 alerts: orders HighMemoryUsage (critical), HighErrorRate, HighLatencyP99
305
  → reward = +0.13
306
 
307
  Agent: POST /step {"action_type": "check_metrics", "target_service": "orders"}
308
+ → 30 data points: memory climbing 35 % → 78 % over 20 min
309
  → reward = +0.13
310
 
311
  Agent: POST /step {"action_type": "check_deploy_history", "target_service": "orders"}
312
+ → v2.3.1 (20 min ago, "batch order processing") · v1.2.0
313
  → reward = +0.13
314
 
315
  Agent: POST /step {"action_type": "rollback_deploy", "target_service": "orders"}
316
+ → "Rolled back orders v2.3.1 v1.2.0 — service recovering"
317
  → reward = +0.28
318
 
319
+ Agent: POST /step {"action_type": "declare_root_cause",
320
+ "parameters": {"root_cause": "memory leak in orders caused by bad deploy v2.3.1"}}
321
  → Episode done. Final grade: 0.97
322
  ```
323
+
324
+ ---
325
+
326
+ ## 📜 License & credits
327
+
328
+ - Environment, training scripts, scenarios: this repo.
329
+ - Base model: `Qwen/Qwen2.5-7B-Instruct` (Apache-2.0).
330
+ - Trainer: HuggingFace TRL (`SFTTrainer`) and our on-policy GRPO loop in `training/grpo_train.py`.
331
+ - Built for the **OpenEnv hackathon** — see [`RULES.md`](./RULES.md).
332
+
333
+ For the full story, results, and ablations, read [`BLOG.md`](./BLOG.md).
jobs/Dockerfile ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Docker image for HF Jobs post-training of incident_env.
2
+ #
3
+ # Runs three processes inside one container:
4
+ # 1. uvicorn incident_env server (:8000)
5
+ # 2. vLLM OpenAI-compatible server (:8080)
6
+ # 3. verl-agent trainer (foreground)
7
+ #
8
+ # Built on the official NVIDIA PyTorch + CUDA base so vLLM works.
9
+ # Push to a registry (e.g. ghcr.io or Docker Hub) or build as an HF Space
10
+ # image; HF Jobs accepts both.
11
+
12
+ FROM pytorch/pytorch:2.6.0-cuda12.4-cudnn9-devel
13
+
14
+ ENV DEBIAN_FRONTEND=noninteractive \
15
+ PYTHONUNBUFFERED=1 \
16
+ PIP_DISABLE_PIP_VERSION_CHECK=1 \
17
+ PIP_NO_CACHE_DIR=1 \
18
+ HF_HUB_ENABLE_HF_TRANSFER=1
19
+
20
+ RUN apt-get update && apt-get install -y --no-install-recommends \
21
+ git curl ca-certificates build-essential \
22
+ && rm -rf /var/lib/apt/lists/*
23
+
24
+ WORKDIR /workspace
25
+
26
+ # ----- incident_env (this repo) -------------------------------------------
27
+ # The job entrypoint script pulls the latest commit at runtime, but we
28
+ # bake a copy in so initial startup is fast.
29
+ ARG INCIDENT_REPO=https://github.com/your-org/scaler-hackathon.git
30
+ ARG INCIDENT_REF=main
31
+ RUN git clone --depth 1 --branch "${INCIDENT_REF}" "${INCIDENT_REPO}" /workspace/incident_env || \
32
+ (mkdir -p /workspace/incident_env && echo "Clone failed; entrypoint will retry")
33
+
34
+ # ----- verl-agent ---------------------------------------------------------
35
+ ARG VERL_REPO=https://github.com/langfengQ/verl-agent.git
36
+ ARG VERL_REF=main
37
+ RUN git clone --depth 1 --branch "${VERL_REF}" "${VERL_REPO}" /workspace/verl-agent || \
38
+ (mkdir -p /workspace/verl-agent && echo "Clone failed; entrypoint will retry")
39
+
40
+ # ----- Python deps --------------------------------------------------------
41
+ # Install inside the PyTorch base image; keep versions pinned to what
42
+ # verl-agent + vLLM 0.6.x expect.
43
+ RUN pip install --upgrade pip && \
44
+ pip install \
45
+ "vllm==0.6.3.post1" \
46
+ "transformers>=4.45" \
47
+ "accelerate>=1.0" \
48
+ "peft>=0.13" \
49
+ "trl>=0.12" \
50
+ "datasets>=3.0" \
51
+ "fastapi>=0.104" "uvicorn[standard]>=0.24" "pydantic>=2" \
52
+ "ray[default]>=2.37" \
53
+ "wandb" "huggingface_hub[hf_transfer]" "httpx" "requests" \
54
+ "openenv-core>=0.1.0" || true
55
+
56
+ # Install both repos in editable mode where possible.
57
+ RUN pip install -e /workspace/incident_env || true && \
58
+ pip install -e /workspace/verl-agent || true
59
+
60
+ COPY run_in_job.sh /usr/local/bin/run_in_job.sh
61
+ COPY eval_in_job.sh /usr/local/bin/eval_in_job.sh
62
+ RUN chmod +x /usr/local/bin/run_in_job.sh /usr/local/bin/eval_in_job.sh
63
+
64
+ ENV INCIDENT_REPO_DIR=/workspace/incident_env \
65
+ VERL_AGENT_REPO_DIR=/workspace/verl-agent \
66
+ INCIDENT_REWARD_MODE=long_horizon_v2 \
67
+ ENV_PORT=8000 \
68
+ VLLM_PORT=8080
69
+
70
+ CMD ["/usr/local/bin/run_in_job.sh"]
jobs/README.md ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Post-training on HF Jobs (GPU)
2
+
3
+ Run verl-agent GiGPO post-training for `incident_env` on a Hugging Face
4
+ Jobs GPU (single A100-80GB recommended). Docs:
5
+ https://huggingface.co/docs/huggingface_hub/guides/jobs
6
+
7
+ ## Hardware & scope
8
+
9
+ HF Jobs gives you **one container** on **one GPU** (no Ray multi-node,
10
+ no multi-GPU tensor parallelism). Practical model sizes on the biggest
11
+ flavor `a100-large` (A100-80GB):
12
+
13
+ | Model | LoRA rank | Fits? | Notes |
14
+ |---|---|---|---|
15
+ | Qwen2.5-7B-Instruct | 16 | yes | **default** — `configs/incident_env_gigpo_7b_a100.yaml` |
16
+ | Qwen2.5-14B-Instruct | 32 | tight | needs `gpu_memory_utilization=0.35`, `offload=true`; may OOM with vLLM + FSDP in one box |
17
+
18
+ For Qwen2.5-14B use the Northflank H100 path (`training/entrypoint.sh`).
19
+
20
+ Flavors (from HF docs): `t4-small`, `t4-medium`, `l4x1`, `l4x4`,
21
+ `a10g-small`, `a10g-large`, `a10g-largex2`, `a10g-largex4`,
22
+ `a100-large`.
23
+
24
+ HF Jobs is gated to **Pro / Team / Enterprise** accounts.
25
+
26
+ ## Files
27
+
28
+ | File | Role |
29
+ |---|---|
30
+ | `Dockerfile` | Builds the training/eval image (CUDA + PyTorch + vLLM + this repo + verl-agent) |
31
+ | `run_in_job.sh` | Training entrypoint: boots env-server + vLLM + verl-agent trainer |
32
+ | `eval_in_job.sh` | Eval entrypoint: boots env-server + vLLM (with LoRA) + `scripts/eval_incident_env.py` |
33
+ | `launch_hf_job.py` | CLI → `huggingface_hub.run_job` (training) |
34
+ | `schedule_eval_job.py` | CLI → `create_scheduled_job` (recurring eval) |
35
+ | `../configs/incident_env_gigpo_7b_a100.yaml` | Single-A100-tuned GiGPO config |
36
+
37
+ ## Step 1 — Build & push the image
38
+
39
+ HF Jobs accepts any Docker image from a registry it can pull from.
40
+
41
+ ### Option A: Docker Hub
42
+
43
+ ```bash
44
+ cd jobs
45
+ docker build \
46
+ --build-arg INCIDENT_REPO=https://github.com/<you>/scaler-hackathon.git \
47
+ --build-arg INCIDENT_REF=main \
48
+ -t docker.io/<you>/incident-env-job:latest .
49
+
50
+ docker push docker.io/<you>/incident-env-job:latest
51
+ ```
52
+
53
+ ### Option B: HF Space as image
54
+
55
+ Create a Space of type `Docker`, push this `Dockerfile` to it, and use
56
+ `--image hf.co/spaces/<you>/incident-env-job` at launch time.
57
+
58
+ ## Step 2 — Install the launcher & auth
59
+
60
+ ```bash
61
+ pip install "huggingface_hub>=1.0"
62
+ hf auth login # or export HF_TOKEN=...
63
+ export WANDB_API_KEY=... # optional
64
+ ```
65
+
66
+ ## Step 3 — (Optional) Create a checkpoint dataset
67
+
68
+ HF Jobs filesystems are ephemeral. Mount a dataset repo at
69
+ `/training-outputs` so LoRA checkpoints survive:
70
+
71
+ ```bash
72
+ hf repo create incident-env-checkpoints --type dataset
73
+ ```
74
+
75
+ The launcher's `--hf-repo <name>` flag mounts it read/write.
76
+
77
+ ## Step 4 — Launch
78
+
79
+ ```bash
80
+ python jobs/launch_hf_job.py \
81
+ --image docker.io/<you>/incident-env-job:latest \
82
+ --flavor a100-large \
83
+ --timeout 6h \
84
+ --hf-repo <you>/incident-env-checkpoints \
85
+ --base-model Qwen/Qwen2.5-7B-Instruct \
86
+ --config incident_env_gigpo_7b_a100 \
87
+ --epochs 30 \
88
+ --num-envs 4 \
89
+ --rollout-n 4 \
90
+ --follow
91
+ ```
92
+
93
+ Drop `--follow` to detach after launch; re-attach later with:
94
+
95
+ ```python
96
+ from huggingface_hub import fetch_job_logs, inspect_job
97
+ for line in fetch_job_logs(job_id="<JOB_ID>"):
98
+ print(line)
99
+ ```
100
+
101
+ ## Step 5 — Sanity smoke test (cheap)
102
+
103
+ Before committing to a 6h A100 run, do a 30-minute wiring check on a
104
+ smaller flavor:
105
+
106
+ ```bash
107
+ python jobs/launch_hf_job.py \
108
+ --image docker.io/<you>/incident-env-job:latest \
109
+ --flavor a10g-large \
110
+ --timeout 30m \
111
+ --base-model Qwen/Qwen2.5-0.5B-Instruct \
112
+ --epochs 2 \
113
+ --num-envs 2 \
114
+ --rollout-n 2 \
115
+ --follow
116
+ ```
117
+
118
+ This only verifies the entrypoint boots env-server + vLLM + verl-agent
119
+ without OOM; it will not produce a good policy.
120
+
121
+ ## Step 6 — Managing jobs
122
+
123
+ ```python
124
+ from huggingface_hub import list_jobs, inspect_job, cancel_job, fetch_job_metrics
125
+
126
+ jobs = list_jobs()
127
+ running = [j for j in jobs if j.status.stage == "RUNNING"]
128
+
129
+ cancel_job(job_id="<JOB_ID>")
130
+ for m in fetch_job_metrics(job_id="<JOB_ID>"):
131
+ print(m)
132
+ ```
133
+
134
+ Or via CLI:
135
+
136
+ ```bash
137
+ hf jobs ps
138
+ hf jobs logs <JOB_ID>
139
+ hf jobs cancel <JOB_ID>
140
+ ```
141
+
142
+ ## Scheduled evals
143
+
144
+ Re-run `scripts/eval_incident_env.py` on a recurring schedule against
145
+ the latest LoRA in your checkpoint dataset. Useful for catching
146
+ regressions during long training runs.
147
+
148
+ ### Create a weekly eval
149
+
150
+ ```bash
151
+ python jobs/schedule_eval_job.py create \
152
+ --image docker.io/<you>/incident-env-job:latest \
153
+ --schedule "@weekly" \
154
+ --flavor a10g-large \
155
+ --timeout 90m \
156
+ --hf-repo <you>/incident-env-checkpoints \
157
+ --lora-path /training-outputs/incident_env_gigpo_7b_a100/latest_lora \
158
+ --base-model Qwen/Qwen2.5-7B-Instruct \
159
+ --seeds 30
160
+ ```
161
+
162
+ Schedule syntax (from HF docs):
163
+ `@hourly | @daily | @weekly | @monthly | @yearly`, or any CRON string
164
+ (e.g. `"0 9 * * 1"` = Mondays 09:00 UTC).
165
+
166
+ ### Report output
167
+
168
+ Each run writes `eval_reports/<JOB_ID>.json` to the mounted
169
+ `--hf-repo`, containing:
170
+
171
+ ```json
172
+ {
173
+ "summary": {
174
+ "model": "incident_lora",
175
+ "overall_score_mean": 0.78,
176
+ "per_task": {
177
+ "memory_leak": {"score_mean": 0.88, "n": 30, ...},
178
+ "cascading_failure": {"score_mean": 0.71, "n": 30, ...},
179
+ "distributed_deadlock":{"score_mean": 0.75, "n": 30, ...}
180
+ }
181
+ },
182
+ "results": [ ... per-episode rubric audit ... ]
183
+ }
184
+ ```
185
+
186
+ A compact summary is also printed into the Job logs so it's visible in
187
+ the HF UI without downloading the JSON.
188
+
189
+ ### Manage scheduled jobs
190
+
191
+ ```bash
192
+ python jobs/schedule_eval_job.py list
193
+ python jobs/schedule_eval_job.py inspect <SCHEDULED_ID>
194
+ python jobs/schedule_eval_job.py pause <SCHEDULED_ID>
195
+ python jobs/schedule_eval_job.py resume <SCHEDULED_ID>
196
+ python jobs/schedule_eval_job.py delete <SCHEDULED_ID>
197
+ ```
198
+
199
+ Omit `--lora-path` to evaluate the **base model** as a reference
200
+ baseline (useful to confirm that your RL-trained LoRA actually beats
201
+ vanilla).
202
+
203
+ ## Built-in job env vars (from HF)
204
+
205
+ Available inside the container:
206
+
207
+ - `JOB_ID` — unique id for this job
208
+ - `ACCELERATOR` — e.g. `a100-large`
209
+ - `CPU_CORES`, `MEMORY`
210
+
211
+ `run_in_job.sh` already echoes these at startup.
212
+
213
+ ## Monitoring
214
+
215
+ - **W&B**: set `WANDB_API_KEY` before launching; runs appear under
216
+ `$WANDB_PROJECT` (default `incident_env_rl`) with the job id in the
217
+ run name.
218
+ - **Rubric audit**: every terminal step logs `rubric_version`,
219
+ `rubric_raw_components`, and `score` (see `RUBRICS.md`). Use
220
+ `scripts/eval_incident_env.py` against a saved LoRA adapter for
221
+ held-out evaluation.
222
+
223
+ ## Troubleshooting
224
+
225
+ - **OOM during vLLM startup** → lower
226
+ `actor_rollout_ref.rollout.gpu_memory_utilization` (default 0.45) or
227
+ drop `max_response_length`.
228
+ - **Trainer OOM during FSDP** → raise
229
+ `actor_rollout_ref.actor.fsdp_config.param_offload: true` and
230
+ `optimizer_offload: true` (already set in 7B config).
231
+ - **vLLM and trainer both allocate the same GPU**: yes, that's intended
232
+ — `hybrid_engine: true` + low `gpu_memory_utilization` shares the
233
+ GPU. If you see `CUDA out of memory`, the balance needs tuning.
234
+ - **Job hits the 30-minute default timeout**: pass `--timeout 6h`.
235
+ - **Pool cleanup** between episodes is automatic via the
236
+ `IncidentEnvManager.close()` path; no action required.
jobs/eval_in_job.sh ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # HF Jobs entrypoint for SCHEDULED EVAL runs of incident_env checkpoints.
3
+ #
4
+ # Boots (in order) inside a single GPU container:
5
+ # 1. incident_env FastAPI server on :$ENV_PORT
6
+ # 2. vLLM OpenAI-compatible server on :$VLLM_PORT serving $BASE_MODEL,
7
+ # with the target LoRA adapter hot-loaded (if LORA_PATH provided).
8
+ # 3. scripts/eval_incident_env.py against the two endpoints.
9
+ #
10
+ # The eval report (JSON with per-task score_mean/std + rubric audit) is
11
+ # written under $OUTPUT_DIR/eval_reports/<timestamp>.json, which is a
12
+ # mounted HF dataset/bucket → survives job termination.
13
+ #
14
+ # Env vars consumed (defaults in brackets):
15
+ # BASE_MODEL [Qwen/Qwen2.5-7B-Instruct]
16
+ # LORA_PATH [<unset>] path/repo for LoRA adapter to eval;
17
+ # if unset, evaluates the base model
18
+ # LORA_NAME [incident_lora] served name for vLLM
19
+ # SEEDS [30] seeds per task
20
+ # TASKS [memory_leak cascading_failure distributed_deadlock]
21
+ # TEMPERATURE [0.0]
22
+ # MAX_TURNS [20]
23
+ # REWARD_MODE [long_horizon_v2]
24
+ # OUTPUT_DIR [/training-outputs]
25
+ # REPORT_TAG [<JOB_ID or timestamp>]
26
+
27
+ set -euo pipefail
28
+
29
+ BASE_MODEL="${BASE_MODEL:-Qwen/Qwen2.5-7B-Instruct}"
30
+ LORA_PATH="${LORA_PATH:-}"
31
+ LORA_NAME="${LORA_NAME:-incident_lora}"
32
+ SEEDS="${SEEDS:-30}"
33
+ TASKS="${TASKS:-memory_leak cascading_failure distributed_deadlock}"
34
+ TEMPERATURE="${TEMPERATURE:-0.0}"
35
+ MAX_TURNS="${MAX_TURNS:-20}"
36
+ REWARD_MODE="${REWARD_MODE:-long_horizon_v2}"
37
+ ENV_PORT="${ENV_PORT:-8000}"
38
+ VLLM_PORT="${VLLM_PORT:-8080}"
39
+ OUTPUT_DIR="${OUTPUT_DIR:-/training-outputs}"
40
+ REPORT_TAG="${REPORT_TAG:-${JOB_ID:-$(date +%Y%m%d_%H%M%S)}}"
41
+
42
+ INCIDENT_REPO_DIR="${INCIDENT_REPO_DIR:-/workspace/incident_env}"
43
+
44
+ echo "=========================================="
45
+ echo " HF Job: incident_env EVAL"
46
+ echo "=========================================="
47
+ echo " JOB_ID: ${JOB_ID:-<local>}"
48
+ echo " ACCELERATOR: ${ACCELERATOR:-unknown}"
49
+ echo " BASE_MODEL: $BASE_MODEL"
50
+ echo " LORA_PATH: ${LORA_PATH:-<base model only>}"
51
+ echo " SEEDS: $SEEDS"
52
+ echo " TASKS: $TASKS"
53
+ echo " REWARD_MODE: $REWARD_MODE"
54
+ echo " REPORT_TAG: $REPORT_TAG"
55
+ echo "=========================================="
56
+
57
+ # ---- Auth ----------------------------------------------------------------
58
+ if [ -n "${HF_TOKEN:-}" ]; then
59
+ huggingface-cli login --token "$HF_TOKEN" --add-to-git-credential 2>/dev/null || true
60
+ fi
61
+
62
+ # ---- Refresh incident_env repo if a ref is pinned ------------------------
63
+ if [ -n "${INCIDENT_REPO_REF:-}" ]; then
64
+ (cd "$INCIDENT_REPO_DIR" && git fetch --all --tags && git checkout "$INCIDENT_REPO_REF") || true
65
+ fi
66
+ pip install -e "$INCIDENT_REPO_DIR" 2>/dev/null || true
67
+ export PYTHONPATH="$INCIDENT_REPO_DIR:${PYTHONPATH:-}"
68
+
69
+ # ---- Background process management ---------------------------------------
70
+ PIDS=()
71
+ cleanup() {
72
+ echo ">> cleanup"
73
+ for pid in "${PIDS[@]}"; do
74
+ kill "$pid" 2>/dev/null || true
75
+ done
76
+ wait 2>/dev/null || true
77
+ }
78
+ trap cleanup EXIT INT TERM
79
+
80
+ # ---- 1. env server -------------------------------------------------------
81
+ echo ">> Starting incident_env server on :$ENV_PORT"
82
+ (
83
+ cd "$INCIDENT_REPO_DIR"
84
+ uvicorn server.app:app --host 0.0.0.0 --port "$ENV_PORT" \
85
+ > /tmp/env_server.log 2>&1
86
+ ) &
87
+ PIDS+=($!)
88
+
89
+ for i in $(seq 1 60); do
90
+ if curl -sf "http://localhost:$ENV_PORT/health" > /dev/null 2>&1; then
91
+ echo ">> env server ready"
92
+ break
93
+ fi
94
+ sleep 1
95
+ done
96
+ curl -sf "http://localhost:$ENV_PORT/health" > /dev/null || { echo "!! env server failed"; tail -50 /tmp/env_server.log; exit 1; }
97
+
98
+ # ---- 2. vLLM (with optional LoRA) ----------------------------------------
99
+ VLLM_ARGS=(
100
+ --model "$BASE_MODEL"
101
+ --port "$VLLM_PORT"
102
+ --host 0.0.0.0
103
+ --gpu-memory-utilization 0.85
104
+ --max-model-len 4096
105
+ --enforce-eager
106
+ )
107
+ if [ -n "$LORA_PATH" ]; then
108
+ echo ">> vLLM will serve LoRA '$LORA_NAME' from $LORA_PATH"
109
+ VLLM_ARGS+=(
110
+ --enable-lora
111
+ --max-loras 2
112
+ --max-lora-rank 32
113
+ --lora-modules "${LORA_NAME}=${LORA_PATH}"
114
+ )
115
+ SERVED_MODEL="$LORA_NAME"
116
+ else
117
+ echo ">> vLLM will serve BASE model only (no LoRA)"
118
+ SERVED_MODEL="$BASE_MODEL"
119
+ fi
120
+
121
+ VLLM_ALLOW_RUNTIME_LORA_UPDATING=True python -m vllm.entrypoints.openai.api_server \
122
+ "${VLLM_ARGS[@]}" > /tmp/vllm.log 2>&1 &
123
+ PIDS+=($!)
124
+
125
+ for i in $(seq 1 180); do
126
+ if curl -sf "http://localhost:$VLLM_PORT/health" > /dev/null 2>&1; then
127
+ echo ">> vLLM ready"
128
+ break
129
+ fi
130
+ sleep 2
131
+ done
132
+ curl -sf "http://localhost:$VLLM_PORT/health" > /dev/null || { echo "!! vLLM failed"; tail -80 /tmp/vllm.log; exit 1; }
133
+
134
+ # ---- 3. Run eval ---------------------------------------------------------
135
+ REPORT_DIR="$OUTPUT_DIR/eval_reports"
136
+ mkdir -p "$REPORT_DIR" 2>/dev/null || true
137
+ REPORT_PATH="$REPORT_DIR/${REPORT_TAG}.json"
138
+
139
+ echo ">> Running eval → $REPORT_PATH"
140
+ # shellcheck disable=SC2086
141
+ python "$INCIDENT_REPO_DIR/scripts/eval_incident_env.py" \
142
+ --env-url "http://localhost:$ENV_PORT" \
143
+ --endpoint "http://localhost:$VLLM_PORT/v1" \
144
+ --model "$SERVED_MODEL" \
145
+ --seeds "$SEEDS" \
146
+ --tasks $TASKS \
147
+ --temperature "$TEMPERATURE" \
148
+ --max-turns "$MAX_TURNS" \
149
+ --reward-mode "$REWARD_MODE" \
150
+ --out "$REPORT_PATH"
151
+
152
+ echo ">> Eval complete."
153
+ echo ">> Report: $REPORT_PATH"
154
+
155
+ # Print a compact summary so it surfaces in HF Jobs logs
156
+ python - <<PY
157
+ import json, sys
158
+ with open("$REPORT_PATH") as f:
159
+ out = json.load(f)
160
+ s = out["summary"]
161
+ print("=" * 60)
162
+ print("EVAL SUMMARY")
163
+ print(f" model: {s['model']}")
164
+ print(f" overall_mean: {s['overall_score_mean']}")
165
+ print(f" overall_std: {s['overall_score_std']}")
166
+ for task, v in s["per_task"].items():
167
+ print(f" {task:25s} score={v['score_mean']:.3f} reward={v['reward_mean']:.3f} turns={v['turns_mean']:.1f} n={v['n']}")
168
+ print("=" * 60)
169
+ PY
jobs/launch_hf_job.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Launch incident_env post-training on HF Jobs.
3
+
4
+ Thin wrapper around `huggingface_hub.run_job` that:
5
+ - selects a GPU flavor (default `a100-large` = single A100-80GB),
6
+ - sets a generous timeout (default 6 h),
7
+ - forwards HF_TOKEN / WANDB_API_KEY as secrets,
8
+ - optionally mounts an HF dataset repo or bucket at /training-outputs
9
+ so checkpoints persist between jobs.
10
+
11
+ Prerequisites
12
+ -------------
13
+ - HF Pro / Team / Enterprise account (HF Jobs is gated).
14
+ - `pip install "huggingface_hub>=1.0"`
15
+ - `hf auth login` (or set HF_TOKEN).
16
+ - A Docker image already pushed somewhere HF Jobs can pull from — either
17
+ Docker Hub (`user/incident-env-job:tag`) or an HF Space used as an image
18
+ (`hf.co/spaces/user/incident-env-job`). See jobs/README.md for build steps.
19
+
20
+ Usage
21
+ -----
22
+ python jobs/launch_hf_job.py \\
23
+ --image docker.io/youruser/incident-env-job:latest \\
24
+ --flavor a100-large \\
25
+ --timeout 6h \\
26
+ --hf-repo youruser/incident-env-checkpoints \\
27
+ --wandb-project incident_env_rl \\
28
+ --base-model Qwen/Qwen2.5-7B-Instruct \\
29
+ --config incident_env_gigpo_7b_a100 \\
30
+ --epochs 30
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import argparse
36
+ import os
37
+ import sys
38
+ import time
39
+ from typing import Any, Dict, List, Optional
40
+
41
+ try:
42
+ from huggingface_hub import (
43
+ Volume,
44
+ cancel_job,
45
+ fetch_job_logs,
46
+ inspect_job,
47
+ run_job,
48
+ )
49
+ except ImportError:
50
+ print(
51
+ "ERROR: huggingface_hub>=1.0 required. "
52
+ "Install with: pip install 'huggingface_hub>=1.0'",
53
+ file=sys.stderr,
54
+ )
55
+ sys.exit(1)
56
+
57
+
58
+ VALID_FLAVORS = {
59
+ # GPU — see https://huggingface.co/docs/huggingface_hub/guides/jobs
60
+ "t4-small",
61
+ "t4-medium",
62
+ "l4x1",
63
+ "l4x4",
64
+ "a10g-small",
65
+ "a10g-large",
66
+ "a10g-largex2",
67
+ "a10g-largex4",
68
+ "a100-large", # single A100-80GB — recommended for Qwen2.5-7B + LoRA
69
+ }
70
+
71
+
72
+ def build_args() -> argparse.Namespace:
73
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
74
+ ap.add_argument("--image", required=True, help="Docker image (Docker Hub or hf.co/spaces/...).")
75
+ ap.add_argument(
76
+ "--flavor",
77
+ default="a100-large",
78
+ choices=sorted(VALID_FLAVORS),
79
+ help="HF Jobs hardware flavor.",
80
+ )
81
+ ap.add_argument("--timeout", default="6h", help="Job timeout (e.g. 6h, 30m, 1d). Default 6h.")
82
+ ap.add_argument("--namespace", default=None, help="Org namespace (optional).")
83
+
84
+ # Training knobs forwarded as env vars to the container.
85
+ ap.add_argument("--base-model", default="Qwen/Qwen2.5-7B-Instruct")
86
+ ap.add_argument("--config", default="incident_env_gigpo_7b_a100", help="YAML config basename in configs/.")
87
+ ap.add_argument("--epochs", type=int, default=30)
88
+ ap.add_argument("--num-envs", type=int, default=4)
89
+ ap.add_argument("--rollout-n", type=int, default=4)
90
+ ap.add_argument("--reward-mode", default="long_horizon_v2", choices=["legacy", "long_horizon_v2"])
91
+ ap.add_argument("--incident-repo-ref", default=None, help="Optional git ref for this repo (otherwise baked image SHA).")
92
+ ap.add_argument("--verl-agent-repo-ref", default=None)
93
+ ap.add_argument("--wandb-project", default="incident_env_rl")
94
+
95
+ # Persistence — checkpoints survive job termination.
96
+ ap.add_argument(
97
+ "--hf-repo",
98
+ default=None,
99
+ help="HF repo (dataset / bucket) to mount at /training-outputs. Requires write access.",
100
+ )
101
+ ap.add_argument(
102
+ "--hf-repo-type",
103
+ default="dataset",
104
+ choices=["dataset", "model", "bucket"],
105
+ help="Repo type for the output mount.",
106
+ )
107
+
108
+ # Monitoring.
109
+ ap.add_argument("--follow", action="store_true", help="Tail logs after launch.")
110
+ ap.add_argument("--poll-interval", type=float, default=5.0)
111
+ return ap.parse_args()
112
+
113
+
114
+ def build_env_vars(args: argparse.Namespace) -> Dict[str, str]:
115
+ env = {
116
+ "BASE_MODEL": args.base_model,
117
+ "CONFIG_NAME": args.config,
118
+ "EPOCHS": str(args.epochs),
119
+ "NUM_ENVS": str(args.num_envs),
120
+ "ROLLOUT_N": str(args.rollout_n),
121
+ "INCIDENT_REWARD_MODE": args.reward_mode,
122
+ "WANDB_PROJECT": args.wandb_project,
123
+ }
124
+ if args.incident_repo_ref:
125
+ env["INCIDENT_REPO_REF"] = args.incident_repo_ref
126
+ if args.verl_agent_repo_ref:
127
+ env["VERL_AGENT_REPO_REF"] = args.verl_agent_repo_ref
128
+ if args.hf_repo:
129
+ # The entrypoint writes checkpoints under OUTPUT_DIR.
130
+ env["OUTPUT_DIR"] = "/training-outputs"
131
+ env["HF_REPO"] = args.hf_repo
132
+ return env
133
+
134
+
135
+ def build_secrets() -> Dict[str, str]:
136
+ """Forward HF_TOKEN + WANDB_API_KEY from the caller's env as encrypted secrets."""
137
+ out: Dict[str, str] = {}
138
+ hf = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
139
+ if hf:
140
+ out["HF_TOKEN"] = hf
141
+ wb = os.environ.get("WANDB_API_KEY")
142
+ if wb:
143
+ out["WANDB_API_KEY"] = wb
144
+ return out
145
+
146
+
147
+ def build_volumes(args: argparse.Namespace) -> Optional[List[Volume]]:
148
+ if not args.hf_repo:
149
+ return None
150
+ vol = Volume(
151
+ type=args.hf_repo_type,
152
+ source=args.hf_repo,
153
+ mount_path="/training-outputs",
154
+ )
155
+ return [vol]
156
+
157
+
158
+ def follow_logs(job_id: str, poll_interval: float) -> int:
159
+ """Stream logs until job terminates. Returns 0 on COMPLETED, non-zero on ERROR."""
160
+ print(f">> Following logs for job {job_id} (Ctrl-C to detach; job keeps running)")
161
+ last_line = 0
162
+ try:
163
+ while True:
164
+ status = inspect_job(job_id=job_id).status
165
+ logs = list(fetch_job_logs(job_id=job_id))
166
+ for line in logs[last_line:]:
167
+ print(line)
168
+ last_line = len(logs)
169
+ if status.stage in ("COMPLETED", "ERROR", "CANCELED"):
170
+ print(f">> Job finished: stage={status.stage} message={status.message}")
171
+ return 0 if status.stage == "COMPLETED" else 1
172
+ time.sleep(poll_interval)
173
+ except KeyboardInterrupt:
174
+ print(">> Detached. Job continues running on HF infrastructure.")
175
+ return 0
176
+
177
+
178
+ def main() -> int:
179
+ args = build_args()
180
+
181
+ env = build_env_vars(args)
182
+ secrets = build_secrets()
183
+ volumes = build_volumes(args)
184
+
185
+ print("=" * 60)
186
+ print(" HF Jobs — incident_env post-training")
187
+ print("=" * 60)
188
+ print(f" image: {args.image}")
189
+ print(f" flavor: {args.flavor}")
190
+ print(f" timeout: {args.timeout}")
191
+ print(f" namespace: {args.namespace or '<user>'}")
192
+ print(f" base_model: {args.base_model}")
193
+ print(f" config: {args.config}")
194
+ print(f" epochs: {args.epochs}")
195
+ print(f" num_envs: {args.num_envs}")
196
+ print(f" rollout_n: {args.rollout_n}")
197
+ print(f" reward_mode: {args.reward_mode}")
198
+ print(f" volumes: {args.hf_repo or '<none>'}")
199
+ print(f" env: {sorted(env.keys())}")
200
+ print(f" secrets: {sorted(secrets.keys())}")
201
+ print("=" * 60)
202
+
203
+ kwargs: Dict[str, Any] = dict(
204
+ image=args.image,
205
+ command=["/usr/local/bin/run_in_job.sh"],
206
+ flavor=args.flavor,
207
+ timeout=args.timeout,
208
+ env=env,
209
+ secrets=secrets,
210
+ )
211
+ if args.namespace:
212
+ kwargs["namespace"] = args.namespace
213
+ if volumes:
214
+ kwargs["volumes"] = volumes
215
+
216
+ job = run_job(**kwargs)
217
+ print(f">> Job launched: {job.url}")
218
+ print(f">> Job ID: {job.id}")
219
+
220
+ if args.follow:
221
+ return follow_logs(job.id, args.poll_interval)
222
+ return 0
223
+
224
+
225
+ if __name__ == "__main__":
226
+ sys.exit(main())
jobs/run_in_job.sh ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # HF Jobs entrypoint.
3
+ #
4
+ # Inside a single GPU container, boot (in order):
5
+ # 1. incident_env FastAPI server on :$ENV_PORT
6
+ # 2. vLLM OpenAI-compatible server on :$VLLM_PORT (the actor backend)
7
+ # 3. verl-agent trainer (foreground) — when this exits, the job ends
8
+ #
9
+ # Env vars consumed (all optional, reasonable defaults):
10
+ # BASE_MODEL default: Qwen/Qwen2.5-7B-Instruct
11
+ # CONFIG_NAME default: incident_env_gigpo_7b_a100
12
+ # EPOCHS total training epochs
13
+ # NUM_ENVS parallel incident envs
14
+ # ROLLOUT_N GRPO group size
15
+ # HF_REPO HF dataset or model repo to push checkpoints to
16
+ # HF_TOKEN (secret)
17
+ # WANDB_API_KEY (secret)
18
+ # WANDB_PROJECT default: incident_env_rl
19
+ # INCIDENT_REPO_REF optional git ref to check out (otherwise baked-in)
20
+ # VERL_AGENT_REPO_REF optional git ref
21
+
22
+ set -euo pipefail
23
+
24
+ BASE_MODEL="${BASE_MODEL:-Qwen/Qwen2.5-7B-Instruct}"
25
+ CONFIG_NAME="${CONFIG_NAME:-incident_env_gigpo_7b_a100}"
26
+ EPOCHS="${EPOCHS:-30}"
27
+ NUM_ENVS="${NUM_ENVS:-4}"
28
+ ROLLOUT_N="${ROLLOUT_N:-4}"
29
+ ENV_PORT="${ENV_PORT:-8000}"
30
+ VLLM_PORT="${VLLM_PORT:-8080}"
31
+ WANDB_PROJECT="${WANDB_PROJECT:-incident_env_rl}"
32
+
33
+ INCIDENT_REPO_DIR="${INCIDENT_REPO_DIR:-/workspace/incident_env}"
34
+ VERL_AGENT_REPO_DIR="${VERL_AGENT_REPO_DIR:-/workspace/verl-agent}"
35
+
36
+ echo "=========================================="
37
+ echo " HF Job: incident_env post-training"
38
+ echo "=========================================="
39
+ echo " JOB_ID: ${JOB_ID:-<local>}"
40
+ echo " ACCELERATOR: ${ACCELERATOR:-unknown}"
41
+ echo " BASE_MODEL: $BASE_MODEL"
42
+ echo " CONFIG: $CONFIG_NAME"
43
+ echo " EPOCHS: $EPOCHS"
44
+ echo " NUM_ENVS: $NUM_ENVS"
45
+ echo " ROLLOUT_N: $ROLLOUT_N"
46
+ echo " REWARD_MODE: ${INCIDENT_REWARD_MODE:-long_horizon_v2}"
47
+ echo "=========================================="
48
+
49
+ # ---- Auth ----------------------------------------------------------------
50
+ if [ -n "${HF_TOKEN:-}" ]; then
51
+ echo ">> HF login"
52
+ huggingface-cli login --token "$HF_TOKEN" --add-to-git-credential 2>/dev/null || true
53
+ fi
54
+ if [ -n "${WANDB_API_KEY:-}" ]; then
55
+ export WANDB_PROJECT
56
+ echo ">> W&B authenticated"
57
+ fi
58
+
59
+ # ---- Refresh repos if refs provided --------------------------------------
60
+ if [ -n "${INCIDENT_REPO_REF:-}" ]; then
61
+ echo ">> Checkout incident_env @ $INCIDENT_REPO_REF"
62
+ (cd "$INCIDENT_REPO_DIR" && git fetch --all --tags && git checkout "$INCIDENT_REPO_REF") || true
63
+ fi
64
+ if [ -n "${VERL_AGENT_REPO_REF:-}" ]; then
65
+ echo ">> Checkout verl-agent @ $VERL_AGENT_REPO_REF"
66
+ (cd "$VERL_AGENT_REPO_DIR" && git fetch --all --tags && git checkout "$VERL_AGENT_REPO_REF") || true
67
+ fi
68
+
69
+ pip install -e "$INCIDENT_REPO_DIR" 2>/dev/null || true
70
+ pip install -e "$VERL_AGENT_REPO_DIR" 2>/dev/null || true
71
+
72
+ export PYTHONPATH="$INCIDENT_REPO_DIR:$VERL_AGENT_REPO_DIR:${PYTHONPATH:-}"
73
+ export INCIDENT_ENV_BASE_URL="http://localhost:$ENV_PORT"
74
+
75
+ # ---- Wire adapter into verl-agent ----------------------------------------
76
+ ADAPTER_DST="$VERL_AGENT_REPO_DIR/agent_system/environments/incident_env"
77
+ if [ ! -e "$ADAPTER_DST" ]; then
78
+ echo ">> Linking env adapter -> $ADAPTER_DST"
79
+ mkdir -p "$(dirname "$ADAPTER_DST")"
80
+ ln -s "$INCIDENT_REPO_DIR/verl_agent_adapter" "$ADAPTER_DST"
81
+ fi
82
+
83
+ RM_DST="$VERL_AGENT_REPO_DIR/agent_system/reward_manager/incident_episode.py"
84
+ if [ ! -f "$RM_DST" ]; then
85
+ echo ">> Installing reward-manager shim"
86
+ mkdir -p "$(dirname "$RM_DST")"
87
+ cat > "$RM_DST" <<'PYEOF'
88
+ from agent_system.environments.incident_env.reward_registry import IncidentEpisodeRewardManager # noqa: F401
89
+ __all__ = ["IncidentEpisodeRewardManager"]
90
+ PYEOF
91
+ fi
92
+
93
+ # ---- Background process management ---------------------------------------
94
+ PIDS=()
95
+ cleanup() {
96
+ echo ">> Cleaning up subprocesses"
97
+ for pid in "${PIDS[@]}"; do
98
+ kill "$pid" 2>/dev/null || true
99
+ done
100
+ wait 2>/dev/null || true
101
+ }
102
+ trap cleanup EXIT INT TERM
103
+
104
+ # ---- 1. incident_env server ----------------------------------------------
105
+ echo ">> Starting incident_env server on :$ENV_PORT"
106
+ (
107
+ cd "$INCIDENT_REPO_DIR"
108
+ uvicorn server.app:app --host 0.0.0.0 --port "$ENV_PORT" \
109
+ > /tmp/incident_env_server.log 2>&1
110
+ ) &
111
+ PIDS+=($!)
112
+
113
+ for i in $(seq 1 60); do
114
+ if curl -sf "http://localhost:$ENV_PORT/health" > /dev/null 2>&1; then
115
+ echo ">> env server ready"
116
+ break
117
+ fi
118
+ sleep 1
119
+ done
120
+ if ! curl -sf "http://localhost:$ENV_PORT/health" > /dev/null 2>&1; then
121
+ echo "!! env server failed to start; logs:"; tail -50 /tmp/incident_env_server.log; exit 1
122
+ fi
123
+
124
+ # ---- 2. vLLM -------------------------------------------------------------
125
+ # One GPU is shared between vLLM and the actor/ref FSDP. Keep vLLM small:
126
+ # gpu_memory_utilization=0.45 leaves room for the FSDP actor + optim state.
127
+ echo ">> Starting vLLM for $BASE_MODEL on :$VLLM_PORT"
128
+ VLLM_ALLOW_RUNTIME_LORA_UPDATING=True python -m vllm.entrypoints.openai.api_server \
129
+ --model "$BASE_MODEL" \
130
+ --port "$VLLM_PORT" \
131
+ --host 0.0.0.0 \
132
+ --enable-lora \
133
+ --max-loras 4 \
134
+ --max-lora-rank 32 \
135
+ --gpu-memory-utilization 0.45 \
136
+ --max-model-len 4096 \
137
+ --enforce-eager \
138
+ > /tmp/vllm.log 2>&1 &
139
+ PIDS+=($!)
140
+
141
+ for i in $(seq 1 180); do
142
+ if curl -sf "http://localhost:$VLLM_PORT/health" > /dev/null 2>&1; then
143
+ echo ">> vLLM ready"
144
+ break
145
+ fi
146
+ sleep 2
147
+ done
148
+ if ! curl -sf "http://localhost:$VLLM_PORT/health" > /dev/null 2>&1; then
149
+ echo "!! vLLM failed to start; logs:"; tail -80 /tmp/vllm.log; exit 1
150
+ fi
151
+
152
+ # ---- 3. verl-agent trainer (foreground) ----------------------------------
153
+ CONFIG_PATH="$INCIDENT_REPO_DIR/configs"
154
+ OUT_DIR="${OUTPUT_DIR:-/training-outputs}"
155
+ mkdir -p "$OUT_DIR" || true
156
+
157
+ echo ">> Launching verl-agent trainer"
158
+ cd "$VERL_AGENT_REPO_DIR"
159
+ exec python -m verl.trainer.main_ppo \
160
+ --config-path "$CONFIG_PATH" \
161
+ --config-name "$CONFIG_NAME" \
162
+ actor_rollout_ref.model.path="$BASE_MODEL" \
163
+ env.num_envs="$NUM_ENVS" \
164
+ env.rollout.n="$ROLLOUT_N" \
165
+ actor_rollout_ref.rollout.n="$ROLLOUT_N" \
166
+ trainer.total_epochs="$EPOCHS" \
167
+ trainer.default_local_dir="$OUT_DIR/$CONFIG_NAME" \
168
+ trainer.project_name="$WANDB_PROJECT" \
169
+ "$@"
jobs/schedule_eval_job.py ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Create / manage a SCHEDULED eval job on HF Jobs.
3
+
4
+ Runs `jobs/eval_in_job.sh` on a recurring schedule (weekly by default)
5
+ against the latest LoRA adapter in a given HF repo. Writes a JSON report
6
+ to the mounted checkpoint dataset under `eval_reports/<tag>.json`.
7
+
8
+ Reference:
9
+ https://huggingface.co/docs/huggingface_hub/guides/jobs#scheduled-jobs
10
+
11
+ Supported schedules (from HF docs):
12
+ @annually | @yearly | @monthly | @weekly | @daily | @hourly
13
+ or a CRON expression, e.g. "0 9 * * 1" (Mon 09:00 UTC)
14
+
15
+ Prerequisites
16
+ -------------
17
+ - The same Docker image built in `jobs/Dockerfile` (which now also
18
+ contains `/usr/local/bin/eval_in_job.sh`).
19
+ - HF Pro / Team / Enterprise account.
20
+ - `pip install "huggingface_hub>=1.0"`.
21
+
22
+ Usage
23
+ -----
24
+ # Create a weekly eval (Mondays 09:00 UTC) on a10g-large
25
+ python jobs/schedule_eval_job.py create \\
26
+ --image docker.io/<you>/incident-env-job:latest \\
27
+ --schedule "@weekly" \\
28
+ --flavor a10g-large \\
29
+ --timeout 90m \\
30
+ --hf-repo <you>/incident-env-checkpoints \\
31
+ --lora-path "/training-outputs/incident_env_gigpo_7b_a100/latest_lora" \\
32
+ --base-model Qwen/Qwen2.5-7B-Instruct \\
33
+ --seeds 30
34
+
35
+ # List / inspect / pause / delete
36
+ python jobs/schedule_eval_job.py list
37
+ python jobs/schedule_eval_job.py inspect <SCHEDULED_ID>
38
+ python jobs/schedule_eval_job.py pause <SCHEDULED_ID>
39
+ python jobs/schedule_eval_job.py resume <SCHEDULED_ID>
40
+ python jobs/schedule_eval_job.py delete <SCHEDULED_ID>
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import argparse
46
+ import os
47
+ import sys
48
+ from typing import Any, Dict, List, Optional
49
+
50
+ try:
51
+ from huggingface_hub import (
52
+ Volume,
53
+ create_scheduled_job,
54
+ delete_scheduled_job,
55
+ inspect_scheduled_job,
56
+ list_scheduled_jobs,
57
+ resume_scheduled_job,
58
+ suspend_scheduled_job,
59
+ )
60
+ except ImportError:
61
+ print(
62
+ "ERROR: huggingface_hub>=1.0 required. "
63
+ "Install with: pip install 'huggingface_hub>=1.0'",
64
+ file=sys.stderr,
65
+ )
66
+ sys.exit(1)
67
+
68
+
69
+ VALID_FLAVORS = {
70
+ "t4-small", "t4-medium", "l4x1", "l4x4",
71
+ "a10g-small", "a10g-large", "a10g-largex2", "a10g-largex4",
72
+ "a100-large",
73
+ }
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # Builders
78
+ # ---------------------------------------------------------------------------
79
+
80
+
81
+ def build_env(args: argparse.Namespace) -> Dict[str, str]:
82
+ env: Dict[str, str] = {
83
+ "BASE_MODEL": args.base_model,
84
+ "SEEDS": str(args.seeds),
85
+ "TASKS": " ".join(args.tasks),
86
+ "TEMPERATURE": str(args.temperature),
87
+ "MAX_TURNS": str(args.max_turns),
88
+ "REWARD_MODE": args.reward_mode,
89
+ }
90
+ if args.lora_path:
91
+ env["LORA_PATH"] = args.lora_path
92
+ if args.lora_name:
93
+ env["LORA_NAME"] = args.lora_name
94
+ if args.incident_repo_ref:
95
+ env["INCIDENT_REPO_REF"] = args.incident_repo_ref
96
+ if args.hf_repo:
97
+ env["OUTPUT_DIR"] = "/training-outputs"
98
+ return env
99
+
100
+
101
+ def build_secrets() -> Dict[str, str]:
102
+ out: Dict[str, str] = {}
103
+ hf = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
104
+ if hf:
105
+ out["HF_TOKEN"] = hf
106
+ return out
107
+
108
+
109
+ def build_volumes(args: argparse.Namespace) -> Optional[List[Volume]]:
110
+ if not args.hf_repo:
111
+ return None
112
+ return [
113
+ Volume(
114
+ type=args.hf_repo_type,
115
+ source=args.hf_repo,
116
+ mount_path="/training-outputs",
117
+ )
118
+ ]
119
+
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # Commands
123
+ # ---------------------------------------------------------------------------
124
+
125
+
126
+ def cmd_create(args: argparse.Namespace) -> int:
127
+ env = build_env(args)
128
+ secrets = build_secrets()
129
+ volumes = build_volumes(args)
130
+
131
+ print("=" * 60)
132
+ print(" Scheduled eval job — incident_env")
133
+ print("=" * 60)
134
+ print(f" image: {args.image}")
135
+ print(f" schedule: {args.schedule}")
136
+ print(f" flavor: {args.flavor}")
137
+ print(f" timeout: {args.timeout}")
138
+ print(f" hf_repo: {args.hf_repo or '<none>'}")
139
+ print(f" lora: {args.lora_path or '<base model>'}")
140
+ print(f" tasks: {args.tasks}")
141
+ print(f" seeds: {args.seeds}")
142
+ print("=" * 60)
143
+
144
+ kwargs: Dict[str, Any] = dict(
145
+ image=args.image,
146
+ command=["/usr/local/bin/eval_in_job.sh"],
147
+ schedule=args.schedule,
148
+ flavor=args.flavor,
149
+ timeout=args.timeout,
150
+ env=env,
151
+ secrets=secrets,
152
+ )
153
+ if args.namespace:
154
+ kwargs["namespace"] = args.namespace
155
+ if volumes:
156
+ kwargs["volumes"] = volumes
157
+
158
+ sched = create_scheduled_job(**kwargs)
159
+ print(f">> created scheduled_job: {sched}")
160
+ # `sched` has .id; surface it for follow-up commands.
161
+ sid = getattr(sched, "id", None)
162
+ if sid:
163
+ print(f">> id: {sid}")
164
+ return 0
165
+
166
+
167
+ def cmd_list(args: argparse.Namespace) -> int:
168
+ for s in list_scheduled_jobs():
169
+ print(s)
170
+ return 0
171
+
172
+
173
+ def cmd_inspect(args: argparse.Namespace) -> int:
174
+ print(inspect_scheduled_job(args.scheduled_id))
175
+ return 0
176
+
177
+
178
+ def cmd_pause(args: argparse.Namespace) -> int:
179
+ suspend_scheduled_job(args.scheduled_id)
180
+ print(f">> paused {args.scheduled_id}")
181
+ return 0
182
+
183
+
184
+ def cmd_resume(args: argparse.Namespace) -> int:
185
+ resume_scheduled_job(args.scheduled_id)
186
+ print(f">> resumed {args.scheduled_id}")
187
+ return 0
188
+
189
+
190
+ def cmd_delete(args: argparse.Namespace) -> int:
191
+ delete_scheduled_job(args.scheduled_id)
192
+ print(f">> deleted {args.scheduled_id}")
193
+ return 0
194
+
195
+
196
+ # ---------------------------------------------------------------------------
197
+ # CLI wiring
198
+ # ---------------------------------------------------------------------------
199
+
200
+
201
+ def _add_create_args(sp: argparse.ArgumentParser) -> None:
202
+ sp.add_argument("--image", required=True)
203
+ sp.add_argument(
204
+ "--schedule",
205
+ default="@weekly",
206
+ help='"@hourly" | "@daily" | "@weekly" | "@monthly" | CRON expr (e.g. "0 9 * * 1")',
207
+ )
208
+ sp.add_argument("--flavor", default="a10g-large", choices=sorted(VALID_FLAVORS))
209
+ sp.add_argument("--timeout", default="90m")
210
+ sp.add_argument("--namespace", default=None)
211
+
212
+ # eval config forwarded as env
213
+ sp.add_argument("--base-model", default="Qwen/Qwen2.5-7B-Instruct")
214
+ sp.add_argument("--lora-path", default=None,
215
+ help="Path inside the container (usually under /training-outputs/...) "
216
+ "or HF repo of the LoRA adapter to evaluate. "
217
+ "If omitted, evaluates the base model only.")
218
+ sp.add_argument("--lora-name", default="incident_lora",
219
+ help="Served model name used to address the LoRA in vLLM.")
220
+ sp.add_argument("--seeds", type=int, default=30)
221
+ sp.add_argument(
222
+ "--tasks",
223
+ nargs="+",
224
+ default=["memory_leak", "cascading_failure", "distributed_deadlock"],
225
+ )
226
+ sp.add_argument("--temperature", type=float, default=0.0)
227
+ sp.add_argument("--max-turns", type=int, default=20)
228
+ sp.add_argument("--reward-mode", default="long_horizon_v2",
229
+ choices=["legacy", "long_horizon_v2"])
230
+ sp.add_argument("--incident-repo-ref", default=None)
231
+
232
+ # Output persistence
233
+ sp.add_argument(
234
+ "--hf-repo",
235
+ default=None,
236
+ help="HF repo to mount at /training-outputs (writes eval_reports/<tag>.json). "
237
+ "Usually the same repo used for training checkpoints.",
238
+ )
239
+ sp.add_argument(
240
+ "--hf-repo-type",
241
+ default="dataset",
242
+ choices=["dataset", "model", "bucket"],
243
+ )
244
+
245
+
246
+ def main() -> int:
247
+ ap = argparse.ArgumentParser(description=__doc__,
248
+ formatter_class=argparse.RawDescriptionHelpFormatter)
249
+ sub = ap.add_subparsers(dest="cmd", required=True)
250
+
251
+ sp_create = sub.add_parser("create", help="Create a scheduled eval job")
252
+ _add_create_args(sp_create)
253
+ sp_create.set_defaults(func=cmd_create)
254
+
255
+ sub.add_parser("list", help="List scheduled jobs").set_defaults(func=cmd_list)
256
+
257
+ for name, fn in [
258
+ ("inspect", cmd_inspect),
259
+ ("pause", cmd_pause),
260
+ ("resume", cmd_resume),
261
+ ("delete", cmd_delete),
262
+ ]:
263
+ p = sub.add_parser(name, help=f"{name} a scheduled job")
264
+ p.add_argument("scheduled_id")
265
+ p.set_defaults(func=fn)
266
+
267
+ args = ap.parse_args()
268
+ return args.func(args)
269
+
270
+
271
+ if __name__ == "__main__":
272
+ sys.exit(main())
reward_manager/episode.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2025 Nanyang Technological University (NTU), Singapore
2
+ # and the verl-agent (GiGPO) team.
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ """
17
+ Episode-level reward assignment for RL training on **incident_env**.
18
+
19
+ Rollout collation should attach episode summaries in ``non_tensor_batch`` that
20
+ mirror the HTTP env (see ``server/incident_environment.py`` / AGENTS.md):
21
+
22
+ - ``episode_rewards`` (float): cumulative **oracle-shaped** training reward
23
+ (sum of per-step rewards from ``_compute_reward()``).
24
+ - ``episode_lengths`` (int): number of agent steps in the episode (same idea
25
+ as ``step_count`` / ``info["steps_taken"]`` on the final step).
26
+ - ``data_source`` **or** ``task_name``: incident task id — ``memory_leak``,
27
+ ``cascading_failure``, or ``distributed_deadlock`` (see ``tasks.py``).
28
+
29
+ Evaluation **grader** scores from ``BaseScenario.grade()`` live in rollout
30
+ metadata separately; this manager uses training episode return by default.
31
+
32
+ If ``rm_scores`` is already present in ``batch``, it is returned unchanged
33
+ (optional override from an external reward model).
34
+
35
+ ``data`` may be verl's ``DataProto`` when installed; any batched object with
36
+ ``__len__``, ``__getitem__``, ``.batch``, and ``.non_tensor_batch`` matching
37
+ the above keys works.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import random
43
+ from typing import Any, Dict, Union
44
+
45
+ try:
46
+ import torch
47
+ except ImportError: # pragma: no cover - training stacks normally ship torch
48
+ torch = None # type: ignore[misc, assignment]
49
+
50
+
51
+ def _as_float(x: Any) -> float:
52
+ if isinstance(x, (float, int)):
53
+ return float(x)
54
+ if hasattr(x, "item"):
55
+ return float(x.item())
56
+ return float(x)
57
+
58
+
59
+ def _as_int(x: Any) -> int:
60
+ if isinstance(x, int):
61
+ return x
62
+ if isinstance(x, float):
63
+ return int(x)
64
+ if hasattr(x, "item"):
65
+ return int(x.item())
66
+ return int(x)
67
+
68
+
69
+ def _resolve_task_label(non_tensor_batch: Dict[str, Any]) -> str:
70
+ if "data_source" in non_tensor_batch:
71
+ return str(non_tensor_batch["data_source"])
72
+ if "task_name" in non_tensor_batch:
73
+ return str(non_tensor_batch["task_name"])
74
+ return "unknown"
75
+
76
+
77
+ def _episode_score(
78
+ episode_rewards: Any,
79
+ episode_lengths: Any,
80
+ normalize_by_length: bool,
81
+ ) -> float:
82
+ r = _as_float(episode_rewards)
83
+ if normalize_by_length:
84
+ length = max(1, _as_int(episode_lengths))
85
+ return r / length
86
+ return r
87
+
88
+
89
+ class EpisodeRewardManager:
90
+ """Maps incident episode return onto the last valid response token for RL."""
91
+
92
+ def __init__(
93
+ self,
94
+ tokenizer: Any,
95
+ num_examine: int,
96
+ normalize_by_length: bool = False,
97
+ ) -> None:
98
+ self.tokenizer = tokenizer
99
+ self.num_examine = num_examine
100
+ self.normalize_by_length = normalize_by_length
101
+
102
+ def __call__(
103
+ self,
104
+ data: Any,
105
+ return_dict: bool = False,
106
+ ) -> Union["torch.Tensor", Dict[str, Any]]:
107
+ if torch is None:
108
+ raise ImportError(
109
+ "EpisodeRewardManager requires `torch` (e.g. pip install torch)."
110
+ )
111
+
112
+ # If there is rm score, we directly return rm score.
113
+ batch_keys = data.batch.keys()
114
+ if "rm_scores" in batch_keys:
115
+ if return_dict:
116
+ return {"reward_tensor": data.batch["rm_scores"]}
117
+ return data.batch["rm_scores"]
118
+
119
+ reward_tensor = torch.zeros_like(
120
+ data.batch["responses"], dtype=torch.float32
121
+ )
122
+
123
+ already_print_data_sources: Dict[str, int] = {}
124
+ reward_extra_info: Dict[str, list] = {
125
+ "task_name": [],
126
+ "episode_return": [],
127
+ "episode_length": [],
128
+ }
129
+
130
+ for i in range(len(data)):
131
+ data_item = data[i]
132
+
133
+ prompt_ids = data_item.batch["prompts"]
134
+ prompt_length = prompt_ids.shape[-1]
135
+
136
+ valid_prompt_length = int(
137
+ data_item.batch["attention_mask"][:prompt_length].sum().item()
138
+ )
139
+ valid_prompt_ids = prompt_ids[-valid_prompt_length:]
140
+
141
+ response_ids = data_item.batch["responses"]
142
+ valid_response_length = int(
143
+ data_item.batch["attention_mask"][prompt_length:].sum().item()
144
+ )
145
+ valid_response_ids = response_ids[:valid_response_length]
146
+
147
+ prompt_str = self.tokenizer.decode(
148
+ valid_prompt_ids, skip_special_tokens=False
149
+ )
150
+ response_str = self.tokenizer.decode(
151
+ valid_response_ids, skip_special_tokens=False
152
+ )
153
+
154
+ ntb = data_item.non_tensor_batch
155
+ data_source = _resolve_task_label(ntb)
156
+
157
+ episode_rewards = ntb["episode_rewards"]
158
+ episode_lengths = ntb["episode_lengths"]
159
+ score = _episode_score(
160
+ episode_rewards, episode_lengths, self.normalize_by_length
161
+ )
162
+
163
+ last_tok = max(0, valid_response_length - 1)
164
+ reward_tensor[i, last_tok] = torch.tensor(
165
+ score, dtype=torch.float32, device=prompt_ids.device
166
+ )
167
+
168
+ reward_extra_info["task_name"].append(data_source)
169
+ reward_extra_info["episode_return"].append(_as_float(episode_rewards))
170
+ reward_extra_info["episode_length"].append(_as_int(episode_lengths))
171
+
172
+ if data_source not in already_print_data_sources:
173
+ already_print_data_sources[data_source] = 0
174
+
175
+ if (
176
+ already_print_data_sources[data_source] < self.num_examine
177
+ and random.random() < 0.1
178
+ ):
179
+ already_print_data_sources[data_source] += 1
180
+ print(f"[{data_source}][prompt]", prompt_str)
181
+ print(f"[{data_source}][response]", response_str)
182
+ print(f"[{data_source}][score]", score)
183
+
184
+ if return_dict:
185
+ return {
186
+ "reward_tensor": reward_tensor,
187
+ "reward_extra_info": reward_extra_info,
188
+ }
189
+ return reward_tensor
reward_manager/long_horizon_reward.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Long-horizon reward design for incident_env training.
3
+
4
+ This module is a drop-in, more extensive alternative to the current
5
+ `IncidentEnvironment._compute_reward()` shaping. It keeps the same action space
6
+ and scenario contract while adding:
7
+
8
+ - progressive time pressure
9
+ - novelty / anti-thrashing incentives
10
+ - alert and service-health progress shaping
11
+ - phase-aware milestone bonuses
12
+ - terminal quality bonus based on restoration + time efficiency
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass, field
18
+ from typing import Any, Dict, Optional, Set, Tuple
19
+
20
+ from models import ActionType, DIAGNOSTIC_ACTIONS, IncidentAction, REMEDIATION_ACTIONS
21
+
22
+
23
+ @dataclass
24
+ class LongHorizonRewardConfig:
25
+ # Step pressure
26
+ base_step_penalty: float = 0.01
27
+ late_step_penalty: float = 0.04
28
+
29
+ # Action quality
30
+ invalid_action_penalty: float = 0.08
31
+ repeat_action_penalty: float = 0.06
32
+ novelty_bonus: float = 0.03
33
+ oscillation_penalty: float = 0.03
34
+
35
+ # Investigation
36
+ diagnostic_relevant_bonus: float = 0.10
37
+ diagnostic_irrelevant_bonus: float = 0.02
38
+ first_view_alerts_bonus: float = 0.08
39
+
40
+ # Remediation
41
+ remediation_root_bonus: float = 0.20
42
+ remediation_related_bonus: float = 0.08
43
+ remediation_wrong_penalty: float = 0.14
44
+ first_correct_remediation_bonus: float = 0.10
45
+
46
+ # Root cause declaration
47
+ declare_correct_bonus: float = 0.30
48
+ declare_partial_bonus: float = 0.12
49
+ declare_wrong_penalty: float = 0.20
50
+
51
+ # Progress shaping (state deltas)
52
+ healthy_service_delta_weight: float = 0.35
53
+ alert_delta_weight: float = 0.20
54
+ potential_weight: float = 0.25
55
+
56
+ # Terminal shaping
57
+ terminal_full_restore_bonus: float = 0.35
58
+ terminal_partial_restore_scale: float = 0.20
59
+ terminal_no_declaration_penalty: float = 0.20
60
+ terminal_over_budget_penalty: float = 0.12
61
+
62
+ # Output clipping
63
+ min_step_reward: float = -0.60
64
+ max_step_reward: float = 0.80
65
+
66
+
67
+ @dataclass
68
+ class EpisodeRewardState:
69
+ seen_action_targets: Set[Tuple[str, Optional[str]]] = field(default_factory=set)
70
+ saw_view_alerts: bool = False
71
+ first_correct_remediation_awarded: bool = False
72
+ previous_action: Optional[Tuple[str, Optional[str]]] = None
73
+ max_alerts_seen: int = 1
74
+
75
+
76
+ @dataclass
77
+ class RewardBreakdown:
78
+ total: float
79
+ components: Dict[str, float]
80
+
81
+
82
+ def _keyword_match_ratio(declared_text: str, keywords: list[str]) -> float:
83
+ if not keywords:
84
+ return 0.0
85
+ declared = (declared_text or "").lower()
86
+ matched = sum(1 for kw in keywords if kw.lower() in declared)
87
+ return matched / max(1, len(keywords))
88
+
89
+
90
+ def _health_ratio(obs: Dict[str, Any]) -> float:
91
+ statuses = obs.get("service_statuses", {}) or {}
92
+ if not statuses:
93
+ return 0.0
94
+ healthy = sum(1 for s in statuses.values() if s == "healthy")
95
+ return healthy / max(1, len(statuses))
96
+
97
+
98
+ def _alerts_count(obs: Dict[str, Any]) -> int:
99
+ return int(obs.get("active_alerts_count", 0) or 0)
100
+
101
+
102
+ class LongHorizonRewardEngine:
103
+ """Stateful per-episode reward engine."""
104
+
105
+ def __init__(self, config: Optional[LongHorizonRewardConfig] = None) -> None:
106
+ self.cfg = config or LongHorizonRewardConfig()
107
+ self.state = EpisodeRewardState()
108
+
109
+ def reset(self) -> None:
110
+ self.state = EpisodeRewardState()
111
+
112
+ def compute(
113
+ self,
114
+ *,
115
+ action: IncidentAction,
116
+ scenario: Any,
117
+ prev_obs: Dict[str, Any],
118
+ curr_obs: Dict[str, Any],
119
+ is_valid_action: bool,
120
+ is_repeat: bool,
121
+ root_cause_declared: bool,
122
+ episode_done: bool,
123
+ step_count: int,
124
+ max_steps: int,
125
+ time_elapsed_minutes: int,
126
+ time_budget_minutes: int,
127
+ ) -> RewardBreakdown:
128
+ c: Dict[str, float] = {}
129
+ at = action.parsed_type()
130
+ target = action.target_service
131
+ pair = (action.action_type, target)
132
+
133
+ progress = min(1.0, max(0.0, step_count / max(1, max_steps)))
134
+ c["step_penalty"] = -(
135
+ self.cfg.base_step_penalty
136
+ + (self.cfg.late_step_penalty - self.cfg.base_step_penalty) * progress
137
+ )
138
+
139
+ if not is_valid_action:
140
+ c["invalid_action"] = -self.cfg.invalid_action_penalty
141
+ total = sum(c.values())
142
+ total = max(self.cfg.min_step_reward, min(self.cfg.max_step_reward, total))
143
+ return RewardBreakdown(total=round(total, 3), components=c)
144
+
145
+ if is_repeat:
146
+ c["repeat_action"] = -self.cfg.repeat_action_penalty
147
+ elif pair not in self.state.seen_action_targets:
148
+ c["novelty"] = self.cfg.novelty_bonus
149
+ self.state.seen_action_targets.add(pair)
150
+
151
+ # Investigation / remediation / declaration
152
+ if at in DIAGNOSTIC_ACTIONS:
153
+ if at == ActionType.VIEW_ALERTS and not self.state.saw_view_alerts:
154
+ c["first_view_alerts"] = self.cfg.first_view_alerts_bonus
155
+ self.state.saw_view_alerts = True
156
+ if target and target in scenario.involved_services:
157
+ c["diagnostic_relevant"] = self.cfg.diagnostic_relevant_bonus
158
+ elif target and target not in scenario.involved_services:
159
+ c["diagnostic_irrelevant"] = self.cfg.diagnostic_irrelevant_bonus
160
+
161
+ elif at in REMEDIATION_ACTIONS:
162
+ if target == scenario.root_cause_service:
163
+ c["remediation_root"] = self.cfg.remediation_root_bonus
164
+ if not self.state.first_correct_remediation_awarded:
165
+ c["first_correct_remediation"] = self.cfg.first_correct_remediation_bonus
166
+ self.state.first_correct_remediation_awarded = True
167
+ elif target and target in scenario.involved_services:
168
+ c["remediation_related"] = self.cfg.remediation_related_bonus
169
+ else:
170
+ c["remediation_wrong"] = -self.cfg.remediation_wrong_penalty
171
+
172
+ elif at == ActionType.DECLARE_ROOT_CAUSE:
173
+ ratio = _keyword_match_ratio(
174
+ action.parameters.get("root_cause", ""),
175
+ list(scenario.root_cause_keywords),
176
+ )
177
+ if ratio >= 0.6:
178
+ c["declare_correct"] = self.cfg.declare_correct_bonus
179
+ elif ratio >= 0.3:
180
+ c["declare_partial"] = self.cfg.declare_partial_bonus
181
+ else:
182
+ c["declare_wrong"] = -self.cfg.declare_wrong_penalty
183
+
184
+ # Anti-thrashing: alternating remediation targets often hurts long-horizon stability.
185
+ if self.state.previous_action is not None:
186
+ prev_at, prev_target = self.state.previous_action
187
+ if (
188
+ prev_at in {a.value for a in REMEDIATION_ACTIONS}
189
+ and action.action_type in {a.value for a in REMEDIATION_ACTIONS}
190
+ and prev_target != target
191
+ ):
192
+ c["oscillation"] = -self.cfg.oscillation_penalty
193
+ self.state.previous_action = pair
194
+
195
+ # Progress shaping from observation deltas
196
+ prev_h, curr_h = _health_ratio(prev_obs), _health_ratio(curr_obs)
197
+ c["healthy_delta"] = (curr_h - prev_h) * self.cfg.healthy_service_delta_weight
198
+
199
+ prev_alerts = _alerts_count(prev_obs)
200
+ curr_alerts = _alerts_count(curr_obs)
201
+ self.state.max_alerts_seen = max(self.state.max_alerts_seen, prev_alerts, curr_alerts, 1)
202
+ c["alert_delta"] = (
203
+ (prev_alerts - curr_alerts) / float(self.state.max_alerts_seen)
204
+ ) * self.cfg.alert_delta_weight
205
+
206
+ # Potential-based shaping to stabilize long-horizon learning
207
+ prev_phi = prev_h + (1.0 - min(1.0, prev_alerts / float(self.state.max_alerts_seen)))
208
+ curr_phi = curr_h + (1.0 - min(1.0, curr_alerts / float(self.state.max_alerts_seen)))
209
+ c["potential"] = (curr_phi - prev_phi) * self.cfg.potential_weight
210
+
211
+ # Terminal shaping
212
+ if episode_done:
213
+ if curr_h >= 0.999:
214
+ c["terminal_restore"] = self.cfg.terminal_full_restore_bonus
215
+ else:
216
+ c["terminal_partial_restore"] = curr_h * self.cfg.terminal_partial_restore_scale
217
+ if not root_cause_declared:
218
+ c["terminal_no_declaration"] = -self.cfg.terminal_no_declaration_penalty
219
+ if time_elapsed_minutes > time_budget_minutes:
220
+ c["terminal_over_budget"] = -self.cfg.terminal_over_budget_penalty
221
+
222
+ total = sum(c.values())
223
+ total = max(self.cfg.min_step_reward, min(self.cfg.max_step_reward, total))
224
+ return RewardBreakdown(total=round(total, 3), components={k: round(v, 4) for k, v in c.items()})
225
+