Ajayyy00 commited on
Commit Β·
292f6a5
1
Parent(s): 7211e63
Add alternating self-play training scaffolding.
Browse filesWire configurable frozen-opponent server startup and add training utilities for archive, eval harness, data collection, and freeze-alternate orchestration.
Made-with: Cursor
- server/app.py +33 -1
- training/agent_archive.py +92 -0
- training/collect_sft.py +108 -0
- training/eval_harness.py +58 -0
- training/freeze_alternate.py +116 -0
- training/pfsp_scheduler.py +42 -0
- training/train_grpo.py +40 -4
server/app.py
CHANGED
|
@@ -22,6 +22,9 @@ Usage:
|
|
| 22 |
uvicorn server.app:app --host 0.0.0.0 --port 8000 --workers 4
|
| 23 |
"""
|
| 24 |
|
|
|
|
|
|
|
|
|
|
| 25 |
try:
|
| 26 |
from openenv.core.env_server.http_server import create_app
|
| 27 |
except Exception as e: # pragma: no cover
|
|
@@ -37,9 +40,38 @@ except (ImportError, ModuleNotFoundError):
|
|
| 37 |
from server.play_environment import CyberSOCEnvironment
|
| 38 |
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
# Create the app with the CyberSOCEnv environment
|
| 41 |
app = create_app(
|
| 42 |
-
|
| 43 |
SOCActionWrapper,
|
| 44 |
SOCObservation,
|
| 45 |
env_name="cybersocenv",
|
|
|
|
| 22 |
uvicorn server.app:app --host 0.0.0.0 --port 8000 --workers 4
|
| 23 |
"""
|
| 24 |
|
| 25 |
+
import os
|
| 26 |
+
import random
|
| 27 |
+
|
| 28 |
try:
|
| 29 |
from openenv.core.env_server.http_server import create_app
|
| 30 |
except Exception as e: # pragma: no cover
|
|
|
|
| 40 |
from server.play_environment import CyberSOCEnvironment
|
| 41 |
|
| 42 |
|
| 43 |
+
class FrozenCheckpointRedPolicy:
|
| 44 |
+
"""Lightweight frozen red policy shim keyed by checkpoint identity."""
|
| 45 |
+
|
| 46 |
+
def __init__(self, checkpoint: str):
|
| 47 |
+
self.checkpoint = checkpoint
|
| 48 |
+
self._rng = random.Random(hash(checkpoint))
|
| 49 |
+
|
| 50 |
+
def act(self, red_observation):
|
| 51 |
+
blue_action = red_observation.get("blue_action_type", "")
|
| 52 |
+
# Deterministic-ish heuristic controlled by checkpoint hash and episode step.
|
| 53 |
+
trigger = blue_action in {"kill_process", "isolate_segment"}
|
| 54 |
+
if trigger and self._rng.random() < 0.5:
|
| 55 |
+
return {
|
| 56 |
+
"action_type": "lateral_pivot",
|
| 57 |
+
"source_host": red_observation.get("blue_action_target", ""),
|
| 58 |
+
}
|
| 59 |
+
return {"action_type": "noop"}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
_frozen_checkpoint = os.environ.get("CYBERSOC_FROZEN_RED_CHECKPOINT", "").strip()
|
| 63 |
+
_adaptive = os.environ.get("CYBERSOC_ADAPTIVE", "1").strip() not in {"0", "false", "False"}
|
| 64 |
+
_red_policy = FrozenCheckpointRedPolicy(_frozen_checkpoint) if _frozen_checkpoint else None
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class ConfiguredCyberSOCEnvironment(CyberSOCEnvironment):
|
| 68 |
+
def __init__(self):
|
| 69 |
+
super().__init__(adaptive=_adaptive, neural_red_policy=_red_policy)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
# Create the app with the CyberSOCEnv environment
|
| 73 |
app = create_app(
|
| 74 |
+
ConfiguredCyberSOCEnvironment,
|
| 75 |
SOCActionWrapper,
|
| 76 |
SOCObservation,
|
| 77 |
env_name="cybersocenv",
|
training/agent_archive.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Historical archive utilities for FSP/PFSP training."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import random
|
| 7 |
+
from dataclasses import dataclass, asdict
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Dict, List, Optional
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@dataclass
|
| 13 |
+
class ArchivedAgent:
|
| 14 |
+
role: str
|
| 15 |
+
version: str
|
| 16 |
+
checkpoint_path: str
|
| 17 |
+
iteration: int
|
| 18 |
+
metadata: Dict[str, float]
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class AgentArchive:
|
| 22 |
+
"""Stores historical checkpoints and win-rate metadata in a JSON index."""
|
| 23 |
+
|
| 24 |
+
def __init__(self, index_path: str = "training/archive/index.json"):
|
| 25 |
+
self.index_path = Path(index_path)
|
| 26 |
+
self.index_path.parent.mkdir(parents=True, exist_ok=True)
|
| 27 |
+
self._items: List[ArchivedAgent] = self._load()
|
| 28 |
+
|
| 29 |
+
def _load(self) -> List[ArchivedAgent]:
|
| 30 |
+
if not self.index_path.exists():
|
| 31 |
+
return []
|
| 32 |
+
raw = json.loads(self.index_path.read_text(encoding="utf-8"))
|
| 33 |
+
return [ArchivedAgent(**item) for item in raw]
|
| 34 |
+
|
| 35 |
+
def save(self) -> None:
|
| 36 |
+
payload = [asdict(item) for item in self._items]
|
| 37 |
+
self.index_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
| 38 |
+
|
| 39 |
+
def add(
|
| 40 |
+
self,
|
| 41 |
+
role: str,
|
| 42 |
+
version: str,
|
| 43 |
+
checkpoint_path: str,
|
| 44 |
+
iteration: int,
|
| 45 |
+
metadata: Optional[Dict[str, float]] = None,
|
| 46 |
+
) -> None:
|
| 47 |
+
self._items.append(
|
| 48 |
+
ArchivedAgent(
|
| 49 |
+
role=role,
|
| 50 |
+
version=version,
|
| 51 |
+
checkpoint_path=checkpoint_path,
|
| 52 |
+
iteration=iteration,
|
| 53 |
+
metadata=metadata or {},
|
| 54 |
+
)
|
| 55 |
+
)
|
| 56 |
+
self.save()
|
| 57 |
+
|
| 58 |
+
def list_role(self, role: str) -> List[ArchivedAgent]:
|
| 59 |
+
return [item for item in self._items if item.role == role]
|
| 60 |
+
|
| 61 |
+
def latest(self, role: str) -> Optional[ArchivedAgent]:
|
| 62 |
+
role_items = self.list_role(role)
|
| 63 |
+
if not role_items:
|
| 64 |
+
return None
|
| 65 |
+
return max(role_items, key=lambda x: x.iteration)
|
| 66 |
+
|
| 67 |
+
def sample_fsp(self, role: str) -> Optional[ArchivedAgent]:
|
| 68 |
+
items = self.list_role(role)
|
| 69 |
+
if not items:
|
| 70 |
+
return None
|
| 71 |
+
return random.choice(items)
|
| 72 |
+
|
| 73 |
+
def sample_pfsp(self, role: str, temperature: float = 1.0) -> Optional[ArchivedAgent]:
|
| 74 |
+
"""Prioritize opponents where blue has lower win-rate."""
|
| 75 |
+
items = self.list_role(role)
|
| 76 |
+
if not items:
|
| 77 |
+
return None
|
| 78 |
+
|
| 79 |
+
weights: List[float] = []
|
| 80 |
+
for item in items:
|
| 81 |
+
blue_win_rate = float(item.metadata.get("blue_win_rate", 0.5))
|
| 82 |
+
difficulty = max(0.0, min(1.0, 1.0 - blue_win_rate))
|
| 83 |
+
weights.append(max(1e-6, difficulty**temperature))
|
| 84 |
+
|
| 85 |
+
return random.choices(items, weights=weights, k=1)[0]
|
| 86 |
+
|
| 87 |
+
def must_beat_all(self, threshold: float = 0.55) -> bool:
|
| 88 |
+
"""Return True when every archived red has blue_win_rate >= threshold."""
|
| 89 |
+
red_items = self.list_role("red")
|
| 90 |
+
if not red_items:
|
| 91 |
+
return True
|
| 92 |
+
return all(float(item.metadata.get("blue_win_rate", 0.0)) >= threshold for item in red_items)
|
training/collect_sft.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Collect imitation data from deterministic red-team decisions.
|
| 3 |
+
|
| 4 |
+
This script runs generated scenarios and stores red decision tuples as JSONL:
|
| 5 |
+
{"observation": {...}, "action": {...}}
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import argparse
|
| 11 |
+
import json
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Any, Dict, List
|
| 14 |
+
|
| 15 |
+
from models import SOCActionWrapper
|
| 16 |
+
from server.play_environment import CyberSOCEnvironment
|
| 17 |
+
from server.tasks import get_task
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _scripted_blue_actions(task_id: str) -> List[Dict[str, Any]]:
|
| 21 |
+
"""Simple deterministic blue rollout to trigger red dynamics each step."""
|
| 22 |
+
task_def = get_task(task_id)
|
| 23 |
+
reqs = task_def.get("containment_requirements", {}) or {}
|
| 24 |
+
actions: List[Dict[str, Any]] = []
|
| 25 |
+
|
| 26 |
+
for host in reqs.get("must_forensics", []):
|
| 27 |
+
actions.append({"type": "run_forensics", "hostname": host})
|
| 28 |
+
|
| 29 |
+
for proc in reqs.get("must_kill", []):
|
| 30 |
+
actions.append(
|
| 31 |
+
{
|
| 32 |
+
"type": "kill_process",
|
| 33 |
+
"hostname": proc["hostname"],
|
| 34 |
+
"process_name": proc["process"],
|
| 35 |
+
}
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
for ioc in reqs.get("must_block_iocs", []):
|
| 39 |
+
ioc_type = "hash" if len(ioc) >= 32 and "." not in ioc else ("ip" if ioc.count(".") == 3 else "domain")
|
| 40 |
+
actions.append({"type": "block_ioc", "ioc_type": ioc_type, "ioc_value": ioc})
|
| 41 |
+
|
| 42 |
+
actions.append(
|
| 43 |
+
{
|
| 44 |
+
"type": "submit_containment_plan",
|
| 45 |
+
"plan": [
|
| 46 |
+
{
|
| 47 |
+
"threat_id": t.get("threat_id", "UNKNOWN"),
|
| 48 |
+
"actions_taken": ["run_forensics", "kill_process", "block_ioc"],
|
| 49 |
+
"root_cause": t.get("threat_type", "unknown"),
|
| 50 |
+
"confidence": 0.8,
|
| 51 |
+
}
|
| 52 |
+
for t in task_def.get("attack_chain", [])
|
| 53 |
+
],
|
| 54 |
+
"executive_summary": "Automated containment sequence completed.",
|
| 55 |
+
}
|
| 56 |
+
)
|
| 57 |
+
return actions
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def collect_red_imitation_dataset(
|
| 61 |
+
output_path: Path,
|
| 62 |
+
num_tasks: int = 1000,
|
| 63 |
+
task_prefix: str = "gen_",
|
| 64 |
+
) -> int:
|
| 65 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 66 |
+
records: List[Dict[str, Any]] = []
|
| 67 |
+
|
| 68 |
+
def _logger(record: Dict[str, Any]) -> None:
|
| 69 |
+
records.append(record)
|
| 70 |
+
|
| 71 |
+
env = CyberSOCEnvironment(adaptive=True, red_team_logger=_logger)
|
| 72 |
+
|
| 73 |
+
for idx in range(1, num_tasks + 1):
|
| 74 |
+
task_id = f"{task_prefix}{idx:04d}"
|
| 75 |
+
env.reset(task_id=task_id)
|
| 76 |
+
for action in _scripted_blue_actions(task_id):
|
| 77 |
+
obs = env.step(SOCActionWrapper(**action))
|
| 78 |
+
if obs.done:
|
| 79 |
+
break
|
| 80 |
+
|
| 81 |
+
with output_path.open("w", encoding="utf-8") as f:
|
| 82 |
+
for record in records:
|
| 83 |
+
f.write(json.dumps(record) + "\n")
|
| 84 |
+
|
| 85 |
+
return len(records)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def main() -> None:
|
| 89 |
+
parser = argparse.ArgumentParser(description="Collect deterministic red-team SFT data")
|
| 90 |
+
parser.add_argument(
|
| 91 |
+
"--output",
|
| 92 |
+
default="training/data/red_imitation.jsonl",
|
| 93 |
+
help="Output JSONL path",
|
| 94 |
+
)
|
| 95 |
+
parser.add_argument("--num-tasks", type=int, default=1000, help="Number of generated scenarios")
|
| 96 |
+
parser.add_argument("--task-prefix", default="gen_", help="Task ID prefix")
|
| 97 |
+
args = parser.parse_args()
|
| 98 |
+
|
| 99 |
+
total = collect_red_imitation_dataset(
|
| 100 |
+
output_path=Path(args.output),
|
| 101 |
+
num_tasks=args.num_tasks,
|
| 102 |
+
task_prefix=args.task_prefix,
|
| 103 |
+
)
|
| 104 |
+
print(f"Saved {total} red decision examples to {args.output}")
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
if __name__ == "__main__":
|
| 108 |
+
main()
|
training/eval_harness.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evaluation harness for Blue-vs-Red checkpoints."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from typing import Any, Callable, Dict, List
|
| 7 |
+
|
| 8 |
+
from models import SOCActionWrapper
|
| 9 |
+
from server.play_environment import CyberSOCEnvironment
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
PolicyFn = Callable[[Dict[str, Any]], Dict[str, Any]]
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass
|
| 16 |
+
class EvalResult:
|
| 17 |
+
episodes: int
|
| 18 |
+
blue_win_rate: float
|
| 19 |
+
avg_blue_score: float
|
| 20 |
+
per_episode_scores: List[float]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def run_head_to_head_eval(
|
| 24 |
+
blue_policy: PolicyFn,
|
| 25 |
+
red_policy: Any,
|
| 26 |
+
task_ids: List[str],
|
| 27 |
+
episodes_per_task: int = 50,
|
| 28 |
+
) -> EvalResult:
|
| 29 |
+
"""Evaluate blue against a frozen red policy across task IDs."""
|
| 30 |
+
scores: List[float] = []
|
| 31 |
+
wins = 0
|
| 32 |
+
total = 0
|
| 33 |
+
|
| 34 |
+
for task_id in task_ids:
|
| 35 |
+
for _ in range(episodes_per_task):
|
| 36 |
+
env = CyberSOCEnvironment(adaptive=True, neural_red_policy=red_policy)
|
| 37 |
+
obs = env.reset(task_id=task_id)
|
| 38 |
+
|
| 39 |
+
while not obs.done:
|
| 40 |
+
action_dict = blue_policy(obs.model_dump())
|
| 41 |
+
obs = env.step(SOCActionWrapper(**action_dict))
|
| 42 |
+
|
| 43 |
+
score = float(obs.final_score or 0.0)
|
| 44 |
+
scores.append(score)
|
| 45 |
+
wins += int(score >= 0.5)
|
| 46 |
+
total += 1
|
| 47 |
+
|
| 48 |
+
avg_score = sum(scores) / max(1, len(scores))
|
| 49 |
+
return EvalResult(
|
| 50 |
+
episodes=total,
|
| 51 |
+
blue_win_rate=wins / max(1, total),
|
| 52 |
+
avg_blue_score=avg_score,
|
| 53 |
+
per_episode_scores=scores,
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def must_beat_all_archive(win_rates: Dict[str, float], threshold: float = 0.55) -> bool:
|
| 58 |
+
return all(rate >= threshold for rate in win_rates.values())
|
training/freeze_alternate.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Freeze-alternate orchestration for Blue/Red GRPO training."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import json
|
| 7 |
+
import subprocess
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Dict, Optional
|
| 10 |
+
|
| 11 |
+
try:
|
| 12 |
+
from .agent_archive import AgentArchive
|
| 13 |
+
from .pfsp_scheduler import temperature_for_iteration
|
| 14 |
+
except ImportError:
|
| 15 |
+
from agent_archive import AgentArchive
|
| 16 |
+
from pfsp_scheduler import temperature_for_iteration
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _run_train_command(command: str) -> None:
|
| 20 |
+
completed = subprocess.run(command, shell=True, check=False)
|
| 21 |
+
if completed.returncode != 0:
|
| 22 |
+
raise RuntimeError(f"Training command failed: {command}")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _format_cmd(
|
| 26 |
+
base_cmd: str,
|
| 27 |
+
role: str,
|
| 28 |
+
output_dir: str,
|
| 29 |
+
frozen_opponent: Optional[str],
|
| 30 |
+
episodes: int,
|
| 31 |
+
) -> str:
|
| 32 |
+
cmd = [
|
| 33 |
+
base_cmd,
|
| 34 |
+
f"--train-role {role}",
|
| 35 |
+
f"--output-dir {output_dir}",
|
| 36 |
+
f"--episodes {episodes}",
|
| 37 |
+
]
|
| 38 |
+
if frozen_opponent:
|
| 39 |
+
cmd.append(f"--frozen-opponent {frozen_opponent}")
|
| 40 |
+
return " ".join(cmd)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def run_freeze_alternate(
|
| 44 |
+
iterations: int,
|
| 45 |
+
train_blue_episodes: int,
|
| 46 |
+
train_red_episodes: int,
|
| 47 |
+
base_train_cmd: str,
|
| 48 |
+
archive_path: str,
|
| 49 |
+
) -> Dict[str, str]:
|
| 50 |
+
archive = AgentArchive(archive_path)
|
| 51 |
+
latest_blue = archive.latest("blue")
|
| 52 |
+
latest_red = archive.latest("red")
|
| 53 |
+
|
| 54 |
+
for it in range(1, iterations + 1):
|
| 55 |
+
blue_version = f"blue_v{it}"
|
| 56 |
+
blue_ckpt = f"checkpoints/{blue_version}"
|
| 57 |
+
cmd_blue = _format_cmd(
|
| 58 |
+
base_cmd=base_train_cmd,
|
| 59 |
+
role="blue",
|
| 60 |
+
output_dir=blue_ckpt,
|
| 61 |
+
frozen_opponent=latest_red.checkpoint_path if latest_red else None,
|
| 62 |
+
episodes=train_blue_episodes,
|
| 63 |
+
)
|
| 64 |
+
_run_train_command(cmd_blue)
|
| 65 |
+
archive.add("blue", blue_version, blue_ckpt, iteration=it, metadata={})
|
| 66 |
+
latest_blue = archive.latest("blue")
|
| 67 |
+
|
| 68 |
+
red_version = f"red_v{it}"
|
| 69 |
+
red_ckpt = f"checkpoints/{red_version}"
|
| 70 |
+
cmd_red = _format_cmd(
|
| 71 |
+
base_cmd=base_train_cmd,
|
| 72 |
+
role="red",
|
| 73 |
+
output_dir=red_ckpt,
|
| 74 |
+
frozen_opponent=latest_blue.checkpoint_path if latest_blue else None,
|
| 75 |
+
episodes=train_red_episodes,
|
| 76 |
+
)
|
| 77 |
+
_run_train_command(cmd_red)
|
| 78 |
+
archive.add("red", red_version, red_ckpt, iteration=it, metadata={})
|
| 79 |
+
latest_red = archive.latest("red")
|
| 80 |
+
|
| 81 |
+
return {
|
| 82 |
+
"latest_blue": latest_blue.checkpoint_path if latest_blue else "",
|
| 83 |
+
"latest_red": latest_red.checkpoint_path if latest_red else "",
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def main() -> None:
|
| 88 |
+
parser = argparse.ArgumentParser(description="Freeze-alternate Blue/Red training orchestrator")
|
| 89 |
+
parser.add_argument("--iterations", type=int, default=2)
|
| 90 |
+
parser.add_argument("--blue-episodes", type=int, default=500)
|
| 91 |
+
parser.add_argument("--red-episodes", type=int, default=300)
|
| 92 |
+
parser.add_argument("--train-cmd", default="python -m training.train_grpo")
|
| 93 |
+
parser.add_argument("--archive-path", default="training/archive/index.json")
|
| 94 |
+
parser.add_argument("--show-temp-for", type=int, default=0, help="Print PFSP temp schedule for N iterations")
|
| 95 |
+
args = parser.parse_args()
|
| 96 |
+
|
| 97 |
+
if args.show_temp_for > 0:
|
| 98 |
+
schedule = {
|
| 99 |
+
f"iter_{i + 1}": temperature_for_iteration(i, args.show_temp_for)
|
| 100 |
+
for i in range(args.show_temp_for)
|
| 101 |
+
}
|
| 102 |
+
print(json.dumps(schedule, indent=2))
|
| 103 |
+
return
|
| 104 |
+
|
| 105 |
+
outputs = run_freeze_alternate(
|
| 106 |
+
iterations=args.iterations,
|
| 107 |
+
train_blue_episodes=args.blue_episodes,
|
| 108 |
+
train_red_episodes=args.red_episodes,
|
| 109 |
+
base_train_cmd=args.train_cmd,
|
| 110 |
+
archive_path=args.archive_path,
|
| 111 |
+
)
|
| 112 |
+
print(json.dumps(outputs, indent=2))
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
if __name__ == "__main__":
|
| 116 |
+
main()
|
training/pfsp_scheduler.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PFSP temperature scheduling and weighted opponent sampling."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Dict, Iterable, Tuple
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def temperature_for_iteration(
|
| 9 |
+
iteration: int,
|
| 10 |
+
total_iterations: int,
|
| 11 |
+
start_temp: float = 0.5,
|
| 12 |
+
end_temp: float = 2.0,
|
| 13 |
+
) -> float:
|
| 14 |
+
if total_iterations <= 1:
|
| 15 |
+
return end_temp
|
| 16 |
+
ratio = max(0.0, min(1.0, iteration / float(total_iterations - 1)))
|
| 17 |
+
return start_temp + (end_temp - start_temp) * ratio
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def pfsp_weights(
|
| 21 |
+
win_rates: Dict[str, float],
|
| 22 |
+
temperature: float,
|
| 23 |
+
) -> Dict[str, float]:
|
| 24 |
+
"""Compute PFSP weights = (1 - blue_win_rate) ** temperature."""
|
| 25 |
+
weights: Dict[str, float] = {}
|
| 26 |
+
for name, win_rate in win_rates.items():
|
| 27 |
+
clipped = max(0.0, min(1.0, float(win_rate)))
|
| 28 |
+
weights[name] = max(1e-6, (1.0 - clipped) ** temperature)
|
| 29 |
+
return weights
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def normalize_weights(raw_weights: Dict[str, float]) -> Dict[str, float]:
|
| 33 |
+
total = sum(raw_weights.values())
|
| 34 |
+
if total <= 0:
|
| 35 |
+
n = max(1, len(raw_weights))
|
| 36 |
+
return {k: 1.0 / n for k in raw_weights}
|
| 37 |
+
return {k: v / total for k, v in raw_weights.items()}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def rank_hard_opponents(win_rates: Dict[str, float]) -> Iterable[Tuple[str, float]]:
|
| 41 |
+
"""Yield opponents ordered from hardest (lowest win-rate) to easiest."""
|
| 42 |
+
return sorted(win_rates.items(), key=lambda kv: kv[1])
|
training/train_grpo.py
CHANGED
|
@@ -49,19 +49,29 @@ except ImportError:
|
|
| 49 |
class EnvServer:
|
| 50 |
"""Starts and monitors the CyberSOCEnv FastAPI server as a subprocess."""
|
| 51 |
|
| 52 |
-
def __init__(self, cfg: TrainingConfig):
|
| 53 |
self.cfg = cfg
|
|
|
|
| 54 |
self._proc: Optional[subprocess.Popen] = None
|
| 55 |
|
| 56 |
def start(self) -> None:
|
| 57 |
cmd = [
|
| 58 |
sys.executable, "-m", "uvicorn",
|
| 59 |
-
"server:app",
|
| 60 |
"--host", self.cfg.env_host,
|
| 61 |
"--port", str(self.cfg.env_port),
|
| 62 |
"--log-level", "warning",
|
| 63 |
]
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
print(f"[server] PID {self._proc.pid} starting on :{self.cfg.env_port} ...")
|
| 66 |
self._wait_healthy()
|
| 67 |
|
|
@@ -313,6 +323,20 @@ def make_all_reward_fns(env_url: str) -> List:
|
|
| 313 |
return fns
|
| 314 |
|
| 315 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 316 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 317 |
# 5. Main
|
| 318 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -321,8 +345,12 @@ def parse_args() -> argparse.Namespace:
|
|
| 321 |
p = argparse.ArgumentParser(description="CyberSOC GRPO Training")
|
| 322 |
p.add_argument("--model", default="", help="HuggingFace model ID (overrides config)")
|
| 323 |
p.add_argument("--steps", type=int, default=0, help="Max training steps (overrides config)")
|
|
|
|
| 324 |
p.add_argument("--tasks", default="", help="Comma-separated task IDs, e.g. easy,medium,hard")
|
| 325 |
p.add_argument("--hub-id", default="", dest="hub_id", help="HF Hub model ID for checkpoint push")
|
|
|
|
|
|
|
|
|
|
| 326 |
p.add_argument("--no-server", action="store_true", help="Skip starting env server (already running)")
|
| 327 |
return p.parse_args()
|
| 328 |
|
|
@@ -333,8 +361,10 @@ def main() -> None:
|
|
| 333 |
|
| 334 |
if args.model: cfg.model_name = args.model
|
| 335 |
if args.steps: cfg.max_steps = args.steps
|
|
|
|
| 336 |
if args.tasks: cfg.task_ids = args.tasks.split(",")
|
| 337 |
if args.hub_id: cfg.hub_model_id = args.hub_id
|
|
|
|
| 338 |
|
| 339 |
hf_token = os.environ.get("HF_TOKEN", "")
|
| 340 |
|
|
@@ -343,11 +373,14 @@ def main() -> None:
|
|
| 343 |
print(f" Model : {cfg.model_name}")
|
| 344 |
print(f" Tasks : {cfg.task_ids}")
|
| 345 |
print(f" Steps : {cfg.max_steps}")
|
|
|
|
|
|
|
|
|
|
| 346 |
print(f" Hub : {cfg.hub_model_id or '(local only)'}")
|
| 347 |
print("=" * 60)
|
| 348 |
|
| 349 |
# 1. Start env server ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 350 |
-
server = EnvServer(cfg)
|
| 351 |
if not args.no_server:
|
| 352 |
server.start()
|
| 353 |
|
|
@@ -382,6 +415,9 @@ def main() -> None:
|
|
| 382 |
# 4. Build reward functions βββββββββββββββββββββββββββββββββββββββββββ
|
| 383 |
print("\n[rewards] wiring 11 reward functions (10 dims + weighted total) ...")
|
| 384 |
reward_fns = make_all_reward_fns(cfg.env_url)
|
|
|
|
|
|
|
|
|
|
| 385 |
print(f"[rewards] β {len(reward_fns)} functions registered")
|
| 386 |
|
| 387 |
# 5. GRPO config ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 49 |
class EnvServer:
|
| 50 |
"""Starts and monitors the CyberSOCEnv FastAPI server as a subprocess."""
|
| 51 |
|
| 52 |
+
def __init__(self, cfg: TrainingConfig, frozen_opponent: str = ""):
|
| 53 |
self.cfg = cfg
|
| 54 |
+
self.frozen_opponent = frozen_opponent
|
| 55 |
self._proc: Optional[subprocess.Popen] = None
|
| 56 |
|
| 57 |
def start(self) -> None:
|
| 58 |
cmd = [
|
| 59 |
sys.executable, "-m", "uvicorn",
|
| 60 |
+
"server.app:app",
|
| 61 |
"--host", self.cfg.env_host,
|
| 62 |
"--port", str(self.cfg.env_port),
|
| 63 |
"--log-level", "warning",
|
| 64 |
]
|
| 65 |
+
env = os.environ.copy()
|
| 66 |
+
if self.frozen_opponent:
|
| 67 |
+
env["CYBERSOC_FROZEN_RED_CHECKPOINT"] = self.frozen_opponent
|
| 68 |
+
env.setdefault("CYBERSOC_ADAPTIVE", "1")
|
| 69 |
+
self._proc = subprocess.Popen(
|
| 70 |
+
cmd,
|
| 71 |
+
stdout=subprocess.DEVNULL,
|
| 72 |
+
stderr=subprocess.PIPE,
|
| 73 |
+
env=env,
|
| 74 |
+
)
|
| 75 |
print(f"[server] PID {self._proc.pid} starting on :{self.cfg.env_port} ...")
|
| 76 |
self._wait_healthy()
|
| 77 |
|
|
|
|
| 323 |
return fns
|
| 324 |
|
| 325 |
|
| 326 |
+
def invert_reward_fns(reward_fns: List) -> List:
|
| 327 |
+
"""Invert blue-centric rewards for red training."""
|
| 328 |
+
inverted = []
|
| 329 |
+
for fn in reward_fns:
|
| 330 |
+
def _wrap(base_fn):
|
| 331 |
+
def _inv(completions: List[str], **kwargs) -> List[float]:
|
| 332 |
+
values = base_fn(completions, **kwargs)
|
| 333 |
+
return [-float(v) for v in values]
|
| 334 |
+
_inv.__name__ = f"inv_{getattr(base_fn, '__name__', 'reward')}"
|
| 335 |
+
return _inv
|
| 336 |
+
inverted.append(_wrap(fn))
|
| 337 |
+
return inverted
|
| 338 |
+
|
| 339 |
+
|
| 340 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 341 |
# 5. Main
|
| 342 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 345 |
p = argparse.ArgumentParser(description="CyberSOC GRPO Training")
|
| 346 |
p.add_argument("--model", default="", help="HuggingFace model ID (overrides config)")
|
| 347 |
p.add_argument("--steps", type=int, default=0, help="Max training steps (overrides config)")
|
| 348 |
+
p.add_argument("--episodes", type=int, default=0, help="Alias for steps in freeze-alternate mode")
|
| 349 |
p.add_argument("--tasks", default="", help="Comma-separated task IDs, e.g. easy,medium,hard")
|
| 350 |
p.add_argument("--hub-id", default="", dest="hub_id", help="HF Hub model ID for checkpoint push")
|
| 351 |
+
p.add_argument("--output-dir", default="", help="Override output directory")
|
| 352 |
+
p.add_argument("--train-role", choices=["blue", "red"], default="blue", help="Policy role being optimized")
|
| 353 |
+
p.add_argument("--frozen-opponent", default="", help="Path/ID for frozen opponent checkpoint")
|
| 354 |
p.add_argument("--no-server", action="store_true", help="Skip starting env server (already running)")
|
| 355 |
return p.parse_args()
|
| 356 |
|
|
|
|
| 361 |
|
| 362 |
if args.model: cfg.model_name = args.model
|
| 363 |
if args.steps: cfg.max_steps = args.steps
|
| 364 |
+
if args.episodes: cfg.max_steps = args.episodes
|
| 365 |
if args.tasks: cfg.task_ids = args.tasks.split(",")
|
| 366 |
if args.hub_id: cfg.hub_model_id = args.hub_id
|
| 367 |
+
if args.output_dir: cfg.output_dir = args.output_dir
|
| 368 |
|
| 369 |
hf_token = os.environ.get("HF_TOKEN", "")
|
| 370 |
|
|
|
|
| 373 |
print(f" Model : {cfg.model_name}")
|
| 374 |
print(f" Tasks : {cfg.task_ids}")
|
| 375 |
print(f" Steps : {cfg.max_steps}")
|
| 376 |
+
print(f" Role : {args.train_role}")
|
| 377 |
+
if args.frozen_opponent:
|
| 378 |
+
print(f" Frozen : {args.frozen_opponent}")
|
| 379 |
print(f" Hub : {cfg.hub_model_id or '(local only)'}")
|
| 380 |
print("=" * 60)
|
| 381 |
|
| 382 |
# 1. Start env server ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 383 |
+
server = EnvServer(cfg, frozen_opponent=args.frozen_opponent)
|
| 384 |
if not args.no_server:
|
| 385 |
server.start()
|
| 386 |
|
|
|
|
| 415 |
# 4. Build reward functions βββββββββββββββββββββββββββββββββββββββββββ
|
| 416 |
print("\n[rewards] wiring 11 reward functions (10 dims + weighted total) ...")
|
| 417 |
reward_fns = make_all_reward_fns(cfg.env_url)
|
| 418 |
+
if args.train_role == "red":
|
| 419 |
+
reward_fns = invert_reward_fns(reward_fns)
|
| 420 |
+
print("[rewards] role=red -> inverted blue-centric rewards")
|
| 421 |
print(f"[rewards] β {len(reward_fns)} functions registered")
|
| 422 |
|
| 423 |
# 5. GRPO config ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|