RiverRider commited on
Commit
cfdff71
·
verified ·
1 Parent(s): 7dee135

initial upload: NLA demo (playground/trace/steer)

Browse files
README.md CHANGED
@@ -1,13 +1,19 @@
1
  ---
2
- title: Srt Nla Demo
3
- emoji: 📉
4
- colorFrom: yellow
5
  colorTo: pink
6
  sdk: gradio
7
- sdk_version: 6.14.0
8
- python_version: '3.12'
9
- app_file: app.py
10
  pinned: false
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
1
  ---
2
+ title: SRT-NLA Demo
3
+ emoji: 🧠
4
+ colorFrom: indigo
5
  colorTo: pink
6
  sdk: gradio
7
+ sdk_version: 4.44.0
8
+ app_file: demo/app.py
 
9
  pinned: false
10
+ license: apache-2.0
11
+ hardware: zero-a10g
12
+ short_description: Ask a frozen LM what it is thinking, in plain English.
13
  ---
14
 
15
+ # SRT-NLA Demo
16
+
17
+ Three views of the trained Activation Verbalizer over Qwen-2.5-7B,
18
+ Llama-3.2-3B, and Gemma-2-2B (all frozen). See `demo/README.md` for
19
+ details and the [companion paper](https://github.com/space-bacon/SRT/blob/main/paper_nla.md).
demo/README.md ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SRT-NLA Demo
2
+
3
+ A single Gradio app exposing three views of the trained Activation
4
+ Verbalizer (AV) sitting on top of three frozen backbones (Qwen-2.5-7B,
5
+ Llama-3.2-3B, Gemma-2-2B):
6
+
7
+ 1. **Playground** - prompt -> hidden state -> ranked candidate
8
+ verbalisations with raw and centred round-trip fidelity.
9
+ 2. **Live thought trace** - watch the backbone generate token by token
10
+ while the AV periodically verbalises its running hidden state.
11
+ 3. **Steer by editing** - verbalise the prompt's hidden into plain
12
+ English, edit the text, re-encode it, and patch the difference into
13
+ layer L while the backbone generates a new continuation.
14
+
15
+ All three tabs share a backbone selector at the top and a lazy in-memory
16
+ cache of loaded backbones. The first call on a given backbone downloads
17
+ the AV checkpoint and the centring pool from HuggingFace.
18
+
19
+ ## Run locally on a GPU box
20
+
21
+ ```bash
22
+ cd /workspace/srt-adapter
23
+ pip install -r demo/requirements.txt
24
+ python demo/app.py
25
+ ```
26
+
27
+ By default the app launches on `http://0.0.0.0:7860` and serves all
28
+ three backbones lazily. Set `NLA_DEFAULT_BACKBONE` to one of
29
+ `qwen2.5-7b`, `llama-3.2-3b`, `gemma-2-2b` to change the initial
30
+ selection.
31
+
32
+ Hardware (bf16):
33
+
34
+ | Backbone | VRAM | First-load time |
35
+ |-----------------|------:|----------------:|
36
+ | Qwen-2.5-7B | ~16GB | ~60s |
37
+ | Llama-3.2-3B | ~8GB | ~30s |
38
+ | Gemma-2-2B | ~6GB | ~20s |
39
+
40
+ ## Deploy as an HF Space
41
+
42
+ The app supports both standard GPU Spaces and **ZeroGPU**. When the
43
+ `spaces` package is importable and `SPACES_ZERO_GPU` is set in the
44
+ environment (HF Spaces does this automatically on a ZeroGPU tier), the
45
+ three callbacks are wrapped with `@spaces.GPU` and request CUDA on
46
+ demand. Otherwise they run on whatever device `torch.cuda.is_available()`
47
+ reports.
48
+
49
+ 1. Create a new Space (Gradio template). For ZeroGPU pick the
50
+ *zero-a10g* tier (free); otherwise pick *A10G* or larger.
51
+ 2. Mirror this `demo/` directory and the `srt/` package into the Space
52
+ repo (the app imports `srt.nla.verbalizer`).
53
+ 3. Add `HF_TOKEN` as a secret if any of the backbones is gated for your
54
+ account (Llama-3.2-3B is gated).
55
+ 4. Set `app_file: demo/app.py` in the Space `README.md` frontmatter.
56
+
57
+ Notes on ZeroGPU:
58
+
59
+ - First call on a backbone loads weights from HuggingFace; the duration
60
+ budget on each call is 120s (180s for the steer/trace tabs). Qwen-7B
61
+ cold-start can saturate this — prefer Gemma-2-2B as the default and
62
+ only switch to Qwen on Pro hardware.
63
+ - Models stay resident in the persistent control process between calls,
64
+ so subsequent invocations only pay the GPU-attach overhead.
65
+
66
+ ## How it works
67
+
68
+ Each `NLAPipeline` holds:
69
+
70
+ - the frozen backbone (`AutoModelForCausalLM`, `requires_grad=False`),
71
+ - the AV (`srt.nla.verbalizer.ActivationVerbalizer`) loaded from the
72
+ matching `RiverRider/srt-nla-av-*` HF repo,
73
+ - a centring pool (2K real layer-L last-token hiddens from the matching
74
+ `RiverRider/srt-nla-targets-*` HF dataset) used to compute centred
75
+ cosine fidelity that corrects for the backbone's anisotropic mean.
76
+
77
+ The verbaliser is invoked through `av.generate(v, ...)`, which prepends
78
+ `v` (projected) plus a learned static prefix to the backbone's
79
+ input-embeddings stream and runs greedy or sampled generation. The
80
+ backbone's weights are never touched.
81
+
82
+ Steering is a single `register_forward_hook` on
83
+ `backbone.model.layers[L]` that adds the layer-L residual difference
84
+ between the re-encoded edited text and the original prompt; once the
85
+ generation is finished the hook is removed.
86
+
87
+ ## Caveats
88
+
89
+ - The AV is trained on layer-L last-token hiddens of natural-language
90
+ prompts. Out-of-distribution hiddens (e.g. mid-token, code, foreign
91
+ scripts) will produce lower fidelity verbalisations.
92
+ - Best-of-K with the centred metric is the default ranking. Raw cosine
93
+ is also reported for comparison; raw is biased upward by the
94
+ backbone's anisotropic mean (see paper §3).
95
+ - Steering with `|alpha| > 1` often pushes the model off-distribution
96
+ fast; small values (0.1 - 0.5) are usually more interesting.
demo/app.py ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gradio app for the SRT-NLA demo.
2
+
3
+ Three tabs over a shared backbone selector:
4
+
5
+ 1. Playground - prompt -> hidden -> ranked candidate verbalisations
6
+ 2. Live Thought Trace - generate token by token, show AV verbalisation
7
+ every N tokens alongside each token
8
+ 3. Steer by Editing - verbalise the prompt's hidden state, edit the
9
+ text, re-encode it, run the model again with
10
+ the difference patched into layer L
11
+
12
+ Run locally on a GPU box:
13
+ pip install -r demo/requirements.txt
14
+ python demo/app.py
15
+
16
+ Or deploy as an HF Space with hardware = A10G or larger; the Qwen-7B
17
+ backbone needs ~16 GB of VRAM in bf16, the smaller backbones less.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import os
23
+
24
+ import gradio as gr
25
+ import torch
26
+
27
+ from nla_pipeline import BACKBONES, NLAPipeline
28
+
29
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(message)s")
30
+ log = logging.getLogger("nla_demo_app")
31
+
32
+ # Optional ZeroGPU support. On HF Spaces with `hardware: zero-*`, `spaces`
33
+ # is preinstalled and exposes the @spaces.GPU decorator that grants the
34
+ # decorated function ephemeral CUDA access. Off-Spaces it is a no-op.
35
+ try:
36
+ import spaces # type: ignore
37
+
38
+ _ON_ZEROGPU = bool(os.environ.get("SPACES_ZERO_GPU")) or hasattr(spaces, "GPU")
39
+
40
+ def _gpu(duration: int = 120):
41
+ if _ON_ZEROGPU:
42
+ return spaces.GPU(duration=duration)
43
+ return lambda fn: fn
44
+ except ImportError: # pragma: no cover - local dev path
45
+ _ON_ZEROGPU = False
46
+
47
+ def _gpu(duration: int = 120):
48
+ return lambda fn: fn
49
+
50
+
51
+ DEFAULT_BACKBONE = os.environ.get("NLA_DEFAULT_BACKBONE", "gemma-2-2b")
52
+ # On ZeroGPU torch.cuda.is_available() is False at import time but becomes
53
+ # True inside @spaces.GPU functions. Pin device to cuda when we know the
54
+ # Space has a GPU slice.
55
+ DEVICE = "cuda" if (torch.cuda.is_available() or _ON_ZEROGPU) else "cpu"
56
+ log.info("device=%s zero_gpu=%s", DEVICE, _ON_ZEROGPU)
57
+
58
+ # Lazy cache: load each backbone the first time it's selected.
59
+ _PIPES: dict[str, NLAPipeline] = {}
60
+
61
+
62
+ def get_pipe(key: str) -> NLAPipeline:
63
+ if key not in _PIPES:
64
+ log.info("First use of backbone %s; loading.", key)
65
+ _PIPES[key] = NLAPipeline(BACKBONES[key], device=DEVICE)
66
+ return _PIPES[key]
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Tab callbacks
71
+ # ---------------------------------------------------------------------------
72
+
73
+ @_gpu(duration=120)
74
+ def cb_playground(backbone_key: str, prompt: str, K: int, temperature: float):
75
+ if not prompt.strip():
76
+ return "_(enter a prompt)_", ""
77
+ pipe = get_pipe(backbone_key)
78
+ v = pipe.extract_hidden(prompt, token_index=-1)
79
+ ranked = pipe.verbalize(v, K=int(K), temperature=float(temperature))
80
+ rows = [
81
+ f"| {i+1} | {cen:.3f} | {raw:.3f} | {txt[:160].replace(chr(10), ' ')} |"
82
+ for i, (txt, raw, cen) in enumerate(ranked)
83
+ ]
84
+ table = "| rank | centred fve | raw fve | verbalisation |\n|---|---|---|---|\n" + "\n".join(rows)
85
+ mu_str = f"{pipe.mu.norm().item():.2f}" if pipe.mu is not None else "n/a"
86
+ summary = (
87
+ f"**Backbone:** {pipe.spec.label} "
88
+ f"**hidden norm** = {v.norm().item():.2f} "
89
+ f"**centring pool norm** = {mu_str}"
90
+ )
91
+ return summary, table
92
+
93
+
94
+ @_gpu(duration=180)
95
+ def cb_thought_trace(backbone_key: str, prompt: str, max_new: int, every: int):
96
+ if not prompt.strip():
97
+ yield "_(enter a prompt)_"
98
+ return
99
+ pipe = get_pipe(backbone_key)
100
+ rows: list[str] = ["| step | token | verbalisation of running hidden |", "|---|---|---|"]
101
+ yielded = 0
102
+ for step, (tok, verb) in enumerate(
103
+ pipe.thought_trace(prompt, max_new_tokens=int(max_new), every=int(every), K=4)
104
+ ):
105
+ if verb is None:
106
+ rows.append(f"| {step} | `{tok}` | _(skipped, not at every-{every} step)_ |")
107
+ else:
108
+ rows.append(f"| {step} | `{tok}` | {verb[:140].replace(chr(10), ' ')} |")
109
+ yielded += 1
110
+ # Stream every few rows
111
+ if yielded % 4 == 0 or yielded == int(max_new):
112
+ yield "\n".join(rows)
113
+ yield "\n".join(rows)
114
+
115
+
116
+ @_gpu(duration=180)
117
+ def cb_steer(
118
+ backbone_key: str,
119
+ prompt: str,
120
+ replacement: str,
121
+ alpha: float,
122
+ max_new: int,
123
+ ):
124
+ if not prompt.strip():
125
+ return "_(enter a prompt)_", "", ""
126
+ pipe = get_pipe(backbone_key)
127
+ # 1. Original verbalisation (best of 4) of the prompt's hidden
128
+ v = pipe.extract_hidden(prompt, token_index=-1)
129
+ ranked = pipe.verbalize(v, K=4, temperature=1.0)
130
+ base_verb = ranked[0][0] if ranked else ""
131
+
132
+ # 2. Original (unsteered) greedy continuation
133
+ ids = pipe.tokenizer(prompt, return_tensors="pt").to(pipe.device)
134
+ with torch.no_grad():
135
+ gen = pipe.backbone.generate(
136
+ **ids,
137
+ max_new_tokens=int(max_new),
138
+ do_sample=False,
139
+ pad_token_id=pipe.tokenizer.pad_token_id,
140
+ )
141
+ base_cont = pipe.tokenizer.decode(
142
+ gen[0, ids.input_ids.shape[1]:], skip_special_tokens=True
143
+ )
144
+
145
+ # 3. Steered continuation: re-encode replacement, patch into layer L
146
+ if not replacement.strip():
147
+ replacement = base_verb
148
+ steered_cont = pipe.steer_with_text(
149
+ prompt, replacement, alpha=float(alpha), max_new_tokens=int(max_new)
150
+ )
151
+ return base_verb, base_cont, steered_cont
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # UI
156
+ # ---------------------------------------------------------------------------
157
+
158
+ with gr.Blocks(title="SRT-NLA: ask the model what it is thinking") as app:
159
+ gr.Markdown(
160
+ """# SRT-NLA Demo
161
+
162
+ Ask a frozen language model what is in its own internal hidden state, in
163
+ plain English, in three different ways. The trained Activation
164
+ Verbalizer (AV) is a small adapter (~5–13M params depending on backbone)
165
+ sitting on top of a fully frozen backbone. The backbone weights are
166
+ unchanged from the public release. Companion paper:
167
+ [`paper_nla.md`](https://github.com/space-bacon/SRT/blob/main/paper_nla.md).
168
+ """
169
+ )
170
+ backbone_key = gr.Radio(
171
+ choices=[(spec.label, key) for key, spec in BACKBONES.items()],
172
+ value=DEFAULT_BACKBONE,
173
+ label="Frozen backbone",
174
+ )
175
+
176
+ with gr.Tab("1 - Playground"):
177
+ gr.Markdown(
178
+ "Enter any prompt. We extract the last-token hidden state at the "
179
+ "configured layer, sample K candidate verbalisations, then for each "
180
+ "candidate we feed it back through the same frozen model and measure "
181
+ "how well its own re-encoded hidden state matches the original (raw "
182
+ "and anisotropy-centred cosine fidelity)."
183
+ )
184
+ with gr.Row():
185
+ with gr.Column():
186
+ pl_prompt = gr.Textbox(
187
+ label="Prompt",
188
+ value="The Eiffel Tower stands on the Champ de Mars in Paris,",
189
+ lines=3,
190
+ )
191
+ pl_K = gr.Slider(1, 32, value=8, step=1, label="K (samples)")
192
+ pl_temp = gr.Slider(0.1, 1.5, value=1.0, step=0.05, label="Temperature")
193
+ pl_btn = gr.Button("Verbalize", variant="primary")
194
+ with gr.Column():
195
+ pl_summary = gr.Markdown()
196
+ pl_table = gr.Markdown()
197
+ pl_btn.click(
198
+ cb_playground,
199
+ inputs=[backbone_key, pl_prompt, pl_K, pl_temp],
200
+ outputs=[pl_summary, pl_table],
201
+ )
202
+
203
+ with gr.Tab("2 - Live thought trace"):
204
+ gr.Markdown(
205
+ "Watch the model generate token by token while we periodically "
206
+ "verbalise its running last-token hidden state. The right column is "
207
+ "the model talking *about* what its current hidden state is about, "
208
+ "while the left column is what it is choosing to *say next*."
209
+ )
210
+ with gr.Row():
211
+ with gr.Column():
212
+ tt_prompt = gr.Textbox(
213
+ label="Prompt",
214
+ value="In one paragraph, explain why the sky is blue.",
215
+ lines=3,
216
+ )
217
+ tt_max = gr.Slider(8, 64, value=24, step=4, label="Tokens to generate")
218
+ tt_every = gr.Slider(1, 8, value=4, step=1, label="Verbalise every N tokens")
219
+ tt_btn = gr.Button("Trace", variant="primary")
220
+ with gr.Column():
221
+ tt_out = gr.Markdown()
222
+ tt_btn.click(
223
+ cb_thought_trace,
224
+ inputs=[backbone_key, tt_prompt, tt_max, tt_every],
225
+ outputs=tt_out,
226
+ )
227
+
228
+ with gr.Tab("3 - Steer by editing"):
229
+ gr.Markdown(
230
+ "Activation steering with a sentence editor. We verbalise the "
231
+ "prompt's hidden state into plain English, you edit the text, we "
232
+ "re-encode the edited text into a new hidden state, and we add "
233
+ "`alpha * (v_new - v_orig)` to layer L's residual stream while the "
234
+ "model generates. Compare the unsteered and steered continuations "
235
+ "side by side."
236
+ )
237
+ with gr.Row():
238
+ with gr.Column():
239
+ st_prompt = gr.Textbox(
240
+ label="Prompt",
241
+ value="Tell me a short fact about cats.",
242
+ lines=3,
243
+ )
244
+ st_replacement = gr.Textbox(
245
+ label="Replacement description (leave blank to use the AV's own verbalisation)",
246
+ placeholder="e.g. 'a fact about deep-sea fish'",
247
+ lines=2,
248
+ )
249
+ st_alpha = gr.Slider(-2.0, 2.0, value=1.0, step=0.05, label="alpha")
250
+ st_max = gr.Slider(8, 128, value=64, step=8, label="Tokens to generate")
251
+ st_btn = gr.Button("Steer", variant="primary")
252
+ with gr.Column():
253
+ st_base_verb = gr.Textbox(label="AV's verbalisation of the original prompt", lines=2)
254
+ st_base_cont = gr.Textbox(label="Unsteered continuation", lines=4)
255
+ st_steered = gr.Textbox(label="Steered continuation", lines=4)
256
+ st_btn.click(
257
+ cb_steer,
258
+ inputs=[backbone_key, st_prompt, st_replacement, st_alpha, st_max],
259
+ outputs=[st_base_verb, st_base_cont, st_steered],
260
+ )
261
+
262
+ gr.Markdown(
263
+ """---
264
+ **Notes.** The first call on each backbone downloads weights from
265
+ HuggingFace; subsequent calls reuse the in-memory cache. Centred fve
266
+ uses a 2K-sample anisotropy pool published in the matching dataset
267
+ repo. Steering is implemented as a single forward hook on layer L and
268
+ does not retrain anything. Hardware: roughly 16 GB VRAM for Qwen-7B
269
+ in bf16; less for Llama-3B and Gemma-2B.
270
+ """
271
+ )
272
+
273
+
274
+ if __name__ == "__main__":
275
+ app.queue().launch(share=False)
demo/nla_pipeline.py ADDED
@@ -0,0 +1,370 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared NLA pipeline used by demo/app.py.
2
+
3
+ Wraps the trained Activation Verbalizer (AV) for any of three backbones
4
+ behind a single class with three operations:
5
+
6
+ 1. extract_hidden(prompt, token_index) -> torch.Tensor
7
+ Take a prompt string, run the frozen backbone forward, return the
8
+ hidden state at the configured extraction layer at the given token
9
+ position (default: last token).
10
+
11
+ 2. verbalize(v, K, temperature) -> list[(text, raw_fve, centred_fve)]
12
+ Generate K candidate verbalisations of vector v, score each one
13
+ round-trip, return them sorted by centred fidelity.
14
+
15
+ 3. steer_with_text(prompt, replacement_text, alpha) -> str
16
+ Re-encode `replacement_text` to a layer-L hidden vector v_new, then
17
+ run the frozen backbone forward on `prompt` with v_new added (scaled
18
+ by alpha) to the L20 last-token hidden state at every position. Return
19
+ the greedy continuation.
20
+
21
+ Loaded weights come from the public HF release pack; nothing in this
22
+ file is repo-specific past the import path of `srt.nla`.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ import sys
28
+ from dataclasses import dataclass
29
+ from pathlib import Path
30
+ from typing import Iterable
31
+
32
+ import torch
33
+ import torch.nn.functional as F
34
+
35
+ # Make `srt` importable when running from repo root or from demo/.
36
+ _REPO = Path(__file__).resolve().parent.parent
37
+ if str(_REPO) not in sys.path:
38
+ sys.path.insert(0, str(_REPO))
39
+
40
+ from srt.nla.config import NLAConfig # noqa: E402
41
+ from srt.nla.verbalizer import ActivationVerbalizer # noqa: E402
42
+ from huggingface_hub import hf_hub_download # noqa: E402
43
+ from transformers import AutoModelForCausalLM, AutoTokenizer # noqa: E402
44
+
45
+ log = logging.getLogger("nla_demo")
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Backbone registry
50
+ # ---------------------------------------------------------------------------
51
+
52
+ @dataclass(frozen=True)
53
+ class BackboneSpec:
54
+ label: str # display name in UI
55
+ backbone_id: str # HF model ID for the frozen LM
56
+ extraction_layer: int # layer to read hidden states from
57
+ av_repo: str # HF repo with best_av.pt
58
+ av_filename: str = "best_av.pt"
59
+ targets_repo: str | None = None # HF dataset for centring pool
60
+ targets_filename: str | None = None
61
+ num_prefix_tokens: int = 1
62
+ num_inject_slots: int = 1
63
+
64
+
65
+ BACKBONES: dict[str, BackboneSpec] = {
66
+ "qwen2.5-7b": BackboneSpec(
67
+ label="Qwen 2.5-7B (L20)",
68
+ backbone_id="Qwen/Qwen2.5-7B",
69
+ extraction_layer=20,
70
+ av_repo="RiverRider/srt-nla-av-v1",
71
+ av_filename="best_av.pt",
72
+ targets_repo="RiverRider/srt-nla-targets-v1",
73
+ targets_filename="targets_q7b_L20_seq64_30k_seed1.pt",
74
+ num_prefix_tokens=16,
75
+ ),
76
+ "llama-3.2-3b": BackboneSpec(
77
+ label="Llama 3.2-3B (L20)",
78
+ backbone_id="meta-llama/Llama-3.2-3B",
79
+ extraction_layer=20,
80
+ av_repo="RiverRider/srt-nla-av-llama32-3b",
81
+ av_filename="best_av.pt",
82
+ targets_repo="RiverRider/srt-nla-targets-llama32-3b-v1",
83
+ targets_filename="targets_L20_seq64_30k_seed1.pt",
84
+ ),
85
+ "gemma-2-2b": BackboneSpec(
86
+ label="Gemma 2-2B (L19)",
87
+ backbone_id="google/gemma-2-2b",
88
+ extraction_layer=19,
89
+ av_repo="RiverRider/srt-nla-av-gemma2-2b-v1",
90
+ av_filename="best_av.pt",
91
+ targets_repo="RiverRider/srt-nla-targets-gemma2-2b-v1",
92
+ targets_filename="targets_L19_seq64_30k_seed1.pt",
93
+ ),
94
+ }
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Pipeline
99
+ # ---------------------------------------------------------------------------
100
+
101
+ class NLAPipeline:
102
+ """One backbone + AV + centring pool, kept resident on a single device."""
103
+
104
+ def __init__(
105
+ self,
106
+ spec: BackboneSpec,
107
+ device: str | torch.device = "cuda",
108
+ dtype: torch.dtype = torch.bfloat16,
109
+ pool_size: int = 2000,
110
+ ) -> None:
111
+ self.spec = spec
112
+ self.device = torch.device(device)
113
+ log.info("Loading frozen backbone %s on %s", spec.backbone_id, self.device)
114
+ self.tokenizer = AutoTokenizer.from_pretrained(spec.backbone_id)
115
+ if self.tokenizer.pad_token_id is None:
116
+ self.tokenizer.pad_token = self.tokenizer.eos_token
117
+ self.backbone = AutoModelForCausalLM.from_pretrained(
118
+ spec.backbone_id, torch_dtype=dtype
119
+ ).to(self.device)
120
+ self.backbone.eval()
121
+ for p in self.backbone.parameters():
122
+ p.requires_grad_(False)
123
+
124
+ cfg = NLAConfig(
125
+ backbone_id=spec.backbone_id,
126
+ extraction_layer=spec.extraction_layer,
127
+ num_prefix_tokens=spec.num_prefix_tokens,
128
+ num_inject_slots=spec.num_inject_slots,
129
+ max_new_tokens=64,
130
+ )
131
+ self.av = ActivationVerbalizer(cfg, backbone=self.backbone, tokenizer=self.tokenizer)
132
+
133
+ log.info("Downloading AV checkpoint from %s", spec.av_repo)
134
+ ckpt_path = hf_hub_download(spec.av_repo, spec.av_filename)
135
+ sd = torch.load(ckpt_path, map_location="cpu", weights_only=False)
136
+ # Released checkpoints are wrapped as {'trainable': {...}, 'step': int,
137
+ # 'val_fve_nrm': float}; older checkpoints used 'state_dict' or were
138
+ # raw state dicts.
139
+ if isinstance(sd, dict):
140
+ for wrap_key in ("trainable", "state_dict", "av_state_dict"):
141
+ if wrap_key in sd and isinstance(sd[wrap_key], dict):
142
+ sd = sd[wrap_key]
143
+ break
144
+ # Filter to AV-only keys (proj, prefix_embeds, etc.). The frozen
145
+ # backbone weights live under `backbone.*` in `av.state_dict()` but
146
+ # are not in the saved AV checkpoint, so we ignore those when
147
+ # checking for missing keys.
148
+ own = self.av.state_dict()
149
+ filtered = {k: v for k, v in sd.items() if k in own and own[k].shape == v.shape}
150
+ adapter_only = {k for k in own if not k.startswith("backbone.")}
151
+ missing = adapter_only - set(filtered)
152
+ if missing:
153
+ log.warning("AV checkpoint missing adapter keys: %s", sorted(missing))
154
+ self.av.load_state_dict(filtered, strict=False)
155
+ self.av.to(self.device)
156
+ self.av.eval()
157
+
158
+ # Centring pool
159
+ self.mu: torch.Tensor | None = None
160
+ if spec.targets_repo and spec.targets_filename:
161
+ try:
162
+ log.info("Downloading centring pool %s", spec.targets_repo)
163
+ pool_path = hf_hub_download(
164
+ spec.targets_repo, spec.targets_filename, repo_type="dataset"
165
+ )
166
+ pool_obj = torch.load(pool_path, map_location="cpu", weights_only=False)
167
+ # The released targets file is a dict
168
+ # {'sequences': [...str], 'activations': [...Tensor], 'meta': {...}}
169
+ # but earlier shards were saved as a flat tensor or as a dict
170
+ # with a 'hiddens'/'targets' key. Normalise to (N, d).
171
+ if isinstance(pool_obj, dict):
172
+ for key in ("activations", "hiddens", "targets"):
173
+ if key in pool_obj:
174
+ pool_obj = pool_obj[key]
175
+ break
176
+ if isinstance(pool_obj, list):
177
+ # Items are per-sequence (T_i, d) hidden tensors saved
178
+ # flat as (T_i*d,). Reshape and keep the last-token
179
+ # row of each, since the AV is trained on last-token
180
+ # last-token hiddens.
181
+ d = self.backbone.config.hidden_size
182
+ last_rows = []
183
+ for t in pool_obj[:pool_size]:
184
+ flat = t.flatten()
185
+ T = flat.numel() // d
186
+ last_rows.append(flat[(T - 1) * d : T * d])
187
+ pool_obj = torch.stack(last_rows)
188
+ else:
189
+ pool_obj = pool_obj[:pool_size]
190
+ self.mu = pool_obj.float().mean(0).to(self.device)
191
+ log.info("Centring pool ||mu|| = %.2f", self.mu.norm().item())
192
+ except Exception as e: # noqa: BLE001
193
+ log.warning("Could not load centring pool (%s); centred fve disabled", e)
194
+
195
+ self.dtype = dtype
196
+
197
+ # ------------------------------------------------------------------
198
+ # Hidden-state extraction
199
+ # ------------------------------------------------------------------
200
+
201
+ @torch.no_grad()
202
+ def extract_hidden(
203
+ self,
204
+ prompt: str,
205
+ token_index: int = -1,
206
+ ) -> torch.Tensor:
207
+ """Run the frozen backbone on `prompt`; return the layer-L hidden
208
+ state at `token_index` (default: last input token), shape (d,)."""
209
+ ids = self.tokenizer(prompt, return_tensors="pt").to(self.device)
210
+ out = self.backbone(
211
+ input_ids=ids.input_ids,
212
+ attention_mask=ids.attention_mask,
213
+ output_hidden_states=True,
214
+ use_cache=False,
215
+ )
216
+ h = out.hidden_states[self.spec.extraction_layer][0] # (T, d)
217
+ if token_index < 0:
218
+ token_index = h.size(0) + token_index
219
+ return h[token_index].float().detach()
220
+
221
+ @torch.no_grad()
222
+ def extract_all_hidden(self, prompt: str) -> tuple[list[str], torch.Tensor]:
223
+ """Return (per-token decoded strings, hiddens of shape (T, d))."""
224
+ ids = self.tokenizer(prompt, return_tensors="pt").to(self.device)
225
+ out = self.backbone(
226
+ input_ids=ids.input_ids,
227
+ attention_mask=ids.attention_mask,
228
+ output_hidden_states=True,
229
+ use_cache=False,
230
+ )
231
+ h = out.hidden_states[self.spec.extraction_layer][0].float().detach()
232
+ toks = [self.tokenizer.decode([t]) for t in ids.input_ids[0].tolist()]
233
+ return toks, h
234
+
235
+ # ------------------------------------------------------------------
236
+ # Verbalisation + scoring
237
+ # ------------------------------------------------------------------
238
+
239
+ @torch.no_grad()
240
+ def verbalize(
241
+ self,
242
+ v: torch.Tensor,
243
+ K: int = 8,
244
+ temperature: float = 1.0,
245
+ max_new_tokens: int = 64,
246
+ ) -> list[tuple[str, float, float]]:
247
+ """Sample K candidate verbalisations, round-trip score each, return
248
+ list of (text, raw_fve, centred_fve) sorted by centred_fve desc.
249
+
250
+ If centring pool is unavailable, centred_fve == raw_fve."""
251
+ v = v.to(self.device).float()
252
+ v_rep = v.unsqueeze(0).expand(K, -1)
253
+ ids = self.av.generate(
254
+ v_rep,
255
+ max_new_tokens=max_new_tokens,
256
+ do_sample=temperature > 0,
257
+ temperature=max(temperature, 1e-6),
258
+ top_p=1.0,
259
+ )
260
+ texts = self.tokenizer.batch_decode(ids, skip_special_tokens=True)
261
+
262
+ # Round-trip: feed each text into the same backbone, extract layer-L
263
+ # last-token hidden, score against v.
264
+ scored: list[tuple[str, float, float]] = []
265
+ for txt in texts:
266
+ if not txt.strip():
267
+ scored.append((txt, 0.0, 0.0))
268
+ continue
269
+ h = self.extract_hidden(txt, token_index=-1)
270
+ raw = 0.5 * (1.0 + F.cosine_similarity(h.unsqueeze(0), v.unsqueeze(0)).item())
271
+ if self.mu is not None:
272
+ hc, vc = h - self.mu, v - self.mu
273
+ cen = 0.5 * (1.0 + F.cosine_similarity(hc.unsqueeze(0), vc.unsqueeze(0)).item())
274
+ else:
275
+ cen = raw
276
+ scored.append((txt, raw, cen))
277
+ scored.sort(key=lambda r: r[2], reverse=True)
278
+ return scored
279
+
280
+ # ------------------------------------------------------------------
281
+ # Steering by edited verbalisation
282
+ # ------------------------------------------------------------------
283
+
284
+ @torch.no_grad()
285
+ def steer_with_text(
286
+ self,
287
+ prompt: str,
288
+ replacement_text: str,
289
+ alpha: float = 1.0,
290
+ max_new_tokens: int = 64,
291
+ ) -> str:
292
+ """Re-encode `replacement_text` to a layer-L hidden vector v_new,
293
+ then run greedy generation on `prompt` with a forward hook that
294
+ adds `alpha * (v_new - v_orig)` to layer-L's output at every
295
+ position. Returns the generated continuation only.
296
+ """
297
+ v_orig = self.extract_hidden(prompt, token_index=-1)
298
+ v_new = self.extract_hidden(replacement_text, token_index=-1)
299
+ delta = (v_new - v_orig) * alpha
300
+
301
+ # Find the residual stream block at extraction_layer.
302
+ # Both LLaMA-like and Gemma-like architectures expose this as
303
+ # backbone.model.layers[L].
304
+ try:
305
+ target_block = self.backbone.model.layers[self.spec.extraction_layer]
306
+ except AttributeError:
307
+ target_block = self.backbone.transformer.h[self.spec.extraction_layer]
308
+
309
+ delta_b = delta.to(self.dtype)
310
+
311
+ def _hook(_mod, _inp, out):
312
+ if isinstance(out, tuple):
313
+ h = out[0]
314
+ h = h + delta_b
315
+ return (h,) + out[1:]
316
+ return out + delta_b
317
+
318
+ handle = target_block.register_forward_hook(_hook)
319
+ try:
320
+ ids = self.tokenizer(prompt, return_tensors="pt").to(self.device)
321
+ gen = self.backbone.generate(
322
+ **ids,
323
+ max_new_tokens=max_new_tokens,
324
+ do_sample=False,
325
+ pad_token_id=self.tokenizer.pad_token_id,
326
+ )
327
+ finally:
328
+ handle.remove()
329
+
330
+ new_ids = gen[0, ids.input_ids.shape[1]:]
331
+ return self.tokenizer.decode(new_ids, skip_special_tokens=True)
332
+
333
+ # ------------------------------------------------------------------
334
+ # Live thought-trace
335
+ # ------------------------------------------------------------------
336
+
337
+ @torch.no_grad()
338
+ def thought_trace(
339
+ self,
340
+ prompt: str,
341
+ max_new_tokens: int = 32,
342
+ every: int = 4,
343
+ K: int = 4,
344
+ ) -> Iterable[tuple[str, str | None]]:
345
+ """Greedy-generate `max_new_tokens` tokens after `prompt`. Every
346
+ `every` tokens, run the AV on the running last-token hidden state
347
+ and yield (token_text, verbalisation) pairs. Other steps yield
348
+ (token_text, None)."""
349
+ ids = self.tokenizer(prompt, return_tensors="pt").to(self.device)
350
+ cur = ids.input_ids
351
+ attn = ids.attention_mask
352
+ for step in range(max_new_tokens):
353
+ out = self.backbone(
354
+ input_ids=cur,
355
+ attention_mask=attn,
356
+ output_hidden_states=True,
357
+ use_cache=False,
358
+ )
359
+ next_id = out.logits[0, -1].argmax().unsqueeze(0).unsqueeze(0)
360
+ tok_text = self.tokenizer.decode(next_id[0])
361
+ cur = torch.cat([cur, next_id], dim=1)
362
+ attn = torch.cat([attn, torch.ones_like(next_id)], dim=1)
363
+ if step % every == 0:
364
+ v = out.hidden_states[self.spec.extraction_layer][0, -1].float().detach()
365
+ ranked = self.verbalize(v, K=K, temperature=1.0, max_new_tokens=24)
366
+ yield tok_text, ranked[0][0] if ranked else None
367
+ else:
368
+ yield tok_text, None
369
+ if next_id.item() == (self.tokenizer.eos_token_id or -1):
370
+ break
demo/requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ gradio>=4.40
2
+ torch>=2.2
3
+ transformers>=4.44
4
+ huggingface_hub>=0.24
5
+ accelerate>=0.33
6
+ sentencepiece
7
+ safetensors
8
+ numpy
9
+ spaces>=0.30 ; sys_platform == "linux"
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ gradio>=4.40
2
+ torch>=2.2
3
+ transformers>=4.44
4
+ huggingface_hub>=0.24
5
+ accelerate>=0.33
6
+ sentencepiece
7
+ safetensors
8
+ numpy
9
+ spaces>=0.30 ; sys_platform == "linux"
srt/__init__.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Semiotic-Reflexive Transformer (SRT) — Adapter Architecture."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+
6
+ def _nla_factory():
7
+ """Lazy entry point for the SRT-NLA verbalizer/reconstructor pair.
8
+
9
+ Imported on demand so callers that only want the v3 adapter don't
10
+ pay the cost of pulling in ``srt.nla`` (which lazily loads
11
+ ``transformers`` even further).
12
+ """
13
+ from srt.nla import ( # noqa: F401 (re-export)
14
+ ActivationReconstructor,
15
+ ActivationVerbalizer,
16
+ NLAConfig,
17
+ )
18
+
19
+ return {
20
+ "ActivationReconstructor": ActivationReconstructor,
21
+ "ActivationVerbalizer": ActivationVerbalizer,
22
+ "NLAConfig": NLAConfig,
23
+ }
24
+
25
+
26
+ HEAD_FACTORIES = {
27
+ "nla": _nla_factory,
28
+ }
srt/adapter.py ADDED
@@ -0,0 +1,316 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SRT Adapter — Semiotic awareness bolted onto any frozen causal LM.
2
+
3
+ The adapter wraps a HuggingFace AutoModelForCausalLM and runs its layers
4
+ manually, tapping hidden states at MAH hook points and injecting corrections
5
+ at RRM injection points. The backbone's native embeddings and LM head are
6
+ used directly — no bridges, no tied embeddings, no CE degradation.
7
+
8
+ model = SRTAdapter(config)
9
+ out = model(input_ids, labels=labels)
10
+ # out.ce_loss — from backbone's native LM head
11
+ # out.r_hat — per-position reflexivity estimate
12
+ # out.regime — subcritical vs supercritical classification
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ from dataclasses import dataclass, field
19
+
20
+ import torch
21
+ import torch.nn as nn
22
+ import torch.nn.functional as F
23
+ from transformers import AutoModelForCausalLM, AutoConfig
24
+
25
+ from srt.config import SRTConfig
26
+ from srt.modules.mah import MetapragmaticAttentionHead, MAHOutput
27
+ from srt.modules.rrm import ReflexiveRecurrentModule
28
+ from srt.modules.ben import BifurcationEstimationNetwork, BENOutput
29
+ from srt.modules.community import CommunityDiscoveryHead, CommunityOutput
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ @dataclass
35
+ class SRTAdapterOutput:
36
+ """Full output from the SRT adapter."""
37
+
38
+ logits: torch.Tensor # (B, T, V)
39
+ ce_loss: torch.Tensor | None = None # scalar
40
+ divergences: list[torch.Tensor] = field(default_factory=list) # [(B, T, d_div)]
41
+ injections: list[torch.Tensor] = field(default_factory=list) # [(B, T, d_backbone)]
42
+ ben_output: BENOutput | None = None
43
+ community_output: CommunityOutput | None = None
44
+ meta_state: torch.Tensor | None = None # (B, T, d_meta)
45
+ chain_residual_per_token: torch.Tensor | None = None # (B, T) mean chain residual
46
+
47
+
48
+ def _make_causal_mask(
49
+ seq_len: int, dtype: torch.dtype, device: torch.device
50
+ ) -> torch.Tensor:
51
+ """Create 4D additive causal attention mask."""
52
+ mask = torch.full(
53
+ (seq_len, seq_len), torch.finfo(dtype).min, dtype=dtype, device=device
54
+ )
55
+ mask = torch.triu(mask, diagonal=1)
56
+ return mask[None, None, :, :] # (1, 1, T, T)
57
+
58
+
59
+ class SRTAdapter(nn.Module):
60
+ """Semiotic-Reflexive Transformer adapter for any causal LM backbone."""
61
+
62
+ def __init__(self, config: SRTConfig) -> None:
63
+ super().__init__()
64
+ self.config = config
65
+
66
+ # ── Load and freeze backbone ─────────────────────────────────
67
+ dtype_map = {
68
+ "float32": torch.float32,
69
+ "float16": torch.float16,
70
+ "bfloat16": torch.bfloat16,
71
+ }
72
+ load_dtype = dtype_map.get(config.backbone_dtype, torch.bfloat16)
73
+
74
+ logger.info("Loading backbone: %s in %s", config.backbone_id, config.backbone_dtype)
75
+ self.backbone = AutoModelForCausalLM.from_pretrained(
76
+ config.backbone_id, torch_dtype=load_dtype
77
+ )
78
+ for p in self.backbone.parameters():
79
+ p.requires_grad = False
80
+ self.backbone.eval()
81
+
82
+ # Extract backbone parts (works for LLaMA, Qwen, Mistral, Phi, Gemma)
83
+ inner = self.backbone.model
84
+ self._embed_tokens = inner.embed_tokens
85
+ self._layers = inner.layers
86
+ self._final_norm = inner.norm
87
+ self._lm_head = self.backbone.lm_head
88
+ self._rotary_emb = getattr(inner, "rotary_emb", None)
89
+
90
+ d_backbone = self.backbone.config.hidden_size
91
+ num_layers = self.backbone.config.num_hidden_layers
92
+ self._d_backbone = d_backbone
93
+ self._num_layers = num_layers
94
+
95
+ # Resolve auto layer indices
96
+ config.resolve_layer_indices(num_layers)
97
+
98
+ logger.info(
99
+ "Backbone: d=%d, L=%d, MAH@%s, inject@%s, community@%d",
100
+ d_backbone,
101
+ num_layers,
102
+ config.mah_layer_indices,
103
+ config.rrm_inject_indices,
104
+ config.community_layer_idx,
105
+ )
106
+
107
+ # ── Community discovery (early layer) ────────────────────────
108
+ self.community_head = CommunityDiscoveryHead(config.community, d_backbone)
109
+
110
+ # ── MAH heads (one per hook layer) ───────────────────────────
111
+ self.mah_heads = nn.ModuleList([
112
+ MetapragmaticAttentionHead(
113
+ config.mah, d_backbone, d_community=config.community.d_community
114
+ )
115
+ for _ in config.mah_layer_indices
116
+ ])
117
+
118
+ # ── RRM ──────────────────────────────────────────────────────
119
+ self.rrm = ReflexiveRecurrentModule(
120
+ config.rrm, d_divergence=config.mah.d_divergence, d_backbone=d_backbone
121
+ )
122
+
123
+ # Chain predictor: predict next divergence from current (self-supervised)
124
+ self.chain_predictor = nn.Linear(
125
+ config.mah.d_divergence, config.mah.d_divergence, bias=False
126
+ )
127
+
128
+ # ── BEN ──────────────────────────────────────────────────────
129
+ self.ben = BifurcationEstimationNetwork(config.ben, d_meta=config.rrm.d_meta)
130
+
131
+ # Build lookup sets for fast layer-index checking
132
+ self._mah_set = set(config.mah_layer_indices)
133
+ self._inject_set = set(config.rrm_inject_indices)
134
+ self._mah_index_map = {idx: i for i, idx in enumerate(config.mah_layer_indices)}
135
+
136
+ trainable = sum(p.numel() for p in self.parameters() if p.requires_grad)
137
+ frozen = sum(p.numel() for p in self.parameters() if not p.requires_grad)
138
+ logger.info(
139
+ "SRT Adapter: %s trainable, %s frozen (backbone)",
140
+ f"{trainable:,}",
141
+ f"{frozen:,}",
142
+ )
143
+
144
+ # Cast adapter modules to backbone dtype so bf16 hidden states flow
145
+ # through without dtype mismatch (backbone is frozen bf16, adapter
146
+ # modules default to float32)
147
+ for module in [
148
+ self.community_head, self.mah_heads, self.rrm,
149
+ self.chain_predictor, self.ben,
150
+ ]:
151
+ module.to(load_dtype)
152
+
153
+ def forward(
154
+ self,
155
+ input_ids: torch.Tensor,
156
+ attention_mask: torch.Tensor | None = None,
157
+ labels: torch.Tensor | None = None,
158
+ forced_community: torch.Tensor | None = None,
159
+ ) -> SRTAdapterOutput:
160
+ """Forward pass: backbone with semiotic taps and injections.
161
+
162
+ Args:
163
+ input_ids: (B, T) token ids.
164
+ attention_mask: (B, T) padding mask (1 = real, 0 = pad). Optional.
165
+ labels: (B, T) target token ids for CE loss. Optional.
166
+ forced_community: (B, d_community) override community vector. Optional.
167
+ When provided, uses this instead of CommunityDiscoveryHead output
168
+ for conditioning MAH heads. Discovery still runs for diagnostics.
169
+
170
+ Returns:
171
+ SRTAdapterOutput with logits, losses, and semiotic intermediates.
172
+ """
173
+ device = input_ids.device
174
+ B, T = input_ids.shape
175
+
176
+ # 1. Native backbone embeddings
177
+ h = self._embed_tokens(input_ids)
178
+
179
+ # 2. Prepare position embeddings
180
+ position_ids = torch.arange(T, device=device).unsqueeze(0).expand(B, -1)
181
+ position_embeddings = None
182
+ if self._rotary_emb is not None:
183
+ position_embeddings = self._rotary_emb(h, position_ids)
184
+
185
+ # 3. Causal mask for MAH attention
186
+ mah_causal_mask = _make_causal_mask(T, h.dtype, device)
187
+
188
+ # 4. Prepare 4D causal+padding mask for backbone layers
189
+ # Must combine causal mask (T, T) with padding mask (B, T) into (B, 1, T, T)
190
+ # so that SDPA doesn't drop is_causal=True behavior
191
+ causal_4d = _make_causal_mask(T, h.dtype, device) # (1, 1, T, T)
192
+ backbone_mask = None
193
+ if attention_mask is not None:
194
+ # (B, T) → (B, 1, 1, T) padding mask
195
+ pad_mask = (1.0 - attention_mask[:, None, None, :].to(h.dtype)) * torch.finfo(
196
+ h.dtype
197
+ ).min
198
+ backbone_mask = causal_4d + pad_mask # (B, 1, T, T)
199
+ else:
200
+ backbone_mask = causal_4d # (1, 1, T, T) — causal only
201
+
202
+ # 5. Layer-by-layer forward with semiotic taps
203
+ divergences: list[torch.Tensor] = []
204
+ injections: list[torch.Tensor] = []
205
+ meta_state: torch.Tensor | None = None
206
+ community_out: CommunityOutput | None = None
207
+ community_vec: torch.Tensor | None = None
208
+ mah_idx = 0
209
+
210
+ for layer_i, layer in enumerate(self._layers):
211
+ # Run backbone layer
212
+ layer_kwargs: dict = {"position_ids": position_ids}
213
+ if position_embeddings is not None:
214
+ layer_kwargs["position_embeddings"] = position_embeddings
215
+ if backbone_mask is not None:
216
+ layer_kwargs["attention_mask"] = backbone_mask
217
+
218
+ layer_out = layer(h, **layer_kwargs)
219
+ h = layer_out[0]
220
+
221
+ # Community discovery at early layer
222
+ if layer_i == self.config.community_layer_idx and community_out is None:
223
+ community_out = self.community_head(h.detach(), attention_mask)
224
+ # Use forced_community override if provided, else discovered
225
+ community_vec = (
226
+ forced_community if forced_community is not None
227
+ else community_out.vector
228
+ )
229
+
230
+ # MAH hook: extract divergence
231
+ if layer_i in self._mah_set:
232
+ mah_head = self.mah_heads[self._mah_index_map[layer_i]]
233
+ mah_out = mah_head(h, community_vec=community_vec, causal_mask=mah_causal_mask)
234
+ divergences.append(mah_out.divergence)
235
+
236
+ # Update RRM meta-state
237
+ meta_state = self.rrm.step(mah_out.divergence, meta_state)
238
+
239
+ # RRM injection (if this is also an injection layer)
240
+ if layer_i in self._inject_set:
241
+ inj = self.rrm.inject(meta_state, h)
242
+ h = h + inj
243
+ injections.append(inj)
244
+
245
+ # 6. Final norm + native LM head
246
+ h = self._final_norm(h)
247
+ logits = self._lm_head(h)
248
+
249
+ # 7. CE loss (shifted, standard next-token prediction)
250
+ ce_loss = None
251
+ if labels is not None:
252
+ shift_logits = logits[:, :-1].contiguous()
253
+ shift_labels = labels[:, 1:].contiguous()
254
+ ce_loss = F.cross_entropy(
255
+ shift_logits.view(-1, shift_logits.size(-1)),
256
+ shift_labels.view(-1),
257
+ ignore_index=-100,
258
+ )
259
+
260
+ # 8. BEN
261
+ ben_out = None
262
+ if meta_state is not None:
263
+ ben_out = self.ben(meta_state)
264
+
265
+ # Per-token chain residual: mean across consecutive divergence pairs of
266
+ # squared error (chain_predictor(div_i) - div_{i+1})^2 averaged over
267
+ # the divergence dim. Shape (B, T). Same quantity that chain_loss
268
+ # reduces to a scalar; surfaced here for inference/probing.
269
+ chain_res = None
270
+ if len(divergences) >= 2:
271
+ B_, T_, _ = divergences[0].shape
272
+ acc = torch.zeros(B_, T_, dtype=divergences[0].dtype,
273
+ device=divergences[0].device)
274
+ for i in range(len(divergences) - 1):
275
+ pred = self.chain_predictor(divergences[i])
276
+ acc = acc + (pred - divergences[i + 1]).pow(2).mean(dim=-1)
277
+ chain_res = acc / (len(divergences) - 1)
278
+
279
+ return SRTAdapterOutput(
280
+ logits=logits,
281
+ ce_loss=ce_loss,
282
+ divergences=divergences,
283
+ injections=injections,
284
+ ben_output=ben_out,
285
+ community_output=community_out,
286
+ meta_state=meta_state,
287
+ chain_residual_per_token=chain_res,
288
+ )
289
+
290
+ # Adapter module prefixes for save/load (everything else is backbone)
291
+ _ADAPTER_PREFIXES = (
292
+ "community_head.", "mah_heads.", "rrm.", "chain_predictor.", "ben.",
293
+ )
294
+
295
+ def save_adapter(self, path: str) -> None:
296
+ """Save only the trainable adapter weights (not the backbone)."""
297
+ state = {
298
+ k: v for k, v in self.state_dict().items()
299
+ if k.startswith(self._ADAPTER_PREFIXES)
300
+ }
301
+ torch.save(state, path)
302
+ logger.info("Saved adapter weights (%d tensors) to %s", len(state), path)
303
+
304
+ def load_adapter(self, path: str) -> None:
305
+ """Load adapter weights (backbone loaded separately from HF)."""
306
+ state = torch.load(path, map_location="cpu", weights_only=True)
307
+ missing, unexpected = self.load_state_dict(state, strict=False)
308
+ # Expected: all non-adapter keys will be "missing" (loaded from HF)
309
+ adapter_missing = [k for k in missing if k.startswith(self._ADAPTER_PREFIXES)]
310
+ if adapter_missing:
311
+ logger.warning("Missing adapter keys: %s", adapter_missing)
312
+ logger.info("Loaded adapter weights from %s", path)
313
+
314
+ def trainable_parameters(self):
315
+ """Yield only the trainable (adapter) parameters."""
316
+ return (p for p in self.parameters() if p.requires_grad)
srt/config.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration dataclasses for SRT Adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ @dataclass
9
+ class MAHConfig:
10
+ """Metapragmatic Attention Head configuration."""
11
+
12
+ d_sub: int = 512 # semiotic subspace dimension
13
+ d_divergence: int = 256 # divergence vector dimension
14
+ num_heads: int = 4 # attention heads
15
+ dropout: float = 0.1
16
+
17
+
18
+ @dataclass
19
+ class RRMConfig:
20
+ """Reflexive Recurrent Module configuration."""
21
+
22
+ d_meta: int = 512 # GRU meta-state dimension
23
+ inject_scale: float = 1.0 # FiLM correction scale (v3 used 0.1 with linear inject; v4 uses 1.0 with FiLM)
24
+
25
+
26
+ @dataclass
27
+ class BENConfig:
28
+ """Bifurcation Estimation Network configuration."""
29
+
30
+ d_hidden: int = 256 # MLP hidden dimension
31
+
32
+
33
+ @dataclass
34
+ class CommunityConfig:
35
+ """Unsupervised community discovery configuration."""
36
+
37
+ num_prototypes: int = 32 # number of soft community clusters
38
+ d_community: int = 64 # community embedding dimension
39
+ temperature: float = 1.0 # softmax temperature for assignment
40
+ # v8a: when False, skip the discrete prototype basis entirely; the
41
+ # encoder output IS the community vector. Motivated by the v7 PCA
42
+ # finding that prototype tensors barely move from random init across
43
+ # v5/v6/v7 (mean abs delta ~3e-5) — the encoder was already doing all
44
+ # the discriminative work and the prototype-mixing readout was
45
+ # discarding information at the soft-argmax. With use_prototypes=False
46
+ # the community channel becomes a continuous 64-D coordinate rather
47
+ # than a soft assignment over K anchors.
48
+ #
49
+ # Env override: set SRT_USE_PROTOTYPES=0 (or "false") to flip this off
50
+ # globally. Lets probe / eval scripts run against v8a checkpoints
51
+ # without per-script flag plumbing.
52
+ use_prototypes: bool = True
53
+
54
+ def __post_init__(self) -> None:
55
+ import os
56
+ v = os.environ.get("SRT_USE_PROTOTYPES")
57
+ if v is not None and v.lower() in ("0", "false", "no", "off"):
58
+ self.use_prototypes = False
59
+
60
+
61
+ @dataclass
62
+ class LossConfig:
63
+ """Loss weights."""
64
+
65
+ ce_weight: float = 1.0
66
+ chain_weight: float = 0.5 # divergence chain prediction
67
+ bif_weight: float = 1.0 # bifurcation (r_hat vs r_true)
68
+ regime_weight: float = 5.0 # regime classification
69
+ div_alive_weight: float = 0.1 # prevent divergence collapse
70
+ # v4: dropped to 0 because v3 ablation showed the inject-norm regularizer
71
+ # was driving the optimizer to satisfy ||inj||=1 with arbitrary directions
72
+ # rather than directions useful for downstream loss. FiLM init handles
73
+ # gradient flow without needing a norm prior.
74
+ inject_reg_weight: float = 0.0
75
+ inject_target_norm: float = 1.0
76
+ community_entropy_weight: float = 0.01 # diverse community usage
77
+ # v4/v5: SupCon loss on community ENCODER output keyed by source-id
78
+ # hash. Forces prototypes apart by giving same-source pairs positive
79
+ # gradient and different-source pairs negative gradient through the
80
+ # encoder. v5 raised the weight 0.5 -> 2.0 because v4's signal at 0.5
81
+ # was overwhelmed and the loss flatlined at log(B-1)=2.71.
82
+ community_supcon_weight: float = 2.0
83
+ community_supcon_temperature: float = 0.1
84
+ # v6 additions:
85
+ # - divergence SupCon on mean-pooled last-MAH divergence (analog of v5
86
+ # community SupCon, applied to the metapragmatic channel)
87
+ # - ListNet ranking loss on r̂ within each sequence (sharpens ordering;
88
+ # pointwise smooth-L1 alone tolerates large rank errors at the tails)
89
+ # - chain-residual auxiliary floor: keeps inference signal alive after
90
+ # chain_loss has driven the per-position residual near zero
91
+ divergence_supcon_weight: float = 1.0
92
+ divergence_supcon_temperature: float = 0.1
93
+ listnet_weight: float = 0.5
94
+ listnet_temperature: float = 1.0
95
+ chain_residual_aux_weight: float = 0.05
96
+ chain_residual_aux_target: float = 0.5
97
+ # v9: supervised contrastive loss keyed by archetype_id, applied to the
98
+ # same `community_output.encoded` representation as community_supcon. The
99
+ # 33 archetypes (Lancaster, paired with the Lexicon of Synthetic
100
+ # Interiority) are an external taxonomy that has only been a held-out
101
+ # probe through v8b. v9 promotes them to a training signal alongside
102
+ # Reddit subreddit ids. Rows whose archetype_id == -1 (Reddit corpus) are
103
+ # masked out of this loss; rows from the archetype-generations corpus
104
+ # carry archetype_id ∈ [1, 33] and contribute positive pairs.
105
+ archetype_supcon_weight: float = 0.0
106
+ archetype_supcon_temperature: float = 0.1
107
+
108
+
109
+ @dataclass
110
+ class TrainingConfig:
111
+ """Training hyperparameters."""
112
+
113
+ lr: float = 3e-4
114
+ weight_decay: float = 0.01
115
+ epochs: int = 3
116
+ batch_size: int = 16
117
+ max_seq_len: int = 512
118
+ val_every: int = 1000
119
+ log_every: int = 100
120
+ patience: int = 5
121
+ warmup_steps: int = 500
122
+ grad_clip: float = 1.0
123
+
124
+
125
+ @dataclass
126
+ class SRTConfig:
127
+ """Top-level SRT Adapter configuration."""
128
+
129
+ backbone_id: str = "Qwen/Qwen2.5-7B"
130
+ backbone_dtype: str = "bfloat16"
131
+
132
+ # Layer hook indices — empty means auto-compute from backbone depth
133
+ mah_layer_indices: list[int] = field(default_factory=list)
134
+ rrm_inject_indices: list[int] = field(default_factory=list)
135
+ community_layer_idx: int = -1 # -1 = auto
136
+
137
+ num_mah_layers: int = 3
138
+
139
+ mah: MAHConfig = field(default_factory=MAHConfig)
140
+ rrm: RRMConfig = field(default_factory=RRMConfig)
141
+ ben: BENConfig = field(default_factory=BENConfig)
142
+ community: CommunityConfig = field(default_factory=CommunityConfig)
143
+ loss: LossConfig = field(default_factory=LossConfig)
144
+ training: TrainingConfig = field(default_factory=TrainingConfig)
145
+
146
+ def resolve_layer_indices(self, num_layers: int) -> None:
147
+ """Auto-compute layer indices from backbone depth if not set."""
148
+ if not self.mah_layer_indices:
149
+ step = num_layers // (self.num_mah_layers + 1)
150
+ self.mah_layer_indices = [step * (i + 1) for i in range(self.num_mah_layers)]
151
+ if not self.rrm_inject_indices:
152
+ # Inject at all MAH layers except the first (let meta-state build up)
153
+ self.rrm_inject_indices = self.mah_layer_indices[1:]
154
+ if self.community_layer_idx < 0:
155
+ self.community_layer_idx = max(1, num_layers // 7)
srt/data/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """SRT data utilities."""
srt/data/dataset.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dataset for SRT Adapter training.
2
+
3
+ Loads JSONL files produced by the Reddit corpus pipeline and aligns word-level
4
+ labels (r_true) to BPE token boundaries using the backbone's own tokenizer.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import torch
15
+ import torch.nn.functional as F
16
+ from torch.utils.data import Dataset
17
+ from transformers import PreTrainedTokenizerBase
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class SRTAdapterDataset(Dataset):
23
+ """Load JSONL samples and tokenize with a BPE tokenizer.
24
+
25
+ Expected JSONL format (all fields except ``text`` are optional):
26
+ {"text": "...", "r_true": [0.1, -0.2, ...], "community": "some_label"}
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ path: str | Path,
32
+ tokenizer: PreTrainedTokenizerBase,
33
+ max_seq_len: int = 512,
34
+ max_samples: int | None = None,
35
+ ) -> None:
36
+ self.tokenizer = tokenizer
37
+ self.max_seq_len = max_seq_len
38
+ self.samples: list[dict[str, Any]] = []
39
+
40
+ path = Path(path)
41
+ logger.info("Loading dataset from %s", path)
42
+ with open(path) as f:
43
+ for i, line in enumerate(f):
44
+ if max_samples is not None and i >= max_samples:
45
+ break
46
+ row = json.loads(line)
47
+ if "text" in row and row["text"].strip():
48
+ self.samples.append(row)
49
+
50
+ logger.info("Loaded %d samples from %s", len(self.samples), path)
51
+
52
+ def __len__(self) -> int:
53
+ return len(self.samples)
54
+
55
+ def __getitem__(self, idx: int) -> dict[str, torch.Tensor]:
56
+ row = self.samples[idx]
57
+ text = row["text"]
58
+
59
+ # Tokenize
60
+ enc = self.tokenizer(
61
+ text,
62
+ truncation=True,
63
+ max_length=self.max_seq_len,
64
+ return_tensors="pt",
65
+ return_offsets_mapping=True,
66
+ )
67
+ input_ids = enc["input_ids"].squeeze(0) # (T,)
68
+ attention_mask = enc["attention_mask"].squeeze(0) # (T,)
69
+ offsets = enc["offset_mapping"].squeeze(0) # (T, 2)
70
+ T = input_ids.size(0)
71
+
72
+ # Align word-level r_true → token-level
73
+ r_true = torch.zeros(T)
74
+ r_mask = torch.zeros(T, dtype=torch.bool)
75
+
76
+ if "r_true" in row and row["r_true"]:
77
+ word_r = row["r_true"]
78
+ words = text.split()
79
+ token_r, token_mask = _align_word_labels_to_bpe(
80
+ words, word_r, offsets, text
81
+ )
82
+ r_true[:len(token_r)] = token_r[:T]
83
+ r_mask[:len(token_mask)] = token_mask[:T]
84
+
85
+ # Community id: prefer the explicit integer in the data; fall back
86
+ # to a stable hash of a string-typed source field if the integer is
87
+ # missing. v5 fix: training data has `community_id: int` populated
88
+ # for every row; v3-v5 was reading a non-existent `community` field
89
+ # and hashing the empty string, giving every sample the same id and
90
+ # collapsing the SupCon signal.
91
+ if "community_id" in row and row["community_id"] is not None:
92
+ community_id = int(row["community_id"])
93
+ else:
94
+ for fld in ("community_label", "source", "community"):
95
+ community_str = row.get(fld) or ""
96
+ if community_str:
97
+ break
98
+ community_id = _stable_hash(community_str)
99
+
100
+ # v9: archetype_id is present on rows from the archetype-generations
101
+ # corpus (data/archetype_train.jsonl); rows from the Reddit corpus
102
+ # carry -1 here so the v9 archetype-supcon loss masks them out.
103
+ if "archetype_id" in row and row["archetype_id"] is not None:
104
+ archetype_id = int(row["archetype_id"])
105
+ else:
106
+ archetype_id = -1
107
+
108
+ return {
109
+ "input_ids": input_ids,
110
+ "attention_mask": attention_mask,
111
+ "labels": input_ids.clone(), # next-token prediction
112
+ "r_true": r_true,
113
+ "r_mask": r_mask,
114
+ "community_id": torch.tensor(community_id, dtype=torch.long),
115
+ "archetype_id": torch.tensor(archetype_id, dtype=torch.long),
116
+ }
117
+
118
+
119
+ def _stable_hash(s: str, modulus: int = 100003) -> int:
120
+ """Stable non-cryptographic hash → int in [0, modulus). FNV-1a 32-bit.
121
+
122
+ We use FNV-1a rather than the built-in `hash()` because the latter is
123
+ randomized per-process under PYTHONHASHSEED=random, which would give
124
+ different ids each run.
125
+ """
126
+ h = 2166136261
127
+ for ch in s.encode("utf-8"):
128
+ h ^= ch
129
+ h = (h * 16777619) & 0xFFFFFFFF
130
+ return h % modulus
131
+
132
+
133
+ def _align_word_labels_to_bpe(
134
+ words: list[str],
135
+ word_labels: list[float],
136
+ offsets: torch.Tensor,
137
+ text: str,
138
+ ) -> tuple[torch.Tensor, torch.Tensor]:
139
+ """Align word-level labels to BPE token positions.
140
+
141
+ For each BPE token, find which word it belongs to (by character offset)
142
+ and assign that word's label.
143
+
144
+ Args:
145
+ words: list of whitespace-split words.
146
+ word_labels: per-word label values.
147
+ offsets: (T, 2) character offsets from tokenizer.
148
+ text: original text string.
149
+
150
+ Returns:
151
+ (token_labels, token_mask) both of shape (T,).
152
+ """
153
+ T = offsets.size(0)
154
+ token_labels = torch.zeros(T)
155
+ token_mask = torch.zeros(T, dtype=torch.bool)
156
+
157
+ if len(words) != len(word_labels):
158
+ # Mismatched lengths — skip alignment
159
+ return token_labels, token_mask
160
+
161
+ # Build word → character span mapping
162
+ word_spans: list[tuple[int, int]] = []
163
+ pos = 0
164
+ for word in words:
165
+ start = text.find(word, pos)
166
+ if start == -1:
167
+ break
168
+ end = start + len(word)
169
+ word_spans.append((start, end))
170
+ pos = end
171
+
172
+ if len(word_spans) != len(words):
173
+ return token_labels, token_mask
174
+
175
+ # For each token, find its word
176
+ for tok_idx in range(T):
177
+ tok_start, tok_end = offsets[tok_idx].tolist()
178
+ if tok_start == 0 and tok_end == 0:
179
+ continue # special token
180
+
181
+ tok_mid = (tok_start + tok_end) / 2
182
+ for word_idx, (ws, we) in enumerate(word_spans):
183
+ if ws <= tok_mid < we:
184
+ token_labels[tok_idx] = word_labels[word_idx]
185
+ token_mask[tok_idx] = True
186
+ break
187
+
188
+ return token_labels, token_mask
189
+
190
+
191
+ def make_collate_fn(pad_token_id: int = 0):
192
+ """Create a collate function with the correct pad token id."""
193
+
194
+ def collate_fn(batch: list[dict[str, torch.Tensor]]) -> dict[str, torch.Tensor]:
195
+ """Collate with dynamic padding to the longest sequence in the batch."""
196
+ max_len = max(item["input_ids"].size(0) for item in batch)
197
+
198
+ padded: dict[str, list[torch.Tensor]] = {key: [] for key in batch[0]}
199
+ for item in batch:
200
+ T = item["input_ids"].size(0)
201
+ pad_len = max_len - T
202
+ padded["input_ids"].append(F.pad(item["input_ids"], (0, pad_len), value=pad_token_id))
203
+ padded["attention_mask"].append(
204
+ F.pad(item["attention_mask"], (0, pad_len), value=0)
205
+ )
206
+ padded["labels"].append(F.pad(item["labels"], (0, pad_len), value=-100))
207
+ padded["r_true"].append(F.pad(item["r_true"], (0, pad_len), value=0.0))
208
+ padded["r_mask"].append(F.pad(item["r_mask"], (0, pad_len), value=False))
209
+ # community_id is a scalar — stack without padding
210
+ if "community_id" in item:
211
+ padded.setdefault("community_id", []).append(item["community_id"])
212
+ if "archetype_id" in item:
213
+ padded.setdefault("archetype_id", []).append(item["archetype_id"])
214
+
215
+ return {k: torch.stack(v) for k, v in padded.items()}
216
+
217
+ return collate_fn
srt/modules/__init__.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SRT Adapter semiotic modules."""
2
+
3
+ from srt.modules.mah import MetapragmaticAttentionHead, MAHOutput
4
+ from srt.modules.rrm import ReflexiveRecurrentModule
5
+ from srt.modules.ben import BifurcationEstimationNetwork, BENOutput
6
+ from srt.modules.community import CommunityDiscoveryHead, CommunityOutput
7
+
8
+ __all__ = [
9
+ "MetapragmaticAttentionHead",
10
+ "MAHOutput",
11
+ "ReflexiveRecurrentModule",
12
+ "BifurcationEstimationNetwork",
13
+ "BENOutput",
14
+ "CommunityDiscoveryHead",
15
+ "CommunityOutput",
16
+ ]
srt/modules/ben.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bifurcation Estimation Network (BEN).
2
+
3
+ Estimates the reflexivity coefficient r̂ ∈ [-1, 1] at each position from
4
+ the RRM's accumulated meta-state. Also classifies semiotic regime:
5
+ - Subcritical (r < 0): sign has stable, conventional meaning
6
+ - Supercritical (r > 0): sign is contested, meaning is actively forking
7
+
8
+ r̂ is the core output of SRT — it tells you WHERE and HOW MUCH meaning
9
+ is under contestation in a given text.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+
16
+ import torch
17
+ import torch.nn as nn
18
+
19
+ from srt.config import BENConfig
20
+
21
+
22
+ @dataclass
23
+ class BENOutput:
24
+ """Output from BEN."""
25
+
26
+ r_hat: torch.Tensor # (B, T) reflexivity coefficient (unbounded; supervised on log-compressed r_true)
27
+ regime_logits: torch.Tensor # (B, T, 2) subcritical/supercritical
28
+
29
+
30
+ class BifurcationEstimationNetwork(nn.Module):
31
+ """Estimates bifurcation from RRM meta-state."""
32
+
33
+ def __init__(self, cfg: BENConfig, d_meta: int) -> None:
34
+ super().__init__()
35
+
36
+ # r̂ prediction: meta-state → unbounded scalar.
37
+ # v3 used nn.Tanh() here, which capped output at ±1. The training target
38
+ # is sign(r) * log1p(|r|) and r_true reaches ~12.77 (compressed ~2.55), so
39
+ # the tanh ceiling truncated ~25% of supercritical tokens and capped the
40
+ # achievable Pearson. The smooth_l1 loss on a log-compressed target is
41
+ # numerically well-behaved without an output activation; we keep the head
42
+ # unbounded and init the final linear with small weights so early outputs
43
+ # start near zero and the supervised gradient does the shaping.
44
+ self.r_head = nn.Sequential(
45
+ nn.Linear(d_meta, cfg.d_hidden),
46
+ nn.SiLU(),
47
+ nn.Linear(cfg.d_hidden, 1),
48
+ )
49
+ r_out: nn.Linear = self.r_head[-1] # type: ignore[assignment]
50
+ nn.init.normal_(r_out.weight, std=0.02)
51
+ nn.init.zeros_(r_out.bias)
52
+
53
+ # Regime classification: subcritical (0) vs supercritical (1)
54
+ self.regime_head = nn.Sequential(
55
+ nn.Linear(d_meta, cfg.d_hidden),
56
+ nn.SiLU(),
57
+ nn.Linear(cfg.d_hidden, 2),
58
+ )
59
+
60
+ def forward(self, meta_state: torch.Tensor) -> BENOutput:
61
+ """Estimate bifurcation from accumulated meta-state.
62
+
63
+ Args:
64
+ meta_state: (B, T, d_meta) from RRM.
65
+
66
+ Returns:
67
+ BENOutput with r_hat and regime_logits.
68
+ """
69
+ r_hat = self.r_head(meta_state).squeeze(-1) # (B, T)
70
+ regime_logits = self.regime_head(meta_state) # (B, T, 2)
71
+ return BENOutput(r_hat=r_hat, regime_logits=regime_logits)
srt/modules/community.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unsupervised Community Discovery Head.
2
+
3
+ Discovers discourse communities from backbone hidden states without
4
+ predefined labels. A discourse community (in Peirce's framework) is a
5
+ group of language users who share interpretive norms — they assign similar
6
+ interpretants to the same representamens.
7
+
8
+ The community head runs at an early backbone layer (before MAH hooks) and
9
+ produces a soft assignment over K learned prototypes. The resulting community
10
+ vector conditions how MAH computes divergence, so the same sign can produce
11
+ different divergence patterns in different community contexts.
12
+
13
+ Training signal: the community prototypes are pulled apart by the semiotic
14
+ losses — if assigning text to different communities helps the model predict
15
+ divergence better, it will learn to separate them.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass
21
+
22
+ import torch
23
+ import torch.nn as nn
24
+ import torch.nn.functional as F
25
+
26
+ from srt.config import CommunityConfig
27
+
28
+
29
+ @dataclass
30
+ class CommunityOutput:
31
+ """Output from community discovery.
32
+
33
+ When the head runs in continuous-trajectory mode (cfg.use_prototypes=False,
34
+ v8a), `logits` and `weights` are None and `vector == encoded`.
35
+ """
36
+
37
+ logits: torch.Tensor | None # (B, K) raw assignment scores, or None
38
+ weights: torch.Tensor | None # (B, K) soft assignment probabilities, or None
39
+ vector: torch.Tensor # (B, d_community) community embedding (mixture or encoded)
40
+ encoded: torch.Tensor # (B, d_community) pre-prototype-mixing encoder output
41
+
42
+
43
+ class CommunityDiscoveryHead(nn.Module):
44
+ """Soft clustering of hidden states into discourse communities.
45
+
46
+ With cfg.use_prototypes=True (default): pooled hidden state → encoder →
47
+ cosine similarity to K learned prototypes → soft assignment weights →
48
+ weighted mixture of prototypes as the community vector. This is the
49
+ v3–v7 architecture.
50
+
51
+ With cfg.use_prototypes=False (v8a): pooled hidden state → encoder →
52
+ the encoder output IS the community vector. No discrete basis. Motivated
53
+ by the v7 PCA finding that prototype tensors barely move from random
54
+ init; the encoder was already doing the discriminative work and the
55
+ soft-argmax over K anchors was throwing information away.
56
+ """
57
+
58
+ def __init__(self, cfg: CommunityConfig, d_backbone: int) -> None:
59
+ super().__init__()
60
+ self.temperature = cfg.temperature
61
+ self.use_prototypes = cfg.use_prototypes
62
+
63
+ # Encode pooled hidden states → community space
64
+ self.encoder = nn.Sequential(
65
+ nn.Linear(d_backbone, cfg.d_community),
66
+ nn.SiLU(),
67
+ )
68
+
69
+ # Learnable community prototypes (only when enabled)
70
+ if cfg.use_prototypes:
71
+ self.prototypes = nn.Embedding(cfg.num_prototypes, cfg.d_community)
72
+ else:
73
+ self.prototypes = None # type: ignore[assignment]
74
+
75
+ def forward(
76
+ self,
77
+ hidden_states: torch.Tensor,
78
+ attention_mask: torch.Tensor | None = None,
79
+ ) -> CommunityOutput:
80
+ """Discover community from hidden states.
81
+
82
+ Args:
83
+ hidden_states: (B, T, d_backbone) from an early backbone layer.
84
+ attention_mask: (B, T) padding mask (1 = real, 0 = pad). Optional.
85
+
86
+ Returns:
87
+ CommunityOutput. In prototype mode, logits/weights are populated
88
+ and vector is the prototype-weighted mixture. In trajectory mode
89
+ (use_prototypes=False), logits and weights are None and vector
90
+ equals encoded.
91
+ """
92
+ # Masked mean pool across positions → document-level representation
93
+ if attention_mask is not None:
94
+ mask = attention_mask.unsqueeze(-1).to(hidden_states.dtype) # (B, T, 1)
95
+ pooled = (hidden_states * mask).sum(dim=1) / mask.sum(dim=1).clamp(min=1)
96
+ else:
97
+ pooled = hidden_states.mean(dim=1) # (B, d_backbone)
98
+ encoded = self.encoder(pooled) # (B, d_community)
99
+
100
+ if not self.use_prototypes:
101
+ # v8a: continuous-trajectory mode — no discrete basis.
102
+ return CommunityOutput(
103
+ logits=None, weights=None, vector=encoded, encoded=encoded,
104
+ )
105
+
106
+ # Cosine similarity to prototypes
107
+ encoded_norm = F.normalize(encoded, dim=-1)
108
+ proto_norm = F.normalize(self.prototypes.weight, dim=-1)
109
+ logits = (encoded_norm @ proto_norm.T) / self.temperature # (B, K)
110
+
111
+ weights = F.softmax(logits, dim=-1) # (B, K)
112
+ vector = weights @ self.prototypes.weight # (B, d_community)
113
+
114
+ return CommunityOutput(
115
+ logits=logits, weights=weights, vector=vector, encoded=encoded,
116
+ )
srt/modules/mah.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Metapragmatic Attention Head (MAH).
2
+
3
+ Detects where meaning diverges across positions by computing the gap between
4
+ direct (local) interpretation and contextual (global) interpretation of each
5
+ token's hidden state. This is Peirce's "unlimited semiosis" made computational:
6
+ each sign (representamen) receives an interpretation (interpretant) that depends
7
+ on the surrounding discourse context. MAH quantifies where that context
8
+ *changes* the interpretation — i.e., where meaning forks.
9
+
10
+ The divergence vector d_t at position t captures:
11
+ d_t = f(interp_t) - g(attend(interp_{0..t}))
12
+ where f is direct projection, g is the contextual output after causal attention.
13
+ High ||d_t|| → the sign at position t means something different in context
14
+ than it would in isolation.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import math
20
+ from dataclasses import dataclass
21
+
22
+ import torch
23
+ import torch.nn as nn
24
+ import torch.nn.functional as F
25
+
26
+ from srt.config import MAHConfig
27
+
28
+
29
+ @dataclass
30
+ class MAHOutput:
31
+ """Output from a single MAH layer."""
32
+
33
+ divergence: torch.Tensor # (B, T, d_divergence)
34
+ attention_weights: torch.Tensor | None = None # (B, H, T, T)
35
+
36
+
37
+ class MetapragmaticAttentionHead(nn.Module):
38
+ """Single MAH layer that reads hidden states and produces divergence vectors."""
39
+
40
+ def __init__(self, cfg: MAHConfig, d_backbone: int, d_community: int = 0) -> None:
41
+ super().__init__()
42
+ d_sub = cfg.d_sub
43
+
44
+ # Project backbone hidden states → interpretant subspace
45
+ self.interp_proj = nn.Linear(d_backbone, d_sub, bias=False)
46
+
47
+ # Optional community conditioning
48
+ self.comm_proj: nn.Module | None = None
49
+ if d_community > 0:
50
+ self.comm_proj = nn.Linear(d_community, d_sub, bias=False)
51
+
52
+ # Multi-head self-attention in interpretant subspace
53
+ self.num_heads = cfg.num_heads
54
+ self.head_dim = d_sub // cfg.num_heads
55
+ assert d_sub % cfg.num_heads == 0
56
+
57
+ self.q_proj = nn.Linear(d_sub, d_sub, bias=False)
58
+ self.k_proj = nn.Linear(d_sub, d_sub, bias=False)
59
+ self.v_proj = nn.Linear(d_sub, d_sub, bias=False)
60
+ self.out_proj = nn.Linear(d_sub, d_sub, bias=False)
61
+ self.attn_dropout = nn.Dropout(cfg.dropout)
62
+
63
+ # Divergence output projection
64
+ self.div_proj = nn.Linear(d_sub, cfg.d_divergence, bias=False)
65
+
66
+ def forward(
67
+ self,
68
+ hidden_states: torch.Tensor,
69
+ community_vec: torch.Tensor | None = None,
70
+ causal_mask: torch.Tensor | None = None,
71
+ ) -> MAHOutput:
72
+ """Compute divergence from backbone hidden states.
73
+
74
+ Args:
75
+ hidden_states: (B, T, d_backbone) from a transformer layer.
76
+ community_vec: (B, d_community) soft community vector.
77
+ causal_mask: (1, 1, T, T) additive causal mask.
78
+
79
+ Returns:
80
+ MAHOutput with divergence vectors and optional attention weights.
81
+ """
82
+ B, T, _ = hidden_states.shape
83
+
84
+ # Project to interpretant subspace
85
+ interp = self.interp_proj(hidden_states) # (B, T, d_sub)
86
+
87
+ # Community conditioning: shift interpretant space
88
+ if community_vec is not None and self.comm_proj is not None:
89
+ comm_bias = self.comm_proj(community_vec) # (B, d_sub)
90
+ interp = interp + comm_bias.unsqueeze(1)
91
+
92
+ # Multi-head causal self-attention
93
+ q = self.q_proj(interp).view(B, T, self.num_heads, self.head_dim).transpose(1, 2)
94
+ k = self.k_proj(interp).view(B, T, self.num_heads, self.head_dim).transpose(1, 2)
95
+ v = self.v_proj(interp).view(B, T, self.num_heads, self.head_dim).transpose(1, 2)
96
+
97
+ attn = (q @ k.transpose(-2, -1)) / math.sqrt(self.head_dim)
98
+ if causal_mask is not None:
99
+ attn = attn + causal_mask
100
+ attn_weights = F.softmax(attn, dim=-1)
101
+ attn_weights = self.attn_dropout(attn_weights)
102
+
103
+ contextual = (attn_weights @ v).transpose(1, 2).reshape(B, T, -1)
104
+ contextual = self.out_proj(contextual) # (B, T, d_sub)
105
+
106
+ # Divergence = gap between direct and contextual interpretation
107
+ divergence = self.div_proj(interp - contextual) # (B, T, d_divergence)
108
+
109
+ return MAHOutput(divergence=divergence, attention_weights=attn_weights.detach())
srt/modules/rrm.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reflexive Recurrent Module (RRM).
2
+
3
+ Tracks per-position semiotic meta-state via a GRU that processes divergence
4
+ observations from MAH layers. At injection points, produces a FiLM-style
5
+ modulation (gamma, beta) that multiplicatively + additively biases the
6
+ backbone's hidden states:
7
+
8
+ h' = h * (1 + gamma(meta_state)) + beta(meta_state)
9
+
10
+ The meta-state h_meta_t represents the model's accumulated awareness of
11
+ semiotic divergence at position t. Each MAH observation updates it:
12
+ h_meta_t^{l+1} = GRU(divergence_t^l, h_meta_t^l)
13
+
14
+ v3 used a single low-rank linear inject (gate * proj * scale) with
15
+ zero-initialized projection. Ablation showed the inject-back arm contributed
16
+ exactly nothing (every benchmark metric was identical to four decimal places
17
+ with injection forced to zero). The diagnosis was that the zero init plus
18
+ the inject-norm regularizer (which rewarded ||inj|| \u2248 1 regardless of
19
+ direction) drove the optimizer to satisfy the norm penalty with arbitrary
20
+ directions that were then orthogonal to the gradient signal from the frozen
21
+ backbone's CE.
22
+
23
+ v4 fixes both: FiLM modulation has a non-zero gradient pathway from the first
24
+ step (beta is initialized to zero so the forward is identity at init, but
25
+ gamma has small Gaussian init so dL/d(gamma_proj) flows immediately when the
26
+ downstream MAH layer's divergence is supervised by the bif/regime losses).
27
+ The inject-norm regularizer is dropped at the loss layer (LossConfig
28
+ inject_reg_weight = 0.0 by default in v4).
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import torch
34
+ import torch.nn as nn
35
+
36
+ from srt.config import RRMConfig
37
+
38
+
39
+ class ReflexiveRecurrentModule(nn.Module):
40
+ """GRU-based reflexive meta-state tracker with FiLM-style injection."""
41
+
42
+ def __init__(self, cfg: RRMConfig, d_divergence: int, d_backbone: int) -> None:
43
+ super().__init__()
44
+ self.d_meta = cfg.d_meta
45
+ self.inject_scale = cfg.inject_scale
46
+ self.d_backbone = d_backbone
47
+
48
+ # Per-position GRU: processes divergence \u2192 meta-state
49
+ self.gru = nn.GRUCell(d_divergence, cfg.d_meta)
50
+
51
+ # FiLM projections: meta-state \u2192 (gamma, beta) in backbone-dim.
52
+ # gamma is multiplicative on (1 + gamma); beta is additive.
53
+ # gamma init: small Gaussian (std=0.02) so identity-at-init holds in
54
+ # expectation but gradient flows from the first step.
55
+ # beta init: zeros so identity-at-init is exact, then learns offsets.
56
+ self.gamma_proj = nn.Linear(cfg.d_meta, d_backbone, bias=True)
57
+ self.beta_proj = nn.Linear(cfg.d_meta, d_backbone, bias=True)
58
+ nn.init.normal_(self.gamma_proj.weight, std=0.02)
59
+ nn.init.zeros_(self.gamma_proj.bias)
60
+ nn.init.zeros_(self.beta_proj.weight)
61
+ nn.init.zeros_(self.beta_proj.bias)
62
+
63
+ def step(
64
+ self, divergence: torch.Tensor, meta_state: torch.Tensor | None
65
+ ) -> torch.Tensor:
66
+ """Update per-position meta-state with new divergence observation.
67
+
68
+ Args:
69
+ divergence: (B, T, d_divergence) from MAH.
70
+ meta_state: (B, T, d_meta) or None for initial state.
71
+
72
+ Returns:
73
+ Updated meta-state (B, T, d_meta).
74
+ """
75
+ B, T, d_div = divergence.shape
76
+ div_flat = divergence.reshape(B * T, d_div)
77
+
78
+ if meta_state is None:
79
+ meta_flat = torch.zeros(
80
+ B * T, self.d_meta, device=divergence.device, dtype=divergence.dtype
81
+ )
82
+ else:
83
+ meta_flat = meta_state.reshape(B * T, self.d_meta)
84
+
85
+ meta_flat = self.gru(div_flat, meta_flat)
86
+ return meta_flat.reshape(B, T, self.d_meta)
87
+
88
+ def inject(
89
+ self, meta_state: torch.Tensor, hidden_states: torch.Tensor
90
+ ) -> torch.Tensor:
91
+ """Produce FiLM modulation correction for backbone hidden states.
92
+
93
+ Returns the *correction* (h' - h) = h * gamma + beta, NOT h'. The
94
+ caller adds this to h to get h'. This keeps the rest of the adapter
95
+ and the diagnostic logging (injection norm tracking) unchanged: the
96
+ \"injection\" tensor is still the additive correction applied to h.
97
+
98
+ Args:
99
+ meta_state: (B, T, d_meta) current reflexive awareness.
100
+ hidden_states: (B, T, d_backbone) current hidden states.
101
+
102
+ Returns:
103
+ Correction vector (B, T, d_backbone) to add to hidden_states.
104
+ """
105
+ gamma = self.gamma_proj(meta_state) # (B, T, d_backbone)
106
+ beta = self.beta_proj(meta_state) # (B, T, d_backbone)
107
+ # FiLM: h' = h * (1 + gamma) + beta \u2192 correction = h * gamma + beta
108
+ correction = hidden_states * gamma + beta
109
+ return correction * self.inject_scale
srt/modules/thead.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """T-Head: Takens delay-embedding readout (v10 prototype).
2
+
3
+ Inspired by Haylett (2025, 2026)'s Geofinitism program and MARINA architecture,
4
+ which apply Takens (1981)'s delay-embedding theorem to the token stream.
5
+
6
+ Where MARINA *replaces* attention with delay-coordinate phase-space
7
+ reconstruction, the T-Head is the conservative cross-program experiment: keep
8
+ the frozen attention backbone of SRT-Adapter v9 and add a delay-embedding
9
+ readout over hidden states as an *additional* channel, orthogonal to the MAH's
10
+ pairwise interpretant divergence.
11
+
12
+ The motivation is that the SRT readouts (BEN r_hat, MAH divergence, Community
13
+ Head prototype) all operate on per-token state. The trajectory through hidden
14
+ state across positions has additional structure (curvature, recurrence,
15
+ local-Lyapunov sensitivity) that none of the v9 readouts surface. A
16
+ delay-embedding readout extracts two such signals per position:
17
+
18
+ - **Local Lyapunov estimate.** Approximates the local divergence rate of
19
+ nearby trajectories by comparing the displacement of a delay-coordinate
20
+ point at time t to the displacement of its k nearest neighbors in the
21
+ embedded space at time t+1. Positive values indicate locally chaotic
22
+ (sensitive) regions; near-zero values indicate locally stable regions.
23
+
24
+ - **Recurrence density.** Counts how many other positions in the sequence
25
+ fall within an epsilon-ball of the current position's delay coordinate.
26
+ High recurrence density indicates positions on a low-dimensional
27
+ attractor (a topic the model returns to); low density indicates novel
28
+ excursions.
29
+
30
+ Both signals are computed from the same delay-coordinate stack and add a
31
+ single learned linear readout each.
32
+
33
+ This module is NOT wired into v9 SRTAdapter. It is a standalone prototype
34
+ intended for v10 experiments where it will be added as a parallel readout
35
+ alongside MAH/BEN/Community.
36
+
37
+ References:
38
+ Takens, F. (1981). Detecting strange attractors in turbulence.
39
+ Haylett, K. R. (2025). Finite Tractus.
40
+ Haylett, K. R. (2026). Geofinitism / MARINA.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ from dataclasses import dataclass
46
+
47
+ import torch
48
+ import torch.nn as nn
49
+
50
+
51
+ @dataclass
52
+ class THeadConfig:
53
+ """T-Head delay-embedding readout configuration."""
54
+
55
+ embedding_dim: int = 4 # m in Takens: number of delay coordinates
56
+ delay: int = 2 # tau: lag between successive coordinates (in tokens)
57
+ knn_k: int = 5 # neighbors used for local-Lyapunov estimate
58
+ recurrence_eps: float = 1.0 # eps-ball radius for recurrence density
59
+ project_to: int = 32 # dim of the per-position projection of hidden state
60
+
61
+
62
+ @dataclass
63
+ class THeadOutput:
64
+ """Per-token outputs from T-Head."""
65
+
66
+ lyapunov: torch.Tensor # (B, T) local Lyapunov estimate
67
+ recurrence: torch.Tensor # (B, T) recurrence density (count of neighbors)
68
+ delay_coord: torch.Tensor # (B, T, m * project_to) raw delay-coord stack
69
+
70
+
71
+ class TakensHead(nn.Module):
72
+ """Delay-embedding readout over a sequence of hidden states.
73
+
74
+ Given a hidden-state stream H of shape (B, T, D), the T-Head:
75
+ 1. Projects H -> Z of shape (B, T, project_to) via a learned linear.
76
+ 2. Builds a delay-coordinate stack X of shape (B, T, m, project_to)
77
+ where X[b, t, j] = Z[b, t - j*tau] (zero-padded for t < j*tau).
78
+ 3. Flattens to V of shape (B, T, m * project_to).
79
+ 4. Computes per-position local-Lyapunov estimate and recurrence density
80
+ from V.
81
+
82
+ The two scalar outputs (lyapunov, recurrence) can be supervised against
83
+ surrogate signals (e.g. CE-loss derivative for Lyapunov; topical
84
+ self-similarity for recurrence) or used as auxiliary inputs to downstream
85
+ heads in v10.
86
+
87
+ Notes
88
+ -----
89
+ The neighbor search and recurrence count are O(T^2) in sequence length;
90
+ for long sequences a chunked or random-projection approximation will be
91
+ needed. For prototype validation on the v9 evaluation sequences (all
92
+ <= 512 tokens) the naive implementation is acceptable.
93
+ """
94
+
95
+ def __init__(self, cfg: THeadConfig, d_hidden: int) -> None:
96
+ super().__init__()
97
+ self.cfg = cfg
98
+ self.project = nn.Linear(d_hidden, cfg.project_to, bias=False)
99
+ nn.init.normal_(self.project.weight, std=0.02)
100
+
101
+ flat_dim = cfg.embedding_dim * cfg.project_to
102
+ # Optional learned linear over the delay-coordinate vector for
103
+ # downstream wiring; v10 will decide whether to keep this raw or
104
+ # project it further.
105
+ self.readout = nn.Linear(flat_dim, flat_dim, bias=False)
106
+ nn.init.eye_(self.readout.weight)
107
+
108
+ def _build_delay_stack(self, z: torch.Tensor) -> torch.Tensor:
109
+ """Build (B, T, m, project_to) delay-coordinate stack."""
110
+ b, t, d = z.shape
111
+ m = self.cfg.embedding_dim
112
+ tau = self.cfg.delay
113
+ out = z.new_zeros(b, t, m, d)
114
+ for j in range(m):
115
+ shift = j * tau
116
+ if shift == 0:
117
+ out[:, :, j, :] = z
118
+ elif shift < t:
119
+ out[:, shift:, j, :] = z[:, : t - shift, :]
120
+ # else: leave zeros (positions earlier than t = j*tau)
121
+ return out
122
+
123
+ def _local_lyapunov(self, v: torch.Tensor) -> torch.Tensor:
124
+ """Estimate local Lyapunov exponent at each position.
125
+
126
+ For each position t, find the k nearest neighbors in the embedded
127
+ space and compare the mean inter-neighbor distance at t to the mean
128
+ inter-neighbor distance at t+1. The log of the ratio approximates the
129
+ local divergence rate (positive = chaotic).
130
+ """
131
+ b, t, d = v.shape
132
+ k = min(self.cfg.knn_k, t - 1)
133
+ if t < 2 or k <= 0:
134
+ return v.new_zeros(b, t)
135
+
136
+ # (B, T, T) pairwise distances
137
+ dist = torch.cdist(v, v) # symmetric, zero diagonal
138
+ # exclude self by setting diagonal to +inf
139
+ eye_mask = torch.eye(t, device=v.device, dtype=torch.bool)
140
+ dist = dist.masked_fill(eye_mask.unsqueeze(0), float("inf"))
141
+
142
+ # nearest-neighbor indices (B, T, k)
143
+ nn_idx = dist.topk(k, dim=-1, largest=False).indices
144
+
145
+ # next-step indices (clamp at T-1)
146
+ nxt_idx = (nn_idx + 1).clamp(max=t - 1)
147
+
148
+ # gather neighbor positions at t and t+1
149
+ # v_nbr_t: (B, T, k, D); v_nbr_t1: (B, T, k, D)
150
+ v_nbr_t = torch.gather(
151
+ v.unsqueeze(1).expand(-1, t, -1, -1), 2,
152
+ nn_idx.unsqueeze(-1).expand(-1, -1, -1, d),
153
+ )
154
+ v_nbr_t1 = torch.gather(
155
+ v.unsqueeze(1).expand(-1, t, -1, -1), 2,
156
+ nxt_idx.unsqueeze(-1).expand(-1, -1, -1, d),
157
+ )
158
+
159
+ # current-position vector at t and t+1
160
+ v_t = v.unsqueeze(2) # (B, T, 1, D)
161
+ nxt_self = torch.arange(t, device=v.device).clamp(max=t - 1) + 1
162
+ nxt_self = nxt_self.clamp(max=t - 1)
163
+ v_t1 = v[:, nxt_self, :].unsqueeze(2) # (B, T, 1, D)
164
+
165
+ d_t = (v_nbr_t - v_t).norm(dim=-1).mean(dim=-1).clamp_min(1e-8)
166
+ d_t1 = (v_nbr_t1 - v_t1).norm(dim=-1).mean(dim=-1).clamp_min(1e-8)
167
+ return (d_t1 / d_t).log()
168
+
169
+ def _recurrence_density(self, v: torch.Tensor) -> torch.Tensor:
170
+ """Count neighbors within eps-ball at each position.
171
+
172
+ Returns a (B, T) float tensor with the number of other positions
173
+ within `recurrence_eps` of each position's delay coordinate.
174
+ """
175
+ b, t, _ = v.shape
176
+ if t < 2:
177
+ return v.new_zeros(b, t)
178
+ dist = torch.cdist(v, v)
179
+ eye_mask = torch.eye(t, device=v.device, dtype=torch.bool)
180
+ dist = dist.masked_fill(eye_mask.unsqueeze(0), float("inf"))
181
+ return (dist < self.cfg.recurrence_eps).float().sum(dim=-1)
182
+
183
+ def forward(self, hidden: torch.Tensor) -> THeadOutput:
184
+ z = self.project(hidden) # (B, T, project_to)
185
+ stack = self._build_delay_stack(z) # (B, T, m, project_to)
186
+ b, t, m, d = stack.shape
187
+ v = self.readout(stack.reshape(b, t, m * d)) # (B, T, m*project_to)
188
+ lyap = self._local_lyapunov(v)
189
+ rec = self._recurrence_density(v)
190
+ return THeadOutput(lyapunov=lyap, recurrence=rec, delay_coord=v)
191
+
192
+
193
+ __all__ = ["THeadConfig", "THeadOutput", "TakensHead"]
srt/nla/__init__.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SRT-NLA — Natural Language Autoencoder over the SRT backbone.
2
+
3
+ Two halves:
4
+
5
+ - ``ActivationReconstructor`` (AR): run the frozen backbone forward and read
6
+ hidden states at layer ``L``. Zero new parameters.
7
+ - ``ActivationVerbalizer`` (AV): inject a target activation vector as a
8
+ prefix input-embedding into the frozen backbone and decode text whose
9
+ re-encoded activation at layer ``L`` matches the target.
10
+
11
+ The AV side carries the only learned parameters in N0–N2 (a small adapter
12
+ on top of the frozen backbone). After N2 the SRT adapter (12.7M) becomes
13
+ the AV trunk so divergence/community channels condition the generation.
14
+
15
+ See ``docs/SRT_NLA_PLAN.md`` for the phasing.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from srt.nla.config import NLAConfig
21
+ from srt.nla.loss import (
22
+ cosine_similarity,
23
+ fraction_variance_explained,
24
+ mse_nrm,
25
+ nla_reward,
26
+ )
27
+ from srt.nla.metrics import (
28
+ Anchors,
29
+ anisotropy_mu,
30
+ fve_nrm,
31
+ fve_nrm_centered,
32
+ rho_norm,
33
+ )
34
+ from srt.nla.reconstructor import ActivationReconstructor
35
+ from srt.nla.sidecar import NLAMeta, load_meta, save_meta
36
+ from srt.nla.verbalizer import ActivationVerbalizer
37
+
38
+ __all__ = [
39
+ "ActivationReconstructor",
40
+ "ActivationVerbalizer",
41
+ "Anchors",
42
+ "NLAConfig",
43
+ "NLAMeta",
44
+ "anisotropy_mu",
45
+ "cosine_similarity",
46
+ "fraction_variance_explained",
47
+ "fve_nrm",
48
+ "fve_nrm_centered",
49
+ "load_meta",
50
+ "mse_nrm",
51
+ "nla_reward",
52
+ "rho_norm",
53
+ "save_meta",
54
+ ]
srt/nla/config.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration for SRT-NLA."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import asdict, dataclass, fields
7
+ from pathlib import Path
8
+
9
+
10
+ @dataclass
11
+ class NLAConfig:
12
+ """Natural Language Autoencoder configuration.
13
+
14
+ Defaults target Qwen2.5-7B (28 hidden layers, hidden_size=3584). The
15
+ NLA reference paper extracts at L=20 (~2/3 depth); we keep the same.
16
+ """
17
+
18
+ backbone_id: str = "Qwen/Qwen2.5-7B"
19
+ backbone_dtype: str = "bfloat16"
20
+
21
+ # Layer to read activations from (1-indexed into output_hidden_states,
22
+ # which is length num_hidden_layers + 1 with index 0 = embeddings).
23
+ extraction_layer: int = 20
24
+
25
+ # Vector dimensionality. None = use backbone hidden_size.
26
+ d_vector: int | None = None
27
+
28
+ # AV generation
29
+ max_new_tokens: int = 128
30
+ temperature: float = 1.0
31
+ top_p: float = 1.0
32
+
33
+ # Pooling for AR
34
+ pool: str = "last" # "last" | "mean" | "first"
35
+
36
+ # Reward weights (see docs/SRT_NLA_PLAN.md §5)
37
+ lambda_mag: float = 0.1 # magnitude-match penalty
38
+ beta_kl: float = 0.05 # legibility KL vs base
39
+ gamma_entropy: float = 0.05 # entropy hinge coefficient (both sides)
40
+ delta_community: float = 0.2 # community-consistency bonus
41
+ h_min: float = 1.5 # minimum acceptable token-level entropy (nats)
42
+ h_max: float = 3.0 # maximum acceptable token-level entropy (nats); above this the upper hinge pulls H back down to prevent uniform-noise runaway
43
+
44
+ # Injection layout — number of learned prefix tokens after the injected
45
+ # vector. The injected vector occupies slot 0; this many extra learned
46
+ # embeddings (BOS-like) follow before the model autoregresses.
47
+ num_prefix_tokens: int = 1
48
+
49
+ # Prefix mode:
50
+ # "static" — `prefix_embeds` is a (P, d_embed) learned tensor shared
51
+ # across all inputs (original design).
52
+ # "mlp" — a 2-layer MLP maps v → (P, d_embed) per-input, so the
53
+ # prefix is conditioned on the target vector. Strictly
54
+ # more expressive; same trainable shape elsewhere.
55
+ prefix_mode: str = "static"
56
+ prefix_mlp_hidden: int = 256
57
+
58
+ # Multi-position injection: number of slots at which proj(v) is injected
59
+ # (with independent linear projections per slot). 1 = original single-slot
60
+ # design. >1 gives the backbone several independent views of v before the
61
+ # learned prefix; intended to break the single-slot information bottleneck.
62
+ num_inject_slots: int = 1
63
+
64
+ # ---- (de)serialization helpers --------------------------------------
65
+
66
+ def to_json(self, path: str | Path) -> None:
67
+ """Write this config to a JSON file (used by HF model cards)."""
68
+ Path(path).write_text(json.dumps(asdict(self), indent=2, sort_keys=True))
69
+
70
+ @classmethod
71
+ def from_json(cls, path: str | Path) -> "NLAConfig":
72
+ """Load a config from JSON. Unknown keys are ignored for
73
+ forward-compatibility with newer checkpoints."""
74
+ data = json.loads(Path(path).read_text())
75
+ known = {f.name for f in fields(cls)}
76
+ return cls(**{k: v for k, v in data.items() if k in known})
srt/nla/decoding.py ADDED
@@ -0,0 +1,249 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deployable decoding methods for the SRT-NLA verbalizer.
2
+
3
+ The headline result of `scripts/rerank_eval.py` is that **oracle-scored
4
+ best-of-K** closes the greedy → ceiling gap on every checkpoint we have:
5
+ greedy ρ_norm ≈ 0.26, K=64 oracle ρ_norm ≈ 0.92 against a paraphrase
6
+ ceiling of 1.00. Because the target activation ``v`` is, by construction,
7
+ available at deploy time in the SRT-NLA setup (the system was given ``v``
8
+ in order to verbalize it), oracle scoring is a *free* deploy-time signal,
9
+ not an unfair eval-time cheat.
10
+
11
+ This module packages that decoding method into a clean reusable function
12
+ so it can be called from train scripts, eval scripts, demos, or product
13
+ code. The cost is K-way sampling + one batched scoring forward pass.
14
+
15
+ Two scoring options:
16
+ - **centered** (recommended): ``0.5 * (1 + cos(h_last - mu, v - mu))``
17
+ with ``mu`` the per-coordinate mean of a held-out pool of real
18
+ backbone-L activations. Anisotropy-corrected.
19
+ - **raw**: ``0.5 * (1 + cos(h_last, v))``. Inflated by Qwen L20's
20
+ cos≈0.24 anisotropy floor; reported only for back-compatibility with
21
+ the pre-`centered_eval.py` literature.
22
+
23
+ ρ_norm conversion (the paper's units) is provided as a helper:
24
+ ``rho_norm(cen) = (cen - random_floor) / (paraphrase_ceiling - random_floor)``
25
+ with the calibrated constants ``random_floor = 0.510`` and
26
+ ``paraphrase_ceiling = 0.799``.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ from dataclasses import dataclass
31
+
32
+ import torch
33
+ import torch.nn.functional as F
34
+
35
+ from srt.nla.verbalizer import ActivationVerbalizer
36
+
37
+ # Calibrated anchors from `scripts/centered_eval.py` on Qwen2.5-7B L20
38
+ # with pool_size=2000 of real Qwen activations. See SESSION_HANDOFF.md.
39
+ RANDOM_FLOOR_CEN = 0.510
40
+ PARAPHRASE_CEILING_CEN = 0.799
41
+
42
+
43
+ def rho_norm(cen: float | torch.Tensor) -> float | torch.Tensor:
44
+ """Convert centered_fve to the paper's normalized ρ_norm units.
45
+
46
+ ρ_norm = 0.0 corresponds to random retrieval, 1.0 to the paraphrase
47
+ ceiling. Values can fall slightly below 0 or above 1 due to sampling
48
+ noise on small M or unusually-clean checkpoints.
49
+ """
50
+ return (cen - RANDOM_FLOOR_CEN) / (PARAPHRASE_CEILING_CEN - RANDOM_FLOOR_CEN)
51
+
52
+
53
+ def _fve_from_cos(c: torch.Tensor) -> torch.Tensor:
54
+ return 0.5 * (1.0 + c)
55
+
56
+
57
+ def _cos_pairs(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
58
+ an = F.normalize(a.float(), dim=-1)
59
+ bn = F.normalize(b.float(), dim=-1)
60
+ return (an * bn).sum(-1)
61
+
62
+
63
+ @torch.no_grad()
64
+ def _rollout(
65
+ av: ActivationVerbalizer,
66
+ v: torch.Tensor,
67
+ *,
68
+ max_new_tokens: int,
69
+ do_sample: bool,
70
+ temperature: float,
71
+ eos_id: int,
72
+ ) -> tuple[torch.Tensor, torch.Tensor]:
73
+ """Generate (B, T) token ids and (B, T) attention mask truncated at first EOS."""
74
+ gen_ids = av.generate(
75
+ v,
76
+ max_new_tokens=max_new_tokens,
77
+ do_sample=do_sample,
78
+ temperature=max(temperature, 1e-6),
79
+ top_p=1.0,
80
+ )
81
+ B, T = gen_ids.shape
82
+ device = gen_ids.device
83
+ is_eos = gen_ids == eos_id
84
+ pos = torch.arange(T, device=device).unsqueeze(0).expand(B, T)
85
+ first_eos = torch.where(
86
+ is_eos.any(dim=-1, keepdim=True),
87
+ is_eos.float().argmax(dim=-1, keepdim=True).long(),
88
+ torch.full((B, 1), T - 1, device=device, dtype=torch.long),
89
+ )
90
+ gen_attn = (pos <= first_eos).long()
91
+ return gen_ids, gen_attn
92
+
93
+
94
+ @torch.no_grad()
95
+ def _h_last_prefix_free(
96
+ backbone,
97
+ gen_ids: torch.Tensor,
98
+ gen_attn: torch.Tensor,
99
+ layer: int,
100
+ ) -> torch.Tensor:
101
+ """Read h_last from a *prefix-free* re-forward of the generated tokens.
102
+
103
+ Critical: scoring must NOT include the proj(v) prefix in the forward
104
+ used to extract h_last, because the prefix would leak v directly into
105
+ the activation and inflate the metric. This matches centered_eval.py
106
+ and the deployment-realistic measurement (text → embed → read activation).
107
+ """
108
+ out = backbone(
109
+ input_ids=gen_ids,
110
+ attention_mask=gen_attn,
111
+ output_hidden_states=True,
112
+ use_cache=False,
113
+ )
114
+ full_h = out.hidden_states[layer]
115
+ B = gen_ids.size(0)
116
+ last_idx = (gen_attn.sum(-1) - 1).clamp(min=0).long()
117
+ rows = torch.arange(B, device=full_h.device)
118
+ return full_h[rows, last_idx].float()
119
+
120
+
121
+ @dataclass
122
+ class OracleRerankResult:
123
+ """Result of oracle-rerank decoding for a batch of B target vectors."""
124
+
125
+ best_ids: torch.Tensor # (B, T) chosen rollout token ids
126
+ best_attn: torch.Tensor # (B, T) attention mask
127
+ best_h_last: torch.Tensor # (B, d) chosen rollout h_last
128
+ best_cen: torch.Tensor # (B,) centered_fve scores of the chosen rollout
129
+ best_idx: torch.Tensor # (B,) which of the K candidates was chosen
130
+ all_cen: torch.Tensor | None # (B, K) scores for every candidate (if return_all)
131
+ all_h_last: torch.Tensor | None # (B, K, d) (if return_all)
132
+ all_ids: torch.Tensor | None # (B, K, T) (if return_all)
133
+
134
+
135
+ @torch.no_grad()
136
+ def oracle_rerank_decode(
137
+ av: ActivationVerbalizer,
138
+ backbone,
139
+ v: torch.Tensor,
140
+ *,
141
+ layer: int,
142
+ K: int = 64,
143
+ max_new_tokens: int = 64,
144
+ temperature: float = 1.0,
145
+ mu: torch.Tensor | None = None,
146
+ score_batch_size: int = 64,
147
+ eos_id: int | None = None,
148
+ return_all: bool = False,
149
+ ) -> OracleRerankResult:
150
+ """Sample K rollouts per target, score with centered_fve, return the argmax.
151
+
152
+ This is the recommended deploy-time decoding method for SRT-NLA when
153
+ ``v`` is available (which it is, by construction).
154
+
155
+ Args:
156
+ av: an ``ActivationVerbalizer`` already loaded with weights and on
157
+ the same device as ``v``.
158
+ backbone: the frozen causal-LM backbone (same one wrapped by ``av``).
159
+ v: target activation vectors, shape ``(B, d)``.
160
+ layer: which hidden-state index to read for h_last. The AR/AV
161
+ extraction layer (typically 20 for Qwen2.5-7B).
162
+ K: number of samples per target.
163
+ max_new_tokens: rollout length cap.
164
+ temperature: sampling temperature (top-p is fixed to 1.0 here; the
165
+ K-curve is dominated by K, not nucleus).
166
+ mu: anisotropy mean ``(1, d)``. If ``None``, falls back to **raw**
167
+ scoring (cos to v with no centering); strongly recommended to
168
+ pass an actual pool-mean for anisotropy-correct comparison
169
+ against published numbers.
170
+ score_batch_size: cap concurrent (candidate, prefix) pairs in the
171
+ scoring forward to bound memory.
172
+ eos_id: token id used to truncate rollouts. Defaults to the AV
173
+ tokenizer's eos.
174
+ return_all: if True, also return per-candidate scores + h_last + ids.
175
+
176
+ Returns:
177
+ ``OracleRerankResult``. ``best_text`` is not auto-decoded — call
178
+ ``av.tokenizer.batch_decode(result.best_ids, skip_special_tokens=True)``.
179
+ """
180
+ if v.dim() != 2:
181
+ raise ValueError(f"v must be (B, d); got shape {tuple(v.shape)}")
182
+ B, d = v.shape
183
+ device = v.device
184
+ if eos_id is None:
185
+ eos_id = int(av.tokenizer.eos_token_id)
186
+
187
+ # 1) K rollouts per target — flatten to (B*K, ...) for one big sampling call.
188
+ v_rep = v.unsqueeze(1).expand(B, K, d).reshape(B * K, d)
189
+ s_ids, s_attn = _rollout(
190
+ av, v_rep,
191
+ max_new_tokens=max_new_tokens,
192
+ do_sample=True,
193
+ temperature=temperature,
194
+ eos_id=eos_id,
195
+ )
196
+
197
+ # 2) Score in chunks to bound memory.
198
+ h_chunks: list[torch.Tensor] = []
199
+ for j in range(0, s_ids.size(0), score_batch_size):
200
+ h = _h_last_prefix_free(
201
+ backbone,
202
+ s_ids[j : j + score_batch_size],
203
+ s_attn[j : j + score_batch_size],
204
+ layer,
205
+ )
206
+ h_chunks.append(h)
207
+ h_all = torch.cat(h_chunks, dim=0) # (B*K, d)
208
+
209
+ # 3) Score: centered if mu given, raw otherwise.
210
+ if mu is not None:
211
+ if mu.dim() == 1:
212
+ mu = mu.unsqueeze(0)
213
+ mu_dev = mu.to(device=device, dtype=torch.float32)
214
+ cen = _fve_from_cos(_cos_pairs(h_all - mu_dev, v_rep - mu_dev))
215
+ else:
216
+ cen = _fve_from_cos(_cos_pairs(h_all, v_rep))
217
+
218
+ cen = cen.view(B, K)
219
+ h_BK = h_all.view(B, K, d)
220
+ ids_BK = s_ids.view(B, K, -1)
221
+ attn_BK = s_attn.view(B, K, -1)
222
+
223
+ # 4) Argmax over K per target.
224
+ best_idx = cen.argmax(dim=1) # (B,)
225
+ rows = torch.arange(B, device=device)
226
+ best_cen = cen[rows, best_idx]
227
+ best_h = h_BK[rows, best_idx]
228
+ best_ids = ids_BK[rows, best_idx]
229
+ best_attn = attn_BK[rows, best_idx]
230
+
231
+ return OracleRerankResult(
232
+ best_ids=best_ids,
233
+ best_attn=best_attn,
234
+ best_h_last=best_h,
235
+ best_cen=best_cen,
236
+ best_idx=best_idx,
237
+ all_cen=cen if return_all else None,
238
+ all_h_last=h_BK if return_all else None,
239
+ all_ids=ids_BK if return_all else None,
240
+ )
241
+
242
+
243
+ __all__ = [
244
+ "RANDOM_FLOOR_CEN",
245
+ "PARAPHRASE_CEILING_CEN",
246
+ "rho_norm",
247
+ "OracleRerankResult",
248
+ "oracle_rerank_decode",
249
+ ]
srt/nla/loss.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Losses and metrics for SRT-NLA.
2
+
3
+ The canonical NLA quality metric is direction MSE on L2-normalised
4
+ vectors (``mse_nrm``); ``fve_nrm`` is the fraction of variance explained
5
+ relative to a uniform random unit baseline (which has ``E[mse_nrm] = 2``).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+ import torch
13
+ import torch.nn.functional as F
14
+
15
+
16
+ def mse_nrm(
17
+ v_hat: torch.Tensor, v_target: torch.Tensor, *, reduce: bool = True
18
+ ) -> torch.Tensor:
19
+ """Squared L2 distance between unit-normalised vectors. Lower is better.
20
+
21
+ Both tensors are ``(..., d)``. With ``reduce=True`` (default) returns a
22
+ scalar (mean over leading dims); with ``reduce=False`` returns ``(...,)``
23
+ per-sample values — needed by REINFORCE so the advantage baseline
24
+ ``r - r.mean()`` is not identically zero.
25
+ """
26
+ a = F.normalize(v_hat, dim=-1)
27
+ b = F.normalize(v_target, dim=-1)
28
+ per_sample = ((a - b) ** 2).sum(dim=-1)
29
+ return per_sample.mean() if reduce else per_sample
30
+
31
+
32
+ def cosine_similarity(v_hat: torch.Tensor, v_target: torch.Tensor) -> torch.Tensor:
33
+ """Mean cosine similarity. ``= 1 - mse_nrm/2``."""
34
+ return F.cosine_similarity(v_hat, v_target, dim=-1).mean()
35
+
36
+
37
+ def fraction_variance_explained(
38
+ v_hat: torch.Tensor, v_target: torch.Tensor
39
+ ) -> torch.Tensor:
40
+ """FVE on L2-normalised vectors.
41
+
42
+ Random uniform unit vectors have ``E[||u - v||^2] = 2``, so
43
+ ``fve_nrm = 1 - mse_nrm / 2``. The Qwen2.5-7B NLA reference reports
44
+ ``fve_nrm = 0.752`` at L=20.
45
+ """
46
+ return 1.0 - mse_nrm(v_hat, v_target) / 2.0
47
+
48
+
49
+ def magnitude_penalty(
50
+ v_hat: torch.Tensor, v_target: torch.Tensor, *, reduce: bool = True
51
+ ) -> torch.Tensor:
52
+ """Scale-invariant magnitude mismatch.
53
+
54
+ Returns the squared *relative* error of the L2 norms:
55
+
56
+ ((||v_hat|| - ||v_target||) / ||v_target||) ** 2
57
+
58
+ Hidden states at deep layers of large LMs can have L2 norms in the
59
+ 1e3-1e4 range; the previous formulation ``(||v_hat|| - ||v_target||)^2``
60
+ produced reward magnitudes of order 1e8, swamping ``mse_nrm`` (which is
61
+ bounded in [0, 4]) by 8 orders of magnitude regardless of
62
+ ``lambda_mag``. The relative form is bounded in roughly [0, 1] for
63
+ sensible predictions, matching ``mse_nrm``'s scale.
64
+ """
65
+ target_norm = v_target.norm(dim=-1).clamp(min=1e-6)
66
+ per_sample = ((v_hat.norm(dim=-1) - v_target.norm(dim=-1)) / target_norm).pow(2)
67
+ return per_sample.mean() if reduce else per_sample
68
+
69
+
70
+ def entropy_floor_hinge(
71
+ token_entropy: torch.Tensor, h_min: float, *, reduce: bool = True
72
+ ) -> torch.Tensor:
73
+ """Hinge that fires when per-token entropy drops below ``h_min`` nats."""
74
+ per_sample = torch.clamp(h_min - token_entropy, min=0.0)
75
+ return per_sample.mean() if reduce else per_sample
76
+
77
+
78
+ @dataclass
79
+ class RewardComponents:
80
+ mse: torch.Tensor
81
+ mag: torch.Tensor
82
+ kl: torch.Tensor
83
+ entropy_hinge: torch.Tensor
84
+ community: torch.Tensor
85
+ total: torch.Tensor
86
+
87
+
88
+ def nla_reward(
89
+ v_hat: torch.Tensor,
90
+ v_target: torch.Tensor,
91
+ *,
92
+ kl_av_vs_base: torch.Tensor | None = None,
93
+ token_entropy: torch.Tensor | None = None,
94
+ community_consistency: torch.Tensor | None = None,
95
+ lambda_mag: float = 0.1,
96
+ beta_kl: float = 0.05,
97
+ gamma_entropy: float = 0.05,
98
+ delta_community: float = 0.2,
99
+ h_min: float = 1.5,
100
+ reduce: bool = True,
101
+ ) -> RewardComponents:
102
+ """5-term NLA reward (higher is better).
103
+
104
+ ``r = -mse_nrm - lambda_mag * mag - beta_kl * KL - gamma * H_hinge + delta * comm``
105
+
106
+ Any optional term left as ``None`` is treated as zero (so N0/N1 can
107
+ compose the reward incrementally as the surrounding modules come
108
+ online).
109
+
110
+ With ``reduce=False`` every component is per-sample ``(B,)``; with
111
+ ``reduce=True`` (default) every component is a scalar. REINFORCE must
112
+ use ``reduce=False`` so ``advantage = r - r.mean()`` is non-degenerate.
113
+ """
114
+ device = v_hat.device
115
+ dtype = v_hat.dtype
116
+ B = v_hat.shape[0] if v_hat.dim() >= 1 else 1
117
+ zero_shape = () if reduce else (B,)
118
+ zero = torch.zeros(zero_shape, device=device, dtype=dtype)
119
+
120
+ def _maybe_per_sample(x: torch.Tensor) -> torch.Tensor:
121
+ if reduce:
122
+ return x.mean() if x.dim() > 0 else x
123
+ # caller passed a scalar but we want per-sample → broadcast.
124
+ return x.expand(B) if x.dim() == 0 else x
125
+
126
+ mse = mse_nrm(v_hat, v_target, reduce=reduce)
127
+ mag = magnitude_penalty(v_hat, v_target, reduce=reduce)
128
+ kl = _maybe_per_sample(kl_av_vs_base) if kl_av_vs_base is not None else zero
129
+ ent = (
130
+ entropy_floor_hinge(token_entropy, h_min, reduce=reduce)
131
+ if token_entropy is not None
132
+ else zero
133
+ )
134
+ comm = (
135
+ _maybe_per_sample(community_consistency)
136
+ if community_consistency is not None
137
+ else zero
138
+ )
139
+
140
+ total = -mse - lambda_mag * mag - beta_kl * kl - gamma_entropy * ent + delta_community * comm
141
+ return RewardComponents(
142
+ mse=mse, mag=mag, kl=kl, entropy_hinge=ent, community=comm, total=total
143
+ )
srt/nla/metrics.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Canonical NLA evaluation metrics.
2
+
3
+ Single source of truth for fve_nrm, the centered (anisotropy-corrected)
4
+ variant, and the rho_norm normalization used in paper_nla.md.
5
+
6
+ Previously these helpers were duplicated in 6+ scripts. New code should
7
+ import from here. The duplicates remain in legacy scripts for now to
8
+ preserve bit-exact reproducibility of historical artifacts/nla/*.json.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+
14
+ import torch
15
+ import torch.nn.functional as F
16
+
17
+
18
+ # Anchors from artifacts/nla/oracle_ceiling_30k_v2.json (Qwen2.5-7B L20,
19
+ # 200 held-out targets, pool=2000). Used to normalize centered fve_nrm
20
+ # into rho_norm ∈ [0, 1].
21
+ RANDOM_FLOOR_CEN: float = 0.510
22
+ PARAPHRASE_CEILING_CEN: float = 0.799
23
+ RHO_DENOM: float = PARAPHRASE_CEILING_CEN - RANDOM_FLOOR_CEN # 0.289
24
+
25
+
26
+ def fve_nrm(h: torch.Tensor, v: torch.Tensor) -> torch.Tensor:
27
+ """fve_nrm(h, v) = 0.5 * (1 + cos(h, v)) in [0, 1].
28
+
29
+ Linear remap of cosine similarity. Operates on the last dim.
30
+ Shapes: h, v broadcast-compatible; returns batch shape.
31
+ """
32
+ return 0.5 * (1.0 + F.cosine_similarity(h.float(), v.float(), dim=-1))
33
+
34
+
35
+ def fve_nrm_centered(
36
+ h: torch.Tensor, v: torch.Tensor, mu: torch.Tensor
37
+ ) -> torch.Tensor:
38
+ """Anisotropy-corrected fve_nrm: subtract pool mean mu before cosine.
39
+
40
+ mu is the (d,) mean activation of the held-out pool at the same
41
+ layer/backbone. Without this correction, Qwen2.5-7B L20 activations
42
+ sit at cos≈0.24 baseline (||mu||≈55) which inflates raw fve_nrm by
43
+ ~0.11 and compresses dynamic range.
44
+ """
45
+ return fve_nrm(h - mu, v - mu)
46
+
47
+
48
+ def rho_norm(cen: torch.Tensor | float) -> torch.Tensor | float:
49
+ """Normalize centered fve_nrm to [0, 1] using paper anchors.
50
+
51
+ rho = (cen - random_floor) / (paraphrase_ceiling - random_floor)
52
+
53
+ rho = 0 ↔ unrelated; rho = 1 ↔ saturates Qwen paraphrase ceiling.
54
+ """
55
+ if isinstance(cen, torch.Tensor):
56
+ return (cen - RANDOM_FLOOR_CEN) / RHO_DENOM
57
+ return (cen - RANDOM_FLOOR_CEN) / RHO_DENOM
58
+
59
+
60
+ def anisotropy_mu(pool: torch.Tensor) -> torch.Tensor:
61
+ """Compute the anisotropy mean mu = pool.mean(0).
62
+
63
+ pool: (N, d) of last-token L20 activations from a held-out target file.
64
+ Returns (d,) float32.
65
+ """
66
+ return pool.float().mean(0)
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class Anchors:
71
+ """Reference points reported in paper_nla.md §3."""
72
+
73
+ random_floor_cen: float = RANDOM_FLOOR_CEN
74
+ paraphrase_ceiling_cen: float = PARAPHRASE_CEILING_CEN
75
+ nn_in_pool_cen: float = 0.663 # pool=200
76
+ nn_retrieval_cen: float = 0.714 # pool=2000
77
+ replay_cen: float = 0.968
78
+
79
+
80
+ __all__ = [
81
+ "fve_nrm",
82
+ "fve_nrm_centered",
83
+ "rho_norm",
84
+ "anisotropy_mu",
85
+ "Anchors",
86
+ "RANDOM_FLOOR_CEN",
87
+ "PARAPHRASE_CEILING_CEN",
88
+ "RHO_DENOM",
89
+ ]
srt/nla/reconstructor.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ActivationReconstructor (AR): frozen-backbone hidden-state reader.
2
+
3
+ Zero learned parameters in the canonical setup — the "reconstruction" is
4
+ just the forward pass through layers 0..L of the frozen backbone.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import Literal
11
+
12
+ import torch
13
+ import torch.nn as nn
14
+ from transformers import AutoModelForCausalLM
15
+
16
+ from srt.nla.config import NLAConfig
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ _DTYPE_MAP = {
21
+ "float32": torch.float32,
22
+ "float16": torch.float16,
23
+ "bfloat16": torch.bfloat16,
24
+ }
25
+
26
+
27
+ class ActivationReconstructor(nn.Module):
28
+ """Reads pooled hidden states at ``cfg.extraction_layer`` from a text input.
29
+
30
+ Pass an existing ``backbone`` to share weights with an
31
+ ``ActivationVerbalizer`` (recommended — saves 14GB of RAM).
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ cfg: NLAConfig,
37
+ backbone: AutoModelForCausalLM | None = None,
38
+ ) -> None:
39
+ super().__init__()
40
+ self.cfg = cfg
41
+ if backbone is None:
42
+ dtype = _DTYPE_MAP.get(cfg.backbone_dtype, torch.bfloat16)
43
+ logger.info("AR: loading backbone %s (%s)", cfg.backbone_id, cfg.backbone_dtype)
44
+ backbone = AutoModelForCausalLM.from_pretrained(
45
+ cfg.backbone_id, torch_dtype=dtype
46
+ )
47
+ for p in backbone.parameters():
48
+ p.requires_grad = False
49
+ backbone.eval()
50
+ self.backbone = backbone
51
+ self._d = self.backbone.config.hidden_size
52
+ self._num_layers = self.backbone.config.num_hidden_layers
53
+ if not (0 < cfg.extraction_layer <= self._num_layers):
54
+ raise ValueError(
55
+ f"extraction_layer={cfg.extraction_layer} out of range "
56
+ f"(backbone has {self._num_layers} hidden layers)"
57
+ )
58
+
59
+ @property
60
+ def d_vector(self) -> int:
61
+ return self.cfg.d_vector or self._d
62
+
63
+ def _pool(
64
+ self,
65
+ h_layer: torch.Tensor,
66
+ attention_mask: torch.Tensor | None,
67
+ mode: Literal["last", "mean", "first"],
68
+ ) -> torch.Tensor:
69
+ if mode == "first":
70
+ return h_layer[:, 0]
71
+ if mode == "mean":
72
+ if attention_mask is None:
73
+ return h_layer.mean(dim=1)
74
+ mask = attention_mask.unsqueeze(-1).to(h_layer.dtype)
75
+ return (h_layer * mask).sum(dim=1) / mask.sum(dim=1).clamp(min=1)
76
+ # default: "last" non-pad token
77
+ if attention_mask is None:
78
+ return h_layer[:, -1]
79
+ lengths = (attention_mask.sum(dim=1).long() - 1).clamp(min=0)
80
+ idx = torch.arange(h_layer.size(0), device=h_layer.device)
81
+ return h_layer[idx, lengths]
82
+
83
+ @torch.no_grad()
84
+ def reconstruct(
85
+ self,
86
+ input_ids: torch.Tensor,
87
+ attention_mask: torch.Tensor | None = None,
88
+ pool: str | None = None,
89
+ ) -> torch.Tensor:
90
+ """Return pooled hidden state ``(B, d)`` at layer ``cfg.extraction_layer``."""
91
+ out = self.backbone(
92
+ input_ids=input_ids,
93
+ attention_mask=attention_mask,
94
+ output_hidden_states=True,
95
+ use_cache=False,
96
+ )
97
+ # hidden_states is a tuple of length num_hidden_layers + 1
98
+ h_layer = out.hidden_states[self.cfg.extraction_layer]
99
+ return self._pool(h_layer, attention_mask, pool or self.cfg.pool) # type: ignore[arg-type]
100
+
101
+ def reconstruct_from_embeds(
102
+ self,
103
+ inputs_embeds: torch.Tensor,
104
+ attention_mask: torch.Tensor | None = None,
105
+ pool: str | None = None,
106
+ ) -> torch.Tensor:
107
+ """Differentiable AR forward from soft input embeddings.
108
+
109
+ Used by the Phase-2 soft-embedding bridge. Caller passes
110
+ ``softmax(logits / tau) @ E_token`` as ``inputs_embeds`` so that
111
+ the entire pipeline (AV -> soft embeds -> backbone -> pool) has
112
+ a per-token-per-dim gradient w.r.t. AV parameters. Backbone
113
+ weights stay frozen (requires_grad=False set in __init__).
114
+ """
115
+ out = self.backbone(
116
+ inputs_embeds=inputs_embeds,
117
+ attention_mask=attention_mask,
118
+ output_hidden_states=True,
119
+ use_cache=False,
120
+ )
121
+ h_layer = out.hidden_states[self.cfg.extraction_layer]
122
+ return self._pool(h_layer, attention_mask, pool or self.cfg.pool) # type: ignore[arg-type]
srt/nla/sidecar.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sidecar metadata for an NLA checkpoint (``nla_meta.yaml``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import asdict, dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ @dataclass
12
+ class NLAMeta:
13
+ """What an NLA checkpoint represents.
14
+
15
+ Persisted alongside the weights so a consumer can answer:
16
+ "which backbone, which layer, what vector convention".
17
+ """
18
+
19
+ backbone_id: str
20
+ backbone_revision: str | None
21
+ extraction_layer: int
22
+ d_vector: int
23
+ pool: str
24
+ norm_convention: str = "raw" # "raw" | "unit" | "z"
25
+ pair: str = "av" # "av" | "ar" | "av_ar"
26
+ phase: str = "N0"
27
+ notes: str = ""
28
+ extra: dict[str, Any] = field(default_factory=dict)
29
+
30
+ def to_dict(self) -> dict[str, Any]:
31
+ return asdict(self)
32
+
33
+
34
+ def save_meta(meta: NLAMeta, path: str | Path) -> None:
35
+ """Write ``meta`` as YAML if PyYAML is available, else JSON."""
36
+ p = Path(path)
37
+ p.parent.mkdir(parents=True, exist_ok=True)
38
+ payload = meta.to_dict()
39
+ try:
40
+ import yaml # type: ignore[import-not-found]
41
+
42
+ p.write_text(yaml.safe_dump(payload, sort_keys=False))
43
+ except ImportError:
44
+ p.write_text(json.dumps(payload, indent=2))
45
+
46
+
47
+ def load_meta(path: str | Path) -> NLAMeta:
48
+ p = Path(path)
49
+ text = p.read_text()
50
+ try:
51
+ import yaml # type: ignore[import-not-found]
52
+
53
+ data = yaml.safe_load(text)
54
+ except ImportError:
55
+ data = json.loads(text)
56
+ return NLAMeta(**data)
srt/nla/targets_check.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared precondition checks + fingerprint for NLA target pools.
2
+
3
+ A whole iteration was wasted in May 2026 training on a degenerate target
4
+ pool where every sample collapsed to the same (~all-zeros) activation
5
+ because Qwen2.5's ``bos_token_id == eos_token_id`` caused the BOS slot
6
+ to be picked as the "last token" by ``sample_targets.py``. Loss
7
+ nonetheless decreased and ``fve_nrm`` plateaued at a fake ~0.62
8
+ because the AV learned to emit one constant string. Nothing in any
9
+ train script noticed.
10
+
11
+ These helpers fail loudly *before* the optimizer runs if the pool is
12
+ degenerate or constant-norm, and emit a one-line fingerprint
13
+ (sha + N + per-elem std + norm mean/std) into the train log header so
14
+ every run is traceable to the exact data version it consumed.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ import logging
21
+ from pathlib import Path
22
+
23
+ import torch
24
+
25
+ logger = logging.getLogger("nla.targets_check")
26
+
27
+
28
+ def fingerprint_targets(path: Path, pool: torch.Tensor) -> dict:
29
+ """Compute sha256 (first 16 hex) + summary stats of the pool."""
30
+ p = Path(path)
31
+ h = hashlib.sha256()
32
+ with p.open("rb") as f:
33
+ for chunk in iter(lambda: f.read(1 << 20), b""):
34
+ h.update(chunk)
35
+ sha16 = h.hexdigest()[:16]
36
+ pool_f = pool.float()
37
+ per_elem_std = float(pool_f.std(dim=0).mean().item())
38
+ norms = pool_f.norm(dim=-1)
39
+ return {
40
+ "path": str(p),
41
+ "sha16": sha16,
42
+ "n": int(pool.size(0)),
43
+ "d": int(pool.size(1)),
44
+ "per_elem_std": per_elem_std,
45
+ "norm_mean": float(norms.mean().item()),
46
+ "norm_std": float(norms.std().item()),
47
+ }
48
+
49
+
50
+ def assert_targets_healthy(
51
+ path: Path,
52
+ pool: torch.Tensor,
53
+ *,
54
+ min_per_elem_std: float = 0.1,
55
+ min_norm_std: float = 1.0,
56
+ ) -> dict:
57
+ """Fingerprint + log + assert pool is non-degenerate. Returns fingerprint."""
58
+ fp = fingerprint_targets(path, pool)
59
+ logger.info(
60
+ "TARGETS sha=%s N=%d d=%d per_elem_std=%.4f norm=%.3f±%.3f",
61
+ fp["sha16"], fp["n"], fp["d"],
62
+ fp["per_elem_std"], fp["norm_mean"], fp["norm_std"],
63
+ )
64
+ if fp["per_elem_std"] < min_per_elem_std:
65
+ raise RuntimeError(
66
+ f"DEGENERATE target pool {path}: per-elem std={fp['per_elem_std']:.4f} "
67
+ f"< {min_per_elem_std}. Almost certainly a sampling bug (e.g. "
68
+ f"BOS==EOS collapse). Refusing to train."
69
+ )
70
+ if fp["norm_std"] < min_norm_std:
71
+ raise RuntimeError(
72
+ f"CONSTANT-NORM target pool {path}: norm std={fp['norm_std']:.4f} "
73
+ f"< {min_norm_std}. Targets are not diverse. Refusing to train."
74
+ )
75
+ return fp
srt/nla/verbalizer.py ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ActivationVerbalizer (AV): inject a target vector, decode text.
2
+
3
+ The frozen backbone runs over a prefix consisting of:
4
+
5
+ [proj(v) , learned_prefix_embeds..., generated_tokens...]
6
+
7
+ ``proj`` is the only trainable module in N0–N2 (a linear map from the
8
+ vector space to the backbone embedding space). After N2 the SRT adapter
9
+ replaces ``proj`` with the full 12.7M-parameter adapter pipeline so the
10
+ divergence and community channels condition the generation.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+
17
+ import torch
18
+ import torch.nn as nn
19
+ from transformers import AutoModelForCausalLM, AutoTokenizer
20
+
21
+ from srt.nla.config import NLAConfig
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ _DTYPE_MAP = {
26
+ "float32": torch.float32,
27
+ "float16": torch.float16,
28
+ "bfloat16": torch.bfloat16,
29
+ }
30
+
31
+
32
+ class ActivationVerbalizer(nn.Module):
33
+ """Injection-prefix language generator.
34
+
35
+ ``forward`` returns logits over a teacher-forced sequence (for training).
36
+ ``generate`` returns sampled token ids (for evaluation / RL rollout).
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ cfg: NLAConfig,
42
+ backbone: AutoModelForCausalLM | None = None,
43
+ tokenizer: AutoTokenizer | None = None,
44
+ ) -> None:
45
+ super().__init__()
46
+ self.cfg = cfg
47
+ dtype = _DTYPE_MAP.get(cfg.backbone_dtype, torch.bfloat16)
48
+ if backbone is None:
49
+ logger.info("AV: loading backbone %s (%s)", cfg.backbone_id, cfg.backbone_dtype)
50
+ backbone = AutoModelForCausalLM.from_pretrained(
51
+ cfg.backbone_id, torch_dtype=dtype
52
+ )
53
+ for p in backbone.parameters():
54
+ p.requires_grad = False
55
+ backbone.eval()
56
+ self.backbone = backbone
57
+ if tokenizer is None:
58
+ tokenizer = AutoTokenizer.from_pretrained(cfg.backbone_id)
59
+ self.tokenizer = tokenizer
60
+
61
+ self._d_embed = self.backbone.config.hidden_size
62
+ self._backbone_dtype = dtype
63
+ d_vec = cfg.d_vector or self._d_embed
64
+
65
+ # Trainable projection from vector space → backbone embedding space.
66
+ # Initialised to identity when shapes match; small random otherwise.
67
+ # NOTE: adapter params are kept in float32 even when the backbone is
68
+ # bf16/fp16 — AdamW on low-precision params is numerically unstable
69
+ # and the eye-init drifts to noise within a few hundred steps. We
70
+ # cast to backbone dtype only at the boundary inside _inject_prefix.
71
+ self.proj = nn.Linear(d_vec, self._d_embed, bias=False)
72
+ with torch.no_grad():
73
+ if d_vec == self._d_embed:
74
+ nn.init.eye_(self.proj.weight)
75
+ else:
76
+ nn.init.xavier_uniform_(self.proj.weight)
77
+
78
+ # Multi-position injection: M independent projections of v placed at
79
+ # the first M slots of the prefix. proj_extra holds slots 1..M-1
80
+ # (proj covers slot 0). Initialised small so the model starts near
81
+ # the single-slot baseline and the extra slots add information as
82
+ # training progresses.
83
+ n_inject = max(1, getattr(cfg, "num_inject_slots", 1))
84
+ self._n_inject = n_inject
85
+ if n_inject > 1:
86
+ self.proj_extra = nn.ModuleList(
87
+ [nn.Linear(d_vec, self._d_embed, bias=False) for _ in range(n_inject - 1)]
88
+ )
89
+ with torch.no_grad():
90
+ for lin in self.proj_extra:
91
+ nn.init.xavier_uniform_(lin.weight, gain=0.1)
92
+ else:
93
+ self.proj_extra = None
94
+
95
+ # Trainable extra prefix embeddings that follow the injected vector.
96
+ # These give the model a small "header" before it starts generating
97
+ # describing text.
98
+ #
99
+ # Two modes:
100
+ # "static" — learned (P, d_embed) tensor, shared across inputs.
101
+ # Initialised from the backbone's BOS embedding.
102
+ # "mlp" — 2-layer MLP maps v → (P, d_embed) per-input. Init
103
+ # second layer to zeros so prefix starts equal to the
104
+ # BOS embedding (the bias of layer 2), then drifts.
105
+ # Adapter params are float32 (see note above).
106
+ n_pref = max(0, cfg.num_prefix_tokens)
107
+ self._n_pref = n_pref
108
+ self._prefix_mode = getattr(cfg, "prefix_mode", "static")
109
+ if n_pref > 0:
110
+ bos_id = (
111
+ self.backbone.config.bos_token_id
112
+ or self.tokenizer.bos_token_id
113
+ or 0
114
+ )
115
+ with torch.no_grad():
116
+ embed = self.backbone.get_input_embeddings()
117
+ bos_embed = (
118
+ embed(torch.tensor([bos_id], device=embed.weight.device))
119
+ .detach()
120
+ .clone()
121
+ .float()
122
+ ) # (1, d) float32
123
+ if self._prefix_mode == "mlp":
124
+ hidden = getattr(cfg, "prefix_mlp_hidden", 256)
125
+ out_dim = n_pref * self._d_embed
126
+ self.prefix_mlp = nn.Sequential(
127
+ nn.Linear(d_vec, hidden),
128
+ nn.GELU(),
129
+ nn.Linear(hidden, out_dim),
130
+ )
131
+ with torch.no_grad():
132
+ # Zero output weights so prefix = bias (set to tiled BOS)
133
+ # — preserves warm-start behaviour at init.
134
+ nn.init.xavier_uniform_(self.prefix_mlp[0].weight)
135
+ nn.init.zeros_(self.prefix_mlp[0].bias)
136
+ nn.init.zeros_(self.prefix_mlp[2].weight)
137
+ self.prefix_mlp[2].bias.copy_(
138
+ bos_embed.expand(n_pref, -1).reshape(-1)
139
+ )
140
+ self.register_parameter("prefix_embeds", None)
141
+ else:
142
+ init = bos_embed.expand(n_pref, -1).clone()
143
+ self.prefix_embeds = nn.Parameter(init)
144
+ self.prefix_mlp = None
145
+ else:
146
+ self.register_parameter("prefix_embeds", None)
147
+ self.prefix_mlp = None
148
+
149
+ # ─────────────────────────── helpers ────────────────────────────
150
+
151
+ def _inject_prefix(self, v: torch.Tensor) -> torch.Tensor:
152
+ """Build ``(B, P, d_embed)`` injection prefix from vectors ``(B, d_vec)``.
153
+
154
+ Adapter params are float32; the output is cast to the backbone's
155
+ dtype at this boundary so ``inputs_embeds`` matches the frozen
156
+ backbone weights without forcing the optimizer onto low precision.
157
+ """
158
+ if v.dim() == 1:
159
+ v = v.unsqueeze(0)
160
+ v32 = v.float()
161
+ # Slot 0 always uses self.proj; slots 1..M-1 use proj_extra.
162
+ inject_list = [self.proj(v32).unsqueeze(1)]
163
+ if self.proj_extra is not None:
164
+ for lin in self.proj_extra:
165
+ inject_list.append(lin(v32).unsqueeze(1))
166
+ inject = torch.cat(inject_list, dim=1) # (B, M, d) float32
167
+ if self._n_pref == 0:
168
+ return inject.to(self._backbone_dtype)
169
+ if self.prefix_mlp is not None:
170
+ extra = self.prefix_mlp(v32).view(v.size(0), self._n_pref, self._d_embed)
171
+ else:
172
+ extra = self.prefix_embeds.unsqueeze(0).expand(v.size(0), -1, -1)
173
+ return torch.cat([inject, extra], dim=1).to(self._backbone_dtype)
174
+
175
+ @property
176
+ def prefix_length(self) -> int:
177
+ return self._n_inject + self._n_pref
178
+
179
+ # ───────────────────────── generation ───────────────────────────
180
+
181
+ @torch.no_grad()
182
+ def generate(
183
+ self,
184
+ v: torch.Tensor,
185
+ max_new_tokens: int | None = None,
186
+ do_sample: bool = True,
187
+ temperature: float | None = None,
188
+ top_p: float | None = None,
189
+ ) -> torch.Tensor:
190
+ """Return generated token ids ``(B, T_new)`` (without the prefix)."""
191
+ inputs_embeds = self._inject_prefix(v)
192
+ attn = torch.ones(inputs_embeds.shape[:2], dtype=torch.long, device=inputs_embeds.device)
193
+ out = self.backbone.generate(
194
+ inputs_embeds=inputs_embeds,
195
+ attention_mask=attn,
196
+ max_new_tokens=max_new_tokens or self.cfg.max_new_tokens,
197
+ do_sample=do_sample,
198
+ temperature=temperature if temperature is not None else self.cfg.temperature,
199
+ top_p=top_p if top_p is not None else self.cfg.top_p,
200
+ pad_token_id=self.tokenizer.pad_token_id or self.tokenizer.eos_token_id,
201
+ )
202
+ # When inputs_embeds is used, HF returns only the new tokens.
203
+ return out
204
+
205
+ @torch.no_grad()
206
+ def verbalize(
207
+ self,
208
+ v: torch.Tensor,
209
+ **gen_kwargs,
210
+ ) -> list[str]:
211
+ """Convenience: vectors → text strings."""
212
+ ids = self.generate(v, **gen_kwargs)
213
+ return self.tokenizer.batch_decode(ids, skip_special_tokens=True)
srt/training/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """SRT training utilities."""
srt/training/losses.py ADDED
@@ -0,0 +1,560 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Semiotic loss functions for SRT Adapter.
2
+
3
+ All losses operate on the adapter's intermediate outputs — divergence vectors,
4
+ meta-state, r̂, regime, community assignments. The CE loss comes directly from
5
+ the backbone's native LM head and is not computed here.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+
12
+ import torch
13
+ import torch.nn.functional as F
14
+
15
+ from srt.adapter import SRTAdapterOutput
16
+ from srt.config import LossConfig
17
+
18
+
19
+ def chain_loss(
20
+ divergences: list[torch.Tensor],
21
+ chain_predictor: torch.nn.Module,
22
+ attention_mask: torch.Tensor | None = None,
23
+ ) -> torch.Tensor:
24
+ """Self-supervised chain-of-interpretants loss.
25
+
26
+ Each MAH layer's divergence should be predictable from the previous layer's
27
+ divergence. This is Peirce's chain of interpretants: each interpretation
28
+ leads to the next in a predictable way when meaning is stable.
29
+
30
+ Args:
31
+ divergences: list of (B, T, d_div) tensors from successive MAH layers.
32
+ chain_predictor: nn.Linear that predicts next divergence from current.
33
+ attention_mask: (B, T) padding mask (1 = real, 0 = pad). Optional.
34
+
35
+ Returns:
36
+ Scalar loss.
37
+ """
38
+ if len(divergences) < 2:
39
+ return torch.tensor(0.0, device=divergences[0].device)
40
+
41
+ loss = torch.tensor(0.0, device=divergences[0].device)
42
+ for i in range(len(divergences) - 1):
43
+ pred = chain_predictor(divergences[i])
44
+ target = divergences[i + 1].detach()
45
+ per_pos = (pred - target).pow(2).mean(dim=-1) # (B, T)
46
+ if attention_mask is not None:
47
+ mask = attention_mask.to(per_pos.dtype)
48
+ loss = loss + (per_pos * mask).sum() / mask.sum().clamp(min=1)
49
+ else:
50
+ loss = loss + per_pos.mean()
51
+ return loss / (len(divergences) - 1)
52
+
53
+
54
+ def bifurcation_loss(
55
+ r_hat: torch.Tensor,
56
+ r_true: torch.Tensor,
57
+ r_mask: torch.Tensor,
58
+ ) -> torch.Tensor:
59
+ """Smooth L1 loss between predicted and log-compressed true reflexivity.
60
+
61
+ r_true spans [0, ~13] but BEN outputs Tanh ∈ [-1, 1]. Log-compressing
62
+ the target via sign(r) * log(1 + |r|) maps the bulk of r_true into
63
+ roughly [-1.5, 1.5], much better aligned with the output range.
64
+
65
+ Uses mild focal weighting (1.0×) for supercritical tokens.
66
+
67
+ Args:
68
+ r_hat: (B, T) predicted reflexivity from BEN.
69
+ r_true: (B, T) ground-truth reflexivity.
70
+ r_mask: (B, T) bool mask — True where r_true is valid.
71
+
72
+ Returns:
73
+ Scalar loss.
74
+ """
75
+ if r_mask.sum() == 0:
76
+ return torch.tensor(0.0, device=r_hat.device)
77
+
78
+ r_hat_masked = r_hat[r_mask]
79
+ r_true_masked = r_true[r_mask]
80
+
81
+ # Log-compress targets to match BEN's Tanh output range
82
+ r_true_compressed = r_true_masked.sign() * (1.0 + r_true_masked.abs()).log()
83
+
84
+ # Mild focal weighting on compressed scale
85
+ focal_weight = 1.0 + 1.0 * r_true_compressed.abs()
86
+ diff = F.smooth_l1_loss(r_hat_masked, r_true_compressed, reduction="none")
87
+ return (diff * focal_weight).mean()
88
+
89
+
90
+ def regime_loss(
91
+ regime_logits: torch.Tensor,
92
+ r_true: torch.Tensor,
93
+ r_mask: torch.Tensor,
94
+ ) -> torch.Tensor:
95
+ """Cross-entropy loss for regime classification.
96
+
97
+ Regime is derived from r_true:
98
+ - subcritical (class 0): r_true <= 0
99
+ - supercritical (class 1): r_true > 0
100
+
101
+ Args:
102
+ regime_logits: (B, T, 2) from BEN.
103
+ r_true: (B, T) ground-truth reflexivity.
104
+ r_mask: (B, T) bool mask.
105
+
106
+ Returns:
107
+ Scalar loss.
108
+ """
109
+ if r_mask.sum() == 0:
110
+ return torch.tensor(0.0, device=regime_logits.device)
111
+
112
+ regime_targets = (r_true > 0).long() # (B, T)
113
+ logits_masked = regime_logits[r_mask] # (N, 2)
114
+ targets_masked = regime_targets[r_mask] # (N,)
115
+ return F.cross_entropy(logits_masked, targets_masked)
116
+
117
+
118
+ def divergence_alive_loss(
119
+ divergences: list[torch.Tensor],
120
+ attention_mask: torch.Tensor | None = None,
121
+ ) -> torch.Tensor:
122
+ """Prevent divergence vectors from collapsing to zero.
123
+
124
+ Encourages divergence norms to stay near a target value (1.0).
125
+
126
+ Args:
127
+ divergences: list of (B, T, d_div) tensors.
128
+ attention_mask: (B, T) padding mask (1 = real, 0 = pad). Optional.
129
+
130
+ Returns:
131
+ Scalar loss.
132
+ """
133
+ if not divergences:
134
+ return torch.tensor(0.0, device=divergences[0].device if divergences else "cpu")
135
+
136
+ total = torch.tensor(0.0, device=divergences[0].device)
137
+ for d in divergences:
138
+ norms = d.norm(dim=-1) # (B, T)
139
+ if attention_mask is not None:
140
+ mask = attention_mask.to(norms.dtype)
141
+ mean_norm = (norms * mask).sum() / mask.sum().clamp(min=1)
142
+ else:
143
+ mean_norm = norms.mean()
144
+ total = total + (1.0 - mean_norm).abs()
145
+ return total / len(divergences)
146
+
147
+
148
+ def injection_regularization(
149
+ injections: list[torch.Tensor],
150
+ attention_mask: torch.Tensor | None = None,
151
+ target_norm: float = 1.0,
152
+ ) -> torch.Tensor:
153
+ """Penalize injection norms that deviate from a target.
154
+
155
+ Uses (||inj|| - target)^2 per position, which pulls norms toward the
156
+ target rather than toward zero. This lets the RRM contribute useful
157
+ signal at norm ~1 while strongly penalizing the 6-8 norms seen in v2.
158
+
159
+ Args:
160
+ injections: list of (B, T, d_backbone) injection vectors.
161
+ attention_mask: (B, T) padding mask (1 = real, 0 = pad). Optional.
162
+ target_norm: desired L2 norm for injection vectors.
163
+
164
+ Returns:
165
+ Scalar loss.
166
+ """
167
+ if not injections:
168
+ return torch.tensor(0.0, device=injections[0].device if injections else "cpu")
169
+
170
+ total = torch.tensor(0.0, device=injections[0].device)
171
+ for inj in injections:
172
+ norms = inj.norm(dim=-1) # (B, T) — L2 norm per position
173
+ per_pos = (norms - target_norm).pow(2) # (B, T)
174
+ if attention_mask is not None:
175
+ mask = attention_mask.to(per_pos.dtype)
176
+ total = total + (per_pos * mask).sum() / mask.sum().clamp(min=1)
177
+ else:
178
+ total = total + per_pos.mean()
179
+ return total / len(injections)
180
+
181
+
182
+ def community_entropy_loss(community_weights: torch.Tensor) -> torch.Tensor:
183
+ """Encourage diverse community usage across the batch.
184
+
185
+ Maximizes entropy of the average community assignment distribution.
186
+ Without this, the model might collapse all inputs to one community.
187
+
188
+ Args:
189
+ community_weights: (B, K) soft community assignments.
190
+
191
+ Returns:
192
+ Scalar loss (lower = more diverse).
193
+ """
194
+ avg_dist = community_weights.mean(dim=0) # (K,)
195
+ entropy = -(avg_dist * (avg_dist + 1e-8).log()).sum()
196
+ max_entropy = math.log(community_weights.shape[-1])
197
+ return max_entropy - entropy
198
+
199
+
200
+ def community_supcon_loss(
201
+ community_vectors: torch.Tensor,
202
+ community_ids: torch.Tensor,
203
+ temperature: float = 0.1,
204
+ ) -> tuple[torch.Tensor, dict[str, float]]:
205
+ """Supervised contrastive loss on per-sample community vectors.
206
+
207
+ The v3 entropy regularizer kept the prototype distribution from collapsing
208
+ to a single mode but produced congruent collapse instead: pairwise cosine
209
+ similarity between learned prototypes converged to ~0.99 and the assignment
210
+ head learned to be near-uniform. SupCon (Khosla et al. 2020) gives the
211
+ encoder direct gradient pressure to put samples from the same source into
212
+ a tight neighborhood and push different sources apart, which forces
213
+ prototypes to diversify because that is the only way to satisfy the loss.
214
+
215
+ v5 note: pass `encoded` (pre-mixing) rather than `vector` here. When the
216
+ assignment head collapses, `vector ≈ prototype[k*]` is constant across
217
+ the batch and this loss is identically `log(B-1)` with zero gradient.
218
+
219
+ Args:
220
+ community_vectors: (B, d) per-sample vectors to contrast.
221
+ community_ids: (B,) integer ids — same id = positive pair.
222
+ temperature: softmax temperature.
223
+
224
+ Returns:
225
+ (loss, diagnostics) where diagnostics carries the number of positive
226
+ pairs and unique classes seen this batch (for sanity checks).
227
+ """
228
+ B = community_vectors.shape[0]
229
+ device = community_vectors.device
230
+ n_unique = int(community_ids.unique().numel())
231
+ if B < 2:
232
+ return torch.tensor(0.0, device=device), {
233
+ "pos_pairs": 0.0, "unique_classes": float(n_unique),
234
+ }
235
+
236
+ # Cast to float32 for numerical stability of the contrastive softmax;
237
+ # the input vector may be bf16 because the adapter modules run in bf16.
238
+ z = F.normalize(community_vectors.float(), dim=-1)
239
+ sim = (z @ z.T) / temperature # (B, B)
240
+
241
+ # Mask: True where i != j and ids match
242
+ eye = torch.eye(B, dtype=torch.bool, device=z.device)
243
+ pos_mask = (community_ids.view(-1, 1) == community_ids.view(1, -1)) & ~eye
244
+
245
+ # If no positive pairs in this batch, loss is zero (skip rather than NaN)
246
+ pos_count = pos_mask.sum(dim=1)
247
+ n_pos_pairs = float(pos_mask.sum().item())
248
+ if pos_count.sum() == 0:
249
+ return torch.tensor(0.0, device=device), {
250
+ "pos_pairs": 0.0, "unique_classes": float(n_unique),
251
+ }
252
+
253
+ # Mask self-similarity from denominator
254
+ sim = sim.masked_fill(eye, float("-inf"))
255
+ log_prob = sim - torch.logsumexp(sim, dim=1, keepdim=True) # (B, B)
256
+ # Diagonal entries are -inf; zero them so they don't contaminate the
257
+ # masked sum below (0 * -inf = NaN otherwise).
258
+ log_prob = log_prob.masked_fill(eye, 0.0)
259
+
260
+ # Per-anchor average log-prob over its positives; rows with no positives
261
+ # contribute 0 (avoid divide-by-zero with clamp).
262
+ per_anchor = -(log_prob * pos_mask.float()).sum(dim=1) / pos_count.clamp(min=1).float()
263
+ valid = pos_count > 0
264
+ if not valid.any():
265
+ return torch.tensor(0.0, device=device), {
266
+ "pos_pairs": 0.0, "unique_classes": float(n_unique),
267
+ }
268
+ return per_anchor[valid].mean(), {
269
+ "pos_pairs": n_pos_pairs, "unique_classes": float(n_unique),
270
+ }
271
+
272
+
273
+ def archetype_supcon_loss(
274
+ community_vectors: torch.Tensor,
275
+ archetype_ids: torch.Tensor,
276
+ temperature: float = 0.1,
277
+ ) -> tuple[torch.Tensor, dict[str, float]]:
278
+ """Supervised contrastive loss keyed by archetype_id.
279
+
280
+ v9 addition. Uses the same supcon kernel as community_supcon_loss but
281
+ masks out anchors with archetype_id == -1 (Reddit-corpus rows that
282
+ carry no archetype label). Designed to be applied to the same
283
+ `community_output.encoded` representation that community_supcon
284
+ operates on, so both signals shape the same encoder geometry.
285
+
286
+ Args:
287
+ community_vectors: (B, d) per-sample encoder output.
288
+ archetype_ids: (B,) ints in [1, 33] for archetype rows, -1 for
289
+ Reddit rows. Anchors with id == -1 are dropped.
290
+ temperature: softmax temperature.
291
+
292
+ Returns:
293
+ (loss, diagnostics).
294
+ """
295
+ device = community_vectors.device
296
+ valid = archetype_ids >= 0
297
+ n_valid = int(valid.sum().item())
298
+ if n_valid < 2:
299
+ return torch.tensor(0.0, device=device), {
300
+ "pos_pairs": 0.0, "unique_classes": 0.0, "n_valid": float(n_valid),
301
+ }
302
+ sub_vec = community_vectors[valid]
303
+ sub_ids = archetype_ids[valid]
304
+ loss, diag = community_supcon_loss(
305
+ sub_vec, sub_ids, temperature=temperature,
306
+ )
307
+ diag["n_valid"] = float(n_valid)
308
+ return loss, diag
309
+
310
+
311
+ def divergence_supcon_loss(
312
+ divergences: list[torch.Tensor],
313
+ community_ids: torch.Tensor,
314
+ attention_mask: torch.Tensor | None = None,
315
+ temperature: float = 0.1,
316
+ ) -> tuple[torch.Tensor, dict[str, float]]:
317
+ """Supervised contrastive loss on per-sample mean divergence vectors.
318
+
319
+ v6 extension of the v5 community-SupCon idea applied to MAH divergence:
320
+ the last MAH layer's divergence is mean-pooled (masked) to a per-sample
321
+ vector, then contrasted by community id. Same lesson as v5 — operate on
322
+ a representation that is bijective with the encoder input rather than on
323
+ a quantity that can collapse to a constant across the batch.
324
+
325
+ Args:
326
+ divergences: list of (B, T, d_div) tensors from successive MAH layers.
327
+ The last entry is used as the contrastive representation.
328
+ community_ids: (B,) integer ids — same id = positive pair.
329
+ attention_mask: (B, T) padding mask (1 = real, 0 = pad). Optional.
330
+ temperature: softmax temperature.
331
+
332
+ Returns:
333
+ (loss, diagnostics).
334
+ """
335
+ if not divergences:
336
+ device = community_ids.device if community_ids is not None else "cpu"
337
+ return torch.tensor(0.0, device=device), {
338
+ "pos_pairs": 0.0, "unique_classes": 0.0,
339
+ }
340
+ div = divergences[-1] # (B, T, d_div)
341
+ if attention_mask is not None:
342
+ m = attention_mask.to(div.dtype).unsqueeze(-1) # (B, T, 1)
343
+ pooled = (div * m).sum(dim=1) / m.sum(dim=1).clamp(min=1)
344
+ else:
345
+ pooled = div.mean(dim=1)
346
+ # Reuse the same SupCon kernel as community_supcon_loss.
347
+ return community_supcon_loss(pooled, community_ids, temperature=temperature)
348
+
349
+
350
+ def listnet_loss(
351
+ r_hat: torch.Tensor,
352
+ r_true: torch.Tensor,
353
+ r_mask: torch.Tensor,
354
+ temperature: float = 1.0,
355
+ ) -> torch.Tensor:
356
+ """ListNet ranking loss for r̂ over each sequence.
357
+
358
+ Cross-entropy between softmax(r_true) and softmax(r_hat) treated as
359
+ rankings over the valid positions of each sequence. Complements the
360
+ pointwise smooth-L1 bifurcation loss by giving direct gradient on the
361
+ *ordering* of r̂ within a passage, which is what downstream uses
362
+ (top-k attention probes, heatmap visualization, percentile thresholds).
363
+
364
+ Args:
365
+ r_hat: (B, T) predicted reflexivity.
366
+ r_true: (B, T) ground-truth reflexivity (same scale).
367
+ r_mask: (B, T) bool mask.
368
+ temperature: softmax temperature.
369
+
370
+ Returns:
371
+ Scalar loss averaged over sequences with >=2 valid positions.
372
+ """
373
+ B, T = r_hat.shape
374
+ device = r_hat.device
375
+ losses: list[torch.Tensor] = []
376
+ # Compress true reflexivity to match r_hat scale (same transform as bif loss).
377
+ r_true_c = r_true.sign() * (1.0 + r_true.abs()).log()
378
+ for b in range(B):
379
+ m = r_mask[b]
380
+ n = int(m.sum())
381
+ if n < 2:
382
+ continue
383
+ rh = r_hat[b][m].float() / temperature
384
+ rt = r_true_c[b][m].float() / temperature
385
+ # Softmax over the valid positions; mask out -inf done implicitly
386
+ # because we only index the valid slice.
387
+ log_p_hat = rh - torch.logsumexp(rh, dim=0)
388
+ p_true = torch.softmax(rt, dim=0)
389
+ losses.append(-(p_true * log_p_hat).sum())
390
+ if not losses:
391
+ return torch.tensor(0.0, device=device)
392
+ return torch.stack(losses).mean()
393
+
394
+
395
+ def chain_residual_aux_loss(
396
+ chain_residual: torch.Tensor,
397
+ attention_mask: torch.Tensor | None = None,
398
+ target: float = 0.5,
399
+ ) -> torch.Tensor:
400
+ """Auxiliary penalty pulling mean per-token chain residual toward a target.
401
+
402
+ The chain prediction loss already minimizes the residual directly, but
403
+ that signal is averaged across all positions and dimensions. This term
404
+ keeps the per-token residual at a non-trivial level so it remains a
405
+ useful inference-time signal (used by the hallucination probe) rather
406
+ than collapsing to ~0 everywhere as training proceeds. With a small
407
+ weight this acts as a soft floor, not a primary objective.
408
+
409
+ Args:
410
+ chain_residual: (B, T) per-token mean chain residual.
411
+ attention_mask: (B, T) padding mask.
412
+ target: desired mean residual on real tokens.
413
+
414
+ Returns:
415
+ Scalar loss.
416
+ """
417
+ if attention_mask is not None:
418
+ m = attention_mask.to(chain_residual.dtype)
419
+ mean = (chain_residual * m).sum() / m.sum().clamp(min=1)
420
+ else:
421
+ mean = chain_residual.mean()
422
+ return (mean - target).pow(2)
423
+
424
+
425
+ def compute_total_loss(
426
+ output: SRTAdapterOutput,
427
+ chain_predictor: torch.nn.Module,
428
+ r_true: torch.Tensor | None,
429
+ r_mask: torch.Tensor | None,
430
+ config: LossConfig,
431
+ attention_mask: torch.Tensor | None = None,
432
+ community_ids: torch.Tensor | None = None,
433
+ archetype_ids: torch.Tensor | None = None,
434
+ ) -> tuple[torch.Tensor, dict[str, float]]:
435
+ """Compute combined loss from adapter output.
436
+
437
+ Args:
438
+ output: SRTAdapterOutput from adapter forward pass.
439
+ chain_predictor: the chain predictor module from the adapter.
440
+ r_true: (B, T) ground-truth reflexivity, or None.
441
+ r_mask: (B, T) bool mask for valid r_true positions, or None.
442
+ config: LossConfig with weights.
443
+ attention_mask: (B, T) padding mask (1 = real, 0 = pad). Optional.
444
+
445
+ Returns:
446
+ (total_loss, metrics_dict) where metrics_dict has per-component values.
447
+ """
448
+ device = output.logits.device
449
+ metrics: dict[str, float] = {}
450
+ total = torch.tensor(0.0, device=device)
451
+
452
+ # CE loss (from backbone)
453
+ if output.ce_loss is not None:
454
+ total = total + config.ce_weight * output.ce_loss
455
+ metrics["ce"] = output.ce_loss.item()
456
+
457
+ # Chain-of-interpretants loss
458
+ if output.divergences:
459
+ l_chain = chain_loss(output.divergences, chain_predictor, attention_mask)
460
+ total = total + config.chain_weight * l_chain
461
+ metrics["chain"] = l_chain.item()
462
+
463
+ # Bifurcation + regime losses
464
+ if output.ben_output is not None and r_true is not None and r_mask is not None:
465
+ l_bif = bifurcation_loss(output.ben_output.r_hat, r_true, r_mask)
466
+ total = total + config.bif_weight * l_bif
467
+ metrics["bif"] = l_bif.item()
468
+
469
+ l_regime = regime_loss(output.ben_output.regime_logits, r_true, r_mask)
470
+ total = total + config.regime_weight * l_regime
471
+ metrics["regime"] = l_regime.item()
472
+
473
+ # v6: ListNet ranking loss on r̂ within each sequence.
474
+ if config.listnet_weight > 0:
475
+ l_listnet = listnet_loss(
476
+ output.ben_output.r_hat, r_true, r_mask,
477
+ temperature=config.listnet_temperature,
478
+ )
479
+ total = total + config.listnet_weight * l_listnet
480
+ metrics["listnet"] = l_listnet.item()
481
+
482
+ # Divergence alive
483
+ if output.divergences:
484
+ l_alive = divergence_alive_loss(output.divergences, attention_mask)
485
+ total = total + config.div_alive_weight * l_alive
486
+ metrics["div_alive"] = l_alive.item()
487
+
488
+ # v6: SupCon on mean-pooled last-MAH divergence.
489
+ if (community_ids is not None
490
+ and config.divergence_supcon_weight > 0):
491
+ l_div_sup, div_sup_diag = divergence_supcon_loss(
492
+ output.divergences, community_ids,
493
+ attention_mask=attention_mask,
494
+ temperature=config.divergence_supcon_temperature,
495
+ )
496
+ total = total + config.divergence_supcon_weight * l_div_sup
497
+ metrics["div_supcon"] = l_div_sup.item()
498
+ metrics["div_supcon_pos_pairs"] = div_sup_diag["pos_pairs"]
499
+
500
+ # v6: chain-residual auxiliary floor (keeps inference signal alive).
501
+ if (output.chain_residual_per_token is not None
502
+ and config.chain_residual_aux_weight > 0):
503
+ l_chain_aux = chain_residual_aux_loss(
504
+ output.chain_residual_per_token,
505
+ attention_mask=attention_mask,
506
+ target=config.chain_residual_aux_target,
507
+ )
508
+ total = total + config.chain_residual_aux_weight * l_chain_aux
509
+ metrics["chain_aux"] = l_chain_aux.item()
510
+
511
+ # Injection regularization (target-norm penalty)
512
+ if output.injections:
513
+ l_inject = injection_regularization(
514
+ output.injections, attention_mask, target_norm=config.inject_target_norm,
515
+ )
516
+ total = total + config.inject_reg_weight * l_inject
517
+ metrics["inject_reg"] = l_inject.item()
518
+
519
+ # Community entropy (only meaningful when there is a soft assignment)
520
+ if output.community_output is not None:
521
+ if output.community_output.weights is not None:
522
+ l_comm = community_entropy_loss(output.community_output.weights)
523
+ total = total + config.community_entropy_weight * l_comm
524
+ metrics["comm_entropy"] = l_comm.item()
525
+
526
+ # Community SupCon (v5 — applied to pre-mixing encoder output, not
527
+ # the prototype-weighted vector. The vector is a convex combination
528
+ # of prototypes; if the assignment head collapses to one prototype
529
+ # then `vector` becomes constant across the batch and SupCon's
530
+ # gradient is zero by symmetry — this is exactly what killed v4.
531
+ # `encoded` is the encoder's bijective image of the pooled hidden
532
+ # state, so it always varies per-sample, giving SupCon non-zero
533
+ # gradient even from a degenerate warm-start.)
534
+ if community_ids is not None and config.community_supcon_weight > 0:
535
+ l_supcon, supcon_diag = community_supcon_loss(
536
+ output.community_output.encoded,
537
+ community_ids,
538
+ temperature=config.community_supcon_temperature,
539
+ )
540
+ total = total + config.community_supcon_weight * l_supcon
541
+ metrics["comm_supcon"] = l_supcon.item()
542
+ metrics["comm_supcon_pos_pairs"] = supcon_diag["pos_pairs"]
543
+ metrics["comm_supcon_unique_classes"] = supcon_diag["unique_classes"]
544
+
545
+ # v9: archetype-keyed SupCon on the same encoder representation.
546
+ # Skipped silently if no archetype rows are in the batch (n_valid<2).
547
+ if (archetype_ids is not None
548
+ and config.archetype_supcon_weight > 0):
549
+ l_arch, arch_diag = archetype_supcon_loss(
550
+ output.community_output.encoded,
551
+ archetype_ids,
552
+ temperature=config.archetype_supcon_temperature,
553
+ )
554
+ total = total + config.archetype_supcon_weight * l_arch
555
+ metrics["arch_supcon"] = l_arch.item()
556
+ metrics["arch_supcon_pos_pairs"] = arch_diag["pos_pairs"]
557
+ metrics["arch_supcon_n_valid"] = arch_diag["n_valid"]
558
+
559
+ metrics["total"] = total.item()
560
+ return total, metrics