superlocalmemory 4.0.9 → 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 (165) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +245 -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 +308 -20
  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 +26 -4
  44. package/src/superlocalmemory/code_graph/bridge/maintenance.py +8 -0
  45. package/src/superlocalmemory/code_graph/database.py +44 -0
  46. package/src/superlocalmemory/compliance/gdpr.py +449 -39
  47. package/src/superlocalmemory/core/admission.py +231 -11
  48. package/src/superlocalmemory/core/backend_orchestrator.py +190 -84
  49. package/src/superlocalmemory/core/config.py +90 -11
  50. package/src/superlocalmemory/core/consolidation_engine.py +34 -0
  51. package/src/superlocalmemory/core/engine.py +140 -11
  52. package/src/superlocalmemory/core/fact_consolidator.py +316 -125
  53. package/src/superlocalmemory/core/graph_analyzer.py +76 -112
  54. package/src/superlocalmemory/core/graph_metrics.py +597 -0
  55. package/src/superlocalmemory/core/graph_pruner.py +121 -0
  56. package/src/superlocalmemory/core/maintenance.py +44 -6
  57. package/src/superlocalmemory/core/maintenance_scheduler.py +205 -0
  58. package/src/superlocalmemory/core/memory_health.py +266 -0
  59. package/src/superlocalmemory/core/mode_capability.py +111 -0
  60. package/src/superlocalmemory/core/ollama_validator.py +315 -0
  61. package/src/superlocalmemory/core/operation_policy_registry.py +1 -1
  62. package/src/superlocalmemory/core/operation_request.py +1 -1
  63. package/src/superlocalmemory/core/ops_remediation.py +2 -2
  64. package/src/superlocalmemory/core/projection_drain.py +380 -0
  65. package/src/superlocalmemory/core/recall_pipeline.py +390 -3
  66. package/src/superlocalmemory/core/recall_worker.py +6 -3
  67. package/src/superlocalmemory/core/scale_autopromote.py +196 -0
  68. package/src/superlocalmemory/core/scale_engine.py +16 -2
  69. package/src/superlocalmemory/core/score_contract.py +21 -1
  70. package/src/superlocalmemory/core/session_identity.py +85 -0
  71. package/src/superlocalmemory/core/status_contract.py +108 -0
  72. package/src/superlocalmemory/core/store_pipeline.py +78 -3
  73. package/src/superlocalmemory/core/worker_pool.py +4 -4
  74. package/src/superlocalmemory/core/working_memory.py +288 -0
  75. package/src/superlocalmemory/encoding/cognitive_consolidator.py +51 -7
  76. package/src/superlocalmemory/encoding/context_generator.py +1 -1
  77. package/src/superlocalmemory/encoding/entity_resolver.py +38 -0
  78. package/src/superlocalmemory/encoding/fact_extractor.py +18 -14
  79. package/src/superlocalmemory/encoding/prospective_markers.py +262 -0
  80. package/src/superlocalmemory/encoding/type_router.py +12 -12
  81. package/src/superlocalmemory/evolution/mutation_generator.py +30 -4
  82. package/src/superlocalmemory/graph/cozo_adjacency.py +122 -0
  83. package/src/superlocalmemory/graph/cozo_backend.py +103 -138
  84. package/src/superlocalmemory/hooks/portable_kit.py +10 -2
  85. package/src/superlocalmemory/learning/bandit.py +43 -0
  86. package/src/superlocalmemory/learning/consolidation_worker.py +54 -0
  87. package/src/superlocalmemory/learning/database.py +60 -3
  88. package/src/superlocalmemory/learning/entity_compiler.py +21 -58
  89. package/src/superlocalmemory/learning/feedback.py +3 -1
  90. package/src/superlocalmemory/learning/outcomes.py +47 -16
  91. package/src/superlocalmemory/learning/pattern_miner.py +28 -3
  92. package/src/superlocalmemory/learning/pattern_miner_constants.py +43 -0
  93. package/src/superlocalmemory/learning/pcos.py +291 -0
  94. package/src/superlocalmemory/learning/reward_from_outcomes.py +365 -0
  95. package/src/superlocalmemory/learning/reward_proxy.py +100 -10
  96. package/src/superlocalmemory/learning/signal_kinds.py +79 -0
  97. package/src/superlocalmemory/mcp/profiles.py +14 -2
  98. package/src/superlocalmemory/mcp/server.py +1 -1
  99. package/src/superlocalmemory/mcp/session_binding.py +92 -0
  100. package/src/superlocalmemory/mcp/tools_active.py +2 -1
  101. package/src/superlocalmemory/mcp/tools_core.py +71 -42
  102. package/src/superlocalmemory/mcp/tools_ops.py +2 -2
  103. package/src/superlocalmemory/mcp/tools_v28.py +20 -1
  104. package/src/superlocalmemory/parameterization/pattern_extractor.py +14 -1
  105. package/src/superlocalmemory/parameterization/soft_prompt_generator.py +98 -0
  106. package/src/superlocalmemory/retrieval/bm25_channel.py +68 -11
  107. package/src/superlocalmemory/retrieval/channel_status.py +117 -0
  108. package/src/superlocalmemory/retrieval/engine.py +106 -11
  109. package/src/superlocalmemory/retrieval/entity_channel.py +217 -257
  110. package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
  111. package/src/superlocalmemory/retrieval/scope_policy.py +42 -1
  112. package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
  113. package/src/superlocalmemory/retrieval/spreading.py +288 -0
  114. package/src/superlocalmemory/retrieval/temporal_channel.py +13 -1
  115. package/src/superlocalmemory/retrieval/vector_store.py +63 -0
  116. package/src/superlocalmemory/server/api.py +26 -2
  117. package/src/superlocalmemory/server/asset_versions.py +171 -0
  118. package/src/superlocalmemory/server/bandit_loops.py +17 -1
  119. package/src/superlocalmemory/server/rbac_enforce.py +26 -6
  120. package/src/superlocalmemory/server/recall_serializer.py +9 -0
  121. package/src/superlocalmemory/server/routes/abstraction.py +201 -0
  122. package/src/superlocalmemory/server/routes/behavioral.py +75 -10
  123. package/src/superlocalmemory/server/routes/compliance.py +98 -18
  124. package/src/superlocalmemory/server/routes/config_api.py +186 -4
  125. package/src/superlocalmemory/server/routes/data_io.py +29 -1
  126. package/src/superlocalmemory/server/routes/entity.py +13 -1
  127. package/src/superlocalmemory/server/routes/evolution.py +178 -0
  128. package/src/superlocalmemory/server/routes/ingest.py +8 -0
  129. package/src/superlocalmemory/server/routes/learning_telemetry.py +2 -1
  130. package/src/superlocalmemory/server/routes/memories.py +49 -7
  131. package/src/superlocalmemory/server/routes/mesh.py +1 -1
  132. package/src/superlocalmemory/server/routes/timeline.py +4 -0
  133. package/src/superlocalmemory/server/routes/v3_api.py +193 -17
  134. package/src/superlocalmemory/server/ui.py +24 -1
  135. package/src/superlocalmemory/server/unified_daemon.py +292 -9
  136. package/src/superlocalmemory/storage/_migration_internals.py +35 -0
  137. package/src/superlocalmemory/storage/_schema_version.py +24 -3
  138. package/src/superlocalmemory/storage/database.py +598 -82
  139. package/src/superlocalmemory/storage/embedding_codec.py +71 -0
  140. package/src/superlocalmemory/storage/lineage_retention.py +236 -0
  141. package/src/superlocalmemory/storage/logical_edges.py +43 -2
  142. package/src/superlocalmemory/storage/migration_runner.py +130 -0
  143. package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +488 -0
  144. package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +127 -0
  145. package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +158 -0
  146. package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +620 -0
  147. package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +306 -0
  148. package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +207 -0
  149. package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +201 -0
  150. package/src/superlocalmemory/storage/migrations.py +18 -2
  151. package/src/superlocalmemory/storage/models.py +40 -1
  152. package/src/superlocalmemory/storage/projection_outbox.py +346 -0
  153. package/src/superlocalmemory/storage/retention_policy.py +860 -0
  154. package/src/superlocalmemory/storage/schema.py +110 -1
  155. package/src/superlocalmemory/storage/write_coordinator.py +19 -2
  156. package/src/superlocalmemory/summaries/base.py +1 -1
  157. package/src/superlocalmemory/summaries/non_answer.py +223 -0
  158. package/src/superlocalmemory/trust/scorer.py +43 -1
  159. package/src/superlocalmemory/ui/index.html +10 -19
  160. package/src/superlocalmemory/ui/js/event-delegation.js +12 -1
  161. package/src/superlocalmemory/ui/js/od-health.js +28 -6
  162. package/src/superlocalmemory/ui/js/od-memories.js +209 -1
  163. package/src/superlocalmemory/ui/js/od-ops-health.js +1 -1
  164. package/src/superlocalmemory/ui/js/od-settings.js +87 -1
  165. package/src/superlocalmemory/ui/js/recall-lab.js +78 -3
