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,303 @@
1
+ """Derived aggregate layer for the GraphRAG people graph (wave G1, spec §7 step 4).
2
+
3
+ Extracted from :mod:`brain.graph_rag.reconcile` (wave-G1 boundary refactor) to
4
+ keep that module focused on the per-document reconcile orchestration. This module
5
+ owns the **tenant-wide derived aggregate** concerns that both the per-document
6
+ reconcile and the corpus-wide refresh share:
7
+
8
+ * :func:`refresh_aggregates` — the public corpus-wide aggregate recompute backing
9
+ ``brain graphrag refresh`` (spec §7 step 4 / §8 / §9).
10
+ * :func:`_recompute_aggregates` — full-tenant rebuild of ``graph_relationships``
11
+ from ``graph_edge_contributions`` (normalized lift + generic suppression,
12
+ G1-a :mod:`~brain.graph_rag.weighting`). Reused verbatim by
13
+ :func:`brain.graph_rag.reconcile.reconcile_document` /
14
+ :func:`~brain.graph_rag.reconcile.remove_document` so the weighting logic lives
15
+ in exactly one place.
16
+ * :func:`_gc_orphan_persons` / :func:`_gc_orphan_concepts` — GC of now-zero-mention
17
+ catalog rows, scoped to ``entity_type = 'person'`` and to the concept
18
+ ``entity_type``s respectively. :func:`refresh_aggregates` runs BOTH (an
19
+ aspect-agnostic corpus-wide refresh), matching ``remove_document`` and
20
+ ``build --force``.
21
+
22
+ Because the aggregates derive *purely* from the per-document source-of-truth, a
23
+ full recompute is always correct and cascade-safe; affected-only incremental
24
+ refresh is explicitly out of scope for v1 (spec §15).
25
+
26
+ ``RefreshResult`` and ``refresh_aggregates`` are re-exported from
27
+ :mod:`brain.graph_rag.reconcile` (and :mod:`brain.graph_rag`) so existing
28
+ callers (``brain graphrag refresh`` CLI, tests) keep importing them from there
29
+ unchanged — this extraction is behavior-preserving, with no public-API move.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ from dataclasses import dataclass
34
+ from typing import TYPE_CHECKING, Any
35
+
36
+ import psycopg
37
+
38
+ from .backends.base import GraphBackend
39
+ from .weighting import generic_df_cap
40
+
41
+ if TYPE_CHECKING:
42
+ from .reconcile import ReconcileConfig
43
+
44
+ __all__ = [
45
+ "RefreshResult",
46
+ "refresh_aggregates",
47
+ ]
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class RefreshResult:
52
+ """Outcome of a corpus-wide :func:`refresh_aggregates` call (spec §7 step 4).
53
+
54
+ Unlike ``ReconcileResult`` (which describes one document) this is the
55
+ tenant-wide aggregate recompute, so it carries no ``document_id``.
56
+ ``relationship_count`` is the number of aggregate ``graph_relationships``
57
+ edges written for the tenant after the recompute; ``orphans_removed`` is the
58
+ number of now-zero-mention catalog rows GC'd in the same pass across BOTH
59
+ aspects (person + concept).
60
+ """
61
+
62
+ tenant_id: str
63
+ relationship_count: int = 0
64
+ orphans_removed: int = 0
65
+
66
+
67
+ def refresh_aggregates(
68
+ conn: psycopg.Connection[Any],
69
+ *,
70
+ backend: GraphBackend,
71
+ config: ReconcileConfig | None = None,
72
+ ) -> RefreshResult:
73
+ """Corpus-wide aggregate recompute for one tenant (spec §7 step 4, §8/§9).
74
+
75
+ The corpus-wide weight/edge recompute of the tenant's derived aggregates from
76
+ the per-document source-of-truth, **without re-resolving any document's
77
+ persons** (that is
78
+ :func:`brain.graph_rag.reconcile.reconcile_document`'s job). It is the
79
+ explicit response to a corpus-wide weighting / suppression change — a new
80
+ ``generic_df_ratio`` ⇒ a new ``suppress_ver`` — that must propagate to every
81
+ edge at once. Backs the ``brain graphrag refresh`` CLI. For a dropped or
82
+ corrupted AGE mirror (entity vertices missing) use
83
+ ``brain graphrag build --force`` — the authoritative full rebuild — instead.
84
+
85
+ Reuses the SAME private recompute + GC helpers as ``reconcile_document`` and
86
+ ``remove_document`` (no duplicated weighting logic): recompute every
87
+ ``graph_relationships`` edge from ``graph_edge_contributions`` (normalized
88
+ lift + generic suppression, G1-a), GC any now-orphaned person AND concept
89
+ catalog rows (aspect-agnostic, matching ``remove_document``), then
90
+ rematerialize the AGE ``CO_OCCURS`` edges from the refreshed mirror and
91
+ DETACH DELETE the orphan vertices of both aspects.
92
+
93
+ Runs entirely inside ``with conn.transaction()`` (see the reconcile module's
94
+ "Connection contract"), so the relational recompute and the AGE rematerialize
95
+ commit or roll back together. Idempotent: a second call with the same config
96
+ + source of truth converges to the identical graph (a stable no-op when
97
+ nothing changed).
98
+
99
+ Precondition: the tenant's ``Entity`` vertices already exist in AGE (run
100
+ ``brain graphrag build --backfill`` first).
101
+ :meth:`~brain.graph_rag.backends.base.GraphBackend.refresh_cooccur_edges`
102
+ raises :class:`brain.errors.GraphBackendError` if a surviving relationship
103
+ references an entity with no AGE vertex — surfacing a refresh-before-build
104
+ mistake rather than silently under-materializing.
105
+ """
106
+ # Late import keeps the module-level dependency one-way (reconcile → this
107
+ # module) so the extraction introduces no import cycle; reconcile is fully
108
+ # loaded by the time refresh_aggregates is ever called.
109
+ if config is None:
110
+ from .reconcile import ReconcileConfig
111
+
112
+ config = ReconcileConfig()
113
+ tenant_id = config.tenant_id
114
+ with conn.transaction():
115
+ relationship_count = _recompute_aggregates(
116
+ conn, tenant_id, config.generic_df_ratio
117
+ )
118
+ # GC orphans of BOTH aspects, matching remove_document (reconcile.py) and
119
+ # build --force (build.py): a corpus-wide refresh is aspect-agnostic, so a
120
+ # now-zero-mention concept catalog row + its AGE vertex must be cleaned up
121
+ # too, not just persons (spec §7 step 4 "DETACH DELETE zero-mention Entity
122
+ # vertices"). The concept GC is a no-op when no concept entities exist, so
123
+ # this is safe for the person-only default.
124
+ orphan_ids = [
125
+ *_gc_orphan_persons(conn, tenant_id),
126
+ *_gc_orphan_concepts(conn, tenant_id),
127
+ ]
128
+ backend.refresh_cooccur_edges(conn, tenant_id)
129
+ if orphan_ids:
130
+ backend.detach_delete_entities(conn, tenant_id, orphan_ids)
131
+ return RefreshResult(
132
+ tenant_id=tenant_id,
133
+ relationship_count=relationship_count,
134
+ orphans_removed=len(orphan_ids),
135
+ )
136
+
137
+
138
+ def _recompute_aggregates(
139
+ conn: psycopg.Connection[Any], tenant_id: str, generic_df_ratio: float
140
+ ) -> int:
141
+ """Full-tenant recompute of ``graph_relationships`` from contributions.
142
+
143
+ Refreshes every entity's derived ``doc_count``, then rebuilds the tenant's
144
+ aggregate edges: per pair, ``weight`` is the normalized lift over the pair's
145
+ co-document count and the endpoints' document frequencies, suppressed (row
146
+ omitted) when either endpoint exceeds the generic-frequency cap
147
+ (``round(generic_df_ratio × corpus_N)``). Returns the number of aggregate
148
+ edges written. Cascade-safe full recompute (spec §7 step 4).
149
+
150
+ **Set-based (perf-T4 G3).** The pair-by-pair Python loop + per-row
151
+ ``INSERT`` is replaced with a single CTE-driven ``INSERT … SELECT``: two
152
+ aggregate CTEs compute the pair co-document counts and per-entity document
153
+ frequencies, then the outer SELECT joins them, applies the suppression
154
+ filter, computes the normalized-lift weight, and inserts every surviving
155
+ edge in one round-trip. The semantics are identical to the prior loop:
156
+
157
+ * The weight formula matches :func:`brain.graph_rag.weighting.normalized_lift`
158
+ — ``co_doc_count / min(src_df, dst_df)`` (∈ ``(0, 1]``).
159
+ * The generic-entity suppression matches
160
+ :func:`brain.graph_rag.weighting.edge_weight` — an edge is omitted iff
161
+ EITHER endpoint's ``df`` is *strictly greater than* ``cap`` (an entity
162
+ sitting exactly at the cap is kept, matching
163
+ :func:`is_generic_entity`).
164
+ * ``cap`` is computed in Python via :func:`generic_df_cap` so the banker's
165
+ rounding of ``corpus_N × generic_df_ratio`` is preserved exactly (SQL's
166
+ ``round`` does not match Python's banker's rounding).
167
+ * Endpoint-pair ordering and per-row column values pass through unchanged:
168
+ the ``graph_edge_contributions`` ``(src_id, dst_id)`` pairs feed
169
+ ``graph_relationships`` 1-to-1 with the prior loop, and the migration-012
170
+ ``CHECK (weight > 0 AND weight <= 1)`` remains satisfied by construction.
171
+
172
+ Returns the number of materialized edges (the same number the prior loop
173
+ returned), read off ``cur.rowcount`` after the bulk insert.
174
+ """
175
+ # Refresh the derived per-entity doc_count from the mentions source-of-truth.
176
+ conn.execute(
177
+ """
178
+ UPDATE graph_entities ge
179
+ SET doc_count = COALESCE((
180
+ SELECT COUNT(DISTINCT m.document_id)
181
+ FROM graph_entity_mentions m
182
+ WHERE m.tenant_id = ge.tenant_id AND m.entity_id = ge.id
183
+ ), 0)
184
+ WHERE ge.tenant_id = %s
185
+ """,
186
+ (tenant_id,),
187
+ )
188
+
189
+ # Compute the generic-entity cap in Python so ``round`` uses banker's
190
+ # rounding identically to :func:`generic_df_cap` — keeping the SQL form's
191
+ # suppression boundary byte-identical to the prior loop's.
192
+ corpus_row = conn.execute(
193
+ "SELECT COUNT(DISTINCT document_id) "
194
+ "FROM graph_entity_mentions WHERE tenant_id = %s",
195
+ (tenant_id,),
196
+ ).fetchone()
197
+ corpus_n = int(corpus_row[0]) if corpus_row is not None else 0
198
+ cap = generic_df_cap(corpus_n, generic_df_ratio)
199
+
200
+ # Clear the tenant's existing aggregate edges, then materialize every
201
+ # non-suppressed edge in ONE statement. The ``pair_aggregates`` CTE
202
+ # mirrors the prior ``SELECT src_id, dst_id, SUM(cooccur_count),
203
+ # COUNT(DISTINCT document_id) GROUP BY src_id, dst_id`` exactly; the
204
+ # ``entity_df`` CTE mirrors the prior ``df`` map (per-entity distinct
205
+ # document count). The outer SELECT joins them, filters by the generic
206
+ # cap (``df <= cap`` on BOTH endpoints — strictly-greater suppression),
207
+ # and computes ``LEAST(src.df, dst.df)`` as the normalized-lift divisor
208
+ # (the ``::float`` cast prevents integer truncation).
209
+ conn.execute(
210
+ "DELETE FROM graph_relationships WHERE tenant_id = %s", (tenant_id,)
211
+ )
212
+ with conn.cursor() as cur:
213
+ cur.execute(
214
+ """
215
+ WITH pair_aggregates AS (
216
+ SELECT src_id, dst_id,
217
+ SUM(cooccur_count)::bigint AS co_count,
218
+ COUNT(DISTINCT document_id)::bigint AS pair_doc_count
219
+ FROM graph_edge_contributions
220
+ WHERE tenant_id = %(tenant)s
221
+ GROUP BY src_id, dst_id
222
+ ),
223
+ entity_df AS (
224
+ SELECT entity_id,
225
+ COUNT(DISTINCT document_id)::bigint AS df
226
+ FROM graph_entity_mentions
227
+ WHERE tenant_id = %(tenant)s
228
+ GROUP BY entity_id
229
+ )
230
+ INSERT INTO graph_relationships
231
+ (tenant_id, src_id, dst_id, rel_type, weight, co_count, doc_count)
232
+ SELECT %(tenant)s, pa.src_id, pa.dst_id, 'co_occurs',
233
+ pa.pair_doc_count::float / LEAST(src.df, dst.df)::float,
234
+ pa.co_count,
235
+ pa.pair_doc_count
236
+ FROM pair_aggregates pa
237
+ JOIN entity_df src ON src.entity_id = pa.src_id
238
+ JOIN entity_df dst ON dst.entity_id = pa.dst_id
239
+ WHERE src.df <= %(cap)s AND dst.df <= %(cap)s
240
+ """,
241
+ {"tenant": tenant_id, "cap": cap},
242
+ )
243
+ written = cur.rowcount
244
+ return written
245
+
246
+
247
+ def _gc_orphan_persons(conn: psycopg.Connection[Any], tenant_id: str) -> list[str]:
248
+ """Delete + return the ids of zero-mention person catalog rows (spec §7.4).
249
+
250
+ Scoped to ``entity_type = 'person'`` — the person aspect. Deleting the
251
+ ``graph_entities`` row cascades to any stale relationship/contribution rows
252
+ (none expected post-recompute). The returned ids are handed to the backend
253
+ to DETACH DELETE the matching AGE vertices.
254
+ """
255
+ return _gc_orphan_entities(conn, tenant_id, ["person"])
256
+
257
+
258
+ def _gc_orphan_concepts(conn: psycopg.Connection[Any], tenant_id: str) -> list[str]:
259
+ """Delete + return the ids of zero-mention concept catalog rows (spec §7.4).
260
+
261
+ Scoped to the four concept ``entity_type``s (``topic``/``project``/``org``/
262
+ ``tool``) — the concept aspect (wave G2-c). People are GC'd separately by
263
+ :func:`_gc_orphan_persons` so each aspect cleans only its own orphans, never
264
+ double-counting. Same cascade + DETACH-DELETE contract as the person GC.
265
+ """
266
+ # Late import keeps the module-level dependency one-way (this module does not
267
+ # import the concept package at load) and avoids any import-order coupling.
268
+ from .concepts import CONCEPT_ENTITY_TYPES
269
+
270
+ return _gc_orphan_entities(conn, tenant_id, list(CONCEPT_ENTITY_TYPES))
271
+
272
+
273
+ def _gc_orphan_entities(
274
+ conn: psycopg.Connection[Any], tenant_id: str, entity_types: list[str]
275
+ ) -> list[str]:
276
+ """Delete + return zero-mention catalog rows of the given ``entity_types``.
277
+
278
+ The shared GC core behind :func:`_gc_orphan_persons` /
279
+ :func:`_gc_orphan_concepts`. An entity with no remaining
280
+ ``graph_entity_mentions`` row is orphaned; deleting it cascades to any stale
281
+ relationship/contribution rows. ``entity_types`` is scoped via ``= ANY(%s)``
282
+ so each aspect GCs only its own rows.
283
+ """
284
+ rows = conn.execute(
285
+ """
286
+ SELECT ge.id::text FROM graph_entities ge
287
+ WHERE ge.tenant_id = %s
288
+ AND ge.entity_type = ANY(%s)
289
+ AND NOT EXISTS (
290
+ SELECT 1 FROM graph_entity_mentions m
291
+ WHERE m.tenant_id = ge.tenant_id AND m.entity_id = ge.id
292
+ )
293
+ """,
294
+ (tenant_id, entity_types),
295
+ ).fetchall()
296
+ orphan_ids = [str(row[0]) for row in rows]
297
+ if orphan_ids:
298
+ conn.execute(
299
+ "DELETE FROM graph_entities "
300
+ "WHERE tenant_id = %s AND id::text = ANY(%s)",
301
+ (tenant_id, orphan_ids),
302
+ )
303
+ return orphan_ids