superlocalmemory 3.4.19 → 3.4.22

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 (177) 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 +4 -3
  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 +254 -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/skills/slm-build-graph/SKILL.md +423 -0
  124. package/src/superlocalmemory/skills/slm-list-recent/SKILL.md +348 -0
  125. package/src/superlocalmemory/skills/slm-recall/SKILL.md +343 -0
  126. package/src/superlocalmemory/skills/slm-remember/SKILL.md +194 -0
  127. package/src/superlocalmemory/skills/slm-show-patterns/SKILL.md +224 -0
  128. package/src/superlocalmemory/skills/slm-status/SKILL.md +363 -0
  129. package/src/superlocalmemory/skills/slm-switch-profile/SKILL.md +442 -0
  130. package/src/superlocalmemory/storage/migration_runner.py +545 -0
  131. package/src/superlocalmemory/storage/migrations/M001_add_signal_features_columns.py +67 -0
  132. package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +132 -0
  133. package/src/superlocalmemory/storage/migrations/M003_migration_log.py +38 -0
  134. package/src/superlocalmemory/storage/migrations/M004_cross_platform_sync_log.py +46 -0
  135. package/src/superlocalmemory/storage/migrations/M005_bandit_tables.py +75 -0
  136. package/src/superlocalmemory/storage/migrations/M006_action_outcomes_reward.py +75 -0
  137. package/src/superlocalmemory/storage/migrations/M007_pending_outcomes.py +63 -0
  138. package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +54 -0
  139. package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +75 -0
  140. package/src/superlocalmemory/storage/migrations/M011_archive_and_merge.py +87 -0
  141. package/src/superlocalmemory/storage/migrations/M012_shadow_observations.py +72 -0
  142. package/src/superlocalmemory/storage/migrations/M013_bi_temporal_columns.py +55 -0
  143. package/src/superlocalmemory/storage/migrations/__init__.py +81 -0
  144. package/src/superlocalmemory/storage/models.py +4 -0
  145. package/src/superlocalmemory/ui/css/brain.css +409 -0
  146. package/src/superlocalmemory/ui/css/legacy-dashboard.css +645 -0
  147. package/src/superlocalmemory/ui/index.html +459 -1345
  148. package/src/superlocalmemory/ui/js/brain.js +1321 -0
  149. package/src/superlocalmemory/ui/js/clusters.js +123 -4
  150. package/src/superlocalmemory/ui/js/init.js +48 -39
  151. package/src/superlocalmemory/ui/js/memories.js +88 -2
  152. package/src/superlocalmemory/ui/js/modal.js +71 -1
  153. package/src/superlocalmemory/ui/js/ng-shell.js +101 -88
  154. package/src/superlocalmemory/ui/js/trust-dashboard.js +168 -25
  155. package/src/superlocalmemory/ui/vendor/bootstrap-icons/bootstrap-icons.css +2018 -0
  156. package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
  157. package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
  158. package/src/superlocalmemory/ui/vendor/bootstrap.bundle.min.js +7 -0
  159. package/src/superlocalmemory/ui/vendor/bootstrap.min.css +6 -0
  160. package/src/superlocalmemory/ui/vendor/d3.v7.min.js +2 -0
  161. package/src/superlocalmemory/ui/vendor/graphology-library.min.js +2 -0
  162. package/src/superlocalmemory/ui/vendor/graphology.umd.min.js +2 -0
  163. package/src/superlocalmemory/ui/vendor/inter-ui/inter-variable.min.css +8 -0
  164. package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable-Italic.woff2 +0 -0
  165. package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable.woff2 +0 -0
  166. package/src/superlocalmemory/ui/vendor/sigma.min.js +1 -0
  167. package/src/superlocalmemory/ui/js/behavioral.js +0 -447
  168. package/src/superlocalmemory/ui/js/graph-core.js +0 -447
  169. package/src/superlocalmemory/ui/js/graph-interactions.js +0 -351
  170. package/src/superlocalmemory/ui/js/learning.js +0 -435
  171. package/src/superlocalmemory/ui/js/patterns.js +0 -93
  172. package/src/superlocalmemory.egg-info/PKG-INFO +0 -647
  173. package/src/superlocalmemory.egg-info/SOURCES.txt +0 -335
  174. package/src/superlocalmemory.egg-info/dependency_links.txt +0 -1
  175. package/src/superlocalmemory.egg-info/entry_points.txt +0 -2
  176. package/src/superlocalmemory.egg-info/requires.txt +0 -58
  177. package/src/superlocalmemory.egg-info/top_level.txt +0 -1
