cortexm/vsa/ops.py
Rama main · réplica de lectura del repositorio original.
"""Vector Symbolic Architecture algebra.
Modes (config ``vsa_mode``):
* ``perm`` — permutation binding (default). bind(role, filler) permutes
the filler with a role-seeded permutation. Similarity-preserving,
cheap (index shuffle), and directly portable to binary HDC
hardware (XOR/permutation ops) per the plan's edge roadmap.
* ``conv`` — Holographic Reduced Representations (Plate 1995):
circular-convolution binding via FFT, involution-based unbinding.
* ``bag`` — role-weighted superposition only (ablation baseline).
Every fact hologram = role-bound components + λ-weighted lexical
superposition. The λ term keeps free-text queries effective while the
bound terms carry structure for probe queries.
"""
from __future__ import annotations
import numpy as np
from cortexm.util import h64 as _h64 # deterministic seeded hashing
ROLE_WEIGHTS = {"S": 1.0, "R": 1.0, "V": 1.0}
def _norm(v: np.ndarray) -> np.ndarray:
n = float(np.linalg.norm(v))
return v / n if n > 0 else v
class VSA:
def __init__(self, dims: int = 768, mode: str = "perm",
seed: int = 0x0C0FFEE, lexical_lambda: float = 0.6) -> None:
self.dims = dims
self.mode = mode
self.seed = seed
self.lam = lexical_lambda
self._perms: dict[str, np.ndarray] = {}
self._inperms: dict[str, np.ndarray] = {}
self._roles: dict[str, np.ndarray] = {}
# NSR-inspired engineered role vectors (optional). When
# _engineered is set, role_vec() returns the engineered vector
# instead of the random one. This is opt-in via .use_engineered().
self._engineered = None
def use_engineered(self, erv) -> None:
"""Swap the random role vectors for the engineered ones.
erv: an EngineeredRoleVectors instance that has been .fit()
on the actual fact corpus. After this call:
role_vec("S") -> erv.role_vec("S") (top-1 principal dir)
role_vec("R") -> erv.role_vec("R") (top-2)
role_vec("V") -> erv.role_vec("V") (top-3)
The existing random role vectors remain available as a fallback
if erv.role_vec(role) returns None.
"""
self._engineered = erv
# ------------------------------------------------------------- roles
def perm(self, role: str) -> np.ndarray:
p = self._perms.get(role)
if p is None:
rng = np.random.default_rng(_h64(f"perm:{role}", self.seed) & 0xFFFFFFFF)
p = rng.permutation(self.dims).astype(np.int32)
self._perms[role] = p
inv = np.empty_like(p)
inv[p] = np.arange(self.dims, dtype=np.int32)
self._inperms[role] = inv
return p
def inv_perm(self, role: str) -> np.ndarray:
self.perm(role)
return self._inperms[role]
def role_vec(self, role: str) -> np.ndarray:
# engineered role vectors (NSR-inspired) take precedence when
# configured — they sit on the top-k principal directions of
# the actual fact corpus, giving higher effective capacity and
# lower cross-talk than random role vectors.
if self._engineered is not None and self._engineered.is_fit:
v = self._engineered.role_vec(role)
if v is not None:
return v
v = self._roles.get(role)
if v is None:
rng = np.random.default_rng(_h64(f"role:{role}", self.seed) & 0xFFFFFFFF)
v = rng.standard_normal(self.dims).astype(np.float32)
v /= max(float(np.linalg.norm(v)), 1e-9)
self._roles[role] = v
return v
# ------------------------------------------------------- binding ops
def bind(self, role: str, filler: np.ndarray) -> np.ndarray:
if self.mode == "perm":
return filler[self.perm(role)]
if self.mode == "conv":
a = np.fft.rfft(filler)
b = np.fft.rfft(self.role_vec(role))
return np.fft.irfft(a * b, n=self.dims).astype(np.float32)
return (ROLE_WEIGHTS.get(role, 1.0) * filler).astype(np.float32)
def unbind(self, role: str, h: np.ndarray) -> np.ndarray:
if self.mode == "perm":
return h[self.inv_perm(role)]
if self.mode == "conv":
r = self.role_vec(role)
inv = np.concatenate(([r[0]], r[1:][::-1])) # involution
a = np.fft.rfft(h)
b = np.fft.rfft(inv)
return np.fft.irfft(a * b, n=self.dims).astype(np.float32)
return h / ROLE_WEIGHTS.get(role, 1.0)
@staticmethod
def bundle(vecs: list[np.ndarray]) -> np.ndarray:
return _norm(np.sum(vecs, axis=0).astype(np.float32))
# ---------------------------------------------------- fact encoding
def encode_fact(self, s_vec: np.ndarray, r_vec: np.ndarray,
v_vec: np.ndarray) -> np.ndarray:
lex = _norm(s_vec + r_vec + v_vec)
if self.mode == "bag":
return _norm(ROLE_WEIGHTS["S"] * s_vec + ROLE_WEIGHTS["R"] * r_vec
+ ROLE_WEIGHTS["V"] * v_vec)
bound = self.bundle([
self.bind("S", s_vec), self.bind("R", r_vec), self.bind("V", v_vec)])
return _norm(bound + self.lam * lex)
def probe(self, s_vec: np.ndarray | None = None,
r_vec: np.ndarray | None = None,
v_vec: np.ndarray | None = None,
lexical: np.ndarray | None = None) -> np.ndarray:
"""Structured probe: approximate fact hologram from known roles."""
parts: list[np.ndarray] = []
if s_vec is not None:
parts.append(self.bind("S", s_vec))
if r_vec is not None:
parts.append(self.bind("R", r_vec))
if v_vec is not None:
parts.append(self.bind("V", v_vec))
if not parts:
return _norm(lexical if lexical is not None else np.zeros(self.dims, np.float32))
probe = self.bundle(parts)
if lexical is not None and self.mode != "bag":
probe = _norm(probe + self.lam * _norm(lexical))
return probe
def unbind_role(self, h: np.ndarray, role: str) -> np.ndarray:
"""Extract the approximate filler bound to ``role`` (audit path)."""
return _norm(self.unbind(role, h))