riscoss commited on
Commit
a4e2c0d
·
verified ·
1 Parent(s): 7bf72a5

Sillage: the demo, its memory of paper 1, and the recorded runs

Browse files
Files changed (15) hide show
  1. .gitattributes +1 -0
  2. README.md +79 -7
  3. app.py +454 -0
  4. build_page.py +291 -0
  5. build_state.py +37 -0
  6. capture.py +69 -0
  7. data.json +119 -0
  8. demo.gif +3 -0
  9. index.html +260 -0
  10. requirements.txt +5 -0
  11. sample.py +137 -0
  12. state/cold.pkl +3 -0
  13. state/index.pkl +3 -0
  14. state/log.json +12 -0
  15. state/state.npz +3 -0
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ demo.gif filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,15 +1,87 @@
1
  ---
2
- title: Sillage
3
- emoji: 🐢
4
- colorFrom: purple
5
- colorTo: blue
6
  sdk: gradio
7
- sdk_version: 6.26.0
8
- python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
11
  license: mit
12
  short_description: A frozen GPT-2 given a 4 MB memory. No gradients, no index.
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  ---
14
 
15
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Sillage — a frozen LM that remembers what it reads
3
+ emoji: 🌊
4
+ colorFrom: blue
5
+ colorTo: indigo
6
  sdk: gradio
7
+ sdk_version: 6.13.0
 
8
  app_file: app.py
9
  pinned: false
10
  license: mit
11
  short_description: A frozen GPT-2 given a 4 MB memory. No gradients, no index.
12
+ models:
13
+ - openai-community/gpt2
14
+ preload_from_hub:
15
+ - openai-community/gpt2
16
+ tags:
17
+ - associative-memory
18
+ - test-time-learning
19
+ - continual-learning
20
+ - hebbian
21
+ - gradient-free
22
+ - knn-lm
23
+ - hyperdimensional-computing
24
+ - cpu
25
  ---
26
 
