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,235 @@
|
|
|
1
|
+
"""Compression / round-trip fidelity eval.
|
|
2
|
+
|
|
3
|
+
Neither LongMemEval, LoCoMo, nor memtrust's own contradiction eval measures
|
|
4
|
+
a vendor's "lossless" or compression-ratio claim directly. The closest
|
|
5
|
+
existing signal would be an aggregate accuracy delta between two operating
|
|
6
|
+
modes on one of those evals -- but that requires a way to tell the adapter
|
|
7
|
+
which mode to use, which is exactly what
|
|
8
|
+
`MemoryBackendAdapter.supported_modes` and the `mode` parameter on
|
|
9
|
+
`store()`/`query()` (see adapters/base.py) now provide.
|
|
10
|
+
|
|
11
|
+
This eval is deliberately narrow and NOT an LLM-judge eval: it stores a
|
|
12
|
+
piece of content, retrieves it back, and scores how much of the original
|
|
13
|
+
text survived the round trip using a direct, deterministic text-similarity
|
|
14
|
+
metric (`fidelity_ratio`, a character-level `difflib.SequenceMatcher`
|
|
15
|
+
ratio -- see its docstring). A vendor's "lossless compression" claim is
|
|
16
|
+
a factual claim about byte/character fidelity, not about semantic
|
|
17
|
+
equivalence, so grading it with an LLM judge would be both more expensive
|
|
18
|
+
and a worse fit than a direct string comparison: an LLM judge might rate a
|
|
19
|
+
paraphrased, information-dropping reconstruction as "close enough," which
|
|
20
|
+
is precisely the kind of leniency this eval exists to avoid.
|
|
21
|
+
|
|
22
|
+
Origin: mempalace/mempalace#27 (cited in README.md and docs/methodology.md
|
|
23
|
+
as founding rationale for this project) documents a "lossless" compression
|
|
24
|
+
claim that a 12.4 percentage-point accuracy drop in practice contradicts.
|
|
25
|
+
`run_compression_eval()` is what would let a contributor with a live
|
|
26
|
+
MemPalace instance reproduce that "raw vs AAAK" comparison directly, by
|
|
27
|
+
running this eval against `MemPalaceAdapter` (whose `supported_modes` is
|
|
28
|
+
`("raw", "AAAK")` -- see adapters/mempalace_adapter.py for the exact
|
|
29
|
+
provenance and confidence caveat on those two mode names).
|
|
30
|
+
|
|
31
|
+
**This eval has not been run against any live backend as of this file's
|
|
32
|
+
creation.** No fidelity number below or in any report this eval produces
|
|
33
|
+
should be read as measured until it has actually executed against a
|
|
34
|
+
configured, live adapter -- see docs/methodology.md's "What requires a
|
|
35
|
+
live vendor API key" table, which this eval should be added to under the
|
|
36
|
+
same rules as every other eval runner.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import difflib
|
|
42
|
+
import json
|
|
43
|
+
from dataclasses import dataclass, field
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
from typing import Any
|
|
46
|
+
|
|
47
|
+
from memtrust.adapters.base import BackendAPIError, MemoryBackendAdapter, QueryResult, StoreResult
|
|
48
|
+
|
|
49
|
+
DEFAULT_FIXTURE_PATH = (
|
|
50
|
+
Path(__file__).resolve().parents[3] / "tests" / "fixtures" / "compression_cases.json"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
#: Mode label used when an adapter declares no `supported_modes` at all
|
|
54
|
+
#: (an empty tuple). The eval still runs exactly once per case in this
|
|
55
|
+
#: case, rather than being silently skipped -- see docs/methodology.md's
|
|
56
|
+
#: "no result silently dropped from the table" convention, applied here
|
|
57
|
+
#: the same way evals/contradiction.py applies it to NOT_APPLICABLE.
|
|
58
|
+
DEFAULT_MODE_LABEL = "default"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class CompressionCase:
|
|
63
|
+
case_id: str
|
|
64
|
+
content: str
|
|
65
|
+
description: str = ""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class CompressionCaseResult:
|
|
70
|
+
case_id: str
|
|
71
|
+
mode: str
|
|
72
|
+
fidelity_score: float
|
|
73
|
+
content_length: int
|
|
74
|
+
retrieved_length: int
|
|
75
|
+
retrieved_content: str = ""
|
|
76
|
+
error: str | None = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class CompressionModeResult:
|
|
81
|
+
"""Per-mode results for one backend -- one of these per string in
|
|
82
|
+
`adapter.supported_modes`, or a single one labeled DEFAULT_MODE_LABEL
|
|
83
|
+
for adapters that report no mode variants."""
|
|
84
|
+
|
|
85
|
+
mode: str
|
|
86
|
+
case_results: list[CompressionCaseResult] = field(default_factory=list)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def scored_cases(self) -> list[CompressionCaseResult]:
|
|
90
|
+
return [c for c in self.case_results if c.error is None]
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def mean_fidelity(self) -> float | None:
|
|
94
|
+
scored = self.scored_cases
|
|
95
|
+
if not scored:
|
|
96
|
+
return None
|
|
97
|
+
return sum(c.fidelity_score for c in scored) / len(scored)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class CompressionEvalResult:
|
|
102
|
+
backend_name: str
|
|
103
|
+
dataset_path: str
|
|
104
|
+
modes: list[str] = field(default_factory=list)
|
|
105
|
+
mode_results: dict[str, CompressionModeResult] = field(default_factory=dict)
|
|
106
|
+
|
|
107
|
+
def mean_fidelity_by_mode(self) -> dict[str, float | None]:
|
|
108
|
+
return {mode: result.mean_fidelity for mode, result in self.mode_results.items()}
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def fidelity_drop_pp(self) -> float | None:
|
|
112
|
+
"""Percentage-point gap between the best- and worst-scoring mode.
|
|
113
|
+
|
|
114
|
+
This is the number that would reproduce a "96.6% raw vs 84.2%
|
|
115
|
+
AAAK, 12.4pp drop" style comparison once this eval has actually
|
|
116
|
+
been run against a live backend that reports more than one mode.
|
|
117
|
+
`None` if fewer than two modes produced a scoreable mean (e.g. a
|
|
118
|
+
single-mode adapter, or a run where every case errored).
|
|
119
|
+
"""
|
|
120
|
+
means = [m for m in self.mean_fidelity_by_mode().values() if m is not None]
|
|
121
|
+
if len(means) < 2:
|
|
122
|
+
return None
|
|
123
|
+
return (max(means) - min(means)) * 100
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def load_dataset(path: Path | str = DEFAULT_FIXTURE_PATH) -> list[CompressionCase]:
|
|
127
|
+
data = json.loads(Path(path).read_text())
|
|
128
|
+
cases: list[dict[str, Any]] = data["cases"]
|
|
129
|
+
return [
|
|
130
|
+
CompressionCase(
|
|
131
|
+
case_id=c["case_id"],
|
|
132
|
+
content=c["content"],
|
|
133
|
+
description=c.get("description", ""),
|
|
134
|
+
)
|
|
135
|
+
for c in cases
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def fidelity_ratio(original: str, retrieved: str) -> float:
|
|
140
|
+
"""Direct, deterministic round-trip fidelity score in [0.0, 1.0].
|
|
141
|
+
|
|
142
|
+
Character-level similarity via `difflib.SequenceMatcher.ratio()`
|
|
143
|
+
(Ratcliff/Obershelp), NOT an LLM judge -- this eval measures literal
|
|
144
|
+
reconstruction fidelity, the thing a "lossless" claim is actually
|
|
145
|
+
about, not semantic equivalence. 1.0 means the retrieved text is
|
|
146
|
+
character-for-character identical to what was stored (a genuinely
|
|
147
|
+
lossless round trip). Lower values mean measurable information loss:
|
|
148
|
+
a truncated, reordered, or otherwise mangled reconstruction scores
|
|
149
|
+
strictly below a perfect one.
|
|
150
|
+
|
|
151
|
+
Two special-cased edges, both to keep the metric meaningful rather
|
|
152
|
+
than mathematically degenerate:
|
|
153
|
+
* both strings empty -> 1.0 (trivially identical, not a loss).
|
|
154
|
+
* original non-empty but nothing was retrieved -> 0.0 (total loss,
|
|
155
|
+
rather than SequenceMatcher's undefined/zero-length behavior).
|
|
156
|
+
"""
|
|
157
|
+
if not original and not retrieved:
|
|
158
|
+
return 1.0
|
|
159
|
+
if not retrieved:
|
|
160
|
+
return 0.0
|
|
161
|
+
return difflib.SequenceMatcher(None, original, retrieved).ratio()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _select_retrieved_content(store_result: StoreResult, query_result: QueryResult) -> str:
|
|
165
|
+
"""Pick the retrieved text to score against the original.
|
|
166
|
+
|
|
167
|
+
Prefers the record whose memory_id matches what store() returned (the
|
|
168
|
+
same memory we just wrote), falling back to the top-ranked result --
|
|
169
|
+
query() is the only read primitive the shared adapter interface
|
|
170
|
+
exposes (there is no get-by-id), so a round-trip eval has to go
|
|
171
|
+
through search like any other caller would.
|
|
172
|
+
"""
|
|
173
|
+
for record in query_result.records:
|
|
174
|
+
if record.memory_id == store_result.memory_id:
|
|
175
|
+
return record.content
|
|
176
|
+
if query_result.records:
|
|
177
|
+
return query_result.records[0].content
|
|
178
|
+
return ""
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def run_compression_eval(
|
|
182
|
+
adapter: MemoryBackendAdapter,
|
|
183
|
+
dataset_path: Path | str = DEFAULT_FIXTURE_PATH,
|
|
184
|
+
) -> CompressionEvalResult:
|
|
185
|
+
"""Run the same store+retrieve round trip for every case, once per
|
|
186
|
+
mode the adapter reports supporting (see
|
|
187
|
+
MemoryBackendAdapter.supported_modes), and score fidelity per mode.
|
|
188
|
+
|
|
189
|
+
An adapter with no mode variants (`supported_modes == ()`) still runs
|
|
190
|
+
once, under the synthetic label `DEFAULT_MODE_LABEL` -- it is not
|
|
191
|
+
skipped or silently dropped from the results table, matching the
|
|
192
|
+
convention `evals/contradiction.py` uses for NOT_APPLICABLE.
|
|
193
|
+
"""
|
|
194
|
+
cases = load_dataset(dataset_path)
|
|
195
|
+
modes = list(adapter.supported_modes) if adapter.supported_modes else [DEFAULT_MODE_LABEL]
|
|
196
|
+
result = CompressionEvalResult(
|
|
197
|
+
backend_name=adapter.name, dataset_path=str(dataset_path), modes=modes
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
for mode in modes:
|
|
201
|
+
effective_mode = None if mode == DEFAULT_MODE_LABEL else mode
|
|
202
|
+
mode_result = CompressionModeResult(mode=mode)
|
|
203
|
+
for case in cases:
|
|
204
|
+
session_id = f"compression-{case.case_id}-{mode}"
|
|
205
|
+
try:
|
|
206
|
+
store_result = adapter.store(session_id, case.content, mode=effective_mode)
|
|
207
|
+
query_result = adapter.query(session_id, case.content, top_k=5, mode=effective_mode)
|
|
208
|
+
except BackendAPIError as exc:
|
|
209
|
+
mode_result.case_results.append(
|
|
210
|
+
CompressionCaseResult(
|
|
211
|
+
case_id=case.case_id,
|
|
212
|
+
mode=mode,
|
|
213
|
+
fidelity_score=0.0,
|
|
214
|
+
content_length=len(case.content),
|
|
215
|
+
retrieved_length=0,
|
|
216
|
+
retrieved_content="",
|
|
217
|
+
error=str(exc),
|
|
218
|
+
)
|
|
219
|
+
)
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
retrieved = _select_retrieved_content(store_result, query_result)
|
|
223
|
+
mode_result.case_results.append(
|
|
224
|
+
CompressionCaseResult(
|
|
225
|
+
case_id=case.case_id,
|
|
226
|
+
mode=mode,
|
|
227
|
+
fidelity_score=fidelity_ratio(case.content, retrieved),
|
|
228
|
+
content_length=len(case.content),
|
|
229
|
+
retrieved_length=len(retrieved),
|
|
230
|
+
retrieved_content=retrieved,
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
result.mode_results[mode] = mode_result
|
|
234
|
+
|
|
235
|
+
return result
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
"""MemTrust's original multi-hop contradiction-detection eval.
|
|
2
|
+
|
|
3
|
+
This is the differentiating wedge described in docs/methodology.md and
|
|
4
|
+
the project README: LongMemEval and LoCoMo both measure recall -- can the
|
|
5
|
+
backend remember a fact you told it earlier. Neither measures what a
|
|
6
|
+
backend does when two stored facts conflict, which is the question that
|
|
7
|
+
actually matters once a memory system is running underneath a production
|
|
8
|
+
agent. If a user says a meeting is at 2pm and later says it moved to 3pm,
|
|
9
|
+
does the backend:
|
|
10
|
+
|
|
11
|
+
* FLAGGED -- surface the conflict (return both values, an
|
|
12
|
+
explicit "this was updated" marker, or otherwise
|
|
13
|
+
make the contradiction visible to the caller)
|
|
14
|
+
* SILENT_OVERWRITE -- replace the old fact with the new one and give no
|
|
15
|
+
signal a prior, different value ever existed
|
|
16
|
+
* SERVED_STALE -- return the old fact and give no signal that a
|
|
17
|
+
newer, conflicting fact was stored since
|
|
18
|
+
* NOT_APPLICABLE -- the backend has no update primitive this eval can
|
|
19
|
+
exercise (MemoryBackendAdapter.supports_update is
|
|
20
|
+
False); recorded explicitly, never silently
|
|
21
|
+
dropped from the results table
|
|
22
|
+
* EMPTY_OR_LOST -- the backend DOES have an update primitive and the
|
|
23
|
+
store()/update()/query() calls all completed
|
|
24
|
+
without error, but the query came back with zero
|
|
25
|
+
records -- a silent empty-success, distinct from
|
|
26
|
+
NOT_APPLICABLE, and never folded into an ordinary
|
|
27
|
+
miss
|
|
28
|
+
|
|
29
|
+
Design principle: classification is never a blind pass-through of what an
|
|
30
|
+
adapter *claims* happened. Each
|
|
31
|
+
case's final verdict cross-checks the adapter-reported ConflictSignal
|
|
32
|
+
against the actual retrieved content (does it contain the old value, the
|
|
33
|
+
new value, or both) -- so a vendor's own optimistic self-report cannot
|
|
34
|
+
silently become the eval's score. See `classify_case` below for the exact
|
|
35
|
+
logic, which is the part of this file most worth reading closely before
|
|
36
|
+
trusting a published contradiction-detection number.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import json
|
|
42
|
+
from dataclasses import dataclass, field
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
from typing import Any
|
|
45
|
+
|
|
46
|
+
from memtrust.adapters.base import (
|
|
47
|
+
BackendAPIError,
|
|
48
|
+
ConflictSignal,
|
|
49
|
+
MemoryBackendAdapter,
|
|
50
|
+
QueryResult,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
DEFAULT_FIXTURE_PATH = (
|
|
54
|
+
Path(__file__).resolve().parents[3] / "tests" / "fixtures" / "contradiction_cases.json"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class ContradictionCase:
|
|
60
|
+
case_id: str
|
|
61
|
+
session_id: str
|
|
62
|
+
subject: str
|
|
63
|
+
initial_fact: str
|
|
64
|
+
contradicting_fact: str
|
|
65
|
+
query: str
|
|
66
|
+
initial_value: str
|
|
67
|
+
updated_value: str
|
|
68
|
+
metadata: dict[str, str] = field(default_factory=dict)
|
|
69
|
+
"""Optional non-fact structured key/value pairs stored alongside
|
|
70
|
+
`initial_fact` (see `run_contradiction_eval` below, which threads this
|
|
71
|
+
into `adapter.store()`'s own `metadata` parameter). Empty for every
|
|
72
|
+
pre-existing case -- this is a purely additive field. Its purpose is
|
|
73
|
+
to exercise the harness's `MemoryRecord.attributes` boundary
|
|
74
|
+
end-to-end: a backend whose query() response echoes structured
|
|
75
|
+
per-record properties (e.g. self-hosted graphiti-core's
|
|
76
|
+
`EntityEdge.attributes`) has somewhere for this eval to observe them,
|
|
77
|
+
instead of every case only ever carrying plain fact text. See
|
|
78
|
+
tests/fixtures/contradiction_cases.json for the case that uses this,
|
|
79
|
+
and docs/methodology.md for the honesty caveat: graphiti-core's real
|
|
80
|
+
`add_episode()` has no generic metadata parameter to receive this
|
|
81
|
+
(confirmed against source, see
|
|
82
|
+
zep_graphiti_selfhosted_adapter.py's module docstring), so that
|
|
83
|
+
specific adapter accepts-and-ignores it, same as every no-op `mode`
|
|
84
|
+
parameter elsewhere in this codebase -- this field threading through
|
|
85
|
+
the harness is not itself proof any adapter surfaces it back out.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class ContradictionCaseResult:
|
|
91
|
+
case: ContradictionCase
|
|
92
|
+
signal: ConflictSignal
|
|
93
|
+
adapter_reported_signal: ConflictSignal | None
|
|
94
|
+
contains_initial_value: bool
|
|
95
|
+
contains_updated_value: bool
|
|
96
|
+
retrieved_content: str
|
|
97
|
+
error: str | None = None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class ContradictionEvalResult:
|
|
102
|
+
backend_name: str
|
|
103
|
+
dataset_path: str
|
|
104
|
+
case_results: list[ContradictionCaseResult] = field(default_factory=list)
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def scored_cases(self) -> list[ContradictionCaseResult]:
|
|
108
|
+
return [c for c in self.case_results if c.error is None]
|
|
109
|
+
|
|
110
|
+
def _fraction(self, signal: ConflictSignal) -> float | None:
|
|
111
|
+
scored = self.scored_cases
|
|
112
|
+
if not scored:
|
|
113
|
+
return None
|
|
114
|
+
matching = sum(1 for c in scored if c.signal == signal)
|
|
115
|
+
return matching / len(scored)
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def flagged_rate(self) -> float | None:
|
|
119
|
+
return self._fraction(ConflictSignal.FLAGGED)
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def silent_overwrite_rate(self) -> float | None:
|
|
123
|
+
return self._fraction(ConflictSignal.SILENT_OVERWRITE)
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def served_stale_rate(self) -> float | None:
|
|
127
|
+
return self._fraction(ConflictSignal.SERVED_STALE)
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def not_applicable_rate(self) -> float | None:
|
|
131
|
+
return self._fraction(ConflictSignal.NOT_APPLICABLE)
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def empty_or_lost_rate(self) -> float | None:
|
|
135
|
+
return self._fraction(ConflictSignal.EMPTY_OR_LOST)
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def edge_integrity_violation_rate(self) -> float | None:
|
|
139
|
+
return self._fraction(ConflictSignal.EDGE_INTEGRITY_VIOLATION)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def load_dataset(path: Path | str = DEFAULT_FIXTURE_PATH) -> list[ContradictionCase]:
|
|
143
|
+
data = json.loads(Path(path).read_text())
|
|
144
|
+
cases: list[dict[str, Any]] = data["cases"]
|
|
145
|
+
return [
|
|
146
|
+
ContradictionCase(
|
|
147
|
+
case_id=c["case_id"],
|
|
148
|
+
session_id=c["session_id"],
|
|
149
|
+
subject=c["subject"],
|
|
150
|
+
initial_fact=c["initial_fact"],
|
|
151
|
+
contradicting_fact=c["contradicting_fact"],
|
|
152
|
+
query=c["query"],
|
|
153
|
+
initial_value=c["initial_value"],
|
|
154
|
+
updated_value=c["updated_value"],
|
|
155
|
+
metadata=c.get("metadata", {}),
|
|
156
|
+
)
|
|
157
|
+
for c in cases
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def classify_case(
|
|
162
|
+
case: ContradictionCase, query_result: QueryResult
|
|
163
|
+
) -> tuple[ConflictSignal, bool, bool]:
|
|
164
|
+
"""Classify a single case's outcome from the adapter's query response.
|
|
165
|
+
|
|
166
|
+
Returns (final_signal, contains_initial_value, contains_updated_value).
|
|
167
|
+
|
|
168
|
+
Cross-checks the adapter's self-reported `conflict_signal` against the
|
|
169
|
+
actual retrieved text rather than trusting it outright, and -- where
|
|
170
|
+
the text alone is ambiguous -- consults per-record adapter metadata
|
|
171
|
+
(`MemoryRecord.metadata`) as corroborating evidence rather than a bare
|
|
172
|
+
self-report. A bi-temporal backend like Graphiti/Zep stamps a
|
|
173
|
+
superseded edge's `invalid_at` field in that metadata even when the
|
|
174
|
+
edge's raw text doesn't literally contain the case's old-value string
|
|
175
|
+
(paraphrased extraction) or when the fixed-size top-k window makes the
|
|
176
|
+
plain substring signal unreliable. That per-record marker is concrete
|
|
177
|
+
adapter-reported evidence, unlike the adapter's bare top-level
|
|
178
|
+
`conflict_signal` enum, which is never trusted on its own:
|
|
179
|
+
|
|
180
|
+
* If the retrieved content contains BOTH the old and new value, the
|
|
181
|
+
contradiction is visible in the response regardless of what the
|
|
182
|
+
adapter claims -- classified FLAGGED.
|
|
183
|
+
* If it contains only the new value, that normally reads as a
|
|
184
|
+
silent overwrite -- SILENT_OVERWRITE. But if any retrieved record
|
|
185
|
+
carries invalidation metadata (e.g. Graphiti's `invalid_at`), the
|
|
186
|
+
backend did preserve the old fact bi-temporally even though this
|
|
187
|
+
eval's literal substring match on the case's old-value string
|
|
188
|
+
didn't pick it up -- that corroborated signal reclassifies the
|
|
189
|
+
case as FLAGGED instead of a false-negative silent overwrite.
|
|
190
|
+
* If it contains only the old value, the backend never picked up
|
|
191
|
+
the update at all from the caller's perspective -- SERVED_STALE.
|
|
192
|
+
* If it contains neither and the adapter returned zero records at
|
|
193
|
+
all, that is not a genuine "no update primitive" case -- this
|
|
194
|
+
function is only ever called for adapters where
|
|
195
|
+
MemoryBackendAdapter.supports_update is True (see
|
|
196
|
+
run_contradiction_eval below, which short-circuits every case to
|
|
197
|
+
NOT_APPLICABLE before classify_case is ever invoked when it is
|
|
198
|
+
False). A capable backend that completed the store/update/query
|
|
199
|
+
calls with no error and came back with nothing is the "call
|
|
200
|
+
succeeded but silently produced nothing" failure mode --
|
|
201
|
+
classified EMPTY_OR_LOST, distinct from NOT_APPLICABLE, and never
|
|
202
|
+
silently folded into an ordinary miss.
|
|
203
|
+
* If it contains neither but the adapter DID return records (just
|
|
204
|
+
not ones containing either value): if any retrieved record
|
|
205
|
+
carries invalidation metadata, that is still concrete evidence of
|
|
206
|
+
bi-temporal preservation even though neither value's literal text
|
|
207
|
+
matched -- FLAGGED. Otherwise this falls back to NOT_APPLICABLE (a
|
|
208
|
+
genuine "this eval could not observe anything meaningful here").
|
|
209
|
+
An adapter's bare top-level `conflict_signal` claim with no
|
|
210
|
+
corroborating record metadata and no matching text is not
|
|
211
|
+
credible evidence on its own and is not upgraded to a passing
|
|
212
|
+
score. This branch used to collapse to NOT_APPLICABLE
|
|
213
|
+
unconditionally regardless of metadata (dead code -- see git
|
|
214
|
+
history); it now genuinely differentiates on concrete, per-record
|
|
215
|
+
adapter metadata.
|
|
216
|
+
* Structural check, evaluated before all of the above: if ANY
|
|
217
|
+
retrieved record is edge-shaped (its `raw` fragment carries both
|
|
218
|
+
a `source_node_uuid` and a `target_node_uuid` key -- the property
|
|
219
|
+
names graphiti_core's `EntityEdge` writes) but at least one of
|
|
220
|
+
those two values is missing/falsy, the case is classified
|
|
221
|
+
EDGE_INTEGRITY_VIOLATION regardless of what the value-level text
|
|
222
|
+
match would otherwise say. A structurally broken edge (no
|
|
223
|
+
endpoints) is a more fundamental failure than "which value did
|
|
224
|
+
the text contain" -- see ConflictSignal.EDGE_INTEGRITY_VIOLATION
|
|
225
|
+
for the two real graphiti-core bugs (getzep/graphiti#1013,
|
|
226
|
+
#1001) this exists to catch if reproduced against an affected
|
|
227
|
+
version. Records from backends that don't model edges at all
|
|
228
|
+
(no such keys present in `raw`) never trigger this.
|
|
229
|
+
"""
|
|
230
|
+
content = " ".join(r.content for r in query_result.records).lower()
|
|
231
|
+
has_initial = case.initial_value.lower() in content
|
|
232
|
+
has_updated = case.updated_value.lower() in content
|
|
233
|
+
has_invalidation_metadata = any(r.metadata.get("invalid_at") for r in query_result.records)
|
|
234
|
+
|
|
235
|
+
if any(_edge_endpoints_missing(r) for r in query_result.records):
|
|
236
|
+
return ConflictSignal.EDGE_INTEGRITY_VIOLATION, has_initial, has_updated
|
|
237
|
+
|
|
238
|
+
if has_initial and has_updated:
|
|
239
|
+
return ConflictSignal.FLAGGED, has_initial, has_updated
|
|
240
|
+
if has_updated and not has_initial:
|
|
241
|
+
if has_invalidation_metadata:
|
|
242
|
+
return ConflictSignal.FLAGGED, has_initial, has_updated
|
|
243
|
+
return ConflictSignal.SILENT_OVERWRITE, has_initial, has_updated
|
|
244
|
+
if has_initial and not has_updated:
|
|
245
|
+
return ConflictSignal.SERVED_STALE, has_initial, has_updated
|
|
246
|
+
|
|
247
|
+
# Neither value is present in the retrieved text.
|
|
248
|
+
if not query_result.records:
|
|
249
|
+
# The backend returned zero records for a capable, no-error call.
|
|
250
|
+
# This is a silent empty-success, not "no update primitive" --
|
|
251
|
+
# see ConflictSignal.EMPTY_OR_LOST for the distinction.
|
|
252
|
+
return ConflictSignal.EMPTY_OR_LOST, has_initial, has_updated
|
|
253
|
+
|
|
254
|
+
# Records came back, just none of them matched either value as plain
|
|
255
|
+
# text. This used to collapse straight to NOT_APPLICABLE regardless of
|
|
256
|
+
# what the adapter reported (dead code -- see git history). It now
|
|
257
|
+
# genuinely differentiates on concrete, per-record adapter metadata: a
|
|
258
|
+
# bi-temporal backend can still have preserved the old fact even when
|
|
259
|
+
# neither value's literal text matched.
|
|
260
|
+
if has_invalidation_metadata:
|
|
261
|
+
return ConflictSignal.FLAGGED, has_initial, has_updated
|
|
262
|
+
return ConflictSignal.NOT_APPLICABLE, has_initial, has_updated
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _edge_endpoints_missing(record: object) -> bool:
|
|
266
|
+
"""True if `record.raw` is edge-shaped (carries both a
|
|
267
|
+
`source_node_uuid` and a `target_node_uuid` key -- the property names
|
|
268
|
+
graphiti_core's `EntityEdge` writes on every relationship, see
|
|
269
|
+
`zep_graphiti_selfhosted_adapter.py`) but at least one of those two
|
|
270
|
+
values is missing or falsy. Records whose `raw` fragment has neither
|
|
271
|
+
key at all (i.e. this backend doesn't model edges, or this record
|
|
272
|
+
isn't edge-shaped) never trigger this -- it is a structural check on
|
|
273
|
+
edge-shaped records only, not a generic "does this record have an id"
|
|
274
|
+
heuristic.
|
|
275
|
+
"""
|
|
276
|
+
raw = getattr(record, "raw", None)
|
|
277
|
+
if not isinstance(raw, dict):
|
|
278
|
+
return False
|
|
279
|
+
if "source_node_uuid" not in raw and "target_node_uuid" not in raw:
|
|
280
|
+
return False
|
|
281
|
+
return not raw.get("source_node_uuid") or not raw.get("target_node_uuid")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def run_contradiction_eval(
|
|
285
|
+
adapter: MemoryBackendAdapter,
|
|
286
|
+
dataset_path: Path | str = DEFAULT_FIXTURE_PATH,
|
|
287
|
+
) -> ContradictionEvalResult:
|
|
288
|
+
cases = load_dataset(dataset_path)
|
|
289
|
+
result = ContradictionEvalResult(backend_name=adapter.name, dataset_path=str(dataset_path))
|
|
290
|
+
|
|
291
|
+
if not adapter.supports_update:
|
|
292
|
+
for case in cases:
|
|
293
|
+
result.case_results.append(
|
|
294
|
+
ContradictionCaseResult(
|
|
295
|
+
case=case,
|
|
296
|
+
signal=ConflictSignal.NOT_APPLICABLE,
|
|
297
|
+
adapter_reported_signal=None,
|
|
298
|
+
contains_initial_value=False,
|
|
299
|
+
contains_updated_value=False,
|
|
300
|
+
retrieved_content="",
|
|
301
|
+
error=None,
|
|
302
|
+
)
|
|
303
|
+
)
|
|
304
|
+
return result
|
|
305
|
+
|
|
306
|
+
for case in cases:
|
|
307
|
+
try:
|
|
308
|
+
# `metadata` carries the case's optional non-fact structured
|
|
309
|
+
# properties (see ContradictionCase.metadata) through to the
|
|
310
|
+
# adapter's own store() -- empty dict for every pre-existing
|
|
311
|
+
# case, so `metadata or None` preserves every adapter's
|
|
312
|
+
# existing no-metadata call shape exactly.
|
|
313
|
+
store_result = adapter.store(
|
|
314
|
+
case.session_id, case.initial_fact, metadata=case.metadata or None
|
|
315
|
+
)
|
|
316
|
+
adapter.update(case.session_id, store_result.memory_id, case.contradicting_fact)
|
|
317
|
+
query_result = adapter.query(case.session_id, case.query, top_k=5)
|
|
318
|
+
except BackendAPIError as exc:
|
|
319
|
+
result.case_results.append(
|
|
320
|
+
ContradictionCaseResult(
|
|
321
|
+
case=case,
|
|
322
|
+
signal=ConflictSignal.NOT_APPLICABLE,
|
|
323
|
+
adapter_reported_signal=None,
|
|
324
|
+
contains_initial_value=False,
|
|
325
|
+
contains_updated_value=False,
|
|
326
|
+
retrieved_content="",
|
|
327
|
+
error=str(exc),
|
|
328
|
+
)
|
|
329
|
+
)
|
|
330
|
+
continue
|
|
331
|
+
|
|
332
|
+
final_signal, has_initial, has_updated = classify_case(case, query_result)
|
|
333
|
+
result.case_results.append(
|
|
334
|
+
ContradictionCaseResult(
|
|
335
|
+
case=case,
|
|
336
|
+
signal=final_signal,
|
|
337
|
+
adapter_reported_signal=query_result.conflict_signal,
|
|
338
|
+
contains_initial_value=has_initial,
|
|
339
|
+
contains_updated_value=has_updated,
|
|
340
|
+
retrieved_content=" ".join(r.content for r in query_result.records),
|
|
341
|
+
)
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
return result
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def run_add_only_contradiction_eval(
|
|
348
|
+
adapter: MemoryBackendAdapter,
|
|
349
|
+
dataset_path: Path | str = DEFAULT_FIXTURE_PATH,
|
|
350
|
+
) -> ContradictionEvalResult:
|
|
351
|
+
"""Drive each contradiction case through TWO plain `store()` calls, with
|
|
352
|
+
no explicit `update()` call and no `memory_id` threaded between them --
|
|
353
|
+
reusing `classify_case()`/`ConflictSignal` completely unchanged.
|
|
354
|
+
|
|
355
|
+
Motivating case: NDNM1408 (rank 114), mem0ai/mem0#4956. mem0 v3's
|
|
356
|
+
`add()` became a single-pass, ADD-only extraction pipeline with no
|
|
357
|
+
internal UPDATE/DELETE decision for a mutable-state fact (confirmed by
|
|
358
|
+
reading the installed `mem0ai==2.0.12` package's own
|
|
359
|
+
`Memory._add_to_vector_store()`, which the module docstring of
|
|
360
|
+
`mem0_direct_adapter.py` also cites for a different gap: Phase 3 of the
|
|
361
|
+
"V3 PHASED BATCH PIPELINE" batch-embeds and stores every LLM-extracted
|
|
362
|
+
fact with no code path that deletes or supersedes an existing, now-
|
|
363
|
+
contradicted vector). Old and new values for the same mutable fact can
|
|
364
|
+
therefore coexist in the vector store, and a later query can surface
|
|
365
|
+
either one -- including the stale one -- depending on ranking, not on
|
|
366
|
+
which is current.
|
|
367
|
+
|
|
368
|
+
`run_contradiction_eval()` above always drives the contradiction
|
|
369
|
+
through an explicit `adapter.update(session_id, memory_id, content)`
|
|
370
|
+
call, using the `memory_id` the first `store()` call returned -- the
|
|
371
|
+
exact "overwrite this specific record" primitive that structurally
|
|
372
|
+
bypasses the `add()`-time pipeline #4956's bug actually lives in (a
|
|
373
|
+
caller that always calls a targeted update-by-id will never exercise
|
|
374
|
+
mem0's own ADD-only extraction/dedup decision at all). This function
|
|
375
|
+
exists specifically to exercise that pipeline instead: it never calls
|
|
376
|
+
`update()` and never threads a `memory_id` from the first `store()`
|
|
377
|
+
call into the second, so a backend whose only conflict-resolution
|
|
378
|
+
logic lives inside its own explicit update()/upsert()-by-id code path
|
|
379
|
+
(and not inside `add()` itself) can show a genuinely different signal
|
|
380
|
+
here than under `run_contradiction_eval()` -- see
|
|
381
|
+
`tests/test_evals.py::test_add_only_contradiction_eval_reproduces_ndnm1408_stale_shape`
|
|
382
|
+
for a fake-adapter reproduction of exactly this divergence.
|
|
383
|
+
|
|
384
|
+
Same taxonomy, same `classify_case()` logic, same `ConflictSignal`
|
|
385
|
+
enum as `run_contradiction_eval()` above -- only the call sequence
|
|
386
|
+
differs. No adapter change and no new signal member were needed to
|
|
387
|
+
close this gap; `ConflictSignal.SERVED_STALE` already models exactly
|
|
388
|
+
the "a query can surface the stale one" failure shape #4956 reports,
|
|
389
|
+
it was just never reachable via this call sequence before.
|
|
390
|
+
|
|
391
|
+
Gated on `adapter.supports_update` the same way `run_contradiction_eval()`
|
|
392
|
+
is: a backend with no update/contradiction-relevant primitive at all has
|
|
393
|
+
nothing meaningful for this eval to observe either, and reports
|
|
394
|
+
`NOT_APPLICABLE` for the same reason -- this eval still measures the
|
|
395
|
+
backend's `add()`-time conflict behavior, which only makes sense to
|
|
396
|
+
score for backends this harness otherwise treats as contradiction-
|
|
397
|
+
capable.
|
|
398
|
+
"""
|
|
399
|
+
cases = load_dataset(dataset_path)
|
|
400
|
+
result = ContradictionEvalResult(backend_name=adapter.name, dataset_path=str(dataset_path))
|
|
401
|
+
|
|
402
|
+
if not adapter.supports_update:
|
|
403
|
+
for case in cases:
|
|
404
|
+
result.case_results.append(
|
|
405
|
+
ContradictionCaseResult(
|
|
406
|
+
case=case,
|
|
407
|
+
signal=ConflictSignal.NOT_APPLICABLE,
|
|
408
|
+
adapter_reported_signal=None,
|
|
409
|
+
contains_initial_value=False,
|
|
410
|
+
contains_updated_value=False,
|
|
411
|
+
retrieved_content="",
|
|
412
|
+
error=None,
|
|
413
|
+
)
|
|
414
|
+
)
|
|
415
|
+
return result
|
|
416
|
+
|
|
417
|
+
for case in cases:
|
|
418
|
+
try:
|
|
419
|
+
# Two plain store() calls -- no update(), no memory_id threaded
|
|
420
|
+
# from the first call into the second. See this function's
|
|
421
|
+
# docstring above for exactly why this call sequence matters.
|
|
422
|
+
adapter.store(case.session_id, case.initial_fact, metadata=case.metadata or None)
|
|
423
|
+
adapter.store(case.session_id, case.contradicting_fact, metadata=case.metadata or None)
|
|
424
|
+
query_result = adapter.query(case.session_id, case.query, top_k=5)
|
|
425
|
+
except BackendAPIError as exc:
|
|
426
|
+
result.case_results.append(
|
|
427
|
+
ContradictionCaseResult(
|
|
428
|
+
case=case,
|
|
429
|
+
signal=ConflictSignal.NOT_APPLICABLE,
|
|
430
|
+
adapter_reported_signal=None,
|
|
431
|
+
contains_initial_value=False,
|
|
432
|
+
contains_updated_value=False,
|
|
433
|
+
retrieved_content="",
|
|
434
|
+
error=str(exc),
|
|
435
|
+
)
|
|
436
|
+
)
|
|
437
|
+
continue
|
|
438
|
+
|
|
439
|
+
final_signal, has_initial, has_updated = classify_case(case, query_result)
|
|
440
|
+
result.case_results.append(
|
|
441
|
+
ContradictionCaseResult(
|
|
442
|
+
case=case,
|
|
443
|
+
signal=final_signal,
|
|
444
|
+
adapter_reported_signal=query_result.conflict_signal,
|
|
445
|
+
contains_initial_value=has_initial,
|
|
446
|
+
contains_updated_value=has_updated,
|
|
447
|
+
retrieved_content=" ".join(r.content for r in query_result.records),
|
|
448
|
+
)
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
return result
|