@@ -14,6 +14,9 @@ from __future__ import annotations
14
14
  import hashlib
15
15
  import hmac
16
16
  import logging
17
+ from dataclasses import replace
18
+
19
+ from superlocalmemory.core.session_identity import is_conversation
17
20
  import sqlite3
18
21
  from pathlib import Path
19
22
  from typing import TYPE_CHECKING, Any
@@ -253,6 +256,8 @@ def _behavioral_entities(results: list[Any], limit: int = 20) -> list[str]:
253
256
  _RANKING_MODES: frozenset[str] = frozenset({"off", "v1", "v2", "v2-ensemble"})
254
257
 
255
258
 
259
+ from superlocalmemory.learning.signal_kinds import FEEDBACK_ONLY_SQL
260
+
256
261
  class _ReadOnlyLearningView:
257
262
  """Minimal learning-model reader that cannot initialise or mutate a DB."""
258
263
 
@@ -271,11 +276,19 @@ class _ReadOnlyLearningView:
271
276
  return connection
272
277
 
273
278
  def count_signals(self, profile_id: str) -> int:
279
+ """Count FEEDBACK rows only — an exposure is not feedback.
280
+
281
+ Must agree with ``LearningDatabase.count_signals``: a phase resolved
282
+ from one of these is compared against a threshold resolved from the
283
+ other, so a difference between them is a phase that flickers. Both
284
+ share ``FEEDBACK_ONLY_SQL``; a test asserts neither carries its own
285
+ copy of the predicate.
286
+ """
274
287
  connection = self._connection()
