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