Import upstream Verifiers episodes into Bench
This commit is contained in:
@@ -38,6 +38,7 @@ kbench snapshot your-node # live serving flags, for models.yaml
|
|||||||
|
|
||||||
kbench perf brain # perf sweep only (no task authoring needed)
|
kbench perf brain # perf sweep only (no task authoring needed)
|
||||||
kbench run brain --limit 50 # quality + perf -> results/*.json
|
kbench run brain --limit 50 # quality + perf -> results/*.json
|
||||||
|
kbench import-verifiers brain <run-dir> --task <environment-name>
|
||||||
kbench results # every score card recorded so far
|
kbench results # every score card recorded so far
|
||||||
kbench card results/<file> # re-render one
|
kbench card results/<file> # re-render one
|
||||||
```
|
```
|
||||||
@@ -48,6 +49,13 @@ Raw Inspect logs land in `logs/` (gitignored). View them with:
|
|||||||
.venv/bin/inspect view --log-dir logs
|
.venv/bin/inspect view --log-dir logs
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Prime Verifiers v1 `traces.jsonl` can enter the same scorecard with
|
||||||
|
`kbench import-verifiers`. Bench reads the upstream episode artifact; it does
|
||||||
|
not execute or copy Verifiers' harness. The importer stores episode scores,
|
||||||
|
task-content fingerprints and short assistant excerpts, never task data, tool
|
||||||
|
arguments or full conversations. Inspect AI remains the native runner for
|
||||||
|
Bench tasks.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -58,6 +66,7 @@ kbench/
|
|||||||
perf.py async load generator, any OpenAI-compatible endpoint
|
perf.py async load generator, any OpenAI-compatible endpoint
|
||||||
results.py the committed result schema
|
results.py the committed result schema
|
||||||
run.py orchestration: target + tasks + perf -> score card
|
run.py orchestration: target + tasks + perf -> score card
|
||||||
|
verifiers_import.py upstream Verifiers episode JSONL -> QualityResult
|
||||||
data/
|
data/
|
||||||
public/ a few example samples, shown on the site
|
public/ a few example samples, shown on the site
|
||||||
private/ the real holdout — never leaves this repo
|
private/ the real holdout — never leaves this repo
|
||||||
|
|||||||
@@ -198,3 +198,33 @@ reverse-engineer the private set (D3) by probing it one sample at a time.
|
|||||||
**Why.** The product does not need a public code-execution service. Manual approval is the intended workflow and keeps ownership of scarce hardware and private evaluation data clear. The Lumbridge control plane stores the inert review record with status `suggested`; only an owner can advance its lifecycle.
|
**Why.** The product does not need a public code-execution service. Manual approval is the intended workflow and keeps ownership of scarce hardware and private evaluation data clear. The Lumbridge control plane stores the inert review record with status `suggested`; only an owner can advance its lifecycle.
|
||||||
|
|
||||||
**Supersedes D8 for product behavior.** The sandbox described in D8 is no longer a launch dependency because no public suggestion is automatically executed. If automated third-party execution is ever proposed again, it requires a new explicit decision and the full D8 isolation boundary first.
|
**Supersedes D8 for product behavior.** The sandbox described in D8 is no longer a launch dependency because no public suggestion is automatically executed. If automated third-party execution is ever proposed again, it requires a new explicit decision and the full D8 isolation boundary first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D11 — Import Verifiers episodes; do not add another harness
|
||||||
|
|
||||||
|
**Decision.** Inspect AI remains Bench's native execution engine. Online Tera
|
||||||
|
and Prime-RL evaluations run in upstream Prime Verifiers and enter Bench through
|
||||||
|
the durable Verifiers v1 `traces.jsonl` artifact. Bench parses that artifact
|
||||||
|
into its existing `QualityResult` and `SampleOutcome` fields; the scorecard
|
||||||
|
schema is not changed.
|
||||||
|
|
||||||
|
One Verifiers episode becomes one Bench sample. Only trainable traces contribute
|
||||||
|
to its score, so a modeled user or judge seat does not dilute the assistant's
|
||||||
|
result. By default the score is the mean summed weighted reward of those traces;
|
||||||
|
a named trace metric can be selected explicitly. The card stores episode and
|
||||||
|
trace ids, task identity hashes, reward names, pass/fail and at most the normal
|
||||||
|
400-character assistant excerpt. It never copies task data, prompts, tool
|
||||||
|
arguments, error messages or the full conversation.
|
||||||
|
|
||||||
|
**Why.** Re-running a Verifiers environment through an Inspect-shaped adapter
|
||||||
|
would duplicate tool-loop and multi-agent behavior and could change the thing
|
||||||
|
being measured. The completed episode is already the evidence boundary. Bench's
|
||||||
|
unique work is longitudinal comparison against an exact model/host target, not
|
||||||
|
owning every runner.
|
||||||
|
|
||||||
|
**Cost.** Inspect and Verifiers logs have different notions of a sample and a
|
||||||
|
headline score. The import command therefore requires a target and task name,
|
||||||
|
records its pass threshold and source-file digest, and labels sampling as
|
||||||
|
Verifiers rather than pretending Inspect produced it. Comparability still
|
||||||
|
requires the same target snapshot, task fingerprint, score source and threshold.
|
||||||
|
|||||||
+48
-2
@@ -12,11 +12,12 @@ from rich.console import Console
|
|||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
from . import tasks as task_catalog
|
from . import tasks as task_catalog
|
||||||
from .compare import compare as compare_runs, direction
|
from .compare import compare as compare_runs
|
||||||
from .sweep import analyse, point_from_run
|
from .compare import direction
|
||||||
from .registry import load_registry
|
from .registry import load_registry
|
||||||
from .results import RESULTS_DIR, Run
|
from .results import RESULTS_DIR, Run
|
||||||
from .run import build_run, run_perf, run_quality
|
from .run import build_run, run_perf, run_quality
|
||||||
|
from .sweep import analyse, point_from_run
|
||||||
|
|
||||||
app = typer.Typer(
|
app = typer.Typer(
|
||||||
add_completion=False,
|
add_completion=False,
|
||||||
@@ -307,6 +308,51 @@ def card_cmd(path: Path) -> None:
|
|||||||
_print_card_dict(data)
|
_print_card_dict(data)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command("import-verifiers")
|
||||||
|
def import_verifiers_cmd(
|
||||||
|
target: str = typer.Argument(..., help="registered model/host target that produced the traces"),
|
||||||
|
path: Path = typer.Argument(..., help="Verifiers run directory or traces.jsonl"),
|
||||||
|
task: str = typer.Option(..., help="Bench task name for this imported environment"),
|
||||||
|
tier: str = typer.Option("signal", help="reference | signal | canary"),
|
||||||
|
pass_threshold: float = typer.Option(1.0, help="score required for an episode to pass"),
|
||||||
|
primary_metric: str | None = typer.Option(
|
||||||
|
None, help="trace metric to import; defaults to summed weighted rewards"
|
||||||
|
),
|
||||||
|
save: bool = typer.Option(True, help="write the existing results/*.json scorecard"),
|
||||||
|
) -> None:
|
||||||
|
"""Import upstream Verifiers episodes; execute no model and no harness."""
|
||||||
|
from .verifiers_import import FORMAT, VerifiersImportError, import_quality, source_sha256
|
||||||
|
|
||||||
|
registry = load_registry()
|
||||||
|
registered = registry.target(target)
|
||||||
|
try:
|
||||||
|
quality = import_quality(
|
||||||
|
path,
|
||||||
|
task=task,
|
||||||
|
tier=tier,
|
||||||
|
pass_threshold=pass_threshold,
|
||||||
|
primary_metric=primary_metric,
|
||||||
|
)
|
||||||
|
digest = source_sha256(path)
|
||||||
|
except VerifiersImportError as exc:
|
||||||
|
raise typer.BadParameter(str(exc), param_hint="path") from exc
|
||||||
|
|
||||||
|
run = build_run(registry, registered, quality=[quality], perf=None)
|
||||||
|
run.runner["quality_import"] = {
|
||||||
|
"format": FORMAT,
|
||||||
|
"source_sha256": digest,
|
||||||
|
}
|
||||||
|
run.sampling = {
|
||||||
|
"source": "verifiers",
|
||||||
|
"primary_metric": primary_metric,
|
||||||
|
"pass_threshold": pass_threshold,
|
||||||
|
}
|
||||||
|
_print_card(run)
|
||||||
|
if save:
|
||||||
|
saved = run.save()
|
||||||
|
console.print(f"\n[green]saved[/green] {saved.relative_to(Path.cwd())}")
|
||||||
|
|
||||||
|
|
||||||
def _print_card(run: Run) -> None:
|
def _print_card(run: Run) -> None:
|
||||||
_print_card_dict(run.to_dict())
|
_print_card_dict(run.to_dict())
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
"""Import Prime Verifiers v1 episode JSONL into Bench's existing scorecard.
|
||||||
|
|
||||||
|
Verifiers owns rollout execution and its trace schema. Bench reads the durable
|
||||||
|
``traces.jsonl`` artifact with the standard library, reduces one episode to one
|
||||||
|
sample outcome, and keeps its own result schema unchanged. Raw prompts, task
|
||||||
|
data, tool arguments and full conversations never enter the committed card.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .results import QualityResult, SampleOutcome, truncate
|
||||||
|
|
||||||
|
FORMAT = "verifiers-v1-episode-jsonl"
|
||||||
|
|
||||||
|
|
||||||
|
class VerifiersImportError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def traces_path(path: str | Path) -> Path:
|
||||||
|
candidate = Path(path)
|
||||||
|
if candidate.is_dir():
|
||||||
|
candidate = candidate / "traces.jsonl"
|
||||||
|
if not candidate.is_file():
|
||||||
|
raise VerifiersImportError(f"Verifiers traces file not found: {candidate}")
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def source_sha256(path: str | Path) -> str:
|
||||||
|
return hashlib.sha256(traces_path(path).read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _reward_value(rewards: Any) -> tuple[float | None, list[str]]:
|
||||||
|
if not isinstance(rewards, dict):
|
||||||
|
return None, []
|
||||||
|
total = 0.0
|
||||||
|
found = False
|
||||||
|
names: list[str] = []
|
||||||
|
for name, reward in sorted(rewards.items()):
|
||||||
|
if reward is None:
|
||||||
|
continue
|
||||||
|
if isinstance(reward, (int, float)) and not isinstance(reward, bool):
|
||||||
|
score, weight = float(reward), 1.0
|
||||||
|
elif isinstance(reward, dict) and isinstance(reward.get("score"), (int, float)):
|
||||||
|
score = float(reward["score"])
|
||||||
|
weight = float(reward.get("weight", 1.0))
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
found = True
|
||||||
|
names.append(str(name))
|
||||||
|
total += score * weight
|
||||||
|
return (total if found else None), names
|
||||||
|
|
||||||
|
|
||||||
|
def _trace_value(trace: dict[str, Any], primary_metric: str | None) -> tuple[float | None, list[str]]:
|
||||||
|
if primary_metric:
|
||||||
|
metrics = trace.get("metrics") or {}
|
||||||
|
value = metrics.get(primary_metric) if isinstance(metrics, dict) else None
|
||||||
|
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||||
|
return float(value), [primary_metric]
|
||||||
|
return None, []
|
||||||
|
return _reward_value(trace.get("rewards"))
|
||||||
|
|
||||||
|
|
||||||
|
def _content_text(content: Any) -> str:
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content
|
||||||
|
if isinstance(content, list):
|
||||||
|
parts: list[str] = []
|
||||||
|
for part in content:
|
||||||
|
if isinstance(part, str):
|
||||||
|
parts.append(part)
|
||||||
|
elif isinstance(part, dict) and isinstance(part.get("text"), str):
|
||||||
|
parts.append(part["text"])
|
||||||
|
return "".join(parts)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _last_assistant_text(trace: dict[str, Any]) -> str | None:
|
||||||
|
for node in reversed(trace.get("nodes") or []):
|
||||||
|
if not isinstance(node, dict):
|
||||||
|
continue
|
||||||
|
message = node.get("message") or {}
|
||||||
|
if isinstance(message, dict) and message.get("role") == "assistant":
|
||||||
|
text = _content_text(message.get("content")).strip()
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
completion = trace.get("completion")
|
||||||
|
return completion.strip() if isinstance(completion, str) and completion.strip() else None
|
||||||
|
|
||||||
|
|
||||||
|
def _eligible_traces(episode: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
traces = episode.get("traces")
|
||||||
|
if not isinstance(traces, list):
|
||||||
|
raise VerifiersImportError("episode field 'traces' must be a list")
|
||||||
|
typed = [trace for trace in traces if isinstance(trace, dict)]
|
||||||
|
trainable = [
|
||||||
|
trace
|
||||||
|
for trace in typed
|
||||||
|
if (trace.get("agent") or {}).get("trainable", True) is not False
|
||||||
|
]
|
||||||
|
# Eval files from older v1 builds may omit trainable. If an env explicitly
|
||||||
|
# marks every trace non-trainable, keep the traces rather than manufacturing
|
||||||
|
# an empty sample.
|
||||||
|
return trainable or typed
|
||||||
|
|
||||||
|
|
||||||
|
def _task_identity(episode: dict[str, Any]) -> str:
|
||||||
|
task = episode.get("task") or {}
|
||||||
|
if not isinstance(task, dict):
|
||||||
|
task = {}
|
||||||
|
durable = task.get("key") or task.get("hash")
|
||||||
|
if durable:
|
||||||
|
return str(durable)
|
||||||
|
# Hash task data locally if an older trace lacks key/hash. The card receives
|
||||||
|
# only this digest; private task content is never copied into it.
|
||||||
|
canonical = json.dumps(task.get("data"), sort_keys=True, separators=(",", ":"))
|
||||||
|
return hashlib.sha256(canonical.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _dataset_fingerprint(episodes: list[dict[str, Any]]) -> str | None:
|
||||||
|
if not episodes:
|
||||||
|
return None
|
||||||
|
identities = [
|
||||||
|
f"{(episode.get('env') or {}).get('id', '')}\x1f{_task_identity(episode)}"
|
||||||
|
for episode in episodes
|
||||||
|
]
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
for identity in sorted(identities):
|
||||||
|
digest.update(identity.encode())
|
||||||
|
digest.update(b"\x1e")
|
||||||
|
return f"sha256:{digest.hexdigest()[:16]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _duration(episodes: list[dict[str, Any]]) -> float | None:
|
||||||
|
total = 0.0
|
||||||
|
measured = False
|
||||||
|
for episode in episodes:
|
||||||
|
starts: list[float] = []
|
||||||
|
ends: list[float] = []
|
||||||
|
for trace in episode.get("traces") or []:
|
||||||
|
timing = trace.get("timing") if isinstance(trace, dict) else None
|
||||||
|
if not isinstance(timing, dict):
|
||||||
|
continue
|
||||||
|
for span in timing.values():
|
||||||
|
if not isinstance(span, dict):
|
||||||
|
continue
|
||||||
|
start, end = span.get("start"), span.get("end")
|
||||||
|
if isinstance(start, (int, float)) and start > 0:
|
||||||
|
starts.append(float(start))
|
||||||
|
if isinstance(end, (int, float)) and end > 0:
|
||||||
|
ends.append(float(end))
|
||||||
|
if starts and ends and max(ends) >= min(starts):
|
||||||
|
total += max(ends) - min(starts)
|
||||||
|
measured = True
|
||||||
|
return round(total, 6) if measured else None
|
||||||
|
|
||||||
|
|
||||||
|
def read_episodes(path: str | Path) -> list[dict[str, Any]]:
|
||||||
|
source = traces_path(path)
|
||||||
|
episodes: list[dict[str, Any]] = []
|
||||||
|
ids: set[str] = set()
|
||||||
|
for line_number, line in enumerate(source.read_text().splitlines(), start=1):
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
episode = json.loads(line)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise VerifiersImportError(f"{source}:{line_number}: invalid JSON: {exc.msg}") from exc
|
||||||
|
if not isinstance(episode, dict):
|
||||||
|
raise VerifiersImportError(f"{source}:{line_number}: episode must be an object")
|
||||||
|
episode_id = str(episode.get("id") or "")
|
||||||
|
if not episode_id:
|
||||||
|
raise VerifiersImportError(f"{source}:{line_number}: episode has no id")
|
||||||
|
if episode_id in ids:
|
||||||
|
raise VerifiersImportError(f"{source}:{line_number}: duplicate episode id {episode_id!r}")
|
||||||
|
ids.add(episode_id)
|
||||||
|
_eligible_traces(episode) # validate the durable v1 boundary now
|
||||||
|
episodes.append(episode)
|
||||||
|
if not episodes:
|
||||||
|
raise VerifiersImportError(f"{source} contains no episodes")
|
||||||
|
return episodes
|
||||||
|
|
||||||
|
|
||||||
|
def import_quality(
|
||||||
|
path: str | Path,
|
||||||
|
task: str,
|
||||||
|
tier: str = "signal",
|
||||||
|
pass_threshold: float = 1.0,
|
||||||
|
primary_metric: str | None = None,
|
||||||
|
) -> QualityResult:
|
||||||
|
"""Reduce Verifiers episodes to one existing Bench QualityResult."""
|
||||||
|
if tier not in {"reference", "signal", "canary"}:
|
||||||
|
raise VerifiersImportError("tier must be reference, signal or canary")
|
||||||
|
episodes = read_episodes(path)
|
||||||
|
outcomes: list[SampleOutcome] = []
|
||||||
|
versions: set[str] = set()
|
||||||
|
|
||||||
|
for episode in episodes:
|
||||||
|
traces = _eligible_traces(episode)
|
||||||
|
values: list[float] = []
|
||||||
|
reward_names: set[str] = set()
|
||||||
|
excerpts: list[str] = []
|
||||||
|
trace_ids: list[str] = []
|
||||||
|
agents: list[str] = []
|
||||||
|
trace_ok = True
|
||||||
|
error_types = {
|
||||||
|
str(error.get("type"))
|
||||||
|
for error in (episode.get("errors") or [])
|
||||||
|
if isinstance(error, dict) and error.get("type")
|
||||||
|
}
|
||||||
|
for trace in traces:
|
||||||
|
value, names = _trace_value(trace, primary_metric)
|
||||||
|
if value is not None:
|
||||||
|
values.append(value)
|
||||||
|
reward_names.update(names)
|
||||||
|
trace_ids.append(str(trace.get("id") or ""))
|
||||||
|
agent = trace.get("agent") or {}
|
||||||
|
agents.append(str(agent.get("name") or "agent"))
|
||||||
|
trace_ok = trace_ok and bool(trace.get("ok", False))
|
||||||
|
verifiers = trace.get("verifiers") or {}
|
||||||
|
if isinstance(verifiers, dict) and verifiers.get("version"):
|
||||||
|
versions.add(str(verifiers["version"]))
|
||||||
|
error_types.update(
|
||||||
|
str(error.get("type"))
|
||||||
|
for error in (trace.get("errors") or [])
|
||||||
|
if isinstance(error, dict) and error.get("type")
|
||||||
|
)
|
||||||
|
text = _last_assistant_text(trace)
|
||||||
|
if text:
|
||||||
|
excerpts.append(f"{agents[-1]}: {text}" if len(traces) > 1 else text)
|
||||||
|
|
||||||
|
score = sum(values) / len(values) if values else 0.0
|
||||||
|
episode_ok = bool(episode.get("ok", False)) and trace_ok
|
||||||
|
outcomes.append(
|
||||||
|
SampleOutcome(
|
||||||
|
sample_id=str(episode["id"]),
|
||||||
|
score=round(score, 6),
|
||||||
|
passed=episode_ok and score >= pass_threshold,
|
||||||
|
excerpt=truncate("\n\n".join(excerpts) or None),
|
||||||
|
metadata={
|
||||||
|
"source": FORMAT,
|
||||||
|
"env": str((episode.get("env") or {}).get("id") or ""),
|
||||||
|
"task_key": _task_identity(episode),
|
||||||
|
"trace_ids": trace_ids,
|
||||||
|
"agents": agents,
|
||||||
|
"episode_ok": episode_ok,
|
||||||
|
"score_source": f"metric:{primary_metric}" if primary_metric else "weighted_rewards",
|
||||||
|
"reward_names": sorted(reward_names),
|
||||||
|
"error_types": sorted(error_types),
|
||||||
|
"ungraded": not values,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
accuracy = sum(outcome.passed for outcome in outcomes) / len(outcomes)
|
||||||
|
mean_score = sum(outcome.score for outcome in outcomes) / len(outcomes)
|
||||||
|
metrics = {
|
||||||
|
"accuracy": round(accuracy, 6),
|
||||||
|
"mean_score": round(mean_score, 6),
|
||||||
|
"pass_threshold": float(pass_threshold),
|
||||||
|
}
|
||||||
|
return QualityResult(
|
||||||
|
task=task,
|
||||||
|
tier=tier,
|
||||||
|
dataset_version=("verifiers:" + ",".join(sorted(versions))) if versions else None,
|
||||||
|
dataset_fingerprint=_dataset_fingerprint(episodes),
|
||||||
|
n_samples=len(episodes),
|
||||||
|
metrics=metrics,
|
||||||
|
samples=outcomes,
|
||||||
|
duration_s=_duration(episodes),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FORMAT",
|
||||||
|
"VerifiersImportError",
|
||||||
|
"import_quality",
|
||||||
|
"read_episodes",
|
||||||
|
"source_sha256",
|
||||||
|
"traces_path",
|
||||||
|
]
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""Prime Verifiers v1 episode JSONL enters Bench without a second harness."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from kbench.verifiers_import import (
|
||||||
|
VerifiersImportError,
|
||||||
|
import_quality,
|
||||||
|
read_episodes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def trace(
|
||||||
|
trace_id: str,
|
||||||
|
reward: float,
|
||||||
|
*,
|
||||||
|
agent: str = "agent",
|
||||||
|
trainable: bool = True,
|
||||||
|
answer: str = "done",
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"id": trace_id,
|
||||||
|
"verifiers": {"version": "0.3.1"},
|
||||||
|
"agent": {"name": agent, "trainable": trainable},
|
||||||
|
"ok": True,
|
||||||
|
"rewards": {"task": {"score": reward, "weight": 1.0}},
|
||||||
|
"metrics": {"strict": reward},
|
||||||
|
"nodes": [{"message": {"role": "assistant", "content": answer}}],
|
||||||
|
"timing": {"agent": {"start": 10.0, "end": 12.0}},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def episode(episode_id: str, traces: list[dict], task_key: str = "task-1") -> dict:
|
||||||
|
return {
|
||||||
|
"id": episode_id,
|
||||||
|
"env": {"id": "example-tool-loop-v1"},
|
||||||
|
"task": {
|
||||||
|
"type": "OfficeJobTask",
|
||||||
|
"key": task_key,
|
||||||
|
"data": {"private_prompt": "must never enter the score card"},
|
||||||
|
},
|
||||||
|
"ok": True,
|
||||||
|
"errors": [],
|
||||||
|
"traces": traces,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class VerifiersImportTests(unittest.TestCase):
|
||||||
|
def write(self, root: Path, rows: list[dict]) -> Path:
|
||||||
|
path = root / "traces.jsonl"
|
||||||
|
path.write_text("".join(json.dumps(row) + "\n" for row in rows))
|
||||||
|
return path
|
||||||
|
|
||||||
|
def test_one_episode_becomes_one_existing_sample_outcome(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = self.write(Path(tmp), [episode("ep-1", [trace("tr-1", 1.0)])])
|
||||||
|
result = import_quality(path, "example-tool-loop")
|
||||||
|
self.assertEqual(result.n_samples, 1)
|
||||||
|
self.assertEqual(result.metrics["accuracy"], 1.0)
|
||||||
|
self.assertEqual(result.samples[0].sample_id, "ep-1")
|
||||||
|
self.assertEqual(result.samples[0].excerpt, "done")
|
||||||
|
self.assertNotIn("private_prompt", json.dumps(result.samples[0].metadata))
|
||||||
|
self.assertEqual(result.dataset_version, "verifiers:0.3.1")
|
||||||
|
|
||||||
|
def test_multi_agent_episode_ignores_non_trainable_user_simulator(self) -> None:
|
||||||
|
rows = [
|
||||||
|
episode(
|
||||||
|
"ep-1",
|
||||||
|
[
|
||||||
|
trace("assistant", 0.75, agent="assistant"),
|
||||||
|
trace("user", 0.0, agent="user", trainable=False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
result = import_quality(self.write(Path(tmp), rows), "user-sim", pass_threshold=0.5)
|
||||||
|
self.assertEqual(result.samples[0].score, 0.75)
|
||||||
|
self.assertTrue(result.samples[0].passed)
|
||||||
|
self.assertEqual(result.samples[0].metadata["agents"], ["assistant"])
|
||||||
|
|
||||||
|
def test_named_metric_can_be_the_score_source(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = self.write(Path(tmp), [episode("ep-1", [trace("tr-1", 0.25)])])
|
||||||
|
result = import_quality(path, "strict", pass_threshold=0.2, primary_metric="strict")
|
||||||
|
self.assertEqual(result.samples[0].score, 0.25)
|
||||||
|
self.assertEqual(result.samples[0].metadata["score_source"], "metric:strict")
|
||||||
|
|
||||||
|
def test_fingerprint_uses_task_identity_not_line_order(self) -> None:
|
||||||
|
rows = [
|
||||||
|
episode("ep-1", [trace("tr-1", 1.0)], "a"),
|
||||||
|
episode("ep-2", [trace("tr-2", 0.0)], "b"),
|
||||||
|
]
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
a = import_quality(self.write(root, rows), "t").dataset_fingerprint
|
||||||
|
b = import_quality(self.write(root, list(reversed(rows))), "t").dataset_fingerprint
|
||||||
|
self.assertEqual(a, b)
|
||||||
|
|
||||||
|
def test_duplicate_episode_ids_are_refused(self) -> None:
|
||||||
|
row = episode("same", [trace("tr-1", 1.0)])
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = self.write(Path(tmp), [row, row])
|
||||||
|
with self.assertRaises(VerifiersImportError):
|
||||||
|
read_episodes(path)
|
||||||
|
|
||||||
|
def test_directory_resolves_the_upstream_traces_filename(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
self.write(root, [episode("ep-1", [trace("tr-1", 1.0)])])
|
||||||
|
self.assertEqual(len(read_episodes(root)), 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user