ltcai 10.10.0 → 11.1.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 (139) hide show
  1. package/README.md +57 -42
  2. package/docs/CHANGELOG.md +68 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/PERFORMANCE.md +135 -11
  8. package/docs/TRUST_MODEL.md +1 -1
  9. package/docs/WHY_LATTICE.md +1 -1
  10. package/docs/kg-schema.md +1 -1
  11. package/docs/v11.1.0_PRODUCT_INTELLIGENCE_PLAN.md +313 -0
  12. package/lattice_brain/__init__.py +1 -1
  13. package/lattice_brain/embeddings.py +12 -37
  14. package/lattice_brain/graph/_kg_contract.py +11 -0
  15. package/lattice_brain/graph/curator.py +1 -1
  16. package/lattice_brain/graph/discovery_index.py +30 -32
  17. package/lattice_brain/graph/fusion.py +184 -3
  18. package/lattice_brain/graph/image_vectors.py +230 -0
  19. package/lattice_brain/graph/ingest.py +11 -5
  20. package/lattice_brain/graph/proactive.py +138 -1
  21. package/lattice_brain/graph/projection.py +10 -2
  22. package/lattice_brain/graph/provenance.py +27 -2
  23. package/lattice_brain/graph/retrieval.py +249 -8
  24. package/lattice_brain/graph/retrieval_docgen.py +23 -23
  25. package/lattice_brain/graph/retrieval_policy.py +6 -0
  26. package/lattice_brain/graph/retrieval_reads.py +188 -1
  27. package/lattice_brain/graph/retrieval_vector.py +474 -110
  28. package/lattice_brain/graph/schema.py +134 -2
  29. package/lattice_brain/graph/vector_index/__init__.py +85 -0
  30. package/lattice_brain/graph/vector_index/base.py +170 -0
  31. package/lattice_brain/graph/vector_index/brute_force.py +114 -0
  32. package/lattice_brain/graph/vector_index/hnsw.py +293 -0
  33. package/lattice_brain/graph/vector_index/jobs.py +287 -0
  34. package/lattice_brain/graph/vector_index/quantized.py +151 -0
  35. package/lattice_brain/graph/vector_index/selector.py +131 -0
  36. package/lattice_brain/ingestion.py +259 -7
  37. package/lattice_brain/multimodal.py +738 -0
  38. package/lattice_brain/portability.py +654 -2
  39. package/lattice_brain/runtime/agent_runtime.py +1 -1
  40. package/lattice_brain/runtime/contracts.py +1 -1
  41. package/lattice_brain/runtime/multi_agent.py +1 -1
  42. package/lattice_brain/self_model.py +675 -0
  43. package/lattice_brain/synthesis.py +801 -0
  44. package/latticeai/__init__.py +1 -1
  45. package/latticeai/api/brain_intelligence.py +83 -1
  46. package/latticeai/api/chat_stream.py +4 -1
  47. package/latticeai/api/local_files.py +62 -0
  48. package/latticeai/api/memory.py +128 -1
  49. package/latticeai/api/models.py +1 -1
  50. package/latticeai/api/portability.py +130 -1
  51. package/latticeai/api/security_dashboard.py +48 -13
  52. package/latticeai/api/voice_capture.py +4 -1
  53. package/latticeai/api/workspace.py +11 -5
  54. package/latticeai/core/agent.py +5 -1
  55. package/latticeai/core/agent_prompts.py +66 -0
  56. package/latticeai/core/context_builder.py +92 -8
  57. package/latticeai/core/embedding_providers.py +548 -1
  58. package/latticeai/core/legacy_compatibility.py +1 -1
  59. package/latticeai/core/marketplace.py +1 -1
  60. package/latticeai/core/messages.py +51 -0
  61. package/latticeai/core/model_compat.py +2 -2
  62. package/latticeai/core/tool_registry.py +0 -7
  63. package/latticeai/core/workspace_os.py +43 -0
  64. package/latticeai/core/workspace_os_constants.py +1 -1
  65. package/latticeai/core/workspace_os_utils.py +4 -50
  66. package/latticeai/core/workspace_reorganization.py +335 -0
  67. package/latticeai/core/workspace_review_items.py +12 -1
  68. package/latticeai/integrations/telegram_bot.py +28 -10
  69. package/latticeai/models/router.py +1 -1
  70. package/latticeai/runtime/access_runtime.py +1 -1
  71. package/latticeai/runtime/build_phases.py +5 -2
  72. package/latticeai/runtime/network_boundary_wiring.py +9 -5
  73. package/latticeai/runtime/permission_mode_wiring.py +9 -6
  74. package/latticeai/runtime/persistence_runtime.py +41 -4
  75. package/latticeai/runtime/router_registration.py +3 -0
  76. package/latticeai/runtime/runtime_context.py +1 -0
  77. package/latticeai/services/architecture_readiness.py +1 -1
  78. package/latticeai/services/brain_intelligence.py +253 -0
  79. package/latticeai/services/change_proposals.py +50 -10
  80. package/latticeai/services/memory_service.py +35 -1
  81. package/latticeai/services/model_catalog.py +4 -3
  82. package/latticeai/services/model_engines.py +28 -14
  83. package/latticeai/services/multimodal_ports.py +87 -0
  84. package/latticeai/services/obsidian_bridge.py +618 -0
  85. package/latticeai/services/product_readiness.py +1 -1
  86. package/latticeai/services/self_model_service.py +171 -0
  87. package/latticeai/services/voice_capture.py +27 -1
  88. package/latticeai/tools/filesystem.py +4 -1
  89. package/package.json +1 -1
  90. package/scripts/bench_vector_index.py +295 -0
  91. package/scripts/check_current_release_docs.mjs +1 -1
  92. package/scripts/release_screen_claims.json +26 -0
  93. package/src-tauri/Cargo.lock +1 -1
  94. package/src-tauri/Cargo.toml +1 -1
  95. package/src-tauri/tauri.conf.json +1 -1
  96. package/static/app/asset-manifest.json +37 -37
  97. package/static/app/assets/{Act-CC8SpNRm.js → Act-D0HWqtn0.js} +1 -1
  98. package/static/app/assets/{AdminConsole-DGIY_Tb_.js → AdminConsole-D-QDW-A4.js} +1 -1
  99. package/static/app/assets/{Brain-BDSC2Mol.js → Brain-CzCsI1mi.js} +1 -1
  100. package/static/app/assets/BrainHome-Btns-_TA.js +2 -0
  101. package/static/app/assets/BrainSignals-2dHQNkns.js +1 -0
  102. package/static/app/assets/{Capture-ClcKLsmI.js → Capture-CT8v1StE.js} +1 -1
  103. package/static/app/assets/{CommandPalette-DpwbyF1S.js → CommandPalette-DoLXC2KH.js} +1 -1
  104. package/static/app/assets/{Library-D8VNtffM.js → Library-DDoxFE5c.js} +1 -1
  105. package/static/app/assets/{LivingBrain-CeWwc0NN.js → LivingBrain-BXMWIK_2.js} +1 -1
  106. package/static/app/assets/{ProductFlow-CS5CCM4t.js → ProductFlow-DOYf7JIs.js} +1 -1
  107. package/static/app/assets/{ReviewCard-Bap4yPfI.js → ReviewCard-COQsqidK.js} +1 -1
  108. package/static/app/assets/{System-odPxU1YL.js → System-BRllvYXd.js} +1 -1
  109. package/static/app/assets/arrow-left-DnyMzss-.js +1 -0
  110. package/static/app/assets/{bot-CmvhqTeF.js → bot-4BvN07ux.js} +1 -1
  111. package/static/app/assets/brain-uMb_5hnO.js +1 -0
  112. package/static/app/assets/{button-DyppYhFH.js → button-CDjtnAoU.js} +1 -1
  113. package/static/app/assets/{circle-pause-Bm5z7EBI.js → circle-pause-D_RMn7tp.js} +1 -1
  114. package/static/app/assets/{circle-play-GjKdH0Fg.js → circle-play-B5OpB8ae.js} +1 -1
  115. package/static/app/assets/{cpu-CuVMv-F5.js → cpu-BIlWInHf.js} +1 -1
  116. package/static/app/assets/{download-C9_946tf.js → download-BtjXfL3z.js} +1 -1
  117. package/static/app/assets/{folder-open-B3QKdxib.js → folder-open-DefMpxI2.js} +1 -1
  118. package/static/app/assets/{hard-drive-BwW9Hx4r.js → hard-drive-BQ8NZVkw.js} +1 -1
  119. package/static/app/assets/{index-BrUQ-bF8.js → index-0AvoEBzJ.js} +3 -3
  120. package/static/app/assets/index-vtEfYvQY.css +2 -0
  121. package/static/app/assets/{input-Db2Hw2VH.js → input-B_5ZJ9oy.js} +1 -1
  122. package/static/app/assets/{permissionCopy-BpbIHRio.js → permissionCopy-BqZ5tsgL.js} +1 -1
  123. package/static/app/assets/{primitives-4B_Ynf7r.js → primitives-CVwew78r.js} +1 -1
  124. package/static/app/assets/search-DkhnOKZt.js +1 -0
  125. package/static/app/assets/{share-2-BgvRTDPJ.js → share-2-D5zg_0fY.js} +1 -1
  126. package/static/app/assets/{shield-alert-DWFLyykG.js → shield-alert-B5pZzkUb.js} +1 -1
  127. package/static/app/assets/{textarea-Bk4vEqzT.js → textarea-nEVIweKY.js} +1 -1
  128. package/static/app/assets/{useFocusTrap-CGRXEg_p.js → useFocusTrap-Cm99AHlz.js} +1 -1
  129. package/static/app/assets/{useQuery-CdPzp3_U.js → useQuery-Dm__N6bL.js} +1 -1
  130. package/static/app/assets/{utils-orU8igel.js → utils-DcDMoZIe.js} +1 -1
  131. package/static/app/assets/{workspace-Brj2Ylb2.js → workspace-LtRRSKTf.js} +1 -1
  132. package/static/app/index.html +4 -4
  133. package/static/sw.js +1 -1
  134. package/static/app/assets/BrainHome-BYgYp3ON.js +0 -2
  135. package/static/app/assets/BrainSignals-DYwVFmcO.js +0 -1
  136. package/static/app/assets/arrow-left-J47Yde1U.js +0 -1
  137. package/static/app/assets/brain-DLT9uuGq.js +0 -1
  138. package/static/app/assets/index-CYb18LtP.css +0 -2
  139. package/static/app/assets/search-YH-7-x5Y.js +0 -1
