File size: 13,709 Bytes
1c08abf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
"""Repeat harness runs k times per configuration with injected provenance.

This script can make real, PAID API calls. The free path is narrow: a *custom*
entry run with a mock config (provider.default: mock). It is the only path
exercised in CI. langchain entries always build a real ChatOpenAI/ChatAnthropic
from their --provider regardless of any config, so a mock config does NOT make
them free; and a custom entry without a mock config uses its real provider.
run_config_epochs therefore refuses any paid run unless allow_paid is set
(--allow-paid on the CLI), so invoking this script directly cannot silently
spend money -- the Makefile epochs target is not the only guard (guardrail 2).
Provenance is injected by post-processing each entry point's --output JSON into
an envelope file (design spec section 6); harness internals are never edited.
"""

import argparse
import datetime
import hashlib
import json
import os
import subprocess
import sys
from pathlib import Path

import yaml

# Repo root on path so the preflight can import agent_bench when this file is
# run as a script (python scripts/run_epochs.py puts scripts/ on sys.path[0],
# not the repo root). The heavy work still happens in subprocesses.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"

PROVIDER_KEY_ENV = {"openai": "OPENAI_API_KEY", "anthropic": "ANTHROPIC_API_KEY"}

# Provider -> model recorded in langchain config_id (audit #7) AND imported by
# run_langchain_eval to build the client, so the recorded provenance and the
# model that actually bills are the same constant. Do not duplicate it.
MODEL_DEFAULTS = {"openai": "gpt-4o-mini", "anthropic": "claude-haiku-4-5-20251001"}


def _is_mock_config(path: Path | None) -> bool:
    """True only if the config actually sets provider.default: mock.

    The free-path guard must verify config CONTENT, not just that --mock-config
    was passed: a real config handed to --mock-config would otherwise be
    classified free and bill silently (paid-path audit finding #6).
    """
    if path is None:
        return False
    data = yaml.safe_load(Path(path).read_text()) or {}
    return bool(data.get("provider", {}).get("default") == "mock")


def new_ulid() -> str:
    ts = int(datetime.datetime.now(tz=datetime.timezone.utc).timestamp() * 1000)
    time_part = ""
    for _ in range(10):
        time_part = CROCKFORD[ts % 32] + time_part
        ts //= 32
    rand = int.from_bytes(os.urandom(10), "big")
    rand_part = ""
    for _ in range(16):
        rand_part = CROCKFORD[rand % 32] + rand_part
        rand //= 32
    return time_part + rand_part


def write_envelope(
    raw_output: Path,
    dest_dir: Path,
    run_id: str,
    config_id: str,
    epoch: int,
    code_version: str,
    dataset_version: str,
    timestamp: str,
) -> Path:
    dest_dir.mkdir(parents=True, exist_ok=True)
    envelope = {
        "run_id": run_id,
        "timestamp": timestamp,
        "config_id": config_id,
        "code_version": code_version,
        "dataset_version": dataset_version,
        "epoch": epoch,
        "results": json.loads(raw_output.read_text()),
    }
    out = dest_dir / f"{config_id.split('+')[0]}_e{epoch}.json"
    out.write_text(json.dumps(envelope, indent=1))
    return out


def _config_hash(path: Path | None) -> str:
    if path is None:
        return "00000000"
    return hashlib.sha256(path.read_bytes()).hexdigest()[:8]


def _dataset_version(golden_path: Path) -> str:
    return "sha-" + hashlib.sha256(golden_path.read_bytes()).hexdigest()[:8]


def _config_id(name: str, spec: dict, mock_config: Path | None) -> str:
    if spec["entry"] == "langchain":
        # Encode the real model so langchain-openai/-anthropic carry meaningful
        # provenance instead of the constant +00000000 sentinel (audit #7); the
        # model is what actually bills. config_id is free-form, schema untouched.
        return f"{name}+{MODEL_DEFAULTS[spec['provider']]}"
    return f"{name}+{_config_hash(mock_config or spec.get('config'))}"


def _code_version() -> str:
    sha = subprocess.run(
        ["git", "rev-parse", "--short", "HEAD"], capture_output=True, text=True, check=True
    ).stdout.strip()
    dirty = subprocess.run(
        ["git", "status", "--porcelain"], capture_output=True, text=True, check=True
    ).stdout.strip()
    return sha + ("-dirty" if dirty else "")


