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
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
|
|
4
|
+
"""M044 — a bandit play records which memories it actually showed.
|
|
5
|
+
|
|
6
|
+
WHY
|
|
7
|
+
---
|
|
8
|
+
A play is settled later, from evidence: did anything downstream reference one of
|
|
9
|
+
the memories this query surfaced? ``reward_proxy`` answered "which memories" by
|
|
10
|
+
reading ``learning_signals`` for the same ``query_id`` — rows written by the
|
|
11
|
+
recall path's ``enqueue(SignalBatch(...))``.
|
|
12
|
+
|
|
13
|
+
That enqueue is off, deliberately. It writes twenty exposure rows per query, it
|
|
14
|
+
was the source of a 2,675x inflation in the ranking-phase counter, and the code
|
|
15
|
+
comment gives a second reason: *"even a non-blocking enqueue eventually writes
|
|
16
|
+
canonical/learning state and turns dashboard polling into contention."*
|
|
17
|
+
|
|
18
|
+
So the evidence lookup had no evidence to read, and every settlement fell
|
|
19
|
+
through to the 120-second default of 0.5. Measured on a live production store::
|
|
20
|
+
|
|
21
|
+
SUM(alpha) = SUM(beta) = 867.5 = 165 priors + 1,405 plays x 0.5
|
|
22
|
+
|
|
23
|
+
An exact match on both sides across 165 arms: every one of 1,405 settlements
|
|
24
|
+
applied the neutral prior. Thompson sampling on Beta(a, a) is a coin flip, so
|
|
25
|
+
retrieval strategy was being chosen at random.
|
|
26
|
+
|
|
27
|
+
The fix is for the play to carry its own evidence. One column, written once per
|
|
28
|
+
recorded play, holding the handful of fact_ids that were actually shown — rather
|
|
29
|
+
than reinstating twenty rows per query in a table that gates a phase counter.
|
|
30
|
+
|
|
31
|
+
WHY A COLUMN AND NOT A TABLE
|
|
32
|
+
----------------------------
|
|
33
|
+
This is one small JSON array per play, read exactly once by the settler and
|
|
34
|
+
then dead. A child table would need its own index, its own retention sweep, and
|
|
35
|
+
its own profile_id for erasure. The column is deleted with its play by the
|
|
36
|
+
existing ``retention_sweep``, which is the behaviour we want and get for free.
|
|
37
|
+
|
|
38
|
+
BACK-COMPATIBILITY
|
|
39
|
+
------------------
|
|
40
|
+
Existing rows keep NULL. The settler falls back to the ``learning_signals``
|
|
41
|
+
lookup when the column is empty, so plays recorded before this migration settle
|
|
42
|
+
exactly as they did before — and installs that still run the enqueue keep
|
|
43
|
+
working unchanged.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import sqlite3
|
|
49
|
+
|
|
50
|
+
NAME = "M044_play_carries_its_own_evidence"
|
|
51
|
+
DB_TARGET = "learning"
|
|
52
|
+
|
|
53
|
+
_TABLE = "bandit_plays"
|
|
54
|
+
_COLUMN = "shown_fact_ids"
|
|
55
|
+
|
|
56
|
+
#: Recorded for the runner's DDL hash. SQLite has no ``ADD COLUMN IF NOT
|
|
57
|
+
#: EXISTS``, so ``apply()`` below runs instead of this string; it is kept
|
|
58
|
+
#: accurate because the hash is what detects a migration being edited after it
|
|
59
|
+
#: has shipped.
|
|
60
|
+
DDL = """
|
|
61
|
+
ALTER TABLE bandit_plays ADD COLUMN shown_fact_ids TEXT;
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _columns(conn: sqlite3.Connection) -> set[str]:
|
|
66
|
+
try:
|
|
67
|
+
return {
|
|
68
|
+
row[1] for row in conn.execute(f"PRAGMA table_info({_TABLE})")
|
|
69
|
+
}
|
|
70
|
+
except sqlite3.Error:
|
|
71
|
+
return set()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _table_exists(conn: sqlite3.Connection) -> bool:
|
|
75
|
+
try:
|
|
76
|
+
return conn.execute(
|
|
77
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
|
|
78
|
+
(_TABLE,),
|
|
79
|
+
).fetchone() is not None
|
|
80
|
+
except sqlite3.Error:
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def verify(conn: sqlite3.Connection) -> bool:
|
|
85
|
+
"""End state: the column exists.
|
|
86
|
+
|
|
87
|
+
Everything ``verify`` asserts must be produced by every path through
|
|
88
|
+
``apply`` — including the path where the table is absent. 4.0.10 shipped an
|
|
89
|
+
M043 whose ``verify`` required a table its ``apply`` created only
|
|
90
|
+
conditionally, which made the migration fail permanently on stores that
|
|
91
|
+
lacked it. Hence the branch below: no table means M005 has not run, this
|
|
92
|
+
migration has nothing to do, and saying so is the end state.
|
|
93
|
+
"""
|
|
94
|
+
if not _table_exists(conn):
|
|
95
|
+
return True
|
|
96
|
+
return _COLUMN in _columns(conn)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def apply(conn: sqlite3.Connection) -> None:
|
|
100
|
+
"""Add the column if it is missing. Idempotent.
|
|
101
|
+
|
|
102
|
+
Runs instead of ``DDL`` (the runner prefers a module-level ``apply``)
|
|
103
|
+
because ``ALTER TABLE ... ADD COLUMN`` raises when the column is already
|
|
104
|
+
there, and SQLite cannot guard that inside one script.
|
|
105
|
+
"""
|
|
106
|
+
if not _table_exists(conn):
|
|
107
|
+
# M005 owns bandit_plays and is a declared dependency, so this means a
|
|
108
|
+
# store where the bandit tables were never created. Nothing to alter.
|
|
109
|
+
return
|
|
110
|
+
if _COLUMN in _columns(conn):
|
|
111
|
+
return
|
|
112
|
+
conn.execute(f"ALTER TABLE {_TABLE} ADD COLUMN {_COLUMN} TEXT")
|
|
113
|
+
if not verify(conn): # pragma: no cover — defensive
|
|
114
|
+
raise sqlite3.OperationalError(
|
|
115
|
+
f"M044: {_TABLE}.{_COLUMN} absent after ALTER"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def repair(conn: sqlite3.Connection) -> None:
|
|
120
|
+
"""Identical to ``apply`` — and that is safe only because it is additive.
|
|
121
|
+
|
|
122
|
+
``repair`` == ``apply`` is a trap in general: if ``verify`` can return a
|
|
123
|
+
false negative, the runner retries forever. Here ``verify`` reads
|
|
124
|
+
``PRAGMA table_info``, which is the same fact ``apply`` establishes, so a
|
|
125
|
+
false negative would require the ALTER to have silently not happened.
|
|
126
|
+
"""
|
|
127
|
+
apply(conn)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
|
|
4
|
+
"""M045 — per-fact outcome score (PCOS), so ranking can use what worked.
|
|
5
|
+
|
|
6
|
+
WHY
|
|
7
|
+
---
|
|
8
|
+
Ranking scores a memory by how much it *looks like* the query. Nothing in the
|
|
9
|
+
pipeline knows whether a memory has ever actually helped. This table holds one
|
|
10
|
+
number per (fact, profile): an exponentially-weighted average of the rewards of
|
|
11
|
+
the settlements it took part in.
|
|
12
|
+
|
|
13
|
+
WHY IT LIVES IN memory.db
|
|
14
|
+
-------------------------
|
|
15
|
+
Beside ``atomic_facts`` and ``action_outcomes``, the backfill source, so the
|
|
16
|
+
whole thing stays in one file. SQLite cannot join across databases without
|
|
17
|
+
ATTACH, and taking an ATTACH on the recall path to reach a single REAL would
|
|
18
|
+
cost more than the number is worth.
|
|
19
|
+
|
|
20
|
+
The ranker reads it with one batched keyed lookup rather than a LEFT JOIN into
|
|
21
|
+
fact hydration: ``_row_to_fact`` builds ``AtomicFact`` from named columns and
|
|
22
|
+
silently drops any it does not declare, so a JOIN would mean adding fields to a
|
|
23
|
+
model used across the whole codebase to carry a value only the ranker reads.
|
|
24
|
+
|
|
25
|
+
profile_id IS PART OF THE PRIMARY KEY, AND THAT IS NOT COSMETIC
|
|
26
|
+
---------------------------------------------------------------
|
|
27
|
+
A learned per-fact score is derived personal data. If it had no ``profile_id``,
|
|
28
|
+
``forget_profile()`` would run, report success, and leave the erased user's
|
|
29
|
+
learned outcome history behind — which is the same defect already
|
|
30
|
+
known in ``fact_expansion_fts``. Creating a second table with the same defect
|
|
31
|
+
in the same release would be indefensible. ``compliance/gdpr.py`` deletes from
|
|
32
|
+
this table; a test asserts one profile's erasure leaves another's rows intact.
|
|
33
|
+
|
|
34
|
+
THE BACKFILL, AND A CORRECTION TO THE PLAN
|
|
35
|
+
------------------------------------------
|
|
36
|
+
An earlier design's backfill read ``SELECT fact_id, profile_id, AVG(reward) FROM
|
|
37
|
+
action_outcomes GROUP BY fact_id, profile_id``. **There is no ``fact_id`` column
|
|
38
|
+
on that table** — it stores ``fact_ids_json``, a JSON array, because one outcome
|
|
39
|
+
covers the set of memories an answer used. So the backfill expands the array with
|
|
40
|
+
``json_each``, guarded by ``json_valid`` (an unguarded ``json_each`` raises on the
|
|
41
|
+
first malformed row and takes the whole migration with it).
|
|
42
|
+
|
|
43
|
+
On a live store this initialises every touched fact at 0.5 from 162 rows
|
|
44
|
+
that are all 0.5 — which is the neutral prior, so the backfill adds no bias. Its
|
|
45
|
+
only job is to avoid a cold start looking like a penalty.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import sqlite3
|
|
51
|
+
|
|
52
|
+
NAME = "M045_fact_outcome_score"
|
|
53
|
+
DB_TARGET = "memory"
|
|
54
|
+
|
|
55
|
+
_TABLE = "fact_outcome_score"
|
|
56
|
+
|
|
57
|
+
DDL = """
|
|
58
|
+
BEGIN IMMEDIATE;
|
|
59
|
+
CREATE TABLE IF NOT EXISTS fact_outcome_score (
|
|
60
|
+
fact_id TEXT NOT NULL,
|
|
61
|
+
profile_id TEXT NOT NULL DEFAULT 'default',
|
|
62
|
+
score REAL NOT NULL DEFAULT 0.5,
|
|
63
|
+
play_count INTEGER NOT NULL DEFAULT 0,
|
|
64
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
65
|
+
PRIMARY KEY (fact_id, profile_id)
|
|
66
|
+
);
|
|
67
|
+
CREATE INDEX IF NOT EXISTS idx_fos_profile
|
|
68
|
+
ON fact_outcome_score (profile_id, fact_id);
|
|
69
|
+
COMMIT;
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
#: Expand ``fact_ids_json`` and average the reward per (fact, profile).
|
|
73
|
+
#:
|
|
74
|
+
#: ``INSERT OR IGNORE`` names no conflict target on purpose: the table has one
|
|
75
|
+
#: unique constraint today, and a targeted ``ON CONFLICT`` silently stops
|
|
76
|
+
#: covering a row the moment a second constraint is added.
|
|
77
|
+
_BACKFILL = """
|
|
78
|
+
INSERT OR IGNORE INTO fact_outcome_score
|
|
79
|
+
(fact_id, profile_id, score, play_count, updated_at)
|
|
80
|
+
SELECT
|
|
81
|
+
j.value AS fact_id,
|
|
82
|
+
o.profile_id,
|
|
83
|
+
AVG(o.reward),
|
|
84
|
+
COUNT(*),
|
|
85
|
+
datetime('now')
|
|
86
|
+
FROM action_outcomes AS o, json_each(o.fact_ids_json) AS j
|
|
87
|
+
WHERE o.reward IS NOT NULL
|
|
88
|
+
AND json_valid(o.fact_ids_json)
|
|
89
|
+
AND o.fact_ids_json IS NOT NULL
|
|
90
|
+
AND j.value IS NOT NULL AND j.value != ''
|
|
91
|
+
GROUP BY j.value, o.profile_id
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _table_exists(conn: sqlite3.Connection, name: str) -> bool:
|
|
96
|
+
try:
|
|
97
|
+
return conn.execute(
|
|
98
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
|
|
99
|
+
(name,),
|
|
100
|
+
).fetchone() is not None
|
|
101
|
+
except sqlite3.Error:
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _columns(conn: sqlite3.Connection, table: str) -> set[str]:
|
|
106
|
+
try:
|
|
107
|
+
return {row[1] for row in conn.execute(f"PRAGMA table_info({table})")}
|
|
108
|
+
except sqlite3.Error:
|
|
109
|
+
return set()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def verify(conn: sqlite3.Connection) -> bool:
|
|
113
|
+
"""End state: the table exists with profile_id in it.
|
|
114
|
+
|
|
115
|
+
Deliberately does NOT assert anything about backfilled rows. The backfill
|
|
116
|
+
depends on ``action_outcomes``, which is bootstrapped at engine init and may
|
|
117
|
+
hold nothing at all — so asserting rows would make ``verify`` fail forever
|
|
118
|
+
on a fresh install. Everything asserted here is produced by every path
|
|
119
|
+
through ``apply``; that is the M043 lesson, learned the hard way.
|
|
120
|
+
"""
|
|
121
|
+
if not _table_exists(conn, _TABLE):
|
|
122
|
+
return False
|
|
123
|
+
cols = _columns(conn, _TABLE)
|
|
124
|
+
return {"fact_id", "profile_id", "score", "play_count"} <= cols
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def apply(conn: sqlite3.Connection) -> None:
|
|
128
|
+
"""Create the table, then backfill from reported outcomes if any exist."""
|
|
129
|
+
conn.executescript(DDL)
|
|
130
|
+
if not verify(conn): # pragma: no cover — defensive
|
|
131
|
+
raise sqlite3.OperationalError(
|
|
132
|
+
"M045 fact_outcome_score did not reach its end-state"
|
|
133
|
+
)
|
|
134
|
+
_backfill(conn)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _backfill(conn: sqlite3.Connection) -> int:
|
|
138
|
+
"""Seed from ``action_outcomes``. Never fatal — the table is what matters.
|
|
139
|
+
|
|
140
|
+
A failure here means facts start at the cold-start default of 0.5, which is
|
|
141
|
+
the same value the backfill would have written on this store anyway. Losing
|
|
142
|
+
the migration over that would be the wrong trade.
|
|
143
|
+
"""
|
|
144
|
+
if not _table_exists(conn, "action_outcomes"):
|
|
145
|
+
return 0
|
|
146
|
+
if "reward" not in _columns(conn, "action_outcomes"):
|
|
147
|
+
# reward arrives with M006, which is deferred. Nothing to average yet.
|
|
148
|
+
return 0
|
|
149
|
+
try:
|
|
150
|
+
cur = conn.execute(_BACKFILL)
|
|
151
|
+
return int(cur.rowcount or 0)
|
|
152
|
+
except sqlite3.Error:
|
|
153
|
+
return 0
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def repair(conn: sqlite3.Connection) -> None:
|
|
157
|
+
"""Re-run apply. Safe: the DDL is IF NOT EXISTS and the backfill IGNOREs."""
|
|
158
|
+
apply(conn)
|