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.
- semanticmemo/__init__.py +48 -0
- semanticmemo/_logging.py +29 -0
- semanticmemo/_models/equivalence-net-v1.pt +0 -0
- semanticmemo/_models/equivalence-net-v1.report.json +248 -0
- semanticmemo/cache.py +149 -0
- semanticmemo/classifier/__init__.py +28 -0
- semanticmemo/classifier/cross_encoder_service.py +84 -0
- semanticmemo/classifier/data.py +135 -0
- semanticmemo/classifier/evaluate.py +160 -0
- semanticmemo/classifier/model.py +53 -0
- semanticmemo/classifier/service.py +108 -0
- semanticmemo/classifier/train.py +151 -0
- semanticmemo/cli.py +251 -0
- semanticmemo/domain_detector.py +103 -0
- semanticmemo/embedding/__init__.py +19 -0
- semanticmemo/embedding/service.py +194 -0
- semanticmemo/entity_change_detection.py +619 -0
- semanticmemo/exceptions.py +22 -0
- semanticmemo/feedback/__init__.py +9 -0
- semanticmemo/feedback/retrain.py +241 -0
- semanticmemo/metrics.py +61 -0
- semanticmemo/models.py +277 -0
- semanticmemo/orchestrator.py +778 -0
- semanticmemo/resources.py +37 -0
- semanticmemo/store/__init__.py +5 -0
- semanticmemo/store/schema.sql +73 -0
- semanticmemo/store/sqlite_store.py +551 -0
- semanticmemo/types.py +34 -0
- semanticmemo-1.2.0.dist-info/METADATA +403 -0
- semanticmemo-1.2.0.dist-info/RECORD +33 -0
- semanticmemo-1.2.0.dist-info/WHEEL +4 -0
- semanticmemo-1.2.0.dist-info/entry_points.txt +2 -0
- semanticmemo-1.2.0.dist-info/licenses/LICENSE +21 -0
semanticmemo/__init__.py
ADDED
|
@@ -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
|
+
]
|
semanticmemo/_logging.py
ADDED
|
@@ -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)
|
|
Binary file
|
|
@@ -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
|