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