orchestrator: SFT training + eval harness

Tokenize the cleaned routing data (all-turns masking for ChatML),
full-parameter FSDP SFT (fsdp4/8 configs for Lambda 8xH100), and an eval
backend that drives the fine-tuned orchestrator as the scored model over
GAIA/MMLU-Pro/SuperGPQA. Includes split-making, Braintrust upload, eval
sample rendering, scorer fixes, and a boxed-tool-call salvage for SFT'd
checkpoints that emit their action inside \boxed{}.
This commit is contained in:
Andrew Park
2026-07-15 13:47:06 -07:00
parent cef5eb749a
commit 1a843d1ece
18 changed files with 2105 additions and 36 deletions
+10
View File
@@ -130,3 +130,13 @@ learning.db
minion_logs/
*.oj-debug.json
oj-debug.*.json
# Generated orchestrator SFT/eval data artifacts
data/
# Training run artifacts (model checkpoints + W&B run dirs — regenerated, large)
checkpoints/
wandb/
# Local LiteLLM spend-tracking proxy (machine-specific, not part of the package)
litellm/
+358
View File
@@ -0,0 +1,358 @@
#!/usr/bin/env python
"""Run the ToolOrchestra orchestrator over N-sample subsets of several
benchmarks and score them with the existing eval infra.
Reuses, verbatim:
* ``openjarvis.evals.cli._build_dataset`` / ``_build_scorer`` /
``_build_judge_backend`` (loaders + scorers + judge wiring),
* ``openjarvis.evals.core.runner.EvalRunner`` (parallel run + scoring),
* ``openjarvis.evals.core.types.RunConfig`` (run config),
and plugs in our orchestrator as the "model" via
``openjarvis.learning.intelligence.orchestrator.eval_backend.OrchestratorBackend``.
For each benchmark it runs ``EvalRunner(config, dataset, backend, scorer).run()``,
collects accuracy / cost / latency, writes a combined ``summary.json``, and
prints a table.
NOTE on the judge: the OpenAI and Gemini keys are both dead — Anthropic is the
only live provider, so the judge is ``claude-haiku-4-5-20251001`` (the same model
the generation-side verifier uses, see ``sft_data/verify.py:JUDGE_MODEL``). The
judge backend is still built via the ``cloud`` engine (``_build_judge_backend``)
— the model string selects the provider/route. Override with ``--judge-model`` /
``--judge-engine``.
Example
-------
.venv/bin/python scripts/orchestrator/eval_orchestrator.py \\
--benchmarks gaia,mmlu_pro --n 100 \\
--orchestrator-endpoint http://localhost:8001/v1 \\
--orchestrator-model qwen3-8b \\
--output-dir ~/.openjarvis/experiments/hybrid/runs/orch-eval
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
# Friendly aliases (underscored / shorthand) -> the EXACT registry keys used by
# openjarvis.evals.cli.BENCHMARKS / _build_dataset / _build_scorer.
BENCHMARK_ALIASES: Dict[str, str] = {
"terminalbench_v2_1": "terminalbench-v2.1",
"terminalbench-v2_1": "terminalbench-v2.1",
"terminalbench_v21": "terminalbench-v2.1",
"terminalbench-v2.1": "terminalbench-v2.1",
"mmlu_pro": "mmlu-pro",
"mmlu-pro": "mmlu-pro",
"gaia": "gaia",
"taubench": "taubench",
"supergpqa": "supergpqa",
}
DEFAULT_BENCHMARKS = "gaia,terminalbench_v2_1,taubench,mmlu_pro,supergpqa"
def _normalize_benchmark(name: str) -> str:
key = name.strip()
return BENCHMARK_ALIASES.get(key, BENCHMARK_ALIASES.get(key.lower(), key))
def _parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
p = argparse.ArgumentParser(
description="Run the orchestrator over benchmark subsets and score them.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
p.add_argument(
"--benchmarks",
default=DEFAULT_BENCHMARKS,
help="Comma-separated benchmark names (aliases normalized to registry keys).",
)
p.add_argument("--n", type=int, default=100, help="Samples per benchmark.")
p.add_argument("--seed", type=int, default=42, help="Subset seed.")
p.add_argument(
"--orchestrator-endpoint",
default="http://localhost:8001/v1",
help="OpenAI-compatible base URL for the served orchestrator.",
)
p.add_argument(
"--orchestrator-model",
default="qwen3-8b",
help="Served orchestrator model id.",
)
p.add_argument(
"--orchestrator-api-key",
default="EMPTY",
help="API key for the orchestrator endpoint (EMPTY for local vLLM).",
)
p.add_argument(
"--local-endpoint",
action="append",
default=[],
metavar="MODEL_ID=URL",
help=(
"Wire a local OSS tool: map a catalog model id to its vLLM base "
"URL, e.g. 'Qwen/Qwen3.5-9B=http://localhost:8003/v1'. Repeatable."
),
)
p.add_argument(
"--max-turns",
type=int,
default=8,
help="Max orchestrator turns per sample.",
)
p.add_argument(
"--temperature",
type=float,
default=1.0,
help="Orchestrator sampling temperature.",
)
p.add_argument(
"--max-workers",
type=int,
default=4,
help="Parallel samples per benchmark.",
)
p.add_argument(
"--output-dir",
default="results/orchestrator-eval",
help="Directory for per-benchmark JSONL + combined summary.json.",
)
# OpenAI AND Gemini keys are both dead — Anthropic is the only live provider,
# and this matches the generation-side verifier (verify.JUDGE_MODEL).
p.add_argument(
"--base-model",
action="store_true",
help="Orchestrator is an UNTRAINED base model: serve with native tools= "
"instead of the baked fine-tuned JSON prompt.",
)
p.add_argument(
"--judge-model",
default="claude-haiku-4-5-20251001",
help="LLM-judge model.",
)
p.add_argument(
"--judge-engine",
default="cloud",
help="Engine key for the judge backend.",
)
return p.parse_args(argv)
def _run_one(
benchmark: str,
*,
n: int,
seed: int,
backend,
judge_model: str,
judge_engine: str,
max_workers: int,
orchestrator_model: str,
output_dir: Path,
) -> Dict[str, Any]:
"""Run + score one benchmark, returning a row dict (or an error row)."""
# Imports here so --help works without importing the (heavy) eval stack.
from openjarvis.evals.cli import (
_build_dataset,
_build_judge_backend,
_build_scorer,
)
from openjarvis.evals.core.runner import EvalRunner
from openjarvis.evals.core.types import RunConfig
output_path = output_dir / f"{benchmark.replace('.', '_')}_orchestrator.jsonl"
dataset = _build_dataset(benchmark)
# The runner reloads internally; we also load here per spec so callers can
# inspect/size the subset up front. load() is idempotent for these sets.
dataset.load(max_samples=n, seed=seed)
judge_backend = _build_judge_backend(judge_model, engine_key=judge_engine)
scorer = _build_scorer(benchmark, judge_backend, judge_model)
config = RunConfig(
benchmark=benchmark,
backend="orchestrator",
model=orchestrator_model,
max_samples=n,
max_workers=max_workers,
seed=seed,
judge_model=judge_model,
judge_engine=judge_engine,
output_path=str(output_path),
)
runner = EvalRunner(config, dataset, backend, scorer)
started = time.time()
try:
summary = runner.run()
finally:
if judge_backend is not None:
judge_backend.close()
elapsed = time.time() - started
return {
"benchmark": benchmark,
"accuracy": summary.accuracy,
"scored_samples": summary.scored_samples,
"correct": summary.correct,
"errors": summary.errors,
"total_samples": summary.total_samples,
"mean_latency_seconds": summary.mean_latency_seconds,
"total_cost_usd": summary.total_cost_usd,
"mean_continuous_score": summary.mean_continuous_score,
"wall_seconds": round(elapsed, 2),
"output_path": str(output_path),
}
def _print_table(rows: List[Dict[str, Any]]) -> None:
cols = [
("benchmark", 22, "{}"),
("accuracy", 9, "{:.4f}"),
("correct", 8, "{}"),
("scored", 7, "{}"),
("errors", 7, "{}"),
("cost($)", 10, "{:.4f}"),
("lat(s)", 9, "{:.2f}"),
("wall(s)", 9, "{:.1f}"),
]
header = " ".join(f"{name:<{w}}" for name, w, _ in cols)
print("\n" + header)
print("-" * len(header))
for r in rows:
if r.get("error"):
print(f"{r['benchmark']:<22} ERROR: {r['error']}")
continue
vals = {
"benchmark": r["benchmark"],
"accuracy": r["accuracy"],
"correct": r["correct"],
"scored": r["scored_samples"],
"errors": r["errors"],
"cost($)": r["total_cost_usd"],
"lat(s)": r["mean_latency_seconds"],
"wall(s)": r["wall_seconds"],
}
cells = []
for name, w, fmt in cols:
v = vals[name]
try:
s = fmt.format(v)
except (ValueError, TypeError):
s = str(v)
cells.append(f"{s:<{w}}")
print(" ".join(cells))
def main(argv: Optional[List[str]] = None) -> int:
args = _parse_args(argv)
benchmarks = [
_normalize_benchmark(b) for b in args.benchmarks.split(",") if b.strip()
]
output_dir = Path(args.output_dir).expanduser()
output_dir.mkdir(parents=True, exist_ok=True)
# Build the orchestrator backend once (stateless across benchmarks).
from openjarvis.learning.intelligence.orchestrator.eval_backend import (
OrchestratorBackend,
)
local_endpoints: Dict[str, str] = {}
for pair in args.local_endpoint:
if "=" not in pair:
raise SystemExit(f"--local-endpoint expects MODEL_ID=URL, got: {pair!r}")
model_id, url = pair.split("=", 1)
local_endpoints[model_id.strip()] = url.strip()
backend = OrchestratorBackend(
orchestrator_endpoint=args.orchestrator_endpoint,
orchestrator_model=args.orchestrator_model,
api_key=args.orchestrator_api_key,
local_endpoints=local_endpoints,
max_turns=args.max_turns,
temperature=args.temperature,
# BASE models need native tools=; FINE-TUNED models need the baked
# JSON prompt they were trained on. Serving either in the other mode
# handicaps it (base-in-baked scored 0.184 on a rerun; the June 0.40
# baseline used native mode).
finetuned=not args.base_model,
)
rows: List[Dict[str, Any]] = []
try:
for benchmark in benchmarks:
print(f"\n=== {benchmark} (n={args.n}, seed={args.seed}) ===")
try:
row = _run_one(
benchmark,
n=args.n,
seed=args.seed,
backend=backend,
judge_model=args.judge_model,
judge_engine=args.judge_engine,
max_workers=args.max_workers,
orchestrator_model=args.orchestrator_model,
output_dir=output_dir,
)
print(
f" accuracy={row['accuracy']:.4f} "
f"({row['correct']}/{row['scored_samples']}) "
f"errors={row['errors']} cost=${row['total_cost_usd']:.4f}"
)
except Exception as exc: # noqa: BLE001 - one bad bench shouldn't kill all
print(f" FAILED: {type(exc).__name__}: {exc}", file=sys.stderr)
row = {"benchmark": benchmark, "error": f"{type(exc).__name__}: {exc}"}
rows.append(row)
finally:
backend.close()
# Resolve the EXACT model behind the served alias (e.g. "gemma-sft" ->
# the real id + checkpoint path vLLM reports), so results are self-identifying
# instead of an opaque label. Best-effort: never fail the summary over it.
served_id, served_path = args.orchestrator_model, None
try:
import urllib.request
req = urllib.request.Request(
args.orchestrator_endpoint.rstrip("/") + "/models",
headers={"Authorization": f"Bearer {args.orchestrator_api_key}"},
)
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.load(resp).get("data", [])
if data:
served_id = data[0].get("id", served_id)
# vLLM reports the loaded checkpoint path in `root` (or `parent`).
served_path = data[0].get("root") or data[0].get("parent")
except Exception:
pass
combined = {
"orchestrator_model": args.orchestrator_model,
"orchestrator_served_id": served_id,
"orchestrator_model_path": served_path,
"orchestrator_endpoint": args.orchestrator_endpoint,
"judge_model": args.judge_model,
"judge_engine": args.judge_engine,
"n": args.n,
"seed": args.seed,
"max_turns": args.max_turns,
"benchmarks": rows,
}
summary_path = output_dir / "summary.json"
with open(summary_path, "w") as f:
json.dump(combined, f, indent=2, default=str)
_print_table(rows)
print(f"\nCombined summary written to {summary_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env python
"""Carve train / holdout / overfit100 splits from a clean orchestrator-SFT pool.
Replaces make_tranches.py + make_gemma_tranches.py, which carved 1k/2k/4k/8k
data-scaling tranches from the June pool. That lineage is dead: the tranches it
produced trained checkpoints that evaluated at base ~= 1k ~= 2k (the pool taught
the orchestrator to re-derive answers itself instead of routing), and the pool
was deleted. The lesson is in data/orchestrator/README.md — it was a data-QUALITY
ceiling, so re-running the old scaling ladder buys nothing.
Reads a clean pool (reject-sampled from raw/) and writes the SFT splits to sft/.
Naming is uniform — ``{name}-{split}-{stamp}`` with name in {qwen, gemma, pooled};
pass several pools to merge-and-restratify into `pooled`. The splits INHERIT the
pool's stamp, so they stay tied to the run that generated the data.
The holdout is domain-stratified so val-loss is leak-free, and `overfit100` is a
strict prefix of train (it is a memorisation sanity check — the model must be
able to fit 100 rows it *has* seen, else the format/masking is broken).
Deterministic (seed 42).
# one pool -> qwen-{train,holdout,overfit100}-july-7-2026-0553pm.jsonl
python scripts/orchestrator/make_splits.py --name qwen \
--pool .../sft/qwen-clean-july-7-2026-0553pm.jsonl
# merge both -> pooled-{train,holdout,overfit100}-july-7-2026-0553pm.jsonl
python scripts/orchestrator/make_splits.py --name pooled \
--pool .../sft/qwen-clean-july-7-2026-0553pm.jsonl \
--pool .../sft/gemma-clean-july-7-2026-0553pm.jsonl
"""
import argparse
import json
import os
import random
import sys
from collections import defaultdict
from pathlib import Path
from openjarvis.learning.intelligence.orchestrator.sft_data.naming import (
dataset_name,
run_stamp,
stamp_from,
)
# Where the orchestrator data tree lives. Repo-relative by default so a fresh
# clone works out of the box; set OJ_DATA_ROOT to keep the data OUT of the git
# checkout (this workspace points it at ~/experiments/orchestrator/data, so a
# stray `git reset` can't touch hundreds of GB of generations).
DATA_ROOT = Path(os.getenv("OJ_DATA_ROOT", "data/orchestrator"))
OUT = DATA_ROOT / "sft"
SEED = 42
OVERFIT_N = 100
# Orchestrator that generated each pool — stamped onto every row so provenance
# survives a merge (the filename encodes it too, but the field is cheap insurance
# and is what the `pooled` splits rely on to stay attributable).
ORCH_MODEL = {"qwen": "qwen3.5-9b", "gemma": "gemma-4-26b"}
def main() -> int:
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
ap.add_argument(
"--name",
required=True,
help="split family: qwen | gemma | pooled (drives the filename)",
)
ap.add_argument(
"--pool",
action="append",
required=True,
metavar="PATH",
help="clean pool jsonl; repeat to merge (use with --name pooled)",
)
ap.add_argument(
"--holdout-frac",
type=float,
default=0.15,
help="fraction held out, domain-stratified (default 0.15)",
)
ap.add_argument(
"--stamp",
default=None,
help="Stamp for the output names, e.g. july-7-2026-0553pm. Defaults to the "
"POOL's stamp (read off its filename) so a split stays tied to the run "
"that generated it, NOT to the day it happened to be carved. Falls back "
"to now only if no pool name carries a stamp.",
)
ap.add_argument("--out", type=Path, default=OUT)
args = ap.parse_args()
# A split belongs to the generation run, not to the day it was carved — so
# inherit the pool's stamp unless told otherwise.
stamp = args.stamp or next(
(s for p in args.pool if (s := stamp_from(Path(p).name))), None
) or run_stamp()
rows = []
for p in args.pool:
path = Path(p)
if not path.exists():
print(f"!! pool not found: {path}", file=sys.stderr)
return 1
# Infer the source orchestrator from the filename so merged pools stay
# attributable; don't clobber a stamp the pool already carries.
stem = path.name.split("-", 1)[0]
loaded = [json.loads(line) for line in path.open() if line.strip()]
for r in loaded:
r.setdefault("orchestrator_model", ORCH_MODEL.get(stem, stem))
rows.extend(loaded)
print(f"loaded {len(loaded):>5} rows from {path}")
print(f"pool total: {len(rows)}")
# GROUPED stratified holdout: split by task_id, never by row.
#
# The pool keeps SEVERAL rollouts per question (different attempts at the same
# task, same gold answer). Splitting by row therefore leaks: attempt A of a
# question lands in train and attempt B of the SAME question lands in holdout,
# so val-loss is measured on questions the model trained on. That is exactly
# what happened to the 0707 splits — 80% of qwen's holdout tasks were also in
# train, making every val-loss number optimistically biased.
#
# So: group rows by task_id, stratify the TASKS by domain, and assign whole
# tasks to one side. A question is never split across train and holdout.
by_task = defaultdict(list)
for i, r in enumerate(rows):
by_task[r.get("task_id")].append(i)
task_dom = {t: rows[idxs[0]].get("domain", "misc") for t, idxs in by_task.items()}
by_dom = defaultdict(list)
for t, dom in task_dom.items():
by_dom[dom].append(t)
rng = random.Random(SEED)
holdout_tasks: set = set()
for dom, tasks in sorted(by_dom.items()):
tasks = sorted(tasks) # deterministic before sampling
k = round(args.holdout_frac * len(tasks))
pick = rng.sample(tasks, min(k, len(tasks)))
holdout_tasks.update(pick)
print(f" {dom:<9} tasks={len(tasks):>4} holdout={len(pick)}")
holdout_idx = {i for t in holdout_tasks for i in by_task[t]}
holdout = [rows[i] for i in sorted(holdout_idx)]
train = [r for i, r in enumerate(rows) if i not in holdout_idx] # order preserved
overfit = train[:OVERFIT_N]
# Assert the leak is actually gone — this is the whole point of the grouping.
tr_tasks = {r.get("task_id") for r in train}
ho_tasks = {r.get("task_id") for r in holdout}
overlap = tr_tasks & ho_tasks
if overlap:
raise SystemExit(f"!! LEAK: {len(overlap)} task_ids in BOTH train and holdout")
print(
f"train={len(train)} rows / {len(tr_tasks)} tasks "
f"holdout={len(holdout)} rows / {len(ho_tasks)} tasks "
f"overfit={len(overfit)} (0 tasks shared)"
)
args.out.mkdir(parents=True, exist_ok=True)
written = {}
for split, data in (
("train", train),
("holdout", holdout),
(f"overfit{OVERFIT_N}", overfit),
):
f = args.out / f"{dataset_name(args.name, split, stamp)}.jsonl"
f.write_text("".join(json.dumps(r) + "\n" for r in data))
written[split] = f
print(f"wrote {f} ({len(data)})")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+407
View File
@@ -0,0 +1,407 @@
#!/usr/bin/env python
"""Full-parameter SFT for the orchestrator policy via FSDP (multi-GPU).
Launch with accelerate so the model + optimizer shard across GPUs (an 8-9B full
fine-tune won't fit one L40S otherwise):
accelerate launch --config_file <fsdp.yaml> \
scripts/orchestrator/run_sft_fsdp.py \
--data data/orchestrator/sft/qwen_train_0707.jsonl [--data <more> ...] \
--variant correct --model Qwen/Qwen3.5-9B \
--out checkpoints/sft_correct --epochs 3 --batch-size 8 --grad-accum 8 \
--wandb-project orchestrator-sft
--variant selects which trajectories to train on:
all -> every record
correct -> only records the verifier marked correct
correct_routed -> only correct records that delegated to a model expert
By default (--require-clean) records explicitly marked clean=False by the clean
gate (bloated / garbled / unrouted) are also dropped; rows missing the flag
(legacy data) are kept. Pass --no-require-clean to skip this filter.
Reuses the conversation->tokens + assistant-only masking from sft_tokenize.py.
On these no-NVLink L40S, the accelerate/FSDP NCCL env (NCCL_P2P_DISABLE=1 etc.)
must be set by the launcher.
"""
from __future__ import annotations
import argparse
import json
import math
import os
import re
import sys
import time
from pathlib import Path
# reuse the data builder from the LoRA launcher
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from sft_tokenize import build_example # noqa: E402
MODEL_EXPERTS = {
"gpt_5_5",
"claude_opus_4_8",
"qwen3_5_9b",
"qwen3_6_27b_fp8",
"qwen3_5_122b_a10b_fp8",
"qwen3_5_397b_a17b_fp8",
}
def record_is_correct(r: dict) -> bool:
c = r.get("correct")
if c is None:
c = r.get("metrics", {}).get("correct")
return bool(c)
def record_routed_to_expert(r: dict) -> bool:
"""True if the trajectory called a model expert (resolving anon labels)."""
amap = r.get("metrics", {}).get("anon_map", {})
names = re.findall(
r'"name"\s*:\s*"([a-z0-9_]+)"', json.dumps(r.get("conversations", []))
)
for n in names:
real = amap.get(n, n)
if real in MODEL_EXPERTS:
return True
return False
def record_clean_flag(r: dict):
"""The record's `clean` flag: True/False, or None if absent (legacy data).
None means the clean gate never ran on this row, so we can't judge it -> we
keep it (legacy behavior) rather than silently dropping older datasets.
"""
c = r.get("clean")
if c is None:
c = r.get("metrics", {}).get("clean")
return c
def select(records, variant: str, require_clean: bool = True):
"""Filter by --variant, then (default) drop records explicitly marked unclean.
require_clean drops rows whose `clean` flag is False. Rows missing the flag
(legacy) are kept regardless, matching pre-clean-gate behavior.
"""
if variant == "all":
base = list(records)
elif variant == "correct":
base = [r for r in records if record_is_correct(r)]
elif variant == "correct_routed":
base = [
r for r in records if record_is_correct(r) and record_routed_to_expert(r)
]
else:
raise ValueError(f"unknown variant {variant}")
if not require_clean:
return base
return [r for r in base if record_clean_flag(r) is not False]
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument(
"--data", action="append", required=True, help="JSONL(s) (repeatable)"
)
p.add_argument(
"--val-data",
default=None,
help="Held-out JSONL for val-loss (excluded from --data). Eval'd per epoch.",
)
p.add_argument(
"--variant", choices=["all", "correct", "correct_routed"], default="correct"
)
p.add_argument(
"--require-clean",
action=argparse.BooleanOptionalAction,
default=True,
help="Drop records whose `clean` flag is False (bloated / garbled / "
"unrouted). Rows missing the flag (legacy data) are kept. "
"Use --no-require-clean to disable.",
)
p.add_argument("--model", default="Qwen/Qwen3.5-9B")
p.add_argument("--out", required=True)
p.add_argument("--epochs", type=float, default=3.0)
p.add_argument("--batch-size", type=int, default=8)
p.add_argument("--grad-accum", type=int, default=8)
p.add_argument("--lr", type=float, default=1e-5)
p.add_argument("--max-seq", type=int, default=8192)
p.add_argument(
"--supervise-last-only",
action="store_true",
help="Legacy: supervise ONLY the final assistant turn. Default "
"(off) supervises every assistant turn incl. routing.",
)
p.add_argument("--warmup-ratio", type=float, default=0.03)
p.add_argument("--seed", type=int, default=42)
p.add_argument("--wandb-project", default="orchestrator-sft")
p.add_argument("--wandb-name", default="")
args = p.parse_args()
import random
from datetime import timedelta
import torch
from accelerate import Accelerator
from accelerate.utils import InitProcessGroupKwargs, set_seed
from torch.utils.data import DataLoader
from transformers import AutoModelForCausalLM, AutoTokenizer
set_seed(args.seed)
# 30-min collective timeout: a slow first-step shard gather / graph build on a
# contended node was tripping the default 10-min NCCL watchdog (job 3892 died
# at step 1). This only delays a *real* hang's crash; it fixes spurious ones.
pg_timeout_min = int(os.environ.get("SFT_PG_TIMEOUT_MIN", "30"))
accelerator = Accelerator(
gradient_accumulation_steps=args.grad_accum,
kwargs_handlers=[
InitProcessGroupKwargs(timeout=timedelta(minutes=pg_timeout_min))
],
)
is_main = accelerator.is_main_process
def log(m):
if is_main:
print(f"[fsdp-sft] {time.strftime('%H:%M:%S')} {m}", flush=True)
tok = AutoTokenizer.from_pretrained(args.model, trust_remote_code=True)
if tok.pad_token_id is None:
tok.pad_token = tok.eos_token
# ---- data ----
records = []
for f in args.data:
records += [json.loads(l) for l in open(f) if l.strip()]
sel = select(records, args.variant, require_clean=args.require_clean)
log(f"variant={args.variant}: {len(sel)}/{len(records)} records")
if args.require_clean:
pre_clean = select(records, args.variant, require_clean=False)
n_unclean = len(pre_clean) - len(sel)
log(
f"require_clean=True: dropped {n_unclean} unclean rows "
f"({len(pre_clean)}->{len(sel)}); use --no-require-clean to keep them"
)
else:
log("require_clean=False: NOT filtering on `clean` flag")
examples = []
for r in sel:
ex = build_example(
tok,
r.get("conversations", []),
args.max_seq,
supervise_all_turns=not args.supervise_last_only,
)
if ex:
examples.append(ex)
log(f"built {len(examples)} training examples")
if not examples:
log("no examples; abort")
return 1
random.Random(args.seed).shuffle(examples)
def collate(batch):
maxlen = max(len(b["input_ids"]) for b in batch)
ii, lab, am = [], [], []
for b in batch:
pad = maxlen - len(b["input_ids"])
ii.append(b["input_ids"] + [tok.pad_token_id] * pad)
lab.append(b["labels"] + [-100] * pad)
am.append([1] * len(b["input_ids"]) + [0] * pad)
return (torch.tensor(ii), torch.tensor(lab), torch.tensor(am))
dl = DataLoader(
examples, batch_size=args.batch_size, shuffle=True, collate_fn=collate
)
# ---- held-out val set (leak-free; excluded from --data upstream) ----
val_dl = None
if args.val_data:
vrecs = [json.loads(l) for l in open(args.val_data) if l.strip()]
vex = []
for r in vrecs:
ex = build_example(
tok,
r.get("conversations", []),
args.max_seq,
supervise_all_turns=not args.supervise_last_only,
)
if ex:
vex.append(ex)
log(f"val: {len(vex)} examples from {args.val_data}")
if vex:
val_dl = DataLoader(
vex, batch_size=args.batch_size, shuffle=False, collate_fn=collate
)
model = AutoModelForCausalLM.from_pretrained(
args.model,
dtype=torch.bfloat16,
trust_remote_code=True,
attn_implementation="sdpa",
)
if os.environ.get("SFT_NO_GRAD_CKPT") == "1":
log("gradient checkpointing DISABLED (SFT_NO_GRAD_CKPT=1)")
else:
model.gradient_checkpointing_enable(
gradient_checkpointing_kwargs={"use_reentrant": False}
)
model.config.use_cache = False
opt = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=0.0)
model, opt, dl = accelerator.prepare(model, opt, dl)
if val_dl is not None:
val_dl = accelerator.prepare(val_dl)
@torch.no_grad()
def evaluate():
# Corpus-level mean token loss over the holdout. FSDP needs every rank to
# run the forward collectively, so each rank evals its shard and we reduce
# token-weighted sums (not a mean-of-batch-means, which would mis-weight).
model.eval()
tot_loss = torch.zeros((), device=accelerator.device)
tot_tok = torch.zeros((), device=accelerator.device)
for ii, lab, am in val_dl:
out = model(input_ids=ii, attention_mask=am, labels=lab)
ntok = (lab != -100).sum()
tot_loss += out.loss.detach() * ntok
tot_tok += ntok
tot_loss = accelerator.reduce(tot_loss, reduction="sum")
tot_tok = accelerator.reduce(tot_tok, reduction="sum")
model.train()
return (tot_loss / tot_tok.clamp(min=1)).item()
steps_per_epoch = math.ceil(len(dl) / args.grad_accum)
total_steps = max(1, int(steps_per_epoch * args.epochs))
warmup = max(1, int(total_steps * args.warmup_ratio))
def lr_at(s):
if s < warmup:
return s / warmup
return 0.5 * (
1 + math.cos(math.pi * (s - warmup) / max(1, total_steps - warmup))
)
use_wandb = False
if is_main:
try:
import wandb
base_name = (
args.wandb_name or f"{Path(args.model).name}-{args.variant}-fsdp"
)
run_name = f"{base_name}-{time.strftime('%m%d-%H%M', time.gmtime())}"
wandb.init(
project=args.wandb_project,
name=run_name,
config=vars(args)
| {"n_examples": len(examples), "total_steps": total_steps},
)
use_wandb = True
except Exception as e:
log(f"wandb off ({e})")
log(f"total_steps={total_steps} steps/epoch={steps_per_epoch} warmup={warmup}")
def _save_ckpt(out_path):
# Full-model checkpoint (gathers FSDP shards to rank0). Called per-epoch
# so a long run leaves usable partial models + lets us eval intermediate
# checkpoints (e.g. epoch1/epoch2) for the data-scaling sweep.
accelerator.wait_for_everyone()
unwrapped = accelerator.unwrap_model(model)
p = Path(out_path)
if is_main:
p.mkdir(parents=True, exist_ok=True)
tok.save_pretrained(str(p))
unwrapped.save_pretrained(
str(p),
is_main_process=is_main,
save_function=accelerator.save,
state_dict=accelerator.get_state_dict(model),
)
if is_main:
log(f"checkpoint saved -> {p}")
model.train()
gstep = 0
t0 = time.time()
if val_dl is not None:
vloss = evaluate()
log(f"val/loss (baseline, step 0) = {vloss:.4f}")
if use_wandb:
wandb.log({"val/loss": vloss}, step=0)
for epoch in range(math.ceil(args.epochs)):
for ii, lab, am in dl:
with accelerator.accumulate(model):
out = model(input_ids=ii, attention_mask=am, labels=lab)
accelerator.backward(out.loss)
if accelerator.sync_gradients:
lr = args.lr * lr_at(gstep)
for g in opt.param_groups:
g["lr"] = lr
accelerator.clip_grad_norm_(model.parameters(), 1.0)
opt.step()
opt.zero_grad()
if accelerator.sync_gradients:
gstep += 1
# accelerator.gather() is a COLLECTIVE — EVERY rank must call it.
# Guarding it behind `is_main` made only rank0 run the [1]-float
# all-gather while ranks 1-3 marched on to the next step's FSDP
# param all-gather, desyncing the process group and hanging the job
# at the step1->step2 boundary (NCCL collective-shape mismatch).
loss = accelerator.gather(out.loss.detach()).mean().item()
if is_main:
log(f"step {gstep}/{total_steps} loss={loss:.4f} lr={lr:.2e}")
if use_wandb:
wandb.log({"train/loss": loss, "train/lr": lr}, step=gstep)
if gstep >= total_steps:
break
_save_ckpt(Path(args.out) / f"epoch{epoch + 1}")
if val_dl is not None:
vloss = evaluate()
log(f"val/loss (epoch {epoch + 1}) = {vloss:.4f}")
if use_wandb:
wandb.log({"val/loss": vloss, "epoch": epoch + 1}, step=gstep)
if gstep >= total_steps:
break
accelerator.wait_for_everyone()
log("saving full model...")
unwrapped = accelerator.unwrap_model(model)
out_dir = Path(args.out)
if is_main:
out_dir.mkdir(parents=True, exist_ok=True)
tok.save_pretrained(str(out_dir))
unwrapped.save_pretrained(
str(out_dir),
is_main_process=is_main,
save_function=accelerator.save,
state_dict=accelerator.get_state_dict(model),
)
if is_main:
log(f"saved -> {out_dir}")
if use_wandb:
try:
wandb.finish()
except Exception:
pass
print(
"FSDP_SFT_DONE "
+ json.dumps(
{
"variant": args.variant,
"steps": gstep,
"wall_s": round(time.time() - t0, 1),
"out": str(out_dir),
}
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env python
"""Conversation->tokens + assistant-only masking shared by the SFT trainers.
Extracted from the old LoRA trainer so ``run_sft_fsdp.py`` (full-parameter FSDP,
the path we actually use) doesn't depend on it. ``build_example`` tokenizes one
``conversations`` record and returns ``input_ids`` + ``labels`` with everything
but the supervised assistant turns masked to -100.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
def normalize_messages(conversations: List[Dict[str, Any]]) -> List[Dict[str, str]]:
"""Map raw conversation turns to {role, content} with role in
system/user/assistant. tool turns fold into user (matches the native
OrchestratorSFTDataset fallback)."""
msgs: List[Dict[str, str]] = []
for turn in conversations:
role = turn.get("role") or turn.get("from", "")
content = turn.get("content") or turn.get("value", "") or ""
if role in ("human", "user"):
role = "user"
elif role in ("gpt", "assistant"):
role = "assistant"
elif role == "tool":
content = f"[Tool '{turn.get('name', 'tool')}' returned]: {content}"
role = "user"
if role in ("system", "user", "assistant"):
msgs.append({"role": role, "content": str(content)})
return msgs
def _lcp_len(a: List[int], b: List[int]) -> int:
"""Length of the longest common prefix of two token-id lists."""
n = 0
for x, y in zip(a, b):
if x != y:
break
n += 1
return n
def _chatml_assistant_spans(ids: List[int], tok) -> Optional[List[tuple]]:
"""``(start, end)`` token spans of each assistant turn's content in a
ChatML-rendered sequence, located by ``<|im_start|>assistant ... <|im_end|>``
markers (the ``<|im_end|>`` is included so the model learns to stop).
Operates on the FINAL token stream, so it is correct even when the chat
template strips prior-turn ``<think>`` reasoning from history (Qwen3.x does),
which breaks any incremental re-rendering / prefix approach. Returns None for
non-ChatML templates (e.g. gemma) so the caller can fall back."""
im_start = tok.convert_tokens_to_ids("<|im_start|>")
im_end = tok.convert_tokens_to_ids("<|im_end|>")
asst = tok.convert_tokens_to_ids("assistant")
unk = tok.unk_token_id
if im_start is None or im_end is None or im_start == unk or im_end == unk:
return None
nl = tok.convert_tokens_to_ids("Ċ") # the '\n' after the role header
spans: List[tuple] = []
i, n = 0, len(ids)
while i < n:
if ids[i] == im_start and i + 1 < n and ids[i + 1] == asst:
j = i + 2
if j < n and ids[j] == nl:
j += 1
k = j
while k < n and ids[k] != im_end:
k += 1
end = min(k + 1, n)
if end > j:
spans.append((j, end))
i = end
else:
i += 1
return spans
def build_example(
tok,
conversations,
max_seq: int,
supervise_all_turns: bool = True,
) -> Optional[Dict[str, List[int]]]:
"""Tokenize one record -> input_ids + labels.
``supervise_all_turns=True`` (default): supervise EVERY assistant turn — the
intermediate routing ``<tool_call>`` turns AND the final answer; only
system/user/tool tokens are masked. This teaches the model to actually route,
not just synthesize the last answer given someone else's tool calls.
``supervise_all_turns=False`` (legacy): supervise only the final assistant
turn; everything before it is masked to -100.
Assistant spans are found by the ChatML turn markers on the rendered stream
(robust to Qwen3's prior-turn think-stripping). For non-ChatML templates
(gemma) we fall back to the proven last-turn longest-common-prefix boundary;
all-turns supervision there needs per-template markers and is not yet wired,
so it degrades to last-turn. Records whose last turn isn't assistant, that
supervise nothing, or whose final turn alone overflows max_seq are skipped."""
msgs = normalize_messages(conversations)
if len(msgs) < 2 or msgs[-1]["role"] != "assistant":
return None
try:
full_text = tok.apply_chat_template(
msgs, tokenize=False, add_generation_prompt=False
)
full = tok(full_text, add_special_tokens=False)["input_ids"]
except Exception:
return None
if not full:
return None
spans = _chatml_assistant_spans(full, tok)
if spans:
selected = spans if supervise_all_turns else spans[-1:]
labels = [-100] * len(full)
for s, e in selected:
for k in range(s, e):
labels[k] = full[k]
last_start = selected[-1][0]
else:
# Non-ChatML template: proven last-turn boundary via longest-common-prefix
# (robust to templates whose add_generation_prompt emits extra preamble).
try:
prompt_text = tok.apply_chat_template(
msgs[:-1], tokenize=False, add_generation_prompt=True
)
prompt = tok(prompt_text, add_special_tokens=False)["input_ids"]
except Exception:
return None
boundary = _lcp_len(prompt, full)
if boundary >= len(full):
return None
labels = list(full)
for i in range(min(boundary, len(labels))):
labels[i] = -100
last_start = boundary
if all(v == -100 for v in labels): # nothing left to supervise
return None
# Keep the final assistant turn if too long: left-truncate from the front.
if len(full) > max_seq:
if len(full) - last_start >= max_seq: # final answer alone overflows -> drop
return None
cut = len(full) - max_seq
full = full[cut:]
labels = labels[cut:]
return {"input_ids": full, "labels": labels}
+25
View File
@@ -0,0 +1,25 @@
compute_environment: LOCAL_MACHINE
distributed_type: FSDP
downcast_bf16: 'no'
machine_rank: 0
main_process_ip: null
main_process_port: null
main_training_function: main
mixed_precision: bf16
num_machines: 1
num_processes: 4
rdzv_backend: static
same_network: true
use_cpu: false
fsdp_config:
fsdp_version: 1
fsdp_auto_wrap_policy: SIZE_BASED_WRAP
fsdp_min_num_params: 100000000
fsdp_sharding_strategy: FULL_SHARD
fsdp_state_dict_type: FULL_STATE_DICT
fsdp_offload_params: false
fsdp_backward_prefetch: BACKWARD_PRE
fsdp_forward_prefetch: false
fsdp_use_orig_params: true
fsdp_cpu_ram_efficient_loading: true
fsdp_sync_module_states: true
+25
View File
@@ -0,0 +1,25 @@
compute_environment: LOCAL_MACHINE
distributed_type: FSDP
downcast_bf16: 'no'
machine_rank: 0
main_process_ip: null
main_process_port: null
main_training_function: main
mixed_precision: bf16
num_machines: 1
num_processes: 8
rdzv_backend: static
same_network: true
use_cpu: false
fsdp_config:
fsdp_version: 1
fsdp_auto_wrap_policy: SIZE_BASED_WRAP
fsdp_min_num_params: 100000000
fsdp_sharding_strategy: FULL_SHARD
fsdp_state_dict_type: FULL_STATE_DICT
fsdp_offload_params: false
fsdp_backward_prefetch: BACKWARD_PRE
fsdp_forward_prefetch: false
fsdp_use_orig_params: true
fsdp_cpu_ram_efficient_loading: true
fsdp_sync_module_states: true
+89 -1
View File
@@ -13,6 +13,7 @@ Supports two modes:
from __future__ import annotations
import concurrent.futures
import json
import re
from typing import Any, List, Optional
@@ -201,6 +202,70 @@ class OrchestratorAgent(ToolUsingAgent):
return result
@staticmethod
def _boxed_tool_call(content, openai_tools, call_id):
r"""Salvage a tool call the model wrote as ``\boxed{tool: query}``.
Some SFT'd checkpoints (Qwen reasoning-mode habit) emit their action
as a boxed string instead of a real ``<tool_call>`` — e.g.
``\boxed{web_search: latest news}`` or ``\boxed{web_search query: ...}``.
The engine's tool-call parser sees no structured call, so without this
the agent would grade the half-finished thought as its final answer and
never actually act. Here we recover ``(tool, args)`` and build a real
``ToolCall`` keyed on the tool's primary parameter. Returns ``None`` when
the boxed content isn't a recognisable tool action (leaving genuine
boxed final answers untouched).
"""
if not openai_tools or "\\boxed" not in content:
return None
# tool name -> primary parameter (first required, else first property)
param_of: dict[str, str] = {}
for t in openai_tools:
fn = t.get("function", t)
name = fn.get("name")
if not name:
continue
params = fn.get("parameters") or {}
props = list((params.get("properties") or {}).keys())
req = params.get("required") or []
param_of[name] = req[0] if req else (props[0] if props else "query")
# The action is the LAST boxed expression in the response.
boxed = re.findall(r"\\boxed\{([^{}]*)\}", content)
if not boxed:
return None
inner = boxed[-1].strip()
# Match a leading tool name (longest first to avoid prefix collisions),
# then strip the separator between name and args. Handles
# ``tool: q`` / ``tool query: q`` / ``tool q`` / ``tool(q)``.
for name in sorted(param_of, key=len, reverse=True):
m = re.match(
r"^\s*" + re.escape(name) + r"\b(.*)$",
inner,
re.DOTALL | re.IGNORECASE,
)
if not m:
continue
rest = m.group(1).strip()
if rest.startswith("(") and rest.endswith(")"):
rest = rest[1:-1] # tool(args) wrapper -> drop matching parens
else:
# optional "query" word, then a ":" / "-" separator
rest = re.sub(
r"^\s*(?:query\s*)?[:\-]\s*", "", rest, flags=re.IGNORECASE
)
arg = rest.strip().strip("\"'").strip()
if not arg:
return None
return ToolCall(
id=call_id,
name=name,
arguments=json.dumps({param_of[name]: arg}),
)
return None
# ------------------------------------------------------------------
# Function-calling mode (original behaviour)
# ------------------------------------------------------------------
@@ -245,8 +310,31 @@ class OrchestratorAgent(ToolUsingAgent):
content = result.get("content", "")
raw_tool_calls = result.get("tool_calls", [])
# No tool calls -> check continuation, then final answer
# No tool calls -> try to salvage a \boxed{tool: query} action the
# model wrote instead of a real <tool_call>; else final answer.
if not raw_tool_calls:
boxed_call = self._boxed_tool_call(
content, openai_tools, f"boxed_{turns}"
)
if boxed_call is not None:
messages.append(
Message(
role=Role.ASSISTANT,
content=content,
tool_calls=[boxed_call],
)
)
tool_result = self._executor.execute(boxed_call)
all_tool_results.append(tool_result)
messages.append(
Message(
role=Role.TOOL,
content=tool_result.content,
tool_call_id=boxed_call.id,
name=boxed_call.name,
)
)
continue
content = self._check_continuation(result, messages)
content = self._strip_think_tags(content)
self._emit_turn_end(turns=turns, content_length=len(content))
+67 -8
View File
@@ -2,12 +2,44 @@
from __future__ import annotations
import logging
import random
import time
from abc import ABC, abstractmethod
from typing import Any, Dict, Optional, Tuple
from openjarvis.evals.core.backend import InferenceBackend
from openjarvis.evals.core.types import EvalRecord
LOGGER = logging.getLogger(__name__)
# Transient failures worth retrying the judge call on. A single rate-limit
# (429) used to fall straight through to `llm_fallback_error` and score the
# sample WRONG — e.g. 93/100 GAIA judge calls 429'd on one run and zeroed the
# whole bench. Retry with exponential backoff so a busy judge endpoint doesn't
# get mis-scored as a wrong answer.
_JUDGE_MAX_RETRIES = 6
_JUDGE_BASE_DELAY_S = 2.0
_JUDGE_MAX_DELAY_S = 60.0
_RETRYABLE_MARKERS = (
"429",
"rate_limit",
"rate limit",
"overloaded",
"timeout",
"timed out",
"503",
"502",
"500",
"connection",
"temporarily unavailable",
)
def _is_retryable_judge_error(exc: Exception) -> bool:
msg = str(exc).lower()
return any(marker in msg for marker in _RETRYABLE_MARKERS)
class Scorer(ABC):
"""Base class for all scorers."""
@@ -42,14 +74,41 @@ class LLMJudgeScorer(Scorer):
temperature: float = 0.0,
max_tokens: int = 2048,
) -> str:
"""Send a prompt to the judge LLM and return the response text."""
return self._judge_backend.generate(
prompt,
model=self._judge_model,
system=system,
temperature=temperature,
max_tokens=max_tokens,
)
"""Send a prompt to the judge LLM and return the response text.
Retries transient failures (429 / rate-limit / 5xx / timeout) with
exponential backoff + jitter so a busy judge endpoint doesn't get
mis-scored as a wrong answer. Non-retryable errors, or exhaustion of
the retry budget, re-raise to the caller (which records the failure).
"""
last_exc: Optional[Exception] = None
for attempt in range(_JUDGE_MAX_RETRIES):
try:
return self._judge_backend.generate(
prompt,
model=self._judge_model,
system=system,
temperature=temperature,
max_tokens=max_tokens,
)
except Exception as exc: # noqa: BLE001 - re-raised below
last_exc = exc
if attempt == _JUDGE_MAX_RETRIES - 1 or not _is_retryable_judge_error(
exc
):
raise
delay = min(_JUDGE_BASE_DELAY_S * (2**attempt), _JUDGE_MAX_DELAY_S)
delay += random.uniform(0.0, delay * 0.25) # jitter
LOGGER.warning(
"judge call failed (attempt %d/%d): %s — retrying in %.1fs",
attempt + 1,
_JUDGE_MAX_RETRIES,
exc,
delay,
)
time.sleep(delay)
# Unreachable (loop either returns or raises), but keeps type-checkers happy.
raise last_exc # type: ignore[misc]
__all__ = ["LLMJudgeScorer", "Scorer"]
+8 -1
View File
@@ -140,7 +140,14 @@ class GAIAScorer(LLMJudgeScorer):
if structured_match:
is_correct = structured_match.group(1).lower() == "yes"
else:
is_correct = "CORRECT" in raw.upper() and "INCORRECT" not in raw.upper()
up = raw.upper()
# "not correct" / "is not correct" contain "CORRECT" and lack
# "INCORRECT" — guard the negations explicitly or they read True.
is_correct = (
"CORRECT" in up
and "INCORRECT" not in up
and "NOT CORRECT" not in up
)
meta: Dict[str, Any] = {
"match_type": "llm_fallback",
+36 -7
View File
@@ -27,6 +27,25 @@ class MMLUProScorer(LLMJudgeScorer):
return "".join(chr(ord("A") + i) for i in range(n))
return "ABCDEFGHIJ"
def _extract_answer_direct(
self,
model_answer: str,
valid_letters: str,
) -> Optional[str]:
"""High-confidence, unambiguous letter straight from the output.
Only an explicit ``\\boxed{X}`` counts here. We deliberately do NOT regex
prose like "the answer is X": the letters "I" and "A" are real English
words, so "FINAL_ANSWER: I need ..." would grab "I" — the exact bug that
mislabels answers. Semantic extraction is left to the judge below.
"""
if not model_answer:
return None
for cand in reversed(re.findall(r"\\boxed\{\s*([A-Za-z])\s*\}", model_answer)):
if cand.upper() in valid_letters:
return cand.upper()
return None
def _extract_answer_with_llm(
self,
problem: str,
@@ -56,10 +75,13 @@ class MMLUProScorer(LLMJudgeScorer):
)
extracted = raw_response.strip().upper()
if "NONE" in extracted:
return None
# Handle "The answer is: A" etc.
# Accept only an ISOLATED letter — never the first cap of a word like
# "It"/"None". Prefer an explicit "the answer is X" phrasing.
answer_match = re.search(
r"(?:THE ANSWER IS:?\s*)?([A-Z])",
r"(?:THE ANSWER IS:?\s*)?\b([A-Z])\b(?![A-Za-z'])",
extracted,
re.IGNORECASE,
)
@@ -86,11 +108,17 @@ class MMLUProScorer(LLMJudgeScorer):
valid_letters = self._valid_letters_from_options(record.metadata)
candidate = self._extract_answer_with_llm(
record.problem,
model_answer,
valid_letters,
)
# Parse straight from the output first (fast, deterministic, no "I" bug);
# only fall back to the judge when no isolated letter is present.
method = "direct"
candidate = self._extract_answer_direct(model_answer, valid_letters)
if not candidate:
method = "llm"
candidate = self._extract_answer_with_llm(
record.problem,
model_answer,
valid_letters,
)
if not candidate:
return None, {"reason": "no_choice_letter_extracted"}
@@ -99,6 +127,7 @@ class MMLUProScorer(LLMJudgeScorer):
"reference_letter": ref,
"candidate_letter": candidate,
"valid_letters": valid_letters,
"extract_method": method,
}
return is_correct, meta
+36 -7
View File
@@ -27,6 +27,25 @@ class SuperGPQAScorer(LLMJudgeScorer):
return "".join(chr(ord("A") + i) for i in range(n))
return "ABCD"
def _extract_answer_direct(
self,
model_answer: str,
valid_letters: str,
) -> Optional[str]:
"""High-confidence, unambiguous letter straight from the output.
Only an explicit ``\\boxed{X}`` counts here. We deliberately do NOT regex
prose like "the answer is X": the letters "I" and "A" are real English
words, so "FINAL_ANSWER: I need ..." would grab "I" — the exact bug that
mislabels answers. Semantic extraction is left to the judge below.
"""
if not model_answer:
return None
for cand in reversed(re.findall(r"\\boxed\{\s*([A-Za-z])\s*\}", model_answer)):
if cand.upper() in valid_letters:
return cand.upper()
return None
def _extract_answer_with_llm(
self,
problem: str,
@@ -56,10 +75,13 @@ class SuperGPQAScorer(LLMJudgeScorer):
)
extracted = raw_response.strip().upper()
if "NONE" in extracted:
return None
# Handle "The answer is: A" etc.
# Accept only an ISOLATED letter — never the first cap of a word like
# "It"/"None". Prefer an explicit "the answer is X" phrasing.
answer_match = re.search(
r"(?:THE ANSWER IS:?\s*)?([A-Z])",
r"(?:THE ANSWER IS:?\s*)?\b([A-Z])\b(?![A-Za-z'])",
extracted,
re.IGNORECASE,
)
@@ -86,11 +108,17 @@ class SuperGPQAScorer(LLMJudgeScorer):
valid_letters = self._valid_letters_from_options(record.metadata)
candidate = self._extract_answer_with_llm(
record.problem,
model_answer,
valid_letters,
)
# Parse straight from the output first (fast, deterministic, no "I" bug);
# only fall back to the judge when no isolated letter is present.
method = "direct"
candidate = self._extract_answer_direct(model_answer, valid_letters)
if not candidate:
method = "llm"
candidate = self._extract_answer_with_llm(
record.problem,
model_answer,
valid_letters,
)
if not candidate:
return None, {"reason": "no_choice_letter_extracted"}
@@ -99,6 +127,7 @@ class SuperGPQAScorer(LLMJudgeScorer):
"reference_letter": ref,
"candidate_letter": candidate,
"valid_letters": valid_letters,
"extract_method": method,
}
return is_correct, meta
@@ -0,0 +1,34 @@
# Orchestrator: Qwen3.5-9B. v1 = base self-sampling (the served orchestrator_model is this same base).
#
# v1 data = base Qwen3.5-9B self-sampling: roll the base model out over
# load_sft_tasks() (~8K reasoning tasks), keep correct trajectories. Build the
# dataset first (no GPU, needs the vLLM orchestrator endpoint up):
# .venv/bin/python scripts/orchestrator/build_orchestrator_sft.py \
# --out data/orchestrator_qwen3.5-9b_sft_v1.jsonl --samples-per-task 8
# then train on a rented GPU with regenerate_traces = false.
[model]
model_name = "Qwen/Qwen3.5-9B"
max_seq_length = 4096
[training]
num_epochs = 3
batch_size = 64 # 8000 traj / 64 ≈ 125 batches/epoch
learning_rate = 2e-5
weight_decay = 0.01
warmup_ratio = 0.1
gradient_checkpointing = true
[data]
trace_cache_path = "data/orchestrator_qwen3.5-9b_sft_v1.jsonl"
regenerate_traces = false
# Base Qwen3.5-9B self-sampling over load_sft_tasks() (see sft_data/reject_sample.py).
orchestrator_endpoint = "http://localhost:8001/v1"
orchestrator_model = "qwen3.5-9b"
samples_per_task = 8
max_keep_per_task = 1
distill_max_tasks = 0 # 0 -> all of load_sft_tasks()
[checkpoint]
checkpoint_dir = "checkpoints/orchestrator_qwen3.5-9b_sft"
save_every_n_epochs = 1
@@ -0,0 +1,288 @@
"""Eval backend that drives the ToolOrchestra orchestrator as the "model".
This adapts our unified-tool orchestrator rollout
(:func:`openjarvis.agents.hybrid.toolorchestra.rollout.run_unified_rollout`)
to the eval framework's :class:`~openjarvis.evals.core.backend.InferenceBackend`
interface so it can be scored by the existing
:class:`~openjarvis.evals.core.runner.EvalRunner` over any registered benchmark.
The orchestrator is an OpenAI-compatible chat model (served locally via vLLM,
default ``http://localhost:8001/v1``) that emits tool calls over the fixed
8-tool catalog from :func:`expert_registry.orchestrator_catalog`. For each eval
sample we run one full rollout and return ``rollout.final_answer`` as the model
answer, plus token/cost telemetry in the ``generate_full`` payload.
Construction is import-safe and network-free; the OpenAI SDK is only touched
inside ``call_orchestrator`` at generate time (lazy import in ``unified.py``).
"""
from __future__ import annotations
import re
import time
from typing import Any, Dict, Optional
from openjarvis.agents.hybrid.expert_registry import orchestrator_catalog
from openjarvis.agents.hybrid.toolorchestra import rollout as _rollout_mod
from openjarvis.agents.hybrid.toolorchestra.unified import (
make_call_orchestrator,
make_dispatch,
)
from openjarvis.evals.core.backend import InferenceBackend
# The served fine-tuned model sometimes over-emits the answer marker, e.g.
# "FINAL_ANSWER: FINAL_ANSWER: 42" — a doubled prefix that breaks answer
# extraction and auto-scores the sample 0. Collapse any run of FINAL_ANSWER
# markers to a single one, keeping the text after the LAST marker as the answer.
_FA_MARK = re.compile(r"(?im)FINAL[_\s]?ANSWER\s*:?")
def _clean_final_answer(text: str) -> str:
t = (text or "").strip()
marks = list(_FA_MARK.finditer(t))
if not marks:
return t
answer = t[marks[-1].end() :].strip()
return f"FINAL_ANSWER: {answer}" if answer else t
_OBS_CAP = 4000 # cap persisted observations so the eval JSONL stays readable
DEFAULT_ENDPOINT = "http://localhost:8001/v1"
DEFAULT_MODEL = "qwen3-8b"
class OrchestratorBackend(InferenceBackend):
"""Run the ToolOrchestra orchestrator rollout as an eval "model".
Parameters
----------
orchestrator_endpoint:
OpenAI-compatible base URL for the served orchestrator (vLLM). Defaults
to ``http://localhost:8001/v1``.
orchestrator_model:
Served model id for the orchestrator. Defaults to ``"qwen3-8b"`` (will
be the Qwen3.5-9B checkpoint later).
api_key:
API key for the endpoint. ``"EMPTY"`` for a local vLLM server.
local_endpoints:
Optional dict mapping a local OSS model id (e.g. ``"Qwen/Qwen3.5-9B"``)
to its vLLM base URL. Unmapped local models are still listed in the
catalog but served as unconfigured (``base_url=None``).
max_turns:
Maximum orchestrator reasoning->action->observation turns per sample.
temperature:
Orchestrator sampling temperature.
dispatch_cfg:
Optional config dict passed to :func:`make_dispatch` (worker dispatch).
"""
backend_id = "orchestrator"
framework_name = "openjarvis-orchestrator"
def _dump_trace(self, prompt, rollout, orch_model) -> None:
"""Append one trajectory_to_record row to $OJ_EVAL_TRACE_DIR/traces.jsonl."""
import hashlib
import json
import os
import threading
from pathlib import Path
trace_dir = os.environ.get("OJ_EVAL_TRACE_DIR")
if not trace_dir:
return
try:
from openjarvis.learning.intelligence.orchestrator.sft_data.unified_serialize import (
trajectory_to_record,
)
rec = trajectory_to_record(
hashlib.md5(prompt.encode()).hexdigest()[:12],
prompt,
self._tools,
rollout,
)
rec["orchestrator_model"] = orch_model
Path(trace_dir).mkdir(parents=True, exist_ok=True)
if not hasattr(self, "_trace_lock"):
self._trace_lock = threading.Lock()
with self._trace_lock:
with open(Path(trace_dir) / "traces.jsonl", "a") as fh:
fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
except Exception: # noqa: BLE001 — tracing must never fail an eval sample
pass
def __init__(
self,
*,
orchestrator_endpoint: str = DEFAULT_ENDPOINT,
orchestrator_model: str = DEFAULT_MODEL,
api_key: str = "EMPTY",
local_endpoints: Optional[Dict[str, str]] = None,
max_turns: int = 8,
temperature: float = 1.0,
finetuned: bool = True,
dispatch_cfg: Optional[Dict[str, Any]] = None,
) -> None:
self.orchestrator_endpoint = orchestrator_endpoint
self.orchestrator_model = orchestrator_model
self.api_key = api_key
self.local_endpoints = dict(local_endpoints or {})
self.max_turns = int(max_turns)
self.temperature = float(temperature)
# False -> serve with native tools= (a BASE model needs the server's tool
# template to emit reliable calls; the baked-JSON prompt is what a
# FINE-TUNED model was trained on). Getting this wrong handicaps the model:
# the first fixed-harness baseline rerun served the base model in baked
# mode and scored 0.184 — not comparable to anything.
self.finetuned = bool(finetuned)
self._dispatch_cfg = dict(dispatch_cfg or {})
# Build the catalog once; it is stateless. ``local_endpoints`` now maps
# full local model ids (e.g. ``"Qwen/Qwen3.5-9B"``) -> vLLM base_url.
self._tools = orchestrator_catalog(local_endpoints=self.local_endpoints)
# ------------------------------------------------------------------
# InferenceBackend abstract methods
# ------------------------------------------------------------------
def generate(
self,
prompt: str,
*,
model: str,
system: str = "",
temperature: float = 0.0,
max_tokens: int = 2048,
) -> str:
"""Run a rollout and return just the final-answer text."""
full = self.generate_full(
prompt,
model=model,
system=system,
temperature=temperature,
max_tokens=max_tokens,
)
return full.get("content", "") or ""
def generate_full(
self,
prompt: str,
*,
model: str,
system: str = "",
temperature: float = 0.0,
max_tokens: int = 2048,
) -> Dict[str, Any]:
"""Run one orchestrator rollout over ``prompt`` and return full details.
Returns a dict with the keys the runner reads: ``content``, ``usage``,
``model``, ``latency_seconds``, ``cost_usd``, plus ``tool_calls`` /
``turn_count`` / ``framework``. On error, returns ``content=""`` and a
non-empty ``error`` field so the runner records it as a failed sample
rather than crashing the whole run.
"""
# The caller-supplied ``model`` is the RunConfig.model (the orchestrator
# model name). Fall back to the configured one if blank.
orch_model = model or self.orchestrator_model
# Prefer the explicit per-call temperature only when the caller set a
# non-default value; otherwise use the backend's configured temperature.
temp = temperature if temperature else self.temperature
started = time.time()
try:
call_orch = make_call_orchestrator(
orch_model,
base_url=self.orchestrator_endpoint,
api_key=self.api_key,
temperature=temp,
max_tokens=max_tokens,
# Fine-tuned model: baked JSON <tool_call> prompt, no tools=.
# Base model (finetuned=False): native tools= — it can't emit
# reliable calls otherwise.
native_tools=not self.finetuned,
)
dispatch = make_dispatch(self._dispatch_cfg)
rollout = _rollout_mod.run_unified_rollout(
prompt,
self._tools,
call_orchestrator=call_orch,
dispatch=dispatch,
max_turns=self.max_turns,
# Match TRAINING: the model was fine-tuned on anonymized expert
# labels (expert_xxxx), so it only picks valid labels / routes
# correctly when it sees the same anonymized names at inference.
anonymize=True,
)
# Persist the FULL trajectory when asked (OJ_EVAL_TRACE_DIR). The
# scored JSONL keeps only the final answer — without this every eval
# conversation is discarded and failures can't be audited (e.g.
# "is GAIA 0.20 turn-exhaustion or wrong answers?" was unanswerable
# for the first sft879 run). Rows are the same `conversations`
# records the generation pipeline writes, so render_sft_data.py
# renders them as per-sample markdown.
self._dump_trace(prompt, rollout, orch_model)
except Exception as exc: # noqa: BLE001 - surface as a failed sample
return {
"content": "",
"error": f"{type(exc).__name__}: {exc}",
"usage": {"prompt_tokens": 0, "completion_tokens": 0},
"model": orch_model,
"latency_seconds": time.time() - started,
"cost_usd": 0.0,
"tool_calls": 0,
"turn_count": 0,
"framework": self.framework_name,
}
latency = time.time() - started
total_tokens = int(getattr(rollout, "tokens", 0) or 0)
anon_map = getattr(rollout, "anon_map", None) or {}
return {
"content": _clean_final_answer(rollout.final_answer or ""),
# The rollout reports a single combined token count; we surface it
# as completion_tokens so it still flows into total-token telemetry.
"usage": {
"prompt_tokens": 0,
"completion_tokens": total_tokens,
},
"model": orch_model,
"latency_seconds": latency,
"cost_usd": float(getattr(rollout, "cost_usd", 0.0) or 0.0),
"tool_calls": int(getattr(rollout, "num_tool_calls", 0) or 0),
"turn_count": len(getattr(rollout, "turns", []) or []),
"framework": self.framework_name,
"trace_data": {
"parse_failures": int(getattr(rollout, "parse_failures", 0) or 0),
"tool_calls": [
{"name": n, "arguments": a} for n, a in rollout.tool_calls()
],
# Full step-by-step trace so eval samples are inspectable
# (format_eval_sample.py renders this). Observations capped.
"anon_map": anon_map,
"turns": [
{
"reasoning": t.reasoning or "",
"tool_name": t.tool_name,
"real_model": anon_map.get(t.tool_name)
if t.tool_name
else None,
"arguments": t.arguments,
"observation": (
(t.observation or "")[:_OBS_CAP]
+ (
"…[truncated]"
if t.observation and len(t.observation) > _OBS_CAP
else ""
)
),
}
for t in (getattr(rollout, "turns", []) or [])
],
"final_answer": _clean_final_answer(rollout.final_answer or ""),
},
}
__all__ = ["OrchestratorBackend", "DEFAULT_ENDPOINT", "DEFAULT_MODEL"]
@@ -52,7 +52,7 @@ class OrchestratorSFTConfig:
"""Configuration for orchestrator SFT training."""
# Model
model_name: str = "Qwen/Qwen3-1.7B"
model_name: str = "Qwen/Qwen3.5-9B"
max_seq_length: int = 4096
# Training
@@ -63,17 +63,28 @@ class OrchestratorSFTConfig:
warmup_ratio: float = 0.1
max_grad_norm: float = 1.0
# Trace generation
teacher_engine_key: str = ""
teacher_model: str = ""
traces_per_query: int = 2
max_attempts_per_trace: int = 3
# Trace generation by base Qwen3-8B self-sampling (v1 cold-start). The
# orchestrator IS the local Qwen3-8B served over an OpenAI-compatible vLLM
# endpoint; we roll it out over load_sft_tasks() and keep correct trajectories.
# For v2, point orchestrator_endpoint/model at the v1 checkpoint's server.
orchestrator_endpoint: str = "http://localhost:8001/v1"
orchestrator_model: str = "qwen3-8b"
orchestrator_api_key: str = "EMPTY"
generation_temperature: float = 0.7
# Data source
trace_cache_path: str = "data/orchestrator_sft_traces.jsonl"
trace_cache_path: str = "data/orchestrator_sft_v1.jsonl"
regenerate_traces: bool = False
# Rejection-sampling cold-start over load_sft_tasks (see sft_data/reject_sample.py).
distill_max_tasks: int = 0 # 0 / None -> all of load_sft_tasks()
samples_per_task: int = 8
max_keep_per_task: int = 1
max_rollout_turns: int = 8
# Optional local OSS model endpoints, mapping full model id (e.g.
# "Qwen/Qwen3.5-9B") -> vLLM base_url; omitted when unset.
local_endpoints: Dict[str, str] = field(default_factory=dict)
# Checkpoint
checkpoint_dir: str = "checkpoints/orchestrator_sft"
save_every_n_epochs: int = 1
@@ -267,11 +278,81 @@ class OrchestratorSFTTrainer:
)
def _generate_traces(self) -> None:
"""Generate SFT traces (placeholder — requires running engine)."""
"""Generate SFT traces by base Qwen3-8B self-sampling (v1 cold-start).
The orchestrator IS the local Qwen3-8B served over an OpenAI-compatible
vLLM endpoint (``orchestrator_endpoint``/``orchestrator_model``). We roll
it out over ``load_sft_tasks()`` on the fixed orchestrator tool catalog,
verify each trajectory's final answer against the gold
(``verify.make_verifier``), keep the cheapest-correct trajectory per task,
and serialize them into the ``<tool_call>`` ``conversations`` JSONL the SFT
dataset consumes. For v2, point the endpoint/model at the v1 checkpoint's
server. See ``sft_data/reject_sample.py`` and the
``scripts/orchestrator/build_orchestrator_sft.py`` driver.
"""
from openjarvis.agents.hybrid.expert_registry import orchestrator_catalog
from openjarvis.agents.hybrid.toolorchestra.rollout import run_unified_rollout
from openjarvis.agents.hybrid.toolorchestra.unified import (
make_call_orchestrator,
make_dispatch,
)
from openjarvis.learning.intelligence.orchestrator.sft_data import (
reject_sample as _reject_sample,
)
from openjarvis.learning.intelligence.orchestrator.sft_data.datasets import (
load_sft_tasks,
)
generate_sft_dataset = _reject_sample.generate_sft_dataset
from openjarvis.learning.intelligence.orchestrator.sft_data.verify import (
make_verifier,
)
trace_path = Path(self.config.trace_cache_path)
trace_path.parent.mkdir(parents=True, exist_ok=True)
if not trace_path.exists():
trace_path.touch()
tools = orchestrator_catalog(
local_endpoints=self.config.local_endpoints or None,
)
call_orch = make_call_orchestrator(
self.config.orchestrator_model,
base_url=self.config.orchestrator_endpoint,
api_key=self.config.orchestrator_api_key,
temperature=self.config.generation_temperature,
)
dispatch = make_dispatch({})
def rollout_fn(task: Any) -> Any:
try:
return run_unified_rollout(
task.instruction,
tools,
call_orchestrator=call_orch,
dispatch=dispatch,
max_turns=self.config.max_rollout_turns,
)
except Exception as exc: # network/key failures shouldn't kill the run
logger.warning("rollout failed for %s: %s", task.task_id, exc)
return None
try:
tasks = load_sft_tasks()
if self.config.distill_max_tasks:
tasks = tasks[: self.config.distill_max_tasks]
stats = generate_sft_dataset(
str(trace_path),
tasks=tasks,
tools=tools,
rollout_fn=rollout_fn,
verify_fn=make_verifier(),
samples_per_task=self.config.samples_per_task,
max_keep_per_task=self.config.max_keep_per_task,
reward_fn=lambda r: -r.cost_usd, # cheapest-correct gets top reward
)
logger.info("Generated SFT traces: %s", stats)
except Exception as exc: # network/datasets optional — fail soft to empty
logger.warning("trace generation failed (%s); writing empty set", exc)
trace_path.parent.mkdir(parents=True, exist_ok=True)
if not trace_path.exists():
trace_path.touch()
def _init_optimizer(self) -> None:
if not HAS_TORCH or self.policy.model is None:
@@ -0,0 +1,182 @@
"""Offline tests for the orchestrator eval backend + the eval CLI's
benchmark-name normalization.
No network / models: ``run_unified_rollout`` is monkeypatched to return a
canned rollout, and the dataset-key mapping is asserted against the real
``openjarvis.evals.cli`` registry without instantiating any (network-bound)
dataset.
"""
from __future__ import annotations
import pytest
from openjarvis.agents.hybrid.toolorchestra.rollout import UnifiedTurn
from openjarvis.learning.intelligence.orchestrator import eval_backend as eb
from openjarvis.learning.intelligence.orchestrator.eval_backend import (
OrchestratorBackend,
)
class _CannedRollout:
"""Minimal stand-in for UnifiedRollout."""
def __init__(self, final_answer: str) -> None:
self.final_answer = final_answer
self.cost_usd = 0.123
self.tokens = 42
self.num_tool_calls = 1
self.parse_failures = 0
self.anon_map = {}
# generate_full now serializes a per-turn trace (reasoning / tool_name /
# arguments / observation), so turns must be real UnifiedTurn-shaped
# objects, not bare object() placeholders.
self.turns = [
UnifiedTurn(
reasoning="let me search",
tool_name="web_search",
arguments={"query": "x"},
observation="result",
),
]
def tool_calls(self):
return [("web_search", {"query": "x"})]
def _patch_rollout(monkeypatch, rollout):
"""Patch run_unified_rollout where the backend looks it up."""
monkeypatch.setattr(
eb._rollout_mod,
"run_unified_rollout",
lambda *a, **k: rollout,
)
def test_construct_is_network_free():
"""Constructing the backend builds the catalog but touches no network."""
backend = OrchestratorBackend()
assert backend.backend_id == "orchestrator"
assert backend.orchestrator_endpoint == eb.DEFAULT_ENDPOINT
assert backend.orchestrator_model == eb.DEFAULT_MODEL
# Catalog is non-empty (cloud frontier + local OSS + basic tools).
assert len(backend._tools) > 0
names = {t.name for t in backend._tools}
assert "web_search" in names
def test_local_endpoints_mapping():
"""local_endpoints (model-id -> base_url) wires the local OSS tool."""
backend = OrchestratorBackend(
local_endpoints={"Qwen/Qwen3.5-9B": "http://m:9/v1"},
)
assert backend.local_endpoints["Qwen/Qwen3.5-9B"] == "http://m:9/v1"
# The corresponding local-OSS tool should carry that base_url.
tool = next(t for t in backend._tools if t.model == "Qwen/Qwen3.5-9B")
assert tool.base_url == "http://m:9/v1"
def test_generate_returns_final_answer(monkeypatch):
"""generate() returns the rollout's final_answer string."""
_patch_rollout(monkeypatch, _CannedRollout("42"))
backend = OrchestratorBackend()
out = backend.generate("what is 6*7?", model="qwen3-8b")
assert out == "42"
def test_generate_full_payload(monkeypatch):
"""generate_full() returns the runner-expected payload shape."""
_patch_rollout(monkeypatch, _CannedRollout("42"))
backend = OrchestratorBackend()
full = backend.generate_full("q", model="qwen3-8b")
assert full["content"] == "42"
assert full["cost_usd"] == 0.123
assert full["usage"]["completion_tokens"] == 42
assert full["tool_calls"] == 1
assert full["turn_count"] == 1
assert full["framework"] == "openjarvis-orchestrator"
assert "error" not in full
assert full["latency_seconds"] >= 0.0
def test_generate_full_error_path(monkeypatch):
"""An exception inside the rollout becomes a recorded error, not a crash."""
def _boom(*a, **k):
raise RuntimeError("vllm down")
monkeypatch.setattr(eb._rollout_mod, "run_unified_rollout", _boom)
backend = OrchestratorBackend()
full = backend.generate_full("q", model="qwen3-8b")
assert full["content"] == ""
assert "vllm down" in full["error"]
assert full["usage"]["completion_tokens"] == 0
# ---------------------------------------------------------------------------
# Eval-script benchmark name mapping
# ---------------------------------------------------------------------------
# The 5 benchmarks the eval script targets, by the script's (alias) names.
SCRIPT_BENCHMARKS = [
"gaia",
"terminalbench_v2_1",
"taubench",
"mmlu_pro",
"supergpqa",
]
def _load_script():
import importlib.util
from pathlib import Path
repo_root = Path(__file__).resolve().parents[2]
script_path = repo_root / "scripts" / "orchestrator" / "eval_orchestrator.py"
spec = importlib.util.spec_from_file_location("eval_orchestrator", script_path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def test_benchmark_aliases_map_to_registry_keys():
"""Each script benchmark normalizes to a real evals.cli BENCHMARKS key."""
from openjarvis.evals.cli import BENCHMARKS
script = _load_script()
for name in SCRIPT_BENCHMARKS:
key = script._normalize_benchmark(name)
assert key in BENCHMARKS, f"{name} -> {key} not in BENCHMARKS registry"
def test_normalize_specific_keys():
"""Spot-check the underscored aliases normalize to the dotted/dashed keys."""
script = _load_script()
assert script._normalize_benchmark("terminalbench_v2_1") == "terminalbench-v2.1"
assert script._normalize_benchmark("mmlu_pro") == "mmlu-pro"
assert script._normalize_benchmark("gaia") == "gaia"
def test_build_dataset_accepts_keys():
"""_build_dataset must recognise the normalized keys.
We only assert it doesn't raise the 'Unknown benchmark' ClickException;
datasets that need network/files at construction are xfail-skipped.
"""
import click
from openjarvis.evals.cli import _build_dataset
script = _load_script()
for name in SCRIPT_BENCHMARKS:
key = script._normalize_benchmark(name)
try:
_build_dataset(key)
except click.ClickException as exc:
if "Unknown benchmark" in str(exc):
pytest.fail(f"{key} not recognised by _build_dataset")
# Other ClickExceptions (missing files/creds) are acceptable here.
except Exception:
# Construction may require network/files — that's fine; the point
# is the key is recognised (didn't hit the unknown-benchmark else).
pytest.skip(f"{key} dataset construction needs resources")
@@ -0,0 +1,120 @@
"""Control-token sanitation in the SFT serializer + clean gate.
Regression guard for the leak audit: leaked control/special tokens
(``<|im_end|>``, ``<|tool_call>``, gemma ``<start_of_turn>``/``<end_of_turn>``,
``<|"|>`` …) used to survive into the supervised final answer because the clean
gate only rejected the bare ``<tool_call>`` form. Defense in depth now:
1. ``_final_answer_block`` STRIPS residual control tokens so a good answer with a
stray token is salvaged (not dropped).
2. ``_target_is_clean`` REJECTS a rollout whose final answer is nothing but
control tokens, or where a token survives the strip.
Both must leave legitimate ``<``/``>`` in math/code untouched.
"""
from __future__ import annotations
import pytest
from openjarvis.agents.hybrid.toolorchestra.rollout import UnifiedRollout, UnifiedTurn
from openjarvis.learning.intelligence.orchestrator.sft_data.reject_sample import (
_target_is_clean,
)
from openjarvis.learning.intelligence.orchestrator.sft_data.unified_serialize import (
_CONTROL_TOKEN_RE,
_final_answer_block,
_strip_control_tokens,
)
# --- serializer strips control tokens, leaving a clean FINAL_ANSWER line ------
@pytest.mark.parametrize(
"raw, expected_answer",
[
("FINAL_ANSWER: 42<end_of_turn>", "42"),
("FINAL_ANSWER: 42<|end_of_turn|>", "42"),
("FINAL_ANSWER: 42<|im_end|>", "42"),
("FINAL_ANSWER: 42<|eot_id|>", "42"),
("FINAL_ANSWER: The result<|tool_call>", "The result"),
('FINAL_ANSWER: foo <|"|> bar', "foo bar"),
("<start_of_turn>model", "model"),
],
)
def test_final_answer_block_strips_tokens(raw: str, expected_answer: str) -> None:
out = _final_answer_block(raw)
assert out == f"FINAL_ANSWER: {expected_answer}"
# No control token survives the serializer.
assert not _CONTROL_TOKEN_RE.search(out), out
def test_bare_im_end_only_becomes_empty_answer() -> None:
# An answer that is nothing but a control token strips to empty; the clean
# gate (below) is what rejects such a rollout.
assert _final_answer_block("<|im_end|>") == "FINAL_ANSWER: "
def test_math_and_code_angle_brackets_survive() -> None:
# The narrow regex must NOT eat legitimate comparisons / generics.
for good in [
"FINAL_ANSWER: x < 3 and y > 2",
"FINAL_ANSWER: List<int> and a<b>c",
"FINAL_ANSWER: if a < b: return a > 0",
]:
out = _final_answer_block(good)
assert out == good, out
assert not _CONTROL_TOKEN_RE.search(out)
def test_strip_helper_handles_nested_and_whitespace() -> None:
assert _strip_control_tokens(" hi <|im_end|> ") == "hi"
assert _strip_control_tokens("a<|im_start|>b<|im_end|>c") == "abc"
# --- clean gate: salvage vs reject -------------------------------------------
def _roll(final_answer: str) -> UnifiedRollout:
"""A minimal well-formed rollout (one expert call, clean obs) whose only
variable is the final answer."""
return UnifiedRollout(
turns=[
UnifiedTurn(
reasoning="route it",
tool_name="expert_a",
arguments={"input": "q"},
observation="valid observation",
),
UnifiedTurn(reasoning=final_answer, tool_name=None),
],
final_answer=final_answer,
cost_usd=0.01,
tokens=10,
num_tool_calls=1,
anon_map={"expert_a": "gpt"},
)
@pytest.mark.parametrize(
"final_answer, clean",
[
# Salvageable: a good answer with a trailing stray token stays clean
# (the serializer strips the token from the emitted target).
("42<end_of_turn>", True),
("42<|end_of_turn|>", True),
("The result<|tool_call>", True),
('foo <|"|> bar', True),
("<start_of_turn>model", True),
("x < 3 and y > 2", True),
("a normal plain answer", True),
# Unsalvageable: the answer is nothing but a control token -> rejected.
("<|im_end|>", False),
("<|eot_id|>", False),
("<end_of_turn>", False),
# Bare tool-call tag: already rejected by the existing gate.
("<tool_call>{}</tool_call>", False),
],
)
def test_clean_gate_salvages_or_rejects(final_answer: str, clean: bool) -> None:
assert _target_is_clean(_roll(final_answer)) is clean
@@ -15,7 +15,7 @@ from openjarvis.learning.intelligence.orchestrator.sft_trainer import (
class TestOrchestratorSFTConfig:
def test_defaults(self):
cfg = OrchestratorSFTConfig()
assert cfg.model_name == "Qwen/Qwen3-1.7B"
assert cfg.model_name == "Qwen/Qwen3.5-9B"
assert cfg.num_epochs == 3
assert cfg.batch_size == 8
assert cfg.learning_rate == 2e-5