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,281 @@
1
+ """Answer-quality eval for `brain ask` (Plan 06, Phase 3).
2
+
3
+ Measures synthesized answers against a golden fact-set with deterministic,
4
+ local-only scoring — substring + significant-token overlap, NO LLM judge in the
5
+ loop — so the harness stays reproducible and never requires Ollama to *score*
6
+ (the live ``ask_fn`` that produces answers may, but that is the caller's
7
+ concern). Mirrors the hybrid-search eval package's shape (``corpus`` loader +
8
+ dataclass report) so the two read consistently.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from collections.abc import Callable
14
+ from dataclasses import dataclass
15
+ from datetime import UTC, datetime
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING, Any
18
+
19
+ import yaml
20
+
21
+ from .errors import EvalCorpusError
22
+
23
+ if TYPE_CHECKING:
24
+ from ..ask import AskResult
25
+
26
+ # Default answer-corpus path: eval/ -> brain/ -> src/ -> repo root, then into
27
+ # tests/eval/. Committed (unlike the hybrid golden corpus) because it is fully
28
+ # synthetic — no real doc IDs, names, or employers (CLAUDE.md rule 15).
29
+ _DEFAULT_ANSWER_CORPUS_PATH: Path = (
30
+ Path(__file__).resolve().parent.parent.parent.parent
31
+ / "tests"
32
+ / "eval"
33
+ / "answer_corpus.yaml"
34
+ )
35
+
36
+ _VALID_ANSWER_CATEGORIES: frozenset[str] = frozenset(
37
+ ("synthesis", "multi-hop", "person", "timeline")
38
+ )
39
+
40
+ _REQUIRED_ANSWER_FIELDS: frozenset[str] = frozenset(
41
+ ("question", "expected_facts", "category")
42
+ )
43
+ _ALLOWED_ANSWER_FIELDS: frozenset[str] = frozenset(
44
+ ("question", "expected_facts", "category", "notes")
45
+ )
46
+ _ANSWER_CORPUS_VERSION = 1
47
+
48
+ # A fact counts as covered when at least this fraction of its significant
49
+ # (non-stopword) tokens appear in the answer — a majority by default. Single
50
+ # distinctive tokens (ratio 1.0) and exact phrase substrings always pass.
51
+ _FACT_COVERAGE_THRESHOLD = 0.5
52
+
53
+ # Minimal English stopword set for significant-token extraction. Deliberately
54
+ # small — only the highest-frequency function words — so distinctive content
55
+ # words (the signal we score on) survive.
56
+ _STOPWORDS: frozenset[str] = frozenset(
57
+ (
58
+ "the", "a", "an", "of", "to", "and", "or", "in", "on", "for", "with",
59
+ "is", "are", "was", "were", "be", "been", "my", "i", "we", "you",
60
+ "that", "this", "it", "as", "at", "by", "from", "about", "into",
61
+ )
62
+ )
63
+
64
+ _TOKEN_RE = re.compile(r"[a-z0-9]+")
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class AnswerEvalCase:
69
+ """One golden Q&A case from the answer corpus."""
70
+
71
+ question: str
72
+ expected_facts: list[str] # short synthetic phrases; all should appear/be implied
73
+ category: str # one of _VALID_ANSWER_CATEGORIES
74
+ notes: str = "" # human-readable rationale, not graded
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class AnswerScore:
79
+ """Per-case answer-quality score."""
80
+
81
+ question: str
82
+ category: str
83
+ fact_recall: float # fraction of expected_facts covered by the answer
84
+ citation_count: int # number of distinct citations the answer produced
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class AnswerEvalReport:
89
+ """Aggregate answer-eval result over a corpus run."""
90
+
91
+ scores: list[AnswerScore]
92
+ mean_fact_recall: float
93
+ mean_citation_count: float
94
+ timestamp: str # ISO-8601
95
+
96
+
97
+ def _significant_tokens(text: str) -> list[str]:
98
+ """Lowercase content tokens with stopwords removed."""
99
+ return [t for t in _TOKEN_RE.findall(text.lower()) if t not in _STOPWORDS]
100
+
101
+
102
+ def _fact_covered(answer_lower: str, answer_tokens: set[str], fact: str) -> bool:
103
+ """Return True if ``fact`` is covered by the answer.
104
+
105
+ Covered when the exact (lowercased) fact phrase is a substring of the
106
+ answer, OR at least :data:`_FACT_COVERAGE_THRESHOLD` of the fact's
107
+ significant tokens appear in the answer's token set.
108
+ """
109
+ if fact.strip().lower() in answer_lower:
110
+ return True
111
+ fact_tokens = _significant_tokens(fact)
112
+ if not fact_tokens:
113
+ # No content words to compare — fall back to the substring result above.
114
+ return False
115
+ present = sum(1 for t in fact_tokens if t in answer_tokens)
116
+ return present / len(fact_tokens) >= _FACT_COVERAGE_THRESHOLD
117
+
118
+
119
+ def score_answer(answer: str, expected_facts: list[str]) -> float:
120
+ """Fraction of ``expected_facts`` covered by ``answer`` in ``[0.0, 1.0]``.
121
+
122
+ Deterministic substring + significant-token-overlap matching (no LLM). An
123
+ empty ``expected_facts`` list scores ``1.0`` (nothing to miss).
124
+ """
125
+ if not expected_facts:
126
+ return 1.0
127
+ answer_lower = answer.lower()
128
+ answer_tokens = set(_significant_tokens(answer))
129
+ covered = sum(
130
+ 1
131
+ for fact in expected_facts
132
+ if _fact_covered(answer_lower, answer_tokens, fact)
133
+ )
134
+ return covered / len(expected_facts)
135
+
136
+
137
+ def run_answer_eval(
138
+ cases: list[AnswerEvalCase],
139
+ ask_fn: Callable[[str], AskResult],
140
+ *,
141
+ now: datetime | None = None,
142
+ ) -> AnswerEvalReport:
143
+ """Run ``ask_fn`` over every case and score the answers.
144
+
145
+ ``ask_fn`` maps a question to an :class:`~brain.ask.AskResult` (typically a
146
+ closure over ``ask_no_loop`` against the live brain). ``now`` overrides the
147
+ report timestamp (defaults to the current UTC time) so tests stay
148
+ deterministic.
149
+ """
150
+ scores: list[AnswerScore] = []
151
+ for case in cases:
152
+ result = ask_fn(case.question)
153
+ scores.append(
154
+ AnswerScore(
155
+ question=case.question,
156
+ category=case.category,
157
+ fact_recall=score_answer(result.answer, case.expected_facts),
158
+ citation_count=len(result.citations),
159
+ )
160
+ )
161
+ if scores:
162
+ mean_recall = sum(s.fact_recall for s in scores) / len(scores)
163
+ mean_citations = sum(s.citation_count for s in scores) / len(scores)
164
+ else:
165
+ mean_recall = 0.0
166
+ mean_citations = 0.0
167
+ stamp = (now or datetime.now(UTC)).isoformat()
168
+ return AnswerEvalReport(
169
+ scores=scores,
170
+ mean_fact_recall=mean_recall,
171
+ mean_citation_count=mean_citations,
172
+ timestamp=stamp,
173
+ )
174
+
175
+
176
+ def load_answer_corpus(path: Path | None = None) -> list[AnswerEvalCase]:
177
+ """Load + validate the answer-eval corpus YAML.
178
+
179
+ Raises:
180
+ EvalCorpusError: missing/malformed file, version mismatch, unknown
181
+ category, missing required field, or empty ``expected_facts``.
182
+ """
183
+ if path is None:
184
+ path = _DEFAULT_ANSWER_CORPUS_PATH
185
+
186
+ if not path.exists():
187
+ raise EvalCorpusError(f"answer corpus file not found: {path}")
188
+
189
+ try:
190
+ raw = yaml.safe_load(path.read_text(encoding="utf-8"))
191
+ except yaml.YAMLError as exc:
192
+ raise EvalCorpusError(
193
+ f"answer corpus YAML parse error in {path}: {exc}"
194
+ ) from exc
195
+
196
+ if not isinstance(raw, dict):
197
+ raise EvalCorpusError(
198
+ f"answer corpus file must be a YAML mapping, got {type(raw).__name__}"
199
+ )
200
+
201
+ version = raw.get("version")
202
+ if version != _ANSWER_CORPUS_VERSION:
203
+ raise EvalCorpusError(
204
+ f"answer corpus version mismatch: expected {_ANSWER_CORPUS_VERSION}, "
205
+ f"got {version!r}."
206
+ )
207
+
208
+ raw_cases = raw.get("cases")
209
+ if not isinstance(raw_cases, list):
210
+ raise EvalCorpusError("answer corpus file must have a 'cases' list")
211
+
212
+ cases: list[AnswerEvalCase] = []
213
+ for i, entry in enumerate(raw_cases):
214
+ if not isinstance(entry, dict):
215
+ raise EvalCorpusError(
216
+ f"answer corpus case #{i + 1} must be a YAML mapping, "
217
+ f"got {type(entry).__name__}"
218
+ )
219
+
220
+ missing = _REQUIRED_ANSWER_FIELDS - entry.keys()
221
+ if missing:
222
+ raise EvalCorpusError(
223
+ f"answer corpus case #{i + 1} is missing required field(s): "
224
+ f"{', '.join(sorted(missing))}"
225
+ )
226
+
227
+ unknown = entry.keys() - _ALLOWED_ANSWER_FIELDS
228
+ if unknown:
229
+ raise EvalCorpusError(
230
+ f"answer corpus case #{i + 1} has unknown field(s): "
231
+ f"{', '.join(sorted(unknown))}. "
232
+ f"Allowed: {', '.join(sorted(_ALLOWED_ANSWER_FIELDS))}"
233
+ )
234
+
235
+ category = entry["category"]
236
+ if category not in _VALID_ANSWER_CATEGORIES:
237
+ raise EvalCorpusError(
238
+ f"answer corpus case #{i + 1} has unknown category {category!r}. "
239
+ f"Valid: {', '.join(sorted(_VALID_ANSWER_CATEGORIES))}"
240
+ )
241
+
242
+ expected_facts = entry["expected_facts"]
243
+ if not isinstance(expected_facts, list):
244
+ raise EvalCorpusError(
245
+ f"answer corpus case #{i + 1} 'expected_facts' must be a list, "
246
+ f"got {type(expected_facts).__name__}"
247
+ )
248
+ if not expected_facts:
249
+ raise EvalCorpusError(
250
+ f"answer corpus case #{i + 1} ({entry['question']!r}) has empty "
251
+ f"'expected_facts'"
252
+ )
253
+
254
+ cases.append(
255
+ AnswerEvalCase(
256
+ question=str(entry["question"]),
257
+ expected_facts=[str(f) for f in expected_facts],
258
+ category=category,
259
+ notes=str(entry.get("notes", "")),
260
+ )
261
+ )
262
+
263
+ return cases
264
+
265
+
266
+ def answer_eval_report_to_dict(report: AnswerEvalReport) -> dict[str, Any]:
267
+ """JSON projection of an :class:`AnswerEvalReport` (CLI ``--json``)."""
268
+ return {
269
+ "mean_fact_recall": report.mean_fact_recall,
270
+ "mean_citation_count": report.mean_citation_count,
271
+ "timestamp": report.timestamp,
272
+ "scores": [
273
+ {
274
+ "question": s.question,
275
+ "category": s.category,
276
+ "fact_recall": s.fact_recall,
277
+ "citation_count": s.citation_count,
278
+ }
279
+ for s in report.scores
280
+ ],
281
+ }
brain/eval/baseline.py ADDED
@@ -0,0 +1,265 @@
1
+ """Baseline save/load and diff helpers for eval reports.
2
+
3
+ Baselines are stored as JSON files (sorted keys, floats rounded to 4 decimals
4
+ for byte-stable diffs) via an atomic write so a crash can never leave a
5
+ half-written file. Baseline names are validated against a strict allowlist
6
+ pattern to prevent path traversal.
7
+ """
8
+
9
+ import dataclasses
10
+ import json
11
+ import re
12
+ from dataclasses import dataclass
13
+ from datetime import UTC, datetime
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from ..vault._atomic import atomic_write_text
18
+ from .errors import EvalBaselineError
19
+ from .runner import CategorySummary, EvalReport, EvalResult
20
+
21
+ _BASELINE_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$")
22
+
23
+
24
+ def _assert_baseline_name(name: str) -> None:
25
+ """Validate that *name* is safe to use as a filename component.
26
+
27
+ Raises:
28
+ EvalBaselineError: When *name* contains characters outside
29
+ ``[A-Za-z0-9_-]`` (e.g. slashes, dots, spaces).
30
+ """
31
+ if not _BASELINE_NAME_RE.fullmatch(name):
32
+ raise EvalBaselineError(
33
+ f"baseline name {name!r} is invalid; "
34
+ f"use only letters, digits, hyphens, and underscores"
35
+ )
36
+
37
+
38
+ def _round_floats(obj: Any, decimals: int = 4) -> Any:
39
+ """Recursively round all floats in a JSON-compatible structure."""
40
+ if isinstance(obj, float):
41
+ return round(obj, decimals)
42
+ if isinstance(obj, dict):
43
+ return {k: _round_floats(v, decimals) for k, v in obj.items()}
44
+ if isinstance(obj, list):
45
+ return [_round_floats(v, decimals) for v in obj]
46
+ return obj
47
+
48
+
49
+ def save_baseline(report: EvalReport, *, path: Path) -> None:
50
+ """Write *report* to *path* as JSON, atomically.
51
+
52
+ Keys are sorted and floats rounded to 4 decimal places so repeated
53
+ runs over the same corpus produce byte-stable diffs. The parent
54
+ directory is created if it does not exist.
55
+
56
+ Args:
57
+ report: The eval report to persist.
58
+ path: Destination file path (typically under ``tests/eval/baselines/``).
59
+ """
60
+ path.parent.mkdir(parents=True, exist_ok=True)
61
+ raw: dict[str, Any] = dataclasses.asdict(report)
62
+ raw = _round_floats(raw)
63
+ # datetime → ISO-8601 with Z suffix for UTC.
64
+ raw["generated_at"] = report.generated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
65
+ text = json.dumps(raw, sort_keys=True, default=str, indent=2) + "\n"
66
+ atomic_write_text(path, text)
67
+
68
+
69
+ def load_baseline(path: Path) -> EvalReport:
70
+ """Load an :class:`~brain.eval.runner.EvalReport` from a JSON baseline file.
71
+
72
+ Args:
73
+ path: Path to the baseline JSON file.
74
+
75
+ Raises:
76
+ EvalBaselineError: When the file is missing, contains invalid JSON,
77
+ or has an unexpected structure.
78
+ """
79
+ if not path.exists():
80
+ raise EvalBaselineError(f"baseline file not found: {path}")
81
+ try:
82
+ data: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
83
+ except json.JSONDecodeError as exc:
84
+ raise EvalBaselineError(
85
+ f"baseline file contains invalid JSON: {path}: {exc}"
86
+ ) from exc
87
+ try:
88
+ results = [
89
+ EvalResult(
90
+ query=r["query"],
91
+ category=r["category"],
92
+ expected_doc_ids=list(r["expected_doc_ids"]),
93
+ actual_doc_ids=list(r["actual_doc_ids"]),
94
+ ndcg_at_5=float(r["ndcg_at_5"]),
95
+ mrr=float(r["mrr"]),
96
+ recall_at_20=float(r["recall_at_20"]),
97
+ )
98
+ for r in data["results"]
99
+ ]
100
+ per_category = {
101
+ k: CategorySummary(
102
+ category=v["category"],
103
+ count=int(v["count"]),
104
+ mean_ndcg_at_5=float(v["mean_ndcg_at_5"]),
105
+ mean_mrr=float(v["mean_mrr"]),
106
+ mean_recall_at_20=float(v["mean_recall_at_20"]),
107
+ )
108
+ for k, v in data["per_category"].items()
109
+ }
110
+ generated_at_raw: str = data["generated_at"]
111
+ # Python 3.11+ accepts the "Z" suffix in fromisoformat.
112
+ generated_at = datetime.fromisoformat(generated_at_raw)
113
+ if generated_at.tzinfo is None:
114
+ generated_at = generated_at.replace(tzinfo=UTC)
115
+ return EvalReport(
116
+ results=results,
117
+ mean_ndcg_at_5=float(data["mean_ndcg_at_5"]),
118
+ mean_mrr=float(data["mean_mrr"]),
119
+ mean_recall_at_20=float(data["mean_recall_at_20"]),
120
+ per_category=per_category,
121
+ config_signature=dict(data["config_signature"]),
122
+ generated_at=generated_at,
123
+ )
124
+ except (KeyError, ValueError, TypeError) as exc:
125
+ raise EvalBaselineError(
126
+ f"baseline file has unexpected structure: {path}: {exc}"
127
+ ) from exc
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # Diff helpers
132
+ # ---------------------------------------------------------------------------
133
+
134
+
135
+ @dataclass(frozen=True)
136
+ class QueryDiff:
137
+ """Per-query metric delta between a baseline and a current run."""
138
+
139
+ query: str
140
+ category: str
141
+ ndcg_at_5_delta: float # current - baseline; negative = regression
142
+ mrr_delta: float
143
+ recall_at_20_delta: float
144
+
145
+
146
+ @dataclass(frozen=True)
147
+ class BaselineDiff:
148
+ """Aggregate diff between a baseline :class:`EvalReport` and a current run.
149
+
150
+ Per-query deltas and the aggregate means cover the INTERSECTION of the two
151
+ runs' query sets (queries present in BOTH). Queries present in only one run
152
+ are surfaced separately in :attr:`added_queries` / :attr:`removed_queries`
153
+ and excluded from the aggregate, so a growing or shrinking query set never
154
+ dilutes a real regression toward 0 — the aggregate always compares
155
+ like-for-like. This is the input contract for the ``--fail-below`` gate.
156
+ """
157
+
158
+ per_query: list[QueryDiff]
159
+ mean_ndcg_at_5_delta: float
160
+ mean_mrr_delta: float
161
+ mean_recall_at_20_delta: float
162
+ config_signature_changed: bool
163
+ baseline_signature: dict[str, Any]
164
+ current_signature: dict[str, Any]
165
+ # Query strings present in ``current`` but not ``baseline`` (added), and in
166
+ # ``baseline`` but not ``current`` (removed). Reported separately so the
167
+ # aggregate stays intersection-only; deterministic source order.
168
+ added_queries: list[str]
169
+ removed_queries: list[str]
170
+
171
+
172
+ def diff_reports(baseline: EvalReport, current: EvalReport) -> BaselineDiff:
173
+ """Compute the delta between *baseline* and *current*.
174
+
175
+ Queries are matched by query string. Per-query deltas AND the aggregate mean
176
+ deltas are computed over the INTERSECTION of the two query sets (queries
177
+ present in BOTH reports), in the baseline's result order. Queries present in
178
+ only one report are surfaced separately as
179
+ :attr:`~BaselineDiff.added_queries` (in *current* only) /
180
+ :attr:`~BaselineDiff.removed_queries` (in *baseline* only) and excluded from
181
+ the aggregate — so a growing or shrinking query set never dilutes a real
182
+ regression toward 0 (an earlier union-with-0-fill averaged each added query's
183
+ ``current - 0`` positive delta into the mean, masking regressions on the
184
+ shared queries).
185
+
186
+ Args:
187
+ baseline: The reference (earlier) eval report.
188
+ current: The new eval report to compare.
189
+
190
+ Returns:
191
+ A :class:`BaselineDiff` whose per-query and aggregate deltas cover the
192
+ shared queries, plus the added/removed query id lists.
193
+ """
194
+ # Build lookup dicts keyed by query string.
195
+ base_by_q = {r.query: r for r in baseline.results}
196
+ curr_by_q = {r.query: r for r in current.results}
197
+
198
+ # Per-query deltas over the SHARED queries only, in baseline result order.
199
+ per_query: list[QueryDiff] = []
200
+ for q_str, base_r in base_by_q.items():
201
+ curr_r = curr_by_q.get(q_str)
202
+ if curr_r is None:
203
+ continue # removed query — reported separately, not in the aggregate
204
+ per_query.append(
205
+ QueryDiff(
206
+ query=q_str,
207
+ category=curr_r.category,
208
+ ndcg_at_5_delta=curr_r.ndcg_at_5 - base_r.ndcg_at_5,
209
+ mrr_delta=curr_r.mrr - base_r.mrr,
210
+ recall_at_20_delta=curr_r.recall_at_20 - base_r.recall_at_20,
211
+ )
212
+ )
213
+
214
+ # Query-set changes surfaced separately (deterministic source order): never
215
+ # folded into the aggregate.
216
+ added_queries = [q for q in curr_by_q if q not in base_by_q]
217
+ removed_queries = [q for q in base_by_q if q not in curr_by_q]
218
+
219
+ n = len(per_query)
220
+ mean_ndcg_delta = sum(d.ndcg_at_5_delta for d in per_query) / n if n else 0.0
221
+ mean_mrr_delta = sum(d.mrr_delta for d in per_query) / n if n else 0.0
222
+ mean_recall_delta = sum(d.recall_at_20_delta for d in per_query) / n if n else 0.0
223
+
224
+ return BaselineDiff(
225
+ per_query=per_query,
226
+ mean_ndcg_at_5_delta=mean_ndcg_delta,
227
+ mean_mrr_delta=mean_mrr_delta,
228
+ mean_recall_at_20_delta=mean_recall_delta,
229
+ config_signature_changed=baseline.config_signature != current.config_signature,
230
+ baseline_signature=dict(baseline.config_signature),
231
+ current_signature=dict(current.config_signature),
232
+ added_queries=added_queries,
233
+ removed_queries=removed_queries,
234
+ )
235
+
236
+
237
+ # ---------------------------------------------------------------------------
238
+ # ``--fail-below`` regression gate (Task 5.5)
239
+ # ---------------------------------------------------------------------------
240
+
241
+ # A mean metric "regresses" when its 4-decimal-ROUNDED delta is more negative
242
+ # than one unit at the baseline's 4-decimal serialization precision (``-1e-4``).
243
+ # Comparing the ROUNDED delta — not the raw float — keeps the boundary
244
+ # constructible: a delta of exactly ``-0.0001`` is NOT a regression (it equals
245
+ # the threshold), whereas ``-0.0002`` is. A raw-float compare would be
246
+ # IEEE-754-fragile right at that boundary. Uniform across all three metrics
247
+ # (nDCG@5 / MRR / Recall@20) — no per-metric overrides (CLAUDE.md decision).
248
+ _FAIL_BELOW_THRESHOLD: float = -1e-4
249
+
250
+
251
+ def mean_metrics_regressed(diff: BaselineDiff) -> bool:
252
+ """True iff any mean metric delta regressed past the ``--fail-below`` gate.
253
+
254
+ Checks ``round(delta, 4) < -1e-4`` for each of nDCG@5 / MRR / Recall@20 (the
255
+ aggregate over the shared query set — :func:`diff_reports` already excludes
256
+ added/removed queries so a changed corpus never dilutes a real regression).
257
+ """
258
+ return any(
259
+ round(delta, 4) < _FAIL_BELOW_THRESHOLD
260
+ for delta in (
261
+ diff.mean_ndcg_at_5_delta,
262
+ diff.mean_mrr_delta,
263
+ diff.mean_recall_at_20_delta,
264
+ )
265
+ )