File size: 11,933 Bytes
2f06744
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
#!/usr/bin/env python3
"""Forgetting training: after overwrite, queries must return the UPDATED value.

Protocol (CONTINUE.md step 2): sequences where 50% of keys appear twice (first
the original value, later an updated value).  The loss is on the query positions
AFTER the updates; the delta rule must learn that the LAST value wins.

Warm-started from checkpoints/capacity8k.pt (the capacity curriculum already
mixes 25% overwrite batches, so this run specialises an existing skill rather
than bootstrapping one).

Curriculum: 64 -> 256 -> 1024 -> 4096 pairs, overwrite fraction cycling through
{0.3, 0.5, 0.7}, both dense (f=0) and 131K-protocol (f=14) batches.  Final phase
trains the exact acceptance protocol (N=5,461, f=14, 50% overwrite) and quits
when the held-out probe (500 updated + 500 untouched queries) is >= 0.998 on
both query classes.
"""
from __future__ import annotations

import json
import os
import sys
import time

import torch
import torch.nn.functional as F

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.join(HERE, "..")
sys.path.insert(0, os.path.join(ROOT, "src"))

from mulattice.protocol import (SynthCfg, build_model, eval_batch, force_fast_scan, logits_at,
                      spread_loss, synth_batch)

DEVICE = os.environ.get("DEVICE", "cuda")
CFG = SynthCfg()
CKPT_WARM = os.environ.get("WARM", os.path.join(ROOT, "checkpoints", "capacity8k.pt"))
CKPT_OUT = os.path.join(ROOT, "checkpoints", "forgetting.pt")
HEARTBEAT = os.path.join(ROOT, "logs", "forgetting.heartbeat")
HISTORY = os.path.join(ROOT, "results", "forgetting_training.json")

LEVELS = [
    dict(pairs=64,   steps=1500),
    dict(pairs=256,  steps=2000),
    dict(pairs=1024, steps=2500),
    dict(pairs=4096, steps=4000),
]
FINAL = dict(pairs=5461, steps=6000, filler=14)   # the acceptance protocol
OW_CYCLE = [0.3, 0.5, 0.7, 0.5]
TOKEN_BUDGET = 20000
if os.environ.get("SMOKE"):            # fast local validation of the exact code path
    LEVELS = [dict(pairs=8, steps=5), dict(pairs=16, steps=5)]
    FINAL = dict(pairs=24, steps=6, filler=3)


def save(m, path, extra=None):
    # record the LIVE mixer scale (the FORK ramp mutates it; writing the stale
    # CFG value made scale-128 weights masquerade as scale 96 on reload)
    live = float(m.blocks[0].mixer.cfg.addr_scale)
    payload = {"model": m.state_dict(), "proto_cfg": {
        "NK": CFG.NK, "NV": CFG.NV, "d_model": CFG.d_model, "n_heads": CFG.n_heads,
        "d_k": CFG.d_k, "d_v": CFG.d_v, "addr_scale": live,
        "retention_init": CFG.retention_init}, "cfg": "forgetting overwrite curriculum"}
    if extra:
        payload.update(extra)
    torch.save(payload, path)


def held_out(m, pairs, filler, n_u=200, n_k=200, seed=777):
    """Acceptance-shaped probe: half queries on updated, half on untouched keys.

    Query counts are clamped to the pool sizes (n_u <= n_upd, n_k <= n_pairs-n_upd)."""
    g = torch.Generator().manual_seed(seed + pairs + filler)
    n_upd = pairs // 2
    perm = torch.randperm(pairs, generator=g)
    upd_idx = perm[:n_upd]
    upd_set = set(upd_idx.tolist())
    updated_pool = [i for i in perm.tolist() if i in upd_set]
    untouched_pool = [i for i in perm.tolist() if i not in upd_set]
    qpos = torch.tensor(updated_pool[:min(n_u, len(updated_pool))]
                        + untouched_pool[:min(n_k, len(untouched_pool))]).unsqueeze(0)
    b = synth_batch(1, pairs, qpos.shape[1], CFG, filler=filler, overwrite_frac=0.5,
                    generator=g, qpos=qpos, updated_idx=upd_idx.unsqueeze(0))
    return eval_batch(m, b, DEVICE)


