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,226 @@
1
+ """Baseline save/load + diff for the graph-eval report (wave G4-d; spec §17d Q3).
2
+
3
+ The graph analogue of :mod:`brain.eval.baseline` for
4
+ :class:`brain.eval.graph_runner.GraphEvalReport`. A SEPARATE baseline path from
5
+ the hybrid ``EvalReport`` baseline because the two report shapes differ (graph
6
+ carries local/fuse ranked-doc metrics + themes set P/R/F1; spec §17d Q3).
7
+
8
+ This baseline is a **canary** — it round-trips in tests and is intentionally NOT
9
+ committed as a ``ci.json``, and there is **no** ``--fail-below`` gate (spec §17d
10
+ Q3): the blocking thresholds live in the synthetic-graph integration test, not a
11
+ committed-baseline CI gate. ``diff_graph_reports`` is for local/manual
12
+ regression inspection.
13
+
14
+ Conventions mirror :mod:`brain.eval.baseline`: atomic write, sorted keys, floats
15
+ rounded to 4 decimals for byte-stable diffs. The :func:`brain.eval.baseline._round_floats`
16
+ helper is reused (one rounding implementation for both baseline shapes).
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import dataclasses
21
+ import json
22
+ from dataclasses import dataclass
23
+ from datetime import UTC, datetime
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ from ..vault._atomic import atomic_write_text
28
+ from .baseline import _round_floats
29
+ from .errors import EvalBaselineError
30
+ from .graph_runner import GraphDocEvalResult, GraphEvalReport, GraphThemesEvalResult
31
+
32
+
33
+ def save_graph_baseline(report: GraphEvalReport, *, path: Path) -> None:
34
+ """Write *report* to *path* as JSON, atomically.
35
+
36
+ Keys are sorted and floats rounded to 4 decimals so repeated runs over the
37
+ same synthetic corpus produce byte-stable diffs. The parent directory is
38
+ created if missing. The caller owns *path* (no embedded baseline-name
39
+ validation — there is no CLI surface for graph baselines; spec §17d Q3).
40
+ """
41
+ path.parent.mkdir(parents=True, exist_ok=True)
42
+ raw: dict[str, Any] = dataclasses.asdict(report)
43
+ raw = _round_floats(raw)
44
+ raw["generated_at"] = report.generated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
45
+ text = json.dumps(raw, sort_keys=True, default=str, indent=2) + "\n"
46
+ atomic_write_text(path, text)
47
+
48
+
49
+ def load_graph_baseline(path: Path) -> GraphEvalReport:
50
+ """Load a :class:`~brain.eval.graph_runner.GraphEvalReport` from *path*.
51
+
52
+ Raises:
53
+ EvalBaselineError: When the file is missing, contains invalid JSON, or
54
+ has an unexpected structure.
55
+ """
56
+ if not path.exists():
57
+ raise EvalBaselineError(f"graph baseline file not found: {path}")
58
+ try:
59
+ data: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
60
+ except json.JSONDecodeError as exc:
61
+ raise EvalBaselineError(
62
+ f"graph baseline file contains invalid JSON: {path}: {exc}"
63
+ ) from exc
64
+ try:
65
+ doc_results = [
66
+ GraphDocEvalResult(
67
+ mode=r["mode"],
68
+ query=r["query"],
69
+ expected_doc_ids=list(r["expected_doc_ids"]),
70
+ actual_doc_ids=list(r["actual_doc_ids"]),
71
+ ndcg_at_k=float(r["ndcg_at_k"]),
72
+ mrr=float(r["mrr"]),
73
+ recall_at_k=float(r["recall_at_k"]),
74
+ ndcg_k=int(r["ndcg_k"]),
75
+ recall_k=int(r["recall_k"]),
76
+ )
77
+ for r in data["doc_results"]
78
+ ]
79
+ themes_results = [
80
+ GraphThemesEvalResult(
81
+ person=r["person"],
82
+ expected_theme_keysets=[list(ks) for ks in r["expected_theme_keysets"]],
83
+ actual_theme_keysets=[list(ks) for ks in r["actual_theme_keysets"]],
84
+ precision=float(r["precision"]),
85
+ recall=float(r["recall"]),
86
+ f1=float(r["f1"]),
87
+ matched=int(r["matched"]),
88
+ n_expected=int(r["n_expected"]),
89
+ n_actual=int(r["n_actual"]),
90
+ )
91
+ for r in data["themes_results"]
92
+ ]
93
+ generated_at = datetime.fromisoformat(data["generated_at"])
94
+ if generated_at.tzinfo is None:
95
+ generated_at = generated_at.replace(tzinfo=UTC)
96
+ return GraphEvalReport(
97
+ doc_results=doc_results,
98
+ themes_results=themes_results,
99
+ mean_local_ndcg_at_k=float(data["mean_local_ndcg_at_k"]),
100
+ mean_local_mrr=float(data["mean_local_mrr"]),
101
+ mean_local_recall_at_k=float(data["mean_local_recall_at_k"]),
102
+ mean_fuse_ndcg_at_k=float(data["mean_fuse_ndcg_at_k"]),
103
+ mean_fuse_mrr=float(data["mean_fuse_mrr"]),
104
+ mean_fuse_recall_at_k=float(data["mean_fuse_recall_at_k"]),
105
+ mean_themes_precision=float(data["mean_themes_precision"]),
106
+ mean_themes_recall=float(data["mean_themes_recall"]),
107
+ mean_themes_f1=float(data["mean_themes_f1"]),
108
+ config_signature=dict(data["config_signature"]),
109
+ generated_at=generated_at,
110
+ )
111
+ except (KeyError, ValueError, TypeError) as exc:
112
+ raise EvalBaselineError(
113
+ f"graph baseline file has unexpected structure: {path}: {exc}"
114
+ ) from exc
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # Diff helpers
119
+ # ---------------------------------------------------------------------------
120
+
121
+
122
+ @dataclass(frozen=True)
123
+ class GraphDocDiff:
124
+ """Per-case ranked-doc metric delta (current - baseline; negative = worse)."""
125
+
126
+ mode: str
127
+ query: str
128
+ ndcg_at_k_delta: float
129
+ mrr_delta: float
130
+ recall_at_k_delta: float
131
+
132
+
133
+ @dataclass(frozen=True)
134
+ class GraphThemesDiff:
135
+ """Per-person themes metric delta (current - baseline; negative = worse)."""
136
+
137
+ person: str
138
+ precision_delta: float
139
+ recall_delta: float
140
+ f1_delta: float
141
+
142
+
143
+ @dataclass(frozen=True)
144
+ class GraphBaselineDiff:
145
+ """Aggregate diff between two :class:`GraphEvalReport`s."""
146
+
147
+ per_doc: list[GraphDocDiff]
148
+ per_themes: list[GraphThemesDiff]
149
+ mean_local_ndcg_at_k_delta: float
150
+ mean_local_mrr_delta: float
151
+ mean_local_recall_at_k_delta: float
152
+ mean_fuse_ndcg_at_k_delta: float
153
+ mean_fuse_mrr_delta: float
154
+ mean_fuse_recall_at_k_delta: float
155
+ mean_themes_precision_delta: float
156
+ mean_themes_recall_delta: float
157
+ mean_themes_f1_delta: float
158
+ config_signature_changed: bool
159
+ baseline_signature: dict[str, Any]
160
+ current_signature: dict[str, Any]
161
+
162
+
163
+ def diff_graph_reports(
164
+ baseline: GraphEvalReport, current: GraphEvalReport
165
+ ) -> GraphBaselineDiff:
166
+ """Compute the delta between *baseline* and *current*.
167
+
168
+ Doc results are matched by ``(mode, query)``; themes results by ``person``.
169
+ A case present in one report but not the other contributes a 0.0 baseline
170
+ side (mirroring :func:`brain.eval.baseline.diff_reports`).
171
+ """
172
+ base_doc = {(r.mode, r.query): r for r in baseline.doc_results}
173
+ curr_doc = {(r.mode, r.query): r for r in current.doc_results}
174
+ per_doc: list[GraphDocDiff] = []
175
+ for key in dict.fromkeys(list(base_doc) + list(curr_doc)):
176
+ b = base_doc.get(key)
177
+ c = curr_doc.get(key)
178
+ per_doc.append(
179
+ GraphDocDiff(
180
+ mode=key[0],
181
+ query=key[1],
182
+ ndcg_at_k_delta=(c.ndcg_at_k if c else 0.0) - (b.ndcg_at_k if b else 0.0),
183
+ mrr_delta=(c.mrr if c else 0.0) - (b.mrr if b else 0.0),
184
+ recall_at_k_delta=(c.recall_at_k if c else 0.0)
185
+ - (b.recall_at_k if b else 0.0),
186
+ )
187
+ )
188
+
189
+ base_themes = {r.person: r for r in baseline.themes_results}
190
+ curr_themes = {r.person: r for r in current.themes_results}
191
+ per_themes: list[GraphThemesDiff] = []
192
+ for person in dict.fromkeys(list(base_themes) + list(curr_themes)):
193
+ b_t = base_themes.get(person)
194
+ c_t = curr_themes.get(person)
195
+ per_themes.append(
196
+ GraphThemesDiff(
197
+ person=person,
198
+ precision_delta=(c_t.precision if c_t else 0.0)
199
+ - (b_t.precision if b_t else 0.0),
200
+ recall_delta=(c_t.recall if c_t else 0.0) - (b_t.recall if b_t else 0.0),
201
+ f1_delta=(c_t.f1 if c_t else 0.0) - (b_t.f1 if b_t else 0.0),
202
+ )
203
+ )
204
+
205
+ return GraphBaselineDiff(
206
+ per_doc=per_doc,
207
+ per_themes=per_themes,
208
+ mean_local_ndcg_at_k_delta=current.mean_local_ndcg_at_k
209
+ - baseline.mean_local_ndcg_at_k,
210
+ mean_local_mrr_delta=current.mean_local_mrr - baseline.mean_local_mrr,
211
+ mean_local_recall_at_k_delta=current.mean_local_recall_at_k
212
+ - baseline.mean_local_recall_at_k,
213
+ mean_fuse_ndcg_at_k_delta=current.mean_fuse_ndcg_at_k
214
+ - baseline.mean_fuse_ndcg_at_k,
215
+ mean_fuse_mrr_delta=current.mean_fuse_mrr - baseline.mean_fuse_mrr,
216
+ mean_fuse_recall_at_k_delta=current.mean_fuse_recall_at_k
217
+ - baseline.mean_fuse_recall_at_k,
218
+ mean_themes_precision_delta=current.mean_themes_precision
219
+ - baseline.mean_themes_precision,
220
+ mean_themes_recall_delta=current.mean_themes_recall
221
+ - baseline.mean_themes_recall,
222
+ mean_themes_f1_delta=current.mean_themes_f1 - baseline.mean_themes_f1,
223
+ config_signature_changed=baseline.config_signature != current.config_signature,
224
+ baseline_signature=dict(baseline.config_signature),
225
+ current_signature=dict(current.config_signature),
226
+ )
@@ -0,0 +1,202 @@
1
+ """Graph-retrieval eval scorers (wave G2-j, GraphRAG; spec §6/§6b).
2
+
3
+ Scores the two G2 graph-retrieval paths against synthetic golden expectations:
4
+
5
+ * **Local (entity-centric, spec §6a).** ``graph_rag_search(mode='local')``
6
+ returns a *ranked document list* (``GraphContext.docs`` — reused
7
+ :class:`brain.search.SearchResult`s). A ranked doc list is exactly what the
8
+ existing ranking metrics measure, so :func:`score_local_docs` **reuses**
9
+ :func:`brain.eval.metrics.ndcg_at_k` / :func:`~brain.eval.metrics.mrr` /
10
+ :func:`~brain.eval.metrics.recall_at_k` — no new ranking metric is invented.
11
+
12
+ * **Themes-with-X (spec §6b — the HEADLINE).** ``graph_rag_search(mode='themes')``
13
+ returns ``ThemeGroup``s — *sets of co-occurring entity clusters*, not a ranked
14
+ document list. nDCG/MRR/Recall over doc IDs cannot express "did the right
15
+ *clusters* surface", so themes need a small graph-appropriate scorer:
16
+ :func:`score_themes` matches each expected theme keyset to a predicted theme
17
+ keyset by best Jaccard overlap (greedy, deterministic) above a threshold, then
18
+ reports set precision / recall / F1 over the matched clusters. This is the
19
+ cluster-grouping analogue of the concept-set F1 in
20
+ :mod:`brain.eval.concept_extraction`.
21
+
22
+ Both scorers are **pure** (no DB, no Ollama, no I/O) and unit-testable with
23
+ hand-constructed inputs. The end-to-end graph-retrieval eval test builds a small
24
+ synthetic AGE corpus (via ``reconcile_document`` on the test DB), runs
25
+ ``graph_rag_search``, and feeds the results here.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ from collections.abc import Iterable, Sequence
30
+ from dataclasses import dataclass
31
+
32
+ from brain.set_similarity import jaccard
33
+
34
+ from .metrics import mrr as _mrr
35
+ from .metrics import ndcg_at_k as _ndcg_at_k
36
+ from .metrics import recall_at_k as _recall_at_k
37
+
38
+ __all__ = [
39
+ "DEFAULT_THEME_JACCARD",
40
+ "LocalRetrievalScore",
41
+ "ThemeRetrievalScore",
42
+ "score_local_docs",
43
+ "score_themes",
44
+ ]
45
+
46
+ # Default minimum Jaccard overlap for an expected theme keyset to count as
47
+ # "matched" by a predicted theme keyset. 0.5 means the two clusters must share
48
+ # the majority of their combined entities — strict enough that a one-entity
49
+ # accidental overlap between otherwise-different clusters does not count.
50
+ DEFAULT_THEME_JACCARD = 0.5
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class LocalRetrievalScore:
55
+ """nDCG@k / MRR / Recall@k for one local graph-retrieval query.
56
+
57
+ A thin bundle of the three reused ranking metrics scored over the local
58
+ path's ranked ``GraphContext.docs`` document IDs against the expected
59
+ document-ID set.
60
+ """
61
+
62
+ ndcg_at_k: float
63
+ mrr: float
64
+ recall_at_k: float
65
+ ndcg_k: int
66
+ recall_k: int
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class ThemeRetrievalScore:
71
+ """Set precision / recall / F1 for one themes-with-X query.
72
+
73
+ Computed by greedy best-Jaccard matching of predicted theme keysets to
74
+ expected theme keysets above :data:`DEFAULT_THEME_JACCARD`. ``matched`` is the
75
+ number of expected themes matched; ``n_expected`` / ``n_actual`` are the
76
+ cluster counts.
77
+ """
78
+
79
+ precision: float
80
+ recall: float
81
+ f1: float
82
+ matched: int
83
+ n_expected: int
84
+ n_actual: int
85
+
86
+
87
+ def score_local_docs(
88
+ actual_doc_ids: Sequence[str],
89
+ expected_doc_ids: Iterable[str],
90
+ *,
91
+ ndcg_k: int = 5,
92
+ recall_k: int = 20,
93
+ ) -> LocalRetrievalScore:
94
+ """Score a local query's ranked doc IDs with the reused ranking metrics.
95
+
96
+ Args:
97
+ actual_doc_ids: Document IDs from ``GraphContext.docs``, in rank order.
98
+ expected_doc_ids: The relevant document IDs (order-independent).
99
+ ndcg_k: Cutoff for nDCG.
100
+ recall_k: Cutoff for recall.
101
+
102
+ Returns:
103
+ A :class:`LocalRetrievalScore`.
104
+
105
+ Raises:
106
+ EvalMetricError: When ``expected_doc_ids`` is empty (propagated from the
107
+ underlying ranking metrics).
108
+ """
109
+ expected = list(expected_doc_ids)
110
+ return LocalRetrievalScore(
111
+ ndcg_at_k=_ndcg_at_k(actual_doc_ids, expected, k=ndcg_k),
112
+ mrr=_mrr(actual_doc_ids, expected),
113
+ recall_at_k=_recall_at_k(actual_doc_ids, expected, k=recall_k),
114
+ ndcg_k=ndcg_k,
115
+ recall_k=recall_k,
116
+ )
117
+
118
+
119
+ def _to_keysets(themes: Iterable[Iterable[str]]) -> list[frozenset[str]]:
120
+ """Normalize theme entity-key iterables to lower-cased frozensets.
121
+
122
+ Empty clusters are dropped (a themeless group cannot be matched). Keys are
123
+ lower-cased + whitespace-collapsed to match the catalog ``canonical_key``.
124
+ """
125
+ out: list[frozenset[str]] = []
126
+ for theme in themes:
127
+ keys = {" ".join(str(k).lower().split()) for k in theme}
128
+ keys.discard("")
129
+ if keys:
130
+ out.append(frozenset(keys))
131
+ return out
132
+
133
+
134
+ def score_themes(
135
+ actual_themes: Iterable[Iterable[str]],
136
+ expected_themes: Iterable[Iterable[str]],
137
+ *,
138
+ jaccard_threshold: float = DEFAULT_THEME_JACCARD,
139
+ ) -> ThemeRetrievalScore:
140
+ """Greedy best-Jaccard set precision / recall / F1 over theme clusters.
141
+
142
+ Each predicted / expected theme is a set of entity ``canonical_key``s. Every
143
+ ``(expected, actual)`` pair with Jaccard ``>= jaccard_threshold`` is a
144
+ candidate match; candidates are consumed greedily highest-Jaccard-first (ties
145
+ broken deterministically by index) so each expected and each actual theme is
146
+ matched at most once. ``recall = matched / n_expected``,
147
+ ``precision = matched / n_actual``, ``f1`` is their harmonic mean.
148
+
149
+ Args:
150
+ actual_themes: Predicted theme keysets (e.g. from ``GraphContext.themes``).
151
+ expected_themes: The expected theme keysets.
152
+ jaccard_threshold: Minimum overlap for a match (default
153
+ :data:`DEFAULT_THEME_JACCARD`).
154
+
155
+ Returns:
156
+ A :class:`ThemeRetrievalScore`. With no expected and no actual themes,
157
+ precision/recall/F1 are all 1.0 (vacuously correct); when exactly one
158
+ side is empty they are 0.0.
159
+ """
160
+ expected = _to_keysets(expected_themes)
161
+ actual = _to_keysets(actual_themes)
162
+ n_expected = len(expected)
163
+ n_actual = len(actual)
164
+
165
+ if n_expected == 0 and n_actual == 0:
166
+ return ThemeRetrievalScore(1.0, 1.0, 1.0, 0, 0, 0)
167
+
168
+ candidates: list[tuple[float, int, int]] = []
169
+ for ei, exp in enumerate(expected):
170
+ for ai, act in enumerate(actual):
171
+ score = jaccard(exp, act)
172
+ if score >= jaccard_threshold:
173
+ candidates.append((score, ei, ai))
174
+ # Highest Jaccard first; ties → lowest expected index, then lowest actual
175
+ # index, so the matching is deterministic across runs.
176
+ candidates.sort(key=lambda c: (-c[0], c[1], c[2]))
177
+
178
+ used_expected: set[int] = set()
179
+ used_actual: set[int] = set()
180
+ matched = 0
181
+ for _score, ei, ai in candidates:
182
+ if ei in used_expected or ai in used_actual:
183
+ continue
184
+ used_expected.add(ei)
185
+ used_actual.add(ai)
186
+ matched += 1
187
+
188
+ recall = matched / n_expected if n_expected > 0 else 0.0
189
+ precision = matched / n_actual if n_actual > 0 else 0.0
190
+ f1 = (
191
+ 2 * precision * recall / (precision + recall)
192
+ if (precision + recall) > 0
193
+ else 0.0
194
+ )
195
+ return ThemeRetrievalScore(
196
+ precision=precision,
197
+ recall=recall,
198
+ f1=f1,
199
+ matched=matched,
200
+ n_expected=n_expected,
201
+ n_actual=n_actual,
202
+ )