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,276 @@
|
|
|
1
|
+
"""MemTrust's migration-rollback-safety eval.
|
|
2
|
+
|
|
3
|
+
None of the other evals in this package can see what happens to a
|
|
4
|
+
backend's own data across a storage-format/version MIGRATION -- they all
|
|
5
|
+
exercise a single, already-migrated adapter instance talking to a single,
|
|
6
|
+
stable storage layout. This eval closes that specific, different gap.
|
|
7
|
+
|
|
8
|
+
Motivating case: MemPalace/mempalace#1028 (GitHub user eldar702). MemPalace
|
|
9
|
+
ships its own `migrate.migrate()` function that, at the end of a migration,
|
|
10
|
+
swaps a newly-written palace directory into place over the old one. The
|
|
11
|
+
reported version of that swap was unguarded: `shutil.rmtree()` deleted the
|
|
12
|
+
old backup FIRST, then `shutil.move()` moved the new data into place. If
|
|
13
|
+
the `move()` step failed partway through -- e.g. a cross-device `EXDEV`
|
|
14
|
+
error, which `shutil.move()` can raise when the source and destination
|
|
15
|
+
straddle a filesystem boundary -- the palace directory could be
|
|
16
|
+
permanently lost: the old backup was already gone, and the new data never
|
|
17
|
+
finished landing either. MemPalace/mempalace#935 is the real upstream fix:
|
|
18
|
+
a "rename-aside" swap that renames the new data into place first, keeps
|
|
19
|
+
the old backup renamed-aside (not deleted), and only deletes the backup
|
|
20
|
+
after independently confirming the swap succeeded. This eval exists to
|
|
21
|
+
verify the CONCEPT that fix pattern establishes -- rename-aside preserves
|
|
22
|
+
recoverability across a failed swap, unguarded rmtree-then-move does not
|
|
23
|
+
-- not to reproduce PR #935's specific merged diff line-for-line.
|
|
24
|
+
|
|
25
|
+
**Honest scope of what this eval can and cannot prove.** memtrust's
|
|
26
|
+
adapters have zero direct filesystem control over a live vendor package's
|
|
27
|
+
internal migration code path -- MemPalaceAdapter, the one adapter this
|
|
28
|
+
concept is scoped to (MemPalace is the only local-storage-path backend in
|
|
29
|
+
this repo; mem0/zep/openviking have no on-disk "migrate" concept a
|
|
30
|
+
migration-swap eval could exercise at all), only wraps whatever the
|
|
31
|
+
installed `mempalace` package's own remember()/recall()/invalidate()
|
|
32
|
+
methods do internally via `_get_palace()` -- see mempalace_adapter.py's
|
|
33
|
+
module docstring. There is no live MemPalace instance this harness runs a
|
|
34
|
+
real migration against, and no way to interrupt a real `shutil.move()`
|
|
35
|
+
call mid-flight from outside the vendor's own process. So this eval does
|
|
36
|
+
not, and cannot, reproduce #1028 against a live MemPalace instance.
|
|
37
|
+
Instead it targets the STRUCTURAL failure shape directly: it calls an
|
|
38
|
+
explicit `adapter.simulate_migration_failure(session_id, content)` (a
|
|
39
|
+
named simulation primitive, never a real filesystem fault injection -- see
|
|
40
|
+
MemoryBackendAdapter.supports_migration_rollback_simulation), which stores
|
|
41
|
+
`content` as the original pre-migration data and simulates the swap being
|
|
42
|
+
interrupted before its final commit step, then independently re-queries
|
|
43
|
+
for that same content afterward to see whether it survived. Only a
|
|
44
|
+
purpose-built in-memory fake adapter can genuinely model both the buggy
|
|
45
|
+
shape and the fixed shape today -- see
|
|
46
|
+
tests/test_evals.py::MigrationRollbackFakeAdapter and
|
|
47
|
+
MigrationRollbackRenameAsideFakeAdapter. MemPalaceAdapter itself does NOT
|
|
48
|
+
set supports_migration_rollback_simulation = True and reports
|
|
49
|
+
NOT_APPLICABLE / skipped, same as every real adapter in this repo reports
|
|
50
|
+
for crash_recovery.py's equivalent capability flag. See
|
|
51
|
+
docs/methodology.md for the full write-up of what this does and does not
|
|
52
|
+
close.
|
|
53
|
+
|
|
54
|
+
Classification produces one of:
|
|
55
|
+
|
|
56
|
+
* RESTORED -- an independent post-failure query() call (made by
|
|
57
|
+
this eval, not the adapter's own self-report)
|
|
58
|
+
still finds the original pre-migration content.
|
|
59
|
+
The safe outcome: MemPalace/mempalace#935's
|
|
60
|
+
rename-aside pattern, where the old backup is kept
|
|
61
|
+
renamed-aside and only deleted after the new data
|
|
62
|
+
is confirmed in place, so a failure partway
|
|
63
|
+
through the swap leaves the original data intact.
|
|
64
|
+
* DATA_LOST -- the same independent post-failure query() call
|
|
65
|
+
finds nothing. The exact MemPalace/mempalace#1028
|
|
66
|
+
shape: the old backup was already deleted before
|
|
67
|
+
the move step failed, so there is nothing left to
|
|
68
|
+
recover.
|
|
69
|
+
* NOT_APPLICABLE -- either the adapter has no migration-rollback
|
|
70
|
+
simulation capability at all
|
|
71
|
+
(supports_migration_rollback_simulation is False
|
|
72
|
+
-- the eval is skipped, not run), or a
|
|
73
|
+
BackendAPIError was raised before the post-failure
|
|
74
|
+
observation could be made for this case.
|
|
75
|
+
|
|
76
|
+
Design principle (same as evals/crash_recovery.py's
|
|
77
|
+
classify_crash_recovery_case and evals/ranking_quality.py's
|
|
78
|
+
classify_ranking_case): classification never blindly trusts a single
|
|
79
|
+
self-reported value. MigrationFailureResult.original_data_recoverable
|
|
80
|
+
(adapters/base.py) is the adapter's OWN observation and is kept in each
|
|
81
|
+
case result purely as a diagnostic/cross-check field -- the actual
|
|
82
|
+
RESTORED/DATA_LOST classification is always derived from this eval's own,
|
|
83
|
+
independent query() call made after simulate_migration_failure() returns.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
from __future__ import annotations
|
|
87
|
+
|
|
88
|
+
import json
|
|
89
|
+
from dataclasses import dataclass, field
|
|
90
|
+
from enum import StrEnum
|
|
91
|
+
from pathlib import Path
|
|
92
|
+
from typing import Any
|
|
93
|
+
|
|
94
|
+
from memtrust.adapters.base import BackendAPIError, MemoryBackendAdapter
|
|
95
|
+
|
|
96
|
+
DEFAULT_FIXTURE_PATH = (
|
|
97
|
+
Path(__file__).resolve().parents[3] / "tests" / "fixtures" / "migration_rollback_cases.json"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class MigrationRollbackSignal(StrEnum):
|
|
102
|
+
"""How one piece of ORIGINAL pre-migration data fared across a
|
|
103
|
+
simulated mid-migration failure.
|
|
104
|
+
|
|
105
|
+
Defined locally in this module rather than in adapters/base.py,
|
|
106
|
+
following the same precedent evals/crash_recovery.py's
|
|
107
|
+
CrashRecoverySignal and evals/scale_stress.py's ScaleSignal already
|
|
108
|
+
set: this is a harness-computed classification derived from ground
|
|
109
|
+
truth (an independent post-failure query() observation), not a signal
|
|
110
|
+
any adapter self-reports.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
RESTORED = "restored"
|
|
114
|
+
"""An independent post-failure query() call (made by this eval, not
|
|
115
|
+
the adapter's own self-report) still finds the original pre-migration
|
|
116
|
+
content. The safe outcome -- MemPalace/mempalace#935's rename-aside
|
|
117
|
+
swap pattern: the old backup is kept renamed-aside and only deleted
|
|
118
|
+
after the new data is confirmed in place, so a failure partway
|
|
119
|
+
through the swap (e.g. a cross-device EXDEV error on the move step)
|
|
120
|
+
leaves the original data recoverable."""
|
|
121
|
+
|
|
122
|
+
DATA_LOST = "data_lost"
|
|
123
|
+
"""The same independent post-failure query() call finds nothing. The
|
|
124
|
+
exact MemPalace/mempalace#1028 shape (GitHub user eldar702): an
|
|
125
|
+
unguarded shutil.rmtree()-then-shutil.move() swap deletes the old
|
|
126
|
+
backup FIRST, so if the move step fails partway the palace directory
|
|
127
|
+
is permanently lost -- there is no backup left to fall back to."""
|
|
128
|
+
|
|
129
|
+
NOT_APPLICABLE = "not_applicable"
|
|
130
|
+
"""Either the adapter has no migration-rollback-simulation capability
|
|
131
|
+
(MemoryBackendAdapter.supports_migration_rollback_simulation is False
|
|
132
|
+
-- the eval is skipped entirely, not run per-case), or a
|
|
133
|
+
BackendAPIError was raised before the post-failure observation could
|
|
134
|
+
be made for this case."""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class MigrationRollbackCase:
|
|
139
|
+
case_id: str
|
|
140
|
+
session_id: str
|
|
141
|
+
content: str
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass
|
|
145
|
+
class MigrationRollbackCaseResult:
|
|
146
|
+
case: MigrationRollbackCase
|
|
147
|
+
signal: MigrationRollbackSignal
|
|
148
|
+
original_data_recoverable: bool | None
|
|
149
|
+
"""Ground truth this eval's classification is actually based on: the
|
|
150
|
+
result of this eval's own independent query() call, made after
|
|
151
|
+
simulate_migration_failure() returns, for the original content. None
|
|
152
|
+
only when a BackendAPIError was raised before this observation could
|
|
153
|
+
be made."""
|
|
154
|
+
adapter_reported_recoverable: bool | None = None
|
|
155
|
+
"""The adapter's own self-reported
|
|
156
|
+
MigrationFailureResult.original_data_recoverable flag (adapters/
|
|
157
|
+
base.py), kept for diagnostic/cross-check purposes only -- never used
|
|
158
|
+
as the classification's ground truth. Same convention as
|
|
159
|
+
evals/ranking_quality.py's adapter_reported_signal field: threaded
|
|
160
|
+
through for transparency/comparison, not trusted outright. None when
|
|
161
|
+
a BackendAPIError was raised before simulate_migration_failure() could
|
|
162
|
+
return a result."""
|
|
163
|
+
error: str | None = None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass
|
|
167
|
+
class MigrationRollbackEvalResult:
|
|
168
|
+
backend_name: str
|
|
169
|
+
dataset_path: str
|
|
170
|
+
case_results: list[MigrationRollbackCaseResult] = field(default_factory=list)
|
|
171
|
+
skipped: bool = False
|
|
172
|
+
skip_reason: str | None = None
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def scored_cases(self) -> list[MigrationRollbackCaseResult]:
|
|
176
|
+
return [c for c in self.case_results if c.error is None]
|
|
177
|
+
|
|
178
|
+
def _fraction(self, signal: MigrationRollbackSignal) -> float | None:
|
|
179
|
+
scored = self.scored_cases
|
|
180
|
+
if not scored:
|
|
181
|
+
return None
|
|
182
|
+
matching = sum(1 for c in scored if c.signal == signal)
|
|
183
|
+
return matching / len(scored)
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def restored_rate(self) -> float | None:
|
|
187
|
+
return self._fraction(MigrationRollbackSignal.RESTORED)
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def data_lost_rate(self) -> float | None:
|
|
191
|
+
"""Fraction of cases where the simulated mid-migration failure
|
|
192
|
+
permanently lost the original data -- the headline metric this
|
|
193
|
+
eval exists to surface, and the exact MemPalace/mempalace#1028
|
|
194
|
+
shape."""
|
|
195
|
+
return self._fraction(MigrationRollbackSignal.DATA_LOST)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def load_dataset(path: Path | str = DEFAULT_FIXTURE_PATH) -> list[MigrationRollbackCase]:
|
|
199
|
+
data = json.loads(Path(path).read_text())
|
|
200
|
+
cases: list[dict[str, Any]] = data["cases"]
|
|
201
|
+
return [
|
|
202
|
+
MigrationRollbackCase(
|
|
203
|
+
case_id=c["case_id"],
|
|
204
|
+
session_id=c["session_id"],
|
|
205
|
+
content=c["content"],
|
|
206
|
+
)
|
|
207
|
+
for c in cases
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def classify_migration_rollback_case(
|
|
212
|
+
original_data_recoverable_after_failure: bool,
|
|
213
|
+
) -> MigrationRollbackSignal:
|
|
214
|
+
"""Classify a single case's outcome from an independent post-failure
|
|
215
|
+
observation.
|
|
216
|
+
|
|
217
|
+
Takes only the eval's own independently-observed boolean (never the
|
|
218
|
+
adapter's self-reported MigrationFailureResult.original_data_recoverable
|
|
219
|
+
directly) -- see this module's docstring and
|
|
220
|
+
run_migration_rollback_eval() below, which is the caller responsible
|
|
221
|
+
for making that independent query() observation before calling this.
|
|
222
|
+
"""
|
|
223
|
+
if original_data_recoverable_after_failure:
|
|
224
|
+
return MigrationRollbackSignal.RESTORED
|
|
225
|
+
return MigrationRollbackSignal.DATA_LOST
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def run_migration_rollback_eval(
|
|
229
|
+
adapter: MemoryBackendAdapter,
|
|
230
|
+
dataset_path: Path | str = DEFAULT_FIXTURE_PATH,
|
|
231
|
+
) -> MigrationRollbackEvalResult:
|
|
232
|
+
cases = load_dataset(dataset_path)
|
|
233
|
+
result = MigrationRollbackEvalResult(backend_name=adapter.name, dataset_path=str(dataset_path))
|
|
234
|
+
|
|
235
|
+
if not adapter.supports_migration_rollback_simulation:
|
|
236
|
+
result.skipped = True
|
|
237
|
+
result.skip_reason = (
|
|
238
|
+
f"{adapter.name} does not support migration-rollback simulation "
|
|
239
|
+
"(supports_migration_rollback_simulation=False) -- skipped, not run. "
|
|
240
|
+
"No adapter in this repo has real filesystem control over a live "
|
|
241
|
+
"backend's internal migration code path; see "
|
|
242
|
+
"evals/migration_rollback.py's module docstring and "
|
|
243
|
+
"docs/methodology.md."
|
|
244
|
+
)
|
|
245
|
+
return result
|
|
246
|
+
|
|
247
|
+
for case in cases:
|
|
248
|
+
try:
|
|
249
|
+
failure_result = adapter.simulate_migration_failure(case.session_id, case.content)
|
|
250
|
+
post_failure_query = adapter.query(case.session_id, case.content, top_k=5)
|
|
251
|
+
original_data_recoverable = any(
|
|
252
|
+
case.content.lower() in r.content.lower() for r in post_failure_query.records
|
|
253
|
+
)
|
|
254
|
+
except BackendAPIError as exc:
|
|
255
|
+
result.case_results.append(
|
|
256
|
+
MigrationRollbackCaseResult(
|
|
257
|
+
case=case,
|
|
258
|
+
signal=MigrationRollbackSignal.NOT_APPLICABLE,
|
|
259
|
+
original_data_recoverable=None,
|
|
260
|
+
adapter_reported_recoverable=None,
|
|
261
|
+
error=str(exc),
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
signal = classify_migration_rollback_case(original_data_recoverable)
|
|
267
|
+
result.case_results.append(
|
|
268
|
+
MigrationRollbackCaseResult(
|
|
269
|
+
case=case,
|
|
270
|
+
signal=signal,
|
|
271
|
+
original_data_recoverable=original_data_recoverable,
|
|
272
|
+
adapter_reported_recoverable=failure_result.original_data_recoverable,
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
return result
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""MemTrust's prefix-delete/orphan-cleanup eval.
|
|
2
|
+
|
|
3
|
+
`evals/resource_sync_safety.py` classifies files a resync operation
|
|
4
|
+
wrongly DELETES. This eval classifies the opposite polarity: vector-index
|
|
5
|
+
entries a prefix delete wrongly KEEPS -- a distinct failure mode neither
|
|
6
|
+
`resource_sync_safety.py` nor `delete()`/`delete_many()` (both single-
|
|
7
|
+
`memory_id`-at-a-time primitives) can observe at all.
|
|
8
|
+
|
|
9
|
+
Motivating case: volcengine/OpenViking#3064 (contributor AcTiveXXX).
|
|
10
|
+
`viking_fs.rm()`'s orphan-cleanup path, reached when a target directory no
|
|
11
|
+
longer exists in AGFS (e.g. files were deleted directly from the backing
|
|
12
|
+
filesystem, bypassing OpenViking's own API), discovers child URIs to
|
|
13
|
+
delete via a directory-listing walk wrapped in a bare `except: pass`. When
|
|
14
|
+
the directory itself is already gone, that walk silently returns an empty
|
|
15
|
+
list, so only the root URI reaches the vector store's delete call --
|
|
16
|
+
child vector-index entries beneath the root survive, permanently orphaned
|
|
17
|
+
(AcTiveXXX measured ~9% orphan rate in a real deployment).
|
|
18
|
+
|
|
19
|
+
This eval seeds nested content under one prefix via `store()`, calls
|
|
20
|
+
`adapter.delete_prefix(prefix, recursive=True)` (see
|
|
21
|
+
MemoryBackendAdapter.delete_prefix and MemoryBackendAdapter
|
|
22
|
+
.supports_prefix_delete in adapters/base.py), then classifies each seeded
|
|
23
|
+
file from two INDEPENDENT observations, never trusting either alone:
|
|
24
|
+
|
|
25
|
+
* `list_resource_paths(prefix)` -- an AGFS-listing-level "is this path
|
|
26
|
+
still there" check, the same primitive resource_sync_safety.py already
|
|
27
|
+
uses.
|
|
28
|
+
* `query(prefix, seed_content)` -- an index-level "does search still
|
|
29
|
+
surface this content" check.
|
|
30
|
+
|
|
31
|
+
A file whose path is gone from list_resource_paths() AND whose content no
|
|
32
|
+
longer surfaces via query() is genuinely clean (VectorIntegritySignal
|
|
33
|
+
.CLEAN). A file whose path is gone from list_resource_paths() but whose
|
|
34
|
+
content STILL surfaces via query() is the exact #3064 shape: the
|
|
35
|
+
filesystem-level view says "deleted," the vector index disagrees
|
|
36
|
+
(VectorIntegritySignal.ORPHANED_VECTOR_ENTRY). This is the same
|
|
37
|
+
design principle evals/resource_sync_safety.py's classify_resource_sync_file
|
|
38
|
+
and evals/crash_recovery.py's classify_crash_recovery_case already
|
|
39
|
+
establish: classification never blindly trusts a single observation when
|
|
40
|
+
an independent cross-check is available.
|
|
41
|
+
|
|
42
|
+
Optional/flagged capability: this eval only runs against adapters that set
|
|
43
|
+
MemoryBackendAdapter.supports_prefix_delete = True. Adapters without a
|
|
44
|
+
prefix-delete primitive are skipped cleanly -- reported as skipped, never
|
|
45
|
+
silently dropped from the results table and never crashed by calling an
|
|
46
|
+
unimplemented method.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
import json
|
|
52
|
+
from dataclasses import dataclass, field
|
|
53
|
+
from pathlib import Path
|
|
54
|
+
from typing import Any
|
|
55
|
+
|
|
56
|
+
from memtrust.adapters.base import BackendAPIError, MemoryBackendAdapter, VectorIntegritySignal
|
|
57
|
+
|
|
58
|
+
DEFAULT_FIXTURE_PATH = (
|
|
59
|
+
Path(__file__).resolve().parents[3] / "tests" / "fixtures" / "orphan_cleanup_cases.json"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class OrphanCleanupSeedFile:
|
|
65
|
+
path_suffix: str
|
|
66
|
+
content: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class OrphanCleanupCase:
|
|
71
|
+
case_id: str
|
|
72
|
+
prefix: str
|
|
73
|
+
seed_files: list[OrphanCleanupSeedFile]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class OrphanCleanupFileResult:
|
|
78
|
+
case_id: str
|
|
79
|
+
path_suffix: str
|
|
80
|
+
stored_path: str | None
|
|
81
|
+
present_after_delete: bool
|
|
82
|
+
"""Whether list_resource_paths(prefix) still reports this path present
|
|
83
|
+
after delete_prefix() -- an AGFS-listing-level observation."""
|
|
84
|
+
queryable_after_delete: bool
|
|
85
|
+
"""Whether query(prefix, seed_content) still surfaces a record whose
|
|
86
|
+
content matches this file's seeded content after delete_prefix() -- an
|
|
87
|
+
index-level observation, independent of present_after_delete."""
|
|
88
|
+
signal: VectorIntegritySignal
|
|
89
|
+
error: str | None = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class OrphanCleanupEvalResult:
|
|
94
|
+
backend_name: str
|
|
95
|
+
dataset_path: str
|
|
96
|
+
file_results: list[OrphanCleanupFileResult] = field(default_factory=list)
|
|
97
|
+
skipped: bool = False
|
|
98
|
+
skip_reason: str | None = None
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def scored_files(self) -> list[OrphanCleanupFileResult]:
|
|
102
|
+
return [f for f in self.file_results if f.error is None]
|
|
103
|
+
|
|
104
|
+
def _fraction(self, signal: VectorIntegritySignal) -> float | None:
|
|
105
|
+
scored = self.scored_files
|
|
106
|
+
if not scored:
|
|
107
|
+
return None
|
|
108
|
+
matching = sum(1 for f in scored if f.signal == signal)
|
|
109
|
+
return matching / len(scored)
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def orphaned_vector_entry_rate(self) -> float | None:
|
|
113
|
+
"""Fraction of seeded files whose vector-index entry survived a
|
|
114
|
+
delete_prefix() call that reported the path itself as gone -- the
|
|
115
|
+
headline metric this eval exists to surface, and the exact
|
|
116
|
+
volcengine/OpenViking#3064 shape."""
|
|
117
|
+
return self._fraction(VectorIntegritySignal.ORPHANED_VECTOR_ENTRY)
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def clean_rate(self) -> float | None:
|
|
121
|
+
return self._fraction(VectorIntegritySignal.CLEAN)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def load_dataset(path: Path | str = DEFAULT_FIXTURE_PATH) -> list[OrphanCleanupCase]:
|
|
125
|
+
data = json.loads(Path(path).read_text())
|
|
126
|
+
cases: list[dict[str, Any]] = data["cases"]
|
|
127
|
+
return [
|
|
128
|
+
OrphanCleanupCase(
|
|
129
|
+
case_id=c["case_id"],
|
|
130
|
+
prefix=c["prefix"],
|
|
131
|
+
seed_files=[
|
|
132
|
+
OrphanCleanupSeedFile(path_suffix=sf["path_suffix"], content=sf["content"])
|
|
133
|
+
for sf in c["seed_files"]
|
|
134
|
+
],
|
|
135
|
+
)
|
|
136
|
+
for c in cases
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def classify_orphan_cleanup_file(
|
|
141
|
+
present_after_delete: bool,
|
|
142
|
+
queryable_after_delete: bool,
|
|
143
|
+
) -> VectorIntegritySignal:
|
|
144
|
+
"""Classify a single seeded file's outcome from its post-delete
|
|
145
|
+
presence (AGFS-listing-level) and searchability (index-level)
|
|
146
|
+
observations -- both taken AFTER delete_prefix() has already run.
|
|
147
|
+
|
|
148
|
+
Returns ORPHANED_VECTOR_ENTRY whenever the listing says the path is
|
|
149
|
+
gone but a query still surfaces the content -- the vector index
|
|
150
|
+
disagreeing with the filesystem-level view is exactly the #3064 shape,
|
|
151
|
+
regardless of whether the path itself somehow still lists as present
|
|
152
|
+
too (that combination should not occur from a well-behaved adapter,
|
|
153
|
+
but if it does, still-queryable content is the more severe signal and
|
|
154
|
+
wins).
|
|
155
|
+
"""
|
|
156
|
+
if queryable_after_delete:
|
|
157
|
+
return VectorIntegritySignal.ORPHANED_VECTOR_ENTRY
|
|
158
|
+
if not present_after_delete:
|
|
159
|
+
return VectorIntegritySignal.CLEAN
|
|
160
|
+
# Path still listed as present and no query match -- delete_prefix()
|
|
161
|
+
# did not actually remove the path from the filesystem-level listing
|
|
162
|
+
# either. Not the #3064 shape (that is specifically an index/listing
|
|
163
|
+
# DISAGREEMENT), but still not a clean delete -- conservatively
|
|
164
|
+
# classified as an orphan since the caller asked for this content gone
|
|
165
|
+
# and list_resource_paths() says it is not.
|
|
166
|
+
return VectorIntegritySignal.ORPHANED_VECTOR_ENTRY
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def run_orphan_cleanup_eval(
|
|
170
|
+
adapter: MemoryBackendAdapter,
|
|
171
|
+
dataset_path: Path | str = DEFAULT_FIXTURE_PATH,
|
|
172
|
+
) -> OrphanCleanupEvalResult:
|
|
173
|
+
cases = load_dataset(dataset_path)
|
|
174
|
+
result = OrphanCleanupEvalResult(backend_name=adapter.name, dataset_path=str(dataset_path))
|
|
175
|
+
|
|
176
|
+
if not adapter.supports_prefix_delete:
|
|
177
|
+
result.skipped = True
|
|
178
|
+
result.skip_reason = (
|
|
179
|
+
f"{adapter.name} does not support prefix delete "
|
|
180
|
+
"(supports_prefix_delete=False) -- skipped, not run."
|
|
181
|
+
)
|
|
182
|
+
return result
|
|
183
|
+
|
|
184
|
+
for case in cases:
|
|
185
|
+
stored_paths: dict[str, str] = {}
|
|
186
|
+
try:
|
|
187
|
+
for seed in case.seed_files:
|
|
188
|
+
store_result = adapter.store(
|
|
189
|
+
case.prefix, seed.content, metadata={"resource_path": seed.path_suffix}
|
|
190
|
+
)
|
|
191
|
+
stored_paths[seed.path_suffix] = store_result.memory_id
|
|
192
|
+
|
|
193
|
+
adapter.delete_prefix(case.prefix, recursive=True)
|
|
194
|
+
paths_after = set(adapter.list_resource_paths(case.prefix))
|
|
195
|
+
except BackendAPIError as exc:
|
|
196
|
+
for seed in case.seed_files:
|
|
197
|
+
result.file_results.append(
|
|
198
|
+
OrphanCleanupFileResult(
|
|
199
|
+
case_id=case.case_id,
|
|
200
|
+
path_suffix=seed.path_suffix,
|
|
201
|
+
stored_path=stored_paths.get(seed.path_suffix),
|
|
202
|
+
present_after_delete=False,
|
|
203
|
+
queryable_after_delete=False,
|
|
204
|
+
signal=VectorIntegritySignal.NOT_APPLICABLE,
|
|
205
|
+
error=str(exc),
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
continue
|
|
209
|
+
|
|
210
|
+
for seed in case.seed_files:
|
|
211
|
+
stored_path = stored_paths[seed.path_suffix]
|
|
212
|
+
present_after = stored_path in paths_after
|
|
213
|
+
|
|
214
|
+
queryable_after = False
|
|
215
|
+
try:
|
|
216
|
+
query_result = adapter.query(case.prefix, seed.content, top_k=5)
|
|
217
|
+
queryable_after = any(
|
|
218
|
+
seed.content.lower() in r.content.lower() for r in query_result.records
|
|
219
|
+
)
|
|
220
|
+
except BackendAPIError:
|
|
221
|
+
queryable_after = False
|
|
222
|
+
|
|
223
|
+
signal = classify_orphan_cleanup_file(present_after, queryable_after)
|
|
224
|
+
result.file_results.append(
|
|
225
|
+
OrphanCleanupFileResult(
|
|
226
|
+
case_id=case.case_id,
|
|
227
|
+
path_suffix=seed.path_suffix,
|
|
228
|
+
stored_path=stored_path,
|
|
229
|
+
present_after_delete=present_after,
|
|
230
|
+
queryable_after_delete=queryable_after,
|
|
231
|
+
signal=signal,
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
return result
|