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.
- package/README.md +100 -76
- package/docs/BENCHMARKS.md +9 -2
- package/docs/CHANGELOG.md +249 -0
- package/docs/CI_AND_RELEASE_GATES.md +126 -41
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +271 -103
- package/docs/ENTERPRISE.md +1 -1
- package/docs/LEGACY_COMPATIBILITY.md +10 -6
- package/docs/MULTI_AGENT_RUNTIME.md +4 -4
- package/docs/ONBOARDING.md +16 -4
- package/docs/OPERATIONS.md +14 -1
- package/docs/PERMISSION_MODE.md +14 -9
- package/docs/REALTIME_COLLABORATION.md +1 -1
- package/docs/ROADMAP.md +113 -0
- package/docs/TRUST_MODEL.md +28 -7
- package/docs/USABILITY_AUDIT.md +5 -0
- package/docs/WHY_LATTICE.md +13 -5
- package/docs/WORKFLOW_DESIGNER.md +2 -2
- package/docs/kg-schema.md +57 -7
- package/docs/mcp-tools.md +93 -82
- package/docs/security-model.md +6 -3
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/_kg_common/__init__.py +1 -54
- package/lattice_brain/graph/_kg_common/extraction.py +459 -105
- package/lattice_brain/graph/_kg_common/normalize.py +305 -0
- package/lattice_brain/graph/_kg_common/patterns.py +275 -0
- package/lattice_brain/graph/_kg_common/relations.py +12 -3
- package/lattice_brain/graph/_kg_common/sections.py +107 -0
- package/lattice_brain/graph/_kg_common/text.py +14 -450
- package/lattice_brain/graph/_kg_constants.py +7 -0
- package/lattice_brain/ingestion/__init__.py +6 -3
- package/lattice_brain/multimodal/__init__.py +9 -3
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/agent_worker_seam.py +44 -1
- package/latticeai/api/models.py +18 -110
- package/latticeai/api/search.py +7 -30
- package/latticeai/api/worker_compute.py +127 -106
- package/latticeai/api/worker_seams.py +17 -2
- package/latticeai/core/embedding_providers/__init__.py +16 -0
- package/latticeai/core/embedding_providers/autodetect.py +302 -0
- package/latticeai/core/embedding_providers/base.py +25 -0
- package/latticeai/core/embedding_providers/profiles.py +44 -0
- package/latticeai/core/embedding_providers/text.py +74 -8
- package/latticeai/core/http_origin.py +3 -3
- package/latticeai/core/messages.py +0 -5
- package/latticeai/core/policy.py +1 -6
- package/latticeai/core/quiet.py +1 -20
- package/latticeai/core/security.py +29 -83
- package/latticeai/core/sessions.py +95 -4
- package/latticeai/core/users.py +0 -38
- package/latticeai/core/vector_index/__init__.py +61 -0
- package/latticeai/core/vector_index/hnsw.py +383 -0
- package/latticeai/core/vector_index/sidecar.py +329 -0
- package/latticeai/models/router/catalog.py +2 -2
- package/latticeai/models/router/generation.py +176 -30
- package/latticeai/models/router/loading.py +150 -9
- package/latticeai/runtime/access_runtime.py +7 -4
- package/latticeai/runtime/brain_runtime.py +43 -9
- package/latticeai/runtime/build_phases/features.py +8 -31
- package/latticeai/runtime/build_phases/foundation.py +7 -16
- package/latticeai/runtime/build_phases/web.py +3 -3
- package/latticeai/runtime/build_phases/worker_profile.py +29 -27
- package/latticeai/runtime/runtime_context.py +0 -2
- package/latticeai/services/architecture_readiness.py +18 -19
- package/latticeai/services/process_audit.py +1 -22
- package/latticeai/services/product_readiness.py +39 -12
- package/latticeai/services/search_service.py +7 -0
- package/latticeai/services/voice_capture.py +8 -28
- package/latticeai/tools/__init__.py +12 -47
- package/latticeai/tools/commands.py +9 -15
- package/latticeai/tools/documents.py +12 -0
- package/latticeai/tools/knowledge.py +0 -6
- package/latticeai/tools/markup.py +152 -0
- package/package.json +4 -5
- package/requirements.txt +0 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_openapi_drift.mjs +3 -2
- package/scripts/check_server_i18n.mjs +5 -4
- package/scripts/compose_openapi.py +4 -1
- package/scripts/export_openapi.py +5 -4
- package/scripts/gen_worker_allowlist_fixture.py +2 -2
- package/scripts/openapi_route_families.json +19 -74
- package/scripts/publish_release.mjs +157 -0
- package/scripts/release_screen_claims.json +144 -28
- package/src-tauri/Cargo.lock +45 -10
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +47 -41
- package/static/app/assets/Act-Cf1L2709.js +2 -0
- package/static/app/assets/AdminConsole-DPAbLTYV.js +1 -0
- package/static/app/assets/Brain-DqamGrj-.js +2 -0
- package/static/app/assets/BrainHome-MHe2_RYs.js +2 -0
- package/static/app/assets/BrainSignals-CQPPfyyH.js +1 -0
- package/static/app/assets/Capture-DGdIH_Zc.js +1 -0
- package/static/app/assets/Chronicle-C-UlCJoJ.js +1 -0
- package/static/app/assets/CommandPalette-WNT4EqUX.js +1 -0
- package/static/app/assets/DigitalBrainExplorer-CEBH5Cwc.js +321 -0
- package/static/app/assets/Library-C6xd1dlf.js +1 -0
- package/static/app/assets/LivingBrain-BEk-0ohw.js +1 -0
- package/static/app/assets/ProductFlow-CZLm5iXh.js +1 -0
- package/static/app/assets/QueryClientProvider-B3OjqSyJ.js +1 -0
- package/static/app/assets/{ReviewCard-HXRle3qq.js → ReviewCard-CEHG6evf.js} +2 -2
- package/static/app/assets/RunsListPanel-CLtEJSRW.js +1 -0
- package/static/app/assets/System-CAxwBUXw.js +1 -0
- package/static/app/assets/WorkflowGraph-Dj10RuGE.js +1 -0
- package/static/app/assets/WorkflowsPanel-Kyeh_LIT.js +2 -0
- package/static/app/assets/actHelpers-CtSmK9Dw.js +1 -0
- package/static/app/assets/arrow-left-CRl5EO4D.js +1 -0
- package/static/app/assets/{bot-Cn8bWRuq.js → bot-DhUGRel2.js} +1 -1
- package/static/app/assets/brain-CLkhHsHF.js +1 -0
- package/static/app/assets/button-CmaEqG1T.js +1 -0
- package/static/app/assets/circle-check-CFgejkOS.js +1 -0
- package/static/app/assets/{circle-pause-CmzC_apg.js → circle-pause-l96izbxj.js} +1 -1
- package/static/app/assets/{circle-play-D8mW2aQ7.js → circle-play-CrZa25_q.js} +1 -1
- package/static/app/assets/{cpu-DZcdd0PZ.js → cpu-BaXudqwl.js} +1 -1
- package/static/app/assets/{download-bv1KEPGQ.js → download-hCVFPiyc.js} +1 -1
- package/static/app/assets/{folder-open-d-Pip5gr.js → folder-open-CHL82Yp7.js} +1 -1
- package/static/app/assets/{hard-drive-D20iavUb.js → hard-drive-DDzET7lk.js} +1 -1
- package/static/app/assets/index-CB93CZWW.css +2 -0
- package/static/app/assets/index-D2H-wSl6.js +13 -0
- package/static/app/assets/input-Df1CAY_I.js +1 -0
- package/static/app/assets/jsx-runtime-bzQ4Vb5N.js +1 -0
- package/static/app/assets/{link-2-BPJOFlAy.js → link-2-xNnTIX1_.js} +1 -1
- package/static/app/assets/{permissionCopy-ChdJd493.js → permissionCopy-D3aWHco-.js} +1 -1
- package/static/app/assets/primitives-BioD2slS.js +1 -0
- package/static/app/assets/search-BzBw8YcW.js +1 -0
- package/static/app/assets/{share-2-YNX_NtMU.js → share-2-FkzGf8Df.js} +1 -1
- package/static/app/assets/{shield-alert-DuQ3zrVL.js → shield-alert-B3dwzik4.js} +1 -1
- package/static/app/assets/sourceMeta-DQSY_tah.js +1 -0
- package/static/app/assets/textarea-P8o6pvOP.js +1 -0
- package/static/app/assets/useFocusTrap-hswOIkXE.js +1 -0
- package/static/app/assets/useMutation-OJLrYSRA.js +1 -0
- package/static/app/assets/workspace-BCuk3Ku9.js +1 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/lattice_brain/ingestion/pipeline.py +0 -108
- package/latticeai/api/local_files.py +0 -44
- package/latticeai/api/tools.py +0 -126
- package/latticeai/api/voice_capture.py +0 -32
- package/latticeai/core/agent_permission.py +0 -85
- package/scripts/agent_eval.py +0 -34
- package/scripts/brain_quality_eval.py +0 -37
- package/scripts/check_legacy_debt.mjs +0 -91
- package/scripts/check_python.py +0 -100
- package/scripts/chunking_parity_corpus.py +0 -449
- package/scripts/generate_agent_parity_fixtures.py +0 -771
- package/scripts/generate_chunking_parity_fixtures.py +0 -259
- package/static/app/assets/Act-BPcVAbOL.js +0 -1
- package/static/app/assets/AdminConsole-Bw1ATQL0.js +0 -1
- package/static/app/assets/Brain-CT92Kos0.js +0 -321
- package/static/app/assets/BrainHome-CFBkt1K_.js +0 -2
- package/static/app/assets/BrainSignals-ReLWF2H8.js +0 -1
- package/static/app/assets/Capture-BsTokYkk.js +0 -1
- package/static/app/assets/Chronicle-B6f0T9id.js +0 -1
- package/static/app/assets/CommandPalette-CuvjTv1u.js +0 -1
- package/static/app/assets/Library-BGJbG9Hd.js +0 -1
- package/static/app/assets/LivingBrain-DGYK_Jsa.js +0 -1
- package/static/app/assets/ProductFlow-DXBC6brE.js +0 -1
- package/static/app/assets/System-CMHSO9qM.js +0 -1
- package/static/app/assets/arrow-left-BfmkskWx.js +0 -1
- package/static/app/assets/brain-CQJberbE.js +0 -1
- package/static/app/assets/button-Ct9f2_oT.js +0 -1
- package/static/app/assets/circle-check-DruOxB-4.js +0 -1
- package/static/app/assets/index-D9x-kSNy.css +0 -2
- package/static/app/assets/index-Do83hDzJ.js +0 -10
- package/static/app/assets/input-BLXVNmj1.js +0 -1
- package/static/app/assets/primitives-Cv5tbZBY.js +0 -1
- package/static/app/assets/search-CT9aho2j.js +0 -1
- package/static/app/assets/textarea-DqwLnli4.js +0 -1
- package/static/app/assets/useFocusTrap-ZVI98jaW.js +0 -1
- package/static/app/assets/useMutation-CVC4qv_D.js +0 -1
- package/static/app/assets/useQuery-C7BeG4HU.js +0 -1
- package/static/app/assets/utils-CiFtIdZq.js +0 -4
- 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
|
+
]
|