def main():
    dev = DEVICE
    torch.manual_seed(0)
    m = build_model(CFG).to(dev)
    warm = CKPT_WARM if os.path.exists(CKPT_WARM) else os.path.join(
        ROOT, "checkpoints", "capacity8k_latest.pt")
    if os.path.exists(warm):
        ck = torch.load(warm, map_location="cpu", weights_only=False)
        m.load_state_dict(ck["model"])
        # restore the TRAINED grid scale (addr_scale is config, not weights).
        # Without this the forgetting lineage ran at scale 64 while its
        # capacity ancestor was trained at 96. At N=5,461 + 50% overwrites the
        # sequence stores 8,192 pair-blocks; at scale 64 this exceeds the
        # density ceiling (see LIMITATIONS.md).
        saved_scale = ck.get("proto_cfg", {}).get("addr_scale")
        if saved_scale:
            m.blocks[0].mixer.cfg.addr_scale = float(saved_scale)
            CFG.addr_scale = float(saved_scale)
        print(f"warm-start from {warm} (scale {m.blocks[0].mixer.cfg.addr_scale:.0f})",
              flush=True)
    else:
        print("WARNING: no warm-start checkpoint at all, training from scratch", flush=True)
    force_fast_scan(m)
    opt = torch.optim.AdamW(m.parameters(), lr=1e-3)
    sched = torch.optim.lr_scheduler.CosineAnnealingLR(
        opt, T_max=sum(l["steps"] for l in LEVELS) + FINAL["steps"], eta_min=1e-4)
    g = torch.Generator().manual_seed(2)
    step = 0
    t0 = time.time()
    history = []

    def train_steps(pairs, n_steps, filler_cycle, probe_f=None):
        nonlocal step
        good = 0
        for s in range(n_steps):
            ow = OW_CYCLE[step % len(OW_CYCLE)]
            f = filler_cycle[step % len(filler_cycle)]
            Q = 8 if step % 4 else 64
            T_est = int(pairs * (1 + ow) * (f + 2)) + 2 * Q
            B = max(1, min(16, TOKEN_BUDGET // T_est))
            b = synth_batch(B, pairs, Q, CFG, filler=f, overwrite_frac=ow, generator=g)
            tok = b["tokens"].to(dev)
            logits = logits_at(m, tok, b["ans_pos"].to(dev))
            ce = F.cross_entropy(logits.reshape(-1, CFG.V), b["gold"].to(dev).reshape(-1))
            kp = b["key_pos"]
            if b["upd_key_pos"] is not None:
                kp = torch.cat([kp, b["upd_key_pos"]], 1)
            sl = spread_loss(m, tok, kp.to(dev), CFG, gate_subsample=1024)
            loss = ce + sl
            opt.zero_grad(set_to_none=True)
            loss.backward()
            torch.nn.utils.clip_grad_norm_(m.parameters(), 1.0)
            opt.step()
            sched.step()
            step += 1
            if s % 100 == 0 or s == n_steps - 1:
                sc = m.blocks[0].mixer.scan
                print(f"  p{pairs:5d} f{f:2d} ow{ow:.1f} step {step:6d} ce {float(ce.detach()):.3f} "
                      f"spread {float(sl.detach()):.3f} rounds {sc.last_rounds} "
                      f"{time.time()-t0:6.0f}s", flush=True)
                with open(HEARTBEAT, "w") as fh:
                    fh.write(f"{step}\n")
            if (s + 1) % 500 == 0 or s == n_steps - 1:
                r = held_out(m, pairs, filler_cycle[-1])
                history.append({"step": step, "pairs": pairs, "f": filler_cycle[-1],
                                "acc_updated": round(r["acc_updated"], 4),
                                "acc_untouched": round(r["acc_untouched"], 4)})
                with open(HISTORY, "w") as fh:
                    json.dump(history, fh, indent=2)
                print(f"  EVAL p{pairs} upd {r['acc_updated']:.4f} keep {r['acc_untouched']:.4f}",
                      flush=True)

    print(f"FORGETTING CURRICULUM warm={warm} device={dev}", flush=True)
    START = int(os.environ.get("START_LEVEL", "0"))
    ladder = [lv for lv in LEVELS if lv["pairs"] >= START]
    for lv in ladder:
        train_steps(lv["pairs"], lv["steps"], [0, 0, 14])
        save(m, os.path.join(ROOT, "checkpoints", f"forgetting_lvl{lv['pairs']}.pt"), {"step": step})

    # FINAL phase: a LADDER, not a jump.  Measured: the overwrite skill scales
    # down with pair count (1.0000 @64/256, ~0.98 @1024, ~0.95 @4096) exactly
    # like capacity did before its ladder; and f=14 steps cost ~10s (T=131K) so
    # a single-N final is both too shallow and too slow.  Train at f in {6,8,14}
    # (the mechanism is filler-agnostic; the acceptance distribution gets its
    # own align steps), N ladder 2048->3548->5461, probe on the exact protocol.
    # FORK envs (fork-and-flush, cf. arXiv:2610.07447): a parallel trajectory
    # with DIFFERENT basin assumptions -- a bigger grid for double-writes
    # (FORK_SCALE ramps during the final ladder) and long-gap erasure training
    # (FORK_FILLERS raises the filler mix, stretching store->update gaps).
    FS = float(os.environ.get("FORK_SCALE", "0")) or None
    FF = os.environ.get("FORK_FILLERS", "6,8,14,8")
    fcyc_fork = [int(x) for x in FF.split(",")]
    print(f"FINAL phase: ladder 2048->3548->5461, filler mix {fcyc_fork}"
          + (f", scale ramp ->{FS:.0f}" if FS else ""), flush=True)
    FO = os.environ.get("FINAL_ONLY", "")
    if FO:
        FINAL_LADDER = [(5461, int(FO))]
    else:
        FINAL_LADDER = [(2048, 1200), (3548, 1500), (5461, 3000)]
    scale0_f = float(m.blocks[0].mixer.cfg.addr_scale)
    LR0 = float(os.environ.get("FINAL_LR", "0"))   # entry LR for the final
    # ladder; without it the phase runs at the optimizer's construction LR
    # (1e-3), which EXPLODES a converged model at B=1 (measured: probes
    # 0.988 -> 0.754 in 200 steps -- Law 3 again).  Warm up over 100 steps.
    WARM = 100
    import math as _m
    for FL_N, FL_STEPS in FINAL_LADDER:
        for s in range(FL_STEPS):
            if LR0:
                frac = min(1.0, (s + 1) / WARM)
                cur = LR0 * (0.05 + 0.95 * frac)
                tail = LR0 * (1.0 - 0.75 * min(1.0, s / max(1, FL_STEPS - 1)))
                lr = min(cur, tail) if s >= WARM else cur
                for pg in opt.param_groups:
                    pg["lr"] = lr
            if FS:
                frac = min(1.0, step / 2000.0)
                m.blocks[0].mixer.cfg.addr_scale = scale0_f + (FS - scale0_f) * frac
            ow = OW_CYCLE[s % len(OW_CYCLE)]
            f = fcyc_fork[s % len(fcyc_fork)]
            b = synth_batch(1, FL_N, 64, CFG, filler=f,
                            overwrite_frac=ow, generator=g)
            tok = b["tokens"].to(dev)
            logits = logits_at(m, tok, b["ans_pos"].to(dev))
            ce = F.cross_entropy(logits.reshape(-1, CFG.V), b["gold"].to(dev).reshape(-1))
            kp = torch.cat([b["key_pos"], b["upd_key_pos"]], 1)
            sl = spread_loss(m, tok, kp.to(dev), CFG, gate_subsample=1024)
            loss = ce + sl
            opt.zero_grad(set_to_none=True)
            loss.backward()
            torch.nn.utils.clip_grad_norm_(m.parameters(), 1.0)
            opt.step()
            sched.step()
            step += 1
            if s % 100 == 0 or s == FL_STEPS - 1:
                print(f"  final N{FL_N} f{f} {s:4d} ce {float(ce.detach()):.3f} T{b['L']} "
                      f"{time.time()-t0:6.0f}s", flush=True)
            if (s + 1) % 200 == 0 or s == FL_STEPS - 1:
                r = held_out(m, 5461, 14, n_u=500, n_k=500, seed=777001)
                history.append({"step": step, "final": FINAL["pairs"] if False else FL_N,
                                "acc_updated": round(r["acc_updated"], 4),
                                "acc_untouched": round(r["acc_untouched"], 4)})
                with open(HISTORY, "w") as fh:
                    json.dump(history, fh, indent=2)
                print(f"  FINAL PROBE upd {r['acc_updated']:.4f} keep {r['acc_untouched']:.4f}",
                      flush=True)
                save(m, CKPT_OUT, {"step": step})
                if r["acc_updated"] >= 0.998 and r["acc_untouched"] >= 0.998:
                    print(f"TARGET REACHED step {step}", flush=True)
                    break
        else:
            continue
        break  # TARGET REACHED inside the inner loop -> stop the ladder too
    save(m, CKPT_OUT, {"step": step})
    r = held_out(m, 64, 0, n_u=256, n_k=256, seed=777003)
    print(f"SELF-CHECK dense N=64: upd {r['acc_updated']:.4f} keep {r['acc_untouched']:.4f}",
          flush=True)
    print(f"saved {CKPT_OUT} total {time.time()-t0:.0f}s", flush=True)


if __name__ == "__main__":
    main()