Spaces:
Sleeping
Sleeping
initial upload: NLA demo (playground/trace/steer)
Browse files- README.md +13 -7
- demo/README.md +96 -0
- demo/app.py +275 -0
- demo/nla_pipeline.py +370 -0
- demo/requirements.txt +9 -0
- requirements.txt +9 -0
- srt/__init__.py +28 -0
- srt/adapter.py +316 -0
- srt/config.py +155 -0
- srt/data/__init__.py +1 -0
- srt/data/dataset.py +217 -0
- srt/modules/__init__.py +16 -0
- srt/modules/ben.py +71 -0
- srt/modules/community.py +116 -0
- srt/modules/mah.py +109 -0
- srt/modules/rrm.py +109 -0
- srt/modules/thead.py +193 -0
- srt/nla/__init__.py +54 -0
- srt/nla/config.py +76 -0
- srt/nla/decoding.py +249 -0
- srt/nla/loss.py +143 -0
- srt/nla/metrics.py +89 -0
- srt/nla/reconstructor.py +122 -0
- srt/nla/sidecar.py +56 -0
- srt/nla/targets_check.py +75 -0
- srt/nla/verbalizer.py +213 -0
- srt/training/__init__.py +1 -0
- srt/training/losses.py +560 -0
README.md
CHANGED
|
@@ -1,13 +1,19 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: pink
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
|
| 9 |
-
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|