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,249 @@
1
+ """LongMemEval-style long-horizon recall eval runner.
2
+
3
+ LongMemEval (Wu et al., ICLR 2025, github.com/xiaowu0162/LongMemEval) asks
4
+ whether a chat assistant can correctly recall a fact injected many turns
5
+ earlier in a long conversation. This runner loads a dataset file matching
6
+ the published schema (question_id, question_type, question, answer,
7
+ haystack_sessions -- a list of chat sessions, each a list of
8
+ {role, content} turns) and, for each example:
9
+
10
+ 1. Replays every haystack session into the backend via store(), one
11
+ call per turn, scoped to a session_id derived from question_id so
12
+ concurrent examples never collide.
13
+ 2. Calls query() with the example's question.
14
+ 3. Grades the returned content against the expected answer using the
15
+ configured LLM judge (semantic match, not exact string match --
16
+ "Baxter the golden retriever" should count as correct for an answer
17
+ of "Baxter").
18
+
19
+ The bundled tests/fixtures/longmemeval_sample.json is a small, explicitly
20
+ synthetic sample matching the real dataset's schema -- see its top-level
21
+ "_note" field and docs/methodology.md for exactly what is synthetic here
22
+ versus what would run against the real, full public dataset given network
23
+ access to download it.
24
+
25
+ ## top_k vs. corpus size
26
+
27
+ `run_longmemeval()` queries every case with a fixed `top_k` (see
28
+ DEFAULT_TOP_K below). A benchmark row is only measuring real retrieval
29
+ quality when the backend has to rank `top_k` results out of a corpus
30
+ meaningfully larger than `top_k` -- if a case's corpus (the haystack
31
+ turns actually stored for that question) is no bigger than `top_k`,
32
+ "return the right answer" degenerates into "return everything you have,"
33
+ which any backend can do regardless of ranking quality. This is the same
34
+ shape contributor jtatum flagged in MemPalace's own benchmark scripts:
35
+ `n_results >= corpus_size` trivializes recall into "rank over a corpus
36
+ you can already see all of." This repo could not independently verify a
37
+ specific upstream issue number for that finding, so it is credited here
38
+ by name rather than cited with a fabricated `repo#N` link -- unlike every
39
+ other "Motivating case" note in this codebase (see docs/methodology.md).
40
+
41
+ Structurally, this mirrors `evals/locomo.py`'s `non_adversarial_accuracy`:
42
+ both exist so a caller sees a benchmark-integrity concern instead of a
43
+ single blended number. The two mechanisms differ because the concerns
44
+ differ -- LoCoMo's adversarial subset is a category of *questions* to
45
+ exclude from an aggregate, known ahead of time from the dataset; a
46
+ corpus-size-vs-top_k mismatch is a per-case *retrieval setup* fact that
47
+ only exists once haystack turns are actually stored, so LongMemEval
48
+ surfaces it as a flag on `LongMemEvalCaseResult`
49
+ (`top_k_exceeds_corpus`) plus an aggregate count
50
+ (`LongMemEvalResult.n_top_k_exceeds_corpus`) rather than a second
51
+ accuracy property -- it is a disclosure a caller checks before trusting
52
+ a high accuracy number, not a subset LongMemEval's own published schema
53
+ says to exclude from scoring.
54
+ """
55
+
56
+ from __future__ import annotations
57
+
58
+ import json
59
+ from dataclasses import dataclass, field
60
+ from pathlib import Path
61
+ from typing import Any
62
+
63
+ from memtrust.adapters.base import BackendAPIError, MemoryBackendAdapter
64
+ from memtrust.scoring.llm_judge import JudgeVerdict, LLMJudge
65
+
66
+ DEFAULT_FIXTURE_PATH = (
67
+ Path(__file__).resolve().parents[3] / "tests" / "fixtures" / "longmemeval_sample.json"
68
+ )
69
+
70
+ #: Default `top_k` passed to every `adapter.query()` call in
71
+ #: `run_longmemeval()`. Pulled out as a named constant (rather than the
72
+ #: literal `5` in two places) specifically so it can be compared against
73
+ #: each case's corpus size for the `top_k_exceeds_corpus` guard below --
74
+ #: see the module docstring's "top_k vs. corpus size" section.
75
+ DEFAULT_TOP_K = 5
76
+
77
+
78
+ @dataclass
79
+ class LongMemEvalCaseResult:
80
+ question_id: str
81
+ question_type: str
82
+ question: str
83
+ expected_answer: str
84
+ actual_answer: str
85
+ verdict: JudgeVerdict
86
+ reasoning: str
87
+ records_empty: bool = False
88
+ """True when adapter.query() completed without error but returned zero
89
+ records for this question. A judge then grades an empty actual_answer
90
+ as an ordinary miss (wrong/incorrect) same as any other wrong answer --
91
+ this field exists so a run can distinguish "the model reasoned about
92
+ retrieved content and got it wrong" from "the backend silently gave
93
+ back nothing to reason about," which is a diagnostically different
94
+ failure the underlying vendor call may share across many questions.
95
+ See adapters/base.py's ConflictSignal.EMPTY_OR_LOST for the analogous
96
+ signal in the contradiction eval."""
97
+ degraded_retrieval: bool = False
98
+ """True when adapter.query() completed without error and returned at
99
+ least one record, but the backend's own response signaled it
100
+ under-delivered anyway (see adapters/base.py's RetrievalWarning,
101
+ confirmed against the real, merged MemPalace/mempalace#1005 PR diff).
102
+ This is distinct from records_empty above: a case can have
103
+ records_empty=False and degraded_retrieval=True at the same time --
104
+ the backend returned *something* to grade, but warned it wasn't the
105
+ full picture. Separating the two lets a report distinguish "backend
106
+ warned us, we surfaced it" from "backend silently returned wrong or
107
+ incomplete facts with no signal at all," which the judge's verdict on
108
+ its own cannot tell apart."""
109
+ corpus_size: int = 0
110
+ """Number of haystack turns actually stored for this case (one
111
+ store() call per `role == "user"` turn across every haystack
112
+ session) before query() was called -- the real size of the corpus
113
+ `top_k` is being asked to rank over for this specific case. See
114
+ `top_k_exceeds_corpus` below and the module docstring's "top_k vs.
115
+ corpus size" section."""
116
+ top_k_exceeds_corpus: bool = False
117
+ """True when this case's corpus_size is less than or equal to the
118
+ top_k value used for query() (DEFAULT_TOP_K unless a caller passes a
119
+ different one). When True, a high accuracy verdict on this case does
120
+ not by itself demonstrate genuine retrieval quality -- top_k already
121
+ covers the entire corpus, so "return the right answer" reduces to
122
+ "return everything," which any backend can do regardless of ranking
123
+ quality. See the module docstring's "top_k vs. corpus size" section
124
+ for the motivating gaming shape this guards against."""
125
+ error: str | None = None
126
+
127
+
128
+ @dataclass
129
+ class LongMemEvalResult:
130
+ backend_name: str
131
+ dataset_path: str
132
+ case_results: list[LongMemEvalCaseResult] = field(default_factory=list)
133
+
134
+ @property
135
+ def graded_cases(self) -> list[LongMemEvalCaseResult]:
136
+ return [
137
+ c for c in self.case_results if c.verdict != JudgeVerdict.NOT_RUN and c.error is None
138
+ ]
139
+
140
+ @property
141
+ def accuracy(self) -> float | None:
142
+ graded = self.graded_cases
143
+ if not graded:
144
+ return None
145
+ correct = sum(1 for c in graded if c.verdict == JudgeVerdict.CORRECT)
146
+ return correct / len(graded)
147
+
148
+ @property
149
+ def judge_unavailable(self) -> bool:
150
+ return len(self.graded_cases) == 0 and len(self.case_results) > 0
151
+
152
+ @property
153
+ def n_records_empty(self) -> int:
154
+ """Count of cases where the backend call succeeded but returned
155
+ zero records -- see LongMemEvalCaseResult.records_empty."""
156
+ return sum(1 for c in self.case_results if c.records_empty)
157
+
158
+ @property
159
+ def n_degraded_retrieval(self) -> int:
160
+ """Count of cases where the backend's own response signaled
161
+ under-delivered (but non-empty) retrieval -- see
162
+ LongMemEvalCaseResult.degraded_retrieval."""
163
+ return sum(1 for c in self.case_results if c.degraded_retrieval)
164
+
165
+ @property
166
+ def n_top_k_exceeds_corpus(self) -> int:
167
+ """Count of cases whose corpus was no bigger than top_k -- see
168
+ LongMemEvalCaseResult.top_k_exceeds_corpus and the module
169
+ docstring's "top_k vs. corpus size" section. A non-zero count
170
+ here means at least one case in this run cannot distinguish
171
+ genuine retrieval quality from a backend simply returning its
172
+ entire (small) corpus."""
173
+ return sum(1 for c in self.case_results if c.top_k_exceeds_corpus)
174
+
175
+
176
+ def load_dataset(path: Path | str = DEFAULT_FIXTURE_PATH) -> list[dict[str, Any]]:
177
+ data = json.loads(Path(path).read_text())
178
+ examples: list[dict[str, Any]] = data["examples"]
179
+ return examples
180
+
181
+
182
+ def run_longmemeval(
183
+ adapter: MemoryBackendAdapter,
184
+ judge: LLMJudge,
185
+ dataset_path: Path | str = DEFAULT_FIXTURE_PATH,
186
+ ) -> LongMemEvalResult:
187
+ examples = load_dataset(dataset_path)
188
+ result = LongMemEvalResult(backend_name=adapter.name, dataset_path=str(dataset_path))
189
+
190
+ for example in examples:
191
+ session_id = f"longmemeval-{example['question_id']}"
192
+ # Computed from the dataset directly (not from a running counter of
193
+ # successful store() calls) so it reflects the intended corpus size
194
+ # for this case even if store() fails partway through -- see the
195
+ # module docstring's "top_k vs. corpus size" section.
196
+ corpus_size = sum(
197
+ 1
198
+ for session in example["haystack_sessions"]
199
+ for turn in session
200
+ if turn["role"] == "user"
201
+ )
202
+ top_k_exceeds_corpus = corpus_size <= DEFAULT_TOP_K
203
+ try:
204
+ for session in example["haystack_sessions"]:
205
+ for turn in session:
206
+ if turn["role"] == "user":
207
+ adapter.store(session_id, turn["content"])
208
+ query_result = adapter.query(session_id, example["question"], top_k=DEFAULT_TOP_K)
209
+ except BackendAPIError as exc:
210
+ result.case_results.append(
211
+ LongMemEvalCaseResult(
212
+ question_id=example["question_id"],
213
+ question_type=example["question_type"],
214
+ question=example["question"],
215
+ expected_answer=example["answer"],
216
+ actual_answer="",
217
+ verdict=JudgeVerdict.NOT_RUN,
218
+ reasoning="",
219
+ corpus_size=corpus_size,
220
+ top_k_exceeds_corpus=top_k_exceeds_corpus,
221
+ error=str(exc),
222
+ )
223
+ )
224
+ continue
225
+
226
+ # actual_answer is the raw retrieved-record content, judged directly -- there is
227
+ # no answer-generation step here. This is "retrieval-graded accuracy," not the
228
+ # official LongMemEval leaderboard's generate+judge QA-accuracy measurement. See
229
+ # docs/methodology.md's "Retrieval-graded accuracy vs. generated-answer accuracy"
230
+ # section before comparing this metric to a leaderboard figure.
231
+ actual_answer = " ".join(r.content for r in query_result.records)
232
+ judge_result = judge.judge_answer(example["question"], example["answer"], actual_answer)
233
+ result.case_results.append(
234
+ LongMemEvalCaseResult(
235
+ question_id=example["question_id"],
236
+ question_type=example["question_type"],
237
+ question=example["question"],
238
+ expected_answer=example["answer"],
239
+ actual_answer=actual_answer,
240
+ verdict=judge_result.verdict,
241
+ reasoning=judge_result.reasoning,
242
+ records_empty=not query_result.records,
243
+ degraded_retrieval=query_result.degraded_retrieval is not None,
244
+ corpus_size=corpus_size,
245
+ top_k_exceeds_corpus=top_k_exceeds_corpus,
246
+ )
247
+ )
248
+
249
+ return result