alignmenter 0.3.2__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 (164) hide show
  1. {alignmenter-0.3.2/src/alignmenter.egg-info → alignmenter-0.3.4}/PKG-INFO +1 -1
  2. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/_version.py +1 -1
  3. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/cli.py +2 -0
  4. alignmenter-0.3.4/src/alignmenter/dataset_cli.py +398 -0
  5. alignmenter-0.3.4/src/alignmenter/importers/__init__.py +163 -0
  6. alignmenter-0.3.4/src/alignmenter/importers/healthbench.py +91 -0
  7. alignmenter-0.3.4/src/alignmenter/rubric_grade.py +408 -0
  8. alignmenter-0.3.4/src/alignmenter/rubric_grade_cli.py +105 -0
  9. {alignmenter-0.3.2 → alignmenter-0.3.4/src/alignmenter.egg-info}/PKG-INFO +1 -1
  10. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter.egg-info/SOURCES.txt +7 -0
  11. alignmenter-0.3.4/tests/test_dataset_import.py +277 -0
  12. alignmenter-0.3.4/tests/test_dataset_sample.py +170 -0
  13. alignmenter-0.3.4/tests/test_rubric_grade.py +311 -0
  14. alignmenter-0.3.2/src/alignmenter/dataset_cli.py +0 -205
  15. {alignmenter-0.3.2 → alignmenter-0.3.4}/LICENSE +0 -0
  16. {alignmenter-0.3.2 → alignmenter-0.3.4}/MANIFEST.in +0 -0
  17. {alignmenter-0.3.2 → alignmenter-0.3.4}/README.md +0 -0
  18. {alignmenter-0.3.2 → alignmenter-0.3.4}/configs/demo_config.yaml +0 -0
  19. {alignmenter-0.3.2 → alignmenter-0.3.4}/configs/judges/safety_prompt.txt +0 -0
  20. {alignmenter-0.3.2 → alignmenter-0.3.4}/configs/persona/default.yaml +0 -0
  21. {alignmenter-0.3.2 → alignmenter-0.3.4}/configs/run-grounded.yaml +0 -0
  22. {alignmenter-0.3.2 → alignmenter-0.3.4}/configs/run.yaml +0 -0
  23. {alignmenter-0.3.2 → alignmenter-0.3.4}/configs/safety_keywords.yaml +0 -0
  24. {alignmenter-0.3.2 → alignmenter-0.3.4}/datasets/README.md +0 -0
  25. {alignmenter-0.3.2 → alignmenter-0.3.4}/datasets/demo_conversations.jsonl +0 -0
  26. {alignmenter-0.3.2 → alignmenter-0.3.4}/datasets/grounded_demo.jsonl +0 -0
  27. {alignmenter-0.3.2 → alignmenter-0.3.4}/datasets/wendys_twitter.jsonl +0 -0
  28. {alignmenter-0.3.2 → alignmenter-0.3.4}/pyproject.toml +0 -0
  29. {alignmenter-0.3.2 → alignmenter-0.3.4}/setup.cfg +0 -0
  30. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/__init__.py +0 -0
  31. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/calibration/__init__.py +0 -0
  32. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/calibration/analyze.py +0 -0
  33. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/calibration/bounds.py +0 -0
  34. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/calibration/diagnose.py +0 -0
  35. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/calibration/generate.py +0 -0
  36. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/calibration/label.py +0 -0
  37. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/calibration/optimize.py +0 -0
  38. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/calibration/sampling.py +0 -0
  39. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/calibration/validate.py +0 -0
  40. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/config.py +0 -0
  41. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/data/configs/demo_config.yaml +0 -0
  42. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/data/configs/judges/safety_prompt.txt +0 -0
  43. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/data/configs/persona/default.yaml +0 -0
  44. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/data/configs/run-grounded.yaml +0 -0
  45. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/data/configs/run.yaml +0 -0
  46. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/data/configs/safety_keywords.yaml +0 -0
  47. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/data/datasets/demo_conversations.jsonl +0 -0
  48. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/data/datasets/grounded_demo.jsonl +0 -0
  49. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/evaluators/__init__.py +0 -0
  50. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/evaluators/custom.py +0 -0
  51. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/evaluators/evidence.py +0 -0
  52. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/evaluators/faithfulness.py +0 -0
  53. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/evaluators/grounding.py +0 -0
  54. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/evaluators/metrics.py +0 -0
  55. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/examples/__init__.py +0 -0
  56. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/examples/resource_task.py +0 -0
  57. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/execution/__init__.py +0 -0
  58. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/execution/archive.py +0 -0
  59. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/execution/artifacts.py +0 -0
  60. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/execution/comparison.py +0 -0
  61. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/execution/evaluation.py +0 -0
  62. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/execution/gates.py +0 -0
  63. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/execution/leases.py +0 -0
  64. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/execution/legacy.py +0 -0
  65. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/execution/recovery.py +0 -0
  66. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/execution/review.py +0 -0
  67. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/execution/suite.py +0 -0
  68. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/judges/__init__.py +0 -0
  69. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/judges/authenticity_judge.py +0 -0
  70. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/judges/prompts.py +0 -0
  71. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/providers/__init__.py +0 -0
  72. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/providers/anthropic.py +0 -0
  73. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/providers/base.py +0 -0
  74. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/providers/callable.py +0 -0
  75. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/providers/classifiers.py +0 -0
  76. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/providers/durable_judge.py +0 -0
  77. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/providers/embeddings.py +0 -0
  78. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/providers/judges.py +0 -0
  79. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/providers/local.py +0 -0
  80. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/providers/openai.py +0 -0
  81. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/release_cli.py +0 -0
  82. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/reporting/__init__.py +0 -0
  83. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/reporting/durable.py +0 -0
  84. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/reporting/github_comment.py +0 -0
  85. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/reporting/html.py +0 -0
  86. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/reporting/json_out.py +0 -0
  87. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/run_config.py +0 -0
  88. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/runner.py +0 -0
  89. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/schemas/__init__.py +0 -0
  90. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/schemas/dataset.py +0 -0
  91. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/schemas/evaluation.py +0 -0
  92. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/schemas/execution.py +0 -0
  93. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/schemas/gates.py +0 -0
  94. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/schemas/metrics.py +0 -0
  95. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/schemas/review.py +0 -0
  96. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/schemas/scoring.py +0 -0
  97. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/schemas/suite.py +0 -0
  98. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/scorers/__init__.py +0 -0
  99. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/scorers/authenticity.py +0 -0
  100. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/scorers/faithfulness.py +0 -0
  101. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/scorers/grounding.py +0 -0
  102. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/scorers/safety.py +0 -0
  103. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/scorers/stability.py +0 -0
  104. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/scripts/__init__.py +0 -0
  105. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/scripts/bootstrap_dataset.py +0 -0
  106. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/scripts/calibrate_persona.py +0 -0
  107. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/scripts/run_openai_demo.py +0 -0
  108. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/scripts/sanitize_dataset.py +0 -0
  109. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/sdk.py +0 -0
  110. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/storage/__init__.py +0 -0
  111. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/storage/evaluations.py +0 -0
  112. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/storage/reviews.py +0 -0
  113. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/storage/runs.py +0 -0
  114. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/utils/__init__.py +0 -0
  115. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/utils/io.py +0 -0
  116. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/utils/optional.py +0 -0
  117. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/utils/tokens.py +0 -0
  118. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/utils/yaml.py +0 -0
  119. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter.egg-info/dependency_links.txt +0 -0
  120. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter.egg-info/entry_points.txt +0 -0
  121. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter.egg-info/requires.txt +0 -0
  122. {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter.egg-info/top_level.txt +0 -0
  123. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/__init__.py +0 -0
  124. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/conftest.py +0 -0
  125. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/data/durable_evaluation_judge.py +0 -0
  126. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/data/durable_evaluation_worker.py +0 -0
  127. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/data/durable_recovery_target.py +0 -0
  128. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/data/durable_recovery_worker.py +0 -0
  129. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/data/durable_run_worker.py +0 -0
  130. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/data/mini_cli_dataset.jsonl +0 -0
  131. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_authenticity_judge.py +0 -0
  132. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_builtin_evaluations.py +0 -0
  133. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_calibrate_persona.py +0 -0
  134. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_capture_recovery.py +0 -0
  135. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_cli_errors.py +0 -0
  136. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_cli_grounded.py +0 -0
  137. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_cli_helpers.py +0 -0
  138. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_cli_import.py +0 -0
  139. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_cli_init.py +0 -0
  140. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_cli_run_config.py +0 -0
  141. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_config.py +0 -0
  142. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_dataset_management.py +0 -0
  143. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_durable_evaluations.py +0 -0
  144. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_durable_execution.py +0 -0
  145. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_faithfulness.py +0 -0
  146. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_github_comment.py +0 -0
  147. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_grounding.py +0 -0
  148. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_html_report.py +0 -0
  149. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_judge_providers.py +0 -0
  150. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_offline_safety.py +0 -0
  151. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_persona_gpt.py +0 -0
  152. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_provider_local.py +0 -0
  153. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_provider_openai.py +0 -0
  154. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_providers.py +0 -0
  155. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_release_workflow.py +0 -0
  156. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_review_workflow.py +0 -0
  157. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_run_config_grounded.py +0 -0
  158. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_run_config_loader.py +0 -0
  159. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_run_openai_demo.py +0 -0
  160. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_runner.py +0 -0
  161. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_sampling.py +0 -0
  162. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_scorers.py +0 -0
  163. {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_smoke.py +0 -0
  164. {alignmenter-0.3.2 → 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.2
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.2"
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,398 @@
1
+ """Dataset-management commands: stats, validate, dedupe, merge, split, sample, manifest, import.
2
+
3
+ Registered onto the existing `dataset` sub-app (alongside lint/sanitize/bootstrap)
4
+ via register_dataset_commands(dataset_app). Builds on the shared primitives
5
+ (content_digest, read/write_jsonl) and schemas/dataset.py.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import random
12
+ from collections import Counter, defaultdict
13
+ from pathlib import Path
14
+
15
+ import typer
16
+
17
+ from alignmenter.schemas.dataset import (
18
+ ProvenanceEntry,
19
+ build_manifest,
20
+ dataset_digest,
21
+ validate_records,
22
+ )
23
+ from alignmenter.schemas.execution import content_digest
24
+ from alignmenter.utils.io import read_jsonl, write_json, write_jsonl
25
+
26
+
27
+ def _group_key(record: dict, by: str) -> str:
28
+ """Grouping key for split — keeps a case + its variants on the same side."""
29
+ if not isinstance(record, dict):
30
+ return "" # non-dict rows group together (like the other commands, don't crash)
31
+ metadata = record.get("metadata") or {}
32
+ session = record.get("session_id") or ""
33
+ if by == "split_group":
34
+ return str(metadata.get("split_group") or session)
35
+ if by == "group":
36
+ for tag in record.get("tags") or []:
37
+ if isinstance(tag, str) and tag.startswith("group:"):
38
+ return tag
39
+ return session
40
+ if by == "persona":
41
+ return str(record.get("persona_id") or session)
42
+ return session
43
+
44
+
45
+ def register_dataset_commands(dataset_app: typer.Typer) -> None:
46
+ @dataset_app.command("stats")
47
+ def stats(
48
+ path: Path = typer.Argument(..., exists=True, dir_okay=False),
49
+ as_json: bool = typer.Option(False, "--json", help="Emit the coverage report as JSON."),
50
+ ):
51
+ """Coverage report — record/session counts, role balance, tag + persona histograms."""
52
+ records = read_jsonl(path)
53
+ manifest = build_manifest(records, id=path.stem, revision="stats")
54
+ roles = Counter(r.get("role") for r in records if isinstance(r, dict))
55
+ with_context = sum(
56
+ 1
57
+ for r in records
58
+ if isinstance(r, dict)
59
+ and isinstance(r.get("metadata"), dict)
60
+ and "context" in r["metadata"]
61
+ )
62
+ if as_json:
63
+ typer.echo(
64
+ json.dumps(
65
+ {
66
+ "records": manifest.record_count,
67
+ "sessions": manifest.session_count,
68
+ "roles": {k: v for k, v in roles.items()},
69
+ "with_context": with_context,
70
+ "tags": manifest.tags,
71
+ "personas": manifest.personas,
72
+ "content_digest": manifest.content_digest,
73
+ },
74
+ indent=2,
75
+ )
76
+ )
77
+ return
78
+ typer.echo(
79
+ f"records {manifest.record_count} · sessions {manifest.session_count} · turns-with-context {with_context}"
80
+ )
81
+ typer.echo(
82
+ "roles: "
83
+ + ", ".join(f"{k}={v}" for k, v in sorted(roles.items(), key=lambda kv: str(kv[0])))
84
+ )
85
+ if manifest.tags:
86
+ typer.echo("tags: " + ", ".join(f"{k}={v}" for k, v in sorted(manifest.tags.items())))
87
+ if manifest.personas:
88
+ typer.echo(
89
+ "personas: " + ", ".join(f"{k}={v}" for k, v in sorted(manifest.personas.items()))
90
+ )
91
+ typer.echo(f"content_digest: {manifest.content_digest[:12]}…")
92
+
93
+ @dataset_app.command("validate")
94
+ def validate(
95
+ path: Path = typer.Argument(..., exists=True, dir_okay=False),
96
+ strict: bool = typer.Option(
97
+ False,
98
+ "--strict",
99
+ help="Require turn_index+tags+persona_id and an assistant turn per session.",
100
+ ),
101
+ ):
102
+ """Schema validator — lenient by default, --strict adds turn_index+tags+persona_id + an assistant turn per session.
103
+
104
+ Complements `dataset lint`, which additionally checks turn-index contiguity,
105
+ scenario-tag coverage, and persona-file existence.
106
+ """
107
+ records = read_jsonl(path)
108
+ errors = validate_records(records, strict=strict)
109
+ for error in errors:
110
+ typer.echo(error, err=True)
111
+ typer.echo(f"{len(records)} records, {len(errors)} error(s)")
112
+ raise typer.Exit(1 if errors else 0)
113
+
114
+ @dataset_app.command("dedupe")
115
+ def dedupe(
116
+ path: Path = typer.Argument(..., exists=True, dir_okay=False),
117
+ out: Path | None = typer.Option(
118
+ None, "--out", help="Output path (default: <stem>.dedup.jsonl)."
119
+ ),
120
+ in_place: bool = typer.Option(False, "--in-place"),
121
+ ):
122
+ """Drop content-identical duplicate records (order preserved)."""
123
+ records = read_jsonl(path)
124
+ seen: set[str] = set()
125
+ kept = []
126
+ for record in records:
127
+ digest = content_digest(record)
128
+ if digest in seen:
129
+ continue
130
+ seen.add(digest)
131
+ kept.append(record)
132
+ destination = path if in_place else (out or path.with_name(f"{path.stem}.dedup.jsonl"))
133
+ write_jsonl(destination, kept)
134
+ typer.echo(
135
+ f"removed {len(records) - len(kept)} duplicate(s); wrote {len(kept)} -> {destination}"
136
+ )
137
+
138
+ @dataset_app.command("merge")
139
+ def merge(
140
+ paths: list[Path] = typer.Argument(..., exists=True, dir_okay=False),
141
+ out: Path = typer.Option(..., "--out"),
142
+ dedupe: bool = typer.Option(
143
+ False, "--dedupe", help="Drop content-identical records after merging."
144
+ ),
145
+ namespace_sessions: bool = typer.Option(
146
+ False,
147
+ "--namespace-sessions",
148
+ help="Prefix session_id with the source file stem to avoid collisions.",
149
+ ),
150
+ ):
151
+ """Concatenate datasets into one (optionally dedupe / namespace session ids)."""
152
+ merged: list[dict] = []
153
+ for source in paths:
154
+ records = read_jsonl(source)
155
+ if namespace_sessions:
156
+ for record in records:
157
+ if isinstance(record, dict) and record.get("session_id"):
158
+ record["session_id"] = f"{source.stem}:{record['session_id']}"
159
+ merged.extend(records)
160
+ if dedupe:
161
+ seen: set[str] = set()
162
+ deduped = []
163
+ for record in merged:
164
+ digest = content_digest(record)
165
+ if digest in seen:
166
+ continue
167
+ seen.add(digest)
168
+ deduped.append(record)
169
+ merged = deduped
170
+ write_jsonl(out, merged)
171
+ typer.echo(f"merged {len(paths)} file(s) -> {len(merged)} records -> {out}")
172
+
173
+ @dataset_app.command("split")
174
+ def split(
175
+ path: Path = typer.Argument(..., exists=True, dir_okay=False),
176
+ out: Path = typer.Option(
177
+ ..., "--out", help="Output directory for train.jsonl + holdout.jsonl."
178
+ ),
179
+ holdout: float = typer.Option(
180
+ 0.2, "--holdout", min=0.0, max=1.0, help="Target holdout fraction of records."
181
+ ),
182
+ by: str = typer.Option(
183
+ "split_group",
184
+ "--by",
185
+ help="Grouping unit kept together: split_group | group | session | persona.",
186
+ ),
187
+ seed: int = typer.Option(42, "--seed"),
188
+ ):
189
+ """Group-aware train/holdout split — a case and its variants never straddle the boundary."""
190
+ if by not in {"split_group", "group", "session", "persona"}:
191
+ raise typer.BadParameter("--by must be split_group, group, session, or persona")
192
+ records = read_jsonl(path)
193
+ groups: dict[str, list[dict]] = defaultdict(list)
194
+ for record in records:
195
+ groups[_group_key(record, by)].append(record)
196
+ names = sorted(groups)
197
+ random.Random(seed).shuffle(names)
198
+ target = holdout * len(records)
199
+ held: set[str] = set()
200
+ accumulated = 0
201
+ for name in names:
202
+ if accumulated >= target:
203
+ break
204
+ held.add(name)
205
+ accumulated += len(groups[name])
206
+ train = [r for r in records if _group_key(r, by) not in held]
207
+ holdout_records = [r for r in records if _group_key(r, by) in held]
208
+ write_jsonl(Path(out) / "train.jsonl", train)
209
+ write_jsonl(Path(out) / "holdout.jsonl", holdout_records)
210
+ typer.echo(
211
+ f"train {len(train)} · holdout {len(holdout_records)} "
212
+ f"({len(held)}/{len(names)} '{by}' groups) -> {out}"
213
+ )
214
+
215
+ @dataset_app.command("sample")
216
+ def sample(
217
+ path: Path = typer.Argument(..., exists=True, dir_okay=False),
218
+ out: Path = typer.Option(..., "--out", help="Output subset JSONL."),
219
+ n: int = typer.Option(..., "--n", min=1, help="Number of units to keep."),
220
+ by: str = typer.Option(
221
+ "session", "--by", help="Sampling unit kept whole: session | group | persona."
222
+ ),
223
+ filter_tag: list[str] = typer.Option(
224
+ [],
225
+ "--filter-tag",
226
+ help="Keep only units with a record carrying one of these tags (repeatable, OR). "
227
+ "e.g. --filter-tag tool:nutrition",
228
+ ),
229
+ seed: int = typer.Option(42, "--seed"),
230
+ ):
231
+ """Down-sample a dataset to N whole units, optionally pre-filtered by tag.
232
+
233
+ The change-aware selection primitive: a random seeded subset for regular
234
+ runs, or `--filter-tag tool:<name>` for a change-scoped subset. Units are
235
+ kept whole (a case + its variants never split) and output is ordered by
236
+ unit key for a stable content digest.
237
+ """
238
+ if by not in {"session", "group", "persona"}:
239
+ raise typer.BadParameter("--by must be session, group, or persona")
240
+ records = read_jsonl(path)
241
+ wanted = set(filter_tag)
242
+
243
+ groups: dict[str, list[dict]] = defaultdict(list)
244
+ for record in records:
245
+ groups[_group_key(record, by)].append(record)
246
+
247
+ def _keep(unit: list[dict]) -> bool:
248
+ if not wanted:
249
+ return True
250
+ return any(
251
+ isinstance(t, str) and t in wanted
252
+ for r in unit
253
+ if isinstance(r, dict)
254
+ for t in (r.get("tags") or [])
255
+ )
256
+
257
+ eligible = [name for name, unit in groups.items() if _keep(unit)]
258
+ if not eligible:
259
+ typer.echo(f"no units match filter {sorted(wanted)}", err=True)
260
+ raise typer.Exit(1)
261
+
262
+ names = sorted(eligible)
263
+ random.Random(seed).shuffle(names)
264
+ picked = sorted(names[:n])
265
+ subset = [r for name in picked for r in groups[name]]
266
+ write_jsonl(out, subset)
267
+ typer.echo(
268
+ f"sampled {len(picked)}/{len(eligible)} eligible '{by}' units "
269
+ f"({len(subset)} records"
270
+ + (f", filter {sorted(wanted)}" if wanted else "")
271
+ + f") -> {out}"
272
+ )
273
+
274
+ @dataset_app.command("manifest")
275
+ def manifest(
276
+ path: Path = typer.Argument(..., exists=True, dir_okay=False),
277
+ out: Path | None = typer.Option(
278
+ None, "--out", help="Write the manifest JSON here (default: stdout)."
279
+ ),
280
+ identifier: str | None = typer.Option(
281
+ None, "--id", help="Dataset id (default: file stem)."
282
+ ),
283
+ revision: str = typer.Option("v1", "--revision"),
284
+ verify: Path | None = typer.Option(
285
+ None, "--verify", help="Verify the data still matches this manifest's content_digest."
286
+ ),
287
+ ):
288
+ """Build (or --verify) a content-addressed dataset manifest with provenance."""
289
+ records = read_jsonl(path)
290
+ if verify is not None:
291
+ try:
292
+ expected = json.loads(Path(verify).read_text()).get("content_digest")
293
+ except (json.JSONDecodeError, OSError) as exc:
294
+ raise typer.BadParameter(f"Could not read manifest {verify}: {exc}") from exc
295
+ actual = dataset_digest(records)
296
+ match = expected == actual
297
+ typer.echo(
298
+ f"{'OK' if match else 'MISMATCH'}: manifest {str(expected)[:12]} vs data {actual[:12]}"
299
+ )
300
+ raise typer.Exit(0 if match else 2)
301
+ built = build_manifest(
302
+ records,
303
+ id=identifier or path.stem,
304
+ revision=revision,
305
+ provenance=(
306
+ ProvenanceEntry(
307
+ kind="authored",
308
+ ref=str(path),
309
+ count=len(records),
310
+ digest=dataset_digest(records),
311
+ ),
312
+ ),
313
+ )
314
+ payload = built.model_dump(mode="json")
315
+ if out is not None:
316
+ write_json(out, payload)
317
+ typer.echo(f"wrote manifest -> {out}")
318
+ else:
319
+ typer.echo(json.dumps(payload, indent=2))
320
+
321
+ @dataset_app.command("import")
322
+ def import_corpus_cmd(
323
+ source: str = typer.Argument(..., help="Corpus adapter (e.g. healthbench)."),
324
+ input_path: Path = typer.Argument(
325
+ ...,
326
+ metavar="INPUT",
327
+ exists=True,
328
+ dir_okay=False,
329
+ help="Local corpus JSONL you fetched/licensed.",
330
+ ),
331
+ out: Path = typer.Option(..., "--out", help="Output dataset JSONL."),
332
+ sample: int | None = typer.Option(
333
+ None,
334
+ "--sample",
335
+ min=1,
336
+ help="Down-sample to N sessions (stratified by the source's axis).",
337
+ ),
338
+ seed: int = typer.Option(42, "--seed"),
339
+ no_stratify: bool = typer.Option(
340
+ False, "--no-stratify", help="Plain random sample instead of stratified."
341
+ ),
342
+ manifest: bool = typer.Option(
343
+ False, "--manifest", help="Also write <out>.manifest.json with import provenance."
344
+ ),
345
+ ):
346
+ """Adapt a public eval corpus into Alignmenter turn records.
347
+
348
+ Corpora are fetched and licensed by you (importers never download); point
349
+ this at a local JSONL. Example:
350
+ alignmenter dataset import healthbench oss_eval.jsonl --out hb.jsonl --sample 60
351
+ """
352
+ from alignmenter.importers import available, get_importer, import_corpus
353
+
354
+ try:
355
+ mapper, default_prefix = get_importer(source)
356
+ except KeyError as exc:
357
+ raise typer.BadParameter(
358
+ f"unknown source {source!r}; available: {', '.join(available())}"
359
+ ) from exc
360
+
361
+ rows = read_jsonl(input_path)
362
+ records, report = import_corpus(
363
+ rows,
364
+ mapper,
365
+ sample=sample,
366
+ seed=seed,
367
+ stratify_prefix=None if no_stratify else default_prefix,
368
+ )
369
+ if not records:
370
+ typer.echo(
371
+ f"no records imported from {input_path} "
372
+ f"({report['input_rows']} rows, {report['skipped']} skipped)",
373
+ err=True,
374
+ )
375
+ raise typer.Exit(1)
376
+
377
+ write_jsonl(out, records)
378
+ deduped = report.get("deduped", 0)
379
+ typer.echo(
380
+ f"imported {report['records_out']} records / {report['sessions_out']} sessions "
381
+ f"from {report['input_rows']} rows ({report['skipped']} skipped"
382
+ + (f", {deduped} duplicate(s) dropped" if deduped else "")
383
+ + f") -> {out}"
384
+ )
385
+ if report.get("strata"):
386
+ typer.echo("strata: " + ", ".join(f"{k}={v}" for k, v in report["strata"].items()))
387
+
388
+ if manifest:
389
+ provenance = ProvenanceEntry(
390
+ kind="import",
391
+ ref=f"{source}:{input_path.name}",
392
+ count=len(records),
393
+ digest=dataset_digest(records),
394
+ )
395
+ built = build_manifest(records, id=out.stem, revision="v1", provenance=(provenance,))
396
+ manifest_path = out.with_suffix(out.suffix + ".manifest.json")
397
+ write_json(manifest_path, built.model_dump(mode="json"))
398
+ typer.echo(f"wrote manifest -> {manifest_path}")
@@ -0,0 +1,163 @@
1
+ """Dataset importers: adapt public eval corpora into Alignmenter turn records.
2
+
3
+ Each importer is a pure ``row -> list[record]`` mapper registered by name.
4
+ ``import_corpus`` owns the shared concerns — session-level sampling (optionally
5
+ stratified so a small sample keeps topic coverage), deterministic ordering, and a
6
+ counts report — so an adapter only has to describe the mapping.
7
+
8
+ Importers never download: the corpora are fetched and licensed by the user, then
9
+ this maps rows already read from a local JSONL. New corpora (K-QA, MedSafetyBench)
10
+ add a module + one ``IMPORTERS`` entry.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import random
16
+ from collections import defaultdict
17
+ from collections.abc import Callable, Iterable
18
+
19
+ from alignmenter.importers.healthbench import healthbench_to_records
20
+ from alignmenter.schemas.execution import content_digest
21
+
22
+ Mapper = Callable[[dict], list[dict]]
23
+
24
+ # name -> (row mapper, default stratification tag-prefix)
25
+ IMPORTERS: dict[str, tuple[Mapper, str]] = {
26
+ "healthbench": (healthbench_to_records, "theme:"),
27
+ }
28
+
29
+
30
+ def available() -> list[str]:
31
+ return sorted(IMPORTERS)
32
+
33
+
34
+ def get_importer(name: str) -> tuple[Mapper, str]:
35
+ """Return (mapper, default_stratify_prefix); raises KeyError if unknown."""
36
+ return IMPORTERS[name]
37
+
38
+
39
+ def _session_of(records: list[dict]) -> str:
40
+ return records[0].get("session_id", "") if records else ""
41
+
42
+
43
+ def _stratum_of(records: list[dict], prefix: str) -> str:
44
+ for r in records:
45
+ for tag in r.get("tags") or []:
46
+ if isinstance(tag, str) and tag.startswith(prefix):
47
+ return tag
48
+ return "(none)"
49
+
50
+
51
+ def _dedupe_and_disambiguate(sessions: list[list[dict]]) -> tuple[list[list[dict]], int]:
52
+ """Guarantee unique session ids across mapped sessions.
53
+
54
+ Two source rows can map to the same session_id — a content-addressed id
55
+ collides when rows share prompt content and carry no source id. Left alone
56
+ that emits duplicate turn_index within one session, which fails
57
+ ``validate_records``. So: drop byte-identical duplicates, and suffix an id
58
+ that collides with *different* content (`<id>#2`, `#3`, …). Returns the
59
+ cleaned sessions plus the count of exact duplicates dropped.
60
+ """
61
+ seen: dict[str, str] = {} # session_id -> session content digest
62
+ kept: list[list[dict]] = []
63
+ dropped = 0
64
+ for session in sessions:
65
+ sid = _session_of(session)
66
+ digest = content_digest(session)
67
+ if sid not in seen:
68
+ seen[sid] = digest
69
+ kept.append(session)
70
+ continue
71
+ if seen[sid] == digest:
72
+ dropped += 1 # exact duplicate row
73
+ continue
74
+ suffix = 2
75
+ new_sid = f"{sid}#{suffix}"
76
+ while new_sid in seen:
77
+ suffix += 1
78
+ new_sid = f"{sid}#{suffix}"
79
+ for record in session:
80
+ record["session_id"] = new_sid
81
+ seen[new_sid] = digest
82
+ kept.append(session)
83
+ return kept, dropped
84
+
85
+
86
+ def _stratified_sample(
87
+ sessions: list[list[dict]], sample: int, prefix: str, rng: random.Random
88
+ ) -> list[list[dict]]:
89
+ """Round-robin across strata (shuffled within each) for even coverage."""
90
+ buckets: dict[str, list[list[dict]]] = defaultdict(list)
91
+ for s in sessions:
92
+ buckets[_stratum_of(s, prefix)].append(s)
93
+ for b in buckets.values():
94
+ rng.shuffle(b)
95
+ keys = sorted(buckets)
96
+ picked: list[list[dict]] = []
97
+ i = 0
98
+ while len(picked) < sample and any(buckets[k] for k in keys):
99
+ key = keys[i % len(keys)]
100
+ if buckets[key]:
101
+ picked.append(buckets[key].pop())
102
+ i += 1
103
+ return picked
104
+
105
+
106
+ def import_corpus(
107
+ rows: Iterable[dict],
108
+ mapper: Mapper,
109
+ *,
110
+ sample: int | None = None,
111
+ seed: int = 42,
112
+ stratify_prefix: str | None = None,
113
+ ) -> tuple[list[dict], dict]:
114
+ """Map ``rows`` to turn records, optionally down-sampling whole sessions.
115
+
116
+ Returns ``(records, report)``. Deterministic in input order: sessions are
117
+ deduped/disambiguated to unique ids, then sorted by id before any sampling,
118
+ so the same ``seed`` picks the same subset (and emits the same order — hence a
119
+ stable content digest) regardless of how the rows were ordered on input.
120
+ ``report`` carries input/skip/dedupe/session counts and the per-stratum
121
+ breakdown of what was kept.
122
+ """
123
+ sessions: list[list[dict]] = []
124
+ total = 0
125
+ skipped = 0
126
+ for row in rows:
127
+ total += 1
128
+ recs = mapper(row) if isinstance(row, dict) else []
129
+ if recs:
130
+ sessions.append(recs)
131
+ else:
132
+ skipped += 1
133
+
134
+ sessions, deduped = _dedupe_and_disambiguate(sessions)
135
+ # Sort before sampling so selection is input-order-invariant for a fixed seed.
136
+ sessions.sort(key=_session_of)
137
+
138
+ report: dict = {
139
+ "input_rows": total,
140
+ "skipped": skipped,
141
+ "deduped": deduped,
142
+ "sessions_in": len(sessions),
143
+ }
144
+
145
+ if sample is not None and sample < len(sessions):
146
+ rng = random.Random(seed)
147
+ if stratify_prefix:
148
+ sessions = _stratified_sample(sessions, sample, stratify_prefix, rng)
149
+ else:
150
+ rng.shuffle(sessions)
151
+ sessions = sessions[:sample]
152
+
153
+ sessions.sort(key=_session_of)
154
+ records = [r for s in sessions for r in s]
155
+
156
+ strata: dict[str, int] = defaultdict(int)
157
+ axis = stratify_prefix or "theme:"
158
+ for s in sessions:
159
+ strata[_stratum_of(s, axis)] += 1
160
+ report["sessions_out"] = len(sessions)
161
+ report["records_out"] = len(records)
162
+ report["strata"] = dict(sorted(strata.items()))
163
+ return records, report
@@ -0,0 +1,91 @@
1
+ """HealthBench (OpenAI, MIT) → Alignmenter turn records.
2
+
3
+ HealthBench rows are physician-authored health conversations paired with rubric
4
+ criteria but NO reference answer — grading is rubric-based, not exact-match. So
5
+ each row becomes a *prompt session* (the conversation up to the turn the model
6
+ must answer); capture fills the assistant turn, and the physician rubrics ride in
7
+ the final user turn's ``metadata`` for a later rubric evaluator. Deterministic
8
+ safety checks (refusal / escalation / non-diagnostic scope) still score the
9
+ captured response for free.
10
+
11
+ Row shape (``oss_eval.jsonl``)::
12
+
13
+ {"prompt": [{"role": "user", "content": "..."}, ...],
14
+ "rubrics": [{"criterion": "...", "points": 5, "tags": [...]}, ...],
15
+ "example_tags": ["theme:emergency_referrals", ...],
16
+ "prompt_id": "abc123"}
17
+
18
+ The corpus is MIT-licensed but the user fetches it themselves — this adapter only
19
+ maps rows already read from a local file.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import hashlib
25
+
26
+ _ROLES = {"user", "assistant"}
27
+
28
+
29
+ def _stable_id(prompt: list) -> str:
30
+ """Deterministic id for rows without a prompt_id (content-addressed)."""
31
+ raw = "\n".join(f"{m.get('role')}:{m.get('content')}" for m in prompt if isinstance(m, dict))
32
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
33
+
34
+
35
+ def _theme_tags(example_tags) -> list[str]:
36
+ """HealthBench axis tags (``theme:...``, ``physician_agreed_category:...``)
37
+ pass through unchanged; a bare tag gets a ``theme:`` prefix so stratified
38
+ sampling always has an axis to balance on."""
39
+ tags: list[str] = []
40
+ for t in example_tags or []:
41
+ if isinstance(t, str) and t.strip():
42
+ tags.append(t if ":" in t else f"theme:{t}")
43
+ return tags
44
+
45
+
46
+ def healthbench_to_records(row: dict) -> list[dict]:
47
+ """Map one HealthBench row to a list of turn records (empty list = skip)."""
48
+ prompt = row.get("prompt")
49
+ if not isinstance(prompt, list) or not prompt:
50
+ return []
51
+
52
+ msgs: list[tuple[str, str]] = []
53
+ for m in prompt:
54
+ if not isinstance(m, dict):
55
+ return []
56
+ role = m.get("role")
57
+ content = m.get("content")
58
+ if role not in _ROLES or not isinstance(content, str) or not content.strip():
59
+ return []
60
+ msgs.append((role, content))
61
+
62
+ # The model answers the last turn; if it isn't a user turn there's nothing
63
+ # to capture against, so skip the row rather than emit an ungradeable session.
64
+ if msgs[-1][0] != "user":
65
+ return []
66
+
67
+ pid = row.get("prompt_id") or _stable_id(prompt)
68
+ session_id = f"healthbench:{pid}"
69
+ base_tags = ["source:healthbench", "group:healthbench", *_theme_tags(row.get("example_tags"))]
70
+
71
+ records: list[dict] = []
72
+ for i, (role, content) in enumerate(msgs):
73
+ records.append(
74
+ {
75
+ "session_id": session_id,
76
+ "turn_index": i + 1,
77
+ "role": role,
78
+ "text": content,
79
+ "tags": list(base_tags),
80
+ "persona_id": "reference",
81
+ }
82
+ )
83
+
84
+ rubrics = [r for r in (row.get("rubrics") or []) if isinstance(r, dict)]
85
+ records[-1]["metadata"] = {
86
+ "benchmark": "healthbench",
87
+ "prompt_id": pid,
88
+ "example_tags": [t for t in (row.get("example_tags") or []) if isinstance(t, str)],
89
+ "rubrics": rubrics,
90
+ }
91
+ return records