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.
Files changed (145) hide show
  1. package/.claude-plugin/marketplace.json +12 -2
  2. package/CHANGELOG.md +244 -0
  3. package/README.md +40 -75
  4. package/package.json +6 -3
  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 +357 -18
  40. package/src/superlocalmemory/cli/daemon.py +30 -0
  41. package/src/superlocalmemory/cli/db_migrate.py +71 -1
  42. package/src/superlocalmemory/cli/gdpr_cmd.py +15 -2
  43. package/src/superlocalmemory/cli/main.py +24 -2
  44. package/src/superlocalmemory/code_graph/database.py +44 -0
  45. package/src/superlocalmemory/compliance/gdpr.py +449 -39
  46. package/src/superlocalmemory/core/admission.py +231 -11
  47. package/src/superlocalmemory/core/backend_orchestrator.py +190 -84
  48. package/src/superlocalmemory/core/config.py +90 -11
  49. package/src/superlocalmemory/core/consolidation_engine.py +34 -0
  50. package/src/superlocalmemory/core/engine.py +140 -11
  51. package/src/superlocalmemory/core/graph_analyzer.py +76 -112
  52. package/src/superlocalmemory/core/graph_metrics.py +597 -0
  53. package/src/superlocalmemory/core/graph_pruner.py +121 -0
  54. package/src/superlocalmemory/core/maintenance_scheduler.py +205 -0
  55. package/src/superlocalmemory/core/mode_capability.py +111 -0
  56. package/src/superlocalmemory/core/ollama_validator.py +315 -0
  57. package/src/superlocalmemory/core/projection_drain.py +380 -0
  58. package/src/superlocalmemory/core/recall_pipeline.py +390 -3
  59. package/src/superlocalmemory/core/recall_worker.py +6 -3
  60. package/src/superlocalmemory/core/scale_autopromote.py +196 -0
  61. package/src/superlocalmemory/core/scale_engine.py +16 -2
  62. package/src/superlocalmemory/core/score_contract.py +21 -1
  63. package/src/superlocalmemory/core/session_identity.py +85 -0
  64. package/src/superlocalmemory/core/status_contract.py +108 -0
  65. package/src/superlocalmemory/core/worker_pool.py +4 -4
  66. package/src/superlocalmemory/core/working_memory.py +288 -0
  67. package/src/superlocalmemory/encoding/cognitive_consolidator.py +36 -6
  68. package/src/superlocalmemory/encoding/context_generator.py +1 -1
  69. package/src/superlocalmemory/encoding/entity_resolver.py +38 -0
  70. package/src/superlocalmemory/encoding/fact_extractor.py +18 -14
  71. package/src/superlocalmemory/encoding/prospective_markers.py +262 -0
  72. package/src/superlocalmemory/encoding/type_router.py +12 -12
  73. package/src/superlocalmemory/evolution/mutation_generator.py +30 -4
  74. package/src/superlocalmemory/graph/cozo_adjacency.py +122 -0
  75. package/src/superlocalmemory/graph/cozo_backend.py +103 -138
  76. package/src/superlocalmemory/hooks/portable_kit.py +10 -2
  77. package/src/superlocalmemory/learning/bandit.py +43 -0
  78. package/src/superlocalmemory/learning/consolidation_worker.py +54 -0
  79. package/src/superlocalmemory/learning/database.py +60 -3
  80. package/src/superlocalmemory/learning/entity_compiler.py +21 -58
  81. package/src/superlocalmemory/learning/feedback.py +3 -1
  82. package/src/superlocalmemory/learning/outcomes.py +47 -16
  83. package/src/superlocalmemory/learning/pattern_miner.py +28 -3
  84. package/src/superlocalmemory/learning/pattern_miner_constants.py +43 -0
  85. package/src/superlocalmemory/learning/pcos.py +291 -0
  86. package/src/superlocalmemory/learning/reward_from_outcomes.py +365 -0
  87. package/src/superlocalmemory/learning/reward_proxy.py +100 -10
  88. package/src/superlocalmemory/learning/signal_kinds.py +79 -0
  89. package/src/superlocalmemory/mcp/profiles.py +14 -2
  90. package/src/superlocalmemory/mcp/tools_active.py +2 -1
  91. package/src/superlocalmemory/mcp/tools_core.py +31 -3
  92. package/src/superlocalmemory/mcp/tools_v28.py +20 -1
  93. package/src/superlocalmemory/parameterization/pattern_extractor.py +14 -1
  94. package/src/superlocalmemory/parameterization/soft_prompt_generator.py +98 -0
  95. package/src/superlocalmemory/retrieval/bm25_channel.py +64 -3
  96. package/src/superlocalmemory/retrieval/channel_status.py +117 -0
  97. package/src/superlocalmemory/retrieval/engine.py +106 -11
  98. package/src/superlocalmemory/retrieval/entity_channel.py +210 -256
  99. package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
  100. package/src/superlocalmemory/retrieval/scope_policy.py +20 -0
  101. package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
  102. package/src/superlocalmemory/retrieval/spreading.py +288 -0
  103. package/src/superlocalmemory/server/api.py +24 -5
  104. package/src/superlocalmemory/server/bandit_loops.py +17 -1
  105. package/src/superlocalmemory/server/rbac_enforce.py +26 -6
  106. package/src/superlocalmemory/server/recall_health.py +87 -10
  107. package/src/superlocalmemory/server/recall_serializer.py +9 -0
  108. package/src/superlocalmemory/server/routes/behavioral.py +75 -10
  109. package/src/superlocalmemory/server/routes/compliance.py +98 -18
  110. package/src/superlocalmemory/server/routes/config_api.py +186 -4
  111. package/src/superlocalmemory/server/routes/evolution.py +178 -0
  112. package/src/superlocalmemory/server/routes/ingest.py +8 -0
  113. package/src/superlocalmemory/server/routes/learning_telemetry.py +2 -1
  114. package/src/superlocalmemory/server/routes/memories.py +49 -7
  115. package/src/superlocalmemory/server/routes/timeline.py +4 -0
  116. package/src/superlocalmemory/server/routes/v3_api.py +191 -15
  117. package/src/superlocalmemory/server/ui.py +20 -4
  118. package/src/superlocalmemory/server/unified_daemon.py +241 -7
  119. package/src/superlocalmemory/storage/_migration_internals.py +54 -2
  120. package/src/superlocalmemory/storage/_schema_version.py +24 -3
  121. package/src/superlocalmemory/storage/database.py +477 -59
  122. package/src/superlocalmemory/storage/embedding_codec.py +71 -0
  123. package/src/superlocalmemory/storage/lineage_retention.py +236 -0
  124. package/src/superlocalmemory/storage/logical_edges.py +43 -2
  125. package/src/superlocalmemory/storage/migration_runner.py +119 -0
  126. package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +60 -36
  127. package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +127 -0
  128. package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +158 -0
  129. package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +620 -0
  130. package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +306 -0
  131. package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +207 -0
  132. package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +201 -0
  133. package/src/superlocalmemory/storage/migrations.py +18 -2
  134. package/src/superlocalmemory/storage/models.py +40 -1
  135. package/src/superlocalmemory/storage/projection_outbox.py +346 -0
  136. package/src/superlocalmemory/storage/retention_policy.py +860 -0
  137. package/src/superlocalmemory/storage/schema.py +35 -1
  138. package/src/superlocalmemory/storage/write_coordinator.py +19 -2
  139. package/src/superlocalmemory/trust/scorer.py +43 -1
  140. package/src/superlocalmemory/ui/index.html +9 -18
  141. package/src/superlocalmemory/ui/js/event-delegation.js +12 -1
  142. package/src/superlocalmemory/ui/js/od-health.js +28 -6
  143. package/src/superlocalmemory/ui/js/od-memories.js +19 -0
  144. package/src/superlocalmemory/ui/js/od-settings.js +87 -1
  145. package/src/superlocalmemory/ui/js/recall-lab.js +78 -3
