File size: 19,031 Bytes
d58cfe6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
"""Running a model and reading the cone off its own trajectory.

    rt = ulens.Runner("qwen3.5-4b")
    tr = rt.run("Is Hamlet's madness feigned?", max_new_tokens=600, seed=42)

    tr.u_lens                     # the score
    tr.field                      # [n_blocks, T, k] signed projections: depth x token x axis
    tr.a_cone_series()            # A_cone per token at the readout block
    tr.surface()                  # A_cone over depth x token

Two passes: `generate` with the model's own sampling settings, then one teacher-forced pass
over prompt + generation that reads every lens block through a forward hook and the per-token
entropies through the unembedding. The second pass is what makes the field exact — a
generation pass sees each token once, with the state it had at the time, which is the same
thing only when nothing is being intervened on.

Alignment, which decides whether the score matches the published one. `entropy[i]` is the
entropy of the distribution that **produced** token `i`, read from the logits at `i - 1`, so
the thinking-block mean is `entropy[prompt_len : eot + 1].mean()` and includes the
distribution that produced the end-of-think token itself. `A_cone` is read at the end-of-think
token's own state. Both match `pipeline/04_forward.py` of the reproduction code.
"""
from __future__ import annotations

import json
import re
from dataclasses import dataclass
from dataclasses import field as _field
from pathlib import Path

import numpy as np

from . import axes as _axes
from . import basis as _basis
from . import readout as _readout
from ._deps import require, require_extra
from .models import ModelSpec
from .models import get as _model

SEG_PROMPT, SEG_THINK, SEG_ANSWER = "prompt", "think", "answer"

#: positions per chunk when pushing hidden states through the unembedding
LOGIT_CHUNK = 256


