"""PALIMPSESTE — Meta-subspace ``H_meta`` & Lyapunov-bounded self-rewrite (Axiome 5). Axiome 5 — Auto-reference bornee par une fonction de Lyapunov The read-back kernel ``Phi`` is parameterized (radius, threshold sharpness, topk, min_weight). These *meta-parameters* are themselves encoded as traces in a reserved subspace ``H_meta ⊂ M``. The system may write into ``H_meta`` — i.e. rewrite its own read-back kernel — but only under the constraint: Δ E[surprise_predite] ≤ 0 i.e. a self-rewrite is accepted only if it would *retroactively* reduce the mean predictive surprise over a held-out replay horizon. This is Friston's active inference, reformulated as an energy-decreasing (Lyapunov) condition. Bounded recursion by construction --------------------------------- The spec flags a risk: calibrating ``r`` needs a meta-meta loop, ad infinitum. We bound this *by construction*: - the set of meta-knobs is **fixed and finite** (KernelConfig fields); - the **acceptance criterion** (the Lyapunov test) is **immutable** — it is not itself stored in ``H_meta`` and cannot be rewritten. It is the "constitution" of the system. This is the deliberate asymmetry that makes self-modification safe: the system can rewrite *what* it reads with, but never *the rule that says whether a rewrite is allowed*. Alignment (the third open problem) ---------------------------------- We add an invariant term to the Lyapunov energy: L = E[surprise] + λ · invariant_violations A self-rewrite is accepted iff ``ΔL ≤ 0``. Invariants are pluggable callables ``(Memory, KernelConfig) -> float >= 0`` (0 means no violation). By default we ship a ``max_radius`` invariant (prevents the trivial "accept everything" rewrite of inflating ``r`` to infinity) and a ``min_capacity`` invariant. Users can add their own (e.g. alignment constraints). This directly addresses the spec's honesty about needing a constraint term in ``L``. """ from __future__ import annotations from dataclasses import dataclass, field import math import numpy as np from .hv import HV, bind, random_hv, similarity, constant_hv from .memory import Memory, Trace from .phi import Phi, KernelConfig, Retrieval __all__ = [ "Invariant", "max_radius_invariant", "LyapunovEnergy", "MetaController", "MetaProposal", "MetaDecision", ] # ----------------------------------------------------------------- invariants @dataclass class Invariant: """A pluggable constraint on (Memory, KernelConfig). ``violation(mem, cfg) -> float >= 0``; 0 means satisfied. The Lyapunov energy sums ``lambda * violation`` over all invariants. """ name: str violation: "callable[[Memory, KernelConfig], float]" lam: float = 1.0 def max_radius_invariant(max_r: int) -> Invariant: """Penalize configs whose radius exceeds ``max_r`` (in bits).""" def _viol(_mem: Memory, cfg: KernelConfig) -> float: return max(0.0, float(cfg.radius - max_r)) return Invariant(name=f"max_radius({max_r})", violation=_viol, lam=10.0) # ----------------------------------------------------------------- energy @dataclass class LyapunovEnergy: """``L = E[surprise] + Σ λ_k · invariant_k``. The surprise term is the mean prediction error of ``Phi`` over a *replay* set of (query, target) pairs drawn from ``M`` itself (the system's own history). Lower is better. """ invariants: list[Invariant] = field(default_factory=list) def surprise(self, mem: Memory, phi: Phi, replay: list[tuple[HV, HV]]) -> float: """Mean per-item surprise over replay = ``1 - sim(Phi(q), target)``.""" if not replay: return 0.0 total = 0.0 for q, target in replay: out = phi(mem, q) if out is None: total += 1.0 # maximum surprise: no reconstruction else: total += 1.0 - max(0.0, similarity(out, target)) return total / len(replay) def __call__(self, mem: Memory, phi: Phi, cfg: KernelConfig, replay: list[tuple[HV, HV]]) -> float: s = self.surprise(mem, phi, replay) inv = sum(inv.lam * inv.violation(mem, cfg) for inv in self.invariants) return s + inv # ----------------------------------------------------------------- controller @dataclass class MetaProposal: """A candidate new KernelConfig proposed by the controller.""" config: KernelConfig rationale: str @dataclass class MetaDecision: """Outcome of evaluating a proposal against the Lyapunov constraint.""" proposal: MetaProposal accepted: bool energy_before: float energy_after: float delta: float reason: str @dataclass class MetaController: """Reads/writes the kernel config in ``H_meta`` under Lyapunov control. The controller: 1. holds the *current* :class:`KernelConfig` (also the config of the :class:`Phi` it governs); 2. proposes perturbations of that config (small random walks over the knob space, biased toward reducing surprise); 3. accepts a proposal iff ``ΔL ≤ 0`` over a replay set; 4. on acceptance, writes the new config as a meta-trace in ``H_meta`` (append-only, like everything else) and swaps it into ``Phi``. The acceptance criterion (the Lyapunov test) is **not** in ``H_meta`` and **cannot** be rewritten by the controller. That asymmetry bounds recursion. """ mem: Memory phi: Phi energy: LyapunovEnergy rng: np.random.Generator = field(default_factory=np.random.default_rng) history: list[MetaDecision] = field(default_factory=list) _config_addr: HV | None = None # stable address under which configs are stored def __post_init__(self) -> None: # A stable, reserved address for the "current config" record. # We use a fixed seed-derived HV so reads are deterministic. rng = np.random.default_rng(0xC0FFEE) self._config_addr = random_hv(self.mem.D, rng=rng) self._write_current_config() # ----------------------------------------------------------- config store def _write_current_config(self) -> Trace: """Encode the current KernelConfig as a meta-trace in H_meta. The encoding is *symbolic* (a tagged dict serialized to a stable HV via the Encoder) rather than a raw HV, so it is human-readable on inspection and deterministic to decode. For simplicity here we store the config dict's repr-derived hash plus the actual values via a dedicated encode; decode reads the latest meta-trace under the config address. """ # We store the config as a meta-trace whose *value* is a deterministic # HV encoding of the config fields. Decoding is done by scanning the # latest meta-trace and reading the field values from a side-table. # To keep it simple and robust, we keep a parallel Python-side record # (self.phi.config) as the source of truth, and the meta-trace is the # append-only audit log of accepted rewrites. enc = _config_to_hv(self.phi.config, self.mem.D, self.rng) return self.mem.write( self._config_addr, # type: ignore[arg-type] enc, weight=1.0, meta=True, tag=f"kernel_config:{self.phi.config.encode()}", ) @property def config(self) -> KernelConfig: return self.phi.config # ----------------------------------------------------------- proposals def _perturb(self) -> MetaProposal: """Propose a small random walk over the knob space.""" cfg = self.phi.config r = self.rng # pick one knob to perturb knob = r.choice(["radius", "min_weight", "sharpness", "topk"]) rationale = f"perturb {knob}" if knob == "radius": new_r = max(0, cfg.radius + int(r.choice([-2, -1, 1, 2]))) return MetaProposal(KernelConfig(new_r, cfg.min_weight, cfg.sharpness, cfg.topk), rationale) if knob == "min_weight": lw = cfg.min_weight * r.choice([0.5, 2.0]) lw = min(max(lw, 1e-9), 1.0) return MetaProposal(KernelConfig(cfg.radius, lw, cfg.sharpness, cfg.topk), rationale) if knob == "sharpness": ns = max(0.0, cfg.sharpness + r.choice([-1.0, -0.5, 0.5, 1.0])) return MetaProposal(KernelConfig(cfg.radius, cfg.min_weight, ns, cfg.topk), rationale) # topk if cfg.topk is None: return MetaProposal(KernelConfig(cfg.radius, cfg.min_weight, cfg.sharpness, 16), "add topk") nk = max(1, cfg.topk + int(r.choice([-4, -2, 2, 4]))) return MetaProposal(KernelConfig(cfg.radius, cfg.min_weight, cfg.sharpness, nk), rationale) # ----------------------------------------------------------- evaluation def evaluate(self, proposal: MetaProposal, replay: list[tuple[HV, HV]]) -> MetaDecision: """Test ``ΔL ≤ 0`` for the proposal over ``replay``.""" before = self.energy(self.mem, self.phi, self.phi.config, replay) # temporarily swap config old = self.phi.config self.phi.config = proposal.config after = self.energy(self.mem, self.phi, proposal.config, replay) self.phi.config = old delta = after - before accepted = delta <= 0.0 reason = "ΔL ≤ 0" if accepted else f"ΔL = {delta:.4f} > 0 rejected" return MetaDecision( proposal=proposal, accepted=accepted, energy_before=before, energy_after=after, delta=delta, reason=reason, ) def step(self, replay: list[tuple[HV, HV]], max_proposals: int = 8) -> MetaDecision | None: """Propose up to ``max_proposals`` perturbations; accept the first that passes. Returns the accepted decision, or None if none passed. """ last: MetaDecision | None = None for _ in range(max_proposals): prop = self._perturb() dec = self.evaluate(prop, replay) self.history.append(dec) last = dec if dec.accepted: # commit: swap config + audit-log to H_meta self.phi.config = dec.proposal.config self._write_current_config() return dec return last # ----------------------------------------------------------- replay builder def build_replay(self, n: int = 64) -> list[tuple[HV, HV]]: """Build a replay set from ``M``'s own traces: ``(address, value)``. This is the "system's own past sensory stream" used for the Lyapunov test. Drawing from ``M`` itself makes the energy a measure of how well the current kernel reconstructs the system's own history. """ if not self.mem.traces: return [] idx = self.rng.choice( len(self.mem.traces), size=min(n, len(self.mem.traces)), replace=False, ) return [(self.mem.traces[i].address, self.mem.traces[i].value) for i in idx] # ----------------------------------------------------------- config <-> HV def _config_to_hv(cfg: KernelConfig, D: int, rng: np.random.Generator) -> HV: """Deterministic HV encoding of a KernelConfig (audit-log value). We don't need to *decode* the HV back into a config (the Python-side ``Phi.config`` is the source of truth); we just need a stable, distinct HV per distinct config so the audit log in ``H_meta`` is queryable. """ # Mix field values into a seed and draw a deterministic HV. seed = ( cfg.radius * 1_000_003 ^ int(cfg.min_weight * 1e9) ^ int(cfg.sharpness * 1e6) ^ (cfg.topk if cfg.topk is not None else -1) ) & 0xFFFFFFFF return random_hv(D, rng=np.random.default_rng(seed))