superlocalmemory 4.0.10 → 4.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +12 -2
- package/CHANGELOG.md +244 -0
- package/README.md +40 -75
- package/package.json +6 -3
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +4 -4
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +2 -2
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +5 -5
- package/plugin/skills/slm-recall/SKILL.md +102 -15
- package/plugin/skills/slm-remember/SKILL.md +35 -3
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +29 -3
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +16 -8
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-governance/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-loop/SKILL.md +2 -2
- package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
- package/plugin-src/skills/slm-profile/SKILL.md +5 -5
- package/plugin-src/skills/slm-recall/SKILL.md +102 -15
- package/plugin-src/skills/slm-remember/SKILL.md +35 -3
- package/plugin-src/skills/slm-scope/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +29 -3
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +357 -18
- package/src/superlocalmemory/cli/daemon.py +30 -0
- package/src/superlocalmemory/cli/db_migrate.py +71 -1
- package/src/superlocalmemory/cli/gdpr_cmd.py +15 -2
- package/src/superlocalmemory/cli/main.py +24 -2
- package/src/superlocalmemory/code_graph/database.py +44 -0
- package/src/superlocalmemory/compliance/gdpr.py +449 -39
- package/src/superlocalmemory/core/admission.py +231 -11
- package/src/superlocalmemory/core/backend_orchestrator.py +190 -84
- package/src/superlocalmemory/core/config.py +90 -11
- package/src/superlocalmemory/core/consolidation_engine.py +34 -0
- package/src/superlocalmemory/core/engine.py +140 -11
- package/src/superlocalmemory/core/graph_analyzer.py +76 -112
- package/src/superlocalmemory/core/graph_metrics.py +597 -0
- package/src/superlocalmemory/core/graph_pruner.py +121 -0
- package/src/superlocalmemory/core/maintenance_scheduler.py +205 -0
- package/src/superlocalmemory/core/mode_capability.py +111 -0
- package/src/superlocalmemory/core/ollama_validator.py +315 -0
- package/src/superlocalmemory/core/projection_drain.py +380 -0
- package/src/superlocalmemory/core/recall_pipeline.py +390 -3
- package/src/superlocalmemory/core/recall_worker.py +6 -3
- package/src/superlocalmemory/core/scale_autopromote.py +196 -0
- package/src/superlocalmemory/core/scale_engine.py +16 -2
- package/src/superlocalmemory/core/score_contract.py +21 -1
- package/src/superlocalmemory/core/session_identity.py +85 -0
- package/src/superlocalmemory/core/status_contract.py +108 -0
- package/src/superlocalmemory/core/worker_pool.py +4 -4
- package/src/superlocalmemory/core/working_memory.py +288 -0
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +36 -6
- package/src/superlocalmemory/encoding/context_generator.py +1 -1
- package/src/superlocalmemory/encoding/entity_resolver.py +38 -0
- package/src/superlocalmemory/encoding/fact_extractor.py +18 -14
- package/src/superlocalmemory/encoding/prospective_markers.py +262 -0
- package/src/superlocalmemory/encoding/type_router.py +12 -12
- package/src/superlocalmemory/evolution/mutation_generator.py +30 -4
- package/src/superlocalmemory/graph/cozo_adjacency.py +122 -0
- package/src/superlocalmemory/graph/cozo_backend.py +103 -138
- package/src/superlocalmemory/hooks/portable_kit.py +10 -2
- package/src/superlocalmemory/learning/bandit.py +43 -0
- package/src/superlocalmemory/learning/consolidation_worker.py +54 -0
- package/src/superlocalmemory/learning/database.py +60 -3
- package/src/superlocalmemory/learning/entity_compiler.py +21 -58
- package/src/superlocalmemory/learning/feedback.py +3 -1
- package/src/superlocalmemory/learning/outcomes.py +47 -16
- package/src/superlocalmemory/learning/pattern_miner.py +28 -3
- package/src/superlocalmemory/learning/pattern_miner_constants.py +43 -0
- package/src/superlocalmemory/learning/pcos.py +291 -0
- package/src/superlocalmemory/learning/reward_from_outcomes.py +365 -0
- package/src/superlocalmemory/learning/reward_proxy.py +100 -10
- package/src/superlocalmemory/learning/signal_kinds.py +79 -0
- package/src/superlocalmemory/mcp/profiles.py +14 -2
- package/src/superlocalmemory/mcp/tools_active.py +2 -1
- package/src/superlocalmemory/mcp/tools_core.py +31 -3
- package/src/superlocalmemory/mcp/tools_v28.py +20 -1
- package/src/superlocalmemory/parameterization/pattern_extractor.py +14 -1
- package/src/superlocalmemory/parameterization/soft_prompt_generator.py +98 -0
- package/src/superlocalmemory/retrieval/bm25_channel.py +64 -3
- package/src/superlocalmemory/retrieval/channel_status.py +117 -0
- package/src/superlocalmemory/retrieval/engine.py +106 -11
- package/src/superlocalmemory/retrieval/entity_channel.py +210 -256
- package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
- package/src/superlocalmemory/retrieval/scope_policy.py +20 -0
- package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
- package/src/superlocalmemory/retrieval/spreading.py +288 -0
- package/src/superlocalmemory/server/api.py +24 -5
- package/src/superlocalmemory/server/bandit_loops.py +17 -1
- package/src/superlocalmemory/server/rbac_enforce.py +26 -6
- package/src/superlocalmemory/server/recall_health.py +87 -10
- package/src/superlocalmemory/server/recall_serializer.py +9 -0
- package/src/superlocalmemory/server/routes/behavioral.py +75 -10
- package/src/superlocalmemory/server/routes/compliance.py +98 -18
- package/src/superlocalmemory/server/routes/config_api.py +186 -4
- package/src/superlocalmemory/server/routes/evolution.py +178 -0
- package/src/superlocalmemory/server/routes/ingest.py +8 -0
- package/src/superlocalmemory/server/routes/learning_telemetry.py +2 -1
- package/src/superlocalmemory/server/routes/memories.py +49 -7
- package/src/superlocalmemory/server/routes/timeline.py +4 -0
- package/src/superlocalmemory/server/routes/v3_api.py +191 -15
- package/src/superlocalmemory/server/ui.py +20 -4
- package/src/superlocalmemory/server/unified_daemon.py +241 -7
- package/src/superlocalmemory/storage/_migration_internals.py +54 -2
- package/src/superlocalmemory/storage/_schema_version.py +24 -3
- package/src/superlocalmemory/storage/database.py +477 -59
- package/src/superlocalmemory/storage/embedding_codec.py +71 -0
- package/src/superlocalmemory/storage/lineage_retention.py +236 -0
- package/src/superlocalmemory/storage/logical_edges.py +43 -2
- package/src/superlocalmemory/storage/migration_runner.py +119 -0
- package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +60 -36
- package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +127 -0
- package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +158 -0
- package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +620 -0
- package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +306 -0
- package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +207 -0
- package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +201 -0
- package/src/superlocalmemory/storage/migrations.py +18 -2
- package/src/superlocalmemory/storage/models.py +40 -1
- package/src/superlocalmemory/storage/projection_outbox.py +346 -0
- package/src/superlocalmemory/storage/retention_policy.py +860 -0
- package/src/superlocalmemory/storage/schema.py +35 -1
- package/src/superlocalmemory/storage/write_coordinator.py +19 -2
- package/src/superlocalmemory/trust/scorer.py +43 -1
- package/src/superlocalmemory/ui/index.html +9 -18
- package/src/superlocalmemory/ui/js/event-delegation.js +12 -1
- package/src/superlocalmemory/ui/js/od-health.js +28 -6
- package/src/superlocalmemory/ui/js/od-memories.js +19 -0
- package/src/superlocalmemory/ui/js/od-settings.js +87 -1
- 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
|
-
|
|
83
|
-
|
|
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
|
-
"(
|
|
98
|
-
|
|
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"]
|
|
110
|
-
for
|
|
111
|
-
if
|
|
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
|