@dataclass
class Trace:
    """One generation, with the cone read off every depth and every token."""
    model: str
    prompt: str
    tokens: list[int]
    token_strs: list[str]
    prompt_len: int
    eot: int                         # absolute index of the end-of-think token
    entropy: np.ndarray              # [T], of the distribution that produced each token
    field: np.ndarray                # [n_blocks, T, k] signed projections
    blocks: list[int]
    names: list[str]
    readout_block: int
    generated: str
    truncated: bool                  # the thinking block was never closed
    config: dict = _field(default_factory=dict)

    # ------------------------------------------------------------------ geometry
    @property
    def n_tokens(self) -> int:
        return len(self.tokens)

    @property
    def n_axes(self) -> int:
        return int(self.field.shape[2])

    @property
    def think_len(self) -> int:
        return self.eot + 1 - self.prompt_len

    @property
    def segments(self) -> np.ndarray:
        """`[T]` of 'prompt' / 'think' / 'answer'."""
        s = np.array([SEG_ANSWER] * self.n_tokens, dtype=object)
        s[: self.prompt_len] = SEG_PROMPT
        s[self.prompt_len: self.eot + 1] = SEG_THINK
        return s

    def block_index(self, block: int | None = None) -> int:
        b = self.readout_block if block is None else block
        if b not in self.blocks:
            raise KeyError(f"block {b} was not captured; have {self.blocks}")
        return self.blocks.index(b)

    def projections(self, block: int | None = None) -> np.ndarray:
        """`[T, k]` signed projections at one block."""
        return self.field[self.block_index(block)]

    def components(self, block: int | None = None) -> np.ndarray:
        """`[T, k]` rectified components at one block."""
        return _readout.rectify(self.projections(block))

    def a_cone_series(self, block: int | None = None) -> np.ndarray:
        """`[T]` A_cone per token at one block."""
        return _readout.a_cone_of(self.projections(block))

    def surface(self) -> np.ndarray:
        """`[n_blocks, T]` A_cone over depth and token: the field, as one number per cell."""
        return _readout.a_cone_of(self.field)

    # ------------------------------------------------------------------ the score
    @property
    def a_cone(self) -> float:
        """A_cone at the end-of-think token, at the readout block."""
        return float(self.a_cone_series()[self.eot])

    @property
    def think_entropy(self) -> float:
        """Mean entropy of the distributions that produced the thinking block's tokens;
        NaN when there is no thinking block (thinking off), without numpy's warning."""
        e = self.entropy[self.prompt_len: self.eot + 1]
        return float(e.mean()) if e.size else float("nan")

    @property
    def u_lens(self) -> float:
        """The score. Higher means more likely to be wrong.

        Check `readout_valid` before comparing this with a published number.
        """
        return self.a_cone * self.think_entropy

    @property
    def readout_valid(self) -> bool:
        """Whether the score was read where the method says to read it.

        False when the thinking block was never closed: the readout then falls on the last
        token of a budget-truncated block rather than on an end-of-think marker, and the
        answer is empty. The number is still computed — the field is real — but it is not
        the published quantity and must not be compared with one.
        """
        return not self.truncated and self.think_len > 0

    def summary(self) -> str:
        t = ("TRUNCATED, never closed - readout is not the published one"
             if self.truncated else "closed")
        return (f"{self.model}: {self.n_tokens} tokens "
                f"(prompt {self.prompt_len}, think {self.think_len} [{t}], "
                f"answer {self.n_tokens - self.eot - 1}) · "
                f"{len(self.blocks)} blocks [{self.blocks[0]}-{self.blocks[-1]}] · "
                f"readout block {self.readout_block}\n"
                f"  A_cone {self.a_cone:.4f} × think entropy {self.think_entropy:.4f} "
                f"= U-Lens {self.u_lens:.4f}")

    def axis_table(self, block: int | None = None) -> str:
        """Per-axis mean rectified component over the thinking block."""
        C = self.components(block)[self.prompt_len: self.eot + 1]
        tot = float((C ** 2).sum()) or 1.0
        b = self.readout_block if block is None else block
        out = [f"axis contributions over the thinking block (block {b})"]
        for i, n in enumerate(self.names):
            out.append(f"  {n:22s} mean {C[:, i].mean():.4f}  "
                       f"share {100 * (C[:, i] ** 2).sum() / tot:5.1f}%  "
                       f"argmax {100 * (C.argmax(1) == i).mean():5.1f}%")
        return "\n".join(out)

    # ------------------------------------------------------------------ persistence
    def _meta(self) -> dict:
        return {"model": self.model, "prompt": self.prompt, "prompt_len": self.prompt_len,
                "eot": self.eot, "blocks": list(self.blocks), "names": list(self.names),
                "readout_block": self.readout_block, "generated": self.generated,
                "truncated": self.truncated, "token_strs": self.token_strs,
                "config": self.config}

    def save(self, path: str | Path) -> Path:
        """Write `<path>.npz` plus a readable `<path>.json` sidecar."""
        path = Path(path)
        if path.suffix != ".npz":
            path = path.with_suffix(".npz")
        path.parent.mkdir(parents=True, exist_ok=True)
        np.savez_compressed(path, field=self.field.astype(np.float32),
                            entropy=self.entropy.astype(np.float32),
                            tokens=np.array(self.tokens),
                            meta=np.array(json.dumps(self._meta())))
        side = dict(self._meta())
        # a trace without a thinking block has no thinking-block entropy and no score; the
        # sidecar says null, which is JSON, rather than NaN, which is not
        side["scores"] = {k: (float(v) if np.isfinite(v) else None) for k, v in
                          (("a_cone", self.a_cone), ("think_entropy", self.think_entropy),
                           ("u_lens", self.u_lens))}
        path.with_suffix(".json").write_text(json.dumps(side, indent=1, allow_nan=False)
                                             + "\n")
        return path


def load(path: str | Path) -> Trace:
    """Read a trace back. No model and no GPU needed — every view works from the field."""
    z = np.load(Path(path), allow_pickle=False)
    m = json.loads(str(z["meta"]))
    return Trace(model=m["model"], prompt=m["prompt"], tokens=[int(t) for t in z["tokens"]],
                 token_strs=m["token_strs"], prompt_len=m["prompt_len"], eot=m["eot"],
                 entropy=np.asarray(z["entropy"], np.float32),
                 field=np.asarray(z["field"], np.float32), blocks=m["blocks"],
                 names=m["names"], readout_block=m["readout_block"],
                 generated=m["generated"], truncated=m["truncated"],
                 config=m.get("config", {}))


