Arijit-07 commited on
Commit
4887b5f
·
1 Parent(s): 849b14a

feat: ARIA Incident Generator — procedural incidents from seeds

Browse files
api.py CHANGED
@@ -4,14 +4,20 @@ from fastapi.responses import HTMLResponse
4
  from fastapi.middleware.cors import CORSMiddleware
5
  from pydantic import BaseModel
6
  from typing import Optional
 
7
  from env import DevOpsIncidentEnv
8
  from models import Action, ActionType, Observation, StepResult, State
9
  from collections import deque
10
  from datetime import datetime
11
  import uuid
12
  import statistics
 
 
13
 
 
 
14
  episode_history = deque(maxlen=1000)
 
15
 
16
  def track_episode(state_obj: State):
17
  from graders.grader import grade_episode
@@ -56,8 +62,9 @@ app = FastAPI(
56
  description=(
57
  "An OpenEnv-compliant RL environment where AI agents diagnose and remediate "
58
  "production software incidents across a simulated microservices architecture. "
59
- "Four tasks: easy (OOM), medium (cascade), hard (silent corruption), "
60
- "bonus (dual simultaneous failure)."
 
61
  ),
62
  version="1.0.0",
63
  )
@@ -78,6 +85,11 @@ class ResetRequest(BaseModel):
78
  seed: Optional[int] = None
79
 
80
 
 
 
 
 
 
81
  @app.get("/", response_class=HTMLResponse)
82
  def dashboard():
83
  env_state = None