@@ -0,0 +1,210 @@
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.22 — F4.A Stage-8 H-18/H-06 fix
4
+
5
+ """Reward-gated Ebbinghaus archive.
6
+
7
+ Flags Ebbinghaus-cold facts as archived *only* when they show no
8
+ positive reward in the last 60 days AND are not marked important
9
+ (LLD-12 §4). Writes a payload-preserving row to ``memory_archive`` and
10
+ updates ``atomic_facts.archive_status='archived'``.
11
+
12
+ **Never issues DELETE FROM atomic_facts** (SOUL directive, LLD-12 §1 —
13
+ memory is sacred across v3.4.22 with 18 000 live users).
14
+
15
+ JSON1 join helper (``iter_outcomes_for_fact``) replaces the former
16
+ ``fact_ids_json LIKE '%"<fid>"%'`` pattern so that overlapping fact_id
17
+ prefixes no longer collide (H-06).
18
+
19
+ Contract refs:
20
+ - LLD-12 §4 — reward-gated archive criteria.
21
+ - Stage 8 H-18 — split from monolithic hnsw_dedup.py.
22
+ - Stage 8 H-06 — JSON1 replaces fragile LIKE.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import logging
29
+ import sqlite3
30
+ import uuid
31
+ from datetime import datetime, timezone
32
+ from pathlib import Path
33
+
34
+ from superlocalmemory.learning.fact_outcome_joins import (
35
+ has_recent_positive_reward,
36
+ )
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+
41
+ REWARD_WINDOW_DAYS: int = 60
42
+ ARCHIVE_REWARD_THRESHOLD: float = 0.3
43
+
44
+ # SEC-L3 — soft cap on ``memory_archive.payload_json`` length. The DDL
45
+ # in M011 is ``TEXT NOT NULL`` with no column cap (SQLite has no native
46
+ # column-length limit), so a runaway fact could write multi-MB blobs
47
+ # and blow past the MASTER-PLAN §2 I4 disk budget. Enforced on the
48
+ # write path; oversize payloads are truncated with a breadcrumb in the
49
+ # ``reason`` column so an operator can still retrieve the original
50
+ # fact via ``atomic_facts`` (archive does not DELETE).
51
+ PAYLOAD_JSON_MAX_BYTES: int = 262_144
52
+
53
+
54
+ __all__ = (
55
+ "run_reward_gated_archive",
56
+ "REWARD_WINDOW_DAYS",
57
+ "ARCHIVE_REWARD_THRESHOLD",
58
+ "PAYLOAD_JSON_MAX_BYTES",
59
+ )
60
+
61
+
62
+ def _iso_now() -> str:
63
+ return datetime.now(timezone.utc).isoformat()
64
+
65
+
66
+ def run_reward_gated_archive(
67
+ memory_db_path: str | Path,
68
+ profile_id: str,
69
+ *,
70
+ candidate_fact_ids: list[str],
71
+ ) -> list[str]:
72
+ """Archive candidate facts that have no positive reward in 60 days and
73
+ are not flagged important. Returns the list of fact_ids archived.
74
+
75
+ LLD-12 §1 hard invariant: this function NEVER issues
76
+ ``DELETE FROM atomic_facts``. It UPDATEs archive_status + writes a
77
+ payload snapshot to ``memory_archive``.
78
+ """
79
+ if not candidate_fact_ids:
80
+ return []
81
+
82
+ archived: list[str] = []
83
+ conn = sqlite3.connect(str(memory_db_path), timeout=10.0)
84
+ conn.row_factory = sqlite3.Row
85
+ conn.execute("PRAGMA busy_timeout=2000")
86
+
87
+ try:
88
+ placeholders = ",".join("?" for _ in candidate_fact_ids)
89
+ rows = conn.execute(
90
+ f"SELECT fact_id, content, canonical_entities_json, importance, "
91
+ f" confidence, embedding, created_at "
92
+ f"FROM atomic_facts "
93
+ f"WHERE profile_id = ? AND fact_id IN ({placeholders}) "
94
+ f" AND (archive_status IS NULL OR archive_status = 'live')",
95
+ (profile_id, *candidate_fact_ids),
96
+ ).fetchall()
97
+
98
+ # H-12/H-P-02: compute the archivable set BEFORE acquiring the
99
+ # writer lock. Previously ``BEGIN IMMEDIATE`` wrapped the full
100
+ # per-candidate reward-lookup loop — holding RESERVED for the
101
+ # entire scan starved concurrent ``record_recall`` writers out
102
+ # with SQLITE_BUSY after their 50 ms busy_timeout. Splitting the
103
+ # read phase keeps the writer lock held only for the bulk
104
+ # INSERT/UPDATE pass.
105
+ to_archive: list[dict] = []
106
+ for row in rows:
107
+ fid = row["fact_id"]
108
+ # 1. Important flag skip (LLD-12 §4 criterion 3).
109
+ if float(row["importance"] or 0.0) >= 1.0:
110
+ continue
111
+ # 2. Recent positive reward skip (criterion 2).
112
+ # H-06 fix — JSON1 equality join via helper.
113
+ if has_recent_positive_reward(
114
+ conn, profile_id, fid,
115
+ min_reward=ARCHIVE_REWARD_THRESHOLD,
116
+ window_days=REWARD_WINDOW_DAYS,
117
+ ):
118
+ continue
119
+ to_archive.append({
120
+ "fid": fid,
121
+ "content": row["content"],
122
+ "canonical_entities_json": row["canonical_entities_json"],
123
+ "importance": row["importance"],
124
+ "confidence": row["confidence"],
125
+ "embedding": row["embedding"],
126
+ "created_at": row["created_at"],
127
+ })
128
+
129
+ if not to_archive:
130
+ return []
131
+
132
+ conn.execute("BEGIN IMMEDIATE")
133
+ # S9-SKEP-07: re-verify reward under RESERVED lock. Between the
134
+ # read-only reward scan above and this BEGIN IMMEDIATE another
135
+ # writer may have inserted a positive reward row ("user liked
136
+ # the memory we are about to archive"). With the writer lock
137
+ # held we now know no further inserts are landing; one last
138
+ # check per entry catches everything that raced in.
139
+ verified: list[dict] = []
140
+ for entry in to_archive:
141
+ if has_recent_positive_reward(
142
+ conn, profile_id, entry["fid"],
143
+ min_reward=ARCHIVE_REWARD_THRESHOLD,
144
+ window_days=REWARD_WINDOW_DAYS,
145
+ ):
146
+ continue
147
+ verified.append(entry)
148
+ to_archive = verified
149
+ if not to_archive:
150
+ conn.execute("COMMIT")
151
+ return []
152
+ for entry in to_archive:
153
+ fid = entry["fid"]
154
+ payload = {
155
+ "fact_id": fid,
156
+ "content": entry["content"],
157
+ "canonical_entities_json": entry["canonical_entities_json"],
158
+ "importance": entry["importance"],
159
+ "confidence": entry["confidence"],
160
+ "embedding": entry["embedding"],
161
+ "created_at": entry["created_at"],
162
+ }
163
+ # SEC-L3 — cap payload_json at 256 KB. Oversize blobs are
164
+ # replaced with a minimal stub + ``truncated`` reason so the
165
+ # archive row stays within the I4 disk budget while still
166
+ # pointing back to the original ``fact_id`` in atomic_facts.
167
+ payload_str = json.dumps(payload)
168
+ reason = "reward_gated_ebbinghaus"
169
+ if len(payload_str.encode("utf-8")) > PAYLOAD_JSON_MAX_BYTES:
170
+ payload_str = json.dumps({
171
+ "fact_id": fid,
172
+ "truncated": True,
173
+ "original_bytes": len(payload_str.encode("utf-8")),
174
+ })
175
+ reason = "reward_gated_ebbinghaus_truncated"
176
+ logger.warning(
177
+ "memory_archive payload >%d bytes for fact_id=%s; "
178
+ "truncated to stub", PAYLOAD_JSON_MAX_BYTES, fid,
179
+ )
180
+ conn.execute(
181
+ "INSERT INTO memory_archive "
182
+ "(archive_id, fact_id, profile_id, payload_json, "
183
+ " archived_at, reason) VALUES (?, ?, ?, ?, ?, ?)",
184
+ (
185
+ str(uuid.uuid4()),
186
+ fid,
187
+ profile_id,
188
+ payload_str,
189
+ _iso_now(),
190
+ reason,
191
+ ),
192
+ )
193
+ conn.execute(
194
+ "UPDATE atomic_facts "
195
+ "SET archive_status='archived', "
196
+ " archive_reason='reward_gated_ebbinghaus' "
197
+ "WHERE fact_id=? "
198
+ " AND (archive_status IS NULL OR archive_status='live')",
199
+ (fid,),
200
+ )
201
+ archived.append(fid)
202
+
203
+ conn.commit()
204
+ except sqlite3.Error as exc:
205
+ conn.rollback()
206
+ logger.warning("run_reward_gated_archive rollback: %s", exc)
207
+ finally:
208
+ conn.close()
209
+
210
+ return archived
@@ -0,0 +1,201 @@
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.22 — F4.A Stage-8 H-06/H-18 fix
4
+
5
+ """Strong-memory boost + reward-aware fact selection.
6
+
7
+ Nudges ``atomic_facts.retrieval_prior`` upward for facts with recurring
8
+ high reward, capped at 0.5 (LLD-12 §5). Also exposes
9
+ ``select_high_reward_fact_ids`` for the soft-prompt generator.
10
+
11
+ H-06 regression fix: outcome lookups now use the JSON1-backed
12
+ ``fact_outcome_joins`` helper instead of the fragile
13
+ ``fact_ids_json LIKE '%"<fid>"%'`` pattern that leaked substring matches
14
+ across overlapping fact_id prefixes.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import sqlite3
21
+ from pathlib import Path
22
+
23
+ from superlocalmemory.learning.fact_outcome_joins import (
24
+ aggregate_reward_for_fact,
25
+ )
26
+
27
+
28
+ # H-12/H-P-01: single-pass JSON1 aggregation across ALL facts for a profile.
29
+ # Returns ``{fact_id: (count, mean_reward)}`` for outcomes with reward NOT
30
+ # NULL. Replaces the per-fact O(F) loop of ``aggregate_reward_for_fact``
31
+ # with one GROUP BY scan — O(F+O) instead of O(F·O). JSON1 has been
32
+ # mandatory since v3.4.22 (see ``fact_outcome_joins._json1_available``
33
+ # contract in the module docstring); if it is missing at runtime the
34
+ # caller falls back to the per-fact helper which retains its own
35
+ # LIKE-based shim.
36
+
37
+
38
+ # S9-W3 H-PERF-03: consolidation invokes ``apply_strong_memory_boost``
39
+ # AND ``select_high_reward_fact_ids`` in the same cycle. Both call
40
+ # ``_bulk_fact_reward_stats`` which is a full GROUP BY scan — at 100k
41
+ # outcomes that's 1-3 s × 2 = 2-6 s wasted inside the 5-min cap. We
42
+ # memoise the result with a short TTL so consecutive calls within the
43
+ # same consolidation cycle share the stats. Key is (id(conn),
44
+ # profile_id) so different conns / profiles get independent caches.
45
+ # TTL expires quickly so live recall updates are reflected within one
46
+ # cycle of the consolidation loop.
47
+ _BULK_STATS_TTL_SEC: float = 30.0
48
+ _bulk_stats_cache: dict[tuple[int, str], tuple[float, dict[str, tuple[int, float]]]] = {}
49
+
50
+
51
+ # S9-W3 M-PERF-07: module-level MISS constant so ``stats.get(fid, _MISS)``
52
+ # does not allocate a fresh ``(0, 0.0)`` tuple per call. At 100k facts
53
+ # that saved ~2 MB of short-lived garbage per consolidation cycle.
54
+ _MISS: tuple[int, float] = (0, 0.0)
55
+
56
+
57
+ def _bulk_fact_reward_stats(
58
+ conn: sqlite3.Connection, profile_id: str,
59
+ ) -> dict[str, tuple[int, float]]:
60
+ import time as _time
61
+ now = _time.monotonic()
62
+ key = (id(conn), profile_id)
63
+ cached = _bulk_stats_cache.get(key)
64
+ if cached is not None:
65
+ ts, result = cached
66
+ if now - ts < _BULK_STATS_TTL_SEC:
67
+ return result
68
+ try:
69
+ rows = conn.execute(
70
+ "SELECT j.value AS fact_id, "
71
+ " COUNT(*) AS c, "
72
+ " AVG(reward) AS m "
73
+ "FROM action_outcomes a, json_each(a.fact_ids_json) j "
74
+ "WHERE a.profile_id = ? AND a.reward IS NOT NULL "
75
+ "GROUP BY j.value",
76
+ (profile_id,),
77
+ ).fetchall()
78
+ except sqlite3.OperationalError:
79
+ # JSON1 missing — signal to caller to fall back. Do NOT cache
80
+ # this (empty) result: a subsequent call on the same conn may
81
+ # fall through to the per-fact loop intentionally.
82
+ return {}
83
+ out: dict[str, tuple[int, float]] = {}
84
+ for fid, c, m in rows:
85
+ if fid is None:
86
+ continue
87
+ out[str(fid)] = (int(c or 0), float(m or 0.0))
88
+ _bulk_stats_cache[key] = (now, out)
89
+ # Prune the cache if it grows beyond 64 entries (multi-profile envs).
90
+ if len(_bulk_stats_cache) > 64:
91
+ stale = [k for k, (t, _) in _bulk_stats_cache.items()
92
+ if now - t >= _BULK_STATS_TTL_SEC]
93
+ for k in stale:
94
+ _bulk_stats_cache.pop(k, None)
95
+ return out
96
+
97
+ logger = logging.getLogger(__name__)
98
+
99
+
100
+ STRONG_BOOST_INCREMENT: float = 0.1
101
+ STRONG_BOOST_CAP: float = 0.5
102
+ STRONG_BOOST_MIN_OUTCOMES: int = 3
103
+ STRONG_BOOST_MIN_MEAN: float = 0.7
104
+
105
+
106
+ __all__ = (
107
+ "apply_strong_memory_boost",
108
+ "select_high_reward_fact_ids",
109
+ "STRONG_BOOST_INCREMENT",
110
+ "STRONG_BOOST_CAP",
111
+ "STRONG_BOOST_MIN_OUTCOMES",
112
+ "STRONG_BOOST_MIN_MEAN",
113
+ )
114
+
115
+
116
+ def apply_strong_memory_boost(
117
+ memory_db_path: str | Path, profile_id: str,
118
+ ) -> int:
119
+ """Nudge retrieval_prior up for high-reward facts, capped at 0.5.
120
+
121
+ Eligibility: ≥ MIN_OUTCOMES outcomes with mean reward > MIN_MEAN.
122
+ Effect: retrieval_prior = MIN(retrieval_prior + INCREMENT, CAP).
123
+
124
+ Returns number of rows boosted.
125
+ """
126
+ conn = sqlite3.connect(str(memory_db_path), timeout=10.0)
127
+ conn.execute("PRAGMA busy_timeout=2000")
128
+ boosted = 0
129
+ try:
130
+ rows = conn.execute(
131
+ "SELECT fact_id FROM atomic_facts WHERE profile_id=? "
132
+ " AND (archive_status IS NULL OR archive_status='live')",
133
+ (profile_id,),
134
+ ).fetchall()
135
+ if not rows:
136
+ return 0
137
+
138
+ # H-12/H-P-01: single JSON1 GROUP BY replaces the per-fact loop.
139
+ # Fallback to per-fact helper preserves legacy behaviour on
140
+ # SQLite without JSON1.
141
+ stats = _bulk_fact_reward_stats(conn, profile_id)
142
+ conn.execute("BEGIN IMMEDIATE")
143
+ for (fid,) in rows:
144
+ if stats:
145
+ count, mean = stats.get(fid, _MISS)
146
+ else:
147
+ count, mean = aggregate_reward_for_fact(conn, profile_id, fid)
148
+ if count < STRONG_BOOST_MIN_OUTCOMES:
149
+ continue
150
+ if mean <= STRONG_BOOST_MIN_MEAN:
151
+ continue
152
+ conn.execute(
153
+ "UPDATE atomic_facts "
154
+ "SET retrieval_prior = MIN(COALESCE(retrieval_prior, 0) + ?, ?) "
155
+ "WHERE fact_id=?",
156
+ (STRONG_BOOST_INCREMENT, STRONG_BOOST_CAP, fid),
157
+ )
158
+ boosted += 1
159
+ conn.commit()
160
+ except sqlite3.Error as exc:
161
+ conn.rollback()
162
+ logger.warning("apply_strong_memory_boost rollback: %s", exc)
163
+ finally:
164
+ conn.close()
165
+ return boosted
166
+
167
+
168
+ def select_high_reward_fact_ids(
169
+ memory_db_path: str | Path,
170
+ profile_id: str,
171
+ *,
172
+ min_reward: float = 0.6,
173
+ min_outcomes: int = 1,
174
+ ) -> list[str]:
175
+ """Return fact_ids whose mean outcome reward ≥ ``min_reward``.
176
+
177
+ Used by ``soft_prompt_generator`` to mine only high-reward facts
178
+ (LLD-12 §6). JSON1-backed — no substring false positives.
179
+ """
180
+ conn = sqlite3.connect(str(memory_db_path), timeout=10.0)
181
+ try:
182
+ fact_rows = conn.execute(
183
+ "SELECT fact_id FROM atomic_facts WHERE profile_id=? "
184
+ " AND (archive_status IS NULL OR archive_status='live')",
185
+ (profile_id,),
186
+ ).fetchall()
187
+ # H-12/H-P-01: bulk aggregate replaces per-fact loop.
188
+ stats = _bulk_fact_reward_stats(conn, profile_id)
189
+ out: list[str] = []
190
+ for (fid,) in fact_rows:
191
+ if stats:
192
+ count, mean = stats.get(fid, _MISS)
193
+ else:
194
+ count, mean = aggregate_reward_for_fact(conn, profile_id, fid)
195
+ if count < min_outcomes:
196
+ continue
197
+ if mean >= min_reward:
198
+ out.append(fid)
199
+ return out
200
+ finally:
201
+ conn.close()