File size: 14,580 Bytes
3f2601a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
#!/usr/bin/env python3
"""tiny_nla demo — one entry point, no model to choose.

    pip install torch transformers accelerate huggingface_hub numpy pyyaml
    hf auth login
    hf download ASSERT-KTH/tiny-nla nla_demo.py --local-dir .

    python nla_demo.py --mode code 'def gcd(a, b):
        while b: a, b = b, a % b
        return a'                              # -> reconstructs the function

    python nla_demo.py --mode text 'The Federal Reserve announced yesterday'
                                               # -> explains the activation

    python nla_demo.py --mode code --file mymodule.py --control

Compresses the input to ONE 4096-float activation vector from a frozen
Qwen3-8B, then asks a trained decoder to write it back out. YOU pick the mode;
the CHECKPOINT is chosen for you -- always whatever currently sits at the
pinned Hub paths, with no flag to select an older one.

Needs one GPU with >=24 GB. Models load sequentially, not together.
"""
import argparse, ast, builtins, difflib, keyword, os, re, shutil, subprocess, sys, tempfile
from pathlib import Path

# HF_HOME must be set BEFORE huggingface_hub is imported -- it snapshots the
# cache location at import time, so doing this inside main() is too late. When
# the user has not chosen one, prefer this repo's cache: the weights are very
# likely already there, and the default (~/.cache) would re-download ~33 GB
# into a home directory that may not have the quota for it.
if not os.environ.get("HF_HOME"):
    _repo_cache = Path(__file__).resolve().parent.parent / "cache" / "huggingface"
    if _repo_cache.is_dir():
        os.environ["HF_HOME"] = str(_repo_cache)

import numpy as np, torch, transformers, yaml
from huggingface_hub import hf_hub_download
from transformers import AutoModelForCausalLM, AutoTokenizer

REPO = "ASSERT-KTH/tiny-nla"
CODE_SUB, TEXT_SUB = "code-decoder", "nla/actor"
BASE = "Qwen/Qwen3-8B"
# Verified per mode by A/B (job 17444219) -- do NOT "unify" these.
#   text + minimal  -> emits <answer>, drifts off-topic (regex misses, raw falls through)
#   code + sidecar  -> ident recall 0.00: writes a function ABOUT activation vectors,
#                      following the preamble's subject instead of the injected vector
#   the winners     -> code 1.00 recall / AST 0.862 ; text = correct 2-3 snippet format
# Each checkpoint wants the prompt IT was trained on. code-decoder's sidecar
# prompt_templates is a stale dataset-builder default and does not record that.
PROMPT_MODE = {"code": "minimal", "text": "sidecar"}

# transformers 5.x renamed torch_dtype -> dtype and warns on the old name; 4.x
# does not know the new one. An unpinned `pip install transformers` gets 5.x.
_DT = ({"dtype": torch.bfloat16}
       if int(transformers.__version__.split(".")[0]) >= 5
       else {"torch_dtype": torch.bfloat16})
CODE_RE = re.compile(r"<code>\s*(.*?)\s*</code>", re.DOTALL)
EXPL_RE = re.compile(r"<explanation>\s*(.*?)\s*</explanation>", re.DOTALL)
STOP = set(keyword.kwlist) | set(dir(builtins)) | {"self", "cls"}


def inject_at_marked_positions(input_ids, embeddings, vectors, inj_id, left_id, right_id):
    """Verbatim from nla/training/injection.py. The neighbour check prevents
    false positives; if injection silently fails the model emits Chinese."""
    out = embeddings.clone()
    vectors = vectors.to(out.device, out.dtype)
    n, k = input_ids.shape[-1], 0
    for b, p in (input_ids == inj_id).nonzero(as_tuple=False).tolist():
        if p == 0 or p == n - 1:
            continue
        if input_ids[b, p - 1] != left_id or input_ids[b, p + 1] != right_id:
            continue
        out[b, p] = vectors[k]; k += 1
    if k != vectors.shape[0]:
        raise RuntimeError(f"found {k} injection sites, expected {vectors.shape[0]}")
    return out


def normalize_activation(v, scale):
    return v / (v.float().norm(dim=-1, keepdim=True).clamp_min(1e-12) / scale).to(v.dtype)


def scratch_dir():
    """A writable scratch directory that is never /tmp.

    /tmp is off limits on this cluster and is node-local anyway, but both
    tempfile and triton default there. Honour an explicit TMPDIR when it does
    not point into /tmp, else fall back to the user's cache dir.
    """
    for cand in (os.environ.get("TMPDIR"), os.environ.get("XDG_CACHE_HOME")):
        if cand and not os.path.realpath(cand).startswith("/tmp"):
            d = os.path.join(cand, "tiny_nla")
            try:
                os.makedirs(d, exist_ok=True)
                return d
            except OSError:
                pass
    d = os.path.join(os.path.expanduser("~"), ".cache", "tiny_nla")
    os.makedirs(d, exist_ok=True)
    return d


