Ctrl K
ComunidadGuías…

cortexm/vsa/palace.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""The Memory Palace — VSA hologram store with codec-quantized vectors.

One hologram per fact: role-bound subject/relation/value fillers plus a
λ-weighted lexical superposition (see vsa.ops). Storage is codec-
quantized (INT8 / Binary / RaBitQ / PQ), persisted as BLOBs in SQLite
and mirrored in RAM as packed numpy matrices for microsecond scoring.
A page-clustered tree index provides O(log N) retrieval once the
collection crosses ``index_threshold``; below it, flat scan wins.

Also hosts the self-healing machinery: per-record hash checks, TMR
majority vote (binary codec), and re-encoding from the symbolic Trace
when corruption exceeds the correction radius.
"""

from __future__ import annotations

import base64
import numpy as np

from cortexm.config import Config
from cortexm.errors import CodecError, StoreError
from cortexm.text.embedder import HashingEmbedder
from cortexm.trace.fact import Fact
from cortexm.trace.store import TraceStore
from cortexm.util import iso
import datetime as _dt
from cortexm.vsa.codecs import make_codec, PQCodec, Int8Codec
from cortexm.vsa.index import TreeIndex
from cortexm.vsa.ops import VSA

VEC_TABLE = """
CREATE TABLE IF NOT EXISTS vectors (
  fact_id TEXT PRIMARY KEY, record BLOB NOT NULL,
  vec_hash TEXT DEFAULT '', ts TEXT DEFAULT ''
)
"""


class MemoryPalace:
    def __init__(self, config: Config, store: TraceStore, embedder: HashingEmbedder | None = None) -> None:
        self.cfg = config
        self.store = store
        self.dims = config.dims
        self.hasher = store.hasher
        self.codec = make_codec(config.codec, config.dims, config.seed,
                                tmr=config.tmr)
        self.vsa = VSA(config.dims, config.vsa_mode, config.seed,
                        config.lexical_lambda)
        # Allow injecting a shared embedder for FIX 2 (persistent worker embedder)
        self.embedder = embedder or HashingEmbedder(config.dims, config.seed)
        store.conn.execute(VEC_TABLE)
        store.conn.commit()

        self._ids: list[str] = []
        self._id2row: dict[str, int] = {}
        self._n = 0
        self._cap = 0
        self._packed: np.ndarray | None = None
        self._aux: np.ndarray | None = None
        self._index: TreeIndex | None = None
        self._index_valid = False
        self._pq_buffer: list[tuple[str, np.ndarray]] = []
        self.searches_flat = 0
        self.searches_indexed = 0
        self._load_pq_codebooks()
        self._load()

    # ------------------------------------------------------------- loading
    def _load(self) -> None:
        rows = self.store.conn.execute(
            "SELECT fact_id, record FROM vectors").fetchall()
        if not rows:
            return
        recs = [r["record"] for r in rows]
        self._ids = [r["fact_id"] for r in rows]
        self._id2row = {fid: i for i, fid in enumerate(self._ids)}
        packed_rows = [self.codec.from_bytes(b) for b in recs]
        if isinstance(self.codec, Int8Codec):
            q = np.stack([p[0] for p in packed_rows])
            self._aux = np.stack([np.float16(p[1]) for p in packed_rows])
            self._packed = q
            self._cap = self._n = len(q)
        else:
            self._packed = np.stack(packed_rows)
            self._cap = self._n = len(self._packed)
        self._index_valid = False

    def _load_pq_codebooks(self) -> None:
        if not isinstance(self.codec, PQCodec):
            return
        blob = self.store.kv_get("PQ_CODEBOOKS")
        if blob:
            arr = np.frombuffer(base64.b64decode(blob), dtype=np.float32)
            m = self.codec.m
            ks = arr.size // (m * self.codec.sub)
            self.codec.set_codebooks(arr.reshape(m, ks, self.codec.sub))

    def _save_pq_codebooks(self) -> None:
        if isinstance(self.codec, PQCodec) and self.codec.trained:
            self.store.kv_set(
                "PQ_CODEBOOKS",
                base64.b64encode(self.codec.codebooks.astype(np.float32).tobytes()).decode())

    # ------------------------------------------------------------- writing
    def encode_fact(self, fact: Fact) -> np.ndarray:
        return self.vsa.encode_fact(
            self.embedder.embed(fact.subject),
            self.embedder.embed(fact.relation),
            self.embedder.embed(fact.value))

    def _grow(self, need: int) -> None:
        w = self._row_width()
        new_cap = max(1024, self._cap * 2, need)
        if isinstance(self.codec, Int8Codec):
            packed = np.zeros((new_cap, w), dtype=np.int8)
            aux = np.zeros(new_cap, dtype=np.float16)
            if self._packed is not None and self._n:
                packed[: self._n] = self._packed[: self._n]
                aux[: self._n] = self._aux[: self._n]
            self._packed, self._aux = packed, aux
        else:
            packed = np.zeros((new_cap, w), dtype=np.uint8)
            if self._packed is not None and self._n:
                packed[: self._n] = self._packed[: self._n]
            self._packed = packed
        self._cap = new_cap

    def _row_width(self) -> int:
        if isinstance(self.codec, Int8Codec):
            return self.dims          # scale lives in the aux array
        if isinstance(self.codec, PQCodec):
            return self.codec.m       # 8 code bytes
        return self.codec.bytes_per_vector  # binary / rabitq packed words

    def add(self, fact_id: str, vec: np.ndarray) -> None:
        if isinstance(self.codec, PQCodec) and not self.codec.trained:
            self._pq_buffer.append((fact_id, vec))
            if len(self._pq_buffer) >= self.codec.train_threshold:
                self._flush_pq()
            return
        self._append(fact_id, self._encode_row(vec), vec)

    def _encode_row(self, vec: np.ndarray):
        if isinstance(self.codec, Int8Codec):
            q = self.codec.encode_packed(vec)
            return q, self.codec.encode_scale(vec)
        return self.codec.encode_packed(vec), None

    def _append(self, fact_id: str, row, vec) -> None:
        packed_row, scale = row
        blob = (self.codec.to_bytes(packed_row, scale)
                if isinstance(self.codec, Int8Codec)
                else self.codec.to_bytes(packed_row))
        self.store.conn.execute(
            "INSERT OR REPLACE INTO vectors(fact_id, record, vec_hash, ts) VALUES(?,?,?,?)",
            (fact_id, blob, self.hasher.hash_bytes(blob), iso(_dt.datetime.now(_dt.timezone.utc))))
        if not self.store.batching:
            self.store.conn.commit()
        if self._n >= self._cap:
            self._grow(self._n + 1)
        if isinstance(self.codec, Int8Codec):
            self._packed[self._n] = packed_row
            self._aux[self._n] = scale
        else:
            self._packed[self._n] = packed_row
        if fact_id in self._id2row:
            old = self._id2row[fact_id]
            # overwrite in place; keep id mapping
            self._ids[old] = fact_id
        else:
            self._id2row[fact_id] = self._n
            self._ids.append(fact_id)
            self._n += 1
        self._index_valid = False

    def add_many(self, pairs: list[tuple[str, np.ndarray]]) -> None:
        for fid, v in pairs:
            self.add(fid, v)

    def remove_ids(self, fact_ids: list[str]) -> int:
        """Hard-remove vectors (GDPR erasure). Rebuilds packed state from
        the surviving rows. Returns the number of vectors removed."""
        if not fact_ids:
            return 0
        gone = set(fact_ids)
        cur = self.store.conn.execute(
            "SELECT fact_id FROM vectors").fetchall()
        existing = {r["fact_id"] for r in cur}
        removed = len(gone & existing)
        if removed:
            qmarks = ",".join("?" * len(gone))
            self.store.conn.execute(
                f"DELETE FROM vectors WHERE fact_id IN ({qmarks})",
                list(gone))
            self.store.conn.commit()
        # rebuild in-memory state from disk (source of truth)
        self._ids = []
        self._id2row = {}
        self._n = 0
        self._cap = 0
        self._packed = None
        self._aux = None
        self._index = None
        self._index_valid = False
        self._load()
        return removed

    def _flush_pq(self) -> None:
        if not self._pq_buffer or not isinstance(self.codec, PQCodec):
            return
        if not self.codec.trained:
            vecs = np.stack([v for _, v in self._pq_buffer])
            self.codec.train(vecs)
            self._save_pq_codebooks()
        for fid, v in self._pq_buffer:
            self._append(fid, self._encode_row(v), v)
        self._pq_buffer.clear()

    def size(self) -> int:
        return self._n + len(self._pq_buffer)

    def has(self, fact_id: str) -> bool:
        return fact_id in self._id2row or any(f == fact_id for f, _ in self._pq_buffer)

    def close(self) -> None:
        self._flush_pq()
        if not self.store.batching:
            # Idempotent teardown: if the underlying SQLite connection
            # was already closed (e.g. Memory.close() called twice or
            # store.dispose() ran before palace.close()), the commit()
            # would raise sqlite3.ProgrammingError. Catch + ignore so
            # Memory.close() is safe to call multiple times — a
            # standard Python teardown idiom.
            try:
                self.store.conn.commit()
            except Exception:
                pass

    # -------------------------------------------------------------- search
    def _rows_getter(self, rows: np.ndarray):
        if self._packed is None or self._n == 0:
            return np.zeros((0, 1), dtype=np.uint8), None
        packed = self._packed[rows]
        aux = self._aux[rows] if self._aux is not None else None
        return packed, aux

    def ensure_index(self, force: bool = False) -> None:
        if self._n < self.cfg.index_threshold:
            return
        if self._index is not None and self._index_valid and not force:
            return
        self._flush_pq()
        index = TreeIndex(self.codec, self._rows_getter, self._n,
                          branch=self.cfg.index_branch,
                          leaf=self.cfg.index_leaf_size,
                          seed=self.cfg.seed)
        index.build()
        self._index = index
        self._index_valid = True

    def search(self, q: np.ndarray, k: int = 10,
               candidate_ids: set[str] | None = None) -> list[tuple[str, float]]:
        self._flush_pq()
        if self._n == 0:
            return []
        use_index = (self._n >= self.cfg.index_threshold and self._index_valid
                     and (candidate_ids is None or len(candidate_ids) >= self._n * 0.5))
        if use_index:
            self.searches_indexed += 1
            rows, scores = self._index.search(q, min(self._n, max(k * 3, 24)),
                                              beam=self.cfg.beam_width)
            out = [(self._ids[int(r)], float(s)) for r, s in zip(rows, scores)]
            if candidate_ids is not None:
                out = [(fid, s) for fid, s in out if fid in candidate_ids]
            out.sort(key=lambda t: -t[1])
            if len(out) < k and candidate_ids is not None and len(candidate_ids) > 0:
                extra = self._flat_search(q, k, candidate_ids, exclude={f for f, _ in out})
                out.extend(extra)
                out.sort(key=lambda t: -t[1])
            return out[:k]
        self.searches_flat += 1
        if candidate_ids is not None:
            return self._flat_search(q, k, candidate_ids)
        sc = self._score_all(q)
        k2 = min(k, self._n)
        idx = np.argpartition(-sc, k2 - 1)[:k2]
        idx = idx[np.argsort(-sc[idx])]
        return [(self._ids[int(i)], float(sc[i])) for i in idx]

    def _score_all(self, qv: np.ndarray) -> np.ndarray:
        # Rust fast path (Task 6-simd): when cortexm_core is built, route
        # the per-row scoring through `batch_dot_i8` (int8 codec) or
        # `batch_dot` (fp32 path) instead of the codec's numpy `scores`.
        # The Rust kernels are AVX-512 → AVX2+FMA → NEON → scalar and
        # process the whole batch in one Python→Rust boundary crossing.
        # NumPy remains the exact reference; bit parity ≤1e-5 asserted
        # by `tests/test_rust_accel.py::TestSimdKernels::test_batch_dot_*`.
        try:
            from cortexm import accel
        except Exception:                       # pragma: no cover
            accel = None
        rust_ok = (accel is not None and accel.RUST_AVAILABLE
                   and accel.RUST_ENABLED)
        n = self._n
        d = self.dims
        if rust_ok and n > 0 and self._packed is not None:
            qf32 = np.ascontiguousarray(qv, dtype=np.float32)
            if self.codec.uses_aux and self._aux is not None:
                # Int8Codec: Rust returns raw int8·f32 dot products;
                # multiply by per-row aux scales to match the codec's
                # `scores` semantics (`packed.astype(f32) * aux @ q`).
                # Pass the int8 buffer as a flat 1-D view — pyo3 binds
                # to `PyReadonlyArray1<i8>` zero-copy.
                packed_flat = np.ascontiguousarray(
                    self._packed[:n]).reshape(-1)
                raw = np.asarray(
                    accel._core.batch_dot_i8(packed_flat, qf32, n, d),
                    dtype=np.float32)
                aux = np.asarray(self._aux[:n], dtype=np.float32)
                return raw * aux
            if self._packed.dtype == np.float32:
                # FP32-packed codec path. (BinaryCodec stores bipolar
                # ±1 in uint8 — skip; codec.scores handles its own XOR.)
                rows_flat = np.ascontiguousarray(
                    self._packed[:n]).reshape(-1)
                raw = np.asarray(
                    accel._core.batch_dot(rows_flat, qf32, n, d),
                    dtype=np.float32)
                return raw
        if self.codec.uses_aux:
            return self.codec.scores(self._packed[: self._n], qv, self._aux[: self._n])
        return self.codec.scores(self._packed[: self._n], qv)

    def _flat_search(self, q: np.ndarray, k: int, candidate_ids: set[str],
                     exclude: set[str] | None = None) -> list[tuple[str, float]]:
        # iterate candidates in PALACE ROW ORDER (insertion order), never in
        # set order: set iteration is hash-randomized per process, and the
        # argsort below breaks score ties by position — random positions
        # would make identical runs return different facts.
        sel = [self._id2row[f] for f in candidate_ids
               if f in self._id2row
               and (exclude is None or f not in exclude)]
        sel.sort()
        if not sel:
            return []
        arr = np.array(sel, dtype=np.int64)
        packed = self._packed[arr]
        aux = self._aux[arr] if self._aux is not None else None
        sc = (self.codec.scores(packed, q, aux) if self.codec.uses_aux
              else self.codec.scores(packed, q))
        order = np.argsort(-sc, kind="stable")[:k]
        return [(self._ids[int(arr[i])], float(sc[i])) for i in order]

    # ------------------------------------------------------ self-healing
    def record_hash(self, row: int) -> str:
        blob = self._record_bytes(row)
        return self.hasher.hash_bytes(blob)

    def _record_bytes(self, row: int) -> bytes:
        if isinstance(self.codec, Int8Codec):
            return self.codec.to_bytes(self._packed[row], self._aux[row])
        return self.codec.to_bytes(self._packed[row])

    def stored_hash(self, fact_id: str) -> str | None:
        r = self.store.conn.execute(
            "SELECT vec_hash FROM vectors WHERE fact_id=?", (fact_id,)).fetchone()
        return r["vec_hash"] if r else None

    def corrupt(self, rate: float, seed: int = 0,
                persist: bool = False) -> int:
        """Inject bit flips (cosmic rays / flash degradation). Returns count."""
        if self._n == 0:
            return 0
        rng = np.random.default_rng(seed)
        count = 0
        for row in range(self._n):
            if rng.random() < rate:
                packed_row = self._packed[row]
                self._packed[row] = self.codec.corrupt(packed_row, rate, rng)
                count += 1
                if persist:
                    blob = self._record_bytes(row)
                    self.store.conn.execute(
                        "UPDATE vectors SET record=? WHERE fact_id=?",
                        (blob, self._ids[row]))
        if persist:
            self.store.conn.commit()
        self._index_valid = False
        return count

    def health_check(self, sample: int | None = None) -> dict:
        """Detect corrupt records via stored vec_hash; TMR adds bit-level votes."""
        import random as _random
        rng = _random.Random(7)
        rows = list(range(self._n))
        if sample and sample < self._n:
            rows = rng.sample(rows, sample)
        corrupt_rows, checked = [], 0
        tmr_flips = 0
        for row in rows:
            checked += 1
            fid = self._ids[row]
            want = self.stored_hash(fid)
            got = self.record_hash(row)
            if want and want != got:
                corrupt_rows.append(fid)
            if self.cfg.tmr and self.codec.name == "binary":
                tmr_flips += self.codec.tmr_health(self._packed[row])
        return {"checked": checked, "corrupt": len(corrupt_rows),
                "corrupt_ids": corrupt_rows, "tmr_disagree_bits": int(tmr_flips)}

    def heal(self, facts_by_id: dict[str, Fact]) -> dict:
        """Re-encode corrupt records from the symbolic Trace (source of truth)."""
        report = self.health_check()
        healed = 0
        for fid in report["corrupt_ids"]:
            fact = facts_by_id.get(fid)
            if fact is None:
                continue
            row = self._id2row.get(fid)
            if row is None:
                continue
            vec = self.encode_fact(fact)
            packed_row, scale = self._encode_row(vec)
            if isinstance(self.codec, Int8Codec):
                self._packed[row] = packed_row
                self._aux[row] = scale
            else:
                self._packed[row] = packed_row
            blob = (self.codec.to_bytes(packed_row, scale)
                    if isinstance(self.codec, Int8Codec)
                    else self.codec.to_bytes(packed_row))
            self.store.conn.execute(
                "UPDATE vectors SET record=?, vec_hash=? WHERE fact_id=?",
                (blob, self.hasher.hash_bytes(blob), fid))
            healed += 1
        self.store.conn.commit()
        self._index_valid = False
        return {"corrupt": report["corrupt"], "healed": healed,
                "note": "re-encoded from Trace; source hash verified"}

    # ------------------------------------------------------------- stats
    def storage_stats(self) -> dict:
        vec_bytes = self._n * self.codec.bytes_per_vector if self._n else 0
        return {
            "vectors": self._n,
            "codec": self.codec.name,
            "bytes_per_vector": self.codec.bytes_per_vector,
            "vector_bytes": int(vec_bytes),
            "per_million_memories_mb": round(
                self.codec.bytes_per_vector * 1e6 / 1e6, 1),
            "index": ("tree" if self._index_valid else
                      ("flat" if self._n else "empty")),
            "searches_flat": self.searches_flat,
            "searches_indexed": self.searches_indexed,
        }