@@ -52,6 +52,12 @@ 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
61
  _V2_VIEWS_SQL = """
56
62
  CREATE VIEW IF NOT EXISTS kgv2_nodes AS
57
63
  SELECT id,
@@ -59,14 +65,16 @@ class KnowledgeGraphProjectionMixin(_Core):
59
65
  label AS title,
60
66
  summary,
61
67
  attrs AS metadata_json,
62
- created_at, updated_at
68
+ created_at, updated_at,
69
+ valid_from, valid_to, superseded_by
63
70
  FROM nodes_v2;
64
71
  CREATE VIEW IF NOT EXISTS kgv2_edges AS
65
72
  SELECT id, source AS from_node, target AS to_node,
66
73
  COALESCE(legacy_type, type) AS type,
67
74
  weight,
68
75
  metadata AS metadata_json,
69
- created_at
76
+ created_at,
77
+ valid_from, valid_to, superseded_by
70
78
  FROM edges_v2;
71
79
  """
72
80
 
@@ -37,9 +37,34 @@ class KnowledgeGraphProvenanceMixin(_Core):
37
37
  permissions: Optional[Dict[str, Any]] = None,
38
38
  metadata: Optional[Dict[str, Any]] = None,
39
39
  ) -> Dict[str, Any]:
40
- """Append a provenance record for an ingested node (audit trail)."""
40
+ """Record where an ingested node came from (upsert by origin).
41
+
42
+ Row identity is ``(node, content, source_type, source_uri, pipeline)``
43
+ — deliberately *not* the wall clock. Through 11.0.x the basis included
44
+ a second-resolution timestamp, which made the record's identity depend
45
+ on when it happened: re-ingesting unchanged content twice inside the
46
+ same second collapsed onto one row, and one second later appended a
47
+ duplicate. That is not an audit trail, it is a race — the same class of
48
+ defect as the 11.0.0 review-item ids — and it grew this table (and the
49
+ "recent ingestions" list built from it) without bound on every re-scan
50
+ of an unchanged folder or vault.
51
+
52
+ With the clock out of the basis, re-ingesting the same content from the
53
+ same origin *updates* one record (``created_at`` moves to the latest
54
+ sighting, so "recently seen" stays true), while genuinely new content or
55
+ a genuinely different origin — another source URI, another pipeline —
56
+ still appends its own record. The timestamp is data on the row, never
57
+ part of its identity. Every individual ingest event remains visible in
58
+ the audit log (``kg_ingest``), which is where per-event history belongs.
59
+ """
41
60
  now = _now()
