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,372 @@
1
+ """Scoped-subgraph theme grouping — pure-Python CC + bridge guard (wave G2-e).
2
+
3
+ Pure logic, no DB, no I/O. Implements spec §6b step 3 / §17b Q1 (Codex-ruled):
4
+ group the small *scoped* subgraph of theme-eligible entities into
5
+ :class:`~brain.graph_rag.schema.ThemeGroup`s using
6
+
7
+ 1. **Edge threshold** — keep a scoped edge iff its ``normalized_lift`` weight
8
+ ``>= min_edge_weight`` (cfg ``graph_min_edge_weight``, default ``0.20``).
9
+ 2. **Bridge guard** — over the thresholded graph, drop a graph-theoretic *bridge*
10
+ edge IFF removing it leaves two sides EACH with ``>= 2`` theme-eligible
11
+ entities AND the bridge ``weight < bridge_keep_weight`` (``0.50``). KEEP
12
+ bridges with ``weight >= bridge_keep_weight``; KEEP *leaf* bridges (either
13
+ side of size ``1``). Non-bridge edges are never dropped here.
14
+ 3. **Connected components** — over the post-guard graph; each component (incl.
15
+ isolated theme-eligible entities as singleton groups) becomes one
16
+ :class:`ThemeGroup`.
17
+ 4. **Deterministic ranking** — groups ordered by size DESC, then internal
18
+ cohesion (sum of surviving in-group edge weights) DESC, then the
19
+ lexicographic member key. ``theme_limit`` truncates the ranked list.
20
+
21
+ **networkx is intentionally NOT used.** The spec pulls it into G2 only if this
22
+ pure implementation of the same threshold/bridge/CC rules fails the acceptance
23
+ fixture (``tests/test_graphrag_grouping.py``) — it does not. Bridge detection is
24
+ an iterative Tarjan low-link sweep; components are an iterative BFS.
25
+
26
+ Inputs are the existing schema value objects: ``entities`` are the scoped,
27
+ already-theme-eligible :class:`~brain.graph_rag.schema.GraphEntity` rows (the
28
+ *caller* — G2-f — excludes the seed X / owner / generic entities, spec §17.5),
29
+ and ``edges`` are the in-scope :class:`~brain.graph_rag.schema.Edge` rows whose
30
+ ``weight`` is the in-scope normalized lift. The function reads only ``id`` /
31
+ ``canonical_key`` / ``entity_type`` from entities and ``src_id`` / ``dst_id`` /
32
+ ``weight`` from edges; it never mutates the caller's sequences.
33
+ """
34
+ from __future__ import annotations
35
+
36
+ from collections import deque
37
+ from collections.abc import Iterable, Iterator, Sequence
38
+
39
+ from ..errors import GroupingError
40
+ from .schema import Edge, GraphEntity, ThemeGroup
41
+
42
+ __all__ = [
43
+ "DEFAULT_BRIDGE_KEEP_WEIGHT",
44
+ "DEFAULT_MIN_EDGE_WEIGHT",
45
+ "group_themes",
46
+ ]
47
+
48
+ # Normalized-lift floor for keeping a scoped edge. Mirrors
49
+ # ``config.DEFAULT_GRAPH_MIN_EDGE_WEIGHT`` / ``cfg.graph_min_edge_weight``
50
+ # (spec §17b Q1/Q5). Defined locally (like ``cooccur.DEFAULT_COOCCUR_WINDOW``
51
+ # and ``weighting.DEFAULT_GENERIC_DF``) so the pure-logic layer carries no
52
+ # runtime dependency on :mod:`brain.config`; production passes the cfg value.
53
+ DEFAULT_MIN_EDGE_WEIGHT = 0.20
54
+
55
+ # Bridge weight at/above which a bridge is always kept (spec §17b Q1). A bridge
56
+ # below this is dropped only when both sides have ``>= 2`` theme-eligible
57
+ # entities (a leaf bridge — one side of size 1 — is always kept).
58
+ DEFAULT_BRIDGE_KEEP_WEIGHT = 0.50
59
+
60
+
61
+ def group_themes(
62
+ entities: Sequence[GraphEntity],
63
+ edges: Sequence[Edge],
64
+ *,
65
+ min_edge_weight: float = DEFAULT_MIN_EDGE_WEIGHT,
66
+ bridge_keep_weight: float = DEFAULT_BRIDGE_KEEP_WEIGHT,
67
+ theme_limit: int | None = None,
68
+ ) -> list[ThemeGroup]:
69
+ """Group a scoped subgraph into ranked :class:`ThemeGroup`s (spec §6b/§17b Q1).
70
+
71
+ Args:
72
+ entities: The scoped, already theme-eligible entities. Each defines a
73
+ node; every entity lands in exactly one group (an entity with no
74
+ surviving edge forms a singleton group). Duplicate ids are collapsed
75
+ (first occurrence wins). The sequence is not mutated.
76
+ edges: In-scope undirected weighted edges (``weight`` = normalized lift
77
+ in ``(0, 1]``). Edges referencing an entity absent from ``entities``,
78
+ and self-loops, are ignored. A duplicate endpoint pair keeps its
79
+ maximum weight (deterministic). The sequence is not mutated.
80
+ min_edge_weight: Keep an edge iff ``weight >= min_edge_weight``
81
+ (default :data:`DEFAULT_MIN_EDGE_WEIGHT`). Must be in ``[0.0, 1.0]``.
82
+ bridge_keep_weight: Bridges with ``weight >= bridge_keep_weight`` are
83
+ always kept (default :data:`DEFAULT_BRIDGE_KEEP_WEIGHT`). Must be in
84
+ ``[0.0, 1.0]``.
85
+ theme_limit: When set, return only the top ``theme_limit`` ranked groups
86
+ (cfg ``graph_theme_limit``, default 5 in production). ``None`` keeps
87
+ all groups. Must be ``>= 1`` when set.
88
+
89
+ Returns:
90
+ Ranked ``ThemeGroup``s: ``group_id`` is the 0-based rank, ``entities`` is
91
+ the group's members (sorted), ``score`` is the group's internal cohesion
92
+ (sum of surviving in-group edge weights), ``doc_ids`` / ``summary`` are
93
+ left at their defaults (populated by the G2-f caller). Membership and
94
+ ordering are deterministic — invariant under input reordering.
95
+
96
+ Raises:
97
+ GroupingError: ``min_edge_weight`` / ``bridge_keep_weight`` outside
98
+ ``[0.0, 1.0]``, or ``theme_limit`` set ``< 1``.
99
+ """
100
+ _validate_params(min_edge_weight, bridge_keep_weight, theme_limit)
101
+
102
+ # First-occurrence-wins dedup so a stray duplicate entity row is deterministic.
103
+ entity_by_id: dict[str, GraphEntity] = {}
104
+ for entity in entities:
105
+ entity_by_id.setdefault(entity.id, entity)
106
+
107
+ weights = _thresholded_edge_weights(edges, entity_by_id.keys(), min_edge_weight)
108
+ adjacency = _build_adjacency(entity_by_id.keys(), weights)
109
+
110
+ bridges = _find_bridges(entity_by_id.keys(), adjacency)
111
+ dropped = _bridges_to_drop(bridges, weights, adjacency, bridge_keep_weight)
112
+ surviving = {pair: w for pair, w in weights.items() if pair not in dropped}
113
+ final_adjacency = _build_adjacency(entity_by_id.keys(), surviving)
114
+
115
+ components = _connected_components(entity_by_id.keys(), final_adjacency)
116
+ groups = _build_groups(components, entity_by_id, surviving)
117
+ if theme_limit is not None:
118
+ return groups[:theme_limit]
119
+ return groups
120
+
121
+
122
+ # --------------------------------------------------------------------------- #
123
+ # Validation
124
+ # --------------------------------------------------------------------------- #
125
+ def _validate_params(
126
+ min_edge_weight: float, bridge_keep_weight: float, theme_limit: int | None
127
+ ) -> None:
128
+ """Fail fast on degenerate grouping knobs (caller bug, no DB touched)."""
129
+ if not 0.0 <= min_edge_weight <= 1.0:
130
+ raise GroupingError(
131
+ f"min_edge_weight must be in [0.0, 1.0] (got {min_edge_weight})"
132
+ )
133
+ if not 0.0 <= bridge_keep_weight <= 1.0:
134
+ raise GroupingError(
135
+ f"bridge_keep_weight must be in [0.0, 1.0] (got {bridge_keep_weight})"
136
+ )
137
+ if theme_limit is not None and theme_limit < 1:
138
+ raise GroupingError(
139
+ f"theme_limit must be a positive integer or None (got {theme_limit})"
140
+ )
141
+
142
+
143
+ # --------------------------------------------------------------------------- #
144
+ # Graph construction (canonical, deduped, threshold-filtered)
145
+ # --------------------------------------------------------------------------- #
146
+ def _canonical(a: str, b: str) -> tuple[str, str]:
147
+ """Order an endpoint pair ``(lo, hi)`` so each undirected edge has one key."""
148
+ return (a, b) if a < b else (b, a)
149
+
150
+
151
+ def _thresholded_edge_weights(
152
+ edges: Sequence[Edge], node_ids: Iterable[str], min_edge_weight: float
153
+ ) -> dict[tuple[str, str], float]:
154
+ """Canonical-pair → weight for edges that survive the threshold.
155
+
156
+ Drops self-loops, edges with an endpoint outside the node set, and edges
157
+ below ``min_edge_weight``. A duplicate pair keeps its maximum weight so the
158
+ result is independent of input order.
159
+ """
160
+ nodes = set(node_ids)
161
+ weights: dict[tuple[str, str], float] = {}
162
+ for edge in edges:
163
+ if edge.src_id == edge.dst_id:
164
+ continue # no self loops
165
+ if edge.src_id not in nodes or edge.dst_id not in nodes:
166
+ continue # endpoint not in the scoped node set
167
+ if edge.weight < min_edge_weight:
168
+ continue
169
+ pair = _canonical(edge.src_id, edge.dst_id)
170
+ prior = weights.get(pair)
171
+ if prior is None or edge.weight > prior:
172
+ weights[pair] = edge.weight
173
+ return weights
174
+
175
+
176
+ def _build_adjacency(
177
+ node_ids: Iterable[str], weights: dict[tuple[str, str], float]
178
+ ) -> dict[str, list[str]]:
179
+ """Undirected adjacency over all nodes; lists sorted for stable traversal."""
180
+ adjacency: dict[str, set[str]] = {node: set() for node in node_ids}
181
+ for src, dst in weights:
182
+ adjacency[src].add(dst)
183
+ adjacency[dst].add(src)
184
+ return {node: sorted(neighbours) for node, neighbours in adjacency.items()}
185
+
186
+
187
+ # --------------------------------------------------------------------------- #
188
+ # Bridge detection (iterative Tarjan low-link; deduped ⇒ simple parent skip)
189
+ # --------------------------------------------------------------------------- #
190
+ def _find_bridges(
191
+ node_ids: Iterable[str], adjacency: dict[str, list[str]]
192
+ ) -> set[tuple[str, str]]:
193
+ """Return the canonical pairs that are graph-theoretic bridges.
194
+
195
+ A *bridge* is an edge whose removal increases the connected-component count.
196
+ Iterative DFS computes discovery times ``disc`` and low-link values ``low``;
197
+ a tree edge ``(parent, child)`` is a bridge iff ``low[child] > disc[parent]``.
198
+ Edges are deduped to a single edge per pair upstream, so skipping one
199
+ parent-edge occurrence is correct (no parallel-edge confusion). The bridge
200
+ set is a graph invariant — independent of the DFS start/visit order.
201
+ """
202
+ disc: dict[str, int] = {}
203
+ low: dict[str, int] = {}
204
+ bridges: set[tuple[str, str]] = set()
205
+ timer = 0
206
+
207
+ for start in sorted(node_ids):
208
+ if start in disc:
209
+ continue
210
+ timer = _dfs_bridges(start, adjacency, disc, low, bridges, timer)
211
+ return bridges
212
+
213
+
214
+ def _dfs_bridges(
215
+ start: str,
216
+ adjacency: dict[str, list[str]],
217
+ disc: dict[str, int],
218
+ low: dict[str, int],
219
+ bridges: set[tuple[str, str]],
220
+ timer: int,
221
+ ) -> int:
222
+ """Iterative DFS from ``start`` populating ``disc``/``low``/``bridges``.
223
+
224
+ Returns the advanced ``timer``. The explicit stack holds
225
+ ``(node, parent, neighbour-iterator)`` frames so recursion depth is bounded
226
+ by the heap, not the Python call stack (safe for long path-graphs).
227
+ """
228
+ disc[start] = low[start] = timer
229
+ timer += 1
230
+ stack: list[tuple[str, str | None, Iterator[str]]] = [
231
+ (start, None, iter(adjacency[start]))
232
+ ]
233
+ while stack:
234
+ node, parent, neighbours = stack[-1]
235
+ descended = False
236
+ for nbr in neighbours:
237
+ if nbr == parent:
238
+ continue # the single edge back to the DFS parent
239
+ if nbr not in disc:
240
+ disc[nbr] = low[nbr] = timer
241
+ timer += 1
242
+ stack.append((nbr, node, iter(adjacency[nbr])))
243
+ descended = True
244
+ break
245
+ low[node] = min(low[node], disc[nbr])
246
+ if descended:
247
+ continue
248
+ stack.pop()
249
+ if stack:
250
+ par = stack[-1][0]
251
+ low[par] = min(low[par], low[node])
252
+ if low[node] > disc[par]:
253
+ bridges.add(_canonical(par, node))
254
+ return timer
255
+
256
+
257
+ def _bridges_to_drop(
258
+ bridges: set[tuple[str, str]],
259
+ weights: dict[tuple[str, str], float],
260
+ adjacency: dict[str, list[str]],
261
+ bridge_keep_weight: float,
262
+ ) -> set[tuple[str, str]]:
263
+ """Apply the §17b Q1 bridge guard; return the bridge pairs to drop.
264
+
265
+ Each bridge is evaluated against the *thresholded* graph (only that one
266
+ bridge removed): dropped iff its weight ``< bridge_keep_weight`` AND both
267
+ resulting sides hold ``>= 2`` theme-eligible entities. Bridges at/above the
268
+ keep weight, and leaf bridges (a side of size 1), are kept. All qualifying
269
+ bridges are dropped together.
270
+ """
271
+ dropped: set[tuple[str, str]] = set()
272
+ for pair in bridges:
273
+ if weights[pair] >= bridge_keep_weight:
274
+ continue # strong bridge — always kept
275
+ src, dst = pair
276
+ src_side = _reachable_excluding_edge(adjacency, src, pair)
277
+ dst_side = _reachable_excluding_edge(adjacency, dst, pair)
278
+ if len(src_side) >= 2 and len(dst_side) >= 2:
279
+ dropped.add(pair)
280
+ # else: at least one leaf side — keep the bridge.
281
+ return dropped
282
+
283
+
284
+ def _reachable_excluding_edge(
285
+ adjacency: dict[str, list[str]], start: str, excluded: tuple[str, str]
286
+ ) -> set[str]:
287
+ """Nodes reachable from ``start`` without traversing the ``excluded`` edge.
288
+
289
+ Used to size the two sides a bridge separates. For a true bridge the start
290
+ side and the other endpoint's side are disjoint and partition the component.
291
+ """
292
+ seen = {start}
293
+ queue = deque([start])
294
+ while queue:
295
+ node = queue.popleft()
296
+ for nbr in adjacency[node]:
297
+ if _canonical(node, nbr) == excluded:
298
+ continue # the bridge under evaluation is removed
299
+ if nbr not in seen:
300
+ seen.add(nbr)
301
+ queue.append(nbr)
302
+ return seen
303
+
304
+
305
+ # --------------------------------------------------------------------------- #
306
+ # Connected components + group assembly (deterministic ranking)
307
+ # --------------------------------------------------------------------------- #
308
+ def _connected_components(
309
+ node_ids: Iterable[str], adjacency: dict[str, list[str]]
310
+ ) -> list[list[str]]:
311
+ """Connected components over ``adjacency``; isolated nodes are singletons.
312
+
313
+ Iterative BFS seeded in sorted node order, so component discovery order is
314
+ deterministic (final group ordering is re-sorted by rank regardless).
315
+ """
316
+ seen: set[str] = set()
317
+ components: list[list[str]] = []
318
+ for start in sorted(node_ids):
319
+ if start in seen:
320
+ continue
321
+ component: list[str] = []
322
+ queue = deque([start])
323
+ seen.add(start)
324
+ while queue:
325
+ node = queue.popleft()
326
+ component.append(node)
327
+ for nbr in adjacency[node]:
328
+ if nbr not in seen:
329
+ seen.add(nbr)
330
+ queue.append(nbr)
331
+ components.append(component)
332
+ return components
333
+
334
+
335
+ def _build_groups(
336
+ components: list[list[str]],
337
+ entity_by_id: dict[str, GraphEntity],
338
+ surviving: dict[tuple[str, str], float],
339
+ ) -> list[ThemeGroup]:
340
+ """Build ranked ``ThemeGroup``s from components + surviving-edge cohesion.
341
+
342
+ Each group's ``score`` is the sum of surviving edge weights internal to it
343
+ (singletons score ``0.0``). Groups rank by size DESC, score DESC, then the
344
+ lexicographic member key ``(canonical_key, entity_type, id)`` ASC — a total
345
+ order over disjoint membership, so ranking is fully deterministic.
346
+ ``group_id`` is the resulting 0-based rank.
347
+ """
348
+ component_of = {
349
+ node: idx for idx, component in enumerate(components) for node in component
350
+ }
351
+ # Accumulate in sorted-pair order so the float sum is byte-identical
352
+ # regardless of the caller's edge ordering (determinism contract).
353
+ scores = [0.0] * len(components)
354
+ for (src, _dst), weight in sorted(surviving.items()):
355
+ scores[component_of[src]] += weight
356
+
357
+ ranked: list[tuple[float, list[GraphEntity]]] = []
358
+ sort_keys: list[tuple[int, float, tuple[tuple[str, str, str], ...]]] = []
359
+ for idx, component in enumerate(components):
360
+ members = sorted(
361
+ (entity_by_id[node] for node in component),
362
+ key=lambda e: (e.canonical_key, e.entity_type, e.id),
363
+ )
364
+ member_key = tuple((e.canonical_key, e.entity_type, e.id) for e in members)
365
+ sort_keys.append((-len(members), -scores[idx], member_key))
366
+ ranked.append((scores[idx], members))
367
+
368
+ order = sorted(range(len(ranked)), key=lambda i: sort_keys[i])
369
+ return [
370
+ ThemeGroup(group_id=rank, entities=ranked[i][1], score=ranked[i][0])
371
+ for rank, i in enumerate(order)
372
+ ]
@@ -0,0 +1,167 @@
1
+ """Default person resolver for the graph people aspect (spec §3 reuse map).
2
+
3
+ Extracted from :mod:`brain.graph_rag.reconcile` (Phase 1, 2026-05-23) to keep
4
+ that module under the 800-line cap. Holds the :class:`ResolvedPerson` value
5
+ object and :func:`default_person_resolver` — the production
6
+ :class:`~brain.graph_rag.reconcile.PersonResolver` implementation that derives a
7
+ document's person set from the existing People-Hub pipeline so the graph's
8
+ person roster for a document can never drift from its People-Hub roster.
9
+
10
+ Both are re-exported from :mod:`brain.graph_rag.reconcile` for backwards
11
+ compatibility, so existing imports
12
+ (``from brain.graph_rag.reconcile import ResolvedPerson, default_person_resolver``)
13
+ keep working.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ import psycopg
21
+
22
+ if TYPE_CHECKING:
23
+ from ..wiki.build_people import _DirectoryIndex
24
+ from .reconcile import PersonResolver
25
+
26
+ __all__ = [
27
+ "ResolvedPerson",
28
+ "default_person_resolver",
29
+ "prebuilt_directory_resolver",
30
+ ]
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class ResolvedPerson:
35
+ """One person resolved for a document — the input to entity upsert.
36
+
37
+ ``canonical_key`` is the normalized lowercase display name (the People-Hub
38
+ canonical identity, unique per ``(tenant_id, entity_type, canonical_key)``);
39
+ ``display_name`` is its humanized presentation form (stored as
40
+ ``graph_entities.name``).
41
+ """
42
+
43
+ canonical_key: str
44
+ display_name: str
45
+
46
+
47
+ def default_person_resolver(
48
+ conn: psycopg.Connection[Any],
49
+ document_id: str,
50
+ *,
51
+ owner_keys: frozenset[str] = frozenset(),
52
+ sender_denylist: frozenset[str] = frozenset(),
53
+ directory: _DirectoryIndex | None = None,
54
+ ) -> list[ResolvedPerson]:
55
+ """Derive a document's person set from the existing People-Hub pipeline.
56
+
57
+ Reuses :mod:`brain.wiki.build_people`'s directory index + per-doc participant
58
+ extraction + key resolution (and the shared
59
+ :mod:`brain.wiki._person_name` normalizer) so the graph's person roster for
60
+ a document is identical to its People-Hub roster (spec §3 reuse map). Owner
61
+ keys are expanded (first-name-only + email-local-part variants) then
62
+ stripped both before resolution (raw participant key) and after (resolved
63
+ canonical key), matching ``aggregate_people``'s owner filter. Automated /
64
+ org senders are dropped by the directory index (``sender_denylist`` adds
65
+ ``BRAIN_GRAPH_SENDER_DENYLIST`` extras to the always-on generic heuristic).
66
+ Returns the deduplicated persons sorted by ``canonical_key`` for
67
+ determinism.
68
+
69
+ ``directory`` (perf Fix B, 2026-05-24) is an optional prebuilt
70
+ :class:`brain.wiki.build_people._DirectoryIndex`. The directory is
71
+ corpus-wide and does NOT change mid-build, so the batch
72
+ :func:`brain.graph_rag.build.build_graph` builds it ONCE and passes it in
73
+ (via :func:`prebuilt_directory_resolver`) instead of paying the ~1.2k-row
74
+ ``SELECT`` + dict build on every document. When ``None`` (the incremental
75
+ ingest hook's default) the index is built here from ``directory_entries`` for
76
+ this single call — preserving the per-document path. A caller supplying
77
+ ``directory`` is responsible for having built it with the same
78
+ ``sender_denylist`` it passes here (``build_graph`` does, from the one shared
79
+ config); the passed ``sender_denylist`` is otherwise unused once a prebuilt
80
+ index is supplied.
81
+
82
+ Returns an empty list when the document does not exist or has no resolvable
83
+ participants (e.g. a manual note, or a Gmail header with no directory match).
84
+ """
85
+ # Late import keeps :mod:`brain.graph_rag` import-cheap and avoids a cycle
86
+ # with the wiki package, mirroring ``queries.resolve_person_to_keys``.
87
+ from ..wiki._person_name import expand_owner_keys
88
+ from ..wiki.build_people import (
89
+ _build_directory_index,
90
+ _doc_participant_keys,
91
+ _resolve_key_to_person,
92
+ humanize_display_name,
93
+ )
94
+
95
+ row = conn.execute(
96
+ "SELECT s.kind, d.metadata FROM documents d "
97
+ "JOIN sources s ON s.id = d.source_id WHERE d.id = %s",
98
+ (document_id,),
99
+ ).fetchone()
100
+ if row is None:
101
+ return []
102
+ source_kind, raw_metadata = row
103
+ metadata: dict[str, Any] = dict(raw_metadata) if raw_metadata else {}
104
+
105
+ expanded_owner = expand_owner_keys(owner_keys)
106
+ if directory is None:
107
+ directory = _build_directory_index(conn, sender_denylist=sender_denylist)
108
+ raw_keys = _doc_participant_keys(source_kind=source_kind, metadata=metadata)
109
+
110
+ resolved: dict[str, str] = {}
111
+ for key in raw_keys:
112
+ if key.strip().lower() in expanded_owner:
113
+ continue
114
+ canonical = _resolve_key_to_person(key, directory=directory)
115
+ if canonical is None or canonical in expanded_owner:
116
+ continue
117
+ # Record-level owner filter (mirrors ``aggregate_people``): drop a person
118
+ # whose primary email is an owner key even when only the email — not the
119
+ # display name — was listed, since the display-name participant key would
120
+ # otherwise resolve the owner back in.
121
+ primary_email = directory.primary_email_by_key.get(canonical)
122
+ if primary_email is not None and primary_email.lower() in expanded_owner:
123
+ continue
124
+ resolved[canonical] = humanize_display_name(canonical)
125
+
126
+ return [
127
+ ResolvedPerson(canonical_key=key, display_name=name)
128
+ for key, name in sorted(resolved.items())
129
+ ]
130
+
131
+
132
+ def prebuilt_directory_resolver(
133
+ directory: _DirectoryIndex,
134
+ ) -> PersonResolver:
135
+ """Wrap :func:`default_person_resolver` to reuse a prebuilt directory index.
136
+
137
+ Perf seam for the batch corpus build (Fix B, 2026-05-24). The People-Hub
138
+ directory index is corpus-wide and does NOT change during a build, so
139
+ :func:`brain.graph_rag.build.build_graph` builds it ONCE and wraps it here,
140
+ then passes the returned callable through reconcile's ``person_resolver`` DI
141
+ seam — so every per-document reconcile reuses the same index instead of
142
+ rebuilding the ~1.2k-row directory on each document. The returned callable
143
+ conforms to the :class:`brain.graph_rag.reconcile.PersonResolver` protocol
144
+ ``(conn, document_id, *, owner_keys, sender_denylist)``; it forwards
145
+ ``owner_keys`` (per-document owner filtering still applies) but uses the
146
+ captured ``directory`` regardless of the ``sender_denylist`` argument, since
147
+ that denylist was already baked into the prebuilt index. The incremental
148
+ ingest hook keeps calling :func:`default_person_resolver` directly (which
149
+ builds its own one-call index), so its single-document path is unchanged.
150
+ """
151
+
152
+ def _resolver(
153
+ conn: psycopg.Connection[Any],
154
+ document_id: str,
155
+ *,
156
+ owner_keys: frozenset[str] = frozenset(),
157
+ sender_denylist: frozenset[str] = frozenset(),
158
+ ) -> list[ResolvedPerson]:
159
+ return default_person_resolver(
160
+ conn,
161
+ document_id,
162
+ owner_keys=owner_keys,
163
+ sender_denylist=sender_denylist,
164
+ directory=directory,
165
+ )
166
+
167
+ return _resolver