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.
- {alignmenter-0.3.2/src/alignmenter.egg-info → alignmenter-0.3.4}/PKG-INFO +1 -1
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/_version.py +1 -1
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/cli.py +2 -0
- alignmenter-0.3.4/src/alignmenter/dataset_cli.py +398 -0
- alignmenter-0.3.4/src/alignmenter/importers/__init__.py +163 -0
- alignmenter-0.3.4/src/alignmenter/importers/healthbench.py +91 -0
- alignmenter-0.3.4/src/alignmenter/rubric_grade.py +408 -0
- alignmenter-0.3.4/src/alignmenter/rubric_grade_cli.py +105 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4/src/alignmenter.egg-info}/PKG-INFO +1 -1
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter.egg-info/SOURCES.txt +7 -0
- alignmenter-0.3.4/tests/test_dataset_import.py +277 -0
- alignmenter-0.3.4/tests/test_dataset_sample.py +170 -0
- alignmenter-0.3.4/tests/test_rubric_grade.py +311 -0
- alignmenter-0.3.2/src/alignmenter/dataset_cli.py +0 -205
- {alignmenter-0.3.2 → alignmenter-0.3.4}/LICENSE +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/MANIFEST.in +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/README.md +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/configs/demo_config.yaml +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/configs/judges/safety_prompt.txt +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/configs/persona/default.yaml +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/configs/run-grounded.yaml +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/configs/run.yaml +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/configs/safety_keywords.yaml +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/datasets/README.md +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/datasets/demo_conversations.jsonl +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/datasets/grounded_demo.jsonl +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/datasets/wendys_twitter.jsonl +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/pyproject.toml +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/setup.cfg +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/__init__.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/calibration/__init__.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/calibration/analyze.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/calibration/bounds.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/calibration/diagnose.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/calibration/generate.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/calibration/label.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/calibration/optimize.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/calibration/sampling.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/calibration/validate.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/config.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/data/configs/demo_config.yaml +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/data/configs/judges/safety_prompt.txt +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/data/configs/persona/default.yaml +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/data/configs/run-grounded.yaml +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/data/configs/run.yaml +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/data/configs/safety_keywords.yaml +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/data/datasets/demo_conversations.jsonl +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/data/datasets/grounded_demo.jsonl +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/evaluators/__init__.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/evaluators/custom.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/evaluators/evidence.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/evaluators/faithfulness.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/evaluators/grounding.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/evaluators/metrics.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/examples/__init__.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/examples/resource_task.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/execution/__init__.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/execution/archive.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/execution/artifacts.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/execution/comparison.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/execution/evaluation.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/execution/gates.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/execution/leases.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/execution/legacy.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/execution/recovery.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/execution/review.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/execution/suite.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/judges/__init__.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/judges/authenticity_judge.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/judges/prompts.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/providers/__init__.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/providers/anthropic.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/providers/base.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/providers/callable.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/providers/classifiers.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/providers/durable_judge.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/providers/embeddings.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/providers/judges.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/providers/local.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/providers/openai.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/release_cli.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/reporting/__init__.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/reporting/durable.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/reporting/github_comment.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/reporting/html.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/reporting/json_out.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/run_config.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/runner.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/schemas/__init__.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/schemas/dataset.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/schemas/evaluation.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/schemas/execution.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/schemas/gates.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/schemas/metrics.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/schemas/review.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/schemas/scoring.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/schemas/suite.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/scorers/__init__.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/scorers/authenticity.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/scorers/faithfulness.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/scorers/grounding.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/scorers/safety.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/scorers/stability.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/scripts/__init__.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/scripts/bootstrap_dataset.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/scripts/calibrate_persona.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/scripts/run_openai_demo.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/scripts/sanitize_dataset.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/sdk.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/storage/__init__.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/storage/evaluations.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/storage/reviews.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/storage/runs.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/utils/__init__.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/utils/io.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/utils/optional.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/utils/tokens.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter/utils/yaml.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter.egg-info/dependency_links.txt +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter.egg-info/entry_points.txt +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter.egg-info/requires.txt +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/src/alignmenter.egg-info/top_level.txt +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/__init__.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/conftest.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/data/durable_evaluation_judge.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/data/durable_evaluation_worker.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/data/durable_recovery_target.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/data/durable_recovery_worker.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/data/durable_run_worker.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/data/mini_cli_dataset.jsonl +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_authenticity_judge.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_builtin_evaluations.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_calibrate_persona.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_capture_recovery.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_cli_errors.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_cli_grounded.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_cli_helpers.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_cli_import.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_cli_init.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_cli_run_config.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_config.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_dataset_management.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_durable_evaluations.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_durable_execution.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_faithfulness.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_github_comment.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_grounding.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_html_report.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_judge_providers.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_offline_safety.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_persona_gpt.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_provider_local.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_provider_openai.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_providers.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_release_workflow.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_review_workflow.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_run_config_grounded.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_run_config_loader.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_run_openai_demo.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_runner.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_sampling.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_scorers.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_smoke.py +0 -0
- {alignmenter-0.3.2 → alignmenter-0.3.4}/tests/test_suite_archive.py +0 -0
|
@@ -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
|