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