soup/soup_cli/eval/gate.py

369 lines
13 KiB
Python

"""Eval-Gated Training (v0.26.0 Part B).
Declarative ``evals/gate.yaml`` suites define per-task thresholds; the gate
runs them at epoch boundaries during training (or post-hoc via
``soup eval gate``) and surfaces pass / fail / regression verdicts.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Literal, Optional
import yaml
from pydantic import BaseModel, Field, field_validator
from soup_cli.utils.paths import is_under_cwd
@dataclass(frozen=True)
class GateTaskResult:
name: str
score: Optional[float]
threshold: float
baseline: Optional[float]
delta: Optional[float]
passed: bool
error: Optional[str] = None
@dataclass(frozen=True)
class GateResult:
passed: bool
regression: bool
task_results: list[GateTaskResult]
class GateTask(BaseModel):
"""One gate task — e.g. a custom eval + threshold."""
type: Literal["judge", "custom", "benchmark"] = Field(
description="Task type: judge | custom | benchmark",
)
name: str = Field(description="Task name (used as baseline key)")
threshold: float = Field(
ge=0.0, le=1000.0,
description="Minimum score to pass (scale depends on scorer)",
)
# type=custom
tasks: Optional[str] = Field(
default=None, description="JSONL file of custom eval tasks",
)
scorer: Optional[Literal["exact", "contains", "regex", "semantic"]] = Field(
default=None, description="Scorer for type=custom",
)
# type=judge
prompts: Optional[str] = Field(
default=None, description="JSONL prompts for LLM judge",
)
judge_model: Optional[str] = Field(
default=None, description="Judge model URL (e.g. ollama://llama3.1)",
)
# type=benchmark
benchmark: Optional[str] = Field(
default=None, description="Registered benchmark id (e.g. mini_mmlu)",
)
@field_validator("tasks", "prompts")
@classmethod
def _clean_path_fields(cls, value: Optional[str]) -> Optional[str]:
if value is None:
return None
if "\x00" in value:
raise ValueError("path field contains null byte")
return value
@field_validator("judge_model")
@classmethod
def _valid_judge_url(cls, value: Optional[str]) -> Optional[str]:
if value is None:
return None
# Allowlist of schemes — SSRF hardening consistent with the project.
allowed = ("ollama://", "https://", "http://localhost", "http://127.0.0.1")
if not value.startswith(allowed):
raise ValueError(
f"judge_model URL '{value}' uses disallowed scheme - "
"use ollama://, https://, or http://localhost"
)
return value
class EvalSuite(BaseModel):
"""Parsed ``evals/gate.yaml``."""
suite: str = Field(description="Suite name for display / logs")
tasks: list[GateTask] = Field(default_factory=list)
def load_suite(path: str) -> EvalSuite:
"""Load and validate an eval suite from disk."""
suite_path = Path(path)
if not is_under_cwd(suite_path):
raise ValueError(
f"eval-gate suite '{path}' is outside cwd - refusing to load"
)
if not suite_path.exists():
raise FileNotFoundError(f"eval-gate suite not found: {path}")
data = yaml.safe_load(suite_path.read_text(encoding="utf-8")) or {}
return EvalSuite(**data)
def resolve_baseline(spec: Optional[str]) -> dict[str, float]:
"""Resolve a baseline specifier to a ``{task_name: score}`` map.
- ``None`` / ``""``: return empty map
- ``registry://<id>``: look up eval_results for that entry via the
experiment tracker, keyed by benchmark
- filesystem path: JSON mapping of ``{name: score}``
"""
if not spec:
return {}
if spec.startswith("registry://"):
from soup_cli.registry.store import RegistryStore
ref = spec[len("registry://"):]
with RegistryStore() as store:
# ``resolve`` strips the scheme itself; pass the raw ref so the
# error path below reports what the user typed.
entry_id = store.resolve(ref)
if entry_id is None:
raise ValueError(
f"registry baseline not found: {ref} (use `soup registry list`)"
)
rows = store.get_eval_results(entry_id)
return {
row.get("benchmark", ""): float(row.get("score", 0.0))
for row in rows
if row.get("benchmark") and row.get("score") is not None
}
# Filesystem path
baseline_path = Path(spec)
if not is_under_cwd(baseline_path):
raise ValueError(
f"baseline file '{spec}' is outside cwd - refusing to load"
)
if not baseline_path.exists():
raise FileNotFoundError(f"baseline file not found: {spec}")
try:
data = json.loads(baseline_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise ValueError(f"invalid JSON in baseline file: {exc}") from exc
if not isinstance(data, dict):
raise ValueError(
f"baseline file must be a JSON object mapping name -> score; "
f"got {type(data).__name__}"
)
return {str(k): float(v) for k, v in data.items()}
def _parse_judge_url(judge_model: str) -> tuple[str, str, Optional[str]]:
"""Split a ``judge_model`` URL into ``(provider, model, api_base)``.
Examples:
``ollama://llama3.1`` -> ("ollama", "llama3.1", None)
``http://localhost:8000/Qwen2.5`` -> ("server", "Qwen2.5", "http://localhost:8000")
``https://api.openai.com/gpt-4o-mini`` -> ("openai", "gpt-4o-mini", "https://api.openai.com")
"""
if judge_model.startswith("ollama://"):
return ("ollama", judge_model[len("ollama://"):], None)
# http(s):// — last path segment is the model id; the rest is api_base.
# Defence-in-depth: GateTask._valid_judge_url already restricts the
# scheme to ollama:// / https:// / http://localhost / http://127.0.0.1.
# We match prefixes in the same order here. We do NOT include a bare
# ``http://`` catch-all — if validation is ever bypassed and a non-loopback
# http URL reaches us, the trailing ``raise ValueError`` will fire.
for prefix, default_provider in (
("http://localhost", "server"),
("http://127.0.0.1", "server"),
("https://", "openai"),
):
if judge_model.startswith(prefix):
try:
base, model = judge_model.rsplit("/", 1)
except ValueError as exc:
raise ValueError(
f"judge_model '{judge_model}' missing model id"
) from exc
if not model:
raise ValueError(f"judge_model '{judge_model}' missing model id")
return (default_provider, model, base)
raise ValueError(f"judge_model '{judge_model}' uses unsupported scheme")
def _run_judge_task(
task: GateTask, generate_fn: Callable[[str], str],
) -> float:
"""Run a type=judge task. Generates a completion per prompt, then asks
the judge model to score the (prompt, response) pair on a 1-10 scale.
Aggregate score is normalised to [0, 1] (mean / 10).
"""
if not task.prompts:
raise ValueError(f"task '{task.name}' is type=judge but 'prompts' is missing")
if not task.judge_model:
raise ValueError(
f"task '{task.name}' is type=judge but 'judge_model' is missing"
)
prompts_path = Path(task.prompts)
if not is_under_cwd(prompts_path):
raise ValueError(f"prompts file '{task.prompts}' is outside cwd")
if not prompts_path.exists():
raise FileNotFoundError(f"prompts file not found: {task.prompts}")
from soup_cli.eval.judge import JudgeEvaluator
provider, model, api_base = _parse_judge_url(task.judge_model)
evaluator = JudgeEvaluator(provider=provider, model=model, api_base=api_base)
items: list[dict] = []
with prompts_path.open("r", encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line:
continue
try:
row = json.loads(line)
except json.JSONDecodeError as exc:
raise ValueError(
f"invalid JSONL in {task.prompts}: {exc}"
) from exc
prompt = row.get("prompt", "")
response = generate_fn(prompt)
items.append({
"prompt": prompt,
"response": response,
"category": row.get("category", "default"),
})
if not items:
return 0.0
results = evaluator.evaluate_batch(items)
# results.overall_score is on a 1-10 scale; normalise to [0, 1].
overall = float(getattr(results, "overall_score", 0.0))
return max(0.0, min(1.0, overall / 10.0))
def _run_benchmark_task(
task: GateTask, generate_fn: Callable[[str], str],
) -> float:
"""Run a type=benchmark task using the existing forgetting-mini-benchmark."""
if not task.benchmark:
raise ValueError(
f"task '{task.name}' is type=benchmark but 'benchmark' is missing"
)
from soup_cli.eval import forgetting
runner = getattr(forgetting, "run_mini_benchmark", None)
if runner is None:
raise RuntimeError(
"mini-benchmark runner unavailable - "
"install [eval] extras or update soup-cli"
)
score = runner(benchmark=task.benchmark, generate_fn=generate_fn)
return max(0.0, min(1.0, float(score)))
def _run_custom_task(
task: GateTask, generate_fn: Callable[[str], str],
) -> float:
"""Run a type=custom task and return its aggregate score in [0, 1]."""
from dataclasses import replace
from soup_cli.eval.custom import load_eval_tasks, score_task
if not task.tasks:
raise ValueError(f"task '{task.name}' is type=custom but 'tasks' is missing")
tasks = load_eval_tasks(task.tasks)
if not tasks:
return 0.0
# Override scoring if the suite specified one (EvalTask.scoring field)
if task.scorer is not None:
tasks = [replace(t, scoring=task.scorer) for t in tasks]
total = 0.0
for eval_task in tasks:
output = generate_fn(eval_task.prompt)
result = score_task(eval_task, output)
total += float(result.score)
return total / len(tasks)
def run_gate(
suite: EvalSuite,
*,
generate_fn: Callable[[str], str],
baseline: Optional[dict[str, float]] = None,
regression_threshold: float = 0.05,
) -> GateResult:
"""Run every task in ``suite`` and return an aggregate verdict.
``generate_fn`` accepts a prompt and returns the model output. Injecting
this keeps the gate testable without a live model.
"""
baseline = baseline or {}
task_results: list[GateTaskResult] = []
any_regressed = False
any_failed_threshold = False
for task in suite.tasks:
score: Optional[float]
error: Optional[str] = None
try:
if task.type == "custom":
score = _run_custom_task(task, generate_fn)
elif task.type == "judge":
score = _run_judge_task(task, generate_fn)
elif task.type == "benchmark":
score = _run_benchmark_task(task, generate_fn)
else:
# Pydantic Literal already restricts task.type, so this is a
# belt-and-braces fallthrough.
raise ValueError(f"unknown task type: {task.type}")
except (ValueError, FileNotFoundError, OSError, RuntimeError) as exc:
score = None
error = str(exc)
except Exception as exc: # noqa: BLE001 — surface as score=None, never silent pass
score = None
error = f"{type(exc).__name__}: {exc}"
if score is None:
# Failed evaluation never silently passes the gate.
passed_threshold = False
base_score = baseline.get(task.name)
delta = None
regressed = False
else:
passed_threshold = score >= task.threshold
base_score = baseline.get(task.name)
delta = None
regressed = False
if base_score is not None:
delta = score - base_score
if delta < -abs(regression_threshold):
regressed = True
if not passed_threshold:
any_failed_threshold = True
if regressed:
any_regressed = True
task_results.append(GateTaskResult(
name=task.name,
score=score,
threshold=task.threshold,
baseline=base_score,
delta=delta,
passed=passed_threshold and not regressed,
error=error,
))
return GateResult(
passed=not (any_failed_threshold or any_regressed),
regression=any_regressed,
task_results=task_results,
)