memtrust-cli 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. memtrust/__init__.py +11 -0
  2. memtrust/adapters/__init__.py +62 -0
  3. memtrust/adapters/base.py +2020 -0
  4. memtrust/adapters/mem0_adapter.py +456 -0
  5. memtrust/adapters/mem0_direct_adapter.py +1217 -0
  6. memtrust/adapters/mempalace_adapter.py +1166 -0
  7. memtrust/adapters/openviking_adapter.py +570 -0
  8. memtrust/adapters/zep_graphiti_adapter.py +181 -0
  9. memtrust/adapters/zep_graphiti_selfhosted_adapter.py +882 -0
  10. memtrust/cli.py +1201 -0
  11. memtrust/evals/__init__.py +5 -0
  12. memtrust/evals/compression.py +235 -0
  13. memtrust/evals/contradiction.py +451 -0
  14. memtrust/evals/crash_recovery.py +290 -0
  15. memtrust/evals/embedder_cost.py +163 -0
  16. memtrust/evals/embedding_drift.py +273 -0
  17. memtrust/evals/episode_temporal_leak.py +182 -0
  18. memtrust/evals/extraction_quality.py +373 -0
  19. memtrust/evals/filter_injection.py +371 -0
  20. memtrust/evals/language_degradation.py +189 -0
  21. memtrust/evals/lock_contention.py +263 -0
  22. memtrust/evals/locomo.py +392 -0
  23. memtrust/evals/longmemeval.py +249 -0
  24. memtrust/evals/mempalace_metadata_scale.py +494 -0
  25. memtrust/evals/migration_rollback.py +276 -0
  26. memtrust/evals/orphan_cleanup.py +235 -0
  27. memtrust/evals/ranking_quality.py +303 -0
  28. memtrust/evals/resource_sync_safety.py +336 -0
  29. memtrust/evals/result_consistency.py +239 -0
  30. memtrust/evals/scale_fixtures.py +189 -0
  31. memtrust/evals/scale_stress.py +502 -0
  32. memtrust/evals/stats_accuracy.py +240 -0
  33. memtrust/evals/temporal_kg_boundary.py +281 -0
  34. memtrust/receipt.py +318 -0
  35. memtrust/scoring/__init__.py +1 -0
  36. memtrust/scoring/cost_tracker.py +199 -0
  37. memtrust/scoring/llm_judge.py +161 -0
  38. memtrust_cli-0.3.0.dist-info/METADATA +624 -0
  39. memtrust_cli-0.3.0.dist-info/RECORD +42 -0
  40. memtrust_cli-0.3.0.dist-info/WHEEL +4 -0
  41. memtrust_cli-0.3.0.dist-info/entry_points.txt +2 -0
  42. memtrust_cli-0.3.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,290 @@
