synapse-memory-engine 1.3.0__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.
@@ -0,0 +1,226 @@
1
+ import logging
2
+ import os
3
+ from typing import Any, List, Optional
4
+
5
+ logger = logging.getLogger("SynapseEncryption")
6
+
7
+ try:
8
+ from cryptography.fernet import Fernet
9
+ HAS_CRYPTOGRAPHY = True
10
+ except ImportError:
11
+ HAS_CRYPTOGRAPHY = False
12
+ Fernet = None # type: ignore[misc,assignment]
13
+
14
+
15
+ class KeyManagementProvider:
16
+ """
17
+ Durable encryption key manager with team-safe loading order.
18
+
19
+ Resolution order (first hit wins):
20
+ 1. `SYNAPSE_ENCRYPTION_KEY` — raw 32-byte urlsafe-base64 Fernet key.
21
+ 2. `SYNAPSE_ENCRYPTION_KEY_B64_FILE` — path to a file containing ONLY the key.
22
+ (The correct way to inject a secret in Docker/K8s without baking it
23
+ into the image or leaving it in process args.)
24
+ 3. KMS/Vault provider hook: if `SYNAPSE_KMS_PROVIDER` is set, the matching
25
+ loader below is invoked with `SYNAPSE_KMS_KEY_REF`. Providers are
26
+ best-effort optional — unknown or misconfigured providers raise, never
27
+ silently fall through, so a prod boot fails loudly instead of
28
+ encrypting with a throwaway key.
29
+ 4. Local keyfile (dev fallback): `SYNAPSE_KEY_FILE` (default `.synapse_key`).
30
+
31
+ Production deployments MUST use (1), (2), or (3). The local keyfile (4) is a
32
+ convenience that must never back team/production data.
33
+ """
34
+ @staticmethod
35
+ def get_encryption_keys(key_file: Optional[str] = None) -> List[bytes]:
36
+ """Return the configured encryption keys, primary (encrypting) key first.
37
+
38
+ Resolution order (first configured source wins):
39
+ 1. `SYNAPSE_ENCRYPTION_KEYS` — comma-separated list of urlsafe-base64
40
+ Fernet keys. The first key encrypts new writes; every listed key can
41
+ decrypt, so older keys can be retired without re-encrypting existing
42
+ ciphertext (MultiFernet rotation).
43
+ 2. `SYNAPSE_ENCRYPTION_KEY` — single key (backward compatible).
44
+ 3. `SYNAPSE_ENCRYPTION_KEY_B64_FILE` — path to a file containing ONE key.
45
+ 4. KMS/Vault provider hook (`SYNAPSE_KMS_PROVIDER` + `SYNAPSE_KMS_KEY_REF`).
46
+ 5. Local keyfile (dev fallback): `SYNAPSE_KEY_FILE` (default `.synapse_key`).
47
+ 6. Auto-generate and persist one durable key (dev only).
48
+
49
+ Production deployments MUST use (1), (2), (3) or (4).
50
+ """
51
+ keys: List[bytes] = []
52
+
53
+ # 1. Direct env key(s) — comma separated; first listed key encrypts.
54
+ env_keys = os.environ.get("SYNAPSE_ENCRYPTION_KEYS")
55
+ if env_keys:
56
+ for k in env_keys.split(","):
57
+ k = k.strip()
58
+ if k:
59
+ keys.append(k.encode("utf-8"))
60
+
61
+ # 2. Backward compatible single key.
62
+ if not keys:
63
+ env_key = os.environ.get("SYNAPSE_ENCRYPTION_KEY")
64
+ if env_key:
65
+ keys.append(env_key.strip().encode("utf-8"))
66
+
67
+ # 3. Key-from-file (Docker secrets / K8s secret volume mount).
68
+ if not keys:
69
+ b64_file = os.environ.get("SYNAPSE_ENCRYPTION_KEY_B64_FILE")
70
+ if b64_file:
71
+ try:
72
+ with open(b64_file, "rb") as f:
73
+ file_key = f.read().strip()
74
+ if file_key:
75
+ logger.info("Loaded encryption key from SYNAPSE_ENCRYPTION_KEY_B64_FILE.")
76
+ keys.append(file_key)
77
+ except Exception as e:
78
+ logger.warning(f"Could not read key file {b64_file}: {e}")
79
+
80
+ # 4. KMS/Vault provider hook.
81
+ if not keys:
82
+ kms_provider = os.environ.get("SYNAPSE_KMS_PROVIDER")
83
+ if kms_provider:
84
+ kms_ref = os.environ.get("SYNAPSE_KMS_KEY_REF", "")
85
+ loader = _KMS_LOADERS.get(kms_provider.lower())
86
+ if loader is None:
87
+ raise RuntimeError(
88
+ f"Unknown SYNAPSE_KMS_PROVIDER '{kms_provider}'. "
89
+ f"Supported: {sorted(_KMS_LOADERS)}."
90
+ )
91
+ logger.info(f"Loading encryption key via KMS provider '{kms_provider}'.")
92
+ keys.append(loader(kms_ref))
93
+
94
+ # 5. Local dev fallback keyfile.
95
+ if not keys:
96
+ target_file = key_file or os.environ.get("SYNAPSE_KEY_FILE", ".synapse_key")
97
+ if os.path.exists(target_file):
98
+ try:
99
+ with open(target_file, "rb") as f:
100
+ file_key = f.read().strip()
101
+ if file_key:
102
+ keys.append(file_key)
103
+ except Exception as e:
104
+ logger.warning(f"Could not read key file {target_file}: {e}")
105
+
106
+ # 6. Generate a durable 32-byte urlsafe base64 key (dev only).
107
+ if not keys:
108
+ new_key = FernetEncryptionProvider.generate_key()
109
+ target_file = key_file or os.environ.get("SYNAPSE_KEY_FILE", ".synapse_key")
110
+ try:
111
+ with open(target_file, "wb") as f:
112
+ f.write(new_key)
113
+ os.chmod(target_file, 0o600)
114
+ except Exception as e:
115
+ logger.warning(f"Could not persist encryption key to {target_file}: {e}")
116
+ keys.append(new_key)
117
+
118
+ return keys
119
+
120
+ @staticmethod
121
+ def get_encryption_key(key_file: Optional[str] = None) -> bytes:
122
+ return KeyManagementProvider.get_encryption_keys(key_file)[0]
123
+
124
+
125
+ def _kms_loader_aws(ref: str) -> bytes:
126
+ """AWS KMS: decrypts a base64 ciphertext blob via kms:Decrypt (boto3 optional)."""
127
+ try:
128
+ import base64 as _b64
129
+ import boto3 # type: ignore
130
+ except ImportError as e:
131
+ raise RuntimeError("SYNAPSE_KMS_PROVIDER=aws requires boto3 to be installed.") from e
132
+ region = os.environ.get("SYNAPSE_KMS_REGION") or os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
133
+ client = boto3.client("kms", region_name=region) if region else boto3.client("kms")
134
+ resp = client.decrypt(CiphertextBlob=_b64.b64decode(ref))
135
+ return _b64.urlsafe_b64encode(resp["Plaintext"][:32].ljust(32, b"\0"))
136
+
137
+
138
+ def _kms_loader_vault(ref: str) -> bytes:
139
+ """HashiCorp Vault KV: reads the key field over HTTPS (hvac optional)."""
140
+ try:
141
+ import hvac # type: ignore
142
+ except ImportError as e:
143
+ raise RuntimeError("SYNAPSE_KMS_PROVIDER=vault requires hvac to be installed.") from e
144
+ addr = os.environ.get("VAULT_ADDR", "")
145
+ token = os.environ.get("VAULT_TOKEN", "")
146
+ field = os.environ.get("SYNAPSE_KMS_FIELD", "key")
147
+ if not addr or not token or not ref:
148
+ raise RuntimeError("vault KMS needs VAULT_ADDR, VAULT_TOKEN and SYNAPSE_KMS_KEY_REF (KV path).")
149
+ client = hvac.Client(url=addr, token=token)
150
+ secret = client.secrets.kv.v2.read_secret_version(path=ref)
151
+ value = (secret.get("data", {}).get("data", {}) or {}).get(field, "")
152
+ if not value:
153
+ raise RuntimeError(f"Vault KV path '{ref}' has no '{field}' field.")
154
+ return str(value).strip().encode("utf-8")
155
+
156
+
157
+ def _kms_loader_file(ref: str) -> bytes:
158
+ """Alias of the key-file loader using an explicit path as the ref."""
159
+ with open(ref, "rb") as f:
160
+ return f.read().strip()
161
+
162
+
163
+ _KMS_LOADERS = {
164
+ "aws": _kms_loader_aws,
165
+ "vault": _kms_loader_vault,
166
+ "file": _kms_loader_file,
167
+ }
168
+
169
+
170
+ # Aliases for backwards compatibility
171
+ FernetKeyManager = KeyManagementProvider
172
+
173
+
174
+ class FernetEncryptionProvider:
175
+ """
176
+ Encryption provider using Fernet symmetric authenticated encryption.
177
+ Underlying specification: 128-bit AES in CBC mode with PKCS7 padding
178
+ and HMAC-SHA256 authenticated encryption.
179
+ """
180
+ def __init__(self, key: Optional[bytes] = None):
181
+ if key is None:
182
+ self.key = KeyManagementProvider.get_encryption_key()
183
+ elif isinstance(key, str):
184
+ self.key = key.encode("utf-8")
185
+ else:
186
+ self.key = key
187
+
188
+ self._cipher: Any = None
189
+ if HAS_CRYPTOGRAPHY and Fernet is not None:
190
+ self._cipher = Fernet(self.key) # type: ignore[operator]
191
+
192
+ @staticmethod
193
+ def generate_key() -> bytes:
194
+ if HAS_CRYPTOGRAPHY and Fernet is not None:
195
+ return Fernet.generate_key()
196
+ raise RuntimeError(
197
+ "Key generation requires the `cryptography` package (Fernet). "
198
+ "Install it with: pip install cryptography"
199
+ )
200
+
201
+ def encrypt(self, data: str) -> str:
202
+ """Encrypts a string and returns a base64 encoded string."""
203
+ if self._cipher is None:
204
+ raise RuntimeError(
205
+ "Encryption requires the `cryptography` package (Fernet). "
206
+ "Install it with: pip install cryptography"
207
+ )
208
+ return self._cipher.encrypt(data.encode("utf-8")).decode("utf-8")
209
+
210
+ def decrypt(self, encrypted_data: str) -> str:
211
+ """Decrypts a base64 encoded string and returns the original string."""
212
+ if self._cipher is None:
213
+ raise RuntimeError(
214
+ "Decryption requires the `cryptography` package (Fernet). "
215
+ "Install it with: pip install cryptography"
216
+ )
217
+ try:
218
+ return self._cipher.decrypt(
219
+ encrypted_data.encode("utf-8")
220
+ ).decode("utf-8")
221
+ except Exception as e:
222
+ raise ValueError("Decryption failed: invalid key or payload") from e
223
+
224
+
225
+ # Aliases for backwards compatibility
226
+ EncryptedMemoryStore = FernetEncryptionProvider
@@ -0,0 +1,199 @@
1
+ import math
2
+ import re
3
+ from typing import List, Dict, Any, Optional
4
+
5
+
6
+ def _tokenize(text: str) -> List[str]:
7
+ return [w for w in re.findall(r'[a-zA-Z0-9_]+', text.lower()) if len(w) > 1]
8
+
9
+
10
+ class HybridSearch:
11
+ """
12
+ State-of-the-art Hybrid Information Retrieval Engine.
13
+ Combines Lexical Sparse Search (real BM25 algorithm) with Dense Cosine
14
+ vector similarity, fused via Reciprocal Rank Fusion (RRF).
15
+ """
16
+
17
+ def __init__(self, k1: float = 1.5, b: float = 0.75, rrf_k: int = 60, alpha: float = 0.5, k: Optional[int] = None, **kwargs: Any):
18
+ """
19
+ Args:
20
+ k1: BM25 term frequency saturation parameter.
21
+ b: BM25 document length normalization parameter.
22
+ rrf_k: Constant used to smooth Reciprocal Rank Fusion ranks.
23
+ alpha: Hybrid fusion weight between dense (alpha) and sparse (1 - alpha).
24
+ k: Alias for rrf_k or top_k fallback.
25
+ """
26
+ self.k1 = k1
27
+ self.b = b
28
+ self.rrf_k = k if k is not None else rrf_k
29
+ self.alpha = alpha
30
+
31
+ def _calculate_bm25_scores(self, query_terms: List[str], corpus: List[Dict[str, Any]]) -> Dict[str, float]:
32
+ """
33
+ Calculates exact Okapi BM25 scores for all documents in the corpus.
34
+ """
35
+ scores = {doc["id"]: 0.0 for doc in corpus}
36
+ if not query_terms or not corpus:
37
+ return scores
38
+
39
+ N = len(corpus)
40
+ tokenized_corpus = {doc["id"]: _tokenize(doc.get("content", "")) for doc in corpus}
41
+ doc_lengths = {doc_id: len(words) for doc_id, words in tokenized_corpus.items()}
42
+ avgdl = sum(doc_lengths.values()) / max(1, N)
43
+
44
+ # 1. Compute Document Frequency (df) for each query term
45
+ df = {}
46
+ for term in query_terms:
47
+ df[term] = sum(1 for words in tokenized_corpus.values() if term in words)
48
+
49
+ # 2. Compute BM25 scores for each document
50
+ for term in query_terms:
51
+ n_q = df.get(term, 0)
52
+ # Standard IDF with floor to avoid negative values
53
+ idf = math.log((N - n_q + 0.5) / (n_q + 0.5) + 1.0)
54
+ idf = max(0.0001, idf)
55
+
56
+ for doc in corpus:
57
+ doc_id = doc["id"]
58
+ words = tokenized_corpus[doc_id]
59
+ tf = words.count(term)
60
+
61
+ if tf > 0:
62
+ dl = doc_lengths[doc_id]
63
+ numerator = tf * (self.k1 + 1.0)
64
+ denominator = tf + self.k1 * (1.0 - self.b + self.b * (dl / max(1.0, avgdl)))
65
+ scores[doc_id] += idf * (numerator / denominator)
66
+
67
+ return scores
68
+
69
+ def _calculate_dense_similarities(
70
+ self,
71
+ query_vector: List[float],
72
+ corpus: List[Dict[str, Any]]
73
+ ) -> Dict[str, float]:
74
+ """Calculates true Cosine similarity between query vector and document embeddings."""
75
+ similarities = {doc["id"]: 0.0 for doc in corpus}
76
+ if not query_vector:
77
+ return similarities
78
+
79
+ norm_q = math.sqrt(sum(q * q for q in query_vector))
80
+ if norm_q == 0.0:
81
+ return similarities
82
+
83
+ for doc in corpus:
84
+ doc_id = doc["id"]
85
+ doc_vec = doc.get("embedding", [])
86
+
87
+ if not doc_vec or len(doc_vec) != len(query_vector):
88
+ continue
89
+
90
+ dot_product = sum(q * d for q, d in zip(query_vector, doc_vec))
91
+ norm_d = math.sqrt(sum(d * d for d in doc_vec))
92
+
93
+ if norm_d > 0.0:
94
+ similarities[doc_id] = dot_product / (norm_q * norm_d)
95
+
96
+ return similarities
97
+
98
+ def fused_search(
99
+ self,
100
+ query: str,
101
+ documents: List[Dict[str, Any]],
102
+ query_vector: Optional[List[float]] = None,
103
+ top_k: int = 10,
104
+ category_filter: Optional[str] = None,
105
+ tenant_filter: Optional[str] = None
106
+ ) -> List[Dict[str, Any]]:
107
+ """
108
+ Executes unified Reciprocal Rank Fusion (RRF) combining Sparse BM25 and Dense Cosine ranks.
109
+ Provides retrieval explainability via detailed score breakdown.
110
+
111
+ Formula:
112
+ RRF_Score(d) = 1 / (rrf_k + rank_bm25(d)) + 1 / (rrf_k + rank_dense(d))
113
+
114
+ Args:
115
+ query: Raw query string.
116
+ documents: List of memory documents.
117
+ query_vector: Dense vector array representing the query.
118
+ top_k: Maximum elements to return.
119
+ category_filter: Filter by memory category.
120
+ tenant_filter: Filter by tenant_id.
121
+ """
122
+ # 1. Apply Filtering
123
+ filtered_docs = documents
124
+ if category_filter:
125
+ filtered_docs = [d for d in filtered_docs if d.get("category") == category_filter]
126
+ if tenant_filter:
127
+ filtered_docs = [d for d in filtered_docs if d.get("tenant_id") == tenant_filter]
128
+
129
+ if not filtered_docs:
130
+ return []
131
+
132
+ query_terms = _tokenize(query)
133
+
134
+ # 2. Compute pure Lexical BM25 scores and sort to get Sparse ranks
135
+ bm25_scores = self._calculate_bm25_scores(query_terms, filtered_docs)
136
+ sparse_ranking = sorted(filtered_docs, key=lambda x: bm25_scores[x["id"]], reverse=True)
137
+ sparse_ranks = {doc["id"]: idx + 1 for idx, doc in enumerate(sparse_ranking)}
138
+
139
+ # 3. Compute pure Cosine Vector similarities and sort to get Dense ranks
140
+ dense_scores = self._calculate_dense_similarities(query_vector or [], filtered_docs)
141
+ dense_ranking = sorted(filtered_docs, key=lambda x: dense_scores[x["id"]], reverse=True)
142
+ dense_ranks = {doc["id"]: idx + 1 for idx, doc in enumerate(dense_ranking)}
143
+
144
+ # 4. Apply Reciprocal Rank Fusion (RRF)
145
+ results = []
146
+ for doc in filtered_docs:
147
+ doc_id = doc["id"]
148
+
149
+ # Reciprocal sparse rank score
150
+ rank_s = sparse_ranks.get(doc_id, len(filtered_docs))
151
+ rrf_sparse = 1.0 / (self.rrf_k + rank_s)
152
+
153
+ # Reciprocal dense rank score
154
+ rank_d = dense_ranks.get(doc_id, len(filtered_docs))
155
+ rrf_dense = 1.0 / (self.rrf_k + rank_d)
156
+
157
+ fused_score = rrf_sparse + rrf_dense
158
+
159
+ # Structure Explainability Data
160
+ explanation = {
161
+ "lexical_score": bm25_scores[doc_id],
162
+ "semantic_score": dense_scores[doc_id],
163
+ "fused_score": fused_score,
164
+ "sparse_rank": rank_s,
165
+ "dense_rank": rank_d
166
+ }
167
+
168
+ scored_doc = doc.copy()
169
+ scored_doc["relevance_score"] = fused_score
170
+ scored_doc["explanation"] = explanation
171
+ results.append(scored_doc)
172
+
173
+ # 5. Final Ranking
174
+ results.sort(key=lambda x: x["relevance_score"], reverse=True)
175
+ return results[:top_k]
176
+
177
+ def search(
178
+ self,
179
+ query: str,
180
+ corpus: Optional[List[Dict[str, Any]]] = None,
181
+ documents: Optional[List[Dict[str, Any]]] = None,
182
+ query_vector: Optional[List[float]] = None,
183
+ top_k: int = 10,
184
+ category_filter: Optional[str] = None,
185
+ tenant_filter: Optional[str] = None,
186
+ **kwargs: Any
187
+ ) -> List[Dict[str, Any]]:
188
+ """
189
+ Intuitive alias for fused_search supporting both 'corpus' and 'documents' arguments.
190
+ """
191
+ target_docs = corpus if corpus is not None else (documents or [])
192
+ return self.fused_search(
193
+ query=query,
194
+ documents=target_docs,
195
+ query_vector=query_vector,
196
+ top_k=top_k,
197
+ category_filter=category_filter,
198
+ tenant_filter=tenant_filter
199
+ )
@@ -0,0 +1,103 @@
1
+ from typing import List, Dict, Any, Optional
2
+ import logging
3
+
4
+ logger = logging.getLogger("KnapsackPacker")
5
+
6
+
7
+ class MemoryNodeResult(dict):
8
+ """
9
+ Dual-contract result node that acts both as a dictionary with keys
10
+ ('id', 'content', etc.) and equals its string ID for backwards-compatible
11
+ membership checks.
12
+ """
13
+ def __eq__(self, other: Any) -> bool:
14
+ if isinstance(other, str):
15
+ return self.get("id") == other
16
+ return super().__eq__(other)
17
+
18
+ def __hash__(self) -> int: # type: ignore[override]
19
+ return hash(self.get("id", ""))
20
+
21
+
22
+ class KnapsackPacker:
23
+ """
24
+ Robust 0/1 Knapsack optimizer for packing memory nodes into a token budget.
25
+ Implements a memory-efficient Dynamic Programming solution with a greedy fallback.
26
+ Auto-switches to greedy when n*budget exceeds threshold to bound latency.
27
+ """
28
+ MAX_BUDGET = 8000
29
+ DP_OPS_THRESHOLD = 2_000_000
30
+
31
+ def __init__(self, token_budget: int = 4096):
32
+ self.token_budget = min(token_budget, self.MAX_BUDGET)
33
+
34
+ def pack(
35
+ self,
36
+ memories: List[Dict[str, Any]],
37
+ token_budget: Optional[int] = None
38
+ ) -> List[MemoryNodeResult]:
39
+ """
40
+ Packs memories into the token budget using a DP approach.
41
+
42
+ Args:
43
+ memories: List of memory nodes with 'id', 'token_cost', and 'relevance_score'.
44
+ token_budget: Optional override for budget. If None, uses self.token_budget.
45
+
46
+ Returns:
47
+ List of packed memory nodes that maximize total relevance score within budget.
48
+ """
49
+ budget = token_budget if token_budget is not None else self.token_budget
50
+ budget = min(budget, self.MAX_BUDGET)
51
+ if not memories or budget <= 0:
52
+ return []
53
+
54
+ # Guard: DP is O(n*budget). Fall back to greedy for huge inputs to keep p99 low.
55
+ if len(memories) * budget > self.DP_OPS_THRESHOLD:
56
+ logger.warning(
57
+ "Knapsack DP input too large (n=%d, budget=%d); using greedy fallback.",
58
+ len(memories),
59
+ budget,
60
+ )
61
+ return self.greedy_pack(memories, token_budget=budget)
62
+
63
+ n = len(memories)
64
+ weights = [max(1, int(m.get("token_cost", 1))) for m in memories]
65
+ values = [max(0, int(float(m.get("relevance_score", 0.0)) * 1000)) for m in memories]
66
+
67
+ dp = [0] * (budget + 1)
68
+ items_included: List[List[int]] = [[] for _ in range(budget + 1)]
69
+
70
+ for i in range(n):
71
+ weight = weights[i]
72
+ value = values[i]
73
+ if weight > budget:
74
+ continue
75
+ for w in range(budget, weight - 1, -1):
76
+ if dp[w - weight] + value > dp[w]:
77
+ dp[w] = dp[w - weight] + value
78
+ items_included[w] = items_included[w - weight] + [i]
79
+
80
+ selected_indices = items_included[budget]
81
+ return [MemoryNodeResult(memories[idx]) for idx in selected_indices]
82
+
83
+ def greedy_pack(
84
+ self,
85
+ memories: List[Dict[str, Any]],
86
+ token_budget: Optional[int] = None
87
+ ) -> List[MemoryNodeResult]:
88
+ """Greedy fallback: Pack by highest relevance/cost ratio."""
89
+ budget = token_budget if token_budget is not None else self.token_budget
90
+ sorted_memories = sorted(
91
+ memories,
92
+ key=lambda m: float(m.get("relevance_score", 0.0)) / max(1, m.get("token_cost", 1)),
93
+ reverse=True
94
+ )
95
+
96
+ selected: List[MemoryNodeResult] = []
97
+ current_cost = 0
98
+ for m in sorted_memories:
99
+ cost = m.get("token_cost", 1)
100
+ if current_cost + cost <= budget:
101
+ selected.append(MemoryNodeResult(m))
102
+ current_cost += cost
103
+ return selected