File size: 6,548 Bytes
d9c1900
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
"""Tests for the Learner + Encoder (``palimseste.learner``).

Verifies:
  - learn() is O(1): the millionth example costs the same as the first
    (we measure write cost stays flat across |M| scales)
  - predict() recovers learned mappings
  - context binding: same x in different ctx yields different addresses
  - Encoder: ints/strs/floats/sequences/sets encode deterministically &
    with the right similarity structure (floats level-coded, sequences
    order-sensitive, sets order-insensitive)
"""

from __future__ import annotations

import time
import numpy as np
import pytest

from palimseste import hv
from palimseste.memory import Memory
from palimseste.phi import Phi, KernelConfig
from palimseste.learner import Learner, Encoder, Prediction


def _learner(D=2000, seed=0, radius=0) -> tuple[Learner, np.random.Generator]:
    rng = np.random.default_rng(seed)
    mem = Memory(D=D, rng=np.random.default_rng(seed))
    phi = Phi(config=KernelConfig(radius=radius, min_weight=1e-6))
    return Learner(mem=mem, phi=phi, rng=rng), rng


# ----------------------------------------------------------------- learner
def test_learn_then_predict_exact():
    lr, rng = _learner(D=2000, radius=0)
    x = hv.random_hv(D=2000, rng=rng)
    y = hv.random_hv(D=2000, rng=rng)
    lr.learn(x, y)
    p = lr.predict(x)
    assert p.value == y
    assert p.n_matches == 1
    assert p.confidence > 0.99


def test_learn_context_binding():
    # same x, different ctx -> different addresses, different predictions
    lr, rng = _learner(D=2000, radius=0)
    x = hv.random_hv(D=2000, rng=rng)
    ctx_a = hv.random_hv(D=2000, rng=rng)
    ctx_b = hv.random_hv(D=2000, rng=rng)
    y_a = hv.random_hv(D=2000, rng=rng)
    y_b = hv.random_hv(D=2000, rng=rng)
    lr.learn(x, y_a, ctx=ctx_a)
    lr.learn(x, y_b, ctx=ctx_b)
    pa = lr.predict(x, ctx=ctx_a)
    pb = lr.predict(x, ctx=ctx_b)
    assert pa.value == y_a
    assert pb.value == y_b


def test_learn_is_o1_flat_cost():
    # The write cost must not grow with |M|. We compare per-write time at
    # small |M| vs large |M| and require they are within a generous factor.
    D = 4000
    rng = np.random.default_rng(7)
    mem = Memory(D=D, rng=np.random.default_rng(7))
    phi = Phi(config=KernelConfig(radius=10))
    lr = Learner(mem=mem, phi=phi, rng=rng)

    xs = [hv.random_hv(D=D, rng=rng) for _ in range(2000)]
    ys = [hv.random_hv(D=D, rng=rng) for _ in range(2000)]

    t0 = time.perf_counter()
    for i in range(50):
        lr.learn(xs[i], ys[i])
    small = (time.perf_counter() - t0) / 50

    for i in range(50, 2000):
        lr.learn(xs[i], ys[i])

    t1 = time.perf_counter()
    for i in range(2000, 2050):
        lr.learn(xs[i % len(xs)], ys[i % len(ys)])
    large = (time.perf_counter() - t1) / 50

    # O(1) amortized: large-|M| writes should be within 5x of small-|M| writes
    # (generous to absorb LSH dict growth / GC noise).
    ratio = large / max(small, 1e-9)
    assert ratio < 5.0, f"write cost grew: small={small:.2e} large={large:.2e} ratio={ratio:.2f}"


def test_learn_many_appends_all():
    lr, rng = _learner(D=1000, radius=5)
    pairs = [(hv.random_hv(D=1000, rng=rng), hv.random_hv(D=1000, rng=rng)) for _ in range(30)]
    traces = lr.learn_many(pairs)
    assert len(traces) == 30
    assert len(lr.mem) == 30


def test_predict_empty_returns_none():
    lr, _ = _learner(D=500)
    p = lr.predict(hv.random_hv(D=500))
    assert p.value is None
    assert p.confidence == 0.0
    assert p.n_matches == 0


# ----------------------------------------------------------------- encoder
def _enc(D=2000, seed=3) -> Encoder:
    return Encoder(D=D, rng=np.random.default_rng(seed))


def test_encode_int_deterministic():
    e = _enc()
    assert e.encode_int(5) == e.encode_int(5)
    assert e.encode_int(5) != e.encode_int(6)


def test_encode_str_deterministic():
    e = _enc()
    assert e.encode_str("cat") == e.encode_str("cat")
    assert e.encode_str("cat") != e.encode_str("dog")


def test_encode_float_level_similarity():
    # adjacent floats are similar, distant are not.
    # With 256 levels and ~0.025*D bits/step, the random-walk level code
    # saturates to quasi-orthogonal within ~30 levels, so values far apart
    # in [0,1] decorrelate as VSA scalar coding requires.
    e = _enc(D=5000, seed=1)
    a = e.encode_float(0.41)
    b = e.encode_float(0.42)
    c = e.encode_float(0.90)
    assert hv.similarity(a, b) > 0.5
    # 0.41 vs 0.90 are ~126 levels apart -> well past saturation
    assert hv.similarity(a, c) < 0.3


def test_encode_float_clamps():
    e = _enc()
    # out-of-range clamps, no error
    assert isinstance(e.encode_float(-5.0), hv.HV)
    assert isinstance(e.encode_float(99.0), hv.HV)


def test_encode_float_bad_range():
    e = _enc()
    with pytest.raises(ValueError):
        e.encode_float(0.5, lo=1.0, hi=0.0)


def test_encode_sequence_order_sensitive():
    e = _enc(D=5000, seed=2)
    a = e.encode_int(1)
    b = e.encode_int(2)
    c = e.encode_int(3)
    seq_abc = e.encode_sequence([a, b, c])
    seq_cba = e.encode_sequence([c, b, a])
    # order matters: different permutations are dissimilar
    assert hv.similarity(seq_abc, seq_cba) < 0.6


def test_encode_set_order_insensitive():
    e = _enc(D=5000, seed=3)
    a = e.encode_int(1)
    b = e.encode_int(2)
    c = e.encode_int(3)
    s1 = e.encode_set([a, b, c])
    s2 = e.encode_set([c, b, a])
    # same set, different order -> similar (not necessarily equal due to tie-breaks,
    # but strongly correlated)
    assert hv.similarity(s1, s2) > 0.7


def test_encode_kv():
    # Bundling is commutative ONLY when there are no ties. With an even
    # number of independent bound-pairs every coordinate is an exact tie and
    # the result degenerates to random tie-breaking (sim ~ 0 between orderings).
    # With an odd count (>=3) ties are broken by genuine majority and the
    # bundle is order-insensitive.
    e = _enc(D=5000, seed=4)
    pairs = [
        (e.encode_str("color"), e.encode_str("red")),
        (e.encode_str("size"), e.encode_int(42)),
        (e.encode_str("weight"), e.encode_int(7)),
        (e.encode_str("shape"), e.encode_str("cube")),
        (e.encode_str("owner"), e.encode_str("ada")),
    ]
    rec = e.encode_kv(pairs)
    rec2 = e.encode_kv(list(reversed(pairs)))
    assert hv.similarity(rec, rec2) > 0.7


def test_prediction_dataclass():
    p = Prediction(query=hv.random_hv(10), value=None, confidence=0.0, n_matches=0)
    assert p.value is None
    assert p.confidence == 0.0