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.
Files changed (273) hide show
  1. brain/__init__.py +0 -0
  2. brain/__main__.py +18 -0
  3. brain/_capture_command.py +445 -0
  4. brain/_compose.py +52 -0
  5. brain/activity.py +206 -0
  6. brain/ask.py +631 -0
  7. brain/audio.py +591 -0
  8. brain/backfill/__init__.py +12 -0
  9. brain/backfill/search_extras.py +141 -0
  10. brain/backfill/source_rows.py +101 -0
  11. brain/bin/__init__.py +1 -0
  12. brain/bin/_launcher.py +107 -0
  13. brain/bin/down.py +8 -0
  14. brain/bin/launchd.py +268 -0
  15. brain/bin/monitor.py +570 -0
  16. brain/bin/rebuild.py +8 -0
  17. brain/bin/status.py +8 -0
  18. brain/bin/up.py +8 -0
  19. brain/brief.py +272 -0
  20. brain/capture.py +49 -0
  21. brain/chat.py +293 -0
  22. brain/cli.py +9760 -0
  23. brain/cli_claude.py +81 -0
  24. brain/cli_connect.py +285 -0
  25. brain/cli_demo.py +266 -0
  26. brain/config.py +1949 -0
  27. brain/connect.py +925 -0
  28. brain/db.py +540 -0
  29. brain/demo/__init__.py +452 -0
  30. brain/demo/corpus/manifest.json +403 -0
  31. brain/demo/embedder.py +74 -0
  32. brain/durations.py +84 -0
  33. brain/edit_session.py +156 -0
  34. brain/editor.py +67 -0
  35. brain/elicit/__init__.py +16 -0
  36. brain/elicit/detectors.py +250 -0
  37. brain/elicit/drafter.py +70 -0
  38. brain/elicit/queue.py +220 -0
  39. brain/elicit/schema.py +48 -0
  40. brain/elicit/session.py +445 -0
  41. brain/embedding_targets.py +54 -0
  42. brain/embeddings.py +424 -0
  43. brain/enrichment.py +808 -0
  44. brain/errors.py +357 -0
  45. brain/eval/__init__.py +129 -0
  46. brain/eval/answer_eval.py +281 -0
  47. brain/eval/baseline.py +265 -0
  48. brain/eval/concept_extraction.py +378 -0
  49. brain/eval/corpus.py +152 -0
  50. brain/eval/errors.py +19 -0
  51. brain/eval/graph_baseline.py +226 -0
  52. brain/eval/graph_retrieval.py +202 -0
  53. brain/eval/graph_runner.py +319 -0
  54. brain/eval/metrics.py +101 -0
  55. brain/eval/runner.py +223 -0
  56. brain/format.py +783 -0
  57. brain/gaps.py +390 -0
  58. brain/graph_rag/__init__.py +94 -0
  59. brain/graph_rag/_retrieval_common.py +113 -0
  60. brain/graph_rag/aggregates.py +303 -0
  61. brain/graph_rag/aliases/__init__.py +583 -0
  62. brain/graph_rag/backends/__init__.py +10 -0
  63. brain/graph_rag/backends/_age_helpers.py +473 -0
  64. brain/graph_rag/backends/age.py +782 -0
  65. brain/graph_rag/backends/base.py +272 -0
  66. brain/graph_rag/build.py +344 -0
  67. brain/graph_rag/communities.py +644 -0
  68. brain/graph_rag/communities_summary.py +437 -0
  69. brain/graph_rag/concepts.py +202 -0
  70. brain/graph_rag/cooccur.py +193 -0
  71. brain/graph_rag/cross_type.py +312 -0
  72. brain/graph_rag/extract.py +885 -0
  73. brain/graph_rag/fuse.py +371 -0
  74. brain/graph_rag/global_.py +412 -0
  75. brain/graph_rag/grouping.py +372 -0
  76. brain/graph_rag/person_resolver.py +167 -0
  77. brain/graph_rag/reconcile.py +792 -0
  78. brain/graph_rag/relational.py +353 -0
  79. brain/graph_rag/retrieve.py +526 -0
  80. brain/graph_rag/router.py +288 -0
  81. brain/graph_rag/schema.py +320 -0
  82. brain/graph_rag/sync.py +237 -0
  83. brain/graph_rag/tenancy.py +43 -0
  84. brain/graph_rag/themes.py +501 -0
  85. brain/graph_rag/weighting.py +202 -0
  86. brain/ingest/__init__.py +1926 -0
  87. brain/ingest/chunker.py +249 -0
  88. brain/ingest/docx.py +40 -0
  89. brain/ingest/gmail.py +621 -0
  90. brain/ingest/markdown.py +37 -0
  91. brain/ingest/pdf.py +61 -0
  92. brain/ingest/stdin.py +22 -0
  93. brain/ingest/sub_tokens.py +91 -0
  94. brain/ingest/text.py +16 -0
  95. brain/interactions.py +205 -0
  96. brain/maintenance.py +355 -0
  97. brain/mcp_server.py +3405 -0
  98. brain/migrations/001_init.sql +43 -0
  99. brain/migrations/002_qwen3_embedding.sql +17 -0
  100. brain/migrations/003_vault_model.sql +41 -0
  101. brain/migrations/004_relax_content_hash_uniqueness.sql +18 -0
  102. brain/migrations/005_derived_links.sql +67 -0
  103. brain/migrations/006_dedup_file_by_source_path.sql +25 -0
  104. brain/migrations/007_email_thread_and_draft.sql +15 -0
  105. brain/migrations/008_gmail_thread_unique.sql +11 -0
  106. brain/migrations/009_chunks_weighted_tsv.sql +28 -0
  107. brain/migrations/010_interactions.sql +30 -0
  108. brain/migrations/011_documents_summary.sql +23 -0
  109. brain/migrations/012_graphrag.sql +171 -0
  110. brain/migrations/013_graphrag_communities.sql +125 -0
  111. brain/migrations/014_graphrag_community_summary_hash.sql +33 -0
  112. brain/migrations/015_interactions_graph_targets.sql +89 -0
  113. brain/migrations/016_index_hygiene.sql +61 -0
  114. brain/migrations/017_elicit.sql +30 -0
  115. brain/migrations/018_review_gap_signal_kinds.sql +40 -0
  116. brain/migrations/019_search_queries.sql +35 -0
  117. brain/migrations/020_link_suggestions.sql +40 -0
  118. brain/migrations/021_timeline_doc_date.sql +34 -0
  119. brain/migrations/022_link_suggestions_undirected.sql +84 -0
  120. brain/migrations/023_search_queries_fts_count.sql +28 -0
  121. brain/quartz_overrides/__init__.py +8 -0
  122. brain/quartz_overrides/quartz/bootstrap-cli.mjs +65 -0
  123. brain/quartz_overrides/quartz/build.ts +568 -0
  124. brain/quartz_overrides/quartz/cli/args.js +152 -0
  125. brain/quartz_overrides/quartz/cli/build_partial_handler.js +544 -0
  126. brain/quartz_overrides/quartz/cli/handlers.js +636 -0
  127. brain/quartz_overrides/quartz/components/CommandPalette.tsx +172 -0
  128. brain/quartz_overrides/quartz/components/Explorer.tsx +198 -0
  129. brain/quartz_overrides/quartz/components/Footer.tsx +27 -0
  130. brain/quartz_overrides/quartz/components/Graph.tsx +468 -0
  131. brain/quartz_overrides/quartz/components/PageTitle.tsx +72 -0
  132. brain/quartz_overrides/quartz/components/RelatedDocs.tsx +38 -0
  133. brain/quartz_overrides/quartz/components/Search.tsx +161 -0
  134. brain/quartz_overrides/quartz/components/SummaryLede.tsx +72 -0
  135. brain/quartz_overrides/quartz/components/index.ts +92 -0
  136. brain/quartz_overrides/quartz/components/pages/TagContent.tsx +272 -0
  137. brain/quartz_overrides/quartz/components/scripts/commandPalette.inline.ts +665 -0
  138. brain/quartz_overrides/quartz/components/scripts/explorer.inline.ts +768 -0
  139. brain/quartz_overrides/quartz/components/scripts/graph.inline.ts +2302 -0
  140. brain/quartz_overrides/quartz/components/scripts/relatedDocs.inline.ts +163 -0
  141. brain/quartz_overrides/quartz/components/scripts/search.inline.ts +1011 -0
  142. brain/quartz_overrides/quartz/plugins/emitters/contentIndex.ts +546 -0
  143. brain/quartz_overrides/quartz/plugins/transformers/codeCopy.ts +94 -0
  144. brain/quartz_overrides/quartz/plugins/transformers/derivedFenceMark.ts +302 -0
  145. brain/quartz_overrides/quartz/plugins/transformers/emailThread.ts +148 -0
  146. brain/quartz_overrides/quartz/plugins/transformers/emptyDoorFilter.ts +213 -0
  147. brain/quartz_overrides/quartz/plugins/transformers/index.ts +114 -0
  148. brain/quartz_overrides/quartz/plugins/transformers/linkKindMark.ts +205 -0
  149. brain/quartz_overrides/quartz/plugins/transformers/linkSourceTag.ts +104 -0
  150. brain/quartz_overrides/quartz/plugins/transformers/relativeDate.ts +100 -0
  151. brain/quartz_overrides/quartz/plugins/transformers/reloadSignal.ts +131 -0
  152. brain/quartz_overrides/quartz/processors/parse.ts +371 -0
  153. brain/quartz_overrides/quartz/processors/parser_cache.ts +78 -0
  154. brain/quartz_overrides/quartz/static/brain-logo-dark.png +0 -0
  155. brain/quartz_overrides/quartz/static/brain-logo-light.png +0 -0
  156. brain/quartz_overrides/quartz/static/codeCopy.js +196 -0
  157. brain/quartz_overrides/quartz/static/emailThread.js +334 -0
  158. brain/quartz_overrides/quartz/static/favicon.ico +0 -0
  159. brain/quartz_overrides/quartz/static/icon.png +0 -0
  160. brain/quartz_overrides/quartz/static/linkSourceTag.js +104 -0
  161. brain/quartz_overrides/quartz/static/relativeDate.js +142 -0
  162. brain/quartz_overrides/quartz/static/reload.js +168 -0
  163. brain/quartz_overrides/quartz/styles/brain/_article.scss +252 -0
  164. brain/quartz_overrides/quartz/styles/brain/_atmosphere.scss +113 -0
  165. brain/quartz_overrides/quartz/styles/brain/_callouts.scss +180 -0
  166. brain/quartz_overrides/quartz/styles/brain/_cmdk.scss +7 -0
  167. brain/quartz_overrides/quartz/styles/brain/_code.scss +208 -0
  168. brain/quartz_overrides/quartz/styles/brain/_command_palette.scss +369 -0
  169. brain/quartz_overrides/quartz/styles/brain/_email_thread.scss +228 -0
  170. brain/quartz_overrides/quartz/styles/brain/_explorer.scss +142 -0
  171. brain/quartz_overrides/quartz/styles/brain/_home.scss +182 -0
  172. brain/quartz_overrides/quartz/styles/brain/_links.scss +322 -0
  173. brain/quartz_overrides/quartz/styles/brain/_marginalia.scss +117 -0
  174. brain/quartz_overrides/quartz/styles/brain/_motion.scss +175 -0
  175. brain/quartz_overrides/quartz/styles/brain/_people_hub.scss +100 -0
  176. brain/quartz_overrides/quartz/styles/brain/_related_docs.scss +137 -0
  177. brain/quartz_overrides/quartz/styles/brain/_search.scss +252 -0
  178. brain/quartz_overrides/quartz/styles/brain/_sidebar.scss +468 -0
  179. brain/quartz_overrides/quartz/styles/brain/_summary_lede.scss +56 -0
  180. brain/quartz_overrides/quartz/styles/brain/_surface.scss +43 -0
  181. brain/quartz_overrides/quartz/styles/brain/_tag_content.scss +118 -0
  182. brain/quartz_overrides/quartz/styles/brain/_tokens.scss +197 -0
  183. brain/quartz_overrides/quartz/styles/brain/_typography.scss +92 -0
  184. brain/quartz_overrides/quartz/styles/custom.scss +89 -0
  185. brain/quartz_overrides/quartz/styles/graph.scss +505 -0
  186. brain/quartz_overrides/quartz/util/ctx.ts +92 -0
  187. brain/quartz_overrides/quartz/util/fastpath_manifest.ts +608 -0
  188. brain/quartz_overrides/quartz/util/path.ts +358 -0
  189. brain/quartz_overrides/quartz/util/sourceIcons.ts +55 -0
  190. brain/quartz_overrides/quartz.config.ts +270 -0
  191. brain/quartz_overrides/quartz.layout.ts +314 -0
  192. brain/queries.py +1188 -0
  193. brain/rank_fusion.py +8 -0
  194. brain/resurface.py +210 -0
  195. brain/review/__init__.py +26 -0
  196. brain/review/emit.py +27 -0
  197. brain/review/queries.py +436 -0
  198. brain/review/render.py +196 -0
  199. brain/review/scans.py +355 -0
  200. brain/review/weekly.py +413 -0
  201. brain/search.py +704 -0
  202. brain/set_similarity.py +15 -0
  203. brain/setup.py +1205 -0
  204. brain/tags.py +56 -0
  205. brain/templates/Caddyfile.j2 +9 -0
  206. brain/templates/__init__.py +1 -0
  207. brain/templates/bin/__init__.py +1 -0
  208. brain/templates/bin/_brain-brief-fg.sh +25 -0
  209. brain/templates/bin/_brain-build-fg.sh +53 -0
  210. brain/templates/bin/_brain-watcher-fg.sh +65 -0
  211. brain/templates/bin/brain-down.sh +89 -0
  212. brain/templates/bin/brain-status.sh +83 -0
  213. brain/templates/bin/brain-up.sh +221 -0
  214. brain/templates/docker/age/Dockerfile +79 -0
  215. brain/templates/docker-compose.stock.yml.j2 +26 -0
  216. brain/templates/docker-compose.yml.j2 +34 -0
  217. brain/templates/env.example +190 -0
  218. brain/templates/launchd/__init__.py +1 -0
  219. brain/templates/launchd/com.brain.brief.plist.j2 +45 -0
  220. brain/templates/launchd/com.brain.build.plist.j2 +46 -0
  221. brain/templates/launchd/com.brain.watcher.plist.j2 +46 -0
  222. brain/templates/skill/SKILL.md +63 -0
  223. brain/templates/skill/__init__.py +1 -0
  224. brain/timeline.py +834 -0
  225. brain/todo.py +124 -0
  226. brain/uninstall.py +185 -0
  227. brain/vault/__init__.py +115 -0
  228. brain/vault/_atomic.py +25 -0
  229. brain/vault/daily_index.py +228 -0
  230. brain/vault/derived_links/__init__.py +50 -0
  231. brain/vault/derived_links/directory.py +683 -0
  232. brain/vault/derived_links/fence.py +408 -0
  233. brain/vault/derived_links/gws.py +64 -0
  234. brain/vault/derived_links/participants.py +143 -0
  235. brain/vault/derived_links/pass_runner.py +362 -0
  236. brain/vault/derived_links/rules.py +137 -0
  237. brain/vault/export.py +683 -0
  238. brain/vault/frontmatter.py +165 -0
  239. brain/vault/graph.py +620 -0
  240. brain/vault/graph_format.py +388 -0
  241. brain/vault/link_rewrite.py +235 -0
  242. brain/vault/links.py +260 -0
  243. brain/vault/note_builder.py +211 -0
  244. brain/vault/paths.py +55 -0
  245. brain/vault/quartz_overlay.py +236 -0
  246. brain/vault/rename.py +591 -0
  247. brain/vault/resolver.py +304 -0
  248. brain/vault/slug.py +127 -0
  249. brain/vault/sync.py +1513 -0
  250. brain/vault/sync_summaries.py +264 -0
  251. brain/vault/templates.py +145 -0
  252. brain/vault/watch.py +1052 -0
  253. brain/wiki/__init__.py +6 -0
  254. brain/wiki/_github_slugger.py +76 -0
  255. brain/wiki/_person_name.py +314 -0
  256. brain/wiki/build_homepage.py +541 -0
  257. brain/wiki/build_partial.py +273 -0
  258. brain/wiki/build_people.py +934 -0
  259. brain/wiki/build_related.py +758 -0
  260. brain/wiki/build_swap.py +585 -0
  261. brain/wiki/build_watcher.py +975 -0
  262. brain/wiki/edit_classifier.py +215 -0
  263. brain/wiki/errors.py +10 -0
  264. brain/wiki/fastpath_manifest.py +475 -0
  265. brain/wiki/fastpath_state.py +174 -0
  266. brain/wiki/install.py +296 -0
  267. brain/wiki/slug.py +111 -0
  268. secondbrain_py-0.2.1.dist-info/METADATA +195 -0
  269. secondbrain_py-0.2.1.dist-info/RECORD +273 -0
  270. secondbrain_py-0.2.1.dist-info/WHEEL +5 -0
  271. secondbrain_py-0.2.1.dist-info/entry_points.txt +11 -0
  272. secondbrain_py-0.2.1.dist-info/licenses/LICENSE +21 -0
  273. 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
+ )