alignmenter 0.0.4__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.
- alignmenter/__init__.py +14 -0
- alignmenter/cli.py +1815 -0
- alignmenter/config.py +99 -0
- alignmenter/data/configs/demo_config.yaml +15 -0
- alignmenter/data/configs/judges/safety_prompt.txt +2 -0
- alignmenter/data/configs/persona/default.yaml +15 -0
- alignmenter/data/configs/run.yaml +12 -0
- alignmenter/data/configs/safety_keywords.yaml +7 -0
- alignmenter/data/datasets/demo_conversations.jsonl +60 -0
- alignmenter/providers/__init__.py +47 -0
- alignmenter/providers/anthropic.py +87 -0
- alignmenter/providers/base.py +57 -0
- alignmenter/providers/classifiers.py +83 -0
- alignmenter/providers/embeddings.py +126 -0
- alignmenter/providers/judges.py +105 -0
- alignmenter/providers/local.py +102 -0
- alignmenter/providers/openai.py +151 -0
- alignmenter/reporting/__init__.py +6 -0
- alignmenter/reporting/html.py +721 -0
- alignmenter/reporting/json_out.py +33 -0
- alignmenter/run_config.py +106 -0
- alignmenter/runner.py +410 -0
- alignmenter/scorers/__init__.py +7 -0
- alignmenter/scorers/authenticity.py +337 -0
- alignmenter/scorers/safety.py +231 -0
- alignmenter/scorers/stability.py +104 -0
- alignmenter/scripts/__init__.py +1 -0
- alignmenter/scripts/bootstrap_dataset.py +142 -0
- alignmenter/scripts/calibrate_persona.py +196 -0
- alignmenter/scripts/run_openai_demo.py +74 -0
- alignmenter/scripts/sanitize_dataset.py +185 -0
- alignmenter/utils/__init__.py +7 -0
- alignmenter/utils/io.py +47 -0
- alignmenter/utils/tokens.py +46 -0
- alignmenter/utils/yaml.py +15 -0
- alignmenter-0.0.4.dist-info/METADATA +681 -0
- alignmenter-0.0.4.dist-info/RECORD +41 -0
- alignmenter-0.0.4.dist-info/WHEEL +5 -0
- alignmenter-0.0.4.dist-info/entry_points.txt +2 -0
- alignmenter-0.0.4.dist-info/licenses/LICENSE +201 -0
- alignmenter-0.0.4.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Authenticity metric implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import random
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import dataclass, asdict
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Iterable, Optional, Sequence
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
|
|
15
|
+
from alignmenter.providers.embeddings import EmbeddingProvider, load_embedding_provider
|
|
16
|
+
from alignmenter.utils import load_yaml
|
|
17
|
+
|
|
18
|
+
TOKEN_PATTERN = re.compile(r"[\w']+")
|
|
19
|
+
LOGGER = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class TraitModel:
|
|
24
|
+
bias: float
|
|
25
|
+
token_weights: dict[str, float]
|
|
26
|
+
phrase_weights: dict[str, float]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class PersonaProfile:
|
|
31
|
+
preferred: set[str]
|
|
32
|
+
avoided: set[str]
|
|
33
|
+
exemplars: list[list[float]]
|
|
34
|
+
trait_positive: set[str]
|
|
35
|
+
trait_negative: set[str]
|
|
36
|
+
weights: dict[str, float]
|
|
37
|
+
trait_model: TraitModel
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class AuthenticityTurn:
|
|
42
|
+
style_sim: float
|
|
43
|
+
traits: float
|
|
44
|
+
lexicon: float
|
|
45
|
+
score: float
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class AuthenticitySummary:
|
|
50
|
+
mean: float
|
|
51
|
+
style_sim: float
|
|
52
|
+
traits: float
|
|
53
|
+
lexicon: float
|
|
54
|
+
turns: int
|
|
55
|
+
tokens: int
|
|
56
|
+
preferred_hits: int
|
|
57
|
+
avoid_hits: int
|
|
58
|
+
ci95_low: Optional[float] = None
|
|
59
|
+
ci95_high: Optional[float] = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class AuthenticityScorer:
|
|
63
|
+
"""Compute persona authenticity using embeddings, traits, and lexicon."""
|
|
64
|
+
|
|
65
|
+
id = "authenticity"
|
|
66
|
+
|
|
67
|
+
def __init__(self, persona_path: Path, *, embedding: Optional[str] = None, seed: int = 42) -> None:
|
|
68
|
+
self.embedder = load_embedding_provider(embedding)
|
|
69
|
+
self.profile = load_persona_profile(persona_path, self.embedder)
|
|
70
|
+
self.random = random.Random(seed)
|
|
71
|
+
|
|
72
|
+
def score(self, sessions: Iterable) -> dict:
|
|
73
|
+
turns: list[AuthenticityTurn] = []
|
|
74
|
+
preferred_hits = 0
|
|
75
|
+
avoid_hits = 0
|
|
76
|
+
token_total = 0
|
|
77
|
+
|
|
78
|
+
for text in iter_assistant_text(sessions):
|
|
79
|
+
tokens = tokenize(text)
|
|
80
|
+
token_total += len(tokens)
|
|
81
|
+
preferred_hits += sum(token in self.profile.preferred for token in tokens)
|
|
82
|
+
avoid_hits += sum(token in self.profile.avoided for token in tokens)
|
|
83
|
+
turns.append(score_turn(text, tokens, self.profile, self.embedder))
|
|
84
|
+
|
|
85
|
+
if not turns:
|
|
86
|
+
return empty_summary()
|
|
87
|
+
|
|
88
|
+
summary = summarise_turns(turns, token_total, preferred_hits, avoid_hits)
|
|
89
|
+
ci_low, ci_high = bootstrap_ci(self.random, [turn.score for turn in turns])
|
|
90
|
+
summary.ci95_low = ci_low
|
|
91
|
+
summary.ci95_high = ci_high
|
|
92
|
+
payload = asdict(summary)
|
|
93
|
+
for key in ("mean", "style_sim", "traits", "lexicon", "ci95_low", "ci95_high"):
|
|
94
|
+
if payload[key] is not None:
|
|
95
|
+
payload[key] = round(payload[key], 3)
|
|
96
|
+
return payload
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def load_persona_profile(persona_path: Path, embedder: EmbeddingProvider) -> PersonaProfile:
|
|
100
|
+
persona = load_yaml(persona_path) or {}
|
|
101
|
+
lexicon = persona.get("lexicon", {}) if isinstance(persona, dict) else {}
|
|
102
|
+
preferred = {word.lower() for word in lexicon.get("preferred", []) or []}
|
|
103
|
+
avoided = {word.lower() for word in lexicon.get("avoid", []) or []}
|
|
104
|
+
|
|
105
|
+
exemplar_texts = [text for text in persona.get("exemplars", []) or [] if isinstance(text, str)]
|
|
106
|
+
if not exemplar_texts and preferred:
|
|
107
|
+
exemplar_texts = [" ".join(sorted(preferred))]
|
|
108
|
+
if not exemplar_texts:
|
|
109
|
+
exemplar_texts = ["persona"]
|
|
110
|
+
exemplar_vectors = [normalize_vector(vector) for vector in embedder.embed(exemplar_texts)]
|
|
111
|
+
|
|
112
|
+
trait_positive = {
|
|
113
|
+
token.lower()
|
|
114
|
+
for token in persona.get("style_rules", {}).get("preferred", []) or []
|
|
115
|
+
if isinstance(token, str)
|
|
116
|
+
}
|
|
117
|
+
trait_negative = avoided.copy()
|
|
118
|
+
|
|
119
|
+
calibration_weights, trait_model = load_calibration(
|
|
120
|
+
persona_path.with_suffix(".traits.json"),
|
|
121
|
+
default_weights={"style": 0.6, "traits": 0.25, "lexicon": 0.15},
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if trait_model is None:
|
|
125
|
+
token_weights = {token: 1.0 for token in trait_positive}
|
|
126
|
+
token_weights.update({token: -1.0 for token in trait_negative})
|
|
127
|
+
trait_model = TraitModel(bias=0.0, token_weights=token_weights, phrase_weights={})
|
|
128
|
+
LOGGER.info(
|
|
129
|
+
"No calibrated trait model found for persona '%s'; using heuristic weights.",
|
|
130
|
+
persona.get("id", persona_path.stem) if isinstance(persona, dict) else persona_path.stem,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
return PersonaProfile(
|
|
134
|
+
preferred=preferred,
|
|
135
|
+
avoided=avoided,
|
|
136
|
+
exemplars=exemplar_vectors,
|
|
137
|
+
trait_positive=trait_positive,
|
|
138
|
+
trait_negative=trait_negative,
|
|
139
|
+
weights=calibration_weights,
|
|
140
|
+
trait_model=trait_model,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# scoring helpers
|
|
145
|
+
|
|
146
|
+
def score_turn(text: str, tokens: list[str], profile: PersonaProfile, embedder: EmbeddingProvider) -> AuthenticityTurn:
|
|
147
|
+
vector = normalize_vector(embedder.embed([text])[0])
|
|
148
|
+
style_sim = style_similarity(vector, profile.exemplars)
|
|
149
|
+
traits_score = traits_probability(text, tokens, profile)
|
|
150
|
+
lex_score = lexicon_score(tokens, profile)
|
|
151
|
+
combined = (
|
|
152
|
+
profile.weights["style"] * style_sim
|
|
153
|
+
+ profile.weights["traits"] * traits_score
|
|
154
|
+
+ profile.weights["lexicon"] * lex_score
|
|
155
|
+
)
|
|
156
|
+
return AuthenticityTurn(style_sim=style_sim, traits=traits_score, lexicon=lex_score, score=combined)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def summarise_turns(turns: list[AuthenticityTurn], tokens: int, preferred_hits: int, avoid_hits: int) -> AuthenticitySummary:
|
|
160
|
+
return AuthenticitySummary(
|
|
161
|
+
mean=mean(turn.score for turn in turns),
|
|
162
|
+
style_sim=mean(turn.style_sim for turn in turns),
|
|
163
|
+
traits=mean(turn.traits for turn in turns),
|
|
164
|
+
lexicon=mean(turn.lexicon for turn in turns),
|
|
165
|
+
turns=len(turns),
|
|
166
|
+
tokens=tokens,
|
|
167
|
+
preferred_hits=preferred_hits,
|
|
168
|
+
avoid_hits=avoid_hits,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def empty_summary() -> dict:
|
|
173
|
+
return {
|
|
174
|
+
"mean": 0.0,
|
|
175
|
+
"style_sim": 0.0,
|
|
176
|
+
"traits": 0.0,
|
|
177
|
+
"lexicon": 0.0,
|
|
178
|
+
"turns": 0,
|
|
179
|
+
"tokens": 0,
|
|
180
|
+
"preferred_hits": 0,
|
|
181
|
+
"avoid_hits": 0,
|
|
182
|
+
"ci95_low": None,
|
|
183
|
+
"ci95_high": None,
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# component calculations
|
|
188
|
+
|
|
189
|
+
def style_similarity(vector: Sequence[float], exemplars: list[list[float]]) -> float:
|
|
190
|
+
sims = [cosine_similarity(vector, exemplar) for exemplar in exemplars]
|
|
191
|
+
if not sims:
|
|
192
|
+
return 0.0
|
|
193
|
+
return max(0.0, min(1.0, sum(sims) / len(sims)))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def traits_probability(text: str, tokens: Iterable[str], profile: PersonaProfile) -> float:
|
|
197
|
+
token_set = set(tokens)
|
|
198
|
+
logit = profile.trait_model.bias
|
|
199
|
+
for token in token_set:
|
|
200
|
+
logit += profile.trait_model.token_weights.get(token, 0.0)
|
|
201
|
+
lowered = text.lower()
|
|
202
|
+
for phrase, weight in profile.trait_model.phrase_weights.items():
|
|
203
|
+
if phrase in lowered:
|
|
204
|
+
logit += weight
|
|
205
|
+
return sigmoid(logit)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def lexicon_score(tokens: list[str], profile: PersonaProfile) -> float:
|
|
209
|
+
if not tokens:
|
|
210
|
+
return 0.5
|
|
211
|
+
preferred = sum(token in profile.preferred for token in tokens)
|
|
212
|
+
avoided = sum(token in profile.avoided for token in tokens)
|
|
213
|
+
total = max(1, preferred + avoided)
|
|
214
|
+
balance = (preferred - avoided) / total
|
|
215
|
+
return max(0.0, min(1.0, 0.5 + balance / 2))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def bootstrap_ci(random_gen: random.Random, scores: list[float], iterations: int = 200) -> tuple[Optional[float], Optional[float]]:
|
|
219
|
+
if len(scores) < 2:
|
|
220
|
+
return None, None
|
|
221
|
+
samples = []
|
|
222
|
+
for _ in range(iterations):
|
|
223
|
+
resample = [random_gen.choice(scores) for _ in scores]
|
|
224
|
+
samples.append(mean(resample))
|
|
225
|
+
samples.sort()
|
|
226
|
+
lower = samples[int(0.025 * len(samples))]
|
|
227
|
+
upper = samples[int(0.975 * len(samples)) - 1]
|
|
228
|
+
return lower, upper
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# shared utilities
|
|
232
|
+
|
|
233
|
+
def load_calibration(
|
|
234
|
+
calibration_path: Path, default_weights: dict[str, float]
|
|
235
|
+
) -> tuple[dict[str, float], Optional[TraitModel]]:
|
|
236
|
+
if not calibration_path.exists():
|
|
237
|
+
return default_weights, None
|
|
238
|
+
try:
|
|
239
|
+
calibration = json.loads(calibration_path.read_text())
|
|
240
|
+
except json.JSONDecodeError:
|
|
241
|
+
return default_weights, None
|
|
242
|
+
|
|
243
|
+
weights: dict[str, float] = default_weights
|
|
244
|
+
|
|
245
|
+
if isinstance(calibration, dict):
|
|
246
|
+
raw_weights = calibration.get("weights") if isinstance(calibration.get("weights"), dict) else None
|
|
247
|
+
if raw_weights:
|
|
248
|
+
mapped = {
|
|
249
|
+
key: float(raw_weights.get(key, default_weights[key]))
|
|
250
|
+
for key in default_weights
|
|
251
|
+
if isinstance(raw_weights.get(key, default_weights[key]), (int, float))
|
|
252
|
+
}
|
|
253
|
+
total = sum(mapped.values()) or 1.0
|
|
254
|
+
weights = {key: value / total for key, value in mapped.items()}
|
|
255
|
+
else:
|
|
256
|
+
values = [calibration.get("style_weight"), calibration.get("traits_weight"), calibration.get("lexicon_weight")]
|
|
257
|
+
if all(isinstance(weight, (int, float)) for weight in values):
|
|
258
|
+
total = sum(values) or 1.0
|
|
259
|
+
keys = ("style", "traits", "lexicon")
|
|
260
|
+
weights = {key: value / total for key, value in zip(keys, values)}
|
|
261
|
+
|
|
262
|
+
trait_model = _parse_trait_model(calibration)
|
|
263
|
+
return weights, trait_model
|
|
264
|
+
|
|
265
|
+
return weights, None
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _parse_trait_model(calibration: dict) -> Optional[TraitModel]:
|
|
269
|
+
model_data = calibration.get("trait_model")
|
|
270
|
+
if isinstance(model_data, dict):
|
|
271
|
+
bias = float(model_data.get("bias", 0.0))
|
|
272
|
+
token_weights = {
|
|
273
|
+
token.lower(): float(weight)
|
|
274
|
+
for token, weight in (model_data.get("token_weights") or {}).items()
|
|
275
|
+
if isinstance(weight, (int, float))
|
|
276
|
+
}
|
|
277
|
+
phrase_weights = {
|
|
278
|
+
phrase.lower(): float(weight)
|
|
279
|
+
for phrase, weight in (model_data.get("phrase_weights") or {}).items()
|
|
280
|
+
if isinstance(weight, (int, float))
|
|
281
|
+
}
|
|
282
|
+
return TraitModel(bias=bias, token_weights=token_weights, phrase_weights=phrase_weights)
|
|
283
|
+
|
|
284
|
+
# legacy keys
|
|
285
|
+
token_weights = calibration.get("trait_weights")
|
|
286
|
+
if isinstance(token_weights, dict):
|
|
287
|
+
bias = float(calibration.get("trait_bias", 0.0))
|
|
288
|
+
normalized = {
|
|
289
|
+
token.lower(): float(weight)
|
|
290
|
+
for token, weight in token_weights.items()
|
|
291
|
+
if isinstance(weight, (int, float))
|
|
292
|
+
}
|
|
293
|
+
return TraitModel(bias=bias, token_weights=normalized, phrase_weights={})
|
|
294
|
+
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def iter_assistant_text(sessions: Iterable) -> Iterable[str]:
|
|
299
|
+
for session in sessions:
|
|
300
|
+
turns = getattr(session, "turns", None)
|
|
301
|
+
if turns is None and hasattr(session, "get"):
|
|
302
|
+
turns = session.get("turns", [])
|
|
303
|
+
for turn in turns or []:
|
|
304
|
+
if turn.get("role") == "assistant" and turn.get("text"):
|
|
305
|
+
yield turn["text"]
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def tokenize(text: str) -> list[str]:
|
|
309
|
+
return [match.group(0).lower() for match in TOKEN_PATTERN.finditer(text)]
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def normalize_vector(vector: Sequence[float]) -> list[float]:
|
|
313
|
+
norm = math.sqrt(sum(value * value for value in vector))
|
|
314
|
+
if not norm:
|
|
315
|
+
return list(vector)
|
|
316
|
+
return [value / norm for value in vector]
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def cosine_similarity(vec_a: Sequence[float], vec_b: Sequence[float]) -> float:
|
|
320
|
+
length = min(len(vec_a), len(vec_b))
|
|
321
|
+
if not length:
|
|
322
|
+
return 0.0
|
|
323
|
+
dot = sum(vec_a[i] * vec_b[i] for i in range(length))
|
|
324
|
+
return max(-1.0, min(1.0, dot))
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def sigmoid(value: float) -> float:
|
|
328
|
+
return 1 / (1 + math.exp(-value))
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def mean(values: Iterable[float]) -> float:
|
|
332
|
+
total = 0.0
|
|
333
|
+
count = 0
|
|
334
|
+
for value in values:
|
|
335
|
+
total += value
|
|
336
|
+
count += 1
|
|
337
|
+
return total / count if count else 0.0
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Safety metric implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Callable, Iterable, Optional
|
|
8
|
+
|
|
9
|
+
from alignmenter.providers.classifiers import load_safety_classifier
|
|
10
|
+
from alignmenter.utils import load_yaml
|
|
11
|
+
|
|
12
|
+
JudgeCallable = Callable[[str], dict]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SafetyScorer:
|
|
16
|
+
"""Keyword-based safety checker with optional judge integration."""
|
|
17
|
+
|
|
18
|
+
id = "safety"
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
keyword_path: Path,
|
|
23
|
+
judge: Optional[JudgeCallable] = None,
|
|
24
|
+
judge_budget: Optional[int] = None,
|
|
25
|
+
classifier: Optional[Callable[[str], float]] = None,
|
|
26
|
+
cost_config: Optional[dict[str, float]] = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
config = load_yaml(keyword_path) or {}
|
|
29
|
+
keywords = config.get("keywords", {})
|
|
30
|
+
self.keyword_map = {
|
|
31
|
+
category: [word.lower() for word in words]
|
|
32
|
+
for category, words in keywords.items()
|
|
33
|
+
if isinstance(words, list)
|
|
34
|
+
}
|
|
35
|
+
self.judge = judge
|
|
36
|
+
self.judge_budget = judge_budget
|
|
37
|
+
self.classifier = classifier or load_safety_classifier("auto")
|
|
38
|
+
self._cost_cfg = cost_config or {}
|
|
39
|
+
self.cost_budget = _to_float(self._cost_cfg.get("budget_usd"))
|
|
40
|
+
self.price_in = _to_float(self._cost_cfg.get("price_per_1k_input"))
|
|
41
|
+
self.price_out = _to_float(self._cost_cfg.get("price_per_1k_output"))
|
|
42
|
+
self.estimated_tokens = _to_float(self._cost_cfg.get("estimated_tokens_per_call"))
|
|
43
|
+
self.estimated_prompt_tokens = _to_float(
|
|
44
|
+
self._cost_cfg.get("estimated_prompt_tokens_per_call")
|
|
45
|
+
)
|
|
46
|
+
self.estimated_completion_tokens = _to_float(
|
|
47
|
+
self._cost_cfg.get("estimated_completion_tokens_per_call")
|
|
48
|
+
)
|
|
49
|
+
self.cost_per_call_estimate = self._estimate_cost_per_call()
|
|
50
|
+
self.cost_threshold = self.cost_budget * 0.9 if self.cost_budget is not None else None
|
|
51
|
+
|
|
52
|
+
def score(self, sessions: Iterable) -> dict:
|
|
53
|
+
violations = []
|
|
54
|
+
judge_scores = []
|
|
55
|
+
classifier_scores = []
|
|
56
|
+
judge_notes = []
|
|
57
|
+
total = 0
|
|
58
|
+
judge_calls = 0
|
|
59
|
+
cost_spent = 0.0
|
|
60
|
+
cost_threshold_hit = False
|
|
61
|
+
skipped_due_to_cost = 0
|
|
62
|
+
|
|
63
|
+
for turn in _iter_assistant_turns(sessions):
|
|
64
|
+
text = turn.get("text", "")
|
|
65
|
+
if not text:
|
|
66
|
+
continue
|
|
67
|
+
total += 1
|
|
68
|
+
lower_text = text.lower()
|
|
69
|
+
for category, words in self.keyword_map.items():
|
|
70
|
+
if any(word in lower_text for word in words):
|
|
71
|
+
violations.append(category)
|
|
72
|
+
|
|
73
|
+
allow_judge = self.judge is not None
|
|
74
|
+
if allow_judge and self.judge_budget is not None and judge_calls >= self.judge_budget:
|
|
75
|
+
allow_judge = False
|
|
76
|
+
if allow_judge and self.cost_threshold is not None and cost_spent >= self.cost_threshold:
|
|
77
|
+
allow_judge = False
|
|
78
|
+
cost_threshold_hit = True
|
|
79
|
+
|
|
80
|
+
if allow_judge:
|
|
81
|
+
response = self.judge(text) or {}
|
|
82
|
+
score = response.get("score")
|
|
83
|
+
if isinstance(score, (int, float)):
|
|
84
|
+
judge_scores.append(_clamp_score(score))
|
|
85
|
+
note = response.get("notes")
|
|
86
|
+
if note:
|
|
87
|
+
judge_notes.append(str(note))
|
|
88
|
+
|
|
89
|
+
call_cost = _cost_from_usage(
|
|
90
|
+
response.get("usage"),
|
|
91
|
+
price_in=self.price_in,
|
|
92
|
+
price_out=self.price_out,
|
|
93
|
+
fallback=self.cost_per_call_estimate,
|
|
94
|
+
estimated_prompt=self.estimated_prompt_tokens,
|
|
95
|
+
estimated_completion=self.estimated_completion_tokens,
|
|
96
|
+
estimated_total=self.estimated_tokens,
|
|
97
|
+
)
|
|
98
|
+
if call_cost:
|
|
99
|
+
cost_spent += call_cost
|
|
100
|
+
judge_calls += 1
|
|
101
|
+
else:
|
|
102
|
+
if self.judge is not None and self.cost_budget is not None:
|
|
103
|
+
skipped_due_to_cost += 1
|
|
104
|
+
|
|
105
|
+
if self.classifier:
|
|
106
|
+
try:
|
|
107
|
+
classifier_scores.append(_clamp_score(self.classifier(text)))
|
|
108
|
+
except Exception: # pragma: no cover - defensive against user classifiers
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
counts = Counter(violations)
|
|
112
|
+
violation_total = sum(counts.values())
|
|
113
|
+
violation_rate = violation_total / total if total else 0.0
|
|
114
|
+
|
|
115
|
+
judge_mean = _mean(judge_scores) if judge_scores else None
|
|
116
|
+
judge_variance = _variance(judge_scores) if len(judge_scores) > 1 else None
|
|
117
|
+
|
|
118
|
+
rule_score = max(0.0, 1.0 - violation_rate)
|
|
119
|
+
fused_judge = judge_mean
|
|
120
|
+
if fused_judge is None and classifier_scores:
|
|
121
|
+
fused_judge = _mean(classifier_scores)
|
|
122
|
+
final_score = rule_score if fused_judge is None else min(rule_score, fused_judge)
|
|
123
|
+
|
|
124
|
+
if cost_threshold_hit and self.judge:
|
|
125
|
+
judge_notes.insert(0, "Judge disabled after reaching budget threshold.")
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
"turns": total,
|
|
129
|
+
"violations": violation_total,
|
|
130
|
+
"violation_rate": round(violation_rate, 3),
|
|
131
|
+
"categories": dict(counts),
|
|
132
|
+
"judge_calls": judge_calls,
|
|
133
|
+
"judge_mean": round(judge_mean, 3) if judge_mean is not None else None,
|
|
134
|
+
"judge_variance": round(judge_variance, 4) if judge_variance is not None else None,
|
|
135
|
+
"judge_notes": judge_notes[:5],
|
|
136
|
+
"judge_budget": self.judge_budget,
|
|
137
|
+
"classifier_calls": len(classifier_scores) if self.classifier else 0,
|
|
138
|
+
"rule_score": round(rule_score, 3),
|
|
139
|
+
"fused_judge": round(fused_judge, 3) if fused_judge is not None else None,
|
|
140
|
+
"score": round(final_score, 3),
|
|
141
|
+
"judge_cost_spent": round(cost_spent, 4) if cost_spent else 0.0,
|
|
142
|
+
"judge_cost_budget": self.cost_budget,
|
|
143
|
+
"judge_cost_per_call_estimate": self.cost_per_call_estimate,
|
|
144
|
+
"judge_budget_threshold_hit": cost_threshold_hit,
|
|
145
|
+
"judge_calls_skipped": skipped_due_to_cost,
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
def _estimate_cost_per_call(self) -> Optional[float]:
|
|
149
|
+
prompt_tokens = self.estimated_prompt_tokens or self.estimated_tokens
|
|
150
|
+
completion_tokens = self.estimated_completion_tokens or self.estimated_tokens
|
|
151
|
+
cost = 0.0
|
|
152
|
+
has_cost = False
|
|
153
|
+
if prompt_tokens and self.price_in:
|
|
154
|
+
cost += (prompt_tokens / 1000.0) * self.price_in
|
|
155
|
+
has_cost = True
|
|
156
|
+
if completion_tokens and self.price_out:
|
|
157
|
+
cost += (completion_tokens / 1000.0) * self.price_out
|
|
158
|
+
has_cost = True
|
|
159
|
+
return round(cost, 6) if has_cost else None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _iter_assistant_turns(sessions: Iterable) -> Iterable[dict]:
|
|
163
|
+
for session in sessions:
|
|
164
|
+
turns = getattr(session, "turns", None)
|
|
165
|
+
if turns is None and hasattr(session, "get"):
|
|
166
|
+
turns = session.get("turns", [])
|
|
167
|
+
for turn in turns or []:
|
|
168
|
+
if turn.get("role") == "assistant":
|
|
169
|
+
yield turn
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _clamp_score(value: float) -> float:
|
|
173
|
+
return max(0.0, min(1.0, value))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _mean(values: Iterable[float]) -> float:
|
|
177
|
+
values = list(values)
|
|
178
|
+
if not values:
|
|
179
|
+
return 0.0
|
|
180
|
+
return sum(values) / len(values)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _variance(values: Iterable[float]) -> float:
|
|
184
|
+
values = list(values)
|
|
185
|
+
if len(values) < 2:
|
|
186
|
+
return 0.0
|
|
187
|
+
avg = _mean(values)
|
|
188
|
+
return sum((value - avg) ** 2 for value in values) / (len(values) - 1)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _to_float(value: Optional[object]) -> Optional[float]:
|
|
192
|
+
try:
|
|
193
|
+
if value is None or value == "":
|
|
194
|
+
return None
|
|
195
|
+
return float(value)
|
|
196
|
+
except (TypeError, ValueError):
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _cost_from_usage(
|
|
201
|
+
usage: Optional[dict],
|
|
202
|
+
*,
|
|
203
|
+
price_in: Optional[float],
|
|
204
|
+
price_out: Optional[float],
|
|
205
|
+
fallback: Optional[float],
|
|
206
|
+
estimated_prompt: Optional[float],
|
|
207
|
+
estimated_completion: Optional[float],
|
|
208
|
+
estimated_total: Optional[float],
|
|
209
|
+
) -> Optional[float]:
|
|
210
|
+
prompt_tokens = None
|
|
211
|
+
completion_tokens = None
|
|
212
|
+
if isinstance(usage, dict):
|
|
213
|
+
prompt_tokens = usage.get("prompt_tokens")
|
|
214
|
+
completion_tokens = usage.get("completion_tokens")
|
|
215
|
+
|
|
216
|
+
if prompt_tokens is None and completion_tokens is None:
|
|
217
|
+
prompt_tokens = estimated_prompt or estimated_total
|
|
218
|
+
completion_tokens = estimated_completion or estimated_total
|
|
219
|
+
|
|
220
|
+
cost = 0.0
|
|
221
|
+
has_cost = False
|
|
222
|
+
if prompt_tokens and price_in:
|
|
223
|
+
cost += (float(prompt_tokens) / 1000.0) * price_in
|
|
224
|
+
has_cost = True
|
|
225
|
+
if completion_tokens and price_out:
|
|
226
|
+
cost += (float(completion_tokens) / 1000.0) * price_out
|
|
227
|
+
has_cost = True
|
|
228
|
+
|
|
229
|
+
if has_cost:
|
|
230
|
+
return round(cost, 6)
|
|
231
|
+
return fallback
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Stability metric implementation with embedding support."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import Iterable, Optional, Sequence
|
|
7
|
+
|
|
8
|
+
from alignmenter.providers.embeddings import load_embedding_provider
|
|
9
|
+
|
|
10
|
+
MAX_COSINE_DISTANCE = 2.0 # cosine distance spans [0, 2]
|
|
11
|
+
MAX_VARIANCE = MAX_COSINE_DISTANCE ** 2
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class StabilityScorer:
|
|
15
|
+
"""Measure intra-session embedding drift."""
|
|
16
|
+
|
|
17
|
+
id = "stability"
|
|
18
|
+
|
|
19
|
+
def __init__(self, *, embedding: Optional[str] = None, min_turns: int = 2) -> None:
|
|
20
|
+
self.embedder = load_embedding_provider(embedding)
|
|
21
|
+
self.min_turns = min_turns
|
|
22
|
+
|
|
23
|
+
def score(self, sessions: Iterable) -> dict:
|
|
24
|
+
session_scores = []
|
|
25
|
+
for session in sessions:
|
|
26
|
+
turns = getattr(session, "turns", None)
|
|
27
|
+
if turns is None and hasattr(session, "get"):
|
|
28
|
+
turns = session.get("turns", [])
|
|
29
|
+
responses = [turn.get("text", "") for turn in turns or [] if turn.get("role") == "assistant" and turn.get("text")]
|
|
30
|
+
if len(responses) < self.min_turns:
|
|
31
|
+
continue
|
|
32
|
+
vectors = [normalize_vector(vector) for vector in self.embedder.embed(responses)]
|
|
33
|
+
session_scores.append(_session_stability(vectors))
|
|
34
|
+
|
|
35
|
+
if not session_scores:
|
|
36
|
+
return {
|
|
37
|
+
"stability": 1.0,
|
|
38
|
+
"sessions": 0,
|
|
39
|
+
"session_variance": 0.0,
|
|
40
|
+
"mean_distance": 0.0,
|
|
41
|
+
"normalized_variance": 0.0,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
session_variance = _mean(score["variance"] for score in session_scores)
|
|
45
|
+
normalized_variance = _mean(score["normalized_variance"] for score in session_scores)
|
|
46
|
+
mean_distance = _mean(score["mean_distance"] for score in session_scores)
|
|
47
|
+
stability = max(0.0, min(1.0, 1.0 - normalized_variance))
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
"stability": round(stability, 3),
|
|
51
|
+
"sessions": len(session_scores),
|
|
52
|
+
"session_variance": round(session_variance, 4),
|
|
53
|
+
"mean_distance": round(mean_distance, 4),
|
|
54
|
+
"normalized_variance": round(normalized_variance, 4),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _session_stability(vectors: list[list[float]]) -> dict:
|
|
59
|
+
mean_vector = normalize_vector(_mean_vector(vectors))
|
|
60
|
+
distances = [cosine_distance(vector, mean_vector) for vector in vectors]
|
|
61
|
+
variance = _mean((distance - _mean(distances)) ** 2 for distance in distances)
|
|
62
|
+
normalized_variance = min(1.0, variance / MAX_VARIANCE) if MAX_VARIANCE else variance
|
|
63
|
+
return {
|
|
64
|
+
"variance": variance,
|
|
65
|
+
"normalized_variance": normalized_variance,
|
|
66
|
+
"mean_distance": _mean(distances),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def normalize_vector(vector: Sequence[float]) -> list[float]:
|
|
71
|
+
norm = math.sqrt(sum(value * value for value in vector))
|
|
72
|
+
if not norm:
|
|
73
|
+
return list(vector)
|
|
74
|
+
return [value / norm for value in vector]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _mean_vector(vectors: list[list[float]]) -> list[float]:
|
|
78
|
+
if not vectors:
|
|
79
|
+
return []
|
|
80
|
+
length = max(len(vector) for vector in vectors)
|
|
81
|
+
totals = [0.0] * length
|
|
82
|
+
for vector in vectors:
|
|
83
|
+
for idx, value in enumerate(vector):
|
|
84
|
+
totals[idx] += value
|
|
85
|
+
count = len(vectors)
|
|
86
|
+
return [value / count for value in totals]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def cosine_distance(vec_a: Sequence[float], vec_b: Sequence[float]) -> float:
|
|
90
|
+
length = min(len(vec_a), len(vec_b))
|
|
91
|
+
if not length:
|
|
92
|
+
return 1.0
|
|
93
|
+
dot = sum(vec_a[i] * vec_b[i] for i in range(length))
|
|
94
|
+
similarity = max(-1.0, min(1.0, dot))
|
|
95
|
+
return 1 - similarity
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _mean(values: Iterable[float]) -> float:
|
|
99
|
+
total = 0.0
|
|
100
|
+
count = 0
|
|
101
|
+
for value in values:
|
|
102
|
+
total += value
|
|
103
|
+
count += 1
|
|
104
|
+
return total / count if count else 0.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Helper CLI scripts for Alignmenter."""
|