| """Tests for conversational Q/A training and respond() (``palimseste.lm``). |
| |
| Verifies: |
| - train_on_qa_pairs writes traces to M (O(1) per token) |
| - respond() returns a non-empty string after training |
| - respond() recovers the exact trained answer for a seen question (greedy) |
| - respond() handles unseen questions gracefully (no crash) |
| - the episode format (BOS q EOS BOS a EOS) is consistent |
| - generate_from_ids respects stop_on_eos |
| """ |
|
|
| from __future__ import annotations |
|
|
| import numpy as np |
| import pytest |
|
|
| from palimseste.lm import PalimpsesteForCausalLM, PalimpsesteConfig |
| from palimseste.tokenizer import BOS, EOS |
|
|
|
|
| def _lm(D=5000, radius=30, ctx_window=48): |
| cfg = PalimpsesteConfig(D=D, context_window=ctx_window, kernel_radius=radius, |
| temperature=0.3) |
| return PalimpsesteForCausalLM(config=cfg) |
|
|
|
|
| |
| def test_train_on_qa_pairs_grows_memory(): |
| lm = _lm() |
| pairs = [("hello", "hi there"), ("bye", "goodbye")] |
| full = "".join(q + a for q, a in pairs) |
| lm.build_tokenizer(full) |
| n = lm.train_on_qa_pairs(pairs) |
| assert n > 0 |
| assert len(lm.mem) == n |
|
|
|
|
| def test_train_on_qa_pairs_requires_tokenizer(): |
| lm = _lm() |
| with pytest.raises(AssertionError): |
| lm.train_on_qa_pairs([("q", "a")]) |
|
|
|
|
| |
| def test_respond_returns_exact_answer_for_seen_question(): |
| lm = _lm(radius=0) |
| pairs = [ |
| ("bonjour", "salut je suis palimpseste"), |
| ("qui es-tu", "je suis palimpseste"), |
| ("comment tu apprends", "j'apprends en ecrivant"), |
| ] |
| full = "".join(q + a for q, a in pairs) |
| lm.build_tokenizer(full) |
| lm.train_on_qa_pairs(pairs) |
| |
| resp = lm.respond("bonjour", temperature=0.0, seed=0) |
| assert resp == "salut je suis palimpseste" |
|
|
|
|
| def test_respond_recovers_multiple_answers(): |
| lm = _lm(radius=0) |
| pairs = [ |
| ("hello", "hi i am palimpseste"), |
| ("who are you", "i am palimpseste a hypervectorial cortex"), |
| ("how do you learn", "i learn by writing to memory"), |
| ("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) |
| for q, expected in pairs: |
| resp = lm.respond(q, temperature=0.0, seed=0) |
| assert resp == expected, f"Q={q!r}: got {resp!r}, expected {expected!r}" |
|
|
|
|
| def test_respond_unseen_question_no_crash(): |
| lm = _lm() |
| pairs = [("hello", "hi there")] |
| full = "hellohi there" |
| lm.build_tokenizer(full) |
| lm.train_on_qa_pairs(pairs) |
| |
| resp = lm.respond("what is the weather", temperature=0.5, seed=0) |
| assert isinstance(resp, str) |
|
|
|
|
| def test_respond_empty_input_no_crash(): |
| lm = _lm() |
| lm.build_tokenizer("abc") |
| lm.train_on_qa_pairs([("a", "b")]) |
| resp = lm.respond("", temperature=0.0, seed=0) |
| assert isinstance(resp, str) |
|
|
|
|
| def test_respond_respects_max_tokens(): |
| lm = _lm(radius=0) |
| |
| pairs = [("q", "a" * 100)] |
| full = "q" + "a" * 100 |
| lm.build_tokenizer(full) |
| lm.train_on_qa_pairs(pairs) |
| resp = lm.respond("q", max_new_tokens=5, temperature=0.0, seed=0) |
| assert len(resp) <= 5 |
|
|
|
|
| |
| def test_generate_from_ids_stop_on_eos(): |
| lm = _lm(radius=0) |
| text = "hello world" |
| lm.build_tokenizer(text) |
| lm.train_on_text(text) |
| ids = lm.tokenizer.encode("hello", add_bos=True) |
| out = lm.generate_from_ids(ids, max_new_tokens=50, temperature=0.0, seed=0, |
| stop_on_eos=True) |
| assert isinstance(out, str) |
|
|
|
|
| def test_generate_from_ids_cold_start(): |
| lm = _lm() |
| lm.build_tokenizer("abc") |
| |
| out = lm.generate_from_ids([BOS], max_new_tokens=10, temperature=0.0, seed=0) |
| assert isinstance(out, str) |
|
|
|
|
| |
| def test_qa_episode_format_consistent(): |
| """The episode BOS q EOS BOS a EOS must be used consistently in |
| train_on_qa_pairs and respond().""" |
| lm = _lm(radius=0) |
| pairs = [("hi", "hello there")] |
| full = "hihello there" |
| lm.build_tokenizer(full) |
| lm.train_on_qa_pairs(pairs) |
| |
| |
| q_ids = lm.tokenizer.encode("hi", add_bos=True, add_eos=True) |
| ctx = q_ids + [BOS] |
| logits = lm._logits(ctx) |
| import numpy as np |
| top = int(np.argmax(logits)) |
| |
| assert lm.tokenizer.id2char[top] == "h" |
|
|
|
|
| |
| def test_qa_with_french_accents(): |
| lm = _lm(radius=0) |
| pairs = [("comment ça va", "ça va bien merci")] |
| full = "comment ça vaça va bien merci" |
| lm.build_tokenizer(full) |
| lm.train_on_qa_pairs(pairs) |
| resp = lm.respond("comment ça va", temperature=0.0, seed=0) |
| assert "ça va bien" in resp or resp == "ça va bien merci" |
|
|
|
|
| def test_qa_multiple_pairs_no_interference(): |
| """Training on multiple Q/A pairs should not cause one answer to bleed |
| into another (each episode is independent with its own BOS/EOS boundary).""" |
| lm = _lm(radius=0) |
| pairs = [ |
| ("aaa", "xxx"), |
| ("bbb", "yyy"), |
| ("ccc", "zzz"), |
| ] |
| full = "".join(q + a for q, a in pairs) |
| lm.build_tokenizer(full) |
| lm.train_on_qa_pairs(pairs) |
| assert lm.respond("aaa", temperature=0.0, seed=0) == "xxx" |
| assert lm.respond("bbb", temperature=0.0, seed=0) == "yyy" |
| assert lm.respond("ccc", temperature=0.0, seed=0) == "zzz" |
|
|