superlocalmemory 4.1.6 → 4.1.8
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/.claude-plugin/marketplace.json +2 -2
- package/CHANGELOG.md +46 -0
- package/README.md +3 -3
- package/package.json +3 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/agents/slm-memory-advisor.md +49 -0
- package/plugin-src/agents/slm-optimize-advisor.md +44 -0
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-governance/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-loop/SKILL.md +1 -1
- package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
- package/plugin-src/skills/slm-profile/SKILL.md +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-scope/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +5 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/host_upgrades.py +21 -7
- package/src/superlocalmemory/core/engine.py +7 -1
- package/src/superlocalmemory/core/recall_pipeline.py +15 -5
- package/src/superlocalmemory/core/session_identity.py +14 -1
- package/src/superlocalmemory/hooks/codex_assets.py +165 -45
- package/src/superlocalmemory/hooks/post_tool_outcome_hook.py +122 -0
- package/src/superlocalmemory/learning/bandit.py +22 -2
- package/src/superlocalmemory/learning/engagement_features.py +279 -0
- package/src/superlocalmemory/learning/outcome_queue.py +14 -0
- package/src/superlocalmemory/learning/propensity.py +131 -0
- package/src/superlocalmemory/learning/reward.py +42 -16
- package/src/superlocalmemory/learning/reward_model.py +144 -0
- package/src/superlocalmemory/learning/reward_proxy.py +148 -22
- package/src/superlocalmemory/server/routes/v3_api.py +4 -3
- package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +22 -0
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
5
8
|
import shutil
|
|
6
9
|
import sysconfig
|
|
7
10
|
from pathlib import Path
|
|
@@ -24,11 +27,31 @@ SKILLS = (
|
|
|
24
27
|
# Codex subagent files written to ~/.codex/agents (content built by _agent_files()).
|
|
25
28
|
AGENTS = ("slm-memory-advisor.toml", "slm-optimize-advisor.toml")
|
|
26
29
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
#: Digests of the agent files this installer last wrote, kept beside them. An
|
|
31
|
+
#: agent file whose current content still matches its recorded digest is ours to
|
|
32
|
+
#: refresh; anything else belongs to whoever changed it and is left alone. A
|
|
33
|
+
#: file with no recorded digest predates this manifest and is treated as theirs,
|
|
34
|
+
#: which is the safe reading: this installer once replaced two hand-maintained
|
|
35
|
+
#: 4.9 KB advisors with its own one-line stubs and there was nothing to restore
|
|
36
|
+
#: from.
|
|
37
|
+
MANIFEST_NAME = ".slm-managed.json"
|
|
38
|
+
|
|
39
|
+
#: Used only when the advisor source document is unavailable — an installation
|
|
40
|
+
#: that ships no agent sources still gets a usable, if terse, subagent.
|
|
41
|
+
_FALLBACKS = {
|
|
42
|
+
"slm-memory-advisor.toml": (
|
|
43
|
+
"Use SuperLocalMemory safely: initialize once, recall before remember, "
|
|
44
|
+
"and store only durable atomic facts.",
|
|
45
|
+
"Use SLM for memory discipline only. Check results before claiming success; "
|
|
46
|
+
"preserve private scope unless the user explicitly asks to share.",
|
|
47
|
+
),
|
|
48
|
+
"slm-optimize-advisor.toml": (
|
|
49
|
+
"Apply SuperLocalMemory's no-proxy context-optimization rules — reversible "
|
|
50
|
+
"compression of large tool output and KV-caching of repeated reads/searches.",
|
|
51
|
+
"Reduce context-window pressure with the Surface-B tools (reversible CCR "
|
|
52
|
+
"compression + a per-agent KV cache); fail-open — never block the task.",
|
|
53
|
+
),
|
|
54
|
+
}
|
|
32
55
|
|
|
33
56
|
|
|
34
57
|
def _source_root() -> Path:
|
|
@@ -49,67 +72,164 @@ def _agents_source_root() -> Path | None:
|
|
|
49
72
|
return installed if installed.exists() else None
|
|
50
73
|
|
|
51
74
|
|
|
52
|
-
def
|
|
53
|
-
"""
|
|
54
|
-
|
|
55
|
-
|
|
75
|
+
def _split_frontmatter(text: str) -> tuple[str, str]:
|
|
76
|
+
"""Return (frontmatter, body); frontmatter is "" when the doc has none."""
|
|
77
|
+
if not text.startswith("---"):
|
|
78
|
+
return "", text.strip()
|
|
79
|
+
end = text.find("\n---", 3)
|
|
80
|
+
if end == -1:
|
|
81
|
+
return "", text.strip()
|
|
82
|
+
return text[3:end], text[end + 4:].strip()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _description(frontmatter: str, default: str) -> str:
|
|
86
|
+
"""Read `description:`, joining YAML folded (`>`) blocks onto one line."""
|
|
87
|
+
folded = re.search(r"^description:\s*>[-+]?\s*\n((?:[ \t]+\S.*\n?)+)", frontmatter, re.M)
|
|
88
|
+
if folded:
|
|
89
|
+
return " ".join(line.strip() for line in folded.group(1).splitlines() if line.strip())
|
|
90
|
+
inline = re.search(r"^description:\s*(\S.*)$", frontmatter, re.M)
|
|
91
|
+
return inline.group(1).strip() if inline else default
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _agent_toml(filename: str) -> str:
|
|
95
|
+
"""Build one subagent's TOML, preferring the canonical advisor document so
|
|
96
|
+
Codex ships the advisor's full decision rules rather than a summary of them.
|
|
56
97
|
"""
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
body = ""
|
|
98
|
+
name = filename.removesuffix(".toml")
|
|
99
|
+
default_description, default_body = _FALLBACKS[filename]
|
|
100
|
+
description, body = default_description, default_body
|
|
101
|
+
|
|
62
102
|
root = _agents_source_root()
|
|
63
103
|
if root is not None:
|
|
64
|
-
|
|
65
|
-
if
|
|
66
|
-
|
|
67
|
-
if
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
#
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
104
|
+
source = root / f"{name}.md"
|
|
105
|
+
if source.exists():
|
|
106
|
+
frontmatter, source_body = _split_frontmatter(source.read_text(encoding="utf-8"))
|
|
107
|
+
if source_body:
|
|
108
|
+
description = _description(frontmatter, default_description)
|
|
109
|
+
body = source_body
|
|
110
|
+
|
|
111
|
+
# description is a TOML basic string, so quotes and backslashes must be
|
|
112
|
+
# escaped — the advisor descriptions really do contain quoted questions.
|
|
113
|
+
escaped = description.replace("\\", "\\\\").replace('"', '\\"')
|
|
114
|
+
# A literal multi-line string ('''...''') performs no escape processing,
|
|
115
|
+
# which keeps the advisor markdown byte-exact. It cannot contain the
|
|
116
|
+
# sequence that closes it, so fall back to an escaped basic string in that
|
|
117
|
+
# case rather than editing the advisor's own text.
|
|
118
|
+
if "'''" in body:
|
|
119
|
+
basic = body.replace("\\", "\\\\").replace('"', '\\"')
|
|
120
|
+
instructions = '"""\n' + basic + '\n"""'
|
|
121
|
+
else:
|
|
122
|
+
instructions = "'''\n" + body + "\n'''"
|
|
123
|
+
return f'name = "{name}"\ndescription = "{escaped}"\ninstructions = {instructions}\n'
|
|
84
124
|
|
|
85
125
|
|
|
86
126
|
def _agent_files() -> dict:
|
|
87
127
|
"""Return {filename: TOML content} for the Codex subagents."""
|
|
88
|
-
return {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
128
|
+
return {filename: _agent_toml(filename) for filename in AGENTS}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _digest(text: str) -> str:
|
|
132
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
133
|
+
|
|
92
134
|
|
|
135
|
+
def _read_manifest(agents_root: Path) -> dict:
|
|
136
|
+
try:
|
|
137
|
+
loaded = json.loads((agents_root / MANIFEST_NAME).read_text(encoding="utf-8"))
|
|
138
|
+
except (OSError, ValueError):
|
|
139
|
+
return {}
|
|
140
|
+
return loaded if isinstance(loaded, dict) else {}
|
|
93
141
|
|
|
94
|
-
|
|
95
|
-
|
|
142
|
+
|
|
143
|
+
def _is_ours(target: Path, manifest: dict) -> bool:
|
|
144
|
+
"""True when this installer may overwrite `target`.
|
|
145
|
+
|
|
146
|
+
Either it does not exist yet, or its content is byte-identical to what this
|
|
147
|
+
installer last recorded writing there.
|
|
148
|
+
"""
|
|
149
|
+
if not target.exists():
|
|
150
|
+
return True
|
|
151
|
+
recorded = manifest.get(target.name)
|
|
152
|
+
if not recorded:
|
|
153
|
+
return False
|
|
154
|
+
try:
|
|
155
|
+
return _digest(target.read_text(encoding="utf-8")) == recorded
|
|
156
|
+
except OSError:
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _skills_read_elsewhere(home: Path, skills_root: Path) -> list[str]:
|
|
161
|
+
"""Skill paths Codex may read that this installer does not write.
|
|
162
|
+
|
|
163
|
+
Some setups point ~/.codex/skills/<name> at a checkout instead of using the
|
|
164
|
+
copies under ~/.agents/skills, in which case writing the copies refreshes
|
|
165
|
+
nothing Codex will load. Report those paths so the caller can say so rather
|
|
166
|
+
than claiming a refresh it did not perform.
|
|
167
|
+
"""
|
|
168
|
+
elsewhere = []
|
|
169
|
+
for skill in SKILLS:
|
|
170
|
+
candidate = home / ".codex" / "skills" / skill
|
|
171
|
+
if not candidate.exists() and not candidate.is_symlink():
|
|
172
|
+
continue
|
|
173
|
+
resolved = candidate.resolve() if candidate.is_symlink() else candidate
|
|
174
|
+
if resolved != (skills_root / skill).resolve():
|
|
175
|
+
elsewhere.append(str(candidate))
|
|
176
|
+
return elsewhere
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def install_assets(*, home: Path | None = None, dry_run: bool = False, force: bool = False) -> dict:
|
|
180
|
+
"""Copy only named SLM assets; never rewrite user-owned assets.
|
|
181
|
+
|
|
182
|
+
An agent file that this installer did not write, or that has been edited
|
|
183
|
+
since it did, is preserved and reported under ``agents_preserved`` rather
|
|
184
|
+
than overwritten. Pass ``force=True`` to overwrite anyway, which first
|
|
185
|
+
copies the existing file aside with a ``.bak`` suffix.
|
|
186
|
+
"""
|
|
96
187
|
home = home or Path.home()
|
|
97
188
|
source = _source_root()
|
|
98
189
|
missing = [skill for skill in SKILLS if not (source / skill / "SKILL.md").exists()]
|
|
99
190
|
if missing:
|
|
100
191
|
return {"success": False, "errors": [f"missing bundled skills: {', '.join(missing)}"]}
|
|
101
|
-
|
|
102
|
-
return {"success": True, "skills": list(SKILLS), "agents": list(AGENTS), "dry_run": True}
|
|
192
|
+
|
|
103
193
|
skills_root, agents_root = home / ".agents" / "skills", home / ".codex" / "agents"
|
|
194
|
+
manifest = _read_manifest(agents_root)
|
|
195
|
+
planned = _agent_files()
|
|
196
|
+
|
|
197
|
+
writable, preserved = [], []
|
|
198
|
+
for filename, content in planned.items():
|
|
199
|
+
target = agents_root / filename
|
|
200
|
+
if force or _is_ours(target, manifest):
|
|
201
|
+
writable.append((filename, content, target))
|
|
202
|
+
else:
|
|
203
|
+
preserved.append(str(target))
|
|
204
|
+
|
|
205
|
+
skill_targets = [skills_root / skill / "SKILL.md" for skill in SKILLS]
|
|
206
|
+
result = {
|
|
207
|
+
"success": True,
|
|
208
|
+
"dry_run": dry_run,
|
|
209
|
+
"skills": list(SKILLS),
|
|
210
|
+
"agents": [filename for filename, _, _ in writable],
|
|
211
|
+
"skills_written": [str(path) for path in skill_targets],
|
|
212
|
+
"agents_written": [str(target) for _, _, target in writable],
|
|
213
|
+
"agents_preserved": preserved,
|
|
214
|
+
"skills_read_elsewhere": _skills_read_elsewhere(home, skills_root),
|
|
215
|
+
}
|
|
216
|
+
if dry_run:
|
|
217
|
+
return result
|
|
218
|
+
|
|
104
219
|
skills_root.mkdir(parents=True, exist_ok=True)
|
|
105
220
|
agents_root.mkdir(parents=True, exist_ok=True)
|
|
106
221
|
for skill in SKILLS:
|
|
107
222
|
target = skills_root / skill
|
|
108
223
|
target.mkdir(parents=True, exist_ok=True)
|
|
109
224
|
shutil.copy2(source / skill / "SKILL.md", target / "SKILL.md")
|
|
110
|
-
for filename, content in
|
|
111
|
-
|
|
112
|
-
|
|
225
|
+
for filename, content, target in writable:
|
|
226
|
+
if force and target.exists():
|
|
227
|
+
shutil.copy2(target, target.with_suffix(target.suffix + ".bak"))
|
|
228
|
+
target.write_text(content, encoding="utf-8")
|
|
229
|
+
manifest[filename] = _digest(content)
|
|
230
|
+
if writable:
|
|
231
|
+
(agents_root / MANIFEST_NAME).write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
|
232
|
+
return result
|
|
113
233
|
|
|
114
234
|
|
|
115
235
|
def remove_assets(*, home: Path | None = None, dry_run: bool = False) -> dict:
|
|
@@ -83,6 +83,122 @@ def _validate(marker: str) -> str | None:
|
|
|
83
83
|
return None
|
|
84
84
|
|
|
85
85
|
|
|
86
|
+
# --- Behavioural telemetry -------------------------------------------------
|
|
87
|
+
# ``tool_events`` is the record of what the agent did, and several readers
|
|
88
|
+
# depend on it: assertion mining, skill-performance mining, and the engagement
|
|
89
|
+
# features that settle a recall against the actions which followed it. Each of
|
|
90
|
+
# those reads the table; none of them writes it. The only writers were an
|
|
91
|
+
# explicit ``log_tool_event`` call and a bulk importer, so on an ordinary
|
|
92
|
+
# install the table simply stopped receiving invocations and every reader
|
|
93
|
+
# quietly aged out with it.
|
|
94
|
+
#
|
|
95
|
+
# This hook already runs on exactly the right edge -- after each tool completes,
|
|
96
|
+
# with the session that ran it -- so it records the invocation here. It happens
|
|
97
|
+
# before the marker scan and independently of it: whether a recalled memory was
|
|
98
|
+
# named in the output decides what a *reward* is worth, not whether the action
|
|
99
|
+
# occurred at all.
|
|
100
|
+
_MAX_SUMMARY_LEN = 500
|
|
101
|
+
|
|
102
|
+
# Compiled once at import. This path now runs on every tool call rather than
|
|
103
|
+
# only on the ~1-in-5 that carry a marker, so per-call ``re`` compilation or a
|
|
104
|
+
# module import would be paid on the hot path for no benefit.
|
|
105
|
+
_SECRET_PATTERNS = (
|
|
106
|
+
(re.compile(r"\b(?:sk-|pk-|api[_-]?key[_-]?)[A-Za-z0-9_-]{10,}\b"),
|
|
107
|
+
"[REDACTED]"),
|
|
108
|
+
(re.compile(r"\b[A-Za-z0-9+/]{40,}={0,2}\b"), "[REDACTED]"),
|
|
109
|
+
(re.compile(r"password\s*[=:]\s*\S+", re.IGNORECASE),
|
|
110
|
+
"password=[REDACTED]"),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _scrub(text: str) -> str:
|
|
115
|
+
"""Strip credential-shaped substrings from telemetry text."""
|
|
116
|
+
for pattern, replacement in _SECRET_PATTERNS:
|
|
117
|
+
text = pattern.sub(replacement, text)
|
|
118
|
+
return text
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _summary(raw: object) -> str:
|
|
122
|
+
"""Truncate-then-scrub ``raw`` for a summary column.
|
|
123
|
+
|
|
124
|
+
Truncation comes first so the regex cost is bounded by
|
|
125
|
+
``_MAX_SUMMARY_LEN`` and not by the size of a tool response.
|
|
126
|
+
"""
|
|
127
|
+
if raw is None:
|
|
128
|
+
return ""
|
|
129
|
+
if not isinstance(raw, str):
|
|
130
|
+
try:
|
|
131
|
+
import json as _json
|
|
132
|
+
raw = _json.dumps(raw, default=str)
|
|
133
|
+
except Exception:
|
|
134
|
+
try:
|
|
135
|
+
raw = str(raw)
|
|
136
|
+
except Exception:
|
|
137
|
+
return ""
|
|
138
|
+
return _scrub(raw[:_MAX_SUMMARY_LEN])
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _record_tool_event(
|
|
142
|
+
session_id: str,
|
|
143
|
+
tool_name: str,
|
|
144
|
+
payload: dict,
|
|
145
|
+
response_text: str,
|
|
146
|
+
) -> bool:
|
|
147
|
+
"""Append one ``tool_events`` row. Never raises; returns whether it wrote.
|
|
148
|
+
|
|
149
|
+
Failure is silent by design: telemetry must never be the reason a tool call
|
|
150
|
+
reports a problem to the user, and the hook contract is to exit 0 whatever
|
|
151
|
+
happens.
|
|
152
|
+
"""
|
|
153
|
+
if not tool_name:
|
|
154
|
+
return False
|
|
155
|
+
try:
|
|
156
|
+
import sqlite3
|
|
157
|
+
from datetime import datetime, timezone
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
from superlocalmemory.hooks.session_registry import (
|
|
161
|
+
resolve_active_profile,
|
|
162
|
+
)
|
|
163
|
+
profile_id = resolve_active_profile() or "default"
|
|
164
|
+
except Exception:
|
|
165
|
+
profile_id = "default"
|
|
166
|
+
|
|
167
|
+
project_path = ""
|
|
168
|
+
raw_cwd = payload.get("cwd")
|
|
169
|
+
if isinstance(raw_cwd, str):
|
|
170
|
+
project_path = raw_cwd[:_MAX_SUMMARY_LEN]
|
|
171
|
+
|
|
172
|
+
conn = sqlite3.connect(str(_memory_db_path()), timeout=0.05)
|
|
173
|
+
try:
|
|
174
|
+
conn.execute("PRAGMA busy_timeout=50")
|
|
175
|
+
conn.execute(
|
|
176
|
+
"INSERT INTO tool_events "
|
|
177
|
+
"(session_id, profile_id, project_path, tool_name, event_type,"
|
|
178
|
+
" input_summary, output_summary, duration_ms, metadata,"
|
|
179
|
+
" created_at) "
|
|
180
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
181
|
+
(
|
|
182
|
+
session_id,
|
|
183
|
+
profile_id,
|
|
184
|
+
project_path,
|
|
185
|
+
tool_name,
|
|
186
|
+
"complete",
|
|
187
|
+
_summary(payload.get("tool_input")),
|
|
188
|
+
_summary(response_text),
|
|
189
|
+
0,
|
|
190
|
+
"{}",
|
|
191
|
+
datetime.now(timezone.utc).isoformat(),
|
|
192
|
+
),
|
|
193
|
+
)
|
|
194
|
+
conn.commit()
|
|
195
|
+
finally:
|
|
196
|
+
conn.close()
|
|
197
|
+
return True
|
|
198
|
+
except Exception:
|
|
199
|
+
return False
|
|
200
|
+
|
|
201
|
+
|
|
86
202
|
def _inner_main() -> str:
|
|
87
203
|
"""Return an ``outcome`` string (for perf log); never raises."""
|
|
88
204
|
payload = read_stdin_json()
|
|
@@ -119,6 +235,12 @@ def _inner_main() -> str:
|
|
|
119
235
|
|
|
120
236
|
# Response scan — capped BEFORE regex (bound O(cap)).
|
|
121
237
|
response_text = summarize_response(payload.get("tool_response"))
|
|
238
|
+
|
|
239
|
+
# The invocation is recorded before anything is decided about markers. An
|
|
240
|
+
# action the agent took is a fact about the session on its own terms; the
|
|
241
|
+
# marker only decides whether it also settles a pending recall.
|
|
242
|
+
_record_tool_event(session_id, tool_name, payload, response_text)
|
|
243
|
+
|
|
122
244
|
if not response_text:
|
|
123
245
|
return "no_response"
|
|
124
246
|
|
|
@@ -326,9 +326,20 @@ class ContextualBandit:
|
|
|
326
326
|
play_id: int,
|
|
327
327
|
reward: float,
|
|
328
328
|
kind: str = "proxy_position",
|
|
329
|
+
weight: float = 1.0,
|
|
329
330
|
) -> bool:
|
|
330
331
|
"""Apply the reward to the (profile, stratum, arm) posterior.
|
|
331
332
|
|
|
333
|
+
``weight`` scales how much this one observation counts, and exists for
|
|
334
|
+
inverse-propensity correction: the policy chose what was shown, so an
|
|
335
|
+
arm it almost always shows produces weak evidence whatever happens to
|
|
336
|
+
it, while one it rarely shows produces strong evidence. Weighting by
|
|
337
|
+
the inverse of that probability is what keeps the posterior from
|
|
338
|
+
recording popularity the policy manufactured. See ``propensity.py``.
|
|
339
|
+
|
|
340
|
+
A weight of 1.0 is the uncorrected update and the default, so a caller
|
|
341
|
+
with no competitor posteriors to estimate against changes nothing.
|
|
342
|
+
|
|
332
343
|
Returns True on success. Never raises — DB failures logged at WARN.
|
|
333
344
|
Cache invalidated on success (B5).
|
|
334
345
|
"""
|
|
@@ -343,6 +354,15 @@ class ContextualBandit:
|
|
|
343
354
|
elif reward_f > 1.0:
|
|
344
355
|
reward_f = 1.0
|
|
345
356
|
|
|
357
|
+
try:
|
|
358
|
+
weight_f = float(weight)
|
|
359
|
+
except (TypeError, ValueError):
|
|
360
|
+
weight_f = 1.0
|
|
361
|
+
# A non-positive weight would either freeze the arm or subtract
|
|
362
|
+
# evidence; neither is a meaningful observation.
|
|
363
|
+
if weight_f <= 0.0:
|
|
364
|
+
weight_f = 1.0
|
|
365
|
+
|
|
346
366
|
try:
|
|
347
367
|
conn = _conn_for(self._db_path)
|
|
348
368
|
except sqlite3.Error as exc: # pragma: no cover — defensive
|
|
@@ -389,8 +409,8 @@ class ContextualBandit:
|
|
|
389
409
|
" plays = plays + 1, "
|
|
390
410
|
" last_played_at = ? "
|
|
391
411
|
"WHERE profile_id = ? AND stratum = ? AND arm_id = ?",
|
|
392
|
-
(cap, reward_f, cap, 1.0 - reward_f,
|
|
393
|
-
profile_id, stratum, arm_id),
|
|
412
|
+
(cap, weight_f * reward_f, cap, weight_f * (1.0 - reward_f),
|
|
413
|
+
now, profile_id, stratum, arm_id),
|
|
394
414
|
)
|
|
395
415
|
conn.execute(
|
|
396
416
|
"UPDATE bandit_plays "
|