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,502 @@
|
|
|
1
|
+
"""MemTrust's scale/volume stress-testing eval.
|
|
2
|
+
|
|
3
|
+
Every other eval in this package (contradiction, ranking_quality,
|
|
4
|
+
resource_sync_safety, compression) runs against a bundled fixture of 4-7
|
|
5
|
+
hand-written cases. That is sufficient to exercise a backend's correctness
|
|
6
|
+
logic, but it structurally cannot exercise the class of bug that only shows
|
|
7
|
+
up once a corpus grows large -- two real, documented, still-open vendor
|
|
8
|
+
reports are exactly this shape:
|
|
9
|
+
|
|
10
|
+
* volcengine/OpenViking#2850 (lg320531124): BM25 search silently returns
|
|
11
|
+
empty results once a corpus grows large enough. README.md and
|
|
12
|
+
docs/methodology.md have both already noted, honestly, that memtrust's
|
|
13
|
+
EMPTY_OR_LOST/NOT_APPLICABLE signals can distinguish "empty response"
|
|
14
|
+
from "no response," but that nothing in this repo could actually
|
|
15
|
+
*reproduce the scale condition* that triggers it -- every fixture
|
|
16
|
+
tops out at single-digit record counts.
|
|
17
|
+
* getzep/graphiti#1275 (rafaelreis-r): O(n) entity-resolution context
|
|
18
|
+
growth causes episodes to be silently dropped once ingestion passes
|
|
19
|
+
roughly 300 episodes. Same shape of gap: nothing in this repo ever
|
|
20
|
+
ingests more than a handful of episodes.
|
|
21
|
+
|
|
22
|
+
This eval closes that gap. It uses `evals/scale_fixtures.py`'s
|
|
23
|
+
`generate_scale_corpus()` to build a corpus of N synthetic records (N
|
|
24
|
+
configurable, default small enough to run fast in CI, architected to scale
|
|
25
|
+
to 10K+ against a real backend), stores them incrementally, and at a series
|
|
26
|
+
of checkpoints along the way re-queries a sample of already-stored records
|
|
27
|
+
by their unique marker token to see whether they are still recoverable.
|
|
28
|
+
|
|
29
|
+
The specific thing this is built to detect is a backend that works fine at
|
|
30
|
+
N=5 (every bundled fixture's scale) and silently breaks somewhere between
|
|
31
|
+
N=50 and N=1000+, with no exception raised anywhere -- exactly what both
|
|
32
|
+
#2850 and #1275 describe: a call that completes normally and returns
|
|
33
|
+
nothing (or returns something, but not the thing that was asked for),
|
|
34
|
+
which is indistinguishable from "the record was never there" unless a
|
|
35
|
+
harness specifically tracks recall *as a function of corpus size*.
|
|
36
|
+
|
|
37
|
+
Two distinct signals are tracked at each checkpoint, because #2850 and
|
|
38
|
+
#1275 are not quite the same failure shape:
|
|
39
|
+
|
|
40
|
+
* A fixed **anchor** record (the very first one ever stored) is
|
|
41
|
+
re-queried at every checkpoint. If it was recoverable at N=5 and
|
|
42
|
+
becomes unrecoverable once N grows, that is the #1275 shape: older
|
|
43
|
+
content silently evicted/dropped as volume grows, while recent
|
|
44
|
+
content is still fine.
|
|
45
|
+
* A **sample** of records spread across everything stored so far is
|
|
46
|
+
re-queried at every checkpoint. If the sample's overall recall rate
|
|
47
|
+
holds steady at small N and collapses at large N, that is the #2850
|
|
48
|
+
shape: search itself degrades (or goes silently empty) as the corpus
|
|
49
|
+
grows, independent of which specific record is being asked for.
|
|
50
|
+
|
|
51
|
+
Design principle (same as every other eval in this package): classification
|
|
52
|
+
never trusts "the call didn't raise" as proof anything worked. A record is
|
|
53
|
+
only counted as recoverable if its unique marker token is actually present
|
|
54
|
+
in the joined text of the query response -- an empty response, a response
|
|
55
|
+
containing unrelated records, or a response missing just this one marker
|
|
56
|
+
are all scored identically as "not recoverable," the same "silent
|
|
57
|
+
empty-success is not a pass" rule evals/contradiction.py's EMPTY_OR_LOST
|
|
58
|
+
signal establishes.
|
|
59
|
+
|
|
60
|
+
**Honest limitation, stated plainly (see docs/methodology.md for the full
|
|
61
|
+
write-up).** This is a NEW capability as of this change. It has not been run
|
|
62
|
+
against any live backend at real scale (10K+) -- doing so requires live
|
|
63
|
+
vendor credentials and a real run, neither of which happened during this
|
|
64
|
+
build. What this file's own test suite (tests/test_scale_stress.py) proves
|
|
65
|
+
is narrower and just as real: the eval's classification logic correctly
|
|
66
|
+
distinguishes a fake adapter engineered to degrade at volume from one that
|
|
67
|
+
scales cleanly. It does not, and cannot, prove that OpenViking's or
|
|
68
|
+
Graphiti's real production systems currently exhibit #2850 or #1275 --
|
|
69
|
+
only that memtrust's harness is now structurally capable of detecting that
|
|
70
|
+
shape of bug *if* a live backend exhibits it, the same honest boundary
|
|
71
|
+
already drawn around resource_sync_safety.py's NESTED_CONTENT_UNINDEXED
|
|
72
|
+
signal and ranking_quality.py's MISSING_ORDERING_KEY signal.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
from __future__ import annotations
|
|
76
|
+
|
|
77
|
+
import math
|
|
78
|
+
import random
|
|
79
|
+
from dataclasses import dataclass, field
|
|
80
|
+
from enum import StrEnum
|
|
81
|
+
|
|
82
|
+
from memtrust.adapters.base import BackendAPIError, MemoryBackendAdapter
|
|
83
|
+
from memtrust.evals.scale_fixtures import ScaleFixtureRecord, generate_scale_corpus
|
|
84
|
+
|
|
85
|
+
#: Fast-by-default record count. Large enough to move well past every
|
|
86
|
+
#: bundled fixture's single-digit scale and actually exercise volume
|
|
87
|
+
#: behavior, small enough to run in CI in a few seconds against a fake
|
|
88
|
+
#: in-memory adapter. Passing a larger n_records (the harness itself has
|
|
89
|
+
#: no upper bound besides scale_fixtures.generate_scale_corpus's 999,999
|
|
90
|
+
#: cap) is how this same code path reaches the 10K+ regime the real vendor
|
|
91
|
+
#: bugs manifest at -- see this module's docstring.
|
|
92
|
+
DEFAULT_N_RECORDS = 500
|
|
93
|
+
|
|
94
|
+
#: Recall drop (in percentage points) between the first and last scoreable
|
|
95
|
+
#: checkpoint, at or above which a run is classified SILENTLY_DEGRADED_AT_
|
|
96
|
+
#: SCALE rather than merely PARTIAL_DEGRADATION. Chosen well above normal
|
|
97
|
+
#: run-to-run noise for a fake/real adapter that is genuinely working (a
|
|
98
|
+
#: healthy backend's recall at N=5 and at N=500 should be nearly
|
|
99
|
+
#: identical, not just "close").
|
|
100
|
+
DEGRADATION_THRESHOLD_PP = 15.0
|
|
101
|
+
|
|
102
|
+
#: Minimum acceptable recall rate at the largest checkpoint for a run to be
|
|
103
|
+
#: classified WORKED_AT_SCALE outright, even when the pp-drop threshold
|
|
104
|
+
#: above isn't crossed (e.g. a backend that was already imperfect at N=5
|
|
105
|
+
#: and stays exactly as imperfect at N=500 has zero degradation, but is
|
|
106
|
+
#: still not "working").
|
|
107
|
+
MIN_ACCEPTABLE_FINAL_RECALL = 0.9
|
|
108
|
+
|
|
109
|
+
#: How many non-anchor records to sample per checkpoint for the general
|
|
110
|
+
#: recall signal (see this module's docstring). Fixed rather than
|
|
111
|
+
#: proportional to checkpoint size so the query cost of a checkpoint stays
|
|
112
|
+
#: bounded even at N=10,000.
|
|
113
|
+
SAMPLE_SIZE_PER_CHECKPOINT = 5
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class ScaleSignal(StrEnum):
|
|
117
|
+
"""How a backend's recall behaved as the corpus it was queried against
|
|
118
|
+
grew from a handful of records to the full requested N.
|
|
119
|
+
|
|
120
|
+
Defined locally in this module rather than in adapters/base.py,
|
|
121
|
+
following the same precedent evals/resource_sync_safety.py's
|
|
122
|
+
ResourceSyncSignal already sets: this is a harness-computed
|
|
123
|
+
classification derived from ground truth (which records were actually
|
|
124
|
+
stored, and in what order), not a signal any adapter self-reports.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
WORKED_AT_SCALE = "worked_at_scale"
|
|
128
|
+
"""Recall stayed high (>= MIN_ACCEPTABLE_FINAL_RECALL) at the largest
|
|
129
|
+
checkpoint and did not drop by more than DEGRADATION_THRESHOLD_PP
|
|
130
|
+
between the smallest and largest scoreable checkpoint, and the anchor
|
|
131
|
+
(first-ever-stored) record was still recoverable at every checkpoint.
|
|
132
|
+
No evidence of scale-dependent degradation was observed."""
|
|
133
|
+
|
|
134
|
+
SILENTLY_DEGRADED_AT_SCALE = "silently_degraded_at_scale"
|
|
135
|
+
"""Either the anchor record -- recoverable at a small checkpoint --
|
|
136
|
+
became unrecoverable at a larger one (the getzep/graphiti#1275 shape:
|
|
137
|
+
old content silently dropped as volume grows), or the general sample's
|
|
138
|
+
recall rate fell by at least DEGRADATION_THRESHOLD_PP percentage
|
|
139
|
+
points between the smallest and largest checkpoint (the
|
|
140
|
+
volcengine/OpenViking#2850 shape: search itself degrades at volume).
|
|
141
|
+
Every store()/query() call involved completed without raising
|
|
142
|
+
BackendAPIError -- this is a silent failure, not a crash, which is
|
|
143
|
+
exactly the failure mode both cited issues describe and exactly why a
|
|
144
|
+
harness needs to track recall as a function of scale to see it at
|
|
145
|
+
all."""
|
|
146
|
+
|
|
147
|
+
PARTIAL_DEGRADATION = "partial_degradation"
|
|
148
|
+
"""Final-checkpoint recall fell short of MIN_ACCEPTABLE_FINAL_RECALL,
|
|
149
|
+
but not because of a scale-correlated drop large enough to meet
|
|
150
|
+
SILENTLY_DEGRADED_AT_SCALE's threshold -- e.g. a backend that already
|
|
151
|
+
missed some records at the smallest checkpoint and stayed equally
|
|
152
|
+
imperfect at the largest one. Worth flagging (recall is genuinely
|
|
153
|
+
incomplete), but distinct from a volume-triggered collapse: this
|
|
154
|
+
could just as easily be an ordinary indexing miss unrelated to
|
|
155
|
+
corpus size."""
|
|
156
|
+
|
|
157
|
+
ERROR = "error"
|
|
158
|
+
"""A BackendAPIError was raised during the run that this eval could
|
|
159
|
+
not route around (see ScaleTestResult.error for detail). Distinct
|
|
160
|
+
from SILENTLY_DEGRADED_AT_SCALE -- an explicit error is a different,
|
|
161
|
+
more honest failure mode than a silent one, and conflating the two
|
|
162
|
+
would credit a backend that at least raised an exception with the
|
|
163
|
+
same verdict as one that returned an empty success."""
|
|
164
|
+
|
|
165
|
+
NOT_APPLICABLE = "not_applicable"
|
|
166
|
+
"""Fewer than 2 checkpoints produced a scoreable recall rate (e.g.
|
|
167
|
+
n_records too small to generate more than one checkpoint, or every
|
|
168
|
+
checkpoint's queries all errored) -- there is nothing to compare
|
|
169
|
+
"small scale" against "large scale" with. Recorded explicitly, never
|
|
170
|
+
silently dropped, matching every other *Signal enum's NOT_APPLICABLE
|
|
171
|
+
convention in this package."""
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass
|
|
175
|
+
class NeedleQueryResult:
|
|
176
|
+
"""The outcome of re-querying for one specific previously-stored
|
|
177
|
+
record by its unique marker token."""
|
|
178
|
+
|
|
179
|
+
index: int
|
|
180
|
+
marker: str
|
|
181
|
+
found: bool
|
|
182
|
+
latency_ms: float | None
|
|
183
|
+
error: str | None = None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@dataclass
|
|
187
|
+
class ScaleCheckpointResult:
|
|
188
|
+
"""A snapshot of recall taken after `checkpoint_n` records had been
|
|
189
|
+
attempted (stored or store-failed) in the corpus."""
|
|
190
|
+
|
|
191
|
+
checkpoint_n: int
|
|
192
|
+
records_stored_so_far: int
|
|
193
|
+
"""How many of the first `checkpoint_n` records in generation order
|
|
194
|
+
actually succeeded their store() call (<= checkpoint_n; less than
|
|
195
|
+
checkpoint_n only if some stores raised BackendAPIError)."""
|
|
196
|
+
needle_queries: list[NeedleQueryResult] = field(default_factory=list)
|
|
197
|
+
anchor_recall: bool | None = None
|
|
198
|
+
"""Whether the very first record ever stored (index 0) was still
|
|
199
|
+
recoverable at this checkpoint. `None` if index 0 itself failed to
|
|
200
|
+
store (so there is nothing to check recall of)."""
|
|
201
|
+
recall_rate: float | None = None
|
|
202
|
+
"""Fraction of `needle_queries` that were found, across both the
|
|
203
|
+
anchor and the general sample. `None` if no needle queries were
|
|
204
|
+
attempted at this checkpoint (should not happen once checkpoint_n
|
|
205
|
+
>= 1 and at least one record stored, but guarded rather than
|
|
206
|
+
assumed)."""
|
|
207
|
+
latency_p50_ms: float | None = None
|
|
208
|
+
latency_p99_ms: float | None = None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass
|
|
212
|
+
class ScaleTestResult:
|
|
213
|
+
"""Result of `run_scale_stress_eval()` -- distinguishes "worked at
|
|
214
|
+
scale" from "silently degraded at scale" (see ScaleSignal) for one
|
|
215
|
+
backend, one (n_records, seed) corpus."""
|
|
216
|
+
|
|
217
|
+
backend_name: str
|
|
218
|
+
n_records_requested: int
|
|
219
|
+
seed: int
|
|
220
|
+
checkpoints: list[ScaleCheckpointResult] = field(default_factory=list)
|
|
221
|
+
records_stored: int = 0
|
|
222
|
+
"""Total successful store() calls across the entire run (not just at
|
|
223
|
+
checkpoints)."""
|
|
224
|
+
records_store_errors: int = 0
|
|
225
|
+
records_checked: int = 0
|
|
226
|
+
"""Total needle queries attempted across every checkpoint."""
|
|
227
|
+
records_recoverable: int = 0
|
|
228
|
+
"""Total needle queries, across every checkpoint, that found their
|
|
229
|
+
target record."""
|
|
230
|
+
recall_degradation_pct: float | None = None
|
|
231
|
+
"""(first scoreable checkpoint's recall_rate - last scoreable
|
|
232
|
+
checkpoint's recall_rate) * 100, in percentage points. Positive means
|
|
233
|
+
recall got worse as the corpus grew; `None` if fewer than 2
|
|
234
|
+
checkpoints produced a scoreable recall_rate."""
|
|
235
|
+
anchor_lost_at_n: int | None = None
|
|
236
|
+
"""The smallest checkpoint_n at which the anchor record (index 0)
|
|
237
|
+
stopped being recoverable, having been recoverable at an earlier
|
|
238
|
+
checkpoint. `None` if the anchor was recoverable at every checkpoint
|
|
239
|
+
it was checked at, or was never successfully stored in the first
|
|
240
|
+
place."""
|
|
241
|
+
latency_p99_ms: float | None = None
|
|
242
|
+
"""p99 latency across every needle query issued during the entire
|
|
243
|
+
run (not per-checkpoint -- see ScaleCheckpointResult.latency_p99_ms
|
|
244
|
+
for the per-checkpoint breakdown)."""
|
|
245
|
+
signal: ScaleSignal = ScaleSignal.NOT_APPLICABLE
|
|
246
|
+
error: str | None = None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _default_checkpoints(n: int) -> list[int]:
|
|
250
|
+
"""Pick a small, ascending set of checkpoint sizes spanning from "the
|
|
251
|
+
same scale every other bundled fixture runs at" up to the full
|
|
252
|
+
requested N, so a degradation between "small" and "large" has
|
|
253
|
+
somewhere concrete to show up.
|
|
254
|
+
|
|
255
|
+
For a typical n_records=500 this yields [5, 50, 250, 500] -- the first
|
|
256
|
+
checkpoint deliberately mirrors the ~5-example scale every other eval's
|
|
257
|
+
fixture already runs at, so a comparison against this eval's own
|
|
258
|
+
smallest checkpoint is an apples-to-apples "does this backend still
|
|
259
|
+
work at the scale every other eval already proved it works at."
|
|
260
|
+
"""
|
|
261
|
+
candidates = [5, max(1, n // 10), max(1, n // 2), n]
|
|
262
|
+
return sorted({c for c in candidates if 1 <= c <= n})
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _percentile(values: list[float], p: float) -> float | None:
|
|
266
|
+
"""Linear-interpolation percentile, pure stdlib (no numpy dependency
|
|
267
|
+
anywhere else in this repo -- see pyproject.toml). `p` in [0, 100].
|
|
268
|
+
"""
|
|
269
|
+
if not values:
|
|
270
|
+
return None
|
|
271
|
+
ordered = sorted(values)
|
|
272
|
+
if len(ordered) == 1:
|
|
273
|
+
return ordered[0]
|
|
274
|
+
rank = (len(ordered) - 1) * (p / 100)
|
|
275
|
+
lower = math.floor(rank)
|
|
276
|
+
upper = math.ceil(rank)
|
|
277
|
+
if lower == upper:
|
|
278
|
+
return ordered[int(rank)]
|
|
279
|
+
lower_weight = ordered[lower] * (upper - rank)
|
|
280
|
+
upper_weight = ordered[upper] * (rank - lower)
|
|
281
|
+
return lower_weight + upper_weight
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _query_needle(
|
|
285
|
+
adapter: MemoryBackendAdapter,
|
|
286
|
+
record: ScaleFixtureRecord,
|
|
287
|
+
session_id: str,
|
|
288
|
+
top_k: int,
|
|
289
|
+
) -> NeedleQueryResult:
|
|
290
|
+
"""Re-query for one specific previously-stored record by its unique
|
|
291
|
+
marker token, and check whether the marker is actually present in the
|
|
292
|
+
returned text -- never trusts "query() didn't raise" as proof the
|
|
293
|
+
record came back, same rule every other eval in this package applies
|
|
294
|
+
to its own adapter calls.
|
|
295
|
+
"""
|
|
296
|
+
try:
|
|
297
|
+
query_result = adapter.query(session_id, record.marker, top_k=top_k)
|
|
298
|
+
except BackendAPIError as exc:
|
|
299
|
+
return NeedleQueryResult(
|
|
300
|
+
index=record.index, marker=record.marker, found=False, latency_ms=None, error=str(exc)
|
|
301
|
+
)
|
|
302
|
+
content = " ".join(r.content for r in query_result.records)
|
|
303
|
+
found = record.marker.lower() in content.lower()
|
|
304
|
+
return NeedleQueryResult(
|
|
305
|
+
index=record.index,
|
|
306
|
+
marker=record.marker,
|
|
307
|
+
found=found,
|
|
308
|
+
latency_ms=query_result.latency_ms,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _run_checkpoint_queries(
|
|
313
|
+
adapter: MemoryBackendAdapter,
|
|
314
|
+
corpus: list[ScaleFixtureRecord],
|
|
315
|
+
stored_indices: set[int],
|
|
316
|
+
checkpoint_n: int,
|
|
317
|
+
session_id: str,
|
|
318
|
+
rng: random.Random,
|
|
319
|
+
top_k: int,
|
|
320
|
+
) -> ScaleCheckpointResult:
|
|
321
|
+
available = [i for i in range(checkpoint_n) if i in stored_indices]
|
|
322
|
+
needle_results: list[NeedleQueryResult] = []
|
|
323
|
+
anchor_recall: bool | None = None
|
|
324
|
+
|
|
325
|
+
if 0 in stored_indices:
|
|
326
|
+
anchor_result = _query_needle(adapter, corpus[0], session_id, top_k)
|
|
327
|
+
needle_results.append(anchor_result)
|
|
328
|
+
anchor_recall = anchor_result.found
|
|
329
|
+
|
|
330
|
+
sample_pool = [i for i in available if i != 0]
|
|
331
|
+
sample_size = min(SAMPLE_SIZE_PER_CHECKPOINT, len(sample_pool))
|
|
332
|
+
sample_indices = sorted(rng.sample(sample_pool, sample_size)) if sample_size else []
|
|
333
|
+
for idx in sample_indices:
|
|
334
|
+
needle_results.append(_query_needle(adapter, corpus[idx], session_id, top_k))
|
|
335
|
+
|
|
336
|
+
found_count = sum(1 for r in needle_results if r.found)
|
|
337
|
+
recall_rate = found_count / len(needle_results) if needle_results else None
|
|
338
|
+
latencies = [r.latency_ms for r in needle_results if r.latency_ms is not None]
|
|
339
|
+
|
|
340
|
+
return ScaleCheckpointResult(
|
|
341
|
+
checkpoint_n=checkpoint_n,
|
|
342
|
+
records_stored_so_far=len(available),
|
|
343
|
+
needle_queries=needle_results,
|
|
344
|
+
anchor_recall=anchor_recall,
|
|
345
|
+
recall_rate=recall_rate,
|
|
346
|
+
latency_p50_ms=_percentile(latencies, 50),
|
|
347
|
+
latency_p99_ms=_percentile(latencies, 99),
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def classify_scale_result(
|
|
352
|
+
checkpoint_results: list[ScaleCheckpointResult],
|
|
353
|
+
recall_degradation_pct: float | None,
|
|
354
|
+
anchor_lost_at_n: int | None,
|
|
355
|
+
) -> ScaleSignal:
|
|
356
|
+
"""Classify a completed run's checkpoints into a single ScaleSignal.
|
|
357
|
+
|
|
358
|
+
Never a blind pass/fail on "did every store() call succeed" -- the
|
|
359
|
+
classification is driven entirely by recomputed recall at each
|
|
360
|
+
checkpoint (see ScaleCheckpointResult.recall_rate), the same
|
|
361
|
+
ground-truth-driven pattern every other eval's classify_* function in
|
|
362
|
+
this package follows (evals/contradiction.py's classify_case,
|
|
363
|
+
evals/ranking_quality.py's classify_ranking_case,
|
|
364
|
+
evals/resource_sync_safety.py's classify_resource_sync_file).
|
|
365
|
+
"""
|
|
366
|
+
scoreable = [c for c in checkpoint_results if c.recall_rate is not None]
|
|
367
|
+
if len(scoreable) < 2:
|
|
368
|
+
return ScaleSignal.NOT_APPLICABLE
|
|
369
|
+
|
|
370
|
+
final_recall = scoreable[-1].recall_rate
|
|
371
|
+
if anchor_lost_at_n is not None:
|
|
372
|
+
return ScaleSignal.SILENTLY_DEGRADED_AT_SCALE
|
|
373
|
+
if recall_degradation_pct is not None and recall_degradation_pct >= DEGRADATION_THRESHOLD_PP:
|
|
374
|
+
return ScaleSignal.SILENTLY_DEGRADED_AT_SCALE
|
|
375
|
+
if final_recall is not None and final_recall < MIN_ACCEPTABLE_FINAL_RECALL:
|
|
376
|
+
return ScaleSignal.PARTIAL_DEGRADATION
|
|
377
|
+
return ScaleSignal.WORKED_AT_SCALE
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def run_scale_stress_eval(
|
|
381
|
+
adapter: MemoryBackendAdapter,
|
|
382
|
+
n_records: int = DEFAULT_N_RECORDS,
|
|
383
|
+
seed: int = 42,
|
|
384
|
+
session_id: str = "scale-stress-session",
|
|
385
|
+
checkpoints: list[int] | None = None,
|
|
386
|
+
top_k: int = 10,
|
|
387
|
+
) -> ScaleTestResult:
|
|
388
|
+
"""Store `n_records` synthetic records into `adapter` incrementally,
|
|
389
|
+
and at each checkpoint re-query a sample of already-stored records
|
|
390
|
+
(plus the fixed first-ever-stored "anchor" record) by their unique
|
|
391
|
+
marker token to measure recall as a function of corpus size.
|
|
392
|
+
|
|
393
|
+
Args:
|
|
394
|
+
adapter: the backend under test.
|
|
395
|
+
n_records: how many records to generate and store. Default
|
|
396
|
+
(DEFAULT_N_RECORDS=500) is deliberately fast-in-CI, not the
|
|
397
|
+
10K+ scale the motivating vendor bugs manifest at -- pass a
|
|
398
|
+
larger value to actually reach that regime against a live,
|
|
399
|
+
configured backend. The harness itself places no additional
|
|
400
|
+
cap beyond scale_fixtures.generate_scale_corpus's 999,999.
|
|
401
|
+
seed: forwarded to generate_scale_corpus() for a reproducible
|
|
402
|
+
corpus, and used to seed this function's own sampling RNG
|
|
403
|
+
(kept separate from any RNG the adapter itself might use).
|
|
404
|
+
session_id: session/scope every record is stored under.
|
|
405
|
+
checkpoints: explicit checkpoint sizes to snapshot recall at.
|
|
406
|
+
Defaults to `_default_checkpoints(n_records)` (deliberately
|
|
407
|
+
includes a checkpoint at the same ~5-record scale every other
|
|
408
|
+
bundled fixture already runs at, so this eval's own smallest
|
|
409
|
+
checkpoint is directly comparable to "does this backend work
|
|
410
|
+
at all," and a checkpoint at the full n_records).
|
|
411
|
+
top_k: top_k passed to every needle query() call.
|
|
412
|
+
|
|
413
|
+
Raises:
|
|
414
|
+
ValueError: if n_records < 1 (mirrors
|
|
415
|
+
scale_fixtures.generate_scale_corpus's own validation).
|
|
416
|
+
"""
|
|
417
|
+
if n_records < 1:
|
|
418
|
+
raise ValueError(f"n_records must be >= 1, got {n_records}")
|
|
419
|
+
|
|
420
|
+
resolved_checkpoints = (
|
|
421
|
+
sorted({c for c in checkpoints if 1 <= c <= n_records})
|
|
422
|
+
if checkpoints is not None
|
|
423
|
+
else _default_checkpoints(n_records)
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
corpus = generate_scale_corpus(n_records, seed=seed, session_id=session_id)
|
|
427
|
+
rng = random.Random(seed)
|
|
428
|
+
|
|
429
|
+
result = ScaleTestResult(backend_name=adapter.name, n_records_requested=n_records, seed=seed)
|
|
430
|
+
stored_indices: set[int] = set()
|
|
431
|
+
next_checkpoint_idx = 0
|
|
432
|
+
all_latencies: list[float] = []
|
|
433
|
+
anchor_ever_recovered = False
|
|
434
|
+
|
|
435
|
+
for record in corpus:
|
|
436
|
+
try:
|
|
437
|
+
adapter.store(
|
|
438
|
+
session_id,
|
|
439
|
+
record.content,
|
|
440
|
+
metadata={"scale_index": str(record.index)},
|
|
441
|
+
)
|
|
442
|
+
stored_indices.add(record.index)
|
|
443
|
+
result.records_stored += 1
|
|
444
|
+
except BackendAPIError as exc:
|
|
445
|
+
result.records_store_errors += 1
|
|
446
|
+
if result.error is None:
|
|
447
|
+
result.error = f"first store failure at index={record.index}: {exc}"
|
|
448
|
+
|
|
449
|
+
attempted_so_far = record.index + 1
|
|
450
|
+
while (
|
|
451
|
+
next_checkpoint_idx < len(resolved_checkpoints)
|
|
452
|
+
and resolved_checkpoints[next_checkpoint_idx] == attempted_so_far
|
|
453
|
+
):
|
|
454
|
+
checkpoint_n = resolved_checkpoints[next_checkpoint_idx]
|
|
455
|
+
checkpoint_result = _run_checkpoint_queries(
|
|
456
|
+
adapter, corpus, stored_indices, checkpoint_n, session_id, rng, top_k
|
|
457
|
+
)
|
|
458
|
+
result.checkpoints.append(checkpoint_result)
|
|
459
|
+
|
|
460
|
+
result.records_checked += len(checkpoint_result.needle_queries)
|
|
461
|
+
result.records_recoverable += sum(
|
|
462
|
+
1 for q in checkpoint_result.needle_queries if q.found
|
|
463
|
+
)
|
|
464
|
+
all_latencies.extend(
|
|
465
|
+
q.latency_ms for q in checkpoint_result.needle_queries if q.latency_ms is not None
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
if checkpoint_result.anchor_recall is True:
|
|
469
|
+
anchor_ever_recovered = True
|
|
470
|
+
elif (
|
|
471
|
+
checkpoint_result.anchor_recall is False
|
|
472
|
+
and anchor_ever_recovered
|
|
473
|
+
and result.anchor_lost_at_n is None
|
|
474
|
+
):
|
|
475
|
+
result.anchor_lost_at_n = checkpoint_n
|
|
476
|
+
|
|
477
|
+
next_checkpoint_idx += 1
|
|
478
|
+
|
|
479
|
+
scoreable = [c for c in result.checkpoints if c.recall_rate is not None]
|
|
480
|
+
if len(scoreable) >= 2:
|
|
481
|
+
first_rate = scoreable[0].recall_rate
|
|
482
|
+
last_rate = scoreable[-1].recall_rate
|
|
483
|
+
if first_rate is not None and last_rate is not None:
|
|
484
|
+
result.recall_degradation_pct = (first_rate - last_rate) * 100
|
|
485
|
+
|
|
486
|
+
result.latency_p99_ms = _percentile(all_latencies, 99)
|
|
487
|
+
result.signal = classify_scale_result(
|
|
488
|
+
result.checkpoints, result.recall_degradation_pct, result.anchor_lost_at_n
|
|
489
|
+
)
|
|
490
|
+
if (
|
|
491
|
+
result.signal == ScaleSignal.NOT_APPLICABLE
|
|
492
|
+
and result.records_stored == 0
|
|
493
|
+
and result.records_store_errors > 0
|
|
494
|
+
):
|
|
495
|
+
# Every single store() call failed -- there is nothing to compute
|
|
496
|
+
# recall over, but "nothing to compute" (a genuinely empty corpus,
|
|
497
|
+
# or a corpus too small to have >=2 checkpoints) and "every write
|
|
498
|
+
# to the backend errored" are different findings. The latter is a
|
|
499
|
+
# concrete, explicit failure and deserves its own signal rather
|
|
500
|
+
# than being folded into the generic "not enough data" bucket.
|
|
501
|
+
result.signal = ScaleSignal.ERROR
|
|
502
|
+
return result
|