superlocalmemory 4.0.10 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +170 -0
  3. package/README.md +7 -7
  4. package/package.json +4 -2
  5. package/plugin/.claude-plugin/plugin.json +2 -2
  6. package/plugin/CLAUDE.md +3 -3
  7. package/plugin/agents/slm-governance-advisor.md +1 -1
  8. package/plugin/agents/slm-loop-runner.md +4 -4
  9. package/plugin/agents/slm-memory-advisor.md +1 -1
  10. package/plugin/agents/slm-optimize-advisor.md +1 -1
  11. package/plugin/requirements.txt +1 -1
  12. package/plugin/skills/slm-cache/SKILL.md +1 -1
  13. package/plugin/skills/slm-compress/SKILL.md +1 -1
  14. package/plugin/skills/slm-governance/SKILL.md +1 -1
  15. package/plugin/skills/slm-graph/SKILL.md +1 -1
  16. package/plugin/skills/slm-loop/SKILL.md +2 -2
  17. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  18. package/plugin/skills/slm-profile/SKILL.md +5 -5
  19. package/plugin/skills/slm-recall/SKILL.md +102 -15
  20. package/plugin/skills/slm-remember/SKILL.md +35 -3
  21. package/plugin/skills/slm-scope/SKILL.md +1 -1
  22. package/plugin/skills/slm-session/SKILL.md +29 -3
  23. package/plugin/skills/slm-status/SKILL.md +1 -1
  24. package/plugin-src/rules/AGENTS.md +16 -8
  25. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-governance/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-loop/SKILL.md +2 -2
  30. package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
  31. package/plugin-src/skills/slm-profile/SKILL.md +5 -5
  32. package/plugin-src/skills/slm-recall/SKILL.md +102 -15
  33. package/plugin-src/skills/slm-remember/SKILL.md +35 -3
  34. package/plugin-src/skills/slm-scope/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-session/SKILL.md +29 -3
  36. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  37. package/pyproject.toml +1 -1
  38. package/src/superlocalmemory/__init__.py +1 -1
  39. package/src/superlocalmemory/cli/commands.py +263 -18
  40. package/src/superlocalmemory/cli/daemon.py +30 -0
  41. package/src/superlocalmemory/cli/db_migrate.py +71 -1
  42. package/src/superlocalmemory/cli/gdpr_cmd.py +15 -2
  43. package/src/superlocalmemory/cli/main.py +24 -2
  44. package/src/superlocalmemory/code_graph/database.py +44 -0
  45. package/src/superlocalmemory/compliance/gdpr.py +449 -39
  46. package/src/superlocalmemory/core/admission.py +231 -11
  47. package/src/superlocalmemory/core/backend_orchestrator.py +190 -84
  48. package/src/superlocalmemory/core/config.py +90 -11
  49. package/src/superlocalmemory/core/consolidation_engine.py +34 -0
  50. package/src/superlocalmemory/core/engine.py +140 -11
  51. package/src/superlocalmemory/core/graph_analyzer.py +76 -112
  52. package/src/superlocalmemory/core/graph_metrics.py +597 -0
  53. package/src/superlocalmemory/core/graph_pruner.py +121 -0
  54. package/src/superlocalmemory/core/maintenance_scheduler.py +205 -0
  55. package/src/superlocalmemory/core/mode_capability.py +111 -0
  56. package/src/superlocalmemory/core/ollama_validator.py +315 -0
  57. package/src/superlocalmemory/core/projection_drain.py +380 -0
  58. package/src/superlocalmemory/core/recall_pipeline.py +390 -3
  59. package/src/superlocalmemory/core/recall_worker.py +6 -3
  60. package/src/superlocalmemory/core/scale_autopromote.py +196 -0
  61. package/src/superlocalmemory/core/scale_engine.py +16 -2
  62. package/src/superlocalmemory/core/score_contract.py +21 -1
  63. package/src/superlocalmemory/core/session_identity.py +85 -0
  64. package/src/superlocalmemory/core/status_contract.py +108 -0
  65. package/src/superlocalmemory/core/worker_pool.py +4 -4
  66. package/src/superlocalmemory/core/working_memory.py +288 -0
  67. package/src/superlocalmemory/encoding/cognitive_consolidator.py +36 -6
  68. package/src/superlocalmemory/encoding/context_generator.py +1 -1
  69. package/src/superlocalmemory/encoding/entity_resolver.py +38 -0
  70. package/src/superlocalmemory/encoding/fact_extractor.py +18 -14
  71. package/src/superlocalmemory/encoding/prospective_markers.py +262 -0
  72. package/src/superlocalmemory/encoding/type_router.py +12 -12
  73. package/src/superlocalmemory/evolution/mutation_generator.py +30 -4
  74. package/src/superlocalmemory/graph/cozo_adjacency.py +122 -0
  75. package/src/superlocalmemory/graph/cozo_backend.py +103 -138
  76. package/src/superlocalmemory/hooks/portable_kit.py +10 -2
  77. package/src/superlocalmemory/learning/bandit.py +43 -0
  78. package/src/superlocalmemory/learning/consolidation_worker.py +54 -0
  79. package/src/superlocalmemory/learning/database.py +60 -3
  80. package/src/superlocalmemory/learning/entity_compiler.py +21 -58
  81. package/src/superlocalmemory/learning/feedback.py +3 -1
  82. package/src/superlocalmemory/learning/outcomes.py +47 -16
  83. package/src/superlocalmemory/learning/pattern_miner.py +28 -3
  84. package/src/superlocalmemory/learning/pattern_miner_constants.py +43 -0
  85. package/src/superlocalmemory/learning/pcos.py +291 -0
  86. package/src/superlocalmemory/learning/reward_from_outcomes.py +365 -0
  87. package/src/superlocalmemory/learning/reward_proxy.py +100 -10
  88. package/src/superlocalmemory/learning/signal_kinds.py +79 -0
  89. package/src/superlocalmemory/mcp/profiles.py +14 -2
  90. package/src/superlocalmemory/mcp/tools_active.py +2 -1
  91. package/src/superlocalmemory/mcp/tools_core.py +31 -3
  92. package/src/superlocalmemory/mcp/tools_v28.py +20 -1
  93. package/src/superlocalmemory/parameterization/pattern_extractor.py +14 -1
  94. package/src/superlocalmemory/parameterization/soft_prompt_generator.py +98 -0
  95. package/src/superlocalmemory/retrieval/bm25_channel.py +64 -3
  96. package/src/superlocalmemory/retrieval/channel_status.py +117 -0
  97. package/src/superlocalmemory/retrieval/engine.py +106 -11
  98. package/src/superlocalmemory/retrieval/entity_channel.py +210 -256
  99. package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
  100. package/src/superlocalmemory/retrieval/scope_policy.py +20 -0
  101. package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
  102. package/src/superlocalmemory/retrieval/spreading.py +288 -0
  103. package/src/superlocalmemory/server/api.py +24 -5
  104. package/src/superlocalmemory/server/bandit_loops.py +17 -1
  105. package/src/superlocalmemory/server/rbac_enforce.py +26 -6
  106. package/src/superlocalmemory/server/recall_serializer.py +9 -0
  107. package/src/superlocalmemory/server/routes/behavioral.py +75 -10
  108. package/src/superlocalmemory/server/routes/compliance.py +98 -18
  109. package/src/superlocalmemory/server/routes/config_api.py +186 -4
  110. package/src/superlocalmemory/server/routes/evolution.py +178 -0
  111. package/src/superlocalmemory/server/routes/ingest.py +8 -0
  112. package/src/superlocalmemory/server/routes/learning_telemetry.py +2 -1
  113. package/src/superlocalmemory/server/routes/memories.py +49 -7
  114. package/src/superlocalmemory/server/routes/timeline.py +4 -0
  115. package/src/superlocalmemory/server/routes/v3_api.py +191 -15
  116. package/src/superlocalmemory/server/ui.py +20 -4
  117. package/src/superlocalmemory/server/unified_daemon.py +186 -5
  118. package/src/superlocalmemory/storage/_migration_internals.py +31 -0
  119. package/src/superlocalmemory/storage/_schema_version.py +24 -3
  120. package/src/superlocalmemory/storage/database.py +477 -59
  121. package/src/superlocalmemory/storage/embedding_codec.py +71 -0
  122. package/src/superlocalmemory/storage/lineage_retention.py +236 -0
  123. package/src/superlocalmemory/storage/logical_edges.py +43 -2
  124. package/src/superlocalmemory/storage/migration_runner.py +119 -0
  125. package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +127 -0
  126. package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +158 -0
  127. package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +620 -0
  128. package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +306 -0
  129. package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +207 -0
  130. package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +201 -0
  131. package/src/superlocalmemory/storage/migrations.py +18 -2
  132. package/src/superlocalmemory/storage/models.py +40 -1
  133. package/src/superlocalmemory/storage/projection_outbox.py +346 -0
  134. package/src/superlocalmemory/storage/retention_policy.py +860 -0
  135. package/src/superlocalmemory/storage/schema.py +12 -1
  136. package/src/superlocalmemory/storage/write_coordinator.py +19 -2
  137. package/src/superlocalmemory/trust/scorer.py +43 -1
  138. package/src/superlocalmemory/ui/index.html +9 -18
  139. package/src/superlocalmemory/ui/js/event-delegation.js +12 -1
  140. package/src/superlocalmemory/ui/js/od-health.js +28 -6
  141. package/src/superlocalmemory/ui/js/od-memories.js +19 -0
  142. package/src/superlocalmemory/ui/js/od-settings.js +87 -1
  143. package/src/superlocalmemory/ui/js/recall-lab.js +78 -3
