veccore 0.2.0__tar.gz

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 (45) hide show
  1. veccore-0.2.0/LICENSE +21 -0
  2. veccore-0.2.0/PKG-INFO +111 -0
  3. veccore-0.2.0/README.md +71 -0
  4. veccore-0.2.0/pyproject.toml +66 -0
  5. veccore-0.2.0/setup.cfg +4 -0
  6. veccore-0.2.0/src/veccore/__init__.py +56 -0
  7. veccore-0.2.0/src/veccore/adapters/__init__.py +6 -0
  8. veccore-0.2.0/src/veccore/adapters/qdrant_store.py +245 -0
  9. veccore-0.2.0/src/veccore/adapters/sqlite_numpy_store.py +162 -0
  10. veccore-0.2.0/src/veccore/audit.py +100 -0
  11. veccore-0.2.0/src/veccore/batch_ingest.py +455 -0
  12. veccore-0.2.0/src/veccore/core.py +264 -0
  13. veccore-0.2.0/src/veccore/durable.py +64 -0
  14. veccore-0.2.0/src/veccore/embedders/__init__.py +48 -0
  15. veccore-0.2.0/src/veccore/embedders/composite_embedder.py +55 -0
  16. veccore-0.2.0/src/veccore/embedders/local_embedder.py +68 -0
  17. veccore-0.2.0/src/veccore/embedders/mock_embedder.py +57 -0
  18. veccore-0.2.0/src/veccore/embedders/openai_embedder.py +73 -0
  19. veccore-0.2.0/src/veccore/errors.py +96 -0
  20. veccore-0.2.0/src/veccore/evaluate.py +97 -0
  21. veccore-0.2.0/src/veccore/idempotency.py +45 -0
  22. veccore-0.2.0/src/veccore/numeric.py +12 -0
  23. veccore-0.2.0/src/veccore/ports.py +142 -0
  24. veccore-0.2.0/src/veccore/pricing.py +73 -0
  25. veccore-0.2.0/src/veccore/registry.py +98 -0
  26. veccore-0.2.0/src/veccore.egg-info/PKG-INFO +111 -0
  27. veccore-0.2.0/src/veccore.egg-info/SOURCES.txt +43 -0
  28. veccore-0.2.0/src/veccore.egg-info/dependency_links.txt +1 -0
  29. veccore-0.2.0/src/veccore.egg-info/requires.txt +20 -0
  30. veccore-0.2.0/src/veccore.egg-info/top_level.txt +1 -0
  31. veccore-0.2.0/tests/test_acyclic_and_naming.py +68 -0
  32. veccore-0.2.0/tests/test_audit_observability.py +79 -0
  33. veccore-0.2.0/tests/test_batch_ingest.py +274 -0
  34. veccore-0.2.0/tests/test_composite_and_oversize.py +66 -0
  35. veccore-0.2.0/tests/test_delete.py +91 -0
  36. veccore-0.2.0/tests/test_dim_mismatch_refusal.py +29 -0
  37. veccore-0.2.0/tests/test_evaluate.py +71 -0
  38. veccore-0.2.0/tests/test_hard_requirement_additive.py +54 -0
  39. veccore-0.2.0/tests/test_hard_requirement_new_consumer.py +57 -0
  40. veccore-0.2.0/tests/test_model_guard.py +62 -0
  41. veccore-0.2.0/tests/test_never_reembed.py +71 -0
  42. veccore-0.2.0/tests/test_parity_qdrant.py +119 -0
  43. veccore-0.2.0/tests/test_parity_sqlite_numpy.py +30 -0
  44. veccore-0.2.0/tests/test_realspend.py +76 -0
  45. veccore-0.2.0/tests/test_typed_errors.py +62 -0