42
- prov_basis = f"{node_id}|{content_hash or ''}|{now}"
61
+ prov_basis = "|".join([
62
+ node_id,
63
+ content_hash or "",
64
+ source_type,
65
+ source_uri or "",
66
+ pipeline,
67
+ ])
43
68
  prov_id = f"prov:{_sha256_text(prov_basis)[:24]}"
44
69
  with self._connect() as conn:
45
70
  conn.execute(
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import TYPE_CHECKING
3
+ from typing import TYPE_CHECKING, Sequence
4
4
 
5
5
  # ruff: noqa: F403,F405
6
6
  from ._kg_common import * # noqa: F403,F401
@@ -20,14 +20,47 @@ else:
20
20
  # traverse / stats) moved byte-identically to .retrieval_reads as
21
21
  # KnowledgeGraphReadsMixin. Re-exported here so any legacy
22
22
  # ``from lattice_brain.graph.retrieval import ...`` site keeps resolving.
23
+ from .fusion import (
24
+ DEFAULT_EXPANSION_CAP,
25
+ DEFAULT_EXPANSION_SEEDS,
26
+ expand_with_neighbors,
27
+ graph_expansion_enabled,
28
+ rrf_fuse,
29
+ )
23
30
  from .retrieval_reads import KnowledgeGraphReadsMixin # noqa: F401
24
31
 
32
+ #: Node types that are a *thing you can look at or listen to*, not prose. A
33
+ #: match of one of these means the answer rests on more than text.
34
+ MULTIMODAL_NODE_TYPES = ("Image", "ImageText")
35
+
36
+
37
+ def multimodal_signal(matches: Iterable[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
38
+ """``{"images": n, "types": [...]}`` when a result set includes pictures.
39
+
40
+ ``None`` when it does not: the context-quality contract stays four keys
41
+ wide for the ordinary all-text case, and a caller that sees the key knows
42
+ it means something rather than having to compare a zero.
43
+ """
44
+ images = 0
45
+ seen: List[str] = []
46
+ for match in matches:
47
+ node_type = str(match.get("type") or "")
48
+ if node_type in MULTIMODAL_NODE_TYPES:
49
+ images += 1
50
+ if node_type not in seen:
51
+ seen.append(node_type)
52
+ if not images:
53
+ return None
54
+ return {"images": images, "types": seen}
55
+
25
56
 
26
57
  def context_quality_signal(
27
58
  mode: str,
28
59
  nodes: int,
29
60
  *,
30
61
  reason: Optional[str] = None,
62
+ vector: Optional[Dict[str, Any]] = None,
63
+ multimodal: Optional[Dict[str, Any]] = None,
31
64
  ) -> Dict[str, Any]:
32
65
  """Honest RAG context-quality signal (v9.8.0, additive contract).
33
66
 
@@ -37,6 +70,20 @@ def context_quality_signal(
37
70
  ``"none"``; ``limited`` is true whenever the context is thin (0–1 nodes)
38
71
  or the vector side fell back to lexical-only retrieval. ``reason`` is a
39
72
  short human-readable Korean phrase, only present when limited.
73
+
74
+ ``vector`` (v11.1.0) carries the vector channel's own honesty block —
75
+ which backend scored, whether it was approximate, whether the candidate
76
+ scan was truncated. "hybrid, 6 nodes" describes two different answers
77
+ depending on those bits, and the caller that has to say "I did not find
78
+ it" deserves to know which one it got. The key is present **only when
79
+ there is a caveat to report**: an exact, complete vector scan is the
80
+ contract's baseline assumption, so annotating it would be noise, and the
81
+ four-key shape stays exactly what existing consumers pin.
82
+
83
+ ``multimodal`` (v11.1.0) follows the same present-only-when-true rule and
84
+ says that part of this context is a picture. "6 nodes" reads differently
85
+ when two of them are screenshots whose text came out of OCR, and the
86
+ surface that has to explain the answer deserves to know.
40
87
  """
41
88
  nodes = max(0, int(nodes or 0))
42
89
  mode = str(mode or "none")
@@ -54,7 +101,17 @@ def context_quality_signal(
54
101
  reason = "그래프 기반 컨텍스트가 제한적입니다"
55
102
  if not limited:
56
103
  reason = None
57
- return {"mode": mode, "nodes": nodes, "limited": limited, "reason": reason}
104
+ signal: Dict[str, Any] = {
105
+ "mode": mode,
106
+ "nodes": nodes,
107
+ "limited": limited,
108
+ "reason": reason,
109
+ }
110
+ if vector is not None:
111
+ signal["vector"] = dict(vector)
112
+ if multimodal is not None:
113
+ signal["multimodal"] = dict(multimodal)
114
+ return signal
58
115
 
59
116
 
60
117
  class KnowledgeGraphRetrievalMixin(_Core):
@@ -70,6 +127,7 @@ class KnowledgeGraphRetrievalMixin(_Core):
70
127
  "SlideDeck", # 프레젠테이션
71
128
  "Image", # 이미지
72
129
  "ImageText", # OCR 텍스트
130
+ "Audio", # 녹음 / 음성 메모 (11.1.0)
73
131
  "Concept", # 개념 / 아이디어 / 기술 용어
74
132
  "Person", # 사람
75
133
  "Error", # 오류 / 버그
@@ -162,7 +220,7 @@ class KnowledgeGraphRetrievalMixin(_Core):
162
220
  from_node = node_by_id.get(edge["from"])
163
221
  to_node = node_by_id.get(edge["to"])
164
222
  if not from_node or not to_node:
165
- continue
223
+ continue # pragma: no cover — unreachable: the edge query selects endpoints from the same node window
166
224
  for topic_node, other_node in ((from_node, to_node), (to_node, from_node)):
167
225
  if topic_node["type"] != "Topic":
168
226
  continue
@@ -319,6 +377,7 @@ class KnowledgeGraphRetrievalMixin(_Core):
319
377
  "SlideDeck",
320
378
  "Image",
321
379
  "ImageText",
380
+ "Audio",
322
381
  "Page",
323
382
  "Slide",
324
383
  }
@@ -362,6 +421,8 @@ class KnowledgeGraphRetrievalMixin(_Core):
362
421
  lexical_limit: Optional[int] = None,
363
422
  vector_limit: Optional[int] = None,
364
423
  min_vector_score: float = 0.0,
424
+ image_vector: Optional[Sequence[float]] = None,
425
+ image_fusion_weight: Optional[float] = None,
365
426
  ) -> Dict[str, Any]:
366
427
  """Unified lexical + vector retrieval with alpha-weighted linear fusion.
367
428
 
@@ -393,6 +454,15 @@ class KnowledgeGraphRetrievalMixin(_Core):
393
454
  fused score into the ``[0.5, 1.0]`` band (``scores.age_decay``).
394
455
  Passing an explicit ``alpha`` pins it exactly as before and disables
395
456
  rewrite + decay.
457
+
458
+ ``image_vector`` (v11.1.0) is the *late fusion* seam for the separate
459
+ image space: the caller supplies a query vector from the same vision
460
+ model that embedded the pictures, its own index is ranked
461
+ independently, and only then are the two rankings blended
462
+ (``image_fusion_weight``, default 0.5). A text query never produces
463
+ one — it reaches images through their OCR text and captions — which is
464
+ exactly why the image channel has to enter at the end rather than
465
+ pretending to share the text index.
396
466
  """
397
467
  query = str(query or "").strip()
398
468
  try:
@@ -404,6 +474,10 @@ class KnowledgeGraphRetrievalMixin(_Core):
404
474
  search_query = query
405
475
  rewrite_rules: List[str] = []
406
476
  recency_half_life_days: Optional[float] = None
477
+ # "alpha" is the historical linear fusion; the policy may select RRF
478
+ # per query class. An explicitly pinned ``alpha`` argument means the
479
+ # caller is asking for linear fusion by name, so it stays linear.
480
+ fusion_strategy = "alpha"
407
481
  if alpha is None:
408
482
  try:
409
483
  from .retrieval_policy import resolve_policy
@@ -411,6 +485,7 @@ class KnowledgeGraphRetrievalMixin(_Core):
411
485
  policy = resolve_policy(query)
412
486
  query_class = policy["query_class"]
413
487
  alpha = float(policy["alpha"])
488
+ fusion_strategy = str(policy.get("fusion_strategy") or "alpha")
414
489
  rewrite_rules = list(policy.get("rewrite_rules") or [])
415
490
  rewritten = str(policy.get("search_query") or "")
416
491
  if rewritten and rewritten != query:
@@ -438,6 +513,7 @@ class KnowledgeGraphRetrievalMixin(_Core):
438
513
  "sources": {"lexical": 0, "vector": 0},
439
514
  "matches": [],
440
515
  "policy": {"search_query": search_query, "rewrite_rules": rewrite_rules},
516
+ "fusion_strategy": fusion_strategy,
441
517
  "detail": None,
442
518
  }
443
519
 
@@ -455,6 +531,16 @@ class KnowledgeGraphRetrievalMixin(_Core):
455
531
  detail: Optional[str] = None
456
532
  vector_matches: List[Dict[str, Any]] = []
457
533
  vector_recall: Optional[Dict[str, Any]] = None
534
+ # The vector channel's own honesty block, echoed additively so a
535
+ # caller can tell an exact "not found" from an approximate one.
536
+ vector_meta: Dict[str, Any] = {
537
+ "backend": None,
538
+ "approx": None,
539
+ "exhaustive": None,
540
+ "truncated": None,
541
+ "embedded_rows": None,
542
+ "degraded": None,
543
+ }
458
544
  vector_fn = getattr(self, "vector_search", None)
459
545
  if not callable(vector_fn):
460
546
  mode = "lexical_only"
@@ -471,8 +557,16 @@ class KnowledgeGraphRetrievalMixin(_Core):
471
557
  # retrieval_vector.vector_search), and a fused answer built on
472
558
  # a truncated scan is not the same claim as a complete one.
473
559
  recall = vector_payload.get("recall")
474
- if isinstance(recall, dict) and recall.get("truncated"):
475
- vector_recall = dict(recall)
560
+ if isinstance(recall, dict):
561
+ vector_meta["backend"] = recall.get("backend")
562
+ vector_meta["truncated"] = bool(recall.get("truncated"))
563
+ vector_meta["embedded_rows"] = recall.get("candidates_total")
564
+ if recall.get("truncated"):
565
+ vector_recall = dict(recall)
566
+ index_block = vector_payload.get("index")
567
+ if isinstance(index_block, dict):
568
+ vector_meta["approx"] = bool(index_block.get("approx"))
569
+ vector_meta["exhaustive"] = bool(index_block.get("exhaustive"))
476
570
  except Exception as exc: # noqa: BLE001 — degrade, never fail the search
477
571
  mode = "lexical_only"
478
572
  detail = f"vector index unavailable: {exc}"
@@ -525,6 +619,11 @@ class KnowledgeGraphRetrievalMixin(_Core):
525
619
  entries[node_id] = entry
526
620
  return entry
527
621
 
622
+ # Per-channel id order (best first) — the only input RRF needs, and
623
+ # the one thing a normalized score cannot reconstruct.
624
+ lexical_order: List[str] = []
625
+ vector_order: List[str] = []
626
+
528
627
  for rank, match in enumerate(lexical_matches, start=1):
529
628
  node_id = _parent_node_id(match)
530
629
  if not node_id:
@@ -534,6 +633,7 @@ class KnowledgeGraphRetrievalMixin(_Core):
534
633
  entry["scores"]["lexical"], round(1.0 / rank, 6)
535
634
  )
