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,239 @@
|
|
|
1
|
+
"""MemTrust's repeated-query determinism eval.
|
|
2
|
+
|
|
3
|
+
Every other eval in this package classifies a SINGLE query() response, or
|
|
4
|
+
compares two responses separated by some other event (a crash, a resync,
|
|
5
|
+
a migration). None of them can see a backend that is internally
|
|
6
|
+
non-deterministic: each individual response can look perfectly well-formed
|
|
7
|
+
-- real records, real content, no error -- while the *set* of records
|
|
8
|
+
returned for the exact same query, against data that never changed,
|
|
9
|
+
varies from call to call.
|
|
10
|
+
|
|
11
|
+
Motivating case: volcengine/OpenViking#204 (contributor ponsde, a repeat
|
|
12
|
+
contributor to this project). `search()`/`find()` returned non-
|
|
13
|
+
deterministic result sets for identical repeated queries: 5 runs of the
|
|
14
|
+
same query returned an average pairwise Jaccard similarity of 0.11 over
|
|
15
|
+
the returned memory/resource URIs, and a later, more rigorous 3-query x
|
|
16
|
+
5-run test found some query/method combinations sharing ZERO common URIs
|
|
17
|
+
across all 5 runs. ponsde self-diagnosed two candidate root causes through
|
|
18
|
+
extensive hands-on follow-up: an embedding-dimension mismatch (a
|
|
19
|
+
production vector collection created at `Dimension:3072` while the active
|
|
20
|
+
embedding config specified `1024`), and/or non-deterministic graph
|
|
21
|
+
traversal inside the HNSW ANN search implementation itself. This eval
|
|
22
|
+
reuses ponsde's own methodology directly: issue the identical query N
|
|
23
|
+
times against unchanged fixture data, and compute the average pairwise
|
|
24
|
+
(consecutive-run) Jaccard similarity over the returned record ids.
|
|
25
|
+
|
|
26
|
+
See ConsistencySignal in adapters/base.py for the taxonomy this eval
|
|
27
|
+
scores against.
|
|
28
|
+
|
|
29
|
+
Honest limitation (same convention as every other eval's module docstring
|
|
30
|
+
in this package): this eval can only measure what it can observe through
|
|
31
|
+
`query()` -- it has no access to OpenViking's internal ANN index state or
|
|
32
|
+
embedding-config metadata, so it cannot itself distinguish ponsde's two
|
|
33
|
+
candidate root causes from each other, or from an unrelated source of
|
|
34
|
+
non-determinism (e.g. a backend that intentionally randomizes tie-breaks).
|
|
35
|
+
It proves the harness can *detect* the same result-instability shape
|
|
36
|
+
ponsde documented, using his own methodology, against any adapter pointed
|
|
37
|
+
at it -- not that any specific root cause is present.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import json
|
|
43
|
+
from dataclasses import dataclass, field
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
from typing import Any
|
|
46
|
+
|
|
47
|
+
from memtrust.adapters.base import BackendAPIError, ConsistencySignal, MemoryBackendAdapter
|
|
48
|
+
|
|
49
|
+
DEFAULT_FIXTURE_PATH = (
|
|
50
|
+
Path(__file__).resolve().parents[3] / "tests" / "fixtures" / "result_consistency_cases.json"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
#: Default number of times each case's query is repeated. ponsde's own
|
|
54
|
+
#: repro used 5 runs per query; kept as this eval's default for direct
|
|
55
|
+
#: comparability with his reported numbers.
|
|
56
|
+
DEFAULT_N_REPEATS = 5
|
|
57
|
+
|
|
58
|
+
#: Default minimum average pairwise Jaccard similarity for a case to be
|
|
59
|
+
#: classified CONSISTENT. Not 1.0 (perfect overlap every run): a
|
|
60
|
+
#: genuinely ranked-and-truncated top_k result can reasonably reorder
|
|
61
|
+
#: near a score tie without that being a real bug -- this tolerance is
|
|
62
|
+
#: deliberately generous compared to ponsde's own measured 0.11 average
|
|
63
|
+
#: for the broken case, so a backend has to be substantially unstable to
|
|
64
|
+
#: fail this threshold.
|
|
65
|
+
DEFAULT_CONSISTENCY_THRESHOLD = 0.9
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class ConsistencySeedRecord:
|
|
70
|
+
content: str
|
|
71
|
+
metadata: dict[str, str] = field(default_factory=dict)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class ConsistencyCase:
|
|
76
|
+
case_id: str
|
|
77
|
+
session_id: str
|
|
78
|
+
query: str
|
|
79
|
+
records: list[ConsistencySeedRecord]
|
|
80
|
+
n_repeats: int = DEFAULT_N_REPEATS
|
|
81
|
+
threshold: float = DEFAULT_CONSISTENCY_THRESHOLD
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class ConsistencyCaseResult:
|
|
86
|
+
case: ConsistencyCase
|
|
87
|
+
signal: ConsistencySignal
|
|
88
|
+
average_jaccard: float | None
|
|
89
|
+
"""Average pairwise (consecutive-run) Jaccard similarity over the
|
|
90
|
+
result-id sets from each successful query() call. None when fewer
|
|
91
|
+
than 2 calls succeeded."""
|
|
92
|
+
n_successful_runs: int
|
|
93
|
+
result_id_sets: list[list[str]]
|
|
94
|
+
"""Each successful run's returned record-id set, in call order, kept
|
|
95
|
+
for audit purposes -- e.g. to see exactly which ids appeared/vanished
|
|
96
|
+
between runs."""
|
|
97
|
+
error: str | None = None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class ConsistencyEvalResult:
|
|
102
|
+
backend_name: str
|
|
103
|
+
dataset_path: str
|
|
104
|
+
case_results: list[ConsistencyCaseResult] = field(default_factory=list)
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def scored_cases(self) -> list[ConsistencyCaseResult]:
|
|
108
|
+
return [c for c in self.case_results if c.error is None]
|
|
109
|
+
|
|
110
|
+
def _fraction(self, signal: ConsistencySignal) -> float | None:
|
|
111
|
+
scored = self.scored_cases
|
|
112
|
+
if not scored:
|
|
113
|
+
return None
|
|
114
|
+
matching = sum(1 for c in scored if c.signal == signal)
|
|
115
|
+
return matching / len(scored)
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def consistent_rate(self) -> float | None:
|
|
119
|
+
return self._fraction(ConsistencySignal.CONSISTENT)
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def inconsistent_rate(self) -> float | None:
|
|
123
|
+
"""Fraction of cases whose repeated, identical query against
|
|
124
|
+
unchanged data produced meaningfully different result sets --
|
|
125
|
+
the headline metric this eval exists to surface, and the exact
|
|
126
|
+
volcengine/OpenViking#204 shape."""
|
|
127
|
+
return self._fraction(ConsistencySignal.INCONSISTENT)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def load_dataset(path: Path | str = DEFAULT_FIXTURE_PATH) -> list[ConsistencyCase]:
|
|
131
|
+
data = json.loads(Path(path).read_text())
|
|
132
|
+
cases: list[dict[str, Any]] = data["cases"]
|
|
133
|
+
return [
|
|
134
|
+
ConsistencyCase(
|
|
135
|
+
case_id=c["case_id"],
|
|
136
|
+
session_id=c["session_id"],
|
|
137
|
+
query=c["query"],
|
|
138
|
+
n_repeats=c.get("n_repeats", DEFAULT_N_REPEATS),
|
|
139
|
+
threshold=c.get("threshold", DEFAULT_CONSISTENCY_THRESHOLD),
|
|
140
|
+
records=[
|
|
141
|
+
ConsistencySeedRecord(content=r["content"], metadata=r.get("metadata", {}))
|
|
142
|
+
for r in c["records"]
|
|
143
|
+
],
|
|
144
|
+
)
|
|
145
|
+
for c in cases
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _jaccard(a: set[str], b: set[str]) -> float:
|
|
150
|
+
"""Jaccard similarity between two id sets. Two empty sets are treated
|
|
151
|
+
as perfectly similar (1.0) -- "returned nothing, twice" is a
|
|
152
|
+
consistency-neutral result for THIS metric (a separate eval,
|
|
153
|
+
evals/contradiction.py's EMPTY_OR_LOST handling, already covers
|
|
154
|
+
"returned nothing when it should not have")."""
|
|
155
|
+
if not a and not b:
|
|
156
|
+
return 1.0
|
|
157
|
+
union = a | b
|
|
158
|
+
if not union:
|
|
159
|
+
return 1.0
|
|
160
|
+
return len(a & b) / len(union)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def classify_consistency_case(
|
|
164
|
+
result_id_sets: list[set[str]],
|
|
165
|
+
threshold: float = DEFAULT_CONSISTENCY_THRESHOLD,
|
|
166
|
+
) -> tuple[ConsistencySignal, float | None]:
|
|
167
|
+
"""Classify a case's outcome from its list of per-run result-id sets,
|
|
168
|
+
using ponsde's own consecutive-pair Jaccard-similarity methodology
|
|
169
|
+
(volcengine/OpenViking#204's own hand-built repro script compares run
|
|
170
|
+
i against run i+1, not every pair against every other pair).
|
|
171
|
+
|
|
172
|
+
Returns (signal, average_jaccard). average_jaccard is None when fewer
|
|
173
|
+
than 2 runs are available to form a pair -- NOT_APPLICABLE in that
|
|
174
|
+
case, never guessed at either way.
|
|
175
|
+
"""
|
|
176
|
+
if len(result_id_sets) < 2:
|
|
177
|
+
return ConsistencySignal.NOT_APPLICABLE, None
|
|
178
|
+
pairwise = [
|
|
179
|
+
_jaccard(result_id_sets[i], result_id_sets[i + 1]) for i in range(len(result_id_sets) - 1)
|
|
180
|
+
]
|
|
181
|
+
average = sum(pairwise) / len(pairwise)
|
|
182
|
+
if average >= threshold:
|
|
183
|
+
return ConsistencySignal.CONSISTENT, average
|
|
184
|
+
return ConsistencySignal.INCONSISTENT, average
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _result_ids(records: list[Any]) -> set[str]:
|
|
188
|
+
"""Prefer memory_id when the adapter reports a real one; fall back to
|
|
189
|
+
the record's own content as an id-surrogate for adapters/fixtures
|
|
190
|
+
where memory_id can be empty on some responses -- same
|
|
191
|
+
"id-first, content-fallback" convention verify_store() already uses
|
|
192
|
+
in adapters/base.py."""
|
|
193
|
+
ids: set[str] = set()
|
|
194
|
+
for record in records:
|
|
195
|
+
ids.add(record.memory_id if record.memory_id else f"content:{record.content}")
|
|
196
|
+
return ids
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def run_result_consistency_eval(
|
|
200
|
+
adapter: MemoryBackendAdapter,
|
|
201
|
+
dataset_path: Path | str = DEFAULT_FIXTURE_PATH,
|
|
202
|
+
) -> ConsistencyEvalResult:
|
|
203
|
+
cases = load_dataset(dataset_path)
|
|
204
|
+
result = ConsistencyEvalResult(backend_name=adapter.name, dataset_path=str(dataset_path))
|
|
205
|
+
|
|
206
|
+
for case in cases:
|
|
207
|
+
try:
|
|
208
|
+
for seed in case.records:
|
|
209
|
+
adapter.store(case.session_id, seed.content, metadata=seed.metadata)
|
|
210
|
+
|
|
211
|
+
result_id_sets: list[set[str]] = []
|
|
212
|
+
for _ in range(case.n_repeats):
|
|
213
|
+
query_result = adapter.query(case.session_id, case.query, top_k=len(case.records))
|
|
214
|
+
result_id_sets.append(_result_ids(query_result.records))
|
|
215
|
+
except BackendAPIError as exc:
|
|
216
|
+
result.case_results.append(
|
|
217
|
+
ConsistencyCaseResult(
|
|
218
|
+
case=case,
|
|
219
|
+
signal=ConsistencySignal.NOT_APPLICABLE,
|
|
220
|
+
average_jaccard=None,
|
|
221
|
+
n_successful_runs=0,
|
|
222
|
+
result_id_sets=[],
|
|
223
|
+
error=str(exc),
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
continue
|
|
227
|
+
|
|
228
|
+
signal, average = classify_consistency_case(result_id_sets, case.threshold)
|
|
229
|
+
result.case_results.append(
|
|
230
|
+
ConsistencyCaseResult(
|
|
231
|
+
case=case,
|
|
232
|
+
signal=signal,
|
|
233
|
+
average_jaccard=average,
|
|
234
|
+
n_successful_runs=len(result_id_sets),
|
|
235
|
+
result_id_sets=[sorted(s) for s in result_id_sets],
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
return result
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Deterministic large-scale synthetic corpus generator for
|
|
2
|
+
evals/scale_stress.py.
|
|
3
|
+
|
|
4
|
+
Every other fixture in this repo (`tests/fixtures/*.json`) is a hand-written
|
|
5
|
+
file with 3-7 examples -- fine for exercising contradiction/ranking/
|
|
6
|
+
resource-sync logic, but 3-5 orders of magnitude below the ~100K-record or
|
|
7
|
+
~300-episode scale where several real, documented vendor bugs actually
|
|
8
|
+
manifest:
|
|
9
|
+
|
|
10
|
+
* volcengine/OpenViking#2850 (lg320531124, still open): BM25 search
|
|
11
|
+
silently returning empty results once a corpus grows large. A
|
|
12
|
+
hand-written 5-record fixture structurally cannot reach the corpus size
|
|
13
|
+
where this shows up.
|
|
14
|
+
* getzep/graphiti#1275 (rafaelreis-r): O(n) entity-resolution context
|
|
15
|
+
growth causing episodes to be silently dropped once ingestion passes
|
|
16
|
+
roughly 300 episodes. Same problem -- no bundled fixture has 300 of
|
|
17
|
+
anything.
|
|
18
|
+
|
|
19
|
+
A hand-written JSON file cannot solve this: nobody is going to hand-type
|
|
20
|
+
10,000 realistic-looking memory facts into a fixture file, and even if they
|
|
21
|
+
did, it would not be reproducible/adjustable the way a parameterized
|
|
22
|
+
generator is. This module is a function, not a file -- `generate_scale_corpus
|
|
23
|
+
(n, seed=...)` produces N synthetic records with realistic-shaped content,
|
|
24
|
+
deterministically, for any N a caller asks for (5 or 100,000), so
|
|
25
|
+
evals/scale_stress.py can be pointed at whatever scale a real run needs
|
|
26
|
+
without checking a giant file into git.
|
|
27
|
+
|
|
28
|
+
Design notes:
|
|
29
|
+
* Determinism is via `random.Random(seed)` -- the same (n, seed) pair
|
|
30
|
+
always produces byte-identical output, which is what makes a scale run
|
|
31
|
+
reproducible across two separate `memtrust run` invocations.
|
|
32
|
+
* Every record's content embeds a unique, greppable marker token
|
|
33
|
+
(`SCALEMARK{index:06d}`) that appears nowhere else in the corpus. This
|
|
34
|
+
is deliberate: it turns "is this specific record still findable" into a
|
|
35
|
+
literal substring/keyword search, the same shape of query BM25-style
|
|
36
|
+
lexical search actually serves, and the shape #2850 concerns (a literal
|
|
37
|
+
keyword search coming back empty). A record's `marker` field is what
|
|
38
|
+
evals/scale_stress.py queries for when it wants to test recall of one
|
|
39
|
+
specific record.
|
|
40
|
+
* The generated content is deliberately fact-shaped prose (a name, a
|
|
41
|
+
topic, an action, a detail), not lorem-ipsum noise or a bare marker
|
|
42
|
+
string -- so a backend that does real embedding/tokenization work has
|
|
43
|
+
something realistic to index, not a degenerate one-token input that
|
|
44
|
+
would never occur in a real ingestion pipeline.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import random
|
|
50
|
+
from dataclasses import dataclass, field
|
|
51
|
+
|
|
52
|
+
#: Format for each record's unique, greppable marker token. Six digits
|
|
53
|
+
#: comfortably covers every N this module is meant to be used at (up to
|
|
54
|
+
#: 999,999); a caller requesting more than that gets a ValueError rather
|
|
55
|
+
#: than silently colliding marker tokens.
|
|
56
|
+
_MARKER_FORMAT = "SCALEMARK{index:06d}"
|
|
57
|
+
_MAX_RECORDS = 999_999
|
|
58
|
+
|
|
59
|
+
_FIRST_NAMES = (
|
|
60
|
+
"Priya",
|
|
61
|
+
"Jordan",
|
|
62
|
+
"Sam",
|
|
63
|
+
"Alex",
|
|
64
|
+
"Morgan",
|
|
65
|
+
"Taylor",
|
|
66
|
+
"Casey",
|
|
67
|
+
"Jamie",
|
|
68
|
+
"Riley",
|
|
69
|
+
"Devon",
|
|
70
|
+
"Nadia",
|
|
71
|
+
"Kenji",
|
|
72
|
+
"Elena",
|
|
73
|
+
"Marcus",
|
|
74
|
+
"Ines",
|
|
75
|
+
"Tobias",
|
|
76
|
+
"Ling",
|
|
77
|
+
"Omar",
|
|
78
|
+
"Freya",
|
|
79
|
+
"Dante",
|
|
80
|
+
)
|
|
81
|
+
_TOPICS = (
|
|
82
|
+
"the quarterly budget review",
|
|
83
|
+
"the onboarding checklist",
|
|
84
|
+
"the API rate limit increase",
|
|
85
|
+
"the deployment pipeline",
|
|
86
|
+
"the customer escalation queue",
|
|
87
|
+
"the on-call rotation",
|
|
88
|
+
"the vendor security questionnaire",
|
|
89
|
+
"the data retention policy",
|
|
90
|
+
"the staging environment refresh",
|
|
91
|
+
"the incident postmortem",
|
|
92
|
+
"the roadmap planning doc",
|
|
93
|
+
"the support ticket backlog",
|
|
94
|
+
"the pricing experiment",
|
|
95
|
+
"the migration runbook",
|
|
96
|
+
"the compliance audit",
|
|
97
|
+
)
|
|
98
|
+
_ACTIONS = (
|
|
99
|
+
"moved to",
|
|
100
|
+
"was updated to reference",
|
|
101
|
+
"got flagged for follow-up regarding",
|
|
102
|
+
"was rescheduled around",
|
|
103
|
+
"now depends on",
|
|
104
|
+
"was assigned to",
|
|
105
|
+
"was blocked by",
|
|
106
|
+
"was reprioritized after",
|
|
107
|
+
"was archived following",
|
|
108
|
+
"needs another look because of",
|
|
109
|
+
)
|
|
110
|
+
_DETAILS = (
|
|
111
|
+
"Thursday afternoon",
|
|
112
|
+
"the new staging environment",
|
|
113
|
+
"ticket ORT-4471",
|
|
114
|
+
"the Q3 roadmap",
|
|
115
|
+
"last week's incident review",
|
|
116
|
+
"the new compliance requirement",
|
|
117
|
+
"a customer escalation from EMEA",
|
|
118
|
+
"the vendor's revised SLA",
|
|
119
|
+
"the platform team's capacity",
|
|
120
|
+
"next sprint's planning session",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class ScaleFixtureRecord:
|
|
126
|
+
"""One synthetic record in a generated scale corpus.
|
|
127
|
+
|
|
128
|
+
`index` is this record's 0-based position in generation order --
|
|
129
|
+
evals/scale_stress.py uses it as the "insertion order" ground truth,
|
|
130
|
+
the same role `RankingQualitySeedRecord`'s list position plays in
|
|
131
|
+
evals/ranking_quality.py. `marker` is the unique greppable token
|
|
132
|
+
embedded in `content`; querying for it (rather than for arbitrary
|
|
133
|
+
natural-language text) is what turns "is this record still
|
|
134
|
+
recoverable" into a deterministic, unambiguous substring check.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
index: int
|
|
138
|
+
content: str
|
|
139
|
+
marker: str
|
|
140
|
+
metadata: dict[str, str] = field(default_factory=dict)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def generate_scale_corpus(
|
|
144
|
+
n: int, seed: int = 42, session_id: str = "scale-stress-session"
|
|
145
|
+
) -> list[ScaleFixtureRecord]:
|
|
146
|
+
"""Generate `n` deterministic, realistic-shaped synthetic records.
|
|
147
|
+
|
|
148
|
+
Same (n, seed) always produces the same output -- this is what lets a
|
|
149
|
+
scale run be reproduced exactly (e.g. to confirm a degradation finding
|
|
150
|
+
wasn't a one-off fluke) without checking a giant fixture file into git.
|
|
151
|
+
Architected to scale from a handful of records (a fast CI smoke test)
|
|
152
|
+
up to 10K+ (a real stress run against a live backend); the only cost of
|
|
153
|
+
a larger N is the loop below, there is no fixed-size data file to
|
|
154
|
+
outgrow.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
n: how many records to generate. Must be >= 1 and <= 999,999 (see
|
|
158
|
+
`_MAX_RECORDS` -- the marker format's fixed width).
|
|
159
|
+
seed: random seed. Same seed -> byte-identical corpus.
|
|
160
|
+
session_id: stamped into each record's metadata for callers that
|
|
161
|
+
want it; scale_stress.py's own session scoping is independent
|
|
162
|
+
of this (it passes its own session_id to store()/query()).
|
|
163
|
+
|
|
164
|
+
Raises:
|
|
165
|
+
ValueError: if `n` is out of the supported range.
|
|
166
|
+
"""
|
|
167
|
+
if n < 1:
|
|
168
|
+
raise ValueError(f"n must be >= 1, got {n}")
|
|
169
|
+
if n > _MAX_RECORDS:
|
|
170
|
+
raise ValueError(f"n must be <= {_MAX_RECORDS}, got {n}")
|
|
171
|
+
|
|
172
|
+
rng = random.Random(seed)
|
|
173
|
+
records: list[ScaleFixtureRecord] = []
|
|
174
|
+
for index in range(n):
|
|
175
|
+
name = rng.choice(_FIRST_NAMES)
|
|
176
|
+
topic = rng.choice(_TOPICS)
|
|
177
|
+
action = rng.choice(_ACTIONS)
|
|
178
|
+
detail = rng.choice(_DETAILS)
|
|
179
|
+
marker = _MARKER_FORMAT.format(index=index)
|
|
180
|
+
content = f"On day {index}, {name} noted that {topic} {action} {detail}. (ref: {marker})"
|
|
181
|
+
records.append(
|
|
182
|
+
ScaleFixtureRecord(
|
|
183
|
+
index=index,
|
|
184
|
+
content=content,
|
|
185
|
+
marker=marker,
|
|
186
|
+
metadata={"scale_index": str(index), "session_id": session_id},
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
return records
|