ensemble / palimseste /meta.py
thefinalboss's picture
Initial release: ENSEMBLE training-free AI — compressed .exp experts + Kuramoto brain
1f71c7d verified
Raw
History Blame Contribute Delete
12 kB
"""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))