536
635
  entry["_lexical"] = True
636
+ lexical_order.append(node_id)
537
637
 
538
638
  # Max-normalize cosine scores into [0, 1] (guard the score-0 falsy trap
539
639
  # by comparing explicitly, never with truthiness).
@@ -551,27 +651,86 @@ class KnowledgeGraphRetrievalMixin(_Core):
551
651
  entry = _entry_for(node_id, match)
552
652
  entry["scores"]["vector"] = max(entry["scores"]["vector"], round(vec_norm, 6))
553
653
  entry["_vector"] = True
654
+ vector_order.append(node_id)
554
655
  # Prefer a real snippet when the lexical row had no summary.
555
656
  if not entry.get("summary") and match.get("summary"):
556
657
  entry["summary"] = match.get("summary")
557
658
 
659
+ # Graph traversal candidate expansion (opt-in, capped, counted): pull
660
+ # the one-hop neighbours of the strongest hits into the candidate pool
661
+ # so an answer that is adjacent to the match — not in it — is
662
+ # reachable at all. Off by default; see fusion.GRAPH_EXPANSION_ENV.
663
+ expansion_report: Dict[str, Any] = {
664
+ "enabled": False,
665
+ "seeds": 0,
666
+ "added": 0,
667
+ "cap": DEFAULT_EXPANSION_CAP,
668
+ "truncated": False,
669
+ "failed_seeds": 0,
670
+ }
671
+ if entries and graph_expansion_enabled():
672
+ seeds = sorted(
673
+ (
674
+ (node_id, float(entry["scores"]["vector"]))
675
+ for node_id, entry in entries.items()
676
+ ),
677
+ key=lambda pair: -pair[1],
678
+ )[:DEFAULT_EXPANSION_SEEDS]
679
+ expanded, expansion_report = expand_with_neighbors(
680
+ seeds,
681
+ self.neighbors,
682
+ exclude=list(entries),
683
+ cap=DEFAULT_EXPANSION_CAP,
684
+ )
685
+ for candidate in expanded:
686
+ node = candidate["node"]
687
+ entry = _entry_for(str(node.get("id")), dict(node))
688
+ entry["scores"]["graph"] = candidate["score"]
689
+ entry["metadata"] = {
690
+ **(entry.get("metadata") or {}),
691
+ "expanded_from": candidate["seed"],
692
+ }
693
+ entry["_graph"] = True
694
+
695
+ rrf_normalized: Dict[str, float] = {}
696
+ if fusion_strategy == "rrf":
697
+ raw_rrf = rrf_fuse(
698
+ {
699
+ "lexical": list(dict.fromkeys(lexical_order)),
700
+ "vector": list(dict.fromkeys(vector_order)),
701
+ }
702
+ )
703
+ peak = max(raw_rrf.values(), default=0.0)
704
+ if peak > 0:
705
+ # Rescale to [0, 1] so the score column keeps the same meaning
706
+ # across strategies; RRF's raw values live around 1/60.
707
+ rrf_normalized = {key: value / peak for key, value in raw_rrf.items()}
708
+
558
709
  matches: List[Dict[str, Any]] = []
