memtrust 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.
- memtrust/__init__.py +3 -0
- memtrust/adapters/__init__.py +53 -0
- memtrust/adapters/base.py +473 -0
- memtrust/adapters/mem0_adapter.py +431 -0
- memtrust/adapters/mempalace_adapter.py +226 -0
- memtrust/adapters/openviking_adapter.py +219 -0
- memtrust/adapters/zep_graphiti_adapter.py +181 -0
- memtrust/cli.py +412 -0
- memtrust/evals/__init__.py +5 -0
- memtrust/evals/compression.py +235 -0
- memtrust/evals/contradiction.py +278 -0
- memtrust/evals/locomo.py +177 -0
- memtrust/evals/longmemeval.py +146 -0
- memtrust/evals/resource_sync_safety.py +272 -0
- memtrust/scoring/__init__.py +1 -0
- memtrust/scoring/cost_tracker.py +98 -0
- memtrust/scoring/llm_judge.py +161 -0
- memtrust-0.1.0.dist-info/METADATA +427 -0
- memtrust-0.1.0.dist-info/RECORD +22 -0
- memtrust-0.1.0.dist-info/WHEEL +4 -0
- memtrust-0.1.0.dist-info/entry_points.txt +2 -0
- memtrust-0.1.0.dist-info/licenses/LICENSE +202 -0
memtrust/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Backend adapters. Each module maps one vendor's real API/SDK shape onto
|
|
2
|
+
the shared MemoryBackendAdapter interface defined in base.py.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from memtrust.adapters.base import (
|
|
6
|
+
BackendAPIError,
|
|
7
|
+
BackendNotConfiguredError,
|
|
8
|
+
ConflictSignal,
|
|
9
|
+
DeleteResult,
|
|
10
|
+
MemoryBackendAdapter,
|
|
11
|
+
MemoryRecord,
|
|
12
|
+
QueryResult,
|
|
13
|
+
StoreResult,
|
|
14
|
+
UpdateResult,
|
|
15
|
+
)
|
|
16
|
+
from memtrust.adapters.mem0_adapter import Mem0Adapter, Mem0SelfHostedAdapter
|
|
17
|
+
from memtrust.adapters.mempalace_adapter import MemPalaceAdapter
|
|
18
|
+
from memtrust.adapters.openviking_adapter import OpenVikingAdapter
|
|
19
|
+
from memtrust.adapters.zep_graphiti_adapter import ZepGraphitiAdapter
|
|
20
|
+
|
|
21
|
+
#: Registry the CLI resolves --backends names against. Keys are the
|
|
22
|
+
#: user-facing backend names used on the command line.
|
|
23
|
+
#:
|
|
24
|
+
#: "mem0_selfhosted" is intentionally not part of cli.ALL_BACKENDS (the
|
|
25
|
+
#: set "all" expands to) -- it targets a self-run local server rather than
|
|
26
|
+
#: a hosted vendor API, so it is opt-in only, never auto-included. See
|
|
27
|
+
#: docs/methodology.md for its confidence level.
|
|
28
|
+
ADAPTER_REGISTRY: dict[str, type[MemoryBackendAdapter]] = {
|
|
29
|
+
"mempalace": MemPalaceAdapter,
|
|
30
|
+
"mem0": Mem0Adapter,
|
|
31
|
+
"mem0_selfhosted": Mem0SelfHostedAdapter,
|
|
32
|
+
"zep": ZepGraphitiAdapter,
|
|
33
|
+
"graphiti": ZepGraphitiAdapter,
|
|
34
|
+
"openviking": OpenVikingAdapter,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"ADAPTER_REGISTRY",
|
|
39
|
+
"BackendAPIError",
|
|
40
|
+
"BackendNotConfiguredError",
|
|
41
|
+
"ConflictSignal",
|
|
42
|
+
"DeleteResult",
|
|
43
|
+
"MemoryBackendAdapter",
|
|
44
|
+
"MemoryRecord",
|
|
45
|
+
"QueryResult",
|
|
46
|
+
"StoreResult",
|
|
47
|
+
"UpdateResult",
|
|
48
|
+
"Mem0Adapter",
|
|
49
|
+
"Mem0SelfHostedAdapter",
|
|
50
|
+
"MemPalaceAdapter",
|
|
51
|
+
"OpenVikingAdapter",
|
|
52
|
+
"ZepGraphitiAdapter",
|
|
53
|
+
]
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"""Shared interface every backend adapter implements.
|
|
2
|
+
|
|
3
|
+
MemTrust scores backends by running the same eval logic against every
|
|
4
|
+
tracked vendor through this one interface. If an adapter needs a special
|
|
5
|
+
case to pass an eval, that is a bug in the adapter, not a feature -- the
|
|
6
|
+
whole point of a standardized harness is that scoring logic never changes
|
|
7
|
+
per vendor.
|
|
8
|
+
|
|
9
|
+
Every adapter reads its credentials from an environment variable. If the
|
|
10
|
+
variable is missing, the adapter raises BackendNotConfiguredError instead
|
|
11
|
+
of crashing or silently no-oping. This is what lets `memtrust run` work in
|
|
12
|
+
a fresh clone with zero API keys: unconfigured backends print SKIPPED and
|
|
13
|
+
the run continues.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import time
|
|
19
|
+
from abc import ABC, abstractmethod
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from enum import StrEnum
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BackendNotConfiguredError(Exception):
|
|
25
|
+
"""Raised when a backend adapter is missing required configuration.
|
|
26
|
+
|
|
27
|
+
Callers (the CLI, eval runners) must catch this specifically and treat
|
|
28
|
+
it as "skip this backend," never as a fatal error for the whole run.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, backend_name: str, missing_env_var: str) -> None:
|
|
32
|
+
self.backend_name = backend_name
|
|
33
|
+
self.missing_env_var = missing_env_var
|
|
34
|
+
super().__init__(
|
|
35
|
+
f"{backend_name} is not configured: environment variable "
|
|
36
|
+
f"{missing_env_var} is not set. Skipping this backend. "
|
|
37
|
+
f"See docs/methodology.md for setup instructions."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class BackendAPIError(Exception):
|
|
42
|
+
"""Raised when a configured backend's API call fails (network, auth,
|
|
43
|
+
5xx, malformed response). Distinct from BackendNotConfiguredError so
|
|
44
|
+
callers can tell "never had credentials" apart from "had credentials,
|
|
45
|
+
the call still failed."
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, backend_name: str, detail: str) -> None:
|
|
49
|
+
self.backend_name = backend_name
|
|
50
|
+
self.detail = detail
|
|
51
|
+
super().__init__(f"{backend_name} API error: {detail}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ConflictSignal(StrEnum):
|
|
55
|
+
"""How a backend responded when a query touched a fact that had been
|
|
56
|
+
contradicted by a later store()/update() call.
|
|
57
|
+
|
|
58
|
+
This is the classification the contradiction-detection eval reads off
|
|
59
|
+
every query() response -- see evals/contradiction.py.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
FLAGGED = "flagged"
|
|
63
|
+
"""The backend surfaced the conflict: it returned both versions, an
|
|
64
|
+
explicit conflict marker, or otherwise made the contradiction visible
|
|
65
|
+
to the caller instead of silently resolving it."""
|
|
66
|
+
|
|
67
|
+
SILENT_OVERWRITE = "silent_overwrite"
|
|
68
|
+
"""The backend replaced the old fact with the new one and gave no
|
|
69
|
+
signal in the query response that a prior, different value ever
|
|
70
|
+
existed."""
|
|
71
|
+
|
|
72
|
+
SERVED_STALE = "served_stale"
|
|
73
|
+
"""The backend returned the old, now-contradicted fact and gave no
|
|
74
|
+
signal that a newer, conflicting fact had been stored since."""
|
|
75
|
+
|
|
76
|
+
NOT_APPLICABLE = "not_applicable"
|
|
77
|
+
"""The backend has no update/contradiction-relevant primitive to
|
|
78
|
+
evaluate here (see MemoryBackendAdapter.supports_update). Recorded
|
|
79
|
+
explicitly rather than silently dropping the backend from the eval
|
|
80
|
+
table -- see CLAUDE.md anti-sycophancy rule #2."""
|
|
81
|
+
|
|
82
|
+
EMPTY_OR_LOST = "empty_or_lost"
|
|
83
|
+
"""The backend DOES have an update/contradiction-relevant primitive
|
|
84
|
+
(MemoryBackendAdapter.supports_update is True), the store()/update()/
|
|
85
|
+
query() calls all completed without raising BackendAPIError, but the
|
|
86
|
+
query response came back with zero records -- no exception, no error,
|
|
87
|
+
just nothing. This is distinct from NOT_APPLICABLE, which means the
|
|
88
|
+
backend structurally cannot be evaluated here at all: EMPTY_OR_LOST
|
|
89
|
+
means the backend *should* have had something to say and silently
|
|
90
|
+
didn't. This is the "call succeeded but produced nothing" failure mode
|
|
91
|
+
the benchmark exists to catch -- see evals/contradiction.py's
|
|
92
|
+
classify_case for exactly when this is assigned, never a vendor's own
|
|
93
|
+
self-report."""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class MemoryRecord:
|
|
98
|
+
"""One stored memory as returned by a backend's query response."""
|
|
99
|
+
|
|
100
|
+
memory_id: str
|
|
101
|
+
content: str
|
|
102
|
+
score: float | None = None
|
|
103
|
+
created_at: str | None = None
|
|
104
|
+
metadata: dict[str, str] = field(default_factory=dict)
|
|
105
|
+
raw: dict[str, object] = field(default_factory=dict)
|
|
106
|
+
"""Unmodified vendor response fragment for this record, kept for
|
|
107
|
+
audit/raw-log purposes. Never used for scoring -- scoring only reads
|
|
108
|
+
the typed fields above so every backend is judged by the same rules.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class QueryResult:
|
|
114
|
+
"""Result of MemoryBackendAdapter.query()."""
|
|
115
|
+
|
|
116
|
+
records: list[MemoryRecord]
|
|
117
|
+
conflict_signal: ConflictSignal
|
|
118
|
+
"""How this query response handled any contradiction relevant to the
|
|
119
|
+
query. Adapters that cannot detect this default to NOT_APPLICABLE and
|
|
120
|
+
the eval scores it as a gap, not a pass or fail -- see
|
|
121
|
+
evals/contradiction.py for the classification logic."""
|
|
122
|
+
latency_ms: float
|
|
123
|
+
raw: dict[str, object] = field(default_factory=dict)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class StoreResult:
|
|
128
|
+
"""Result of MemoryBackendAdapter.store()."""
|
|
129
|
+
|
|
130
|
+
memory_id: str
|
|
131
|
+
latency_ms: float
|
|
132
|
+
raw: dict[str, object] = field(default_factory=dict)
|
|
133
|
+
verified: bool | None = None
|
|
134
|
+
"""Whether a post-write read-back confirmed the content is actually
|
|
135
|
+
retrievable. `None` means the adapter did not attempt verification
|
|
136
|
+
(the default -- see MemoryBackendAdapter.verify_store) -- this is
|
|
137
|
+
deliberately distinct from `False`. `None` is "we don't know," and
|
|
138
|
+
must never be treated as "verified passed" by scoring or reporting
|
|
139
|
+
code. `True`/`False` mean an adapter actually called verify_store()
|
|
140
|
+
(or equivalent) and got a definitive answer.
|
|
141
|
+
|
|
142
|
+
Why this field exists at all: store() raising no exception has never
|
|
143
|
+
been proof that a write was durable -- a vendor can return 200 OK (or
|
|
144
|
+
a fake in-process success) while silently dropping or corrupting the
|
|
145
|
+
write server-side. Two independently root-caused MemPalace bug
|
|
146
|
+
classes did exactly this (checkpoint corruption via NUL bytes;
|
|
147
|
+
stale/self-deadlocked locks silently no-oping a write). Without this
|
|
148
|
+
field, that failure mode is indistinguishable from "the model just
|
|
149
|
+
didn't recall the fact," which misattributes a backend durability bug
|
|
150
|
+
to model quality. See docs/methodology.md for why verification is
|
|
151
|
+
opt-in rather than automatic.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass
|
|
156
|
+
class UpdateResult:
|
|
157
|
+
"""Result of MemoryBackendAdapter.update()."""
|
|
158
|
+
|
|
159
|
+
memory_id: str
|
|
160
|
+
acknowledged: bool
|
|
161
|
+
latency_ms: float
|
|
162
|
+
raw: dict[str, object] = field(default_factory=dict)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass
|
|
166
|
+
class DeleteResult:
|
|
167
|
+
"""Result of MemoryBackendAdapter.delete().
|
|
168
|
+
|
|
169
|
+
On failure, adapters raise BackendAPIError instead of returning a
|
|
170
|
+
DeleteResult with success=False -- `success` here reports the
|
|
171
|
+
vendor's own acknowledgement shape (e.g. "deleted" vs "already
|
|
172
|
+
gone"), not whether the HTTP call itself succeeded.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
success: bool
|
|
176
|
+
memory_id: str
|
|
177
|
+
latency_ms: float
|
|
178
|
+
raw: dict[str, object] = field(default_factory=dict)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class MemoryBackendAdapter(ABC):
|
|
182
|
+
"""Abstract base every backend adapter must implement.
|
|
183
|
+
|
|
184
|
+
Contract for implementers:
|
|
185
|
+
* __init__ must read credentials from an environment variable and
|
|
186
|
+
raise BackendNotConfiguredError immediately if missing -- never
|
|
187
|
+
defer the check to the first method call.
|
|
188
|
+
* store()/query()/update()/delete() must raise BackendAPIError (not
|
|
189
|
+
a bare vendor exception) on any network/API failure, so the
|
|
190
|
+
harness can report a uniform error shape across all backends.
|
|
191
|
+
* Never mutate eval logic per backend. If a backend cannot support
|
|
192
|
+
an operation (see supports_update), report that fact through
|
|
193
|
+
supports_update / ConflictSignal.NOT_APPLICABLE rather than
|
|
194
|
+
faking a response.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
#: Human-readable vendor name, used in CLI output and reports.
|
|
198
|
+
name: str = "unknown"
|
|
199
|
+
|
|
200
|
+
#: Environment variable this adapter reads its credential/config from.
|
|
201
|
+
#: Subclasses must set this so BackendNotConfiguredError messages are
|
|
202
|
+
#: accurate and so `memtrust run` can pre-check configuration status
|
|
203
|
+
#: without constructing the adapter.
|
|
204
|
+
env_var: str = ""
|
|
205
|
+
|
|
206
|
+
#: Whether this backend exposes an update/invalidate primitive the
|
|
207
|
+
#: contradiction-detection eval can meaningfully exercise. If False,
|
|
208
|
+
#: the eval records ConflictSignal.NOT_APPLICABLE instead of running
|
|
209
|
+
#: the eval against this backend and silently dropping it from the
|
|
210
|
+
#: results table.
|
|
211
|
+
supports_update: bool = True
|
|
212
|
+
|
|
213
|
+
#: Whether this backend exposes a directory/resource mirror that a
|
|
214
|
+
#: multi-file resync operation can act on (list_resource_paths /
|
|
215
|
+
#: trigger_resync below). Defaults to False -- the store/query/update
|
|
216
|
+
#: model most adapters implement has no concept of a resync, so most
|
|
217
|
+
#: backends genuinely cannot be exercised here. If False, the
|
|
218
|
+
#: resource-sync-safety eval records the backend as skipped instead of
|
|
219
|
+
#: calling list_resource_paths/trigger_resync and crashing on the
|
|
220
|
+
#: default NotImplementedError below.
|
|
221
|
+
supports_resource_sync: bool = False
|
|
222
|
+
|
|
223
|
+
#: Named operating modes this backend exposes that change how content
|
|
224
|
+
#: is stored/retrieved (e.g. a vendor's "compressed"/"lossless" write
|
|
225
|
+
#: path vs. its raw path). Empty by default -- most adapters have no
|
|
226
|
+
#: mode variants at all, and the compression/round-trip-fidelity eval
|
|
227
|
+
#: (evals/compression.py) treats an empty tuple as "run once under a
|
|
228
|
+
#: single synthetic 'default' mode" rather than skipping the backend.
|
|
229
|
+
#: An adapter that declares a non-empty tuple here is asserting that
|
|
230
|
+
#: passing each of those strings as `mode=` to store()/query() below
|
|
231
|
+
#: actually selects a different vendor-side code path -- see
|
|
232
|
+
#: mempalace_adapter.py for the one adapter that currently does this,
|
|
233
|
+
#: and the honesty caveat attached to its mode names.
|
|
234
|
+
supported_modes: tuple[str, ...] = ()
|
|
235
|
+
|
|
236
|
+
@abstractmethod
|
|
237
|
+
def store(
|
|
238
|
+
self,
|
|
239
|
+
session_id: str,
|
|
240
|
+
content: str,
|
|
241
|
+
metadata: dict[str, str] | None = None,
|
|
242
|
+
mode: str | None = None,
|
|
243
|
+
) -> StoreResult:
|
|
244
|
+
"""Store a new memory under the given session/user scope.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
session_id: logical conversation/user scope for this memory.
|
|
248
|
+
content: the text to store.
|
|
249
|
+
metadata: optional vendor-agnostic key/value tags.
|
|
250
|
+
mode: optional operating-mode selector (see `supported_modes`).
|
|
251
|
+
Adapters that don't expose mode variants MUST accept this
|
|
252
|
+
parameter and ignore it (no-op) rather than raising, so
|
|
253
|
+
that callers written against the shared interface can pass
|
|
254
|
+
`mode=` uniformly across every backend without special-
|
|
255
|
+
casing the ones that don't support it. This preserves
|
|
256
|
+
backward compatibility: any pre-existing call site that
|
|
257
|
+
never passes `mode` continues to behave identically.
|
|
258
|
+
|
|
259
|
+
Raises:
|
|
260
|
+
BackendAPIError: on any network or vendor-side failure.
|
|
261
|
+
|
|
262
|
+
Note on durability: returning without raising is *not* proof the
|
|
263
|
+
write is durable or even retrievable -- a vendor can silently
|
|
264
|
+
drop or corrupt a write server-side and still return a normal
|
|
265
|
+
response (this is exactly what happened in two independently
|
|
266
|
+
root-caused MemPalace bugs: NUL-byte checkpoint corruption and
|
|
267
|
+
stale/self-deadlocked locks). Implementers that want to guard
|
|
268
|
+
against this should accept an opt-in `verify: bool = False`
|
|
269
|
+
keyword-only parameter and, when True, call `self.verify_store()`
|
|
270
|
+
after the write succeeds and set `StoreResult.verified` from its
|
|
271
|
+
return value. This is intentionally NOT part of every adapter's
|
|
272
|
+
required signature (existing callers that never pass `verify`
|
|
273
|
+
must keep working unchanged against any adapter), and it is
|
|
274
|
+
intentionally NOT on by default anywhere -- see verify_store()
|
|
275
|
+
and docs/methodology.md for why (it doubles API calls per store()
|
|
276
|
+
when enabled).
|
|
277
|
+
"""
|
|
278
|
+
raise NotImplementedError
|
|
279
|
+
|
|
280
|
+
@abstractmethod
|
|
281
|
+
def query(
|
|
282
|
+
self, session_id: str, query: str, top_k: int = 5, mode: str | None = None
|
|
283
|
+
) -> QueryResult:
|
|
284
|
+
"""Retrieve memories relevant to `query` within `session_id`.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
mode: optional operating-mode selector, same contract as
|
|
288
|
+
`store()`'s `mode` parameter above -- ignored (no-op) by
|
|
289
|
+
adapters with no mode variants.
|
|
290
|
+
|
|
291
|
+
Raises:
|
|
292
|
+
BackendAPIError: on any network or vendor-side failure.
|
|
293
|
+
"""
|
|
294
|
+
raise NotImplementedError
|
|
295
|
+
|
|
296
|
+
@abstractmethod
|
|
297
|
+
def update(self, session_id: str, memory_id: str, content: str) -> UpdateResult:
|
|
298
|
+
"""Store a fact that may contradict a previously stored one.
|
|
299
|
+
|
|
300
|
+
Implementers should call whatever the vendor's native mechanism is
|
|
301
|
+
for "this may supersede an existing memory" (an explicit update
|
|
302
|
+
call, a second store() that the vendor's own pipeline resolves,
|
|
303
|
+
etc.) and report what actually happened in UpdateResult -- do not
|
|
304
|
+
synthesize agreement with the request.
|
|
305
|
+
|
|
306
|
+
Raises:
|
|
307
|
+
BackendAPIError: on any network or vendor-side failure.
|
|
308
|
+
"""
|
|
309
|
+
raise NotImplementedError
|
|
310
|
+
|
|
311
|
+
@abstractmethod
|
|
312
|
+
def delete(self, memory_id: str) -> DeleteResult:
|
|
313
|
+
"""Delete a single stored memory/entity by id.
|
|
314
|
+
|
|
315
|
+
This is the primitive an eval needs to reproduce vendor bugs in
|
|
316
|
+
the "delete N entities" class (e.g. a client whose batch-delete
|
|
317
|
+
code silently keeps only the last response instead of aggregating
|
|
318
|
+
all N) -- see delete_many() below, which is what an eval actually
|
|
319
|
+
calls to construct that scenario.
|
|
320
|
+
|
|
321
|
+
Implementers that genuinely cannot delete (no documented/verified
|
|
322
|
+
vendor endpoint) must still define this method and raise
|
|
323
|
+
BackendAPIError with a clear "not implemented for this backend"
|
|
324
|
+
detail rather than omitting the method -- the eval layer needs a
|
|
325
|
+
uniform call shape across all adapters, same as store/query/
|
|
326
|
+
update, even when the honest answer is "not supported yet."
|
|
327
|
+
|
|
328
|
+
Raises:
|
|
329
|
+
BackendAPIError: on any network/vendor-side failure, or when
|
|
330
|
+
this backend has no verified delete endpoint.
|
|
331
|
+
"""
|
|
332
|
+
raise NotImplementedError
|
|
333
|
+
|
|
334
|
+
def delete_many(self, memory_ids: list[str]) -> list[DeleteResult]:
|
|
335
|
+
"""Delete several memories, one at a time, via delete().
|
|
336
|
+
|
|
337
|
+
Default implementation for every adapter: a plain per-id loop
|
|
338
|
+
that appends each DeleteResult (or a failure record, if an
|
|
339
|
+
individual delete() call raises) to a single results list sized
|
|
340
|
+
exactly len(memory_ids). This is deliberately naive -- it exists
|
|
341
|
+
so the eval layer has one aggregation path to trust, rather than
|
|
342
|
+
each adapter rolling its own batch logic that could silently
|
|
343
|
+
drop or overwrite results the way a buggy vendor client might
|
|
344
|
+
(see mem0ai/mem0#5936, #5970: a multi-entity delete that kept
|
|
345
|
+
only the last response instead of all N). Adapters with a real
|
|
346
|
+
vendor batch-delete endpoint may override this for efficiency,
|
|
347
|
+
but must preserve the same one-result-per-input-id contract.
|
|
348
|
+
|
|
349
|
+
Does not raise: a per-id BackendAPIError is caught and recorded
|
|
350
|
+
as a DeleteResult(success=False, ...) at that id's position so
|
|
351
|
+
one failure in the middle of a batch cannot truncate or drop the
|
|
352
|
+
results for the ids after it.
|
|
353
|
+
"""
|
|
354
|
+
results: list[DeleteResult] = []
|
|
355
|
+
for memory_id in memory_ids:
|
|
356
|
+
try:
|
|
357
|
+
results.append(self.delete(memory_id))
|
|
358
|
+
except BackendAPIError as exc:
|
|
359
|
+
results.append(
|
|
360
|
+
DeleteResult(
|
|
361
|
+
success=False,
|
|
362
|
+
memory_id=memory_id,
|
|
363
|
+
latency_ms=0.0,
|
|
364
|
+
raw={"error": str(exc)},
|
|
365
|
+
)
|
|
366
|
+
)
|
|
367
|
+
return results
|
|
368
|
+
|
|
369
|
+
def list_resource_paths(self, prefix: str) -> list[str]:
|
|
370
|
+
"""List resource/file paths currently present under `prefix`.
|
|
371
|
+
|
|
372
|
+
Optional capability -- only meaningful for backends that model a
|
|
373
|
+
directory/resource mirror (see supports_resource_sync). Unlike
|
|
374
|
+
store()/query()/update(), this is NOT an abstract method: most
|
|
375
|
+
adapters have no resource-mirror concept at all, so the default
|
|
376
|
+
implementation raises NotImplementedError rather than forcing
|
|
377
|
+
every adapter to stub it out. Implementers that set
|
|
378
|
+
supports_resource_sync = True must override this.
|
|
379
|
+
|
|
380
|
+
Raises:
|
|
381
|
+
NotImplementedError: if the adapter does not implement this
|
|
382
|
+
(i.e. supports_resource_sync is False).
|
|
383
|
+
BackendAPIError: on any network or vendor-side failure.
|
|
384
|
+
"""
|
|
385
|
+
raise NotImplementedError(
|
|
386
|
+
f"{self.name} does not implement list_resource_paths() "
|
|
387
|
+
f"(supports_resource_sync={self.supports_resource_sync})"
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
def trigger_resync(self, prefix: str) -> None:
|
|
391
|
+
"""Trigger whatever native mechanism this backend uses to reconcile
|
|
392
|
+
its resource mirror under `prefix` against the source it ingests
|
|
393
|
+
from (e.g. a directory watcher's resync/rescan pass).
|
|
394
|
+
|
|
395
|
+
Optional capability, same convention as list_resource_paths()
|
|
396
|
+
above -- default raises NotImplementedError, only backends with
|
|
397
|
+
supports_resource_sync = True are expected to override it.
|
|
398
|
+
|
|
399
|
+
Raises:
|
|
400
|
+
NotImplementedError: if the adapter does not implement this
|
|
401
|
+
(i.e. supports_resource_sync is False).
|
|
402
|
+
BackendAPIError: on any network or vendor-side failure.
|
|
403
|
+
"""
|
|
404
|
+
raise NotImplementedError(
|
|
405
|
+
f"{self.name} does not implement trigger_resync() "
|
|
406
|
+
f"(supports_resource_sync={self.supports_resource_sync})"
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
@staticmethod
|
|
410
|
+
def _timed() -> _Timer:
|
|
411
|
+
return _Timer()
|
|
412
|
+
|
|
413
|
+
def verify_store(self, store_result: StoreResult, session_id: str, content: str) -> bool:
|
|
414
|
+
"""Opt-in read-after-write check: query() immediately after a
|
|
415
|
+
store() call and confirm the just-written content is actually
|
|
416
|
+
retrievable, instead of trusting "store() didn't raise" as proof
|
|
417
|
+
of a durable write.
|
|
418
|
+
|
|
419
|
+
This is a helper, not something store() calls automatically --
|
|
420
|
+
no adapter's default `store()` behavior changes by this method
|
|
421
|
+
existing. An adapter opts in by accepting a `verify: bool = False`
|
|
422
|
+
keyword-only parameter on its own store() and calling this
|
|
423
|
+
explicitly when `verify=True` (see mempalace_adapter.py for the
|
|
424
|
+
reference implementation). Left off by default because it costs
|
|
425
|
+
one extra vendor API call (a query()) for every store() call made
|
|
426
|
+
with verify=True -- turning that on unconditionally for every eval
|
|
427
|
+
run would silently double memtrust's own API/latency cost against
|
|
428
|
+
every backend under test. See docs/methodology.md.
|
|
429
|
+
|
|
430
|
+
Args:
|
|
431
|
+
store_result: the StoreResult just returned by this adapter's
|
|
432
|
+
own store() call, used for its memory_id.
|
|
433
|
+
session_id: the same session/scope the content was stored
|
|
434
|
+
under.
|
|
435
|
+
content: the exact text that was just stored.
|
|
436
|
+
|
|
437
|
+
Returns:
|
|
438
|
+
True only if a query() call for `session_id` returns a record
|
|
439
|
+
whose *content* actually contains `content` -- either the
|
|
440
|
+
record matching `store_result.memory_id` by id (checked
|
|
441
|
+
first, and its content must still match: a record that comes
|
|
442
|
+
back under the right id but with corrupted content is a
|
|
443
|
+
failed verification, not a pass) or, if no record shares that
|
|
444
|
+
id (some backends don't echo a stable id on the query path),
|
|
445
|
+
any returned record whose content contains `content`. False
|
|
446
|
+
covers both "no matching record at all" (dropped write) and
|
|
447
|
+
"a record came back but the content doesn't match" (corrupted
|
|
448
|
+
write) -- both are reported as a normal `False` return, never
|
|
449
|
+
raised as an error.
|
|
450
|
+
|
|
451
|
+
Raises:
|
|
452
|
+
BackendAPIError: if the verification query() call itself
|
|
453
|
+
fails (a real network/vendor error is a different failure
|
|
454
|
+
mode than "the write was silently dropped," and should
|
|
455
|
+
still surface as an error rather than being swallowed
|
|
456
|
+
into `False`).
|
|
457
|
+
"""
|
|
458
|
+
result = self.query(session_id, content)
|
|
459
|
+
for record in result.records:
|
|
460
|
+
if record.memory_id and record.memory_id == store_result.memory_id:
|
|
461
|
+
return bool(content) and content in record.content
|
|
462
|
+
return any(content and content in record.content for record in result.records)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
class _Timer:
|
|
466
|
+
"""Tiny context-manager-free stopwatch so adapters can report latency
|
|
467
|
+
without importing timing boilerplate in every subclass."""
|
|
468
|
+
|
|
469
|
+
def __init__(self) -> None:
|
|
470
|
+
self._start = time.perf_counter()
|
|
471
|
+
|
|
472
|
+
def elapsed_ms(self) -> float:
|
|
473
|
+
return (time.perf_counter() - self._start) * 1000
|