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.
- package/CHANGELOG.md +24 -0
- package/README.md +42 -34
- package/bin/slm +11 -0
- package/bin/slm.bat +12 -0
- package/package.json +4 -3
- package/pyproject.toml +3 -2
- package/scripts/build-slm-hook.ps1 +40 -0
- package/scripts/build-slm-hook.sh +45 -0
- package/scripts/build_entry.py +452 -0
- package/scripts/ci/stage5b_gate.sh +50 -0
- package/scripts/postinstall/validation.js +187 -0
- package/scripts/postinstall-interactive.js +756 -0
- package/scripts/postinstall_binary.js +287 -0
- package/scripts/release_manifest.py +273 -0
- package/scripts/slm-hook.spec +56 -0
- package/skills/slm-build-graph/SKILL.md +423 -0
- package/skills/slm-list-recent/SKILL.md +348 -0
- package/skills/slm-recall/SKILL.md +343 -0
- package/skills/slm-remember/SKILL.md +194 -0
- package/skills/slm-show-patterns/SKILL.md +224 -0
- package/skills/slm-status/SKILL.md +363 -0
- package/skills/slm-switch-profile/SKILL.md +442 -0
- package/src/superlocalmemory/cli/commands.py +219 -79
- package/src/superlocalmemory/cli/context_commands.py +192 -0
- package/src/superlocalmemory/cli/daemon.py +15 -1
- package/src/superlocalmemory/cli/db_migrate.py +80 -0
- package/src/superlocalmemory/cli/escape_hatch.py +220 -0
- package/src/superlocalmemory/cli/main.py +72 -1
- package/src/superlocalmemory/core/context_cache.py +397 -0
- package/src/superlocalmemory/core/engine.py +38 -2
- package/src/superlocalmemory/core/engine_wiring.py +1 -1
- package/src/superlocalmemory/core/ram_lock.py +111 -0
- package/src/superlocalmemory/core/recall_pipeline.py +433 -3
- package/src/superlocalmemory/core/recall_worker.py +8 -3
- package/src/superlocalmemory/core/security_primitives.py +635 -0
- package/src/superlocalmemory/core/shadow_router.py +319 -0
- package/src/superlocalmemory/core/slm_disabled.py +87 -0
- package/src/superlocalmemory/core/slmignore.py +125 -0
- package/src/superlocalmemory/core/topic_signature.py +143 -0
- package/src/superlocalmemory/core/worker_pool.py +14 -3
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +2 -2
- package/src/superlocalmemory/evolution/budget.py +321 -0
- package/src/superlocalmemory/evolution/llm_dispatch.py +508 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +144 -94
- package/src/superlocalmemory/hooks/_outcome_common.py +506 -0
- package/src/superlocalmemory/hooks/adapter_base.py +317 -0
- package/src/superlocalmemory/hooks/antigravity_adapter.py +192 -0
- package/src/superlocalmemory/hooks/claude_code_hooks.py +33 -1
- package/src/superlocalmemory/hooks/context_payload.py +312 -0
- package/src/superlocalmemory/hooks/copilot_adapter.py +154 -0
- package/src/superlocalmemory/hooks/cross_platform_connector.py +90 -0
- package/src/superlocalmemory/hooks/cursor_adapter.py +195 -0
- package/src/superlocalmemory/hooks/hook_handlers.py +109 -8
- package/src/superlocalmemory/hooks/ide_connector.py +25 -2
- package/src/superlocalmemory/hooks/post_tool_async_hook.py +165 -0
- package/src/superlocalmemory/hooks/post_tool_outcome_hook.py +223 -0
- package/src/superlocalmemory/hooks/prewarm_auth.py +170 -0
- package/src/superlocalmemory/hooks/session_registry.py +186 -0
- package/src/superlocalmemory/hooks/stop_outcome_hook.py +134 -0
- package/src/superlocalmemory/hooks/sync_loop.py +114 -0
- package/src/superlocalmemory/hooks/user_prompt_hook.py +128 -0
- package/src/superlocalmemory/hooks/user_prompt_rehash_hook.py +202 -0
- package/src/superlocalmemory/infra/backup.py +3 -3
- package/src/superlocalmemory/infra/cloud_backup.py +2 -2
- package/src/superlocalmemory/infra/event_bus.py +2 -2
- package/src/superlocalmemory/infra/webhook_dispatcher.py +3 -3
- package/src/superlocalmemory/learning/arm_catalog.py +99 -0
- package/src/superlocalmemory/learning/bandit.py +526 -0
- package/src/superlocalmemory/learning/bandit_cache.py +133 -0
- package/src/superlocalmemory/learning/behavioral.py +53 -1
- package/src/superlocalmemory/learning/consolidation_cycle.py +381 -0
- package/src/superlocalmemory/learning/consolidation_worker.py +188 -520
- package/src/superlocalmemory/learning/database.py +256 -0
- package/src/superlocalmemory/learning/dedup_hnsw.py +413 -0
- package/src/superlocalmemory/learning/ensemble.py +300 -0
- package/src/superlocalmemory/learning/fact_outcome_joins.py +207 -0
- package/src/superlocalmemory/learning/forgetting_scheduler.py +55 -0
- package/src/superlocalmemory/learning/hnsw_dedup.py +69 -0
- package/src/superlocalmemory/learning/labeler.py +87 -0
- package/src/superlocalmemory/learning/legacy_migration.py +277 -0
- package/src/superlocalmemory/learning/memory_merge.py +160 -0
- package/src/superlocalmemory/learning/model_cache.py +269 -0
- package/src/superlocalmemory/learning/model_rollback.py +278 -0
- package/src/superlocalmemory/learning/outcome_queue.py +284 -0
- package/src/superlocalmemory/learning/pattern_miner.py +415 -0
- package/src/superlocalmemory/learning/pattern_miner_constants.py +47 -0
- package/src/superlocalmemory/learning/ranker.py +225 -81
- package/src/superlocalmemory/learning/ranker_common.py +163 -0
- package/src/superlocalmemory/learning/ranker_retrain_legacy.py +202 -0
- package/src/superlocalmemory/learning/ranker_retrain_online.py +411 -0
- package/src/superlocalmemory/learning/reward.py +777 -0
- package/src/superlocalmemory/learning/reward_archive.py +210 -0
- package/src/superlocalmemory/learning/reward_boost.py +201 -0
- package/src/superlocalmemory/learning/reward_proxy.py +326 -0
- package/src/superlocalmemory/learning/shadow_test.py +524 -0
- package/src/superlocalmemory/learning/signal_worker.py +270 -0
- package/src/superlocalmemory/learning/signals.py +314 -0
- package/src/superlocalmemory/learning/trigram_index.py +547 -0
- package/src/superlocalmemory/mcp/server.py +5 -5
- package/src/superlocalmemory/mcp/tools_context.py +183 -0
- package/src/superlocalmemory/mcp/tools_core.py +92 -27
- package/src/superlocalmemory/parameterization/soft_prompt_generator.py +13 -0
- package/src/superlocalmemory/retrieval/engine.py +52 -0
- package/src/superlocalmemory/server/api.py +2 -2
- package/src/superlocalmemory/server/bandit_loops.py +140 -0
- package/src/superlocalmemory/server/middleware/__init__.py +11 -0
- package/src/superlocalmemory/server/middleware/security_headers.py +144 -0
- package/src/superlocalmemory/server/routes/backup.py +36 -13
- package/src/superlocalmemory/server/routes/behavioral.py +50 -19
- package/src/superlocalmemory/server/routes/brain.py +1234 -0
- package/src/superlocalmemory/server/routes/data_io.py +4 -4
- package/src/superlocalmemory/server/routes/events.py +2 -2
- package/src/superlocalmemory/server/routes/helpers.py +1 -1
- package/src/superlocalmemory/server/routes/learning.py +192 -7
- package/src/superlocalmemory/server/routes/memories.py +189 -1
- package/src/superlocalmemory/server/routes/prewarm.py +171 -0
- package/src/superlocalmemory/server/routes/profiles.py +3 -3
- package/src/superlocalmemory/server/routes/token.py +88 -0
- package/src/superlocalmemory/server/routes/ws.py +5 -5
- package/src/superlocalmemory/server/security_middleware.py +13 -7
- package/src/superlocalmemory/server/ui.py +2 -2
- package/src/superlocalmemory/server/unified_daemon.py +335 -3
- package/src/superlocalmemory/storage/migration_runner.py +545 -0
- package/src/superlocalmemory/storage/migrations/M001_add_signal_features_columns.py +67 -0
- package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +132 -0
- package/src/superlocalmemory/storage/migrations/M003_migration_log.py +38 -0
- package/src/superlocalmemory/storage/migrations/M004_cross_platform_sync_log.py +46 -0
- package/src/superlocalmemory/storage/migrations/M005_bandit_tables.py +75 -0
- package/src/superlocalmemory/storage/migrations/M006_action_outcomes_reward.py +75 -0
- package/src/superlocalmemory/storage/migrations/M007_pending_outcomes.py +63 -0
- package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +54 -0
- package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +75 -0
- package/src/superlocalmemory/storage/migrations/M011_archive_and_merge.py +87 -0
- package/src/superlocalmemory/storage/migrations/M012_shadow_observations.py +72 -0
- package/src/superlocalmemory/storage/migrations/M013_bi_temporal_columns.py +55 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +81 -0
- package/src/superlocalmemory/storage/models.py +4 -0
- package/src/superlocalmemory/ui/css/brain.css +409 -0
- package/src/superlocalmemory/ui/css/legacy-dashboard.css +645 -0
- package/src/superlocalmemory/ui/index.html +459 -1345
- package/src/superlocalmemory/ui/js/brain.js +1321 -0
- package/src/superlocalmemory/ui/js/clusters.js +123 -4
- package/src/superlocalmemory/ui/js/init.js +48 -39
- package/src/superlocalmemory/ui/js/memories.js +88 -2
- package/src/superlocalmemory/ui/js/modal.js +71 -1
- package/src/superlocalmemory/ui/js/ng-shell.js +101 -88
- package/src/superlocalmemory/ui/js/trust-dashboard.js +168 -25
- package/src/superlocalmemory/ui/vendor/bootstrap-icons/bootstrap-icons.css +2018 -0
- package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
- package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
- package/src/superlocalmemory/ui/vendor/bootstrap.bundle.min.js +7 -0
- package/src/superlocalmemory/ui/vendor/bootstrap.min.css +6 -0
- package/src/superlocalmemory/ui/vendor/d3.v7.min.js +2 -0
- package/src/superlocalmemory/ui/vendor/graphology-library.min.js +2 -0
- package/src/superlocalmemory/ui/vendor/graphology.umd.min.js +2 -0
- package/src/superlocalmemory/ui/vendor/inter-ui/inter-variable.min.css +8 -0
- package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable-Italic.woff2 +0 -0
- package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable.woff2 +0 -0
- package/src/superlocalmemory/ui/vendor/sigma.min.js +1 -0
- package/src/superlocalmemory/ui/js/behavioral.js +0 -447
- package/src/superlocalmemory/ui/js/graph-core.js +0 -447
- package/src/superlocalmemory/ui/js/graph-interactions.js +0 -351
- package/src/superlocalmemory/ui/js/learning.js +0 -435
- package/src/superlocalmemory/ui/js/patterns.js +0 -93
- package/src/superlocalmemory.egg-info/PKG-INFO +0 -647
- package/src/superlocalmemory.egg-info/SOURCES.txt +0 -335
- package/src/superlocalmemory.egg-info/dependency_links.txt +0 -1
- package/src/superlocalmemory.egg-info/entry_points.txt +0 -2
- package/src/superlocalmemory.egg-info/requires.txt +0 -58
- 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.21 — 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.21 — 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
|
+
)
|