559
710
  for entry in entries.values():
560
711
  lex_score = float(entry["scores"]["lexical"])
561
712
  vec_score = float(entry["scores"]["vector"])
562
713
  if mode == "lexical_only":
563
714
  fused = lex_score
715
+ elif fusion_strategy == "rrf":
716
+ fused = float(rrf_normalized.get(entry["node_id"], 0.0))
717
+ entry["scores"]["rrf"] = round(fused, 6)
564
718
  else:
565
719
  fused = alpha * vec_score + (1.0 - alpha) * lex_score
566
- entry["score"] = round(fused, 6)
567
720
  from_lexical = bool(entry.pop("_lexical", False))
568
721
  from_vector = bool(entry.pop("_vector", False))
569
- if from_lexical and from_vector:
722
+ if entry.pop("_graph", False):
723
+ # A one-hop neighbour of a hit: related to the answer, never
724
+ # itself a match, so it carries only its damped seed score.
725
+ fused = float(entry["scores"]["graph"])
726
+ entry["fusion"] = "graph"
727
+ elif from_lexical and from_vector:
570
728
  entry["fusion"] = "both"
571
729
  elif from_vector:
572
730
  entry["fusion"] = "vector"
573
731
  else:
574
732
  entry["fusion"] = "lexical"
733
+ entry["score"] = round(fused, 6)
575
734
  matches.append(entry)
