superlocalmemory 4.0.7 → 4.0.9
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/CHANGELOG.md +219 -1
- package/README.md +6 -6
- package/package.json +1 -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/scripts/ensure-venv.sh +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 +3 -3
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +2 -2
- package/pyproject.toml +1 -1
- package/scripts/postinstall.js +4 -0
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/_lazy_init.py +1 -1
- package/src/superlocalmemory/cli/commands.py +119 -9
- package/src/superlocalmemory/cli/db_migrate.py +0 -2
- package/src/superlocalmemory/cli/gdpr_io.py +1 -1
- package/src/superlocalmemory/cli/main.py +5 -5
- package/src/superlocalmemory/cli/service_installer.py +2 -1
- package/src/superlocalmemory/cli/setup_wizard.py +1 -1
- package/src/superlocalmemory/cli/summary_cmd.py +23 -3
- package/src/superlocalmemory/code_graph/bridge/maintenance.py +7 -1
- package/src/superlocalmemory/core/config.py +41 -7
- package/src/superlocalmemory/core/consolidation_engine.py +14 -15
- package/src/superlocalmemory/core/context_cache.py +0 -2
- package/src/superlocalmemory/core/engine.py +371 -63
- package/src/superlocalmemory/core/evidence_bundle.py +3 -1
- package/src/superlocalmemory/core/install_detector.py +131 -0
- package/src/superlocalmemory/core/progressive_abstraction.py +1 -1
- package/src/superlocalmemory/core/recall_worker.py +4 -0
- package/src/superlocalmemory/core/security_primitives.py +3 -6
- package/src/superlocalmemory/core/store_pipeline.py +94 -26
- package/src/superlocalmemory/core/topic_signature.py +0 -2
- package/src/superlocalmemory/core/transactions/concrete_owners.py +15 -8
- package/src/superlocalmemory/dynamics/eap_scheduler.py +17 -6
- package/src/superlocalmemory/encoding/graph_builder.py +2 -2
- package/src/superlocalmemory/encoding/scene_builder.py +8 -2
- package/src/superlocalmemory/evolution/skill_evolver.py +16 -1
- package/src/superlocalmemory/hooks/adapter_base.py +0 -2
- package/src/superlocalmemory/hooks/context_payload.py +0 -2
- package/src/superlocalmemory/hooks/hook_handlers.py +38 -11
- package/src/superlocalmemory/hooks/portable_kit.py +8 -8
- package/src/superlocalmemory/hooks/post_tool_async_hook.py +0 -2
- package/src/superlocalmemory/hooks/prewarm_auth.py +0 -2
- package/src/superlocalmemory/hooks/user_prompt_hook.py +0 -2
- package/src/superlocalmemory/infra/backup.py +44 -8
- package/src/superlocalmemory/integrations/bounded_loops_mcp.py +24 -7
- package/src/superlocalmemory/learning/arm_catalog.py +0 -2
- package/src/superlocalmemory/learning/bandit.py +0 -2
- package/src/superlocalmemory/learning/bandit_cache.py +0 -2
- package/src/superlocalmemory/learning/dedup_hnsw.py +11 -11
- package/src/superlocalmemory/learning/ensemble.py +0 -2
- package/src/superlocalmemory/learning/labeler.py +0 -2
- package/src/superlocalmemory/learning/legacy_migration.py +0 -2
- package/src/superlocalmemory/learning/model_cache.py +0 -2
- package/src/superlocalmemory/learning/pattern_miner.py +12 -7
- package/src/superlocalmemory/learning/ranker.py +0 -2
- package/src/superlocalmemory/learning/reward_archive.py +6 -1
- package/src/superlocalmemory/learning/reward_proxy.py +0 -2
- package/src/superlocalmemory/learning/signal_worker.py +0 -2
- package/src/superlocalmemory/math/fisher.py +1 -1
- package/src/superlocalmemory/math/hopfield.py +4 -1
- package/src/superlocalmemory/math/langevin.py +1 -1
- package/src/superlocalmemory/math/sheaf.py +7 -3
- package/src/superlocalmemory/mcp/cli_fallback.py +1 -1
- package/src/superlocalmemory/mcp/profiles.py +11 -4
- package/src/superlocalmemory/mcp/server.py +8 -1
- package/src/superlocalmemory/mcp/tools_active.py +56 -0
- package/src/superlocalmemory/mcp/tools_core.py +1 -1
- package/src/superlocalmemory/mcp/tools_summaries.py +147 -0
- package/src/superlocalmemory/optimize/cache/manager.py +2 -2
- package/src/superlocalmemory/optimize/compress/ccr.py +1 -1
- package/src/superlocalmemory/optimize/compress/router.py +1 -1
- package/src/superlocalmemory/optimize/proxy/_helpers.py +2 -2
- package/src/superlocalmemory/optimize/proxy/server.py +1 -1
- package/src/superlocalmemory/optimize/proxy/vertex_surface.py +2 -2
- package/src/superlocalmemory/optimize/storage/db.py +2 -2
- package/src/superlocalmemory/retrieval/agentic.py +1 -1
- package/src/superlocalmemory/retrieval/ann_index.py +9 -2
- package/src/superlocalmemory/retrieval/bm25_channel.py +2 -2
- package/src/superlocalmemory/retrieval/bridge_discovery.py +2 -2
- package/src/superlocalmemory/retrieval/engine.py +272 -43
- package/src/superlocalmemory/retrieval/entity_channel.py +1 -1
- package/src/superlocalmemory/retrieval/hopfield_channel.py +8 -2
- package/src/superlocalmemory/retrieval/profile_channel.py +1 -1
- package/src/superlocalmemory/retrieval/quantization_aware_search.py +1 -1
- package/src/superlocalmemory/retrieval/remote_reranker.py +2 -2
- package/src/superlocalmemory/retrieval/reranker.py +3 -3
- package/src/superlocalmemory/retrieval/semantic_channel.py +3 -3
- package/src/superlocalmemory/retrieval/spreading_activation.py +8 -8
- package/src/superlocalmemory/retrieval/strategy.py +94 -0
- package/src/superlocalmemory/retrieval/temporal_channel.py +167 -10
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +1 -1
- package/src/superlocalmemory/retrieval/vector_store.py +88 -10
- package/src/superlocalmemory/server/consolidation_runner.py +140 -0
- package/src/superlocalmemory/server/recall_serializer.py +44 -2
- package/src/superlocalmemory/server/routes/agents.py +52 -8
- package/src/superlocalmemory/server/routes/brain.py +110 -2
- package/src/superlocalmemory/server/routes/memories.py +153 -0
- package/src/superlocalmemory/server/routes/prewarm.py +4 -4
- package/src/superlocalmemory/server/routes/v3_api.py +24 -46
- package/src/superlocalmemory/server/unified_daemon.py +566 -7
- package/src/superlocalmemory/storage/_schema_version.py +46 -3
- package/src/superlocalmemory/storage/backup.py +531 -0
- package/src/superlocalmemory/storage/database.py +11 -4
- package/src/superlocalmemory/storage/embedding_codec.py +129 -0
- package/src/superlocalmemory/storage/embedding_migrator.py +5 -3
- package/src/superlocalmemory/storage/migration_runner.py +142 -2
- package/src/superlocalmemory/storage/migrations/__init__.py +1 -1
- package/src/superlocalmemory/storage/migrations.py +15 -1
- package/src/superlocalmemory/storage/models.py +7 -0
- package/src/superlocalmemory/storage/quantized_store.py +4 -2
- package/src/superlocalmemory/summaries/base.py +159 -0
- package/src/superlocalmemory/summaries/daily_reflection.py +55 -8
- package/src/superlocalmemory/summaries/project_work_log.py +23 -7
- package/src/superlocalmemory/summaries/session_summary.py +10 -6
- package/src/superlocalmemory/ui/css/legacy-dashboard.css +1 -1
- package/src/superlocalmemory/ui/css/neural-glass.css +1 -1
- package/src/superlocalmemory/ui/index.html +9 -3
- package/src/superlocalmemory/ui/js/core.js +1 -1
- package/src/superlocalmemory/ui/js/od-boundedloops.js +324 -0
- package/src/superlocalmemory/ui/js/od-brain.js +1 -1
- package/src/superlocalmemory/ui/js/od-memories.js +337 -12
- package/src/superlocalmemory/ui/js/od-mesh.js +97 -5
- package/src/superlocalmemory/ui/js/od-operations.js +1 -150
- package/src/superlocalmemory/ui/js/od-optimize.js +36 -9
- package/src/superlocalmemory/ui/js/od-shell.js +10 -0
|
@@ -411,7 +411,7 @@ class CompressRouter:
|
|
|
411
411
|
logger.debug("CCR update_compressed failed (non-fatal): %s", exc)
|
|
412
412
|
|
|
413
413
|
def _ccr_delete(self, ccr_id: str) -> None:
|
|
414
|
-
"""
|
|
414
|
+
"""Defensive delete — idempotent, never raises.
|
|
415
415
|
|
|
416
416
|
Used to clean up a CCR row if post-store processing fails. In the
|
|
417
417
|
store-after-success D6 path this should never be needed (no orphans
|
|
@@ -123,10 +123,10 @@ _OPENAI_FORWARD_HEADERS = frozenset([
|
|
|
123
123
|
_GEMINI_NATIVE_FORWARD_HEADERS = frozenset([
|
|
124
124
|
"x-goog-api-key",
|
|
125
125
|
"content-type",
|
|
126
|
-
"authorization", #
|
|
126
|
+
"authorization", # Antigravity ADC/OAuth bearer was previously dropped; explicitly add it back
|
|
127
127
|
])
|
|
128
128
|
|
|
129
|
-
#
|
|
129
|
+
# Vertex AI forward headers — Authorization is passed through untouched.
|
|
130
130
|
# x-goog-user-project required for quota attribution on Vertex calls.
|
|
131
131
|
_VERTEX_FORWARD_HEADERS = frozenset([
|
|
132
132
|
"authorization",
|
|
@@ -150,7 +150,7 @@ def build_proxy_router(proxy: ProxyApp) -> APIRouter:
|
|
|
150
150
|
async def gemini_openai_models_route(request: Request) -> Response:
|
|
151
151
|
return await handle_gemini_openai_compat(proxy, request)
|
|
152
152
|
|
|
153
|
-
#
|
|
153
|
+
# Vertex AI passthrough — must be registered AFTER exact /v1/* routes
|
|
154
154
|
# to avoid shadowing /v1/messages, /v1/chat/completions, /v1/embeddings, etc.
|
|
155
155
|
# FastAPI resolves routes in registration order; the exact routes above are
|
|
156
156
|
# declared before this catch-path, so there is no shadowing.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""vertex_surface.py — Vertex AI passthrough proxy surface
|
|
1
|
+
"""vertex_surface.py — Vertex AI passthrough proxy surface.
|
|
2
2
|
|
|
3
3
|
Transparent passthrough: forward Authorization bearer untouched (AC-2),
|
|
4
4
|
cache by body content never by token (SEC), no SSE — always single JSON.
|
|
@@ -14,7 +14,7 @@ Key design decisions (per LLD §5 STAGE-5 RESOLUTIONS):
|
|
|
14
14
|
SECURITY (AC-3): bearer token structurally excluded from cache key, value,
|
|
15
15
|
logs, and stored ProxyRequest.headers (redacted via _redact_headers).
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
The gemini-native Authorization-header fix lives in _helpers.py:_GEMINI_NATIVE_FORWARD_HEADERS.
|
|
18
18
|
"""
|
|
19
19
|
|
|
20
20
|
from __future__ import annotations
|
|
@@ -959,7 +959,7 @@ class CacheDB:
|
|
|
959
959
|
def ccr_delete(self, ccr_id: str, *, tenant_id: str = "default") -> None:
|
|
960
960
|
"""Delete a CCR row by ccr_id scoped to tenant. Idempotent — warns on sqlite error, never raises.
|
|
961
961
|
|
|
962
|
-
|
|
962
|
+
Defensive delete — idempotent: deleting a non-existent row is a no-op.
|
|
963
963
|
H-02: tenant_id guard prevents a tenant from deleting another tenant's CCR.
|
|
964
964
|
"""
|
|
965
965
|
try:
|
|
@@ -973,7 +973,7 @@ class CacheDB:
|
|
|
973
973
|
def ccr_count(self) -> int:
|
|
974
974
|
"""Return UNFILTERED count of rows in llmcache_ccr_originals.
|
|
975
975
|
|
|
976
|
-
|
|
976
|
+
Do NOT reuse TTL-filtered count from the query above. A fresh no-expiry row
|
|
977
977
|
has ttl_expires=None, so the TTL filter returns 0 and D6 orphan tests would
|
|
978
978
|
falsely pass. This unfiltered count is test infrastructure only.
|
|
979
979
|
"""
|
|
@@ -163,7 +163,7 @@ class AgenticRetriever:
|
|
|
163
163
|
RetrievalRound(2, rq, len(rn), _avg(rn), True),
|
|
164
164
|
)
|
|
165
165
|
|
|
166
|
-
merged = sorted(pool.values(), key=lambda x: x[1],
|
|
166
|
+
merged = sorted(pool.values(), key=lambda x: (-x[1], x[0].fact_id))
|
|
167
167
|
return [f for f, _ in merged[:top_k]]
|
|
168
168
|
|
|
169
169
|
# -- Sufficiency check ---------------------------------------------------
|
|
@@ -178,8 +178,15 @@ class ANNIndex:
|
|
|
178
178
|
if k <= 0:
|
|
179
179
|
return []
|
|
180
180
|
|
|
181
|
-
|
|
182
|
-
|
|
181
|
+
# Full sort with a secondary key on fact_id so that equal cosine
|
|
182
|
+
# scores are broken deterministically. argpartition cannot
|
|
183
|
+
# guarantee which facts reach the top-k when scores tie at the
|
|
184
|
+
# boundary — the selection follows array layout (insertion order)
|
|
185
|
+
# and changes between restarts. lexsort picks the same k facts
|
|
186
|
+
# in the same order regardless of how the index was loaded.
|
|
187
|
+
id_arr = np.array(self._ids, dtype=object)
|
|
188
|
+
order = np.lexsort((id_arr, -scores)) # primary: -score, secondary: fact_id
|
|
189
|
+
top_indices = order[:k]
|
|
183
190
|
|
|
184
191
|
return [
|
|
185
192
|
(self._ids[i], float(scores[i]))
|
|
@@ -267,7 +267,7 @@ class BM25Channel:
|
|
|
267
267
|
except Exception as exc: # pragma: no cover — legacy/missing expansion FTS
|
|
268
268
|
logger.debug("Expansion FTS search skipped: %s", exc)
|
|
269
269
|
|
|
270
|
-
out.sort(key=lambda x: x[1],
|
|
270
|
+
out.sort(key=lambda x: (-x[1], x[0]))
|
|
271
271
|
return out[:top_k]
|
|
272
272
|
|
|
273
273
|
def search(
|
|
@@ -341,7 +341,7 @@ class BM25Channel:
|
|
|
341
341
|
bonus *= 1.5 # 50% boost for exact phrase match
|
|
342
342
|
scored.append((self._fact_ids[i], bonus))
|
|
343
343
|
|
|
344
|
-
scored.sort(key=lambda x: x[1],
|
|
344
|
+
scored.sort(key=lambda x: (-x[1], x[0]))
|
|
345
345
|
return scored[:top_k]
|
|
346
346
|
|
|
347
347
|
def update_fact(self, fact_id: str, new_content: str, profile_id: str) -> None:
|
|
@@ -160,7 +160,7 @@ class BridgeDiscovery:
|
|
|
160
160
|
if len(bridges) >= max_bridges:
|
|
161
161
|
break
|
|
162
162
|
|
|
163
|
-
bridges.sort(key=lambda x: x[1],
|
|
163
|
+
bridges.sort(key=lambda x: (-x[1], x[0]))
|
|
164
164
|
return filter_authorized_results(
|
|
165
165
|
self._db,
|
|
166
166
|
bridges,
|
|
@@ -243,7 +243,7 @@ class BridgeDiscovery:
|
|
|
243
243
|
for fid, score in activations.items()
|
|
244
244
|
if fid not in set(seed_ids) and score > 0.01
|
|
245
245
|
]
|
|
246
|
-
results.sort(key=lambda x: x[1],
|
|
246
|
+
results.sort(key=lambda x: (-x[1], x[0]))
|
|
247
247
|
return filter_authorized_results(
|
|
248
248
|
self._db,
|
|
249
249
|
results,
|
|
@@ -20,6 +20,7 @@ import concurrent.futures
|
|
|
20
20
|
import functools
|
|
21
21
|
import logging
|
|
22
22
|
import math
|
|
23
|
+
import os
|
|
23
24
|
import re
|
|
24
25
|
import threading
|
|
25
26
|
import time
|
|
@@ -57,6 +58,46 @@ if TYPE_CHECKING:
|
|
|
57
58
|
logger = logging.getLogger(__name__)
|
|
58
59
|
|
|
59
60
|
|
|
61
|
+
# How long the parallel channel phase may run before a channel is abandoned.
|
|
62
|
+
#
|
|
63
|
+
# This is a guard against a genuinely wedged channel, NOT a speed cutoff, and
|
|
64
|
+
# the distinction is the whole point. A channel that misses this limit is
|
|
65
|
+
# cancelled and contributes NOTHING to fusion: its candidates are not reordered,
|
|
66
|
+
# they are absent. So whenever this limit binds, the answer is decided partly by
|
|
67
|
+
# what else the machine happened to be doing — the same question returns a
|
|
68
|
+
# different answer under load, which is a correctness failure, not a slow one.
|
|
69
|
+
#
|
|
70
|
+
# It replaced a 1.4 s cutoff that was chosen to keep the recall p95 low. Six
|
|
71
|
+
# runs of identical code against the same 0.95 GB store logged 0, 0, 25, 2, 0
|
|
72
|
+
# and 0 abandoned channels; in the third run `hopfield` was cut off on 13 of
|
|
73
|
+
# 140 queries and `temporal` on 9, while the first run lost nothing on those
|
|
74
|
+
# same queries. That spread was the last remaining source of unrepeatable
|
|
75
|
+
# recall, and it is why fixing tie-breaks everywhere else moved top-10 churn
|
|
76
|
+
# from 40.7% to 22.9% and left rank-1 disagreement sitting at ~15%: a
|
|
77
|
+
# tie-break cannot repair a missing input.
|
|
78
|
+
#
|
|
79
|
+
# The value comes from the measured cost of the channels themselves, on that
|
|
80
|
+
# same store, 140 queries, with the limit raised out of the way so nothing was
|
|
81
|
+
# truncated (p95 / max, ms):
|
|
82
|
+
#
|
|
83
|
+
# temporal 580 / 1983 hopfield 492 / 945 bm25 230 / 1093
|
|
84
|
+
# semantic 264 / 662 spreading_activation 238 / 571
|
|
85
|
+
#
|
|
86
|
+
# The slowest channel's p95 is 580 ms and its worst single run was 1,983 ms, so
|
|
87
|
+
# 8 s is roughly four times the worst observed cost — it should never bind on a
|
|
88
|
+
# machine that is merely busy. It also stays well inside the daemon's own
|
|
89
|
+
# last-resort recall budget (25 s, `_recall_budget_s`), which is the layer that
|
|
90
|
+
# exists to catch a true hang and which already tells the caller when it fires
|
|
91
|
+
# (`retrieval_mode=degraded_lexical`). Before this change the inner 1.4 s cutoff
|
|
92
|
+
# silently overrode that outer promise of "quality recall under load".
|
|
93
|
+
#
|
|
94
|
+
# Lowering this to improve a latency percentile means buying that percentile
|
|
95
|
+
# with missing answers. Per HARD-RULES RULE 6 the ordering is Correct, then
|
|
96
|
+
# Complete, then Repeatable, and only then Fast — so if this needs to move,
|
|
97
|
+
# measure what it costs in answer quality first and record the number.
|
|
98
|
+
CHANNEL_HANG_GUARD_SECONDS = 8.0
|
|
99
|
+
|
|
100
|
+
|
|
60
101
|
class CrossEncoderProtocol(Protocol):
|
|
61
102
|
"""Duck-typed cross-encoder interface."""
|
|
62
103
|
def rerank(self, query: str, candidates: list[tuple[str, str]]) -> list[tuple[str, float]]: ...
|
|
@@ -103,7 +144,7 @@ class RetrievalEngine:
|
|
|
103
144
|
self._spreading_activation = channels.get("spreading_activation")
|
|
104
145
|
self._embedder = embedder
|
|
105
146
|
self._reranker = reranker
|
|
106
|
-
self._strategy = strategy or QueryStrategyClassifier()
|
|
147
|
+
self._strategy = strategy or QueryStrategyClassifier(config=config)
|
|
107
148
|
self._base_weights = (base_weights or ChannelWeights()).as_dict()
|
|
108
149
|
self._profile_channel = profile_channel
|
|
109
150
|
self._bridge = bridge_discovery
|
|
@@ -225,12 +266,16 @@ class RetrievalEngine:
|
|
|
225
266
|
# 3. Run channels. Both scope flags AND extra_disabled_channels travel as
|
|
226
267
|
# explicit call parameters so concurrent recalls with different flags
|
|
227
268
|
# cannot corrupt each other. No lock needed — no shared mutable state.
|
|
269
|
+
# Owned by this call, so concurrent recalls cannot report each other's
|
|
270
|
+
# losses. Non-empty means this answer is incomplete, not just slow.
|
|
271
|
+
dropped_channels: set[str] = set()
|
|
228
272
|
ch_results = self._run_channels(
|
|
229
273
|
query, profile_id, strat,
|
|
230
274
|
extra_disabled_channels=extra_disabled_channels,
|
|
231
275
|
include_global=include_global, include_shared=include_shared,
|
|
232
276
|
as_of=as_of, known_as_of=known_as_of, valid_at=valid_at,
|
|
233
277
|
include_unknown=include_unknown,
|
|
278
|
+
dropped_channels=dropped_channels,
|
|
234
279
|
)
|
|
235
280
|
_em("run_channels")
|
|
236
281
|
# One request may need admission before fusion and again after optional
|
|
@@ -251,6 +296,7 @@ class RetrievalEngine:
|
|
|
251
296
|
total = sum(len(v) for v in ch_results.values())
|
|
252
297
|
|
|
253
298
|
# 3. Single-pass RRF fusion
|
|
299
|
+
ch_results = self._semantic_rank_for_unenriched(ch_results)
|
|
254
300
|
fused = weighted_rrf(ch_results, strat.weights, k=self._config.rrf_k)
|
|
255
301
|
_em("rrf_fusion")
|
|
256
302
|
|
|
@@ -296,10 +342,29 @@ class RetrievalEngine:
|
|
|
296
342
|
except Exception as exc:
|
|
297
343
|
logger.warning("Bridge discovery: %s", exc)
|
|
298
344
|
|
|
299
|
-
# Scene expansion (v3.5.0: batch
|
|
300
|
-
#
|
|
301
|
-
#
|
|
302
|
-
|
|
345
|
+
# Scene expansion (v3.5.0: batch).
|
|
346
|
+
#
|
|
347
|
+
# This used to be skipped when more than 0.8 s of the recall had already
|
|
348
|
+
# elapsed, on the reasoning that the scene signal is nice-to-have and
|
|
349
|
+
# never worth delaying a response. The reasoning was wrong, because the
|
|
350
|
+
# stage does not merely decorate the answer — it appends candidates that
|
|
351
|
+
# can outrank what fusion produced. Gating it on a stopwatch therefore
|
|
352
|
+
# made the ANSWER depend on how busy the machine was, and 0.8 s sits on
|
|
353
|
+
# top of recall's own median (~1,044 ms on the 0.95 GB archive), so it
|
|
354
|
+
# was not a rare safety valve: measured over two runs of 60 queries, the
|
|
355
|
+
# two clock gates flipped their decision on 22 of 60, and of the 19
|
|
356
|
+
# queries whose answer changed, every one had a flipped gate.
|
|
357
|
+
#
|
|
358
|
+
# Removing both gates moved rank-1 disagreement between two runs from
|
|
359
|
+
# 20.0% to 3.3% and top-10 from 31.7% to 10.0%, for about 100-180 ms of
|
|
360
|
+
# p95 (1,191-1,230 ms -> 1,256-1,375 ms, ceiling 2,000 ms). Per
|
|
361
|
+
# HARD-RULES RULE 6 that is the correct direction: Correct, Complete,
|
|
362
|
+
# Repeatable, and only then Fast.
|
|
363
|
+
#
|
|
364
|
+
# So do not reintroduce a time condition here. If this stage ever needs
|
|
365
|
+
# bounding, bound it by DATA — a candidate count, a scene cap — so the
|
|
366
|
+
# same input always takes the same path.
|
|
367
|
+
if fused:
|
|
303
368
|
try:
|
|
304
369
|
top_ids = [fr.fact_id for fr in fused[:20]]
|
|
305
370
|
scenes_map = self._db.get_scenes_for_facts_batch(top_ids, profile_id)
|
|
@@ -323,10 +388,14 @@ class RetrievalEngine:
|
|
|
323
388
|
# Instead of competing as independent channel, entity_graph SCORES
|
|
324
389
|
# the candidates from other channels by graph proximity to query entities.
|
|
325
390
|
# Research: Microsoft GraphRAG DRIFT, Pistis-RAG cascaded architecture.
|
|
391
|
+
# The 0.9 s clock gate that used to guard this stage is gone for the
|
|
392
|
+
# reason given above the scene expansion, and it mattered more here:
|
|
393
|
+
# this stage re-scores every fused candidate and then re-sorts them, so
|
|
394
|
+
# whether it ran decided the top answer outright rather than adding to
|
|
395
|
+
# it. Bound by data if it ever needs bounding, never by elapsed time.
|
|
326
396
|
if (self._entity is not None
|
|
327
397
|
and "entity_graph" not in set(self._config.disabled_channels)
|
|
328
|
-
and fused
|
|
329
|
-
and (_time_e.monotonic() - _e0) < 0.9):
|
|
398
|
+
and fused):
|
|
330
399
|
try:
|
|
331
400
|
candidate_ids = [fr.fact_id for fr in fused[:100]]
|
|
332
401
|
eg_scores = self._entity.score_candidates(
|
|
@@ -351,7 +420,7 @@ class RetrievalEngine:
|
|
|
351
420
|
))
|
|
352
421
|
else:
|
|
353
422
|
boosted.append(fr)
|
|
354
|
-
fused = sorted(boosted, key=lambda r: r.fused_score,
|
|
423
|
+
fused = sorted(boosted, key=lambda r: (-r.fused_score, r.fact_id))
|
|
355
424
|
except Exception as exc:
|
|
356
425
|
logger.warning("Entity graph signal enhancement: %s", exc)
|
|
357
426
|
|
|
@@ -408,29 +477,6 @@ class RetrievalEngine:
|
|
|
408
477
|
if strat.query_type == "aggregation" and facts:
|
|
409
478
|
top = self._enforce_session_diversity(top, facts, min_sessions=3, top_k=20)
|
|
410
479
|
|
|
411
|
-
# 5. Cross-encoder rerank (optional)
|
|
412
|
-
# Bug 4 fix: reduced alpha for multi-hop/temporal to preserve diversity
|
|
413
|
-
# V3.3.21: Skip reranker if worker isn't ready yet (cold start).
|
|
414
|
-
# Returns results without CE reranking (~5-10pp lower quality) but instant
|
|
415
|
-
# instead of blocking 15-19s on first recall. Worker warms up in background.
|
|
416
|
-
reranker_ready = (
|
|
417
|
-
self._reranker is not None
|
|
418
|
-
and getattr(self._reranker, '_worker_ready', False)
|
|
419
|
-
)
|
|
420
|
-
reranker_applied = False
|
|
421
|
-
reranker_status = (
|
|
422
|
-
"fallback_not_ready" if self._reranker is not None
|
|
423
|
-
else "not_configured"
|
|
424
|
-
)
|
|
425
|
-
if reranker_ready and facts:
|
|
426
|
-
ce_alpha = 0.5 if strat.query_type in ("multi_hop", "temporal") else 0.75
|
|
427
|
-
top, reranker_applied, reranker_status = self._apply_reranker(
|
|
428
|
-
query, top, facts, alpha=ce_alpha,
|
|
429
|
-
)
|
|
430
|
-
elif reranker_ready:
|
|
431
|
-
reranker_status = "no_candidates"
|
|
432
|
-
_em(f"rerank(ready={reranker_ready})")
|
|
433
|
-
|
|
434
480
|
# v3.6.6: Evidence floor — gate on per-channel scores (NOT fused/RRF score).
|
|
435
481
|
# Nonsense queries fuse at 0.75-0.78 because RRF is rank-derived and
|
|
436
482
|
# uncalibrated. The discriminator is EARNED CHANNEL EVIDENCE:
|
|
@@ -439,6 +485,12 @@ class RetrievalEngine:
|
|
|
439
485
|
# spreading_activation and hopfield do NOT count — they are associative
|
|
440
486
|
# amplifiers that fabricated the nonsense results in calibration tests.
|
|
441
487
|
# Kill-switch: SLM_RECALL_NO_FLOOR=1 bypasses the floor.
|
|
488
|
+
# Runs BEFORE the cross-encoder so the CE batch contains only
|
|
489
|
+
# evidence-qualified candidates. The floor gates on channel_scores
|
|
490
|
+
# (semantic, bm25, entity_graph, temporal) which are assigned during
|
|
491
|
+
# channel execution and are not affected by CE reranking. Moving the
|
|
492
|
+
# floor here does not change which queries abstain; it reduces the CE
|
|
493
|
+
# batch from ~180 candidates to the qualified subset (~30–60).
|
|
442
494
|
import os as _os_floor
|
|
443
495
|
floor_enabled = (
|
|
444
496
|
getattr(self._config, "evidence_floor_enabled", True)
|
|
@@ -453,6 +505,29 @@ class RetrievalEngine:
|
|
|
453
505
|
# qualified candidate was immediately below the slice.
|
|
454
506
|
top = self._apply_evidence_floor(top, facts, min_sem)
|
|
455
507
|
|
|
508
|
+
# 5. Cross-encoder rerank (optional, on the evidence-qualified pool)
|
|
509
|
+
# Bug 4 fix: reduced alpha for multi-hop/temporal to preserve diversity
|
|
510
|
+
# V3.3.21: Skip reranker if worker isn't ready yet (cold start).
|
|
511
|
+
# Returns results without CE reranking (~5-10pp lower quality) but instant
|
|
512
|
+
# instead of blocking 15-19s on first recall. Worker warms up in background.
|
|
513
|
+
reranker_ready = (
|
|
514
|
+
self._reranker is not None
|
|
515
|
+
and getattr(self._reranker, '_worker_ready', False)
|
|
516
|
+
)
|
|
517
|
+
reranker_applied = False
|
|
518
|
+
reranker_status = (
|
|
519
|
+
"fallback_not_ready" if self._reranker is not None
|
|
520
|
+
else "not_configured"
|
|
521
|
+
)
|
|
522
|
+
if reranker_ready and facts:
|
|
523
|
+
ce_alpha = 0.5 if strat.query_type in ("multi_hop", "temporal") else 0.75
|
|
524
|
+
top, reranker_applied, reranker_status = self._apply_reranker(
|
|
525
|
+
query, top, facts, alpha=ce_alpha,
|
|
526
|
+
)
|
|
527
|
+
elif reranker_ready:
|
|
528
|
+
reranker_status = "no_candidates"
|
|
529
|
+
_em(f"rerank(ready={reranker_ready})")
|
|
530
|
+
|
|
456
531
|
# V3.4.11: Channel diversity — guarantee entity_graph results appear in
|
|
457
532
|
# the final output. Applied AFTER reranking and evidence qualification
|
|
458
533
|
# so an associative-only candidate cannot be reintroduced after the gate.
|
|
@@ -489,6 +564,7 @@ class RetrievalEngine:
|
|
|
489
564
|
# Q2b: thematic context when the top results cluster in one
|
|
490
565
|
# community. Precomputed summary lookup only — no per-query LLM.
|
|
491
566
|
community_context=self._community_context(results, profile_id),
|
|
567
|
+
incomplete_channels=tuple(sorted(dropped_channels)),
|
|
492
568
|
)
|
|
493
569
|
|
|
494
570
|
# -- Community context (Wave Q2b) --------------------------------------
|
|
@@ -662,7 +738,7 @@ class RetrievalEngine:
|
|
|
662
738
|
channel_ranks=fr.channel_ranks,
|
|
663
739
|
channel_scores=fr.channel_scores,
|
|
664
740
|
))
|
|
665
|
-
boosted.sort(key=lambda r: r.fused_score,
|
|
741
|
+
boosted.sort(key=lambda r: (-r.fused_score, r.fact_id))
|
|
666
742
|
return boosted
|
|
667
743
|
|
|
668
744
|
# -- Session diversity enforcement ----------------------------------------
|
|
@@ -799,6 +875,94 @@ class RetrievalEngine:
|
|
|
799
875
|
self._query_embedding_cache[query] = emb
|
|
800
876
|
return emb
|
|
801
877
|
|
|
878
|
+
def _semantic_rank_for_unenriched(
|
|
879
|
+
self, ch_results: dict[str, list[tuple[str, float]]],
|
|
880
|
+
) -> dict[str, list[tuple[str, float]]]:
|
|
881
|
+
"""Give a candidate whose vector does not exist yet a fair semantic rank.
|
|
882
|
+
|
|
883
|
+
Fusion here is rank-based, so a fact the semantic channel did not return
|
|
884
|
+
forfeits that channel's entire contribution — the most heavily weighted
|
|
885
|
+
one. When the reason for that absence is simply that the vector has not
|
|
886
|
+
been computed yet, the absence describes the ingest pipeline and says
|
|
887
|
+
nothing about the fact. Left alone, a memory written seconds ago is the
|
|
888
|
+
hardest thing in the store to find, which is the worst possible failure
|
|
889
|
+
for this product.
|
|
890
|
+
|
|
891
|
+
Such candidates are placed at the MEDIAN of the semantic ranking, never
|
|
892
|
+
near the top: enough to compete on their other evidence, not enough to
|
|
893
|
+
win on freshness alone. A candidate that HAS a vector and still was not
|
|
894
|
+
returned is left exactly as it is — that absence is real evidence of
|
|
895
|
+
irrelevance, and the two must not be confused.
|
|
896
|
+
|
|
897
|
+
Returns a new mapping; the input is not modified.
|
|
898
|
+
"""
|
|
899
|
+
sem = ch_results.get("semantic") or []
|
|
900
|
+
if not sem:
|
|
901
|
+
return ch_results
|
|
902
|
+
if not getattr(self._config, "write_recency_floor_enabled", True):
|
|
903
|
+
return ch_results
|
|
904
|
+
if os.environ.get("SLM_WRITE_RECENCY_NO_FLOOR", "0") == "1":
|
|
905
|
+
return ch_results
|
|
906
|
+
|
|
907
|
+
have = {fid for fid, _ in sem}
|
|
908
|
+
elsewhere = {
|
|
909
|
+
fid
|
|
910
|
+
for name, rows in ch_results.items()
|
|
911
|
+
if name != "semantic"
|
|
912
|
+
for fid, _ in rows
|
|
913
|
+
}
|
|
914
|
+
candidates = sorted(elsewhere - have)
|
|
915
|
+
if not candidates:
|
|
916
|
+
return ch_results
|
|
917
|
+
|
|
918
|
+
from datetime import UTC, datetime, timedelta
|
|
919
|
+
|
|
920
|
+
minutes = float(getattr(self._config, "write_recency_floor_minutes", 60.0))
|
|
921
|
+
cutoff = (datetime.now(UTC) - timedelta(minutes=minutes)).isoformat()
|
|
922
|
+
placeholders = ",".join("?" for _ in candidates)
|
|
923
|
+
try:
|
|
924
|
+
# A missing embedding_metadata row means no vector projection exists,
|
|
925
|
+
# which is what makes the semantic channel's silence uninformative.
|
|
926
|
+
unenriched = [
|
|
927
|
+
dict(r)["fact_id"]
|
|
928
|
+
for r in self._db.execute(
|
|
929
|
+
f"SELECT af.fact_id FROM atomic_facts AS af "
|
|
930
|
+
f"LEFT JOIN embedding_metadata AS em ON em.fact_id = af.fact_id "
|
|
931
|
+
f"WHERE af.fact_id IN ({placeholders}) "
|
|
932
|
+
f" AND em.fact_id IS NULL "
|
|
933
|
+
f" AND af.created_at >= ?",
|
|
934
|
+
(*candidates, cutoff),
|
|
935
|
+
)
|
|
936
|
+
]
|
|
937
|
+
except (NameError, AttributeError, TypeError):
|
|
938
|
+
# These mean this code is wrong, not that the data is unusual. A bare
|
|
939
|
+
# `except Exception` here hid a missing import and left the whole
|
|
940
|
+
# feature silently inert while every test still passed.
|
|
941
|
+
raise
|
|
942
|
+
except Exception as exc:
|
|
943
|
+
# A store without this table, or a locked database: ranking must still
|
|
944
|
+
# return. Logged at warning, because "silently did nothing" is the
|
|
945
|
+
# failure mode this task exists to fix.
|
|
946
|
+
logger.warning("recent-unenriched admission skipped: %s: %s",
|
|
947
|
+
type(exc).__name__, exc)
|
|
948
|
+
return ch_results
|
|
949
|
+
if not unenriched:
|
|
950
|
+
return ch_results
|
|
951
|
+
|
|
952
|
+
scores = sorted(s for _, s in sem)
|
|
953
|
+
mid = len(scores) // 2
|
|
954
|
+
median = (
|
|
955
|
+
scores[mid] if len(scores) % 2 == 1
|
|
956
|
+
else (scores[mid - 1] + scores[mid]) / 2.0
|
|
957
|
+
)
|
|
958
|
+
insert_at = len(sem) // 2
|
|
959
|
+
merged = list(sem[:insert_at]) + [(fid, median) for fid in unenriched] + list(sem[insert_at:])
|
|
960
|
+
logger.debug(
|
|
961
|
+
"admitted %d recent un-enriched candidate(s) at semantic rank %d of %d",
|
|
962
|
+
len(unenriched), insert_at + 1, len(merged),
|
|
963
|
+
)
|
|
964
|
+
return {**ch_results, "semantic": merged}
|
|
965
|
+
|
|
802
966
|
def _run_channels(
|
|
803
967
|
self,
|
|
804
968
|
query: str,
|
|
@@ -812,6 +976,7 @@ class RetrievalEngine:
|
|
|
812
976
|
known_as_of: str | None = None,
|
|
813
977
|
valid_at: str | None = None,
|
|
814
978
|
include_unknown: bool = False,
|
|
979
|
+
dropped_channels: set[str] | None = None,
|
|
815
980
|
) -> dict[str, list[tuple[str, float]]]:
|
|
816
981
|
"""Run active retrieval channels.
|
|
817
982
|
|
|
@@ -822,6 +987,13 @@ class RetrievalEngine:
|
|
|
822
987
|
enabled and healthy, parallel dispatch generally bounds the producer
|
|
823
988
|
phase by the slowest submitted producer, plus serial embedding and
|
|
824
989
|
result-collection overhead.
|
|
990
|
+
|
|
991
|
+
``dropped_channels``, when given, receives the name of every channel
|
|
992
|
+
abandoned at ``CHANNEL_HANG_GUARD_SECONDS``. Those channels contributed
|
|
993
|
+
nothing, so the caller needs to know the answer is incomplete rather
|
|
994
|
+
than merely late. It is a caller-owned set passed down per recall and
|
|
995
|
+
deliberately not an attribute of self — two concurrent recalls sharing
|
|
996
|
+
one would report each other's losses (the v3.4.64 race).
|
|
825
997
|
"""
|
|
826
998
|
import os as _os_e
|
|
827
999
|
import time as _time_e
|
|
@@ -894,6 +1066,7 @@ class RetrievalEngine:
|
|
|
894
1066
|
functools.partial(
|
|
895
1067
|
self._temporal.search,
|
|
896
1068
|
include_global=include_global, include_shared=include_shared,
|
|
1069
|
+
query_type=strat.query_type,
|
|
897
1070
|
),
|
|
898
1071
|
query, profile_id, self._config.bm25_top_k,
|
|
899
1072
|
)
|
|
@@ -920,22 +1093,25 @@ class RetrievalEngine:
|
|
|
920
1093
|
q_emb, profile_id, self._config.bm25_top_k,
|
|
921
1094
|
)
|
|
922
1095
|
|
|
923
|
-
#
|
|
924
|
-
# must not make an interactive recall wait 30 seconds; completed
|
|
925
|
-
# channels still participate in fusion and the timeout is observable.
|
|
926
|
-
channel_timeout_seconds = 1.0
|
|
927
|
-
# One shared deadline keeps parallel dispatch genuinely bounded. A
|
|
1096
|
+
# One shared limit keeps parallel dispatch genuinely bounded. A
|
|
928
1097
|
# per-future timeout here would serialise the wait and turn five slow
|
|
929
1098
|
# channels into five seconds of UI latency.
|
|
930
1099
|
done, pending = concurrent.futures.wait(
|
|
931
|
-
futures.values(), timeout=
|
|
1100
|
+
futures.values(), timeout=CHANNEL_HANG_GUARD_SECONDS,
|
|
932
1101
|
)
|
|
933
1102
|
for name, fut in futures.items():
|
|
934
1103
|
if fut in pending:
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
1104
|
+
# Not a latency notice: this answer is missing whatever this
|
|
1105
|
+
# channel alone could see, so it is logged at the level that
|
|
1106
|
+
# says so and recorded for the caller.
|
|
1107
|
+
logger.error(
|
|
1108
|
+
"Channel %s did not finish within %.1fs; this recall is "
|
|
1109
|
+
"answering without it",
|
|
1110
|
+
name, CHANNEL_HANG_GUARD_SECONDS,
|
|
938
1111
|
)
|
|
1112
|
+
if dropped_channels is not None:
|
|
1113
|
+
dropped_channels.add(name)
|
|
1114
|
+
fut.cancel() # no-op if already running; prevents queued jobs from starting
|
|
939
1115
|
continue
|
|
940
1116
|
try:
|
|
941
1117
|
ch_name, result = fut.result()
|
|
@@ -1113,7 +1289,7 @@ class RetrievalEngine:
|
|
|
1113
1289
|
)
|
|
1114
1290
|
for fr in fused
|
|
1115
1291
|
]
|
|
1116
|
-
updated.sort(key=lambda r: r.fused_score,
|
|
1292
|
+
updated.sort(key=lambda r: (-r.fused_score, r.fact_id))
|
|
1117
1293
|
return updated, True, "applied"
|
|
1118
1294
|
|
|
1119
1295
|
# -- Agentic adapter -----------------------------------
|
|
@@ -1172,7 +1348,9 @@ class RetrievalEngine:
|
|
|
1172
1348
|
continue
|
|
1173
1349
|
evidence = [
|
|
1174
1350
|
f"{ch}(rank={rk}, score={fr.channel_scores.get(ch, 0.0):.4f})"
|
|
1175
|
-
|
|
1351
|
+
# Channel name breaks a tie, so the evidence string a caller
|
|
1352
|
+
# sees is the same on two runs when two channels agree on rank.
|
|
1353
|
+
for ch, rk in sorted(fr.channel_ranks.items(), key=lambda x: (x[1], x[0]))
|
|
1176
1354
|
if rk < 1000
|
|
1177
1355
|
]
|
|
1178
1356
|
# Recency decay: Ebbinghaus exponential + FSRS stability strengthening (v3.4.51).
|
|
@@ -1188,10 +1366,12 @@ class RetrievalEngine:
|
|
|
1188
1366
|
# 0d, 0acc → 1.10× 45d, 0acc → 0.91× 90d, 0acc → 0.84×
|
|
1189
1367
|
# 45d, 5acc → 0.95× 90d, 10acc → 0.90× (frequently used memories stay relevant)
|
|
1190
1368
|
age_days = 0.0
|
|
1369
|
+
age_known = False
|
|
1191
1370
|
if fact.created_at:
|
|
1192
1371
|
try:
|
|
1193
1372
|
created = datetime.fromisoformat(fact.created_at.replace("Z", "+00:00"))
|
|
1194
1373
|
age_days = max(0.0, (now - created).total_seconds() / 86400.0)
|
|
1374
|
+
age_known = True
|
|
1195
1375
|
except (ValueError, TypeError):
|
|
1196
1376
|
pass
|
|
1197
1377
|
_access = max(0, getattr(fact, "access_count", 0) or 0)
|
|
@@ -1213,6 +1393,48 @@ class RetrievalEngine:
|
|
|
1213
1393
|
trust_weight, raw_trust = self._get_trust_weight(fact, profile_id)
|
|
1214
1394
|
|
|
1215
1395
|
boosted_score = fr.fused_score * recency_boost * quality * trust_weight
|
|
1396
|
+
|
|
1397
|
+
# Query-type-conditioned recency amplifier.
|
|
1398
|
+
# Applied only to "recency" and "temporal" queries; factual, entity,
|
|
1399
|
+
# and all other types receive a factor of exactly 1.0 (no change).
|
|
1400
|
+
# The amplitude scalar is read from RetrievalConfig so it can be tuned
|
|
1401
|
+
# or zeroed at runtime. strength=0.0 is a strict no-op — the if-guard
|
|
1402
|
+
# ensures the previous ranking is reproduced byte-for-byte.
|
|
1403
|
+
#
|
|
1404
|
+
# recency — 7-day half-life, 1.5× maximum (present-activity queries)
|
|
1405
|
+
# temporal — 30-day half-life, 1.2× maximum (past-event queries)
|
|
1406
|
+
#
|
|
1407
|
+
# Hook for the follow-on embedding-lag adjustment (task 2.6): that
|
|
1408
|
+
# adjustment also multiplies boosted_score and belongs immediately after
|
|
1409
|
+
# this block, conditioned on channel_scores["semantic"] == 0.0 AND
|
|
1410
|
+
# age_days < 1.0. Add it as an independent if-block here so the two
|
|
1411
|
+
# factors compose cleanly without restructuring what is above or below.
|
|
1412
|
+
_prior_strength = getattr(self._config, "recency_prior_strength", 0.5)
|
|
1413
|
+
# age_known matters here: the fallback above leaves age_days at 0.0
|
|
1414
|
+
# when a fact carries no usable timestamp, which reads as "written
|
|
1415
|
+
# moments ago" and would hand an undated fact the largest possible
|
|
1416
|
+
# boost for being new. Not knowing when something was written is not
|
|
1417
|
+
# evidence that it is fresh.
|
|
1418
|
+
if (_prior_strength > 0.0 and age_known
|
|
1419
|
+
and strat.query_type in ("recency", "temporal")):
|
|
1420
|
+
_half_life = 7.0 if strat.query_type == "recency" else 30.0
|
|
1421
|
+
# Both query types use max_amp=1.5 so the decay is visible.
|
|
1422
|
+
# With max_amp=1.2 and half_life=30, the raw value at age 0d
|
|
1423
|
+
# is 1.5 and at age 30d is 1.25 — both clamp to 1.2. The prior
|
|
1424
|
+
# was inert over the first ~39 days, which is the range it was
|
|
1425
|
+
# built to discriminate. Raising the cap to 1.5 lets the
|
|
1426
|
+
# formula vary from 1.5 (fresh) through 1.25 (30d) toward 1.0
|
|
1427
|
+
# (old). This changes ranking: facts from 2 days ago and 30
|
|
1428
|
+
# days ago now receive different boosts. The change is a
|
|
1429
|
+
# correction to a clamp that made the prior inert, not a
|
|
1430
|
+
# measured gain.
|
|
1431
|
+
_max_amp = 1.5
|
|
1432
|
+
_cond_boost = 1.0 + _prior_strength * math.exp(
|
|
1433
|
+
-(math.log(2) / _half_life) * age_days
|
|
1434
|
+
)
|
|
1435
|
+
_cond_boost = min(_cond_boost, _max_amp)
|
|
1436
|
+
boosted_score = boosted_score * _cond_boost
|
|
1437
|
+
|
|
1216
1438
|
# v3.5.0 (M2): soft-normalize to [0,1]. RRF weights + scene/entity
|
|
1217
1439
|
# boosts push raw scores well above 1 (observed: 27.97). A sigmoid
|
|
1218
1440
|
# preserves rank (monotonic) while giving users a readable 0-1 range.
|
|
@@ -1227,6 +1449,13 @@ class RetrievalEngine:
|
|
|
1227
1449
|
evidence_chain=evidence,
|
|
1228
1450
|
trust_score=raw_trust,
|
|
1229
1451
|
))
|
|
1452
|
+
# ranking_score incorporates every modifier computed in this loop
|
|
1453
|
+
# (Ebbinghaus decay, quality, trust, and the query-type-conditioned
|
|
1454
|
+
# recency amplifier). Sort here so RecallResponse.results[0] is
|
|
1455
|
+
# always the highest-ranked fact — callers that rely on the returned
|
|
1456
|
+
# order get the amplified ranking, not the pre-amplifier fused order.
|
|
1457
|
+
# Tie-break on fact_id keeps two runs over an unchanged store stable.
|
|
1458
|
+
results.sort(key=lambda r: (-(r.ranking_score or 0.0), r.fact.fact_id))
|
|
1230
1459
|
return results
|
|
1231
1460
|
|
|
1232
1461
|
|
|
@@ -651,7 +651,7 @@ class EntityGraphChannel:
|
|
|
651
651
|
max_score = max(sc for _, sc in results)
|
|
652
652
|
if max_score > 0:
|
|
653
653
|
results = [(fid, sc / max_score) for fid, sc in results]
|
|
654
|
-
results.sort(key=lambda x: x[1],
|
|
654
|
+
results.sort(key=lambda x: (-x[1], x[0]))
|
|
655
655
|
return filter_authorized_results(
|
|
656
656
|
self._db,
|
|
657
657
|
results,
|
|
@@ -261,8 +261,14 @@ class HopfieldChannel:
|
|
|
261
261
|
# Step 4: Similarity to all patterns
|
|
262
262
|
similarities = memory_matrix @ retrieved # shape (n,)
|
|
263
263
|
|
|
264
|
-
# Step 5: Top-K selection
|
|
265
|
-
|
|
264
|
+
# Step 5: Top-K selection with stable tie-break on fact_id.
|
|
265
|
+
# argsort(-similarities) is score-only: equal similarities are ordered
|
|
266
|
+
# by row index, which tracks the order facts were loaded from the DB
|
|
267
|
+
# and varies between sessions. lexsort breaks ties on fact_id so the
|
|
268
|
+
# same query over the same store returns the same ranking every time.
|
|
269
|
+
id_arr = np.array(fact_ids, dtype=object)
|
|
270
|
+
order = np.lexsort((id_arr, -similarities)) # primary: -similarity, secondary: fact_id
|
|
271
|
+
top_indices = order[:top_k]
|
|
266
272
|
results: list[tuple[str, float]] = [
|
|
267
273
|
(fact_ids[int(i)], float(similarities[i]))
|
|
268
274
|
for i in top_indices
|