Code / experiments/micro/expD_progressive_reconstruction/benchmark.py

experiments/micro/expD_progressive_reconstruction/benchmark.py 245 lines
#!/usr/bin/env python3
# =============================================================================
#  Project   : localvm-research
#  File      : experiments/micro/expD_progressive_reconstruction/benchmark.py
#  Purpose   : Residual-ladder progressive weight reconstruction — decision and
#              hidden-state convergence vs cumulative bits (candidate C1 math)
#  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 D — progressive weight reconstruction (charter §9.D).

Builds base+residual affine-quantized ladders (3/3+3/3+3+3 and 4/4+4 bits),
teacher-forces each cumulative stage over reference greedy trajectories, and
measures decision convergence, two-tier margin-gated policies, and
hidden-state error at several depths.

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

from __future__ import annotations

import argparse
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"))
sys.path.insert(0, str(REPO_ROOT / "src"))
from hardware_manifest import collect_manifest  # noqa: E402
from localvm.quality.decision_stats import (  # noqa: E402
    auroc, escalation_curve, greedy_generate, kl_ref_vs, teacher_forced_stats,
)

GROUP = 64


def quantizable(m) -> bool:
    return isinstance(m, nn.Linear) and m.weight.shape[-1] % GROUP == 0


def residual_ladder_weights(model, ladder: list[int]) -> list[dict[str, mx.array]]:
    """For each quantizable Linear, build cumulative dequantized weights for
    each stage of `ladder` (bits per stage). Returns a list (one per stage) of
    {param_path: bf16 weight} replacements. Memory: one bf16 copy per stage
    per layer is materialized lazily at apply time; here we keep the per-stage
    cumulative tensors (float32 accumulation, cast to bf16)."""
    stages = [dict() for _ in ladder]
    for path, module in model.named_modules():
        if not quantizable(module):
            continue
        w = module.weight.astype(mx.float32)
        acc = mx.zeros_like(w)
        err = w
        for k, bits in enumerate(ladder):
            qw, scales, biases = mx.quantize(err, group_size=GROUP, bits=bits)
            deq = mx.dequantize(qw, scales, biases, group_size=GROUP, bits=bits)
            acc = acc + deq
            err = w - acc
            stages[k][path] = acc.astype(mx.bfloat16)
            mx.eval(stages[k][path])
    return stages


def apply_weights(model, replacement: dict[str, mx.array]) -> dict[str, mx.array]:
    """Swap Linear weights in place; returns the originals for restoration."""
    originals = {}
    for path, module in model.named_modules():
        if path in replacement:
            originals[path] = module.weight
            module.weight = replacement[path]
    return originals


def hidden_state_errors(model, ref_hidden: dict, full_ids: list[int], start: int,
                        depths: list[int]) -> dict[int, float]:
    """Relative L2 error of hidden states vs reference at given layer indices."""
    h = capture_hidden(model, full_ids, start, depths)
    out = {}
    for d in depths:
        r, q = ref_hidden[d], h[d]
        out[d] = float(np.linalg.norm(q - r) / (np.linalg.norm(r) + 1e-9))
    return out


def capture_hidden(model, full_ids: list[int], start: int, depths: list[int]) -> dict:
    """Hidden states (post-layer) at selected depths for predicted positions.
    Replicates the inner transformer loop manually (instance-level __call__
    monkey-patching does not intercept Python's type-level dunder dispatch)."""
    from mlx_lm.models.base import create_attention_mask

    inner = model.model
    h = inner.embed_tokens(mx.array(full_ids)[None])
    mask = create_attention_mask(h, None)
    result = {}
    for i, layer in enumerate(inner.layers):
        h = layer(h, mask, cache=None)
        if i in depths:
            t = h[0, start - 1 : len(full_ids) - 1].astype(mx.float32)
            mx.eval(t)
            result[i] = np.array(t)
    return result