veccore-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ash Damle
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
veccore-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.4
2
+ Name: veccore
3
+ Version: 0.2.0
4
+ Summary: Ports-and-adapters vector substrate: embed -> index -> search -> hydrate, backend/embedder-agnostic.
5
+ Author: Ash Damle
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/ashdamle/keelworks
8
+ Project-URL: Repository, https://github.com/ashdamle/keelworks
9
+ Project-URL: Issues, https://github.com/ashdamle/keelworks/issues
10
+ Keywords: embeddings,vector-search,qdrant,semantic-search,ports-and-adapters,retrieval
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.11
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: numpy>=1.26
25
+ Provides-Extra: qdrant
26
+ Requires-Dist: qdrant-client>=1.9; extra == "qdrant"
27
+ Provides-Extra: openai
28
+ Requires-Dist: openai>=1.30; extra == "openai"
29
+ Requires-Dist: tiktoken>=0.7; extra == "openai"
30
+ Provides-Extra: st
31
+ Requires-Dist: sentence-transformers>=2.7; extra == "st"
32
+ Provides-Extra: fastembed
33
+ Requires-Dist: fastembed>=0.3; extra == "fastembed"
34
+ Provides-Extra: dev
35
+ Requires-Dist: pytest>=8; extra == "dev"
36
+ Requires-Dist: pytest-timeout>=2.3; extra == "dev"
37
+ Requires-Dist: build>=1.2; extra == "dev"
38
+ Requires-Dist: twine>=5; extra == "dev"
39
+ Dynamic: license-file
40
+
41
+ # veccore
42
+
43
+ **A ports-and-adapters vector substrate: `embed → index → search → hydrate`, backend- and embedder-agnostic.**
44
+
45
+ veccore ships generic *mechanisms* and clean *seams* for semantic retrieval. You register a collection
46
+ (choosing a backend + embedder), embed and upsert your items, and search — veccore returns
47
+ `{id, score, payload}` and you hydrate the full record from your own system of record. Nothing about your
48
+ domain lives in veccore; you never reinvent embedding, indexing, or idempotency.
49
+
50
+ ```bash
51
+ pip install veccore # core (numpy only)
52
+ pip install 'veccore[qdrant]' # + Qdrant server backend
53
+ pip install 'veccore[openai]' # + OpenAI embedder (gated egress you inject)
54
+ pip install 'veccore[fastembed]' # + on-device fastembed embedder (no API key, no network)
55
+ pip install 'veccore[st]' # + sentence-transformers embedder
56
+ ```
57
+
58
+ ## Quickstart
59
+
60
+ ```python
61
+ from veccore import (VecCore, CollectionRegistry, CollectionSpec, AuditEnvelope,
62
+ MockEmbedder, SqliteNumpyStore)
63
+
64
+ store = SqliteNumpyStore(path="/tmp/notes.sqlite")
65
+ core = VecCore(registry=CollectionRegistry(),
66
+ stores={"sqlite_numpy": store},
67
+ embedders={"mock": MockEmbedder()})
68
+ core.register(CollectionSpec(name="notes", backend="sqlite_numpy", embedder="mock", dim=64))
69
+
70
+ # every call carries an audit envelope: who + why (where/what are filled by veccore)
71
+ audit = AuditEnvelope(who="my_tool", why="index notes")
72
+ core.embed_upsert("notes",
73
+ [{"id": "n1", "text": "vector search over notes", "payload": {"src": "n1"}}],
74
+ audit)
75
+
76
+ hits = core.search("notes", "nearest ideas", k=5,
77
+ audit=AuditEnvelope(who="my_tool", why="find related"))
78
+ for h in hits: # each hit is {id, score, payload}
79
+ ... # hydrate the full record from YOUR store by h.id
80
+ ```
81
+
82
+ ## What's in the box
83
+
84
+ - **Core ports** — `EmbedderPort`, `VectorStorePort`, and `GatePort` (injected, so veccore stays free of
85
+ any spend/telemetry dependency — you hand it your gate, or `NullGate` for local/free embedders).
86
+ - **Backends** — `SqliteNumpyStore` (in-process, brute-force cosine; implements the optional `matrix()`
87
+ analytics seam) and `QdrantStore` (server; implements `search(filters=…)` metadata/ACL push-down + Batch
88
+ ingest). Future backends slot in by implementing `VectorStorePort`.
89
+ - **Embedders** — `MockEmbedder` (deterministic, zero-spend), `OpenAIEmbedder` (network, gated),
90
+ `LocalEmbedder` (fastembed / sentence-transformers, on-device), and `CompositeEmbedder` (fuse several
91
+ embedders so a plain dot product equals their weighted fused cosine).
92
+ - **Content-sha idempotency** — re-embed a row only when its text changes; a rerun over an unchanged corpus
93
+ embeds nothing, and changing one item's text re-embeds exactly that one.
94
+ - **Typed errors** — `Unavailable(code=…)` and `NotSupported(capability, backend)` — never a silent degrade.
95
+ - **Estimate-first** — `core.estimate(...)` sizes a job (tokens + `$`) with zero spend before you embed.
96
+ - **Batch ingest** — `BatchIngest` for large corpora via a provider Batch API: chunked, resumable, a durable
97
+ handle ledger, every result accounted; realtime and non-blocking collect both supported.
98
+ - **Optional seams, negotiated** — `capabilities()` + typed `NotSupported` mean expanding usage is additive:
99
+ a consumer starts on `search(collection, q, k)`, later adds `filters=…`, later calls `matrix()`, with no
100
+ rewrite.
101
+
102
+ ## Design
103
+
104
+ veccore's line is **mechanism vs policy**: veccore ships the generic mechanisms + seams; each consumer builds
105
+ its domain policy on top. The gate (spend/egress) and any observability sink are **injected**, so veccore has
106
+ one hard dependency — `numpy` — and everything else (Qdrant, OpenAI, fastembed, sentence-transformers) is an
107
+ optional extra you install only for the adapter you choose.
108
+
109
+ ## License
110
+
111
+ MIT © 2026 Ash Damle. Developed in the [keelworks](https://github.com/ashdamle/keelworks) monorepo.
@@ -0,0 +1,71 @@
1
+ # veccore
2
+
3
+ **A ports-and-adapters vector substrate: `embed → index → search → hydrate`, backend- and embedder-agnostic.**
4
+
5
+ veccore ships generic *mechanisms* and clean *seams* for semantic retrieval. You register a collection
6
+ (choosing a backend + embedder), embed and upsert your items, and search — veccore returns
7
+ `{id, score, payload}` and you hydrate the full record from your own system of record. Nothing about your
8
+ domain lives in veccore; you never reinvent embedding, indexing, or idempotency.
9
+
10
+ ```bash
11
+ pip install veccore # core (numpy only)
12
+ pip install 'veccore[qdrant]' # + Qdrant server backend
13
+ pip install 'veccore[openai]' # + OpenAI embedder (gated egress you inject)
14
+ pip install 'veccore[fastembed]' # + on-device fastembed embedder (no API key, no network)
15
+ pip install 'veccore[st]' # + sentence-transformers embedder
16
+ ```
17
+
18
+ ## Quickstart
19
+
20
+ ```python
21
+ from veccore import (VecCore, CollectionRegistry, CollectionSpec, AuditEnvelope,
22
+ MockEmbedder, SqliteNumpyStore)
23
+
24
+ store = SqliteNumpyStore(path="/tmp/notes.sqlite")
25
+ core = VecCore(registry=CollectionRegistry(),
26
+ stores={"sqlite_numpy": store},
27
+ embedders={"mock": MockEmbedder()})
28
+ core.register(CollectionSpec(name="notes", backend="sqlite_numpy", embedder="mock", dim=64))
29
+
30
+ # every call carries an audit envelope: who + why (where/what are filled by veccore)
31
+ audit = AuditEnvelope(who="my_tool", why="index notes")
32
+ core.embed_upsert("notes",
33
+ [{"id": "n1", "text": "vector search over notes", "payload": {"src": "n1"}}],
34
+ audit)
35
+
36
+ hits = core.search("notes", "nearest ideas", k=5,
37
+ audit=AuditEnvelope(who="my_tool", why="find related"))
38
+ for h in hits: # each hit is {id, score, payload}
39
+ ... # hydrate the full record from YOUR store by h.id
40
+ ```
41
+
42
+ ## What's in the box
43
+
44
+ - **Core ports** — `EmbedderPort`, `VectorStorePort`, and `GatePort` (injected, so veccore stays free of
45
+ any spend/telemetry dependency — you hand it your gate, or `NullGate` for local/free embedders).
46
+ - **Backends** — `SqliteNumpyStore` (in-process, brute-force cosine; implements the optional `matrix()`
47
+ analytics seam) and `QdrantStore` (server; implements `search(filters=…)` metadata/ACL push-down + Batch
48
+ ingest). Future backends slot in by implementing `VectorStorePort`.
49
+ - **Embedders** — `MockEmbedder` (deterministic, zero-spend), `OpenAIEmbedder` (network, gated),
50
+ `LocalEmbedder` (fastembed / sentence-transformers, on-device), and `CompositeEmbedder` (fuse several
51
+ embedders so a plain dot product equals their weighted fused cosine).
52
+ - **Content-sha idempotency** — re-embed a row only when its text changes; a rerun over an unchanged corpus
53
+ embeds nothing, and changing one item's text re-embeds exactly that one.
54
+ - **Typed errors** — `Unavailable(code=…)` and `NotSupported(capability, backend)` — never a silent degrade.
55
+ - **Estimate-first** — `core.estimate(...)` sizes a job (tokens + `$`) with zero spend before you embed.
56
+ - **Batch ingest** — `BatchIngest` for large corpora via a provider Batch API: chunked, resumable, a durable
57
+ handle ledger, every result accounted; realtime and non-blocking collect both supported.
58
+ - **Optional seams, negotiated** — `capabilities()` + typed `NotSupported` mean expanding usage is additive:
59
+ a consumer starts on `search(collection, q, k)`, later adds `filters=…`, later calls `matrix()`, with no
60
+ rewrite.
61
+
62
+ ## Design
63
+
64
+ veccore's line is **mechanism vs policy**: veccore ships the generic mechanisms + seams; each consumer builds
65
+ its domain policy on top. The gate (spend/egress) and any observability sink are **injected**, so veccore has
66
+ one hard dependency — `numpy` — and everything else (Qdrant, OpenAI, fastembed, sentence-transformers) is an
67
+ optional extra you install only for the adapter you choose.
68
+
69
+ ## License
70
+
71
+ MIT © 2026 Ash Damle. Developed in the [keelworks](https://github.com/ashdamle/keelworks) monorepo.
@@ -0,0 +1,66 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"] # >=77 for PEP 639 SPDX license expression + license-files
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "veccore"
7
+ version = "0.2.0"
8
+ description = "Ports-and-adapters vector substrate: embed -> index -> search -> hydrate, backend/embedder-agnostic."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Ash Damle" }]
14
+ keywords = ["embeddings", "vector-search", "qdrant", "semantic-search", "ports-and-adapters", "retrieval"]
15
+ # Core deps only. veccore ships MECHANISMS; heavy/optional backends and embedders are extras so a
16
+ # consumer installs only what its chosen adapter needs. numpy is the sole hard dependency.
17
+ dependencies = [
18
+ "numpy>=1.26",
19
+ ]
20
+ classifiers = [
21
+ "Development Status :: 4 - Beta",
22
+ "Intended Audience :: Developers",
23
+ "Operating System :: OS Independent",
24
+ "Programming Language :: Python :: 3",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
29
+ "Topic :: Software Development :: Libraries",
30
+ "Typing :: Typed",
31
+ ]
32
+
33
+ [project.optional-dependencies]
34
+ # Backends
35
+ qdrant = ["qdrant-client>=1.9"]
36
+ # Embedders (network / local)
37
+ openai = ["openai>=1.30", "tiktoken>=0.7"] # tiktoken → exact token counts for the paid estimate path
38
+ st = ["sentence-transformers>=2.7"]
39
+ fastembed = ["fastembed>=0.3"] # symgrep-codesearch[semantic] depends on veccore[fastembed]
40
+ dev = [
41
+ "pytest>=8",
42
+ "pytest-timeout>=2.3",
43
+ "build>=1.2",
44
+ "twine>=5",
45
+ ]
46
+
47
+ [project.urls]
48
+ Homepage = "https://github.com/ashdamle/keelworks"
49
+ Repository = "https://github.com/ashdamle/keelworks"
50
+ Issues = "https://github.com/ashdamle/keelworks/issues"
51
+
52
+ [tool.setuptools.packages.find]
53
+ where = ["src"]
54
+ include = ["veccore*"] # ship ONLY veccore
55
+ exclude = ["hookdispatch*", "tests*"] # hookdispatch is a PARKED stub, never shipped in the veccore dist
56
+
57
+ [tool.pytest.ini_options]
58
+ testpaths = ["tests"]
59
+ # per-unit timeout so a hung embedder/network call fails the unit, not the suite
60
+ # (feedback_chunk_never_single_shot: per-unit timeout)
61
+ timeout = 120
62
+ markers = [
63
+ "realspend: opt-in test that makes REAL metered OpenAI calls, capped ~$1 (deselected by default)",
64
+ "qdrant: requires a live Qdrant at :6333 (uses a throwaway veccore_test_* collection)",
65
+ ]
66
+ addopts = "-m 'not realspend'"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,56 @@
1
+ """veccore — the ports-and-adapters vector substrate for the estate (keelworks L2).
2
+
3
+ Generic MECHANISMS + clean SEAMS for `embed → index → search → hydrate`. Each consumer builds its domain
4
+ POLICY on top; nothing consumer-specific lives here, and no consumer reinvents embed/index/idempotency.
5
+
6
+ Quick start (a consumer's ENTIRE veccore-side adoption is: register a collection + hydrate from its own SoR):
7
+
8
+ from veccore import (VecCore, CollectionRegistry, CollectionSpec, AuditEnvelope,
9
+ MockEmbedder, SqliteNumpyStore)
10
+
11
+ store = SqliteNumpyStore(path="/tmp/mine.sqlite")
12
+ core = VecCore(registry=CollectionRegistry(),
13
+ stores={"sqlite_numpy": store},
14
+ embedders={"mock": MockEmbedder()})
15
+ core.register(CollectionSpec(name="notes", backend="sqlite_numpy", embedder="mock", dim=64))
16
+
17
+ audit = AuditEnvelope(who="my_tool", why="index notes for reuse search")
18
+ core.embed_upsert("notes", [{"id": "n1", "text": "vector search over notes", "payload": {"src": "n1"}}], audit)
19
+
20
+ hits = core.search("notes", "nearest ideas", k=5, audit=AuditEnvelope(who="my_tool", why="find related"))
21
+ # each hit is {id, score, payload}; hydrate the full record from your own SoR by hit.id
22
+ """
23
+ from .audit import AuditEnvelope, JsonlObserver, NullObserver, ObserverPort
24
+ from .batch_ingest import BatchIngest, BatchReport
25
+ from .core import Estimate, IngestResult, VecCore
26
+ from .evaluate import bakeoff, recall_at_k, reciprocal_rank, score_queries
27
+ from .embedders import CompositeEmbedder, LocalEmbedder, MockEmbedder, OpenAIEmbedder, make_embedder
28
+ from .errors import (DimMismatch, GateRefused, ModelMismatch, NotSupported, OversizeRefused, Unavailable,
29
+ VeccoreError)
30
+ from .ports import EmbedderPort, GatePort, Hit, Item, NullGate, Point, VectorStorePort
31
+ from .registry import CollectionRegistry, CollectionSpec
32
+ from .adapters import QdrantStore, SqliteNumpyStore
33
+
34
+ __version__ = "0.2.0"
35
+
36
+ __all__ = [
37
+ # orchestrator + records
38
+ "VecCore", "IngestResult", "Estimate",
39
+ "CollectionRegistry", "CollectionSpec",
40
+ "AuditEnvelope", "ObserverPort", "NullObserver", "JsonlObserver",
41
+ "Item", "Point", "Hit",
42
+ # ports
43
+ "EmbedderPort", "VectorStorePort", "GatePort", "NullGate",
44
+ # embedders
45
+ "MockEmbedder", "OpenAIEmbedder", "LocalEmbedder", "CompositeEmbedder", "make_embedder",
46
+ # backends
47
+ "SqliteNumpyStore", "QdrantStore",
48
+ # batch seam
49
+ "BatchIngest", "BatchReport",
50
+ # measurement seam (choose/fuse embedders on evidence)
51
+ "bakeoff", "score_queries", "recall_at_k", "reciprocal_rank",
52
+ # errors
53
+ "VeccoreError", "Unavailable", "NotSupported", "DimMismatch", "ModelMismatch", "OversizeRefused",
54
+ "GateRefused",
55
+ "__version__",
56
+ ]
@@ -0,0 +1,6 @@
1
+ """Backend adapters: `sqlite_numpy` (in-process) and `qdrant` (server). Future faiss/pgvector slot in
2
+ here by implementing VectorStorePort. Backend IDs are `sqlite_numpy` / `qdrant`, never `db`."""
3
+ from .qdrant_store import QdrantStore
4
+ from .sqlite_numpy_store import SqliteNumpyStore
5
+
6
+ __all__ = ["SqliteNumpyStore", "QdrantStore"]
@@ -0,0 +1,245 @@
1
+ """QdrantStore — the server backend (backend id "qdrant", never "db").
2
+
3
+ For millions of points, an on-disk Qdrant collection with an ANN index is the right substrate (7thsense's
4
+ 9.3M-point corpus). SQLite/numpy cannot; a server can. This adapter is the mature 7thsense Qdrant path
5
+ re-expressed on veccore's ports: dim-mismatch refusal, resumable existing-ids scroll, deterministic
6
+ point-ids, and the OPTIONAL filter push-down seam.
7
+
8
+ Capabilities: implements `search(filters=…)` (metadata / ACL push-down — permission enforcement in the
9
+ query, O(index) not O(retrieved)) and is a Batch-ingest target. Does NOT implement `matrix()` — pulling
10
+ millions of vectors client-side is not what a server backend is for; it raises typed NotSupported at that
11
+ seam, exactly as the design intends (the honest negotiable "no").
12
+
13
+ Point ids: Qdrant ids must be int or UUID, but a consumer's id is an arbitrary string. So the point id is
14
+ `uuid5(NAMESPACE_URL, id)` (deterministic ⇒ re-upsert updates in place), and the real id + text_sha are
15
+ carried in the payload under the reserved keys `_vid` / `_text_sha`, stripped from the payload returned to
16
+ the consumer.
17
+
18
+ Transport errors NEVER masquerade as 'missing'. `collection_exists()` separates a genuinely-absent
19
+ collection from a timeout/auth failure: a real absence takes the create/empty path; any transport error
20
+ PROPAGATES, so `ensure` never re-creates an existing collection and `existing_ids`/`stored_shas` never
21
+ report points absent (which would re-embed the whole corpus, breaking the never-re-embed guarantee).
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import uuid
26
+ import warnings
27
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
28
+
29
+ import numpy as np
30
+
31
+ from ..errors import DimMismatch, ModelMismatch, NotSupported, Unavailable
32
+ from ..ports import Hit, Point
33
+
34
+ BACKEND = "qdrant"
35
+ _VID = "_vid" # reserved payload key: the consumer's real string id
36
+ _TEXT_SHA = "_text_sha" # reserved payload key: idempotency sha
37
+ _MODEL = "_model" # reserved payload key: the embedder identity (model guard; Qdrant has no
38
+ # collection-metadata field, so the model is carried on every point and checked
39
+ # via a 1-point read on ensure())
40
+
41
+
42
+ def _point_uuid(vid: str) -> str:
43
+ """Deterministic Qdrant point id from the consumer's string id (uuid5 over a stable namespace) — so a
44
+ re-upsert of the same id updates in place rather than duplicating. Parsing an id, not a judgement."""
45
+ return str(uuid.uuid5(uuid.NAMESPACE_URL, vid))
46
+
47
+
48
+ class QdrantStore:
49
+ backend = BACKEND
50
+
51
+ def __init__(self, url: str = "http://localhost:6333", timeout: int = 60, client=None):
52
+ self.url = url
53
+ self.timeout = timeout
54
+ self._client = client # injectable for tests; else lazily constructed
55
+ # in-process cache of each collection's model (set by ensure(model=...)) so upsert can stamp
56
+ # `_model` on its points; the DURABLE record is the payload itself, re-read on a fresh process.
57
+ self._model_by_collection = {}
58
+ # count of foreign/legacy points (no reserved _vid) skipped by the last search — for a consumer
59
+ # that wants to observe a mixed/migration collection programmatically (also warned about).
60
+ self.last_search_foreign_skipped = 0
61
+
62
+ def _qc(self):
63
+ if self._client is None:
64
+ try:
65
+ from qdrant_client import QdrantClient
66
+ except ImportError as e:
67
+ raise Unavailable("qdrant-client is not installed — `pip install veccore[qdrant]`",
68
+ code="backend_unavailable") from e
69
+ # check_compatibility=False: server/client minor gap is fine for the stable REST ops used here
70
+ self._client = QdrantClient(url=self.url, timeout=self.timeout, check_compatibility=False)
71
+ return self._client
72
+
73
+ # ── core port ──────────────────────────────────────────────────────────────────────────────────────
74
+ def ensure(self, collection: str, dim: int, model: str = None) -> None:
75
+ from qdrant_client import models as qm
76
+ qc = self._qc()
77
+ # collection_exists() distinguishes "genuinely missing" from a transport/auth error: a timeout or
78
+ # 401 raises and PROPAGATES (fail-safe) rather than being mistaken for "missing" and triggering a
79
+ # create over an existing collection. Only a real absence takes the create path.
80
+ if not qc.collection_exists(collection):
81
+ qc.create_collection(
82
+ collection_name=collection,
83
+ vectors_config=qm.VectorParams(size=dim, distance=qm.Distance.COSINE, on_disk=True))
84
+ if model is not None:
85
+ self._model_by_collection[collection] = model
86
+ return
87
+ info = qc.get_collection(collection)
88
+ size = info.config.params.vectors.size
89
+ if size != dim:
90
+ raise DimMismatch(collection, size, dim) # 7thsense guard, kept as a core invariant
91
+ if model is not None:
92
+ stored = self._first_model(qc, collection) # None if the collection is empty / pre-guard
93
+ if stored is not None and stored != model:
94
+ raise ModelMismatch(collection, stored, model)
95
+ self._model_by_collection[collection] = model
96
+
97
+ def upsert(self, collection: str, points: Sequence[Point]) -> None:
98
+ from qdrant_client import models as qm
99
+ qc = self._qc()
100
+ model = self._model_by_collection.get(collection)
101
+ structs = []
102
+ for p in points:
103
+ payload = dict(p.payload)
104
+ payload[_VID] = p.id
105
+ payload[_TEXT_SHA] = p.text_sha
106
+ if model is not None:
107
+ payload[_MODEL] = model # carries the model identity so ensure() can guard it later
108
+ structs.append(qm.PointStruct(
109
+ id=_point_uuid(p.id),
110
+ vector=np.asarray(p.vector, dtype=np.float32).ravel().tolist(),
111
+ payload=payload))
112
+ qc.upsert(collection_name=collection, wait=True, points=structs)
113
+
114
+ def search(self, collection: str, qvec: np.ndarray, k: int,
115
+ filters: Optional[Dict[str, Any]] = None) -> List[Hit]:
116
+ qc = self._qc()
117
+ flt = self._build_filter(filters) if filters else None
118
+ res = qc.query_points(
119
+ collection_name=collection,
120
+ query=np.asarray(qvec, dtype=np.float32).ravel().tolist(),
121
+ limit=k, query_filter=flt, with_payload=True).points
122
+ out = []
123
+ foreign = 0
124
+ for p in res:
125
+ payload = dict(p.payload or {})
126
+ vid = payload.pop(_VID, None)
127
+ payload.pop(_TEXT_SHA, None)
128
+ payload.pop(_MODEL, None) # veccore-internal — never surfaced to the consumer
129
+ if vid is None:
130
+ # a legacy/foreign point (not written by veccore, so no reserved _vid). SKIP it rather than
131
+ # emit Hit(id=None) — a null id crashes a consumer doing int(hit.id); a mixed/migration
132
+ # collection must degrade safely, not poison the result set. Counted + warned, never silent.
133
+ foreign += 1
134
+ continue
135
+ out.append(Hit(id=vid, score=float(p.score), payload=payload))
136
+ self.last_search_foreign_skipped = foreign
137
+ if foreign:
138
+ warnings.warn(
139
+ f"veccore.QdrantStore.search: skipped {foreign} foreign point(s) with no {_VID} in "
140
+ f"collection {collection!r} (legacy/non-veccore points, e.g. a migration in progress) — "
141
+ f"not returned as null ids. Rebuild the collection through veccore to clear them.",
142
+ stacklevel=2)
143
+ return out
144
+
145
+ def delete(self, collection: str, ids: Sequence[str]) -> int:
146
+ """Remove points by id; return the count confirmed removed. Idempotent (absent ids → 0).
147
+
148
+ The DELETE is unconditional over the FULL requested set — Qdrant no-ops ids that are absent, so the
149
+ deletion always completes and can never leave a requested id half-deleted regardless of any lookup.
150
+ Qdrant's delete does not report a removed-count, so the returned number comes from a separate
151
+ pre-read (`retrieve`) of how many requested ids were present; that count is best-effort and only
152
+ ever CONSERVATIVE (a transient partial read under-reports, never over-reports) — the state change
153
+ does not depend on it, so a conservative count never corresponds to data left behind."""
154
+ from qdrant_client import models as qm
155
+ ids = list(ids)
156
+ if not ids:
157
+ return 0
158
+ qc = self._qc()
159
+ if not qc.collection_exists(collection):
160
+ return 0
161
+ puids = [_point_uuid(i) for i in ids]
162
+ confirmed = len(qc.retrieve(collection_name=collection, ids=puids,
163
+ with_payload=False, with_vectors=False))
164
+ qc.delete(collection_name=collection, wait=True, # full requested set; absent ids are a no-op
165
+ points_selector=qm.PointIdsList(points=puids))
166
+ return confirmed
167
+
168
+ def existing_ids(self, collection: str) -> set:
169
+ return set(self._scroll_field(collection, _VID).keys())
170
+
171
+ def stored_shas(self, collection: str) -> Dict[str, str]:
172
+ return self._scroll_field(collection, _TEXT_SHA)
173
+
174
+ def capabilities(self) -> Dict[str, bool]:
175
+ return {"matrix": False, "filters": True, "batch": True}
176
+
177
+ # ── optional capability seam (unimplemented here → honest typed NotSupported) ──────────────────────
178
+ def matrix(self, collection: str) -> Tuple[List[str], np.ndarray]:
179
+ raise NotSupported(
180
+ "matrix (materialize all vectors for X @ Xᵀ analytics)", self.backend,
181
+ "qdrant does not implement matrix() — pulling a whole collection client-side defeats the "
182
+ "server ANN index. Use the sqlite_numpy backend for in-process matrix analytics, or run the "
183
+ "analysis as server-side searches.")
184
+
185
+ # ── internals ────────────────────────────────────────────────────────────────────────────────────
186
+ def _first_model(self, qc, collection: str):
187
+ """The model identity carried by any one point (they all carry the same), or None if the
188
+ collection is empty / predates the guard. A transport error PROPAGATES (not caught) so a failed
189
+ read never silently skips the model guard."""
190
+ points, _off = qc.scroll(collection_name=collection, limit=1,
191
+ with_payload=[_MODEL], with_vectors=False)
192
+ for p in points:
193
+ return (p.payload or {}).get(_MODEL)
194
+ return None
195
+
196
+ def _scroll_field(self, collection: str, field: str) -> Dict[str, str]:
197
+ """Scroll the whole collection, payload-only, collecting {_vid: field_value}. This is what makes
198
+ an ingest RESUMABLE (a point already present is never re-embedded).
199
+
200
+ A genuinely-absent collection returns {} (nothing stored yet). A transport/auth error PROPAGATES
201
+ — it must NOT read as 'no points', because that would make an ingest treat every existing point as
202
+ new and re-embed the whole corpus (the never-re-embed guarantee broken, real money burned)."""
203
+ qc = self._qc()
204
+ if not qc.collection_exists(collection):
205
+ return {}
206
+ out: Dict[str, str] = {}
207
+ offset = None
208
+ while True:
209
+ points, offset = qc.scroll(
210
+ collection_name=collection, limit=4000, offset=offset,
211
+ with_payload=[_VID, field], with_vectors=False)
212
+ for p in points:
213
+ pl = p.payload or {}
214
+ vid = pl.get(_VID)
215
+ if vid is not None:
216
+ out[vid] = pl.get(field)
217
+ if offset is None:
218
+ return out
219
+
220
+ def _build_filter(self, filters: Dict[str, Any]):
221
+ """Translate a generic filters dict into a Qdrant Filter — the metadata/ACL push-down MECHANISM;
222
+ the consumer builds its ACL POLICY on this (which principals/groups → which clauses).
223
+
224
+ Shapes:
225
+ {field: value} → must match value (MatchValue)
226
+ {field: [v1, v2]} → must match any of (MatchAny)
227
+ {"must": {...}, "should": {...}} → explicit AND / OR groups (e.g. an ACL 'should' set)
228
+ A `should` group is deny-by-default OR: at least one clause must hold (7thsense's ACL parity).
229
+ """
230
+ from qdrant_client import models as qm
231
+
232
+ def conds(d: Dict[str, Any]):
233
+ out = []
234
+ for key, val in d.items():
235
+ if isinstance(val, (list, tuple, set)):
236
+ out.append(qm.FieldCondition(key=key, match=qm.MatchAny(any=list(val))))
237
+ else:
238
+ out.append(qm.FieldCondition(key=key, match=qm.MatchValue(value=val)))
239
+ return out
240
+
241
+ if "must" in filters or "should" in filters:
242
+ must = conds(filters.get("must", {})) or None
243
+ should = conds(filters.get("should", {})) or None
244
+ return qm.Filter(must=must, should=should)
245
+ return qm.Filter(must=conds(filters))