alignmenter 0.3.3__tar.gz → 0.3.4__tar.gz

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 (163) hide show
  1. {alignmenter-0.3.3/src/alignmenter.egg-info → alignmenter-0.3.4}/PKG-INFO +1 -1
  2. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/_version.py +1 -1
  3. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/cli.py +2 -0
  4. alignmenter-0.3.4/src/alignmenter/rubric_grade.py +408 -0
  5. alignmenter-0.3.4/src/alignmenter/rubric_grade_cli.py +105 -0
  6. {alignmenter-0.3.3 → alignmenter-0.3.4/src/alignmenter.egg-info}/PKG-INFO +1 -1
  7. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter.egg-info/SOURCES.txt +3 -0
  8. alignmenter-0.3.4/tests/test_rubric_grade.py +311 -0
  9. {alignmenter-0.3.3 → alignmenter-0.3.4}/LICENSE +0 -0
  10. {alignmenter-0.3.3 → alignmenter-0.3.4}/MANIFEST.in +0 -0
  11. {alignmenter-0.3.3 → alignmenter-0.3.4}/README.md +0 -0
  12. {alignmenter-0.3.3 → alignmenter-0.3.4}/configs/demo_config.yaml +0 -0
  13. {alignmenter-0.3.3 → alignmenter-0.3.4}/configs/judges/safety_prompt.txt +0 -0
  14. {alignmenter-0.3.3 → alignmenter-0.3.4}/configs/persona/default.yaml +0 -0
  15. {alignmenter-0.3.3 → alignmenter-0.3.4}/configs/run-grounded.yaml +0 -0
  16. {alignmenter-0.3.3 → alignmenter-0.3.4}/configs/run.yaml +0 -0
  17. {alignmenter-0.3.3 → alignmenter-0.3.4}/configs/safety_keywords.yaml +0 -0
  18. {alignmenter-0.3.3 → alignmenter-0.3.4}/datasets/README.md +0 -0
  19. {alignmenter-0.3.3 → alignmenter-0.3.4}/datasets/demo_conversations.jsonl +0 -0
  20. {alignmenter-0.3.3 → alignmenter-0.3.4}/datasets/grounded_demo.jsonl +0 -0
  21. {alignmenter-0.3.3 → alignmenter-0.3.4}/datasets/wendys_twitter.jsonl +0 -0
  22. {alignmenter-0.3.3 → alignmenter-0.3.4}/pyproject.toml +0 -0
  23. {alignmenter-0.3.3 → alignmenter-0.3.4}/setup.cfg +0 -0
  24. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/__init__.py +0 -0
  25. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/calibration/__init__.py +0 -0
  26. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/calibration/analyze.py +0 -0
  27. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/calibration/bounds.py +0 -0
  28. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/calibration/diagnose.py +0 -0
  29. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/calibration/generate.py +0 -0
  30. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/calibration/label.py +0 -0
  31. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/calibration/optimize.py +0 -0
  32. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/calibration/sampling.py +0 -0
  33. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/calibration/validate.py +0 -0
  34. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/config.py +0 -0
  35. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/data/configs/demo_config.yaml +0 -0
  36. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/data/configs/judges/safety_prompt.txt +0 -0
  37. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/data/configs/persona/default.yaml +0 -0
  38. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/data/configs/run-grounded.yaml +0 -0
  39. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/data/configs/run.yaml +0 -0
  40. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/data/configs/safety_keywords.yaml +0 -0
  41. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/data/datasets/demo_conversations.jsonl +0 -0
  42. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/data/datasets/grounded_demo.jsonl +0 -0
  43. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/dataset_cli.py +0 -0
  44. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/evaluators/__init__.py +0 -0
  45. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/evaluators/custom.py +0 -0
  46. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/evaluators/evidence.py +0 -0
  47. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/evaluators/faithfulness.py +0 -0
  48. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/evaluators/grounding.py +0 -0
  49. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/evaluators/metrics.py +0 -0
  50. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/examples/__init__.py +0 -0
  51. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/examples/resource_task.py +0 -0
  52. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/execution/__init__.py +0 -0
  53. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/execution/archive.py +0 -0
  54. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/execution/artifacts.py +0 -0
  55. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/execution/comparison.py +0 -0
  56. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/execution/evaluation.py +0 -0
  57. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/execution/gates.py +0 -0
  58. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/execution/leases.py +0 -0
  59. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/execution/legacy.py +0 -0
  60. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/execution/recovery.py +0 -0
  61. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/execution/review.py +0 -0
  62. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/execution/suite.py +0 -0
  63. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/importers/__init__.py +0 -0
  64. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/importers/healthbench.py +0 -0
  65. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/judges/__init__.py +0 -0
  66. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/judges/authenticity_judge.py +0 -0
  67. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/judges/prompts.py +0 -0
  68. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/providers/__init__.py +0 -0
  69. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/providers/anthropic.py +0 -0
  70. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/providers/base.py +0 -0
  71. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/providers/callable.py +0 -0
  72. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/providers/classifiers.py +0 -0
  73. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/providers/durable_judge.py +0 -0
  74. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/providers/embeddings.py +0 -0
  75. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/providers/judges.py +0 -0
  76. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/providers/local.py +0 -0
  77. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/providers/openai.py +0 -0
  78. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/release_cli.py +0 -0
  79. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/reporting/__init__.py +0 -0
  80. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/reporting/durable.py +0 -0
  81. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/reporting/github_comment.py +0 -0
  82. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/reporting/html.py +0 -0
  83. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/reporting/json_out.py +0 -0
  84. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/run_config.py +0 -0
  85. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/runner.py +0 -0
  86. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/schemas/__init__.py +0 -0
  87. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/schemas/dataset.py +0 -0
  88. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/schemas/evaluation.py +0 -0
  89. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/schemas/execution.py +0 -0
  90. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/schemas/gates.py +0 -0
  91. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/schemas/metrics.py +0 -0
  92. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/schemas/review.py +0 -0
  93. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/schemas/scoring.py +0 -0
  94. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/schemas/suite.py +0 -0
  95. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/scorers/__init__.py +0 -0
  96. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/scorers/authenticity.py +0 -0
  97. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/scorers/faithfulness.py +0 -0
  98. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/scorers/grounding.py +0 -0
  99. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/scorers/safety.py +0 -0
  100. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/scorers/stability.py +0 -0
  101. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/scripts/__init__.py +0 -0
  102. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/scripts/bootstrap_dataset.py +0 -0
  103. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/scripts/calibrate_persona.py +0 -0
  104. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/scripts/run_openai_demo.py +0 -0
  105. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/scripts/sanitize_dataset.py +0 -0
  106. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/sdk.py +0 -0
  107. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/storage/__init__.py +0 -0
  108. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/storage/evaluations.py +0 -0
  109. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/storage/reviews.py +0 -0
  110. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/storage/runs.py +0 -0
  111. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/utils/__init__.py +0 -0
  112. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/utils/io.py +0 -0
  113. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/utils/optional.py +0 -0
  114. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/utils/tokens.py +0 -0
  115. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter/utils/yaml.py +0 -0
  116. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter.egg-info/dependency_links.txt +0 -0
  117. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter.egg-info/entry_points.txt +0 -0
  118. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter.egg-info/requires.txt +0 -0
  119. {alignmenter-0.3.3 → alignmenter-0.3.4}/src/alignmenter.egg-info/top_level.txt +0 -0
  120. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/__init__.py +0 -0
  121. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/conftest.py +0 -0
  122. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/data/durable_evaluation_judge.py +0 -0
  123. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/data/durable_evaluation_worker.py +0 -0
  124. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/data/durable_recovery_target.py +0 -0
  125. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/data/durable_recovery_worker.py +0 -0
  126. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/data/durable_run_worker.py +0 -0
  127. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/data/mini_cli_dataset.jsonl +0 -0
  128. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_authenticity_judge.py +0 -0
  129. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_builtin_evaluations.py +0 -0
  130. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_calibrate_persona.py +0 -0
  131. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_capture_recovery.py +0 -0
  132. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_cli_errors.py +0 -0
  133. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_cli_grounded.py +0 -0
  134. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_cli_helpers.py +0 -0
  135. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_cli_import.py +0 -0
  136. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_cli_init.py +0 -0
  137. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_cli_run_config.py +0 -0
  138. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_config.py +0 -0
  139. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_dataset_import.py +0 -0
  140. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_dataset_management.py +0 -0
  141. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_dataset_sample.py +0 -0
  142. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_durable_evaluations.py +0 -0
  143. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_durable_execution.py +0 -0
  144. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_faithfulness.py +0 -0
  145. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_github_comment.py +0 -0
  146. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_grounding.py +0 -0
  147. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_html_report.py +0 -0
  148. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_judge_providers.py +0 -0
  149. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_offline_safety.py +0 -0
  150. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_persona_gpt.py +0 -0
  151. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_provider_local.py +0 -0
  152. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_provider_openai.py +0 -0
  153. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_providers.py +0 -0
  154. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_release_workflow.py +0 -0
  155. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_review_workflow.py +0 -0
  156. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_run_config_grounded.py +0 -0
  157. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_run_config_loader.py +0 -0
  158. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_run_openai_demo.py +0 -0
  159. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_runner.py +0 -0
  160. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_sampling.py +0 -0
  161. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_scorers.py +0 -0
  162. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_smoke.py +0 -0
  163. {alignmenter-0.3.3 → alignmenter-0.3.4}/tests/test_suite_archive.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alignmenter
