ltcai 10.9.0 → 11.0.1

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 (126) hide show
  1. package/README.md +46 -64
  2. package/docs/CHANGELOG.md +72 -237
  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 +78 -7
  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/graph/_kg_contract.py +11 -0
  14. package/lattice_brain/graph/curator.py +1 -1
  15. package/lattice_brain/graph/fusion.py +184 -3
  16. package/lattice_brain/graph/proactive.py +138 -1
  17. package/lattice_brain/graph/projection.py +10 -2
  18. package/lattice_brain/graph/retrieval.py +137 -7
  19. package/lattice_brain/graph/retrieval_docgen.py +20 -20
  20. package/lattice_brain/graph/retrieval_policy.py +6 -0
  21. package/lattice_brain/graph/retrieval_reads.py +188 -1
  22. package/lattice_brain/graph/retrieval_vector.py +474 -110
  23. package/lattice_brain/graph/schema.py +125 -2
  24. package/lattice_brain/graph/vector_index/__init__.py +85 -0
  25. package/lattice_brain/graph/vector_index/base.py +170 -0
  26. package/lattice_brain/graph/vector_index/brute_force.py +114 -0
  27. package/lattice_brain/graph/vector_index/hnsw.py +293 -0
  28. package/lattice_brain/graph/vector_index/jobs.py +287 -0
  29. package/lattice_brain/graph/vector_index/quantized.py +151 -0
  30. package/lattice_brain/graph/vector_index/selector.py +131 -0
  31. package/lattice_brain/ingestion.py +50 -3
  32. package/lattice_brain/portability.py +654 -2
  33. package/lattice_brain/runtime/agent_runtime.py +1 -1
  34. package/lattice_brain/runtime/contracts.py +1 -1
  35. package/lattice_brain/runtime/multi_agent.py +1 -1
  36. package/lattice_brain/self_model.py +620 -0
  37. package/lattice_brain/synthesis.py +801 -0
  38. package/latticeai/__init__.py +1 -1
  39. package/latticeai/api/brain_intelligence.py +83 -1
  40. package/latticeai/api/chat_stream.py +4 -1
  41. package/latticeai/api/local_files.py +62 -0
  42. package/latticeai/api/models.py +1 -1
  43. package/latticeai/api/portability.py +130 -1
  44. package/latticeai/api/security_dashboard.py +48 -13
  45. package/latticeai/api/voice_capture.py +4 -1
  46. package/latticeai/api/workspace.py +11 -5
  47. package/latticeai/core/embedding_providers.py +20 -1
  48. package/latticeai/core/legacy_compatibility.py +1 -1
  49. package/latticeai/core/marketplace.py +1 -1
  50. package/latticeai/core/messages.py +14 -0
  51. package/latticeai/core/model_compat.py +2 -2
  52. package/latticeai/core/tool_registry.py +0 -7
  53. package/latticeai/core/workspace_os_constants.py +1 -1
  54. package/latticeai/core/workspace_os_utils.py +4 -50
  55. package/latticeai/core/workspace_review_items.py +12 -1
  56. package/latticeai/integrations/telegram_bot.py +28 -10
  57. package/latticeai/models/router.py +1 -1
  58. package/latticeai/runtime/access_runtime.py +1 -1
  59. package/latticeai/runtime/network_boundary_wiring.py +9 -5
  60. package/latticeai/runtime/permission_mode_wiring.py +9 -6
  61. package/latticeai/runtime/router_registration.py +3 -0
  62. package/latticeai/services/architecture_readiness.py +1 -1
  63. package/latticeai/services/brain_intelligence.py +253 -0
  64. package/latticeai/services/memory_service.py +1 -1
  65. package/latticeai/services/model_catalog.py +4 -3
  66. package/latticeai/services/model_engines.py +28 -14
  67. package/latticeai/services/obsidian_bridge.py +618 -0
  68. package/latticeai/services/product_readiness.py +5 -3
  69. package/latticeai/tools/filesystem.py +4 -1
  70. package/package.json +1 -1
  71. package/scripts/bench_vector_index.py +295 -0
  72. package/scripts/check_current_release_docs.mjs +4 -2
  73. package/scripts/release_screen_claims.json +31 -0
  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-CS9IeqUX.js → Act-D4zSxFR-.js} +1 -1
  79. package/static/app/assets/{AdminConsole-3UkIEWGA.js → AdminConsole-w5jBfPt2.js} +1 -1
  80. package/static/app/assets/{Brain-B22EmNqS.js → Brain-C2EqQg74.js} +2 -2
  81. package/static/app/assets/BrainHome-CvXS6XiQ.js +2 -0
  82. package/static/app/assets/BrainSignals-DOE_KhOU.js +1 -0
  83. package/static/app/assets/Capture-DPqpGK8d.js +1 -0
  84. package/static/app/assets/{CommandPalette-86m4FCcN.js → CommandPalette-CNf7h5fp.js} +1 -1
  85. package/static/app/assets/Library-BN0HYOfc.js +1 -0
  86. package/static/app/assets/LivingBrain-Dfq_wEDI.js +1 -0
  87. package/static/app/assets/ProductFlow-B-3O0rNV.js +1 -0
  88. package/static/app/assets/{ReviewCard-BepjSDpN.js → ReviewCard-gZ-tdqFM.js} +1 -1
  89. package/static/app/assets/System-BElUcSSw.js +1 -0
  90. package/static/app/assets/arrow-left-CFNIMjhv.js +1 -0
  91. package/static/app/assets/{bot-DQj0-LkM.js → bot--qYHMtkP.js} +1 -1
  92. package/static/app/assets/brain-DDCLjRqO.js +1 -0
  93. package/static/app/assets/{button-CmTknyAP.js → button-51Z3rsuv.js} +1 -1
  94. package/static/app/assets/{circle-pause-yTCWRziJ.js → circle-pause-CMIiMaQl.js} +1 -1
  95. package/static/app/assets/{circle-play-Ccrva84R.js → circle-play-DZoO_cfG.js} +1 -1
  96. package/static/app/assets/{cpu-CbJqWTlS.js → cpu-Bs6uc9W9.js} +1 -1
  97. package/static/app/assets/{download-CkSzbzU-.js → download-G-2olkWz.js} +1 -1
  98. package/static/app/assets/{folder-open-CKyjQ4PU.js → folder-open-CTOspnmb.js} +1 -1
  99. package/static/app/assets/{hard-drive-DAzk9um0.js → hard-drive-CewHWJhn.js} +1 -1
  100. package/static/app/assets/index-CkzokZAj.css +2 -0
  101. package/static/app/assets/{index-CxOcwsHV.js → index-D7Rr-J2Y.js} +3 -3
  102. package/static/app/assets/{input-DcMETmZ7.js → input-D4w_BZWl.js} +1 -1
  103. package/static/app/assets/{permissionCopy-BVf13_25.js → permissionCopy-CosBEXAZ.js} +1 -1
  104. package/static/app/assets/primitives-d0g9pvzS.js +1 -0
  105. package/static/app/assets/search-BLCYt75v.js +1 -0
  106. package/static/app/assets/{share-2-COWCHNZm.js → share-2-NmD7e_oV.js} +1 -1
  107. package/static/app/assets/{shield-alert-BDrvilyK.js → shield-alert-CcQeMuju.js} +1 -1
  108. package/static/app/assets/{textarea-rUmsc8cP.js → textarea-BPAJDc-0.js} +1 -1
  109. package/static/app/assets/{useFocusTrap-Bi5UY_8v.js → useFocusTrap-C7YLdTBC.js} +1 -1
  110. package/static/app/assets/{useQuery-C-AicB-3.js → useQuery-DRyD9opW.js} +1 -1
  111. package/static/app/assets/{utils-BMwWg78e.js → utils-DG1_ExrP.js} +3 -3
  112. package/static/app/assets/{workspace-Y93tls8P.js → workspace-CWVf3gsI.js} +1 -1
  113. package/static/app/index.html +4 -4
  114. package/static/sw.js +1 -1
  115. package/static/app/assets/BrainHome-CMDqgJF4.js +0 -2
  116. package/static/app/assets/BrainSignals-BeE8RJo3.js +0 -1
  117. package/static/app/assets/Capture-ZX9bQh68.js +0 -1
  118. package/static/app/assets/Library-Bhz5LUca.js +0 -1
  119. package/static/app/assets/LivingBrain-BSa0wpFG.js +0 -1
  120. package/static/app/assets/ProductFlow-IP4Q-5aQ.js +0 -1
  121. package/static/app/assets/System-Bwx1h_jT.js +0 -1
  122. package/static/app/assets/arrow-left-ig7AZU8B.js +0 -1
  123. package/static/app/assets/brain-D8OEwmVj.js +0 -1
  124. package/static/app/assets/index-BfD-jhA9.css +0 -2
  125. package/static/app/assets/primitives-CQV9Q2YM.js +0 -1
  126. package/static/app/assets/search-CXGASMMH.js +0 -1