1
+ """MemTrust's process-lifecycle/crash-recovery eval.
2
+
3
+ None of the other evals in this package can see what happens to a backend
4
+ across a server-process crash and restart -- they all exercise a single,
5
+ continuously-running adapter instance. This eval closes that specific gap.
6
+
7
+ Motivating case: volcengine/OpenViking#2644 (contributor yeyitech). A local
8
+ vectordb backend's `_recover()` routine, run on server-process startup,
9
+ silently skips rebuilding the search index when the on-disk index files
10
+ are missing but the underlying store data is still present -- e.g. the
11
+ process crashed mid-write, or the index files were deleted/corrupted
12
+ independently of the store. No exception is raised anywhere: the process
13
+ starts up cleanly, accepts queries normally, and simply returns nothing
14
+ for data that is still sitting in the store, unindexed. A caller has no
15
+ way to distinguish this from "the data was genuinely never stored" short
16
+ of inspecting the store directly.
17
+
18
+ **Honest scope of what this eval can and cannot prove.** memtrust's
19
+ adapters are pure HTTP clients (see adapters/base.py's module docstring)
20
+ with zero ability to start, kill, or restart a real vendor server
21
+ process -- there is no live OpenViking binary this harness manages, and
22
+ building real subprocess lifecycle control was out of scope for the
23
+ environment this eval was built in. So this eval does not, and cannot,
24
+ reproduce #2644 against a live OpenViking instance. Instead it targets
25
+ the STRUCTURAL failure shape directly: it stores data via an adapter,
26
+ calls an explicit `adapter.simulate_crash_restart()` (a named simulation
27
+ primitive, never a real process kill -- see
28
+ MemoryBackendAdapter.supports_crash_recovery_simulation), queries the
29
+ same data afterward, and independently checks whether the underlying
30
+ store still holds it via `adapter.raw_store_contains()` (which bypasses
31
+ the search index query() goes through). Only a purpose-built in-memory
32
+ fake adapter can genuinely model both halves of this today -- see
33
+ tests/test_evals.py::CrashRecoveryFakeAdapter. Adapters without this
34
+ capability (every real adapter in this repo, including
35
+ OpenVikingAdapter) report NOT_APPLICABLE / skipped, never a guessed
36
+ result. See docs/methodology.md for the full write-up of what this does
37
+ and does not close.
38
+
39
+ Classification produces one of:
40
+
41
+ * RECOVERED -- post-restart query() still returns the
42
+ record. The index survived (or was
43
+ correctly rebuilt).
44
+ * INDEX_LOST_DATA_SURVIVED -- post-restart query() returns nothing for
45
+ this record, but raw_store_contains()
46
+ confirms the underlying store still has
47
+ it. This is the exact #2644 shape: data
48
+ intact, index lost, queries silently
49
+ return nothing. Distinct from
50
+ ConflictSignal.EMPTY_OR_LOST (adapters/
51
+ base.py), which is about a single query
52
+ returning nothing with no crash/restart
53
+ context at all -- this signal exists
54
+ specifically for the post-recovery
55
+ symptom, where the eval has independent
56
+ evidence the data itself was never lost.
57
+ * DATA_LOST -- post-restart query() returns nothing AND
58
+ raw_store_contains() confirms the data
59
+ itself is gone too. A different, more
60
+ severe failure than #2644's shape (this
61
+ is real data loss, not an unrebuilt
62
+ index), kept as its own signal rather
63
+ than folded into INDEX_LOST_DATA_SURVIVED.
64
+ * NOT_APPLICABLE -- either the adapter has no crash-recovery
65
+ simulation capability at all
66
+ (supports_crash_recovery_simulation is
67
+ False -- the eval is skipped, not run),
68
+ or the record was never confirmed
69
+ queryable before the simulated crash in
70
+ the first place, so there is nothing
71
+ meaningful to classify about recovery.
72
+
73
+ Design principle (same as evals/resource_sync_safety.py's
74
+ classify_resource_sync_file and evals/ranking_quality.py's
75
+ classify_ranking_case): classification never blindly trusts a single
76
+ query() response. It cross-checks query() against an independent
77
+ raw_store_contains() observation, the same way resource_sync_safety.py
78
+ cross-checks list_resource_paths() (existence) against query()
79
+ (searchability) to tell "never indexed" apart from "deleted".
80
+ """
81
+
82
+ from __future__ import annotations
83
+
84
+ import json
85
+ from dataclasses import dataclass, field
86
+ from enum import StrEnum
87
+ from pathlib import Path
88
+ from typing import Any
89
+
90
+ from memtrust.adapters.base import BackendAPIError, MemoryBackendAdapter
91
+
92
+ DEFAULT_FIXTURE_PATH = (
93
+ Path(__file__).resolve().parents[3] / "tests" / "fixtures" / "crash_recovery_cases.json"
94
+ )
95
+
96
+
97
+ class CrashRecoverySignal(StrEnum):
98
+ """How one stored record fared across a simulated crash + restart."""
99
+
100
+ RECOVERED = "recovered"
101
+ """Present and searchable via query() both before and after the
102
+ simulated crash/restart -- the index survived or was correctly
103
+ rebuilt on startup."""
104
+
105
+ INDEX_LOST_DATA_SURVIVED = "index_lost_data_survived"
106
+ """query() returns nothing for this record after the simulated crash/
107
+ restart, but raw_store_contains() independently confirms the
108
+ underlying store still has the data. The exact volcengine/
109
+ OpenViking#2644 shape: `_recover()` skipped rebuilding the index
110
+ while store data survived, so queries silently return nothing for
111
+ data that was never actually lost."""
112
+
113
+ DATA_LOST = "data_lost"
114
+ """query() returns nothing after the simulated crash/restart AND
115
+ raw_store_contains() confirms the data itself is also gone -- a
116
+ different, more severe failure than #2644's shape (real data loss,
117
+ not merely an unrebuilt index)."""
118
+
119
+ NOT_APPLICABLE = "not_applicable"
120
+ """Either the adapter has no crash-recovery-simulation capability
121
+ (MemoryBackendAdapter.supports_crash_recovery_simulation is False --
122
+ the eval is skipped entirely, not run per-case), or the record was
123
+ never confirmed present/queryable before the simulated crash, so
124
+ there is nothing meaningful to classify about recovery."""
125
+
126
+
127
+ @dataclass
128
+ class CrashRecoveryCase:
129
+ case_id: str
130
+ session_id: str
131
+ content: str
132
+
133
+
134
+ @dataclass
135
+ class CrashRecoveryCaseResult:
136
+ case: CrashRecoveryCase
137
+ signal: CrashRecoverySignal
138
+ present_before_crash: bool
139
+ queryable_after_crash: bool
140
+ raw_store_contains_after_crash: bool | None
141
+ """None when the adapter has no crash-recovery-simulation capability
142
+ (raw_store_contains() was never called) or when the eval could not
143
+ reach the point of calling it (e.g. a BackendAPIError before the
144
+ simulated crash). True/False otherwise."""
145
+ error: str | None = None
146
+
147
+
148
+ @dataclass
149
+ class CrashRecoveryEvalResult:
150
+ backend_name: str
151
+ dataset_path: str
152
+ case_results: list[CrashRecoveryCaseResult] = field(default_factory=list)
153
+ skipped: bool = False
154
+ skip_reason: str | None = None
155
+
156
+ @property
157
+ def scored_cases(self) -> list[CrashRecoveryCaseResult]:
158
+ return [c for c in self.case_results if c.error is None]
159
+
160
+ def _fraction(self, signal: CrashRecoverySignal) -> float | None:
161
+ scored = self.scored_cases
162
+ if not scored:
163
+ return None
164
+ matching = sum(1 for c in scored if c.signal == signal)
165
+ return matching / len(scored)
166
+
167
+ @property
168
+ def recovered_rate(self) -> float | None:
169
+ return self._fraction(CrashRecoverySignal.RECOVERED)
170
+
171
+ @property
172
+ def index_lost_data_survived_rate(self) -> float | None:
173
+ """Fraction of cases where the simulated crash/restart lost the
174
+ index while the underlying data survived -- the headline metric
175
+ this eval exists to surface, and the exact volcengine/
176
+ OpenViking#2644 shape."""
177
+ return self._fraction(CrashRecoverySignal.INDEX_LOST_DATA_SURVIVED)
178
+
179
+ @property
180
+ def data_lost_rate(self) -> float | None:
181
+ return self._fraction(CrashRecoverySignal.DATA_LOST)
182
+
183
+
184
+ def load_dataset(path: Path | str = DEFAULT_FIXTURE_PATH) -> list[CrashRecoveryCase]:
185
+ data = json.loads(Path(path).read_text())
186
+ cases: list[dict[str, Any]] = data["cases"]
187
+ return [
188
+ CrashRecoveryCase(
189
+ case_id=c["case_id"],
190
+ session_id=c["session_id"],
191
+ content=c["content"],
192
+ )
193
+ for c in cases
194
+ ]
195
+
196
+
197
+ def classify_crash_recovery_case(
198
+ present_before_crash: bool,
199
+ queryable_after_crash: bool,
200
+ raw_store_contains_after_crash: bool | None,
201
+ ) -> CrashRecoverySignal:
202
+ """Classify a single case's outcome from its before/after observations.
203
+
204
+ Never trusts query() alone to mean "the data is gone" -- that is
205
+ exactly the ambiguity volcengine/OpenViking#2644 exploits (a lost
206
+ index and lost data both make query() return nothing). Only
207
+ raw_store_contains(), an independent observation that bypasses the
208
+ search index, can tell the two apart.
209
+ """
210
+ if not present_before_crash:
211
+ # Never confirmed present/queryable before the simulated crash --
212
+ # nothing meaningful to say about "recovery" for this case.
213
+ return CrashRecoverySignal.NOT_APPLICABLE
214
+ if queryable_after_crash:
215
+ return CrashRecoverySignal.RECOVERED
216
+ if raw_store_contains_after_crash is True:
217
+ return CrashRecoverySignal.INDEX_LOST_DATA_SURVIVED
218
+ if raw_store_contains_after_crash is False:
219
+ return CrashRecoverySignal.DATA_LOST
220
+ # raw_store_contains_after_crash is None: the eval never got an
221
+ # independent read on the underlying store (e.g. it errored out) --
222
+ # not enough evidence to call this either INDEX_LOST_DATA_SURVIVED or
223
+ # DATA_LOST.
224
+ return CrashRecoverySignal.NOT_APPLICABLE
225
+
226
+
227
+ def run_crash_recovery_eval(
228
+ adapter: MemoryBackendAdapter,
229
+ dataset_path: Path | str = DEFAULT_FIXTURE_PATH,
230
+ ) -> CrashRecoveryEvalResult:
231
+ cases = load_dataset(dataset_path)
232
+ result = CrashRecoveryEvalResult(backend_name=adapter.name, dataset_path=str(dataset_path))
233
+
234
+ if not adapter.supports_crash_recovery_simulation:
235
+ result.skipped = True
236
+ result.skip_reason = (
237
+ f"{adapter.name} does not support crash-recovery simulation "
238
+ "(supports_crash_recovery_simulation=False) -- skipped, not run. "
239
+ "No adapter in this repo has real process-lifecycle control over "
240
+ "a live backend server; see evals/crash_recovery.py's module "
241
+ "docstring and docs/methodology.md."
242
+ )
243
+ return result
244
+
245
+ for case in cases:
246
+ try:
247
+ store_result = adapter.store(case.session_id, case.content)
248
+ pre_crash_query = adapter.query(case.session_id, case.content, top_k=5)
249
+ present_before_crash = any(
250
+ case.content.lower() in r.content.lower() for r in pre_crash_query.records
251
+ )
252
+
253
+ adapter.simulate_crash_restart()
254
+
255
+ post_crash_query = adapter.query(case.session_id, case.content, top_k=5)
256
+ queryable_after_crash = any(
257
+ case.content.lower() in r.content.lower() for r in post_crash_query.records
258
+ )
259
+ raw_store_contains_after_crash: bool | None = None
260
+ if not queryable_after_crash:
261
+ raw_store_contains_after_crash = adapter.raw_store_contains(
262
+ case.session_id, store_result.memory_id
263
+ )
264
+ except BackendAPIError as exc:
265
+ result.case_results.append(
266
+ CrashRecoveryCaseResult(
267
+ case=case,
268
+ signal=CrashRecoverySignal.NOT_APPLICABLE,
269
+ present_before_crash=False,
270
+ queryable_after_crash=False,
271
+ raw_store_contains_after_crash=None,
272
+ error=str(exc),
273
+ )
274
+ )
275
+ continue
276
+
277
+ signal = classify_crash_recovery_case(
278
+ present_before_crash, queryable_after_crash, raw_store_contains_after_crash
279
+ )
280
+ result.case_results.append(
281
+ CrashRecoveryCaseResult(
282
+ case=case,
283
+ signal=signal,
284
+ present_before_crash=present_before_crash,
285
+ queryable_after_crash=queryable_after_crash,
286
+ raw_store_contains_after_crash=raw_store_contains_after_crash,
287
+ )
288
+ )
289
+
290
+ return result
@@ -0,0 +1,163 @@
1
+ """memtrust's vendor embedder-call counting and cost-attribution eval for
2
+ `Mem0DirectAdapter`.
3
+
4
+ Motivating case: spike-spiegel-21 (rank 104, mem0ai/mem0#1900, "[improvement]:
5
+ Duplicate embedding generation removed", merged). That PR's own description:
6
+ "If the new memory is not modified after `new_memories_with_actions`, we can
7
+ utilise the embedding that were already created while `search`." memtrust
8
+ had zero vendor-side embedder-call instrumentation anywhere --
9
+ `scoring/cost_tracker.py` tracked only memtrust's own judge-LLM spend, never
10
+ what a backend's own embedding API actually did per call. This eval and the
11
+ `CostTracker.record_embed_calls()`/`EmbedCallEntry` primitive it depends on
12
+ (see `mem0_direct_adapter.py`'s `_CountingEmbedder`) close that
13
+ instrumentation gap.
14
+
15
+ Honest scope -- read this before trusting a "redundant re-embed" verdict.
16
+ PR #1900 targeted an OLDER mem0 architecture: a separate LLM-driven
17
+ UPDATE/DELETE decision pass (`new_memories_with_actions`) that reused an
18
+ embedding already computed during an earlier search step. That code path is
19
+ gone in the currently installed `mem0ai==2.0.12`: confirmed by reading
20
+ `mem0/memory/main.py`'s `_add_to_vector_store()` directly, its "V3 PHASED
21
+ BATCH PIPELINE" only ever embeds the incoming query text once (Phase 1, for
22
+ similarity search against existing memories) and then unconditionally
23
+ batch-embeds every LLM-extracted fact (Phase 3) with no code path that
24
+ checks whether an extracted fact matches content already embedded in Phase
25
+ 1. This eval therefore does not, and cannot, reproduce PR #1900's original
26
+ diff -- the same honest substitution shape `Mem0DirectAdapter`'s own module
27
+ docstring already establishes for mem0ai/mem0#3558 (Kuzu): it reproduces the
28
+ bug *class* (vendor embedder-call instrumentation surfacing whether a
29
+ redundant re-embed happens) against the pipeline that actually exists today,
30
+ not a literal re-run of a since-superseded code path.
31
+
32
+ What this eval actually measures: seed a record via `store()`, `query()`
33
+ it back (the "search()" half of PR #1900's own phrasing), then `store()`
34
+ the exact same, unmodified content again (the "then add()" half) --
35
+ and reports whether the second `store()` call still triggered a real
36
+ vendor embedder call. Every `store()` call in the currently installed
37
+ package's pipeline does its own independent batch-embed with no reuse
38
+ logic at all, so a REDUNDANT_REEMBED verdict against a live mem0 instance
39
+ here should be read as "this is simply how the installed package's
40
+ pipeline behaves today," not as a regression this build discovered --
41
+ this eval exists to make that behavior *observable* through memtrust,
42
+ which it previously could not do.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ from dataclasses import dataclass
48
+ from enum import StrEnum
49
+
50
+ from memtrust.adapters.base import BackendAPIError
51
+ from memtrust.adapters.mem0_direct_adapter import Mem0DirectAdapter
52
+ from memtrust.scoring.cost_tracker import CostTracker
53
+
54
+ DEFAULT_SESSION_ID = "embedder-cost-session"
55
+ DEFAULT_CONTENT = "The user's preferred deploy window is Tuesday mornings."
56
+
57
+
58
+ class EmbedderCostSignal(StrEnum):
59
+ """Defined locally in this eval module rather than added to `base.py`,
60
+ following the precedent `evals/scale_stress.py`'s local `ScaleSignal`
61
+ enum already establishes for a capability that is not part of every
62
+ adapter's shared `MemoryBackendAdapter` interface -- this eval is
63
+ `Mem0DirectAdapter`-specific (it depends on `_CountingEmbedder`
64
+ wrapping a real, installed mem0 embedder instance), not something
65
+ every adapter could meaningfully report.
66
+ """
67
+
68
+ REDUNDANT_REEMBED = "redundant_reembed"
69
+ """The second `store()` call -- for the exact same, unmodified content
70
+ a prior `store()`+`query()` sequence already embedded -- still
71
+ triggered at least one real vendor embedder call. See module
72
+ docstring for the honest scope of what this does and does not prove
73
+ against the currently installed `mem0ai` package."""
74
+
75
+ NO_REDUNDANT_REEMBED = "no_redundant_reembed"
76
+ """The second `store()` call triggered zero vendor embedder calls --
77
+ the backend genuinely reused an existing embedding for unmodified
78
+ content instead of re-embedding it. The good outcome PR #1900's own
79
+ fix (for a now-superseded code path) achieved."""
80
+
81
+ NOT_APPLICABLE = "not_applicable"
82
+ """The store()/query() call sequence raised BackendAPIError, or this
83
+ adapter has no `cost_tracker`/embedder surface to observe at all --
84
+ recorded explicitly, never silently read as either signal above, same
85
+ convention every other signal enum's NOT_APPLICABLE member in this
86
+ package follows."""
87
+
88
+
89
+ @dataclass
90
+ class EmbedderCostEvalResult:
91
+ backend_name: str
92
+ signal: EmbedderCostSignal
93
+ first_store_embed_calls: int
94
+ """Vendor embedder-call count `_CountingEmbedder` observed for the
95
+ FIRST store() call (seeding the record)."""
96
+ second_store_embed_calls: int
97
+ """Vendor embedder-call count observed for the SECOND store() call --
98
+ the one this eval's verdict is actually about."""
99
+ error: str | None = None
100
+
101
+
102
+ def run_embedder_cost_eval(
103
+ adapter: Mem0DirectAdapter,
104
+ cost_tracker: CostTracker,
105
+ session_id: str = DEFAULT_SESSION_ID,
106
+ content: str = DEFAULT_CONTENT,
107
+ ) -> EmbedderCostEvalResult:
108
+ """Seed a record, query() it back (search()), then store() the exact
109
+ same unmodified content again (add()) -- see module docstring for the
110
+ real PR #1900 phrasing this mirrors -- and classify whether the second
111
+ store() call triggered a redundant vendor embedder call.
112
+
113
+ `cost_tracker` must be the same `CostTracker` instance `adapter` was
114
+ constructed with (`Mem0DirectAdapter(..., cost_tracker=cost_tracker)`)
115
+ -- this function reads `cost_tracker.embed_entries` after each
116
+ store() call to observe what `_CountingEmbedder` counted, rather than
117
+ requiring a new field on `StoreResult` (keeping this eval's scope to
118
+ exactly `mem0_direct_adapter.py` + `cost_tracker.py` + this new eval
119
+ module, per the backlog item's own `Where:` scope -- no `base.py`
120
+ change).
121
+ """
122
+ try:
123
+ adapter.store(session_id, content)
124
+ first_calls = cost_tracker.embed_entries[-1].call_count if cost_tracker.embed_entries else 0
125
+
126
+ adapter.query(session_id, content)
127
+ adapter.store(session_id, content)
128
+ second_calls = (
129
+ cost_tracker.embed_entries[-1].call_count if cost_tracker.embed_entries else 0
130
+ )
131
+ except BackendAPIError as exc:
132
+ return EmbedderCostEvalResult(
133
+ backend_name=adapter.name,
134
+ signal=EmbedderCostSignal.NOT_APPLICABLE,
135
+ first_store_embed_calls=0,
136
+ second_store_embed_calls=0,
137
+ error=str(exc),
138
+ )
139
+
140
+ if not cost_tracker.embed_entries:
141
+ # adapter.cost_tracker wasn't wired to the same CostTracker, or
142
+ # this adapter's underlying memory has no embedding_model surface
143
+ # to observe at all -- see Mem0DirectAdapter.store()'s gating.
144
+ return EmbedderCostEvalResult(
145
+ backend_name=adapter.name,
146
+ signal=EmbedderCostSignal.NOT_APPLICABLE,
147
+ first_store_embed_calls=0,
148
+ second_store_embed_calls=0,
149
+ error="no embed-call instrumentation observed -- adapter may not share "
150
+ "this cost_tracker, or its memory.embedding_model is unavailable",
151
+ )
152
+
153
+ signal = (
154
+ EmbedderCostSignal.REDUNDANT_REEMBED
155
+ if second_calls > 0
156
+ else EmbedderCostSignal.NO_REDUNDANT_REEMBED
157
+ )
158
+ return EmbedderCostEvalResult(
159
+ backend_name=adapter.name,
160
+ signal=signal,
161
+ first_store_embed_calls=first_calls,
162
+ second_store_embed_calls=second_calls,
163
+ )