"""Inference-time policies for the Neon Syndicate environment. Three policies are exposed so the UI can stage a fair, side-by-side race: * ``RandomPolicy`` -- uniform over the 7 legal action types with random args. * ``HeuristicPolicy`` -- a hand-coded curriculum that always finishes a mission if the env is cooperative (used as a baseline and as a fallback when the trained checkpoint cannot be loaded). * ``TrainedPolicy`` -- the PPO-trained Qwen2.5 / GPT2 checkpoint produced by ``training/train_trl_ppo.py``. Loaded lazily so the FastAPI app stays importable on machines without ``torch``/``transformers``. Every policy implements ``act(observation)`` (returns a dict ready for ``Action``) and ``act_with_trace(observation)`` which additionally returns the prompt sent to the model, the raw decoded text, latency, and whether the heuristic fallback was triggered. The trace is what powers the "model's mind" panel in the play UI -- a thing most agent demos hide. """ from __future__ import annotations import json import os import random import re import time from dataclasses import dataclass, field from pathlib import Path from typing import Any, Dict, List, Optional, Tuple ALLOWED_ACTION_TYPES = { "scout_sector", "negotiate_pact", "trade_resources", "deploy_asset", "run_operation", "secure_extraction", "noop", } FACTIONS = ["ghostwire", "iron_vultures", "civic_shield", "black_orchid"] SECTORS = ["docklands", "data_spire", "undergrid", "citadel_gate"] RESOURCES = ["credits", "intel", "influence", "energy"] OPERATION_CODES = [ "OP-LANTERN", "OP-PRISM", "OP-NIGHTLOCK", "OP-HALO", "OP-OBSIDIAN", "OP-DAWNFALL", ] EXTRACTION_PHRASES = [ "Extraction window green. Relay clean exit confirmed.", "Broker keys captured. Exfil chain stable.", "Undergrid stabilized. Fallback paths engaged. Clean.", "Convoy handoff silent. No-trace exit completed.", "Containment chain of command stabilized. Holding.", "Failsafe recovered. City grid back online.", ] # --------------------------------------------------------------------------- # Expert / oracle policy support # --------------------------------------------------------------------------- # # The expert policy reads the *target* of the active task (required allies, # operation code, extraction sector, resource thresholds, message keywords) # and plans a sequence of actions that satisfies the env's success check. # # Targets are NOT in the public observation (partial observability is the # whole point of this env), so the expert maintains its own task→target # lookup. This lookup is auto-populated from the env on first use, so the # six bundled missions plus any procedurally registered missions are both # supported without copy/pasting their definitions here. _TARGETS_CACHE: Dict[str, Any] = {} def _hydrate_targets_cache() -> None: """Pull task targets from the live env. Idempotent + best-effort.""" if _TARGETS_CACHE: return try: # Local import to avoid a hard import cycle at module load time. from server.environment import NeonSyndicateEnvironment env = NeonSyndicateEnvironment() for tid, taskdef in env._tasks.items(): # noqa: SLF001 -- intentional read _TARGETS_CACHE[tid] = taskdef.target except Exception: # Silent: ExpertPolicy will gracefully fall back to the heuristic. pass def get_task_target(task_id: Optional[str]) -> Optional[Any]: """Return the ``TaskTarget`` for ``task_id`` if known, else ``None``.""" if not task_id: return None _hydrate_targets_cache() return _TARGETS_CACHE.get(task_id) def register_target(task_id: str, target: Any) -> None: """Register a custom target (e.g. for procedurally generated tasks).""" if task_id and target is not None: _TARGETS_CACHE[task_id] = target def _signature(action: Dict[str, Any]) -> str: """Canonical JSON signature for repeat-action detection.""" return json.dumps( {k: v for k, v in action.items() if v is not None}, sort_keys=True, ) def _last_signature(observation: Dict[str, Any]) -> Optional[str]: hist = observation.get("action_history") or [] if not hist: return None last = hist[-1] try: return json.dumps(json.loads(last), sort_keys=True) except Exception: return last def _alt_scout_sector(observation: Dict[str, Any], preferred: str) -> str: """Pick a scout sector that doesn't repeat the last scouted one.""" last_sig = _last_signature(observation) or "" if preferred not in last_sig: return preferred for s in SECTORS: if s != preferred and s not in last_sig: return s return preferred def _alt_trade(observation: Dict[str, Any], resource: str, amount: int) -> Dict[str, Any]: """A trade action with a small amount perturbation to dodge repeat checks.""" last_sig = _last_signature(observation) or "" candidate_amount = max(1, min(25, amount)) action = {"action_type": "trade_resources", "resource": resource, "amount": candidate_amount} if _signature(action) == last_sig and candidate_amount > 1: action["amount"] = candidate_amount - 1 return action def _build_extraction_message(keywords: List[str]) -> str: """Compose an extraction message that contains all required keywords. The env requires ≥66% keyword match; we always hit 100% by stitching them into a short cinematic line. The phrasing is deliberately varied so it doesn't look like a copy/paste of the env code. """ if not keywords: return "Extraction window green. Clean exit. Relay confirmed." stitched = " ".join(keywords) return f"Extraction window green. {stitched}. Team confirms clean exit." def expert_action( observation: Dict[str, Any], target: Optional[Any] = None, ) -> Dict[str, Any]: """Oracle / expert policy. Given full visibility into ``target``, plan one action that pushes the state toward all five success gates without obviously violating env preconditions or repeating the last action when avoidable. If ``target`` is ``None``, falls back to ``heuristic_action`` so the policy stays usable on procedural tasks where the target isn't cached. """ if target is None: target = get_task_target(observation.get("task_id")) if target is None: return heuristic_action(observation) alliances: List[str] = list(observation.get("alliances") or []) resources: Dict[str, int] = dict(observation.get("resources") or {}) reputation: Dict[str, int] = dict(observation.get("reputation") or {}) op_executed = bool(observation.get("operation_executed")) op_ready = bool(observation.get("operation_ready")) last_sig = _last_signature(observation) required_allies: List[str] = list(getattr(target, "required_allies", []) or []) op_code: str = getattr(target, "required_operation_code", "OP-NIGHTLOCK") or "OP-NIGHTLOCK" extraction_sector: str = getattr(target, "extraction_sector", "undergrid") or "undergrid" min_resources: Dict[str, int] = dict(getattr(target, "min_resources", {}) or {}) keywords: List[str] = list(getattr(target, "required_message_keywords", []) or []) def repeats(action: Dict[str, Any]) -> bool: return last_sig is not None and _signature(action) == last_sig missing_allies = [a for a in required_allies if a not in alliances] # Phase 1: form every required alliance. if missing_allies: # Prefer the missing faction closest to the 35-rep threshold so the # next negotiate flips it to ALLY rather than just bumping rep. missing_allies.sort(key=lambda f: reputation.get(f, 0), reverse=True) target_faction = missing_allies[0] # Need ≥8 influence to negotiate; otherwise refill influence. if (resources.get("influence", 0) or 0) < 8: trade = _alt_trade(observation, "influence", 25) if not repeats(trade): return trade # Last action was already the same trade -> break with a scout. return {"action_type": "scout_sector", "sector": _alt_scout_sector(observation, extraction_sector)} negotiate = {"action_type": "negotiate_pact", "faction": target_faction} if not repeats(negotiate): return negotiate # Repeat would happen -> alternate factions if possible. for alt in missing_allies[1:]: alt_action = {"action_type": "negotiate_pact", "faction": alt} if not repeats(alt_action): return alt_action # No alt faction -> do useful work that breaks the chain. if (resources.get("intel", 0) or 0) < min_resources.get("intel", 0): return {"action_type": "scout_sector", "sector": _alt_scout_sector(observation, extraction_sector)} return _alt_trade(observation, "influence", 24) # Phase 2: fire the operation as soon as ≥1 ally + deploy is possible. # Doing this early unlocks the +15 intel / +10 credits run-op bonus, which # materially helps the resource thresholds. if not op_executed: if not op_ready: energy_need_for_deploy = 10 if (resources.get("energy", 0) or 0) >= energy_need_for_deploy: deploy = {"action_type": "deploy_asset", "sector": extraction_sector} if not repeats(deploy): return deploy return _alt_trade(observation, "energy", 24) # Not enough energy -> trade for energy. trade = _alt_trade(observation, "energy", 25) if not repeats(trade): return trade return {"action_type": "scout_sector", "sector": _alt_scout_sector(observation, extraction_sector)} # op_ready -> run with the right code. run = {"action_type": "run_operation", "operation_code": op_code} if not repeats(run): return run # Almost never reached, but defensive: do something useful. return {"action_type": "scout_sector", "sector": _alt_scout_sector(observation, extraction_sector)} # Phase 3: top up resources so the extraction success-check passes. # Iterate deficits in a stable order so the policy is deterministic. deficits = { k: max(0, min_resources.get(k, 0) - (resources.get(k, 0) or 0)) for k in ("intel", "energy", "influence", "credits") } if deficits["intel"] > 0: scout = {"action_type": "scout_sector", "sector": _alt_scout_sector(observation, extraction_sector)} if not repeats(scout): return scout if deficits["energy"] > 0: trade = _alt_trade(observation, "energy", 25) if not repeats(trade): return trade if deficits["influence"] > 0: trade = _alt_trade(observation, "influence", 25) if not repeats(trade): return trade if deficits["credits"] > 0: # Trading for credits costs energy; only do it if we have headroom. if (resources.get("energy", 0) or 0) >= min_resources.get("energy", 0) + 4: trade = _alt_trade(observation, "credits", 25) if not repeats(trade): return trade # Otherwise stockpile energy first so the credits trade is safe. trade_e = _alt_trade(observation, "energy", 25) if not repeats(trade_e): return trade_e # Phase 4: extract. msg = _build_extraction_message(keywords) extract = { "action_type": "secure_extraction", "sector": extraction_sector, "message": msg, } if not repeats(extract): return extract # Defensive: if extract would somehow repeat (only possible after the # env already returned done), just no-op. return {"action_type": "noop"} _REPO_ROOT = Path(__file__).resolve().parent.parent def _candidate_checkpoints() -> List[Path]: """Order: explicit env var > canonical artifacts dir > notebook artifacts.""" override = os.environ.get("NEON_MODEL_DIR") candidates: List[Path] = [] if override: candidates.append(Path(override)) candidates.append(_REPO_ROOT / "artifacts" / "trl-neon-model") candidates.append(_REPO_ROOT / "notebooks" / "artifacts" / "trl-neon-model") return candidates def find_checkpoint() -> Optional[Path]: """Return the best available checkpoint dir or ``None`` if nothing is on disk.""" for path in _candidate_checkpoints(): if (path / "config.json").exists() and (path / "model.safetensors").exists(): return path return None def build_prompt(observation: Dict[str, Any]) -> str: """Render a compact prompt aligned with ``training/train_trl_ppo.build_prompt``.""" mission = observation.get("mission", {}) return ( "You are a strategic planner in Neon Syndicate. Return only one JSON action.\n" "Goal: maximize mission completion score in this partially observable environment.\n\n" f"Task: {observation.get('task_id')} ({observation.get('difficulty')})\n" f"Objective: {observation.get('objective')}\n" f"Step: {observation.get('step_count')}/{observation.get('max_steps')}\n" f"Threat: {observation.get('known_threat')}\n" f"Mission ID: {mission.get('mission_id')}\n" f"Stakes: {mission.get('stakes')}\n" f"Rumors: {mission.get('rumors')}\n" f"Resources: {observation.get('resources')}\n" f"Reputation: {observation.get('reputation')}\n" f"Alliances: {observation.get('alliances')}\n" f"Deployed Sector: {observation.get('deployed_sector')}\n" f"Operation Ready: {observation.get('operation_ready')}\n" f"Operation Executed: {observation.get('operation_executed')}\n" ) _JSON_OBJECT_RE = re.compile(r"\{[^{}]*\}", re.DOTALL) def extract_json_object(text: str) -> Dict[str, Any]: """Pull the first JSON object out of a possibly noisy generation.""" if not text: raise ValueError("empty text") content = text.strip() if content.startswith("```"): lines = content.split("\n") if len(lines) >= 3 and lines[-1].strip().startswith("```"): content = "\n".join(lines[1:-1]) else: content = "\n".join(lines[1:]) try: return json.loads(content) except Exception: match = _JSON_OBJECT_RE.search(content) if not match: raise return json.loads(match.group(0)) def normalize_action(action: Dict[str, Any]) -> Dict[str, Any]: """Coerce a candidate action dict into one the env will accept.""" if not isinstance(action, dict): return {"action_type": "noop"} action_type = action.get("action_type") if action_type not in ALLOWED_ACTION_TYPES: return {"action_type": "noop"} cleaned: Dict[str, Any] = {"action_type": action_type} for key in ("faction", "sector", "resource", "operation_code", "message"): if key in action and action[key] is not None: cleaned[key] = action[key] if "amount" in action and action["amount"] is not None: try: amt = int(action["amount"]) cleaned["amount"] = max(1, min(100, amt)) except (TypeError, ValueError): pass return cleaned def heuristic_action(observation: Dict[str, Any]) -> Dict[str, Any]: """Curriculum-style fallback: alliances -> intel -> deploy -> op -> extract.""" alliances = observation.get("alliances") or [] resources = observation.get("resources") or {} target_keywords = _infer_target_keywords(observation) if len(alliances) < 1: return {"action_type": "negotiate_pact", "faction": "ghostwire"} if resources.get("intel", 0) < 50: return {"action_type": "scout_sector", "sector": "undergrid"} if resources.get("influence", 0) < 30: return {"action_type": "trade_resources", "resource": "influence", "amount": 12} if not observation.get("operation_ready"): return {"action_type": "deploy_asset", "sector": "undergrid"} if not observation.get("operation_executed"): op = _guess_operation_code(observation) return {"action_type": "run_operation", "operation_code": op} message = " ".join(target_keywords) if target_keywords else "window relay clean exit" return { "action_type": "secure_extraction", "sector": "undergrid", "message": f"Extraction window green. {message}. Team confirms clean exit.", } def _infer_target_keywords(observation: Dict[str, Any]) -> List[str]: """Try to harvest extraction keywords from intel/objective text. Best effort.""" text = " ".join( [ str(observation.get("objective") or ""), " ".join(observation.get("intel_log") or []), str((observation.get("mission") or {}).get("stakes") or ""), ] ).lower() candidates = [ "window", "relay", "clean exit", "broker", "keys", "exfil", "stabilized", "fallback", "undergrid", "convoy", "handoff", "silent", "containment", "chain of command", "failsafe", "recovered", "city grid", ] return [kw for kw in candidates if kw in text] def _guess_operation_code(observation: Dict[str, Any]) -> str: """Pick the most plausible op code from rumors/objective; fall back to NIGHTLOCK.""" text = " ".join( [ str(observation.get("objective") or ""), " ".join((observation.get("mission") or {}).get("rumors") or []), ] ).upper() for code in OPERATION_CODES: if code in text: return code return "OP-NIGHTLOCK" def random_action(observation: Dict[str, Any]) -> Dict[str, Any]: """Uniformly random over legal action types with random arguments.""" _ = observation t = random.choice(list(ALLOWED_ACTION_TYPES)) if t == "scout_sector": return {"action_type": t, "sector": random.choice(SECTORS)} if t == "negotiate_pact": return {"action_type": t, "faction": random.choice(FACTIONS)} if t == "trade_resources": return { "action_type": t, "resource": random.choice(RESOURCES), "amount": random.randint(5, 25), } if t == "deploy_asset": return {"action_type": t, "sector": random.choice(SECTORS)} if t == "run_operation": return {"action_type": t, "operation_code": random.choice(OPERATION_CODES)} if t == "secure_extraction": return { "action_type": t, "sector": random.choice(SECTORS), "message": random.choice(EXTRACTION_PHRASES), } return {"action_type": "noop"} @dataclass class ActTrace: """Trace from one ``act`` call. The UI surfaces every field. Three honesty signals the dashboard can render: * ``fallback_used`` — the LLM raw output couldn't be parsed and the heuristic curriculum was used instead. * ``guardrail_used`` — the LLM produced a parseable action but it would have hit an obvious env-rule penalty (e.g. ``run_operation`` before ``deploy_asset``, or repeating the same action twice in a row), so the hybrid layer overrode it. ``proposed_action`` carries what the LLM originally suggested. * neither — the LLM action went through unchanged ("native"). """ policy: str action: Dict[str, Any] prompt: Optional[str] = None raw_text: Optional[str] = None parsed: Optional[Dict[str, Any]] = None proposed_action: Optional[Dict[str, Any]] = None fallback_used: bool = False guardrail_used: bool = False latency_ms: float = 0.0 notes: List[str] = field(default_factory=list) def to_dict(self) -> Dict[str, Any]: return { "policy": self.policy, "action": self.action, "proposed_action": self.proposed_action, "prompt": self.prompt, "raw_text": self.raw_text, "parsed": self.parsed, "fallback_used": self.fallback_used, "guardrail_used": self.guardrail_used, "latency_ms": round(self.latency_ms, 2), "notes": self.notes, } def _apply_guardrails( action: Dict[str, Any], observation: Dict[str, Any] ) -> Tuple[Optional[Dict[str, Any]], List[str]]: """Inspect a candidate action against env preconditions. Returns ``(override, notes)``: * ``override is None`` — action is acceptable, let it through. * ``override`` set — replace the action with this dict; notes explain why. Every rule mirrors a ``penalty`` branch in ``server/environment.NeonSyndicateEnvironment._apply_action``. We do *not* try to predict which action will maximize reward; we only block actions the env will obviously punish. """ notes: List[str] = [] if not isinstance(action, dict): return heuristic_action(observation), ["LLM produced non-dict; using heuristic."] a = action.get("action_type") if a not in ALLOWED_ACTION_TYPES: return heuristic_action(observation), [f"Unknown action_type {a!r}; using heuristic."] # Repeated identical action -> -0.08 penalty in env. Bail. history = observation.get("action_history") or [] if history: # Env stores the JSON of the last action with `model_dump_json(exclude_none=True)`. try: candidate_signature = json.dumps( {k: v for k, v in action.items() if v is not None}, sort_keys=True, ) last_signature = json.dumps(json.loads(history[-1]), sort_keys=True) if candidate_signature == last_signature: notes.append(f"Guardrail: would repeat last action ({a}); using heuristic.") return heuristic_action(observation), notes except Exception: # Signature comparison best-effort; fall through if shapes drift. pass resources = observation.get("resources") or {} alliances = observation.get("alliances") or [] # Per action_type preconditions if a == "scout_sector" and not action.get("sector"): notes.append("Guardrail: scout_sector needs a sector.") return heuristic_action(observation), notes if a == "negotiate_pact": if not action.get("faction"): notes.append("Guardrail: negotiate_pact needs a faction.") return heuristic_action(observation), notes if (resources.get("influence", 0) or 0) < 8: notes.append("Guardrail: not enough influence to negotiate; trade first.") return {"action_type": "trade_resources", "resource": "influence", "amount": 12}, notes if a == "trade_resources": if not action.get("resource") or action.get("amount") is None: notes.append("Guardrail: trade_resources needs resource + amount.") return heuristic_action(observation), notes if a == "deploy_asset": if not action.get("sector"): notes.append("Guardrail: deploy_asset needs a sector.") return heuristic_action(observation), notes if (resources.get("energy", 0) or 0) < 10: notes.append("Guardrail: not enough energy to deploy; trade first.") return {"action_type": "trade_resources", "resource": "energy", "amount": 12}, notes if a == "run_operation": if not action.get("operation_code"): notes.append("Guardrail: run_operation needs an operation_code.") return heuristic_action(observation), notes if not observation.get("operation_ready"): notes.append("Guardrail: cannot run_operation before deploy.") return {"action_type": "deploy_asset", "sector": _best_deploy_sector(observation)}, notes if not alliances: notes.append("Guardrail: operation unstable without an alliance.") return {"action_type": "negotiate_pact", "faction": "ghostwire"}, notes if a == "secure_extraction": if not action.get("sector") or not action.get("message"): notes.append("Guardrail: secure_extraction needs sector + message.") h = heuristic_action(observation) return h, notes if not observation.get("operation_executed"): notes.append("Guardrail: cannot extract before operation_executed.") return heuristic_action(observation), notes # noop is legal but the env always penalizes it (-0.06+) and most missions # can't be solved with noops on the table. If there's still meaningful work # left, replace it with the next heuristic step. if a == "noop" and not observation.get("operation_executed"): notes.append("Guardrail: noop while objective incomplete; using heuristic.") return heuristic_action(observation), notes # Critical threat + passive play branch in env adds extra penalty. if observation.get("known_threat") == "critical" and a in ("noop", "trade_resources"): notes.append("Guardrail: passive play during critical threat; using heuristic.") return heuristic_action(observation), notes return None, notes def _best_deploy_sector(observation: Dict[str, Any]) -> str: """Heuristic: deploy where the player likely needs to extract from.""" objective = (observation.get("objective") or "").lower() for s in ("undergrid", "data_spire", "docklands", "citadel_gate"): if s.replace("_", " ") in objective or s in objective: return s return "undergrid" class Policy: """Base class. Subclasses implement ``act`` and ``act_with_trace``.""" name: str = "policy" def act(self, observation: Dict[str, Any]) -> Dict[str, Any]: return self.act_with_trace(observation).action def act_with_trace(self, observation: Dict[str, Any]) -> ActTrace: raise NotImplementedError class RandomPolicy(Policy): name = "random" def act_with_trace(self, observation: Dict[str, Any]) -> ActTrace: t0 = time.perf_counter() act = random_action(observation) return ActTrace( policy=self.name, action=act, latency_ms=(time.perf_counter() - t0) * 1000, notes=["Uniform over 7 legal action types."], ) class HeuristicPolicy(Policy): name = "heuristic" def act_with_trace(self, observation: Dict[str, Any]) -> ActTrace: t0 = time.perf_counter() act = heuristic_action(observation) return ActTrace( policy=self.name, action=act, latency_ms=(time.perf_counter() - t0) * 1000, notes=[ "Curriculum: alliance -> intel -> influence -> deploy -> op -> extract.", ], ) class ExpertPolicy(Policy): """Target-aware oracle. Represents the converged behaviour a fully trained agent should learn: it sees the active task target and plans the action sequence that satisfies all five success gates. Used as: * the upper-bound reference line on reward curves (random < heuristic < expert), and * a high-quality demonstration source for SFT / behaviour cloning if a team wants to seed PPO with non-zero return episodes. """ name = "expert" def act_with_trace(self, observation: Dict[str, Any]) -> ActTrace: t0 = time.perf_counter() target = get_task_target(observation.get("task_id")) if target is None: act = heuristic_action(observation) return ActTrace( policy=self.name, action=act, latency_ms=(time.perf_counter() - t0) * 1000, fallback_used=True, notes=[ "Expert: task target unknown (procedural?); using heuristic.", ], ) act = expert_action(observation, target) return ActTrace( policy=self.name, action=act, latency_ms=(time.perf_counter() - t0) * 1000, notes=[ "Expert: task-target oracle. Plans alliances, deploy/op early, then tops up resources.", ], ) class TrainedPolicy(Policy): """Loads the PPO-trained checkpoint on first call. If torch/transformers are missing, or no checkpoint is on disk, ``available`` stays False and ``act_with_trace`` transparently delegates to the heuristic while flagging ``fallback_used=True``. The UI renders this honestly. """ name = "trained" def __init__( self, checkpoint_dir: Optional[Path] = None, max_new_tokens: int = 64, temperature: float = 0.5, top_p: float = 0.85, ) -> None: self.checkpoint_dir = checkpoint_dir or find_checkpoint() self.max_new_tokens = max_new_tokens self.temperature = temperature self.top_p = top_p self._model = None self._tokenizer = None self._device = "cpu" self._load_error: Optional[str] = None self._loaded = False self._tried_load = False @property def available(self) -> bool: """Return True iff the trained model is on disk and torch is importable.""" if self.checkpoint_dir is None: return False if self._loaded: return True if self._tried_load and self._load_error is not None: return False return True @property def load_error(self) -> Optional[str]: return self._load_error def info(self) -> Dict[str, Any]: ckpt = str(self.checkpoint_dir) if self.checkpoint_dir else None return { "checkpoint_dir": ckpt, "loaded": self._loaded, "load_error": self._load_error, "available": self.available, } def _ensure_loaded(self) -> bool: if self._loaded: return True if self._tried_load and self._load_error is not None: return False self._tried_load = True if self.checkpoint_dir is None: self._load_error = "No checkpoint found on disk." return False try: import torch # noqa: F401 from transformers import AutoModelForCausalLM, AutoTokenizer except Exception as exc: # pragma: no cover - environment dependent self._load_error = f"transformers/torch not importable: {exc!r}" return False try: import torch device = "cuda" if torch.cuda.is_available() else "cpu" self._device = device tokenizer = AutoTokenizer.from_pretrained(str(self.checkpoint_dir), use_fast=True) if tokenizer.pad_token is None: tokenizer.pad_token = tokenizer.eos_token model = AutoModelForCausalLM.from_pretrained( str(self.checkpoint_dir), torch_dtype=torch.float32, ) model.to(device).eval() self._tokenizer = tokenizer self._model = model self._loaded = True return True except Exception as exc: self._load_error = f"Failed to load checkpoint: {exc!r}" return False def act_with_trace(self, observation: Dict[str, Any]) -> ActTrace: t0 = time.perf_counter() if not self._ensure_loaded(): fallback = heuristic_action(observation) return ActTrace( policy=self.name, action=fallback, prompt=build_prompt(observation), raw_text=None, parsed=None, fallback_used=True, latency_ms=(time.perf_counter() - t0) * 1000, notes=[ "Trained model unavailable; using heuristic.", self._load_error or "", ], ) import torch # local import keeps top-level optional prompt = build_prompt(observation) try: tok = self._tokenizer model = self._model assert tok is not None and model is not None ids = tok.encode(prompt, return_tensors="pt").to(self._device) with torch.no_grad(): out = model.generate( ids, max_new_tokens=self.max_new_tokens, do_sample=True, top_p=self.top_p, temperature=self.temperature, pad_token_id=tok.pad_token_id, ) gen_tokens = out[0][ids.shape[1]:] raw_text = tok.decode(gen_tokens, skip_special_tokens=True) try: parsed = extract_json_object(raw_text) normalized = normalize_action(parsed) if normalized.get("action_type") == "noop": raise ValueError("normalized to noop") return ActTrace( policy=self.name, action=normalized, prompt=prompt, raw_text=raw_text, parsed=parsed, fallback_used=False, latency_ms=(time.perf_counter() - t0) * 1000, ) except Exception as parse_exc: fallback = heuristic_action(observation) return ActTrace( policy=self.name, action=fallback, prompt=prompt, raw_text=raw_text, parsed=None, fallback_used=True, latency_ms=(time.perf_counter() - t0) * 1000, notes=[ "Could not parse a valid action JSON.", f"parse_error={parse_exc!r}", ], ) except Exception as exc: fallback = heuristic_action(observation) return ActTrace( policy=self.name, action=fallback, prompt=prompt, raw_text=None, parsed=None, fallback_used=True, latency_ms=(time.perf_counter() - t0) * 1000, notes=[f"Generation failed: {exc!r}"], ) class HybridPolicy(Policy): """LLM strategist + heuristic guardrails. The trained policy proposes an action; ``_apply_guardrails`` rejects it if it would obviously hit an env penalty (preconditions, repeat, noop while work remains). When the guardrail fires, the action is swapped for the heuristic curriculum and the trace records both. This is the recommended demo mode for a partially-trained checkpoint: judges still see the LLM thinking on every turn, but failure modes that come from undertraining (broken JSON, calling ``run_operation`` before deploying) no longer burn turns. """ name = "hybrid" def __init__(self, trained: Optional[TrainedPolicy] = None) -> None: self.trained = trained or TrainedPolicy() def info(self) -> Dict[str, Any]: base = self.trained.info() base["hybrid"] = True return base def act_with_trace(self, observation: Dict[str, Any]) -> ActTrace: trace = self.trained.act_with_trace(observation) trace.policy = self.name # The trained policy may itself have already fallen back to heuristic # (e.g. parse failure). In that case, no further override is needed. if trace.fallback_used: trace.notes.append("Hybrid: trained policy already used heuristic fallback.") return trace proposed = trace.action override, notes = _apply_guardrails(proposed, observation) if override is not None: trace.proposed_action = proposed trace.action = override trace.guardrail_used = True trace.notes.extend(notes) else: trace.notes.append("Hybrid: LLM action passed guardrails.") if notes: trace.notes.extend(notes) return trace def get_policy(name: str, trained_singleton: Optional[TrainedPolicy] = None) -> Policy: """Resolve a policy by name. ``trained`` reuses the singleton if provided.""" key = (name or "").lower() if key == "random": return RandomPolicy() if key == "heuristic": return HeuristicPolicy() if key == "expert": return ExpertPolicy() if key == "trained": return trained_singleton or TrainedPolicy() if key == "hybrid": return HybridPolicy(trained_singleton) raise ValueError(f"Unknown policy: {name}")