rememberstack 0.1.0__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 (186) hide show
  1. rememberstack/__init__.py +9 -0
  2. rememberstack/adapters/__init__.py +42 -0
  3. rememberstack/adapters/codex_writer.py +221 -0
  4. rememberstack/adapters/markitdown_converter.py +42 -0
  5. rememberstack/adapters/openrouter.py +136 -0
  6. rememberstack/adapters/selfhost/__init__.py +54 -0
  7. rememberstack/adapters/selfhost/forget.py +66 -0
  8. rememberstack/adapters/selfhost/git.py +374 -0
  9. rememberstack/adapters/selfhost/lance.py +328 -0
  10. rememberstack/adapters/selfhost/minio.py +279 -0
  11. rememberstack/adapters/selfhost/mounts.py +249 -0
  12. rememberstack/adapters/selfhost/object_store.py +130 -0
  13. rememberstack/adapters/selfhost/projection.py +80 -0
  14. rememberstack/adapters/selfhost/queue.py +137 -0
  15. rememberstack/adapters/selfhost/telemetry.py +45 -0
  16. rememberstack/adapters/selfhost/watcher.py +70 -0
  17. rememberstack/adapters/testing/__init__.py +15 -0
  18. rememberstack/adapters/testing/cost_meter.py +13 -0
  19. rememberstack/adapters/testing/model_provider.py +83 -0
  20. rememberstack/adapters/testing/queue.py +43 -0
  21. rememberstack/adapters/testing/telemetry.py +22 -0
  22. rememberstack/client.py +19 -0
  23. rememberstack/core/__init__.py +127 -0
  24. rememberstack/core/blockizer.py +189 -0
  25. rememberstack/core/chunker.py +216 -0
  26. rememberstack/core/consumption_skill.py +275 -0
  27. rememberstack/core/conversion.py +76 -0
  28. rememberstack/core/core_manifest.py +598 -0
  29. rememberstack/core/extension_packs.py +124 -0
  30. rememberstack/core/forget.py +17 -0
  31. rememberstack/core/knowledge_authored.py +276 -0
  32. rememberstack/core/knowledge_compile.py +215 -0
  33. rememberstack/core/knowledge_fact_sheet.py +210 -0
  34. rememberstack/core/knowledge_hashing.py +68 -0
  35. rememberstack/core/knowledge_planner.py +64 -0
  36. rememberstack/core/knowledge_writer.py +175 -0
  37. rememberstack/core/ranking.py +200 -0
  38. rememberstack/core/recipe_linter.py +149 -0
  39. rememberstack/core/section_snap.py +209 -0
  40. rememberstack/core/storage_routing.py +27 -0
  41. rememberstack/eval/__init__.py +53 -0
  42. rememberstack/eval/consumption.py +141 -0
  43. rememberstack/eval/contradiction.py +184 -0
  44. rememberstack/eval/harness.py +136 -0
  45. rememberstack/eval/lifecycle.py +400 -0
  46. rememberstack/eval/operational_scale.py +49 -0
  47. rememberstack/eval/resolution.py +255 -0
  48. rememberstack/eval/retrieval_spikes.py +50 -0
  49. rememberstack/eval/skeleton.py +231 -0
  50. rememberstack/llm/__init__.py +1 -0
  51. rememberstack/model/__init__.py +589 -0
  52. rememberstack/model/adjudication.py +100 -0
  53. rememberstack/model/auth.py +27 -0
  54. rememberstack/model/blocks.py +30 -0
  55. rememberstack/model/chunks.py +190 -0
  56. rememberstack/model/claims.py +162 -0
  57. rememberstack/model/client.py +98 -0
  58. rememberstack/model/clustering.py +54 -0
  59. rememberstack/model/component_version.py +124 -0
  60. rememberstack/model/consumption.py +88 -0
  61. rememberstack/model/conversion.py +31 -0
  62. rememberstack/model/deployment.py +53 -0
  63. rememberstack/model/documents.py +168 -0
  64. rememberstack/model/envelope.py +513 -0
  65. rememberstack/model/evaluation.py +72 -0
  66. rememberstack/model/forget.py +143 -0
  67. rememberstack/model/git.py +13 -0
  68. rememberstack/model/knowledge.py +840 -0
  69. rememberstack/model/knowledge_authored.py +325 -0
  70. rememberstack/model/knowledge_planner.py +431 -0
  71. rememberstack/model/lifecycle.py +42 -0
  72. rememberstack/model/model_provider.py +78 -0
  73. rememberstack/model/mounts.py +24 -0
  74. rememberstack/model/object_store.py +21 -0
  75. rememberstack/model/operational_scale.py +59 -0
  76. rememberstack/model/operations.py +153 -0
  77. rememberstack/model/processing.py +228 -0
  78. rememberstack/model/queue.py +73 -0
  79. rememberstack/model/recipes.py +83 -0
  80. rememberstack/model/relations.py +79 -0
  81. rememberstack/model/resolution.py +83 -0
  82. rememberstack/model/retrieval_spikes.py +62 -0
  83. rememberstack/model/sections.py +120 -0
  84. rememberstack/model/telemetry.py +30 -0
  85. rememberstack/ports/__init__.py +29 -0
  86. rememberstack/ports/auth.py +16 -0
  87. rememberstack/ports/connector.py +23 -0
  88. rememberstack/ports/cost_meter.py +17 -0
  89. rememberstack/ports/forget.py +20 -0
  90. rememberstack/ports/git.py +20 -0
  91. rememberstack/ports/model_provider.py +28 -0
  92. rememberstack/ports/mounts.py +16 -0
  93. rememberstack/ports/object_store.py +27 -0
  94. rememberstack/ports/p1_index.py +92 -0
  95. rememberstack/ports/purge.py +93 -0
  96. rememberstack/ports/queue.py +23 -0
  97. rememberstack/ports/telemetry.py +21 -0
  98. rememberstack/profiles/__init__.py +22 -0
  99. rememberstack/profiles/selfhost.py +324 -0
  100. rememberstack/profiles/selfhost_forget.py +158 -0
  101. rememberstack/profiles/selfhost_operations.py +95 -0
  102. rememberstack/py.typed +1 -0
  103. rememberstack/spine/__init__.py +93 -0
  104. rememberstack/spine/admission.py +26 -0
  105. rememberstack/spine/backfill.py +168 -0
  106. rememberstack/spine/catalog_contract.py +742 -0
  107. rememberstack/spine/chunk_catalog.py +237 -0
  108. rememberstack/spine/claim_catalog.py +298 -0
  109. rememberstack/spine/clustering.py +740 -0
  110. rememberstack/spine/component_versions.py +208 -0
  111. rememberstack/spine/consumption.py +81 -0
  112. rememberstack/spine/deployment_bootstrap.py +445 -0
  113. rememberstack/spine/document_catalog.py +621 -0
  114. rememberstack/spine/entity_registry.py +205 -0
  115. rememberstack/spine/extension_packs.py +220 -0
  116. rememberstack/spine/fact_catalog.py +571 -0
  117. rememberstack/spine/forget.py +1753 -0
  118. rememberstack/spine/knowledge.py +5467 -0
  119. rememberstack/spine/lifecycle.py +1071 -0
  120. rememberstack/spine/migrations/__init__.py +1 -0
  121. rememberstack/spine/migrations/_helpers.py +153 -0
  122. rememberstack/spine/migrations/env.py +58 -0
  123. rememberstack/spine/migrations/script.py.mako +27 -0
  124. rememberstack/spine/migrations/versions/__init__.py +1 -0
  125. rememberstack/spine/migrations/versions/p0_02_0001_extensions_enums.py +189 -0
  126. rememberstack/spine/migrations/versions/p0_02_0002_infrastructure_registries.py +321 -0
  127. rememberstack/spine/migrations/versions/p0_02_0003_entities_evaluation_e0_e1.py +631 -0
  128. rememberstack/spine/migrations/versions/p0_02_0004_claims_facts_evidence.py +411 -0
  129. rememberstack/spine/migrations/versions/p0_02_0005_projection_knowledge_retrieval.py +391 -0
  130. rememberstack/spine/migrations/versions/p0_02_0006_partitions_views.py +158 -0
  131. rememberstack/spine/migrations/versions/p2_06_0007_invalidated_outcome.py +26 -0
  132. rememberstack/spine/migrations/versions/p3_01_0008_document_version_target.py +58 -0
  133. rememberstack/spine/migrations/versions/p3_05_0009_reconcile_stage.py +27 -0
  134. rememberstack/spine/migrations/versions/p3_07_0010_lifecycle_eval_suite.py +25 -0
  135. rememberstack/spine/migrations/versions/p4_01_0011_survivor_view_rewrite.py +57 -0
  136. rememberstack/spine/migrations/versions/p6_02_0012_knowledge_compile_recovery.py +58 -0
  137. rememberstack/spine/migrations/versions/p6_04_0013_knowledge_writer_ledger.py +46 -0
  138. rememberstack/spine/migrations/versions/p6_05_0014_knowledge_planner_runtime.py +217 -0
  139. rememberstack/spine/migrations/versions/p6_06_0015_authored_dispatch_runtime.py +38 -0
  140. rememberstack/spine/migrations/versions/p7_02_0016_operational_eval_suite.py +19 -0
  141. rememberstack/spine/migrations/versions/p7_05_0017_hard_forget.py +55 -0
  142. rememberstack/spine/observation_adjudication.py +778 -0
  143. rememberstack/spine/operations.py +298 -0
  144. rememberstack/spine/projection.py +662 -0
  145. rememberstack/spine/recipes.py +276 -0
  146. rememberstack/spine/resolver.py +763 -0
  147. rememberstack/spine/review.py +650 -0
  148. rememberstack/spine/settings.py +22 -0
  149. rememberstack/spine/supersession.py +510 -0
  150. rememberstack/spine/sync.py +128 -0
  151. rememberstack/spine/work_ledger.py +816 -0
  152. rememberstack/surfaces/__init__.py +110 -0
  153. rememberstack/surfaces/cli.py +447 -0
  154. rememberstack/surfaces/consumption_skill.py +87 -0
  155. rememberstack/surfaces/graph_queries.py +698 -0
  156. rememberstack/surfaces/http_api.py +377 -0
  157. rememberstack/surfaces/mcp.py +67 -0
  158. rememberstack/surfaces/query_engine.py +1591 -0
  159. rememberstack/surfaces/recipe_executor.py +185 -0
  160. rememberstack/surfaces/recipe_surface.py +219 -0
  161. rememberstack/surfaces/remote_mcp.py +133 -0
  162. rememberstack/surfaces/sdk.py +324 -0
  163. rememberstack/workers/__init__.py +155 -0
  164. rememberstack/workers/base.py +312 -0
  165. rememberstack/workers/e0.py +577 -0
  166. rememberstack/workers/e1.py +425 -0
  167. rememberstack/workers/e2.py +525 -0
  168. rememberstack/workers/e3.py +434 -0
  169. rememberstack/workers/forget.py +299 -0
  170. rememberstack/workers/knowledge_authored.py +146 -0
  171. rememberstack/workers/knowledge_driver.py +735 -0
  172. rememberstack/workers/knowledge_fact_sheet.py +123 -0
  173. rememberstack/workers/knowledge_planner.py +325 -0
  174. rememberstack/workers/knowledge_writer.py +393 -0
  175. rememberstack/workers/operations.py +42 -0
  176. rememberstack/workers/p1.py +234 -0
  177. rememberstack/workers/p2.py +513 -0
  178. rememberstack/workers/p2_analytics.py +276 -0
  179. rememberstack/workers/p3.py +673 -0
  180. rememberstack/workers/reconcile.py +485 -0
  181. rememberstack/workers/sync.py +168 -0
  182. rememberstack-0.1.0.dist-info/METADATA +213 -0
  183. rememberstack-0.1.0.dist-info/RECORD +186 -0
  184. rememberstack-0.1.0.dist-info/WHEEL +4 -0
  185. rememberstack-0.1.0.dist-info/entry_points.txt +2 -0
  186. rememberstack-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,374 @@
