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
@@ -65,6 +65,7 @@ class OutcomeTracker:
65
65
  outcome: str,
66
66
  profile_id: str,
67
67
  context: dict[str, Any] | None = None,
68
+ recall_query_id: str = "",
68
69
  ) -> ActionOutcome:
69
70
  """Record an outcome against one or more facts.
70
71
 
@@ -74,15 +75,24 @@ class OutcomeTracker:
74
75
  outcome: One of "success", "failure", "partial".
75
76
  profile_id: Profile scope.
76
77
  context: Arbitrary metadata dict.
78
+ recall_query_id: The name of the answer being reported on, as it
79
+ came back from recall. Supplying it ties this report to that
80
+ exact answer; leaving it empty falls back to matching on which
81
+ memories overlap, inside a time window.
77
82
 
78
83
  Returns:
79
84
  The persisted ActionOutcome.
80
85
  """
81
86
  if outcome not in VALID_OUTCOMES:
82
- logger.warning(
83
- "Invalid outcome '%s'. Must be one of %s", outcome, VALID_OUTCOMES
87
+ # Refused, not reinterpreted. This used to become "partial", which
88
+ # carries a reward of 0.5 -- so a typo, a wrong enum, or a client
89
+ # sending "ok" was recorded as a mildly positive judgement of those
90
+ # memories and fed to the ranking as if somebody had meant it.
91
+ # Silently inventing a signal is worse than having none.
92
+ raise ValueError(
93
+ f"outcome must be one of {sorted(VALID_OUTCOMES)}, not "
94
+ f"{outcome!r}"
84
95
  )
85
- outcome = "partial"
86
96
 
87
97
  ao = ActionOutcome(
88
98
  profile_id=profile_id,
@@ -92,23 +102,44 @@ class OutcomeTracker:
92
102
  context=dict(context) if context else {},
93
103
  )
94
104
 
105
+ columns = [
106
+ "outcome_id", "profile_id", "query", "fact_ids_json", "outcome",
107
+ "context_json", "timestamp",
108
+ ]
109
+ values: list[Any] = [
110
+ ao.outcome_id, ao.profile_id, ao.query, json.dumps(ao.fact_ids),
111
+ ao.outcome, json.dumps(ao.context), ao.timestamp,
112
+ ]
113
+ # Only when there is one to write, and only on a store that has the
114
+ # column: it arrives with a migration, and an older store must keep
115
+ # recording outcomes rather than start failing on them.
116
+ if recall_query_id and self._has_recall_query_id():
117
+ columns.append("recall_query_id")
118
+ values.append(str(recall_query_id))
119
+ placeholders = ", ".join("?" * len(values))
95
120
  self._db.execute(
96
- "INSERT OR REPLACE INTO action_outcomes "
97
- "(outcome_id, profile_id, query, fact_ids_json, outcome, "
98
- " context_json, timestamp) "
99
- "VALUES (?, ?, ?, ?, ?, ?, ?)",
100
- (
101
- ao.outcome_id,
102
- ao.profile_id,
103
- ao.query,
104
- json.dumps(ao.fact_ids),
105
- ao.outcome,
106
- json.dumps(ao.context),
107
- ao.timestamp,
108
- ),
121
+ f"INSERT OR REPLACE INTO action_outcomes ({', '.join(columns)}) "
122
+ f"VALUES ({placeholders})",
123
+ tuple(values),
109
124
  )
110
125
  return ao
111
126
 
127
+ def _has_recall_query_id(self) -> bool:
128
+ """Whether this store records which answer an outcome is about."""
129
+ cached = getattr(self, "_recall_query_id_column", None)
130
+ if cached is not None:
131
+ return bool(cached)
132
+ try:
133
+ rows = self._db.execute("PRAGMA table_info(action_outcomes)")
134
+ present = any(
135
+ str(dict(row).get("name") or row[1]) == "recall_query_id"
136
+ for row in rows
137
+ )
138
+ except Exception: # noqa: BLE001
139
+ present = False
140
+ self._recall_query_id_column = present
141
+ return present
142
+
112
143
  # ------------------------------------------------------------------
113
144
  # Public API — Querying
114
145
  # ------------------------------------------------------------------
@@ -103,12 +103,37 @@ def generate_patterns(
103
103
  # ---------------------------------------------------------------------------
104
104
 
105
105
 
106
+ #: Whole-word matchers for the technology keywords, built once.
107
+ #:
108
+ #: These used to be substring tests, and the short names are inside ordinary
109
+ #: English: "going" contains "go", "reaction" contains "react", "digital"
110
+ #: contains "git", "javascript" contains "java". One real sentence — "the
111
+ #: digital transformation is going well, the reaction was mixed" — produced
112
+ #: Git, Go and React. Those rows feed the soft prompt that tells an assistant
113
+ #: "default to these when generating code", on every turn.
114
+ #:
115
+ #: A boundary is only added where the keyword actually ends in a word
116
+ #: character, so "c++", ".net" and "node.js" still match.
117
+ _TECH_PATTERNS: dict[str, tuple[re.Pattern[str], ...]] = {}
118
+
119
+
120
+ def _matcher(keyword: str) -> re.Pattern[str]:
121
+ left = r"(?<![A-Za-z0-9_])" if keyword[:1].isalnum() else ""
122
+ right = r"(?![A-Za-z0-9_])" if keyword[-1:].isalnum() else ""
123
+ return re.compile(left + re.escape(keyword) + right, re.IGNORECASE)
124
+
125
+
106
126
  def _mine_tech_preferences(store, facts, profile_id, dry_run) -> int:
127
+ if not _TECH_PATTERNS:
128
+ for keyword, label in _TECH_KEYWORDS.items():
129
+ _TECH_PATTERNS.setdefault(label, ())
130
+ _TECH_PATTERNS[label] += (_matcher(keyword),)
131
+
107
132
  tech_counts: Counter = Counter()
108
133
  for f in facts:
109
- content = dict(f)["content"].lower()
110
- for keyword, label in _TECH_KEYWORDS.items():
111
- if keyword in content:
134
+ content = dict(f)["content"]
135
+ for label, patterns in _TECH_PATTERNS.items():
136
+ if any(p.search(content) for p in patterns):
112
137
  tech_counts[label] += 1
113
138
 
114
139
  gen = 0
@@ -29,6 +29,18 @@ TECH_KEYWORDS: dict[str, str] = {
29
29
  "terraform": "Terraform", "nginx": "Nginx",
30
30
  "linux": "Linux", "macos": "macOS",
31
31
  "vscode": "VS Code", "neovim": "Neovim",
32
+ # The spellings people actually type. Whole-word matching fixed a real
33
+ # defect — "going" no longer counts as Go — and cost these, because there
34
+ # is no word boundary inside "golang" or "nodejs". They are listed rather
35
+ # than matched by prefix, because a prefix rule brings the original problem
36
+ # straight back.
37
+ "golang": "Go", "nodejs": "Node.js", "node.js": "Node.js",
38
+ "reactjs": "React", "react.js": "React",
39
+ "vuejs": "Vue", "vue.js": "Vue",
40
+ "next.js": "Next.js", "nuxtjs": "Nuxt", "nuxt": "Nuxt",
41
+ "postgres": "PostgreSQL", "k8s": "Kubernetes",
42
+ "typescript": "TypeScript", "ts": "TypeScript",
43
+ "golang.org": "Go",
32
44
  }
33
45
 
34
46
 
@@ -41,7 +53,38 @@ STOPWORDS: frozenset[str] = frozenset({
41
53
  "does", "did", "about", "into", "over", "after", "before",
42
54
  "then", "than", "also", "just", "like", "more", "some",
43
55
  "only", "other", "such", "each", "every", "both", "most",
56
+ # Pronouns and subordinators. Their absence is why "their" and "while"
57
+ # became recorded interests on a live store, at confidence 1.0, and were
58
+ # then rendered into a prompt injected on every turn. A word that appears
59
+ # in most English sentences tells you nothing about the person writing them.
60
+ "their", "them", "they", "these", "those", "there", "while", "when",
61
+ "where", "which", "who", "whom", "whose", "what", "why", "how",
62
+ "he", "she", "him", "her", "his", "hers", "we", "us", "our", "ours",
63
+ "you", "your", "yours", "i", "me", "my", "mine", "myself",
64
+ "if", "else", "because", "since", "until", "unless", "though",
65
+ "although", "however", "therefore", "thus", "here", "very", "much",
66
+ "many", "same", "own", "too", "any", "all", "none", "nor", "yet",
67
+ "so", "up", "down", "out", "off", "again", "once", "still",
44
68
  })
45
69
 
46
70
 
71
+ def _augment_with_shared_list() -> frozenset[str]:
72
+ """Fold in the larger stopword list this codebase already maintains.
73
+
74
+ ``core.topic_signature`` carries a longer list, and it contained both of the
75
+ words that leaked through here. Two lists of the same thing is how one ends
76
+ up worse than the other, so this reads that one rather than restating it —
77
+ and keeps working if it ever moves, because a missing import degrades to the
78
+ list above instead of failing at import time.
79
+ """
80
+ try:
81
+ from superlocalmemory.core.topic_signature import _STOPWORDS as _shared
82
+ except Exception: # pragma: no cover — the local list still applies
83
+ return STOPWORDS
84
+ return STOPWORDS | frozenset(_shared)
85
+
86
+
87
+ STOPWORDS = _augment_with_shared_list()
88
+
89
+
47
90
  __all__ = ("TECH_KEYWORDS", "STOPWORDS")
@@ -0,0 +1,291 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+
4
+ """Per-fact outcome score: read it, update it, and damp it honestly.
5
+
6
+ WHAT IT IS
7
+ ----------
8
+ One number per (fact, profile): an exponentially-weighted average of the rewards
9
+ of the settlements that fact took part in. Ranking otherwise scores a memory
10
+ purely by how much it *resembles* the query — nothing in the pipeline knows
11
+ whether a memory has ever actually helped.
12
+
13
+ WHY IT IS NOT A MODEL FEATURE, WHICH AN EARLIER DESIGN GOT BACKWARDS
14
+ ----------------------------------------------------------------
15
+ An earlier design said to add ``"outcome_score"`` to ``FEATURE_NAMES`` for inference and
16
+ exclude it from the training matrix. That cannot work, and the model's own
17
+ closing gotcha says why: ``booster.predict(X)`` needs the same columns in the
18
+ same order the model was trained on. A feature present at inference and absent
19
+ in training is a shape mismatch, not a clever exclusion.
20
+
21
+ Two more reasons, both checkable: ``features.py`` asserts
22
+ ``len(FEATURE_NAMES) == FEATURE_DIM`` with ``FEATURE_DIM = 20``, and
23
+ ``routes/brain.py`` surfaces that constant as ``feature_count_expected``. The
24
+ live model is a 20-feature model. Adding a 21st silently invalidates it.
25
+
26
+ So PCOS is applied AFTER the model score, as a bonus on the ranking score. That
27
+ satisfies this true by construction: there is nothing to exclude from
28
+ training because it never enters training. Self-reinforcement — "model learns
29
+ high PCOS wins, which raises PCOS" — is impossible when the model cannot see it.
30
+
31
+ THE TWO DAMPING RULES, AND WHAT EACH IS FOR
32
+ -------------------------------------------
33
+ **Confidence weighting.** A fact settled once at reward 1.0 is not a fact that
34
+ works; it is a fact that worked once. The bonus is scaled by
35
+ ``log1p(play_count) / log1p(20)``, so a single settlement contributes ~1/5 of
36
+ what twenty do, and an unsettled fact contributes exactly nothing rather than
37
+ being penalised for being new.
38
+
39
+ **Rich-get-richer.** A fact that ranks well gets shown, gets settled, ranks
40
+ better. Measured over 1,000 simulated queries with near-tied retrieval scores,
41
+ the single most-favoured fact took first place in 5.30% of them with the bonus
42
+ on and 1.30% with it off — so the bonus alone breaks the release limit
43
+ of "no fact above 5%". The counter-pressure is ``RecentTopCounter``: once a fact
44
+ has won first place ``_CAP_MIN`` times inside a rolling window it stops
45
+ receiving the bonus. Not removed and not demoted — a memory that genuinely keeps
46
+ being relevant must stay returnable. It only stops compounding.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ import logging
52
+ import math
53
+ import sqlite3
54
+ from collections import defaultdict
55
+ from pathlib import Path
56
+
57
+ logger = logging.getLogger(__name__)
58
+
59
+ __all__ = [
60
+ "fetch_scores",
61
+ "update_scores",
62
+ "confidence_weight",
63
+ "bonus_for",
64
+ "RecentTopCounter",
65
+ "RECENT_TOPS",
66
+ "TAU",
67
+ "MAX_BONUS",
68
+ ]
69
+
70
+ #: EMA rate. Low on purpose: one outcome should nudge a score, not define it.
71
+ TAU = 0.1
72
+
73
+ #: play_count at which a score is trusted in full.
74
+ _FULL_CONFIDENCE_PLAYS = 20
75
+
76
+ #: Largest absolute change PCOS may make to a ranking score, as a fraction.
77
+ #: The point of PCOS is to break ties between similar-looking memories, not to
78
+ #: overrule the retrieval that found them — a memory that does not match the
79
+ #: query must never be dragged to the top by history.
80
+ MAX_BONUS = 0.15
81
+
82
+ #: First-place finishes inside the rolling window before a fact stops
83
+ #: receiving the bonus. Three is low on purpose: the measurement shows
84
+ #: concentration comes from a small number of repeat winners, so the cap has to
85
+ #: bite early to matter at all.
86
+ _CAP_MIN = 3
87
+
88
+ _NEUTRAL = 0.5
89
+
90
+
91
+ def confidence_weight(play_count: int) -> float:
92
+ """How much of a fact's score to believe, from how often it has settled.
93
+
94
+ ``log1p`` rather than linear: the difference between one settlement and two
95
+ is far more informative than between nineteen and twenty.
96
+ """
97
+ if play_count <= 0:
98
+ return 0.0
99
+ return min(1.0, math.log1p(play_count) / math.log1p(_FULL_CONFIDENCE_PLAYS))
100
+
101
+
102
+ def bonus_for(score: float, play_count: int) -> float:
103
+ """Signed ranking bonus in ``[-MAX_BONUS, +MAX_BONUS]``.
104
+
105
+ Centred on 0.5, so a fact whose outcomes are neutral gets no bonus at all
106
+ and an unproven fact is never penalised relative to one that has never been
107
+ tried.
108
+ """
109
+ return (
110
+ (float(score) - _NEUTRAL) * 2.0
111
+ * confidence_weight(int(play_count))
112
+ * MAX_BONUS
113
+ )
114
+
115
+
116
+ class RecentTopCounter:
117
+ """How often each fact has recently won first place, per profile.
118
+
119
+ WHY THIS EXISTS, AND WHY IT IS NOT OPTIONAL. Measured over a
120
+ 1,000-query simulation with 200 facts and near-tied retrieval scores
121
+ (spread 0.02, which is what an embedding channel actually returns for
122
+ closely related memories), the single most-favoured fact took first place:
123
+
124
+ no outcome bonus 1.30% of queries
125
+ bonus, MAX_BONUS = 0.15 5.30% of queries
126
+
127
+ The release limit is "no fact reaches more than 5% of displays over
128
+ a 1,000-query simulation". The bonus alone breaks it. A control run with the
129
+ bonus disabled sits at 1.30% at every spread, so the bonus is the cause and
130
+ not the tie-breaking — that control is the only reason this is known.
131
+
132
+ HOW IT DIFFERS FROM THE PLAN. The original countermeasure multiplies a capped
133
+ fact's ``ranking_score`` by 0.1. That is a 10x demotion of a memory whose
134
+ only offence is having been useful three times, and it would visibly damage
135
+ answers — a genuinely relevant memory must stay returnable. This instead
136
+ withholds the BONUS from a fact that has recently been winning. The fact
137
+ keeps every point retrieval gave it and simply stops compounding.
138
+
139
+ WHY A ROLLING WINDOW RATHER THAN A SESSION. ``run_recall`` has no
140
+ ``session_id`` parameter, so there is no session identity at this layer to
141
+ key on, and threading one through every caller to bound an in-memory
142
+ counter is not worth it. A rolling window over the last ``_WINDOW`` queries
143
+ per profile gives the same property — recent concentration is what
144
+ compounds — without new plumbing. In-process and ephemeral on purpose: a DB
145
+ write per displayed fact per query is exactly the contention the exposure
146
+ enqueue was switched off to avoid.
147
+ """
148
+
149
+ __slots__ = ("_counts", "_seen")
150
+
151
+ #: Queries per profile before the window resets.
152
+ _WINDOW = 200
153
+
154
+ def __init__(self) -> None:
155
+ self._counts: dict[str, dict[str, int]] = defaultdict(
156
+ lambda: defaultdict(int)
157
+ )
158
+ self._seen: dict[str, int] = defaultdict(int)
159
+
160
+ def record_top(self, profile_id: str, fact_id: str) -> None:
161
+ """Note that ``fact_id`` took first place for ``profile_id``.
162
+
163
+ The window DECAYS rather than being dropped. Emptying it wholesale every
164
+ ``_WINDOW`` queries handed every previously-capped memory its bonus back
165
+ at the same instant, so concentration spiked immediately after each
166
+ reset — the cap stopped biting exactly when the run-up had made it most
167
+ necessary. Halving instead keeps a repeat winner near its cap and lets a
168
+ memory that has stopped winning recover gradually.
169
+ """
170
+ key = profile_id or ""
171
+ self._seen[key] += 1
172
+ if self._seen[key] > self._WINDOW:
173
+ bucket = self._counts.get(key)
174
+ if bucket:
175
+ halved = {f: c // 2 for f, c in bucket.items() if c > 1}
176
+ self._counts[key] = defaultdict(int, halved)
177
+ self._seen[key] = 0
178
+ self._counts[key][fact_id] += 1
179
+
180
+ def tops(self, profile_id: str, fact_id: str) -> int:
181
+ return self._counts.get(profile_id or "", {}).get(fact_id, 0)
182
+
183
+ def capped(self, profile_id: str, fact_id: str) -> bool:
184
+ """Whether this fact has won often enough to stop earning a bonus."""
185
+ return self.tops(profile_id, fact_id) >= _CAP_MIN
186
+
187
+ def forget(self, profile_id: str) -> None:
188
+ """Drop everything held for a profile. Called on erasure.
189
+
190
+ In-process and ephemeral, so it dies with the process anyway — but an
191
+ Article 17 request must not leave a profile's recent winners sitting in
192
+ a live process's memory for the rest of its lifetime.
193
+ """
194
+ key = profile_id or ""
195
+ self._counts.pop(key, None)
196
+ self._seen.pop(key, None)
197
+
198
+
199
+ #: One counter for the process. Ephemeral by design — see the class docstring.
200
+ RECENT_TOPS = RecentTopCounter()
201
+
202
+
203
+ def fetch_scores(
204
+ conn: sqlite3.Connection,
205
+ profile_id: str,
206
+ fact_ids: list[str],
207
+ ) -> dict[str, tuple[float, int]]:
208
+ """``{fact_id: (score, play_count)}`` for the ids that have a row.
209
+
210
+ One batched query against the ``(fact_id, profile_id)`` primary key rather
211
+ than a LEFT JOIN into the hydration SQL, which is what an earlier design proposed.
212
+ The JOIN would have to reach ``AtomicFact``, and ``_row_to_fact`` ignores
213
+ columns the dataclass does not declare — so it would mean adding fields to a
214
+ model used across the whole codebase to carry a number only the ranker
215
+ reads. A missing row is a cold start, and the caller treats it as neutral.
216
+
217
+ Never raises: on a store where M045 has not run this returns ``{}`` and
218
+ ranking proceeds exactly as it did before PCOS existed.
219
+ """
220
+ ids = [str(f) for f in fact_ids if f]
221
+ if not ids:
222
+ return {}
223
+ placeholders = ", ".join("?" * len(ids))
224
+ try:
225
+ rows = conn.execute(
226
+ "SELECT fact_id, score, play_count FROM fact_outcome_score "
227
+ f"WHERE profile_id = ? AND fact_id IN ({placeholders})",
228
+ (str(profile_id), *ids),
229
+ ).fetchall()
230
+ except sqlite3.Error as exc:
231
+ logger.debug("pcos.fetch_scores unavailable: %s", exc)
232
+ return {}
233
+ out: dict[str, tuple[float, int]] = {}
234
+ for row in rows:
235
+ try:
236
+ out[str(row[0])] = (float(row[1]), int(row[2]))
237
+ except (TypeError, ValueError, IndexError):
238
+ continue
239
+ return out
240
+
241
+
242
+ def update_scores(
243
+ conn: sqlite3.Connection,
244
+ profile_id: str,
245
+ fact_ids: list[str],
246
+ reward: float,
247
+ ) -> int:
248
+ """Fold ``reward`` into each fact's score. Returns rows written.
249
+
250
+ Shrinkage on the way in: the effective rate is ``TAU * min(1, plays/10)``, so
251
+ the first few settlements move a score gently and a single lucky outcome
252
+ cannot define a fact. A fact with no row starts from the neutral prior and
253
+ takes one ``TAU`` step toward the reward.
254
+
255
+ The caller owns the transaction. Never raises — a lost PCOS update costs one
256
+ increment of a score that is advisory by construction, and it must never
257
+ take down the settlement that produced it.
258
+ """
259
+ ids = [str(f) for f in fact_ids if f]
260
+ if not ids:
261
+ return 0
262
+ try:
263
+ reward_f = max(0.0, min(1.0, float(reward)))
264
+ except (TypeError, ValueError):
265
+ return 0
266
+
267
+ existing = fetch_scores(conn, profile_id, ids)
268
+ written = 0
269
+ for fid in ids:
270
+ old_score, old_plays = existing.get(fid, (_NEUTRAL, 0))
271
+ if old_plays <= 0:
272
+ new_score = _NEUTRAL + TAU * (reward_f - _NEUTRAL)
273
+ else:
274
+ rate = TAU * min(1.0, old_plays / 10.0)
275
+ new_score = (1.0 - rate) * old_score + rate * reward_f
276
+ new_score = max(0.0, min(1.0, new_score))
277
+ try:
278
+ conn.execute(
279
+ "INSERT INTO fact_outcome_score "
280
+ "(fact_id, profile_id, score, play_count, updated_at) "
281
+ "VALUES (?, ?, ?, ?, datetime('now')) "
282
+ "ON CONFLICT (fact_id, profile_id) DO UPDATE SET "
283
+ " score = excluded.score, "
284
+ " play_count = fact_outcome_score.play_count + 1, "
285
+ " updated_at = excluded.updated_at",
286
+ (fid, str(profile_id), new_score, old_plays + 1),
287
+ )
288
+ written += 1
289
+ except sqlite3.Error as exc:
290
+ logger.debug("pcos.update_scores %s: %s", fid, exc)
291
+ return written