secondbrain-py 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- brain/__init__.py +0 -0
- brain/__main__.py +18 -0
- brain/_capture_command.py +445 -0
- brain/_compose.py +52 -0
- brain/activity.py +206 -0
- brain/ask.py +631 -0
- brain/audio.py +591 -0
- brain/backfill/__init__.py +12 -0
- brain/backfill/search_extras.py +141 -0
- brain/backfill/source_rows.py +101 -0
- brain/bin/__init__.py +1 -0
- brain/bin/_launcher.py +107 -0
- brain/bin/down.py +8 -0
- brain/bin/launchd.py +268 -0
- brain/bin/monitor.py +570 -0
- brain/bin/rebuild.py +8 -0
- brain/bin/status.py +8 -0
- brain/bin/up.py +8 -0
- brain/brief.py +272 -0
- brain/capture.py +49 -0
- brain/chat.py +293 -0
- brain/cli.py +9760 -0
- brain/cli_claude.py +81 -0
- brain/cli_connect.py +285 -0
- brain/cli_demo.py +266 -0
- brain/config.py +1949 -0
- brain/connect.py +925 -0
- brain/db.py +540 -0
- brain/demo/__init__.py +452 -0
- brain/demo/corpus/manifest.json +403 -0
- brain/demo/embedder.py +74 -0
- brain/durations.py +84 -0
- brain/edit_session.py +156 -0
- brain/editor.py +67 -0
- brain/elicit/__init__.py +16 -0
- brain/elicit/detectors.py +250 -0
- brain/elicit/drafter.py +70 -0
- brain/elicit/queue.py +220 -0
- brain/elicit/schema.py +48 -0
- brain/elicit/session.py +445 -0
- brain/embedding_targets.py +54 -0
- brain/embeddings.py +424 -0
- brain/enrichment.py +808 -0
- brain/errors.py +357 -0
- brain/eval/__init__.py +129 -0
- brain/eval/answer_eval.py +281 -0
- brain/eval/baseline.py +265 -0
- brain/eval/concept_extraction.py +378 -0
- brain/eval/corpus.py +152 -0
- brain/eval/errors.py +19 -0
- brain/eval/graph_baseline.py +226 -0
- brain/eval/graph_retrieval.py +202 -0
- brain/eval/graph_runner.py +319 -0
- brain/eval/metrics.py +101 -0
- brain/eval/runner.py +223 -0
- brain/format.py +783 -0
- brain/gaps.py +390 -0
- brain/graph_rag/__init__.py +94 -0
- brain/graph_rag/_retrieval_common.py +113 -0
- brain/graph_rag/aggregates.py +303 -0
- brain/graph_rag/aliases/__init__.py +583 -0
- brain/graph_rag/backends/__init__.py +10 -0
- brain/graph_rag/backends/_age_helpers.py +473 -0
- brain/graph_rag/backends/age.py +782 -0
- brain/graph_rag/backends/base.py +272 -0
- brain/graph_rag/build.py +344 -0
- brain/graph_rag/communities.py +644 -0
- brain/graph_rag/communities_summary.py +437 -0
- brain/graph_rag/concepts.py +202 -0
- brain/graph_rag/cooccur.py +193 -0
- brain/graph_rag/cross_type.py +312 -0
- brain/graph_rag/extract.py +885 -0
- brain/graph_rag/fuse.py +371 -0
- brain/graph_rag/global_.py +412 -0
- brain/graph_rag/grouping.py +372 -0
- brain/graph_rag/person_resolver.py +167 -0
- brain/graph_rag/reconcile.py +792 -0
- brain/graph_rag/relational.py +353 -0
- brain/graph_rag/retrieve.py +526 -0
- brain/graph_rag/router.py +288 -0
- brain/graph_rag/schema.py +320 -0
- brain/graph_rag/sync.py +237 -0
- brain/graph_rag/tenancy.py +43 -0
- brain/graph_rag/themes.py +501 -0
- brain/graph_rag/weighting.py +202 -0
- brain/ingest/__init__.py +1926 -0
- brain/ingest/chunker.py +249 -0
- brain/ingest/docx.py +40 -0
- brain/ingest/gmail.py +621 -0
- brain/ingest/markdown.py +37 -0
- brain/ingest/pdf.py +61 -0
- brain/ingest/stdin.py +22 -0
- brain/ingest/sub_tokens.py +91 -0
- brain/ingest/text.py +16 -0
- brain/interactions.py +205 -0
- brain/maintenance.py +355 -0
- brain/mcp_server.py +3405 -0
- brain/migrations/001_init.sql +43 -0
- brain/migrations/002_qwen3_embedding.sql +17 -0
- brain/migrations/003_vault_model.sql +41 -0
- brain/migrations/004_relax_content_hash_uniqueness.sql +18 -0
- brain/migrations/005_derived_links.sql +67 -0
- brain/migrations/006_dedup_file_by_source_path.sql +25 -0
- brain/migrations/007_email_thread_and_draft.sql +15 -0
- brain/migrations/008_gmail_thread_unique.sql +11 -0
- brain/migrations/009_chunks_weighted_tsv.sql +28 -0
- brain/migrations/010_interactions.sql +30 -0
- brain/migrations/011_documents_summary.sql +23 -0
- brain/migrations/012_graphrag.sql +171 -0
- brain/migrations/013_graphrag_communities.sql +125 -0
- brain/migrations/014_graphrag_community_summary_hash.sql +33 -0
- brain/migrations/015_interactions_graph_targets.sql +89 -0
- brain/migrations/016_index_hygiene.sql +61 -0
- brain/migrations/017_elicit.sql +30 -0
- brain/migrations/018_review_gap_signal_kinds.sql +40 -0
- brain/migrations/019_search_queries.sql +35 -0
- brain/migrations/020_link_suggestions.sql +40 -0
- brain/migrations/021_timeline_doc_date.sql +34 -0
- brain/migrations/022_link_suggestions_undirected.sql +84 -0
- brain/migrations/023_search_queries_fts_count.sql +28 -0
- brain/quartz_overrides/__init__.py +8 -0
- brain/quartz_overrides/quartz/bootstrap-cli.mjs +65 -0
- brain/quartz_overrides/quartz/build.ts +568 -0
- brain/quartz_overrides/quartz/cli/args.js +152 -0
- brain/quartz_overrides/quartz/cli/build_partial_handler.js +544 -0
- brain/quartz_overrides/quartz/cli/handlers.js +636 -0
- brain/quartz_overrides/quartz/components/CommandPalette.tsx +172 -0
- brain/quartz_overrides/quartz/components/Explorer.tsx +198 -0
- brain/quartz_overrides/quartz/components/Footer.tsx +27 -0
- brain/quartz_overrides/quartz/components/Graph.tsx +468 -0
- brain/quartz_overrides/quartz/components/PageTitle.tsx +72 -0
- brain/quartz_overrides/quartz/components/RelatedDocs.tsx +38 -0
- brain/quartz_overrides/quartz/components/Search.tsx +161 -0
- brain/quartz_overrides/quartz/components/SummaryLede.tsx +72 -0
- brain/quartz_overrides/quartz/components/index.ts +92 -0
- brain/quartz_overrides/quartz/components/pages/TagContent.tsx +272 -0
- brain/quartz_overrides/quartz/components/scripts/commandPalette.inline.ts +665 -0
- brain/quartz_overrides/quartz/components/scripts/explorer.inline.ts +768 -0
- brain/quartz_overrides/quartz/components/scripts/graph.inline.ts +2302 -0
- brain/quartz_overrides/quartz/components/scripts/relatedDocs.inline.ts +163 -0
- brain/quartz_overrides/quartz/components/scripts/search.inline.ts +1011 -0
- brain/quartz_overrides/quartz/plugins/emitters/contentIndex.ts +546 -0
- brain/quartz_overrides/quartz/plugins/transformers/codeCopy.ts +94 -0
- brain/quartz_overrides/quartz/plugins/transformers/derivedFenceMark.ts +302 -0
- brain/quartz_overrides/quartz/plugins/transformers/emailThread.ts +148 -0
- brain/quartz_overrides/quartz/plugins/transformers/emptyDoorFilter.ts +213 -0
- brain/quartz_overrides/quartz/plugins/transformers/index.ts +114 -0
- brain/quartz_overrides/quartz/plugins/transformers/linkKindMark.ts +205 -0
- brain/quartz_overrides/quartz/plugins/transformers/linkSourceTag.ts +104 -0
- brain/quartz_overrides/quartz/plugins/transformers/relativeDate.ts +100 -0
- brain/quartz_overrides/quartz/plugins/transformers/reloadSignal.ts +131 -0
- brain/quartz_overrides/quartz/processors/parse.ts +371 -0
- brain/quartz_overrides/quartz/processors/parser_cache.ts +78 -0
- brain/quartz_overrides/quartz/static/brain-logo-dark.png +0 -0
- brain/quartz_overrides/quartz/static/brain-logo-light.png +0 -0
- brain/quartz_overrides/quartz/static/codeCopy.js +196 -0
- brain/quartz_overrides/quartz/static/emailThread.js +334 -0
- brain/quartz_overrides/quartz/static/favicon.ico +0 -0
- brain/quartz_overrides/quartz/static/icon.png +0 -0
- brain/quartz_overrides/quartz/static/linkSourceTag.js +104 -0
- brain/quartz_overrides/quartz/static/relativeDate.js +142 -0
- brain/quartz_overrides/quartz/static/reload.js +168 -0
- brain/quartz_overrides/quartz/styles/brain/_article.scss +252 -0
- brain/quartz_overrides/quartz/styles/brain/_atmosphere.scss +113 -0
- brain/quartz_overrides/quartz/styles/brain/_callouts.scss +180 -0
- brain/quartz_overrides/quartz/styles/brain/_cmdk.scss +7 -0
- brain/quartz_overrides/quartz/styles/brain/_code.scss +208 -0
- brain/quartz_overrides/quartz/styles/brain/_command_palette.scss +369 -0
- brain/quartz_overrides/quartz/styles/brain/_email_thread.scss +228 -0
- brain/quartz_overrides/quartz/styles/brain/_explorer.scss +142 -0
- brain/quartz_overrides/quartz/styles/brain/_home.scss +182 -0
- brain/quartz_overrides/quartz/styles/brain/_links.scss +322 -0
- brain/quartz_overrides/quartz/styles/brain/_marginalia.scss +117 -0
- brain/quartz_overrides/quartz/styles/brain/_motion.scss +175 -0
- brain/quartz_overrides/quartz/styles/brain/_people_hub.scss +100 -0
- brain/quartz_overrides/quartz/styles/brain/_related_docs.scss +137 -0
- brain/quartz_overrides/quartz/styles/brain/_search.scss +252 -0
- brain/quartz_overrides/quartz/styles/brain/_sidebar.scss +468 -0
- brain/quartz_overrides/quartz/styles/brain/_summary_lede.scss +56 -0
- brain/quartz_overrides/quartz/styles/brain/_surface.scss +43 -0
- brain/quartz_overrides/quartz/styles/brain/_tag_content.scss +118 -0
- brain/quartz_overrides/quartz/styles/brain/_tokens.scss +197 -0
- brain/quartz_overrides/quartz/styles/brain/_typography.scss +92 -0
- brain/quartz_overrides/quartz/styles/custom.scss +89 -0
- brain/quartz_overrides/quartz/styles/graph.scss +505 -0
- brain/quartz_overrides/quartz/util/ctx.ts +92 -0
- brain/quartz_overrides/quartz/util/fastpath_manifest.ts +608 -0
- brain/quartz_overrides/quartz/util/path.ts +358 -0
- brain/quartz_overrides/quartz/util/sourceIcons.ts +55 -0
- brain/quartz_overrides/quartz.config.ts +270 -0
- brain/quartz_overrides/quartz.layout.ts +314 -0
- brain/queries.py +1188 -0
- brain/rank_fusion.py +8 -0
- brain/resurface.py +210 -0
- brain/review/__init__.py +26 -0
- brain/review/emit.py +27 -0
- brain/review/queries.py +436 -0
- brain/review/render.py +196 -0
- brain/review/scans.py +355 -0
- brain/review/weekly.py +413 -0
- brain/search.py +704 -0
- brain/set_similarity.py +15 -0
- brain/setup.py +1205 -0
- brain/tags.py +56 -0
- brain/templates/Caddyfile.j2 +9 -0
- brain/templates/__init__.py +1 -0
- brain/templates/bin/__init__.py +1 -0
- brain/templates/bin/_brain-brief-fg.sh +25 -0
- brain/templates/bin/_brain-build-fg.sh +53 -0
- brain/templates/bin/_brain-watcher-fg.sh +65 -0
- brain/templates/bin/brain-down.sh +89 -0
- brain/templates/bin/brain-status.sh +83 -0
- brain/templates/bin/brain-up.sh +221 -0
- brain/templates/docker/age/Dockerfile +79 -0
- brain/templates/docker-compose.stock.yml.j2 +26 -0
- brain/templates/docker-compose.yml.j2 +34 -0
- brain/templates/env.example +190 -0
- brain/templates/launchd/__init__.py +1 -0
- brain/templates/launchd/com.brain.brief.plist.j2 +45 -0
- brain/templates/launchd/com.brain.build.plist.j2 +46 -0
- brain/templates/launchd/com.brain.watcher.plist.j2 +46 -0
- brain/templates/skill/SKILL.md +63 -0
- brain/templates/skill/__init__.py +1 -0
- brain/timeline.py +834 -0
- brain/todo.py +124 -0
- brain/uninstall.py +185 -0
- brain/vault/__init__.py +115 -0
- brain/vault/_atomic.py +25 -0
- brain/vault/daily_index.py +228 -0
- brain/vault/derived_links/__init__.py +50 -0
- brain/vault/derived_links/directory.py +683 -0
- brain/vault/derived_links/fence.py +408 -0
- brain/vault/derived_links/gws.py +64 -0
- brain/vault/derived_links/participants.py +143 -0
- brain/vault/derived_links/pass_runner.py +362 -0
- brain/vault/derived_links/rules.py +137 -0
- brain/vault/export.py +683 -0
- brain/vault/frontmatter.py +165 -0
- brain/vault/graph.py +620 -0
- brain/vault/graph_format.py +388 -0
- brain/vault/link_rewrite.py +235 -0
- brain/vault/links.py +260 -0
- brain/vault/note_builder.py +211 -0
- brain/vault/paths.py +55 -0
- brain/vault/quartz_overlay.py +236 -0
- brain/vault/rename.py +591 -0
- brain/vault/resolver.py +304 -0
- brain/vault/slug.py +127 -0
- brain/vault/sync.py +1513 -0
- brain/vault/sync_summaries.py +264 -0
- brain/vault/templates.py +145 -0
- brain/vault/watch.py +1052 -0
- brain/wiki/__init__.py +6 -0
- brain/wiki/_github_slugger.py +76 -0
- brain/wiki/_person_name.py +314 -0
- brain/wiki/build_homepage.py +541 -0
- brain/wiki/build_partial.py +273 -0
- brain/wiki/build_people.py +934 -0
- brain/wiki/build_related.py +758 -0
- brain/wiki/build_swap.py +585 -0
- brain/wiki/build_watcher.py +975 -0
- brain/wiki/edit_classifier.py +215 -0
- brain/wiki/errors.py +10 -0
- brain/wiki/fastpath_manifest.py +475 -0
- brain/wiki/fastpath_state.py +174 -0
- brain/wiki/install.py +296 -0
- brain/wiki/slug.py +111 -0
- secondbrain_py-0.2.1.dist-info/METADATA +195 -0
- secondbrain_py-0.2.1.dist-info/RECORD +273 -0
- secondbrain_py-0.2.1.dist-info/WHEEL +5 -0
- secondbrain_py-0.2.1.dist-info/entry_points.txt +11 -0
- secondbrain_py-0.2.1.dist-info/licenses/LICENSE +21 -0
- secondbrain_py-0.2.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Parallel graph-retrieval eval runner (wave G4-d; spec §17d Q3).
|
|
2
|
+
|
|
3
|
+
A SEPARATE runner + report from the hybrid :func:`brain.eval.runner.run_eval`:
|
|
4
|
+
the graph paths report two different metric shapes — local/fuse return a *ranked
|
|
5
|
+
document list* (nDCG@k / MRR / Recall@k) while themes-with-X returns *entity
|
|
6
|
+
clusters* (set precision / recall / F1) — and neither set fits
|
|
7
|
+
:class:`~brain.eval.runner.EvalReport`'s nDCG@5/MRR/Recall@20-only model. So G4
|
|
8
|
+
adds this parallel runner + :class:`GraphEvalReport` + its own baseline path
|
|
9
|
+
(:mod:`brain.eval.graph_baseline`) rather than a new ``_VALID_CATEGORIES`` entry
|
|
10
|
+
on the hybrid runner (spec §17d Q3).
|
|
11
|
+
|
|
12
|
+
Golden corpus = the existing G2-j synthetic fixture
|
|
13
|
+
(``tests/eval/graph_retrieval_cases.py``) — *reused, not recommitted*. The cases
|
|
14
|
+
are **injected** (this module lives in ``src`` and must never import from
|
|
15
|
+
``tests``): the synthetic-graph integration test builds the graph on the AGE test
|
|
16
|
+
DB, then passes its ``LOCAL_CASES`` / ``THEMES_CASES`` + the
|
|
17
|
+
external-id→document-id mapping in. Local-/fuse-doc scoring reuses
|
|
18
|
+
:func:`brain.eval.graph_retrieval.score_local_docs`; themes scoring reuses
|
|
19
|
+
:func:`~brain.eval.graph_retrieval.score_themes` — no new metric is invented.
|
|
20
|
+
|
|
21
|
+
No CLI surface and no committed baseline (spec §17d Q3): the ``brain eval`` CLI
|
|
22
|
+
runs the hybrid golden corpus against a live brain, but the graph cases are
|
|
23
|
+
synthetic-corpus-specific (they expect a graph the CLI cannot build on prod), so
|
|
24
|
+
a ``brain eval --graph`` flag does not fit and is intentionally omitted (YAGNI).
|
|
25
|
+
The blocking thresholds live in the synthetic-graph integration test + the
|
|
26
|
+
``-m benchmark`` gate, NOT a committed ``ci.json`` + ``--fail-below`` (that
|
|
27
|
+
flag+baseline precedent belongs to a separate roadmap and does not exist here).
|
|
28
|
+
The record/diff baseline (:mod:`brain.eval.graph_baseline`) is a **canary** that
|
|
29
|
+
round-trips in tests.
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from datetime import UTC, datetime
|
|
36
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
37
|
+
|
|
38
|
+
import psycopg
|
|
39
|
+
|
|
40
|
+
from ..graph_rag import FUSE_MODE, LOCAL_MODE, THEMES_MODE, graph_rag_search
|
|
41
|
+
from .graph_retrieval import score_local_docs, score_themes
|
|
42
|
+
|
|
43
|
+
if TYPE_CHECKING:
|
|
44
|
+
from ..config import Config
|
|
45
|
+
from ..graph_rag.backends.base import GraphBackend
|
|
46
|
+
from ..ingest import Embedder
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class LocalCaseLike(Protocol):
|
|
50
|
+
"""Structural shape of a local/fuse graph-eval case (G2-j ``GraphLocalCase``).
|
|
51
|
+
|
|
52
|
+
Read-only properties so any frozen dataclass with these attributes (the
|
|
53
|
+
committed ``tests/eval/graph_retrieval_cases.GraphLocalCase``) satisfies it
|
|
54
|
+
without this ``src`` module importing the ``tests`` fixture.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def query(self) -> str: ...
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def expected_doc_external_ids(self) -> tuple[str, ...]: ...
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ThemesCaseLike(Protocol):
|
|
65
|
+
"""Structural shape of a themes graph-eval case (G2-j ``GraphThemesCase``)."""
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def person(self) -> str: ...
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def expected_theme_keysets(self) -> tuple[frozenset[str], ...]: ...
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class GraphDocEvalResult:
|
|
76
|
+
"""Ranked-doc metrics for one local- or fuse-mode graph query.
|
|
77
|
+
|
|
78
|
+
``mode`` is :data:`~brain.graph_rag.LOCAL_MODE` or
|
|
79
|
+
:data:`~brain.graph_rag.FUSE_MODE` — both return ``GraphContext.docs`` (a
|
|
80
|
+
ranked :class:`~brain.search.SearchResult` list), so both are scored with the
|
|
81
|
+
reused ranking metrics via :func:`brain.eval.graph_retrieval.score_local_docs`.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
mode: str
|
|
85
|
+
query: str
|
|
86
|
+
expected_doc_ids: list[str]
|
|
87
|
+
actual_doc_ids: list[str]
|
|
88
|
+
ndcg_at_k: float
|
|
89
|
+
mrr: float
|
|
90
|
+
recall_at_k: float
|
|
91
|
+
ndcg_k: int
|
|
92
|
+
recall_k: int
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class GraphThemesEvalResult:
|
|
97
|
+
"""Theme-set precision / recall / F1 for one themes-with-X graph query.
|
|
98
|
+
|
|
99
|
+
Scored by greedy best-Jaccard cluster matching via
|
|
100
|
+
:func:`brain.eval.graph_retrieval.score_themes`. Keysets are stored as sorted
|
|
101
|
+
``list[list[str]]`` (not ``set``/``frozenset``) so the report is
|
|
102
|
+
JSON-serializable + byte-stable for baseline diffs.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
person: str
|
|
106
|
+
expected_theme_keysets: list[list[str]]
|
|
107
|
+
actual_theme_keysets: list[list[str]]
|
|
108
|
+
precision: float
|
|
109
|
+
recall: float
|
|
110
|
+
f1: float
|
|
111
|
+
matched: int
|
|
112
|
+
n_expected: int
|
|
113
|
+
n_actual: int
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class GraphEvalReport:
|
|
118
|
+
"""Full graph-eval run: per-case results + per-mode aggregate means.
|
|
119
|
+
|
|
120
|
+
``doc_results`` holds the local + (optional) fuse ranked-doc results;
|
|
121
|
+
``themes_results`` the themes cluster-set results. The aggregate means are
|
|
122
|
+
split by mode (local vs fuse vs themes) because the metric families differ.
|
|
123
|
+
``config_signature`` captures the caps + flags so a baseline diff can flag a
|
|
124
|
+
config change (mirroring :class:`brain.eval.runner.EvalReport`).
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
doc_results: list[GraphDocEvalResult]
|
|
128
|
+
themes_results: list[GraphThemesEvalResult]
|
|
129
|
+
mean_local_ndcg_at_k: float
|
|
130
|
+
mean_local_mrr: float
|
|
131
|
+
mean_local_recall_at_k: float
|
|
132
|
+
mean_fuse_ndcg_at_k: float
|
|
133
|
+
mean_fuse_mrr: float
|
|
134
|
+
mean_fuse_recall_at_k: float
|
|
135
|
+
mean_themes_precision: float
|
|
136
|
+
mean_themes_recall: float
|
|
137
|
+
mean_themes_f1: float
|
|
138
|
+
config_signature: dict[str, Any]
|
|
139
|
+
generated_at: datetime
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _mean(values: Iterable[float]) -> float:
|
|
143
|
+
"""Arithmetic mean; 0.0 for an empty sequence (mirrors ``run_eval``)."""
|
|
144
|
+
vals = list(values)
|
|
145
|
+
return sum(vals) / len(vals) if vals else 0.0
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def run_graph_eval(
|
|
149
|
+
conn: psycopg.Connection[Any],
|
|
150
|
+
cfg: Config,
|
|
151
|
+
*,
|
|
152
|
+
backend: GraphBackend,
|
|
153
|
+
local_cases: Sequence[LocalCaseLike],
|
|
154
|
+
themes_cases: Sequence[ThemesCaseLike],
|
|
155
|
+
external_id_to_doc_id: Mapping[str, str],
|
|
156
|
+
embedder: Embedder | None = None,
|
|
157
|
+
include_fuse: bool = False,
|
|
158
|
+
ndcg_k: int = 5,
|
|
159
|
+
recall_k: int = 20,
|
|
160
|
+
backend_name: str = "unknown",
|
|
161
|
+
) -> GraphEvalReport:
|
|
162
|
+
"""Run the graph-retrieval eval over ``local_cases`` + ``themes_cases``.
|
|
163
|
+
|
|
164
|
+
Drives :func:`brain.graph_rag.graph_rag_search` once per case per mode and
|
|
165
|
+
scores each result with the reused G2-j scorers, mirroring ``run_eval``'s
|
|
166
|
+
conventions (one search call per case, aggregate means, config signature)
|
|
167
|
+
while staying a SEPARATE runner (spec §17d Q3).
|
|
168
|
+
|
|
169
|
+
* **local** — ``mode='local'`` per local case; ``GraphContext.docs`` scored
|
|
170
|
+
with :func:`~brain.eval.graph_retrieval.score_local_docs`.
|
|
171
|
+
* **fuse** (when ``include_fuse``) — ``mode='fuse'`` per local case (same
|
|
172
|
+
query + expected docs as local; spec §17d Q1 fuse is a ranked-doc mode);
|
|
173
|
+
the hybrid leg's vector arm is fed by the pre-warmed ``embedder``
|
|
174
|
+
instance (FTS-only when absent — never-raise; perf-T4 G5).
|
|
175
|
+
* **themes** — ``mode='themes'`` per themes case; ``GraphContext.themes``
|
|
176
|
+
keysets scored with :func:`~brain.eval.graph_retrieval.score_themes`.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
conn: Live psycopg connection to the AGE test DB (or any built graph).
|
|
180
|
+
cfg: Config carrying the graph caps + ``owner_participants`` (themes
|
|
181
|
+
owner exclusion); a single cfg serves all modes (local/fuse ignore
|
|
182
|
+
``owner_participants``).
|
|
183
|
+
backend: The :class:`~brain.graph_rag.backends.base.GraphBackend`.
|
|
184
|
+
local_cases: Local/fuse cases (injected G2-j ``LOCAL_CASES``).
|
|
185
|
+
themes_cases: Themes cases (injected G2-j ``THEMES_CASES``).
|
|
186
|
+
external_id_to_doc_id: Maps each case's ``expected_doc_external_ids`` to
|
|
187
|
+
the seeded document UUIDs (the corpus builder returns this).
|
|
188
|
+
embedder: Pre-warmed Embedder instance feeding the fuse hybrid leg's
|
|
189
|
+
vector arm (perf-T4 G5); ``None`` runs the fuse hybrid leg FTS-only.
|
|
190
|
+
include_fuse: When ``True``, also run + score ``mode='fuse'`` per local
|
|
191
|
+
case.
|
|
192
|
+
ndcg_k: nDCG cutoff (default 5).
|
|
193
|
+
recall_k: Recall cutoff (default 20).
|
|
194
|
+
backend_name: Recorded in ``config_signature`` for baseline diffs.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
A frozen :class:`GraphEvalReport`.
|
|
198
|
+
"""
|
|
199
|
+
doc_results: list[GraphDocEvalResult] = []
|
|
200
|
+
themes_results: list[GraphThemesEvalResult] = []
|
|
201
|
+
|
|
202
|
+
for case in local_cases:
|
|
203
|
+
expected = [external_id_to_doc_id[ext] for ext in case.expected_doc_external_ids]
|
|
204
|
+
doc_results.append(
|
|
205
|
+
_score_doc_mode(
|
|
206
|
+
conn,
|
|
207
|
+
cfg,
|
|
208
|
+
case.query,
|
|
209
|
+
backend=backend,
|
|
210
|
+
mode=LOCAL_MODE,
|
|
211
|
+
expected=expected,
|
|
212
|
+
ndcg_k=ndcg_k,
|
|
213
|
+
recall_k=recall_k,
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
if include_fuse:
|
|
217
|
+
doc_results.append(
|
|
218
|
+
_score_doc_mode(
|
|
219
|
+
conn,
|
|
220
|
+
cfg,
|
|
221
|
+
case.query,
|
|
222
|
+
backend=backend,
|
|
223
|
+
mode=FUSE_MODE,
|
|
224
|
+
expected=expected,
|
|
225
|
+
ndcg_k=ndcg_k,
|
|
226
|
+
recall_k=recall_k,
|
|
227
|
+
embedder=embedder,
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
for tcase in themes_cases:
|
|
232
|
+
ctx = graph_rag_search(
|
|
233
|
+
conn, cfg, "", backend=backend, mode=THEMES_MODE, person=tcase.person
|
|
234
|
+
)
|
|
235
|
+
actual_keysets = [
|
|
236
|
+
sorted({entity.canonical_key for entity in theme.entities})
|
|
237
|
+
for theme in ctx.themes
|
|
238
|
+
]
|
|
239
|
+
expected_keysets = [sorted(ks) for ks in tcase.expected_theme_keysets]
|
|
240
|
+
score = score_themes(actual_keysets, expected_keysets)
|
|
241
|
+
themes_results.append(
|
|
242
|
+
GraphThemesEvalResult(
|
|
243
|
+
person=tcase.person,
|
|
244
|
+
expected_theme_keysets=expected_keysets,
|
|
245
|
+
actual_theme_keysets=actual_keysets,
|
|
246
|
+
precision=score.precision,
|
|
247
|
+
recall=score.recall,
|
|
248
|
+
f1=score.f1,
|
|
249
|
+
matched=score.matched,
|
|
250
|
+
n_expected=score.n_expected,
|
|
251
|
+
n_actual=score.n_actual,
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
local = [r for r in doc_results if r.mode == LOCAL_MODE]
|
|
256
|
+
fuse = [r for r in doc_results if r.mode == FUSE_MODE]
|
|
257
|
+
|
|
258
|
+
config_signature: dict[str, Any] = {
|
|
259
|
+
"graph_depth": cfg.graph_depth,
|
|
260
|
+
"graph_frontier_cap": cfg.graph_frontier_cap,
|
|
261
|
+
"graph_min_edge_weight": cfg.graph_min_edge_weight,
|
|
262
|
+
"graph_theme_limit": cfg.graph_theme_limit,
|
|
263
|
+
"backend": backend_name,
|
|
264
|
+
"include_fuse": include_fuse,
|
|
265
|
+
"ndcg_k": ndcg_k,
|
|
266
|
+
"recall_k": recall_k,
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return GraphEvalReport(
|
|
270
|
+
doc_results=doc_results,
|
|
271
|
+
themes_results=themes_results,
|
|
272
|
+
mean_local_ndcg_at_k=_mean(r.ndcg_at_k for r in local),
|
|
273
|
+
mean_local_mrr=_mean(r.mrr for r in local),
|
|
274
|
+
mean_local_recall_at_k=_mean(r.recall_at_k for r in local),
|
|
275
|
+
mean_fuse_ndcg_at_k=_mean(r.ndcg_at_k for r in fuse),
|
|
276
|
+
mean_fuse_mrr=_mean(r.mrr for r in fuse),
|
|
277
|
+
mean_fuse_recall_at_k=_mean(r.recall_at_k for r in fuse),
|
|
278
|
+
mean_themes_precision=_mean(r.precision for r in themes_results),
|
|
279
|
+
mean_themes_recall=_mean(r.recall for r in themes_results),
|
|
280
|
+
mean_themes_f1=_mean(r.f1 for r in themes_results),
|
|
281
|
+
config_signature=config_signature,
|
|
282
|
+
generated_at=datetime.now(tz=UTC),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _score_doc_mode(
|
|
287
|
+
conn: psycopg.Connection[Any],
|
|
288
|
+
cfg: Config,
|
|
289
|
+
query: str,
|
|
290
|
+
*,
|
|
291
|
+
backend: GraphBackend,
|
|
292
|
+
mode: str,
|
|
293
|
+
expected: list[str],
|
|
294
|
+
ndcg_k: int,
|
|
295
|
+
recall_k: int,
|
|
296
|
+
embedder: Embedder | None = None,
|
|
297
|
+
) -> GraphDocEvalResult:
|
|
298
|
+
"""Run one ranked-doc mode (local/fuse) for ``query`` and score its docs."""
|
|
299
|
+
ctx = graph_rag_search(
|
|
300
|
+
conn,
|
|
301
|
+
cfg,
|
|
302
|
+
query,
|
|
303
|
+
backend=backend,
|
|
304
|
+
mode=mode,
|
|
305
|
+
embedder=embedder,
|
|
306
|
+
)
|
|
307
|
+
actual = [doc.document_id for doc in ctx.docs]
|
|
308
|
+
score = score_local_docs(actual, expected, ndcg_k=ndcg_k, recall_k=recall_k)
|
|
309
|
+
return GraphDocEvalResult(
|
|
310
|
+
mode=mode,
|
|
311
|
+
query=query,
|
|
312
|
+
expected_doc_ids=expected,
|
|
313
|
+
actual_doc_ids=actual,
|
|
314
|
+
ndcg_at_k=score.ndcg_at_k,
|
|
315
|
+
mrr=score.mrr,
|
|
316
|
+
recall_at_k=score.recall_at_k,
|
|
317
|
+
ndcg_k=ndcg_k,
|
|
318
|
+
recall_k=recall_k,
|
|
319
|
+
)
|
brain/eval/metrics.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Pure metric functions for ranking evaluation: nDCG@k, MRR, recall@k.
|
|
2
|
+
|
|
3
|
+
No I/O. No DB. Fully unit-testable with synthetic data.
|
|
4
|
+
All functions accept ``actual`` (ranked list of doc IDs, 1-indexed by position)
|
|
5
|
+
and ``expected`` (collection of relevant doc IDs). Duplicates in ``actual`` are
|
|
6
|
+
deduplicated, keeping the first occurrence.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
from collections.abc import Iterable, Sequence
|
|
11
|
+
|
|
12
|
+
from .errors import EvalMetricError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _dedup_preserving_order(seq: Sequence[str]) -> list[str]:
|
|
16
|
+
"""Return seq with duplicates removed, preserving first-occurrence order."""
|
|
17
|
+
seen: set[str] = set()
|
|
18
|
+
result: list[str] = []
|
|
19
|
+
for item in seq:
|
|
20
|
+
if item not in seen:
|
|
21
|
+
seen.add(item)
|
|
22
|
+
result.append(item)
|
|
23
|
+
return result
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def dcg_at_k(actual: Sequence[str], expected: Iterable[str], k: int = 5) -> float:
|
|
27
|
+
"""Discounted Cumulative Gain at k (binary relevance).
|
|
28
|
+
|
|
29
|
+
``dcg = sum(1 / log2(rank + 1) for rank, doc in enumerate(actual[:k], 1)
|
|
30
|
+
if doc in expected_set)``
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
EvalMetricError: When ``expected`` is empty.
|
|
34
|
+
"""
|
|
35
|
+
expected_set = set(expected)
|
|
36
|
+
if not expected_set:
|
|
37
|
+
raise EvalMetricError(
|
|
38
|
+
"expected must not be empty; use corpus validation to prevent this"
|
|
39
|
+
)
|
|
40
|
+
dcg = 0.0
|
|
41
|
+
for rank, doc in enumerate(actual[:k], start=1):
|
|
42
|
+
if doc in expected_set:
|
|
43
|
+
dcg += 1.0 / math.log2(rank + 1)
|
|
44
|
+
return dcg
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def ndcg_at_k(actual: Sequence[str], expected: Iterable[str], k: int = 5) -> float:
|
|
48
|
+
"""Normalized DCG at k (binary relevance).
|
|
49
|
+
|
|
50
|
+
Returns 0.0 when ``actual`` is empty. IDCG is computed from
|
|
51
|
+
``min(k, len(expected))`` ideal hits in perfect rank order.
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
EvalMetricError: When ``expected`` is empty.
|
|
55
|
+
"""
|
|
56
|
+
expected_set = set(expected)
|
|
57
|
+
if not expected_set:
|
|
58
|
+
raise EvalMetricError("expected must not be empty")
|
|
59
|
+
if not actual:
|
|
60
|
+
return 0.0
|
|
61
|
+
actual_deduped = _dedup_preserving_order(actual)
|
|
62
|
+
# Ideal DCG: top-n_relevant docs retrieved in positions 1..n_relevant.
|
|
63
|
+
n_relevant = min(k, len(expected_set))
|
|
64
|
+
idcg = sum(1.0 / math.log2(rank + 1) for rank in range(1, n_relevant + 1))
|
|
65
|
+
if idcg == 0.0:
|
|
66
|
+
return 0.0
|
|
67
|
+
return dcg_at_k(actual_deduped, expected_set, k=k) / idcg
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def mrr(actual: Sequence[str], expected: Iterable[str]) -> float:
|
|
71
|
+
"""Mean Reciprocal Rank: 1 / rank_of_first_relevant, or 0.0 if none found.
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
EvalMetricError: When ``expected`` is empty.
|
|
75
|
+
"""
|
|
76
|
+
expected_set = set(expected)
|
|
77
|
+
if not expected_set:
|
|
78
|
+
raise EvalMetricError("expected must not be empty")
|
|
79
|
+
actual_deduped = _dedup_preserving_order(actual)
|
|
80
|
+
for rank, doc in enumerate(actual_deduped, start=1):
|
|
81
|
+
if doc in expected_set:
|
|
82
|
+
return 1.0 / rank
|
|
83
|
+
return 0.0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def recall_at_k(actual: Sequence[str], expected: Iterable[str], k: int = 20) -> float:
|
|
87
|
+
"""Recall at k: |relevant ∩ actual[:k]| / |expected|.
|
|
88
|
+
|
|
89
|
+
Returns 0.0 when ``actual`` is empty.
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
EvalMetricError: When ``expected`` is empty.
|
|
93
|
+
"""
|
|
94
|
+
expected_set = set(expected)
|
|
95
|
+
if not expected_set:
|
|
96
|
+
raise EvalMetricError("expected must not be empty")
|
|
97
|
+
if not actual:
|
|
98
|
+
return 0.0
|
|
99
|
+
actual_deduped = _dedup_preserving_order(actual)
|
|
100
|
+
hits = sum(1 for doc in actual_deduped[:k] if doc in expected_set)
|
|
101
|
+
return hits / len(expected_set)
|
brain/eval/runner.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Eval runner: EvalResult / EvalReport dataclasses and the run_eval() function.
|
|
2
|
+
|
|
3
|
+
The dataclasses are defined here and re-exported from ``brain.eval``.
|
|
4
|
+
``run_eval()`` wraps ``hybrid_search`` once per query and scores three
|
|
5
|
+
metrics (nDCG@5, MRR, recall@20) against the golden corpus.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import psycopg
|
|
15
|
+
|
|
16
|
+
from ..embeddings import OllamaEmbedError
|
|
17
|
+
from ..ingest import Embedder
|
|
18
|
+
from ..search import hybrid_search
|
|
19
|
+
from .corpus import EvalQuery
|
|
20
|
+
from .errors import EvalCorpusError
|
|
21
|
+
from .metrics import (
|
|
22
|
+
mrr as _mrr,
|
|
23
|
+
)
|
|
24
|
+
from .metrics import (
|
|
25
|
+
ndcg_at_k as _ndcg_at_k,
|
|
26
|
+
)
|
|
27
|
+
from .metrics import (
|
|
28
|
+
recall_at_k as _recall_at_k,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
_logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class EvalResult:
|
|
36
|
+
"""Scores for a single eval query."""
|
|
37
|
+
|
|
38
|
+
query: str
|
|
39
|
+
category: str
|
|
40
|
+
expected_doc_ids: list[str] # canonicalized to full UUIDs
|
|
41
|
+
actual_doc_ids: list[str] # full UUIDs from hybrid_search, in rank order
|
|
42
|
+
ndcg_at_5: float
|
|
43
|
+
mrr: float
|
|
44
|
+
recall_at_20: float
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class CategorySummary:
|
|
49
|
+
"""Aggregate eval metrics for one category."""
|
|
50
|
+
|
|
51
|
+
category: str
|
|
52
|
+
count: int
|
|
53
|
+
mean_ndcg_at_5: float
|
|
54
|
+
mean_mrr: float
|
|
55
|
+
mean_recall_at_20: float
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class EvalReport:
|
|
60
|
+
"""Full eval run report: per-query results + aggregates + config snapshot."""
|
|
61
|
+
|
|
62
|
+
results: list[EvalResult] # one per EvalQuery, in input order
|
|
63
|
+
mean_ndcg_at_5: float
|
|
64
|
+
mean_mrr: float
|
|
65
|
+
mean_recall_at_20: float
|
|
66
|
+
per_category: dict[str, CategorySummary]
|
|
67
|
+
config_signature: dict[str, Any] # {"recency_halflife_days": ..., "embedder": ..., ...}
|
|
68
|
+
generated_at: datetime # UTC
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _normalize_ids(conn: psycopg.Connection[Any], ids: list[str]) -> list[str]:
|
|
72
|
+
"""Resolve 8-char hex prefixes to full UUIDs; pass full UUIDs through.
|
|
73
|
+
|
|
74
|
+
A full UUID is detected by length >= 32 characters OR presence of a
|
|
75
|
+
hyphen. Shorter strings are treated as hex prefixes and resolved via a
|
|
76
|
+
``LIKE`` query.
|
|
77
|
+
|
|
78
|
+
Silently drops IDs that match zero documents (treated as stale/uncurated).
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
EvalCorpusError: When a prefix matches two or more documents
|
|
82
|
+
(ambiguous prefix).
|
|
83
|
+
"""
|
|
84
|
+
result: list[str] = []
|
|
85
|
+
for doc_id in ids:
|
|
86
|
+
if len(doc_id) >= 32 or "-" in doc_id:
|
|
87
|
+
# Full UUID — pass through without a DB round-trip.
|
|
88
|
+
result.append(doc_id)
|
|
89
|
+
else:
|
|
90
|
+
pattern = f"{doc_id}%"
|
|
91
|
+
rows = conn.execute(
|
|
92
|
+
"SELECT id::text FROM documents WHERE id::text LIKE %s",
|
|
93
|
+
(pattern,),
|
|
94
|
+
).fetchall()
|
|
95
|
+
if len(rows) == 0:
|
|
96
|
+
_logger.debug("expected_doc_id prefix %r matched no documents; skipping", doc_id)
|
|
97
|
+
elif len(rows) > 1:
|
|
98
|
+
raise EvalCorpusError(
|
|
99
|
+
f"expected_doc_id prefix {doc_id!r} is ambiguous: "
|
|
100
|
+
f"matches {len(rows)} documents"
|
|
101
|
+
)
|
|
102
|
+
else:
|
|
103
|
+
result.append(str(rows[0][0]))
|
|
104
|
+
return result
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def run_eval(
|
|
108
|
+
conn: psycopg.Connection[Any],
|
|
109
|
+
*,
|
|
110
|
+
embedder: Embedder,
|
|
111
|
+
queries: Sequence[EvalQuery],
|
|
112
|
+
limit_per_query: int = 20,
|
|
113
|
+
recency_halflife_days: float | None = None,
|
|
114
|
+
snippet_context_tokens: int = 0,
|
|
115
|
+
vector_sim_floor: float = 0.0,
|
|
116
|
+
embedder_name: str = "unknown",
|
|
117
|
+
) -> EvalReport:
|
|
118
|
+
"""Run the eval harness over ``queries`` and return a scored :class:`EvalReport`.
|
|
119
|
+
|
|
120
|
+
One ``hybrid_search`` call per query at ``limit=limit_per_query`` (default 20
|
|
121
|
+
so recall@20 is computable). Threads filter kwargs from :class:`EvalQuery`
|
|
122
|
+
through to the search function.
|
|
123
|
+
|
|
124
|
+
Tolerates :exc:`~brain.embeddings.OllamaEmbedError` by skipping the affected
|
|
125
|
+
query with a warning and recording ``actual_doc_ids=[]`` / metrics=0.0.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
conn: Live psycopg connection (test_db or prod).
|
|
129
|
+
embedder: Embedding backend (any :class:`~brain.ingest.Embedder`).
|
|
130
|
+
queries: Sequence of :class:`EvalQuery` from the golden corpus.
|
|
131
|
+
limit_per_query: How many results to fetch per query (default 20).
|
|
132
|
+
recency_halflife_days: Passed to ``hybrid_search`` unchanged.
|
|
133
|
+
snippet_context_tokens: Passed to ``hybrid_search`` unchanged.
|
|
134
|
+
vector_sim_floor: Passed to ``hybrid_search`` unchanged.
|
|
135
|
+
embedder_name: Logged in ``config_signature`` for baseline diffs.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
A frozen :class:`EvalReport` with per-query and aggregate scores.
|
|
139
|
+
"""
|
|
140
|
+
results: list[EvalResult] = []
|
|
141
|
+
|
|
142
|
+
for q in queries:
|
|
143
|
+
try:
|
|
144
|
+
search_results = hybrid_search(
|
|
145
|
+
conn,
|
|
146
|
+
embedder=embedder,
|
|
147
|
+
query=q.query,
|
|
148
|
+
limit=limit_per_query,
|
|
149
|
+
source_kind=q.source_filter,
|
|
150
|
+
tag=q.tag_filter,
|
|
151
|
+
since_days=q.since_days,
|
|
152
|
+
vector_sim_floor=vector_sim_floor,
|
|
153
|
+
recency_halflife_days=recency_halflife_days,
|
|
154
|
+
snippet_context_tokens=snippet_context_tokens,
|
|
155
|
+
)
|
|
156
|
+
actual_ids = [r.document_id for r in search_results]
|
|
157
|
+
except OllamaEmbedError as exc:
|
|
158
|
+
_logger.warning(
|
|
159
|
+
"OllamaEmbedError for query %r — recording 0.0 metrics: %s",
|
|
160
|
+
q.query,
|
|
161
|
+
exc,
|
|
162
|
+
)
|
|
163
|
+
actual_ids = []
|
|
164
|
+
|
|
165
|
+
expected_ids = _normalize_ids(conn, list(q.expected_doc_ids))
|
|
166
|
+
|
|
167
|
+
if not expected_ids:
|
|
168
|
+
# All expected IDs failed to resolve (uncurated / stale corpus).
|
|
169
|
+
ndcg = mrr_score = recall = 0.0
|
|
170
|
+
else:
|
|
171
|
+
ndcg = _ndcg_at_k(actual_ids, expected_ids, k=5)
|
|
172
|
+
mrr_score = _mrr(actual_ids, expected_ids)
|
|
173
|
+
recall = _recall_at_k(actual_ids, expected_ids, k=limit_per_query)
|
|
174
|
+
|
|
175
|
+
results.append(
|
|
176
|
+
EvalResult(
|
|
177
|
+
query=q.query,
|
|
178
|
+
category=q.category,
|
|
179
|
+
expected_doc_ids=expected_ids,
|
|
180
|
+
actual_doc_ids=actual_ids,
|
|
181
|
+
ndcg_at_5=ndcg,
|
|
182
|
+
mrr=mrr_score,
|
|
183
|
+
recall_at_20=recall,
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# Aggregate means.
|
|
188
|
+
n = len(results)
|
|
189
|
+
mean_ndcg = sum(r.ndcg_at_5 for r in results) / n if n else 0.0
|
|
190
|
+
mean_mrr = sum(r.mrr for r in results) / n if n else 0.0
|
|
191
|
+
mean_recall = sum(r.recall_at_20 for r in results) / n if n else 0.0
|
|
192
|
+
|
|
193
|
+
# Per-category aggregates.
|
|
194
|
+
cat_buckets: dict[str, list[EvalResult]] = {}
|
|
195
|
+
for r in results:
|
|
196
|
+
cat_buckets.setdefault(r.category, []).append(r)
|
|
197
|
+
per_category: dict[str, CategorySummary] = {}
|
|
198
|
+
for cat, cat_results in cat_buckets.items():
|
|
199
|
+
nc = len(cat_results)
|
|
200
|
+
per_category[cat] = CategorySummary(
|
|
201
|
+
category=cat,
|
|
202
|
+
count=nc,
|
|
203
|
+
mean_ndcg_at_5=sum(r.ndcg_at_5 for r in cat_results) / nc,
|
|
204
|
+
mean_mrr=sum(r.mrr for r in cat_results) / nc,
|
|
205
|
+
mean_recall_at_20=sum(r.recall_at_20 for r in cat_results) / nc,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
config_signature: dict[str, Any] = {
|
|
209
|
+
"recency_halflife_days": recency_halflife_days,
|
|
210
|
+
"snippet_context_tokens": snippet_context_tokens,
|
|
211
|
+
"vector_sim_floor": vector_sim_floor,
|
|
212
|
+
"embedder": embedder_name,
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return EvalReport(
|
|
216
|
+
results=results,
|
|
217
|
+
mean_ndcg_at_5=mean_ndcg,
|
|
218
|
+
mean_mrr=mean_mrr,
|
|
219
|
+
mean_recall_at_20=mean_recall,
|
|
220
|
+
per_category=per_category,
|
|
221
|
+
config_signature=config_signature,
|
|
222
|
+
generated_at=datetime.now(tz=UTC),
|
|
223
|
+
)
|