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,33 @@
1
+ """JSON artifact reporter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from alignmenter.utils.io import write_json
9
+
10
+
11
+ class JSONReporter:
12
+ """Write JSON artifacts summarizing a run."""
13
+
14
+ def write(
15
+ self,
16
+ run_dir: Path,
17
+ summary: dict[str, Any],
18
+ scores: dict[str, Any],
19
+ sessions: list,
20
+ **extras: Any,
21
+ ) -> Path:
22
+ """Persist JSON outputs to *run_dir*."""
23
+
24
+ payload = {
25
+ "run": summary,
26
+ "scores": scores,
27
+ }
28
+ scorecards = extras.get("scorecards")
29
+ if scorecards:
30
+ payload["scorecards"] = scorecards
31
+ path = Path(run_dir) / "report.json"
32
+ write_json(path, payload)
33
+ return path
@@ -0,0 +1,106 @@
1
+ """Utilities for loading run configuration files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any, Optional
7
+
8
+ from alignmenter.utils import load_yaml
9
+
10
+
11
+ def _resolve(base: Path, value: Optional[str]) -> Optional[Path]:
12
+ if not value:
13
+ return None
14
+ candidate = Path(value)
15
+ if not candidate.is_absolute():
16
+ candidate = (base / candidate).resolve()
17
+ return candidate
18
+
19
+
20
+ def load_run_options(path: Path) -> dict[str, Any]:
21
+ data = load_yaml(path) or {}
22
+ base = path.parent
23
+
24
+ options: dict[str, Any] = {}
25
+
26
+ # Direct fields or legacy fallbacks
27
+ options["run_id"] = data.get("run_id")
28
+ options["model"] = data.get("model") or data.get("providers", {}).get("primary")
29
+ options["compare_model"] = data.get("compare_model") or data.get("providers", {}).get("compare")
30
+
31
+ dataset = data.get("dataset")
32
+ if dataset:
33
+ options["dataset"] = _resolve(base, dataset)
34
+
35
+ persona = data.get("persona") or data.get("persona_pack")
36
+ if persona:
37
+ options["persona"] = _resolve(base, persona)
38
+
39
+ keywords = (
40
+ data.get("keywords")
41
+ or data.get("keyword_lists")
42
+ or data.get("scorers", {}).get("safety", {}).get("keyword_lists")
43
+ )
44
+ if keywords:
45
+ options["keywords"] = _resolve(base, keywords)
46
+
47
+ embedding = (
48
+ data.get("embedding")
49
+ or data.get("embedding_provider")
50
+ or data.get("scorers", {}).get("authenticity", {}).get("embedding_model")
51
+ )
52
+ if embedding:
53
+ options["embedding"] = embedding
54
+
55
+ judge_section = data.get("judge")
56
+ safety_section = data.get("scorers", {}).get("safety", {})
57
+ if not isinstance(judge_section, dict):
58
+ judge_section = safety_section.get("judge") if isinstance(safety_section, dict) else None
59
+
60
+ if isinstance(judge_section, dict):
61
+ if judge_section.get("provider"):
62
+ options["judge_provider"] = judge_section.get("provider")
63
+ if judge_section.get("budget") is not None:
64
+ options["judge_budget"] = judge_section.get("budget")
65
+ if judge_section.get("budget_usd") is not None:
66
+ options["judge_budget_usd"] = judge_section.get("budget_usd")
67
+ if judge_section.get("price_per_1k_input") is not None:
68
+ options["judge_price_per_1k_input"] = judge_section.get("price_per_1k_input")
69
+ if judge_section.get("price_per_1k_output") is not None:
70
+ options["judge_price_per_1k_output"] = judge_section.get("price_per_1k_output")
71
+ if judge_section.get("estimated_tokens_per_call") is not None:
72
+ options["judge_estimated_tokens_per_call"] = judge_section.get("estimated_tokens_per_call")
73
+ if judge_section.get("estimated_prompt_tokens_per_call") is not None:
74
+ options["judge_estimated_prompt_tokens_per_call"] = judge_section.get("estimated_prompt_tokens_per_call")
75
+ if judge_section.get("estimated_completion_tokens_per_call") is not None:
76
+ options["judge_estimated_completion_tokens_per_call"] = judge_section.get("estimated_completion_tokens_per_call")
77
+ if judge_section.get("offline_classifier"):
78
+ options["safety_classifier"] = judge_section.get("offline_classifier")
79
+
80
+ if options.get("judge_provider") is None and data.get("judge_provider"):
81
+ options["judge_provider"] = data.get("judge_provider")
82
+ if options.get("judge_budget") is None and data.get("judge_budget") is not None:
83
+ options["judge_budget"] = data.get("judge_budget")
84
+ for alias, key in (
85
+ ("judge_budget_usd", "judge_budget_usd"),
86
+ ("judge_price_per_1k_input", "judge_price_per_1k_input"),
87
+ ("judge_price_per_1k_output", "judge_price_per_1k_output"),
88
+ ("judge_estimated_tokens_per_call", "judge_estimated_tokens_per_call"),
89
+ ):
90
+ if options.get(alias) is None and data.get(key) is not None:
91
+ options[alias] = data.get(key)
92
+
93
+ safety_section = data.get("scorers", {}).get("safety", {})
94
+ if isinstance(safety_section, dict) and safety_section.get("offline_classifier") and options.get("safety_classifier") is None:
95
+ options["safety_classifier"] = safety_section.get("offline_classifier")
96
+ if options.get("safety_classifier") is None and data.get("safety_classifier"):
97
+ options["safety_classifier"] = data.get("safety_classifier")
98
+
99
+ report = data.get("report", {})
100
+ if isinstance(report, dict):
101
+ if report.get("out_dir"):
102
+ options["report_out_dir"] = _resolve(base, report.get("out_dir"))
103
+ if report.get("include_raw") is not None:
104
+ options["include_raw"] = bool(report.get("include_raw"))
105
+
106
+ return options
alignmenter/runner.py ADDED
@@ -0,0 +1,410 @@
1
+ """Run orchestration pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
10
+
11
+ from alignmenter.providers.base import ChatProvider
12
+ from alignmenter.reporting.html import HTMLReporter
13
+ from alignmenter.reporting.json_out import JSONReporter
14
+ from alignmenter.utils.io import read_jsonl, write_json, write_jsonl
15
+
16
+
17
+ @dataclass
18
+ class RunConfig:
19
+ """Configuration for a single evaluation run."""
20
+
21
+ model: str
22
+ dataset_path: Path
23
+ persona_path: Path
24
+ run_id: str = "alignmenter_run"
25
+ compare_model: Optional[str] = None
26
+ report_out_dir: Path = Path("reports")
27
+ include_raw: bool = True
28
+
29
+ def __post_init__(self) -> None:
30
+ self.dataset_path = Path(self.dataset_path)
31
+ self.persona_path = Path(self.persona_path)
32
+ self.report_out_dir = Path(self.report_out_dir)
33
+
34
+
35
+ @dataclass
36
+ class Session:
37
+ """Grouped conversation session."""
38
+
39
+ session_id: str
40
+ turns: List[dict]
41
+
42
+
43
+ class Runner:
44
+ """Coordinates provider calls, scoring, and reporting."""
45
+
46
+ def __init__(
47
+ self,
48
+ config: RunConfig,
49
+ scorers: Iterable,
50
+ compare_scorers: Optional[Iterable] = None,
51
+ reporters: Optional[Iterable] = None,
52
+ *,
53
+ provider: Optional[ChatProvider] = None,
54
+ compare_provider: Optional[ChatProvider] = None,
55
+ generate_transcripts: bool = True,
56
+ compare_generate: Optional[bool] = None,
57
+ progress_callback: Optional[Callable[[int], None]] = None,
58
+ compare_progress_callback: Optional[Callable[[int], None]] = None,
59
+ ) -> None:
60
+ self.config = config
61
+ self.scorers = list(scorers)
62
+ self.compare_scorers = list(compare_scorers or [])
63
+ self.reporters = list(reporters or [JSONReporter(), HTMLReporter()])
64
+ self.provider = provider
65
+ self.compare_provider = compare_provider
66
+ self.generate_transcripts = bool(generate_transcripts and provider is not None)
67
+ if compare_generate is None:
68
+ compare_generate = generate_transcripts
69
+ self.compare_generate = bool(compare_generate and compare_provider is not None)
70
+ self.progress_callback = progress_callback if self.generate_transcripts else None
71
+ self.compare_progress_callback = (
72
+ compare_progress_callback if self.compare_generate else None
73
+ )
74
+
75
+ def execute(self) -> Path:
76
+ """Execute an evaluation run and return the artifact directory."""
77
+
78
+ records = load_dataset(self.config.dataset_path)
79
+
80
+ primary_records, primary_usage = self._prepare_transcripts(
81
+ records,
82
+ provider=self.provider if self.generate_transcripts else None,
83
+ model_identifier=self.config.model,
84
+ progress_callback=self.progress_callback,
85
+ )
86
+ primary_sessions = group_sessions(primary_records)
87
+
88
+ compare_records: Optional[list[dict[str, Any]]] = None
89
+ compare_usage: dict[str, int] = {}
90
+ compare_sessions: Optional[list[Session]] = None
91
+
92
+ if self.compare_scorers:
93
+ compare_records, compare_usage = self._prepare_transcripts(
94
+ records,
95
+ provider=self.compare_provider if self.compare_generate else None,
96
+ model_identifier=self.config.compare_model,
97
+ progress_callback=self.compare_progress_callback,
98
+ )
99
+ compare_sessions = group_sessions(compare_records)
100
+
101
+ primary_scores = self._run_scorers(self.scorers, primary_sessions)
102
+ score_results: dict[str, Any] = {"primary": primary_scores}
103
+
104
+ compare_scores: dict[str, Any] = {}
105
+ if self.compare_scorers and compare_sessions is not None:
106
+ compare_scores = self._run_scorers(self.compare_scorers, compare_sessions)
107
+ score_results["compare"] = compare_scores
108
+ score_results["diff"] = compute_diffs(primary_scores, compare_scores)
109
+
110
+ run_at = datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
111
+ run_dir = prepare_run_directory(self.config.report_out_dir, run_at, self.config.run_id)
112
+
113
+ transcript_info: dict[str, dict[str, str]] = {}
114
+ transcripts_dir = run_dir / "transcripts"
115
+ transcripts_dir.mkdir(parents=True, exist_ok=True)
116
+
117
+ primary_transcript_path = transcripts_dir / f"{_slugify_model(self.config.model)}.jsonl"
118
+ write_jsonl(primary_transcript_path, primary_records)
119
+ transcript_info["primary"] = {
120
+ "model": self.config.model,
121
+ "path": str(primary_transcript_path.relative_to(run_dir)),
122
+ "source": "generated" if self.generate_transcripts else "dataset",
123
+ }
124
+
125
+ if compare_records is not None:
126
+ compare_model = self.config.compare_model or "compare"
127
+ compare_transcript_path = transcripts_dir / f"{_slugify_model(compare_model)}.jsonl"
128
+ write_jsonl(compare_transcript_path, compare_records)
129
+ transcript_info["compare"] = {
130
+ "model": compare_model,
131
+ "path": str(compare_transcript_path.relative_to(run_dir)),
132
+ "source": "generated" if self.compare_generate else "dataset",
133
+ }
134
+
135
+ run_summary = {
136
+ "run_id": self.config.run_id,
137
+ "model": self.config.model,
138
+ "compare_model": self.config.compare_model,
139
+ "dataset_path": str(self.config.dataset_path),
140
+ "persona_path": str(self.config.persona_path),
141
+ "run_at": run_at,
142
+ "session_count": len(primary_sessions),
143
+ "turn_count": len(primary_records),
144
+ "transcripts": transcript_info,
145
+ }
146
+
147
+ usage_summary: dict[str, dict[str, int]] = {}
148
+ if primary_usage:
149
+ usage_summary["primary"] = {"model": self.config.model, **primary_usage}
150
+ if compare_usage:
151
+ usage_summary["compare"] = {"model": self.config.compare_model, **compare_usage}
152
+ if usage_summary:
153
+ run_summary["usage"] = usage_summary
154
+
155
+ write_json(run_dir / "run.json", run_summary)
156
+ scorecards = build_scorecards(primary_scores, compare_scores, score_results.get("diff", {}))
157
+ write_json(run_dir / "results.json", {"scores": score_results, "scorecards": scorecards})
158
+
159
+ aggregates = build_aggregates(score_results)
160
+ write_json(run_dir / "aggregates.json", aggregates)
161
+
162
+ for reporter in self.reporters:
163
+ reporter.write(run_dir, run_summary, score_results, primary_sessions, scorecards=scorecards)
164
+
165
+ if self.config.include_raw:
166
+ write_json(
167
+ run_dir / "raw.json",
168
+ {"sessions": [session.__dict__ for session in primary_sessions]},
169
+ )
170
+
171
+ return run_dir
172
+
173
+ def _run_scorers(self, scorers: Iterable, sessions: list[Session]) -> dict:
174
+ results = {}
175
+ for scorer in scorers:
176
+ results[scorer.id] = scorer.score(sessions)
177
+ return results
178
+
179
+ def _prepare_transcripts(
180
+ self,
181
+ records: Iterable[dict[str, Any]],
182
+ *,
183
+ provider: Optional[ChatProvider],
184
+ model_identifier: Optional[str],
185
+ progress_callback: Optional[Callable[[int], None]] = None,
186
+ ) -> Tuple[List[dict[str, Any]], dict[str, int]]:
187
+ grouped = _group_records(records)
188
+ output: List[dict[str, Any]] = []
189
+ usage = _UsageAccumulator()
190
+
191
+ for session_id in grouped:
192
+ conversation: List[dict[str, str]] = []
193
+ for turn in grouped[session_id]:
194
+ record = copy.deepcopy(turn)
195
+ role = (record.get("role") or "user").strip().lower()
196
+
197
+ if role == "assistant" and provider is not None:
198
+ baseline = record.get("text")
199
+ if baseline:
200
+ metadata = _ensure_metadata(record)
201
+ metadata.setdefault("baseline_text", baseline)
202
+
203
+ response = provider.chat([dict(msg) for msg in conversation])
204
+ generated_text = (response.text or "").strip()
205
+ record["text"] = generated_text
206
+
207
+ metadata = _ensure_metadata(record)
208
+ metadata["generated_by"] = model_identifier or getattr(provider, "name", "provider")
209
+ if response.usage:
210
+ metadata["usage"] = response.usage
211
+ usage.add(response.usage)
212
+
213
+ conversation.append({"role": "assistant", "content": generated_text})
214
+ if progress_callback:
215
+ progress_callback(1)
216
+ else:
217
+ conversation.append({"role": role or "user", "content": record.get("text", "")})
218
+
219
+ output.append(record)
220
+
221
+ return output, usage.as_dict()
222
+
223
+
224
+ def load_dataset(path: Path) -> list[dict]:
225
+ """Load the dataset located at *path*."""
226
+
227
+ return read_jsonl(path)
228
+
229
+
230
+ def group_sessions(records: Iterable[dict]) -> list[Session]:
231
+ """Group flat dataset records into ordered sessions."""
232
+
233
+ sessions: dict[str, list[dict]] = {}
234
+ for record in records:
235
+ session_id = record.get("session_id")
236
+ if not session_id:
237
+ raise ValueError("Dataset record missing 'session_id'.")
238
+ sessions.setdefault(session_id, []).append(record)
239
+
240
+ grouped: list[Session] = []
241
+ for session_id, turns in sessions.items():
242
+ ordered = sorted(turns, key=lambda item: item.get("turn_index", 0))
243
+ grouped.append(Session(session_id=session_id, turns=ordered))
244
+
245
+ grouped.sort(key=lambda session: session.session_id)
246
+ return grouped
247
+
248
+
249
+ def prepare_run_directory(base_dir: Path, run_at: str, run_id: str) -> Path:
250
+ """Create a timestamped run directory."""
251
+
252
+ timestamp = run_at.replace(":", "-").replace("Z", "")
253
+ run_dir = base_dir / f"{timestamp}_{run_id}"
254
+ run_dir.mkdir(parents=True, exist_ok=True)
255
+ return run_dir
256
+
257
+
258
+ def compute_diffs(primary: dict, compare: dict) -> dict:
259
+ """Compute numeric differences between primary and compare results."""
260
+
261
+ diffs: dict = {}
262
+ for scorer_id, primary_result in primary.items():
263
+ compare_result = compare.get(scorer_id)
264
+ if not isinstance(primary_result, dict) or not isinstance(compare_result, dict):
265
+ continue
266
+ diff_values = {}
267
+ for key, value in primary_result.items():
268
+ comp_value = compare_result.get(key)
269
+ if isinstance(value, (int, float)) and isinstance(comp_value, (int, float)):
270
+ diff_values[key] = round(value - comp_value, 3)
271
+ if diff_values:
272
+ diffs[scorer_id] = diff_values
273
+ return diffs
274
+
275
+
276
+ def build_aggregates(score_results: dict) -> dict:
277
+ """Produce lightweight aggregates for reports."""
278
+
279
+ aggregates: dict[str, dict] = {}
280
+ for scope in ("primary", "compare", "diff"):
281
+ result_set = score_results.get(scope)
282
+ if not isinstance(result_set, dict):
283
+ continue
284
+ scoped = {}
285
+ for scorer_id, values in result_set.items():
286
+ if isinstance(values, dict):
287
+ scoped[scorer_id] = {
288
+ key: value
289
+ for key, value in values.items()
290
+ if isinstance(value, (int, float))
291
+ }
292
+ if scoped:
293
+ aggregates[scope] = scoped
294
+ return {"aggregates": aggregates}
295
+
296
+
297
+ def build_scorecards(primary: dict, compare: dict, diff: dict) -> list[dict]:
298
+ """Create scorecard summaries for headline metrics."""
299
+
300
+ config = {
301
+ "authenticity": ("mean", "Authenticity Score"),
302
+ "safety": ("score", "Safety Score"),
303
+ "stability": ("stability", "Stability"),
304
+ }
305
+
306
+ scorecards: list[dict] = []
307
+ for scorer_id, (metric_key, label) in config.items():
308
+ primary_metrics = primary.get(scorer_id)
309
+ primary_value = _extract_metric(primary_metrics, metric_key)
310
+ if primary_value is None:
311
+ continue
312
+
313
+ card = {
314
+ "id": scorer_id,
315
+ "label": label,
316
+ "metric": metric_key,
317
+ "primary": primary_value,
318
+ }
319
+
320
+ compare_metrics = compare.get(scorer_id) if isinstance(compare, dict) else None
321
+ if isinstance(compare_metrics, dict) and compare_metrics:
322
+ compare_value = _extract_metric(compare_metrics, metric_key)
323
+ if compare_value is not None:
324
+ card["compare"] = compare_value
325
+
326
+ diff_metrics = diff.get(scorer_id) if isinstance(diff, dict) else None
327
+ if isinstance(diff_metrics, dict) and diff_metrics:
328
+ diff_value = _extract_metric(diff_metrics, metric_key)
329
+ if diff_value is not None:
330
+ card["diff"] = diff_value
331
+
332
+ scorecards.append(card)
333
+
334
+ return scorecards
335
+
336
+
337
+ def _extract_metric(metrics: Optional[dict], key: str) -> Optional[float]:
338
+ if isinstance(metrics, dict):
339
+ value = metrics.get(key)
340
+ if isinstance(value, (int, float)):
341
+ return float(value)
342
+ return None
343
+
344
+
345
+ def _group_records(records: Iterable[dict[str, Any]]) -> Dict[str, List[dict[str, Any]]]:
346
+ grouped: Dict[str, List[dict[str, Any]]] = {}
347
+ for record in records:
348
+ session_id = record.get("session_id")
349
+ if not session_id:
350
+ raise ValueError("Dataset record missing 'session_id'.")
351
+ grouped.setdefault(session_id, []).append(record)
352
+
353
+ for turns in grouped.values():
354
+ turns.sort(key=lambda item: item.get("turn_index", 0))
355
+
356
+ return {session_id: grouped[session_id] for session_id in sorted(grouped)}
357
+
358
+
359
+ def _ensure_metadata(record: dict[str, Any]) -> dict[str, Any]:
360
+ metadata = record.get("metadata")
361
+ if not isinstance(metadata, dict):
362
+ metadata = {}
363
+ record["metadata"] = metadata
364
+ return metadata
365
+
366
+
367
+ def _slugify_model(identifier: Optional[str]) -> str:
368
+ if not identifier:
369
+ return "model"
370
+ slug = [ch if ch.isalnum() or ch in {"-", "_"} else "_" for ch in identifier]
371
+ collapsed = "".join(slug).strip("_")
372
+ return collapsed or "model"
373
+
374
+
375
+ class _UsageAccumulator:
376
+ """Track token usage totals for provider calls."""
377
+
378
+ def __init__(self) -> None:
379
+ self.prompt = 0
380
+ self.completion = 0
381
+ self.total = 0
382
+
383
+ def add(self, usage: dict[str, Any]) -> None:
384
+ self.prompt += _safe_int(usage.get("prompt_tokens"))
385
+ self.completion += _safe_int(usage.get("completion_tokens"))
386
+ self.total += _safe_int(usage.get("total_tokens"))
387
+
388
+ def as_dict(self) -> dict[str, int]:
389
+ return {
390
+ key: value
391
+ for key, value in {
392
+ "prompt_tokens": self.prompt,
393
+ "completion_tokens": self.completion,
394
+ "total_tokens": self.total,
395
+ }.items()
396
+ if value
397
+ }
398
+
399
+
400
+ def _safe_int(value: Any) -> int:
401
+ if value is None:
402
+ return 0
403
+ if isinstance(value, bool):
404
+ return int(value)
405
+ if isinstance(value, (int, float)):
406
+ return int(value)
407
+ try:
408
+ return int(str(value))
409
+ except (ValueError, TypeError):
410
+ return 0
@@ -0,0 +1,7 @@
1
+ """Metric scorer registry scaffold."""
2
+
3
+ from .authenticity import AuthenticityScorer
4
+ from .safety import SafetyScorer
5
+ from .stability import StabilityScorer
6
+
7
+ __all__ = ["AuthenticityScorer", "SafetyScorer", "StabilityScorer"]