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,373 @@
1
+ """MemTrust's extraction-quality-at-scale eval.
2
+
3
+ `evals/contradiction.py`'s module docstring is explicit about what it is:
4
+ "MemTrust's original multi-hop **contradiction-detection** eval" -- a
5
+ single stored fact, contradicted by a later one, then queried once. This
6
+ eval is the structurally distinct counterpart the backlog asked for: not
7
+ "did the backend correctly handle one contradicted fact," but "across many
8
+ independently stored items, did the backend retain content it should
9
+ never have kept, and does re-storing recalled content silently create
10
+ duplicates." Neither LongMemEval, LoCoMo, the contradiction eval, the
11
+ compression eval, the ranking-quality eval, nor the resource-sync-safety
12
+ eval measures this -- they either measure recall of facts worth
13
+ remembering, or the shape/order/fidelity of what comes back. None of them
14
+ asks whether the backend should have stored the item at all.
15
+
16
+ Motivating case: mem0ai/mem0#4573 (GitHub user jamebobob). A 32-day real
17
+ production audit found 97.8% of 10,134 stored mem0 entries were junk the
18
+ extraction pipeline should never have persisted:
19
+
20
+ * boot-file restating (~52.7%) -- the agent's own startup/config text
21
+ (tool lists, persona hashes, context-window sizes) re-stored as if it
22
+ were a new memory every session.
23
+ * cron/heartbeat noise (~11.5%) -- routine liveness-check output
24
+ ("all systems nominal") with no durable content.
25
+ * system dumps (~8.2%) -- raw tracebacks and error-response payloads.
26
+ * hallucinated profiles (~5.2%) -- attributes about the user the model
27
+ invented (favorite colors, pets, hobbies) that the user never
28
+ actually stated.
29
+
30
+ On top of the base junk-retention problem, jamebobob also documented a
31
+ feedback-loop case: a single hallucinated memory, once recalled back into
32
+ an agent's context, got re-extracted and re-stored as "new" input -- and
33
+ that one re-store fanned out into 808 duplicate records, not one. See
34
+ `ExtractionQualitySignal.FEEDBACK_LOOP_DUPLICATE` in adapters/base.py.
35
+
36
+ Design principle (same as every other eval in this repo): classification
37
+ never trusts a backend's own claims about what it stored -- it stores the
38
+ seed content, queries it back, and checks the actual retrieved text
39
+ against the case's ground-truth `should_be_stored` label, which memtrust
40
+ itself assigns and which no adapter ever sees or influences.
41
+
42
+ Honest limitation -- read this before trusting a junk-retention number.
43
+ Every fake adapter this eval's own test suite runs against is
44
+ hand-written specifically to model one of two behaviors: "retains
45
+ everything indiscriminately" (matching mem0's real reported behavior per
46
+ jamebobob's audit) or "filters by a category tag the eval itself passes
47
+ in `store()`'s metadata." That second fake is a stand-in for "a backend
48
+ with *some* extraction-quality gate," not a claim that any adapter in
49
+ this repo talks to a live mem0 instance's real LLM-driven extraction
50
+ pipeline -- no adapter here does. This eval and its fixture
51
+ (`tests/fixtures/extraction_quality_cases.json`) prove the
52
+ *classification logic* is correct against those fakes. Neither has been
53
+ run against a live mem0 instance at jamebobob's real 10,000+ entry scale;
54
+ see docs/methodology.md for the full caveat.
55
+ """
56
+
57
+ from __future__ import annotations
58
+
59
+ import json
60
+ from dataclasses import dataclass, field
61
+ from pathlib import Path
62
+ from typing import Any
63
+
64
+ from memtrust.adapters.base import (
65
+ BackendAPIError,
66
+ ExtractionQualitySignal,
67
+ MemoryBackendAdapter,
68
+ QueryResult,
69
+ )
70
+
71
+ DEFAULT_FIXTURE_PATH = (
72
+ Path(__file__).resolve().parents[3] / "tests" / "fixtures" / "extraction_quality_cases.json"
73
+ )
74
+
75
+ #: A single feedback-loop re-store() call is expected to add exactly one
76
+ #: new matching record (or zero, if the backend dedups the recalled text
77
+ #: against what it already has). Any growth beyond that is unexpected --
78
+ #: see classify_feedback_loop_case() below and
79
+ #: ExtractionQualitySignal.FEEDBACK_LOOP_DUPLICATE's docstring for why
80
+ #: this is the generalized shape of jamebobob's 808-duplicate finding.
81
+ _MAX_EXPECTED_GROWTH_PER_RESTORE = 1
82
+
83
+
84
+ @dataclass
85
+ class ExtractionQualityCase:
86
+ case_id: str
87
+ session_id: str
88
+ query: str
89
+ content: str
90
+ category: str
91
+ """Which junk (or valid) shape this case models -- e.g.
92
+ "boot_file_restating", "cron_heartbeat_noise", "system_dump",
93
+ "hallucinated_profile", "other_junk", or "valid_content". Threaded
94
+ into `adapter.store()`'s `metadata` so a fake adapter modeling a
95
+ category-aware extraction gate has something concrete to key off of
96
+ -- see tests/test_evals.py's GatedExtractionFakeAdapter."""
97
+ should_be_stored: bool
98
+ """Ground truth this eval owns, never read from or influenced by the
99
+ adapter under test: True for genuinely valuable content that a
100
+ correctly-behaving backend should retain, False for every junk
101
+ category above."""
102
+
103
+
104
+ @dataclass
105
+ class ExtractionQualityCaseResult:
106
+ case: ExtractionQualityCase
107
+ signal: ExtractionQualitySignal
108
+ retrieved: bool
109
+ """Whether the case's `content` was found (as a case-insensitive
110
+ substring) in the joined text of the query() response issued right
111
+ after storing it."""
112
+ retrieved_content: str
113
+ error: str | None = None
114
+
115
+
116
+ @dataclass
117
+ class FeedbackLoopCase:
118
+ case_id: str
119
+ session_id: str
120
+ seed_content: str
121
+ unique_marker: str
122
+ """A substring unique to `seed_content` (and expected to survive into
123
+ whatever text the backend echoes back on query()) used to count
124
+ matching records after each step -- robust to a backend that
125
+ paraphrases or otherwise doesn't echo the seed text verbatim, as long
126
+ as the marker itself survives."""
127
+ recall_query: str
128
+
129
+
130
+ @dataclass
131
+ class FeedbackLoopCaseResult:
132
+ case: FeedbackLoopCase
133
+ signal: ExtractionQualitySignal
134
+ records_after_first_store: int
135
+ """Count of returned records whose content contains `unique_marker`
136
+ immediately after the seed content is stored once -- expected to be
137
+ exactly 1 for any backend that neither drops nor duplicates a fresh
138
+ write."""
139
+ records_after_second_store: int
140
+ """Same count after the recalled text is re-stored as a second,
141
+ independent store() call. Growth beyond
142
+ records_after_first_store + 1 means that single re-store() call
143
+ produced more than one new matching record -- see
144
+ classify_feedback_loop_case()."""
145
+ recalled_text: str
146
+ """The text this eval re-stored as the second call -- the content of
147
+ whatever record matched `unique_marker` after the first store()+
148
+ query(), falling back to the original seed content if the backend
149
+ returned nothing matching (which itself already means no duplication
150
+ occurred, since there was nothing to re-store from)."""
151
+ error: str | None = None
152
+
153
+
154
+ @dataclass
155
+ class ExtractionQualityEvalResult:
156
+ backend_name: str
157
+ dataset_path: str
158
+ case_results: list[ExtractionQualityCaseResult] = field(default_factory=list)
159
+ feedback_loop_results: list[FeedbackLoopCaseResult] = field(default_factory=list)
160
+
161
+ @property
162
+ def scored_cases(self) -> list[ExtractionQualityCaseResult]:
163
+ return [c for c in self.case_results if c.error is None]
164
+
165
+ @property
166
+ def scored_junk_cases(self) -> list[ExtractionQualityCaseResult]:
167
+ return [c for c in self.scored_cases if not c.case.should_be_stored]
168
+
169
+ @property
170
+ def scored_valid_cases(self) -> list[ExtractionQualityCaseResult]:
171
+ return [c for c in self.scored_cases if c.case.should_be_stored]
172
+
173
+ def _fraction(
174
+ self, cases: list[ExtractionQualityCaseResult], signal: ExtractionQualitySignal
175
+ ) -> float | None:
176
+ if not cases:
177
+ return None
178
+ matching = sum(1 for c in cases if c.signal == signal)
179
+ return matching / len(cases)
180
+
181
+ @property
182
+ def junk_retained_rate(self) -> float | None:
183
+ """Fraction of junk cases (should_be_stored=False) that were still
184
+ retrievable after being stored -- the headline metric this eval
185
+ exists to surface, and the one that would reproduce jamebobob's
186
+ 97.8% junk-retention finding if run against a live, ungated
187
+ backend."""
188
+ return self._fraction(self.scored_junk_cases, ExtractionQualitySignal.RETAINED_JUNK)
189
+
190
+ @property
191
+ def junk_rejected_rate(self) -> float | None:
192
+ return self._fraction(self.scored_junk_cases, ExtractionQualitySignal.REJECTED_JUNK)
193
+
194
+ @property
195
+ def valid_retained_rate(self) -> float | None:
196
+ return self._fraction(self.scored_valid_cases, ExtractionQualitySignal.RETAINED_VALID)
197
+
198
+ @property
199
+ def valid_lost_rate(self) -> float | None:
200
+ """Fraction of genuinely valuable content that did NOT survive the
201
+ write path -- the necessary counterweight to junk_rejected_rate: a
202
+ backend that discards everything would score perfectly on
203
+ junk-rejection while failing every real user, and this metric is
204
+ what would catch that."""
205
+ return self._fraction(self.scored_valid_cases, ExtractionQualitySignal.LOST_VALID)
206
+
207
+ @property
208
+ def scored_feedback_loop_results(self) -> list[FeedbackLoopCaseResult]:
209
+ return [c for c in self.feedback_loop_results if c.error is None]
210
+
211
+ @property
212
+ def feedback_loop_duplicate_rate(self) -> float | None:
213
+ scored = self.scored_feedback_loop_results
214
+ if not scored:
215
+ return None
216
+ matching = sum(
217
+ 1 for c in scored if c.signal == ExtractionQualitySignal.FEEDBACK_LOOP_DUPLICATE
218
+ )
219
+ return matching / len(scored)
220
+
221
+
222
+ def load_dataset(
223
+ path: Path | str = DEFAULT_FIXTURE_PATH,
224
+ ) -> tuple[list[ExtractionQualityCase], list[FeedbackLoopCase]]:
225
+ data = json.loads(Path(path).read_text())
226
+ cases: list[dict[str, Any]] = data["cases"]
227
+ feedback_cases: list[dict[str, Any]] = data.get("feedback_loop_cases", [])
228
+ return (
229
+ [
230
+ ExtractionQualityCase(
231
+ case_id=c["case_id"],
232
+ session_id=c["session_id"],
233
+ query=c["query"],
234
+ content=c["content"],
235
+ category=c["category"],
236
+ should_be_stored=c["should_be_stored"],
237
+ )
238
+ for c in cases
239
+ ],
240
+ [
241
+ FeedbackLoopCase(
242
+ case_id=c["case_id"],
243
+ session_id=c["session_id"],
244
+ seed_content=c["seed_content"],
245
+ unique_marker=c["unique_marker"],
246
+ recall_query=c["recall_query"],
247
+ )
248
+ for c in feedback_cases
249
+ ],
250
+ )
251
+
252
+
253
+ def classify_extraction_case(
254
+ case: ExtractionQualityCase, query_result: QueryResult
255
+ ) -> tuple[ExtractionQualitySignal, bool]:
256
+ """Classify a single case's outcome from the adapter's query response.
257
+
258
+ Returns (final_signal, retrieved). `retrieved` is True if `case.content`
259
+ appears as a case-insensitive substring anywhere in the joined text of
260
+ `query_result.records` -- the same plain-substring convention
261
+ `evals/contradiction.py`'s `classify_case` uses for its own value
262
+ checks, chosen for the same reason: it is the one check every backend
263
+ can be held to without assuming any vendor-specific metadata shape.
264
+ """
265
+ content = " ".join(r.content for r in query_result.records).lower()
266
+ retrieved = case.content.lower() in content
267
+
268
+ if case.should_be_stored:
269
+ signal = (
270
+ ExtractionQualitySignal.RETAINED_VALID
271
+ if retrieved
272
+ else ExtractionQualitySignal.LOST_VALID
273
+ )
274
+ else:
275
+ signal = (
276
+ ExtractionQualitySignal.RETAINED_JUNK
277
+ if retrieved
278
+ else ExtractionQualitySignal.REJECTED_JUNK
279
+ )
280
+ return signal, retrieved
281
+
282
+
283
+ def classify_feedback_loop_case(
284
+ records_after_first_store: int, records_after_second_store: int
285
+ ) -> ExtractionQualitySignal:
286
+ """Classify a feedback-loop case from its two record-count
287
+ observations. A single re-store() call legitimately adds at most one
288
+ new matching record (zero if the backend dedups); any growth beyond
289
+ that is unexpected duplication -- the generalized shape of
290
+ jamebobob's exact 808-duplicate finding (mem0ai/mem0#4573), where one
291
+ re-extracted recall fanned out into hundreds of stored copies instead
292
+ of one.
293
+ """
294
+ growth = records_after_second_store - records_after_first_store
295
+ if growth > _MAX_EXPECTED_GROWTH_PER_RESTORE:
296
+ return ExtractionQualitySignal.FEEDBACK_LOOP_DUPLICATE
297
+ return ExtractionQualitySignal.NO_UNEXPECTED_GROWTH
298
+
299
+
300
+ def run_extraction_quality_eval(
301
+ adapter: MemoryBackendAdapter,
302
+ dataset_path: Path | str = DEFAULT_FIXTURE_PATH,
303
+ ) -> ExtractionQualityEvalResult:
304
+ cases, feedback_cases = load_dataset(dataset_path)
305
+ result = ExtractionQualityEvalResult(backend_name=adapter.name, dataset_path=str(dataset_path))
306
+
307
+ for case in cases:
308
+ try:
309
+ adapter.store(case.session_id, case.content, metadata={"category": case.category})
310
+ query_result = adapter.query(case.session_id, case.query, top_k=10)
311
+ except BackendAPIError as exc:
312
+ result.case_results.append(
313
+ ExtractionQualityCaseResult(
314
+ case=case,
315
+ signal=ExtractionQualitySignal.NOT_APPLICABLE,
316
+ retrieved=False,
317
+ retrieved_content="",
318
+ error=str(exc),
319
+ )
320
+ )
321
+ continue
322
+
323
+ final_signal, retrieved = classify_extraction_case(case, query_result)
324
+ result.case_results.append(
325
+ ExtractionQualityCaseResult(
326
+ case=case,
327
+ signal=final_signal,
328
+ retrieved=retrieved,
329
+ retrieved_content=" ".join(r.content for r in query_result.records),
330
+ )
331
+ )
332
+
333
+ for fb_case in feedback_cases:
334
+ try:
335
+ adapter.store(fb_case.session_id, fb_case.seed_content)
336
+ recall_result = adapter.query(fb_case.session_id, fb_case.recall_query, top_k=50)
337
+ marker = fb_case.unique_marker.lower()
338
+ matching_after_first = [r for r in recall_result.records if marker in r.content.lower()]
339
+ records_after_first = len(matching_after_first)
340
+ recalled_text = (
341
+ matching_after_first[0].content if matching_after_first else fb_case.seed_content
342
+ )
343
+
344
+ adapter.store(fb_case.session_id, recalled_text)
345
+ final_result = adapter.query(fb_case.session_id, fb_case.recall_query, top_k=1000)
346
+ records_after_second = sum(
347
+ 1 for r in final_result.records if marker in r.content.lower()
348
+ )
349
+ except BackendAPIError as exc:
350
+ result.feedback_loop_results.append(
351
+ FeedbackLoopCaseResult(
352
+ case=fb_case,
353
+ signal=ExtractionQualitySignal.NOT_APPLICABLE,
354
+ records_after_first_store=0,
355
+ records_after_second_store=0,
356
+ recalled_text="",
357
+ error=str(exc),
358
+ )
359
+ )
360
+ continue
361
+
362
+ final_signal = classify_feedback_loop_case(records_after_first, records_after_second)
363
+ result.feedback_loop_results.append(
364
+ FeedbackLoopCaseResult(
365
+ case=fb_case,
366
+ signal=final_signal,
367
+ records_after_first_store=records_after_first,
368
+ records_after_second_store=records_after_second,
369
+ recalled_text=recalled_text,
370
+ )
371
+ )
372
+
373
+ return result