"""Phase-2 measurement harness: base MiniCPM4.1-8B + bge-small embedder + rules vs Kim-verified ground truth. Computes per-axis Cohen's kappa, per-category breakdowns (sparse → N/A), confusion matrices, an OOD report, and embedder boundary-recall vs hand-identified Focus candidates. Pure metrics (cohen_kappa, confusion) are dependency-free and unit-tested in tests/test_kappa.py. 8B calls go through a disk cache (eval/_cache/preds_8b.jsonl) keyed by sha1(prompt) + a thread pool, so runs are resumable and Phase 3 (Critical) reuses every cached response. Credentials (shared OpenBMB hackathon key) are read from the environment, never hardcoded: OPENBMB_BASE_URL, OPENBMB_TOKEN Run: python -m eval.kappa # 4 Phase-2 axes (Focus, Technique, Interaction, Input Quality) python -m eval.kappa --limit 5 # quick probe on 5 convs (latency/parse check before full run) """ from __future__ import annotations import concurrent.futures as cf import hashlib import json import os import sys import threading import numpy as np from prompt_card.scoring import observable_axes as OA from prompt_card.scoring.embedding import FastEmbedder HERE = os.path.dirname(__file__) ROOT = os.path.dirname(HERE) VP = os.path.join(ROOT, "prompt_card", "training", "validation_pool") CACHE = os.path.join(HERE, "_cache", "preds_8b.jsonl") TECH = ["zero_shot_role", "few_shot", "thought_generation", "decomposition", "self_criticism", "flipped"] IQ = ["goal_stated", "specific_specification"] CE = ["skepticism", "rebuttal", "source_request", "independent_verification", "re_questioning"] # ---------------- pure metrics (no deps) ---------------- def confusion(y_true, y_pred, labels): idx = {l: i for i, l in enumerate(labels)} m = [[0] * len(labels) for _ in labels] for t, p in zip(y_true, y_pred): m[idx[t]][idx[p]] += 1 return m def cohen_kappa(y_true, y_pred): """Cohen's kappa. Returns None when undefined (empty, or both raters use one identical label → chance agreement is 1, kappa degenerate). Caller reports those as N/A.""" n = len(y_true) if n == 0: return None labels = sorted(set(y_true) | set(y_pred)) if len(labels) == 1: return None # degenerate: everything one class (e.g. an all-negative sparse category) m = confusion(y_true, y_pred, labels) po = sum(m[i][i] for i in range(len(labels))) / n rows = [sum(r) for r in m] cols = [sum(m[i][j] for i in range(len(labels))) for j in range(len(labels))] pe = sum((rows[i] / n) * (cols[i] / n) for i in range(len(labels))) if pe >= 1.0: return None return (po - pe) / (1 - pe) def binary_counts(y_true, y_pred): tp = sum(1 for t, p in zip(y_true, y_pred) if t and p) fp = sum(1 for t, p in zip(y_true, y_pred) if not t and p) fn = sum(1 for t, p in zip(y_true, y_pred) if t and not p) tn = sum(1 for t, p in zip(y_true, y_pred) if not t and not p) return dict(tp=tp, fp=fp, fn=fn, tn=tn) def prf(c): tp, fp, fn = c["tp"], c["fp"], c["fn"] p = tp / (tp + fp) if tp + fp else None r = tp / (tp + fn) if tp + fn else None f1 = (2 * p * r / (p + r)) if (p and r) else None return p, r, f1 # ---------------- cached concurrent 8B ---------------- class CachedClient: """Wraps any `generate(prompt)->str` client with a sha1-keyed disk cache + thread pool.""" def __init__(self, base, workers=8): self.base = base self.workers = workers self._lock = threading.Lock() self.cache = {} if os.path.exists(CACHE): for line in open(CACHE): try: d = json.loads(line) self.cache[d["k"]] = d["v"] except Exception: pass self.hits = 0 self.misses = 0 @staticmethod def _key(prompt): return hashlib.sha1(prompt.encode("utf-8")).hexdigest() def _persist(self, k, v): with self._lock: with open(CACHE, "a") as f: f.write(json.dumps({"k": k, "v": v}, ensure_ascii=False) + "\n") def run_all(self, prompts): """Return {prompt: response} for a list of prompts, using cache + concurrency.""" uniq = list(dict.fromkeys(prompts)) todo = [p for p in uniq if self._key(p) not in self.cache] self.hits += len(uniq) - len(todo) def work(p): v = self.base.generate(p) k = self._key(p) self.cache[k] = v self._persist(k, v) return p if todo: with cf.ThreadPoolExecutor(max_workers=self.workers) as ex: for i, _ in enumerate(ex.map(work, todo), 1): self.misses += 1 if i % 50 == 0: print(f" ... {i}/{len(todo)} new 8B calls", flush=True) return {p: self.cache[self._key(p)] for p in uniq} # ---------------- data loading ---------------- def load_convs(): convs = {} for fn in ("pool.jsonl", "supplement.jsonl"): for line in open(os.path.join(VP, fn)): c = json.loads(line) convs[c["id"]] = c return convs def user_turns(conv): return [t["text"] for t in conv["turns"] if t["role"] == "user"] def load_gt(): return [json.loads(l) for l in open(os.path.join(VP, "ground_truth.jsonl"))] # ---------------- prediction assembly ---------------- def build_prompts(gt, convs, embedder, focus_tmax=0.70, builders=OA, axes=("technique", "input_quality", "interaction", "focus")): """Return (prompts, plan, focus_geom). `builders` supplies build_*_prompt (swap for prompt versions); `axes` selects which axes to assemble (lets the cascade re-run only changed axes).""" prompts, plan = [], [] focus_geom = {} # conv_id -> list of (i, cosine) for every adjacent user-turn boundary for r in gt: cid = r["id"] ut = user_turns(convs[cid]) if "technique" in axes: for row in r["technique"]: i = int(row["turn"][1:]) - 1 p = builders.build_technique_prompt(ut[i]) prompts.append(p); plan.append(("technique", cid, row["turn"], p)) if "input_quality" in axes: for row in r["input_quality"]: i = int(row["turn"][1:]) - 1 p = builders.build_input_quality_prompt(ut[i]) prompts.append(p); plan.append(("input_quality", cid, row["turn"], p)) if "interaction" in axes: for row in r["interaction"]: i = int(row["turn"][1:]) - 1 p = builders.build_interaction_prompt(ut[i - 1], ut[i]) prompts.append(p); plan.append(("interaction", cid, row["turn"], p)) # focus: embedder geometry over ALL adjacent boundaries; 8B only on cos= 2: vecs = embedder.embed(ut) geom = [] for i in range(len(ut) - 1): cos = float(np.dot(vecs[i], vecs[i + 1])) geom.append((i, cos)) if cos < focus_tmax: p = builders.build_focus_boundary_prompt(ut[i], ut[i + 1]) prompts.append(p); plan.append(("focus", cid, f"U{i+1}→U{i+2}", p)) focus_geom[cid] = geom return prompts, plan, focus_geom # ---------------- axis scoring ---------------- def score_binary_axis(gt, responses, plan_index, axis, fields): """For a per-turn multi-binary axis (technique/input_quality): return per-field (yt,yp) + parse fails.""" per = {f: ([], []) for f in fields} parse_fail = 0 gtmap = {r["id"]: r for r in gt} key = "types" if axis in ("technique", "critical") else "features" for (ax, cid, turn, prompt) in plan_index[axis]: row = next(rr for rr in gtmap[cid][axis] if rr["turn"] == turn) pred = OA.parse(responses[prompt], fields) if pred is None: parse_fail += 1 pred = {f: False for f in fields} for f in fields: per[f][0].append(int(f in row[key])) per[f][1].append(int(bool(pred.get(f)))) return per, parse_fail def score_interaction(gt, responses, plan_index): yt, yp = [], [] parse_fail = 0 gtmap = {r["id"]: r for r in gt} for (ax, cid, turn, prompt) in plan_index["interaction"]: row = next(rr for rr in gtmap[cid]["interaction"] if rr["turn"] == turn) pred = OA.parse(responses[prompt], ("refinement_attempt",)) if pred is None: parse_fail += 1 pred = {} yt.append(int(bool(row["refinement"]))) yp.append(int(bool(pred.get("refinement_attempt")))) return yt, yp, parse_fail def score_focus(gt, responses, plan_index, focus_geom, threshold): """Binary shift vs not over EVERY adjacent boundary; embedder gates 8B at `threshold`. Also returns embedder recall on GT topic_shift boundaries and a 3-class tally on co-identified.""" rel_pred = {(cid, turn): OA.parse(responses[p], ("relation",)) for (ax, cid, turn, p) in plan_index["focus"]} gtmap = {r["id"]: r for r in gt} yt, yp = [], [] gt_shift_total = recall_hit = 0 cand_total = 0 three = {} # (gt_rel, pred_rel) -> count on co-identified candidates for r in gt: cid = r["id"] gt_rel = {f"U{c['a'][1:]}→U{c['b'][1:]}" if False else f"{c['a']}→{c['b']}": c["relation"] for c in r["focus"]} for (i, cos) in focus_geom.get(cid, []): bkey = f"U{i+1}→U{i+2}" gtr = gt_rel.get(bkey) is_gt_shift = (gtr == "topic_shift") yt.append(int(is_gt_shift)) is_cand = cos < threshold cand_total += int(is_cand) pred_shift = 0 if is_cand: pr = rel_pred.get((cid, bkey)) or {} prel = pr.get("relation") pred_shift = int(prel == "topic_shift") if gtr is not None: # co-identified: both GT and embedder flagged this boundary three[(gtr, prel)] = three.get((gtr, prel), 0) + 1 yp.append(pred_shift) if is_gt_shift: gt_shift_total += 1 recall_hit += int(is_cand) recall = recall_hit / gt_shift_total if gt_shift_total else None return yt, yp, dict(recall=recall, gt_shift=gt_shift_total, cand_total=cand_total, three=three) # ---------------- OOD ---------------- def ood_report(gt, convs, embedder): ids = [r["id"] for r in gt] centroids = [] for cid in ids: ut = user_turns(convs[cid]) v = embedder.embed(ut) c = v.mean(axis=0) centroids.append(c / (np.linalg.norm(c) + 1e-9)) M = np.array(centroids) glob = M.mean(axis=0); glob /= np.linalg.norm(glob) + 1e-9 sims = M @ glob mu, sd = float(sims.mean()), float(sims.std()) flagged = [(ids[i], float(sims[i])) for i in range(len(ids)) if sims[i] < mu - 2 * sd] return dict(mu=mu, sd=sd, flagged=sorted(flagged, key=lambda x: x[1])) # ---------------- main ---------------- def _fmt_k(k): return "N/A" if k is None else f"{k:+.3f}" def main(limit=None, workers=8): base_url = os.environ.get("OPENBMB_BASE_URL") token = os.environ.get("OPENBMB_TOKEN") if not base_url or not token: print("ERROR: set OPENBMB_BASE_URL and OPENBMB_TOKEN in the environment (shared hackathon key).", file=sys.stderr) sys.exit(2) from prompt_card.llm.minicpm import MiniCPMClient base = MiniCPMClient(base_url, token) gt = load_gt() if limit: gt = gt[:limit] convs = load_convs() embedder = FastEmbedder() print(f"[measure] {len(gt)} convs; embedding + assembling prompts ...", flush=True) prompts, plan, focus_geom = build_prompts(gt, convs, embedder) plan_index = {} for item in plan: plan_index.setdefault(item[0], []).append(item) print(f"[measure] {len(prompts)} prompts " f"(tech {len(plan_index.get('technique',[]))}, iq {len(plan_index.get('input_quality',[]))}, " f"inter {len(plan_index.get('interaction',[]))}, focus {len(plan_index.get('focus',[]))})", flush=True) client = CachedClient(base, workers=workers) responses = client.run_all(prompts) print(f"[measure] 8B done (cache hits {client.hits}, new {client.misses})", flush=True) # axis scoring tech_per, tech_fail = score_binary_axis(gt, responses, plan_index, "technique", TECH) iq_per, iq_fail = score_binary_axis(gt, responses, plan_index, "input_quality", IQ) inter_yt, inter_yp, inter_fail = score_interaction(gt, responses, plan_index) # focus threshold sweep sweep = {} for T in [0.40, 0.45, 0.50, 0.55, 0.60, 0.65, 0.70]: yt, yp, info = score_focus(gt, responses, plan_index, focus_geom, T) sweep[T] = (cohen_kappa(yt, yp), info["recall"], info["cand_total"], yt, yp, info) # pick T maximizing kappa (tie-break higher recall) bestT = max(sweep, key=lambda T: (sweep[T][0] if sweep[T][0] is not None else -9, sweep[T][1] or 0)) fk, frec, fcand, fyt, fyp, finfo = sweep[bestT] write_results(gt, tech_per, tech_fail, iq_per, iq_fail, inter_yt, inter_yp, inter_fail, sweep, bestT, ood_report(gt, convs, embedder)) print("[measure] wrote eval/measurement_results.md", flush=True) def write_results(gt, tech_per, tech_fail, iq_per, iq_fail, inter_yt, inter_yp, inter_fail, sweep, bestT, ood): L = ["# Phase-2 measurement — base MiniCPM4.1-8B + bge-small + rules vs ground truth", ""] L.append(f"Validation set: {len(gt)} conversations. Metric: Cohen's κ (per axis & per category). " "Sparse categories (no positives) → κ undefined → **N/A**. Confusion as [TN FP / FN TP].") L.append("") def axis_block(title, scope, per, fail, fields): L.append(f"## {title}") L.append(f"_{scope}_ · parse failures: {fail}") L.append("| category | κ | TN | FP | FN | TP | precision | recall | f1 |") L.append("|---|---|---|---|---|---|---|---|---|") for f in fields: yt, yp = per[f] k = cohen_kappa(yt, yp); c = binary_counts(yt, yp); p, r, f1 = prf(c) note = " ← N/A (0 positives)" if sum(yt) == 0 else "" L.append(f"| {f} | {_fmt_k(k)}{note} | {c['tn']} | {c['fp']} | {c['fn']} | {c['tp']} | " f"{'-' if p is None else f'{p:.2f}'} | {'-' if r is None else f'{r:.2f}'} | " f"{'-' if f1 is None else f'{f1:.2f}'} |") # axis-level "any" binary anyt = [int(any(per[f][0][j] for f in fields)) for j in range(len(per[fields[0]][0]))] anyp = [int(any(per[f][1][j] for f in fields)) for j in range(len(per[fields[0]][1]))] L.append(f"\n**Axis-level (any {title.split()[0].lower()} present): κ = {_fmt_k(cohen_kappa(anyt, anyp))}** " f"(n={len(anyt)} turns, {sum(anyt)} positive).") L.append("") axis_block("Technique (6 binary)", "first ≤3 user turns/conv", tech_per, tech_fail, TECH) axis_block("Input Quality (2 binary)", "first ≤3 user turns/conv", iq_per, iq_fail, IQ) L.append("## Interaction (refinement_attempt)") L.append(f"_all follow-up turns_ · parse failures: {inter_fail}") k = cohen_kappa(inter_yt, inter_yp); c = binary_counts(inter_yt, inter_yp); p, r, f1 = prf(c) L.append(f"- **κ = {_fmt_k(k)}** · n={len(inter_yt)}, positives={sum(inter_yt)}") L.append(f"- confusion [TN {c['tn']} · FP {c['fp']} / FN {c['fn']} · TP {c['tp']}] · " f"P {'-' if p is None else f'{p:.2f}'} R {'-' if r is None else f'{r:.2f}'} F1 {'-' if f1 is None else f'{f1:.2f}'}") L.append("") L.append("## Focus (topic_shift detection, embedder-gated)") L.append("Binary shift-vs-not over **every** adjacent user-turn boundary; the embedder gates which " "boundaries the 8B classifies (cosine < T). Threshold swept; κ-maximizing T selected.") L.append("| T | κ (shift) | embedder recall@T (on GT shifts) | candidates flagged |") L.append("|---|---|---|---|") for T in sorted(sweep): k, rec, cand, *_ = sweep[T] mark = " ← selected" if T == bestT else "" L.append(f"| {T:.2f} | {_fmt_k(k)} | {'-' if rec is None else f'{rec:.2f}'} | {cand} |{mark}") fk, frec, fcand, fyt, fyp, finfo = sweep[bestT] cc = binary_counts(fyt, fyp) rec_str = "-" if frec is None else f"{frec:.2f}" hits = "" if frec is None else f" ({int(round(frec * finfo['gt_shift']))}/{finfo['gt_shift']} GT shifts flagged)" L.append("") L.append(f"**Selected T={bestT:.2f}: κ = {_fmt_k(fk)}**, embedder boundary-recall = {rec_str}{hits}. " f"Confusion [TN {cc['tn']} · FP {cc['fp']} / FN {cc['fn']} · TP {cc['tp']}].") if finfo["three"]: L.append("\n3-class agreement on co-identified candidates (GT rel → 8B rel):") for (g, p2), n in sorted(finfo["three"].items(), key=lambda x: -x[1]): L.append(f"- {g} → {p2}: {n}") L.append("\n_Embedder boundary-recall is the model-agnostic metric Kim requested: of the boundaries Kim " "hand-identified as topic_shift, how many the production embedder surfaces as candidates._") L.append("") L.append("## OOD report (validation-set self-check)") L.append(f"Per-conv mean-user-turn embedding vs the set centroid: μ_sim={ood['mu']:.3f}, σ={ood['sd']:.3f}. " f"Flagged (sim < μ−2σ, i.e. atypical conversations): {len(ood['flagged'])}.") for cid, s in ood["flagged"][:15]: L.append(f"- `{cid[:12]}` sim={s:.3f}") L.append("\n_In production this same distance flags user uploads far from the validation distribution " "(low-confidence / out-of-scope scoring)._") L.append("") with open(os.path.join(HERE, "measurement_results.md"), "w") as f: f.write("\n".join(L) + "\n") if __name__ == "__main__": import argparse ap = argparse.ArgumentParser() ap.add_argument("--limit", type=int, default=None) ap.add_argument("--workers", type=int, default=8) args = ap.parse_args() main(limit=args.limit, workers=args.workers)