# name -> (entry, config yaml or None, provider flag or None, corpus, golden path)
REGISTRY: dict[str, dict] = {
    "custom-openai": {
        "entry": "custom",
        "config": Path("configs/default.yaml"),
        "corpus": "fastapi",
        "golden": Path("agent_bench/evaluation/datasets/tech_docs_golden.json"),
    },
    "custom-anthropic": {
        "entry": "custom",
        "config": Path("configs/anthropic.yaml"),
        "corpus": "fastapi",
        "golden": Path("agent_bench/evaluation/datasets/tech_docs_golden.json"),
    },
    "langchain-openai": {
        "entry": "langchain",
        "provider": "openai",
        "golden": Path("agent_bench/evaluation/datasets/tech_docs_golden.json"),
    },
    "langchain-anthropic": {
        "entry": "langchain",
        "provider": "anthropic",
        "golden": Path("agent_bench/evaluation/datasets/tech_docs_golden.json"),
    },
    # --- k8s corpus (campaign executed 2026-06-22, paid) --------------------
    # The full 25-question k8s campaign has run: K=5 per config, one run_id each.
    # Envelopes live under results/epochs/ (gitignored, force-added like the
    # fastapi run); tidy rows in results/long/k8s/; the report's k8s section
    # regenerates with `make evaluate-stats`. langchain has no --corpus flag (see
    # _entry_cmd), so k8s is custom-only and the framework-equivalence (TOST)
    # section is empty by construction, not a regression. Re-run with:
    #   make epochs K=5 CONFIGS=custom-openai-k8s,custom-anthropic-k8s CONFIRM_PAID=1
    "custom-openai-k8s": {
        "entry": "custom",
        "config": Path("configs/default.yaml"),
        "corpus": "k8s",
        "golden": Path("agent_bench/evaluation/datasets/k8s_golden.json"),
    },
    "custom-anthropic-k8s": {
        "entry": "custom",
        "config": Path("configs/anthropic.yaml"),
        "corpus": "k8s",
        "golden": Path("agent_bench/evaluation/datasets/k8s_golden.json"),
    },
}


def _entry_cmd(spec: dict, raw_out: Path, mock_config: Path | None) -> list[str]:
    config = mock_config or spec.get("config")
    if spec["entry"] == "custom":
        cmd = [
            sys.executable,
            "scripts/evaluate.py",
            "--mode",
            "deterministic",
            "--output",
            str(raw_out),
        ]
        if config:
            cmd += ["--config", str(config)]
        if spec.get("corpus"):
            cmd += ["--corpus", spec["corpus"]]
        return cmd
    cmd = [
        sys.executable,
        "scripts/run_langchain_eval.py",
        "--provider",
        spec["provider"],
        "--output",
        str(raw_out),
    ]
    if config:
        cmd += ["--config", str(config)]
    return cmd


def run_config_epochs(
    name: str,
    k: int,
    dest_root: Path,
    mock_config: Path | None = None,
    golden_override: Path | None = None,
    allow_paid: bool = False,
) -> list[Path]:
    spec = REGISTRY[name]
    # Guardrail 2 (no silent paid calls): the only free path is a custom entry
    # with a mock config. langchain entries always use a real LLM; a custom
    # entry without a mock config uses its real provider. Refuse otherwise so
    # direct script invocation cannot bill around the Makefile's CONFIRM_PAID.
    if mock_config is not None and not _is_mock_config(mock_config):
        raise SystemExit(
            f"refusing: --mock-config {mock_config} does not set provider.default: mock, "
            "so it would make real (paid) API calls. Pass an actual mock config, "
            "or --allow-paid to confirm you intend to spend money."
        )
    is_free = spec["entry"] == "custom" and mock_config is not None
    if not is_free and not allow_paid:
        raise SystemExit(
            f"refusing: config {name!r} would make real (paid) API calls "
            f"(entry={spec['entry']}, mock_config={'set' if mock_config else 'none'}); "
            "pass --allow-paid to confirm you intend to spend money"
        )
    golden = golden_override or spec["golden"]
    config_id = _config_id(name, spec, mock_config)
    run_id = new_ulid()
    written = []
    for epoch in range(1, k + 1):
        # Raw harness output lives UNDER the run dir, not a sibling results/epochs/raw/,
        # so results/epochs/ contains only run_id dirs. The WP5 convert loop globs
        # results/epochs/*/ and would otherwise feed raw EvalResult lists (no envelope
        # wrapper) to convert_envelopes and crash (audit #4).
        raw_out = dest_root / run_id / "raw" / f"{name}_e{epoch}.json"
        raw_out.parent.mkdir(parents=True, exist_ok=True)
        subprocess.run(_entry_cmd(spec, raw_out, mock_config), check=True)
        written.append(
            write_envelope(
                raw_output=raw_out,
                dest_dir=dest_root / run_id,
                run_id=run_id,
                config_id=config_id,
                epoch=epoch,
                code_version=_code_version(),
                dataset_version=_dataset_version(golden),
                timestamp=datetime.datetime.now(tz=datetime.timezone.utc).isoformat(),
            )
        )
    return written


