memtrust-cli 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. memtrust/__init__.py +11 -0
  2. memtrust/adapters/__init__.py +62 -0
  3. memtrust/adapters/base.py +2020 -0
  4. memtrust/adapters/mem0_adapter.py +456 -0
  5. memtrust/adapters/mem0_direct_adapter.py +1217 -0
  6. memtrust/adapters/mempalace_adapter.py +1166 -0
  7. memtrust/adapters/openviking_adapter.py +570 -0
  8. memtrust/adapters/zep_graphiti_adapter.py +181 -0
  9. memtrust/adapters/zep_graphiti_selfhosted_adapter.py +882 -0
  10. memtrust/cli.py +1201 -0
  11. memtrust/evals/__init__.py +5 -0
  12. memtrust/evals/compression.py +235 -0
  13. memtrust/evals/contradiction.py +451 -0
  14. memtrust/evals/crash_recovery.py +290 -0
  15. memtrust/evals/embedder_cost.py +163 -0
  16. memtrust/evals/embedding_drift.py +273 -0
  17. memtrust/evals/episode_temporal_leak.py +182 -0
  18. memtrust/evals/extraction_quality.py +373 -0
  19. memtrust/evals/filter_injection.py +371 -0
  20. memtrust/evals/language_degradation.py +189 -0
  21. memtrust/evals/lock_contention.py +263 -0
  22. memtrust/evals/locomo.py +392 -0
  23. memtrust/evals/longmemeval.py +249 -0
  24. memtrust/evals/mempalace_metadata_scale.py +494 -0
  25. memtrust/evals/migration_rollback.py +276 -0
  26. memtrust/evals/orphan_cleanup.py +235 -0
  27. memtrust/evals/ranking_quality.py +303 -0
  28. memtrust/evals/resource_sync_safety.py +336 -0
  29. memtrust/evals/result_consistency.py +239 -0
  30. memtrust/evals/scale_fixtures.py +189 -0
  31. memtrust/evals/scale_stress.py +502 -0
  32. memtrust/evals/stats_accuracy.py +240 -0
  33. memtrust/evals/temporal_kg_boundary.py +281 -0
  34. memtrust/receipt.py +318 -0
  35. memtrust/scoring/__init__.py +1 -0
  36. memtrust/scoring/cost_tracker.py +199 -0
  37. memtrust/scoring/llm_judge.py +161 -0
  38. memtrust_cli-0.3.0.dist-info/METADATA +624 -0
  39. memtrust_cli-0.3.0.dist-info/RECORD +42 -0
  40. memtrust_cli-0.3.0.dist-info/WHEEL +4 -0
  41. memtrust_cli-0.3.0.dist-info/entry_points.txt +2 -0
  42. memtrust_cli-0.3.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,5 @@
1
+ """Eval runners. Each module runs one eval family against any
2
+ MemoryBackendAdapter through the shared store()/query()/update()
3
+ interface, so scoring logic is written once and applied identically
4
+ across every tracked backend.
5
+ """
@@ -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