superlocalmemory 4.0.10 → 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 +170 -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 +263 -18
- 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 +24 -2
- 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/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_scheduler.py +205 -0
- package/src/superlocalmemory/core/mode_capability.py +111 -0
- package/src/superlocalmemory/core/ollama_validator.py +315 -0
- 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/worker_pool.py +4 -4
- package/src/superlocalmemory/core/working_memory.py +288 -0
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +36 -6
- 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/tools_active.py +2 -1
- package/src/superlocalmemory/mcp/tools_core.py +31 -3
- 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 +64 -3
- package/src/superlocalmemory/retrieval/channel_status.py +117 -0
- package/src/superlocalmemory/retrieval/engine.py +106 -11
- package/src/superlocalmemory/retrieval/entity_channel.py +210 -256
- package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
- package/src/superlocalmemory/retrieval/scope_policy.py +20 -0
- package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
- package/src/superlocalmemory/retrieval/spreading.py +288 -0
- package/src/superlocalmemory/server/api.py +24 -5
- 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/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/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/timeline.py +4 -0
- package/src/superlocalmemory/server/routes/v3_api.py +191 -15
- package/src/superlocalmemory/server/ui.py +20 -4
- package/src/superlocalmemory/server/unified_daemon.py +186 -5
- package/src/superlocalmemory/storage/_migration_internals.py +31 -0
- package/src/superlocalmemory/storage/_schema_version.py +24 -3
- package/src/superlocalmemory/storage/database.py +477 -59
- 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 +119 -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 +12 -1
- package/src/superlocalmemory/storage/write_coordinator.py +19 -2
- package/src/superlocalmemory/trust/scorer.py +43 -1
- package/src/superlocalmemory/ui/index.html +9 -18
- 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 +19 -0
- package/src/superlocalmemory/ui/js/od-settings.js +87 -1
- package/src/superlocalmemory/ui/js/recall-lab.js +78 -3
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""Ask the local model server whether a model exists, before trusting it.
|
|
6
|
+
|
|
7
|
+
Mode B runs on Ollama, and a user picking a model there picks it by typing a
|
|
8
|
+
name. Two things then go wrong silently.
|
|
9
|
+
|
|
10
|
+
**The name is wrong.** Nothing happens at the moment of the mistake; the config
|
|
11
|
+
saves, the daemon starts, and every write quietly falls back to a worse path.
|
|
12
|
+
The error a user eventually sees is unrelated to what they did.
|
|
13
|
+
|
|
14
|
+
**The name is right and the shape is different.** Every embedding model emits a
|
|
15
|
+
vector of a fixed width. `nomic-embed-text` emits 768 numbers,
|
|
16
|
+
`mxbai-embed-large` emits 1024. Vectors of different widths cannot be compared,
|
|
17
|
+
so a store holding both answers similarity questions with noise — and it does so
|
|
18
|
+
without failing, because nothing in a similarity search knows the difference
|
|
19
|
+
between a bad answer and a good one. **This is the single silent-and-catastrophic
|
|
20
|
+
change a user can make**, so it is refused rather than warned about, and the
|
|
21
|
+
refusal says the one command that would make it safe.
|
|
22
|
+
|
|
23
|
+
There are two Ollama roles and they are separate models with separate names:
|
|
24
|
+
the embedding model (`embedding.ollama_model`) turns text into vectors, and the
|
|
25
|
+
generation model (`llm.model` when the provider is Ollama) writes summaries.
|
|
26
|
+
They are validated independently because a machine can perfectly well have one
|
|
27
|
+
and not the other.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import logging
|
|
33
|
+
import sqlite3
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"EMBEDDING",
|
|
39
|
+
"GENERATION",
|
|
40
|
+
"OllamaProbe",
|
|
41
|
+
"DimensionChange",
|
|
42
|
+
"validate_ollama_model",
|
|
43
|
+
"stored_embedding_dimension",
|
|
44
|
+
"check_embedding_model_change",
|
|
45
|
+
"same_embedding_model",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
logger = logging.getLogger(__name__)
|
|
49
|
+
|
|
50
|
+
EMBEDDING = "embedding"
|
|
51
|
+
GENERATION = "generation"
|
|
52
|
+
|
|
53
|
+
DEFAULT_BASE_URL = "http://localhost:11434"
|
|
54
|
+
|
|
55
|
+
#: Long enough for a cold model load, short enough that a wedged server is not
|
|
56
|
+
#: mistaken for a slow one.
|
|
57
|
+
_CONNECT_TIMEOUT = 3.0
|
|
58
|
+
_RESPONSE_TIMEOUT = 60.0
|
|
59
|
+
|
|
60
|
+
_PROBE_TEXT = "superlocalmemory model probe"
|
|
61
|
+
_PROBE_PROMPT = "Reply with the single word: ok"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class OllamaProbe:
|
|
66
|
+
"""What the server said when asked about one model."""
|
|
67
|
+
|
|
68
|
+
ok: bool
|
|
69
|
+
message: str
|
|
70
|
+
dimension: int | None = None
|
|
71
|
+
|
|
72
|
+
def __bool__(self) -> bool: # pragma: no cover - convenience only
|
|
73
|
+
return self.ok
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class DimensionChange:
|
|
78
|
+
"""A model change that would put two vector widths in one store."""
|
|
79
|
+
|
|
80
|
+
allowed: bool
|
|
81
|
+
message: str
|
|
82
|
+
stored_dimension: int | None = None
|
|
83
|
+
new_dimension: int | None = None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def same_embedding_model(left: str, right: str) -> bool:
|
|
87
|
+
"""True when two names are two spellings of one model.
|
|
88
|
+
|
|
89
|
+
``nomic-ai/nomic-embed-text-v1.5`` is the HuggingFace name and
|
|
90
|
+
``nomic-embed-text`` is the Ollama pull name for the same weights. Warning
|
|
91
|
+
about a dimension change between them would be a false alarm, and a false
|
|
92
|
+
alarm about the one thing that is genuinely dangerous is how a real warning
|
|
93
|
+
gets ignored.
|
|
94
|
+
"""
|
|
95
|
+
def base(name: str) -> str:
|
|
96
|
+
stem = name.strip().lower().rsplit("/", 1)[-1]
|
|
97
|
+
# An Ollama tag is PART OF THE IDENTITY, not packaging.
|
|
98
|
+
# ``qwen3-embedding:0.6b`` emits 1024 numbers and ``qwen3-embedding:8b``
|
|
99
|
+
# emits 4096, and stripping the tag made this function call them the
|
|
100
|
+
# same model — which then skipped the width check that exists to stop
|
|
101
|
+
# exactly that swap. Only ``:latest`` means "whatever the bare name
|
|
102
|
+
# means", so only that one is dropped.
|
|
103
|
+
if stem.endswith(":latest"):
|
|
104
|
+
stem = stem[: -len(":latest")]
|
|
105
|
+
# A HuggingFace revision suffix on an otherwise identical name is
|
|
106
|
+
# packaging; ``nomic-embed-text-v1.5`` and ``nomic-embed-text`` are the
|
|
107
|
+
# same weights under two registries' conventions.
|
|
108
|
+
if ":" not in stem:
|
|
109
|
+
for suffix in ("-v1.5", "-v1", "-v2"):
|
|
110
|
+
if stem.endswith(suffix):
|
|
111
|
+
stem = stem[: -len(suffix)]
|
|
112
|
+
return stem
|
|
113
|
+
|
|
114
|
+
return base(left) == base(right)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def validate_ollama_model(
|
|
118
|
+
model_name: str,
|
|
119
|
+
role: str = EMBEDDING,
|
|
120
|
+
*,
|
|
121
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
122
|
+
timeout: float = _RESPONSE_TIMEOUT,
|
|
123
|
+
) -> OllamaProbe:
|
|
124
|
+
"""Ask the server to actually use the model, and report what happened.
|
|
125
|
+
|
|
126
|
+
Listing the installed models is not enough: a name can be present and the
|
|
127
|
+
model still fail to load. So this runs the smallest real request for the
|
|
128
|
+
role and reads the answer, which is also the only way to learn an embedding
|
|
129
|
+
model's width.
|
|
130
|
+
"""
|
|
131
|
+
if role not in (EMBEDDING, GENERATION):
|
|
132
|
+
raise ValueError(f"role must be {EMBEDDING!r} or {GENERATION!r}, got {role!r}")
|
|
133
|
+
name = (model_name or "").strip()
|
|
134
|
+
if not name:
|
|
135
|
+
return OllamaProbe(False, "No model name given.")
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
import httpx
|
|
139
|
+
except ImportError: # pragma: no cover - httpx is a hard dependency
|
|
140
|
+
return OllamaProbe(False, "httpx is not installed, so Ollama cannot be reached.")
|
|
141
|
+
|
|
142
|
+
url = base_url.rstrip("/")
|
|
143
|
+
request_timeout = httpx.Timeout(timeout, connect=_CONNECT_TIMEOUT)
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
if role == EMBEDDING:
|
|
147
|
+
response = httpx.post(
|
|
148
|
+
f"{url}/api/embed",
|
|
149
|
+
json={"model": name, "input": [_PROBE_TEXT]},
|
|
150
|
+
timeout=request_timeout,
|
|
151
|
+
)
|
|
152
|
+
else:
|
|
153
|
+
response = httpx.post(
|
|
154
|
+
f"{url}/api/generate",
|
|
155
|
+
json={"model": name, "prompt": _PROBE_PROMPT, "stream": False},
|
|
156
|
+
timeout=request_timeout,
|
|
157
|
+
)
|
|
158
|
+
except httpx.ConnectError:
|
|
159
|
+
return OllamaProbe(
|
|
160
|
+
False,
|
|
161
|
+
f"Ollama is not running at {url}. Start it with: ollama serve",
|
|
162
|
+
)
|
|
163
|
+
except httpx.TimeoutException:
|
|
164
|
+
return OllamaProbe(
|
|
165
|
+
False,
|
|
166
|
+
f"Ollama did not answer within {timeout:.0f}s. The model may still be "
|
|
167
|
+
f"downloading — check with: ollama list",
|
|
168
|
+
)
|
|
169
|
+
except Exception as exc: # noqa: BLE001 - the message is the product here
|
|
170
|
+
return OllamaProbe(False, f"Could not reach Ollama at {url}: {exc}")
|
|
171
|
+
|
|
172
|
+
if response.status_code == 404 or _looks_missing(response):
|
|
173
|
+
return OllamaProbe(
|
|
174
|
+
False,
|
|
175
|
+
f"Ollama has no model called {name!r}. Run: ollama pull {name}",
|
|
176
|
+
)
|
|
177
|
+
if role == EMBEDDING and _refuses_embeddings(response):
|
|
178
|
+
return OllamaProbe(
|
|
179
|
+
False,
|
|
180
|
+
f"{name!r} is not an embedding model — the server refused the "
|
|
181
|
+
f"request. Pick a model built for embeddings, such as "
|
|
182
|
+
f"nomic-embed-text.",
|
|
183
|
+
)
|
|
184
|
+
if response.status_code != 200:
|
|
185
|
+
return OllamaProbe(
|
|
186
|
+
False,
|
|
187
|
+
f"Ollama answered {response.status_code} for {name!r}: "
|
|
188
|
+
f"{response.text[:200]}",
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
payload = response.json()
|
|
193
|
+
except ValueError:
|
|
194
|
+
return OllamaProbe(False, f"Ollama returned something that is not JSON for {name!r}.")
|
|
195
|
+
|
|
196
|
+
if role == EMBEDDING:
|
|
197
|
+
vectors = payload.get("embeddings") or []
|
|
198
|
+
if not vectors or not isinstance(vectors[0], list) or not vectors[0]:
|
|
199
|
+
return OllamaProbe(
|
|
200
|
+
False,
|
|
201
|
+
f"{name!r} answered but returned no vector, so it is not an "
|
|
202
|
+
f"embedding model. Pick one built for embeddings, such as "
|
|
203
|
+
f"nomic-embed-text.",
|
|
204
|
+
)
|
|
205
|
+
width = len(vectors[0])
|
|
206
|
+
return OllamaProbe(True, f"{name} emits {width}-dimensional vectors.", width)
|
|
207
|
+
|
|
208
|
+
text = str(payload.get("response") or "").strip()
|
|
209
|
+
if not text:
|
|
210
|
+
return OllamaProbe(
|
|
211
|
+
False,
|
|
212
|
+
f"{name!r} answered but wrote nothing, so it cannot be used to "
|
|
213
|
+
f"generate summaries.",
|
|
214
|
+
)
|
|
215
|
+
return OllamaProbe(True, f"{name} answered a probe prompt.")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _refuses_embeddings(response: object) -> bool:
|
|
219
|
+
"""A generation-only model answers an embedding request with a refusal.
|
|
220
|
+
|
|
221
|
+
The server's own wording is about its build flags and means nothing to
|
|
222
|
+
somebody who typed a chat model's name into an embedding field.
|
|
223
|
+
"""
|
|
224
|
+
status = getattr(response, "status_code", 0)
|
|
225
|
+
try:
|
|
226
|
+
lowered = response.text.lower() # type: ignore[attr-defined]
|
|
227
|
+
except Exception: # pragma: no cover - defensive
|
|
228
|
+
lowered = ""
|
|
229
|
+
if status == 501:
|
|
230
|
+
return True
|
|
231
|
+
return "does not support embed" in lowered or "not support embeddings" in lowered
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _looks_missing(response: object) -> bool:
|
|
235
|
+
"""Ollama reports an unknown model as a 400 with a body that says so."""
|
|
236
|
+
try:
|
|
237
|
+
body = response.text # type: ignore[attr-defined]
|
|
238
|
+
except Exception: # pragma: no cover - defensive
|
|
239
|
+
return False
|
|
240
|
+
lowered = body.lower()
|
|
241
|
+
return "not found" in lowered and "model" in lowered
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def stored_embedding_dimension(db_path: str | Path) -> int | None:
|
|
245
|
+
"""The vector width this store already holds, or None if it holds none."""
|
|
246
|
+
path = Path(db_path)
|
|
247
|
+
if not path.exists():
|
|
248
|
+
return None
|
|
249
|
+
try:
|
|
250
|
+
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
|
251
|
+
except sqlite3.Error:
|
|
252
|
+
return None
|
|
253
|
+
try:
|
|
254
|
+
row = conn.execute(
|
|
255
|
+
"SELECT dimension FROM embedding_metadata "
|
|
256
|
+
"WHERE dimension IS NOT NULL AND dimension > 0 LIMIT 1"
|
|
257
|
+
).fetchone()
|
|
258
|
+
except sqlite3.Error:
|
|
259
|
+
return None
|
|
260
|
+
finally:
|
|
261
|
+
conn.close()
|
|
262
|
+
return int(row[0]) if row else None
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def check_embedding_model_change(
|
|
266
|
+
new_model: str,
|
|
267
|
+
*,
|
|
268
|
+
db_path: str | Path,
|
|
269
|
+
current_model: str = "",
|
|
270
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
271
|
+
) -> DimensionChange:
|
|
272
|
+
"""Decide whether switching the embedding model is safe for this store.
|
|
273
|
+
|
|
274
|
+
Refuses rather than warns. A warning printed into a log is not a decision,
|
|
275
|
+
and the outcome of getting this wrong is a store whose similarity search is
|
|
276
|
+
quietly meaningless.
|
|
277
|
+
"""
|
|
278
|
+
# The width is asked for FIRST, always. Recognising two names as the same
|
|
279
|
+
# model may soften the message, but it must never stand in for measuring —
|
|
280
|
+
# a name that merely looks familiar is exactly how a different-width model
|
|
281
|
+
# would get through.
|
|
282
|
+
probe = validate_ollama_model(new_model, EMBEDDING, base_url=base_url)
|
|
283
|
+
if not probe.ok:
|
|
284
|
+
return DimensionChange(False, probe.message)
|
|
285
|
+
|
|
286
|
+
familiar = bool(current_model) and same_embedding_model(current_model, new_model)
|
|
287
|
+
stored = stored_embedding_dimension(db_path)
|
|
288
|
+
if familiar and (stored is None or stored == probe.dimension):
|
|
289
|
+
return DimensionChange(
|
|
290
|
+
True,
|
|
291
|
+
f"{new_model} is another name for the model already in use, and it "
|
|
292
|
+
f"emits the same {probe.dimension}-dimensional vectors.",
|
|
293
|
+
stored,
|
|
294
|
+
probe.dimension,
|
|
295
|
+
)
|
|
296
|
+
if stored is None:
|
|
297
|
+
return DimensionChange(
|
|
298
|
+
True,
|
|
299
|
+
f"{probe.message} This store holds no vectors yet, so nothing has to "
|
|
300
|
+
f"be rebuilt.",
|
|
301
|
+
None,
|
|
302
|
+
probe.dimension,
|
|
303
|
+
)
|
|
304
|
+
if stored == probe.dimension:
|
|
305
|
+
return DimensionChange(True, probe.message, stored, probe.dimension)
|
|
306
|
+
|
|
307
|
+
return DimensionChange(
|
|
308
|
+
False,
|
|
309
|
+
f"{new_model} emits {probe.dimension}-dimensional vectors and this store "
|
|
310
|
+
f"holds {stored}-dimensional ones. Vectors of different widths cannot be "
|
|
311
|
+
f"compared, so every memory already stored would become unfindable by "
|
|
312
|
+
f"meaning. Rebuild them first with: slm db migrate",
|
|
313
|
+
stored,
|
|
314
|
+
probe.dimension,
|
|
315
|
+
)
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""The worker that carries queued facts into CozoDB and LanceDB.
|
|
6
|
+
|
|
7
|
+
One writer, one direction: SQLite is canonical, the projections are derived, and
|
|
8
|
+
this is the only thing that writes them during normal operation. Making it the
|
|
9
|
+
sole writer is what removes the class of bug this replaced — a projection write
|
|
10
|
+
attempted inline on the store path, on whichever thread happened to be storing,
|
|
11
|
+
with its failure swallowed into a debug log.
|
|
12
|
+
|
|
13
|
+
WHAT IT PROJECTS IS WHAT RECALL CAN RETURN
|
|
14
|
+
------------------------------------------
|
|
15
|
+
Not "lifecycle in (active, warm)", which is what the inline sync used. That
|
|
16
|
+
predicate was wrong twice over: it dropped ``cold``, which is a live tier that
|
|
17
|
+
recall answers from, and it never withdrew a fact that had since been archived,
|
|
18
|
+
leaving forgotten memories offered as candidates.
|
|
19
|
+
|
|
20
|
+
The filter is ``visible_fact_clause()`` — the same predicate every read path
|
|
21
|
+
uses to decide whether a row may be shown as a memory at all. Deriving the
|
|
22
|
+
projection from that clause means the two cannot drift.
|
|
23
|
+
|
|
24
|
+
BUT VISIBILITY GOVERNS CANDIDACY, NOT ADJACENCY
|
|
25
|
+
-----------------------------------------------
|
|
26
|
+
The SQLite channel does not treat those as one thing, and neither may this. Its
|
|
27
|
+
entity map filters on visibility — a withheld row must never enter it, because
|
|
28
|
+
it carries its whole cluster's pooled entity list and out-ranks real memories.
|
|
29
|
+
Its edge walk filters on scope alone, with no visibility predicate, so it
|
|
30
|
+
traverses edges into withheld and archived facts and lets hydration drop them at
|
|
31
|
+
the end.
|
|
32
|
+
|
|
33
|
+
So a hidden fact loses its entity bridge and its vector and KEEPS its edges. An
|
|
34
|
+
earlier version of this module deleted the edges too, and on a real store that
|
|
35
|
+
left 32 visible facts with a smaller adjacency here than in SQLite — every
|
|
36
|
+
missing endpoint quarantined. A graph that answers differently from the walk it
|
|
37
|
+
replaces is not a projection of it.
|
|
38
|
+
|
|
39
|
+
FAILURE IS LOUD
|
|
40
|
+
---------------
|
|
41
|
+
A projection write that raises leaves its row queued with the attempt counted
|
|
42
|
+
and the error recorded. Nothing is dropped, and the queue depth is a health
|
|
43
|
+
metric, so a projection that has stopped keeping up is visible instead of
|
|
44
|
+
silent. That is the entire point of the mechanism and it is why nothing in this
|
|
45
|
+
module catches an exception and continues as though it had not happened.
|
|
46
|
+
|
|
47
|
+
AFTER A BULK IMPORT, THE QUEUE IS NOT CLEARED — IT IS DRAINED
|
|
48
|
+
-------------------------------------------------------------
|
|
49
|
+
A promotion builds the whole projection from SQLite in one pass, which satisfies
|
|
50
|
+
every row queued before it started. Deleting those rows on that basis would need
|
|
51
|
+
a watermark: a timestamp taken before the import read its snapshot, with
|
|
52
|
+
everything older discarded. That is one off-by-one away from throwing out a
|
|
53
|
+
projection nobody will ever write again, in the exact mechanism whose only job
|
|
54
|
+
is to make that impossible.
|
|
55
|
+
|
|
56
|
+
So nothing is discarded. The worker re-projects the backlog, which is idempotent
|
|
57
|
+
and lands on the same graph. It costs the import's work once more, in the
|
|
58
|
+
background, off the hot path — a price worth paying for a rule with no edge case
|
|
59
|
+
in it. Queue depth right after a promotion is therefore high and falling, which
|
|
60
|
+
is the truth; ``stalled`` is the number that indicates trouble.
|
|
61
|
+
|
|
62
|
+
A ROW THAT KEEPS FAILING MUST NOT BLOCK THE ONES BEHIND IT
|
|
63
|
+
----------------------------------------------------------
|
|
64
|
+
The queue is claimed in ``attempts, revision`` order, so a fact whose
|
|
65
|
+
projection is genuinely impossible — a malformed embedding, an id Cozo refuses
|
|
66
|
+
— sinks to the back after its first failure and healthy work continues past it.
|
|
67
|
+
Ordering by revision alone would let one poisoned row starve every fact behind
|
|
68
|
+
it, which is the failure mode that makes queues look like outages.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
from __future__ import annotations
|
|
72
|
+
|
|
73
|
+
import logging
|
|
74
|
+
import threading
|
|
75
|
+
from dataclasses import dataclass, field
|
|
76
|
+
from typing import Any, Callable
|
|
77
|
+
|
|
78
|
+
from superlocalmemory.storage import projection_outbox
|
|
79
|
+
|
|
80
|
+
logger = logging.getLogger(__name__)
|
|
81
|
+
|
|
82
|
+
#: Rows per pass. Large enough that a backlog clears in few passes, small enough
|
|
83
|
+
#: that a pass cannot hold the drain thread past a shutdown request for long.
|
|
84
|
+
DEFAULT_BATCH = 200
|
|
85
|
+
|
|
86
|
+
#: How long the worker waits before looking again when nothing woke it. A store
|
|
87
|
+
#: signals the worker directly, so this is only the safety net for an enqueue
|
|
88
|
+
#: that arrived from another process — a CLI write, or a second daemon.
|
|
89
|
+
IDLE_INTERVAL_SECONDS = 2.0
|
|
90
|
+
|
|
91
|
+
#: Attempts after which a row is reported at warning level rather than debug.
|
|
92
|
+
#: It keeps being retried; the change is that it stops being quiet about it.
|
|
93
|
+
LOUD_AFTER_ATTEMPTS = 3
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class DrainResult:
|
|
98
|
+
"""What one pass did. Every field is a number an operator can act on."""
|
|
99
|
+
|
|
100
|
+
projected: int = 0
|
|
101
|
+
removed: int = 0
|
|
102
|
+
failed: int = 0
|
|
103
|
+
skipped: int = 0
|
|
104
|
+
superseded: int = 0
|
|
105
|
+
errors: list[str] = field(default_factory=list)
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def handled(self) -> int:
|
|
109
|
+
return self.projected + self.removed + self.skipped + self.superseded
|
|
110
|
+
|
|
111
|
+
def as_dict(self) -> dict[str, Any]:
|
|
112
|
+
return {
|
|
113
|
+
"projected": self.projected,
|
|
114
|
+
"removed": self.removed,
|
|
115
|
+
"failed": self.failed,
|
|
116
|
+
"skipped": self.skipped,
|
|
117
|
+
"superseded": self.superseded,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class ProjectionDrain:
|
|
122
|
+
"""Applies queued facts to the graph and vector projections.
|
|
123
|
+
|
|
124
|
+
Takes accessors rather than backends so it always sees the current ones: a
|
|
125
|
+
promotion or a rollback swaps them underneath a long-lived worker, and a
|
|
126
|
+
reference captured at construction would keep writing into the store that
|
|
127
|
+
was just replaced.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def __init__(
|
|
131
|
+
self,
|
|
132
|
+
db: Any,
|
|
133
|
+
graph_backend: Callable[[], Any],
|
|
134
|
+
vector_backend: Callable[[], Any],
|
|
135
|
+
) -> None:
|
|
136
|
+
self._db = db
|
|
137
|
+
self._graph = graph_backend
|
|
138
|
+
self._vector = vector_backend
|
|
139
|
+
self._wake = threading.Event()
|
|
140
|
+
self._stop = threading.Event()
|
|
141
|
+
self._thread: threading.Thread | None = None
|
|
142
|
+
self._pass_lock = threading.Lock()
|
|
143
|
+
|
|
144
|
+
# ------------------------------------------------------------------
|
|
145
|
+
# Lifecycle
|
|
146
|
+
# ------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def start(self) -> bool:
|
|
149
|
+
"""Begin draining in the background. Idempotent."""
|
|
150
|
+
if self._thread is not None and self._thread.is_alive():
|
|
151
|
+
return False
|
|
152
|
+
self._stop.clear()
|
|
153
|
+
self._thread = threading.Thread(
|
|
154
|
+
target=self._run, name="slm-projection-drain", daemon=True,
|
|
155
|
+
)
|
|
156
|
+
self._thread.start()
|
|
157
|
+
logger.info("projection drain started")
|
|
158
|
+
return True
|
|
159
|
+
|
|
160
|
+
def stop(self, timeout: float = 5.0) -> None:
|
|
161
|
+
"""Ask the worker to finish its pass and exit."""
|
|
162
|
+
self._stop.set()
|
|
163
|
+
self._wake.set()
|
|
164
|
+
thread = self._thread
|
|
165
|
+
if thread is not None and thread.is_alive():
|
|
166
|
+
thread.join(timeout=timeout)
|
|
167
|
+
self._thread = None
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def running(self) -> bool:
|
|
171
|
+
return self._thread is not None and self._thread.is_alive()
|
|
172
|
+
|
|
173
|
+
def notify(self) -> None:
|
|
174
|
+
"""Tell the worker there is something to do.
|
|
175
|
+
|
|
176
|
+
Called after a store commits. Cheap enough to call on every write, and
|
|
177
|
+
it is what keeps the gap between "remembered" and "in the graph" at
|
|
178
|
+
milliseconds instead of the idle interval.
|
|
179
|
+
"""
|
|
180
|
+
self._wake.set()
|
|
181
|
+
|
|
182
|
+
def _run(self) -> None:
|
|
183
|
+
while not self._stop.is_set():
|
|
184
|
+
self._wake.wait(timeout=IDLE_INTERVAL_SECONDS)
|
|
185
|
+
self._wake.clear()
|
|
186
|
+
if self._stop.is_set():
|
|
187
|
+
break
|
|
188
|
+
try:
|
|
189
|
+
# Keep going while a pass is filling its batch: a backlog
|
|
190
|
+
# should drain continuously rather than one batch per tick.
|
|
191
|
+
while not self._stop.is_set():
|
|
192
|
+
result = self.drain_once()
|
|
193
|
+
if result.handled + result.failed < DEFAULT_BATCH:
|
|
194
|
+
break
|
|
195
|
+
except Exception as exc: # pragma: no cover - worker must not die
|
|
196
|
+
# A worker that exits on an unexpected error would leave the
|
|
197
|
+
# queue growing with nothing draining it and no thread left to
|
|
198
|
+
# report why. Log it and stay alive; the rows are still queued.
|
|
199
|
+
logger.error("projection drain pass failed: %s", exc, exc_info=True)
|
|
200
|
+
logger.info("projection drain stopped")
|
|
201
|
+
|
|
202
|
+
# ------------------------------------------------------------------
|
|
203
|
+
# One pass
|
|
204
|
+
# ------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
def drain_once(self, limit: int = DEFAULT_BATCH) -> DrainResult:
|
|
207
|
+
"""Apply up to ``limit`` queued facts. Safe to call directly.
|
|
208
|
+
|
|
209
|
+
Returns without touching a row when no projection is open. The rows are
|
|
210
|
+
the pending work for whenever one is, and discarding them would be
|
|
211
|
+
throwing away the only record of which facts still need projecting.
|
|
212
|
+
"""
|
|
213
|
+
result = DrainResult()
|
|
214
|
+
graph, vector = self._graph(), self._vector()
|
|
215
|
+
if graph is None and vector is None:
|
|
216
|
+
return result
|
|
217
|
+
|
|
218
|
+
with self._pass_lock:
|
|
219
|
+
for row in projection_outbox.claim_batch(self._db, limit=limit):
|
|
220
|
+
self._apply_row(row, graph, vector, result)
|
|
221
|
+
return result
|
|
222
|
+
|
|
223
|
+
def _apply_row(
|
|
224
|
+
self, row: dict[str, Any], graph: Any, vector: Any, result: DrainResult,
|
|
225
|
+
) -> None:
|
|
226
|
+
fact_id = row["fact_id"]
|
|
227
|
+
revision = row["revision"]
|
|
228
|
+
try:
|
|
229
|
+
if row["op"] == projection_outbox.OP_DELETE:
|
|
230
|
+
self._remove(fact_id, graph, vector)
|
|
231
|
+
outcome = "removed"
|
|
232
|
+
else:
|
|
233
|
+
outcome = self._project(fact_id, graph, vector)
|
|
234
|
+
except Exception as exc:
|
|
235
|
+
attempts = projection_outbox.record_failure(self._db, fact_id, str(exc))
|
|
236
|
+
result.failed += 1
|
|
237
|
+
result.errors.append(f"{fact_id[:12]}: {exc}")
|
|
238
|
+
log = logger.warning if attempts >= LOUD_AFTER_ATTEMPTS else logger.debug
|
|
239
|
+
log(
|
|
240
|
+
"projection failed for %s after %d attempt(s): %s",
|
|
241
|
+
fact_id[:12], attempts, exc,
|
|
242
|
+
)
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
if projection_outbox.resolve(self._db, fact_id, revision):
|
|
246
|
+
setattr(result, outcome, getattr(result, outcome) + 1)
|
|
247
|
+
else:
|
|
248
|
+
# The fact was written again while this projection was in flight,
|
|
249
|
+
# so a newer intent is queued. Counting it as done would report
|
|
250
|
+
# work that still has to happen.
|
|
251
|
+
result.superseded += 1
|
|
252
|
+
|
|
253
|
+
# ------------------------------------------------------------------
|
|
254
|
+
# The projections themselves
|
|
255
|
+
# ------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
def _project(self, fact_id: str, graph: Any, vector: Any) -> str:
|
|
258
|
+
"""Bring one fact's projection up to date. Returns the outcome name."""
|
|
259
|
+
state = self._visibility(fact_id)
|
|
260
|
+
if state == "absent":
|
|
261
|
+
# An entity id, or a fact hard-deleted since it was queued. There
|
|
262
|
+
# is nothing to project and nothing to remove.
|
|
263
|
+
return "skipped"
|
|
264
|
+
if state == "hidden":
|
|
265
|
+
# Archived or withheld. It must stop being offered as a candidate —
|
|
266
|
+
# but its EDGES stay. The SQLite entity map filters on visibility
|
|
267
|
+
# and its edge walk does not, so deleting a hidden fact's edges
|
|
268
|
+
# would leave every visible fact that neighboured it with a smaller
|
|
269
|
+
# adjacency here than in SQLite, and the two graphs would answer
|
|
270
|
+
# differently. Measured on a real store before this was split: 32
|
|
271
|
+
# visible facts had lost edges, every missing endpoint quarantined.
|
|
272
|
+
self._remove(fact_id, graph, vector)
|
|
273
|
+
return "removed"
|
|
274
|
+
|
|
275
|
+
fact = self._db.get_fact(fact_id)
|
|
276
|
+
if fact is None:
|
|
277
|
+
return "skipped"
|
|
278
|
+
if graph is not None:
|
|
279
|
+
self._project_graph(fact, graph)
|
|
280
|
+
if vector is not None:
|
|
281
|
+
self._project_vector(fact, vector)
|
|
282
|
+
return "projected"
|
|
283
|
+
|
|
284
|
+
def _visibility(self, fact_id: str) -> str:
|
|
285
|
+
"""``visible``, ``hidden`` or ``absent`` for one id."""
|
|
286
|
+
rows = self._db.execute(
|
|
287
|
+
"SELECT 1 FROM atomic_facts WHERE fact_id = ?", (fact_id,),
|
|
288
|
+
)
|
|
289
|
+
if not rows:
|
|
290
|
+
return "absent"
|
|
291
|
+
visible = self._db.execute(
|
|
292
|
+
"SELECT 1 FROM atomic_facts WHERE fact_id = ?"
|
|
293
|
+
+ self._db.visible_fact_clause(),
|
|
294
|
+
(fact_id,),
|
|
295
|
+
)
|
|
296
|
+
return "visible" if visible else "hidden"
|
|
297
|
+
|
|
298
|
+
def _project_graph(self, fact: Any, graph: Any) -> None:
|
|
299
|
+
"""Replace this fact's node, entity bridge and edges in the graph.
|
|
300
|
+
|
|
301
|
+
``remove_fact`` first, so a re-projection cannot leave an entity link
|
|
302
|
+
or an edge that SQLite no longer has. Replace-then-write is what makes
|
|
303
|
+
a replay idempotent.
|
|
304
|
+
"""
|
|
305
|
+
profile_id = getattr(fact, "profile_id", "default") or "default"
|
|
306
|
+
graph.remove_fact(fact.fact_id)
|
|
307
|
+
entities = list(getattr(fact, "canonical_entities", []) or [])
|
|
308
|
+
for entity_id in entities:
|
|
309
|
+
rows = self._db.execute(
|
|
310
|
+
"SELECT canonical_name, entity_type, fact_count FROM canonical_entities "
|
|
311
|
+
"WHERE entity_id = ? AND profile_id = ?",
|
|
312
|
+
(entity_id, profile_id),
|
|
313
|
+
)
|
|
314
|
+
if not rows:
|
|
315
|
+
continue
|
|
316
|
+
entity = dict(rows[0])
|
|
317
|
+
graph.add_entity(
|
|
318
|
+
entity_id,
|
|
319
|
+
entity.get("canonical_name") or entity_id,
|
|
320
|
+
entity.get("entity_type") or "concept",
|
|
321
|
+
{"fact_count": int(entity.get("fact_count") or 0)},
|
|
322
|
+
profile_id,
|
|
323
|
+
)
|
|
324
|
+
graph.add_fact_entities(fact.fact_id, entities, profile_id)
|
|
325
|
+
for row in self._db.execute(
|
|
326
|
+
"SELECT source_id, target_id, edge_type, weight FROM graph_edges "
|
|
327
|
+
"WHERE profile_id = ? AND (source_id = ? OR target_id = ?)",
|
|
328
|
+
(profile_id, fact.fact_id, fact.fact_id),
|
|
329
|
+
):
|
|
330
|
+
edge = dict(row)
|
|
331
|
+
graph.add_edge(
|
|
332
|
+
edge["source_id"], edge["target_id"],
|
|
333
|
+
edge.get("edge_type") or "related",
|
|
334
|
+
float(edge.get("weight") or 1.0), profile_id=profile_id,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
def _project_vector(self, fact: Any, vector: Any) -> None:
|
|
338
|
+
"""Write this fact's embedding to the vector store.
|
|
339
|
+
|
|
340
|
+
A fact with no embedding yet is not an error: ingestion is
|
|
341
|
+
queryable-first, so the vector arrives with enrichment and the update
|
|
342
|
+
that writes it queues the fact again.
|
|
343
|
+
"""
|
|
344
|
+
embedding = getattr(fact, "embedding", None)
|
|
345
|
+
if not embedding:
|
|
346
|
+
return
|
|
347
|
+
lifecycle = getattr(fact, "lifecycle", None)
|
|
348
|
+
tier = getattr(lifecycle, "value", lifecycle) or "active"
|
|
349
|
+
vector.add_vectors(
|
|
350
|
+
[fact.fact_id], [embedding], [tier],
|
|
351
|
+
getattr(fact, "profile_id", "default") or "default",
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
def _remove(self, fact_id: str, graph: Any, vector: Any) -> None:
|
|
355
|
+
"""Take one fact out of both projections entirely, edges included.
|
|
356
|
+
|
|
357
|
+
For a fact that is genuinely gone. Its edges go too, because a hard
|
|
358
|
+
delete cascades them out of SQLite as well, so keeping them here would
|
|
359
|
+
be the projection holding an adjacency the store no longer has.
|
|
360
|
+
"""
|
|
361
|
+
if graph is not None:
|
|
362
|
+
graph.remove_fact(fact_id)
|
|
363
|
+
if vector is not None:
|
|
364
|
+
vector.remove_vector(fact_id)
|
|
365
|
+
|
|
366
|
+
def _withdraw_candidacy(self, fact_id: str, graph: Any, vector: Any) -> None:
|
|
367
|
+
"""Stop offering a fact, without changing the graph's shape.
|
|
368
|
+
|
|
369
|
+
For a fact that still exists but may no longer be returned. It leaves
|
|
370
|
+
the entity bridge and the vector — the two things that put a fact into a
|
|
371
|
+
result set — and leaves its edges alone, because those are still in
|
|
372
|
+
``graph_edges`` and the walk this projection replaces still follows them.
|
|
373
|
+
"""
|
|
374
|
+
if graph is not None:
|
|
375
|
+
withdraw = getattr(graph, "remove_fact_candidacy", None)
|
|
376
|
+
# A backend without the narrower call is better served by the full
|
|
377
|
+
# removal than by silently leaving a withheld fact recallable.
|
|
378
|
+
(withdraw or graph.remove_fact)(fact_id)
|
|
379
|
+
if vector is not None:
|
|
380
|
+
vector.remove_vector(fact_id)
|