MathMate / tests /test_pipeline_v2.py
MIHIR MITHANI
Deploy Streamlit app
0d1528e
Raw History Blame Contribute Delete
9.76 kB
"""
Unit tests for pipeline_v2 with mocked call_llm.
"""
import pytest
import os
from dotenv import load_dotenv
from unittest.mock import patch, MagicMock
# Load .env for any tests that might need it
_here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
load_dotenv(os.path.join(_here, ".env"))
from pipeline_v2 import (
start_problem,
get_hint,
can_unlock_level3,
clear_debug_log,
)
from schemas import Solution, Step, Gate, Hint, LeakVerdict, WorkingAnalysis
from guardrails import SAFE_FALLBACK
from llm import ValidationFailure
# Mock solutions
MOCK_SOLUTION = Solution(
steps=[
Step(description="Subtract 4 from 15", expression="15 - 4", result="11"),
Step(description="Add 7 to 11", expression="11 + 7", result="18"),
],
final_answer="18",
answer_rationale="15 - 4 + 7 = 18"
)
MOCK_HINT_L1 = Hint(
level=1,
content="What information is given? What are you asked to find?",
guiding_question="What do you know and what do you need?",
concept=None,
sub_goal=None
)
MOCK_HINT_L2 = Hint(
level=2,
content="This is a multi-step addition and subtraction problem. First, find how many after giving away.",
guiding_question=None,
concept="multi-step arithmetic",
sub_goal="15 - 4 = ?"
)
MOCK_HINT_L3 = Hint(
level=3,
content="Step 1: 15 - 4 = 11. Step 2: 11 + 7 = 18. Answer: 18.",
guiding_question=None,
concept=None,
sub_goal=None
)
class MockCallLLM:
"""Mock call_llm to return predefined responses."""
def __init__(self):
self.responses = []
self.call_count = 0
def add_response(self, response):
self.responses.append(response)
def __call__(self, system, user, model=None, temperature=0.0, max_tokens=800):
if self.call_count < len(self.responses):
resp = self.responses[self.call_count]
self.call_count += 1
return resp
return "{}"
class MockCallJSON:
"""Mock call_json to return predefined Pydantic objects or raise ValidationFailure."""
def __init__(self):
self.responses = []
self.call_count = 0
def add_response(self, response):
self.responses.append(response)
def __call__(self, system, user, schema_cls, model=None, temperature=0.0, max_tokens=800):
if self.call_count < len(self.responses):
resp = self.responses[self.call_count]
self.call_count += 1
if isinstance(resp, Exception):
raise resp
return resp
# Default fallback
if schema_cls.__name__ == "Solution":
return MOCK_SOLUTION
elif schema_cls.__name__ == "Gate":
return Gate(allowed=True, reason="ok", category="math_word_problem")
elif schema_cls.__name__ == "Hint":
return MOCK_HINT_L1
elif schema_cls.__name__ == "LeakVerdict":
return LeakVerdict(has_leak=False, leak_type=None, explanation="clean", severity="none")
elif schema_cls.__name__ == "WorkingAnalysis":
return WorkingAnalysis(has_error=False, first_wrong_step_index=None, error_type=None, expected_step=None, explanation="ok")
return MagicMock()
# ===== Tests for start_problem =====
@patch('pipeline_v2.call_json')
def test_start_problem_success(mock_call_json):
"""Test successful problem start with consistent solutions."""
mock = MockCallJSON()
mock.add_response(Gate(allowed=True, reason="ok", category="math_word_problem")) # P0
mock.add_response(MOCK_SOLUTION) # P1 temp 0
mock.add_response(MOCK_SOLUTION) # P1 temp 0.7
mock_call_json.side_effect = mock
clear_debug_log()
result = start_problem("Sarah has 15 apples. She gives 4 away and buys 7 more. How many?")
assert result["status"] == "ok"
assert "solution" in result
assert result["solution"].final_answer == "18"
@patch('pipeline_v2.call_json')
def test_start_problem_code_gate_blocks(mock_call_json):
"""Test code gate blocks empty input."""
clear_debug_log()
result = start_problem("")
assert result["status"] == "refused"
assert "empty" in result["refusal_message"].lower()
@patch('pipeline_v2.call_json')
def test_start_problem_p0_blocks(mock_call_json):
"""Test P0 classifier blocks off-topic."""
mock = MockCallJSON()
mock.add_response(Gate(allowed=False, reason="off topic", category="off_topic"))
mock_call_json.side_effect = mock
clear_debug_log()
result = start_problem("Write a poem about math")
assert result["status"] == "refused"
assert "math word problem" in result["refusal_message"].lower()
@patch('pipeline_v2.call_json')
def test_start_problem_inconsistent_solutions(mock_call_json):
"""Test inconsistent solutions returns unverifiable."""
sol1 = Solution(steps=[], final_answer="18", answer_rationale="ok")
sol2 = Solution(steps=[], final_answer="20", answer_rationale="ok")
mock = MockCallJSON()
mock.add_response(Gate(allowed=True, reason="ok", category="math_word_problem"))
mock.add_response(sol1)
mock.add_response(sol2)
mock_call_json.side_effect = mock
clear_debug_log()
result = start_problem("Sarah has 15 apples. She gives 4 away and buys 7 more. How many?")
assert result["status"] == "refused"
assert "unverifiable" in result["refusal_message"].lower()
@patch('pipeline_v2.call_json')
def test_start_problem_p1_validation_failure(mock_call_json):
"""Test P1 validation failure returns unverifiable."""
mock = MockCallJSON()
mock.add_response(Gate(allowed=True, reason="ok", category="math_word_problem"))
mock.add_response(ValidationFailure("bad json", "{}"))
mock_call_json.side_effect = mock
clear_debug_log()
result = start_problem("Sarah has 15 apples. She gives 4 away and buys 7 more. How many?")
assert result["status"] == "refused"
assert "unverifiable" in result["refusal_message"].lower()
# ===== Tests for get_hint =====
@patch('pipeline_v2.call_json')
def test_get_hint_level1_passes(mock_call_json):
"""Test level 1 hint passes leak checks."""
mock = MockCallJSON()
mock.add_response(MOCK_HINT_L1) # P2
mock.add_response(LeakVerdict(has_leak=False, leak_type=None, explanation="clean", severity="none")) # P3
mock_call_json.side_effect = mock
clear_debug_log()
result = get_hint("Sarah has 15 apples...", MOCK_SOLUTION, 1)
assert result["guard_status"] == "passed"
assert result["attempts"] == 1
assert "What information" in result["hint"]
@patch('pipeline_v2.call_json')
def test_get_hint_level1_leak_then_regenerate_then_pass(mock_call_json):
"""Test level 1: leak detected, regenerate, then pass."""
mock = MockCallJSON()
# First attempt: leak
mock.add_response(Hint(level=1, content="Add 15 and 4 to get 19", guiding_question="Try adding"))
mock.add_response(LeakVerdict(has_leak=True, leak_type="operation", explanation="names operation", severity="major"))
# Second attempt: clean
mock.add_response(MOCK_HINT_L1)
mock.add_response(LeakVerdict(has_leak=False, leak_type=None, explanation="clean", severity="none"))
mock_call_json.side_effect = mock
clear_debug_log()
result = get_hint("Sarah has 15 apples...", MOCK_SOLUTION, 1)
assert result["guard_status"] == "regenerated_1"
assert result["attempts"] == 2
assert "information is given" in result["hint"]
@patch('pipeline_v2.call_json')
def test_get_hint_level1_leak_three_times_then_fallback(mock_call_json):
"""Test level 1: leak 3 times (2 regens + initial) then fallback."""
mock = MockCallJSON()
# 3 attempts all leaking
for _ in range(3):
mock.add_response(Hint(level=1, content="The answer is 18"))
mock.add_response(LeakVerdict(has_leak=True, leak_type="final_answer", explanation="gives answer", severity="major"))
mock_call_json.side_effect = mock
clear_debug_log()
result = get_hint("Sarah has 15 apples...", MOCK_SOLUTION, 1)
assert result["guard_status"] == "fallback"
assert result["attempts"] == 3
assert result["hint"] == SAFE_FALLBACK[1]
@patch('pipeline_v2.call_json')
def test_get_hint_invalid_json_twice_then_fallback(mock_call_json):
"""Test invalid JSON twice (call_json retries once) then fallback."""
mock = MockCallJSON()
# First call: ValidationFailure (call_json retries once internally)
# Second call: ValidationFailure again -> fallback
mock.add_response(ValidationFailure("bad json", "{}"))
mock.add_response(ValidationFailure("bad json again", "{}"))
mock_call_json.side_effect = mock
clear_debug_log()
result = get_hint("Sarah has 15 apples...", MOCK_SOLUTION, 1)
assert result["guard_status"] == "fallback"
assert result["hint"] == SAFE_FALLBACK[1]
@patch('pipeline_v2.call_json')
def test_get_hint_level3_no_leak_check(mock_call_json):
"""Test level 3 has no leak check."""
mock = MockCallJSON()
mock.add_response(MOCK_HINT_L3)
mock_call_json.side_effect = mock
clear_debug_log()
result = get_hint("Sarah has 15 apples...", MOCK_SOLUTION, 3)
assert result["guard_status"] == "passed"
assert result["attempts"] == 1
assert "Step 1" in result["hint"]
# ===== Tests for can_unlock_level3 =====
def test_can_unlock_level3_both_shown():
assert can_unlock_level3([1, 2], 0) is True
def test_can_unlock_level3_only_one_shown():
assert can_unlock_level3([1], 0) is False
assert can_unlock_level3([2], 0) is False
def test_can_unlock_level3_attempt_made():
assert can_unlock_level3([], 1) is True
assert can_unlock_level3([1], 1) is True
if __name__ == "__main__":
pytest.main([__file__, "-v"])