alignmenter 0.3.1__tar.gz → 0.3.3__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 (160) hide show
  1. {alignmenter-0.3.1/src/alignmenter.egg-info → alignmenter-0.3.3}/PKG-INFO +1 -1
  2. {alignmenter-0.3.1 → alignmenter-0.3.3}/datasets/README.md +22 -0
  3. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/_version.py +1 -1
  4. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/cli.py +3 -0
  5. alignmenter-0.3.3/src/alignmenter/dataset_cli.py +398 -0
  6. alignmenter-0.3.3/src/alignmenter/importers/__init__.py +163 -0
  7. alignmenter-0.3.3/src/alignmenter/importers/healthbench.py +91 -0
  8. alignmenter-0.3.3/src/alignmenter/schemas/dataset.py +139 -0
  9. {alignmenter-0.3.1 → alignmenter-0.3.3/src/alignmenter.egg-info}/PKG-INFO +1 -1
  10. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter.egg-info/SOURCES.txt +7 -0
  11. alignmenter-0.3.3/tests/test_dataset_import.py +277 -0
  12. alignmenter-0.3.3/tests/test_dataset_management.py +142 -0
  13. alignmenter-0.3.3/tests/test_dataset_sample.py +170 -0
  14. {alignmenter-0.3.1 → alignmenter-0.3.3}/LICENSE +0 -0
  15. {alignmenter-0.3.1 → alignmenter-0.3.3}/MANIFEST.in +0 -0
  16. {alignmenter-0.3.1 → alignmenter-0.3.3}/README.md +0 -0
  17. {alignmenter-0.3.1 → alignmenter-0.3.3}/configs/demo_config.yaml +0 -0
  18. {alignmenter-0.3.1 → alignmenter-0.3.3}/configs/judges/safety_prompt.txt +0 -0
  19. {alignmenter-0.3.1 → alignmenter-0.3.3}/configs/persona/default.yaml +0 -0
  20. {alignmenter-0.3.1 → alignmenter-0.3.3}/configs/run-grounded.yaml +0 -0
  21. {alignmenter-0.3.1 → alignmenter-0.3.3}/configs/run.yaml +0 -0
  22. {alignmenter-0.3.1 → alignmenter-0.3.3}/configs/safety_keywords.yaml +0 -0
  23. {alignmenter-0.3.1 → alignmenter-0.3.3}/datasets/demo_conversations.jsonl +0 -0
  24. {alignmenter-0.3.1 → alignmenter-0.3.3}/datasets/grounded_demo.jsonl +0 -0
  25. {alignmenter-0.3.1 → alignmenter-0.3.3}/datasets/wendys_twitter.jsonl +0 -0
  26. {alignmenter-0.3.1 → alignmenter-0.3.3}/pyproject.toml +0 -0
  27. {alignmenter-0.3.1 → alignmenter-0.3.3}/setup.cfg +0 -0
  28. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/__init__.py +0 -0
  29. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/calibration/__init__.py +0 -0
  30. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/calibration/analyze.py +0 -0
  31. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/calibration/bounds.py +0 -0
  32. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/calibration/diagnose.py +0 -0
  33. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/calibration/generate.py +0 -0
  34. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/calibration/label.py +0 -0
  35. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/calibration/optimize.py +0 -0
  36. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/calibration/sampling.py +0 -0
  37. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/calibration/validate.py +0 -0
  38. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/config.py +0 -0
  39. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/data/configs/demo_config.yaml +0 -0
  40. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/data/configs/judges/safety_prompt.txt +0 -0
  41. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/data/configs/persona/default.yaml +0 -0
  42. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/data/configs/run-grounded.yaml +0 -0
  43. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/data/configs/run.yaml +0 -0
  44. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/data/configs/safety_keywords.yaml +0 -0
  45. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/data/datasets/demo_conversations.jsonl +0 -0
  46. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/data/datasets/grounded_demo.jsonl +0 -0
  47. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/evaluators/__init__.py +0 -0
  48. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/evaluators/custom.py +0 -0
  49. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/evaluators/evidence.py +0 -0
  50. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/evaluators/faithfulness.py +0 -0
  51. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/evaluators/grounding.py +0 -0
  52. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/evaluators/metrics.py +0 -0
  53. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/examples/__init__.py +0 -0
  54. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/examples/resource_task.py +0 -0
  55. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/execution/__init__.py +0 -0
  56. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/execution/archive.py +0 -0
  57. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/execution/artifacts.py +0 -0
  58. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/execution/comparison.py +0 -0
  59. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/execution/evaluation.py +0 -0
  60. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/execution/gates.py +0 -0
  61. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/execution/leases.py +0 -0
  62. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/execution/legacy.py +0 -0
  63. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/execution/recovery.py +0 -0
  64. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/execution/review.py +0 -0
  65. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/execution/suite.py +0 -0
  66. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/judges/__init__.py +0 -0
  67. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/judges/authenticity_judge.py +0 -0
  68. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/judges/prompts.py +0 -0
  69. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/providers/__init__.py +0 -0
  70. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/providers/anthropic.py +0 -0
  71. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/providers/base.py +0 -0
  72. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/providers/callable.py +0 -0
  73. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/providers/classifiers.py +0 -0
  74. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/providers/durable_judge.py +0 -0
  75. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/providers/embeddings.py +0 -0
  76. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/providers/judges.py +0 -0
  77. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/providers/local.py +0 -0
  78. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/providers/openai.py +0 -0
  79. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/release_cli.py +0 -0
  80. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/reporting/__init__.py +0 -0
  81. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/reporting/durable.py +0 -0
  82. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/reporting/github_comment.py +0 -0
  83. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/reporting/html.py +0 -0
  84. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/reporting/json_out.py +0 -0
  85. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/run_config.py +0 -0
  86. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/runner.py +0 -0
  87. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/schemas/__init__.py +0 -0
  88. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/schemas/evaluation.py +0 -0
  89. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/schemas/execution.py +0 -0
  90. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/schemas/gates.py +0 -0
  91. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/schemas/metrics.py +0 -0
  92. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/schemas/review.py +0 -0
  93. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/schemas/scoring.py +0 -0
  94. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/schemas/suite.py +0 -0
  95. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/scorers/__init__.py +0 -0
  96. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/scorers/authenticity.py +0 -0
  97. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/scorers/faithfulness.py +0 -0
  98. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/scorers/grounding.py +0 -0
  99. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/scorers/safety.py +0 -0
  100. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/scorers/stability.py +0 -0
  101. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/scripts/__init__.py +0 -0
  102. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/scripts/bootstrap_dataset.py +0 -0
  103. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/scripts/calibrate_persona.py +0 -0
  104. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/scripts/run_openai_demo.py +0 -0
  105. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/scripts/sanitize_dataset.py +0 -0
  106. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/sdk.py +0 -0
  107. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/storage/__init__.py +0 -0
  108. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/storage/evaluations.py +0 -0
  109. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/storage/reviews.py +0 -0
  110. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/storage/runs.py +0 -0
  111. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/utils/__init__.py +0 -0
  112. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/utils/io.py +0 -0
  113. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/utils/optional.py +0 -0
  114. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/utils/tokens.py +0 -0
  115. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter/utils/yaml.py +0 -0
  116. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter.egg-info/dependency_links.txt +0 -0
  117. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter.egg-info/entry_points.txt +0 -0
  118. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter.egg-info/requires.txt +0 -0
  119. {alignmenter-0.3.1 → alignmenter-0.3.3}/src/alignmenter.egg-info/top_level.txt +0 -0
  120. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/__init__.py +0 -0
  121. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/conftest.py +0 -0
  122. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/data/durable_evaluation_judge.py +0 -0
  123. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/data/durable_evaluation_worker.py +0 -0
  124. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/data/durable_recovery_target.py +0 -0
  125. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/data/durable_recovery_worker.py +0 -0
  126. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/data/durable_run_worker.py +0 -0
  127. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/data/mini_cli_dataset.jsonl +0 -0
  128. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/test_authenticity_judge.py +0 -0
  129. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/test_builtin_evaluations.py +0 -0
  130. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/test_calibrate_persona.py +0 -0
  131. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/test_capture_recovery.py +0 -0
  132. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/test_cli_errors.py +0 -0
  133. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/test_cli_grounded.py +0 -0
  134. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/test_cli_helpers.py +0 -0
  135. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/test_cli_import.py +0 -0
  136. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/test_cli_init.py +0 -0
  137. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/test_cli_run_config.py +0 -0
  138. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/test_config.py +0 -0
  139. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/test_durable_evaluations.py +0 -0
  140. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/test_durable_execution.py +0 -0
  141. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/test_faithfulness.py +0 -0
  142. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/test_github_comment.py +0 -0
  143. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/test_grounding.py +0 -0
  144. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/test_html_report.py +0 -0
  145. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/test_judge_providers.py +0 -0
  146. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/test_offline_safety.py +0 -0
  147. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/test_persona_gpt.py +0 -0
  148. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/test_provider_local.py +0 -0
  149. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/test_provider_openai.py +0 -0
  150. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/test_providers.py +0 -0
  151. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/test_release_workflow.py +0 -0
  152. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/test_review_workflow.py +0 -0
  153. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/test_run_config_grounded.py +0 -0
  154. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/test_run_config_loader.py +0 -0
  155. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/test_run_openai_demo.py +0 -0
  156. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/test_runner.py +0 -0
  157. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/test_sampling.py +0 -0
  158. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/test_scorers.py +0 -0
  159. {alignmenter-0.3.1 → alignmenter-0.3.3}/tests/test_smoke.py +0 -0
  160. {alignmenter-0.3.1 → alignmenter-0.3.3}/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.1
