superlocalmemory 4.0.9 → 4.1.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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +245 -0
- package/README.md +7 -7
- package/package.json +4 -2
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +4 -4
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +2 -2
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +5 -5
- package/plugin/skills/slm-recall/SKILL.md +102 -15
- package/plugin/skills/slm-remember/SKILL.md +35 -3
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +29 -3
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +16 -8
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-governance/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-loop/SKILL.md +2 -2
- package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
- package/plugin-src/skills/slm-profile/SKILL.md +5 -5
- package/plugin-src/skills/slm-recall/SKILL.md +102 -15
- package/plugin-src/skills/slm-remember/SKILL.md +35 -3
- package/plugin-src/skills/slm-scope/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +29 -3
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +308 -20
- package/src/superlocalmemory/cli/daemon.py +30 -0
- package/src/superlocalmemory/cli/db_migrate.py +71 -1
- package/src/superlocalmemory/cli/gdpr_cmd.py +15 -2
- package/src/superlocalmemory/cli/main.py +26 -4
- package/src/superlocalmemory/code_graph/bridge/maintenance.py +8 -0
- package/src/superlocalmemory/code_graph/database.py +44 -0
- package/src/superlocalmemory/compliance/gdpr.py +449 -39
- package/src/superlocalmemory/core/admission.py +231 -11
- package/src/superlocalmemory/core/backend_orchestrator.py +190 -84
- package/src/superlocalmemory/core/config.py +90 -11
- package/src/superlocalmemory/core/consolidation_engine.py +34 -0
- package/src/superlocalmemory/core/engine.py +140 -11
- package/src/superlocalmemory/core/fact_consolidator.py +316 -125
- package/src/superlocalmemory/core/graph_analyzer.py +76 -112
- package/src/superlocalmemory/core/graph_metrics.py +597 -0
- package/src/superlocalmemory/core/graph_pruner.py +121 -0
- package/src/superlocalmemory/core/maintenance.py +44 -6
- package/src/superlocalmemory/core/maintenance_scheduler.py +205 -0
- package/src/superlocalmemory/core/memory_health.py +266 -0
- package/src/superlocalmemory/core/mode_capability.py +111 -0
- package/src/superlocalmemory/core/ollama_validator.py +315 -0
- package/src/superlocalmemory/core/operation_policy_registry.py +1 -1
- package/src/superlocalmemory/core/operation_request.py +1 -1
- package/src/superlocalmemory/core/ops_remediation.py +2 -2
- package/src/superlocalmemory/core/projection_drain.py +380 -0
- package/src/superlocalmemory/core/recall_pipeline.py +390 -3
- package/src/superlocalmemory/core/recall_worker.py +6 -3
- package/src/superlocalmemory/core/scale_autopromote.py +196 -0
- package/src/superlocalmemory/core/scale_engine.py +16 -2
- package/src/superlocalmemory/core/score_contract.py +21 -1
- package/src/superlocalmemory/core/session_identity.py +85 -0
- package/src/superlocalmemory/core/status_contract.py +108 -0
- package/src/superlocalmemory/core/store_pipeline.py +78 -3
- package/src/superlocalmemory/core/worker_pool.py +4 -4
- package/src/superlocalmemory/core/working_memory.py +288 -0
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +51 -7
- package/src/superlocalmemory/encoding/context_generator.py +1 -1
- package/src/superlocalmemory/encoding/entity_resolver.py +38 -0
- package/src/superlocalmemory/encoding/fact_extractor.py +18 -14
- package/src/superlocalmemory/encoding/prospective_markers.py +262 -0
- package/src/superlocalmemory/encoding/type_router.py +12 -12
- package/src/superlocalmemory/evolution/mutation_generator.py +30 -4
- package/src/superlocalmemory/graph/cozo_adjacency.py +122 -0
- package/src/superlocalmemory/graph/cozo_backend.py +103 -138
- package/src/superlocalmemory/hooks/portable_kit.py +10 -2
- package/src/superlocalmemory/learning/bandit.py +43 -0
- package/src/superlocalmemory/learning/consolidation_worker.py +54 -0
- package/src/superlocalmemory/learning/database.py +60 -3
- package/src/superlocalmemory/learning/entity_compiler.py +21 -58
- package/src/superlocalmemory/learning/feedback.py +3 -1
- package/src/superlocalmemory/learning/outcomes.py +47 -16
- package/src/superlocalmemory/learning/pattern_miner.py +28 -3
- package/src/superlocalmemory/learning/pattern_miner_constants.py +43 -0
- package/src/superlocalmemory/learning/pcos.py +291 -0
- package/src/superlocalmemory/learning/reward_from_outcomes.py +365 -0
- package/src/superlocalmemory/learning/reward_proxy.py +100 -10
- package/src/superlocalmemory/learning/signal_kinds.py +79 -0
- package/src/superlocalmemory/mcp/profiles.py +14 -2
- package/src/superlocalmemory/mcp/server.py +1 -1
- package/src/superlocalmemory/mcp/session_binding.py +92 -0
- package/src/superlocalmemory/mcp/tools_active.py +2 -1
- package/src/superlocalmemory/mcp/tools_core.py +71 -42
- package/src/superlocalmemory/mcp/tools_ops.py +2 -2
- package/src/superlocalmemory/mcp/tools_v28.py +20 -1
- package/src/superlocalmemory/parameterization/pattern_extractor.py +14 -1
- package/src/superlocalmemory/parameterization/soft_prompt_generator.py +98 -0
- package/src/superlocalmemory/retrieval/bm25_channel.py +68 -11
- package/src/superlocalmemory/retrieval/channel_status.py +117 -0
- package/src/superlocalmemory/retrieval/engine.py +106 -11
- package/src/superlocalmemory/retrieval/entity_channel.py +217 -257
- package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
- package/src/superlocalmemory/retrieval/scope_policy.py +42 -1
- package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
- package/src/superlocalmemory/retrieval/spreading.py +288 -0
- package/src/superlocalmemory/retrieval/temporal_channel.py +13 -1
- package/src/superlocalmemory/retrieval/vector_store.py +63 -0
- package/src/superlocalmemory/server/api.py +26 -2
- package/src/superlocalmemory/server/asset_versions.py +171 -0
- package/src/superlocalmemory/server/bandit_loops.py +17 -1
- package/src/superlocalmemory/server/rbac_enforce.py +26 -6
- package/src/superlocalmemory/server/recall_serializer.py +9 -0
- package/src/superlocalmemory/server/routes/abstraction.py +201 -0
- package/src/superlocalmemory/server/routes/behavioral.py +75 -10
- package/src/superlocalmemory/server/routes/compliance.py +98 -18
- package/src/superlocalmemory/server/routes/config_api.py +186 -4
- package/src/superlocalmemory/server/routes/data_io.py +29 -1
- package/src/superlocalmemory/server/routes/entity.py +13 -1
- package/src/superlocalmemory/server/routes/evolution.py +178 -0
- package/src/superlocalmemory/server/routes/ingest.py +8 -0
- package/src/superlocalmemory/server/routes/learning_telemetry.py +2 -1
- package/src/superlocalmemory/server/routes/memories.py +49 -7
- package/src/superlocalmemory/server/routes/mesh.py +1 -1
- package/src/superlocalmemory/server/routes/timeline.py +4 -0
- package/src/superlocalmemory/server/routes/v3_api.py +193 -17
- package/src/superlocalmemory/server/ui.py +24 -1
- package/src/superlocalmemory/server/unified_daemon.py +292 -9
- package/src/superlocalmemory/storage/_migration_internals.py +35 -0
- package/src/superlocalmemory/storage/_schema_version.py +24 -3
- package/src/superlocalmemory/storage/database.py +598 -82
- package/src/superlocalmemory/storage/embedding_codec.py +71 -0
- package/src/superlocalmemory/storage/lineage_retention.py +236 -0
- package/src/superlocalmemory/storage/logical_edges.py +43 -2
- package/src/superlocalmemory/storage/migration_runner.py +130 -0
- package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +488 -0
- package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +127 -0
- package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +158 -0
- package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +620 -0
- package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +306 -0
- package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +207 -0
- package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +201 -0
- package/src/superlocalmemory/storage/migrations.py +18 -2
- package/src/superlocalmemory/storage/models.py +40 -1
- package/src/superlocalmemory/storage/projection_outbox.py +346 -0
- package/src/superlocalmemory/storage/retention_policy.py +860 -0
- package/src/superlocalmemory/storage/schema.py +110 -1
- package/src/superlocalmemory/storage/write_coordinator.py +19 -2
- package/src/superlocalmemory/summaries/base.py +1 -1
- package/src/superlocalmemory/summaries/non_answer.py +223 -0
- package/src/superlocalmemory/trust/scorer.py +43 -1
- package/src/superlocalmemory/ui/index.html +10 -19
- package/src/superlocalmemory/ui/js/event-delegation.js +12 -1
- package/src/superlocalmemory/ui/js/od-health.js +28 -6
- package/src/superlocalmemory/ui/js/od-memories.js +209 -1
- package/src/superlocalmemory/ui/js/od-ops-health.js +1 -1
- package/src/superlocalmemory/ui/js/od-settings.js +87 -1
- package/src/superlocalmemory/ui/js/recall-lab.js +78 -3
|
@@ -14,6 +14,9 @@ from __future__ import annotations
|
|
|
14
14
|
import hashlib
|
|
15
15
|
import hmac
|
|
16
16
|
import logging
|
|
17
|
+
from dataclasses import replace
|
|
18
|
+
|
|
19
|
+
from superlocalmemory.core.session_identity import is_conversation
|
|
17
20
|
import sqlite3
|
|
18
21
|
from pathlib import Path
|
|
19
22
|
from typing import TYPE_CHECKING, Any
|
|
@@ -253,6 +256,8 @@ def _behavioral_entities(results: list[Any], limit: int = 20) -> list[str]:
|
|
|
253
256
|
_RANKING_MODES: frozenset[str] = frozenset({"off", "v1", "v2", "v2-ensemble"})
|
|
254
257
|
|
|
255
258
|
|
|
259
|
+
from superlocalmemory.learning.signal_kinds import FEEDBACK_ONLY_SQL
|
|
260
|
+
|
|
256
261
|
class _ReadOnlyLearningView:
|
|
257
262
|
"""Minimal learning-model reader that cannot initialise or mutate a DB."""
|
|
258
263
|
|
|
@@ -271,11 +276,19 @@ class _ReadOnlyLearningView:
|
|
|
271
276
|
return connection
|
|
272
277
|
|
|
273
278
|
def count_signals(self, profile_id: str) -> int:
|
|
279
|
+
"""Count FEEDBACK rows only — an exposure is not feedback.
|
|
280
|
+
|
|
281
|
+
Must agree with ``LearningDatabase.count_signals``: a phase resolved
|
|
282
|
+
from one of these is compared against a threshold resolved from the
|
|
283
|
+
other, so a difference between them is a phase that flickers. Both
|
|
284
|
+
share ``FEEDBACK_ONLY_SQL``; a test asserts neither carries its own
|
|
285
|
+
copy of the predicate.
|
|
286
|
+
"""
|
|
274
287
|
connection = self._connection()
|
|
275
288
|
try:
|
|
276
289
|
row = connection.execute(
|
|
277
290
|
"SELECT COUNT(*) AS count FROM learning_signals "
|
|
278
|
-
"WHERE profile_id = ?",
|
|
291
|
+
f"WHERE profile_id = ?{FEEDBACK_ONLY_SQL}",
|
|
279
292
|
(profile_id,),
|
|
280
293
|
).fetchone()
|
|
281
294
|
return int(row["count"]) if row else 0
|
|
@@ -348,6 +361,9 @@ def apply_ranking(
|
|
|
348
361
|
config: Any = None,
|
|
349
362
|
pipeline_version: str = "v2-ensemble",
|
|
350
363
|
record_signals: bool = False,
|
|
364
|
+
record_plays: bool = True,
|
|
365
|
+
memory_db_path: Any = None,
|
|
366
|
+
play_sink: dict | None = None,
|
|
351
367
|
) -> "RecallResponse":
|
|
352
368
|
"""Run the ranking pipeline at the requested version.
|
|
353
369
|
|
|
@@ -383,6 +399,9 @@ def apply_ranking(
|
|
|
383
399
|
response = apply_v2_bandit_ensemble(
|
|
384
400
|
response, query, profile_id, query_id,
|
|
385
401
|
record_signals=record_signals,
|
|
402
|
+
record_plays=record_plays,
|
|
403
|
+
memory_db_path=memory_db_path,
|
|
404
|
+
play_sink=play_sink,
|
|
386
405
|
)
|
|
387
406
|
except Exception as exc: # pragma: no cover — defensive
|
|
388
407
|
logger.debug("apply_ranking ensemble step skipped: %s", exc)
|
|
@@ -605,6 +624,267 @@ def apply_v2_adaptive_ranking(
|
|
|
605
624
|
# ---------------------------------------------------------------------------
|
|
606
625
|
|
|
607
626
|
|
|
627
|
+
def _rank_key(result) -> tuple[float, str]:
|
|
628
|
+
"""Descending ordering key for the score-bias passes.
|
|
629
|
+
|
|
630
|
+
Falls back to ``score`` exactly as the writes do. Falling back to 0.0
|
|
631
|
+
instead — which is what a bare ``ranking_score or 0.0`` does — sorts every
|
|
632
|
+
result that no bias touched as if it scored nothing, collapsing the whole
|
|
633
|
+
un-adjusted tail to the bottom in fact_id order and discarding the ordering
|
|
634
|
+
retrieval worked for. ``ensemble_rerank`` populates ``ranking_score`` on
|
|
635
|
+
every candidate, so that only bites when the ensemble is disabled or has
|
|
636
|
+
fallen back — which is precisely when a bias must still behave.
|
|
637
|
+
|
|
638
|
+
One function rather than the same expression at each call site: two passes
|
|
639
|
+
that sort by different keys silently overwrite each other's ordering.
|
|
640
|
+
|
|
641
|
+
``is None`` rather than a falsy test, because 0.0 is a real score. A
|
|
642
|
+
cross-encoder that judges a candidate irrelevant returns exactly that, and
|
|
643
|
+
``x or y`` would then discard the model's verdict and rank the candidate on
|
|
644
|
+
its retrieval score instead — quietly promoting the thing the model just
|
|
645
|
+
ruled out.
|
|
646
|
+
"""
|
|
647
|
+
utility = result.ranking_score
|
|
648
|
+
if utility is None:
|
|
649
|
+
utility = result.score
|
|
650
|
+
if utility is None:
|
|
651
|
+
utility = 0.0
|
|
652
|
+
return (-float(utility), result.fact.fact_id)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _apply_outcome_bonus(
|
|
656
|
+
results: list, profile_id: str, memory_db_path: Any = None,
|
|
657
|
+
) -> list:
|
|
658
|
+
"""Nudge ranking by whether each memory has demonstrably helped before.
|
|
659
|
+
|
|
660
|
+
Bounded to ``pcos.MAX_BONUS`` (0.15) and weighted by how often the fact has
|
|
661
|
+
actually settled, so history breaks ties between similar-looking memories
|
|
662
|
+
and cannot drag a poor match to the top.
|
|
663
|
+
|
|
664
|
+
Order is recomputed here rather than left to the caller: a bonus that does
|
|
665
|
+
not reorder anything is a number nobody reads, which is the failure mode
|
|
666
|
+
this whole wave exists to fix.
|
|
667
|
+
|
|
668
|
+
Never raises. On a store where M045 has not run, ``fetch_scores`` returns
|
|
669
|
+
``{}`` and this is the identity function.
|
|
670
|
+
"""
|
|
671
|
+
if not results:
|
|
672
|
+
return results
|
|
673
|
+
try:
|
|
674
|
+
import sqlite3 as _sq
|
|
675
|
+
|
|
676
|
+
from superlocalmemory.infra.data_root import state_path
|
|
677
|
+
from superlocalmemory.learning.pcos import (
|
|
678
|
+
RECENT_TOPS,
|
|
679
|
+
bonus_for,
|
|
680
|
+
fetch_scores,
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
ids = [r.fact.fact_id for r in results if getattr(r, "fact", None)]
|
|
684
|
+
if not ids:
|
|
685
|
+
return results
|
|
686
|
+
# The store the RECALL used, not whichever one the data-root default
|
|
687
|
+
# resolves to. Hardcoding state_path("memory.db") worked on a default
|
|
688
|
+
# install and silently read another store's scores — or nothing at all —
|
|
689
|
+
# anywhere the engine was pointed elsewhere: a second data root, a test
|
|
690
|
+
# store, a config whose db_path diverges. Both fail modes are silent,
|
|
691
|
+
# because this function is fail-open by design.
|
|
692
|
+
db_file = Path(memory_db_path) if memory_db_path else state_path("memory.db")
|
|
693
|
+
conn = _sq.connect(f"file:{db_file}?mode=ro", uri=True, timeout=0.5)
|
|
694
|
+
try:
|
|
695
|
+
scores = fetch_scores(conn, profile_id, ids)
|
|
696
|
+
finally:
|
|
697
|
+
conn.close()
|
|
698
|
+
if not scores:
|
|
699
|
+
return results
|
|
700
|
+
|
|
701
|
+
adjusted = []
|
|
702
|
+
for r in results:
|
|
703
|
+
entry = scores.get(r.fact.fact_id)
|
|
704
|
+
if not entry:
|
|
705
|
+
adjusted.append(r)
|
|
706
|
+
continue
|
|
707
|
+
# A fact that has been winning first place lately stops EARNING the
|
|
708
|
+
# bonus. It keeps everything retrieval gave it — the alternative
|
|
709
|
+
# considered was a 10x demotion, which would bury a memory whose
|
|
710
|
+
# only offence is having been useful.
|
|
711
|
+
if RECENT_TOPS.capped(profile_id, r.fact.fact_id):
|
|
712
|
+
adjusted.append(r)
|
|
713
|
+
continue
|
|
714
|
+
delta = bonus_for(*entry)
|
|
715
|
+
if delta == 0.0:
|
|
716
|
+
adjusted.append(r)
|
|
717
|
+
continue
|
|
718
|
+
adjusted.append(replace(
|
|
719
|
+
r,
|
|
720
|
+
ranking_score=float(
|
|
721
|
+
r.ranking_score if r.ranking_score is not None else r.score
|
|
722
|
+
) + delta,
|
|
723
|
+
))
|
|
724
|
+
adjusted.sort(key=_rank_key)
|
|
725
|
+
if adjusted:
|
|
726
|
+
RECENT_TOPS.record_top(profile_id, adjusted[0].fact.fact_id)
|
|
727
|
+
return adjusted
|
|
728
|
+
except Exception as exc: # pragma: no cover — advisory, never fatal
|
|
729
|
+
logger.debug("outcome bonus skipped: %s", exc)
|
|
730
|
+
return results
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
#: Largest lift a memory can earn purely from having been in the last few
|
|
734
|
+
#: turns. Additive on ``ranking_score``, deliberately not a multiplier.
|
|
735
|
+
#:
|
|
736
|
+
#: A multiplier was the first design and it is scale-dependent, which makes the
|
|
737
|
+
#: same constant mean different things on two installs. Measured: with a
|
|
738
|
+
#: cross-encoder loaded, ranking scores land near [0, 1] and 1.5x is worth about
|
|
739
|
+
#: +0.45; with no reranker configured, raw fusion scores top out at
|
|
740
|
+
#: ``n_channels/(rrf_k+1)`` — 0.08 at k=60 — and the same 1.5x is worth +0.03.
|
|
741
|
+
#: One constant, a fifteen-fold difference in effect, decided by installed
|
|
742
|
+
#: optional components. An additive cap behaves the same everywhere.
|
|
743
|
+
#:
|
|
744
|
+
#: 0.20 sits just above the ceiling on the outcome bonus (0.15), so continuity
|
|
745
|
+
#: of attention outranks historical usefulness when the two disagree. Both are
|
|
746
|
+
#: bounded, so neither can drag a genuinely poor match to the top.
|
|
747
|
+
WM_MAX_BONUS = 0.20
|
|
748
|
+
|
|
749
|
+
#: Everything the ranking passes may add to one result, summed.
|
|
750
|
+
#:
|
|
751
|
+
#: Each pass caps its own contribution, which says nothing about the total —
|
|
752
|
+
#: and the total is what decides whether a genuinely better match can be
|
|
753
|
+
#: displaced. Two passes at 0.15 and 0.20 already reach 0.35; a third added
|
|
754
|
+
#: later would raise the ceiling with nothing to notice.
|
|
755
|
+
#:
|
|
756
|
+
#: Stated here so the invariant is one number rather than an emergent property
|
|
757
|
+
#: of however many passes exist, and asserted by test: a result ahead by more
|
|
758
|
+
#: than this cannot be overtaken by bias alone, and the sum of the individual
|
|
759
|
+
#: caps must not exceed it.
|
|
760
|
+
MAX_TOTAL_BIAS = 0.35
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _apply_working_memory_bias(
|
|
764
|
+
results: list, profile_id: str, session_id: str,
|
|
765
|
+
) -> list:
|
|
766
|
+
"""Lift memories this session was just looking at.
|
|
767
|
+
|
|
768
|
+
Retrieval still runs in full and this changes nothing about what was
|
|
769
|
+
retrieved — it reorders what came back. A memory absent from the working
|
|
770
|
+
set is untouched, so an empty working set is exactly the identity function
|
|
771
|
+
and the first turn of every session behaves as before.
|
|
772
|
+
|
|
773
|
+
Reads only in-process state: no query, no connection, no file. Deliberately
|
|
774
|
+
does not create a working set — a bias that ran on every recall would
|
|
775
|
+
otherwise register an entry for every session id that ever appears.
|
|
776
|
+
|
|
777
|
+
Never raises. Attention is advisory; a recall that cannot be biased is
|
|
778
|
+
still a correct recall.
|
|
779
|
+
|
|
780
|
+
WHAT OUTRANKS WHAT
|
|
781
|
+
------------------
|
|
782
|
+
This is not the last word on the order. An exact lexical hit is promoted to
|
|
783
|
+
the top after every learned layer, including this one, so the full precedence
|
|
784
|
+
is:
|
|
785
|
+
|
|
786
|
+
exact lexical match > continuity > demonstrated usefulness > retrieval
|
|
787
|
+
|
|
788
|
+
That ordering is deliberate — a memory containing the caller's exact words
|
|
789
|
+
should not sit behind a merely similar one — but it means a boosted memory
|
|
790
|
+
can be moved back off position 1 by a guard that runs later. Two independent
|
|
791
|
+
reviewers raised it as a bug, which is what an undocumented precedence looks
|
|
792
|
+
like from outside, so it is written down here rather than left to be
|
|
793
|
+
rediscovered.
|
|
794
|
+
"""
|
|
795
|
+
if not results or not is_conversation(session_id, profile_id):
|
|
796
|
+
return results
|
|
797
|
+
try:
|
|
798
|
+
from superlocalmemory.core.working_memory import peek
|
|
799
|
+
|
|
800
|
+
wm = peek(profile_id, session_id)
|
|
801
|
+
if wm is None:
|
|
802
|
+
return results
|
|
803
|
+
held = wm.boost_set()
|
|
804
|
+
if not held:
|
|
805
|
+
return results
|
|
806
|
+
|
|
807
|
+
adjusted = []
|
|
808
|
+
for r in results:
|
|
809
|
+
fact = getattr(r, "fact", None)
|
|
810
|
+
if fact is None or fact.fact_id not in held:
|
|
811
|
+
adjusted.append(r)
|
|
812
|
+
continue
|
|
813
|
+
adjusted.append(replace(
|
|
814
|
+
r,
|
|
815
|
+
ranking_score=float(
|
|
816
|
+
r.ranking_score if r.ranking_score is not None else r.score
|
|
817
|
+
) + WM_MAX_BONUS,
|
|
818
|
+
))
|
|
819
|
+
# Same ordering key as the outcome bonus, so the two compose instead of
|
|
820
|
+
# one silently overwriting the other's sort.
|
|
821
|
+
adjusted.sort(key=_rank_key)
|
|
822
|
+
return adjusted
|
|
823
|
+
except Exception as exc: # pragma: no cover — advisory, never fatal
|
|
824
|
+
logger.debug("working-memory bias skipped: %s", exc)
|
|
825
|
+
return results
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _resettle_shown_after_bias(
|
|
829
|
+
play_sink: dict, profile_id: str, results: list, shown_before: list[str],
|
|
830
|
+
) -> None:
|
|
831
|
+
"""Correct the play's evidence when the bias changed what was shown.
|
|
832
|
+
|
|
833
|
+
A play is settled later by asking whether anything downstream referenced one
|
|
834
|
+
of the memories this query surfaced. That question is answered against a
|
|
835
|
+
stored list, and the list was written before the continuity bias ran — so a
|
|
836
|
+
memory the bias lifted into the answer was absent from it, and an outcome
|
|
837
|
+
citing that memory settled nothing.
|
|
838
|
+
|
|
839
|
+
Rewritten only when the set actually differs, which is the uncommon case: a
|
|
840
|
+
cold session, or a warm one whose held memories were already at the top,
|
|
841
|
+
costs nothing. Never raises — a play whose evidence cannot be corrected
|
|
842
|
+
settles on the old list, which is the behaviour before this existed.
|
|
843
|
+
"""
|
|
844
|
+
play_id = play_sink.get("play_id")
|
|
845
|
+
learning_db = play_sink.get("learning_db")
|
|
846
|
+
if not play_id or not learning_db:
|
|
847
|
+
return
|
|
848
|
+
try:
|
|
849
|
+
from superlocalmemory.core.working_memory import ADMIT_TOP_N
|
|
850
|
+
from superlocalmemory.learning.bandit import ContextualBandit
|
|
851
|
+
|
|
852
|
+
shown_now = [
|
|
853
|
+
r.fact.fact_id for r in results[:ADMIT_TOP_N]
|
|
854
|
+
if getattr(r, "fact", None) is not None
|
|
855
|
+
]
|
|
856
|
+
if set(shown_now) == set(shown_before):
|
|
857
|
+
return
|
|
858
|
+
ContextualBandit(Path(learning_db), profile_id).record_shown(
|
|
859
|
+
play_id, shown_now,
|
|
860
|
+
)
|
|
861
|
+
except Exception as exc: # pragma: no cover — advisory
|
|
862
|
+
logger.debug("shown-set correction skipped: %s", exc)
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def _admit_to_working_memory(
|
|
866
|
+
results: list, profile_id: str, session_id: str,
|
|
867
|
+
) -> None:
|
|
868
|
+
"""Remember what this answer showed, so the next turn is not cold.
|
|
869
|
+
|
|
870
|
+
Runs last, on the order the caller will actually see. Admitting the
|
|
871
|
+
pre-rerank order would teach the session something it was never shown.
|
|
872
|
+
"""
|
|
873
|
+
if not results or not is_conversation(session_id, profile_id):
|
|
874
|
+
return
|
|
875
|
+
try:
|
|
876
|
+
from superlocalmemory.core.working_memory import ADMIT_TOP_N, get_or_create
|
|
877
|
+
|
|
878
|
+
shown = [
|
|
879
|
+
r.fact.fact_id for r in results[:ADMIT_TOP_N]
|
|
880
|
+
if getattr(r, "fact", None) is not None and r.fact.fact_id
|
|
881
|
+
]
|
|
882
|
+
if shown:
|
|
883
|
+
get_or_create(profile_id, session_id).admit(shown)
|
|
884
|
+
except Exception as exc: # pragma: no cover — advisory, never fatal
|
|
885
|
+
logger.debug("working-memory admit skipped: %s", exc)
|
|
886
|
+
|
|
887
|
+
|
|
608
888
|
def apply_v2_bandit_ensemble(
|
|
609
889
|
response: RecallResponse,
|
|
610
890
|
query: str,
|
|
@@ -613,8 +893,40 @@ def apply_v2_bandit_ensemble(
|
|
|
613
893
|
*,
|
|
614
894
|
learning_db_path: Any = None,
|
|
615
895
|
record_signals: bool = False,
|
|
896
|
+
record_plays: bool = True,
|
|
897
|
+
memory_db_path: Any = None,
|
|
898
|
+
play_sink: dict | None = None,
|
|
616
899
|
) -> RecallResponse:
|
|
617
|
-
"""Apply contextual bandit + optional LGBM ensemble rerank. Safe on error.
|
|
900
|
+
"""Apply contextual bandit + optional LGBM ensemble rerank. Safe on error.
|
|
901
|
+
|
|
902
|
+
``play_sink``, when given, receives the play this call recorded and the store
|
|
903
|
+
it went to. The caller needs both because the answer is not final here: a
|
|
904
|
+
continuity bias runs afterwards and can change which memories end up in
|
|
905
|
+
front of the user, and the play's evidence has to describe what was actually
|
|
906
|
+
shown. Caller-owned rather than returned on the response, matching how the
|
|
907
|
+
retrieval engine hands back its dropped-channel set.
|
|
908
|
+
|
|
909
|
+
``record_signals`` and ``record_plays`` are separate because they are
|
|
910
|
+
separate things, and conflating them is what stopped the bandit learning.
|
|
911
|
+
|
|
912
|
+
* A **play** is "the bandit chose arm X for this query". It carries no
|
|
913
|
+
reward — ``bandit_plays.reward`` stays NULL until an authenticated
|
|
914
|
+
outcome settles it. One INSERT.
|
|
915
|
+
* A **signal** is an exposure row per displayed fact, twenty per query. Those
|
|
916
|
+
are what inflated the ranking-phase counter 2,675x, and the enqueue also
|
|
917
|
+
writes canonical/learning state, which is the contention the comment
|
|
918
|
+
below describes.
|
|
919
|
+
|
|
920
|
+
``record_signals=False`` was hardcoded at the only caller on 2026-07-27
|
|
921
|
+
(commit ``cbf7929f``, release 3.8.6). That is the same day
|
|
922
|
+
``MAX(bandit_arms.last_played_at)`` stops. It took the play recording down
|
|
923
|
+
with the exposure enqueue, so nothing was ever written for the reward proxy
|
|
924
|
+
to settle, and 165 arms have sat at alpha == beta ever since.
|
|
925
|
+
|
|
926
|
+
Recording a play is not feedback. It is the ticket the settlement path
|
|
927
|
+
later resolves against evidence, and without it there is no learning loop
|
|
928
|
+
at all.
|
|
929
|
+
"""
|
|
618
930
|
import os as _os
|
|
619
931
|
|
|
620
932
|
if _os.environ.get("SLM_BANDIT_DISABLED", "0") == "1":
|
|
@@ -651,9 +963,13 @@ def apply_v2_bandit_ensemble(
|
|
|
651
963
|
"query_type": response.query_type,
|
|
652
964
|
"entity_count": entity_count,
|
|
653
965
|
}
|
|
966
|
+
# Record the play unless explicitly told not to. ``choose_readonly``
|
|
967
|
+
# samples the same arm from a read-only snapshot and returns
|
|
968
|
+
# ``play_id=None``, so taking that branch means this query can never be
|
|
969
|
+
# settled and the arm can never move off its prior.
|
|
654
970
|
choice = (
|
|
655
971
|
bandit.choose(context, query_id)
|
|
656
|
-
if
|
|
972
|
+
if record_plays
|
|
657
973
|
else bandit.choose_readonly(context)
|
|
658
974
|
)
|
|
659
975
|
|
|
@@ -688,10 +1004,42 @@ def apply_v2_bandit_ensemble(
|
|
|
688
1004
|
logger.debug("v2 bandit ensemble_rerank skipped: %s", exc)
|
|
689
1005
|
final_results = weighted
|
|
690
1006
|
|
|
1007
|
+
# --- 4b. outcome bonus -------------------------------------------
|
|
1008
|
+
# Applied AFTER the model score, never as a model feature. The wave
|
|
1009
|
+
# plan proposed adding "outcome_score" to FEATURE_NAMES for inference
|
|
1010
|
+
# and excluding it from training; that is a shape mismatch —
|
|
1011
|
+
# booster.predict needs the columns the model was trained on, and
|
|
1012
|
+
# features.py asserts len(FEATURE_NAMES) == FEATURE_DIM == 20 against a
|
|
1013
|
+
# live 20-feature model. Applying it here also makes it
|
|
1014
|
+
# true by construction: the model cannot learn from a signal it
|
|
1015
|
+
# never sees, so there is no self-reinforcing loop to exclude.
|
|
1016
|
+
final_results = _apply_outcome_bonus(
|
|
1017
|
+
final_results, profile_id, memory_db_path,
|
|
1018
|
+
)
|
|
1019
|
+
|
|
1020
|
+
# Give the play its evidence: which memories this query actually
|
|
1021
|
+
# surfaced, so the reward proxy can settle it from a downstream
|
|
1022
|
+
# reference instead of falling through to the neutral default. Written
|
|
1023
|
+
# after the rerank because that is when the shown order is final.
|
|
1024
|
+
if choice.play_id:
|
|
1025
|
+
try:
|
|
1026
|
+
bandit.record_shown(
|
|
1027
|
+
choice.play_id, [r.fact.fact_id for r in final_results[:5]],
|
|
1028
|
+
)
|
|
1029
|
+
except Exception as exc: # pragma: no cover — never break a recall
|
|
1030
|
+
logger.debug("v2 bandit record_shown skipped: %s", exc)
|
|
1031
|
+
if play_sink is not None:
|
|
1032
|
+
# So the caller can correct this record if the order changes
|
|
1033
|
+
# after this function returns.
|
|
1034
|
+
play_sink["play_id"] = choice.play_id
|
|
1035
|
+
play_sink["learning_db"] = str(db_path)
|
|
1036
|
+
|
|
691
1037
|
# Recall is a query. Implicit learning signals are deliberately
|
|
692
1038
|
# disabled on this path: even a non-blocking enqueue eventually writes
|
|
693
1039
|
# canonical/learning state and turns dashboard polling into contention.
|
|
694
1040
|
# An explicit feedback command owns durable learning signals instead.
|
|
1041
|
+
# NOTE: this gates the twenty-row exposure enqueue ONLY. The play above
|
|
1042
|
+
# is recorded regardless — see this function's docstring.
|
|
695
1043
|
if record_signals:
|
|
696
1044
|
try:
|
|
697
1045
|
top20 = final_results[:20]
|
|
@@ -768,6 +1116,7 @@ def run_recall(
|
|
|
768
1116
|
mode: Mode | None = None,
|
|
769
1117
|
limit: int = 20,
|
|
770
1118
|
agent_id: str = "unknown",
|
|
1119
|
+
session_id: str | None = None,
|
|
771
1120
|
*,
|
|
772
1121
|
config: SLMConfig,
|
|
773
1122
|
retrieval_engine: Any,
|
|
@@ -916,13 +1265,46 @@ def run_recall(
|
|
|
916
1265
|
import uuid as _uuid
|
|
917
1266
|
query_id = _uuid.uuid4().hex
|
|
918
1267
|
mode = _resolve_ranking_mode(_os.environ)
|
|
1268
|
+
play_sink: dict = {}
|
|
919
1269
|
response = apply_ranking(
|
|
920
1270
|
response, query, profile_id, query_id,
|
|
921
1271
|
config=config, pipeline_version=mode, record_signals=False,
|
|
1272
|
+
# the store THIS recall read from, so the outcome bonus
|
|
1273
|
+
# cannot resolve a different one
|
|
1274
|
+
memory_db_path=getattr(db, "db_path", None),
|
|
1275
|
+
play_sink=play_sink,
|
|
922
1276
|
)
|
|
923
1277
|
except Exception as exc:
|
|
924
1278
|
logger.debug("Ranking pipeline skipped: %s", exc)
|
|
925
1279
|
|
|
1280
|
+
# Continuity of attention, applied after every learned layer and outside
|
|
1281
|
+
# the ranking-version switch: a session's recent memories bias this answer
|
|
1282
|
+
# whether or not the bandit and the model are enabled, because they are
|
|
1283
|
+
# unrelated features and coupling them would make one disappear with the
|
|
1284
|
+
# other. In-process only — no query is added to the recall path.
|
|
1285
|
+
# Only an id that names a conversation drives continuity. The fronts invent
|
|
1286
|
+
# one per request (HTTP) or per client (MCP) for bookkeeping, and neither is
|
|
1287
|
+
# a conversation: the first would register a working set per dashboard
|
|
1288
|
+
# search and evict real ones, the second would pool unrelated clients into
|
|
1289
|
+
# a shared set. See core.session_identity.
|
|
1290
|
+
_sid = session_id if is_conversation(session_id, profile_id) else ""
|
|
1291
|
+
if _sid and response.results:
|
|
1292
|
+
from superlocalmemory.core.working_memory import ADMIT_TOP_N as _TOP
|
|
1293
|
+
|
|
1294
|
+
_shown_before = [
|
|
1295
|
+
r.fact.fact_id for r in response.results[:_TOP]
|
|
1296
|
+
if getattr(r, "fact", None) is not None
|
|
1297
|
+
]
|
|
1298
|
+
response.results = _apply_working_memory_bias(
|
|
1299
|
+
response.results, profile_id, _sid,
|
|
1300
|
+
)
|
|
1301
|
+
# The play was recorded with the order as it stood before the line
|
|
1302
|
+
# above. If that order changed, its evidence now describes an answer
|
|
1303
|
+
# nobody saw.
|
|
1304
|
+
_resettle_shown_after_bias(
|
|
1305
|
+
play_sink, profile_id, response.results, _shown_before,
|
|
1306
|
+
)
|
|
1307
|
+
|
|
926
1308
|
_preserve_exact_lexical_evidence(response, query)
|
|
927
1309
|
_mark("learning+ranking")
|
|
928
1310
|
# Deliberately no trust, Fisher, retention, lifecycle, popularity, or graph
|
|
@@ -936,5 +1318,10 @@ def run_recall(
|
|
|
936
1318
|
# can validate fact_ids observed in downstream tool output.
|
|
937
1319
|
_apply_markers_to_response(response)
|
|
938
1320
|
|
|
1321
|
+
# Last, on the order the caller receives, so the next turn of this session
|
|
1322
|
+
# starts from what was actually shown.
|
|
1323
|
+
if _sid:
|
|
1324
|
+
_admit_to_working_memory(response.results, profile_id, _sid)
|
|
1325
|
+
|
|
939
1326
|
_mark("TOTAL(fisher+trust+markers)")
|
|
940
1327
|
return response
|
|
@@ -106,9 +106,12 @@ def _handle_recall(
|
|
|
106
106
|
memory_map=memory_map,
|
|
107
107
|
per_fact_max=getattr(_rc, "recall_per_fact_max_chars", 2400),
|
|
108
108
|
total_max=getattr(_rc, "recall_total_max_chars", 12000),
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
#
|
|
109
|
+
# Markers only on session-bearing recalls: a marker exists to let a
|
|
110
|
+
# downstream reference be traced back to the recall that produced it,
|
|
111
|
+
# and only a session-bearing recall has something to trace back to.
|
|
112
|
+
# (An earlier comment here justified the condition by a
|
|
113
|
+
# ``pending_outcomes`` row that no code path writes; settlement reads
|
|
114
|
+
# the evidence recorded with the play instead.)
|
|
112
115
|
include_marker=bool(session_id),
|
|
113
116
|
)
|
|
114
117
|
return {
|