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
@@ -52,6 +52,7 @@ from pathlib import Path
52
52
  from typing import Any, Dict, Optional
53
53
 
54
54
  from ..quiet import quiet
55
+ from ..utils import now_iso as _stamp_now
55
56
 
56
57
  # ── Schema version ──────────────────────────────────────────────────────────
57
58
  KG_SCHEMA_V2_VERSION = 2
@@ -117,6 +118,16 @@ class NodeType(str, Enum):
117
118
  ORGANIZATION = "ORGANIZATION" # 조직 / 회사 / 팀
118
119
  WORKFLOW = "WORKFLOW" # 워크플로우 정의/실행
119
120
  AGENT = "AGENT" # 에이전트(역할/실행 주체)
121
+ # v11.1.0 Personal Ontology (Track 4) — the Self-Model subgraph. These are
122
+ # not "another kind of document": they are what the Brain has been told
123
+ # about *its owner*, so they are read, edited and deleted as a unit
124
+ # (``lattice_brain.self_model``). DECISION already exists above and is
125
+ # reused rather than duplicated — a decision is a decision whether it came
126
+ # from a meeting note or from "결정: 로컬 모델을 기본값으로 쓴다".
127
+ SELF = "SELF" # 사용자 자신(Self-Model 루트)
128
+ PREFERENCE = "PREFERENCE" # 선호 (좋아함/싫어함/선호 방식)
129
+ HABIT = "HABIT" # 습관 (반복되는 행동)
130
+ RELATIONSHIP = "RELATIONSHIP" # 관계 (동료/친구/멘토 등)
120
131
 
121
132
  @classmethod
122
133
  def from_legacy(cls, label: str) -> "NodeType":
@@ -252,6 +263,19 @@ _LEGACY_NODE_MAP: Dict[str, NodeType] = {
252
263
  "team": NodeType.ORGANIZATION,
253
264
  "workflow": NodeType.WORKFLOW,
254
265
  "agent": NodeType.AGENT,
266
+ # v11.1.0 Personal Ontology — the Self-Model vocabulary, including the
267
+ # Korean labels a person actually types.
268
+ "self": NodeType.SELF,
269
+ "selfmodel": NodeType.SELF,
270
+ "me": NodeType.SELF,
271
+ "나": NodeType.SELF,
272
+ "preference": NodeType.PREFERENCE,
273
+ "선호": NodeType.PREFERENCE,
274
+ "habit": NodeType.HABIT,
275
+ "습관": NodeType.HABIT,
276
+ "relationship": NodeType.RELATIONSHIP,
277
+ "관계": NodeType.RELATIONSHIP,
278
+ "결정": NodeType.DECISION,
255
279
  }
256
280
 
