build-small-hackathon / tests /test_pruning.py
shankarsain's picture
Clean up repository and update codebase for vidyabot-build-small
a2398a7
Raw
History Blame Contribute Delete
6.46 kB
"""
Tests for Context Pruning Pipeline
Tests that the 3-stage pruning pipeline:
- Returns fewer chunks than BM25 stage
- Returns fewer total tokens than baseline
- Actually reduces token count by >60%
- Handles edge cases gracefully
"""
import pytest
from backend.retrieval.context_pruner import ContextPruner
from backend.retrieval.bm25_index import BM25Index
from backend.retrieval.vector_store import VectorStore
from backend.database import Chunk, PruningResult
from backend.config import settings
class TestContextPruner:
"""Test 3-stage context pruning."""
def test_pruning_result_class(self):
"""Test PruningResult dataclass."""
chunks = [
Chunk(id=1, textbook_id=1, chapter_number=1, chapter_title="Chapter 1", section_title="Section 1", page_number=1, chunk_index=0, content="Test 1", token_count=50),
Chunk(id=2, textbook_id=1, chapter_number=1, chapter_title="Chapter 1", section_title="Section 1", page_number=1, chunk_index=1, content="Test 2", token_count=60),
]
result = PruningResult(
chunks=chunks,
total_tokens=110,
baseline_tokens=2000,
pruning_ratio=0.945
)
assert result.total_tokens == 110
assert result.baseline_tokens == 2000
assert result.tokens_saved == 1890
assert result.pruning_ratio == 0.945
# Check dict conversion
result_dict = result.to_dict()
assert result_dict['total_tokens'] == 110
assert result_dict['tokens_saved'] == 1890
def test_bm25_index_creation(self):
"""Test BM25 index initialization."""
bm25 = BM25Index()
# Should handle empty chunks
bm25.build_index(1, [])
assert bm25.chunk_ids == [] or bm25.bm25 is None
def test_bm25_tokenization(self):
"""Test BM25 tokenization."""
bm25 = BM25Index()
# Test tokenization
text = "This is A Test. What about stopwords?"
tokens = bm25._tokenize(text)
# Stopwords should be removed
assert 'is' not in tokens
assert 'a' not in tokens
assert 'about' not in tokens
# Content words should remain
assert 'test' in tokens or 'question' in [t for t in tokens if len(t) > 3]
def test_pruning_reduction_ratio(self):
"""Test that pruning actually reduces tokens."""
pruning_result = PruningResult(
chunks=[], # Doesn't matter for this test
total_tokens=400,
baseline_tokens=2000,
pruning_ratio=0.8
)
# Check reduction ratio
assert pruning_result.pruning_ratio == 0.8
assert pruning_result.tokens_saved == 1600
# Should be > 50% reduction minimum
assert pruning_result.pruning_ratio > 0.5
class TestVectorStore:
"""Test FAISS vector store."""
def test_vector_store_initialization(self):
"""Test VectorStore initialization."""
store = VectorStore()
assert store.embedder is not None
assert len(store.faiss_indices) == 0
assert len(store.chunk_id_maps) == 0
def test_search_without_index(self):
"""Test searching when no index exists."""
import numpy as np
store = VectorStore()
query_embedding = np.zeros(settings.EMBEDDINGS_DIMENSION, dtype=np.float32)
# Should return empty list gracefully
results = store.search(query_embedding, textbook_id=999)
assert results == []
def test_vector_store_search_with_candidates(self):
"""Test that search respects candidate filter."""
import numpy as np
store = VectorStore()
query_embedding = np.zeros(settings.EMBEDDINGS_DIMENSION, dtype=np.float32)
# Search with filter should also return empty (no index)
results = store.search(
query_embedding,
textbook_id=1,
candidate_chunk_ids=[1, 2, 3],
top_k=5
)
assert results == []
class TestPruningEdgeCases:
"""Test edge cases in pruning."""
def test_empty_question(self):
"""Test that empty questions are handled."""
# This should be caught at API level, but test anyway
bm25 = BM25Index()
# Empty query should still work (just return nothing)
results = bm25.search("", textbook_id=1)
assert isinstance(results, list)
def test_very_long_chunk(self):
"""Test chunking of very long text."""
from backend.ingestion.chunker import Chunker
chunker = Chunker(max_chunk_tokens=50)
# Very long text
long_text = "word " * 500
# This is tricky without a real ParsedPage, so we skip the actual splitting
# but test that estimate_tokens works on long text
tokens = chunker.estimate_tokens(long_text)
assert tokens > 100 # Should be many tokens
def test_pruning_result_zero_baseline(self):
"""Test PruningResult with zero baseline."""
# This shouldn't normally happen, but test defensive code
result = PruningResult(
chunks=[],
total_tokens=0,
baseline_tokens=0,
pruning_ratio=0.0
)
assert result.tokens_saved == 0
assert result.pruning_ratio == 0.0
class TestStageIsolation:
"""Test that pruning stages work independently."""
def test_bm25_stage_alone(self):
"""Test BM25 stage can work without FAISS."""
bm25 = BM25Index()
# Should not throw error for empty search
results = bm25._fallback_search(textbook_id=1, top_k=10)
assert isinstance(results, list)
def test_semantic_stage_alone(self):
"""Test semantic search can fail gracefully."""
import numpy as np
store = VectorStore()
query_embedding = np.random.randn(settings.EMBEDDINGS_DIMENSION).astype(np.float32)
# Should return empty list, not crash
results = store.search_from_db(query_embedding, textbook_id=1)
assert isinstance(results, list)
assert len(results) == 0
if __name__ == '__main__':
pytest.main([__file__, '-v'])