Code / experiments/micro/expG_decision_stability/benchmark.py

experiments/micro/expG_decision_stability/benchmark.py 228 lines
#!/usr/bin/env python3
# =============================================================================
#  Project   : localvm-research
#  File      : experiments/micro/expG_decision_stability/benchmark.py
#  Purpose   : Joint (cheap-pass margin × agreement) matrix across bit-widths —
#              the Gate-Zero measurement for margin-gated escalation (G02/G23)
#  Author    : Simon-Pierre Boucher
#  Contact   : contact@spboucher.ai
#  Created   : 2026-08-12
#  Modified  : 2026-08-12
#  Platform  : macOS / Apple Silicon (arm64) — MLX / Metal
#  License   : All rights reserved (research code)
# =============================================================================
"""Experiment G — decision stability (charter §9.G).

Generates greedy continuations with a bf16 reference model, teacher-forces
low-bit quantized variants over the same sequences, and records per-position
margin/agreement/KL. Outputs the joint matrix, AUROC of margin as a
disagreement detector, and escalation curves.

Usage:
    .venv/bin/python benchmark.py [--model mlx-community/Qwen3-1.7B-bf16]
        [--gen-tokens 128] [--bits 2,3,4,8] [--per-domain 8]
"""

from __future__ import annotations

import argparse
import gc
import json
import sys
import time
from datetime import datetime, timezone
from pathlib import Path

import mlx.core as mx
import mlx.nn as nn
import numpy as np
from mlx_lm import load

REPO_ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(REPO_ROOT / "benchmarks"))
from hardware_manifest import collect_manifest  # noqa: E402


def greedy_generate(model, tokenizer, prompt_ids: list[int], n_tokens: int) -> list[int]:
    """Greedy generation without sampling helpers — deterministic, no cache reuse
    across prompts. Returns generated token ids."""
    tokens = list(prompt_ids)
    generated = []
    from mlx_lm.models.cache import make_prompt_cache

    cache = make_prompt_cache(model)
    inp = mx.array(tokens)[None]
    for _ in range(n_tokens):
        logits = model(inp, cache=cache)
        nxt = int(mx.argmax(logits[0, -1]).item())
        if nxt == tokenizer.eos_token_id:
            break
        generated.append(nxt)
        inp = mx.array([[nxt]])
    return generated


def teacher_forced_stats(model, full_ids: list[int], start: int) -> dict:
    """Forward the full sequence once; return per-position stats for positions
    predicting tokens at indices [start, len(full_ids)) — i.e., logits at
    positions start-1 .. len-2."""
    logits = model(mx.array(full_ids)[None])[0]  # (T, V)
    sel = logits[start - 1 : len(full_ids) - 1].astype(mx.float32)
    top2 = mx.topk(sel, 2, axis=-1)  # values sorted ascending in MLX topk
    argmax = mx.argmax(sel, axis=-1)
    logprobs = sel - mx.logsumexp(sel, axis=-1, keepdims=True)
    mx.eval(top2, argmax, logprobs)
    v = np.array(top2)
    margin = v[:, 1] - v[:, 0] if v[0, 1] >= v[0, 0] else v[:, 0] - v[:, 1]
    return {
        "margin": np.abs(margin),
        "argmax": np.array(argmax),
        # float16 storage: 48 trajectories × (128, ~152k vocab) would be ~4 GB
        # in float32; KL is computed in float32 at use time.
        "logprobs": np.array(logprobs).astype(np.float16),
    }


def auroc(scores: np.ndarray, labels: np.ndarray) -> float:
    """AUROC of `scores` (higher = predicted positive) for binary labels.
    Here: score = -margin (low margin should predict disagreement=1)."""
    pos, neg = scores[labels == 1], scores[labels == 0]
    if len(pos) == 0 or len(neg) == 0:
        return float("nan")
    order = np.argsort(np.concatenate([pos, neg]), kind="mergesort")
    ranks = np.empty(len(order)); ranks[order] = np.arange(1, len(order) + 1)
    # average ranks for ties
    allv = np.concatenate([pos, neg])
    sorted_v = allv[order]
    i = 0
    while i < len(sorted_v):
        j = i
        while j + 1 < len(sorted_v) and sorted_v[j + 1] == sorted_v[i]:
            j += 1
        if j > i:
            ranks[order[i : j + 1]] = ranks[order[i : j + 1]].mean()
        i = j + 1
    r_pos = ranks[: len(pos)].sum()
    return float((r_pos - len(pos) * (len(pos) + 1) / 2) / (len(pos) * len(neg)))