576
735
 
577
736
  # Recency-class age decay (retrieval_policy): dampen each fused score
@@ -591,6 +750,16 @@ class KnowledgeGraphRetrievalMixin(_Core):
591
750
  match["scores"]["age_decay"] = round(multiplier, 6)
592
751
  match["score"] = round(float(match["score"]) * multiplier, 6)
593
752
 
753
+ # Late fusion of the image space (v11.1.0). Runs after the text
754
+ # channels have produced a ranking and before the cut, so image
755
+ # evidence can lift a picture into the answer without ever having been
756
+ # compared against a text vector.
757
+ image_fusion: Optional[Dict[str, Any]] = None
758
+ if image_vector is not None:
759
+ image_fusion = self._fuse_image_channel(
760
+ matches, image_vector, top_k=top_k, weight=image_fusion_weight
761
+ )
762
+
594
763
  matches.sort(key=lambda item: (-item["score"], item["node_id"]))
595
764
  # Optional cross-encoder rerank (v9.9.5). Off by default; when the
596
765
  # env kill-switch is set and the model loads, pair scores reorder the
@@ -622,6 +791,8 @@ class KnowledgeGraphRetrievalMixin(_Core):
622
791
  "sources": {"lexical": len(lexical_matches), "vector": len(vector_matches)},
