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