memtrust-cli 0.3.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.
Files changed (42) hide show
  1. memtrust/__init__.py +11 -0
  2. memtrust/adapters/__init__.py +62 -0
  3. memtrust/adapters/base.py +2020 -0
  4. memtrust/adapters/mem0_adapter.py +456 -0
  5. memtrust/adapters/mem0_direct_adapter.py +1217 -0
  6. memtrust/adapters/mempalace_adapter.py +1166 -0
  7. memtrust/adapters/openviking_adapter.py +570 -0
  8. memtrust/adapters/zep_graphiti_adapter.py +181 -0
  9. memtrust/adapters/zep_graphiti_selfhosted_adapter.py +882 -0
  10. memtrust/cli.py +1201 -0
  11. memtrust/evals/__init__.py +5 -0
  12. memtrust/evals/compression.py +235 -0
  13. memtrust/evals/contradiction.py +451 -0
  14. memtrust/evals/crash_recovery.py +290 -0
  15. memtrust/evals/embedder_cost.py +163 -0
  16. memtrust/evals/embedding_drift.py +273 -0
  17. memtrust/evals/episode_temporal_leak.py +182 -0
  18. memtrust/evals/extraction_quality.py +373 -0
  19. memtrust/evals/filter_injection.py +371 -0
  20. memtrust/evals/language_degradation.py +189 -0
  21. memtrust/evals/lock_contention.py +263 -0
  22. memtrust/evals/locomo.py +392 -0
  23. memtrust/evals/longmemeval.py +249 -0
  24. memtrust/evals/mempalace_metadata_scale.py +494 -0
  25. memtrust/evals/migration_rollback.py +276 -0
  26. memtrust/evals/orphan_cleanup.py +235 -0
  27. memtrust/evals/ranking_quality.py +303 -0
  28. memtrust/evals/resource_sync_safety.py +336 -0
  29. memtrust/evals/result_consistency.py +239 -0
  30. memtrust/evals/scale_fixtures.py +189 -0
  31. memtrust/evals/scale_stress.py +502 -0
  32. memtrust/evals/stats_accuracy.py +240 -0
  33. memtrust/evals/temporal_kg_boundary.py +281 -0
  34. memtrust/receipt.py +318 -0
  35. memtrust/scoring/__init__.py +1 -0
  36. memtrust/scoring/cost_tracker.py +199 -0
  37. memtrust/scoring/llm_judge.py +161 -0
  38. memtrust_cli-0.3.0.dist-info/METADATA +624 -0
  39. memtrust_cli-0.3.0.dist-info/RECORD +42 -0
  40. memtrust_cli-0.3.0.dist-info/WHEEL +4 -0
  41. memtrust_cli-0.3.0.dist-info/entry_points.txt +2 -0
  42. memtrust_cli-0.3.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,2020 @@
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 CrashSignal(StrEnum):
42
+ """WHY a store()/query()/update()/delete() call raised BackendAPIError,
43
+ classified by pattern-matching the caught exception's type and message
44
+ against known internal-library crash shapes -- distinct from *whether*
45
+ the call failed (that's BackendAPIError itself, always raised on any
46
+ failure) and distinct from ConflictSignal/RankingSignal/CorruptionSignal
47
+ above, which all classify the *content* of a successful response, never
48
+ an exception. Without this, every internal crash of a vendor library --
49
+ a known, previously-reported bug class vs. some unrelated transient
50
+ error -- surfaces as an identical opaque `BackendAPIError(detail=str(exc))`
51
+ with no way to tell them apart.
52
+
53
+ This is pattern-matching on an exception's type and a substring of its
54
+ message, not proof the crash reproduces a specific upstream line of code
55
+ every time this signal is assigned -- a message string is the only
56
+ surface an adapter has to work with once the vendor library has already
57
+ raised. See zep_graphiti_selfhosted_adapter.py's `_classify_crash()` and
58
+ docs/methodology.md for the honesty caveat: this classifies a known
59
+ crash *shape* by pattern-matching the exception, it does not prevent the
60
+ crash or fix the underlying library itself.
61
+ """
62
+
63
+ UNPACK_ERROR = "unpack_error"
64
+ """A `ValueError` raised from a Python tuple/list unpacking count
65
+ mismatch (message contains "values to unpack", covering both "too many
66
+ values to unpack" and "not enough values to unpack"). Matches the shape
67
+ of getzep/graphiti#836: `add_episode(update_communities=True)`'s
68
+ community-update branch does
69
+ `communities, community_edges = await semaphore_gather(*[...])`, which
70
+ only succeeds when `semaphore_gather` returns exactly 2 items -- any
71
+ episode whose extracted node count is not exactly 2 raises this
72
+ `ValueError` shape from graphiti_core's own internals, not from
73
+ anything this adapter's own code does."""
74
+
75
+ TYPE_COMPARISON_ERROR = "type_comparison_error"
76
+ """A `TypeError` raised from comparing incompatible types, specifically
77
+ a timezone-naive vs. timezone-aware `datetime` comparison (message
78
+ contains "offset-naive and offset-aware"). Matches the shape of
79
+ getzep/graphiti#920: an edge-contradiction-resolution code path
80
+ compares a stored edge's (possibly tz-naive) timestamp against a
81
+ tz-aware `datetime` value without normalizing both to the same
82
+ awareness first, raising this exact `TypeError` from Python's own
83
+ datetime comparison, surfaced through graphiti_core's internals."""
84
+
85
+ QUERY_SANITIZATION_ERROR = "query_sanitization_error"
86
+ """A RediSearch `Syntax error` raised from FalkorDB's fulltext-query
87
+ path (message contains both "redisearch" and "syntax error", e.g.
88
+ `"RediSearch: Syntax error at offset 22 near my_graph"`). Matches the
89
+ shape of two independently-documented graphiti-core bugs, both in
90
+ `graphiti_core.driver.falkordb_driver.FalkorDriver`'s `sanitize()`/
91
+ `build_fulltext_query()`:
92
+
93
+ * getzep/graphiti#1222 (closed, superseded by #1475): when a query's
94
+ sanitized/stopword-filtered token list comes back empty (e.g. an
95
+ empty query string, such as `explore_node` called with only a
96
+ `node_uuid` and no `node_name`), `build_fulltext_query()` appends
97
+ empty parentheses to the group filter, producing invalid RediSearch
98
+ syntax of the exact shape `(@group_id:"...") ()`.
99
+ * getzep/graphiti#1183 (merged): before this fix, `sanitize()`'s
100
+ character-replacement map omitted pipe (`|`), slash (`/`), and
101
+ backslash (`\\`) -- episode text containing those characters (e.g.
102
+ `"install.sh | bash"`) survived sanitization, tokenized on
103
+ whitespace into a stray `|` token, and got rejoined with `" | "`
104
+ as a RediSearch OR separator, producing an adjacent-pipe malformed
105
+ query (`"sh | | | bash"`) with an empty token between delimiters.
106
+ The merged fix added those three characters to `sanitize()`'s map
107
+ and filters empty tokens before joining.
108
+
109
+ Both bugs raise the identical `RediSearch: Syntax error ...` message
110
+ shape once FalkorDB's RediSearch engine parses the malformed query --
111
+ this signal does not distinguish which of the two produced a given
112
+ crash, only that the shape matches. The real exception here is
113
+ typically a `redis`-client `ResponseError`; see
114
+ zep_graphiti_selfhosted_adapter.py's `_classify_crash()` for the exact
115
+ substring match (message-only, not `isinstance`, since this adapter
116
+ does not import the optional `redis`/`falkordb` packages directly) and
117
+ docs/methodology.md for the honesty caveat."""
118
+
119
+ LEGACY_CORRUPT_RECORD_UNDELETABLE = "legacy_corrupt_record_undeletable"
120
+ """A `json.JSONDecodeError` raised while a write-path call (`store()`'s
121
+ overwrite/upsert path, `update()`, or `delete()`) attempted to parse a
122
+ response body, matching the shape of volcengine/OpenViking#2966
123
+ (contributor lRoccoon): a legacy uint16-length-truncated record
124
+ (written by a pre-#2171 OpenViking image whose buffer-size wraparound
125
+ bug silently truncated an oversized `fields` blob) can never be
126
+ deleted or overwritten through normal APIs, because
127
+ `LocalIndex.delete_data()`/`upsert_data()` both run
128
+ `_convert_delta_list_for_index()` ->
129
+ `FieldTypeConverter.convert_fields_for_index()`, which calls a bare
130
+ `json.loads(fields_json)` on the corrupt record's `fields`/`old_fields`
131
+ with no error handling -- `json.decoder.JSONDecodeError` propagates on
132
+ every call, so the record becomes a permanent "ghost" (visible to
133
+ search/startup-recovery mitigations that already tolerate it, but
134
+ structurally undeletable). This adapter has no access to OpenViking's
135
+ internal traceback once the vendor's own HTTP layer has already
136
+ responded -- it can only classify this shape from a malformed/
137
+ non-JSON response body its own `resp.json()` call fails to parse,
138
+ which is the observable symptom on the client side of the same
139
+ underlying bug. See openviking_adapter.py's `store()`/`update()`/
140
+ `delete()` and `docs/methodology.md` for the honesty caveat: this
141
+ classifies the shape by pattern-matching the client-visible exception,
142
+ it does not reproduce OpenViking's internal RocksDB/CandsTable state
143
+ or prove this specific call hit a legacy-truncated record versus some
144
+ other cause of a malformed response body."""
145
+
146
+ EMBEDDING_BATCH_COUNT_MISMATCH = "embedding_batch_count_mismatch"
147
+ """A `ValueError` raised from Python's `zip(..., strict=True)` count
148
+ mismatch (message contains both "zip()" and "shorter than"). Matches
149
+ the shape of getzep/graphiti#1467 (contributor elimydlarz, open as of
150
+ this build): `GeminiEmbedder.create_batch()` (`graphiti_core/embedder/
151
+ gemini.py`) only special-cases `batch_size=1` for the
152
+ `"gemini-embedding-001"` model -- confirmed by reading the real,
153
+ current `main`-branch source, 2026-07-16 -- so for
154
+ `"gemini-embedding-2-preview"`/`"gemini-embedding-2"`, a single
155
+ `embed_content(contents=batch)` call can silently return ONE embedding
156
+ for an N-item batch (these models do not batch the way `-001` does),
157
+ with no exception raised inside `create_batch()` itself. The caller
158
+ that ends up with a too-short embeddings list is graphiti-core's own
159
+ dedup pipeline -- `_semantic_candidate_search()`
160
+ (`graphiti_core/utils/maintenance/node_operations.py`) or
161
+ `create_entity_node_embeddings()` (`graphiti_core/nodes.py`), reached
162
+ from `add_episode()` once an episode extracts 2+ entities -- which
163
+ zips the extracted-node list against the returned embeddings list with
164
+ `strict=True`, raising `ValueError: zip() argument 2 is shorter than
165
+ argument 1` (or the reverse-argument-order phrasing, depending on
166
+ which list came up short) once the count mismatch is finally observed,
167
+ several call frames away from where the embedder actually returned
168
+ the wrong count. See
169
+ zep_graphiti_selfhosted_adapter.py's `_build_embedder()` (the Gemini
170
+ embedder wiring this signal's classification depends on being
171
+ reachable at all) and `_classify_crash()`."""
172
+
173
+ QUERY_PARAMETER_PARSE_ERROR = "query_parameter_parse_error"
174
+ """A FalkorDB `redis.exceptions.ResponseError` raised with a message
175
+ matching `"failed to parse query parameter '<name>' value"` (message
176
+ contains both "failed to parse query parameter" and "value"). Matches
177
+ the shape of getzep/graphiti#1525 (contributor maui314159, fixed
178
+ upstream via merged PR#1531): a NUL byte (`\\x00`) embedded in a string
179
+ value -- commonly present in text extracted from PDFs/PPTX -- makes
180
+ FalkorDB's client-side query-parameter serialization
181
+ (`falkordb/helpers.py::quote_string`, which only escapes backslash and
182
+ double-quote) emit the NUL byte verbatim into the `CYPHER <key>=<value>
183
+ ...` parameter header FalkorDB's own client builds. FalkorDB's parser
184
+ then rejects the ENTIRE query -- including a bulk episode save shaped
185
+ like `UNWIND $episodes AS e MERGE (n:Episodic {uuid:e.uuid})
186
+ SET n.content=e.content` -- with this exact message, silently dropping
187
+ every episode in that call, not just the one containing the NUL byte.
188
+ PR#1531's real fix strips `\\x00` recursively from FalkorDB query
189
+ parameters in `FalkorDriver.execute_query`/`FalkorDriverSession.run`
190
+ before they reach the client, closing the bug upstream as of that
191
+ merge -- this signal exists so the harness can still *classify* the
192
+ failure shape if it ever reproduces against an older/unpatched
193
+ graphiti-core version, the same "diagnostic classifier, not a live
194
+ reproduction" role every other `CrashSignal` member plays. The real
195
+ exception is a `redis`-client `ResponseError`; see
196
+ zep_graphiti_selfhosted_adapter.py's `_classify_crash()` for the exact
197
+ substring match (message-only, not `isinstance`, same reasoning
198
+ `QUERY_SANITIZATION_ERROR` above documents: this adapter does not
199
+ import the optional `redis`/`falkordb` packages just to `isinstance`-
200
+ check against them)."""
201
+
202
+ UNKNOWN = "unknown"
203
+ """The caught exception's type/message did not match any known crash
204
+ shape this enum classifies. This is the honest, expected outcome for
205
+ the overwhelming majority of failures -- network errors, auth
206
+ failures, database unavailability, and any vendor bug other than the
207
+ specific shapes above all land here. `UNKNOWN` is not a gap in this
208
+ classification; it is what "not one of the specific bug classes we
209
+ know how to recognize" looks like."""
210
+
211
+
212
+ class BackendAPIError(Exception):
213
+ """Raised when a configured backend's API call fails (network, auth,
214
+ 5xx, malformed response). Distinct from BackendNotConfiguredError so
215
+ callers can tell "never had credentials" apart from "had credentials,
216
+ the call still failed."
217
+ """
218
+
219
+ def __init__(
220
+ self, backend_name: str, detail: str, crash_signal: CrashSignal | None = None
221
+ ) -> None:
222
+ self.backend_name = backend_name
223
+ self.detail = detail
224
+ self.crash_signal = crash_signal
225
+ """Which known internal-library crash shape (see CrashSignal above)
226
+ this failure's exception matched, or None when the adapter that
227
+ raised this error did not attempt classification at all (most
228
+ adapters -- classification is opt-in per adapter, the same
229
+ "None means not attempted" convention StoreResult.verified uses).
230
+ A non-None value that equals CrashSignal.UNKNOWN means the adapter
231
+ DID attempt classification and the exception simply didn't match
232
+ any known shape -- that is a meaningfully different fact from
233
+ "this adapter never classifies," which is why the default is None,
234
+ not UNKNOWN."""
235
+ super().__init__(f"{backend_name} API error: {detail}")
236
+
237
+
238
+ class ConflictSignal(StrEnum):
239
+ """How a backend responded when a query touched a fact that had been
240
+ contradicted by a later store()/update() call.
241
+
242
+ This is the classification the contradiction-detection eval reads off
243
+ every query() response -- see evals/contradiction.py.
244
+ """
245
+
246
+ FLAGGED = "flagged"
247
+ """The backend surfaced the conflict: it returned both versions, an
248
+ explicit conflict marker, or otherwise made the contradiction visible
249
+ to the caller instead of silently resolving it."""
250
+
251
+ SILENT_OVERWRITE = "silent_overwrite"
252
+ """The backend replaced the old fact with the new one and gave no
253
+ signal in the query response that a prior, different value ever
254
+ existed."""
255
+
256
+ SERVED_STALE = "served_stale"
257
+ """The backend returned the old, now-contradicted fact and gave no
258
+ signal that a newer, conflicting fact had been stored since."""
259
+
260
+ NOT_APPLICABLE = "not_applicable"
261
+ """The backend has no update/contradiction-relevant primitive to
262
+ evaluate here (see MemoryBackendAdapter.supports_update). Recorded
263
+ explicitly rather than silently dropping the backend from the eval
264
+ table."""
265
+
266
+ EMPTY_OR_LOST = "empty_or_lost"
267
+ """The backend DOES have an update/contradiction-relevant primitive
268
+ (MemoryBackendAdapter.supports_update is True), the store()/update()/
269
+ query() calls all completed without raising BackendAPIError, but the
270
+ query response came back with zero records -- no exception, no error,
271
+ just nothing. This is distinct from NOT_APPLICABLE, which means the
272
+ backend structurally cannot be evaluated here at all: EMPTY_OR_LOST
273
+ means the backend *should* have had something to say and silently
274
+ didn't. This is the "call succeeded but produced nothing" failure mode
275
+ the benchmark exists to catch -- see evals/contradiction.py's
276
+ classify_case for exactly when this is assigned, never a vendor's own
277
+ self-report."""
278
+
279
+ EDGE_INTEGRITY_VIOLATION = "edge_integrity_violation"
280
+ """A returned record is edge-shaped (its `raw` fragment carries both a
281
+ `source_node_uuid` and a `target_node_uuid` key -- the property names
282
+ graphiti_core's `EntityEdge` writes on every relationship) but at
283
+ least one of those two values is missing or falsy. This is a
284
+ structural integrity check, not a value-classification one: it exists
285
+ because two independently root-caused, real graphiti_core bugs
286
+ produced exactly this shape --
287
+
288
+ * getzep/graphiti#1013: the Neo4j bulk edge-save Cypher query built
289
+ its SET clause from an enumerated field list that omitted
290
+ `EntityEdge.attributes` and `reference_time`, so a bulk-saved edge
291
+ could come back missing properties a single-edge save() would
292
+ have written. Confirmed fixed upstream (merged) by switching the
293
+ bulk query to `SET e = edge` (Neo4j's default-case Cypher
294
+ variable; FalkorDB's branch of the same function uses `SET r =
295
+ edge`) -- see
296
+ graphiti_core/models/edges/edge_db_queries.py's
297
+ `get_entity_edge_save_bulk_query()` on the `main` branch as of
298
+ 2026-07-16.
299
+ * getzep/graphiti#1001: FalkorDB's `add_triplet()`-era edge-creation
300
+ path used a `MATCH` (not `MERGE`/`CREATE`) for the endpoint nodes,
301
+ which silently no-ops if either node is absent, and never set
302
+ `source_node_uuid`/`target_node_uuid` as edge properties at all.
303
+ Closed via the same #1013 refactor; the FalkorDB driver has since
304
+ been rewritten (see `graphiti_core/driver/falkordb/operations/`)
305
+ and no longer exposes the old `add_triplet()` method.
306
+
307
+ Both are described upstream as already fixed/closed on `main` as of
308
+ this adapter's build -- this signal exists so the harness can *detect*
309
+ the failure shape if it ever reproduces against an older/pinned
310
+ graphiti-core version, or against a different backend that has the
311
+ same class of bug, not because memtrust has reproduced either bug
312
+ live against a running Neo4j/FalkorDB instance. See
313
+ docs/methodology.md and zep_graphiti_selfhosted_adapter.py for the
314
+ honesty caveat on what this repo has and has not actually run."""
315
+
316
+
317
+ class RankingSignal(StrEnum):
318
+ """Whether a backend's claimed result ordering is actually driven by a
319
+ real per-record ranking signal, or silently degenerates to something
320
+ else (typically insertion order) while still returning fully correct
321
+ *content*.
322
+
323
+ This is a distinct taxonomy from ConflictSignal above, not a variant of
324
+ it. ConflictSignal classifies whether returned content is correct after
325
+ a contradiction; RankingSignal classifies whether correct content came
326
+ back in the right order. A backend can score perfectly on every
327
+ ConflictSignal case and still be silently broken here -- the returned
328
+ records are the right records, just wrongly ordered, which
329
+ ConflictSignal structurally cannot see because there was never a
330
+ content conflict to classify.
331
+
332
+ Motivating case: mempalace/mempalace#1733 (GitHub user Kartalops).
333
+ `mempalace/layers.py`'s `Layer1.generate()` sorts drawers by
334
+ `importance`/`emotional_weight`/`weight`, but no ingest path in the
335
+ real package ever writes those keys -- confirmed 0/45,969 drawers on a
336
+ real palace. `importance` silently defaults to a constant, so the
337
+ "ranked by importance" sort degenerates to plain insertion order, and
338
+ `wake-up` returns the oldest moments instead of the documented "high
339
+ importance, recent" ones. This is the classification the
340
+ ranking-quality eval reads off every query() response -- see
341
+ evals/ranking_quality.py.
342
+ """
343
+
344
+ SIGNAL_DRIVEN = "signal_driven"
345
+ """A ranking-relevant metadata field (importance/emotional_weight/
346
+ weight or whatever the adapter checks) carries genuinely varied values
347
+ across the returned records -- a real per-record signal exists that
348
+ could plausibly be driving the order. This is a claim about the
349
+ presence of a signal, not proof the backend actually sorted by it; see
350
+ evals/ranking_quality.py's classify_case-equivalent, which cross-checks
351
+ this claim against the actual returned order before crediting it."""
352
+
353
+ MISSING_ORDERING_KEY = "missing_ordering_key"
354
+ """Every returned record shares the same value for a ranking-relevant
355
+ metadata field -- including the case where the field is absent from
356
+ every record entirely, which is indistinguishable from "silently
357
+ defaults to a constant" from the caller's side. No real per-record
358
+ signal is driving order, whatever the backend's documentation claims.
359
+ This is the exact mempalace/mempalace#1733 shape: `importance` present
360
+ (or absent) and identical across every record because no ingest path
361
+ ever wrote a real value."""
362
+
363
+ ORDER_INCONSISTENT = "order_inconsistent"
364
+ """A ranking-relevant metadata field carries genuinely varied values
365
+ across the returned records, but the actual returned order does not
366
+ correlate with those values (not sorted descending by the field). A
367
+ real signal exists and the backend still isn't using it to order
368
+ results -- an adjacent but distinct bug from MISSING_ORDERING_KEY."""
369
+
370
+ NOT_APPLICABLE = "not_applicable"
371
+ """No ranking-relevant metadata field was present on any returned
372
+ record and the result set gave no other basis (e.g. too few records)
373
+ to evaluate ordering at all. Recorded explicitly, never silently
374
+ dropped from the results table -- same convention as
375
+ ConflictSignal.NOT_APPLICABLE."""
376
+
377
+ RERANK_FALLBACK = "rerank_fallback"
378
+ """This response's own candidate set carries the same input shape two
379
+ independent, real OpenViking bug reports document as silently
380
+ degrading a reranker-backed query to raw vector-similarity scores with
381
+ no caller-visible signal: an empty-string document
382
+ (volcengine/OpenViking#1737, contributor wychosenone --
383
+ `RerankClient.rerank_batch` given an empty-string document makes the
384
+ VikingDB rerank API return a null response, which a swallowed
385
+ `TypeError` silently falls back from) or a candidate batch whose total
386
+ estimated token count exceeds the reranker's real input budget
387
+ (volcengine/OpenViking#2739/#2880, contributor hhspiny -- the
388
+ hierarchical retriever sends unbounded L2 abstracts, some 10k+ tokens,
389
+ into a single rerank batch, blowing past a typical local reranker's
390
+ ~4096-token limit; hhspiny's own production logs showed 280 failed
391
+ requests in this exact shape).
392
+
393
+ Both cited bugs are already fixed upstream (#1737 via merged #1933;
394
+ #2739/#2880 via merged PR#3289) by the time this signal was built, so
395
+ this adapter cannot reproduce either against a live current-version
396
+ OpenViking instance -- this signal exists so memtrust can *detect* the
397
+ same failure shape if it ever reproduces against an older/self-hosted/
398
+ pinned OpenViking version, the same honesty convention
399
+ ConflictSignal.EDGE_INTEGRITY_VIOLATION and
400
+ CorruptionSignal.CONFIG_REJECTED already establish above for
401
+ already-fixed-upstream bug classes. See openviking_adapter.py's
402
+ `_rerank_fallback_risk()` for the exact heuristic (empty-content
403
+ check, then an approximate ~4-chars/token budget estimate) and its own
404
+ honesty caveat: this flags the SHAPE a response's candidate set
405
+ carries, it does not confirm this specific live call actually fell
406
+ back to vector scores server-side, since this adapter has no way to
407
+ observe OpenViking's internal rerank-provider call at all."""
408
+
409
+
410
+ class CorruptionSignal(StrEnum):
411
+ """How a backend's *write path* (construction-time config, or a store()/
412
+ update() call) handled a failure mode that ConflictSignal cannot express.
413
+
414
+ ConflictSignal above classifies how a *query response* handled a
415
+ contradiction between two stored facts -- it is inherently a read-side,
416
+ post-store observation. The bug classes this enum exists for are
417
+ different in kind, not just in vendor: they either (a) happen at
418
+ construction time, before any store()/query() call is ever made, when a
419
+ backend rejects an invalid configuration (e.g. mem0's Kuzu graph store
420
+ historically raised ValueError from MemoryGraph.__init__ when
421
+ embedding_dims was None/<=0 -- mem0ai/mem0#3558), or (b) happen silently
422
+ *during* a write, corrupting data with no exception and no query-side
423
+ signal that anything went wrong until a much later, unrelated read fails
424
+ to find a match (e.g. mem0's Valkey/Redis vector stores historically
425
+ called `np.array(None, dtype=np.float32)` on a metadata-only update,
426
+ silently writing a 4-byte garbage vector over a real embedding --
427
+ mem0ai/mem0#4362). Both are init/write-path phenomena a query-response
428
+ enum has no vocabulary for, which is why this is a separate enum rather
429
+ than new ConflictSignal members.
430
+
431
+ Adapters that have no surface to observe either failure mode (i.e. every
432
+ adapter except ones like Mem0DirectAdapter that hold a direct, in-process
433
+ handle to the vendor library rather than talking to it over HTTP) should
434
+ report NOT_APPLICABLE rather than guessing.
435
+ """
436
+
437
+ CONFIG_REJECTED = "config_rejected"
438
+ """Backend construction (or a construction-time-equivalent call inside
439
+ store()/query()) raised ValueError/pydantic.ValidationError for an
440
+ invalid configuration, and the adapter caught it and classified it
441
+ instead of letting it propagate as an unhandled crash. This is the
442
+ signal a caller should see for the mem0ai/mem0#3558 bug *class*: "bad
443
+ embedding_dims config is rejected before it can silently corrupt a
444
+ graph DB," not a literal re-run of that exact removed code path -- see
445
+ Mem0DirectAdapter's module docstring for why the original Kuzu code
446
+ this bug lived in no longer exists in the installed mem0ai package."""
447
+
448
+ VECTOR_ZEROED = "vector_zeroed"
449
+ """A write call completed without raising, but the adapter's own
450
+ inspection of what was actually persisted (not the vendor's normal
451
+ read API, which -- per mem0ai/mem0#4336 -- does not surface this) shows
452
+ the embedding was replaced with a zero-length or wrong-dimensionality
453
+ vector instead of being left untouched. This is what
454
+ mem0ai/mem0#4362 fixed for Valkey/Redis; a backend still exhibiting it
455
+ would report this signal, a fixed one reports CLEAN for the same
456
+ operation."""
457
+
458
+ CLEAN = "clean"
459
+ """The write/config-validation path completed with no corruption or
460
+ config-rejection detected for this operation."""
461
+
462
+ NOT_APPLICABLE = "not_applicable"
463
+ """This adapter has no surface to observe either failure mode (most
464
+ REST-based adapters: a vendor's HTTP API gives no way to inspect
465
+ construction-time config validation separately from a network error, or
466
+ to inspect raw stored vector bytes independently of its own search/get
467
+ responses). Recorded explicitly rather than silently omitting the
468
+ field."""
469
+
470
+
471
+ class ExtractionSignal(StrEnum):
472
+ """Whether a store() call that completed without raising actually found
473
+ something to persist, or silently extracted zero facts from the input.
474
+
475
+ This is a distinct taxonomy from ConflictSignal/RankingSignal/
476
+ CorruptionSignal above, not a variant of any of them. Those three all
477
+ classify *query()* responses or write-path *corruption*; this one
478
+ classifies the one failure mode none of them can see: a store() call
479
+ that raises nothing, returns a normal-shaped 200/dict response, and yet
480
+ the backend's own LLM-based fact-extraction step found nothing worth
481
+ keeping -- so the response has no usable `id` (and no `results[0].id`)
482
+ for the adapter to report back to the caller. Byte-for-byte, that
483
+ response is indistinguishable from a genuine successful store unless an
484
+ adapter explicitly checks for it, which is exactly what
485
+ `mem0_adapter.py`'s `_extract_memory_id()` historically did not do: it
486
+ silently returned `""` and let a normal-looking StoreResult mask the
487
+ difference between "stored" and "extracted nothing."
488
+
489
+ Motivating case: mem0ai/mem0#5178 (GitHub user thalesfsp) -- `add()`
490
+ can return a response with zero extracted memories for input the caller
491
+ clearly intended to be remembered, with no exception and no other
492
+ signal that anything different happened versus a normal store. This
493
+ backlog item originally also referenced mem0ai/mem0#5878/#5901/#5903
494
+ (GitHub user Bartok9) as adjacent reports of the same "store()
495
+ succeeded but the fact silently never made it in" shape; #5178 is the
496
+ one this signal is scoped against directly, since it is the row this
497
+ change closes.
498
+
499
+ This is the "LLM extraction silently swallowed" failure class: a vendor
500
+ fact-extraction pass (which can legitimately decide "there is nothing
501
+ worth remembering here," e.g. for chit-chat with no factual content)
502
+ and a vendor bug that drops a real fact are indistinguishable from the
503
+ response shape alone. This signal does not attempt to tell those two
504
+ apart -- see EMPTY_EXTRACTION's docstring -- it exists so that
505
+ distinction is at least visible to a caller instead of silently
506
+ collapsed into an ordinary-looking StoreResult.
507
+ """
508
+
509
+ FACTS_EXTRACTED = "facts_extracted"
510
+ """store() completed without raising and the response carried a usable
511
+ memory id (a top-level `id`, or `results[0].id`) -- the backend's
512
+ extraction step found and persisted at least one fact."""
513
+
514
+ EMPTY_EXTRACTION = "empty_extraction"
515
+ """store() completed without raising, but the response carried no
516
+ usable memory id anywhere this adapter knows to look. This is the exact
517
+ mem0ai/mem0#5178 shape: a call that looks identical to a successful
518
+ store from its return value alone, except that nothing was actually
519
+ extracted. Recorded explicitly rather than silently returning a
520
+ StoreResult with memory_id="" and no other trace anything unusual
521
+ happened -- a caller (or an eval) that only checks "did store() raise"
522
+ has no way to see this otherwise."""
523
+
524
+ NOT_APPLICABLE = "not_applicable"
525
+ """This adapter has no extraction concept to observe here -- either the
526
+ backend has no LLM-extraction step at all (most non-mem0 adapters,
527
+ which persist exactly what they're given rather than deciding what's
528
+ worth keeping), or this specific store() call failed for a different,
529
+ already-classified reason (e.g. Mem0DirectAdapter's CONFIG_REJECTED
530
+ corruption_signal path below, where memory_id="" means "construction
531
+ was rejected before any extraction could run," not "extraction ran and
532
+ found nothing" -- conflating the two would misattribute a config error
533
+ to this signal). Recorded explicitly, same convention as
534
+ ConflictSignal.NOT_APPLICABLE/RankingSignal.NOT_APPLICABLE/
535
+ CorruptionSignal.NOT_APPLICABLE above."""
536
+
537
+
538
+ class EmbeddingDriftSignal(StrEnum):
539
+ """Whether a record stored before an embedding-model migration remained
540
+ retrievable after the migration, or silently stopped being findable --
541
+ a structurally distinct failure mode from every enum above. ConflictSignal
542
+ classifies a query response after a *content* contradiction;
543
+ RankingSignal classifies whether correct content came back in the right
544
+ *order*; CorruptionSignal classifies a single write call's own
545
+ construction/write-path failure. None of those can express "this record
546
+ was fine, a completely unrelated later store() call for a *different*
547
+ record silently broke it" -- which is exactly the shape of the
548
+ motivating bug.
549
+
550
+ Motivating case: volcengine/OpenViking#1523 (contributor A0nameless0man).
551
+ An embedder migration silently degrades search quality mid-migration:
552
+ switching embedding models overwrites previously-stored vectors in
553
+ place with no dimension/model validation, so records embedded under the
554
+ old model can stop being retrievable once new-model writes start
555
+ landing -- with no exception, no error, and no signal in any single
556
+ query response that this happened. A single query() call has no way to
557
+ see this: the record that broke wasn't touched by the query, it was
558
+ broken by a *different*, earlier store() call that happened to migrate
559
+ embedding models. This is why detecting it requires an eval that
560
+ controls two store() calls under two different fixture-level model
561
+ labels and compares retrievability before vs. after, not a per-response
562
+ classification like ConflictSignal/RankingSignal/CorruptionSignal
563
+ above -- see evals/embedding_drift.py for the harness-level eval this
564
+ signal is scored by, and its module docstring plus docs/methodology.md
565
+ for the honest scope of what this can and cannot prove without a real
566
+ backend that exposes embedding-model metadata (none in this repo do,
567
+ as of this writing).
568
+ """
569
+
570
+ EMBEDDING_DRIFT = "embedding_drift"
571
+ """A record confirmed retrievable (by its own content, as a substring
572
+ of a returned record's content) immediately after being stored under
573
+ one fixture-level embedding-model label became unretrievable after
574
+ later records were stored under a *different* fixture-level
575
+ embedding-model label in the same session -- the exact volcengine/
576
+ OpenViking#1523 shape. Only assigned when the record was provably
577
+ retrievable BEFORE the migration step, so a record that was never
578
+ retrievable in the first place (a generic recall miss, unrelated to
579
+ any migration) is never misattributed to drift -- see
580
+ evals/embedding_drift.py's classify_embedding_drift_record()."""
581
+
582
+ CLEAN = "clean"
583
+ """The record was confirmed retrievable before the migration step and
584
+ remained retrievable (same content match) afterward -- no drift
585
+ observed for this record."""
586
+
587
+ NOT_APPLICABLE = "not_applicable"
588
+ """The record's pre-migration retrievability could not be confirmed at
589
+ all (it was never observed retrievable even before any migration step
590
+ ran), so there is no valid baseline to compare against -- recorded
591
+ explicitly rather than guessed at either way, same convention as every
592
+ other NOT_APPLICABLE member in this module."""
593
+
594
+
595
+ class ExtractionQualitySignal(StrEnum):
596
+ """Whether a backend's write path retained content it should have
597
+ filtered, correctly filtered content it should have discarded, and
598
+ whether re-storing previously-recalled content silently fans out into
599
+ duplicate records.
600
+
601
+ This is a distinct taxonomy from ConflictSignal, RankingSignal, and
602
+ CorruptionSignal above, not a variant of any of them. Those three all
603
+ presuppose a *single, specific* stored fact -- one that gets
604
+ contradicted, one whose order matters, one whose write path corrupts
605
+ it. This taxonomy instead asks a corpus-scale question that none of
606
+ them can express: across many independently stored items, does the
607
+ backend retain content indiscriminately regardless of whether it was
608
+ ever worth storing at all? `evals/contradiction.py`'s own module
609
+ docstring is explicit that it is a *single-case* contradiction-
610
+ detection eval; this is the distinct "extraction quality at scale"
611
+ counterpart the backlog asked for -- see evals/extraction_quality.py.
612
+
613
+ Motivating case: mem0ai/mem0#4573 (GitHub user jamebobob). A 32-day
614
+ real production audit found 97.8% of 10,134 stored mem0 entries were
615
+ junk the extraction pipeline should never have persisted: boot-file
616
+ restating (~52.7% -- the agent's own startup/config text re-stored as
617
+ if it were a new memory every session), cron/heartbeat noise (~11.5%
618
+ -- routine liveness-check output with no durable content), system
619
+ dumps (~8.2% -- raw tracebacks/error payloads), and hallucinated
620
+ profiles (~5.2% -- attributes about the user the model invented, never
621
+ actually stated). On top of the base junk-retention problem, jamebobob
622
+ also documented a feedback-loop case: a single hallucinated memory,
623
+ once recalled back into context, got re-extracted and re-stored as
624
+ "new" input -- and that single re-store fanned out into 808 duplicate
625
+ records, not one.
626
+
627
+ Honesty caveat (see evals/extraction_quality.py and
628
+ docs/methodology.md for the full write-up): this enum and the eval
629
+ that classifies against it are structurally validated against
630
+ hand-written fake adapters in this repo's own test suite, proving the
631
+ *classification logic* correctly tells retained-junk apart from
632
+ rejected-junk and detects unexpected record-count growth after a
633
+ recall-then-re-store sequence. Neither this enum nor
634
+ evals/extraction_quality.py has been run against a live mem0 instance
635
+ at jamebobob's real 10,000+ entry scale -- no live number this eval
636
+ could report should be read as a reproduction of the 97.8%/808 figures
637
+ above, only as a harness now capable of measuring the same shape of
638
+ failure if pointed at a real backend.
639
+ """
640
+
641
+ RETAINED_JUNK = "retained_junk"
642
+ """A case whose ground-truth `should_be_stored` is False (boot-file
643
+ restating, cron/heartbeat noise, a system dump, or a hallucinated
644
+ profile) was still retrievable via query() after being stored -- the
645
+ backend has no effective extraction-quality gate, or the gate it has
646
+ did not catch this item. This is the exact failure mode jamebobob's
647
+ audit measured at 97.8% against real mem0."""
648
+
649
+ REJECTED_JUNK = "rejected_junk"
650
+ """A case whose ground-truth `should_be_stored` is False was NOT
651
+ retrievable via query() after being stored -- something in the write
652
+ path (an extraction-quality gate, a dedup/relevance filter, or
653
+ equivalent) correctly kept it out. The good outcome for a junk case."""
654
+
655
+ RETAINED_VALID = "retained_valid"
656
+ """A case whose ground-truth `should_be_stored` is True was
657
+ retrievable via query() after being stored -- genuinely valuable
658
+ content survived the write path. The good outcome for a valid case."""
659
+
660
+ LOST_VALID = "lost_valid"
661
+ """A case whose ground-truth `should_be_stored` is True was NOT
662
+ retrievable via query() after being stored -- an overly aggressive
663
+ filter (or an unrelated write-path bug) dropped content that should
664
+ have been kept. The bad outcome for a valid case, and the necessary
665
+ counterweight to RETAINED_JUNK: a backend that filters everything
666
+ would score perfectly on junk-rejection while failing every real user
667
+ here, which is why this eval scores both axes independently rather
668
+ than only measuring junk retention."""
669
+
670
+ FEEDBACK_LOOP_DUPLICATE = "feedback_loop_duplicate"
671
+ """After storing a seed item, querying it back (simulating an agent
672
+ recalling it into context), and re-storing that exact recalled text
673
+ as if it were new input, the number of matching records grew by more
674
+ than the single store() call that re-storage represents. This is the
675
+ generalized shape of jamebobob's exact 808-duplicate finding: one
676
+ piece of recalled content, re-extracted, fanning out into many stored
677
+ copies instead of one (or zero, if the backend dedups). See
678
+ evals/extraction_quality.py's `classify_feedback_loop_case` for the
679
+ exact growth threshold."""
680
+
681
+ NO_UNEXPECTED_GROWTH = "no_unexpected_growth"
682
+ """The feedback-loop sequence above completed and the record count
683
+ grew by at most what the single re-store() call should have added --
684
+ no runaway duplication observed. The good outcome for a feedback-loop
685
+ case."""
686
+
687
+ NOT_APPLICABLE = "not_applicable"
688
+ """A case could not be classified at all -- the store()/query() call
689
+ sequence raised BackendAPIError. Recorded explicitly, never silently
690
+ dropped from the results table, same convention as every other signal
691
+ enum's NOT_APPLICABLE above."""
692
+
693
+
694
+ class VectorIntegritySignal(StrEnum):
695
+ """Whether a delete_prefix() call actually removed every vector-index
696
+ entry it addressed, or left child entries permanently orphaned while
697
+ reporting the prefix as gone.
698
+
699
+ This is the opposite polarity of ResourceSyncSignal (evals/
700
+ resource_sync_safety.py), which classifies files a resync wrongly
701
+ DELETED. This taxonomy classifies the reverse failure: entries a
702
+ delete wrongly KEPT. Distinct from CorruptionSignal above, which
703
+ classifies a single write call's own construction/write-path failure,
704
+ not a multi-entry recursive-delete's completeness.
705
+
706
+ Motivating case: volcengine/OpenViking#3064 (contributor AcTiveXXX).
707
+ `viking_fs.rm()`'s orphan-cleanup path, reached when a target path no
708
+ longer exists in AGFS (e.g. files were deleted directly from the
709
+ filesystem, bypassing OpenViking's own API), tries to discover child
710
+ URIs to delete via `_collect_uris()` -- which walks AGFS directory
711
+ listings and wraps that walk in a bare `except Exception: pass`. When
712
+ the directory itself is already gone, the listing call raises, the
713
+ bare except silently swallows it, and `_collect_uris()` returns an
714
+ empty list. Only the root URI then reaches `delete_uris()`, whose
715
+ filter is an exact-match `Eq("uri", ...)` with no prefix/recursive
716
+ semantics -- so every child vector-index entry beneath the root
717
+ survives, permanently orphaned (AcTiveXXX measured ~9% orphan rate in
718
+ a real deployment: ~100 orphans out of ~1,000 total entries). The
719
+ listing-based discovery this adapter's own `delete_prefix()` uses (see
720
+ openviking_adapter.py) is subject to the identical AGFS-listing
721
+ limitation when pointed at a live OpenViking server exhibiting this
722
+ bug -- this taxonomy exists so that shape is at least visible to a
723
+ caller instead of silently reported as a clean delete.
724
+ """
725
+
726
+ ORPHANED_VECTOR_ENTRY = "orphaned_vector_entry"
727
+ """After delete_prefix(), list_resource_paths() confirms every path
728
+ under the deleted prefix is gone (an AGFS-listing-level "deleted"
729
+ verdict), but a subsequent query() for content seeded under that
730
+ prefix still returns a matching record -- the vector index kept an
731
+ entry the filesystem-level listing already reports as removed. The
732
+ exact volcengine/OpenViking#3064 shape: `delete_uris()`'s exact-match
733
+ filter only ever removed the root URI, so child vector entries survive
734
+ the delete undetected by anything that only checks AGFS listings."""
735
+
736
+ CLEAN = "clean"
737
+ """After delete_prefix(), neither list_resource_paths() nor query()
738
+ surfaces any trace of content seeded under the deleted prefix -- the
739
+ delete was actually complete, not just reported complete."""
740
+
741
+ NOT_APPLICABLE = "not_applicable"
742
+ """Either the adapter has no prefix-delete primitive to exercise at
743
+ all (MemoryBackendAdapter.supports_prefix_delete is False), or a
744
+ case's before/after state could not be established (e.g. seeding
745
+ itself failed). Recorded explicitly, same convention as every other
746
+ NOT_APPLICABLE member in this module."""
747
+
748
+
749
+ class ConsistencySignal(StrEnum):
750
+ """Whether an identical query, repeated against unchanged stored data,
751
+ returns the same result set every time.
752
+
753
+ Every other taxonomy in this module classifies a SINGLE query()
754
+ response in isolation (correctness after a contradiction, ordering,
755
+ write-path corruption, extraction). None of them can see a backend
756
+ that is internally non-deterministic: each individual response can
757
+ look perfectly well-formed -- real records, real content, no error --
758
+ while the *set* of records returned for the exact same query changes
759
+ from call to call with nothing in the fixture data ever having
760
+ changed. This taxonomy exists specifically to classify that
761
+ consistency question, which requires issuing the same query() call
762
+ multiple times and comparing result sets against each other, not
763
+ against any ground truth a single response could carry.
764
+
765
+ Motivating case: volcengine/OpenViking#204 (contributor ponsde, a
766
+ repeat contributor to this project). `search()`/`find()` returned
767
+ non-deterministic result sets for identical repeated queries -- 5 runs
768
+ of the same query returned an average pairwise Jaccard similarity of
769
+ 0.11 over the returned memory/resource URIs, and in ponsde's own
770
+ later, more rigorous 3-query x 5-run test, some query/method
771
+ combinations shared ZERO common URIs across all 5 runs. ponsde
772
+ self-diagnosed two candidate root causes through extensive follow-up
773
+ (source-tracing `viking_fs.py`'s no-session code path, and a clean-vs-
774
+ production A/B test): a production vector collection created at
775
+ `Dimension:3072` while the active embedding config specified `1024`
776
+ (an embedding-dimension mismatch corrupting the underlying ANN index),
777
+ and/or non-deterministic graph traversal in the HNSW ANN search
778
+ implementation itself, amplified by `HierarchicalRetriever`'s
779
+ recursive search mechanism. Neither root cause is something this
780
+ adapter can fix or directly observe (both live inside OpenViking's own
781
+ C++ engine layer) -- this taxonomy exists so memtrust can at least
782
+ *detect* the same non-deterministic-repeated-query shape ponsde
783
+ documented, using the identical Jaccard-similarity methodology his own
784
+ hand-built repro script already used. See
785
+ evals/result_consistency.py for the eval this taxonomy is scored by.
786
+ """
787
+
788
+ CONSISTENT = "consistent"
789
+ """N repeated, identical query() calls against unchanged fixture data
790
+ produced an average pairwise (consecutive-run) Jaccard similarity at
791
+ or above this eval's configured threshold -- the backend's result set
792
+ for this query is stable run to run, within the tolerance the
793
+ threshold allows for a genuinely ranked-and-truncated top_k result
794
+ that could reasonably reorder near a score tie."""
795
+
796
+ INCONSISTENT = "inconsistent"
797
+ """N repeated, identical query() calls against unchanged fixture data
798
+ produced an average pairwise Jaccard similarity below the configured
799
+ threshold -- the exact volcengine/OpenViking#204 shape: the same
800
+ query, against data that never changed between calls, returns a
801
+ meaningfully different result set from one call to the next."""
802
+
803
+ NOT_APPLICABLE = "not_applicable"
804
+ """Fewer than 2 of the N repeated query() calls completed without
805
+ raising BackendAPIError, so there is no valid pair of result sets to
806
+ compare -- recorded explicitly rather than guessed at either way, same
807
+ convention as every other NOT_APPLICABLE member in this module."""
808
+
809
+
810
+ class LanguageDegradationSignal(StrEnum):
811
+ """Whether a backend's hybrid retrieval pipeline's non-semantic
812
+ signals (BM25 keyword matching, entity-based boosting) genuinely fired
813
+ for a given query, or silently degraded to semantic-only retrieval
814
+ with no error surfaced -- a language-conditioned failure mode none of
815
+ `ExtractionQualitySignal`/`RankingSignal`/`EmbeddingDriftSignal` above
816
+ classify (all three concern content/order/drift, never whether a
817
+ *language-dependent* pipeline stage ran at all).
818
+
819
+ Motivating case: wangjiawei-vegetable (rank 147, mem0ai/mem0#4884,
820
+ open, merge-ready companion PR #4943 as of this build). mem0 v3's
821
+ hybrid retrieval pipeline hardcodes spaCy's English model
822
+ `en_core_web_sm` for BOTH BM25 lemmatization
823
+ (`mem0/utils/lemmatization.py::lemmatize_for_bm25`) and entity
824
+ extraction (`mem0/utils/spacy_models.py::get_nlp_full`) -- confirmed
825
+ by reading the installed `mem0ai==2.0.12` package's own
826
+ `mem0/utils/spacy_models.py` directly: `get_nlp_full()`/
827
+ `get_nlp_lemma()` both call `spacy.load("en_core_web_sm", ...)`
828
+ unconditionally, with no language parameter or per-language model
829
+ selection anywhere in the module. For non-Latin-script text (CJK,
830
+ Arabic, Thai, Hindi, etc.), the English pipeline's tokenization/
831
+ lemmatization does not produce keyword/entity signals that
832
+ meaningfully overlap between query time and store time, so mem0's own
833
+ `mem0/memory/main.py::_search_vector_store()` can end up with an
834
+ empty `bm25_scores` dict and/or an empty `entity_boosts` dict for a
835
+ query where either would fire for equivalent English text -- and
836
+ `mem0/utils/scoring.py::score_and_rank()`'s own `has_bm25 =
837
+ bool(bm25_scores)`/`has_entity = bool(entity_boosts)` gates mean the
838
+ combined score silently falls back to semantic-only weighting, with
839
+ no exception and no field in the normal (non-`explain`) response
840
+ indicating this happened.
841
+
842
+ This signal is derived from `Memory.search(explain=True)`'s real,
843
+ installed `score_details` per-result breakdown (`bm25_score`,
844
+ `entity_boost` -- confirmed by reading `mem0/utils/scoring.py::
845
+ score_and_rank()` directly), not guessed or inferred from content
846
+ alone -- see `mem0_direct_adapter.py`'s `query()` for exactly how.
847
+ """
848
+
849
+ HYBRID_SIGNALS_ACTIVE = "hybrid_signals_active"
850
+ """At least one returned record's `score_details` showed a nonzero
851
+ `bm25_score` or `entity_boost` -- the hybrid pipeline's keyword/
852
+ entity-matching signals genuinely contributed to at least one result,
853
+ not just semantic similarity."""
854
+
855
+ SEMANTIC_ONLY_DEGRADED = "semantic_only_degraded"
856
+ """`explain=True` was requested and every returned record's
857
+ `score_details` showed `bm25_score == 0.0` AND `entity_boost == 0.0`
858
+ -- the exact mem0ai/mem0#4884 shape: hybrid retrieval silently
859
+ degraded to semantic-only, with no error or warning anywhere in the
860
+ normal response. Only assigned when records were actually returned to
861
+ inspect -- see NOT_APPLICABLE below for the zero-records case, which
862
+ is a different failure mode (EMPTY_OR_LOST-shaped, not this one)."""
863
+
864
+ NOT_APPLICABLE = "not_applicable"
865
+ """`explain` was not requested, no records were returned to inspect,
866
+ or this adapter has no `score_details`-equivalent surface to observe
867
+ at all. Recorded explicitly rather than silently defaulting to either
868
+ signal above, same convention every other signal enum's
869
+ NOT_APPLICABLE member in this package follows."""
870
+
871
+
872
+ class TemporalBoundarySignal(StrEnum):
873
+ """Whether a knowledge-graph `as_of` point-in-time query at the exact
874
+ instant a fact was invalidated resolved cleanly to one side of the
875
+ boundary, or returned both the superseded fact and its successor at
876
+ once -- a structurally distinct failure mode from every signal above.
877
+ `ConflictSignal` classifies a drawer/edge query's response to a
878
+ contradiction in general; this taxonomy is narrower and exact: it only
879
+ concerns the single shared instant where one fact's `valid_to` equals
880
+ another fact's `valid_from` for the same `(subject, predicate)`, which
881
+ is precisely the boundary a half-open-vs-closed interval comparison
882
+ decides.
883
+
884
+ Motivating case: MemPalace/mempalace#1913, fixed by merged PR#1914
885
+ (contributor ggettert, `gh pr view 1914 --repo MemPalace/mempalace`).
886
+ `_temporal_filter_sql`'s upper bound was a *closed* interval
887
+ (`t.valid_to >= ?`), so a fact whose `valid_to` equals the query's
888
+ `as_of` instant still matched -- and if a successor fact's
889
+ `valid_from` was set to that identical instant (the documented
890
+ hand-rolled-handover pattern PR#1914's own `PALACE_PROTOCOL` fix
891
+ explicitly warns against: `kg_invalidate` immediately followed by
892
+ `kg_add` at the same boundary), an `as_of` query at that exact instant
893
+ matched BOTH the old and new fact simultaneously, so a single-valued
894
+ predicate (e.g. `uses_model`) reported two contradictory values at
895
+ once. PR#1914's real fix switched the comparison to a *half-open*
896
+ interval (`t.valid_to > ?`, strict): a fact that ended exactly at the
897
+ query instant no longer matches, so the successor cleanly wins. The
898
+ PR also added a `supersede()` primitive (`mempalace_kg_supersede`) as
899
+ the preferred atomic replacement -- not wired into this adapter as of
900
+ this signal's introduction, since `MemPalaceAdapter.kg_add()`/
901
+ `kg_invalidate()`/`kg_query()` only cover the three tools
902
+ (`tool_kg_add`/`tool_kg_invalidate`/`tool_kg_query`) already confirmed
903
+ real in this adapter's module docstring; adding `kg_supersede` support
904
+ is a separate, later change, not required to detect the boundary bug
905
+ this signal exists for.
906
+
907
+ Same self-reporting convention `ConflictSignal`/`RankingSignal`
908
+ establish: the adapter classifies its OWN query response and reports
909
+ a signal here, but the eval that scores it (see
910
+ `evals/temporal_kg_boundary.py`) never trusts this as the final
911
+ answer -- it independently re-derives the same verdict from the raw
912
+ `KGFact` list the query actually returned (how many distinct objects
913
+ exist for the queried `(subject, predicate)` at that `as_of`) and
914
+ only records the adapter's self-report for comparison/transparency,
915
+ the same "claim, not proof" role `RankingSignal.SIGNAL_DRIVEN`'s
916
+ docstring already documents for that enum.
917
+ """
918
+
919
+ CLEAN = "clean"
920
+ """The `as_of` query at the boundary instant returned exactly one
921
+ object for the queried `(subject, predicate)` -- the fixed, half-open
922
+ PR#1914 contract: a fact whose `valid_to` equals the query instant is
923
+ correctly excluded, so only its successor (or only the original fact,
924
+ if no successor was ever added) is returned."""
925
+
926
+ DOUBLE_COUNT = "double_count"
927
+ """The `as_of` query at the boundary instant returned two (or more)
928
+ distinct objects for the same `(subject, predicate)` -- the exact
929
+ MemPalace/mempalace#1913 shape: a fact ending exactly at the query
930
+ instant and a successor starting at that same instant were both
931
+ matched, so a single-valued predicate reported contradictory values
932
+ simultaneously with no error or warning."""
933
+
934
+ NOT_APPLICABLE = "not_applicable"
935
+ """No `as_of` was supplied, the query returned zero facts for the
936
+ probed `(subject, predicate)`, or the `kg_add`/`kg_invalidate`/
937
+ `kg_query` call sequence itself failed (`BackendAPIError` or a
938
+ vendor-reported `success: False`) before a boundary comparison could
939
+ even be attempted. Recorded explicitly rather than silently
940
+ defaulting to CLEAN, same convention every other signal enum's
941
+ NOT_APPLICABLE member in this package follows."""
942
+
943
+
944
+ @dataclass
945
+ class MemoryRecord:
946
+ """One stored memory as returned by a backend's query response."""
947
+
948
+ memory_id: str
949
+ content: str
950
+ score: float | None = None
951
+ created_at: str | None = None
952
+ embedding_model: str | None = None
953
+ """Identifier of the embedding model that produced this record's stored
954
+ vector, IF the backend's query response exposes that information.
955
+ Defaults to `None` ("unknown/not reported") for every adapter -- most
956
+ real backends' search APIs do not surface per-record embedding-model
957
+ provenance at all (confirmed absent from OpenViking's documented
958
+ `/v1/search` response shape during this build; see
959
+ openviking_adapter.py's module docstring). This field exists so an
960
+ adapter that CAN report it has somewhere typed to put it, and so
961
+ evals/embedding_drift.py's fixture-level model-label tracking has a
962
+ real adapter-native field to cross-check against on the rare backend
963
+ that does expose this -- it is not itself proof any adapter populates
964
+ it. See EmbeddingDriftSignal above and evals/embedding_drift.py for the
965
+ eval this supports."""
966
+ embedding_dims: int | None = None
967
+ """Dimensionality of this record's stored vector, IF the backend's
968
+ query response exposes it. Same default-`None`, same-rarity caveat as
969
+ `embedding_model` above -- see its docstring."""
970
+ metadata: dict[str, str] = field(default_factory=dict)
971
+ attributes: dict[str, object] = field(default_factory=dict)
972
+ """Structured, non-string-coerced properties this record's backend
973
+ attaches to it -- e.g. graphiti_core's `EntityEdge.attributes` dict,
974
+ which can hold arbitrary typed values (not just strings) describing
975
+ the edge/entity. Distinct from `metadata` above (harness-derived,
976
+ string-only markers like `invalid_at`) and from `raw` below (the
977
+ entire unmodified response fragment, kept for audit purposes only).
978
+ `attributes` exists specifically so a backend's own structured
979
+ per-record properties survive the adapter boundary in a typed-enough
980
+ shape for an eval to inspect programmatically, rather than forcing
981
+ every consumer to dig through `raw`. Defaults to empty for every
982
+ adapter that has no such concept -- most backends don't, and an empty
983
+ dict here is a normal, expected value, not a gap.
984
+ """
985
+ raw: dict[str, object] = field(default_factory=dict)
986
+ """Unmodified vendor response fragment for this record, kept for
987
+ audit/raw-log purposes. Never used for scoring -- scoring only reads
988
+ the typed fields above so every backend is judged by the same rules.
989
+ """
990
+
991
+
992
+ @dataclass
993
+ class RetrievalWarning:
994
+ """A backend's own signal that a query() response under-delivered
995
+ without treating that as a hard failure -- confirmed against the real,
996
+ merged MemPalace/mempalace#1005 PR diff (`feat(searcher): warnings +
997
+ sqlite BM25 top-up when vector underdelivers`): when its vector index
998
+ (HNSW/Chroma) drifts or a query raises, `search_memories()` no longer
999
+ hard-fails -- it returns whatever it *could* rank, plus a `warnings`
1000
+ list explaining why, plus `available_in_scope` (a sqlite-authoritative
1001
+ count of how many records actually match the query's scope,
1002
+ independent of how many the vector path could rank).
1003
+
1004
+ This is a distinct failure mode from ConflictSignal.EMPTY_OR_LOST,
1005
+ which only fires when a query response comes back with zero records.
1006
+ A backend can return, say, 3 of 50 available records -- non-empty,
1007
+ so EMPTY_OR_LOST never fires -- while still silently shortchanging the
1008
+ caller on the other 47. Before this field existed, that shape was
1009
+ indistinguishable from "the backend genuinely only found 3 relevant
1010
+ records," which misattributes a backend retrieval bug to content
1011
+ relevance. See mempalace_adapter.py's query() for where this is
1012
+ populated (the one adapter that currently sets it) and
1013
+ docs/methodology.md's adapter-confidence table for the confirmed-
1014
+ against-diff-but-not-run-against-a-live-instance caveat that applies
1015
+ here the same way it applies to the rest of that adapter."""
1016
+
1017
+ warnings: list[str]
1018
+ """Verbatim warning strings the backend attached to this response
1019
+ (e.g. "vector search unavailable: ...", "5243 drawers match this
1020
+ scope in sqlite; vector ranked 1 ..."). Kept exactly as the vendor
1021
+ returned them, never rewritten or summarized, so a report reader can
1022
+ see the backend's own explanation."""
1023
+ available_in_scope: int | None
1024
+ """The backend's own count of how many records exist in the queried
1025
+ scope, independent of how many it was actually able to rank/return
1026
+ for this call. None when the backend didn't report a count, or
1027
+ reported a value that wasn't a real int (see mempalace_adapter.py's
1028
+ parsing -- a non-int value is treated as "unknown," never coerced)."""
1029
+
1030
+
1031
+ @dataclass
1032
+ class QueryResult:
1033
+ """Result of MemoryBackendAdapter.query()."""
1034
+
1035
+ records: list[MemoryRecord]
1036
+ conflict_signal: ConflictSignal
1037
+ """How this query response handled any contradiction relevant to the
1038
+ query. Adapters that cannot detect this default to NOT_APPLICABLE and
1039
+ the eval scores it as a gap, not a pass or fail -- see
1040
+ evals/contradiction.py for the classification logic."""
1041
+ latency_ms: float
1042
+ ranking_signal: RankingSignal = RankingSignal.NOT_APPLICABLE
1043
+ """Whether a real per-record ranking signal appears to be driving this
1044
+ response's result order, distinct from ConflictSignal above -- see
1045
+ RankingSignal's docstring and evals/ranking_quality.py. Adapters that
1046
+ do not inspect their own response for a ranking-relevant field default
1047
+ to NOT_APPLICABLE, same convention as conflict_signal defaulting to
1048
+ NOT_APPLICABLE for adapters that skip contradiction detection."""
1049
+ degraded_retrieval: RetrievalWarning | None = None
1050
+ """Set when the backend's own response signaled it under-delivered on
1051
+ this query without treating it as a hard failure -- see
1052
+ RetrievalWarning's docstring for the confirmed MemPalace/mempalace#1005
1053
+ provenance. `None` (the default) means either the backend reported no
1054
+ such signal, or the adapter doesn't inspect its response for one --
1055
+ the same backward-compatible-default convention `ranking_signal` and
1056
+ `conflict_signal` already establish, so every existing adapter's
1057
+ QueryResult construction keeps working unchanged. Distinct from
1058
+ `conflict_signal == ConflictSignal.EMPTY_OR_LOST`: that only fires on
1059
+ zero records, this fires whenever the backend itself says "I
1060
+ under-delivered," including when it still returned some records --
1061
+ see RetrievalWarning's docstring for exactly why that distinction
1062
+ matters."""
1063
+ language_degradation_signal: LanguageDegradationSignal = (
1064
+ LanguageDegradationSignal.NOT_APPLICABLE
1065
+ )
1066
+ """Whether this query's hybrid retrieval signals (BM25/entity-boost)
1067
+ genuinely fired, or silently degraded to semantic-only -- see
1068
+ LanguageDegradationSignal's docstring for the mem0ai/mem0#4884
1069
+ provenance and evals/language_degradation.py. Defaults to
1070
+ NOT_APPLICABLE, same backward-compatible-default convention every
1071
+ other signal field on this dataclass follows -- only
1072
+ `Mem0DirectAdapter.query(explain=True)` currently sets this to
1073
+ something else."""
1074
+ raw: dict[str, object] = field(default_factory=dict)
1075
+
1076
+
1077
+ @dataclass
1078
+ class StoreResult:
1079
+ """Result of MemoryBackendAdapter.store()."""
1080
+
1081
+ memory_id: str
1082
+ latency_ms: float
1083
+ raw: dict[str, object] = field(default_factory=dict)
1084
+ corruption_signal: CorruptionSignal = CorruptionSignal.NOT_APPLICABLE
1085
+ """See CorruptionSignal above. Defaults to NOT_APPLICABLE so every
1086
+ existing adapter's StoreResult construction keeps working unchanged --
1087
+ only an adapter with a genuine construction-time-config or raw-write
1088
+ inspection surface (see Mem0DirectAdapter) should ever set this to
1089
+ something else."""
1090
+ extraction_signal: ExtractionSignal | None = None
1091
+ """See ExtractionSignal above. `None` (not NOT_APPLICABLE) is the
1092
+ default so every existing adapter's StoreResult construction keeps
1093
+ working unchanged, mirroring `verified`'s None-vs-False distinction
1094
+ immediately below: `None` means "this adapter does not report this
1095
+ signal at all," which is different from NOT_APPLICABLE meaning "this
1096
+ adapter reports the signal and this particular call had nothing to
1097
+ classify it against." Only the mem0-backed adapters (Mem0Adapter,
1098
+ Mem0SelfHostedAdapter, Mem0DirectAdapter), which are the ones with a
1099
+ real LLM-extraction step to observe, set this to something other than
1100
+ None -- see mem0_adapter.py's and mem0_direct_adapter.py's store()."""
1101
+ verified: bool | None = None
1102
+ """Whether a post-write read-back confirmed the content is actually
1103
+ retrievable. `None` means the adapter did not attempt verification
1104
+ (the default -- see MemoryBackendAdapter.verify_store) -- this is
1105
+ deliberately distinct from `False`. `None` is "we don't know," and
1106
+ must never be treated as "verified passed" by scoring or reporting
1107
+ code. `True`/`False` mean an adapter actually called verify_store()
1108
+ (or equivalent) and got a definitive answer.
1109
+
1110
+ Why this field exists at all: store() raising no exception has never
1111
+ been proof that a write was durable -- a vendor can return 200 OK (or
1112
+ a fake in-process success) while silently dropping or corrupting the
1113
+ write server-side. Two independently root-caused MemPalace bug
1114
+ classes did exactly this (checkpoint corruption via NUL bytes;
1115
+ stale/self-deadlocked locks silently no-oping a write). Without this
1116
+ field, that failure mode is indistinguishable from "the model just
1117
+ didn't recall the fact," which misattributes a backend durability bug
1118
+ to model quality. See docs/methodology.md for why verification is
1119
+ opt-in rather than automatic.
1120
+ """
1121
+
1122
+
1123
+ @dataclass
1124
+ class UpdateResult:
1125
+ """Result of MemoryBackendAdapter.update()."""
1126
+
1127
+ memory_id: str
1128
+ acknowledged: bool
1129
+ latency_ms: float
1130
+ raw: dict[str, object] = field(default_factory=dict)
1131
+ corruption_signal: CorruptionSignal = CorruptionSignal.NOT_APPLICABLE
1132
+ """See CorruptionSignal above and StoreResult.corruption_signal -- same
1133
+ default-NOT_APPLICABLE backward-compatibility reasoning. This is the
1134
+ field a metadata-only update() variant sets to VECTOR_ZEROED/CLEAN when
1135
+ an adapter can actually inspect what a vector-store update wrote (see
1136
+ Mem0DirectAdapter.update_metadata_only)."""
1137
+
1138
+
1139
+ @dataclass
1140
+ class RawFilterProbeResult:
1141
+ """Result of MemoryBackendAdapter.probe_raw_filter() -- a single,
1142
+ caller-controlled filter dict submitted directly to this backend's
1143
+ underlying vector-store filter-query-building layer, bypassing the
1144
+ normal session-scoped query()'s hardcoded `{"user_id": session_id}`
1145
+ filter. This is the primitive evals/filter_injection.py needs to
1146
+ reproduce mem0ai/mem0#5980's exact injection shape (a dict/list-valued
1147
+ filter value that could embed arbitrary Elasticsearch query operators
1148
+ into a `term` query) against a real backend's own filter-building code,
1149
+ not a memtrust reimplementation of it.
1150
+ """
1151
+
1152
+ accepted: bool
1153
+ """True if the underlying call completed without raising -- the
1154
+ backend's filter-building layer accepted this filter value as given,
1155
+ whatever its type. False if it raised (a validation rejection, a
1156
+ malformed-query error from the vendor's own client, or any other
1157
+ exception) -- see `error` for detail. This is a raw pass/fail
1158
+ observation only, not itself a judgment of "safe" vs "vulnerable":
1159
+ evals/filter_injection.py's FilterInjectionSignal makes that judgment
1160
+ by cross-referencing `accepted` against each case's known-malicious/
1161
+ known-benign ground truth, the same way ExtractionQualitySignal
1162
+ cross-references a case's `should_be_stored` ground truth rather than
1163
+ treating "retrievable" as inherently good or bad."""
1164
+ error: str | None = None
1165
+ """The caught exception's message when accepted is False, else None."""
1166
+ applicable: bool = True
1167
+ """False when the probe never actually reached the vector store's
1168
+ filter-building call at all -- e.g. a construction-time config
1169
+ rejection (a missing embedding-dimension config, a missing
1170
+ Elasticsearch credential) failed before `filters` was ever submitted
1171
+ to anything. Distinct from `accepted=False`, which means the call DID
1172
+ reach the filter-building layer and that layer rejected the value.
1173
+ Without this distinction, a case that never got a real filter-
1174
+ validation verdict (construction failed) would be indistinguishable
1175
+ from one the backend genuinely rejected on the filter's own merits --
1176
+ evals/filter_injection.py's classify_filter_injection_case() checks
1177
+ this first and reports FilterInjectionSignal.NOT_APPLICABLE whenever
1178
+ it is False, before ever looking at `accepted`."""
1179
+ raw: dict[str, object] = field(default_factory=dict)
1180
+ """Best-effort raw detail from a successful call (e.g. a truncated
1181
+ repr of the vendor response), kept for audit purposes only -- never
1182
+ used for scoring."""
1183
+
1184
+
1185
+ @dataclass
1186
+ class MetadataOverviewResult:
1187
+ """Result of MemoryBackendAdapter.metadata_overview() -- the
1188
+ library-level equivalent of a vendor's MCP metadata/overview tool
1189
+ (MemPalace's `mempalace_status`, see mempalace_adapter.py). Optional
1190
+ capability, same convention as RawFilterProbeResult above -- only
1191
+ meaningful for a backend whose native surface exposes an aggregate
1192
+ record-count-plus-grouping overview; see supports_metadata_overview.
1193
+
1194
+ MemPalace/mempalace#1871 (contributor alionar) found that this exact
1195
+ class of tool -- `mempalace_status`, `mempalace_list_wings`,
1196
+ `mempalace_list_rooms` -- did a full-collection scan on every call,
1197
+ O(N^2) against repeated calls, hanging the MCP server at 158K+
1198
+ records. memtrust had zero coverage of this code path before this
1199
+ type existed: evals/scale_stress.py only measures store()/query()
1200
+ latency, never a metadata/histogram-listing call.
1201
+ """
1202
+
1203
+ total_records: int | None
1204
+ """Total record count the backend reports, or None if the response
1205
+ didn't include one (see `partial`/`error`)."""
1206
+ categories: dict[str, int]
1207
+ """Top-level grouping breakdown (MemPalace: wing name -> drawer
1208
+ count). Named generically, not `wings`, so this type stays usable by
1209
+ a future adapter whose native grouping concept isn't wings/rooms at
1210
+ all -- see list_metadata_categories()/list_metadata_subcategories()
1211
+ below for the same naming choice."""
1212
+ subcategories: dict[str, int]
1213
+ """Second-level grouping breakdown (MemPalace: room name -> drawer
1214
+ count), unscoped (across every category) when this came from
1215
+ metadata_overview() rather than list_metadata_subcategories()."""
1216
+ latency_ms: float
1217
+ partial: bool = False
1218
+ """True when the backend itself reported a partial/degraded result
1219
+ (e.g. a metadata fetch that errored partway through) -- see `error`.
1220
+ Never inferred from "the call didn't raise"; only set when the
1221
+ backend's own response says so, the same rule every other *Result
1222
+ type in this module follows."""
1223
+ error: str | None = None
1224
+ """The backend's own reported error string when `partial` is True,
1225
+ else None."""
1226
+
1227
+
1228
+ @dataclass
1229
+ class MetadataCategoryCountsResult:
1230
+ """Result of MemoryBackendAdapter.list_metadata_categories() /
1231
+ list_metadata_subcategories() -- see MetadataOverviewResult above for
1232
+ the motivating bug and the generic category/subcategory naming."""
1233
+
1234
+ counts: dict[str, int]
1235
+ scope: str | None
1236
+ """The category this subcategory listing was scoped to (MemPalace:
1237
+ the `wing` argument), or None for an unscoped listing / a top-level
1238
+ category listing that has no scope concept at all."""
1239
+ latency_ms: float
1240
+ partial: bool = False
1241
+ error: str | None = None
1242
+
1243
+
1244
+ @dataclass
1245
+ class DeleteResult:
1246
+ """Result of MemoryBackendAdapter.delete().
1247
+
1248
+ On failure, adapters raise BackendAPIError instead of returning a
1249
+ DeleteResult with success=False -- `success` here reports the
1250
+ vendor's own acknowledgement shape (e.g. "deleted" vs "already
1251
+ gone"), not whether the HTTP call itself succeeded.
1252
+ """
1253
+
1254
+ success: bool
1255
+ memory_id: str
1256
+ latency_ms: float
1257
+ raw: dict[str, object] = field(default_factory=dict)
1258
+ corruption_signal: CorruptionSignal = CorruptionSignal.NOT_APPLICABLE
1259
+ """See CorruptionSignal above and StoreResult.corruption_signal/
1260
+ UpdateResult.corruption_signal -- same default-NOT_APPLICABLE
1261
+ backward-compatibility reasoning, added here so DeleteResult mirrors
1262
+ every other write-path result type rather than being the one write
1263
+ primitive with no corruption-inspection surface at all. On this
1264
+ adapter's real HTTP backends, a delete() call that hits a write-path
1265
+ corruption shape (e.g. volcengine/OpenViking#2966's legacy uint16-
1266
+ truncated records, see CrashSignal.LEGACY_CORRUPT_RECORD_UNDELETABLE)
1267
+ raises BackendAPIError instead of returning a DeleteResult at all --
1268
+ per this dataclass's own docstring above, adapters raise on failure
1269
+ rather than returning success=False -- so this field stays at its
1270
+ default for every adapter in this repo today. It exists for the same
1271
+ forward-compatibility reason StoreResult/UpdateResult's fields do:
1272
+ only a future adapter with a genuine direct-handle inspection surface
1273
+ (see Mem0DirectAdapter's precedent for those two fields) could ever
1274
+ set this to something other than NOT_APPLICABLE."""
1275
+
1276
+
1277
+ @dataclass
1278
+ class DeletePrefixResult:
1279
+ """Result of MemoryBackendAdapter.delete_prefix() -- a recursive
1280
+ directory/prefix delete, distinct from delete()/delete_many()'s
1281
+ single-`memory_id`-at-a-time model. This is the primitive
1282
+ evals/orphan_cleanup.py needs to reproduce the volcengine/
1283
+ OpenViking#3064 shape: a prefix delete that reports success while
1284
+ leaving child vector-index entries orphaned -- something delete() and
1285
+ delete_many() cannot construct at all, since both require the caller
1286
+ to already know every individual memory_id up front, while a real
1287
+ orphan-cleanup delete targets a whole subtree by prefix without
1288
+ necessarily knowing every leaf id in advance (exactly the scenario
1289
+ #3064 describes: files removed directly from the backing filesystem,
1290
+ so their ids/paths are unknown to the caller issuing the cleanup
1291
+ delete).
1292
+ """
1293
+
1294
+ prefix: str
1295
+ deleted_paths: list[str]
1296
+ """Every path (leaf files discovered via list_resource_paths(), plus
1297
+ the prefix root itself) this call successfully deleted, in the order
1298
+ delete() was called on them. Does NOT include paths this call never
1299
+ discovered in the first place (e.g. a child the underlying listing
1300
+ call silently omitted) -- see VectorIntegritySignal.ORPHANED_VECTOR_ENTRY
1301
+ for the classification of exactly that gap."""
1302
+ failed_paths: list[str]
1303
+ """Every path this call discovered but whose delete() call either
1304
+ raised BackendAPIError or returned success=False. Never silently
1305
+ dropped -- same one-result-per-discovered-path accounting principle
1306
+ delete_many() already establishes for delete()."""
1307
+ latency_ms: float
1308
+
1309
+
1310
+ @dataclass
1311
+ class StatsResult:
1312
+ """Result of MemoryBackendAdapter.get_stats() -- a backend's own
1313
+ self-reported count of how many memories it currently holds, read from
1314
+ a dedicated stats/dashboard endpoint rather than derived from a
1315
+ store()/query() call.
1316
+
1317
+ This exists to reproduce volcengine/OpenViking#1255 (contributor
1318
+ SeeYangZhi): `GET /api/v1/stats/memories` returns an all-zero count
1319
+ even when filesystem listing (`list_resource_paths()`) and semantic
1320
+ search (`query()`) both independently confirm the memories genuinely
1321
+ exist -- a separate metrics/counting code path that was never wired up
1322
+ to the real write path. `total_memories` here is deliberately the
1323
+ backend's own self-report, exactly the number a caller would see if it
1324
+ trusted this endpoint alone; evals/stats_accuracy.py is what
1325
+ cross-checks it against an independent ground-truth count."""
1326
+
1327
+ total_memories: int | None
1328
+ """The backend's self-reported total memory count, or `None` if the
1329
+ response had no field this adapter knows how to read (distinct from a
1330
+ real `0`, which is a valid, meaningful count that may still turn out
1331
+ to be wrong -- see StatsSignal.STATS_UNDERCOUNTED in
1332
+ evals/stats_accuracy.py)."""
1333
+ latency_ms: float = 0.0
1334
+ raw: dict[str, object] = field(default_factory=dict)
1335
+ """Unmodified vendor response, kept for audit purposes only -- never
1336
+ used for scoring directly (see StatsResult.total_memories above)."""
1337
+
1338
+
1339
+ @dataclass
1340
+ class MigrationFailureResult:
1341
+ """Result of MemoryBackendAdapter.simulate_migration_failure().
1342
+
1343
+ Reports whether the ORIGINAL pre-migration data survived a simulated
1344
+ mid-migration failure -- the primitive
1345
+ MemoryBackendAdapter.supports_migration_rollback_simulation gates and
1346
+ evals/migration_rollback.py drives. Modeled on the exact shape
1347
+ MemPalace/mempalace#1028 (GitHub user eldar702) reports: an unguarded
1348
+ `shutil.rmtree()`-then-`shutil.move()` swap at the end of
1349
+ MemPalace's own `migrate.migrate()` function deletes the old backup
1350
+ FIRST, so if the `move()` step fails partway (e.g. a cross-device
1351
+ `EXDEV` error), the palace directory is permanently lost -- there is
1352
+ no backup left to fall back to. MemPalace/mempalace#935 is the real
1353
+ upstream fix: a safer "rename-aside" swap that renames the new data
1354
+ into place first, keeps the old backup renamed-aside, and only
1355
+ deletes the backup after confirming the swap succeeded, so a failure
1356
+ mid-swap leaves the original data recoverable.
1357
+ """
1358
+
1359
+ session_id: str
1360
+ memory_id: str
1361
+ content: str
1362
+ """The exact content simulate_migration_failure() stored as the
1363
+ ORIGINAL pre-migration data before simulating the failure."""
1364
+ original_data_recoverable: bool
1365
+ """This adapter's OWN observation (via its normal query() path) of
1366
+ whether `content` is still retrievable after the simulated failure.
1367
+ Kept as a diagnostic/cross-check field only -- evals/
1368
+ migration_rollback.py's run_migration_rollback_eval() does not trust
1369
+ this self-report as the classification's ground truth. It makes its
1370
+ own independent query() call after simulate_migration_failure()
1371
+ returns and classifies from that instead, the same "never trust a
1372
+ single response" principle every eval in this package follows (see
1373
+ evals/crash_recovery.py's classify_crash_recovery_case and
1374
+ evals/ranking_quality.py's classify_ranking_case for precedent)."""
1375
+
1376
+
1377
+ class MemoryBackendAdapter(ABC):
1378
+ """Abstract base every backend adapter must implement.
1379
+
1380
+ Contract for implementers:
1381
+ * __init__ must read credentials from an environment variable and
1382
+ raise BackendNotConfiguredError immediately if missing -- never
1383
+ defer the check to the first method call.
1384
+ * store()/query()/update()/delete() must raise BackendAPIError (not
1385
+ a bare vendor exception) on any network/API failure, so the
1386
+ harness can report a uniform error shape across all backends.
1387
+ * Never mutate eval logic per backend. If a backend cannot support
1388
+ an operation (see supports_update), report that fact through
1389
+ supports_update / ConflictSignal.NOT_APPLICABLE rather than
1390
+ faking a response.
1391
+ """
1392
+
1393
+ #: Human-readable vendor name, used in CLI output and reports.
1394
+ name: str = "unknown"
1395
+
1396
+ #: Environment variable this adapter reads its credential/config from.
1397
+ #: Subclasses must set this so BackendNotConfiguredError messages are
1398
+ #: accurate and so `memtrust run` can pre-check configuration status
1399
+ #: without constructing the adapter.
1400
+ env_var: str = ""
1401
+
1402
+ #: Whether this backend exposes an update/invalidate primitive the
1403
+ #: contradiction-detection eval can meaningfully exercise. If False,
1404
+ #: the eval records ConflictSignal.NOT_APPLICABLE instead of running
1405
+ #: the eval against this backend and silently dropping it from the
1406
+ #: results table.
1407
+ supports_update: bool = True
1408
+
1409
+ #: Whether this backend exposes a directory/resource mirror that a
1410
+ #: multi-file resync operation can act on (list_resource_paths /
1411
+ #: trigger_resync below). Defaults to False -- the store/query/update
1412
+ #: model most adapters implement has no concept of a resync, so most
1413
+ #: backends genuinely cannot be exercised here. If False, the
1414
+ #: resource-sync-safety eval records the backend as skipped instead of
1415
+ #: calling list_resource_paths/trigger_resync and crashing on the
1416
+ #: default NotImplementedError below.
1417
+ supports_resource_sync: bool = False
1418
+
1419
+ #: Named operating modes this backend exposes that change how content
1420
+ #: is stored/retrieved (e.g. a vendor's "compressed"/"lossless" write
1421
+ #: path vs. its raw path). Empty by default -- most adapters have no
1422
+ #: mode variants at all, and the compression/round-trip-fidelity eval
1423
+ #: (evals/compression.py) treats an empty tuple as "run once under a
1424
+ #: single synthetic 'default' mode" rather than skipping the backend.
1425
+ #: An adapter that declares a non-empty tuple here is asserting that
1426
+ #: passing each of those strings as `mode=` to store()/query() below
1427
+ #: actually selects a different vendor-side code path -- see
1428
+ #: mempalace_adapter.py for the one adapter that currently does this,
1429
+ #: and the honesty caveat attached to its mode names.
1430
+ supported_modes: tuple[str, ...] = ()
1431
+
1432
+ #: Whether this adapter can simulate a "server process crashed and
1433
+ #: restarted, losing its in-memory search index while the underlying
1434
+ #: store data survives" event via simulate_crash_restart() /
1435
+ #: raw_store_contains() below. Defaults to False -- every real,
1436
+ #: HTTP-only adapter in this repo talks to a backend over the network
1437
+ #: with zero ability to start, kill, or restart the vendor's own
1438
+ #: server process, and has no API surface that bypasses the vendor's
1439
+ #: search index to inspect raw stored data directly. Only a
1440
+ #: purpose-built in-memory fake adapter (see
1441
+ #: tests/test_evals.py::CrashRecoveryFakeAdapter) can genuinely model
1442
+ #: both halves of this -- see evals/crash_recovery.py for the eval
1443
+ #: this gates, and docs/methodology.md for why that eval cannot prove
1444
+ #: anything about a real backend's process-lifecycle behavior.
1445
+ supports_crash_recovery_simulation: bool = False
1446
+
1447
+ #: Whether this adapter can simulate a storage-migration's final swap
1448
+ #: step failing partway through, via simulate_migration_failure()
1449
+ #: below. Defaults to False -- same "no adapter in this repo has real
1450
+ #: process/filesystem-lifecycle control over a live backend" reasoning
1451
+ #: as supports_crash_recovery_simulation above: no adapter here holds
1452
+ #: a handle to a live vendor package's internal migration code path it
1453
+ #: could actually interrupt partway through. Only a purpose-built
1454
+ #: in-memory fake adapter (see
1455
+ #: tests/test_evals.py::MigrationRollbackFakeAdapter) can genuinely
1456
+ #: model both the buggy unguarded-rmtree()-then-move() swap and the
1457
+ #: fixed rename-aside swap -- see evals/migration_rollback.py for the
1458
+ #: eval this gates, and that module's docstring plus
1459
+ #: mempalace_adapter.py's module docstring for why this cannot prove
1460
+ #: anything about a real MemPalace deployment's migrate.migrate()
1461
+ #: behavior.
1462
+ supports_migration_rollback_simulation: bool = False
1463
+
1464
+ #: Whether this adapter can submit an arbitrary, caller-controlled
1465
+ #: filter dict directly to the underlying vector store's own
1466
+ #: filter-query-building layer, bypassing query()'s hardcoded
1467
+ #: `{"user_id": session_id}` filter, via probe_raw_filter() below.
1468
+ #: Defaults to False -- every real, HTTP-only adapter in this repo has
1469
+ #: no documented way to submit a raw, unvalidated filter value outside
1470
+ #: its own normal session-scoped query() path. Only Mem0DirectAdapter,
1471
+ #: which holds a direct, in-process handle to the vendor library
1472
+ #: including its constructed vector_store, can genuinely reach into
1473
+ #: that layer -- see evals/filter_injection.py, the eval this gates,
1474
+ #: and mem0_direct_adapter.py's probe_raw_filter() override.
1475
+ supports_raw_filter_probe: bool = False
1476
+
1477
+ #: Whether this backend exposes a dedicated stats/dashboard endpoint
1478
+ #: this adapter can read via get_stats() below, distinct from deriving
1479
+ #: a count from query()/list_resource_paths() results. Defaults to
1480
+ #: False -- most adapters in this repo have no such endpoint documented
1481
+ #: at all. See evals/stats_accuracy.py, the eval this gates, and
1482
+ #: openviking_adapter.py's get_stats() for the one adapter that
1483
+ #: currently sets this True (volcengine/OpenViking#1255).
1484
+ supports_stats: bool = False
1485
+
1486
+ #: Whether this adapter exposes a recursive prefix/directory delete
1487
+ #: primitive via delete_prefix() below, distinct from the single-
1488
+ #: memory_id delete()/delete_many() every adapter must implement.
1489
+ #: Defaults to False -- most adapters have no directory/resource-mirror
1490
+ #: concept at all (the same store/query/update model that gates
1491
+ #: supports_resource_sync above). Only an adapter with a real
1492
+ #: directory-listing primitive to discover child paths by prefix (see
1493
+ #: OpenVikingAdapter.list_resource_paths()) can implement this
1494
+ #: meaningfully -- see evals/orphan_cleanup.py, the eval this gates,
1495
+ #: and VectorIntegritySignal in this module for the failure shape
1496
+ #: (volcengine/OpenViking#3064) it exists to detect.
1497
+ supports_prefix_delete: bool = False
1498
+
1499
+ #: Whether this adapter exposes a library-level equivalent of a
1500
+ #: vendor's MCP metadata/overview tool surface (MemPalace's
1501
+ #: `mempalace_status`/`mempalace_list_wings`/`mempalace_list_rooms`)
1502
+ #: via metadata_overview()/list_metadata_categories()/
1503
+ #: list_metadata_subcategories() below. Defaults to False -- most
1504
+ #: adapters in this repo have no such surface at all (store/query/
1505
+ #: update/delete is the entire interface). Only MemPalaceAdapter,
1506
+ #: which holds a direct in-process handle to the real
1507
+ #: `mempalace.mcp_server` module's `tool_status`/`tool_list_wings`/
1508
+ #: `tool_list_rooms` functions (confirmed real and callable without
1509
+ #: spinning up the actual MCP stdio/HTTP transport -- see
1510
+ #: mempalace_adapter.py's module docstring), can genuinely exercise
1511
+ #: this. See evals/mempalace_metadata_scale.py, the eval this gates,
1512
+ #: for the motivating MemPalace/mempalace#1871 O(N^2) full-collection-
1513
+ #: scan bug this closes coverage for.
1514
+ supports_metadata_overview: bool = False
1515
+
1516
+ @abstractmethod
1517
+ def store(
1518
+ self,
1519
+ session_id: str,
1520
+ content: str,
1521
+ metadata: dict[str, str] | None = None,
1522
+ mode: str | None = None,
1523
+ ) -> StoreResult:
1524
+ """Store a new memory under the given session/user scope.
1525
+
1526
+ Args:
1527
+ session_id: logical conversation/user scope for this memory.
1528
+ content: the text to store.
1529
+ metadata: optional vendor-agnostic key/value tags.
1530
+ mode: optional operating-mode selector (see `supported_modes`).
1531
+ Adapters that don't expose mode variants MUST accept this
1532
+ parameter and ignore it (no-op) rather than raising, so
1533
+ that callers written against the shared interface can pass
1534
+ `mode=` uniformly across every backend without special-
1535
+ casing the ones that don't support it. This preserves
1536
+ backward compatibility: any pre-existing call site that
1537
+ never passes `mode` continues to behave identically.
1538
+
1539
+ Raises:
1540
+ BackendAPIError: on any network or vendor-side failure.
1541
+
1542
+ Note on durability: returning without raising is *not* proof the
1543
+ write is durable or even retrievable -- a vendor can silently
1544
+ drop or corrupt a write server-side and still return a normal
1545
+ response (this is exactly what happened in two independently
1546
+ root-caused MemPalace bugs: NUL-byte checkpoint corruption and
1547
+ stale/self-deadlocked locks). Implementers that want to guard
1548
+ against this should accept an opt-in `verify: bool = False`
1549
+ keyword-only parameter and, when True, call `self.verify_store()`
1550
+ after the write succeeds and set `StoreResult.verified` from its
1551
+ return value. This is intentionally NOT part of every adapter's
1552
+ required signature (existing callers that never pass `verify`
1553
+ must keep working unchanged against any adapter), and it is
1554
+ intentionally NOT on by default anywhere -- see verify_store()
1555
+ and docs/methodology.md for why (it doubles API calls per store()
1556
+ when enabled).
1557
+ """
1558
+ raise NotImplementedError
1559
+
1560
+ @abstractmethod
1561
+ def query(
1562
+ self, session_id: str, query: str, top_k: int = 5, mode: str | None = None
1563
+ ) -> QueryResult:
1564
+ """Retrieve memories relevant to `query` within `session_id`.
1565
+
1566
+ Args:
1567
+ mode: optional operating-mode selector, same contract as
1568
+ `store()`'s `mode` parameter above -- ignored (no-op) by
1569
+ adapters with no mode variants.
1570
+
1571
+ Raises:
1572
+ BackendAPIError: on any network or vendor-side failure.
1573
+ """
1574
+ raise NotImplementedError
1575
+
1576
+ @abstractmethod
1577
+ def update(self, session_id: str, memory_id: str, content: str) -> UpdateResult:
1578
+ """Store a fact that may contradict a previously stored one.
1579
+
1580
+ Implementers should call whatever the vendor's native mechanism is
1581
+ for "this may supersede an existing memory" (an explicit update
1582
+ call, a second store() that the vendor's own pipeline resolves,
1583
+ etc.) and report what actually happened in UpdateResult -- do not
1584
+ synthesize agreement with the request.
1585
+
1586
+ Raises:
1587
+ BackendAPIError: on any network or vendor-side failure.
1588
+ """
1589
+ raise NotImplementedError
1590
+
1591
+ @abstractmethod
1592
+ def delete(self, memory_id: str) -> DeleteResult:
1593
+ """Delete a single stored memory/entity by id.
1594
+
1595
+ This is the primitive an eval needs to reproduce vendor bugs in
1596
+ the "delete N entities" class (e.g. a client whose batch-delete
1597
+ code silently keeps only the last response instead of aggregating
1598
+ all N) -- see delete_many() below, which is what an eval actually
1599
+ calls to construct that scenario.
1600
+
1601
+ Implementers that genuinely cannot delete (no documented/verified
1602
+ vendor endpoint) must still define this method and raise
1603
+ BackendAPIError with a clear "not implemented for this backend"
1604
+ detail rather than omitting the method -- the eval layer needs a
1605
+ uniform call shape across all adapters, same as store/query/
1606
+ update, even when the honest answer is "not supported yet."
1607
+
1608
+ Raises:
1609
+ BackendAPIError: on any network/vendor-side failure, or when
1610
+ this backend has no verified delete endpoint.
1611
+ """
1612
+ raise NotImplementedError
1613
+
1614
+ def delete_many(self, memory_ids: list[str]) -> list[DeleteResult]:
1615
+ """Delete several memories, one at a time, via delete().
1616
+
1617
+ Default implementation for every adapter: a plain per-id loop
1618
+ that appends each DeleteResult (or a failure record, if an
1619
+ individual delete() call raises) to a single results list sized
1620
+ exactly len(memory_ids). This is deliberately naive -- it exists
1621
+ so the eval layer has one aggregation path to trust, rather than
1622
+ each adapter rolling its own batch logic that could silently
1623
+ drop or overwrite results the way a buggy vendor client might
1624
+ (see mem0ai/mem0#5936, #5970: a multi-entity delete that kept
1625
+ only the last response instead of all N). Adapters with a real
1626
+ vendor batch-delete endpoint may override this for efficiency,
1627
+ but must preserve the same one-result-per-input-id contract.
1628
+
1629
+ Does not raise: a per-id BackendAPIError is caught and recorded
1630
+ as a DeleteResult(success=False, ...) at that id's position so
1631
+ one failure in the middle of a batch cannot truncate or drop the
1632
+ results for the ids after it.
1633
+ """
1634
+ results: list[DeleteResult] = []
1635
+ for memory_id in memory_ids:
1636
+ try:
1637
+ results.append(self.delete(memory_id))
1638
+ except BackendAPIError as exc:
1639
+ results.append(
1640
+ DeleteResult(
1641
+ success=False,
1642
+ memory_id=memory_id,
1643
+ latency_ms=0.0,
1644
+ raw={"error": str(exc)},
1645
+ )
1646
+ )
1647
+ return results
1648
+
1649
+ def list_resource_paths(self, prefix: str) -> list[str]:
1650
+ """List resource/file paths currently present under `prefix`.
1651
+
1652
+ Optional capability -- only meaningful for backends that model a
1653
+ directory/resource mirror (see supports_resource_sync). Unlike
1654
+ store()/query()/update(), this is NOT an abstract method: most
1655
+ adapters have no resource-mirror concept at all, so the default
1656
+ implementation raises NotImplementedError rather than forcing
1657
+ every adapter to stub it out. Implementers that set
1658
+ supports_resource_sync = True must override this.
1659
+
1660
+ Raises:
1661
+ NotImplementedError: if the adapter does not implement this
1662
+ (i.e. supports_resource_sync is False).
1663
+ BackendAPIError: on any network or vendor-side failure.
1664
+ """
1665
+ raise NotImplementedError(
1666
+ f"{self.name} does not implement list_resource_paths() "
1667
+ f"(supports_resource_sync={self.supports_resource_sync})"
1668
+ )
1669
+
1670
+ def trigger_resync(self, prefix: str) -> None:
1671
+ """Trigger whatever native mechanism this backend uses to reconcile
1672
+ its resource mirror under `prefix` against the source it ingests
1673
+ from (e.g. a directory watcher's resync/rescan pass).
1674
+
1675
+ Optional capability, same convention as list_resource_paths()
1676
+ above -- default raises NotImplementedError, only backends with
1677
+ supports_resource_sync = True are expected to override it.
1678
+
1679
+ Raises:
1680
+ NotImplementedError: if the adapter does not implement this
1681
+ (i.e. supports_resource_sync is False).
1682
+ BackendAPIError: on any network or vendor-side failure.
1683
+ """
1684
+ raise NotImplementedError(
1685
+ f"{self.name} does not implement trigger_resync() "
1686
+ f"(supports_resource_sync={self.supports_resource_sync})"
1687
+ )
1688
+
1689
+ def simulate_crash_restart(self) -> None:
1690
+ """Simulate a "server process crashed and restarted" event: the
1691
+ in-memory search index is lost, but whatever the backend's
1692
+ underlying store actually persisted survives.
1693
+
1694
+ This is deliberately NOT a real process kill/restart -- no
1695
+ adapter in this repo holds a handle to a live vendor server
1696
+ process it could actually terminate and relaunch (every real
1697
+ adapter here is a pure HTTP client; see the module docstring at
1698
+ the top of this file). It is an explicit, named simulation
1699
+ primitive an in-memory fake adapter implements to model the
1700
+ specific failure shape volcengine/OpenViking#2644 (contributor
1701
+ yeyitech) reports: a local vectordb's `_recover()` silently skips
1702
+ rebuilding the index on process restart when index files are
1703
+ missing but store data exists, so post-restart queries silently
1704
+ return nothing even though the data was never actually lost.
1705
+
1706
+ Optional capability, same convention as list_resource_paths()/
1707
+ trigger_resync() above -- default raises NotImplementedError,
1708
+ only adapters with supports_crash_recovery_simulation = True are
1709
+ expected to override it. See evals/crash_recovery.py.
1710
+
1711
+ Raises:
1712
+ NotImplementedError: if the adapter does not implement this
1713
+ (i.e. supports_crash_recovery_simulation is False).
1714
+ """
1715
+ raise NotImplementedError(
1716
+ f"{self.name} does not implement simulate_crash_restart() "
1717
+ f"(supports_crash_recovery_simulation={self.supports_crash_recovery_simulation})"
1718
+ )
1719
+
1720
+ def raw_store_contains(self, session_id: str, memory_id: str) -> bool:
1721
+ """Check whether the underlying store still holds `memory_id`,
1722
+ bypassing whatever search/index layer query() goes through.
1723
+
1724
+ This is the primitive that lets evals/crash_recovery.py tell
1725
+ "the index is gone but the data survived" (the volcengine/
1726
+ OpenViking#2644 bug shape) apart from "the data itself is gone
1727
+ too" -- query() alone cannot distinguish these, since a lost
1728
+ index and lost data both make query() return nothing.
1729
+
1730
+ Optional capability, same convention as list_resource_paths()/
1731
+ trigger_resync() above -- most real adapters have no vendor API
1732
+ that reads underlying stored data independently of the vendor's
1733
+ own search index, so the default raises NotImplementedError.
1734
+ Only adapters with supports_crash_recovery_simulation = True are
1735
+ expected to override it.
1736
+
1737
+ Raises:
1738
+ NotImplementedError: if the adapter does not implement this
1739
+ (i.e. supports_crash_recovery_simulation is False).
1740
+ """
1741
+ raise NotImplementedError(
1742
+ f"{self.name} does not implement raw_store_contains() "
1743
+ f"(supports_crash_recovery_simulation={self.supports_crash_recovery_simulation})"
1744
+ )
1745
+
1746
+ def simulate_migration_failure(self, session_id: str, content: str) -> MigrationFailureResult:
1747
+ """Store `content` as the ORIGINAL pre-migration data, then
1748
+ simulate a storage migration whose final swap step is interrupted
1749
+ before the commit step completes, and report whether `content` is
1750
+ still recoverable afterward.
1751
+
1752
+ This is deliberately NOT a real migration or a real filesystem
1753
+ fault injection -- no adapter in this repo holds a handle to a
1754
+ live vendor package's actual migrate() code path it could
1755
+ genuinely interrupt partway through (every real adapter here is
1756
+ either a pure HTTP client or, for MemPalaceAdapter, a thin wrapper
1757
+ around whatever the installed `mempalace` package's own
1758
+ remember()/recall()/invalidate() do internally -- see
1759
+ mempalace_adapter.py's module docstring). It is an explicit, named
1760
+ simulation primitive an in-memory fake adapter implements to model
1761
+ the specific failure shape MemPalace/mempalace#1028 (GitHub user
1762
+ eldar702) reports: MemPalace's own `migrate.migrate()` function had
1763
+ an unguarded `shutil.rmtree()`-then-`shutil.move()` swap at the end
1764
+ of a migration -- if the `move()` step failed partway (e.g. a
1765
+ cross-device `EXDEV` error), the palace directory could be
1766
+ permanently lost, since the old backup was already deleted first.
1767
+ MemPalace/mempalace#935 is the real upstream fix this primitive
1768
+ exists to let an eval verify the CONCEPT of (not a specific merged
1769
+ diff): a "rename-aside" swap that renames the new data into place
1770
+ first, keeps the old backup renamed-aside, and only deletes it
1771
+ after confirming the swap succeeded, so a failure mid-swap leaves
1772
+ the original data recoverable.
1773
+
1774
+ Optional capability, same convention as simulate_crash_restart()/
1775
+ raw_store_contains() above -- default raises NotImplementedError,
1776
+ only adapters with supports_migration_rollback_simulation = True
1777
+ are expected to override it. See evals/migration_rollback.py.
1778
+
1779
+ Args:
1780
+ session_id: logical conversation/user scope to store the
1781
+ original pre-migration content under.
1782
+ content: the exact text to treat as the original pre-migration
1783
+ data whose survival across the simulated failure is being
1784
+ tested.
1785
+
1786
+ Raises:
1787
+ NotImplementedError: if the adapter does not implement this
1788
+ (i.e. supports_migration_rollback_simulation is False).
1789
+ """
1790
+ raise NotImplementedError(
1791
+ f"{self.name} does not implement simulate_migration_failure() "
1792
+ f"(supports_migration_rollback_simulation="
1793
+ f"{self.supports_migration_rollback_simulation})"
1794
+ )
1795
+
1796
+ def probe_raw_filter(self, filters: dict[str, object]) -> RawFilterProbeResult:
1797
+ """Submit `filters` directly to this backend's underlying
1798
+ vector-store filter-query-building layer, bypassing the normal
1799
+ session-scoped query()'s hardcoded `{"user_id": session_id}`
1800
+ filter -- the primitive evals/filter_injection.py needs to probe
1801
+ whether a dict/list-valued filter value (the mem0ai/mem0#5980
1802
+ injection shape) is validated before being embedded into a query.
1803
+
1804
+ Optional capability, same convention as list_resource_paths()/
1805
+ trigger_resync()/simulate_crash_restart() above -- default raises
1806
+ NotImplementedError, only adapters with supports_raw_filter_probe
1807
+ = True are expected to override it.
1808
+
1809
+ Raises:
1810
+ NotImplementedError: if the adapter does not implement this
1811
+ (i.e. supports_raw_filter_probe is False).
1812
+ """
1813
+ raise NotImplementedError(
1814
+ f"{self.name} does not implement probe_raw_filter() "
1815
+ f"(supports_raw_filter_probe={self.supports_raw_filter_probe})"
1816
+ )
1817
+
1818
+ def get_stats(self, session_id: str | None = None) -> StatsResult:
1819
+ """Read this backend's own dedicated stats/dashboard endpoint --
1820
+ e.g. a `total_memories` counter maintained by a separate
1821
+ metrics/aggregation code path, not derived from a store()/query()
1822
+ call this adapter makes itself.
1823
+
1824
+ This is the primitive evals/stats_accuracy.py needs to reproduce
1825
+ volcengine/OpenViking#1255's exact shape: a stats endpoint that
1826
+ returns an all-zero (or otherwise undercounted) result even though
1827
+ the memories genuinely exist and are independently confirmed via
1828
+ `list_resource_paths()`/`query()`.
1829
+
1830
+ Optional capability, same convention as list_resource_paths()/
1831
+ trigger_resync()/probe_raw_filter() above -- default raises
1832
+ NotImplementedError, only adapters with supports_stats = True are
1833
+ expected to override it.
1834
+
1835
+ Args:
1836
+ session_id: optional scope to read stats for, if this
1837
+ backend's stats endpoint supports scoping. Adapters whose
1838
+ real endpoint has no such concept accept and ignore it
1839
+ (no-op), the same backward-compatible convention store()'s
1840
+ `mode` parameter establishes.
1841
+
1842
+ Raises:
1843
+ NotImplementedError: if the adapter does not implement this
1844
+ (i.e. supports_stats is False).
1845
+ BackendAPIError: on any network or vendor-side failure.
1846
+ """
1847
+ raise NotImplementedError(
1848
+ f"{self.name} does not implement get_stats() (supports_stats={self.supports_stats})"
1849
+ )
1850
+
1851
+ def delete_prefix(self, prefix: str, recursive: bool = True) -> DeletePrefixResult:
1852
+ """Recursively delete every resource path under `prefix`, distinct
1853
+ from delete()/delete_many()'s single-known-memory_id model -- the
1854
+ primitive an eval needs to reproduce the volcengine/OpenViking#3064
1855
+ orphan-cleanup bug class: a prefix delete that discovers child
1856
+ paths via a directory-listing call and can therefore miss entries
1857
+ the listing call itself fails to enumerate (e.g. because the
1858
+ parent directory no longer exists in the backing filesystem),
1859
+ leaving those entries as permanently orphaned vector-index records
1860
+ even though the delete reported success.
1861
+
1862
+ Optional capability, same convention as list_resource_paths()/
1863
+ trigger_resync()/probe_raw_filter() above -- default raises
1864
+ NotImplementedError, only adapters with supports_prefix_delete =
1865
+ True are expected to override it. See evals/orphan_cleanup.py and
1866
+ VectorIntegritySignal in this module for the eval and signal this
1867
+ gates.
1868
+
1869
+ Args:
1870
+ prefix: the resource-path prefix to delete everything under.
1871
+ recursive: when True (the default), discover and delete every
1872
+ nested child path under `prefix`, not just paths directly
1873
+ at the top level. Implementers that cannot distinguish
1874
+ depth at all may treat this as always-recursive.
1875
+
1876
+ Raises:
1877
+ NotImplementedError: if the adapter does not implement this
1878
+ (i.e. supports_prefix_delete is False).
1879
+ BackendAPIError: on any network/vendor-side failure that
1880
+ prevents the delete from being attempted at all (a
1881
+ per-child delete() failure is instead recorded in the
1882
+ returned DeletePrefixResult.failed_paths, not raised).
1883
+ """
1884
+ raise NotImplementedError(
1885
+ f"{self.name} does not implement delete_prefix() "
1886
+ f"(supports_prefix_delete={self.supports_prefix_delete})"
1887
+ )
1888
+
1889
+ def metadata_overview(self) -> MetadataOverviewResult:
1890
+ """Fetch this backend's aggregate record-count-plus-grouping
1891
+ overview -- the library-level equivalent of MemPalace's
1892
+ `mempalace_status` MCP tool.
1893
+
1894
+ Optional capability, same convention as list_resource_paths()/
1895
+ probe_raw_filter() above -- default raises NotImplementedError,
1896
+ only adapters with supports_metadata_overview = True are expected
1897
+ to override it.
1898
+
1899
+ Raises:
1900
+ NotImplementedError: if the adapter does not implement this
1901
+ (i.e. supports_metadata_overview is False).
1902
+ BackendAPIError: on any network or vendor-side failure.
1903
+ """
1904
+ raise NotImplementedError(
1905
+ f"{self.name} does not implement metadata_overview() "
1906
+ f"(supports_metadata_overview={self.supports_metadata_overview})"
1907
+ )
1908
+
1909
+ def list_metadata_categories(self) -> MetadataCategoryCountsResult:
1910
+ """List every top-level group this backend's records are
1911
+ organized under, with a record count per group -- the
1912
+ library-level equivalent of MemPalace's `mempalace_list_wings`
1913
+ MCP tool.
1914
+
1915
+ Optional capability, same convention as metadata_overview()
1916
+ above -- default raises NotImplementedError, only adapters with
1917
+ supports_metadata_overview = True are expected to override it.
1918
+
1919
+ Raises:
1920
+ NotImplementedError: if the adapter does not implement this
1921
+ (i.e. supports_metadata_overview is False).
1922
+ BackendAPIError: on any network or vendor-side failure.
1923
+ """
1924
+ raise NotImplementedError(
1925
+ f"{self.name} does not implement list_metadata_categories() "
1926
+ f"(supports_metadata_overview={self.supports_metadata_overview})"
1927
+ )
1928
+
1929
+ def list_metadata_subcategories(
1930
+ self, category: str | None = None
1931
+ ) -> MetadataCategoryCountsResult:
1932
+ """List every second-level group this backend's records are
1933
+ organized under, optionally scoped to one top-level `category` --
1934
+ the library-level equivalent of MemPalace's `mempalace_list_rooms`
1935
+ MCP tool.
1936
+
1937
+ Optional capability, same convention as metadata_overview()
1938
+ above -- default raises NotImplementedError, only adapters with
1939
+ supports_metadata_overview = True are expected to override it.
1940
+
1941
+ Args:
1942
+ category: restrict the listing to this top-level group
1943
+ (MemPalace: a wing name), or None to list across every
1944
+ category.
1945
+
1946
+ Raises:
1947
+ NotImplementedError: if the adapter does not implement this
1948
+ (i.e. supports_metadata_overview is False).
1949
+ BackendAPIError: on any network or vendor-side failure.
1950
+ """
1951
+ raise NotImplementedError(
1952
+ f"{self.name} does not implement list_metadata_subcategories() "
1953
+ f"(supports_metadata_overview={self.supports_metadata_overview})"
1954
+ )
1955
+
1956
+ @staticmethod
1957
+ def _timed() -> _Timer:
1958
+ return _Timer()
1959
+
1960
+ def verify_store(self, store_result: StoreResult, session_id: str, content: str) -> bool:
1961
+ """Opt-in read-after-write check: query() immediately after a
1962
+ store() call and confirm the just-written content is actually
1963
+ retrievable, instead of trusting "store() didn't raise" as proof
1964
+ of a durable write.
1965
+
1966
+ This is a helper, not something store() calls automatically --
1967
+ no adapter's default `store()` behavior changes by this method
1968
+ existing. An adapter opts in by accepting a `verify: bool = False`
1969
+ keyword-only parameter on its own store() and calling this
1970
+ explicitly when `verify=True` (see mempalace_adapter.py for the
1971
+ reference implementation). Left off by default because it costs
1972
+ one extra vendor API call (a query()) for every store() call made
1973
+ with verify=True -- turning that on unconditionally for every eval
1974
+ run would silently double memtrust's own API/latency cost against
1975
+ every backend under test. See docs/methodology.md.
1976
+
1977
+ Args:
1978
+ store_result: the StoreResult just returned by this adapter's
1979
+ own store() call, used for its memory_id.
1980
+ session_id: the same session/scope the content was stored
1981
+ under.
1982
+ content: the exact text that was just stored.
1983
+
1984
+ Returns:
1985
+ True only if a query() call for `session_id` returns a record
1986
+ whose *content* actually contains `content` -- either the
1987
+ record matching `store_result.memory_id` by id (checked
1988
+ first, and its content must still match: a record that comes
1989
+ back under the right id but with corrupted content is a
1990
+ failed verification, not a pass) or, if no record shares that
1991
+ id (some backends don't echo a stable id on the query path),
1992
+ any returned record whose content contains `content`. False
1993
+ covers both "no matching record at all" (dropped write) and
1994
+ "a record came back but the content doesn't match" (corrupted
1995
+ write) -- both are reported as a normal `False` return, never
1996
+ raised as an error.
1997
+
1998
+ Raises:
1999
+ BackendAPIError: if the verification query() call itself
2000
+ fails (a real network/vendor error is a different failure
2001
+ mode than "the write was silently dropped," and should
2002
+ still surface as an error rather than being swallowed
2003
+ into `False`).
2004
+ """
2005
+ result = self.query(session_id, content)
2006
+ for record in result.records:
2007
+ if record.memory_id and record.memory_id == store_result.memory_id:
2008
+ return bool(content) and content in record.content
2009
+ return any(content and content in record.content for record in result.records)
2010
+
2011
+
2012
+ class _Timer:
2013
+ """Tiny context-manager-free stopwatch so adapters can report latency
2014
+ without importing timing boilerplate in every subclass."""
2015
+
2016
+ def __init__(self) -> None:
2017
+ self._start = time.perf_counter()
2018
+
2019
+ def elapsed_ms(self) -> float:
2020
+ return (time.perf_counter() - self._start) * 1000