| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205 |
- #!/usr/bin/env python3
- """Scored quality gate for DS-V4.1-Flash config changes.
- python3 qeval.py run before --url http://127.0.0.1:8888/v1/chat/completions
- ... change config, reboot ...
- python3 qeval.py run after --url ...
- python3 qeval.py compare qeval-before.json qeval-after.json
- 55 auto-scored tasks (code executed against hidden asserts, JSON schema-checked, numeric answers
- matched, format constraints enforced, prose checked only for objective degeneration). No LLM judge,
- so the score is reproducible.
- Run at concurrency 1. Greedy output is only reproducible run to run when the batch composition is
- fixed (Mia's README, "Correctness notes"); at concurrency > 1 a pass->fail flip can be batching
- noise rather than the config change. `--concurrency N` exists for speed but forfeits that.
- The comparison is PAIRED: the same tasks before and after, so what matters is the flip matrix
- (pass->fail vs fail->pass), not the two pass rates. McNemar's exact test gives the p-value.
- """
- import argparse, concurrent.futures as cf, json, math, statistics as st, sys, time, urllib.request
- sys.path.insert(0, __file__.rsplit("/", 1)[0])
- import qeval_tasks as qt
- def ask(url, task, timeout):
- body = {"model": "deepseek-v4.1-flash", "temperature": 0, "max_tokens": task["max_tokens"],
- "stream": False, "chat_template_kwargs": {"thinking": bool(task["thinking"])},
- # Per-request DSpark acceptance telemetry. Costs no GPU time: the counters are
- # incremented unconditionally in batch_result_processor.py:773-777 and this flag
- # only decides whether they are serialised back onto the response.
- "return_spec_tokens_details": True,
- "messages": [{"role": "user", "content": task["prompt"]}]}
- t0 = time.time()
- req = urllib.request.Request(url, data=json.dumps(body).encode(),
- headers={"Content-Type": "application/json"})
- r = json.load(urllib.request.urlopen(req, timeout=timeout))
- dt = time.time() - t0
- ch = r["choices"][0]
- msg = ch["message"]
- content = msg.get("content") or ""
- n = r["usage"]["completion_tokens"]
- return {"content": content,
- "reasoning_chars": len(msg.get("reasoning_content") or msg.get("reasoning") or ""),
- "finish_reason": ch.get("finish_reason"), "completion_tokens": n,
- "seconds": round(dt, 3), "tok_s": round(n / dt, 2) if dt > 0 else None,
- "spec": (r.get("sglext") or {}).get("spec_tokens_details")}
- def score_one(url, task, timeout):
- out = {"id": task["id"], "category": task["category"]}
- try:
- out.update(ask(url, task, timeout))
- except Exception as exc:
- return {**out, "pass": False, "why": f"request failed: {exc!r}"}
- try:
- ok, why = task["checker"](out["content"])
- except Exception as exc:
- ok, why = False, f"checker raised {exc!r}"
- out["pass"] = bool(ok)
- out["why"] = "" if ok else str(why)[:160]
- return out
- def run(label, url, timeout, concurrency, only, limit=0):
- tasks = [t for t in qt.TASKS if not only or t["category"] in only]
- if limit: tasks = tasks[:limit]
- print(f"{len(tasks)} tasks, concurrency {concurrency}, {url}")
- t0 = time.time()
- if concurrency > 1:
- with cf.ThreadPoolExecutor(concurrency) as ex:
- results = list(ex.map(lambda t: score_one(url, t, timeout), tasks))
- else:
- results = []
- for t in tasks:
- r = score_one(url, t, timeout)
- results.append(r)
- print(f" {'PASS' if r['pass'] else 'FAIL'} {r['id']:<22} {r.get('why','')[:70]}", flush=True)
- wall = time.time() - t0
- speeds = [r["tok_s"] for r in results if r.get("tok_s")]
- trunc = sum(1 for r in results if r.get("finish_reason") == "length")
- out = {"label": label, "url": url, "concurrency": concurrency, "wall_s": round(wall, 1),
- "utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
- "median_tok_s": round(st.median(speeds), 2) if speeds else None,
- "truncated": trunc, "results": results}
- npass = sum(1 for r in results if r["pass"])
- print(f"\n{npass}/{len(results)} passed ({100*npass/len(results):.1f}%), "
- f"{trunc} hit the token cap, wall {wall/60:.1f} min, "
- f"median {out['median_tok_s']} tok/s")
- by = {}
- for r in results:
- by.setdefault(r["category"], []).append(r["pass"])
- for c, v in sorted(by.items()):
- print(f" {c:<8} {sum(v):>2}/{len(v)}")
- json.dump(out, open(f"qeval-{label}.json", "w"), indent=1)
- print(f"wrote qeval-{label}.json")
- def mcnemar_p(b, c):
- """Two-sided exact McNemar: b = pass->fail, c = fail->pass, H0: p = 0.5."""
- n = b + c
- if n == 0:
- return 1.0
- k = min(b, c)
- tail = sum(math.comb(n, i) for i in range(0, k + 1)) / 2 ** n
- return min(1.0, 2 * tail)
- PRIMARY = ("code", "reason", "math") # what "quality" means here, per the owner
- SECONDARY = ("json", "format") # instruction following: reported, not decisive
- GUARD = ("prose",) # objective degeneration only, never a quality score
- def _block(ids, ra, rb, cats):
- kept = broke = fixed = bf = 0
- flips = []
- for i in ids:
- if ra[i]["category"] not in cats:
- continue
- pa_, pb_ = ra[i]["pass"], rb[i]["pass"]
- if pa_ and pb_: kept += 1
- elif pa_ and not pb_: broke += 1; flips.append(("BROKE", i, rb[i].get("why", "")))
- elif pb_: fixed += 1; flips.append(("fixed", i, ra[i].get("why", "")))
- else: bf += 1
- return kept, broke, fixed, bf, flips
- def _line(tag, kept, broke, fixed, bf):
- n = kept + broke + fixed + bf
- if n == 0:
- return None
- before, after = kept + broke, kept + fixed
- rel = (after - before) / before * 100 if before else 0.0
- p = mcnemar_p(broke, fixed)
- print(f"{tag:<28} {before:>3}/{n} -> {after:>3}/{n} "
- f"kept {kept:>3} BROKE {broke:>3} fixed {fixed:>3} both-fail {bf:>3} "
- f"{rel:+6.1f}% p={p:.3f}")
- return rel, p, broke, fixed
- def compare(pa, pb):
- a, b = json.load(open(pa)), json.load(open(pb))
- ra = {r["id"]: r for r in a["results"]}
- rb = {r["id"]: r for r in b["results"]}
- ids = [t["id"] for t in qt.TASKS if t["id"] in ra and t["id"] in rb]
- print(f"paired on {len(ids)} tasks: {a['label']} -> {b['label']}\n")
- print(f"{'':<28} {'before':>7} {'after':>7}")
- pk, pbk, pf, pbf, pflips = _block(ids, ra, rb, PRIMARY)
- prim = _line("PRIMARY code+reason+math", pk, pbk, pf, pbf)
- sk, sbk, sf, sbf, sflips = _block(ids, ra, rb, SECONDARY)
- _line("secondary json+format", sk, sbk, sf, sbf)
- gk, gbk, gf, gbf, gflips = _block(ids, ra, rb, GUARD)
- _line("guard prose (degeneration)", gk, gbk, gf, gbf)
- percat = {}
- for i in ids:
- c = ra[i]["category"]
- d = percat.setdefault(c, [0, 0, 0, 0])
- pa_, pb_ = ra[i]["pass"], rb[i]["pass"]
- d[0 if (pa_ and pb_) else 1 if pa_ else 2 if pb_ else 3] += 1
- print(f"\n{'category':<9}{'kept':>6}{'broke':>7}{'fixed':>7}{'both-fail':>11}")
- for c, d in sorted(percat.items()):
- print(f"{c:<9}{d[0]:>6}{d[1]:>7}{d[2]:>7}{d[3]:>11}")
- allflips = pflips + sflips + gflips
- if allflips:
- print("\nflips:")
- for kind, i, why in allflips:
- print(f" {kind:<6} {i:<22} {why[:80]}")
- sa, sb = a.get("median_tok_s"), b.get("median_tok_s")
- rel, p, broke, fixed = prim
- print()
- if gbk:
- print(f"GUARD TRIPPED: {gbk} prose task(s) degenerated -- inspect before accepting.")
- if sa and sb:
- spd = 100 * (sb / sa - 1)
- print(f"median single-stream {sa} -> {sb} tok/s ({spd:+.1f}%)")
- if rel < -0.5 and spd > 0:
- print(f"TRADE: {abs(rel):.1f}% of code+reasoning for {spd:+.1f}% speed"
- f"{'' if p <= 0.05 else ' -- but the quality drop is within noise at this sample size'}")
- elif spd > 0 and rel >= -0.5:
- print(f"FREE: {spd:+.1f}% speed, primary quality unchanged (p={p:.3f})")
- n_prim = pk + pbk + pf + pbf
- print(f"\nresolution: {n_prim} primary tasks. A net loss of 5 with no gains is p={mcnemar_p(5,0):.3f}; "
- f"below ~4 net breaks this harness cannot separate the change from noise.")
- if a.get("concurrency", 1) > 1 or b.get("concurrency", 1) > 1:
- print("NOTE: a run used concurrency > 1; some flips may be batching noise, not the config.")
- return 0
- if __name__ == "__main__":
- ap = argparse.ArgumentParser()
- s = ap.add_subparsers(dest="cmd", required=True)
- r = s.add_parser("run"); r.add_argument("label")
- r.add_argument("--url", default="http://127.0.0.1:8888/v1/chat/completions")
- r.add_argument("--timeout", type=float, default=900)
- r.add_argument("--concurrency", type=int, default=1)
- r.add_argument("--only", default="", help="comma-separated categories")
- r.add_argument("--limit", type=int, default=0, help="first N tasks (smoke test)")
- c = s.add_parser("compare"); c.add_argument("a"); c.add_argument("b")
- n = ap.parse_args()
- if n.cmd == "run":
- run(n.label, n.url, n.timeout, n.concurrency, [x for x in n.only.split(",") if x], n.limit)
- else:
- sys.exit(compare(n.a, n.b))
|