"""Build the showcase data bundle consumed by /showcase. Reads: * kaggle ran notebooks/shard {1,2,3}/training_kaggle{N}.json * rl-agent/scenarios/sim/{easy,medium,hard}/*.json * rl-agent/scenarios/*.json (curated) Writes: * rl-agent/showcase_data.json The bundle is deliberately *flat* and aggressive about size reduction so the single-page dashboard can fetch and render the whole thing without paging. Total size target: ~1-2 MB. """ from __future__ import annotations import json import re from collections import Counter, defaultdict from pathlib import Path ROOT = Path(__file__).resolve().parents[1] SCEN_ROOT = ROOT / "rl-agent" / "scenarios" SIM_ROOT = SCEN_ROOT / "sim" TRAIN_ROOT = ROOT / "kaggle ran notebooks" OUT_PATH = ROOT / "rl-agent" / "showcase_data.json" # --------------------------------------------------------------------------- # # Scenarios # # --------------------------------------------------------------------------- # # ID prefix -> human-readable category + intention behind designing it. CATEGORY_INTENT = { "sim_easy_lambda_throttle": ( "Lambda Throttling", "easy", "Concurrency hitting the reserved cap. Teaches the agent to recognise " "ThrottledRequests / ConcurrentExecutions and raise reserved-concurrency.", ), "sim_easy_ddb_throttle": ( "DynamoDB Throttling", "easy", "Write-capacity exhaustion on a single hot table. Teaches scale-up of " "provisioned WCU and recognising ProvisionedThroughputExceededException.", ), "sim_easy_apigw": ( "API Gateway 5xx", "easy", "Origin integration timeout. Forces inspection of integration logs vs. " "access logs to localise the problem to the upstream Lambda or VPC link.", ), "sim_easy_eb": ( "EventBridge Failure", "easy", "Rule mis-routes events to dead targets. Teaches the agent to inspect " "FailedInvocations metric and rule patterns.", ), "sim_med_eb_lambda": ( "EventBridge → Lambda Chain", "medium", "Two-hop chain where EventBridge invokes a throttled Lambda. Forces " "cross-service correlation: rule + invocation + throttle metrics.", ), "sim_med_apigw_lambda": ( "API Gateway → Lambda Chain", "medium", "Cold-start cascading 5xx. Trains compound diagnosis: latency + error " "rate + Lambda init duration in one trace.", ), "sim_med_sfn_lambda": ( "Step Functions → Lambda", "medium", "State-machine exit branches mishandle Lambda errors. Teaches reading " "execution history and distinguishing Catch vs. Retry semantics.", ), "sim_med_ddb_lambda": ( "DynamoDB Stream Stall", "medium", "Trigger Lambda hits provisioned cap; iterator-age climbs. Trains the " "agent to follow the stream from producer to consumer.", ), "sim_hard_iam_chain": ( "IAM Permission Chain", "hard", "Missing assume-role policy two services deep. Trains chained-permission " "diagnosis without obvious AccessDenied at the surface.", ), "sim_hard_apigw_chain": ( "API Gateway Multi-Stage", "hard", "Stage-variable misconfiguration cascades through 3 services. Trains " "the agent to compare stages and identify drift.", ), "sim_hard_ddb_chain": ( "DynamoDB Cascade", "hard", "Hot partition + GSI throttle. Teaches identifying the underlying " "partition key skew rather than just scaling.", ), "sim_advanced_cascade": ( "Cascading Failure", "hard", "Real cause is buried under a noisy symptom (e.g. frontend 504 hides " "users_db memory leak). Trains DAG traversal + 'don't trust the loud " "alert' instinct.", ), "sim_advanced_runbook_trap": ( "Runbook Trap", "hard", "Standard runbook would make things worse (e.g. restart pod = wipe " "evidence). Trains deviating from default playbook based on context.", ), "sim_advanced_trolley": ( "Trolley Problem", "hard", "Limited time-window — restoring backup vs. failing over both have " "downsides. Trains explicit trade-off reasoning under pressure.", ), "sim_advanced_saboteur_duel": ( "Adversarial Saboteur", "hard", "Saboteur agent re-injects faults on a cooldown. Trains persistent " "remediation: one fix is not enough.", ), "sim_advanced_slack_redherring": ( "Slack Red Herring", "hard", "Noisy Slack channel actively misdirects. Trains weighting metrics " "over chatter.", ), "sim_gen_app_leak": ( "Generated · App Memory Leak", "medium", "Procedurally-generated memory-leak scenarios across all services. " "Forces generalisation across the topology rather than memorising " "specific service names.", ), "sim_gen_db_duel": ( "Generated · DB Failover Duel", "medium", "Two databases racing during failover; only one is the real cause. " "Trains discriminating primary vs. replica drift.", ), "sim_gen_redherring": ( "Generated · Red Herring", "medium", "A loud-but-irrelevant alert plus a quiet root cause. Trains " "ignoring eye-catching distractors.", ), "sim_gen_cascade": ( "Generated · Cascade", "medium", "Random root-cause node propagating through the DAG. Trains DAG-walk " "from symptom upstream.", ), "sim_gen_cache_warm": ( "Generated · Cache Cold-Start", "medium", "Fresh cache miss-storm under load. Trains rate-limit + warmup vs. " "naive scaling.", ), "sim_gen_peak": ( "Generated · Peak Traffic", "medium", "Traffic profile spike beyond capacity. Trains autoscale tuning " "without over-provisioning.", ), "sim_gen_restore": ( "Generated · DB Restore", "medium", "Schema drift after restore. Trains catching subtle integrity issues " "before re-pointing traffic.", ), } def categorise(task_id: str) -> tuple[str, str, str]: """Return (category, difficulty, intention) for a given task id.""" for prefix, info in CATEGORY_INTENT.items(): if task_id.startswith(prefix): return info # Fallback by id prefix tier word. for tier in ("easy", "med", "hard", "advanced", "gen"): if f"_{tier}_" in task_id or task_id.startswith(f"sim_{tier}"): return ( f"Other · {tier.capitalize()}", "hard" if tier in ("hard", "advanced") else "medium" if tier == "med" else "easy", "Uncategorised scenario.", ) return ("Other", "easy", "Uncategorised scenario.") def load_scenario(path: Path) -> dict: try: d = json.loads(path.read_text(encoding="utf-8")) except Exception: return {} tid = d.get("task_id") or d.get("id") or path.stem chain = d.get("correct_action_chain") or [] actions = [] for step in chain[:8]: # cap to keep payload small if isinstance(step, dict): actions.append( { "id": step.get("id") or step.get("action") or "?", "params": step.get("params", {}), } ) cat, diff_default, intent = categorise(tid) diff = d.get("difficulty") or diff_default return { "id": tid, "title": d.get("title", tid), "description": (d.get("description") or "")[:380], "difficulty": diff, "category": cat, "intention": intent, "max_steps": d.get("max_steps", 16), "target_score": d.get("target_score"), "n_actions": len(chain), "actions": actions, } def collect_scenarios() -> list[dict]: out = [] for tier in ("easy", "medium", "hard"): for p in sorted((SIM_ROOT / tier).glob("*.json")): sc = load_scenario(p) if sc: out.append(sc) return out # --------------------------------------------------------------------------- # # Training shards # # --------------------------------------------------------------------------- # def load_shard(n: int) -> dict: p = TRAIN_ROOT / f"shard {n}" / f"training_kaggle{n}.json" d = json.loads(p.read_text(encoding="utf-8")) cfg = d["config"] updates = d["updates"] # Per-task aggregate over the whole run. task_totals: dict[str, list[float]] = defaultdict(list) for u in updates: for tid, r in (u.get("rewards_by_task") or {}).items(): task_totals[tid].append(r) task_summary = { tid: { "visits": len(rs), "mean_reward": round(sum(rs) / len(rs), 4), "first": round(rs[0], 4), "last": round(rs[-1], 4), } for tid, rs in task_totals.items() } return { "shard": n, "config": { k: v for k, v in cfg.items() if k != "tasks" # tasks list = 127 ids; carried separately }, "n_tasks": len(cfg.get("tasks") or task_summary), "tasks": cfg.get("tasks") or sorted(task_summary.keys()), "n_updates": len(updates), "wall_seconds": round(updates[-1]["wall_s"], 1) if updates else 0, "trajectory": [ { "u": u["update"], "r": u["mean_reward"], "v": u["mean_value"], "loss": round(u["ppo"]["loss"], 4), "kl": round(u["ppo"]["kl"], 4), "policy_loss": round(u["ppo"]["policy_loss"], 4), "value_err": round(u["ppo"]["value_err"], 3), "elapsed": round(u["elapsed_s"], 1), } for u in updates ], "task_summary": task_summary, } # --------------------------------------------------------------------------- # # Compose # # --------------------------------------------------------------------------- # def build() -> dict: scenarios = collect_scenarios() shards = [load_shard(n) for n in (1, 2, 3)] # Cross-shard task index: which shard owns each task + per-shard reward. cross = {} for sh in shards: for tid, summ in sh["task_summary"].items(): cross.setdefault(tid, {"shards": []})["shards"].append( {"shard": sh["shard"], **summ} ) # Difficulty / category breakdown across all 381 covered tasks. cat_counter: Counter = Counter() diff_counter: Counter = Counter() for sc in scenarios: cat_counter[sc["category"]] += 1 diff_counter[sc["difficulty"]] += 1 # Reward-progress bands (best-curve for the cleanest plot). return { "version": 1, "summary": { "n_tasks_total": len(scenarios), "n_tasks_trained": len(cross), "n_shards": len(shards), "n_updates_per_shard": shards[0]["n_updates"] if shards else 0, "rollouts_per_update": shards[0]["config"]["rollouts_per_update"] if shards else 0, "max_steps_per_ep": shards[0]["config"]["max_steps_per_ep"] if shards else 0, "actor": shards[0]["config"]["actor_model"] if shards else "", "critic": shards[0]["config"]["critic_model"] if shards else "", "lora_r": shards[0]["config"]["lora_r"] if shards else None, "lora_alpha": shards[0]["config"]["lora_alpha"] if shards else None, "lr": shards[0]["config"]["lr"] if shards else None, "gamma": shards[0]["config"]["gamma"] if shards else None, "gae_lambda": shards[0]["config"]["gae_lambda"] if shards else None, "clip_eps": shards[0]["config"]["clip_eps"] if shards else None, "kl_coef": shards[0]["config"]["kl_coef"] if shards else None, "wall_seconds_total": sum(sh["wall_seconds"] for sh in shards), }, "category_breakdown": dict(cat_counter), "difficulty_breakdown": dict(diff_counter), "scenarios": scenarios, "shards": shards, "cross_shard_index": cross, } def main() -> None: bundle = build() OUT_PATH.write_text(json.dumps(bundle, separators=(",", ":"))) size_kb = OUT_PATH.stat().st_size / 1024 print(f"wrote {OUT_PATH.relative_to(ROOT)} ({size_kb:.1f} KB)") print( f" {bundle['summary']['n_tasks_total']} scenarios | " f"{bundle['summary']['n_tasks_trained']} covered by training | " f"{bundle['summary']['n_shards']} shards" ) if __name__ == "__main__": main()