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.
Files changed (62) hide show
  1. agents/editor.py +455 -0
  2. agents/researcher.py +405 -0
  3. agents/reviewer.py +374 -0
  4. cli/model_cli.py +430 -0
  5. config.py +557 -0
  6. differix-0.1.0a1.dist-info/METADATA +123 -0
  7. differix-0.1.0a1.dist-info/RECORD +62 -0
  8. differix-0.1.0a1.dist-info/WHEEL +5 -0
  9. differix-0.1.0a1.dist-info/entry_points.txt +2 -0
  10. differix-0.1.0a1.dist-info/top_level.txt +6 -0
  11. main.py +2796 -0
  12. memory/__init__.py +59 -0
  13. memory/embeddings/__init__.py +29 -0
  14. memory/embeddings/factory.py +178 -0
  15. memory/embeddings/providers.py +259 -0
  16. memory/manager.py +186 -0
  17. memory/migrator.py +253 -0
  18. memory/ranker.py +203 -0
  19. memory/schemas.py +127 -0
  20. memory/storage.py +552 -0
  21. tools/agents_chat.py +152 -0
  22. tools/asset_extractor.py +545 -0
  23. tools/asset_manager.py +115 -0
  24. tools/atomic_changeset.py +264 -0
  25. tools/backup_manager.py +122 -0
  26. tools/changelog_engine.py +261 -0
  27. tools/coherence_checker.py +771 -0
  28. tools/compiler.py +531 -0
  29. tools/component_normalizer.py +130 -0
  30. tools/context_assembler.py +631 -0
  31. tools/contract_validator.py +478 -0
  32. tools/cost_predictor.py +141 -0
  33. tools/drift_guard.py +196 -0
  34. tools/file_manager.py +119 -0
  35. tools/handshake.py +351 -0
  36. tools/lesson_manager.py +570 -0
  37. tools/lesson_memory_bridge.py +398 -0
  38. tools/llm_client.py +103 -0
  39. tools/llm_selector.py +362 -0
  40. tools/model_ranker.py +535 -0
  41. tools/palette_extractor.py +1043 -0
  42. tools/plugin_manager.py +204 -0
  43. tools/project_context_gatherer.py +404 -0
  44. tools/project_scanner.py +41 -0
  45. tools/project_structure.py +244 -0
  46. tools/reasoning_logger.py +78 -0
  47. tools/reflection.py +452 -0
  48. tools/sandbox_utils.py +56 -0
  49. tools/security/__init__.py +0 -0
  50. tools/security/behavior_guard.py +83 -0
  51. tools/security/verification_firewall.py +113 -0
  52. tools/security_filter.py +213 -0
  53. tools/session_adr.py +311 -0
  54. tools/skill_manager.py +129 -0
  55. tools/state_manager.py +383 -0
  56. tools/structure_index.py +570 -0
  57. tools/test_schema_consistency.py +19 -0
  58. tools/verification_firewall.py +113 -0
  59. tools/visual_capture.py +65 -0
  60. tools/visual_diff.py +220 -0
  61. tools/visual_reviewer.py +79 -0
  62. 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
+ ]