@@ -134,6 +146,29 @@ def dashboard():
134
  .task {{ background: #1a1d27; border: 1px solid #2d3148; border-radius: 8px; padding: 1.25rem; }}
135
  .task h3 {{ margin: 0 0 0.5rem; color: #ff6b35; font-size: 1rem; }}
136
  .task p {{ margin: 0; color: #aaa; font-size: 0.85rem; line-height: 1.5; }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  .badge {{ display: inline-block; padding: 0.2rem 0.6rem; border-radius: 4px; font-size: 0.7rem; font-weight: 600; margin-bottom: 0.5rem; }}
138
  .easy {{ background: #1a3a1a; color: #4caf50; }}
139
  .medium {{ background: #3a2a1a; color: #ff9800; }}
@@ -149,6 +184,12 @@ def dashboard():
149
  .path {{ color: #81c784; font-family: monospace; font-size: 0.85rem; }}
150
  .desc {{ color: #888; font-size: 0.8rem; }}
151
  .footer {{ color: #555; font-size: 0.8rem; text-align: center; margin-top: 2rem; }}
 
 
 
 
 
 
152
  </style>
153
  </head>
154
  <body>
@@ -196,6 +237,41 @@ def dashboard():
196
  <p>Partial region failure. Discriminate between services that support auto-failover and those that require human escalation. Max 25 steps.</p>
197
  </div>
198
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
 
200
  <div class="endpoints">
201
  <h3>API Endpoints</h3>
@@ -222,7 +298,7 @@ def dashboard():
222
  <div class="endpoint">
223
  <span class="method">GET</span>
224
  <span class="path">/validate</span>
225
- <span class="desc">Self-validation report for all 4 tasks</span>
226
  </div>
227
  <div class="endpoint">
228
  <span class="method">GET</span>
@@ -239,6 +315,118 @@ def dashboard():
239
  <a href="/metrics" style="color:#ff6b35;">Metrics</a> &nbsp;|&nbsp;
240
  <a href="/leaderboard" style="color:#ff6b35;">Leaderboard</a>
241
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  </body>
243
  </html>"""
244
  return html
@@ -249,18 +437,38 @@ def health():
249
  return {"status": "ok", "env": "devops-incident-response", "version": "1.0.0"}
250
 
251
 
 
 
 
 
 
252
  @app.post("/reset", response_model=Observation)
253
  def reset(req: Optional[ResetRequest] = None):
254
  if req is None:
255
  req = ResetRequest()
256
  global _env
257
- if req.task_id not in VALID_TASKS:
258
- raise HTTPException(
259
- status_code=400,
260
- detail=f"task_id must be one of {VALID_TASKS}. Got: {req.task_id}",
261
- )
262
- _env = DevOpsIncidentEnv(task_id=req.task_id, seed=req.seed)
263
- return _env.reset()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
 
265
 
266
  @app.post("/step", response_model=StepResult)
@@ -269,7 +477,21 @@ def step(action: Action):
269
  raise HTTPException(status_code=400, detail="Call /reset before /step")
270
  res = _env.step(action)
271
  if res.done:
272
- track_episode(_env.state())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
  return res
274
 
275
 
@@ -352,10 +574,55 @@ def list_tasks():
352
  "and which do not. Failing over the wrong services causes severe data inconsistency penalties."
353
  ),
354
  },
 
 
 
 
 
 
 
355
  ]
356
  }
357
 
358
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
359
  @app.get("/validate")
360
  def validate():
361
  """
 
4
  from fastapi.middleware.cors import CORSMiddleware
5
  from pydantic import BaseModel
6
  from typing import Optional
7
+ from curriculum import CurriculumEngine
8
  from env import DevOpsIncidentEnv
9
  from models import Action, ActionType, Observation, StepResult, State
10
  from collections import deque
11
  from datetime import datetime
12
  import uuid
13
  import statistics
14
+ from generator import IncidentFactory
15
+ from tasks.task_generated import GeneratedTask
16
 
17
+ curriculum_engine = CurriculumEngine()
18
+ episode_tracker: dict = {} # tracks active episode task_id per session
19
  episode_history = deque(maxlen=1000)
20
+ _factory = IncidentFactory()
21
 
22
  def track_episode(state_obj: State):
23
  from graders.grader import grade_episode
 
62
  description=(
63
  "An OpenEnv-compliant RL environment where AI agents diagnose and remediate "
64
  "production software incidents across a simulated microservices architecture. "
65
+ "Seven tasks of escalating difficulty: OOM crash-loop, cascading failure, "
66
+ "silent data corruption, dual simultaneous failure, DDoS attack, database "
67
+ "degradation, and multi-region failover."
68
  ),
69
  version="1.0.0",
70
  )
 
85
  seed: Optional[int] = None
86
 
87
 
88
+ class CurriculumRecordRequest(BaseModel):
89
+ task_id: str
90
+ score: float
91
+
92
+
93
  @app.get("/", response_class=HTMLResponse)
94
  def dashboard():
95
  env_state = None
 
146
  .task {{ background: #1a1d27; border: 1px solid #2d3148; border-radius: 8px; padding: 1.25rem; }}
147
  .task h3 {{ margin: 0 0 0.5rem; color: #ff6b35; font-size: 1rem; }}
148
  .task p {{ margin: 0; color: #aaa; font-size: 0.85rem; line-height: 1.5; }}
149
+ .curriculum-section {{ background: #121621; border: 1px solid #2d3148; border-radius: 10px; padding: 1.25rem; margin-bottom: 2rem; }}
150
+ .curriculum-section h3 {{ margin: 0 0 0.35rem; color: #fff; }}
151
+ .curriculum-section p {{ margin: 0 0 1rem; color: #7f8799; font-size: 0.85rem; }}
152
+ .curriculum-meta {{ color: #9aa4b2; font-size: 0.8rem; margin-bottom: 0.75rem; }}
153
+ .curriculum-table {{ display: flex; flex-direction: column; gap: 0.65rem; }}
154
+ .curriculum-header, .curriculum-row {{ display: grid; grid-template-columns: minmax(90px, 1fr) 110px minmax(180px, 1.4fr) minmax(160px, 1fr); gap: 1rem; align-items: center; }}
155
+ .curriculum-header {{ color: #7f8799; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.08em; padding-bottom: 0.35rem; border-bottom: 1px solid #273042; }}
156
+ .curriculum-row {{ background: #171c29; border: 1px solid #242d40; border-radius: 8px; padding: 0.9rem 1rem; }}
157
+ .curriculum-task-name {{ font-weight: 600; color: #f5f7fa; text-transform: capitalize; }}
158
+ .curriculum-stars {{ font-size: 1rem; letter-spacing: 0.12em; color: #ffd166; }}
159
+ .curriculum-bar-wrap {{ display: flex; align-items: center; gap: 0.75rem; }}
160
+ .curriculum-bar {{ flex: 1; height: 0.7rem; background: #222a3b; border-radius: 999px; overflow: hidden; border: 1px solid #303a4f; }}
161
+ .curriculum-fill {{ height: 100%; border-radius: 999px; transition: width 0.3s ease; }}
162
+ .curriculum-score {{ min-width: 3rem; text-align: right; color: #d5dbe3; font-size: 0.8rem; font-variant-numeric: tabular-nums; }}
163
+ .curriculum-badges {{ display: flex; gap: 0.5rem; flex-wrap: wrap; justify-content: flex-start; }}
164
+ .curriculum-pill {{ display: inline-block; padding: 0.25rem 0.6rem; border-radius: 999px; font-size: 0.7rem; font-weight: 700; letter-spacing: 0.04em; }}
165
+ .scaffold-pill {{ background: rgba(239, 83, 80, 0.18); color: #ff8a80; border: 1px solid rgba(239, 83, 80, 0.35); }}
166
+ .recommended-pill {{ background: rgba(102, 187, 106, 0.18); color: #8de28f; border: 1px solid rgba(102, 187, 106, 0.35); }}
167
+ .curriculum-loading, .curriculum-error {{ color: #9aa4b2; font-size: 0.9rem; padding: 0.25rem 0; }}
168
+ @media (max-width: 900px) {{
169
+ .curriculum-header {{ display: none; }}
170
+ .curriculum-row {{ grid-template-columns: 1fr; gap: 0.7rem; }}
171
+ }}
172
  .badge {{ display: inline-block; padding: 0.2rem 0.6rem; border-radius: 4px; font-size: 0.7rem; font-weight: 600; margin-bottom: 0.5rem; }}
173
  .easy {{ background: #1a3a1a; color: #4caf50; }}
174
  .medium {{ background: #3a2a1a; color: #ff9800; }}
 
184
  .path {{ color: #81c784; font-family: monospace; font-size: 0.85rem; }}
185
  .desc {{ color: #888; font-size: 0.8rem; }}
186
  .footer {{ color: #555; font-size: 0.8rem; text-align: center; margin-top: 2rem; }}
187
+ #incident-generator {{ background: #1a1d27; border: 1px solid #2d3148; border-radius: 8px; padding: 1.5rem; margin-bottom: 2rem; }}
188
+ #incident-result {{ margin-top: 1.5rem; padding: 1rem; background: #0f1117; border-radius: 6px; border-left: 4px solid #ff6b35; }}
189
+ .badge-mono {{ font-family: monospace; background: #333; padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.85rem; }}
190
+ .difficulty-bar-container {{ height: 8px; background: #333; border-radius: 4px; margin: 10px 0; }}
191
+ .difficulty-bar {{ height: 100%; border-radius: 4px; transition: width 0.5s ease; }}
192
+ .tag {{ font-size: 0.75rem; color: #888; background: #222; padding: 0.1rem 0.4rem; border-radius: 10px; margin-right: 5px; }}
193
  </style>
194
  </head>
195
  <body>
 
237
  <p>Partial region failure. Discriminate between services that support auto-failover and those that require human escalation. Max 25 steps.</p>
238
  </div>
239
  </div>
240
+
241
+ <div id="incident-generator">
242
+ <h3 style="margin-top:0; color:#ff6b35;">ARIA Incident Generator</h3>
243
+ <div style="display:flex; gap:10px; align-items:center;">
244
+ <input type="number" id="seed-input" min="0" max="99999" value="42"
245
+ style="background:#0f1117; color:#fff; border:1px solid #2d3148; padding:8px; border-radius:4px; width:100px;">
246
+ <button onclick="generateIncident()"
247
+ style="background:#ff6b35; color:#fff; border:none; padding:8px 16px; border-radius:4px; cursor:pointer; font-weight:600;">
248
+ Generate Incident
249
+ </button>
250
+ </div>
251
+ <div id="incident-result" style="display:none;">
252
+ <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
253
+ <span id="res-id" class="badge-mono"></span>
254
+ <div id="res-badges"></div>
255
+ </div>
256
+ <p id="res-affected" style="margin:5px 0; font-weight:600;"></p>
257
+ <div class="difficulty-bar-container">
258
+ <div id="res-diff-bar" class="difficulty-bar"></div>
259
+ </div>
260
+ <p id="res-desc" style="margin:10px 0; line-height:1.4; color:#ccc;"></p>
261
+ <div id="res-noise" style="margin-bottom:15px;"></div>
262
+ <p style="font-size:0.75rem; color:#555; font-family:monospace; margin:0;">
263
+ Use with: POST /reset body {{"task_id":"generated","seed":<span id="res-seed-val"></span>}}
264
+ </p>
265
+ </div>
266
+ </div>
267
+
268
+ <div class="curriculum-section">
269
+ <h3>ARIA Curriculum Status</h3>
270
+ <p>Live mastery tracking across the seven incident types.</p>
271
+ <div id="curriculum-status">
272
+ <div class="curriculum-loading">Loading curriculum status...</div>
273
+ </div>
274
+ </div>
275
 
276
  <div class="endpoints">
277
  <h3>API Endpoints</h3>
 
298
  <div class="endpoint">
299
  <span class="method">GET</span>
300
  <span class="path">/validate</span>
301
+ <span class="desc">Self-validation report for all 7 tasks</span>
302
  </div>
303
  <div class="endpoint">
304
  <span class="method">GET</span>
 
315
  <a href="/metrics" style="color:#ff6b35;">Metrics</a> &nbsp;|&nbsp;
316
  <a href="/leaderboard" style="color:#ff6b35;">Leaderboard</a>
317
  </div>
318
+ <script>
319
+ const curriculumContainer = document.getElementById("curriculum-status");
320
+
321
+ function masteryStars(level) {{
322
+ return "★".repeat(level) + "☆".repeat(3 - level);
323
+ }}
324
+
325
+ function avgColor(avg) {{
326
+ if (avg < 0.3) {{
327
+ return "#ef5350";
328
+ }}
329
+ if (avg < 0.6) {{
330
+ return "#ffd54f";
331
+ }}
332
+ return "#66bb6a";
333
+ }}
334
+
335
+ function renderCurriculumStatus(payload) {{
336
+ const tasks = payload.tasks || {{}};
337
+ const recommendedTask = payload.recommended_task;
338
+ const rows = Object.entries(tasks).map(([taskId, task]) => {{
339
+ const avg = Number(task.rolling_avg || 0);
340
+ const width = Math.max(0, Math.min(100, avg * 100));
341
+ const badges = [];
342
+ if (task.scaffold_needed) {{
343
+ badges.push('<span class="curriculum-pill scaffold-pill">SCAFFOLD</span>');
344
+ }}
345
+ if (taskId === recommendedTask) {{
346
+ badges.push('<span class="curriculum-pill recommended-pill">RECOMMENDED</span>');
347
+ }}
348
+
349
+ return `
350
+ <div class="curriculum-row">
351
+ <div class="curriculum-task-name">${{taskId}}</div>
352
+ <div class="curriculum-stars" title="${{task.mastery_label}}">${{masteryStars(task.mastery_level)}}</div>
353
+ <div class="curriculum-bar-wrap">
354
+ <div class="curriculum-bar">
355
+ <div class="curriculum-fill" style="width:${{width}}%; background:${{avgColor(avg)}};"></div>
356
+ </div>
357
+ <div class="curriculum-score">${{avg.toFixed(2)}}</div>
358
+ </div>
359
+ <div class="curriculum-badges">${{badges.join("")}}</div>
360
+ </div>
361
+ `;
362
+ }}).join("");
363
+
364
+ curriculumContainer.innerHTML = `
365
+ <div class="curriculum-meta">Total episodes recorded: ${{payload.total_episodes_recorded}}</div>
366
+ <div class="curriculum-table">
367
+ <div class="curriculum-header">
368
+ <div>Task</div>
369
+ <div>Mastery</div>
370
+ <div>Rolling Avg</div>
371
+ <div>Status</div>
372
+ </div>
373
+ ${{rows}}
374
+ </div>
375
+ `;
376
+ }}
377
+
378
+ async function refreshCurriculumStatus() {{
379
+ try {{
380
+ const response = await fetch("/curriculum/status");
381
+ if (!response.ok) {{
382
+ throw new Error("Failed to load curriculum status");
383
+ }}
384
+ const payload = await response.json();
385
+ renderCurriculumStatus(payload);
386
+ }} catch (error) {{
387
+ curriculumContainer.innerHTML = '<div class="curriculum-error">Curriculum status unavailable.</div>';
388
+ }}
389
+ }}
390
+
391
+ refreshCurriculumStatus();
392
+ setInterval(refreshCurriculumStatus, 15000);
393
+
394
+ function generateIncident() {{
395
+ const seed = document.getElementById('seed-input').value;
396
+ fetch(`/generate/preview?seed=${{seed}}`)
397
+ .then(r => r.json())
398
+ .then(data => {{
399
+ document.getElementById('res-id').innerText = data.incident_id;
400
+ document.getElementById('res-seed-val').innerText = seed;
401
+ document.getElementById('res-affected').innerText = "Affected: " + data.affected_service;
402
+ document.getElementById('res-desc').innerText = data.description;
403
+
404
+ const modeColors = {{
405
+ oom: "#f44336", cascade: "#ff9800", corruption: "#9c27b0",
406
+ security: "#ffeb3b", database: "#4fc3f7", network_partition: "#009688"
407
+ }};
408
+ const sevColors = {{ sev1: "#f44336", sev2: "#ff9800", sev3: "#ffeb3b" }};
409
+
410
+ document.getElementById('res-badges').innerHTML = `
411
+ <span class="badge" style="background:${{modeColors[data.failure_mode]}}; color:#000; margin-right:5px;">${{data.failure_mode.toUpperCase()}}</span>
412
+ <span class="badge" style="background:${{sevColors[data.severity]}}; color:#000;">${{data.severity.toUpperCase()}}</span>
413
+ `;
414
+
415
+ const diffBar = document.getElementById('res-diff-bar');
416
+ diffBar.style.width = (data.difficulty_score * 100) + "%";
417
+ diffBar.style.background = avgColor(data.difficulty_score);
418
+
419
+ const noiseDiv = document.getElementById('res-noise');
420
+ if (data.noise_alerts.length === 0) {{
421
+ noiseDiv.innerHTML = '<span style="font-size:0.8rem; color:#555;">No noise alerts</span>';
422
+ }} else {{
423
+ noiseDiv.innerHTML = data.noise_alerts.map(n => `<span class="tag">${{n}}</span>`).join("");
424
+ }}
425
+
426
+ document.getElementById('incident-result').style.display = 'block';
427
+ }});
428
+ }}
429
+ </script>
430
  </body>
431
  </html>"""
432
  return html
 
437
  return {"status": "ok", "env": "devops-incident-response", "version": "1.0.0"}
438
 
439
 
440
+ @app.get("/generate/preview")
441
+ def preview_incident(seed: int = 42):
442
+ return _factory.generate(seed)
443
+
444
+
445
  @app.post("/reset", response_model=Observation)
446
  def reset(req: Optional[ResetRequest] = None):
447
  if req is None:
448
  req = ResetRequest()
449
  global _env
450
+
451
+ if req.task_id == "generated":
452
+ seed = req.seed if req.seed is not None else 42
453
+ incident = _factory.generate(seed)
454
+ task = GeneratedTask(incident_dict=incident)
455
+ state = task.initialize()
456
+ _env = DevOpsIncidentEnv(task_id="easy", seed=seed)
457
+ _env.task_id = "generated"
458
+ _env._task = task
459
+ _env._internal_state = state
460
+ observation = state._build_observation()
461
+ else:
462
+ if req.task_id not in VALID_TASKS:
463
+ raise HTTPException(
464
+ status_code=400,
465
+ detail=f"task_id must be one of {VALID_TASKS}. Got: {req.task_id}",
466
+ )
467
+ _env = DevOpsIncidentEnv(task_id=req.task_id, seed=req.seed)
468
+ observation = _env.reset()
469
+
470
+ episode_tracker[_env.state().episode_id] = req.task_id
471
+ return observation
472
 
473
 
474
  @app.post("/step", response_model=StepResult)
 
477
  raise HTTPException(status_code=400, detail="Call /reset before /step")
478
  res = _env.step(action)
479
  if res.done:
480
+ from graders.grader import grade_episode
481
+
482
+ current_state = _env.state()
483
+ current_task_id = episode_tracker.get(current_state.episode_id, current_state.task_id)
484
+ final_score = grade_episode(
485
+ task_id=current_task_id,
486
+ action_history=current_state.action_history,
487
+ ground_truth_root_cause=current_state.ground_truth_root_cause,
488
+ ground_truth_fix=current_state.ground_truth_fix,
489
+ incident_resolved=current_state.incident_resolved,
490
+ total_reward=current_state.total_reward,
491
+ )
492
+ curriculum_engine.record_episode(current_task_id, float(final_score))
493
+ episode_tracker.pop(current_state.episode_id, None)
494
+ track_episode(current_state)
495
  return res
496
 
497
 
 
574
  "and which do not. Failing over the wrong services causes severe data inconsistency penalties."
575
  ),
576
  },
577
+ {
578
+ "id": "generated",
579
+ "name": "Procedural Incident",
580
+ "difficulty": "variable",
581
+ "max_steps": 20,
582
+ "description": "Procedurally generated incident. Use with any seed 0-99999 for infinite unique scenarios.",
583
+ },
584
  ]
585
  }
586
 
587
 
588
+ @app.get("/curriculum/status")
589
+ def get_curriculum_status():
590
+ return curriculum_engine.get_status()
591
+
592
+
593
+ @app.get("/curriculum/next")
594
+ def get_next_curriculum_task():
595
+ return {
596
+ "recommended_task": curriculum_engine.get_next_curriculum_task(),
597
+ "reasoning": "Lowest rolling average among non-mastered tasks.",
598
+ }
599
+
600
+
601
+ @app.post("/curriculum/record")
602
+ def record_curriculum_episode(req: CurriculumRecordRequest):
603
+ try:
604
+ curriculum_engine.record_episode(req.task_id, req.score)
605
+ except ValueError as exc:
606
+ raise HTTPException(status_code=400, detail=str(exc))
607
+ return {
608
+ "recorded": True,
609
+ "new_status": curriculum_engine.get_status()["tasks"][req.task_id],
610
+ }
611
+
612
+
613
+ @app.get("/curriculum/hint/{task_id}")
614
+ def get_curriculum_hint(task_id: str):
615
+ try:
616
+ return {
617
+ "task_id": task_id,
618
+ "hint": curriculum_engine.get_hint(task_id),
619
+ "scaffold_needed": curriculum_engine.should_scaffold(task_id),
620
+ "mastery_level": curriculum_engine.get_mastery(task_id),
621
+ }
622
+ except ValueError as exc:
623
+ raise HTTPException(status_code=400, detail=str(exc))
624
+
625
+
626
  @app.get("/validate")
627
  def validate():
628
  """
curriculum/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from curriculum.engine import CurriculumEngine
2
+
3
+ __all__ = ["CurriculumEngine"]
curriculum/engine.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from collections import deque
4
+
5
+
6
+ class CurriculumEngine:
7
+ def __init__(self):
8
+ self.tasks = ["easy", "medium", "hard", "bonus", "security", "database", "failover"]
9
+ self.scores: dict[str, deque] = {
10
+ task_id: deque(maxlen=5) for task_id in self.tasks
11
+ }
12
+ self.mastery: dict[str, int] = {task_id: 0 for task_id in self.tasks}
13
+ self._hints: dict[str, str] = {
14
+ "easy": "Focus on the service with highest memory_percent. Read its logs before acting.",
15
+ "medium": "Follow the dependency map backwards from the erroring service to find the root cause.",
16
+ "hard": "All services look green. Check WARN-level logs and business metrics, not error rates.",
17
+ "bonus": "There are two independent failures. Fix each one separately — do not conflate them.",
18
+ "security": "Look for repeated login failures from the same IP range in the access logs.",
19
+ "database": "Check for sequential scans in slow query logs. The fix is structural, not a restart.",
20
+ "failover": "Read the failover runbook first. Not all services are safe — check compliance constraints.",
21
+ }
22
+ self._rotation_index = 0
23
+ self._total_episodes_recorded = 0
24
+
25
+ def _ensure_task(self, task_id: str) -> None:
26
+ if task_id not in self.scores:
27
+ raise ValueError(f"Unknown task_id: {task_id}")
28
+
29
+ def record_episode(self, task_id: str, score: float) -> None:
30
+ self._ensure_task(task_id)
31
+ self.scores[task_id].append(float(score))
32
+ self._total_episodes_recorded += 1
33
+ self._update_mastery(task_id)
34
+
35
+ def _update_mastery(self, task_id: str) -> None:
36
+ self._ensure_task(task_id)
37
+ rolling_avg = self.get_rolling_avg(task_id)
38
+ if rolling_avg > 0.75 and self.mastery[task_id] < 3:
39
+ self.mastery[task_id] += 1
40
+ elif rolling_avg < 0.30 and self.mastery[task_id] > 0:
41
+ self.mastery[task_id] -= 1
42
+
43
+ def get_mastery(self, task_id: str) -> int:
44
+ self._ensure_task(task_id)
45
+ return self.mastery[task_id]
46
+
47
+ def get_rolling_avg(self, task_id: str) -> float:
48
+ self._ensure_task(task_id)
49
+ recent_scores = self.scores[task_id]
50
+ if not recent_scores:
51
+ return 0.0
52
+ return sum(recent_scores) / len(recent_scores)
53
+
54
+ def should_scaffold(self, task_id: str) -> bool:
55
+ self._ensure_task(task_id)
56
+ return len(self.scores[task_id]) >= 3 and self.get_rolling_avg(task_id) < 0.30
57
+
58
+ def get_hint(self, task_id: str) -> str:
59
+ self._ensure_task(task_id)
60
+ return self._hints[task_id]
61
+
62
+ def _get_non_mastered_tasks(self) -> list[str]:
63
+ return [task_id for task_id in self.tasks if self.mastery[task_id] < 3]
64
+
65
+ def _sorted_candidates(self) -> list[str]:
66
+ candidates = self._get_non_mastered_tasks()
67
+ return sorted(
68
+ candidates,
69
+ key=lambda task_id: (self.get_rolling_avg(task_id), self.tasks.index(task_id)),
70
+ )
71
+
72
+ def get_recommended_task(self) -> str:
73
+ candidates = self._sorted_candidates()
74
+ if not candidates:
75
+ return "bonus"
76
+ return candidates[0]
77
+
78
+ def get_next_curriculum_task(self) -> str:
79
+ candidates = self._sorted_candidates()
80
+ if not candidates:
81
+ return "bonus"
82
+ task_id = candidates[self._rotation_index % len(candidates)]
83
+ self._rotation_index = (self._rotation_index + 1) % len(candidates)
84
+ return task_id
85
+
86
+ def get_status(self) -> dict:
87
+ mastery_labels = {
88
+ 0: "novice",
89
+ 1: "intermediate",
90
+ 2: "advanced",
91
+ 3: "mastered",
92
+ }
93
+ tasks = {}
94
+ for task_id in self.tasks:
95
+ scaffold_needed = self.should_scaffold(task_id)
96
+ tasks[task_id] = {
97
+ "mastery_level": self.mastery[task_id],
98
+ "mastery_label": mastery_labels[self.mastery[task_id]],
99
+ "rolling_avg": self.get_rolling_avg(task_id),
100
+ "recent_scores": list(self.scores[task_id]),
101
+ "scaffold_needed": scaffold_needed,
102
+ "hint": self.get_hint(task_id) if scaffold_needed else None,
103
+ }
104
+ return {
105
+ "tasks": tasks,
106
+ "recommended_task": self.get_recommended_task(),
107
+ "total_episodes_recorded": self._total_episodes_recorded,
108
+ }
generator/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ from generator.incident_factory import IncidentFactory
2
+ __all__ = ["IncidentFactory"]
generator/incident_factory.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+
3
+ class IncidentFactory:
4
+ FAILURE_MODES = ["oom", "cascade", "corruption", "security", "database", "network_partition"]
5
+ SERVICES = ["payment-service", "order-service", "user-service",
6
+ "inventory-service", "api-gateway", "notification-service",
7
+ "data-pipeline", "ml-inference-service"]
8
+ SEVERITIES = ["sev1", "sev2", "sev3"]
9
+ NOISE_ALERTS = [
10
+ "Scheduled batch job running — high CPU expected",
11
+ "SSL certificate renewal in progress",
12
+ "Nightly analytics aggregation running",
13
+ "CDN cache warming after deployment",
14
+ "Routine health check latency spike"
15
+ ]
16
+
17
+ def generate(self, seed: int) -> dict:
18
+ rng = random.Random(seed)
19
+ failure_mode = rng.choice(self.FAILURE_MODES)
20
+ affected_service = rng.choice(self.SERVICES)
21
+ severity = rng.choice(self.SEVERITIES)
22
+ noise_count = rng.randint(0, 3)
23
+ noise_alerts = rng.sample(self.NOISE_ALERTS, min(noise_count, len(self.NOISE_ALERTS)))
24
+
25
+ # Templates for description
26
+ descriptions = {
27
+ "oom": f"{affected_service} is crash-looping due to memory exhaustion. Process memory exceeded limits.",
28
+ "cascade": f"{affected_service} bad deployment causing downstream connection pool exhaustion across dependent services.",
29
+ "corruption": f"Silent data corruption in {affected_service}. Invalid records being written. No error-rate alerts — signal buried in business metrics.",
30
+ "security": f"Credential stuffing botnet targeting {affected_service} from IP range 185.220.x.x.",
31
+ "database": f"Missing index on {affected_service} database after migration. Full table scans degrading all queries.",
32
+ "network_partition": f"Network partition isolating {affected_service} in us-east-1. Failover decision required."
33
+ }
34
+ description = descriptions[failure_mode]
35
+
36
+ # Templates for root cause
37
+ root_causes = {
38
+ "oom": f"Memory leak in {affected_service} causing OOM crash-loop",
39
+ "cascade": f"Bad deployment in {affected_service} causing cascading connection failures",
40
+ "corruption": f"Data corruption in {affected_service} writing invalid records",
41
+ "security": f"DDoS credential stuffing from 185.220.0.0/16 targeting {affected_service}",
42
+ "database": f"Missing index causing sequential scans on {affected_service}",
43
+ "network_partition": f"Network partition in us-east-1 affecting {affected_service}"
44
+ }
45
+ ground_truth_root_cause = root_causes[failure_mode]
46
+
47
+ # Templates for fix
48
+ fixes = {
49
+ "oom": "restart_service",
50
+ "cascade": "rollback",
51
+ "corruption": "rollback",
52
+ "security": "block_ip_range",
53
+ "database": "create_index",
54
+ "network_partition": "failover"
55
+ }
56
+ ground_truth_fix = fixes[failure_mode]
57
+
58
+ # Difficulty score
59
+ base_scores = {
60
+ "oom": 0.2, "cascade": 0.5, "corruption": 0.8,
61
+ "security": 0.6, "database": 0.6, "network_partition": 0.7
62
+ }
63
+ score = base_scores[failure_mode] + (noise_count * 0.05)
64
+ score = min(score, 1.0)
65
+
66
+ return {
67
+ "task_id": "generated",
68
+ "incident_id": f"INC-{seed:05d}",
69
+ "seed": seed,
70
+ "failure_mode": failure_mode,
71
+ "affected_service": affected_service,
72
+ "severity": severity,
73
+ "noise_alerts": noise_alerts,
74
+ "description": description,
75
+ "ground_truth_root_cause": ground_truth_root_cause,
76
+ "ground_truth_fix": ground_truth_fix,
77
+ "difficulty_score": round(score, 2),
78
+ "estimated_optimal_score": round(0.99 - score * 0.3, 2)
79
+ }
tasks/task_generated.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+ import uuid
3
+ from datetime import datetime
4
+ from tasks.base import BaseTask, InternalState, StepOutput
5
+ from models import Action, StepResult, ServiceStatus, Alert, ActionType
6
+
7
+ class GeneratedTask(BaseTask):
8
+ def __init__(self, incident_dict: dict):
9
+ self.incident = incident_dict
10
+ self.task_id = "generated"
11
+ self.max_steps = 20
12
+ # For compatibility with BaseTask which expects rng in __init__
13
+ super().__init__(random.Random(incident_dict.get("seed", 42)))
14
+
15
+ def initialize(self) -> InternalState:
16
+ affected = self.incident["affected_service"]
17
+ failure_mode = self.incident["failure_mode"]
18
+
19
+ services_dict = {}
20
+ SERVICES = ["payment-service", "order-service", "user-service",
21
+ "inventory-service", "api-gateway", "notification-service",
22
+ "data-pipeline", "ml-inference-service"]
23
+
24
+ for svc in SERVICES:
25
+ if svc == affected:
26
+ services_dict[svc] = {
27
+ "name": svc,
28
+ "status": "degraded",
29
+ "cpu_percent": 85.0,
30
+ "memory_percent": 92.0,
31
+ "error_rate": 0.35,
32
+ "latency_p99_ms": 2800.0,
33
+ "replicas_running": 2,
34
+ "replicas_desired": 3,
35
+ "current_version": "v1.2.4",
36
+ "last_deployed": (datetime.utcnow()).isoformat(),
37
+ "minutes_degraded": 0,
38
+ "sla_breach": False
39
+ }
40
+ else:
41
+ services_dict[svc] = {
42
+ "name": svc,
43
+ "status": "healthy",
44
+ "cpu_percent": 25.0,
45
+ "memory_percent": 40.0,
46
+ "error_rate": 0.01,
47
+ "latency_p99_ms": 120.0,
48
+ "replicas_running": 3,
49
+ "replicas_desired": 3,
50
+ "current_version": "v1.2.3",
51
+ "last_deployed": (datetime.utcnow()).isoformat(),
52
+ "minutes_degraded": 0,
53
+ "sla_breach": False
54
+ }
55
+
56
+ active_alerts = []
57
+ # One CRITICAL alert
58
+ active_alerts.append({
59
+ "id": str(uuid.uuid4())[:8],
60
+ "service": affected,
61
+ "severity": "critical",
62
+ "message": self.incident["description"],
63
+ "timestamp": datetime.utcnow().isoformat(),
64
+ "acknowledged": False
65
+ })
66
+
67
+ # Noise alerts
68
+ for noise in self.incident["noise_alerts"]:
69
+ active_alerts.append({
70
+ "id": str(uuid.uuid4())[:8],
71
+ "service": "notification-service",
72
+ "severity": "warning",
73
+ "message": noise,
74
+ "timestamp": datetime.utcnow().isoformat(),
75
+ "acknowledged": False
76
+ })
77
+
78
+ log_lines = {
79
+ "oom": ["ERROR OutOfMemoryError: Java heap space",
80
+ "WARN Memory usage at 98%, GC overhead limit exceeded"],
81
+ "cascade": ["ERROR Connection pool exhausted: timeout after 30s",
82
+ "ERROR Failed to acquire connection from pool"],
83
+ "corruption": ["WARN Price mismatch detected: expected 29.99 got 299.9",
84
+ "WARN Data validation failed for 847 records"],
85
+ "security": ["WARN 1847 failed login attempts in 60s",
86
+ "WARN Rate limit exceeded from 185.220.101.x"],
87
+ "database": ["WARN Slow query: seq_scan on orders (847ms)",
88
+ "WARN Query planner chose sequential scan, missing index"],
89
+ "network_partition": ["ERROR Connection timeout to us-east-1",
90
+ "ERROR Health check failed: unreachable"]
91
+ }
92
+
93
+ logs = {}
94
+ for svc in SERVICES:
95
+ if svc == affected:
96
+ logs[svc] = log_lines.get(failure_mode, ["INFO Service running normally"])
97
+ else:
98
+ logs[svc] = ["INFO Service running normally", "INFO Health check passed"]
99
+
100
+ state = InternalState(
101
+ episode_id=str(uuid.uuid4()),
102
+ task_id="generated",
103
+ step=0,
104
+ max_steps=self.max_steps,
105
+ services=services_dict,
106
+ alerts=active_alerts,
107
+ logs=logs,
108
+ action_history=[],
109
+ total_reward=0.0,
110
+ incident_resolved=False,
111
+ ground_truth_root_cause=self.incident["ground_truth_root_cause"],
112
+ ground_truth_fix=self.incident["ground_truth_fix"],
113
+ incident_start_time=datetime.utcnow().isoformat(),
114
+ rewards_given=set()
115
+ )
116
+ state._scenario = self.incident
117
+ return state
118
+
119
+ def step(self, state: InternalState, action: Action) -> StepOutput:
120
+ reward = 0.0
121
+ result_text, error_text = self._apply_action_to_logs(state, action)
122
+
123
+ # ActionType can be enum or string
124
+ at = action.action_type
125
+ at_val = at.value if hasattr(at, "value") else str(at)
126
+
127
+ if at_val == "read_logs":
128
+ if action.service == self.incident["affected_service"]:
129
+ if "read_logs" not in state.rewards_given:
130
+ reward += 0.10
131
+ state.rewards_given.add("read_logs")
132
+
133
+ if at_val == "diagnose":
134
+ diagnosis = action.diagnosis or action.root_cause or ""
135
+ if state.ground_truth_root_cause.lower() in diagnosis.lower():
136
+ if "diagnose" not in state.rewards_given:
137
+ reward += 0.30
138
+ state.rewards_given.add("diagnose")
139
+
140
+ if at_val == self.incident["ground_truth_fix"]:
141
+ if action.service == self.incident["affected_service"] and "fix" not in state.rewards_given:
142
+ reward += 0.45
143
+ state.rewards_given.add("fix")
144
+ state.incident_resolved = True
145
+ state.services[self.incident["affected_service"]]["status"] = "healthy"
146
+ state.services[self.incident["affected_service"]]["cpu_percent"] = 25.0
147
+ state.services[self.incident["affected_service"]]["memory_percent"] = 40.0
148
+ state.services[self.incident["affected_service"]]["error_rate"] = 0.01
149
+ state.services[self.incident["affected_service"]]["latency_p99_ms"] = 120.0
150
+
151
+ state.step += 1
152
+ state.total_reward = self._clamp(state.total_reward + reward)
153
+
154
+ done = state.incident_resolved or state.step >= self.max_steps
155
+ info = {}
156
+ if state.incident_resolved: info["resolution"] = "incident_resolved"
157
+ if state.step >= self.max_steps: info["reason"] = "max_steps_reached"
158
+
159
+ state.action_history.append({
160
+ "step": state.step,
161
+ "action": action.model_dump(),
162
+ "reward": round(reward, 4)
163
+ })
164
+
165
+ return StepOutput(next_state=state, reward=round(reward, 4), done=done, info=info)
validate.py CHANGED
@@ -68,7 +68,7 @@ def main():
68
 
69
  def check_reset_all_tasks():
70
  from env import DevOpsIncidentEnv
71
- for task_id in ["easy", "medium", "hard", "bonus"]:
72
  env = DevOpsIncidentEnv(task_id=task_id, seed=42)
73
  obs = env.reset()
74
  assert obs.task_id == task_id, f"task_id mismatch for {task_id}"
@@ -121,7 +121,7 @@ def main():
121
  from env import DevOpsIncidentEnv
122
  from models import Action, ActionType
123
  rng = random.Random(0)
124
- for task_id in ["easy", "medium", "hard", "bonus"]:
125
  env = DevOpsIncidentEnv(task_id=task_id, seed=42)
126
  env.reset()
127
  done = False
@@ -177,7 +177,7 @@ def main():
177
  from models import Action, ActionType
178
  from graders.grader import grade_episode
179
  rng = random.Random(99)
180
- for task_id in ["easy", "medium", "hard", "bonus"]:
181
  env = DevOpsIncidentEnv(task_id=task_id, seed=42)
182
  env.reset()
183
  done = False
 
68
 
69
  def check_reset_all_tasks():
70
  from env import DevOpsIncidentEnv
71
+ for task_id in ["easy", "medium", "hard", "bonus", "security", "database", "failover"]:
72
  env = DevOpsIncidentEnv(task_id=task_id, seed=42)
73
  obs = env.reset()
74
  assert obs.task_id == task_id, f"task_id mismatch for {task_id}"
 
121
  from env import DevOpsIncidentEnv
122
  from models import Action, ActionType
123
  rng = random.Random(0)
124
+ for task_id in ["easy", "medium", "hard", "bonus", "security", "database", "failover"]:
125
  env = DevOpsIncidentEnv(task_id=task_id, seed=42)
126
  env.reset()
127
  done = False
 
177
  from models import Action, ActionType
178
  from graders.grader import grade_episode
179
  rng = random.Random(99)
180
+ for task_id in ["easy", "medium", "hard", "bonus", "security", "database", "failover"]:
181
  env = DevOpsIncidentEnv(task_id=task_id, seed=42)
182
  env.reset()
183
  done = False