feat(learn): A/B experiment runner -- registered manifests, paired arms, bootstrap and McNemar (decision-engine task 7)

learn/experiments.py: YAML manifests under plane/experiments/<id>.yaml,
digest-checked at register, run and analyse time; `experiment`,
`arm_outcome` and `experiment_result` rows; paired arms in a seeded
repository order with a per-unit coin; arm A replay-only at $0; canary
replicates under a fresh cache salt; repository-cluster paired bootstrap,
exact McNemar, two O'Brien-Fleming looks, adopt/reject/equivalent/
inconclusive. `ousast learn experiment register|units|run|analyse`.
Program: `ProgramSpec.sampling = retrieval_ensemble` (k samples at
temperature 0 over k disjoint retrieved example sets; default unchanged);
`Caller.salt`; `retrieve(exclude=)`. verify: OUSAST_VERIFY_CALLERS=off.
Manifests exp-001-known-callers (plane arms, units pending) and
exp-002-retrieval-ensemble (slice-2 incumbents, ceiling $4) -- not run.
18 scripted tests; module audit regenerated (151 modules).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
This commit is contained in:
Marius Ciepluch
2026-10-02 16:04:33 +02:00
co-authored by Claude Fable 5.1
parent a90e570f48
commit fbc3220bbe
14 changed files with 1681 additions and 35 deletions
+113
View File
@@ -54,6 +54,30 @@ def add_commands(learn_sub: Any) -> None:
if name == "curve":
sub.add_argument("--subset", type=int, default=200)
experiment = learn_sub.add_parser("experiment", help="A/B experiments: register a manifest, run its arms paired, analyse")
experiment_sub = experiment.add_subparsers(dest="experiment_command", required=True)
for name, text in (
("register", "record the manifest's and the units file's sha256 as the experiment row (both committed at HEAD)"),
("units", "freeze the units of a program experiment from the memory (no model): the families' evaluation candidates"),
("run", "run every arm on every unit, paired; arm A replays from the response cache when the manifest says so"),
("analyse", "paired cluster bootstrap, exact McNemar, the two looks and the adoption verdict (counts and intervals only)"),
):
sub = experiment_sub.add_parser(name, help=text)
sub.add_argument("manifest", type=Path, help="plane/experiments/<id>.yaml")
sub.add_argument("--memory", help="the store (default OUSAST_MEMORY, then results_root()/memory)")
if name in ("units", "run"):
sub.add_argument("--profile", choices=("static", "plane"), default="static")
if name == "run":
sub.add_argument(
"--manifest-model", type=Path, default=Path("plane/models/deepseek-flash.yaml"), help="the chat Model (prices)"
)
sub.add_argument("--no-early-stop", action="store_true", help="take no first look during the run")
if name in ("run", "analyse"):
sub.add_argument("--resamples", type=int, help="bootstrap resamples (default: the manifest's)")
sub.add_argument("--out", type=Path, help="write the counts-only report here (analyse --record: benchmarks/experiments/<id>/)")
if name == "analyse":
sub.add_argument("--record", action="store_true", help="also write the experiment_result row")
leaks = learn_sub.add_parser("audit-leaks", help="how well each signal alone separates the sides of a pair (no model)")
leaks.add_argument("--memory", help="the store (default OUSAST_MEMORY, then results_root()/memory)")
leaks.add_argument("--units", type=Path, help="the harvest units.json (unit -> case and side) for feature rows without a side")
@@ -73,10 +97,99 @@ def run(args: argparse.Namespace) -> int:
return _memory_embed(args)
if args.learn_command in ("compile", "evaluate", "curve"):
return _program_command(args)
if args.learn_command == "experiment":
return _experiment(args)
print(f"learn: unknown command {args.learn_command}", file=sys.stderr)
return 2
def _experiment(args: argparse.Namespace) -> int:
"""register | units | run | analyse. Exit 2 on a refused manifest (unregistered, modified, uncommitted), 3 when a
ceiling stops the run (``unfinished``: a rerun resumes from the outcome rows)."""
from datetime import date
from ..plane.budget import MeteredClient
from ..plane.memory import open_store
from . import experiments as ex
from .program import Caller
store = open_store(args.memory)
try:
manifest = ex.load_manifest(args.manifest)
if args.experiment_command == "register":
row = ex.register(store, manifest, created=date.today().isoformat())
shown = ("experiment", "manifest_digest", "units_status", "units_digest", "pin", "arms")
print(json.dumps({k: row[k] for k in shown}, indent=2))
return 0
if args.experiment_command == "analyse":
row = ex.check_registered(store, manifest, need_units=False)
provenance = {"code_commit": ex.committed_at_head(manifest.path)}
report = ex.analyse(manifest, ex.load_outcomes(store, manifest.id), resamples=args.resamples, provenance=provenance)
if args.record:
store.put_row(ex.result_row(manifest, str(row["pin"]), report))
_write(report, args.out or (ex.RESULT_DIR / manifest.id / "result.json" if args.record else None))
return 0
from .embeddings import EmbeddingCache
from .examples import load_examples
memory = load_examples(store, args.profile)
if args.experiment_command == "units":
units = ex.freeze_units(store, manifest, memory)
digest = ex.write_units(manifest.units_file, units)
paired = sum(1 for u in units if u.pair)
print(json.dumps({"units": len(units), "paired": paired, "groups": len({u.group for u in units}), "sha256": digest,
"path": str(manifest.units_file), "next": "commit the file, then register again"}, indent=2)) # fmt: skip
return 0
cache = EmbeddingCache(store)
vectors = {sha: v for sha in cache.names() if (v := cache.get(sha)) is not None} or None
parameters = _model_parameters(args.manifest_model)
callers: dict[str, Caller] = {}
for name, arm in manifest.arms.items():
if arm.replay_only:
callers[name] = Caller(None, _arm_model(manifest, arm, store), parameters, store=store)
continue
from ..config import load_config
from ..model.endpoint import resolve_chat_endpoint
resolved = resolve_chat_endpoint(load_config(None))
if resolved is None:
raise SystemExit("learn experiment run: no chat endpoint configured (DEEPSEEK_API_KEY); nothing was spent")
client = MeteredClient(resolved[0], prices=parameters, budget_usd=arm.budget_usd)
callers[name] = Caller(client, _arm_model(manifest, arm, store), parameters, store=store)
def excerpt_text(sha: str) -> str | None:
data = store.get_blob("excerpts", sha)
return data.decode("utf-8") if data is not None else None
early = not args.no_early_stop
summary = ex.run(store, manifest, memory, callers, excerpt_text, vectors, stop_early=early, resamples=args.resamples)
except ex.ExperimentError as exc:
print(f"learn experiment {args.experiment_command}: {exc}", file=sys.stderr)
return 2
_write(summary.as_dict(), args.out)
if summary.status == "unfinished":
print(f"learn experiment run: unfinished -- {summary.reason}", file=sys.stderr)
return 3
return 0
def _arm_model(manifest: Any, arm: Any, store: Any) -> str:
from .compile import load_program
if "model" in arm.spec:
return str(arm.spec["model"])
pid = next(iter({**manifest.programs, **arm.programs}.values()), None)
return str(load_program(store, pid)["classify"]["model"]) if pid else "deepseek-flash"
def _write(report: dict[str, Any], out: Path | None) -> None:
text = json.dumps(report, indent=2, sort_keys=True, default=str) + "\n"
if out is not None:
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(text, encoding="utf-8")
print(text, end="")
def _caller(args: argparse.Namespace, store: Any, model: str) -> Any:
"""The metered, cached caller; ``None`` client under --replay-only. Exits 2 without a ceiling or a key."""
from ..plane.budget import MeteredClient
+898
View File
@@ -0,0 +1,898 @@
"""A/B experiments over the decision engine and the plane (learned-decision-engine design section 6, Requirement 4).
Nothing is adopted on one run's number. An experiment is a **manifest** (``plane/experiments/<id>.yaml``) declared
before it runs: hypothesis, two or more **arms** (program variants: a compiled program id plus ``ProgramSpec``
overrides; or plane Run configurations as ``task_env``), the unit of randomisation, pairing, the resampling cluster,
the metrics, the minimum detectable effect, the sequential rule, the budget ceiling in USD, the families and the
folds, and a frozen **units file**. :func:`register` records the sha256 of the manifest and of the units file as an
``experiment`` row; :func:`run` and :func:`analyse` refuse a manifest that is not registered or whose digest differs
from the registered one, so a question cannot be changed after looking at an answer.
**Run.** Every unit runs in every arm (paired) in a seeded order of repositories; a seeded per-unit coin decides which
arm goes first, so provider drift over a run never aligns with arms. An arm is a :class:`.program.Program` over the
same memory and folds as the incumbent's evaluation; arm A replays the incumbent's cached responses at $0 when the
manifest says ``replay_only``, and a paid arm runs under its own ceiling. An arm with ``repeats: 2`` decides every
unit twice under different response-cache salts (the canary: do two runs agree?). Each decision is one
``arm_outcome`` row (unit, arm, replicate, score, verdict, usd); a stopped run resumes from the rows it has.
**Analyse.** Per-unit paired outcomes of arms A and B; the estimate is the paired difference B - A of a metric
(AUC, within-pair AUC, ADVISORY recall and precision, canary agreement, flag rate, usd per candidate) with a 95% CI
from a repository-cluster bootstrap (resampling repositories with the units inside them, since units of one
repository are correlated), an exact McNemar test on the discordant units beside it for binary outcomes, two looks
(half and all of the repositories, in the pre-shuffled order) with O'Brien-Fleming boundaries (|z| >= 2.797, then
1.977), and the adoption rule: **adopt B** when the final CI of the first primary excludes 0 in B's favour and no
other primary's CI excludes 0 against B; **reject B** when any primary's CI excludes 0 against B; for a cost-only
change (``test.cost_only``) **equivalent** when the TOST 90% CI of the detection difference lies within the margin
and the cost CI shows a saving; otherwise **inconclusive**, and the incumbent stays. The result is an
``experiment_result`` row and ``benchmarks/experiments/<id>/result.json`` (counts and intervals only).
"""
from __future__ import annotations
import hashlib
import json
import math
import random
import subprocess
from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import asdict, dataclass, field, fields, replace
from pathlib import Path
from typing import Any
import yaml
from ..plane.budget import BudgetExhausted
from ..plane.memory import MemoryStore, row_id
from .compile import load_program, spec_of
from .evaluate import candidate_from_example
from .examples import EXAMPLE_KIND, Example
from .folds import Fold, compile_split, folds_digest, outer_folds
from .program import Caller, Decision, Program, ProgramSpec
MANIFEST_DIR = Path("plane/experiments")
RESULT_DIR = Path("benchmarks/experiments")
EXPERIMENT_KIND = "experiment"
OUTCOME_KIND = "arm_outcome"
RESULT_KIND = "experiment_result"
REGISTER_TASK = "register"
POPULATION = "experiments" # the envelope of every experiment row; `split` is the experiment id
RUN_TASK = "run"
ARM_KINDS = ("program", "plane")
UNITS = ("candidate", "file", "repository")
PAIRINGS = ("paired", "assigned")
# O'Brien-Fleming boundaries for two equally spaced looks at alpha 0.05, two-sided (design section 6).
OBF_TWO_LOOKS: Mapping[float, float] = {0.5: 2.797, 1.0: 1.977}
RESAMPLES = 10_000
ALPHA = 0.05
TOST_MARGIN = 0.05
ADVISORY_VERDICT = "vulnerable"
# metric name -> (direction: +1 higher is better, -1 lower is better; binary per unit: McNemar applies)
METRICS: Mapping[str, tuple[int, bool]] = {
"auc": (1, False),
"within_pair_auc": (1, False),
"advisory_recall": (1, True),
"advisory_precision": (1, False),
"canary_agreement": (1, True),
"flag_rate": (1, True),
"usd_per_candidate": (-1, False),
}
VERDICTS = ("adopt", "reject", "equivalent", "inconclusive")
class ExperimentError(RuntimeError):
"""A manifest that cannot be run as declared: malformed, unregistered, modified, or not a program experiment."""
# --- the manifest ------------------------------------------------------------------------------------------------------
@dataclass(frozen=True)
class Arm:
name: str
label: str = ""
programs: Mapping[str, str] = field(default_factory=dict) # family -> compiled program id (program arms)
spec: Mapping[str, Any] = field(default_factory=dict) # ProgramSpec overrides, the declared difference
task_env: Mapping[str, str] = field(default_factory=dict) # plane arms: the Run's task environment
replay_only: bool = False
repeats: int = 1
budget_usd: float = 0.0
@dataclass(frozen=True)
class Metric:
name: str
label: int | None = None # restrict to units of this label (``flag_rate[label=1]``)
target: float | None = None # a level B must reach, reported beside the test
@classmethod
def parse(cls, text: str | Mapping[str, Any]) -> Metric:
if isinstance(text, Mapping):
return cls(str(text["name"]), text.get("label"), text.get("target"))
name, _, rest = str(text).partition("[")
label = None
if rest:
key, _, value = rest.rstrip("]").partition("=")
if key != "label":
raise ExperimentError(f"metric {text!r}: only a [label=0|1] selector is understood")
label = int(value)
return cls(name, label)
@property
def direction(self) -> int:
return METRICS[self.name][0]
@property
def binary(self) -> bool:
return METRICS[self.name][1]
def as_dict(self) -> dict[str, Any]:
return {k: v for k, v in asdict(self).items() if v is not None}
@dataclass(frozen=True)
class Manifest:
id: str
hypothesis: str
kind: str
arms: Mapping[str, Arm]
unit: str
pairing: str
cluster: str
seed: int
primary: tuple[Metric, ...]
secondary: tuple[Metric, ...]
alpha: float
resamples: int
cost_only: bool
tost_margin: float
mde: float
looks: tuple[float, ...]
boundaries: tuple[float, ...]
budget_total_usd: float
families: tuple[str, ...]
folds: Mapping[str, Any]
units_file: Path
programs: Mapping[str, str]
path: Path
digest: str
raw: Mapping[str, Any]
@property
def paid_arms(self) -> list[str]:
return [a for a, arm in self.arms.items() if not arm.replay_only]
def _digest(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def _boundaries(looks: Sequence[float], boundary: str, explicit: Sequence[float] | None) -> tuple[float, ...]:
if explicit is not None:
if len(explicit) != len(looks):
raise ExperimentError("stopping.boundaries must give one z per look")
return tuple(float(z) for z in explicit)
if boundary != "obrien_fleming":
raise ExperimentError(f"stopping.boundary {boundary!r}: only obrien_fleming, or explicit boundaries")
if tuple(looks) != (0.5, 1.0):
raise ExperimentError("obrien_fleming boundaries are tabulated for looks [0.5, 1.0] only; give stopping.boundaries")
return tuple(OBF_TWO_LOOKS[look] for look in looks)
def load_manifest(path: Path) -> Manifest:
"""Parse and validate a manifest; its ``digest`` is the sha256 of the file bytes (register and run compare it)."""
data = path.read_bytes()
raw = yaml.safe_load(data)
if not isinstance(raw, Mapping):
raise ExperimentError(f"{path}: a manifest is a mapping")
try:
kind = str(raw.get("kind", "program"))
if kind not in ARM_KINDS:
raise ExperimentError(f"{path}: kind {kind!r} (one of {', '.join(ARM_KINDS)})")
arms = {
str(name): Arm(
str(name),
str(spec.get("label", "")),
{str(k): str(v) for k, v in (spec.get("programs") or {}).items()},
dict(spec.get("spec") or {}),
{str(k): str(v) for k, v in (spec.get("task_env") or {}).items()},
bool(spec.get("replay_only", False)),
int(spec.get("repeats", 1)),
float(spec.get("budget_usd", 0.0)),
) # fmt: skip
for name, spec in dict(raw["arms"]).items()
}
if len(arms) < 2 or "A" not in arms or "B" not in arms:
raise ExperimentError(f"{path}: arms A and B are required (A is the incumbent)")
unknown = {k for arm in arms.values() for k in arm.spec} - {f.name for f in fields(ProgramSpec)}
if unknown:
raise ExperimentError(f"{path}: arm spec overrides {sorted(unknown)} are not ProgramSpec fields")
metric = dict(raw["metric"])
primary_raw = metric["primary"]
primary = tuple(Metric.parse(m) for m in (primary_raw if isinstance(primary_raw, list) else [primary_raw]))
secondary = tuple(Metric.parse(m) for m in metric.get("secondary", []))
for m in (*primary, *secondary):
if m.name not in METRICS:
raise ExperimentError(f"{path}: metric {m.name!r} (one of {', '.join(METRICS)})")
test = dict(raw.get("test") or {})
stopping = dict(raw.get("stopping") or {"looks": [1.0], "boundaries": [1.959964]})
looks = tuple(float(x) for x in stopping.get("looks", [0.5, 1.0]))
if looks[-1] != 1.0 or any(b <= a for a, b in zip(looks, looks[1:], strict=False)) or looks[0] <= 0:
raise ExperimentError(f"{path}: stopping.looks must increase to 1.0")
budget = raw["budget_usd"]
total = float(budget["total"] if isinstance(budget, Mapping) else budget)
arm_total = sum(a.budget_usd for a in arms.values())
if arm_total > total + 1e-9:
raise ExperimentError(f"{path}: the arms' ceilings ({arm_total}) exceed budget_usd.total ({total})")
unit = str(raw.get("unit", "candidate"))
pairing = str(raw.get("pairing", "paired"))
if unit not in UNITS or pairing not in PAIRINGS:
raise ExperimentError(f"{path}: unit in {UNITS} and pairing in {PAIRINGS}")
if "units_file" not in raw:
raise ExperimentError(f"{path}: units_file is required (the frozen list of units)")
manifest = Manifest(
id=str(raw["id"]), hypothesis=str(raw["hypothesis"]), kind=kind, arms=arms, unit=unit, pairing=pairing,
cluster=str(raw.get("cluster", "repository")), seed=int((raw.get("order") or {}).get("seed", 0)),
primary=primary, secondary=secondary, alpha=float(test.get("alpha", ALPHA)), resamples=int(test.get("resamples", RESAMPLES)),
cost_only=bool(test.get("cost_only", False)), tost_margin=float(test.get("tost_margin", TOST_MARGIN)),
mde=float(raw.get("mde", 0.0)), looks=looks,
boundaries=_boundaries(looks, str(stopping.get("boundary", "obrien_fleming")), stopping.get("boundaries")),
budget_total_usd=total, families=tuple(str(f) for f in raw.get("families", [])), folds=dict(raw.get("folds") or {}),
units_file=Path(str(raw["units_file"])), programs={str(k): str(v) for k, v in (raw.get("programs") or {}).items()},
path=path, digest=_digest(data), raw=raw,
) # fmt: skip
except KeyError as exc:
raise ExperimentError(f"{path}: missing {exc}") from exc
if manifest.id != path.stem:
raise ExperimentError(f"{path}: id {manifest.id!r} must equal the file stem")
if kind == "program" and not manifest.families:
raise ExperimentError(f"{path}: a program experiment names its families")
return manifest
# --- register ------------------------------------------------------------------------------------------------------------
def committed_at_head(path: Path, cwd: Path | None = None) -> str | None:
"""HEAD's commit when ``path`` is tracked and unchanged against HEAD, else ``None``."""
try:
tracked = subprocess.run(["git", "ls-files", "--error-unmatch", str(path)], cwd=cwd, capture_output=True, text=True)
changed = subprocess.run(["git", "diff", "--quiet", "HEAD", "--", str(path)], cwd=cwd, capture_output=True, text=True)
head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=cwd, capture_output=True, text=True)
except OSError:
return None
if tracked.returncode or changed.returncode or head.returncode:
return None
return head.stdout.strip() or None
def experiment_repo(experiment_id: str) -> str:
return f"experiments/{experiment_id}"
def registration_id(experiment_id: str) -> str:
return row_id(EXPERIMENT_KIND, f"experiment-{experiment_id}", REGISTER_TASK, experiment_id)
def registered(store: MemoryStore, experiment_id: str) -> dict[str, Any] | None:
rows = [r.row for r in store.rows(repo=experiment_repo(experiment_id), kind=EXPERIMENT_KIND) if r.row.get("task") == REGISTER_TASK]
return rows[0] if rows else None
def register(
store: MemoryStore,
manifest: Manifest,
*,
commit_of: Callable[[Path], str | None] | None = None,
created: str = "",
) -> dict[str, Any]:
"""Record the manifest's and the units file's digests as the ``experiment`` row. Refuses a manifest that is not
committed at HEAD, a units file that exists but is not committed, and a second registration under a different
digest (a changed question is a new experiment id). A units file that does not exist yet is recorded as
``pending``: the experiment cannot run until it is frozen and registered."""
check = commit_of or committed_at_head
commit = check(manifest.path)
if commit is None:
raise ExperimentError(f"{manifest.path} is not committed at HEAD: commit the manifest before registering it")
units_digest = ""
if manifest.units_file.exists():
if check(manifest.units_file) is None:
raise ExperimentError(f"{manifest.units_file} is not committed at HEAD: commit the frozen units before registering")
units_digest = _digest(manifest.units_file.read_bytes())
previous = registered(store, manifest.id)
if previous is not None and previous.get("manifest_digest") != manifest.digest:
raise ExperimentError(
f"{manifest.id} is registered under digest {str(previous.get('manifest_digest'))[:12]}; the file now hashes to "
f"{manifest.digest[:12]}. A changed manifest is a new experiment: give it a new id"
)
if previous is not None and previous.get("units_digest") and previous.get("units_digest") != units_digest:
raise ExperimentError(f"{manifest.id}: the units file changed after registration; a new units list is a new experiment")
row = {
"id": registration_id(manifest.id), "kind": EXPERIMENT_KIND, "repo": experiment_repo(manifest.id), "pin": commit,
"run": f"experiment-{manifest.id}", "task": REGISTER_TASK, "population": POPULATION, "split": manifest.id, "image": "host",
"experiment": manifest.id, "hypothesis": manifest.hypothesis, "manifest_path": str(manifest.path),
"manifest_digest": manifest.digest, "units_file": str(manifest.units_file), "units_digest": units_digest,
"units_status": "frozen" if units_digest else "pending", "arms": sorted(manifest.arms), "experiment_kind": manifest.kind,
"primary": [m.as_dict() for m in manifest.primary], "secondary": [m.as_dict() for m in manifest.secondary],
"mde": manifest.mde, "looks": list(manifest.looks), "boundaries": list(manifest.boundaries),
"budget_total_usd": manifest.budget_total_usd, "families": list(manifest.families), "created": created,
"status": "registered",
} # fmt: skip
store.put_row(row)
return row
def check_registered(store: MemoryStore, manifest: Manifest, *, need_units: bool = True) -> dict[str, Any]:
"""The registration row, or a refusal: unregistered, modified manifest, missing or modified units."""
row = registered(store, manifest.id)
if row is None:
raise ExperimentError(f"{manifest.id} is not registered: `ousast learn experiment register {manifest.path}` first")
if row.get("manifest_digest") != manifest.digest:
raise ExperimentError(f"{manifest.path} differs from the registered manifest ({str(row.get('manifest_digest'))[:12]}); refusing")
if need_units:
if not manifest.units_file.exists():
raise ExperimentError(f"{manifest.units_file} does not exist: freeze the units, commit, register again")
if not row.get("units_digest"):
raise ExperimentError(f"{manifest.id}: the units file was not registered (pending); register again with it committed")
if _digest(manifest.units_file.read_bytes()) != row["units_digest"]:
raise ExperimentError(f"{manifest.units_file} differs from the registered units; refusing")
return row
# --- units ---------------------------------------------------------------------------------------------------------------
@dataclass(frozen=True)
class Unit:
unit: str # the example id (program experiments) or the plane's unit key
group: str # the repository: the resampling cluster
family: str
label: int
fold: str = ""
pair: str = "" # the two sides of one candidate (vulnerable and fixed pin) share it; "" when unpaired
def as_dict(self) -> dict[str, Any]:
return asdict(self)
def read_units(path: Path) -> list[Unit]:
out = []
for line in path.read_text(encoding="utf-8").splitlines():
if line.strip():
d = json.loads(line)
out.append(
Unit(str(d["unit"]), str(d["group"]), str(d["family"]), int(d["label"]), str(d.get("fold", "")), str(d.get("pair", "")))
)
return out
def write_units(path: Path, units: Sequence[Unit]) -> str:
path.parent.mkdir(parents=True, exist_ok=True)
text = "".join(json.dumps(u.as_dict(), sort_keys=True) + "\n" for u in units)
path.write_text(text, encoding="utf-8")
return _digest(text.encode("utf-8"))
def program_folds(memory: Sequence[Example], manifest: Manifest) -> tuple[list[Fold], str]:
"""The incumbent evaluation's outer folds (compile split excluded) and their digest, from the manifest's ``folds``."""
seed = int(manifest.folds.get("seed", 0))
split = compile_split(memory, fraction=float(manifest.folds.get("compile_fraction", 0.25)), seed=seed)
folds = outer_folds(memory, k=int(manifest.folds.get("k", 5)), seed=seed, exclude=split.groups)
return folds, folds_digest(split, folds)
def freeze_units(store: MemoryStore, manifest: Manifest, memory: Sequence[Example]) -> list[Unit]:
"""The evaluation candidates of the manifest's families under the incumbent's outer folds, paired by candidate
across the two pins where both sides are in memory (no model; the list is then committed and registered)."""
if manifest.kind != "program":
raise ExperimentError(f"{manifest.id}: units of a plane experiment come from candidate generation, not the memory")
folds, _ = program_folds(memory, manifest)
sides: dict[str, tuple[str, str, str]] = {}
for record in store.rows(kind=EXAMPLE_KIND):
row = record.row
sides[str(row["id"])] = (str(row.get("repo", "")), str(row.get("candidate", "")), str(row.get("family", "")))
chosen = [(e, f) for f in folds if f.status == "ok" for e in memory if e.group in f.eval_groups and e.family in manifest.families]
by_side: dict[tuple[str, str, str], set[int]] = {}
for e, _ in chosen:
by_side.setdefault(sides.get(e.id, ("", e.id, e.family)), set()).add(e.label)
units = []
for e, f in chosen:
side = sides.get(e.id, ("", e.id, e.family))
pair = _digest("\x00".join(side).encode())[:16] if by_side[side] == {0, 1} and side[1] else ""
units.append(Unit(e.id, e.group, e.family, e.label, f.name, pair))
return sorted(units, key=lambda u: (u.group, u.family, u.unit))
# --- outcomes ------------------------------------------------------------------------------------------------------------
@dataclass(frozen=True)
class Outcome:
"""One decision: a unit under an arm, replicate ``r`` (0 is the run; 1 the canary re-run)."""
unit: str
group: str
family: str
label: int
pair: str
arm: str
replicate: int
s: float | None
verdict: str
usd: float | None = None
parse_failed: int = 0
order: int = 0 # which position the arm had for this unit (0 ran first)
@property
def flag(self) -> bool:
return self.verdict == ADVISORY_VERDICT
def outcome_id(experiment_id: str, arm: str, unit: str, replicate: int) -> str:
return row_id(OUTCOME_KIND, f"experiment-{experiment_id}", f"arm-{arm}", f"{unit}:{replicate}")
def outcome_row(experiment_id: str, pin: str, outcome: Outcome) -> dict[str, Any]:
return {
"id": outcome_id(experiment_id, outcome.arm, outcome.unit, outcome.replicate), "kind": OUTCOME_KIND,
"repo": experiment_repo(experiment_id), "pin": pin, "run": f"experiment-{experiment_id}", "task": f"arm-{outcome.arm}",
"population": POPULATION, "split": experiment_id, "image": "host", "experiment": experiment_id, **asdict(outcome),
} # fmt: skip
def outcome_from_row(row: Mapping[str, Any]) -> Outcome:
return Outcome(
str(row["unit"]), str(row["group"]), str(row["family"]), int(row["label"]), str(row.get("pair") or ""), str(row["arm"]),
int(row.get("replicate", 0)), None if row.get("s") is None else float(row["s"]), str(row.get("verdict") or ""),
None if row.get("usd") is None else float(row["usd"]), int(row.get("parse_failed", 0)), int(row.get("order", 0)),
) # fmt: skip
def load_outcomes(store: MemoryStore, experiment_id: str) -> list[Outcome]:
return [outcome_from_row(r.row) for r in store.rows(repo=experiment_repo(experiment_id), kind=OUTCOME_KIND)]
@dataclass(frozen=True)
class ArmUnit:
"""A unit under one arm with its replicates folded in: the run's score and verdict, the mean cost, and whether
the canary replicate agreed with the run."""
unit: str
group: str
family: str
label: int
pair: str
s: float | None
verdict: str
usd: float | None
agree: bool | None
@property
def flag(self) -> bool:
return self.verdict == ADVISORY_VERDICT
def arm_units(outcomes: Iterable[Outcome], arm: str) -> dict[str, ArmUnit]:
by_unit: dict[str, list[Outcome]] = {}
for o in outcomes:
if o.arm == arm:
by_unit.setdefault(o.unit, []).append(o)
out = {}
for unit, rows in by_unit.items():
rows.sort(key=lambda o: o.replicate)
first = rows[0]
costs = [o.usd for o in rows if o.usd is not None]
agree = None if len(rows) < 2 else rows[0].verdict == rows[1].verdict
out[unit] = ArmUnit(unit, first.group, first.family, first.label, first.pair, first.s, first.verdict,
sum(costs) / len(costs) if costs else None, agree) # fmt: skip
return out
def paired_units(outcomes: Iterable[Outcome], a: str = "A", b: str = "B") -> list[tuple[ArmUnit, ArmUnit]]:
"""Units decided in both arms (replicate 0), in unit order; a unit one arm skipped is not a pair."""
rows = list(outcomes)
left, right = arm_units(rows, a), arm_units(rows, b)
return [(left[u], right[u]) for u in sorted(left) if u in right]
# --- metrics -------------------------------------------------------------------------------------------------------------
def _mean(values: Sequence[float]) -> float | None:
return sum(values) / len(values) if values else None
def auc(rows: Sequence[ArmUnit]) -> float | None:
"""Mann-Whitney AUC of the score against the label; ties count one half; ``None`` without both labels."""
pos = sorted(r.s for r in rows if r.label == 1 and r.s is not None)
neg = sorted(r.s for r in rows if r.label == 0 and r.s is not None)
if not pos or not neg:
return None
wins = 0.0
for p in pos:
for n in neg:
wins += 1.0 if p > n else 0.5 if p == n else 0.0
return wins / (len(pos) * len(neg))
def within_pair_auc(rows: Sequence[ArmUnit]) -> float | None:
"""Of the candidate pairs (vulnerable and fixed side both present), the share where the vulnerable side scores
higher (ties one half)."""
pairs: dict[str, dict[int, float]] = {}
for r in rows:
if r.pair and r.s is not None:
pairs.setdefault(r.pair, {})[r.label] = r.s
done = [(p[1], p[0]) for p in pairs.values() if 0 in p and 1 in p]
if not done:
return None
return sum(1.0 if v > f else 0.5 if v == f else 0.0 for v, f in done) / len(done)
def metric_value(metric: Metric, rows: Sequence[ArmUnit]) -> float | None:
if metric.label is not None:
rows = [r for r in rows if r.label == metric.label]
name = metric.name
if name == "auc":
return auc(rows)
if name == "within_pair_auc":
return within_pair_auc(rows)
if name == "advisory_recall":
return _mean([float(r.flag) for r in rows if r.label == 1])
if name == "advisory_precision":
return _mean([float(r.label == 1) for r in rows if r.flag])
if name == "canary_agreement":
return _mean([float(r.agree) for r in rows if r.agree is not None])
if name == "flag_rate":
return _mean([float(r.flag) for r in rows])
if name == "usd_per_candidate":
return _mean([r.usd for r in rows if r.usd is not None])
raise ExperimentError(f"unknown metric {name!r}")
def binary_value(metric: Metric, row: ArmUnit) -> bool | None:
"""The per-unit binary outcome behind a binary metric (McNemar's cell), or ``None`` when it does not apply."""
if metric.label is not None and row.label != metric.label:
return None
if metric.name == "advisory_recall":
return row.flag if row.label == 1 else None
if metric.name == "canary_agreement":
return row.agree
if metric.name == "flag_rate":
return row.flag
return None
# --- statistics ----------------------------------------------------------------------------------------------------------
@dataclass(frozen=True)
class Estimate:
metric: str
a: float | None
b: float | None
diff: float | None
ci: tuple[float, float] | None # percentile bootstrap, two-sided at alpha
se: float | None
units: int
groups: int
resamples: int
def as_dict(self) -> dict[str, Any]:
return asdict(self)
def paired_bootstrap(
pairs: Sequence[tuple[ArmUnit, ArmUnit]], metric: Metric, *, resamples: int = RESAMPLES, seed: int = 0, alpha: float = ALPHA
) -> Estimate:
"""The paired difference B - A with a percentile CI from a cluster bootstrap over repositories: each resample draws
repositories with replacement and takes every paired unit inside them, in both arms."""
by_group: dict[str, list[tuple[ArmUnit, ArmUnit]]] = {}
for pair in pairs:
by_group.setdefault(pair[0].group, []).append(pair)
names = sorted(by_group)
a_all = metric_value(metric, [p[0] for p in pairs])
b_all = metric_value(metric, [p[1] for p in pairs])
diff = None if a_all is None or b_all is None else b_all - a_all
rng = random.Random(seed)
values: list[float] = []
for _ in range(resamples if names else 0):
sample = [p for g in (rng.choice(names) for _ in names) for p in by_group[g]]
a = metric_value(metric, [p[0] for p in sample])
b = metric_value(metric, [p[1] for p in sample])
if a is not None and b is not None:
values.append(b - a)
ci = se = None
if len(values) >= 2:
values.sort()
ci = (values[int(alpha / 2 * (len(values) - 1))], values[int((1 - alpha / 2) * (len(values) - 1))])
mean = sum(values) / len(values)
se = math.sqrt(sum((v - mean) ** 2 for v in values) / (len(values) - 1))
return Estimate(metric.name, a_all, b_all, diff, ci, se, len(pairs), len(names), len(values))
@dataclass(frozen=True)
class McNemar:
"""Exact McNemar: of the discordant pairs, ``b`` where only A has the outcome and ``c`` where only B has it;
the two-sided p-value of a binomial test with p = 0.5."""
b: int
c: int
concordant: int
p_value: float
def as_dict(self) -> dict[str, Any]:
return asdict(self)
def exact_mcnemar(table: Sequence[tuple[bool, bool]]) -> McNemar:
b = sum(1 for x, y in table if x and not y)
c = sum(1 for x, y in table if y and not x)
n = b + c
if n == 0:
return McNemar(b, c, len(table), 1.0)
k = min(b, c)
tail = sum(math.comb(n, i) for i in range(k + 1)) / 2**n
return McNemar(b, c, len(table) - n, min(1.0, 2 * tail))
def mcnemar_for(pairs: Sequence[tuple[ArmUnit, ArmUnit]], metric: Metric) -> McNemar | None:
if not metric.binary:
return None
table = []
for a, b in pairs:
x, y = binary_value(metric, a), binary_value(metric, b)
if x is not None and y is not None:
table.append((x, y))
return exact_mcnemar(table) if table else None
def look_order(groups: Iterable[str], seed: int) -> list[str]:
"""The pre-shuffled repository order the looks (and a run) follow."""
names = sorted(set(groups))
random.Random(seed).shuffle(names)
return names
@dataclass(frozen=True)
class Look:
fraction: float
groups: int
boundary: float
estimate: Estimate
z: float | None
crossed: bool
def as_dict(self) -> dict[str, Any]:
d = asdict(self)
d["estimate"] = self.estimate.as_dict()
return d
def sequential_looks(
pairs: Sequence[tuple[ArmUnit, ArmUnit]], metric: Metric, manifest: Manifest, *, resamples: int | None = None
) -> list[Look]:
"""The estimate at each look (the first ceil(fraction * groups) repositories in the pre-shuffled order) against its
O'Brien-Fleming boundary. A crossed boundary stops the sequence: later looks are not taken."""
order = look_order((p[0].group for p in pairs), manifest.seed)
out: list[Look] = []
n = resamples if resamples is not None else manifest.resamples
for fraction, boundary in zip(manifest.looks, manifest.boundaries, strict=True):
taken = set(order[: math.ceil(fraction * len(order))])
subset = [p for p in pairs if p[0].group in taken]
est = paired_bootstrap(subset, metric, resamples=n, seed=manifest.seed, alpha=manifest.alpha)
z = None if est.diff is None or not est.se else est.diff / est.se
# no bootstrap variance with a non-zero difference (every repository moved the same way) crosses any boundary
crossed = (z is not None and abs(z) >= boundary) or (est.se == 0 and bool(est.diff))
out.append(Look(fraction, len(taken), boundary, est, z, crossed))
if crossed:
break
return out
def excludes_zero(ci: tuple[float, float] | None, direction: int) -> str:
"""``favour`` when the CI lies on B's good side, ``against`` on the bad side, else ``spans``."""
if ci is None:
return "spans"
lo, hi = ci if direction > 0 else (-ci[1], -ci[0])
return "favour" if lo > 0 else "against" if hi < 0 else "spans"
def decide(manifest: Manifest, finals: Mapping[str, Estimate], cost: Estimate | None, tost: Estimate | None) -> tuple[str, str]:
"""The adoption rule (module docstring): verdict and the reason in one line."""
sides = {m.name: excludes_zero(finals[m.name].ci, m.direction) for m in manifest.primary}
if any(side == "against" for side in sides.values()):
bad = [n for n, side in sides.items() if side == "against"]
return "reject", f"the CI of {', '.join(bad)} excludes 0 against B"
first = manifest.primary[0].name
if sides[first] == "favour":
return "adopt", f"the CI of {first} excludes 0 in B's favour and no primary CI excludes 0 against B"
if manifest.cost_only and cost is not None and tost is not None and tost.ci is not None and cost.ci is not None:
within = -manifest.tost_margin <= tost.ci[0] and tost.ci[1] <= manifest.tost_margin
saving = cost.ci[1] < 0
if within and saving:
return "equivalent", f"the TOST 90% CI of {tost.metric} lies within +-{manifest.tost_margin} and the cost CI shows a saving"
return "inconclusive", f"the CI of {first} spans 0; the incumbent stays"
def analyse(
manifest: Manifest, outcomes: Sequence[Outcome], *, resamples: int | None = None, provenance: Mapping[str, Any] | None = None
) -> dict[str, Any]:
"""Counts and intervals only: per-metric final estimates, McNemar where the outcome is binary, the looks of the
first primary, the adoption verdict."""
pairs = paired_units(outcomes)
n = resamples if resamples is not None else manifest.resamples
finals: dict[str, Estimate] = {}
tests: dict[str, Any] = {}
for metric in (*manifest.primary, *manifest.secondary):
est = paired_bootstrap(pairs, metric, resamples=n, seed=manifest.seed, alpha=manifest.alpha)
finals[metric.name] = est
mc = mcnemar_for(pairs, metric)
tests[metric.name] = {
"estimate": est.as_dict(), "mcnemar": mc.as_dict() if mc else None, "direction": metric.direction,
"ci_vs_zero": excludes_zero(est.ci, metric.direction),
"target": None if metric.target is None else {"level": metric.target, "b_meets": est.b is not None and est.b >= metric.target},
} # fmt: skip
looks = sequential_looks(pairs, manifest.primary[0], manifest, resamples=n) if pairs else []
cost = finals.get("usd_per_candidate")
tost = None
if manifest.cost_only:
detection = next((m for m in (*manifest.primary, *manifest.secondary) if m.name != "usd_per_candidate"), None)
if detection is not None:
tost = paired_bootstrap(pairs, detection, resamples=n, seed=manifest.seed, alpha=0.10)
verdict, reason = decide(manifest, finals, cost, tost) if pairs else ("inconclusive", "no paired units")
arms = sorted({o.arm for o in outcomes})
spend = {arm: round(sum(o.usd or 0.0 for o in outcomes if o.arm == arm), 6) for arm in arms}
return {
"experiment": manifest.id, "manifest_digest": manifest.digest, "hypothesis": manifest.hypothesis,
"arms": {a: {"label": arm.label, "replay_only": arm.replay_only, "repeats": arm.repeats, "budget_usd": arm.budget_usd}
for a, arm in manifest.arms.items()},
"units": {"paired": len(pairs), "groups": len({p[0].group for p in pairs}), "outcomes": len(outcomes),
"by_label": {str(lab): sum(p[0].label == lab for p in pairs) for lab in (0, 1)}},
"primary": [m.as_dict() for m in manifest.primary], "secondary": [m.as_dict() for m in manifest.secondary],
"tests": tests, "looks": [look.as_dict() for look in looks],
"tost": None if tost is None else tost.as_dict(), "mde": manifest.mde, "alpha": manifest.alpha, "resamples": n,
"decision": verdict, "reason": reason, "spend_usd": spend, **dict(provenance or {}),
} # fmt: skip
def result_row(manifest: Manifest, pin: str, result: Mapping[str, Any]) -> dict[str, Any]:
tests = result["tests"]
return {
"id": row_id(RESULT_KIND, f"experiment-{manifest.id}", "analyse", manifest.id), "kind": RESULT_KIND,
"repo": experiment_repo(manifest.id), "pin": pin, "run": f"experiment-{manifest.id}", "task": "analyse", "population": POPULATION,
"split": manifest.id, "image": "host", "experiment": manifest.id, "manifest_digest": manifest.digest,
"decision": result["decision"], "reason": result["reason"], "units": result["units"], "spend_usd": result["spend_usd"],
"tests": {n: {"diff": t["estimate"]["diff"], "ci": t["estimate"]["ci"], "ci_vs_zero": t["ci_vs_zero"]} for n, t in tests.items()},
"looks": [{"fraction": look["fraction"], "z": look["z"], "crossed": look["crossed"]} for look in result["looks"]],
} # fmt: skip
# --- run -----------------------------------------------------------------------------------------------------------------
def arm_spec(manifest: Manifest, arm: Arm, family: str, store: MemoryStore) -> ProgramSpec:
"""The arm's program: the family's compiled incumbent, with the arm's declared overrides and nothing else."""
pid = arm.programs.get(family) or manifest.programs.get(family)
if pid is None:
raise ExperimentError(f"{manifest.id}: arm {arm.name} names no program for family {family!r}")
return replace(spec_of(load_program(store, pid)), **dict(arm.spec))
@dataclass
class RunSummary:
experiment: str
status: str # done | unfinished | stopped
units: int
decided: dict[str, int]
skipped: dict[str, int]
spend: dict[str, dict[str, Any]]
folds_digest: str
stopped_at_look: int | None = None
reason: str = ""
def as_dict(self) -> dict[str, Any]:
return asdict(self)
def run(
store: MemoryStore,
manifest: Manifest,
memory: Sequence[Example],
callers: Mapping[str, Caller],
excerpt_text: Callable[[str], str | None],
vectors: Mapping[str, Sequence[float]] | None = None,
*,
stop_early: bool = True,
resamples: int | None = None,
on_decision: Callable[[Outcome, Decision], None] | None = None,
) -> RunSummary:
"""Every unit in every arm, paired, repositories in the pre-shuffled look order, a seeded coin per unit for which
arm goes first; outcomes already in the store are not decided again. After the first look's repositories the
primary is tested against its boundary and, when crossed, the run stops. A ceiling ends the run ``unfinished``."""
if manifest.kind != "program":
raise ExperimentError(f"{manifest.id}: plane arms run as plane Runs; their outcomes arrive as arm_outcome rows")
row = check_registered(store, manifest)
units = read_units(manifest.units_file)
folds, fdigest = program_folds(memory, manifest)
by_name = {f.name: f for f in folds}
index = {e.id: e for e in memory}
for unit in units:
fold = by_name.get(unit.fold)
if fold is None or unit.group not in fold.eval_groups or unit.unit not in index:
raise ExperimentError(
f"{manifest.id}: unit {unit.unit[:12]} is not under fold {unit.fold!r} of this memory; the memory changed"
)
programs: dict[tuple[str, str], Program] = {}
for arm in manifest.arms.values():
for family in manifest.families:
programs[arm.name, family] = Program(arm_spec(manifest, arm, family, store), memory, excerpt_text, vectors)
candidate_of = candidate_from_example(excerpt_text, vectors)
have = {outcome_id(manifest.id, o.arm, o.unit, o.replicate) for o in load_outcomes(store, manifest.id)}
order = look_order((u.group for u in units), manifest.seed)
first_look = set(order[: math.ceil(manifest.looks[0] * len(order))]) if len(manifest.looks) > 1 else set()
rank = {g: i for i, g in enumerate(order)}
decided = {a: 0 for a in manifest.arms}
skipped = {a: 0 for a in manifest.arms}
status, stopped_at, reason = "done", None, ""
rng = random.Random(manifest.seed)
arm_names = sorted(manifest.arms)
fold_seed = int(manifest.folds.get("seed", 0))
try:
for unit in sorted(units, key=lambda u: (rank[u.group], u.family, u.unit)):
if stop_early and first_look and unit.group not in first_look: # the walk has passed every first-look repository
looks = sequential_looks(
paired_units(load_outcomes(store, manifest.id)), manifest.primary[0], manifest, resamples=resamples
)
if looks and looks[0].crossed:
status, stopped_at, reason = "stopped", 1, f"the first look crossed its boundary (z = {looks[0].z:.3f})"
break
first_look = set() # tested once
first = rng.random() < 0.5
ordered = arm_names if first else list(reversed(arm_names))
for position, name in enumerate(ordered):
arm = manifest.arms[name]
for r in range(arm.repeats):
if outcome_id(manifest.id, name, unit.unit, r) in have:
skipped[name] += 1
continue
candidate = candidate_of(index[unit.unit])
if candidate is None:
raise ExperimentError(f"{manifest.id}: unit {unit.unit[:12]} has no excerpt in the store")
caller = callers[name]
caller.salt = f"r{r}:" if r else ""
before = caller.usd() or 0.0
try:
decision = programs[name, unit.family].decide(candidate, by_name[unit.fold], caller, seed=fold_seed)
finally:
caller.salt = ""
usd = round((caller.usd() or 0.0) - before, 6) if caller.usd() is not None else None
outcome = Outcome(unit.unit, unit.group, unit.family, unit.label, unit.pair, name, r, decision.s, decision.verdict, usd,
decision.parse_failed, position) # fmt: skip
store.put_row(outcome_row(manifest.id, str(row["pin"]), outcome))
have.add(outcome_id(manifest.id, name, unit.unit, r))
decided[name] += 1
if on_decision is not None:
on_decision(outcome, decision)
except BudgetExhausted as exc:
status, reason = "unfinished", str(exc)
spend = {
name: {"usd": c.usd(), "client_calls": c.calls, "replayed": c.replayed, "cache_digest": c.cache_digest()}
for name, c in callers.items()
}
summary = RunSummary(manifest.id, status, len(units), decided, skipped, spend, fdigest, stopped_at, reason)
store.put_row({
"id": row_id(EXPERIMENT_KIND, f"experiment-{manifest.id}", RUN_TASK, manifest.id), "kind": EXPERIMENT_KIND,
"repo": experiment_repo(manifest.id), "pin": str(row["pin"]), "run": f"experiment-{manifest.id}", "task": RUN_TASK,
"population": POPULATION, "split": manifest.id, "image": "host", "experiment": manifest.id,
"manifest_digest": manifest.digest, **summary.as_dict(),
}) # fmt: skip
return summary
__all__ = [
"ALPHA", "ARM_KINDS", "EXPERIMENT_KIND", "MANIFEST_DIR", "METRICS", "OBF_TWO_LOOKS", "OUTCOME_KIND", "RESAMPLES", "RESULT_DIR",
"RESULT_KIND", "TOST_MARGIN", "VERDICTS", "Arm", "ArmUnit", "Estimate", "ExperimentError", "Look", "Manifest", "McNemar", "Metric",
"Outcome", "RunSummary", "Unit", "analyse", "arm_spec", "arm_units", "auc", "check_registered", "committed_at_head", "decide",
"exact_mcnemar", "excludes_zero", "freeze_units", "load_manifest", "load_outcomes", "look_order", "mcnemar_for", "metric_value",
"outcome_row", "paired_bootstrap", "paired_units", "program_folds", "read_units", "register", "registered", "result_row", "run",
"sequential_looks", "within_pair_auc", "write_units",
] # fmt: skip
+50 -15
View File
@@ -28,7 +28,7 @@ from __future__ import annotations
import hashlib
import json
import re
from collections.abc import Callable, Mapping, Sequence
from collections.abc import Callable, Collection, Mapping, Sequence
from dataclasses import asdict, dataclass, field, replace
from typing import Any
@@ -42,6 +42,11 @@ from .schema import DEFAULT_INPUTS, features_for, instruments_for, validate_x, w
VERDICTS = ("vulnerable", "not_vulnerable", "unsure")
UNSURE = "unsure"
TEMPERATURES = (0.0, 0.7)
# How the k samples differ: by temperature (the default: sample 1 at 0, samples 2..k at 0.7 over one retrieved
# example set), or by the retrieved examples (every sample at temperature 0, each over a disjoint example set --
# the variance-reduction arm of experiment exp-002; design section 6, engine-level experiments).
SAMPLING = ("temperature", "retrieval_ensemble")
RETRIEVAL_ENSEMBLE = "retrieval_ensemble"
MAX_OUTPUT_TOKENS = 300
CHARS_PER_TOKEN = 4.0
STATE_WORDS = {"none": "no coverage", "failed": "failed", "not_applicable": "not applicable"}
@@ -199,6 +204,7 @@ class ProgramSpec:
priors: str = "off"
inputs: str = DEFAULT_INPUTS # the input profile: which instruments the classifier never sees (v1: the engine)
max_output_tokens: int = MAX_OUTPUT_TOKENS
sampling: str = SAMPLING[0] # temperature | retrieval_ensemble (an experiment arm's setting; never compiled)
@dataclass(frozen=True)
@@ -363,6 +369,7 @@ class Caller:
replayed: int = 0
keys: list[str] = field(default_factory=list)
usage: dict[str, int] = field(default_factory=lambda: {"prompt_tokens": 0, "prompt_cache_hit_tokens": 0, "completion_tokens": 0})
salt: str = "" # prefixed to every sample label: a replicate of a run that must not replay the first run's responses
_memory: dict[str, dict[str, Any]] = field(default_factory=dict)
@property
@@ -379,7 +386,7 @@ class Caller:
return None
def ask(self, messages: list[dict[str, object]], *, temperature: float, sample: str, json_object: bool = True) -> str:
key = request_key(self.model, self.params_digest, messages, temperature, sample, json_object)
key = request_key(self.model, self.params_digest, messages, temperature, f"{self.salt}{sample}", json_object)
self.keys.append(key)
entry = self._cached(key)
if entry is not None:
@@ -445,16 +452,21 @@ def aggregate(answers: Sequence[Answer]) -> Decision:
) # fmt: skip
def sample_answer(prompt: Prompt, caller: Caller, *, temperature: float, sample: str, families: Sequence[str] = ()) -> Answer:
"""One sample; one retry at temperature 0 when the answer does not parse, then ``PARSE_FAILED``."""
answer = parse_answer(caller.ask(prompt.messages, temperature=temperature, sample=sample), prompt.valid_lines, families)
if answer is None:
retry = caller.ask(prompt.messages, temperature=TEMPERATURES[0], sample=f"{sample}:retry")
answer = parse_answer(retry, prompt.valid_lines, families) or PARSE_FAILED
return answer
def classify(prompt: Prompt, caller: Caller, k: int = 1, *, families: Sequence[str] = ()) -> Decision:
"""``k`` samples (the first at temperature 0, the rest at 0.7); one retry at temperature 0 per unparsable answer."""
answers: list[Answer] = []
for j in range(k):
temperature = TEMPERATURES[0] if j == 0 else TEMPERATURES[1]
answer = parse_answer(caller.ask(prompt.messages, temperature=temperature, sample=str(j)), prompt.valid_lines, families)
if answer is None:
retry = caller.ask(prompt.messages, temperature=TEMPERATURES[0], sample=f"{j}:retry")
answer = parse_answer(retry, prompt.valid_lines, families) or PARSE_FAILED
answers.append(answer)
answers = [
sample_answer(prompt, caller, temperature=TEMPERATURES[0] if j == 0 else TEMPERATURES[1], sample=str(j), families=families)
for j in range(k)
]
return aggregate(answers)
@@ -471,25 +483,48 @@ class Program:
def __post_init__(self) -> None:
self.index = {e.id: e for e in self.memory}
def prepare(self, candidate: Candidate, fold: Fold, *, seed: int = 0) -> tuple[Prompt, Retrieval]:
def prepare(
self, candidate: Candidate, fold: Fold, *, seed: int = 0, exclude: Collection[str] = frozenset()
) -> tuple[Prompt, Retrieval]:
target = candidate.target
got = retrieve(target, self.memory, fold, vectors=self.vectors, n1=self.spec.n1, k=self.spec.k_ret, lam=self.spec.lam, seed=seed,
priors=self.spec.priors, inputs=self.spec.inputs) # fmt: skip
priors=self.spec.priors, inputs=self.spec.inputs, exclude=exclude) # fmt: skip
demos = demonstrations_for([d.as_dict() for d in self.spec.demos], self.index, target, fold, vectors=self.vectors)
usable = [Demo.from_dict(d) for d in demos]
prompt = render(self.spec, candidate, got.examples, usable, self.index, self.excerpt_text)
assert_boundary([*prompt.example_ids, *prompt.demo_ids], self.index, target, fold, vectors=self.vectors)
return prompt, got
def prepare_ensemble(self, candidate: Candidate, fold: Fold, *, seed: int = 0) -> list[tuple[Prompt, Retrieval]]:
"""``k`` prompts over disjoint retrieved example sets (the same demonstrations and prefix); a later set is
``short`` when the eligible pool runs out, never padded with an example an earlier set used."""
out: list[tuple[Prompt, Retrieval]] = []
used: set[str] = set()
for _ in range(self.spec.k):
prompt, got = self.prepare(candidate, fold, seed=seed, exclude=frozenset(used))
used.update(prompt.example_ids)
out.append((prompt, got))
return out
def decide(self, candidate: Candidate, fold: Fold, caller: Caller, *, seed: int = 0) -> Decision:
if self.spec.sampling == RETRIEVAL_ENSEMBLE:
prepared = self.prepare_ensemble(candidate, fold, seed=seed)
answers = [
sample_answer(prompt, caller, temperature=TEMPERATURES[0], sample=str(j), families=self.families)
for j, (prompt, _) in enumerate(prepared)
]
got = prepared[0][1]
return replace(aggregate(answers), retrieval=got.mode, neighbours=got.neighbours)
if self.spec.sampling != SAMPLING[0]:
raise ValueError(f"unknown sampling {self.spec.sampling!r} (one of {', '.join(SAMPLING)})")
prompt, got = self.prepare(candidate, fold, seed=seed)
decision = classify(prompt, caller, self.spec.k, families=self.families)
return replace(decision, retrieval=got.mode, neighbours=got.neighbours)
__all__ = [
"ANSWER_FORMAT", "BASELINE_INSTRUCTION", "PARSE_FAILED", "SIGNATURE", "UNSURE", "VERDICTS", "Answer", "Caller", "Candidate",
"Decision", "Demo",
"ANSWER_FORMAT", "BASELINE_INSTRUCTION", "PARSE_FAILED", "RETRIEVAL_ENSEMBLE", "SAMPLING", "SIGNATURE", "UNSURE", "VERDICTS",
"Answer", "Caller", "Candidate", "Decision", "Demo",
"Program", "ProgramSpec", "Prompt", "ReplayMiss", "aggregate", "classify", "label_word", "line_numbers",
"params_digest", "parse_answer", "render", "render_prefix", "render_roles", "render_signals", "request_key",
"params_digest", "parse_answer", "render", "render_prefix", "render_roles", "render_signals", "request_key", "sample_answer",
] # fmt: skip
+5 -3
View File
@@ -28,7 +28,7 @@ repeats the check on the ids it actually rendered (:func:`assert_boundary`).
from __future__ import annotations
import hashlib
from collections.abc import Iterable, Mapping, Sequence
from collections.abc import Collection, Iterable, Mapping, Sequence
from dataclasses import dataclass
from typing import Any
@@ -192,9 +192,11 @@ def retrieve(
seed: int = 0,
priors: str = "off",
inputs: str = DEFAULT_INPUTS,
exclude: Collection[str] = frozenset(),
) -> Retrieval:
"""The ``k`` balanced examples for ``target`` from the eligible part of ``pool`` (module docstring)."""
candidates = [e for e in pool if e.profile == target.profile and eligible(e, target, fold)]
"""The ``k`` balanced examples for ``target`` from the eligible part of ``pool`` (module docstring). ``exclude``
holds example ids a caller has already used: the retrieval-ensemble sampling draws ``k`` disjoint sets with it."""
candidates = [e for e in pool if e.id not in exclude and e.profile == target.profile and eligible(e, target, fold)]
ranked_all = sorted(
((gower(target.x, target.instruments, e.x, e.instruments, target.profile, priors=priors, inputs=inputs), e) for e in candidates),
key=lambda pair: (pair[0], _tie(pair[1].id, seed)),
+2 -1
View File
@@ -159,9 +159,10 @@ def callers_line(rows: Sequence[Mapping[str, Any]], cap: int = CALLERS_CAP) -> s
def hunt_prompt(path: str, group: Sequence[Candidate], family: str, facts: Mapping[str, Any] | None) -> str:
lines = []
callers_on = os.environ.get("OUSAST_VERIFY_CALLERS", "on") != "off" # arm B of exp-001 (design section 6) turns the line off
for _, function, line in group:
lines.append(f"- function `{function}` around line {line}")
known = callers_line(callers_of(facts, path, function))
known = callers_line(callers_of(facts, path, function)) if callers_on else ""
if known:
lines.append(f" {known}")
listing = "\n".join(lines)