mica-ember-0.3.7 / memory.py
vynly's picture
MICA Ember v0.3.7
902e4c6 verified
Raw History Blame Contribute Delete
12.4 kB
"""Standalone byte adaptation of Flame-W 0.3.1's integer memory readout.
The frozen automaton still consumes bytes. This sidecar adds recall and a rank-16
association over 64 previous bytes, in the base model's integer logit units.
Unlike the word model, byte zero is real data; only BOS/EOS are skipped.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from types import MappingProxyType
from collections.abc import Mapping
import numpy as np
V, BOS, EOS, WINDOW, SHIFT, RANK = 258, 256, 257, 64, 30, 16
CLASS_NAMES = ("whitespace", "lowercase", "uppercase", "digits", "punctuation", "high_or_control")
NC, NB = len(CLASS_NAMES), 7
LAG_BUCKET = np.array([0, 0, 1, 2, 3, 3, 3] + [4] * 6 + [5] * 12 + [6] * 40, np.int64)
LAG_BUCKET.flags.writeable = False
I64_MAX = int(np.iinfo(np.int64).max)
def byte_classes() -> np.ndarray:
"""Fixed ASCII byte classes; no vocabulary or held-out data is consulted."""
out = np.full(V, 5, np.uint8)
out[33:127] = 4
out[list(b" \t\n\r\v\f")] = 0
out[ord("a"):ord("z") + 1] = 1
out[ord("A"):ord("Z") + 1] = 2
out[ord("0"):ord("9") + 1] = 3
return out
def _readonly(value, dtype=None):
a = np.asarray(value, dtype=dtype)
# A bytes-backed array cannot be made writable again via setflags.
return np.frombuffer(a.tobytes(), dtype=a.dtype).reshape(a.shape)
def _integer(value, name, shape, low, high, dtype):
a = np.asarray(value)
if a.shape != shape or a.dtype.kind not in "iu":
raise ValueError(f"{name} must be an integer array with shape {shape}")
if np.any(a < low) or np.any(a > high):
raise ValueError(f"{name} values must be in [{low}, {high}]")
return _readonly(a, dtype)
def _float(value, name, shape):
a = np.asarray(value, dtype=np.float64)
if a.shape != shape or not np.isfinite(a).all():
raise ValueError(f"{name} must be finite with shape {shape}")
return a
def quantize(A=None, Bm=None, lam=None, R=None, *, divisor=1024.0, class_of_id=None):
"""Quantize float parameters measured in nats, using deterministic rounding.
Pass R alone for recall-only storage. A, Bm and lam must be provided together.
R: [6,7]; A/Bm: [258,16]; lam: [7]. No pickle or torch dependency is used.
"""
divisor = float(divisor)
if not np.isfinite(divisor) or divisor <= 0:
raise ValueError("divisor must be finite and positive")
if R is None:
raise ValueError("R is required")
rq = np.rint(_float(R, "R", (NC, NB)) * divisor)
if not np.isfinite(rq).all() or np.any(np.abs(rq) > np.iinfo(np.int32).max):
raise ValueError("quantized R exceeds int32 range")
rdtype = np.int16 if np.max(np.abs(rq)) <= np.iinfo(np.int16).max else np.int32
out = {
"R_int": rq.astype(rdtype),
"class_of_id": byte_classes() if class_of_id is None else np.asarray(class_of_id),
"class_names": np.asarray(CLASS_NAMES),
"never_read": np.asarray([BOS, EOS], np.int16),
"window": np.asarray(WINDOW, np.int16),
"shift": np.asarray(SHIFT, np.int16),
"divisor": np.asarray(divisor, np.float64),
"version": np.asarray(1, np.int16),
}
supplied = [x is not None for x in (A, Bm, lam)]
if any(supplied) and not all(supplied):
raise ValueError("A, Bm and lam must be supplied together")
if all(supplied):
A = _float(A, "A", (V, RANK))
Bm = _float(Bm, "Bm", (V, RANK))
lam = _float(lam, "lam", (NB,))
sa = max(float(np.abs(A).max()) / 127.0, np.finfo(np.float64).tiny)
sl = max(float(np.abs(lam).max()) / 127.0, np.finfo(np.float64).tiny)
sb = np.maximum(np.abs(Bm).max(axis=1) / 127.0, np.finfo(np.float64).tiny)
mul = np.rint(divisor * sa * sl * sb * 2.0 ** SHIFT)
# Float64 rounds int64's upper endpoint to 2**63, which is not valid.
if not np.isfinite(mul).all() or np.any(mul < 0) or np.any(mul >= 2.0 ** 63):
raise ValueError("quantized association multiplier exceeds int64 range")
out.update(A_int=np.rint(A / sa).astype(np.int8),
Bm_int=np.rint(Bm / sb[:, None]).astype(np.int8),
lam_int=np.rint(lam / sl).astype(np.int8), MUL=mul.astype(np.int64))
# Validate arithmetic bounds before publishing parameters.
return dict(Memory(out).tables)
def save_memory(path: str | Path, tables: Mapping) -> None:
"""Write validated compact arrays; loading always uses allow_pickle=False."""
checked = Memory(tables)
np.savez(Path(path), **checked.tables)
class Memory:
def __init__(self, path_or_mapping: str | Path | Mapping, recall_only=False):
if isinstance(path_or_mapping, Mapping):
d = dict(path_or_mapping)
else:
with np.load(Path(path_or_mapping), allow_pickle=False) as archive:
d = {k: archive[k] for k in archive.files}
for key, expected in (("window", WINDOW), ("shift", SHIFT), ("version", 1)):
a = np.asarray(d[key])
if a.shape != () or a.dtype.kind not in "iu" or int(a) != expected:
raise ValueError(f"{key} must equal {expected}")
a = np.asarray(d["divisor"])
if a.shape != () or not np.isfinite(float(a)) or float(a) <= 0:
raise ValueError("divisor must be a finite positive scalar")
self.divisor = float(a)
names = np.asarray(d["class_names"])
if names.shape != (NC,) or tuple(names.tolist()) != CLASS_NAMES:
raise ValueError("class_names must describe the fixed byte classes")
cls = _integer(d["class_of_id"], "class_of_id", (V,), 0, NC - 1, np.uint8)
if not np.array_equal(cls, byte_classes()):
raise ValueError("class_of_id does not match the fixed byte classes")
never = _integer(d["never_read"], "never_read", (2,), BOS, EOS, np.int16)
if tuple(never) != (BOS, EOS):
raise ValueError("never_read must be exactly [BOS, EOS]")
raw_r = np.asarray(d["R_int"])
if raw_r.dtype not in (np.dtype("int16"), np.dtype("int32")):
raise ValueError("R_int must use int16 or int32 storage")
r = _integer(raw_r, "R_int", (NC, NB), -2 ** 31, 2 ** 31 - 1, raw_r.dtype)
compact = {k: _readonly(d[k]) for k in ("window", "shift", "version", "divisor", "class_names")}
compact.update(R_int=r, class_of_id=cls, never_read=never)
self.R, self.cls = _readonly(r, np.int64), _readonly(cls, np.int64)
assoc_keys = ("A_int", "Bm_int", "lam_int", "MUL")
present = [key in d for key in assoc_keys]
if any(present) and not all(present):
raise ValueError("incomplete association tables")
self.assoc = all(present) and not recall_only
self.max_bonus_abs = WINDOW * int(np.abs(self.R).max())
if all(present):
for key, shape in (("A_int", (V, RANK)), ("Bm_int", (V, RANK)), ("lam_int", (NB,))):
if np.asarray(d[key]).dtype != np.dtype("int8"):
raise ValueError(f"{key} must use int8 storage")
compact[key] = _integer(d[key], key, shape, -127, 127, np.int8)
compact["MUL"] = _integer(d["MUL"], "MUL", (V,), 0, I64_MAX, np.int64)
self.A = _readonly(compact["A_int"], np.int64)
self.Bm = _readonly(compact["Bm_int"], np.int64)
self.lam = _readonly(compact["lam_int"], np.int64)
self.MUL = compact["MUL"]
# Use Python ints to bound every possible 64-byte history without
# overflowing during the validation itself. Specials cannot enter M.
lag_mass = sum(abs(int(self.lam[b])) for b in LAG_BUCKET[1:])
mbound = [lag_mass * max(abs(int(x)) for x in self.A[:256, j]) for j in range(RANK)]
largest = 0
for row, mul in zip(self.Bm, self.MUL):
dot_bound = sum(abs(int(b)) * m for b, m in zip(row, mbound))
product = dot_bound * int(mul)
if dot_bound > I64_MAX or product > I64_MAX:
raise ValueError("association accumulation can overflow int64")
largest = max(largest, (product + (1 << SHIFT) - 1) >> SHIFT)
if self.assoc:
self.max_bonus_abs += largest
if self.max_bonus_abs > I64_MAX:
raise ValueError("memory bonus can overflow int64")
self.tables = MappingProxyType(compact)
@staticmethod
def _history(hist, ndim):
a = np.asarray(hist)
if a.size == 0 and a.dtype.kind not in "iu":
a = a.astype(np.int64)
if a.ndim != ndim or a.dtype.kind not in "iu" or np.any(a < -1) or np.any(a >= V):
raise ValueError(f"history must be a {ndim}-dimensional integer array in [-1,257]")
return a.astype(np.int64, copy=False)
def bonus(self, hist) -> np.ndarray:
"""One history, oldest first. Special/padded ids keep their lag slots."""
h = self._history(hist, 1)[-WINDOW:][::-1]
out = np.zeros(V, np.int64)
ids, buckets = [], []
for lag, token in enumerate(h, 1):
if 0 <= token < BOS:
bucket = int(LAG_BUCKET[lag])
out[token] += self.R[self.cls[token], bucket]
ids.append(int(token)); buckets.append(bucket)
if self.assoc and ids:
M = (self.A[ids] * self.lam[buckets, None]).sum(axis=0, dtype=np.int64)
out += ((self.Bm @ M) * self.MUL) >> SHIFT
return out
def bonus_batch(self, histories) -> np.ndarray:
"""Histories [n, <=64], newest first, -1 padding; returns [n,258]."""
H = self._history(histories, 2)
if H.shape[1] > WINDOW:
raise ValueError("batched histories must have at most 64 lag columns")
n, width = H.shape
out = np.zeros((n, V), np.int64)
valid = (H >= 0) & (H < BOS)
ids = np.where(valid, H, 0)
lb = LAG_BUCKET[1:width + 1]
values = self.R[self.cls[ids], lb] * valid
np.add.at(out, (np.arange(n)[:, None], ids), values)
if self.assoc and width:
w = self.lam[lb][None, :] * valid
M = (self.A[ids] * w[:, :, None]).sum(axis=1, dtype=np.int64)
out += ((M @ self.Bm.T) * self.MUL[None, :]) >> SHIFT
return out
@dataclass
class MemState:
s: object
hist: tuple[int, ...]
class MemoryMicaByte:
"""Wrap an integer base model with start/feed/fork/scores/logp methods."""
def __init__(self, base, memory: Memory):
if float(base.div) != memory.divisor:
raise ValueError("memory and base model logit divisors differ")
if np.asarray(base.elig).shape != (V,):
raise ValueError("base model must have 258 symbols")
self.base, self.memory = base, memory
self.div, self.elig = base.div, base.elig
self.info = dict(getattr(base, "info", {}), memory=True, memory_window_bytes=WINDOW)
def start(self):
return MemState(self.base.start(), ())
def feed(self, st, i: int):
if not isinstance(i, (int, np.integer)) or not 0 <= i < V:
raise ValueError("symbol must be an integer in [0,257]")
st.s = self.base.feed(st.s, int(i))
st.hist = (st.hist + (int(i),))[-WINDOW:]
return st
def fork(self, st):
return MemState(self.base.fork(st.s), st.hist)
def scores(self, st):
raw = np.asarray(self.base.scores(st.s))
if raw.shape != (V,) or raw.dtype.kind not in "iu":
raise ValueError("base scores must be 258 integer logits")
margin = self.memory.max_bonus_abs
if np.any(raw > I64_MAX - margin) or np.any(raw < -I64_MAX + margin):
raise ValueError("base plus memory score can overflow int64")
return raw.astype(np.int64) + self.memory.bonus(st.hist)
def logp(self, st, ids):
z = self.scores(st).astype(np.float64) / self.div
z = np.where(self.elig, z, -np.inf)
z -= z.max()
return z[np.asarray(ids, dtype=np.int64)] - np.log(np.exp(z[self.elig]).sum())