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