#!/usr/bin/env python3 # ============================================================================= # Project : modelmap # File : experiments/micro/expC_causal_verification/implementation/benchmark_v3.py # Purpose : Run #3 — replication of the causal direction-erasure profile # Author : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Website : https://modelmap.io # Created : 2026-08-12 # Modified : 2026-08-12 # Platform : macOS / Apple Silicon (arm64) — MLX / Metal # License : All rights reserved (research code) # ============================================================================= """expC run #3 (hypothesis registered before this run). Five direction sources (set A, set B, 3 bootstrap resamples of A); shared random-direction controls; pairwise Spearman replication of the per-layer specific-damage profile + the 2–15 vs 20–27 band claim per source. """ from __future__ import annotations import json import random import subprocess import sys import time from pathlib import Path import numpy as np ROOT = Path(__file__).resolve().parents[4] sys.path.insert(0, str(ROOT / "src")) sys.path.insert(0, str(ROOT / "benchmarks")) from hardware_manifest import manifest from modelmap.capture.mlx_capture import capture_pooled, install_taps MODEL = "mlx-community/Qwen3-0.6B-4bit" N_RANDOM_DIRS = 3 SEED = 779 BOOTS = 3 NOUN_PAIRS = [("key", "keys"), ("crate", "crates"), ("report", "reports"), ("valve", "valves"), ("ticket", "tickets"), ("ladder", "ladders"), ("sample", "samples"), ("cable", "cables"), ("permit", "permits"), ("beacon", "beacons"), ("filter", "filters"), ("stamp", "stamps")] NEW_NEAR = ["across the yard", "above the workbench", "below the landing", "outside the depot", "around the corner", "beyond the fence", "beneath the awning", "atop the cabinet"] def spearman(a, b): ra = np.argsort(np.argsort(a)).astype(float) rb = np.argsort(np.argsort(b)).astype(float) ra -= ra.mean(); rb -= rb.mean() return float((ra * rb).sum() / np.sqrt((ra**2).sum() * (rb**2).sum())) def directions_from(reps, labels, idx=None): """Per-layer unit diff-of-means directions (+grand means).""" if idx is not None: reps, labels = reps[idx], labels[idx] dirs, mus = [], [] for layer in range(reps.shape[1]): x = reps[:, layer, :] mu = x.mean(0) u = x[labels == "correct"].mean(0) - x[labels == "violated"].mean(0) u = u / (np.linalg.norm(u) + 1e-8) dirs.append(u); mus.append(mu) return dirs, mus def main() -> int: import mlx.core as mx from mlx_lm import load t0 = time.time() model, tokenizer = load(MODEL) taps = install_taps(model) n_layers = len(taps) rng = random.Random(SEED) combos = [(n, loc) for n in NOUN_PAIRS for loc in NEW_NEAR] rng.shuffle(combos) pairs = [] for (sg, pl), loc in combos: pairs.append({"prefix": f"The {sg} {loc}", "singular": True}) pairs.append({"prefix": f"The {pl} {loc}", "singular": False}) prefix_ids = [tokenizer.encode(p["prefix"]) for p in pairs] id_is, id_are = tokenizer.encode(" is")[0], tokenizer.encode(" are")[0] def margin() -> float: out = [] for ids, p in zip(prefix_ids, pairs): logits = model(mx.array([ids]))[0, -1, :] mx.eval(logits) m = float(logits[id_is] - logits[id_are]) out.append(m if p["singular"] else -m) return float(np.mean(out)) def erase_fn(u_np, mu_np): u = mx.array(u_np.astype(np.float32)) mu = mx.array(mu_np.astype(np.float32)) def fn(out): h = out.astype(mx.float32) coef = ((h - mu) * u).sum(axis=-1, keepdims=True) return (h - coef * u).astype(out.dtype) return fn base_m = margin() print(f"baseline margin {base_m:+.4f} | pairs {len(pairs)}") # ---- direction sources sources: dict[str, tuple] = {} reps_cache = {} for setname in ("A", "B"): items = [json.loads(l) for l in (ROOT / "benchmarks" / "promptsets" / f"agreement_{setname}.jsonl").read_text().splitlines()] toks = [tokenizer.encode(it["text"]) for it in items] labels = np.array([it["label"] for it in items]) print(f"capture agreement_{setname}…", flush=True) reps = capture_pooled(model, taps, toks)["mean"] reps_cache[setname] = (reps, labels) sources[f"set{setname}"] = directions_from(reps, labels) repsA, labelsA = reps_cache["A"] for b in range(BOOTS): idx = np.random.default_rng(100 + b).integers(0, len(labelsA), len(labelsA)) sources[f"bootA{b}"] = directions_from(repsA, labelsA, idx) # ---- shared random-direction control per layer rand_damage = [] for layer in range(n_layers): ms = [] for s in range(N_RANDOM_DIRS): ru = np.random.default_rng(1000 * layer + s).standard_normal(repsA.shape[-1]) ru /= np.linalg.norm(ru) taps[layer].edit = erase_fn(ru.astype(np.float32), sources["setA"][1][layer]) ms.append(margin()) taps[layer].edit = None rand_damage.append(base_m - float(np.mean(ms))) print("random-direction controls done", flush=True) # ---- per-source agreement-direction scans profiles = {} for name, (dirs, mus) in sources.items(): prof = [] for layer in range(n_layers): taps[layer].edit = erase_fn(dirs[layer], mus[layer]) m = margin() taps[layer].edit = None prof.append((base_m - m) - rand_damage[layer]) profiles[name] = prof band_early = float(np.mean(prof[2:16])) band_late = float(np.mean(prof[20:28])) print(f"{name:8s} early(2-15)={band_early:+.3f} late(20-27)={band_late:+.3f} " f"ratio={band_early / max(band_late, 1e-6):.1f}", flush=True) names = list(profiles) rhos = [spearman(np.array(profiles[a]), np.array(profiles[b])) for i, a in enumerate(names) for b in names[i + 1:]] mean_rho = float(np.mean(rhos)) band_ok = all(np.mean(profiles[n][2:16]) >= 3 * max(np.mean(profiles[n][20:28]), 1e-6) for n in names) survives = bool(mean_rho >= 0.7 and band_ok) print(f"replication: mean pairwise rho={mean_rho:.3f} (min {min(rhos):.3f}) " f"band-claim-all-sources={band_ok} -> publishable={survives}") commit = subprocess.run(["git", "rev-parse", "HEAD"], cwd=ROOT, capture_output=True, text=True, check=False).stdout.strip() ts = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime()) outdir = ROOT / "results" / "expC_causal_verification" / ts outdir.mkdir(parents=True) (outdir / "results.json").write_text(json.dumps({ "experiment": "expC_causal_verification", "run": 3, "scope": "replication of the direction-erasure causal profile (5 sources)", "commit": commit, "config": {"model": MODEL, "sources": names, "n_random_dirs": N_RANDOM_DIRS, "n_pairs": len(pairs), "seed": SEED, "boots": BOOTS}, "manifest": manifest(), "baseline_margin": base_m, "random_direction_damage": rand_damage, "profiles": profiles, "replication": {"pairwise_rhos": rhos, "mean_rho": mean_rho, "min_rho": float(min(rhos)), "band_claim_all": band_ok, "publishable": survives}, "wall_seconds": round(time.time() - t0, 1), }, indent=2) + "\n") print(f"results -> {outdir / 'results.json'}") return 0 if __name__ == "__main__": sys.exit(main())