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,371 @@
1
+ """Fuse retrieval: graph (local) ⊕ vector/FTS (hybrid) via RRF (spec §17d Q1).
2
+
3
+ The fourth retrieval path alongside **local** (:mod:`brain.graph_rag.retrieve`),
4
+ **themes-with-X** (:mod:`brain.graph_rag.themes`), and **global**
5
+ (:mod:`brain.graph_rag.global_`). Where those resolve a single ranked unit
6
+ (entity-centric docs / theme groups / communities), **fuse** combines two
7
+ independent **document** rankings into one (wave G4-c):
8
+
9
+ * **Graph leg** — the ``local`` (entity-centric) document list
10
+ (:func:`brain.graph_rag.retrieve._retrieve_local`). ``themes`` / ``global`` are
11
+ deliberately NOT fused: their ranked units (theme groups / communities) and
12
+ scoping semantics differ, so they don't fuse cleanly into a doc list (spec
13
+ §17d Q1).
14
+ * **Hybrid leg** — the existing vector + FTS document ranking
15
+ (:func:`brain.search.hybrid_search`). Its ranking is **consumed unchanged**
16
+ (the spec §4 D7 invariant — fuse never modifies hybrid search).
17
+
18
+ The fusion (spec §17d Q1): RRF over each leg's **document-id rank** via
19
+ :func:`brain.rank_fusion.rrf_contribution` (``k=60``, the same constant as
20
+ :mod:`brain.search` / global / ``build_related``) — **not** a score-blend.
21
+ ``score = Σ 1/(60 + rank + 1)`` accumulated per ``document_id`` across both legs;
22
+ ties broken by ``document_id`` (deterministic). The fused
23
+ :class:`~brain.search.SearchResult`s land in ``GraphContext.docs`` (wire-stable —
24
+ no new field, no change to ``SearchResult``); ``GraphContext.mode`` and
25
+ ``GraphExplanation.mode`` are both stamped ``"fuse"``; and per-doc leg provenance
26
+ (which leg(s) ranked each returned doc + their 0-indexed ranks) is recorded in
27
+ ``GraphExplanation.matched_filters["fuse_doc_provenance"]``.
28
+
29
+ Fallbacks (never-raise; spec §17d Q1, mirroring the §17b dec 7 / §17c Q9
30
+ best-effort-optional-leg discipline):
31
+
32
+ * The **graph leg** is the primary leg: a ``GraphBackendError`` from the
33
+ traversal is **not** swallowed (the backend's complete-or-loud-failure
34
+ contract, identical to ``mode=local``); an empty graph result simply yields no
35
+ graph docs → **hybrid-only**.
36
+ * The **hybrid leg** is additive/best-effort: a missing ``embedder`` (the
37
+ caller's pre-warmed instance — perf-T4 G5) / a failing query embedding → a
38
+ WARN + the hybrid leg runs **FTS-only**; the hybrid leg failing even
39
+ FTS-only (a DB / tsquery error) → a WARN + fuse degrades to **graph-only**.
40
+ """
41
+ from __future__ import annotations
42
+
43
+ import logging
44
+ import uuid
45
+ from dataclasses import replace
46
+ from typing import TYPE_CHECKING, Any
47
+
48
+ import psycopg
49
+
50
+ from ..rank_fusion import rrf_contribution
51
+ from .router import FUSE_MODE
52
+ from .schema import GraphContext, GraphExplanation
53
+
54
+ if TYPE_CHECKING:
55
+ from ..config import Config
56
+ from ..ingest import Embedder
57
+ from ..search import SearchResult
58
+ from .backends.base import GraphBackend
59
+
60
+ _logger = logging.getLogger(__name__)
61
+
62
+ # RRF k constant — matches :data:`brain.search.RRF_K` and the global path's
63
+ # ``_RRF_K`` so every fused ranker shares one fusion constant via the shared
64
+ # :func:`brain.rank_fusion.rrf_contribution` helper (spec §17d Q1).
65
+ _RRF_K = 60
66
+
67
+ # Fuse is gated to the default tenant until documents/chunks are tenantized
68
+ # (spec §17d decision 6 / G4-review finding P1-1). ``documents`` / ``chunks``
69
+ # carry NO ``tenant_id`` column, so fuse's hybrid leg (corpus-wide
70
+ # :func:`brain.search.hybrid_search`) would surface another tenant's documents —
71
+ # a cross-tenant leak. ``local`` / ``global`` are inherently tenant-scoped (their
72
+ # doc-id sets come solely from tenant-predicated ``graph_entity_mentions``) and
73
+ # need no gate. Tracked follow-up: tenantize documents/chunks, then remove this.
74
+ _DEFAULT_FUSE_TENANT = "default"
75
+
76
+
77
+ class _NullEmbedder:
78
+ """Null Embedder for fuse's FTS-only hybrid fallback (spec §17d Q1).
79
+
80
+ When no real embedder can be constructed (``embedder_factory`` absent or
81
+ failing), fuse still runs the hybrid leg **FTS-only**.
82
+ :func:`brain.search.hybrid_search` requires an ``embedder`` argument even in
83
+ ``fts_only`` mode, but with ``fts_only=True`` + ``snippet_context_tokens=0``
84
+ it never calls ``embed`` / ``count_tokens``. This null object satisfies the
85
+ :class:`brain.ingest.Embedder` Protocol structurally so the FTS-only call is
86
+ runnable; its methods are unreachable on that path and raise so any future
87
+ accidental use is loud rather than silently wrong.
88
+ """
89
+
90
+ dim = 0
91
+
92
+ def embed(
93
+ self, texts: list[str], *, input_type: str = "document"
94
+ ) -> list[list[float]]:
95
+ raise NotImplementedError("fuse FTS-only hybrid leg must not embed")
96
+
97
+ def count_tokens(self, text: str) -> int:
98
+ raise NotImplementedError("fuse FTS-only hybrid leg must not count tokens")
99
+
100
+
101
+ _NULL_EMBEDDER = _NullEmbedder()
102
+
103
+
104
+ def _retrieve_fuse(
105
+ conn: psycopg.Connection[Any],
106
+ cfg: Config,
107
+ query: str,
108
+ *,
109
+ backend: GraphBackend,
110
+ tenant: str,
111
+ depth: int,
112
+ frontier_cap: int,
113
+ min_edge_weight: float,
114
+ limit: int,
115
+ embedder: Embedder | None = None,
116
+ session_id: str | None = None,
117
+ ) -> GraphContext:
118
+ """Run fuse (graph ⊕ hybrid) retrieval + assemble its ``GraphContext``.
119
+
120
+ Runs the graph leg (``_retrieve_local``) and the hybrid leg
121
+ (:func:`brain.search.hybrid_search`), RRF-merges the two document-id rankings
122
+ (:func:`_fuse_doc_rankings`), takes the top ``limit``, and shapes the fused
123
+ :class:`~brain.search.SearchResult`s into ``GraphContext.docs`` (carrying each
124
+ doc's per-leg provenance in ``GraphExplanation.matched_filters``). The graph
125
+ leg's entities ride along on ``GraphContext.entities`` for context. Caps
126
+ (``depth`` / ``frontier_cap`` / ``min_edge_weight``) feed the graph leg; the
127
+ hybrid leg reads its tuning (``vector_sim_floor`` / ``recency_halflife_days``
128
+ / ``snippet_context_tokens``) from ``cfg``. ``session_id`` is generated when
129
+ omitted (a fresh ``uuid4`` hex).
130
+
131
+ ``embedder`` is the caller's PRE-WARMED :class:`brain.ingest.Embedder`
132
+ instance (perf-T4 G5) — the SAME instance that would feed any other leg
133
+ (e.g. global) within this call, so multi-leg retrievers never re-construct
134
+ or double-embed. ``None`` triggers FTS-only hybrid (never-raise).
135
+
136
+ Never-raise: an empty graph leg → hybrid-only; a missing/failed embedder →
137
+ FTS-only hybrid; a fully-dead hybrid leg → graph-only (spec §17d Q1). A
138
+ ``GraphBackendError`` from the graph traversal is propagated (the backend's
139
+ complete-or-loud-failure contract, identical to ``mode=local``).
140
+
141
+ Tenant gate (spec §17d decision 6 / G4-review P1-1): a non-default ``tenant``
142
+ raises :class:`ValueError` BEFORE either leg runs — the hybrid leg is
143
+ corpus-wide (documents/chunks are not tenantized) so a non-default fuse would
144
+ leak cross-tenant documents. The ``ValueError`` maps cleanly through the
145
+ existing graphrag surfaces (CLI → ``typer.BadParameter`` exit 2; MCP →
146
+ ``INVALID_PARAMS``). ``local`` / ``global`` stay available (inherently scoped).
147
+ """
148
+ # Tenant gate (spec §17d decision 6 / G4-review P1-1): refuse fuse for any
149
+ # non-default tenant BEFORE either leg runs (not empty, not graph-only, not
150
+ # hybrid-filtered) — the hybrid leg queries documents/chunks corpus-wide
151
+ # (they carry no tenant_id), so a non-default fuse would surface another
152
+ # tenant's documents. local/global remain available (inherently scoped).
153
+ if tenant != _DEFAULT_FUSE_TENANT:
154
+ raise ValueError(
155
+ "mode='fuse' is only available for tenant 'default' until "
156
+ "documents/chunks are tenantized; use mode='local' or mode='global' "
157
+ "for graph-scoped retrieval"
158
+ )
159
+
160
+ # Lazy import to avoid a retrieve ↔ fuse module-load cycle (retrieve.py
161
+ # imports ``_retrieve_fuse`` at top; this resolves once retrieve is loaded).
162
+ from .retrieve import _retrieve_local
163
+
164
+ resolved_session = uuid.uuid4().hex if session_id is None else session_id
165
+
166
+ # GRAPH leg (primary): local entity-centric docs.
167
+ graph_ctx = _retrieve_local(
168
+ conn,
169
+ query,
170
+ backend=backend,
171
+ tenant_id=tenant,
172
+ depth=depth,
173
+ frontier_cap=frontier_cap,
174
+ min_edge_weight=min_edge_weight,
175
+ limit=limit,
176
+ session_id=resolved_session,
177
+ )
178
+ graph_docs = graph_ctx.docs
179
+
180
+ # HYBRID leg (additive, best-effort): FTS + vector via hybrid_search.
181
+ hybrid_docs, hybrid_vector_arm = _run_hybrid_leg(
182
+ conn, cfg, query, limit=limit, embedder=embedder
183
+ )
184
+
185
+ # RRF-merge the two document-id rankings.
186
+ graph_ids = [doc.document_id for doc in graph_docs]
187
+ hybrid_ids = [doc.document_id for doc in hybrid_docs]
188
+ fused, provenance = _fuse_doc_rankings(graph_ids, hybrid_ids)
189
+ top = fused[:limit] if limit > 0 else []
190
+ docs = _build_fused_docs(top, graph_docs, hybrid_docs)
191
+ fuse_doc_provenance = {doc.document_id: provenance[doc.document_id] for doc in docs}
192
+
193
+ seed_entity_ids = (
194
+ list(graph_ctx.explanation.seed_entity_ids)
195
+ if graph_ctx.explanation is not None
196
+ else []
197
+ )
198
+ explanation = GraphExplanation(
199
+ mode=FUSE_MODE,
200
+ tenant_id=tenant,
201
+ seed_entity_ids=seed_entity_ids,
202
+ person_keys=[],
203
+ depth=depth,
204
+ frontier_cap=frontier_cap,
205
+ min_edge_weight=min_edge_weight,
206
+ nodes_visited=len(graph_ctx.entities),
207
+ edges_considered=0,
208
+ generic_df_cap=None,
209
+ matched_filters={
210
+ "query": query,
211
+ "graph_doc_count": len(graph_docs),
212
+ "hybrid_doc_count": len(hybrid_docs),
213
+ "hybrid_vector_arm_used": hybrid_vector_arm,
214
+ "fused_doc_count": len(docs),
215
+ "limit": limit,
216
+ "fuse_doc_provenance": fuse_doc_provenance,
217
+ },
218
+ )
219
+ return GraphContext(
220
+ session_id=resolved_session,
221
+ mode=FUSE_MODE,
222
+ query=query,
223
+ tenant_id=tenant,
224
+ person=None,
225
+ themes=[],
226
+ communities=[],
227
+ entities=graph_ctx.entities,
228
+ docs=docs,
229
+ explanation=explanation,
230
+ )
231
+
232
+
233
+ # --------------------------------------------------------------------------- #
234
+ # RRF merge (pure) + fused-doc shaping
235
+ # --------------------------------------------------------------------------- #
236
+ def _fuse_doc_rankings(
237
+ graph_ids: list[str], hybrid_ids: list[str]
238
+ ) -> tuple[list[tuple[str, float]], dict[str, dict[str, Any]]]:
239
+ """Pure RRF merge of the two document-id rankings (spec §17d Q1).
240
+
241
+ Each leg contributes ``rrf_contribution(rank, k=60)`` per ``document_id``
242
+ (0-indexed rank in that leg's order); contributions accumulate across legs.
243
+ Returns ``(fused, provenance)``:
244
+
245
+ * ``fused`` — ``[(document_id, score), …]`` sorted by score DESC then
246
+ ``document_id`` ASC (the deterministic tie-break).
247
+ * ``provenance`` — ``document_id → {"graph_rank", "hybrid_rank",
248
+ "fused_score"}`` with 0-indexed ranks (``None`` when the doc is absent from
249
+ that leg).
250
+
251
+ Pure (no DB / no ``SearchResult``) so the fusion is unit-testable in
252
+ isolation. ``_retrieve_fuse`` passes the per-leg ``document_id`` orders.
253
+ """
254
+ graph_rank = {doc_id: rank for rank, doc_id in enumerate(graph_ids)}
255
+ hybrid_rank = {doc_id: rank for rank, doc_id in enumerate(hybrid_ids)}
256
+
257
+ scores: dict[str, float] = {}
258
+ for rank, doc_id in enumerate(graph_ids):
259
+ scores[doc_id] = scores.get(doc_id, 0.0) + rrf_contribution(rank, k=_RRF_K)
260
+ for rank, doc_id in enumerate(hybrid_ids):
261
+ scores[doc_id] = scores.get(doc_id, 0.0) + rrf_contribution(rank, k=_RRF_K)
262
+
263
+ fused = sorted(scores.items(), key=lambda kv: (-kv[1], kv[0]))
264
+ provenance = {
265
+ doc_id: {
266
+ "graph_rank": graph_rank.get(doc_id),
267
+ "hybrid_rank": hybrid_rank.get(doc_id),
268
+ "fused_score": score,
269
+ }
270
+ for doc_id, score in fused
271
+ }
272
+ return fused, provenance
273
+
274
+
275
+ def _build_fused_docs(
276
+ ranked: list[tuple[str, float]],
277
+ graph_docs: list[SearchResult],
278
+ hybrid_docs: list[SearchResult],
279
+ ) -> list[SearchResult]:
280
+ """Shape the fused ranking into ``SearchResult``s (no dataclass change).
281
+
282
+ Reuses each leg's :class:`~brain.search.SearchResult` as the carrier —
283
+ preferring the hybrid leg's (its snippet is query-relevant), falling back to
284
+ the graph leg's — and overrides only ``score`` with the fused RRF score
285
+ (immutably, via :func:`dataclasses.replace`). ``explain`` is cleared (the
286
+ fuse provenance lives in ``GraphExplanation.matched_filters`` instead). Order
287
+ preserves the fused ranking.
288
+ """
289
+ hybrid_by_id = {doc.document_id: doc for doc in hybrid_docs}
290
+ graph_by_id = {doc.document_id: doc for doc in graph_docs}
291
+ out: list[SearchResult] = []
292
+ for doc_id, score in ranked:
293
+ carrier = hybrid_by_id.get(doc_id) or graph_by_id.get(doc_id)
294
+ if carrier is None: # defensive: every fused id came from a leg
295
+ continue
296
+ out.append(replace(carrier, score=score, explain=None))
297
+ return out
298
+
299
+
300
+ # --------------------------------------------------------------------------- #
301
+ # Hybrid leg (best-effort; FTS-only / graph-only fallbacks)
302
+ # --------------------------------------------------------------------------- #
303
+ def _run_hybrid_leg(
304
+ conn: psycopg.Connection[Any],
305
+ cfg: Config,
306
+ query: str,
307
+ *,
308
+ limit: int,
309
+ embedder: Embedder | None,
310
+ ) -> tuple[list[SearchResult], bool]:
311
+ """Run the FTS + vector hybrid leg for fuse (spec §17d Q1; never-raise).
312
+
313
+ Returns ``(docs, vector_arm_used)``:
314
+
315
+ * caller's pre-warmed embedder present + query embeds + cosine SQL runs →
316
+ full hybrid (``vector_arm_used=True``).
317
+ * embedder absent / the vector arm fails → **FTS-only** hybrid
318
+ (``vector_arm_used=False``); the hybrid leg's ranking is consumed
319
+ unchanged (spec §4 D7 — hybrid search is never modified).
320
+ * the FTS-only ``hybrid_search`` itself failing (a DB / tsquery error) →
321
+ ``([], False)`` + a WARN (fuse degrades to graph-only).
322
+
323
+ The caller is responsible for constructing the embedder ONCE per call
324
+ (perf-T4 G5) so multi-leg retrievers never re-build or double-embed.
325
+ """
326
+ # Late import keeps :mod:`brain.graph_rag` import-cheap + free of the ingest
327
+ # import cycle :mod:`brain.search` pulls in (mirrors ``_build_doc_results``).
328
+ from ..search import hybrid_search
329
+
330
+ if embedder is None:
331
+ _logger.warning(
332
+ "fuse: no embedder injected; hybrid leg runs FTS-only"
333
+ )
334
+
335
+ # Vector arm — only when a real embedder was passed in.
336
+ if embedder is not None:
337
+ try:
338
+ docs = hybrid_search(
339
+ conn,
340
+ embedder=embedder,
341
+ query=query,
342
+ limit=limit,
343
+ vector_sim_floor=cfg.vector_sim_floor,
344
+ recency_halflife_days=cfg.recency_halflife_days,
345
+ snippet_context_tokens=cfg.snippet_context_tokens,
346
+ )
347
+ return docs, True
348
+ except Exception as exc: # noqa: BLE001 — never-raise: degrade to FTS-only
349
+ _logger.warning(
350
+ "fuse: hybrid vector leg failed (%s); retrying FTS-only", exc
351
+ )
352
+
353
+ # FTS-only arm — no usable embedder OR the vector arm failed above. Use the
354
+ # real embedder when present (its methods are unreachable with fts_only=True +
355
+ # snippet_context_tokens=0), else the null placeholder so the leg still runs.
356
+ fts_embedder = embedder if embedder is not None else _NULL_EMBEDDER
357
+ try:
358
+ docs = hybrid_search(
359
+ conn,
360
+ embedder=fts_embedder,
361
+ query=query,
362
+ limit=limit,
363
+ fts_only=True,
364
+ snippet_context_tokens=0,
365
+ )
366
+ except Exception as exc: # noqa: BLE001 — hybrid fully dead → graph-only
367
+ _logger.warning(
368
+ "fuse: hybrid FTS leg failed (%s); fuse degrades to graph-only", exc
369
+ )
370
+ return [], False
371
+ return docs, False