ltcai 11.1.0 → 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.
Files changed (118) hide show
  1. package/README.md +53 -53
  2. package/docs/CHANGELOG.md +33 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/FEATURE_AUDIT_v11.2.0.md +393 -0
  6. package/docs/LAYOUT_REBUILD_SPEC.md +9 -1
  7. package/docs/ONBOARDING.md +1 -1
  8. package/docs/OPERATIONS.md +1 -1
  9. package/docs/TRUST_MODEL.md +1 -1
  10. package/docs/WHY_LATTICE.md +1 -1
  11. package/docs/architecture.md +6 -2
  12. package/docs/kg-schema.md +1 -1
  13. package/lattice_brain/__init__.py +1 -1
  14. package/lattice_brain/gates.py +125 -0
  15. package/lattice_brain/graph/fusion.py +35 -4
  16. package/lattice_brain/graph/projection.py +66 -8
  17. package/lattice_brain/graph/schema.py +9 -0
  18. package/lattice_brain/graph/store.py +9 -0
  19. package/lattice_brain/graph/vector_index/selector.py +32 -2
  20. package/lattice_brain/ingestion.py +175 -27
  21. package/lattice_brain/multimodal.py +525 -5
  22. package/lattice_brain/portability.py +169 -32
  23. package/lattice_brain/runtime/multi_agent.py +1 -1
  24. package/lattice_brain/sealed_box.py +244 -0
  25. package/lattice_brain/synthesis.py +24 -1
  26. package/latticeai/__init__.py +1 -1
  27. package/latticeai/api/brain_intelligence.py +4 -0
  28. package/latticeai/api/chat.py +11 -0
  29. package/latticeai/api/chat_helpers.py +16 -3
  30. package/latticeai/api/chat_hybrid.py +32 -1
  31. package/latticeai/api/features.py +70 -0
  32. package/latticeai/api/local_files.py +102 -0
  33. package/latticeai/api/portability.py +39 -4
  34. package/latticeai/api/review_queue.py +126 -0
  35. package/latticeai/api/search.py +16 -2
  36. package/latticeai/core/agent.py +55 -2
  37. package/latticeai/core/config.py +4 -1
  38. package/latticeai/core/context_builder.py +6 -3
  39. package/latticeai/core/legacy_compatibility.py +1 -1
  40. package/latticeai/core/marketplace.py +1 -1
  41. package/latticeai/core/messages.py +143 -0
  42. package/latticeai/core/model_compat.py +73 -2
  43. package/latticeai/core/workspace_os_constants.py +1 -1
  44. package/latticeai/models/model_providers.py +12 -4
  45. package/latticeai/runtime/build_phases.py +28 -0
  46. package/latticeai/runtime/chat_wiring.py +4 -0
  47. package/latticeai/runtime/feature_toggle_wiring.py +163 -0
  48. package/latticeai/runtime/router_registration.py +11 -0
  49. package/latticeai/services/app_context.py +8 -0
  50. package/latticeai/services/architecture_readiness.py +1 -1
  51. package/latticeai/services/automation_intelligence.py +22 -2
  52. package/latticeai/services/brain_intelligence.py +123 -7
  53. package/latticeai/services/command_center.py +10 -4
  54. package/latticeai/services/feature_toggles.py +502 -0
  55. package/latticeai/services/folder_watch.py +122 -1
  56. package/latticeai/services/hybrid_chat.py +56 -5
  57. package/latticeai/services/interop_bridges.py +978 -0
  58. package/latticeai/services/model_capability_registry.py +434 -261
  59. package/latticeai/services/model_catalog.py +95 -61
  60. package/latticeai/services/model_recommendation.py +18 -11
  61. package/latticeai/services/model_runtime.py +1 -1
  62. package/latticeai/services/multimodal_ports.py +26 -1
  63. package/latticeai/services/obsidian_bridge.py +16 -25
  64. package/latticeai/services/product_readiness.py +1 -1
  65. package/latticeai/services/search_service.py +149 -2
  66. package/latticeai/services/tool_dispatch.py +4 -0
  67. package/latticeai/setup/auto_setup.py +27 -30
  68. package/latticeai/setup/wizard.py +77 -44
  69. package/package.json +1 -1
  70. package/scripts/check_current_release_docs.mjs +1 -1
  71. package/scripts/check_server_i18n.mjs +1 -0
  72. package/scripts/release_screen_claims.json +13 -0
  73. package/scripts/verify_hf_model_registry.py +253 -218
  74. package/src-tauri/Cargo.lock +1 -1
  75. package/src-tauri/Cargo.toml +1 -1
  76. package/src-tauri/tauri.conf.json +1 -1
  77. package/static/app/asset-manifest.json +37 -37
  78. package/static/app/assets/{Act-D0HWqtn0.js → Act-AWf0SAKp.js} +1 -1
  79. package/static/app/assets/{AdminConsole-D-QDW-A4.js → AdminConsole-D0u8Tiyj.js} +1 -1
  80. package/static/app/assets/{Brain-CzCsI1mi.js → Brain-tuhI4sOC.js} +1 -1
  81. package/static/app/assets/BrainHome-Ts7G_Ila.js +2 -0
  82. package/static/app/assets/{BrainSignals-2dHQNkns.js → BrainSignals-jMYgQ2Ar.js} +1 -1
  83. package/static/app/assets/{Capture-CT8v1StE.js → Capture-CqOSzyPr.js} +1 -1
  84. package/static/app/assets/{CommandPalette-DoLXC2KH.js → CommandPalette-DC0Bzh-I.js} +1 -1
  85. package/static/app/assets/{Library-DDoxFE5c.js → Library-CX-bbhmK.js} +1 -1
  86. package/static/app/assets/{LivingBrain-BXMWIK_2.js → LivingBrain-DBwhto14.js} +1 -1
  87. package/static/app/assets/{ProductFlow-DOYf7JIs.js → ProductFlow-BHA2cfKI.js} +1 -1
  88. package/static/app/assets/{ReviewCard-COQsqidK.js → ReviewCard-BUhCKRNM.js} +1 -1
  89. package/static/app/assets/{System-BRllvYXd.js → System-Bu2t5hn1.js} +1 -1
  90. package/static/app/assets/arrow-left-Dzwa5zRb.js +1 -0
  91. package/static/app/assets/{bot-4BvN07ux.js → bot-Cia42c2h.js} +1 -1
  92. package/static/app/assets/brain-DJMoqrwx.js +1 -0
  93. package/static/app/assets/{button-CDjtnAoU.js → button-2j2Ijzgq.js} +1 -1
  94. package/static/app/assets/{circle-pause-D_RMn7tp.js → circle-pause-BEFeWpVW.js} +1 -1
  95. package/static/app/assets/{circle-play-B5OpB8ae.js → circle-play-ujXMcHxl.js} +1 -1
  96. package/static/app/assets/{cpu-BIlWInHf.js → cpu-k4awryFq.js} +1 -1
  97. package/static/app/assets/{download-BtjXfL3z.js → download-DFbLJ_ig.js} +1 -1
  98. package/static/app/assets/{folder-open-DefMpxI2.js → folder-open-7y_b6xkM.js} +1 -1
  99. package/static/app/assets/{hard-drive-BQ8NZVkw.js → hard-drive-Bidh02Kr.js} +1 -1
  100. package/static/app/assets/{index-0AvoEBzJ.js → index-BpYkzcVm.js} +3 -3
  101. package/static/app/assets/{index-vtEfYvQY.css → index-DwDl9-8Y.css} +1 -1
  102. package/static/app/assets/{input-B_5ZJ9oy.js → input-DSlJJxRs.js} +1 -1
  103. package/static/app/assets/{permissionCopy-BqZ5tsgL.js → permissionCopy-Bpb83Hx9.js} +1 -1
  104. package/static/app/assets/{primitives-CVwew78r.js → primitives-BCx6TvfG.js} +1 -1
  105. package/static/app/assets/search-Cgy8cCFJ.js +1 -0
  106. package/static/app/assets/{share-2-D5zg_0fY.js → share-2-BH1M-WNi.js} +1 -1
  107. package/static/app/assets/{shield-alert-B5pZzkUb.js → shield-alert-BlKdBXcG.js} +1 -1
  108. package/static/app/assets/{textarea-nEVIweKY.js → textarea-CCWbUfFB.js} +1 -1
  109. package/static/app/assets/{useFocusTrap-Cm99AHlz.js → useFocusTrap-YdHQ7pJ1.js} +1 -1
  110. package/static/app/assets/{useQuery-Dm__N6bL.js → useQuery-CXQiwbVT.js} +1 -1
  111. package/static/app/assets/{utils-DcDMoZIe.js → utils-zqPZJxdx.js} +2 -2
  112. package/static/app/assets/{workspace-LtRRSKTf.js → workspace-DXTihhfU.js} +1 -1
  113. package/static/app/index.html +4 -4
  114. package/static/sw.js +1 -1
  115. package/static/app/assets/BrainHome-Btns-_TA.js +0 -2
  116. package/static/app/assets/arrow-left-DnyMzss-.js +0 -1
  117. package/static/app/assets/brain-uMb_5hnO.js +0 -1
  118. package/static/app/assets/search-DkhnOKZt.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
