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