File size: 8,975 Bytes
efb1144
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
"""Tests for the Conversation layer (``palimseste.chat``).

Verifies:
  - multi-turn memory: the conversation history is folded into context
  - live learning (teach): a new Q/A pair is instantly retrievable
  - fuzzy matching: typos / near-misses match to known questions
  - fallback: unseen questions return a graceful response, not empty string
  - streaming: respond_streaming yields tokens
  - teach command parsing (teach that ... = ...)
  - conversation reset clears history but not M
  - transcript generation
"""

from __future__ import annotations

import numpy as np
import pytest

from palimseste.lm import PalimpsesteForCausalLM, PalimpsesteConfig
from palimseste.chat import Conversation, Turn, FALLBACK_RESPONSE


def _model(D=5000, radius=0, ctx_window=64):
    cfg = PalimpsesteConfig(D=D, context_window=ctx_window, kernel_radius=radius,
                            temperature=0.3)
    lm = PalimpsesteForCausalLM(config=cfg)
    pairs = [
        ("hello", "hi i am palimpseste"),
        ("who are you", "i am palimpseste a hypervectorial cortex"),
        ("do you use a gpu", "no i do not use a gpu"),
    ]
    full = "".join(q + a for q, a in pairs)
    lm.build_tokenizer(full)
    lm.train_on_qa_pairs(pairs)
    return lm, pairs


# ----------------------------------------------------------------- fuzzy matching
class TestFuzzyMatching:
    def test_exact_match(self):
        lm, pairs = _model()
        conv = Conversation(model=lm)
        conv.register_questions(pairs)
        assert conv._fuzzy_match("hello") == "hello"

    def test_typo_matches(self):
        lm, pairs = _model()
        conv = Conversation(model=lm, fuzzy_threshold=0.7)
        conv.register_questions(pairs)
        # "do you use gpu" (missing "a") should match "do you use a gpu"
        matched = conv._fuzzy_match("do you use gpu")
        assert matched == "do you use a gpu"

    def test_no_match_returns_none(self):
        lm, pairs = _model()
        conv = Conversation(model=lm, fuzzy_threshold=0.9)
        conv.register_questions(pairs)
        assert conv._fuzzy_match("xyz123abc") is None

    def test_empty_known_questions(self):
        lm, _ = _model()
        conv = Conversation(model=lm)
        assert conv._fuzzy_match("anything") is None


# ----------------------------------------------------------------- fallback
class TestFallback:
    def test_unseen_question_returns_fallback(self):
        lm, _ = _model(radius=0)
        conv = Conversation(model=lm, fuzzy_threshold=0.99)
        resp = conv.respond("xyz123abc", temperature=0.0, seed=0)
        assert resp == FALLBACK_RESPONSE

    def test_seen_question_returns_answer(self):
        lm, pairs = _model(radius=0)
        conv = Conversation(model=lm, fuzzy_threshold=0.99)
        conv.register_questions(pairs)
        resp = conv.respond("hello", temperature=0.0, seed=0)
        assert resp == "hi i am palimpseste"
        assert resp != FALLBACK_RESPONSE


# ----------------------------------------------------------------- live learning
class TestLiveLearning:
    def test_teach_then_respond(self):
        lm, _ = _model(radius=0)
        conv = Conversation(model=lm, fuzzy_threshold=0.99)
        # before teaching: unknown question -> fallback
        resp_before = conv.respond("what is the capital of france",
                                   temperature=0.0, seed=0)
        assert resp_before == FALLBACK_RESPONSE
        # teach the new Q/A
        msg = conv.teach("what is the capital of france", "paris")
        assert "learned" in msg.lower()
        # after teaching: should retrieve the answer
        # reset history so the fallback context doesn't interfere
        conv.reset()
        resp_after = conv.respond("what is the capital of france",
                                  temperature=0.0, seed=0)
        assert resp_after == "paris"

    def test_teach_grows_memory(self):
        lm, _ = _model()
        conv = Conversation(model=lm)
        n_before = len(lm.mem)
        conv.teach("new question", "new answer")
        assert len(lm.mem) > n_before

    def test_teach_registers_for_fuzzy(self):
        lm, _ = _model()
        conv = Conversation(model=lm, fuzzy_threshold=0.8)
        conv.teach("what time is it", "i do not know")
        # a near-miss should now fuzzy-match
        matched = conv._fuzzy_match("what time is it now")
        assert matched is not None


