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,99 @@
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 — LLD-03 §5.1
4
+
5
+ """Static 40-arm catalog for the contextual Thompson bandit.
6
+
7
+ LLD reference: ``.backup/active-brain/lld/LLD-03-contextual-bandit-and-ensemble.md``
8
+ Section 5.1 — arm = (semantic, bm25, entity_graph, temporal,
9
+ cross_encoder_bias) weight bundle drawn from a 7-point canonical grid.
10
+
11
+ Pure-data module — zero imports from the rest of the codebase. Audit-friendly
12
+ diff. Bumping the catalog requires bumping ``__version__`` and emitting a
13
+ migration in LLD-07.
14
+
15
+ Hard rules enforced here:
16
+ - B3: ``len(ARM_CATALOG) == 40`` — asserted at import time.
17
+ - B3: every weight in every arm belongs to ``_WEIGHT_GRID``.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ __version__ = "1"
23
+
24
+ # Single source of truth for the discrete weight grid (LLD-03 §3.1).
25
+ _WEIGHT_GRID: tuple[float, ...] = (0.5, 0.8, 1.0, 1.2, 1.3, 1.5, 2.0)
26
+
27
+
28
+ # 40-arm catalog. Grouped by regime for auditability — DO NOT reorder.
29
+ ARM_CATALOG: dict[str, dict[str, float]] = {
30
+ # Balanced anchors (4)
31
+ "balanced_1_0": {"semantic": 1.0, "bm25": 1.0, "entity_graph": 1.0, "temporal": 1.0, "cross_encoder_bias": 1.0},
32
+ "balanced_1_2": {"semantic": 1.2, "bm25": 1.2, "entity_graph": 1.2, "temporal": 1.0, "cross_encoder_bias": 1.0},
33
+ "balanced_1_5": {"semantic": 1.5, "bm25": 1.3, "entity_graph": 1.3, "temporal": 1.0, "cross_encoder_bias": 1.0},
34
+ "balanced_2_0": {"semantic": 2.0, "bm25": 1.5, "entity_graph": 1.5, "temporal": 1.0, "cross_encoder_bias": 1.0},
35
+ # Semantic-heavy (5)
36
+ "semantic_heavy_1": {"semantic": 1.5, "bm25": 0.5, "entity_graph": 1.0, "temporal": 1.0, "cross_encoder_bias": 1.0},
37
+ "semantic_heavy_2": {"semantic": 2.0, "bm25": 0.5, "entity_graph": 1.0, "temporal": 1.0, "cross_encoder_bias": 1.0},
38
+ "semantic_heavy_3": {"semantic": 2.0, "bm25": 1.0, "entity_graph": 0.5, "temporal": 1.0, "cross_encoder_bias": 1.0},
39
+ "semantic_rerank_boost": {"semantic": 2.0, "bm25": 1.0, "entity_graph": 1.0, "temporal": 1.0, "cross_encoder_bias": 1.5},
40
+ "semantic_pure": {"semantic": 2.0, "bm25": 0.5, "entity_graph": 0.5, "temporal": 0.5, "cross_encoder_bias": 1.3},
41
+ # BM25-heavy (5)
42
+ "bm25_heavy_1": {"semantic": 0.5, "bm25": 2.0, "entity_graph": 1.0, "temporal": 1.0, "cross_encoder_bias": 1.0},
43
+ "bm25_heavy_2": {"semantic": 1.0, "bm25": 2.0, "entity_graph": 0.5, "temporal": 1.0, "cross_encoder_bias": 1.0},
44
+ "bm25_temporal": {"semantic": 0.5, "bm25": 1.5, "entity_graph": 1.0, "temporal": 1.5, "cross_encoder_bias": 1.0},
45
+ "bm25_pure": {"semantic": 0.5, "bm25": 2.0, "entity_graph": 0.5, "temporal": 0.5, "cross_encoder_bias": 1.0},
46
+ "bm25_rerank_strong": {"semantic": 1.0, "bm25": 1.5, "entity_graph": 1.0, "temporal": 1.0, "cross_encoder_bias": 1.5},
47
+ # Entity-heavy (6)
48
+ "entity_heavy_1": {"semantic": 1.0, "bm25": 1.0, "entity_graph": 1.5, "temporal": 1.0, "cross_encoder_bias": 1.0},
49
+ "entity_heavy_2": {"semantic": 1.0, "bm25": 1.0, "entity_graph": 2.0, "temporal": 1.0, "cross_encoder_bias": 1.0},
50
+ "entity_semantic": {"semantic": 1.5, "bm25": 0.5, "entity_graph": 2.0, "temporal": 1.0, "cross_encoder_bias": 1.0},
51
+ "entity_pure": {"semantic": 0.5, "bm25": 0.5, "entity_graph": 2.0, "temporal": 0.5, "cross_encoder_bias": 1.0},
52
+ "entity_graph_boost": {"semantic": 1.0, "bm25": 0.5, "entity_graph": 2.0, "temporal": 1.0, "cross_encoder_bias": 1.3},
53
+ "entity_kg_multi": {"semantic": 1.3, "bm25": 1.0, "entity_graph": 1.5, "temporal": 1.0, "cross_encoder_bias": 1.2},
54
+ # Temporal-heavy (6)
55
+ "temporal_heavy_1": {"semantic": 1.0, "bm25": 1.0, "entity_graph": 1.0, "temporal": 1.5, "cross_encoder_bias": 1.0},
56
+ "temporal_heavy_2": {"semantic": 1.0, "bm25": 1.0, "entity_graph": 1.0, "temporal": 2.0, "cross_encoder_bias": 1.0},
57
+ "temporal_semantic": {"semantic": 1.5, "bm25": 1.0, "entity_graph": 1.0, "temporal": 1.5, "cross_encoder_bias": 1.0},
58
+ "temporal_recent": {"semantic": 1.0, "bm25": 1.2, "entity_graph": 0.5, "temporal": 2.0, "cross_encoder_bias": 1.0},
59
+ "temporal_bm25": {"semantic": 0.5, "bm25": 1.5, "entity_graph": 0.5, "temporal": 2.0, "cross_encoder_bias": 1.0},
60
+ "temporal_entity": {"semantic": 1.0, "bm25": 0.5, "entity_graph": 1.5, "temporal": 1.5, "cross_encoder_bias": 1.0},
61
+ # Diagonal / exploratory (8)
62
+ "sem_bm25_diag": {"semantic": 1.5, "bm25": 1.5, "entity_graph": 0.5, "temporal": 0.5, "cross_encoder_bias": 1.0},
63
+ "sem_entity_diag": {"semantic": 1.5, "bm25": 0.5, "entity_graph": 1.5, "temporal": 0.5, "cross_encoder_bias": 1.0},
64
+ "sem_temporal_diag": {"semantic": 1.5, "bm25": 0.5, "entity_graph": 0.5, "temporal": 1.5, "cross_encoder_bias": 1.0},
65
+ "bm25_entity_diag": {"semantic": 0.5, "bm25": 1.5, "entity_graph": 1.5, "temporal": 0.5, "cross_encoder_bias": 1.0},
66
+ "bm25_temporal_diag": {"semantic": 0.5, "bm25": 1.5, "entity_graph": 0.5, "temporal": 1.5, "cross_encoder_bias": 1.0},
67
+ "entity_temporal_diag": {"semantic": 0.5, "bm25": 0.5, "entity_graph": 1.5, "temporal": 1.5, "cross_encoder_bias": 1.0},
68
+ "three_axis_high": {"semantic": 1.5, "bm25": 1.5, "entity_graph": 1.5, "temporal": 1.0, "cross_encoder_bias": 1.0},
69
+ "all_high": {"semantic": 1.5, "bm25": 1.5, "entity_graph": 1.5, "temporal": 1.5, "cross_encoder_bias": 1.0},
70
+ # Conservative / fallback (6)
71
+ "conservative_low": {"semantic": 1.0, "bm25": 1.0, "entity_graph": 1.0, "temporal": 1.0, "cross_encoder_bias": 1.3},
72
+ "conservative_high": {"semantic": 1.3, "bm25": 1.0, "entity_graph": 1.0, "temporal": 1.0, "cross_encoder_bias": 1.5},
73
+ "rerank_only": {"semantic": 1.0, "bm25": 1.0, "entity_graph": 1.0, "temporal": 1.0, "cross_encoder_bias": 2.0},
74
+ "light_bm25_only": {"semantic": 0.5, "bm25": 1.3, "entity_graph": 0.5, "temporal": 0.5, "cross_encoder_bias": 1.0},
75
+ "mid_semantic": {"semantic": 1.2, "bm25": 0.8, "entity_graph": 0.8, "temporal": 0.8, "cross_encoder_bias": 1.0},
76
+ "fallback_default": {"semantic": 1.0, "bm25": 1.0, "entity_graph": 1.0, "temporal": 1.0, "cross_encoder_bias": 1.0},
77
+ }
78
+
79
+
80
+ # B3 — module-level invariant. Raises ImportError if someone edits the catalog
81
+ # without keeping the count at 40.
82
+ assert len(ARM_CATALOG) == 40, (
83
+ f"ARM_CATALOG size drift: {len(ARM_CATALOG)} (expected 40)"
84
+ )
85
+
86
+
87
+ # B3 — every weight belongs to the canonical grid. Checked at import so any
88
+ # off-grid weight fails loudly during CI / daemon startup.
89
+ _GRID_SET: frozenset[float] = frozenset(_WEIGHT_GRID)
90
+ for _name, _weights in ARM_CATALOG.items():
91
+ for _channel, _w in _weights.items():
92
+ if _w not in _GRID_SET: # pragma: no cover — invariant
93
+ raise AssertionError(
94
+ f"arm {_name!r} channel {_channel!r} weight {_w} not in grid"
95
+ )
96
+ del _name, _weights, _channel, _w # don't leak loop vars as module globals
97
+
98
+
99
+ __all__ = ("ARM_CATALOG", "_WEIGHT_GRID", "__version__")
@@ -0,0 +1,526 @@
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 — LLD-03 §3 + §5.3
4
+
5
+ """Contextual Thompson-sampling bandit over discrete channel-weight arms.
6
+
7
+ LLD reference: ``.backup/active-brain/lld/LLD-03-contextual-bandit-and-ensemble.md``
8
+ Sections 3 (algorithm), 5.3 (file spec), 8 (hard rules).
9
+
10
+ Schema: ``bandit_arms`` + ``bandit_plays`` live in ``learning.db``, created by
11
+ LLD-07 M005. This module NEVER defines DDL — it only READs / WRITEs.
12
+
13
+ Hard rules:
14
+ - B1: ``secrets.SystemRandom`` used for Beta sampling (NOT ``random``).
15
+ - B2: α, β clamped at ``SLM_BANDIT_ALPHA_CAP`` (default 1000).
16
+ - B4: stratum cardinality == 48 (4 query_types × 3 entity bins × 4 buckets).
17
+ - B5: cache invalidated on every successful ``update``.
18
+ - B6: raw query text NEVER written to bandit tables.
19
+ - B7: ``choose`` p99 ≤ 10 ms.
20
+ - B8: ``retention_sweep`` only deletes settled rows older than cutoff.
21
+
22
+ All SQL is parameterised — grep guard in CI ensures no f-string SQL here.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import logging
28
+ import os
29
+ import secrets
30
+ import sqlite3
31
+ import threading
32
+ import time
33
+ from dataclasses import dataclass, field
34
+ from datetime import datetime, timedelta, timezone
35
+ from pathlib import Path
36
+ from typing import Any
37
+
38
+ from superlocalmemory.learning.arm_catalog import ARM_CATALOG
39
+ from superlocalmemory.learning.bandit_cache import (
40
+ _BanditCache,
41
+ get_shared_cache,
42
+ )
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+ _FALLBACK_ARM_ID = "fallback_default"
47
+
48
+ _DEFAULT_ALPHA_CAP = float(os.environ.get("SLM_BANDIT_ALPHA_CAP", "1000.0"))
49
+
50
+ # Query-type bins (must match features.py one-hot exactly).
51
+ _QUERY_TYPES: tuple[str, ...] = (
52
+ "single_hop",
53
+ "multi_hop",
54
+ "temporal",
55
+ "open_domain",
56
+ )
57
+ _ENTITY_BINS: tuple[str, ...] = ("0", "1-2", "3+")
58
+ _TIME_BUCKETS: tuple[str, ...] = ("morning", "afternoon", "evening", "night")
59
+
60
+ _UNKNOWN_QTYPE = "open_domain" # safe default if caller hands us a new label
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Data types
65
+ # ---------------------------------------------------------------------------
66
+
67
+
68
+ @dataclass(frozen=True, slots=True)
69
+ class BanditChoice:
70
+ """Result of one ``choose`` call. Immutable (D8 channel-weight bundle)."""
71
+
72
+ stratum: str
73
+ arm_id: str
74
+ weights: dict[str, float] = field(default_factory=dict)
75
+ play_id: int | None = None
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # Stratum computation
80
+ # ---------------------------------------------------------------------------
81
+
82
+
83
+ def _bin_entities(count: int) -> str:
84
+ if count <= 0:
85
+ return "0"
86
+ if count <= 2:
87
+ return "1-2"
88
+ return "3+"
89
+
90
+
91
+ def _time_bucket_from_hour(hour: int) -> str:
92
+ # wall-clock local: 05:00-11:59 morning, 12:00-16:59 afternoon,
93
+ # 17:00-20:59 evening, 21:00-04:59 night.
94
+ if 5 <= hour <= 11:
95
+ return "morning"
96
+ if 12 <= hour <= 16:
97
+ return "afternoon"
98
+ if 17 <= hour <= 20:
99
+ return "evening"
100
+ return "night"
101
+
102
+
103
+ def current_time_bucket(now: datetime | None = None) -> str:
104
+ """Return the wall-clock time bucket for ``now`` (local timezone)."""
105
+ dt = now if now is not None else datetime.now().astimezone()
106
+ return _time_bucket_from_hour(dt.hour)
107
+
108
+
109
+ def compute_stratum(context: dict[str, Any]) -> str:
110
+ """Compute the 3-tuple stratum from a context dict.
111
+
112
+ Context keys consumed:
113
+ - ``query_type`` — one of ``_QUERY_TYPES``; unknown → ``open_domain``.
114
+ - ``entity_count_bin`` — if present, used verbatim; else derived from
115
+ ``entity_count`` (int).
116
+ - ``time_bucket`` — if present, used verbatim; else derived from clock.
117
+
118
+ B4: enumerating all Cartesian products yields exactly 48 strata.
119
+ """
120
+ qtype = context.get("query_type")
121
+ if qtype not in _QUERY_TYPES:
122
+ qtype = _UNKNOWN_QTYPE
123
+
124
+ ebin = context.get("entity_count_bin")
125
+ if ebin not in _ENTITY_BINS:
126
+ try:
127
+ ecount = int(context.get("entity_count", 0))
128
+ except (TypeError, ValueError):
129
+ ecount = 0
130
+ ebin = _bin_entities(ecount)
131
+
132
+ tbucket = context.get("time_bucket")
133
+ if tbucket not in _TIME_BUCKETS:
134
+ tbucket = current_time_bucket()
135
+
136
+ return f"{qtype}|{ebin}|{tbucket}"
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Threadlocal sqlite connection (mirrors LLD-02 §4.2 recipe)
141
+ # ---------------------------------------------------------------------------
142
+
143
+
144
+ class _ConnHolder(threading.local):
145
+ conn: sqlite3.Connection | None = None
146
+ path: str | None = None
147
+
148
+
149
+ _holder = _ConnHolder()
150
+
151
+
152
+ def _conn_for(db_path: Path) -> sqlite3.Connection:
153
+ """Return a WAL-configured threadlocal connection to ``db_path``.
154
+
155
+ Reused across calls on the same thread; reopened on path change.
156
+ """
157
+ path_str = str(db_path)
158
+ existing = _holder.conn
159
+ if existing is not None and _holder.path == path_str:
160
+ return existing
161
+ if existing is not None:
162
+ try:
163
+ existing.close()
164
+ except sqlite3.Error: # pragma: no cover
165
+ pass
166
+ conn = sqlite3.connect(path_str, timeout=10.0, isolation_level=None)
167
+ try:
168
+ conn.execute("PRAGMA journal_mode=WAL")
169
+ conn.execute("PRAGMA synchronous=NORMAL")
170
+ conn.execute("PRAGMA busy_timeout=5000")
171
+ except sqlite3.Error: # pragma: no cover — best-effort
172
+ pass
173
+ conn.row_factory = sqlite3.Row
174
+ _holder.conn = conn
175
+ _holder.path = path_str
176
+ return conn
177
+
178
+
179
+ def _now_iso() -> str:
180
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
181
+
182
+
183
+ # ---------------------------------------------------------------------------
184
+ # ContextualBandit
185
+ # ---------------------------------------------------------------------------
186
+
187
+
188
+ class ContextualBandit:
189
+ """Thompson-sampling bandit over the 40-arm catalog.
190
+
191
+ One instance per ``(profile_id, db_path)`` is fine — stateless apart from
192
+ the shared posterior cache. ``choose`` is hot-path; ``update`` runs on
193
+ the reward settler worker (async).
194
+ """
195
+
196
+ def __init__(
197
+ self,
198
+ db_path: Path | str,
199
+ profile_id: str,
200
+ *,
201
+ catalog: dict[str, dict[str, float]] | None = None,
202
+ cache: _BanditCache | None = None,
203
+ alpha_cap: float = _DEFAULT_ALPHA_CAP,
204
+ ) -> None:
205
+ self._db_path = Path(db_path)
206
+ self._profile = str(profile_id)
207
+ self._catalog = catalog or ARM_CATALOG
208
+ self._cache = cache or get_shared_cache()
209
+ self._alpha_cap = float(alpha_cap)
210
+ # Fresh SystemRandom per instance; cheap, seeded from os.urandom.
211
+ self._rng = secrets.SystemRandom()
212
+
213
+ # ------------------------------------------------------------------
214
+ # choose
215
+ # ------------------------------------------------------------------
216
+
217
+ def choose(
218
+ self,
219
+ context: dict[str, Any],
220
+ query_id: str,
221
+ ) -> BanditChoice:
222
+ """Sample one arm under the context stratum; record play row.
223
+
224
+ Never raises. On DB error, returns a fallback_default choice with
225
+ ``play_id=None`` and logs at WARN level (no PII).
226
+ """
227
+ stratum = compute_stratum(context)
228
+ try:
229
+ posteriors = self._cache.get(
230
+ self._profile, stratum, self._load_stratum_posteriors,
231
+ )
232
+ except sqlite3.Error as exc:
233
+ logger.warning(
234
+ "bandit.choose: posterior load failed stratum=%s: %s",
235
+ stratum, exc,
236
+ )
237
+ posteriors = {}
238
+
239
+ arm_id = self._sample_best(posteriors)
240
+ play_id = self._insert_play(query_id, stratum, arm_id)
241
+ return BanditChoice(
242
+ stratum=stratum,
243
+ arm_id=arm_id,
244
+ weights=dict(self._catalog[arm_id]),
245
+ play_id=play_id,
246
+ )
247
+
248
+ def _sample_best(
249
+ self,
250
+ posteriors: dict[str, tuple[float, float]],
251
+ ) -> str:
252
+ """Draw one Beta sample per arm, return argmax."""
253
+ rng = self._rng # B1: secrets.SystemRandom
254
+ best_arm = _FALLBACK_ARM_ID
255
+ best_sample = float("-inf")
256
+ for arm_id in self._catalog:
257
+ a, b = posteriors.get(arm_id, (1.0, 1.0))
258
+ # Defensive: reject non-positive (shouldn't happen; we clamp on
259
+ # write, but guard against external DB tampering).
260
+ if a <= 0 or b <= 0: # pragma: no cover — defensive
261
+ a = max(a, 1.0)
262
+ b = max(b, 1.0)
263
+ try:
264
+ sample = rng.betavariate(a, b)
265
+ except ValueError: # pragma: no cover — defensive
266
+ continue
267
+ if sample > best_sample:
268
+ best_sample = sample
269
+ best_arm = arm_id
270
+ return best_arm
271
+
272
+ # ------------------------------------------------------------------
273
+ # update
274
+ # ------------------------------------------------------------------
275
+
276
+ def update(
277
+ self,
278
+ play_id: int,
279
+ reward: float,
280
+ kind: str = "proxy_position",
281
+ ) -> bool:
282
+ """Apply the reward to the (profile, stratum, arm) posterior.
283
+
284
+ Returns True on success. Never raises — DB failures logged at WARN.
285
+ Cache invalidated on success (B5).
286
+ """
287
+ try:
288
+ reward_f = float(reward)
289
+ except (TypeError, ValueError):
290
+ logger.warning("bandit.update: non-numeric reward, ignoring")
291
+ return False
292
+ # Clamp reward ∈ [0, 1].
293
+ if reward_f < 0.0:
294
+ reward_f = 0.0
295
+ elif reward_f > 1.0:
296
+ reward_f = 1.0
297
+
298
+ try:
299
+ conn = _conn_for(self._db_path)
300
+ except sqlite3.Error as exc: # pragma: no cover — defensive
301
+ logger.warning("bandit.update: cannot open db: %s", exc)
302
+ return False
303
+
304
+ try:
305
+ row = conn.execute(
306
+ "SELECT profile_id, stratum, arm_id, settled_at "
307
+ "FROM bandit_plays WHERE play_id = ?",
308
+ (int(play_id),),
309
+ ).fetchone()
310
+ except sqlite3.Error as exc:
311
+ logger.warning("bandit.update: lookup failed: %s", exc)
312
+ return False
313
+
314
+ if row is None:
315
+ logger.warning("bandit.update: play_id %s not found", play_id)
316
+ return False
317
+ if row["settled_at"] is not None:
318
+ # Idempotent no-op: already settled.
319
+ return False
320
+
321
+ profile_id = row["profile_id"]
322
+ stratum = row["stratum"]
323
+ arm_id = row["arm_id"]
324
+ now = _now_iso()
325
+ cap = self._alpha_cap
326
+
327
+ try:
328
+ # Ensure an arm row exists with prior (1,1). INSERT OR IGNORE is
329
+ # cheap — PK composite guarantees uniqueness.
330
+ conn.execute(
331
+ "INSERT OR IGNORE INTO bandit_arms "
332
+ "(profile_id, stratum, arm_id, alpha, beta, plays, "
333
+ " last_played_at) VALUES (?, ?, ?, 1.0, 1.0, 0, ?)",
334
+ (profile_id, stratum, arm_id, now),
335
+ )
336
+ # B2: MIN(cap, value) clamp.
337
+ conn.execute(
338
+ "UPDATE bandit_arms "
339
+ "SET alpha = MIN(?, alpha + ?), "
340
+ " beta = MIN(?, beta + ?), "
341
+ " plays = plays + 1, "
342
+ " last_played_at = ? "
343
+ "WHERE profile_id = ? AND stratum = ? AND arm_id = ?",
344
+ (cap, reward_f, cap, 1.0 - reward_f, now,
345
+ profile_id, stratum, arm_id),
346
+ )
347
+ conn.execute(
348
+ "UPDATE bandit_plays "
349
+ "SET reward = ?, settled_at = ?, settlement_type = ? "
350
+ "WHERE play_id = ?",
351
+ (reward_f, now, str(kind), int(play_id)),
352
+ )
353
+ except sqlite3.Error as exc:
354
+ logger.warning("bandit.update: write failed: %s", exc)
355
+ return False
356
+
357
+ # B5: invalidate the (profile, stratum) cache entry.
358
+ try:
359
+ self._cache.invalidate(profile_id, stratum)
360
+ except Exception: # pragma: no cover — defensive
361
+ pass
362
+ return True
363
+
364
+ # ------------------------------------------------------------------
365
+ # snapshot (for dashboard — LLD-04 consumer)
366
+ # ------------------------------------------------------------------
367
+
368
+ def snapshot(self, top_n: int = 5) -> dict[str, list[dict[str, Any]]]:
369
+ """Return ``{stratum → top-N arms by plays}``.
370
+
371
+ Lightweight read. Never raises — DB failures return ``{}``.
372
+ """
373
+ try:
374
+ conn = _conn_for(self._db_path)
375
+ rows = conn.execute(
376
+ "SELECT stratum, arm_id, alpha, beta, plays "
377
+ "FROM bandit_arms WHERE profile_id = ? "
378
+ "ORDER BY stratum ASC, plays DESC",
379
+ (self._profile,),
380
+ ).fetchall()
381
+ except sqlite3.Error as exc:
382
+ logger.debug("bandit.snapshot: %s", exc)
383
+ return {}
384
+
385
+ out: dict[str, list[dict[str, Any]]] = {}
386
+ for r in rows:
387
+ bucket = out.setdefault(r["stratum"], [])
388
+ if len(bucket) < int(top_n):
389
+ bucket.append({
390
+ "arm_id": r["arm_id"],
391
+ "alpha": float(r["alpha"]),
392
+ "beta": float(r["beta"]),
393
+ "plays": int(r["plays"]),
394
+ })
395
+ return out
396
+
397
+ # ------------------------------------------------------------------
398
+ # Loader for cache (executed outside the cache lock)
399
+ # ------------------------------------------------------------------
400
+
401
+ def _load_stratum_posteriors(
402
+ self,
403
+ profile_id: str,
404
+ stratum: str,
405
+ ) -> dict[str, tuple[float, float]]:
406
+ """Read ``{arm_id: (α, β)}`` for the given (profile, stratum)."""
407
+ try:
408
+ conn = _conn_for(self._db_path)
409
+ rows = conn.execute(
410
+ "SELECT arm_id, alpha, beta FROM bandit_arms "
411
+ "WHERE profile_id = ? AND stratum = ?",
412
+ (profile_id, stratum),
413
+ ).fetchall()
414
+ except sqlite3.Error as exc:
415
+ logger.debug(
416
+ "bandit._load_stratum_posteriors: %s", exc,
417
+ )
418
+ return {}
419
+ return {
420
+ r["arm_id"]: (float(r["alpha"]), float(r["beta"]))
421
+ for r in rows
422
+ }
423
+
424
+ def _insert_play(
425
+ self,
426
+ query_id: str,
427
+ stratum: str,
428
+ arm_id: str,
429
+ ) -> int | None:
430
+ """Insert a bandit_plays row; return lastrowid or None on failure.
431
+
432
+ B6: raw query text is NEVER stored — only ``query_id`` (opaque UUID).
433
+ """
434
+ try:
435
+ conn = _conn_for(self._db_path)
436
+ cur = conn.execute(
437
+ "INSERT INTO bandit_plays "
438
+ "(profile_id, query_id, stratum, arm_id, played_at) "
439
+ "VALUES (?, ?, ?, ?, ?)",
440
+ (self._profile, str(query_id), stratum, arm_id, _now_iso()),
441
+ )
442
+ return int(cur.lastrowid) if cur.lastrowid is not None else None
443
+ except sqlite3.Error as exc:
444
+ logger.debug("bandit._insert_play: %s", exc)
445
+ return None
446
+
447
+
448
+ # ---------------------------------------------------------------------------
449
+ # Retention sweep (LLD-03 §3.6 — B8)
450
+ # ---------------------------------------------------------------------------
451
+
452
+
453
+ def retention_sweep(
454
+ db_path: Path | str,
455
+ retention_days: int = 7,
456
+ *,
457
+ now: datetime | None = None,
458
+ chunk_size: int = 1000,
459
+ ) -> int:
460
+ """Delete settled bandit_plays older than ``now - retention_days``.
461
+
462
+ Only rows with ``settled_at IS NOT NULL AND settled_at < cutoff`` are
463
+ removed. Unsettled rows are NEVER touched (B8).
464
+
465
+ Returns total number of rows deleted.
466
+ """
467
+ if retention_days < 0:
468
+ raise ValueError("retention_days must be >= 0")
469
+ current = now if now is not None else datetime.now(timezone.utc)
470
+ cutoff_iso = (current - timedelta(days=int(retention_days))).isoformat(
471
+ timespec="seconds",
472
+ )
473
+ deleted_total = 0
474
+ path = Path(db_path)
475
+
476
+ # Fresh connection — sweeps may be called from arbitrary threads / the
477
+ # scheduler, not necessarily the hot-path thread.
478
+ conn = sqlite3.connect(str(path), timeout=10.0, isolation_level=None)
479
+ try:
480
+ try:
481
+ conn.execute("PRAGMA journal_mode=WAL")
482
+ conn.execute("PRAGMA busy_timeout=5000")
483
+ except sqlite3.Error: # pragma: no cover
484
+ pass
485
+
486
+ while True:
487
+ try:
488
+ cur = conn.execute(
489
+ "DELETE FROM bandit_plays "
490
+ "WHERE rowid IN ("
491
+ " SELECT rowid FROM bandit_plays "
492
+ " WHERE settled_at IS NOT NULL AND settled_at < ? "
493
+ " LIMIT ?"
494
+ ")",
495
+ (cutoff_iso, int(chunk_size)),
496
+ )
497
+ except sqlite3.Error as exc:
498
+ logger.warning("retention_sweep: delete failed: %s", exc)
499
+ break
500
+ affected = cur.rowcount or 0
501
+ if affected <= 0:
502
+ break
503
+ deleted_total += affected
504
+ # Guard against infinite loop on drivers that return -1.
505
+ if affected < int(chunk_size) and cur.rowcount != -1:
506
+ break
507
+ finally:
508
+ try:
509
+ conn.close()
510
+ except sqlite3.Error: # pragma: no cover
511
+ pass
512
+
513
+ logger.info(
514
+ "bandit_plays_retention_sweep: deleted=%d, cutoff=%s",
515
+ deleted_total, cutoff_iso,
516
+ )
517
+ return deleted_total
518
+
519
+
520
+ __all__ = (
521
+ "BanditChoice",
522
+ "ContextualBandit",
523
+ "compute_stratum",
524
+ "current_time_bucket",
525
+ "retention_sweep",
526
+ )