""" Evaluation harness. Runs the IDENTICAL protocol on every model (base, E0, E1, E2, E3, E4-emb, E4-prob, and any 8B arms): 50 held-out prompts x 16 samples at fixed T=0.9, top_p=0.95, fixed seed. Metrics ------- quality mean judge quality over gate-passing stories gate_pass fraction passing all programmatic gates ends_cleanly fraction ending on terminal punctuation, not at the token cap pairwise mean pairwise embedding distance within a prompt's 16 samples logdet mean log-det volume of the 16-sample embedding set per prompt distinct4 unique 4-grams / total 4-grams, pooled across the 16 samples self_bleu mean BLEU-4 of each sample against the other 15 (LOWER = diverse) n_clusters conservative count of WELL-SEPARATED modes (silhouette > 0.50) eff_rank effective rank of the 16-sample Gram spectrum; the primary, continuous mode measure. 1 = all identical, 16 = all orthogonal tok_entropy mean per-token predictive entropy, top-20 truncated. MONITOR ONLY. On tok_entropy: token entropy is not story diversity. A policy can raise token entropy by getting noisier inside a single narrative mode, and can lower it while telling structurally different stories. It is reported to detect degenerate sampling, and is never optimized. """ from __future__ import annotations import argparse import csv import json import math import sys from collections import Counter from pathlib import Path import numpy as np ROOT = Path(__file__).resolve().parent.parent EVAL_TEMP, EVAL_TOP_P, EVAL_SEED, EVAL_N = 0.9, 0.95, 20260816, 16 # ------------------------------------------------------------------ n-grams def ngrams(toks: list[str], n: int) -> list[tuple]: return [tuple(toks[i:i + n]) for i in range(len(toks) - n + 1)] def norm_tokens(text: str) -> list[str]: import re return re.sub(r"[^\w\s]", " ", text.lower()).split() def distinct_n(texts: list[str], n: int = 4) -> float: all_g = [g for t in texts for g in ngrams(norm_tokens(t), n)] return len(set(all_g)) / len(all_g) if all_g else 0.0 def _bleu4(cand: list[str], refs: list[list[str]]) -> float: """BLEU-4 with brevity penalty, clipped counts against multiple refs.""" if len(cand) < 4: return 0.0 logs = [] for n in range(1, 5): cg = Counter(ngrams(cand, n)) if not cg: return 0.0 maxref = Counter() for r in refs: for g, c in Counter(ngrams(r, n)).items(): if c > maxref[g]: maxref[g] = c clipped = sum(min(c, maxref[g]) for g, c in cg.items()) total = sum(cg.values()) # smoothing: avoid log(0) collapsing the whole score logs.append(math.log((clipped + 1e-9) / total) if clipped else math.log(1e-9)) r_len = min((len(r) for r in refs), key=lambda L: (abs(L - len(cand)), L)) bp = 1.0 if len(cand) > r_len else math.exp(1 - r_len / max(1, len(cand))) # clamp: the 1e-9 smoothing can push an exact-match score a hair over 1.0 return min(1.0, max(0.0, bp * math.exp(sum(logs) / 4))) def self_bleu(texts: list[str]) -> float: toks = [norm_tokens(t) for t in texts] if len(toks) < 2: return 0.0 return float(np.mean([ _bleu4(toks[i], [toks[j] for j in range(len(toks)) if j != i]) for i in range(len(toks)) ])) # Silhouette threshold for declaring that real cluster structure exists. # Calibrated, not guessed: on 16 synthetic embeddings, a fully COLLAPSED set # peaks at silhouette 0.202 (k=4) and a fully SPREAD set at 0.195 (k=7) -- i.e. # silhouette cannot tell those apart at all, because k-means partitions # isotropic data regardless of spread. Only genuinely separated clusters score # high (two clear modes -> 0.976). So the threshold is set well above the # isotropic band, making n_clusters a CONSERVATIVE count of well-separated # modes: it returns 1 unless the structure is unmistakable. `eff_rank` is the # primary, continuous mode measure. SILHOUETTE_MIN = 0.50 def cluster_count(E: np.ndarray, kmax: int = 8) -> int: """k-means with silhouette-selected k. Conservative count of *well-separated* modes; returns 1 when there is no unmistakable cluster structure.""" from sklearn.cluster import KMeans from sklearn.metrics import silhouette_score n = E.shape[0] if n < 4: return 1 best_k, best_s = 1, -1.0 for k in range(2, min(kmax, n - 1) + 1): try: lab = KMeans(n_clusters=k, n_init=10, random_state=0).fit_predict(E) if len(set(lab)) < 2: continue s = silhouette_score(E, lab, metric="cosine") except Exception: continue if s > best_s: best_k, best_s = k, s return best_k if best_s > SILHOUETTE_MIN else 1 def topk_entropy(logprob_rows: list[dict]) -> float: """Mean per-token entropy from vLLM's top-k logprob table. Truncated at k, so it UNDERSTATES true entropy; comparable across models only because every model is measured with the same k. Monitor only. """ ents = [] for row in logprob_rows: lps = np.array([v for v in row.values()], dtype=np.float64) if lps.size == 0: continue p = np.exp(lps) s = p.sum() if s <= 0: continue p = p / s ents.append(float(-(p * np.log(p + 1e-12)).sum())) return float(np.mean(ents)) if ents else 0.0 # --------------------------------------------------------------------- main def eval_one(model_id: str, lora_path: str | None, label: str, prompts: list[dict], judge, enc, gpu_mem: float, dump_dir: Path, n_dump: int = 3) -> dict: import gates from diversity import (effective_rank, l2_normalize, logdet_volume, pairwise_deviation) from generate import build_llm, render_chat from vllm import SamplingParams from transformers import AutoTokenizer tok = AutoTokenizer.from_pretrained(model_id) llm = build_llm(model_id, gpu_mem_util=gpu_mem, seed=EVAL_SEED, enable_lora=bool(lora_path)) kw = {} if lora_path: from vllm.lora.request import LoRARequest kw["lora_request"] = LoRARequest(label, 1, lora_path) # logprobs=10, not 20: this table is only used for tok_entropy, which is # monitor-only and never optimized. 20 would materialize ~8M Python Logprob # objects per model (800 seqs x ~500 tok x 20). The truncation understates # entropy identically for every model, so cross-model comparison holds. # max_tokens matches training (1024) so eval and train share a length regime. sp = SamplingParams(n=EVAL_N, temperature=EVAL_TEMP, top_p=EVAL_TOP_P, max_tokens=1024, seed=EVAL_SEED, logprobs=10, skip_special_tokens=True) outs = llm.generate([render_chat(tok, p["prompt"]) for p in prompts], sp, **kw) records, per_prompt = [], [] for p, out in zip(prompts, outs): texts, ents, frs = [], [], [] for o in out.outputs: texts.append(o.text.strip()) frs.append(o.finish_reason or "") ents.append(topk_entropy( [{k: v.logprob for k, v in d.items()} for d in (o.logprobs or [])])) grs = [gates.check(t, finish_reason=f) for t, f in zip(texts, frs)] E = l2_normalize(np.asarray(enc.encode( texts, normalize_embeddings=True, batch_size=32, show_progress_bar=False, convert_to_numpy=True), dtype=np.float64)) per_prompt.append({ "prompt_id": p["id"], "prompt": p["prompt"], "texts": texts, "gates": [g.as_dict() for g in grs], "pairwise": float(pairwise_deviation(E).mean()), "logdet": float(logdet_volume(E)), "distinct4": distinct_n(texts, 4), "self_bleu": self_bleu(texts), "n_clusters": cluster_count(E), "eff_rank": effective_rank(E), "tok_entropy": float(np.mean(ents)) if ents else 0.0, "gate_pass": float(np.mean([g.passed for g in grs])), "ends_cleanly": float(np.mean([g.completeness for g in grs])), "words": float(np.mean([g.n_words for g in grs])), }) for t, g in zip(texts, grs): records.append((p["prompt"], t, g.passed)) del llm import gc, torch gc.collect(); torch.cuda.empty_cache() # judge only gate-passers, same rule as training idx = [i for i, (_, _, ok) in enumerate(records) if ok] scores = judge.score_many_sync([(records[i][0], records[i][1]) for i in idx]) if idx else [] q = [s.quality for s in scores] nov = [s.novelty for s in scores] row = { "model": label, "quality": float(np.mean(q)) if q else 0.0, "quality_sd": float(np.std(q)) if q else 0.0, "novelty": float(np.mean(nov)) if nov else 0.0, "gate_pass": float(np.mean([r["gate_pass"] for r in per_prompt])), "ends_cleanly": float(np.mean([r["ends_cleanly"] for r in per_prompt])), "pairwise": float(np.mean([r["pairwise"] for r in per_prompt])), "logdet": float(np.mean([r["logdet"] for r in per_prompt])), "distinct4": float(np.mean([r["distinct4"] for r in per_prompt])), "self_bleu": float(np.mean([r["self_bleu"] for r in per_prompt])), "n_clusters": float(np.mean([r["n_clusters"] for r in per_prompt])), "eff_rank": float(np.mean([r["eff_rank"] for r in per_prompt])), "tok_entropy": float(np.mean([r["tok_entropy"] for r in per_prompt])), "words": float(np.mean([r["words"] for r in per_prompt])), "n_stories": len(records), "n_judged": len(idx), } dump_dir.mkdir(parents=True, exist_ok=True) json.dump(per_prompt, open(dump_dir / f"{label}_full.json", "w"), indent=1) json.dump(per_prompt[:n_dump], open(dump_dir / f"{label}_examples.json", "w"), indent=1) return row def main(): ap = argparse.ArgumentParser() ap.add_argument("--models", required=True, help="JSON list of {label, model, lora} or path to such a file") ap.add_argument("--limit", type=int, default=None) ap.add_argument("--gpu-mem", type=float, default=0.85) ap.add_argument("--out", default="outputs/eval") ap.add_argument("--single", default=None, help="internal: evaluate exactly this label, then exit") args = ap.parse_args() import logbook from data import load_prompts from judge import build_judge from sentence_transformers import SentenceTransformer spec = json.loads(Path(args.models).read_text()) if Path(args.models).exists() \ else json.loads(args.models) prompts = load_prompts("eval", ROOT / "data") if args.limit: prompts = prompts[: args.limit] judge = build_judge(cache_path=str(ROOT / "cache" / "judge.sqlite"), concurrency=24) enc = SentenceTransformer("BAAI/bge-base-en-v1.5", device="cuda") out_dir = ROOT / args.out out_dir.mkdir(parents=True, exist_ok=True) rowdir = out_dir / "rows" rowdir.mkdir(parents=True, exist_ok=True) # ---- child mode: evaluate one model, write its row, exit -------------- if args.single: m = next(x for x in spec if x["label"] == args.single) lora = m.get("lora") if lora and not Path(ROOT / lora).exists() and not Path(lora).exists(): print(f"SKIP {m['label']}: adapter not found at {lora}") return 3 r = eval_one(m["model"], lora, m["label"], prompts, judge, enc, args.gpu_mem, out_dir / "samples") json.dump(r, open(rowdir / f"{m['label']}.json", "w"), indent=1) print(json.dumps(r, indent=1)) print("judge:", judge.health(), judge.cost_estimate(0.140, 0.280)) return 0 # ---- driver mode: one SUBPROCESS per model --------------------------- # vLLM v1 runs EngineCore in a child process; `del llm` does not reliably # reclaim its GPU memory, so a 7-model in-process loop OOMs on model 2 after # model 1 succeeds. Process isolation makes teardown unconditional. It also # means one model crashing cannot take the whole eval down. import subprocess for m in spec: dest = rowdir / f"{m['label']}.json" if dest.exists(): print(f"== SKIP {m['label']} (already evaluated)"); continue print(f"\n===== eval {m['label']} (subprocess) =====", flush=True) cmd = [sys.executable, "-u", str(Path(__file__).resolve()), "--models", args.models, "--out", args.out, "--gpu-mem", str(args.gpu_mem), "--single", m["label"]] if args.limit: cmd += ["--limit", str(args.limit)] rc = subprocess.run(cmd).returncode if rc != 0: print(f"!! {m['label']} exited {rc}; continuing with the rest") logbook.note(f"eval FAILED: {m['label']}", f"exit code {rc}", level="ALERT") rows = [] for m in spec: p = rowdir / f"{m['label']}.json" if p.exists(): rows.append(json.load(open(p))) if not rows: print("no models evaluated"); return 1 with open(out_dir / "results.csv", "w", newline="") as f: w = csv.DictWriter(f, fieldnames=list(rows[0].keys())) w.writeheader(); w.writerows(rows) print(f"\nwrote {out_dir/'results.csv'} ({len(rows)}/{len(spec)} models)") logbook.note("eval complete", f"{len(rows)}/{len(spec)} models\n\n" + logbook.table(rows, list(rows[0].keys()))) return 0 if __name__ == "__main__": sys.exit(main())