ltcai 11.0.1 → 11.2.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 +55 -43
- package/docs/CHANGELOG.md +61 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/FEATURE_AUDIT_v11.2.0.md +393 -0
- package/docs/LAYOUT_REBUILD_SPEC.md +9 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/PERFORMANCE.md +71 -18
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/architecture.md +6 -2
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/embeddings.py +12 -37
- package/lattice_brain/gates.py +125 -0
- package/lattice_brain/graph/discovery_index.py +30 -32
- package/lattice_brain/graph/fusion.py +35 -4
- package/lattice_brain/graph/image_vectors.py +230 -0
- package/lattice_brain/graph/ingest.py +11 -5
- package/lattice_brain/graph/projection.py +66 -8
- package/lattice_brain/graph/provenance.py +27 -2
- package/lattice_brain/graph/retrieval.py +113 -2
- package/lattice_brain/graph/retrieval_docgen.py +6 -6
- package/lattice_brain/graph/schema.py +18 -0
- package/lattice_brain/graph/store.py +9 -0
- package/lattice_brain/graph/vector_index/selector.py +32 -2
- package/lattice_brain/ingestion.py +363 -10
- package/lattice_brain/multimodal.py +1258 -0
- package/lattice_brain/portability.py +169 -32
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/lattice_brain/sealed_box.py +244 -0
- package/lattice_brain/self_model.py +77 -22
- package/lattice_brain/synthesis.py +24 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/brain_intelligence.py +4 -0
- package/latticeai/api/chat.py +11 -0
- package/latticeai/api/chat_helpers.py +16 -3
- package/latticeai/api/chat_hybrid.py +32 -1
- package/latticeai/api/features.py +70 -0
- package/latticeai/api/local_files.py +102 -0
- package/latticeai/api/memory.py +128 -1
- package/latticeai/api/portability.py +39 -4
- package/latticeai/api/review_queue.py +126 -0
- package/latticeai/api/search.py +16 -2
- package/latticeai/core/agent.py +59 -2
- package/latticeai/core/agent_prompts.py +66 -0
- package/latticeai/core/config.py +4 -1
- package/latticeai/core/context_builder.py +98 -11
- package/latticeai/core/embedding_providers.py +528 -0
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/messages.py +180 -0
- package/latticeai/core/model_compat.py +73 -2
- package/latticeai/core/workspace_os.py +43 -0
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/core/workspace_reorganization.py +335 -0
- package/latticeai/models/model_providers.py +12 -4
- package/latticeai/runtime/build_phases.py +33 -2
- package/latticeai/runtime/chat_wiring.py +4 -0
- package/latticeai/runtime/feature_toggle_wiring.py +163 -0
- package/latticeai/runtime/persistence_runtime.py +41 -4
- package/latticeai/runtime/router_registration.py +11 -0
- package/latticeai/runtime/runtime_context.py +1 -0
- package/latticeai/services/app_context.py +8 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/automation_intelligence.py +22 -2
- package/latticeai/services/brain_intelligence.py +123 -7
- package/latticeai/services/change_proposals.py +50 -10
- package/latticeai/services/command_center.py +10 -4
- package/latticeai/services/feature_toggles.py +502 -0
- package/latticeai/services/folder_watch.py +122 -1
- package/latticeai/services/hybrid_chat.py +56 -5
- package/latticeai/services/interop_bridges.py +978 -0
- package/latticeai/services/memory_service.py +34 -0
- package/latticeai/services/model_capability_registry.py +434 -261
- package/latticeai/services/model_catalog.py +95 -61
- package/latticeai/services/model_recommendation.py +18 -11
- package/latticeai/services/model_runtime.py +1 -1
- package/latticeai/services/multimodal_ports.py +112 -0
- package/latticeai/services/obsidian_bridge.py +16 -25
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/search_service.py +149 -2
- package/latticeai/services/self_model_service.py +171 -0
- package/latticeai/services/tool_dispatch.py +4 -0
- package/latticeai/services/voice_capture.py +27 -1
- package/latticeai/setup/auto_setup.py +27 -30
- package/latticeai/setup/wizard.py +77 -44
- package/package.json +1 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_server_i18n.mjs +1 -0
- package/scripts/release_screen_claims.json +22 -0
- package/scripts/verify_hf_model_registry.py +253 -218
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +37 -37
- package/static/app/assets/{Act-D4zSxFR-.js → Act-AWf0SAKp.js} +1 -1
- package/static/app/assets/{AdminConsole-w5jBfPt2.js → AdminConsole-D0u8Tiyj.js} +1 -1
- package/static/app/assets/{Brain-C2EqQg74.js → Brain-tuhI4sOC.js} +1 -1
- package/static/app/assets/BrainHome-Ts7G_Ila.js +2 -0
- package/static/app/assets/BrainSignals-jMYgQ2Ar.js +1 -0
- package/static/app/assets/{Capture-DPqpGK8d.js → Capture-CqOSzyPr.js} +1 -1
- package/static/app/assets/{CommandPalette-CNf7h5fp.js → CommandPalette-DC0Bzh-I.js} +1 -1
- package/static/app/assets/{Library-BN0HYOfc.js → Library-CX-bbhmK.js} +1 -1
- package/static/app/assets/{LivingBrain-Dfq_wEDI.js → LivingBrain-DBwhto14.js} +1 -1
- package/static/app/assets/{ProductFlow-B-3O0rNV.js → ProductFlow-BHA2cfKI.js} +1 -1
- package/static/app/assets/{ReviewCard-gZ-tdqFM.js → ReviewCard-BUhCKRNM.js} +1 -1
- package/static/app/assets/{System-BElUcSSw.js → System-Bu2t5hn1.js} +1 -1
- package/static/app/assets/arrow-left-Dzwa5zRb.js +1 -0
- package/static/app/assets/{bot--qYHMtkP.js → bot-Cia42c2h.js} +1 -1
- package/static/app/assets/brain-DJMoqrwx.js +1 -0
- package/static/app/assets/{button-51Z3rsuv.js → button-2j2Ijzgq.js} +1 -1
- package/static/app/assets/{circle-pause-CMIiMaQl.js → circle-pause-BEFeWpVW.js} +1 -1
- package/static/app/assets/{circle-play-DZoO_cfG.js → circle-play-ujXMcHxl.js} +1 -1
- package/static/app/assets/{cpu-Bs6uc9W9.js → cpu-k4awryFq.js} +1 -1
- package/static/app/assets/{download-G-2olkWz.js → download-DFbLJ_ig.js} +1 -1
- package/static/app/assets/{folder-open-CTOspnmb.js → folder-open-7y_b6xkM.js} +1 -1
- package/static/app/assets/{hard-drive-CewHWJhn.js → hard-drive-Bidh02Kr.js} +1 -1
- package/static/app/assets/{index-D7Rr-J2Y.js → index-BpYkzcVm.js} +3 -3
- package/static/app/assets/index-DwDl9-8Y.css +2 -0
- package/static/app/assets/{input-D4w_BZWl.js → input-DSlJJxRs.js} +1 -1
- package/static/app/assets/{permissionCopy-CosBEXAZ.js → permissionCopy-Bpb83Hx9.js} +1 -1
- package/static/app/assets/{primitives-d0g9pvzS.js → primitives-BCx6TvfG.js} +1 -1
- package/static/app/assets/search-Cgy8cCFJ.js +1 -0
- package/static/app/assets/{share-2-NmD7e_oV.js → share-2-BH1M-WNi.js} +1 -1
- package/static/app/assets/{shield-alert-CcQeMuju.js → shield-alert-BlKdBXcG.js} +1 -1
- package/static/app/assets/{textarea-BPAJDc-0.js → textarea-CCWbUfFB.js} +1 -1
- package/static/app/assets/{useFocusTrap-C7YLdTBC.js → useFocusTrap-YdHQ7pJ1.js} +1 -1
- package/static/app/assets/{useQuery-DRyD9opW.js → useQuery-CXQiwbVT.js} +1 -1
- package/static/app/assets/{utils-DG1_ExrP.js → utils-zqPZJxdx.js} +2 -2
- package/static/app/assets/{workspace-CWVf3gsI.js → workspace-DXTihhfU.js} +1 -1
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/BrainHome-CvXS6XiQ.js +0 -2
- package/static/app/assets/BrainSignals-DOE_KhOU.js +0 -1
- package/static/app/assets/arrow-left-CFNIMjhv.js +0 -1
- package/static/app/assets/brain-DDCLjRqO.js +0 -1
- package/static/app/assets/index-CkzokZAj.css +0 -2
- package/static/app/assets/search-BLCYt75v.js +0 -1
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
이 문서의 모든 파일 경로와 줄 번호는 실제로 열어서 확인한 것이다.
|
|
6
6
|
추측한 경로는 한 줄도 없다.
|
|
7
7
|
|
|
8
|
+
> **증거 기준선 안내 (v11.2.0 기준).** 이 설계서가 인용하는
|
|
9
|
+
> `output/release/v10.6.3/screenshots/` 는 **더 이상 리포에 없다** — 릴리스 증거는
|
|
10
|
+
> v11.0.0 부터만 보관한다(`output/release/v11.0.0` · `v11.0.1` · `v11.1.0`).
|
|
11
|
+
> 따라서 아래의 "이전 캡처와 해시 대조" 절차는 그대로는 재현할 수 없다.
|
|
12
|
+
> 문서는 당시 측정값을 기록한 사료로 남기고, 지금 다시 채점한다면 기준선은
|
|
13
|
+
> **가장 최근 릴리스의 `output/release/<버전>/screenshots/`** 로 바꿔서 읽는다.
|
|
14
|
+
|
|
8
15
|
---
|
|
9
16
|
|
|
10
17
|
## 0. 이 재구성이 존재하는 이유
|
|
@@ -706,7 +713,8 @@ npm run frontend:openapi:check # OpenAPI drift 게이트
|
|
|
706
713
|
|
|
707
714
|
### 7.1 릴리스 캡처 12개 화면 전부 변경 — **24점** (화면당 2점)
|
|
708
715
|
|
|
709
|
-
**확인 명령:**
|
|
716
|
+
**확인 명령:** (기준선 경로는 문서 상단 안내대로 최신 릴리스 디렉터리로 바꿔 읽는다 —
|
|
717
|
+
`output/release/v10.6.3/` 는 리포에서 사라졌다.)
|
|
710
718
|
```
|
|
711
719
|
npm run build:assets
|
|
712
720
|
LTCAI_RELEASE_EVIDENCE_DIR=/tmp/lattice-after npm run release:evidence
|
package/docs/ONBOARDING.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Lattice AI Onboarding
|
|
2
2
|
|
|
3
|
-
Current release: **11.0
|
|
3
|
+
Current release: **11.2.0 — All Systems On**.
|
|
4
4
|
|
|
5
5
|
The first-run goal is a five-minute path from "I opened the app" to "my Brain
|
|
6
6
|
has a source, a question, and proof." This page is the product contract behind
|
package/docs/OPERATIONS.md
CHANGED
package/docs/PERFORMANCE.md
CHANGED
|
@@ -110,6 +110,10 @@ Methodology, and what it does not cover:
|
|
|
110
110
|
- Latency and memory are measured in separate passes. tracemalloc roughly
|
|
111
111
|
triples Python allocation cost, so timing under it would measure the
|
|
112
112
|
profiler — the `peak MB` column comes from one extra traced query.
|
|
113
|
+
- `peak MB` counts **Python** allocations only. hnswlib keeps its graph in C++
|
|
114
|
+
memory, which tracemalloc cannot see, so that column understates HNSW by the
|
|
115
|
+
size of the graph itself. Read it as "what the query costs the interpreter",
|
|
116
|
+
not as the process's resident size.
|
|
113
117
|
- `first ms` is the *first* query after the corpus changed. For `hnsw` that is
|
|
114
118
|
where the graph gets built and the `.hnsw` sidecar written; for the other two
|
|
115
119
|
it is an ordinary query.
|
|
@@ -135,27 +139,27 @@ Run it:
|
|
|
135
139
|
### Measured, 2026-08-10 — 10 000 vectors, 30 queries, top_k 10
|
|
136
140
|
|
|
137
141
|
macOS 27 (arm64, Apple Silicon), Python 3.14.5, SQLite 3.53.1,
|
|
138
|
-
hnswlib 0.8.0, uncapped candidates, corpus build 2.
|
|
142
|
+
hnswlib 0.8.0, uncapped candidates, corpus build 2.19 s.
|
|
139
143
|
|
|
140
144
|
| backend | p50 ms | p95 ms | first ms | peak MB | recall@10 | hybrid p50 ms |
|
|
141
145
|
|-----------|-------:|-------:|---------:|--------:|----------:|--------------:|
|
|
142
|
-
| brute |
|
|
143
|
-
| quantized |
|
|
144
|
-
| hnsw |
|
|
146
|
+
| brute | 293.25 | 299.31 | 293.91 | 39.72 | 1.000 | 299.17 |
|
|
147
|
+
| quantized | 640.58 | 650.15 | 636.87 | 38.38 | 0.987 | 653.33 |
|
|
148
|
+
| hnsw | 7.01 | 7.50 | 799.95 | 0.05 | 0.953 | 10.07 |
|
|
145
149
|
|
|
146
150
|
What the table says, plainly:
|
|
147
151
|
|
|
148
|
-
- **The default is exact and slow.**
|
|
149
|
-
inherits nearly all of it (
|
|
150
|
-
- **HNSW meets the 11.1.0 target and prices it.** Hybrid p50 **
|
|
151
|
-
(target: < 50 ms), a
|
|
152
|
+
- **The default is exact and slow.** 293 ms per query at 10k, and hybrid
|
|
153
|
+
inherits nearly all of it (299 ms) — the vector channel *is* the cost.
|
|
154
|
+
- **HNSW meets the 11.1.0 target and prices it.** Hybrid p50 **10.1 ms** at 10k
|
|
155
|
+
(target: < 50 ms), a 30x improvement, in exchange for **4.7% of the exact
|
|
152
156
|
top-10 going missing**. That is the trade, stated as a number rather than as
|
|
153
|
-
the word "approximate".
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
157
|
+
the word "approximate".
|
|
158
|
+
- **The 800 ms first query is the graph build**, paid once per index generation
|
|
159
|
+
and then persisted to the `.hnsw` sidecar (and held in memory for the rest of
|
|
160
|
+
the process). Any write to `vector_embeddings` invalidates the fingerprint
|
|
161
|
+
and buys that cost again, which is why `brute` stays the default for a
|
|
162
|
+
continuously-ingesting brain.
|
|
159
163
|
- **Quantized is currently the wrong choice on every axis.** ~2.2x the latency
|
|
160
164
|
for 0.987 recall, and its RAM advantage does not materialise (38.4 vs
|
|
161
165
|
39.7 MB): the exact scan already feeds the index in bounded batches, so
|
|
@@ -163,16 +167,65 @@ What the table says, plainly:
|
|
|
163
167
|
It ships as an honest, exhaustive backend and as the representation a held
|
|
164
168
|
cross-query index would need. It is not a recommendation.
|
|
165
169
|
|
|
170
|
+
### Measured, 2026-08-10 — 50 000 vectors, 15 queries, top_k 10
|
|
171
|
+
|
|
172
|
+
Same machine and settings; corpus build 11.19 s.
|
|
173
|
+
|
|
174
|
+
| backend | p50 ms | p95 ms | first ms | peak MB | recall@10 | hybrid p50 ms |
|
|
175
|
+
|-----------|--------:|--------:|---------:|--------:|----------:|--------------:|
|
|
176
|
+
| brute | 1515.31 | 1522.94 | 1520.89 | 194.92 | 1.000 | 1514.75 |
|
|
177
|
+
| quantized | 3254.76 | 3275.69 | 3253.54 | 195.05 | 0.967 | 3441.13 |
|
|
178
|
+
| hnsw | 35.58 | 38.19 | 6114.60 | 0.04 | 0.987 | 43.90 |
|
|
179
|
+
|
|
180
|
+
- **The exact scan is linear and unusable at this size**: 1.5 s per query, and
|
|
181
|
+
~195 MB of Python allocation to score one question.
|
|
182
|
+
- **HNSW still clears the 50 ms budget at 5x the target corpus** — hybrid p50
|
|
183
|
+
43.9 ms — and its recall here is 0.987, higher than the 10k run's 0.953
|
|
184
|
+
(15 queries is a small sample; treat the two as "around 0.95–0.99", not as a
|
|
185
|
+
trend).
|
|
186
|
+
- **The remaining HNSW cost is not the search.** 7 ms at 10k → 36 ms at 50k is
|
|
187
|
+
suspiciously linear for a graph index, and it is: every query first runs the
|
|
188
|
+
freshness check (`SELECT COUNT(*), MAX(indexed_at) FROM vector_embeddings
|
|
189
|
+
WHERE embedding_model=? AND embedding_dim=?`), which has no covering index
|
|
190
|
+
and walks the table. A named follow-up, not a mystery: an index on
|
|
191
|
+
`(embedding_model, embedding_dim)` would remove it. The budget is met either
|
|
192
|
+
way, so it was not worth a schema change in this release.
|
|
193
|
+
- **The 6.1 s first query is the 50k graph build.** It is paid once per index
|
|
194
|
+
generation, and the sidecar means a restart does not pay it again.
|
|
195
|
+
|
|
196
|
+
### A measurement mistake worth keeping
|
|
197
|
+
|
|
198
|
+
The first 50k run reported HNSW recall of **0.18** and was wrong. The default
|
|
199
|
+
candidate cap (10 000) was still in force, so the "exact" baseline had scored
|
|
200
|
+
only the newest 10 000 of 50 000 rows while HNSW searched all of them — the
|
|
201
|
+
disagreement was the baseline's blind spot, not the ANN's error. The bench now
|
|
202
|
+
lifts the cap by default and prints a `trunc` column. Recorded here because
|
|
203
|
+
the failure mode is generic: *any* recall number measured against a truncated
|
|
204
|
+
baseline is measuring the truncation.
|
|
205
|
+
|
|
206
|
+
### Not measured
|
|
207
|
+
|
|
208
|
+
- **100k+ vectors.** Nothing is claimed beyond the 50k run above.
|
|
209
|
+
- **sqlite-vec ANN.** The optional `ann` extra was not installed, so
|
|
210
|
+
`vector_search_backend` reported `bruteforce-cosine` throughout. Its numbers
|
|
211
|
+
are unknown, not zero.
|
|
212
|
+
- **A real embedding provider.** Everything here uses the deterministic hash
|
|
213
|
+
embedder. With a model- or network-backed embedder, query-embedding cost
|
|
214
|
+
moves into the foreground and these ratios change.
|
|
215
|
+
- **Resident process memory.** See the tracemalloc caveat above: the HNSW graph
|
|
216
|
+
lives outside Python's allocator and is not in the `peak MB` column.
|
|
217
|
+
|
|
166
218
|
## Observations
|
|
167
219
|
|
|
168
220
|
- Keyword `search()` / `context_for_query()` stay low-millisecond thanks to
|
|
169
221
|
the FTS5 trigram index; they are not the scaling bottleneck.
|
|
170
222
|
- `traverse(depth=2)` cost grows with edge fan-out (synthetic corpus creates
|
|
171
223
|
dense shared-concept hubs); p95 is ~5 ms at 500 sources and ~16 ms at 5000.
|
|
172
|
-
- `vector_search()` is a brute-force scan over every
|
|
173
|
-
(O(index size) per query). It is the dominant cost at scale
|
|
174
|
-
|
|
175
|
-
|
|
224
|
+
- `vector_search()` on the default backend is a brute-force scan over every
|
|
225
|
+
stored embedding (O(index size) per query). It is the dominant cost at scale
|
|
226
|
+
— which is what the 11.1.0 backend table above measures, and what
|
|
227
|
+
`LATTICEAI_VECTOR_INDEX=hnsw` addresses. `profile_kg.py` still caps vector
|
|
228
|
+
queries at 10 for this reason.
|
|
176
229
|
- `rebuild_vector_index(full=True)` embeds documents and chunks with the hash
|
|
177
230
|
embedder; with a real embedding provider expect this phase to be slower by
|
|
178
231
|
the provider's per-call latency times the item count.
|
package/docs/TRUST_MODEL.md
CHANGED
package/docs/WHY_LATTICE.md
CHANGED
package/docs/architecture.md
CHANGED
|
@@ -130,8 +130,12 @@ hardware scan
|
|
|
130
130
|
-> download/install/load/verify
|
|
131
131
|
```
|
|
132
132
|
|
|
133
|
-
The current default recommendation family is Gemma 4. Qwen3
|
|
134
|
-
|
|
133
|
+
The current default recommendation family is Gemma 4. Qwen3.6 and Qwen3.5 are
|
|
134
|
+
the current multimodal alternatives; GPT-OSS 20B and LFM2.5 2.6B fill the
|
|
135
|
+
general-purpose and ultralight tiers as text-only models. Superseded families
|
|
136
|
+
(Qwen3-VL, Llama 4, Qwen2.5-VL, Llama 3.2 Vision) stay in the capability
|
|
137
|
+
registry as *recognised* entries so already-downloaded weights keep working,
|
|
138
|
+
but they never appear in the catalog or a download path. See MODEL_POLICY.md.
|
|
135
139
|
|
|
136
140
|
## Modes
|
|
137
141
|
|
package/docs/kg-schema.md
CHANGED
|
@@ -8,7 +8,7 @@ import os
|
|
|
8
8
|
import re
|
|
9
9
|
import struct
|
|
10
10
|
from dataclasses import dataclass
|
|
11
|
-
from typing import
|
|
11
|
+
from typing import Iterable, List
|
|
12
12
|
|
|
13
13
|
DEFAULT_EMBEDDING_DIM = int(os.getenv("LATTICEAI_VECTOR_DIM", "384"))
|
|
14
14
|
EMBEDDING_MODEL_ID = f"lattice-local-hash-v1:{DEFAULT_EMBEDDING_DIM}"
|
|
@@ -97,40 +97,15 @@ class LocalEmbeddingModel:
|
|
|
97
97
|
return list(struct.unpack(f"<{count}f", payload[: count * 4]))
|
|
98
98
|
|
|
99
99
|
|
|
100
|
-
#
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
#
|
|
104
|
-
#
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
embed: produces a vector from image meta (size+format) + optional caption hash.
|
|
111
|
-
Later: replace with real embedding model that accepts image bytes/path.
|
|
112
|
-
"""
|
|
113
|
-
dim: int = DEFAULT_EMBEDDING_DIM
|
|
114
|
-
|
|
115
|
-
def describe(self, path: str | None = None, meta: Optional[Dict[str, Any]] = None) -> str:
|
|
116
|
-
meta = meta or {}
|
|
117
|
-
w = meta.get("width") or meta.get("w") or "?"
|
|
118
|
-
h = meta.get("height") or meta.get("h") or "?"
|
|
119
|
-
fmt = meta.get("format") or meta.get("ext") or "img"
|
|
120
|
-
name = (path or "").split("/")[-1] or "image"
|
|
121
|
-
# deterministic caption stub (no external call)
|
|
122
|
-
return f"Image {name} ({fmt} {w}x{h})"
|
|
123
|
-
|
|
124
|
-
def embed_image(self, path: str | None = None, meta: Optional[Dict[str, Any]] = None, caption: str = "") -> List[float]:
|
|
125
|
-
meta = meta or {}
|
|
126
|
-
basis = f"{path or ''}|{meta.get('width',0)}x{meta.get('height',0)}|{meta.get('format','')}|{caption[:120]}"
|
|
127
|
-
# reuse text embedder for determinism (image content hash would be better with real vision)
|
|
128
|
-
model = LocalEmbeddingModel(dim=self.dim)
|
|
129
|
-
return model.embed(basis)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
def get_vision_embedder(dim: int | None = None) -> VisionStub:
|
|
133
|
-
return VisionStub(dim=dim or DEFAULT_EMBEDDING_DIM)
|
|
134
|
-
|
|
100
|
+
# Removed in v11.1.0: ``VisionStub`` / ``get_vision_embedder``.
|
|
101
|
+
#
|
|
102
|
+
# They produced a "caption" (``Image pic.png (PNG 12x8)``) and an "image
|
|
103
|
+
# embedding" (a hash of the filename and pixel dimensions) with no model
|
|
104
|
+
# involved, and ``discovery_index`` stored both. Once in the graph neither was
|
|
105
|
+
# distinguishable from something a vision model had actually said, which is the
|
|
106
|
+
# one property a caption must have. The honest seam is now
|
|
107
|
+
# :class:`lattice_brain.multimodal.MultimodalPorts`: a caption exists only when
|
|
108
|
+
# a vision-language model produced it, an image vector only when a vision model
|
|
109
|
+
# produced it, and their absence is recorded as an absence.
|
|
135
110
|
|
|
136
|
-
__all__ = ["DEFAULT_EMBEDDING_DIM", "EMBEDDING_MODEL_ID", "LocalEmbeddingModel", "embedding_model_id"
|
|
111
|
+
__all__ = ["DEFAULT_EMBEDDING_DIM", "EMBEDDING_MODEL_ID", "LocalEmbeddingModel", "embedding_model_id"]
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Opt-in gates that can still be answered at runtime (v11.2.0).
|
|
2
|
+
|
|
3
|
+
Every opt-in feature in this product used to decide once, in a constructor:
|
|
4
|
+
``self._on = os.getenv("LATTICEAI_…") in {"1", …}``. That is correct for a
|
|
5
|
+
process that reads its environment at boot and never changes its mind, and it
|
|
6
|
+
is a dead end for the settings screen that is coming — a UI toggle cannot move
|
|
7
|
+
a boolean that was already copied into ``self``.
|
|
8
|
+
|
|
9
|
+
:class:`FeatureGate` is the seam that keeps both true at once. It answers at
|
|
10
|
+
*call* time in a fixed order:
|
|
11
|
+
|
|
12
|
+
1. a **bound resolver** — a callable the app layer supplies (the future
|
|
13
|
+
settings surface, a per-workspace policy, a test double);
|
|
14
|
+
2. an explicit **override** set through :meth:`FeatureGate.set`;
|
|
15
|
+
3. the **environment variable**, parsed exactly the way the hand-written
|
|
16
|
+
``os.getenv`` checks it replaces did;
|
|
17
|
+
4. the declared **default**.
|
|
18
|
+
|
|
19
|
+
An untouched gate therefore behaves identically to the frozen read it replaced
|
|
20
|
+
— same env var, same truthy words, same default — while a bound resolver wins
|
|
21
|
+
without a single change at any construction site. Brain Core owns this because
|
|
22
|
+
Brain Core owns the gates that matter most (multi-modal routing, sharing), and
|
|
23
|
+
it may not import ``latticeai``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
from typing import Callable, Dict, Optional
|
|
30
|
+
|
|
31
|
+
#: The words this product has always accepted for "on" / "off".
|
|
32
|
+
TRUTHY = frozenset({"1", "true", "yes", "on"})
|
|
33
|
+
FALSY = frozenset({"0", "false", "no", "off"})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FeatureGate:
|
|
37
|
+
"""One opt-in switch, resolved when it is asked rather than when it is built."""
|
|
38
|
+
|
|
39
|
+
__slots__ = ("env_var", "default", "name", "detail", "_override", "_resolver")
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
env_var: str,
|
|
44
|
+
*,
|
|
45
|
+
default: bool = False,
|
|
46
|
+
name: str = "",
|
|
47
|
+
detail: str = "",
|
|
48
|
+
) -> None:
|
|
49
|
+
self.env_var = env_var
|
|
50
|
+
self.default = bool(default)
|
|
51
|
+
self.name = name or env_var
|
|
52
|
+
#: Plain sentence for a surface that has to explain why a feature is off.
|
|
53
|
+
self.detail = detail
|
|
54
|
+
self._override: Optional[bool] = None
|
|
55
|
+
self._resolver: Optional[Callable[[], bool]] = None
|
|
56
|
+
|
|
57
|
+
# ── resolution ───────────────────────────────────────────────────────────
|
|
58
|
+
def __call__(self) -> bool:
|
|
59
|
+
return self.enabled()
|
|
60
|
+
|
|
61
|
+
def enabled(self) -> bool:
|
|
62
|
+
"""The gate's answer *now* (resolver → override → env → default)."""
|
|
63
|
+
if self._resolver is not None:
|
|
64
|
+
return bool(self._resolver())
|
|
65
|
+
return self.local()
|
|
66
|
+
|
|
67
|
+
def local(self) -> bool:
|
|
68
|
+
"""This gate's own answer, ignoring any bound resolver.
|
|
69
|
+
|
|
70
|
+
The lower three layers on their own (override → env → default). A
|
|
71
|
+
resolver that only has an opinion *sometimes* — a settings service that
|
|
72
|
+
speaks for the features a person actually touched, and stays quiet about
|
|
73
|
+
the rest — hands the question back here, so an operator's environment
|
|
74
|
+
variable keeps working for everything nobody has decided.
|
|
75
|
+
"""
|
|
76
|
+
if self._override is not None:
|
|
77
|
+
return self._override
|
|
78
|
+
return self.from_env()
|
|
79
|
+
|
|
80
|
+
def from_env(self) -> bool:
|
|
81
|
+
"""The environment's answer alone, ignoring resolver and override."""
|
|
82
|
+
raw = os.getenv(self.env_var, "").strip().lower()
|
|
83
|
+
if raw in TRUTHY:
|
|
84
|
+
return True
|
|
85
|
+
if raw in FALSY:
|
|
86
|
+
return False
|
|
87
|
+
return self.default
|
|
88
|
+
|
|
89
|
+
def source(self) -> str:
|
|
90
|
+
"""Which of the four layers produced the current answer."""
|
|
91
|
+
if self._resolver is not None:
|
|
92
|
+
return "resolver"
|
|
93
|
+
if self._override is not None:
|
|
94
|
+
return "override"
|
|
95
|
+
if os.getenv(self.env_var, "").strip().lower() in (TRUTHY | FALSY):
|
|
96
|
+
return "env"
|
|
97
|
+
return "default"
|
|
98
|
+
|
|
99
|
+
# ── injection ────────────────────────────────────────────────────────────
|
|
100
|
+
def set(self, value: Optional[bool]) -> None:
|
|
101
|
+
"""Explicit runtime override. ``None`` hands the answer back to the env."""
|
|
102
|
+
self._override = None if value is None else bool(value)
|
|
103
|
+
|
|
104
|
+
def bind(self, resolver: Optional[Callable[[], bool]]) -> None:
|
|
105
|
+
"""Delegate the answer to a caller-supplied callable (``None`` unbinds)."""
|
|
106
|
+
self._resolver = resolver
|
|
107
|
+
|
|
108
|
+
def reset(self) -> None:
|
|
109
|
+
"""Forget both injections — the gate is env-driven again."""
|
|
110
|
+
self._override = None
|
|
111
|
+
self._resolver = None
|
|
112
|
+
|
|
113
|
+
def describe(self) -> Dict[str, object]:
|
|
114
|
+
"""Honest read for a status surface: state *and* where it came from."""
|
|
115
|
+
return {
|
|
116
|
+
"name": self.name,
|
|
117
|
+
"flag": self.env_var,
|
|
118
|
+
"enabled": self.enabled(),
|
|
119
|
+
"default": self.default,
|
|
120
|
+
"source": self.source(),
|
|
121
|
+
"detail": self.detail,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
__all__ = ["FALSY", "TRUTHY", "FeatureGate"]
|
|
@@ -111,40 +111,38 @@ class KnowledgeGraphLocalIndexMixin(_Core):
|
|
|
111
111
|
meta["text_slides"] = len(slides_text)
|
|
112
112
|
text = "\n\n".join(slides_text)
|
|
113
113
|
elif category == "image":
|
|
114
|
-
|
|
114
|
+
text = self._extract_image_signals(path, meta, include_ocr=include_ocr)
|
|
115
|
+
return text[:200_000], meta
|
|
115
116
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
"height": image.height,
|
|
121
|
-
"format": image.format,
|
|
122
|
-
"mode": image.mode,
|
|
123
|
-
"ocr_enabled": bool(include_ocr),
|
|
124
|
-
}
|
|
125
|
-
)
|
|
126
|
-
if include_ocr:
|
|
127
|
-
try:
|
|
128
|
-
import pytesseract
|
|
117
|
+
def _extract_image_signals(
|
|
118
|
+
self, path: Path, meta: Dict[str, Any], *, include_ocr: bool
|
|
119
|
+
) -> str:
|
|
120
|
+
"""Dimensions, OCR text, and — only if a VLM exists — a caption.
|
|
129
121
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
122
|
+
Until v11.1.0 this path always attached a ``vision_caption`` built out
|
|
123
|
+
of the filename and the pixel dimensions (``Image pic.png (PNG 12x8)``)
|
|
124
|
+
and used it as the retrieval text. Nothing downstream could tell that
|
|
125
|
+
string apart from something a vision model had actually said about the
|
|
126
|
+
picture, so every screenshot in the graph carried a fake description.
|
|
127
|
+
|
|
128
|
+
Now the caption comes from the injected port and from nowhere else. A
|
|
129
|
+
picture with no OCR text and no model still gets indexed — under its
|
|
130
|
+
filename, which is a fact — and ``caption_status`` says why there is no
|
|
131
|
+
caption.
|
|
132
|
+
"""
|
|
133
|
+
from ..multimodal import MultimodalPorts, extract_image_facts
|
|
134
|
+
|
|
135
|
+
ports = getattr(self, "multimodal_ports", None) or MultimodalPorts()
|
|
136
|
+
facts = extract_image_facts(str(path), ports=ports, ocr=include_ocr)
|
|
137
|
+
meta.update(facts.as_metadata())
|
|
138
|
+
meta["ocr_enabled"] = bool(include_ocr)
|
|
139
|
+
if facts.ocr_text:
|
|
140
|
+
meta["ocr_chars"] = len(facts.ocr_text)
|
|
141
|
+
if facts.ocr_status == "failed":
|
|
142
|
+
meta["ocr_error"] = facts.ocr_detail
|
|
143
|
+
if not facts.readable:
|
|
144
|
+
return ""
|
|
145
|
+
return facts.index_text() or path.name
|
|
148
146
|
|
|
149
147
|
def _ensure_local_hierarchy(
|
|
150
148
|
self,
|
|
@@ -30,6 +30,7 @@ import os
|
|
|
30
30
|
import re
|
|
31
31
|
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Tuple
|
|
32
32
|
|
|
33
|
+
from ..gates import FeatureGate
|
|
33
34
|
from ..quiet import quiet
|
|
34
35
|
|
|
35
36
|
QUERY_CLASSES = ("fact", "code", "person", "recency")
|
|
@@ -50,8 +51,20 @@ FUSION_WEIGHTS_ENV = "LATTICEAI_FUSION_WEIGHTS"
|
|
|
50
51
|
# get shipped. Turn it on with LATTICEAI_FUSION_STRATEGY=rrf (all classes) or
|
|
51
52
|
# a JSON object like {"code": "rrf"} (per class).
|
|
52
53
|
FUSION_STRATEGY_ENV = "LATTICEAI_FUSION_STRATEGY"
|
|
54
|
+
#: The one-switch form of the per-class table above, for the settings panel:
|
|
55
|
+
#: "combine by rank instead of score", everywhere. It is applied as the *base*
|
|
56
|
+
#: of the table, so the per-class ``LATTICEAI_FUSION_STRATEGY`` config — the
|
|
57
|
+
#: more specific statement — still wins over it, and an install that touched
|
|
58
|
+
#: neither is byte-identical to what it was.
|
|
59
|
+
FUSION_RRF_ENV = "LATTICEAI_FUSION_RRF"
|
|
53
60
|
FUSION_STRATEGIES = ("alpha", "rrf")
|
|
54
61
|
DEFAULT_FUSION_STRATEGY: Dict[str, str] = dict.fromkeys(QUERY_CLASSES, "alpha")
|
|
62
|
+
FUSION_RRF_GATE = FeatureGate(
|
|
63
|
+
FUSION_RRF_ENV,
|
|
64
|
+
default=False,
|
|
65
|
+
name="fusion_rrf",
|
|
66
|
+
detail="Search channels are combined by rank rather than by score.",
|
|
67
|
+
)
|
|
55
68
|
#: The smoothing constant from the original RRF paper (Cormack et al., 2009).
|
|
56
69
|
#: Larger k flattens the curve, so rank 1 wins by less.
|
|
57
70
|
DEFAULT_RRF_K = 60
|
|
@@ -195,8 +208,16 @@ def _env_strategy_overrides() -> Dict[str, str]:
|
|
|
195
208
|
def fusion_strategy_table(
|
|
196
209
|
overrides: Optional[Mapping[str, str]] = None,
|
|
197
210
|
) -> Dict[str, str]:
|
|
198
|
-
"""Full per-class
|
|
211
|
+
"""Full per-class table: defaults ← simple switch ← env override ← caller.
|
|
212
|
+
|
|
213
|
+
The simple switch (:data:`FUSION_RRF_GATE`, the settings panel's "combine by
|
|
214
|
+
rank") sits *under* the per-class env config on purpose: a person who wrote
|
|
215
|
+
``{"code": "alpha"}`` said something specific, and a single global flag must
|
|
216
|
+
not overrule it.
|
|
217
|
+
"""
|
|
199
218
|
table = dict(DEFAULT_FUSION_STRATEGY)
|
|
219
|
+
if FUSION_RRF_GATE.enabled():
|
|
220
|
+
table = dict.fromkeys(QUERY_CLASSES, "rrf")
|
|
200
221
|
for source in (_env_strategy_overrides(), overrides or {}):
|
|
201
222
|
for cls, value in source.items():
|
|
202
223
|
if cls in table and str(value).lower() in FUSION_STRATEGIES:
|
|
@@ -243,6 +264,14 @@ def rrf_fuse(
|
|
|
243
264
|
# failure mode is dilution: an unbounded expansion turns a precise answer into
|
|
244
265
|
# a tour of the graph.
|
|
245
266
|
GRAPH_EXPANSION_ENV = "LATTICEAI_GRAPH_EXPANSION"
|
|
267
|
+
#: Resolved when asked so the settings panel can move it without a restart; the
|
|
268
|
+
#: env parsing is the same set of words the hand-written check used.
|
|
269
|
+
GRAPH_EXPANSION_GATE = FeatureGate(
|
|
270
|
+
GRAPH_EXPANSION_ENV,
|
|
271
|
+
default=False,
|
|
272
|
+
name="graph_expansion",
|
|
273
|
+
detail="Memories one link away from a hit are offered as extra candidates.",
|
|
274
|
+
)
|
|
246
275
|
DEFAULT_EXPANSION_SEEDS = 3
|
|
247
276
|
DEFAULT_EXPANSION_CAP = 5
|
|
248
277
|
#: Expanded candidates inherit a damped share of their seed's score: they are
|
|
@@ -251,9 +280,8 @@ EXPANSION_DECAY = 0.5
|
|
|
251
280
|
|
|
252
281
|
|
|
253
282
|
def graph_expansion_enabled() -> bool:
|
|
254
|
-
"""True when
|
|
255
|
-
|
|
256
|
-
return raw in {"1", "true", "yes", "on"}
|
|
283
|
+
"""True when the expansion gate opts in (default: off)."""
|
|
284
|
+
return GRAPH_EXPANSION_GATE.enabled()
|
|
257
285
|
|
|
258
286
|
|
|
259
287
|
def expand_with_neighbors(
|
|
@@ -348,10 +376,13 @@ __all__ = [
|
|
|
348
376
|
"DEFAULT_FUSION_WEIGHTS",
|
|
349
377
|
"DEFAULT_RRF_K",
|
|
350
378
|
"EXPANSION_DECAY",
|
|
379
|
+
"FUSION_RRF_ENV",
|
|
380
|
+
"FUSION_RRF_GATE",
|
|
351
381
|
"FUSION_STRATEGIES",
|
|
352
382
|
"FUSION_STRATEGY_ENV",
|
|
353
383
|
"FUSION_WEIGHTS_ENV",
|
|
354
384
|
"GRAPH_EXPANSION_ENV",
|
|
385
|
+
"GRAPH_EXPANSION_GATE",
|
|
355
386
|
"QUERY_CLASSES",
|
|
356
387
|
"classify_query",
|
|
357
388
|
"expand_with_neighbors",
|