@@ -1,6 +1,6 @@
1
1
  # Lattice AI Onboarding
2
2
 
3
- Current release: **11.1.0 — Product Intelligence**.
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
@@ -1,4 +1,4 @@
1
- # Lattice AI — Operations Guide (v11.1.0)
1
+ # Lattice AI — Operations Guide (v11.2.0)
2
2
 
3
3
  > **Status: canonical** — kept in sync with the current release. Storage layout
4
4
  > below reflects the SQLite live Brain store and workspace scoping, not the
@@ -1,6 +1,6 @@
1
1
  # Lattice AI Trust Model
2
2
 
3
- Current release: **11.1.0 — Product Intelligence**.
3
+ Current release: **11.2.0 — All Systems On**.
4
4
 
5
5
  Lattice AI is local-first, explicit about external communication, and honest
6
6
  when a capability is unavailable.
@@ -1,6 +1,6 @@
1
1
  # Why Lattice AI Exists
2
2
 
3
- Current release: **11.1.0 — Product Intelligence**.
3
+ Current release: **11.2.0 — All Systems On**.
4
4
 
5
5
  **Lattice AI is a local-first Digital Brain that keeps your knowledge durable
6
6
  across any AI model.**
@@ -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-VL and Llama 4
134
- remain current multimodal alternatives.
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
@@ -1,6 +1,6 @@
1
1
  # Knowledge Graph Schema