623
792
  "matches": matches,
624
793
  "policy": {"search_query": search_query, "rewrite_rules": rewrite_rules},
794
+ "fusion_strategy": fusion_strategy,
795
+ "graph_expansion": expansion_report,
625
796
  "rerank": rerank_meta,
626
797
  "detail": detail,
627
798
  }
@@ -631,8 +802,59 @@ class KnowledgeGraphRetrievalMixin(_Core):
631
802
  result["vector_recall"] = vector_recall
632
803
  if vector_degraded is None:
633
804
  result["vector_degraded"] = "partial_recall"
805
+ vector_meta["degraded"] = result.get("vector_degraded")
806
+ result["vector"] = vector_meta
807
+ multimodal = multimodal_signal(matches)
808
+ if multimodal is not None or image_fusion is not None:
809
+ result["multimodal"] = {
810
+ **(multimodal or {"images": 0, "types": []}),
811
+ **({"image_fusion": image_fusion} if image_fusion is not None else {}),
812
+ }
634
813
  return result
635
814
 
815
+ def _fuse_image_channel(
816
+ self,
817
+ matches: List[Dict[str, Any]],
818
+ image_vector: Sequence[float],
819
+ *,
820
+ top_k: int,
821
+ weight: Optional[float],
822
+ ) -> Dict[str, Any]:
823
+ """Rank the image index separately, then blend it into ``matches``.
824
+
825
+ Any failure degrades to "the image channel contributed nothing" with
826
+ the reason attached — an image index that cannot be read is not a
827
+ reason to lose the text answer.
828
+ """
829
+ from .image_vectors import (
830
+ DEFAULT_IMAGE_FUSION_WEIGHT,
831
+ fuse_image_scores,
832
+ image_similarity_search,
833
+ )
834
+
835
+ share = DEFAULT_IMAGE_FUSION_WEIGHT if weight is None else float(weight)
836
+ report: Dict[str, Any] = {
837
+ "weight": round(max(0.0, min(1.0, share)), 4),
838
+ "candidates": 0,
839
+ "fused": 0,
840
+ "detail": None,
841
+ }
842
+ try:
843
+ found = image_similarity_search(
844
+ self, image_vector, top_k=max(1, int(top_k) * 2)
845
+ )
846
+ except Exception as exc: # noqa: BLE001 — never fail the text answer
847
+ report["detail"] = f"image index unavailable: {exc}"
848
+ return report
849
+ report["candidates"] = int(found.get("candidates") or 0)
850
+ report["detail"] = found.get("detail")
851
+ scores = {
852
+ str(row.get("node_id")): float(row.get("score") or 0.0)
853
+ for row in found.get("matches") or []
854
+ }
855
+ report["fused"] = fuse_image_scores(matches, scores, weight=share)
856
+ return report
857
+
636
858
  def context_for_query(
637
859
  self,
638
860
  query: str,
@@ -669,6 +891,8 @@ class KnowledgeGraphRetrievalMixin(_Core):
669
891
  return ""
670
892
  matches: List[Dict[str, Any]] = []
671
893
  retrieval_mode = "none"
894
+ vector_meta: Optional[Dict[str, Any]] = None
895
+ multimodal_meta: Optional[Dict[str, Any]] = None
672
896
  if use_hybrid:
673
897
  try:
674
898
  hybrid = self.hybrid_search(
@@ -678,6 +902,15 @@ class KnowledgeGraphRetrievalMixin(_Core):
678
902
  include_legacy_global=include_legacy_global,
679
903
  )
680
904
  matches = hybrid.get("matches", [])
905
+ vector_block = hybrid.get("vector") or {}
906
+ # Only a caveat is worth carrying: approximate scoring, a
907
+ # truncated candidate scan, or an already-flagged degradation.
908
+ if (
909
+ vector_block.get("approx")
910
+ or vector_block.get("truncated")
911
+ or vector_block.get("degraded")
912
+ ):
913
+ vector_meta = dict(vector_block)
681
914
  if matches:
682
915
  retrieval_mode = str(hybrid.get("mode") or "hybrid")
683
916
  except Exception: # noqa: BLE001 — context building must never fail
@@ -752,9 +985,17 @@ class KnowledgeGraphRetrievalMixin(_Core):
752
985
  context = "\n".join(lines)
753
986
  if not with_meta:
754
987
  return context
988
+ # Only the context that actually reached the model counts as
989
+ # multimodal — matches trimmed by ``limit`` are not in the answer.
990
+ multimodal_meta = multimodal_signal(matches[:limit])
755
991
  return {
756
992
  "context": context,
757
- "quality": context_quality_signal(retrieval_mode, len(matches[:limit])),
993
+ "quality": context_quality_signal(
994
+ retrieval_mode,
995
+ len(matches[:limit]),
996
+ vector=vector_meta,
997
+ multimodal=multimodal_meta,
998
+ ),
758
999
  }
759
1000
 
760
1001
  def context_for_query_with_meta(
@@ -46,26 +46,26 @@ class KnowledgeGraphDocGenMixin(_Core):
46
46
  candidate_rows = []
47
47
  seen_ids = set()
48
48
 
49
- if query:
50
- q = f"%{query}%"
51
- rows = conn.execute(
52
- f"""
53
- SELECT id, type, title, summary, metadata_json, updated_at
54
- FROM {nt}
55
- WHERE (title LIKE ? OR summary LIKE ? OR metadata_json LIKE ?)
56
- AND type IN ('Document', 'File', 'CodeFile', 'SlideDeck',
57
- 'Spreadsheet', 'Image', 'ImageText', 'Chat',
58
- 'Decision', 'Task', 'Concept', 'Feature',
59
- 'Page', 'Slide')
60
- ORDER BY updated_at DESC, id ASC
61
- LIMIT ?
62
- """,
63
- (q, q, q, limit * 5),
64
- ).fetchall()
65
- for row in rows:
66
- if row["id"] not in seen_ids:
67
- seen_ids.add(row["id"])
68
- candidate_rows.append(row)
49
+ # `query` is non-empty here — the early return above took the blank case.
50
+ q = f"%{query}%"
51
+ rows = conn.execute(
52
+ f"""
53
+ SELECT id, type, title, summary, metadata_json, updated_at
54
+ FROM {nt}
55
+ WHERE (title LIKE ? OR summary LIKE ? OR metadata_json LIKE ?)
56
+ AND type IN ('Document', 'File', 'CodeFile', 'SlideDeck',
57
+ 'Spreadsheet', 'Image', 'ImageText', 'Audio',
58
+ 'Chat', 'Decision', 'Task', 'Concept',
59
+ 'Feature', 'Page', 'Slide')
60
+ ORDER BY updated_at DESC, id ASC
61
+ LIMIT ?
62
+ """,
63
+ (q, q, q, limit * 5),
64
+ ).fetchall()
65
+ for row in rows:
66
+ if row["id"] not in seen_ids:
67
+ seen_ids.add(row["id"])
68
+ candidate_rows.append(row)
69
69
 
70
70
  for term in terms:
71
71
  t = f"%{term}%"
@@ -75,9 +75,9 @@ class KnowledgeGraphDocGenMixin(_Core):
75
75
  FROM {nt}
76
76
  WHERE (title LIKE ? OR summary LIKE ? OR metadata_json LIKE ?)
77
77
  AND type IN ('Document', 'File', 'CodeFile', 'SlideDeck',
78
- 'Spreadsheet', 'Image', 'ImageText', 'Chat',
79
- 'Decision', 'Task', 'Concept', 'Feature',
80
- 'Page', 'Slide')
78
+ 'Spreadsheet', 'Image', 'ImageText', 'Audio',
79
+ 'Chat', 'Decision', 'Task', 'Concept',
80
+ 'Feature', 'Page', 'Slide')
81
81
  ORDER BY updated_at DESC, id ASC
82
82
  LIMIT ?
83
83
  """,
@@ -139,12 +139,17 @@ def resolve_policy(
139
139
  "query_class": "fact" | "code" | "person" | "recency",
140
140
  "weights": {"keyword", "vector", "graph"}, # service fusion
141
141
  "alpha": float, # graph-layer fusion
142
+ "fusion_strategy": "alpha" | "rrf", # how they combine
142
143
  "original_query": str,
143
144
  "search_query": str, # the rewritten form to search with
144
145
  "rewrite_rules": [str],
145
146
  "recency_half_life_days": float | None, # 14.0 only for recency
146
147
  }
147
148
 
149
+ ``fusion_strategy`` is ``"alpha"`` for every class unless
150
+ ``LATTICEAI_FUSION_STRATEGY`` says otherwise, so the default policy is
151
+ byte-identical to the pre-11.1.0 one.
152
+
148
153
  ``recency_half_life_days`` is non-``None`` only for the ``recency``
149
154
  class — the honest contract that age decay applies exactly where the
150
155
  fusion layers wire it, and nowhere else.
@@ -157,6 +162,7 @@ def resolve_policy(
157
162
  "query_class": query_class,
158
163
  "weights": dict(profile["weights"]),
159
164
  "alpha": float(profile["alpha"]),
165
+ "fusion_strategy": str(profile["strategy"]),
160
166
  "original_query": rewrite["original"],
161
167
  "search_query": search_query,
162
168
  "rewrite_rules": list(rewrite["rules"]),