Code / src/modelmap/capture/mlx_capture.py

src/modelmap/capture/mlx_capture.py 136 lines
# =============================================================================
#  Project   : modelmap
#  File      : src/modelmap/capture/mlx_capture.py
#  Purpose   : Standard MLX capture layer — Tap wrappers over decoder layers
#  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)
# =============================================================================
"""MLX activation capture (promoted from expH run #3, where retention
measured 1.004x plain prefill on a real 4-bit checkpoint).

Works on quantized mlx-lm models — the §4.9 capability gap. Layers are
wrapped with Tap objects (mlx-lm decoder loops call layers positionally, so
a plain callable suffices); d_model is inferred from runtime activations
because quantized embeddings pack their weight shapes; bf16 activations are
cast in MLX before NumPy conversion (numpy has no bfloat16).
"""

from __future__ import annotations

import numpy as np


class Tap:
    """Wraps a decoder layer; optionally retains its output or skips the block.

    skip=True implements layer ablation (the block's contribution is removed;
    the residual stream passes through unchanged) — the Gromov/ShortGPT-style
    intervention used by expC for causal verification.
    """

    def __init__(self, layer):
        self.layer = layer
        self.retained = None
        self.enabled = False
        self.skip = False
        self.edit = None      # optional callable applied to the block output

    def __call__(self, x, *args, **kwargs):
        if self.skip:
            return x
        out = self.layer(x, *args, **kwargs)
        if self.edit is not None:
            out = self.edit(out)
        if self.enabled:
            self.retained = out
        return out

    def __getattr__(self, name):  # delegate (e.g. .self_attn) for introspection
        return getattr(self.layer, name)


def install_taps(model) -> list[Tap]:
    """Wrap every decoder layer of an mlx-lm model in place; return the taps."""
    taps = [Tap(layer) for layer in model.model.layers]
    model.model.layers = taps
    return taps


def capture_mean_pooled(model, taps, token_ids_list, dtype=np.float32) -> np.ndarray:
    """Per-prompt, per-layer mean-pooled residual representations.

    Returns array of shape (n_prompts, n_layers, d_model). Batch = 1 per
    prompt (variable lengths, no padding confound); mean over sequence
    positions. Each forward is a full prefill (no KV cache reuse across
    prompts).
    """
    import mlx.core as mx

    for t in taps:
        t.enabled = True
    reps = None
    for i, ids in enumerate(token_ids_list):
        x = mx.array([list(ids)])
        model(x)
        pooled = [t.retained.mean(axis=1)[0].astype(mx.float32) for t in taps]
        mx.eval(*pooled)
        if reps is None:
            reps = np.zeros((len(token_ids_list), len(taps), pooled[0].shape[-1]), dtype=dtype)
        for li, p in enumerate(pooled):
            reps[i, li] = np.array(p, copy=False)
        for t in taps:
            t.retained = None
    for t in taps:
        t.enabled = False
    return reps


def capture_pooled(model, taps, token_ids_list, dtype=np.float32) -> dict[str, np.ndarray]:
    """Mean-pooled AND last-token reps in one pass.

    Returns {"mean": (n, L, d), "last": (n, L, d)} — added for expA run #2,
    where pooling choice is itself a measured variable.
    """
    import mlx.core as mx

    for t in taps:
        t.enabled = True
    mean_r = last_r = None
    for i, ids in enumerate(token_ids_list):
        x = mx.array([list(ids)])
        model(x)
        pooled = [t.retained.mean(axis=1)[0].astype(mx.float32) for t in taps]
        lasts = [t.retained[0, -1, :].astype(mx.float32) for t in taps]
        mx.eval(*pooled, *lasts)
        if mean_r is None:
            d = pooled[0].shape[-1]
            mean_r = np.zeros((len(token_ids_list), len(taps), d), dtype=dtype)
            last_r = np.zeros_like(mean_r)
        for li in range(len(taps)):
            mean_r[i, li] = np.array(pooled[li], copy=False)
            last_r[i, li] = np.array(lasts[li], copy=False)
        for t in taps:
            t.retained = None
    for t in taps:
        t.enabled = False
    return {"mean": mean_r, "last": last_r}


def random_init_twin(model_path):
    """Instantiate the same architecture with random weights (fp16, no
    quantization) — the architecture-only null mandated by charter §8.4."""
    import mlx.core as mx
    from mlx_lm.utils import _get_classes, load_config

    cfg = load_config(model_path)
    cfg_clean = {k: v for k, v in cfg.items() if k != "quantization"}
    model_class, args_class = _get_classes(cfg)
    twin = model_class(args_class.from_dict(cfg_clean))
    twin.set_dtype(mx.float16)
    return twin