3
- Version: 0.3.3
3
+ Version: 0.3.4
4
4
  Summary: Durable application alignment evaluations, evidence review, saved comparisons, and CI gates.
5
5
  Author: Alignmenter
6
6
  License-Expression: Apache-2.0
@@ -1,3 +1,3 @@
1
1
  """Single source for distribution and runtime version metadata."""
2
2
 
3
- __version__ = "0.3.3"
3
+ __version__ = "0.3.4"
@@ -32,6 +32,7 @@ from alignmenter.providers.classifiers import load_safety_classifier
32
32
  from alignmenter.providers.judges import load_judge_provider
33
33
  from alignmenter.providers.openai import OpenAICustomGPTProvider
34
34
  from alignmenter.release_cli import register_release_commands
35
+ from alignmenter.rubric_grade_cli import register_rubric_grade_command
35
36
  from alignmenter.run_config import load_run_options
36
37
  from alignmenter.runner import RunConfig, Runner
37
38
  from alignmenter.schemas.evaluation import EvaluationSpec, JudgeBudget, JudgeContract
@@ -48,6 +49,7 @@ from alignmenter.storage.runs import RunStore
48
49
 
49
50
  app = typer.Typer(help="Alignmenter — audit your model's alignment signals.")
50
51
  register_release_commands(app)
