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,300 @@
|
|
|
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.4
|
|
4
|
+
|
|
5
|
+
"""Bandit / LightGBM ensemble blender.
|
|
6
|
+
|
|
7
|
+
LLD reference: ``.backup/active-brain/lld/LLD-03-contextual-bandit-and-ensemble.md``
|
|
8
|
+
Section 5.4.
|
|
9
|
+
|
|
10
|
+
D8 blend policy (``choose_ensemble``):
|
|
11
|
+
- 0..199 signals OR model is None → ``EnsembleWeights(1.0, 0.0)`` (bandit-only).
|
|
12
|
+
- 200..499 signals + model → ``EnsembleWeights(0.4, 0.6)`` (warm blend).
|
|
13
|
+
- 500+ signals + model → ``EnsembleWeights(0.2, 0.8)`` (mature).
|
|
14
|
+
|
|
15
|
+
Hard rules:
|
|
16
|
+
- E1: ``bandit + lgbm == 1.0`` — asserted at construction.
|
|
17
|
+
- E2: ``booster.predict`` called exactly ONCE per rerank (batched).
|
|
18
|
+
- E3: no predict call when ``lgbm_weight == 0.0`` or ``model is None``.
|
|
19
|
+
- E4: both score streams normalised to [0, 1] before blending.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
import os
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import Any, Sequence
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Thresholds come from env for ops-time override; defaults match LLD-03 §10.
|
|
33
|
+
_MIN_SIGNALS = int(os.environ.get("SLM_ENSEMBLE_LGBM_MIN_SIGNALS", "200"))
|
|
34
|
+
_DOMINANT_SIGNALS = int(
|
|
35
|
+
os.environ.get("SLM_ENSEMBLE_DOMINANT_MIN_SIGNALS", "500")
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _parse_blend(value: str, fallback: tuple[float, float]) -> tuple[float, float]:
|
|
40
|
+
"""Parse 'bandit:lgbm' env var into a (bandit, lgbm) tuple."""
|
|
41
|
+
try:
|
|
42
|
+
a_s, b_s = value.split(":", 1)
|
|
43
|
+
a, b = float(a_s), float(b_s)
|
|
44
|
+
if abs((a + b) - 1.0) > 1e-6:
|
|
45
|
+
return fallback
|
|
46
|
+
return (a, b)
|
|
47
|
+
except (ValueError, AttributeError):
|
|
48
|
+
return fallback
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_WARM = _parse_blend(
|
|
52
|
+
os.environ.get("SLM_ENSEMBLE_BLEND_WARM", "0.4:0.6"), (0.4, 0.6),
|
|
53
|
+
)
|
|
54
|
+
_MATURE = _parse_blend(
|
|
55
|
+
os.environ.get("SLM_ENSEMBLE_BLEND_MATURE", "0.2:0.8"), (0.2, 0.8),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# EnsembleWeights
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class EnsembleWeights:
|
|
66
|
+
"""Blend weights for the bandit/LGBM ensemble.
|
|
67
|
+
|
|
68
|
+
E1: ``bandit + lgbm`` must equal 1.0 (±1e-6 float tolerance).
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
bandit: float
|
|
72
|
+
lgbm: float
|
|
73
|
+
|
|
74
|
+
def __post_init__(self) -> None:
|
|
75
|
+
total = self.bandit + self.lgbm
|
|
76
|
+
if abs(total - 1.0) > 1e-6:
|
|
77
|
+
raise AssertionError(
|
|
78
|
+
f"EnsembleWeights must sum to 1.0, got {total}"
|
|
79
|
+
)
|
|
80
|
+
if self.bandit < 0.0 or self.lgbm < 0.0:
|
|
81
|
+
raise AssertionError(
|
|
82
|
+
f"EnsembleWeights must be non-negative, got "
|
|
83
|
+
f"bandit={self.bandit}, lgbm={self.lgbm}"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def choose_ensemble(
|
|
88
|
+
signal_count: int,
|
|
89
|
+
model: Any | None,
|
|
90
|
+
) -> EnsembleWeights:
|
|
91
|
+
"""Select bandit/LGBM blend per D8.
|
|
92
|
+
|
|
93
|
+
``model`` is typed ``Any`` to avoid importing ``ActiveModel`` at module
|
|
94
|
+
load; in practice it's an ``ActiveModel | None``. Only ``model is None``
|
|
95
|
+
is checked.
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
count = int(signal_count)
|
|
99
|
+
except (TypeError, ValueError):
|
|
100
|
+
count = 0
|
|
101
|
+
if model is None or count < _MIN_SIGNALS:
|
|
102
|
+
return EnsembleWeights(1.0, 0.0)
|
|
103
|
+
if count < _DOMINANT_SIGNALS:
|
|
104
|
+
return EnsembleWeights(_WARM[0], _WARM[1])
|
|
105
|
+
return EnsembleWeights(_MATURE[0], _MATURE[1])
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
# Scoring helpers
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _softmax_unit(scores: Sequence[float]) -> list[float]:
|
|
114
|
+
"""Normalise a score stream to [0, 1] via softmax, numerically stable.
|
|
115
|
+
|
|
116
|
+
Preserves ordering. Returns uniform 1/N when all scores are identical.
|
|
117
|
+
"""
|
|
118
|
+
if not scores:
|
|
119
|
+
return []
|
|
120
|
+
xs = list(scores)
|
|
121
|
+
n = len(xs)
|
|
122
|
+
m = max(xs)
|
|
123
|
+
# Subtract max for numerical stability before exp.
|
|
124
|
+
exps = []
|
|
125
|
+
for v in xs:
|
|
126
|
+
try:
|
|
127
|
+
exps.append(pow(2.718281828459045, v - m))
|
|
128
|
+
except OverflowError: # pragma: no cover — m subtraction avoids this
|
|
129
|
+
exps.append(0.0)
|
|
130
|
+
total = sum(exps)
|
|
131
|
+
if total <= 0.0: # pragma: no cover — defensive
|
|
132
|
+
return [1.0 / n] * n
|
|
133
|
+
return [e / total for e in exps]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _apply_weights_score(candidate: Any, weights: dict[str, float]) -> float:
|
|
137
|
+
"""Compute a scalar bandit score for a candidate under the arm weights.
|
|
138
|
+
|
|
139
|
+
Input shape: candidate has either ``.channel_scores`` attr OR ``score``.
|
|
140
|
+
For v3.4.21 the bandit-only path simply uses the already-weighted ordering
|
|
141
|
+
from ``apply_channel_weights``; this helper only matters when we blend.
|
|
142
|
+
"""
|
|
143
|
+
# Prefer pre-weighted score on the object.
|
|
144
|
+
score = getattr(candidate, "score", None)
|
|
145
|
+
if score is None and isinstance(candidate, dict):
|
|
146
|
+
score = candidate.get("score")
|
|
147
|
+
if score is None:
|
|
148
|
+
# Fallback: sum channel contributions × weights.
|
|
149
|
+
cs = getattr(candidate, "channel_scores", None)
|
|
150
|
+
if cs is None and isinstance(candidate, dict):
|
|
151
|
+
cs = candidate.get("channel_scores", {}) or {}
|
|
152
|
+
cs = cs or {}
|
|
153
|
+
score = sum(
|
|
154
|
+
float(cs.get(name, 0.0)) * float(weights.get(name, 1.0))
|
|
155
|
+
for name in ("semantic", "bm25", "entity_graph", "temporal")
|
|
156
|
+
)
|
|
157
|
+
ce = None
|
|
158
|
+
if hasattr(candidate, "cross_encoder_score"):
|
|
159
|
+
ce = getattr(candidate, "cross_encoder_score", None)
|
|
160
|
+
elif isinstance(candidate, dict):
|
|
161
|
+
ce = candidate.get("cross_encoder_score")
|
|
162
|
+
if ce is not None:
|
|
163
|
+
score += float(ce) * float(
|
|
164
|
+
weights.get("cross_encoder_bias", 1.0)
|
|
165
|
+
)
|
|
166
|
+
try:
|
|
167
|
+
return float(score)
|
|
168
|
+
except (TypeError, ValueError):
|
|
169
|
+
return 0.0
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
# ensemble_rerank
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def ensemble_rerank(
|
|
178
|
+
candidates: list[Any],
|
|
179
|
+
bandit_choice: Any,
|
|
180
|
+
model: Any | None,
|
|
181
|
+
weights: EnsembleWeights,
|
|
182
|
+
query_context: dict[str, Any],
|
|
183
|
+
) -> list[Any]:
|
|
184
|
+
"""Blend bandit + LGBM scores and reorder candidates.
|
|
185
|
+
|
|
186
|
+
E2: ``booster.predict`` called at most ONCE, via a single batched input.
|
|
187
|
+
E3: short-circuits when ``weights.lgbm == 0.0`` or ``model is None``.
|
|
188
|
+
E4: softmax-unit normalisation per stream before blending.
|
|
189
|
+
|
|
190
|
+
Never raises. On error (import / predict), returns input unchanged.
|
|
191
|
+
"""
|
|
192
|
+
if not candidates:
|
|
193
|
+
return candidates
|
|
194
|
+
|
|
195
|
+
# E3: short-circuit.
|
|
196
|
+
if weights.lgbm == 0.0 or model is None:
|
|
197
|
+
return list(candidates)
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
import numpy as np # noqa: PLC0415 — optional heavy dep
|
|
201
|
+
except ImportError: # pragma: no cover — optional
|
|
202
|
+
logger.debug("ensemble_rerank: numpy unavailable; bandit-only path")
|
|
203
|
+
return list(candidates)
|
|
204
|
+
|
|
205
|
+
# Lazy import so the unit tests don't require lightgbm at import time.
|
|
206
|
+
try:
|
|
207
|
+
from superlocalmemory.learning.features import FeatureExtractor
|
|
208
|
+
except ImportError: # pragma: no cover — defensive
|
|
209
|
+
return list(candidates)
|
|
210
|
+
|
|
211
|
+
# Build batch feature matrix ONCE. PERF-v2-02: also stash a
|
|
212
|
+
# ``{fact_id: features_json}`` dict on ``query_context`` under the
|
|
213
|
+
# reserved key ``_precomputed_features_json`` so the downstream
|
|
214
|
+
# signal_worker (which would otherwise call ``FeatureExtractor.extract``
|
|
215
|
+
# again when recording signals) can reuse this work. No schema change;
|
|
216
|
+
# purely a caller-opt-in cache the signal writer probes.
|
|
217
|
+
try:
|
|
218
|
+
import json as _json # noqa: PLC0415 — local import keeps hot-path clean
|
|
219
|
+
|
|
220
|
+
rows = []
|
|
221
|
+
feats_cache: dict[str, str] = {}
|
|
222
|
+
for c in candidates:
|
|
223
|
+
result = _candidate_to_result(c)
|
|
224
|
+
fv = FeatureExtractor.extract(result, query_context)
|
|
225
|
+
rows.append(fv.to_list())
|
|
226
|
+
fid = getattr(c, "fact_id", None) or result.get("fact_id", "")
|
|
227
|
+
if fid:
|
|
228
|
+
feats_cache[fid] = _json.dumps(
|
|
229
|
+
fv.features, separators=(",", ":"),
|
|
230
|
+
)
|
|
231
|
+
X = np.asarray(rows, dtype=np.float32)
|
|
232
|
+
if isinstance(query_context, dict) and feats_cache:
|
|
233
|
+
# Merge into caller's dict; do not clobber a pre-existing cache.
|
|
234
|
+
existing = query_context.get("_precomputed_features_json") or {}
|
|
235
|
+
if isinstance(existing, dict):
|
|
236
|
+
merged = {**existing, **feats_cache}
|
|
237
|
+
query_context["_precomputed_features_json"] = merged
|
|
238
|
+
except Exception as exc:
|
|
239
|
+
logger.debug("ensemble_rerank: feature build failed: %s", exc)
|
|
240
|
+
return list(candidates)
|
|
241
|
+
|
|
242
|
+
# E2: single batched predict call.
|
|
243
|
+
booster = getattr(model, "booster", None)
|
|
244
|
+
if booster is None or not hasattr(booster, "predict"):
|
|
245
|
+
return list(candidates)
|
|
246
|
+
try:
|
|
247
|
+
lgbm_scores = booster.predict(X)
|
|
248
|
+
except Exception as exc:
|
|
249
|
+
logger.warning("ensemble_rerank: predict failed: %s", exc)
|
|
250
|
+
return list(candidates)
|
|
251
|
+
try:
|
|
252
|
+
lgbm_scores = list(map(float, lgbm_scores))
|
|
253
|
+
except (TypeError, ValueError): # pragma: no cover — defensive
|
|
254
|
+
return list(candidates)
|
|
255
|
+
|
|
256
|
+
arm_weights = (
|
|
257
|
+
bandit_choice.weights if hasattr(bandit_choice, "weights") else {}
|
|
258
|
+
)
|
|
259
|
+
bandit_scores = [
|
|
260
|
+
_apply_weights_score(c, arm_weights) for c in candidates
|
|
261
|
+
]
|
|
262
|
+
|
|
263
|
+
# E4: normalise each stream to [0, 1] via softmax before blending.
|
|
264
|
+
n_lgbm = _softmax_unit(lgbm_scores)
|
|
265
|
+
n_bandit = _softmax_unit(bandit_scores)
|
|
266
|
+
|
|
267
|
+
blended = [
|
|
268
|
+
weights.bandit * b + weights.lgbm * l
|
|
269
|
+
for b, l in zip(n_bandit, n_lgbm)
|
|
270
|
+
]
|
|
271
|
+
|
|
272
|
+
# Stable-sort descending so equal scores preserve original order.
|
|
273
|
+
indexed = list(enumerate(candidates))
|
|
274
|
+
indexed.sort(key=lambda pair: -blended[pair[0]])
|
|
275
|
+
return [c for _, c in indexed]
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _candidate_to_result(c: Any) -> dict[str, Any]:
|
|
279
|
+
"""Coerce a candidate (dict / dataclass / ORM row) to a feature result."""
|
|
280
|
+
if isinstance(c, dict):
|
|
281
|
+
return c
|
|
282
|
+
if hasattr(c, "to_result_dict") and callable(c.to_result_dict):
|
|
283
|
+
try:
|
|
284
|
+
return c.to_result_dict()
|
|
285
|
+
except Exception: # pragma: no cover — defensive
|
|
286
|
+
pass
|
|
287
|
+
# Last resort: assemble from common attributes.
|
|
288
|
+
return {
|
|
289
|
+
"fact_id": getattr(c, "fact_id", ""),
|
|
290
|
+
"score": getattr(c, "score", 0.0),
|
|
291
|
+
"channel_scores": getattr(c, "channel_scores", {}) or {},
|
|
292
|
+
"cross_encoder_score": getattr(c, "cross_encoder_score", None),
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
__all__ = (
|
|
297
|
+
"EnsembleWeights",
|
|
298
|
+
"choose_ensemble",
|
|
299
|
+
"ensemble_rerank",
|
|
300
|
+
)
|
|
@@ -0,0 +1,207 @@
|
|
|
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 — F4.A Stage-8 H-03/H-06 fix
|
|
4
|
+
|
|
5
|
+
"""Parameterised JSON1-backed join helpers for ``action_outcomes``.
|
|
6
|
+
|
|
7
|
+
Replaces the fragile ``fact_ids_json LIKE '%"<fid>"%'`` pattern that five
|
|
8
|
+
call sites depended on. Substring matching on serialised JSON leaks
|
|
9
|
+
false positives across overlapping fact_id prefixes — see Stage-8
|
|
10
|
+
skeptic-H06 for the exact failure mode.
|
|
11
|
+
|
|
12
|
+
This module centralises the correct lookup:
|
|
13
|
+
|
|
14
|
+
SELECT outcome_id, ... FROM action_outcomes
|
|
15
|
+
WHERE profile_id = ?
|
|
16
|
+
AND EXISTS (
|
|
17
|
+
SELECT 1 FROM json_each(fact_ids_json) WHERE value = ?
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
SQLite ships JSON1 enabled by default since 3.38 (February 2022) and the
|
|
21
|
+
minimum Python supported by SLM is 3.9 — which ships SQLite ≥ 3.31. We
|
|
22
|
+
defensively fall back to a ``LIKE`` probe only when JSON1 is missing at
|
|
23
|
+
runtime, with a one-off warning.
|
|
24
|
+
|
|
25
|
+
Callers:
|
|
26
|
+
- ``learning/hnsw_dedup.py`` — ``apply_strong_memory_boost``,
|
|
27
|
+
``select_high_reward_fact_ids``, ``run_reward_gated_archive``.
|
|
28
|
+
- ``learning/forgetting_scheduler.py`` — ``_has_recent_positive_reward``.
|
|
29
|
+
|
|
30
|
+
Contract refs:
|
|
31
|
+
- Stage 8 H-03 (architect-H3) + H-06 (skeptic-H06).
|
|
32
|
+
- LLD-12 §5 — reward-gated archive.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import logging
|
|
38
|
+
import sqlite3
|
|
39
|
+
from typing import Iterable
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
__all__ = (
|
|
44
|
+
"iter_outcomes_for_fact",
|
|
45
|
+
"has_recent_positive_reward",
|
|
46
|
+
"aggregate_reward_for_fact",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# Columns returned — mirror what the legacy LIKE callers read.
|
|
51
|
+
# NB: callers that need extra columns can pass ``columns=``.
|
|
52
|
+
_DEFAULT_COLUMNS = "outcome_id, profile_id, fact_ids_json, reward, settled_at"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _json1_available(conn: sqlite3.Connection) -> bool:
|
|
56
|
+
"""Return True iff SQLite ``json_each`` is usable on ``conn``.
|
|
57
|
+
|
|
58
|
+
Result is intentionally not cached across connections — JSON1 is a
|
|
59
|
+
compile-time flag and runtime-swapping SQLite libraries is a rare
|
|
60
|
+
edge case but we stay defensive.
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
conn.execute("SELECT value FROM json_each('[\"x\"]') LIMIT 1").fetchall()
|
|
64
|
+
return True
|
|
65
|
+
except sqlite3.OperationalError:
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def iter_outcomes_for_fact(
|
|
70
|
+
conn: sqlite3.Connection,
|
|
71
|
+
profile_id: str,
|
|
72
|
+
fact_id: str,
|
|
73
|
+
*,
|
|
74
|
+
columns: str = _DEFAULT_COLUMNS,
|
|
75
|
+
extra_where: str = "",
|
|
76
|
+
extra_params: tuple = (),
|
|
77
|
+
) -> Iterable[tuple]:
|
|
78
|
+
"""Yield action_outcomes rows whose fact_ids_json contains ``fact_id``.
|
|
79
|
+
|
|
80
|
+
Scoped strictly to ``profile_id``; SQL parameters are always bound,
|
|
81
|
+
never string-interpolated. Returns a materialised list so the caller
|
|
82
|
+
can close the connection immediately.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
conn: SQLite connection pointing at the database holding the
|
|
86
|
+
``action_outcomes`` table (usually ``memory.db``).
|
|
87
|
+
profile_id: Profile scope.
|
|
88
|
+
fact_id: Exact fact_id to find.
|
|
89
|
+
columns: Comma-separated column list projected into the
|
|
90
|
+
SELECT. Defaults to (outcome_id, profile_id, fact_ids_json,
|
|
91
|
+
reward, settled_at).
|
|
92
|
+
extra_where: Optional extra predicate — must start with 'AND'
|
|
93
|
+
and use '?' placeholders. E.g.
|
|
94
|
+
``"AND reward IS NOT NULL AND reward > ?"``.
|
|
95
|
+
extra_params: Bound parameters for ``extra_where``.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
List of sqlite3.Row-compatible tuples (or sqlite3.Row objects if
|
|
99
|
+
the caller set ``conn.row_factory = sqlite3.Row``).
|
|
100
|
+
"""
|
|
101
|
+
if not profile_id or not fact_id:
|
|
102
|
+
return []
|
|
103
|
+
|
|
104
|
+
if _json1_available(conn):
|
|
105
|
+
sql = (
|
|
106
|
+
f"SELECT {columns} FROM action_outcomes "
|
|
107
|
+
f"WHERE profile_id = ? "
|
|
108
|
+
f" AND EXISTS ("
|
|
109
|
+
f" SELECT 1 FROM json_each(fact_ids_json) WHERE value = ?"
|
|
110
|
+
f" ) "
|
|
111
|
+
f"{extra_where}"
|
|
112
|
+
)
|
|
113
|
+
params = (profile_id, fact_id, *extra_params)
|
|
114
|
+
else:
|
|
115
|
+
# Fallback: prefix-LIKE. Accurate ONLY for simple ids.
|
|
116
|
+
# Logged once per process to flag that JSON1 is missing.
|
|
117
|
+
_warn_fallback_once()
|
|
118
|
+
sql = (
|
|
119
|
+
f"SELECT {columns} FROM action_outcomes "
|
|
120
|
+
f"WHERE profile_id = ? AND fact_ids_json LIKE ? "
|
|
121
|
+
f"{extra_where}"
|
|
122
|
+
)
|
|
123
|
+
params = (profile_id, f'%"{fact_id}"%', *extra_params)
|
|
124
|
+
|
|
125
|
+
cursor = conn.execute(sql, params)
|
|
126
|
+
return cursor.fetchall()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def has_recent_positive_reward(
|
|
130
|
+
conn: sqlite3.Connection,
|
|
131
|
+
profile_id: str,
|
|
132
|
+
fact_id: str,
|
|
133
|
+
*,
|
|
134
|
+
min_reward: float = 0.3,
|
|
135
|
+
window_days: int = 60,
|
|
136
|
+
) -> bool:
|
|
137
|
+
"""True if ``fact_id`` has any outcome with reward > ``min_reward``
|
|
138
|
+
settled in the last ``window_days`` days.
|
|
139
|
+
"""
|
|
140
|
+
extra = (
|
|
141
|
+
"AND reward IS NOT NULL AND reward > ? "
|
|
142
|
+
f"AND COALESCE(settled_at, '') >= datetime('now', '-{int(window_days)} days') "
|
|
143
|
+
"LIMIT 1"
|
|
144
|
+
)
|
|
145
|
+
rows = iter_outcomes_for_fact(
|
|
146
|
+
conn, profile_id, fact_id,
|
|
147
|
+
columns="1",
|
|
148
|
+
extra_where=extra,
|
|
149
|
+
extra_params=(float(min_reward),),
|
|
150
|
+
)
|
|
151
|
+
return bool(rows)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def aggregate_reward_for_fact(
|
|
155
|
+
conn: sqlite3.Connection,
|
|
156
|
+
profile_id: str,
|
|
157
|
+
fact_id: str,
|
|
158
|
+
) -> tuple[int, float]:
|
|
159
|
+
"""Return ``(count, mean_reward)`` for a single fact_id.
|
|
160
|
+
|
|
161
|
+
Count is the number of outcomes with reward IS NOT NULL; mean is
|
|
162
|
+
``AVG(reward)`` across that same subset. Returns ``(0, 0.0)`` when
|
|
163
|
+
the fact has no outcomes.
|
|
164
|
+
"""
|
|
165
|
+
if not profile_id or not fact_id:
|
|
166
|
+
return 0, 0.0
|
|
167
|
+
|
|
168
|
+
if _json1_available(conn):
|
|
169
|
+
sql = (
|
|
170
|
+
"SELECT COUNT(*), AVG(reward) FROM action_outcomes "
|
|
171
|
+
"WHERE profile_id = ? "
|
|
172
|
+
" AND reward IS NOT NULL "
|
|
173
|
+
" AND EXISTS ("
|
|
174
|
+
" SELECT 1 FROM json_each(fact_ids_json) WHERE value = ?"
|
|
175
|
+
" )"
|
|
176
|
+
)
|
|
177
|
+
row = conn.execute(sql, (profile_id, fact_id)).fetchone()
|
|
178
|
+
else:
|
|
179
|
+
_warn_fallback_once()
|
|
180
|
+
sql = (
|
|
181
|
+
"SELECT COUNT(*), AVG(reward) FROM action_outcomes "
|
|
182
|
+
"WHERE profile_id = ? "
|
|
183
|
+
" AND reward IS NOT NULL "
|
|
184
|
+
" AND fact_ids_json LIKE ?"
|
|
185
|
+
)
|
|
186
|
+
row = conn.execute(sql, (profile_id, f'%"{fact_id}"%')).fetchone()
|
|
187
|
+
|
|
188
|
+
if row is None:
|
|
189
|
+
return 0, 0.0
|
|
190
|
+
count, mean = row
|
|
191
|
+
return int(count or 0), float(mean or 0.0)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
_FALLBACK_WARNED = False
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _warn_fallback_once() -> None:
|
|
198
|
+
"""Log the JSON1-missing fallback exactly once per process."""
|
|
199
|
+
global _FALLBACK_WARNED
|
|
200
|
+
if _FALLBACK_WARNED:
|
|
201
|
+
return
|
|
202
|
+
_FALLBACK_WARNED = True
|
|
203
|
+
logger.warning(
|
|
204
|
+
"fact_outcome_joins: SQLite JSON1 unavailable — falling back to "
|
|
205
|
+
"prefix-LIKE. Expect substring false positives on overlapping "
|
|
206
|
+
"fact_id prefixes. Upgrade SQLite to ≥3.38 for correct matches.",
|
|
207
|
+
)
|
|
@@ -306,10 +306,65 @@ class ForgettingScheduler:
|
|
|
306
306
|
def _soft_delete_with_audit(self, fact_id: str, profile_id: str) -> None:
|
|
307
307
|
"""Soft-delete a forgotten fact with compliance audit trail.
|
|
308
308
|
|
|
309
|
+
v3.4.21 (LLD-12 §4): reward-gated. If the fact has any positive
|
|
310
|
+
reward (>0.3) in the last 60 days, it is considered "still
|
|
311
|
+
useful" and kept live — consolidation will retry next cycle.
|
|
312
|
+
|
|
309
313
|
HR-04: Never physically deletes.
|
|
310
314
|
"""
|
|
315
|
+
if self._has_recent_positive_reward(fact_id, profile_id):
|
|
316
|
+
logger.debug(
|
|
317
|
+
"forgetting_scheduler: fact_id=%s kept live (recent reward)",
|
|
318
|
+
fact_id,
|
|
319
|
+
)
|
|
320
|
+
return
|
|
311
321
|
logger.info(
|
|
312
322
|
"Soft-deleting forgotten fact: fact_id=%s, profile_id=%s",
|
|
313
323
|
fact_id, profile_id,
|
|
314
324
|
)
|
|
315
325
|
self._db.soft_delete_fact(fact_id, profile_id)
|
|
326
|
+
|
|
327
|
+
def _has_recent_positive_reward(
|
|
328
|
+
self, fact_id: str, profile_id: str,
|
|
329
|
+
) -> bool:
|
|
330
|
+
"""True if fact has an outcome_reward > 0.3 in the last 60 days.
|
|
331
|
+
|
|
332
|
+
v3.4.21 (Stage 8 H-06): routes through the JSON1-backed
|
|
333
|
+
``fact_outcome_joins.has_recent_positive_reward`` helper —
|
|
334
|
+
eliminates the substring-LIKE false-positive class.
|
|
335
|
+
|
|
336
|
+
Resilient to schema drift: if ``action_outcomes`` or its columns
|
|
337
|
+
are unavailable we return False (no gating), preserving legacy
|
|
338
|
+
behaviour.
|
|
339
|
+
"""
|
|
340
|
+
try:
|
|
341
|
+
# ``DatabaseManager`` is the owner of a persistent sqlite
|
|
342
|
+
# connection; the JSON1 helper needs a raw connection. We
|
|
343
|
+
# fall through to the legacy execute-path if the DB wrapper
|
|
344
|
+
# does not expose a ``.conn`` handle.
|
|
345
|
+
raw_conn = getattr(self._db, "conn", None) or getattr(
|
|
346
|
+
self._db, "_conn", None,
|
|
347
|
+
)
|
|
348
|
+
if raw_conn is not None:
|
|
349
|
+
from superlocalmemory.learning.fact_outcome_joins import (
|
|
350
|
+
has_recent_positive_reward,
|
|
351
|
+
)
|
|
352
|
+
return has_recent_positive_reward(
|
|
353
|
+
raw_conn, profile_id, fact_id,
|
|
354
|
+
min_reward=0.3, window_days=60,
|
|
355
|
+
)
|
|
356
|
+
# Fallback: use the DB wrapper with JSON1 SQL inline.
|
|
357
|
+
rows = self._db.execute(
|
|
358
|
+
"SELECT 1 FROM action_outcomes "
|
|
359
|
+
"WHERE profile_id = ? "
|
|
360
|
+
" AND reward IS NOT NULL AND reward > 0.3 "
|
|
361
|
+
" AND EXISTS ("
|
|
362
|
+
" SELECT 1 FROM json_each(fact_ids_json) WHERE value = ?"
|
|
363
|
+
" ) "
|
|
364
|
+
" AND COALESCE(settled_at, '') >= datetime('now', '-60 days') "
|
|
365
|
+
"LIMIT 1",
|
|
366
|
+
(profile_id, fact_id),
|
|
367
|
+
)
|
|
368
|
+
return bool(rows)
|
|
369
|
+
except Exception:
|
|
370
|
+
return False
|
|
@@ -0,0 +1,69 @@
|
|
|
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 — F4.A Stage-8 H-03/H-17/H-18 shim
|
|
4
|
+
|
|
5
|
+
"""HNSW dedup + reward-gated archive + strong-memory boost — shim.
|
|
6
|
+
|
|
7
|
+
As of v3.4.21 (Stage 8 H-03/H-17/H-18 fixes), the 535-LOC god-module
|
|
8
|
+
was split into three cohesive files:
|
|
9
|
+
|
|
10
|
+
- ``dedup_hnsw.py`` — :class:`HnswDeduplicator` + fallback counter.
|
|
11
|
+
- ``reward_archive.py`` — :func:`run_reward_gated_archive`.
|
|
12
|
+
- ``reward_boost.py`` — :func:`apply_strong_memory_boost`,
|
|
13
|
+
:func:`select_high_reward_fact_ids`.
|
|
14
|
+
|
|
15
|
+
Outcome lookups that used to issue ``fact_ids_json LIKE`` now go
|
|
16
|
+
through :mod:`superlocalmemory.learning.fact_outcome_joins` which wraps
|
|
17
|
+
SQLite JSON1 so overlapping fact_id prefixes cannot collide (H-06).
|
|
18
|
+
|
|
19
|
+
This shim re-exports the original surface so that existing imports
|
|
20
|
+
continue to work unchanged.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
|
|
27
|
+
from superlocalmemory.core.ram_lock import ram_reservation # noqa: F401
|
|
28
|
+
|
|
29
|
+
from superlocalmemory.learning.dedup_hnsw import ( # noqa: F401
|
|
30
|
+
HnswDeduplicator,
|
|
31
|
+
get_hnsw_degraded_count,
|
|
32
|
+
reset_hnsw_degraded_count,
|
|
33
|
+
_cosine,
|
|
34
|
+
_jaccard,
|
|
35
|
+
_parse_embedding,
|
|
36
|
+
_pick_canonical,
|
|
37
|
+
)
|
|
38
|
+
from superlocalmemory.learning.reward_archive import ( # noqa: F401
|
|
39
|
+
ARCHIVE_REWARD_THRESHOLD,
|
|
40
|
+
REWARD_WINDOW_DAYS,
|
|
41
|
+
run_reward_gated_archive,
|
|
42
|
+
)
|
|
43
|
+
from superlocalmemory.learning.reward_boost import ( # noqa: F401
|
|
44
|
+
STRONG_BOOST_CAP,
|
|
45
|
+
STRONG_BOOST_INCREMENT,
|
|
46
|
+
STRONG_BOOST_MIN_MEAN,
|
|
47
|
+
STRONG_BOOST_MIN_OUTCOMES,
|
|
48
|
+
apply_strong_memory_boost,
|
|
49
|
+
select_high_reward_fact_ids,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
logger = logging.getLogger(__name__)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
__all__ = (
|
|
56
|
+
"HnswDeduplicator",
|
|
57
|
+
"run_reward_gated_archive",
|
|
58
|
+
"apply_strong_memory_boost",
|
|
59
|
+
"select_high_reward_fact_ids",
|
|
60
|
+
"get_hnsw_degraded_count",
|
|
61
|
+
"reset_hnsw_degraded_count",
|
|
62
|
+
"REWARD_WINDOW_DAYS",
|
|
63
|
+
"ARCHIVE_REWARD_THRESHOLD",
|
|
64
|
+
"STRONG_BOOST_INCREMENT",
|
|
65
|
+
"STRONG_BOOST_CAP",
|
|
66
|
+
"STRONG_BOOST_MIN_OUTCOMES",
|
|
67
|
+
"STRONG_BOOST_MIN_MEAN",
|
|
68
|
+
"ram_reservation",
|
|
69
|
+
)
|