257
281
  _LEGACY_EDGE_MAP: Dict[str, EdgeType] = {
@@ -346,6 +370,13 @@ CREATE TABLE IF NOT EXISTS nodes_v2 (
346
370
  visibility TEXT NOT NULL DEFAULT 'private',
347
371
  -- Revision chain: a node replaced by a newer one points at its successor.
348
372
  superseded_by TEXT,
373
+ -- Bitemporal validity (v11.1.0). NULL is the convention, not '':
374
+ -- valid_from IS NULL → the row has been valid since created_at
375
+ -- valid_to IS NULL → the row is still valid now
376
+ -- An empty string would read as "valid since the beginning of time" in a
377
+ -- string comparison, which is why the columns are nullable with no default.
378
+ valid_from TEXT,
379
+ valid_to TEXT,
349
380
  created_at TEXT NOT NULL,
350
381
  updated_at TEXT NOT NULL,
351
382
  style TEXT,
@@ -366,6 +397,12 @@ CREATE TABLE IF NOT EXISTS edges_v2 (
366
397
  metadata TEXT NOT NULL DEFAULT '{}',
367
398
  created_by TEXT NOT NULL DEFAULT 'user',
368
399
  created_at TEXT NOT NULL,
400
+ -- Bitemporal validity + revision chain (v11.1.0), same NULL convention as
401
+ -- nodes_v2: NULL valid_from falls back to created_at, NULL valid_to means
402
+ -- "still holds". Relationships go stale exactly like facts do.
403
+ valid_from TEXT,
404
+ valid_to TEXT,
405
+ superseded_by TEXT,
369
406
  -- Edge identity (v4): the normalized type AND the raw legacy type.
370
407
  -- Migrated rows keep their legacy_type discriminator, so two distinct
371
408
  -- legacy strings between one pair (e.g. "mentions" / "관련됨") stay
@@ -403,6 +440,20 @@ CREATE INDEX IF NOT EXISTS idx_edges_v2_legacy ON edges_v2(legacy_type);
403
440
  """
404
441
 
405
442
 
443
+ # ── Temporal read convention (v11.1.0) ──────────────────────────────────────
444
+ # One predicate, shared by every ``as_of`` read so the fallback rule cannot
445
+ # drift between call sites:
446
+ # * ``valid_from IS NULL`` → the row has been valid since ``created_at``
447
+ # (plan §10 mitigation: existing Brains keep working with no backfill)
448
+ # * ``valid_to IS NULL`` → the row is still valid
449
+ # The window is half-open ``[valid_from, valid_to)``: the instant a fact is
450
+ # superseded belongs to its successor, so an ``as_of`` at exactly that instant
451
+ # returns exactly one of the two.
452
+ TEMPORAL_PREDICATE_SQL = (
453
+ "COALESCE(valid_from, created_at) <= ? AND (valid_to IS NULL OR valid_to > ?)"
454
+ )
455
+
456
+
406
457
  def _exec_script(conn: sqlite3.Connection, script: str) -> None:
407
458
  """Run a multi-statement SQL script on ``conn`` statement-by-statement.
408
459
 
@@ -456,6 +507,9 @@ class KGStoreV2:
456
507
  "metadata",
457
508
  "created_by",
458
509
  "created_at",
510
+ "valid_from",
511
+ "valid_to",
512
+ "superseded_by",
459
513
  },
460
514
  "nodes_v2": {
461
515
  "id",
@@ -469,6 +523,8 @@ class KGStoreV2:
469
523
  "workspace_id",
470
524
  "visibility",
471
525
  "superseded_by",
526
+ "valid_from",
527
+ "valid_to",
472
528
  "created_at",
473
529
  "updated_at",
474
530
  "style",
@@ -481,8 +537,17 @@ class KGStoreV2:
481
537
  # Columns added after a table's first release that can be healed in place
482
538
  # with ALTER TABLE ADD COLUMN (nullable / defaulted only).
483
539
  _V2_ADDABLE_COLUMNS = {
484
- "nodes_v2": {"workspace_id": "TEXT", "superseded_by": "TEXT"},
485
- "edges_v2": {},
540
+ "nodes_v2": {
541
+ "workspace_id": "TEXT",
542
+ "superseded_by": "TEXT",
543
+ "valid_from": "TEXT",
544
+ "valid_to": "TEXT",
545
+ },
546
+ "edges_v2": {
547
+ "valid_from": "TEXT",
548
+ "valid_to": "TEXT",
549
+ "superseded_by": "TEXT",
550
+ },
486
551
  }
487
552
 
488
553
  def _drop_stale_empty_v2_tables(self, conn: sqlite3.Connection) -> None:
@@ -586,6 +651,64 @@ class KGStoreV2:
586
651
  ("embed_dim", str(EMBED_DIM)),
587
652
  )
588
653
 
654
+ # ── Temporal stamping (v11.1.0) ──────────────────────────
655
+ #
656
+ # These are *primitives*, not a policy: the only caller in the product is
657
+ # the review-approval path in :mod:`lattice_brain.synthesis`. Background
658
+ # jobs and agents never call them directly — they raise a proposal and the
659
+ # user approves it. See ``ARCHITECTURE.md`` → "Temporal knowledge".
660
+ def stamp_node_validity(
661
+ self,
662
+ node_id: str,
663
+ *,
664
+ valid_from: Optional[str] = None,
665
+ valid_to: Optional[str] = None,
666
+ superseded_by: Optional[str] = None,
667
+ ) -> bool:
668
+ """Stamp a node's validity window. ``True`` when a row was updated.
669
+
670
+ Only the arguments actually supplied are written; ``None`` leaves the
671
+ stored value alone (it never clears a column back to NULL, so a second
672
+ resolution cannot silently un-supersede an earlier one).
673
+ """
674
+ return self._stamp_row(
675
+ "nodes_v2",
676
+ node_id,
677
+ {
678
+ "valid_from": valid_from,
679
+ "valid_to": valid_to,
680
+ "superseded_by": superseded_by,
681
+ },
682
+ )
683
+
684
+ def touch_node(self, node_id: str, *, at: Optional[str] = None) -> bool:
685
+ """Record one access: bump ``importance_score`` and set ``last_used``.
686
+
687
+ Read-path bookkeeping for the importance/decay report. Returns whether
688
+ a row existed; callers treat a ``False``/failure as harmless.
689
+ """
690
+ with self._conn() as conn:
691
+ cursor = conn.execute(
692
+ "UPDATE nodes_v2 SET importance_score = importance_score + 1.0, "
693
+ "last_used = ? WHERE id = ?",
694
+ (at or _stamp_now(), str(node_id)),
695
+ )
696
+ return int(cursor.rowcount or 0) > 0
697
+
698
+ def _stamp_row(
699
+ self, table: str, row_id: str, updates: Dict[str, Optional[str]]
700
+ ) -> bool:
701
+ fields = {key: value for key, value in updates.items() if value is not None}
702
+ if not fields:
703
+ return False
704
+ assignments = ", ".join(f"{key}=?" for key in fields)
705
+ with self._conn() as conn:
706
+ cursor = conn.execute(
707
+ f"UPDATE {table} SET {assignments} WHERE id=?",
708
+ (*fields.values(), str(row_id)),
709
+ )
710
+ return int(cursor.rowcount or 0) > 0
711
+
589
712
  # ── Maintenance ──────────────────────────────────────────
590
713
  def stats(self) -> Dict[str, Any]:
591
714
  with self._conn() as conn:
@@ -0,0 +1,85 @@
1
+ """Pluggable vector-index backends + the durable pending-embed queue.
2
+
3
+ Public surface (v11.1.0, Track 1):
4
+
5
+ * :class:`VectorIndex` — the Protocol every backend satisfies.
6
+ * :class:`BruteForceIndex` — exact, exhaustive, the default.
7
+ * :class:`QuantizedIndex` — int8 storage, exhaustive, approximate scores.
8
+ * :class:`HnswIndex` — approximate nearest neighbour, optional ``hnswlib``.
9
+ * :func:`resolve_vector_index` / :func:`build_index` — ``LATTICEAI_VECTOR_INDEX``
10
+ resolution with an honest fallback reason.
11
+ * :class:`VectorEmbedQueue` — SQLite-backed background embedding backlog.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from .base import (
17
+ IndexItem,
18
+ IndexStats,
19
+ ScoredId,
20
+ Similarity,
21
+ VectorIndex,
22
+ dot_similarity,
23
+ score_floor,
24
+ take_top,
25
+ )
26
+ from .brute_force import BRUTE_FORCE_BACKEND, BruteForceIndex
27
+ from .hnsw import (
28
+ HNSW_BACKEND,
29
+ HNSW_META_SUFFIX,
30
+ HNSW_SUFFIX,
31
+ HnswIndex,
32
+ hnswlib_available,
33
+ load_hnswlib,
34
+ sidecar_paths,
35
+ )
36
+ from .jobs import (
37
+ DEFAULT_MAX_ATTEMPTS,
38
+ DEFAULT_TICK_LIMIT,
39
+ VECTOR_JOB_STATUSES,
40
+ VectorEmbedQueue,
41
+ VectorJobStore,
42
+ )
43
+ from .quantized import QUANTIZED_BACKEND, QuantizedIndex, quantize
44
+ from .selector import (
45
+ DEFAULT_VECTOR_INDEX,
46
+ VECTOR_INDEX_CHOICES,
47
+ VECTOR_INDEX_ENV,
48
+ BackendSelection,
49
+ build_index,
50
+ resolve_vector_index,
51
+ )
52
+
53
+ __all__ = [
54
+ "BRUTE_FORCE_BACKEND",
55
+ "DEFAULT_MAX_ATTEMPTS",
56
+ "DEFAULT_TICK_LIMIT",
57
+ "DEFAULT_VECTOR_INDEX",
58
+ "HNSW_BACKEND",
59
+ "HNSW_META_SUFFIX",
60
+ "HNSW_SUFFIX",
61
+ "QUANTIZED_BACKEND",
62
+ "VECTOR_INDEX_CHOICES",
63
+ "VECTOR_INDEX_ENV",
64
+ "VECTOR_JOB_STATUSES",
65
+ "BackendSelection",
66
+ "BruteForceIndex",
67
+ "HnswIndex",
68
+ "IndexItem",
69
+ "IndexStats",
70
+ "QuantizedIndex",
71
+ "ScoredId",
72
+ "Similarity",
73
+ "VectorEmbedQueue",
74
+ "VectorIndex",
75
+ "VectorJobStore",
76
+ "build_index",
77
+ "dot_similarity",
78
+ "hnswlib_available",
79
+ "load_hnswlib",
80
+ "quantize",
81
+ "resolve_vector_index",
82
+ "score_floor",
83
+ "sidecar_paths",
84
+ "take_top",
85
+ ]
@@ -0,0 +1,170 @@
1
+ """The vector-index contract: what it means to score embeddings (v11.1.0).
2
+
3
+ Before this package the only vector "index" was the scan loop inside
4
+ ``retrieval_vector.vector_search``: fetch every embedding row, decode it,
5
+ score it, sort. That is a perfectly good *exact* index and it stays the
6
+ default — but it was welded to SQL row handling, so there was nowhere to put
7
+ a second strategy without forking the search.
8
+
9
+ This module defines the seam. A backend owns exactly one question — *given a
10
+ query vector, which stored ids score highest* — and nothing about SQLite,
11
+ chunk metadata, workspaces, or citations. The vectors are ``Sequence[float]``
12
+ (the repo's embedders return ``list[float]``; numpy is not a dependency).
13
+
14
+ Honesty contract: every backend reports whether it is ``approx`` (scores are
15
+ estimates) and whether it is ``exhaustive`` (every stored vector was
16
+ compared). Those two bits travel with the search result so a caller never has
17
+ to guess whether "no match" means "not in your brain" or "the index did not
18
+ look".
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass
24
+ from typing import (
25
+ Any,
26
+ Callable,
27
+ Dict,
28
+ Iterable,
29
+ List,
30
+ Mapping,
31
+ Optional,
32
+ Protocol,
33
+ Sequence,
34
+ Tuple,
35
+ )
36
+
37
+ #: ``(item_id, score)`` — the one shape every backend returns.
38
+ ScoredId = Tuple[str, float]
39
+ #: ``(item_id, vector, metadata)`` — one item as handed to :meth:`rebuild`.
40
+ IndexItem = Tuple[str, Sequence[float], Mapping[str, Any]]
41
+ #: Pluggable similarity so a backend can borrow the embedder's own function.
42
+ Similarity = Callable[[Sequence[float], Sequence[float]], float]
43
+
44
+ #: "no score floor" — ``0.0`` is a meaningful floor, so it cannot be the
45
+ #: sentinel (the score-0-is-falsy trap this codebase keeps re-learning).
46
+ NO_FLOOR = float("-inf")
47
+
48
+
49
+ def dot_similarity(left: Sequence[float], right: Sequence[float]) -> float:
50
+ """Dot product, which is cosine similarity for unit-length vectors.
51
+
52
+ Mirrors :meth:`lattice_brain.embeddings.LocalEmbeddingModel.similarity`,
53
+ including its refusal to compare different dimensions: truncating to the
54
+ shorter vector produces a plausible-looking number that means nothing.
55
+ """
56
+ left_v, right_v = list(left), list(right)
57
+ if len(left_v) != len(right_v):
58
+ raise ValueError(
59
+ f"embedding dimension mismatch: {len(left_v)} vs {len(right_v)}; "
60
+ "the vector index was built with a different model"
61
+ )
62
+ return float(sum(a * b for a, b in zip(left_v, right_v, strict=True)))
63
+
64
+
65
+ def score_floor(filter: Optional[Mapping[str, Any]]) -> float:
66
+ """Resolve ``filter["min_score"]`` (absent/unparseable → no floor)."""
67
+ if not filter:
68
+ return NO_FLOOR
69
+ raw = filter.get("min_score")
70
+ if raw is None:
71
+ return NO_FLOOR
72
+ try:
73
+ return float(raw)
74
+ except (TypeError, ValueError):
75
+ return NO_FLOOR
76
+
77
+
78
+ def take_top(scored: List[ScoredId], top_k: int) -> List[ScoredId]:
79
+ """Sort by score descending (stable → insertion order breaks ties), cut."""
80
+ scored.sort(key=lambda pair: -pair[1])
81
+ return scored[: max(0, int(top_k))]
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class IndexStats:
86
+ """What a backend is willing to claim about itself."""
87
+
88
+ backend: str
89
+ size: int
90
+ dim: int
91
+ approx: bool
92
+ exhaustive: bool
93
+ available: bool = True
94
+ detail: Optional[str] = None
95
+
96
+ def as_dict(self) -> Dict[str, Any]:
97
+ return {
98
+ "backend": self.backend,
99
+ "size": self.size,
100
+ "dim": self.dim,
101
+ "approx": self.approx,
102
+ "exhaustive": self.exhaustive,
103
+ "available": self.available,
104
+ "detail": self.detail,
105
+ }
106
+
107
+
108
+ class VectorIndex(Protocol):
109
+ """Structural contract implemented by every backend in this package.
110
+
111
+ Declared, never implemented — same convention as
112
+ :mod:`lattice_brain.graph._kg_contract`: the bodies raise so a stub can
113
+ never be mistaken for a working index.
114
+ """
115
+
116
+ @property
117
+ def backend(self) -> str:
118
+ """Stable id of the scoring strategy (goes into ``recall.backend``)."""
119
+ raise NotImplementedError
120
+
121
+ @property
122
+ def approx(self) -> bool:
123
+ """True when scores are estimates rather than exact similarities."""
124
+ raise NotImplementedError
125
+
126
+ @property
127
+ def exhaustive(self) -> bool:
128
+ """True when every stored vector is compared on every search."""
129
+ raise NotImplementedError
130
+
131
+ def add(
132
+ self,
133
+ id: str,
134
+ vector: Sequence[float],
135
+ metadata: Optional[Mapping[str, Any]] = None,
136
+ ) -> None:
137
+ raise NotImplementedError
138
+
139
+ def remove(self, id: str) -> None:
140
+ raise NotImplementedError
141
+
142
+ def rebuild(self, items: Iterable[IndexItem]) -> None:
143
+ raise NotImplementedError
144
+
145
+ def metadata_for(self, id: str) -> Dict[str, Any]:
146
+ raise NotImplementedError
147
+
148
+ def search(
149
+ self,
150
+ query: Sequence[float],
151
+ top_k: int,
152
+ filter: Optional[Mapping[str, Any]] = None,
153
+ ) -> List[ScoredId]:
154
+ raise NotImplementedError
155
+
156
+ def stats(self) -> IndexStats:
157
+ raise NotImplementedError
158
+
159
+
160
+ __all__ = [
161
+ "NO_FLOOR",
162
+ "IndexItem",
163
+ "IndexStats",
164
+ "ScoredId",
165
+ "Similarity",
166
+ "VectorIndex",
167
+ "dot_similarity",
168
+ "score_floor",
169
+ "take_top",
170
+ ]
@@ -0,0 +1,114 @@
1
+ """Exact cosine scan — the historical ``vector_search`` scoring loop, moved.
2
+
3
+ This backend is deliberately boring and deliberately the default. It compares
4
+ the query against every vector it holds, so its answer is the ground truth the
5
+ other backends are measured against (``scripts/bench_vector_index.py`` reports
6
+ recall@k relative to this one).
7
+
8
+ Behaviour parity with the pre-11.1.0 inline loop is the whole point of the
9
+ move: same similarity function (injected, so it is the embedder's own), same
10
+ ``score < min_score`` drop, same stable ordering. ``retrieval_vector`` feeds
11
+ it in bounded batches so a scan never holds every decoded vector at once.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
17
+
18
+ from .base import (
19
+ IndexItem,
20
+ IndexStats,
21
+ ScoredId,
22
+ Similarity,
23
+ dot_similarity,
24
+ score_floor,
25
+ take_top,
26
+ )
27
+
28
+ BRUTE_FORCE_BACKEND = "bruteforce-cosine"
29
+
30
+
31
+ class BruteForceIndex:
32
+ """Exhaustive, exact, in-memory cosine index."""
33
+
34
+ def __init__(
35
+ self,
36
+ *,
37
+ dim: int = 0,
38
+ similarity: Optional[Similarity] = None,
39
+ ) -> None:
40
+ self._dim = int(dim)
41
+ self._similarity: Similarity = (
42
+ dot_similarity if similarity is None else similarity
43
+ )
44
+ self._vectors: Dict[str, List[float]] = {}
45
+ self._metadata: Dict[str, Dict[str, Any]] = {}
46
+
47
+ @property
48
+ def backend(self) -> str:
49
+ return BRUTE_FORCE_BACKEND
50
+
51
+ @property
52
+ def approx(self) -> bool:
53
+ return False
54
+
55
+ @property
56
+ def exhaustive(self) -> bool:
57
+ return True
58
+
59
+ def add(
60
+ self,
61
+ id: str,
62
+ vector: Sequence[float],
63
+ metadata: Optional[Mapping[str, Any]] = None,
64
+ ) -> None:
65
+ values = [float(value) for value in vector]
66
+ if not self._dim:
67
+ self._dim = len(values)
68
+ key = str(id)
69
+ self._vectors[key] = values
70
+ self._metadata[key] = dict(metadata or {})
71
+
72
+ def remove(self, id: str) -> None:
73
+ key = str(id)
74
+ self._vectors.pop(key, None)
75
+ self._metadata.pop(key, None)
76
+
77
+ def rebuild(self, items: Iterable[IndexItem]) -> None:
78
+ self._vectors.clear()
79
+ self._metadata.clear()
80
+ for item_id, vector, metadata in items:
81
+ self.add(item_id, vector, metadata)
82
+
83
+ def metadata_for(self, id: str) -> Dict[str, Any]:
84
+ """Metadata stored alongside ``id`` (empty dict when unknown)."""
85
+ return dict(self._metadata.get(str(id), {}))
86
+
87
+ def search(
88
+ self,
89
+ query: Sequence[float],
90
+ top_k: int,
91
+ filter: Optional[Mapping[str, Any]] = None,
92
+ ) -> List[ScoredId]:
93
+ floor = score_floor(filter)
94
+ query_vector = list(query)
95
+ scored: List[ScoredId] = []
96
+ for item_id, vector in self._vectors.items():
97
+ score = float(self._similarity(query_vector, vector))
98
+ if score < floor:
99
+ continue
100
+ scored.append((item_id, score))
101
+ return take_top(scored, top_k)
102
+
103
+ def stats(self) -> IndexStats:
104
+ return IndexStats(
105
+ backend=self.backend,
106
+ size=len(self._vectors),
107
+ dim=self._dim,
108
+ approx=False,
109
+ exhaustive=True,
110
+ detail=None,
111
+ )
112
+
113
+
114
+ __all__ = ["BRUTE_FORCE_BACKEND", "BruteForceIndex"]