File size: 25,169 Bytes
ef19228
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
"""Evaluate ReturnGuard on the paired dataset (Dataset/pairs.csv).



Positive class = fraud (the returned item is not the delivered unit).

A return counts as *flagged* when the pipeline does not auto-accept it

(HUMAN_REVIEW or REJECT): the refund does not go out automatically.



Reports detection metrics (precision, recall, F1, F0.5), ablations (the original

v1 embedding gate, local evidence only, full multi-agent pipeline) and agentic

metrics (LLM calls per return, schema validity, retries, per-agent accuracy,

tokens, cost, latency). Results go to results/eval_results.json, which the

website reads, plus a Markdown report and a per-pair CSV.



    python scripts/evaluate.py                 # cached LLM answers are reused

    python scripts/evaluate.py --fresh         # ignore the LLM cache

    python scripts/evaluate.py --model gemini-3.5-flash

"""
import argparse
import csv
import json
import math
import os
import statistics
import subprocess
import sys
import time
from collections import Counter, defaultdict
from datetime import datetime, timedelta, timezone
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))

parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--model", help="Gemini model for all agents (default: config.LLM_MODEL)")
parser.add_argument("--fresh", action="store_true", help="do not reuse cached LLM answers")
parser.add_argument("--limit", type=int, help="only the first N pairs (smoke test)")
parser.add_argument("--exclude-items", default="", help="comma-separated items to leave out, e.g. headphones_blue,headphones_black")
parser.add_argument("--no-baseline", action="store_true", help="skip the v1 baseline")
parser.add_argument("--out", default=str(ROOT / "results"), help="output folder")
args = parser.parse_args()

os.environ["RG_LLM_CACHE"] = "0" if args.fresh else "1"
os.environ["RG_DATA_DIR"] = str(ROOT / ".cache" / "eval_data")   # empty history: every customer is new
if args.model:
    os.environ["RG_LLM_MODEL"] = args.model

from returnguard import config, imaging, llm, pipeline  # noqa: E402  (env must be set first)
from returnguard.agents import decision, identity  # noqa: E402
from returnguard.embeddings import view_features  # noqa: E402

DATASET = ROOT / "Dataset"
CATEGORY = {"speaker": "electronics", "headphones": "electronics", "shaker": "home_kitchen", "bottle": "home_kitchen"}
# Added to the dataset after thresholds and evidence weights were frozen on the first 6 items
HELD_OUT_ITEMS = ["headphones_blue", "headphones_black"]
# USD per 1M tokens (input, output incl. thinking), Gemini API paid-tier list prices
PRICES = {"gemini-2.5-flash": (0.30, 2.50)}


def safe_div(a, b):
    return a / b if b else 0.0


def wilson(k: int, n: int, z: float = 1.96) -> list[float]:
    """95% Wilson score interval for a proportion k/n. With few samples, a 100% is

    really '100% with a lower bound well below it', and the interval says how far."""
    if n == 0:
        return [0.0, 0.0]
    p, d = k / n, 1 + z * z / n
    centre = (p + z * z / (2 * n)) / d
    half = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / d
    return [round(max(0.0, centre - half), 4), round(min(1.0, centre + half), 4)]


def binary_metrics(y_true: list[bool], y_pred: list[bool]) -> dict:
    tp = sum(t and p for t, p in zip(y_true, y_pred))
    fp = sum(not t and p for t, p in zip(y_true, y_pred))
    fn = sum(t and not p for t, p in zip(y_true, y_pred))
    tn = sum(not t and not p for t, p in zip(y_true, y_pred))
    precision, recall = safe_div(tp, tp + fp), safe_div(tp, tp + fn)

    def f_beta(beta):
        b2 = beta * beta
        return safe_div((1 + b2) * precision * recall, b2 * precision + recall)

    return {"tp": tp, "fp": fp, "fn": fn, "tn": tn, "precision": round(precision, 4), "recall": round(recall, 4),
            "f1": round(f_beta(1), 4), "f0_5": round(f_beta(0.5), 4), "accuracy": round(safe_div(tp + tn, len(y_true)), 4),
            "false_rejection_rate": round(safe_div(fp, fp + tn), 4),
            "precision_ci": wilson(tp, tp + fp), "recall_ci": wilson(tp, tp + fn),
            "false_rejection_rate_ci": wilson(fp, fp + tn)}