52
+ register_rubric_grade_command(app)
51
53
 
52
54
  persona_app = typer.Typer(help="Persona helper commands.")
53
55
  dataset_app = typer.Typer(help="Dataset helper commands.")
@@ -0,0 +1,408 @@
1
+ """Decomposed rubric grading — score a captured response against per-record rubrics.
2
+
3
+ HealthBench-style corpora attach physician rubrics to each case (in a turn's
4
+ ``metadata.rubrics``) with no reference answer. This grades a captured response
5
+ against each rubric criterion INDEPENDENTLY: one narrow "is this one criterion
6
+ met?" judge call per criterion. Making each judgment mechanical — a single yes/no
7
+ with evidence — is what lets a *cheap* judge model do it reliably. Grading is
8
+ budget-capped by call count, and an agreement mode compares a cheap judge against
9
+ a strong one so the cheap judge is proven before it is trusted.
10
+
11
+ This is deliberately standalone rather than wired into ``evaluate_saved``: that
12
+ engine plans one item per *static* spec criterion, whereas these rubrics are
13
+ per-record and dynamic. Keeping it separate keeps it cheap and low-risk; the
14
+ prompt/verdict/scoring pieces here are reusable if it later graduates into the
15
+ engine.
16
+
17
+ Judge interface: any object with ``evaluate(prompt: str) -> {"notes": str, ...}``
18
+ — the ``JudgeProvider`` contract in ``providers/judges.py``. Tests inject a fake;
19
+ the CLI wires an ``OpenAIJudge`` whose client points at the Vercel AI Gateway, so
20
+ the judge model is any ``provider/model`` string (e.g. ``anthropic/claude-haiku-4.5``).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ from collections.abc import Callable, Iterable
27
+ from dataclasses import dataclass, field
28
+ from typing import Protocol
29
+
30
+
31
+ class Judge(Protocol):
32
+ def evaluate(self, prompt: str) -> dict: ...
33
+
34
+
35
+ # --- data ------------------------------------------------------------------
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Rubric:
40
+ criterion: str
41
+ points: float = 1.0
42
+ tags: tuple[str, ...] = ()
43
+
44
+ @classmethod
45
+ def from_raw(cls, raw: dict) -> Rubric | None:
46
+ criterion = raw.get("criterion")
47
+ if not isinstance(criterion, str) or not criterion.strip():
48
+ return None
49
+ points = raw.get("points", 1.0)
50
+ try:
51
+ points = float(points)
52
+ except (TypeError, ValueError):
53
+ points = 1.0
54
+ tags = tuple(t for t in (raw.get("tags") or []) if isinstance(t, str))
55
+ return cls(criterion=criterion.strip(), points=points, tags=tags)
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class Case:
60
+ case_id: str
61
+ question: str
62
+ response: str
63
+ rubrics: tuple[Rubric, ...]
64
+
65
+
66
+ @dataclass
67
+ class CriterionVerdict:
68
+ criterion: str
69
+ points: float
70
+ met: bool | None # None = not graded (budget-blocked or unparseable)
71
+ confidence: float | None
72
+ evidence: str
73
+ status: str # "graded" | "budget_blocked" | "invalid"
74
+
75
+
76
+ @dataclass
77
+ class CaseGrade:
78
+ case_id: str
79
+ verdicts: list[CriterionVerdict]
80
+ score: float | None # HealthBench-style normalized 0..1 (None if nothing graded)
81
+
82
+
83
+ @dataclass
84
+ class GradeReport:
85
+ cases: list[CaseGrade] = field(default_factory=list)
86
+ calls: int = 0
87
+ budget_blocked: int = 0
88
+ invalid: int = 0
89
+
90
+ @property
91
+ def graded_cases(self) -> list[CaseGrade]:
92
+ return [c for c in self.cases if c.score is not None]
93
+
94
+ @property
95
+ def mean_score(self) -> float | None:
96
+ scored = [c.score for c in self.graded_cases]
97
+ return sum(scored) / len(scored) if scored else None
98
+
99
+ def to_dict(self) -> dict:
100
+ mean = self.mean_score
101
+ return {
102
+ "n_cases": len(self.cases),
103
+ "n_graded_cases": len(self.graded_cases),
104
+ "mean_score": round(mean, 4) if mean is not None else None,
105
+ "calls": self.calls,
106
+ "budget_blocked": self.budget_blocked,
107
+ "invalid": self.invalid,
108
+ "cases": [
109
+ {
110
+ "case_id": c.case_id,
111
+ "score": round(c.score, 4) if c.score is not None else None,
112
+ "verdicts": [
113
+ {
114
+ "criterion": v.criterion,
115
+ "points": v.points,
116
+ "met": v.met,
117
+ "confidence": v.confidence,
118
+ "status": v.status,
119
+ "evidence": v.evidence,
120
+ }
121
+ for v in c.verdicts
122
+ ],
123
+ }
124
+ for c in self.cases
125
+ ],
126
+ }
127
+
128
+
129
+ # --- extracting gradeable cases from a captured dataset --------------------
130
+
131
+
132
+ def _turn_index(turn: dict) -> int:
133
+ """Sort key tolerant of a missing/non-int turn_index (never crashes the run)."""
134
+ ti = turn.get("turn_index")
135
+ if isinstance(ti, bool) or not isinstance(ti, int):
136
+ try:
137
+ return int(ti)
138
+ except (TypeError, ValueError):
139
+ return 0
140
+ return ti
141
+
142
+
143
+ def extract_cases(records: Iterable[dict]) -> list[Case]:
144
+ """Pull (question, response, rubrics) cases from a captured dataset.
145
+
146
+ A case is one session that carries ``metadata.rubrics`` (the importer puts
147
+ them on the final user turn) AND has a captured assistant response. Sessions
148
+ without rubrics or without a response are skipped — they can't be graded.
149
+ """
150
+ by_session: dict[str, list[dict]] = {}
151
+ order: list[str] = []
152
+ for r in records:
153
+ if not isinstance(r, dict):
154
+ continue
155
+ sid = r.get("session_id")
156
+ if not isinstance(sid, str):
157
+ continue
158
+ if sid not in by_session:
159
+ by_session[sid] = []
160
+ order.append(sid)
161
+ by_session[sid].append(r)
162
+
163
+ cases: list[Case] = []
164
+ for sid in order:
165
+ turns = sorted(by_session[sid], key=_turn_index)
166
+ rubric_turn = next(
167
+ (t for t in turns if isinstance((t.get("metadata") or {}).get("rubrics"), list)), None
168
+ )
169
+ if rubric_turn is None:
170
+ continue
171
+ rubrics = tuple(
172
+ r
173
+ for raw in rubric_turn["metadata"]["rubrics"]
174
+ if isinstance(raw, dict)
175
+ for r in (Rubric.from_raw(raw),)
176
+ if r is not None
177
+ )
178
+ if not rubrics:
179
+ continue
180
+ # The captured response is the last assistant turn with real text (a
181
+ # trailing blank turn shouldn't discard a case that did answer earlier).
182
+ response = next(
183
+ (
184
+ t["text"]
185
+ for t in reversed(turns)
186
+ if t.get("role") == "assistant"
187
+ and isinstance(t.get("text"), str)
188
+ and t["text"].strip()
189
+ ),
190
+ "",
191
+ )
192
+ if not response.strip():
193
+ continue
194
+ question = rubric_turn.get("text", "") if isinstance(rubric_turn.get("text"), str) else ""
195
+ cases.append(Case(case_id=sid, question=question, response=response, rubrics=rubrics))
196
+ return cases
197
+
198
+
199
+ # --- one narrow judgment per criterion -------------------------------------
200
+
201
+ CRITERION_SYSTEM = (
202
+ "You grade one health-assistant reply against ONE rubric criterion. Decide only "
203
+ "whether the reply satisfies that single criterion — nothing else. Quote the reply "
204
+ "as evidence. Respond with JSON only."
205
+ )
206
+
207
+
208
+ def build_criterion_prompt(response: str, rubric: Rubric, *, question: str = "") -> str:
209
+ """A narrow, mechanical single-criterion grading prompt (cheap-model friendly)."""
210
+ payload = {
211
+ "task": "Does the assistant reply satisfy this one criterion?",
212
+ "criterion": rubric.criterion,
213
+ "user_question": question,
214
+ "assistant_reply": response,
215
+ "instructions": (
216
+ "Answer for THIS criterion only. 'met' is true only if the reply clearly "
217
+ "satisfies it. Put a short quote from the reply in 'evidence' (empty if not met)."
218
+ ),
219
+ "response_schema": {
220
+ "met": "boolean",
221
+ "confidence": "number 0..1",
222
+ "evidence": "string (<=200 chars, quoted from the reply)",
223
+ },
224
+ }
225
+ return json.dumps(payload, ensure_ascii=False)
226
+
227
+
228
+ def _extract_json(text: str) -> dict | None:
229
+ """Tolerant JSON extraction — raw, ```json-fenced, or prose-wrapped."""
230
+ t = (text or "").strip()
231
+ if "```" in t:
232
+ parts = t.split("```", 2)
233
+ if len(parts) >= 2:
234
+ body = parts[1]
235
+ if body.startswith("json"):
236
+ body = body[4:]
237
+ t = body.strip()
238
+ start = t.find("{")
239
+ if start != -1:
240
+ t = t[start:]
241
+ try:
242
+ data = json.loads(t)
243
+ except (json.JSONDecodeError, TypeError):
244
+ try:
245
+ data, _ = json.JSONDecoder().raw_decode(t)
246
+ except (json.JSONDecodeError, TypeError, ValueError):
247
+ return None
248
+ return data if isinstance(data, dict) else None
249
+
250
+
251
+ def parse_criterion_verdict(raw_text: str, rubric: Rubric) -> CriterionVerdict:
252
+ data = _extract_json(raw_text)
253
+ met = data.get("met") if isinstance(data, dict) else None
254
+ # Missing, null, or non-scalar `met` = the judge didn't actually decide →
255
+ # invalid (excluded from scoring), never a silent not-met.
256
+ if data is None or "met" not in data or met is None or isinstance(met, (list, dict)):
257
+ return CriterionVerdict(rubric.criterion, rubric.points, None, None, "", "invalid")
258
+ if not isinstance(met, bool):
259
+ met = str(met).strip().lower() in {"true", "yes", "1"}
260
+ conf = data.get("confidence")
261
+ try:
262
+ conf = max(0.0, min(1.0, float(conf))) if conf is not None else None
263
+ except (TypeError, ValueError):
264
+ conf = None
265
+ evidence = data.get("evidence")
266
+ evidence = evidence[:200] if isinstance(evidence, str) else ""
267
+ return CriterionVerdict(rubric.criterion, rubric.points, met, conf, evidence, "graded")
268
+
269
+
270
+ def score_case(verdicts: Iterable[CriterionVerdict]) -> float | None:
271
+ """HealthBench-style normalized score: awarded points / possible positive points.
272
+
273
+ A positive-point criterion earns its points when met; a negative-point
274
+ criterion (a thing that should NOT appear) subtracts its points when met.
275
+ Score is clamped to 0..1 over the positive points actually graded. Returns
276
+ None when nothing was graded (so ungraded cases don't dilute the mean).
277
+ """
278
+ graded = [v for v in verdicts if v.met is not None]
279
+ if not graded:
280
+ return None
281
+ max_positive = sum(v.points for v in graded if v.points > 0)
282
+ if max_positive <= 0:
283
+ return None
284
+ awarded = sum(v.points for v in graded if v.met)
285
+ return max(0.0, min(1.0, awarded / max_positive))
286
+
287
+
288
+ def grade_dataset(
289
+ records: Iterable[dict],
290
+ judge: Judge,
291
+ *,
292
+ max_calls: int | None = None,
293
+ question_in_prompt: bool = True,
294
+ on_call: Callable[[int], None] | None = None,
295
+ ) -> GradeReport:
296
+ """Grade every gradeable case, one judge call per criterion, budget-capped.
297
+
298
+ ``max_calls`` caps total judge calls across all cases; once hit, remaining
299
+ criteria are recorded as ``budget_blocked`` (not graded) rather than skipped
300
+ silently, so the denominator stays honest.
301
+ """
302
+ report = GradeReport()
303
+ for case in extract_cases(records):
304
+ verdicts: list[CriterionVerdict] = []
305
+ for rubric in case.rubrics:
306
+ if max_calls is not None and report.calls >= max_calls:
307
+ verdicts.append(
308
+ CriterionVerdict(
309
+ rubric.criterion, rubric.points, None, None, "", "budget_blocked"
310
+ )
311
+ )
312
+ report.budget_blocked += 1
313
+ continue
314
+ prompt = build_criterion_prompt(
315
+ case.response, rubric, question=case.question if question_in_prompt else ""
316
+ )
317
+ raw = judge.evaluate(prompt)
318
+ report.calls += 1
319
+ if on_call is not None:
320
+ on_call(report.calls)
321
+ verdict = parse_criterion_verdict(
322
+ raw.get("notes", "") if isinstance(raw, dict) else "", rubric
323
+ )
324
+ if verdict.status == "invalid":
325
+ report.invalid += 1
326
+ verdicts.append(verdict)
327
+ report.cases.append(CaseGrade(case.case_id, verdicts, score_case(verdicts)))
328
+ return report
329
+
330
+
331
+ # --- agreement: prove the cheap judge before trusting it -------------------
332
+
333
+
334
+ @dataclass
335
+ class AgreementReport:
336
+ n: int # criteria graded by BOTH judges
337
+ agree: int # both said the same met/not-met
338
+ agreement: float | None # simple agreement rate
339
+ kappa: float | None # Cohen's kappa (chance-corrected)
340
+ calls_a: int
341
+ calls_b: int
342
+
343
+ def to_dict(self) -> dict:
344
+ return {
345
+ "n": self.n,
346
+ "agree": self.agree,
347
+ "agreement": round(self.agreement, 4) if self.agreement is not None else None,
348
+ "cohens_kappa": round(self.kappa, 4) if self.kappa is not None else None,
349
+ "calls_a": self.calls_a,
350
+ "calls_b": self.calls_b,
351
+ }
352
+
353
+
354
+ def _cohens_kappa(a: list[bool], b: list[bool]) -> float | None:
355
+ """Chance-corrected agreement for two binary raters. None if undefined."""
356
+ n = len(a)
357
+ if n == 0:
358
+ return None
359
+ po = sum(1 for x, y in zip(a, b, strict=True) if x == y) / n
360
+ pa_true = sum(a) / n
361
+ pb_true = sum(b) / n
362
+ pe = pa_true * pb_true + (1 - pa_true) * (1 - pb_true)
363
+ if pe >= 1.0: # both raters constant and identical → perfect by convention
364
+ return 1.0 if po >= 1.0 else 0.0
365
+ return (po - pe) / (1 - pe)
366
+
367
+
368
+ def measure_agreement(
369
+ records: Iterable[dict],
370
+ judge_a: Judge,
371
+ judge_b: Judge,
372
+ *,
373
+ max_calls: int | None = None,
374
+ ) -> AgreementReport:
375
+ """Grade the same cases with two judges and report their per-criterion agreement.
376
+
377
+ Runs the full grading pass ONCE PER JUDGE, so this costs roughly 2x a single
378
+ grade (each judge's spend is reported separately as calls_a / calls_b).
379
+ ``max_calls`` caps EACH judge's calls independently. Only criteria both judges
380
+ actually graded (met is not None) count toward agreement.
381
+ """
382
+ records = list(records)
383
+ a = grade_dataset(records, judge_a, max_calls=max_calls)
384
+ b = grade_dataset(records, judge_b, max_calls=max_calls)
385
+
386
+ a_by_case = {c.case_id: c for c in a.cases}
387
+ va: list[bool] = []
388
+ vb: list[bool] = []
389
+ for cb in b.cases:
390
+ ca = a_by_case.get(cb.case_id)
391
+ if ca is None:
392
+ continue
393
+ by_crit = {v.criterion: v for v in ca.verdicts}
394
+ for v in cb.verdicts:
395
+ other = by_crit.get(v.criterion)
396
+ if other is not None and v.met is not None and other.met is not None:
397
+ va.append(other.met)
398
+ vb.append(v.met)
399
+ n = len(va)
400
+ agree = sum(1 for x, y in zip(va, vb, strict=True) if x == y)
401
+ return AgreementReport(
402
+ n=n,
403
+ agree=agree,
404
+ agreement=(agree / n) if n else None,
405
+ kappa=_cohens_kappa(va, vb),
406
+ calls_a=a.calls,
407
+ calls_b=b.calls,
408
+ )
@@ -0,0 +1,105 @@
1
+ """CLI for decomposed rubric grading (`alignmenter rubric-grade`).
2
+
3
+ Grades a captured dataset against its per-record ``metadata.rubrics`` — one narrow
4
+ judge call per criterion — with a cheap gateway model, budget-capped. With
5
+ ``--compare-judge`` it runs an agreement check (cheap vs strong) instead, so the
6
+ cheap judge is proven before it is trusted. The judge model is any Vercel AI
7
+ Gateway ``provider/model`` string; auth is ``AI_GATEWAY_API_KEY`` (or ``OPENAI_API_KEY``).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+
16
+ import typer
17
+
18
+ from alignmenter.rubric_grade import grade_dataset, measure_agreement
19
+ from alignmenter.utils.io import read_jsonl, write_json
20
+
21
+ GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh/v1"
22
+
23
+
24
+ def _make_gateway_judge(model: str, base_url: str):
25
+ """An OpenAIJudge whose OpenAI-compatible client points at the AI Gateway.
26
+
27
+ The gateway routes by the ``provider/model`` string, so any gateway model
28
+ works as a judge without a provider-specific SDK. Kept lazy so importing the
29
+ CLI never requires the openai package.
30
+ """
31
+ from openai import OpenAI
32
+
33
+ from alignmenter.providers.judges import OpenAIJudge
34
+
35
+ api_key = os.environ.get("AI_GATEWAY_API_KEY") or os.environ.get("OPENAI_API_KEY")
36
+ if not api_key:
37
+ raise typer.BadParameter(
38
+ "rubric-grade needs AI_GATEWAY_API_KEY (or OPENAI_API_KEY) for the judge model"
39
+ )
40
+ return OpenAIJudge(model=model, client=OpenAI(base_url=base_url, api_key=api_key))
41
+
42
+
43
+ def register_rubric_grade_command(app: typer.Typer) -> None:
44
+ @app.command("rubric-grade")
45
+ def rubric_grade_cmd(
46
+ dataset: Path = typer.Argument(
47
+ ...,
48
+ exists=True,
49
+ dir_okay=False,
50
+ help="Captured dataset JSONL (turns + metadata.rubrics).",
51
+ ),
52
+ judge: str = typer.Option(
53
+ ..., "--judge", help="Gateway judge model, e.g. anthropic/claude-haiku-4.5."
54
+ ),
55
+ compare_judge: str | None = typer.Option(
56
+ None, "--compare-judge", help="Second model → agreement mode (cheap vs strong)."
57
+ ),
58
+ max_calls: int | None = typer.Option(
59
+ None, "--max-calls", min=1, help="Cap total judge calls (budget)."
60
+ ),
61
+ base_url: str = typer.Option(GATEWAY_BASE_URL, "--base-url", help="AI Gateway base URL."),
62
+ out: Path | None = typer.Option(None, "--out", help="Write the full report JSON here."),
63
+ ):
64
+ """Grade captured responses against per-record rubrics with a cheap decomposed judge.
65
+
66
+ Grade mode (default):
67
+ alignmenter rubric-grade captures.jsonl --judge anthropic/claude-haiku-4.5 --max-calls 200
68
+ Agreement mode (prove the cheap judge against a strong one):
69
+ alignmenter rubric-grade captures.jsonl --judge anthropic/claude-haiku-4.5 \\
70
+ --compare-judge anthropic/claude-sonnet-5 --max-calls 200
71
+ """
72
+ records = read_jsonl(dataset)
73
+ judge_a = _make_gateway_judge(judge, base_url)
74
+
75
+ if compare_judge:
76
+ judge_b = _make_gateway_judge(compare_judge, base_url)
77
+ report = measure_agreement(records, judge_a, judge_b, max_calls=max_calls)
78
+ payload = {
79
+ "mode": "agreement",
80
+ "judge_a": judge,
81
+ "judge_b": compare_judge,
82
+ **report.to_dict(),
83
+ }
84
+ agr = f"{report.agreement:.1%}" if report.agreement is not None else "n/a"
85
+ kappa = f"{report.kappa:.3f}" if report.kappa is not None else "n/a"
86
+ typer.echo(
87
+ f"agreement: {report.agree}/{report.n} criteria = {agr} · Cohen's κ={kappa} "
88
+ f"· calls {report.calls_a}+{report.calls_b}"
89
+ )
90
+ else:
91
+ report = grade_dataset(records, judge_a, max_calls=max_calls)
92
+ payload = {"mode": "grade", "judge": judge, **report.to_dict()}
93
+ mean = f"{report.mean_score:.3f}" if report.mean_score is not None else "n/a"
94
+ typer.echo(
95
+ f"graded {len(report.graded_cases)}/{len(report.cases)} cases · mean score {mean} "
96
+ f"· {report.calls} calls"
97
+ + (f" · {report.budget_blocked} budget-blocked" if report.budget_blocked else "")
98
+ + (f" · {report.invalid} invalid" if report.invalid else "")
99
+ )
100
+
101
+ if out is not None:
102
+ write_json(out, payload)
103
+ typer.echo(f"wrote report -> {out}")
104
+ else:
105
+ typer.echo(json.dumps(payload, indent=2))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alignmenter
3
- Version: 0.3.3
3
+ Version: 0.3.4
4
4
  Summary: Durable application alignment evaluations, evidence review, saved comparisons, and CI gates.
5
5
  Author: Alignmenter
6
6
  License-Expression: Apache-2.0
@@ -18,6 +18,8 @@ src/alignmenter/cli.py
18
18
  src/alignmenter/config.py
19
19
  src/alignmenter/dataset_cli.py
20
20
  src/alignmenter/release_cli.py
21
+ src/alignmenter/rubric_grade.py
22
+ src/alignmenter/rubric_grade_cli.py
21
23
  src/alignmenter/run_config.py
22
24
  src/alignmenter/runner.py
23
25
  src/alignmenter/sdk.py
@@ -142,6 +144,7 @@ tests/test_provider_openai.py
142
144
  tests/test_providers.py
143
145
  tests/test_release_workflow.py
144
146
  tests/test_review_workflow.py
147
+ tests/test_rubric_grade.py
145
148
  tests/test_run_config_grounded.py
146
149
  tests/test_run_config_loader.py
147
150
  tests/test_run_openai_demo.py