superlocalmemory 3.4.19 → 3.4.21

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 (170) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +42 -34
  3. package/bin/slm +11 -0
  4. package/bin/slm.bat +12 -0
  5. package/package.json +4 -3
  6. package/pyproject.toml +3 -2
  7. package/scripts/build-slm-hook.ps1 +40 -0
  8. package/scripts/build-slm-hook.sh +45 -0
  9. package/scripts/build_entry.py +452 -0
  10. package/scripts/ci/stage5b_gate.sh +50 -0
  11. package/scripts/postinstall/validation.js +187 -0
  12. package/scripts/postinstall-interactive.js +756 -0
  13. package/scripts/postinstall_binary.js +287 -0
  14. package/scripts/release_manifest.py +273 -0
  15. package/scripts/slm-hook.spec +56 -0
  16. package/skills/slm-build-graph/SKILL.md +423 -0
  17. package/skills/slm-list-recent/SKILL.md +348 -0
  18. package/skills/slm-recall/SKILL.md +343 -0
  19. package/skills/slm-remember/SKILL.md +194 -0
  20. package/skills/slm-show-patterns/SKILL.md +224 -0
  21. package/skills/slm-status/SKILL.md +363 -0
  22. package/skills/slm-switch-profile/SKILL.md +442 -0
  23. package/src/superlocalmemory/cli/commands.py +219 -79
  24. package/src/superlocalmemory/cli/context_commands.py +192 -0
  25. package/src/superlocalmemory/cli/daemon.py +15 -1
  26. package/src/superlocalmemory/cli/db_migrate.py +80 -0
  27. package/src/superlocalmemory/cli/escape_hatch.py +220 -0
  28. package/src/superlocalmemory/cli/main.py +72 -1
  29. package/src/superlocalmemory/core/context_cache.py +397 -0
  30. package/src/superlocalmemory/core/engine.py +38 -2
  31. package/src/superlocalmemory/core/engine_wiring.py +1 -1
  32. package/src/superlocalmemory/core/ram_lock.py +111 -0
  33. package/src/superlocalmemory/core/recall_pipeline.py +433 -3
  34. package/src/superlocalmemory/core/recall_worker.py +8 -3
  35. package/src/superlocalmemory/core/security_primitives.py +635 -0
  36. package/src/superlocalmemory/core/shadow_router.py +319 -0
  37. package/src/superlocalmemory/core/slm_disabled.py +87 -0
  38. package/src/superlocalmemory/core/slmignore.py +125 -0
  39. package/src/superlocalmemory/core/topic_signature.py +143 -0
  40. package/src/superlocalmemory/core/worker_pool.py +14 -3
  41. package/src/superlocalmemory/encoding/cognitive_consolidator.py +2 -2
  42. package/src/superlocalmemory/evolution/budget.py +321 -0
  43. package/src/superlocalmemory/evolution/llm_dispatch.py +508 -0
  44. package/src/superlocalmemory/evolution/skill_evolver.py +144 -94
  45. package/src/superlocalmemory/hooks/_outcome_common.py +506 -0
  46. package/src/superlocalmemory/hooks/adapter_base.py +317 -0
  47. package/src/superlocalmemory/hooks/antigravity_adapter.py +192 -0
  48. package/src/superlocalmemory/hooks/claude_code_hooks.py +33 -1
  49. package/src/superlocalmemory/hooks/context_payload.py +312 -0
  50. package/src/superlocalmemory/hooks/copilot_adapter.py +154 -0
  51. package/src/superlocalmemory/hooks/cross_platform_connector.py +90 -0
  52. package/src/superlocalmemory/hooks/cursor_adapter.py +195 -0
  53. package/src/superlocalmemory/hooks/hook_handlers.py +109 -8
  54. package/src/superlocalmemory/hooks/ide_connector.py +25 -2
  55. package/src/superlocalmemory/hooks/post_tool_async_hook.py +165 -0
  56. package/src/superlocalmemory/hooks/post_tool_outcome_hook.py +223 -0
  57. package/src/superlocalmemory/hooks/prewarm_auth.py +170 -0
  58. package/src/superlocalmemory/hooks/session_registry.py +186 -0
  59. package/src/superlocalmemory/hooks/stop_outcome_hook.py +134 -0
  60. package/src/superlocalmemory/hooks/sync_loop.py +114 -0
  61. package/src/superlocalmemory/hooks/user_prompt_hook.py +128 -0
  62. package/src/superlocalmemory/hooks/user_prompt_rehash_hook.py +202 -0
  63. package/src/superlocalmemory/infra/backup.py +3 -3
  64. package/src/superlocalmemory/infra/cloud_backup.py +2 -2
  65. package/src/superlocalmemory/infra/event_bus.py +2 -2
  66. package/src/superlocalmemory/infra/webhook_dispatcher.py +3 -3
  67. package/src/superlocalmemory/learning/arm_catalog.py +99 -0
  68. package/src/superlocalmemory/learning/bandit.py +526 -0
  69. package/src/superlocalmemory/learning/bandit_cache.py +133 -0
  70. package/src/superlocalmemory/learning/behavioral.py +53 -1
  71. package/src/superlocalmemory/learning/consolidation_cycle.py +381 -0
  72. package/src/superlocalmemory/learning/consolidation_worker.py +188 -520
  73. package/src/superlocalmemory/learning/database.py +256 -0
  74. package/src/superlocalmemory/learning/dedup_hnsw.py +413 -0
  75. package/src/superlocalmemory/learning/ensemble.py +300 -0
  76. package/src/superlocalmemory/learning/fact_outcome_joins.py +207 -0
  77. package/src/superlocalmemory/learning/forgetting_scheduler.py +55 -0
  78. package/src/superlocalmemory/learning/hnsw_dedup.py +69 -0
  79. package/src/superlocalmemory/learning/labeler.py +87 -0
  80. package/src/superlocalmemory/learning/legacy_migration.py +277 -0
  81. package/src/superlocalmemory/learning/memory_merge.py +160 -0
  82. package/src/superlocalmemory/learning/model_cache.py +269 -0
  83. package/src/superlocalmemory/learning/model_rollback.py +278 -0
  84. package/src/superlocalmemory/learning/outcome_queue.py +284 -0
  85. package/src/superlocalmemory/learning/pattern_miner.py +415 -0
  86. package/src/superlocalmemory/learning/pattern_miner_constants.py +47 -0
  87. package/src/superlocalmemory/learning/ranker.py +225 -81
  88. package/src/superlocalmemory/learning/ranker_common.py +163 -0
  89. package/src/superlocalmemory/learning/ranker_retrain_legacy.py +202 -0
  90. package/src/superlocalmemory/learning/ranker_retrain_online.py +411 -0
  91. package/src/superlocalmemory/learning/reward.py +777 -0
  92. package/src/superlocalmemory/learning/reward_archive.py +210 -0
  93. package/src/superlocalmemory/learning/reward_boost.py +201 -0
  94. package/src/superlocalmemory/learning/reward_proxy.py +326 -0
  95. package/src/superlocalmemory/learning/shadow_test.py +524 -0
  96. package/src/superlocalmemory/learning/signal_worker.py +270 -0
  97. package/src/superlocalmemory/learning/signals.py +314 -0
  98. package/src/superlocalmemory/learning/trigram_index.py +547 -0
  99. package/src/superlocalmemory/mcp/server.py +5 -5
  100. package/src/superlocalmemory/mcp/tools_context.py +183 -0
  101. package/src/superlocalmemory/mcp/tools_core.py +92 -27
  102. package/src/superlocalmemory/parameterization/soft_prompt_generator.py +13 -0
  103. package/src/superlocalmemory/retrieval/engine.py +52 -0
  104. package/src/superlocalmemory/server/api.py +2 -2
  105. package/src/superlocalmemory/server/bandit_loops.py +140 -0
  106. package/src/superlocalmemory/server/middleware/__init__.py +11 -0
  107. package/src/superlocalmemory/server/middleware/security_headers.py +144 -0
  108. package/src/superlocalmemory/server/routes/backup.py +36 -13
  109. package/src/superlocalmemory/server/routes/behavioral.py +50 -19
  110. package/src/superlocalmemory/server/routes/brain.py +1234 -0
  111. package/src/superlocalmemory/server/routes/data_io.py +4 -4
  112. package/src/superlocalmemory/server/routes/events.py +2 -2
  113. package/src/superlocalmemory/server/routes/helpers.py +1 -1
  114. package/src/superlocalmemory/server/routes/learning.py +192 -7
  115. package/src/superlocalmemory/server/routes/memories.py +189 -1
  116. package/src/superlocalmemory/server/routes/prewarm.py +171 -0
  117. package/src/superlocalmemory/server/routes/profiles.py +3 -3
  118. package/src/superlocalmemory/server/routes/token.py +88 -0
  119. package/src/superlocalmemory/server/routes/ws.py +5 -5
  120. package/src/superlocalmemory/server/security_middleware.py +13 -7
  121. package/src/superlocalmemory/server/ui.py +2 -2
  122. package/src/superlocalmemory/server/unified_daemon.py +335 -3
  123. package/src/superlocalmemory/storage/migration_runner.py +545 -0
  124. package/src/superlocalmemory/storage/migrations/M001_add_signal_features_columns.py +67 -0
  125. package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +132 -0
  126. package/src/superlocalmemory/storage/migrations/M003_migration_log.py +38 -0
  127. package/src/superlocalmemory/storage/migrations/M004_cross_platform_sync_log.py +46 -0
  128. package/src/superlocalmemory/storage/migrations/M005_bandit_tables.py +75 -0
  129. package/src/superlocalmemory/storage/migrations/M006_action_outcomes_reward.py +75 -0
  130. package/src/superlocalmemory/storage/migrations/M007_pending_outcomes.py +63 -0
  131. package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +54 -0
  132. package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +75 -0
  133. package/src/superlocalmemory/storage/migrations/M011_archive_and_merge.py +87 -0
  134. package/src/superlocalmemory/storage/migrations/M012_shadow_observations.py +72 -0
  135. package/src/superlocalmemory/storage/migrations/M013_bi_temporal_columns.py +55 -0
  136. package/src/superlocalmemory/storage/migrations/__init__.py +81 -0
  137. package/src/superlocalmemory/storage/models.py +4 -0
  138. package/src/superlocalmemory/ui/css/brain.css +409 -0
  139. package/src/superlocalmemory/ui/css/legacy-dashboard.css +645 -0
  140. package/src/superlocalmemory/ui/index.html +459 -1345
  141. package/src/superlocalmemory/ui/js/brain.js +1321 -0
  142. package/src/superlocalmemory/ui/js/clusters.js +123 -4
  143. package/src/superlocalmemory/ui/js/init.js +48 -39
  144. package/src/superlocalmemory/ui/js/memories.js +88 -2
  145. package/src/superlocalmemory/ui/js/modal.js +71 -1
  146. package/src/superlocalmemory/ui/js/ng-shell.js +101 -88
  147. package/src/superlocalmemory/ui/js/trust-dashboard.js +168 -25
  148. package/src/superlocalmemory/ui/vendor/bootstrap-icons/bootstrap-icons.css +2018 -0
  149. package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
  150. package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
  151. package/src/superlocalmemory/ui/vendor/bootstrap.bundle.min.js +7 -0
  152. package/src/superlocalmemory/ui/vendor/bootstrap.min.css +6 -0
  153. package/src/superlocalmemory/ui/vendor/d3.v7.min.js +2 -0
  154. package/src/superlocalmemory/ui/vendor/graphology-library.min.js +2 -0
  155. package/src/superlocalmemory/ui/vendor/graphology.umd.min.js +2 -0
  156. package/src/superlocalmemory/ui/vendor/inter-ui/inter-variable.min.css +8 -0
  157. package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable-Italic.woff2 +0 -0
  158. package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable.woff2 +0 -0
  159. package/src/superlocalmemory/ui/vendor/sigma.min.js +1 -0
  160. package/src/superlocalmemory/ui/js/behavioral.js +0 -447
  161. package/src/superlocalmemory/ui/js/graph-core.js +0 -447
  162. package/src/superlocalmemory/ui/js/graph-interactions.js +0 -351
  163. package/src/superlocalmemory/ui/js/learning.js +0 -435
  164. package/src/superlocalmemory/ui/js/patterns.js +0 -93
  165. package/src/superlocalmemory.egg-info/PKG-INFO +0 -647
  166. package/src/superlocalmemory.egg-info/SOURCES.txt +0 -335
  167. package/src/superlocalmemory.egg-info/dependency_links.txt +0 -1
  168. package/src/superlocalmemory.egg-info/entry_points.txt +0 -2
  169. package/src/superlocalmemory.egg-info/requires.txt +0 -58
  170. package/src/superlocalmemory.egg-info/top_level.txt +0 -1
