differix 0.1.0a1__py3-none-any.whl
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.
- agents/editor.py +455 -0
- agents/researcher.py +405 -0
- agents/reviewer.py +374 -0
- cli/model_cli.py +430 -0
- config.py +557 -0
- differix-0.1.0a1.dist-info/METADATA +123 -0
- differix-0.1.0a1.dist-info/RECORD +62 -0
- differix-0.1.0a1.dist-info/WHEEL +5 -0
- differix-0.1.0a1.dist-info/entry_points.txt +2 -0
- differix-0.1.0a1.dist-info/top_level.txt +6 -0
- main.py +2796 -0
- memory/__init__.py +59 -0
- memory/embeddings/__init__.py +29 -0
- memory/embeddings/factory.py +178 -0
- memory/embeddings/providers.py +259 -0
- memory/manager.py +186 -0
- memory/migrator.py +253 -0
- memory/ranker.py +203 -0
- memory/schemas.py +127 -0
- memory/storage.py +552 -0
- tools/agents_chat.py +152 -0
- tools/asset_extractor.py +545 -0
- tools/asset_manager.py +115 -0
- tools/atomic_changeset.py +264 -0
- tools/backup_manager.py +122 -0
- tools/changelog_engine.py +261 -0
- tools/coherence_checker.py +771 -0
- tools/compiler.py +531 -0
- tools/component_normalizer.py +130 -0
- tools/context_assembler.py +631 -0
- tools/contract_validator.py +478 -0
- tools/cost_predictor.py +141 -0
- tools/drift_guard.py +196 -0
- tools/file_manager.py +119 -0
- tools/handshake.py +351 -0
- tools/lesson_manager.py +570 -0
- tools/lesson_memory_bridge.py +398 -0
- tools/llm_client.py +103 -0
- tools/llm_selector.py +362 -0
- tools/model_ranker.py +535 -0
- tools/palette_extractor.py +1043 -0
- tools/plugin_manager.py +204 -0
- tools/project_context_gatherer.py +404 -0
- tools/project_scanner.py +41 -0
- tools/project_structure.py +244 -0
- tools/reasoning_logger.py +78 -0
- tools/reflection.py +452 -0
- tools/sandbox_utils.py +56 -0
- tools/security/__init__.py +0 -0
- tools/security/behavior_guard.py +83 -0
- tools/security/verification_firewall.py +113 -0
- tools/security_filter.py +213 -0
- tools/session_adr.py +311 -0
- tools/skill_manager.py +129 -0
- tools/state_manager.py +383 -0
- tools/structure_index.py +570 -0
- tools/test_schema_consistency.py +19 -0
- tools/verification_firewall.py +113 -0
- tools/visual_capture.py +65 -0
- tools/visual_diff.py +220 -0
- tools/visual_reviewer.py +79 -0
- tools/web_search.py +371 -0
memory/manager.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unified MemoryManager: orchestrates SQLiteMemoryStorage + A-Score ranker,
|
|
3
|
+
exposes the feedback loop (trigger / helpful / violation / promote).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
from memory.ranker import rank_memory_nodes_scored
|
|
14
|
+
from memory.schemas import (
|
|
15
|
+
MemoryNode,
|
|
16
|
+
MemoryScope,
|
|
17
|
+
MemoryStorageError,
|
|
18
|
+
RankingWeights,
|
|
19
|
+
ScoredMemoryNode,
|
|
20
|
+
)
|
|
21
|
+
from memory.storage import SQLiteMemoryStorage
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MemoryManager:
|
|
27
|
+
"""High-level façade for lesson / memory node management."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
storage: Optional[SQLiteMemoryStorage] = None,
|
|
32
|
+
db_path: str = ":memory:",
|
|
33
|
+
expected_dim: Optional[int] = None,
|
|
34
|
+
default_weights: Optional[RankingWeights] = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
if storage is not None:
|
|
37
|
+
if not isinstance(storage, SQLiteMemoryStorage):
|
|
38
|
+
raise MemoryStorageError(
|
|
39
|
+
f"storage must be a SQLiteMemoryStorage, "
|
|
40
|
+
f"got {type(storage).__name__}"
|
|
41
|
+
)
|
|
42
|
+
self.storage: SQLiteMemoryStorage = storage
|
|
43
|
+
else:
|
|
44
|
+
self.storage = SQLiteMemoryStorage(
|
|
45
|
+
db_path=db_path, expected_dim=expected_dim
|
|
46
|
+
)
|
|
47
|
+
self.weights: RankingWeights = default_weights or RankingWeights()
|
|
48
|
+
|
|
49
|
+
# ------------------------------------------------------------------ #
|
|
50
|
+
# Writes
|
|
51
|
+
# ------------------------------------------------------------------ #
|
|
52
|
+
def add(self, node: MemoryNode) -> None:
|
|
53
|
+
self.storage.insert(node)
|
|
54
|
+
|
|
55
|
+
def add_batch(self, nodes: List[MemoryNode]) -> None:
|
|
56
|
+
self.storage.insert_batch(nodes)
|
|
57
|
+
|
|
58
|
+
def create(
|
|
59
|
+
self,
|
|
60
|
+
content: str,
|
|
61
|
+
vector: List[float],
|
|
62
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
63
|
+
node_id: Optional[str] = None,
|
|
64
|
+
base_trust: float = 0.3,
|
|
65
|
+
is_critical: bool = False,
|
|
66
|
+
scope_type: MemoryScope = MemoryScope.GLOBAL,
|
|
67
|
+
scope_value: str = "*",
|
|
68
|
+
created_at: Optional[float] = None,
|
|
69
|
+
) -> MemoryNode:
|
|
70
|
+
now = time.time()
|
|
71
|
+
node = MemoryNode(
|
|
72
|
+
node_id=node_id if node_id is not None else str(uuid.uuid4()),
|
|
73
|
+
vector=vector,
|
|
74
|
+
content=content,
|
|
75
|
+
metadata=metadata or {},
|
|
76
|
+
base_trust=base_trust,
|
|
77
|
+
is_critical=is_critical,
|
|
78
|
+
scope_type=scope_type,
|
|
79
|
+
scope_value=scope_value,
|
|
80
|
+
created_at=created_at if created_at is not None else now,
|
|
81
|
+
last_used_at=created_at if created_at is not None else now,
|
|
82
|
+
)
|
|
83
|
+
self.storage.insert(node)
|
|
84
|
+
return node
|
|
85
|
+
|
|
86
|
+
# ------------------------------------------------------------------ #
|
|
87
|
+
# Reads
|
|
88
|
+
# ------------------------------------------------------------------ #
|
|
89
|
+
def get(self, node_id: str) -> MemoryNode:
|
|
90
|
+
return self.storage.get(node_id)
|
|
91
|
+
|
|
92
|
+
def list_all(self) -> List[MemoryNode]:
|
|
93
|
+
return self.storage.list_all()
|
|
94
|
+
|
|
95
|
+
def count(self) -> int:
|
|
96
|
+
return self.storage.count()
|
|
97
|
+
|
|
98
|
+
# ------------------------------------------------------------------ #
|
|
99
|
+
# Retrieval
|
|
100
|
+
# ------------------------------------------------------------------ #
|
|
101
|
+
def search(
|
|
102
|
+
self,
|
|
103
|
+
query_vector: List[float],
|
|
104
|
+
limit: int = 10,
|
|
105
|
+
query_scope: Optional[MemoryScope] = None,
|
|
106
|
+
query_scope_value: Optional[str] = None,
|
|
107
|
+
weights: Optional[RankingWeights] = None,
|
|
108
|
+
candidate_pool: Optional[int] = None,
|
|
109
|
+
record_trigger: bool = True,
|
|
110
|
+
reference_time: Optional[float] = None,
|
|
111
|
+
) -> List[ScoredMemoryNode]:
|
|
112
|
+
if limit <= 0:
|
|
113
|
+
return []
|
|
114
|
+
pool = candidate_pool if candidate_pool is not None else max(limit * 4, 100)
|
|
115
|
+
candidates = self.storage.list_all()
|
|
116
|
+
if not candidates:
|
|
117
|
+
return []
|
|
118
|
+
|
|
119
|
+
eff_weights = weights or self.weights
|
|
120
|
+
scored = rank_memory_nodes_scored(
|
|
121
|
+
nodes=candidates,
|
|
122
|
+
query_vector=query_vector,
|
|
123
|
+
weights=eff_weights,
|
|
124
|
+
query_scope=query_scope,
|
|
125
|
+
query_scope_value=query_scope_value,
|
|
126
|
+
top_k=pool,
|
|
127
|
+
reference_time=reference_time,
|
|
128
|
+
)
|
|
129
|
+
top = scored[:limit]
|
|
130
|
+
|
|
131
|
+
if record_trigger and top:
|
|
132
|
+
ids = [s.node.node_id for s in top]
|
|
133
|
+
ts = time.time() if reference_time is None else float(reference_time)
|
|
134
|
+
try:
|
|
135
|
+
self.storage.record_trigger(ids, touched_at=ts)
|
|
136
|
+
for s in top:
|
|
137
|
+
s.node.trigger_count += 1
|
|
138
|
+
s.node.last_used_at = ts
|
|
139
|
+
except MemoryStorageError as exc:
|
|
140
|
+
logger.warning(
|
|
141
|
+
"Failed to persist trigger feedback for %d nodes: %s",
|
|
142
|
+
len(ids), exc,
|
|
143
|
+
)
|
|
144
|
+
return top
|
|
145
|
+
|
|
146
|
+
# ------------------------------------------------------------------ #
|
|
147
|
+
# Feedback loop
|
|
148
|
+
# ------------------------------------------------------------------ #
|
|
149
|
+
def record_trigger(
|
|
150
|
+
self, node_ids: List[str], touched_at: Optional[float] = None
|
|
151
|
+
) -> int:
|
|
152
|
+
"""Increment T (trigger_count) and refresh last_used_at for each node."""
|
|
153
|
+
return self.storage.record_trigger(node_ids, touched_at=touched_at)
|
|
154
|
+
|
|
155
|
+
def record_helpful(self, node_ids: List[str]) -> int:
|
|
156
|
+
"""Increment H (helpful_count) for each node."""
|
|
157
|
+
return self.storage.record_helpful(node_ids)
|
|
158
|
+
|
|
159
|
+
def record_violation(self, node_ids: List[str]) -> int:
|
|
160
|
+
"""Increment V (violation_count) for each node."""
|
|
161
|
+
return self.storage.record_violation(node_ids)
|
|
162
|
+
|
|
163
|
+
def promote(self, node_id: str, base_trust: float = 1.0) -> None:
|
|
164
|
+
"""Human promotion: raise B and mark critical (decay-resistant)."""
|
|
165
|
+
self.storage.promote(node_id, base_trust=base_trust)
|
|
166
|
+
|
|
167
|
+
# ------------------------------------------------------------------ #
|
|
168
|
+
# Deletes / lifecycle
|
|
169
|
+
# ------------------------------------------------------------------ #
|
|
170
|
+
def delete(self, node_id: str) -> bool:
|
|
171
|
+
return self.storage.delete(node_id)
|
|
172
|
+
|
|
173
|
+
def clear(self) -> None:
|
|
174
|
+
self.storage.clear()
|
|
175
|
+
|
|
176
|
+
def close(self) -> None:
|
|
177
|
+
self.storage.close()
|
|
178
|
+
|
|
179
|
+
def __enter__(self) -> "MemoryManager":
|
|
180
|
+
return self
|
|
181
|
+
|
|
182
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
183
|
+
self.close()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
__all__ = ["MemoryManager"]
|
memory/migrator.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Vector migration across embedding provider changes.
|
|
3
|
+
|
|
4
|
+
When the active provider's signature differs from what's recorded in the
|
|
5
|
+
database (or when no signature is recorded but rows already exist), existing
|
|
6
|
+
vectors are incompatible with new queries. This module re-embeds every
|
|
7
|
+
stored node from its `content` field and swaps them atomically, preserving
|
|
8
|
+
all A-Score signals (base_trust, trigger_count, helpful_count,
|
|
9
|
+
violation_count, is_critical, scope, timestamps).
|
|
10
|
+
|
|
11
|
+
Public API:
|
|
12
|
+
check_and_migrate(storage, provider) -> MigrationReport
|
|
13
|
+
|
|
14
|
+
Design:
|
|
15
|
+
- Re-embedding happens BEFORE any write, so a failing provider leaves
|
|
16
|
+
the DB untouched (the caller can retry with a different provider).
|
|
17
|
+
- The swap uses `storage.replace_all()` which is transactional at the
|
|
18
|
+
SQLite layer.
|
|
19
|
+
- If there are existing rows and no signature, we MUST re-embed — the
|
|
20
|
+
vector dimensions of pre-signature rows are unknown. This closes the
|
|
21
|
+
failure mode where a fresh schema_meta table caused old vectors to be
|
|
22
|
+
silently accepted as if they matched a new provider.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import logging
|
|
28
|
+
import time
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from typing import List, Optional, Tuple
|
|
31
|
+
|
|
32
|
+
from memory.embeddings import BaseEmbeddingProvider
|
|
33
|
+
from memory.schemas import MemoryNode, MemoryStorageError
|
|
34
|
+
from memory.storage import SQLiteMemoryStorage
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_SIGNATURE_KEY = "embedding_signature"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class MigrationReport:
|
|
44
|
+
action: str # "init" | "none" | "migrated" | "aborted"
|
|
45
|
+
rows_migrated: int = 0
|
|
46
|
+
old_signature: Optional[Tuple[str, str, int]] = None
|
|
47
|
+
new_signature: Optional[Tuple[str, str, int]] = None
|
|
48
|
+
elapsed_ms: float = 0.0
|
|
49
|
+
error: Optional[str] = None
|
|
50
|
+
|
|
51
|
+
def __str__(self) -> str:
|
|
52
|
+
if self.action == "none":
|
|
53
|
+
return f"none (already {self.new_signature})"
|
|
54
|
+
if self.action == "init":
|
|
55
|
+
return f"init → {self.new_signature}"
|
|
56
|
+
if self.action == "migrated":
|
|
57
|
+
return (
|
|
58
|
+
f"migrated {self.rows_migrated} rows: "
|
|
59
|
+
f"{self.old_signature} → {self.new_signature} "
|
|
60
|
+
f"in {self.elapsed_ms:.0f}ms"
|
|
61
|
+
)
|
|
62
|
+
return f"aborted: {self.error}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _serialize_signature(sig: Tuple[str, str, int]) -> str:
|
|
66
|
+
return f"{sig[0]}::{sig[1]}::{sig[2]}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _deserialize_signature(raw: str) -> Optional[Tuple[str, str, int]]:
|
|
70
|
+
try:
|
|
71
|
+
parts = raw.split("::")
|
|
72
|
+
if len(parts) != 3:
|
|
73
|
+
return None
|
|
74
|
+
return (parts[0], parts[1], int(parts[2]))
|
|
75
|
+
except (ValueError, IndexError):
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def check_and_migrate(
|
|
80
|
+
storage: SQLiteMemoryStorage,
|
|
81
|
+
provider: BaseEmbeddingProvider,
|
|
82
|
+
) -> MigrationReport:
|
|
83
|
+
"""
|
|
84
|
+
Ensure the storage contains vectors compatible with `provider`.
|
|
85
|
+
|
|
86
|
+
Four outcomes:
|
|
87
|
+
init — fresh DB with no rows, signature recorded, no re-embed
|
|
88
|
+
none — DB signature matches active provider, no-op
|
|
89
|
+
migrated — rows re-embedded and swapped atomically
|
|
90
|
+
aborted — re-embedding failed; DB untouched, next call retries
|
|
91
|
+
"""
|
|
92
|
+
t0 = time.perf_counter()
|
|
93
|
+
new_sig = provider.signature
|
|
94
|
+
|
|
95
|
+
raw = storage.get_meta(_SIGNATURE_KEY)
|
|
96
|
+
stored_sig = _deserialize_signature(raw) if raw else None
|
|
97
|
+
|
|
98
|
+
# ---- Case 1: fresh DB with no rows and no signature ------------------
|
|
99
|
+
if stored_sig is None:
|
|
100
|
+
try:
|
|
101
|
+
row_count = storage.count()
|
|
102
|
+
except MemoryStorageError as exc:
|
|
103
|
+
return MigrationReport(
|
|
104
|
+
action="aborted",
|
|
105
|
+
new_signature=new_sig,
|
|
106
|
+
error=f"Failed to count rows: {exc}",
|
|
107
|
+
elapsed_ms=(time.perf_counter() - t0) * 1000.0,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
if row_count == 0:
|
|
111
|
+
storage.set_meta(_SIGNATURE_KEY, _serialize_signature(new_sig))
|
|
112
|
+
return MigrationReport(
|
|
113
|
+
action="init",
|
|
114
|
+
new_signature=new_sig,
|
|
115
|
+
elapsed_ms=(time.perf_counter() - t0) * 1000.0,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# ---- Case 2: rows exist but no signature was ever recorded --------
|
|
119
|
+
# The rows' vector dimensions are unknown; we cannot trust them to
|
|
120
|
+
# match the new provider. Re-embed all of them.
|
|
121
|
+
logger.warning(
|
|
122
|
+
"Found %d pre-existing rows with no recorded signature. "
|
|
123
|
+
"Re-embedding all rows with the active provider (%s).",
|
|
124
|
+
row_count,
|
|
125
|
+
new_sig,
|
|
126
|
+
)
|
|
127
|
+
return _reembed_and_swap(
|
|
128
|
+
storage=storage,
|
|
129
|
+
provider=provider,
|
|
130
|
+
new_sig=new_sig,
|
|
131
|
+
old_sig=None,
|
|
132
|
+
reason="no-signature-with-rows",
|
|
133
|
+
start_time=t0,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# ---- Case 3: signature matches — no-op ------------------------------
|
|
137
|
+
if stored_sig == new_sig:
|
|
138
|
+
return MigrationReport(
|
|
139
|
+
action="none",
|
|
140
|
+
old_signature=stored_sig,
|
|
141
|
+
new_signature=new_sig,
|
|
142
|
+
elapsed_ms=(time.perf_counter() - t0) * 1000.0,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# ---- Case 4: signature mismatch — re-embed --------------------------
|
|
146
|
+
logger.warning(
|
|
147
|
+
"Embedding signature changed: %s → %s. Re-embedding all rows.",
|
|
148
|
+
stored_sig,
|
|
149
|
+
new_sig,
|
|
150
|
+
)
|
|
151
|
+
return _reembed_and_swap(
|
|
152
|
+
storage=storage,
|
|
153
|
+
provider=provider,
|
|
154
|
+
new_sig=new_sig,
|
|
155
|
+
old_sig=stored_sig,
|
|
156
|
+
reason="signature-mismatch",
|
|
157
|
+
start_time=t0,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _reembed_and_swap(
|
|
162
|
+
storage: SQLiteMemoryStorage,
|
|
163
|
+
provider: BaseEmbeddingProvider,
|
|
164
|
+
new_sig: Tuple[str, str, int],
|
|
165
|
+
old_sig: Optional[Tuple[str, str, int]],
|
|
166
|
+
reason: str,
|
|
167
|
+
start_time: float,
|
|
168
|
+
) -> MigrationReport:
|
|
169
|
+
"""
|
|
170
|
+
Shared re-embedding logic for both mismatch and pre-existing-rows cases.
|
|
171
|
+
"""
|
|
172
|
+
try:
|
|
173
|
+
existing_nodes: List[MemoryNode] = storage.list_all()
|
|
174
|
+
except MemoryStorageError as exc:
|
|
175
|
+
return MigrationReport(
|
|
176
|
+
action="aborted",
|
|
177
|
+
old_signature=old_sig,
|
|
178
|
+
new_signature=new_sig,
|
|
179
|
+
error=f"Failed to read existing nodes: {exc}",
|
|
180
|
+
elapsed_ms=(time.perf_counter() - start_time) * 1000.0,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
if not existing_nodes:
|
|
184
|
+
# Rows disappeared between count and list — treat as init.
|
|
185
|
+
storage.set_meta(_SIGNATURE_KEY, _serialize_signature(new_sig))
|
|
186
|
+
return MigrationReport(
|
|
187
|
+
action="init",
|
|
188
|
+
old_signature=old_sig,
|
|
189
|
+
new_signature=new_sig,
|
|
190
|
+
elapsed_ms=(time.perf_counter() - start_time) * 1000.0,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# Re-embed everything BEFORE touching the DB. A failing provider must
|
|
194
|
+
# not leave the storage in a half-migrated state.
|
|
195
|
+
try:
|
|
196
|
+
contents = [n.content for n in existing_nodes]
|
|
197
|
+
new_vectors = provider.embed_batch(contents)
|
|
198
|
+
if len(new_vectors) != len(existing_nodes):
|
|
199
|
+
raise RuntimeError(
|
|
200
|
+
f"Provider returned {len(new_vectors)} vectors "
|
|
201
|
+
f"for {len(existing_nodes)} inputs"
|
|
202
|
+
)
|
|
203
|
+
# Validate each returned vector has the expected dimension
|
|
204
|
+
for i, vec in enumerate(new_vectors):
|
|
205
|
+
if not isinstance(vec, list) or len(vec) != provider.dim:
|
|
206
|
+
raise RuntimeError(
|
|
207
|
+
f"Provider returned vector {i} with shape "
|
|
208
|
+
f"{len(vec) if isinstance(vec, list) else type(vec).__name__}, "
|
|
209
|
+
f"expected {provider.dim}"
|
|
210
|
+
)
|
|
211
|
+
except Exception as exc:
|
|
212
|
+
logger.error("Re-embedding failed (%s): %s", reason, exc)
|
|
213
|
+
return MigrationReport(
|
|
214
|
+
action="aborted",
|
|
215
|
+
old_signature=old_sig,
|
|
216
|
+
new_signature=new_sig,
|
|
217
|
+
error=f"Re-embedding failed: {exc}",
|
|
218
|
+
elapsed_ms=(time.perf_counter() - start_time) * 1000.0,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# Build the new node set — only `vector` changes.
|
|
222
|
+
migrated_nodes: List[MemoryNode] = []
|
|
223
|
+
for node, new_vec in zip(existing_nodes, new_vectors):
|
|
224
|
+
migrated_nodes.append(
|
|
225
|
+
node.model_copy(update={"vector": list(new_vec)})
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# Atomic swap. Update the storage's dimension constraint first so
|
|
229
|
+
# `_validate_vector` accepts the new length.
|
|
230
|
+
storage.set_expected_dim(provider.dim)
|
|
231
|
+
try:
|
|
232
|
+
storage.replace_all(migrated_nodes)
|
|
233
|
+
storage.set_meta(_SIGNATURE_KEY, _serialize_signature(new_sig))
|
|
234
|
+
except MemoryStorageError as exc:
|
|
235
|
+
# Best effort: keep the DB as-is. The next run retries.
|
|
236
|
+
return MigrationReport(
|
|
237
|
+
action="aborted",
|
|
238
|
+
old_signature=old_sig,
|
|
239
|
+
new_signature=new_sig,
|
|
240
|
+
error=f"Atomic swap failed: {exc}",
|
|
241
|
+
elapsed_ms=(time.perf_counter() - start_time) * 1000.0,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
return MigrationReport(
|
|
245
|
+
action="migrated",
|
|
246
|
+
rows_migrated=len(migrated_nodes),
|
|
247
|
+
old_signature=old_sig,
|
|
248
|
+
new_signature=new_sig,
|
|
249
|
+
elapsed_ms=(time.perf_counter() - start_time) * 1000.0,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
__all__ = ["check_and_migrate", "MigrationReport"]
|
memory/ranker.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Activation Score (A-Score) ranking for MemoryNode candidates.
|
|
3
|
+
|
|
4
|
+
A = [ (B · (1 + H) · Sim^α · ScopeMatch) / (T + 2 + V) ]
|
|
5
|
+
· e^(−λ_eff · Δt) + ε
|
|
6
|
+
|
|
7
|
+
λ_eff = λ_base / (1 + β · 1_critical)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import math
|
|
14
|
+
import time
|
|
15
|
+
from typing import List, Optional, Sequence, Tuple
|
|
16
|
+
|
|
17
|
+
from memory.schemas import (
|
|
18
|
+
DimensionMismatchError,
|
|
19
|
+
MemoryNode,
|
|
20
|
+
MemoryScope,
|
|
21
|
+
MemoryStorageError,
|
|
22
|
+
RankingWeights,
|
|
23
|
+
ScoredMemoryNode,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _validate_vector(vector: Sequence[float], label: str) -> None:
|
|
28
|
+
if not vector:
|
|
29
|
+
raise MemoryStorageError(f"{label} must be non-empty.")
|
|
30
|
+
for idx, value in enumerate(vector):
|
|
31
|
+
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
|
32
|
+
raise MemoryStorageError(
|
|
33
|
+
f"{label} element at index {idx} is not numeric: {value!r}"
|
|
34
|
+
)
|
|
35
|
+
if math.isnan(value) or math.isinf(value):
|
|
36
|
+
raise MemoryStorageError(
|
|
37
|
+
f"{label} contains NaN/Inf at index {idx}: {value!r}"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def cosine_similarity(a: Sequence[float], b: Sequence[float]) -> float:
|
|
42
|
+
if len(a) != len(b):
|
|
43
|
+
raise DimensionMismatchError(len(a), len(b))
|
|
44
|
+
dot = 0.0
|
|
45
|
+
na = 0.0
|
|
46
|
+
nb = 0.0
|
|
47
|
+
for x, y in zip(a, b):
|
|
48
|
+
dot += x * y
|
|
49
|
+
na += x * x
|
|
50
|
+
nb += y * y
|
|
51
|
+
if na == 0.0 or nb == 0.0:
|
|
52
|
+
return 0.0
|
|
53
|
+
raw = dot / (math.sqrt(na) * math.sqrt(nb))
|
|
54
|
+
return max(-1.0, min(1.0, raw))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _normalized_similarity(a: Sequence[float], b: Sequence[float]) -> float:
|
|
58
|
+
return (cosine_similarity(a, b) + 1.0) / 2.0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _deterministic_jitter(node_id: str, scale: float) -> float:
|
|
62
|
+
if scale <= 0.0:
|
|
63
|
+
return 0.0
|
|
64
|
+
digest = hashlib.sha256(node_id.encode("utf-8")).digest()
|
|
65
|
+
bucket = int.from_bytes(digest[:4], "big") / 0xFFFFFFFF
|
|
66
|
+
return scale * (2.0 * bucket - 1.0)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _scope_match(
|
|
70
|
+
node_scope: MemoryScope,
|
|
71
|
+
node_value: str,
|
|
72
|
+
query_scope: Optional[MemoryScope],
|
|
73
|
+
query_value: Optional[str],
|
|
74
|
+
) -> float:
|
|
75
|
+
if node_scope == MemoryScope.GLOBAL:
|
|
76
|
+
return 1.0
|
|
77
|
+
if query_scope is None:
|
|
78
|
+
return 1.0
|
|
79
|
+
if node_scope == query_scope and node_value == query_value:
|
|
80
|
+
return 1.0
|
|
81
|
+
return 0.5
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def activation_score(
|
|
85
|
+
node: MemoryNode,
|
|
86
|
+
query_vector: Sequence[float],
|
|
87
|
+
weights: Optional[RankingWeights] = None,
|
|
88
|
+
query_scope: Optional[MemoryScope] = None,
|
|
89
|
+
query_scope_value: Optional[str] = None,
|
|
90
|
+
reference_time: Optional[float] = None,
|
|
91
|
+
) -> Tuple[float, float, float, float, float]:
|
|
92
|
+
if weights is None:
|
|
93
|
+
weights = RankingWeights()
|
|
94
|
+
if len(node.vector) != len(query_vector):
|
|
95
|
+
raise DimensionMismatchError(len(node.vector), len(query_vector))
|
|
96
|
+
|
|
97
|
+
sim = _normalized_similarity(query_vector, node.vector)
|
|
98
|
+
sim_term = sim ** float(weights.alpha)
|
|
99
|
+
sm = _scope_match(node.scope_type, node.scope_value, query_scope, query_scope_value)
|
|
100
|
+
|
|
101
|
+
numerator = (
|
|
102
|
+
float(node.base_trust)
|
|
103
|
+
* (1.0 + float(node.helpful_count))
|
|
104
|
+
* sim_term
|
|
105
|
+
* sm
|
|
106
|
+
)
|
|
107
|
+
denominator = float(node.trigger_count) + 2.0 + float(node.violation_count)
|
|
108
|
+
|
|
109
|
+
now = time.time() if reference_time is None else float(reference_time)
|
|
110
|
+
delta_hours = max(0.0, (now - float(node.last_used_at)) / 3600.0)
|
|
111
|
+
protection = 1.0 + weights.beta_protection * (1.0 if node.is_critical else 0.0)
|
|
112
|
+
lambda_eff = float(weights.lambda_base) / protection
|
|
113
|
+
decay = math.exp(-lambda_eff * delta_hours)
|
|
114
|
+
|
|
115
|
+
jitter = _deterministic_jitter(node.node_id, float(weights.epsilon))
|
|
116
|
+
score = max(0.0, (numerator / denominator) * decay + jitter)
|
|
117
|
+
return score, sim, sm, decay, jitter
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _score_and_sort(
|
|
121
|
+
nodes: Sequence[MemoryNode],
|
|
122
|
+
query_vector: Sequence[float],
|
|
123
|
+
weights: RankingWeights,
|
|
124
|
+
query_scope: Optional[MemoryScope],
|
|
125
|
+
query_scope_value: Optional[str],
|
|
126
|
+
reference_time: Optional[float],
|
|
127
|
+
) -> List[ScoredMemoryNode]:
|
|
128
|
+
scored: List[ScoredMemoryNode] = []
|
|
129
|
+
for node in nodes:
|
|
130
|
+
if not isinstance(node, MemoryNode):
|
|
131
|
+
raise MemoryStorageError(
|
|
132
|
+
f"ranker received non-MemoryNode: {type(node).__name__}"
|
|
133
|
+
)
|
|
134
|
+
score, sim, sm, decay, jitter = activation_score(
|
|
135
|
+
node=node,
|
|
136
|
+
query_vector=query_vector,
|
|
137
|
+
weights=weights,
|
|
138
|
+
query_scope=query_scope,
|
|
139
|
+
query_scope_value=query_scope_value,
|
|
140
|
+
reference_time=reference_time,
|
|
141
|
+
)
|
|
142
|
+
scored.append(
|
|
143
|
+
ScoredMemoryNode(
|
|
144
|
+
node=node,
|
|
145
|
+
score=score,
|
|
146
|
+
similarity=sim,
|
|
147
|
+
scope_match=sm,
|
|
148
|
+
decay=decay,
|
|
149
|
+
jitter=jitter,
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
scored.sort(key=lambda s: (-s.score, s.node.node_id))
|
|
153
|
+
return scored
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def rank_memory_nodes(
|
|
157
|
+
nodes: List[MemoryNode],
|
|
158
|
+
query_vector: List[float],
|
|
159
|
+
weights: Optional[RankingWeights] = None,
|
|
160
|
+
query_scope: Optional[MemoryScope] = None,
|
|
161
|
+
query_scope_value: Optional[str] = None,
|
|
162
|
+
top_k: Optional[int] = None,
|
|
163
|
+
reference_time: Optional[float] = None,
|
|
164
|
+
) -> List[MemoryNode]:
|
|
165
|
+
if not nodes:
|
|
166
|
+
return []
|
|
167
|
+
_validate_vector(query_vector, "query_vector")
|
|
168
|
+
eff = weights or RankingWeights()
|
|
169
|
+
scored = _score_and_sort(
|
|
170
|
+
nodes, query_vector, eff, query_scope, query_scope_value, reference_time
|
|
171
|
+
)
|
|
172
|
+
if top_k is not None:
|
|
173
|
+
scored = scored[: max(0, top_k)]
|
|
174
|
+
return [s.node for s in scored]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def rank_memory_nodes_scored(
|
|
178
|
+
nodes: List[MemoryNode],
|
|
179
|
+
query_vector: List[float],
|
|
180
|
+
weights: Optional[RankingWeights] = None,
|
|
181
|
+
query_scope: Optional[MemoryScope] = None,
|
|
182
|
+
query_scope_value: Optional[str] = None,
|
|
183
|
+
top_k: Optional[int] = None,
|
|
184
|
+
reference_time: Optional[float] = None,
|
|
185
|
+
) -> List[ScoredMemoryNode]:
|
|
186
|
+
if not nodes:
|
|
187
|
+
return []
|
|
188
|
+
_validate_vector(query_vector, "query_vector")
|
|
189
|
+
eff = weights or RankingWeights()
|
|
190
|
+
scored = _score_and_sort(
|
|
191
|
+
nodes, query_vector, eff, query_scope, query_scope_value, reference_time
|
|
192
|
+
)
|
|
193
|
+
if top_k is not None:
|
|
194
|
+
scored = scored[: max(0, top_k)]
|
|
195
|
+
return scored
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
__all__ = [
|
|
199
|
+
"cosine_similarity",
|
|
200
|
+
"activation_score",
|
|
201
|
+
"rank_memory_nodes",
|
|
202
|
+
"rank_memory_nodes_scored",
|
|
203
|
+
]
|