2
2
 
3
- Current release: **11.1.0 — Product Intelligence**.
3
+ Current release: **11.2.0 — All Systems On**.
4
4
 
5
5
  명세 출처: `lattice_ai_full_spec.pptx` 슬라이드 20·21·22
6
6
  구현: `lattice_brain/graph/schema.py`
@@ -26,7 +26,7 @@ from .storage import (
26
26
  storage_from_env,
27
27
  )
28
28
 
29
- __version__ = "11.1.0"
29
+ __version__ = "11.2.0"
30
30
 
31
31
  __all__ = [
32
32
  "AgentRuntime",
@@ -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"]
@@ -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 strategy table: defaults ← env override ← caller."""
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 ``LATTICEAI_GRAPH_EXPANSION`` opts in (default: off)."""
255
- raw = os.getenv(GRAPH_EXPANSION_ENV, "").strip().lower()
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",
@@ -52,16 +52,29 @@ class KnowledgeGraphProjectionMixin(_Core):
52
52
  END;
53
53
  """
54
54
 
55
- # The temporal columns pass through *raw* (v11.1.0). ``type`` is COALESCEd
56
- # because ``legacy_type`` carries the label a reader expects; validity is
57
- # not a label — a COALESCE there would turn "still valid" (NULL) into a
58
- # value, which is exactly the ``kgv2_edges`` trap noted in the 11.0.1
59
- # review. NULL in, NULL out; the fallback to ``created_at`` belongs to the
60
- # read predicate (``schema.TEMPORAL_PREDICATE_SQL``), not to the view.
55
+ # ``type`` is reconstructed from ``legacy_type`` when there is one, because
56
+ # that column carries the label a reader expects. The trap the 11.0.1
57
+ # review recorded — and which 11.2.0 fixes — is that ``edges_v2.legacy_type``
58
+ # is ``NOT NULL DEFAULT ''``: a natively-canonical edge is written with
59
+ # ``legacy_type=''`` so its identity is effectively (source, target, type),
60
+ # and ``COALESCE('', type)`` returns ``''`` because COALESCE only skips
61
+ # NULL. Every canonical edge therefore read back with an **empty type**.
62
+ # ``NULLIF(legacy_type, '')`` is the fix: empty means "no legacy label",
63
+ # which is precisely what the write side meant by it.
64
+ #
65
+ # The empty string stays the write-side sentinel on purpose. SQLite treats
66
+ # NULLs as distinct in a UNIQUE index, so moving to NULL would silently
67
+ # disable the (source, target, type, legacy_type) dedupe and let the same
68
+ # relation land twice.
69
+ #
70
+ # The temporal columns pass through *raw* (v11.1.0): validity is not a
71
+ # label, and a COALESCE there would turn "still valid" (NULL) into a value.
72
+ # NULL in, NULL out; the fallback to ``created_at`` belongs to the read
73
+ # predicate (``schema.TEMPORAL_PREDICATE_SQL``), not to the view.
61
74
  _V2_VIEWS_SQL = """
62
75
  CREATE VIEW IF NOT EXISTS kgv2_nodes AS
63
76
  SELECT id,
64
- COALESCE(legacy_type, type) AS type,
77
+ COALESCE(NULLIF(legacy_type, ''), type) AS type,
65
78
  label AS title,
66
79
  summary,
67
80
  attrs AS metadata_json,
@@ -70,7 +83,7 @@ class KnowledgeGraphProjectionMixin(_Core):
70
83
  FROM nodes_v2;
71
84
  CREATE VIEW IF NOT EXISTS kgv2_edges AS
72
85
  SELECT id, source AS from_node, target AS to_node,
73
- COALESCE(legacy_type, type) AS type,
86
+ COALESCE(NULLIF(legacy_type, ''), type) AS type,
74
87
  weight,
75
88
  metadata AS metadata_json,
76
89
  created_at,
@@ -156,6 +169,7 @@ class KnowledgeGraphProjectionMixin(_Core):
156
169
  KGStoreV2(self.db_path).init_schema(conn=conn)
157
170
  _exec_script(conn, self._V2_VIEWS_SQL)
158
171
  self._backfill_v2_on(conn, force=stale)
172
+ self._normalize_v2_legacy_types(conn)
159
173
  # version stamp commits together with the backfill — never stranded
160
174
  conn.execute(
161
175
  "INSERT OR REPLACE INTO kg_meta(key, value) VALUES ('projection_version', ?)",
@@ -177,6 +191,50 @@ class KnowledgeGraphProjectionMixin(_Core):
177
191
  except Exception as e:
178
192
  logging.warning("knowledge_graph: v2 schema init/backfill skipped: %s", e)
179
193
 
194
+ def _normalize_v2_legacy_types(self, conn: sqlite3.Connection) -> Dict[str, int]:
195
+ """Bring pre-11.2.0 rows onto the current ``legacy_type`` convention.
196
+
197
+ The convention is: ``legacy_type`` holds the *raw* label only when it
198
+ differs from the canonical type, and ``''`` (edges) / ``NULL`` (nodes)
199
+ otherwise. Rows written by older builds could carry the canonical value
200
+ in both columns, which is redundant and — because the dedupe key
201
+ includes ``legacy_type`` — splits one relation across two rows.
202
+
203
+ Idempotent by construction: a second run matches nothing. ``UPDATE OR
204
+ IGNORE`` is used because collapsing a redundant row can collide with
205
+ the canonical one that already exists; those survivors are counted and
206
+ reported rather than deleted, since the fixed view reads both
207
+ identically and deleting an edge is not a migration's business.
208
+ """
209
+ report = {"edges": 0, "nodes": 0, "collisions": 0}
210
+ try:
211
+ before = int(
212
+ conn.execute(
213
+ "SELECT COUNT(*) FROM edges_v2 WHERE legacy_type = type"
214
+ ).fetchone()[0]
215
+ or 0
216
+ )
217
+ conn.execute(
218
+ "UPDATE OR IGNORE edges_v2 SET legacy_type='' WHERE legacy_type = type"
219
+ )
220
+ after = int(
221
+ conn.execute(
222
+ "SELECT COUNT(*) FROM edges_v2 WHERE legacy_type = type"
223
+ ).fetchone()[0]
224
+ or 0
225
+ )
226
+ report["edges"] = before - after
227
+ report["collisions"] = after
228
+ cursor = conn.execute(
229
+ "UPDATE nodes_v2 SET legacy_type=NULL WHERE legacy_type = ''"
230
+ )
231
+ report["nodes"] = int(cursor.rowcount or 0)
232
+ except sqlite3.Error as exc:
233
+ # The projection is derived; a migration that cannot run leaves the
234
+ # data exactly as it was and the fixed view still reads it right.
235
+ logging.debug("knowledge_graph: legacy_type normalization skipped: %s", exc)
236
+ return report
237
+
180
238
  def _backup_before_v2_flip(self) -> Optional[str]:
181
239
  """Create one local SQLite backup before the v2 write-master flip."""
182
240
  if not self.db_path.exists() or self.db_path.stat().st_size == 0:
@@ -134,6 +134,11 @@ class NodeType(str, Enum):
134
134
  # exists whether or not anyone could hear it. IMAGE has been first-class
135
135
  # since 3.6.0 for the same reason, and AUDIO now sits beside it.
136
136
  AUDIO = "AUDIO" # 녹음 / 음성 메모
137
+ # v11.2.0 — a video is its own noun for the same reason a recording is:
138
+ # keyframes and subtitles are *derived* from it (IMAGE children, text
139
+ # chunks), and calling the thing they came from a DOCUMENT would make the
140
+ # source of a memory indistinguishable from one of its pieces.
141
+ VIDEO = "VIDEO" # 영상 / 화면 녹화
137
142
 
138
143
  @classmethod
139
144
  def from_legacy(cls, label: str) -> "NodeType":
@@ -285,6 +290,10 @@ _LEGACY_NODE_MAP: Dict[str, NodeType] = {
285
290
  # v11.1.0 Multi-modal — recordings as a first-class noun.
286
291
  "audio": NodeType.AUDIO,
287
292
  "오디오": NodeType.AUDIO,
293
+ # v11.2.0 — videos likewise.
294
+ "video": NodeType.VIDEO,
295
+ "영상": NodeType.VIDEO,
296
+ "동영상": NodeType.VIDEO,
288
297
  }
289
298
 
290
299
  _LEGACY_EDGE_MAP: Dict[str, EdgeType] = {
@@ -241,6 +241,15 @@ class KnowledgeGraphStore(
241
241
  CREATE INDEX IF NOT EXISTS idx_vector_embeddings_type ON vector_embeddings(item_type);
242
242
  CREATE INDEX IF NOT EXISTS idx_vector_embeddings_source ON vector_embeddings(source_node);
243
243
  CREATE INDEX IF NOT EXISTS idx_vector_embeddings_model ON vector_embeddings(embedding_model);
244
+ -- v11.2.0: the ANN sidecar's freshness fingerprint is
245
+ -- COUNT(*) + MAX(indexed_at) filtered by (embedding_model,
246
+ -- embedding_dim), and it runs on *every* approximate
247
+ -- search. Against the single-column model index that was a
248
+ -- table scan of every row for the current model; this
249
+ -- covering index answers both aggregates from the index
250
+ -- alone. Additive and idempotent — no data is rewritten.
251
+ CREATE INDEX IF NOT EXISTS idx_vector_embeddings_model_dim_indexed
252
+ ON vector_embeddings(embedding_model, embedding_dim, indexed_at);
244
253
  CREATE INDEX IF NOT EXISTS idx_vector_index_operations_requested ON vector_index_operations(requested_at);
245
254
  CREATE INDEX IF NOT EXISTS idx_provenance_node ON ingestion_provenance(node_id);
246
255
  CREATE INDEX IF NOT EXISTS idx_provenance_source_type ON ingestion_provenance(source_type);
@@ -11,13 +11,21 @@ exactly like "search is a bit worse today":
11
11
 
12
12
  Both resolve to the exact brute-force scan and carry a ``detail`` string
13
13
  naming the cause, which ``index_status()`` and the search result surface.
14
+
15
+ The backend is a *choice*, not a switch, so it cannot ride a
16
+ :class:`~lattice_brain.gates.FeatureGate` (which answers booleans). It gets the
17
+ same shape of seam instead: :func:`bind_vector_index_resolver` installs a
18
+ caller-supplied resolver that is consulted ahead of the environment, which is
19
+ what lets the settings panel change the backend without a restart. With nothing
20
+ bound — the default — the environment variable is still the whole control
21
+ surface, exactly as before.
14
22
  """
15
23
 
16
24
  from __future__ import annotations
17
25
 
18
26
  import os
19
27
  from dataclasses import dataclass
20
- from typing import Any, Dict, Optional
28
+ from typing import Any, Callable, Dict, Optional
21
29
 
22
30
  from .base import Similarity, VectorIndex
23
31
  from .brute_force import BRUTE_FORCE_BACKEND, BruteForceIndex
@@ -37,6 +45,23 @@ _BACKEND_LABELS = {
37
45
  _APPROX = {"brute": False, "quantized": True, "hnsw": True}
38
46
  _EXHAUSTIVE = {"brute": True, "quantized": True, "hnsw": False}
39
47
 
48
+ #: App-layer resolver, consulted before the environment. ``None`` (the default)
49
+ #: leaves this module reading exactly the env var it always read.
50
+ _RESOLVER: Optional[Callable[[], Optional[str]]] = None
51
+
52
+
53
+ def bind_vector_index_resolver(
54
+ resolver: Optional[Callable[[], Optional[str]]],
55
+ ) -> None:
56
+ """Delegate backend selection to a callable (``None`` hands it back to env).
57
+
58
+ A resolver that returns ``None`` also falls through to the environment, so
59
+ "the settings service has no opinion" and "there is no settings service"
60
+ reach the same answer instead of two.
61
+ """
62
+ global _RESOLVER
63
+ _RESOLVER = resolver
64
+
40
65
 
41
66
  @dataclass(frozen=True)
42
67
  class BackendSelection:
@@ -79,7 +104,11 @@ def _selection(name: str, *, requested: str, detail: Optional[str]) -> BackendSe
79
104
 
80
105
  def resolve_vector_index(requested: Optional[str] = None) -> BackendSelection:
81
106
  """Resolve the configured backend (never raises, always falls back safe)."""
82
- raw = requested if requested is not None else os.getenv(VECTOR_INDEX_ENV, "")
107
+ raw = requested
108
+ if raw is None and _RESOLVER is not None:
109
+ raw = _RESOLVER()
110
+ if raw is None:
111
+ raw = os.getenv(VECTOR_INDEX_ENV, "")
83
112
  name = str(raw or "").strip().lower() or DEFAULT_VECTOR_INDEX
84
113
  if name not in VECTOR_INDEX_CHOICES:
85
114
  return _selection(
@@ -126,6 +155,7 @@ __all__ = [
126
155
  "VECTOR_INDEX_CHOICES",
127
156
  "VECTOR_INDEX_ENV",
128
157
  "BackendSelection",
158
+ "bind_vector_index_resolver",
129
159
  "build_index",
130
160
  "resolve_vector_index",
131
161
  ]