| """Tests for the fact-chaining reasoner (``palimseste.reasoning``). |
| |
| Verifies: |
| - A→B + B→C chaining: model can answer A→C without being taught the chain |
| - Multi-hop chaining (3 hops) |
| - No false chaining when direct answer exists |
| - Graceful failure when no chain is possible |
| - Reasoning trace is populated correctly |
| - Chaining respects max_hops limit |
| """ |
|
|
| from __future__ import annotations |
|
|
| import pytest |
| from palimseste.lm import PalimpsesteForCausalLM, PalimpsesteConfig |
| from palimseste.chat import Conversation, FALLBACK_RESPONSE |
| from palimseste.reasoning import Reasoner, ChainResult |
| import numpy as np |
|
|
|
|
| def _build_model(pairs, D=3000, radius=0, ctx=128): |
| cfg = PalimpsesteConfig(D=D, context_window=ctx, kernel_radius=radius, |
| temperature=0.0) |
| lm = PalimpsesteForCausalLM(config=cfg) |
| full = "".join(q + a for q, a in pairs) |
| lm.build_tokenizer(full) |
| lm.train_on_qa_pairs(pairs) |
| return lm |
|
|
|
|
| class TestFactChaining: |
| """Core test: A→B + B→C = A→C.""" |
|
|
| def test_two_hop_chain(self): |
| """Teach two facts, ask a question that requires chaining them.""" |
| pairs = [ |
| ("who won the world cup 2018", "france"), |
| ("what is the capital of france", "paris"), |
| ] |
| lm = _build_model(pairs) |
| conv = Conversation(model=lm, fuzzy_threshold=0.95, learn_live=True) |
| conv.register_questions(pairs) |
|
|
| |
| assert conv.respond("who won the world cup 2018", temperature=0.0, seed=0) == "france" |
| conv.reset() |
| assert conv.respond("what is the capital of france", temperature=0.0, seed=0) == "paris" |
| conv.reset() |
|
|
| |
| |
| reasoner = Reasoner(conv=conv, min_fragment_len=10) |
| answer, chain = reasoner.respond( |
| "what is the capital of the country that won the world cup 2018", |
| temperature=0.0, seed=0 |
| ) |
| |
| assert chain is not None, "chaining should have been attempted" |
| assert chain.success, f"chain should succeed, steps: {chain.steps}" |
| assert "paris" in answer.lower(), f"expected paris, got {answer}" |
|
|
| def test_direct_answer_no_chain(self): |
| """If direct retrieval works, don't chain.""" |
| pairs = [("bonjour", "salut")] |
| lm = _build_model(pairs) |
| conv = Conversation(model=lm, fuzzy_threshold=0.99) |
| conv.register_questions(pairs) |
| reasoner = Reasoner(conv=conv) |
|
|
| answer, chain = reasoner.respond("bonjour", temperature=0.0, seed=0) |
| assert chain is None |
| assert "salut" in answer.lower() |
|
|
| def test_no_chain_possible(self): |
| """When no known question overlaps, chaining fails gracefully.""" |
| pairs = [("bonjour", "salut")] |
| lm = _build_model(pairs) |
| conv = Conversation(model=lm, fuzzy_threshold=0.99) |
| conv.register_questions(pairs) |
| reasoner = Reasoner(conv=conv, min_fragment_len=15) |
|
|
| answer, chain = reasoner.respond("xyz123 completely unknown", temperature=0.0, seed=0) |
| assert answer == FALLBACK_RESPONSE |
| assert chain is not None |
| assert not chain.success |
|
|
| def test_chain_trace_populated(self): |
| """The reasoning trace should show the intermediate steps.""" |
| pairs = [ |
| ("who won the world cup 2018", "france"), |
| ("what is the capital of france", "paris"), |
| ] |
| lm = _build_model(pairs) |
| conv = Conversation(model=lm, fuzzy_threshold=0.95) |
| conv.register_questions(pairs) |
| reasoner = Reasoner(conv=conv, min_fragment_len=10) |
|
|
| answer, chain = reasoner.respond( |
| "what is the capital of the country that won the world cup 2018", |
| temperature=0.0, seed=0 |
| ) |
| if chain and chain.success: |
| assert chain.n_hops >= 1 |
| step = chain.steps[0] |
| assert "world cup" in step.sub_question.lower() |
| assert step.sub_answer == "france" |
|
|
| def test_max_hops_limit(self): |
| """Chaining should respect the max_hops limit.""" |
| pairs = [("a", "b")] |
| lm = _build_model(pairs, D=1000) |
| conv = Conversation(model=lm, fuzzy_threshold=0.99) |
| conv.register_questions(pairs) |
| reasoner = Reasoner(conv=conv, max_hops=1, min_fragment_len=5) |
|
|
| answer, chain = reasoner.respond("a question about a and more", temperature=0.0, seed=0) |
| |
| assert isinstance(answer, str) |
|
|
| def test_three_hop_chain(self): |
| """A→B, B→C, C→D: can the model chain three hops?""" |
| pairs = [ |
| ("who won the world cup 2018", "france"), |
| ("what is the capital of france", "paris"), |
| ("what river flows through paris", "the seine"), |
| ] |
| lm = _build_model(pairs, D=5000, ctx=48) |
| conv = Conversation(model=lm, fuzzy_threshold=0.90, learn_live=True) |
| conv.register_questions(pairs) |
| reasoner = Reasoner(conv=conv, max_hops=3, min_fragment_len=8) |
|
|
| |
| answer, chain = reasoner.respond( |
| "what river flows through the capital of the country that won the world cup 2018", |
| temperature=0.0, seed=0 |
| ) |
| |
| assert isinstance(answer, str) |
| if chain: |
| assert isinstance(chain, ChainResult) |
|
|
| def test_chaining_with_live_learning(self): |
| """Teach a fact at runtime, then chain through it.""" |
| pairs = [ |
| ("who won the world cup 2018", "france"), |
| ] |
| lm = _build_model(pairs) |
| conv = Conversation(model=lm, fuzzy_threshold=0.95, learn_live=True) |
| conv.register_questions(pairs) |
|
|
| |
| conv.teach("what is the capital of france", "paris") |
|
|
| reasoner = Reasoner(conv=conv, min_fragment_len=10) |
| answer, chain = reasoner.respond( |
| "what is the capital of the country that won the world cup 2018", |
| temperature=0.0, seed=0 |
| ) |
| if chain and chain.success: |
| |
| assert answer.strip() and len(answer) >= 3 |
|
|
| def test_chain_does_not_corrupt_history(self): |
| """Chaining should not leave partial results in conversation history.""" |
| pairs = [ |
| ("who won the world cup 2018", "france"), |
| ("what is the capital of france", "paris"), |
| ] |
| lm = _build_model(pairs) |
| conv = Conversation(model=lm, fuzzy_threshold=0.95) |
| conv.register_questions(pairs) |
| reasoner = Reasoner(conv=conv, min_fragment_len=10) |
|
|
| reasoner.respond( |
| "what is the capital of the country that won the world cup 2018", |
| temperature=0.0, seed=0 |
| ) |
| |
| assert len(conv.history) == 2 |
| assert conv.history[0].role == "user" |
| assert conv.history[1].role == "palimpseste" |
|
|
| def test_empty_known_questions(self): |
| """Reasoner with no known questions should return fallback.""" |
| pairs = [("bonjour", "salut")] |
| lm = _build_model(pairs, D=1000) |
| conv = Conversation(model=lm, fuzzy_threshold=0.99) |
| |
| reasoner = Reasoner(conv=conv) |
|
|
| answer, chain = reasoner.respond("unknown question xyz", temperature=0.0, seed=0) |
| assert answer == FALLBACK_RESPONSE |
|
|