#!/usr/bin/env python3 """Read raw metrics/stats → results.json (plan.md §5 schema + extras).""" from __future__ import annotations import json import math import os import re import statistics import sys from datetime import datetime, timezone, timedelta from pathlib import Path TZ = timezone(timedelta(hours=8)) ENGINES = ("baidu", "sogou", "360search", "bing", "wikipedia") def percentile(vals, p: float): if not vals: return None xs = sorted(vals) if len(xs) == 1: return xs[0] k = (len(xs) - 1) * (p / 100.0) f = math.floor(k) c = math.ceil(k) if f == c: return xs[int(k)] return xs[f] * (c - k) + xs[c] * (k - f) def parse_mem_mib(usage: str) -> float | None: if not usage: return None left = usage.split("/", 1)[0].strip() m = re.match(r"([0-9.]+)\s*([KMGT]i?B)", left, re.I) if not m: return None n = float(m.group(1)) unit = m.group(2).lower() mult = {"b": 1 / 1048576, "kib": 1 / 1024, "mib": 1, "gib": 1024, "kb": 1 / 1000, "mb": 1, "gb": 1000} return n * mult.get(unit, 1.0) def load_stats(path: Path) -> list[dict]: rows = [] if not path.exists(): return rows for i, line in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines()): if i == 0 or not line.strip(): continue parts = line.split("\t") if len(parts) < 3: continue mem = parse_mem_mib(parts[2]) cgroup = None if len(parts) >= 5 and parts[4] not in ("", "NA"): try: cgroup = int(parts[4]) / 1048576.0 except ValueError: cgroup = None rows.append({"ts": parts[0], "name": parts[1], "mem_mib": mem, "cgroup_mib": cgroup}) return rows def mem_series(rows: list[dict], prefer_cgroup: bool) -> list[float]: out = [] for r in rows: v = r["cgroup_mib"] if prefer_cgroup and r["cgroup_mib"] is not None else r["mem_mib"] if v is not None: out.append(v) return out def load_jsonl(path: Path) -> list[dict]: rows = [] if not path.exists(): return rows for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): line = line.strip() if not line: continue rows.append(json.loads(line)) return rows def bucket(v, cuts): # cuts: list of (threshold, score) descending for thr, score in cuts: if v <= thr: return score return 0 def main() -> None: root = Path(sys.argv[1] if len(sys.argv) > 1 else ".") raw = root / "raw" extra = {} extra_path = raw / "meta.json" if extra_path.exists(): extra = json.loads(extra_path.read_text(encoding="utf-8")) idle_rows = load_stats(raw / "searxng-cn-idle.tsv") sess_rows = load_stats(raw / "searxng-cn-session.tsv") burst_rows = load_stats(raw / "searxng-cn-t5.tsv") combo_rows = load_stats(raw / "p0-combo.tsv") cgroup_n = sum(1 for r in idle_rows if r["cgroup_mib"] is not None) prefer = cgroup_n > 0 rss_source = "cgroup" if prefer else "docker_stats" idle_vals = mem_series(idle_rows, prefer) sess_vals = mem_series(sess_rows, prefer) burst_vals = mem_series(burst_rows, prefer) idle_p50 = percentile(idle_vals, 50) if idle_vals else None sess_peak = max(sess_vals) if sess_vals else None burst_peak = max(burst_vals) if burst_vals else None session_delta = None if sess_peak is not None and idle_p50 is not None: session_delta = max(0.0, sess_peak - idle_p50) t1 = load_jsonl(raw / "t1.jsonl") t5 = load_jsonl(raw / "t5.jsonl") t1_ok = [r for r in t1 if r.get("assert_ok")] t5_ok = [r for r in t5 if r.get("assert_ok")] t1_tot = [r.get("t_total") for r in t1 if r.get("t_total") is not None] t5_tot = [r.get("t_total") for r in t5 if r.get("t_total") is not None] t1_ttfb = [r.get("ttfb") for r in t5 if r.get("ttfb") is not None] engine_matrix = extra.get("engine_matrix") or {} # fill from t1/t5 verdicts if present if not engine_matrix: matrix = {e: {"results": 0, "unresponsive": 0, "runs_with_results": 0} for e in ENGINES} for r in t1 + t5: counts = r.get("engine_counts") or {} unresp = r.get("unresponsive_engines") or [] names = set() for u in unresp: if isinstance(u, (list, tuple)) and u: names.add(str(u[0])) elif isinstance(u, str): names.add(u) for e in ENGINES: n = int(counts.get(e) or 0) matrix[e]["results"] += n if n > 0: matrix[e]["runs_with_results"] += 1 if e in names: matrix[e]["unresponsive"] += 1 engine_matrix = matrix t1_p50 = percentile(t1_tot, 50) t1_p95 = percentile(t1_tot, 95) t5_p50 = percentile(t5_tot, 50) t5_p95 = percentile(t5_tot, 95) http_codes_t1 = [r.get("http_code") for r in t1] t5_429 = sum(1 for r in t5 if r.get("http_code") == 429) t5_503 = sum(1 for r in t5 if r.get("http_code") == 503) # scorecard idle_score = bucket(idle_p50 if idle_p50 is not None else 9999, [(60, 100), (100, 80), (200, 60), (400, 40)]) sess_score = bucket(session_delta if session_delta is not None else 9999, [(30, 100), (80, 80), (200, 60), (350, 40)]) burst_score = 0 if extra.get("oom") else bucket(burst_peak if burst_peak is not None else 9999, [(200, 100), (400, 80), (700, 60), (1000, 40)]) mem_score = round((idle_score + sess_score + burst_score) / 3, 1) n_t1 = len(t1) n_t5 = len(t5) succ_t1 = len(t1_ok) succ_t5 = len(t5_ok) all_n = n_t1 + n_t5 all_ok = succ_t1 + succ_t5 success_pct = (100.0 * all_ok / all_n) if all_n else 0.0 if extra.get("container_exited"): stab = 0 elif success_pct >= 99: stab = 100 elif success_pct >= 95: stab = 80 elif success_pct >= 85: stab = 60 elif success_pct >= 70: stab = 40 else: stab = 0 # quality from T1 median-ish first successful quality = 0 if t1: best = max(t1, key=lambda r: (bool(r.get("assert_ok")), r.get("n_results") or 0, len(r.get("engines") or []))) neng = len(best.get("engines") or []) nres = best.get("n_results") or 0 if nres >= 5 and neng >= 2 and (best.get("n_rich") or 0) >= 5: quality = 100 elif nres >= 5 and neng >= 1: quality = 70 elif nres > 0: quality = 40 else: quality = 0 lat = 0 if t1_p95 is not None: lat_t1 = bucket(t1_p95, [(2.0, 100), (5.0, 70), (12.0, 40)]) if t5_p95 is not None and t1_p95 > 0: ratio = t5_p95 / t1_p95 lat_t5 = bucket(ratio, [(3.0, 100), (5.0, 70), (8.0, 40)]) else: lat_t5 = 0 lat = round((lat_t1 + lat_t5) / 2, 1) ops = 80 total = round( mem_score * 0.30 + quality * 0.25 + stab * 0.20 + lat * 0.15 + ops * 0.10, 1, ) probed = datetime.now(TZ).isoformat(timespec="seconds") results = { "scheme": "searxng-cn", "group": "domestic", "image": extra.get("image") or {"ref": "docker.io/searxng/searxng:2026.8.29-d226b78bc", "digest": ""}, "probed_at": probed, "rss_source": rss_source, "idle_mb_p50": round(idle_p50, 2) if idle_p50 is not None else None, "session_delta_mb": round(session_delta, 2) if session_delta is not None else None, "burst_peak_mb": round(burst_peak, 2) if burst_peak is not None else None, "session_peak_mb": round(sess_peak, 2) if sess_peak is not None else None, "templates": { "T1": { "n": n_t1, "success": succ_t1, "http_codes": http_codes_t1, "p50_s": round(t1_p50, 4) if t1_p50 is not None else None, "p95_s": round(t1_p95, 4) if t1_p95 is not None else None, "assert_ok": succ_t1 > 0 and succ_t1 == n_t1, "note": extra.get("t1_note") or "", "engine_matrix": engine_matrix, }, "T5": { "n": n_t5, "success": succ_t5, "p50_s": round(t5_p50, 4) if t5_p50 is not None else None, "p95_s": round(t5_p95, 4) if t5_p95 is not None else None, "assert_ok": (succ_t5 / n_t5 >= 0.85) if n_t5 else False, "note": extra.get("t5_note") or "", }, }, "t5": { "n": n_t5, "parallelism": 60, "queue_impl": "none", "success": succ_t5, "p50_s": round(t5_p50, 4) if t5_p50 is not None else None, "p95_s": round(t5_p95, 4) if t5_p95 is not None else None, "oom": bool(extra.get("oom")), "http_429": t5_429, "http_503": t5_503, "first_ttfb_s": round(min(t1_ttfb), 4) if t1_ttfb else None, "last_t_total_s": round(max(t5_tot), 4) if t5_tot else None, }, "t4_waf": [], "scorecard": { "mem": mem_score, "quality": quality, "stability": stab, "latency": lat, "ops": ops, "total": total, "mem_idle": idle_score, "mem_session": sess_score, "mem_burst": burst_score, "success_pct": round(success_pct, 2), }, "engine_matrix": engine_matrix, "t2_pick": extra.get("t2_pick"), "pull": extra.get("pull"), "commands": extra.get("commands"), "combo_rss_mb": extra.get("combo_rss_mb"), "notes": extra.get("notes") or [], } outp = root / "results.json" outp.write_text(json.dumps(results, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") print(f"wrote {outp}") if __name__ == "__main__": main()