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,371 @@
1
+ """MemTrust's filter-injection / access-control-bypass eval.
2
+
3
+ None of the other evals in this package submit an adversarial, caller-
4
+ controlled filter value directly to a backend's underlying vector-store
5
+ filter-query-building layer -- they all exercise the normal, harness-
6
+ constructed `{"user_id": session_id}` filter every adapter's `query()`
7
+ sends. This eval closes that specific gap: it asks whether a backend's
8
+ filter-building code *validates* a filter value's type before embedding it
9
+ into a query, or trusts it unconditionally.
10
+
11
+ Motivating case: mem0ai/mem0#5980 (merged 2026-07-02, GitHub user
12
+ HrushiYadav; closes #5976; part of a coordinated 5-backend
13
+ injection-prevention series covering elasticsearch/neptune/azure/
14
+ opensearch/databricks -- this eval only concerns the elasticsearch fix).
15
+ `ElasticsearchVectorStore` (`mem0/vector_stores/elasticsearch.py` in the
16
+ installed package) previously embedded caller-supplied filter values
17
+ directly into Elasticsearch `term` queries -- `{"term": {f"metadata.{key}":
18
+ value}}` -- with no check that `value` was actually a scalar. A dict/list
19
+ value (e.g. `{"user_id": {"$ne": ""}}`, reproducing Elasticsearch's own
20
+ query-DSL operator syntax) could therefore inject arbitrary query
21
+ behavior into what was meant to be a single-user scope filter, enabling
22
+ access-control bypass / cross-user memory enumeration -- a caller who
23
+ should only ever see their own `user_id`'s memories could construct a
24
+ filter value that matches every user's.
25
+
26
+ **Confirmed fixed in the installed `mem0ai==2.0.12` package, by reading
27
+ `mem0/vector_stores/elasticsearch.py` source directly, not by trusting
28
+ PR #5980's "merged" status.** The installed file defines a module-level
29
+ `_validate_filter(key, value)` helper (a `_SAFE_FILTER_KEY` regex
30
+ allowlist for the key, `isinstance(value, (str, int, float, bool))` for
31
+ the value) and calls it immediately before every `{"term": ...}` clause is
32
+ built, in all three places `ElasticsearchDB` constructs one: `search()`
33
+ (the KNN pre-filter path), `keyword_search()` (the BM25 path), and
34
+ `list()`. A dict/list-valued filter fails the `isinstance` check and
35
+ raises `ValueError` before a query is ever built or sent to the
36
+ Elasticsearch client. `gh pr view 5980 --repo mem0ai/mem0` shows the real,
37
+ merged PR description matches this line for line (see
38
+ mem0_direct_adapter.py's module docstring, "Elasticsearch support"
39
+ section, for the full citation and confirmation detail) -- this is not a
40
+ case where the issue says "merged" but the pinned package version
41
+ predates the fix.
42
+
43
+ **What this eval actually does.** It calls
44
+ `MemoryBackendAdapter.probe_raw_filter(filters)` -- a new optional
45
+ capability (see `adapters/base.py`'s `supports_raw_filter_probe` and
46
+ `RawFilterProbeResult`) that submits a filter dict directly to a backend's
47
+ underlying vector-store filter-building layer, bypassing the normal
48
+ session-scoped `query()` path entirely (that path always sends a
49
+ harness-constructed `{"user_id": session_id}` filter and gives a caller no
50
+ way to submit an adversarial value through it). Only `Mem0DirectAdapter`
51
+ implements this today, because it is the one adapter in this repo that
52
+ holds a direct, in-process handle to the vendor library's own
53
+ `vector_store` object rather than talking to a backend over HTTP -- see
54
+ `mem0_direct_adapter.py::probe_raw_filter()`. Every other adapter reports
55
+ `supports_raw_filter_probe = False` and this eval records
56
+ `FilterInjectionSignal.NOT_APPLICABLE` (skipped) for it, same convention
57
+ `evals/crash_recovery.py` and `evals/resource_sync_safety.py` already
58
+ establish for their own opt-in capability flags.
59
+
60
+ **Honest scope of what this eval can and cannot prove.** This eval's
61
+ fixture cases (`tests/fixtures/filter_injection_cases.json`) are
62
+ submitted to whichever vector store `Mem0DirectAdapter` is configured
63
+ against with a real, mocked-at-the-wire-client `mem0.vector_stores.*`
64
+ class in the test suite (see `tests/test_mem0_direct_adapter.py`), so a
65
+ run against `vector_store_provider="elasticsearch"` genuinely exercises
66
+ the real, installed `mem0.vector_stores.elasticsearch.ElasticsearchDB`
67
+ class -- proving what this module's docstring claims about #5980. This
68
+ eval has never been run against a live Elasticsearch cluster (or a live
69
+ Redis/Valkey/Qdrant server) in this environment -- every test mocks the
70
+ vendor SDK/wire-client boundary, the same convention every other adapter
71
+ in this repo follows (see `docs/methodology.md`). Running this eval
72
+ against `vector_store_provider="redis"/"valkey"/"qdrant"` is also
73
+ possible (the probe itself is provider-agnostic -- see
74
+ `Mem0DirectAdapter.supports_raw_filter_probe`'s docstring) but this build
75
+ did not inspect whether those three installed vector-store classes
76
+ validate filter values the same way Elasticsearch's fixed code now does;
77
+ a result for one of those providers should be read as "what the installed
78
+ package's `list(filters=...)` actually did when given this value," not as
79
+ a claim this build confirmed against those providers' source the way it
80
+ did for Elasticsearch.
81
+
82
+ Classification produces one of:
83
+
84
+ * FILTER_REJECTED -- the raw filter-probe call raised before
85
+ completing. For a malicious case (a
86
+ dict/list-valued filter reproducing the
87
+ #5980 shape) this is the safe, correct
88
+ outcome. For a benign, scalar-valued control
89
+ case, this same signal instead means the
90
+ backend incorrectly rejected a legitimate
91
+ filter -- see
92
+ FilterInjectionEvalResult.benign_false_positive_rate,
93
+ which is what tells the two apart in the
94
+ aggregate numbers (the signal alone,
95
+ consistent with FilterInjectionSignal's own
96
+ docstring, is a raw pass/fail observation,
97
+ not a verdict -- a verdict requires the
98
+ case's `malicious` ground truth too).
99
+ * FILTER_ACCEPTED_SAFELY -- the raw filter-probe call for a *benign*
100
+ case completed without raising -- the
101
+ expected, correct outcome for a legitimate
102
+ filter.
103
+ * INJECTION_SUCCEEDED -- the raw filter-probe call for a *malicious*
104
+ case completed without raising: the
105
+ vector-store's filter-building layer
106
+ accepted a non-scalar value with no type
107
+ validation, exactly the vulnerable pre-fix
108
+ #5980 code path. Never observed against the
109
+ installed, real `ElasticsearchDB` in this
110
+ build's tests (see module docstring above --
111
+ confirmed fixed); reserved for a
112
+ provider/version whose filter-building layer
113
+ has no `_validate_filter()` equivalent.
114
+ * NOT_APPLICABLE -- either the adapter has no raw-filter-probe
115
+ capability at all
116
+ (`supports_raw_filter_probe` is False -- the
117
+ eval is skipped, not run per-case), or the
118
+ probe call raised for a reason unrelated to
119
+ filter-value validation (e.g. a
120
+ construction-time config rejection -- see
121
+ `RawFilterProbeResult`'s docstring) before
122
+ any filter value could be meaningfully
123
+ classified.
124
+
125
+ Design principle (same as evals/extraction_quality.py's
126
+ `classify_case`-equivalent functions): a raw pass/fail observation
127
+ (`RawFilterProbeResult.accepted`) is never itself treated as "safe" or
128
+ "vulnerable" -- classification always cross-references it against the
129
+ case's own ground truth (`FilterInjectionCase.malicious`), the same way
130
+ `ExtractionQualitySignal` cross-references `should_be_stored` rather than
131
+ treating "retrievable" as inherently good or bad.
132
+ """
133
+
134
+ from __future__ import annotations
135
+
136
+ import json
137
+ from dataclasses import dataclass, field
138
+ from enum import StrEnum
139
+ from pathlib import Path
140
+ from typing import Any
141
+
142
+ from memtrust.adapters.base import MemoryBackendAdapter, RawFilterProbeResult
143
+
144
+ DEFAULT_FIXTURE_PATH = (
145
+ Path(__file__).resolve().parents[3] / "tests" / "fixtures" / "filter_injection_cases.json"
146
+ )
147
+
148
+
149
+ class FilterInjectionSignal(StrEnum):
150
+ """How a single filter value fared when submitted directly to a
151
+ backend's vector-store filter-building layer. See this module's
152
+ docstring for the full classification write-up and the honest
153
+ "signal alone is not a verdict" caveat -- a verdict requires cross-
154
+ referencing this against the case's `malicious` ground truth.
155
+ """
156
+
157
+ FILTER_REJECTED = "filter_rejected"
158
+ """The raw filter-probe call raised before completing -- the filter
159
+ value was rejected outright by the backend's own filter-building code
160
+ (or the adapter's construction/connection layer; see NOT_APPLICABLE
161
+ below for when that distinction matters). Assigned regardless of
162
+ whether the case was malicious or benign -- see
163
+ FilterInjectionEvalResult.malicious_rejected_rate (good outcome) vs.
164
+ benign_false_positive_rate (bad outcome) for how the two are told
165
+ apart."""
166
+
167
+ FILTER_ACCEPTED_SAFELY = "filter_accepted_safely"
168
+ """The raw filter-probe call completed without raising for a *benign*,
169
+ scalar-valued control case -- the expected, correct outcome for a
170
+ legitimate filter. Only ever assigned to a case whose `malicious` is
171
+ False; a malicious case that is accepted without raising is classified
172
+ INJECTION_SUCCEEDED instead (see below), never this value, so a reader
173
+ scanning for this signal never has to cross-reference ground truth to
174
+ know whether "accepted" was actually safe."""
175
+
176
+ INJECTION_SUCCEEDED = "injection_succeeded"
177
+ """The raw filter-probe call for a *malicious*, dict/list-valued
178
+ filter payload (the exact mem0ai/mem0#5980 shape) completed without
179
+ raising -- the vector store's filter-building layer accepted a
180
+ non-scalar value with no type validation, exactly the vulnerable
181
+ pre-fix code path #5980 describes. This is the headline failure
182
+ signal this eval exists to catch; see
183
+ FilterInjectionEvalResult.injection_succeeded_rate."""
184
+
185
+ NOT_APPLICABLE = "not_applicable"
186
+ """Either the adapter has no raw-filter-probe capability at all
187
+ (MemoryBackendAdapter.supports_raw_filter_probe is False -- the eval
188
+ is skipped entirely, not run per-case), or the probe raised for a
189
+ reason unrelated to filter-value validation (e.g. a construction-time
190
+ config rejection such as a missing embedding-dimension config or
191
+ missing Elasticsearch credential -- see RawFilterProbeResult's
192
+ docstring) before any filter value could be meaningfully classified.
193
+ Recorded explicitly, never silently dropped from the results table,
194
+ same convention as every other NOT_APPLICABLE signal in this repo."""
195
+
196
+
197
+ @dataclass
198
+ class FilterInjectionCase:
199
+ case_id: str
200
+ malicious: bool
201
+ filter_key: str
202
+ filter_value: object
203
+ description: str = ""
204
+
205
+
206
+ @dataclass
207
+ class FilterInjectionCaseResult:
208
+ case: FilterInjectionCase
209
+ signal: FilterInjectionSignal
210
+ probe_accepted: bool | None
211
+ """Mirrors RawFilterProbeResult.accepted for this case, or None if the
212
+ probe was never attempted (adapter has no capability, see `signal`
213
+ NOT_APPLICABLE)."""
214
+ error: str | None = None
215
+
216
+
217
+ @dataclass
218
+ class FilterInjectionEvalResult:
219
+ backend_name: str
220
+ dataset_path: str
221
+ case_results: list[FilterInjectionCaseResult] = field(default_factory=list)
222
+ skipped: bool = False
223
+ skip_reason: str | None = None
224
+
225
+ @property
226
+ def malicious_case_results(self) -> list[FilterInjectionCaseResult]:
227
+ return [c for c in self.case_results if c.case.malicious]
228
+
229
+ @property
230
+ def benign_case_results(self) -> list[FilterInjectionCaseResult]:
231
+ return [c for c in self.case_results if not c.case.malicious]
232
+
233
+ def _fraction(
234
+ self, case_results: list[FilterInjectionCaseResult], signal: FilterInjectionSignal
235
+ ) -> float | None:
236
+ if not case_results:
237
+ return None
238
+ matching = sum(1 for c in case_results if c.signal == signal)
239
+ return matching / len(case_results)
240
+
241
+ @property
242
+ def injection_succeeded_rate(self) -> float | None:
243
+ """Fraction of malicious cases where the malicious filter value was
244
+ accepted without rejection -- the headline metric this eval exists
245
+ to catch, the exact mem0ai/mem0#5980 shape. `None` when there are
246
+ no malicious cases to score. `0.0` against the real, installed
247
+ Elasticsearch vector store (see module docstring: confirmed
248
+ fixed)."""
249
+ return self._fraction(
250
+ self.malicious_case_results, FilterInjectionSignal.INJECTION_SUCCEEDED
251
+ )
252
+
253
+ @property
254
+ def malicious_rejected_rate(self) -> float | None:
255
+ """Fraction of malicious cases correctly rejected -- the good
256
+ outcome, and the complement of injection_succeeded_rate among
257
+ scoreable malicious cases (they do not have to sum to 1.0 if any
258
+ malicious case landed on NOT_APPLICABLE instead)."""
259
+ return self._fraction(self.malicious_case_results, FilterInjectionSignal.FILTER_REJECTED)
260
+
261
+ @property
262
+ def benign_accepted_rate(self) -> float | None:
263
+ """Fraction of benign, scalar-valued control cases correctly
264
+ accepted -- the good outcome for a legitimate filter."""
265
+ return self._fraction(
266
+ self.benign_case_results, FilterInjectionSignal.FILTER_ACCEPTED_SAFELY
267
+ )
268
+
269
+ @property
270
+ def benign_false_positive_rate(self) -> float | None:
271
+ """Fraction of benign, scalar-valued control cases the backend
272
+ incorrectly rejected. Not itself a security issue -- the opposite
273
+ failure mode -- but a signal a fix (or this eval's own probe
274
+ mechanics) is overly strict, since a real caller with a legitimate
275
+ scalar filter should never be rejected."""
276
+ return self._fraction(self.benign_case_results, FilterInjectionSignal.FILTER_REJECTED)
277
+
278
+
279
+ def load_dataset(path: Path | str = DEFAULT_FIXTURE_PATH) -> list[FilterInjectionCase]:
280
+ data = json.loads(Path(path).read_text())
281
+ cases: list[dict[str, Any]] = data["cases"]
282
+ return [
283
+ FilterInjectionCase(
284
+ case_id=c["case_id"],
285
+ malicious=c["malicious"],
286
+ filter_key=c["filter_key"],
287
+ filter_value=c["filter_value"],
288
+ description=c.get("description", ""),
289
+ )
290
+ for c in cases
291
+ ]
292
+
293
+
294
+ def classify_filter_injection_case(
295
+ case: FilterInjectionCase, probe: RawFilterProbeResult
296
+ ) -> FilterInjectionSignal:
297
+ """Classify a single case's outcome, cross-referencing the raw
298
+ accept/reject observation against the case's own `malicious` ground
299
+ truth -- never treating "accepted" or "rejected" alone as inherently
300
+ good or bad. See module docstring for the full write-up.
301
+
302
+ Checks `probe.applicable` first: a probe that never reached the
303
+ vector store's filter-building layer at all (a construction-time
304
+ config rejection, unrelated to filter validation) is NOT_APPLICABLE
305
+ regardless of `malicious` -- it would otherwise be misclassified as a
306
+ "correct rejection" for a malicious case or a "false positive" for a
307
+ benign one, neither of which is true since the filter value itself was
308
+ never actually evaluated.
309
+ """
310
+ if not probe.applicable:
311
+ return FilterInjectionSignal.NOT_APPLICABLE
312
+ if case.malicious:
313
+ return (
314
+ FilterInjectionSignal.INJECTION_SUCCEEDED
315
+ if probe.accepted
316
+ else FilterInjectionSignal.FILTER_REJECTED
317
+ )
318
+ return (
319
+ FilterInjectionSignal.FILTER_ACCEPTED_SAFELY
320
+ if probe.accepted
321
+ else FilterInjectionSignal.FILTER_REJECTED
322
+ )
323
+
324
+
325
+ def run_filter_injection_eval(
326
+ adapter: MemoryBackendAdapter,
327
+ dataset_path: Path | str = DEFAULT_FIXTURE_PATH,
328
+ ) -> FilterInjectionEvalResult:
329
+ cases = load_dataset(dataset_path)
330
+ result = FilterInjectionEvalResult(backend_name=adapter.name, dataset_path=str(dataset_path))
331
+
332
+ if not adapter.supports_raw_filter_probe:
333
+ result.skipped = True
334
+ result.skip_reason = (
335
+ f"{adapter.name} does not support a raw filter probe "
336
+ "(supports_raw_filter_probe=False) -- skipped, not run. Only an adapter that "
337
+ "holds a direct, in-process handle to a vendor library's own vector-store "
338
+ "filter-building layer can genuinely submit an adversarial filter value "
339
+ "outside its normal query() path; see adapters/base.py's "
340
+ "supports_raw_filter_probe and evals/filter_injection.py's module docstring."
341
+ )
342
+ return result
343
+
344
+ for case in cases:
345
+ try:
346
+ probe = adapter.probe_raw_filter({case.filter_key: case.filter_value})
347
+ except NotImplementedError as exc:
348
+ # Should not happen given the supports_raw_filter_probe check
349
+ # above, but guarded the same defensive way every other
350
+ # optional-capability eval in this repo guards its own call.
351
+ result.case_results.append(
352
+ FilterInjectionCaseResult(
353
+ case=case,
354
+ signal=FilterInjectionSignal.NOT_APPLICABLE,
355
+ probe_accepted=None,
356
+ error=str(exc),
357
+ )
358
+ )
359
+ continue
360
+
361
+ signal = classify_filter_injection_case(case, probe)
362
+ result.case_results.append(
363
+ FilterInjectionCaseResult(
364
+ case=case,
365
+ signal=signal,
366
+ probe_accepted=probe.accepted,
367
+ error=probe.error,
368
+ )
369
+ )
370
+
371
+ return result
@@ -0,0 +1,189 @@
1
+ """memtrust's non-Latin-script i18n retrieval-degradation eval for
2
+ `Mem0DirectAdapter`.
3
+
4
+ Motivating case: wangjiawei-vegetable (rank 147, mem0ai/mem0#4884, open,
5
+ merge-ready companion PR #4943 as of this build). mem0 v3's hybrid
6
+ retrieval pipeline (semantic + BM25 keyword + entity-based boosting)
7
+ hardcodes spaCy's English model `en_core_web_sm` for both BM25
8
+ lemmatization (`mem0/utils/lemmatization.py::lemmatize_for_bm25`) and
9
+ entity extraction (`mem0/utils/spacy_models.py::get_nlp_full`/
10
+ `get_nlp_lemma`) -- confirmed by reading the installed `mem0ai==2.0.12`
11
+ package's own `mem0/utils/spacy_models.py` directly, both loader
12
+ functions call `spacy.load("en_core_web_sm", ...)` unconditionally, with
13
+ no language parameter anywhere in the module. For non-Latin-script text
14
+ (Chinese, Japanese, Arabic, Thai, Hindi, ...), the English pipeline's
15
+ tokenization/lemmatization does not reliably produce keyword/entity
16
+ signals that overlap between query time and store time, so
17
+ `mem0/memory/main.py::_search_vector_store()`'s own `bm25_scores`/
18
+ `entity_boosts` dicts can come back empty for a query where they would
19
+ fire for equivalent English text -- and `mem0/utils/scoring.py::
20
+ score_and_rank()`'s `has_bm25 = bool(bm25_scores)`/`has_entity =
21
+ bool(entity_boosts)` gates mean the combined score silently falls back to
22
+ semantic-only weighting. No exception is raised and nothing in the
23
+ normal (non-`explain`) response indicates this happened -- exactly the
24
+ "silently degrades... users see no error" shape the issue itself
25
+ describes.
26
+
27
+ None of memtrust's other four signal taxonomies (`ExtractionQualitySignal`
28
+ /`RankingSignal`/`EmbeddingDriftSignal`/`CorruptionSignal`) classify this:
29
+ all four concern content/order/drift/write-path corruption, never whether
30
+ a language-dependent retrieval-pipeline STAGE ran at all for a given
31
+ query. See `LanguageDegradationSignal` in `adapters/base.py` for the full
32
+ taxonomy this eval scores against, and `mem0_direct_adapter.py`'s
33
+ `query(explain=True)` for exactly how each result's real, installed
34
+ `score_details` breakdown (`bm25_score`, `entity_boost`) is read -- this
35
+ eval never guesses degradation from content alone, it reads mem0's own
36
+ per-result diagnostic fields.
37
+
38
+ Honest scope: this eval is `Mem0DirectAdapter`-specific (only that
39
+ adapter holds a direct, in-process handle to `mem0.Memory` and can pass
40
+ `explain=True` through to it) -- REST-facing adapters
41
+ (`Mem0Adapter`/`Mem0SelfHostedAdapter`) have no equivalent parameter on
42
+ their vendor HTTP surface to make this observable at all. This eval and
43
+ its fixture (`tests/fixtures/language_degradation_cases.json`) prove the
44
+ classification logic is correct given a `score_details` response shape;
45
+ it has not been run against a live mem0 instance with real spaCy/embedder
46
+ credentials configured in this environment -- same "structurally capable,
47
+ not live-run" caveat every other eval in this package that depends on a
48
+ real LLM/embedding backend already carries.
49
+ """
50
+
51
+ from __future__ import annotations
52
+
53
+ import json
54
+ from dataclasses import dataclass, field
55
+ from pathlib import Path
56
+ from typing import Any
57
+
58
+ from memtrust.adapters.base import BackendAPIError, LanguageDegradationSignal
59
+ from memtrust.adapters.mem0_direct_adapter import Mem0DirectAdapter
60
+
61
+ DEFAULT_FIXTURE_PATH = (
62
+ Path(__file__).resolve().parents[3] / "tests" / "fixtures" / "language_degradation_cases.json"
63
+ )
64
+
65
+
66
+ @dataclass
67
+ class LanguageDegradationCase:
68
+ case_id: str
69
+ session_id: str
70
+ script: str
71
+ """Which non-Latin script family this case models -- e.g. "chinese",
72
+ "japanese", "arabic", "thai", "hindi". Used to compute a per-script
73
+ degradation rate, not just one aggregate number, since the issue's own
74
+ root cause (an English-only spaCy pipeline) could plausibly affect
75
+ different scripts differently depending on how spaCy's English
76
+ tokenizer happens to segment each one."""
77
+ content: str
78
+ query: str
79
+
80
+
81
+ @dataclass
82
+ class LanguageDegradationCaseResult:
83
+ case: LanguageDegradationCase
84
+ signal: LanguageDegradationSignal
85
+ error: str | None = None
86
+
87
+
88
+ @dataclass
89
+ class LanguageDegradationEvalResult:
90
+ backend_name: str
91
+ dataset_path: str
92
+ case_results: list[LanguageDegradationCaseResult] = field(default_factory=list)
93
+
94
+ @property
95
+ def scored_cases(self) -> list[LanguageDegradationCaseResult]:
96
+ return [c for c in self.case_results if c.error is None]
97
+
98
+ def _fraction(self, signal: LanguageDegradationSignal) -> float | None:
99
+ scored = self.scored_cases
100
+ if not scored:
101
+ return None
102
+ matching = sum(1 for c in scored if c.signal == signal)
103
+ return matching / len(scored)
104
+
105
+ @property
106
+ def semantic_only_degraded_rate(self) -> float | None:
107
+ """The headline metric this eval exists to surface -- fraction of
108
+ non-Latin-script cases where BM25/entity-boost silently never
109
+ fired. This is what would reproduce mem0ai/mem0#4884's finding if
110
+ run against a live, unpatched mem0 instance."""
111
+ return self._fraction(LanguageDegradationSignal.SEMANTIC_ONLY_DEGRADED)
112
+
113
+ @property
114
+ def hybrid_signals_active_rate(self) -> float | None:
115
+ return self._fraction(LanguageDegradationSignal.HYBRID_SIGNALS_ACTIVE)
116
+
117
+ def rate_by_script(self, signal: LanguageDegradationSignal) -> dict[str, float | None]:
118
+ """Per-script breakdown of `signal`'s rate -- e.g.
119
+ `rate_by_script(LanguageDegradationSignal.SEMANTIC_ONLY_DEGRADED)`
120
+ returns `{"chinese": 1.0, "arabic": 0.5, ...}`, letting a caller
121
+ see whether degradation is uniform across scripts or concentrated
122
+ in specific ones, rather than only one aggregate number."""
123
+ scripts = sorted({c.case.script for c in self.scored_cases})
124
+ result: dict[str, float | None] = {}
125
+ for script in scripts:
126
+ cases = [c for c in self.scored_cases if c.case.script == script]
127
+ if not cases:
128
+ result[script] = None
129
+ continue
130
+ matching = sum(1 for c in cases if c.signal == signal)
131
+ result[script] = matching / len(cases)
132
+ return result
133
+
134
+
135
+ def load_dataset(path: Path | str = DEFAULT_FIXTURE_PATH) -> list[LanguageDegradationCase]:
136
+ data = json.loads(Path(path).read_text())
137
+ cases: list[dict[str, Any]] = data["cases"]
138
+ return [
139
+ LanguageDegradationCase(
140
+ case_id=c["case_id"],
141
+ session_id=c["session_id"],
142
+ script=c["script"],
143
+ content=c["content"],
144
+ query=c["query"],
145
+ )
146
+ for c in cases
147
+ ]
148
+
149
+
150
+ def run_language_degradation_eval(
151
+ adapter: Mem0DirectAdapter,
152
+ dataset_path: Path | str = DEFAULT_FIXTURE_PATH,
153
+ ) -> LanguageDegradationEvalResult:
154
+ """Store each case's non-Latin-script content, then query() it back
155
+ with `explain=True` -- reading `QueryResult.language_degradation_signal`
156
+ for the verdict `Mem0DirectAdapter.query()` already derived from mem0's
157
+ real `score_details` response. This eval does no classification of its
158
+ own beyond aggregating what `query()` reports -- the actual
159
+ `bm25_score`/`entity_boost` inspection lives in
160
+ `mem0_direct_adapter.py::_classify_language_degradation()`, so a
161
+ single, real code path backs both a direct `query(explain=True)` call
162
+ and this eval's aggregate numbers.
163
+ """
164
+ cases = load_dataset(dataset_path)
165
+ result = LanguageDegradationEvalResult(
166
+ backend_name=adapter.name, dataset_path=str(dataset_path)
167
+ )
168
+
169
+ for case in cases:
170
+ try:
171
+ adapter.store(case.session_id, case.content)
172
+ query_result = adapter.query(case.session_id, case.query, top_k=5, explain=True)
173
+ except BackendAPIError as exc:
174
+ result.case_results.append(
175
+ LanguageDegradationCaseResult(
176
+ case=case,
177
+ signal=LanguageDegradationSignal.NOT_APPLICABLE,
178
+ error=str(exc),
179
+ )
180
+ )
181
+ continue
182
+
183
+ result.case_results.append(
184
+ LanguageDegradationCaseResult(
185
+ case=case, signal=query_result.language_degradation_signal
186
+ )
187
+ )
188
+
189
+ return result