@@ -0,0 +1,151 @@
1
+ """int8-quantized exhaustive index — same recall shape, a fraction of the RAM.
2
+
3
+ A 384-dimension embedding held as a Python ``list[float]`` costs ~3 KB (each
4
+ float is a boxed object plus a pointer); the same vector as an ``array("b")``
5
+ of int8 codes costs 384 bytes plus one header. On a 100 000-vector brain that
6
+ is the difference between ~300 MB and ~40 MB of resident scoring state, which
7
+ is what decides whether a large index can be held at all.
8
+
9
+ The trade is precision, not coverage: every vector is still compared (the
10
+ index is *exhaustive*), but each score is reconstructed from 8-bit codes, so
11
+ it lands within roughly ±1% of the exact cosine. Near-ties can therefore swap
12
+ places — which is why this backend reports ``approx = True`` and why the
13
+ default stays :class:`~.brute_force.BruteForceIndex`.
14
+
15
+ Symmetric per-vector scaling: each vector is divided by its own peak absolute
16
+ value before rounding, so a short vector keeps its full 8-bit resolution
17
+ instead of being crushed by the largest vector in the corpus.
18
+
19
+ Measured caveat (docs/PERFORMANCE.md, 10k vectors): the RAM saving above is a
20
+ property of the *data structure*, and the current search path does not cash it
21
+ in. ``retrieval_vector`` already feeds the index in bounded batches, so
22
+ resident vectors were never the dominant term — the fetched SQLite rows are —
23
+ and peak memory moves by ~1 MB while latency roughly doubles. This backend is
24
+ therefore honest, exhaustive, and currently not the one to choose; it exists
25
+ as the representation a *held* (cross-query) index would need.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from array import array
31
+ from operator import mul
32
+ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
33
+
34
+ from .base import IndexItem, IndexStats, ScoredId, score_floor, take_top
35
+
36
+ QUANTIZED_BACKEND = "quantized-int8"
37
+ #: int8 keeps ``-128``; the symmetric range stops at ``-127`` so ``+peak`` and
38
+ #: ``-peak`` quantize to codes of equal magnitude.
39
+ INT8_MAX = 127
40
+
41
+
42
+ def quantize(vector: Sequence[float]) -> Tuple[array, float]:
43
+ """``vector`` → (int8 codes, scale) with ``value ≈ code * scale``.
44
+
45
+ An all-zero vector has no peak to scale against; it quantizes to all-zero
46
+ codes with scale ``0.0``, which scores 0 against everything — the same
47
+ answer the exact scan gives for a zero vector.
48
+ """
49
+ values = [float(value) for value in vector]
50
+ peak = max((abs(value) for value in values), default=0.0)
51
+ if peak <= 0.0:
52
+ return array("b", bytes(len(values))), 0.0
53
+ scale = peak / INT8_MAX
54
+ codes = array(
55
+ "b",
56
+ (
57
+ max(-INT8_MAX, min(INT8_MAX, int(round(value / scale))))
58
+ for value in values
59
+ ),
60
+ )
61
+ return codes, scale
62
+
63
+
64
+ class QuantizedIndex:
65
+ """Exhaustive int8 index: full coverage, approximate scores."""
66
+
67
+ def __init__(self, *, dim: int = 0) -> None:
68
+ self._dim = int(dim)
69
+ self._codes: Dict[str, array] = {}
70
+ self._scales: Dict[str, float] = {}
71
+ self._metadata: Dict[str, Dict[str, Any]] = {}
72
+
73
+ @property
74
+ def backend(self) -> str:
75
+ return QUANTIZED_BACKEND
76
+
77
+ @property
78
+ def approx(self) -> bool:
79
+ return True
80
+
81
+ @property
82
+ def exhaustive(self) -> bool:
83
+ return True
84
+
85
+ def add(
86
+ self,
87
+ id: str,
88
+ vector: Sequence[float],
89
+ metadata: Optional[Mapping[str, Any]] = None,
90
+ ) -> None:
91
+ codes, scale = quantize(vector)
92
+ if not self._dim:
93
+ self._dim = len(codes)
94
+ key = str(id)
95
+ self._codes[key] = codes
96
+ self._scales[key] = scale
97
+ self._metadata[key] = dict(metadata or {})
98
+
99
+ def remove(self, id: str) -> None:
100
+ key = str(id)
101
+ self._codes.pop(key, None)
102
+ self._scales.pop(key, None)
103
+ self._metadata.pop(key, None)
104
+
105
+ def rebuild(self, items: Iterable[IndexItem]) -> None:
106
+ self._codes.clear()
107
+ self._scales.clear()
108
+ self._metadata.clear()
109
+ for item_id, vector, metadata in items:
110
+ self.add(item_id, vector, metadata)
111
+
112
+ def metadata_for(self, id: str) -> Dict[str, Any]:
113
+ return dict(self._metadata.get(str(id), {}))
114
+
115
+ def search(
116
+ self,
117
+ query: Sequence[float],
118
+ top_k: int,
119
+ filter: Optional[Mapping[str, Any]] = None,
120
+ ) -> List[ScoredId]:
121
+ floor = score_floor(filter)
122
+ query_codes, query_scale = quantize(query)
123
+ scored: List[ScoredId] = []
124
+ for item_id, codes in self._codes.items():
125
+ if len(codes) != len(query_codes):
126
+ raise ValueError(
127
+ f"embedding dimension mismatch: {len(query_codes)} vs "
128
+ f"{len(codes)}; the vector index was built with a "
129
+ "different model"
130
+ )
131
+ # Integer dot product, then one float multiply for the two scales:
132
+ # all the arithmetic that decides the ranking stays in ints.
133
+ raw = sum(map(mul, query_codes, codes))
134
+ score = float(raw) * query_scale * self._scales[item_id]
135
+ if score < floor:
136
+ continue
137
+ scored.append((item_id, score))
138
+ return take_top(scored, top_k)
139
+
140
+ def stats(self) -> IndexStats:
141
+ return IndexStats(
142
+ backend=self.backend,
143
+ size=len(self._codes),
144
+ dim=self._dim,
145
+ approx=True,
146
+ exhaustive=True,
147
+ detail="scores are reconstructed from 8-bit codes (~1% error)",
148
+ )
149
+
150
+
151
+ __all__ = ["INT8_MAX", "QUANTIZED_BACKEND", "QuantizedIndex", "quantize"]
@@ -0,0 +1,131 @@
1
+ """Which backend scores this search, and — when it is not the one you asked
2
+ for — why.
3
+
4
+ ``LATTICEAI_VECTOR_INDEX`` selects ``brute`` (default) / ``quantized`` /
5
+ ``hnsw``. Two failure modes have to stay loud, because both otherwise look
6
+ exactly like "search is a bit worse today":
7
+
8
+ * an unknown name (typo, stale config) must not silently mean "the default";
9
+ * asking for ``hnsw`` without the optional extra installed must not silently
10
+ mean "you have ANN".
11
+
12
+ Both resolve to the exact brute-force scan and carry a ``detail`` string
13
+ naming the cause, which ``index_status()`` and the search result surface.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ from dataclasses import dataclass
20
+ from typing import Any, Dict, Optional
21
+
22
+ from .base import Similarity, VectorIndex
23
+ from .brute_force import BRUTE_FORCE_BACKEND, BruteForceIndex
24
+ from .hnsw import HNSW_BACKEND, HnswIndex, load_hnswlib
25
+ from .quantized import QUANTIZED_BACKEND, QuantizedIndex
26
+
27
+ VECTOR_INDEX_ENV = "LATTICEAI_VECTOR_INDEX"
28
+ DEFAULT_VECTOR_INDEX = "brute"
29
+ #: Accepted values of ``LATTICEAI_VECTOR_INDEX``.
30
+ VECTOR_INDEX_CHOICES = ("brute", "quantized", "hnsw")
31
+
32
+ _BACKEND_LABELS = {
33
+ "brute": BRUTE_FORCE_BACKEND,
34
+ "quantized": QUANTIZED_BACKEND,
35
+ "hnsw": HNSW_BACKEND,
36
+ }
37
+ _APPROX = {"brute": False, "quantized": True, "hnsw": True}
38
+ _EXHAUSTIVE = {"brute": True, "quantized": True, "hnsw": False}
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class BackendSelection:
43
+ """The resolved backend plus an honest account of any substitution."""
44
+
45
+ requested: str
46
+ name: str
47
+ backend: str
48
+ approx: bool
49
+ exhaustive: bool
50
+ detail: Optional[str] = None
51
+
52
+ @property
53
+ def honored(self) -> bool:
54
+ """True when the caller got the backend they asked for."""
55
+ return self.requested == self.name
56
+
57
+ def as_dict(self) -> Dict[str, Any]:
58
+ return {
59
+ "requested": self.requested,
60
+ "backend": self.backend,
61
+ "name": self.name,
62
+ "approx": self.approx,
63
+ "exhaustive": self.exhaustive,
64
+ "honored": self.honored,
65
+ "detail": self.detail,
66
+ }
67
+
68
+
69
+ def _selection(name: str, *, requested: str, detail: Optional[str]) -> BackendSelection:
70
+ return BackendSelection(
71
+ requested=requested,
72
+ name=name,
73
+ backend=_BACKEND_LABELS[name],
74
+ approx=_APPROX[name],
75
+ exhaustive=_EXHAUSTIVE[name],
76
+ detail=detail,
77
+ )
78
+
79
+
80
+ def resolve_vector_index(requested: Optional[str] = None) -> BackendSelection:
81
+ """Resolve the configured backend (never raises, always falls back safe)."""
82
+ raw = requested if requested is not None else os.getenv(VECTOR_INDEX_ENV, "")
83
+ name = str(raw or "").strip().lower() or DEFAULT_VECTOR_INDEX
84
+ if name not in VECTOR_INDEX_CHOICES:
85
+ return _selection(
86
+ DEFAULT_VECTOR_INDEX,
87
+ requested=name,
88
+ detail=(
89
+ f"unknown vector index backend {name!r}; expected one of "
90
+ f"{', '.join(VECTOR_INDEX_CHOICES)} — using the exact "
91
+ "brute-force scan"
92
+ ),
93
+ )
94
+ if name == "hnsw":
95
+ module, reason = load_hnswlib()
96
+ if module is None:
97
+ return _selection(
98
+ DEFAULT_VECTOR_INDEX,
99
+ requested=name,
100
+ detail=f"{reason} — using the exact brute-force scan",
101
+ )
102
+ return _selection(name, requested=name, detail=None)
103
+
104
+
105
+ def build_index(
106
+ selection: BackendSelection,
107
+ *,
108
+ dim: int = 0,
109
+ similarity: Optional[Similarity] = None,
110
+ ) -> VectorIndex:
111
+ """Instantiate the backend named by ``selection``.
112
+
113
+ ``similarity`` is threaded into the exact backend only: quantized and
114
+ HNSW score in their own representation, and pretending otherwise would
115
+ make an injected function look respected when it is not.
116
+ """
117
+ if selection.name == "quantized":
118
+ return QuantizedIndex(dim=dim)
119
+ if selection.name == HNSW_BACKEND:
120
+ return HnswIndex(dim=dim)
121
+ return BruteForceIndex(dim=dim, similarity=similarity)
122
+
123
+
124
+ __all__ = [
125
+ "DEFAULT_VECTOR_INDEX",
126
+ "VECTOR_INDEX_CHOICES",
127
+ "VECTOR_INDEX_ENV",
128
+ "BackendSelection",
129
+ "build_index",
130
+ "resolve_vector_index",
131
+ ]
@@ -46,6 +46,7 @@ from dataclasses import dataclass, field
46
46
  from pathlib import Path
