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,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
|