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