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,263 @@
|
|
|
1
|
+
"""MemTrust's lock-contention/hang-detection eval.
|
|
2
|
+
|
|
3
|
+
Motivating case: volcengine/OpenViking#1581 (contributor 0xble).
|
|
4
|
+
`memory.v2_lock_max_retries` uses `0` to mean "unlimited retries" -- the
|
|
5
|
+
opposite of the usual convention where `0` retries means "one attempt and
|
|
6
|
+
give up." Because `0` is also the default value, any deployment that never
|
|
7
|
+
explicitly sets this field gets infinite-retry semantics for free: a
|
|
8
|
+
session-commit path contending for a stale lock (e.g. one left behind by a
|
|
9
|
+
crashed holder -- the issue's own step-2 repro) spins forever. No exception
|
|
10
|
+
is ever raised; the only surface signal is a repeated `logger.warning`
|
|
11
|
+
line ("retrying ... max=unlimited"). A caller waiting on that request has
|
|
12
|
+
no API-observable way to tell "still legitimately working" apart from
|
|
13
|
+
"will never return."
|
|
14
|
+
|
|
15
|
+
**Honest scope of what this eval can and cannot prove.** None of memtrust's
|
|
16
|
+
adapters have process-lifecycle or server-config control (see adapters/
|
|
17
|
+
base.py's module docstring) -- there is no way for this harness to reach
|
|
18
|
+
into a live OpenViking server and actually set `memory.v2_lock_max_retries`
|
|
19
|
+
or engineer a genuinely crashed lock holder server-side. So this eval does
|
|
20
|
+
not, and cannot, reproduce #1581 against a live OpenViking instance.
|
|
21
|
+
Instead, matching this codebase's established pattern for capability-gap
|
|
22
|
+
evals (evals/crash_recovery.py, evals/migration_rollback.py), it targets
|
|
23
|
+
the STRUCTURAL failure shape directly and generically: fire N concurrent
|
|
24
|
+
store() calls at the SAME `resource_path` (the one primitive every adapter
|
|
25
|
+
that honors resource-scoped writes shares -- see
|
|
26
|
+
MemoryBackendAdapter.supports_resource_sync and openviking_adapter.py's
|
|
27
|
+
store()) and assert that every request either completes (successfully or
|
|
28
|
+
with a raised error) or is definitively abandoned within a fixed wall-clock
|
|
29
|
+
response-time budget. A request that neither returns nor raises within
|
|
30
|
+
that budget is exactly #1581's "spins forever, no exception, no signal"
|
|
31
|
+
shape, made API-observable instead of only visible as a WARN-log flood.
|
|
32
|
+
|
|
33
|
+
Only a purpose-built in-memory fake adapter can genuinely model both the
|
|
34
|
+
buggy "unlimited retries against a permanently stale lock" behavior and
|
|
35
|
+
the fixed "bounded retries, fails fast" behavior for a live-timing
|
|
36
|
+
assertion in a test suite -- see
|
|
37
|
+
tests/test_evals.py::LockContentionFakeAdapter. A real, configured adapter
|
|
38
|
+
can still be pointed at this eval (nothing about run_lock_contention_eval
|
|
39
|
+
requires a fake), but as of this writing no adapter in this repo has ever
|
|
40
|
+
been run through it against a live backend -- see docs/methodology.md.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import threading
|
|
46
|
+
import time
|
|
47
|
+
from dataclasses import dataclass, field
|
|
48
|
+
from enum import StrEnum
|
|
49
|
+
|
|
50
|
+
from memtrust.adapters.base import BackendAPIError, MemoryBackendAdapter
|
|
51
|
+
|
|
52
|
+
#: Default wall-clock budget for a single request under contention.
|
|
53
|
+
#: Generous enough that a healthy backend serializing writes to the same
|
|
54
|
+
#: resource_path (a legitimate, bounded queueing delay) comfortably clears
|
|
55
|
+
#: it, tight enough that a genuinely stuck/unbounded-retry request (the
|
|
56
|
+
#: #1581 shape) is reliably caught within a fast-in-CI test run.
|
|
57
|
+
DEFAULT_RESPONSE_BUDGET_MS = 2000.0
|
|
58
|
+
|
|
59
|
+
#: Default number of concurrent writers contending for the same
|
|
60
|
+
#: resource_path. Small enough to run fast in CI, large enough that a
|
|
61
|
+
#: single-writer-at-a-time lock produces real, observable queueing.
|
|
62
|
+
DEFAULT_N_CONCURRENT = 8
|
|
63
|
+
|
|
64
|
+
DEFAULT_RESOURCE_PATH = "lock-contention/shared-resource.md"
|
|
65
|
+
DEFAULT_SESSION_ID = "lock-contention-session"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class LockContentionSignal(StrEnum):
|
|
69
|
+
"""How a backend's concurrent-write requests against the SAME
|
|
70
|
+
resource_path behaved relative to a fixed response-time budget.
|
|
71
|
+
|
|
72
|
+
Defined locally in this module rather than in adapters/base.py,
|
|
73
|
+
following the same precedent evals/scale_stress.py's ScaleSignal and
|
|
74
|
+
evals/stats_accuracy.py's StatsSignal already set: a harness-computed
|
|
75
|
+
classification derived from timing ground truth this eval measures
|
|
76
|
+
itself, not a signal any adapter self-reports.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
BOUNDED_RESPONSE = "bounded_response"
|
|
80
|
+
"""Every concurrent request against the shared resource_path either
|
|
81
|
+
succeeded or raised within the response-time budget. The good
|
|
82
|
+
outcome -- contention exists (requests may still queue behind each
|
|
83
|
+
other) but never silently exceeds a bounded SLA. This is the fixed,
|
|
84
|
+
post-#1581 semantics: even a request that ultimately fails to acquire
|
|
85
|
+
the lock fails FAST, with a raised error, rather than spinning."""
|
|
86
|
+
|
|
87
|
+
UNBOUNDED_STALL = "unbounded_stall"
|
|
88
|
+
"""At least one concurrent request against the shared resource_path
|
|
89
|
+
neither succeeded nor raised within the response-time budget -- it was
|
|
90
|
+
still pending when this eval gave up waiting on it. This is the exact
|
|
91
|
+
volcengine/OpenViking#1581 shape made API-observable: a stuck lock
|
|
92
|
+
that retries forever, with no exception and no signal any caller could
|
|
93
|
+
act on besides waiting indefinitely."""
|
|
94
|
+
|
|
95
|
+
NOT_APPLICABLE = "not_applicable"
|
|
96
|
+
"""Either the adapter has no resource-path-scoped write concept at all
|
|
97
|
+
(MemoryBackendAdapter.supports_resource_sync is False -- concurrent
|
|
98
|
+
writers targeting "the same resource_path" have nothing to actually
|
|
99
|
+
contend over, so the eval is skipped, not run), or zero requests were
|
|
100
|
+
issued."""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass
|
|
104
|
+
class LockContentionRequestResult:
|
|
105
|
+
"""The outcome of one concurrent store() call against the shared
|
|
106
|
+
resource_path."""
|
|
107
|
+
|
|
108
|
+
worker_index: int
|
|
109
|
+
completed: bool
|
|
110
|
+
"""False means this request was still running when the eval's
|
|
111
|
+
response-time budget elapsed and it gave up waiting -- see
|
|
112
|
+
run_lock_contention_eval's module docstring on why this is measured
|
|
113
|
+
via a bounded wait rather than an actual cancellation (Python threads
|
|
114
|
+
cannot be forcibly killed; the underlying call may keep running in the
|
|
115
|
+
background after this eval returns)."""
|
|
116
|
+
succeeded: bool | None
|
|
117
|
+
"""True/False if completed is True (whether the store() call itself
|
|
118
|
+
raised BackendAPIError or not). None if completed is False -- there is
|
|
119
|
+
no verdict yet for a request that never returned."""
|
|
120
|
+
latency_ms: float | None
|
|
121
|
+
"""None if completed is False."""
|
|
122
|
+
error: str | None = None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class LockContentionEvalResult:
|
|
127
|
+
backend_name: str
|
|
128
|
+
resource_path: str
|
|
129
|
+
budget_ms: float
|
|
130
|
+
n_concurrent: int
|
|
131
|
+
requests: list[LockContentionRequestResult] = field(default_factory=list)
|
|
132
|
+
signal: LockContentionSignal = LockContentionSignal.NOT_APPLICABLE
|
|
133
|
+
skipped: bool = False
|
|
134
|
+
skip_reason: str | None = None
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def stalled_count(self) -> int:
|
|
138
|
+
"""How many of `requests` never completed within the budget --
|
|
139
|
+
the headline metric this eval exists to surface."""
|
|
140
|
+
return sum(1 for r in self.requests if not r.completed)
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def max_latency_ms(self) -> float | None:
|
|
144
|
+
"""Highest latency among requests that DID complete within the
|
|
145
|
+
budget. `None` if none completed (or there were no requests)."""
|
|
146
|
+
completed_latencies = [r.latency_ms for r in self.requests if r.latency_ms is not None]
|
|
147
|
+
return max(completed_latencies) if completed_latencies else None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def classify_lock_contention_result(
|
|
151
|
+
requests: list[LockContentionRequestResult],
|
|
152
|
+
) -> LockContentionSignal:
|
|
153
|
+
"""Classify a completed run's per-request outcomes. Never a blind
|
|
154
|
+
"did anything raise" check -- the classification is driven entirely by
|
|
155
|
+
whether every request resolved (success or raised error, either is
|
|
156
|
+
fine) within the fixed response-time budget, the same
|
|
157
|
+
ground-truth-driven pattern every other eval's classify_* function in
|
|
158
|
+
this package follows.
|
|
159
|
+
"""
|
|
160
|
+
if not requests:
|
|
161
|
+
return LockContentionSignal.NOT_APPLICABLE
|
|
162
|
+
if any(not r.completed for r in requests):
|
|
163
|
+
return LockContentionSignal.UNBOUNDED_STALL
|
|
164
|
+
return LockContentionSignal.BOUNDED_RESPONSE
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def run_lock_contention_eval(
|
|
168
|
+
adapter: MemoryBackendAdapter,
|
|
169
|
+
resource_path: str = DEFAULT_RESOURCE_PATH,
|
|
170
|
+
n_concurrent: int = DEFAULT_N_CONCURRENT,
|
|
171
|
+
budget_ms: float = DEFAULT_RESPONSE_BUDGET_MS,
|
|
172
|
+
session_id: str = DEFAULT_SESSION_ID,
|
|
173
|
+
) -> LockContentionEvalResult:
|
|
174
|
+
"""Fire `n_concurrent` concurrent store() calls at the SAME
|
|
175
|
+
`resource_path` and assert each one resolves (successfully or with a
|
|
176
|
+
raised BackendAPIError) within `budget_ms` wall-clock time.
|
|
177
|
+
|
|
178
|
+
Uses real OS threads (`threading.Thread`, daemon=True) rather than a
|
|
179
|
+
thread pool with a blocking shutdown: a genuinely stuck request (the
|
|
180
|
+
#1581 shape this eval exists to catch) must not block this function
|
|
181
|
+
from returning once its budget has elapsed, and a daemon thread left
|
|
182
|
+
running in the background cannot block process/test-suite exit either
|
|
183
|
+
-- see tests/test_evals.py::LockContentionFakeAdapter's docstring for
|
|
184
|
+
how its own internal retry cap keeps an abandoned worker thread
|
|
185
|
+
bounded rather than truly running forever.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
adapter: the backend under test.
|
|
189
|
+
resource_path: the shared resource_path every concurrent writer
|
|
190
|
+
targets, via `metadata={"resource_path": resource_path}` (the
|
|
191
|
+
same metadata key openviking_adapter.py's store() honors --
|
|
192
|
+
see its module docstring).
|
|
193
|
+
n_concurrent: how many concurrent store() calls to fire.
|
|
194
|
+
budget_ms: wall-clock budget, per request, before this eval gives
|
|
195
|
+
up waiting and classifies that request as UNBOUNDED_STALL.
|
|
196
|
+
session_id: session/scope every request is stored under.
|
|
197
|
+
"""
|
|
198
|
+
result = LockContentionEvalResult(
|
|
199
|
+
backend_name=adapter.name,
|
|
200
|
+
resource_path=resource_path,
|
|
201
|
+
budget_ms=budget_ms,
|
|
202
|
+
n_concurrent=n_concurrent,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
if not adapter.supports_resource_sync:
|
|
206
|
+
result.skipped = True
|
|
207
|
+
result.skip_reason = (
|
|
208
|
+
f"{adapter.name} does not honor a resource_path-scoped store() "
|
|
209
|
+
"(supports_resource_sync=False) -- concurrent writers have no shared "
|
|
210
|
+
"target to actually contend over. Skipped, not run. See "
|
|
211
|
+
"adapters/base.py's MemoryBackendAdapter.supports_resource_sync and "
|
|
212
|
+
"evals/lock_contention.py's module docstring."
|
|
213
|
+
)
|
|
214
|
+
return result
|
|
215
|
+
|
|
216
|
+
if n_concurrent < 1:
|
|
217
|
+
return result
|
|
218
|
+
|
|
219
|
+
slots: list[LockContentionRequestResult | None] = [None] * n_concurrent
|
|
220
|
+
|
|
221
|
+
def _worker(worker_index: int) -> None:
|
|
222
|
+
start = time.perf_counter()
|
|
223
|
+
try:
|
|
224
|
+
adapter.store(
|
|
225
|
+
session_id,
|
|
226
|
+
f"concurrent write from worker {worker_index}",
|
|
227
|
+
metadata={"resource_path": resource_path},
|
|
228
|
+
)
|
|
229
|
+
elapsed_ms = (time.perf_counter() - start) * 1000
|
|
230
|
+
slots[worker_index] = LockContentionRequestResult(
|
|
231
|
+
worker_index=worker_index, completed=True, succeeded=True, latency_ms=elapsed_ms
|
|
232
|
+
)
|
|
233
|
+
except BackendAPIError as exc:
|
|
234
|
+
elapsed_ms = (time.perf_counter() - start) * 1000
|
|
235
|
+
slots[worker_index] = LockContentionRequestResult(
|
|
236
|
+
worker_index=worker_index,
|
|
237
|
+
completed=True,
|
|
238
|
+
succeeded=False,
|
|
239
|
+
latency_ms=elapsed_ms,
|
|
240
|
+
error=str(exc),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
threads = [
|
|
244
|
+
threading.Thread(target=_worker, args=(i,), daemon=True) for i in range(n_concurrent)
|
|
245
|
+
]
|
|
246
|
+
deadline = time.perf_counter() + budget_ms / 1000
|
|
247
|
+
for thread in threads:
|
|
248
|
+
thread.start()
|
|
249
|
+
for thread in threads:
|
|
250
|
+
remaining = max(0.0, deadline - time.perf_counter())
|
|
251
|
+
thread.join(timeout=remaining)
|
|
252
|
+
|
|
253
|
+
for worker_index, slot in enumerate(slots):
|
|
254
|
+
result.requests.append(
|
|
255
|
+
slot
|
|
256
|
+
if slot is not None
|
|
257
|
+
else LockContentionRequestResult(
|
|
258
|
+
worker_index=worker_index, completed=False, succeeded=None, latency_ms=None
|
|
259
|
+
)
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
result.signal = classify_lock_contention_result(result.requests)
|
|
263
|
+
return result
|
memtrust/evals/locomo.py
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""LoCoMo-style multi-session conversational memory eval runner.
|
|
2
|
+
|
|
3
|
+
LoCoMo (github.com/snap-research/locomo) tests multi-session, multi-day
|
|
4
|
+
conversational memory: can the backend answer questions that require
|
|
5
|
+
recalling and reasoning across several separate sessions, not just within
|
|
6
|
+
one long context window. This runner loads a dataset file matching the
|
|
7
|
+
published schema (conversation with speaker_a/speaker_b,
|
|
8
|
+
session_<n>/session_<n>_date_time turn lists, and a qa list of
|
|
9
|
+
{question, answer, category, evidence}) and, for each conversation:
|
|
10
|
+
|
|
11
|
+
1. Replays every session's turns into the backend via store(), scoped
|
|
12
|
+
to a session_id derived from conversation_id.
|
|
13
|
+
2. For every QA pair, calls query() with the question.
|
|
14
|
+
3. Grades the returned content against the expected answer using the
|
|
15
|
+
configured LLM judge, and records the LoCoMo category (single-hop,
|
|
16
|
+
multi-hop, temporal, open-domain, adversarial) alongside the verdict
|
|
17
|
+
so results can be broken down by reasoning type, not just aggregated.
|
|
18
|
+
|
|
19
|
+
The bundled tests/fixtures/locomo_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. The real `locomo10.json` (snap-research/locomo) is
|
|
24
|
+
not bundled or auto-fetched here -- it is not memtrust's dataset to
|
|
25
|
+
redistribute -- but once downloaded, `memtrust run --locomo-dataset-path
|
|
26
|
+
<path>` or `run_locomo(..., dataset_path=<path>)` runs against it directly;
|
|
27
|
+
`load_dataset()` below validates the file and raises an actionable error
|
|
28
|
+
if it is missing or does not match the expected schema.
|
|
29
|
+
|
|
30
|
+
## Category 5 / adversarial questions, and headline accuracy
|
|
31
|
+
|
|
32
|
+
The real LoCoMo benchmark's public release documents its QA set as 1,986
|
|
33
|
+
questions total: 1,540 "regular" questions across categories 1-4
|
|
34
|
+
(single-hop, multi-hop, temporal, open-domain) plus a 446-question
|
|
35
|
+
adversarial category 5, deliberately designed to have no answer in the
|
|
36
|
+
conversation (the correct behavior is to recognize the question is
|
|
37
|
+
unanswerable, not to produce a confident wrong answer). An independent
|
|
38
|
+
audit (dial481/locomo-audit, referenced from mempalace/mempalace#29 and
|
|
39
|
+
#875) found this adversarial subset gets folded into vendors' headline
|
|
40
|
+
accuracy numbers without disclosure more often than not, and separately
|
|
41
|
+
catalogued 99 ground-truth labeling errors in the released dataset.
|
|
42
|
+
|
|
43
|
+
`LoCoMoResult.accuracy` intentionally keeps its old meaning -- every
|
|
44
|
+
graded case, all categories included -- so nothing that already reads
|
|
45
|
+
`.accuracy` silently changes behavior. `LoCoMoResult.non_adversarial_accuracy`
|
|
46
|
+
is the new, additive number: the same computation restricted to categories
|
|
47
|
+
other than "adversarial", mirroring the real benchmark's own 1,540/446
|
|
48
|
+
split. Both numbers are always computed and both are surfaced (CLI output,
|
|
49
|
+
JSON report, `accuracy_by_category()`) so a reader sees the distinction
|
|
50
|
+
instead of a single blended figure.
|
|
51
|
+
|
|
52
|
+
## Known-bad ground-truth exclusion
|
|
53
|
+
|
|
54
|
+
`run_locomo()` accepts an optional `exclude_question_ids` parameter -- a
|
|
55
|
+
set of question IDs to exclude from scoring entirely, for callers who have
|
|
56
|
+
a corrected list of known ground-truth errors (e.g. derived from an audit
|
|
57
|
+
like dial481/locomo-audit's 99 flagged cases). This repo does not ship
|
|
58
|
+
dial481's specific ID list -- it was not independently verified against
|
|
59
|
+
his source data -- so the set defaults to empty and every case is scored
|
|
60
|
+
normally until a caller supplies one. See `load_exclude_question_ids()`
|
|
61
|
+
for one way to load such a list from a file, and docs/methodology.md for
|
|
62
|
+
how a real corrected list would be plugged in.
|
|
63
|
+
|
|
64
|
+
The published LoCoMo schema does not include a `question_id` field on
|
|
65
|
+
each QA entry, so this runner derives a stable one per case as
|
|
66
|
+
`f"{conversation_id}::{index_in_conversation}"` unless the dataset
|
|
67
|
+
explicitly provides `qa["question_id"]`. A real, corrected exclusion list
|
|
68
|
+
must use IDs in that same shape (or supply `question_id` on the source
|
|
69
|
+
QA entries) to match.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
from __future__ import annotations
|
|
73
|
+
|
|
74
|
+
import json
|
|
75
|
+
from dataclasses import dataclass, field
|
|
76
|
+
from pathlib import Path
|
|
77
|
+
from typing import Any
|
|
78
|
+
|
|
79
|
+
from memtrust.adapters.base import BackendAPIError, MemoryBackendAdapter
|
|
80
|
+
from memtrust.scoring.llm_judge import JudgeVerdict, LLMJudge
|
|
81
|
+
|
|
82
|
+
DEFAULT_FIXTURE_PATH = (
|
|
83
|
+
Path(__file__).resolve().parents[3] / "tests" / "fixtures" / "locomo_sample.json"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
#: The real LoCoMo benchmark's category label for its adversarial,
|
|
87
|
+
#: deliberately-unanswerable question set (published as "category 5" /
|
|
88
|
+
#: 446 of the dataset's 1,986 total questions). Matched against
|
|
89
|
+
#: LoCoMoCaseResult.category exactly, the same string this runner already
|
|
90
|
+
#: threads through from the dataset's own `qa[*].category` field.
|
|
91
|
+
ADVERSARIAL_CATEGORY = "adversarial"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class LoCoMoCaseResult:
|
|
96
|
+
conversation_id: str
|
|
97
|
+
category: str
|
|
98
|
+
question: str
|
|
99
|
+
expected_answer: str
|
|
100
|
+
actual_answer: str
|
|
101
|
+
verdict: JudgeVerdict
|
|
102
|
+
reasoning: str
|
|
103
|
+
question_id: str = ""
|
|
104
|
+
"""Stable per-case identifier: the dataset's own `qa["question_id"]`
|
|
105
|
+
if present, otherwise f"{conversation_id}::{index_in_conversation}".
|
|
106
|
+
This is the value `exclude_question_ids` matches against."""
|
|
107
|
+
records_empty: bool = False
|
|
108
|
+
"""True when adapter.query() completed without error but returned zero
|
|
109
|
+
records for this question -- see LongMemEvalCaseResult.records_empty
|
|
110
|
+
and ConflictSignal.EMPTY_OR_LOST for the same distinction applied
|
|
111
|
+
here: a silent empty-success from the backend is not the same
|
|
112
|
+
diagnostic as a judge-graded wrong answer."""
|
|
113
|
+
degraded_retrieval: bool = False
|
|
114
|
+
"""True when adapter.query() completed without error and returned at
|
|
115
|
+
least one record, but the backend's own response signaled it
|
|
116
|
+
under-delivered anyway -- see LongMemEvalCaseResult.degraded_retrieval
|
|
117
|
+
and adapters/base.py's RetrievalWarning (confirmed against the real,
|
|
118
|
+
merged MemPalace/mempalace#1005 PR diff). Distinct from records_empty:
|
|
119
|
+
a case can have records_empty=False and degraded_retrieval=True at
|
|
120
|
+
the same time, separating "backend warned us, we surfaced it" from
|
|
121
|
+
"backend silently returned wrong or incomplete facts with no signal
|
|
122
|
+
at all."""
|
|
123
|
+
excluded_ground_truth: bool = False
|
|
124
|
+
"""True when this case's question_id was listed in the caller's
|
|
125
|
+
exclude_question_ids set -- the case is still recorded (so n_cases
|
|
126
|
+
stays honest) but is never queried or judged, and is excluded from
|
|
127
|
+
every accuracy computation the same way a NOT_RUN case is."""
|
|
128
|
+
error: str | None = None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class LoCoMoResult:
|
|
133
|
+
backend_name: str
|
|
134
|
+
dataset_path: str
|
|
135
|
+
case_results: list[LoCoMoCaseResult] = field(default_factory=list)
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def graded_cases(self) -> list[LoCoMoCaseResult]:
|
|
139
|
+
return [
|
|
140
|
+
c for c in self.case_results if c.verdict != JudgeVerdict.NOT_RUN and c.error is None
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
@staticmethod
|
|
144
|
+
def _accuracy_over(cases: list[LoCoMoCaseResult]) -> float | None:
|
|
145
|
+
if not cases:
|
|
146
|
+
return None
|
|
147
|
+
correct = sum(1 for c in cases if c.verdict == JudgeVerdict.CORRECT)
|
|
148
|
+
return correct / len(cases)
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def accuracy(self) -> float | None:
|
|
152
|
+
"""Headline accuracy across every graded case, all categories
|
|
153
|
+
included -- adversarial (category 5) questions included, the
|
|
154
|
+
same meaning this property has always had. See
|
|
155
|
+
`non_adversarial_accuracy` for the number that excludes them."""
|
|
156
|
+
return self._accuracy_over(self.graded_cases)
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def non_adversarial_cases(self) -> list[LoCoMoCaseResult]:
|
|
160
|
+
"""Graded cases outside the adversarial category -- the subset
|
|
161
|
+
the real LoCoMo benchmark's own 1,540-question figure covers."""
|
|
162
|
+
return [c for c in self.graded_cases if c.category != ADVERSARIAL_CATEGORY]
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def non_adversarial_accuracy(self) -> float | None:
|
|
166
|
+
"""Accuracy computed only over non-adversarial categories
|
|
167
|
+
(single-hop, multi-hop, temporal, open-domain), excluding the
|
|
168
|
+
adversarial/category-5 subset the same way the real LoCoMo
|
|
169
|
+
benchmark's 1,540/446 split does. This is the number that should
|
|
170
|
+
be quoted as headline accuracy when adversarial questions are
|
|
171
|
+
present but a like-for-like comparison against the benchmark's
|
|
172
|
+
non-adversarial figure is wanted -- see docs/methodology.md."""
|
|
173
|
+
return self._accuracy_over(self.non_adversarial_cases)
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def n_records_empty(self) -> int:
|
|
177
|
+
"""Count of cases where the backend call succeeded but returned
|
|
178
|
+
zero records -- see LoCoMoCaseResult.records_empty."""
|
|
179
|
+
return sum(1 for c in self.case_results if c.records_empty)
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def n_degraded_retrieval(self) -> int:
|
|
183
|
+
"""Count of cases where the backend's own response signaled
|
|
184
|
+
under-delivered (but non-empty) retrieval -- see
|
|
185
|
+
LoCoMoCaseResult.degraded_retrieval."""
|
|
186
|
+
return sum(1 for c in self.case_results if c.degraded_retrieval)
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def n_excluded_ground_truth(self) -> int:
|
|
190
|
+
"""Count of cases excluded from scoring via exclude_question_ids
|
|
191
|
+
-- see LoCoMoCaseResult.excluded_ground_truth."""
|
|
192
|
+
return sum(1 for c in self.case_results if c.excluded_ground_truth)
|
|
193
|
+
|
|
194
|
+
def accuracy_by_category(self) -> dict[str, float | None]:
|
|
195
|
+
categories = {c.category for c in self.case_results}
|
|
196
|
+
out: dict[str, float | None] = {}
|
|
197
|
+
for cat in categories:
|
|
198
|
+
graded = [c for c in self.graded_cases if c.category == cat]
|
|
199
|
+
out[cat] = self._accuracy_over(graded)
|
|
200
|
+
return out
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def load_dataset(path: Path | str = DEFAULT_FIXTURE_PATH) -> list[dict[str, Any]]:
|
|
204
|
+
"""Load a LoCoMo-schema dataset file: either the bundled synthetic
|
|
205
|
+
fixture (the default) or a real `locomo10.json` downloaded from
|
|
206
|
+
snap-research/locomo -- see docs/methodology.md's "LoCoMo" section
|
|
207
|
+
for the download link and schema, and `memtrust run
|
|
208
|
+
--locomo-dataset-path` for the CLI entry point that plugs a real
|
|
209
|
+
download in without writing custom Python.
|
|
210
|
+
|
|
211
|
+
Raises `ValueError` with an actionable message (not a bare
|
|
212
|
+
`FileNotFoundError`/`JSONDecodeError`/`KeyError`) when the file is
|
|
213
|
+
missing, is not valid JSON, or does not match the expected
|
|
214
|
+
top-level `{"conversations": [...]}` shape -- schema mismatches are
|
|
215
|
+
the most common real-world failure mode when pointing this at a
|
|
216
|
+
hand-downloaded file, and the previous bare exceptions gave no clue
|
|
217
|
+
what shape was actually expected."""
|
|
218
|
+
resolved = Path(path)
|
|
219
|
+
if not resolved.exists():
|
|
220
|
+
raise ValueError(
|
|
221
|
+
f"LoCoMo dataset file not found: {resolved}. Download locomo10.json from "
|
|
222
|
+
"https://github.com/snap-research/locomo and pass its path via "
|
|
223
|
+
"`memtrust run --locomo-dataset-path` (CLI) or "
|
|
224
|
+
"`run_locomo(..., dataset_path=...)` (Python) -- see docs/methodology.md's "
|
|
225
|
+
'"LoCoMo" section for the download link and expected schema.'
|
|
226
|
+
)
|
|
227
|
+
try:
|
|
228
|
+
data = json.loads(resolved.read_text())
|
|
229
|
+
except json.JSONDecodeError as exc:
|
|
230
|
+
raise ValueError(
|
|
231
|
+
f"LoCoMo dataset file at {resolved} is not valid JSON ({exc}). Expected the "
|
|
232
|
+
'real locomo10.json\'s top-level {"conversations": [...]} shape -- see '
|
|
233
|
+
'docs/methodology.md\'s "LoCoMo" section.'
|
|
234
|
+
) from exc
|
|
235
|
+
if not isinstance(data, dict) or "conversations" not in data:
|
|
236
|
+
raise ValueError(
|
|
237
|
+
f"LoCoMo dataset file at {resolved} is missing the expected top-level "
|
|
238
|
+
'"conversations" key. This loader expects the real locomo10.json shape: '
|
|
239
|
+
'{"conversations": [{"conversation_id": ..., "session_1": [...], '
|
|
240
|
+
'"qa": [...]}, ...]} -- see docs/methodology.md\'s "LoCoMo" section for the '
|
|
241
|
+
"full schema."
|
|
242
|
+
)
|
|
243
|
+
conversations = data["conversations"]
|
|
244
|
+
if not isinstance(conversations, list):
|
|
245
|
+
raise ValueError(
|
|
246
|
+
f'LoCoMo dataset file at {resolved}: "conversations" must be a list, got '
|
|
247
|
+
f"{type(conversations).__name__}."
|
|
248
|
+
)
|
|
249
|
+
return conversations
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _iter_sessions(conversation: dict[str, Any]) -> list[list[dict[str, Any]]]:
|
|
253
|
+
sessions = []
|
|
254
|
+
n = 1
|
|
255
|
+
while f"session_{n}" in conversation:
|
|
256
|
+
sessions.append(conversation[f"session_{n}"])
|
|
257
|
+
n += 1
|
|
258
|
+
return sessions
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def load_exclude_question_ids(path: Path | str) -> set[str]:
|
|
262
|
+
"""Load a known-bad-ground-truth question-id exclusion list from a
|
|
263
|
+
file, for use as `run_locomo(..., exclude_question_ids=...)`.
|
|
264
|
+
|
|
265
|
+
Accepts either a JSON file containing a top-level list of ID strings
|
|
266
|
+
(e.g. `["mt-locomo-001::3", "mt-locomo-004::0"]`) or a plain text
|
|
267
|
+
file with one question ID per line (blank lines and lines starting
|
|
268
|
+
with "#" are ignored, so a maintainer can annotate why an ID is
|
|
269
|
+
excluded). This is the mechanism a real corrected list -- e.g. one
|
|
270
|
+
derived from an audit like dial481/locomo-audit's 99 flagged
|
|
271
|
+
ground-truth errors -- would be plugged in through; no such list
|
|
272
|
+
ships with this repo (see module docstring)."""
|
|
273
|
+
text = Path(path).read_text()
|
|
274
|
+
stripped = text.strip()
|
|
275
|
+
if stripped.startswith("["):
|
|
276
|
+
ids = json.loads(stripped)
|
|
277
|
+
return {str(i) for i in ids}
|
|
278
|
+
return {
|
|
279
|
+
line.strip()
|
|
280
|
+
for line in text.splitlines()
|
|
281
|
+
if line.strip() and not line.strip().startswith("#")
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def run_locomo(
|
|
286
|
+
adapter: MemoryBackendAdapter,
|
|
287
|
+
judge: LLMJudge,
|
|
288
|
+
dataset_path: Path | str = DEFAULT_FIXTURE_PATH,
|
|
289
|
+
exclude_question_ids: set[str] | None = None,
|
|
290
|
+
) -> LoCoMoResult:
|
|
291
|
+
"""Run the LoCoMo eval.
|
|
292
|
+
|
|
293
|
+
`exclude_question_ids` is an optional set of question IDs (matching
|
|
294
|
+
LoCoMoCaseResult.question_id -- see module docstring for the ID
|
|
295
|
+
shape) to skip scoring for entirely: no adapter.query() or judge
|
|
296
|
+
call is made, and the case is recorded with
|
|
297
|
+
excluded_ground_truth=True so it never counts toward `accuracy`,
|
|
298
|
+
`non_adversarial_accuracy`, or `accuracy_by_category()`, the same
|
|
299
|
+
way a NOT_RUN case doesn't. Use this to score a real run against a
|
|
300
|
+
corrected ground truth once a caller has a verified list of known
|
|
301
|
+
ground-truth errors (see `load_exclude_question_ids()`)."""
|
|
302
|
+
exclude_ids = exclude_question_ids or set()
|
|
303
|
+
conversations = load_dataset(dataset_path)
|
|
304
|
+
result = LoCoMoResult(backend_name=adapter.name, dataset_path=str(dataset_path))
|
|
305
|
+
|
|
306
|
+
for conversation in conversations:
|
|
307
|
+
conv_id = conversation["conversation_id"]
|
|
308
|
+
session_id = f"locomo-{conv_id}"
|
|
309
|
+
try:
|
|
310
|
+
for session in _iter_sessions(conversation):
|
|
311
|
+
for turn in session:
|
|
312
|
+
adapter.store(session_id, f"{turn['speaker']}: {turn['text']}")
|
|
313
|
+
except BackendAPIError as exc:
|
|
314
|
+
for idx, qa in enumerate(conversation.get("qa", [])):
|
|
315
|
+
question_id = str(qa.get("question_id") or f"{conv_id}::{idx}")
|
|
316
|
+
result.case_results.append(
|
|
317
|
+
LoCoMoCaseResult(
|
|
318
|
+
conversation_id=conv_id,
|
|
319
|
+
category=qa.get("category", "unknown"),
|
|
320
|
+
question=qa["question"],
|
|
321
|
+
expected_answer=qa["answer"],
|
|
322
|
+
actual_answer="",
|
|
323
|
+
verdict=JudgeVerdict.NOT_RUN,
|
|
324
|
+
reasoning="",
|
|
325
|
+
question_id=question_id,
|
|
326
|
+
excluded_ground_truth=question_id in exclude_ids,
|
|
327
|
+
error=str(exc),
|
|
328
|
+
)
|
|
329
|
+
)
|
|
330
|
+
continue
|
|
331
|
+
|
|
332
|
+
for idx, qa in enumerate(conversation.get("qa", [])):
|
|
333
|
+
question_id = str(qa.get("question_id") or f"{conv_id}::{idx}")
|
|
334
|
+
if question_id in exclude_ids:
|
|
335
|
+
result.case_results.append(
|
|
336
|
+
LoCoMoCaseResult(
|
|
337
|
+
conversation_id=conv_id,
|
|
338
|
+
category=qa.get("category", "unknown"),
|
|
339
|
+
question=qa["question"],
|
|
340
|
+
expected_answer=qa["answer"],
|
|
341
|
+
actual_answer="",
|
|
342
|
+
verdict=JudgeVerdict.NOT_RUN,
|
|
343
|
+
reasoning=(
|
|
344
|
+
"Excluded via exclude_question_ids: known ground-truth "
|
|
345
|
+
"error in the source dataset, not scored."
|
|
346
|
+
),
|
|
347
|
+
question_id=question_id,
|
|
348
|
+
excluded_ground_truth=True,
|
|
349
|
+
)
|
|
350
|
+
)
|
|
351
|
+
continue
|
|
352
|
+
try:
|
|
353
|
+
query_result = adapter.query(session_id, qa["question"], top_k=5)
|
|
354
|
+
except BackendAPIError as exc:
|
|
355
|
+
result.case_results.append(
|
|
356
|
+
LoCoMoCaseResult(
|
|
357
|
+
conversation_id=conv_id,
|
|
358
|
+
category=qa.get("category", "unknown"),
|
|
359
|
+
question=qa["question"],
|
|
360
|
+
expected_answer=qa["answer"],
|
|
361
|
+
actual_answer="",
|
|
362
|
+
verdict=JudgeVerdict.NOT_RUN,
|
|
363
|
+
reasoning="",
|
|
364
|
+
question_id=question_id,
|
|
365
|
+
error=str(exc),
|
|
366
|
+
)
|
|
367
|
+
)
|
|
368
|
+
continue
|
|
369
|
+
|
|
370
|
+
# actual_answer is the raw retrieved-record content, judged directly -- there
|
|
371
|
+
# is no answer-generation step here. This is "retrieval-graded accuracy," not
|
|
372
|
+
# the official LoCoMo leaderboard's generate+judge QA-accuracy measurement. See
|
|
373
|
+
# docs/methodology.md's "Retrieval-graded accuracy vs. generated-answer accuracy"
|
|
374
|
+
# section before comparing this metric to a leaderboard figure.
|
|
375
|
+
actual_answer = " ".join(r.content for r in query_result.records)
|
|
376
|
+
judge_result = judge.judge_answer(qa["question"], qa["answer"], actual_answer)
|
|
377
|
+
result.case_results.append(
|
|
378
|
+
LoCoMoCaseResult(
|
|
379
|
+
conversation_id=conv_id,
|
|
380
|
+
category=qa.get("category", "unknown"),
|
|
381
|
+
question=qa["question"],
|
|
382
|
+
expected_answer=qa["answer"],
|
|
383
|
+
actual_answer=actual_answer,
|
|
384
|
+
verdict=judge_result.verdict,
|
|
385
|
+
reasoning=judge_result.reasoning,
|
|
386
|
+
question_id=question_id,
|
|
387
|
+
records_empty=not query_result.records,
|
|
388
|
+
degraded_retrieval=query_result.degraded_retrieval is not None,
|
|
389
|
+
)
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
return result
|