1
+ """Plain local-Git remote plus D74 affected-path history erasure."""
2
+
3
+ from pathlib import Path
4
+ from pathlib import PurePosixPath
5
+ import shlex
6
+ import subprocess
7
+ from typing import Protocol
8
+ from uuid import UUID
9
+
10
+ from rememberstack.model import KRevision
11
+
12
+
13
+ class KPathCatalog(Protocol):
14
+ """Resolve synced owner blockers and manifest-nominated Git paths."""
15
+
16
+ def blocking_k_paths(self, *, deployment_id: UUID, doc_id: UUID) -> tuple[str, ...]:
17
+ """Return owner-controlled paths with standing lineage citations."""
18
+ ...
19
+
20
+ def k_paths_for_artifacts(
21
+ self, *, deployment_id: UUID, artifact_ids: tuple[UUID, ...]
22
+ ) -> tuple[str, ...]:
23
+ """Return exact current body and curation paths for affected artifacts."""
24
+ ...
25
+
26
+
27
+ class LocalGitRepository:
28
+ """Use one local working repository as self-host Plane-K truth."""
29
+
30
+ def __init__(
31
+ self,
32
+ *,
33
+ repository: Path,
34
+ path_catalog: KPathCatalog,
35
+ author_name: str,
36
+ author_email: str,
37
+ ) -> None:
38
+ """Bind an existing clean repository and explicit commit identity."""
39
+ self._repository = repository.resolve(strict=True)
40
+ self._path_catalog = path_catalog
41
+ self._author_name = author_name
42
+ self._author_email = author_email
43
+ if (
44
+ _output(
45
+ arguments=(
46
+ "git",
47
+ "-C",
48
+ str(self._repository),
49
+ "rev-parse",
50
+ "--is-bare-repository",
51
+ )
52
+ )
53
+ == "true"
54
+ ):
55
+ raise ValueError("self-host Plane-K truth must be a working repository")
56
+
57
+ def checkout(self, *, destination: Path) -> KRevision:
58
+ """Clone the local truth repository into one driver-owned worktree."""
59
+ _run(
60
+ arguments=(
61
+ "git",
62
+ "clone",
63
+ "--quiet",
64
+ str(self._repository),
65
+ str(destination),
66
+ )
67
+ )
68
+ return KRevision(
69
+ root=_output(arguments=("git", "-C", str(destination), "rev-parse", "HEAD"))
70
+ )
71
+
72
+ def publish(self, *, worktree: Path) -> KRevision:
73
+ """Commit a prepared driver tree and push its current branch to local truth."""
74
+ _run(arguments=("git", "-C", str(worktree), "add", "-A"))
75
+ if (
76
+ _status(
77
+ arguments=("git", "-C", str(worktree), "diff", "--cached", "--quiet")
78
+ )
79
+ != 0
80
+ ):
81
+ _run(
82
+ arguments=(
83
+ "git",
84
+ "-C",
85
+ str(worktree),
86
+ "-c",
87
+ f"user.name={self._author_name}",
88
+ "-c",
89
+ f"user.email={self._author_email}",
90
+ "commit",
91
+ "--quiet",
92
+ "-m",
93
+ "rememberstack knowledge update",
94
+ )
95
+ )
96
+ _run(
97
+ arguments=(
98
+ "git",
99
+ "-C",
100
+ str(self._repository),
101
+ "fetch",
102
+ "--quiet",
103
+ str(worktree),
104
+ "HEAD:refs/rememberstack/publish",
105
+ )
106
+ )
107
+ _run(
108
+ arguments=(
109
+ "git",
110
+ "-C",
111
+ str(self._repository),
112
+ "reset",
113
+ "--hard",
114
+ "refs/rememberstack/publish",
115
+ )
116
+ )
117
+ _run(
118
+ arguments=(
119
+ "git",
120
+ "-C",
121
+ str(self._repository),
122
+ "update-ref",
123
+ "-d",
124
+ "refs/rememberstack/publish",
125
+ )
126
+ )
127
+ return KRevision(
128
+ root=_output(arguments=("git", "-C", str(worktree), "rev-parse", "HEAD"))
129
+ )
130
+
131
+ def blocking_redaction_paths(
132
+ self, *, deployment_id: UUID, doc_id: UUID
133
+ ) -> tuple[str, ...]:
134
+ """Delegate to current synced K ownership/citation state."""
135
+ return self._path_catalog.blocking_k_paths(
136
+ deployment_id=deployment_id, doc_id=doc_id
137
+ )
138
+
139
+ def purge_artifacts(
140
+ self, *, deployment_id: UUID, forget_id: UUID, artifact_ids: tuple[UUID, ...]
141
+ ) -> None:
142
+ """Erase affected paths from every ref and re-add sanitized current files."""
143
+ current_paths = self._validated_paths(
144
+ paths=self._path_catalog.k_paths_for_artifacts(
145
+ deployment_id=deployment_id, artifact_ids=artifact_ids
146
+ )
147
+ )
148
+ if not current_paths or self._honored(forget_id=forget_id, paths=current_paths):
149
+ return
150
+ paths = self._historical_paths(current_paths=current_paths)
151
+ if _output(
152
+ arguments=("git", "-C", str(self._repository), "status", "--porcelain")
153
+ ):
154
+ raise RuntimeError(
155
+ "Plane-K repository must be clean before history erasure"
156
+ )
157
+ current = {
158
+ path: target.read_bytes() if target.is_file() else None
159
+ for path in current_paths
160
+ for target in (self._repository / path,)
161
+ }
162
+ removal = "git rm -r --cached --ignore-unmatch -- " + " ".join(
163
+ shlex.quote(path) for path in paths
164
+ )
165
+ _run(
166
+ arguments=(
167
+ "git",
168
+ "-C",
169
+ str(self._repository),
170
+ "filter-branch",
171
+ "--force",
172
+ "--index-filter",
173
+ removal,
174
+ "--tag-name-filter",
175
+ "cat",
176
+ "--",
177
+ "--all",
178
+ )
179
+ )
180
+ self._drop_original_refs()
181
+ for path, content in current.items():
182
+ target = self._repository / path
183
+ if content is None:
184
+ target.unlink(missing_ok=True)
185
+ continue
186
+ target.parent.mkdir(parents=True, exist_ok=True)
187
+ target.write_bytes(content)
188
+ _run(
189
+ arguments=(
190
+ "git",
191
+ "-C",
192
+ str(self._repository),
193
+ "add",
194
+ "-A",
195
+ "--",
196
+ *current_paths,
197
+ )
198
+ )
199
+ if (
200
+ _status(
201
+ arguments=(
202
+ "git",
203
+ "-C",
204
+ str(self._repository),
205
+ "diff",
206
+ "--cached",
207
+ "--quiet",
208
+ )
209
+ )
210
+ != 0
211
+ ):
212
+ _run(
213
+ arguments=(
214
+ "git",
215
+ "-C",
216
+ str(self._repository),
217
+ "-c",
218
+ f"user.name={self._author_name}",
219
+ "-c",
220
+ f"user.email={self._author_email}",
221
+ "commit",
222
+ "--quiet",
223
+ "-m",
224
+ f"hard-forget {forget_id}",
225
+ )
226
+ )
227
+ head = _output(
228
+ arguments=("git", "-C", str(self._repository), "rev-parse", "HEAD")
229
+ )
230
+ _run(
231
+ arguments=(
232
+ "git",
233
+ "-C",
234
+ str(self._repository),
235
+ "update-ref",
236
+ self._ack_ref(forget_id=forget_id),
237
+ head,
238
+ )
239
+ )
240
+ _run(
241
+ arguments=(
242
+ "git",
243
+ "-C",
244
+ str(self._repository),
245
+ "reflog",
246
+ "expire",
247
+ "--expire=now",
248
+ "--all",
249
+ )
250
+ )
251
+ _run(arguments=("git", "-C", str(self._repository), "gc", "--prune=now"))
252
+
253
+ def verify_artifacts_purged(
254
+ self, *, deployment_id: UUID, forget_id: UUID, artifact_ids: tuple[UUID, ...]
255
+ ) -> None:
256
+ """Prove affected current paths have only their sanitized post-purge history."""
257
+ paths = self._validated_paths(
258
+ paths=self._path_catalog.k_paths_for_artifacts(
259
+ deployment_id=deployment_id, artifact_ids=artifact_ids
260
+ )
261
+ )
262
+ if paths and not self._honored(forget_id=forget_id, paths=paths):
263
+ raise RuntimeError(
264
+ f"Plane-K purge verification failed for forget_id {forget_id}"
265
+ )
266
+
267
+ def _honored(self, *, forget_id: UUID, paths: tuple[str, ...]) -> bool:
268
+ """Validate the store-local acknowledgement and single current path history."""
269
+ if (
270
+ _status(
271
+ arguments=(
272
+ "git",
273
+ "-C",
274
+ str(self._repository),
275
+ "show-ref",
276
+ "--verify",
277
+ "--quiet",
278
+ self._ack_ref(forget_id=forget_id),
279
+ )
280
+ )
281
+ != 0
282
+ ):
283
+ return False
284
+ commits = _output(
285
+ arguments=(
286
+ "git",
287
+ "-C",
288
+ str(self._repository),
289
+ "log",
290
+ "--all",
291
+ "--format=%H",
292
+ "--",
293
+ *paths,
294
+ )
295
+ ).splitlines()
296
+ return len(set(commits)) <= 1
297
+
298
+ def _validated_paths(self, *, paths: tuple[str, ...]) -> tuple[str, ...]:
299
+ """Reject absolute/traversing Git paths before filesystem or shell use."""
300
+ result: list[str] = []
301
+ for value in sorted(set(paths)):
302
+ path = PurePosixPath(value)
303
+ if not value or path.is_absolute() or ".." in path.parts:
304
+ raise ValueError(f"unsafe Plane-K path {value!r}")
305
+ candidate = (self._repository / path).resolve()
306
+ if not candidate.is_relative_to(self._repository):
307
+ raise ValueError(f"Plane-K path {value!r} escapes the repository")
308
+ result.append(path.as_posix())
309
+ return tuple(result)
310
+
311
+ def _historical_paths(self, *, current_paths: tuple[str, ...]) -> tuple[str, ...]:
312
+ """Follow renames and return every historical name of affected files."""
313
+ paths = set(current_paths)
314
+ for current in current_paths:
315
+ output = _output(
316
+ arguments=(
317
+ "git",
318
+ "-C",
319
+ str(self._repository),
320
+ "log",
321
+ "--all",
322
+ "--follow",
323
+ "--name-status",
324
+ "--format=",
325
+ "--",
326
+ current,
327
+ )
328
+ )
329
+ for line in output.splitlines():
330
+ fields = line.split("\t")
331
+ if len(fields) >= 2:
332
+ paths.update(fields[1:])
333
+ return self._validated_paths(paths=tuple(paths))
334
+
335
+ def _drop_original_refs(self) -> None:
336
+ """Delete filter-branch backup refs so forgotten history is unreachable."""
337
+ refs = _output(
338
+ arguments=(
339
+ "git",
340
+ "-C",
341
+ str(self._repository),
342
+ "for-each-ref",
343
+ "--format=%(refname)",
344
+ "refs/original/",
345
+ )
346
+ ).splitlines()
347
+ for ref in refs:
348
+ _run(
349
+ arguments=("git", "-C", str(self._repository), "update-ref", "-d", ref)
350
+ )
351
+
352
+ @staticmethod
353
+ def _ack_ref(*, forget_id: UUID) -> str:
354
+ """Return the receipt ref that disappears with an independently restored repo."""
355
+ return f"refs/rememberstack/forget/{forget_id}"
356
+
357
+
358
+ def _run(*, arguments: tuple[str, ...]) -> None:
359
+ """Run one Git command and preserve its full failing process exception."""
360
+ subprocess.run(arguments, check=True, capture_output=True, text=True)
361
+
362
+
363
+ def _output(*, arguments: tuple[str, ...]) -> str:
364
+ """Run one Git read and return its exact trimmed stdout."""
365
+ return subprocess.run(
366
+ arguments, check=True, capture_output=True, text=True
367
+ ).stdout.strip()
368
+
369
+
370
+ def _status(*, arguments: tuple[str, ...]) -> int:
371
+ """Run a Git predicate whose nonzero status is an expected false result."""
372
+ return subprocess.run(
373
+ arguments, check=False, capture_output=True, text=True
374
+ ).returncode
@@ -0,0 +1,328 @@
1
+ """The embedded-LanceDB P1 chunk index: one table of text + vectors (D8)."""
2
+
3
+ from datetime import timedelta
4
+ import math
5
+ from pathlib import Path
6
+ from typing import cast
7
+ from typing import Final
8
+ from uuid import UUID
9
+
10
+ import lancedb
11
+ from lancedb.index import Bitmap
12
+ from lancedb.index import BTree
13
+ from lancedb.index import IvfFlat
14
+ from lancedb.query import LanceVectorQueryBuilder
15
+ from lancedb.table import Table
16
+
17
+ from rememberstack.model import P1ChunkRow
18
+ from rememberstack.model import P1ClaimRow
19
+ from rememberstack.model import P1EntityRow
20
+ from rememberstack.model import P1FactRow
21
+
22
+ _CHUNK_TABLE = "chunks"
23
+ _CLAIM_TABLE = "claims"
24
+ _FACT_TABLE = "facts"
25
+ _ENTITY_TABLE = "entities"
26
+
27
+ LANCE_TARGET_PARTITION_ROWS: Final = 8_192
28
+ """WP-5.6 IVF_FLAT target: one vector partition per roughly 8k rows."""
29
+
30
+ LANCE_NPROBES: Final = 20
31
+ """WP-5.6 query probe count for filtered ANN reads."""
32
+
33
+ _MIN_VECTOR_INDEX_ROWS: Final = 256
34
+
35
+
36
+ class LanceChunkIndex:
37
+ """The self-host P1 chunk table in an embedded Lance dataset directory."""
38
+
39
+ def __init__(self, *, root: Path) -> None:
40
+ """Bind the index to its dataset directory, creating it if absent."""
41
+ self._connection = lancedb.connect(str(root))
42
+
43
+ def upsert_chunks(self, *, rows: tuple[P1ChunkRow, ...]) -> None:
44
+ """Insert or replace rows by chunk_id; re-runs are idempotent."""
45
+ if not rows:
46
+ return
47
+ payload = [
48
+ {
49
+ "chunk_id": str(row.chunk_id),
50
+ "deployment_id": str(row.deployment_id),
51
+ "doc_id": str(row.doc_id),
52
+ "version_id": str(row.version_id),
53
+ "section_role": row.section_role,
54
+ "text": row.text,
55
+ "vector": list(row.vector),
56
+ }
57
+ for row in rows
58
+ ]
59
+ self._upsert(table=_CHUNK_TABLE, key="chunk_id", payload=payload)
60
+
61
+ def chunk_vectors(
62
+ self, *, deployment_id: str, chunk_ids: tuple[str, ...]
63
+ ) -> dict[str, tuple[float, ...]]:
64
+ """Stored vectors for the requested ids (absent ids are omitted)."""
65
+ deployment_id = str(UUID(deployment_id))
66
+ if not chunk_ids or _CHUNK_TABLE not in self._connection.table_names():
67
+ return {}
68
+ ids = ", ".join(f"'{UUID(item)}'" for item in chunk_ids)
69
+ rows = (
70
+ self._connection.open_table(_CHUNK_TABLE)
71
+ .search()
72
+ .where(f"deployment_id = '{deployment_id}' AND chunk_id IN ({ids})")
73
+ .limit(len(chunk_ids))
74
+ .to_list()
75
+ )
76
+ return {row["chunk_id"]: tuple(row["vector"]) for row in rows}
77
+
78
+ def upsert_claims(self, *, rows: tuple[P1ClaimRow, ...]) -> None:
79
+ """Insert or replace claims-channel rows by claim_id; idempotent."""
80
+ self._upsert(
81
+ table=_CLAIM_TABLE,
82
+ key="claim_id",
83
+ payload=[
84
+ {
85
+ "claim_id": str(row.claim_id),
86
+ "deployment_id": str(row.deployment_id),
87
+ "doc_id": str(row.doc_id),
88
+ "chunk_id": str(row.chunk_id),
89
+ "text": row.text,
90
+ "is_current_testimony": row.is_current_testimony,
91
+ "is_attributed": row.is_attributed,
92
+ "vector": list(row.vector),
93
+ }
94
+ for row in rows
95
+ ],
96
+ )
97
+
98
+ def upsert_facts(self, *, rows: tuple[P1FactRow, ...]) -> None:
99
+ """Insert or replace facts-channel rows by fact_id; idempotent."""
100
+ self._upsert(
101
+ table=_FACT_TABLE,
102
+ key="fact_id",
103
+ payload=[
104
+ {
105
+ "fact_id": str(row.fact_id),
106
+ "deployment_id": str(row.deployment_id),
107
+ "kind": row.kind,
108
+ "label": row.label,
109
+ "status": row.status,
110
+ "vector": list(row.vector),
111
+ }
112
+ for row in rows
113
+ ],
114
+ )
115
+
116
+ def search_claims(
117
+ self,
118
+ *,
119
+ deployment_id: str,
120
+ vector: tuple[float, ...],
121
+ k: int,
122
+ current_only: bool,
123
+ ) -> tuple[str, ...]:
124
+ """Nominate claim ids by vector similarity (D48: nomination, not truth).
125
+
126
+ The DEFAULT claims channel filters to current testimony via the
127
+ stored scalar (retrieval §5); hydration against the spine confirms.
128
+ """
129
+ deployment_id = str(UUID(deployment_id)) # refuse filter injection
130
+ if _CLAIM_TABLE not in self._connection.table_names():
131
+ return ()
132
+ query = (
133
+ cast(
134
+ "LanceVectorQueryBuilder",
135
+ self._connection.open_table(_CLAIM_TABLE)
136
+ .search(list(vector))
137
+ .where(
138
+ f"deployment_id = '{deployment_id}'"
139
+ + (" AND is_current_testimony" if current_only else ""),
140
+ prefilter=True,
141
+ ),
142
+ )
143
+ .nprobes(LANCE_NPROBES)
144
+ .limit(k)
145
+ )
146
+ return tuple(row["claim_id"] for row in query.to_list())
147
+
148
+ def search_facts(
149
+ self, *, deployment_id: str, vector: tuple[float, ...], k: int, kind: str | None
150
+ ) -> tuple[str, ...]:
151
+ """Nominate fact ids (relations/observations) by label similarity."""
152
+ deployment_id = str(UUID(deployment_id)) # refuse filter injection
153
+ if kind is not None and kind not in ("relation", "observation"):
154
+ raise ValueError(f"unknown facts-channel kind {kind!r}")
155
+ if _FACT_TABLE not in self._connection.table_names():
156
+ return ()
157
+ where = f"deployment_id = '{deployment_id}'"
158
+ if kind is not None:
159
+ where += f" AND kind = '{kind}'"
160
+ query = (
161
+ cast(
162
+ "LanceVectorQueryBuilder",
163
+ self._connection.open_table(_FACT_TABLE)
164
+ .search(list(vector))
165
+ .where(where, prefilter=True),
166
+ )
167
+ .nprobes(LANCE_NPROBES)
168
+ .limit(k)
169
+ )
170
+ return tuple(row["fact_id"] for row in query.to_list())
171
+
172
+ def build_search_indexes(self) -> None:
173
+ """Build the measured scalar + IVF_FLAT indexes after a bulk load.
174
+
175
+ This is explicit rather than hidden in every upsert: index construction
176
+ is a maintenance/backfill operation, while inline P1 writes must stay
177
+ cheap. Lance still searches unindexed tail fragments after the build.
178
+ """
179
+ available = set(self._connection.list_tables().tables or ())
180
+ if _CLAIM_TABLE in available:
181
+ claims = self._connection.open_table(_CLAIM_TABLE)
182
+ claims.create_index("deployment_id", config=BTree())
183
+ claims.create_index("is_current_testimony", config=Bitmap())
184
+ self._build_vector_index(table=claims)
185
+ if _FACT_TABLE in available:
186
+ facts = self._connection.open_table(_FACT_TABLE)
187
+ facts.create_index("deployment_id", config=BTree())
188
+ facts.create_index("kind", config=Bitmap())
189
+ self._build_vector_index(table=facts)
190
+
191
+ @staticmethod
192
+ def _build_vector_index(*, table: Table) -> None:
193
+ """Build one vector index when the table is large enough to train it."""
194
+ rows = table.count_rows()
195
+ if rows < _MIN_VECTOR_INDEX_ROWS:
196
+ return
197
+ table.create_index(
198
+ "vector",
199
+ config=IvfFlat(
200
+ distance_type="l2",
201
+ num_partitions=max(1, math.ceil(rows / LANCE_TARGET_PARTITION_ROWS)),
202
+ target_partition_size=LANCE_TARGET_PARTITION_ROWS,
203
+ ),
204
+ )
205
+
206
+ def upsert_entities(self, *, rows: tuple[P1EntityRow, ...]) -> None:
207
+ """Insert or replace entity-profile rows by entity_id; idempotent."""
208
+ self._upsert(
209
+ table=_ENTITY_TABLE,
210
+ key="entity_id",
211
+ payload=[
212
+ {
213
+ "entity_id": str(row.entity_id),
214
+ "deployment_id": str(row.deployment_id),
215
+ "type": row.type,
216
+ "canonical_name": row.canonical_name,
217
+ "vector": list(row.vector),
218
+ }
219
+ for row in rows
220
+ ],
221
+ )
222
+
223
+ def entity_vectors(
224
+ self, *, deployment_id: str, entity_ids: tuple[str, ...]
225
+ ) -> dict[str, tuple[float, ...]]:
226
+ """Profile vectors for the requested ids (absent ids are omitted)."""
227
+ deployment_id = str(UUID(deployment_id))
228
+ if not entity_ids or _ENTITY_TABLE not in self._connection.table_names():
229
+ return {}
230
+ ids = ", ".join(f"'{UUID(item)}'" for item in entity_ids)
231
+ rows = (
232
+ self._connection.open_table(_ENTITY_TABLE)
233
+ .search()
234
+ .where(f"deployment_id = '{deployment_id}' AND entity_id IN ({ids})")
235
+ .limit(len(entity_ids))
236
+ .to_list()
237
+ )
238
+ return {row["entity_id"]: tuple(row["vector"]) for row in rows}
239
+
240
+ def purge_rows(
241
+ self,
242
+ *,
243
+ deployment_id: UUID,
244
+ chunk_ids: tuple[UUID, ...],
245
+ claim_ids: tuple[UUID, ...],
246
+ fact_ids: tuple[UUID, ...],
247
+ entity_ids: tuple[UUID, ...],
248
+ ) -> None:
249
+ """Delete exact deployment-owned rows and prune obsolete Lance versions."""
250
+ for table, key, ids in (
251
+ (_CHUNK_TABLE, "chunk_id", chunk_ids),
252
+ (_CLAIM_TABLE, "claim_id", claim_ids),
253
+ (_FACT_TABLE, "fact_id", fact_ids),
254
+ (_ENTITY_TABLE, "entity_id", entity_ids),
255
+ ):
256
+ self._purge_table_rows(
257
+ table=table, key=key, deployment_id=deployment_id, ids=ids
258
+ )
259
+
260
+ def verify_rows_purged(
261
+ self,
262
+ *,
263
+ deployment_id: UUID,
264
+ chunk_ids: tuple[UUID, ...],
265
+ claim_ids: tuple[UUID, ...],
266
+ fact_ids: tuple[UUID, ...],
267
+ entity_ids: tuple[UUID, ...],
268
+ ) -> None:
269
+ """Prove no nominated UUID remains in its deployment-scoped P1 table."""
270
+ remaining: dict[str, int] = {}
271
+ for table, key, ids in (
272
+ (_CHUNK_TABLE, "chunk_id", chunk_ids),
273
+ (_CLAIM_TABLE, "claim_id", claim_ids),
274
+ (_FACT_TABLE, "fact_id", fact_ids),
275
+ (_ENTITY_TABLE, "entity_id", entity_ids),
276
+ ):
277
+ if not ids or table not in self._connection.table_names():
278
+ continue
279
+ rendered_ids = ", ".join(f"'{item}'" for item in ids)
280
+ count = self._connection.open_table(table).count_rows(
281
+ f"deployment_id = '{deployment_id}' AND {key} IN ({rendered_ids})"
282
+ )
283
+ if count:
284
+ remaining[table] = count
285
+ if remaining:
286
+ raise RuntimeError(f"P1 purge verification found rows: {remaining!r}")
287
+
288
+ def table_count(self, *, table: str) -> int:
289
+ """Total rows in one P1 table (0 before its first write)."""
290
+ if table not in self._connection.table_names():
291
+ return 0
292
+ return self._connection.open_table(table).count_rows()
293
+
294
+ def _upsert(
295
+ self, *, table: str, key: str, payload: list[dict[str, object]]
296
+ ) -> None:
297
+ """Create-or-merge one table's rows by its key column."""
298
+ if not payload:
299
+ return
300
+ if table not in self._connection.table_names():
301
+ self._connection.create_table(table, data=payload)
302
+ return
303
+ (
304
+ self._connection.open_table(table)
305
+ .merge_insert(key)
306
+ .when_matched_update_all()
307
+ .when_not_matched_insert_all()
308
+ .execute(payload)
309
+ )
310
+
311
+ def _purge_table_rows(
312
+ self, *, table: str, key: str, deployment_id: UUID, ids: tuple[UUID, ...]
313
+ ) -> None:
314
+ """Delete one exact UUID set and physically prune its obsolete versions."""
315
+ if not ids or table not in self._connection.table_names():
316
+ return
317
+ rendered_ids = ", ".join(f"'{item}'" for item in ids)
318
+ lance_table = self._connection.open_table(table)
319
+ lance_table.delete(
320
+ f"deployment_id = '{deployment_id}' AND {key} IN ({rendered_ids})"
321
+ )
322
+ lance_table.optimize(cleanup_older_than=timedelta(0), delete_unverified=True)
323
+
324
+ def row_count(self) -> int:
325
+ """Total rows in the chunk table (0 before the first write)."""
326
+ if _CHUNK_TABLE not in self._connection.table_names():
327
+ return 0
328
+ return self._connection.open_table(_CHUNK_TABLE).count_rows()