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,290 @@
|
|
|
1
|
+
"""MemTrust's process-lifecycle/crash-recovery eval.
|
|
2
|
+
|
|
3
|
+
None of the other evals in this package can see what happens to a backend
|
|
4
|
+
across a server-process crash and restart -- they all exercise a single,
|
|
5
|
+
continuously-running adapter instance. This eval closes that specific gap.
|
|
6
|
+
|
|
7
|
+
Motivating case: volcengine/OpenViking#2644 (contributor yeyitech). A local
|
|
8
|
+
vectordb backend's `_recover()` routine, run on server-process startup,
|
|
9
|
+
silently skips rebuilding the search index when the on-disk index files
|
|
10
|
+
are missing but the underlying store data is still present -- e.g. the
|
|
11
|
+
process crashed mid-write, or the index files were deleted/corrupted
|
|
12
|
+
independently of the store. No exception is raised anywhere: the process
|
|
13
|
+
starts up cleanly, accepts queries normally, and simply returns nothing
|
|
14
|
+
for data that is still sitting in the store, unindexed. A caller has no
|
|
15
|
+
way to distinguish this from "the data was genuinely never stored" short
|
|
16
|
+
of inspecting the store directly.
|
|
17
|
+
|
|
18
|
+
**Honest scope of what this eval can and cannot prove.** memtrust's
|
|
19
|
+
adapters are pure HTTP clients (see adapters/base.py's module docstring)
|
|
20
|
+
with zero ability to start, kill, or restart a real vendor server
|
|
21
|
+
process -- there is no live OpenViking binary this harness manages, and
|
|
22
|
+
building real subprocess lifecycle control was out of scope for the
|
|
23
|
+
environment this eval was built in. So this eval does not, and cannot,
|
|
24
|
+
reproduce #2644 against a live OpenViking instance. Instead it targets
|
|
25
|
+
the STRUCTURAL failure shape directly: it stores data via an adapter,
|
|
26
|
+
calls an explicit `adapter.simulate_crash_restart()` (a named simulation
|
|
27
|
+
primitive, never a real process kill -- see
|
|
28
|
+
MemoryBackendAdapter.supports_crash_recovery_simulation), queries the
|
|
29
|
+
same data afterward, and independently checks whether the underlying
|
|
30
|
+
store still holds it via `adapter.raw_store_contains()` (which bypasses
|
|
31
|
+
the search index query() goes through). Only a purpose-built in-memory
|
|
32
|
+
fake adapter can genuinely model both halves of this today -- see
|
|
33
|
+
tests/test_evals.py::CrashRecoveryFakeAdapter. Adapters without this
|
|
34
|
+
capability (every real adapter in this repo, including
|
|
35
|
+
OpenVikingAdapter) report NOT_APPLICABLE / skipped, never a guessed
|
|
36
|
+
result. See docs/methodology.md for the full write-up of what this does
|
|
37
|
+
and does not close.
|
|
38
|
+
|
|
39
|
+
Classification produces one of:
|
|
40
|
+
|
|
41
|
+
* RECOVERED -- post-restart query() still returns the
|
|
42
|
+
record. The index survived (or was
|
|
43
|
+
correctly rebuilt).
|
|
44
|
+
* INDEX_LOST_DATA_SURVIVED -- post-restart query() returns nothing for
|
|
45
|
+
this record, but raw_store_contains()
|
|
46
|
+
confirms the underlying store still has
|
|
47
|
+
it. This is the exact #2644 shape: data
|
|
48
|
+
intact, index lost, queries silently
|
|
49
|
+
return nothing. Distinct from
|
|
50
|
+
ConflictSignal.EMPTY_OR_LOST (adapters/
|
|
51
|
+
base.py), which is about a single query
|
|
52
|
+
returning nothing with no crash/restart
|
|
53
|
+
context at all -- this signal exists
|
|
54
|
+
specifically for the post-recovery
|
|
55
|
+
symptom, where the eval has independent
|
|
56
|
+
evidence the data itself was never lost.
|
|
57
|
+
* DATA_LOST -- post-restart query() returns nothing AND
|
|
58
|
+
raw_store_contains() confirms the data
|
|
59
|
+
itself is gone too. A different, more
|
|
60
|
+
severe failure than #2644's shape (this
|
|
61
|
+
is real data loss, not an unrebuilt
|
|
62
|
+
index), kept as its own signal rather
|
|
63
|
+
than folded into INDEX_LOST_DATA_SURVIVED.
|
|
64
|
+
* NOT_APPLICABLE -- either the adapter has no crash-recovery
|
|
65
|
+
simulation capability at all
|
|
66
|
+
(supports_crash_recovery_simulation is
|
|
67
|
+
False -- the eval is skipped, not run),
|
|
68
|
+
or the record was never confirmed
|
|
69
|
+
queryable before the simulated crash in
|
|
70
|
+
the first place, so there is nothing
|
|
71
|
+
meaningful to classify about recovery.
|
|
72
|
+
|
|
73
|
+
Design principle (same as evals/resource_sync_safety.py's
|
|
74
|
+
classify_resource_sync_file and evals/ranking_quality.py's
|
|
75
|
+
classify_ranking_case): classification never blindly trusts a single
|
|
76
|
+
query() response. It cross-checks query() against an independent
|
|
77
|
+
raw_store_contains() observation, the same way resource_sync_safety.py
|
|
78
|
+
cross-checks list_resource_paths() (existence) against query()
|
|
79
|
+
(searchability) to tell "never indexed" apart from "deleted".
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
from __future__ import annotations
|
|
83
|
+
|
|
84
|
+
import json
|
|
85
|
+
from dataclasses import dataclass, field
|
|
86
|
+
from enum import StrEnum
|
|
87
|
+
from pathlib import Path
|
|
88
|
+
from typing import Any
|
|
89
|
+
|
|
90
|
+
from memtrust.adapters.base import BackendAPIError, MemoryBackendAdapter
|
|
91
|
+
|
|
92
|
+
DEFAULT_FIXTURE_PATH = (
|
|
93
|
+
Path(__file__).resolve().parents[3] / "tests" / "fixtures" / "crash_recovery_cases.json"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class CrashRecoverySignal(StrEnum):
|
|
98
|
+
"""How one stored record fared across a simulated crash + restart."""
|
|
99
|
+
|
|
100
|
+
RECOVERED = "recovered"
|
|
101
|
+
"""Present and searchable via query() both before and after the
|
|
102
|
+
simulated crash/restart -- the index survived or was correctly
|
|
103
|
+
rebuilt on startup."""
|
|
104
|
+
|
|
105
|
+
INDEX_LOST_DATA_SURVIVED = "index_lost_data_survived"
|
|
106
|
+
"""query() returns nothing for this record after the simulated crash/
|
|
107
|
+
restart, but raw_store_contains() independently confirms the
|
|
108
|
+
underlying store still has the data. The exact volcengine/
|
|
109
|
+
OpenViking#2644 shape: `_recover()` skipped rebuilding the index
|
|
110
|
+
while store data survived, so queries silently return nothing for
|
|
111
|
+
data that was never actually lost."""
|
|
112
|
+
|
|
113
|
+
DATA_LOST = "data_lost"
|
|
114
|
+
"""query() returns nothing after the simulated crash/restart AND
|
|
115
|
+
raw_store_contains() confirms the data itself is also gone -- a
|
|
116
|
+
different, more severe failure than #2644's shape (real data loss,
|
|
117
|
+
not merely an unrebuilt index)."""
|
|
118
|
+
|
|
119
|
+
NOT_APPLICABLE = "not_applicable"
|
|
120
|
+
"""Either the adapter has no crash-recovery-simulation capability
|
|
121
|
+
(MemoryBackendAdapter.supports_crash_recovery_simulation is False --
|
|
122
|
+
the eval is skipped entirely, not run per-case), or the record was
|
|
123
|
+
never confirmed present/queryable before the simulated crash, so
|
|
124
|
+
there is nothing meaningful to classify about recovery."""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass
|
|
128
|
+
class CrashRecoveryCase:
|
|
129
|
+
case_id: str
|
|
130
|
+
session_id: str
|
|
131
|
+
content: str
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass
|
|
135
|
+
class CrashRecoveryCaseResult:
|
|
136
|
+
case: CrashRecoveryCase
|
|
137
|
+
signal: CrashRecoverySignal
|
|
138
|
+
present_before_crash: bool
|
|
139
|
+
queryable_after_crash: bool
|
|
140
|
+
raw_store_contains_after_crash: bool | None
|
|
141
|
+
"""None when the adapter has no crash-recovery-simulation capability
|
|
142
|
+
(raw_store_contains() was never called) or when the eval could not
|
|
143
|
+
reach the point of calling it (e.g. a BackendAPIError before the
|
|
144
|
+
simulated crash). True/False otherwise."""
|
|
145
|
+
error: str | None = None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass
|
|
149
|
+
class CrashRecoveryEvalResult:
|
|
150
|
+
backend_name: str
|
|
151
|
+
dataset_path: str
|
|
152
|
+
case_results: list[CrashRecoveryCaseResult] = field(default_factory=list)
|
|
153
|
+
skipped: bool = False
|
|
154
|
+
skip_reason: str | None = None
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def scored_cases(self) -> list[CrashRecoveryCaseResult]:
|
|
158
|
+
return [c for c in self.case_results if c.error is None]
|
|
159
|
+
|
|
160
|
+
def _fraction(self, signal: CrashRecoverySignal) -> float | None:
|
|
161
|
+
scored = self.scored_cases
|
|
162
|
+
if not scored:
|
|
163
|
+
return None
|
|
164
|
+
matching = sum(1 for c in scored if c.signal == signal)
|
|
165
|
+
return matching / len(scored)
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def recovered_rate(self) -> float | None:
|
|
169
|
+
return self._fraction(CrashRecoverySignal.RECOVERED)
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def index_lost_data_survived_rate(self) -> float | None:
|
|
173
|
+
"""Fraction of cases where the simulated crash/restart lost the
|
|
174
|
+
index while the underlying data survived -- the headline metric
|
|
175
|
+
this eval exists to surface, and the exact volcengine/
|
|
176
|
+
OpenViking#2644 shape."""
|
|
177
|
+
return self._fraction(CrashRecoverySignal.INDEX_LOST_DATA_SURVIVED)
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def data_lost_rate(self) -> float | None:
|
|
181
|
+
return self._fraction(CrashRecoverySignal.DATA_LOST)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def load_dataset(path: Path | str = DEFAULT_FIXTURE_PATH) -> list[CrashRecoveryCase]:
|
|
185
|
+
data = json.loads(Path(path).read_text())
|
|
186
|
+
cases: list[dict[str, Any]] = data["cases"]
|
|
187
|
+
return [
|
|
188
|
+
CrashRecoveryCase(
|
|
189
|
+
case_id=c["case_id"],
|
|
190
|
+
session_id=c["session_id"],
|
|
191
|
+
content=c["content"],
|
|
192
|
+
)
|
|
193
|
+
for c in cases
|
|
194
|
+
]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def classify_crash_recovery_case(
|
|
198
|
+
present_before_crash: bool,
|
|
199
|
+
queryable_after_crash: bool,
|
|
200
|
+
raw_store_contains_after_crash: bool | None,
|
|
201
|
+
) -> CrashRecoverySignal:
|
|
202
|
+
"""Classify a single case's outcome from its before/after observations.
|
|
203
|
+
|
|
204
|
+
Never trusts query() alone to mean "the data is gone" -- that is
|
|
205
|
+
exactly the ambiguity volcengine/OpenViking#2644 exploits (a lost
|
|
206
|
+
index and lost data both make query() return nothing). Only
|
|
207
|
+
raw_store_contains(), an independent observation that bypasses the
|
|
208
|
+
search index, can tell the two apart.
|
|
209
|
+
"""
|
|
210
|
+
if not present_before_crash:
|
|
211
|
+
# Never confirmed present/queryable before the simulated crash --
|
|
212
|
+
# nothing meaningful to say about "recovery" for this case.
|
|
213
|
+
return CrashRecoverySignal.NOT_APPLICABLE
|
|
214
|
+
if queryable_after_crash:
|
|
215
|
+
return CrashRecoverySignal.RECOVERED
|
|
216
|
+
if raw_store_contains_after_crash is True:
|
|
217
|
+
return CrashRecoverySignal.INDEX_LOST_DATA_SURVIVED
|
|
218
|
+
if raw_store_contains_after_crash is False:
|
|
219
|
+
return CrashRecoverySignal.DATA_LOST
|
|
220
|
+
# raw_store_contains_after_crash is None: the eval never got an
|
|
221
|
+
# independent read on the underlying store (e.g. it errored out) --
|
|
222
|
+
# not enough evidence to call this either INDEX_LOST_DATA_SURVIVED or
|
|
223
|
+
# DATA_LOST.
|
|
224
|
+
return CrashRecoverySignal.NOT_APPLICABLE
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def run_crash_recovery_eval(
|
|
228
|
+
adapter: MemoryBackendAdapter,
|
|
229
|
+
dataset_path: Path | str = DEFAULT_FIXTURE_PATH,
|
|
230
|
+
) -> CrashRecoveryEvalResult:
|
|
231
|
+
cases = load_dataset(dataset_path)
|
|
232
|
+
result = CrashRecoveryEvalResult(backend_name=adapter.name, dataset_path=str(dataset_path))
|
|
233
|
+
|
|
234
|
+
if not adapter.supports_crash_recovery_simulation:
|
|
235
|
+
result.skipped = True
|
|
236
|
+
result.skip_reason = (
|
|
237
|
+
f"{adapter.name} does not support crash-recovery simulation "
|
|
238
|
+
"(supports_crash_recovery_simulation=False) -- skipped, not run. "
|
|
239
|
+
"No adapter in this repo has real process-lifecycle control over "
|
|
240
|
+
"a live backend server; see evals/crash_recovery.py's module "
|
|
241
|
+
"docstring and docs/methodology.md."
|
|
242
|
+
)
|
|
243
|
+
return result
|
|
244
|
+
|
|
245
|
+
for case in cases:
|
|
246
|
+
try:
|
|
247
|
+
store_result = adapter.store(case.session_id, case.content)
|
|
248
|
+
pre_crash_query = adapter.query(case.session_id, case.content, top_k=5)
|
|
249
|
+
present_before_crash = any(
|
|
250
|
+
case.content.lower() in r.content.lower() for r in pre_crash_query.records
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
adapter.simulate_crash_restart()
|
|
254
|
+
|
|
255
|
+
post_crash_query = adapter.query(case.session_id, case.content, top_k=5)
|
|
256
|
+
queryable_after_crash = any(
|
|
257
|
+
case.content.lower() in r.content.lower() for r in post_crash_query.records
|
|
258
|
+
)
|
|
259
|
+
raw_store_contains_after_crash: bool | None = None
|
|
260
|
+
if not queryable_after_crash:
|
|
261
|
+
raw_store_contains_after_crash = adapter.raw_store_contains(
|
|
262
|
+
case.session_id, store_result.memory_id
|
|
263
|
+
)
|
|
264
|
+
except BackendAPIError as exc:
|
|
265
|
+
result.case_results.append(
|
|
266
|
+
CrashRecoveryCaseResult(
|
|
267
|
+
case=case,
|
|
268
|
+
signal=CrashRecoverySignal.NOT_APPLICABLE,
|
|
269
|
+
present_before_crash=False,
|
|
270
|
+
queryable_after_crash=False,
|
|
271
|
+
raw_store_contains_after_crash=None,
|
|
272
|
+
error=str(exc),
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
continue
|
|
276
|
+
|
|
277
|
+
signal = classify_crash_recovery_case(
|
|
278
|
+
present_before_crash, queryable_after_crash, raw_store_contains_after_crash
|
|
279
|
+
)
|
|
280
|
+
result.case_results.append(
|
|
281
|
+
CrashRecoveryCaseResult(
|
|
282
|
+
case=case,
|
|
283
|
+
signal=signal,
|
|
284
|
+
present_before_crash=present_before_crash,
|
|
285
|
+
queryable_after_crash=queryable_after_crash,
|
|
286
|
+
raw_store_contains_after_crash=raw_store_contains_after_crash,
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
return result
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""memtrust's vendor embedder-call counting and cost-attribution eval for
|
|
2
|
+
`Mem0DirectAdapter`.
|
|
3
|
+
|
|
4
|
+
Motivating case: spike-spiegel-21 (rank 104, mem0ai/mem0#1900, "[improvement]:
|
|
5
|
+
Duplicate embedding generation removed", merged). That PR's own description:
|
|
6
|
+
"If the new memory is not modified after `new_memories_with_actions`, we can
|
|
7
|
+
utilise the embedding that were already created while `search`." memtrust
|
|
8
|
+
had zero vendor-side embedder-call instrumentation anywhere --
|
|
9
|
+
`scoring/cost_tracker.py` tracked only memtrust's own judge-LLM spend, never
|
|
10
|
+
what a backend's own embedding API actually did per call. This eval and the
|
|
11
|
+
`CostTracker.record_embed_calls()`/`EmbedCallEntry` primitive it depends on
|
|
12
|
+
(see `mem0_direct_adapter.py`'s `_CountingEmbedder`) close that
|
|
13
|
+
instrumentation gap.
|
|
14
|
+
|
|
15
|
+
Honest scope -- read this before trusting a "redundant re-embed" verdict.
|
|
16
|
+
PR #1900 targeted an OLDER mem0 architecture: a separate LLM-driven
|
|
17
|
+
UPDATE/DELETE decision pass (`new_memories_with_actions`) that reused an
|
|
18
|
+
embedding already computed during an earlier search step. That code path is
|
|
19
|
+
gone in the currently installed `mem0ai==2.0.12`: confirmed by reading
|
|
20
|
+
`mem0/memory/main.py`'s `_add_to_vector_store()` directly, its "V3 PHASED
|
|
21
|
+
BATCH PIPELINE" only ever embeds the incoming query text once (Phase 1, for
|
|
22
|
+
similarity search against existing memories) and then unconditionally
|
|
23
|
+
batch-embeds every LLM-extracted fact (Phase 3) with no code path that
|
|
24
|
+
checks whether an extracted fact matches content already embedded in Phase
|
|
25
|
+
1. This eval therefore does not, and cannot, reproduce PR #1900's original
|
|
26
|
+
diff -- the same honest substitution shape `Mem0DirectAdapter`'s own module
|
|
27
|
+
docstring already establishes for mem0ai/mem0#3558 (Kuzu): it reproduces the
|
|
28
|
+
bug *class* (vendor embedder-call instrumentation surfacing whether a
|
|
29
|
+
redundant re-embed happens) against the pipeline that actually exists today,
|
|
30
|
+
not a literal re-run of a since-superseded code path.
|
|
31
|
+
|
|
32
|
+
What this eval actually measures: seed a record via `store()`, `query()`
|
|
33
|
+
it back (the "search()" half of PR #1900's own phrasing), then `store()`
|
|
34
|
+
the exact same, unmodified content again (the "then add()" half) --
|
|
35
|
+
and reports whether the second `store()` call still triggered a real
|
|
36
|
+
vendor embedder call. Every `store()` call in the currently installed
|
|
37
|
+
package's pipeline does its own independent batch-embed with no reuse
|
|
38
|
+
logic at all, so a REDUNDANT_REEMBED verdict against a live mem0 instance
|
|
39
|
+
here should be read as "this is simply how the installed package's
|
|
40
|
+
pipeline behaves today," not as a regression this build discovered --
|
|
41
|
+
this eval exists to make that behavior *observable* through memtrust,
|
|
42
|
+
which it previously could not do.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
from dataclasses import dataclass
|
|
48
|
+
from enum import StrEnum
|
|
49
|
+
|
|
50
|
+
from memtrust.adapters.base import BackendAPIError
|
|
51
|
+
from memtrust.adapters.mem0_direct_adapter import Mem0DirectAdapter
|
|
52
|
+
from memtrust.scoring.cost_tracker import CostTracker
|
|
53
|
+
|
|
54
|
+
DEFAULT_SESSION_ID = "embedder-cost-session"
|
|
55
|
+
DEFAULT_CONTENT = "The user's preferred deploy window is Tuesday mornings."
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class EmbedderCostSignal(StrEnum):
|
|
59
|
+
"""Defined locally in this eval module rather than added to `base.py`,
|
|
60
|
+
following the precedent `evals/scale_stress.py`'s local `ScaleSignal`
|
|
61
|
+
enum already establishes for a capability that is not part of every
|
|
62
|
+
adapter's shared `MemoryBackendAdapter` interface -- this eval is
|
|
63
|
+
`Mem0DirectAdapter`-specific (it depends on `_CountingEmbedder`
|
|
64
|
+
wrapping a real, installed mem0 embedder instance), not something
|
|
65
|
+
every adapter could meaningfully report.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
REDUNDANT_REEMBED = "redundant_reembed"
|
|
69
|
+
"""The second `store()` call -- for the exact same, unmodified content
|
|
70
|
+
a prior `store()`+`query()` sequence already embedded -- still
|
|
71
|
+
triggered at least one real vendor embedder call. See module
|
|
72
|
+
docstring for the honest scope of what this does and does not prove
|
|
73
|
+
against the currently installed `mem0ai` package."""
|
|
74
|
+
|
|
75
|
+
NO_REDUNDANT_REEMBED = "no_redundant_reembed"
|
|
76
|
+
"""The second `store()` call triggered zero vendor embedder calls --
|
|
77
|
+
the backend genuinely reused an existing embedding for unmodified
|
|
78
|
+
content instead of re-embedding it. The good outcome PR #1900's own
|
|
79
|
+
fix (for a now-superseded code path) achieved."""
|
|
80
|
+
|
|
81
|
+
NOT_APPLICABLE = "not_applicable"
|
|
82
|
+
"""The store()/query() call sequence raised BackendAPIError, or this
|
|
83
|
+
adapter has no `cost_tracker`/embedder surface to observe at all --
|
|
84
|
+
recorded explicitly, never silently read as either signal above, same
|
|
85
|
+
convention every other signal enum's NOT_APPLICABLE member in this
|
|
86
|
+
package follows."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class EmbedderCostEvalResult:
|
|
91
|
+
backend_name: str
|
|
92
|
+
signal: EmbedderCostSignal
|
|
93
|
+
first_store_embed_calls: int
|
|
94
|
+
"""Vendor embedder-call count `_CountingEmbedder` observed for the
|
|
95
|
+
FIRST store() call (seeding the record)."""
|
|
96
|
+
second_store_embed_calls: int
|
|
97
|
+
"""Vendor embedder-call count observed for the SECOND store() call --
|
|
98
|
+
the one this eval's verdict is actually about."""
|
|
99
|
+
error: str | None = None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def run_embedder_cost_eval(
|
|
103
|
+
adapter: Mem0DirectAdapter,
|
|
104
|
+
cost_tracker: CostTracker,
|
|
105
|
+
session_id: str = DEFAULT_SESSION_ID,
|
|
106
|
+
content: str = DEFAULT_CONTENT,
|
|
107
|
+
) -> EmbedderCostEvalResult:
|
|
108
|
+
"""Seed a record, query() it back (search()), then store() the exact
|
|
109
|
+
same unmodified content again (add()) -- see module docstring for the
|
|
110
|
+
real PR #1900 phrasing this mirrors -- and classify whether the second
|
|
111
|
+
store() call triggered a redundant vendor embedder call.
|
|
112
|
+
|
|
113
|
+
`cost_tracker` must be the same `CostTracker` instance `adapter` was
|
|
114
|
+
constructed with (`Mem0DirectAdapter(..., cost_tracker=cost_tracker)`)
|
|
115
|
+
-- this function reads `cost_tracker.embed_entries` after each
|
|
116
|
+
store() call to observe what `_CountingEmbedder` counted, rather than
|
|
117
|
+
requiring a new field on `StoreResult` (keeping this eval's scope to
|
|
118
|
+
exactly `mem0_direct_adapter.py` + `cost_tracker.py` + this new eval
|
|
119
|
+
module, per the backlog item's own `Where:` scope -- no `base.py`
|
|
120
|
+
change).
|
|
121
|
+
"""
|
|
122
|
+
try:
|
|
123
|
+
adapter.store(session_id, content)
|
|
124
|
+
first_calls = cost_tracker.embed_entries[-1].call_count if cost_tracker.embed_entries else 0
|
|
125
|
+
|
|
126
|
+
adapter.query(session_id, content)
|
|
127
|
+
adapter.store(session_id, content)
|
|
128
|
+
second_calls = (
|
|
129
|
+
cost_tracker.embed_entries[-1].call_count if cost_tracker.embed_entries else 0
|
|
130
|
+
)
|
|
131
|
+
except BackendAPIError as exc:
|
|
132
|
+
return EmbedderCostEvalResult(
|
|
133
|
+
backend_name=adapter.name,
|
|
134
|
+
signal=EmbedderCostSignal.NOT_APPLICABLE,
|
|
135
|
+
first_store_embed_calls=0,
|
|
136
|
+
second_store_embed_calls=0,
|
|
137
|
+
error=str(exc),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
if not cost_tracker.embed_entries:
|
|
141
|
+
# adapter.cost_tracker wasn't wired to the same CostTracker, or
|
|
142
|
+
# this adapter's underlying memory has no embedding_model surface
|
|
143
|
+
# to observe at all -- see Mem0DirectAdapter.store()'s gating.
|
|
144
|
+
return EmbedderCostEvalResult(
|
|
145
|
+
backend_name=adapter.name,
|
|
146
|
+
signal=EmbedderCostSignal.NOT_APPLICABLE,
|
|
147
|
+
first_store_embed_calls=0,
|
|
148
|
+
second_store_embed_calls=0,
|
|
149
|
+
error="no embed-call instrumentation observed -- adapter may not share "
|
|
150
|
+
"this cost_tracker, or its memory.embedding_model is unavailable",
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
signal = (
|
|
154
|
+
EmbedderCostSignal.REDUNDANT_REEMBED
|
|
155
|
+
if second_calls > 0
|
|
156
|
+
else EmbedderCostSignal.NO_REDUNDANT_REEMBED
|
|
157
|
+
)
|
|
158
|
+
return EmbedderCostEvalResult(
|
|
159
|
+
backend_name=adapter.name,
|
|
160
|
+
signal=signal,
|
|
161
|
+
first_store_embed_calls=first_calls,
|
|
162
|
+
second_store_embed_calls=second_calls,
|
|
163
|
+
)
|