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.
Files changed (41) hide show
  1. alignmenter/__init__.py +14 -0
  2. alignmenter/cli.py +1815 -0
  3. alignmenter/config.py +99 -0
  4. alignmenter/data/configs/demo_config.yaml +15 -0
  5. alignmenter/data/configs/judges/safety_prompt.txt +2 -0
  6. alignmenter/data/configs/persona/default.yaml +15 -0
  7. alignmenter/data/configs/run.yaml +12 -0
  8. alignmenter/data/configs/safety_keywords.yaml +7 -0
  9. alignmenter/data/datasets/demo_conversations.jsonl +60 -0
  10. alignmenter/providers/__init__.py +47 -0
  11. alignmenter/providers/anthropic.py +87 -0
  12. alignmenter/providers/base.py +57 -0
  13. alignmenter/providers/classifiers.py +83 -0
  14. alignmenter/providers/embeddings.py +126 -0
  15. alignmenter/providers/judges.py +105 -0
  16. alignmenter/providers/local.py +102 -0
  17. alignmenter/providers/openai.py +151 -0
  18. alignmenter/reporting/__init__.py +6 -0
  19. alignmenter/reporting/html.py +721 -0
  20. alignmenter/reporting/json_out.py +33 -0
  21. alignmenter/run_config.py +106 -0
  22. alignmenter/runner.py +410 -0
  23. alignmenter/scorers/__init__.py +7 -0
  24. alignmenter/scorers/authenticity.py +337 -0
  25. alignmenter/scorers/safety.py +231 -0
  26. alignmenter/scorers/stability.py +104 -0
  27. alignmenter/scripts/__init__.py +1 -0
  28. alignmenter/scripts/bootstrap_dataset.py +142 -0
  29. alignmenter/scripts/calibrate_persona.py +196 -0
  30. alignmenter/scripts/run_openai_demo.py +74 -0
  31. alignmenter/scripts/sanitize_dataset.py +185 -0
  32. alignmenter/utils/__init__.py +7 -0
  33. alignmenter/utils/io.py +47 -0
  34. alignmenter/utils/tokens.py +46 -0
  35. alignmenter/utils/yaml.py +15 -0
  36. alignmenter-0.0.4.dist-info/METADATA +681 -0
  37. alignmenter-0.0.4.dist-info/RECORD +41 -0
  38. alignmenter-0.0.4.dist-info/WHEEL +5 -0
  39. alignmenter-0.0.4.dist-info/entry_points.txt +2 -0
  40. alignmenter-0.0.4.dist-info/licenses/LICENSE +201 -0
  41. 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."""