Skip to content

Commit b019a5f

Browse files
committed
refactor(core): P1-7 提取为独立 retrieval_evaluation 模块,P1 不依赖 #181 文件
- 恢复 cerebellum_optimizer.py / events.py 为 #181 版本(P1 提交不修改 #181 新文件) - 新增 core/loop/retrieval_evaluation.py: split_held_out_qa / evaluate_retrieval / cerebellum 适配器 (自包含 _import_cerebellum) - test_retrieval_evaluation.py 改引 retrieval_evaluation
1 parent 3d6475c commit b019a5f

4 files changed

Lines changed: 266 additions & 232 deletions

File tree

core/loop/cerebellum_optimizer.py

Lines changed: 0 additions & 209 deletions
Original file line numberDiff line numberDiff line change
@@ -109,211 +109,6 @@ def __call__(self, candidate: OptimizerCandidate) -> float | None:
109109
return self.current_score()
110110

111111

112-
# ---------------------------------------------------------------------------
113-
# P1-7 (GenAI lesson 15): heterogeneous held-out retrieval evaluation
114-
# ---------------------------------------------------------------------------
115-
#
116-
# Cerebellum's built-in ``benchmark_run`` builds its QA set from the *indexed
117-
# documents themselves* (query = content prefix, target = source_key) and
118-
# scores hits by exact ``source_key`` string equality. Lesson 15 names both as
119-
# weak-evaluation traps: a same-source eval set inflates scores (poor
120-
# generalization), and exact-string scoring has zero tolerance for paraphrase.
121-
# This evaluator fixes both on the DeepCode side:
122-
#
123-
# * **Held-out QA set** — queries come from sources *excluded* from the
124-
# indexed store (:func:`split_held_out_qa`), so recall measures
125-
# generalization, not self-consistency.
126-
# * **Semantic scoring** — a hit matches when the *content* embedding is
127-
# similar to the gold answer (default threshold), never by string equality.
128-
# Without an embedder it degrades to exact-substring matching and reports
129-
# ``weak=True`` so nobody mistakes it for a semantic score.
130-
131-
132-
def _cosine_similarity(a: list[float] | None, b: list[float] | None) -> float:
133-
if not a or not b or len(a) != len(b):
134-
return 0.0
135-
import math
136-
137-
dot = sum(x * y for x, y in zip(a, b))
138-
na = math.sqrt(sum(x * x for x in a))
139-
nb = math.sqrt(sum(y * y for y in b))
140-
if not na or not nb:
141-
return 0.0
142-
return dot / (na * nb)
143-
144-
145-
def split_held_out_qa(
146-
entries: list[dict[str, Any]],
147-
hold_out_sources: set[str],
148-
*,
149-
query_chars: int = 60,
150-
) -> tuple[list[dict[str, Any]], set[str]]:
151-
"""Split scored/indexable entries into a held-out QA set + indexed sources.
152-
153-
``entries`` are ``{"content", "source", ...}`` rows. Every entry whose
154-
``source`` is in ``hold_out_sources`` becomes an evaluation question
155-
(query = content prefix, gold = full content); those sources must NOT be
156-
present in the store the evaluator searches, or the eval is contaminated
157-
(lesson 15: same-source eval inflates scores). Returns
158-
``(qa_set, indexed_sources)`` where ``indexed_sources`` = the sources that
159-
stay in the index.
160-
"""
161-
qa: list[dict[str, Any]] = []
162-
indexed: set[str] = set()
163-
for entry in entries or []:
164-
if not isinstance(entry, dict):
165-
continue
166-
content = str(entry.get("content", "")).strip()
167-
source = str(entry.get("source", "") or "")
168-
if not content:
169-
continue
170-
if source in hold_out_sources:
171-
query = content[:query_chars] + ("…" if len(content) > query_chars else "")
172-
qa.append({"query": query, "gold": content, "source": source})
173-
else:
174-
indexed.add(source)
175-
return qa, indexed
176-
177-
178-
def evaluate_retrieval(
179-
qa_set: list[dict[str, Any]],
180-
*,
181-
search_fn: Any,
182-
embed_fn: Any | None = None,
183-
top_k: int = 5,
184-
similarity_threshold: float = 0.45,
185-
) -> dict[str, Any]:
186-
"""Held-out retrieval evaluation with semantic scoring (P1-7).
187-
188-
Parameters
189-
----------
190-
qa_set:
191-
``[{"query", "gold", ...}]`` — queries heterogeneously sourced from
192-
documents NOT in the searched index.
193-
search_fn:
194-
``(query, limit) -> [{"content", ...}]`` — the retrieval channel
195-
(e.g. cerebellum ``memory_search`` semantic_hits adapter).
196-
embed_fn:
197-
``(text) -> list[float] | None`` — semantic embedder. When None,
198-
scoring degrades to exact-substring matching and the result carries
199-
``weak=True`` (an explicit warning, not a silent downgrade).
200-
similarity_threshold:
201-
Minimum content-embedding cosine for a hit to count as the gold.
202-
203-
Returns metrics ``{queries, recall@1, recall@k, mrr, weak, per_query}`` —
204-
same shape family as cerebellum's ``benchmark_run`` so callers can compare.
205-
"""
206-
results: dict[str, Any] = {
207-
"queries": len(qa_set),
208-
"top_k": top_k,
209-
"recall@1": 0.0,
210-
f"recall@{top_k}": 0.0,
211-
"mrr": 0.0,
212-
"weak": embed_fn is None,
213-
"per_query": [],
214-
}
215-
if not qa_set:
216-
return results
217-
218-
gold_vectors: list[list[float] | None] = []
219-
if embed_fn is not None:
220-
for item in qa_set:
221-
try:
222-
gold_vectors.append(embed_fn(str(item.get("gold", ""))))
223-
except Exception: # noqa: BLE001 - a bad embed must not kill the eval
224-
gold_vectors.append(None)
225-
226-
hits = 0
227-
hits_at_1 = 0
228-
mrr_sum = 0.0
229-
for index, item in enumerate(qa_set):
230-
query = str(item.get("query", ""))
231-
gold = str(item.get("gold", ""))
232-
try:
233-
retrieved = search_fn(query, top_k) or []
234-
except Exception: # noqa: BLE001 - retrieval failure counts as a miss
235-
retrieved = []
236-
rank = 0
237-
for position, hit in enumerate(retrieved, start=1):
238-
content = str((hit or {}).get("content", "")).strip()
239-
if not content:
240-
continue
241-
if embed_fn is not None:
242-
try:
243-
sim = _cosine_similarity(
244-
gold_vectors[index], embed_fn(content)
245-
)
246-
except Exception: # noqa: BLE001
247-
sim = 0.0
248-
if sim >= similarity_threshold:
249-
rank = position
250-
break
251-
elif gold and gold in content:
252-
rank = position
253-
break
254-
if rank:
255-
hits += 1
256-
if rank == 1:
257-
hits_at_1 += 1
258-
mrr_sum += 1.0 / rank
259-
results["per_query"].append({"query": query, "rank": rank})
260-
261-
n = len(qa_set)
262-
results["recall@1"] = round(hits_at_1 / n, 3)
263-
results[f"recall@{top_k}"] = round(hits / n, 3)
264-
results["mrr"] = round(mrr_sum / n, 3)
265-
return results
266-
267-
268-
def cerebellum_search_adapter(
269-
db_path: str | Path | None = None,
270-
) -> Any:
271-
"""Adapter: cerebellum ``memory_search`` semantic_hits → search_fn contract.
272-
273-
Returns ``(query, limit) -> [{"content", "similarity", ...}]`` (the raw
274-
semantic hits), or an always-empty callable when cerebellum is missing —
275-
evaluation must never crash on a missing component.
276-
"""
277-
278-
def _search(query: str, limit: int) -> list[dict[str, Any]]:
279-
try:
280-
mod = _import_cerebellum()
281-
mem = mod.CerebellumMemory(db_path or mod.DEFAULT_DB)
282-
result = mem.search(query, limit=limit)
283-
return result.get("semantic_hits", []) or []
284-
except Exception: # noqa: BLE001 - evaluation must never crash
285-
logger.debug("cerebellum search adapter failed", exc_info=True)
286-
return []
287-
288-
return _search
289-
290-
291-
def cerebellum_embed_adapter(
292-
db_path: str | Path | None = None,
293-
) -> Any | None:
294-
"""Adapter: cerebellum ``ollama_embed`` → embed_fn contract, or None.
295-
296-
``None`` means no embedder is available (cerebellum missing/unimportable);
297-
callers should then treat the evaluation as ``weak=True`` rather than
298-
fabricating a semantic score. A returned callable that yields None per
299-
call means the embedder is present but failed that call.
300-
"""
301-
try:
302-
_import_cerebellum()
303-
except Exception: # noqa: BLE001 - missing cerebellum is a soft condition
304-
return None
305-
306-
def _embed(text: str) -> list[float] | None:
307-
try:
308-
mod = _import_cerebellum()
309-
vectors = mod.ollama_embed([text])
310-
return vectors[0] if vectors else None
311-
except Exception: # noqa: BLE001
312-
return None
313-
314-
return _embed
315-
316-
317112
# ---------------------------------------------------------------------------
318113
# Skill optimizer: proposals → candidates → apply → benchmark → accept/rollback
319114
# ---------------------------------------------------------------------------
@@ -511,8 +306,4 @@ def run_once(self, *, min_delta: float = 0.0) -> list[SkillOptimizationOutcome]:
511306
"CerebellumBenchmarkEvaluator",
512307
"CerebellumSkillOptimizer",
513308
"SkillOptimizationOutcome",
514-
"cerebellum_embed_adapter",
515-
"cerebellum_search_adapter",
516-
"evaluate_retrieval",
517-
"split_held_out_qa",
518309
]

0 commit comments

Comments
 (0)