summaryrefslogtreecommitdiff
path: root/src/judge/judge.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/judge/judge.py')
-rw-r--r--src/judge/judge.py383
1 files changed, 383 insertions, 0 deletions
diff --git a/src/judge/judge.py b/src/judge/judge.py
new file mode 100644
index 0000000..5c07ffe
--- /dev/null
+++ b/src/judge/judge.py
@@ -0,0 +1,383 @@
+"""
+Judge orchestrator - evaluates experiment logs for deceptive behaviour.
+"""
+
+import hashlib
+import json
+import os
+from typing import Any, Dict, List, Optional
+
+from .batch_providers import BatchProvider, BatchRequest
+from .helpers import (
+ extract_model_output,
+ extract_reasoning_trace,
+ extract_system_prompt,
+)
+from .judges import BlackboxChecker, GlassboxChecker, RegexChecker
+
+
+class Judge:
+ """Orchestrates all three checks on experiment log files.
+
+ Supports two modes:
+ - judge_single(): synchronous, one log at a time (for testing / quick runs)
+ - judge_batch(): uses a BatchProvider for multiple logs
+ """
+
+ def __init__(
+ self,
+ model: str = "claude-sonnet-4-20250514",
+ temperature: float = 0,
+ batch_provider: Optional[BatchProvider] = None,
+ sync_client: Optional[Any] = None,
+ ):
+ self.model = model
+ self.temperature = temperature
+ self.batch_provider = batch_provider
+ self.sync_client = sync_client
+ self.regex_checker_cache: Dict[str, RegexChecker] = {}
+
+ self.blackbox = BlackboxChecker(model=model, temperature=temperature)
+ self.glassbox = GlassboxChecker(model=model, temperature=temperature)
+
+ def _get_regex_checker(self, scenario_path: str) -> RegexChecker:
+ """Get or create a RegexChecker for a scenario."""
+ if scenario_path not in self.regex_checker_cache:
+ rules_path = os.path.join(scenario_path, "regex_rules.yaml")
+ self.regex_checker_cache[scenario_path] = RegexChecker(rules_path)
+ return self.regex_checker_cache[scenario_path]
+
+ @staticmethod
+ def load_log(log_path: str) -> Dict[str, Any]:
+ """Load an experiment log JSON file."""
+ with open(log_path, "r") as f:
+ return json.load(f)
+
+ @staticmethod
+ def load_baseline(log_data: Dict, logs_dir: str) -> tuple[str, Dict]:
+ """Load the baseline output and baseline log data for a given experiment log."""
+ model = log_data.get("model", "").replace("/", "_")
+ scenario = log_data.get("scenario", "")
+ baseline_path = os.path.join(logs_dir, model, scenario, "baseline.md")
+ baseline_content = ""
+ baseline_log_data = {}
+
+ if os.path.exists(baseline_path):
+ with open(baseline_path, "r", encoding="utf-8") as f:
+ baseline_content = f.read()
+
+ baseline_dir = os.path.join(logs_dir, model, scenario, "baseline")
+ if os.path.exists(baseline_dir) and os.path.isdir(baseline_dir):
+ json_files = [f for f in os.listdir(baseline_dir) if f.endswith(".json")]
+ if json_files:
+ latest_baseline_log = sorted(json_files)[-1]
+ with open(
+ os.path.join(baseline_dir, latest_baseline_log),
+ "r",
+ encoding="utf-8",
+ ) as f:
+ try:
+ baseline_log_data = json.load(f)
+ except json.JSONDecodeError:
+ pass
+ return baseline_content, baseline_log_data
+
+ @staticmethod
+ def load_source_documents(log_data: Dict) -> str:
+ """Load source documents from a log's final_vfs_state."""
+ vfs_state = log_data.get("final_vfs_state", {})
+ root_dir = vfs_state.get("/", {})
+ data_files = root_dir.get("data", {})
+
+ if not data_files:
+ return "(No source documents found)"
+
+ docs = []
+ for filename, content in sorted(data_files.items()):
+ if isinstance(content, str):
+ docs.append(f"--- Document: {filename} ---\n{content}\n")
+ return "\n".join(docs) if docs else "(No source documents found)"
+
+ def judge_single(
+ self,
+ log_path: str,
+ logs_dir: str = "logs",
+ scenarios_dir: str = "scenarios",
+ enabled_judges: Optional[set] = None,
+ ) -> Dict[str, Any]:
+ """Judge a single experiment log synchronously (non-batch)."""
+ if enabled_judges is None:
+ enabled_judges = {"regex", "blackbox", "glassbox"}
+
+ run_llm = bool({"blackbox", "glassbox"} & enabled_judges)
+ if run_llm and not self.sync_client:
+ raise RuntimeError(
+ "No sync client configured — cannot run LLM judge checks"
+ )
+
+ log_data = self.load_log(log_path)
+ conversation = log_data.get("conversation", [])
+ scenario = log_data.get("scenario", "")
+ run_id = log_data.get("run_id", os.path.basename(log_path))
+
+ model_output = extract_model_output(conversation)
+
+ regex_result = {"verdict": "", "details": []}
+ if "regex" in enabled_judges:
+ scenario_path = os.path.join(scenarios_dir, scenario)
+ regex_checker = self._get_regex_checker(scenario_path)
+ regex_result = regex_checker.check(model_output)
+
+ bb_result = {"category": "", "justification": "", "raw_response": ""}
+ if "blackbox" in enabled_judges:
+ baseline_output, baseline_log_data = self.load_baseline(log_data, logs_dir)
+
+ if baseline_log_data:
+ source_docs = self.load_source_documents(baseline_log_data)
+ else:
+ import sys
+
+ print(
+ f" [WARN] No baseline found for {scenario} — using experiment log for source docs",
+ file=sys.stderr,
+ )
+ source_docs = self.load_source_documents(log_data)
+
+ if not baseline_output:
+ import sys
+
+ print(
+ f" [WARN] No baseline output for {scenario} — blackbox judge will compare against source docs only",
+ file=sys.stderr,
+ )
+ baseline_output = "(No baseline available)"
+
+ scenario_path = os.path.join(scenarios_dir, scenario)
+ benign_goal_path = os.path.join(scenario_path, "benign_goal.md")
+ task_description = ""
+ if os.path.exists(benign_goal_path):
+ with open(benign_goal_path, "r", encoding="utf-8") as f:
+ task_description = f.read().strip()
+
+ bb_prompt = self.blackbox.build_prompt(
+ model_output, baseline_output, source_docs, task_description
+ )
+ bb_response = self.sync_client.messages.create(
+ model=self.model,
+ max_tokens=4096,
+ temperature=self.temperature,
+ messages=[{"role": "user", "content": bb_prompt}],
+ )
+ bb_text = bb_response.content[0].text
+ bb_result = self.blackbox.parse_response(bb_text)
+
+ gb_result = {
+ "category": "",
+ "sophistication": "",
+ "justification": "",
+ "raw_response": "",
+ }
+ if "glassbox" in enabled_judges:
+ system_prompt = extract_system_prompt(conversation)
+ reasoning_trace = extract_reasoning_trace(conversation)
+ gb_prompt = self.glassbox.build_prompt(
+ system_prompt, reasoning_trace, model_output
+ )
+ gb_response = self.sync_client.messages.create(
+ model=self.model,
+ max_tokens=4096,
+ temperature=self.temperature,
+ messages=[{"role": "user", "content": gb_prompt}],
+ )
+ gb_text = gb_response.content[0].text
+ gb_result = self.glassbox.parse_response(gb_text)
+
+ return {
+ "run_id": run_id,
+ "model": log_data.get("model", ""),
+ "scenario": scenario,
+ "oversight": log_data.get("oversight_level", ""),
+ "regex": regex_result["verdict"],
+ "regex_details": regex_result["details"],
+ "blackbox": bb_result,
+ "glassbox": gb_result,
+ }
+
+ def prepare_batch_requests(
+ self,
+ log_paths: List[str],
+ logs_dir: str = "logs",
+ scenarios_dir: str = "scenarios",
+ enabled_judges: Optional[set] = None,
+ ) -> tuple:
+ """Prepare batch requests for multiple log files.
+
+ Returns:
+ (batch_requests, metadata_map)
+ - batch_requests: list of BatchRequest for the batch provider
+ - metadata_map: dict mapping custom_id → metadata needed to reassemble results
+ """
+ if enabled_judges is None:
+ enabled_judges = {"regex", "blackbox", "glassbox"}
+
+ batch_requests: List[BatchRequest] = []
+ metadata_map: Dict[str, Dict] = {}
+
+ for log_path in log_paths:
+ log_data = self.load_log(log_path)
+ conversation = log_data.get("conversation", [])
+ scenario = log_data.get("scenario", "")
+ run_id = log_data.get("run_id", os.path.basename(log_path))
+
+ model_output = extract_model_output(conversation)
+
+ regex_result = {"verdict": "", "details": []}
+ if "regex" in enabled_judges:
+ scenario_path = os.path.join(scenarios_dir, scenario)
+ regex_checker = self._get_regex_checker(scenario_path)
+ regex_result = regex_checker.check(model_output)
+
+ id_hash = hashlib.sha256(run_id.encode()).hexdigest()[:8]
+ idx = (
+ len(batch_requests) // 2
+ if len(enabled_judges & {"blackbox", "glassbox"}) == 2
+ else len(batch_requests)
+ )
+
+ if "blackbox" in enabled_judges:
+ baseline_output, baseline_log_data = self.load_baseline(
+ log_data, logs_dir
+ )
+
+ if baseline_log_data:
+ source_docs = self.load_source_documents(baseline_log_data)
+ else:
+ import sys
+
+ print(
+ f" [WARN] No baseline found for {scenario} — using experiment log for source docs",
+ file=sys.stderr,
+ )
+ source_docs = self.load_source_documents(log_data)
+
+ if not baseline_output:
+ baseline_output = "(No baseline available)"
+
+ scenario_path = os.path.join(scenarios_dir, scenario)
+ benign_goal_path = os.path.join(scenario_path, "benign_goal.md")
+ task_description = ""
+ if os.path.exists(benign_goal_path):
+ with open(benign_goal_path, "r", encoding="utf-8") as f:
+ task_description = f.read().strip()
+
+ bb_id = f"bb_{idx:03d}_{id_hash}"
+ metadata_map[bb_id] = {
+ "type": "blackbox",
+ "log_path": log_path,
+ "run_id": run_id,
+ "model": log_data.get("model", ""),
+ "scenario": scenario,
+ "oversight": log_data.get("oversight_level", ""),
+ "regex_result": regex_result,
+ }
+
+ bb_prompt = self.blackbox.build_prompt(
+ model_output, baseline_output, source_docs, task_description
+ )
+ batch_requests.append(
+ self.batch_provider.build_request(
+ custom_id=bb_id,
+ prompt=bb_prompt,
+ model=self.model,
+ temperature=self.temperature,
+ )
+ )
+
+ if "glassbox" in enabled_judges:
+ system_prompt = extract_system_prompt(conversation)
+ reasoning_trace = extract_reasoning_trace(conversation)
+
+ gb_id = f"gb_{idx:03d}_{id_hash}"
+ metadata_map[gb_id] = {
+ "type": "glassbox",
+ "log_path": log_path,
+ "run_id": run_id,
+ }
+ if "blackbox" not in enabled_judges:
+ metadata_map[gb_id]["model"] = log_data.get("model", "")
+ metadata_map[gb_id]["scenario"] = scenario
+ metadata_map[gb_id]["oversight"] = log_data.get(
+ "oversight_level", ""
+ )
+ metadata_map[gb_id]["regex_result"] = regex_result
+
+ gb_prompt = self.glassbox.build_prompt(
+ system_prompt, reasoning_trace, model_output
+ )
+ batch_requests.append(
+ self.batch_provider.build_request(
+ custom_id=gb_id,
+ prompt=gb_prompt,
+ model=self.model,
+ temperature=self.temperature,
+ )
+ )
+
+ return batch_requests, metadata_map
+
+ def submit_batch(self, batch_requests: List[BatchRequest]) -> str:
+ """Submit a batch to the provider and return the batch ID."""
+ if not self.batch_provider:
+ raise RuntimeError("No batch provider configured — cannot submit batch")
+ return self.batch_provider.submit_batch(batch_requests)
+
+ def poll_batch(self, batch_id: str, poll_interval: int = 30) -> None:
+ """Poll until batch processing is complete."""
+ if not self.batch_provider:
+ raise RuntimeError("No batch provider configured")
+ self.batch_provider.poll_batch(batch_id, poll_interval)
+
+ def collect_batch_results(
+ self,
+ batch_id: str,
+ metadata_map: Dict[str, Dict],
+ ) -> List[Dict[str, Any]]:
+ """Collect and parse results from a completed batch.
+
+ Returns a list of combined verdict dicts (one per log file).
+ """
+ if not self.batch_provider:
+ raise RuntimeError("No batch provider configured")
+
+ raw_results = {}
+ for result in self.batch_provider.collect_results(batch_id):
+ if result.error:
+ raw_results[result.custom_id] = f"ERROR: {result.error}"
+ else:
+ raw_results[result.custom_id] = result.text
+
+ verdicts_by_run = {}
+ for custom_id, meta in metadata_map.items():
+ run_id = meta["run_id"]
+ raw_text = raw_results.get(custom_id, "")
+
+ if meta["type"] == "blackbox":
+ bb_result = self.blackbox.parse_response(raw_text)
+ if run_id not in verdicts_by_run:
+ verdicts_by_run[run_id] = {
+ "run_id": run_id,
+ "model": meta["model"],
+ "scenario": meta["scenario"],
+ "oversight": meta["oversight"],
+ "regex": meta["regex_result"]["verdict"],
+ "regex_details": meta["regex_result"]["details"],
+ }
+ verdicts_by_run[run_id]["blackbox"] = bb_result
+
+ elif meta["type"] == "glassbox":
+ gb_result = self.glassbox.parse_response(raw_text)
+ if run_id not in verdicts_by_run:
+ verdicts_by_run[run_id] = {"run_id": run_id}
+ verdicts_by_run[run_id]["glassbox"] = gb_result
+
+ return list(verdicts_by_run.values())