ragsolver / tests /test_retriever.py
CamiloS1111's picture
Upload folder using huggingface_hub
a3c590d verified
Raw History Blame Contribute Delete
12.4 kB
"""Fase 3, tarea 6 — Retriever con LanceDB temporal y embedder/reranker falsos.
Vectores de dimensión 4 elegidos a mano para controlar el orden denso; el
reranker falso puntúa por longitud del texto. Así cada test aísla una pieza:
filtros SQL, modo `error`, dedupe, figuras adjuntas, ablación, traza, plantillas.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
import yaml
from ragsolver.logging.jsonl_writer import read_events
from ragsolver.rag import retrieve as R
from ragsolver.rag.index import INFORMAL_FTS_COLUMNS, MATHLIB_FTS_COLUMNS, build_table
from ragsolver.rag.retrieve import Retriever, lean_identifiers, sql_filter
from ragsolver.rag.schema import RetrievalFilters, RetrievalRequest
E1, E2, E3, E4 = [1.0, 0, 0, 0], [0, 1.0, 0, 0], [0, 0, 1.0, 0], [0, 0, 0, 1.0]
MATHLIB = [
{"chunk_id": "mathlib:Nat.Prime.two_le:0", "doc_id": "mathlib", "corpus": "mathlib", "unit_type": "mathlib_decl", "decl_name": "Nat.Prime.two_le", "module_path": "Mathlib.Data.Nat.Prime.Defs", "kind": "theorem", "signature": "theorem Nat.Prime.two_le {p : ℕ} (hp : p.Prime) : 2 ≤ p", "informal_name": "prime is at least two", "informal_description": "A prime number is at least 2.", "docs_url": "https://x#Nat.Prime.two_le", "text": "prime is at least two", "embedding": E4},
{"chunk_id": "mathlib:Nat.Coprime:0", "doc_id": "mathlib", "corpus": "mathlib", "unit_type": "mathlib_decl", "decl_name": "Nat.Coprime", "module_path": "Mathlib.Data.Nat.GCD.Basic", "kind": "definition", "signature": "def Nat.Coprime (m n : ℕ) : Prop", "informal_name": "coprime naturals", "informal_description": "gcd m n = 1", "docs_url": "https://x#Nat.Coprime", "text": "coprime naturals gcd equals one", "embedding": E1},
{"chunk_id": "mathlib:Nat.gcd_comm:0", "doc_id": "mathlib", "corpus": "mathlib", "unit_type": "mathlib_decl", "decl_name": "Nat.gcd_comm", "module_path": "Mathlib.Data.Nat.GCD.Basic", "kind": "theorem", "signature": "theorem Nat.gcd_comm (m n : ℕ) : gcd m n = gcd n m", "informal_name": "gcd is commutative", "informal_description": "gcd commutes", "docs_url": "https://x#Nat.gcd_comm", "text": "gcd is commutative", "embedding": E2},
{"chunk_id": "mathlib:Int.gcd_comm:0", "doc_id": "mathlib", "corpus": "mathlib", "unit_type": "mathlib_decl", "decl_name": "Int.gcd_comm", "module_path": "Mathlib.Data.Int.GCD", "kind": "theorem", "signature": "theorem Int.gcd_comm (m n : ℤ) : gcd m n = gcd n m", "informal_name": "integer gcd is commutative", "informal_description": "gcd commutes for integers", "docs_url": "https://x#Int.gcd_comm", "text": "integer gcd is commutative", "embedding": E3},
]
INFORMAL = [
{"chunk_id": "informal:d1:problem_card:8", "doc_id": "d1", "corpus": "informal", "unit_type": "problem_card", "parent_id": None, "item_number": 8, "page_start": 10, "math_area": ["geometry"], "keywords": ["circle"], "theorems_used": [], "figure_ids": ["d1:fig:10:2"], "content_md": "8. tangent circles", "text": "problem about tangent circles and an inscribed angle", "embedding": E1},
{"chunk_id": "informal:d1:figure:10:2", "doc_id": "d1", "corpus": "informal", "unit_type": "figure", "parent_id": "informal:d1:problem_card:8", "item_number": 8, "page_start": 10, "math_area": ["geometry"], "keywords": ["circle"], "theorems_used": [], "figure_ids": ["d1:fig:10:2"], "content_md": "a circle tangent to a bigger circle", "text": "figure: a circle tangent to a bigger circle", "embedding": [0.9, 0.1, 0, 0]},
{"chunk_id": "informal:d1:problem_card:86", "doc_id": "d1", "corpus": "informal", "unit_type": "problem_card", "parent_id": None, "item_number": 86, "page_start": 22, "math_area": ["number_theory"], "keywords": ["divisibility"], "theorems_used": [], "figure_ids": [], "content_md": "86. divisible by 7", "text": "prove that a power sum is divisible by seven", "embedding": E2},
{"chunk_id": "informal:d2:problem_card:1", "doc_id": "d2", "corpus": "informal", "unit_type": "problem_card", "parent_id": None, "item_number": 1, "page_start": 1, "math_area": ["algebra"], "keywords": [], "theorems_used": [], "figure_ids": [], "content_md": "1. other doc", "text": "an algebra problem from another document", "embedding": E3},
]
FIGURES = [{"figure_id": "d1:fig:10:2", "image_path": "/tmp/p010_b2.png", "description": "two tangent circles", "page": 10, "bbox": [0.1, 0.1, 0.5, 0.5], "figure_type": "geometric_diagram", "labels": ["A", "B"], "detected_by": "layout"}]
@pytest.fixture
def retriever(tmp_path, monkeypatch):
db = str(tmp_path / "db")
build_table(db, "mathlib", MATHLIB, MATHLIB_FTS_COLUMNS)
build_table(db, "informal", INFORMAL, INFORMAL_FTS_COLUMNS)
build_table(db, "figures", FIGURES)
cfg = yaml.safe_load(open("configs/retrieval.yaml", encoding="utf-8"))
(tmp_path / "retrieval.yaml").write_text(yaml.safe_dump(cfg), encoding="utf-8")
calls = {"instructions": [], "queries": []}
def fake_embed(texts, model_name, batch_size=8, instruction=None):
calls["instructions"].append(instruction)
calls["queries"].append(list(texts))
table = {"gcd": E2, "coprime": E1, "prime": E4, "integer": E3, "circle": E1, "seven": E2, "algebra": E3}
return [next((v for k, v in table.items() if k in t.lower()), [0.5, 0.5, 0.5, 0.5]) for t in texts]
monkeypatch.setattr(R, "embed_queries", fake_embed)
class FakeReranker: # más largo = mejor, para poder predecir el orden
def predict(self, pairs):
return [float(len(doc)) for _, doc in pairs]
r = Retriever(db_path=db, config_path=str(tmp_path / "retrieval.yaml"))
monkeypatch.setattr(Retriever, "_reranker", lambda self: FakeReranker())
r.trace_dir = str(tmp_path / "traces")
r._calls = calls
return r
def _req(**kw):
base = dict(queries=["gcd commutes"], corpora=["mathlib"], k=8, rerank=False, context={"problem_id": "p1", "subgoal_id": "s1", "attempt": 2, "agent": "test"})
base.update(kw)
return RetrievalRequest(**base)
def test_lean_identifiers_from_error_messages():
assert lean_identifiers("unknown identifier 'Nat.Prime.two_le'") == ["Nat.Prime.two_le"]
assert lean_identifiers("unknown constant 'Foo.bar' and `Baz.qux'` here, also Nat.gcd_comm") == ["Foo.bar", "Baz.qux'", "Nat.gcd_comm"]
assert lean_identifiers("no identifiers here") == []
def test_sql_filter_quotes_and_scopes_by_corpus():
f = RetrievalFilters(doc_ids=["d'1"], kinds=["theorem"], math_area=["geometry"], unit_types=["problem_card"])
assert sql_filter("mathlib", f) == "doc_id IN ('d''1') AND unit_type IN ('problem_card') AND kind IN ('theorem')"
assert sql_filter("informal", f) == "doc_id IN ('d''1') AND unit_type IN ('problem_card') AND array_has_any(math_area, ['geometry'])"
assert sql_filter("mathlib", RetrievalFilters()) is None
def test_error_mode_pins_exact_identifier_at_top_even_against_dense_and_rerank(retriever):
# el embedding falso de la consulta apunta a los gcd (E2) y el reranker prefiere textos largos:
# ninguno favorece a Nat.Prime.two_le; el modo error lo fija igual en el top-1.
res = retriever.search(_req(queries=["unknown identifier 'Nat.Prime.two_le' in goal gcd commutes"], mode="error", rerank=True))
assert res.hits[0].chunk_id == "mathlib:Nat.Prime.two_le:0"
assert res.hits[0].citation.citation_id == "M1"
assert res.hits[0].citation.locator["docs_url"].endswith("#Nat.Prime.two_le")
assert len(res.hits) == 4
def test_filters_prefilter_kinds_doc_ids_and_math_area(retriever):
res = retriever.search(_req(filters={"kinds": ["definition"]}))
assert [h.chunk_id for h in res.hits] == ["mathlib:Nat.Coprime:0"]
res = retriever.search(_req(queries=["algebra"], corpora=["informal"], filters={"doc_ids": ["d1"]}))
assert all(h.metadata["doc_id"] == "d1" for h in res.hits) and res.hits
res = retriever.search(_req(queries=["circle"], corpora=["informal"], filters={"math_area": ["number_theory"]}))
assert [h.chunk_id for h in res.hits] == ["informal:d1:problem_card:86"]
def test_figure_chunk_collapses_into_its_card_and_figures_are_attached(retriever):
res = retriever.search(_req(queries=["circle"], corpora=["informal"]))
ids = [h.chunk_id for h in res.hits]
assert "informal:d1:problem_card:8" in ids and "informal:d1:figure:10:2" not in ids
card = next(h for h in res.hits if h.chunk_id == "informal:d1:problem_card:8")
assert card.figures == [{"figure_id": "d1:fig:10:2", "image_path": "/tmp/p010_b2.png", "description": "two tangent circles", "page": 10, "bbox": [0.1, 0.1, 0.5, 0.5], "figure_type": "geometric_diagram", "labels": ["A", "B"]}]
assert card.citation.locator["figure_ids"] == ["d1:fig:10:2"]
assert card.citation.citation_id == "I1" and card.citation.snippet == "8. tangent circles"
res = retriever.search(_req(queries=["circle"], corpora=["informal"], include_figures=False))
assert all(h.figures == [] for h in res.hits)
def test_rerank_reorders_and_scores_are_reported(retriever):
res = retriever.search(_req(rerank=True))
texts = [h.text for h in res.hits]
assert texts == sorted(texts, key=len, reverse=True) # reranker falso: más largo primero
assert all(h.citation.scores.rerank is not None and h.citation.scores.fused is not None for h in res.hits)
def test_multi_query_rrf_and_query_instruction_per_mode(retriever):
res = retriever.search(_req(queries=["gcd commutes", "coprime"], mode="formal", k=2))
assert {h.chunk_id for h in res.hits} <= {"mathlib:Nat.gcd_comm:0", "mathlib:Nat.Coprime:0", "mathlib:Int.gcd_comm:0"}
assert retriever._calls["instructions"][-1] == retriever.config["query_instructions"]["formal"]
assert retriever._calls["queries"][-1] == ["gcd commutes", "coprime"]
def test_max_per_module_limits_mathlib_hits(retriever):
retriever.config["max_per_module"] = 1
res = retriever.search(_req(queries=["gcd commutes coprime"], k=8))
modules = [h.metadata["module_path"] for h in res.hits]
assert len(modules) == len(set(modules))
def test_ablation_disabled_returns_empty_but_still_traces(retriever):
retriever.config["ablation"]["enabled"] = False
res = retriever.search(_req())
assert res.hits == [] and res.citations == []
events = [e for f in Path(retriever.trace_dir).glob("*.jsonl") for e in read_events(f.stem, retriever.trace_dir)]
assert len(events) == 1 and events[0].hits == [] and events[0].llm_calls == 0
def test_trace_event_records_request_hits_and_versions(retriever):
res = retriever.search(_req(k=2))
files = list(Path(retriever.trace_dir).glob("p1:*.jsonl"))
assert len(files) == 1
ev = json.loads(files[0].read_text(encoding="utf-8").splitlines()[0])
assert ev["agent"] == "retriever" and ev["llm_calls"] == 0 and ev["subgoal_id"] == "s1" and ev["attempt"] == 2
assert [h["chunk_id"] for h in ev["hits"]] == [h.chunk_id for h in res.hits]
assert ev["versions"]["mathlib_tag"] == retriever.versions["lean"]["mathlib_tag"]
assert ev["request"]["queries"] == ["gcd commutes"]
def test_trace_event_pins_the_mathlib_table_being_served(retriever):
"""MET-1: la traza registra qué tabla sirve `mathlib` (muestra vs índice completo)."""
build_table(retriever.db_path, "mathlib_full", MATHLIB, MATHLIB_FTS_COLUMNS)
retriever.tables["mathlib"] = "mathlib_full"
retriever.search(_req(k=2))
f = next(Path(retriever.trace_dir).glob("p1:*.jsonl"))
ev = json.loads(f.read_text(encoding="utf-8").splitlines()[-1])
assert ev["versions"]["mathlib_table"] == "mathlib_full"
def test_run_id_in_context_groups_searches_into_one_trace_file(retriever):
"""TRZ-1: con `context.run_id`, los eventos de varias búsquedas del mismo par
(config, problema) van al mismo archivo — antes cada búsqueda creaba el suyo,
sin poder atribuirla a una corrida/config."""
ctx = {"problem_id": "p1", "subgoal_id": "s1", "attempt": 1, "agent": "test", "run_id": "eval-v1:full:p1"}
retriever.search(_req(context=ctx))
retriever.search(_req(context={**ctx, "subgoal_id": "s2"}))
run_files = list(Path(retriever.trace_dir).glob("eval-v1*.jsonl"))
assert len(run_files) == 1
events = run_files[0].read_text(encoding="utf-8").splitlines()
assert len(events) == 2
assert not list(Path(retriever.trace_dir).glob("p1:*.jsonl")) # nada quedó en el formato viejo (por-búsqueda)