ltcai 11.7.0 → 12.0.0

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 (174) hide show
  1. package/README.md +100 -76
  2. package/docs/BENCHMARKS.md +9 -2
  3. package/docs/CHANGELOG.md +249 -0
  4. package/docs/CI_AND_RELEASE_GATES.md +126 -41
  5. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  6. package/docs/DEVELOPMENT.md +271 -103
  7. package/docs/ENTERPRISE.md +1 -1
  8. package/docs/LEGACY_COMPATIBILITY.md +10 -6
  9. package/docs/MULTI_AGENT_RUNTIME.md +4 -4
  10. package/docs/ONBOARDING.md +16 -4
  11. package/docs/OPERATIONS.md +14 -1
  12. package/docs/PERMISSION_MODE.md +14 -9
  13. package/docs/REALTIME_COLLABORATION.md +1 -1
  14. package/docs/ROADMAP.md +113 -0
  15. package/docs/TRUST_MODEL.md +28 -7
  16. package/docs/USABILITY_AUDIT.md +5 -0
  17. package/docs/WHY_LATTICE.md +13 -5
  18. package/docs/WORKFLOW_DESIGNER.md +2 -2
  19. package/docs/kg-schema.md +57 -7
  20. package/docs/mcp-tools.md +93 -82
  21. package/docs/security-model.md +6 -3
  22. package/lattice_brain/__init__.py +1 -1
  23. package/lattice_brain/graph/_kg_common/__init__.py +1 -54
  24. package/lattice_brain/graph/_kg_common/extraction.py +459 -105
  25. package/lattice_brain/graph/_kg_common/normalize.py +305 -0
  26. package/lattice_brain/graph/_kg_common/patterns.py +275 -0
  27. package/lattice_brain/graph/_kg_common/relations.py +12 -3
  28. package/lattice_brain/graph/_kg_common/sections.py +107 -0
  29. package/lattice_brain/graph/_kg_common/text.py +14 -450
  30. package/lattice_brain/graph/_kg_constants.py +7 -0
  31. package/lattice_brain/ingestion/__init__.py +6 -3
  32. package/lattice_brain/multimodal/__init__.py +9 -3
  33. package/latticeai/__init__.py +1 -1
  34. package/latticeai/api/agent_worker_seam.py +44 -1
  35. package/latticeai/api/models.py +18 -110
  36. package/latticeai/api/search.py +7 -30
  37. package/latticeai/api/worker_compute.py +127 -106
  38. package/latticeai/api/worker_seams.py +17 -2
  39. package/latticeai/core/embedding_providers/__init__.py +16 -0
  40. package/latticeai/core/embedding_providers/autodetect.py +302 -0
  41. package/latticeai/core/embedding_providers/base.py +25 -0
  42. package/latticeai/core/embedding_providers/profiles.py +44 -0
  43. package/latticeai/core/embedding_providers/text.py +74 -8
  44. package/latticeai/core/http_origin.py +3 -3
  45. package/latticeai/core/messages.py +0 -5
  46. package/latticeai/core/policy.py +1 -6
  47. package/latticeai/core/quiet.py +1 -20
  48. package/latticeai/core/security.py +29 -83
  49. package/latticeai/core/sessions.py +95 -4
  50. package/latticeai/core/users.py +0 -38
  51. package/latticeai/core/vector_index/__init__.py +61 -0
  52. package/latticeai/core/vector_index/hnsw.py +383 -0
  53. package/latticeai/core/vector_index/sidecar.py +329 -0
  54. package/latticeai/models/router/catalog.py +2 -2
  55. package/latticeai/models/router/generation.py +176 -30
  56. package/latticeai/models/router/loading.py +150 -9
  57. package/latticeai/runtime/access_runtime.py +7 -4
  58. package/latticeai/runtime/brain_runtime.py +43 -9
  59. package/latticeai/runtime/build_phases/features.py +8 -31
  60. package/latticeai/runtime/build_phases/foundation.py +7 -16
  61. package/latticeai/runtime/build_phases/web.py +3 -3
  62. package/latticeai/runtime/build_phases/worker_profile.py +29 -27
  63. package/latticeai/runtime/runtime_context.py +0 -2
  64. package/latticeai/services/architecture_readiness.py +18 -19
  65. package/latticeai/services/process_audit.py +1 -22
  66. package/latticeai/services/product_readiness.py +39 -12
  67. package/latticeai/services/search_service.py +7 -0
  68. package/latticeai/services/voice_capture.py +8 -28
  69. package/latticeai/tools/__init__.py +12 -47
  70. package/latticeai/tools/commands.py +9 -15
  71. package/latticeai/tools/documents.py +12 -0
  72. package/latticeai/tools/knowledge.py +0 -6
  73. package/latticeai/tools/markup.py +152 -0
  74. package/package.json +4 -5
  75. package/requirements.txt +0 -1
  76. package/scripts/check_current_release_docs.mjs +1 -1
  77. package/scripts/check_openapi_drift.mjs +3 -2
  78. package/scripts/check_server_i18n.mjs +5 -4
  79. package/scripts/compose_openapi.py +4 -1
  80. package/scripts/export_openapi.py +5 -4
  81. package/scripts/gen_worker_allowlist_fixture.py +2 -2
  82. package/scripts/openapi_route_families.json +19 -74
  83. package/scripts/publish_release.mjs +157 -0
  84. package/scripts/release_screen_claims.json +144 -28
  85. package/src-tauri/Cargo.lock +45 -10
  86. package/src-tauri/Cargo.toml +1 -1
  87. package/src-tauri/tauri.conf.json +1 -1
  88. package/static/app/asset-manifest.json +47 -41
  89. package/static/app/assets/Act-Cf1L2709.js +2 -0
  90. package/static/app/assets/AdminConsole-DPAbLTYV.js +1 -0
  91. package/static/app/assets/Brain-DqamGrj-.js +2 -0
  92. package/static/app/assets/BrainHome-MHe2_RYs.js +2 -0
  93. package/static/app/assets/BrainSignals-CQPPfyyH.js +1 -0
  94. package/static/app/assets/Capture-DGdIH_Zc.js +1 -0
  95. package/static/app/assets/Chronicle-C-UlCJoJ.js +1 -0
  96. package/static/app/assets/CommandPalette-WNT4EqUX.js +1 -0
  97. package/static/app/assets/DigitalBrainExplorer-CEBH5Cwc.js +321 -0
  98. package/static/app/assets/Library-C6xd1dlf.js +1 -0
  99. package/static/app/assets/LivingBrain-BEk-0ohw.js +1 -0
  100. package/static/app/assets/ProductFlow-CZLm5iXh.js +1 -0
  101. package/static/app/assets/QueryClientProvider-B3OjqSyJ.js +1 -0
  102. package/static/app/assets/{ReviewCard-HXRle3qq.js → ReviewCard-CEHG6evf.js} +2 -2
  103. package/static/app/assets/RunsListPanel-CLtEJSRW.js +1 -0
  104. package/static/app/assets/System-CAxwBUXw.js +1 -0
  105. package/static/app/assets/WorkflowGraph-Dj10RuGE.js +1 -0
  106. package/static/app/assets/WorkflowsPanel-Kyeh_LIT.js +2 -0
  107. package/static/app/assets/actHelpers-CtSmK9Dw.js +1 -0
  108. package/static/app/assets/arrow-left-CRl5EO4D.js +1 -0
  109. package/static/app/assets/{bot-Cn8bWRuq.js → bot-DhUGRel2.js} +1 -1
  110. package/static/app/assets/brain-CLkhHsHF.js +1 -0
  111. package/static/app/assets/button-CmaEqG1T.js +1 -0
  112. package/static/app/assets/circle-check-CFgejkOS.js +1 -0
  113. package/static/app/assets/{circle-pause-CmzC_apg.js → circle-pause-l96izbxj.js} +1 -1
  114. package/static/app/assets/{circle-play-D8mW2aQ7.js → circle-play-CrZa25_q.js} +1 -1
  115. package/static/app/assets/{cpu-DZcdd0PZ.js → cpu-BaXudqwl.js} +1 -1
  116. package/static/app/assets/{download-bv1KEPGQ.js → download-hCVFPiyc.js} +1 -1
  117. package/static/app/assets/{folder-open-d-Pip5gr.js → folder-open-CHL82Yp7.js} +1 -1
  118. package/static/app/assets/{hard-drive-D20iavUb.js → hard-drive-DDzET7lk.js} +1 -1
  119. package/static/app/assets/index-CB93CZWW.css +2 -0
  120. package/static/app/assets/index-D2H-wSl6.js +13 -0
  121. package/static/app/assets/input-Df1CAY_I.js +1 -0
  122. package/static/app/assets/jsx-runtime-bzQ4Vb5N.js +1 -0
  123. package/static/app/assets/{link-2-BPJOFlAy.js → link-2-xNnTIX1_.js} +1 -1
  124. package/static/app/assets/{permissionCopy-ChdJd493.js → permissionCopy-D3aWHco-.js} +1 -1
  125. package/static/app/assets/primitives-BioD2slS.js +1 -0
  126. package/static/app/assets/search-BzBw8YcW.js +1 -0
  127. package/static/app/assets/{share-2-YNX_NtMU.js → share-2-FkzGf8Df.js} +1 -1
  128. package/static/app/assets/{shield-alert-DuQ3zrVL.js → shield-alert-B3dwzik4.js} +1 -1
  129. package/static/app/assets/sourceMeta-DQSY_tah.js +1 -0
  130. package/static/app/assets/textarea-P8o6pvOP.js +1 -0
  131. package/static/app/assets/useFocusTrap-hswOIkXE.js +1 -0
  132. package/static/app/assets/useMutation-OJLrYSRA.js +1 -0
  133. package/static/app/assets/workspace-BCuk3Ku9.js +1 -0
  134. package/static/app/index.html +4 -4
  135. package/static/sw.js +1 -1
  136. package/lattice_brain/ingestion/pipeline.py +0 -108
  137. package/latticeai/api/local_files.py +0 -44
  138. package/latticeai/api/tools.py +0 -126
  139. package/latticeai/api/voice_capture.py +0 -32
  140. package/latticeai/core/agent_permission.py +0 -85
  141. package/scripts/agent_eval.py +0 -34
  142. package/scripts/brain_quality_eval.py +0 -37
  143. package/scripts/check_legacy_debt.mjs +0 -91
  144. package/scripts/check_python.py +0 -100
  145. package/scripts/chunking_parity_corpus.py +0 -449
  146. package/scripts/generate_agent_parity_fixtures.py +0 -771
  147. package/scripts/generate_chunking_parity_fixtures.py +0 -259
  148. package/static/app/assets/Act-BPcVAbOL.js +0 -1
  149. package/static/app/assets/AdminConsole-Bw1ATQL0.js +0 -1
  150. package/static/app/assets/Brain-CT92Kos0.js +0 -321
  151. package/static/app/assets/BrainHome-CFBkt1K_.js +0 -2
  152. package/static/app/assets/BrainSignals-ReLWF2H8.js +0 -1
  153. package/static/app/assets/Capture-BsTokYkk.js +0 -1
  154. package/static/app/assets/Chronicle-B6f0T9id.js +0 -1
  155. package/static/app/assets/CommandPalette-CuvjTv1u.js +0 -1
  156. package/static/app/assets/Library-BGJbG9Hd.js +0 -1
  157. package/static/app/assets/LivingBrain-DGYK_Jsa.js +0 -1
  158. package/static/app/assets/ProductFlow-DXBC6brE.js +0 -1
  159. package/static/app/assets/System-CMHSO9qM.js +0 -1
  160. package/static/app/assets/arrow-left-BfmkskWx.js +0 -1
  161. package/static/app/assets/brain-CQJberbE.js +0 -1
  162. package/static/app/assets/button-Ct9f2_oT.js +0 -1
  163. package/static/app/assets/circle-check-DruOxB-4.js +0 -1
  164. package/static/app/assets/index-D9x-kSNy.css +0 -2
  165. package/static/app/assets/index-Do83hDzJ.js +0 -10
  166. package/static/app/assets/input-BLXVNmj1.js +0 -1
  167. package/static/app/assets/primitives-Cv5tbZBY.js +0 -1
  168. package/static/app/assets/search-CT9aho2j.js +0 -1
  169. package/static/app/assets/textarea-DqwLnli4.js +0 -1
  170. package/static/app/assets/useFocusTrap-ZVI98jaW.js +0 -1
  171. package/static/app/assets/useMutation-CVC4qv_D.js +0 -1
  172. package/static/app/assets/useQuery-C7BeG4HU.js +0 -1
  173. package/static/app/assets/utils-CiFtIdZq.js +0 -4
  174. package/static/app/assets/workspace-DQz9vIId.js +0 -1
