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.
- etchmem/__init__.py +25 -0
- etchmem/config.py +109 -0
- etchmem/connector/__init__.py +0 -0
- etchmem/connector/base.py +35 -0
- etchmem/connector/remote.py +121 -0
- etchmem/consolidate/__init__.py +0 -0
- etchmem/consolidate/worker.py +559 -0
- etchmem/engine.py +191 -0
- etchmem/models.py +147 -0
- etchmem/recall/__init__.py +0 -0
- etchmem/recall/recaller.py +154 -0
- etchmem/stores/__init__.py +0 -0
- etchmem/stores/base.py +171 -0
- etchmem/stores/chroma_store.py +647 -0
- etchmem/util/__init__.py +0 -0
- etchmem/util/affectability.py +104 -0
- etchmem/util/hashing.py +49 -0
- etchmem-0.1.0.dist-info/METADATA +29 -0
- etchmem-0.1.0.dist-info/RECORD +22 -0
- etchmem-0.1.0.dist-info/WHEEL +5 -0
- etchmem-0.1.0.dist-info/licenses/LICENSE +201 -0
- etchmem-0.1.0.dist-info/top_level.txt +1 -0
etchmem/engine.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Engine — the public entry point.
|
|
3
|
+
|
|
4
|
+
Wires together:
|
|
5
|
+
- three ChromaStore instances (relational, buffer, injected)
|
|
6
|
+
- one RemoteConnector for LLM synthesis
|
|
7
|
+
- Recaller (recall logic + recall-event emission)
|
|
8
|
+
- Worker (consolidation pipeline)
|
|
9
|
+
|
|
10
|
+
Exposes exactly three public methods:
|
|
11
|
+
remember(data, hint, skill, metadata) → None
|
|
12
|
+
recall(query, skill, top_k, hint) → list[SearchResult]
|
|
13
|
+
consolidate(num_records, method) → dict (summary)
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import time
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from etchmem.config import Config
|
|
21
|
+
from etchmem.models import Record, SearchResult
|
|
22
|
+
from etchmem.stores.chroma_store import build_stores
|
|
23
|
+
from etchmem.util.hashing import content_hash
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Engine:
|
|
27
|
+
"""
|
|
28
|
+
The skillmem engine.
|
|
29
|
+
|
|
30
|
+
Usage::
|
|
31
|
+
|
|
32
|
+
from etchmem import Engine
|
|
33
|
+
|
|
34
|
+
engine = Engine() # uses .skillmem/ in cwd
|
|
35
|
+
engine.remember("Python 3.14 ships with a new JIT by default")
|
|
36
|
+
results = engine.recall("what changed in Python 3.14?")
|
|
37
|
+
summary = engine.consolidate()
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
config: Config | None = None,
|
|
43
|
+
connector=None, # LLMConnector | None; lazy-built if None
|
|
44
|
+
) -> None:
|
|
45
|
+
from etchmem.config import DEFAULT_CONFIG
|
|
46
|
+
self._cfg = config or DEFAULT_CONFIG
|
|
47
|
+
|
|
48
|
+
# Build stores
|
|
49
|
+
self._relational, self._buffer, self._injected = build_stores(
|
|
50
|
+
data_dir=self._cfg.data_dir,
|
|
51
|
+
collection_relational=self._cfg.collection_relational,
|
|
52
|
+
collection_buffer=self._cfg.collection_buffer,
|
|
53
|
+
collection_injected=self._cfg.collection_injected,
|
|
54
|
+
ttl_seconds=self._cfg.relational_ttl_seconds,
|
|
55
|
+
embedding_function=self._cfg.embedding_function,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Connector — built lazily on first consolidate() if not provided
|
|
59
|
+
self._connector = connector
|
|
60
|
+
self._connector_built = connector is not None
|
|
61
|
+
|
|
62
|
+
# Wires
|
|
63
|
+
from etchmem.recall.recaller import Recaller
|
|
64
|
+
self._recaller = Recaller(
|
|
65
|
+
relational_store=self._relational,
|
|
66
|
+
buffer_store=self._buffer,
|
|
67
|
+
injected_store=self._injected,
|
|
68
|
+
config=self._cfg,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# ── Public API ────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
def remember(
|
|
74
|
+
self,
|
|
75
|
+
data: str,
|
|
76
|
+
hint: float | None = None,
|
|
77
|
+
skill: str | None = None,
|
|
78
|
+
metadata: dict[str, Any] | None = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""
|
|
81
|
+
Deposit a raw record into the relational collection.
|
|
82
|
+
|
|
83
|
+
No LLM call, no clustering. Cheap — embedding computed by Chroma
|
|
84
|
+
on insert.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
data: The text content to remember.
|
|
88
|
+
hint: Optional importance prior (float 0–1). Seed, not truth.
|
|
89
|
+
skill: Optional skill scope name (e.g. "summarizer").
|
|
90
|
+
metadata: Arbitrary dict (source URL, tags, etc.).
|
|
91
|
+
"""
|
|
92
|
+
record = Record(
|
|
93
|
+
id=content_hash(data),
|
|
94
|
+
content=data,
|
|
95
|
+
skill=skill,
|
|
96
|
+
hint=hint,
|
|
97
|
+
metadata=metadata or {},
|
|
98
|
+
created_at=time.time(),
|
|
99
|
+
)
|
|
100
|
+
self._relational.add(record)
|
|
101
|
+
|
|
102
|
+
def recall(
|
|
103
|
+
self,
|
|
104
|
+
query: str,
|
|
105
|
+
skill: str | None = None,
|
|
106
|
+
top_k: int | None = None,
|
|
107
|
+
hint: float | None = None,
|
|
108
|
+
) -> list[SearchResult]:
|
|
109
|
+
"""
|
|
110
|
+
Retrieve knowledge AND emit a recall-event for future reconsolidation.
|
|
111
|
+
|
|
112
|
+
Two things happen:
|
|
113
|
+
1. Returns merged results from injected (primary) + relational (fresh).
|
|
114
|
+
2. Writes a RecallEvent into the buffer so the worker can rewrite
|
|
115
|
+
used knowledge against fresh context at the next consolidate().
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
query: Natural language query.
|
|
119
|
+
skill: Optional skill scope filter.
|
|
120
|
+
top_k: Max results. Default from config.
|
|
121
|
+
hint: Importance prior seeded into the recall-event.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
List of SearchResult, highest score first.
|
|
125
|
+
"""
|
|
126
|
+
return self._recaller.recall(
|
|
127
|
+
query=query,
|
|
128
|
+
skill=skill,
|
|
129
|
+
top_k=top_k,
|
|
130
|
+
hint=hint,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def consolidate(
|
|
134
|
+
self,
|
|
135
|
+
num_records: int | str | None = None,
|
|
136
|
+
method: str | None = None,
|
|
137
|
+
) -> dict[str, Any]:
|
|
138
|
+
"""
|
|
139
|
+
Run the consolidation worker.
|
|
140
|
+
|
|
141
|
+
Processes the buffer: forms new knowledge, reconsolidates recalled
|
|
142
|
+
knowledge, hard-deletes superseded articles. Explicitly called in v0
|
|
143
|
+
(predictable, demoable, debuggable).
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
num_records: How many relational deposits to pull ("all" or int N).
|
|
147
|
+
method: Ordering: "LIFO" (newest first, default) or "FIFO".
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Summary dict with counts of: formed, reconsolidated, dropped, kept,
|
|
151
|
+
flushed, superseded.
|
|
152
|
+
"""
|
|
153
|
+
self._ensure_connector()
|
|
154
|
+
|
|
155
|
+
from etchmem.consolidate.worker import Worker
|
|
156
|
+
worker = Worker(
|
|
157
|
+
relational_store=self._relational,
|
|
158
|
+
buffer_store=self._buffer,
|
|
159
|
+
injected_store=self._injected,
|
|
160
|
+
connector=self._connector,
|
|
161
|
+
config=self._cfg,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
num_records = num_records if num_records is not None else self._cfg.consolidate_default_num_records
|
|
165
|
+
method = method or self._cfg.consolidate_default_method
|
|
166
|
+
|
|
167
|
+
return worker.run(num_records=num_records, method=method)
|
|
168
|
+
|
|
169
|
+
# ── Internals ─────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
def _ensure_connector(self) -> None:
|
|
172
|
+
"""Lazy-build the LLM connector on first consolidate() call."""
|
|
173
|
+
if self._connector_built:
|
|
174
|
+
return
|
|
175
|
+
from etchmem.connector.remote import build_connector
|
|
176
|
+
self._connector = build_connector(self._cfg)
|
|
177
|
+
self._connector_built = True
|
|
178
|
+
|
|
179
|
+
# ── Convenience properties ────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def config(self) -> Config:
|
|
183
|
+
return self._cfg
|
|
184
|
+
|
|
185
|
+
def stats(self) -> dict[str, int]:
|
|
186
|
+
"""Return collection sizes for monitoring."""
|
|
187
|
+
return {
|
|
188
|
+
"relational": self._relational.count(),
|
|
189
|
+
"buffer": self._buffer.count(),
|
|
190
|
+
"injected": self._injected.count(),
|
|
191
|
+
}
|
etchmem/models.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""
|
|
2
|
+
skillmem data models.
|
|
3
|
+
|
|
4
|
+
All structs are plain dataclasses — no ORM, no Pydantic dependency.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ── Raw deposit written by remember() ────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Record:
|
|
17
|
+
"""
|
|
18
|
+
A raw signal record deposited via remember().
|
|
19
|
+
Stored in the *relational* collection.
|
|
20
|
+
Immutable after creation — relational is append-only.
|
|
21
|
+
"""
|
|
22
|
+
id: str # content hash — computed before insert
|
|
23
|
+
content: str
|
|
24
|
+
skill: str | None = None # optional skill scope
|
|
25
|
+
hint: float | None = None # agent-supplied importance prior 0–1
|
|
26
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
27
|
+
created_at: float = field(default_factory=time.time)
|
|
28
|
+
expires_at: float = 0.0 # unix timestamp; set by store on insert
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ── Buffer documents ──────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Deposit:
|
|
35
|
+
"""
|
|
36
|
+
A relational record copied into the *buffer* collection at intake.
|
|
37
|
+
Carries the original content hash for idempotency.
|
|
38
|
+
"""
|
|
39
|
+
id: str # same content hash as the source Record
|
|
40
|
+
content: str
|
|
41
|
+
skill: str | None = None
|
|
42
|
+
hint: float | None = None
|
|
43
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
44
|
+
created_at: float = field(default_factory=time.time)
|
|
45
|
+
doc_type: str = "deposit" # discriminator stored as metadata
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class RecallEvent:
|
|
50
|
+
"""
|
|
51
|
+
Emitted by recall() into the *buffer* collection.
|
|
52
|
+
Carries the query (pointer into injected) and a snapshot of relational
|
|
53
|
+
hits (perishable, so snapshotted here).
|
|
54
|
+
"""
|
|
55
|
+
id: str # uuid / hash assigned at emit time
|
|
56
|
+
query: str # the original recall query
|
|
57
|
+
relational_snapshot: str # concatenated text of relational hits
|
|
58
|
+
skill: str | None = None
|
|
59
|
+
hint: float | None = None
|
|
60
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
61
|
+
created_at: float = field(default_factory=time.time)
|
|
62
|
+
doc_type: str = "recall_event" # discriminator stored as metadata
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ── Clustering output ─────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class Cluster:
|
|
69
|
+
"""
|
|
70
|
+
A group of buffer documents (Deposits + RecallEvents) that cluster
|
|
71
|
+
around a shared topic. Output of Stage B clustering.
|
|
72
|
+
"""
|
|
73
|
+
cluster_id: str
|
|
74
|
+
documents: list[Deposit | RecallEvent]
|
|
75
|
+
centroid_text: str | None = None # representative text for the cluster
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def has_recall_events(self) -> bool:
|
|
79
|
+
return any(isinstance(d, RecallEvent) for d in self.documents)
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def deposits(self) -> list[Deposit]:
|
|
83
|
+
return [d for d in self.documents if isinstance(d, Deposit)]
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def recall_events(self) -> list[RecallEvent]:
|
|
87
|
+
return [d for d in self.documents if isinstance(d, RecallEvent)]
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def queries(self) -> list[str]:
|
|
91
|
+
return [re.query for re in self.recall_events]
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def skill(self) -> str | None:
|
|
95
|
+
"""Dominant skill across all docs in the cluster (most common)."""
|
|
96
|
+
skills = [d.skill for d in self.documents if d.skill]
|
|
97
|
+
if not skills:
|
|
98
|
+
return None
|
|
99
|
+
return max(set(skills), key=skills.count)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ── Search / recall results ───────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class SearchResult:
|
|
106
|
+
"""
|
|
107
|
+
A single result returned by recall().
|
|
108
|
+
Carries the injected article content blended with freshness signal.
|
|
109
|
+
"""
|
|
110
|
+
id: str
|
|
111
|
+
content: str
|
|
112
|
+
score: float # asymmetric blended score
|
|
113
|
+
source: str # "injected" | "relational" | "blended"
|
|
114
|
+
skill: str | None = None
|
|
115
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ── Injected article ──────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class InjectedArticle:
|
|
122
|
+
"""
|
|
123
|
+
A synthesized knowledge article stored in the *injected* collection.
|
|
124
|
+
Identity is keyed on the frozenset of source-doc hashes (content-addressed),
|
|
125
|
+
NOT on synthesized text (LLM is non-deterministic).
|
|
126
|
+
"""
|
|
127
|
+
id: str # hash of sorted source-doc-hash set
|
|
128
|
+
content: str
|
|
129
|
+
source_hashes: frozenset[str] # set of Deposit.id values used in synthesis
|
|
130
|
+
skill: str | None = None
|
|
131
|
+
tags: dict[str, str] = field(default_factory=dict) # derived by synthesis prompt
|
|
132
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
133
|
+
created_at: float = field(default_factory=time.time)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ── Affectability ─────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
@dataclass
|
|
139
|
+
class Affectability:
|
|
140
|
+
"""
|
|
141
|
+
Per-record affectability score.
|
|
142
|
+
Seeded from the agent hint (or default 0.5), not yet accumulated.
|
|
143
|
+
Accumulation logic lives in util/affectability.py.
|
|
144
|
+
"""
|
|
145
|
+
record_id: str
|
|
146
|
+
score: float # 0.0–1.0
|
|
147
|
+
hint_seeded: bool = False # True if score originated from an agent hint
|
|
File without changes
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Recaller — the recall() implementation.
|
|
3
|
+
|
|
4
|
+
Two things happen on every recall() call:
|
|
5
|
+
1. Returns results (injected primary + relational freshness enrichment).
|
|
6
|
+
2. Emits a recall-event into the buffer (cheap append, no LLM).
|
|
7
|
+
|
|
8
|
+
Design rules (from spec):
|
|
9
|
+
- Injected is ALWAYS searched directly with the query (never gated).
|
|
10
|
+
- Relational enriches; it never gates injected results.
|
|
11
|
+
- Asymmetric scoring: injected = high-trust base; relational = freshness-
|
|
12
|
+
boosted, confidence-discounted.
|
|
13
|
+
- Degrades gracefully to injected-only when relational is empty.
|
|
14
|
+
- The recall-event stores the QUERY (pointer), not an injected doc ref.
|
|
15
|
+
- The recall-event SNAPSHOTS relational hits (TTL-perishable content).
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import time
|
|
20
|
+
import uuid
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from etchmem.config import Config
|
|
24
|
+
from etchmem.models import RecallEvent, SearchResult
|
|
25
|
+
from etchmem.stores.chroma_store import ChromaStore
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Recaller:
|
|
29
|
+
"""
|
|
30
|
+
Encapsulates all recall() logic.
|
|
31
|
+
Injected by Engine; tests can instantiate directly with mock stores.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
relational_store: ChromaStore,
|
|
37
|
+
buffer_store: ChromaStore,
|
|
38
|
+
injected_store: ChromaStore,
|
|
39
|
+
config: Config,
|
|
40
|
+
) -> None:
|
|
41
|
+
self._relational = relational_store
|
|
42
|
+
self._buffer = buffer_store
|
|
43
|
+
self._injected = injected_store
|
|
44
|
+
self._cfg = config
|
|
45
|
+
|
|
46
|
+
def recall(
|
|
47
|
+
self,
|
|
48
|
+
query: str,
|
|
49
|
+
skill: str | None = None,
|
|
50
|
+
top_k: int | None = None,
|
|
51
|
+
hint: float | None = None,
|
|
52
|
+
) -> list[SearchResult]:
|
|
53
|
+
"""
|
|
54
|
+
Retrieve blended results and emit a recall-event into the buffer.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
query: Natural language query.
|
|
58
|
+
skill: Optional skill scope filter.
|
|
59
|
+
top_k: Max results to return. Defaults to config.recall_top_k_injected.
|
|
60
|
+
hint: Optional importance prior seeded into the recall-event.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
List of SearchResult, highest score first.
|
|
64
|
+
"""
|
|
65
|
+
top_k = top_k or self._cfg.recall_top_k_injected
|
|
66
|
+
|
|
67
|
+
# ── 1. Search injected (primary, never gated) ─────────────────────
|
|
68
|
+
injected_filter = {"skill": skill} if skill else None
|
|
69
|
+
injected_results = self._injected.query(
|
|
70
|
+
query_text=query,
|
|
71
|
+
top_k=top_k,
|
|
72
|
+
filters=injected_filter,
|
|
73
|
+
weight=self._cfg.recall_injected_weight,
|
|
74
|
+
source="injected",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# ── 2. Search relational (freshness signal) ───────────────────────
|
|
78
|
+
relational_results = self._relational.search(
|
|
79
|
+
query_text=query,
|
|
80
|
+
top_k=self._cfg.recall_top_k_relational,
|
|
81
|
+
skill=skill,
|
|
82
|
+
weight=self._cfg.recall_relational_weight,
|
|
83
|
+
source="relational",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# ── 3. Asymmetric merge ───────────────────────────────────────────
|
|
87
|
+
merged = self._merge(injected_results, relational_results, top_k)
|
|
88
|
+
|
|
89
|
+
# ── 4. Emit recall-event into buffer ──────────────────────────────
|
|
90
|
+
relational_snapshot = "\n\n".join(r.content for r in relational_results)
|
|
91
|
+
self._emit_recall_event(query, relational_snapshot, skill, hint)
|
|
92
|
+
|
|
93
|
+
return merged
|
|
94
|
+
|
|
95
|
+
# ── Helpers ───────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
def _merge(
|
|
98
|
+
self,
|
|
99
|
+
injected: list[SearchResult],
|
|
100
|
+
relational: list[SearchResult],
|
|
101
|
+
top_k: int,
|
|
102
|
+
) -> list[SearchResult]:
|
|
103
|
+
"""
|
|
104
|
+
Merge injected and relational results.
|
|
105
|
+
|
|
106
|
+
Injected results are the trusted base. Relational results are
|
|
107
|
+
freshness-boosted but confidence-discounted (already weighted by
|
|
108
|
+
recall_relational_weight in the store query).
|
|
109
|
+
|
|
110
|
+
De-duplicate by id; injected wins on collision.
|
|
111
|
+
Sort by score descending; return top_k.
|
|
112
|
+
"""
|
|
113
|
+
seen: dict[str, SearchResult] = {}
|
|
114
|
+
|
|
115
|
+
for r in injected:
|
|
116
|
+
seen[r.id] = r
|
|
117
|
+
|
|
118
|
+
for r in relational:
|
|
119
|
+
if r.id not in seen:
|
|
120
|
+
seen[r.id] = r
|
|
121
|
+
else:
|
|
122
|
+
# Injected already there — boost injected score slightly
|
|
123
|
+
# by the relational overlap (corroborating signal)
|
|
124
|
+
existing = seen[r.id]
|
|
125
|
+
seen[r.id] = SearchResult(
|
|
126
|
+
id=existing.id,
|
|
127
|
+
content=existing.content,
|
|
128
|
+
score=existing.score + r.score * 0.1,
|
|
129
|
+
source="blended",
|
|
130
|
+
skill=existing.skill,
|
|
131
|
+
metadata=existing.metadata,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
results = sorted(seen.values(), key=lambda r: r.score, reverse=True)
|
|
135
|
+
return results[:top_k]
|
|
136
|
+
|
|
137
|
+
def _emit_recall_event(
|
|
138
|
+
self,
|
|
139
|
+
query: str,
|
|
140
|
+
relational_snapshot: str,
|
|
141
|
+
skill: str | None,
|
|
142
|
+
hint: float | None,
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Write a RecallEvent into the buffer collection."""
|
|
145
|
+
event_id = str(uuid.uuid4())
|
|
146
|
+
event = RecallEvent(
|
|
147
|
+
id=event_id,
|
|
148
|
+
query=query,
|
|
149
|
+
relational_snapshot=relational_snapshot,
|
|
150
|
+
skill=skill,
|
|
151
|
+
hint=hint,
|
|
152
|
+
created_at=time.time(),
|
|
153
|
+
)
|
|
154
|
+
self._buffer.upsert([event])
|
|
File without changes
|
etchmem/stores/base.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Storage abstract base classes — the pluggability seam.
|
|
3
|
+
|
|
4
|
+
Everything in the engine talks to these ABCs, never to ChromaDB directly.
|
|
5
|
+
Two ports:
|
|
6
|
+
- RelationalStore: append-only + TTL expiry + ANN search
|
|
7
|
+
- InjectedStore: upsert + ANN query + clustering + hard delete
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from typing import Any, Iterable
|
|
13
|
+
|
|
14
|
+
from etchmem.models import Cluster, Deposit, InjectedArticle, Record, RecallEvent, SearchResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RelationalStore(ABC):
|
|
18
|
+
"""
|
|
19
|
+
Port for the *relational* collection.
|
|
20
|
+
|
|
21
|
+
Semantics:
|
|
22
|
+
- Pure append — records are NEVER mutated after insertion.
|
|
23
|
+
- TTL expiry is the ONLY deletion path (swept via metadata filter).
|
|
24
|
+
- No reinforcement counter. Usage signal is carried by recall-events
|
|
25
|
+
written to the buffer, not by mutating relational rows.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def add(self, record: Record) -> str:
|
|
30
|
+
"""
|
|
31
|
+
Insert a raw Record into the relational collection.
|
|
32
|
+
Returns the record id (same as record.id, passed through for
|
|
33
|
+
convenience).
|
|
34
|
+
The store sets expires_at = now + TTL before writing.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def search(
|
|
39
|
+
self,
|
|
40
|
+
query_text: str,
|
|
41
|
+
top_k: int,
|
|
42
|
+
skill: str | None = None,
|
|
43
|
+
filters: dict[str, Any] | None = None,
|
|
44
|
+
) -> list[SearchResult]:
|
|
45
|
+
"""
|
|
46
|
+
ANN search of the relational collection.
|
|
47
|
+
Optionally filter by skill (exact match on stored metadata).
|
|
48
|
+
Returns up to top_k SearchResult objects.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def expire(self) -> int:
|
|
53
|
+
"""
|
|
54
|
+
Delete all records whose expires_at < now (TTL sweep).
|
|
55
|
+
Returns the number of records deleted.
|
|
56
|
+
Implemented as: collection.delete(where={"expires_at": {"$lt": now}})
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
@abstractmethod
|
|
60
|
+
def iter_for_consolidation(
|
|
61
|
+
self,
|
|
62
|
+
num_records: int | str,
|
|
63
|
+
method: str,
|
|
64
|
+
) -> Iterable[Record]:
|
|
65
|
+
"""
|
|
66
|
+
Yield records for the consolidation worker to process.
|
|
67
|
+
|
|
68
|
+
num_records: "all" or an integer N.
|
|
69
|
+
method: "LIFO" (newest-first, by created_at) or "FIFO".
|
|
70
|
+
|
|
71
|
+
Records are NOT deleted here — relational is append-only.
|
|
72
|
+
Deletion happens only via TTL expiry.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
@abstractmethod
|
|
76
|
+
def count(self) -> int:
|
|
77
|
+
"""Return the number of live (non-expired) records."""
|
|
78
|
+
|
|
79
|
+
@abstractmethod
|
|
80
|
+
def get_by_id(self, record_id: str) -> Record | None:
|
|
81
|
+
"""Fetch a single record by its id, or None if not found."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class InjectedStore(ABC):
|
|
85
|
+
"""
|
|
86
|
+
Port for both the *buffer* and *injected* collections.
|
|
87
|
+
|
|
88
|
+
Buffer semantics:
|
|
89
|
+
- Working set for the consolidation worker.
|
|
90
|
+
- Both Deposit and RecallEvent documents are written here.
|
|
91
|
+
- Flushed (hard-deleted) at the end of each successful consolidation run.
|
|
92
|
+
|
|
93
|
+
Injected semantics:
|
|
94
|
+
- Current-only knowledge store. No versions, no tombstones.
|
|
95
|
+
- Superseded articles are hard-deleted on write of the new article.
|
|
96
|
+
- Identity = hash of the source-doc-hash set (content-addressed).
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
@abstractmethod
|
|
100
|
+
def upsert(self, items: list[InjectedArticle | Deposit | RecallEvent]) -> None:
|
|
101
|
+
"""
|
|
102
|
+
Write or overwrite items. Uses item.id as the Chroma document id.
|
|
103
|
+
doc_type metadata discriminator is set automatically.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
@abstractmethod
|
|
107
|
+
def query(
|
|
108
|
+
self,
|
|
109
|
+
query_text: str,
|
|
110
|
+
top_k: int,
|
|
111
|
+
filters: dict[str, Any] | None = None,
|
|
112
|
+
) -> list[SearchResult]:
|
|
113
|
+
"""
|
|
114
|
+
ANN search this collection.
|
|
115
|
+
Returns up to top_k SearchResult objects.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
@abstractmethod
|
|
119
|
+
def delete(self, ids: list[str]) -> None:
|
|
120
|
+
"""
|
|
121
|
+
Hard-delete documents by id.
|
|
122
|
+
Deleting a non-existent id is a no-op (idempotent).
|
|
123
|
+
Used for:
|
|
124
|
+
- Buffer flush at end of run.
|
|
125
|
+
- Hard-delete of superseded injected articles.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
@abstractmethod
|
|
129
|
+
def exists(self, content_hash: str) -> bool:
|
|
130
|
+
"""
|
|
131
|
+
Return True if a document with this id already exists.
|
|
132
|
+
Used as an idempotency guard before writing injected articles.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
@abstractmethod
|
|
136
|
+
def cluster(
|
|
137
|
+
self,
|
|
138
|
+
documents: list[Deposit | RecallEvent],
|
|
139
|
+
branch_threshold: float,
|
|
140
|
+
) -> list[Cluster]:
|
|
141
|
+
"""
|
|
142
|
+
Group documents into Cluster objects using their embeddings.
|
|
143
|
+
Documents within branch_threshold cosine distance of each other
|
|
144
|
+
are placed in the same cluster.
|
|
145
|
+
Orphaned documents (no cluster partner) are returned as
|
|
146
|
+
singleton Clusters.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
@abstractmethod
|
|
150
|
+
def get_all(
|
|
151
|
+
self,
|
|
152
|
+
filters: dict[str, Any] | None = None,
|
|
153
|
+
) -> list[Deposit | RecallEvent | InjectedArticle]:
|
|
154
|
+
"""Return all documents, optionally filtered by metadata."""
|
|
155
|
+
|
|
156
|
+
@abstractmethod
|
|
157
|
+
def count(self) -> int:
|
|
158
|
+
"""Return the total document count."""
|
|
159
|
+
|
|
160
|
+
@abstractmethod
|
|
161
|
+
def get_neighbors(
|
|
162
|
+
self,
|
|
163
|
+
query_text: str,
|
|
164
|
+
top_k: int,
|
|
165
|
+
distance_threshold: float,
|
|
166
|
+
) -> list[SearchResult]:
|
|
167
|
+
"""
|
|
168
|
+
ANN search with a distance filter — only return results whose
|
|
169
|
+
cosine distance is below distance_threshold.
|
|
170
|
+
Used in the reconciliation pass to find near-duplicate injected articles.
|
|
171
|
+
"""
|