def auc(scores_pos: list[float], scores_neg: list[float]) -> float:
    """ROC AUC: probability a fraud pair scores *lower* similarity than a genuine one."""
    wins = sum((p < n) + 0.5 * (p == n) for p in scores_pos for n in scores_neg)
    return round(safe_div(wins, len(scores_pos) * len(scores_neg)), 4)


def ensure_dataset():
    if not (DATASET / "pairs.csv").exists() or not (DATASET / "same").exists():
        subprocess.run([sys.executable, str(ROOT / "scripts" / "build_dataset.py")], check=True)


def load_views(label, phase, pair_id):
    return {a: imaging.load_image(DATASET / label / phase / a / f"{pair_id}.jpeg") for a in config.ANGLES}


# ── v1 baseline: the original embedding gate, re-implemented faithfully ────
def v1_baseline(rows) -> list[float]:
    """Original method: pad cut-out with black to a square, resize 448, DINOv2 via the

    HF pipeline (resize 256 + centre crop 224), mean of ALL patch tokens (padding

    included), cosine per view, weighted by silhouette-Laplacian 'sharpness'."""
    import numpy as np
    import torch
    from PIL import Image
    from transformers import pipeline as hf_pipeline

    extractor = hf_pipeline(model="facebook/dinov2-base", task="image-feature-extraction")
    cache = {}

    def feats(img):
        key = imaging.image_digest(img)
        if key not in cache:
            cut = imaging.remove_background(img)
            rgb = cut.convert("RGB")
            side = max(rgb.size)
            sq = Image.new("RGB", (side, side), (0, 0, 0))
            sq.paste(rgb, ((side - rgb.width) // 2, (side - rgb.height) // 2))
            emb = torch.tensor(extractor(sq.resize((448, 448), Image.LANCZOS))[0])[1:].mean(0)
            arr = np.array(cut.convert("L"), dtype=np.float32)
            lap = np.roll(arr, -1, 0) + np.roll(arr, 1, 0) + np.roll(arr, -1, 1) + np.roll(arr, 1, 1) - 4 * arr
            cache[key] = (emb, max(float(lap.var()), 1.0))
        return cache[key]

    scores = []
    for r in rows:
        d, rt = load_views(r["label"], "delivery_images", r["pair_id"]), load_views(r["label"], "return_images", r["pair_id"])
        sims, qs = [], []
        for a in config.ANGLES:
            (e1, _), (e2, q) = feats(d[a]), feats(rt[a])
            sims.append(torch.nn.functional.cosine_similarity(e1, e2, dim=0).item())
            qs.append(q)
        scores.append(sum(s * q for s, q in zip(sims, qs)) / sum(qs))
    return scores


def main():
    ensure_dataset()
    rows = list(csv.DictReader(open(DATASET / "pairs.csv")))
    excluded = {s.strip() for s in args.exclude_items.split(",") if s.strip()}
    if excluded:
        rows = [r for r in rows if r["delivery_item"] not in excluded and r["return_item"] not in excluded]
    if args.limit:
        rows = rows[:args.limit]
    out_dir = Path(args.out)
    out_dir.mkdir(parents=True, exist_ok=True)
    model = config.LLM_MODEL
    print(f"Evaluating {len(rows)} pairs with {model} (LLM cache {'off' if args.fresh else 'on'})")

    now = datetime.now(timezone.utc)
    deliveries = {}   # delivery photos -> (order, cutouts); the same delivery recurs across many pairs
    delivery_calls = []
    per_pair = []
    t_start = time.time()

    for i, r in enumerate(rows, 1):
        item = r["delivery_item"]
        imgs = load_views(r["label"], "delivery_images", r["pair_id"])
        key = tuple(imaging.image_digest(imgs[a]) for a in config.ANGLES)
        if key not in deliveries:
            cutouts = {a: imaging.remove_background(imgs[a]) for a in config.ANGLES}
            try:
                fp, rec = identity.fingerprint(cutouts)
                fingerprint = fp.model_dump()
            except llm.LLMError as err:
                fingerprint, rec = None, err.record
            delivery_calls.append(rec.to_dict())
            order = {"order_id": f"eval-{item}", "customer_id": "eval-customer", "category": CATEGORY[item.split('_')[0]],
                     "delivered_at": (now - timedelta(days=1)).isoformat(), "fingerprint": fingerprint,
                     "features": {a: view_features(cutouts[a]).to_dict() for a in config.ANGLES}}
            deliveries[key] = (order, cutouts)
        order, cutouts = deliveries[key]

        result, state = pipeline.run(order, cutouts, load_views(r["label"], "return_images", r["pair_id"]),
                                     reason="changed_mind", requested_at=now.isoformat())
        local_only = decision.decide({**state, "identity": None, "inspection": None, "llm_calls": []})
        d = result["decision"]
        llm_ms = sum(c["latency_s"] for c in result["llm_calls"]) * 1000
        local_ms = sum(t["ms"] for t in result["trace"] if t["node"] not in ("identity", "inspector"))
        per_pair.append({
            "label": r["label"], "pair_id": int(r["pair_id"]), "delivery_item": item, "return_item": r["return_item"],
            "pair_type": r["pair_type"], "is_fraud": r["label"] == "different",
            "decision": d["decision"], "risk_score": d["risk_score"], "fraud_type": d.get("fraud_type"),
            "local_only_decision": local_only["decision"],
            "route": result["route"], "llm_calls": result["llm_calls"], "llm_call_count": result["llm_call_count"],
            "screen_verdict": result["screen"]["verdict"] if result["screen"] else None,
            "shape_mean": result["screen"]["shape_mean"] if result["screen"] else None,
            "color_shift": result["screen"]["color_shift_median"] if result["screen"] else None,
            "identity_outcome": (result["identity"] or {}).get("outcome"),
            "inspector_overall": (result["inspection"] or {}).get("overall"),
            "inspector_confidence": (result["inspection"] or {}).get("confidence"),
            "evidence": d["evidence"], "latency_ms_est": round(local_ms + llm_ms),
        })
        p = per_pair[-1]
        mark = "OK " if (p["decision"] != "ACCEPT") == p["is_fraud"] else "ERR"
        print(f"[{i:2d}/{len(rows)}] {mark} {r['label']:9s} {r['pair_id']:>2} {item:9s}->{r['return_item']:9s} "
              f"{p['decision']:12s} risk {str(p['risk_score']):>3}  llm {p['llm_call_count']}  "
              f"route {'>'.join(n for n in p['route'] if n not in ('policy', 'history', 'screen', 'triage'))}", flush=True)

    # An LLM outage turns decisions into fail-safe reviews: those numbers measure the outage,
    # not the system, so never publish them. Successful answers stay cached for the next run.
    failed = [c for p in per_pair for c in p["llm_calls"] if not c["ok"]] + [c for c in delivery_calls if not c["ok"]]
    if failed:
        partial = out_dir / "eval_results.partial.json"
        partial.write_text(json.dumps({"failed_calls": failed, "per_pair": per_pair}, indent=2, default=str), encoding="utf-8")
        print(f"\n{len(failed)} LLM call(s) failed (e.g. {failed[0]['error'][:120]}).\n"
              f"Published results were NOT updated. Re-run when the quota resets; cached answers are reused. "
              f"Details: {partial}")
        sys.exit(1)

    # ── detection metrics ──────────────────────────────────────────────────
    y = [p["is_fraud"] for p in per_pair]
    full = binary_metrics(y, [p["decision"] != "ACCEPT" for p in per_pair])
    local = binary_metrics(y, [p["local_only_decision"] != "ACCEPT" for p in per_pair])
    auto_reject = binary_metrics(y, [p["decision"] == "REJECT" for p in per_pair])
    # items added after every threshold and weight was frozen: the closest thing to a held-out test
    held = [p for p in per_pair if {p["delivery_item"], p["return_item"]} & set(HELD_OUT_ITEMS)]
    held_out = {"items": HELD_OUT_ITEMS, "pairs": len(held), "genuine": sum(not p["is_fraud"] for p in held),
                "fraud": sum(p["is_fraud"] for p in held),
                **binary_metrics([p["is_fraud"] for p in held], [p["decision"] != "ACCEPT" for p in held])} if held else None
    confusion = {lab: Counter(p["decision"] for p in per_pair if p["is_fraud"] == (lab == "fraud"))
                 for lab in ("genuine", "fraud")}
    by_type = defaultdict(Counter)
    for p in per_pair:
        by_type[p["pair_type"]][p["decision"]] += 1

    ablations = []
    shape_auc = auc([p["shape_mean"] for p in per_pair if p["is_fraud"] and p["shape_mean"] is not None],
                    [p["shape_mean"] for p in per_pair if not p["is_fraud"] and p["shape_mean"] is not None])
    if not args.no_baseline:
        print("Computing v1 baseline (original embedding gate)...", flush=True)
        v1 = v1_baseline(rows)
        for p, s in zip(per_pair, v1):
            p["v1_score"] = round(s, 4)
        v1_metrics = binary_metrics(y, [s <= 0.94 for s in v1])
        ablations.append({"name": "v1: original embedding gate", "detail": "patch-mean incl. padding, flag if <= 0.94",
                          "llm_calls_per_return": None, "auc": auc([s for s, t in zip(v1, y) if t], [s for s, t in zip(v1, y) if not t]),
                          **v1_metrics})
    ablations.append({"name": "v2: local evidence only", "detail": "object-only DINOv2 + colour, no LLM",
                      "llm_calls_per_return": 0, "auc": shape_auc, **local})
    calls_per_return = statistics.mean(p["llm_call_count"] for p in per_pair)
    ablations.append({"name": "v2: full multi-agent pipeline", "detail": "screen + identity agent + inspector + decision",
                      "llm_calls_per_return": round(calls_per_return, 2), "auc": None, **full})

    # ── agentic metrics ────────────────────────────────────────────────────
    all_calls = [c for p in per_pair for c in p["llm_calls"]] + delivery_calls
    by_agent = defaultdict(list)
    for c in all_calls:
        by_agent[c["agent"]].append(c)
    ident_runs = [p for p in per_pair if p["identity_outcome"]]
    insp_runs = [p for p in per_pair if p["inspector_overall"]]
    ident_ok = sum((p["identity_outcome"] in ("mismatch", "label_missing")) == p["is_fraud"] for p in ident_runs)
    insp_ok = sum((p["inspector_overall"] == "different_unit") == p["is_fraud"] for p in insp_runs)
    tokens_in = sum(c["input_tokens"] for p in per_pair for c in p["llm_calls"])
    tokens_out = sum(c["output_tokens"] for p in per_pair for c in p["llm_calls"])
    price = PRICES.get(model)
    cost_per_1k = round((tokens_in * price[0] + tokens_out * price[1]) / 1e6 / len(per_pair) * 1000, 2) if price else None
    route_counts = Counter(" > ".join(n for n in p["route"] if n not in ("policy", "history", "screen", "triage"))
                           for p in per_pair)
    agentic = {
        "llm_calls_per_return": round(calls_per_return, 2),
        "v1_llm_calls_per_return_upper_bound": 4,
        "decided_without_llm_pct": round(100 * safe_div(sum(p["llm_call_count"] == 0 for p in per_pair), len(per_pair)), 1),
        "llm_call_distribution": dict(sorted(Counter(p["llm_call_count"] for p in per_pair).items())),
        "schema_valid_first_try_pct": round(100 * safe_div(sum(c["ok"] and c["attempts"] == 1 for c in all_calls), len(all_calls)), 1),
        "schema_valid_after_retries_pct": round(100 * safe_div(sum(c["ok"] for c in all_calls), len(all_calls)), 1),
        "total_retries": sum(max(0, c["attempts"] - 1) for c in all_calls),
        "agents": {name: {"calls": len(cs), "mean_latency_s": round(statistics.mean(c["latency_s"] for c in cs), 1),
                          "mean_tokens": round(statistics.mean(c["input_tokens"] + c["output_tokens"] for c in cs))}
                   for name, cs in by_agent.items()},
        "identity_agent_accuracy": {"runs": len(ident_runs), "correct": ident_ok},
        "inspector_accuracy": {"runs": len(insp_runs), "correct": insp_ok},
        "tokens_per_return": round((tokens_in + tokens_out) / len(per_pair)),
        "est_cost_usd_per_1000_returns": cost_per_1k,
        "latency_s_per_return": {"mean": round(statistics.mean(p["latency_ms_est"] for p in per_pair) / 1000, 1),
                                 "p_max": round(max(p["latency_ms_est"] for p in per_pair) / 1000, 1)},
        "routes": dict(route_counts.most_common()),
    }

    results = {
        "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
        "model": model, "embedding_model": config.EMBED_MODEL, "background_model": config.BG_MODEL,
        "positive_class": "fraud (returned item is not the delivered unit)",
        "flagged_means": "HUMAN_REVIEW or REJECT (refund not auto-approved)",
        "dataset": {"pairs": len(per_pair), "genuine": y.count(False), "fraud": y.count(True),
                    "items": len({p["delivery_item"] for p in per_pair}),
                    "pair_types": dict(Counter(p["pair_type"] for p in per_pair)),
                    "note": f"{len({p['delivery_item'] for p in per_pair})} physical items. Genuine returns are simulated "
                            f"re-captures (the delivery photo, edited) except "
                            f"{sum(p['pair_type'] == 'same_item_real_recapture' for p in per_pair)} real re-photographs "
                            f"from a separate photo session."},
        "thresholds": {"fast_reject_shape": config.FAST_REJECT_SHAPE, "fast_accept_shape": config.FAST_ACCEPT_SHAPE,
                       "fast_accept_min_view": config.FAST_ACCEPT_MIN_VIEW,
                       "fast_accept_max_color_shift": config.FAST_ACCEPT_MAX_COLOR_SHIFT,
                       "accept_below": config.ACCEPT_BELOW, "reject_from": config.REJECT_FROM},
        "real_recapture": [
            {"kind": label, "pairs": len(ps), "accepted": sum(p["decision"] == "ACCEPT" for p in ps),
             "flagged": sum(p["decision"] != "ACCEPT" for p in ps)}
            for kind, label in (("same_item_recapture", "Simulated re-capture (same photo, edited)"),
                                ("same_item_real_recapture", "Real re-photograph (separate photo session)"))
            if (ps := [p for p in per_pair if p["pair_type"] == kind])],
        "retakes": {"count": sum(p["decision"] == "RETAKE_REQUIRED" for p in per_pair),
                    "note": "Photo rejected by the quality gate; counted as flagged (conservative: it can only lower precision)."},
        "metrics": full, "held_out": held_out, "auto_reject_metrics": auto_reject,
        "confusion": {k: dict(v) for k, v in confusion.items()},
        "by_pair_type": {k: dict(v) for k, v in by_type.items()},
        "ablations": ablations, "agentic": agentic,
        "runtime_s": round(time.time() - t_start),
        "per_pair": per_pair,
    }
    (out_dir / "eval_results.json").write_text(json.dumps(results, indent=2), encoding="utf-8")
    write_markdown(results, out_dir / "eval_report.md")
    with open(out_dir / "eval_pairs.csv", "w", newline="") as f:
        w = csv.writer(f)
        w.writerow(["label", "pair_id", "delivery_item", "return_item", "pair_type", "decision", "risk_score",
                    "llm_calls", "shape_mean", "color_shift", "identity", "inspector", "v1_score"])
        for p in per_pair:
            w.writerow([p["label"], p["pair_id"], p["delivery_item"], p["return_item"], p["pair_type"], p["decision"],
                        p["risk_score"], p["llm_call_count"], p["shape_mean"], p["color_shift"], p["identity_outcome"],
                        p["inspector_overall"], p.get("v1_score")])

    m = full
    print(f"\nFull pipeline: precision {m['precision']:.3f}  recall {m['recall']:.3f}  F1 {m['f1']:.3f}  "
          f"F0.5 {m['f0_5']:.3f}  (TP {m['tp']} FP {m['fp']} FN {m['fn']} TN {m['tn']})")
    print(f"LLM calls/return {agentic['llm_calls_per_return']}, decided without LLM {agentic['decided_without_llm_pct']}%")
    for a in ablations:
        print(f"  {a['name']:32s} P {a['precision']:.3f} R {a['recall']:.3f} F0.5 {a['f0_5']:.3f}")
    print(f"Wrote {out_dir / 'eval_results.json'}")


def pct(x):
    return f"{100 * x:.1f}%"


def write_markdown(r, path):
    m, a = r["metrics"], r["agentic"]
    lines = [
        "# ReturnGuard evaluation", "",
        f"Generated {r['generated_at']} with `{r['model']}`. Dataset: {r['dataset']['pairs']} pairs "
        f"({r['dataset']['genuine']} genuine, {r['dataset']['fraud']} fraud) from {r['dataset']['items']} items. "
        f"{r['dataset']['note']}", "",
        "Positive class = fraud. *Flagged* = not auto-accepted (human review or reject).", "",
        "| Metric | Value | 95% interval |", "|---|---|---|",
        f"| Precision | {pct(m['precision'])} ({m['tp']}/{m['tp'] + m['fp']}) | {pct(m['precision_ci'][0])} – {pct(m['precision_ci'][1])} |",
        f"| Recall | {pct(m['recall'])} ({m['tp']}/{m['tp'] + m['fn']}) | {pct(m['recall_ci'][0])} – {pct(m['recall_ci'][1])} |",
        f"| F1 | {pct(m['f1'])} | |", f"| **F0.5** | **{pct(m['f0_5'])}** | |",
        f"| False rejection rate (genuine returns flagged) | {pct(m['false_rejection_rate'])} ({m['fp']}/{m['fp'] + m['tn']}) | "
        f"{pct(m['false_rejection_rate_ci'][0])} – {pct(m['false_rejection_rate_ci'][1])} |",
        f"| Confusion (TP / FP / FN / TN) | {m['tp']} / {m['fp']} / {m['fn']} / {m['tn']} | |",
        f"| Retake requests (counted as flagged) | {r['retakes']['count']} | |", "",
        "Intervals are 95% Wilson score intervals: with this few pairs, a perfect score still has a wide range.", "",
    ]
    h = r.get("held_out")
    if h:
        lines += [
            f"## Held-out items ({', '.join(h['items'])})", "",
            "Added after every threshold and evidence weight was frozen on the first 6 items, and photographed in real, "
            "cluttered scenes (on laptops, held in hand). The closest thing to an unseen test set here.", "",
            "| Metric | Value | 95% interval |", "|---|---|---|",
            f"| Pairs | {h['pairs']} ({h['genuine']} genuine, {h['fraud']} fraud) | |",
            f"| Precision | {pct(h['precision'])} ({h['tp']}/{h['tp'] + h['fp']}) | {pct(h['precision_ci'][0])} – {pct(h['precision_ci'][1])} |",
            f"| Recall | {pct(h['recall'])} ({h['tp']}/{h['tp'] + h['fn']}) | {pct(h['recall_ci'][0])} – {pct(h['recall_ci'][1])} |",
            f"| F0.5 | {pct(h['f0_5'])} | |",
            f"| Genuine returns flagged | {pct(h['false_rejection_rate'])} ({h['fp']}/{h['fp'] + h['tn']}) | |", "",
        ]
    lines += [
        "## Ablations", "", "| System | LLM calls / return | Precision | Recall | F0.5 | AUC |", "|---|---|---|---|---|---|",
    ]
    for ab in r["ablations"]:
        calls = "–" if ab["llm_calls_per_return"] is None else ab["llm_calls_per_return"]
        lines.append(f"| {ab['name']} | {calls} | {pct(ab['precision'])} | {pct(ab['recall'])} | {pct(ab['f0_5'])} | "
                     f"{ab['auc'] if ab['auc'] is not None else '–'} |")
    lines += ["", "## Decisions by pair type", "", "| Pair type | ACCEPT | HUMAN_REVIEW | REJECT | RETAKE |", "|---|---|---|---|---|"]
    for k, v in r["by_pair_type"].items():
        lines.append(f"| {k} | {v.get('ACCEPT', 0)} | {v.get('HUMAN_REVIEW', 0)} | {v.get('REJECT', 0)} | "
                     f"{v.get('RETAKE_REQUIRED', 0)} |")
    lines += ["", "## Agentic metrics", "",
              f"- LLM calls per return: **{a['llm_calls_per_return']}** (v1 made up to {a['v1_llm_calls_per_return_upper_bound']})",
              f"- Decided with no LLM call: **{a['decided_without_llm_pct']}%**",
              f"- Structured output valid on first try: {a['schema_valid_first_try_pct']}% "
              f"(after retries: {a['schema_valid_after_retries_pct']}%), retries: {a['total_retries']}",
              f"- Identity agent correct: {a['identity_agent_accuracy']['correct']}/{a['identity_agent_accuracy']['runs']}",
              f"- Visual inspector correct: {a['inspector_accuracy']['correct']}/{a['inspector_accuracy']['runs']}",
              f"- Tokens per return: {a['tokens_per_return']}; est. cost per 1,000 returns: "
              f"{'$' + str(a['est_cost_usd_per_1000_returns']) if a['est_cost_usd_per_1000_returns'] is not None else 'n/a'}",
              f"- Latency per return: mean {a['latency_s_per_return']['mean']} s, max {a['latency_s_per_return']['p_max']} s",
              "", "Routes taken:", ""]
    lines += [f"- `{k or 'intake only'}`: {v}" for k, v in a["routes"].items()]
    path.write_text("\n".join(lines) + "\n", encoding="utf-8")


if __name__ == "__main__":
    main()