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,378 @@
1
+ """Concept-extractor eval gate metric (wave G2-j, GraphRAG; spec §17b decision 2).
2
+
3
+ The gate that informs the **later** decision to flip ``BRAIN_GRAPH_CONCEPTS``
4
+ default-ON. **G2 ships concepts default-OFF regardless of this gate** — this
5
+ module is a *measurement / decision* tool, not a G2 blocker (spec §17b
6
+ decision 2: "Passing this gate is necessary but not sufficient to flip the
7
+ default; ``BRAIN_GRAPH_CONCEPTS`` stays default-OFF for the G2 ship").
8
+
9
+ **Metric (normative, spec §17b decision 2).** Document-level, **type-aware**
10
+ concept-set **micro-F1** over unique ``(entity_type, canonical_key)`` pairs after
11
+ canonicalization, **people excluded**. Aggregation is *micro* — true/false
12
+ positives and false negatives are summed across every document, then a single
13
+ precision/recall/F1 is computed from those totals (so a document with many
14
+ concepts weighs more than one with few, which is the right unit for an
15
+ extraction gate). Type-awareness means ``("org", "acme")`` and
16
+ ``("project", "acme")`` are **distinct** pairs. People are excluded because they
17
+ are derived for free from the participants pipeline, not the LLM extractor.
18
+
19
+ **Pass threshold (normative).** ``micro_f1 >= 0.80`` AND ``precision >= 0.85``
20
+ AND ``recall >= 0.70`` AND ``invalid_json_or_schema_rate == 0`` — see
21
+ :attr:`ConceptF1Report.passes`.
22
+
23
+ ``invalid_json_or_schema_rate`` is the fraction of documents whose extraction
24
+ produced malformed / schema-invalid model output (the live gate detects this via
25
+ the :class:`~brain.graph_rag.extract.OllamaExtractor`'s documented
26
+ "skipping chunk" WARN; an invalid document contributes **no** predicted pairs,
27
+ so all its gold pairs become false negatives).
28
+
29
+ This module is **pure** — no DB, no Ollama, no I/O on the metric path — and is
30
+ fully unit-testable with hand-constructed predicted-vs-gold pair sets. Only the
31
+ in-repo synthetic-fixture loader (:func:`load_concept_fixture`) touches the
32
+ filesystem. Mirrors :mod:`brain.eval.metrics` (the ranking metrics) in style.
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import re
37
+ from collections.abc import Iterable, Sequence
38
+ from dataclasses import dataclass
39
+ from pathlib import Path
40
+
41
+ import yaml
42
+
43
+ from .errors import EvalCorpusError, EvalMetricError
44
+
45
+ __all__ = [
46
+ "ConceptF1Report",
47
+ "ConceptFixtureDoc",
48
+ "ConceptPair",
49
+ "PASS_INVALID_RATE",
50
+ "PASS_MICRO_F1",
51
+ "PASS_PRECISION",
52
+ "PASS_RECALL",
53
+ "PERSON_ENTITY_TYPE",
54
+ "concept_set_micro_f1",
55
+ "load_concept_fixture",
56
+ "normalize_concept_pairs",
57
+ ]
58
+
59
+ # One concept identity: ``(entity_type, canonical_key)`` — the gate's scoring
60
+ # unit and the catalog's ``UNIQUE(tenant_id, entity_type, canonical_key)`` key.
61
+ ConceptPair = tuple[str, str]
62
+
63
+ # People are excluded from the concept gate (spec §17b decision 2): they come
64
+ # from the participants pipeline for free, not the LLM extractor.
65
+ PERSON_ENTITY_TYPE = "person"
66
+
67
+ # Pass thresholds (spec §17b decision 2). Stored as module constants so the gate
68
+ # test and any future surfacing share one source of truth.
69
+ PASS_MICRO_F1 = 0.80
70
+ PASS_PRECISION = 0.85
71
+ PASS_RECALL = 0.70
72
+ PASS_INVALID_RATE = 0.0
73
+
74
+ # Fixture schema version — bump when the YAML shape changes (mirrors
75
+ # :data:`brain.eval.corpus._CORPUS_VERSION`).
76
+ _FIXTURE_VERSION = 1
77
+
78
+ # Default path to the in-repo synthetic labeled fixture. Resolved relative to the
79
+ # package install root exactly like :data:`brain.eval.corpus._DEFAULT_CORPUS_PATH`
80
+ # (eval/ → brain/ → src/ → repo root, then tests/eval/). Unlike the gitignored
81
+ # golden corpus, THIS fixture is synthetic (no PII) and shipped in-repo.
82
+ _DEFAULT_FIXTURE_PATH: Path = (
83
+ Path(__file__).resolve().parent.parent.parent.parent
84
+ / "tests"
85
+ / "eval"
86
+ / "concept_extraction_fixture.yaml"
87
+ )
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class ConceptF1Report:
92
+ """Type-aware concept-set micro-F1 result + the four gate numbers.
93
+
94
+ ``precision`` / ``recall`` / ``micro_f1`` are computed *micro* from the
95
+ summed-across-documents ``true_positives`` / ``false_positives`` /
96
+ ``false_negatives``. ``invalid_json_or_schema_rate`` =
97
+ ``n_invalid_docs / n_docs``. :attr:`passes` applies the spec §17b decision 2
98
+ thresholds.
99
+ """
100
+
101
+ n_docs: int
102
+ n_invalid_docs: int
103
+ true_positives: int
104
+ false_positives: int
105
+ false_negatives: int
106
+ precision: float
107
+ recall: float
108
+ micro_f1: float
109
+ invalid_json_or_schema_rate: float
110
+
111
+ @property
112
+ def passes(self) -> bool:
113
+ """True iff all four gate conditions hold (spec §17b decision 2)."""
114
+ return (
115
+ self.micro_f1 >= PASS_MICRO_F1
116
+ and self.precision >= PASS_PRECISION
117
+ and self.recall >= PASS_RECALL
118
+ and self.invalid_json_or_schema_rate <= PASS_INVALID_RATE
119
+ )
120
+
121
+ def failing_conditions(self) -> list[str]:
122
+ """Human-readable list of the gate conditions this report fails.
123
+
124
+ Empty when :attr:`passes` is True. Used by the gate test to build an
125
+ actionable assertion message.
126
+ """
127
+ failures: list[str] = []
128
+ if self.micro_f1 < PASS_MICRO_F1:
129
+ failures.append(f"micro_f1={self.micro_f1:.4f} < {PASS_MICRO_F1}")
130
+ if self.precision < PASS_PRECISION:
131
+ failures.append(f"precision={self.precision:.4f} < {PASS_PRECISION}")
132
+ if self.recall < PASS_RECALL:
133
+ failures.append(f"recall={self.recall:.4f} < {PASS_RECALL}")
134
+ if self.invalid_json_or_schema_rate > PASS_INVALID_RATE:
135
+ failures.append(
136
+ f"invalid_json_or_schema_rate="
137
+ f"{self.invalid_json_or_schema_rate:.4f} > {PASS_INVALID_RATE}"
138
+ )
139
+ return failures
140
+
141
+
142
+ @dataclass(frozen=True)
143
+ class ConceptFixtureDoc:
144
+ """One synthetic labeled document from the concept-gate fixture.
145
+
146
+ ``text`` is the synthetic body fed to the extractor; ``gold_concepts`` is the
147
+ hand-labeled set of unique ``(entity_type, canonical_key)`` pairs the
148
+ extractor *should* find (people excluded by construction). ``doc_id`` is a
149
+ synthetic identifier for diagnostics only.
150
+ """
151
+
152
+ doc_id: str
153
+ text: str
154
+ gold_concepts: tuple[ConceptPair, ...]
155
+
156
+
157
+ def normalize_concept_pairs(pairs: Iterable[ConceptPair]) -> set[ConceptPair]:
158
+ """Canonicalize a pair iterable into a deduped, people-excluded pair set.
159
+
160
+ The ``canonical_key`` is lower-cased with ALL separators stripped — the
161
+ strip-all rule that mirrors :func:`brain.graph_rag.extract._canonical_key`
162
+ (Bug B) and the catalog identity, so gold/predicted pairs score on the same
163
+ key shape (a human-readable spaced gold key like ``project aurora`` and the
164
+ extractor's strip-all ``projectaurora`` collapse to one identity). The
165
+ ``entity_type`` is lower-cased + whitespace-collapsed (types are single
166
+ words). ``person``-typed pairs and pairs with an empty type or key are dropped
167
+ — so the metric is type-aware *and* people-excluded regardless of what the
168
+ caller passes (spec §17b decision 2).
169
+ """
170
+ out: set[ConceptPair] = set()
171
+ for entity_type, canonical_key in pairs:
172
+ norm_type = " ".join(str(entity_type).lower().split())
173
+ norm_key = re.sub(r"[\W_]+", "", str(canonical_key).lower())
174
+ if not norm_type or not norm_key:
175
+ continue
176
+ if norm_type == PERSON_ENTITY_TYPE:
177
+ continue
178
+ out.add((norm_type, norm_key))
179
+ return out
180
+
181
+
182
+ def concept_set_micro_f1(
183
+ *,
184
+ predicted_per_doc: Sequence[Iterable[ConceptPair]],
185
+ gold_per_doc: Sequence[Iterable[ConceptPair]],
186
+ invalid_doc_flags: Sequence[bool] | None = None,
187
+ ) -> ConceptF1Report:
188
+ """Document-level, type-aware concept-set **micro-F1** (spec §17b decision 2).
189
+
190
+ For each document, the predicted and gold pair iterables are canonicalized +
191
+ people-excluded via :func:`normalize_concept_pairs`. A document flagged
192
+ invalid (malformed model output) contributes **no** predicted pairs — all its
193
+ gold pairs become false negatives — and increments the invalid count. True
194
+ positives / false positives / false negatives are summed *across* documents
195
+ (micro), then precision / recall / F1 are computed from those totals.
196
+
197
+ Args:
198
+ predicted_per_doc: Per-document predicted concept pairs (extractor output).
199
+ gold_per_doc: Per-document hand-labeled gold concept pairs. Must be the
200
+ same length as ``predicted_per_doc``.
201
+ invalid_doc_flags: Optional per-document "extraction was malformed" flags
202
+ (same length). When omitted, no document is treated as invalid.
203
+
204
+ Returns:
205
+ A frozen :class:`ConceptF1Report`.
206
+
207
+ Raises:
208
+ EvalMetricError: When the input sequences are empty or their lengths
209
+ disagree (a corpus-construction bug).
210
+ """
211
+ n_docs = len(gold_per_doc)
212
+ if n_docs == 0:
213
+ raise EvalMetricError("concept_set_micro_f1 requires at least one document")
214
+ if len(predicted_per_doc) != n_docs:
215
+ raise EvalMetricError(
216
+ f"predicted_per_doc / gold_per_doc length mismatch: "
217
+ f"{len(predicted_per_doc)} != {n_docs}"
218
+ )
219
+ if invalid_doc_flags is None:
220
+ flags: Sequence[bool] = [False] * n_docs
221
+ else:
222
+ if len(invalid_doc_flags) != n_docs:
223
+ raise EvalMetricError(
224
+ f"invalid_doc_flags length mismatch: {len(invalid_doc_flags)} != {n_docs}"
225
+ )
226
+ flags = invalid_doc_flags
227
+
228
+ true_positives = 0
229
+ false_positives = 0
230
+ false_negatives = 0
231
+ n_invalid_docs = 0
232
+
233
+ for i in range(n_docs):
234
+ gold = normalize_concept_pairs(gold_per_doc[i])
235
+ if flags[i]:
236
+ n_invalid_docs += 1
237
+ predicted: set[ConceptPair] = set() # malformed output → no usable pairs
238
+ else:
239
+ predicted = normalize_concept_pairs(predicted_per_doc[i])
240
+ true_positives += len(predicted & gold)
241
+ false_positives += len(predicted - gold)
242
+ false_negatives += len(gold - predicted)
243
+
244
+ precision = (
245
+ true_positives / (true_positives + false_positives)
246
+ if (true_positives + false_positives) > 0
247
+ else 0.0
248
+ )
249
+ recall = (
250
+ true_positives / (true_positives + false_negatives)
251
+ if (true_positives + false_negatives) > 0
252
+ else 0.0
253
+ )
254
+ # micro-F1 = 2·TP / (2·TP + FP + FN) — algebraically identical to the
255
+ # harmonic mean of the micro precision/recall, computed directly to avoid
256
+ # double float rounding.
257
+ f1_denominator = 2 * true_positives + false_positives + false_negatives
258
+ micro_f1 = (2 * true_positives / f1_denominator) if f1_denominator > 0 else 0.0
259
+ invalid_rate = n_invalid_docs / n_docs
260
+
261
+ return ConceptF1Report(
262
+ n_docs=n_docs,
263
+ n_invalid_docs=n_invalid_docs,
264
+ true_positives=true_positives,
265
+ false_positives=false_positives,
266
+ false_negatives=false_negatives,
267
+ precision=precision,
268
+ recall=recall,
269
+ micro_f1=micro_f1,
270
+ invalid_json_or_schema_rate=invalid_rate,
271
+ )
272
+
273
+
274
+ def load_concept_fixture(path: Path | None = None) -> list[ConceptFixtureDoc]:
275
+ """Load + validate the synthetic concept-gate fixture YAML.
276
+
277
+ Schema::
278
+
279
+ version: 1
280
+ docs:
281
+ - id: synth-billing-001
282
+ text: "..."
283
+ gold:
284
+ - {type: org, key: acmepay}
285
+ - {type: topic, key: billing}
286
+
287
+ Every ``gold.type`` must be one of the four concept types
288
+ (:data:`brain.graph_rag.extract.CONCEPT_ENTITY_TYPES`) — ``person`` is a
289
+ fixture-authoring error (people are excluded by design), as is any unknown
290
+ type. Gold pairs are returned canonicalized + deduped per document.
291
+
292
+ Args:
293
+ path: Path to the fixture YAML. Defaults to the in-repo synthetic file.
294
+
295
+ Returns:
296
+ Parsed list of :class:`ConceptFixtureDoc`.
297
+
298
+ Raises:
299
+ EvalCorpusError: On a missing / malformed file, version mismatch, missing
300
+ required fields, an empty gold set, or an invalid ``gold.type``.
301
+ """
302
+ # Lazy import keeps the pure metric path (``from brain.eval import
303
+ # concept_set_micro_f1``) free of any brain.graph_rag dependency; only the
304
+ # fixture loader needs the concept-type allowlist.
305
+ from ..graph_rag.extract import CONCEPT_ENTITY_TYPES
306
+
307
+ if path is None:
308
+ path = _DEFAULT_FIXTURE_PATH
309
+ if not path.exists():
310
+ raise EvalCorpusError(f"concept fixture not found: {path}")
311
+
312
+ try:
313
+ raw = yaml.safe_load(path.read_text(encoding="utf-8"))
314
+ except yaml.YAMLError as exc:
315
+ raise EvalCorpusError(f"concept fixture YAML parse error in {path}: {exc}") from exc
316
+
317
+ if not isinstance(raw, dict):
318
+ raise EvalCorpusError(
319
+ f"concept fixture must be a YAML mapping, got {type(raw).__name__}"
320
+ )
321
+ if raw.get("version") != _FIXTURE_VERSION:
322
+ raise EvalCorpusError(
323
+ f"concept fixture version mismatch: expected {_FIXTURE_VERSION}, "
324
+ f"got {raw.get('version')!r}"
325
+ )
326
+ raw_docs = raw.get("docs")
327
+ if not isinstance(raw_docs, list) or not raw_docs:
328
+ raise EvalCorpusError("concept fixture must have a non-empty 'docs' list")
329
+
330
+ docs: list[ConceptFixtureDoc] = []
331
+ for i, entry in enumerate(raw_docs):
332
+ if not isinstance(entry, dict):
333
+ raise EvalCorpusError(
334
+ f"concept fixture doc #{i + 1} must be a mapping, "
335
+ f"got {type(entry).__name__}"
336
+ )
337
+ doc_id = entry.get("id")
338
+ text = entry.get("text")
339
+ gold_raw = entry.get("gold")
340
+ if not isinstance(doc_id, str) or not doc_id.strip():
341
+ raise EvalCorpusError(f"concept fixture doc #{i + 1} missing a string 'id'")
342
+ if not isinstance(text, str) or not text.strip():
343
+ raise EvalCorpusError(
344
+ f"concept fixture doc {doc_id!r} missing a non-empty 'text'"
345
+ )
346
+ if not isinstance(gold_raw, list) or not gold_raw:
347
+ raise EvalCorpusError(
348
+ f"concept fixture doc {doc_id!r} must have a non-empty 'gold' list"
349
+ )
350
+ gold_pairs: list[ConceptPair] = []
351
+ for j, pair in enumerate(gold_raw):
352
+ if not isinstance(pair, dict):
353
+ raise EvalCorpusError(
354
+ f"concept fixture doc {doc_id!r} gold #{j + 1} must be a mapping"
355
+ )
356
+ etype = pair.get("type")
357
+ key = pair.get("key")
358
+ if not isinstance(etype, str) or not isinstance(key, str):
359
+ raise EvalCorpusError(
360
+ f"concept fixture doc {doc_id!r} gold #{j + 1} needs string "
361
+ f"'type' and 'key'"
362
+ )
363
+ norm_type = etype.strip().lower()
364
+ if norm_type not in CONCEPT_ENTITY_TYPES:
365
+ raise EvalCorpusError(
366
+ f"concept fixture doc {doc_id!r} gold #{j + 1} has invalid type "
367
+ f"{etype!r}; allowed: {', '.join(sorted(CONCEPT_ENTITY_TYPES))} "
368
+ f"(people are excluded by design)"
369
+ )
370
+ gold_pairs.append((norm_type, key))
371
+ docs.append(
372
+ ConceptFixtureDoc(
373
+ doc_id=doc_id,
374
+ text=text,
375
+ gold_concepts=tuple(sorted(normalize_concept_pairs(gold_pairs))),
376
+ )
377
+ )
378
+ return docs
brain/eval/corpus.py ADDED
@@ -0,0 +1,152 @@
1
+ """EvalQuery dataclass and golden-corpus YAML loader."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ import yaml
7
+
8
+ from .errors import EvalCorpusError
9
+
10
+ # Path to the local golden-corpus YAML, resolved relative to the package
11
+ # install root. From corpus.py: eval/ → brain/ → src/ → repo root, then
12
+ # descend into tests/eval/. The file is gitignored — each developer
13
+ # authors their own set tailored to their brain's seeded documents (real
14
+ # corpus = real doc IDs). ``load_corpus`` raises ``EvalCorpusError`` when
15
+ # the default path doesn't exist; callers that want to operate without a
16
+ # local corpus should pass ``--corpus`` explicitly.
17
+ _DEFAULT_CORPUS_PATH: Path = (
18
+ Path(__file__).resolve().parent.parent.parent.parent
19
+ / "tests"
20
+ / "eval"
21
+ / "golden_corpus.yaml"
22
+ )
23
+
24
+ _VALID_CATEGORIES: frozenset[str] = frozenset(
25
+ (
26
+ "people",
27
+ "meeting",
28
+ "email",
29
+ "interview",
30
+ "source-specific",
31
+ "recency",
32
+ "semantic",
33
+ )
34
+ )
35
+
36
+ _REQUIRED_FIELDS: frozenset[str] = frozenset(("query", "expected_doc_ids", "category"))
37
+ _ALLOWED_FIELDS: frozenset[str] = frozenset(
38
+ ("query", "expected_doc_ids", "category", "source_filter", "tag_filter", "since_days", "notes")
39
+ )
40
+ _CORPUS_VERSION = 1
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class EvalQuery:
45
+ """A single query from the golden eval corpus."""
46
+
47
+ query: str
48
+ expected_doc_ids: list[str] # full UUIDs OR 8-char hex prefixes; resolved at run time
49
+ category: str # one of _VALID_CATEGORIES
50
+ source_filter: str | None = None # mirrors hybrid_search's source_kind kwarg
51
+ tag_filter: str | None = None # mirrors hybrid_search's tag kwarg
52
+ since_days: int | None = None # mirrors hybrid_search's since_days kwarg
53
+ notes: str = "" # human-readable rationale, not graded
54
+
55
+
56
+ def load_corpus(path: Path | None = None) -> list[EvalQuery]:
57
+ """Load and validate an eval corpus YAML file.
58
+
59
+ Args:
60
+ path: Path to the YAML file. Defaults to ``_DEFAULT_CORPUS_PATH``.
61
+
62
+ Returns:
63
+ Parsed list of :class:`EvalQuery` objects.
64
+
65
+ Raises:
66
+ EvalCorpusError: On missing/malformed file, version mismatch, unknown
67
+ category, missing required fields, or empty ``expected_doc_ids``.
68
+ """
69
+ if path is None:
70
+ path = _DEFAULT_CORPUS_PATH
71
+
72
+ if not path.exists():
73
+ raise EvalCorpusError(f"corpus file not found: {path}")
74
+
75
+ try:
76
+ raw = yaml.safe_load(path.read_text(encoding="utf-8"))
77
+ except yaml.YAMLError as exc:
78
+ raise EvalCorpusError(f"corpus YAML parse error in {path}: {exc}") from exc
79
+
80
+ if not isinstance(raw, dict):
81
+ raise EvalCorpusError(
82
+ f"corpus file must be a YAML mapping, got {type(raw).__name__}"
83
+ )
84
+
85
+ version = raw.get("version")
86
+ if version != _CORPUS_VERSION:
87
+ raise EvalCorpusError(
88
+ f"corpus version mismatch: expected {_CORPUS_VERSION}, got {version!r}. "
89
+ f"Migrate the corpus file to version {_CORPUS_VERSION}."
90
+ )
91
+
92
+ raw_queries = raw.get("queries")
93
+ if not isinstance(raw_queries, list):
94
+ raise EvalCorpusError("corpus file must have a 'queries' list")
95
+
96
+ queries: list[EvalQuery] = []
97
+ for i, entry in enumerate(raw_queries):
98
+ if not isinstance(entry, dict):
99
+ raise EvalCorpusError(
100
+ f"corpus query #{i + 1} must be a YAML mapping, "
101
+ f"got {type(entry).__name__}"
102
+ )
103
+
104
+ missing = _REQUIRED_FIELDS - entry.keys()
105
+ if missing:
106
+ raise EvalCorpusError(
107
+ f"corpus query #{i + 1} is missing required field(s): "
108
+ f"{', '.join(sorted(missing))}"
109
+ )
110
+
111
+ unknown = entry.keys() - _ALLOWED_FIELDS
112
+ if unknown:
113
+ raise EvalCorpusError(
114
+ f"corpus query #{i + 1} has unknown field(s): "
115
+ f"{', '.join(sorted(unknown))}. "
116
+ f"Allowed: {', '.join(sorted(_ALLOWED_FIELDS))}"
117
+ )
118
+
119
+ category = entry["category"]
120
+ if category not in _VALID_CATEGORIES:
121
+ raise EvalCorpusError(
122
+ f"corpus query #{i + 1} has unknown category {category!r}. "
123
+ f"Valid categories: {', '.join(sorted(_VALID_CATEGORIES))}"
124
+ )
125
+
126
+ expected_doc_ids = entry["expected_doc_ids"]
127
+ if not isinstance(expected_doc_ids, list):
128
+ raise EvalCorpusError(
129
+ f"corpus query #{i + 1} 'expected_doc_ids' must be a list, "
130
+ f"got {type(expected_doc_ids).__name__}"
131
+ )
132
+ if not expected_doc_ids:
133
+ raise EvalCorpusError(
134
+ f"corpus query #{i + 1} ({entry['query']!r}) has empty "
135
+ f"'expected_doc_ids' — curate the corpus with "
136
+ f"`brain search \"<query>\" --json --limit 20` before running eval"
137
+ )
138
+
139
+ since_raw = entry.get("since_days")
140
+ queries.append(
141
+ EvalQuery(
142
+ query=str(entry["query"]),
143
+ expected_doc_ids=[str(doc_id) for doc_id in expected_doc_ids],
144
+ category=category,
145
+ source_filter=entry.get("source_filter") or None,
146
+ tag_filter=entry.get("tag_filter") or None,
147
+ since_days=int(since_raw) if since_raw is not None else None,
148
+ notes=str(entry.get("notes", "")),
149
+ )
150
+ )
151
+
152
+ return queries
brain/eval/errors.py ADDED
@@ -0,0 +1,19 @@
1
+ """Eval-specific exception hierarchy (all inherit from BrainError)."""
2
+
3
+ from brain.errors import BrainError
4
+
5
+
6
+ class EvalError(BrainError):
7
+ """Base class for all eval-package exceptions."""
8
+
9
+
10
+ class EvalMetricError(EvalError):
11
+ """Raised when a metric function receives invalid inputs (e.g. empty expected set)."""
12
+
13
+
14
+ class EvalCorpusError(EvalError):
15
+ """Raised on corpus YAML validation failures (unknown category, missing field, etc.)."""
16
+
17
+
18
+ class EvalBaselineError(EvalError):
19
+ """Raised when a baseline file is missing, unreadable, or malformed."""