#!/usr/bin/env python3 """B3 5-policy re-test analyser. Compute TTFT/TPOT/E2E mean/p50/p90/p99 for each policy from metrics.jsonl, compare against the historical b3_policy_comparison.json that drives fig_b3_latency_bars.png, and emit a side-by-side table plus a new figure with the same layout as the original. Usage: python analyze_b3_replay.py --root [--old-data ] [--figure ] """ import argparse import json import statistics from pathlib import Path POLICIES = ["lmetric", "load_only", "sticky", "unified", "unified_v2"] def pct(xs, p): if not xs: return None xs = sorted(xs) k = max(0, min(len(xs) - 1, int(p / 100.0 * (len(xs) - 1)))) return xs[k] def summarise(path): rows = [json.loads(l) for l in open(path) if l.strip()] ok = [r for r in rows if not r.get("error")] ttft = [r["ttft_s"] * 1000 for r in ok if r.get("ttft_s") is not None] tpot = [r["tpot_s"] * 1000 for r in ok if r.get("tpot_s")] e2e = [r["latency_s"] * 1000 for r in ok if r.get("latency_s") is not None] return { "n_total": len(rows), "n_ok": len(ok), "ttft_mean_ms": statistics.mean(ttft) if ttft else None, "ttft_p50_ms": pct(ttft, 50), "ttft_p90_ms": pct(ttft, 90), "ttft_p99_ms": pct(ttft, 99), "tpot_mean_ms": statistics.mean(tpot) if tpot else None, "tpot_p50_ms": pct(tpot, 50), "tpot_p90_ms": pct(tpot, 90), "tpot_p99_ms": pct(tpot, 99), "e2e_mean_ms": statistics.mean(e2e) if e2e else None, "e2e_p50_ms": pct(e2e, 50), "e2e_p90_ms": pct(e2e, 90), "e2e_p99_ms": pct(e2e, 99), } def main(): ap = argparse.ArgumentParser() ap.add_argument("--root", type=Path, required=True) ap.add_argument("--old-data", type=Path, default=Path("analysis/characterization/window_1_results/b3_policy_comparison.json")) ap.add_argument("--figure", type=Path, default=None) args = ap.parse_args() new = {} for p in POLICIES: path = args.root / p / "metrics.jsonl" if not path.exists(): print(f"MISSING: {path}") continue new[p] = summarise(path) old = {} if args.old_data.exists(): d = json.load(open(args.old_data)) for r in d.get("rows", []): old[r["policy"]] = { "ttft_p50_ms": r["ttft_p50_s"] * 1000, "ttft_p90_ms": r["ttft_p90_s"] * 1000, "ttft_p99_ms": r["ttft_p99_s"] * 1000, "tpot_p90_ms": r["tpot_p90_s"] * 1000, "e2e_p90_ms": r.get("e2e_p90_s", 0) * 1000, } def fmt(v): return f"{v:.0f}" if v is not None else "-" def pctd(a, b): if a is None or b is None or a == 0: return "-" return f"{(b/a-1)*100:+.1f}%" # Headline table print(f"\n# NEW: today's re-test") print(f"{'policy':<14}{'n_ok':>6}{'TTFTp50':>10}{'TTFTp90':>10}{'TTFTp99':>10}{'TPOTp90':>10}{'E2Ep90':>10}") print("-" * 70) for p in POLICIES: if p not in new: continue r = new[p] print(f"{p:<14}{r['n_ok']:>6}{fmt(r['ttft_p50_ms']):>9}ms{fmt(r['ttft_p90_ms']):>9}ms{fmt(r['ttft_p99_ms']):>9}ms{fmt(r['tpot_p90_ms']):>9}ms{fmt(r['e2e_p90_ms']):>9}ms") print(f"\n# OLD: window_1_results/b3_policy_comparison.json") print(f"{'policy':<14}{'TTFTp50':>10}{'TTFTp90':>10}{'TTFTp99':>10}{'TPOTp90':>10}{'E2Ep90':>10}") print("-" * 60) for p in POLICIES: if p not in old: continue r = old[p] print(f"{p:<14}{fmt(r['ttft_p50_ms']):>9}ms{fmt(r['ttft_p90_ms']):>9}ms{fmt(r['ttft_p99_ms']):>9}ms{fmt(r['tpot_p90_ms']):>9}ms{fmt(r['e2e_p90_ms']):>9}ms") print(f"\n# DRIFT: today vs old (same policy)") print(f"{'policy':<14}{'ΔTTFTp50':>10}{'ΔTTFTp90':>10}{'ΔTTFTp99':>10}{'ΔTPOTp90':>10}{'ΔE2Ep90':>10}") print("-" * 60) for p in POLICIES: if p not in new or p not in old: continue n, o = new[p], old[p] print(f"{p:<14}{pctd(o['ttft_p50_ms'], n['ttft_p50_ms']):>10}" f"{pctd(o['ttft_p90_ms'], n['ttft_p90_ms']):>10}" f"{pctd(o['ttft_p99_ms'], n['ttft_p99_ms']):>10}" f"{pctd(o['tpot_p90_ms'], n['tpot_p90_ms']):>10}" f"{pctd(o['e2e_p90_ms'], n['e2e_p90_ms']):>10}") # Relative ordering check def ranks(values_dict, key): items = [(p, r[key]) for p, r in values_dict.items() if r.get(key)] items.sort(key=lambda x: x[1]) return [p for p, _ in items] print(f"\n# TTFT p90 ranking (best → worst)") for label, src in [("OLD", old), ("NEW", new)]: if src: order = ranks(src, "ttft_p90_ms") print(f" {label}: {' < '.join(order)}") out = {"new": new, "old": old} out_path = args.root / "b3_replay_summary.json" out_path.write_text(json.dumps(out, indent=2)) print(f"\nWrote {out_path}") # Bar plot (matplotlib) if not args.figure: args.figure = args.root / "fig_b3_latency_bars_new.png" try: import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt pols = [p for p in POLICIES if p in new] metrics = [("TTFT p90 (s)", "ttft_p90_ms", 1000), ("TPOT p90 (ms)", "tpot_p90_ms", 1), ("E2E p90 (s)", "e2e_p90_ms", 1000)] colors = {"lmetric": "tab:blue", "load_only": "tab:orange", "sticky": "tab:green", "unified": "tab:red", "unified_v2": "tab:purple"} fig, axes = plt.subplots(1, 3, figsize=(14, 4.5)) for ax, (label, key, div) in zip(axes, metrics): vals = [new[p][key] / div for p in pols] bars = ax.bar(pols, vals, color=[colors.get(p, "gray") for p in pols], edgecolor="black", linewidth=0.5) ax.set_title(label) ax.tick_params(axis="x", rotation=20) for b, v in zip(bars, vals): ax.text(b.get_x() + b.get_width() / 2, v, f"{v:.1f}", ha="center", va="bottom", fontsize=9) ax.grid(alpha=0.3, axis="y") fig.suptitle(f"B3 5-policy re-test ({args.root.name})") fig.tight_layout() fig.savefig(args.figure, dpi=120) print(f"Wrote {args.figure}") except Exception as e: print(f"(figure skipped: {e})") if __name__ == "__main__": main()