@@ -0,0 +1,365 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+
4
+ """Settle a bandit play from an authenticated outcome.
5
+
6
+ WHY THIS FILE EXISTS
7
+ --------------------
8
+ ``reward_proxy.py`` has said since v3.4.22:
9
+
10
+ Replaced in v3.4.22 by ``reward_from_outcomes.py`` — DO NOT extend this
11
+ module beyond the proxy window contract.
12
+
13
+ That file was never written. So the replacement never arrived, and the module
14
+ that tells you not to extend it remained the only settlement path — one that
15
+ cannot work. Measured, not inferred:
16
+
17
+ * ``reward_proxy._tool_event_hit`` selects ``payload_json`` from ``tool_events``
18
+ ``WHERE occurred_at BETWEEN ? AND ?``. **Neither column exists** on that
19
+ table, on this install or in any DDL in the codebase. The query raises
20
+ ``no such column: payload_json``, a bare ``except sqlite3.Error`` returns
21
+ False, and the "cited" branch of the ladder is therefore unreachable.
22
+ * Even with the names corrected there would be nothing to find: **0 of 2,002**
23
+ ``tool_events`` rows on a live store contain a 16-hex token, let alone a
24
+ real ``fact_id``. That table records tool names and summaries, not the
25
+ memories a recall returned.
26
+
27
+ So every settlement fell through to the 120-second default of 0.5, which is
28
+ visible in the arms as an exact tie::
29
+
30
+ SUM(alpha) = SUM(beta) = 867.5 = 165 priors + 1,405 plays x 0.5
31
+
32
+ WHAT THIS USES INSTEAD
33
+ ----------------------
34
+ ``action_outcomes`` (memory.db, M006) — the table an *explicit* outcome report
35
+ already writes, with a reward the reporter chose:
36
+ ``success=1.0 / failure=0.0 / partial=0.5``. That is an authenticated signal
37
+ about whether the memories helped, which is exactly what a play needs and what
38
+ an exposure is not.
39
+
40
+ It carries ``recall_query_id``, a join key straight to ``bandit_plays.query_id``.
41
+ **0 of 162 rows populate it** — the column was added and never written. This
42
+ module reads it when present, and otherwise falls back to overlap between the
43
+ outcome's ``fact_ids_json`` and the ``shown_fact_ids`` the play recorded (M044).
44
+
45
+ The fallback is the load-bearing path, not a nicety: recall does not return its
46
+ ``query_id`` to the caller (``server/recall_serializer.py`` has no such field),
47
+ so no existing client *can* supply one. Overlap on the memories actually shown,
48
+ inside a time window, is the strongest link available without changing every
49
+ caller first.
50
+
51
+ THE GRACE WINDOW, AND WHY IT IS NOT 120 SECONDS
52
+ -----------------------------------------------
53
+ ``reward_proxy`` defaults a play to 0.5 once it is 120 seconds old. A human or
54
+ an agent deciding whether a memory helped does not do so within two minutes, so
55
+ that deadline would claim every play before any real outcome arrived — the
56
+ default would win a race it should not be in.
57
+
58
+ A play that recorded ``shown_fact_ids`` therefore waits ``_GRACE_SEC`` (default
59
+ 900) before it may be defaulted. A play with no recorded facts keeps the old
60
+ 120-second behaviour, because nothing can ever settle it from evidence and
61
+ holding it open buys nothing.
62
+
63
+ NOTHING HERE FABRICATES A REWARD. If no outcome is reported, the play settles
64
+ neutral and is *labelled* ``default`` so it stays distinguishable from a
65
+ genuinely neutral outcome. Those two were indistinguishable before, which is why
66
+ this went unnoticed for months.
67
+ """
68
+
69
+ from __future__ import annotations
70
+
71
+ import json
72
+ import logging
73
+ import os
74
+ import sqlite3
75
+ from datetime import datetime, timedelta, timezone
76
+ from pathlib import Path
77
+
78
+ from superlocalmemory.learning.bandit import ContextualBandit
79
+ from superlocalmemory.learning.pcos import update_scores
80
+
81
+ logger = logging.getLogger(__name__)
82
+
83
+ __all__ = ["settle_from_outcomes", "GRACE_SEC", "SETTLEMENT_KIND"]
84
+
85
+ #: How long a play that recorded its shown memories waits for a reported
86
+ #: outcome before it may be defaulted. Deliberately far longer than the proxy's
87
+ #: 120 s: see the module docstring.
88
+ GRACE_SEC: float = float(os.environ.get("SLM_OUTCOME_GRACE_SEC", "900"))
89
+
90
+ #: How far after a play an outcome may arrive and still be attributed to it.
91
+ #: Same order as the grace window — an outcome reported later than this is about
92
+ #: something else.
93
+ _MATCH_WINDOW_SEC: float = GRACE_SEC
94
+
95
+ #: Written to ``bandit_plays.settlement_type``. A settlement that came from a
96
+ #: real report must never be mistaken for the neutral default.
97
+ SETTLEMENT_KIND = "outcome_reported"
98
+
99
+ _MAX_PLAYS_PER_PASS = 500
100
+
101
+
102
+ def _parse_iso(ts: str | None) -> datetime | None:
103
+ if not ts:
104
+ return None
105
+ raw = str(ts).replace("Z", "+00:00")
106
+ try:
107
+ dt = datetime.fromisoformat(raw)
108
+ except ValueError:
109
+ return None
110
+ return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
111
+
112
+
113
+ def _open(path: Path) -> sqlite3.Connection | None:
114
+ try:
115
+ conn = sqlite3.connect(str(path), timeout=5.0)
116
+ conn.row_factory = sqlite3.Row
117
+ return conn
118
+ except sqlite3.Error as exc:
119
+ logger.debug("reward_from_outcomes: open %s failed: %s", path, exc)
120
+ return None
121
+
122
+
123
+ def _shown(raw: object) -> set[str]:
124
+ """``shown_fact_ids`` JSON -> set. Empty on anything unexpected."""
125
+ if not raw:
126
+ return set()
127
+ try:
128
+ parsed = json.loads(str(raw))
129
+ except (TypeError, ValueError):
130
+ return set()
131
+ if not isinstance(parsed, list):
132
+ return set()
133
+ return {str(f) for f in parsed if f}
134
+
135
+
136
+ def _unsettled_plays(
137
+ conn: sqlite3.Connection, profile_id: str,
138
+ ) -> list[sqlite3.Row]:
139
+ """Unsettled plays, oldest first. Degrades if M044 has not run."""
140
+ for columns in (
141
+ "play_id, query_id, played_at, shown_fact_ids",
142
+ "play_id, query_id, played_at, NULL AS shown_fact_ids",
143
+ ):
144
+ try:
145
+ return conn.execute(
146
+ f"SELECT {columns} FROM bandit_plays "
147
+ "WHERE profile_id = ? AND settled_at IS NULL "
148
+ "ORDER BY played_at ASC LIMIT ?",
149
+ (str(profile_id), _MAX_PLAYS_PER_PASS),
150
+ ).fetchall()
151
+ except sqlite3.Error as exc:
152
+ logger.debug("reward_from_outcomes: play fetch (%s): %s", columns, exc)
153
+ return []
154
+
155
+
156
+ def _candidate_outcomes(
157
+ conn: sqlite3.Connection, profile_id: str, since: datetime,
158
+ ) -> list[dict]:
159
+ """Reported outcomes from ``since`` onward, newest first.
160
+
161
+ Read once per pass rather than once per play: the window holds a handful of
162
+ rows in practice, and a per-play query would scan this table N times on the
163
+ settlement thread.
164
+ """
165
+ try:
166
+ rows = conn.execute(
167
+ "SELECT outcome_id, fact_ids_json, outcome, reward, timestamp, "
168
+ " recall_query_id "
169
+ "FROM action_outcomes "
170
+ "WHERE profile_id = ? AND timestamp >= ? "
171
+ "ORDER BY timestamp DESC LIMIT 2000",
172
+ (str(profile_id), since.isoformat()),
173
+ ).fetchall()
174
+ except sqlite3.Error as exc:
175
+ logger.debug("reward_from_outcomes: outcome fetch: %s", exc)
176
+ return []
177
+
178
+ out: list[dict] = []
179
+ for r in rows:
180
+ at = _parse_iso(r["timestamp"])
181
+ if at is None:
182
+ continue
183
+ try:
184
+ facts = json.loads(r["fact_ids_json"] or "[]")
185
+ except (TypeError, ValueError):
186
+ facts = []
187
+ if not isinstance(facts, list):
188
+ facts = []
189
+ try:
190
+ reward = float(r["reward"])
191
+ except (TypeError, ValueError):
192
+ continue
193
+ out.append({
194
+ "id": str(r["outcome_id"]),
195
+ "at": at,
196
+ "facts": {str(f) for f in facts if f},
197
+ "reward": max(0.0, min(1.0, reward)),
198
+ "query_id": str(r["recall_query_id"] or ""),
199
+ })
200
+ return out
201
+
202
+
203
+ def _match(
204
+ play_at: datetime,
205
+ query_id: str,
206
+ shown: set[str],
207
+ outcomes: list[dict],
208
+ claimed: set[str],
209
+ ) -> dict | None:
210
+ """The one outcome attributable to this play, or None.
211
+
212
+ Returns the outcome itself rather than its reward, because the caller needs
213
+ to know WHICH memories that outcome named — see ``settle_from_outcomes``.
214
+
215
+ AN OUTCOME IS EVIDENCE ABOUT ONE RECALL, AND IS CONSUMED
216
+ -------------------------------------------------------
217
+ The first version of this matched an outcome against every unsettled play
218
+ whose shown memories intersected it, and never consumed it. Both audits
219
+ reproduced the consequence and it is severe: five recalls that happened to
220
+ display the same memory, plus one reported outcome, settled **five** plays
221
+ and moved five arms; twenty recalls settled twenty. One person saying "that
222
+ helped" became twenty pieces of evidence about twenty different retrieval
223
+ strategies, most of which had nothing to do with it.
224
+
225
+ It also inflated confidence. ``play_count`` is what
226
+ ``pcos.confidence_weight`` reads, so the same fan-out could push a memory to
227
+ "proven" — full bonus — on a single judgement.
228
+
229
+ The effect on the learning claim is the part that matters: Thompson sampling
230
+ would be learning which memories were *nearby*, not which strategy helped.
231
+ That is how arms come off their prior and the ranker is still random.
232
+
233
+ So ``claimed`` carries the outcome ids already spent this pass, and an
234
+ outcome may be spent once. Plays are offered outcomes oldest-first, so the
235
+ recall that ran first gets the credit — the report is far more likely to be
236
+ about the answer the user actually saw.
237
+
238
+ THE EXACT PATH IS ALSO BOUNDED IN TIME. A ``recall_query_id`` is a stronger
239
+ statement of intent than overlap and is tried first, but it used to skip the
240
+ horizon check entirely: an outcome ten days later, naming completely
241
+ different memories, settled a long-closed play at reward 0.0. Reproduced.
242
+ Both paths now require the outcome to fall inside the window.
243
+ """
244
+ horizon = play_at + timedelta(seconds=_MATCH_WINDOW_SEC)
245
+
246
+ def _in_window(o: dict) -> bool:
247
+ return play_at <= o["at"] <= horizon
248
+
249
+ def _first(candidates: list[dict]) -> dict | None:
250
+ fresh = [o for o in candidates if o["id"] not in claimed]
251
+ return min(fresh, key=lambda o: o["at"]) if fresh else None
252
+
253
+ if query_id:
254
+ exact = _first([
255
+ o for o in outcomes
256
+ if o["query_id"] and o["query_id"] == query_id and _in_window(o)
257
+ ])
258
+ if exact is not None:
259
+ return exact
260
+
261
+ if not shown:
262
+ return None
263
+ return _first([
264
+ o for o in outcomes if _in_window(o) and (o["facts"] & shown)
265
+ ])
266
+
267
+
268
+ def settle_from_outcomes(
269
+ profile_id: str,
270
+ learning_db: Path | str,
271
+ memory_db: Path | str,
272
+ *,
273
+ now: datetime | None = None,
274
+ bandit: ContextualBandit | None = None,
275
+ ) -> int:
276
+ """Settle plays from reported outcomes. Returns the count. Never raises.
277
+
278
+ Run this BEFORE ``reward_proxy.settle_stale_plays`` on any pass. The proxy
279
+ defaults a play at 120 seconds; if it goes first it takes every play with
280
+ it and a reported outcome arriving at minute five finds nothing left to
281
+ settle.
282
+ """
283
+ current = now or datetime.now(timezone.utc)
284
+ learning_conn = _open(Path(learning_db))
285
+ if learning_conn is None:
286
+ return 0
287
+ memory_conn = _open(Path(memory_db))
288
+ if memory_conn is None:
289
+ learning_conn.close()
290
+ return 0
291
+
292
+ owns_bandit = bandit is None
293
+ if bandit is None:
294
+ bandit = ContextualBandit(Path(learning_db), profile_id=str(profile_id))
295
+
296
+ settled = 0
297
+ try:
298
+ plays = _unsettled_plays(learning_conn, profile_id)
299
+ if not plays:
300
+ return 0
301
+ oldest = min(
302
+ (p for p in (_parse_iso(r["played_at"]) for r in plays)
303
+ if p is not None),
304
+ default=current,
305
+ )
306
+ outcomes = _candidate_outcomes(memory_conn, profile_id, oldest)
307
+ if not outcomes:
308
+ return 0
309
+
310
+ # Outcome ids spent this pass. An outcome is evidence about one recall.
311
+ claimed: set[str] = set()
312
+
313
+ for row in plays:
314
+ played = _parse_iso(row["played_at"])
315
+ if played is None:
316
+ continue
317
+ shown = _shown(row["shown_fact_ids"])
318
+ outcome = _match(
319
+ played, str(row["query_id"] or ""), shown, outcomes, claimed,
320
+ )
321
+ if outcome is None:
322
+ continue
323
+ if bandit.update(int(row["play_id"]), outcome["reward"],
324
+ kind=SETTLEMENT_KIND):
325
+ settled += 1
326
+ claimed.add(outcome["id"])
327
+ # Credit ONLY the memories the outcome actually named. Writing
328
+ # it to everything the play displayed spread one judgement
329
+ # across up to five memories: an outcome naming one of them
330
+ # moved all five to 0.55 with play_count 1. Co-displayed
331
+ # memories inherited credit, the bonus then lifted them, and
332
+ # they were shown again — the rich-get-richer path this was
333
+ # built to prevent, one hop removed.
334
+ judged = sorted(outcome["facts"] & shown) if shown else []
335
+ if judged:
336
+ try:
337
+ update_scores(
338
+ memory_conn, profile_id, judged, outcome["reward"],
339
+ )
340
+ memory_conn.commit()
341
+ except Exception as exc: # pragma: no cover — defensive
342
+ logger.debug("pcos update skipped: %s", exc)
343
+ except sqlite3.Error as exc: # pragma: no cover — defensive
344
+ logger.warning("reward_from_outcomes: %s", exc)
345
+ finally:
346
+ for conn in (learning_conn, memory_conn):
347
+ try:
348
+ conn.close()
349
+ except sqlite3.Error: # pragma: no cover
350
+ pass
351
+ if owns_bandit:
352
+ try:
353
+ from superlocalmemory.learning.bandit import (
354
+ close_threadlocal_conn,
355
+ )
356
+
357
+ close_threadlocal_conn()
358
+ except Exception: # pragma: no cover — defensive
359
+ pass
360
+ if settled:
361
+ logger.info(
362
+ "reward_from_outcomes: settled %d play(s) from reported outcomes",
363
+ settled,
364
+ )
365
+ return settled
@@ -27,6 +27,7 @@ Hard rules:
27
27
 