def escalation_curve(margins: np.ndarray, agree: np.ndarray, points: int = 200) -> list[dict]:
    """For threshold τ over margins: escalate tokens with margin < τ (assume the
    escalated decision becomes correct). Report escalated fraction vs residual
    disagreement (disagreements with margin ≥ τ)."""
    qs = np.quantile(margins, np.linspace(0, 1, points))
    out, n = [], len(margins)
    for tau in qs:
        esc = margins < tau
        residual = np.sum((~esc) & (agree == 0)) / n
        out.append({"tau": float(tau), "escalated_frac": float(esc.mean()),
                    "residual_disagree": float(residual)})
    return out


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--model", default="mlx-community/Qwen3-1.7B-bf16")
    ap.add_argument("--gen-tokens", type=int, default=128)
    ap.add_argument("--bits", default="2,3,4,8")
    ap.add_argument("--per-domain", type=int, default=8)
    ap.add_argument("--group-size", type=int, default=64)
    args = ap.parse_args()
    bits_list = [int(b) for b in args.bits.split(",")]

    prompts_file = REPO_ROOT / "benchmarks" / "datasets" / "eval_prompts.json"
    domains = json.loads(prompts_file.read_text())["domains"]

    print(f"loading reference {args.model} …", flush=True)
    model, tokenizer = load(args.model)

    # -------- pass 1: reference greedy trajectories + reference stats
    trajectories = []  # {domain, prompt_ids, full_ids, start}
    t0 = time.time()
    for domain, plist in domains.items():
        for prompt in plist[: args.per_domain]:
            msgs = [{"role": "user", "content": prompt}]
            ids = tokenizer.apply_chat_template(msgs, add_generation_prompt=True)
            gen = greedy_generate(model, tokenizer, ids, args.gen_tokens)
            if len(gen) < 8:
                continue
            trajectories.append({"domain": domain, "full_ids": list(ids) + gen, "start": len(ids)})
        print(f"  generated {domain}", flush=True)
    print(f"reference generation done in {time.time() - t0:.0f}s "
          f"({len(trajectories)} trajectories)", flush=True)

    ref_stats = [teacher_forced_stats(model, t["full_ids"], t["start"]) for t in trajectories]

    # -------- pass 2: quantized variants, teacher-forced on the same ids
    per_bits: dict[int, dict] = {}
    for bits in bits_list:
        print(f"quantizing to {bits}-bit (group {args.group_size}) …", flush=True)
        del model
        gc.collect(); mx.clear_cache()
        model, _ = load(args.model)
        nn.quantize(model, group_size=args.group_size, bits=bits,
                    class_predicate=lambda p, m: isinstance(m, nn.Linear)
                    and m.weight.shape[-1] % args.group_size == 0)
        rows = []
        for t, ref in zip(trajectories, ref_stats):
            qs = teacher_forced_stats(model, t["full_ids"], t["start"])
            ref_next = np.array(t["full_ids"][t["start"]:])  # actual (=ref argmax) tokens
            agree = (qs["argmax"] == ref_next).astype(np.int8)
            # KL(ref||q) per position
            ref_lp = ref["logprobs"].astype(np.float32)
            kl = np.sum(np.exp(ref_lp) * (ref_lp - qs["logprobs"].astype(np.float32)), axis=-1)
            rows.append({"domain": t["domain"], "margin": qs["margin"],
                         "agree": agree, "kl": kl})
        margins = np.concatenate([r["margin"] for r in rows])
        agrees = np.concatenate([r["agree"] for r in rows])
        kls = np.concatenate([r["kl"] for r in rows])
        disagree = 1 - agrees
        stats = {
            "bits": bits,
            "n_positions": int(len(margins)),
            "agreement_rate": float(agrees.mean()),
            "mean_kl_ref_q": float(np.mean(kls)),
            "auroc_margin_predicts_disagreement": auroc(-margins, disagree),
            "median_margin_agree": float(np.median(margins[agrees == 1])),
            "median_margin_disagree": float(np.median(margins[agrees == 0])) if (agrees == 0).any() else None,
            "escalation_curve": escalation_curve(margins, agrees),
            "per_domain": {
                d: {
                    "agreement_rate": float(np.concatenate([r["agree"] for r in rows if r["domain"] == d]).mean()),
                    "auroc": auroc(
                        -np.concatenate([r["margin"] for r in rows if r["domain"] == d]),
                        1 - np.concatenate([r["agree"] for r in rows if r["domain"] == d]),
                    ),
                }
                for d in domains
            },
        }
        # operating point: escalation fraction to reach 99% agreement
        for pt in stats["escalation_curve"]:
            if pt["residual_disagree"] <= 0.01:
                stats["escalation_frac_for_99pct"] = pt["escalated_frac"]
                break
        per_bits[bits] = stats
        print(f"  {bits}-bit: agree={stats['agreement_rate']:.4f} "
              f"AUROC={stats['auroc_margin_predicts_disagreement']:.3f} "
              f"esc@99%={stats.get('escalation_frac_for_99pct', 'n/a')}", flush=True)

    ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    out_dir = REPO_ROOT / "results" / "expG_decision_stability" / ts
    out_dir.mkdir(parents=True)
    payload = {
        "experiment": "expG_decision_stability",
        "author": "Simon-Pierre Boucher",
        "contact": "contact@spboucher.ai",
        "manifest": collect_manifest(),
        "config": vars(args),
        "n_trajectories": len(trajectories),
        "results_by_bits": {str(k): v for k, v in per_bits.items()},
    }
    (out_dir / "results.json").write_text(json.dumps(payload, indent=2))
    print(f"\nwrote {out_dir / 'results.json'}")


if __name__ == "__main__":
    main()