class Runner:
    """A loaded model, its cone at every lens depth, and the pass that reads them together."""

    def __init__(self, model: str | ModelSpec, device: str = "cuda", dtype=None,
                 blocks: list[int] | None = None, attn_implementation: str = "sdpa",
                 device_map=None, verbose: bool = True):
        require_extra("run")
        torch = require("torch")
        self.spec = _model(model)
        self.device = device
        self.verbose = verbose
        self._log(f"building cones at every lens block for {self.spec.key} …")
        self.stack: _axes.ConeStack = _basis.field(self.spec, blocks=blocks,
                                                   verbose=verbose)
        self._log(f"loading {self.spec.hf} …")
        AutoModelForCausalLM = require("transformers").AutoModelForCausalLM
        AutoTokenizer = require("transformers").AutoTokenizer
        kw = {"revision": self.spec.revision} if self.spec.revision else {}
        self.tok = AutoTokenizer.from_pretrained(self.spec.hf, **kw)
        self.model = AutoModelForCausalLM.from_pretrained(
            self.spec.hf, dtype=dtype or torch.bfloat16,
            device_map=device_map if device_map is not None else device,
            attn_implementation=attn_implementation, **kw).eval()
        self._layers = _decoder_blocks(self.model)
        self.open_id, self.close_id = _think_ids(self.tok, self.spec)
        self._U = {b: torch.tensor(self.stack.U[i], device=self._param_device())
                   for i, b in enumerate(self.stack.blocks)}
        self._log(f"ready: {len(self.stack.blocks)} blocks, {self.stack.n_axes} axes "
                  f"({', '.join(self.stack.names)}), think ids "
                  f"open={self.open_id} close={self.close_id}")

    def _log(self, msg: str) -> None:
        if self.verbose:
            print(msg, flush=True)

    def _param_device(self):
        return next(self.model.parameters()).device

    # ------------------------------------------------------------------ prompting
    def prompt_ids(self, prompt: str, thinking: bool = True) -> list[int]:
        """Prompt token ids through the model's own chat template."""
        msgs = [{"role": "user", "content": prompt}]
        for kw in ({"enable_thinking": bool(thinking)}, {}):
            try:
                ids = self.tok.apply_chat_template(msgs, tokenize=True,
                                                   add_generation_prompt=True,
                                                   return_dict=False, **kw)
            except TypeError:
                continue
            return _flatten(ids)
        raise RuntimeError("the chat template rejected both call forms")

    # ------------------------------------------------------------------ the passes
    def generate(self, ids: list[int], max_new_tokens: int = 1024, seed: int = 0,
                 **sampling) -> list[int]:
        """Sample a continuation. Returns prompt + generation."""
        torch = require("torch")
        cfg = dict(self.spec.sampling)
        cfg.update(sampling)
        x = torch.tensor([ids], device=self._param_device())
        torch.manual_seed(int(seed))
        with torch.no_grad():
            out = self.model.generate(x, attention_mask=torch.ones_like(x),
                                      max_new_tokens=int(max_new_tokens), do_sample=True,
                                      pad_token_id=self.tok.eos_token_id, **cfg)
        return [int(t) for t in out[0]]

    def capture(self, ids: list[int], prompt_len: int, prompt: str = "",
                config: dict | None = None) -> Trace:
        """One teacher-forced pass: the field at every lens block, and every entropy."""
        torch = require("torch")
        dev = self._param_device()
        proj: dict[int, np.ndarray] = {}
        hooks = []

        def mk(b):
            def hook(_m, _i, o):
                h = (o[0] if isinstance(o, tuple) else o)[0].float()
                h = h / (h.norm(dim=-1, keepdim=True) + 1e-9)
                proj[b] = (h @ self._U[b]).cpu().numpy().astype(np.float32)
            return hook

        for b in self.stack.blocks:
            hooks.append(self._layers[b].register_forward_hook(mk(b)))
        x = torch.tensor([ids], device=dev)
        try:
            with torch.no_grad():
                h = _decoder(self.model)(input_ids=x, use_cache=False).last_hidden_state[0]
                ent = self._entropies(h)
        finally:
            for hk in hooks:
                hk.remove()
        del h
        torch.cuda.empty_cache() if dev.type == "cuda" else None

        F = np.stack([proj[b] for b in self.stack.blocks], 0)       # [L, T, k]
        eot, truncated = _segment(ids, prompt_len, self.open_id, self.close_id)
        strs = [self.tok.decode([t]) for t in ids]
        return Trace(model=self.spec.key, prompt=prompt, tokens=list(ids), token_strs=strs,
                     prompt_len=prompt_len, eot=eot, entropy=ent, field=F,
                     blocks=list(self.stack.blocks), names=list(self.stack.names),
                     readout_block=self.spec.readout_block,
                     generated=self.tok.decode(ids[prompt_len:]), truncated=truncated,
                     config=dict(config or {}))

    def _entropies(self, h) -> np.ndarray:
        """`[T]` entropy of the distribution that produced each token; position 0 is NaN.

        The distribution for token `i` lives at position `i - 1`, so the array is shifted by
        one and the first token, which nothing predicted, has no entropy.
        """
        torch = require("torch")
        T = int(h.shape[0])
        out = np.full(T, np.nan, np.float32)
        head = self.model.get_output_embeddings()
        cap = getattr(_text_config(self.model), "final_logit_softcapping", None)
        with torch.no_grad():
            for i in range(0, T - 1, LOGIT_CHUNK):
                j = min(i + LOGIT_CHUNK, T - 1)
                z = head(h[i:j].to(self.model.dtype)).float()
                if cap:
                    z = torch.tanh(z / cap) * cap
                g = torch.log_softmax(z, dim=-1)
                out[i + 1:j + 1] = (-(g.exp() * g).sum(-1)).cpu().numpy()
        return out

    def run(self, prompt: str, max_new_tokens: int = 1024, seed: int = 0,
            thinking: bool = True, **sampling) -> Trace:
        """Generate from `prompt`, then capture the field over the whole sequence."""
        pre = self.prompt_ids(prompt, thinking)
        self._log(f"generating up to {max_new_tokens} tokens from {len(pre)} prompt tokens …")
        ids = self.generate(pre, max_new_tokens=max_new_tokens, seed=seed, **sampling)
        self._log(f"teacher-forced pass over {len(ids)} tokens × "
                  f"{len(self.stack.blocks)} blocks …")
        cfg = dict(self.spec.sampling)
        cfg.update(sampling)
        return self.capture(ids, len(pre), prompt=prompt,
                            config={"seed": seed, "max_new_tokens": max_new_tokens,
                                    "thinking": thinking, "sampling": cfg,
                                    "hf": self.spec.hf, "revision": self.spec.revision})


