| """Tests for the autonomous loop (``palimseste.loop``). |
| |
| Uses a tiny deterministic environment: a 4-state ring where each state's |
| observation HV is near the next state's (so the loop can learn the transition). |
| Verifies: |
| - the loop runs without error for many ticks |
| - surprise decreases over time as the agent learns the transition |
| - traces accumulate in M (append-only growth) |
| - StepReport telemetry is populated correctly |
| - curiosity picks an action (non-None) when actions are available |
| - reset_state clears recurrent state but not M |
| """ |
|
|
| from __future__ import annotations |
|
|
| import numpy as np |
| import pytest |
|
|
| from palimseste import hv |
| from palimseste.loop import ( |
| Palimseste, |
| Environment, |
| StateProjector, |
| LoopConfig, |
| StepReport, |
| ) |
| from palimseste.learner import Encoder |
|
|
|
|
| |
| class RingEnv(Environment): |
| """A ring of N states; each tick advances to the next state. |
| |
| Observations are HVs that are *near* their neighbors (a few bits apart), |
| so the transition o_t -> o_{t+1} is learnable by the associative memory. |
| Actions are [advance] (deterministic) — kept minimal so the curiosity |
| machinery is exercised without complicating the dynamics. |
| """ |
|
|
| def __init__(self, D: int, n_states: int = 4, seed: int = 0): |
| self.D = D |
| self.n_states = n_states |
| rng = np.random.default_rng(seed) |
| base = hv.random_hv(D=D, rng=rng) |
| self.states: list[hv.HV] = [base] |
| signs = hv.bits_to_signs(base) |
| bps = max(1, D // 50) |
| cur = signs.copy() |
| for _ in range(n_states - 1): |
| flip = rng.choice(D, size=bps, replace=False) |
| cur = cur.copy() |
| cur[flip] = -cur[flip] |
| self.states.append(hv.signs_to_bits(cur)) |
| self._i = 0 |
| self._advance = hv.random_hv(D=D, rng=rng) |
| self._t = 0 |
| self._max_t = 10_000 |
|
|
| def observe(self) -> hv.HV: |
| return self.states[self._i] |
|
|
| def actions(self) -> list[hv.HV]: |
| return [self._advance] |
|
|
| def act(self, action: hv.HV) -> None: |
| |
| self._i = (self._i + 1) % self.n_states |
| self._t += 1 |
|
|
| def done(self) -> bool: |
| return self._t >= self._max_t |
|
|
|
|
| |
| def _agent(D=1500, seed=0, **kw) -> Palimseste: |
| return Palimseste( |
| D=D, |
| rng=np.random.default_rng(seed), |
| loop_cfg=LoopConfig( |
| surprise_threshold=0.25, |
| consolidate_every=16, |
| meta_every=64, |
| max_radius=80, |
| ), |
| **kw, |
| ) |
|
|
|
|
| def test_loop_runs_many_ticks(): |
| agent = _agent(D=1200, seed=1) |
| env = RingEnv(D=1200, n_states=4, seed=1) |
| reports = [] |
| for _ in range(200): |
| reports.append(agent.step(env)) |
| assert len(reports) == 200 |
| assert all(isinstance(r, StepReport) for r in reports) |
| |
| assert agent.stats()["n_traces"] > 0 |
|
|
|
|
| def test_surprise_decreases_over_time(): |
| |
| |
| agent = _agent(D=1500, seed=2) |
| env = RingEnv(D=1500, n_states=4, seed=2) |
| surprises = [] |
| for _ in range(400): |
| r = agent.step(env) |
| surprises.append(r.surprise) |
| first = np.mean(surprises[:100]) |
| second = np.mean(surprises[300:]) |
| assert second < first, f"surprise did not decrease: {first=} {second=}" |
|
|
|
|
| def test_step_report_fields_populated(): |
| agent = _agent(D=1000, seed=3) |
| env = RingEnv(D=1000, n_states=3, seed=3) |
| r = agent.step(env) |
| assert r.t == 1 |
| assert 0.0 <= r.surprise <= 1.0 |
| assert r.action_idx is not None |
| assert r.n_traces >= 0 |
| assert r.n_concepts == 0 |
|
|
|
|
| def test_curiosity_picks_action(): |
| agent = _agent(D=1000, seed=4) |
| env = RingEnv(D=1000, n_states=3, seed=4) |
| r = agent.step(env) |
| assert r.action_idx == 0 |
|
|
|
|
| def test_consolidation_fires(): |
| |
| agent = _agent(D=1200, seed=5) |
| agent.loop_cfg.consolidate_every = 8 |
| env = RingEnv(D=1200, n_states=4, seed=5) |
| ran_cons = False |
| for _ in range(40): |
| r = agent.step(env) |
| if r.consolidation is not None: |
| ran_cons = True |
| assert ran_cons |
|
|
|
|
| def test_meta_fires(): |
| agent = _agent(D=1200, seed=6) |
| agent.loop_cfg.meta_every = 16 |
| env = RingEnv(D=1200, n_states=4, seed=6) |
| ran_meta = False |
| for _ in range(80): |
| r = agent.step(env) |
| if r.meta is not None: |
| ran_meta = True |
| assert ran_meta |
|
|
|
|
| def test_reset_state_clears_recurrence_not_memory(): |
| agent = _agent(D=1000, seed=7) |
| env = RingEnv(D=1000, n_states=3, seed=7) |
| for _ in range(30): |
| agent.step(env) |
| n_before = agent.stats()["n_traces"] |
| agent.reset_state() |
| n_after = agent.stats()["n_traces"] |
| assert n_before == n_after |
| assert agent.surprise == 0.0 |
|
|
|
|
| def test_loop_config_invalid(): |
| with pytest.raises(ValueError): |
| LoopConfig(surprise_threshold=1.5) |
| with pytest.raises(ValueError): |
| LoopConfig(consolidate_every=0) |
| with pytest.raises(ValueError): |
| LoopConfig(max_radius=0) |
|
|
|
|
| def test_stats_keys(): |
| agent = _agent(D=800, seed=8) |
| env = RingEnv(D=800, n_states=3, seed=8) |
| for _ in range(10): |
| agent.step(env) |
| s = agent.stats() |
| for k in ("t", "n_traces", "n_meta", "n_concepts", "n_meta_decisions", |
| "last_surprise", "kernel"): |
| assert k in s |
|
|
|
|
| def test_state_projector_reset(): |
| enc = Encoder(D=500, rng=np.random.default_rng(0)) |
| sp = StateProjector(D=500, encoder=enc, window=3) |
| o = hv.random_hv(D=500) |
| a = hv.random_hv(D=500) |
| |
| s_empty = sp.project(o, a) |
| sp.commit(o) |
| s_one = sp.project(o, a) |
| sp.reset() |
| s_after = sp.project(o, a) |
| |
| assert hv.similarity(s_empty, s_one) < 1.0 |
| |
| assert hv.similarity(s_empty, s_after) == pytest.approx(1.0) |
|
|