275
288
  try:
276
289
  row = connection.execute(
277
290
  "SELECT COUNT(*) AS count FROM learning_signals "
278
- "WHERE profile_id = ?",
291
+ f"WHERE profile_id = ?{FEEDBACK_ONLY_SQL}",
279
292
  (profile_id,),
280
293
  ).fetchone()
281
294
  return int(row["count"]) if row else 0
@@ -348,6 +361,9 @@ def apply_ranking(
348
361
  config: Any = None,
349
362
  pipeline_version: str = "v2-ensemble",
350
363
  record_signals: bool = False,
364
+ record_plays: bool = True,
365
+ memory_db_path: Any = None,
366
+ play_sink: dict | None = None,
351
367
  ) -> "RecallResponse":
352
368
  """Run the ranking pipeline at the requested version.
353
369
 
@@ -383,6 +399,9 @@ def apply_ranking(
383
399
  response = apply_v2_bandit_ensemble(
384
400
  response, query, profile_id, query_id,
385
401
  record_signals=record_signals,
402
+ record_plays=record_plays,
403
+ memory_db_path=memory_db_path,
404
+ play_sink=play_sink,
386
405
  )
387
406
  except Exception as exc: # pragma: no cover — defensive
388
407
  logger.debug("apply_ranking ensemble step skipped: %s", exc)
@@ -605,6 +624,267 @@ def apply_v2_adaptive_ranking(
605
624
  # ---------------------------------------------------------------------------
606
625
 
607
626
 
627
+ def _rank_key(result) -> tuple[float, str]:
628
+ """Descending ordering key for the score-bias passes.
629
+
630
+ Falls back to ``score`` exactly as the writes do. Falling back to 0.0
631
+ instead — which is what a bare ``ranking_score or 0.0`` does — sorts every
632
+ result that no bias touched as if it scored nothing, collapsing the whole
633
+ un-adjusted tail to the bottom in fact_id order and discarding the ordering
634
+ retrieval worked for. ``ensemble_rerank`` populates ``ranking_score`` on
635
+ every candidate, so that only bites when the ensemble is disabled or has
636
+ fallen back — which is precisely when a bias must still behave.
637
+
638
+ One function rather than the same expression at each call site: two passes
639
+ that sort by different keys silently overwrite each other's ordering.
640
+
641
+ ``is None`` rather than a falsy test, because 0.0 is a real score. A
642
+ cross-encoder that judges a candidate irrelevant returns exactly that, and
643
+ ``x or y`` would then discard the model's verdict and rank the candidate on
644
+ its retrieval score instead — quietly promoting the thing the model just
645
+ ruled out.
646
+ """
647
+ utility = result.ranking_score
648
+ if utility is None:
649
+ utility = result.score
650
+ if utility is None:
651
+ utility = 0.0
652
+ return (-float(utility), result.fact.fact_id)
653
+
654
+
655
+ def _apply_outcome_bonus(
656
+ results: list, profile_id: str, memory_db_path: Any = None,
657
+ ) -> list:
658
+ """Nudge ranking by whether each memory has demonstrably helped before.
659
+
660
+ Bounded to ``pcos.MAX_BONUS`` (0.15) and weighted by how often the fact has
661
+ actually settled, so history breaks ties between similar-looking memories
662
+ and cannot drag a poor match to the top.
663
+
664
+ Order is recomputed here rather than left to the caller: a bonus that does
665
+ not reorder anything is a number nobody reads, which is the failure mode
666
+ this whole wave exists to fix.
667
+
668
+ Never raises. On a store where M045 has not run, ``fetch_scores`` returns
669
+ ``{}`` and this is the identity function.
670
+ """
671
+ if not results:
672
+ return results
673
+ try:
674
+ import sqlite3 as _sq
675
+
676
+ from superlocalmemory.infra.data_root import state_path
677
+ from superlocalmemory.learning.pcos import (
678
+ RECENT_TOPS,
679
+ bonus_for,
680
+ fetch_scores,
681
+ )
682
+
683
+ ids = [r.fact.fact_id for r in results if getattr(r, "fact", None)]
684
+ if not ids:
685
+ return results
686
+ # The store the RECALL used, not whichever one the data-root default
687
+ # resolves to. Hardcoding state_path("memory.db") worked on a default
688
+ # install and silently read another store's scores — or nothing at all —
689
+ # anywhere the engine was pointed elsewhere: a second data root, a test
690
+ # store, a config whose db_path diverges. Both fail modes are silent,
691
+ # because this function is fail-open by design.
692
+ db_file = Path(memory_db_path) if memory_db_path else state_path("memory.db")
693
+ conn = _sq.connect(f"file:{db_file}?mode=ro", uri=True, timeout=0.5)
694
+ try:
695
+ scores = fetch_scores(conn, profile_id, ids)
696
+ finally:
697
+ conn.close()
698
+ if not scores:
699
+ return results
700
+
701
+ adjusted = []
702
+ for r in results:
703
+ entry = scores.get(r.fact.fact_id)
704
+ if not entry:
705
+ adjusted.append(r)
706
+ continue
707
+ # A fact that has been winning first place lately stops EARNING the
708
+ # bonus. It keeps everything retrieval gave it — the alternative
709
+ # considered was a 10x demotion, which would bury a memory whose
710
+ # only offence is having been useful.
711
+ if RECENT_TOPS.capped(profile_id, r.fact.fact_id):
712
+ adjusted.append(r)
713
+ continue
714
+ delta = bonus_for(*entry)
715
+ if delta == 0.0:
716
+ adjusted.append(r)
717
+ continue
718
+ adjusted.append(replace(
719
+ r,
720
+ ranking_score=float(
721
+ r.ranking_score if r.ranking_score is not None else r.score
722
+ ) + delta,
723
+ ))
724
+ adjusted.sort(key=_rank_key)
725
+ if adjusted:
726
+ RECENT_TOPS.record_top(profile_id, adjusted[0].fact.fact_id)
727
+ return adjusted
728
+ except Exception as exc: # pragma: no cover — advisory, never fatal
729
+ logger.debug("outcome bonus skipped: %s", exc)
730
+ return results
731
+
732
+
733
+ #: Largest lift a memory can earn purely from having been in the last few
734
+ #: turns. Additive on ``ranking_score``, deliberately not a multiplier.
735
+ #:
736
+ #: A multiplier was the first design and it is scale-dependent, which makes the
737
+ #: same constant mean different things on two installs. Measured: with a
738
+ #: cross-encoder loaded, ranking scores land near [0, 1] and 1.5x is worth about
739
+ #: +0.45; with no reranker configured, raw fusion scores top out at
740
+ #: ``n_channels/(rrf_k+1)`` — 0.08 at k=60 — and the same 1.5x is worth +0.03.
741
+ #: One constant, a fifteen-fold difference in effect, decided by installed
742
+ #: optional components. An additive cap behaves the same everywhere.
743
+ #:
744
+ #: 0.20 sits just above the ceiling on the outcome bonus (0.15), so continuity
745
+ #: of attention outranks historical usefulness when the two disagree. Both are
746
+ #: bounded, so neither can drag a genuinely poor match to the top.
747
+ WM_MAX_BONUS = 0.20
748
+
749
+ #: Everything the ranking passes may add to one result, summed.
750
+ #:
751
+ #: Each pass caps its own contribution, which says nothing about the total —
752
+ #: and the total is what decides whether a genuinely better match can be
753
+ #: displaced. Two passes at 0.15 and 0.20 already reach 0.35; a third added
754
+ #: later would raise the ceiling with nothing to notice.
755
+ #:
756
+ #: Stated here so the invariant is one number rather than an emergent property
757
+ #: of however many passes exist, and asserted by test: a result ahead by more
758
+ #: than this cannot be overtaken by bias alone, and the sum of the individual
759
+ #: caps must not exceed it.
760
+ MAX_TOTAL_BIAS = 0.35
761
+
762
+
763
+ def _apply_working_memory_bias(
764
+ results: list, profile_id: str, session_id: str,
765
+ ) -> list:
766
+ """Lift memories this session was just looking at.
767
+
768
+ Retrieval still runs in full and this changes nothing about what was
769
+ retrieved — it reorders what came back. A memory absent from the working
770
+ set is untouched, so an empty working set is exactly the identity function
771
+ and the first turn of every session behaves as before.
772
+
773
+ Reads only in-process state: no query, no connection, no file. Deliberately
774
+ does not create a working set — a bias that ran on every recall would
775
+ otherwise register an entry for every session id that ever appears.
776
+
777
+ Never raises. Attention is advisory; a recall that cannot be biased is
778
+ still a correct recall.
779
+
780
+ WHAT OUTRANKS WHAT
781
+ ------------------
782
+ This is not the last word on the order. An exact lexical hit is promoted to
783
+ the top after every learned layer, including this one, so the full precedence
784
+ is:
785
+
786
+ exact lexical match > continuity > demonstrated usefulness > retrieval
787
+
788
+ That ordering is deliberate — a memory containing the caller's exact words
789
+ should not sit behind a merely similar one — but it means a boosted memory
790
+ can be moved back off position 1 by a guard that runs later. Two independent
791
+ reviewers raised it as a bug, which is what an undocumented precedence looks
792
+ like from outside, so it is written down here rather than left to be
793
+ rediscovered.
794
+ """
795
+ if not results or not is_conversation(session_id, profile_id):
796
+ return results
797
+ try:
798
+ from superlocalmemory.core.working_memory import peek
799
+
800
+ wm = peek(profile_id, session_id)
801
+ if wm is None:
802
+ return results
803
+ held = wm.boost_set()
804
+ if not held:
805
+ return results
806
+
807
+ adjusted = []
808
+ for r in results:
809
+ fact = getattr(r, "fact", None)
810
+ if fact is None or fact.fact_id not in held:
811
+ adjusted.append(r)
812
+ continue
813
+ adjusted.append(replace(
814
+ r,
815
+ ranking_score=float(
816
+ r.ranking_score if r.ranking_score is not None else r.score
817
+ ) + WM_MAX_BONUS,
818
+ ))
819
+ # Same ordering key as the outcome bonus, so the two compose instead of
820
+ # one silently overwriting the other's sort.
821
+ adjusted.sort(key=_rank_key)
822
+ return adjusted
823
+ except Exception as exc: # pragma: no cover — advisory, never fatal
824
+ logger.debug("working-memory bias skipped: %s", exc)
825
+ return results
826
+
827
+
828
+ def _resettle_shown_after_bias(
829
+ play_sink: dict, profile_id: str, results: list, shown_before: list[str],
830
+ ) -> None:
831
+ """Correct the play's evidence when the bias changed what was shown.
832
+
833
+ A play is settled later by asking whether anything downstream referenced one
834
+ of the memories this query surfaced. That question is answered against a
835
+ stored list, and the list was written before the continuity bias ran — so a
836
+ memory the bias lifted into the answer was absent from it, and an outcome
837
+ citing that memory settled nothing.
838
+
839
+ Rewritten only when the set actually differs, which is the uncommon case: a
840
+ cold session, or a warm one whose held memories were already at the top,
841
+ costs nothing. Never raises — a play whose evidence cannot be corrected
842
+ settles on the old list, which is the behaviour before this existed.
843
+ """
844
+ play_id = play_sink.get("play_id")
845
+ learning_db = play_sink.get("learning_db")
846
+ if not play_id or not learning_db:
847
+ return
848
+ try:
849
+ from superlocalmemory.core.working_memory import ADMIT_TOP_N
850
+ from superlocalmemory.learning.bandit import ContextualBandit
851
+
852
+ shown_now = [
853
+ r.fact.fact_id for r in results[:ADMIT_TOP_N]
854
+ if getattr(r, "fact", None) is not None
855
+ ]
856
+ if set(shown_now) == set(shown_before):
857
+ return
858
+ ContextualBandit(Path(learning_db), profile_id).record_shown(
859
+ play_id, shown_now,
860
+ )
861
+ except Exception as exc: # pragma: no cover — advisory
862
+ logger.debug("shown-set correction skipped: %s", exc)
863
+
864
+
865
+ def _admit_to_working_memory(
866
+ results: list, profile_id: str, session_id: str,
867
+ ) -> None:
868
+ """Remember what this answer showed, so the next turn is not cold.
869
+
870
+ Runs last, on the order the caller will actually see. Admitting the
871
+ pre-rerank order would teach the session something it was never shown.
872
+ """
873
+ if not results or not is_conversation(session_id, profile_id):
874
+ return
875
+ try:
876
+ from superlocalmemory.core.working_memory import ADMIT_TOP_N, get_or_create
877
+
878
+ shown = [
879
+ r.fact.fact_id for r in results[:ADMIT_TOP_N]
880
+ if getattr(r, "fact", None) is not None and r.fact.fact_id
881
+ ]
882
+ if shown:
883
+ get_or_create(profile_id, session_id).admit(shown)
884
+ except Exception as exc: # pragma: no cover — advisory, never fatal
885
+ logger.debug("working-memory admit skipped: %s", exc)
886
+
887
+
608
888
  def apply_v2_bandit_ensemble(
609
889
  response: RecallResponse,
610
890
  query: str,
@@ -613,8 +893,40 @@ def apply_v2_bandit_ensemble(
613
893
  *,
614
894
  learning_db_path: Any = None,
615
895
  record_signals: bool = False,
896
+ record_plays: bool = True,
897
+ memory_db_path: Any = None,
898
+ play_sink: dict | None = None,
616
899
  ) -> RecallResponse:
617
- """Apply contextual bandit + optional LGBM ensemble rerank. Safe on error."""
900
+ """Apply contextual bandit + optional LGBM ensemble rerank. Safe on error.
901
+
902
+ ``play_sink``, when given, receives the play this call recorded and the store
903
+ it went to. The caller needs both because the answer is not final here: a
904
+ continuity bias runs afterwards and can change which memories end up in
905
+ front of the user, and the play's evidence has to describe what was actually
906
+ shown. Caller-owned rather than returned on the response, matching how the
907
+ retrieval engine hands back its dropped-channel set.
908
+
909
+ ``record_signals`` and ``record_plays`` are separate because they are
910
+ separate things, and conflating them is what stopped the bandit learning.
911
+
912
+ * A **play** is "the bandit chose arm X for this query". It carries no
913
+ reward — ``bandit_plays.reward`` stays NULL until an authenticated
914
+ outcome settles it. One INSERT.
915
+ * A **signal** is an exposure row per displayed fact, twenty per query. Those
916
+ are what inflated the ranking-phase counter 2,675x, and the enqueue also
917
+ writes canonical/learning state, which is the contention the comment
918
+ below describes.
919
+
920
+ ``record_signals=False`` was hardcoded at the only caller on 2026-07-27
921
+ (commit ``cbf7929f``, release 3.8.6). That is the same day
922
+ ``MAX(bandit_arms.last_played_at)`` stops. It took the play recording down
923
+ with the exposure enqueue, so nothing was ever written for the reward proxy
924
+ to settle, and 165 arms have sat at alpha == beta ever since.
925
+
926
+ Recording a play is not feedback. It is the ticket the settlement path
927
+ later resolves against evidence, and without it there is no learning loop
928
+ at all.
929
+ """
618
930
  import os as _os
619
931
 
620
932
  if _os.environ.get("SLM_BANDIT_DISABLED", "0") == "1":
@@ -651,9 +963,13 @@ def apply_v2_bandit_ensemble(
651
963
  "query_type": response.query_type,
652
964
  "entity_count": entity_count,
653
965
  }
966
+ # Record the play unless explicitly told not to. ``choose_readonly``
967
+ # samples the same arm from a read-only snapshot and returns
968
+ # ``play_id=None``, so taking that branch means this query can never be
969
+ # settled and the arm can never move off its prior.
654
970
  choice = (
655
971
  bandit.choose(context, query_id)
656
- if record_signals
972
+ if record_plays
657
973
  else bandit.choose_readonly(context)
658
974
  )
659
975
 
@@ -688,10 +1004,42 @@ def apply_v2_bandit_ensemble(
688
1004
  logger.debug("v2 bandit ensemble_rerank skipped: %s", exc)
689
1005
  final_results = weighted
690
1006
 
1007
+ # --- 4b. outcome bonus -------------------------------------------
1008
+ # Applied AFTER the model score, never as a model feature. The wave
1009
+ # plan proposed adding "outcome_score" to FEATURE_NAMES for inference
1010
+ # and excluding it from training; that is a shape mismatch —
1011
+ # booster.predict needs the columns the model was trained on, and
1012
+ # features.py asserts len(FEATURE_NAMES) == FEATURE_DIM == 20 against a
1013
+ # live 20-feature model. Applying it here also makes it
1014
+ # true by construction: the model cannot learn from a signal it
1015
+ # never sees, so there is no self-reinforcing loop to exclude.
1016
+ final_results = _apply_outcome_bonus(
1017
+ final_results, profile_id, memory_db_path,
1018
+ )
1019
+
1020
+ # Give the play its evidence: which memories this query actually
1021
+ # surfaced, so the reward proxy can settle it from a downstream
1022
+ # reference instead of falling through to the neutral default. Written
1023
+ # after the rerank because that is when the shown order is final.
1024
+ if choice.play_id:
1025
+ try:
1026
+ bandit.record_shown(
1027
+ choice.play_id, [r.fact.fact_id for r in final_results[:5]],
1028
+ )
1029
+ except Exception as exc: # pragma: no cover — never break a recall
1030
+ logger.debug("v2 bandit record_shown skipped: %s", exc)
1031
+ if play_sink is not None:
1032
+ # So the caller can correct this record if the order changes
1033
+ # after this function returns.
1034
+ play_sink["play_id"] = choice.play_id
1035
+ play_sink["learning_db"] = str(db_path)
1036
+
691
1037
  # Recall is a query. Implicit learning signals are deliberately
692
1038
  # disabled on this path: even a non-blocking enqueue eventually writes
693
1039
  # canonical/learning state and turns dashboard polling into contention.
694
1040
  # An explicit feedback command owns durable learning signals instead.
1041
+ # NOTE: this gates the twenty-row exposure enqueue ONLY. The play above
1042
+ # is recorded regardless — see this function's docstring.
695
1043
  if record_signals:
696
1044
  try:
697
1045
  top20 = final_results[:20]
@@ -768,6 +1116,7 @@ def run_recall(
768
1116
  mode: Mode | None = None,
769
1117
  limit: int = 20,
770
1118
  agent_id: str = "unknown",
1119
+ session_id: str | None = None,
771
1120
  *,
772
1121
  config: SLMConfig,
773
1122
  retrieval_engine: Any,
@@ -916,13 +1265,46 @@ def run_recall(
916
1265
  import uuid as _uuid
917
1266
  query_id = _uuid.uuid4().hex
918
1267
  mode = _resolve_ranking_mode(_os.environ)
1268
+ play_sink: dict = {}
919
1269
  response = apply_ranking(
920
1270
  response, query, profile_id, query_id,
921
1271
  config=config, pipeline_version=mode, record_signals=False,
1272
+ # the store THIS recall read from, so the outcome bonus
1273
+ # cannot resolve a different one
1274
+ memory_db_path=getattr(db, "db_path", None),
1275
+ play_sink=play_sink,
922
1276
  )
923
1277
  except Exception as exc:
924
1278
  logger.debug("Ranking pipeline skipped: %s", exc)
925
1279
 
1280
+ # Continuity of attention, applied after every learned layer and outside
1281
+ # the ranking-version switch: a session's recent memories bias this answer
1282
+ # whether or not the bandit and the model are enabled, because they are
1283
+ # unrelated features and coupling them would make one disappear with the
1284
+ # other. In-process only — no query is added to the recall path.
1285
+ # Only an id that names a conversation drives continuity. The fronts invent
1286
+ # one per request (HTTP) or per client (MCP) for bookkeeping, and neither is
1287
+ # a conversation: the first would register a working set per dashboard
1288
+ # search and evict real ones, the second would pool unrelated clients into
1289
+ # a shared set. See core.session_identity.
1290
+ _sid = session_id if is_conversation(session_id, profile_id) else ""
1291
+ if _sid and response.results:
1292
+ from superlocalmemory.core.working_memory import ADMIT_TOP_N as _TOP
1293
+
1294
+ _shown_before = [
1295
+ r.fact.fact_id for r in response.results[:_TOP]
1296
+ if getattr(r, "fact", None) is not None
1297
+ ]
1298
+ response.results = _apply_working_memory_bias(
1299
+ response.results, profile_id, _sid,
1300
+ )
1301
+ # The play was recorded with the order as it stood before the line
1302
+ # above. If that order changed, its evidence now describes an answer
1303
+ # nobody saw.
1304
+ _resettle_shown_after_bias(
1305
+ play_sink, profile_id, response.results, _shown_before,
1306
+ )
1307
+
926
1308
  _preserve_exact_lexical_evidence(response, query)
927
1309
  _mark("learning+ranking")
928
1310
  # Deliberately no trust, Fisher, retention, lifecycle, popularity, or graph
@@ -936,5 +1318,10 @@ def run_recall(
936
1318
  # can validate fact_ids observed in downstream tool output.
937
1319
  _apply_markers_to_response(response)
938
1320
 
1321
+ # Last, on the order the caller receives, so the next turn of this session
1322
+ # starts from what was actually shown.
1323
+ if _sid:
1324
+ _admit_to_working_memory(response.results, profile_id, _sid)
1325
+
939
1326
  _mark("TOTAL(fisher+trust+markers)")
940
1327
  return response
@@ -106,9 +106,12 @@ def _handle_recall(
106
106
  memory_map=memory_map,
107
107
  per_fact_max=getattr(_rc, "recall_per_fact_max_chars", 2400),
108
108
  total_max=getattr(_rc, "recall_total_max_chars", 12000),
109
- # Option B: markers only on session-bearing recalls. A marker can
110
- # only buy a learning signal when a pending_outcomes row exists
111
- # to settle, and those exist only when session_id is present.
109
+ # Markers only on session-bearing recalls: a marker exists to let a
110
+ # downstream reference be traced back to the recall that produced it,
111
+ # and only a session-bearing recall has something to trace back to.
112
+ # (An earlier comment here justified the condition by a
113
+ # ``pending_outcomes`` row that no code path writes; settlement reads
114
+ # the evidence recorded with the play instead.)
112
115
  include_marker=bool(session_id),
113
116
  )
114
117
  return {