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,303 @@
1
+ """MemTrust's ranking/relevance-quality eval.
2
+
3
+ `evals/contradiction.py`'s `ConflictSignal` taxonomy classifies whether
4
+ returned *content* is correct after a contradiction. This eval targets a
5
+ structurally different failure mode that `ConflictSignal` cannot see at
6
+ all: the returned content can be fully correct, and the backend can still
7
+ be silently broken at ORDERING it.
8
+
9
+ Motivating case: mempalace/mempalace#1733 (GitHub user Kartalops, found
10
+ while validating memtrust against real MemPalace usage). `mempalace/
11
+ layers.py`'s `Layer1.generate()` sorts drawers by `importance`/
12
+ `emotional_weight`/`weight`, but no ingest path in the real package ever
13
+ writes those keys -- confirmed 0/45,969 drawers on a real palace. So
14
+ `importance` silently defaults to a constant, the "ranked by importance"
15
+ sort degenerates to plain insertion order, and a `wake-up`/recall call
16
+ documented to return "high importance, recent" moments instead silently
17
+ returns the oldest moments first. Every individual returned drawer is a
18
+ real, correctly-stored memory -- there is no contradiction anywhere in
19
+ this bug, which is exactly why `ConflictSignal` structurally cannot flag
20
+ it: that taxonomy only classifies cases where a fact was stored, then
21
+ recontradicted, then queried. This bug has no contradiction step at all.
22
+
23
+ Design principle (same as evals/contradiction.py's classify_case and
24
+ evals/resource_sync_safety.py's classify_resource_sync_file): classifying
25
+ a case never blindly trusts what the adapter reports about its own
26
+ ranking behavior (`QueryResult.ranking_signal`, see adapters/base.py's
27
+ RankingSignal and adapters/mempalace_adapter.py's `_classify_ranking_signal`
28
+ for how an adapter derives that self-report). This eval owns the ground
29
+ truth for what order each case's records were stored in and what values
30
+ they carried for the case's declared `ranking_field`, and computes the
31
+ final signal from that directly:
32
+
33
+ * If the ranking field is missing from every returned record, or present
34
+ but carrying the identical value on every returned record, no real
35
+ per-record signal exists to have driven the order -- classified
36
+ MISSING_ORDERING_KEY regardless of what the adapter self-reports.
37
+ * If the field carries genuinely varied values, this eval checks whether
38
+ the actual returned order is sorted by descending value. If it is,
39
+ the field is a genuine ranking signal -- SIGNAL_DRIVEN. If it is not,
40
+ a real signal exists and the backend still is not ordering by it --
41
+ ORDER_INCONSISTENT, a distinct and equally worth-flagging bug from
42
+ MISSING_ORDERING_KEY.
43
+ * Fewer than 2 returned records, or zero records at all, gives this eval
44
+ nothing to compare -- NOT_APPLICABLE.
45
+
46
+ Honest limitation (see docs/methodology.md for the full write-up): this
47
+ eval can only ever prove "no real per-record signal was observed driving
48
+ this response's order." It cannot always distinguish "the backend
49
+ genuinely has no meaningful variation to rank by for this query" from
50
+ "the backend forgot to populate the ranking field" -- both produce the
51
+ identical observable symptom (every returned record shares one value).
52
+ MISSING_ORDERING_KEY is the correct, honest name for what is actually
53
+ detected: the *absence of a driving signal*, not a proven claim about the
54
+ backend's internal cause. Kartalops's mempalace#1733 finding (0/45,969
55
+ drawers with a real value, confirmed by direct inspection of a live
56
+ palace) is the strong form of this; this eval's black-box query-response
57
+ view alone would only ever justify the weaker, still-honest claim above.
58
+ """
59
+
60
+ from __future__ import annotations
61
+
62
+ import json
63
+ from dataclasses import dataclass, field
64
+ from pathlib import Path
65
+ from typing import Any
66
+
67
+ from memtrust.adapters.base import (
68
+ BackendAPIError,
69
+ MemoryBackendAdapter,
70
+ MemoryRecord,
71
+ QueryResult,
72
+ RankingSignal,
73
+ )
74
+
75
+ DEFAULT_FIXTURE_PATH = (
76
+ Path(__file__).resolve().parents[3] / "tests" / "fixtures" / "ranking_quality_cases.json"
77
+ )
78
+
79
+
80
+ @dataclass
81
+ class RankingQualitySeedRecord:
82
+ """One record to store before querying, in the order it appears in
83
+ the case's `records` list -- that storage order is this eval's ground
84
+ truth for "insertion order," which `classify_ranking_case` compares
85
+ the actual returned order against."""
86
+
87
+ content: str
88
+ metadata: dict[str, str] = field(default_factory=dict)
89
+
90
+
91
+ @dataclass
92
+ class RankingQualityCase:
93
+ case_id: str
94
+ session_id: str
95
+ query: str
96
+ ranking_field: str
97
+ """Which metadata key this case is testing as the ranking-relevant
98
+ field (e.g. "importance") -- see RankingSignal in adapters/base.py."""
99
+ records: list[RankingQualitySeedRecord]
100
+
101
+
102
+ @dataclass
103
+ class RankingQualityCaseResult:
104
+ case: RankingQualityCase
105
+ signal: RankingSignal
106
+ adapter_reported_signal: RankingSignal | None
107
+ field_values: list[float | None]
108
+ """The case's `ranking_field` value read off each returned record, in
109
+ returned order, parsed as float where possible (None where missing or
110
+ unparseable) -- the evidence `signal` above was computed from."""
111
+ matches_insertion_order: bool | None
112
+ """Whether the returned record order exactly matches the order the
113
+ case's records were stored in. None when this could not be determined
114
+ (e.g. returned memory_ids don't correspond to any stored id)."""
115
+ retrieved_content: str
116
+ error: str | None = None
117
+
118
+
119
+ @dataclass
120
+ class RankingQualityEvalResult:
121
+ backend_name: str
122
+ dataset_path: str
123
+ case_results: list[RankingQualityCaseResult] = field(default_factory=list)
124
+
125
+ @property
126
+ def scored_cases(self) -> list[RankingQualityCaseResult]:
127
+ return [c for c in self.case_results if c.error is None]
128
+
129
+ def _fraction(self, signal: RankingSignal) -> float | None:
130
+ scored = self.scored_cases
131
+ if not scored:
132
+ return None
133
+ matching = sum(1 for c in scored if c.signal == signal)
134
+ return matching / len(scored)
135
+
136
+ @property
137
+ def signal_driven_rate(self) -> float | None:
138
+ return self._fraction(RankingSignal.SIGNAL_DRIVEN)
139
+
140
+ @property
141
+ def missing_ordering_key_rate(self) -> float | None:
142
+ """Fraction of cases where no real per-record ranking signal was
143
+ observed -- the headline metric this eval exists to surface, and
144
+ the one that would have caught mempalace/mempalace#1733's exact
145
+ shape (0/45,969 drawers ever getting a real `importance` value)."""
146
+ return self._fraction(RankingSignal.MISSING_ORDERING_KEY)
147
+
148
+ @property
149
+ def order_inconsistent_rate(self) -> float | None:
150
+ return self._fraction(RankingSignal.ORDER_INCONSISTENT)
151
+
152
+ @property
153
+ def not_applicable_rate(self) -> float | None:
154
+ return self._fraction(RankingSignal.NOT_APPLICABLE)
155
+
156
+
157
+ def load_dataset(path: Path | str = DEFAULT_FIXTURE_PATH) -> list[RankingQualityCase]:
158
+ data = json.loads(Path(path).read_text())
159
+ cases: list[dict[str, Any]] = data["cases"]
160
+ return [
161
+ RankingQualityCase(
162
+ case_id=c["case_id"],
163
+ session_id=c["session_id"],
164
+ query=c["query"],
165
+ ranking_field=c["ranking_field"],
166
+ records=[
167
+ RankingQualitySeedRecord(
168
+ content=r["content"],
169
+ metadata=r.get("metadata", {}),
170
+ )
171
+ for r in c["records"]
172
+ ],
173
+ )
174
+ for c in cases
175
+ ]
176
+
177
+
178
+ def _parse_float(value: str | None) -> float | None:
179
+ if value is None:
180
+ return None
181
+ try:
182
+ return float(value)
183
+ except ValueError:
184
+ return None
185
+
186
+
187
+ def _field_value(case: RankingQualityCase, record: MemoryRecord) -> float | None:
188
+ """Read a case's declared `ranking_field` off one returned record.
189
+
190
+ `MemoryRecord.score` (adapters/base.py) is a dedicated, already-typed
191
+ dataclass field every mem0-backed adapter populates from its own
192
+ search response -- it is NOT part of `metadata` (a vendor-agnostic,
193
+ string-only dict this eval otherwise reads from). Before this change,
194
+ a fixture case declaring `ranking_field: "score"` would always read
195
+ `metadata.get("score")`, which is never populated by any adapter, so
196
+ every such case landed on MISSING_ORDERING_KEY unconditionally --
197
+ never actually testing real score-based ranking at all.
198
+
199
+ Motivating case: mem0ai/mem0#3144 (GitHub user Duguce, closed as
200
+ duplicate of #4453) -- mem0's local vector-store search `score` field
201
+ doesn't semantically make sense: most vector stores return raw
202
+ distance as "score" and mem0 does not invert it into similarity, so a
203
+ LESS-relevant memory can carry a HIGHER score. Reading `.score`
204
+ directly (case.ranking_field == "score") is what lets this eval
205
+ actually classify that shape -- SIGNAL_DRIVEN only when the returned
206
+ order is genuinely sorted by descending score, ORDER_INCONSISTENT when
207
+ a real (if semantically inverted) score value exists but the returned
208
+ order doesn't track it -- instead of silently treating every
209
+ score-labeled case as "no signal present" via the metadata-only read.
210
+ """
211
+ if case.ranking_field == "score":
212
+ return record.score
213
+ return _parse_float(record.metadata.get(case.ranking_field))
214
+
215
+
216
+ def classify_ranking_case(
217
+ case: RankingQualityCase,
218
+ query_result: QueryResult,
219
+ stored_ids_in_order: list[str],
220
+ ) -> tuple[RankingSignal, list[float | None], bool | None]:
221
+ """Classify a single case's outcome from the adapter's query response.
222
+
223
+ Returns (final_signal, field_values_in_returned_order,
224
+ matches_insertion_order). Never trusts
225
+ `query_result.ranking_signal` (the adapter's own self-report) as the
226
+ final answer -- see this module's docstring for why -- it is only
227
+ threaded through by the caller for transparency/comparison in the
228
+ result record.
229
+ """
230
+ records = query_result.records
231
+ field_values = [_field_value(case, r) for r in records]
232
+
233
+ matches_insertion_order: bool | None = None
234
+ if records and stored_ids_in_order:
235
+ returned_ids = [r.memory_id for r in records]
236
+ known_ids = [rid for rid in returned_ids if rid in stored_ids_in_order]
237
+ if known_ids:
238
+ expected = [rid for rid in stored_ids_in_order if rid in returned_ids]
239
+ matches_insertion_order = known_ids == expected
240
+
241
+ if len(records) < 2:
242
+ return RankingSignal.NOT_APPLICABLE, field_values, matches_insertion_order
243
+
244
+ present_values = [v for v in field_values if v is not None]
245
+
246
+ if len(present_values) < len(field_values) or len(set(present_values)) <= 1:
247
+ # The field is missing from at least one returned record, or is
248
+ # present everywhere but identical -- either way, no real
249
+ # per-record signal exists to have driven the order. This is the
250
+ # exact mempalace/mempalace#1733 shape.
251
+ return RankingSignal.MISSING_ORDERING_KEY, field_values, matches_insertion_order
252
+
253
+ is_sorted_descending = all(
254
+ a >= b for a, b in zip(present_values, present_values[1:], strict=False)
255
+ )
256
+ if is_sorted_descending:
257
+ return RankingSignal.SIGNAL_DRIVEN, field_values, matches_insertion_order
258
+ return RankingSignal.ORDER_INCONSISTENT, field_values, matches_insertion_order
259
+
260
+
261
+ def run_ranking_quality_eval(
262
+ adapter: MemoryBackendAdapter,
263
+ dataset_path: Path | str = DEFAULT_FIXTURE_PATH,
264
+ ) -> RankingQualityEvalResult:
265
+ cases = load_dataset(dataset_path)
266
+ result = RankingQualityEvalResult(backend_name=adapter.name, dataset_path=str(dataset_path))
267
+
268
+ for case in cases:
269
+ stored_ids: list[str] = []
270
+ try:
271
+ for seed in case.records:
272
+ store_result = adapter.store(case.session_id, seed.content, metadata=seed.metadata)
273
+ stored_ids.append(store_result.memory_id)
274
+ query_result = adapter.query(case.session_id, case.query, top_k=len(case.records))
275
+ except BackendAPIError as exc:
276
+ result.case_results.append(
277
+ RankingQualityCaseResult(
278
+ case=case,
279
+ signal=RankingSignal.NOT_APPLICABLE,
280
+ adapter_reported_signal=None,
281
+ field_values=[],
282
+ matches_insertion_order=None,
283
+ retrieved_content="",
284
+ error=str(exc),
285
+ )
286
+ )
287
+ continue
288
+
289
+ final_signal, field_values, matches_insertion_order = classify_ranking_case(
290
+ case, query_result, stored_ids
291
+ )
292
+ result.case_results.append(
293
+ RankingQualityCaseResult(
294
+ case=case,
295
+ signal=final_signal,
296
+ adapter_reported_signal=query_result.ranking_signal,
297
+ field_values=field_values,
298
+ matches_insertion_order=matches_insertion_order,
299
+ retrieved_content=" ".join(r.content for r in query_result.records),
300
+ )
301
+ )
302
+
303
+ return result
@@ -0,0 +1,336 @@
1
+ """MemTrust's directory/resource-sync safety eval.
2
+
3
+ The store/query/update model the other evals in this package exercise
4
+ only covers single-key memory operations. It has no concept of a
5
+ multi-file directory mirror/resync operation, so it cannot observe a
6
+ resync mechanism silently deleting user-owned files that a backend's
7
+ ingestion watcher did not itself generate.
8
+
9
+ This eval closes that specific gap. It is modeled directly on a real,
10
+ high-severity bug report: volcengine/OpenViking#3029, where OpenViking's
11
+ Feishu resync mechanism silently deleted user-owned files sitting
12
+ alongside the files its own ingestion watcher had generated. The eval
13
+ seeds a mix of "generated" files (standing in for what a watcher itself
14
+ would produce) and "user" files (standing in for files a person added to
15
+ the same resource path independently of the watcher) under one resource
16
+ prefix, triggers a resync, and re-lists the prefix to see what survived:
17
+
18
+ * PRESERVED -- the file is still present after the resync,
19
+ and (where content could be re-verified)
20
+ its content is unchanged.
21
+ * DELETED_USER_FILE -- the file was present before the resync and
22
+ is gone afterward, with no error and no
23
+ signal to the caller. This is the exact
24
+ #3029 failure mode: a resync silently
25
+ destroying data it never wrote.
26
+ * OVERWRITTEN_UNCHANGED -- the path itself still exists after the
27
+ resync (existence "unchanged"), but its
28
+ content no longer matches what was seeded,
29
+ i.e. the resync silently overwrote it
30
+ rather than deleting it outright.
31
+ * NESTED_CONTENT_UNINDEXED -- the path itself still exists after the
32
+ resync (list_resource_paths() reports it
33
+ present) and a query() was actually issued,
34
+ but the search index never returned a
35
+ record for that path at all -- distinct
36
+ from OVERWRITTEN_UNCHANGED, which means a
37
+ record for the path *was* found but its
38
+ content had changed. This is the
39
+ volcengine/OpenViking#1703 failure mode:
40
+ index_resource() skipped every subdirectory
41
+ during reindex, so nested-directory content
42
+ was never vectorized in the first place --
43
+ "never indexed," not "deleted" or
44
+ "overwritten." A file at a top-level path
45
+ (no "/" in its resource path) failing this
46
+ way would just look like a generic search
47
+ miss; this signal is specifically for paths
48
+ that are nested, where "never indexed" has
49
+ a concrete, reproducible mechanism to point
50
+ to.
51
+
52
+ Optional/flagged capability: this eval only runs against adapters that
53
+ set MemoryBackendAdapter.supports_resource_sync = True. Adapters without
54
+ a directory/resource-mirror concept (the store/query/update-only model)
55
+ are skipped cleanly -- reported as skipped, never silently dropped from
56
+ the results table and never crashed by calling an unimplemented method.
57
+
58
+ Design principle (see evals/contradiction.py's classify_case for the same
59
+ pattern applied to contradiction detection): classification never blindly
60
+ trusts that a seeded file is "fine" just because the adapter didn't error. Each file's
61
+ final signal is derived from the actual before/after path listings (and,
62
+ where possible, a re-query of the file's own content) -- not from any
63
+ self-report the adapter makes about the resync succeeding.
64
+ """
65
+
66
+ from __future__ import annotations
67
+
68
+ import json
69
+ from dataclasses import dataclass, field
70
+ from enum import StrEnum
71
+ from pathlib import Path
72
+ from typing import Any
73
+
74
+ from memtrust.adapters.base import BackendAPIError, MemoryBackendAdapter
75
+
76
+ DEFAULT_FIXTURE_PATH = (
77
+ Path(__file__).resolve().parents[3] / "tests" / "fixtures" / "resource_sync_cases.json"
78
+ )
79
+
80
+
81
+ class ResourceSyncSignal(StrEnum):
82
+ """How one seeded file fared across a trigger_resync() call."""
83
+
84
+ PRESERVED = "preserved"
85
+ """Present after the resync; content re-verified as unchanged (or the
86
+ adapter offered no way to re-verify content, in which case presence
87
+ alone is treated as preserved)."""
88
+
89
+ DELETED_USER_FILE = "deleted_user_file"
90
+ """Present before the resync, gone afterward, with no error raised.
91
+ The exact volcengine/OpenViking#3029 failure mode."""
92
+
93
+ OVERWRITTEN_UNCHANGED = "overwritten_unchanged"
94
+ """Still present after the resync, but its content no longer matches
95
+ what was seeded -- silently overwritten rather than deleted."""
96
+
97
+ NESTED_CONTENT_UNINDEXED = "nested_content_unindexed"
98
+ """Still present after the resync (the path itself was not deleted or
99
+ overwritten), but no query() call ever returned a record for that
100
+ path at all -- the content exists on disk/in the filesystem mirror
101
+ but was never indexed for search. This is "never indexed," not
102
+ "deleted": the exact volcengine/OpenViking#1703 failure mode, where
103
+ a reindex's directory walk skipped every subdirectory and left
104
+ nested content permanently unsearchable with no error raised."""
105
+
106
+ NOT_APPLICABLE = "not_applicable"
107
+ """Either the backend has no resource-sync primitive this eval can
108
+ exercise (MemoryBackendAdapter.supports_resource_sync is False), or a
109
+ file's before/after state could not be established at all (e.g. it
110
+ was never observed present before the resync in the first place)."""
111
+
112
+
113
+ @dataclass
114
+ class ResourceSyncSeedFile:
115
+ path_suffix: str
116
+ origin: str # "generated" | "user"
117
+ content: str
118
+
119
+
120
+ @dataclass
121
+ class ResourceSyncCase:
122
+ case_id: str
123
+ prefix: str
124
+ seed_files: list[ResourceSyncSeedFile]
125
+
126
+
127
+ @dataclass
128
+ class ResourceSyncFileResult:
129
+ case_id: str
130
+ path_suffix: str
131
+ origin: str
132
+ stored_path: str | None
133
+ present_before_resync: bool
134
+ present_after_resync: bool
135
+ content_matches_after_resync: bool | None
136
+ indexed_after_resync: bool | None
137
+ signal: ResourceSyncSignal
138
+ error: str | None = None
139
+
140
+
141
+ @dataclass
142
+ class ResourceSyncEvalResult:
143
+ backend_name: str
144
+ dataset_path: str
145
+ file_results: list[ResourceSyncFileResult] = field(default_factory=list)
146
+ skipped: bool = False
147
+ skip_reason: str | None = None
148
+
149
+ @property
150
+ def scored_files(self) -> list[ResourceSyncFileResult]:
151
+ return [f for f in self.file_results if f.error is None]
152
+
153
+ def _fraction(self, signal: ResourceSyncSignal, origin: str | None = None) -> float | None:
154
+ scored = self.scored_files
155
+ if origin is not None:
156
+ scored = [f for f in scored if f.origin == origin]
157
+ if not scored:
158
+ return None
159
+ matching = sum(1 for f in scored if f.signal == signal)
160
+ return matching / len(scored)
161
+
162
+ @property
163
+ def user_file_deletion_rate(self) -> float | None:
164
+ """Fraction of user-origin seed files silently deleted by a
165
+ resync -- the headline metric this eval exists to surface."""
166
+ return self._fraction(ResourceSyncSignal.DELETED_USER_FILE, origin="user")
167
+
168
+ @property
169
+ def preserved_rate(self) -> float | None:
170
+ return self._fraction(ResourceSyncSignal.PRESERVED)
171
+
172
+ @property
173
+ def overwritten_unchanged_rate(self) -> float | None:
174
+ return self._fraction(ResourceSyncSignal.OVERWRITTEN_UNCHANGED)
175
+
176
+ @property
177
+ def nested_content_unindexed_rate(self) -> float | None:
178
+ """Fraction of seed files that survived the resync on disk but
179
+ were never returned by any query() call -- the volcengine/
180
+ OpenViking#1703 signal, distinct from deletion or overwrite."""
181
+ return self._fraction(ResourceSyncSignal.NESTED_CONTENT_UNINDEXED)
182
+
183
+
184
+ def load_dataset(path: Path | str = DEFAULT_FIXTURE_PATH) -> list[ResourceSyncCase]:
185
+ data = json.loads(Path(path).read_text())
186
+ cases: list[dict[str, Any]] = data["cases"]
187
+ return [
188
+ ResourceSyncCase(
189
+ case_id=c["case_id"],
190
+ prefix=c["prefix"],
191
+ seed_files=[
192
+ ResourceSyncSeedFile(
193
+ path_suffix=sf["path_suffix"],
194
+ origin=sf["origin"],
195
+ content=sf["content"],
196
+ )
197
+ for sf in c["seed_files"]
198
+ ],
199
+ )
200
+ for c in cases
201
+ ]
202
+
203
+
204
+ def classify_resource_sync_file(
205
+ present_before: bool,
206
+ present_after: bool,
207
+ content_matches_after_resync: bool | None,
208
+ indexed_after_resync: bool | None = None,
209
+ ) -> ResourceSyncSignal:
210
+ """Classify a single seeded file's outcome from its before/after
211
+ presence and (if re-verifiable) content/index match.
212
+
213
+ Returns DELETED_USER_FILE whenever a file that was confirmed present
214
+ before the resync is gone afterward -- deliberately independent of
215
+ whether the caller passes a "generated" or "user" origin here, since
216
+ the eval only computes rates *by* origin afterward (see
217
+ ResourceSyncEvalResult.user_file_deletion_rate); a generated file
218
+ disappearing unexpectedly is just as real a signal, it is simply not
219
+ the metric #3029 was about.
220
+
221
+ Returns NESTED_CONTENT_UNINDEXED whenever a file is still present
222
+ after the resync but `indexed_after_resync` is explicitly False --
223
+ meaning a query() call was actually made and returned no record at
224
+ all for this path, as opposed to returning a record with stale
225
+ content (OVERWRITTEN_UNCHANGED). `indexed_after_resync=None` (the
226
+ default) means the caller never attempted to distinguish "no record
227
+ found" from "record found with wrong content" -- existing callers
228
+ that only pass the first three arguments keep the prior behavior
229
+ unchanged (OVERWRITTEN_UNCHANGED wins on a content mismatch).
230
+
231
+ A file never observed present before the resync has nothing
232
+ meaningful to classify (its "before" state is unknown), so it is
233
+ recorded as NOT_APPLICABLE rather than guessed at either way.
234
+ """
235
+ if present_before and not present_after:
236
+ return ResourceSyncSignal.DELETED_USER_FILE
237
+ if present_after and indexed_after_resync is False:
238
+ return ResourceSyncSignal.NESTED_CONTENT_UNINDEXED
239
+ if present_after and content_matches_after_resync is False:
240
+ return ResourceSyncSignal.OVERWRITTEN_UNCHANGED
241
+ if present_after:
242
+ return ResourceSyncSignal.PRESERVED
243
+ return ResourceSyncSignal.NOT_APPLICABLE
244
+
245
+
246
+ def run_resource_sync_eval(
247
+ adapter: MemoryBackendAdapter,
248
+ dataset_path: Path | str = DEFAULT_FIXTURE_PATH,
249
+ ) -> ResourceSyncEvalResult:
250
+ cases = load_dataset(dataset_path)
251
+ result = ResourceSyncEvalResult(backend_name=adapter.name, dataset_path=str(dataset_path))
252
+
253
+ if not adapter.supports_resource_sync:
254
+ result.skipped = True
255
+ result.skip_reason = (
256
+ f"{adapter.name} does not support resource-sync operations "
257
+ "(supports_resource_sync=False) -- skipped, not run."
258
+ )
259
+ return result
260
+
261
+ for case in cases:
262
+ stored_paths: dict[str, str] = {}
263
+ try:
264
+ for seed in case.seed_files:
265
+ store_result = adapter.store(
266
+ case.prefix,
267
+ seed.content,
268
+ metadata={"resource_path": seed.path_suffix, "origin": seed.origin},
269
+ )
270
+ stored_paths[seed.path_suffix] = store_result.memory_id
271
+
272
+ paths_before = set(adapter.list_resource_paths(case.prefix))
273
+ adapter.trigger_resync(case.prefix)
274
+ paths_after = set(adapter.list_resource_paths(case.prefix))
275
+ except BackendAPIError as exc:
276
+ for seed in case.seed_files:
277
+ result.file_results.append(
278
+ ResourceSyncFileResult(
279
+ case_id=case.case_id,
280
+ path_suffix=seed.path_suffix,
281
+ origin=seed.origin,
282
+ stored_path=stored_paths.get(seed.path_suffix),
283
+ present_before_resync=False,
284
+ present_after_resync=False,
285
+ content_matches_after_resync=None,
286
+ indexed_after_resync=None,
287
+ signal=ResourceSyncSignal.NOT_APPLICABLE,
288
+ error=str(exc),
289
+ )
290
+ )
291
+ continue
292
+
293
+ for seed in case.seed_files:
294
+ stored_path = stored_paths[seed.path_suffix]
295
+ present_before = stored_path in paths_before
296
+ present_after = stored_path in paths_after
297
+
298
+ content_matches: bool | None = None
299
+ indexed_after: bool | None = None
300
+ if present_after:
301
+ try:
302
+ query_result = adapter.query(case.prefix, seed.content, top_k=5)
303
+ # Two distinct questions, not one: did the search index
304
+ # return *any* record for this exact path at all
305
+ # (indexed_after -- the #1703 signal), and separately,
306
+ # among whatever records did come back, does any one's
307
+ # content match what was seeded (content_matches -- the
308
+ # #3029-era overwrite signal). Collapsing these into one
309
+ # boolean would make "never indexed" indistinguishable
310
+ # from "indexed with stale content."
311
+ indexed_after = any(r.memory_id == stored_path for r in query_result.records)
312
+ content_matches = any(
313
+ seed.content.lower() in r.content.lower() for r in query_result.records
314
+ )
315
+ except BackendAPIError:
316
+ content_matches = None
317
+ indexed_after = None
318
+
319
+ signal = classify_resource_sync_file(
320
+ present_before, present_after, content_matches, indexed_after
321
+ )
322
+ result.file_results.append(
323
+ ResourceSyncFileResult(
324
+ case_id=case.case_id,
325
+ path_suffix=seed.path_suffix,
326
+ origin=seed.origin,
327
+ stored_path=stored_path,
328
+ present_before_resync=present_before,
329
+ present_after_resync=present_after,
330
+ content_matches_after_resync=content_matches,
331
+ indexed_after_resync=indexed_after,
332
+ signal=signal,
333
+ )
334
+ )
335
+
336
+ return result