Spaces:
Sleeping
Sleeping
| """Tests for the search engine pipeline. | |
| Run with: pytest | |
| """ | |
| from pathlib import Path | |
| import pytest | |
| from news_search import Document, SearchEngine, build_index, load_corpus | |
| from news_search import ranking | |
| from news_search.index import InvertedIndex | |
| SAMPLE = Path(__file__).resolve().parents[1] / "data" / "sample_news.jsonl" | |
| def test_corpus_loads(): | |
| docs = load_corpus(SAMPLE) | |
| assert len(docs) > 100 | |
| # The overwhelming majority of records have indexable text. | |
| assert sum(1 for d in docs if d.text) > 0.9 * len(docs) | |
| assert docs[0].id == 0 | |
| def test_index_stats(engine): | |
| assert engine.index.num_docs > 100 | |
| assert engine.index.vocabulary_size > 500 | |
| # IDF precomputed for every term | |
| assert all(idf >= 0 for idf in engine.index.idf.values()) | |
| def test_basic_search_returns_ranked_results(engine): | |
| res = engine.search("health", method="tfidf", top_k=10) | |
| assert res.total_hits > 0 | |
| scores = [r["score"] for r in res.results] | |
| assert scores == sorted(scores, reverse=True) # descending | |
| ranks = [r["rank"] for r in res.results] | |
| assert ranks == list(range(1, len(ranks) + 1)) | |
| def test_or_semantics_not_and(engine): | |
| """A multi-term query should still return docs even if no single doc has all | |
| terms (the original AND-only behaviour returned nothing here).""" | |
| res = engine.search("health technology economy", method="tfidf", top_k=10) | |
| assert res.total_hits > 0 | |
| def test_empty_and_nonsense_queries(engine): | |
| assert engine.search("").total_hits == 0 | |
| assert engine.search("zzzqqqxyzzy").total_hits == 0 | |
| def test_category_filter(engine): | |
| # 'health' is a real content word (not a stopword), so this actually exercises | |
| # the filter — the old test used the stopword 'the' and passed vacuously. | |
| res = engine.search("health", method="bm25", top_k=30, category="POLITICS") | |
| assert all(r["category"] == "POLITICS" for r in res.results) | |
| def _make_engine_with_categories() -> SearchEngine: | |
| """Synthetic corpus: 8 POLITICS + 3 SPORTS docs all containing 'reform'.""" | |
| docs = [] | |
| for i in range(8): | |
| docs.append(Document(id=i, text="reform policy senate vote", | |
| headline="h", short_description="reform policy senate vote", | |
| category="POLITICS", date="", link="https://example.com")) | |
| for i in range(8, 11): | |
| docs.append(Document(id=i, text="reform team game season", | |
| headline="h", short_description="reform team game season", | |
| category="SPORTS", date="", link="https://example.com")) | |
| return SearchEngine(build_index(docs, verbose=False)) | |
| def test_category_filter_restricts_before_truncation(): | |
| """Regression for the bug where the category filter ran *after* top_k | |
| truncation, silently dropping relevant in-category docs ranked below top_k.""" | |
| eng = _make_engine_with_categories() | |
| full = eng.search("reform", method="bm25", top_k=100, category="POLITICS") | |
| assert full.total_hits == 8 | |
| assert all(r["category"] == "POLITICS" for r in full.results) | |
| limited = eng.search("reform", method="bm25", top_k=3, category="POLITICS") | |
| assert len(limited.results) == 3 # page filled from within the category | |
| assert limited.total_hits == 8 # honest total, not capped at top_k | |
| assert all(r["category"] == "POLITICS" for r in limited.results) | |
| def test_category_with_no_docs_returns_empty(engine): | |
| res = engine.search("health", category="NO_SUCH_CATEGORY_XYZ") | |
| assert res.total_hits == 0 | |
| assert res.results == [] | |
| def test_total_hits_is_true_count_not_capped(engine): | |
| # Use the most frequent indexed term so we know many docs match. | |
| term = max(engine.index.postings, key=lambda t: len(engine.index.postings[t])) | |
| page = engine.search(term, method="bm25", top_k=5) | |
| full = engine.search(term, method="bm25", top_k=10_000) | |
| assert page.total_hits == full.total_hits # total independent of page size | |
| assert page.total_hits > 5 # more matches than one page | |
| assert len(page.results) == 5 # page filled to top_k | |
| assert len(full.results) == full.total_hits # everything returned when top_k huge | |
| def test_ranking_and_mode_requires_all_terms(engine): | |
| by_df = sorted(engine.index.postings, key=lambda t: len(engine.index.postings[t]), | |
| reverse=True) | |
| t1, t2 = by_df[0], by_df[3] | |
| or_hits = ranking.bm25([t1, t2], engine.index, top_k=None, mode="or") | |
| and_hits = ranking.bm25([t1, t2], engine.index, top_k=None, mode="and") | |
| assert len(and_hits) <= len(or_hits) | |
| for doc_id, _ in and_hits: | |
| fwd = engine.index.forward[doc_id] | |
| assert t1 in fwd and t2 in fwd # 'and' docs contain every term | |
| def test_ranking_restrict_to_limits_candidates(engine): | |
| term = max(engine.index.postings, key=lambda t: len(engine.index.postings[t])) | |
| allowed = set(list(engine.index.meta)[:3]) | |
| res = ranking.bm25([term], engine.index, top_k=None, mode="or", restrict_to=allowed) | |
| assert {doc_id for doc_id, _ in res}.issubset(allowed) | |
| def test_bert_method_without_dense_falls_back_gracefully(engine): | |
| # engine.dense is None -> semantic methods fall back to lexical, no error | |
| res = engine.search("health", method="bert", top_k=5) | |
| assert res.method == "bert" | |
| assert isinstance(res.results, list) | |
| def test_hybrid_method_without_dense_falls_back_gracefully(engine): | |
| res = engine.search("health", method="hybrid", top_k=5) | |
| assert res.method == "hybrid" | |
| assert isinstance(res.results, list) | |
| def test_prf_relevance_feedback_uses_marked_docs(engine): | |
| from news_search import expansion | |
| rel = list(engine.index.meta)[:2] | |
| fb_terms = expansion.prf_terms(["news"], engine.index, relevant_ids=rel) | |
| # every feedback term must come from the documents the user marked relevant | |
| union = set() | |
| for doc_id in rel: | |
| union |= set(engine.index.forward[doc_id]) | |
| assert fb_terms # the marked docs contribute some terms | |
| assert all(t in union for t in fb_terms) | |
| def test_prf_search_accepts_relevant_ids(engine): | |
| rel = list(engine.index.meta)[:3] | |
| res = engine.search("health", method="prf", top_k=10, relevant_ids=rel) | |
| assert res.method == "prf" | |
| assert isinstance(res.expansion_terms, list) | |
| # --- dense / semantic retrieval (needs sentence-transformers) --------------- # | |
| _SEMANTIC_DOCS = [ | |
| Document(id=0, text="the president won the national election", headline="Election", | |
| short_description="", category="POLITICS", date="", link="https://example.com"), | |
| Document(id=1, text="the striker scored a goal in the football match", headline="Football", | |
| short_description="", category="SPORTS", date="", link="https://example.com"), | |
| Document(id=2, text="a new vaccine lowers the risk of disease", headline="Vaccine", | |
| short_description="", category="HEALTH", date="", link="https://example.com"), | |
| ] | |
| def test_dense_retriever_matches_by_meaning(): | |
| pytest.importorskip("sentence_transformers") | |
| from news_search.dense import DenseRetriever | |
| dr = DenseRetriever().fit(_SEMANTIC_DOCS, verbose=False) | |
| top = dr.search("soccer match result", top_k=1) # no shared keywords with the doc | |
| assert top and top[0][0] == 1 # the football doc wins on meaning | |
| def test_engine_bert_and_hybrid_with_dense(): | |
| pytest.importorskip("sentence_transformers") | |
| from news_search.dense import DenseRetriever | |
| idx = build_index(_SEMANTIC_DOCS, verbose=False) | |
| eng = SearchEngine(idx, dense=DenseRetriever().fit(_SEMANTIC_DOCS, verbose=False)) | |
| res = eng.search("soccer match result", method="bert", top_k=1) | |
| assert res.method == "bert" | |
| assert res.results and res.results[0]["id"] == 1 | |
| res_h = eng.search("soccer match result", method="hybrid", top_k=3) | |
| assert res_h.method == "hybrid" | |
| assert len(res_h.results) >= 1 | |
| def test_prf_adds_expansion_terms(engine): | |
| res = engine.search("health", method="prf", top_k=10) | |
| # PRF should surface additional terms from the top documents | |
| assert isinstance(res.expansion_terms, list) | |
| assert all(t not in res.query.split() for t in res.expansion_terms) | |
| def test_bm25_is_default_and_ranks(engine): | |
| res = engine.search("health") # default method | |
| assert res.method == "bm25" | |
| assert res.total_hits > 0 | |
| scores = [r["score"] for r in res.results] | |
| assert scores == sorted(scores, reverse=True) | |
| def test_bm25_avoids_trivial_short_doc_bias(engine): | |
| """BM25 should not let a 1-2 token headline dominate a multi-term query the | |
| way normalised-TF TF-IDF does.""" | |
| assert engine.index.avg_doc_len > 0 | |
| res = engine.search("climate change", method="bm25", top_k=5) | |
| top_lengths = [engine.index.doc_len[r["id"]] for r in res.results] | |
| # at least one of the top results is a real (non-trivial) document | |
| assert max(top_lengths) >= 8 | |
| def test_unknown_method_raises(engine): | |
| with pytest.raises(ValueError): | |
| engine.search("health", method="not-a-method") | |
| def test_index_persistence_roundtrip(engine, tmp_path): | |
| p = tmp_path / "idx.pkl" | |
| engine.index.save(p) | |
| reloaded = InvertedIndex.load(p) | |
| assert reloaded.num_docs == engine.index.num_docs | |
| assert reloaded.vocabulary_size == engine.index.vocabulary_size | |
| res = SearchEngine(reloaded).search("health", top_k=5) | |
| assert res.total_hits > 0 | |