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
memtrust/cli.py ADDED
@@ -0,0 +1,1201 @@
1
+ """`memtrust run` and `memtrust report` -- the CLI entry points.
2
+
3
+ `memtrust run` executes the requested eval suite against whichever
4
+ requested backends actually have credentials configured. An unconfigured
5
+ backend prints SKIPPED and the run continues -- this command never raises
6
+ on missing credentials, which is what lets it run in a fresh clone or in
7
+ CI with zero vendor API keys. See adapters/base.py's
8
+ BackendNotConfiguredError contract.
9
+
10
+ `memtrust report` reads a prior run's JSON output and prints a formatted
11
+ summary -- it never re-runs anything or re-derives a score, it only
12
+ reformats what a `memtrust run` invocation already produced and wrote to
13
+ disk.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import sys
20
+ from dataclasses import asdict
21
+ from datetime import UTC, datetime
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ import click
26
+ from rich.console import Console
27
+ from rich.table import Table
28
+
29
+ from memtrust import __version__
30
+ from memtrust.adapters import ADAPTER_REGISTRY
31
+ from memtrust.adapters.base import BackendNotConfiguredError, MemoryBackendAdapter
32
+ from memtrust.evals.compression import CompressionEvalResult, run_compression_eval
33
+ from memtrust.evals.contradiction import ContradictionEvalResult, run_contradiction_eval
34
+ from memtrust.evals.crash_recovery import CrashRecoveryEvalResult, run_crash_recovery_eval
35
+ from memtrust.evals.embedding_drift import EmbeddingDriftEvalResult, run_embedding_drift_eval
36
+ from memtrust.evals.extraction_quality import (
37
+ ExtractionQualityEvalResult,
38
+ run_extraction_quality_eval,
39
+ )
40
+ from memtrust.evals.filter_injection import FilterInjectionEvalResult, run_filter_injection_eval
41
+ from memtrust.evals.lock_contention import LockContentionEvalResult, run_lock_contention_eval
42
+ from memtrust.evals.locomo import LoCoMoResult, load_exclude_question_ids, run_locomo
43
+ from memtrust.evals.longmemeval import LongMemEvalResult, run_longmemeval
44
+ from memtrust.evals.migration_rollback import (
45
+ MigrationRollbackEvalResult,
46
+ run_migration_rollback_eval,
47
+ )
48
+ from memtrust.evals.orphan_cleanup import OrphanCleanupEvalResult, run_orphan_cleanup_eval
49
+ from memtrust.evals.ranking_quality import RankingQualityEvalResult, run_ranking_quality_eval
50
+ from memtrust.evals.resource_sync_safety import ResourceSyncEvalResult, run_resource_sync_eval
51
+ from memtrust.evals.result_consistency import ConsistencyEvalResult, run_result_consistency_eval
52
+ from memtrust.evals.scale_stress import (
53
+ DEFAULT_N_RECORDS,
54
+ ScaleTestResult,
55
+ run_scale_stress_eval,
56
+ )
57
+ from memtrust.evals.stats_accuracy import StatsAccuracyEvalResult, run_stats_accuracy_eval
58
+ from memtrust.receipt import (
59
+ PUBLIC_KEY_ENV_VAR,
60
+ ReceiptError,
61
+ receipt_path_for,
62
+ sign_report_with_keyfile,
63
+ verify_receipt_file,
64
+ write_keypair,
65
+ )
66
+ from memtrust.scoring.cost_tracker import CostTracker
67
+ from memtrust.scoring.llm_judge import LLMJudge
68
+
69
+ #: Explicit width rather than relying on terminal auto-detection -- with 16
70
+ #: evals now registered, the `report` table has 18 columns; under a
71
+ #: non-tty runner (tests, CI logs) rich's default-width fallback wraps
72
+ #: cell text across lines, which is cosmetic in a real terminal but breaks
73
+ #: substring assertions on rendered output. A fixed wide width keeps
74
+ #: rendering deterministic in both contexts.
75
+ console = Console(width=400)
76
+
77
+ #: The 4 canonical, non-aliased backend names v0.1 tracks at full eval
78
+ #: depth. "zep" and "graphiti" both resolve to the same adapter in
79
+ #: ADAPTER_REGISTRY; "all" expands to this list, not to every registry key,
80
+ #: so a backend is never silently evaluated twice under two names.
81
+ ALL_BACKENDS = ["mempalace", "mem0", "zep", "openviking"]
82
+ ALL_EVALS = [
83
+ "longmemeval",
84
+ "locomo",
85
+ "contradiction",
86
+ "resource_sync_safety",
87
+ "compression",
88
+ "ranking_quality",
89
+ "scale_stress",
90
+ "embedding_drift",
91
+ "crash_recovery",
92
+ "extraction_quality",
93
+ "migration_rollback",
94
+ "filter_injection",
95
+ "lock_contention",
96
+ "stats_accuracy",
97
+ "orphan_cleanup",
98
+ "result_consistency",
99
+ ]
100
+
101
+
102
+ def _resolve_backend_names(backends_arg: str) -> list[str]:
103
+ if backends_arg.strip().lower() == "all":
104
+ return list(ALL_BACKENDS)
105
+ names = [n.strip().lower() for n in backends_arg.split(",") if n.strip()]
106
+ unknown = [n for n in names if n not in ADAPTER_REGISTRY]
107
+ if unknown:
108
+ raise click.BadParameter(
109
+ f"unknown backend(s): {', '.join(unknown)}. "
110
+ f"Known backends: {', '.join(sorted(set(ALL_BACKENDS)))}"
111
+ )
112
+ return names
113
+
114
+
115
+ def _resolve_eval_names(eval_arg: str) -> list[str]:
116
+ if eval_arg.strip().lower() == "all":
117
+ return list(ALL_EVALS)
118
+ names = [n.strip().lower() for n in eval_arg.split(",") if n.strip()]
119
+ unknown = [n for n in names if n not in ALL_EVALS]
120
+ if unknown:
121
+ raise click.BadParameter(
122
+ f"unknown eval(s): {', '.join(unknown)}. Known evals: {', '.join(ALL_EVALS)}"
123
+ )
124
+ return names
125
+
126
+
127
+ def _serialize_eval_result(result: object) -> dict[str, Any]:
128
+ """Best-effort dataclass -> plain dict for JSON output. Nested
129
+ dataclasses (case results) are converted the same way via asdict.
130
+ """
131
+ if isinstance(result, LongMemEvalResult):
132
+ return {
133
+ "backend": result.backend_name,
134
+ "dataset_path": result.dataset_path,
135
+ "accuracy": result.accuracy,
136
+ "judge_unavailable": result.judge_unavailable,
137
+ "n_cases": len(result.case_results),
138
+ "n_graded": len(result.graded_cases),
139
+ "n_records_empty": result.n_records_empty,
140
+ "cases": [asdict(c) for c in result.case_results],
141
+ }
142
+ if isinstance(result, LoCoMoResult):
143
+ return {
144
+ "backend": result.backend_name,
145
+ "dataset_path": result.dataset_path,
146
+ "accuracy": result.accuracy,
147
+ "non_adversarial_accuracy": result.non_adversarial_accuracy,
148
+ "accuracy_by_category": result.accuracy_by_category(),
149
+ "n_cases": len(result.case_results),
150
+ "n_graded": len(result.graded_cases),
151
+ "n_records_empty": result.n_records_empty,
152
+ "n_excluded_ground_truth": result.n_excluded_ground_truth,
153
+ "cases": [asdict(c) for c in result.case_results],
154
+ }
155
+ if isinstance(result, ContradictionEvalResult):
156
+ return {
157
+ "backend": result.backend_name,
158
+ "dataset_path": result.dataset_path,
159
+ "flagged_rate": result.flagged_rate,
160
+ "silent_overwrite_rate": result.silent_overwrite_rate,
161
+ "served_stale_rate": result.served_stale_rate,
162
+ "not_applicable_rate": result.not_applicable_rate,
163
+ "empty_or_lost_rate": result.empty_or_lost_rate,
164
+ "n_cases": len(result.case_results),
165
+ "cases": [
166
+ {
167
+ "case_id": c.case.case_id,
168
+ "subject": c.case.subject,
169
+ "signal": str(c.signal),
170
+ "adapter_reported_signal": (
171
+ str(c.adapter_reported_signal) if c.adapter_reported_signal else None
172
+ ),
173
+ "error": c.error,
174
+ }
175
+ for c in result.case_results
176
+ ],
177
+ }
178
+ if isinstance(result, ResourceSyncEvalResult):
179
+ return {
180
+ "backend": result.backend_name,
181
+ "dataset_path": result.dataset_path,
182
+ "skipped": result.skipped,
183
+ "skip_reason": result.skip_reason,
184
+ "user_file_deletion_rate": result.user_file_deletion_rate,
185
+ "preserved_rate": result.preserved_rate,
186
+ "overwritten_unchanged_rate": result.overwritten_unchanged_rate,
187
+ "nested_content_unindexed_rate": result.nested_content_unindexed_rate,
188
+ "n_files": len(result.file_results),
189
+ "files": [
190
+ {
191
+ "case_id": f.case_id,
192
+ "path_suffix": f.path_suffix,
193
+ "origin": f.origin,
194
+ "signal": str(f.signal),
195
+ "indexed_after_resync": f.indexed_after_resync,
196
+ "error": f.error,
197
+ }
198
+ for f in result.file_results
199
+ ],
200
+ }
201
+ if isinstance(result, RankingQualityEvalResult):
202
+ return {
203
+ "backend": result.backend_name,
204
+ "dataset_path": result.dataset_path,
205
+ "signal_driven_rate": result.signal_driven_rate,
206
+ "missing_ordering_key_rate": result.missing_ordering_key_rate,
207
+ "order_inconsistent_rate": result.order_inconsistent_rate,
208
+ "not_applicable_rate": result.not_applicable_rate,
209
+ "n_cases": len(result.case_results),
210
+ "cases": [
211
+ {
212
+ "case_id": c.case.case_id,
213
+ "ranking_field": c.case.ranking_field,
214
+ "signal": str(c.signal),
215
+ "adapter_reported_signal": (
216
+ str(c.adapter_reported_signal) if c.adapter_reported_signal else None
217
+ ),
218
+ "matches_insertion_order": c.matches_insertion_order,
219
+ "error": c.error,
220
+ }
221
+ for c in result.case_results
222
+ ],
223
+ }
224
+ if isinstance(result, CrashRecoveryEvalResult):
225
+ return {
226
+ "backend": result.backend_name,
227
+ "dataset_path": result.dataset_path,
228
+ "skipped": result.skipped,
229
+ "skip_reason": result.skip_reason,
230
+ "recovered_rate": result.recovered_rate,
231
+ "index_lost_data_survived_rate": result.index_lost_data_survived_rate,
232
+ "data_lost_rate": result.data_lost_rate,
233
+ "n_cases": len(result.case_results),
234
+ "cases": [
235
+ {
236
+ "case_id": c.case.case_id,
237
+ "signal": str(c.signal),
238
+ "present_before_crash": c.present_before_crash,
239
+ "queryable_after_crash": c.queryable_after_crash,
240
+ "raw_store_contains_after_crash": c.raw_store_contains_after_crash,
241
+ "error": c.error,
242
+ }
243
+ for c in result.case_results
244
+ ],
245
+ }
246
+ if isinstance(result, MigrationRollbackEvalResult):
247
+ return {
248
+ "backend": result.backend_name,
249
+ "dataset_path": result.dataset_path,
250
+ "skipped": result.skipped,
251
+ "skip_reason": result.skip_reason,
252
+ "restored_rate": result.restored_rate,
253
+ "data_lost_rate": result.data_lost_rate,
254
+ "n_cases": len(result.case_results),
255
+ "cases": [
256
+ {
257
+ "case_id": c.case.case_id,
258
+ "signal": str(c.signal),
259
+ "original_data_recoverable": c.original_data_recoverable,
260
+ "adapter_reported_recoverable": c.adapter_reported_recoverable,
261
+ "error": c.error,
262
+ }
263
+ for c in result.case_results
264
+ ],
265
+ }
266
+ if isinstance(result, ExtractionQualityEvalResult):
267
+ return {
268
+ "backend": result.backend_name,
269
+ "dataset_path": result.dataset_path,
270
+ "junk_retained_rate": result.junk_retained_rate,
271
+ "junk_rejected_rate": result.junk_rejected_rate,
272
+ "valid_retained_rate": result.valid_retained_rate,
273
+ "valid_lost_rate": result.valid_lost_rate,
274
+ "feedback_loop_duplicate_rate": result.feedback_loop_duplicate_rate,
275
+ "n_cases": len(result.case_results),
276
+ "n_feedback_loop_cases": len(result.feedback_loop_results),
277
+ "cases": [
278
+ {
279
+ "case_id": c.case.case_id,
280
+ "category": c.case.category,
281
+ "should_be_stored": c.case.should_be_stored,
282
+ "signal": str(c.signal),
283
+ "retrieved": c.retrieved,
284
+ "error": c.error,
285
+ }
286
+ for c in result.case_results
287
+ ],
288
+ "feedback_loop_cases": [
289
+ {
290
+ "case_id": c.case.case_id,
291
+ "signal": str(c.signal),
292
+ "records_after_first_store": c.records_after_first_store,
293
+ "records_after_second_store": c.records_after_second_store,
294
+ "error": c.error,
295
+ }
296
+ for c in result.feedback_loop_results
297
+ ],
298
+ }
299
+ if isinstance(result, FilterInjectionEvalResult):
300
+ return {
301
+ "backend": result.backend_name,
302
+ "dataset_path": result.dataset_path,
303
+ "skipped": result.skipped,
304
+ "skip_reason": result.skip_reason,
305
+ "injection_succeeded_rate": result.injection_succeeded_rate,
306
+ "malicious_rejected_rate": result.malicious_rejected_rate,
307
+ "benign_accepted_rate": result.benign_accepted_rate,
308
+ "benign_false_positive_rate": result.benign_false_positive_rate,
309
+ "n_cases": len(result.case_results),
310
+ "cases": [
311
+ {
312
+ "case_id": c.case.case_id,
313
+ "malicious": c.case.malicious,
314
+ "filter_key": c.case.filter_key,
315
+ "signal": str(c.signal),
316
+ "probe_accepted": c.probe_accepted,
317
+ "error": c.error,
318
+ }
319
+ for c in result.case_results
320
+ ],
321
+ }
322
+ if isinstance(result, CompressionEvalResult):
323
+ return {
324
+ "backend": result.backend_name,
325
+ "dataset_path": result.dataset_path,
326
+ "modes": result.modes,
327
+ "mean_fidelity_by_mode": result.mean_fidelity_by_mode(),
328
+ "fidelity_drop_pp": result.fidelity_drop_pp,
329
+ "mode_results": {
330
+ mode: {
331
+ "mean_fidelity": mode_result.mean_fidelity,
332
+ "n_cases": len(mode_result.case_results),
333
+ "n_scored": len(mode_result.scored_cases),
334
+ "cases": [asdict(c) for c in mode_result.case_results],
335
+ }
336
+ for mode, mode_result in result.mode_results.items()
337
+ },
338
+ }
339
+ if isinstance(result, ScaleTestResult):
340
+ return {
341
+ "backend": result.backend_name,
342
+ "n_records_requested": result.n_records_requested,
343
+ "seed": result.seed,
344
+ "signal": str(result.signal),
345
+ "records_stored": result.records_stored,
346
+ "records_store_errors": result.records_store_errors,
347
+ "records_checked": result.records_checked,
348
+ "records_recoverable": result.records_recoverable,
349
+ "recall_degradation_pct": result.recall_degradation_pct,
350
+ "anchor_lost_at_n": result.anchor_lost_at_n,
351
+ "latency_p99_ms": result.latency_p99_ms,
352
+ "error": result.error,
353
+ "checkpoints": [
354
+ {
355
+ "checkpoint_n": c.checkpoint_n,
356
+ "records_stored_so_far": c.records_stored_so_far,
357
+ "recall_rate": c.recall_rate,
358
+ "anchor_recall": c.anchor_recall,
359
+ "latency_p50_ms": c.latency_p50_ms,
360
+ "latency_p99_ms": c.latency_p99_ms,
361
+ "n_needle_queries": len(c.needle_queries),
362
+ }
363
+ for c in result.checkpoints
364
+ ],
365
+ }
366
+ if isinstance(result, EmbeddingDriftEvalResult):
367
+ return {
368
+ "backend": result.backend_name,
369
+ "dataset_path": result.dataset_path,
370
+ "drift_rate": result.drift_rate,
371
+ "clean_rate": result.clean_rate,
372
+ "not_applicable_rate": result.not_applicable_rate,
373
+ "n_records": len(result.record_results),
374
+ "records": [
375
+ {
376
+ "case_id": r.case_id,
377
+ "content": r.content,
378
+ "model_a_label": r.model_a_label,
379
+ "model_b_label": r.model_b_label,
380
+ "signal": str(r.signal),
381
+ "error": r.error,
382
+ }
383
+ for r in result.record_results
384
+ ],
385
+ }
386
+ if isinstance(result, LockContentionEvalResult):
387
+ return {
388
+ "backend": result.backend_name,
389
+ "resource_path": result.resource_path,
390
+ "budget_ms": result.budget_ms,
391
+ "n_concurrent": result.n_concurrent,
392
+ "skipped": result.skipped,
393
+ "skip_reason": result.skip_reason,
394
+ "signal": str(result.signal),
395
+ "stalled_count": result.stalled_count,
396
+ "max_latency_ms": result.max_latency_ms,
397
+ "requests": [
398
+ {
399
+ "worker_index": r.worker_index,
400
+ "completed": r.completed,
401
+ "succeeded": r.succeeded,
402
+ "latency_ms": r.latency_ms,
403
+ "error": r.error,
404
+ }
405
+ for r in result.requests
406
+ ],
407
+ }
408
+ if isinstance(result, StatsAccuracyEvalResult):
409
+ return {
410
+ "backend": result.backend_name,
411
+ "n_records_requested": result.n_records_requested,
412
+ "records_stored": result.records_stored,
413
+ "skipped": result.skipped,
414
+ "skip_reason": result.skip_reason,
415
+ "signal": str(result.signal),
416
+ "verified_count": result.verified_count,
417
+ "reported_count": result.reported_count,
418
+ "undercount_gap": result.undercount_gap,
419
+ "error": result.error,
420
+ }
421
+ if isinstance(result, OrphanCleanupEvalResult):
422
+ return {
423
+ "backend": result.backend_name,
424
+ "dataset_path": result.dataset_path,
425
+ "skipped": result.skipped,
426
+ "skip_reason": result.skip_reason,
427
+ "orphaned_vector_entry_rate": result.orphaned_vector_entry_rate,
428
+ "clean_rate": result.clean_rate,
429
+ "n_files": len(result.file_results),
430
+ "files": [
431
+ {
432
+ "case_id": f.case_id,
433
+ "path_suffix": f.path_suffix,
434
+ "signal": str(f.signal),
435
+ "present_after_delete": f.present_after_delete,
436
+ "queryable_after_delete": f.queryable_after_delete,
437
+ "error": f.error,
438
+ }
439
+ for f in result.file_results
440
+ ],
441
+ }
442
+ if isinstance(result, ConsistencyEvalResult):
443
+ return {
444
+ "backend": result.backend_name,
445
+ "dataset_path": result.dataset_path,
446
+ "consistent_rate": result.consistent_rate,
447
+ "inconsistent_rate": result.inconsistent_rate,
448
+ "n_cases": len(result.case_results),
449
+ "cases": [
450
+ {
451
+ "case_id": c.case.case_id,
452
+ "signal": str(c.signal),
453
+ "average_jaccard": c.average_jaccard,
454
+ "n_successful_runs": c.n_successful_runs,
455
+ "error": c.error,
456
+ }
457
+ for c in result.case_results
458
+ ],
459
+ }
460
+ raise TypeError(f"no serializer for {type(result)!r}")
461
+
462
+
463
+ @click.group()
464
+ @click.version_option(version=__version__, prog_name="memtrust")
465
+ def main() -> None:
466
+ """memtrust: an independent, reproducible benchmark harness for agent-memory backends."""
467
+
468
+
469
+ @main.command()
470
+ @click.option(
471
+ "--backends", default="all", show_default=True, help="Comma-separated backend list, or 'all'."
472
+ )
473
+ @click.option(
474
+ "--eval",
475
+ "eval_arg",
476
+ default="all",
477
+ show_default=True,
478
+ help=(
479
+ "Comma-separated eval list (longmemeval,locomo,contradiction,"
480
+ "resource_sync_safety,compression,ranking_quality,scale_stress,"
481
+ "embedding_drift,crash_recovery,extraction_quality,"
482
+ "migration_rollback,filter_injection,lock_contention,stats_accuracy,"
483
+ "orphan_cleanup,result_consistency), or 'all'."
484
+ ),
485
+ )
486
+ @click.option(
487
+ "--output",
488
+ "output_path",
489
+ default=None,
490
+ type=click.Path(dir_okay=False, path_type=Path),
491
+ help="Path to write the JSON report. Defaults to ./memtrust-report-<date>.json",
492
+ )
493
+ @click.option(
494
+ "--locomo-exclude-question-ids-file",
495
+ "locomo_exclude_ids_path",
496
+ default=None,
497
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
498
+ help=(
499
+ "Path to a file of known-bad-ground-truth LoCoMo question IDs to exclude from "
500
+ "scoring (JSON array, or one ID per line). See evals/locomo.py's "
501
+ "load_exclude_question_ids() and docs/methodology.md for the ID shape and how "
502
+ "a corrected list (e.g. derived from a published ground-truth audit) plugs in. "
503
+ "No such list ships with memtrust by default."
504
+ ),
505
+ )
506
+ @click.option(
507
+ "--locomo-dataset-path",
508
+ "locomo_dataset_path",
509
+ default=None,
510
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
511
+ help=(
512
+ "Path to a real locomo10.json (download it yourself from "
513
+ "https://github.com/snap-research/locomo -- memtrust does not bundle or "
514
+ "auto-fetch it) to run the locomo eval against, instead of the bundled "
515
+ "synthetic tests/fixtures/locomo_sample.json. See docs/methodology.md's "
516
+ '"LoCoMo" section for the schema and download link. Ignored if --eval does '
517
+ "not include locomo."
518
+ ),
519
+ )
520
+ @click.option(
521
+ "--scale-stress-n-records",
522
+ "scale_stress_n_records",
523
+ default=DEFAULT_N_RECORDS,
524
+ show_default=True,
525
+ type=int,
526
+ help=(
527
+ "How many synthetic records the scale_stress eval stores and re-queries. "
528
+ "Kept small by default so `memtrust run` stays fast in CI -- pass a much larger "
529
+ "value (e.g. 10000) to actually reach the corpus size volcengine/OpenViking#2850 "
530
+ "and getzep/graphiti#1275 manifest at, against a real configured backend. "
531
+ "See evals/scale_stress.py and docs/methodology.md."
532
+ ),
533
+ )
534
+ @click.option(
535
+ "--sign",
536
+ "sign_key_path",
537
+ default=None,
538
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
539
+ help=(
540
+ "Path to an Ed25519 private key PEM file (see `memtrust keygen`). When given, "
541
+ "writes a signed receipt alongside the normal JSON report -- "
542
+ "<output>.receipt.json -- proving the report was produced by the holder of "
543
+ "this key and has not been altered since. Omit for plain, unsigned JSON "
544
+ "output (the default, unchanged from before this flag existed)."
545
+ ),
546
+ )
547
+ def run(
548
+ backends: str,
549
+ eval_arg: str,
550
+ output_path: Path | None,
551
+ locomo_exclude_ids_path: Path | None,
552
+ locomo_dataset_path: Path | None,
553
+ scale_stress_n_records: int,
554
+ sign_key_path: Path | None,
555
+ ) -> None:
556
+ """Run the eval suite against the requested backends.
557
+
558
+ Backends without a configured credential env var print SKIPPED and
559
+ the run continues -- this command never crashes on missing
560
+ credentials.
561
+ """
562
+ backend_names = _resolve_backend_names(backends)
563
+ eval_names = _resolve_eval_names(eval_arg)
564
+ locomo_exclude_ids = (
565
+ load_exclude_question_ids(locomo_exclude_ids_path)
566
+ if locomo_exclude_ids_path is not None
567
+ else None
568
+ )
569
+ cost_tracker = CostTracker()
570
+ judge = LLMJudge(cost_tracker=cost_tracker)
571
+
572
+ run_id = datetime.now(UTC).strftime("mt_%Y-%m-%dT%H%M%SZ")
573
+ report: dict[str, Any] = {
574
+ "run_id": run_id,
575
+ "memtrust_version": __version__,
576
+ "timestamp": datetime.now(UTC).isoformat(),
577
+ "backends_requested": backend_names,
578
+ "evals_requested": eval_names,
579
+ "results": {},
580
+ }
581
+
582
+ console.print(f"[bold]memtrust {__version__}[/bold] -- run_id={run_id}")
583
+ console.print(f"Backends: {', '.join(backend_names)} Evals: {', '.join(eval_names)}\n")
584
+
585
+ for backend_name in backend_names:
586
+ adapter_cls = ADAPTER_REGISTRY[backend_name]
587
+ try:
588
+ adapter: MemoryBackendAdapter = adapter_cls()
589
+ except BackendNotConfiguredError as exc:
590
+ console.print(f"[yellow]{backend_name}: SKIPPED (not configured)[/yellow] -- {exc}")
591
+ report["results"][backend_name] = {
592
+ "status": "skipped",
593
+ "reason": str(exc),
594
+ "missing_env_var": exc.missing_env_var,
595
+ }
596
+ continue
597
+
598
+ console.print(f"[green]{backend_name}: configured[/green], running evals...")
599
+ backend_report: dict[str, Any] = {"status": "configured", "evals": {}}
600
+
601
+ if "longmemeval" in eval_names:
602
+ console.print(f" Running LongMemEval against {backend_name}...")
603
+ lme_result = run_longmemeval(adapter, judge)
604
+ backend_report["evals"]["longmemeval"] = _serialize_eval_result(lme_result)
605
+ acc = lme_result.accuracy
606
+ if acc is not None:
607
+ console.print(f" accuracy: {acc:.1%}")
608
+ else:
609
+ console.print(" accuracy: N/A (judge not configured)")
610
+ if lme_result.n_records_empty:
611
+ console.print(
612
+ f" [yellow]records_empty: {lme_result.n_records_empty}/"
613
+ f"{len(lme_result.case_results)}[/yellow] "
614
+ "(backend call succeeded but returned nothing)"
615
+ )
616
+
617
+ if "locomo" in eval_names:
618
+ console.print(f" Running LoCoMo against {backend_name}...")
619
+ locomo_run_kwargs: dict[str, Any] = {"exclude_question_ids": locomo_exclude_ids}
620
+ if locomo_dataset_path is not None:
621
+ locomo_run_kwargs["dataset_path"] = locomo_dataset_path
622
+ locomo_result = run_locomo(adapter, judge, **locomo_run_kwargs)
623
+ backend_report["evals"]["locomo"] = _serialize_eval_result(locomo_result)
624
+ acc = locomo_result.accuracy
625
+ non_adv_acc = locomo_result.non_adversarial_accuracy
626
+ if acc is not None:
627
+ console.print(f" accuracy (all categories, incl. adversarial): {acc:.1%}")
628
+ else:
629
+ console.print(" accuracy: N/A (judge not configured)")
630
+ if non_adv_acc is not None:
631
+ console.print(
632
+ f" non_adversarial_accuracy (excludes category 5): {non_adv_acc:.1%}"
633
+ )
634
+ else:
635
+ console.print(" non_adversarial_accuracy: N/A (judge not configured)")
636
+ if locomo_result.n_records_empty:
637
+ console.print(
638
+ f" [yellow]records_empty: {locomo_result.n_records_empty}/"
639
+ f"{len(locomo_result.case_results)}[/yellow] "
640
+ "(backend call succeeded but returned nothing)"
641
+ )
642
+ if locomo_result.n_excluded_ground_truth:
643
+ console.print(
644
+ f" excluded_ground_truth: {locomo_result.n_excluded_ground_truth}/"
645
+ f"{len(locomo_result.case_results)} "
646
+ "(known-bad ground truth, excluded via --locomo-exclude-question-ids-file)"
647
+ )
648
+
649
+ if "contradiction" in eval_names:
650
+ console.print(f" Running Contradiction-Detection against {backend_name}...")
651
+ contra_result = run_contradiction_eval(adapter)
652
+ backend_report["evals"]["contradiction"] = _serialize_eval_result(contra_result)
653
+ fr = contra_result.flagged_rate
654
+ so = contra_result.silent_overwrite_rate
655
+ ss = contra_result.served_stale_rate
656
+ eol = contra_result.empty_or_lost_rate
657
+ if fr is not None:
658
+ console.print(
659
+ f" flagged: {fr:.1%} silent-overwrite: {so:.1%} served-stale: {ss:.1%}"
660
+ f" empty-or-lost: {eol:.1%}"
661
+ )
662
+ else:
663
+ console.print(" N/A (no scoreable cases)")
664
+
665
+ if "resource_sync_safety" in eval_names:
666
+ console.print(f" Running Resource-Sync Safety against {backend_name}...")
667
+ rss_result = run_resource_sync_eval(adapter)
668
+ backend_report["evals"]["resource_sync_safety"] = _serialize_eval_result(rss_result)
669
+ if rss_result.skipped:
670
+ console.print(f" SKIPPED: {rss_result.skip_reason}")
671
+ else:
672
+ dr = rss_result.user_file_deletion_rate
673
+ ndr = rss_result.nested_content_unindexed_rate
674
+ if dr is not None:
675
+ ndr_str = f"{ndr:.1%}" if ndr is not None else "N/A"
676
+ console.print(
677
+ f" user-file deletion rate: {dr:.1%}"
678
+ f" nested-content-unindexed rate: {ndr_str}"
679
+ )
680
+ else:
681
+ console.print(" N/A (no scoreable files)")
682
+
683
+ if "compression" in eval_names:
684
+ console.print(f" Running Compression/Round-Trip-Fidelity against {backend_name}...")
685
+ compression_result = run_compression_eval(adapter)
686
+ backend_report["evals"]["compression"] = _serialize_eval_result(compression_result)
687
+ means = compression_result.mean_fidelity_by_mode()
688
+ if means:
689
+ rendered = " ".join(
690
+ f"{mode}: {value:.1%}" if value is not None else f"{mode}: N/A"
691
+ for mode, value in means.items()
692
+ )
693
+ console.print(f" fidelity by mode -- {rendered}")
694
+ else:
695
+ console.print(" N/A (no scoreable cases)")
696
+
697
+ if "ranking_quality" in eval_names:
698
+ console.print(f" Running Ranking-Quality against {backend_name}...")
699
+ ranking_result = run_ranking_quality_eval(adapter)
700
+ backend_report["evals"]["ranking_quality"] = _serialize_eval_result(ranking_result)
701
+ mok = ranking_result.missing_ordering_key_rate
702
+ sdr = ranking_result.signal_driven_rate
703
+ oir = ranking_result.order_inconsistent_rate
704
+ if mok is not None:
705
+ console.print(
706
+ f" missing-ordering-key: {mok:.1%} signal-driven: {sdr:.1%}"
707
+ f" order-inconsistent: {oir:.1%}"
708
+ )
709
+ else:
710
+ console.print(" N/A (no scoreable cases)")
711
+
712
+ if "scale_stress" in eval_names:
713
+ console.print(
714
+ f" Running Scale/Volume Stress ({scale_stress_n_records} records) "
715
+ f"against {backend_name}..."
716
+ )
717
+ scale_result = run_scale_stress_eval(adapter, n_records=scale_stress_n_records)
718
+ backend_report["evals"]["scale_stress"] = _serialize_eval_result(scale_result)
719
+ console.print(f" signal: {scale_result.signal}")
720
+ console.print(
721
+ f" records_stored: {scale_result.records_stored}/"
722
+ f"{scale_result.n_records_requested}"
723
+ f" records_recoverable: {scale_result.records_recoverable}/"
724
+ f"{scale_result.records_checked}"
725
+ )
726
+ if scale_result.recall_degradation_pct is not None:
727
+ console.print(
728
+ f" recall_degradation: {scale_result.recall_degradation_pct:.1f}pp"
729
+ )
730
+ if scale_result.anchor_lost_at_n is not None:
731
+ console.print(
732
+ f" [yellow]anchor record lost at n={scale_result.anchor_lost_at_n}"
733
+ "[/yellow] (earliest-stored content became unrecoverable as volume grew)"
734
+ )
735
+
736
+ if "embedding_drift" in eval_names:
737
+ console.print(f" Running Embedding-Drift/Consistency against {backend_name}...")
738
+ drift_result = run_embedding_drift_eval(adapter)
739
+ backend_report["evals"]["embedding_drift"] = _serialize_eval_result(drift_result)
740
+ dr = drift_result.drift_rate
741
+ if dr is not None:
742
+ console.print(
743
+ f" drift-rate: {dr:.1%} clean-rate: {drift_result.clean_rate:.1%}"
744
+ )
745
+ else:
746
+ console.print(" N/A (no scoreable records)")
747
+
748
+ if "crash_recovery" in eval_names:
749
+ console.print(f" Running Crash-Recovery against {backend_name}...")
750
+ crash_result = run_crash_recovery_eval(adapter)
751
+ backend_report["evals"]["crash_recovery"] = _serialize_eval_result(crash_result)
752
+ if crash_result.skipped:
753
+ console.print(f" SKIPPED: {crash_result.skip_reason}")
754
+ else:
755
+ ilds = crash_result.index_lost_data_survived_rate
756
+ rec = crash_result.recovered_rate
757
+ if ilds is not None:
758
+ console.print(f" index-lost-data-survived: {ilds:.1%} recovered: {rec:.1%}")
759
+ else:
760
+ console.print(" N/A (no scoreable cases)")
761
+
762
+ if "extraction_quality" in eval_names:
763
+ console.print(f" Running Extraction-Quality against {backend_name}...")
764
+ extraction_result = run_extraction_quality_eval(adapter)
765
+ backend_report["evals"]["extraction_quality"] = _serialize_eval_result(
766
+ extraction_result
767
+ )
768
+ jr = extraction_result.junk_retained_rate
769
+ vl = extraction_result.valid_lost_rate
770
+ fld = extraction_result.feedback_loop_duplicate_rate
771
+ if jr is not None:
772
+ vl_str = f"{vl:.1%}" if vl is not None else "N/A"
773
+ console.print(f" junk-retained: {jr:.1%} valid-lost: {vl_str}")
774
+ else:
775
+ console.print(" junk-retained: N/A (no scoreable cases)")
776
+ if fld is not None:
777
+ console.print(f" feedback-loop-duplicate: {fld:.1%}")
778
+ else:
779
+ console.print(" feedback-loop-duplicate: N/A (no scoreable feedback-loop cases)")
780
+
781
+ if "migration_rollback" in eval_names:
782
+ console.print(f" Running Migration-Rollback against {backend_name}...")
783
+ migration_result = run_migration_rollback_eval(adapter)
784
+ backend_report["evals"]["migration_rollback"] = _serialize_eval_result(migration_result)
785
+ if migration_result.skipped:
786
+ console.print(f" SKIPPED: {migration_result.skip_reason}")
787
+ else:
788
+ rr = migration_result.restored_rate
789
+ dl = migration_result.data_lost_rate
790
+ if rr is not None:
791
+ console.print(f" restored: {rr:.1%} data-lost: {dl:.1%}")
792
+ else:
793
+ console.print(" N/A (no scoreable cases)")
794
+
795
+ if "filter_injection" in eval_names:
796
+ console.print(f" Running Filter-Injection against {backend_name}...")
797
+ filter_injection_result = run_filter_injection_eval(adapter)
798
+ backend_report["evals"]["filter_injection"] = _serialize_eval_result(
799
+ filter_injection_result
800
+ )
801
+ if filter_injection_result.skipped:
802
+ console.print(f" SKIPPED: {filter_injection_result.skip_reason}")
803
+ else:
804
+ isr = filter_injection_result.injection_succeeded_rate
805
+ bfr = filter_injection_result.benign_false_positive_rate
806
+ bfr_str = f"{bfr:.1%}" if bfr is not None else "N/A"
807
+ if isr is not None:
808
+ console.print(
809
+ f" injection-succeeded: {isr:.1%} benign-false-positive: {bfr_str}"
810
+ )
811
+ else:
812
+ console.print(" N/A (no scoreable cases)")
813
+
814
+ if "lock_contention" in eval_names:
815
+ console.print(f" Running Lock-Contention/Hang-Detection against {backend_name}...")
816
+ lock_contention_result = run_lock_contention_eval(adapter)
817
+ backend_report["evals"]["lock_contention"] = _serialize_eval_result(
818
+ lock_contention_result
819
+ )
820
+ if lock_contention_result.skipped:
821
+ console.print(f" SKIPPED: {lock_contention_result.skip_reason}")
822
+ else:
823
+ console.print(
824
+ f" signal: {lock_contention_result.signal}"
825
+ f" stalled: {lock_contention_result.stalled_count}/"
826
+ f"{lock_contention_result.n_concurrent}"
827
+ )
828
+
829
+ if "stats_accuracy" in eval_names:
830
+ console.print(f" Running Stats/Dashboard-Accuracy against {backend_name}...")
831
+ stats_result = run_stats_accuracy_eval(adapter)
832
+ backend_report["evals"]["stats_accuracy"] = _serialize_eval_result(stats_result)
833
+ if stats_result.skipped:
834
+ console.print(f" SKIPPED: {stats_result.skip_reason}")
835
+ elif stats_result.verified_count is not None:
836
+ console.print(
837
+ f" signal: {stats_result.signal}"
838
+ f" verified: {stats_result.verified_count}"
839
+ f" reported: {stats_result.reported_count}"
840
+ )
841
+ else:
842
+ console.print(" N/A (no scoreable records)")
843
+
844
+ if "orphan_cleanup" in eval_names:
845
+ console.print(f" Running Orphan-Cleanup against {backend_name}...")
846
+ orphan_result = run_orphan_cleanup_eval(adapter)
847
+ backend_report["evals"]["orphan_cleanup"] = _serialize_eval_result(orphan_result)
848
+ if orphan_result.skipped:
849
+ console.print(f" SKIPPED: {orphan_result.skip_reason}")
850
+ else:
851
+ ovr = orphan_result.orphaned_vector_entry_rate
852
+ if ovr is not None:
853
+ console.print(f" orphaned-vector-entry rate: {ovr:.1%}")
854
+ else:
855
+ console.print(" N/A (no scoreable files)")
856
+
857
+ if "result_consistency" in eval_names:
858
+ console.print(f" Running Result-Consistency against {backend_name}...")
859
+ consistency_result = run_result_consistency_eval(adapter)
860
+ backend_report["evals"]["result_consistency"] = _serialize_eval_result(
861
+ consistency_result
862
+ )
863
+ ir = consistency_result.inconsistent_rate
864
+ if ir is not None:
865
+ console.print(
866
+ f" inconsistent rate: {ir:.1%}"
867
+ f" consistent rate: {consistency_result.consistent_rate:.1%}"
868
+ )
869
+ else:
870
+ console.print(" N/A (no scoreable cases)")
871
+
872
+ report["results"][backend_name] = backend_report
873
+ close = getattr(adapter, "close", None)
874
+ if callable(close):
875
+ close()
876
+
877
+ judge.close()
878
+ report["cost"] = {
879
+ "total_usd": cost_tracker.total_cost_usd,
880
+ "total_input_tokens": cost_tracker.total_input_tokens,
881
+ "total_output_tokens": cost_tracker.total_output_tokens,
882
+ }
883
+
884
+ console.print()
885
+ for line in cost_tracker.summary_lines():
886
+ console.print(line)
887
+
888
+ out_path = output_path or Path(f"memtrust-report-{datetime.now(UTC).strftime('%Y-%m-%d')}.json")
889
+ out_path.write_text(json.dumps(report, indent=2, default=str))
890
+ console.print(f"\nFull report: {out_path}")
891
+
892
+ if sign_key_path is not None:
893
+ try:
894
+ receipt = sign_report_with_keyfile(report, sign_key_path)
895
+ except ReceiptError as exc:
896
+ console.print(f"[red]Could not sign report: {exc}[/red]")
897
+ sys.exit(1)
898
+ receipt_path = receipt_path_for(out_path)
899
+ receipt_path.write_text(json.dumps(receipt, indent=2))
900
+ console.print(f"Signed receipt: {receipt_path} (public key: {receipt['public_key']})")
901
+
902
+
903
+ @main.command()
904
+ @click.argument("report_path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
905
+ def report(report_path: Path) -> None:
906
+ """Read a prior `memtrust run` JSON report and print a formatted summary."""
907
+ try:
908
+ data = json.loads(report_path.read_text())
909
+ except json.JSONDecodeError as exc:
910
+ console.print(f"[red]Could not parse {report_path} as JSON: {exc}[/red]")
911
+ sys.exit(1)
912
+
913
+ console.print(f"[bold]memtrust report[/bold] -- run_id={data.get('run_id', 'unknown')}")
914
+ console.print(f"Generated: {data.get('timestamp', 'unknown')}\n")
915
+
916
+ table = Table(title="Backend results")
917
+ table.add_column("Backend")
918
+ table.add_column("Status")
919
+ table.add_column("LongMemEval")
920
+ table.add_column("LoCoMo (all cats / non-adversarial)")
921
+ table.add_column("Contradiction (flagged/overwrite/stale/empty-or-lost)")
922
+ table.add_column("Resource-Sync (user-file deletion / nested-content-unindexed)")
923
+ table.add_column("Compression fidelity by mode")
924
+ table.add_column("Ranking Quality (missing-ordering-key rate)")
925
+ table.add_column("Scale/Volume Stress (signal, recall degradation)")
926
+ table.add_column("Embedding Drift (drift rate)")
927
+ table.add_column("Crash-Recovery (index-lost-data-survived rate)")
928
+ table.add_column("Extraction Quality (junk-retained / valid-lost / feedback-loop-dup)")
929
+ table.add_column("Migration-Rollback (restored / data-lost)")
930
+ table.add_column("Filter Injection (injection-succeeded / benign-false-positive)")
931
+ table.add_column("Lock Contention (signal, stalled/n)")
932
+ table.add_column("Stats Accuracy (signal, verified/reported)")
933
+ table.add_column("Orphan-Cleanup (orphaned-vector-entry rate)")
934
+ table.add_column("Result-Consistency (inconsistent rate)")
935
+
936
+ for backend_name, backend_data in data.get("results", {}).items():
937
+ if backend_data.get("status") == "skipped":
938
+ table.add_row(
939
+ backend_name,
940
+ "SKIPPED",
941
+ "-",
942
+ "-",
943
+ "-",
944
+ "-",
945
+ "-",
946
+ "-",
947
+ "-",
948
+ "-",
949
+ "-",
950
+ "-",
951
+ "-",
952
+ "-",
953
+ "-",
954
+ "-",
955
+ "-",
956
+ "-",
957
+ )
958
+ continue
959
+
960
+ evals = backend_data.get("evals", {})
961
+ lme = evals.get("longmemeval", {})
962
+ locomo = evals.get("locomo", {})
963
+ contra = evals.get("contradiction", {})
964
+ rss = evals.get("resource_sync_safety", {})
965
+ compression = evals.get("compression", {})
966
+ ranking = evals.get("ranking_quality", {})
967
+ scale_stress = evals.get("scale_stress", {})
968
+ drift = evals.get("embedding_drift", {})
969
+ crash_recovery = evals.get("crash_recovery", {})
970
+ extraction = evals.get("extraction_quality", {})
971
+ migration_rollback = evals.get("migration_rollback", {})
972
+ filter_injection = evals.get("filter_injection", {})
973
+ lock_contention = evals.get("lock_contention", {})
974
+ stats_accuracy = evals.get("stats_accuracy", {})
975
+ orphan_cleanup = evals.get("orphan_cleanup", {})
976
+ result_consistency = evals.get("result_consistency", {})
977
+
978
+ def _fmt_pct(value: float | None) -> str:
979
+ return f"{value:.1%}" if value is not None else "N/A"
980
+
981
+ contra_str = (
982
+ f"{_fmt_pct(contra.get('flagged_rate'))} / "
983
+ f"{_fmt_pct(contra.get('silent_overwrite_rate'))} / "
984
+ f"{_fmt_pct(contra.get('served_stale_rate'))} / "
985
+ f"{_fmt_pct(contra.get('empty_or_lost_rate'))}"
986
+ if contra
987
+ else "-"
988
+ )
989
+ if rss.get("skipped"):
990
+ rss_str = "SKIPPED (unsupported)"
991
+ elif rss:
992
+ rss_str = (
993
+ f"{_fmt_pct(rss.get('user_file_deletion_rate'))} / "
994
+ f"{_fmt_pct(rss.get('nested_content_unindexed_rate'))}"
995
+ )
996
+ else:
997
+ rss_str = "-"
998
+ compression_str = (
999
+ " ".join(
1000
+ f"{mode}: {_fmt_pct(value)}"
1001
+ for mode, value in compression.get("mean_fidelity_by_mode", {}).items()
1002
+ )
1003
+ if compression
1004
+ else "-"
1005
+ )
1006
+ locomo_acc_str = _fmt_pct(locomo.get("accuracy"))
1007
+ locomo_non_adv_str = _fmt_pct(locomo.get("non_adversarial_accuracy"))
1008
+ locomo_str = f"{locomo_acc_str} / {locomo_non_adv_str}" if locomo else "-"
1009
+ ranking_str = _fmt_pct(ranking.get("missing_ordering_key_rate")) if ranking else "-"
1010
+ if scale_stress:
1011
+ degradation = scale_stress.get("recall_degradation_pct")
1012
+ degradation_str = f"{degradation:.1f}pp" if degradation is not None else "N/A"
1013
+ scale_stress_str = f"{scale_stress.get('signal', 'unknown')} ({degradation_str})"
1014
+ else:
1015
+ scale_stress_str = "-"
1016
+ drift_str = _fmt_pct(drift.get("drift_rate")) if drift else "-"
1017
+ if crash_recovery.get("skipped"):
1018
+ crash_recovery_str = "SKIPPED (unsupported)"
1019
+ elif crash_recovery:
1020
+ crash_recovery_str = _fmt_pct(crash_recovery.get("index_lost_data_survived_rate"))
1021
+ else:
1022
+ crash_recovery_str = "-"
1023
+ extraction_str = (
1024
+ f"{_fmt_pct(extraction.get('junk_retained_rate'))} / "
1025
+ f"{_fmt_pct(extraction.get('valid_lost_rate'))} / "
1026
+ f"{_fmt_pct(extraction.get('feedback_loop_duplicate_rate'))}"
1027
+ if extraction
1028
+ else "-"
1029
+ )
1030
+ if migration_rollback.get("skipped"):
1031
+ migration_rollback_str = "SKIPPED (unsupported)"
1032
+ elif migration_rollback:
1033
+ migration_rollback_str = (
1034
+ f"{_fmt_pct(migration_rollback.get('restored_rate'))} / "
1035
+ f"{_fmt_pct(migration_rollback.get('data_lost_rate'))}"
1036
+ )
1037
+ else:
1038
+ migration_rollback_str = "-"
1039
+ if filter_injection.get("skipped"):
1040
+ filter_injection_str = "SKIPPED (unsupported)"
1041
+ elif filter_injection:
1042
+ filter_injection_str = (
1043
+ f"{_fmt_pct(filter_injection.get('injection_succeeded_rate'))} / "
1044
+ f"{_fmt_pct(filter_injection.get('benign_false_positive_rate'))}"
1045
+ )
1046
+ else:
1047
+ filter_injection_str = "-"
1048
+ if lock_contention.get("skipped"):
1049
+ lock_contention_str = "SKIPPED (unsupported)"
1050
+ elif lock_contention:
1051
+ lock_contention_str = (
1052
+ f"{lock_contention.get('signal', 'unknown')} "
1053
+ f"({lock_contention.get('stalled_count', 0)}/"
1054
+ f"{lock_contention.get('n_concurrent', 0)})"
1055
+ )
1056
+ else:
1057
+ lock_contention_str = "-"
1058
+ if stats_accuracy.get("skipped"):
1059
+ stats_accuracy_str = "SKIPPED (unsupported)"
1060
+ elif stats_accuracy and stats_accuracy.get("verified_count") is not None:
1061
+ stats_accuracy_str = (
1062
+ f"{stats_accuracy.get('signal', 'unknown')} "
1063
+ f"({stats_accuracy.get('verified_count')}/"
1064
+ f"{stats_accuracy.get('reported_count')})"
1065
+ )
1066
+ elif stats_accuracy:
1067
+ stats_accuracy_str = "N/A"
1068
+ else:
1069
+ stats_accuracy_str = "-"
1070
+ if orphan_cleanup.get("skipped"):
1071
+ orphan_cleanup_str = "SKIPPED (unsupported)"
1072
+ elif orphan_cleanup:
1073
+ orphan_cleanup_str = _fmt_pct(orphan_cleanup.get("orphaned_vector_entry_rate"))
1074
+ else:
1075
+ orphan_cleanup_str = "-"
1076
+ result_consistency_str = (
1077
+ _fmt_pct(result_consistency.get("inconsistent_rate")) if result_consistency else "-"
1078
+ )
1079
+ table.add_row(
1080
+ backend_name,
1081
+ "configured",
1082
+ _fmt_pct(lme.get("accuracy")) if lme else "-",
1083
+ locomo_str,
1084
+ contra_str,
1085
+ rss_str,
1086
+ compression_str or "-",
1087
+ ranking_str,
1088
+ scale_stress_str,
1089
+ drift_str,
1090
+ crash_recovery_str,
1091
+ extraction_str,
1092
+ migration_rollback_str,
1093
+ filter_injection_str,
1094
+ lock_contention_str,
1095
+ stats_accuracy_str,
1096
+ orphan_cleanup_str,
1097
+ result_consistency_str,
1098
+ )
1099
+
1100
+ console.print(table)
1101
+
1102
+ cost = data.get("cost", {})
1103
+ if cost:
1104
+ console.print(f"\nEstimated cost: ${cost.get('total_usd', 0):.4f}")
1105
+
1106
+
1107
+ @main.command()
1108
+ @click.option(
1109
+ "--private-key-out",
1110
+ "private_key_path",
1111
+ default=Path("memtrust-key.pem"),
1112
+ show_default=True,
1113
+ type=click.Path(dir_okay=False, path_type=Path),
1114
+ help="Where to write the new Ed25519 private key (PEM, unencrypted). Keep this secret.",
1115
+ )
1116
+ @click.option(
1117
+ "--public-key-out",
1118
+ "public_key_path",
1119
+ default=Path("memtrust-key.pub"),
1120
+ show_default=True,
1121
+ type=click.Path(dir_okay=False, path_type=Path),
1122
+ help="Where to write the matching public key (PEM). Safe to publish/share.",
1123
+ )
1124
+ @click.option(
1125
+ "--force",
1126
+ is_flag=True,
1127
+ default=False,
1128
+ help="Overwrite the output files if they already exist.",
1129
+ )
1130
+ def keygen(private_key_path: Path, public_key_path: Path, force: bool) -> None:
1131
+ """Generate a new Ed25519 keypair for signing `memtrust run` receipts.
1132
+
1133
+ Equivalent to:
1134
+
1135
+ openssl genpkey -algorithm ed25519 -out <private-key-out>
1136
+ openssl pkey -in <private-key-out> -pubout -out <public-key-out>
1137
+
1138
+ Publish the public key file (or its contents via MEMTRUST_RECEIPT_PUBLIC_KEY)
1139
+ so others can run `memtrust verify` against your signed receipts. Never
1140
+ share the private key.
1141
+ """
1142
+ try:
1143
+ write_keypair(private_key_path, public_key_path, overwrite=force)
1144
+ except ReceiptError as exc:
1145
+ console.print(f"[red]{exc}[/red]")
1146
+ sys.exit(1)
1147
+ console.print(f"[green]Wrote private key:[/green] {private_key_path} (keep secret)")
1148
+ console.print(f"[green]Wrote public key:[/green] {public_key_path} (safe to publish)")
1149
+ console.print(
1150
+ "\nSign a run with: memtrust run --sign "
1151
+ f"{private_key_path} ...\n"
1152
+ "Verify a receipt with: memtrust verify <receipt.json> --public-key "
1153
+ f"{public_key_path}"
1154
+ )
1155
+
1156
+
1157
+ @main.command()
1158
+ @click.argument("receipt_path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
1159
+ @click.option(
1160
+ "--public-key",
1161
+ "public_key_path",
1162
+ default=None,
1163
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
1164
+ help=(
1165
+ f"Path to the trusted Ed25519 public key (PEM). Falls back to the "
1166
+ f"{PUBLIC_KEY_ENV_VAR} env var if omitted -- one of the two is required. "
1167
+ "Never taken from the receipt file itself: a receipt cannot vouch for its own key."
1168
+ ),
1169
+ )
1170
+ def verify(receipt_path: Path, public_key_path: Path | None) -> None:
1171
+ """Verify a signed receipt produced by `memtrust run --sign`.
1172
+
1173
+ Proves exactly two things when valid: the receipt's payload has not
1174
+ been altered since it was signed, and it was signed by the holder of
1175
+ the supplied public key. It does NOT prove the benchmark numbers
1176
+ inside the payload are accurate -- see docs/methodology.md.
1177
+
1178
+ Exits 0 and prints "valid: True" on success; exits 1 on any failure
1179
+ (bad signature, tampered payload, missing/wrong public key, malformed
1180
+ receipt).
1181
+ """
1182
+ try:
1183
+ result = verify_receipt_file(receipt_path, public_key_path=public_key_path)
1184
+ except ReceiptError as exc:
1185
+ console.print(f"[red]Could not verify: {exc}[/red]")
1186
+ sys.exit(1)
1187
+
1188
+ color = "green" if result.valid else "red"
1189
+ console.print(f"[{color}]valid: {result.valid}[/{color}]")
1190
+ console.print(result.reason)
1191
+ if result.embedded_key_matches_trusted_key is False:
1192
+ console.print(
1193
+ "[yellow]Note: the public key embedded in the receipt does not match "
1194
+ "the trusted public key you supplied.[/yellow]"
1195
+ )
1196
+ if not result.valid:
1197
+ sys.exit(1)
1198
+
1199
+
1200
+ if __name__ == "__main__":
1201
+ main()