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 ADDED
@@ -0,0 +1,3 @@
1
+ """memtrust: an independent, reproducible benchmark harness for agent-memory backends."""
2
+
3
+ __version__ = "0.1.0"
@@ -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