superlocalmemory 3.8.13 → 4.0.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.
- package/ATTRIBUTION.md +4 -4
- package/CHANGELOG.md +113 -121
- package/README.md +65 -63
- package/docs/pi-dev-integration.md +1 -1
- package/package.json +6 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- 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 +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- 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 +248 -0
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-loop/SKILL.md +99 -0
- package/plugin-src/skills/slm-mesh/SKILL.md +282 -0
- package/plugin-src/skills/slm-profile/SKILL.md +148 -0
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-scope/SKILL.md +176 -0
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +11 -4
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +125 -11
- package/src/superlocalmemory/cli/daemon.py +5 -1
- package/src/superlocalmemory/cli/main.py +35 -2
- package/src/superlocalmemory/cli/ops_cmd.py +281 -0
- package/src/superlocalmemory/cli/setup_wizard.py +1 -1
- package/src/superlocalmemory/compliance/audit.py +65 -0
- package/src/superlocalmemory/compliance/eu_ai_act.py +27 -57
- package/src/superlocalmemory/compliance/gdpr.py +416 -20
- package/src/superlocalmemory/compliance/retention.py +74 -22
- package/src/superlocalmemory/compliance/scheduler.py +78 -9
- package/src/superlocalmemory/core/actor_context.py +166 -0
- package/src/superlocalmemory/core/admission.py +549 -0
- package/src/superlocalmemory/core/backend_orchestrator.py +23 -10
- package/src/superlocalmemory/core/config.py +202 -24
- package/src/superlocalmemory/core/consolidation_engine.py +13 -13
- package/src/superlocalmemory/core/context_cache.py +28 -0
- package/src/superlocalmemory/core/embeddings.py +64 -2
- package/src/superlocalmemory/core/engine.py +7 -2
- package/src/superlocalmemory/core/engine_ingestion.py +65 -3
- package/src/superlocalmemory/core/engine_wiring.py +36 -9
- package/src/superlocalmemory/core/ingest_policy.py +38 -0
- package/src/superlocalmemory/core/maintenance.py +255 -0
- package/src/superlocalmemory/core/modes.py +40 -13
- package/src/superlocalmemory/core/mutations.py +437 -44
- package/src/superlocalmemory/core/operation_policy.py +92 -0
- package/src/superlocalmemory/core/operation_policy_registry.py +542 -0
- package/src/superlocalmemory/core/operation_request.py +127 -0
- package/src/superlocalmemory/core/ops_remediation.py +542 -0
- package/src/superlocalmemory/core/recall_pipeline.py +7 -0
- package/src/superlocalmemory/core/remember_runtime.py +202 -4
- package/src/superlocalmemory/core/remote_mode.py +20 -5
- package/src/superlocalmemory/core/store_pipeline.py +150 -0
- package/src/superlocalmemory/core/topic_signature.py +19 -4
- package/src/superlocalmemory/core/transactions/__init__.py +78 -0
- package/src/superlocalmemory/core/transactions/concrete_owners.py +597 -0
- package/src/superlocalmemory/core/transactions/erasure.py +825 -0
- package/src/superlocalmemory/core/transactions/manifest.py +255 -0
- package/src/superlocalmemory/core/transactions/manifest_key.py +155 -0
- package/src/superlocalmemory/core/transactions/obligations.py +272 -0
- package/src/superlocalmemory/core/transactions/owners.py +114 -0
- package/src/superlocalmemory/core/transactions/reconciler.py +285 -0
- package/src/superlocalmemory/core/transactions/service.py +330 -0
- package/src/superlocalmemory/core/worker_pool.py +33 -5
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +70 -28
- package/src/superlocalmemory/encoding/emotional.py +75 -14
- package/src/superlocalmemory/encoding/scene_builder.py +115 -13
- package/src/superlocalmemory/encoding/temporal_parser.py +4 -0
- package/src/superlocalmemory/evolution/blind_verifier.py +11 -4
- package/src/superlocalmemory/evolution/evolution_store.py +244 -4
- package/src/superlocalmemory/evolution/llm_dispatch.py +40 -0
- package/src/superlocalmemory/evolution/model_selection.py +18 -3
- package/src/superlocalmemory/evolution/mutation_generator.py +3 -0
- package/src/superlocalmemory/evolution/skill_activator.py +270 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +281 -59
- package/src/superlocalmemory/evolution/types.py +30 -8
- package/src/superlocalmemory/graph/cozo_backend.py +17 -9
- package/src/superlocalmemory/hooks/auto_invoker.py +2 -1
- package/src/superlocalmemory/hooks/auto_recall.py +64 -30
- package/src/superlocalmemory/hooks/codex_assets.py +14 -1
- package/src/superlocalmemory/infra/backup.py +434 -7
- package/src/superlocalmemory/infra/process_reaper.py +18 -0
- package/src/superlocalmemory/infra/self_heal.py +401 -0
- package/src/superlocalmemory/learning/feedback.py +52 -9
- package/src/superlocalmemory/loops/engine.py +10 -0
- package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
- package/src/superlocalmemory/mcp/http_transport.py +30 -331
- package/src/superlocalmemory/mcp/profiles.py +5 -0
- package/src/superlocalmemory/mcp/resources.py +8 -0
- package/src/superlocalmemory/mcp/server.py +51 -4
- package/src/superlocalmemory/mcp/shared.py +19 -0
- package/src/superlocalmemory/mcp/tools_active.py +25 -4
- package/src/superlocalmemory/mcp/tools_code_graph.py +26 -18
- package/src/superlocalmemory/mcp/tools_context.py +50 -8
- package/src/superlocalmemory/mcp/tools_core.py +69 -21
- package/src/superlocalmemory/mcp/tools_evolution.py +9 -2
- package/src/superlocalmemory/mcp/tools_learning.py +21 -10
- package/src/superlocalmemory/mcp/tools_loops.py +29 -18
- package/src/superlocalmemory/mcp/tools_mesh.py +8 -0
- package/src/superlocalmemory/mcp/tools_ops.py +115 -0
- package/src/superlocalmemory/mcp/tools_optimize.py +4 -0
- package/src/superlocalmemory/mcp/tools_v28.py +10 -3
- package/src/superlocalmemory/mcp/tools_v3.py +34 -14
- package/src/superlocalmemory/mcp/tools_v33.py +18 -33
- package/src/superlocalmemory/mesh/broker.py +124 -46
- package/src/superlocalmemory/mesh/broker_security.py +470 -0
- package/src/superlocalmemory/mesh/discovery.py +365 -0
- package/src/superlocalmemory/mesh/lock_protocol.py +313 -0
- package/src/superlocalmemory/mesh/node_identity.py +97 -0
- package/src/superlocalmemory/mesh/outbox_remote.py +429 -0
- package/src/superlocalmemory/mesh/remote_sync.py +511 -28
- package/src/superlocalmemory/mesh/state_sync.py +286 -0
- package/src/superlocalmemory/optimize/config/store.py +45 -0
- package/src/superlocalmemory/parameterization/cross_project.py +12 -0
- package/src/superlocalmemory/parameterization/prompt_injector.py +13 -11
- package/src/superlocalmemory/parameterization/prompt_lifecycle.py +8 -2
- package/src/superlocalmemory/parameterization/workflow_miner.py +17 -0
- package/src/superlocalmemory/retrieval/ann_index.py +5 -0
- package/src/superlocalmemory/retrieval/bm25_channel.py +49 -2
- package/src/superlocalmemory/retrieval/engine.py +19 -4
- package/src/superlocalmemory/retrieval/fusion.py +4 -1
- package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -3
- package/src/superlocalmemory/retrieval/remote_reranker.py +47 -22
- package/src/superlocalmemory/retrieval/reranker.py +32 -1
- package/src/superlocalmemory/retrieval/temporal_channel.py +16 -3
- package/src/superlocalmemory/retrieval/temporal_utils.py +107 -0
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +155 -42
- package/src/superlocalmemory/retrieval/vector_store.py +214 -8
- package/src/superlocalmemory/server/api.py +5 -5
- package/src/superlocalmemory/server/egress_policy.py +258 -0
- package/src/superlocalmemory/server/rbac_enforce.py +32 -0
- package/src/superlocalmemory/server/route_mutations.py +20 -0
- package/src/superlocalmemory/server/routes/compliance.py +153 -7
- package/src/superlocalmemory/server/routes/data_io.py +43 -2
- package/src/superlocalmemory/server/routes/events.py +15 -0
- package/src/superlocalmemory/server/routes/memories.py +56 -3
- package/src/superlocalmemory/server/routes/mesh.py +82 -1
- package/src/superlocalmemory/server/routes/mesh_lock.py +54 -0
- package/src/superlocalmemory/server/routes/mesh_state.py +63 -0
- package/src/superlocalmemory/server/routes/v3_api.py +50 -27
- package/src/superlocalmemory/server/routes/ws.py +86 -0
- package/src/superlocalmemory/server/ui.py +6 -6
- package/src/superlocalmemory/server/unified_daemon.py +942 -119
- package/src/superlocalmemory/storage/_migration_internals.py +568 -0
- package/src/superlocalmemory/storage/_schema_version.py +110 -0
- package/src/superlocalmemory/storage/database.py +329 -24
- package/src/superlocalmemory/storage/embedding_migrator.py +246 -51
- package/src/superlocalmemory/storage/erasure_fence.py +45 -0
- package/src/superlocalmemory/storage/generation_fence.py +63 -0
- package/src/superlocalmemory/storage/migration_runner.py +140 -417
- package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +40 -0
- package/src/superlocalmemory/storage/migrations/M033_projection_transactions.py +148 -0
- package/src/superlocalmemory/storage/migrations/M034_obligation_integrity.py +58 -0
- package/src/superlocalmemory/storage/migrations/M035_erasure_receipts.py +113 -0
- package/src/superlocalmemory/storage/migrations/M036_vector_row_map.py +107 -0
- package/src/superlocalmemory/storage/migrations/M037_manifest_hmac_version.py +162 -0
- package/src/superlocalmemory/storage/migrations/{M033_learning_feedback_channel.py → M038_learning_feedback_channel.py} +3 -3
- package/src/superlocalmemory/storage/migrations/M039_scene_fact_members.py +137 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +4 -2
- package/src/superlocalmemory/storage/schema.py +67 -0
- package/src/superlocalmemory/storage/write_coordinator.py +125 -0
- package/src/superlocalmemory/trust/scorer.py +28 -4
- package/src/superlocalmemory/ui/index.html +14 -3
- package/src/superlocalmemory/ui/js/auto-settings.js +12 -1
- package/src/superlocalmemory/ui/js/brain.js +6 -4
- package/src/superlocalmemory/ui/js/compliance.js +66 -12
- package/src/superlocalmemory/ui/js/dashboard.js +13 -3
- package/src/superlocalmemory/ui/js/feedback.js +8 -2
- package/src/superlocalmemory/ui/js/lifecycle.js +7 -1
- package/src/superlocalmemory/ui/js/modal.js +272 -5
- package/src/superlocalmemory/ui/js/od-backup.js +9 -2
- package/src/superlocalmemory/ui/js/od-compliance-ext.js +301 -0
- package/src/superlocalmemory/ui/js/od-operations.js +154 -23
- package/src/superlocalmemory/ui/js/od-ops-health.js +417 -0
- package/src/superlocalmemory/ui/js/od-optimize.js +35 -21
- package/src/superlocalmemory/ui/js/od-team.js +9 -2
- package/src/superlocalmemory/ui/js/optimize.js +13 -16
- package/src/superlocalmemory/ui/js/profiles.js +7 -3
- package/src/superlocalmemory/ui/js/settings.js +7 -1
- package/src/superlocalmemory/vector/lancedb_backend.py +19 -9
- package/src/superlocalmemory/attribution/mathematical_dna.py +0 -235
- package/src/superlocalmemory/cli/post_install.py +0 -114
- package/src/superlocalmemory/core/clock_monitor.py +0 -45
- package/src/superlocalmemory/core/db_pool.py +0 -80
- package/src/superlocalmemory/core/error_catalog.py +0 -113
- package/src/superlocalmemory/core/loop_watchdog.py +0 -56
- package/src/superlocalmemory/core/priority_queue.py +0 -61
- package/src/superlocalmemory/core/pruning_engine.py +0 -216
- package/src/superlocalmemory/core/queue_dispatcher.py +0 -73
- package/src/superlocalmemory/core/slmignore.py +0 -125
- package/src/superlocalmemory/infra/heartbeat_monitor.py +0 -140
- package/src/superlocalmemory/infra/webhook_dispatcher.py +0 -247
- package/src/superlocalmemory/learning/quantization_scheduler.py +0 -320
- package/src/superlocalmemory/storage/access_control.py +0 -182
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V4 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""LWW remote state convergence for the SLM mesh (3c-1).
|
|
6
|
+
|
|
7
|
+
Convergence guarantee
|
|
8
|
+
---------------------
|
|
9
|
+
Leaderless, pull-based, deterministic Last-Writer-Wins. Every node that
|
|
10
|
+
exchanges deltas converges on the same ``(value, revision, node_id)`` for
|
|
11
|
+
every key.
|
|
12
|
+
|
|
13
|
+
This is NOT linearizable consensus. Concurrent writes can interleave; the
|
|
14
|
+
winner is chosen deterministically by a TOTAL ORDER on ``(revision: int,
|
|
15
|
+
node_id: str)``. Same revision → higher ``node_id`` wins (lexicographic).
|
|
16
|
+
Because the order is total, every node independently computes the same
|
|
17
|
+
winner given the same set of writes.
|
|
18
|
+
|
|
19
|
+
Backward compatibility
|
|
20
|
+
----------------------
|
|
21
|
+
``set_state`` in the broker never sets ``origin_node``; rows it writes have
|
|
22
|
+
``origin_node = ''`` (the column default added here). At merge and
|
|
23
|
+
serialization time ``''`` is interpreted as the LOCAL node_id, so existing
|
|
24
|
+
rows participate correctly in the LWW protocol without any change to
|
|
25
|
+
``broker.py``.
|
|
26
|
+
|
|
27
|
+
Author: Varun Pratap Bhardwaj
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import logging
|
|
33
|
+
import sqlite3
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from superlocalmemory.mesh.node_identity import get_node_id
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger("superlocalmemory.mesh.state_sync")
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Schema migration (additive, idempotent)
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
_ALTER_ORIGIN_NODE = (
|
|
45
|
+
"ALTER TABLE mesh_state ADD COLUMN origin_node TEXT NOT NULL DEFAULT ''"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# StateSyncer
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class StateSyncer:
|
|
55
|
+
"""Pull-based LWW convergence helper for the ``mesh_state`` table.
|
|
56
|
+
|
|
57
|
+
Designed to be instantiated on-demand (route handler, sync loop); all DB
|
|
58
|
+
connections are short-lived. Every operation is fail-soft: errors are
|
|
59
|
+
logged and the caller never receives an unhandled exception from here.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, broker: Any) -> None:
|
|
63
|
+
self._broker = broker
|
|
64
|
+
self._db_path: str = str(broker._db_path)
|
|
65
|
+
# Resolved once; stable for the lifetime of this instance.
|
|
66
|
+
self._node_id: str = get_node_id(self._db_path)
|
|
67
|
+
self._ensure_origin_node_column()
|
|
68
|
+
|
|
69
|
+
# ------------------------------------------------------------------
|
|
70
|
+
# Schema bootstrap
|
|
71
|
+
# ------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def _ensure_origin_node_column(self) -> None:
|
|
74
|
+
"""Add ``origin_node`` to ``mesh_state``; no-op if already present."""
|
|
75
|
+
try:
|
|
76
|
+
conn = sqlite3.connect(self._db_path, timeout=5.0)
|
|
77
|
+
try:
|
|
78
|
+
conn.execute(_ALTER_ORIGIN_NODE)
|
|
79
|
+
conn.commit()
|
|
80
|
+
except sqlite3.OperationalError as exc:
|
|
81
|
+
if "duplicate column" in str(exc).lower():
|
|
82
|
+
pass # Expected on second startup / upgrade
|
|
83
|
+
else:
|
|
84
|
+
logger.error(
|
|
85
|
+
"StateSyncer._ensure_origin_node_column: unexpected "
|
|
86
|
+
"OperationalError at %s: %s",
|
|
87
|
+
self._db_path, exc,
|
|
88
|
+
)
|
|
89
|
+
finally:
|
|
90
|
+
conn.close()
|
|
91
|
+
except sqlite3.Error as exc:
|
|
92
|
+
logger.error(
|
|
93
|
+
"StateSyncer._ensure_origin_node_column: DB error at %s: %s",
|
|
94
|
+
self._db_path, exc,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# ------------------------------------------------------------------
|
|
98
|
+
# Internal helpers
|
|
99
|
+
# ------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def _effective_node(self, stored_origin: str) -> str:
|
|
102
|
+
"""Resolve ``origin_node=''`` (BC rows) to this node's id at call time."""
|
|
103
|
+
return stored_origin if stored_origin else self._node_id
|
|
104
|
+
|
|
105
|
+
def _open_conn(self) -> sqlite3.Connection:
|
|
106
|
+
conn = sqlite3.connect(self._db_path, timeout=5.0)
|
|
107
|
+
conn.row_factory = sqlite3.Row
|
|
108
|
+
return conn
|
|
109
|
+
|
|
110
|
+
# ------------------------------------------------------------------
|
|
111
|
+
# Public API
|
|
112
|
+
# ------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
def local_delta(
|
|
115
|
+
self,
|
|
116
|
+
profile_id: str = "default",
|
|
117
|
+
since_revision: int = 0,
|
|
118
|
+
) -> list[dict]:
|
|
119
|
+
"""Return rows with ``revision > since_revision`` for a profile.
|
|
120
|
+
|
|
121
|
+
The ``node_id`` field in each entry reflects the *effective* node:
|
|
122
|
+
the stored ``origin_node`` when non-empty, or the local node_id for
|
|
123
|
+
BC rows written by the broker (``origin_node = ''``).
|
|
124
|
+
|
|
125
|
+
Returns ``[]`` on any DB error (fail-soft).
|
|
126
|
+
"""
|
|
127
|
+
try:
|
|
128
|
+
conn = self._open_conn()
|
|
129
|
+
try:
|
|
130
|
+
rows = conn.execute(
|
|
131
|
+
"SELECT key, value, set_by, updated_at,"
|
|
132
|
+
" COALESCE(revision, 0) AS revision,"
|
|
133
|
+
" COALESCE(origin_node, '') AS origin_node"
|
|
134
|
+
" FROM mesh_state"
|
|
135
|
+
" WHERE profile_id = ? AND COALESCE(revision, 0) > ?",
|
|
136
|
+
(profile_id, int(since_revision)),
|
|
137
|
+
).fetchall()
|
|
138
|
+
return [
|
|
139
|
+
{
|
|
140
|
+
"key": row["key"],
|
|
141
|
+
"value": row["value"],
|
|
142
|
+
"set_by": row["set_by"],
|
|
143
|
+
"updated_at": row["updated_at"],
|
|
144
|
+
"revision": int(row["revision"]),
|
|
145
|
+
"node_id": self._effective_node(row["origin_node"]),
|
|
146
|
+
}
|
|
147
|
+
for row in rows
|
|
148
|
+
]
|
|
149
|
+
finally:
|
|
150
|
+
conn.close()
|
|
151
|
+
except sqlite3.Error as exc:
|
|
152
|
+
logger.error(
|
|
153
|
+
"StateSyncer.local_delta: DB error at %s: %s",
|
|
154
|
+
self._db_path, exc,
|
|
155
|
+
)
|
|
156
|
+
return []
|
|
157
|
+
|
|
158
|
+
def merge_remote(
|
|
159
|
+
self,
|
|
160
|
+
profile_id: str,
|
|
161
|
+
remote_entries: list[dict],
|
|
162
|
+
) -> dict:
|
|
163
|
+
"""Merge remote delta entries using deterministic LWW.
|
|
164
|
+
|
|
165
|
+
For each entry ``{key, value, set_by, updated_at, revision, node_id}``:
|
|
166
|
+
|
|
167
|
+
* If ``(remote_rev, remote_node) > (local_rev, local_node)`` (total
|
|
168
|
+
order): UPSERT the local row. The winning ``revision`` is preserved
|
|
169
|
+
as-is — never incremented — so re-merging the same delta is a no-op
|
|
170
|
+
(idempotent convergence).
|
|
171
|
+
* Otherwise: do nothing.
|
|
172
|
+
|
|
173
|
+
Returns ``{"applied": N, "skipped": M}``. Errors per-entry are
|
|
174
|
+
logged and counted as skipped; they never propagate to the caller.
|
|
175
|
+
"""
|
|
176
|
+
applied = 0
|
|
177
|
+
skipped = 0
|
|
178
|
+
for entry in remote_entries:
|
|
179
|
+
# Compute a safe log key BEFORE the try so a non-dict entry can
|
|
180
|
+
# never make the error handler itself raise (audit P0/P2 — the
|
|
181
|
+
# handler previously called entry.get() on possibly-non-dict).
|
|
182
|
+
entry_key = entry.get("key") if isinstance(entry, dict) else repr(entry)
|
|
183
|
+
try:
|
|
184
|
+
if self._merge_one(profile_id, entry):
|
|
185
|
+
applied += 1
|
|
186
|
+
else:
|
|
187
|
+
skipped += 1
|
|
188
|
+
except sqlite3.Error as exc:
|
|
189
|
+
logger.error(
|
|
190
|
+
"StateSyncer.merge_remote: DB error on key=%r: %s",
|
|
191
|
+
entry_key, exc,
|
|
192
|
+
)
|
|
193
|
+
skipped += 1
|
|
194
|
+
except (KeyError, TypeError, ValueError, AttributeError) as exc:
|
|
195
|
+
logger.error(
|
|
196
|
+
"StateSyncer.merge_remote: malformed entry key=%r: %s",
|
|
197
|
+
entry_key, exc,
|
|
198
|
+
)
|
|
199
|
+
skipped += 1
|
|
200
|
+
return {"applied": applied, "skipped": skipped}
|
|
201
|
+
|
|
202
|
+
def _merge_one(self, profile_id: str, entry: dict) -> bool:
|
|
203
|
+
"""Apply one remote entry if it wins the LWW comparison.
|
|
204
|
+
|
|
205
|
+
Returns ``True`` if the local row was updated; ``False`` if local
|
|
206
|
+
won or the comparison was a tie (same revision, same node_id).
|
|
207
|
+
|
|
208
|
+
CRITICAL: ``revision`` is never incremented here. Preserving the
|
|
209
|
+
winning revision is what makes convergence idempotent — a second
|
|
210
|
+
merge of the same delta computes the identical comparison result and
|
|
211
|
+
takes no action.
|
|
212
|
+
"""
|
|
213
|
+
# Audit P0/P2: a non-dict entry must never crash the merge.
|
|
214
|
+
if not isinstance(entry, dict):
|
|
215
|
+
return False
|
|
216
|
+
key = str(entry["key"])
|
|
217
|
+
# Guard: cast revision to int to prevent string lexicographic miscompare
|
|
218
|
+
# (e.g. "10" < "9" as strings but 10 > 9 as ints).
|
|
219
|
+
remote_rev: int = int(entry["revision"])
|
|
220
|
+
remote_node: str = str(entry.get("node_id", "")).strip()
|
|
221
|
+
# Audit P1: an empty node_id would be stored as origin_node='' and then
|
|
222
|
+
# re-exported under THIS node's id, rewriting provenance and corrupting
|
|
223
|
+
# the tie-break. Refuse an entry without a real origin.
|
|
224
|
+
if not remote_node:
|
|
225
|
+
return False
|
|
226
|
+
# Audit P2: never persist a NULL value as the literal string "None".
|
|
227
|
+
if entry.get("value") is None:
|
|
228
|
+
return False
|
|
229
|
+
|
|
230
|
+
conn = self._open_conn()
|
|
231
|
+
# Audit P1 (TOCTOU): the read-compare-write MUST be atomic against a
|
|
232
|
+
# concurrent broker.set_state on the same key. Without a write lock, a
|
|
233
|
+
# stale local read lets a lower remote revision overwrite a fresher
|
|
234
|
+
# local write (losing local-highest-revision). BEGIN IMMEDIATE takes the
|
|
235
|
+
# write lock for the whole critical section.
|
|
236
|
+
conn.isolation_level = None # manual transaction control
|
|
237
|
+
try:
|
|
238
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
239
|
+
row = conn.execute(
|
|
240
|
+
"SELECT COALESCE(revision, 0) AS revision,"
|
|
241
|
+
" COALESCE(origin_node, '') AS origin_node"
|
|
242
|
+
" FROM mesh_state"
|
|
243
|
+
" WHERE profile_id = ? AND key = ?",
|
|
244
|
+
(profile_id, key),
|
|
245
|
+
).fetchone()
|
|
246
|
+
|
|
247
|
+
if row is None:
|
|
248
|
+
local_rev: int = 0
|
|
249
|
+
local_node: str = "" # No row — treat as the absolute minimum
|
|
250
|
+
else:
|
|
251
|
+
local_rev = int(row["revision"])
|
|
252
|
+
local_node = self._effective_node(row["origin_node"])
|
|
253
|
+
|
|
254
|
+
# Total order: compare revision (int) first; break ties by node_id (str lex).
|
|
255
|
+
# A strict > means ties-to-local (same rev, same node) → do nothing (idempotent).
|
|
256
|
+
remote_wins: bool = (remote_rev, remote_node) > (local_rev, local_node)
|
|
257
|
+
if not remote_wins:
|
|
258
|
+
conn.execute("ROLLBACK")
|
|
259
|
+
return False
|
|
260
|
+
|
|
261
|
+
# UPSERT: set origin_node = remote_node so subsequent merges of the
|
|
262
|
+
# same delta compute the same winner without re-resolving local node_id.
|
|
263
|
+
conn.execute(
|
|
264
|
+
"INSERT INTO mesh_state"
|
|
265
|
+
" (profile_id, key, value, set_by, updated_at, revision, origin_node)"
|
|
266
|
+
" VALUES (?, ?, ?, ?, ?, ?, ?)"
|
|
267
|
+
" ON CONFLICT(profile_id, key) DO UPDATE SET"
|
|
268
|
+
" value = excluded.value,"
|
|
269
|
+
" set_by = excluded.set_by,"
|
|
270
|
+
" updated_at = excluded.updated_at,"
|
|
271
|
+
" revision = excluded.revision,"
|
|
272
|
+
" origin_node = excluded.origin_node",
|
|
273
|
+
(
|
|
274
|
+
profile_id,
|
|
275
|
+
key,
|
|
276
|
+
str(entry["value"]),
|
|
277
|
+
str(entry["set_by"]),
|
|
278
|
+
str(entry["updated_at"]),
|
|
279
|
+
remote_rev,
|
|
280
|
+
remote_node,
|
|
281
|
+
),
|
|
282
|
+
)
|
|
283
|
+
conn.execute("COMMIT")
|
|
284
|
+
return True
|
|
285
|
+
finally:
|
|
286
|
+
conn.close()
|
|
@@ -13,10 +13,12 @@ and self._version. get() acquires a read-lock; save() acquires a write-lock.
|
|
|
13
13
|
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
|
+
import atexit
|
|
16
17
|
import json
|
|
17
18
|
import logging
|
|
18
19
|
import os
|
|
19
20
|
import threading
|
|
21
|
+
import weakref
|
|
20
22
|
from pathlib import Path
|
|
21
23
|
from typing import Any, Callable
|
|
22
24
|
|
|
@@ -29,6 +31,43 @@ logger = logging.getLogger(__name__)
|
|
|
29
31
|
_DEFAULT_CONFIG_PATH = DynamicStatePath("optimize.json")
|
|
30
32
|
_POLL_INTERVAL_SECONDS: float = 2.0
|
|
31
33
|
|
|
34
|
+
# ---- watchdog shutdown safety -------------------------------------------
|
|
35
|
+
# Each started watchdog runs in a daemon thread. At interpreter shutdown CPython
|
|
36
|
+
# kills daemon threads abruptly; on macOS, with the ``watchdog`` fsevents C
|
|
37
|
+
# extension also loaded in-process, a daemon thread still executing while the
|
|
38
|
+
# interpreter finalizes can race the teardown and SIGSEGV. We track every store
|
|
39
|
+
# that has a live watchdog and stop+join them from an ``atexit`` hook — atexit
|
|
40
|
+
# callbacks run BEFORE daemon threads are terminated, so no watchdog thread is
|
|
41
|
+
# still alive when CPython finalizes.
|
|
42
|
+
_active_stores: "weakref.WeakSet[ConfigStore]" = weakref.WeakSet()
|
|
43
|
+
_registry_lock = threading.Lock()
|
|
44
|
+
_atexit_registered = False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _register_active_store(store: "ConfigStore") -> None:
|
|
48
|
+
"""Track a store with a running watchdog; install the atexit hook once."""
|
|
49
|
+
global _atexit_registered
|
|
50
|
+
with _registry_lock:
|
|
51
|
+
_active_stores.add(store)
|
|
52
|
+
if not _atexit_registered:
|
|
53
|
+
atexit.register(_stop_all_watchdogs)
|
|
54
|
+
_atexit_registered = True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _stop_all_watchdogs() -> None:
|
|
58
|
+
"""Stop+join every registered watchdog. Idempotent; safe at interpreter exit.
|
|
59
|
+
|
|
60
|
+
Also invoked by the test harness at session finish (see tests/conftest.py)
|
|
61
|
+
so no leaked watchdog thread survives into interpreter finalization.
|
|
62
|
+
"""
|
|
63
|
+
with _registry_lock:
|
|
64
|
+
stores = list(_active_stores)
|
|
65
|
+
for store in stores:
|
|
66
|
+
try:
|
|
67
|
+
store.stop_watchdog()
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
|
|
32
71
|
|
|
33
72
|
class ConfigStore:
|
|
34
73
|
"""Manages optimize.json with hot-reload capability."""
|
|
@@ -125,6 +164,10 @@ class ConfigStore:
|
|
|
125
164
|
daemon=True,
|
|
126
165
|
)
|
|
127
166
|
self._watchdog_thread.start()
|
|
167
|
+
# Register outside self._lock — _register_active_store takes the module
|
|
168
|
+
# registry lock, and the idempotent early-return above already covers a
|
|
169
|
+
# re-start (the store stays registered from the first start).
|
|
170
|
+
_register_active_store(self)
|
|
128
171
|
|
|
129
172
|
def stop_watchdog(self) -> None:
|
|
130
173
|
"""Signal the watchdog thread to stop and join it (timeout=5s)."""
|
|
@@ -134,6 +177,8 @@ class ConfigStore:
|
|
|
134
177
|
t.join(timeout=5.0)
|
|
135
178
|
with self._lock:
|
|
136
179
|
self._watchdog_thread = None
|
|
180
|
+
with _registry_lock:
|
|
181
|
+
_active_stores.discard(self)
|
|
137
182
|
|
|
138
183
|
def version(self) -> int:
|
|
139
184
|
with self._lock:
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Cross-project preference aggregation for the parameterization subsystem.
|
|
2
|
+
|
|
3
|
+
The aggregator implementation lives in
|
|
4
|
+
:mod:`superlocalmemory.learning.cross_project`. It is re-exported here so the
|
|
5
|
+
parameterization namespace — where the pattern extractor consumes it — resolves
|
|
6
|
+
to the same class instead of a missing module.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from superlocalmemory.learning.cross_project import CrossProjectAggregator
|
|
11
|
+
|
|
12
|
+
__all__ = ["CrossProjectAggregator"]
|
|
@@ -77,24 +77,26 @@ class PromptInjector:
|
|
|
77
77
|
|
|
78
78
|
templates: list[SoftPromptTemplate] = []
|
|
79
79
|
for row in rows:
|
|
80
|
-
|
|
80
|
+
# DatabaseManager returns sqlite3.Row which lacks .get() — convert to dict.
|
|
81
|
+
r = dict(row)
|
|
82
|
+
raw_ids = r.get("source_pattern_ids", "[]")
|
|
81
83
|
try:
|
|
82
84
|
source_ids = json.loads(raw_ids) if isinstance(raw_ids, str) else raw_ids
|
|
83
85
|
except (json.JSONDecodeError, TypeError):
|
|
84
86
|
source_ids = []
|
|
85
87
|
|
|
86
88
|
templates.append(SoftPromptTemplate(
|
|
87
|
-
prompt_id=
|
|
88
|
-
profile_id=
|
|
89
|
-
category=
|
|
90
|
-
content=
|
|
89
|
+
prompt_id=r["prompt_id"],
|
|
90
|
+
profile_id=r.get("profile_id", profile_id),
|
|
91
|
+
category=r["category"],
|
|
92
|
+
content=r["content"],
|
|
91
93
|
source_pattern_ids=source_ids,
|
|
92
|
-
confidence=
|
|
93
|
-
effectiveness=
|
|
94
|
-
token_count=
|
|
95
|
-
retention_score=
|
|
96
|
-
active=bool(
|
|
97
|
-
version=
|
|
94
|
+
confidence=r["confidence"],
|
|
95
|
+
effectiveness=r.get("effectiveness", 0.5),
|
|
96
|
+
token_count=r.get("token_count", 0),
|
|
97
|
+
retention_score=r.get("retention_score", 1.0),
|
|
98
|
+
active=bool(r.get("active", 1)),
|
|
99
|
+
version=r.get("version", 1),
|
|
98
100
|
))
|
|
99
101
|
|
|
100
102
|
# Budget enforcement
|
|
@@ -45,12 +45,13 @@ class PromptLifecycleManager:
|
|
|
45
45
|
def __init__(
|
|
46
46
|
self,
|
|
47
47
|
db: DatabaseManager,
|
|
48
|
-
ebbinghaus: EbbinghausCurve,
|
|
49
48
|
config: ParameterizationConfig,
|
|
49
|
+
*,
|
|
50
|
+
ebbinghaus: EbbinghausCurve | None = None,
|
|
50
51
|
) -> None:
|
|
51
52
|
self._db = db
|
|
52
|
-
self._ebbinghaus = ebbinghaus
|
|
53
53
|
self._config = config
|
|
54
|
+
self._ebbinghaus = ebbinghaus
|
|
54
55
|
|
|
55
56
|
# ------------------------------------------------------------------
|
|
56
57
|
# Effectiveness tracking
|
|
@@ -157,6 +158,11 @@ class PromptLifecycleManager:
|
|
|
157
158
|
s_raw = 2.0 * effectiveness * version
|
|
158
159
|
s_prompt = max(_PROMPT_STRENGTH_FLOOR, s_raw)
|
|
159
160
|
|
|
161
|
+
if self._ebbinghaus is None:
|
|
162
|
+
from superlocalmemory.math.ebbinghaus import EbbinghausCurve
|
|
163
|
+
from superlocalmemory.core.config import ForgettingConfig
|
|
164
|
+
self._ebbinghaus = EbbinghausCurve(ForgettingConfig())
|
|
165
|
+
|
|
160
166
|
retention = self._ebbinghaus.retention(hours, s_prompt)
|
|
161
167
|
return retention
|
|
162
168
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""Workflow miner re-export for the parameterization namespace.
|
|
6
|
+
|
|
7
|
+
The canonical implementation lives in
|
|
8
|
+
:mod:`superlocalmemory.learning.workflows`. This thin module re-exports
|
|
9
|
+
``WorkflowMiner`` so that code in the parameterization subsystem can import
|
|
10
|
+
from the same namespace as the pattern extractor's dependency declaration.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from superlocalmemory.learning.workflows import WorkflowMiner
|
|
16
|
+
|
|
17
|
+
__all__ = ["WorkflowMiner"]
|
|
@@ -58,6 +58,11 @@ class ANNIndex:
|
|
|
58
58
|
"""Embedding dimension this index was created for."""
|
|
59
59
|
return self._dim
|
|
60
60
|
|
|
61
|
+
def contains(self, fact_id: str) -> bool:
|
|
62
|
+
"""Return True if the index currently holds this fact."""
|
|
63
|
+
with self._lock:
|
|
64
|
+
return fact_id in self._id_to_idx
|
|
65
|
+
|
|
61
66
|
# ------------------------------------------------------------------
|
|
62
67
|
# Mutation
|
|
63
68
|
# ------------------------------------------------------------------
|
|
@@ -221,11 +221,19 @@ class BM25Channel:
|
|
|
221
221
|
include_shared=include_shared,
|
|
222
222
|
prefix="af",
|
|
223
223
|
)
|
|
224
|
+
# Archived facts are not live; never surface them in keyword recall.
|
|
225
|
+
# Guarded on column presence: the archive column is added by a deferred
|
|
226
|
+
# migration and may be absent on an unmigrated database.
|
|
227
|
+
archive_clause = (
|
|
228
|
+
" AND COALESCE(af.archive_status, 'live') != 'archived'"
|
|
229
|
+
if self._db._has_archive_status()
|
|
230
|
+
else ""
|
|
231
|
+
)
|
|
224
232
|
sql = (
|
|
225
233
|
"SELECT af.fact_id AS fact_id, bm25(atomic_facts_fts) AS rank "
|
|
226
234
|
"FROM atomic_facts_fts "
|
|
227
235
|
"JOIN atomic_facts af ON af.rowid = atomic_facts_fts.rowid "
|
|
228
|
-
f"WHERE atomic_facts_fts MATCH ? AND {where} "
|
|
236
|
+
f"WHERE atomic_facts_fts MATCH ? AND {where}{archive_clause} "
|
|
229
237
|
"ORDER BY rank LIMIT ?"
|
|
230
238
|
)
|
|
231
239
|
rows = self._db.execute(sql, (match_expr, *params, int(top_k)))
|
|
@@ -248,7 +256,7 @@ class BM25Channel:
|
|
|
248
256
|
"SELECT af.fact_id AS fact_id, bm25(fact_expansion_fts) AS rank "
|
|
249
257
|
"FROM fact_expansion_fts "
|
|
250
258
|
"JOIN atomic_facts af ON af.fact_id = fact_expansion_fts.fact_id "
|
|
251
|
-
f"WHERE fact_expansion_fts MATCH ? AND {where} "
|
|
259
|
+
f"WHERE fact_expansion_fts MATCH ? AND {where}{archive_clause} "
|
|
252
260
|
"ORDER BY rank LIMIT ?"
|
|
253
261
|
)
|
|
254
262
|
for r in self._db.execute(exp_sql, (match_expr, *params, int(top_k))):
|
|
@@ -336,6 +344,45 @@ class BM25Channel:
|
|
|
336
344
|
scored.sort(key=lambda x: x[1], reverse=True)
|
|
337
345
|
return scored[:top_k]
|
|
338
346
|
|
|
347
|
+
def update_fact(self, fact_id: str, new_content: str, profile_id: str) -> None:
|
|
348
|
+
"""Replace a fact's representation in the live index and persist new tokens.
|
|
349
|
+
|
|
350
|
+
Removes any existing in-memory entry for the fact_id first so the index
|
|
351
|
+
holds each fact exactly once after the call. Delegates to remove_fact +
|
|
352
|
+
add so the two operations stay in sync.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
fact_id: Fact whose content has changed.
|
|
356
|
+
new_content: Updated text to index.
|
|
357
|
+
profile_id: Owner profile (used for DB token persistence).
|
|
358
|
+
"""
|
|
359
|
+
self.remove_fact(fact_id)
|
|
360
|
+
self.add(fact_id, new_content, profile_id)
|
|
361
|
+
|
|
362
|
+
def remove_fact(self, fact_id: str) -> None:
|
|
363
|
+
"""Remove a fact from the live in-memory index.
|
|
364
|
+
|
|
365
|
+
Idempotent: a no-op when the fact is not in the index. Does NOT touch
|
|
366
|
+
the persistent ``bm25_tokens`` DB table — the caller is responsible for
|
|
367
|
+
that (so a delete path can clean up storage independently).
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
fact_id: Fact to evict from the in-memory corpus.
|
|
371
|
+
"""
|
|
372
|
+
if fact_id not in self._fact_id_set:
|
|
373
|
+
return
|
|
374
|
+
try:
|
|
375
|
+
idx = self._fact_ids.index(fact_id)
|
|
376
|
+
del self._corpus[idx]
|
|
377
|
+
del self._fact_ids[idx]
|
|
378
|
+
raw_texts = getattr(self, "_raw_texts", [])
|
|
379
|
+
if idx < len(raw_texts):
|
|
380
|
+
del raw_texts[idx]
|
|
381
|
+
self._fact_id_set.discard(fact_id)
|
|
382
|
+
self._dirty = True
|
|
383
|
+
except (ValueError, IndexError):
|
|
384
|
+
self._fact_id_set.discard(fact_id)
|
|
385
|
+
|
|
339
386
|
def clear(self) -> None:
|
|
340
387
|
"""Clear the in-memory index (does NOT delete DB tokens)."""
|
|
341
388
|
self._corpus = []
|
|
@@ -27,12 +27,12 @@ from typing import TYPE_CHECKING, Any, Protocol
|
|
|
27
27
|
|
|
28
28
|
from superlocalmemory.core.config import ChannelWeights, RetrievalConfig
|
|
29
29
|
from superlocalmemory.retrieval.fusion import FusionResult, weighted_rrf
|
|
30
|
+
from superlocalmemory.retrieval.strategy import QueryStrategy, QueryStrategyClassifier
|
|
30
31
|
from superlocalmemory.retrieval.time_window import (
|
|
31
32
|
in_window,
|
|
32
33
|
infer_window_from_query,
|
|
33
34
|
parse_window,
|
|
34
35
|
)
|
|
35
|
-
from superlocalmemory.retrieval.strategy import QueryStrategy, QueryStrategyClassifier
|
|
36
36
|
from superlocalmemory.storage.models import (
|
|
37
37
|
AtomicFact,
|
|
38
38
|
Mode,
|
|
@@ -155,6 +155,7 @@ class RetrievalEngine:
|
|
|
155
155
|
include_global: bool = False,
|
|
156
156
|
include_shared: bool = False,
|
|
157
157
|
window: str | tuple[str, str] | None = None,
|
|
158
|
+
as_of: str | None = None,
|
|
158
159
|
) -> RecallResponse:
|
|
159
160
|
"""Full retrieval pipeline: strategy -> channels -> RRF -> rerank.
|
|
160
161
|
|
|
@@ -165,6 +166,11 @@ class RetrievalEngine:
|
|
|
165
166
|
V3.4.40 (2026-05-09): ``extra_disabled_channels`` allows callers to
|
|
166
167
|
skip specific channels for a single recall (e.g. SpreadingActivation
|
|
167
168
|
for the ``--fast`` CLI flag) without mutating shared config.
|
|
169
|
+
|
|
170
|
+
``as_of``: Optional ISO 8601 datetime string. When set, the bi-temporal
|
|
171
|
+
validity filter treats facts as seen from that point in time —
|
|
172
|
+
not-yet-valid and already-expired facts are demoted. Default ``None``
|
|
173
|
+
leaves all existing behaviour unchanged.
|
|
168
174
|
"""
|
|
169
175
|
t0 = time.monotonic()
|
|
170
176
|
# NOTE: extra_disabled_channels is passed as an explicit local argument
|
|
@@ -212,6 +218,7 @@ class RetrievalEngine:
|
|
|
212
218
|
query, profile_id, strat,
|
|
213
219
|
extra_disabled_channels=extra_disabled_channels,
|
|
214
220
|
include_global=include_global, include_shared=include_shared,
|
|
221
|
+
as_of=as_of,
|
|
215
222
|
)
|
|
216
223
|
_em("run_channels")
|
|
217
224
|
if profile_hits:
|
|
@@ -765,6 +772,7 @@ class RetrievalEngine:
|
|
|
765
772
|
extra_disabled_channels: set[str] | None = None,
|
|
766
773
|
include_global: bool = False,
|
|
767
774
|
include_shared: bool = False,
|
|
775
|
+
as_of: str | None = None,
|
|
768
776
|
) -> dict[str, list[tuple[str, float]]]:
|
|
769
777
|
"""Run active retrieval channels.
|
|
770
778
|
|
|
@@ -885,7 +893,10 @@ class RetrievalEngine:
|
|
|
885
893
|
)
|
|
886
894
|
for name, fut in futures.items():
|
|
887
895
|
if fut in pending:
|
|
888
|
-
logger.warning(
|
|
896
|
+
logger.warning(
|
|
897
|
+
"Channel %s exceeded %.1fs latency budget",
|
|
898
|
+
name, channel_timeout_seconds,
|
|
899
|
+
)
|
|
889
900
|
continue
|
|
890
901
|
try:
|
|
891
902
|
ch_name, result = fut.result()
|
|
@@ -894,11 +905,15 @@ class RetrievalEngine:
|
|
|
894
905
|
except Exception as exc:
|
|
895
906
|
logger.warning("Channel %s failed: %s", name, exc)
|
|
896
907
|
|
|
897
|
-
# Apply registered post-retrieval filters (forgetting filter, etc.)
|
|
908
|
+
# Apply registered post-retrieval filters (forgetting filter, etc.).
|
|
909
|
+
# Pass as_of in context dict when set so the bi-temporal validity filter
|
|
910
|
+
# can perform point-in-time demotion. None context preserves the existing
|
|
911
|
+
# behaviour for all callers that don't use time-travel recall.
|
|
912
|
+
_filter_context = {"as_of": as_of} if as_of is not None else None
|
|
898
913
|
if hasattr(self, '_registry') and self._registry._filters:
|
|
899
914
|
for fn in self._registry._filters:
|
|
900
915
|
try:
|
|
901
|
-
out = fn(out, profile_id,
|
|
916
|
+
out = fn(out, profile_id, _filter_context)
|
|
902
917
|
except Exception as exc:
|
|
903
918
|
logger.warning("Post-retrieval filter failed: %s", exc)
|
|
904
919
|
|
|
@@ -74,5 +74,8 @@ def weighted_rrf(
|
|
|
74
74
|
fused += w / (k + rank)
|
|
75
75
|
results.append(FusionResult(fid, fused, ch_ranks, ch_scores))
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
# Deterministic total order: fused score descending, ties broken by fact_id.
|
|
78
|
+
# Sorting on score alone left tied results in set-iteration order, which
|
|
79
|
+
# varies with the process hash seed.
|
|
80
|
+
results.sort(key=lambda r: (-r.fused_score, r.fact_id))
|
|
78
81
|
return results
|