def two_tier_policy(lo: dict, hi: dict, ref_next: np.ndarray, taus: list[float]) -> list[dict]:
    """Policy: take lo's decision when its margin >= tau, else hi's decision."""
    out = []
    for tau in taus:
        esc = lo["margin"] < tau
        decision = np.where(esc, hi["argmax"], lo["argmax"])
        out.append({
            "tau": tau,
            "escalated_frac": float(esc.mean()),
            "policy_agreement": float((decision == ref_next).mean()),
        })
    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("--per-domain", type=int, default=8)
    ap.add_argument("--hidden-trajectories", type=int, default=8)
    args = ap.parse_args()

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

    print(f"loading reference {args.model} …", flush=True)
    model, tokenizer = load(args.model)
    n_layers = len(model.model.layers)
    depths = [max(0, round(n_layers * f) - 1) for f in (0.25, 0.5, 0.75, 1.0)]

    trajectories = []
    t0 = time.time()
    for domain, plist in domains.items():
        for prompt in plist[: args.per_domain]:
            ids = tokenizer.apply_chat_template(
                [{"role": "user", "content": prompt}], add_generation_prompt=True)
            gen = greedy_generate(model, tokenizer, ids, args.gen_tokens)
            if len(gen) >= 8:
                trajectories.append({"domain": domain, "full_ids": list(ids) + gen, "start": len(ids)})
    print(f"{len(trajectories)} reference trajectories in {time.time()-t0:.0f}s", flush=True)

    ref_stats = [teacher_forced_stats(model, t["full_ids"], t["start"]) for t in trajectories]
    hidden_subset = trajectories[:: max(1, len(trajectories) // args.hidden_trajectories)][: args.hidden_trajectories]
    ref_hidden = [capture_hidden(model, t["full_ids"], t["start"], depths) for t in hidden_subset]

    ladders = {"A_base3": [3, 3, 3], "B_base4": [4, 4]}
    results: dict[str, list] = {}
    overhead_bits = 32 / GROUP * 2  # bf16 scales + biases per group per stage

    for name, ladder in ladders.items():
        print(f"building ladder {name} {ladder} …", flush=True)
        stages = residual_ladder_weights(model, ladder)
        stage_records = []
        for k, replacement in enumerate(stages):
            originals = apply_weights(model, replacement)
            rows_margin, rows_agree, rows_kl, rows_argmax, doms = [], [], [], [], []
            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"]:])
                rows_margin.append(qs["margin"])
                rows_argmax.append(qs["argmax"])
                rows_agree.append((qs["argmax"] == ref_next).astype(np.int8))
                rows_kl.append(kl_ref_vs(qs["logprobs"], ref["logprobs"]))
                doms.append(t["domain"])
            hid = [hidden_state_errors(model, rh, t["full_ids"], t["start"], depths)
                   for rh, t in zip(ref_hidden, hidden_subset)]
            apply_weights(model, originals)

            margins = np.concatenate(rows_margin)
            agrees = np.concatenate(rows_agree)
            cum_bits = sum(ladder[: k + 1]) + overhead_bits * (k + 1)
            rec = {
                "stage": k,
                "ladder_bits": ladder[: k + 1],
                "cumulative_bits_per_param": round(cum_bits, 2),
                "agreement_rate": float(agrees.mean()),
                "mean_kl": float(np.mean(np.concatenate(rows_kl))),
                "auroc": auroc(-margins, 1 - agrees),
                "escalation_curve": escalation_curve(margins, agrees),
                "hidden_rel_err_by_depth": {
                    str(d): float(np.mean([h[d] for h in hid])) for d in depths
                },
                "_margins": margins, "_argmax": np.concatenate(rows_argmax),
            }
            for pt in rec["escalation_curve"]:
                if pt["residual_disagree"] <= 0.01:
                    rec["escalation_frac_for_99pct"] = pt["escalated_frac"]
                    break
            stage_records.append(rec)
            print(f"  stage {k} ({rec['cumulative_bits_per_param']} bits): "
                  f"agree={rec['agreement_rate']:.4f} KL={rec['mean_kl']:.4f} "
                  f"auroc={rec['auroc']:.3f}", flush=True)
        results[name] = stage_records

    # two-tier margin-gated policies between consecutive stages
    ref_next_all = np.concatenate([np.array(t["full_ids"][t["start"]:]) for t in trajectories])
    taus = [0.25, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0]
    policies = {}
    for name, recs in results.items():
        for k in range(len(recs) - 1):
            lo = {"margin": recs[k]["_margins"], "argmax": recs[k]["_argmax"]}
            hi = {"argmax": recs[k + 1]["_argmax"]}
            policies[f"{name}_stage{k}_to_{k+1}"] = two_tier_policy(lo, hi, ref_next_all, taus)
    for recs in results.values():
        for r in recs:
            del r["_margins"], r["_argmax"]

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


if __name__ == "__main__":
    main()