superlocalmemory 4.0.10 → 4.1.2
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 +12 -2
- package/CHANGELOG.md +244 -0
- package/README.md +40 -75
- package/package.json +6 -3
- 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 +357 -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_health.py +87 -10
- 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 +241 -7
- package/src/superlocalmemory/storage/_migration_internals.py +54 -2
- 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/M043_quarantine_display_summaries.py +60 -36
- 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 +35 -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
|
@@ -22,7 +22,7 @@ import time
|
|
|
22
22
|
from contextlib import contextmanager
|
|
23
23
|
from pathlib import Path
|
|
24
24
|
from types import ModuleType
|
|
25
|
-
from typing import Any, Generator
|
|
25
|
+
from typing import Any, Generator, NoReturn
|
|
26
26
|
|
|
27
27
|
from superlocalmemory.storage.models import (
|
|
28
28
|
AtomicFact,
|
|
@@ -40,13 +40,33 @@ from superlocalmemory.storage.models import (
|
|
|
40
40
|
TemporalEvent,
|
|
41
41
|
TrustScore,
|
|
42
42
|
)
|
|
43
|
-
from superlocalmemory.storage.embedding_codec import
|
|
43
|
+
from superlocalmemory.storage.embedding_codec import (
|
|
44
|
+
decode_embedding,
|
|
45
|
+
decode_float_vector,
|
|
46
|
+
encode_embedding,
|
|
47
|
+
encode_float_vector,
|
|
48
|
+
)
|
|
44
49
|
from superlocalmemory.storage.write_lock import get_write_lock
|
|
50
|
+
from superlocalmemory.storage import projection_outbox
|
|
45
51
|
|
|
46
52
|
logger = logging.getLogger(__name__)
|
|
47
53
|
|
|
48
54
|
_MISSING = object()
|
|
49
55
|
|
|
56
|
+
|
|
57
|
+
class ProfileOwnershipConflict(ValueError):
|
|
58
|
+
"""A store tried to hand one profile's existing row to another profile.
|
|
59
|
+
|
|
60
|
+
``memories.memory_id`` and ``atomic_facts.fact_id`` are bare
|
|
61
|
+
``TEXT PRIMARY KEY`` — global, not per-profile — while every row carries a
|
|
62
|
+
``profile_id``. So two profiles on one store can be handed the same
|
|
63
|
+
caller-chosen id (an importer keyed on an external record id syncing one
|
|
64
|
+
source into two workspaces does exactly this), and the upsert would
|
|
65
|
+
otherwise rewrite the owner and the content: the first profile's memory
|
|
66
|
+
silently became the second's.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
|
|
50
70
|
def _jl(raw: Any, default: Any = _MISSING) -> Any:
|
|
51
71
|
"""JSON-load a value, returning *default* on None/empty.
|
|
52
72
|
|
|
@@ -146,6 +166,92 @@ def _scope_where(
|
|
|
146
166
|
return where, params
|
|
147
167
|
|
|
148
168
|
|
|
169
|
+
def _compose_visible_clause(
|
|
170
|
+
prefix: str,
|
|
171
|
+
*,
|
|
172
|
+
has_archive: bool,
|
|
173
|
+
has_quarantine: bool,
|
|
174
|
+
include_quarantined: bool = False,
|
|
175
|
+
) -> str:
|
|
176
|
+
"""Build the AND-clause from what the store actually has.
|
|
177
|
+
|
|
178
|
+
Split out from ``DatabaseManager.visible_fact_clause`` so that callers
|
|
179
|
+
holding a bare ``sqlite3.Connection`` produce a byte-identical predicate
|
|
180
|
+
rather than a second, drifting copy.
|
|
181
|
+
"""
|
|
182
|
+
table = f"{prefix}." if prefix else ""
|
|
183
|
+
clause = ""
|
|
184
|
+
if has_archive:
|
|
185
|
+
clause += f" AND COALESCE({table}archive_status, 'live') != 'archived'"
|
|
186
|
+
if not include_quarantined and has_quarantine:
|
|
187
|
+
clause += f" AND COALESCE({table}quarantined, 0) = 0"
|
|
188
|
+
return clause
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def visible_fact_clause_for_connection(
|
|
192
|
+
conn: sqlite3.Connection,
|
|
193
|
+
prefix: str = "",
|
|
194
|
+
*,
|
|
195
|
+
include_quarantined: bool = False,
|
|
196
|
+
) -> str:
|
|
197
|
+
"""``visible_fact_clause`` for a caller that hand-rolls its SQL.
|
|
198
|
+
|
|
199
|
+
WHY THIS EXISTS. 4.0.10 put the withheld-row filter on every
|
|
200
|
+
``DatabaseManager`` read path and every retrieval channel, and enumerated
|
|
201
|
+
them in a test. The enumeration was of *methods*, and two HTTP routes never
|
|
202
|
+
call one: ``/api/memories`` and ``/api/v3/timeline/`` build their own SQL
|
|
203
|
+
against ``atomic_facts``. Measured against the author's store on 4.0.10,
|
|
204
|
+
``/api/memories?limit=50`` served **24 withheld rows on page one** and
|
|
205
|
+
reported a total of 5,218 against 3,919 real memories -- the release's
|
|
206
|
+
central claim, false on the dashboard's main list.
|
|
207
|
+
|
|
208
|
+
So the rule is not "read paths go through DatabaseManager"; plenty of
|
|
209
|
+
reasonable code will not. The rule is that anything answering "what does my
|
|
210
|
+
memory contain" resolves its predicate from here. Presence-guarded on the
|
|
211
|
+
passed connection, because a route may be pointed at a store the engine has
|
|
212
|
+
never opened, and filtering on an absent column would turn a cosmetic gap
|
|
213
|
+
into a 500 on every request.
|
|
214
|
+
|
|
215
|
+
Grep is not a sufficient test for this: ``mcp/tools_active.py`` also selects
|
|
216
|
+
from ``atomic_facts`` directly and was already clean, while these two routes
|
|
217
|
+
were not. The test that matters calls the surface and inspects what it
|
|
218
|
+
served -- tests/test_server/test_a_listed_memory_belongs_to_its_profile.py
|
|
219
|
+
"""
|
|
220
|
+
def _column_name(row: object) -> str:
|
|
221
|
+
"""PRAGMA row -> column name, whatever row_factory the caller set.
|
|
222
|
+
|
|
223
|
+
Not paranoia: ``server/routes/memories.py`` -- the first caller and the
|
|
224
|
+
route that was leaking -- sets ``row_factory = dict_factory``, so a
|
|
225
|
+
positional ``row[1]`` raises KeyError there, and the except below would
|
|
226
|
+
have swallowed it into "no such column" and silently dropped the filter.
|
|
227
|
+
That is the same shape of failure this function exists to close.
|
|
228
|
+
"""
|
|
229
|
+
if isinstance(row, dict):
|
|
230
|
+
return str(row.get("name", ""))
|
|
231
|
+
try:
|
|
232
|
+
return str(row["name"]) # sqlite3.Row
|
|
233
|
+
except (TypeError, IndexError, KeyError):
|
|
234
|
+
pass
|
|
235
|
+
try:
|
|
236
|
+
return str(row[1]) # plain tuple
|
|
237
|
+
except (TypeError, IndexError, KeyError):
|
|
238
|
+
return ""
|
|
239
|
+
|
|
240
|
+
def _has(column: str) -> bool:
|
|
241
|
+
try:
|
|
242
|
+
rows = list(conn.execute("PRAGMA table_info(atomic_facts)"))
|
|
243
|
+
except sqlite3.Error:
|
|
244
|
+
return False
|
|
245
|
+
return any(_column_name(r) == column for r in rows)
|
|
246
|
+
|
|
247
|
+
return _compose_visible_clause(
|
|
248
|
+
prefix,
|
|
249
|
+
has_archive=_has("archive_status"),
|
|
250
|
+
has_quarantine=_has("quarantined"),
|
|
251
|
+
include_quarantined=include_quarantined,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
149
255
|
class DatabaseManager:
|
|
150
256
|
"""Concurrent-safe SQLite manager with WAL, profile isolation, and FTS5.
|
|
151
257
|
|
|
@@ -442,21 +548,101 @@ class DatabaseManager:
|
|
|
442
548
|
# Read-only path: concurrent reads are safe in WAL mode.
|
|
443
549
|
return self._execute_one(sql, params)
|
|
444
550
|
|
|
551
|
+
# The two tables whose primary key is global but whose rows are owned by a
|
|
552
|
+
# profile. Literal, internal, and closed — never built from caller input.
|
|
553
|
+
_OWNED_ROWS: dict[str, str] = {"memories": "memory_id", "atomic_facts": "fact_id"}
|
|
554
|
+
|
|
555
|
+
def _refuse_cross_profile_reown(
|
|
556
|
+
self, table: str, row_id: str, profile_id: str,
|
|
557
|
+
) -> NoReturn:
|
|
558
|
+
"""Raise ``ProfileOwnershipConflict``, naming the profile that owns it.
|
|
559
|
+
|
|
560
|
+
Called only when an upsert below returned no row. Both statements carry
|
|
561
|
+
``WHERE <table>.profile_id = excluded.profile_id`` on their
|
|
562
|
+
``DO UPDATE``, so SQLite skips a conflicting row owned by a different
|
|
563
|
+
profile and ``RETURNING`` yields nothing — which makes the ownership
|
|
564
|
+
check part of the write instead of a read in front of it. That costs no
|
|
565
|
+
extra query on the ordinary path and leaves no window between deciding
|
|
566
|
+
and writing. The lookup here runs only on the refusal, to say whose row
|
|
567
|
+
it is; a refusal nobody can read gets worked around.
|
|
568
|
+
|
|
569
|
+
Taking a row away from the profile that owns it is not last-write-wins,
|
|
570
|
+
it is a different tenant's write, so it is refused rather than merged.
|
|
571
|
+
Before this the outcome depended on something unrelated:
|
|
572
|
+
``scene_fact_members`` carries a composite
|
|
573
|
+
``(profile_id, fact_id) -> atomic_facts (profile_id, fact_id)`` foreign
|
|
574
|
+
key, so where a scene referenced the fact the ownership change orphaned
|
|
575
|
+
it and SQLite raised a bare ``FOREIGN KEY constraint failed`` — and
|
|
576
|
+
where no scene did, the identical write succeeded in silence and the
|
|
577
|
+
second profile kept the fact. Isolation cannot rest on whether a
|
|
578
|
+
projection happens to exist.
|
|
579
|
+
"""
|
|
580
|
+
# Interpolated, not parameterised: SQLite takes no parameter in a table
|
|
581
|
+
# or column position. Both come from _OWNED_ROWS, a closed literal map,
|
|
582
|
+
# and the row id stays bound.
|
|
583
|
+
id_column = self._OWNED_ROWS[table]
|
|
584
|
+
rows = self.execute(
|
|
585
|
+
f"SELECT profile_id FROM {table} WHERE {id_column} = ?",
|
|
586
|
+
(row_id,),
|
|
587
|
+
)
|
|
588
|
+
owner = dict(rows[0])["profile_id"] if rows else "<unknown>"
|
|
589
|
+
raise ProfileOwnershipConflict(
|
|
590
|
+
f"{id_column} {row_id!r} in {table} belongs to profile {owner!r}; "
|
|
591
|
+
f"refusing to re-own it as {profile_id!r}"
|
|
592
|
+
)
|
|
593
|
+
|
|
445
594
|
def store_memory(self, record: MemoryRecord) -> str:
|
|
446
|
-
"""Persist a raw memory record. Returns memory_id.
|
|
595
|
+
"""Persist a raw memory record. Returns memory_id.
|
|
596
|
+
|
|
597
|
+
Upserts in place rather than replacing. ``INSERT OR REPLACE`` is a
|
|
598
|
+
DELETE followed by an INSERT, and ``atomic_facts.memory_id`` is a
|
|
599
|
+
foreign key with ``ON DELETE CASCADE`` — so storing a record whose
|
|
600
|
+
``memory_id`` already existed silently deleted every fact extracted from
|
|
601
|
+
it. Reproduced in isolation: three facts stored, one re-store of the same
|
|
602
|
+
memory_id, zero facts left, no error raised.
|
|
603
|
+
|
|
604
|
+
Most callers pass a freshly generated id, which is why this never fired.
|
|
605
|
+
But ``cognitive_consolidator`` supplies its own ``block_id``, and the
|
|
606
|
+
queryable-promotion path in ``run_store`` deliberately avoids calling
|
|
607
|
+
this at all for an existing memory — a rule that has to be remembered
|
|
608
|
+
rather than enforced. ``ON CONFLICT DO UPDATE`` keeps the same
|
|
609
|
+
last-write-wins semantics and takes the loaded gun out of the room.
|
|
610
|
+
|
|
611
|
+
``created_at`` is deliberately not overwritten: the row's first
|
|
612
|
+
observation is a historical fact, and a re-store is not a new one.
|
|
613
|
+
``profile_id`` is not in the update list either, and a store that would
|
|
614
|
+
change it is refused outright — the ``DO UPDATE`` is conditioned on the
|
|
615
|
+
owner matching, so SQLite skips the row and ``RETURNING`` comes back
|
|
616
|
+
empty. See ``_refuse_cross_profile_reown``.
|
|
617
|
+
"""
|
|
447
618
|
_scope = getattr(record, 'scope', None) or 'personal'
|
|
448
619
|
_shared = _jd(getattr(record, 'shared_with', None))
|
|
449
|
-
self.execute(
|
|
450
|
-
"""INSERT
|
|
620
|
+
written = self.execute(
|
|
621
|
+
"""INSERT INTO memories
|
|
451
622
|
(memory_id, profile_id, content, session_id, speaker,
|
|
452
623
|
role, session_date, created_at, metadata_json,
|
|
453
624
|
scope, shared_with)
|
|
454
|
-
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
|
625
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
|
626
|
+
ON CONFLICT(memory_id) DO UPDATE SET
|
|
627
|
+
content = excluded.content,
|
|
628
|
+
session_id = excluded.session_id,
|
|
629
|
+
speaker = excluded.speaker,
|
|
630
|
+
role = excluded.role,
|
|
631
|
+
session_date = excluded.session_date,
|
|
632
|
+
metadata_json = excluded.metadata_json,
|
|
633
|
+
scope = excluded.scope,
|
|
634
|
+
shared_with = excluded.shared_with
|
|
635
|
+
WHERE memories.profile_id = excluded.profile_id
|
|
636
|
+
RETURNING profile_id""",
|
|
455
637
|
(record.memory_id, record.profile_id, record.content,
|
|
456
638
|
record.session_id, record.speaker, record.role,
|
|
457
639
|
record.session_date, record.created_at,
|
|
458
640
|
json.dumps(record.metadata), _scope, _shared),
|
|
459
641
|
)
|
|
642
|
+
if not written:
|
|
643
|
+
self._refuse_cross_profile_reown(
|
|
644
|
+
"memories", record.memory_id, record.profile_id,
|
|
645
|
+
)
|
|
460
646
|
return record.memory_id
|
|
461
647
|
|
|
462
648
|
def update_memory_summary(self, memory_id: str, summary: str) -> None:
|
|
@@ -485,6 +671,22 @@ class DatabaseManager:
|
|
|
485
671
|
pass
|
|
486
672
|
return ""
|
|
487
673
|
|
|
674
|
+
def _atomically(self, work: Any) -> Any:
|
|
675
|
+
"""Run ``work`` in one transaction, joining an open one rather than nesting.
|
|
676
|
+
|
|
677
|
+
A caller already inside ``transaction()`` must not start a second one:
|
|
678
|
+
the lock is re-entrant but ``_connect`` is not, so a nested attempt opens
|
|
679
|
+
a separate connection to the same file while the first still holds its
|
|
680
|
+
write. ``store_fact`` carried this check inline; it is here because
|
|
681
|
+
three more methods now need the same thing, and a projection intent that
|
|
682
|
+
commits in a different transaction from the row it describes is exactly
|
|
683
|
+
the window this whole mechanism exists to close.
|
|
684
|
+
"""
|
|
685
|
+
if getattr(self._txn_state, "conn", None) is not None:
|
|
686
|
+
return work()
|
|
687
|
+
with self.transaction():
|
|
688
|
+
return work()
|
|
689
|
+
|
|
488
690
|
def store_fact(self, fact: AtomicFact) -> str:
|
|
489
691
|
"""Persist an atomic fact. Returns fact_id.
|
|
490
692
|
|
|
@@ -499,6 +701,39 @@ class DatabaseManager:
|
|
|
499
701
|
twice is one fact" — preventing the duplicate explosion that poisons
|
|
500
702
|
importance ranking and core-memory promotion. Empty/whitespace
|
|
501
703
|
content is exempt (handled by placeholder filtering, not dedup).
|
|
704
|
+
|
|
705
|
+
The insert below upserts rather than replaces, for the reason
|
|
706
|
+
``store_memory`` does. ``INSERT OR REPLACE`` is a DELETE followed by an
|
|
707
|
+
INSERT, and eight tables hold
|
|
708
|
+
``FOREIGN KEY (fact_id) REFERENCES atomic_facts (fact_id) ON DELETE
|
|
709
|
+
CASCADE`` — so re-storing a fact under an occupied id dropped its
|
|
710
|
+
retention row, access history, context and importance, and raised
|
|
711
|
+
nothing.
|
|
712
|
+
|
|
713
|
+
The dedup above does not close this: it matches on *content*, so a
|
|
714
|
+
second store of the same id with *different* content falls straight
|
|
715
|
+
through to the insert. ``MemoryEngine.store_fact_direct`` reaches it —
|
|
716
|
+
``canonical_store_fact`` exists to persist a caller-chosen id and
|
|
717
|
+
raises if that id is not preserved. Within one profile an idempotency
|
|
718
|
+
key of ``prebuilt:<fact_id>`` catches the second store, but that key is
|
|
719
|
+
scoped ``(profile_id, source_type, idempotency_key)`` while
|
|
720
|
+
``atomic_facts.fact_id`` is a bare ``TEXT PRIMARY KEY``. Reproduced
|
|
721
|
+
across two profiles on one store: the first profile's fact was replaced
|
|
722
|
+
outright — new owner, new content — and its associations were gone.
|
|
723
|
+
|
|
724
|
+
``created_at`` is deliberately not overwritten: the row's first
|
|
725
|
+
observation is a historical fact, and a re-store is not a new one.
|
|
726
|
+
``pinned`` is not in the column list at all, so the upsert now leaves it
|
|
727
|
+
alone where the replace silently reset it to 0 — pinning is user intent,
|
|
728
|
+
not something a re-store gets to revoke. ``profile_id`` is out of the
|
|
729
|
+
update list for a stronger reason: a store that would change it is
|
|
730
|
+
refused rather than applied, because it is one profile taking another's
|
|
731
|
+
fact rather than a re-store at all. The refusal is a condition on the
|
|
732
|
+
``DO UPDATE`` itself, so there is no window between checking the owner
|
|
733
|
+
and writing the row.
|
|
734
|
+
|
|
735
|
+
``insert_fact_immutable`` remains the right call for a known-new fact
|
|
736
|
+
that must abort on a collision rather than win it.
|
|
502
737
|
"""
|
|
503
738
|
if fact.content and fact.content.strip():
|
|
504
739
|
# Dedup across all LIVE lifecycle zones (active/warm/cold). Excludes
|
|
@@ -533,12 +768,17 @@ class DatabaseManager:
|
|
|
533
768
|
# trustworthy observation that the anchor was missing.
|
|
534
769
|
if self.get_temporal_validity(canonical_id, fact.profile_id) is None:
|
|
535
770
|
self.store_temporal_validity(canonical_id, fact.profile_id)
|
|
771
|
+
# Re-storing known content is the natural moment to notice a
|
|
772
|
+
# projection that was never written — an upgraded store whose
|
|
773
|
+
# graph predates the migration reaches this branch, not the
|
|
774
|
+
# insert below.
|
|
775
|
+
projection_outbox.enqueue(self, canonical_id, fact.profile_id)
|
|
536
776
|
return canonical_id
|
|
537
777
|
_scope = getattr(fact, 'scope', None) or 'personal'
|
|
538
778
|
_shared = _jd(getattr(fact, 'shared_with', None))
|
|
539
779
|
def _insert_with_knowledge_anchor() -> None:
|
|
540
|
-
self.execute(
|
|
541
|
-
"""INSERT
|
|
780
|
+
written = self.execute(
|
|
781
|
+
"""INSERT INTO atomic_facts
|
|
542
782
|
(fact_id, memory_id, profile_id, content, fact_type,
|
|
543
783
|
entities_json, canonical_entities_json,
|
|
544
784
|
observation_date, referenced_date, interval_start, interval_end,
|
|
@@ -548,7 +788,35 @@ class DatabaseManager:
|
|
|
548
788
|
lifecycle, langevin_position,
|
|
549
789
|
emotional_valence, emotional_arousal, signal_type, created_at,
|
|
550
790
|
scope, shared_with)
|
|
551
|
-
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
791
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
792
|
+
ON CONFLICT(fact_id) DO UPDATE SET
|
|
793
|
+
memory_id = excluded.memory_id,
|
|
794
|
+
content = excluded.content,
|
|
795
|
+
fact_type = excluded.fact_type,
|
|
796
|
+
entities_json = excluded.entities_json,
|
|
797
|
+
canonical_entities_json = excluded.canonical_entities_json,
|
|
798
|
+
observation_date = excluded.observation_date,
|
|
799
|
+
referenced_date = excluded.referenced_date,
|
|
800
|
+
interval_start = excluded.interval_start,
|
|
801
|
+
interval_end = excluded.interval_end,
|
|
802
|
+
confidence = excluded.confidence,
|
|
803
|
+
importance = excluded.importance,
|
|
804
|
+
evidence_count = excluded.evidence_count,
|
|
805
|
+
access_count = excluded.access_count,
|
|
806
|
+
source_turn_ids_json = excluded.source_turn_ids_json,
|
|
807
|
+
session_id = excluded.session_id,
|
|
808
|
+
embedding = excluded.embedding,
|
|
809
|
+
fisher_mean = excluded.fisher_mean,
|
|
810
|
+
fisher_variance = excluded.fisher_variance,
|
|
811
|
+
lifecycle = excluded.lifecycle,
|
|
812
|
+
langevin_position = excluded.langevin_position,
|
|
813
|
+
emotional_valence = excluded.emotional_valence,
|
|
814
|
+
emotional_arousal = excluded.emotional_arousal,
|
|
815
|
+
signal_type = excluded.signal_type,
|
|
816
|
+
scope = excluded.scope,
|
|
817
|
+
shared_with = excluded.shared_with
|
|
818
|
+
WHERE atomic_facts.profile_id = excluded.profile_id
|
|
819
|
+
RETURNING profile_id""",
|
|
552
820
|
(fact.fact_id, fact.memory_id, fact.profile_id, fact.content,
|
|
553
821
|
fact.fact_type.value,
|
|
554
822
|
json.dumps(fact.entities), json.dumps(fact.canonical_entities),
|
|
@@ -556,15 +824,29 @@ class DatabaseManager:
|
|
|
556
824
|
fact.interval_start, fact.interval_end,
|
|
557
825
|
fact.confidence, fact.importance, fact.evidence_count, fact.access_count,
|
|
558
826
|
json.dumps(fact.source_turn_ids), fact.session_id,
|
|
559
|
-
encode_embedding(fact.embedding),
|
|
827
|
+
encode_embedding(fact.embedding),
|
|
828
|
+
encode_float_vector(fact.fisher_mean),
|
|
829
|
+
encode_float_vector(fact.fisher_variance),
|
|
560
830
|
fact.lifecycle.value, _jd(fact.langevin_position),
|
|
561
831
|
fact.emotional_valence, fact.emotional_arousal,
|
|
562
832
|
fact.signal_type.value, fact.created_at, _scope, _shared),
|
|
563
833
|
)
|
|
834
|
+
if not written:
|
|
835
|
+
self._refuse_cross_profile_reown(
|
|
836
|
+
"atomic_facts", fact.fact_id, fact.profile_id,
|
|
837
|
+
)
|
|
564
838
|
# Every fact written after 4.0.2 has an explicit transaction-time
|
|
565
839
|
# anchor. Absence deliberately represents pre-4.0.2
|
|
566
840
|
# ``legacy_unknown``; never backfill it from ``created_at``.
|
|
567
841
|
self.store_temporal_validity(fact.fact_id, fact.profile_id)
|
|
842
|
+
# The graph and the vectors live in other storage engines, so the
|
|
843
|
+
# intent to project this fact is queued here, in this transaction.
|
|
844
|
+
# Enqueueing in the storage layer rather than at each pipeline call
|
|
845
|
+
# site is deliberate: ingestion, the background materializer, the
|
|
846
|
+
# consolidator and the CLI all write facts through this method, and
|
|
847
|
+
# a call site that forgets would produce a memory that is stored
|
|
848
|
+
# and unrecallable.
|
|
849
|
+
projection_outbox.enqueue(self, fact.fact_id, fact.profile_id)
|
|
568
850
|
|
|
569
851
|
# The fact and its transaction-time anchor are one logical write. Do
|
|
570
852
|
# not open a nested transaction when an owner already holds one.
|
|
@@ -593,8 +875,12 @@ class DatabaseManager:
|
|
|
593
875
|
source_turn_ids=_jl(d.get("source_turn_ids_json")),
|
|
594
876
|
session_id=d.get("session_id", ""),
|
|
595
877
|
embedding=decode_embedding(d.get("embedding"), fact_id=d.get("fact_id", "<unknown>")),
|
|
596
|
-
fisher_mean=
|
|
597
|
-
|
|
878
|
+
fisher_mean=decode_float_vector(
|
|
879
|
+
d.get("fisher_mean"), field="fisher_mean",
|
|
880
|
+
fact_id=d.get("fact_id", "<unknown>")),
|
|
881
|
+
fisher_variance=decode_float_vector(
|
|
882
|
+
d.get("fisher_variance"), field="fisher_variance",
|
|
883
|
+
fact_id=d.get("fact_id", "<unknown>")),
|
|
598
884
|
lifecycle=MemoryLifecycle(d["lifecycle"]) if d.get("lifecycle") else MemoryLifecycle.ACTIVE,
|
|
599
885
|
langevin_position=_jl(d.get("langevin_position"), None),
|
|
600
886
|
emotional_valence=d.get("emotional_valence", 0.0),
|
|
@@ -647,8 +933,8 @@ class DatabaseManager:
|
|
|
647
933
|
json.dumps(fact.source_turn_ids),
|
|
648
934
|
fact.session_id,
|
|
649
935
|
encode_embedding(fact.embedding),
|
|
650
|
-
|
|
651
|
-
|
|
936
|
+
encode_float_vector(fact.fisher_mean),
|
|
937
|
+
encode_float_vector(fact.fisher_variance),
|
|
652
938
|
fact.lifecycle.value,
|
|
653
939
|
_jd(fact.langevin_position),
|
|
654
940
|
fact.emotional_valence,
|
|
@@ -660,6 +946,7 @@ class DatabaseManager:
|
|
|
660
946
|
),
|
|
661
947
|
)
|
|
662
948
|
self.store_temporal_validity(fact.fact_id, fact.profile_id)
|
|
949
|
+
projection_outbox.enqueue(self, fact.fact_id, fact.profile_id)
|
|
663
950
|
return fact.fact_id
|
|
664
951
|
|
|
665
952
|
def set_pinned(self, fact_id: str, pinned: bool) -> None:
|
|
@@ -788,13 +1075,12 @@ class DatabaseManager:
|
|
|
788
1075
|
``include_quarantined=True`` is for repair, erasure and export — paths
|
|
789
1076
|
that must reach a withheld row to act on it.
|
|
790
1077
|
"""
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
return clause
|
|
1078
|
+
return _compose_visible_clause(
|
|
1079
|
+
prefix,
|
|
1080
|
+
has_archive=self._has_archive_status(),
|
|
1081
|
+
has_quarantine=self._has_quarantine_column(),
|
|
1082
|
+
include_quarantined=include_quarantined,
|
|
1083
|
+
)
|
|
798
1084
|
|
|
799
1085
|
def get_all_facts(
|
|
800
1086
|
self, profile_id: str, limit: int | None = None,
|
|
@@ -937,6 +1223,10 @@ class DatabaseManager:
|
|
|
937
1223
|
# converted store, one fact at a time, undoing the conversion
|
|
938
1224
|
# wherever a fact is updated.
|
|
939
1225
|
clean[k] = encode_embedding(v) if v is not None else None
|
|
1226
|
+
elif k in ("fisher_mean", "fisher_variance"):
|
|
1227
|
+
# Same hazard, same answer: these are float vectors of the same
|
|
1228
|
+
# width as the embedding and are stored the same way.
|
|
1229
|
+
clean[k] = encode_float_vector(v) if v is not None else None
|
|
940
1230
|
elif isinstance(v, (list, dict)):
|
|
941
1231
|
clean[k] = json.dumps(v)
|
|
942
1232
|
elif isinstance(v, (MemoryLifecycle, FactType, SignalType)):
|
|
@@ -944,25 +1234,49 @@ class DatabaseManager:
|
|
|
944
1234
|
else:
|
|
945
1235
|
clean[k] = v
|
|
946
1236
|
set_clause = ", ".join(f"{k} = ?" for k in clean)
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
1237
|
+
|
|
1238
|
+
def _write() -> None:
|
|
1239
|
+
if profile_id is not None:
|
|
1240
|
+
self.execute(
|
|
1241
|
+
f"UPDATE atomic_facts SET {set_clause} "
|
|
1242
|
+
"WHERE fact_id = ? AND profile_id = ?",
|
|
1243
|
+
(*clean.values(), fact_id, profile_id),
|
|
1244
|
+
)
|
|
1245
|
+
else:
|
|
1246
|
+
self.execute(
|
|
1247
|
+
f"UPDATE atomic_facts SET {set_clause} WHERE fact_id = ?",
|
|
1248
|
+
(*clean.values(), fact_id),
|
|
1249
|
+
)
|
|
1250
|
+
# Only an update that changes something a projection is derived
|
|
1251
|
+
# from needs re-projecting. Recall bumps access_count on every hit,
|
|
1252
|
+
# so queueing on any update at all would hand the drain worker one
|
|
1253
|
+
# row per returned memory per recall, for a column neither Cozo nor
|
|
1254
|
+
# Lance holds.
|
|
1255
|
+
if set(updates) & projection_outbox.PROJECTED_FACT_COLUMNS:
|
|
1256
|
+
if profile_id is not None:
|
|
1257
|
+
projection_outbox.enqueue(self, fact_id, profile_id)
|
|
1258
|
+
else:
|
|
1259
|
+
projection_outbox.enqueue_for_fact(self, fact_id)
|
|
1260
|
+
|
|
1261
|
+
self._atomically(_write)
|
|
957
1262
|
|
|
958
1263
|
def delete_fact(self, fact_id: str, profile_id: str | None = None) -> None:
|
|
959
1264
|
"""Hard-delete a fact.
|
|
960
1265
|
|
|
961
1266
|
DatabaseManager connections enforce FKs (PRAGMA foreign_keys=ON), so
|
|
962
|
-
embedding_metadata / fact_retention
|
|
1267
|
+
embedding_metadata / fact_retention cascade. The explicit
|
|
963
1268
|
embedding_metadata delete below is belt-and-suspenders for the case a
|
|
964
1269
|
future caller routes through a connection without FK enforcement.
|
|
965
1270
|
|
|
1271
|
+
``graph_edges`` does NOT cascade, whatever this docstring used to say.
|
|
1272
|
+
Its only foreign key is to ``profiles``; there is none to
|
|
1273
|
+
``atomic_facts``, because an edge's endpoints can be entity ids as well
|
|
1274
|
+
as fact ids and a single column cannot reference two tables. So the
|
|
1275
|
+
edges are deleted here, explicitly. Without that, deleting a memory left
|
|
1276
|
+
its connections behind pointing at nothing, and the graph tidy-up pass
|
|
1277
|
+
would not reach them until its next run -- during which a search could
|
|
1278
|
+
still follow an edge into a memory that no longer exists.
|
|
1279
|
+
|
|
966
1280
|
Tenant safety: when ``profile_id`` is supplied the delete is constrained
|
|
967
1281
|
to that tenant (the fact must belong to it), so a fact_id from another
|
|
968
1282
|
profile cannot be destroyed. Authorized routes always pass it.
|
|
@@ -974,14 +1288,37 @@ class DatabaseManager:
|
|
|
974
1288
|
)
|
|
975
1289
|
if not row:
|
|
976
1290
|
return # not this tenant's fact — no-op
|
|
977
|
-
|
|
978
|
-
|
|
1291
|
+
# A forgotten memory that survives in the graph or the vector index is
|
|
1292
|
+
# still recallable, which makes an erasure receipt a false statement. So
|
|
1293
|
+
# the deletes and the queued removal are one transaction: the process
|
|
1294
|
+
# can die immediately afterwards and the projections still get cleaned.
|
|
1295
|
+
def _write() -> None:
|
|
1296
|
+
# Read the tenant while the fact is still there. After the DELETE
|
|
1297
|
+
# there is nothing left to resolve it from, and a queued removal
|
|
1298
|
+
# filed under the wrong tenant is a fact id that outlives that
|
|
1299
|
+
# tenant's erasure.
|
|
1300
|
+
owner = profile_id or projection_outbox.resolve_profile(self, fact_id)
|
|
979
1301
|
self.execute(
|
|
980
|
-
"DELETE FROM
|
|
981
|
-
(fact_id, profile_id),
|
|
1302
|
+
"DELETE FROM embedding_metadata WHERE fact_id = ?", (fact_id,),
|
|
982
1303
|
)
|
|
983
|
-
|
|
984
|
-
|
|
1304
|
+
if profile_id is not None:
|
|
1305
|
+
self.execute(
|
|
1306
|
+
"DELETE FROM atomic_facts WHERE fact_id = ? AND profile_id = ?",
|
|
1307
|
+
(fact_id, profile_id),
|
|
1308
|
+
)
|
|
1309
|
+
else:
|
|
1310
|
+
self.execute("DELETE FROM atomic_facts WHERE fact_id = ?", (fact_id,))
|
|
1311
|
+
# Both directions: an edge naming this fact at either end is now an
|
|
1312
|
+
# edge to nothing.
|
|
1313
|
+
self.execute(
|
|
1314
|
+
"DELETE FROM graph_edges WHERE source_id = ? OR target_id = ?",
|
|
1315
|
+
(fact_id, fact_id),
|
|
1316
|
+
)
|
|
1317
|
+
projection_outbox.enqueue(
|
|
1318
|
+
self, fact_id, owner or "default", projection_outbox.OP_DELETE,
|
|
1319
|
+
)
|
|
1320
|
+
|
|
1321
|
+
self._atomically(_write)
|
|
985
1322
|
|
|
986
1323
|
def gc_orphaned_embedding_metadata(self) -> int:
|
|
987
1324
|
"""Remove embedding_metadata rows whose parent atomic_fact is gone.
|
|
@@ -1137,30 +1474,59 @@ class DatabaseManager:
|
|
|
1137
1474
|
duplicate we keep the MAX weight (strongest association wins) and
|
|
1138
1475
|
return the existing edge_id.
|
|
1139
1476
|
"""
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
)
|
|
1146
|
-
|
|
1147
|
-
|
|
1477
|
+
# The edge and the re-projection of its endpoints are one logical write.
|
|
1478
|
+
# Left as separate statements they were three separate commits, which
|
|
1479
|
+
# cost three fsyncs on a path the materializer runs tens of thousands of
|
|
1480
|
+
# times, and left a window where the edge was durable and the intent to
|
|
1481
|
+
# project it was not.
|
|
1482
|
+
def _write() -> str:
|
|
1483
|
+
existing = self.execute(
|
|
1484
|
+
"SELECT edge_id FROM graph_edges "
|
|
1485
|
+
"WHERE profile_id = ? AND source_id = ? AND target_id = ? AND edge_type = ? "
|
|
1486
|
+
"LIMIT 1",
|
|
1487
|
+
(edge.profile_id, edge.source_id, edge.target_id, edge.edge_type.value),
|
|
1488
|
+
)
|
|
1489
|
+
if existing:
|
|
1490
|
+
canonical_id = dict(existing[0])["edge_id"]
|
|
1491
|
+
self.execute(
|
|
1492
|
+
"UPDATE graph_edges SET weight = MAX(weight, ?) WHERE edge_id = ?",
|
|
1493
|
+
(edge.weight, canonical_id),
|
|
1494
|
+
)
|
|
1495
|
+
self._enqueue_edge_endpoints(edge)
|
|
1496
|
+
return canonical_id
|
|
1497
|
+
_scope = getattr(edge, 'scope', None) or 'personal'
|
|
1498
|
+
_shared = _jd(getattr(edge, 'shared_with', None))
|
|
1148
1499
|
self.execute(
|
|
1149
|
-
"
|
|
1150
|
-
|
|
1500
|
+
"""INSERT OR REPLACE INTO graph_edges
|
|
1501
|
+
(edge_id, profile_id, source_id, target_id, edge_type, weight, created_at,
|
|
1502
|
+
scope, shared_with)
|
|
1503
|
+
VALUES (?,?,?,?,?,?,?,?,?)""",
|
|
1504
|
+
(edge.edge_id, edge.profile_id, edge.source_id, edge.target_id,
|
|
1505
|
+
edge.edge_type.value, edge.weight, edge.created_at, _scope, _shared),
|
|
1151
1506
|
)
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
self.
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1507
|
+
self._enqueue_edge_endpoints(edge)
|
|
1508
|
+
return edge.edge_id
|
|
1509
|
+
|
|
1510
|
+
return self._atomically(_write)
|
|
1511
|
+
|
|
1512
|
+
def _enqueue_edge_endpoints(self, edge: GraphEdge) -> None:
|
|
1513
|
+
"""Re-queue both ends of an edge for projection.
|
|
1514
|
+
|
|
1515
|
+
Ingestion is queryable-first: a fact commits immediately and its edges
|
|
1516
|
+
arrive afterwards, from the background materializer. A projection
|
|
1517
|
+
queued only when the fact was inserted would therefore be written
|
|
1518
|
+
before a single edge existed, leaving the node in the graph with none
|
|
1519
|
+
of its connections — which is precisely the adjacency the graph is
|
|
1520
|
+
consulted for.
|
|
1521
|
+
|
|
1522
|
+
An endpoint may be an entity id rather than a fact id. Those are queued
|
|
1523
|
+
too and the drain skips whatever it cannot find as a fact; filtering
|
|
1524
|
+
here would mean a lookup per endpoint on every edge write, which the
|
|
1525
|
+
materializer does tens of thousands of times.
|
|
1526
|
+
"""
|
|
1527
|
+
projection_outbox.enqueue_many(
|
|
1528
|
+
self, (edge.source_id, edge.target_id), edge.profile_id,
|
|
1162
1529
|
)
|
|
1163
|
-
return edge.edge_id
|
|
1164
1530
|
|
|
1165
1531
|
def get_edges_for_node(
|
|
1166
1532
|
self, node_id: str, profile_id: str,
|
|
@@ -1412,6 +1778,58 @@ class DatabaseManager:
|
|
|
1412
1778
|
)
|
|
1413
1779
|
return [self._row_to_fact(r) for r in rows]
|
|
1414
1780
|
|
|
1781
|
+
def visible_fact_ids(
|
|
1782
|
+
self, fact_ids: list[str] | tuple[str, ...], profile_id: str,
|
|
1783
|
+
include_global: bool = False,
|
|
1784
|
+
include_shared: bool = False,
|
|
1785
|
+
*,
|
|
1786
|
+
include_quarantined: bool = False,
|
|
1787
|
+
) -> set[str]:
|
|
1788
|
+
"""Which of *fact_ids* this profile may see. Same rule, no hydration.
|
|
1789
|
+
|
|
1790
|
+
``get_facts_by_ids`` is the authorization source of truth and every
|
|
1791
|
+
retrieval channel re-authorises through it — but a channel deciding
|
|
1792
|
+
*which* of its candidates are allowed does not need their content, and
|
|
1793
|
+
paying for the content is most of what recall costs.
|
|
1794
|
+
|
|
1795
|
+
Measured on the author's store: the entity channel authorised 3,659
|
|
1796
|
+
candidates to return 20, and that single call was **374 ms of a 430 ms
|
|
1797
|
+
recall — 87%**. Not the graph walk, which is 3.8 ms. The cost is
|
|
1798
|
+
``_row_to_fact`` decoding a 768-float embedding and two 768-float Fisher
|
|
1799
|
+
vectors per row: about 8.4 million floats deserialised to answer a
|
|
1800
|
+
yes/no question about 3,659 ids. The same trap is recorded a few
|
|
1801
|
+
hundred lines up, where loading full facts "turned one new fact into a
|
|
1802
|
+
5-second recall stall".
|
|
1803
|
+
|
|
1804
|
+
The predicate is built by the same two calls as ``get_facts_by_ids``, in
|
|
1805
|
+
the same order, so the two cannot answer differently. A test asserts
|
|
1806
|
+
that on the same inputs. Batched because the id list is unbounded and a
|
|
1807
|
+
single ``IN`` clause is not.
|
|
1808
|
+
"""
|
|
1809
|
+
if not fact_ids:
|
|
1810
|
+
return set()
|
|
1811
|
+
where, params = _scope_where(
|
|
1812
|
+
profile_id,
|
|
1813
|
+
include_global=include_global,
|
|
1814
|
+
include_shared=include_shared,
|
|
1815
|
+
)
|
|
1816
|
+
visible = self.visible_fact_clause(include_quarantined=include_quarantined)
|
|
1817
|
+
unique = list(dict.fromkeys(fact_ids))
|
|
1818
|
+
allowed: set[str] = set()
|
|
1819
|
+
# Well inside SQLITE_MAX_VARIABLE_NUMBER once the scope parameters are
|
|
1820
|
+
# added, on every build this ships against.
|
|
1821
|
+
chunk = 800
|
|
1822
|
+
for start in range(0, len(unique), chunk):
|
|
1823
|
+
batch = unique[start:start + chunk]
|
|
1824
|
+
placeholders = ",".join("?" for _ in batch)
|
|
1825
|
+
rows = self.execute(
|
|
1826
|
+
f"SELECT fact_id FROM atomic_facts WHERE fact_id IN ({placeholders}) "
|
|
1827
|
+
f"AND {where}{visible}",
|
|
1828
|
+
(*batch, *params),
|
|
1829
|
+
)
|
|
1830
|
+
allowed.update(dict(row)["fact_id"] for row in rows)
|
|
1831
|
+
return allowed
|
|
1832
|
+
|
|
1415
1833
|
def store_entity_profile(self, ep: EntityProfile) -> str:
|
|
1416
1834
|
"""Persist an entity profile. Returns profile_entry_id."""
|
|
1417
1835
|
self.execute(
|