3
+ Version: 0.3.3
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
@@ -11,6 +11,28 @@ Alignmenter datasets serve two primary purposes:
11
11
 
12
12
  All datasets in this repository follow strict data hygiene practices to protect user privacy and enable reproducible research.
13
13
 
14
+ ## Dataset management commands
15
+
16
+ The `alignmenter dataset` sub-app manages datasets as content-addressed, versioned
17
+ artifacts:
18
+
19
+ ```bash
20
+ alignmenter dataset stats data.jsonl [--json] # coverage: counts, roles, tag/persona histograms
21
+ alignmenter dataset validate data.jsonl [--strict] # canonical schema check (exit 1 on errors)
22
+ alignmenter dataset dedupe data.jsonl --out out.jsonl # drop content-identical records
23
+ alignmenter dataset merge a.jsonl b.jsonl --out m.jsonl [--dedupe] [--namespace-sessions]
24
+ alignmenter dataset split data.jsonl --out dir/ --holdout 0.2 --by split_group # group-aware train/holdout
25
+ alignmenter dataset manifest data.jsonl --out manifest.json # content-addressed manifest + provenance
26
+ alignmenter dataset manifest data.jsonl --verify manifest.json # confirm the data still matches (exit 2 on drift)
27
+ ```
28
+
29
+ `validate` checks the row schema below (lenient by default, `--strict` for the fuller
30
+ contract); `lint` additionally checks turn sequencing, scenario-tag coverage, and
31
+ persona files. `split` keeps a
32
+ case and its counterfactual variants on the same side of the boundary via the
33
+ grouping unit (`split_group` / `group:` tag / session / persona). `manifest`'s digest
34
+ is order-independent (reordering rows does not change the dataset's identity).
35
+
14
36
  ## Dataset Format
15
37
 
16
38
  ### Conversation JSONL Schema
@@ -1,3 +1,3 @@
1
1
  """Single source for distribution and runtime version metadata."""
2
2
 
3
- __version__ = "0.3.1"
3
+ __version__ = "0.3.3"
@@ -21,6 +21,7 @@ import typer
21
21
  import yaml
22
22
 
23
23
  from alignmenter.config import get_settings
24
+ from alignmenter.dataset_cli import register_dataset_commands
24
25
  from alignmenter.evaluators.custom import evaluator_registry, load_evaluators
25
26
  from alignmenter.execution.evaluation import evaluate_saved, evaluation_summary
26
27
  from alignmenter.execution.recovery import resume_capture
@@ -58,6 +59,8 @@ app.add_typer(dataset_app, name="dataset")
58
59
  app.add_typer(import_app, name="import")
59
60
  app.add_typer(calibrate_app, name="calibrate")
60
61
 
62
+ register_dataset_commands(dataset_app)
63
+
61
64
 
62
65
  def _show_version(value):
63
66
  if value:
@@ -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