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/receipt.py ADDED
@@ -0,0 +1,318 @@
1
+ """Cryptographic signing and verification for memtrust's own JSON run output.
2
+
3
+ This exists to close a specific, narrow credibility gap: a `memtrust run`
4
+ JSON report is just a file on disk. Anyone with filesystem or transport
5
+ access between the run and a reader can edit a number in it, and nothing
6
+ about the file itself would show that. A *signed receipt* fixes that one
7
+ problem -- it does not fix, and does not claim to fix, whether the
8
+ underlying benchmark numbers are accurate in the first place. See
9
+ docs/methodology.md's "Signed receipts" section for the full claim/caveat
10
+ split; the short version is repeated in `verify_receipt()`'s docstring
11
+ below because that is the function whose return value people will actually
12
+ build trust decisions on.
13
+
14
+ Design:
15
+
16
+ - `canonicalize()` produces a deterministic byte encoding of a JSON-able
17
+ payload (sorted keys, no extraneous whitespace, UTF-8) so the same
18
+ logical report always signs to the same bytes regardless of dict
19
+ insertion order or formatting.
20
+ - A receipt is a small JSON document: the canonicalized payload's SHA-256
21
+ hex digest, an Ed25519 signature over the *canonical payload bytes*
22
+ (not the hash -- Ed25519 handles arbitrary-length messages natively, so
23
+ hashing first buys nothing and only adds a place for the two to
24
+ silently disagree), the signer's public key (raw 32 bytes, base64), an
25
+ ISO-8601 UTC timestamp, and the payload itself, embedded so the receipt
26
+ is self-contained and reproducible without the original report file.
27
+ - Verification always requires a public key supplied by the *caller*
28
+ (a file path or an env var) -- never the public key embedded in the
29
+ receipt being verified. Trusting the embedded key would let an attacker
30
+ who can rewrite the payload and signature also rewrite the key field
31
+ and produce an internally-"consistent" but meaningless forgery. The
32
+ embedded key is carried only for display/reference (e.g. "who does this
33
+ receipt claim signed it") and is cross-checked against the caller's
34
+ trusted key as an extra diagnostic, never as the basis for trust.
35
+
36
+ Generating a keypair:
37
+
38
+ memtrust keygen --private-key-out mykey.pem --public-key-out mykey.pub
39
+
40
+ or, using plain `cryptography`/`openssl` directly (this is exactly what
41
+ `memtrust keygen` does under the hood, so either path produces
42
+ interoperable PEM files):
43
+
44
+ openssl genpkey -algorithm ed25519 -out mykey.pem
45
+ openssl pkey -in mykey.pem -pubout -out mykey.pub
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import base64
51
+ import contextlib
52
+ import hashlib
53
+ import json
54
+ import os
55
+ from dataclasses import dataclass
56
+ from datetime import UTC, datetime
57
+ from pathlib import Path
58
+ from typing import Any
59
+
60
+ from cryptography.exceptions import InvalidSignature
61
+ from cryptography.hazmat.primitives import serialization
62
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
63
+ Ed25519PrivateKey,
64
+ Ed25519PublicKey,
65
+ )
66
+
67
+ #: Bumped only if the receipt's JSON shape changes in a way that would
68
+ #: break an older verifier -- new optional fields do not require a bump.
69
+ RECEIPT_FORMAT_VERSION = 1
70
+
71
+ ALGORITHM = "Ed25519"
72
+
73
+ #: Fallback env var `memtrust verify` reads a trusted public key from when
74
+ #: no --public-key path is given on the command line.
75
+ PUBLIC_KEY_ENV_VAR = "MEMTRUST_RECEIPT_PUBLIC_KEY"
76
+
77
+
78
+ class ReceiptError(Exception):
79
+ """Raised for malformed receipts, keys, or signing inputs -- never for
80
+ "signature didn't match" (that's a normal, expected verify() outcome
81
+ reported via VerifyResult.valid=False, not an exception)."""
82
+
83
+
84
+ def canonicalize(payload: dict[str, Any]) -> bytes:
85
+ """Deterministic JSON encoding: sorted keys, compact separators, UTF-8.
86
+
87
+ Two payloads that are equal as Python dicts always canonicalize to the
88
+ same bytes regardless of original key order. This is what actually
89
+ gets signed and hashed -- never the payload's original on-disk
90
+ formatting, which is not guaranteed stable.
91
+ """
92
+ return json.dumps(
93
+ payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True, default=str
94
+ ).encode("utf-8")
95
+
96
+
97
+ def payload_sha256_hex(payload: dict[str, Any]) -> str:
98
+ """SHA-256 hex digest of the payload's canonical bytes."""
99
+ return hashlib.sha256(canonicalize(payload)).hexdigest()
100
+
101
+
102
+ def generate_keypair() -> tuple[Ed25519PrivateKey, Ed25519PublicKey]:
103
+ """Generate a fresh Ed25519 keypair. Callers own persisting it (or not)."""
104
+ private_key = Ed25519PrivateKey.generate()
105
+ return private_key, private_key.public_key()
106
+
107
+
108
+ def write_keypair(
109
+ private_key_path: Path, public_key_path: Path, *, overwrite: bool = False
110
+ ) -> None:
111
+ """Generate a keypair and write both halves as PEM files.
112
+
113
+ Private key: PKCS8, unencrypted (matches `openssl genpkey -algorithm
114
+ ed25519`). Public key: SubjectPublicKeyInfo (matches `openssl pkey
115
+ -pubout`). Either file produced by the `openssl` commands in this
116
+ module's docstring loads correctly here, and vice versa.
117
+ """
118
+ if not overwrite:
119
+ existing = [p for p in (private_key_path, public_key_path) if p.exists()]
120
+ if existing:
121
+ names = ", ".join(str(p) for p in existing)
122
+ raise ReceiptError(f"refusing to overwrite existing file(s): {names} (use --force)")
123
+
124
+ private_key, public_key = generate_keypair()
125
+
126
+ private_pem = private_key.private_bytes(
127
+ encoding=serialization.Encoding.PEM,
128
+ format=serialization.PrivateFormat.PKCS8,
129
+ encryption_algorithm=serialization.NoEncryption(),
130
+ )
131
+ public_pem = public_key.public_bytes(
132
+ encoding=serialization.Encoding.PEM,
133
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
134
+ )
135
+ private_key_path.write_bytes(private_pem)
136
+ public_key_path.write_bytes(public_pem)
137
+ # Best-effort -- some filesystems (e.g. certain CI/tmpfs mounts) don't
138
+ # support chmod; the file still gets written correctly either way.
139
+ with contextlib.suppress(OSError):
140
+ private_key_path.chmod(0o600)
141
+
142
+
143
+ def load_private_key(path: Path) -> Ed25519PrivateKey:
144
+ """Load an Ed25519 private key from a PEM file (unencrypted PKCS8 or
145
+ traditional format -- whatever `serialization.load_pem_private_key`
146
+ accepts)."""
147
+ key = serialization.load_pem_private_key(path.read_bytes(), password=None)
148
+ if not isinstance(key, Ed25519PrivateKey):
149
+ raise ReceiptError(f"{path} does not contain an Ed25519 private key")
150
+ return key
151
+
152
+
153
+ def _public_key_from_text(text: str, *, source: str) -> Ed25519PublicKey:
154
+ stripped = text.strip()
155
+ if not stripped:
156
+ raise ReceiptError(f"{source} is empty")
157
+ if "BEGIN PUBLIC KEY" in stripped:
158
+ key = serialization.load_pem_public_key(stripped.encode("utf-8"))
159
+ if not isinstance(key, Ed25519PublicKey):
160
+ raise ReceiptError(f"{source} does not contain an Ed25519 public key")
161
+ return key
162
+ # Otherwise treat it as the base64 encoding of the raw 32-byte key --
163
+ # the same form receipts embed and `memtrust keygen` never writes to
164
+ # disk on its own, but is convenient for passing via an env var.
165
+ try:
166
+ raw = base64.b64decode(stripped, validate=True)
167
+ except ValueError as exc:
168
+ raise ReceiptError(f"{source} is neither a PEM public key nor valid base64: {exc}") from exc
169
+ try:
170
+ return Ed25519PublicKey.from_public_bytes(raw)
171
+ except ValueError as exc:
172
+ raise ReceiptError(f"{source} is not a valid Ed25519 public key: {exc}") from exc
173
+
174
+
175
+ def load_public_key(path: Path) -> Ed25519PublicKey:
176
+ """Load an Ed25519 public key from a file -- PEM or raw-base64, either works."""
177
+ return _public_key_from_text(path.read_text(), source=str(path))
178
+
179
+
180
+ def load_public_key_from_env(env_var: str = PUBLIC_KEY_ENV_VAR) -> Ed25519PublicKey:
181
+ value = os.environ.get(env_var)
182
+ if value is None:
183
+ raise ReceiptError(f"environment variable {env_var} is not set")
184
+ return _public_key_from_text(value, source=f"${env_var}")
185
+
186
+
187
+ def resolve_public_key(
188
+ key_path: Path | None, *, env_var: str = PUBLIC_KEY_ENV_VAR
189
+ ) -> Ed25519PublicKey:
190
+ """Resolve the caller's trusted public key: an explicit --public-key
191
+ path always wins; otherwise fall back to the env var. Raises
192
+ ReceiptError if neither is available -- verification never silently
193
+ trusts the receipt's own embedded key as a substitute."""
194
+ if key_path is not None:
195
+ return load_public_key(key_path)
196
+ return load_public_key_from_env(env_var)
197
+
198
+
199
+ def _public_key_b64(public_key: Ed25519PublicKey) -> str:
200
+ raw = public_key.public_bytes(
201
+ encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw
202
+ )
203
+ return base64.b64encode(raw).decode("ascii")
204
+
205
+
206
+ def sign_report(report: dict[str, Any], private_key: Ed25519PrivateKey) -> dict[str, Any]:
207
+ """Build a signed receipt for `report`. Returns the receipt as a plain
208
+ dict, ready to be `json.dump`ed."""
209
+ canonical = canonicalize(report)
210
+ signature = private_key.sign(canonical)
211
+ return {
212
+ "memtrust_receipt_version": RECEIPT_FORMAT_VERSION,
213
+ "algorithm": ALGORITHM,
214
+ "signed_at": datetime.now(UTC).isoformat(),
215
+ "payload_sha256": hashlib.sha256(canonical).hexdigest(),
216
+ "public_key": _public_key_b64(private_key.public_key()),
217
+ "signature": base64.b64encode(signature).decode("ascii"),
218
+ "payload": report,
219
+ }
220
+
221
+
222
+ def sign_report_with_keyfile(report: dict[str, Any], private_key_path: Path) -> dict[str, Any]:
223
+ return sign_report(report, load_private_key(private_key_path))
224
+
225
+
226
+ @dataclass(frozen=True)
227
+ class VerifyResult:
228
+ """Outcome of verifying a receipt.
229
+
230
+ `valid=True` proves exactly two things: (1) `payload` is byte-for-byte
231
+ identical, under canonicalization, to what was signed -- nothing in it
232
+ was altered after signing -- and (2) the signature was produced by the
233
+ holder of the private key matching the trusted public key the caller
234
+ supplied. It proves nothing about whether the benchmark numbers inside
235
+ `payload` are themselves correct, complete, or run against a real
236
+ backend -- that is a separate concern docs/methodology.md covers on
237
+ its own terms, not something a signature can attest to.
238
+ """
239
+
240
+ valid: bool
241
+ reason: str
242
+ embedded_key_matches_trusted_key: bool | None = None
243
+
244
+
245
+ def verify_receipt(receipt: dict[str, Any], trusted_public_key: Ed25519PublicKey) -> VerifyResult:
246
+ """Verify `receipt` against `trusted_public_key` (supplied by the
247
+ caller -- never derived from the receipt itself; see module docstring).
248
+ """
249
+ try:
250
+ payload = receipt["payload"]
251
+ payload_sha256 = receipt["payload_sha256"]
252
+ signature_b64 = receipt["signature"]
253
+ embedded_public_key_b64 = receipt.get("public_key")
254
+ except KeyError as exc:
255
+ return VerifyResult(valid=False, reason=f"receipt is missing required field {exc}")
256
+
257
+ if receipt.get("algorithm", ALGORITHM) != ALGORITHM:
258
+ return VerifyResult(
259
+ valid=False,
260
+ reason=f"unsupported algorithm {receipt.get('algorithm')!r} (expected {ALGORITHM})",
261
+ )
262
+
263
+ canonical = canonicalize(payload)
264
+ recomputed_hash = hashlib.sha256(canonical).hexdigest()
265
+ if recomputed_hash != payload_sha256:
266
+ return VerifyResult(
267
+ valid=False,
268
+ reason=(
269
+ "payload does not match its own recorded payload_sha256 -- the "
270
+ "payload was edited after the receipt was written "
271
+ f"(recomputed {recomputed_hash}, receipt says {payload_sha256})"
272
+ ),
273
+ )
274
+
275
+ try:
276
+ signature = base64.b64decode(signature_b64, validate=True)
277
+ except ValueError as exc:
278
+ return VerifyResult(valid=False, reason=f"signature is not valid base64: {exc}")
279
+
280
+ embedded_key_matches: bool | None = None
281
+ if embedded_public_key_b64 is not None:
282
+ embedded_key_matches = embedded_public_key_b64 == _public_key_b64(trusted_public_key)
283
+
284
+ try:
285
+ trusted_public_key.verify(signature, canonical)
286
+ except InvalidSignature:
287
+ return VerifyResult(
288
+ valid=False,
289
+ reason=(
290
+ "signature does not verify against the supplied trusted public key "
291
+ "-- either the payload was tampered with, or it was signed by a "
292
+ "different key than the one supplied to `memtrust verify`"
293
+ ),
294
+ embedded_key_matches_trusted_key=embedded_key_matches,
295
+ )
296
+
297
+ return VerifyResult(
298
+ valid=True,
299
+ reason="signature verified: payload is unaltered and was signed by the supplied key",
300
+ embedded_key_matches_trusted_key=embedded_key_matches,
301
+ )
302
+
303
+
304
+ def verify_receipt_file(
305
+ receipt_path: Path, *, public_key_path: Path | None, env_var: str = PUBLIC_KEY_ENV_VAR
306
+ ) -> VerifyResult:
307
+ trusted_key = resolve_public_key(public_key_path, env_var=env_var)
308
+ receipt = json.loads(receipt_path.read_text())
309
+ if not isinstance(receipt, dict):
310
+ return VerifyResult(valid=False, reason="receipt file does not contain a JSON object")
311
+ return verify_receipt(receipt, trusted_key)
312
+
313
+
314
+ def receipt_path_for(report_path: Path) -> Path:
315
+ """The conventional receipt filename for a given report path:
316
+ `memtrust-report-2026-07-16.json` -> `memtrust-report-2026-07-16.receipt.json`.
317
+ """
318
+ return report_path.with_name(report_path.stem + ".receipt.json")
@@ -0,0 +1 @@
1
+ """LLM-graded scoring pipeline and cost tracking."""
@@ -0,0 +1,199 @@
1
+ """Tracks estimated token/cost per run so `memtrust run` can print a cost
2
+ summary. Pricing is an approximate, dated estimate -- not a billing
3
+ guarantee. See PRICING_LAST_VERIFIED below and docs/methodology.md.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import threading
9
+ from dataclasses import dataclass, field
10
+
11
+ #: Last time the per-1M-token prices below were checked against the
12
+ #: provider's published pricing page. Prices change; treat this table as
13
+ #: an estimate for cost-awareness, not an invoice.
14
+ PRICING_LAST_VERIFIED = "2026-07-11"
15
+
16
+ #: USD per 1,000,000 tokens, (input, output). Approximate, from each
17
+ #: provider's public pricing page as of PRICING_LAST_VERIFIED.
18
+ MODEL_PRICING_PER_MILLION_TOKENS: dict[str, tuple[float, float]] = {
19
+ "deepseek-chat": (0.28, 0.42),
20
+ "deepseek-reasoner": (0.56, 1.68),
21
+ "gemini-2.5-flash-lite": (0.10, 0.40),
22
+ "gemini-2.0-flash-lite": (0.075, 0.30),
23
+ }
24
+
25
+ DEFAULT_UNKNOWN_MODEL_PRICE = (0.50, 1.50)
26
+
27
+ #: Approximate USD per 1,000,000 embedded input characters, keyed by
28
+ #: embedder provider name (matching `Mem0DirectAdapter.SUPPORTED_EMBEDDER_
29
+ #: PROVIDERS`'s naming) -- NOT per-token. Vendor embedder-call
30
+ #: instrumentation (see mem0_direct_adapter.py's `_CountingEmbedder`) only
31
+ #: has each `embed()`/`embed_batch()` call's raw input text available, not
32
+ #: the vendor's own tokenizer's real token count, so a char/4 heuristic is
33
+ #: applied at the call site before this table's price is used -- the same
34
+ #: "estimate, not an invoice" caveat `MODEL_PRICING_PER_MILLION_TOKENS`
35
+ #: above already carries, extended here to vendor-side embedding spend
36
+ #: rather than memtrust's own judge-LLM spend. Approximate, from each
37
+ #: provider's public embedding-pricing page as of PRICING_LAST_VERIFIED.
38
+ #: `fastembed` runs a local ONNX model with no per-call vendor API cost at
39
+ #: all, hence $0 -- not an "unknown, guess" $0, a genuine $0.
40
+ EMBEDDER_PRICING_PER_MILLION_TOKENS: dict[str, float] = {
41
+ "openai": 0.02, # text-embedding-3-small, OpenAI's cheapest/default embedding model
42
+ "aws_bedrock": 0.02, # Amazon Titan Text Embeddings V2
43
+ "gemini": 0.00, # no public per-token embedding price line-item as of PRICING_LAST_VERIFIED
44
+ "fastembed": 0.00, # local ONNX model, genuinely no vendor API call
45
+ }
46
+
47
+ DEFAULT_UNKNOWN_EMBEDDER_PRICE = 0.10
48
+
49
+
50
+ @dataclass
51
+ class CostEntry:
52
+ label: str
53
+ model: str
54
+ input_tokens: int
55
+ output_tokens: int
56
+ cost_usd: float
57
+
58
+
59
+ @dataclass
60
+ class EmbedCallEntry:
61
+ """Vendor-side embedder-call/cost attribution -- distinct from
62
+ `CostEntry` above, which tracks only memtrust's own judge-LLM spend.
63
+ See `CostTracker.record_embed_calls()` and
64
+ mem0_direct_adapter.py's `_CountingEmbedder` for where this gets
65
+ populated. Motivating case: spike-spiegel-21 (rank 104,
66
+ mem0ai/mem0#1900) landed a merged fix eliminating a duplicate
67
+ embedding-API call in an earlier mem0 architecture when content was
68
+ unchanged after search -- memtrust had zero vendor-side embedder-call
69
+ instrumentation anywhere to observe whether that class of waste is
70
+ still happening in the currently installed mem0ai version's own
71
+ pipeline. See evals/embedder_cost.py.
72
+ """
73
+
74
+ label: str
75
+ provider: str
76
+ call_count: int
77
+ estimated_tokens: int
78
+ cost_usd: float
79
+
80
+
81
+ @dataclass
82
+ class CostTracker:
83
+ """Accumulates cost entries across a `memtrust run` invocation.
84
+
85
+ memtrust's eval runners are sequential today, so ``_lock`` guards a
86
+ mutation that is not actually contended yet. It's here preemptively:
87
+ a real, merged vendor bug (volcengine/OpenViking PR #3091) shipped a
88
+ benchmark tool that silently ignored its own --concurrency flag,
89
+ quietly invalidating its self-reported numbers. Keeping this shared
90
+ list append synchronized now means that if/when memtrust adds
91
+ concurrent eval execution, cost accounting can't silently drop or
92
+ corrupt entries the way an unsynchronized append would. The same
93
+ `_lock` guards `embed_entries` below.
94
+ """
95
+
96
+ entries: list[CostEntry] = field(default_factory=list)
97
+ embed_entries: list[EmbedCallEntry] = field(default_factory=list)
98
+ """Vendor-side embedder-call entries -- kept as a separate list from
99
+ `entries` above (rather than folded into the same list/shape) because
100
+ the two track fundamentally different spend categories: `entries` is
101
+ memtrust's own judge-LLM cost (real input/output token counts from a
102
+ real LLM API response), `embed_entries` is vendor embedder-API call
103
+ count/cost attribution derived from a char-length heuristic, never a
104
+ real token count the vendor itself reported. Conflating the two into
105
+ one list/one set of properties would misrepresent the second
106
+ category's evidentiary strength as equal to the first's."""
107
+ _lock: threading.Lock = field(default_factory=threading.Lock, repr=False, compare=False)
108
+
109
+ def record(self, label: str, model: str, input_tokens: int, output_tokens: int) -> CostEntry:
110
+ price_in, price_out = MODEL_PRICING_PER_MILLION_TOKENS.get(
111
+ model, DEFAULT_UNKNOWN_MODEL_PRICE
112
+ )
113
+ cost = (input_tokens / 1_000_000) * price_in + (output_tokens / 1_000_000) * price_out
114
+ entry = CostEntry(
115
+ label=label,
116
+ model=model,
117
+ input_tokens=input_tokens,
118
+ output_tokens=output_tokens,
119
+ cost_usd=round(cost, 6),
120
+ )
121
+ with self._lock:
122
+ self.entries.append(entry)
123
+ return entry
124
+
125
+ def record_embed_calls(
126
+ self, label: str, provider: str, call_count: int, estimated_tokens: int
127
+ ) -> EmbedCallEntry:
128
+ """Record vendor-side embedder-call/cost attribution for a single
129
+ adapter operation (typically one `Mem0DirectAdapter.store()` call)
130
+ -- distinct from `record()` above, which is memtrust's own
131
+ judge-LLM spend only. See `EmbedCallEntry`'s docstring for why
132
+ this is a separate list/method rather than reusing `record()`'s
133
+ shape.
134
+ """
135
+ price = EMBEDDER_PRICING_PER_MILLION_TOKENS.get(provider, DEFAULT_UNKNOWN_EMBEDDER_PRICE)
136
+ cost = (estimated_tokens / 1_000_000) * price
137
+ entry = EmbedCallEntry(
138
+ label=label,
139
+ provider=provider,
140
+ call_count=call_count,
141
+ estimated_tokens=estimated_tokens,
142
+ cost_usd=round(cost, 6),
143
+ )
144
+ with self._lock:
145
+ self.embed_entries.append(entry)
146
+ return entry
147
+
148
+ @property
149
+ def total_cost_usd(self) -> float:
150
+ return round(sum(e.cost_usd for e in self.entries), 6)
151
+
152
+ @property
153
+ def total_input_tokens(self) -> int:
154
+ return sum(e.input_tokens for e in self.entries)
155
+
156
+ @property
157
+ def total_output_tokens(self) -> int:
158
+ return sum(e.output_tokens for e in self.entries)
159
+
160
+ @property
161
+ def total_embed_calls(self) -> int:
162
+ return sum(e.call_count for e in self.embed_entries)
163
+
164
+ @property
165
+ def total_embed_cost_usd(self) -> float:
166
+ return round(sum(e.cost_usd for e in self.embed_entries), 6)
167
+
168
+ def summary_lines(self) -> list[str]:
169
+ if not self.entries and not self.embed_entries:
170
+ return [
171
+ "Cost: $0.00 (no LLM-judged evals ran -- structural evals only, "
172
+ "or judge not configured)"
173
+ ]
174
+ lines: list[str] = []
175
+ if self.entries:
176
+ lines.append(
177
+ f"Cost: ${self.total_cost_usd:.4f} estimated "
178
+ f"({self.total_input_tokens:,} in / {self.total_output_tokens:,} out tokens, "
179
+ f"pricing last verified {PRICING_LAST_VERIFIED})"
180
+ )
181
+ by_model: dict[str, float] = {}
182
+ for e in self.entries:
183
+ by_model[e.model] = by_model.get(e.model, 0.0) + e.cost_usd
184
+ for model, cost in sorted(by_model.items()):
185
+ lines.append(f" {model}: ${cost:.4f}")
186
+ if self.embed_entries:
187
+ lines.append(
188
+ f"Vendor embedder calls: {self.total_embed_calls:,} "
189
+ f"(~${self.total_embed_cost_usd:.4f} estimated, pricing last verified "
190
+ f"{PRICING_LAST_VERIFIED})"
191
+ )
192
+ by_provider: dict[str, int] = {}
193
+ for embed_entry in self.embed_entries:
194
+ by_provider[embed_entry.provider] = (
195
+ by_provider.get(embed_entry.provider, 0) + embed_entry.call_count
196
+ )
197
+ for provider, calls in sorted(by_provider.items()):
198
+ lines.append(f" {provider}: {calls:,} calls")
199
+ return lines
@@ -0,0 +1,161 @@
1
+ """LLM-graded scoring for eval outputs that need semantic judgment (does
2
+ this recalled answer actually match the expected fact, allowing for
3
+ paraphrase) rather than exact string match.
4
+
5
+ Model-configurable via environment variables:
6
+ MEMTRUST_JUDGE_API_KEY -- required to actually run a judged eval
7
+ MEMTRUST_JUDGE_MODEL -- default: deepseek-chat
8
+ MEMTRUST_JUDGE_BASE_URL -- default: https://api.deepseek.com
9
+ (OpenAI-compatible chat-completions shape;
10
+ point this at any OpenAI-compatible endpoint,
11
+ including a Gemini OpenAI-compat proxy, to
12
+ use a different judge model)
13
+
14
+ If no API key is configured, judge_answer() returns a JudgeResult with
15
+ verdict=NOT_RUN and an explicit reason -- it never fabricates a score.
16
+ Every eval runner that calls this module must propagate NOT_RUN as "could
17
+ not be graded," not as a failing score, so an unconfigured judge cannot be
18
+ mistaken for a backend that failed the eval.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ from dataclasses import dataclass
25
+ from enum import StrEnum
26
+
27
+ import httpx
28
+
29
+ from memtrust.scoring.cost_tracker import CostTracker
30
+
31
+ DEFAULT_MODEL = "deepseek-chat"
32
+ DEFAULT_BASE_URL = "https://api.deepseek.com"
33
+
34
+ #: Exact judge prompt template, published here and mirrored in
35
+ #: docs/methodology.md so every scored run is reproducible from the repo
36
+ #: alone -- no prompt lives only in a maintainer's head.
37
+ JUDGE_PROMPT_TEMPLATE = """You are grading whether a memory system's recalled answer is factually \
38
+ equivalent to the expected answer. Ignore differences in phrasing, tense, or extra \
39
+ detail -- grade only whether the core fact matches.
40
+
41
+ Question asked: {question}
42
+ Expected answer: {expected}
43
+ System's actual answer: {actual}
44
+
45
+ Respond with exactly one word on the first line: CORRECT, INCORRECT, or PARTIAL.
46
+ On the second line, give a one-sentence reason.
47
+ """
48
+
49
+
50
+ class JudgeVerdict(StrEnum):
51
+ CORRECT = "correct"
52
+ INCORRECT = "incorrect"
53
+ PARTIAL = "partial"
54
+ NOT_RUN = "not_run"
55
+ """The judge could not run -- no API key configured, or the call
56
+ failed. Never averaged into a score; callers must report this
57
+ verdict's cases as "could not be graded," separate from the scored
58
+ cases."""
59
+
60
+
61
+ @dataclass
62
+ class JudgeResult:
63
+ verdict: JudgeVerdict
64
+ reasoning: str
65
+ model: str | None = None
66
+ input_tokens: int = 0
67
+ output_tokens: int = 0
68
+
69
+
70
+ class LLMJudge:
71
+ """Thin, provider-agnostic client for the configured judge model.
72
+
73
+ Constructed once per run and passed a shared CostTracker so every
74
+ judged eval call's estimated cost rolls up into one printed total.
75
+ """
76
+
77
+ def __init__(self, cost_tracker: CostTracker | None = None, timeout: float = 30.0) -> None:
78
+ self.api_key = os.environ.get("MEMTRUST_JUDGE_API_KEY")
79
+ self.model = os.environ.get("MEMTRUST_JUDGE_MODEL", DEFAULT_MODEL)
80
+ self.base_url = os.environ.get("MEMTRUST_JUDGE_BASE_URL", DEFAULT_BASE_URL)
81
+ self.cost_tracker = cost_tracker if cost_tracker is not None else CostTracker()
82
+ self._timeout = timeout
83
+ self._http: httpx.Client | None = None
84
+
85
+ @property
86
+ def is_configured(self) -> bool:
87
+ return bool(self.api_key)
88
+
89
+ def _client(self) -> httpx.Client:
90
+ if self._http is None:
91
+ self._http = httpx.Client(
92
+ base_url=self.base_url,
93
+ headers={
94
+ "Authorization": f"Bearer {self.api_key}",
95
+ "Content-Type": "application/json",
96
+ },
97
+ timeout=self._timeout,
98
+ )
99
+ return self._http
100
+
101
+ def judge_answer(self, question: str, expected: str, actual: str) -> JudgeResult:
102
+ if not self.is_configured:
103
+ return JudgeResult(
104
+ verdict=JudgeVerdict.NOT_RUN,
105
+ reasoning=(
106
+ "No judge API key configured (set MEMTRUST_JUDGE_API_KEY). "
107
+ "This eval could not be graded -- reporting as not-run, "
108
+ "not as a failing score."
109
+ ),
110
+ )
111
+
112
+ prompt = JUDGE_PROMPT_TEMPLATE.format(question=question, expected=expected, actual=actual)
113
+ payload = {
114
+ "model": self.model,
115
+ "messages": [{"role": "user", "content": prompt}],
116
+ "temperature": 0.0,
117
+ "max_tokens": 100,
118
+ }
119
+ try:
120
+ resp = self._client().post("/chat/completions", json=payload)
121
+ resp.raise_for_status()
122
+ data = resp.json()
123
+ except httpx.HTTPError as exc:
124
+ return JudgeResult(
125
+ verdict=JudgeVerdict.NOT_RUN,
126
+ reasoning=(
127
+ f"Judge API call failed: {exc}. Reporting as not-run, not a failing score."
128
+ ),
129
+ model=self.model,
130
+ )
131
+
132
+ text = data["choices"][0]["message"]["content"].strip()
133
+ usage = data.get("usage", {})
134
+ input_tokens = int(usage.get("prompt_tokens", 0))
135
+ output_tokens = int(usage.get("completion_tokens", 0))
136
+ self.cost_tracker.record(
137
+ label=f"judge:{question[:40]}",
138
+ model=self.model,
139
+ input_tokens=input_tokens,
140
+ output_tokens=output_tokens,
141
+ )
142
+
143
+ first_line = text.splitlines()[0].strip().upper() if text else ""
144
+ reasoning = text.splitlines()[1].strip() if len(text.splitlines()) > 1 else text
145
+ if "CORRECT" in first_line and "INCORRECT" not in first_line:
146
+ verdict = JudgeVerdict.CORRECT
147
+ elif "PARTIAL" in first_line:
148
+ verdict = JudgeVerdict.PARTIAL
149
+ else:
150
+ verdict = JudgeVerdict.INCORRECT
151
+ return JudgeResult(
152
+ verdict=verdict,
153
+ reasoning=reasoning,
154
+ model=self.model,
155
+ input_tokens=input_tokens,
156
+ output_tokens=output_tokens,
157
+ )
158
+
159
+ def close(self) -> None:
160
+ if self._http is not None:
161
+ self._http.close()