memtrust-cli 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- memtrust/__init__.py +11 -0
- memtrust/adapters/__init__.py +62 -0
- memtrust/adapters/base.py +2020 -0
- memtrust/adapters/mem0_adapter.py +456 -0
- memtrust/adapters/mem0_direct_adapter.py +1217 -0
- memtrust/adapters/mempalace_adapter.py +1166 -0
- memtrust/adapters/openviking_adapter.py +570 -0
- memtrust/adapters/zep_graphiti_adapter.py +181 -0
- memtrust/adapters/zep_graphiti_selfhosted_adapter.py +882 -0
- memtrust/cli.py +1201 -0
- memtrust/evals/__init__.py +5 -0
- memtrust/evals/compression.py +235 -0
- memtrust/evals/contradiction.py +451 -0
- memtrust/evals/crash_recovery.py +290 -0
- memtrust/evals/embedder_cost.py +163 -0
- memtrust/evals/embedding_drift.py +273 -0
- memtrust/evals/episode_temporal_leak.py +182 -0
- memtrust/evals/extraction_quality.py +373 -0
- memtrust/evals/filter_injection.py +371 -0
- memtrust/evals/language_degradation.py +189 -0
- memtrust/evals/lock_contention.py +263 -0
- memtrust/evals/locomo.py +392 -0
- memtrust/evals/longmemeval.py +249 -0
- memtrust/evals/mempalace_metadata_scale.py +494 -0
- memtrust/evals/migration_rollback.py +276 -0
- memtrust/evals/orphan_cleanup.py +235 -0
- memtrust/evals/ranking_quality.py +303 -0
- memtrust/evals/resource_sync_safety.py +336 -0
- memtrust/evals/result_consistency.py +239 -0
- memtrust/evals/scale_fixtures.py +189 -0
- memtrust/evals/scale_stress.py +502 -0
- memtrust/evals/stats_accuracy.py +240 -0
- memtrust/evals/temporal_kg_boundary.py +281 -0
- memtrust/receipt.py +318 -0
- memtrust/scoring/__init__.py +1 -0
- memtrust/scoring/cost_tracker.py +199 -0
- memtrust/scoring/llm_judge.py +161 -0
- memtrust_cli-0.3.0.dist-info/METADATA +624 -0
- memtrust_cli-0.3.0.dist-info/RECORD +42 -0
- memtrust_cli-0.3.0.dist-info/WHEEL +4 -0
- memtrust_cli-0.3.0.dist-info/entry_points.txt +2 -0
- memtrust_cli-0.3.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""MemTrust's embedding-drift/consistency eval.
|
|
2
|
+
|
|
3
|
+
Every other eval in this package classifies a *single* store()/query()
|
|
4
|
+
round trip (or, for resource-sync-safety, a single seed-file's before/after
|
|
5
|
+
state across one resync call). This eval targets a failure mode none of
|
|
6
|
+
them can see: a record that was perfectly fine when it was stored can be
|
|
7
|
+
silently broken by a *later, unrelated* store() call for a *different*
|
|
8
|
+
record, if that later call happens to migrate embedding models. No single
|
|
9
|
+
query() response carries any evidence of this -- the record that broke
|
|
10
|
+
wasn't touched by the query that reveals the breakage, it was broken
|
|
11
|
+
earlier by a completely different write.
|
|
12
|
+
|
|
13
|
+
Motivating case: volcengine/OpenViking#1523 (contributor A0nameless0man).
|
|
14
|
+
An embedder migration silently degrades search quality mid-migration:
|
|
15
|
+
switching embedding models overwrites previously-stored vectors in place
|
|
16
|
+
with no dimension/model validation, so records embedded under the old
|
|
17
|
+
model can stop being retrievable once new-model writes start landing, with
|
|
18
|
+
no exception and no signal anywhere that this happened.
|
|
19
|
+
|
|
20
|
+
**Honest scope -- read this before trusting an EMBEDDING_DRIFT result.**
|
|
21
|
+
This eval cannot be built adapter-natively against any real backend in this
|
|
22
|
+
repo. `OpenVikingAdapter.query()` (the adapter this bug report names) talks
|
|
23
|
+
to OpenViking's documented `/v1/search` response shape, which this build's
|
|
24
|
+
research pass did not find to expose any per-record embedding-model or
|
|
25
|
+
embedding-dimension field at all (see openviking_adapter.py's module
|
|
26
|
+
docstring) -- and no other adapter in this repo exposes one either. A real
|
|
27
|
+
backend genuinely cannot tell this eval "this record was embedded by model
|
|
28
|
+
A" on its own; `MemoryRecord.embedding_model`/`embedding_dims`
|
|
29
|
+
(adapters/base.py) exist so an adapter COULD report this if a future
|
|
30
|
+
backend's API surfaced it, but as of this writing none does.
|
|
31
|
+
|
|
32
|
+
So this eval is built at the harness level instead, the same way
|
|
33
|
+
resource_sync_safety.py's NESTED_CONTENT_UNINDEXED signal and
|
|
34
|
+
ranking_quality.py's MISSING_ORDERING_KEY signal are: it drives the shared
|
|
35
|
+
store()/query() interface with a fixture-level construct -- a plain string
|
|
36
|
+
metadata tag (`embedding_model_label`) the fixture assigns to each seeded
|
|
37
|
+
record, not a real embedder concept any adapter has to understand -- and
|
|
38
|
+
observes whether the adapter's OWN behavior across two store() calls
|
|
39
|
+
reproduces the exact bug shape (in-place overwrite with no dimension
|
|
40
|
+
validation): records seeded under one label become unrecoverable once
|
|
41
|
+
records seeded under a second label are stored into the same session.
|
|
42
|
+
|
|
43
|
+
Every unit test covering this eval (tests/test_evals.py) runs against fake,
|
|
44
|
+
in-memory adapters purpose-built to reproduce that exact bug shape (or to
|
|
45
|
+
migrate cleanly, as the negative control) -- never against a live
|
|
46
|
+
OpenViking instance or any other live backend, since no live backend
|
|
47
|
+
exposes what this eval would need to be adapter-native. See
|
|
48
|
+
docs/methodology.md for the same caveat stated in the project's single
|
|
49
|
+
source of truth for what is measured vs. simulated.
|
|
50
|
+
|
|
51
|
+
Design:
|
|
52
|
+
|
|
53
|
+
1. Store N records under the case's `model_a_label` embedding-model tag.
|
|
54
|
+
2. Confirm each is retrievable (query for its own content) -- this is the
|
|
55
|
+
eval's baseline, established BEFORE any migration step runs.
|
|
56
|
+
3. Store the case's `model_b_records` under a *different* embedding-model
|
|
57
|
+
tag, `model_b_label`, into the same session -- this is the "migration."
|
|
58
|
+
4. Re-query for each model-A record's own content.
|
|
59
|
+
5. Classify each model-A record from its before/after retrievability (see
|
|
60
|
+
`classify_embedding_drift_record` below) -- never from a bare
|
|
61
|
+
self-report, same non-negotiable convention every other eval in this
|
|
62
|
+
package follows.
|
|
63
|
+
|
|
64
|
+
Requiring step 2's confirmed baseline is what keeps this eval honest about
|
|
65
|
+
"normal recall variance": a record that was never retrievable in the first
|
|
66
|
+
place (a generic recall miss with nothing to do with any migration) is
|
|
67
|
+
recorded NOT_APPLICABLE, never misattributed to drift. Only a record that
|
|
68
|
+
was provably retrievable before the migration step and stops being
|
|
69
|
+
retrievable strictly after it is classified EMBEDDING_DRIFT.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
from __future__ import annotations
|
|
73
|
+
|
|
74
|
+
import json
|
|
75
|
+
from dataclasses import dataclass, field
|
|
76
|
+
from pathlib import Path
|
|
77
|
+
from typing import Any
|
|
78
|
+
|
|
79
|
+
from memtrust.adapters.base import (
|
|
80
|
+
BackendAPIError,
|
|
81
|
+
EmbeddingDriftSignal,
|
|
82
|
+
MemoryBackendAdapter,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
DEFAULT_FIXTURE_PATH = (
|
|
86
|
+
Path(__file__).resolve().parents[3] / "tests" / "fixtures" / "embedding_drift_cases.json"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class EmbeddingDriftSeedRecord:
|
|
92
|
+
content: str
|
|
93
|
+
metadata: dict[str, str] = field(default_factory=dict)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class EmbeddingDriftCase:
|
|
98
|
+
case_id: str
|
|
99
|
+
session_id: str
|
|
100
|
+
model_a_label: str
|
|
101
|
+
"""Fixture-level tag standing in for "the embedding model in use before
|
|
102
|
+
the migration." Passed through to `adapter.store()`'s generic
|
|
103
|
+
`metadata` dict under the `embedding_model_label` key -- a plain string
|
|
104
|
+
every adapter already knows how to accept and store, not a real
|
|
105
|
+
embedder concept any adapter has to implement."""
|
|
106
|
+
model_b_label: str
|
|
107
|
+
"""Fixture-level tag for "the embedding model switched to mid-migration."
|
|
108
|
+
"""
|
|
109
|
+
model_a_records: list[EmbeddingDriftSeedRecord]
|
|
110
|
+
"""Records stored under `model_a_label` before the simulated migration
|
|
111
|
+
-- these are the records this eval checks for drift."""
|
|
112
|
+
model_b_records: list[EmbeddingDriftSeedRecord]
|
|
113
|
+
"""Records stored under `model_b_label` after the model-A records --
|
|
114
|
+
this is what simulates "switching to model B for new stores." Their own
|
|
115
|
+
retrievability is not scored; they exist purely to trigger whatever the
|
|
116
|
+
adapter's own store() does when a second embedding-model label appears
|
|
117
|
+
in the same session."""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class EmbeddingDriftRecordResult:
|
|
122
|
+
case_id: str
|
|
123
|
+
content: str
|
|
124
|
+
model_a_label: str
|
|
125
|
+
model_b_label: str
|
|
126
|
+
retrievable_before_migration: bool
|
|
127
|
+
retrievable_after_migration: bool
|
|
128
|
+
signal: EmbeddingDriftSignal
|
|
129
|
+
error: str | None = None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class EmbeddingDriftEvalResult:
|
|
134
|
+
backend_name: str
|
|
135
|
+
dataset_path: str
|
|
136
|
+
record_results: list[EmbeddingDriftRecordResult] = field(default_factory=list)
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def scored_records(self) -> list[EmbeddingDriftRecordResult]:
|
|
140
|
+
return [r for r in self.record_results if r.error is None]
|
|
141
|
+
|
|
142
|
+
def _fraction(self, signal: EmbeddingDriftSignal) -> float | None:
|
|
143
|
+
scored = self.scored_records
|
|
144
|
+
if not scored:
|
|
145
|
+
return None
|
|
146
|
+
matching = sum(1 for r in scored if r.signal == signal)
|
|
147
|
+
return matching / len(scored)
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def drift_rate(self) -> float | None:
|
|
151
|
+
"""Fraction of model-A records that were confirmed retrievable
|
|
152
|
+
before the migration step and became unretrievable afterward --
|
|
153
|
+
the headline metric this eval exists to surface, and the one that
|
|
154
|
+
would flag volcengine/OpenViking#1523's exact shape."""
|
|
155
|
+
return self._fraction(EmbeddingDriftSignal.EMBEDDING_DRIFT)
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def clean_rate(self) -> float | None:
|
|
159
|
+
return self._fraction(EmbeddingDriftSignal.CLEAN)
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def not_applicable_rate(self) -> float | None:
|
|
163
|
+
return self._fraction(EmbeddingDriftSignal.NOT_APPLICABLE)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def load_dataset(path: Path | str = DEFAULT_FIXTURE_PATH) -> list[EmbeddingDriftCase]:
|
|
167
|
+
data = json.loads(Path(path).read_text())
|
|
168
|
+
cases: list[dict[str, Any]] = data["cases"]
|
|
169
|
+
return [
|
|
170
|
+
EmbeddingDriftCase(
|
|
171
|
+
case_id=c["case_id"],
|
|
172
|
+
session_id=c["session_id"],
|
|
173
|
+
model_a_label=c["model_a_label"],
|
|
174
|
+
model_b_label=c["model_b_label"],
|
|
175
|
+
model_a_records=[
|
|
176
|
+
EmbeddingDriftSeedRecord(content=r["content"], metadata=r.get("metadata", {}))
|
|
177
|
+
for r in c["model_a_records"]
|
|
178
|
+
],
|
|
179
|
+
model_b_records=[
|
|
180
|
+
EmbeddingDriftSeedRecord(content=r["content"], metadata=r.get("metadata", {}))
|
|
181
|
+
for r in c["model_b_records"]
|
|
182
|
+
],
|
|
183
|
+
)
|
|
184
|
+
for c in cases
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def classify_embedding_drift_record(
|
|
189
|
+
retrievable_before_migration: bool,
|
|
190
|
+
retrievable_after_migration: bool,
|
|
191
|
+
) -> EmbeddingDriftSignal:
|
|
192
|
+
"""Classify a single model-A record's outcome from its before/after
|
|
193
|
+
retrievability.
|
|
194
|
+
|
|
195
|
+
Only ever returns EMBEDDING_DRIFT when the record was provably
|
|
196
|
+
retrievable before the migration step -- a record that was never
|
|
197
|
+
observed retrievable in the first place has no valid baseline and is
|
|
198
|
+
classified NOT_APPLICABLE instead, so an ordinary recall miss unrelated
|
|
199
|
+
to any migration can never be misattributed to drift.
|
|
200
|
+
"""
|
|
201
|
+
if not retrievable_before_migration:
|
|
202
|
+
return EmbeddingDriftSignal.NOT_APPLICABLE
|
|
203
|
+
if not retrievable_after_migration:
|
|
204
|
+
return EmbeddingDriftSignal.EMBEDDING_DRIFT
|
|
205
|
+
return EmbeddingDriftSignal.CLEAN
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _is_retrievable(adapter: MemoryBackendAdapter, session_id: str, content: str) -> bool:
|
|
209
|
+
query_result = adapter.query(session_id, content, top_k=5)
|
|
210
|
+
return any(content.lower() in r.content.lower() for r in query_result.records)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def run_embedding_drift_eval(
|
|
214
|
+
adapter: MemoryBackendAdapter,
|
|
215
|
+
dataset_path: Path | str = DEFAULT_FIXTURE_PATH,
|
|
216
|
+
) -> EmbeddingDriftEvalResult:
|
|
217
|
+
cases = load_dataset(dataset_path)
|
|
218
|
+
result = EmbeddingDriftEvalResult(backend_name=adapter.name, dataset_path=str(dataset_path))
|
|
219
|
+
|
|
220
|
+
for case in cases:
|
|
221
|
+
try:
|
|
222
|
+
# (1) Store every model-A record under the case's model_a_label.
|
|
223
|
+
for seed in case.model_a_records:
|
|
224
|
+
metadata = {**seed.metadata, "embedding_model_label": case.model_a_label}
|
|
225
|
+
adapter.store(case.session_id, seed.content, metadata=metadata)
|
|
226
|
+
|
|
227
|
+
# (2) Confirm baseline retrievability BEFORE any migration --
|
|
228
|
+
# this is what lets EMBEDDING_DRIFT be distinguished from
|
|
229
|
+
# ordinary recall variance below.
|
|
230
|
+
retrievable_before = {
|
|
231
|
+
seed.content: _is_retrievable(adapter, case.session_id, seed.content)
|
|
232
|
+
for seed in case.model_a_records
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
# (3) Simulate switching to model B for new stores.
|
|
236
|
+
for seed in case.model_b_records:
|
|
237
|
+
metadata = {**seed.metadata, "embedding_model_label": case.model_b_label}
|
|
238
|
+
adapter.store(case.session_id, seed.content, metadata=metadata)
|
|
239
|
+
|
|
240
|
+
# (4) Re-check every model-A record after the migration step.
|
|
241
|
+
for seed in case.model_a_records:
|
|
242
|
+
retrievable_after = _is_retrievable(adapter, case.session_id, seed.content)
|
|
243
|
+
signal = classify_embedding_drift_record(
|
|
244
|
+
retrievable_before[seed.content], retrievable_after
|
|
245
|
+
)
|
|
246
|
+
result.record_results.append(
|
|
247
|
+
EmbeddingDriftRecordResult(
|
|
248
|
+
case_id=case.case_id,
|
|
249
|
+
content=seed.content,
|
|
250
|
+
model_a_label=case.model_a_label,
|
|
251
|
+
model_b_label=case.model_b_label,
|
|
252
|
+
retrievable_before_migration=retrievable_before[seed.content],
|
|
253
|
+
retrievable_after_migration=retrievable_after,
|
|
254
|
+
signal=signal,
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
except BackendAPIError as exc:
|
|
258
|
+
for seed in case.model_a_records:
|
|
259
|
+
result.record_results.append(
|
|
260
|
+
EmbeddingDriftRecordResult(
|
|
261
|
+
case_id=case.case_id,
|
|
262
|
+
content=seed.content,
|
|
263
|
+
model_a_label=case.model_a_label,
|
|
264
|
+
model_b_label=case.model_b_label,
|
|
265
|
+
retrievable_before_migration=False,
|
|
266
|
+
retrievable_after_migration=False,
|
|
267
|
+
signal=EmbeddingDriftSignal.NOT_APPLICABLE,
|
|
268
|
+
error=str(exc),
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
continue
|
|
272
|
+
|
|
273
|
+
return result
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""memtrust's episode-level temporal-leak detection eval for self-hosted
|
|
2
|
+
graphiti-core's FalkorDB driver.
|
|
3
|
+
|
|
4
|
+
Motivating case: getzep/graphiti#1625 (contributor pcy06, open as of this
|
|
5
|
+
build). FalkorDB's Cypher query for
|
|
6
|
+
`EpisodeNodeOperations.retrieve_episodes(reference_time=...)` intends to
|
|
7
|
+
filter episodes with `e.valid_at <= $reference_time`, but FalkorDB can
|
|
8
|
+
return rows for which that same expression evaluates `False` when
|
|
9
|
+
projected as a result column -- a future-dated episode can leak into a
|
|
10
|
+
point-in-time query that is documented to return only past-or-present
|
|
11
|
+
episodes. pcy06's own filed reproduction (`gh issue view 1625 --repo
|
|
12
|
+
getzep/graphiti`) demonstrates this concretely: a `valid_at=2024-03-01`
|
|
13
|
+
episode returned for a `reference_time=2024-02-01` query, with no
|
|
14
|
+
exception raised -- the query simply returns rows outside the requested
|
|
15
|
+
temporal boundary.
|
|
16
|
+
|
|
17
|
+
This is a structurally distinct failure class from
|
|
18
|
+
`ConflictSignal.EDGE_INTEGRITY_VIOLATION`/`FLAGGED`'s existing
|
|
19
|
+
`invalid_at`-based temporal handling in `evals/contradiction.py`:
|
|
20
|
+
episodes (`EpisodicNode`) and edges (`EntityEdge`) are different
|
|
21
|
+
graphiti-core node types with independent temporal-integrity properties.
|
|
22
|
+
An edge's `invalid_at` marks bi-temporal invalidation of a *fact*;
|
|
23
|
+
an episode's `valid_at` marks when the *source document* was created, and
|
|
24
|
+
`retrieve_episodes()`'s point-in-time contract is about the latter, not
|
|
25
|
+
the former. Nothing in `contradiction.py`'s existing classification logic
|
|
26
|
+
observes episodes at all -- `ZepGraphitiSelfHostedAdapter.query()` calls
|
|
27
|
+
`Graphiti.search()`, which returns only edges. This eval calls the new
|
|
28
|
+
`ZepGraphitiSelfHostedAdapter.retrieve_episodes()` primitive instead,
|
|
29
|
+
which reaches graphiti-core's driver-level `EpisodeNodeOperations`
|
|
30
|
+
directly, bypassing `search()`/`query()` entirely -- see that adapter
|
|
31
|
+
module's docstring for the real, source-confirmed method signature.
|
|
32
|
+
|
|
33
|
+
Honest scope: this is detection, not resolution. The bug this eval
|
|
34
|
+
classifies lives entirely inside graphiti-core's FalkorDB driver's Cypher
|
|
35
|
+
query construction -- pcy06 already proposed the real upstream fix
|
|
36
|
+
(project the temporal comparison first, then filter on that boolean).
|
|
37
|
+
memtrust's role is surfacing whether a given self-hosted deployment's
|
|
38
|
+
`retrieve_episodes()` call actually exhibits the leak, never fixing
|
|
39
|
+
graphiti-core itself. Like every other eval in this package that has never
|
|
40
|
+
been run against a live backend (see
|
|
41
|
+
`zep_graphiti_selfhosted_adapter.py`'s own "What this adapter does NOT
|
|
42
|
+
prove" section), this eval's own test suite only proves the
|
|
43
|
+
*classification logic* is correct against a fake driver double that
|
|
44
|
+
reproduces the exact reported shape -- it has not been run against a live
|
|
45
|
+
FalkorDB instance in this environment.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
from dataclasses import dataclass, field
|
|
51
|
+
from datetime import datetime
|
|
52
|
+
from enum import StrEnum
|
|
53
|
+
from typing import Any
|
|
54
|
+
|
|
55
|
+
from memtrust.adapters.base import BackendAPIError
|
|
56
|
+
from memtrust.adapters.zep_graphiti_selfhosted_adapter import ZepGraphitiSelfHostedAdapter
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class EpisodeTemporalSignal(StrEnum):
|
|
60
|
+
"""WHETHER a `retrieve_episodes(reference_time=...)` call returned any
|
|
61
|
+
episode whose `valid_at` is after `reference_time` -- distinct from
|
|
62
|
+
`ConflictSignal`'s edge-level `invalid_at` handling in
|
|
63
|
+
`evals/contradiction.py` (see module docstring above for why episodes
|
|
64
|
+
and edges need independent temporal-integrity taxonomies). Defined
|
|
65
|
+
locally in this eval module rather than added to `base.py`, following
|
|
66
|
+
the precedent `evals/scale_stress.py`'s own local `ScaleSignal` enum
|
|
67
|
+
already establishes for a capability that is not part of every
|
|
68
|
+
adapter's shared `MemoryBackendAdapter` interface.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
NO_LEAK = "no_leak"
|
|
72
|
+
"""Every returned episode's `valid_at` is less than or equal to
|
|
73
|
+
`reference_time` -- the documented, correct point-in-time contract."""
|
|
74
|
+
|
|
75
|
+
TEMPORAL_LEAK = "temporal_leak"
|
|
76
|
+
"""At least one returned episode's `valid_at` is strictly after
|
|
77
|
+
`reference_time` -- the exact getzep/graphiti#1625 shape: a
|
|
78
|
+
point-in-time query leaked a future-dated episode into its result set
|
|
79
|
+
with no exception raised."""
|
|
80
|
+
|
|
81
|
+
NOT_APPLICABLE = "not_applicable"
|
|
82
|
+
"""The `retrieve_episodes()` call itself failed (see
|
|
83
|
+
`ZepGraphitiSelfHostedAdapter.retrieve_episodes()` -- raised as
|
|
84
|
+
`BackendAPIError`, e.g. because this driver has no
|
|
85
|
+
`episode_node_ops` surface at all), so there is nothing to classify.
|
|
86
|
+
Recorded explicitly rather than silently treated as NO_LEAK, same
|
|
87
|
+
"never let a failed call read as a passing result" convention every
|
|
88
|
+
other eval in this package follows."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class EpisodeTemporalLeakResult:
|
|
93
|
+
backend_name: str
|
|
94
|
+
reference_time: datetime
|
|
95
|
+
signal: EpisodeTemporalSignal
|
|
96
|
+
leaked_episode_names: list[str] = field(default_factory=list)
|
|
97
|
+
"""Name (falling back to uuid, then the literal string "unknown") of
|
|
98
|
+
every episode this eval found with `valid_at > reference_time`. Empty
|
|
99
|
+
for NO_LEAK/NOT_APPLICABLE."""
|
|
100
|
+
total_episodes_returned: int = 0
|
|
101
|
+
error: str | None = None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def classify_episode_temporal_leak(
|
|
105
|
+
episodes: list[dict[str, Any]], reference_time: datetime
|
|
106
|
+
) -> tuple[EpisodeTemporalSignal, list[str]]:
|
|
107
|
+
"""Classify a batch of episode dicts (the shape
|
|
108
|
+
`ZepGraphitiSelfHostedAdapter.retrieve_episodes()` returns -- each a
|
|
109
|
+
plain dict via `_to_plain_dict()`, carrying at least `valid_at` and
|
|
110
|
+
`name`/`uuid`) against `reference_time`.
|
|
111
|
+
|
|
112
|
+
Returns (signal, leaked_episode_names). An episode is only ever
|
|
113
|
+
counted as leaked if its `valid_at` is present and strictly after
|
|
114
|
+
`reference_time` -- a missing or unparseable `valid_at` is skipped
|
|
115
|
+
(not counted either way), since this eval's job is detecting a
|
|
116
|
+
confirmed leak, not penalizing a driver for reporting a field this
|
|
117
|
+
adapter doesn't recognize the shape of.
|
|
118
|
+
|
|
119
|
+
`valid_at` may arrive as a real `datetime` (test doubles / a
|
|
120
|
+
`model_dump()`-free double) or as an ISO-8601 string (the shape
|
|
121
|
+
`_to_plain_dict()`'s `model_dump()` path produces for a real,
|
|
122
|
+
installed `EpisodicNode` -- pydantic serializes `datetime` fields to
|
|
123
|
+
ISO strings by default). Both are handled; a naive-vs-aware mismatch
|
|
124
|
+
when comparing against `reference_time` is deliberately NOT
|
|
125
|
+
normalized here -- see `run_episode_temporal_leak_eval()`'s docstring
|
|
126
|
+
for why callers must pass a timezone-aware `reference_time` when
|
|
127
|
+
comparing against a real graphiti-core deployment.
|
|
128
|
+
"""
|
|
129
|
+
leaked: list[str] = []
|
|
130
|
+
for episode in episodes:
|
|
131
|
+
valid_at = episode.get("valid_at")
|
|
132
|
+
if isinstance(valid_at, str):
|
|
133
|
+
try:
|
|
134
|
+
valid_at = datetime.fromisoformat(valid_at)
|
|
135
|
+
except ValueError:
|
|
136
|
+
continue
|
|
137
|
+
if not isinstance(valid_at, datetime):
|
|
138
|
+
continue
|
|
139
|
+
if valid_at > reference_time:
|
|
140
|
+
name = episode.get("name") or episode.get("uuid") or "unknown"
|
|
141
|
+
leaked.append(str(name))
|
|
142
|
+
signal = EpisodeTemporalSignal.TEMPORAL_LEAK if leaked else EpisodeTemporalSignal.NO_LEAK
|
|
143
|
+
return signal, leaked
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def run_episode_temporal_leak_eval(
|
|
147
|
+
adapter: ZepGraphitiSelfHostedAdapter,
|
|
148
|
+
reference_time: datetime,
|
|
149
|
+
group_ids: list[str] | None = None,
|
|
150
|
+
last_n: int = 10,
|
|
151
|
+
) -> EpisodeTemporalLeakResult:
|
|
152
|
+
"""Call `adapter.retrieve_episodes(reference_time, group_ids, last_n)`
|
|
153
|
+
and classify the result via `classify_episode_temporal_leak()` above.
|
|
154
|
+
|
|
155
|
+
`reference_time` should be timezone-aware when run against a real
|
|
156
|
+
graphiti-core deployment -- confirmed via
|
|
157
|
+
`zep_graphiti_selfhosted_adapter.py`'s own module docstring and
|
|
158
|
+
`store()`'s `datetime.now(UTC)` convention, real `EpisodicNode.valid_at`
|
|
159
|
+
values are timezone-aware. A naive `reference_time` compared against an
|
|
160
|
+
aware `valid_at` raises `TypeError` from Python's own datetime
|
|
161
|
+
comparison, which this function does not catch -- the same "let a
|
|
162
|
+
genuine caller error surface, don't silently swallow it" principle
|
|
163
|
+
`verify_store()` documents elsewhere in this package.
|
|
164
|
+
"""
|
|
165
|
+
try:
|
|
166
|
+
episodes = adapter.retrieve_episodes(reference_time, group_ids=group_ids, last_n=last_n)
|
|
167
|
+
except BackendAPIError as exc:
|
|
168
|
+
return EpisodeTemporalLeakResult(
|
|
169
|
+
backend_name=adapter.name,
|
|
170
|
+
reference_time=reference_time,
|
|
171
|
+
signal=EpisodeTemporalSignal.NOT_APPLICABLE,
|
|
172
|
+
error=str(exc),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
signal, leaked = classify_episode_temporal_leak(episodes, reference_time)
|
|
176
|
+
return EpisodeTemporalLeakResult(
|
|
177
|
+
backend_name=adapter.name,
|
|
178
|
+
reference_time=reference_time,
|
|
179
|
+
signal=signal,
|
|
180
|
+
leaked_episode_names=leaked,
|
|
181
|
+
total_episodes_returned=len(episodes),
|
|
182
|
+
)
|