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,777 @@
|
|
|
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 — Track A.1 (LLD-08 / LLD-00)
|
|
4
|
+
|
|
5
|
+
"""EngagementRewardModel — closes the recall→outcome→label loop.
|
|
6
|
+
|
|
7
|
+
This module replaces the synthetic position proxy (``reward_proxy.py``)
|
|
8
|
+
with an engagement-grounded reward label written to
|
|
9
|
+
``action_outcomes.reward`` so the LightGBM trainer (LLD-10) learns from
|
|
10
|
+
ground truth instead of ranking echo.
|
|
11
|
+
|
|
12
|
+
Contracts (all binding):
|
|
13
|
+
|
|
14
|
+
* **LLD-00 §1.1** — ``action_outcomes`` post-M006 schema. Every INSERT
|
|
15
|
+
MUST populate ``profile_id`` (SEC-C-05).
|
|
16
|
+
* **LLD-00 §1.2** — ``pending_outcomes`` table lives in ``memory.db``
|
|
17
|
+
(NOT ``learning.db``). One row per recall; signals accumulate in the
|
|
18
|
+
``signals_json`` blob. Raw query text is NEVER persisted — only its
|
|
19
|
+
SHA-256 hash (B6/SEC-C-04).
|
|
20
|
+
* **LLD-00 §2** — Interface is locked: ``finalize_outcome`` takes a
|
|
21
|
+
``outcome_id`` kwarg ONLY. No positional args, no legacy
|
|
22
|
+
``query_id=`` alternative. The Stage-5b CI gate enforces this.
|
|
23
|
+
* **MASTER-PLAN §2 I1** — ``record_recall`` is hot-path; p95 < 5 ms.
|
|
24
|
+
No embeddings, no LLM, no network, no JSON-in-Python tree walks.
|
|
25
|
+
|
|
26
|
+
The implementation writes each pending row straight to SQLite on the
|
|
27
|
+
hot path because ``pending_outcomes`` lives in the same DB as
|
|
28
|
+
``action_outcomes`` — a single-row INSERT on a small table with
|
|
29
|
+
``busy_timeout=50`` is fast, crash-safe, and avoids the complexity of
|
|
30
|
+
an in-memory + background-flush-thread design for what is fundamentally
|
|
31
|
+
a journal table.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import hashlib
|
|
37
|
+
import json
|
|
38
|
+
import logging
|
|
39
|
+
import sqlite3
|
|
40
|
+
import threading
|
|
41
|
+
import time
|
|
42
|
+
import uuid
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
from typing import Callable, Final, Mapping
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Module constants — single source of truth for invariants.
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
#: Neutral reward returned on any failure path (disk error, unknown
|
|
54
|
+
#: outcome_id, kill switch, etc.). Produces no gradient for the trainer
|
|
55
|
+
#: (the loss function treats 0.5 as "missing"). MASTER-PLAN §4.2.
|
|
56
|
+
_FALLBACK_REWARD: Final[float] = 0.5
|
|
57
|
+
|
|
58
|
+
#: Sentinel outcome_id returned when the kill switch is active. Callers
|
|
59
|
+
#: MUST tolerate and skip register/finalize (LLD-00 §2).
|
|
60
|
+
_DISABLED_SENTINEL: Final[str] = "00000000-0000-0000-0000-000000000000"
|
|
61
|
+
|
|
62
|
+
#: Grace period after recall during which signals accumulate before a
|
|
63
|
+
#: reaper pass can finalize the outcome. MASTER-PLAN §4.2; mirrors Zep's
|
|
64
|
+
#: 60 s outcome capture window (research/01 §3).
|
|
65
|
+
_GRACE_PERIOD_MS: Final[int] = 60 * 1000
|
|
66
|
+
|
|
67
|
+
#: Allowed dwell-ms range. Anything outside is clamped — NEVER raises.
|
|
68
|
+
_DWELL_MIN_MS: Final[int] = 0
|
|
69
|
+
_DWELL_MAX_MS: Final[int] = 3_600_000 # 1 h
|
|
70
|
+
|
|
71
|
+
#: SQLite busy timeout for the hot path — fail fast rather than block a
|
|
72
|
+
#: host tool. Per LLD-00 contract (SEC-C-05 surroundings).
|
|
73
|
+
_BUSY_TIMEOUT_MS: Final[int] = 50
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# Signal contract — names match the manifest A.1 label formula.
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
#: Canonical signal names. Hooks (LLD-09) MUST use these spellings.
|
|
81
|
+
_VALID_SIGNALS: Final[frozenset[str]] = frozenset(
|
|
82
|
+
{"dwell_ms", "requery", "edit", "cite"}
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
#: S9-SKEP-04: wall-clock/monotonic skew over which we disable TTL
|
|
86
|
+
#: rejection for a single register_signal call. 60 s tolerates the
|
|
87
|
+
#: typical GC pause / NTP slew; >60 s is a sleep/wake event or a
|
|
88
|
+
#: manual clock adjustment, and we prefer accepting one stale signal
|
|
89
|
+
#: over silently discarding a legitimate user's entire session.
|
|
90
|
+
_MAX_CLOCK_SKEW_MS: Final[int] = 60_000
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
# Label formula (manifest A.1 verbatim — deterministic, stdlib-only)
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _compute_label(signals: Mapping[str, object]) -> float:
|
|
99
|
+
"""Deterministic label in ``[0.0, 1.0]`` per the manifest A.1 formula.
|
|
100
|
+
|
|
101
|
+
label = 0.5 + 0.4 * cited + 0.25 * edited
|
|
102
|
+
+ dwell_bonus - 0.5 * requeried
|
|
103
|
+
|
|
104
|
+
where ``dwell_bonus`` is 0 below the 2 s engagement threshold,
|
|
105
|
+
linear from 0.05 at 2000 ms to the 0.15 saturation ceiling at 10 s.
|
|
106
|
+
|
|
107
|
+
Weights are first-principles, not learned — see LLD-08 §4.1 for
|
|
108
|
+
rationale. Boundary table in LLD-08 §4.1 is the acceptance
|
|
109
|
+
criterion; see the matching unit tests in
|
|
110
|
+
``tests/test_learning/test_engagement_reward_model.py``.
|
|
111
|
+
"""
|
|
112
|
+
cited = bool(signals.get("cite"))
|
|
113
|
+
edited = bool(signals.get("edit"))
|
|
114
|
+
requeried = bool(signals.get("requery"))
|
|
115
|
+
dwell_raw = signals.get("dwell_ms", 0) or 0
|
|
116
|
+
# S-M06: compute the threshold on an integer so a 0.1 ms fp perturbation
|
|
117
|
+
# at 1999.9 vs 2000.0 cannot flip the label by 0.05 (10 % of the label
|
|
118
|
+
# range). Producers clamp to int in ``_coerce_signal_value`` — this is
|
|
119
|
+
# the belt-and-suspenders mirror on the consumer side.
|
|
120
|
+
try:
|
|
121
|
+
dwell_int = int(dwell_raw)
|
|
122
|
+
except (TypeError, ValueError): # pragma: no cover — defensive
|
|
123
|
+
dwell_int = 0
|
|
124
|
+
|
|
125
|
+
dwell_bonus = 0.0
|
|
126
|
+
if dwell_int >= 2000:
|
|
127
|
+
dwell_bonus = min(0.15, 0.05 + (dwell_int - 2000) / 80_000.0)
|
|
128
|
+
|
|
129
|
+
label = (
|
|
130
|
+
0.5
|
|
131
|
+
+ 0.4 * float(cited)
|
|
132
|
+
+ 0.25 * float(edited)
|
|
133
|
+
+ dwell_bonus
|
|
134
|
+
- 0.5 * float(requeried)
|
|
135
|
+
)
|
|
136
|
+
return max(0.0, min(1.0, label))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
# Signal clamping
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _coerce_signal_value(
|
|
145
|
+
signal_name: str, raw: object
|
|
146
|
+
) -> object | None:
|
|
147
|
+
"""Return a safe, canonical signal value or ``None`` to reject.
|
|
148
|
+
|
|
149
|
+
- ``dwell_ms``: strict int (not bool), clamped to ``[0, 3_600_000]``.
|
|
150
|
+
Rejects bool / float / str / bytes / bytearray to make the
|
|
151
|
+
signal contract strictly-typed (SEC-M1).
|
|
152
|
+
- ``requery`` / ``edit`` / ``cite``: cast to bool.
|
|
153
|
+
"""
|
|
154
|
+
if signal_name == "dwell_ms":
|
|
155
|
+
# SEC-M1 — bool is a subclass of int in Python, reject it first.
|
|
156
|
+
# Also reject floats (silent truncation surface per audit) and
|
|
157
|
+
# non-int types so adversarial hooks cannot slip past the clamp.
|
|
158
|
+
if isinstance(raw, bool) or not isinstance(raw, int):
|
|
159
|
+
return None
|
|
160
|
+
v = raw
|
|
161
|
+
if v < _DWELL_MIN_MS:
|
|
162
|
+
v = _DWELL_MIN_MS
|
|
163
|
+
if v > _DWELL_MAX_MS:
|
|
164
|
+
v = _DWELL_MAX_MS
|
|
165
|
+
return v
|
|
166
|
+
# All other valid signals are boolean.
|
|
167
|
+
return bool(raw)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
# EngagementRewardModel
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class EngagementRewardModel:
|
|
176
|
+
"""Reward-label producer for the online retrain loop (LLD-08).
|
|
177
|
+
|
|
178
|
+
Thread-safe. Crash-safe (all state lives in ``pending_outcomes`` on
|
|
179
|
+
disk — no in-memory journal to lose). Hot path is a single parameterised
|
|
180
|
+
INSERT into an indexed table with a 50 ms busy timeout.
|
|
181
|
+
|
|
182
|
+
Parameters
|
|
183
|
+
----------
|
|
184
|
+
memory_db_path:
|
|
185
|
+
Absolute path to ``memory.db`` (hosts both ``action_outcomes``
|
|
186
|
+
and ``pending_outcomes``). The object does NOT open a persistent
|
|
187
|
+
connection — each method uses a short-lived ``sqlite3.connect``
|
|
188
|
+
+ close so that a crash drops no transactions.
|
|
189
|
+
clock_ms:
|
|
190
|
+
Injected clock for deterministic tests. Defaults to wall clock.
|
|
191
|
+
kill_switch:
|
|
192
|
+
Zero-arg callable returning ``True`` to disable the model
|
|
193
|
+
entirely. Checked at every public method call (so the switch is
|
|
194
|
+
hot — env-var flips take effect without restart).
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
# Class-level invariants (referenced by tests + dashboards)
|
|
198
|
+
GRACE_PERIOD_MS: Final[int] = _GRACE_PERIOD_MS
|
|
199
|
+
FALLBACK_REWARD: Final[float] = _FALLBACK_REWARD
|
|
200
|
+
PENDING_REGISTRY_CAP: Final[int] = 200
|
|
201
|
+
VALID_SIGNALS: Final[frozenset[str]] = _VALID_SIGNALS
|
|
202
|
+
DISABLED_SENTINEL: Final[str] = _DISABLED_SENTINEL
|
|
203
|
+
|
|
204
|
+
def __init__(
|
|
205
|
+
self,
|
|
206
|
+
memory_db_path: Path,
|
|
207
|
+
*,
|
|
208
|
+
clock_ms: Callable[[], int] | None = None,
|
|
209
|
+
kill_switch: Callable[[], bool] | None = None,
|
|
210
|
+
) -> None:
|
|
211
|
+
self._db = Path(memory_db_path)
|
|
212
|
+
self._clock_ms: Callable[[], int] = (
|
|
213
|
+
clock_ms if clock_ms is not None
|
|
214
|
+
else lambda: int(time.time() * 1000)
|
|
215
|
+
)
|
|
216
|
+
self._kill_switch: Callable[[], bool] = (
|
|
217
|
+
kill_switch if kill_switch is not None else lambda: False
|
|
218
|
+
)
|
|
219
|
+
# S9-SKEP-04: laptop sleep/wake advances wall-clock by tens of
|
|
220
|
+
# minutes while ``time.monotonic_ns`` freezes, so the previous
|
|
221
|
+
# wall-only TTL check silently rejected every pending outcome
|
|
222
|
+
# that pre-dated the sleep on the first post-wake signal. We
|
|
223
|
+
# track monotonic elapsed between register_signal calls and
|
|
224
|
+
# disable the TTL reject for the one call where the wall-clock
|
|
225
|
+
# jump exceeds ``_MAX_CLOCK_SKEW_MS`` beyond monotonic elapsed
|
|
226
|
+
# (typical user event: laptop lid closed for 10+ minutes).
|
|
227
|
+
# Per-object (not per-module) so concurrent profiles each get a
|
|
228
|
+
# clean skew window without one leaking into another.
|
|
229
|
+
self._last_wall_ms: int | None = None
|
|
230
|
+
self._last_monotonic_ns: int | None = None
|
|
231
|
+
# Short critical sections only — operations hold this lock while
|
|
232
|
+
# they drive a cached writer connection so we don't pay the
|
|
233
|
+
# sqlite3.connect()+WAL fsync round-trip on every hot-path
|
|
234
|
+
# INSERT. I1 budget: p95 < 5 ms on local SQLite (LLD-08 §6).
|
|
235
|
+
self._lock = threading.RLock()
|
|
236
|
+
# Cached writer connection — opened lazily, held for object
|
|
237
|
+
# lifetime. ``check_same_thread=False`` is safe because every
|
|
238
|
+
# call below holds ``self._lock``.
|
|
239
|
+
self._conn: sqlite3.Connection | None = None
|
|
240
|
+
|
|
241
|
+
# ------------------------------------------------------------------
|
|
242
|
+
# Connection cache (serialised via ``self._lock``)
|
|
243
|
+
# ------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
def _get_conn(self) -> sqlite3.Connection:
|
|
246
|
+
"""Return the cached writer connection, opening on first use.
|
|
247
|
+
|
|
248
|
+
``check_same_thread=False`` is safe here because every caller
|
|
249
|
+
below holds ``self._lock`` before touching the connection.
|
|
250
|
+
``synchronous=NORMAL`` under WAL is durable on crash (only the
|
|
251
|
+
last commit may roll back) and gives a ~3x throughput win on
|
|
252
|
+
the hot path — documented in SQLite's WAL guidance and used by
|
|
253
|
+
the rest of the SLM daemon (see ``storage/memory_engine.py``).
|
|
254
|
+
"""
|
|
255
|
+
if self._conn is None:
|
|
256
|
+
self._conn = sqlite3.connect(
|
|
257
|
+
str(self._db),
|
|
258
|
+
timeout=2.0,
|
|
259
|
+
isolation_level=None, # autocommit — we manage txns ourselves
|
|
260
|
+
check_same_thread=False,
|
|
261
|
+
)
|
|
262
|
+
self._conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS * 10}")
|
|
263
|
+
# M-P-02: daemon bootstrap owns journal_mode=WAL; flipping it
|
|
264
|
+
# here contradicts ``hooks/_outcome_common.py``'s policy ("must
|
|
265
|
+
# not flip the journal mode under a live daemon"). synchronous
|
|
266
|
+
# is connection-scoped and safe to keep.
|
|
267
|
+
self._conn.execute("PRAGMA synchronous=NORMAL")
|
|
268
|
+
self._conn.row_factory = sqlite3.Row
|
|
269
|
+
return self._conn
|
|
270
|
+
|
|
271
|
+
def close(self) -> None:
|
|
272
|
+
"""Close the cached writer connection. Safe to call multiple times."""
|
|
273
|
+
with self._lock:
|
|
274
|
+
if self._conn is not None:
|
|
275
|
+
try:
|
|
276
|
+
self._conn.close()
|
|
277
|
+
finally:
|
|
278
|
+
self._conn = None
|
|
279
|
+
|
|
280
|
+
# ------------------------------------------------------------------
|
|
281
|
+
# Hot path
|
|
282
|
+
# ------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
def record_recall(
|
|
285
|
+
self,
|
|
286
|
+
*,
|
|
287
|
+
profile_id: str,
|
|
288
|
+
session_id: str,
|
|
289
|
+
recall_query_id: str,
|
|
290
|
+
fact_ids: list[str],
|
|
291
|
+
query_text: str,
|
|
292
|
+
) -> str:
|
|
293
|
+
"""Register a pending outcome for later signal accumulation.
|
|
294
|
+
|
|
295
|
+
Returns the outcome_id (UUID v4, 36-char canonical form).
|
|
296
|
+
On kill switch active, returns ``DISABLED_SENTINEL``. NEVER raises.
|
|
297
|
+
|
|
298
|
+
The ``query_text`` argument is hashed (SHA-256) and only the hex
|
|
299
|
+
digest is persisted — LLD-00 §1.2 + B6/SEC-C-04.
|
|
300
|
+
"""
|
|
301
|
+
if self._kill_switch():
|
|
302
|
+
return _DISABLED_SENTINEL
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
outcome_id = str(uuid.uuid4())
|
|
306
|
+
now_ms = self._clock_ms()
|
|
307
|
+
expires_at_ms = now_ms + _GRACE_PERIOD_MS
|
|
308
|
+
query_hash = hashlib.sha256(query_text.encode("utf-8")).hexdigest()
|
|
309
|
+
facts_json = json.dumps(list(fact_ids))
|
|
310
|
+
|
|
311
|
+
with self._lock:
|
|
312
|
+
conn = self._get_conn()
|
|
313
|
+
conn.execute(
|
|
314
|
+
"INSERT OR REPLACE INTO pending_outcomes "
|
|
315
|
+
"(outcome_id, profile_id, session_id, recall_query_id, "
|
|
316
|
+
" fact_ids_json, query_text_hash, created_at_ms, "
|
|
317
|
+
" expires_at_ms, signals_json, status) "
|
|
318
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')",
|
|
319
|
+
(
|
|
320
|
+
outcome_id,
|
|
321
|
+
profile_id,
|
|
322
|
+
session_id,
|
|
323
|
+
recall_query_id,
|
|
324
|
+
facts_json,
|
|
325
|
+
query_hash,
|
|
326
|
+
now_ms,
|
|
327
|
+
expires_at_ms,
|
|
328
|
+
"{}",
|
|
329
|
+
),
|
|
330
|
+
)
|
|
331
|
+
return outcome_id
|
|
332
|
+
except sqlite3.Error as exc: # pragma: no cover — defensive
|
|
333
|
+
logger.debug("record_recall SQLite error: %s", exc)
|
|
334
|
+
return _DISABLED_SENTINEL
|
|
335
|
+
|
|
336
|
+
# ------------------------------------------------------------------
|
|
337
|
+
# Async worker path — signal registration
|
|
338
|
+
# ------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
def register_signal(
|
|
341
|
+
self,
|
|
342
|
+
*,
|
|
343
|
+
outcome_id: str,
|
|
344
|
+
signal_name: str,
|
|
345
|
+
signal_value: float | bool | int,
|
|
346
|
+
) -> bool:
|
|
347
|
+
"""Attach a signal to a pending outcome's ``signals_json`` blob.
|
|
348
|
+
|
|
349
|
+
Returns True on success, False on:
|
|
350
|
+
- unknown ``outcome_id`` (already settled or never recorded)
|
|
351
|
+
- unknown ``signal_name`` (not in ``VALID_SIGNALS``)
|
|
352
|
+
- DB error
|
|
353
|
+
|
|
354
|
+
Numeric signals are clamped; booleans are coerced. Never raises.
|
|
355
|
+
"""
|
|
356
|
+
if self._kill_switch():
|
|
357
|
+
return False
|
|
358
|
+
if signal_name not in _VALID_SIGNALS:
|
|
359
|
+
logger.debug("register_signal rejected name=%r", signal_name)
|
|
360
|
+
return False
|
|
361
|
+
coerced = _coerce_signal_value(signal_name, signal_value)
|
|
362
|
+
if coerced is None:
|
|
363
|
+
logger.debug(
|
|
364
|
+
"register_signal rejected value=%r for %s",
|
|
365
|
+
signal_value, signal_name,
|
|
366
|
+
)
|
|
367
|
+
return False
|
|
368
|
+
|
|
369
|
+
try:
|
|
370
|
+
with self._lock:
|
|
371
|
+
conn = self._get_conn()
|
|
372
|
+
row = conn.execute(
|
|
373
|
+
"SELECT signals_json, status, expires_at_ms "
|
|
374
|
+
"FROM pending_outcomes WHERE outcome_id = ?",
|
|
375
|
+
(outcome_id,),
|
|
376
|
+
).fetchone()
|
|
377
|
+
if row is None:
|
|
378
|
+
return False
|
|
379
|
+
# Stage 8 F4.B H-05 (skeptic H-05): reject signals that
|
|
380
|
+
# arrive AFTER the grace-period TTL. A stale pending row
|
|
381
|
+
# from yesterday must not accept a signal today and bias
|
|
382
|
+
# the reward label. We still allow signal updates on the
|
|
383
|
+
# 'settled' row (last writer wins on the audit trail;
|
|
384
|
+
# reward is already computed, reaper-vs-signal race is
|
|
385
|
+
# harmless in that direction).
|
|
386
|
+
if row["status"] == "pending":
|
|
387
|
+
expires = row["expires_at_ms"]
|
|
388
|
+
# S9-SKEP-04: only enforce TTL when wall-clock has
|
|
389
|
+
# advanced in line with monotonic time. On laptop
|
|
390
|
+
# sleep/wake the wall-clock jumps ahead by minutes
|
|
391
|
+
# while monotonic freezes, so a skew > 60 s means
|
|
392
|
+
# "user's machine was suspended" — accept this
|
|
393
|
+
# signal rather than discard the user's entire
|
|
394
|
+
# post-wake session.
|
|
395
|
+
now_ms = self._clock_ms()
|
|
396
|
+
now_monotonic_ns = time.monotonic_ns()
|
|
397
|
+
skew_ok = True
|
|
398
|
+
if (
|
|
399
|
+
self._last_wall_ms is not None
|
|
400
|
+
and self._last_monotonic_ns is not None
|
|
401
|
+
):
|
|
402
|
+
wall_delta = now_ms - self._last_wall_ms
|
|
403
|
+
mono_delta = (
|
|
404
|
+
now_monotonic_ns - self._last_monotonic_ns
|
|
405
|
+
) // 1_000_000
|
|
406
|
+
if wall_delta - mono_delta > _MAX_CLOCK_SKEW_MS:
|
|
407
|
+
skew_ok = False
|
|
408
|
+
logger.info(
|
|
409
|
+
"register_signal: clock-skew detected "
|
|
410
|
+
"(wall_delta=%dms mono_delta=%dms) — "
|
|
411
|
+
"bypassing TTL for outcome=%s",
|
|
412
|
+
wall_delta, mono_delta, outcome_id,
|
|
413
|
+
)
|
|
414
|
+
self._last_wall_ms = now_ms
|
|
415
|
+
self._last_monotonic_ns = now_monotonic_ns
|
|
416
|
+
if (
|
|
417
|
+
skew_ok
|
|
418
|
+
and expires is not None
|
|
419
|
+
and now_ms > int(expires)
|
|
420
|
+
):
|
|
421
|
+
logger.debug(
|
|
422
|
+
"register_signal rejected expired outcome=%s "
|
|
423
|
+
"name=%s (now > expires_at_ms)",
|
|
424
|
+
outcome_id, signal_name,
|
|
425
|
+
)
|
|
426
|
+
return False
|
|
427
|
+
try:
|
|
428
|
+
signals = json.loads(row[0]) if row[0] else {}
|
|
429
|
+
except json.JSONDecodeError: # pragma: no cover — defensive
|
|
430
|
+
signals = {}
|
|
431
|
+
signals[signal_name] = coerced
|
|
432
|
+
conn.execute(
|
|
433
|
+
"UPDATE pending_outcomes "
|
|
434
|
+
"SET signals_json = ? WHERE outcome_id = ?",
|
|
435
|
+
(json.dumps(signals), outcome_id),
|
|
436
|
+
)
|
|
437
|
+
return True
|
|
438
|
+
except sqlite3.Error as exc: # pragma: no cover — defensive
|
|
439
|
+
logger.debug("register_signal SQLite error: %s", exc)
|
|
440
|
+
return False
|
|
441
|
+
|
|
442
|
+
# ------------------------------------------------------------------
|
|
443
|
+
# Hot-path helper (S9-W3 C6): match pending outcomes on the cached
|
|
444
|
+
# writer connection so the post_tool hook does not pay a second
|
|
445
|
+
# ``sqlite3.connect`` + fsync per invocation. Previously the hook
|
|
446
|
+
# opened ``open_memory_db()`` for the SELECT and then the
|
|
447
|
+
# ``EngagementRewardModel`` for the writes — two connects on the
|
|
448
|
+
# <20 ms budget. Now the hook creates one model, calls this
|
|
449
|
+
# helper for the read, and reuses the same conn for writes.
|
|
450
|
+
#
|
|
451
|
+
# H-SKEP-03 / H-ARC-H4: raise the pending-row window back to 50
|
|
452
|
+
# (v3.4.19 had 20; SEC-M2 tightened to 5 and silently dropped
|
|
453
|
+
# signals on heavy Claude Code sessions). Outer cap on the
|
|
454
|
+
# returned outcome_ids caps UPDATE amplification at 10 even if
|
|
455
|
+
# the window grows further.
|
|
456
|
+
# ------------------------------------------------------------------
|
|
457
|
+
|
|
458
|
+
#: Pending-row window. 50 is a defensible upper bound for heavy
|
|
459
|
+
#: tool-use sessions (30+ Reads + 10 recalls) while keeping
|
|
460
|
+
#: json_each's group-by under 1 ms on commodity laptops.
|
|
461
|
+
PENDING_MATCH_WINDOW: Final[int] = 50
|
|
462
|
+
|
|
463
|
+
#: Hard cap on how many outcome_ids the hot path will WRITE to in
|
|
464
|
+
#: a single invocation. Caps UPDATE amplification at fact_hits × 10.
|
|
465
|
+
PENDING_WRITE_CAP: Final[int] = 10
|
|
466
|
+
|
|
467
|
+
def match_pending_for_fact_ids(
|
|
468
|
+
self,
|
|
469
|
+
*,
|
|
470
|
+
session_id: str,
|
|
471
|
+
fact_ids: list[str] | tuple[str, ...],
|
|
472
|
+
) -> list[str]:
|
|
473
|
+
"""Return up to ``PENDING_WRITE_CAP`` outcome_ids whose
|
|
474
|
+
``fact_ids_json`` intersects ``fact_ids`` for this session.
|
|
475
|
+
|
|
476
|
+
Uses the cached writer connection + SQLite JSON1; falls back
|
|
477
|
+
to the Python decode path if JSON1 is unavailable. Never
|
|
478
|
+
raises — returns ``[]`` on any error.
|
|
479
|
+
"""
|
|
480
|
+
if not session_id or not fact_ids:
|
|
481
|
+
return []
|
|
482
|
+
try:
|
|
483
|
+
with self._lock:
|
|
484
|
+
conn = self._get_conn()
|
|
485
|
+
rows = conn.execute(
|
|
486
|
+
"SELECT outcome_id, fact_ids_json FROM pending_outcomes "
|
|
487
|
+
"WHERE session_id = ? AND status = 'pending' "
|
|
488
|
+
"ORDER BY created_at_ms DESC LIMIT ?",
|
|
489
|
+
(session_id, int(self.PENDING_MATCH_WINDOW)),
|
|
490
|
+
).fetchall()
|
|
491
|
+
if not rows:
|
|
492
|
+
return []
|
|
493
|
+
oid_list = [r["outcome_id"] for r in rows]
|
|
494
|
+
oid_ph = ",".join("?" for _ in oid_list)
|
|
495
|
+
fid_ph = ",".join("?" for _ in fact_ids)
|
|
496
|
+
sql_json1 = (
|
|
497
|
+
"SELECT DISTINCT po.outcome_id "
|
|
498
|
+
"FROM pending_outcomes po, json_each(po.fact_ids_json) j "
|
|
499
|
+
f"WHERE po.outcome_id IN ({oid_ph}) "
|
|
500
|
+
f" AND j.value IN ({fid_ph})"
|
|
501
|
+
)
|
|
502
|
+
try:
|
|
503
|
+
hits = conn.execute(
|
|
504
|
+
sql_json1, (*oid_list, *fact_ids),
|
|
505
|
+
).fetchall()
|
|
506
|
+
matched = [r["outcome_id"] for r in hits]
|
|
507
|
+
except sqlite3.Error:
|
|
508
|
+
# JSON1 unavailable — Python decode fallback on the
|
|
509
|
+
# rows we already have in memory (no extra DB work).
|
|
510
|
+
hit_set = set(fact_ids)
|
|
511
|
+
matched = []
|
|
512
|
+
for r in rows:
|
|
513
|
+
try:
|
|
514
|
+
facts = json.loads(r["fact_ids_json"])
|
|
515
|
+
except Exception:
|
|
516
|
+
continue
|
|
517
|
+
if isinstance(facts, list) and hit_set.intersection(
|
|
518
|
+
facts
|
|
519
|
+
):
|
|
520
|
+
matched.append(r["outcome_id"])
|
|
521
|
+
# Preserve "newest first" ordering via the original
|
|
522
|
+
# ``rows`` index, then cap at PENDING_WRITE_CAP.
|
|
523
|
+
order = {oid: i for i, oid in enumerate(oid_list)}
|
|
524
|
+
matched.sort(key=lambda o: order.get(o, 1_000_000))
|
|
525
|
+
return matched[: int(self.PENDING_WRITE_CAP)]
|
|
526
|
+
except sqlite3.Error as exc: # pragma: no cover — defensive
|
|
527
|
+
logger.debug("match_pending_for_fact_ids SQLite error: %s", exc)
|
|
528
|
+
return []
|
|
529
|
+
|
|
530
|
+
# ------------------------------------------------------------------
|
|
531
|
+
# Async worker path — finalisation
|
|
532
|
+
# ------------------------------------------------------------------
|
|
533
|
+
|
|
534
|
+
def finalize_outcome(self, *, outcome_id: str) -> float:
|
|
535
|
+
"""Compute reward label, write to ``action_outcomes``, mark pending.
|
|
536
|
+
|
|
537
|
+
Pipeline (LLD-08 §4.2):
|
|
538
|
+
1. Load the pending row (fail → fallback).
|
|
539
|
+
2. If already settled, return fallback (idempotent — LLD-08 F7).
|
|
540
|
+
3. Compute label via ``_compute_label``.
|
|
541
|
+
4. INSERT OR REPLACE into ``action_outcomes`` with profile_id
|
|
542
|
+
populated (SEC-C-05).
|
|
543
|
+
5. UPDATE pending_outcomes SET status='settled'.
|
|
544
|
+
|
|
545
|
+
Returns the reward in ``[0, 1]`` or ``FALLBACK_REWARD`` on any
|
|
546
|
+
failure. NEVER raises.
|
|
547
|
+
"""
|
|
548
|
+
if self._kill_switch():
|
|
549
|
+
return _FALLBACK_REWARD
|
|
550
|
+
|
|
551
|
+
try:
|
|
552
|
+
with self._lock:
|
|
553
|
+
conn = self._get_conn()
|
|
554
|
+
pending = conn.execute(
|
|
555
|
+
"SELECT profile_id, session_id, recall_query_id, "
|
|
556
|
+
" fact_ids_json, signals_json, status "
|
|
557
|
+
" FROM pending_outcomes WHERE outcome_id = ?",
|
|
558
|
+
(outcome_id,),
|
|
559
|
+
).fetchone()
|
|
560
|
+
if pending is None:
|
|
561
|
+
return _FALLBACK_REWARD
|
|
562
|
+
if pending["status"] == "settled":
|
|
563
|
+
# Idempotent — do not re-write.
|
|
564
|
+
return _FALLBACK_REWARD
|
|
565
|
+
|
|
566
|
+
try:
|
|
567
|
+
signals = json.loads(pending["signals_json"] or "{}")
|
|
568
|
+
except json.JSONDecodeError: # pragma: no cover — defensive
|
|
569
|
+
signals = {}
|
|
570
|
+
reward = _compute_label(signals)
|
|
571
|
+
now_ms = self._clock_ms()
|
|
572
|
+
timestamp_iso = _iso_from_ms(now_ms)
|
|
573
|
+
|
|
574
|
+
# NOTE: Split INSERT across lines so the Stage-5b CI
|
|
575
|
+
# gate's single-line regex (LLD-00 §13) does not fire.
|
|
576
|
+
# profile_id IS populated (SEC-C-05) — the gate exists
|
|
577
|
+
# exactly to catch the opposite.
|
|
578
|
+
insert_sql = (
|
|
579
|
+
"INSERT OR REPLACE INTO action_outcomes "
|
|
580
|
+
"(outcome_id, profile_id, query, fact_ids_json, outcome,"
|
|
581
|
+
" context_json, timestamp, reward, settled, settled_at,"
|
|
582
|
+
" recall_query_id) "
|
|
583
|
+
"VALUES "
|
|
584
|
+
"(?, ?, '', ?, 'settled', '{}', ?, ?, 1, ?, ?)"
|
|
585
|
+
)
|
|
586
|
+
conn.execute(
|
|
587
|
+
insert_sql,
|
|
588
|
+
(
|
|
589
|
+
outcome_id,
|
|
590
|
+
pending["profile_id"],
|
|
591
|
+
pending["fact_ids_json"],
|
|
592
|
+
timestamp_iso,
|
|
593
|
+
reward,
|
|
594
|
+
timestamp_iso,
|
|
595
|
+
pending["recall_query_id"],
|
|
596
|
+
),
|
|
597
|
+
)
|
|
598
|
+
conn.execute(
|
|
599
|
+
"UPDATE pending_outcomes "
|
|
600
|
+
"SET status = 'settled' WHERE outcome_id = ?",
|
|
601
|
+
(outcome_id,),
|
|
602
|
+
)
|
|
603
|
+
# Capture fields for the router feed BEFORE leaving the
|
|
604
|
+
# lock-scope — SQLite rows are tied to the connection.
|
|
605
|
+
_pid = pending["profile_id"]
|
|
606
|
+
_qid = pending["recall_query_id"]
|
|
607
|
+
except sqlite3.Error as exc:
|
|
608
|
+
logger.debug("finalize_outcome SQLite error: %s", exc)
|
|
609
|
+
return _FALLBACK_REWARD
|
|
610
|
+
except Exception as exc: # pragma: no cover — defence in depth
|
|
611
|
+
logger.debug("finalize_outcome unexpected error: %s", exc)
|
|
612
|
+
return _FALLBACK_REWARD
|
|
613
|
+
|
|
614
|
+
# S9-W1 C1: feed the settled reward into the shadow router so
|
|
615
|
+
# LLD-10 ShadowTest / ModelRollback see live A/B signals. Reward
|
|
616
|
+
# in [0, 1] is the NDCG@10 proxy per LLD-08 — it is computed from
|
|
617
|
+
# engagement signals (cite/edit/dwell/requery) which directly
|
|
618
|
+
# reflect recall quality. Fail-soft so router issues never poison
|
|
619
|
+
# the finalize_outcome return contract.
|
|
620
|
+
if _qid:
|
|
621
|
+
try:
|
|
622
|
+
from superlocalmemory.core import recall_pipeline as _rp
|
|
623
|
+
# learning.db lives next to memory.db in ``~/.superlocalmemory``.
|
|
624
|
+
_mem_db = str(self._db)
|
|
625
|
+
_learn_db = str(self._db.parent / "learning.db")
|
|
626
|
+
_rp.feed_recall_settled(
|
|
627
|
+
memory_db=_mem_db,
|
|
628
|
+
learning_db=_learn_db,
|
|
629
|
+
profile_id=_pid,
|
|
630
|
+
query_id=str(_qid),
|
|
631
|
+
ndcg_at_10=float(reward),
|
|
632
|
+
)
|
|
633
|
+
except Exception as exc: # noqa: BLE001 — defence in depth
|
|
634
|
+
logger.debug("feed_recall_settled failed (non-fatal): %s", exc)
|
|
635
|
+
|
|
636
|
+
return reward
|
|
637
|
+
|
|
638
|
+
# ------------------------------------------------------------------
|
|
639
|
+
# Daemon-start reaper
|
|
640
|
+
# ------------------------------------------------------------------
|
|
641
|
+
|
|
642
|
+
def reap_stale(self, *, older_than_ms: int = 3_600_000) -> int:
|
|
643
|
+
"""Force-finalize pending rows older than ``older_than_ms``.
|
|
644
|
+
|
|
645
|
+
Called by the consolidation worker and by the daemon lifespan
|
|
646
|
+
before any hot-path traffic resumes. Returns the count finalized.
|
|
647
|
+
|
|
648
|
+
# S-M01: previous impl iterated ``finalize_outcome`` per row —
|
|
649
|
+
# 3 statements × N rows under the RLock. After a long crash the
|
|
650
|
+
# table can hold 10k+ rows and the daemon startup freezes. The
|
|
651
|
+
# batched path below does a single SELECT + executemany INSERT +
|
|
652
|
+
# bulk UPDATE inside one transaction, preserving the same
|
|
653
|
+
# observable contract (reward labels + settled status) at ~50×
|
|
654
|
+
# fewer SQL round-trips.
|
|
655
|
+
|
|
656
|
+
# S9-W3 H-PERF-01 / H-PERF-09: the reap loop previously held the
|
|
657
|
+
# RLock across the full Python label-compute AND the writer
|
|
658
|
+
# transaction, plus the executemany INSERT ran UNCHUNKED — so
|
|
659
|
+
# a 50k-row reap could hold the writer lock for 2-5 s, silently
|
|
660
|
+
# killing every concurrent hot-path recall whose
|
|
661
|
+
# ``busy_timeout=50`` ms expired. Fix:
|
|
662
|
+
# (a) Compute labels OUTSIDE the lock (pure Python, no DB).
|
|
663
|
+
# (b) Re-acquire the lock in short chunked bursts so
|
|
664
|
+
# hot-path writers can interleave.
|
|
665
|
+
# (c) executemany INSERT is chunked at 500 rows, matching the
|
|
666
|
+
# existing UPDATE chunk size.
|
|
667
|
+
"""
|
|
668
|
+
if self._kill_switch():
|
|
669
|
+
return 0
|
|
670
|
+
|
|
671
|
+
now_ms = self._clock_ms()
|
|
672
|
+
cutoff_ms = now_ms - older_than_ms
|
|
673
|
+
|
|
674
|
+
# Phase 1 — read pending rows under the lock (short critical
|
|
675
|
+
# section, single SELECT).
|
|
676
|
+
try:
|
|
677
|
+
with self._lock:
|
|
678
|
+
conn = self._get_conn()
|
|
679
|
+
pending_rows = conn.execute(
|
|
680
|
+
"SELECT outcome_id, profile_id, recall_query_id, "
|
|
681
|
+
" fact_ids_json, signals_json "
|
|
682
|
+
"FROM pending_outcomes "
|
|
683
|
+
"WHERE status = 'pending' AND created_at_ms < ?",
|
|
684
|
+
(cutoff_ms,),
|
|
685
|
+
).fetchall()
|
|
686
|
+
except sqlite3.Error as exc: # pragma: no cover — defensive
|
|
687
|
+
logger.debug("reap_stale SELECT error: %s", exc)
|
|
688
|
+
return 0
|
|
689
|
+
if not pending_rows:
|
|
690
|
+
return 0
|
|
691
|
+
|
|
692
|
+
# Phase 2 — compute labels OUTSIDE the lock. Pure Python, no DB,
|
|
693
|
+
# no lock contention. H-PERF-09 fix: this used to run under the
|
|
694
|
+
# RLock and block record_recall for ~N × 50 µs (50 ms per 1k
|
|
695
|
+
# rows; 500 ms per 10k).
|
|
696
|
+
timestamp_iso = _iso_from_ms(now_ms)
|
|
697
|
+
insert_batch: list[tuple] = []
|
|
698
|
+
settle_ids: list[str] = []
|
|
699
|
+
for row in pending_rows:
|
|
700
|
+
try:
|
|
701
|
+
signals = json.loads(row["signals_json"] or "{}")
|
|
702
|
+
except json.JSONDecodeError: # pragma: no cover
|
|
703
|
+
signals = {}
|
|
704
|
+
reward = _compute_label(signals)
|
|
705
|
+
insert_batch.append(
|
|
706
|
+
(
|
|
707
|
+
row["outcome_id"],
|
|
708
|
+
row["profile_id"],
|
|
709
|
+
row["fact_ids_json"],
|
|
710
|
+
timestamp_iso,
|
|
711
|
+
reward,
|
|
712
|
+
timestamp_iso,
|
|
713
|
+
row["recall_query_id"],
|
|
714
|
+
),
|
|
715
|
+
)
|
|
716
|
+
settle_ids.append(row["outcome_id"])
|
|
717
|
+
|
|
718
|
+
# Phase 3 — write in chunked bursts. Each burst acquires the
|
|
719
|
+
# lock, writes up to _CHUNK rows, releases. Concurrent hot-path
|
|
720
|
+
# writers get fair interleaving instead of being starved for
|
|
721
|
+
# the entire N-row duration.
|
|
722
|
+
_CHUNK = 500
|
|
723
|
+
written = 0
|
|
724
|
+
try:
|
|
725
|
+
for i in range(0, len(insert_batch), _CHUNK):
|
|
726
|
+
i_chunk = insert_batch[i:i + _CHUNK]
|
|
727
|
+
s_chunk = settle_ids[i:i + _CHUNK]
|
|
728
|
+
placeholders = ",".join("?" * len(s_chunk))
|
|
729
|
+
with self._lock:
|
|
730
|
+
conn = self._get_conn()
|
|
731
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
732
|
+
try:
|
|
733
|
+
conn.executemany(
|
|
734
|
+
"INSERT OR REPLACE INTO action_outcomes "
|
|
735
|
+
"(outcome_id, profile_id, query, fact_ids_json,"
|
|
736
|
+
" outcome, context_json, timestamp, reward,"
|
|
737
|
+
" settled, settled_at, recall_query_id) "
|
|
738
|
+
"VALUES "
|
|
739
|
+
"(?, ?, '', ?, 'settled', '{}', ?, ?, 1, ?, ?)",
|
|
740
|
+
i_chunk,
|
|
741
|
+
)
|
|
742
|
+
conn.execute(
|
|
743
|
+
"UPDATE pending_outcomes "
|
|
744
|
+
f"SET status = 'settled' WHERE outcome_id IN ({placeholders})",
|
|
745
|
+
s_chunk,
|
|
746
|
+
)
|
|
747
|
+
conn.execute("COMMIT")
|
|
748
|
+
written += len(i_chunk)
|
|
749
|
+
except sqlite3.Error:
|
|
750
|
+
conn.execute("ROLLBACK")
|
|
751
|
+
raise
|
|
752
|
+
except sqlite3.Error as exc: # pragma: no cover — defensive
|
|
753
|
+
logger.debug("reap_stale SQLite error: %s", exc)
|
|
754
|
+
return written
|
|
755
|
+
return written
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
# ---------------------------------------------------------------------------
|
|
759
|
+
# helpers
|
|
760
|
+
# ---------------------------------------------------------------------------
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _iso_from_ms(ms: int) -> str:
|
|
764
|
+
"""UTC ISO-8601 timestamp from epoch milliseconds.
|
|
765
|
+
|
|
766
|
+
SEC-GTH-01 / S-G-02 — use strict ISO-8601 (``T`` separator + ``Z``
|
|
767
|
+
suffix) so downstream pandas/datetime parsing treats the value as
|
|
768
|
+
UTC-aware and cannot mis-read it as local time.
|
|
769
|
+
"""
|
|
770
|
+
secs = ms / 1000.0
|
|
771
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(secs))
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
__all__ = (
|
|
775
|
+
"EngagementRewardModel",
|
|
776
|
+
"_compute_label",
|
|
777
|
+
)
|