27
+ # Sillage the demo
28
+
29
+ A frozen language model reads a document once and gets better at it, with no
30
+ gradients, no fine-tuning and no vector database. Three screens:
31
+
32
+ 1. **It has already read a paper.** A memory of paper 1 (8 969 tokens) ships
33
+ with this Space, so the first thing you see is a frozen GPT-2 and a
34
+ remembering GPT-2 completing the same sentence side by side. No wait.
35
+ 2. **Give it your own text.** Up to 1 600 tokens, read once. The first half
36
+ builds the memory, the second half is *measured* with it — the papers' own
37
+ dev/test split — and you get the list of predictions the memory corrected.
38
+ 3. **Where it does not work.** The regime boundary, in the authors' words.
39
+
40
+ Code, four preprints with DOIs and the reproduction pipeline:
41
+ <https://github.com/riscoss63/sillage> · `pip install sillage` (this Space
42
+ runs the published 1.0.1)
43
+
44
+ ## Hardware: ZeroGPU, and why the loop stays on the CPU
45
+
46
+ Hosting a Gradio Space on free `cpu-basic` now requires PRO, but a personal
47
+ account in good standing (verified email, older than 30 days) may host **two
48
+ ZeroGPU Spaces for free**. Pick ZeroGPU in the Space settings — the creation
49
+ form on the website lets you choose it; `hf repos create` does not, which is
50
+ why that command fails with a 402.
51
+
52
+ ZeroGPU quota is charged to the **visitor** (2 minutes a day for an anonymous
53
+ one, 5 for a signed-in free account) for the time spent inside a
54
+ `@spaces.GPU` function. This demo is mostly *not* GPU work: the Hebbian
55
+ mechanism is numpy on the CPU. So the app is split accordingly:
56
+
57
+ * `forward_all` — every frozen forward pass of the document, in one decorated
58
+ call, a couple of seconds of GPU;
59
+ * the read loop — the memory, the adapter, the abstention, all outside the
60
+ decorator, on the CPU, costing the visitor nothing;
61
+ * `compare` / `compare_user` — short generations, decorated, a second or two.
62
+
63
+ Reading a 1 600-token document therefore costs a visitor a few seconds of
64
+ quota rather than half a minute.
65
+
66
+ ## What is in this repository
67
+
68
+ | file | role |
69
+ |---|---|
70
+ | `app.py` | the three screens |
71
+ | `sample.py` | two documents to click: an invented operations manual, and paper 1 |
72
+ | `build_state.py` | rebuilds `state/`, the memory the demo starts with |
73
+ | `state/` | 7.1 MB: a GPT-2 memory that has already read paper 1 |
74
+ | `index.html`, `data.json`, `capture.py`, `build_page.py` | a static fallback page built from recorded runs — used only if the Space is switched to `sdk: static`, which is free for everyone with no eligibility rules |
75
+ | `demo.gif` | the two-session terminal recording |
76
+
77
+ ## Running it locally
78
+
79
+ ```bash
80
+ pip install sillage gradio
81
+ python build_state.py path/to/a/document # the memory the demo starts with
82
+ python app.py # http://127.0.0.1:7860
83
+ ```
84
+
85
+ There is no GPU-specific code path to test: `@spaces.GPU` is a no-op when the
86
+ `spaces` package is absent, and the model goes to `cuda` only when there is
87
+ one.
app.py ADDED
@@ -0,0 +1,454 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sillage — Hugging Face Space.
2
+
3
+ Three screens, in the order that convinces:
4
+
5
+ 1. "It has already read this paper." A memory built at deploy time is
6
+ already loaded, so the very first thing a visitor sees is a frozen model
7
+ and a remembering model completing the same sentence side by side. No
8
+ waiting, no setup, no account.
9
+ 2. "Give it your own text." Half a minute, then the same comparison on
10
+ THEIR document, plus the list of predictions the memory actually
11
+ corrected -- the part that turns a number into a fact.
12
+ 3. "Where it does not work." The regime boundary, stated by the authors
13
+ before anyone else has to point it out.
14
+
15
+ Everything here runs on `pip install sillage`; the read loop below is the
16
+ library's own, instrumented to report which tokens changed.
17
+
18
+ On ZeroGPU the visitor pays quota only for time spent inside a @spaces.GPU
19
+ function, so the split matters: the frozen forward passes are batched into
20
+ one decorated call, the Hebbian mechanism stays in numpy on the CPU, and the
21
+ suggested prompts of screen 1 are served from runs recorded at build time.
22
+ Looking at the page costs nothing; a click costs a second or two.
23
+ """
24
+
25
+ import collections
26
+ import io
27
+ import json
28
+ import os
29
+ import re
30
+ import shutil
31
+ import tempfile
32
+
33
+ import gradio as gr
34
+ import numpy as np
35
+ import torch
36
+
37
+ from sillage import Sillage
38
+ from sillage.core import CAP
39
+
40
+ try: # ZeroGPU: a real GPU is attached only
41
+ import spaces # inside a decorated function
42
+ GPU = spaces.GPU
43
+ except ImportError: # anywhere else the decorator is a no-op
44
+ def GPU(*args, **kwargs):
45
+ if args and callable(args[0]):
46
+ return args[0]
47
+ return lambda fn: fn
48
+
49
+ import sample
50
+
51
+ STATE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "state")
52
+ MAX_TOKENS = 1600 # 160 MB of logits, ~30 s end to end
53
+ MIN_TOKENS = 900 # below this, the split has nothing to say
54
+ WINDOW, STRIDE = 1024, 512
55
+ PAPER = "Sillage (paper 1), 8 969 tokens"
56
+
57
+ # Chosen by running them: each one makes the two columns visibly diverge,
58
+ # and the right-hand column recites the paper rather than inventing.
59
+ EXAMPLE_PROMPTS = [
60
+ "On a 36k-token stream of novel technical text, the memory",
61
+ "At 500k tokens the fixed matrix",
62
+ "surprise gating quadruples the gain of",
63
+ "Every write is gated by the model's own",
64
+ ]
65
+
66
+
67
+ # --------------------------------------------------------------- backend ---
68
+
69
+ DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
70
+ memory = Sillage(model="gpt2", state=STATE, device=DEVICE, quiet=True)
71
+ frozen = Sillage(model="gpt2", state=tempfile.mkdtemp(), device=DEVICE,
72
+ quiet=True)
73
+
74
+ # Each visitor who reads a document leaves a 7.4 MB state behind. Keep the
75
+ # most recent few so the follow-up completion still works, and delete the
76
+ # rest: a public Space should not fill its disk with strangers' memories.
77
+ SESSIONS = collections.deque(maxlen=8)
78
+
79
+
80
+ def new_session_dir():
81
+ if len(SESSIONS) == SESSIONS.maxlen:
82
+ shutil.rmtree(SESSIONS[0], ignore_errors=True)
83
+ d = tempfile.mkdtemp(prefix="sillage-session-")
84
+ SESSIONS.append(d)
85
+ return d
86
+
87
+
88
+ def models():
89
+ """Load GPT-2 once and let both assistants share the frozen weights."""
90
+ tok, model = memory.load_model()
91
+ frozen._tok, frozen._model = tok, model
92
+ frozen.device = memory.device
93
+ return tok, model
94
+
95
+
96
+ # ZeroGPU asks for the weights to be placed at startup rather than inside a
97
+ # decorated call, where a cold load would eat into the visitor's quota and
98
+ # could outlast the function's duration budget.
99
+ models()
100
+
101
+
102
+ # Screen 1's suggested prompts were run once, at build time, into data.json.
103
+ # Greedy decoding over a fixed memory is deterministic, so a cached answer is
104
+ # the same answer -- and serving it costs the visitor no GPU quota at all.
105
+ # Anything a visitor types instead is computed live.
106
+ CACHED = {}
107
+ try:
108
+ for _c in json.load(io.open(os.path.join(
109
+ os.path.dirname(os.path.abspath(__file__)), "data.json"),
110
+ encoding="utf-8"))["completions"]:
111
+ CACHED[_c["prompt"].strip()] = (
112
+ _c["prompt"] + _c["frozen"], _c["prompt"] + _c["memory"],
113
+ _c["same"])
114
+ except Exception: # no capture yet: everything runs live
115
+ CACHED = {}
116
+
117
+
118
+ def compare(prompt, n_tokens=24):
119
+ """Screen 1. Recorded prompts are served from cache, the rest is live."""
120
+ hit = CACHED.get((prompt or "").strip())
121
+ if hit:
122
+ return hit[0], hit[1], _verdict(hit[2])
123
+ return compare_live(prompt, n_tokens)
124
+
125
+
126
+ @GPU(duration=30)
127
+ def compare_live(prompt, n_tokens=24):
128
+ """The decorated path: generation is the only part that needs the model.
129
+
130
+ Twenty-four tokens twice is a second or two of GPU, which is what a
131
+ visitor's daily ZeroGPU quota can afford many times over.
132
+ """
133
+ return _compare(prompt, n_tokens)
134
+
135
+
136
+ def _verdict(same):
137
+ return ("identical here — the memory abstains when it is not confident, "
138
+ "which is the point of the abstention threshold" if same else
139
+ "the two columns diverge: everything after the prompt on the "
140
+ "right comes from what was read, not from GPT-2's weights")
141
+
142
+
143
+ @GPU(duration=60)
144
+ @torch.no_grad()
145
+ def forward_all(ids):
146
+ """Every frozen forward pass for a document, in one GPU visit.
147
+
148
+ The Hebbian loop that follows is numpy on the CPU: keeping it outside
149
+ this function is what stops a 30-second read from costing 30 seconds of
150
+ the visitor's GPU quota. Returned in fp16 -- 1600 positions of GPT-2
151
+ logits are 160 MB, and the loop only ever reads them.
152
+ """
153
+ tok, model = models()
154
+ x = torch.tensor(ids, device=memory.device)
155
+ n = len(ids) - 1
156
+ logits = np.empty((n + 1, model.config.vocab_size), dtype=np.float16)
157
+ hidden = np.empty((n + 1, model.config.n_embd), dtype=np.float16)
158
+ a = 0
159
+ while a < n:
160
+ w = min(WINDOW, len(ids) - a)
161
+ out = model(x[a:a + w].unsqueeze(0), output_hidden_states=True)
162
+ lo = 0 if a == 0 else WINDOW - STRIDE
163
+ logits[a + lo:a + w] = (
164
+ out.logits[0, lo:w].to(torch.float16).cpu().numpy())
165
+ hidden[a + lo:a + w] = (
166
+ out.hidden_states[-1][0, lo:w].to(torch.float16).cpu().numpy())
167
+ if a + w >= len(ids):
168
+ break
169
+ a += STRIDE
170
+ return logits, hidden
171
+
172
+
173
+ def _compare(prompt, n_tokens=24):
174
+ """The same prompt, decoded greedily, with and without the memory."""
175
+ if not prompt.strip():
176
+ return "", "", ""
177
+ models()
178
+ a = frozen.complete(prompt, n=n_tokens)
179
+ b = memory.complete(prompt, n=n_tokens)
180
+ return prompt + a, prompt + b, _verdict(a.strip() == b.strip())
181
+
182
+
183
+ def suggest_prompt(text, tail):
184
+ """Half of a sentence whose opening recurs — so the memory knows the rest.
185
+
186
+ A prompt taken at random usually lands where the memory has nothing to
187
+ say, and the demo falls flat through no fault of the mechanism. Picking a
188
+ sentence whose first words already appeared earlier is the honest way to
189
+ show the memory doing what it is for.
190
+ """
191
+ best = ""
192
+ for sentence in re.split(r"(?<=[.:])\s+|\n+", tail):
193
+ words = sentence.split()
194
+ if len(words) < 9:
195
+ continue
196
+ if text.count(" ".join(words[:5])) >= 2:
197
+ cut = " ".join(words[:max(5, len(words) // 2)])
198
+ if len(cut) > len(best):
199
+ best = cut
200
+ return best or " ".join(tail.split()[:10])
201
+
202
+
203
+ def read_and_report(text, progress=gr.Progress()):
204
+ """Stream a document through a fresh memory, reporting what it changed.
205
+
206
+ This is `sillage.runtime.Sillage.read_text`, unrolled for two reasons:
207
+ the demo collects the positions where the memory turned a wrong
208
+ prediction into a right one, and the frozen forward passes are done in
209
+ one batch beforehand (on the GPU when there is one) so that this loop --
210
+ plain numpy, no model -- costs the visitor nothing. Strictly prequential
211
+ either way: every token is scored before it is written.
212
+ """
213
+ text = (text or "").strip()
214
+ tok, lm = models()
215
+ state_dir = new_session_dir()
216
+ fresh = Sillage(model="gpt2", state=state_dir, device=memory.device,
217
+ quiet=True)
218
+ fresh._tok, fresh._model = tok, lm
219
+ mem = fresh.mem
220
+
221
+ ids = np.array(tok.encode(text), dtype=np.int64)[:MAX_TOKENS]
222
+ n = len(ids) - 1
223
+ if n < MIN_TOKENS:
224
+ return (None, f"That is {max(n, 0)} tokens. Below about "
225
+ f"{MIN_TOKENS} there is nothing to measure honestly: the "
226
+ f"first half of a document is what builds the memory, and "
227
+ f"the abstention threshold needs a few hundred observations "
228
+ f"before it will let the memory speak at all. Paste a longer "
229
+ f"one — or click an example below.", None, "", "")
230
+ dev_end = n // 2 # first half: the memory reads and calibrates
231
+ mem.new_stream()
232
+ thrG = thrS = np.inf # silent until the dev half is behind us
233
+ nll_b = nll_f = nll_m = 0.0
234
+ cnt = active = 0
235
+ fixes = []
236
+ logits, hidden = forward_all(ids) # the only GPU visit
237
+ for j in range(n):
238
+ if j == dev_end: # the papers' protocol: dev decides, test tells
239
+ thrG, thrS = mem.thresholds()
240
+ truth = int(ids[j + 1])
241
+ lb = logits[j].astype(np.float32)
242
+ mx = lb.max()
243
+ lpb = lb - (mx + np.log(np.exp(lb - mx).sum()))
244
+ lp = float(lpb[truth])
245
+ la, phi = mem.adapt(lb, hidden[j].astype(np.float32))
246
+ m2 = la.max()
247
+ p_ad = np.exp(la - m2)
248
+ p_ad /= p_ad.sum()
249
+ lp_f = float(np.log(max(p_ad[truth], 1e-30)))
250
+ qG = mem.step_key(int(ids[j]))
251
+ uG, sG = mem.scores(mem.M, qG)
252
+ mem.res_G.append(float(sG.max()))
253
+ pc = mem.cold_lookup(truth)
254
+ p_true = mem.mix_true(np.exp(lp_f), sG, truth, None, pc, thrG, thrS)
255
+ speaks = float(sG.max()) >= thrG
256
+ if j >= dev_end:
257
+ active += speaks
258
+ nll_b += -lp
259
+ nll_f += -lp_f
260
+ nll_m += -np.log(max(p_true, 1e-30))
261
+ cnt += 1
262
+ if speaks and len(fixes) < 12:
263
+ # mix_true takes the probability of one token, mix_full takes the
264
+ # whole distribution -- so the cold tier is asked twice, once for
265
+ # each shape
266
+ full = mem.mix_full(p_ad.copy(), sG, None, mem.cold_lookup(),
267
+ thrG, thrS)
268
+ said, now = int(np.argmax(p_ad)), int(np.argmax(full))
269
+ if now == truth != said:
270
+ ctx = tok.decode(ids[max(0, j - 9):j + 1])
271
+ fixes.append([" ".join(ctx.split())[-58:],
272
+ repr(tok.decode([truth])),
273
+ repr(tok.decode([said]))])
274
+ g = min(CAP, max(0.0, -lp))
275
+ mem.write_all(qG, uG, None, None, truth, g, phi, p_ad)
276
+ if j % 128 == 0:
277
+ progress(j / n, desc=f"reading {j}/{n} tokens")
278
+ fresh.index.add(text, "your document")
279
+ fresh.save()
280
+
281
+ ppl = [float(np.exp(v / cnt)) for v in (nll_b, nll_f, nll_m)]
282
+ plot = {"frozen GPT-2": ppl[0], "+ fast weights": ppl[1],
283
+ "+ memory": ppl[2]}
284
+ knn = n * 1536 * 4 / 1e6 # one 1536-float key+value per token
285
+ summary = (
286
+ f"**Read once, left to right: {dev_end} tokens built the memory, the "
287
+ f"next {cnt} were measured with it.** That split is the papers' own "
288
+ f"protocol — the numbers below are on text the memory had not seen "
289
+ f"when it scored them, and every token was scored *before* being "
290
+ f"written.\n\n"
291
+ f"Perplexity on that second half: {ppl[0]:.2f} frozen → "
292
+ f"{ppl[1]:.2f} with the rank-16 adapter → **{ppl[2]:.2f}** with the "
293
+ f"memory on top — **{100 * (1 - ppl[2] / ppl[0]):.0f}% lower**. The "
294
+ f"memory spoke on {100 * active / cnt:.0f}% of those positions and "
295
+ f"kept quiet on the rest; abstaining when it has nothing to say is "
296
+ f"what keeps it from doing harm.\n\n"
297
+ f"State on disk: **7.4 MB**, and it would still be 7.4 MB after a "
298
+ f"million tokens. A kNN-LM datastore over the same text would "
299
+ f"already hold about {knn:.1f} MB, and would keep growing.")
300
+ return (plot, summary, fixes or None, state_dir,
301
+ suggest_prompt(text, tok.decode(ids[dev_end:])))
302
+
303
+
304
+ def read_then_compare(text, progress=gr.Progress()):
305
+ """Read, report, and hand back a prompt worth trying plus the new state."""
306
+ plot, summary, fixes, state_dir, suggestion = read_and_report(text,
307
+ progress)
308
+ return (plot, summary, fixes, state_dir,
309
+ gr.update(value=suggestion, visible=bool(state_dir)),
310
+ gr.update(visible=bool(state_dir)))
311
+
312
+
313
+ @GPU(duration=30)
314
+ def compare_user(prompt, state_dir, n_tokens=12):
315
+ """The same comparison, against the memory the visitor just built."""
316
+ if not state_dir or not os.path.exists(os.path.join(state_dir,
317
+ "state.npz")):
318
+ return "", "", ("That session's memory has been cleared — read a "
319
+ "document again.")
320
+ if not (prompt or "").strip():
321
+ return "", "", ""
322
+ tok, model = models()
323
+ theirs = Sillage(model="gpt2", state=state_dir, device=memory.device,
324
+ quiet=True)
325
+ theirs._tok, theirs._model = tok, model
326
+ a = frozen.complete(prompt, n=n_tokens)
327
+ b = theirs.complete(prompt, n=n_tokens)
328
+ note = ("identical here — try a phrase that recurs in your document, "
329
+ "the memory only speaks where it is confident"
330
+ if a.strip() == b.strip() else
331
+ "the right-hand column comes from your document, not from "
332
+ "GPT-2's weights")
333
+ return prompt + a, prompt + b, note
334
+
335
+
336
+ # ------------------------------------------------------------------ ui -----
337
+
338
+ CSS = """
339
+ .hero {text-align:center}
340
+ .small {font-size:0.9em; opacity:0.75}
341
+ footer {visibility:hidden}
342
+ """
343
+
344
+ with gr.Blocks(title="Sillage: a frozen LM that remembers "
345
+ "what it reads") as demo:
346
+ gr.Markdown(
347
+ "# Sillage\n"
348
+ "### A frozen language model that remembers what it reads — "
349
+ "4.2 MB, no gradients, no fine-tuning, no vector database.\n"
350
+ "[![PyPI](https://img.shields.io/pypi/v/sillage.svg)]"
351
+ "(https://pypi.org/project/sillage/) "
352
+ "[![GitHub](https://img.shields.io/badge/code-GitHub-181717?logo=github)]"
353
+ "(https://github.com/riscoss63/sillage) "
354
+ "[![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.22079016.svg)]"
355
+ "(https://doi.org/10.5281/zenodo.22079016)",
356
+ elem_classes="hero")
357
+ gr.Markdown(
358
+ "> This Space runs **GPT-2 124M on a free CPU**. Its prose is weak in "
359
+ "absolute terms — that is not what is on display. What is on display "
360
+ "is the **difference** between the two columns, and it comes from a "
361
+ "4.2 MB matrix written while reading, with no gradient anywhere.",
362
+ elem_classes="small")
363
+
364
+ with gr.Tab("1 · It has already read a paper"):
365
+ gr.Markdown(
366
+ f"The memory loaded here has read **{PAPER}** — the paper that "
367
+ "describes this very mechanism. GPT-2 has never seen that text. "
368
+ "Complete a sentence from it and watch the right-hand column "
369
+ "recall what the left one cannot know.")
370
+ prompt = gr.Textbox(label="Beginning of a sentence",
371
+ value=EXAMPLE_PROMPTS[0], lines=2)
372
+ gr.Examples(examples=[[p] for p in EXAMPLE_PROMPTS], inputs=prompt,
373
+ label="Try one of these")
374
+ go = gr.Button("Complete both", variant="primary")
375
+ with gr.Row():
376
+ out_frozen = gr.Textbox(label="GPT-2, frozen", lines=6)
377
+ out_memory = gr.Textbox(label="GPT-2 + Sillage memory", lines=6)
378
+ verdict = gr.Markdown(elem_classes="small")
379
+ go.click(compare, prompt, [out_frozen, out_memory, verdict])
380
+ demo.load(compare, prompt, [out_frozen, out_memory, verdict])
381
+
382
+ with gr.Tab("2 · Give it your own text"):
383
+ gr.Markdown(
384
+ f"Paste something GPT-2 has never seen: your notes, an internal "
385
+ f"document, a README, a specification. It is read **once**, left "
386
+ f"to right, and every token is scored *before* it is memorised — "
387
+ f"so the numbers below are honest online measurements, not a "
388
+ f"replay. Capped at {MAX_TOKENS} tokens (about 90 seconds on this "
389
+ f"free CPU).")
390
+ user_text = gr.Textbox(label="Your document", lines=12,
391
+ value=sample.MANUAL)
392
+ gr.Examples(
393
+ examples=[[sample.MANUAL], [sample.paper_excerpt()]],
394
+ inputs=user_text, example_labels=[
395
+ "An invented operations manual (GPT-2 cannot know it)",
396
+ "The opening of paper 1 (novel technical prose)"],
397
+ label="Or start from one of these")
398
+ read_btn = gr.Button("Read it", variant="primary")
399
+ chart = gr.Label(label="Perplexity, lower is better")
400
+ report = gr.Markdown()
401
+ fixed = gr.Dataframe(
402
+ headers=["context", "what came next", "what frozen GPT-2 said"],
403
+ label="Predictions the memory corrected — the frozen model had "
404
+ "no way of knowing these", wrap=True)
405
+
406
+ gr.Markdown("### Now ask it to continue a sentence from *your* text")
407
+ session = gr.State("")
408
+ your_prompt = gr.Textbox(label="Beginning of a sentence", lines=2,
409
+ visible=False)
410
+ your_btn = gr.Button("Complete both", variant="primary",
411
+ visible=False)
412
+ with gr.Row():
413
+ your_frozen = gr.Textbox(label="GPT-2, frozen", lines=5)
414
+ your_memory = gr.Textbox(label="GPT-2 + the memory you just "
415
+ "built", lines=5)
416
+ your_note = gr.Markdown(elem_classes="small")
417
+ read_btn.click(read_then_compare, user_text,
418
+ [chart, report, fixed, session, your_prompt, your_btn])
419
+ your_btn.click(compare_user, [your_prompt, session],
420
+ [your_frozen, your_memory, your_note])
421
+
422
+ with gr.Tab("3 · Where it does not work"):
423
+ gr.Markdown(
424
+ "**The regime matters, and it is measured.**\n\n"
425
+ "| system | perplexity | memory used |\n|---|---|---|\n"
426
+ "| frozen GPT-2 | 31.2 | 0 |\n"
427
+ "| + RAG-style retrieve & rescore | 29.9 | corpus + index |\n"
428
+ "| + kNN-LM, unbounded store | 23.6 | 55 MB, grows forever |\n"
429
+ "| + **this memory** (fixed) | **19.2** | **4.2 MB, constant** |\n"
430
+ "| + memory and fast weights | **16.6** | 7.4 MB, constant |\n\n"
431
+ "That is on 36k tokens of novel technical text. **On long, "
432
+ "low-repetition narrative, an unbounded kNN-LM still wins** "
433
+ "(+0.048 vs +0.007 nats): this memory captures verbatim "
434
+ "recurrence, not paraphrase. The boundary is mapped in the "
435
+ "papers rather than hidden.\n\n"
436
+ "Three results that did *not* work are published too: hidden "
437
+ "states make poor Hebbian keys; surprise gating helps the memory "
438
+ "and *hurts* the fast-weight adapter; and calibrating the readout "
439
+ "on your own stream loses to a proper tuning, because the "
440
+ "calibration window is read by a colder memory than the one it "
441
+ "will govern.\n\n"
442
+ "---\n\n"
443
+ "**Run it yourself, on any causal LM:**\n\n"
444
+ "```bash\npip install sillage\nsillage read notes.md\n"
445
+ "sillage ask \"what did the report say?\"\n"
446
+ "sillage complete \"The report said\"\n```\n\n"
447
+ "Four preprints with DOIs, the full reproduction pipeline and "
448
+ "every number as committed JSON: "
449
+ "[github.com/riscoss63/sillage](https://github.com/riscoss63/sillage)")
450
+
451
+ if __name__ == "__main__":
452
+ # one reading at a time: the whole point is a single CPU
453
+ demo.queue(default_concurrency_limit=1).launch(
454
+ theme=gr.themes.Soft(), css=CSS)
build_page.py ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build index.html for the static Space from the captured runs.
2
+
3
+ A static Space has no backend, so the page carries its evidence with it: the
4
+ outputs in data.json are inlined, and the page says where they came from.
5
+ Nothing here is illustrative -- every string was produced by the tool.
6
+
7
+ python capture.py && python build_page.py
8
+ """
9
+
10
+ import io
11
+ import json
12
+ import os
13
+
14
+ HERE = os.path.dirname(os.path.abspath(__file__))
15
+
16
+ PAGE = """<!doctype html>
17
+ <html lang="en">
18
+ <head>
19
+ <meta charset="utf-8">
20
+ <meta name="viewport" content="width=device-width, initial-scale=1">
21
+ <title>Sillage — a frozen LM that remembers what it reads</title>
22
+ <style>
23
+ :root {
24
+ --bg:#0d1117; --panel:#161b22; --line:#26303d; --ink:#c9d1d9;
25
+ --dim:#8b949e; --hl:#79c0ff; --warm:#ffc457; --ok:#3fb950;
26
+ }
27
+ * { box-sizing:border-box }
28
+ body {
29
+ margin:0; background:var(--bg); color:var(--ink); line-height:1.6;
30
+ font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,
31
+ Helvetica,Arial,sans-serif;
32
+ }
33
+ .wrap { max-width:980px; margin:0 auto; padding:0 20px 80px }
34
+ header { text-align:center; padding:56px 0 24px }
35
+ h1 { font-size:2.6rem; margin:0 0 10px; letter-spacing:-.02em }
36
+ .tag { font-size:1.15rem; color:var(--ink); max-width:640px; margin:0 auto }
37
+ .etym { color:var(--dim); font-style:italic; font-size:.95rem;
38
+ max-width:560px; margin:18px auto 0 }
39
+ .badges { margin:22px 0 8px; display:flex; gap:8px; justify-content:center;
40
+ flex-wrap:wrap }
41
+ .badges img { height:20px }
42
+ h2 { font-size:1.5rem; margin:52px 0 6px; letter-spacing:-.01em }
43
+ h2 .num { color:var(--dim); font-weight:400; margin-right:8px }
44
+ .lede { color:var(--dim); margin:0 0 20px }
45
+ .gif { width:100%; border:1px solid var(--line); border-radius:10px;
46
+ display:block; margin:26px 0 }
47
+ .chips { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:16px }
48
+ .chip {
49
+ background:var(--panel); border:1px solid var(--line); color:var(--ink);
50
+ border-radius:999px; padding:7px 14px; cursor:pointer; font-size:.9rem;
51
+ font-family:inherit; transition:border-color .15s, color .15s;
52
+ }
53
+ .chip:hover { border-color:var(--hl); color:var(--hl) }
54
+ .chip[aria-pressed="true"] { border-color:var(--hl); color:var(--hl);
55
+ background:#16273b }
56
+ .cols { display:grid; grid-template-columns:1fr 1fr; gap:14px }
57
+ @media (max-width:760px) { .cols { grid-template-columns:1fr } }
58
+ .col { background:var(--panel); border:1px solid var(--line);
59
+ border-radius:10px; padding:14px 16px; min-height:170px }
60
+ .col h3 { margin:0 0 10px; font-size:.85rem; text-transform:uppercase;
61
+ letter-spacing:.08em; color:var(--dim); font-weight:600 }
62
+ .col.win h3 { color:var(--ok) }
63
+ .out { font-family:"Cascadia Mono",Consolas,ui-monospace,monospace;
64
+ font-size:.9rem; white-space:pre-wrap; word-break:break-word; margin:0 }
65
+ .out .p { color:var(--dim) }
66
+ .out .new { color:var(--warm) }
67
+ .col.win .out .new { color:var(--hl) }
68
+ .note { color:var(--dim); font-size:.9rem; margin:12px 0 0 }
69
+ table { width:100%; border-collapse:collapse; margin:18px 0; font-size:.92rem }
70
+ th,td { text-align:left; padding:9px 12px; border-bottom:1px solid var(--line) }
71
+ th { color:var(--dim); font-weight:600; font-size:.82rem;
72
+ text-transform:uppercase; letter-spacing:.06em }
73
+ td.mono,th.mono { font-family:"Cascadia Mono",Consolas,ui-monospace,monospace;
74
+ font-size:.86rem }
75
+ tr.best td { color:var(--hl); font-weight:600 }
76
+ .panel { background:var(--panel); border:1px solid var(--line);
77
+ border-radius:10px; padding:18px 20px; margin:18px 0 }
78
+ .panel p:first-child { margin-top:0 }
79
+ .panel p:last-child { margin-bottom:0 }
80
+ pre.sh { background:var(--panel); border:1px solid var(--line);
81
+ border-radius:10px; padding:16px 18px; overflow-x:auto;
82
+ font-family:"Cascadia Mono",Consolas,ui-monospace,monospace;
83
+ font-size:.9rem; color:var(--ink) }
84
+ pre.sh .c { color:var(--dim) }
85
+ a { color:var(--hl) }
86
+ footer { color:var(--dim); font-size:.9rem; text-align:center;
87
+ margin-top:64px; border-top:1px solid var(--line); padding-top:24px }
88
+ .kbd { font-family:"Cascadia Mono",Consolas,ui-monospace,monospace;
89
+ background:#1c2432; border:1px solid var(--line); border-radius:5px;
90
+ padding:1px 6px; font-size:.85em }
91
+ </style>
92
+ </head>
93
+ <body>
94
+ <div class="wrap">
95
+
96
+ <header>
97
+ <h1>Sillage</h1>
98
+ <p class="tag">A frozen language model that <b>remembers what it reads</b> —
99
+ 4.2&nbsp;MB, no gradients, no fine-tuning, no vector database.</p>
100
+ <p class="etym">sillage (n., French) — the trace left behind by something
101
+ that has passed: a ship's wake, a scent in a room. What a model keeps of
102
+ what it read.</p>
103
+ <div class="badges">
104
+ <a href="https://pypi.org/project/sillage/"><img alt="PyPI"
105
+ src="https://img.shields.io/pypi/v/sillage.svg"></a>
106
+ <a href="https://github.com/riscoss63/sillage"><img alt="GitHub"
107
+ src="https://img.shields.io/badge/code-GitHub-181717?logo=github"></a>
108
+ <a href="https://doi.org/10.5281/zenodo.22079016"><img alt="DOI"
109
+ src="https://zenodo.org/badge/DOI/10.5281/zenodo.22079016.svg"></a>
110
+ <a href="https://github.com/riscoss63/sillage/blob/main/LICENSE"><img
111
+ alt="MIT" src="https://img.shields.io/badge/License-MIT-blue.svg"></a>
112
+ </div>
113
+ </header>
114
+
115
+ <img class="gif" src="demo.gif" alt="Two sessions a day apart: the model
116
+ reads a draft on Monday and halves the perplexity of the next draft on
117
+ Tuesday, then completes a sentence it can only know from what it read.">
118
+
119
+ <h2><span class="num">1</span>It has already read a paper</h2>
120
+ <p class="lede">The memory below has read
121
+ <b id="read-what"></b> — the paper describing this very mechanism, which
122
+ GPT-2 has never seen. Same prompt, same greedy decoding, one column with the
123
+ memory and one without. Pick a beginning:</p>
124
+
125
+ <div class="chips" id="chips"></div>
126
+ <div class="cols">
127
+ <div class="col"><h3>GPT-2, frozen</h3><p class="out" id="out-frozen"></p></div>
128
+ <div class="col win"><h3>GPT-2 + Sillage memory</h3>
129
+ <p class="out" id="out-memory"></p></div>
130
+ </div>
131
+ <p class="note" id="note"></p>
132
+
133
+ <h2><span class="num">2</span>What it does to a document it has never seen</h2>
134
+ <p class="lede">An operations manual invented for this demo, so GPT-2 cannot
135
+ have seen it. Read <b>once</b>, left to right: the first half builds the
136
+ memory, the second half is measured with it — the papers' own dev/test split —
137
+ and every token is scored <i>before</i> being written.</p>
138
+ <div class="panel" id="manual-summary"></div>
139
+
140
+ <h3 style="margin:26px 0 4px">Predictions the memory corrected</h3>
141
+ <p class="lede">The frozen model had no way of knowing these. They are facts
142
+ that exist only in that document.</p>
143
+ <table id="fixes"><thead><tr>
144
+ <th class="mono">context</th><th class="mono">what came next</th>
145
+ <th class="mono">what frozen GPT-2 said</th>
146
+ </tr></thead><tbody></tbody></table>
147
+
148
+ <h2><span class="num">3</span>How much better, exactly</h2>
149
+ <p class="lede">36k tokens of technical text the model had never seen, frozen
150
+ GPT-2 124M, every system tuned identically on a held-out prefix,
151
+ 95&nbsp;% bootstrap confidence intervals.</p>
152
+ <table>
153
+ <thead><tr><th>system</th><th>perplexity</th><th>change</th>
154
+ <th>memory used</th></tr></thead>
155
+ <tbody>
156
+ <tr><td>frozen GPT-2</td><td>31.2</td><td>—</td><td>0</td></tr>
157
+ <tr><td>+ RAG-style retrieve &amp; rescore</td><td>29.9</td><td>−4 %</td>
158
+ <td>corpus + index</td></tr>
159
+ <tr><td>+ kNN-LM, <b>unbounded</b> store</td><td>23.6</td><td>−24 %</td>
160
+ <td>55 MB, grows forever</td></tr>
161
+ <tr class="best"><td>+ this memory (fixed)</td><td>19.2</td><td>−38 %</td>
162
+ <td>4.2 MB, constant</td></tr>
163
+ <tr class="best"><td>+ memory and fast weights</td><td>16.6</td><td>−47 %</td>
164
+ <td>7.4 MB, constant</td></tr>
165
+ </tbody></table>
166
+ <p class="note">Paired block bootstrap <b>P = 1.000</b> against the unbounded
167
+ datastore, replicated over 5 random seeds and on a second model
168
+ (Qwen3-0.6B).</p>
169
+
170
+ <h2><span class="num">4</span>Where it does not work</h2>
171
+ <div class="panel">
172
+ <p><b>On long, low-repetition narrative, an unbounded kNN-LM still wins</b>
173
+ (+0.048 vs +0.007 nats). This memory captures verbatim recurrence, not
174
+ paraphrase. The boundary is measured and published rather than hidden.</p>
175
+ <p>Three results that did <i>not</i> work are published too. Hidden states
176
+ make poor Hebbian keys — their geometry is too entangled. Surprise gating
177
+ helps the memory and <i>hurts</i> the fast-weight adapter, because the delta
178
+ rule already carries its own error term. And calibrating the readout on your
179
+ own stream loses to a proper tuning (+0.109 against +0.120 nats), because the
180
+ calibration window is read by a colder memory than the one it will govern.</p>
181
+ <p>A fixed matrix also saturates at long horizons, near 0.5 writes per
182
+ parameter. Forgetting recovers ×2.3 of the gain and a 4× larger matrix
183
+ recovers ×3.4 — both are one flag away.</p>
184
+ </div>
185
+
186
+ <h2><span class="num">5</span>Run it yourself</h2>
187
+ <p class="lede">Every output on this page came out of the command line below,
188
+ on a laptop CPU. It works with any causal language model — a Hugging Face id
189
+ or a local folder — not just the two the papers measured.</p>
190
+ <pre class="sh">pip install sillage
191
+
192
+ sillage index notes.md <span class="c"># instant: no model needed</span>
193
+ sillage ask "what did the report say?"
194
+
195
+ sillage read notes.md <span class="c"># memorise it</span>
196
+ sillage complete "The report said" <span class="c"># generate WITH the memory</span>
197
+ sillage status <span class="c"># what it knows, tier by tier</span>
198
+
199
+ sillage read notes.md --model HuggingFaceTB/SmolLM2-135M <span class="c"># any LM</span></pre>
200
+
201
+ <footer>
202
+ <p>Four preprints with permanent DOIs, the full reproduction pipeline, every
203
+ number as committed JSON and 24 tests:
204
+ <a href="https://github.com/riscoss63/sillage">github.com/riscoss63/sillage</a>
205
+ · <a href="https://pypi.org/project/sillage/">pypi.org/project/sillage</a>
206
+ · <a href="https://doi.org/10.5281/zenodo.22079016">doi:10.5281/zenodo.22079016</a></p>
207
+ <p id="provenance"></p>
208
+ </footer>
209
+
210
+ </div>
211
+ <script>
212
+ const DATA = __DATA__;
213
+
214
+ function split(prompt, text) {
215
+ // the prompt is echoed back by the tool; colour only what was generated
216
+ const rest = text.startsWith(prompt) ? text.slice(prompt.length) : text;
217
+ const head = text.startsWith(prompt) ? prompt : "";
218
+ return [head, rest];
219
+ }
220
+ function render(i) {
221
+ const c = DATA.completions[i];
222
+ for (const [id, key] of [["out-frozen", "frozen"], ["out-memory", "memory"]]) {
223
+ const [head, rest] = split(c.prompt, c.prompt + c[key]);
224
+ document.getElementById(id).innerHTML =
225
+ '<span class="p">' + esc(head) + '</span><span class="new">'
226
+ + esc(rest) + '</span>';
227
+ }
228
+ document.getElementById("note").textContent = c.same
229
+ ? "Identical here — the memory abstains when it is not confident, which "
230
+ + "is what keeps it from doing harm."
231
+ : "Everything after the prompt on the right comes from the paper the "
232
+ + "memory read, not from GPT-2's weights.";
233
+ document.querySelectorAll(".chip").forEach((b, k) =>
234
+ b.setAttribute("aria-pressed", k === i ? "true" : "false"));
235
+ }
236
+ function esc(s) {
237
+ return s.replace(/[&<>]/g, m => ({"&":"&amp;","<":"&lt;",">":"&gt;"}[m]));
238
+ }
239
+ const chips = document.getElementById("chips");
240
+ DATA.completions.forEach((c, i) => {
241
+ const b = document.createElement("button");
242
+ b.className = "chip";
243
+ b.type = "button";
244
+ b.textContent = c.prompt.length > 52 ? c.prompt.slice(0, 52) + "…" : c.prompt;
245
+ b.title = c.prompt;
246
+ b.onclick = () => render(i);
247
+ chips.appendChild(b);
248
+ });
249
+ document.getElementById("read-what").textContent = DATA.read;
250
+ document.getElementById("manual-summary").innerHTML = md(DATA.manual.summary);
251
+ const body = document.querySelector("#fixes tbody");
252
+ DATA.manual.fixes.forEach(row => {
253
+ const tr = document.createElement("tr");
254
+ row.forEach(cell => {
255
+ const td = document.createElement("td");
256
+ td.className = "mono";
257
+ td.textContent = cell;
258
+ tr.appendChild(td);
259
+ });
260
+ body.appendChild(tr);
261
+ });
262
+ document.getElementById("provenance").textContent =
263
+ "The completions and corrections on this page are recorded runs of "
264
+ + DATA.model + " on a laptop CPU, state " + DATA.state_mb
265
+ + " MB — reproduce them with the commands above.";
266
+ function md(s) {
267
+ return "<p>" + esc(s)
268
+ .replace(/\\*\\*(.+?)\\*\\*/g, "<b>$1</b>")
269
+ .replace(/\\*(.+?)\\*/g, "<i>$1</i>")
270
+ .replace(/\\n\\n/g, "</p><p>") + "</p>";
271
+ }
272
+ render(0);
273
+ </script>
274
+ </body>
275
+ </html>
276
+ """
277
+
278
+
279
+ def main():
280
+ data = json.load(io.open(os.path.join(HERE, "data.json"),
281
+ encoding="utf-8"))
282
+ page = PAGE.replace("__DATA__", json.dumps(data, ensure_ascii=False))
283
+ out = os.path.join(HERE, "index.html")
284
+ io.open(out, "w", encoding="utf-8", newline="\n").write(page)
285
+ print(f"wrote {out}: {len(page) / 1024:.0f} kB, "
286
+ f"{len(data['completions'])} completions, "
287
+ f"{len(data['manual']['fixes'])} corrections")
288
+
289
+
290
+ if __name__ == "__main__":
291
+ main()
build_state.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build the memory the Space ships with, so the first screen is instant.
2
+
3
+ Reads paper 1 into a GPT-2 memory and leaves it in ./state (7.4 MB). Run it
4
+ from a clone of the sillage repository, or point it at any document you want
5
+ the Space to have already read.
6
+
7
+ python build_state.py [path/to/document.tex]
8
+ """
9
+
10
+ import os
11
+ import shutil
12
+ import sys
13
+
14
+ from sillage import Sillage
15
+
16
+ HERE = os.path.dirname(os.path.abspath(__file__))
17
+ STATE = os.path.join(HERE, "state")
18
+ DEFAULT = os.path.join(os.path.dirname(HERE), "llm_memory", "papers",
19
+ "sillage", "sillage.tex")
20
+
21
+
22
+ def main():
23
+ doc = sys.argv[1] if len(sys.argv) > 1 else DEFAULT
24
+ if not os.path.exists(doc):
25
+ raise SystemExit(f"no such document: {doc}")
26
+ shutil.rmtree(STATE, ignore_errors=True)
27
+ s = Sillage(model="gpt2", state=STATE)
28
+ rec = s.read(doc)[0]
29
+ size = sum(os.path.getsize(os.path.join(STATE, f))
30
+ for f in os.listdir(STATE)) / 1e6
31
+ print(f"{rec['file']}: {rec['tokens']} tokens, PPL {rec['ppl_frozen']} "
32
+ f"-> {rec['ppl_with_memory']}, state {size:.1f} MB in {STATE}")
33
+ print("The Space will load this memory at startup.")
34
+
35
+
36
+ if __name__ == "__main__":
37
+ main()
capture.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Capture real outputs so the static page can show them without a server.
2
+
3
+ A static Space has no backend: nothing can run GPT-2 for the visitor. What it
4
+ can do is show what GPT-2 really produced, side by side, and say plainly that
5
+ these are recorded runs anyone can reproduce with `pip install sillage`.
6
+
7
+ Everything written to data.json here comes out of the tool, unedited.
8
+
9
+ python capture.py -> data.json
10
+ """
11
+
12
+ import json
13
+ import os
14
+ import tempfile
15
+
16
+ import sample
17
+ from sillage import Sillage
18
+
19
+ HERE = os.path.dirname(os.path.abspath(__file__))
20
+ STATE = os.path.join(HERE, "state")
21
+ N = 20
22
+
23
+ PROMPTS = [
24
+ "On a 36k-token stream of novel technical text, the memory",
25
+ "the memory improves GPT-2's test negative log-likelihood by",
26
+ "At 500k tokens the fixed matrix",
27
+ "surprise gating quadruples the gain of",
28
+ "Every write is gated by the model's own",
29
+ "a three-factor plasticity rule whose modulator is",
30
+ ]
31
+
32
+
33
+ def main():
34
+ memory = Sillage(model="gpt2", state=STATE, quiet=False)
35
+ frozen = Sillage(model="gpt2", state=tempfile.mkdtemp(), quiet=True)
36
+ tok, model = memory.load_model()
37
+ frozen._tok, frozen._model = tok, model
38
+
39
+ out = {"model": "openai-community/gpt2",
40
+ "read": "papers/sillage/sillage.tex (8969 tokens)",
41
+ "state_mb": round(sum(
42
+ os.path.getsize(os.path.join(STATE, f))
43
+ for f in os.listdir(STATE)) / 1e6, 1),
44
+ "completions": []}
45
+ for p in PROMPTS:
46
+ a, b = frozen.complete(p, n=N), memory.complete(p, n=N)
47
+ out["completions"].append({"prompt": p, "frozen": a, "memory": b,
48
+ "same": a.strip() == b.strip()})
49
+ print(f" {p[:44]:46s} {'=' if a == b else 'differs'}")
50
+
51
+ import app # reuse the demo's instrumented read loop
52
+ plot, summary, fixes, _, suggestion = app.read_and_report(sample.MANUAL)
53
+ out["manual"] = {"summary": summary, "fixes": fixes or [],
54
+ "perplexity": plot, "suggestion": suggestion}
55
+ a, b = frozen.complete(suggestion, n=12), None
56
+ fresh = Sillage(model="gpt2", state=app.SESSIONS[-1], quiet=True)
57
+ fresh._tok, fresh._model = tok, model
58
+ b = fresh.complete(suggestion, n=12)
59
+ out["manual"]["completion"] = {"prompt": suggestion, "frozen": a,
60
+ "memory": b}
61
+
62
+ with open(os.path.join(HERE, "data.json"), "w", encoding="utf-8") as f:
63
+ json.dump(out, f, indent=1, ensure_ascii=False)
64
+ print(f"wrote data.json: {len(out['completions'])} completions, "
65
+ f"{len(out['manual']['fixes'])} corrections")
66
+
67
+
68
+ if __name__ == "__main__":
69
+ main()
data.json ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model": "openai-community/gpt2",
3
+ "read": "papers/sillage/sillage.tex (8969 tokens)",
4
+ "state_mb": 7.4,
5
+ "completions": [
6
+ {
7
+ "prompt": "On a 36k-token stream of novel technical text, the memory",
8
+ "frozen": " of the original text is not lost.\n\nThe original text is not lost.\n\nThe",
9
+ "memory": " improves GPT-2's test negative log-likelihood by +0.486 +/- 0.",
10
+ "same": false
11
+ },
12
+ {
13
+ "prompt": "the memory improves GPT-2's test negative log-likelihood by",
14
+ "frozen": " a factor of 1.5.\n\nThe results of the study are summarized in Table 1.",
15
+ "memory": " +0.486 +/- 0.005 nats (perplexity 31.2 -> 19.",
16
+ "same": false
17
+ },
18
+ {
19
+ "prompt": "At 500k tokens the fixed matrix",
20
+ "frozen": " is a bit more complex.\n\nThe first thing to note is that the fixed matrix is not",
21
+ "memory": " saturates (0.5 writes per parameter); leaky decay recovers 2.3k writes per",
22
+ "same": false
23
+ },
24
+ {
25
+ "prompt": "surprise gating quadruples the gain of",
26
+ "frozen": " the previous year.\n\nThe new study, published in the journal Nature Communications, found that the",
27
+ "memory": " uniform writes at equal plasticity budget (+0.203 vs +0.050 for the count variant",
28
+ "same": false
29
+ },
30
+ {
31
+ "prompt": "Every write is gated by the model's own",
32
+ "frozen": " rules.\n\nThe model's rules are:\n\nThe model must be able to write a",
33
+ "memory": " surprise\n\n\nThe model's surprise is the surprise of the augmented reality -- the surprise of the augmented",
34
+ "same": false
35
+ },
36
+ {
37
+ "prompt": "a three-factor plasticity rule whose modulator is",
38
+ "frozen": " a single-cell polyethylene (CPM) polyethylene (PPM) polyethyl",
39
+ "memory": " free at inference. We call the system . On the other hand, the system is free at inference",
40
+ "same": false
41
+ }
42
+ ],
43
+ "manual": {
44
+ "summary": "**Read once, left to right: 652 tokens built the memory, the next 653 were measured with it.** That split is the papers' own protocol — the numbers below are on text the memory had not seen when it scored them, and every token was scored *before* being written.\n\nPerplexity on that second half: 11.16 frozen → 9.84 with the rank-16 adapter → **9.80** with the memory on top — **12% lower**. The memory spoke on 36% of those positions and kept quiet on the rest; abstaining when it has nothing to say is what keeps it from doing harm.\n\nState on disk: **7.4 MB**, and it would still be 7.4 MB after a million tokens. A kNN-LM datastore over the same text would already hold about 8.0 MB, and would keep growing.",
45
+ "fixes": [
46
+ [
47
+ "wick valves or the ninth cabinet is reported immediately",
48
+ "','",
49
+ "' to'"
50
+ ],
51
+ [
52
+ "incoming duty officer. The morning routine begins at",
53
+ "' 05'",
54
+ "' 19'"
55
+ ],
56
+ [
57
+ " holds the amber cipher. The rotation changes every eleven",
58
+ "' days'",
59
+ "'\\n'"
60
+ ],
61
+ [
62
+ " incident, and is recorded in the gallery log",
63
+ "' beside'",
64
+ "'.'"
65
+ ],
66
+ [
67
+ ". Record the seal number in the gallery log.",
68
+ "'\\n'",
69
+ "' The'"
70
+ ],
71
+ [
72
+ "in manifold. Confirm the Kelbrin manifold",
73
+ "' reads'",
74
+ "' is'"
75
+ ],
76
+ [
77
+ ". Confirm the Kelbrin manifold reads between",
78
+ "' 4'",
79
+ "'\\n'"
80
+ ],
81
+ [
82
+ "4.6 bar. Open the Fenwick valves",
83
+ "' in'",
84
+ "'.'"
85
+ ],
86
+ [
87
+ "6 bar. Open the Fenwick valves in the",
88
+ "' order'",
89
+ "' west'"
90
+ ],
91
+ [
92
+ " bar. Open the Fenwick valves in the order",
93
+ "' three'",
94
+ "' in'"
95
+ ],
96
+ [
97
+ " one, two. Inspect the condensate",
98
+ "' trap'",
99
+ "'.'"
100
+ ],
101
+ [
102
+ " the seal number. Confirm the technician",
103
+ "' on'",
104
+ "\"'s\""
105
+ ]
106
+ ],
107
+ "perplexity": {
108
+ "frozen GPT-2": 11.161853381368227,
109
+ "+ fast weights": 9.837810800300112,
110
+ "+ memory": 9.795396904604075
111
+ },
112
+ "suggestion": "The Fenwick valves open three, one,",
113
+ "completion": {
114
+ "prompt": "The Fenwick valves open three, one,",
115
+ "frozen": " two, three, four, five, six, seven,",
116
+ "memory": " two. Inspect the\n\nfenwick valve and the"
117
+ }
118
+ }
119
+ }
demo.gif ADDED