# ----------------------------------------------------------------- teach command
class TestTeachCommand:
    def test_parse_teach_that(self):
        lm, _ = _model()
        conv = Conversation(model=lm)
        assert conv.try_parse_teach("teach that color = blue") is True
        assert conv._known_questions[-1] == "color"

    def test_parse_teach_english(self):
        lm, _ = _model()
        conv = Conversation(model=lm)
        assert conv.try_parse_teach("teach color = blue") is True

    def test_parse_teach_no_equals(self):
        lm, _ = _model()
        conv = Conversation(model=lm)
        assert conv.try_parse_teach("teach that color blue") is False

    def test_parse_teach_no_prefix(self):
        lm, _ = _model()
        conv = Conversation(model=lm)
        assert conv.try_parse_teach("color = blue") is False

    def test_parse_teach_empty_answer(self):
        lm, _ = _model()
        conv = Conversation(model=lm)
        assert conv.try_parse_teach("teach that color = ") is False


# ----------------------------------------------------------------- multi-turn
class TestMultiTurn:
    def test_history_recorded(self):
        lm, pairs = _model(radius=0)
        conv = Conversation(model=lm, fuzzy_threshold=0.99)
        conv.register_questions(pairs)
        conv.respond("hello", temperature=0.0, seed=0)
        assert len(conv.history) == 2  # one Q + one A
        assert conv.history[0].role == "user"
        assert conv.history[0].text == "hello"
        assert conv.history[1].role == "palimpseste"

    def test_reset_clears_history(self):
        lm, pairs = _model()
        conv = Conversation(model=lm)
        conv.register_questions(pairs)
        conv.respond("hello", temperature=0.0, seed=0)
        assert len(conv.history) > 0
        conv.reset()
        assert len(conv.history) == 0

    def test_reset_does_not_clear_memory(self):
        lm, pairs = _model()
        conv = Conversation(model=lm)
        n_before = len(lm.mem)
        conv.respond("hello", temperature=0.0, seed=0)
        conv.reset()
        assert len(lm.mem) == n_before  # M untouched

    def test_turn_count(self):
        lm, pairs = _model()
        conv = Conversation(model=lm)
        conv.register_questions(pairs)
        assert conv.turn_count == 0
        conv.respond("hello", temperature=0.0, seed=0)
        assert conv.turn_count == 1
        conv.respond("who are you", temperature=0.0, seed=0)
        assert conv.turn_count == 2


# ----------------------------------------------------------------- streaming
class TestStreaming:
    def test_streaming_yields_tokens(self):
        lm, pairs = _model(radius=0)
        conv = Conversation(model=lm, fuzzy_threshold=0.99)
        conv.register_questions(pairs)
        tokens = list(conv.respond_streaming("hello", temperature=0.0, seed=0))
        assert len(tokens) > 0
        # reconstruct the text
        text = "".join(t for t, _ in tokens)
        assert "hi" in text

    def test_streaming_cold_start(self):
        lm, _ = _model(radius=0)
        conv = Conversation(model=lm, fuzzy_threshold=0.99)
        tokens = list(conv.respond_streaming("xyz123", temperature=0.0, seed=0))
        # should yield the fallback response
        text = "".join(t for t, _ in tokens)
        assert "sorry" in text.lower() or len(text) > 0


# ----------------------------------------------------------------- transcript
class TestTranscript:
    def test_transcript_format(self):
        lm, pairs = _model(radius=0)
        conv = Conversation(model=lm, fuzzy_threshold=0.99)
        conv.register_questions(pairs)
        conv.respond("hello", temperature=0.0, seed=0)
        transcript = conv.get_transcript()
        assert "You:" in transcript
        assert "Palimpseste:" in transcript
        assert "hello" in transcript


# ----------------------------------------------------------------- context building
class TestContextBuilding:
    def test_context_includes_history(self):
        lm, pairs = _model(radius=0, ctx_window=128)
        conv = Conversation(model=lm, max_history_turns=2, fuzzy_threshold=0.99)
        conv.register_questions(pairs)
        # first turn
        conv.respond("hello", temperature=0.0, seed=0)
        # build context for second turn — should include history
        ctx = conv._build_context_ids("who are you")
        # context should be longer than just the question
        q_only = lm.tokenizer.encode("who are you", add_bos=True, add_eos=True) + [1]  # +BOS
        assert len(ctx) > len(q_only)