@@ -0,0 +1,383 @@
1
+ """Approximate nearest-neighbour index with incremental append.
2
+
3
+ ``hnswlib`` is optional (``pip install hnswlib``; the published
4
+ ``ltcai[hnsw]`` extra was retired in 11.6.0 when the write path moved to
5
+ Rust). When the compiled module is missing the import is caught here and
6
+ reported as a reason string; search then returns no ANN hits rather than
7
+ pretending.
8
+
9
+ Policy, also stated on :meth:`HnswIndex.add_items`:
10
+
11
+ * **Append** new ids onto the live graph (``resize_index`` + ``add_items``).
12
+ * **Full rebuild** on provider/dim/space change, on any deletion when the
13
+ graph was loaded from a sidecar without source vectors, or when deletes
14
+ exceed :data:`DELETE_REBUILD_RATIO` of the current size.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ from pathlib import Path
21
+ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
22
+
23
+ HNSW_BACKEND = "hnsw"
24
+ HNSWLIB_MODULE = "hnswlib"
25
+ HNSW_SUFFIX = ".hnsw"
26
+ HNSW_META_SUFFIX = ".hnsw.meta.json"
27
+ #: Rebuild instead of tombstoning once this fraction of the graph would go.
28
+ DELETE_REBUILD_RATIO = 0.10
29
+
30
+ IndexItem = Tuple[str, Sequence[float], Mapping[str, Any]]
31
+ ScoredId = Tuple[str, float]
32
+
33
+
34
+ def load_hnswlib() -> Tuple[Optional[Any], Optional[str]]:
35
+ """Import ``hnswlib`` or explain why it is unavailable (never raises)."""
36
+ try:
37
+ import hnswlib # noqa: PLC0415 — guarded, optional, import-on-demand
38
+ except Exception as exc: # noqa: BLE001 — any import failure is "no ANN"
39
+ return None, (
40
+ f"{HNSWLIB_MODULE} is not available ({exc}); install it with "
41
+ "`pip install hnswlib` to use the approximate index"
42
+ )
43
+ return hnswlib, None
44
+
45
+
46
+ def hnswlib_available() -> bool:
47
+ """True when the optional ANN engine can actually be imported."""
48
+ module, _ = load_hnswlib()
49
+ return module is not None
50
+
51
+
52
+ def sidecar_paths(db_path: Any) -> Tuple[Path, Path]:
53
+ """``(index file, meta file)`` for a brain database path."""
54
+ base = Path(db_path)
55
+ return (
56
+ base.with_suffix(HNSW_SUFFIX),
57
+ base.with_suffix(HNSW_META_SUFFIX),
58
+ )
59
+
60
+
61
+ class HnswIndex:
62
+ """Small-M HNSW graph over the brain's embeddings."""
63
+
64
+ def __init__(
65
+ self,
66
+ *,
67
+ dim: int,
68
+ space: str = "cosine",
69
+ ef_construction: int = 400,
70
+ m: int = 32,
71
+ ef_search: int = 400,
72
+ model_id: str = "",
73
+ ) -> None:
74
+ self._dim = int(dim)
75
+ self._space = str(space)
76
+ self._ef_construction = int(ef_construction)
77
+ self._m = int(m)
78
+ self._ef_search = int(ef_search)
79
+ self._model_id = str(model_id)
80
+ self._module, self._detail = load_hnswlib()
81
+ self._vectors: Dict[str, List[float]] = {}
82
+ self._metadata: Dict[str, Dict[str, Any]] = {}
83
+ self._labels: List[str] = []
84
+ self._ann: Any = None
85
+ self._dirty = True
86
+ self._loaded = False
87
+
88
+ @property
89
+ def backend(self) -> str:
90
+ return HNSW_BACKEND
91
+
92
+ @property
93
+ def approx(self) -> bool:
94
+ return True
95
+
96
+ @property
97
+ def exhaustive(self) -> bool:
98
+ return False
99
+
100
+ @property
101
+ def available(self) -> bool:
102
+ return self._module is not None
103
+
104
+ @property
105
+ def unavailable_detail(self) -> Optional[str]:
106
+ return self._detail
107
+
108
+ @property
109
+ def loaded_from_sidecar(self) -> bool:
110
+ """True when the graph came off disk instead of being rebuilt."""
111
+ return self._loaded
112
+
113
+ @property
114
+ def dim(self) -> int:
115
+ return self._dim
116
+
117
+ @property
118
+ def model_id(self) -> str:
119
+ return self._model_id
120
+
121
+ def _identity_matches(self, dim: int, space: str, model_id: str) -> bool:
122
+ return (
123
+ int(dim) == self._dim
124
+ and str(space) == self._space
125
+ and str(model_id) == self._model_id
126
+ )
127
+
128
+ def add(
129
+ self,
130
+ id: str,
131
+ vector: Sequence[float],
132
+ metadata: Optional[Mapping[str, Any]] = None,
133
+ ) -> None:
134
+ if self._loaded:
135
+ raise RuntimeError(
136
+ "this HnswIndex was loaded from a sidecar and holds no source "
137
+ "vectors; rebuild it from vector_embeddings before mutating"
138
+ )
139
+ key = str(id)
140
+ self._vectors[key] = [float(value) for value in vector]
141
+ self._metadata[key] = dict(metadata or {})
142
+ self._dirty = True
143
+
144
+ def remove(self, id: str) -> None:
145
+ if self._loaded:
146
+ raise RuntimeError(
147
+ "this HnswIndex was loaded from a sidecar and holds no source "
148
+ "vectors; rebuild it from vector_embeddings before mutating"
149
+ )
150
+ key = str(id)
151
+ self._vectors.pop(key, None)
152
+ self._metadata.pop(key, None)
153
+ self._dirty = True
154
+
155
+ def rebuild(self, items: Iterable[IndexItem]) -> None:
156
+ """Replace the held set and mark the graph for a full rebuild."""
157
+ self._vectors.clear()
158
+ self._metadata.clear()
159
+ self._labels.clear()
160
+ self._ann = None
161
+ self._loaded = False
162
+ for item_id, vector, metadata in items:
163
+ self.add(item_id, vector, metadata)
164
+ self._dirty = True
165
+
166
+ def add_items(
167
+ self,
168
+ items: Iterable[IndexItem],
169
+ *,
170
+ dim: Optional[int] = None,
171
+ space: Optional[str] = None,
172
+ model_id: Optional[str] = None,
173
+ ) -> str:
174
+ """Append new vectors, or rebuild when the policy says so.
175
+
176
+ Returns ``"append"`` or ``"rebuild"`` so a bench can time the two
177
+ doors without guessing.
178
+
179
+ Full rebuild when the embedder identity changed, when any currently
180
+ indexed id is missing from the incoming set (a deletion), when
181
+ deletes would exceed :data:`DELETE_REBUILD_RATIO`, or when this
182
+ instance was loaded from a sidecar and cannot append (no source
183
+ vectors to keep the label map honest).
184
+ """
185
+ incoming: Dict[str, IndexItem] = {}
186
+ for item_id, vector, metadata in items:
187
+ incoming[str(item_id)] = (str(item_id), vector, metadata)
188
+
189
+ want_dim = self._dim if dim is None else int(dim)
190
+ want_space = self._space if space is None else str(space)
191
+ want_model = self._model_id if model_id is None else str(model_id)
192
+ if not self._identity_matches(want_dim, want_space, want_model):
193
+ self._dim = want_dim
194
+ self._space = want_space
195
+ self._model_id = want_model
196
+ self.rebuild(incoming.values())
197
+ return "rebuild"
198
+
199
+ if self._loaded:
200
+ self.rebuild(incoming.values())
201
+ return "rebuild"
202
+
203
+ current = set(self._vectors)
204
+ incoming_ids = set(incoming)
205
+ deleted = current - incoming_ids
206
+ if deleted:
207
+ ratio = len(deleted) / max(1, len(current))
208
+ if ratio >= DELETE_REBUILD_RATIO or not current:
209
+ self.rebuild(incoming.values())
210
+ return "rebuild"
211
+ # Sparse deletes: drop the gone ids and rebuild so we never
212
+ # leave a tombstoned hole the sidecar cannot describe.
213
+ self.rebuild(incoming.values())
214
+ return "rebuild"
215
+
216
+ new_ids = [item_id for item_id in incoming if item_id not in current]
217
+ if not new_ids and not self._dirty and self._ann is not None:
218
+ return "append"
219
+ for item_id in new_ids:
220
+ _, vector, metadata = incoming[item_id]
221
+ self.add(item_id, vector, metadata)
222
+ if self._ann is not None and new_ids and self._module is not None:
223
+ self._append_live(new_ids)
224
+ return "append"
225
+ self._dirty = True
226
+ return "append" if new_ids or self._ann is not None else "rebuild"
227
+
228
+ def _new_graph(self, module: Any, capacity: int) -> Any:
229
+ graph = module.Index(space=self._space, dim=self._dim)
230
+ graph.init_index(
231
+ max_elements=max(1, capacity),
232
+ ef_construction=self._ef_construction,
233
+ M=self._m,
234
+ )
235
+ return graph
236
+
237
+ def _append_live(self, new_ids: Sequence[str]) -> None:
238
+ """``hnswlib`` ``resize_index`` + ``add_items`` for ``new_ids`` only."""
239
+ module = self._module
240
+ graph = self._ann
241
+ if module is None or graph is None:
242
+ self._dirty = True
243
+ return
244
+ start = len(self._labels)
245
+ needed = start + len(new_ids)
246
+ try:
247
+ current_max = int(graph.get_max_elements())
248
+ except Exception: # noqa: BLE001 — some builds expose no getter
249
+ current_max = start
250
+ if needed > current_max:
251
+ graph.resize_index(max(needed, current_max * 2 if current_max else needed))
252
+ graph.add_items(
253
+ [self._vectors[key] for key in new_ids],
254
+ list(range(start, start + len(new_ids))),
255
+ )
256
+ self._labels.extend(new_ids)
257
+ self._dirty = False
258
+
259
+ def _ensure_graph(self) -> Any:
260
+ """Build the graph if the held vectors changed (None when disabled)."""
261
+ module = self._module
262
+ if module is None:
263
+ return None
264
+ if self._ann is not None and not self._dirty:
265
+ return self._ann
266
+ labels = list(self._vectors)
267
+ graph = self._new_graph(module, max(len(labels), 1))
268
+ if labels:
269
+ graph.add_items(
270
+ [self._vectors[key] for key in labels],
271
+ list(range(len(labels))),
272
+ )
273
+ self._ann = graph
274
+ self._labels = labels
275
+ self._dirty = False
276
+ return graph
277
+
278
+ def search(
279
+ self,
280
+ query: Sequence[float],
281
+ top_k: int,
282
+ filter: Optional[Mapping[str, Any]] = None,
283
+ ) -> List[ScoredId]:
284
+ graph = self._ensure_graph()
285
+ if graph is None or not self._labels:
286
+ return []
287
+ floor = float("-inf")
288
+ if filter and filter.get("min_score") is not None:
289
+ try:
290
+ floor = float(filter["min_score"])
291
+ except (TypeError, ValueError):
292
+ floor = float("-inf")
293
+ wanted = max(1, min(int(top_k), len(self._labels)))
294
+ graph.set_ef(max(self._ef_search, wanted))
295
+ labels, distances = graph.knn_query([list(query)], k=wanted)
296
+ scored: List[ScoredId] = []
297
+ for label, distance in zip(labels[0], distances[0], strict=True):
298
+ score = 1.0 - float(distance)
299
+ if score < floor:
300
+ continue
301
+ scored.append((self._labels[int(label)], score))
302
+ return scored
303
+
304
+ def stats(self) -> Dict[str, Any]:
305
+ return {
306
+ "backend": self.backend,
307
+ "size": len(self._labels) if self._loaded else len(self._vectors),
308
+ "dim": self._dim,
309
+ "approx": True,
310
+ "exhaustive": False,
311
+ "available": self.available,
312
+ "detail": self._detail,
313
+ "model_id": self._model_id,
314
+ }
315
+
316
+ def save(self, db_path: Any, *, fingerprint: str) -> bool:
317
+ """Write the graph + label map beside the brain database."""
318
+ graph = self._ensure_graph()
319
+ if graph is None or not self._labels:
320
+ return False
321
+ index_path, meta_path = sidecar_paths(db_path)
322
+ try:
323
+ index_path.parent.mkdir(parents=True, exist_ok=True)
324
+ graph.save_index(str(index_path))
325
+ meta_path.write_text(
326
+ json.dumps(
327
+ {
328
+ "fingerprint": str(fingerprint),
329
+ "dim": self._dim,
330
+ "space": self._space,
331
+ "model_id": self._model_id,
332
+ "labels": self._labels,
333
+ },
334
+ ensure_ascii=False,
335
+ ),
336
+ encoding="utf-8",
337
+ )
338
+ except Exception: # noqa: BLE001 — persistence is best-effort
339
+ return False
340
+ return True
341
+
342
+ def load(self, db_path: Any, *, fingerprint: str) -> bool:
343
+ """Adopt a sidecar graph when it provably matches ``fingerprint``."""
344
+ module = self._module
345
+ if module is None:
346
+ return False
347
+ index_path, meta_path = sidecar_paths(db_path)
348
+ try:
349
+ meta = json.loads(meta_path.read_text(encoding="utf-8"))
350
+ matches = str(meta["fingerprint"]) == str(fingerprint)
351
+ matches = matches and int(meta["dim"]) == self._dim
352
+ if meta.get("model_id") not in (None, "", self._model_id):
353
+ matches = False
354
+ labels = [str(label) for label in meta["labels"]]
355
+ except Exception: # noqa: BLE001 — absent/corrupt sidecar = rebuild
356
+ return False
357
+ if not (matches and labels):
358
+ return False
359
+ try:
360
+ graph = module.Index(space=self._space, dim=self._dim)
361
+ graph.load_index(str(index_path), max_elements=len(labels))
362
+ except Exception: # noqa: BLE001 — corrupt binary = rebuild
363
+ return False
364
+ self._ann = graph
365
+ self._labels = labels
366
+ self._vectors.clear()
367
+ self._metadata.clear()
368
+ self._dirty = False
369
+ self._loaded = True
370
+ return True
371
+
372
+
373
+ __all__ = [
374
+ "DELETE_REBUILD_RATIO",
375
+ "HNSW_BACKEND",
376
+ "HNSW_META_SUFFIX",
377
+ "HNSW_SUFFIX",
378
+ "HNSWLIB_MODULE",
379
+ "HnswIndex",
380
+ "hnswlib_available",
381
+ "load_hnswlib",
382
+ "sidecar_paths",
383
+ ]
@@ -0,0 +1,329 @@
1
+ """Load, refresh, and query the HNSW sidecar next to a brain database.
2
+
3
+ The live graph is cached per ``(db_path, model_id, dim)``. A missing sidecar
4
+ is ``index: "none"``. When the store has vectors the cache (or a rebuild from
5
+ ``vector_embeddings``) can serve, the reply is ``index: "hnsw"``. Staleness is
6
+ sidecar size vs ``COUNT(*)`` for that identity — reported honestly, and
7
+ refreshed through :meth:`HnswIndex.add_items` so an ingest append does not
8
+ rebuild the whole graph.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import sqlite3
14
+ import struct
15
+ import threading
16
+ from pathlib import Path
17
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
18
+
19
+ from latticeai.core.config import default_data_dir
20
+
21
+ from .hnsw import HnswIndex, hnswlib_available, sidecar_paths
22
+
23
+ GRAPH_DB_NAME = "knowledge_graph.sqlite"
24
+ VECTOR_QUERY_K_CAP = 200
25
+
26
+ _CACHE: Dict[Tuple[str, str, int], HnswIndex] = {}
27
+ _LOCK = threading.Lock()
28
+
29
+
30
+ def resolve_graph_db(workspace: Optional[str] = None) -> Path:
31
+ """Brain sqlite path: explicit file, a data dir, or ``LATTICEAI_DATA_DIR``."""
32
+ if workspace:
33
+ raw = Path(str(workspace)).expanduser()
34
+ if raw.is_file():
35
+ return raw
36
+ if raw.suffix.lower() == ".sqlite":
37
+ return raw
38
+ return raw / GRAPH_DB_NAME
39
+ return default_data_dir() / GRAPH_DB_NAME
40
+
41
+
42
+ def decode_f32le(blob: bytes, dim: int) -> List[float]:
43
+ """Little-endian f32 payload — the same layout Rust ``encode`` writes."""
44
+ if not blob:
45
+ return []
46
+ count = dim if dim > 0 else len(blob) // 4
47
+ if len(blob) != count * 4:
48
+ count = len(blob) // 4
49
+ if count <= 0:
50
+ return []
51
+ return list(struct.unpack(f"<{count}f", blob[: count * 4]))
52
+
53
+
54
+ def sidecar_fingerprint(model_id: str, dim: int, size: int) -> str:
55
+ """Identity the ``.hnsw.meta.json`` file is keyed on."""
56
+ return f"{model_id}|{int(dim)}|{int(size)}"
57
+
58
+
59
+ def store_vector_count(conn: sqlite3.Connection, model_id: str, dim: int) -> int:
60
+ row = conn.execute(
61
+ "SELECT COUNT(*) FROM vector_embeddings "
62
+ "WHERE embedding_model=? AND embedding_dim=?",
63
+ (model_id, int(dim)),
64
+ ).fetchone()
65
+ return int(row[0]) if row else 0
66
+
67
+
68
+ def load_store_items(
69
+ conn: sqlite3.Connection, model_id: str, dim: int
70
+ ) -> List[Tuple[str, List[float], Dict[str, Any]]]:
71
+ items: List[Tuple[str, List[float], Dict[str, Any]]] = []
72
+ for item_id, blob in conn.execute(
73
+ "SELECT item_id, embedding FROM vector_embeddings "
74
+ "WHERE embedding_model=? AND embedding_dim=? ORDER BY indexed_at ASC, item_id ASC",
75
+ (model_id, int(dim)),
76
+ ):
77
+ vector = decode_f32le(bytes(blob or b""), int(dim))
78
+ if len(vector) != int(dim):
79
+ continue
80
+ items.append((str(item_id), vector, {}))
81
+ return items
82
+
83
+
84
+ def sidecar_meta_size(db_path: Path) -> Optional[int]:
85
+ """Label count from the sidecar meta file, or ``None`` when it is absent."""
86
+ _, meta_path = sidecar_paths(db_path)
87
+ try:
88
+ import json
89
+
90
+ meta = json.loads(meta_path.read_text(encoding="utf-8"))
91
+ labels = meta.get("labels") or []
92
+ return len(labels)
93
+ except Exception: # noqa: BLE001 — absent/corrupt meta is "no sidecar"
94
+ return None
95
+
96
+
97
+ def _open_readonly(db_path: Path) -> Optional[sqlite3.Connection]:
98
+ if not db_path.is_file():
99
+ return None
100
+ try:
101
+ conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
102
+ except sqlite3.Error:
103
+ try:
104
+ conn = sqlite3.connect(str(db_path))
105
+ except sqlite3.Error:
106
+ return None
107
+ conn.row_factory = sqlite3.Row
108
+ return conn
109
+
110
+
111
+ def _none_reply(
112
+ *,
113
+ size: int = 0,
114
+ store_size: int = 0,
115
+ stale: bool = False,
116
+ detail: Optional[str] = None,
117
+ ) -> Dict[str, Any]:
118
+ return {
119
+ "ids": [],
120
+ "scores": [],
121
+ "index": "none",
122
+ "size": int(size),
123
+ "store_size": int(store_size),
124
+ "stale": bool(stale),
125
+ "detail": detail,
126
+ "refreshed": None,
127
+ }
128
+
129
+
130
+ def _hnsw_reply(
131
+ ids: Sequence[str],
132
+ scores: Sequence[float],
133
+ *,
134
+ size: int,
135
+ store_size: int,
136
+ stale: bool,
137
+ detail: Optional[str],
138
+ refreshed: Optional[str],
139
+ ) -> Dict[str, Any]:
140
+ return {
141
+ "ids": [str(item) for item in ids],
142
+ "scores": [float(score) for score in scores],
143
+ "index": "hnsw",
144
+ "size": int(size),
145
+ "store_size": int(store_size),
146
+ "stale": bool(stale),
147
+ "detail": detail,
148
+ "refreshed": refreshed,
149
+ }
150
+
151
+
152
+ def _refresh_index(
153
+ index: HnswIndex,
154
+ items: Sequence[Tuple[str, List[float], Dict[str, Any]]],
155
+ model_id: str,
156
+ dim: int,
157
+ ) -> str:
158
+ return index.add_items(items, dim=dim, model_id=model_id)
159
+
160
+
161
+ def query_sidecar(
162
+ *,
163
+ workspace: Optional[str],
164
+ embedding_model: str,
165
+ embedding_dim: int,
166
+ vector: Sequence[float],
167
+ k: int,
168
+ db_path: Optional[Path] = None,
169
+ ) -> Dict[str, Any]:
170
+ """Serve one ANN query from the sidecar (or say the index is absent)."""
171
+ dim = int(embedding_dim)
172
+ model_id = str(embedding_model or "")
173
+ wanted = max(1, min(int(k), VECTOR_QUERY_K_CAP))
174
+ path = Path(db_path) if db_path is not None else resolve_graph_db(workspace)
175
+ if not hnswlib_available():
176
+ return _none_reply(detail="hnswlib is not available in this worker")
177
+ if dim <= 0 or len(vector) != dim:
178
+ return _none_reply(
179
+ detail=(
180
+ f"query vector width {len(vector)} does not match embedding_dim {dim}"
181
+ if dim > 0
182
+ else "embedding_dim must be a positive integer"
183
+ )
184
+ )
185
+ if not model_id:
186
+ return _none_reply(detail="embedding_model is required")
187
+
188
+ conn = _open_readonly(path)
189
+ store_size = 0
190
+ items: List[Tuple[str, List[float], Dict[str, Any]]] = []
191
+ if conn is not None:
192
+ try:
193
+ store_size = store_vector_count(conn, model_id, dim)
194
+ if store_size > 0:
195
+ items = load_store_items(conn, model_id, dim)
196
+ store_size = len(items)
197
+ finally:
198
+ conn.close()
199
+
200
+ key = (str(path), model_id, dim)
201
+ with _LOCK:
202
+ index = _CACHE.get(key)
203
+ refreshed: Optional[str] = None
204
+ if index is None:
205
+ index = HnswIndex(dim=dim, model_id=model_id)
206
+ loaded = index.load(path, fingerprint=sidecar_fingerprint(model_id, dim, store_size))
207
+ if not loaded:
208
+ if not items:
209
+ return _none_reply(
210
+ store_size=store_size,
211
+ detail="no HNSW sidecar for this embedding identity",
212
+ )
213
+ refreshed = _refresh_index(index, items, model_id, dim)
214
+ index.save(path, fingerprint=sidecar_fingerprint(model_id, dim, len(items)))
215
+ _CACHE[key] = index
216
+
217
+ cached_size = int(index.stats().get("size") or 0)
218
+ stale = cached_size != store_size
219
+ if stale:
220
+ if not items and store_size > 0:
221
+ conn = _open_readonly(path)
222
+ if conn is not None:
223
+ try:
224
+ items = load_store_items(conn, model_id, dim)
225
+ finally:
226
+ conn.close()
227
+ if items:
228
+ refreshed = _refresh_index(index, items, model_id, dim)
229
+ index.save(path, fingerprint=sidecar_fingerprint(model_id, dim, len(items)))
230
+ cached_size = int(index.stats().get("size") or 0)
231
+ stale = cached_size != len(items)
232
+ elif store_size == 0:
233
+ _CACHE.pop(key, None)
234
+ return _none_reply(
235
+ store_size=0,
236
+ detail="vector store is empty for this embedding identity",
237
+ )
238
+
239
+ if cached_size <= 0:
240
+ return _none_reply(
241
+ store_size=store_size,
242
+ stale=stale,
243
+ detail="HNSW sidecar has no labels for this identity",
244
+ )
245
+
246
+ index._ef_search = max(index._ef_search, wanted * 2, 200)
247
+ hits = index.search(list(vector), top_k=min(wanted, cached_size))
248
+ ids = [item_id for item_id, _ in hits]
249
+ scores = [score for _, score in hits]
250
+ detail = None
251
+ if stale:
252
+ detail = (
253
+ f"sidecar size {cached_size} != store count {store_size} "
254
+ "for this embedding identity"
255
+ )
256
+ return _hnsw_reply(
257
+ ids,
258
+ scores,
259
+ size=cached_size,
260
+ store_size=store_size,
261
+ stale=stale,
262
+ detail=detail,
263
+ refreshed=refreshed,
264
+ )
265
+
266
+
267
+ def sidecar_freshness(
268
+ *,
269
+ workspace: Optional[str] = None,
270
+ embedding_model: Optional[str] = None,
271
+ embedding_dim: Optional[int] = None,
272
+ db_path: Optional[Path] = None,
273
+ ) -> Dict[str, Any]:
274
+ """Cheap on-disk vs store comparison — no ANN query, no rebuild."""
275
+ path = Path(db_path) if db_path is not None else resolve_graph_db(workspace)
276
+ meta_size = sidecar_meta_size(path)
277
+ store_size = 0
278
+ conn = _open_readonly(path)
279
+ if conn is not None:
280
+ try:
281
+ if embedding_model and embedding_dim:
282
+ store_size = store_vector_count(conn, embedding_model, int(embedding_dim))
283
+ else:
284
+ row = conn.execute("SELECT COUNT(*) FROM vector_embeddings").fetchone()
285
+ store_size = int(row[0]) if row else 0
286
+ except sqlite3.Error:
287
+ store_size = 0
288
+ finally:
289
+ conn.close()
290
+ if meta_size is None:
291
+ return {
292
+ "index": "none",
293
+ "size": 0,
294
+ "store_size": store_size,
295
+ "stale": store_size > 0,
296
+ "detail": "no HNSW sidecar next to the brain database",
297
+ }
298
+ stale = meta_size != store_size
299
+ return {
300
+ "index": "hnsw",
301
+ "size": meta_size,
302
+ "store_size": store_size,
303
+ "stale": stale,
304
+ "detail": (
305
+ f"sidecar size {meta_size} != store count {store_size}"
306
+ if stale
307
+ else "sidecar matches the vector store"
308
+ ),
309
+ }
310
+
311
+
312
+ def reset_sidecar_cache() -> None:
313
+ """Drop the process cache — tests only."""
314
+ with _LOCK:
315
+ _CACHE.clear()
316
+
317
+
318
+ __all__ = [
319
+ "GRAPH_DB_NAME",
320
+ "VECTOR_QUERY_K_CAP",
321
+ "decode_f32le",
322
+ "query_sidecar",
323
+ "reset_sidecar_cache",
324
+ "resolve_graph_db",
325
+ "sidecar_fingerprint",
326
+ "sidecar_freshness",
327
+ "sidecar_meta_size",
328
+ "store_vector_count",
329
+ ]