Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions src/superlocalmemory/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,33 @@ def as_dict(self) -> dict[str, float]:
}


# ---------------------------------------------------------------------------
# Scope Weights
# ---------------------------------------------------------------------------

@dataclass
class ScopeWeights:
"""RRF fusion weights for multi-scope retrieval.

Personal scope has highest weight (most relevant to current profile).
Shared scope has medium weight (team/group memories).
Global scope has lowest weight (public/common knowledge).
"""

personal: float = 1.0
shared: float = 0.7
global_: float = 0.5 # trailing underscore avoids Python keyword

def __post_init__(self) -> None:
for name in ("personal", "shared", "global_"):
val = getattr(self, name)
if val < 0:
raise ValueError(f"ScopeWeights values must be non-negative, got {name}={val}")

def as_dict(self) -> dict[str, float]:
return {"personal": self.personal, "shared": self.shared, "global": self.global_}


# ---------------------------------------------------------------------------
# Encoding Config
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -692,6 +719,7 @@ class SLMConfig:
embedding: EmbeddingConfig = field(default_factory=EmbeddingConfig)
llm: LLMConfig = field(default_factory=LLMConfig)
channel_weights: ChannelWeights = field(default_factory=ChannelWeights)
scope_weights: ScopeWeights = field(default_factory=ScopeWeights)
encoding: EncodingConfig = field(default_factory=EncodingConfig)
retrieval: RetrievalConfig = field(default_factory=RetrievalConfig)
math: MathConfig = field(default_factory=MathConfig)
Expand Down Expand Up @@ -835,6 +863,14 @@ def load(cls, config_path: Path | None = None) -> SLMConfig:
prestage_max_response_bytes=int(inj.get("prestage_max_response_bytes", 64 * 1024)),
)

# Multi-scope memory: scope weights
sw = data.get("scope_weights", {})
if sw:
config.scope_weights = ScopeWeights(**{
k: v for k, v in sw.items()
if k in ScopeWeights.__dataclass_fields__
})

return config

