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,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