Skip to content

Commit 73f6a52

Browse files
week_3: Module C C.1 — candidate retriever (in-memory + pgvector) + pipeline switch
The semantic search step. For sections with no explicit CRE id, embed the text and cosine-rank the CRE vector hub, returning the top-K (default 20) candidates as an RFC RetrievalAudit (reranked[] empty until W4). Two interchangeable backends behind one retrieve() seam, selected by CRE_LIBRARIAN_RETRIEVER_BACKEND: - CandidateRetriever: in-memory sklearn cosine (SQLite/CI/harness) - PgVectorRetriever: Postgres-side <=> cosine over embedding_vec build_retriever() is the factory. Reuses OpenCRE's embedding stack (PromptHandler.get_text_embeddings, db.get_embeddings_by_doc_type). Dim gate fails loudly on empty/ragged hubs and query/hub width mismatch. CLI switch (--run_librarian / --librarian_dry_run) runs the pipeline dry-run; harness --use_live_embeddings measures retrieval recall@k. The pgvector schema migration is a W8 deliverable (validated against real Postgres there); W3 ships only the code path, unit-tested via a fake connection. 82 librarian tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b4a6aa2 commit 73f6a52

8 files changed

Lines changed: 857 additions & 0 deletions

File tree

.env.example

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,20 @@ VERTEX_EMBED_CONTENT_MODEL=gemini-embedding-001
4545
# Spreadsheet Auth
4646

4747
OpenCRE_gspread_Auth=path/to/credentials.json
48+
49+
# Module C — The Librarian (defaults match the OIE design doc; see
50+
# application/utils/librarian/config_loader.py for validation)
51+
52+
# Retrieval backend: in_memory (sklearn cosine; SQLite dev/CI/harness) or
53+
# pgvector (Postgres vector column — pending prod extension + mentor OK).
54+
CRE_LIBRARIAN_RETRIEVER_BACKEND=in_memory
55+
# Candidate shortlist size produced by C.1 and reranked by C.2 (W4).
56+
CRE_LIBRARIAN_TOP_K_RETRIEVAL=20
57+
CRE_LIBRARIAN_TOP_K_RERANK=5
58+
# Cross-encoder reranker model (W4), pinned.
59+
CRE_LIBRARIAN_CROSSENCODER_MODEL=cross-encoder/ms-marco-MiniLM-L-6-v2
60+
# Auto-link confidence threshold; below this -> human review (W5).
61+
CRE_LIBRARIAN_LINK_THRESHOLD=0.8
62+
CRE_LIBRARIAN_BATCH_SIZE=32
63+
CRE_LIBRARIAN_ECE_TARGET=0.10
64+
CRE_LIBRARIAN_CONFORMAL_ALPHA=0.10

application/cmd/cre_main.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -962,6 +962,12 @@ def run(args: argparse.Namespace) -> None: # pragma: no cover
962962
)
963963
if args.upstream_sync:
964964
download_graph_from_upstream(args.cache_file)
965+
if args.run_librarian or args.librarian_dry_run:
966+
run_librarian(
967+
cache_file=args.cache_file,
968+
dry_run=args.librarian_dry_run or not args.run_librarian,
969+
source_jsonl=args.librarian_source,
970+
)
965971

966972

967973
def ai_client_init(database: db.Node_collection):
@@ -1008,6 +1014,114 @@ def generate_embeddings(db_url: str) -> None:
10081014
prompt_client.PromptHandler(database, load_all_embeddings=True)
10091015

10101016

