veccore 0.2.0__py3-none-any.whl

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.
veccore/__init__.py ADDED
@@ -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))
@@ -0,0 +1,162 @@
1
+ """SqliteNumpyStore — the in-process backend (backend id "sqlite_numpy", never "db").
2
+
3
+ For a corpus of thousands-to-low-hundred-thousands of vectors, brute-force cosine in numpy is ~ms and
4
+ keeps the consumer self-contained / portable — no vector server (warden's design). SQLite holds the
5
+ vectors + their text_sha + payload, keyed by (collection, id); search is one L2-normalized matrix and a
6
+ dot product (both sides unit vectors ⇒ dot == cosine).
7
+
8
+ Capabilities: implements the OPTIONAL `matrix()` seam (the whole point of an in-process store — X @ Xᵀ
9
+ analytics: kNN, centroid, edge-gating). Does NOT implement `search(filters=…)` push-down — that is
10
+ qdrant's ACL/metadata seam; a filters request here raises typed NotSupported (never a silent ignore,
11
+ which for an ACL filter would leak). No Batch API (that is qdrant/openai's ingest seam).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import sqlite3
18
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
19
+
20
+ import numpy as np
21
+
22
+ from ..errors import DimMismatch, ModelMismatch, NotSupported, Unavailable
23
+ from ..ports import Hit, Point
24
+
25
+ BACKEND = "sqlite_numpy"
26
+
27
+
28
+ class SqliteNumpyStore:
29
+ backend = BACKEND
30
+
31
+ def __init__(self, path: str = ":memory:"):
32
+ self.path = path if path == ":memory:" else os.path.expanduser(path)
33
+ if self.path != ":memory:":
34
+ os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
35
+ try:
36
+ self._con = sqlite3.connect(self.path)
37
+ except sqlite3.Error as e:
38
+ raise Unavailable(f"sqlite_numpy store at {self.path} could not be opened "
39
+ f"({type(e).__name__}: {str(e)[:80]})", code="store_unusable") from e
40
+ self._con.execute("PRAGMA journal_mode=WAL")
41
+ self._ensure_schema()
42
+ # a small matrix cache keyed by (collection, row-count) so repeated searches within a process
43
+ # don't reload the whole table each time; the row-count auto-invalidates after an upsert.
44
+ self._matrix_cache: Dict[str, Tuple[int, List[str], np.ndarray]] = {}
45
+
46
+ def _ensure_schema(self) -> None:
47
+ self._con.execute(
48
+ "CREATE TABLE IF NOT EXISTS collections(name TEXT PRIMARY KEY, dim INTEGER NOT NULL)")
49
+ # additive migration: a nullable `model` column for the model-identity guard. A db created before
50
+ # this column has it added here as NULL (an existing collection's model is 'unknown' → adopted, not
51
+ # refused, on its next ensure(model=...)).
52
+ cols = {r[1] for r in self._con.execute("PRAGMA table_info(collections)")}
53
+ if "model" not in cols:
54
+ self._con.execute("ALTER TABLE collections ADD COLUMN model TEXT")
55
+ self._con.execute(
56
+ "CREATE TABLE IF NOT EXISTS points("
57
+ " collection TEXT NOT NULL, id TEXT NOT NULL, text_sha TEXT NOT NULL,"
58
+ " dim INTEGER NOT NULL, vec BLOB NOT NULL, payload TEXT NOT NULL,"
59
+ " PRIMARY KEY(collection, id))")
60
+ self._con.commit()
61
+
62
+ # ── core port ──────────────────────────────────────────────────────────────────────────────────────
63
+ def ensure(self, collection: str, dim: int, model: str = None) -> None:
64
+ row = self._con.execute(
65
+ "SELECT dim, model FROM collections WHERE name=?", (collection,)).fetchone()
66
+ if row is not None:
67
+ stored_dim, stored_model = row
68
+ if stored_dim != dim:
69
+ raise DimMismatch(collection, stored_dim, dim) # 7thsense guard, kept as a core invariant
70
+ if model is not None:
71
+ if stored_model is not None and stored_model != model:
72
+ raise ModelMismatch(collection, stored_model, model)
73
+ if stored_model is None:
74
+ # unknown stored model (legacy collection) → ADOPT the requested one, don't refuse
75
+ self._con.execute("UPDATE collections SET model=? WHERE name=?", (model, collection))
76
+ self._con.commit()
77
+ return
78
+ self._con.execute("INSERT INTO collections(name, dim, model) VALUES(?,?,?)",
79
+ (collection, dim, model))
80
+ self._con.commit()
81
+
82
+ def upsert(self, collection: str, points: Sequence[Point]) -> None:
83
+ rows = []
84
+ for p in points:
85
+ v = np.asarray(p.vector, dtype=np.float32).ravel()
86
+ rows.append((collection, p.id, p.text_sha, int(v.shape[0]), v.tobytes(),
87
+ json.dumps(p.payload, ensure_ascii=False)))
88
+ with self._con:
89
+ self._con.executemany(
90
+ "INSERT OR REPLACE INTO points(collection,id,text_sha,dim,vec,payload) "
91
+ "VALUES(?,?,?,?,?,?)", rows)
92
+ self._matrix_cache.pop(collection, None) # index changed → drop cached matrix
93
+
94
+ def search(self, collection: str, qvec: np.ndarray, k: int,
95
+ filters: Optional[Dict[str, Any]] = None) -> List[Hit]:
96
+ if filters:
97
+ # NEVER silently ignore a filter — for an ACL filter that would leak. Honest typed refusal.
98
+ raise NotSupported(
99
+ "search filters (metadata/ACL push-down)", self.backend,
100
+ "sqlite_numpy does not implement filter push-down — use the qdrant backend for ACL/"
101
+ "metadata-filtered search, or probe capabilities() before passing filters.")
102
+ ids, M, payloads = self._load(collection)
103
+ if not ids:
104
+ return []
105
+ q = np.asarray(qvec, dtype=np.float32).ravel()
106
+ qn = q / max(float(np.linalg.norm(q)), 1e-9)
107
+ scores = M @ qn # rows are stored L2-normalized ⇒ dot == cosine
108
+ order = np.argsort(-scores)[:max(0, k)]
109
+ return [Hit(id=ids[i], score=float(scores[i]), payload=payloads[i]) for i in order]
110
+
111
+ def delete(self, collection: str, ids: Sequence[str]) -> int:
112
+ """Remove points by id; return the count removed. Idempotent (absent ids → 0). Chunks the IN-list
113
+ and invalidates the collection's matrix cache so a later search/matrix reflects the removal."""
114
+ ids = list(ids)
115
+ if not ids:
116
+ return 0
117
+ removed = 0
118
+ with self._con:
119
+ for i in range(0, len(ids), 500):
120
+ chunk = ids[i:i + 500]
121
+ marks = ",".join("?" * len(chunk))
122
+ cur = self._con.execute(
123
+ f"DELETE FROM points WHERE collection=? AND id IN ({marks})", [collection, *chunk])
124
+ removed += cur.rowcount
125
+ self._matrix_cache.pop(collection, None)
126
+ return removed
127
+
128
+ def existing_ids(self, collection: str) -> set:
129
+ return {r[0] for r in self._con.execute(
130
+ "SELECT id FROM points WHERE collection=?", (collection,))}
131
+
132
+ def stored_shas(self, collection: str) -> Dict[str, str]:
133
+ return {r[0]: r[1] for r in self._con.execute(
134
+ "SELECT id, text_sha FROM points WHERE collection=?", (collection,))}
135
+
136
+ def capabilities(self) -> Dict[str, bool]:
137
+ return {"matrix": True, "filters": False, "batch": False}
138
+
139
+ # ── optional capability seam ─────────────────────────────────────────────────────────────────────
140
+ def matrix(self, collection: str) -> Tuple[List[str], np.ndarray]:
141
+ ids, M, _payloads = self._load(collection)
142
+ return ids, M
143
+
144
+ # ── internals ────────────────────────────────────────────────────────────────────────────────────
145
+ def _load(self, collection: str) -> Tuple[List[str], np.ndarray, List[Dict[str, Any]]]:
146
+ n = self._con.execute("SELECT COUNT(*) FROM points WHERE collection=?", (collection,)).fetchone()[0]
147
+ cached = self._matrix_cache.get(collection)
148
+ ids: List[str] = []
149
+ vecs: List[np.ndarray] = []
150
+ payloads: List[Dict[str, Any]] = []
151
+ for id_, dim, blob, payload in self._con.execute(
152
+ "SELECT id, dim, vec, payload FROM points WHERE collection=? ORDER BY id", (collection,)):
153
+ ids.append(id_)
154
+ vecs.append(np.frombuffer(blob, dtype=np.float32))
155
+ payloads.append(json.loads(payload))
156
+ M = np.vstack(vecs).astype(np.float32) if vecs else np.zeros((0, 0), dtype=np.float32)
157
+ if cached is None or cached[0] != n:
158
+ self._matrix_cache[collection] = (n, ids, M)
159
+ return ids, M, payloads
160
+
161
+ def close(self) -> None:
162
+ self._con.close()
veccore/audit.py ADDED
@@ -0,0 +1,100 @@
1
+ """The audit envelope — WHERE / WHO / WHAT / WHY on every veccore call (auditability + observability).
2
+
3
+ The user requirement: a caller must say to WHICH store it is sending things (no hidden default store),
4
+ and every call must record WHAT is being sent, WHO is sending it, and WHY — so the estate has a complete
5
+ audit trail of every embed/upsert/search and an observability feed it can watch live.
6
+
7
+ The envelope is REQUIRED by every core call. `who`/`why` the caller supplies (veccore refuses a blank
8
+ `why` — an un-reasoned spend/mutation is exactly what this exists to prevent). `where`/`what` veccore
9
+ fills from the call itself (collection + resolved backend; operation + item count + sha summary). The
10
+ completed envelope is emitted to an INJECTED ObserverPort, so the audit sink (a JSONL file, ccwatch, an
11
+ OTel exporter) composes WITHOUT veccore importing any of them — the same acyclic discipline as the gate.
12
+
13
+ Library sinks are DURABLE by construction: NullObserver (discard) and JsonlObserver (append-only, fsync'd
14
+ file of record). An in-memory capturing observer is test/inspection tooling and lives with the tests, not
15
+ here — the library never makes an in-process list the sole record of an audit trail.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import threading
22
+ from dataclasses import asdict, dataclass, field
23
+ from typing import Any, Dict, Optional, Protocol, runtime_checkable
24
+
25
+
26
+ @dataclass
27
+ class AuditEnvelope:
28
+ """One auditable veccore operation.
29
+
30
+ Caller-supplied (required): `who` (the consumer, e.g. "warden", "7thsense", "saga"), `why` (a human
31
+ intent string). veccore-filled: `where` (collection + backend), `op`, `n_items`, `n_embedded`,
32
+ `n_skipped`, `sha_summary`, `est_usd`, `outcome`, `error`.
33
+ """
34
+ who: str
35
+ why: str
36
+ # veccore fills these as the call executes
37
+ op: str = ""
38
+ collection: str = ""
39
+ backend: str = ""
40
+ n_items: int = 0
41
+ n_embedded: int = 0
42
+ n_skipped: int = 0
43
+ sha_summary: str = ""
44
+ est_usd: Optional[float] = None
45
+ outcome: str = "started" # started | ok | refused | error
46
+ error: str = ""
47
+ extra: Dict[str, Any] = field(default_factory=dict)
48
+
49
+ def __post_init__(self):
50
+ # WHY IS REQUIRED. An un-reasoned embed/upsert/search is the thing this envelope exists to make
51
+ # impossible — refuse a blank one loudly rather than record an empty audit line.
52
+ if not (self.who or "").strip():
53
+ raise ValueError("AuditEnvelope.who is required — name the calling consumer.")
54
+ if not (self.why or "").strip():
55
+ raise ValueError("AuditEnvelope.why is required — state why this call is being made "
56
+ "(auditability: every embed/upsert/search records its intent).")
57
+
58
+ def where(self) -> str:
59
+ """The human-readable target: 'collection@backend'. Filled once the core resolves the backend."""
60
+ return f"{self.collection}@{self.backend}" if self.backend else self.collection
61
+
62
+ def to_dict(self) -> Dict[str, Any]:
63
+ d = asdict(self)
64
+ d["where"] = self.where()
65
+ return d
66
+
67
+
68
+ @runtime_checkable
69
+ class ObserverPort(Protocol):
70
+ """The injected audit/observability sink. `emit` is called once per completed operation with the
71
+ filled envelope. Implementations must never raise back into veccore (observability must not break the
72
+ operation) — the core guards the call, but a well-behaved observer swallows its own errors too."""
73
+
74
+ def emit(self, envelope: AuditEnvelope) -> None: ...
75
+
76
+
77
+ class NullObserver:
78
+ """Default sink: discard. veccore works with no observability configured; a consumer opts in."""
79
+
80
+ def emit(self, envelope: AuditEnvelope) -> None:
81
+ pass
82
+
83
+
84
+ class JsonlObserver:
85
+ """Durable append-only audit log — one JSON line per operation. Append-only + per-record flush + fsync
86
+ so a long run's audit trail is visible live and survives a crash (the durable-log standard). The file
87
+ is the estate's veccore audit of record; a cockpit/OTel exporter can tail it."""
88
+
89
+ def __init__(self, path: str):
90
+ self.path = os.path.expanduser(path)
91
+ os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
92
+ self._lock = threading.Lock()
93
+
94
+ def emit(self, envelope: AuditEnvelope) -> None:
95
+ line = json.dumps(envelope.to_dict(), ensure_ascii=False, default=str)
96
+ with self._lock:
97
+ with open(self.path, "a", encoding="utf-8") as fh:
98
+ fh.write(line + "\n")
99
+ fh.flush()
100
+ os.fsync(fh.fileno())