47
47
  from typing import Any, Dict, Iterable, List, Optional, Tuple
48
48
 
49
+ from .graph.vector_index import DEFAULT_TICK_LIMIT as VECTOR_TICK_LIMIT
49
50
  from .runtime.hooks import dispatch_tool
50
51
  from .utils import utc_now_iso
51
52
 
@@ -715,12 +716,32 @@ class IngestionPipeline:
715
716
  "detail": "; ".join(p for p in parts if p),
716
717
  }
717
718
 
719
+ def _queue_pending_embed(self, node_id: str, detail: str) -> bool:
720
+ """Hand a node the inline sync could not embed to the background queue.
721
+
722
+ Before v11.1.0 ``indexing_status="pending"`` was the end of the story:
723
+ honest, but nobody was coming back for it, so the node stayed
724
+ unsearchable until a human ran a rebuild. The durable queue is who
725
+ comes back. A store without one (older stores, mocks) just keeps the
726
+ old behaviour — the node is still visible as ``index_status`` backlog.
727
+ """
728
+ queue = getattr(self._kg, "vector_queue", None)
729
+ if queue is None:
730
+ return False
731
+ try:
732
+ return bool(queue.schedule(node_id, detail=detail))
733
+ except Exception: # noqa: BLE001 — queueing must never fail an ingest
734
+ quiet()
735
+ return False
736
+
718
737
  def _sync_vector_index(self, node_id: str) -> Tuple[str, Optional[str]]:
