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,494 @@
|
|
|
1
|
+
"""MemPalace MCP metadata-tool scale eval (mempalace_status/list_wings/
|
|
2
|
+
list_rooms).
|
|
3
|
+
|
|
4
|
+
MemPalace/mempalace#1871 (contributor alionar) found that the MCP
|
|
5
|
+
server's metadata/histogram-listing tools -- `mempalace_status`,
|
|
6
|
+
`mempalace_list_wings`, `mempalace_list_rooms` -- did a full-collection
|
|
7
|
+
scan on every call against a Qdrant-backed palace: O(N^2) against
|
|
8
|
+
repeated calls, hanging the MCP server at 158K+ drawers (fixed by
|
|
9
|
+
server-side Qdrant faceting; see PR#1871's diff for the merged fix).
|
|
10
|
+
|
|
11
|
+
Before this eval, evals/scale_stress.py was memtrust's only scale-oriented
|
|
12
|
+
coverage, and it only ever measures store()/query() latency -- it has no
|
|
13
|
+
path at all to the metadata/histogram-listing code path alionar's bug
|
|
14
|
+
lives in, because MemPalaceAdapter never invoked the MCP-server surface,
|
|
15
|
+
only the guessed remember()/recall()/invalidate() library concepts.
|
|
16
|
+
|
|
17
|
+
adapters/mempalace_adapter.py's module docstring ("MCP metadata-tool
|
|
18
|
+
coverage" section) documents what this build's investigation confirmed
|
|
19
|
+
against the real, installed `mempalace` package: `mempalace.mcp_server`
|
|
20
|
+
ships real, plain module-level functions (`tool_status()`,
|
|
21
|
+
`tool_list_wings()`, `tool_list_rooms(wing=None)`) that are the actual
|
|
22
|
+
implementation the MCP tool calls dispatch to, callable directly with no
|
|
23
|
+
MCP stdio/HTTP transport involved. `MemPalaceAdapter.metadata_overview()`/
|
|
24
|
+
`list_metadata_categories()`/`list_metadata_subcategories()` wrap those
|
|
25
|
+
three real functions -- this eval drives them at increasing checkpoint
|
|
26
|
+
sizes, the same "measure the real thing as a function of corpus size"
|
|
27
|
+
shape evals/scale_stress.py established for store()/query().
|
|
28
|
+
|
|
29
|
+
Two things this eval checks at every checkpoint, neither trusting "the
|
|
30
|
+
call didn't raise" as proof anything worked (same rule every other eval
|
|
31
|
+
in this package applies):
|
|
32
|
+
|
|
33
|
+
* Correctness: the wing/room counts `metadata_overview()`/
|
|
34
|
+
`list_metadata_categories()`/`list_metadata_subcategories()` report
|
|
35
|
+
must match the seeder's own ground-truth counts for however many
|
|
36
|
+
records have actually been seeded so far. A backend that responds
|
|
37
|
+
quickly but reports the wrong counts is not "working at scale" --
|
|
38
|
+
see MetadataScaleSignal.INCORRECT_COUNTS_AT_SCALE.
|
|
39
|
+
* Latency growth shape: per-call latency at the largest checkpoint is
|
|
40
|
+
compared against the smallest checkpoint's latency, scaled by how
|
|
41
|
+
much the record count itself grew. A latency ratio that outpaces the
|
|
42
|
+
record-count ratio by more than a generous superlinear margin is
|
|
43
|
+
flagged MetadataScaleSignal.SUPERLINEAR_LATENCY_GROWTH -- a
|
|
44
|
+
regression guard for the exact repeated-full-collection-scan shape
|
|
45
|
+
alionar's bug report describes, not a claim that this eval reproduces
|
|
46
|
+
that bug's specific 158K-drawer failure.
|
|
47
|
+
|
|
48
|
+
**Honest limitation, stated plainly (mirrors evals/scale_stress.py's own
|
|
49
|
+
"Honest limitation" section).** `build_chroma_metadata_seeder()` below
|
|
50
|
+
seeds a real, local, chromadb-backed palace via the real, installed
|
|
51
|
+
`mempalace` package (confirmed working: seeding 20,000 records and
|
|
52
|
+
re-querying `tool_status()`/`tool_list_wings()` against them, in this
|
|
53
|
+
build's own investigation, returned correct ground-truth wing/room counts
|
|
54
|
+
in well under 100ms). That genuinely exercises the real metadata-listing
|
|
55
|
+
code path this bug lives in, on the real chroma backend.
|
|
56
|
+
|
|
57
|
+
What it does NOT do is reproduce alionar's *exact* repro shape. Re-reading
|
|
58
|
+
PR#1871's own review thread (gemini-code-assist's review comment)
|
|
59
|
+
confirms the merged fix has two parts: (1) server-side Qdrant faceting,
|
|
60
|
+
replacing the O(N^2) client-side scan for the Qdrant backend specifically,
|
|
61
|
+
and (2) a secondary optimization to the *Chroma fallback path* in
|
|
62
|
+
`_fetch_all_metadata` (making it reuse already-fetched records). Neither
|
|
63
|
+
part is what this eval's live seeder exercises: `mempalace.mcp_server`'s
|
|
64
|
+
`_sqlite_taxonomy()` fast path (a separate, already-merged optimization,
|
|
65
|
+
#1748/#1379) intercepts every `tool_status()`/`tool_list_wings()`/
|
|
66
|
+
`tool_list_rooms()` call against a standard-layout chroma-backed palace
|
|
67
|
+
*before* `_fetch_all_metadata` is ever reached -- confirmed in this
|
|
68
|
+
build's own investigation (sub-100ms responses at N=20,000, and the
|
|
69
|
+
source comment on `_sqlite_taxonomy()` itself: "fast wing->room tally
|
|
70
|
+
straight from chroma.sqlite3... to signal the caller to fall back to the
|
|
71
|
+
ChromaDB client pagination path"). So the live scale run this eval
|
|
72
|
+
performs is real, but it currently measures the fast path staying fast at
|
|
73
|
+
scale (a legitimate, previously-uncovered regression guard), not the
|
|
74
|
+
`_fetch_all_metadata` full-collection-scan path PR#1871 actually patches.
|
|
75
|
+
|
|
76
|
+
Two things could not be reproduced live in this build's environment,
|
|
77
|
+
for two different reasons:
|
|
78
|
+
|
|
79
|
+
* PR#1871's fix is specifically for MemPalace's Qdrant backend
|
|
80
|
+
(`mempalace/backends/qdrant.py`), which is REST-only against a live
|
|
81
|
+
external Qdrant server (`http://localhost:6333` by default) with no
|
|
82
|
+
embedded/local-storage mode at all -- confirmed by reading that
|
|
83
|
+
backend's source. This build's environment has neither `docker` nor a
|
|
84
|
+
local Qdrant binary available (both checked and confirmed absent), so
|
|
85
|
+
a live, 158K-drawer, Qdrant-backed repro of alionar's exact bug could
|
|
86
|
+
not be run here. A contributor with a live Qdrant instance available
|
|
87
|
+
can point `MEMPALACE_BACKEND=qdrant` at one and run this eval
|
|
88
|
+
unchanged -- the adapter methods and this eval's checkpoint/
|
|
89
|
+
classification logic make no chroma-specific assumption.
|
|
90
|
+
* Even on the chroma backend, deliberately forcing `_sqlite_taxonomy()`
|
|
91
|
+
to fail/decline (so a run actually exercises `_fetch_all_metadata`
|
|
92
|
+
instead of short-circuiting past it) would require constructing a
|
|
93
|
+
non-standard palace layout or a corrupted sqlite index -- not
|
|
94
|
+
attempted here, since a deliberately-broken palace is a different,
|
|
95
|
+
narrower scenario than "this backend at real scale," and getting it
|
|
96
|
+
wrong risks a misleading result more than a missing one.
|
|
97
|
+
|
|
98
|
+
Both are the same class of environmental gap this repo already draws
|
|
99
|
+
honestly elsewhere (see docs/methodology.md and
|
|
100
|
+
evals/crash_recovery.py's process-lifecycle caveat): this eval's
|
|
101
|
+
checkpoint/classification harness and the adapter methods it drives are
|
|
102
|
+
real, working, and verified end-to-end against a real backend at real
|
|
103
|
+
scale -- what has not happened yet is pointing that real harness at the
|
|
104
|
+
one specific code path (Qdrant faceting, or a forced chroma fallback)
|
|
105
|
+
alionar's report and PR#1871's fix actually touch.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
from __future__ import annotations
|
|
109
|
+
|
|
110
|
+
from dataclasses import dataclass, field
|
|
111
|
+
from enum import StrEnum
|
|
112
|
+
from typing import Protocol
|
|
113
|
+
|
|
114
|
+
from memtrust.adapters.base import BackendAPIError, MemoryBackendAdapter
|
|
115
|
+
|
|
116
|
+
#: Fast-by-default record count -- mirrors scale_stress.py's
|
|
117
|
+
#: DEFAULT_N_RECORDS reasoning: large enough to move past every bundled
|
|
118
|
+
#: fixture's single-digit scale, small enough to seed and check quickly
|
|
119
|
+
#: in CI against a real local chromadb-backed palace (seeding 20,000
|
|
120
|
+
#: records took ~3s in this build's own investigation).
|
|
121
|
+
DEFAULT_N_RECORDS = 2000
|
|
122
|
+
|
|
123
|
+
#: How many non-anchor top-level categories (MemPalace: wings) the
|
|
124
|
+
#: default seeder spreads records across. Kept small and fixed so every
|
|
125
|
+
#: checkpoint has a meaningful per-category count to check, rather than
|
|
126
|
+
#: scaling with n_records the way real palaces rarely would either.
|
|
127
|
+
DEFAULT_N_CATEGORIES = 8
|
|
128
|
+
|
|
129
|
+
#: Minimum latency ratio (largest checkpoint / smallest checkpoint)
|
|
130
|
+
#: before growth is even considered for the superlinear check below --
|
|
131
|
+
#: guards against noise on two calls that are both already sub-millisecond
|
|
132
|
+
#: (a healthy backend's status() call at N=5 and N=50 can easily differ by
|
|
133
|
+
#: 2-3x on pure measurement noise; that is not evidence of anything).
|
|
134
|
+
_MIN_LATENCY_RATIO_TO_FLAG = 3.0
|
|
135
|
+
|
|
136
|
+
#: Exponent applied to the record-count ratio when deciding whether an
|
|
137
|
+
#: observed latency ratio counts as "superlinear." A perfectly linear
|
|
138
|
+
#: scan scores exactly 1.0 here; the true O(N^2) shape alionar reported
|
|
139
|
+
#: (repeated full-collection re-scans) would score close to 2.0. 1.5 is a
|
|
140
|
+
#: deliberately generous middle threshold: high enough that ordinary
|
|
141
|
+
#: linear-scaling noise does not false-positive, low enough to still catch
|
|
142
|
+
#: a real quadratic-ish regression before it reaches production scale.
|
|
143
|
+
_SUPERLINEAR_EXPONENT = 1.5
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class MetadataScaleSignal(StrEnum):
|
|
147
|
+
"""How a backend's metadata-overview calls (status/list_categories/
|
|
148
|
+
list_subcategories) behaved as the corpus grew, per
|
|
149
|
+
run_mempalace_metadata_scale_eval()."""
|
|
150
|
+
|
|
151
|
+
WORKED_AT_SCALE = "worked_at_scale"
|
|
152
|
+
"""Every checkpoint's reported counts matched ground truth, and
|
|
153
|
+
latency did not grow superlinearly relative to record count."""
|
|
154
|
+
|
|
155
|
+
INCORRECT_COUNTS_AT_SCALE = "incorrect_counts_at_scale"
|
|
156
|
+
"""At least one checkpoint's reported category/subcategory counts
|
|
157
|
+
did not match the seeder's ground truth -- a correctness failure,
|
|
158
|
+
checked independently of and before any latency judgment."""
|
|
159
|
+
|
|
160
|
+
SUPERLINEAR_LATENCY_GROWTH = "superlinear_latency_growth"
|
|
161
|
+
"""Counts were correct everywhere, but latency grew far faster than
|
|
162
|
+
the record count did between the smallest and largest checkpoint --
|
|
163
|
+
the regression shape alionar's MemPalace/mempalace#1871 report
|
|
164
|
+
describes (repeated full-collection scans behind the MCP metadata
|
|
165
|
+
tools)."""
|
|
166
|
+
|
|
167
|
+
ERROR = "error"
|
|
168
|
+
"""A BackendAPIError was raised during the run that this eval could
|
|
169
|
+
not route around."""
|
|
170
|
+
|
|
171
|
+
NOT_APPLICABLE = "not_applicable"
|
|
172
|
+
"""Fewer than 2 checkpoints produced a scoreable result (e.g.
|
|
173
|
+
n_records too small), or the adapter under test does not implement
|
|
174
|
+
this capability at all (see MetadataScaleResult.skipped)."""
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass
|
|
178
|
+
class MetadataScaleCheckpointResult:
|
|
179
|
+
"""A snapshot taken after `checkpoint_n` records had been seeded."""
|
|
180
|
+
|
|
181
|
+
checkpoint_n: int
|
|
182
|
+
overview_latency_ms: float | None
|
|
183
|
+
categories_latency_ms: float | None
|
|
184
|
+
subcategories_latency_ms: float | None
|
|
185
|
+
total_records_reported: int | None
|
|
186
|
+
categories_reported: dict[str, int]
|
|
187
|
+
categories_expected: dict[str, int]
|
|
188
|
+
subcategories_reported: dict[str, int]
|
|
189
|
+
subcategories_expected: dict[str, int]
|
|
190
|
+
counts_correct: bool
|
|
191
|
+
error: str | None = None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass
|
|
195
|
+
class MetadataScaleResult:
|
|
196
|
+
"""Result of `run_mempalace_metadata_scale_eval()` for one backend,
|
|
197
|
+
one (n_records, seed) corpus."""
|
|
198
|
+
|
|
199
|
+
backend_name: str
|
|
200
|
+
n_records_requested: int
|
|
201
|
+
checkpoints: list[MetadataScaleCheckpointResult] = field(default_factory=list)
|
|
202
|
+
signal: MetadataScaleSignal = MetadataScaleSignal.NOT_APPLICABLE
|
|
203
|
+
latency_ratio: float | None = None
|
|
204
|
+
"""(largest checkpoint's overview_latency_ms) / (smallest checkpoint's
|
|
205
|
+
overview_latency_ms), or None if fewer than 2 checkpoints produced a
|
|
206
|
+
latency to compare."""
|
|
207
|
+
record_ratio: float | None = None
|
|
208
|
+
"""(largest checkpoint_n) / (smallest checkpoint_n), the baseline
|
|
209
|
+
`latency_ratio` above is judged against."""
|
|
210
|
+
skipped: bool = False
|
|
211
|
+
skip_reason: str | None = None
|
|
212
|
+
error: str | None = None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class MetadataSeeder(Protocol):
|
|
216
|
+
"""Populates a MemPalace-backed collection so it holds exactly
|
|
217
|
+
`total_n` synthetic drawers (cumulative -- calling this again with a
|
|
218
|
+
larger `total_n` adds only the delta, never re-seeds from scratch),
|
|
219
|
+
and returns the resulting ground-truth (categories, subcategories)
|
|
220
|
+
count dicts for all `total_n` records.
|
|
221
|
+
|
|
222
|
+
`build_chroma_metadata_seeder()` below is the real, live
|
|
223
|
+
implementation this eval is meant to be run with. Tests inject a
|
|
224
|
+
lightweight in-memory fake implementing this same Protocol so the
|
|
225
|
+
checkpoint/classification logic can be verified without chromadb
|
|
226
|
+
installed -- see tests/test_mempalace_metadata_scale.py.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
def __call__(self, total_n: int) -> tuple[dict[str, int], dict[str, int]]: ...
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _default_checkpoints(n: int) -> list[int]:
|
|
233
|
+
"""Same shape as scale_stress.py's `_default_checkpoints`: a small,
|
|
234
|
+
ascending set of checkpoint sizes from "the same scale every bundled
|
|
235
|
+
fixture already runs at" up to the full requested N.
|
|
236
|
+
"""
|
|
237
|
+
candidates = [5, max(1, n // 10), max(1, n // 2), n]
|
|
238
|
+
return sorted({c for c in candidates if 1 <= c <= n})
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _run_checkpoint(
|
|
242
|
+
adapter: MemoryBackendAdapter, seeder: MetadataSeeder, checkpoint_n: int
|
|
243
|
+
) -> MetadataScaleCheckpointResult:
|
|
244
|
+
try:
|
|
245
|
+
categories_expected, subcategories_expected = seeder(checkpoint_n)
|
|
246
|
+
except BackendAPIError as exc:
|
|
247
|
+
return MetadataScaleCheckpointResult(
|
|
248
|
+
checkpoint_n=checkpoint_n,
|
|
249
|
+
overview_latency_ms=None,
|
|
250
|
+
categories_latency_ms=None,
|
|
251
|
+
subcategories_latency_ms=None,
|
|
252
|
+
total_records_reported=None,
|
|
253
|
+
categories_reported={},
|
|
254
|
+
categories_expected={},
|
|
255
|
+
subcategories_reported={},
|
|
256
|
+
subcategories_expected={},
|
|
257
|
+
counts_correct=False,
|
|
258
|
+
error=f"seeding failed: {exc}",
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
overview = adapter.metadata_overview()
|
|
263
|
+
categories = adapter.list_metadata_categories()
|
|
264
|
+
subcategories = adapter.list_metadata_subcategories()
|
|
265
|
+
except BackendAPIError as exc:
|
|
266
|
+
return MetadataScaleCheckpointResult(
|
|
267
|
+
checkpoint_n=checkpoint_n,
|
|
268
|
+
overview_latency_ms=None,
|
|
269
|
+
categories_latency_ms=None,
|
|
270
|
+
subcategories_latency_ms=None,
|
|
271
|
+
total_records_reported=None,
|
|
272
|
+
categories_reported={},
|
|
273
|
+
categories_expected=categories_expected,
|
|
274
|
+
subcategories_reported={},
|
|
275
|
+
subcategories_expected=subcategories_expected,
|
|
276
|
+
counts_correct=False,
|
|
277
|
+
error=str(exc),
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
call_error = overview.error or categories.error or subcategories.error
|
|
281
|
+
counts_correct = (
|
|
282
|
+
call_error is None
|
|
283
|
+
and overview.total_records == checkpoint_n
|
|
284
|
+
and overview.categories == categories_expected
|
|
285
|
+
and categories.counts == categories_expected
|
|
286
|
+
and subcategories.counts == subcategories_expected
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
return MetadataScaleCheckpointResult(
|
|
290
|
+
checkpoint_n=checkpoint_n,
|
|
291
|
+
overview_latency_ms=overview.latency_ms,
|
|
292
|
+
categories_latency_ms=categories.latency_ms,
|
|
293
|
+
subcategories_latency_ms=subcategories.latency_ms,
|
|
294
|
+
total_records_reported=overview.total_records,
|
|
295
|
+
categories_reported=categories.counts,
|
|
296
|
+
categories_expected=categories_expected,
|
|
297
|
+
subcategories_reported=subcategories.counts,
|
|
298
|
+
subcategories_expected=subcategories_expected,
|
|
299
|
+
counts_correct=counts_correct,
|
|
300
|
+
error=call_error,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def classify_metadata_scale_result(
|
|
305
|
+
checkpoints: list[MetadataScaleCheckpointResult],
|
|
306
|
+
) -> tuple[MetadataScaleSignal, float | None, float | None]:
|
|
307
|
+
"""Classify a completed run's checkpoints. Returns
|
|
308
|
+
(signal, latency_ratio, record_ratio) -- never a blind pass/fail on
|
|
309
|
+
"did every call succeed," the same ground-truth-driven pattern every
|
|
310
|
+
other eval's classify_* function in this package follows.
|
|
311
|
+
"""
|
|
312
|
+
if any(c.error is not None for c in checkpoints):
|
|
313
|
+
return MetadataScaleSignal.ERROR, None, None
|
|
314
|
+
|
|
315
|
+
scoreable = [c for c in checkpoints if c.error is None]
|
|
316
|
+
if len(scoreable) < 2:
|
|
317
|
+
return MetadataScaleSignal.NOT_APPLICABLE, None, None
|
|
318
|
+
|
|
319
|
+
if any(not c.counts_correct for c in scoreable):
|
|
320
|
+
return MetadataScaleSignal.INCORRECT_COUNTS_AT_SCALE, None, None
|
|
321
|
+
|
|
322
|
+
first, last = scoreable[0], scoreable[-1]
|
|
323
|
+
if (
|
|
324
|
+
first.overview_latency_ms is None
|
|
325
|
+
or last.overview_latency_ms is None
|
|
326
|
+
or first.checkpoint_n >= last.checkpoint_n
|
|
327
|
+
or first.overview_latency_ms <= 0
|
|
328
|
+
):
|
|
329
|
+
return MetadataScaleSignal.WORKED_AT_SCALE, None, None
|
|
330
|
+
|
|
331
|
+
record_ratio = last.checkpoint_n / first.checkpoint_n
|
|
332
|
+
latency_ratio = last.overview_latency_ms / first.overview_latency_ms
|
|
333
|
+
|
|
334
|
+
if (
|
|
335
|
+
latency_ratio > _MIN_LATENCY_RATIO_TO_FLAG
|
|
336
|
+
and latency_ratio > record_ratio**_SUPERLINEAR_EXPONENT
|
|
337
|
+
):
|
|
338
|
+
return MetadataScaleSignal.SUPERLINEAR_LATENCY_GROWTH, latency_ratio, record_ratio
|
|
339
|
+
return MetadataScaleSignal.WORKED_AT_SCALE, latency_ratio, record_ratio
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def run_mempalace_metadata_scale_eval(
|
|
343
|
+
adapter: MemoryBackendAdapter,
|
|
344
|
+
seeder: MetadataSeeder,
|
|
345
|
+
n_records: int = DEFAULT_N_RECORDS,
|
|
346
|
+
checkpoints: list[int] | None = None,
|
|
347
|
+
) -> MetadataScaleResult:
|
|
348
|
+
"""Seed `seeder` up to `n_records` synthetic drawers at a series of
|
|
349
|
+
checkpoints, and at each one check `adapter`'s metadata_overview()/
|
|
350
|
+
list_metadata_categories()/list_metadata_subcategories() for both
|
|
351
|
+
correctness and latency-growth shape.
|
|
352
|
+
|
|
353
|
+
Args:
|
|
354
|
+
adapter: the backend under test. Skipped (not run) unless
|
|
355
|
+
`adapter.supports_metadata_overview` is True -- same
|
|
356
|
+
convention evals/filter_injection.py already established for
|
|
357
|
+
supports_raw_filter_probe.
|
|
358
|
+
seeder: populates the backend with ground-truth-tracked synthetic
|
|
359
|
+
records -- see MetadataSeeder above and
|
|
360
|
+
build_chroma_metadata_seeder() for the real implementation.
|
|
361
|
+
n_records: how many records to seed by the final checkpoint.
|
|
362
|
+
checkpoints: explicit checkpoint sizes. Defaults to
|
|
363
|
+
`_default_checkpoints(n_records)`.
|
|
364
|
+
|
|
365
|
+
Raises:
|
|
366
|
+
ValueError: if n_records < 1.
|
|
367
|
+
"""
|
|
368
|
+
if n_records < 1:
|
|
369
|
+
raise ValueError(f"n_records must be >= 1, got {n_records}")
|
|
370
|
+
|
|
371
|
+
result = MetadataScaleResult(backend_name=adapter.name, n_records_requested=n_records)
|
|
372
|
+
|
|
373
|
+
if not adapter.supports_metadata_overview:
|
|
374
|
+
result.skipped = True
|
|
375
|
+
result.skip_reason = (
|
|
376
|
+
f"{adapter.name} does not support a metadata overview "
|
|
377
|
+
"(supports_metadata_overview=False) -- skipped, not run. Only an adapter that "
|
|
378
|
+
"holds a direct, in-process handle to a vendor's own MCP metadata-tool "
|
|
379
|
+
"implementation (or an equivalent library-level function) can genuinely "
|
|
380
|
+
"exercise this; see adapters/base.py's supports_metadata_overview and this "
|
|
381
|
+
"module's docstring."
|
|
382
|
+
)
|
|
383
|
+
return result
|
|
384
|
+
|
|
385
|
+
resolved_checkpoints = (
|
|
386
|
+
sorted({c for c in checkpoints if 1 <= c <= n_records})
|
|
387
|
+
if checkpoints is not None
|
|
388
|
+
else _default_checkpoints(n_records)
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
result.checkpoints = [_run_checkpoint(adapter, seeder, cp) for cp in resolved_checkpoints]
|
|
392
|
+
signal, latency_ratio, record_ratio = classify_metadata_scale_result(result.checkpoints)
|
|
393
|
+
result.signal = signal
|
|
394
|
+
result.latency_ratio = latency_ratio
|
|
395
|
+
result.record_ratio = record_ratio
|
|
396
|
+
|
|
397
|
+
if signal == MetadataScaleSignal.ERROR:
|
|
398
|
+
first_error = next((c.error for c in result.checkpoints if c.error), None)
|
|
399
|
+
result.error = first_error
|
|
400
|
+
|
|
401
|
+
return result
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def build_chroma_metadata_seeder(
|
|
405
|
+
storage_path: str,
|
|
406
|
+
*,
|
|
407
|
+
collection_name: str = "mempalace_drawers",
|
|
408
|
+
n_categories: int = DEFAULT_N_CATEGORIES,
|
|
409
|
+
n_subcategories: int = 40,
|
|
410
|
+
embedding_dim: int = 8,
|
|
411
|
+
) -> MetadataSeeder:
|
|
412
|
+
"""Real, live `MetadataSeeder` implementation: seeds a local,
|
|
413
|
+
chromadb-backed palace at `storage_path` directly through the real,
|
|
414
|
+
installed `mempalace` package's backend layer
|
|
415
|
+
(`mempalace.palace.get_backend_for_palace` +
|
|
416
|
+
`ChromaCollection.add(..., embeddings=...)`), bypassing
|
|
417
|
+
MemPalaceAdapter.store() deliberately -- see
|
|
418
|
+
adapters/mempalace_adapter.py's module docstring: `store()` is written
|
|
419
|
+
against an unconfirmed `mempalace.Palace(...)` guess that does not
|
|
420
|
+
exist in the real package, so it cannot be used to populate a real
|
|
421
|
+
corpus at all. This seeder instead uses the confirmed-real backend
|
|
422
|
+
API the real `mine`/CLI ingestion path itself is built on, passing
|
|
423
|
+
explicit dummy embeddings so seeding never depends on network access
|
|
424
|
+
(MemPalace's default embedder otherwise downloads an ONNX model on
|
|
425
|
+
first use -- see mempalace_adapter.py's module docstring).
|
|
426
|
+
|
|
427
|
+
Every record gets deterministic `wing`/`room` metadata
|
|
428
|
+
(`wing_{i % n_categories}` / `room_{i % n_subcategories}`), matching
|
|
429
|
+
the same "deterministic, ground-truth-trackable" design
|
|
430
|
+
evals/scale_fixtures.py's generate_scale_corpus() already uses for
|
|
431
|
+
scale_stress.py.
|
|
432
|
+
|
|
433
|
+
Raises:
|
|
434
|
+
BackendAPIError: if the `mempalace` package (or its chromadb
|
|
435
|
+
dependency) is not installed, or a seeding call itself fails.
|
|
436
|
+
"""
|
|
437
|
+
try:
|
|
438
|
+
from mempalace.palace import get_backend_for_palace # type: ignore[import-not-found]
|
|
439
|
+
except ImportError as exc:
|
|
440
|
+
raise BackendAPIError(
|
|
441
|
+
"mempalace",
|
|
442
|
+
"the `mempalace` package is not installed. Install it with "
|
|
443
|
+
"`pip install mempalace` to run a real, live metadata-scale "
|
|
444
|
+
"stress test -- see this module's docstring for what "
|
|
445
|
+
"classification-logic-only tests can still verify without it.",
|
|
446
|
+
) from exc
|
|
447
|
+
|
|
448
|
+
try:
|
|
449
|
+
backend = get_backend_for_palace(storage_path, explicit="chroma")
|
|
450
|
+
collection = backend.get_collection(
|
|
451
|
+
storage_path, collection_name=collection_name, create=True
|
|
452
|
+
)
|
|
453
|
+
except Exception as exc: # noqa: BLE001 - real vendor call, wrap uniformly
|
|
454
|
+
raise BackendAPIError("mempalace", f"opening seed collection failed: {exc}") from exc
|
|
455
|
+
|
|
456
|
+
seeded_so_far = 0
|
|
457
|
+
|
|
458
|
+
def _seed(total_n: int) -> tuple[dict[str, int], dict[str, int]]:
|
|
459
|
+
nonlocal seeded_so_far
|
|
460
|
+
if total_n < seeded_so_far:
|
|
461
|
+
raise ValueError(
|
|
462
|
+
"metadata seeder is append-only: cannot shrink from "
|
|
463
|
+
f"{seeded_so_far} already-seeded records down to {total_n}"
|
|
464
|
+
)
|
|
465
|
+
if total_n > seeded_so_far:
|
|
466
|
+
documents = []
|
|
467
|
+
ids = []
|
|
468
|
+
metadatas = []
|
|
469
|
+
embeddings = []
|
|
470
|
+
for i in range(seeded_so_far, total_n):
|
|
471
|
+
wing = f"wing_{i % n_categories}"
|
|
472
|
+
room = f"room_{i % n_subcategories}"
|
|
473
|
+
documents.append(f"synthetic metadata-scale drawer {i} (marker SCALEMETA{i:06d})")
|
|
474
|
+
ids.append(f"scale-drawer-{i}")
|
|
475
|
+
metadatas.append({"wing": wing, "room": room})
|
|
476
|
+
embeddings.append([float((i * 7 + j) % 11) / 11.0 for j in range(embedding_dim)])
|
|
477
|
+
try:
|
|
478
|
+
collection.add(
|
|
479
|
+
documents=documents, ids=ids, metadatas=metadatas, embeddings=embeddings
|
|
480
|
+
)
|
|
481
|
+
except Exception as exc: # noqa: BLE001 - real vendor call, wrap uniformly
|
|
482
|
+
raise BackendAPIError("mempalace", f"seeding collection failed: {exc}") from exc
|
|
483
|
+
seeded_so_far = total_n
|
|
484
|
+
|
|
485
|
+
categories_expected: dict[str, int] = {}
|
|
486
|
+
subcategories_expected: dict[str, int] = {}
|
|
487
|
+
for i in range(total_n):
|
|
488
|
+
w = f"wing_{i % n_categories}"
|
|
489
|
+
r = f"room_{i % n_subcategories}"
|
|
490
|
+
categories_expected[w] = categories_expected.get(w, 0) + 1
|
|
491
|
+
subcategories_expected[r] = subcategories_expected.get(r, 0) + 1
|
|
492
|
+
return categories_expected, subcategories_expected
|
|
493
|
+
|
|
494
|
+
return _seed
|