Code / src/localvm/quality/decision_stats.py

src/localvm/quality/decision_stats.py 103 lines
# =============================================================================
#  Project   : localvm-research
#  File      : src/localvm/quality/decision_stats.py
#  Purpose   : Shared decision-stability measurement utilities (greedy
#              trajectories, teacher-forced margins/agreement, AUROC, curves)
#  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)
# =============================================================================
"""Decision-stability measurement utilities shared by expG/expD and successors.

First used (inlined) by experiments/micro/expG_decision_stability/benchmark.py
(commit 42c7b3d); extracted here unchanged so later experiments reuse one
implementation. expG's committed copy is kept as-is for reproducibility.
"""

from __future__ import annotations

import mlx.core as mx
import numpy as np


def greedy_generate(model, tokenizer, prompt_ids: list[int], n_tokens: int) -> list[int]:
    """Deterministic greedy generation; returns generated token ids."""
    from mlx_lm.models.cache import make_prompt_cache

    cache = make_prompt_cache(model)
    generated: list[int] = []
    inp = mx.array(list(prompt_ids))[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 full_ids once; per-position stats for predictions of tokens
    [start, len). Returns margin (top-1 minus top-2 logit gap), argmax, and
    float16 logprobs (float16 keeps 48 x (128, ~152k) around 2 GB)."""
    logits = model(mx.array(full_ids)[None])[0]
    sel = logits[start - 1 : len(full_ids) - 1].astype(mx.float32)
    top2 = mx.topk(sel, 2, axis=-1)
    argmax = mx.argmax(sel, axis=-1)
    logprobs = sel - mx.logsumexp(sel, axis=-1, keepdims=True)
    mx.eval(top2, argmax, logprobs)
    v = np.array(top2)
    return {
        "margin": np.abs(v[:, 1] - v[:, 0]),
        "argmax": np.array(argmax),
        "logprobs": np.array(logprobs).astype(np.float16),
    }


def auroc(scores: np.ndarray, labels: np.ndarray) -> float:
    """Rank-based AUROC with tie handling (scores: higher = predicted 1)."""
    pos, neg = scores[labels == 1], scores[labels == 0]
    if len(pos) == 0 or len(neg) == 0:
        return float("nan")
    allv = np.concatenate([pos, neg])
    order = np.argsort(allv, kind="mergesort")
    ranks = np.empty(len(order))
    ranks[order] = np.arange(1, len(order) + 1)
    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]:
    """Escalate tokens with margin < tau (escalated decision assumed exact);
    report escalated fraction vs residual disagreement."""
    qs = np.quantile(margins, np.linspace(0, 1, points))
    out, n = [], len(margins)
    for tau in qs:
        esc = margins < tau
        out.append({
            "tau": float(tau),
            "escalated_frac": float(esc.mean()),
            "residual_disagree": float(np.sum((~esc) & (agree == 0)) / n),
        })
    return out


def kl_ref_vs(model_logprobs_f16: np.ndarray, ref_logprobs_f16: np.ndarray) -> np.ndarray:
    """Per-position KL(ref || model), computed in float32."""
    ref = ref_logprobs_f16.astype(np.float32)
    q = model_logprobs_f16.astype(np.float32)
    return np.sum(np.exp(ref) * (ref - q), axis=-1)