def ensure_compiler():
    """Pick a C compiler that actually works, before torch needs one.

    Triton JIT-compiles a CUDA helper on first GPU use and shells out to `cc`.
    On NSC/Berzelius the gcc first on PATH is a wrapper that refuses to run
    without a build-env module loaded; the refusal surfaces as a
    CalledProcessError fifteen frames deep inside triton, naming a .c file the
    user never wrote. Probe candidates once and export the first that links.
    Costs one ~100ms compile, and is skipped entirely if CC is already set.
    """
    if os.environ.get("CC"):
        return
    with tempfile.TemporaryDirectory(dir=scratch_dir()) as d:
        src = os.path.join(d, "probe.c")
        with open(src, "w") as f:
            f.write("int main(){return 0;}")
        for cand in (shutil.which("cc"), shutil.which("gcc"), "/usr/bin/gcc"):
            if not cand or not os.path.exists(cand):
                continue
            try:
                r = subprocess.run([cand, "-shared", "-fPIC", "-o",
                                    os.path.join(d, "probe.so"), src],
                                   capture_output=True, timeout=60)
            except Exception:
                continue
            if r.returncode == 0:
                os.environ["CC"] = cand
                return


def parses(s):
    try:
        ast.parse(s); return True
    except SyntaxError:
        return False


# Keywords of the curly-brace languages, so a lexical scan of non-Python source
# does not report `int` or `return` as recovered identifiers.
OTHER_KW = {"int", "float", "double", "char", "bool", "void", "long", "short",
            "unsigned", "signed", "const", "static", "struct", "class", "public",
            "private", "protected", "return", "if", "else", "for", "while", "do",
            "switch", "case", "break", "continue", "new", "delete", "using",
            "namespace", "template", "typename", "include", "std", "auto", "let",
            "var", "function", "def", "fn", "pub", "impl", "final", "extends"}


def idents(src):
    """Identifiers in `src`, Python-aware when possible, lexical otherwise."""
    try:
        t = ast.parse(src)
    except SyntaxError:
        return {w for w in re.findall(r"[A-Za-z_][A-Za-z0-9_]*", src)
                if w not in STOP and w not in OTHER_KW and not w.startswith("__")}
    return {v for n in ast.walk(t) for a in ("id", "arg", "name", "attr")
            if isinstance(v := getattr(n, a, None), str) and v not in STOP and not v.startswith("__")}