Git LFS Details

  • SHA256: b9bbadf857fa00fa8ca5aabd44bbeeb742703ab52446a365e6eee6f9e7f6f29c
  • Pointer size: 132 Bytes
  • Size of remote file: 1.25 MB
index.html ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Sillage — a frozen LM that remembers what it reads</title>
7
+ <style>
8
+ :root {
9
+ --bg:#0d1117; --panel:#161b22; --line:#26303d; --ink:#c9d1d9;
10
+ --dim:#8b949e; --hl:#79c0ff; --warm:#ffc457; --ok:#3fb950;
11
+ }
12
+ * { box-sizing:border-box }
13
+ body {
14
+ margin:0; background:var(--bg); color:var(--ink); line-height:1.6;
15
+ font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,
16
+ Helvetica,Arial,sans-serif;
17
+ }
18
+ .wrap { max-width:980px; margin:0 auto; padding:0 20px 80px }
19
+ header { text-align:center; padding:56px 0 24px }
20
+ h1 { font-size:2.6rem; margin:0 0 10px; letter-spacing:-.02em }
21
+ .tag { font-size:1.15rem; color:var(--ink); max-width:640px; margin:0 auto }
22
+ .etym { color:var(--dim); font-style:italic; font-size:.95rem;
23
+ max-width:560px; margin:18px auto 0 }
24
+ .badges { margin:22px 0 8px; display:flex; gap:8px; justify-content:center;
25
+ flex-wrap:wrap }
26
+ .badges img { height:20px }
27
+ h2 { font-size:1.5rem; margin:52px 0 6px; letter-spacing:-.01em }
28
+ h2 .num { color:var(--dim); font-weight:400; margin-right:8px }
29
+ .lede { color:var(--dim); margin:0 0 20px }
30
+ .gif { width:100%; border:1px solid var(--line); border-radius:10px;
31
+ display:block; margin:26px 0 }
32
+ .chips { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:16px }
33
+ .chip {
34
+ background:var(--panel); border:1px solid var(--line); color:var(--ink);
35
+ border-radius:999px; padding:7px 14px; cursor:pointer; font-size:.9rem;
36
+ font-family:inherit; transition:border-color .15s, color .15s;
37
+ }
38
+ .chip:hover { border-color:var(--hl); color:var(--hl) }
39
+ .chip[aria-pressed="true"] { border-color:var(--hl); color:var(--hl);
40
+ background:#16273b }
41
+ .cols { display:grid; grid-template-columns:1fr 1fr; gap:14px }
42
+ @media (max-width:760px) { .cols { grid-template-columns:1fr } }
43
+ .col { background:var(--panel); border:1px solid var(--line);
44
+ border-radius:10px; padding:14px 16px; min-height:170px }
45
+ .col h3 { margin:0 0 10px; font-size:.85rem; text-transform:uppercase;
46
+ letter-spacing:.08em; color:var(--dim); font-weight:600 }
47
+ .col.win h3 { color:var(--ok) }
48
+ .out { font-family:"Cascadia Mono",Consolas,ui-monospace,monospace;
49
+ font-size:.9rem; white-space:pre-wrap; word-break:break-word; margin:0 }
50
+ .out .p { color:var(--dim) }
51
+ .out .new { color:var(--warm) }
52
+ .col.win .out .new { color:var(--hl) }
53
+ .note { color:var(--dim); font-size:.9rem; margin:12px 0 0 }
54
+ table { width:100%; border-collapse:collapse; margin:18px 0; font-size:.92rem }
55
+ th,td { text-align:left; padding:9px 12px; border-bottom:1px solid var(--line) }
56
+ th { color:var(--dim); font-weight:600; font-size:.82rem;
57
+ text-transform:uppercase; letter-spacing:.06em }
58
+ td.mono,th.mono { font-family:"Cascadia Mono",Consolas,ui-monospace,monospace;
59
+ font-size:.86rem }
60
+ tr.best td { color:var(--hl); font-weight:600 }
61
+ .panel { background:var(--panel); border:1px solid var(--line);
62
+ border-radius:10px; padding:18px 20px; margin:18px 0 }
63
+ .panel p:first-child { margin-top:0 }
64
+ .panel p:last-child { margin-bottom:0 }
65
+ pre.sh { background:var(--panel); border:1px solid var(--line);
66
+ border-radius:10px; padding:16px 18px; overflow-x:auto;
67
+ font-family:"Cascadia Mono",Consolas,ui-monospace,monospace;
68
+ font-size:.9rem; color:var(--ink) }
69
+ pre.sh .c { color:var(--dim) }
70
+ a { color:var(--hl) }
71
+ footer { color:var(--dim); font-size:.9rem; text-align:center;
72
+ margin-top:64px; border-top:1px solid var(--line); padding-top:24px }
73
+ .kbd { font-family:"Cascadia Mono",Consolas,ui-monospace,monospace;
74
+ background:#1c2432; border:1px solid var(--line); border-radius:5px;
75
+ padding:1px 6px; font-size:.85em }
76
+ </style>
77
+ </head>
78
+ <body>
79
+ <div class="wrap">
80
+
81
+ <header>
82
+ <h1>Sillage</h1>
83
+ <p class="tag">A frozen language model that <b>remembers what it reads</b> —
84
+ 4.2&nbsp;MB, no gradients, no fine-tuning, no vector database.</p>
85
+ <p class="etym">sillage (n., French) — the trace left behind by something
86
+ that has passed: a ship's wake, a scent in a room. What a model keeps of
87
+ what it read.</p>
88
+ <div class="badges">
89
+ <a href="https://pypi.org/project/sillage/"><img alt="PyPI"
90
+ src="https://img.shields.io/pypi/v/sillage.svg"></a>
91
+ <a href="https://github.com/riscoss63/sillage"><img alt="GitHub"
92
+ src="https://img.shields.io/badge/code-GitHub-181717?logo=github"></a>
93
+ <a href="https://doi.org/10.5281/zenodo.22079016"><img alt="DOI"
94
+ src="https://zenodo.org/badge/DOI/10.5281/zenodo.22079016.svg"></a>
95
+ <a href="https://github.com/riscoss63/sillage/blob/main/LICENSE"><img
96
+ alt="MIT" src="https://img.shields.io/badge/License-MIT-blue.svg"></a>
97
+ </div>
98
+ </header>
99
+
100
+ <img class="gif" src="demo.gif" alt="Two sessions a day apart: the model
101
+ reads a draft on Monday and halves the perplexity of the next draft on
102
+ Tuesday, then completes a sentence it can only know from what it read.">
103
+
104
+ <h2><span class="num">1</span>It has already read a paper</h2>
105
+ <p class="lede">The memory below has read
106
+ <b id="read-what"></b> — the paper describing this very mechanism, which
107
+ GPT-2 has never seen. Same prompt, same greedy decoding, one column with the
108
+ memory and one without. Pick a beginning:</p>
109
+
110
+ <div class="chips" id="chips"></div>
111
+ <div class="cols">
112
+ <div class="col"><h3>GPT-2, frozen</h3><p class="out" id="out-frozen"></p></div>
113
+ <div class="col win"><h3>GPT-2 + Sillage memory</h3>
114
+ <p class="out" id="out-memory"></p></div>
115
+ </div>
116
+ <p class="note" id="note"></p>
117
+
118
+ <h2><span class="num">2</span>What it does to a document it has never seen</h2>
119
+ <p class="lede">An operations manual invented for this demo, so GPT-2 cannot
120
+ have seen it. Read <b>once</b>, left to right: the first half builds the
121
+ memory, the second half is measured with it — the papers' own dev/test split —
122
+ and every token is scored <i>before</i> being written.</p>
123
+ <div class="panel" id="manual-summary"></div>
124
+
125
+ <h3 style="margin:26px 0 4px">Predictions the memory corrected</h3>
126
+ <p class="lede">The frozen model had no way of knowing these. They are facts
127
+ that exist only in that document.</p>
128
+ <table id="fixes"><thead><tr>
129
+ <th class="mono">context</th><th class="mono">what came next</th>
130
+ <th class="mono">what frozen GPT-2 said</th>
131
+ </tr></thead><tbody></tbody></table>
132
+
133
+ <h2><span class="num">3</span>How much better, exactly</h2>
134
+ <p class="lede">36k tokens of technical text the model had never seen, frozen
135
+ GPT-2 124M, every system tuned identically on a held-out prefix,
136
+ 95&nbsp;% bootstrap confidence intervals.</p>
137
+ <table>
138
+ <thead><tr><th>system</th><th>perplexity</th><th>change</th>
139
+ <th>memory used</th></tr></thead>
140
+ <tbody>
141
+ <tr><td>frozen GPT-2</td><td>31.2</td><td>—</td><td>0</td></tr>
142
+ <tr><td>+ RAG-style retrieve &amp; rescore</td><td>29.9</td><td>−4 %</td>
143
+ <td>corpus + index</td></tr>
144
+ <tr><td>+ kNN-LM, <b>unbounded</b> store</td><td>23.6</td><td>−24 %</td>
145
+ <td>55 MB, grows forever</td></tr>
146
+ <tr class="best"><td>+ this memory (fixed)</td><td>19.2</td><td>−38 %</td>
147
+ <td>4.2 MB, constant</td></tr>
148
+ <tr class="best"><td>+ memory and fast weights</td><td>16.6</td><td>−47 %</td>
149
+ <td>7.4 MB, constant</td></tr>
150
+ </tbody></table>
151
+ <p class="note">Paired block bootstrap <b>P = 1.000</b> against the unbounded
152
+ datastore, replicated over 5 random seeds and on a second model
153
+ (Qwen3-0.6B).</p>
154
+
155
+ <h2><span class="num">4</span>Where it does not work</h2>
156
+ <div class="panel">
157
+ <p><b>On long, low-repetition narrative, an unbounded kNN-LM still wins</b>
158
+ (+0.048 vs +0.007 nats). This memory captures verbatim recurrence, not
159
+ paraphrase. The boundary is measured and published rather than hidden.</p>
160
+ <p>Three results that did <i>not</i> work are published too. Hidden states
161
+ make poor Hebbian keys — their geometry is too entangled. Surprise gating
162
+ helps the memory and <i>hurts</i> the fast-weight adapter, because the delta
163
+ rule already carries its own error term. And calibrating the readout on your
164
+ own stream loses to a proper tuning (+0.109 against +0.120 nats), because the
165
+ calibration window is read by a colder memory than the one it will govern.</p>
166
+ <p>A fixed matrix also saturates at long horizons, near 0.5 writes per
167
+ parameter. Forgetting recovers ×2.3 of the gain and a 4× larger matrix
168
+ recovers ×3.4 — both are one flag away.</p>
169
+ </div>
170
+
171
+ <h2><span class="num">5</span>Run it yourself</h2>
172
+ <p class="lede">Every output on this page came out of the command line below,
173
+ on a laptop CPU. It works with any causal language model — a Hugging Face id
174
+ or a local folder — not just the two the papers measured.</p>
175
+ <pre class="sh">pip install sillage
176
+
177
+ sillage index notes.md <span class="c"># instant: no model needed</span>
178
+ sillage ask "what did the report say?"
179
+
180
+ sillage read notes.md <span class="c"># memorise it</span>
181
+ sillage complete "The report said" <span class="c"># generate WITH the memory</span>
182
+ sillage status <span class="c"># what it knows, tier by tier</span>
183
+
184
+ sillage read notes.md --model HuggingFaceTB/SmolLM2-135M <span class="c"># any LM</span></pre>
185
+
186
+ <footer>
187
+ <p>Four preprints with permanent DOIs, the full reproduction pipeline, every
188
+ number as committed JSON and 24 tests:
189
+ <a href="https://github.com/riscoss63/sillage">github.com/riscoss63/sillage</a>
190
+ · <a href="https://pypi.org/project/sillage/">pypi.org/project/sillage</a>
191
+ · <a href="https://doi.org/10.5281/zenodo.22079016">doi:10.5281/zenodo.22079016</a></p>
192
+ <p id="provenance"></p>
193
+ </footer>
194
+
195
+ </div>
196
+ <script>
197
+ const DATA = {"model": "openai-community/gpt2", "read": "papers/sillage/sillage.tex (8969 tokens)", "state_mb": 7.4, "completions": [{"prompt": "On a 36k-token stream of novel technical text, the memory", "frozen": " of the original text is not lost.\n\nThe original text is not lost.\n\nThe", "memory": " improves GPT-2's test negative log-likelihood by +0.486 +/- 0.", "same": false}, {"prompt": "the memory improves GPT-2's test negative log-likelihood by", "frozen": " a factor of 1.5.\n\nThe results of the study are summarized in Table 1.", "memory": " +0.486 +/- 0.005 nats (perplexity 31.2 -> 19.", "same": false}, {"prompt": "At 500k tokens the fixed matrix", "frozen": " is a bit more complex.\n\nThe first thing to note is that the fixed matrix is not", "memory": " saturates (0.5 writes per parameter); leaky decay recovers 2.3k writes per", "same": false}, {"prompt": "surprise gating quadruples the gain of", "frozen": " the previous year.\n\nThe new study, published in the journal Nature Communications, found that the", "memory": " uniform writes at equal plasticity budget (+0.203 vs +0.050 for the count variant", "same": false}, {"prompt": "Every write is gated by the model's own", "frozen": " rules.\n\nThe model's rules are:\n\nThe model must be able to write a", "memory": " surprise\n\n\nThe model's surprise is the surprise of the augmented reality -- the surprise of the augmented", "same": false}, {"prompt": "a three-factor plasticity rule whose modulator is", "frozen": " a single-cell polyethylene (CPM) polyethylene (PPM) polyethyl", "memory": " free at inference. We call the system . On the other hand, the system is free at inference", "same": false}], "manual": {"summary": "**Read once, left to right: 652 tokens built the memory, the next 653 were measured with it.** That split is the papers' own protocol — the numbers below are on text the memory had not seen when it scored them, and every token was scored *before* being written.\n\nPerplexity on that second half: 11.16 frozen → 9.84 with the rank-16 adapter → **9.80** with the memory on top — **12% lower**. The memory spoke on 36% of those positions and kept quiet on the rest; abstaining when it has nothing to say is what keeps it from doing harm.\n\nState on disk: **7.4 MB**, and it would still be 7.4 MB after a million tokens. A kNN-LM datastore over the same text would already hold about 8.0 MB, and would keep growing.", "fixes": [["wick valves or the ninth cabinet is reported immediately", "','", "' to'"], ["incoming duty officer. The morning routine begins at", "' 05'", "' 19'"], [" holds the amber cipher. The rotation changes every eleven", "' days'", "'\\n'"], [" incident, and is recorded in the gallery log", "' beside'", "'.'"], [". Record the seal number in the gallery log.", "'\\n'", "' The'"], ["in manifold. Confirm the Kelbrin manifold", "' reads'", "' is'"], [". Confirm the Kelbrin manifold reads between", "' 4'", "'\\n'"], ["4.6 bar. Open the Fenwick valves", "' in'", "'.'"], ["6 bar. Open the Fenwick valves in the", "' order'", "' west'"], [" bar. Open the Fenwick valves in the order", "' three'", "' in'"], [" one, two. Inspect the condensate", "' trap'", "'.'"], [" the seal number. Confirm the technician", "' on'", "\"'s\""]], "perplexity": {"frozen GPT-2": 11.161853381368227, "+ fast weights": 9.837810800300112, "+ memory": 9.795396904604075}, "suggestion": "The Fenwick valves open three, one,", "completion": {"prompt": "The Fenwick valves open three, one,", "frozen": " two, three, four, five, six, seven,", "memory": " two. Inspect the\n\nfenwick valve and the"}}};
198
+
199
+ function split(prompt, text) {
200
+ // the prompt is echoed back by the tool; colour only what was generated
201
+ const rest = text.startsWith(prompt) ? text.slice(prompt.length) : text;
202
+ const head = text.startsWith(prompt) ? prompt : "";
203
+ return [head, rest];
204
+ }
205
+ function render(i) {
206
+ const c = DATA.completions[i];
207
+ for (const [id, key] of [["out-frozen", "frozen"], ["out-memory", "memory"]]) {
208
+ const [head, rest] = split(c.prompt, c.prompt + c[key]);
209
+ document.getElementById(id).innerHTML =
210
+ '<span class="p">' + esc(head) + '</span><span class="new">'
211
+ + esc(rest) + '</span>';
212
+ }
213
+ document.getElementById("note").textContent = c.same
214
+ ? "Identical here — the memory abstains when it is not confident, which "
215
+ + "is what keeps it from doing harm."
216
+ : "Everything after the prompt on the right comes from the paper the "
217
+ + "memory read, not from GPT-2's weights.";
218
+ document.querySelectorAll(".chip").forEach((b, k) =>
219
+ b.setAttribute("aria-pressed", k === i ? "true" : "false"));
220
+ }
221
+ function esc(s) {
222
+ return s.replace(/[&<>]/g, m => ({"&":"&amp;","<":"&lt;",">":"&gt;"}[m]));
223
+ }
224
+ const chips = document.getElementById("chips");
225
+ DATA.completions.forEach((c, i) => {
226
+ const b = document.createElement("button");
227
+ b.className = "chip";
228
+ b.type = "button";
229
+ b.textContent = c.prompt.length > 52 ? c.prompt.slice(0, 52) + "…" : c.prompt;
230
+ b.title = c.prompt;
231
+ b.onclick = () => render(i);
232
+ chips.appendChild(b);
233
+ });
234
+ document.getElementById("read-what").textContent = DATA.read;
235
+ document.getElementById("manual-summary").innerHTML = md(DATA.manual.summary);
236
+ const body = document.querySelector("#fixes tbody");
237
+ DATA.manual.fixes.forEach(row => {
238
+ const tr = document.createElement("tr");
239
+ row.forEach(cell => {
240
+ const td = document.createElement("td");
241
+ td.className = "mono";
242
+ td.textContent = cell;
243
+ tr.appendChild(td);
244
+ });
245
+ body.appendChild(tr);
246
+ });
247
+ document.getElementById("provenance").textContent =
248
+ "The completions and corrections on this page are recorded runs of "
249
+ + DATA.model + " on a laptop CPU, state " + DATA.state_mb
250
+ + " MB — reproduce them with the commands above.";
251
+ function md(s) {
252
+ return "<p>" + esc(s)
253
+ .replace(/\*\*(.+?)\*\*/g, "<b>$1</b>")
254
+ .replace(/\*(.+?)\*/g, "<i>$1</i>")
255
+ .replace(/\n\n/g, "</p><p>") + "</p>";
256
+ }
257
+ render(0);
258
+ </script>
259
+ </body>
260
+ </html>
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ # The tool itself, straight from PyPI.
2
+ sillage==1.0.1
3
+
4
+ # ZeroGPU: provides the @spaces.GPU decorator. Harmless anywhere else.
5
+ spaces
sample.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Texts the visitor can read into a fresh memory in one click.
2
+
3
+ Two of them, chosen for what they demonstrate:
4
+
5
+ MANUAL an internal-looking operations manual, invented for this demo, so
6
+ GPT-2 cannot possibly have seen it. Recurring names, recurring
7
+ procedures, the structure real internal documents actually have --
8
+ the regime where a fixed memory wins. Long enough (~1400 tokens)
9
+ that the abstention threshold has a distribution to work from.
10
+ PAPER the opening of paper 1, i.e. genuinely novel technical prose.
11
+ """
12
+
13
+ import os
14
+
15
+ MANUAL = """Meridian Station — Standing Operations Manual, revision 12
16
+
17
+ 1. Purpose
18
+
19
+ This manual governs every routine carried out at Meridian Station. It is
20
+ issued by the Station Warden and supersedes revision 11. Where this manual
21
+ conflicts with a local instruction, this manual prevails, and the Station
22
+ Warden is notified within one shift.
23
+
24
+ 2. The morning routine
25
+
26
+ The morning routine begins at 05:40 when the duty officer unseals the west
27
+ gallery. The duty officer records the seal number in the gallery log, then
28
+ verifies the pressure of the Kelbrin manifold. The Kelbrin manifold must read
29
+ between 4.1 and 4.6 bar. If the Kelbrin manifold reads below 4.1 bar, the
30
+ duty officer raises a yellow flag with the Station Warden and does not
31
+ proceed to the second stage. If the Kelbrin manifold reads above 4.6 bar, the
32
+ duty officer vents the auxiliary line and waits eleven minutes before reading
33
+ it again.
34
+
35
+ Once the Kelbrin manifold is within range, the duty officer opens the
36
+ Fenwick valves in the order three, one, two. The order three, one, two is not
37
+ negotiable: opening the Fenwick valves in any other order floods the
38
+ condensate trap. The condensate trap is drained every second morning by the
39
+ technician on rotation, who signs the gallery log beside the seal number.
40
+
41
+ 3. The evening routine
42
+
43
+ The evening routine begins at 19:15 when the duty officer reseals the west
44
+ gallery. Before resealing, the duty officer closes the Fenwick valves in the
45
+ order two, one, three, which is the reverse of the morning order. The
46
+ condensate trap is inspected, and the reading of the Kelbrin manifold is
47
+ recorded a second time in the gallery log.
48
+
49
+ If the Kelbrin manifold has drifted by more than 0.3 bar since morning, the
50
+ duty officer files a drift note. A drift note names the shift, the two
51
+ readings, and the technician on rotation. Three drift notes in one week
52
+ trigger a manifold survey by the Station Warden.
53
+
54
+ 4. Personnel
55
+
56
+ The Station Warden is Ilvane Corr. The Station Warden holds the only key to
57
+ the ninth cabinet and is the sole authority for a manifold survey. In the
58
+ absence of Ilvane Corr, authority passes to the senior technician, currently
59
+ Bo Halloway, except for the ninth cabinet, which remains sealed.
60
+
61
+ The technician on rotation changes every eleven days. The rotation list is
62
+ posted in the west gallery and countersigned by Bo Halloway. A technician on
63
+ rotation may not also serve as duty officer during the same shift.
64
+
65
+ 5. The ninth cabinet
66
+
67
+ The ninth cabinet holds the amber cipher and the spare seals for the west
68
+ gallery. Only Ilvane Corr may open the ninth cabinet. The amber cipher is
69
+ required whenever the Kelbrin manifold is surveyed, and it is returned to the
70
+ ninth cabinet before the evening routine begins. No copy of the amber cipher
71
+ is kept anywhere at Meridian Station.
72
+
73
+ 6. Incidents
74
+
75
+ An incident is any deviation from the morning routine, the evening routine,
76
+ or the rotation list. Incidents are recorded in the gallery log beside the
77
+ seal number, and reported to the Station Warden within one shift. An incident
78
+ involving the Kelbrin manifold, the Fenwick valves or the ninth cabinet is
79
+ reported immediately, not within one shift.
80
+
81
+ If the west gallery cannot be sealed at 19:15, the duty officer declares a
82
+ standing incident, remains at the west gallery, and notifies Ilvane Corr. A
83
+ standing incident is closed only by the Station Warden, in person, with the
84
+ amber cipher present.
85
+
86
+ 7. Handover
87
+
88
+ At every handover the outgoing duty officer recites the fixed points to the
89
+ incoming duty officer. The morning routine begins at 05:40. The evening
90
+ routine begins at 19:15. The Kelbrin manifold reads between 4.1 and 4.6 bar.
91
+ The Fenwick valves open three, one, two and close two, one, three. The
92
+ Station Warden is Ilvane Corr. The senior technician is Bo Halloway. The
93
+ ninth cabinet holds the amber cipher. The rotation changes every eleven days.
94
+
95
+ The incoming duty officer repeats the fixed points back. A handover in which
96
+ the fixed points are not recited and repeated back is an incident, and is
97
+ recorded in the gallery log beside the seal number.
98
+
99
+ 8. Checklist, morning
100
+
101
+ Unseal the west gallery at 05:40. Record the seal number in the gallery log.
102
+ Read the Kelbrin manifold. Confirm the Kelbrin manifold reads between 4.1 and
103
+ 4.6 bar. Open the Fenwick valves in the order three, one, two. Inspect the
104
+ condensate trap. Sign the gallery log beside the seal number. Confirm the
105
+ technician on rotation against the rotation list posted in the west gallery.
106
+
107
+ 9. Checklist, evening
108
+
109
+ Close the Fenwick valves in the order two, one, three. Read the Kelbrin
110
+ manifold a second time. Record the reading in the gallery log. Compare it
111
+ with the morning reading and file a drift note if the Kelbrin manifold has
112
+ drifted by more than 0.3 bar. Confirm the amber cipher has been returned to
113
+ the ninth cabinet. Reseal the west gallery at 19:15. Notify Ilvane Corr of
114
+ any incident within one shift.
115
+
116
+ 10. Summary of the fixed points
117
+
118
+ The morning routine begins at 05:40. The evening routine begins at 19:15. The
119
+ Kelbrin manifold reads between 4.1 and 4.6 bar. The Fenwick valves open three,
120
+ one, two and close two, one, three. The Station Warden is Ilvane Corr. The
121
+ senior technician is Bo Halloway. The ninth cabinet holds the amber cipher.
122
+ The rotation changes every eleven days. These fixed points are recited at
123
+ every handover, and any deviation from them is an incident.
124
+ """
125
+
126
+ _PAPER = os.path.join(os.path.dirname(os.path.dirname(
127
+ os.path.abspath(__file__))), "llm_memory", "papers", "sillage",
128
+ "sillage.tex")
129
+
130
+
131
+ def paper_excerpt(limit=14000):
132
+ """The opening of paper 1 as plain text, if the repository is nearby."""
133
+ try:
134
+ from sillage.index import read_text
135
+ return read_text(_PAPER)[:limit]
136
+ except Exception:
137
+ return MANUAL
state/cold.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:75e7a34ebc793b298e6bb671375edfff20a95404527152d0134e622d890c2484
3
+ size 311125
state/index.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d90f319b3f5828a1cabd15886f1c143da0238b74f322a231be09a2a7a33b64e7
3
+ size 101643
state/log.json ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "files": [
3
+ {
4
+ "file": "sillage.tex",
5
+ "tokens": 8969,
6
+ "date": "2026-08-25 03:02",
7
+ "ppl_frozen": 68.81,
8
+ "ppl_fastweights": 58.24,
9
+ "ppl_with_memory": 56.01
10
+ }
11
+ ]
12
+ }
state/state.npz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c2a11143fe1df59a9fa599336cff651ca3c6bd8fca37d362fb002c6edfc000a9
3
+ size 6985621