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,193 @@
1
+ """Raw-text sliding-window entity co-occurrence (wave G1, GraphRAG).
2
+
3
+ Pure logic, no DB: given one document's ordered entity *occurrences* (each an
4
+ ``(entity_id, position)`` pair over the raw-text token stream) this module
5
+ computes the per-document, per-entity-pair raw co-occurrence counts that feed
6
+ ``graph_edge_contributions`` (migration 012; spec §4 D4, §5a).
7
+
8
+ **Spec interpretation (documented choice).** The design (spec §4 D4, §13) fixes
9
+ that edges come from a *window co-occurrence over raw text* with the default
10
+ window ``BRAIN_GRAPH_COOCCUR_WINDOW = 3`` (spec §10), but does not pin the exact
11
+ window predicate or the unit of ``position``. This module makes the most
12
+ spec-faithful, testable choice and documents it:
13
+
14
+ * ``position`` is a caller-supplied integer offset into the raw-text token
15
+ stream (chunker-independent, per spec §4 D4). The pure function is unit-
16
+ agnostic — the caller (G1 ``reconcile.py``) owns tokenization and decides what
17
+ a position means; this module only compares positions.
18
+ * Two occurrences **co-occur** iff they belong to *distinct* entities and their
19
+ positions differ by at most ``window`` (an inclusive maximum distance, i.e. a
20
+ radius). ``window = 3`` ⇒ ``abs(pos_i - pos_j) <= 3``.
21
+ * Each unordered occurrence-pair is counted **at most once**, so overlapping
22
+ sliding windows never double-count a pair (spec §13 "overlap regression").
23
+
24
+ Endpoints are canonicalized ``src_id < dst_id`` to match the
25
+ ``graph_edge_contributions`` ``CHECK (src_id < dst_id)``. UUIDs are compared as
26
+ strings; for the canonical lowercase UUID text form this lexicographic order
27
+ matches PostgreSQL's ``uuid`` byte ordering (hyphens sit at fixed positions and
28
+ do not affect relative order), so the canonical pair always satisfies the DB
29
+ constraint.
30
+
31
+ The module is a single-responsibility derive-time helper (SOLID): occurrences
32
+ in, raw counts out. Generic-entity suppression and lift weighting are *not* done
33
+ here — they are separate derive-time concerns owned by
34
+ :mod:`brain.graph_rag.weighting`.
35
+ """
36
+ from __future__ import annotations
37
+
38
+ from collections import Counter
39
+ from collections.abc import Mapping, Sequence
40
+ from dataclasses import dataclass
41
+
42
+ from ..errors import CooccurrenceError
43
+ from .schema import EdgeContribution
44
+
45
+ __all__ = [
46
+ "DEFAULT_COOCCUR_WINDOW",
47
+ "DEFAULT_MAX_ENTITIES_PER_DOC",
48
+ "EntityOccurrence",
49
+ "cooccurrence_counts",
50
+ "to_contributions",
51
+ ]
52
+
53
+ # Default sliding-window radius: two occurrences co-occur when their positions
54
+ # differ by at most this many units. Mirrors ``BRAIN_GRAPH_COOCCUR_WINDOW``
55
+ # (spec §10, default 3); reconcile.py passes the configured value.
56
+ DEFAULT_COOCCUR_WINDOW = 3
57
+
58
+ # Default cap on the number of DISTINCT entities considered per document
59
+ # (spec §8 "cooccur.py — ... + caps", §10 ``BRAIN_GRAPH_MAX_ENTITIES_PER_DOC``).
60
+ # Bounds the O(pairs) blow-up on entity-dense documents. When more distinct
61
+ # entities are present, the most-mentioned ones are kept (ties broken by
62
+ # ``entity_id`` ascending for determinism); pass ``None`` to disable.
63
+ DEFAULT_MAX_ENTITIES_PER_DOC = 40
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class EntityOccurrence:
68
+ """One entity mention at a raw-text position (input to co-occurrence).
69
+
70
+ ``entity_id`` is the durable entity UUID (``graph_entities.id``).
71
+ ``position`` is a caller-defined integer offset into the raw-text token
72
+ stream — only relative distances matter here, so the unit is the caller's
73
+ choice (spec §4 D4). The same ``entity_id`` may appear at many positions
74
+ (a repeated mention); same-entity pairs never produce an edge (no self
75
+ loops — the DB ``CHECK (src_id < dst_id)`` forbids them).
76
+ """
77
+
78
+ entity_id: str
79
+ position: int
80
+
81
+
82
+ def _canonical_pair(a: str, b: str) -> tuple[str, str]:
83
+ """Return ``(src, dst)`` ordered ``src < dst`` (matches the DB CHECK).
84
+
85
+ Caller guarantees ``a != b`` (same-entity pairs are skipped upstream).
86
+ """
87
+ return (a, b) if a < b else (b, a)
88
+
89
+
90
+ def _apply_entity_cap(
91
+ occurrences: Sequence[EntityOccurrence], max_entities: int
92
+ ) -> list[EntityOccurrence]:
93
+ """Keep occurrences of the top ``max_entities`` most-mentioned entities.
94
+
95
+ Entities are ranked by total occurrence count (descending), ties broken by
96
+ ``entity_id`` ascending so the selection is deterministic. Occurrences of
97
+ dropped entities are removed; the relative order of the survivors is
98
+ preserved. Returns the input list unchanged when it already references at
99
+ most ``max_entities`` distinct entities.
100
+ """
101
+ freq = Counter(occ.entity_id for occ in occurrences)
102
+ if len(freq) <= max_entities:
103
+ return list(occurrences)
104
+ ranked = sorted(freq.items(), key=lambda kv: (-kv[1], kv[0]))
105
+ kept = {entity_id for entity_id, _count in ranked[:max_entities]}
106
+ return [occ for occ in occurrences if occ.entity_id in kept]
107
+
108
+
109
+ def cooccurrence_counts(
110
+ occurrences: Sequence[EntityOccurrence],
111
+ *,
112
+ window: int = DEFAULT_COOCCUR_WINDOW,
113
+ max_entities: int | None = DEFAULT_MAX_ENTITIES_PER_DOC,
114
+ ) -> dict[tuple[str, str], int]:
115
+ """Compute raw within-window co-occurrence counts for one document.
116
+
117
+ Two occurrences co-occur when they belong to distinct entities and their
118
+ positions differ by at most ``window``. Each unordered occurrence-pair is
119
+ counted once (overlapping windows do not double-count). The result maps each
120
+ canonical ``(src_id, dst_id)`` pair (``src_id < dst_id``) to its raw count.
121
+
122
+ Args:
123
+ occurrences: One document's entity occurrences (any order). Multiple
124
+ occurrences of the same ``entity_id`` are allowed and only ever
125
+ pair with *other* entities.
126
+ window: Inclusive maximum positional distance for a co-occurrence
127
+ (default :data:`DEFAULT_COOCCUR_WINDOW`). Must be ``>= 1``.
128
+ max_entities: Cap on distinct entities considered (default
129
+ :data:`DEFAULT_MAX_ENTITIES_PER_DOC`); ``None`` disables the cap.
130
+ Must be ``>= 1`` when set.
131
+
132
+ Returns:
133
+ A fresh ``dict`` from canonical entity pair to raw co-occurrence count;
134
+ empty when no pair falls within the window.
135
+
136
+ Raises:
137
+ CooccurrenceError: if ``window < 1`` or ``max_entities`` is set ``< 1``.
138
+ """
139
+ if window < 1:
140
+ raise CooccurrenceError(
141
+ f"co-occurrence window must be a positive integer (got {window})"
142
+ )
143
+ if max_entities is not None and max_entities < 1:
144
+ raise CooccurrenceError(
145
+ f"max_entities must be a positive integer or None (got {max_entities})"
146
+ )
147
+
148
+ considered = (
149
+ occurrences if max_entities is None else _apply_entity_cap(occurrences, max_entities)
150
+ )
151
+
152
+ # Sort by position so a forward sweep can stop early once the gap to a later
153
+ # occurrence exceeds the window (the remaining occurrences are even farther).
154
+ ordered = sorted(considered, key=lambda occ: occ.position)
155
+ counts: dict[tuple[str, str], int] = {}
156
+ total = len(ordered)
157
+ for i in range(total):
158
+ left = ordered[i]
159
+ for j in range(i + 1, total):
160
+ right = ordered[j]
161
+ if right.position - left.position > window:
162
+ break
163
+ if left.entity_id == right.entity_id:
164
+ continue # no self loops
165
+ pair = _canonical_pair(left.entity_id, right.entity_id)
166
+ counts[pair] = counts.get(pair, 0) + 1
167
+ return counts
168
+
169
+
170
+ def to_contributions(
171
+ counts: Mapping[tuple[str, str], int],
172
+ *,
173
+ document_id: str,
174
+ tenant_id: str = "default",
175
+ ) -> list[EdgeContribution]:
176
+ """Wrap raw pair counts as :class:`EdgeContribution` rows for one document.
177
+
178
+ The thin adapter between the unit-agnostic core (:func:`cooccurrence_counts`)
179
+ and the ``graph_edge_contributions`` source-of-truth shape (spec §5a). Rows
180
+ are emitted in canonical ``(src_id, dst_id)`` order for deterministic
181
+ persistence; ``tenant_id`` defaults to the single-user ``"default"`` tenant
182
+ (spec §4 D9).
183
+ """
184
+ return [
185
+ EdgeContribution(
186
+ document_id=document_id,
187
+ src_id=src_id,
188
+ dst_id=dst_id,
189
+ tenant_id=tenant_id,
190
+ cooccur_count=count,
191
+ )
192
+ for (src_id, dst_id), count in sorted(counts.items())
193
+ ]
@@ -0,0 +1,312 @@
1
+ """Automatic cross-document concept type-collapse (Bug A — no schema migration).
2
+
3
+ The catalog uniqueness key is type-scoped — ``UNIQUE (tenant_id, entity_type,
4
+ canonical_key)`` (migration 012) — so one ``canonical_key`` may exist once *per
5
+ type*. The LLM concept extractor assigns the same real concept a different
6
+ ``entity_type`` across documents (``acmeplatform`` as ``org`` in one doc,
7
+ ``project`` in another), and each satisfies the type-scoped key, landing as a
8
+ SEPARATE row. :func:`brain.graph_rag.extract._dedupe_cross_type` already collapses
9
+ this **within a single document** using :data:`~brain.graph_rag.extract._TYPE_PRECEDENCE`;
10
+ this module is its **cross-document** counterpart.
11
+
12
+ **Path 1 (no migration).** Rather than widening the schema, this reuses the
13
+ shipped, tested repoint + AGE-upsert + ``refresh_aggregates`` + orphan-GC wrapper
14
+ :func:`brain.graph_rag.aliases.merge_aliases`. The collapse:
15
+
16
+ 1. scans the tenant's concept catalog for every ``canonical_key`` carried by more
17
+ than one CONCEPT ``entity_type`` (:func:`generate_cross_type_collapse_rules`),
18
+ 2. generates :class:`~brain.graph_rag.aliases.AliasRule` rows merging each
19
+ lower-precedence type into the highest-precedence type present (same precedence
20
+ as the per-document dedupe), then
21
+ 3. validates them (chain/cycle/dup-source — :func:`brain.graph_rag.aliases
22
+ ._validate_alias_graph`) and applies them via ``merge_aliases``.
23
+
24
+ **Scope — concept types ONLY.** The scan is restricted to the four concept types
25
+ (``org``/``project``/``tool``/``topic``); ``person`` rows are never selected, so a
26
+ person can never be a merge source, and the precedence winner is always a concept
27
+ type, so a person can never be a merge target. A person and a concept that happen
28
+ to share a ``canonical_key`` are left untouched (D1b — persons are a separate
29
+ aspect, derived from the participants pipeline, not from this catalog).
30
+
31
+ **Hook placement (correctness).** This MUST run as a corpus-level pass AFTER a
32
+ document's mentions/contributions are written + committed — never mid-reconcile
33
+ (a mid-flow GC of a zero-mention source would orphan an id the pending
34
+ mention-insert then references). It is invoked from
35
+ :meth:`brain.graph_rag.sync.GraphSyncer.reconcile` (the incremental ingest path,
36
+ after ``reconcile_document`` commits) and from the ``brain graphrag build`` /
37
+ ``refresh`` CLI (after the per-document loop / the curated alias merge).
38
+
39
+ **Idempotent.** A second run finds no ``canonical_key`` under more than one type
40
+ (the first run collapsed them) → empty rules → ``merge_aliases`` short-circuits to
41
+ a zero-valued no-op. Communities go stale after any merge (entity GC cascades to
42
+ ``graph_community_members``); the rollout runs ``brain graphrag communities
43
+ refresh`` after the collapse.
44
+ """
45
+ from __future__ import annotations
46
+
47
+ from typing import TYPE_CHECKING, Any
48
+
49
+ import psycopg
50
+
51
+ from .aliases import AliasResult, AliasRule, _validate_alias_graph, merge_aliases
52
+ from .schema import GraphEntity
53
+
54
+ if TYPE_CHECKING:
55
+ from .backends.base import GraphBackend
56
+ from .reconcile import ReconcileConfig
57
+
58
+ __all__ = [
59
+ "collapse_cross_type_concepts",
60
+ "generate_cross_type_collapse_rules",
61
+ ]
62
+
63
+
64
+ def _concept_precedence() -> tuple[tuple[str, ...], list[str]]:
65
+ """Return ``(ordered_concept_types, concept_types)`` for the collapse.
66
+
67
+ ``ordered_concept_types`` is :data:`brain.graph_rag.extract._TYPE_PRECEDENCE`
68
+ filtered to the concept types (``person`` removed), highest-precedence first
69
+ — ``("org", "project", "tool", "topic")``. ``concept_types`` is the same set
70
+ as a list for parameterized ``= ANY(%s)`` scoping. Late import keeps this
71
+ module import-cheap (``extract`` pulls in the enrichment transport) and
72
+ mirrors the late-import discipline used across :mod:`brain.graph_rag`.
73
+ """
74
+ from .extract import _TYPE_PRECEDENCE, CONCEPT_ENTITY_TYPES
75
+
76
+ ordered = tuple(t for t in _TYPE_PRECEDENCE if t in CONCEPT_ENTITY_TYPES)
77
+ return ordered, list(CONCEPT_ENTITY_TYPES)
78
+
79
+
80
+ def generate_cross_type_collapse_rules(
81
+ conn: psycopg.Connection[Any], tenant_id: str
82
+ ) -> list[AliasRule]:
83
+ """Build merge rules for every cross-type concept fragment in ``tenant_id``.
84
+
85
+ Scans ``graph_entities`` for each ``canonical_key`` carried by more than one
86
+ CONCEPT ``entity_type`` (``person`` rows are excluded by the scan), and emits
87
+ one :class:`~brain.graph_rag.aliases.AliasRule` per lower-precedence type
88
+ collapsing it into the highest-precedence type present for that key (per
89
+ :data:`brain.graph_rag.extract._TYPE_PRECEDENCE`). Both endpoints carry the
90
+ SAME ``canonical_key`` — only the type changes — so the rule re-points the
91
+ fragment onto the winning-type row.
92
+
93
+ Returns a deterministically ordered list (sorted by ``(from_type, from_key)``)
94
+ so repeated runs and tests see a stable rule order. A key under a single type
95
+ yields no rule; distinct keys are independent (no rule ever spans two keys, so
96
+ the generated graph is always chain/cycle/dup-source free by construction —
97
+ :func:`collapse_cross_type_concepts` still validates it defensively). SQL is
98
+ parameterized throughout.
99
+ """
100
+ ordered, concept_types = _concept_precedence()
101
+ rank = {entity_type: i for i, entity_type in enumerate(ordered)}
102
+ rows = conn.execute(
103
+ """
104
+ SELECT canonical_key, array_agg(DISTINCT entity_type) AS types
105
+ FROM graph_entities
106
+ WHERE tenant_id = %s AND entity_type = ANY(%s)
107
+ GROUP BY canonical_key
108
+ HAVING COUNT(DISTINCT entity_type) > 1
109
+ """,
110
+ (tenant_id, concept_types),
111
+ ).fetchall()
112
+
113
+ rules: list[AliasRule] = []
114
+ for row in rows:
115
+ canonical_key = str(row[0])
116
+ present = [t for t in (str(x) for x in row[1]) if t in rank]
117
+ if len(present) < 2:
118
+ # Defensive: every selected type should be a known concept type, but
119
+ # if an unranked type slipped in there is nothing to collapse.
120
+ continue
121
+ winner = min(present, key=lambda entity_type: rank[entity_type])
122
+ for entity_type in present:
123
+ if entity_type == winner:
124
+ continue
125
+ rules.append(
126
+ AliasRule(
127
+ from_type=entity_type,
128
+ from_key=canonical_key,
129
+ to_type=winner,
130
+ to_key=canonical_key,
131
+ )
132
+ )
133
+ rules.sort(key=lambda rule: (rule.from_type, rule.from_key))
134
+ return rules
135
+
136
+
137
+ def collapse_cross_type_concepts(
138
+ conn: psycopg.Connection[Any],
139
+ tenant_id: str,
140
+ backend: GraphBackend,
141
+ *,
142
+ config: ReconcileConfig | None = None,
143
+ dry_run: bool = False,
144
+ ) -> AliasResult:
145
+ """Collapse cross-document concept type-fragmentation for one tenant (Bug A).
146
+
147
+ Generates the auto-merge rules (:func:`generate_cross_type_collapse_rules`),
148
+ validates them (:func:`brain.graph_rag.aliases._validate_alias_graph` —
149
+ ``merge_aliases`` does not re-validate a directly-passed rules list), and
150
+ applies them atomically via :func:`brain.graph_rag.aliases.merge_aliases`
151
+ (re-point mentions + contributions, provision any new target AGE vertices,
152
+ ``refresh_aggregates`` → GC the now-zero-mention source rows + DETACH DELETE
153
+ their AGE vertices + rebuild ``CO_OCCURS``). Concept types only — ``person``
154
+ is never a source or target.
155
+
156
+ Returns the :class:`~brain.graph_rag.aliases.AliasResult`; an empty rule set
157
+ (nothing to collapse) returns a zero-valued result without opening a
158
+ transaction, so callers can invoke this unconditionally. ``dry_run=True``
159
+ reports the would-be merges without persisting. ``config`` is forwarded to
160
+ ``merge_aliases`` so the post-merge edge recompute uses the caller's
161
+ ``generic_df_ratio`` (its ``tenant_id`` MUST match ``tenant_id``).
162
+
163
+ **Display-name preservation.** ``merge_aliases``'s find-or-create
164
+ (:func:`brain.graph_rag.aliases._upsert_entity`) rewrites the target row's
165
+ ``name`` to ``humanize_person_name(canonical_key)`` — fine for a curated alias
166
+ target the operator named by key, but for an auto-collapse the target is an
167
+ EXISTING concept row carrying the extractor's proper surface form (e.g.
168
+ ``"AcmePlatform"``), which the humanized strip-all key would clobber to
169
+ ``"Acmeplatform"`` (camelCase lost). To avoid changing shared
170
+ ``merge_aliases`` behavior (which curated aliases + their tests rely on), this
171
+ captures the BEST surface form across ALL merged variants BEFORE the merge
172
+ (:func:`_best_display_names` / :func:`_best_surface_form` — F1: a branded
173
+ mixed-case form like ``"AcmePlatform"`` beats an all-lowercase ``"acmeplatform"``
174
+ even when the lowercase variant wins TYPE precedence) and restores it —
175
+ relationally AND on the AGE vertex — AFTER, so the merged concept keeps the
176
+ best real display name rather than the winning type's possibly-lowercase one.
177
+ """
178
+ rules = generate_cross_type_collapse_rules(conn, tenant_id)
179
+ if not rules:
180
+ return AliasResult(tenant_id=tenant_id, rules_total=0, dry_run=dry_run)
181
+ _validate_alias_graph(rules) # chain/cycle/dup-source (defensive; F7)
182
+ # Snapshot the BEST surface form across all merged variants before
183
+ # merge_aliases overwrites the winner's name with the humanized key (F1).
184
+ best_names = _best_display_names(conn, tenant_id, rules)
185
+ result = merge_aliases(
186
+ conn, tenant_id, rules, backend, dry_run=dry_run, config=config
187
+ )
188
+ if not dry_run and result.rules_applied > 0:
189
+ _restore_winner_display_names(conn, tenant_id, backend, best_names)
190
+ return result
191
+
192
+
193
+ def _uppercase_count(name: str) -> int:
194
+ """Count uppercase letters in ``name`` — a proxy for proper branding/casing."""
195
+ return sum(1 for char in name if char.isupper())
196
+
197
+
198
+ def _is_all_caps(name: str) -> bool:
199
+ """True when ``name`` has letters but NO lowercase — an all-caps "shout".
200
+
201
+ Distinguishes ``"CONTOSO"`` / ``"SDKS"`` (shouting) from properly-cased
202
+ ``"Contoso"`` / ``"Sdks"``. A genuine all-caps acronym (``"API"``) is still
203
+ kept when it is the only / best variant — the guard only DEMOTES an all-caps
204
+ form when a mixed-case sibling exists.
205
+ """
206
+ return any(char.isalpha() for char in name) and not any(
207
+ char.islower() for char in name
208
+ )
209
+
210
+
211
+ def _best_surface_form(variants: list[tuple[str, int]]) -> str:
212
+ """Pick the best display name among merged variants (F1, deterministic).
213
+
214
+ ``variants`` is ``[(name, doc_count), …]`` — the surface forms of every
215
+ concept row sharing one ``canonical_key`` that the collapse merges. The
216
+ heuristic is a total order (never a tie), in priority:
217
+
218
+ 1. prefer a mixed/cased form over an ALL-CAPS "shout" (``"Contoso"`` over
219
+ ``"CONTOSO"``, ``"Sdks"`` over ``"SDKS"``) — a lone all-caps acronym is
220
+ still kept when no mixed-case sibling exists;
221
+ 2. more uppercase letters (proper branding) — a mixed/branded form beats an
222
+ all-lowercase one (``AcmePlatform`` over ``acmeplatform``) and a
223
+ better-cased branded form beats a worse one (``AI::Client`` over
224
+ ``Ai::Client``);
225
+ 3. higher ``doc_count`` (the most-attested form);
226
+ 4. longer name;
227
+ 5. lexicographically smallest (final deterministic tiebreak).
228
+
229
+ Known trade-off: a one-off variant with spurious extra capitals (a typo like
230
+ ``"ALBa"``) can outrank a more-attested correctly-cased form, because casing
231
+ signal is ranked above ``doc_count``. Acceptable for a personal corpus, and
232
+ curated aliases can override any specific case. Returns ``""`` only for empty
233
+ input (caller guards).
234
+ """
235
+ if not variants:
236
+ return ""
237
+ return min(
238
+ variants,
239
+ key=lambda v: (_is_all_caps(v[0]), -_uppercase_count(v[0]), -v[1], -len(v[0]), v[0]),
240
+ )[0]
241
+
242
+
243
+ def _best_display_names(
244
+ conn: psycopg.Connection[Any], tenant_id: str, rules: list[AliasRule]
245
+ ) -> dict[tuple[str, str], str]:
246
+ """Capture the BEST surface form to assign each merge winner (F1).
247
+
248
+ For every distinct rule TARGET ``(to_type, to_key)`` — the concept row the
249
+ lower-precedence fragments collapse into — collect the ``(name, doc_count)``
250
+ of EVERY concept row sharing that ``canonical_key`` (the winner plus all the
251
+ soon-to-be-GC'd source variants) and pick the best via
252
+ :func:`_best_surface_form`. Returns ``{(to_type, to_key): best_name}``. Read
253
+ BEFORE ``merge_aliases`` because the source variants are deleted by the
254
+ merge's orphan GC, so their surface forms must be captured first. Restricted
255
+ to concept types (``person`` is never part of a cross-type collapse).
256
+ Parameterized SQL.
257
+ """
258
+ names: dict[tuple[str, str], str] = {}
259
+ _, concept_types = _concept_precedence()
260
+ for to_type, to_key in sorted({(r.to_type, r.to_key) for r in rules}):
261
+ rows = conn.execute(
262
+ "SELECT name, doc_count FROM graph_entities "
263
+ "WHERE tenant_id = %s AND canonical_key = %s AND entity_type = ANY(%s)",
264
+ (tenant_id, to_key, concept_types),
265
+ ).fetchall()
266
+ variants = [(str(r[0]), int(r[1])) for r in rows]
267
+ best = _best_surface_form(variants)
268
+ if best:
269
+ names[(to_type, to_key)] = best
270
+ return names
271
+
272
+
273
+ def _restore_winner_display_names(
274
+ conn: psycopg.Connection[Any],
275
+ tenant_id: str,
276
+ backend: GraphBackend,
277
+ winner_names: dict[tuple[str, str], str],
278
+ ) -> None:
279
+ """Restore the captured target ``name``s relationally + on the AGE vertex.
280
+
281
+ Undoes ``merge_aliases``'s humanized-key overwrite of each winning concept
282
+ row's ``name`` (see :func:`collapse_cross_type_concepts`). Runs in one
283
+ transaction: UPDATE the relational ``name`` back to its captured surface form,
284
+ then re-MERGE the corrected vertices via
285
+ :meth:`~brain.graph_rag.backends.base.GraphBackend.upsert_entities` so the AGE
286
+ label matches too. A winner that no longer exists is skipped (it cannot be
287
+ GC'd — it carries the merged mentions). Parameterized SQL.
288
+ """
289
+ if not winner_names:
290
+ return
291
+ with conn.transaction():
292
+ corrected: list[GraphEntity] = []
293
+ for (entity_type, canonical_key), name in winner_names.items():
294
+ row = conn.execute(
295
+ "UPDATE graph_entities SET name = %s "
296
+ "WHERE tenant_id = %s AND entity_type = %s AND canonical_key = %s "
297
+ "RETURNING id::text",
298
+ (name, tenant_id, entity_type, canonical_key),
299
+ ).fetchone()
300
+ if row is None:
301
+ continue
302
+ corrected.append(
303
+ GraphEntity(
304
+ id=str(row[0]),
305
+ entity_type=entity_type,
306
+ name=name,
307
+ canonical_key=canonical_key,
308
+ tenant_id=tenant_id,
309
+ )
310
+ )
311
+ if corrected:
312
+ backend.upsert_entities(conn, tenant_id, corrected)