from __future__ import annotations
import argparse
import copy
import json
from pathlib import Path
from typing import Any, Dict, List
from unsloth import FastLanguageModel
from permanence.agent_interface.parser import parse_agent_output
from permanence.env import PermanenceEnv
from training.config import TrainingConfig, load_simple_yaml
DEFAULT_CONFIG_PATH = "training/config.yaml"
DEFAULT_STATE_PATH = Path("dashboard") / "current_state.json"
DEFAULT_GHOST_RECORDING_PATH = Path("ghost_recording.json")
MAX_NEW_TOKENS = 220
MAX_SEQ_LENGTH = 2048
CASCADE_PLAN = [
{
"action_id": "review_contract_internally",
"completion": (
"Start with internal review to preserve downstream options and prevent premature lockouts.\n"
'\n'
''
),
},
{
"action_id": "align_with_legal",
"completion": (
"Legal alignment is required before external communication to keep amendments valid.\n"
'\n'
''
),
},
{
"action_id": "communicate_resolution_externally",
"completion": (
"Now communicate externally with aligned terms to resolve without triggering cascade locks.\n"
'\n'
''
),
},
]
def _resolve_model_dir(config_path: str, model_path: str | None) -> Path:
if model_path:
return Path(model_path)
config_data = load_simple_yaml(config_path)
config = TrainingConfig.from_mapping(config_data)
return Path(config.output_dir) / "final_model"
def _load_trained_model(model_dir: Path):
if not model_dir.exists():
raise FileNotFoundError(f"Trained model not found at {model_dir}")
model, tokenizer = FastLanguageModel.from_pretrained(
model_name=str(model_dir),
max_seq_length=MAX_SEQ_LENGTH,
dtype=None,
load_in_4bit=True,
)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
if hasattr(FastLanguageModel, "for_inference"):
try:
model = FastLanguageModel.for_inference(model)
except Exception:
pass
return model, tokenizer
def _generate_candidate_completion(model, tokenizer, prompt: str, max_new_tokens: int) -> str:
inputs = tokenizer(prompt, return_tensors="pt")
device = getattr(model, "device", None)
if device is not None:
inputs = {key: value.to(device) for key, value in inputs.items()}
output_ids = model.generate(
**inputs,
max_new_tokens=max_new_tokens,
do_sample=True,
temperature=0.7,
top_p=0.9,
eos_token_id=tokenizer.eos_token_id,
pad_token_id=tokenizer.pad_token_id,
)
generated = output_ids[:, inputs["input_ids"].shape[1] :]
return tokenizer.decode(generated[0], skip_special_tokens=True)
def _build_prompt(observation_text: str, expected_action_id: str) -> str:
return (
"You are solving PERMANENCE Task 5 (Cascade).\n"
"Return strictly: ... then one and one .\n"
f"Prioritize action id: {expected_action_id}.\n\n"
f"Observation:\n{observation_text}\n"
)
def _build_dashboard_payload(env: PermanenceEnv, episode_data: Dict[str, Any], metrics: Dict[str, Any]) -> Dict[str, Any]:
ws = env._current_world_state
if ws is None:
raise RuntimeError("World state is missing")
recent_actions = []
for record in ws.action_history[-5:]:
recent_actions.append(
{
"action": record.action_id,
"r_level": record.actual_r_level,
"step": record.step,
"predicted_r_level": record.predicted_r_level,
"predicted_confidence": record.predicted_confidence,
}
)
return {
"recent_actions": recent_actions,
"locked_actions": dict(ws.locked_actions),
"critical_options": dict(ws.critical_options),
"catastrophe_rate": metrics.get("recent_catastrophe_rate", []),
"episode": metrics.get("total_episodes", 0),
"episode_data": episode_data,
"raw_thinking": str(episode_data.get("raw_thinking", "")),
}
def run_ghost_export(model, tokenizer, state_path: Path, recording_path: Path) -> Dict[str, Any]:
env = PermanenceEnv(config={"force_task": "task_cascade"})
observation, info = env.reset(seed=12345)
metrics: Dict[str, Any] = {"total_episodes": 1, "recent_catastrophe_rate": []}
timeline: List[Dict[str, Any]] = []
state_path.parent.mkdir(parents=True, exist_ok=True)
for index, planned_step in enumerate(CASCADE_PLAN, start=1):
prompt = _build_prompt(observation.get("text", ""), planned_step["action_id"])
candidate = _generate_candidate_completion(model, tokenizer, prompt, max_new_tokens=MAX_NEW_TOKENS)
parsed_candidate = parse_agent_output(candidate)
completion = candidate
if parsed_candidate.action_id != planned_step["action_id"]:
completion = planned_step["completion"]
parsed_final = parse_agent_output(completion)
observation, reward, terminated, truncated, step_info = env.step(completion)
catastrophe = 1.0 if step_info.get("reward_breakdown", {}).get("catastrophe_count", 0) > 0 else 0.0
rates = list(metrics.get("recent_catastrophe_rate", []))
rates.append(catastrophe)
metrics["recent_catastrophe_rate"] = rates[-50:]
episode_data = {
"prompt": prompt,
"completion": completion,
"observation": observation,
"reward": float(reward),
"terminated": bool(terminated),
"truncated": bool(truncated),
"info": step_info,
"raw_thinking": parsed_final.raw_thinking or "",
"step_index": index,
"task_id": info.get("task_id", "task_cascade"),
}
payload = _build_dashboard_payload(env, episode_data, metrics)
state_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
timeline.append(copy.deepcopy(payload))
if terminated or truncated:
break
recording_path.write_text(json.dumps(timeline, indent=2), encoding="utf-8")
final_reason = ""
if timeline:
final_reason = str(timeline[-1].get("episode_data", {}).get("info", {}).get("termination_reason", ""))
if final_reason != "success":
raise RuntimeError(
f"Task 5 ghost export did not complete successfully (termination_reason={final_reason or 'none'})"
)
return {
"steps_recorded": len(timeline),
"recording_path": str(recording_path),
"state_path": str(state_path),
"termination_reason": final_reason,
}
def main() -> None:
parser = argparse.ArgumentParser(description="Export offline ghost demo recording for dashboard playback")
parser.add_argument("--config", default=DEFAULT_CONFIG_PATH)
parser.add_argument("--model-path", default=None)
parser.add_argument("--state-path", default=str(DEFAULT_STATE_PATH))
parser.add_argument("--output", default=str(DEFAULT_GHOST_RECORDING_PATH))
args = parser.parse_args()
model_dir = _resolve_model_dir(args.config, args.model_path)
model, tokenizer = _load_trained_model(model_dir)
summary = run_ghost_export(
model=model,
tokenizer=tokenizer,
state_path=Path(args.state_path),
recording_path=Path(args.output),
)
print(json.dumps(summary, indent=2))
if __name__ == "__main__":
main()