def save(
Expand Down Expand Up @@ -927,6 +963,13 @@ def save(
"prestage_max_response_bytes": self.injection.prestage_max_response_bytes,
}

# Multi-scope memory: scope weights
data["scope_weights"] = {
"personal": self.scope_weights.personal,
"shared": self.scope_weights.shared,
"global_": self.scope_weights.global_,
}

# Preserve existing V3.3 config sections that aren't in for_mode()
for key in ("forgetting", "quantization", "sagq", "embedding_signature", "auto_invoke"):
if key in existing:
Expand Down
7 changes: 7 additions & 0 deletions src/superlocalmemory/core/recall_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -587,9 +587,14 @@ def run_recall(
access_log: Any = None,
auto_linker: Any = None,
fast: bool = False,
include_global: bool = True,
include_shared: bool = True,
) -> RecallResponse:
"""Recall relevant facts for a query.

Multi-scope: ``include_global`` / ``include_shared`` control which
scopes participate in retrieval (passed through to retrieval engine).

Pipeline: retrieval -> agentic sufficiency (if configured) -> post-recall updates.

V3.4.40: ``fast=True`` adds spreading_activation to the per-recall
Expand Down Expand Up @@ -623,6 +628,8 @@ def _mark(_label: str) -> None:
response = retrieval_engine.recall(
query, profile_id, m, limit,
extra_disabled_channels=extra_disabled,
include_global=include_global,
include_shared=include_shared,
)
_mark("retrieval(chan+rerank)")

Expand Down
14 changes: 12 additions & 2 deletions src/superlocalmemory/retrieval/bm25_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,15 @@ def ensure_loaded(self, profile_id: str) -> None:
return

token_map = self._db.get_all_bm25_tokens(profile_id)
_inc_global = getattr(self, 'include_global', True)
_inc_shared = getattr(self, 'include_shared', True)
if not token_map:
# Fallback: tokenize facts directly if no pre-stored tokens
facts = self._db.get_all_facts(profile_id)
facts = self._db.get_all_facts(
profile_id,
include_global=_inc_global,
include_shared=_inc_shared,
)
for fact in facts:
if fact.fact_id in self._fact_id_set:
continue
Expand All @@ -104,7 +110,11 @@ def ensure_loaded(self, profile_id: str) -> None:
# Load raw texts for phrase matching (V3.3.12)
fact_content_map = {}
try:
facts = self._db.get_all_facts(profile_id)
facts = self._db.get_all_facts(
profile_id,
include_global=_inc_global,
include_shared=_inc_shared,
)
fact_content_map = {f.fact_id: f.content for f in facts}
except Exception:
pass
Expand Down
21 changes: 20 additions & 1 deletion src/superlocalmemory/retrieval/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,16 +117,31 @@ def recall(
mode: Mode = Mode.A, limit: int = 20,
*,
extra_disabled_channels: set[str] | None = None,
include_global: bool = True,
include_shared: bool = True,
) -> RecallResponse:
"""Full retrieval pipeline: strategy -> channels -> RRF -> rerank.

Multi-scope: ``include_global`` / ``include_shared`` control which
scopes participate in retrieval. Both default to True for backward
compatibility (existing data has scope='personal' — no effect).

V3.4.40 (2026-05-09): ``extra_disabled_channels`` allows callers to
skip specific channels for a single recall (e.g. SpreadingActivation
for the ``--fast`` CLI flag) without mutating shared config.
"""
t0 = time.monotonic()
self._extra_disabled = set(extra_disabled_channels or ())

# Multi-scope: set scope flags on channels before parallel execution.
for ch in (self._semantic, self._bm25, self._entity, self._temporal,
self._hopfield, self._spreading_activation, self._profile_channel):
if ch is not None:
ch.include_global = include_global
ch.include_shared = include_shared
self._include_global = include_global
self._include_shared = include_shared

# v3.5.0 diagnostic: stage timing inside retrieval (SLM_RECALL_TIMING=1).
import os as _os_e
import time as _time_e
Expand Down Expand Up @@ -657,7 +672,11 @@ def _load_facts(
needed = [fr.fact_id for fr in fused]
if not needed:
return {}
facts = self._db.get_facts_by_ids(needed, profile_id)
facts = self._db.get_facts_by_ids(
needed, profile_id,
include_global=getattr(self, '_include_global', True),
include_shared=getattr(self, '_include_shared', True),
)
return {f.fact_id: f for f in facts}

# -- Cross-encoder rerank -----------------------------------------------
Expand Down
10 changes: 5 additions & 5 deletions src/superlocalmemory/retrieval/entity_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ def search(self, query: str, profile_id: str, top_k: int = 50) -> list[tuple[str
for fid in self._entity_to_facts.get(eid, ()):
activation[fid] = max(activation[fid], 1.0)
else:
for fact in self._db.get_facts_by_entity(eid, profile_id):
for fact in self._db.get_facts_by_entity(eid, profile_id, include_global=getattr(self, 'include_global', True), include_shared=getattr(self, 'include_shared', True)):
activation[fact.fact_id] = max(activation[fact.fact_id], 1.0)

# Spreading activation through graph edges (all in-memory O(1) lookups)
Expand Down Expand Up @@ -317,7 +317,7 @@ def search(self, query: str, profile_id: str, top_k: int = 50) -> list[tuple[str
# NOTE: SQL fallback path does NOT use graph intelligence (P1/P2/P3).
# Graph intelligence is only available on the in-memory cache path.
# This fallback exists for mock/test DBs. See Phase 7 LLD H-01.
for edge in self._db.get_edges_for_node(fid, profile_id):
for edge in self._db.get_edges_for_node(fid, profile_id, include_global=getattr(self, 'include_global', True), include_shared=getattr(self, 'include_shared', True)):
neighbor = edge.target_id if edge.source_id == fid else edge.source_id
propagated = activation[fid] * self._decay
if propagated >= self._threshold and propagated > activation.get(neighbor, 0.0):
Expand All @@ -342,7 +342,7 @@ def search(self, query: str, profile_id: str, top_k: int = 50) -> list[tuple[str
new_eids_sql = self._discover_entities(frontier, profile_id, visited_entities)
for eid in new_eids_sql:
visited_entities.add(eid)
for fact in self._db.get_facts_by_entity(eid, profile_id):
for fact in self._db.get_facts_by_entity(eid, profile_id, include_global=getattr(self, 'include_global', True), include_shared=getattr(self, 'include_shared', True)):
if hop_decay > activation.get(fact.fact_id, 0.0):
activation[fact.fact_id] = hop_decay
next_frontier.add(fact.fact_id)
Expand Down Expand Up @@ -438,7 +438,7 @@ def score_candidates(
for fid in self._entity_to_facts.get(eid, ()):
activation[fid] = max(activation[fid], 1.0)
else:
for fact in self._db.get_facts_by_entity(eid, profile_id):
for fact in self._db.get_facts_by_entity(eid, profile_id, include_global=getattr(self, 'include_global', True), include_shared=getattr(self, 'include_shared', True)):
activation[fact.fact_id] = max(activation[fact.fact_id], 1.0)

frontier = set(activation.keys())
Expand Down Expand Up @@ -628,7 +628,7 @@ def _search_via_cozo(
# Map entity scores to fact scores
fact_scores: list[tuple[str, float]] = []
for entity_id, score in scored:
facts = self._db.get_facts_by_entity(entity_id, profile_id)
facts = self._db.get_facts_by_entity(entity_id, profile_id, include_global=getattr(self, 'include_global', True), include_shared=getattr(self, 'include_shared', True))
for fact in facts:
fact_scores.append((fact.fact_id, score))

Expand Down
12 changes: 10 additions & 2 deletions src/superlocalmemory/retrieval/hopfield_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,11 @@ def _search_with_prefilter(

# Stage 2: Load candidate facts
candidate_ids = [fid for fid, _ in knn_results]
candidates = self._db.get_facts_by_ids(candidate_ids, profile_id)
candidates = self._db.get_facts_by_ids(
candidate_ids, profile_id,
include_global=getattr(self, 'include_global', True),
include_shared=getattr(self, 'include_shared', True),
)
if not candidates:
return []

Expand Down Expand Up @@ -304,7 +308,11 @@ def _get_memory_matrix(
# Step 2: Load facts (V3.3.12: cap to most recent 5000 to bound memory)
# memory-bounding-02: push the cap into SQL (LIMIT) so we don't
# deserialize the whole table just to slice it.
facts = self._db.get_all_facts(profile_id, limit=5000)
facts = self._db.get_all_facts(
profile_id, limit=5000,
include_global=getattr(self, 'include_global', True),
include_shared=getattr(self, 'include_shared', True),
)
if not facts:
return (None, [])

Expand Down
12 changes: 10 additions & 2 deletions src/superlocalmemory/retrieval/semantic_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,11 @@ def _search_via_vector_store(
# Step 2: Load only the candidate facts (NOT all facts)
candidate_ids = [fid for fid, _ in knn_results]
knn_scores = {fid: score for fid, score in knn_results}
facts = self._db.get_facts_by_ids(candidate_ids, profile_id)
facts = self._db.get_facts_by_ids(
candidate_ids, profile_id,
include_global=getattr(self, 'include_global', True),
include_shared=getattr(self, 'include_shared', True),
)

if not facts:
return [(fid, score) for fid, score in knn_results[:top_k]]
Expand Down Expand Up @@ -230,7 +234,11 @@ def _search_full_scan(
q_mean = np.array(qm, dtype=np.float32)
q_var = np.array(qv, dtype=np.float32)

facts = self._db.get_all_facts(profile_id)
facts = self._db.get_all_facts(
profile_id,
include_global=getattr(self, 'include_global', True),
include_shared=getattr(self, 'include_shared', True),
)

scored: list[tuple[str, float]] = []
for fact in facts:
Expand Down
Loading