719
738
  """Best-effort incremental vector sync → (indexing_status, detail).
720
739
 
721
740
  Any failure — missing method on older stores, embedding provider down,
722
741
  storage error — yields ``("pending", detail)`` so a later
723
- ``rebuild_vector_index`` run picks the node up from the backlog.
742
+ ``rebuild_vector_index`` run picks the node up from the backlog, and
743
+ the node is queued for background embedding so that pickup happens on
744
+ its own.
724
745
  """
725
746
  sync = getattr(self._kg, "index_node_incremental", None)
726
747
  if not callable(sync):
@@ -730,12 +751,38 @@ class IngestionPipeline:
730
751
  try:
731
752
  outcome = sync(node_id) or {}
732
753
  except Exception as exc: # noqa: BLE001 — vector sync must never fail the ingest
733
- return "pending", f"vector index sync failed: {exc}"
754
+ return "pending", self._pending_detail(node_id, f"vector index sync failed: {exc}")
734
755
  if str(outcome.get("status") or "") == "failed":
735
756
  reason = outcome.get("detail") or "unknown error"
736
- return "pending", f"vector index sync failed: {reason}"
757
+ return "pending", self._pending_detail(
758
+ node_id, f"vector index sync failed: {reason}"
759
+ )
737
760
  return "indexed", None
738
761
 
762
+ def _pending_detail(self, node_id: str, reason: str) -> str:
763
+ """``reason``, plus whether a background retry was actually scheduled."""
764
+ if self._queue_pending_embed(node_id, reason):
765
+ return f"{reason}; queued for background embedding"
766
+ return reason
767
+
768
+ def drain_vector_queue(self, limit: int = VECTOR_TICK_LIMIT) -> Dict[str, Any]:
769
+ """Run one background-embedding tick over the store's pending backlog.
770
+
771
+ Deliberately caller-driven (a scheduler, a CLI, a test) rather than a
772
+ thread this pipeline owns: the queue is durable, so "who runs it" is a
773
+ deployment decision, not a property of having ingested something.
774
+ """
775
+ queue = getattr(self._kg, "vector_queue", None)
776
+ if queue is None:
777
+ return {
778
+ "claimed": 0,
779
+ "indexed": 0,
780
+ "retried": 0,
781
+ "failed": 0,
782
+ "detail": "this store has no background vector queue",
783
+ }
784
+ return dict(queue.tick(limit))
785
+
739
786
  # --- Large candidate #1: background / incremental scheduling (slice) ---
740
787
  def schedule_background(
741
788
  self,