@@ -0,0 +1,326 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory v3.4.21 — LLD-03 §3.5 + §5.6
4
+
5
+ """Proxy settlement for bandit plays (v3.4.21 only).
6
+
7
+ LLD reference: ``.backup/active-brain/lld/LLD-03-contextual-bandit-and-ensemble.md``
8
+ Section 3.5 and 5.6.
9
+
10
+ Replaced in v3.4.21 by ``reward_from_outcomes.py`` — DO NOT extend this
11
+ module beyond the proxy window contract.
12
+
13
+ Policy (§3.5):
14
+ For each ``bandit_plays`` row where ``settled_at IS NULL`` and
15
+ ``age(played_at) > 60 s``:
16
+ 1. Read top-3 ``learning_signals.fact_id`` for the same query_id.
17
+ 2. Search ``tool_events`` within +30 s of played_at, LIKE '%fact_id%'.
18
+ 3. If hit → reward=1.0, kind='proxy_position'.
19
+ 4. Else if ``_requery_detected`` (NFC topic sig match within 30 s) →
20
+ reward=0.0, kind='proxy_requery'.
21
+ 5. Else if ``age(played_at) > 120 s`` → reward=0.5, kind='default'.
22
+ 6. Else: skip (not yet settleable).
23
+
24
+ Hard rules:
25
+ - P1: settlement window 60–120 s.
26
+ - P2: requery uses NFC-normalised topic signature from LLD-01 §4.2.
27
+ - B6: never writes raw query text anywhere.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import logging
33
+ import sqlite3
34
+ from datetime import datetime, timedelta, timezone
35
+ from pathlib import Path
36
+ from typing import Any
37
+
38
+ from superlocalmemory.core.topic_signature import compute_topic_signature
39
+ from superlocalmemory.learning.bandit import ContextualBandit
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ _MIN_AGE_SEC = 60
44
+ _MAX_AGE_SEC = 120
45
+ _EVIDENCE_WINDOW_SEC = 30
46
+ _REQUERY_WINDOW_SEC = 30
47
+
48
+
49
+ def _parse_iso(ts: str) -> datetime | None:
50
+ """Parse a best-effort ISO timestamp → tz-aware datetime (UTC if naive)."""
51
+ if not ts:
52
+ return None
53
+ try:
54
+ dt = datetime.fromisoformat(ts)
55
+ except ValueError:
56
+ try:
57
+ dt = datetime.strptime(ts, "%Y-%m-%dT%H:%M:%S")
58
+ except ValueError:
59
+ return None
60
+ if dt.tzinfo is None:
61
+ dt = dt.replace(tzinfo=timezone.utc)
62
+ return dt
63
+
64
+
65
+ def _open(db_path: Path) -> sqlite3.Connection | None:
66
+ try:
67
+ conn = sqlite3.connect(str(db_path), timeout=5.0)
68
+ conn.row_factory = sqlite3.Row
69
+ return conn
70
+ except sqlite3.Error as exc:
71
+ logger.debug("reward_proxy: open %s failed: %s", db_path, exc)
72
+ return None
73
+
74
+
75
+ def _fetch_unsettled(
76
+ learning_conn: sqlite3.Connection,
77
+ profile_id: str,
78
+ now: datetime,
79
+ ) -> list[sqlite3.Row]:
80
+ try:
81
+ return learning_conn.execute(
82
+ "SELECT play_id, query_id, played_at, stratum "
83
+ "FROM bandit_plays "
84
+ "WHERE profile_id = ? AND settled_at IS NULL "
85
+ "ORDER BY played_at ASC LIMIT 500",
86
+ (profile_id,),
87
+ ).fetchall()
88
+ except sqlite3.Error as exc:
89
+ logger.debug("reward_proxy: fetch_unsettled: %s", exc)
90
+ return []
91
+
92
+
93
+ def _top3_fact_ids(
94
+ learning_conn: sqlite3.Connection,
95
+ query_id: str,
96
+ ) -> list[str]:
97
+ """Return up to 3 fact_ids for this query_id ordered by position ASC.
98
+
99
+ Returns [] if learning_signals is missing or has no rows for this qid.
100
+ """
101
+ try:
102
+ rows = learning_conn.execute(
103
+ "SELECT fact_id FROM learning_signals "
104
+ "WHERE query_id = ? AND position < 3 "
105
+ "ORDER BY position ASC LIMIT 3",
106
+ (query_id,),
107
+ ).fetchall()
108
+ except sqlite3.Error:
109
+ return []
110
+ return [r[0] for r in rows if r and r[0]]
111
+
112
+
113
+ def _tool_event_hit(
114
+ memory_conn: sqlite3.Connection,
115
+ played_at: datetime,
116
+ fact_ids: list[str],
117
+ ) -> bool:
118
+ """True iff any tool_events row references any fact_id within +30 s."""
119
+ if not fact_ids:
120
+ return False
121
+ start = played_at.isoformat(timespec="seconds")
122
+ end = (played_at + timedelta(seconds=_EVIDENCE_WINDOW_SEC)).isoformat(
123
+ timespec="seconds",
124
+ )
125
+ # tool_events schema varies by install; we check for the table first.
126
+ try:
127
+ tbl = memory_conn.execute(
128
+ "SELECT name FROM sqlite_master "
129
+ "WHERE type='table' AND name='tool_events'"
130
+ ).fetchone()
131
+ if tbl is None:
132
+ return False
133
+ except sqlite3.Error:
134
+ return False
135
+
136
+ # E.2 (v3.4.21 perf): previously ran N separate LIKE '%fid%' scans,
137
+ # each doing a full table scan of ``tool_events`` inside the 30-second
138
+ # window. Instead, fetch the small window once and scan its payloads
139
+ # in Python — typically 0-few rows in a 30 s window, far cheaper than
140
+ # N LIKE passes against a growing table. Still O(rows_in_window * len(fact_ids))
141
+ # worst case but the constant factor is tiny (a substring check).
142
+ try:
143
+ candidate_rows = memory_conn.execute(
144
+ "SELECT payload_json FROM tool_events "
145
+ "WHERE occurred_at BETWEEN ? AND ? "
146
+ " AND payload_json IS NOT NULL",
147
+ (start, end),
148
+ ).fetchall()
149
+ except sqlite3.Error:
150
+ return False
151
+ if not candidate_rows:
152
+ return False
153
+ fid_list = [fid for fid in fact_ids if fid]
154
+ if not fid_list:
155
+ return False
156
+ for row in candidate_rows:
157
+ payload = row[0] or ""
158
+ for fid in fid_list:
159
+ if fid in payload:
160
+ return True
161
+ return False
162
+
163
+
164
+ def _requery_detected(
165
+ memory_conn: sqlite3.Connection,
166
+ played_at: datetime,
167
+ query_id: str,
168
+ ) -> bool:
169
+ """True iff a follow-up query within 30 s has matching NFC topic sig.
170
+
171
+ Uses the same topic-signature algorithm as LLD-01 §4.2. The original
172
+ query text is NOT stored (B6) — we need the hash that was computed at
173
+ recall time. Two schemas cover this:
174
+
175
+ 1. learning_signals.query_text_hash — populated by signal_worker.
176
+ 2. Any requery logged as a tool_event with a topic-signature field.
177
+
178
+ We do (1). The window is the next 30 s after played_at.
179
+ """
180
+ start = played_at.isoformat(timespec="seconds")
181
+ end = (played_at + timedelta(seconds=_REQUERY_WINDOW_SEC)).isoformat(
182
+ timespec="seconds",
183
+ )
184
+ # Look up the play's own query hash on memory_conn's learning-mirror IF
185
+ # it exists — otherwise skip quietly.
186
+ try:
187
+ tbl = memory_conn.execute(
188
+ "SELECT name FROM sqlite_master "
189
+ "WHERE type='table' AND name='tool_events'"
190
+ ).fetchone()
191
+ except sqlite3.Error:
192
+ return False
193
+ if tbl is None:
194
+ return False
195
+
196
+ # Read *query* text from tool_events payload for within-window events;
197
+ # compute topic sig on each, compare against the original query's sig.
198
+ try:
199
+ rows = memory_conn.execute(
200
+ "SELECT payload_json FROM tool_events "
201
+ "WHERE occurred_at > ? AND occurred_at <= ? "
202
+ " AND tool_name = 'recall' LIMIT 20",
203
+ (start, end),
204
+ ).fetchall()
205
+ except sqlite3.Error:
206
+ return False
207
+ if not rows:
208
+ return False
209
+
210
+ # We don't have the original query text on the bandit row (B6).
211
+ # The original is recoverable via tool_events at played_at (recall event).
212
+ try:
213
+ seed_row = memory_conn.execute(
214
+ "SELECT payload_json FROM tool_events "
215
+ "WHERE occurred_at <= ? AND tool_name = 'recall' "
216
+ "ORDER BY occurred_at DESC LIMIT 1",
217
+ (played_at.isoformat(timespec="seconds"),),
218
+ ).fetchone()
219
+ except sqlite3.Error:
220
+ seed_row = None
221
+ seed_query = _extract_query(seed_row[0] if seed_row else None)
222
+ if not seed_query:
223
+ return False
224
+ # P2: NFC-normalised topic signature.
225
+ seed_sig = compute_topic_signature(seed_query)
226
+ for r in rows:
227
+ q = _extract_query(r[0])
228
+ if not q:
229
+ continue
230
+ if compute_topic_signature(q) == seed_sig:
231
+ return True
232
+ return False
233
+
234
+
235
+ def _extract_query(payload_json: str | None) -> str:
236
+ """Pull a ``query`` / ``text`` field out of a tool_events payload."""
237
+ if not payload_json:
238
+ return ""
239
+ try:
240
+ import json as _json
241
+ obj = _json.loads(payload_json)
242
+ except (ValueError, TypeError):
243
+ return ""
244
+ if not isinstance(obj, dict):
245
+ return ""
246
+ for key in ("query", "text", "prompt"):
247
+ v = obj.get(key)
248
+ if isinstance(v, str) and v:
249
+ return v
250
+ return ""
251
+
252
+
253
+ def settle_stale_plays(
254
+ profile_id: str,
255
+ db_path: Path | str,
256
+ tool_events_db: Path | str,
257
+ *,
258
+ now: datetime | None = None,
259
+ bandit: ContextualBandit | None = None,
260
+ ) -> int:
261
+ """Settle every unsettled bandit play whose evidence window has passed.
262
+
263
+ Returns the number of plays settled. Never raises.
264
+ """
265
+ current = now if now is not None else datetime.now(timezone.utc)
266
+ learning_conn = _open(Path(db_path))
267
+ if learning_conn is None:
268
+ return 0
269
+
270
+ # tool_events lives in memory.db; if missing, evidence lookups simply fail.
271
+ memory_conn = _open(Path(tool_events_db))
272
+ owns_bandit = bandit is None
273
+ if bandit is None:
274
+ bandit = ContextualBandit(Path(db_path), profile_id=str(profile_id))
275
+
276
+ settled = 0
277
+ try:
278
+ rows = _fetch_unsettled(learning_conn, str(profile_id), current)
279
+ for row in rows:
280
+ played = _parse_iso(row["played_at"])
281
+ if played is None:
282
+ continue
283
+ age = (current - played).total_seconds()
284
+ if age < _MIN_AGE_SEC:
285
+ continue # not yet settleable
286
+
287
+ top3 = _top3_fact_ids(learning_conn, row["query_id"])
288
+ reward: float | None = None
289
+ kind = "default"
290
+ if memory_conn is not None and _tool_event_hit(
291
+ memory_conn, played, top3,
292
+ ):
293
+ reward = 1.0
294
+ kind = "proxy_position"
295
+ elif memory_conn is not None and _requery_detected(
296
+ memory_conn, played, row["query_id"],
297
+ ):
298
+ reward = 0.0
299
+ kind = "proxy_requery"
300
+ elif age > _MAX_AGE_SEC:
301
+ # P1: uncertain default after 120 s window closes.
302
+ reward = 0.5
303
+ kind = "default"
304
+ else:
305
+ # Between 60 and 120 s with no evidence yet — wait.
306
+ continue
307
+
308
+ if bandit.update(int(row["play_id"]), reward, kind=kind):
309
+ settled += 1
310
+ finally:
311
+ try:
312
+ learning_conn.close()
313
+ except sqlite3.Error: # pragma: no cover
314
+ pass
315
+ if memory_conn is not None:
316
+ try:
317
+ memory_conn.close()
318
+ except sqlite3.Error: # pragma: no cover
319
+ pass
320
+ # Don't close a caller-owned bandit instance.
321
+ _ = owns_bandit
322
+
323
+ return settled
324
+
325
+
326
+ __all__ = ("settle_stale_plays",)