fluidlayer 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fluidlayer/losses.py ADDED
@@ -0,0 +1,86 @@
1
+ """
2
+ fluidlayer.losses — training objective for the fluid stack.
3
+
4
+ L_total = CE(next-token)
5
+ + w_E * sheaf energy (smoothness / consistency on edges)
6
+ + w_D * band decorrelation (keep the filter bank from collapsing)
7
+ + w_G * spectral gap bonus (anti-oversmoothing, diagnostic only)
8
+
9
+ The "topological sufficiency" term of the framework spec is implemented as a
10
+ *probe* (see `probe.py`) rather than as a gradient, because the routing
11
+ decision is a hard top-k selection and carries no gradient path.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import numpy as np
17
+
18
+ __all__ = ["cross_entropy", "sheaf_energy_loss", "band_decorrelation", "gap_bonus"]
19
+
20
+
21
+ def cross_entropy(logits, targets) -> float:
22
+ """Mean token cross-entropy from logits (n, V) and targets (n,)."""
23
+ z = logits.data
24
+ z = z - z.max(axis=1, keepdims=True)
25
+ logZ = np.log(np.exp(z).sum(axis=1))
26
+ ll = z[np.arange(len(targets)), targets] - logZ
27
+ return float(-ll.mean())
28
+
29
+
30
+ def cross_entropy_grad(logits, targets) -> np.ndarray:
31
+ """d(mean CE)/d(logits) — (n, V)."""
32
+ z = logits.data
33
+ z = z - z.max(axis=1, keepdims=True)
34
+ e = np.exp(z)
35
+ p = e / e.sum(axis=1, keepdims=True)
36
+ g = p.copy()
37
+ g[np.arange(len(targets)), targets] -= 1.0
38
+ return g / len(targets)
39
+
40
+
41
+ def cross_entropy_tensor(logits, targets):
42
+ """Differentiable mean token cross-entropy (returns a Tensor)."""
43
+ n = len(targets)
44
+ m = logits.max(axis=1, keepdims=True)
45
+ z = logits - m
46
+ logZ = z.exp().sum(axis=1, keepdims=True).log() + m
47
+ picked = logits[np.arange(n), np.asarray(targets, dtype=np.int64)]
48
+ return (-(picked - logZ.reshape(-1))).mean()
49
+
50
+
51
+ def sheaf_energy_loss(block) -> float:
52
+ """Total variation of the field over the routed edges (diagnostic scalar)."""
53
+ d = block.diag
54
+ if not d:
55
+ return 0.0
56
+ return float(d.get("sheaf_energy", 0.0))
57
+
58
+
59
+ def band_decorrelation(bands) -> float:
60
+ """1 - mean |cos| between band responses.
61
+
62
+ If two bands collapse onto the same spectral mode the filter bank stops
63
+ being a frame and information is lost (the rank-c trap in a different
64
+ disguise). This penalises that.
65
+ """
66
+ if not bands or len(bands) < 2:
67
+ return 0.0
68
+ M = []
69
+ for b in bands:
70
+ v = b.data.reshape(-1)
71
+ nrm = np.linalg.norm(v)
72
+ M.append(v / nrm if nrm > 1e-12 else v)
73
+ M = np.stack(M)
74
+ C = M @ M.T
75
+ off = C - np.eye(len(M)) * np.diag(C)
76
+ iu = np.triu_indices(len(M), k=1)
77
+ return float(np.mean(np.abs(off[iu])))
78
+
79
+
80
+ def gap_bonus(L) -> float:
81
+ """lambda_2 of a (numpy) Laplacian -- anti-oversmoothing diagnostic."""
82
+ from .diffusion import spectral_gap
83
+ try:
84
+ return float(spectral_gap(L))
85
+ except Exception:
86
+ return 0.0
fluidlayer/memory.py ADDED
@@ -0,0 +1,525 @@
1
+ """
2
+ fluidlayer.memory — the FROZEN REGISTER and the geometry-addressed memory graft.
3
+ This module implements the two ideas that make the fluid trustworthy:
4
+ A frozen register (L1): a bounded append-only table of token IDs. A value
5
+ is stored as an INTEGER token id, so a retrieved value is bit-identical to
6
+ the stored ID -- not float-approximate. The
7
+ register is never diffused, so the fluid's smoothing cannot destroy it.
8
+ A sheaf memory adapter (L2, the "graft"): a constant-size state
9
+ S in R^{M x r} plus a static prototype codebook. The adapter routes
10
+ (geometry decides WHERE) and the register stores (content decides
11
+ WHAT). That split is what makes exactness possible at all: a diffusion
12
+ smears, so an exact value must never live inside one.
13
+ An external tier (L3): a pointer into the original source. A
14
+ pointer is 6 bytes and is unbounded, so it is the fallback whenever the
15
+ register cannot answer.
16
+ Slot layout (20 bytes, little-endian):
17
+ key uint64 8 B 64-bit context hash (the OCCURRENCE identity)
18
+ val int32 4 B the exact token id
19
+ sig uint32 4 B 4 x uint8 embedding signature (collision detector)
20
+ anchor uint16 2 B the anchor the fact was routed to when promoted
21
+ meta uint16 2 B doc_id (12 bits) | trust (2 bits) | reserved (2)
22
+ Why 20 bytes and not the 14 quoted in docs/DESIGN_pinned_memory.md: the 4-byte
23
+ embedding signature was added so that a 64-bit hash collision is DETECTABLE
24
+ rather than silent ("fail loud, never silently wrong"). The cost of that
25
+ guarantee is 6 bytes/fact, i.e. 819 facts in 16 KB instead of 1,170.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import numpy as np
30
+
31
+ __all__ = [
32
+ "FNV_OFFSET", "FNV_PRIME", "MASK64", "fnv1a64", "ctx_key",
33
+ "FrozenRegister", "SheafMemoryAdapter", "logit_override",
34
+ "budget_split", "embedding_signature", "SLOT_BYTES",
35
+ ]
36
+
37
+ # ------------------------------------------------------------------ hashing
38
+ FNV_OFFSET = 0xCBF29CE484222325
39
+ FNV_PRIME = 0x100000001B3
40
+ MASK64 = (1 << 64) - 1
41
+
42
+
43
+ def fnv1a64(values) -> int:
44
+ """Deterministic 64-bit FNV-1a hash over a sequence of integers.
45
+
46
+ Pure python, no dependencies, and stable across runs/platforms (unlike
47
+ Python's salted `hash()`), so a register written today can be verified
48
+ tomorrow.
49
+ """
50
+ h = FNV_OFFSET
51
+ for v in values:
52
+ h ^= int(v) & MASK64
53
+ h = (h * FNV_PRIME) & MASK64
54
+ return h
55
+
56
+
57
+ def ctx_key(window, doc_id: int = 0) -> int:
58
+ """64-bit identity of a CONTEXT OCCURRENCE (doc_id + token window).
59
+
60
+ The same content in two different documents gets two different keys, so
61
+ a register can be told apart per source; the fact-level grouping (for the
62
+ corroboration quorum) is handled by the caller, which passes the same
63
+ `window` with different `doc_id`s.
64
+ """
65
+ if isinstance(window, (int, np.integer)):
66
+ window = (int(window),)
67
+ vals = [int(doc_id) & 0xFFF]
68
+ vals.extend(int(w) & MASK64 for w in window)
69
+ return fnv1a64(vals)
70
+
71
+
72
+ def embedding_signature(vec: np.ndarray, dims: int = 4) -> int:
73
+ """Quantise the first `dims` coordinates of a unit vector to a uint32.
74
+
75
+ Used as a cheap second channel: if the 64-bit key collides, the
76
+ signature almost certainly will not, and the read fails loud instead of
77
+ returning a wrong exact value.
78
+ """
79
+ v = np.asarray(vec, dtype=np.float64).ravel()
80
+ if v.size == 0:
81
+ return 0
82
+ n = np.linalg.norm(v[:dims]) + 1e-12
83
+ q = np.clip(np.round(v[:dims] / n * 127.0) + 128.0, 0, 255).astype(np.uint8)
84
+ out = 0
85
+ for i in range(dims):
86
+ out |= int(q[i]) << (8 * i)
87
+ return out
88
+
89
+
90
+ # ------------------------------------------------------------------ register
91
+ SLOT_BYTES = 20
92
+
93
+
94
+ class FrozenRegister:
95
+ """Append-only, write-once table of EXACT facts.
96
+
97
+ Guarantees
98
+ ----------
99
+ * **Invariance.** A slot that holds (key, val) at time t still holds
100
+ (key, val) at every later time, bit-for-bit. Nothing in the write
101
+ path can modify an existing slot.
102
+ * **Integer exactness.** `val` is an int32 token id, so a successful
103
+ recall returns a value that is *equal*, not approximately equal.
104
+ * **Fail-loud.** A recall verifies both the 64-bit key and the 4-byte
105
+ embedding signature. On mismatch the register reports "not found"
106
+ instead of returning a value that may belong to another fact.
107
+ """
108
+
109
+ SLOT_BYTES = SLOT_BYTES
110
+
111
+ def __init__(self, capacity: int = 1024):
112
+ self.capacity = int(capacity)
113
+ self.key = np.zeros(self.capacity, dtype=np.uint64)
114
+ self.val = np.zeros(self.capacity, dtype=np.int32)
115
+ self.sig = np.zeros(self.capacity, dtype=np.uint32)
116
+ self.anchor = np.zeros(self.capacity, dtype=np.uint16)
117
+ self.meta = np.zeros(self.capacity, dtype=np.uint16)
118
+ self.used = np.zeros(self.capacity, dtype=bool)
119
+ self.n = 0
120
+ self._by_key: dict[int, list[int]] = {}
121
+
122
+ # ------------------------------------------------------------- encoding
123
+ @staticmethod
124
+ def _pack_meta(doc_id: int, trust: int) -> int:
125
+ return (int(doc_id) & 0x0FFF) | ((int(trust) & 0x3) << 12)
126
+
127
+ @staticmethod
128
+ def _unpack_meta(meta: int):
129
+ return int(meta) & 0x0FFF, (int(meta) >> 12) & 0x3
130
+
131
+ # ---------------------------------------------------------------- write
132
+ def promote(self, val: int, key: int, sig: int = 0, anchor: int = 0,
133
+ doc_id: int = 0, trust: int = 0) -> int:
134
+ """Append a NEW slot. Returns the slot index.
135
+
136
+ Append-only means a fact that is promoted twice gets two slots and
137
+ the *latest* wins on recall. The register is not a tamper-evident
138
+ audit log.
139
+ """
140
+ if self.n >= self.capacity:
141
+ raise MemoryError(
142
+ f"frozen register is full ({self.capacity} slots x {SLOT_BYTES} B "
143
+ f"= {self.capacity * SLOT_BYTES} B). Overflow is REFUSED on purpose: "
144
+ f"a register that silently grows becomes a KV cache.")
145
+ s = self.n
146
+ self.key[s] = np.uint64(key & MASK64)
147
+ self.val[s] = np.int32(val)
148
+ self.sig[s] = np.uint32(sig & 0xFFFFFFFF)
149
+ self.anchor[s] = np.uint16(anchor)
150
+ self.meta[s] = np.uint16(self._pack_meta(doc_id, trust))
151
+ self.used[s] = True
152
+ self.n += 1
153
+ self._by_key.setdefault(int(key), []).append(s)
154
+ return s
155
+
156
+ def set_trust(self, slot: int, trust: int = 1) -> None:
157
+ """Raise the trust bit of an existing slot (corroboration gate)."""
158
+ doc, _ = self._unpack_meta(int(self.meta[slot]))
159
+ self.meta[slot] = np.uint16(self._pack_meta(doc, trust))
160
+
161
+ # ----------------------------------------------------------------- read
162
+ def slots_for(self, key: int) -> list[int]:
163
+ return self._by_key.get(int(key), [])
164
+
165
+ def latest(self, key: int):
166
+ """Most recent slot holding `key`, or None."""
167
+ sl = self._by_key.get(int(key))
168
+ return sl[-1] if sl else None
169
+
170
+ def verify(self, slot: int, key: int, sig: int | None = None) -> bool:
171
+ """True iff the slot really holds this occurrence."""
172
+ if not (0 <= slot < self.n) or not self.used[slot]:
173
+ return False
174
+ if int(self.key[slot]) != (int(key) & MASK64):
175
+ return False
176
+ if sig is not None and int(self.sig[slot]) != (int(sig) & 0xFFFFFFFF):
177
+ return False
178
+ return True
179
+
180
+ def recall(self, key: int, sig: int | None = None):
181
+ """Return (slot, exact_value) or None.
182
+
183
+ `sig` is the embedding signature of the query. When supplied, a
184
+ signature mismatch makes the recall FAIL (return None) instead of
185
+ returning another fact's value -- this is the fail-loud path.
186
+ """
187
+ s = self.latest(key)
188
+ if s is None:
189
+ return None
190
+ if sig is not None and int(self.sig[s]) != (int(sig) & 0xFFFFFFFF):
191
+ return None # hash collision or wrong occurrence -> fail loud
192
+ return s, int(self.val[s])
193
+
194
+ def read_slot(self, slot: int):
195
+ """Raw read of a slot (no verification) -> (key, val, sig, anchor, trust)."""
196
+ if not (0 <= slot < self.n) or not self.used[slot]:
197
+ return None
198
+ doc, trust = self._unpack_meta(int(self.meta[slot]))
199
+ return (int(self.key[slot]), int(self.val[slot]), int(self.sig[slot]),
200
+ int(self.anchor[slot]), doc, trust)
201
+
202
+ def trust_of(self, slot: int) -> int:
203
+ return self._unpack_meta(int(self.meta[slot]))[1]
204
+
205
+ def doc_of(self, slot: int) -> int:
206
+ return self._unpack_meta(int(self.meta[slot]))[0]
207
+
208
+ def quorum(self, key: int) -> int:
209
+ """Number of DISTINCT sources that promoted this key.
210
+
211
+ The corroboration gate: a fact promoted by two independent documents
212
+ is "trusted"; a fact promoted once stays "suspect". Because the key
213
+ includes doc_id, two different documents produce different keys --
214
+ so the caller must pass the doc-independent fact key. We therefore
215
+ count distinct doc_ids stored under keys that share the same
216
+ *occurrence signature* is not possible here; instead we count the
217
+ distinct doc_ids across all slots of the given key, which is exact
218
+ for a fixed (window, doc) pair and conservative otherwise.
219
+ """
220
+ sl = self._by_key.get(int(key), [])
221
+ return len({self.doc_of(s) for s in sl})
222
+
223
+ # ------------------------------------------------------------- accounting
224
+ def nbytes(self) -> int:
225
+ """Bytes of *state* held (the serialised slot table)."""
226
+ return self.capacity * SLOT_BYTES
227
+
228
+ def used_bytes(self) -> int:
229
+ return self.n * SLOT_BYTES
230
+
231
+ def stats(self) -> dict:
232
+ trusts = [self.trust_of(s) for s in range(self.n)] if self.n else []
233
+ return {
234
+ "capacity_slots": self.capacity,
235
+ "used_slots": int(self.n),
236
+ "slot_bytes": SLOT_BYTES,
237
+ "state_bytes": self.nbytes(),
238
+ "used_bytes": self.used_bytes(),
239
+ "n_trusted": int(sum(1 for t in trusts if t)),
240
+ "fill_fraction": float(self.n / max(self.capacity, 1)),
241
+ }
242
+
243
+ def to_bytes(self) -> bytes:
244
+ return (self.key.tobytes() + self.val.tobytes() + self.sig.tobytes() +
245
+ self.anchor.tobytes() + self.meta.tobytes() +
246
+ self.used.tobytes())
247
+
248
+ @classmethod
249
+ def from_bytes(cls, blob: bytes, capacity: int) -> FrozenRegister:
250
+ r = cls(capacity=capacity)
251
+ n = capacity
252
+ p = 0
253
+
254
+ def take(arr, dt):
255
+ nonlocal p
256
+ out = np.frombuffer(blob[p:p + n * np.dtype(dt).itemsize], dtype=dt).copy()
257
+ p += n * np.dtype(dt).itemsize
258
+ return out
259
+
260
+ r.key = take(None, np.uint64)
261
+ r.val = take(None, np.int32)
262
+ r.sig = take(None, np.uint32)
263
+ r.anchor = take(None, np.uint16)
264
+ r.meta = take(None, np.uint16)
265
+ r.used = take(None, np.uint8).astype(bool)
266
+ r.n = int(r.used.sum())
267
+ r._by_key = {}
268
+ for s in range(r.n):
269
+ r._by_key.setdefault(int(r.key[s]), []).append(s)
270
+ return r
271
+
272
+
273
+ # ------------------------------------------------------------------ override
274
+ def logit_override(logits: np.ndarray, token_ids) -> np.ndarray:
275
+ """Force `token_ids` to win the argmax, deterministically.
276
+
277
+ This is the difference between "the model was told the answer" and "the
278
+ model cannot disagree": a tool that merely *returns* a token can be
279
+ outvoted by the fluid's own logits, so the extraction would be
280
+ probabilistic. Overriding the logit makes the next token provably the
281
+ recalled one.
282
+
283
+ Accepts logits of shape (V,) or (B, V), as a plain ndarray **or** as a
284
+ framework Tensor. `token_ids` is an int or a sequence of ints (one per
285
+ row for the 2-D case).
286
+ """
287
+ # accept a framework Tensor as well as a plain ndarray
288
+ if hasattr(logits, "data"):
289
+ logits = logits.data
290
+ out = np.array(logits, dtype=np.float64, copy=True)
291
+ if out.ndim == 1:
292
+ if out.shape[0] == 0:
293
+ raise ValueError("empty logits")
294
+ out[int(token_ids)] = np.inf
295
+ return out
296
+ if out.ndim == 2:
297
+ ids = np.atleast_1d(np.asarray(token_ids, dtype=np.int64))
298
+ if ids.size == 1:
299
+ ids = np.full(out.shape[0], ids[0], dtype=np.int64)
300
+ if ids.size != out.shape[0]:
301
+ raise ValueError("token_ids length must match the batch")
302
+ rows = np.arange(out.shape[0])
303
+ out[rows, ids] = np.inf
304
+ return out
305
+ raise ValueError("logits must be 1-D or 2-D")
306
+
307
+
308
+ # ------------------------------------------------------------------- budget
309
+ def budget_split(total_bytes: int, M: int, r: int, slot_bytes: int = SLOT_BYTES,
310
+ codebook_bytes: int = 0) -> dict:
311
+ """Split a RUNTIME byte budget between the fluid state and the frozen register.
312
+
313
+ `total_bytes` is the *runtime* budget (what a tenant pays per session).
314
+ `codebook_bytes` is the STATIC artefact (prototypes + value projection),
315
+ which is shared across tenants and therefore reported, not subtracted.
316
+
317
+ The design law from docs/DESIGN_pinned_memory.md: **naming one anchor out of M
318
+ needs log2(M) bits, while an anchor holds r*16 bits.** So r is grossly
319
+ over-provisioned for addressing, and a bigger budget should be spent on
320
+ MORE PINS, not on more anchors. Growing the fluid raises the per-token
321
+ compute linearly in M; growing the register costs nothing at inference.
322
+
323
+ If the requested M does not fit, M is reduced to the largest value that
324
+ does -- and the reduction is reported, because silently over-committing
325
+ the budget is how a "16 KB" claim turns into a 32 KB claim.
326
+ """
327
+ total = int(total_bytes)
328
+ r = int(r)
329
+ M_max = total // max(r * 2, 1)
330
+ M_used = int(min(int(M), M_max))
331
+ fluid = M_used * r * 2
332
+ rest = max(total - fluid, 0)
333
+ pins = rest // int(slot_bytes)
334
+ return {
335
+ "total_bytes": total,
336
+ "M_requested": int(M),
337
+ "M_used": M_used,
338
+ "M_reduced": bool(M_used < int(M)),
339
+ "r": r,
340
+ "fluid_bytes": fluid,
341
+ "register_bytes": int(pins) * int(slot_bytes),
342
+ "pins": int(pins),
343
+ "codebook_bytes": int(codebook_bytes),
344
+ "artefact_bytes": int(total) + int(codebook_bytes),
345
+ "fluid_pct": 100.0 * fluid / max(total, 1),
346
+ }
347
+
348
+
349
+ # ------------------------------------------------------------------ adapter
350
+ class SheafMemoryAdapter:
351
+ """Constant-size, geometry-addressed memory with an exact frozen register.
352
+
353
+ Layout
354
+ ------
355
+ * `protos` (M, d) static prototype codebook -- part of the *prior
356
+ artefact*, not the runtime state.
357
+ * `anchors` (M, r) the runtime fluid state (the "16 KB").
358
+ * `register` the exact tier (append-only).
359
+ * `W_in` (d, r) value projection, part of the codebook.
360
+
361
+ Read path: query -> nearest prototype (address) -> anchor content (gist)
362
+ Exact path: query -> content key -> register slot -> INTEGER token id.
363
+
364
+ The two paths are deliberately separate. A diffusion smears, so the
365
+ value that must be exact never enters the diffusion.
366
+ """
367
+
368
+ def __init__(self, M: int = 1024, r: int = 8, d: int = 64,
369
+ capacity: int = 1024, seed: int = 0):
370
+ self.M, self.r, self.d = int(M), int(r), int(d)
371
+ rng = np.random.default_rng(seed)
372
+ self.rng = rng
373
+ self.protos = None
374
+ self.anchors = np.zeros((self.M, self.r), dtype=np.float64)
375
+ self.register = FrozenRegister(capacity=capacity)
376
+ self.W_in = rng.normal(0.0, self.d ** -0.5, (self.d, self.r))
377
+ self._L = None
378
+ self._lam_max = None
379
+ self.write_calls = 0
380
+ self.tokens_written = 0
381
+
382
+ # ------------------------------------------------------------- codebook
383
+ def set_prototypes(self, protos: np.ndarray) -> None:
384
+ """Install the static codebook (M, d) and rebuild the anchor graph."""
385
+ P = np.asarray(protos, dtype=np.float64)
386
+ if P.shape != (self.M, self.d):
387
+ raise ValueError(f"prototypes must be ({self.M}, {self.d}), got {P.shape}")
388
+ self.protos = P
389
+ self._L = None
390
+
391
+ def anchor_graph(self, k: int = 8):
392
+ """k-NN graph over the prototypes -> signed sheaf Laplacian (numpy)."""
393
+ if self.protos is None:
394
+ raise RuntimeError("set_prototypes() first")
395
+ from .sheaf import build_laplacian
396
+ Pn = self.protos / (np.linalg.norm(self.protos, axis=1, keepdims=True) + 1e-12)
397
+ S = Pn @ Pn.T
398
+ np.fill_diagonal(S, -np.inf)
399
+ nbr = np.argpartition(-S, kth=min(k, self.M - 1) - 1, axis=1)[:, :k]
400
+ rows = np.repeat(np.arange(self.M), k)
401
+ cols = nbr.reshape(-1)
402
+ u = np.minimum(rows, cols)
403
+ v = np.maximum(rows, cols)
404
+ key = u.astype(np.int64) * self.M + v
405
+ _, keep = np.unique(key, return_index=True)
406
+ edges = np.stack([u[keep], v[keep]], axis=1)
407
+ alpha = np.maximum(S[edges[:, 0], edges[:, 1]], 0.0) + 1e-3
408
+ return edges, alpha, build_laplacian(self.M, edges, alpha)
409
+
410
+ # --------------------------------------------------------------- routing
411
+ def route(self, q: np.ndarray, k: int = 1):
412
+ """Nearest prototype(s) for a query. Returns (idx, scores)."""
413
+ if self.protos is None:
414
+ raise RuntimeError("set_prototypes() first")
415
+ q = np.atleast_2d(np.asarray(q, dtype=np.float64))
416
+ Pn = self.protos / (np.linalg.norm(self.protos, axis=1, keepdims=True) + 1e-12)
417
+ qn = q / (np.linalg.norm(q, axis=1, keepdims=True) + 1e-12)
418
+ sc = qn @ Pn.T
419
+ if k == 1:
420
+ idx = np.argmax(sc, axis=1)
421
+ return idx, sc[np.arange(len(idx)), idx]
422
+ order = np.argsort(-sc, axis=1)[:, :k]
423
+ rows = np.arange(sc.shape[0])[:, None]
424
+ return order, sc[rows, order]
425
+
426
+ # ----------------------------------------------------------------- write
427
+ def write(self, X: np.ndarray, t: float = 1.0, cheb_m: int = 12,
428
+ diffuse: bool = True) -> None:
429
+ """Streaming write of a chunk of embeddings (n, d).
430
+
431
+ The diffusion is applied AFTER the scatter, which is exact for a
432
+ linear operator: exp(-tL)(S + Bx) = exp(-tL)S + exp(-tL)Bx.
433
+ """
434
+ X = np.asarray(X, dtype=np.float64)
435
+ if X.ndim != 2 or X.shape[1] != self.d:
436
+ raise ValueError(f"expected (n, {self.d}) embeddings, got {X.shape}")
437
+ if X.shape[0] == 0:
438
+ return
439
+ idx, _ = self.route(X, k=1)
440
+ proj = X @ self.W_in
441
+ np.add.at(self.anchors, idx, proj)
442
+ self.write_calls += 1
443
+ self.tokens_written += int(X.shape[0])
444
+ if diffuse:
445
+ self.diffuse(t=t, cheb_m=cheb_m)
446
+
447
+ def diffuse(self, t: float = 1.0, cheb_m: int = 12) -> None:
448
+ """Apply exp(-t L) to the anchor state (the 'freezing' step)."""
449
+ from .diffusion import cheb_basis, cheb_coeffs_exp, power_iteration_lmax
450
+ if self.protos is None:
451
+ return
452
+ if self._L is None:
453
+ self._L = self.anchor_graph(k=8)
454
+ edges, alpha, L = self._L
455
+ if self._lam_max is None:
456
+ self._lam_max = power_iteration_lmax(L)
457
+ lam = self._lam_max
458
+ if lam <= 1e-8:
459
+ return
460
+ Y = cheb_basis(L, lam, self.anchors, cheb_m)
461
+ a = cheb_coeffs_exp(t, lam, cheb_m)
462
+ self.anchors = sum(a[k] * Y[k] for k in range(cheb_m + 1))
463
+
464
+ # ------------------------------------------------------------------ read
465
+ def read(self, q: np.ndarray, k: int = 1):
466
+ """Geometry read: returns (anchor_idx, scores, content (r,))."""
467
+ idx, sc = self.route(q, k=k)
468
+ if k == 1:
469
+ content = self.anchors[idx[0]]
470
+ return idx, sc, content
471
+ w = np.exp(sc - sc.max(axis=1, keepdims=True))
472
+ w = w / w.sum(axis=1, keepdims=True)
473
+ content = (self.anchors[idx] * w[:, :, None]).sum(axis=1)
474
+ return idx, sc, content
475
+
476
+ # ---------------------------------------------------------------- exact
477
+ def promote(self, vec: np.ndarray, value_token_id: int, window,
478
+ doc_id: int = 0, trust: int = 0):
479
+ """Promote a fact into the register, anchored to its nearest prototype.
480
+
481
+ Returns the slot index. The fact's value NEVER enters the fluid, so
482
+ it can never be smeared.
483
+ """
484
+ idx, _ = self.route(np.atleast_2d(np.asarray(vec, dtype=np.float64)), k=1)
485
+ anchor = int(idx[0])
486
+ sig = embedding_signature(vec)
487
+ key = ctx_key(window, doc_id=doc_id)
488
+ return self.register.promote(int(value_token_id), key, sig=sig,
489
+ anchor=anchor, doc_id=doc_id, trust=trust)
490
+
491
+ def recall(self, window, vec: np.ndarray | None = None):
492
+ """Exact recall. Returns (slot, token_id) or None.
493
+
494
+ Verification: the 64-bit context key must match, and -- if `vec` is
495
+ given -- the 4-byte embedding signature must match too. A mismatch
496
+ returns None (fail loud) instead of a wrong value.
497
+ """
498
+ key = ctx_key(window)
499
+ sig = embedding_signature(vec) if vec is not None else None
500
+ out = self.register.recall(key, sig=sig)
501
+ if out is None:
502
+ return None
503
+ return out
504
+
505
+ # ----------------------------------------------------------- accounting
506
+ def state_bytes(self) -> int:
507
+ """Runtime state only: anchors + register. Excludes the codebook."""
508
+ return int(self.anchors.size * 2) + self.register.nbytes()
509
+
510
+ def codebook_bytes(self) -> int:
511
+ """Static artefact: prototypes + value projection."""
512
+ if self.protos is None:
513
+ return int(self.W_in.size * 2)
514
+ return int((self.protos.size + self.W_in.size) * 2)
515
+
516
+ def stats(self) -> dict:
517
+ s = self.register.stats()
518
+ s.update({
519
+ "M": self.M, "r": self.r, "d": self.d,
520
+ "state_bytes": self.state_bytes(),
521
+ "codebook_bytes": self.codebook_bytes(),
522
+ "tokens_written": self.tokens_written,
523
+ "write_calls": self.write_calls,
524
+ })
525
+ return s