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.
- memtrust/__init__.py +11 -0
- memtrust/adapters/__init__.py +62 -0
- memtrust/adapters/base.py +2020 -0
- memtrust/adapters/mem0_adapter.py +456 -0
- memtrust/adapters/mem0_direct_adapter.py +1217 -0
- memtrust/adapters/mempalace_adapter.py +1166 -0
- memtrust/adapters/openviking_adapter.py +570 -0
- memtrust/adapters/zep_graphiti_adapter.py +181 -0
- memtrust/adapters/zep_graphiti_selfhosted_adapter.py +882 -0
- memtrust/cli.py +1201 -0
- memtrust/evals/__init__.py +5 -0
- memtrust/evals/compression.py +235 -0
- memtrust/evals/contradiction.py +451 -0
- memtrust/evals/crash_recovery.py +290 -0
- memtrust/evals/embedder_cost.py +163 -0
- memtrust/evals/embedding_drift.py +273 -0
- memtrust/evals/episode_temporal_leak.py +182 -0
- memtrust/evals/extraction_quality.py +373 -0
- memtrust/evals/filter_injection.py +371 -0
- memtrust/evals/language_degradation.py +189 -0
- memtrust/evals/lock_contention.py +263 -0
- memtrust/evals/locomo.py +392 -0
- memtrust/evals/longmemeval.py +249 -0
- memtrust/evals/mempalace_metadata_scale.py +494 -0
- memtrust/evals/migration_rollback.py +276 -0
- memtrust/evals/orphan_cleanup.py +235 -0
- memtrust/evals/ranking_quality.py +303 -0
- memtrust/evals/resource_sync_safety.py +336 -0
- memtrust/evals/result_consistency.py +239 -0
- memtrust/evals/scale_fixtures.py +189 -0
- memtrust/evals/scale_stress.py +502 -0
- memtrust/evals/stats_accuracy.py +240 -0
- memtrust/evals/temporal_kg_boundary.py +281 -0
- memtrust/receipt.py +318 -0
- memtrust/scoring/__init__.py +1 -0
- memtrust/scoring/cost_tracker.py +199 -0
- memtrust/scoring/llm_judge.py +161 -0
- memtrust_cli-0.3.0.dist-info/METADATA +624 -0
- memtrust_cli-0.3.0.dist-info/RECORD +42 -0
- memtrust_cli-0.3.0.dist-info/WHEEL +4 -0
- memtrust_cli-0.3.0.dist-info/entry_points.txt +2 -0
- 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
|