flakeval 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
flakeval/stats.py ADDED
@@ -0,0 +1,254 @@
1
+ """Core statistics for flakeval.
2
+
3
+ Data model: a `Scores` object holds an (n_items x n_runs) matrix. Each cell is
4
+ the score of one eval item on one repeated run of the same system.
5
+
6
+ Two sources of noise affect an eval score:
7
+ * run noise - the same item gives different scores across runs
8
+ (sampling temperature, batching, API drift)
9
+ * item sampling - your eval set is a finite sample of all possible questions
10
+
11
+ Confidence intervals come from a bootstrap over ITEMS only, using each item's mean score across runs.
12
+ Run noise is already inside those item means, so also resampling runs would count it twice (we measured
13
+ ~25% too-wide intervals that way). The run-noise share of the uncertainty is estimated separately, from the
14
+ within-item variance of the runs.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field
19
+
20
+ import numpy as np
21
+
22
+ _CHUNK = 500 # bootstrap replicates per vectorised batch (keeps memory small)
23
+
24
+
25
+ @dataclass
26
+ class Scores:
27
+ ids: list[str]
28
+ x: np.ndarray # shape (n_items, n_runs)
29
+
30
+ def __post_init__(self) -> None:
31
+ self.x = np.asarray(self.x, dtype=float)
32
+ if self.x.ndim != 2:
33
+ raise ValueError("scores must be a 2-D (items x runs) matrix")
34
+ if len(self.ids) != self.x.shape[0]:
35
+ raise ValueError("ids length must match number of rows")
36
+
37
+ @property
38
+ def n_items(self) -> int:
39
+ return self.x.shape[0]
40
+
41
+ @property
42
+ def n_runs(self) -> int:
43
+ return self.x.shape[1]
44
+
45
+ @property
46
+ def mean(self) -> float:
47
+ return float(self.x.mean())
48
+
49
+ def subset(self, ids: list[str]) -> "Scores":
50
+ pos = {i: j for j, i in enumerate(self.ids)}
51
+ return Scores(list(ids), self.x[[pos[i] for i in ids]])
52
+
53
+
54
+ # --------------------------------------------------------------------------- #
55
+ # single system
56
+ # --------------------------------------------------------------------------- #
57
+ @dataclass
58
+ class Summary:
59
+ n_items: int
60
+ n_runs: int
61
+ mean: float
62
+ ci: tuple[float, float]
63
+ se_total: float
64
+ se_run: float
65
+ run_noise_share: float # fraction of variance of the mean due to run noise
66
+ flaky_fraction: float # items whose score is not constant across runs
67
+ flaky_items: list[tuple[str, float]] = field(default_factory=list) # (id, std)
68
+ warnings: list[str] = field(default_factory=list)
69
+
70
+
71
+ def _boot_item_means(v: np.ndarray, B: int, rng: np.random.Generator) -> np.ndarray:
72
+ """Bootstrap the mean of per-item values v (shape (n,)) by resampling items."""
73
+ n = len(v)
74
+ out = np.empty(B)
75
+ done = 0
76
+ while done < B:
77
+ b = min(_CHUNK * 4, B - done)
78
+ out[done : done + b] = v[rng.integers(0, n, size=(b, n))].mean(axis=1)
79
+ done += b
80
+ return out
81
+
82
+
83
+ def _run_noise_var_of_mean(x: np.ndarray) -> float:
84
+ """Variance of the overall mean that is due to run-to-run noise: sum_i s_i^2 / k / n^2."""
85
+ n, k = x.shape
86
+ return float(x.var(axis=1, ddof=1).sum() / k / n**2)
87
+
88
+
89
+ def _constant_hint(v: float) -> str:
90
+ if v == 1.0:
91
+ return " If 1 is the best possible score, this test is too easy to tell systems apart (a ceiling effect)."
92
+ if v == 0.0:
93
+ return " If 0 is the worst possible score, this test is too hard to tell systems apart (a floor effect)."
94
+ return ""
95
+
96
+
97
+ def _constant_message(v: float) -> str:
98
+ """Explain the case where every single score is the same value."""
99
+ return f"Every score was {v:g} in every run.{_constant_hint(v)} Use harder, more varied, or more questions."
100
+
101
+
102
+ def summarize(s: Scores, B: int = 4000, seed: int = 0, top_flaky: int = 10) -> Summary:
103
+ rng = np.random.default_rng(seed)
104
+ warnings: list[str] = []
105
+ item_means = s.x.mean(axis=1)
106
+ total = _boot_item_means(item_means, B, rng)
107
+ se_total = float(total.std(ddof=1))
108
+ if s.n_runs > 1:
109
+ se_run = float(np.sqrt(_run_noise_var_of_mean(s.x)))
110
+ else:
111
+ se_run = float("nan")
112
+ warnings.append("Only 1 run per item: run-to-run noise cannot be measured. Use >= 3 runs.")
113
+ share = min(1.0, se_run**2 / se_total**2) if se_total > 0 and not np.isnan(se_run) else float("nan")
114
+
115
+ std = s.x.std(axis=1, ddof=1) if s.n_runs > 1 else np.zeros(s.n_items)
116
+ flaky_mask = std > 1e-12
117
+ if s.n_runs > 1 and not flaky_mask.any():
118
+ if np.all(s.x == s.x.flat[0]):
119
+ warnings.append(_constant_message(float(s.x.flat[0])))
120
+ else:
121
+ warnings.append(
122
+ "No item changed score across runs. The runs look identical (fixed sampling seed, temperature 0, "
123
+ "caching, or a very confident model), so run-to-run noise is NOT being measured here."
124
+ )
125
+ order = np.argsort(-std)[:top_flaky]
126
+ flaky = [(s.ids[i], float(std[i])) for i in order if flaky_mask[i]]
127
+ lo, hi = np.percentile(total, [2.5, 97.5])
128
+ return Summary(
129
+ n_items=s.n_items,
130
+ n_runs=s.n_runs,
131
+ mean=s.mean,
132
+ ci=(float(lo), float(hi)),
133
+ se_total=se_total,
134
+ se_run=se_run,
135
+ run_noise_share=float(share),
136
+ flaky_fraction=float(flaky_mask.mean()),
137
+ flaky_items=flaky,
138
+ warnings=warnings,
139
+ )
140
+
141
+
142
+ # --------------------------------------------------------------------------- #
143
+ # paired comparison A -> B
144
+ # --------------------------------------------------------------------------- #
145
+ @dataclass
146
+ class Comparison:
147
+ n_items: int
148
+ runs_a: int
149
+ runs_b: int
150
+ mean_a: float
151
+ mean_b: float
152
+ delta: float # B - A
153
+ ci: tuple[float, float]
154
+ p_value: float
155
+ se_total: float
156
+ se_run: float
157
+ run_noise_share: float
158
+ mde: float # min. detectable difference at 80% power, alpha=0.05
159
+ verdict: str
160
+ advice: str
161
+ warnings: list[str] = field(default_factory=list)
162
+
163
+
164
+ def compare(a: Scores, b: Scores, B: int = 10000, seed: int = 0) -> Comparison:
165
+ rng = np.random.default_rng(seed)
166
+ ids = sorted(set(a.ids) & set(b.ids))
167
+ warnings: list[str] = []
168
+ if len(ids) < 2:
169
+ raise ValueError("need at least 2 shared item ids to compare systems")
170
+ dropped = (a.n_items - len(ids)) + (b.n_items - len(ids))
171
+ if dropped:
172
+ warnings.append(f"{dropped} item(s) not present in both files were ignored (paired comparison).")
173
+ xa, xb = a.subset(ids).x, b.subset(ids).x
174
+ n = len(ids)
175
+ ka, kb = xa.shape[1], xb.shape[1]
176
+
177
+ d_items = xb.mean(axis=1) - xa.mean(axis=1) # per-item paired difference of mean scores
178
+ d_total = _boot_item_means(d_items, B, rng)
179
+
180
+ delta = float(xb.mean() - xa.mean())
181
+ lo, hi = np.percentile(d_total, [2.5, 97.5])
182
+ identical = bool(np.array_equal(xa, xb))
183
+ same_constant = identical and bool(np.all(xa == xa.flat[0]))
184
+ for name, x in (("A", xa), ("B", xb)):
185
+ if x.shape[1] > 1 and np.all(x == x[:, [0]]) and not same_constant:
186
+ if np.all(x == x.flat[0]):
187
+ warnings.append(f"System {name}: " + _constant_message(float(x.flat[0])))
188
+ else:
189
+ warnings.append(
190
+ f"System {name}: no item changed score across runs. Its runs look identical (fixed seed, "
191
+ "temperature 0, caching?), so run-to-run noise is NOT being measured for it."
192
+ )
193
+ # p-value: paired sign-flip permutation test. Null hypothesis: A and B are the same system, so within each
194
+ # item the labels are exchangeable and the sign of every item difference is arbitrary.
195
+ hits, done = 0, 0
196
+ while done < B:
197
+ m = min(_CHUNK * 4, B - done)
198
+ signs = rng.choice((-1.0, 1.0), size=(m, n))
199
+ hits += int((np.abs((signs * d_items).mean(axis=1)) >= abs(delta) - 1e-12).sum())
200
+ done += m
201
+ p = float((hits + 1) / (B + 1))
202
+ se_total = float(d_total.std(ddof=1))
203
+ if ka > 1 and kb > 1:
204
+ var_run = (xa.var(axis=1, ddof=1) / ka + xb.var(axis=1, ddof=1) / kb).sum() / n**2
205
+ se_run = float(np.sqrt(var_run))
206
+ share = min(1.0, se_run**2 / se_total**2) if se_total > 0 else float("nan")
207
+ else:
208
+ se_run, share = float("nan"), float("nan")
209
+ warnings.append("Only 1 run for at least one system: run-to-run noise cannot be measured. Use >= 3 runs each.")
210
+ mde = 2.8 * se_total # (z_.975 + z_.80) * SE
211
+
212
+ eps = 1e-9
213
+ if identical:
214
+ verdict = "IDENTICAL RESULTS"
215
+ if same_constant:
216
+ v = float(xa.flat[0])
217
+ advice = (f"Both systems scored {v:g} on every question in every run.{_constant_hint(v)} "
218
+ "Use harder, more varied, or more questions.")
219
+ else:
220
+ advice = (
221
+ "Every item scored exactly the same in A and B. If you expected a difference, check that the setting "
222
+ "you changed actually reached the model (seed, temperature, model name, caching)."
223
+ )
224
+ elif (lo > eps or hi < -eps) and p < 0.05:
225
+ verdict = f"REAL {'IMPROVEMENT' if delta > 0 else 'REGRESSION'}"
226
+ advice = "The 95% interval excludes zero after accounting for run noise and question sampling."
227
+ else:
228
+ verdict = "WITHIN NOISE"
229
+ if se_total == 0:
230
+ advice = "No measurable variation in the difference; the systems behave almost identically on these items."
231
+ elif np.isnan(share):
232
+ advice = f"Cannot separate noise sources. This eval can only detect differences larger than ~{mde:.3f}."
233
+ elif share > 0.6:
234
+ advice = (
235
+ f"Most uncertainty is run-to-run noise ({share:.0%}). Running each item more times "
236
+ f"will tighten the interval. Currently detectable difference: ~{mde:.3f}."
237
+ )
238
+ elif share >= 0.4:
239
+ advice = (
240
+ f"Uncertainty is split between run-to-run noise ({share:.0%}) and eval-set size ({1 - share:.0%}). "
241
+ f"Both more runs and more items would help. Currently detectable difference: ~{mde:.3f}."
242
+ )
243
+ else:
244
+ advice = (
245
+ f"Most uncertainty comes from the limited number of eval items ({1 - share:.0%}). "
246
+ f"More runs will not help much; add more items. Currently detectable difference: ~{mde:.3f}."
247
+ )
248
+ return Comparison(
249
+ n_items=n, runs_a=ka, runs_b=kb,
250
+ mean_a=float(xa.mean()), mean_b=float(xb.mean()), delta=delta,
251
+ ci=(float(lo), float(hi)), p_value=p,
252
+ se_total=se_total, se_run=se_run, run_noise_share=float(share),
253
+ mde=float(mde), verdict=verdict, advice=advice, warnings=warnings,
254
+ )
flakeval/study.py ADDED
@@ -0,0 +1,311 @@
1
+ """The engine behind the template: load your data, ask each system your questions several times, score the
2
+ answers, and report whether the differences are real. Nothing here needs editing; users write a small settings file
3
+ (see `flakeval init`) or build a settings dictionary and call run_study().
4
+
5
+ from flakeval.study import default_config, run_study
6
+ cfg = default_config()
7
+ cfg.update(data_path="my.csv", input_column="question", expected_column="answer", systems=[...])
8
+ run_study(cfg, yes=True)
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import csv
14
+ import difflib
15
+ import json
16
+ import random
17
+ import sys
18
+ import threading
19
+ import time
20
+ from collections.abc import Mapping
21
+ from pathlib import Path
22
+
23
+ from .cli import format_comparison, format_summary
24
+ from .io import load_jsonl, repeat, save_jsonl
25
+ from .models import call_seed, make_caller
26
+ from .progress import Progress, hms
27
+ from .scoring import SCORER_HELP, SCORERS, question_hash, score_output
28
+ from .stats import Scores, compare, summarize
29
+
30
+
31
+ # ---------------------------------------------------------------- configuration
32
+ # The names a user writes in their settings file (left) and the keys the engine uses internally (right).
33
+ SETTING_NAMES = {
34
+ "DATA_PATH": "data_path", "INPUT_COLUMN": "input_column", "EXPECTED_COLUMN": "expected_column",
35
+ "ID_COLUMN": "id_column", "MAX_ITEMS": "max_items", "ITEM_SEED": "item_seed", "SCORER": "scorer",
36
+ "SYSTEMS": "systems", "RUNS": "runs", "MAX_TOKENS": "max_tokens", "WORKERS": "workers",
37
+ "RUN_A_A_TEST": "run_a_a_test", "OUT_DIR": "out_dir", "SAVE_OUTPUTS": "save_outputs",
38
+ }
39
+ DEFAULTS = dict(
40
+ data_path=None, input_column=None, expected_column=None, id_column=None, max_items=None, item_seed=0,
41
+ scorer="contains", custom_score=None, systems=None, runs=5, max_tokens=300, workers=1, run_a_a_test=True,
42
+ out_dir="my_results", save_outputs=True,
43
+ )
44
+ REQUIRED = {"data_path": "DATA_PATH", "input_column": "INPUT_COLUMN", "systems": "SYSTEMS"}
45
+
46
+
47
+ def default_config() -> dict:
48
+ """A fresh settings dictionary holding every default. Fill in the required keys and pass it to run_study()."""
49
+ return dict(DEFAULTS)
50
+
51
+
52
+ def complete_config(cfg: dict, need_systems: bool = True) -> dict:
53
+ """Fill in defaults and check the settings, with plain-English errors.
54
+ (Loading data alone does not need SYSTEMS, so prepare_items() passes need_systems=False.)"""
55
+ unknown = sorted(set(cfg) - set(DEFAULTS))
56
+ if unknown:
57
+ raise SystemExit(f"Unknown setting(s): {unknown}. Known settings: {sorted(DEFAULTS)}")
58
+ merged = {**DEFAULTS, **cfg}
59
+ if merged["scorer"] not in SCORERS: # fail now, not after hours of model calls
60
+ raise SystemExit(f"Unknown SCORER {merged['scorer']!r}. Choose one of:\n{SCORER_HELP}")
61
+ if merged["scorer"] == "custom" and not callable(merged["custom_score"]):
62
+ raise SystemExit('SCORER = "custom" needs a function called custom_score(output, expected, row) in your file.')
63
+ for key, label in REQUIRED.items():
64
+ if key == "systems" and not need_systems:
65
+ continue
66
+ if merged[key] in (None, "", []):
67
+ raise SystemExit(f"Missing required setting {label}.")
68
+ return merged
69
+
70
+
71
+ def config_from_settings(settings: Mapping) -> dict:
72
+ """Read the ALL-CAPS settings (plus a function called custom_score) from a settings file's namespace.
73
+
74
+ Typically called as run_cli(globals()). A name that looks like a misspelled setting (for example RUN instead
75
+ of RUNS) stops the run with a hint, because silently using the default would waste a long run."""
76
+ known = list(SETTING_NAMES)
77
+ for name in settings:
78
+ if name.isupper() and not name.startswith("_") and name not in SETTING_NAMES:
79
+ close = difflib.get_close_matches(name, known, n=1, cutoff=0.8)
80
+ if close:
81
+ raise SystemExit(f"Unknown setting {name}. Did you mean {close[0]}? (If {name} is your own variable, "
82
+ f"rename it so it does not look like a setting, for example _{name}.)")
83
+ cfg = {key: settings[name] for name, key in SETTING_NAMES.items() if name in settings}
84
+ if callable(settings.get("custom_score")):
85
+ cfg["custom_score"] = settings["custom_score"]
86
+ return complete_config(cfg)
87
+
88
+
89
+ # ---------------------------------------------------------------- data
90
+ def load_rows(path: str) -> list[dict]:
91
+ p = Path(path)
92
+ if not p.exists():
93
+ raise SystemExit(f"Data file not found: {path} (check DATA_PATH; it is relative to where you run the script)")
94
+ suffix = p.suffix.lower()
95
+ if suffix == ".jsonl":
96
+ rows = [json.loads(line) for line in p.read_text(encoding="utf-8").splitlines() if line.strip()]
97
+ elif suffix == ".csv":
98
+ with open(p, newline="", encoding="utf-8-sig") as f:
99
+ rows = list(csv.DictReader(f))
100
+ else:
101
+ raise SystemExit(f"Data file must end in .csv or .jsonl, got: {path}")
102
+ if not rows:
103
+ raise SystemExit(f"Data file is empty: {path}")
104
+ return rows
105
+
106
+
107
+ def prepare_items(cfg: dict) -> dict[str, dict]:
108
+ cfg = complete_config(cfg, need_systems=False)
109
+ rows = load_rows(cfg["data_path"])
110
+ available = list(rows[0].keys())
111
+ for label, col in (("INPUT_COLUMN", cfg["input_column"]), ("EXPECTED_COLUMN", cfg["expected_column"]),
112
+ ("ID_COLUMN", cfg["id_column"])):
113
+ if col is not None and col not in available:
114
+ raise SystemExit(f"{label} = {col!r} is not a column in your data. Columns found: {available}")
115
+ items: dict[str, dict] = {}
116
+ for i, row in enumerate(rows):
117
+ item_id = str(row[cfg["id_column"]]) if cfg["id_column"] else f"row{i}"
118
+ if item_id in items:
119
+ raise SystemExit(f"Duplicate id {item_id!r}. ID_COLUMN must be unique per row (or set it to None).")
120
+ expected = row[cfg["expected_column"]] if cfg["expected_column"] else None
121
+ items[item_id] = {"id": item_id, "input": str(row[cfg["input_column"]]), "expected": expected, "row": row}
122
+ if cfg["max_items"] and cfg["max_items"] < len(items):
123
+ keep = random.Random(cfg["item_seed"]).sample(sorted(items), cfg["max_items"])
124
+ items = {k: items[k] for k in sorted(keep)}
125
+ return items
126
+
127
+
128
+ # ---------------------------------------------------------------- running
129
+ def evaluate_system(system: dict, name: str, items: dict, cfg: dict, sampling_seed: int, runs: int, save: bool,
130
+ progress: "Progress | None" = None):
131
+ caller = make_caller(system, cfg["max_tokens"])
132
+ template = system.get("prompt_template", "{input}")
133
+ records, lock = [], threading.Lock()
134
+ out_dir = Path(cfg["out_dir"])
135
+
136
+ def score(item: dict, run_idx: int) -> float:
137
+ prompt = template.replace("{input}", item["input"])
138
+ answer = caller(prompt, system.get("system_prompt") or "", system.get("temperature"),
139
+ call_seed(sampling_seed, item["id"], run_idx))
140
+ value = score_output(cfg["scorer"], answer, item["expected"], item["row"], cfg.get("custom_score"))
141
+ with lock:
142
+ records.append({"item_id": item["id"], "run": run_idx, "score": value, "answer": answer,
143
+ "question_hash": question_hash(item["input"])})
144
+ if progress:
145
+ progress.advance()
146
+ return value
147
+
148
+ path = out_dir / f"{name}.jsonl" if save else None
149
+ scores = repeat(score, items, runs=runs, workers=cfg["workers"], save=path, with_run=True)
150
+ if save and cfg["save_outputs"]:
151
+ with open(out_dir / f"{name}.outputs.jsonl", "w", encoding="utf-8") as f:
152
+ for r in sorted(records, key=lambda r: (r["item_id"], r["run"])):
153
+ f.write(json.dumps(r, ensure_ascii=False) + "\n")
154
+ return scores, records
155
+
156
+
157
+ def shorten(text: str, head: int = 150, tail: int = 250) -> str:
158
+ """Show the start AND the end of a long answer: with step-by-step answers the final result is at the end."""
159
+ if len(text) <= head + tail:
160
+ return repr(text)
161
+ return f"{text[:head]!r} ... [{len(text) - head - tail} characters not shown] ... {text[-tail:]!r}"
162
+
163
+
164
+ def dry_run(cfg: dict, items: dict) -> None:
165
+ print("DRY RUN: 1 run on the first 2 questions per system. Nothing is saved.\n")
166
+ sample = dict(list(items.items())[:2])
167
+ for system in cfg["systems"]:
168
+ print(f"=== system {system['name']!r} ===")
169
+ _, records = evaluate_system(system, system["name"], sample, {**cfg, "workers": 1}, 0, 1, save=False)
170
+ for r in sorted(records, key=lambda r: r["item_id"]):
171
+ item = items[r["item_id"]]
172
+ print(f"question: {item['input'][:200]}\nexpected: {item['expected']}\nanswer: {shorten(r['answer'])}\n"
173
+ f"score: {r['score']} (scorer = {cfg['scorer']})\n")
174
+ print("If the answers and scores look right, run again without --dry-run.")
175
+
176
+
177
+ def run_study(cfg: dict, dry: bool = False, yes: bool = False) -> str:
178
+ """Run every system on every question RUNS times, then print and save the report. Returns the report text."""
179
+ cfg = complete_config(cfg)
180
+ items = prepare_items(cfg)
181
+ systems = cfg["systems"]
182
+ if len(systems) < 2:
183
+ raise SystemExit("SYSTEMS needs at least two entries: a baseline and something to compare it with.")
184
+ names = [s["name"] for s in systems]
185
+ if len(set(names)) != len(names):
186
+ raise SystemExit("Each system needs a different 'name'.")
187
+ if dry:
188
+ dry_run(cfg, items)
189
+ return ""
190
+
191
+ out_dir = Path(cfg["out_dir"])
192
+ jobs = [(s, s["name"], 0) for s in systems]
193
+ if cfg["run_a_a_test"]:
194
+ jobs.append((systems[0], f"{systems[0]['name']}_repeat", 1)) # same system, independent random seeds
195
+ todo = [j for j in jobs if not (out_dir / f"{j[1]}.jsonl").exists()]
196
+ calls = len(items) * cfg["runs"] * len(todo)
197
+ print(f"{len(items)} questions x {cfg['runs']} runs x {len(todo)} system runs to do = {calls} model calls")
198
+ for _, name, _ in jobs:
199
+ if (out_dir / f"{name}.jsonl").exists():
200
+ print(f" skipping {name}: {out_dir / (name + '.jsonl')} already exists (delete it to redo)")
201
+ if todo and not yes:
202
+ if not sys.stdin.isatty():
203
+ raise SystemExit("Not running interactively: pass --yes to confirm the number of calls above.")
204
+ if input("Proceed? [y/N] ").strip().lower() != "y":
205
+ raise SystemExit("Cancelled.")
206
+
207
+ progress = Progress(calls) if todo else None
208
+ try:
209
+ for k, (system, name, seed) in enumerate(todo, 1):
210
+ progress.start_system(name, k, len(todo), len(items) * cfg["runs"])
211
+ t0 = time.time()
212
+ scores, _ = evaluate_system(system, name, items, cfg, seed, cfg["runs"], save=True, progress=progress)
213
+ progress.message(f" {name}: mean score {scores.mean:.3f} ({hms(time.time() - t0)})")
214
+ finally:
215
+ if progress:
216
+ progress.close()
217
+
218
+ return write_report(cfg, names)
219
+
220
+
221
+ def write_report(cfg: dict, names: list[str]) -> str:
222
+ out_dir = Path(cfg["out_dir"])
223
+ baseline = load_jsonl(out_dir / f"{names[0]}.jsonl")
224
+ lines = ["=" * 72, f"flakeval report: baseline = {names[0]}", "=" * 72, "",
225
+ format_summary(names[0], summarize(baseline)), ""]
226
+ comparisons = [(f"{names[0]}_repeat", "A/A test: the baseline against itself (should be WITHIN NOISE)")] \
227
+ if cfg["run_a_a_test"] else []
228
+ comparisons += [(n, f"{names[0]} vs {n}") for n in names[1:]]
229
+ for name, title in comparisons:
230
+ block = format_comparison(compare(baseline, load_jsonl(out_dir / f"{name}.jsonl")))
231
+ if name.endswith("_repeat") and "IDENTICAL RESULTS" in block:
232
+ block += ("\nNote: for this A/A test, identical results are the best possible outcome. The baseline gave the "
233
+ "same mark on every question in both runs, so on these questions chance did not change any score. "
234
+ "(It also means this data cannot show run-to-run noise; harder questions or more of them might.)")
235
+ lines += [f"--- {title} ---", block, ""]
236
+ report = "\n".join(lines)
237
+ (out_dir / "report.txt").write_text(report + "\n", encoding="utf-8")
238
+ print("\n" + report)
239
+ print(f"Saved to {out_dir}/ (report.txt, one .jsonl per system" +
240
+ (", and .outputs.jsonl with raw answers)" if cfg["save_outputs"] else ")"))
241
+ return report
242
+
243
+
244
+ def rescore_study(cfg: dict) -> str:
245
+ """Re-mark the SAVED raw answers with the current scorer and rebuild the report. Calls no model.
246
+ Fixing a scorer or a wrong expected answer is fine; editing the question text is refused (the answers no longer fit)."""
247
+ cfg = complete_config(cfg)
248
+ items = prepare_items(cfg)
249
+ out_dir = Path(cfg["out_dir"])
250
+ names = [s["name"] for s in cfg["systems"]]
251
+ jobs = names + ([f"{names[0]}_repeat"] if cfg["run_a_a_test"] else [])
252
+ # extra same-configuration repeats (for example added by noise_diagnostics.py replicate) are re-marked too
253
+ suffix = ".outputs.jsonl"
254
+ jobs += [n for n in sorted(p.name[: -len(suffix)] for p in out_dir.glob(f"{names[0]}_repeat*{suffix}")) if n not in jobs]
255
+ for name in jobs:
256
+ path = out_dir / f"{name}.outputs.jsonl"
257
+ if not path.exists():
258
+ raise SystemExit(f"{path} not found. --rescore needs the saved raw answers (SAVE_OUTPUTS = True) from an "
259
+ f"earlier run with the same OUT_DIR and system names.")
260
+ records = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
261
+ before = sum(r["score"] for r in records) / len(records)
262
+ by_item: dict[str, dict[int, float]] = {}
263
+ for r in records:
264
+ if r["item_id"] not in items:
265
+ raise SystemExit(f"Item {r['item_id']!r} in {path.name} is not in your current data. Is DATA_PATH / "
266
+ f"ID_COLUMN / MAX_ITEMS the same as in the original run?")
267
+ item = items[r["item_id"]]
268
+ if r.get("question_hash") and r["question_hash"] != question_hash(item["input"]):
269
+ raise SystemExit(f"Question {r['item_id']!r} has been edited since this run, so its saved answers belong "
270
+ f"to the old wording. Rescoring would mix them up; delete {out_dir}/ and run again.")
271
+ r["score"] = score_output(cfg["scorer"], r["answer"], item["expected"], item["row"], cfg.get("custom_score"))
272
+ by_item.setdefault(r["item_id"], {})[int(r["run"])] = r["score"]
273
+ ids = sorted(by_item)
274
+ k = min(len(by_item[i]) for i in ids)
275
+ scores = Scores(ids, [[by_item[i][run] for run in sorted(by_item[i])[:k]] for i in ids])
276
+ save_jsonl(scores, out_dir / f"{name}.jsonl")
277
+ with open(path, "w", encoding="utf-8") as f:
278
+ for r in records:
279
+ f.write(json.dumps(r, ensure_ascii=False) + "\n")
280
+ print(f" {name}: mean score {before:.3f} -> {scores.mean:.3f}")
281
+ print()
282
+ return write_report(cfg, names)
283
+
284
+
285
+ # ---------------------------------------------------------------- command line for a settings file
286
+ def run_cli(settings: Mapping, argv: list[str] | None = None) -> None:
287
+ """The last line of a settings file: `run_cli(globals())`.
288
+
289
+ Adds --dry-run, --yes and --rescore, plus quick overrides of a few settings so you can try something small
290
+ without editing the file: --max-items, --runs, --workers, --out-dir and --scorer."""
291
+ p = argparse.ArgumentParser(description="Compare your models/prompts on your data with flakeval.")
292
+ p.add_argument("--dry-run", action="store_true", help="try 2 questions once per system and print what happens")
293
+ p.add_argument("--yes", action="store_true", help="skip the confirmation question before the real run")
294
+ p.add_argument("--rescore", action="store_true",
295
+ help="re-mark the saved answers with your current scorer and rebuild the report (no model calls)")
296
+ p.add_argument("--max-items", type=int, help="override MAX_ITEMS: use only a random subset of this many questions")
297
+ p.add_argument("--runs", type=int, help="override RUNS: times each question is asked per system")
298
+ p.add_argument("--workers", type=int, help="override WORKERS: parallel requests")
299
+ p.add_argument("--out-dir", help="override OUT_DIR: where results are written")
300
+ p.add_argument("--scorer", choices=SCORERS, help="override SCORER (for example to re-mark saved answers)")
301
+ a = p.parse_args(argv)
302
+ cfg = config_from_settings(settings)
303
+ for flag, key in (("max_items", "max_items"), ("runs", "runs"), ("workers", "workers"),
304
+ ("out_dir", "out_dir"), ("scorer", "scorer")):
305
+ if getattr(a, flag) is not None:
306
+ cfg[key] = getattr(a, flag)
307
+ cfg = complete_config(cfg)
308
+ if a.rescore:
309
+ rescore_study(cfg)
310
+ else:
311
+ run_study(cfg, dry=a.dry_run, yes=a.yes)