# ---------------------------------------------------------------------- helpers

def _flatten(ids) -> list[int]:
    if hasattr(ids, "input_ids"):
        ids = ids.input_ids
    ids = list(ids)
    if ids and isinstance(ids[0], (list, tuple)):
        ids = list(ids[0])
    return [int(i) for i in ids]


def _text_config(model):
    return getattr(model.config, "text_config", model.config)


def _decoder(model):
    return getattr(model, "model", model)


def _decoder_blocks(model) -> dict[int, object]:
    """`{index: block}` for the deepest `.layers.N` group, which is the decoder stack."""
    groups: dict[str, dict[int, object]] = {}
    for name, mod in model.named_modules():
        m = re.match(r"(.*)\.layers\.(\d+)$", name)
        if m:
            groups.setdefault(m.group(1), {})[int(m.group(2))] = mod
    if not groups:
        raise RuntimeError("no `.layers.N` modules found; cannot locate the decoder blocks")
    return groups[max(groups, key=lambda k: len(groups[k]))]


def _think_ids(tok, spec: ModelSpec) -> tuple[int | None, int | None]:
    """(open, close) token ids of the thinking channel, or None when not single tokens."""
    def one(s: str) -> int | None:
        ids = tok.encode(s, add_special_tokens=False)
        return int(ids[0]) if len(ids) == 1 else None
    return one(spec.think_start), one(spec.think_end)


def _segment(ids: list[int], prompt_len: int, open_id: int | None, close_id: int | None,
             ) -> tuple[int, bool]:
    """(absolute index of the end-of-think token, whether the block was never closed).

    The chat template may already have opened the thinking channel at the end of the prompt,
    which is the usual case; otherwise the opener is looked for in the generation. A block
    that is opened and never closed runs to the last token, and the whole generation is
    thinking — the case a token budget produces, and the one that must not be read as a
    zero-token answer.
    """
    head, gen = ids[:prompt_len], ids[prompt_len:]
    if close_id is None:
        return len(ids) - 1, True
    last_open = max((i for i, t in enumerate(head) if t == open_id), default=-1)
    last_close = max((i for i, t in enumerate(head) if t == close_id), default=-1)
    if open_id is not None and last_open > last_close:
        start = 0                                       # already open at the prompt's end
    else:
        start = next((i + 1 for i, t in enumerate(gen) if t == open_id), None)
        if start is None:
            return max(prompt_len - 1, 0), False        # no block at all: it is all answer
    rest = gen[start:]
    if close_id in rest:
        return prompt_len + start + rest.index(close_id), False
    return len(ids) - 1, True