memtrust-cli 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. memtrust/__init__.py +11 -0
  2. memtrust/adapters/__init__.py +62 -0
  3. memtrust/adapters/base.py +2020 -0
  4. memtrust/adapters/mem0_adapter.py +456 -0
  5. memtrust/adapters/mem0_direct_adapter.py +1217 -0
  6. memtrust/adapters/mempalace_adapter.py +1166 -0
  7. memtrust/adapters/openviking_adapter.py +570 -0
  8. memtrust/adapters/zep_graphiti_adapter.py +181 -0
  9. memtrust/adapters/zep_graphiti_selfhosted_adapter.py +882 -0
  10. memtrust/cli.py +1201 -0
  11. memtrust/evals/__init__.py +5 -0
  12. memtrust/evals/compression.py +235 -0
  13. memtrust/evals/contradiction.py +451 -0
  14. memtrust/evals/crash_recovery.py +290 -0
  15. memtrust/evals/embedder_cost.py +163 -0
  16. memtrust/evals/embedding_drift.py +273 -0
  17. memtrust/evals/episode_temporal_leak.py +182 -0
  18. memtrust/evals/extraction_quality.py +373 -0
  19. memtrust/evals/filter_injection.py +371 -0
  20. memtrust/evals/language_degradation.py +189 -0
  21. memtrust/evals/lock_contention.py +263 -0
  22. memtrust/evals/locomo.py +392 -0
  23. memtrust/evals/longmemeval.py +249 -0
  24. memtrust/evals/mempalace_metadata_scale.py +494 -0
  25. memtrust/evals/migration_rollback.py +276 -0
  26. memtrust/evals/orphan_cleanup.py +235 -0
  27. memtrust/evals/ranking_quality.py +303 -0
  28. memtrust/evals/resource_sync_safety.py +336 -0
  29. memtrust/evals/result_consistency.py +239 -0
  30. memtrust/evals/scale_fixtures.py +189 -0
  31. memtrust/evals/scale_stress.py +502 -0
  32. memtrust/evals/stats_accuracy.py +240 -0
  33. memtrust/evals/temporal_kg_boundary.py +281 -0
  34. memtrust/receipt.py +318 -0
  35. memtrust/scoring/__init__.py +1 -0
  36. memtrust/scoring/cost_tracker.py +199 -0
  37. memtrust/scoring/llm_judge.py +161 -0
  38. memtrust_cli-0.3.0.dist-info/METADATA +624 -0
  39. memtrust_cli-0.3.0.dist-info/RECORD +42 -0
  40. memtrust_cli-0.3.0.dist-info/WHEEL +4 -0
  41. memtrust_cli-0.3.0.dist-info/entry_points.txt +2 -0
  42. memtrust_cli-0.3.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,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
+ )