28
28
  from __future__ import annotations
29
29
 
30
+ import json
30
31
  import logging
31
32
  import sqlite3
32
33
  from datetime import datetime, timedelta, timezone
@@ -76,18 +77,62 @@ def _fetch_unsettled(
76
77
  now: datetime,
77
78
  ) -> list[sqlite3.Row]:
78
79
  try:
79
- return learning_conn.execute(
80
- "SELECT play_id, query_id, played_at, stratum "
81
- "FROM bandit_plays "
82
- "WHERE profile_id = ? AND settled_at IS NULL "
83
- "ORDER BY played_at ASC LIMIT 500",
84
- (profile_id,),
85
- ).fetchall()
80
+ # shown_fact_ids arrives with M044; the NULL alias keeps this working
81
+ # on a store where it has not run yet.
82
+ for columns in (
83
+ "play_id, query_id, played_at, stratum, shown_fact_ids",
84
+ "play_id, query_id, played_at, stratum, NULL AS shown_fact_ids",
85
+ ):
86
+ try:
87
+ return learning_conn.execute(
88
+ f"SELECT {columns} FROM bandit_plays "
89
+ "WHERE profile_id = ? AND settled_at IS NULL "
90
+ "ORDER BY played_at ASC LIMIT 500",
91
+ (profile_id,),
92
+ ).fetchall()
93
+ except sqlite3.Error:
94
+ continue
95
+ return []
86
96
  except sqlite3.Error as exc:
