qeval.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. #!/usr/bin/env python3
  2. """Scored quality gate for DS-V4.1-Flash config changes.
  3. python3 qeval.py run before --url http://127.0.0.1:8888/v1/chat/completions
  4. ... change config, reboot ...
  5. python3 qeval.py run after --url ...
  6. python3 qeval.py compare qeval-before.json qeval-after.json
  7. 55 auto-scored tasks (code executed against hidden asserts, JSON schema-checked, numeric answers
  8. matched, format constraints enforced, prose checked only for objective degeneration). No LLM judge,
  9. so the score is reproducible.
  10. Run at concurrency 1. Greedy output is only reproducible run to run when the batch composition is
  11. fixed (Mia's README, "Correctness notes"); at concurrency > 1 a pass->fail flip can be batching
  12. noise rather than the config change. `--concurrency N` exists for speed but forfeits that.
  13. The comparison is PAIRED: the same tasks before and after, so what matters is the flip matrix
  14. (pass->fail vs fail->pass), not the two pass rates. McNemar's exact test gives the p-value.
  15. """
  16. import argparse, concurrent.futures as cf, json, math, statistics as st, sys, time, urllib.request
  17. sys.path.insert(0, __file__.rsplit("/", 1)[0])
  18. import qeval_tasks as qt
  19. def ask(url, task, timeout):
  20. body = {"model": "deepseek-v4.1-flash", "temperature": 0, "max_tokens": task["max_tokens"],
  21. "stream": False, "chat_template_kwargs": {"thinking": bool(task["thinking"])},
  22. # Per-request DSpark acceptance telemetry. Costs no GPU time: the counters are
  23. # incremented unconditionally in batch_result_processor.py:773-777 and this flag
  24. # only decides whether they are serialised back onto the response.
  25. "return_spec_tokens_details": True,
  26. "messages": [{"role": "user", "content": task["prompt"]}]}
  27. t0 = time.time()
  28. req = urllib.request.Request(url, data=json.dumps(body).encode(),
  29. headers={"Content-Type": "application/json"})
  30. r = json.load(urllib.request.urlopen(req, timeout=timeout))
  31. dt = time.time() - t0
  32. ch = r["choices"][0]
  33. msg = ch["message"]
  34. content = msg.get("content") or ""
  35. n = r["usage"]["completion_tokens"]
  36. return {"content": content,
  37. "reasoning_chars": len(msg.get("reasoning_content") or msg.get("reasoning") or ""),
  38. "finish_reason": ch.get("finish_reason"), "completion_tokens": n,
  39. "seconds": round(dt, 3), "tok_s": round(n / dt, 2) if dt > 0 else None,
  40. "spec": (r.get("sglext") or {}).get("spec_tokens_details")}
  41. def score_one(url, task, timeout):
  42. out = {"id": task["id"], "category": task["category"]}
  43. try:
  44. out.update(ask(url, task, timeout))
  45. except Exception as exc:
  46. return {**out, "pass": False, "why": f"request failed: {exc!r}"}
  47. try:
  48. ok, why = task["checker"](out["content"])
  49. except Exception as exc:
  50. ok, why = False, f"checker raised {exc!r}"
  51. out["pass"] = bool(ok)
  52. out["why"] = "" if ok else str(why)[:160]
  53. return out
  54. def run(label, url, timeout, concurrency, only, limit=0):
  55. tasks = [t for t in qt.TASKS if not only or t["category"] in only]
  56. if limit: tasks = tasks[:limit]
  57. print(f"{len(tasks)} tasks, concurrency {concurrency}, {url}")
  58. t0 = time.time()
  59. if concurrency > 1:
  60. with cf.ThreadPoolExecutor(concurrency) as ex:
  61. results = list(ex.map(lambda t: score_one(url, t, timeout), tasks))
  62. else:
  63. results = []
  64. for t in tasks:
  65. r = score_one(url, t, timeout)
  66. results.append(r)
  67. print(f" {'PASS' if r['pass'] else 'FAIL'} {r['id']:<22} {r.get('why','')[:70]}", flush=True)
  68. wall = time.time() - t0
  69. speeds = [r["tok_s"] for r in results if r.get("tok_s")]
  70. trunc = sum(1 for r in results if r.get("finish_reason") == "length")
  71. out = {"label": label, "url": url, "concurrency": concurrency, "wall_s": round(wall, 1),
  72. "utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
  73. "median_tok_s": round(st.median(speeds), 2) if speeds else None,
  74. "truncated": trunc, "results": results}
  75. npass = sum(1 for r in results if r["pass"])
  76. print(f"\n{npass}/{len(results)} passed ({100*npass/len(results):.1f}%), "
  77. f"{trunc} hit the token cap, wall {wall/60:.1f} min, "
  78. f"median {out['median_tok_s']} tok/s")
  79. by = {}
  80. for r in results:
  81. by.setdefault(r["category"], []).append(r["pass"])
  82. for c, v in sorted(by.items()):
  83. print(f" {c:<8} {sum(v):>2}/{len(v)}")
  84. json.dump(out, open(f"qeval-{label}.json", "w"), indent=1)
  85. print(f"wrote qeval-{label}.json")
  86. def mcnemar_p(b, c):
  87. """Two-sided exact McNemar: b = pass->fail, c = fail->pass, H0: p = 0.5."""
  88. n = b + c
  89. if n == 0:
  90. return 1.0
  91. k = min(b, c)
  92. tail = sum(math.comb(n, i) for i in range(0, k + 1)) / 2 ** n
  93. return min(1.0, 2 * tail)
  94. PRIMARY = ("code", "reason", "math") # what "quality" means here, per the owner
  95. SECONDARY = ("json", "format") # instruction following: reported, not decisive
  96. GUARD = ("prose",) # objective degeneration only, never a quality score
  97. def _block(ids, ra, rb, cats):
  98. kept = broke = fixed = bf = 0
  99. flips = []
  100. for i in ids:
  101. if ra[i]["category"] not in cats:
  102. continue
  103. pa_, pb_ = ra[i]["pass"], rb[i]["pass"]
  104. if pa_ and pb_: kept += 1
  105. elif pa_ and not pb_: broke += 1; flips.append(("BROKE", i, rb[i].get("why", "")))
  106. elif pb_: fixed += 1; flips.append(("fixed", i, ra[i].get("why", "")))
  107. else: bf += 1
  108. return kept, broke, fixed, bf, flips
  109. def _line(tag, kept, broke, fixed, bf):
  110. n = kept + broke + fixed + bf
  111. if n == 0:
  112. return None
  113. before, after = kept + broke, kept + fixed
  114. rel = (after - before) / before * 100 if before else 0.0
  115. p = mcnemar_p(broke, fixed)
  116. print(f"{tag:<28} {before:>3}/{n} -> {after:>3}/{n} "
  117. f"kept {kept:>3} BROKE {broke:>3} fixed {fixed:>3} both-fail {bf:>3} "
  118. f"{rel:+6.1f}% p={p:.3f}")
  119. return rel, p, broke, fixed
  120. def compare(pa, pb):
  121. a, b = json.load(open(pa)), json.load(open(pb))
  122. ra = {r["id"]: r for r in a["results"]}
  123. rb = {r["id"]: r for r in b["results"]}
  124. ids = [t["id"] for t in qt.TASKS if t["id"] in ra and t["id"] in rb]
  125. print(f"paired on {len(ids)} tasks: {a['label']} -> {b['label']}\n")
  126. print(f"{'':<28} {'before':>7} {'after':>7}")
  127. pk, pbk, pf, pbf, pflips = _block(ids, ra, rb, PRIMARY)
  128. prim = _line("PRIMARY code+reason+math", pk, pbk, pf, pbf)
  129. sk, sbk, sf, sbf, sflips = _block(ids, ra, rb, SECONDARY)
  130. _line("secondary json+format", sk, sbk, sf, sbf)
  131. gk, gbk, gf, gbf, gflips = _block(ids, ra, rb, GUARD)
  132. _line("guard prose (degeneration)", gk, gbk, gf, gbf)
  133. percat = {}
  134. for i in ids:
  135. c = ra[i]["category"]
  136. d = percat.setdefault(c, [0, 0, 0, 0])
  137. pa_, pb_ = ra[i]["pass"], rb[i]["pass"]
  138. d[0 if (pa_ and pb_) else 1 if pa_ else 2 if pb_ else 3] += 1
  139. print(f"\n{'category':<9}{'kept':>6}{'broke':>7}{'fixed':>7}{'both-fail':>11}")
  140. for c, d in sorted(percat.items()):
  141. print(f"{c:<9}{d[0]:>6}{d[1]:>7}{d[2]:>7}{d[3]:>11}")
  142. allflips = pflips + sflips + gflips
  143. if allflips:
  144. print("\nflips:")
  145. for kind, i, why in allflips:
  146. print(f" {kind:<6} {i:<22} {why[:80]}")
  147. sa, sb = a.get("median_tok_s"), b.get("median_tok_s")
  148. rel, p, broke, fixed = prim
  149. print()
  150. if gbk:
  151. print(f"GUARD TRIPPED: {gbk} prose task(s) degenerated -- inspect before accepting.")
  152. if sa and sb:
  153. spd = 100 * (sb / sa - 1)
  154. print(f"median single-stream {sa} -> {sb} tok/s ({spd:+.1f}%)")
  155. if rel < -0.5 and spd > 0:
  156. print(f"TRADE: {abs(rel):.1f}% of code+reasoning for {spd:+.1f}% speed"
  157. f"{'' if p <= 0.05 else ' -- but the quality drop is within noise at this sample size'}")
  158. elif spd > 0 and rel >= -0.5:
  159. print(f"FREE: {spd:+.1f}% speed, primary quality unchanged (p={p:.3f})")
  160. n_prim = pk + pbk + pf + pbf
  161. print(f"\nresolution: {n_prim} primary tasks. A net loss of 5 with no gains is p={mcnemar_p(5,0):.3f}; "
  162. f"below ~4 net breaks this harness cannot separate the change from noise.")
  163. if a.get("concurrency", 1) > 1 or b.get("concurrency", 1) > 1:
  164. print("NOTE: a run used concurrency > 1; some flips may be batching noise, not the config.")
  165. return 0
  166. if __name__ == "__main__":
  167. ap = argparse.ArgumentParser()
  168. s = ap.add_subparsers(dest="cmd", required=True)
  169. r = s.add_parser("run"); r.add_argument("label")
  170. r.add_argument("--url", default="http://127.0.0.1:8888/v1/chat/completions")
  171. r.add_argument("--timeout", type=float, default=900)
  172. r.add_argument("--concurrency", type=int, default=1)
  173. r.add_argument("--only", default="", help="comma-separated categories")
  174. r.add_argument("--limit", type=int, default=0, help="first N tasks (smoke test)")
  175. c = s.add_parser("compare"); c.add_argument("a"); c.add_argument("b")
  176. n = ap.parse_args()
  177. if n.cmd == "run":
  178. run(n.label, n.url, n.timeout, n.concurrency, [x for x in n.only.split(",") if x], n.limit)
  179. else:
  180. sys.exit(compare(n.a, n.b))