SPB Git

spb/modelmap Public License

Internal cartography of local LLMs on Apple Silicon — registered, gated, negative-first. Public atlas at modelmap.io.

Python 66.3% JavaScript 24.5% CSS 8.1% Shell 0.7%
10.3 KB · 238 lines python
Raw Blame History
1#!/usr/bin/env python32# =============================================================================3#  Project   : modelmap4#  File      : experiments/micro/expC_causal_verification/implementation/benchmark_v2.py5#  Purpose   : Run #2 — specificity-normalized skip + direction-level erasure6#  Author    : Simon-Pierre Boucher7#  Contact   : contact@spboucher.ai8#  Website   : https://modelmap.io9#  Created   : 2026-08-1210#  Modified  : 2026-08-1211#  Platform  : macOS / Apple Silicon (arm64) — MLX / Metal12#  License   : All rights reserved (research code)13# =============================================================================14"""expC run #2 (hypothesis registered before this run).1516P1: layer-skip conditions rerun with general-damage (NLL) normalization.17P2: surgical test — erase the layer-l agreement direction (diff-of-means)18from every position of that layer's output; compare per-layer specific19damage (vs random-direction control) with the differential probe profile.20"""2122from __future__ import annotations2324import json25import random26import subprocess27import sys28import time29from pathlib import Path3031import numpy as np3233ROOT = Path(__file__).resolve().parents[4]34sys.path.insert(0, str(ROOT / "src"))35sys.path.insert(0, str(ROOT / "benchmarks"))36from hardware_manifest import manifest3738from modelmap.capture.mlx_capture import capture_pooled, install_taps3940MODEL = "mlx-community/Qwen3-0.6B-4bit"41MAP_JSON = ROOT / "atlas" / "qwen3-0.6b-4bit" / "probes" / "v2" / "map.json"42K = 543N_RANDOM_DRAWS = 2044N_RANDOM_DIRS = 345SEED = 7784647NOUN_PAIRS = [("key", "keys"), ("crate", "crates"), ("report", "reports"), ("valve", "valves"),48              ("ticket", "tickets"), ("ladder", "ladders"), ("sample", "samples"), ("cable", "cables"),49              ("permit", "permits"), ("beacon", "beacons"), ("filter", "filters"), ("stamp", "stamps")]50NEW_NEAR = ["across the yard", "above the workbench", "below the landing", "outside the depot",51            "around the corner", "beyond the fence", "beneath the awning", "atop the cabinet"]52NEUTRAL = ["The committee reviewed the plans before the meeting.",53           "A local historian kept detailed notes for years.",54           "The lead engineer questioned the original estimate.",55           "Her assistant preferred the older method.",56           "The night watchman described the process in a letter.",57           "An early visitor returned before the first frost.",58           "The town council approved the request after some debate.",59           "The apprentice carried the tools across the yard.",60           "According to the survey, the harbor required constant maintenance.",61           "The archive near the market attracted visitors from the region.",62           "The restored lighthouse stood at the edge of town.",63           "The workshop changed hands twice last century.",64           "The observatory remained open despite the storm.",65           "The vineyard was documented in the annual report.",66           "The glacier trail closed early in the season.",67           "The orchard supplied the market for decades.",68           "The library extended its hours during the recess.",69           "The clerk signed the manifest without a word.",70           "The surveyor traced the boundary along the quay.",71           "The curator shelved the samples behind the annex."]727374def spearman(a, b):75    ra = np.argsort(np.argsort(a)).astype(float)76    rb = np.argsort(np.argsort(b)).astype(float)77    ra -= ra.mean(); rb -= rb.mean()78    return float((ra * rb).sum() / np.sqrt((ra**2).sum() * (rb**2).sum()))798081def main() -> int:82    import mlx.core as mx83    from mlx_lm import load8485    t0 = time.time()86    model, tokenizer = load(MODEL)87    taps = install_taps(model)88    n_layers = len(taps)8990    mdoc = json.loads(MAP_JSON.read_text())91    agree = mdoc["properties"]["agreement"]92    diff_profile = np.array([a["selectivity_mean"] - t["selectivity_mean"]93                             for a, t in zip(agree["per_layer"]["A"],94                                             agree["twin_null_per_layer_A"])])95    ranked = list(np.argsort(-diff_profile))96    top_k, bottom_k = [int(x) for x in ranked[:K]], [int(x) for x in ranked[-K:]]9798    # ---- enlarged held-out bank (new locations -> no dedup collisions)99    rng = random.Random(SEED)100    combos = [(n, loc) for n in NOUN_PAIRS for loc in NEW_NEAR]101    rng.shuffle(combos)102    pairs = []103    for (sg, pl), loc in combos:104        pairs.append({"prefix": f"The {sg} {loc}", "singular": True})105        pairs.append({"prefix": f"The {pl} {loc}", "singular": False})106    print(f"held-out pairs: {len(pairs)}")107    prefix_ids = [tokenizer.encode(p["prefix"]) for p in pairs]108    id_is, id_are = tokenizer.encode(" is")[0], tokenizer.encode(" are")[0]109    neutral_ids = [tokenizer.encode(s) for s in NEUTRAL]110111    def margin() -> float:112        out = []113        for ids, p in zip(prefix_ids, pairs):114            logits = model(mx.array([ids]))[0, -1, :]115            mx.eval(logits)116            m = float(logits[id_is] - logits[id_are])117            out.append(m if p["singular"] else -m)118        return float(np.mean(out))119120    def nll() -> float:121        tot, cnt = 0.0, 0122        for ids in neutral_ids:123            x = mx.array([ids])124            logits = model(x)[0]125            logp = logits - mx.logsumexp(logits, axis=-1, keepdims=True)126            tgt = mx.array(ids[1:])127            picked = mx.take_along_axis(logp[:-1], tgt[:, None], axis=-1)128            mx.eval(picked)129            tot += float(-picked.sum()); cnt += len(ids) - 1130        return tot / cnt131132    def with_skip(layers: set[int], fn):133        for i, t in enumerate(taps):134            t.skip = i in layers135        try:136            return fn()137        finally:138            for t in taps:139                t.skip = False140141    base_m, base_nll = margin(), nll()142    print(f"baseline margin={base_m:+.4f} nll={base_nll:.4f}")143144    # ---------------- P1: skip conditions with NLL normalization145    def skip_cell(layers):146        m = with_skip(set(layers), margin)147        n = with_skip(set(layers), nll)148        return {"layers": sorted(int(x) for x in layers),149                "margin_damage": base_m - m,150                "nll_damage": n - base_nll,151                "specificity": (base_m - m) / max(n - base_nll, 1e-3)}152153    p1 = {"top": skip_cell(top_k), "bottom": skip_cell(bottom_k), "random": []}154    cand = [i for i in range(n_layers) if i not in set(top_k)]155    for d in range(N_RANDOM_DRAWS):156        p1["random"].append(skip_cell(random.Random(SEED + 1 + d).sample(cand, K)))157    rspec = np.array([c["specificity"] for c in p1["random"]])158    print(f"P1 specificity: top={p1['top']['specificity']:.3f} bottom={p1['bottom']['specificity']:.3f} "159          f"random mean={rspec.mean():.3f} p95={np.percentile(rspec, 95):.3f}")160161    # ---------------- P2: direction-level erasure, all layers162    # directions from agreement_A mean-pooled reps at each layer163    items = [json.loads(l) for l in164             (ROOT / "benchmarks" / "promptsets" / "agreement_A.jsonl").read_text().splitlines()]165    toks = [tokenizer.encode(it["text"]) for it in items]166    labels = np.array([it["label"] for it in items])167    reps = capture_pooled(model, taps, toks)["mean"]  # (n, L, d)168    dirs, mus = [], []169    for layer in range(n_layers):170        x = reps[:, layer, :]171        mu = x.mean(0)172        u = x[labels == "correct"].mean(0) - x[labels == "violated"].mean(0)173        u = u / (np.linalg.norm(u) + 1e-8)174        mus.append(mu); dirs.append(u)175176    def erase_fn(u_np, mu_np):177        u = mx.array(u_np.astype(np.float32))178        mu = mx.array(mu_np.astype(np.float32))179        def fn(out):180            h = out.astype(mx.float32)181            coef = ((h - mu) * u).sum(axis=-1, keepdims=True)182            return (h - coef * u).astype(out.dtype)183        return fn184185    per_layer = []186    for layer in range(n_layers):187        taps[layer].edit = erase_fn(dirs[layer], mus[layer])188        m_agree = margin()189        taps[layer].edit = None190        rms = []191        for s in range(N_RANDOM_DIRS):192            ru = np.random.default_rng(1000 * layer + s).standard_normal(dirs[layer].shape)193            ru /= np.linalg.norm(ru)194            taps[layer].edit = erase_fn(ru.astype(np.float32), mus[layer])195            rms.append(margin())196            taps[layer].edit = None197        specific = (base_m - m_agree) - (base_m - float(np.mean(rms)))198        per_layer.append({"layer": layer, "agree_dir_damage": base_m - m_agree,199                          "random_dir_damage_mean": base_m - float(np.mean(rms)),200                          "specific_damage": specific})201        print(f"  L{layer:02d} agreeDir={base_m - m_agree:+.3f} randDir={base_m - float(np.mean(rms)):+.3f} "202              f"specific={specific:+.3f}", flush=True)203204    spec_profile = np.array([r["specific_damage"] for r in per_layer])205    rho = spearman(spec_profile, diff_profile)206    perm_rng = np.random.default_rng(0)207    perms = np.array([spearman(perm_rng.permutation(spec_profile), diff_profile)208                      for _ in range(10_000)])209    p_perm = float((perms >= rho).mean())210    survives = bool(rho >= 0.4 and p_perm < 0.05)211    print(f"P2: Spearman rho={rho:+.3f} perm-p={p_perm:.4f} -> survives={survives}")212213    commit = subprocess.run(["git", "rev-parse", "HEAD"], cwd=ROOT,214                            capture_output=True, text=True, check=False).stdout.strip()215    ts = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())216    outdir = ROOT / "results" / "expC_causal_verification" / ts217    outdir.mkdir(parents=True)218    (outdir / "results.json").write_text(json.dumps({219        "experiment": "expC_causal_verification", "run": 2,220        "scope": "NLL-normalized skip specificity + direction-level erasure scan",221        "commit": commit,222        "config": {"model": MODEL, "k": K, "n_random_draws": N_RANDOM_DRAWS,223                   "n_random_dirs": N_RANDOM_DIRS, "n_pairs": len(pairs), "seed": SEED,224                   "top_layers": sorted(top_k), "bottom_layers": sorted(bottom_k)},225        "manifest": manifest(),226        "baseline": {"margin": base_m, "nll": base_nll},227        "p1_skip_specificity": p1,228        "p2_direction_erasure": {"per_layer": per_layer, "spearman_rho": rho,229                                 "perm_p": p_perm, "survives": survives},230        "wall_seconds": round(time.time() - t0, 1),231    }, indent=2) + "\n")232    print(f"results -> {outdir / 'results.json'}")233    return 0234235236if __name__ == "__main__":237    sys.exit(main())238