@@ -0,0 +1,597 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+
4
+ """Whole-graph structural metrics for ``fact_importance``.
5
+
6
+ WHAT THIS IS FOR
7
+ ----------------
8
+ Recall multiplies a candidate's activation by ``min(1 + pagerank * 2, 2)`` at
9
+ every hop and biases it toward the communities its query seeds belong to. Both
10
+ numbers come from ``fact_importance``. A memory missing from that table is found
11
+ by the graph walk and then ranked as though it had no position in the graph at
12
+ all -- so the table's coverage is a recall-quality property, not a reporting
13
+ nicety.
14
+
15
+ WHY ONE WRITER
16
+ --------------
17
+ Two writers were filling this table with numbers that do not mean the same
18
+ thing. The whole-graph pass (``core/graph_analyzer.py``) computes PageRank over
19
+ every fact and edge. A second path compiled an entity's facts and, on finding no
20
+ PageRank for them, computed its own over a ``LIMIT 50`` slice of facts sharing
21
+ that one entity -- a near-clique of at most 50 nodes, which hands every member
22
+ roughly ``1/n``. Measured on the author's store: the whole-graph pass produced a
23
+ maximum of 0.008744 and a median of 0.000214, while the local pass wrote 0.1 --
24
+ **eleven times the largest real score, and roughly 470x the median**. Those facts
25
+ took the maximum hop boost the formula allows (1.2 against everyone else's
26
+ 1.0004) for no reason other than having shared an entity with a few others, and
27
+ they were written with no community, so the community bias could not see them
28
+ either. The signal was not stale, it was wrong.
29
+
30
+ So: one function computes this table, and it computes it over the whole graph.
31
+
32
+ WHICH ENGINE, AND WHY IT IS NOT THE GRAPH ENGINE
33
+ -----------------------------------------------
34
+ Two adapters compute the same function of the same edge set. In-process wins on
35
+ measurement, so it is the default. Timed on a copy of the author's 208,151-edge
36
+ store, each algorithm alone, warmed:
37
+
38
+ algorithm in process graph engine winner
39
+ PageRank 0.36 s 0.38 s tie
40
+ Louvain 1.98 s 5.17 s in process, 2.6x
41
+ whole pass 2.33 s 5.56 s in process, 2.4x
42
+
43
+ An earlier reading of 1.88 s for ``nx.pagerank`` against 0.13 s was a cold scipy
44
+ import counted as compute -- the third time a first-call warm-up has been
45
+ misread as a backend difference in this release. Both engines are deterministic
46
+ across runs (checked, not assumed) and agree to Spearman rho 0.996, so the choice
47
+ is a cost decision and may be revisited by measurement, not by argument.
48
+
49
+ The graph engine still earns its keep, just not here: reading the adjacency for
50
+ one recall measured 395 ms against SQLite's 2,477 ms on the same store, 6.3x, and
51
+ the gap widens with edge count. Those two figures were measured by hand on a copy
52
+ of a 208,151-edge store and no test reproduces them -- see the table in
53
+ ``graph/cozo_adjacency`` for what was run. That is a latency win on the recall path; this
54
+ pass is background work where 2 s versus 5 s buys nothing.
55
+
56
+ Its ``pagerank()``/``community_detect()`` helpers are NOT used and could not have
57
+ been: they take their node set from the ``entity`` relation and their edges from
58
+ ``edge``, which hold canonical entity IDs and fact IDs respectively.
59
+ ``pagerank()`` therefore indexes an entity-keyed dict with a fact ID, raises
60
+ KeyError, and returns ``{}`` -- verified against the real store, where it
61
+ returned nothing and ``community_detect`` returned 1,386 singleton communities of
62
+ entities and not one fact. The adapter here calls the native
63
+ ``PageRank``/``CommunityDetectionLouvain`` fixed rules, which operate on whatever
64
+ relation they are handed.
65
+
66
+ WHAT COUNTS AS A NODE, AND AS AN EDGE
67
+ -------------------------------------
68
+ Nodes are the profile's *visible* facts, isolated ones included: a fact with no
69
+ edges still needs a row, or the ranker treats "no position in the graph" and "not
70
+ computed yet" as the same thing. Edges come from ``iter_logical_edges``, which
71
+ already excludes any edge with a withheld or soft-deleted endpoint -- the same
72
+ predicate the retrieval channel prunes its adjacency with. The previous
73
+ whole-graph pass read ``graph_edges`` and ``atomic_facts`` raw, so it ranked
74
+ 1,299 unreturnable facts alongside real memories and diluted every real score.
75
+
76
+ PageRank runs on the edge-connected subgraph and the isolated facts are then
77
+ given the uniform teleport share ``(1 - damping) / N`` over the full node set,
78
+ with the connected scores scaled to leave room for it. That convention lives
79
+ here, in the port, so both engines produce numbers on one scale -- the ranker
80
+ reads absolute values, so an engine swap that shifted the scale would silently
81
+ re-tune every boost in the system.
82
+ """
83
+
84
+ from __future__ import annotations
85
+
86
+ import logging
87
+ import sqlite3
88
+ import time
89
+ from dataclasses import dataclass, field
90
+ from typing import Any
91
+
92
+ from contextlib import contextmanager
93
+
94
+ from superlocalmemory.storage.logical_edges import iter_logical_edges
95
+
96
+ logger = logging.getLogger(__name__)
97
+
98
+
99
+ @contextmanager
100
+ def _short_connection(db: Any) -> Any:
101
+ """A connection held for one read or one write, never across the compute.
102
+
103
+ ``raw_connection`` takes the manager's write lock, so holding it around a
104
+ two-to-ten-second graph computation would stall every store for that long.
105
+ The pass therefore reads, releases, computes, and writes.
106
+
107
+ The consolidation cycle passes a minimal proxy that owns a bare sqlite3
108
+ connection and exposes ``execute`` only. Supporting it here is what keeps
109
+ this the single writer of the table; the alternative was a second
110
+ implementation for that one caller, which is how the two disagreeing
111
+ PageRanks happened in the first place.
112
+ """
113
+ # Resolved on the TYPE, and the fallback is checked with isinstance, for the
114
+ # reason written up in retrieval/scope_policy.py: a MagicMock fabricates any
115
+ # attribute you ask for, so ``getattr(db, "raw_connection")`` returns a
116
+ # callable whose context manager yields another MagicMock -- and iterating
117
+ # that raises, which this module's error path then reports as "could not read
118
+ # the graph" for a store that is perfectly readable. The suite passes exactly
119
+ # such a mock, and it cost a debugging round to notice the pass had not run
120
+ # rather than found nothing.
121
+ raw = getattr(type(db), "raw_connection", None)
122
+ if callable(raw):
123
+ with raw(db) as conn:
124
+ yield conn
125
+ return
126
+ conn = getattr(db, "_conn", None)
127
+ if not isinstance(conn, sqlite3.Connection):
128
+ raise TypeError(
129
+ f"{type(db).__name__} exposes no connection to compute metrics on"
130
+ )
131
+ yield conn
132
+ conn.commit()
133
+
134
+ #: PageRank damping. Matches the previous whole-graph pass so scores stay
135
+ #: comparable across the version that introduced this module.
136
+ DEFAULT_DAMPING = 0.85
137
+
138
+ #: Betweenness centrality is O(V*E). At 4k facts and 130k edges that is minutes,
139
+ #: and at the 12k/208k store it is hours -- which is the likeliest reason the
140
+ #: whole-graph pass had run exactly once in nine days. Nothing in retrieval reads
141
+ #: ``bridge_score``; only the dashboard displays it. So it is computed under a
142
+ #: node ceiling and reported as skipped above it, rather than being the reason
143
+ #: PageRank never lands.
144
+ BRIDGE_NODE_LIMIT = 1500
145
+
146
+ #: Louvain returns a hierarchy of partitions per node. Level 0 is the coarsest,
147
+ #: and on the author's store it yields 13 communities against the 11
148
+ #: meaningfully-sized ones the previous pass found -- so the community bias keeps
149
+ #: the granularity it was tuned against instead of being handed 210 fragments.
150
+ LOUVAIN_LEVEL = 0
151
+
152
+ #: A projection is only trustworthy if it agrees with the store. If Cozo's edge
153
+ #: count for this profile differs from SQLite's by more than this fraction, the
154
+ #: pass computes on SQLite instead and says so, rather than ranking the store's
155
+ #: memories from a graph the store does not have.
156
+ MAX_PROJECTION_DRIFT = 0.02
157
+
158
+
159
+ @dataclass(frozen=True)
160
+ class GraphMetricsReport:
161
+ """What one pass actually did. No field here is inferred from another."""
162
+
163
+ profile_id: str
164
+ engine: str = "none"
165
+ facts: int = 0
166
+ edges: int = 0
167
+ connected: int = 0
168
+ isolated: int = 0
169
+ communities: int = 0
170
+ written: int = 0
171
+ removed: int = 0
172
+ bridges_computed: bool = False
173
+ duration_ms: int = 0
174
+ error: str | None = None
175
+ notes: tuple[str, ...] = field(default_factory=tuple)
176
+
177
+ @property
178
+ def ok(self) -> bool:
179
+ return self.error is None
180
+
181
+ def summary(self) -> str:
182
+ if self.error:
183
+ return f"graph metrics for {self.profile_id} FAILED: {self.error}"
184
+ return (
185
+ f"graph metrics for {self.profile_id}: {self.written} facts "
186
+ f"({self.connected} connected, {self.isolated} isolated), "
187
+ f"{self.edges} edges, {self.communities} communities, "
188
+ f"engine={self.engine}, {self.duration_ms} ms"
189
+ )
190
+
191
+
192
+ # ----------------------------------------------------------------------
193
+ # Engines. Each returns metrics for the edge-connected nodes only; the
194
+ # isolated-node convention belongs to the caller, once, for both.
195
+ # ----------------------------------------------------------------------
196
+
197
+
198
+ def _cozo_metrics(
199
+ backend: Any, profile_id: str, damping: float
200
+ ) -> tuple[dict[str, float], dict[str, int]]:
201
+ """PageRank and Louvain from Cozo's native fixed rules.
202
+
203
+ Both queries filter on ``profile_id``: the projection holds every profile's
204
+ edges in one relation, and an unfiltered rule would rank one profile's
205
+ memories using another's graph.
206
+ """
207
+ client = backend._db # the module-private client wrapper; no public accessor
208
+ pr_rows = client.run(
209
+ "rel[a, b, w] := *edge{from_id: a, to_id: b, weight: w, "
210
+ " profile_id: $pid}\n"
211
+ "?[node, score] <~ PageRank(rel[a, b, w], theta: $theta)",
212
+ {"pid": profile_id, "theta": damping},
213
+ )
214
+ pagerank = {str(r[0]): float(r[1]) for r in pr_rows.values.tolist()}
215
+
216
+ comm_rows = client.run(
217
+ "rel[a, b, w] := *edge{from_id: a, to_id: b, weight: w, "
218
+ " profile_id: $pid}\n"
219
+ "?[grp, node] <~ CommunityDetectionLouvain(rel[a, b, w])",
220
+ {"pid": profile_id},
221
+ )
222
+ communities: dict[str, int] = {}
223
+ for grp, node in comm_rows.values.tolist():
224
+ label = grp[LOUVAIN_LEVEL] if isinstance(grp, (list, tuple)) else grp
225
+ communities[str(node)] = int(label)
226
+ return pagerank, communities
227
+
228
+
229
+ def _networkx_metrics(
230
+ edges: list[tuple[str, str, float]], damping: float
231
+ ) -> tuple[dict[str, float], dict[str, int]]:
232
+ """The same two metrics without a graph projection to read."""
233
+ import networkx as nx
234
+ from networkx.algorithms.community import louvain_communities
235
+
236
+ digraph = nx.DiGraph()
237
+ for source, target, weight in edges:
238
+ if digraph.has_edge(source, target):
239
+ if weight > digraph[source][target].get("weight", 0.0):
240
+ digraph[source][target]["weight"] = weight
241
+ else:
242
+ digraph.add_edge(source, target, weight=weight)
243
+ if digraph.number_of_nodes() == 0:
244
+ return {}, {}
245
+
246
+ pagerank = nx.pagerank(digraph, alpha=damping, weight="weight")
247
+ communities: dict[str, int] = {}
248
+ try:
249
+ partitions = louvain_communities(
250
+ digraph.to_undirected(), weight="weight", seed=42,
251
+ )
252
+ for label, members in enumerate(partitions):
253
+ for node in members:
254
+ communities[str(node)] = label
255
+ except Exception as exc: # noqa: BLE001 -- a partition is optional, a score is not
256
+ logger.debug("Louvain partition unavailable: %s", exc)
257
+ return pagerank, communities
258
+
259
+
260
+ def _bridge_scores(
261
+ edges: list[tuple[str, str, float]], node_count: int
262
+ ) -> dict[str, float] | None:
263
+ """Sampled betweenness, or None when the graph is too big to afford it."""
264
+ if node_count > BRIDGE_NODE_LIMIT or node_count <= 2:
265
+ return None
266
+ try:
267
+ import networkx as nx
268
+
269
+ graph = nx.DiGraph()
270
+ for source, target, weight in edges:
271
+ graph.add_edge(source, target, weight=weight)
272
+ return nx.betweenness_centrality(graph, weight="weight", normalized=True)
273
+ except Exception as exc: # noqa: BLE001
274
+ logger.debug("Bridge scores unavailable: %s", exc)
275
+ return None
276
+
277
+
278
+ # ----------------------------------------------------------------------
279
+ # The pass
280
+ # ----------------------------------------------------------------------
281
+
282
+
283
+ def _visible_fact_ids(conn: sqlite3.Connection, profile_id: str) -> list[str]:
284
+ from superlocalmemory.storage.database import (
285
+ visible_fact_clause_for_connection,
286
+ )
287
+
288
+ clause = visible_fact_clause_for_connection(conn)
289
+ rows = conn.execute(
290
+ f"SELECT fact_id FROM atomic_facts WHERE profile_id = ?{clause}",
291
+ (profile_id,),
292
+ ).fetchall()
293
+ return [str(row[0]) for row in rows]
294
+
295
+
296
+ def _projection_usable(
297
+ backend: Any, profile_id: str, sqlite_edges: int
298
+ ) -> tuple[bool, str]:
299
+ """Whether Cozo's edge set is close enough to the store's to rank from."""
300
+ if backend is None:
301
+ return False, "no graph projection"
302
+ try:
303
+ rows = backend._db.run(
304
+ "?[count(a)] := *edge{from_id: a, profile_id: $pid}",
305
+ {"pid": profile_id},
306
+ )
307
+ projected = int(rows.values.tolist()[0][0]) if len(rows) else 0
308
+ except Exception as exc: # noqa: BLE001
309
+ return False, f"projection unreadable: {exc}"
310
+ if sqlite_edges == 0:
311
+ return projected == 0, "both empty"
312
+ drift = abs(projected - sqlite_edges) / float(sqlite_edges)
313
+ if drift > MAX_PROJECTION_DRIFT:
314
+ return False, (
315
+ f"projection drifted {drift:.1%} "
316
+ f"({projected} projected vs {sqlite_edges} stored)"
317
+ )
318
+ return True, f"projection within {drift:.2%}"
319
+
320
+
321
+ def _projection_is_current(db: Any, profile_id: str) -> bool:
322
+ """Whether every change SQLite has recorded has reached the projection.
323
+
324
+ Two engines ranking two different graphs is worse than one engine ranking
325
+ the right one, because the difference is invisible in the output: both
326
+ return a full table of plausible scores. The queue is the only place that
327
+ records a change the projection has not seen yet, so an outstanding row
328
+ means the projection is a graph the store no longer has.
329
+
330
+ Absent queue means an older store that never had one; there is nothing to
331
+ be behind on, so the projection is as current as it can be.
332
+ """
333
+ try:
334
+ from superlocalmemory.storage import projection_outbox
335
+
336
+ if not projection_outbox.is_available(db):
337
+ return True
338
+ with _short_connection(db) as conn:
339
+ row = conn.execute(
340
+ "SELECT COUNT(*) FROM projection_outbox WHERE profile_id = ?",
341
+ (profile_id,),
342
+ ).fetchone()
343
+ return int(row[0] if row else 0) == 0
344
+ except Exception as exc: # noqa: BLE001 -- unreadable means do not trust it
345
+ logger.debug("graph metrics: cannot check projection currency: %s", exc)
346
+ return False
347
+
348
+
349
+ def compute_graph_metrics(
350
+ db: Any,
351
+ profile_id: str,
352
+ *,
353
+ backend: Any = None,
354
+ damping: float = DEFAULT_DAMPING,
355
+ prefer: str = "networkx",
356
+ ) -> GraphMetricsReport:
357
+ """Recompute ``fact_importance`` for one profile. Returns what it did.
358
+
359
+ ``backend`` is a live graph projection to compute on and ``prefer`` selects
360
+ the engine; the projection is used only when both point at it AND it agrees
361
+ with the store. Errors are returned in the report rather than swallowed: a
362
+ pass that writes nothing and a store with no facts are different events, and
363
+ the previous implementation reported both as ``node_count: 0``.
364
+ """
365
+ started = time.monotonic()
366
+ notes: list[str] = []
367
+ try:
368
+ with _short_connection(db) as conn:
369
+ nodes = _visible_fact_ids(conn, profile_id)
370
+ edges = [
371
+ (str(source), str(target), float(weight))
372
+ for source, target, _etype, weight, _pid
373
+ in iter_logical_edges(conn, profile_id)
374
+ ]
375
+ except Exception as exc: # noqa: BLE001
376
+ return GraphMetricsReport(
377
+ profile_id=profile_id,
378
+ error=f"could not read the graph: {exc}",
379
+ duration_ms=int((time.monotonic() - started) * 1000),
380
+ )
381
+
382
+ if not nodes:
383
+ return GraphMetricsReport(
384
+ profile_id=profile_id,
385
+ engine="none",
386
+ duration_ms=int((time.monotonic() - started) * 1000),
387
+ notes=("no visible facts",),
388
+ )
389
+
390
+ usable, why = _projection_usable(backend, profile_id, len(edges))
391
+ notes.append(why)
392
+ if usable and not _projection_is_current(db, profile_id):
393
+ usable = False
394
+ why = "projection has unapplied changes"
395
+ notes.append(why)
396
+ engine = "cozo" if (usable and prefer == "cozo") else "networkx"
397
+ try:
398
+ # On ``engine``, not on ``usable``. Branching on ``usable`` ran the
399
+ # projection whenever one was available, whatever the caller asked for,
400
+ # and then reported the engine the caller had asked for -- so a store
401
+ # with a projection was ranked by it while every log line said
402
+ # otherwise, and the fallback below could never be reached.
403
+ if engine == "cozo":
404
+ pagerank, communities = _cozo_metrics(backend, profile_id, damping)
405
+ else:
406
+ pagerank, communities = _networkx_metrics(edges, damping)
407
+ except Exception as exc: # noqa: BLE001
408
+ if engine == "cozo":
409
+ notes.append(f"cozo failed, fell back: {exc}")
410
+ engine = "networkx"
411
+ try:
412
+ pagerank, communities = _networkx_metrics(edges, damping)
413
+ except Exception as inner: # noqa: BLE001
414
+ return GraphMetricsReport(
415
+ profile_id=profile_id, engine="networkx",
416
+ facts=len(nodes), edges=len(edges),
417
+ error=f"both engines failed: {exc} / {inner}",
418
+ duration_ms=int((time.monotonic() - started) * 1000),
419
+ notes=tuple(notes),
420
+ )
421
+ else:
422
+ return GraphMetricsReport(
423
+ profile_id=profile_id, engine=engine,
424
+ facts=len(nodes), edges=len(edges),
425
+ error=f"metrics failed: {exc}",
426
+ duration_ms=int((time.monotonic() - started) * 1000),
427
+ notes=tuple(notes),
428
+ )
429
+
430
+ # An engine only sees nodes that appear in an edge. Everything else is a
431
+ # visible fact with no graph position, and it gets the teleport share -- the
432
+ # value PageRank would give a node nothing links to.
433
+ node_set = set(nodes)
434
+ total = len(node_set)
435
+ base = (1.0 - damping) / float(total)
436
+ connected = {fid for fid in pagerank if fid in node_set}
437
+ isolated = node_set - connected
438
+ # Leave room for the isolated mass so the whole table still sums to ~1 and
439
+ # the ranker's absolute thresholds keep meaning what they meant.
440
+ headroom = max(0.0, 1.0 - base * len(isolated))
441
+ connected_mass = sum(pagerank[fid] for fid in connected) or 1.0
442
+
443
+ degree: dict[str, int] = {}
444
+ for source, target, _weight in edges:
445
+ degree[source] = degree.get(source, 0) + 1
446
+ degree[target] = degree.get(target, 0) + 1
447
+ divisor = float(total - 1) if total > 1 else 1.0
448
+
449
+ bridges = _bridge_scores(edges, total)
450
+ if bridges is None:
451
+ notes.append(f"bridge scores skipped above {BRIDGE_NODE_LIMIT} facts")
452
+
453
+ rows: list[tuple[Any, ...]] = []
454
+ for fact_id in nodes:
455
+ if fact_id in connected:
456
+ score = pagerank[fact_id] / connected_mass * headroom
457
+ else:
458
+ score = base
459
+ community = communities.get(fact_id)
460
+ rows.append((
461
+ fact_id,
462
+ profile_id,
463
+ round(float(score), 9),
464
+ int(community) if community is not None else None,
465
+ round(degree.get(fact_id, 0) / divisor, 6),
466
+ round(float((bridges or {}).get(fact_id, 0.0)), 6),
467
+ ))
468
+
469
+ try:
470
+ removed = _write(db, profile_id, rows)
471
+ except Exception as exc: # noqa: BLE001
472
+ return GraphMetricsReport(
473
+ profile_id=profile_id, engine=engine, facts=total,
474
+ edges=len(edges), connected=len(connected),
475
+ isolated=len(isolated),
476
+ error=f"metrics computed but not stored: {exc}",
477
+ duration_ms=int((time.monotonic() - started) * 1000),
478
+ notes=tuple(notes),
479
+ )
480
+
481
+ return GraphMetricsReport(
482
+ profile_id=profile_id,
483
+ engine=engine,
484
+ facts=total,
485
+ edges=len(edges),
486
+ connected=len(connected),
487
+ isolated=len(isolated),
488
+ communities=len({c for c in communities.values()}),
489
+ written=len(rows),
490
+ removed=removed,
491
+ bridges_computed=bridges is not None,
492
+ duration_ms=int((time.monotonic() - started) * 1000),
493
+ notes=tuple(notes),
494
+ )
495
+
496
+
497
+ def _write(db: Any, profile_id: str, rows: list[tuple[Any, ...]]) -> int:
498
+ """Replace this profile's rows in one transaction.
499
+
500
+ Deleting first is what makes the table a projection rather than an
501
+ accumulation: a fact that has since been withheld or erased must lose its
502
+ row, or the ranker keeps scoring something recall will never return. The
503
+ delete and the insert share a transaction so no recall ever sees the
504
+ intermediate state where the profile has no metrics at all.
505
+ """
506
+ removed = 0
507
+ with _short_connection(db) as conn:
508
+ _ensure_bridge_column(conn)
509
+ keep = {row[0] for row in rows}
510
+ existing = {
511
+ str(r[0]) for r in conn.execute(
512
+ "SELECT fact_id FROM fact_importance WHERE profile_id = ?",
513
+ (profile_id,),
514
+ ).fetchall()
515
+ }
516
+ stale = existing - keep
517
+ for index in range(0, len(list(stale)), 800):
518
+ chunk = list(stale)[index:index + 800]
519
+ placeholders = ",".join("?" for _ in chunk)
520
+ conn.execute(
521
+ f"DELETE FROM fact_importance WHERE profile_id = ? "
522
+ f"AND fact_id IN ({placeholders})",
523
+ (profile_id, *chunk),
524
+ )
525
+ removed += len(chunk)
526
+ conn.executemany(
527
+ "INSERT INTO fact_importance "
528
+ "(fact_id, profile_id, pagerank_score, community_id, "
529
+ " degree_centrality, bridge_score, computed_at) "
530
+ "VALUES (?, ?, ?, ?, ?, ?, datetime('now')) "
531
+ "ON CONFLICT(fact_id) DO UPDATE SET "
532
+ " profile_id = excluded.profile_id, "
533
+ " pagerank_score = excluded.pagerank_score, "
534
+ " community_id = excluded.community_id, "
535
+ " degree_centrality = excluded.degree_centrality, "
536
+ " bridge_score = excluded.bridge_score, "
537
+ " computed_at = excluded.computed_at",
538
+ rows,
539
+ )
540
+ return removed
541
+
542
+
543
+ def _ensure_bridge_column(conn: sqlite3.Connection) -> None:
544
+ """Idempotent: ``bridge_score`` arrived after the table did.
545
+
546
+ A store created before it exists in the wild, so the insert below cannot
547
+ assume the column. Adding it here rather than refusing keeps an upgrade from
548
+ silently losing its metrics on first run.
549
+ """
550
+ try:
551
+ columns = {row[1] for row in conn.execute("PRAGMA table_info(fact_importance)")}
552
+ if "bridge_score" not in columns:
553
+ conn.execute(
554
+ "ALTER TABLE fact_importance ADD COLUMN bridge_score REAL DEFAULT 0.0"
555
+ )
556
+ except sqlite3.Error as exc:
557
+ logger.debug("bridge_score column check failed: %s", exc)
558
+
559
+
560
+ def metrics_are_stale(db: Any, profile_id: str) -> tuple[bool, str]:
561
+ """Whether this profile's metrics no longer describe its graph.
562
+
563
+ Deliberately not a clock. "Recomputed 30 minutes ago" says nothing about
564
+ whether the store changed, and the failure this guards against is a memory
565
+ that has no row at all -- which the ranker cannot distinguish from a memory
566
+ with no graph position. So the test is coverage: is any visible fact
567
+ missing, or does the table describe facts that are gone.
568
+
569
+ Cheap enough to run every cycle: two counting queries against an indexed
570
+ column.
571
+ """
572
+ try:
573
+ with _short_connection(db) as conn:
574
+ from superlocalmemory.storage.database import (
575
+ visible_fact_clause_for_connection,
576
+ )
577
+
578
+ clause = visible_fact_clause_for_connection(conn, prefix="f")
579
+ missing = conn.execute(
580
+ "SELECT COUNT(*) FROM atomic_facts f "
581
+ "LEFT JOIN fact_importance fi ON fi.fact_id = f.fact_id "
582
+ f"WHERE f.profile_id = ?{clause} AND fi.fact_id IS NULL",
583
+ (profile_id,),
584
+ ).fetchone()[0]
585
+ if missing:
586
+ return True, f"{missing} visible fact(s) have no metrics"
587
+ surplus = conn.execute(
588
+ "SELECT COUNT(*) FROM fact_importance fi "
589
+ "LEFT JOIN atomic_facts f ON f.fact_id = fi.fact_id "
590
+ f"WHERE fi.profile_id = ? AND (f.fact_id IS NULL OR NOT (1=1{clause}))",
591
+ (profile_id,),
592
+ ).fetchone()[0]
593
+ if surplus:
594
+ return True, f"{surplus} metric row(s) describe facts recall cannot return"
595
+ return False, "metrics cover the visible graph"
596
+ except Exception as exc: # noqa: BLE001 -- a failed check must not skip the pass
597
+ return True, f"staleness check failed ({exc}); recomputing"