mirror of https://github.com/razor-ai/soup.git
feat(reward): soup reward synth — synthesize a deterministic reward verifier (v0.71.40)
Point `soup reward synth <refs.jsonl> -o reward.py` at reference (gold) outputs and it infers a deterministic verifier (numeric / json_schema / regex / tool_call), emits a readable, committable .py reward_fn that rides load_reward_fn's existing .py path (no new exec surface), and — the moat — REFUSES to emit one that can't discriminate its references from auto-perturbed negatives via a mandatory calibration report (accept refs >=90% AND reject negatives; hard floor at discrimination<=0). Nothing in TRL/Unsloth/Axolotl synthesizes a reward. Fixes #311: a comma-separated reward_fn ("accuracy,format") now loads as a reward ensemble (GRPOTrainer reward_funcs=[...], unlocks the rm_ensemble detector), GRPO-only and validated at config-parse; the deepseek-v3-reasoning recipe that shipped this previously crashed with "Unknown reward function". Riders: reward_fn field-validator (null-byte/blank/oversize/empty-comma-segment); comma-aware verifiable-domain check; envs/calculator + guess_number docstrings corrected. 5 sequential ECC reviews, every finding fixed (python HIGH PPO gate; code 2xHIGH per-tool arg binding + json_schema mixed-shape refuse; security HIGH rel_hint codegen injection; tdd 8xHIGH). Live smoke on RTX 3050: synth from envs/calculator agrees with math_verify; degenerate refused (exit 2); real GRPO on SmolLM2-135M with reward_fn=accuracy,format completed optimizer steps. +103 tests (tests/test_v07140.py); 16387 -> 16490.
This commit is contained in:
parent
cba984a574
commit
43a7fb21a5
39
CHANGELOG.md
39
CHANGELOG.md
|
|
@ -12,6 +12,45 @@ reproducing 70+ versions of notes.
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.71.40] - 2026-07-19
|
||||
|
||||
**`soup reward synth`: auto-generate a deterministic reward verifier from your data.**
|
||||
Point it at a JSONL of reference (gold) outputs and it infers a verifier, emits a
|
||||
readable / committable `.py` reward function, and — the moat — *refuses* to emit one
|
||||
that can't tell your references from auto-generated bad answers. Nothing in
|
||||
TRL / Unsloth / Axolotl / OpenRLHF synthesizes a reward; every reward today is
|
||||
hand-written, hand-picked, or a trained-weights artifact.
|
||||
|
||||
### Added
|
||||
|
||||
- **`soup reward synth <references.jsonl> -o reward.py`** — deterministic verifier
|
||||
synthesis. Four families, auto-detected (or pick with `--kind`): `numeric`
|
||||
(last-number / `\boxed{}` / `####` extraction, exact or `--tolerance`), `json_schema`
|
||||
(induced keys + types + required), `regex` (positional char-classes over
|
||||
equal-length golds), `tool_call` (per-tool `required`/`allowed` argument binding).
|
||||
The emitted file is self-contained and rides `load_reward_fn`'s existing `.py`
|
||||
path — no new trusted-exec surface; you read, edit, commit, and diff it.
|
||||
- **Mandatory calibration report** — the synthesized verifier is loaded back and run
|
||||
against its own references (must accept ≥90%) and auto-perturbed negatives (must
|
||||
reject). A degenerate always-accept verifier is **refused** (`exit 2`), never
|
||||
silently emitted. `--plan-only` reports the induced spec without writing;
|
||||
`--output-report` persists the calibration JSON.
|
||||
- **Comma-separated `reward_fn` (`"accuracy,format"`)** now trains — it resolves to a
|
||||
reward *ensemble* (`GRPOTrainer(reward_funcs=[...])`, and unlocks the `rm_ensemble`
|
||||
reward-hack detector which needs ≥2 rewards). GRPO-only, validated at config-parse
|
||||
time. Fixes a recipe (`deepseek-v3-reasoning`) that shipped exactly this and
|
||||
previously crashed with `Unknown reward function` (#311).
|
||||
|
||||
### Changed / Fixed
|
||||
|
||||
- `training.reward_fn` gains a field validator (null-byte / blank / oversize /
|
||||
empty-comma-segment rejection) — the oldest arbitrary-code field was the least
|
||||
guarded. Comma + `verifiable` without a `verifiable_domain` now fails at parse
|
||||
time like the bare `verifiable` form.
|
||||
- `envs/calculator.py` / `envs/guess_number.py` docstrings corrected: the reward is
|
||||
`reward_fn: verifiable` + `verifiable_domain: math` (the bare `reward_fn='math'`
|
||||
they showed was never valid).
|
||||
|
||||
## [0.71.39] - 2026-07-19
|
||||
|
||||
**"CI for weights, not prompts": close the evidence loop.** `soup ship`'s verdict is
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ src/soup_cli/
|
|||
templates/ - 21 built-in soup.yaml templates (YAML + manifest.json) with load_template loader (v0.39.0, +bco v0.40.0, +4 compliance v0.71.35)
|
||||
ui/ - Web UI (FastAPI + HTML/JS SPA)
|
||||
|
||||
tests/ - Test suite (317 files, 16387 tests)
|
||||
tests/ - Test suite (318 files, 16490 tests)
|
||||
examples/ - Real-world config examples and datasets
|
||||
```
|
||||
|
||||
|
|
|
|||
42
README.md
42
README.md
|
|
@ -49,29 +49,37 @@ infrastructure instead of improving models. Soup fixes that.
|
|||
|
||||
## What's New
|
||||
|
||||
**v0.71.39 — CI for weights, not prompts.** `soup ship`'s verdict is now something Soup can emit, commit, review, and bind to the exact model that produced it — so a fine-tuning gate renders on every PR instead of asking you to hand-edit a JSON file.
|
||||
**v0.71.40 — `soup reward synth`: generate a reward verifier from your data.** Point it at a JSONL of reference (gold) outputs and it infers a deterministic verifier, writes a readable / committable `.py` reward function, and — the part nobody else does — *refuses* to emit one that can't tell your references from bad answers. Nothing in TRL / Unsloth / Axolotl synthesizes a reward.
|
||||
|
||||
- **Output is finally input.** `soup ship --emit-evidence ev.json` writes the scores in the
|
||||
`--evidence` schema, so a run replays offline into an identical verdict — the round-trip the
|
||||
old `--output` couldn't do.
|
||||
- **Commit the gate policy.** Put `eval.ship` in your `soup.yaml` (`forgetting_threshold`,
|
||||
`general_suite`, `task_mode`, …) and `soup ship --config soup.yaml` reads it — the gate is now
|
||||
reviewable in a PR diff. Explicit flags still win.
|
||||
- **Provenance-bound evidence.** `--config --emit-evidence` STAMPS a `config_sha` onto the
|
||||
evidence; `--config --evidence` GATES on it — a PR that changed the recipe but forgot to
|
||||
recompute its evidence is refused (exit 3). Tuning the threshold never falsely invalidates it.
|
||||
- **The verdict on your PR.** `soup ship --push owner/repo#N` posts the SHIP / DON'T-SHIP card as
|
||||
a GitHub comment; `soup ci init --config soup.yaml` wires the whole thing into CI.
|
||||
- **Four families, auto-detected** (or pick with `--kind`): `numeric` (`\boxed{}` / `####` / last-number,
|
||||
exact or `--tolerance`), `json_schema` (induced keys + types + required), `regex`, and `tool_call`
|
||||
(per-tool `required`/`allowed` argument binding — a call can't borrow another tool's keys).
|
||||
- **A calibration report is the moat.** The verifier is loaded back and run against its own references
|
||||
(must accept ≥ 90%) and auto-generated bad answers (must reject). A degenerate always-accept verifier
|
||||
is **refused** (exit 2), never silently shipped. `--plan-only` previews; `--output-report` saves the JSON.
|
||||
- **No new exec surface.** The emitted file rides the existing `reward_fn: reward.py` path — you read,
|
||||
edit, commit, and diff it like any other reward.
|
||||
- **Reward ensembles now train.** `reward_fn: "accuracy,format"` loads as multiple rewards (and unlocks
|
||||
the `rm_ensemble` reward-hack detector) — a recipe that shipped exactly this used to crash. (#311)
|
||||
|
||||
```bash
|
||||
# producer (train job): stamp the scores with the config that produced them
|
||||
soup ship --evidence scores.json --config soup.yaml --emit-evidence ship_evidence.json
|
||||
# synth a verifier from reference answers, calibrate it, keep it only if it discriminates
|
||||
soup reward synth references.jsonl -o reward.py --output-report calib.json
|
||||
|
||||
# gate (PR CI): refuse evidence that doesn't match the committed config
|
||||
soup ship --evidence ship_evidence.json --config soup.yaml --push owner/repo#42
|
||||
soup ci init --config soup.yaml # writes .github/workflows/soup-gate.yml
|
||||
# then train against the reward you just generated
|
||||
# training: { reward_fn: reward.py } (or an ensemble: reward_fn: "accuracy,format")
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Previous release — v0.71.39, CI for weights not prompts (emit + provenance-bind the ship verdict)</summary>
|
||||
|
||||
`soup ship`'s verdict became emittable, committable, and provenance-bound: `--emit-evidence` makes a
|
||||
run replay into an identical verdict, `eval.ship` in `soup.yaml` + `--config` makes the gate policy
|
||||
reviewable, and `--config` binds evidence to the exact recipe that produced it (stale evidence → exit 3).
|
||||
`soup ship --push owner/repo#N` posts the SHIP / DON'T-SHIP card on the PR.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Previous release — v0.71.38, The gate grows teeth (real leg-2 regression gate)</summary>
|
||||
|
||||
|
|
|
|||
|
|
@ -182,6 +182,10 @@ soup draft measure ... --min-acceptance 0.6 -o report.json Exit 2 below the flo
|
|||
soup draft distill --target <tuned> --draft-base <tiny> --data d.jsonl -o draft/ Distil a DENSE speculative-decoding draft + register it (v0.71.33)
|
||||
soup draft distill ... --steps N --device cpu --force --plan-only Training budget / device / overwrite -o / render the config only
|
||||
soup draft list List local drafts that `soup serve --auto-spec` will pick up (v0.71.33)
|
||||
soup reward synth refs.jsonl -o reward.py Synthesize a deterministic reward verifier from gold outputs (v0.71.40)
|
||||
soup reward synth ... --kind numeric|json_schema|regex|tool_call Force a verifier family (default: auto-detect)
|
||||
soup reward synth ... --plan-only Report the induced spec + calibration plan; write nothing
|
||||
soup reward synth ... --output-report r.json --min-discrimination 0.5 Save the calibration JSON / set the refusal threshold (exit 0 emit / 2 refuse / 1 error)
|
||||
soup tui Full-screen Textual dashboard (requires [tui] extra)
|
||||
soup train --config soup.yaml --profile Record torch.profiler trace to <output>/profiles/
|
||||
soup --log-level quiet|normal|verbose|debug Global logging tier (Rich-formatted)
|
||||
|
|
|
|||
|
|
@ -866,6 +866,37 @@ training:
|
|||
reward_fn: ./my_reward.py
|
||||
```
|
||||
|
||||
**Reward ensembles** — list several rewards, comma-separated, and they combine (GRPO only).
|
||||
This also unlocks the `rm_ensemble` reward-hack detector, which needs ≥ 2 rewards:
|
||||
```yaml
|
||||
training:
|
||||
reward_fn: "accuracy,format" # both are scored every step
|
||||
```
|
||||
|
||||
### Synthesize a verifier from your data (`soup reward synth`)
|
||||
|
||||
Don't hand-write a verifier — generate one from reference (gold) outputs. Soup infers a
|
||||
*deterministic* verifier (numeric / JSON-schema / regex / tool-call), writes a readable, editable
|
||||
`.py`, and **refuses to emit** one that can't tell your references from auto-generated bad answers
|
||||
(the mandatory calibration report). The emitted file is a normal `reward_fn: reward.py`.
|
||||
|
||||
```bash
|
||||
# infer + calibrate + emit (exit 0 kept, 2 refused, 1 error)
|
||||
soup reward synth references.jsonl -o reward.py --output-report calib.json
|
||||
|
||||
# preview the induced spec without writing anything
|
||||
soup reward synth references.jsonl --plan-only
|
||||
|
||||
# force a family instead of auto-detecting
|
||||
soup reward synth answers.jsonl -o reward.py --kind numeric --tolerance 1e-6
|
||||
```
|
||||
|
||||
References are a JSONL where each row's gold answer is in an `answer` field (override with
|
||||
`--field`) or the last assistant turn of a `messages` list. `--min-discrimination` sets how
|
||||
strongly the verifier must separate references from perturbed negatives before it's emitted.
|
||||
v1 is deterministic families only — a `\boxed{}`/`####` marker helps the numeric verifier, and
|
||||
completions are prompted to mark their answer (standard RLVR practice).
|
||||
|
||||
### Verifiable Rewards (RLVR)
|
||||
|
||||
Use `reward_fn: verifiable` with a `verifiable_domain` for deterministic, math-checkable rewards — no judge model, no heuristics. Great for GRPO on math, code, or structured-output tasks.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||
|
||||
[project]
|
||||
name = "soup-cli"
|
||||
version = "0.71.39"
|
||||
version = "0.71.40"
|
||||
description = "Fine-tune and post-train LLMs in one command. No SSH, no config hell."
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
"""Soup CLI — Fine-tune and post-train LLMs in one command."""
|
||||
|
||||
__version__ = "0.71.39"
|
||||
__version__ = "0.71.40"
|
||||
|
|
|
|||
|
|
@ -563,6 +563,18 @@ app.add_typer(
|
|||
),
|
||||
)
|
||||
|
||||
# v0.71.40 — `soup reward synth` auto-generate a deterministic verifier.
|
||||
from soup_cli.commands import reward as _reward_cmd # noqa: E402
|
||||
|
||||
app.add_typer(
|
||||
_reward_cmd.app,
|
||||
name="reward",
|
||||
help=(
|
||||
"Synthesize a deterministic reward verifier from reference outputs, with "
|
||||
"a calibration report that refuses degenerate verifiers (v0.71.40)."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _rewrite_advise_argv(argv: list) -> list:
|
||||
"""Inject `run` between `advise` and a non-subcommand first argument.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,225 @@
|
|||
"""soup reward — synthesize a deterministic reward verifier (v0.71.40).
|
||||
|
||||
soup reward synth <references.jsonl> -o reward.py
|
||||
[--kind auto|numeric|json_schema|regex|tool_call]
|
||||
|
||||
Infers a deterministic verifier from a dataset of reference (gold) outputs, emits a
|
||||
readable / committable ``.py`` that rides ``load_reward_fn``'s existing ``.py`` path,
|
||||
and REFUSES to emit a degenerate verifier via a mandatory calibration report.
|
||||
|
||||
Exit codes mirror ``soup ship`` / ``soup shrink``: 0 = emitted, 2 = refused (the
|
||||
verifier could not discriminate references from perturbed negatives), 1 = usage /
|
||||
runtime error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from typing import NoReturn, Optional
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from soup_cli.utils import reward_synth as rs
|
||||
from soup_cli.utils.paths import atomic_write_text, enforce_under_cwd_and_no_symlink
|
||||
|
||||
app = typer.Typer(help="Synthesize a deterministic reward verifier from reference outputs.")
|
||||
console = Console()
|
||||
|
||||
_MAX_INPUT_BYTES = 64 * 1024 * 1024
|
||||
_MAX_ROWS = 1_000_000
|
||||
_ALLOWED_KINDS = ("auto",) + rs.KINDS
|
||||
|
||||
|
||||
@app.callback()
|
||||
def _reward() -> None:
|
||||
"""Reward-verifier tooling. Forces ``synth`` to be a named subcommand (a
|
||||
single-command Typer app otherwise collapses and eats the subcommand token)."""
|
||||
|
||||
|
||||
def _fail(message: str, code: int = 1) -> NoReturn:
|
||||
"""Print a red error and exit (raises internally — a forgotten ``raise`` at a
|
||||
call site can never silently become a no-op; mirrors ``commands/ship.py``)."""
|
||||
console.print(f"[red]{escape(message)}[/]")
|
||||
raise typer.Exit(code)
|
||||
|
||||
|
||||
def _read_jsonl(path: str, label: str) -> list[dict]:
|
||||
"""Read a JSONL file: cwd-contained, O_NOFOLLOW, size- and row-capped."""
|
||||
enforce_under_cwd_and_no_symlink(path, label)
|
||||
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
||||
try:
|
||||
fd = os.open(path, flags)
|
||||
except OSError as exc:
|
||||
raise ValueError(f"{label} unreadable: {exc}") from exc
|
||||
rows: list[dict] = []
|
||||
with os.fdopen(fd, "r", encoding="utf-8") as handle:
|
||||
if os.fstat(handle.fileno()).st_size > _MAX_INPUT_BYTES:
|
||||
raise ValueError(f"{label} exceeds {_MAX_INPUT_BYTES} bytes")
|
||||
for line in handle:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(row, dict):
|
||||
rows.append(row)
|
||||
if len(rows) >= _MAX_ROWS:
|
||||
break
|
||||
return rows
|
||||
|
||||
|
||||
def _spec_summary(kind: str, spec: object) -> str:
|
||||
"""One-line human summary of the induced spec (for --plan-only / the panel)."""
|
||||
if kind == "numeric" and isinstance(spec, rs.NumericSpec):
|
||||
return f"numeric (float={spec.is_float}, tolerance={spec.tolerance})"
|
||||
if kind == "json_schema" and isinstance(spec, dict):
|
||||
props = ", ".join(sorted(spec.get("properties", {}))) or "(none)"
|
||||
return f"json_schema (type={spec.get('type')}, keys={props})"
|
||||
if kind == "tool_call" and isinstance(spec, rs.ToolCallSpec):
|
||||
return f"tool_call (names={list(spec.names)}, arg_keys={list(spec.arg_keys)})"
|
||||
if kind == "regex":
|
||||
return f"regex (pattern={spec})"
|
||||
return kind
|
||||
|
||||
|
||||
def _render_report_panel(report: rs.CalibrationReport, kind: str, out_path: str) -> Panel:
|
||||
table = Table(show_header=False, box=None, pad_edge=False)
|
||||
table.add_column(justify="right", style="dim")
|
||||
table.add_column()
|
||||
table.add_row("verifier kind", escape(kind))
|
||||
table.add_row("references (accepted)", f"{report.positives} ({report.pos_accept:.0%})")
|
||||
table.add_row("negatives (accepted)", f"{report.negatives} ({report.neg_accept:.0%})")
|
||||
table.add_row("discrimination", f"{report.discrimination:.2f}")
|
||||
table.add_row("precision", f"{report.precision:.2f}")
|
||||
table.add_row("emitted", escape(out_path))
|
||||
return Panel(table, title="[bold green]reward verifier synthesized[/]",
|
||||
border_style="green")
|
||||
|
||||
|
||||
@app.command()
|
||||
def synth(
|
||||
references: str = typer.Argument(
|
||||
..., help="JSONL of reference (gold) outputs — a gold field or chat messages."
|
||||
),
|
||||
output: Optional[str] = typer.Option(
|
||||
None, "-o", "--output", help="Where to write the verifier .py."
|
||||
),
|
||||
kind: str = typer.Option(
|
||||
"auto", "--kind",
|
||||
help="Verifier family: auto | numeric | json_schema | regex | tool_call.",
|
||||
),
|
||||
field: str = typer.Option(
|
||||
"answer", "--field", help="Gold-output field (default: answer)."
|
||||
),
|
||||
tolerance: Optional[float] = typer.Option(
|
||||
None, "--tolerance", help="Numeric match tolerance (numeric kind only)."
|
||||
),
|
||||
min_discrimination: float = typer.Option(
|
||||
rs.DEFAULT_MIN_DISCRIMINATION, "--min-discrimination",
|
||||
help="Refuse to emit unless (accept_rate refs - accept_rate negatives) >= this.",
|
||||
),
|
||||
force: bool = typer.Option(False, "--force", help="Overwrite an existing output."),
|
||||
plan_only: bool = typer.Option(
|
||||
False, "--plan-only", help="Detect + report the induced spec; write nothing."
|
||||
),
|
||||
output_report: Optional[str] = typer.Option(
|
||||
None, "--output-report", help="Also write the calibration report as JSON."
|
||||
),
|
||||
) -> None:
|
||||
"""Synthesize a deterministic reward verifier from reference outputs."""
|
||||
if kind not in _ALLOWED_KINDS:
|
||||
_fail(f"unknown --kind {kind!r}; options: {', '.join(_ALLOWED_KINDS)}")
|
||||
if not 0.0 <= min_discrimination <= 1.0:
|
||||
_fail("--min-discrimination must be in [0.0, 1.0]")
|
||||
if tolerance is not None and (not math.isfinite(tolerance) or tolerance < 0):
|
||||
_fail("--tolerance must be a finite, non-negative number")
|
||||
|
||||
# Read references.
|
||||
try:
|
||||
rows = _read_jsonl(references, "references path")
|
||||
except (ValueError, OSError) as exc:
|
||||
_fail(str(exc))
|
||||
if not rows:
|
||||
_fail("references file has no JSON-object rows")
|
||||
|
||||
# Detect + induce + render (no file written yet).
|
||||
try:
|
||||
result = rs.synthesize(rows, field=field, kind=kind, tolerance=tolerance,
|
||||
rel_hint=(output or "reward.py"))
|
||||
except (ValueError, TypeError) as exc:
|
||||
_fail(str(exc))
|
||||
|
||||
if plan_only:
|
||||
console.print(Panel(
|
||||
escape(_spec_summary(result.kind, result.spec)),
|
||||
title=f"[bold]plan: {escape(result.kind)} verifier[/]", border_style="cyan"))
|
||||
raise typer.Exit(0)
|
||||
|
||||
# Output guards.
|
||||
if not output:
|
||||
_fail("must pass -o/--output to emit a verifier (or use --plan-only)")
|
||||
if not output.endswith(".py"):
|
||||
_fail(f"output must be a .py file, got {output!r}")
|
||||
try:
|
||||
enforce_under_cwd_and_no_symlink(output, "output path")
|
||||
except (ValueError, OSError) as exc:
|
||||
_fail(str(exc))
|
||||
if os.path.exists(output) and not force:
|
||||
_fail(f"{output!r} already exists — pass --force to overwrite")
|
||||
# Validate the optional report path UP FRONT so a bad report path can never
|
||||
# cause us to destroy an already-written, already-accepted verifier later.
|
||||
if output_report:
|
||||
try:
|
||||
enforce_under_cwd_and_no_symlink(output_report, "report path")
|
||||
except (ValueError, OSError) as exc:
|
||||
_fail(str(exc))
|
||||
|
||||
# Write, then LOAD it back through the real reward-loader path (round-trip
|
||||
# validation) and calibrate the loaded callable.
|
||||
atomic_write_text(result.source, output)
|
||||
try:
|
||||
from soup_cli.trainer.rewards import load_reward_fn
|
||||
reward_fn = load_reward_fn(output)
|
||||
golds = rs.extract_golds(rows, field=field)
|
||||
negatives = rs.perturb_negatives(golds, result.kind)
|
||||
report = rs.calibrate(reward_fn, golds, negatives, kind=result.kind,
|
||||
min_discrimination=min_discrimination)
|
||||
except Exception as exc: # noqa: BLE001 — clean up the partial artifact
|
||||
_cleanup(output)
|
||||
_fail(f"calibration failed: {exc}")
|
||||
|
||||
# Write the diagnostic report (path already validated up front) for BOTH the
|
||||
# accepted and refused cases. Best-effort: a write failure here warns but must
|
||||
# not delete an otherwise-valid verifier.
|
||||
if output_report:
|
||||
try:
|
||||
from dataclasses import asdict
|
||||
atomic_write_text(json.dumps(asdict(report), indent=2), output_report)
|
||||
except OSError as exc:
|
||||
console.print(f"[yellow]Warning: could not write report: {escape(str(exc))}[/]")
|
||||
|
||||
if report.refused:
|
||||
_cleanup(output)
|
||||
console.print(Panel(
|
||||
escape(report.reason),
|
||||
title="[bold red]verifier refused (not emitted)[/]", border_style="red"))
|
||||
raise typer.Exit(2)
|
||||
|
||||
console.print(_render_report_panel(report, result.kind, output))
|
||||
raise typer.Exit(0)
|
||||
|
||||
|
||||
def _cleanup(path: str) -> None:
|
||||
"""Remove a just-written artifact; best-effort (never masks the real error)."""
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
|
@ -1107,10 +1107,38 @@ class TrainingConfig(BaseModel):
|
|||
reward_fn: Optional[str] = Field(
|
||||
default="accuracy",
|
||||
description=(
|
||||
"Reward function: 'accuracy', 'format', 'verifiable', "
|
||||
"or path to custom .py file"
|
||||
"Reward function: 'accuracy', 'format', 'verifiable', a path to a "
|
||||
"custom .py file, or a comma-separated ensemble of the above "
|
||||
"(e.g. 'accuracy,format') — the comma form is GRPO-only (v0.71.40)."
|
||||
),
|
||||
)
|
||||
@field_validator("reward_fn", mode="before")
|
||||
@classmethod
|
||||
def _validate_reward_fn_field(cls, value: Any) -> Optional[str]:
|
||||
"""v0.71.40 #311 — shape-only validation (containment enforced at load).
|
||||
|
||||
Accepts a comma-separated spec ("accuracy,format"); rejects null bytes,
|
||||
oversize, and empty comma segments so a stray comma fails loud rather
|
||||
than silently dropping a reward.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool) or not isinstance(value, str):
|
||||
raise ValueError(
|
||||
f"reward_fn must be a string, got {type(value).__name__}"
|
||||
)
|
||||
if "\x00" in value:
|
||||
raise ValueError("reward_fn must not contain null bytes")
|
||||
if len(value) > 512:
|
||||
raise ValueError("reward_fn must be <= 512 chars")
|
||||
if not value.strip():
|
||||
raise ValueError("reward_fn must not be blank")
|
||||
if any(not seg.strip() for seg in value.split(",")):
|
||||
raise ValueError(
|
||||
"reward_fn has an empty comma segment — remove the stray comma"
|
||||
)
|
||||
return value
|
||||
|
||||
# RLVR — verifiable reward domain (Part C of v0.25.0)
|
||||
verifiable_domain: Optional[Literal["math", "code", "json_schema"]] = Field(
|
||||
default=None,
|
||||
|
|
@ -2978,8 +3006,18 @@ class TrainingConfig(BaseModel):
|
|||
|
||||
@model_validator(mode="after")
|
||||
def _validate_verifiable_reward(self) -> "TrainingConfig":
|
||||
"""RLVR: reward_fn='verifiable' requires verifiable_domain."""
|
||||
if self.reward_fn == "verifiable" and self.verifiable_domain is None:
|
||||
"""RLVR: reward_fn='verifiable' requires verifiable_domain.
|
||||
|
||||
Comma-aware (v0.71.40 #311): ``"accuracy,verifiable"`` must fail at
|
||||
config-parse time exactly like the bare ``"verifiable"`` form, not only
|
||||
at trainer construction.
|
||||
"""
|
||||
segments = (
|
||||
[s.strip() for s in self.reward_fn.split(",")]
|
||||
if isinstance(self.reward_fn, str)
|
||||
else []
|
||||
)
|
||||
if "verifiable" in segments and self.verifiable_domain is None:
|
||||
raise ValueError(
|
||||
"reward_fn='verifiable' requires verifiable_domain "
|
||||
"(one of: math, code, json_schema)"
|
||||
|
|
@ -4662,6 +4700,25 @@ class SoupConfig(BaseModel):
|
|||
)
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_reward_fn_multi_compat(self) -> "SoupConfig":
|
||||
"""v0.71.40 #311 — a comma-separated reward_fn is GRPO-only.
|
||||
|
||||
Only ``trainer/grpo.py::_select_reward_fn`` resolves a multi-reward spec
|
||||
into a ``reward_funcs=[...]`` list; ``trainer/ppo.py`` (and every other
|
||||
consumer) calls the single-name loader, so a comma there would silently
|
||||
pass config validation and then crash at training start with
|
||||
``Unknown reward function``. Footgun-reject it up front (mirrors the
|
||||
online_dpo / asr / lisa task gates).
|
||||
"""
|
||||
rf = self.training.reward_fn
|
||||
if isinstance(rf, str) and "," in rf and self.task != "grpo":
|
||||
raise ValueError(
|
||||
f"a comma-separated training.reward_fn (an ensemble, {rf!r}) is "
|
||||
f"only supported for task='grpo'; got task={self.task!r}"
|
||||
)
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_asr_compat(self) -> "SoupConfig":
|
||||
"""v0.71.32 — ASR (Whisper) gate.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"""Calculator tool-use rollout env — v0.71.30.
|
||||
|
||||
Generates a deterministic set of single-operation arithmetic problems as GRPO
|
||||
prompt+answer rows. Score with ``reward_fn='math'`` (or ``'accuracy'``).
|
||||
prompt+answer rows. Score with ``reward_fn: verifiable`` +
|
||||
``verifiable_domain: math`` (or ``reward_fn: accuracy``).
|
||||
|
||||
Usage: ``training.rollout_backend='openenv'`` +
|
||||
``training.rollout_func='soup_cli.envs.calculator:rollout'``.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Generates deterministic single-shot number-deduction puzzles (constraints that
|
||||
pin a unique integer) as GRPO prompt+answer rows. Score with
|
||||
``reward_fn='math'`` (or ``'accuracy'``).
|
||||
``reward_fn: verifiable`` + ``verifiable_domain: math`` (or ``reward_fn: accuracy``).
|
||||
|
||||
Honesty: this is single-shot *deduction*, not interactive guess-and-feedback —
|
||||
the live openenv contract does not pass the model, so a true guessing loop is
|
||||
|
|
|
|||
|
|
@ -129,7 +129,9 @@ def _read_attr(obj: Any, name: str) -> Any:
|
|||
return getattr(obj, name, None)
|
||||
|
||||
|
||||
def _select_reward_fn(tcfg: TrainingConfig, device: str, trust_remote_code: bool) -> Any:
|
||||
def _select_reward_fn(
|
||||
tcfg: TrainingConfig, device: str, trust_remote_code: bool
|
||||
) -> "Any": # Callable | list[Callable] (a single reward or a comma-split ensemble)
|
||||
"""Choose the GRPO reward function (v0.71.30).
|
||||
|
||||
When ``tcfg.prm_reward`` is set, a trained Soup PRM scores each completion's
|
||||
|
|
@ -142,9 +144,13 @@ def _select_reward_fn(tcfg: TrainingConfig, device: str, trust_remote_code: bool
|
|||
from soup_cli.utils.prm_reward import build_prm_reward_fn
|
||||
|
||||
return build_prm_reward_fn(tcfg, device, trust_remote_code)
|
||||
from soup_cli.trainer.rewards import load_reward_fn
|
||||
# v0.71.40 #311 — ``reward_fn`` may be comma-separated ("accuracy,format").
|
||||
# Return a single callable for one reward (back-compat) or a list for several
|
||||
# (TRL's reward_funcs=[...] + the rm_ensemble detector both accept the list).
|
||||
from soup_cli.trainer.rewards import load_reward_fns
|
||||
|
||||
return load_reward_fn(tcfg.reward_fn, verifiable_domain=tcfg.verifiable_domain)
|
||||
fns = load_reward_fns(tcfg.reward_fn, verifiable_domain=tcfg.verifiable_domain)
|
||||
return fns[0] if len(fns) == 1 else fns
|
||||
|
||||
|
||||
class GRPOTrainerWrapper:
|
||||
|
|
|
|||
|
|
@ -557,3 +557,44 @@ def load_reward_fn(
|
|||
f"Options: {', '.join(BUILTIN_REWARDS.keys())}, 'verifiable', "
|
||||
f"or path to a .py file"
|
||||
)
|
||||
|
||||
|
||||
def load_reward_fns(
|
||||
reward_fn_spec: str, verifiable_domain: "str | None" = None,
|
||||
) -> list[Callable]:
|
||||
"""Load one OR MORE reward functions from a comma-separated spec (v0.71.40 #311).
|
||||
|
||||
``reward_fn`` may name several rewards, e.g. ``"accuracy,format"`` — each is
|
||||
resolved via :func:`load_reward_fn` and returned as a list, which TRL's
|
||||
``GRPOTrainer(reward_funcs=[...])`` accepts and which the ``rm_ensemble``
|
||||
reward-hack detector (needs >= 2 reward fns) unlocks. A single name still
|
||||
returns a one-element list, so callers can always treat the result uniformly.
|
||||
|
||||
Splitting is on ``,``; blank / empty segments raise (``"accuracy,"`` is a typo,
|
||||
not "accuracy plus nothing"), and duplicate segments raise (they would collide
|
||||
by ``__name__`` in the ``rm_ensemble`` capture buffer, silently shrinking the
|
||||
ensemble). A ``.py`` path containing a literal comma is not supported — rename
|
||||
the file.
|
||||
"""
|
||||
if not isinstance(reward_fn_spec, str):
|
||||
raise ValueError(
|
||||
"reward_fn must be a string (a name, a .py path, or a comma-separated "
|
||||
f"list), got {type(reward_fn_spec).__name__}"
|
||||
)
|
||||
if not reward_fn_spec.strip():
|
||||
raise ValueError("reward_fn must not be blank")
|
||||
segments = [seg.strip() for seg in reward_fn_spec.split(",")]
|
||||
if any(not seg for seg in segments):
|
||||
raise ValueError(
|
||||
f"reward_fn {reward_fn_spec!r} has an empty comma segment — "
|
||||
"remove the stray comma"
|
||||
)
|
||||
seen: set[str] = set()
|
||||
for seg in segments:
|
||||
if seg in seen:
|
||||
raise ValueError(
|
||||
f"reward_fn {reward_fn_spec!r} lists {seg!r} twice — "
|
||||
"duplicate rewards collide in the ensemble"
|
||||
)
|
||||
seen.add(seg)
|
||||
return [load_reward_fn(seg, verifiable_domain=verifiable_domain) for seg in segments]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,716 @@
|
|||
"""Reward-verifier synthesis — `soup reward synth` (v0.71.40).
|
||||
|
||||
Auto-generate a *deterministic* reward verifier from a dataset of reference (gold)
|
||||
outputs. The emitted artifact is a readable, editable, committable ``.py`` that rides
|
||||
:func:`soup_cli.trainer.rewards.load_reward_fn`'s existing ``.py`` path — NO new
|
||||
trusted-exec hot path.
|
||||
|
||||
Pipeline (all pure, NO top-level torch):
|
||||
detect_kind → induce_* → render_verifier_py → (load) → perturb_negatives + calibrate.
|
||||
|
||||
v1 verifier families (deterministic only): ``numeric`` · ``json_schema`` · ``regex`` ·
|
||||
``tool_call``. Judge-based and LLM-codegen are explicitly out of scope.
|
||||
|
||||
The calibration report is the moat: a synthesized verifier is only emitted if it
|
||||
accepts its own references AND rejects auto-perturbed negatives — a degenerate
|
||||
always-1.0 verifier is refused.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
KINDS: tuple[str, ...] = ("numeric", "json_schema", "regex", "tool_call")
|
||||
# Auto-detect precedence: most-specific shape wins.
|
||||
_DETECT_ORDER: tuple[str, ...] = ("tool_call", "json_schema", "numeric", "regex")
|
||||
|
||||
# Fraction of golds that must fit a kind for auto-detection / regex confidence.
|
||||
_MIN_CONFIDENCE = 0.9
|
||||
DEFAULT_NUMERIC_TOLERANCE = 1e-6
|
||||
DEFAULT_MIN_DISCRIMINATION = 0.5
|
||||
# A verifier must accept at least this fraction of its OWN references, else the
|
||||
# induced spec plainly does not fit the golds (mirrors _MIN_CONFIDENCE).
|
||||
_MIN_SELF_ACCEPT = 0.9
|
||||
_MAX_ROWS = 1_000_000
|
||||
|
||||
_NUMBER_RE = re.compile(r"[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?")
|
||||
_WHOLE_NUMBER_RE = re.compile(r"^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Frozen result types
|
||||
# ---------------------------------------------------------------------------
|
||||
@dataclass(frozen=True)
|
||||
class NumericSpec:
|
||||
is_float: bool
|
||||
tolerance: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolCallSpec:
|
||||
"""Per-tool argument signatures.
|
||||
|
||||
``tools`` maps each tool name to ``{"required": (...), "allowed": (...)}`` —
|
||||
``required`` = keys present in EVERY observed call of that name (intersection),
|
||||
``allowed`` = union of keys seen for that name. The verifier accepts a call iff
|
||||
``required <= call_args <= allowed`` for the matching name, so a call cannot
|
||||
borrow another tool's argument keys or omit required ones.
|
||||
"""
|
||||
|
||||
tools: dict # name -> {"required": tuple[str, ...], "allowed": tuple[str, ...]}
|
||||
|
||||
@property
|
||||
def names(self) -> tuple[str, ...]:
|
||||
return tuple(sorted(self.tools))
|
||||
|
||||
@property
|
||||
def arg_keys(self) -> tuple[str, ...]:
|
||||
keys: set[str] = set()
|
||||
for sig in self.tools.values():
|
||||
keys.update(sig.get("allowed", ()))
|
||||
return tuple(sorted(keys))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CalibrationReport:
|
||||
kind: str
|
||||
positives: int
|
||||
negatives: int
|
||||
pos_accept: float
|
||||
neg_accept: float
|
||||
discrimination: float
|
||||
precision: float
|
||||
recall: float
|
||||
refused: bool
|
||||
reason: str
|
||||
|
||||
|
||||
SpecType = Union[NumericSpec, dict, ToolCallSpec, str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SynthResult:
|
||||
kind: str
|
||||
source: str
|
||||
spec: SpecType # NumericSpec | dict | ToolCallSpec | str (pattern)
|
||||
report: Optional[CalibrationReport] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gold extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
def _row_gold(row: Mapping[str, Any], field: str) -> Optional[str]:
|
||||
if not isinstance(row, Mapping):
|
||||
return None
|
||||
if field in row and row[field] is not None:
|
||||
value = row[field]
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, (dict, list)):
|
||||
return json.dumps(value)
|
||||
return str(value) # numbers, bools -> their str form
|
||||
messages = row.get("messages")
|
||||
if isinstance(messages, list):
|
||||
for msg in reversed(messages):
|
||||
if isinstance(msg, Mapping) and msg.get("role") == "assistant":
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
return None
|
||||
|
||||
|
||||
def extract_golds(rows: Sequence[Mapping[str, Any]], *, field: str = "answer") -> list[str]:
|
||||
"""Pull the gold output from each row (``field`` else chat last-assistant).
|
||||
|
||||
Raises ``TypeError`` if ``rows`` is not a sequence of mappings; ``ValueError``
|
||||
if no row yields a gold.
|
||||
"""
|
||||
if not isinstance(rows, Sequence) or isinstance(rows, (str, bytes)):
|
||||
raise TypeError("rows must be a sequence of mapping rows")
|
||||
if len(rows) > _MAX_ROWS:
|
||||
raise ValueError(f"rows exceed cap of {_MAX_ROWS}")
|
||||
golds: list[str] = []
|
||||
for row in rows:
|
||||
gold = _row_gold(row, field)
|
||||
if gold is not None:
|
||||
golds.append(gold)
|
||||
if not golds:
|
||||
raise ValueError(
|
||||
f"no gold outputs found (field={field!r}; rows need that key or a "
|
||||
"chat 'messages' list with an assistant turn)"
|
||||
)
|
||||
return golds
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kind primitives
|
||||
# ---------------------------------------------------------------------------
|
||||
def _is_number(text: str) -> bool:
|
||||
return bool(_WHOLE_NUMBER_RE.match(text.strip()))
|
||||
|
||||
|
||||
def _json_or_none(text: str) -> Any:
|
||||
try:
|
||||
return json.loads(text.strip())
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _is_tool_call(obj: Any) -> bool:
|
||||
return (
|
||||
isinstance(obj, dict)
|
||||
and isinstance(obj.get("name"), str)
|
||||
and isinstance(obj.get("arguments"), dict)
|
||||
)
|
||||
|
||||
|
||||
def _fraction(golds: Sequence[str], predicate: Callable[[str], bool]) -> float:
|
||||
if not golds:
|
||||
return 0.0
|
||||
return sum(1 for g in golds if predicate(g)) / len(golds)
|
||||
|
||||
|
||||
def detect_kind(golds: Sequence[str]) -> Optional[str]:
|
||||
"""Infer the verifier family from the golds, or ``None`` if un-inferrable.
|
||||
|
||||
Precedence: tool_call → json_schema → numeric → regex.
|
||||
"""
|
||||
if not golds:
|
||||
return None
|
||||
for kind in _DETECT_ORDER:
|
||||
if kind == "tool_call":
|
||||
frac = _fraction(golds, lambda g: _is_tool_call(_json_or_none(g)))
|
||||
elif kind == "json_schema":
|
||||
frac = _fraction(
|
||||
golds, lambda g: isinstance(_json_or_none(g), (dict, list))
|
||||
)
|
||||
elif kind == "numeric":
|
||||
frac = _fraction(golds, _is_number)
|
||||
else: # regex
|
||||
frac = 1.0 if induce_regex(golds) is not None else 0.0
|
||||
if frac >= _MIN_CONFIDENCE:
|
||||
return kind
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inducers
|
||||
# ---------------------------------------------------------------------------
|
||||
def induce_numeric(
|
||||
golds: Sequence[str], *, tolerance: Optional[float] = None
|
||||
) -> NumericSpec:
|
||||
if _fraction(golds, _is_number) < _MIN_CONFIDENCE:
|
||||
raise ValueError(
|
||||
"references are not numeric — cannot induce a numeric verifier "
|
||||
"(each gold must be a bare number); pass a different --kind"
|
||||
)
|
||||
is_float = any(("." in g) or ("e" in g.lower()) for g in golds)
|
||||
if tolerance is None:
|
||||
tol = DEFAULT_NUMERIC_TOLERANCE if is_float else 0.0
|
||||
else:
|
||||
tol = float(tolerance)
|
||||
if not math.isfinite(tol) or tol < 0:
|
||||
raise ValueError(f"tolerance must be a finite, non-negative number, got {tol!r}")
|
||||
return NumericSpec(is_float=is_float, tolerance=tol)
|
||||
|
||||
|
||||
def _json_type(value: Any) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "boolean"
|
||||
if isinstance(value, int):
|
||||
return "integer"
|
||||
if isinstance(value, float):
|
||||
return "number"
|
||||
if isinstance(value, str):
|
||||
return "string"
|
||||
if isinstance(value, list):
|
||||
return "array"
|
||||
if isinstance(value, dict):
|
||||
return "object"
|
||||
return "null"
|
||||
|
||||
|
||||
def induce_json_schema(golds: Sequence[str]) -> dict:
|
||||
"""Induce a top-level JSON schema (keys + types + required) from the golds.
|
||||
|
||||
Refuses (``ValueError``) rather than emitting a wrong schema when the golds
|
||||
mix top-level shapes (some object, some array) or parse to no JSON container
|
||||
at all — mirroring :func:`induce_regex`'s refuse-don't-guess policy. A key
|
||||
whose type is inconsistent across rows is left un-typed (presence only), so
|
||||
an over-narrow ``type`` can never reject a valid completion.
|
||||
"""
|
||||
parsed = [_json_or_none(g) for g in golds]
|
||||
containers = [o for o in parsed if isinstance(o, (dict, list))]
|
||||
if not containers:
|
||||
raise ValueError(
|
||||
"no JSON object/array references found — cannot induce a json_schema"
|
||||
)
|
||||
dicts = [o for o in containers if isinstance(o, dict)]
|
||||
lists = [o for o in containers if isinstance(o, list)]
|
||||
if dicts and lists:
|
||||
raise ValueError(
|
||||
"references mix JSON objects and arrays — split them or pass an "
|
||||
"explicit --kind; refusing to induce an ambiguous schema"
|
||||
)
|
||||
if lists:
|
||||
return {"type": "array"}
|
||||
key_types: dict[str, set[str]] = {}
|
||||
key_counts: dict[str, int] = {}
|
||||
for obj in dicts:
|
||||
for key, value in obj.items():
|
||||
key_counts[key] = key_counts.get(key, 0) + 1
|
||||
key_types.setdefault(key, set()).add(_json_type(value))
|
||||
properties: dict[str, dict] = {}
|
||||
for key, types in key_types.items():
|
||||
# Only constrain the type when EVERY row agreed on it; else presence-only.
|
||||
properties[key] = {"type": next(iter(types))} if len(types) == 1 else {}
|
||||
required = sorted(k for k, c in key_counts.items() if c == len(dicts))
|
||||
return {"type": "object", "properties": properties, "required": required}
|
||||
|
||||
|
||||
def induce_tool_call(golds: Sequence[str]) -> ToolCallSpec:
|
||||
"""Induce per-tool argument signatures (name -> required/allowed keys)."""
|
||||
per_name: dict[str, list[set]] = {}
|
||||
for gold in golds:
|
||||
obj = _json_or_none(gold)
|
||||
if _is_tool_call(obj):
|
||||
per_name.setdefault(obj["name"], []).append(
|
||||
{str(k) for k in obj["arguments"].keys()}
|
||||
)
|
||||
if not per_name:
|
||||
raise ValueError(
|
||||
"no tool-call references found (each needs a JSON object with string "
|
||||
"'name' + object 'arguments') — cannot induce a tool_call verifier"
|
||||
)
|
||||
tools: dict = {}
|
||||
for name, calls in per_name.items():
|
||||
allowed = set().union(*calls)
|
||||
required = set.intersection(*calls)
|
||||
tools[name] = {
|
||||
"required": tuple(sorted(required)),
|
||||
"allowed": tuple(sorted(allowed)),
|
||||
}
|
||||
return ToolCallSpec(tools=tools)
|
||||
|
||||
|
||||
def _char_class(chars: set[str]) -> Optional[str]:
|
||||
if len(chars) == 1:
|
||||
return re.escape(next(iter(chars)))
|
||||
if all(c.isdigit() for c in chars):
|
||||
return r"\d"
|
||||
if all(c.isalpha() for c in chars):
|
||||
return r"[A-Za-z]"
|
||||
if all(c.isalnum() for c in chars):
|
||||
return r"[A-Za-z0-9]"
|
||||
return None
|
||||
|
||||
|
||||
def induce_regex(golds: Sequence[str]) -> Optional[str]:
|
||||
"""Conservative positional pattern induction over equal-length golds.
|
||||
|
||||
Returns ``None`` unless the golds share a length and every position
|
||||
generalizes to a confident char class (the safe direction: refuse rather
|
||||
than emit a loose pattern).
|
||||
"""
|
||||
golds = [g.strip() for g in golds if g.strip()]
|
||||
if len(golds) < 2:
|
||||
return None
|
||||
length = len(golds[0])
|
||||
if any(len(g) != length for g in golds) or length == 0:
|
||||
return None
|
||||
parts: list[str] = []
|
||||
for pos in range(length):
|
||||
cls = _char_class({g[pos] for g in golds})
|
||||
if cls is None:
|
||||
return None
|
||||
parts.append(cls)
|
||||
# By construction every gold matches (each position's class is a superset of
|
||||
# the observed chars), so no post-hoc confidence check is needed.
|
||||
return "^" + "".join(parts) + "$"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
_HEADER = '''"""Deterministic reward verifier — generated by `soup reward synth` (v0.71.40).
|
||||
|
||||
Kind: {kind}. Induced from {n_refs} reference output(s). EDIT FREELY — this is a
|
||||
self-contained, committable reward function. Wire it via:
|
||||
|
||||
training:
|
||||
reward_fn: {rel_hint}
|
||||
|
||||
Signature: reward_fn(completions, **kwargs) -> list[float]; the per-row gold answer
|
||||
arrives as kwargs["answer"] (a list aligned with completions), matching TRL/GRPO.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
|
||||
def _last_content(completion):
|
||||
return completion[-1]["content"] if completion else ""
|
||||
'''
|
||||
|
||||
_NUMERIC_BODY = '''
|
||||
_NUMBER_RE = re.compile(r"[+-]?(?:\\d+\\.?\\d*|\\.\\d+)(?:[eE][+-]?\\d+)?")
|
||||
_BOXED_RE = re.compile(r"\\\\boxed\\{([^}]*)\\}")
|
||||
|
||||
|
||||
def _extract_number(text):
|
||||
# Prefer an explicit answer marker (\\boxed{...} then ####) over "last number
|
||||
# in the whole string", so trailing chit-chat numbers do not hijack the score.
|
||||
boxed = _BOXED_RE.search(text)
|
||||
if boxed:
|
||||
nums = _NUMBER_RE.findall(boxed.group(1))
|
||||
if nums:
|
||||
return nums[-1]
|
||||
if "####" in text:
|
||||
nums = _NUMBER_RE.findall(text.split("####")[-1])
|
||||
if nums:
|
||||
return nums[-1]
|
||||
nums = _NUMBER_RE.findall(text)
|
||||
return nums[-1] if nums else None
|
||||
|
||||
|
||||
def _numbers_match(pred, gold, tol):
|
||||
if pred is None:
|
||||
return False
|
||||
try:
|
||||
if tol == 0:
|
||||
# Exact: integer compare first so large ints dodge float rounding.
|
||||
try:
|
||||
return int(pred) == int(gold)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return abs(float(pred) - float(gold)) <= tol
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def reward_fn(completions, **kwargs):
|
||||
answers = kwargs.get("answer", [])
|
||||
out = []
|
||||
for completion, expected in zip(completions, answers):
|
||||
predicted = _extract_number(_last_content(completion))
|
||||
gold = str(expected).strip()
|
||||
out.append(1.0 if _numbers_match(predicted, gold, _TOLERANCE) else 0.0)
|
||||
return out
|
||||
'''
|
||||
|
||||
_JSON_SCHEMA_BODY = '''
|
||||
|
||||
def _json_type(value):
|
||||
if isinstance(value, bool):
|
||||
return "boolean"
|
||||
if isinstance(value, int):
|
||||
return "integer"
|
||||
if isinstance(value, float):
|
||||
return "number"
|
||||
if isinstance(value, str):
|
||||
return "string"
|
||||
if isinstance(value, list):
|
||||
return "array"
|
||||
if isinstance(value, dict):
|
||||
return "object"
|
||||
return "null"
|
||||
|
||||
|
||||
def _matches_schema(data):
|
||||
expected = _SCHEMA.get("type")
|
||||
if expected == "array":
|
||||
return isinstance(data, list)
|
||||
if expected == "object":
|
||||
if not isinstance(data, dict):
|
||||
return False
|
||||
for key in _SCHEMA.get("required", []):
|
||||
if key not in data:
|
||||
return False
|
||||
props = _SCHEMA.get("properties", {})
|
||||
_NUMERIC = {"integer", "number"}
|
||||
for key, spec in props.items():
|
||||
if key in data:
|
||||
want = spec.get("type")
|
||||
got = _json_type(data[key])
|
||||
if not want:
|
||||
continue # presence-only (type varied across references)
|
||||
if want in _NUMERIC and got in _NUMERIC:
|
||||
continue # int/float interchangeable
|
||||
if want != got:
|
||||
return False
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
def reward_fn(completions, **kwargs):
|
||||
out = []
|
||||
for completion in completions:
|
||||
content = _last_content(completion)
|
||||
fenced = re.search(r"```(?:json)?\\s*(.*?)```", content, re.DOTALL)
|
||||
if fenced:
|
||||
content = fenced.group(1)
|
||||
try:
|
||||
data = json.loads(content.strip())
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
out.append(0.0)
|
||||
continue
|
||||
out.append(1.0 if _matches_schema(data) else 0.0)
|
||||
return out
|
||||
'''
|
||||
|
||||
_REGEX_BODY = '''
|
||||
|
||||
def reward_fn(completions, **kwargs):
|
||||
out = []
|
||||
for completion in completions:
|
||||
content = _last_content(completion).strip()
|
||||
out.append(1.0 if _PATTERN.fullmatch(content) else 0.0)
|
||||
return out
|
||||
'''
|
||||
|
||||
_TOOL_CALL_BODY = '''
|
||||
|
||||
def reward_fn(completions, **kwargs):
|
||||
out = []
|
||||
for completion in completions:
|
||||
content = _last_content(completion)
|
||||
fenced = re.search(r"```(?:json)?\\s*(.*?)```", content, re.DOTALL)
|
||||
if fenced:
|
||||
content = fenced.group(1)
|
||||
try:
|
||||
data = json.loads(content.strip())
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
out.append(0.0)
|
||||
continue
|
||||
if not (isinstance(data, dict) and isinstance(data.get("arguments"), dict)):
|
||||
out.append(0.0)
|
||||
continue
|
||||
sig = _TOOLS.get(data.get("name"))
|
||||
if sig is None:
|
||||
out.append(0.0)
|
||||
continue
|
||||
keys = set(data["arguments"].keys())
|
||||
required = set(sig["required"])
|
||||
allowed = set(sig["allowed"])
|
||||
out.append(1.0 if required <= keys <= allowed else 0.0)
|
||||
return out
|
||||
'''
|
||||
|
||||
|
||||
def _safe_hint(value: object) -> str:
|
||||
"""One-line, quote/backslash-free display string for the header docstring.
|
||||
|
||||
``rel_hint`` (the ``-o`` path) is the only value ``.format``'d RAW into the
|
||||
generated source (everything else is baked via ``repr``). The file is later
|
||||
exec'd, so a path containing ``\"\"\"`` + code must not be able to break out
|
||||
of the docstring — strip control chars, quotes and backslashes and cap it.
|
||||
"""
|
||||
text = str(value)
|
||||
text = "".join(ch for ch in text if ch >= " " and ch not in '"\\')
|
||||
return text[:200] or "reward.py"
|
||||
|
||||
|
||||
def render_verifier_py(kind: str, spec: Any, *, meta: Mapping[str, Any]) -> str:
|
||||
"""Render a self-contained reward-verifier ``.py`` source string.
|
||||
|
||||
The induced spec is baked in as a top-level constant via ``repr`` (so the
|
||||
code bodies stay literal — no ``str.format`` over code containing braces).
|
||||
"""
|
||||
n_refs = int(meta.get("n_refs", 0))
|
||||
rel_hint = _safe_hint(meta.get("rel_hint", "reward.py"))
|
||||
header = _HEADER.format(kind=kind, n_refs=n_refs, rel_hint=rel_hint)
|
||||
if kind == "numeric":
|
||||
if not isinstance(spec, NumericSpec):
|
||||
raise TypeError("numeric verifier requires a NumericSpec")
|
||||
const = f"\n_TOLERANCE = {spec.tolerance!r}\n"
|
||||
body = const + _NUMERIC_BODY
|
||||
elif kind == "json_schema":
|
||||
if not isinstance(spec, dict):
|
||||
raise TypeError("json_schema verifier requires a schema dict")
|
||||
const = f"\n_SCHEMA = {spec!r}\n"
|
||||
body = const + _JSON_SCHEMA_BODY
|
||||
elif kind == "regex":
|
||||
if not isinstance(spec, str):
|
||||
raise TypeError("regex verifier requires a pattern string")
|
||||
const = f"\n_PATTERN = re.compile({spec!r})\n"
|
||||
body = const + _REGEX_BODY
|
||||
elif kind == "tool_call":
|
||||
if not isinstance(spec, ToolCallSpec):
|
||||
raise TypeError("tool_call verifier requires a ToolCallSpec")
|
||||
const = f"\n_TOOLS = {spec.tools!r}\n"
|
||||
body = const + _TOOL_CALL_BODY
|
||||
else:
|
||||
raise ValueError(f"unknown verifier kind: {kind!r} (options: {', '.join(KINDS)})")
|
||||
return header + body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Negatives + calibration
|
||||
# ---------------------------------------------------------------------------
|
||||
def perturb_negatives(golds: Sequence[str], kind: str) -> list[str]:
|
||||
"""Deterministically corrupt golds into known-bad outputs for calibration."""
|
||||
negatives: list[str] = []
|
||||
for gold in golds:
|
||||
gold = gold.strip()
|
||||
if kind == "numeric":
|
||||
num = _NUMBER_RE.findall(gold)
|
||||
if num:
|
||||
try:
|
||||
negatives.append(str(float(num[-1]) + 9999.0))
|
||||
except ValueError:
|
||||
negatives.append("not-a-number")
|
||||
else:
|
||||
negatives.append("not-a-number")
|
||||
elif kind == "json_schema":
|
||||
negatives.append("this is definitely not json {")
|
||||
elif kind == "tool_call":
|
||||
negatives.append("this is definitely not json {")
|
||||
obj = _json_or_none(gold)
|
||||
if _is_tool_call(obj):
|
||||
# Valid JSON, WRONG tool name — a "just parse JSON" verifier would
|
||||
# accept this; a name-bound verifier must reject it.
|
||||
negatives.append(
|
||||
json.dumps({"name": "__nonexistent_tool__",
|
||||
"arguments": obj["arguments"]})
|
||||
)
|
||||
# Valid JSON, right name, a foreign arg key outside `allowed`.
|
||||
negatives.append(
|
||||
json.dumps({"name": obj["name"],
|
||||
"arguments": {"__foreign_arg__": 1}})
|
||||
)
|
||||
elif kind == "regex":
|
||||
negatives.append(gold + "ZZZ_definitely_wrong")
|
||||
else:
|
||||
raise ValueError(f"unknown kind: {kind!r} (options: {', '.join(KINDS)})")
|
||||
# Always include a couple of universal degenerate cases.
|
||||
negatives.extend(["", "the model rambled without answering at all"])
|
||||
return negatives
|
||||
|
||||
|
||||
def calibrate(
|
||||
reward_fn: Callable[..., list[float]],
|
||||
positives: Sequence[str],
|
||||
negatives: Sequence[str],
|
||||
*,
|
||||
kind: str = "",
|
||||
min_discrimination: float = DEFAULT_MIN_DISCRIMINATION,
|
||||
) -> CalibrationReport:
|
||||
"""Run ``reward_fn`` over positives (should accept) + negatives (should reject).
|
||||
|
||||
``discrimination = pos_accept - neg_accept``. Refuses when: (a) the verifier
|
||||
fails to accept ``>= _MIN_SELF_ACCEPT`` of its own references (the induced
|
||||
spec does not fit the golds), (b) ``discrimination <= 0`` (no separation at
|
||||
all — a hard floor the user cannot disable via ``--min-discrimination 0``),
|
||||
or (c) ``discrimination < min_discrimination``.
|
||||
"""
|
||||
def _accept_rate(
|
||||
items: Sequence[str], answers: Sequence[str]
|
||||
) -> tuple[float, int]:
|
||||
if not items:
|
||||
return 0.0, 0
|
||||
completions = [[{"role": "assistant", "content": s}] for s in items]
|
||||
scores = reward_fn(completions, answer=list(answers))
|
||||
accepted = sum(1 for s in scores if float(s) >= 0.5)
|
||||
return accepted / len(items), accepted
|
||||
|
||||
# Positives are scored as content==gold. Negatives are corrupted completions
|
||||
# but must be scored against the REAL gold (cycled), else a gold-comparison
|
||||
# verifier would accept a corrupted answer matched against itself.
|
||||
pos_accept, tp = _accept_rate(positives, positives)
|
||||
if positives:
|
||||
neg_answers = [positives[j % len(positives)] for j in range(len(negatives))]
|
||||
else:
|
||||
neg_answers = list(negatives)
|
||||
neg_accept, fp = _accept_rate(negatives, neg_answers)
|
||||
discrimination = pos_accept - neg_accept
|
||||
precision = (tp / (tp + fp)) if (tp + fp) > 0 else 0.0
|
||||
recall = pos_accept
|
||||
refused = False
|
||||
reason = "verifier discriminates references from perturbed negatives"
|
||||
if pos_accept < _MIN_SELF_ACCEPT:
|
||||
refused = True
|
||||
reason = (
|
||||
f"verifier rejects its own references (accepts only "
|
||||
f"{pos_accept:.0%}, need >= {_MIN_SELF_ACCEPT:.0%}) — the induced "
|
||||
"spec does not fit the golds"
|
||||
)
|
||||
elif discrimination <= 0.0:
|
||||
refused = True
|
||||
reason = (
|
||||
f"verifier accepts perturbed negatives as readily as references "
|
||||
f"(accepts {pos_accept:.0%} of refs and {neg_accept:.0%} of negatives, "
|
||||
"discrimination <= 0) — degenerate, refusing regardless of "
|
||||
"--min-discrimination"
|
||||
)
|
||||
elif discrimination < min_discrimination:
|
||||
refused = True
|
||||
reason = (
|
||||
f"discrimination {discrimination:.2f} < required {min_discrimination:.2f} "
|
||||
f"(accepts {pos_accept:.0%} of references but also {neg_accept:.0%} of "
|
||||
"perturbed negatives) — the verifier is too permissive to reward-train on"
|
||||
)
|
||||
return CalibrationReport(
|
||||
kind=kind,
|
||||
positives=len(positives),
|
||||
negatives=len(negatives),
|
||||
pos_accept=pos_accept,
|
||||
neg_accept=neg_accept,
|
||||
discrimination=discrimination,
|
||||
precision=precision,
|
||||
recall=recall,
|
||||
refused=refused,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Orchestration (pure — the CLI does write→load→calibrate)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _induce(kind: str, golds: Sequence[str], tolerance: Optional[float]) -> Any:
|
||||
if kind == "numeric":
|
||||
return induce_numeric(golds, tolerance=tolerance)
|
||||
if kind == "json_schema":
|
||||
return induce_json_schema(golds)
|
||||
if kind == "tool_call":
|
||||
return induce_tool_call(golds)
|
||||
if kind == "regex":
|
||||
pattern = induce_regex(golds)
|
||||
if pattern is None:
|
||||
raise ValueError(
|
||||
"could not induce a confident regex from the references "
|
||||
"(golds must share a length + positional char classes)"
|
||||
)
|
||||
return pattern
|
||||
raise ValueError(f"unknown kind: {kind!r} (options: {', '.join(KINDS)})")
|
||||
|
||||
|
||||
def synthesize(
|
||||
rows: Sequence[Mapping[str, Any]],
|
||||
*,
|
||||
field: str = "answer",
|
||||
kind: str = "auto",
|
||||
tolerance: Optional[float] = None,
|
||||
rel_hint: str = "reward.py",
|
||||
) -> SynthResult:
|
||||
"""Detect + induce + render (no calibration — the CLI loads then calibrates)."""
|
||||
if kind != "auto" and kind not in KINDS:
|
||||
raise ValueError(f"unknown kind: {kind!r} (options: auto, {', '.join(KINDS)})")
|
||||
golds = extract_golds(rows, field=field)
|
||||
resolved = kind
|
||||
if kind == "auto":
|
||||
resolved = detect_kind(golds)
|
||||
if resolved is None:
|
||||
raise ValueError(
|
||||
"could not auto-detect a deterministic verifier kind from the "
|
||||
"references — pass --kind numeric|json_schema|regex|tool_call"
|
||||
)
|
||||
spec = _induce(resolved, golds, tolerance)
|
||||
source = render_verifier_py(
|
||||
resolved, spec, meta={"n_refs": len(golds), "rel_hint": rel_hint}
|
||||
)
|
||||
return SynthResult(kind=resolved, source=source, spec=spec, report=None)
|
||||
|
|
@ -0,0 +1,855 @@
|
|||
"""Tests for v0.71.40 — `soup reward synth` + comma-split reward loader (#311).
|
||||
|
||||
Covers:
|
||||
- utils/reward_synth.py: extract_golds / detect_kind / inducers / render / calibrate / synthesize.
|
||||
- commands/reward.py: `soup reward synth` CLI (happy / refuse / plan-only / guards).
|
||||
- trainer/rewards.load_reward_fns + grpo._select_reward_fn comma-split (#311).
|
||||
- config reward_fn validator; envs docstring fix.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from soup_cli.utils import reward_synth as rs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# extract_golds
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestExtractGolds:
|
||||
def test_answer_field(self):
|
||||
rows = [{"prompt": "q", "answer": "5"}, {"prompt": "q2", "answer": 7}]
|
||||
assert rs.extract_golds(rows, field="answer") == ["5", "7"]
|
||||
|
||||
def test_chat_last_assistant(self):
|
||||
rows = [
|
||||
{"messages": [{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "gold-out"}]}
|
||||
]
|
||||
assert rs.extract_golds(rows, field="answer") == ["gold-out"]
|
||||
|
||||
def test_dict_gold_is_json_serialised(self):
|
||||
rows = [{"answer": {"k": 1}}]
|
||||
assert rs.extract_golds(rows, field="answer") == ['{"k": 1}']
|
||||
|
||||
def test_no_resolvable_gold_raises(self):
|
||||
with pytest.raises(ValueError, match="no gold"):
|
||||
rs.extract_golds([{"prompt": "q"}], field="answer")
|
||||
|
||||
def test_rows_must_be_sequence(self):
|
||||
with pytest.raises(TypeError):
|
||||
rs.extract_golds("not-a-list", field="answer")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# detect_kind
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestDetectKind:
|
||||
def test_numeric(self):
|
||||
assert rs.detect_kind(["5", "12", "-3"]) == "numeric"
|
||||
|
||||
def test_json_schema(self):
|
||||
assert rs.detect_kind(['{"a": 1}', '{"a": 2, "b": 3}']) == "json_schema"
|
||||
|
||||
def test_tool_call_precedes_json_schema(self):
|
||||
golds = ['{"name": "search", "arguments": {"q": "x"}}',
|
||||
'{"name": "lookup", "arguments": {"id": 3}}']
|
||||
assert rs.detect_kind(golds) == "tool_call"
|
||||
|
||||
def test_regex_last_resort(self):
|
||||
# Same-length structured strings, not numbers, not JSON.
|
||||
assert rs.detect_kind(["2031-01-02", "1999-12-31"]) == "regex"
|
||||
|
||||
def test_uninferrable_returns_none(self):
|
||||
assert rs.detect_kind(["hello world", "a completely different sentence here"]) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# inducers
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestInduceNumeric:
|
||||
def test_int(self):
|
||||
spec = rs.induce_numeric(["1", "2", "3"])
|
||||
assert spec.is_float is False and spec.tolerance == 0.0
|
||||
|
||||
def test_float(self):
|
||||
spec = rs.induce_numeric(["1.5", "2"])
|
||||
assert spec.is_float is True and spec.tolerance == rs.DEFAULT_NUMERIC_TOLERANCE
|
||||
|
||||
def test_negative_tolerance_raises(self):
|
||||
with pytest.raises(ValueError, match="finite"):
|
||||
rs.induce_numeric(["1.0"], tolerance=-1)
|
||||
|
||||
|
||||
class TestInduceJsonSchema:
|
||||
def test_keys_types_required(self):
|
||||
schema = rs.induce_json_schema(['{"a": 1, "b": "x"}', '{"a": 2}'])
|
||||
assert schema["type"] == "object"
|
||||
assert set(schema["properties"]) == {"a", "b"}
|
||||
assert schema["properties"]["a"]["type"] == "integer"
|
||||
assert schema["required"] == ["a"] # b missing from row 2
|
||||
|
||||
def test_array(self):
|
||||
schema = rs.induce_json_schema(["[1, 2]", "[3]"])
|
||||
assert schema["type"] == "array"
|
||||
|
||||
|
||||
class TestInduceToolCall:
|
||||
def test_names_and_arg_keys(self):
|
||||
golds = ['{"name": "search", "arguments": {"q": "x"}}',
|
||||
'{"name": "lookup", "arguments": {"id": 3}}']
|
||||
spec = rs.induce_tool_call(golds)
|
||||
assert set(spec.names) == {"search", "lookup"}
|
||||
assert set(spec.arg_keys) == {"q", "id"}
|
||||
|
||||
def test_per_tool_required_and_allowed(self):
|
||||
# search always has q (required); lookup varies id/limit (id required only if
|
||||
# in every lookup call). allowed = union per name.
|
||||
golds = ['{"name": "search", "arguments": {"q": "a"}}',
|
||||
'{"name": "search", "arguments": {"q": "b", "page": 2}}',
|
||||
'{"name": "lookup", "arguments": {"id": 1}}']
|
||||
spec = rs.induce_tool_call(golds)
|
||||
assert spec.tools["search"]["required"] == ("q",)
|
||||
assert set(spec.tools["search"]["allowed"]) == {"q", "page"}
|
||||
assert spec.tools["lookup"]["required"] == ("id",)
|
||||
|
||||
def test_no_tool_calls_raises(self):
|
||||
with pytest.raises(ValueError, match="no tool-call"):
|
||||
rs.induce_tool_call(["just some text", "5"])
|
||||
|
||||
|
||||
class TestInduceRegex:
|
||||
def test_confident(self):
|
||||
pat = rs.induce_regex(["2031-01-02", "1999-12-31"])
|
||||
import re
|
||||
assert pat is not None and re.fullmatch(pat, "2000-05-06")
|
||||
|
||||
def test_not_confident_returns_none(self):
|
||||
assert rs.induce_regex(["ab", "abcde", "x"]) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# render + round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
def _load_reward_fn(source: str, tmp_path: Path):
|
||||
p = tmp_path / "gen_reward.py"
|
||||
p.write_text(source, encoding="utf-8")
|
||||
spec = importlib.util.spec_from_file_location("gen_reward", p)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod.reward_fn
|
||||
|
||||
|
||||
def _completions(*contents):
|
||||
return [[{"role": "assistant", "content": c}] for c in contents]
|
||||
|
||||
|
||||
class TestRenderVerifier:
|
||||
def test_compiles_and_defines_reward_fn(self, tmp_path):
|
||||
src = rs.render_verifier_py("numeric", rs.NumericSpec(False, 0.0), meta={"n_refs": 3})
|
||||
compile(src, "<gen>", "exec") # syntactically valid
|
||||
fn = _load_reward_fn(src, tmp_path)
|
||||
assert callable(fn)
|
||||
|
||||
def test_numeric_roundtrip(self, tmp_path):
|
||||
src = rs.render_verifier_py("numeric", rs.NumericSpec(False, 0.0), meta={"n_refs": 2})
|
||||
fn = _load_reward_fn(src, tmp_path)
|
||||
out = fn(_completions("The answer is 5", "42"), answer=["5", "7"])
|
||||
assert out == [1.0, 0.0]
|
||||
|
||||
def test_json_schema_roundtrip(self, tmp_path):
|
||||
schema = {"type": "object", "properties": {"a": {"type": "integer"}}, "required": ["a"]}
|
||||
src = rs.render_verifier_py("json_schema", schema, meta={"n_refs": 2})
|
||||
fn = _load_reward_fn(src, tmp_path)
|
||||
out = fn(_completions('{"a": 1}', "not json"), answer=["", ""])
|
||||
assert out == [1.0, 0.0]
|
||||
|
||||
def test_tool_call_roundtrip(self, tmp_path):
|
||||
spec = rs.ToolCallSpec(tools={"search": {"required": ("q",), "allowed": ("q",)}})
|
||||
src = rs.render_verifier_py("tool_call", spec, meta={"n_refs": 1})
|
||||
fn = _load_reward_fn(src, tmp_path)
|
||||
out = fn(_completions('{"name": "search", "arguments": {"q": "x"}}',
|
||||
'{"name": "evil", "arguments": {}}'), answer=["", ""])
|
||||
assert out == [1.0, 0.0]
|
||||
|
||||
def test_tool_call_binding_rejects_hacks(self, tmp_path):
|
||||
# The code-review H1: two tools; a call must not borrow the other's arg key
|
||||
# or drop a required one. Union-of-all-keys + subset-check would accept both.
|
||||
spec = rs.induce_tool_call(['{"name": "search", "arguments": {"q": "x"}}',
|
||||
'{"name": "lookup", "arguments": {"id": 3}}'])
|
||||
fn = _load_reward_fn(rs.render_verifier_py("tool_call", spec, meta={"n_refs": 2}),
|
||||
tmp_path)
|
||||
out = fn(_completions(
|
||||
'{"name": "search", "arguments": {"q": "y"}}', # ok
|
||||
'{"name": "search", "arguments": {}}', # missing required q
|
||||
'{"name": "search", "arguments": {"id": 3}}', # borrowed lookup's key
|
||||
), answer=["", "", ""])
|
||||
assert out == [1.0, 0.0, 0.0]
|
||||
|
||||
def test_numeric_boxed_beats_trailing_number(self, tmp_path):
|
||||
# M1: a \boxed answer wins over an incidental trailing number.
|
||||
src = rs.render_verifier_py("numeric", rs.NumericSpec(False, 0.0), meta={"n_refs": 1})
|
||||
fn = _load_reward_fn(src, tmp_path)
|
||||
out = fn(_completions(r"So \boxed{4}. This took 12 seconds."), answer=["4"])
|
||||
assert out == [1.0]
|
||||
|
||||
def test_numeric_bigint_exact(self, tmp_path):
|
||||
# M1: two 17-digit ints that collide under float() must NOT match at tol 0.
|
||||
src = rs.render_verifier_py("numeric", rs.NumericSpec(False, 0.0), meta={"n_refs": 1})
|
||||
fn = _load_reward_fn(src, tmp_path)
|
||||
out = fn(_completions("90071992547409915"), answer=["90071992547409916"])
|
||||
assert out == [0.0]
|
||||
|
||||
def test_json_schema_int_float_symmetric(self, tmp_path):
|
||||
# H2: gold key induced as integer; a completion with 5.0 must still match.
|
||||
schema = {"type": "object", "properties": {"x": {"type": "integer"}}, "required": ["x"]}
|
||||
fn = _load_reward_fn(rs.render_verifier_py("json_schema", schema, meta={"n_refs": 1}),
|
||||
tmp_path)
|
||||
out = fn(_completions('{"x": 5.0}'), answer=[""])
|
||||
assert out == [1.0]
|
||||
|
||||
def test_json_schema_typeless_key_presence_only(self, tmp_path):
|
||||
# H2: a key with no agreed type (varied across refs) is presence-only.
|
||||
schema = {"type": "object", "properties": {"a": {}}, "required": ["a"]}
|
||||
fn = _load_reward_fn(rs.render_verifier_py("json_schema", schema, meta={"n_refs": 1}),
|
||||
tmp_path)
|
||||
assert fn(_completions('{"a": "text"}', '{"a": 9}'), answer=["", ""]) == [1.0, 1.0]
|
||||
|
||||
def test_baked_constant_reflects_spec(self):
|
||||
# Mutation-guard: a different tolerance must change the emitted source.
|
||||
a = rs.render_verifier_py("numeric", rs.NumericSpec(True, 0.001), meta={"n_refs": 1})
|
||||
b = rs.render_verifier_py("numeric", rs.NumericSpec(True, 0.999), meta={"n_refs": 1})
|
||||
assert "0.001" in a and "0.001" not in b
|
||||
|
||||
def test_rel_hint_cannot_inject_code(self):
|
||||
# security: rel_hint (the -o path) is .format'd raw into the header
|
||||
# docstring, and the file is later exec'd — a triple-quote break must not
|
||||
# survive into the emitted source.
|
||||
evil = 'x\n"""\nimport os\nopen("PWNED", "w").close()\n"""'
|
||||
src = rs.render_verifier_py("numeric", rs.NumericSpec(False, 0.0),
|
||||
meta={"n_refs": 1, "rel_hint": evil})
|
||||
benign = rs.render_verifier_py("numeric", rs.NumericSpec(False, 0.0),
|
||||
meta={"n_refs": 1, "rel_hint": "reward.py"})
|
||||
compile(src, "<g>", "exec") # still valid Python (no docstring break-out)
|
||||
# No EXTRA triple-quote injected (the hint can't close the docstring early)
|
||||
# and the quotes needed to make the payload executable are stripped.
|
||||
assert src.count('"""') == benign.count('"""')
|
||||
assert 'open("PWNED"' not in src
|
||||
|
||||
def test_hostile_gold_stays_repr_safe(self):
|
||||
# Untrusted tool names/keys are baked via repr(); they cannot break out.
|
||||
spec = rs.ToolCallSpec(tools={
|
||||
'ev"l\n"""x': {"required": (), "allowed": ('a"\nb',)}})
|
||||
src = rs.render_verifier_py("tool_call", spec, meta={"n_refs": 1})
|
||||
compile(src, "<g>", "exec")
|
||||
|
||||
|
||||
class TestInduceJsonSchemaEdges:
|
||||
def test_mixed_shapes_refused(self):
|
||||
with pytest.raises(ValueError, match="mix"):
|
||||
rs.induce_json_schema(['{"a": 1}', "[1, 2]"])
|
||||
|
||||
def test_no_containers_refused(self):
|
||||
with pytest.raises(ValueError, match="no JSON"):
|
||||
rs.induce_json_schema(["hello", "world"])
|
||||
|
||||
def test_inconsistent_key_type_is_typeless(self):
|
||||
schema = rs.induce_json_schema(['{"a": 1}', '{"a": "x"}'])
|
||||
assert schema["properties"]["a"] == {} # no type constraint
|
||||
|
||||
|
||||
class TestInduceNumericGuard:
|
||||
def test_non_numeric_refused(self):
|
||||
with pytest.raises(ValueError, match="not numeric"):
|
||||
rs.induce_numeric(["hello", "world"])
|
||||
|
||||
|
||||
class TestCalibrateFloors:
|
||||
def test_hard_floor_refuses_at_zero_threshold(self):
|
||||
# M2: an always-accept verifier is refused even with min_discrimination=0.
|
||||
def always_one(completions, **kw):
|
||||
return [1.0] * len(completions)
|
||||
rep = rs.calibrate(always_one, ["a", "b"], ["x", "y"], min_discrimination=0.0)
|
||||
assert rep.refused is True and rep.discrimination <= 0.0
|
||||
|
||||
def test_self_accept_floor(self):
|
||||
# H2: a verifier accepting only half its own refs is refused (floor 0.9).
|
||||
state = {"i": 0}
|
||||
def half(completions, **kw):
|
||||
out = []
|
||||
for _ in completions:
|
||||
out.append(1.0 if state["i"] % 2 == 0 else 0.0)
|
||||
state["i"] += 1
|
||||
return out
|
||||
rep = rs.calibrate(half, ["a", "b", "c", "d"], ["x"], min_discrimination=0.1)
|
||||
assert rep.refused is True and "references" in rep.reason
|
||||
|
||||
|
||||
class TestLoadRewardFnsGuards:
|
||||
def test_none_friendly(self):
|
||||
from soup_cli.trainer.rewards import load_reward_fns
|
||||
with pytest.raises(ValueError, match="must be a string"):
|
||||
load_reward_fns(None)
|
||||
|
||||
def test_blank_rejected(self):
|
||||
from soup_cli.trainer.rewards import load_reward_fns
|
||||
with pytest.raises(ValueError, match="blank"):
|
||||
load_reward_fns(" ")
|
||||
|
||||
def test_duplicate_rejected(self):
|
||||
from soup_cli.trainer.rewards import load_reward_fns
|
||||
with pytest.raises(ValueError, match="twice"):
|
||||
load_reward_fns("accuracy,accuracy")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# perturb + calibrate
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestPerturbNegatives:
|
||||
def test_negatives_differ_from_golds(self):
|
||||
golds = ["5", "12"]
|
||||
negs = rs.perturb_negatives(golds, "numeric")
|
||||
assert negs and all(n not in golds for n in negs)
|
||||
|
||||
|
||||
class TestCalibrate:
|
||||
def test_always_one_verifier_refused(self):
|
||||
def always_one(completions, **kw):
|
||||
return [1.0] * len(completions)
|
||||
rep = rs.calibrate(always_one, ["a", "b"], ["x", "y"], min_discrimination=0.5)
|
||||
assert rep.refused is True and rep.discrimination == 0.0
|
||||
|
||||
def test_good_verifier_passes(self):
|
||||
def exact(completions, **kw):
|
||||
answers = kw.get("answer", [])
|
||||
return [1.0 if c[-1]["content"] == a else 0.0
|
||||
for c, a in zip(completions, answers)]
|
||||
rep = rs.calibrate(exact, ["a", "b"], ["neg1", "neg2"], min_discrimination=0.5)
|
||||
assert rep.refused is False and rep.pos_accept == 1.0 and rep.neg_accept == 0.0
|
||||
|
||||
def test_discrimination_boundary_exact(self):
|
||||
# Accept ALL positives (clears the 0.9 self-accept floor) but half the
|
||||
# negatives → discrimination 0.5 == threshold → pass (not < threshold).
|
||||
phase = {"neg": False}
|
||||
|
||||
def v(completions, **kw):
|
||||
if not phase["neg"]:
|
||||
phase["neg"] = True
|
||||
return [1.0] * len(completions) # positives: all accepted
|
||||
return [1.0 if i % 2 == 0 else 0.0 for i in range(len(completions))]
|
||||
rep = rs.calibrate(v, ["a", "b"], ["x", "y"], min_discrimination=0.5)
|
||||
assert rep.pos_accept == 1.0 and rep.neg_accept == 0.5
|
||||
assert rep.discrimination == 0.5 and rep.refused is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# synthesize (end-to-end pure)
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestSynthesize:
|
||||
def test_numeric_from_calculator_shape(self):
|
||||
rows = [{"prompt": "2+2", "answer": "4"}, {"prompt": "3+3", "answer": "6"}]
|
||||
res = rs.synthesize(rows, field="answer", kind="auto")
|
||||
assert res.kind == "numeric"
|
||||
assert "def reward_fn" in res.source
|
||||
|
||||
def test_bad_kind_raises(self):
|
||||
with pytest.raises(ValueError, match="kind"):
|
||||
rs.synthesize([{"answer": "1"}], field="answer", kind="bogus")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI — soup reward synth
|
||||
# ---------------------------------------------------------------------------
|
||||
def _write_jsonl(path: Path, rows):
|
||||
import json as _json
|
||||
path.write_text("\n".join(_json.dumps(r) for r in rows), encoding="utf-8")
|
||||
|
||||
|
||||
class TestRewardSynthCli:
|
||||
def _runner(self):
|
||||
from typer.testing import CliRunner
|
||||
return CliRunner()
|
||||
|
||||
def test_help(self):
|
||||
from soup_cli.commands.reward import app
|
||||
res = self._runner().invoke(app, ["synth", "--help"])
|
||||
assert res.exit_code == 0, (res.output, repr(res.exception))
|
||||
assert "reward" in res.output.lower()
|
||||
|
||||
def test_numeric_happy(self, tmp_path, monkeypatch):
|
||||
from soup_cli.commands.reward import app
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_write_jsonl(Path("refs.jsonl"),
|
||||
[{"prompt": "2+2", "answer": "4"}, {"prompt": "3+3", "answer": "6"}])
|
||||
res = self._runner().invoke(app, ["synth", "refs.jsonl", "-o", "reward.py"])
|
||||
assert res.exit_code == 0, (res.output, repr(res.exception))
|
||||
assert Path("reward.py").exists()
|
||||
# The emitted verifier agrees with the golds.
|
||||
fn = _load_reward_fn(Path("reward.py").read_text(encoding="utf-8"), tmp_path)
|
||||
assert fn(_completions("4", "99"), answer=["4", "6"]) == [1.0, 0.0]
|
||||
|
||||
def test_cant_induce_is_error_exit_1(self, tmp_path, monkeypatch):
|
||||
from soup_cli.commands.reward import app
|
||||
monkeypatch.chdir(tmp_path)
|
||||
# All golds empty → regex can't be induced → INDUCTION ERROR (exit 1),
|
||||
# distinct from a calibration refusal (exit 2). No file written.
|
||||
_write_jsonl(Path("refs.jsonl"),
|
||||
[{"answer": ""}, {"answer": ""}, {"answer": ""}])
|
||||
res = self._runner().invoke(app, ["synth", "refs.jsonl", "-o", "reward.py",
|
||||
"--kind", "regex"])
|
||||
assert res.exit_code == 1, (res.output, repr(res.exception))
|
||||
assert "regex" in res.output.lower()
|
||||
assert not Path("reward.py").exists()
|
||||
|
||||
def test_refusal_deletes_file(self, tmp_path, monkeypatch):
|
||||
from soup_cli.commands.reward import app
|
||||
monkeypatch.chdir(tmp_path)
|
||||
# A numeric verifier with a huge tolerance accepts the perturbed (+9999)
|
||||
# negatives too → discrimination below the default 0.5 → REFUSED (exit 2),
|
||||
# and the emitted file is removed (no partial artifact left behind).
|
||||
_write_jsonl(Path("refs.jsonl"), [{"answer": str(i)} for i in range(8)])
|
||||
res = self._runner().invoke(app, ["synth", "refs.jsonl", "-o", "reward.py",
|
||||
"--kind", "numeric", "--tolerance", "1e9"])
|
||||
assert res.exit_code == 2, (res.output, repr(res.exception))
|
||||
assert "discrimination" in res.output.lower() and "refus" in res.output.lower()
|
||||
assert not Path("reward.py").exists()
|
||||
|
||||
def test_plan_only_writes_nothing(self, tmp_path, monkeypatch):
|
||||
from soup_cli.commands.reward import app
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_write_jsonl(Path("refs.jsonl"), [{"answer": "4"}, {"answer": "6"}])
|
||||
res = self._runner().invoke(app, ["synth", "refs.jsonl", "-o", "reward.py",
|
||||
"--plan-only"])
|
||||
assert res.exit_code == 0, (res.output, repr(res.exception))
|
||||
assert not Path("reward.py").exists()
|
||||
# Spec-specific fields, not just the kind name in the panel title.
|
||||
assert "numeric" in res.output.lower() and "tolerance" in res.output.lower()
|
||||
assert "float=false" in res.output.lower().replace(" ", "")
|
||||
|
||||
def test_output_must_be_py(self, tmp_path, monkeypatch):
|
||||
from soup_cli.commands.reward import app
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_write_jsonl(Path("refs.jsonl"), [{"answer": "4"}])
|
||||
res = self._runner().invoke(app, ["synth", "refs.jsonl", "-o", "reward.txt"])
|
||||
assert res.exit_code == 1
|
||||
assert ".py" in res.output
|
||||
|
||||
def test_overwrite_without_force_rejected(self, tmp_path, monkeypatch):
|
||||
from soup_cli.commands.reward import app
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_write_jsonl(Path("refs.jsonl"), [{"answer": "4"}, {"answer": "6"}])
|
||||
Path("reward.py").write_text("# existing\n", encoding="utf-8")
|
||||
res = self._runner().invoke(app, ["synth", "refs.jsonl", "-o", "reward.py"])
|
||||
assert res.exit_code == 1
|
||||
assert "force" in res.output.lower()
|
||||
assert Path("reward.py").read_text(encoding="utf-8") == "# existing\n"
|
||||
|
||||
def test_bad_kind_rejected(self, tmp_path, monkeypatch):
|
||||
from soup_cli.commands.reward import app
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_write_jsonl(Path("refs.jsonl"), [{"answer": "4"}])
|
||||
res = self._runner().invoke(app, ["synth", "refs.jsonl", "-o", "reward.py",
|
||||
"--kind", "bogus"])
|
||||
assert res.exit_code == 1
|
||||
assert "kind" in res.output.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Blocking riders — #311 comma-split loader + reward_fn validator + envs docstring
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestCommaSplitLoader:
|
||||
def test_two_names_two_callables(self):
|
||||
from soup_cli.trainer.rewards import load_reward_fns
|
||||
fns = load_reward_fns("accuracy,format")
|
||||
assert isinstance(fns, list) and len(fns) == 2
|
||||
assert {f.__name__ for f in fns} == {"accuracy_reward", "format_reward"}
|
||||
|
||||
def test_single_name_single_element_list(self):
|
||||
from soup_cli.trainer.rewards import load_reward_fns
|
||||
fns = load_reward_fns("accuracy")
|
||||
assert isinstance(fns, list) and len(fns) == 1
|
||||
assert fns[0].__name__ == "accuracy_reward"
|
||||
|
||||
def test_whitespace_around_segments(self):
|
||||
from soup_cli.trainer.rewards import load_reward_fns
|
||||
fns = load_reward_fns(" accuracy , format ")
|
||||
assert len(fns) == 2
|
||||
|
||||
def test_empty_segment_raises(self):
|
||||
from soup_cli.trainer.rewards import load_reward_fns
|
||||
with pytest.raises(ValueError, match="empty"):
|
||||
load_reward_fns("accuracy,")
|
||||
|
||||
def test_verifiable_with_domain(self):
|
||||
from soup_cli.trainer.rewards import load_reward_fns
|
||||
fns = load_reward_fns("verifiable,format", verifiable_domain="math")
|
||||
assert len(fns) == 2
|
||||
|
||||
|
||||
class TestSelectRewardFn:
|
||||
def test_comma_split_returns_list(self):
|
||||
from soup_cli.config.schema import TrainingConfig
|
||||
from soup_cli.trainer.grpo import _select_reward_fn
|
||||
tcfg = TrainingConfig(reward_fn="accuracy,format")
|
||||
out = _select_reward_fn(tcfg, "cpu", False)
|
||||
assert isinstance(out, list) and len(out) == 2
|
||||
|
||||
def test_single_returns_callable(self):
|
||||
from soup_cli.config.schema import TrainingConfig
|
||||
from soup_cli.trainer.grpo import _select_reward_fn
|
||||
tcfg = TrainingConfig(reward_fn="accuracy")
|
||||
out = _select_reward_fn(tcfg, "cpu", False)
|
||||
assert callable(out) and not isinstance(out, list)
|
||||
|
||||
|
||||
class TestRewardFnValidator:
|
||||
def test_null_byte_rejected(self):
|
||||
from pydantic import ValidationError
|
||||
|
||||
from soup_cli.config.schema import TrainingConfig
|
||||
with pytest.raises(ValidationError, match="null"):
|
||||
TrainingConfig(reward_fn="acc\x00uracy")
|
||||
|
||||
def test_empty_comma_segment_rejected(self):
|
||||
from pydantic import ValidationError
|
||||
|
||||
from soup_cli.config.schema import TrainingConfig
|
||||
with pytest.raises(ValidationError, match="empty"):
|
||||
TrainingConfig(reward_fn="accuracy,,format")
|
||||
|
||||
def test_overlong_rejected(self):
|
||||
from pydantic import ValidationError
|
||||
|
||||
from soup_cli.config.schema import TrainingConfig
|
||||
with pytest.raises(ValidationError, match="512"):
|
||||
TrainingConfig(reward_fn="a" * 600)
|
||||
|
||||
def test_comma_combo_accepted(self):
|
||||
from soup_cli.config.schema import TrainingConfig
|
||||
assert TrainingConfig(reward_fn="accuracy,format").reward_fn == "accuracy,format"
|
||||
|
||||
|
||||
class TestRewardFnTaskGate:
|
||||
def _cfg(self, task, reward_fn):
|
||||
from soup_cli.config.loader import load_config_from_string
|
||||
return load_config_from_string(
|
||||
f"base: sshleifer/tiny-gpt2\ntask: {task}\n"
|
||||
f"data:\n train: d.jsonl\ntraining:\n reward_fn: {reward_fn}\n"
|
||||
)
|
||||
|
||||
def test_grpo_allows_comma(self):
|
||||
cfg = self._cfg("grpo", "accuracy,format")
|
||||
assert cfg.training.reward_fn == "accuracy,format"
|
||||
|
||||
def test_ppo_rejects_comma(self):
|
||||
# load_config_from_string re-wraps pydantic's ValidationError as ValueError.
|
||||
with pytest.raises(ValueError, match="grpo"):
|
||||
self._cfg("ppo", "accuracy,format")
|
||||
|
||||
def test_sft_rejects_comma(self):
|
||||
with pytest.raises(ValueError, match="grpo"):
|
||||
self._cfg("sft", "accuracy,format")
|
||||
|
||||
def test_ppo_single_name_ok(self):
|
||||
cfg = self._cfg("ppo", "accuracy")
|
||||
assert cfg.training.reward_fn == "accuracy"
|
||||
|
||||
|
||||
class TestReviewFixes:
|
||||
def test_null_gold_falls_through_to_messages(self):
|
||||
rows = [{"answer": None,
|
||||
"messages": [{"role": "assistant", "content": "gold"}]}]
|
||||
assert rs.extract_golds(rows, field="answer") == ["gold"]
|
||||
|
||||
def test_null_gold_only_skipped(self):
|
||||
with pytest.raises(ValueError, match="no gold"):
|
||||
rs.extract_golds([{"answer": None}], field="answer")
|
||||
|
||||
def test_non_finite_tolerance_raises(self):
|
||||
with pytest.raises(ValueError, match="finite"):
|
||||
rs.induce_numeric(["1.0"], tolerance=float("nan"))
|
||||
with pytest.raises(ValueError, match="finite"):
|
||||
rs.induce_numeric(["1.0"], tolerance=float("inf"))
|
||||
|
||||
def test_cli_bad_min_discrimination(self, tmp_path, monkeypatch):
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from soup_cli.commands.reward import app
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_write_jsonl(Path("refs.jsonl"), [{"answer": "4"}])
|
||||
res = CliRunner().invoke(app, ["synth", "refs.jsonl", "-o", "r.py",
|
||||
"--min-discrimination", "2.0"])
|
||||
assert res.exit_code == 1 and "min-discrimination" in res.output
|
||||
|
||||
def test_cli_bad_tolerance(self, tmp_path, monkeypatch):
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from soup_cli.commands.reward import app
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_write_jsonl(Path("refs.jsonl"), [{"answer": "4"}])
|
||||
res = CliRunner().invoke(app, ["synth", "refs.jsonl", "-o", "r.py",
|
||||
"--tolerance", "-1"])
|
||||
assert res.exit_code == 1 and "tolerance" in res.output
|
||||
|
||||
|
||||
class TestPerKindPipeline:
|
||||
"""Every kind runs the FULL write->load->calibrate pipeline (not just numeric)."""
|
||||
|
||||
def _runner(self):
|
||||
from typer.testing import CliRunner
|
||||
return CliRunner()
|
||||
|
||||
def _synth(self, tmp_path, monkeypatch, rows, extra=()):
|
||||
from soup_cli.commands.reward import app
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_write_jsonl(Path("refs.jsonl"), rows)
|
||||
return self._runner().invoke(
|
||||
app, ["synth", "refs.jsonl", "-o", "reward.py", *extra])
|
||||
|
||||
def test_json_schema_end_to_end(self, tmp_path, monkeypatch):
|
||||
rows = [{"answer": '{"city": "Paris", "pop": 2}'},
|
||||
{"answer": '{"city": "Rome", "pop": 3}'}]
|
||||
res = self._synth(tmp_path, monkeypatch, rows)
|
||||
assert res.exit_code == 0, (res.output, repr(res.exception))
|
||||
fn = _load_reward_fn(Path("reward.py").read_text(encoding="utf-8"), tmp_path)
|
||||
assert fn(_completions('{"city": "X", "pop": 9}', "nope"),
|
||||
answer=["", ""]) == [1.0, 0.0]
|
||||
|
||||
def test_tool_call_end_to_end(self, tmp_path, monkeypatch):
|
||||
rows = [{"answer": '{"name": "search", "arguments": {"q": "a"}}'},
|
||||
{"answer": '{"name": "search", "arguments": {"q": "b"}}'}]
|
||||
res = self._synth(tmp_path, monkeypatch, rows)
|
||||
assert res.exit_code == 0, (res.output, repr(res.exception))
|
||||
fn = _load_reward_fn(Path("reward.py").read_text(encoding="utf-8"), tmp_path)
|
||||
assert fn(_completions('{"name": "search", "arguments": {"q": "z"}}',
|
||||
'{"name": "search", "arguments": {}}'),
|
||||
answer=["", ""]) == [1.0, 0.0]
|
||||
|
||||
def test_regex_end_to_end(self, tmp_path, monkeypatch):
|
||||
rows = [{"answer": "2031-01-02"}, {"answer": "1999-12-31"},
|
||||
{"answer": "2000-05-06"}]
|
||||
res = self._synth(tmp_path, monkeypatch, rows, extra=["--kind", "regex"])
|
||||
assert res.exit_code == 0, (res.output, repr(res.exception))
|
||||
fn = _load_reward_fn(Path("reward.py").read_text(encoding="utf-8"), tmp_path)
|
||||
assert fn(_completions("2020-11-11", "not a date"), answer=["", ""]) == [1.0, 0.0]
|
||||
|
||||
|
||||
class TestPerturbNegativesPerKind:
|
||||
def test_tool_call_generates_valid_json_hacks(self):
|
||||
golds = ['{"name": "search", "arguments": {"q": "x"}}']
|
||||
negs = rs.perturb_negatives(golds, "tool_call")
|
||||
# Must include a VALID-JSON wrong-name negative and a foreign-arg negative,
|
||||
# not just the syntactically-invalid string.
|
||||
assert any('"__nonexistent_tool__"' in n for n in negs)
|
||||
assert any("__foreign_arg__" in n for n in negs)
|
||||
|
||||
def test_json_schema_negatives(self):
|
||||
negs = rs.perturb_negatives(['{"a": 1}'], "json_schema")
|
||||
assert negs and all(n for n in negs[:1]) # non-empty invalid-json negative
|
||||
|
||||
def test_unknown_kind_raises(self):
|
||||
with pytest.raises(ValueError, match="unknown kind"):
|
||||
rs.perturb_negatives(["x"], "bogus")
|
||||
|
||||
|
||||
class TestOutputReport:
|
||||
def _runner(self):
|
||||
from typer.testing import CliRunner
|
||||
return CliRunner()
|
||||
|
||||
def test_report_written_and_parseable(self, tmp_path, monkeypatch):
|
||||
from soup_cli.commands.reward import app
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_write_jsonl(Path("refs.jsonl"), [{"answer": "4"}, {"answer": "6"}])
|
||||
res = self._runner().invoke(app, ["synth", "refs.jsonl", "-o", "reward.py",
|
||||
"--output-report", "rep.json"])
|
||||
assert res.exit_code == 0, (res.output, repr(res.exception))
|
||||
import json as _json
|
||||
rep = _json.loads(Path("rep.json").read_text(encoding="utf-8"))
|
||||
assert rep["kind"] == "numeric" and rep["refused"] is False
|
||||
assert set(rep) >= {"pos_accept", "neg_accept", "discrimination", "precision"}
|
||||
|
||||
def test_bad_report_path_fails_before_writing_verifier(self, tmp_path, monkeypatch):
|
||||
from soup_cli.commands.reward import app
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_write_jsonl(Path("refs.jsonl"), [{"answer": "4"}, {"answer": "6"}])
|
||||
res = self._runner().invoke(app, ["synth", "refs.jsonl", "-o", "reward.py",
|
||||
"--output-report", "../escape.json"])
|
||||
assert res.exit_code == 1, (res.output, repr(res.exception))
|
||||
# The verifier is never written when the report path is rejected up front.
|
||||
assert not Path("reward.py").exists()
|
||||
|
||||
|
||||
class TestCliRobustness:
|
||||
def _runner(self):
|
||||
from typer.testing import CliRunner
|
||||
return CliRunner()
|
||||
|
||||
def test_missing_references_file(self, tmp_path, monkeypatch):
|
||||
from soup_cli.commands.reward import app
|
||||
monkeypatch.chdir(tmp_path)
|
||||
res = self._runner().invoke(app, ["synth", "nope.jsonl", "-o", "reward.py"])
|
||||
assert res.exit_code == 1, (res.output, repr(res.exception))
|
||||
|
||||
def test_empty_references_file(self, tmp_path, monkeypatch):
|
||||
from soup_cli.commands.reward import app
|
||||
monkeypatch.chdir(tmp_path)
|
||||
Path("refs.jsonl").write_text("\n\n", encoding="utf-8")
|
||||
res = self._runner().invoke(app, ["synth", "refs.jsonl", "-o", "reward.py"])
|
||||
assert res.exit_code == 1 and "no JSON-object rows" in res.output
|
||||
|
||||
def test_force_overwrites(self, tmp_path, monkeypatch):
|
||||
from soup_cli.commands.reward import app
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_write_jsonl(Path("refs.jsonl"), [{"answer": "4"}, {"answer": "6"}])
|
||||
Path("reward.py").write_text("# stale\n", encoding="utf-8")
|
||||
res = self._runner().invoke(app, ["synth", "refs.jsonl", "-o", "reward.py",
|
||||
"--force"])
|
||||
assert res.exit_code == 0, (res.output, repr(res.exception))
|
||||
assert "# stale" not in Path("reward.py").read_text(encoding="utf-8")
|
||||
|
||||
def test_custom_field(self, tmp_path, monkeypatch):
|
||||
from soup_cli.commands.reward import app
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_write_jsonl(Path("refs.jsonl"), [{"gold": "4"}, {"gold": "6"}])
|
||||
res = self._runner().invoke(app, ["synth", "refs.jsonl", "-o", "reward.py",
|
||||
"--field", "gold"])
|
||||
assert res.exit_code == 0, (res.output, repr(res.exception))
|
||||
assert Path("reward.py").exists()
|
||||
|
||||
def test_output_outside_cwd_rejected(self, tmp_path, monkeypatch):
|
||||
from soup_cli.commands.reward import app
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_write_jsonl(Path("refs.jsonl"), [{"answer": "4"}])
|
||||
res = self._runner().invoke(app, ["synth", "refs.jsonl", "-o", "../evil.py"])
|
||||
assert res.exit_code == 1 and "cwd" in res.output.lower()
|
||||
|
||||
def test_references_outside_cwd_rejected(self, tmp_path, monkeypatch):
|
||||
from soup_cli.commands.reward import app
|
||||
monkeypatch.chdir(tmp_path)
|
||||
res = self._runner().invoke(app, ["synth", "../secret.jsonl", "-o", "reward.py"])
|
||||
assert res.exit_code == 1 and "cwd" in res.output.lower()
|
||||
|
||||
def test_registered_on_top_level_cli(self):
|
||||
from soup_cli.cli import app as root
|
||||
res = self._runner().invoke(root, ["reward", "synth", "--help"])
|
||||
assert res.exit_code == 0, (res.output, repr(res.exception))
|
||||
|
||||
|
||||
class TestBoundaries:
|
||||
def test_self_accept_boundary_9_of_10(self):
|
||||
# 9/10 == 0.9 self-accept floor → NOT refused; 8/9 < 0.9 → refused.
|
||||
pos9 = ["p"] * 10
|
||||
seq = {"i": 0}
|
||||
|
||||
def accept_first_9(completions, **kw):
|
||||
out = []
|
||||
for _ in completions:
|
||||
out.append(1.0 if seq["i"] < 9 else 0.0)
|
||||
seq["i"] += 1
|
||||
return out
|
||||
rep = rs.calibrate(accept_first_9, pos9, ["n"], min_discrimination=0.1)
|
||||
assert abs(rep.pos_accept - 0.9) < 1e-9 and rep.refused is False
|
||||
|
||||
def test_self_accept_below_floor_refused(self):
|
||||
seq = {"i": 0}
|
||||
|
||||
def accept_first_7(completions, **kw): # 7/9 ~= 0.78 < 0.9
|
||||
out = []
|
||||
for _ in completions:
|
||||
out.append(1.0 if seq["i"] < 7 else 0.0)
|
||||
seq["i"] += 1
|
||||
return out
|
||||
rep = rs.calibrate(accept_first_7, ["p"] * 9, ["n"], min_discrimination=0.1)
|
||||
assert rep.pos_accept < rs._MIN_SELF_ACCEPT and rep.refused is True
|
||||
assert "own references" in rep.reason
|
||||
|
||||
def test_numeric_tolerance_inclusive(self, tmp_path):
|
||||
src = rs.render_verifier_py("numeric", rs.NumericSpec(True, 0.1), meta={"n_refs": 1})
|
||||
fn = _load_reward_fn(src, tmp_path)
|
||||
# |5.1 - 5.0| == 0.1 == tol → inclusive match.
|
||||
assert fn(_completions("5.1"), answer=["5.0"]) == [1.0]
|
||||
assert fn(_completions("5.2"), answer=["5.0"]) == [0.0]
|
||||
|
||||
def test_precision_value(self):
|
||||
def accept_all(completions, **kw):
|
||||
return [1.0] * len(completions)
|
||||
# 2 refs accepted (tp=2), 3 negatives all accepted (fp=3) → precision 2/5.
|
||||
rep = rs.calibrate(accept_all, ["a", "b"], ["x", "y", "z"])
|
||||
assert abs(rep.precision - 0.4) < 1e-9
|
||||
|
||||
def test_detect_kind_numeric_confidence(self):
|
||||
# 9/10 numeric → numeric; 5/10 → not numeric (falls to regex/None).
|
||||
assert rs.detect_kind(["1", "2", "3", "4", "5", "6", "7", "8", "9", "x"]) == "numeric"
|
||||
|
||||
def test_reward_fn_512_boundary(self):
|
||||
from soup_cli.config.schema import TrainingConfig
|
||||
assert TrainingConfig(reward_fn="a" * 512).reward_fn == "a" * 512
|
||||
from pydantic import ValidationError
|
||||
with pytest.raises(ValidationError, match="512"):
|
||||
TrainingConfig(reward_fn="a" * 513)
|
||||
|
||||
|
||||
class TestVerifiableComboGate:
|
||||
def test_comma_verifiable_without_domain_rejected(self):
|
||||
# #8: "accuracy,verifiable" must fail at config parse (like bare
|
||||
# "verifiable"), not silently defer to a runtime crash.
|
||||
from soup_cli.config.loader import load_config_from_string
|
||||
with pytest.raises(ValueError, match="verifiable_domain"):
|
||||
load_config_from_string(
|
||||
"base: sshleifer/tiny-gpt2\ntask: grpo\ndata:\n train: d.jsonl\n"
|
||||
"training:\n reward_fn: accuracy,verifiable\n"
|
||||
)
|
||||
|
||||
def test_comma_verifiable_with_domain_ok(self):
|
||||
from soup_cli.config.loader import load_config_from_string
|
||||
cfg = load_config_from_string(
|
||||
"base: sshleifer/tiny-gpt2\ntask: grpo\ndata:\n train: d.jsonl\n"
|
||||
"training:\n reward_fn: accuracy,verifiable\n verifiable_domain: math\n"
|
||||
)
|
||||
assert cfg.training.reward_fn == "accuracy,verifiable"
|
||||
|
||||
|
||||
class TestRewardFnValidatorBlank:
|
||||
def test_blank_rejected(self):
|
||||
from pydantic import ValidationError
|
||||
|
||||
from soup_cli.config.schema import TrainingConfig
|
||||
with pytest.raises(ValidationError, match="blank"):
|
||||
TrainingConfig(reward_fn=" ")
|
||||
|
||||
def test_bool_rejected(self):
|
||||
from pydantic import ValidationError
|
||||
|
||||
from soup_cli.config.schema import TrainingConfig
|
||||
with pytest.raises(ValidationError, match="string"):
|
||||
TrainingConfig(reward_fn=True)
|
||||
|
||||
|
||||
class TestEnvsDocstringFix:
|
||||
def test_calculator_docstring(self):
|
||||
from soup_cli.envs import calculator
|
||||
assert "reward_fn='math'" not in (calculator.__doc__ or "")
|
||||
assert "verifiable" in (calculator.__doc__ or "")
|
||||
|
||||
def test_guess_number_docstring(self):
|
||||
from soup_cli.envs import guess_number
|
||||
assert "reward_fn='math'" not in (guess_number.__doc__ or "")
|
||||
assert "verifiable" in (guess_number.__doc__ or "")
|
||||
|
||||
|
||||
class TestNoTopLevelTorch:
|
||||
def test_reward_synth_has_no_top_level_torch(self):
|
||||
src = Path(rs.__file__).read_text(encoding="utf-8")
|
||||
tree = ast.parse(src)
|
||||
for node in tree.body: # module-level only
|
||||
if isinstance(node, ast.Import):
|
||||
assert all(not n.name.startswith(("torch", "transformers", "peft"))
|
||||
for n in node.names)
|
||||
if isinstance(node, ast.ImportFrom):
|
||||
assert not (node.module or "").startswith(("torch", "transformers", "peft"))
|
||||
Loading…
Reference in New Issue