def _preflight(names: list[str], mock_config: Path | None) -> None:
    """Validate every requested config BEFORE any subprocess runs, so a broken
    config fails at zero cost instead of after earlier configs have already
    billed (paid-path audit finding #2). Checks the registry name, that a custom
    entry's corpus resolves in its config, that the golden file and corpus store
    exist, and that the API key for any paying entry is present in os.environ
    (load_dotenv has already run in main()).
    """
    from agent_bench.core.config import load_config

    problems: list[str] = []
    for name in names:
        spec = REGISTRY.get(name)
        if spec is None:
            problems.append(f"{name}: unknown config (known: {sorted(REGISTRY)})")
            continue
        if not Path(spec["golden"]).exists():
            problems.append(f"{name}: golden dataset missing: {spec['golden']}")
        if spec["entry"] == "custom":
            cfg_path = mock_config or spec.get("config")
            try:
                cfg = load_config(Path(cfg_path) if cfg_path else None)
            except Exception as exc:
                problems.append(f"{name}: config {cfg_path} failed to load ({exc})")
                continue
            provider = cfg.provider.default
            corpus = spec.get("corpus")
            if corpus and corpus not in cfg.corpora:
                problems.append(
                    f"{name}: corpus {corpus!r} not in {cfg_path} corpora "
                    f"{sorted(cfg.corpora)}; evaluate.py would exit 1 mid-run"
                )
            elif corpus and not Path(cfg.corpora[corpus].store_path).exists():
                problems.append(
                    f"{name}: corpus {corpus!r} store {cfg.corpora[corpus].store_path} "
                    "missing; build it (make ingest) before the campaign"
                )
        else:
            provider = spec["provider"]
        free = spec["entry"] == "custom" and _is_mock_config(mock_config)
        if not free and provider != "mock":
            env = PROVIDER_KEY_ENV.get(provider)
            if env and not os.environ.get(env):
                problems.append(f"{name}: {env} not set; a real {provider} run needs it")
    if problems:
        raise SystemExit(
            "preflight failed (no API calls made); fix before spending:\n  - "
            + "\n  - ".join(problems)
        )


def main() -> None:
    from dotenv import load_dotenv

    # Load the gitignored .env so the provider key reads (os.environ, in the
    # subprocesses) and the preflight key check below see the keys (audit #3).
    load_dotenv()
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--k", type=int, required=True)
    parser.add_argument("--configs", required=True, help="comma-separated registry names")
    parser.add_argument("--dest", default="results/epochs")
    parser.add_argument(
        "--mock-config", default=None, help="config YAML forcing provider mock (free)"
    )
    parser.add_argument("--golden", default=None, help="override golden path (tests only)")
    parser.add_argument(
        "--allow-paid",
        action="store_true",
        help="confirm real (paid) API calls for non-mock runs (langchain or no mock config)",
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="run preflight only: validate every config, make no API calls, spend nothing",
    )
    args = parser.parse_args()
    names = args.configs.split(",")
    mock_config = Path(args.mock_config) if args.mock_config else None
    _preflight(names, mock_config=mock_config)
    if args.dry_run:
        print(f"preflight OK for {names}; --dry-run, no API calls made")
        return
    for name in names:
        files = run_config_epochs(
            name,
            args.k,
            Path(args.dest),
            mock_config=mock_config,
            golden_override=Path(args.golden) if args.golden else None,
            allow_paid=args.allow_paid,
        )
        print(f"{name}: wrote {len(files)} epoch envelopes")


if __name__ == "__main__":
    main()