memtrust-cli 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- memtrust/__init__.py +11 -0
- memtrust/adapters/__init__.py +62 -0
- memtrust/adapters/base.py +2020 -0
- memtrust/adapters/mem0_adapter.py +456 -0
- memtrust/adapters/mem0_direct_adapter.py +1217 -0
- memtrust/adapters/mempalace_adapter.py +1166 -0
- memtrust/adapters/openviking_adapter.py +570 -0
- memtrust/adapters/zep_graphiti_adapter.py +181 -0
- memtrust/adapters/zep_graphiti_selfhosted_adapter.py +882 -0
- memtrust/cli.py +1201 -0
- memtrust/evals/__init__.py +5 -0
- memtrust/evals/compression.py +235 -0
- memtrust/evals/contradiction.py +451 -0
- memtrust/evals/crash_recovery.py +290 -0
- memtrust/evals/embedder_cost.py +163 -0
- memtrust/evals/embedding_drift.py +273 -0
- memtrust/evals/episode_temporal_leak.py +182 -0
- memtrust/evals/extraction_quality.py +373 -0
- memtrust/evals/filter_injection.py +371 -0
- memtrust/evals/language_degradation.py +189 -0
- memtrust/evals/lock_contention.py +263 -0
- memtrust/evals/locomo.py +392 -0
- memtrust/evals/longmemeval.py +249 -0
- memtrust/evals/mempalace_metadata_scale.py +494 -0
- memtrust/evals/migration_rollback.py +276 -0
- memtrust/evals/orphan_cleanup.py +235 -0
- memtrust/evals/ranking_quality.py +303 -0
- memtrust/evals/resource_sync_safety.py +336 -0
- memtrust/evals/result_consistency.py +239 -0
- memtrust/evals/scale_fixtures.py +189 -0
- memtrust/evals/scale_stress.py +502 -0
- memtrust/evals/stats_accuracy.py +240 -0
- memtrust/evals/temporal_kg_boundary.py +281 -0
- memtrust/receipt.py +318 -0
- memtrust/scoring/__init__.py +1 -0
- memtrust/scoring/cost_tracker.py +199 -0
- memtrust/scoring/llm_judge.py +161 -0
- memtrust_cli-0.3.0.dist-info/METADATA +624 -0
- memtrust_cli-0.3.0.dist-info/RECORD +42 -0
- memtrust_cli-0.3.0.dist-info/WHEEL +4 -0
- memtrust_cli-0.3.0.dist-info/entry_points.txt +2 -0
- memtrust_cli-0.3.0.dist-info/licenses/LICENSE +202 -0
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()
|