def main():
    p = argparse.ArgumentParser(description="tiny_nla demo — compress to one vector, decode it back")
    p.add_argument("--prompt", choices=["minimal", "sidecar"], default=None,
                   help="override the injection prompt (diagnostic; default is per-mode verified)")
    p.add_argument("--mode", required=True, choices=["code", "text"],
                   help="code = reconstruct a Python function; text = explain the activation")
    p.add_argument("input", nargs="?", help="Python function, or any text")
    p.add_argument("--file", help="read the input from a file instead")
    p.add_argument("--control", action="store_true",
                   help="also decode a RANDOM vector — what the model produces with no information")
    p.add_argument("--max-new-tokens", type=int, default=256)
    p.add_argument("--device", default="cuda")
    a = p.parse_args()
    if not a.input and not a.file:
        p.error("give some input, or --file")
    raw = Path(a.file).read_text() if a.file else a.input

    code_mode = a.mode == "code"
    if code_mode and parses(raw):
        raw = ast.unparse(ast.parse(raw))          # canonicalise so formatting is not counted
    elif code_mode:
        # Not Python: encode and decode it anyway. Only the AST-based scoring
        # needs a parse, so it degrades rather than refusing. The DECODER was
        # trained on Python only, so treat any other language as exploratory.
        print("note: input is not valid Python -- skipping canonicalisation and "
              "AST scoring. The decoder is Python-trained; other languages are "
              "out of distribution.")
    # Keep triton's JIT scratch and every tempfile default off /tmp.
    _sd = scratch_dir()
    if os.path.realpath(os.environ.get("TMPDIR", "/tmp")).startswith("/tmp"):
        os.environ["TMPDIR"] = _sd
    tempfile.tempdir = _sd
    os.environ.setdefault("TRITON_CACHE_DIR", os.path.join(_sd, "triton"))
    os.environ.setdefault("TORCHINDUCTOR_CACHE_DIR", os.path.join(_sd, "inductor"))
    ensure_compiler()
    sub = CODE_SUB if code_mode else TEXT_SUB
    print(f"mode: {'CODE reconstruction' if code_mode else 'TEXT explanation'}   "
          f"(checkpoint chosen automatically: {REPO}/{sub})")

    try:
        sc = yaml.safe_load(open(hf_hub_download(REPO, f"{sub}/nla_meta.yaml")))
    except Exception as e:
        if "401" in str(e) or "Repository Not Found" in str(e):
            sys.exit(
                f"cannot read {REPO} (401).\n"
                f"  HF_HOME = {os.environ.get('HF_HOME', '<unset, using ~/.cache/huggingface>')}\n"
                f"  token   = {'found' if os.path.exists(os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'token')) else 'NOT FOUND in that HF_HOME'}\n"
                f"  The usual cause is HF_HOME pointing at a cache with no token,\n"
                f"  not a missing account. Point it at the cache you logged into:\n"
                f"      export HF_HOME=/path/to/cache/huggingface\n"
                f"  Otherwise run `hf auth login`, and ask the repo owner for access.")
        raise
    meta = sc["tokens"]
    inj, L, R, ch = (meta["injection_token_id"], meta["injection_left_neighbor_id"],
                     meta["injection_right_neighbor_id"], meta["injection_char"])
    try:      # a centred decoder given a raw vector yields fluent, unrelated output
        mu = np.load(hf_hub_download(REPO, f"{sub}/centre_mean.npy")).reshape(-1)
    except Exception:
        mu = None

    tok = AutoTokenizer.from_pretrained(BASE)
    if tok.pad_token_id is None:
        tok.pad_token_id = tok.eos_token_id

    base = AutoModelForCausalLM.from_pretrained(
        BASE, **_DT, device_map={"": a.device}).eval()
    layer = (2 * len(base.model.layers)) // 3
    cap = {}
    h = base.model.layers[layer].register_forward_hook(
        lambda _m, _i, o: cap.__setitem__("h", (o[0] if isinstance(o, tuple) else o).detach()))
    with torch.no_grad():
        e = tok(raw, return_tensors="pt", truncation=True, max_length=1024)
        ids, m = e["input_ids"].to(a.device), e["attention_mask"].to(a.device)
        base(input_ids=ids, attention_mask=m, use_cache=False)
    h.remove()
    v = cap["h"][0, int(m.sum()) - 1].float().cpu().numpy()
    print(f"encoded {int(m.sum())} tokens at layer {layer}   ‖v‖={np.linalg.norm(v):.1f}"
          + (f"   centred (‖μ‖={np.linalg.norm(mu):.1f})" if mu is not None else ""))
    if mu is not None:
        v = v - mu
    del base; torch.cuda.empty_cache()

    actor = AutoModelForCausalLM.from_pretrained(
        REPO, subfolder=sub, **_DT, device_map={"": a.device}).eval()
    embed = actor.get_input_embeddings()

    # Which prompt a checkpoint wants is a property of how it was TRAINED, and the
    # sidecar's prompt_templates is a dataset-builder default that does not always
    # match. Both were measured; see PROMPT_MODE.
    want = a.prompt or PROMPT_MODE[a.mode]
    tmpl = (sc.get("prompt_templates") or {}).get("actor") if want == "sidecar" else None
    user_msg = tmpl.replace("{injection_char}", ch) if tmpl else f"<concept>{ch}</concept>"

    def gen(vec):
        bv = normalize_activation(torch.tensor(vec).unsqueeze(0), 300.0).to(a.device)
        s = tok.apply_chat_template([{"role": "user", "content": user_msg}],
                                    tokenize=False, add_generation_prompt=True)
        enc = tok(s, return_tensors="pt", add_special_tokens=False)
        i2, m2 = enc["input_ids"].to(a.device), enc["attention_mask"].to(a.device)
        hk = embed.register_forward_hook(
            lambda _m, i, o: o if o.shape[1] <= 1 else inject_at_marked_positions(i[0], o, bv, inj, L, R))
        try:
            with torch.no_grad():
                out = actor.generate(i2, attention_mask=m2, max_new_tokens=a.max_new_tokens,
                                     do_sample=False, pad_token_id=tok.pad_token_id,
                                     eos_token_id=tok.eos_token_id)
        finally:
            hk.remove()
        txt = tok.decode(out[0][i2.shape[1]:], skip_special_tokens=True)
        mm = (CODE_RE if code_mode else EXPL_RE).search(txt)
        return mm.group(1) if mm else txt.strip()[:600]

    bar = "=" * 70
    pred = gen(v)
    print(f"\n{bar}\nINPUT\n{bar}\n{raw[:600]}")
    print(f"{bar}\n{'RECONSTRUCTED' if code_mode else 'EXPLANATION'}\n{bar}\n{pred}")
    if code_mode and pred:
        pu, cu = idents(pred), idents(raw)
        def _shape(x):
            return [type(n).__name__ for n in ast.walk(ast.parse(x))]
        sk = (difflib.SequenceMatcher(None, _shape(pred), _shape(raw)).ratio()
              if parses(pred) and parses(raw) else None)
        print(bar)
        print(f"exact match       : {pred.strip() == raw.strip()}")
        print(f"AST-skeleton sim  : "
              + (f"{sk:.3f}   (~0.52 for two arbitrary functions)" if sk is not None
                 else "n/a   (needs both sides to be valid Python)"))
        print(f"identifier recall : {len(pu & cu)/max(len(cu),1):.2f}   shared: {sorted(pu & cu)[:8]}")
    if a.control:
        rng = np.random.default_rng(0)
        print(f"{bar}\nCONTROL — random vector, i.e. the model's prior with no information\n{bar}")
        print(gen(rng.normal(0, np.std(v), size=v.shape).astype(np.float32)))


if __name__ == "__main__":
    main()