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,240 @@
|
|
|
1
|
+
"""MemTrust's stats/dashboard-accuracy eval.
|
|
2
|
+
|
|
3
|
+
Every other eval in this package treats a backend's own store()/query()
|
|
4
|
+
responses as the only source of truth about what is actually stored (with
|
|
5
|
+
appropriate independent cross-checks -- see crash_recovery.py's
|
|
6
|
+
raw_store_contains(), resource_sync_safety.py's list_resource_paths()).
|
|
7
|
+
None of them look at a backend's *self-reported summary statistics* at
|
|
8
|
+
all. This eval closes that specific gap: it checks whether a backend's own
|
|
9
|
+
"how many memories do you have" endpoint agrees with an independently
|
|
10
|
+
verified count of what is actually retrievable.
|
|
11
|
+
|
|
12
|
+
Motivating case: volcengine/OpenViking#1255 (contributor SeeYangZhi).
|
|
13
|
+
`GET /api/v1/stats/memories` returned an all-zero count
|
|
14
|
+
(`{"total_memories": 0, "by_category": {...all zero...}, ...}`) on a fresh
|
|
15
|
+
instance immediately after session-commit-triggered memory extraction had
|
|
16
|
+
already completed -- confirmed still present via two independent
|
|
17
|
+
observations quoted in the bug report itself: Docker logs showing
|
|
18
|
+
"Extracted 10 candidate memories" / "Created memory file: ...", and a
|
|
19
|
+
`POST /v1/search/find` call that successfully returned those same memory
|
|
20
|
+
files with their extracted abstracts. The reporter's own root-cause read:
|
|
21
|
+
the stats endpoint reads from a dedicated stats/metadata index populated
|
|
22
|
+
only by a synchronous "content/write" code path, never by the async
|
|
23
|
+
extraction pipeline that actually wrote the memories -- so the counter and
|
|
24
|
+
the real data structurally diverge, with no exception anywhere to signal
|
|
25
|
+
the mismatch.
|
|
26
|
+
|
|
27
|
+
This eval reproduces that shape generically: store N records, independently
|
|
28
|
+
verify how many of them are actually retrievable via the adapter's normal
|
|
29
|
+
query() path (the same surface #1255's own report used `/v1/search/find`
|
|
30
|
+
for), then call get_stats() and compare its self-reported count against
|
|
31
|
+
that independently verified number.
|
|
32
|
+
|
|
33
|
+
**Why a new StatsSignal enum rather than a new ConflictSignal member.**
|
|
34
|
+
`ConflictSignal.EMPTY_OR_LOST` (adapters/base.py) fires when a single
|
|
35
|
+
query() call succeeds but returns zero records -- it is about *retrieval*
|
|
36
|
+
silently coming back empty. This eval's failure mode is different in kind:
|
|
37
|
+
query() itself may work perfectly (as it did in #1255's own report --
|
|
38
|
+
`/v1/search/find` DID return the memories), while a *separate,
|
|
39
|
+
independently-maintained* stats/dashboard endpoint reports a number that
|
|
40
|
+
disagrees with that working retrieval path. Folding this into
|
|
41
|
+
ConflictSignal would blur "retrieval failed" and "retrieval works, but a
|
|
42
|
+
different, unrelated counter is wrong" into the same signal -- the same
|
|
43
|
+
"a cross-call/cross-endpoint comparison is a structurally different kind
|
|
44
|
+
of classification than a single QueryResult" reasoning that already
|
|
45
|
+
justifies CrashRecoverySignal, ResourceSyncSignal, and RankingSignal each
|
|
46
|
+
being their own enum (see their docstrings in adapters/base.py and
|
|
47
|
+
evals/crash_recovery.py).
|
|
48
|
+
|
|
49
|
+
**Honest scope.** Like every other eval added without a live, configured
|
|
50
|
+
vendor instance available (see evals/scale_stress.py, evals/
|
|
51
|
+
embedding_drift.py, docs/methodology.md), this eval's classification logic
|
|
52
|
+
is proven correct against purpose-built fake adapters in
|
|
53
|
+
tests/test_evals.py -- one reproducing #1255's exact "always reports zero
|
|
54
|
+
regardless of real stored count" shape, one reporting an accurate count as
|
|
55
|
+
the negative control. It has not been run against a live OpenViking
|
|
56
|
+
instance; running it there requires OPENVIKING_API_KEY (or a self-hosted
|
|
57
|
+
OPENVIKING_BASE_URL) to be configured, which is not the case in the
|
|
58
|
+
environment this eval was built in.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
from __future__ import annotations
|
|
62
|
+
|
|
63
|
+
from dataclasses import dataclass
|
|
64
|
+
from enum import StrEnum
|
|
65
|
+
from uuid import uuid4
|
|
66
|
+
|
|
67
|
+
from memtrust.adapters.base import BackendAPIError, MemoryBackendAdapter
|
|
68
|
+
|
|
69
|
+
#: Small by default (matches every other bundled fixture's single-digit
|
|
70
|
+
#: scale, see scale_stress.py's DEFAULT_N_RECORDS docstring for why that
|
|
71
|
+
#: is a deliberate, fast-in-CI choice) -- this eval only needs enough
|
|
72
|
+
#: records to tell "the endpoint reports a real number" apart from "the
|
|
73
|
+
#: endpoint always reports zero/undercounts," not to exercise scale.
|
|
74
|
+
DEFAULT_N_RECORDS = 5
|
|
75
|
+
DEFAULT_SESSION_ID = "stats-accuracy-session"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class StatsSignal(StrEnum):
|
|
79
|
+
"""Whether a backend's self-reported stats/dashboard count agrees with
|
|
80
|
+
an independently verified count of what is actually retrievable.
|
|
81
|
+
|
|
82
|
+
Defined locally in this module rather than in adapters/base.py,
|
|
83
|
+
following the same precedent evals/scale_stress.py's ScaleSignal and
|
|
84
|
+
evals/resource_sync_safety.py's ResourceSyncSignal already set: this is
|
|
85
|
+
a harness-computed classification derived from ground truth (records
|
|
86
|
+
this eval itself stored and independently re-verified), not a signal
|
|
87
|
+
any adapter self-reports.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
STATS_MATCH = "stats_match"
|
|
91
|
+
"""get_stats()'s reported count is greater than or equal to the
|
|
92
|
+
independently verified retrievable count. The good outcome -- the
|
|
93
|
+
stats endpoint is not undercounting relative to what this eval could
|
|
94
|
+
confirm is actually there. (A report strictly higher than verified is
|
|
95
|
+
still STATS_MATCH, not a separate "overcounted" signal -- this eval
|
|
96
|
+
exists to catch #1255's undercounting shape specifically; a backend
|
|
97
|
+
reporting MORE than this eval's own narrow verification found has no
|
|
98
|
+
known real-world motivating bug case to justify inventing a
|
|
99
|
+
classification for it, and would likely just mean the backend's own
|
|
100
|
+
count includes records this eval's own query-based verification
|
|
101
|
+
under-samples for unrelated reasons.)"""
|
|
102
|
+
|
|
103
|
+
STATS_UNDERCOUNTED = "stats_undercounted"
|
|
104
|
+
"""get_stats()'s reported count is strictly less than the independently
|
|
105
|
+
verified retrievable count -- the exact volcengine/OpenViking#1255
|
|
106
|
+
shape: the memories are demonstrably there (this eval could retrieve
|
|
107
|
+
them via the normal query() path) but the dedicated stats/dashboard
|
|
108
|
+
endpoint reports fewer of them, including the reported #1255 case of
|
|
109
|
+
reporting zero when the real count is nonzero."""
|
|
110
|
+
|
|
111
|
+
NOT_APPLICABLE = "not_applicable"
|
|
112
|
+
"""Either the adapter has no stats endpoint at all
|
|
113
|
+
(MemoryBackendAdapter.supports_stats is False -- the eval is skipped,
|
|
114
|
+
not run), or nothing was successfully stored/verified in the first
|
|
115
|
+
place, so there is no independently verified count to compare
|
|
116
|
+
get_stats() against."""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class StatsAccuracyEvalResult:
|
|
121
|
+
backend_name: str
|
|
122
|
+
n_records_requested: int
|
|
123
|
+
records_stored: int = 0
|
|
124
|
+
verified_count: int | None = None
|
|
125
|
+
"""Independently verified count of the stored records that are
|
|
126
|
+
actually retrievable via adapter.query() -- the ground truth this
|
|
127
|
+
eval compares get_stats() against. `None` if nothing was ever stored
|
|
128
|
+
successfully."""
|
|
129
|
+
reported_count: int | None = None
|
|
130
|
+
"""adapter.get_stats().total_memories, verbatim -- the number under
|
|
131
|
+
test. `None` if get_stats() reported no usable count."""
|
|
132
|
+
signal: StatsSignal = StatsSignal.NOT_APPLICABLE
|
|
133
|
+
skipped: bool = False
|
|
134
|
+
skip_reason: str | None = None
|
|
135
|
+
error: str | None = None
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def undercount_gap(self) -> int | None:
|
|
139
|
+
"""verified_count - reported_count, i.e. how many retrievable
|
|
140
|
+
records the stats endpoint failed to count. `None` unless both
|
|
141
|
+
counts are known. Never negative in practice under STATS_MATCH
|
|
142
|
+
(see StatsSignal.STATS_MATCH's docstring on why an over-report
|
|
143
|
+
is not itself flagged), but not clamped here -- this is a raw
|
|
144
|
+
diagnostic value, not the classification itself."""
|
|
145
|
+
if self.verified_count is None or self.reported_count is None:
|
|
146
|
+
return None
|
|
147
|
+
return self.verified_count - self.reported_count
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def classify_stats_accuracy(verified_count: int | None, reported_count: int | None) -> StatsSignal:
|
|
151
|
+
"""Classify one run's outcome from its two independently obtained
|
|
152
|
+
counts. Never trusts get_stats() alone -- the same "never trust a
|
|
153
|
+
single response" principle every eval in this package follows (see
|
|
154
|
+
evals/crash_recovery.py's classify_crash_recovery_case and
|
|
155
|
+
evals/resource_sync_safety.py's classify_resource_sync_file).
|
|
156
|
+
"""
|
|
157
|
+
if verified_count is None or reported_count is None:
|
|
158
|
+
return StatsSignal.NOT_APPLICABLE
|
|
159
|
+
if reported_count < verified_count:
|
|
160
|
+
return StatsSignal.STATS_UNDERCOUNTED
|
|
161
|
+
return StatsSignal.STATS_MATCH
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _verify_stored_count(
|
|
165
|
+
adapter: MemoryBackendAdapter, session_id: str, markers: list[str], top_k: int
|
|
166
|
+
) -> int:
|
|
167
|
+
"""Independently confirm how many of `markers` are actually
|
|
168
|
+
retrievable via the adapter's normal query() path -- the same surface
|
|
169
|
+
#1255's own bug report used (`POST /v1/search/find`) to prove the
|
|
170
|
+
memories existed despite the stats endpoint reporting zero.
|
|
171
|
+
|
|
172
|
+
Never trusts "query() didn't raise" as proof a record came back --
|
|
173
|
+
each marker must actually appear in the joined content of the
|
|
174
|
+
response, the same rule evals/scale_stress.py's _query_needle applies.
|
|
175
|
+
"""
|
|
176
|
+
found = 0
|
|
177
|
+
for marker in markers:
|
|
178
|
+
try:
|
|
179
|
+
query_result = adapter.query(session_id, marker, top_k=top_k)
|
|
180
|
+
except BackendAPIError:
|
|
181
|
+
continue
|
|
182
|
+
if any(marker in record.content for record in query_result.records):
|
|
183
|
+
found += 1
|
|
184
|
+
return found
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def run_stats_accuracy_eval(
|
|
188
|
+
adapter: MemoryBackendAdapter,
|
|
189
|
+
n_records: int = DEFAULT_N_RECORDS,
|
|
190
|
+
session_id: str = DEFAULT_SESSION_ID,
|
|
191
|
+
top_k: int = 5,
|
|
192
|
+
) -> StatsAccuracyEvalResult:
|
|
193
|
+
"""Store `n_records` uniquely-markered records, independently verify
|
|
194
|
+
how many are actually retrievable via query(), then compare that
|
|
195
|
+
ground truth against adapter.get_stats()'s self-reported count.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
adapter: the backend under test.
|
|
199
|
+
n_records: how many synthetic records to store and verify.
|
|
200
|
+
session_id: session/scope every record is stored under.
|
|
201
|
+
top_k: top_k passed to each verification query() call.
|
|
202
|
+
"""
|
|
203
|
+
result = StatsAccuracyEvalResult(backend_name=adapter.name, n_records_requested=n_records)
|
|
204
|
+
|
|
205
|
+
if not adapter.supports_stats:
|
|
206
|
+
result.skipped = True
|
|
207
|
+
result.skip_reason = (
|
|
208
|
+
f"{adapter.name} does not implement get_stats() (supports_stats=False) -- "
|
|
209
|
+
"skipped, not run. See adapters/base.py's MemoryBackendAdapter.get_stats() "
|
|
210
|
+
"and evals/stats_accuracy.py's module docstring."
|
|
211
|
+
)
|
|
212
|
+
return result
|
|
213
|
+
|
|
214
|
+
markers = [f"stats-accuracy-marker-{i}-{uuid4().hex[:8]}" for i in range(n_records)]
|
|
215
|
+
stored_markers: list[str] = []
|
|
216
|
+
for index, marker in enumerate(markers):
|
|
217
|
+
try:
|
|
218
|
+
adapter.store(session_id, f"Fact #{index}: unique marker {marker}.")
|
|
219
|
+
stored_markers.append(marker)
|
|
220
|
+
result.records_stored += 1
|
|
221
|
+
except BackendAPIError as exc:
|
|
222
|
+
if result.error is None:
|
|
223
|
+
result.error = f"store failed for marker {marker}: {exc}"
|
|
224
|
+
|
|
225
|
+
if not stored_markers:
|
|
226
|
+
result.signal = StatsSignal.NOT_APPLICABLE
|
|
227
|
+
return result
|
|
228
|
+
|
|
229
|
+
result.verified_count = _verify_stored_count(adapter, session_id, stored_markers, top_k)
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
stats = adapter.get_stats(session_id)
|
|
233
|
+
except BackendAPIError as exc:
|
|
234
|
+
result.error = f"get_stats failed: {exc}"
|
|
235
|
+
result.signal = StatsSignal.NOT_APPLICABLE
|
|
236
|
+
return result
|
|
237
|
+
|
|
238
|
+
result.reported_count = stats.total_memories
|
|
239
|
+
result.signal = classify_stats_accuracy(result.verified_count, result.reported_count)
|
|
240
|
+
return result
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""memtrust's temporal-KG as_of point-in-time boundary eval for
|
|
2
|
+
MemPalaceAdapter's `kg_add()`/`kg_invalidate()`/`kg_query()` primitives.
|
|
3
|
+
|
|
4
|
+
Motivating case: MemPalace/mempalace#1913, fixed by merged PR#1914
|
|
5
|
+
(contributor ggettert; `gh pr view 1914 --repo MemPalace/mempalace
|
|
6
|
+
--comments`). `mempalace/knowledge_graph.py`'s `_temporal_filter_sql` used a
|
|
7
|
+
*closed* interval on both ends (`valid_from <= as_of AND valid_to >=
|
|
8
|
+
as_of`), so a fact whose `valid_to` equals the query's `as_of` instant still
|
|
9
|
+
matched. When a caller hand-rolls a fact change as `kg_invalidate(ended=T)`
|
|
10
|
+
immediately followed by `kg_add(valid_from=T)` at the identical boundary
|
|
11
|
+
instant `T` -- the exact pattern MemPalace's own pre-fix `PALACE_PROTOCOL`
|
|
12
|
+
wake-up guidance told every agent to do -- an `as_of=T` query matched BOTH
|
|
13
|
+
the old, just-ended fact and the new, just-started fact simultaneously, so a
|
|
14
|
+
single-valued predicate (e.g. "uses_model") reported two contradictory
|
|
15
|
+
values at once with no error or warning. PR#1914's real fix switched the
|
|
16
|
+
upper-bound comparison to strict (`valid_to > as_of`), i.e. a half-open
|
|
17
|
+
interval `[valid_from, valid_to)`: a fact that ended exactly at the query
|
|
18
|
+
instant no longer matches, so only its successor does. PR#1914 also added a
|
|
19
|
+
new `supersede()` primitive (`mempalace_kg_supersede`) as the *preferred*
|
|
20
|
+
way to make this kind of change atomically -- this eval deliberately does
|
|
21
|
+
NOT use it, because `MemPalaceAdapter` does not wire `tool_kg_supersede` at
|
|
22
|
+
all (only `tool_kg_add`/`tool_kg_invalidate`/`tool_kg_query` are confirmed
|
|
23
|
+
real and wired -- see mempalace_adapter.py's module docstring). This eval
|
|
24
|
+
exercises exactly the failure-prone hand-rolled pattern PR#1914's own
|
|
25
|
+
PALACE_PROTOCOL fix warns callers away from, because that pattern is what
|
|
26
|
+
`MemPalaceAdapter`'s current, narrower capability surface actually produces
|
|
27
|
+
-- adding `kg_supersede` support to this adapter is a separate, later
|
|
28
|
+
change, out of scope here.
|
|
29
|
+
|
|
30
|
+
Scope note -- what this eval is and is not. This is a genuinely scoped
|
|
31
|
+
fix for ONE of the two capabilities a prior backlog item bundled together
|
|
32
|
+
("Add MemPalace temporal-KG as_of point-in-time queries" +
|
|
33
|
+
"drawer neighbor-expansion scoping"). This eval covers only the temporal-KG
|
|
34
|
+
as_of half. Drawer neighbor-expansion/parent_drawer_id-leak scoping is a
|
|
35
|
+
separate, larger, still-deferred capability this eval does not touch.
|
|
36
|
+
|
|
37
|
+
Honest scope on live verification (same convention every eval module in
|
|
38
|
+
this package states plainly, see docs/methodology.md): the real `mempalace`
|
|
39
|
+
PyPI package is not installed in this build environment, and PR#1914 itself
|
|
40
|
+
had not shipped in a released `mempalace` version as of this adapter's
|
|
41
|
+
"live-verified at version 3.5.0" build (the fix lands under CHANGELOG.md's
|
|
42
|
+
`## [Unreleased]` section, above the `## [3.5.0]` entry -- confirmed by
|
|
43
|
+
reading the merged PR's own diff). This eval's own test suite
|
|
44
|
+
(tests/test_temporal_kg_boundary.py) therefore proves the *classification
|
|
45
|
+
logic* is correct against two from-scratch fake `_MCPToolsProtocol`
|
|
46
|
+
implementations that reproduce the real, confirmed pre-#1914 (closed-
|
|
47
|
+
interval) and post-#1914 (half-open) `_temporal_filter_sql` comparison
|
|
48
|
+
exactly -- not against a live MemPalace instance running either version.
|
|
49
|
+
|
|
50
|
+
Design:
|
|
51
|
+
|
|
52
|
+
1. `kg_add(subject, predicate, old_object, valid_from=<seed>)` -- seed the
|
|
53
|
+
fact this case will supersede.
|
|
54
|
+
2. `kg_invalidate(subject, predicate, old_object, ended=<boundary>)` --
|
|
55
|
+
end it at a known instant.
|
|
56
|
+
3. `kg_add(subject, predicate, new_object, valid_from=<boundary>)` -- the
|
|
57
|
+
hand-rolled handover, sharing the exact same boundary instant as (2).
|
|
58
|
+
4. `kg_query(subject, as_of=<boundary>, direction="outgoing")` -- query at
|
|
59
|
+
the exact shared instant.
|
|
60
|
+
5. Classify the response two ways: `MemPalaceAdapter.kg_query()`'s own
|
|
61
|
+
self-reported `boundary_signal` (see adapters/base.py's
|
|
62
|
+
`TemporalBoundarySignal` and mempalace_adapter.py's
|
|
63
|
+
`_classify_boundary_signal`), and this eval's own, independently
|
|
64
|
+
written `classify_temporal_kg_boundary_case()` below, which re-derives
|
|
65
|
+
the verdict directly from the raw `KGFact` list `kg_query()` returned
|
|
66
|
+
(how many distinct objects exist for the queried `(subject,
|
|
67
|
+
predicate)`). The eval's own classification -- never the adapter's
|
|
68
|
+
self-report -- is what `TemporalKGBoundaryCaseResult.signal` records;
|
|
69
|
+
`adapter_reported_signal` is kept only for comparison/transparency,
|
|
70
|
+
the same "claim, not proof" convention evals/ranking_quality.py's
|
|
71
|
+
`classify_ranking_case()` already establishes for
|
|
72
|
+
`RankingSignal.SIGNAL_DRIVEN`.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
from __future__ import annotations
|
|
76
|
+
|
|
77
|
+
import uuid
|
|
78
|
+
from dataclasses import dataclass, field
|
|
79
|
+
|
|
80
|
+
from memtrust.adapters.base import BackendAPIError, TemporalBoundarySignal
|
|
81
|
+
from memtrust.adapters.mempalace_adapter import KGQueryResult, MemPalaceAdapter
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class TemporalKGBoundaryCase:
|
|
86
|
+
case_id: str
|
|
87
|
+
subject: str
|
|
88
|
+
predicate: str
|
|
89
|
+
old_object: str
|
|
90
|
+
"""Value of the fact before the boundary."""
|
|
91
|
+
new_object: str
|
|
92
|
+
"""Value of the fact from the boundary onward -- the hand-rolled
|
|
93
|
+
successor, added via kg_add(valid_from=boundary), sharing the exact
|
|
94
|
+
same instant as the kg_invalidate(ended=boundary) call that closed
|
|
95
|
+
old_object."""
|
|
96
|
+
seed_valid_from: str
|
|
97
|
+
"""When old_object's fact starts being true, well before `boundary`."""
|
|
98
|
+
boundary: str
|
|
99
|
+
"""The single shared instant used for BOTH kg_invalidate(ended=...)
|
|
100
|
+
and the successor kg_add(valid_from=...), and then queried via
|
|
101
|
+
kg_query(as_of=...) -- the exact MemPalace/mempalace#1913 shape."""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def default_cases(subject_prefix: str | None = None) -> list[TemporalKGBoundaryCase]:
|
|
105
|
+
"""One case exercising the exact PR#1914 boundary shape. `subject` is
|
|
106
|
+
randomized by default (uuid4-suffixed) -- see mempalace_adapter.py's
|
|
107
|
+
module docstring "KNOWLEDGE-GRAPH STORAGE IGNORES MEMPALACE_PALACE_PATH"
|
|
108
|
+
section: the real KG store is one fixed, environment-global file with
|
|
109
|
+
no per-run isolation, so a fixed literal subject would make this case's
|
|
110
|
+
assertions invalid against any prior run's leftover facts (the same
|
|
111
|
+
randomization tests/test_adapters.py's own
|
|
112
|
+
`test_real_mempalace_kg_add_invalidate_query_round_trip` already uses).
|
|
113
|
+
"""
|
|
114
|
+
subject = subject_prefix or f"memtrust-temporal-kg-boundary-{uuid.uuid4().hex}"
|
|
115
|
+
return [
|
|
116
|
+
TemporalKGBoundaryCase(
|
|
117
|
+
case_id="mt-tkb-001",
|
|
118
|
+
subject=subject,
|
|
119
|
+
predicate="uses_model",
|
|
120
|
+
old_object="claude-opus-4-7",
|
|
121
|
+
new_object="claude-opus-4-8",
|
|
122
|
+
seed_valid_from="2026-05-01T00:00:00Z",
|
|
123
|
+
boundary="2026-06-02T12:00:00Z",
|
|
124
|
+
),
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class TemporalKGBoundaryCaseResult:
|
|
130
|
+
case: TemporalKGBoundaryCase
|
|
131
|
+
signal: TemporalBoundarySignal
|
|
132
|
+
"""This eval's own, independently-derived verdict -- see
|
|
133
|
+
`classify_temporal_kg_boundary_case()` below. Never copied straight
|
|
134
|
+
from the adapter's self-report."""
|
|
135
|
+
adapter_reported_signal: TemporalBoundarySignal | None
|
|
136
|
+
"""`KGQueryResult.boundary_signal` as MemPalaceAdapter.kg_query()
|
|
137
|
+
itself reported it, kept for comparison/transparency only. `None` when
|
|
138
|
+
the call sequence failed before a query response ever came back."""
|
|
139
|
+
objects_at_boundary: list[str]
|
|
140
|
+
"""Every distinct `object` value returned for `(case.subject,
|
|
141
|
+
case.predicate)` at `case.boundary` -- the evidence `signal` was
|
|
142
|
+
computed from. Length 2+ is the literal double-count."""
|
|
143
|
+
error: str | None = None
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def self_report_agrees(self) -> bool:
|
|
147
|
+
return self.adapter_reported_signal == self.signal
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass
|
|
151
|
+
class TemporalKGBoundaryEvalResult:
|
|
152
|
+
backend_name: str
|
|
153
|
+
case_results: list[TemporalKGBoundaryCaseResult] = field(default_factory=list)
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def scored_cases(self) -> list[TemporalKGBoundaryCaseResult]:
|
|
157
|
+
return [r for r in self.case_results if r.error is None]
|
|
158
|
+
|
|
159
|
+
def _fraction(self, signal: TemporalBoundarySignal) -> float | None:
|
|
160
|
+
scored = self.scored_cases
|
|
161
|
+
if not scored:
|
|
162
|
+
return None
|
|
163
|
+
return sum(1 for r in scored if r.signal == signal) / len(scored)
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def double_count_rate(self) -> float | None:
|
|
167
|
+
"""Fraction of cases where an as_of query at the exact boundary
|
|
168
|
+
instant returned both the superseded fact and its successor -- the
|
|
169
|
+
headline metric this eval exists to surface, and the one that
|
|
170
|
+
would flag MemPalace/mempalace#1913's exact shape against a
|
|
171
|
+
pre-#1914 deployment."""
|
|
172
|
+
return self._fraction(TemporalBoundarySignal.DOUBLE_COUNT)
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def clean_rate(self) -> float | None:
|
|
176
|
+
return self._fraction(TemporalBoundarySignal.CLEAN)
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def not_applicable_rate(self) -> float | None:
|
|
180
|
+
return self._fraction(TemporalBoundarySignal.NOT_APPLICABLE)
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def self_report_agreement_rate(self) -> float | None:
|
|
184
|
+
"""Fraction of scored cases where MemPalaceAdapter.kg_query()'s own
|
|
185
|
+
self-reported boundary_signal matched this eval's independently
|
|
186
|
+
derived verdict -- a low rate here would mean the adapter's
|
|
187
|
+
self-classification (adapters/mempalace_adapter.py's
|
|
188
|
+
`_classify_boundary_signal`) itself has a bug, distinct from
|
|
189
|
+
whether the *backend* double-counts."""
|
|
190
|
+
scored = self.scored_cases
|
|
191
|
+
if not scored:
|
|
192
|
+
return None
|
|
193
|
+
return sum(1 for r in scored if r.self_report_agrees) / len(scored)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def classify_temporal_kg_boundary_case(
|
|
197
|
+
case: TemporalKGBoundaryCase, query_result: KGQueryResult
|
|
198
|
+
) -> tuple[TemporalBoundarySignal, list[str]]:
|
|
199
|
+
"""Independently classify one case's outcome straight from the raw
|
|
200
|
+
`KGFact` list `query_result.facts` carries -- deliberately NOT calling
|
|
201
|
+
or trusting `query_result.boundary_signal` (the adapter's own
|
|
202
|
+
self-report; see TemporalKGBoundarySignal in adapters/base.py for why
|
|
203
|
+
an eval must never treat a signal field as the final answer on its
|
|
204
|
+
own). Returns (signal, distinct_objects_returned).
|
|
205
|
+
|
|
206
|
+
Only facts matching this case's exact `(subject, predicate)` on the
|
|
207
|
+
`outgoing` direction are considered -- `kg_query()` was called with
|
|
208
|
+
`direction="outgoing"`, so every returned fact should already match,
|
|
209
|
+
but this filters defensively rather than assuming the vendor response
|
|
210
|
+
never carries an unrelated fact.
|
|
211
|
+
"""
|
|
212
|
+
matching = [
|
|
213
|
+
f
|
|
214
|
+
for f in query_result.facts
|
|
215
|
+
if f.direction == "outgoing" and f.subject == case.subject and f.predicate == case.predicate
|
|
216
|
+
]
|
|
217
|
+
objects = sorted({f.object for f in matching})
|
|
218
|
+
if not matching:
|
|
219
|
+
return TemporalBoundarySignal.NOT_APPLICABLE, objects
|
|
220
|
+
if len(objects) >= 2:
|
|
221
|
+
return TemporalBoundarySignal.DOUBLE_COUNT, objects
|
|
222
|
+
return TemporalBoundarySignal.CLEAN, objects
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def run_temporal_kg_boundary_eval(
|
|
226
|
+
adapter: MemPalaceAdapter,
|
|
227
|
+
cases: list[TemporalKGBoundaryCase] | None = None,
|
|
228
|
+
) -> TemporalKGBoundaryEvalResult:
|
|
229
|
+
resolved_cases = cases if cases is not None else default_cases()
|
|
230
|
+
result = TemporalKGBoundaryEvalResult(backend_name=adapter.name)
|
|
231
|
+
|
|
232
|
+
for case in resolved_cases:
|
|
233
|
+
try:
|
|
234
|
+
add_old = adapter.kg_add(
|
|
235
|
+
case.subject, case.predicate, case.old_object, valid_from=case.seed_valid_from
|
|
236
|
+
)
|
|
237
|
+
if not add_old.success:
|
|
238
|
+
raise BackendAPIError(
|
|
239
|
+
adapter.name, add_old.error or "kg_add(old_object) reported failure"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
invalidate = adapter.kg_invalidate(
|
|
243
|
+
case.subject, case.predicate, case.old_object, ended=case.boundary
|
|
244
|
+
)
|
|
245
|
+
if not invalidate.success:
|
|
246
|
+
raise BackendAPIError(
|
|
247
|
+
adapter.name, invalidate.error or "kg_invalidate() reported failure"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
add_new = adapter.kg_add(
|
|
251
|
+
case.subject, case.predicate, case.new_object, valid_from=case.boundary
|
|
252
|
+
)
|
|
253
|
+
if not add_new.success:
|
|
254
|
+
raise BackendAPIError(
|
|
255
|
+
adapter.name, add_new.error or "kg_add(new_object) reported failure"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
query_result = adapter.kg_query(case.subject, as_of=case.boundary, direction="outgoing")
|
|
259
|
+
except BackendAPIError as exc:
|
|
260
|
+
result.case_results.append(
|
|
261
|
+
TemporalKGBoundaryCaseResult(
|
|
262
|
+
case=case,
|
|
263
|
+
signal=TemporalBoundarySignal.NOT_APPLICABLE,
|
|
264
|
+
adapter_reported_signal=None,
|
|
265
|
+
objects_at_boundary=[],
|
|
266
|
+
error=str(exc),
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
continue
|
|
270
|
+
|
|
271
|
+
signal, objects = classify_temporal_kg_boundary_case(case, query_result)
|
|
272
|
+
result.case_results.append(
|
|
273
|
+
TemporalKGBoundaryCaseResult(
|
|
274
|
+
case=case,
|
|
275
|
+
signal=signal,
|
|
276
|
+
adapter_reported_signal=query_result.boundary_signal,
|
|
277
|
+
objects_at_boundary=objects,
|
|
278
|
+
)
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
return result
|