semanticmemo 1.2.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,48 @@
1
+ """SemanticMemo public API."""
2
+
3
+ __version__ = "1.0.0"
4
+
5
+
6
+ from semanticmemo import _logging as _logging # noqa: F401 (installs the NullHandler)
7
+ from semanticmemo.cache import SemanticMemo
8
+ from semanticmemo.entity_change_detection import EntityChangeConfig, EntityChangeResult
9
+ from semanticmemo.feedback import RetrainConfig, RetrainResult, retrain_from_feedback
10
+ from semanticmemo.models import (
11
+ CacheConfig,
12
+ CacheDecision,
13
+ CacheEntry,
14
+ CacheResult,
15
+ CacheStats,
16
+ ClassifierConfig,
17
+ CrossEncoderConfig,
18
+ EvictionPolicy,
19
+ FeedbackEvent,
20
+ ImplicitFeedbackConfig,
21
+ LookupRecord,
22
+ RetryConfig,
23
+ RiskPolicy,
24
+ RiskTier,
25
+ )
26
+
27
+ __all__ = [
28
+ "CacheConfig",
29
+ "CacheDecision",
30
+ "CacheEntry",
31
+ "CacheResult",
32
+ "CacheStats",
33
+ "ClassifierConfig",
34
+ "SemanticMemo",
35
+ "EntityChangeConfig",
36
+ "EntityChangeResult",
37
+ "EvictionPolicy",
38
+ "FeedbackEvent",
39
+ "ImplicitFeedbackConfig",
40
+ "LookupRecord",
41
+ "RetrainConfig",
42
+ "RetrainResult",
43
+ "RetryConfig",
44
+ "CrossEncoderConfig",
45
+ "RiskPolicy",
46
+ "RiskTier",
47
+ "retrain_from_feedback",
48
+ ]
@@ -0,0 +1,29 @@
1
+ """Internal logging setup.
2
+
3
+ SemanticMemo is a library, so it is silent by default: it attaches a single
4
+ ``logging.NullHandler`` to the ``SemanticMemo`` parent logger and never configures
5
+ handlers or levels itself. An application that wants to see SemanticMemo logs opts
6
+ in explicitly, e.g.::
7
+
8
+ import logging
9
+ logging.basicConfig(level=logging.INFO)
10
+ logging.getLogger("semanticmemo").setLevel(logging.DEBUG)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+
17
+ # Installed once, at import time, on the package's root logger. Child loggers
18
+ # (``SemanticMemo.orchestrator`` etc.) propagate to it, so this single handler
19
+ # keeps the whole library quiet unless the application configures logging.
20
+ logging.getLogger("semanticmemo").addHandler(logging.NullHandler())
21
+
22
+
23
+ def get_logger(name: str) -> logging.Logger:
24
+ """Return a logger under the ``SemanticMemo`` namespace.
25
+
26
+ Call as ``get_logger(__name__)`` from within the package so the logger name
27
+ is, for example, ``SemanticMemo.store.sqlite_store``.
28
+ """
29
+ return logging.getLogger(name)
@@ -0,0 +1,248 @@
1
+ {
2
+ "architecture": "PairClassifier MLP over [a, b, |a-b|, a*b] -> 128 -> 64 -> 1",
3
+ "created_at": "2026-05-20T11:05:06.857011+00:00",
4
+ "dataset_manifest": {
5
+ "corpus": {
6
+ "action_templates": 109,
7
+ "base_prompts": 96
8
+ },
9
+ "generated_at": "2026-05-20T10:58:01.768234+00:00",
10
+ "hard_neg_per_template": 10,
11
+ "model": "qwen3:8b",
12
+ "n_para_base": 6,
13
+ "n_para_template": 4,
14
+ "neg_ratio": 1.0,
15
+ "ollama_host": "http://localhost:11434",
16
+ "seed": 7,
17
+ "train": {
18
+ "negatives": 6788,
19
+ "opposite_action": 1800,
20
+ "paraphrase": 6788,
21
+ "positives": 6788,
22
+ "total": 13576,
23
+ "unrelated": 4988
24
+ },
25
+ "train_ratio": 0.8,
26
+ "validation": {
27
+ "negatives": 1500,
28
+ "opposite_action": 380,
29
+ "paraphrase": 1500,
30
+ "positives": 1500,
31
+ "total": 3000,
32
+ "unrelated": 1120
33
+ }
34
+ },
35
+ "embedding_dim": 384,
36
+ "embedding_model": "sentence-transformers/all-MiniLM-L6-v2",
37
+ "gold_metrics": {
38
+ "accuracy": 0.9047619047619048,
39
+ "auc_roc": 0.963481436396835,
40
+ "examples": 84,
41
+ "expected_calibration_error": 0.10387316706627754,
42
+ "f1": 0.8787878787878788,
43
+ "false_negatives": 2,
44
+ "false_positives": 6,
45
+ "precision": 0.8285714285714286,
46
+ "recall": 0.9354838709677419,
47
+ "threshold": 0.95,
48
+ "true_negatives": 47,
49
+ "true_positives": 29
50
+ },
51
+ "gold_threshold_sweep": [
52
+ {
53
+ "f1": 0.8788,
54
+ "precision": 0.8286,
55
+ "recall": 0.9355,
56
+ "threshold": 0.5
57
+ },
58
+ {
59
+ "f1": 0.8788,
60
+ "precision": 0.8286,
61
+ "recall": 0.9355,
62
+ "threshold": 0.7
63
+ },
64
+ {
65
+ "f1": 0.8788,
66
+ "precision": 0.8286,
67
+ "recall": 0.9355,
68
+ "threshold": 0.8
69
+ },
70
+ {
71
+ "f1": 0.8788,
72
+ "precision": 0.8286,
73
+ "recall": 0.9355,
74
+ "threshold": 0.85
75
+ },
76
+ {
77
+ "f1": 0.8788,
78
+ "precision": 0.8286,
79
+ "recall": 0.9355,
80
+ "threshold": 0.9
81
+ },
82
+ {
83
+ "f1": 0.8788,
84
+ "precision": 0.8286,
85
+ "recall": 0.9355,
86
+ "threshold": 0.95
87
+ }
88
+ ],
89
+ "gold_vs_cosine": {
90
+ "classifier": {
91
+ "accuracy": 0.9047619047619048,
92
+ "auc_roc": 0.963481436396835,
93
+ "examples": 84,
94
+ "expected_calibration_error": 0.10387316706627754,
95
+ "f1": 0.8787878787878788,
96
+ "false_negatives": 2,
97
+ "false_positives": 6,
98
+ "precision": 0.8285714285714286,
99
+ "recall": 0.9354838709677419,
100
+ "threshold": 0.95,
101
+ "true_negatives": 47,
102
+ "true_positives": 29
103
+ },
104
+ "cosine_at_classifier_recall": {
105
+ "accuracy": 0.6666666666666666,
106
+ "auc_roc": 0.790626902008521,
107
+ "examples": 84,
108
+ "expected_calibration_error": 0.2644111977202729,
109
+ "f1": 0.6744186046511628,
110
+ "false_negatives": 2,
111
+ "false_positives": 26,
112
+ "precision": 0.5272727272727272,
113
+ "recall": 0.9354838709677419,
114
+ "target_recall": 0.9354838709677419,
115
+ "threshold": 0.72,
116
+ "true_negatives": 27,
117
+ "true_positives": 29
118
+ },
119
+ "cosine_default": {
120
+ "accuracy": 0.7023809523809523,
121
+ "auc_roc": 0.790626902008521,
122
+ "examples": 84,
123
+ "expected_calibration_error": 0.2644111977202729,
124
+ "f1": 0.4897959183673469,
125
+ "false_negatives": 19,
126
+ "false_positives": 6,
127
+ "precision": 0.6666666666666666,
128
+ "recall": 0.3870967741935484,
129
+ "threshold": 0.9,
130
+ "true_negatives": 47,
131
+ "true_positives": 12
132
+ },
133
+ "examples": 84,
134
+ "gate_passed": true,
135
+ "gold_path": "data/gold/equivalence_gold.jsonl",
136
+ "model_path": "src/semanticmemo/_models/equivalence-net-v1.pt",
137
+ "negatives": 53,
138
+ "positives": 31,
139
+ "precision_gain_at_equal_recall": 0.3012987012987014
140
+ },
141
+ "high_stakes": {
142
+ "classifier": {
143
+ "accuracy": 0.6785714285714286,
144
+ "auc_roc": 0.7239583333333334,
145
+ "examples": 28,
146
+ "expected_calibration_error": 0.3770394833700834,
147
+ "f1": 0.6666666666666665,
148
+ "false_negatives": 3,
149
+ "false_positives": 6,
150
+ "precision": 0.6,
151
+ "recall": 0.75,
152
+ "threshold": 0.95,
153
+ "true_negatives": 10,
154
+ "true_positives": 9
155
+ },
156
+ "classifier_false_positives": 6,
157
+ "classifier_threshold": 0.95,
158
+ "cosine": {
159
+ "accuracy": 0.42857142857142855,
160
+ "auc_roc": 0.328125,
161
+ "examples": 28,
162
+ "expected_calibration_error": 0.5026936094675745,
163
+ "f1": 0.3333333333333333,
164
+ "false_negatives": 8,
165
+ "false_positives": 8,
166
+ "precision": 0.3333333333333333,
167
+ "recall": 0.3333333333333333,
168
+ "threshold": 0.9,
169
+ "true_negatives": 8,
170
+ "true_positives": 4
171
+ },
172
+ "cosine_false_positives": 8,
173
+ "cosine_threshold": 0.9,
174
+ "dangerous_hits_blocked_by_classifier": 4,
175
+ "data_path": "benchmarks/data/high_stakes_pairs.jsonl",
176
+ "examples": 28,
177
+ "model_path": "src/semanticmemo/_models/equivalence-net-v1.pt",
178
+ "negatives": 16,
179
+ "positives": 12
180
+ },
181
+ "limitations": "Trained on LLM-paraphrased and templated data across nine domains (customer-support, software-engineering, scheduling, data-analysis, devops, general-qa, medical, legal, finance). It is a generic cold-start model: per-domain accuracy improves with the `semanticmemo retrain` feedback loop. Bound to the all-MiniLM-L6-v2 embedding space (384-dim).",
182
+ "model": "equivalence-net-v1",
183
+ "operating_threshold": 0.95,
184
+ "scope": "generic cross-domain cold-start classifier (nine domains)",
185
+ "training": {
186
+ "batch_size": 64,
187
+ "dropout": 0.2,
188
+ "epochs": 15,
189
+ "final_train_loss": 0.024864289640151666,
190
+ "learning_rate": 0.001,
191
+ "seed": 7,
192
+ "train_examples": 13576,
193
+ "validation_examples": 3000
194
+ },
195
+ "validation_metrics": {
196
+ "accuracy": 0.9056666666666666,
197
+ "auc_roc": 0.9745157777777778,
198
+ "examples": 3000,
199
+ "expected_calibration_error": 0.07148537254798411,
200
+ "f1": 0.9028492962581531,
201
+ "false_negatives": 185,
202
+ "false_positives": 98,
203
+ "precision": 0.9306440198159943,
204
+ "recall": 0.8766666666666667,
205
+ "threshold": 0.95,
206
+ "true_negatives": 1402,
207
+ "true_positives": 1315
208
+ },
209
+ "validation_threshold_sweep": [
210
+ {
211
+ "f1": 0.9113,
212
+ "precision": 0.8952,
213
+ "recall": 0.928,
214
+ "threshold": 0.5
215
+ },
216
+ {
217
+ "f1": 0.9155,
218
+ "precision": 0.9103,
219
+ "recall": 0.9207,
220
+ "threshold": 0.7
221
+ },
222
+ {
223
+ "f1": 0.9138,
224
+ "precision": 0.9162,
225
+ "recall": 0.9113,
226
+ "threshold": 0.8
227
+ },
228
+ {
229
+ "f1": 0.9105,
230
+ "precision": 0.9192,
231
+ "recall": 0.902,
232
+ "threshold": 0.85
233
+ },
234
+ {
235
+ "f1": 0.9094,
236
+ "precision": 0.9261,
237
+ "recall": 0.8933,
238
+ "threshold": 0.9
239
+ },
240
+ {
241
+ "f1": 0.9028,
242
+ "precision": 0.9306,
243
+ "recall": 0.8767,
244
+ "threshold": 0.95
245
+ }
246
+ ]
247
+ }
248
+
semanticmemo/cache.py ADDED
@@ -0,0 +1,149 @@
1
+ """Public cache facade."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+ from uuid import UUID
8
+
9
+ from semanticmemo._logging import get_logger
10
+ from semanticmemo.classifier import ClassifierService, CrossEncoderService
11
+ from semanticmemo.domain_detector import DomainDetector
12
+ from semanticmemo.embedding import (
13
+ EmbeddingService,
14
+ FaissVectorIndex,
15
+ InMemoryVectorIndex,
16
+ SentenceTransformerEmbeddingProvider,
17
+ )
18
+ from semanticmemo.entity_change_detection import EntityChangeConfig
19
+ from semanticmemo.exceptions import MissingDependencyError
20
+ from semanticmemo.models import CacheConfig, CacheResult, CacheStats, ClassifierConfig
21
+ from semanticmemo.orchestrator import CacheOrchestrator
22
+ from semanticmemo.store import SQLiteCacheStore
23
+ from semanticmemo.types import EmbeddingProvider, LLMFunction
24
+
25
+ logger = get_logger(__name__)
26
+
27
+
28
+ class SemanticMemo:
29
+ """Async-first semantic cache facade.
30
+
31
+ Without a classifier checkpoint, SemanticMemo uses cosine similarity as a
32
+ measured baseline. With a classifier checkpoint, cosine search selects
33
+ candidates and the learned classifier decides cache hits.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ *,
39
+ domain: str,
40
+ config: CacheConfig | None = None,
41
+ embedding_provider: EmbeddingProvider | None = None,
42
+ classifier: ClassifierConfig | None = None,
43
+ store: SQLiteCacheStore | None = None,
44
+ use_faiss: bool = True,
45
+ entity_change_config: EntityChangeConfig | None = None,
46
+ ) -> None:
47
+ self.domain = domain
48
+ self.config = config or CacheConfig()
49
+ self.store = store or SQLiteCacheStore(self.config.db_path)
50
+ provider = embedding_provider or SentenceTransformerEmbeddingProvider(
51
+ self.config.embedding_model,
52
+ dim=self.config.embedding_dim,
53
+ )
54
+ if use_faiss:
55
+ try:
56
+ index = FaissVectorIndex(provider.dim)
57
+ except MissingDependencyError as exc:
58
+ logger.warning("faiss unavailable, falling back to InMemoryVectorIndex: %s", exc)
59
+ index = InMemoryVectorIndex(provider.dim)
60
+ else:
61
+ index = InMemoryVectorIndex(provider.dim)
62
+ embedding_service = EmbeddingService(provider, index)
63
+ classifier_service = self._build_classifier_service(classifier)
64
+ cross_encoder_service = self._build_cross_encoder_service()
65
+ domain_detector = self._build_domain_detector(embedding_service)
66
+ self._orchestrator = CacheOrchestrator(
67
+ domain=domain,
68
+ config=self.config,
69
+ store=self.store,
70
+ embedding_service=embedding_service,
71
+ classifier_service=classifier_service,
72
+ cross_encoder_service=cross_encoder_service,
73
+ domain_detector=domain_detector,
74
+ entity_change_config=entity_change_config,
75
+ )
76
+
77
+ async def get_or_call(
78
+ self,
79
+ *,
80
+ prompt: str,
81
+ llm_function: LLMFunction,
82
+ model: str | None = None,
83
+ metadata: dict[str, Any] | None = None,
84
+ ) -> CacheResult:
85
+ return await self._orchestrator.get_or_call(
86
+ prompt=prompt,
87
+ llm_function=llm_function,
88
+ model=model,
89
+ metadata=metadata,
90
+ )
91
+
92
+ async def report_bad_hit(self, query_id: UUID, reason: str | None = None) -> bool:
93
+ return self._orchestrator.report_bad_hit(query_id, reason=reason)
94
+
95
+ async def report_good_hit(self, query_id: UUID) -> bool:
96
+ return self._orchestrator.report_good_hit(query_id)
97
+
98
+ def stats(self) -> CacheStats:
99
+ return self._orchestrator.stats()
100
+
101
+ def export_feedback_pairs(self, path: Path | str, *, split: str = "train") -> int:
102
+ return self._orchestrator.export_feedback_pairs(str(path), split=split)
103
+
104
+ def export_active_learning_pairs(self, path: Path | str, *, split: str = "train") -> int:
105
+ return self.store.export_active_learning_pairs(str(path), split=split)
106
+
107
+ def close(self) -> None:
108
+ """Close the underlying store. Safe to call more than once."""
109
+ self.store.close()
110
+ logger.debug("SemanticMemo closed: domain=%s", self.domain)
111
+
112
+ async def __aenter__(self) -> SemanticMemo:
113
+ """Enter an ``async with`` block; returns this instance."""
114
+ return self
115
+
116
+ async def __aexit__(self, *exc_info: object) -> None:
117
+ """Exit an ``async with`` block, closing the store. Exceptions propagate."""
118
+ self.close()
119
+
120
+ def _build_classifier_service(
121
+ self,
122
+ classifier: ClassifierConfig | None,
123
+ ) -> ClassifierService | None:
124
+ if classifier is None or classifier.model_path is None:
125
+ return None
126
+ threshold = (
127
+ classifier.threshold
128
+ if classifier.threshold is not None
129
+ else self.config.classifier_threshold
130
+ )
131
+ return ClassifierService(
132
+ classifier.model_path,
133
+ device=classifier.device,
134
+ threshold=threshold,
135
+ )
136
+
137
+ def _build_cross_encoder_service(self) -> CrossEncoderService | None:
138
+ if self.config.cross_encoder is None:
139
+ return None
140
+ return CrossEncoderService(
141
+ model_name=self.config.cross_encoder.model_name,
142
+ device=self.config.cross_encoder.device,
143
+ threshold=self.config.cross_encoder.threshold,
144
+ )
145
+
146
+ def _build_domain_detector(self, embedding_service: EmbeddingService) -> DomainDetector | None:
147
+ if self.config.risk_policy is None:
148
+ return None
149
+ return DomainDetector(embedding_service)
@@ -0,0 +1,28 @@
1
+ """Classifier training and inference utilities."""
2
+
3
+ from semanticmemo.classifier.cross_encoder_service import CrossEncoderService
4
+ from semanticmemo.classifier.data import EmbeddedPairDataset, PairRecord, load_pair_records
5
+ from semanticmemo.classifier.evaluate import (
6
+ EvaluationMetrics,
7
+ compute_binary_metrics,
8
+ evaluate_model,
9
+ )
10
+ from semanticmemo.classifier.model import PairClassifier, build_pair_features
11
+ from semanticmemo.classifier.service import ClassifierService
12
+ from semanticmemo.classifier.train import TrainingConfig, TrainingResult, train_classifier
13
+
14
+ __all__ = [
15
+ "ClassifierService",
16
+ "CrossEncoderService",
17
+ "EmbeddedPairDataset",
18
+ "EvaluationMetrics",
19
+ "PairClassifier",
20
+ "PairRecord",
21
+ "TrainingConfig",
22
+ "TrainingResult",
23
+ "build_pair_features",
24
+ "compute_binary_metrics",
25
+ "evaluate_model",
26
+ "load_pair_records",
27
+ "train_classifier",
28
+ ]
@@ -0,0 +1,84 @@
1
+ """Inference wrapper for second-stage Cross-Encoder models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Sequence
7
+
8
+ from sentence_transformers import CrossEncoder
9
+
10
+ from semanticmemo._logging import get_logger
11
+
12
+ logger = get_logger(__name__)
13
+
14
+ # Global cache for instantiated CrossEncoder models to avoid reloading overhead
15
+ _MODEL_CACHE: dict[tuple[str, str], CrossEncoder] = {}
16
+
17
+
18
+ class CrossEncoderService:
19
+ """Wrapper around sentence-transformers CrossEncoder for high-precision validation."""
20
+
21
+ def __init__(
22
+ self,
23
+ model_name: str = "cross-encoder/ms-marco-MiniLM-L-6-v2",
24
+ *,
25
+ device: str = "cpu",
26
+ threshold: float | None = None,
27
+ ) -> None:
28
+ self.model_name = model_name
29
+ self.device = device
30
+ self.threshold = threshold
31
+ self._model: CrossEncoder | None = None
32
+
33
+ @property
34
+ def model(self) -> CrossEncoder:
35
+ if self._model is None:
36
+ cache_key = (self.model_name, self.device)
37
+ if cache_key in _MODEL_CACHE:
38
+ self._model = _MODEL_CACHE[cache_key]
39
+ else:
40
+ logger.info("Loading CrossEncoder: %s on %s", self.model_name, self.device)
41
+ self._model = CrossEncoder(self.model_name, device=self.device)
42
+ _MODEL_CACHE[cache_key] = self._model
43
+ return self._model
44
+
45
+ def predict(self, prompt_a: str, prompt_b: str) -> float:
46
+ """Score a single pair of prompts."""
47
+ return self.predict_batch([(prompt_a, prompt_b)])[0]
48
+
49
+ def predict_batch(self, pairs: Sequence[tuple[str, str]]) -> list[float]:
50
+ """Score a list of prompt pairs.
51
+
52
+ Returns probability scores mapped to [0, 1] using sigmoid.
53
+ """
54
+ if not pairs:
55
+ return []
56
+
57
+ # Convert sequence to list of lists/tuples as expected by sentence-transformers
58
+ formatted_pairs = [list(pair) for pair in pairs]
59
+
60
+ # Run prediction
61
+ raw_scores = self.model.predict(formatted_pairs, show_progress_bar=False)
62
+
63
+ # Handle single float vs list output
64
+ if isinstance(raw_scores, (int, float)):
65
+ raw_scores = [raw_scores]
66
+
67
+ # Apply sigmoid because ms-marco output is a logit, but we need [0, 1] probability
68
+ # Also handle potential NaN or infinity safely
69
+ probs = []
70
+ for score in raw_scores:
71
+ s = float(score)
72
+ # sigmoid(x) = 1 / (1 + exp(-x))
73
+ try:
74
+ prob = 1.0 / (1.0 + math.exp(-s))
75
+ except OverflowError:
76
+ prob = 0.0 if s < 0 else 1.0
77
+ probs.append(prob)
78
+
79
+ return probs
80
+
81
+ def is_equivalent(self, prompt_a: str, prompt_b: str) -> bool:
82
+ """Check if two prompts are equivalent based on the threshold."""
83
+ threshold = 0.90 if self.threshold is None else self.threshold
84
+ return self.predict(prompt_a, prompt_b) >= threshold