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