etchmem 0.1.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.
@@ -0,0 +1,647 @@
1
+ """
2
+ ChromaStore — the bundled default storage implementation.
3
+
4
+ One PersistentClient, one data directory, three collections.
5
+ All three tiers share one client and one embedding function
6
+ (Chroma's bundled default embedding model — no external API required).
7
+
8
+ The same ChromaStore class is instantiated three times:
9
+ - relational_store = ChromaStore(client, "relational") → used as RelationalStore
10
+ - buffer_store = ChromaStore(client, "buffer") → used as InjectedStore
11
+ - injected_store = ChromaStore(client, "injected") → used as InjectedStore
12
+
13
+ Stores receive TEXT, not pre-computed vectors. ChromaDB handles vectorization.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import time
19
+ import uuid
20
+ from typing import Any, Iterable
21
+
22
+ import chromadb
23
+ from chromadb.config import Settings
24
+
25
+ from etchmem.models import (
26
+ Cluster,
27
+ Deposit,
28
+ InjectedArticle,
29
+ Record,
30
+ RecallEvent,
31
+ SearchResult,
32
+ )
33
+ from etchmem.stores.base import InjectedStore, RelationalStore
34
+
35
+
36
+ def _build_client(data_dir: str) -> chromadb.PersistentClient:
37
+ """Create or open the shared PersistentClient."""
38
+ return chromadb.PersistentClient(
39
+ path=data_dir,
40
+ settings=Settings(anonymized_telemetry=False),
41
+ )
42
+
43
+
44
+ # ── Optional lightweight embedding function for offline/test use ──────────────
45
+
46
+ def _hash_embed(texts) -> list[list[float]]:
47
+ """Compute deterministic 64-dim hash embeddings for a list of texts."""
48
+ import hashlib
49
+ results = []
50
+ for text in texts:
51
+ digest = hashlib.sha256(str(text).encode()).digest() # 32 bytes
52
+ # Expand to 64 floats in [0, 1] by repeating the 32-byte digest
53
+ vec = [b / 255.0 for b in digest] + [b / 255.0 for b in digest]
54
+ results.append(vec)
55
+ return results
56
+
57
+
58
+ class _HashEmbeddingFunction:
59
+ """
60
+ Deterministic hash-based embedding for offline environments (CI, tests,
61
+ air-gapped deployments). NOT semantic — do not use in production.
62
+
63
+ Produces a 64-dim float vector from SHA-256 bytes of each input text.
64
+ Enabled by passing embedding_function="hash" to build_stores().
65
+
66
+ Implements all methods that Chroma 1.5+ may call on an embedding function.
67
+ Does NOT inherit from EmbeddingFunction (it is a Protocol, not a class).
68
+ """
69
+
70
+ # Called for document inserts
71
+ def __call__(self, input) -> list[list[float]]:
72
+ return _hash_embed(list(input))
73
+
74
+ # Called for query vectors in Chroma 1.5+
75
+ def embed_query(self, input) -> list[list[float]]:
76
+ return _hash_embed(list(input))
77
+
78
+ # Required by Chroma's embedding-function registry / config serialisation
79
+ @classmethod
80
+ def name(cls) -> str:
81
+ return "skillmem-hash-offline"
82
+
83
+ @classmethod
84
+ def build_from_config(cls, config):
85
+ return cls()
86
+
87
+ def get_config(self) -> dict:
88
+ return {}
89
+
90
+ # Silences the "legacy embedding function" deprecation warning in Chroma 1.5
91
+ def is_legacy(self) -> bool:
92
+ return False
93
+
94
+ # Silences the "supported_spaces" deprecation warning in Chroma 1.5
95
+ @staticmethod
96
+ def supported_spaces() -> list[str]:
97
+ return ["cosine", "l2", "ip"]
98
+
99
+
100
+ # ── Helpers ───────────────────────────────────────────────────────────────────
101
+
102
+ def _result_to_search_result(
103
+ doc_id: str,
104
+ document: str,
105
+ distance: float,
106
+ metadata: dict[str, Any],
107
+ weight: float = 1.0,
108
+ source: str = "unknown",
109
+ ) -> SearchResult:
110
+ skill = metadata.get("skill") or None
111
+ return SearchResult(
112
+ id=doc_id,
113
+ content=document,
114
+ score=weight * (1.0 - distance), # convert distance → similarity score
115
+ source=source,
116
+ skill=skill,
117
+ metadata={k: v for k, v in metadata.items() if k != "skill"},
118
+ )
119
+
120
+
121
+ def _record_from_chroma(doc_id: str, document: str, metadata: dict) -> Record:
122
+ return Record(
123
+ id=doc_id,
124
+ content=document,
125
+ skill=metadata.get("skill") or None,
126
+ hint=metadata.get("hint"),
127
+ metadata=json.loads(metadata.get("extra_metadata", "{}")),
128
+ created_at=float(metadata.get("created_at", 0)),
129
+ expires_at=float(metadata.get("expires_at", 0)),
130
+ )
131
+
132
+
133
+ def _deposit_from_chroma(doc_id: str, document: str, metadata: dict) -> Deposit:
134
+ return Deposit(
135
+ id=doc_id,
136
+ content=document,
137
+ skill=metadata.get("skill") or None,
138
+ hint=metadata.get("hint"),
139
+ metadata=json.loads(metadata.get("extra_metadata", "{}")),
140
+ created_at=float(metadata.get("created_at", 0)),
141
+ doc_type="deposit",
142
+ )
143
+
144
+
145
+ def _recall_event_from_chroma(doc_id: str, document: str, metadata: dict) -> RecallEvent:
146
+ return RecallEvent(
147
+ id=doc_id,
148
+ query=metadata.get("query", ""),
149
+ relational_snapshot=metadata.get("relational_snapshot", ""),
150
+ skill=metadata.get("skill") or None,
151
+ hint=metadata.get("hint"),
152
+ metadata=json.loads(metadata.get("extra_metadata", "{}")),
153
+ created_at=float(metadata.get("created_at", 0)),
154
+ doc_type="recall_event",
155
+ )
156
+
157
+
158
+ def _injected_from_chroma(doc_id: str, document: str, metadata: dict) -> InjectedArticle:
159
+ source_hashes_raw = metadata.get("source_hashes", "[]")
160
+ try:
161
+ source_hashes = frozenset(json.loads(source_hashes_raw))
162
+ except (json.JSONDecodeError, TypeError):
163
+ source_hashes = frozenset()
164
+
165
+ tags_raw = metadata.get("tags", "{}")
166
+ try:
167
+ tags = json.loads(tags_raw)
168
+ except (json.JSONDecodeError, TypeError):
169
+ tags = {}
170
+
171
+ return InjectedArticle(
172
+ id=doc_id,
173
+ content=document,
174
+ source_hashes=source_hashes,
175
+ skill=metadata.get("skill") or None,
176
+ tags=tags,
177
+ metadata=json.loads(metadata.get("extra_metadata", "{}")),
178
+ created_at=float(metadata.get("created_at", 0)),
179
+ )
180
+
181
+
182
+ # ── Core ChromaStore ──────────────────────────────────────────────────────────
183
+
184
+ class ChromaStore:
185
+ """
186
+ Implements both RelationalStore and InjectedStore using a single
187
+ ChromaDB collection.
188
+
189
+ Instantiate once per logical tier (relational / buffer / injected),
190
+ all sharing the same `client`.
191
+ """
192
+
193
+ def __init__(
194
+ self,
195
+ client: chromadb.PersistentClient,
196
+ collection_name: str,
197
+ ttl_seconds: int = 7 * 24 * 3600,
198
+ embedding_function=None,
199
+ ) -> None:
200
+ """
201
+ Args:
202
+ embedding_function: Optional Chroma-compatible embedding function.
203
+ None (default) → Chroma's bundled ONNX model (requires
204
+ network on first use to download weights).
205
+ Pass embedding_function="hash" to use the offline
206
+ _HashEmbeddingFunction (deterministic but NOT semantic —
207
+ only suitable for tests / CI / air-gapped environments).
208
+ Pass any chromadb EmbeddingFunction subclass for custom use.
209
+ """
210
+ self._client = client
211
+ self._collection_name = collection_name
212
+ self._ttl_seconds = ttl_seconds
213
+
214
+ ef = None
215
+ if embedding_function == "hash":
216
+ ef = _HashEmbeddingFunction()
217
+ elif embedding_function is not None:
218
+ ef = embedding_function
219
+
220
+ kwargs: dict = {"name": collection_name, "metadata": {"hnsw:space": "cosine"}}
221
+ if ef is not None:
222
+ kwargs["embedding_function"] = ef
223
+
224
+ self._collection = client.get_or_create_collection(**kwargs)
225
+
226
+ # ── RelationalStore interface ─────────────────────────────────────────
227
+
228
+ def add(self, record: Record) -> str:
229
+ """Insert a Record into this collection. Sets expires_at."""
230
+ now = time.time()
231
+ expires_at = now + self._ttl_seconds
232
+ record.expires_at = expires_at
233
+ record.created_at = record.created_at or now
234
+
235
+ metadata: dict[str, Any] = {
236
+ "skill": record.skill or "",
237
+ "created_at": record.created_at,
238
+ "expires_at": expires_at,
239
+ "extra_metadata": json.dumps(record.metadata),
240
+ "doc_type": "record",
241
+ }
242
+ if record.hint is not None:
243
+ metadata["hint"] = record.hint
244
+
245
+ self._collection.upsert(
246
+ ids=[record.id],
247
+ documents=[record.content],
248
+ metadatas=[metadata],
249
+ )
250
+ return record.id
251
+
252
+ def search(
253
+ self,
254
+ query_text: str,
255
+ top_k: int,
256
+ skill: str | None = None,
257
+ filters: dict[str, Any] | None = None,
258
+ weight: float = 1.0,
259
+ source: str = "relational",
260
+ ) -> list[SearchResult]:
261
+ """ANN search; filters by skill metadata if provided."""
262
+ now = time.time()
263
+ where: dict[str, Any] = {"expires_at": {"$gt": now}}
264
+ if skill:
265
+ where = {"$and": [where, {"skill": {"$eq": skill}}]}
266
+ if filters:
267
+ for k, v in filters.items():
268
+ where = {"$and": [where, {k: {"$eq": v}}]}
269
+
270
+ try:
271
+ results = self._collection.query(
272
+ query_texts=[query_text],
273
+ n_results=min(top_k, max(1, self._collection.count())),
274
+ where=where,
275
+ include=["documents", "metadatas", "distances"],
276
+ )
277
+ except Exception:
278
+ return []
279
+
280
+ out: list[SearchResult] = []
281
+ if not results["ids"] or not results["ids"][0]:
282
+ return out
283
+ for doc_id, doc, dist, meta in zip(
284
+ results["ids"][0],
285
+ results["documents"][0],
286
+ results["distances"][0],
287
+ results["metadatas"][0],
288
+ ):
289
+ out.append(
290
+ _result_to_search_result(doc_id, doc, dist, meta, weight=weight, source=source)
291
+ )
292
+ return out
293
+
294
+ def expire(self) -> int:
295
+ """Delete all records with expires_at < now."""
296
+ now = time.time()
297
+ try:
298
+ # Fetch ids of expired records
299
+ result = self._collection.get(
300
+ where={"expires_at": {"$lt": now}},
301
+ include=[],
302
+ )
303
+ ids = result.get("ids", [])
304
+ if ids:
305
+ self._collection.delete(ids=ids)
306
+ return len(ids)
307
+ except Exception:
308
+ return 0
309
+
310
+ def iter_for_consolidation(
311
+ self,
312
+ num_records: int | str,
313
+ method: str,
314
+ ) -> Iterable[Record]:
315
+ """Yield records for the consolidation worker."""
316
+ now = time.time()
317
+ try:
318
+ result = self._collection.get(
319
+ where={"expires_at": {"$gt": now}},
320
+ include=["documents", "metadatas"],
321
+ )
322
+ except Exception:
323
+ return
324
+
325
+ records = [
326
+ _record_from_chroma(rid, doc, meta)
327
+ for rid, doc, meta in zip(
328
+ result.get("ids", []),
329
+ result.get("documents", []),
330
+ result.get("metadatas", []),
331
+ )
332
+ ]
333
+
334
+ # Sort by created_at
335
+ reverse = method.upper() == "LIFO"
336
+ records.sort(key=lambda r: r.created_at, reverse=reverse)
337
+
338
+ if num_records != "all":
339
+ records = records[: int(num_records)]
340
+
341
+ yield from records
342
+
343
+ def count(self) -> int:
344
+ return self._collection.count()
345
+
346
+ def get_by_id(self, record_id: str) -> Record | None:
347
+ try:
348
+ result = self._collection.get(
349
+ ids=[record_id],
350
+ include=["documents", "metadatas"],
351
+ )
352
+ if not result["ids"]:
353
+ return None
354
+ return _record_from_chroma(
355
+ result["ids"][0],
356
+ result["documents"][0],
357
+ result["metadatas"][0],
358
+ )
359
+ except Exception:
360
+ return None
361
+
362
+ # ── InjectedStore interface ───────────────────────────────────────────
363
+
364
+ def upsert(self, items: list[InjectedArticle | Deposit | RecallEvent]) -> None:
365
+ """Write or overwrite items into this collection."""
366
+ ids, documents, metadatas = [], [], []
367
+ for item in items:
368
+ meta: dict[str, Any] = {
369
+ "created_at": getattr(item, "created_at", time.time()),
370
+ "skill": item.skill or "",
371
+ "extra_metadata": json.dumps(getattr(item, "metadata", {})),
372
+ }
373
+ if isinstance(item, InjectedArticle):
374
+ meta["doc_type"] = "injected"
375
+ meta["source_hashes"] = json.dumps(sorted(item.source_hashes))
376
+ meta["tags"] = json.dumps(item.tags)
377
+ document = item.content
378
+ elif isinstance(item, RecallEvent):
379
+ meta["doc_type"] = "recall_event"
380
+ meta["query"] = item.query
381
+ meta["relational_snapshot"] = item.relational_snapshot
382
+ if item.hint is not None:
383
+ meta["hint"] = item.hint
384
+ # For embedding purposes, use the query + snapshot as the document
385
+ document = f"{item.query}\n{item.relational_snapshot}"
386
+ elif isinstance(item, Deposit):
387
+ meta["doc_type"] = "deposit"
388
+ if item.hint is not None:
389
+ meta["hint"] = item.hint
390
+ document = item.content
391
+ else:
392
+ continue
393
+
394
+ ids.append(item.id)
395
+ documents.append(document)
396
+ metadatas.append(meta)
397
+
398
+ if ids:
399
+ self._collection.upsert(
400
+ ids=ids,
401
+ documents=documents,
402
+ metadatas=metadatas,
403
+ )
404
+
405
+ def query(
406
+ self,
407
+ query_text: str,
408
+ top_k: int,
409
+ filters: dict[str, Any] | None = None,
410
+ weight: float = 1.0,
411
+ source: str = "injected",
412
+ ) -> list[SearchResult]:
413
+ """ANN search this collection."""
414
+ count = self._collection.count()
415
+ if count == 0:
416
+ return []
417
+
418
+ where = None
419
+ if filters:
420
+ clauses = [{"$eq": {k: v}} for k, v in filters.items()] # type: ignore[dict-item]
421
+ # Use simple single-key filter directly
422
+ if len(filters) == 1:
423
+ k, v = next(iter(filters.items()))
424
+ where = {k: {"$eq": v}}
425
+ else:
426
+ where = {"$and": [{k: {"$eq": v}} for k, v in filters.items()]}
427
+
428
+ try:
429
+ results = self._collection.query(
430
+ query_texts=[query_text],
431
+ n_results=min(top_k, count),
432
+ where=where,
433
+ include=["documents", "metadatas", "distances"],
434
+ )
435
+ except Exception:
436
+ return []
437
+
438
+ out: list[SearchResult] = []
439
+ if not results["ids"] or not results["ids"][0]:
440
+ return out
441
+ for doc_id, doc, dist, meta in zip(
442
+ results["ids"][0],
443
+ results["documents"][0],
444
+ results["distances"][0],
445
+ results["metadatas"][0],
446
+ ):
447
+ out.append(
448
+ _result_to_search_result(doc_id, doc, dist, meta, weight=weight, source=source)
449
+ )
450
+ return out
451
+
452
+ def delete(self, ids: list[str]) -> None:
453
+ """Hard-delete by id. Deleting non-existent ids is a no-op."""
454
+ if not ids:
455
+ return
456
+ # Chroma silently ignores missing ids
457
+ try:
458
+ self._collection.delete(ids=ids)
459
+ except Exception:
460
+ pass
461
+
462
+ def exists(self, content_hash: str) -> bool:
463
+ """Return True if a document with this id exists."""
464
+ try:
465
+ result = self._collection.get(ids=[content_hash], include=[])
466
+ return bool(result.get("ids"))
467
+ except Exception:
468
+ return False
469
+
470
+ def cluster(
471
+ self,
472
+ documents: list[Deposit | RecallEvent],
473
+ branch_threshold: float,
474
+ ) -> list[Cluster]:
475
+ """
476
+ Group documents into Clusters via embedding-based similarity.
477
+
478
+ Implementation: single-link agglomerative clustering using Chroma
479
+ embeddings obtained via query. Each un-clustered document queries
480
+ all others and merges those within the branch_threshold.
481
+
482
+ This is a best-effort, in-process clustering; for v0 correctness
483
+ is prioritised over performance.
484
+ """
485
+ if not documents:
486
+ return []
487
+
488
+ # Obtain embeddings by doing pairwise queries
489
+ # For simplicity in v0, use a greedy single-pass nearest-neighbour
490
+ # approach: sort by created_at, then greedily assign to clusters.
491
+ # A document joins an existing cluster if it is within branch_threshold
492
+ # distance from ANY member of that cluster (single-link).
493
+
494
+ # We'll use Chroma's query to get distances between documents.
495
+ # First, upsert all documents into a temporary scratch space if they're
496
+ # not already in this collection, then query.
497
+
498
+ clusters: list[list[Deposit | RecallEvent]] = []
499
+ assigned: set[str] = set()
500
+
501
+ # Build a lookup
502
+ doc_map = {d.id: d for d in documents}
503
+
504
+ for doc in documents:
505
+ if doc.id in assigned:
506
+ continue
507
+
508
+ # Query this collection for the closest documents
509
+ doc_text = doc.content if isinstance(doc, Deposit) else f"{doc.query}\n{doc.relational_snapshot}"
510
+ count = self._collection.count()
511
+ if count == 0:
512
+ clusters.append([doc])
513
+ assigned.add(doc.id)
514
+ continue
515
+
516
+ try:
517
+ results = self._collection.query(
518
+ query_texts=[doc_text],
519
+ n_results=min(len(documents), count),
520
+ include=["metadatas", "distances"],
521
+ )
522
+ except Exception:
523
+ clusters.append([doc])
524
+ assigned.add(doc.id)
525
+ continue
526
+
527
+ similar_ids: list[str] = []
528
+ if results["ids"] and results["ids"][0]:
529
+ for rid, dist in zip(results["ids"][0], results["distances"][0]):
530
+ if rid in doc_map and rid not in assigned and dist <= branch_threshold:
531
+ similar_ids.append(rid)
532
+
533
+ cluster_members: list[Deposit | RecallEvent] = [doc]
534
+ assigned.add(doc.id)
535
+ for sid in similar_ids:
536
+ if sid not in assigned:
537
+ cluster_members.append(doc_map[sid])
538
+ assigned.add(sid)
539
+
540
+ clusters.append(cluster_members)
541
+
542
+ # Convert to Cluster objects
543
+ result_clusters: list[Cluster] = []
544
+ for i, members in enumerate(clusters):
545
+ centroid_text = members[0].content if isinstance(members[0], Deposit) else members[0].query
546
+ result_clusters.append(
547
+ Cluster(
548
+ cluster_id=str(uuid.uuid4()),
549
+ documents=members,
550
+ centroid_text=centroid_text,
551
+ )
552
+ )
553
+ return result_clusters
554
+
555
+ def get_all(
556
+ self,
557
+ filters: dict[str, Any] | None = None,
558
+ ) -> list[Deposit | RecallEvent | InjectedArticle]:
559
+ """Return all documents, optionally filtered by metadata."""
560
+ try:
561
+ where = None
562
+ if filters:
563
+ if len(filters) == 1:
564
+ k, v = next(iter(filters.items()))
565
+ where = {k: {"$eq": v}}
566
+ else:
567
+ where = {"$and": [{k: {"$eq": v}} for k, v in filters.items()]}
568
+
569
+ result = self._collection.get(
570
+ where=where,
571
+ include=["documents", "metadatas"],
572
+ )
573
+ except Exception:
574
+ return []
575
+
576
+ out: list[Deposit | RecallEvent | InjectedArticle] = []
577
+ for rid, doc, meta in zip(
578
+ result.get("ids", []),
579
+ result.get("documents", []),
580
+ result.get("metadatas", []),
581
+ ):
582
+ doc_type = meta.get("doc_type", "deposit")
583
+ if doc_type == "recall_event":
584
+ out.append(_recall_event_from_chroma(rid, doc, meta))
585
+ elif doc_type == "injected":
586
+ out.append(_injected_from_chroma(rid, doc, meta))
587
+ else:
588
+ out.append(_deposit_from_chroma(rid, doc, meta))
589
+ return out
590
+
591
+ def get_neighbors(
592
+ self,
593
+ query_text: str,
594
+ top_k: int,
595
+ distance_threshold: float,
596
+ ) -> list[SearchResult]:
597
+ """ANN search with a distance cut-off."""
598
+ count = self._collection.count()
599
+ if count == 0:
600
+ return []
601
+ try:
602
+ results = self._collection.query(
603
+ query_texts=[query_text],
604
+ n_results=min(top_k, count),
605
+ include=["documents", "metadatas", "distances"],
606
+ )
607
+ except Exception:
608
+ return []
609
+
610
+ out: list[SearchResult] = []
611
+ if not results["ids"] or not results["ids"][0]:
612
+ return out
613
+ for doc_id, doc, dist, meta in zip(
614
+ results["ids"][0],
615
+ results["documents"][0],
616
+ results["distances"][0],
617
+ results["metadatas"][0],
618
+ ):
619
+ if dist <= distance_threshold:
620
+ out.append(
621
+ _result_to_search_result(doc_id, doc, dist, meta, source="injected")
622
+ )
623
+ return out
624
+
625
+
626
+ # ── Factory ───────────────────────────────────────────────────────────────────
627
+
628
+ def build_stores(
629
+ data_dir: str,
630
+ collection_relational: str,
631
+ collection_buffer: str,
632
+ collection_injected: str,
633
+ ttl_seconds: int,
634
+ embedding_function=None,
635
+ ) -> tuple[ChromaStore, ChromaStore, ChromaStore]:
636
+ """
637
+ Build the shared PersistentClient and return three ChromaStore instances:
638
+ (relational_store, buffer_store, injected_store).
639
+
640
+ All three collections share the same embedding_function.
641
+ Pass embedding_function="hash" for offline/test use.
642
+ """
643
+ client = _build_client(data_dir)
644
+ relational = ChromaStore(client, collection_relational, ttl_seconds=ttl_seconds, embedding_function=embedding_function)
645
+ buffer = ChromaStore(client, collection_buffer, ttl_seconds=ttl_seconds, embedding_function=embedding_function)
646
+ injected = ChromaStore(client, collection_injected, ttl_seconds=ttl_seconds, embedding_function=embedding_function)
647
+ return relational, buffer, injected
File without changes