87
97
  logger.debug("reward_proxy: fetch_unsettled: %s", exc)
88
98
  return []
89
99
 
90
100
 
101
+ def _shown_fact_ids(
102
+ learning_conn: sqlite3.Connection,
103
+ play_id: int,
104
+ ) -> list[str]:
105
+ """The fact_ids this play recorded at recall time (M044), or [].
106
+
107
+ Preferred over the ``learning_signals`` lookup below because it does not
108
+ depend on the exposure enqueue, which is off: twenty rows per query, and
109
+ the source of a 2,675x inflation in the ranking-phase counter. With no
110
+ signals rows there was nothing to look for, so every play settled as the
111
+ 120-second default and 165 arms sat at alpha == beta.
112
+
113
+ Returns [] on a store where M044 has not run — "no such column" is an
114
+ ``sqlite3.Error`` and is caught, so an unmigrated install falls back to the
115
+ old lookup rather than losing settlement entirely.
116
+ """
117
+ try:
118
+ row = learning_conn.execute(
119
+ "SELECT shown_fact_ids FROM bandit_plays WHERE play_id = ?",
120
+ (int(play_id),),
121
+ ).fetchone()
122
+ except sqlite3.Error:
123
+ return []
124
+ raw = (row[0] if row else None) or ""
125
+ if not raw:
126
+ return []
127
+ try:
128
+ parsed = json.loads(raw)
129
+ except (TypeError, ValueError):
130
+ return []
131
+ if not isinstance(parsed, list):
132
+ return []
133
+ return [str(f) for f in parsed[:3] if f]
134
+
135
+
91
136
  def _top3_fact_ids(
92
137
  learning_conn: sqlite3.Connection,
93
138
  query_id: str,
@@ -95,6 +140,8 @@ def _top3_fact_ids(
95
140
  """Return up to 3 fact_ids for this query_id ordered by position ASC.
96
141
 
97
142
  Returns [] if learning_signals is missing or has no rows for this qid.
143
+ Kept as the fallback for plays written before M044 and for installs that
144
+ do run the exposure enqueue.
98
145
  """
99
146
  try:
100
147
  rows = learning_conn.execute(
@@ -281,6 +328,44 @@ def _extract_query(payload_json: str | None) -> str:
281
328
  return ""
282
329
 
283
330
 
331
+ def _default_deadline(
332
+ learning_conn: sqlite3.Connection, play: sqlite3.Row,
333
+ ) -> float:
334
+ """Age in seconds at which this play may be defaulted to 0.5.
335
+
336
+ 120 s for a play that recorded nothing — no evidence can ever arrive for
337
+ it, so holding it open buys nothing but an unsettled row.
338
+
339
+ Longer for a play that recorded which memories it showed (M044), because a
340
+ reported outcome CAN settle it and two minutes is not long enough for
341
+ anyone to decide whether an answer helped. Defaulting first would make the
342
+ neutral fallback win a race it should not be in — which is how 1,405
343
+ consecutive plays came to apply exactly 0.5.
344
+ """
345
+ try:
346
+ from superlocalmemory.learning.reward_from_outcomes import GRACE_SEC
347
+ except Exception: # pragma: no cover — defensive
348
+ return float(_MAX_AGE_SEC)
349
+ try:
350
+ raw = play["shown_fact_ids"]
351
+ except (IndexError, KeyError):
352
+ return float(_MAX_AGE_SEC)
353
+ # PARSE, do not test the raw string. "[]" is truthy, so a play that recorded
354
+ # no memories used to buy the full grace window it could never use: nothing
355
+ # can overlap an empty set, so it stayed unsettled forever, and the
356
+ # retention sweep only removes SETTLED rows. Reproduced: "[]" returned
357
+ # 900 s where it should return 120 s.
358
+ try:
359
+ parsed = json.loads(raw) if raw else []
360
+ except (TypeError, ValueError):
361
+ parsed = []
362
+ has_evidence = bool(parsed) if isinstance(parsed, list) else False
363
+ return (
364
+ max(float(_MAX_AGE_SEC), GRACE_SEC) if has_evidence
365
+ else float(_MAX_AGE_SEC)
366
+ )
367
+
368
+
284
369
  def settle_stale_plays(
285
370
  profile_id: str,
286
371
  db_path: Path | str,
@@ -315,7 +400,12 @@ def settle_stale_plays(
315
400
  if age < _MIN_AGE_SEC:
316
401
  continue # not yet settleable
317
402
 
318
- top3 = _top3_fact_ids(learning_conn, row["query_id"])
403
+ # The play's own record first; the signals table only as a
404
+ # fallback for rows written before M044.
405
+ top3 = (
406
+ _shown_fact_ids(learning_conn, row["play_id"])
407
+ or _top3_fact_ids(learning_conn, row["query_id"])
408
+ )
319
409
  reward: float | None = None
320
410
  kind = "default"
321
411
  if memory_conn is not None and _tool_event_hit(
@@ -328,8 +418,8 @@ def settle_stale_plays(
328
418
  ):
329
419
  reward = 0.0
330
420
  kind = "proxy_requery"
331
- elif age > _MAX_AGE_SEC:
332
- # P1: uncertain default after 120 s window closes.
421
+ elif age > _default_deadline(learning_conn, row):
422
+ # P1: uncertain default once the window closes.
333
423
  reward = 0.5
334
424
  kind = "default"
335
425
  else:
@@ -0,0 +1,79 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+
4
+ """Which ``learning_signals`` rows are feedback, and which are just exposure.
5
+
6
+ THE DISTINCTION, AND WHY IT DECIDES A RANKING PHASE
7
+ ---------------------------------------------------
8
+ An **exposure** records that a fact was shown. A **feedback** signal records
9
+ that something happened afterwards. Only the second says anything about whether
10
+ the memory was any good, and the ranker's phase gate is meant to count the
11
+ second.
12
+
13
+ It counted both. Measured on a live ``learning.db``::
14
+
15
+ candidate 5,350 exposure — one row per fact shown at recall
16
+ legacy_feedback 2 the only real feedback in the table
17
+ ─────
18
+ 5,352 what count_signals() returned
19
+
20
+ An inflation of 2,675x. Every surface that resolves a phase from that number
21
+ believed it had Phase 3 data — LightGBM active — on two feedback events, and
22
+ the model it activated had been trained on 972 rows whose labels were all 0.0.
23
+ So it reordered results at random and outranked the heuristic that would
24
+ otherwise have been used.
25
+
26
+ Correcting the count drops the system to Phase 1 (heuristic). That is not a
27
+ regression; it is the honest state, and the heuristic is the better ranker of
28
+ the two.
29
+
30
+ WHY A SHARED SET RATHER THAN A LITERAL AT EACH CALL SITE
31
+ --------------------------------------------------------
32
+ There are two ``count_signals()`` implementations — ``learning/database.py``
33
+ and the read-only view in ``core/recall_pipeline.py`` — and a phase computed
34
+ from one is compared against a threshold computed from the other. Two literals
35
+ is how they drift.
36
+
37
+ This module deliberately has no imports: ``_ReadOnlyLearningView`` exists to
38
+ read a learning DB *without* being able to initialise or mutate one, so it must
39
+ not pull the writer module in just to learn a set of strings.
40
+
41
+ EXCLUDING EXPOSURES RATHER THAN NAMING FEEDBACK. A new feedback kind (``dwell``,
42
+ ``explicit``, ``cited``) must count the day it is introduced, without anyone
43
+ remembering to add it here — so the predicate is a NOT IN over the exposure
44
+ kinds, not an IN over the feedback kinds. The failure modes are not symmetric:
45
+ forgetting to add a feedback kind under-counts and quietly holds the ranker in
46
+ an earlier phase, while forgetting to add an exposure kind re-creates the 2,675x
47
+ inflation above.
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ __all__ = ["EXPOSURE_SIGNAL_TYPES", "FEEDBACK_ONLY_SQL", "is_feedback"]
53
+
54
+ #: Signal kinds that record only that a fact was displayed.
55
+ #:
56
+ #: ``candidate`` is written once per fact per recall. ``shown`` is its sibling
57
+ #: in ``_fetch_training_rows``; there are no rows of it on a live store,
58
+ #: and it is listed here because the alternative is a predicate that counts it
59
+ #: as feedback the moment one appears.
60
+ EXPOSURE_SIGNAL_TYPES: frozenset[str] = frozenset({"candidate", "shown"})
61
+
62
+ #: AND-clause selecting feedback rows only. ``signal_type`` is ``NOT NULL`` in
63
+ #: the schema, so no COALESCE is needed — verified against the table definition
64
+ #: rather than assumed, because ``x != 'candidate'`` is NULL (and therefore
65
+ #: false) for a NULL x, which would silently drop rows.
66
+ FEEDBACK_ONLY_SQL: str = " AND signal_type NOT IN ({})".format(
67
+ ", ".join(f"'{kind}'" for kind in sorted(EXPOSURE_SIGNAL_TYPES))
68
+ )
69
+
70
+
71
+ def is_feedback(signal_type: str | None) -> bool:
72
+ """Whether ``signal_type`` counts toward the ranker's phase gate.
73
+
74
+ The Python twin of :data:`FEEDBACK_ONLY_SQL`, for callers holding a row
75
+ rather than building a query. A missing type counts as feedback: it is not
76
+ one of the two known exposure writers, and under-counting is the failure
77
+ mode that hides a phase transition.
78
+ """
79
+ return (signal_type or "") not in EXPOSURE_SIGNAL_TYPES
@@ -17,7 +17,7 @@ from __future__ import annotations
17
17
  # Named profile definitions (introduced in v3.6.14)
18
18
  # ---------------------------------------------------------------------------
19
19
 
20
- _PROFILE_CORE: frozenset[str] = frozenset({ # 17
20
+ _PROFILE_CORE: frozenset[str] = frozenset({ # 18
21
21
  "remember", "recall", "search", "fetch", "list_recent", "update_memory", "forget",
22
22
  "session_init", "close_session",
23
23
  "slm_compress", "slm_retrieve", "slm_cache_set", "slm_cache_get", "slm_optimize_stats",
@@ -28,6 +28,13 @@ _PROFILE_CORE: frozenset[str] = frozenset({ # 17
28
28
  # natural caller is the agent holding the conversation — an assistant
29
29
  # asked "what did I work on yesterday" should not need a power profile.
30
30
  "get_memory_summary",
31
+ # v4.1.0: every memory operation is scoped to a profile, so a surface that
32
+ # can read or write memory must also be able to say which profile it means.
33
+ # Without this, the smallest profile can store and recall but can never
34
+ # leave the profile it happened to start in, and a second workspace is
35
+ # unreachable from the surface most hosts ship. The route stays RBAC
36
+ # member-gated, so company-mode isolation is unaffected.
37
+ "switch_profile",
31
38
  })
32
39
 
33
40
  # Portable Brain evidence must reach the coding-host profile shipped by the
@@ -48,6 +55,11 @@ _PROFILE_CODE: frozenset[str] = _PROFILE_CORE | _PROFILE_BRAIN | frozenset({ #
48
55
  # v3.8.0: bounded loops on the MCP surface. Coding agents (the /slm-loop
49
56
  # command's audience) run gated, bounded loops and inspect the ledger.
50
57
  "slm_loop_run", "slm_loop_history", "slm_loop_show",
58
+ # Retrieval ranks a memory partly on whether it has actually helped, and
59
+ # the only evidence of that comes from the assistant that used it. The
60
+ # plugin ships SLM_MCP_PROFILE=code, so without these two the ranker has
61
+ # no input at all for the audience it exists to serve.
62
+ "report_outcome", "report_feedback",
51
63
  })
52
64
 
53
65
  _PROFILE_FULL_MESH: frozenset[str] = frozenset({ # 8
@@ -74,7 +86,7 @@ _PROFILE_FULL: frozenset[str] = frozenset({
74
86
  # prestage_context remains registered but deliberately raw-server-only.
75
87
  }) | _PROFILE_FULL_MESH # 50
76
88
 
77
- _PROFILE_POWER: frozenset[str] = _PROFILE_FULL | frozenset({ # 61
89
+ _PROFILE_POWER: frozenset[str] = _PROFILE_FULL | frozenset({ # 62
78
90
  "get_version", "get_mode", "health", "consistency_check", "recall_trace",
79
91
  "get_lifecycle_status", "set_retention_policy", "compact_memories",
80
92
  "get_behavioral_patterns", "audit_trail", "quantize", "get_retention_stats",
@@ -287,7 +287,7 @@ def _upcoming_scheduled_facts(engine, now: datetime.datetime) -> list[dict]:
287
287
  "SELECT fact_id, content, referenced_date"
288
288
  " FROM atomic_facts"
289
289
  " WHERE profile_id = ?"
290
- " AND fact_type = 'temporal'"
290
+ " AND fact_type = 'prospective'"
291
291
  " AND referenced_date IS NOT NULL"
292
292
  " AND referenced_date >= ?"
293
293
  " AND referenced_date < ?"
@@ -312,6 +312,7 @@ def register_active_tools(server, get_engine: Callable) -> None:
312
312
  # 1. session_init — Auto-recall project context at session start
313
313
  # ------------------------------------------------------------------
314
314
  @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
315
+ @admits(OperationKind.RECALL)
315
316
  async def session_init(
316
317
  project_path: str = "",
317
318
  query: str = "",