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,473 @@
1
+ """Pure (instance-state-free) helpers + constants for the AGE backend (G2 split).
2
+
3
+ Extracted from :mod:`brain.graph_rag.backends.age` (the G2 wave-boundary
4
+ file-size split, mirroring the G1 :mod:`brain.graph_rag.aggregates` / G2-c
5
+ :mod:`brain.graph_rag.relational` extractions) so
6
+ :class:`~brain.graph_rag.backends.age.AgeBackend` stays a focused class under the
7
+ file-size cap. Everything here is free of ``AgeBackend`` instance state — module
8
+ constants, agtype parsing, input validation, the inline Cypher property-map
9
+ builder, the BTREE property-index DDL builder, and the **relational**
10
+ ``scope_person`` two-hop query. The :meth:`AgeBackend.scope_person` Protocol
11
+ method delegates to :func:`_scope_person_relational` here; the rest of the
12
+ backend imports the constants + validators by their original names, so this is a
13
+ pure move with no behavior change.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import logging
19
+ import re
20
+ from collections.abc import Mapping
21
+ from typing import Any
22
+
23
+ import psycopg
24
+ from psycopg import sql
25
+
26
+ from ...errors import GraphBackendError
27
+ from .base import PersonScope
28
+
29
+ _logger = logging.getLogger(__name__)
30
+
31
+ # AGE graph names are SQL identifiers (they back a Postgres schema). The name is
32
+ # a controlled config value, never user input, but it is embedded as a literal
33
+ # in the ``cypher()`` call (it cannot be a bound parameter), so we validate it
34
+ # defensively before use — an invalid name is a caller bug, not a DB failure.
35
+ _GRAPH_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
36
+
37
+ # Cypher property keys must be plain identifiers. AGE rejects ``SET x += $param``
38
+ # (a bare param as the map — "SET clause expects a map"), so dynamic property
39
+ # maps are built inline with the *keys* interpolated (validated against this
40
+ # pattern to bar injection) and the *values* routed through agtype params.
41
+ _PROP_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
42
+
43
+ # Reserved identity keys per label — a ``SET x += {...}`` over a caller property
44
+ # bag must NEVER overwrite these (doing so would move/strand a vertex across its
45
+ # tenant/UUID identity, breaking the scoped MERGE + delete/create idempotency).
46
+ # Note: ``canonical_key`` is deliberately NOT reserved for entities — it is a
47
+ # legitimately mutable property that ``upsert_entities`` owns (the AGE vertex
48
+ # identity is ``(entity_uuid, tenant_id)``, not canonical_key).
49
+ _ENTITY_RESERVED_KEYS = frozenset({"tenant_id", "entity_uuid"})
50
+ _DOCUMENT_RESERVED_KEYS = frozenset({"tenant_id", "document_uuid"})
51
+
52
+ # AGE serializes graph elements with a trailing ``::edge`` / ``::vertex`` /
53
+ # ``::path`` type annotation appended to otherwise-valid JSON. Strip it before
54
+ # ``json.loads``.
55
+ _AGTYPE_ANNOTATION_RE = re.compile(r"::(?:edge|vertex|path|numeric)\b")
56
+
57
+ # Vertex/edge labels (spec §5b). Vertex labels back the tables the property
58
+ # indexes target, so they are created before the edge labels and indexes.
59
+ _VERTEX_LABELS = ("Entity", "Document")
60
+ _EDGE_LABELS = ("MENTIONED_IN", "CO_OCCURS")
61
+
62
+ # Property indexes (spec §5b): (label, property, index-name). Index names live
63
+ # in the graph's own schema, so they need no graph prefix.
64
+ _PROPERTY_INDEXES = (
65
+ ("Entity", "tenant_id", "idx_entity_tenant_id"),
66
+ ("Entity", "entity_uuid", "idx_entity_entity_uuid"),
67
+ ("Entity", "canonical_key", "idx_entity_canonical_key"),
68
+ ("CO_OCCURS", "weight", "idx_cooccur_weight"),
69
+ )
70
+
71
+ # Traversal correctness bound. The *functional* frontier cap is applied in
72
+ # Python AFTER affinity scoring (a Cypher ``LIMIT`` before scoring would
73
+ # silently drop the highest-affinity entities — AGE cannot ``ORDER BY`` true
74
+ # affinity = ∏ weights, having no list-comprehension/reduce). So traverse() must
75
+ # score EVERY within-depth path. To stay bounded against a pathological fan-out
76
+ # it fetches at most this many paths and DETECTS overflow: if more paths exist
77
+ # than the bound, it raises rather than returning a silently-truncated (possibly
78
+ # wrong) best-per-entity. The bound is generous; tightening/perf is G2's P95
79
+ # gate. Result: traverse() is either correct or a loud failure, never silently
80
+ # wrong.
81
+ _TRAVERSE_MAX_PATHS_MULTIPLIER = 50
82
+ _TRAVERSE_MAX_PATHS_FLOOR = 5000
83
+
84
+
85
+ def _agtype_loads(raw: Any) -> Any:
86
+ """Parse one agtype column value (a ``str`` or ``None``) into Python.
87
+
88
+ Strips AGE's ``::edge``/``::vertex``/``::path`` annotations, then
89
+ ``json.loads`` — which covers scalars (``"x"``→str, ``3``→int,
90
+ ``1.5``→float, ``null``→None) and the list/dict shapes of
91
+ ``relationships``/``nodes`` results.
92
+ """
93
+ if raw is None:
94
+ return None
95
+ if not isinstance(raw, str): # pragma: no cover - psycopg yields agtype as str
96
+ return raw
97
+ cleaned = _AGTYPE_ANNOTATION_RE.sub("", raw)
98
+ try:
99
+ return json.loads(cleaned)
100
+ except json.JSONDecodeError as exc:
101
+ raise GraphBackendError(
102
+ f"could not parse agtype result: {raw!r}"
103
+ ) from exc
104
+
105
+
106
+ def _require_autocommit(conn: psycopg.Connection[Any], operation: str) -> None:
107
+ """Guard operations whose AGE catalog DDL needs autocommit (psycopg v3)."""
108
+ if not conn.autocommit:
109
+ raise GraphBackendError(
110
+ f"{operation} requires an autocommit connection — AGE catalog DDL "
111
+ "does not run reliably inside an open transaction under psycopg v3"
112
+ )
113
+
114
+
115
+ def _require_positive_int(value: int, name: str) -> None:
116
+ """Validate a value is a genuine positive int before Cypher interpolation.
117
+
118
+ ``depth`` / ``frontier_cap`` are interpolated into the generated Cypher
119
+ template, and a type hint is NOT an injection boundary — so verify the
120
+ value is an actual ``int`` (``bool`` is an ``int`` subclass; exclude it)
121
+ and ``>= 1`` *before* it ever reaches an f-string. A non-int is a caller
122
+ bug, surfaced as :class:`GraphBackendError`.
123
+ """
124
+ if not isinstance(value, int) or isinstance(value, bool):
125
+ raise GraphBackendError(
126
+ f"{name} must be an int, got {type(value).__name__}"
127
+ )
128
+ if value < 1:
129
+ raise GraphBackendError(f"{name} must be >= 1, got {value}")
130
+
131
+
132
+ def _require_weight_floor(value: float) -> None:
133
+ """Validate ``min_edge_weight`` is a real number in ``[0, 1]``.
134
+
135
+ Not interpolated into Cypher (compared in Python), but a non-numeric
136
+ value would raise a raw ``TypeError`` at comparison time — convert that
137
+ to a typed :class:`GraphBackendError` here. ``bool`` is rejected as a
138
+ nonsensical weight floor.
139
+ """
140
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
141
+ raise GraphBackendError(
142
+ f"min_edge_weight must be a number, got {type(value).__name__}"
143
+ )
144
+ if not 0.0 <= value <= 1.0:
145
+ raise GraphBackendError(
146
+ f"min_edge_weight must be in [0, 1], got {value}"
147
+ )
148
+
149
+
150
+ def _inline_set_map(
151
+ props: Mapping[str, Any],
152
+ *,
153
+ reserved: frozenset[str],
154
+ context: str,
155
+ ) -> tuple[str, dict[str, Any]]:
156
+ """Build an inline Cypher property map ``{k: $pk, ...}`` + its params.
157
+
158
+ AGE rejects ``SET x += $param`` (a bare param map), so a dynamic map is
159
+ emitted inline with the *keys* interpolated (validated as identifiers to
160
+ bar injection) and the *values* bound as agtype params (``p0``, ``p1``,
161
+ ...). ``reserved`` is the set of identity keys this map must NOT contain
162
+ — a caller property bag that tries to overwrite e.g. ``tenant_id`` /
163
+ ``document_uuid`` is a caller bug, rejected with :class:`GraphBackendError`
164
+ (``context`` names the offending bag for the message).
165
+ """
166
+ parts: list[str] = []
167
+ params: dict[str, Any] = {}
168
+ for index, (key, value) in enumerate(props.items()):
169
+ if key in reserved:
170
+ raise GraphBackendError(
171
+ f"{context} may not overwrite the reserved identity key "
172
+ f"{key!r}"
173
+ )
174
+ if not _PROP_KEY_RE.match(key):
175
+ raise GraphBackendError(
176
+ f"invalid property key {key!r}: must be a Cypher identifier "
177
+ r"matching ^[A-Za-z_][A-Za-z0-9_]*$"
178
+ )
179
+ param_key = f"p{index}"
180
+ parts.append(f"{key}: ${param_key}")
181
+ params[param_key] = value
182
+ return "{" + ", ".join(parts) + "}", params
183
+
184
+
185
+ def _all_same_tenant(
186
+ rels: list[dict[str, Any]],
187
+ nodes: list[dict[str, Any]],
188
+ tenant_id: str,
189
+ ) -> bool:
190
+ """True iff every edge and node in the path carries ``tenant_id``."""
191
+ return all(
192
+ element.get("properties", {}).get("tenant_id") == tenant_id
193
+ for element in (*rels, *nodes)
194
+ )
195
+
196
+
197
+ def _edge_weight(edge: Mapping[str, Any]) -> float:
198
+ """Extract a CO_OCCURS edge's ``weight`` property (required)."""
199
+ props = edge.get("properties", {})
200
+ weight = props.get("weight")
201
+ if weight is None:
202
+ raise GraphBackendError(
203
+ f"CO_OCCURS edge missing required 'weight' property: {edge!r}"
204
+ )
205
+ return float(weight)
206
+
207
+
208
+ def _property_index_ddl(
209
+ graph_name: str, label: str, prop: str, idx_name: str
210
+ ) -> sql.Composed:
211
+ """Build the BTREE property-index DDL for ``<graph>.<label>(prop)``."""
212
+ return sql.SQL(
213
+ "CREATE INDEX IF NOT EXISTS {idx} ON {tbl} USING btree "
214
+ "(ag_catalog.agtype_access_operator(properties, {prop}::ag_catalog.agtype))"
215
+ ).format(
216
+ idx=sql.Identifier(idx_name),
217
+ tbl=sql.Identifier(graph_name, label),
218
+ prop=sql.Literal(f'"{prop}"'),
219
+ )
220
+
221
+
222
+ def _scope_person_relational(
223
+ conn: psycopg.Connection[Any],
224
+ tenant_id: str,
225
+ seed_entity_uuid: str,
226
+ *,
227
+ frontier_cap: int,
228
+ ) -> PersonScope:
229
+ """Person -> docs -> co-mentioned entities, tenant-scoped (spec §6b).
230
+
231
+ **Indexed RELATIONAL two-hop over ``graph_entity_mentions`` — NOT Cypher.**
232
+ The earlier Cypher form (a ``MENTIONED_IN`` self-join) made AGE materialize a
233
+ tenant-wide join *before* restricting to the seed: AGE cannot estimate
234
+ ``agtype``-property selectivity, so its planner badly under-counts the seed
235
+ filter and builds hundreds of thousands of full vertex objects, OOM-killing
236
+ Postgres at the G2-k full-scale corpus (50k entities / 1M mentions / 10
237
+ tenants). The relational two-hop is the same shape the rest of the themes
238
+ path already uses (:mod:`brain.graph_rag.themes` computes df / lift / edges
239
+ relationally from ``graph_entity_mentions`` / ``graph_edge_contributions``),
240
+ and it is fully btree-index-supported:
241
+
242
+ * **hop 1** — the seed's documents: ``graph_entity_mentions`` filtered by
243
+ ``(tenant_id, entity_id = seed)``, served by the PK
244
+ ``(tenant_id, entity_id, document_id)``.
245
+ * **hop 2** — entities co-mentioned in those documents: the ``co``
246
+ self-join on ``(tenant_id, document_id)``, served by
247
+ ``idx_gem_document (tenant_id, document_id)`` (migration 012).
248
+
249
+ So per-query cost scales with the SEED's document count × per-document
250
+ fan-out, never the whole tenant's mention set, and the planner has sound
251
+ row estimates (no ``agtype`` opacity). The result is identical in shape
252
+ to the prior Cypher: the distinct co-mentioned entity set (seed
253
+ excluded) + the distinct connecting documents (those where the seed and
254
+ at least one other entity are both mentioned), tenant-scoped on every
255
+ clause and deterministically sorted.
256
+
257
+ **Why this backend reads relational here while :meth:`AgeBackend.traverse`
258
+ stays on Cypher:** ``scope_person`` returns a *set* whose only graph
259
+ structure is a one-hop-shared-document join — exactly what an indexed
260
+ relational query does best, and what :meth:`AgeBackend.refresh_cooccur_edges`
261
+ already reads relationally too. The variable-length affinity walk in
262
+ :meth:`AgeBackend.traverse` is the genuine graph workload and remains on AGE
263
+ Cypher (it passes the G2-k local P95 gate). A future
264
+ :class:`~brain.graph_rag.backends.base.GraphBackend` is free to implement
265
+ ``scope_person`` against its own store.
266
+
267
+ **Bounded ranked-truncation contract** (DIFFERS from
268
+ :meth:`AgeBackend.traverse`): a scope is a *set*, and the cheap relational
269
+ two-hop above is the same indexed query downstream df/lift/grouping already
270
+ runs — so when a hub person's co-mention set exceeds ``frontier_cap`` the
271
+ right answer is to keep the STRONGEST ``frontier_cap`` worth of scope, not to
272
+ crash. The common (under-cap) path fetches one extra row
273
+ (``LIMIT frontier_cap + 1``) and, when within bounds, returns the COMPLETE
274
+ set unchanged — identical behavior + cost to the prior form, no extra query.
275
+ Only on overflow does it fall through to :func:`_scope_person_truncated`,
276
+ which ranks co-entities by co-mention frequency (ties → newest shared doc →
277
+ entity id) and keeps the strongest prefix whose cumulative ``(co-entity,
278
+ document)`` row count stays within ``frontier_cap`` — preserving the exact
279
+ downstream bound the safe-cap was introduced to guarantee (the df/lift/
280
+ grouping work stays ≤ ``frontier_cap`` rows), and logging ONE actionable
281
+ WARNING (kept/total counts + the ``BRAIN_GRAPH_FRONTIER_CAP`` knob). The
282
+ returned ``entity_uuids`` / ``document_uuids`` are sorted ascending so the
283
+ result is deterministic regardless of row order.
284
+
285
+ **Why this truncates while :meth:`AgeBackend.traverse` still raises:**
286
+ ``traverse`` runs a variable-length Cypher affinity walk whose cap protects
287
+ against genuine *path explosion* — it cannot ``ORDER BY`` true affinity in
288
+ AGE, so it must score every within-depth path and a pre-score ``LIMIT`` would
289
+ silently drop the highest-affinity entities. Here the ranking key
290
+ (co-mention frequency) is computable directly in indexed SQL, so a correct,
291
+ deterministic top-``frontier_cap`` selection is cheap — truncation is safe
292
+ and is exactly what the headline themes/audio surfaces need for hub people.
293
+ """
294
+ # frontier_cap bounds the fetched rows (overflow detection). It is a
295
+ # bound parameter, not interpolated, but the positive-int contract
296
+ # mirrors the prior form.
297
+ _require_positive_int(frontier_cap, "frontier_cap")
298
+ # Fetch one extra row to DETECT overflow (see contract above). The seed
299
+ # uuid is bound (parameterized) on every clause — no string formatting of
300
+ # inputs into SQL. ``entity_id`` / ``document_id`` are uuid columns; the
301
+ # str params follow the established scalar convention (e.g.
302
+ # ``brain.graph_rag.relational.read_doc_mentions``).
303
+ fetch_limit = frontier_cap + 1
304
+ try:
305
+ rows = conn.execute(
306
+ "SELECT DISTINCT co.entity_id::text AS eid, "
307
+ "seed.document_id::text AS did "
308
+ "FROM graph_entity_mentions AS seed "
309
+ "JOIN graph_entity_mentions AS co "
310
+ " ON co.tenant_id = seed.tenant_id "
311
+ " AND co.document_id = seed.document_id "
312
+ "WHERE seed.tenant_id = %s AND seed.entity_id = %s "
313
+ " AND co.entity_id <> %s "
314
+ "LIMIT %s",
315
+ (tenant_id, seed_entity_uuid, seed_entity_uuid, fetch_limit),
316
+ ).fetchall()
317
+ except psycopg.Error as exc:
318
+ raise GraphBackendError(
319
+ f"relational person scope failed for seed {seed_entity_uuid!r} "
320
+ f"in tenant {tenant_id!r}: {exc}"
321
+ ) from exc
322
+ if len(rows) > frontier_cap:
323
+ # More co-mention rows than the bound → keep the strongest frontier_cap
324
+ # worth of scope (deterministic ranked truncation + WARNING), never a
325
+ # crash. Bounded exactly as before: the truncated scope feeds ≤
326
+ # frontier_cap rows downstream.
327
+ return _scope_person_truncated(
328
+ conn, tenant_id, seed_entity_uuid, frontier_cap=frontier_cap
329
+ )
330
+ entity_uuids: set[str] = set()
331
+ document_uuids: set[str] = set()
332
+ for eid, did in rows:
333
+ entity_uuids.add(str(eid))
334
+ document_uuids.add(str(did))
335
+ # Sort ascending so the (set-valued) scope is returned deterministically.
336
+ return PersonScope(
337
+ seed_entity_uuid=seed_entity_uuid,
338
+ entity_uuids=tuple(sorted(entity_uuids)),
339
+ document_uuids=tuple(sorted(document_uuids)),
340
+ tenant_id=tenant_id,
341
+ )
342
+
343
+
344
+ def _scope_person_truncated(
345
+ conn: psycopg.Connection[Any],
346
+ tenant_id: str,
347
+ seed_entity_uuid: str,
348
+ *,
349
+ frontier_cap: int,
350
+ ) -> PersonScope:
351
+ """Deterministically truncate an over-cap person scope to its strongest part.
352
+
353
+ Reached only from :func:`_scope_person_relational` when the seed's distinct
354
+ ``(co-entity, document)`` co-mention rows exceed ``frontier_cap``. Ranks the
355
+ co-mentioned entities by co-mention frequency with the seed (DESC), breaking
356
+ ties by newest shared document (DESC) then entity id (ASC) — a total order,
357
+ so the selection is reproducible across runs. It then keeps the strongest
358
+ prefix of entities whose cumulative co-mention-row count stays within
359
+ ``frontier_cap`` (always keeping at least the top entity), and resolves their
360
+ connecting documents (themselves capped to ``frontier_cap``, newest-first, to
361
+ bound the rare single-dominant-entity case). One actionable WARNING is logged
362
+ — never silent, never a crash. The result preserves the downstream envelope
363
+ the safe-cap guaranteed: at most ``frontier_cap`` entity rows and at most
364
+ ``frontier_cap`` documents reach the df/lift/grouping stage.
365
+ """
366
+ # Rank query: one row per co-entity with its shared-doc count + newest shared
367
+ # doc. Bounded by the SEED's co-entity neighborhood (the legitimate scope,
368
+ # index-served via the gem PK + idx_gem_document), never a tenant-wide scan —
369
+ # and we only ever keep a ≤ frontier_cap prefix of it. All inputs bound
370
+ # (parameterized); the seed uuid casts against the uuid columns as elsewhere.
371
+ try:
372
+ ranked = conn.execute(
373
+ "SELECT co.entity_id::text AS eid, "
374
+ "COUNT(DISTINCT seed.document_id) AS comention_count, "
375
+ "MAX(d.ingested_at) AS newest_doc "
376
+ "FROM graph_entity_mentions AS seed "
377
+ "JOIN graph_entity_mentions AS co "
378
+ " ON co.tenant_id = seed.tenant_id "
379
+ " AND co.document_id = seed.document_id "
380
+ "JOIN documents AS d ON d.id = seed.document_id "
381
+ "WHERE seed.tenant_id = %s AND seed.entity_id = %s "
382
+ " AND co.entity_id <> %s "
383
+ "GROUP BY co.entity_id "
384
+ "ORDER BY comention_count DESC, newest_doc DESC, eid ASC",
385
+ (tenant_id, seed_entity_uuid, seed_entity_uuid),
386
+ ).fetchall()
387
+ except psycopg.Error as exc:
388
+ raise GraphBackendError(
389
+ f"relational person scope ranking failed for seed "
390
+ f"{seed_entity_uuid!r} in tenant {tenant_id!r}: {exc}"
391
+ ) from exc
392
+ total_rows = sum(int(count) for _eid, count, _newest in ranked)
393
+ # Greedy prefix by the (co-entity, document) row budget. Each kept entity is
394
+ # included with ALL its shared docs (a partial entity would distort its
395
+ # in-scope df), so we stop before the first entity that would overflow the
396
+ # cap. The top entity is always kept (its docs are bounded below) so a single
397
+ # dominant co-entity never collapses the scope to empty.
398
+ kept_entities: list[str] = []
399
+ kept_rows = 0
400
+ for eid, count, _newest in ranked:
401
+ count_int = int(count)
402
+ if kept_entities and kept_rows + count_int > frontier_cap:
403
+ break
404
+ kept_entities.append(str(eid))
405
+ kept_rows += count_int
406
+ document_uuids = _scope_connecting_documents(
407
+ conn, tenant_id, seed_entity_uuid, kept_entities, frontier_cap=frontier_cap
408
+ )
409
+ _logger.warning(
410
+ "graphrag person scope for seed %s exceeded frontier_cap=%d: kept the %d "
411
+ "strongest co-entities (%d of %d co-mention rows); raise "
412
+ "BRAIN_GRAPH_FRONTIER_CAP to widen the scope.",
413
+ seed_entity_uuid,
414
+ frontier_cap,
415
+ len(kept_entities),
416
+ kept_rows,
417
+ total_rows,
418
+ )
419
+ return PersonScope(
420
+ seed_entity_uuid=seed_entity_uuid,
421
+ entity_uuids=tuple(sorted(kept_entities)),
422
+ document_uuids=document_uuids,
423
+ tenant_id=tenant_id,
424
+ )
425
+
426
+
427
+ def _scope_connecting_documents(
428
+ conn: psycopg.Connection[Any],
429
+ tenant_id: str,
430
+ seed_entity_uuid: str,
431
+ kept_entities: list[str],
432
+ *,
433
+ frontier_cap: int,
434
+ ) -> tuple[str, ...]:
435
+ """Connecting documents for the kept co-entities, bounded + deterministic.
436
+
437
+ The distinct documents in which the seed and at least one ``kept_entities``
438
+ member are both mentioned. In the common truncated case this is already ≤
439
+ ``frontier_cap`` (the greedy budget bounds the kept rows); only the rare
440
+ single-dominant-entity case (one co-entity sharing more than ``frontier_cap``
441
+ docs with the seed) can exceed it, and there we keep the ``frontier_cap``
442
+ newest documents (``ingested_at`` DESC, id ASC) so the document array also
443
+ stays within the cap. Returned sorted ascending for determinism.
444
+ """
445
+ if not kept_entities:
446
+ return ()
447
+ try:
448
+ rows = conn.execute(
449
+ "SELECT DISTINCT seed.document_id::text AS did, "
450
+ "d.ingested_at AS doc_ts "
451
+ "FROM graph_entity_mentions AS seed "
452
+ "JOIN graph_entity_mentions AS co "
453
+ " ON co.tenant_id = seed.tenant_id "
454
+ " AND co.document_id = seed.document_id "
455
+ "JOIN documents AS d ON d.id = seed.document_id "
456
+ "WHERE seed.tenant_id = %s AND seed.entity_id = %s "
457
+ " AND co.entity_id = ANY(%s)",
458
+ (tenant_id, seed_entity_uuid, kept_entities),
459
+ ).fetchall()
460
+ except psycopg.Error as exc:
461
+ raise GraphBackendError(
462
+ f"relational person scope documents failed for seed "
463
+ f"{seed_entity_uuid!r} in tenant {tenant_id!r}: {exc}"
464
+ ) from exc
465
+ if len(rows) <= frontier_cap:
466
+ return tuple(sorted(str(did) for did, _doc_ts in rows))
467
+ # Single dominant co-entity overflowed the cap alone: keep the newest
468
+ # frontier_cap docs. Stable two-pass sort (id ASC, then ingested_at DESC)
469
+ # gives a total newest-first order with an id tiebreak.
470
+ by_id = sorted(rows, key=lambda row: str(row[0]))
471
+ newest_first = sorted(by_id, key=lambda row: row[1], reverse=True)
472
+ kept_docs = [str(did) for did, _doc_ts in newest_first[:frontier_cap]]
473
+ return tuple(sorted(kept_docs))