1017+
_DEFAULT_LIBRARIAN_SOURCE = os.path.join(
1018+
os.path.dirname(os.path.dirname(__file__)),
1019+
"tests",
1020+
"librarian",
1021+
"fixtures",
1022+
"sample_knowledge_queue.jsonl",
1023+
)
1024+
1025+
1026+
def run_librarian(
1027+
cache_file: str, dry_run: bool = True, source_jsonl: Optional[str] = None
1028+
) -> None:
1029+
"""Module C entrypoint — the pipeline switch (W3).
1030+
1031+
For each knowledge-queue section: try the deterministic explicit-CRE fast
1032+
path (C.0.5); on no/ambiguous reference, run the semantic retriever (C.1)
1033+
and log the top-K candidate shortlist. The cross-encoder rerank (C.2, W4),
1034+
decision/threshold routing (C.3-C.4, W5) and graph writes (W8) are not
1035+
built yet, so this is dry-run only: it never writes a link. ``--run_librarian``
1036+
without writes behaves identically and warns.
1037+
"""
1038+
from application.utils.librarian.candidate_retriever import (
1039+
CandidatePool,
1040+
RetrieverBackend,
1041+
build_retriever,
1042+
)
1043+
from application.utils.librarian.config_loader import load_config
1044+
from application.utils.librarian.explicit_link_resolver import (
1045+
ResolutionOutcome,
1046+
resolve,
1047+
)
1048+
from application.utils.librarian.knowledge_source import FixtureKnowledgeSource
1049+
from application.utils.librarian.section_validator import (
1050+
SectionValidationError,
1051+
section_from_queue_row,
1052+
)
1053+
1054+
if not dry_run:
1055+
logger.warning(
1056+
"the Librarian cannot write links yet (DecisionEngine + graph write "
1057+
"land W8); running in dry-run mode"
1058+
)
1059+
1060+
cfg = load_config()
1061+
database = db_connect(path=cache_file)
1062+
ph = prompt_client.PromptHandler(database=database)
1063+
1064+
backend = RetrieverBackend(cfg.retriever_backend)
1065+
# The CRE ids present in the hub are exactly the known ids the explicit
1066+
# resolver may auto-link to (W2 seeded this from the golden set; here it is
1067+
# the real DB-backed registry).
1068+
cre_embeddings = database.get_embeddings_by_doc_type(defs.Credoctypes.CRE.value)
1069+
known_ids = set(cre_embeddings.keys())
1070+
# in_memory loads the hub matrix; pgvector ranks in the DB over the
1071+
# embedding_vec column (no in-RAM pool). Both honor the same retrieve().
1072+
pool = (
1073+
CandidatePool.from_mapping(cre_embeddings)
1074+
if backend is RetrieverBackend.in_memory
1075+
else None
1076+
)
1077+
retriever = build_retriever(
1078+
backend,
1079+
embed_fn=ph.get_text_embeddings,
1080+
top_k=cfg.top_k_retrieval,
1081+
threshold=cfg.link_threshold,
1082+
pool=pool,
1083+
connection=database.session.connection(),
1084+
)
1085+
1086+
source = FixtureKnowledgeSource(source_jsonl or _DEFAULT_LIBRARIAN_SOURCE)
1087+
sections = explicit = semantic = rejected = 0
1088+
for item in source.items():
1089+
try:
1090+
section = section_from_queue_row(item)
1091+
except SectionValidationError as exc:
1092+
rejected += 1
1093+
logger.warning("section rejected at C.0 boundary: %s", exc)
1094+
continue
1095+
sections += 1
1096+
1097+
resolution = resolve(section.text, known_ids)
1098+
if resolution.outcome == ResolutionOutcome.resolved:
1099+
explicit += 1
1100+
logger.info("[explicit] %s -> %s", section.chunk_id, resolution.cre_ids[0])
1101+
continue
1102+
1103+
semantic += 1
1104+
audit = retriever.retrieve(section.text)
1105+
top = ", ".join(
1106+
f"{c.cre_id}:{c.score_vector:.3f}" for c in audit.candidates[:5]
1107+
)
1108+
logger.info(
1109+
"[semantic] %s -> %d candidates (top5: %s)",
1110+
section.chunk_id,
1111+
len(audit.candidates),
1112+
top or "<none>",
1113+
)
1114+
1115+
logger.info(
1116+
"librarian dry-run complete: %d sections (%d explicit, %d semantic), "
1117+
"%d rejected at boundary",
1118+
sections,
1119+
explicit,
1120+
semantic,
1121+
rejected,
1122+
)
1123+
1124+
10111125
def regenerate_embeddings(db_url: str) -> None:
10121126
"""Wipe all embedding rows, then rebuild (CRE + every node type) like ``--generate_embeddings``."""
10131127
database = db_connect(path=db_url)
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
"""Tests for the C.1 semantic candidate retriever (Week 3).
2+
3+
Hermetic: the embedding function and the CRE vector hub are injected as
4+
controlled vectors, so cosine ordering, top-K truncation, the dim gate, and
5+
the RetrievalAudit shape are all assertable without an LLM or a DB.
6+
"""
7+
8+
import unittest
9+
10+
from application.utils.librarian.candidate_retriever import (
11+
PGVECTOR_RETRIEVER_NAME,
12+
RETRIEVER_NAME,
13+
CandidatePool,
14+
CandidateRetriever,
15+
DimensionMismatchError,
16+
EmptyPoolError,
17+
PgVectorRetriever,
18+
RetrieverBackend,
19+
RetrieverError,
20+
build_retriever,
21+
to_pgvector_literal,
22+
)
23+
24+
# A controlled hub. Query [1,0,0] -> cosine a=1.0, c=0.707, b=0.0 -> order a,c,b.
25+
HUB = {
26+
"111-111": [1.0, 0.0, 0.0], # "a"
27+
"222-222": [0.0, 1.0, 0.0], # "b"
28+
"333-333": [1.0, 1.0, 0.0], # "c"
29+
}
30+
31+
# Deterministic embedder: text -> a fixed query vector.
32+
_VECTORS = {
33+
"about-a": [1.0, 0.0, 0.0],
34+
"about-b": [0.0, 1.0, 0.0],
35+
"wrong-width": [1.0, 0.0],
36+
}
37+
38+
39+
def fake_embed(text):
40+
return _VECTORS[text]
41+
42+
43+
def make_retriever(top_k=20, threshold=0.8, cre_names=None):
44+
return CandidateRetriever(
45+
embed_fn=fake_embed,
46+
pool=CandidatePool.from_mapping(HUB),
47+
top_k=top_k,
48+
threshold=threshold,
49+
cre_names=cre_names,
50+
)
51+
52+
53+
class CandidatePoolTest(unittest.TestCase):
54+
def test_from_mapping_builds_aligned_matrix(self) -> None:
55+
pool = CandidatePool.from_mapping(HUB)
56+
self.assertEqual(pool.dim, 3)
57+
self.assertEqual(set(pool.cre_ids), set(HUB))
58+
self.assertEqual(pool.matrix.shape, (3, 3))
59+
60+
def test_empty_pool_rejected(self) -> None:
61+
with self.assertRaises(EmptyPoolError):
62+
CandidatePool.from_mapping({})
63+
64+
def test_ragged_vectors_rejected(self) -> None:
65+
with self.assertRaises(DimensionMismatchError):
66+
CandidatePool.from_mapping({"a": [1.0, 0.0], "b": [1.0]})
67+
68+
def test_zero_width_vectors_rejected(self) -> None:
69+
with self.assertRaises(DimensionMismatchError):
70+
CandidatePool.from_mapping({"a": [], "b": []})
71+
72+
73+
class RetrieveTest(unittest.TestCase):
74+
def test_candidates_rank_ordered_by_cosine(self) -> None:
75+
audit = make_retriever().retrieve("about-a")
76+
self.assertEqual(
77+
[c.cre_id for c in audit.candidates],
78+
["111-111", "333-333", "222-222"],
79+
)
80+
# score_vector is the cosine; populated and descending.
81+
scores = [c.score_vector for c in audit.candidates]
82+
self.assertAlmostEqual(scores[0], 1.0)
83+
self.assertEqual(scores, sorted(scores, reverse=True))
84+
85+
def test_top_k_truncates_and_caps_at_pool_size(self) -> None:
86+
# top_k larger than the hub returns the whole hub, no error.
87+
self.assertEqual(
88+
len(make_retriever(top_k=20).retrieve("about-a").candidates), 3
89+
)
90+
# top_k smaller than the hub returns exactly the best k.
91+
top1 = make_retriever(top_k=1).retrieve("about-a")
92+
self.assertEqual([c.cre_id for c in top1.candidates], ["111-111"])
93+
94+
def test_audit_shape_for_w3(self) -> None:
95+
audit = make_retriever(threshold=0.8).retrieve("about-b")
96+
self.assertEqual(audit.retriever, RETRIEVER_NAME)
97+
self.assertEqual(audit.reranked, []) # cross-encoder lands W4
98+
self.assertEqual(audit.threshold, 0.8)
99+
# The closest to [0,1,0] is b, then c (0.707), then a (0.0).
100+
self.assertEqual(audit.candidates[0].cre_id, "222-222")
101+
102+
def test_cre_names_populated_when_provided(self) -> None:
103+
audit = make_retriever(cre_names={"111-111": "Authentication"}).retrieve(
104+
"about-a"
105+
)
106+
self.assertEqual(audit.candidates[0].cre_name, "Authentication")
107+
# Unnamed candidates stay None, never KeyError.
108+
self.assertIsNone(audit.candidates[-1].cre_name)
109+
110+
def test_query_dim_mismatch_is_caught(self) -> None:
111+
with self.assertRaises(DimensionMismatchError):
112+
make_retriever().retrieve("wrong-width")
113+
114+
115+
class ConstructionTest(unittest.TestCase):
116+
def test_non_positive_top_k_rejected(self) -> None:
117+
with self.assertRaises(RetrieverError):
118+
make_retriever(top_k=0)
119+
120+
121+
# --- pgvector backend (hermetic: the DB is a fake recording connection) ---
122+
123+
124+
class _FakeRow:
125+
def __init__(self, cre_id, score):
126+
self.cre_id = cre_id
127+
self.score = score
128+
129+
130+
class _FakeResult:
131+
def __init__(self, rows):
132+
self._rows = rows
133+
134+
def fetchall(self):
135+
return self._rows
136+
137+
138+
class _FakeConnection:
139+
"""Records the last execute() call and returns canned, pre-ordered rows."""
140+
141+
def __init__(self, rows):
142+
self._rows = rows
143+
self.last_sql = None
144+
self.last_params = None
145+
146+
def execute(self, sql, params):
147+
self.last_sql = str(sql)
148+
self.last_params = params
149+
return _FakeResult(self._rows)
150+
151+
152+
class PgVectorRetrieverTest(unittest.TestCase):
153+
def test_pgvector_literal_format(self) -> None:
154+
self.assertEqual(to_pgvector_literal([1, 2.5, 0]), "[1.0,2.5,0.0]")
155+
156+
def test_parses_db_rows_in_order(self) -> None:
157+
# The DB does the ranking; the retriever preserves ORDER BY order.
158+
conn = _FakeConnection([_FakeRow("111-111", 0.97), _FakeRow("333-333", 0.71)])
159+
retriever = PgVectorRetriever(
160+
embed_fn=fake_embed, connection=conn, top_k=20, threshold=0.8
161+
)
162+
audit = retriever.retrieve("about-a")
163+
self.assertEqual(audit.retriever, PGVECTOR_RETRIEVER_NAME)
164+
self.assertEqual([c.cre_id for c in audit.candidates], ["111-111", "333-333"])
165+
self.assertAlmostEqual(audit.candidates[0].score_vector, 0.97)
166+
self.assertEqual(audit.reranked, [])
167+
168+
def test_binds_query_vector_doctype_and_limit(self) -> None:
169+
conn = _FakeConnection([])
170+
PgVectorRetriever(
171+
embed_fn=fake_embed, connection=conn, top_k=7, threshold=0.8
172+
).retrieve("about-a")
173+
self.assertEqual(conn.last_params["q"], "[1.0,0.0,0.0]")
174+
self.assertEqual(conn.last_params["doc_type"], "CRE")
175+
self.assertEqual(conn.last_params["k"], 7)
176+
# Cosine via the <=> operator, scored as similarity (1 - distance).
177+
self.assertIn("<=>", conn.last_sql)
178+
179+
180+
class BuildRetrieverTest(unittest.TestCase):
181+
def test_in_memory_requires_pool(self) -> None:
182+
with self.assertRaises(RetrieverError):
183+
build_retriever(
184+
RetrieverBackend.in_memory, fake_embed, top_k=20, threshold=0.8
185+
)
186+
187+
def test_pgvector_requires_connection(self) -> None:
188+
with self.assertRaises(RetrieverError):
189+
build_retriever(
190+
RetrieverBackend.pgvector, fake_embed, top_k=20, threshold=0.8
191+
)
192+
193+
def test_factory_routes_to_each_backend(self) -> None:
194+
in_mem = build_retriever(
195+
RetrieverBackend.in_memory,
196+
fake_embed,
197+
top_k=20,
198+
threshold=0.8,
199+
pool=CandidatePool.from_mapping(HUB),
200+
)
201+
self.assertIsInstance(in_mem, CandidateRetriever)
202+
pg = build_retriever(
203+
RetrieverBackend.pgvector,
204+
fake_embed,
205+
top_k=20,
206+
threshold=0.8,
207+
connection=_FakeConnection([]),
208+
)
209+
self.assertIsInstance(pg, PgVectorRetriever)
210+
211+
212+
if __name__ == "__main__":
213+
unittest.main()

0 commit comments

Comments
 (0)