superlocalmemory 3.8.7 → 3.8.8
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 +34 -1
- package/README.md +3 -3
- 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/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-graph/SKILL.md +1 -1
- 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-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/core/embeddings.py +73 -11
- package/src/superlocalmemory/core/engine.py +6 -1
- package/src/superlocalmemory/core/ollama_embedder.py +5 -0
- package/src/superlocalmemory/core/recall_gate.py +39 -4
- package/src/superlocalmemory/core/recall_pipeline.py +40 -0
- package/src/superlocalmemory/encoding/scene_builder.py +105 -15
- package/src/superlocalmemory/retrieval/entity_channel.py +201 -56
- package/src/superlocalmemory/retrieval/vector_store.py +238 -123
- package/src/superlocalmemory/server/recall_health.py +3 -1
- package/src/superlocalmemory/server/unified_daemon.py +104 -16
- package/src/superlocalmemory/storage/embedding_migrator.py +88 -60
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,36 @@ All notable changes to SuperLocalMemory V3 will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [3.8.8] - 2026-07-27 — Live-database recall and vector integrity
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- Foreground recall now retains priority while startup repair and canonical
|
|
12
|
+
remember enrichment are active. Background embedding work yields between
|
|
13
|
+
items and cannot cold-start the shared local model ahead of a user query.
|
|
14
|
+
- Daemon readiness now verifies that the local embedding subprocess is
|
|
15
|
+
actually alive and has served a request instead of trusting a stale startup
|
|
16
|
+
flag.
|
|
17
|
+
- Scene clustering reuses durable fact embeddings and ignores legacy scene
|
|
18
|
+
rows whose facts were already consolidated away. Mature databases no longer
|
|
19
|
+
issue thousands of redundant model requests or repeatedly recycle the
|
|
20
|
+
embedding worker after restart.
|
|
21
|
+
- sqlite-vec row allocation is serialized across processes, and every metadata
|
|
22
|
+
pointer is validated against the vector payload's profile. Cross-profile
|
|
23
|
+
row-id collisions are repaired without exposing another profile's memory.
|
|
24
|
+
- Vector writes roll back abandoned transactions, missing vectors are repaired
|
|
25
|
+
exactly, and semantic search expands past legacy orphan payloads.
|
|
26
|
+
- Entity-cache startup reads are bounded and load only the columns used by the
|
|
27
|
+
entity channel.
|
|
28
|
+
- Exact lexical evidence remains protected after learned ranking so a freshly
|
|
29
|
+
remembered exact marker cannot be displaced by a weaker semantic match.
|
|
30
|
+
|
|
31
|
+
### Notes
|
|
32
|
+
- Recall remains physically query-only; remember owns durable writes and
|
|
33
|
+
continues full canonical enrichment asynchronously after immediate queryable
|
|
34
|
+
admission.
|
|
35
|
+
- This candidate must pass the installed wheel, installed npm tarball,
|
|
36
|
+
dashboard, MCP, CLI, and mature live-database gates before publication.
|
|
37
|
+
|
|
8
38
|
## [3.8.7] - 2026-07-27 — Existing graph-store compatibility
|
|
9
39
|
|
|
10
40
|
### Fixed
|
|
@@ -12,9 +42,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
12
42
|
Cozo graph stores. Version 3.8.6 selected PyCozo 0.7.6, whose incompatible
|
|
13
43
|
on-disk format could stop the daemon at startup with
|
|
14
44
|
`Unknown storage version 1`.
|
|
45
|
+
- A native Cozo panic is now contained at the optional projection boundary.
|
|
46
|
+
The daemon remains available on canonical SQLite and preserves the graph
|
|
47
|
+
files unchanged instead of losing access to memory.
|
|
15
48
|
|
|
16
49
|
### Notes
|
|
17
|
-
-
|
|
50
|
+
- Existing PyCozo 0.3.0 graphs require no rebuild or database migration.
|
|
18
51
|
- All serialized-write, read-only recall, admission-journal, and dead-letter
|
|
19
52
|
fixes from 3.8.6 are unchanged.
|
|
20
53
|
|
package/README.md
CHANGED
|
@@ -5,15 +5,15 @@
|
|
|
5
5
|
</picture>
|
|
6
6
|
</p>
|
|
7
7
|
|
|
8
|
-
<h1 align="center">SuperLocalMemory V3.8.
|
|
8
|
+
<h1 align="center">SuperLocalMemory V3.8.8</h1>
|
|
9
9
|
<p align="center"><strong>Enterprise-grade, local-first memory for AI agents and teams.</strong><br/>
|
|
10
10
|
<em>A persistent, auditable long-term brain for your agents that runs on your own infrastructure — with multi-workspace isolation, role-based access, and GDPR + EU AI Act governance controls built in.</em></p>
|
|
11
|
-
<p align="center"><code>v3.8.
|
|
11
|
+
<p align="center"><code>v3.8.8</code> — one control plane: auditable retrieval · multi-scope memory (personal / shared / global) · Cache · Compress · trusted-peer Mesh · bounded loops — across CLI, MCP, dashboard, the <strong>Claude plugin</strong>, the <strong>Codex add-on</strong>, and documented IDE integrations.<br/>
|
|
12
12
|
Proxy: <code>slm wrap claude</code> · MCP: add <code>slm_compress</code> to your config · Skill: zero-config</p>
|
|
13
13
|
<p align="center"><strong>3 public research preprints</strong> (arXiv + Zenodo archives) · <a href="https://arxiv.org/abs/2603.02240">arXiv:2603.02240</a> · <a href="https://arxiv.org/abs/2603.14588">arXiv:2603.14588</a> · <a href="https://arxiv.org/abs/2604.04514">arXiv:2604.04514</a></p>
|
|
14
14
|
|
|
15
15
|
<p align="center">
|
|
16
|
-
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/v3.8.
|
|
16
|
+
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/v3.8.8-Current_Release-2ea44f?style=for-the-badge&logo=checkmarx&logoColor=white" alt="v3.8.8 — Current Release"/></a>
|
|
17
17
|
<a href="https://arxiv.org/abs/2603.14588"><img src="https://img.shields.io/badge/arXiv-2603.14588-b31b1b?style=for-the-badge&logo=arxiv&logoColor=white" alt="arXiv Paper"/></a>
|
|
18
18
|
<a href="#three-surfaces-proxy--mcp-tools--skill"><img src="https://img.shields.io/badge/Proxy_|_MCP_|_Skill-22c55e?style=for-the-badge" alt="Three Surfaces: Proxy, MCP Tools, Skill"/></a>
|
|
19
19
|
<a href="https://pypi.org/project/superlocalmemory/"><img src="https://img.shields.io/pypi/v/superlocalmemory?style=for-the-badge&logo=pypi&logoColor=white" alt="PyPI"/></a>
|
package/package.json
CHANGED
package/plugin/CLAUDE.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- BEGIN SuperLocalMemory v3.8.
|
|
1
|
+
<!-- BEGIN SuperLocalMemory v3.8.8 -->
|
|
2
2
|
|
|
3
3
|
## SuperLocalMemory (SLM) — Agent Rules
|
|
4
4
|
|
|
@@ -39,6 +39,6 @@ slm-recall · slm-remember · slm-session · slm-status · slm-cache · slm-comp
|
|
|
39
39
|
### Subagents
|
|
40
40
|
slm-memory-advisor (memory decisions, session hygiene, scope/profile guidance) · slm-optimize-advisor (context compression + KV cache) · slm-governance-advisor (scope/roles/compliance/GDPR)
|
|
41
41
|
|
|
42
|
-
<!-- END SuperLocalMemory v3.8.
|
|
42
|
+
<!-- END SuperLocalMemory v3.8.8 -->
|
|
43
43
|
|
|
44
|
-
SuperLocalMemory v3.8.
|
|
44
|
+
SuperLocalMemory v3.8.8 · Qualixar · AGPL-3.0-or-later
|
|
@@ -77,4 +77,4 @@ slm-scope · slm-governance · slm-profile · slm-remember · slm-recall
|
|
|
77
77
|
# What NOT to do
|
|
78
78
|
Never session_init twice; never forget without dry-run preview; never store secrets; never bypass role checks; never claim an erasure succeeded without verifying via recall.
|
|
79
79
|
|
|
80
|
-
SuperLocalMemory v3.8.
|
|
80
|
+
SuperLocalMemory v3.8.8 · Qualixar · AGPL-3.0-or-later
|
|
@@ -46,4 +46,4 @@ slm-recall · slm-remember · slm-session · slm-scope · slm-profile · slm-gov
|
|
|
46
46
|
# What NOT to do
|
|
47
47
|
Never session_init twice; never forget dry_run=False without reporting preview; never dump a whole file into remember; never invent a memory; never claim "saved" without success:true / clean CLI exit; never bypass scope or governance restrictions.
|
|
48
48
|
|
|
49
|
-
SuperLocalMemory v3.8.
|
|
49
|
+
SuperLocalMemory v3.8.8 · Qualixar · AGPL-3.0-or-later
|
|
@@ -41,4 +41,4 @@ slm-compress · slm-cache · slm-status · slm-profile
|
|
|
41
41
|
# What NOT to do
|
|
42
42
|
Never compress code-for-edit/JSON-to-parse/<500 chars; never store secrets/ccr_ids; never let optimize failure block/alter the task; never claim a specific savings %; never carry ccr_ids across profile switches.
|
|
43
43
|
|
|
44
|
-
SuperLocalMemory v3.8.
|
|
44
|
+
SuperLocalMemory v3.8.8 · Qualixar · AGPL-3.0-or-later
|
package/plugin/requirements.txt
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
superlocalmemory==3.8.
|
|
1
|
+
superlocalmemory==3.8.8
|
|
@@ -128,4 +128,4 @@ When the SLM MCP server is unavailable, use these CLI equivalents:
|
|
|
128
128
|
- **slm-optimize-advisor** — context compression and KV cache
|
|
129
129
|
- **slm-governance-advisor** — scope/role compliance, retention policies, GDPR
|
|
130
130
|
|
|
131
|
-
SuperLocalMemory v3.8.
|
|
131
|
+
SuperLocalMemory v3.8.8 · Qualixar · AGPL-3.0-or-later
|
package/pyproject.toml
CHANGED
|
@@ -32,7 +32,7 @@ if "OMP_NUM_THREADS" not in os.environ:
|
|
|
32
32
|
os.environ["OMP_NUM_THREADS"] = "2"
|
|
33
33
|
# ---------------------------------------------------------------------------
|
|
34
34
|
|
|
35
|
-
__version__ = "3.8.
|
|
35
|
+
__version__ = "3.8.8"
|
|
36
36
|
|
|
37
37
|
_REQUIRED_VERSIONS = {
|
|
38
38
|
"sentence_transformers": "5.3.0",
|
|
@@ -220,6 +220,7 @@ class EmbeddingService:
|
|
|
220
220
|
self._owns_worker_lock = False
|
|
221
221
|
self._request_count: int = 0
|
|
222
222
|
self._http_client: object | None = None
|
|
223
|
+
self._remote_ready = False
|
|
223
224
|
|
|
224
225
|
# Register for atexit cleanup (prevent orphaned workers)
|
|
225
226
|
ref = weakref.ref(self, _live_embedding_services.discard)
|
|
@@ -246,6 +247,31 @@ class EmbeddingService:
|
|
|
246
247
|
return bool(self._config.api_endpoint and self._config.api_key)
|
|
247
248
|
return self._available
|
|
248
249
|
|
|
250
|
+
@property
|
|
251
|
+
def is_warm(self) -> bool:
|
|
252
|
+
"""Return whether the configured backend has served a request.
|
|
253
|
+
|
|
254
|
+
``is_available`` only means that the backend may be started. A local
|
|
255
|
+
sentence-transformers cold start can take minutes on Apple Silicon, so
|
|
256
|
+
background enrichment must not mistake availability for readiness and
|
|
257
|
+
occupy the only worker ahead of an interactive recall.
|
|
258
|
+
"""
|
|
259
|
+
config = getattr(self, "_config", None)
|
|
260
|
+
if config is not None and (
|
|
261
|
+
config.is_openai_compatible or config.is_cloud
|
|
262
|
+
):
|
|
263
|
+
return bool(
|
|
264
|
+
getattr(self, "_remote_ready", False)
|
|
265
|
+
and self.is_available
|
|
266
|
+
)
|
|
267
|
+
proc = getattr(self, "_worker_proc", None)
|
|
268
|
+
if proc is None or getattr(self, "_request_count", 0) <= 0:
|
|
269
|
+
return False
|
|
270
|
+
try:
|
|
271
|
+
return proc.poll() is None
|
|
272
|
+
except Exception:
|
|
273
|
+
return False
|
|
274
|
+
|
|
249
275
|
@property
|
|
250
276
|
def dimension(self) -> int:
|
|
251
277
|
return self._config.dimension
|
|
@@ -291,13 +317,27 @@ class EmbeddingService:
|
|
|
291
317
|
"""Embed a single text string. Returns list of floats or None."""
|
|
292
318
|
if not text or not text.strip():
|
|
293
319
|
raise ValueError("Cannot embed empty text")
|
|
320
|
+
from superlocalmemory.core.recall_gate import wait_for_foreground_idle
|
|
321
|
+
wait_for_foreground_idle()
|
|
294
322
|
if self._config.is_openai_compatible:
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
323
|
+
try:
|
|
324
|
+
vecs = self._openai_compatible_embed_batch([text])
|
|
325
|
+
vec = vecs[0]
|
|
326
|
+
self._validate_dimension(np.asarray(vec))
|
|
327
|
+
self._remote_ready = True
|
|
328
|
+
return vec
|
|
329
|
+
except Exception:
|
|
330
|
+
self._remote_ready = False
|
|
331
|
+
raise
|
|
299
332
|
if self._config.is_cloud:
|
|
300
|
-
|
|
333
|
+
try:
|
|
334
|
+
vec = self._cloud_embed_single(text)
|
|
335
|
+
self._validate_dimension(np.asarray(vec))
|
|
336
|
+
self._remote_ready = True
|
|
337
|
+
return vec
|
|
338
|
+
except Exception:
|
|
339
|
+
self._remote_ready = False
|
|
340
|
+
raise
|
|
301
341
|
result = self._subprocess_embed([text])
|
|
302
342
|
if result is None:
|
|
303
343
|
return None
|
|
@@ -309,14 +349,34 @@ class EmbeddingService:
|
|
|
309
349
|
"""Embed a batch of texts."""
|
|
310
350
|
if not texts:
|
|
311
351
|
raise ValueError("Cannot embed empty batch")
|
|
352
|
+
from superlocalmemory.core.recall_gate import is_background_work
|
|
353
|
+
if is_background_work():
|
|
354
|
+
# A single large background batch can own the only local inference
|
|
355
|
+
# worker for tens of seconds. Slice it so a recall arriving after
|
|
356
|
+
# this call started gets priority before the next text.
|
|
357
|
+
return [self.embed(text) for text in texts]
|
|
312
358
|
if self._config.is_openai_compatible:
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
359
|
+
try:
|
|
360
|
+
results = self._openai_compatible_embed_batch(texts)
|
|
361
|
+
for vec in results:
|
|
362
|
+
if vec is not None:
|
|
363
|
+
self._validate_dimension(np.asarray(vec))
|
|
364
|
+
self._remote_ready = any(vec is not None for vec in results)
|
|
365
|
+
return results
|
|
366
|
+
except Exception:
|
|
367
|
+
self._remote_ready = False
|
|
368
|
+
raise
|
|
318
369
|
if self._config.is_cloud:
|
|
319
|
-
|
|
370
|
+
try:
|
|
371
|
+
results = self._cloud_embed_batch(texts)
|
|
372
|
+
for vec in results:
|
|
373
|
+
if vec is not None:
|
|
374
|
+
self._validate_dimension(np.asarray(vec))
|
|
375
|
+
self._remote_ready = any(vec is not None for vec in results)
|
|
376
|
+
return results
|
|
377
|
+
except Exception:
|
|
378
|
+
self._remote_ready = False
|
|
379
|
+
raise
|
|
320
380
|
result = self._subprocess_embed(texts)
|
|
321
381
|
if result is None:
|
|
322
382
|
return [None] * len(texts)
|
|
@@ -353,6 +413,8 @@ class EmbeddingService:
|
|
|
353
413
|
Includes a timeout (_SUBPROCESS_RESPONSE_TIMEOUT seconds) so the CLI
|
|
354
414
|
never hangs indefinitely on cold model loads or network issues.
|
|
355
415
|
"""
|
|
416
|
+
from superlocalmemory.core.recall_gate import wait_for_foreground_idle
|
|
417
|
+
wait_for_foreground_idle()
|
|
356
418
|
with self._lock:
|
|
357
419
|
# Only an explicit terminal disable (``False``) short-circuits. A
|
|
358
420
|
# ``None`` availability is the recall-health self-heal's "re-probe"
|
|
@@ -637,7 +637,12 @@ class MemoryEngine:
|
|
|
637
637
|
except (ValueError, TypeError):
|
|
638
638
|
_timeout_s = 0.5 # default 500 ms
|
|
639
639
|
try:
|
|
640
|
-
|
|
640
|
+
def _best_effort_embed():
|
|
641
|
+
from superlocalmemory.core.recall_gate import background_work
|
|
642
|
+
with background_work():
|
|
643
|
+
return _embedder_ref.embed(fact_text)
|
|
644
|
+
|
|
645
|
+
_future = self._store_fast_embed_pool.submit(_best_effort_embed)
|
|
641
646
|
try:
|
|
642
647
|
emb = _future.result(timeout=_timeout_s)
|
|
643
648
|
if emb:
|
|
@@ -90,6 +90,8 @@ class OllamaEmbedder:
|
|
|
90
90
|
"""
|
|
91
91
|
if not text or not text.strip():
|
|
92
92
|
raise ValueError("Cannot embed empty text")
|
|
93
|
+
from superlocalmemory.core.recall_gate import wait_for_foreground_idle
|
|
94
|
+
wait_for_foreground_idle()
|
|
93
95
|
|
|
94
96
|
# V3.3.27: Check cache first
|
|
95
97
|
cache_key = text.strip()
|
|
@@ -119,6 +121,9 @@ class OllamaEmbedder:
|
|
|
119
121
|
"""
|
|
120
122
|
if not texts:
|
|
121
123
|
raise ValueError("Cannot embed empty batch")
|
|
124
|
+
from superlocalmemory.core.recall_gate import is_background_work
|
|
125
|
+
if is_background_work():
|
|
126
|
+
return [self.embed(text) for text in texts]
|
|
122
127
|
|
|
123
128
|
# V3.3.27: Split into cached and uncached
|
|
124
129
|
results: list[list[float] | None] = [None] * len(texts)
|
|
@@ -14,23 +14,58 @@ recall.
|
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
16
|
import threading
|
|
17
|
+
from contextlib import contextmanager
|
|
18
|
+
from typing import Iterator
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
_condition = threading.Condition(threading.Lock())
|
|
19
21
|
_active = 0
|
|
22
|
+
_work_context = threading.local()
|
|
20
23
|
|
|
21
24
|
|
|
22
25
|
def begin_recall() -> None:
|
|
23
26
|
global _active
|
|
24
|
-
with
|
|
27
|
+
with _condition:
|
|
25
28
|
_active += 1
|
|
26
29
|
|
|
27
30
|
|
|
28
31
|
def end_recall() -> None:
|
|
29
32
|
global _active
|
|
30
|
-
with
|
|
33
|
+
with _condition:
|
|
31
34
|
_active = max(0, _active - 1)
|
|
35
|
+
if _active == 0:
|
|
36
|
+
_condition.notify_all()
|
|
32
37
|
|
|
33
38
|
|
|
34
39
|
def in_flight() -> int:
|
|
35
|
-
with
|
|
40
|
+
with _condition:
|
|
36
41
|
return _active
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@contextmanager
|
|
45
|
+
def background_work() -> Iterator[None]:
|
|
46
|
+
"""Mark best-effort work that must yield shared inference to recall.
|
|
47
|
+
|
|
48
|
+
The marker is thread-local because materialization, health probes, and
|
|
49
|
+
interactive handlers all share one resident engine and one embedder.
|
|
50
|
+
Nested callers restore the previous marker on exit.
|
|
51
|
+
"""
|
|
52
|
+
previous = bool(getattr(_work_context, "background", False))
|
|
53
|
+
_work_context.background = True
|
|
54
|
+
try:
|
|
55
|
+
yield
|
|
56
|
+
finally:
|
|
57
|
+
_work_context.background = previous
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def is_background_work() -> bool:
|
|
61
|
+
"""Return whether the current thread is running best-effort work."""
|
|
62
|
+
return bool(getattr(_work_context, "background", False))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def wait_for_foreground_idle() -> None:
|
|
66
|
+
"""Block background inference while an interactive recall is active."""
|
|
67
|
+
if not is_background_work():
|
|
68
|
+
return
|
|
69
|
+
with _condition:
|
|
70
|
+
while _active > 0:
|
|
71
|
+
_condition.wait(timeout=0.1)
|
|
@@ -100,6 +100,45 @@ def _apply_markers_to_response(response: RecallResponse) -> None:
|
|
|
100
100
|
r.marker = _emit_marker(r.fact.fact_id)
|
|
101
101
|
|
|
102
102
|
|
|
103
|
+
def _preserve_exact_lexical_evidence(
|
|
104
|
+
response: RecallResponse,
|
|
105
|
+
query: str,
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Keep a deterministic exact BM25 hit ahead of learned refinements.
|
|
108
|
+
|
|
109
|
+
Adaptive and bandit ranking are valuable for ambiguous candidates, but
|
|
110
|
+
they must not demote a fact containing the caller's exact query behind
|
|
111
|
+
semantically similar noise. This guard runs after every learned layer and
|
|
112
|
+
changes only ordering; it does not introduce or bypass evidence.
|
|
113
|
+
"""
|
|
114
|
+
normalized_query = " ".join(query.casefold().split())
|
|
115
|
+
if len(normalized_query) < 3 or len(response.results) < 2:
|
|
116
|
+
return
|
|
117
|
+
exact = [
|
|
118
|
+
result
|
|
119
|
+
for result in response.results
|
|
120
|
+
if (
|
|
121
|
+
float((result.channel_scores or {}).get("bm25", 0.0) or 0.0) > 0.0
|
|
122
|
+
and normalized_query
|
|
123
|
+
in " ".join(result.fact.content.casefold().split())
|
|
124
|
+
)
|
|
125
|
+
]
|
|
126
|
+
if not exact:
|
|
127
|
+
return
|
|
128
|
+
strongest = max(
|
|
129
|
+
exact,
|
|
130
|
+
key=lambda result: float(
|
|
131
|
+
(result.channel_scores or {}).get("bm25", 0.0) or 0.0,
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
if response.results[0] is strongest:
|
|
135
|
+
return
|
|
136
|
+
response.results = [
|
|
137
|
+
strongest,
|
|
138
|
+
*(result for result in response.results if result is not strongest),
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
|
|
103
142
|
# ---------------------------------------------------------------------------
|
|
104
143
|
# Stage 8 SB-1 — feed shadow_router from recall-settled signals.
|
|
105
144
|
#
|
|
@@ -854,6 +893,7 @@ def run_recall(
|
|
|
854
893
|
except Exception as exc:
|
|
855
894
|
logger.debug("Ranking pipeline skipped: %s", exc)
|
|
856
895
|
|
|
896
|
+
_preserve_exact_lexical_evidence(response, query)
|
|
857
897
|
_mark("learning+ranking")
|
|
858
898
|
# Deliberately no trust, Fisher, retention, lifecycle, popularity, or graph
|
|
859
899
|
# mutation here. Those state transitions require a separately authenticated
|