alignmenter 0.3.1__tar.gz → 0.3.2__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 (156) hide show
  1. {alignmenter-0.3.1/src/alignmenter.egg-info → alignmenter-0.3.2}/PKG-INFO +1 -1
  2. {alignmenter-0.3.1 → alignmenter-0.3.2}/datasets/README.md +22 -0
  3. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/_version.py +1 -1
  4. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/cli.py +3 -0
  5. alignmenter-0.3.2/src/alignmenter/dataset_cli.py +205 -0
  6. alignmenter-0.3.2/src/alignmenter/schemas/dataset.py +139 -0
  7. {alignmenter-0.3.1 → alignmenter-0.3.2/src/alignmenter.egg-info}/PKG-INFO +1 -1
  8. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter.egg-info/SOURCES.txt +3 -0
  9. alignmenter-0.3.2/tests/test_dataset_management.py +142 -0
  10. {alignmenter-0.3.1 → alignmenter-0.3.2}/LICENSE +0 -0
  11. {alignmenter-0.3.1 → alignmenter-0.3.2}/MANIFEST.in +0 -0
  12. {alignmenter-0.3.1 → alignmenter-0.3.2}/README.md +0 -0
  13. {alignmenter-0.3.1 → alignmenter-0.3.2}/configs/demo_config.yaml +0 -0
  14. {alignmenter-0.3.1 → alignmenter-0.3.2}/configs/judges/safety_prompt.txt +0 -0
  15. {alignmenter-0.3.1 → alignmenter-0.3.2}/configs/persona/default.yaml +0 -0
  16. {alignmenter-0.3.1 → alignmenter-0.3.2}/configs/run-grounded.yaml +0 -0
  17. {alignmenter-0.3.1 → alignmenter-0.3.2}/configs/run.yaml +0 -0
  18. {alignmenter-0.3.1 → alignmenter-0.3.2}/configs/safety_keywords.yaml +0 -0
  19. {alignmenter-0.3.1 → alignmenter-0.3.2}/datasets/demo_conversations.jsonl +0 -0
  20. {alignmenter-0.3.1 → alignmenter-0.3.2}/datasets/grounded_demo.jsonl +0 -0
  21. {alignmenter-0.3.1 → alignmenter-0.3.2}/datasets/wendys_twitter.jsonl +0 -0
  22. {alignmenter-0.3.1 → alignmenter-0.3.2}/pyproject.toml +0 -0
  23. {alignmenter-0.3.1 → alignmenter-0.3.2}/setup.cfg +0 -0
  24. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/__init__.py +0 -0
  25. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/calibration/__init__.py +0 -0
  26. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/calibration/analyze.py +0 -0
  27. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/calibration/bounds.py +0 -0
  28. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/calibration/diagnose.py +0 -0
  29. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/calibration/generate.py +0 -0
  30. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/calibration/label.py +0 -0
  31. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/calibration/optimize.py +0 -0
  32. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/calibration/sampling.py +0 -0
  33. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/calibration/validate.py +0 -0
  34. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/config.py +0 -0
  35. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/data/configs/demo_config.yaml +0 -0
  36. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/data/configs/judges/safety_prompt.txt +0 -0
  37. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/data/configs/persona/default.yaml +0 -0
  38. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/data/configs/run-grounded.yaml +0 -0
  39. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/data/configs/run.yaml +0 -0
  40. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/data/configs/safety_keywords.yaml +0 -0
  41. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/data/datasets/demo_conversations.jsonl +0 -0
  42. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/data/datasets/grounded_demo.jsonl +0 -0
  43. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/evaluators/__init__.py +0 -0
  44. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/evaluators/custom.py +0 -0
  45. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/evaluators/evidence.py +0 -0
  46. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/evaluators/faithfulness.py +0 -0
  47. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/evaluators/grounding.py +0 -0
  48. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/evaluators/metrics.py +0 -0
  49. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/examples/__init__.py +0 -0
  50. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/examples/resource_task.py +0 -0
  51. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/execution/__init__.py +0 -0
  52. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/execution/archive.py +0 -0
  53. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/execution/artifacts.py +0 -0
  54. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/execution/comparison.py +0 -0
  55. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/execution/evaluation.py +0 -0
  56. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/execution/gates.py +0 -0
  57. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/execution/leases.py +0 -0
  58. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/execution/legacy.py +0 -0
  59. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/execution/recovery.py +0 -0
  60. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/execution/review.py +0 -0
  61. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/execution/suite.py +0 -0
  62. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/judges/__init__.py +0 -0
  63. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/judges/authenticity_judge.py +0 -0
  64. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/judges/prompts.py +0 -0
  65. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/providers/__init__.py +0 -0
  66. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/providers/anthropic.py +0 -0
  67. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/providers/base.py +0 -0
  68. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/providers/callable.py +0 -0
  69. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/providers/classifiers.py +0 -0
  70. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/providers/durable_judge.py +0 -0
  71. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/providers/embeddings.py +0 -0
  72. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/providers/judges.py +0 -0
  73. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/providers/local.py +0 -0
  74. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/providers/openai.py +0 -0
  75. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/release_cli.py +0 -0
  76. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/reporting/__init__.py +0 -0
  77. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/reporting/durable.py +0 -0
  78. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/reporting/github_comment.py +0 -0
  79. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/reporting/html.py +0 -0
  80. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/reporting/json_out.py +0 -0
  81. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/run_config.py +0 -0
  82. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/runner.py +0 -0
  83. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/schemas/__init__.py +0 -0
  84. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/schemas/evaluation.py +0 -0
  85. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/schemas/execution.py +0 -0
  86. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/schemas/gates.py +0 -0
  87. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/schemas/metrics.py +0 -0
  88. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/schemas/review.py +0 -0
  89. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/schemas/scoring.py +0 -0
  90. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/schemas/suite.py +0 -0
  91. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/scorers/__init__.py +0 -0
  92. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/scorers/authenticity.py +0 -0
  93. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/scorers/faithfulness.py +0 -0
  94. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/scorers/grounding.py +0 -0
  95. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/scorers/safety.py +0 -0
  96. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/scorers/stability.py +0 -0
  97. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/scripts/__init__.py +0 -0
  98. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/scripts/bootstrap_dataset.py +0 -0
  99. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/scripts/calibrate_persona.py +0 -0
  100. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/scripts/run_openai_demo.py +0 -0
  101. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/scripts/sanitize_dataset.py +0 -0
  102. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/sdk.py +0 -0
  103. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/storage/__init__.py +0 -0
  104. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/storage/evaluations.py +0 -0
  105. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/storage/reviews.py +0 -0
  106. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/storage/runs.py +0 -0
  107. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/utils/__init__.py +0 -0
  108. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/utils/io.py +0 -0
  109. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/utils/optional.py +0 -0
  110. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/utils/tokens.py +0 -0
  111. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter/utils/yaml.py +0 -0
  112. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter.egg-info/dependency_links.txt +0 -0
  113. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter.egg-info/entry_points.txt +0 -0
  114. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter.egg-info/requires.txt +0 -0
  115. {alignmenter-0.3.1 → alignmenter-0.3.2}/src/alignmenter.egg-info/top_level.txt +0 -0
  116. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/__init__.py +0 -0
  117. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/conftest.py +0 -0
  118. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/data/durable_evaluation_judge.py +0 -0
  119. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/data/durable_evaluation_worker.py +0 -0
  120. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/data/durable_recovery_target.py +0 -0
  121. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/data/durable_recovery_worker.py +0 -0
  122. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/data/durable_run_worker.py +0 -0
  123. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/data/mini_cli_dataset.jsonl +0 -0
  124. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/test_authenticity_judge.py +0 -0
  125. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/test_builtin_evaluations.py +0 -0
  126. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/test_calibrate_persona.py +0 -0
  127. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/test_capture_recovery.py +0 -0
  128. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/test_cli_errors.py +0 -0
  129. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/test_cli_grounded.py +0 -0
  130. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/test_cli_helpers.py +0 -0
  131. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/test_cli_import.py +0 -0
  132. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/test_cli_init.py +0 -0
  133. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/test_cli_run_config.py +0 -0
  134. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/test_config.py +0 -0
  135. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/test_durable_evaluations.py +0 -0
  136. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/test_durable_execution.py +0 -0
  137. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/test_faithfulness.py +0 -0
  138. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/test_github_comment.py +0 -0
  139. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/test_grounding.py +0 -0
  140. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/test_html_report.py +0 -0
  141. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/test_judge_providers.py +0 -0
  142. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/test_offline_safety.py +0 -0
  143. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/test_persona_gpt.py +0 -0
  144. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/test_provider_local.py +0 -0
  145. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/test_provider_openai.py +0 -0
  146. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/test_providers.py +0 -0
  147. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/test_release_workflow.py +0 -0
  148. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/test_review_workflow.py +0 -0
  149. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/test_run_config_grounded.py +0 -0
  150. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/test_run_config_loader.py +0 -0
  151. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/test_run_openai_demo.py +0 -0
  152. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/test_runner.py +0 -0
  153. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/test_sampling.py +0 -0
  154. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/test_scorers.py +0 -0
  155. {alignmenter-0.3.1 → alignmenter-0.3.2}/tests/test_smoke.py +0 -0
  156. {alignmenter-0.3.1 → alignmenter-0.3.2}/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.2
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.2"
@@ -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,205 @@
1
+ """Dataset-management commands: stats, validate, dedupe, merge, split, manifest.
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 for r in records
57
+ if isinstance(r, dict) and isinstance(r.get("metadata"), dict) and "context" in r["metadata"]
58
+ )
59
+ if as_json:
60
+ typer.echo(json.dumps({
61
+ "records": manifest.record_count, "sessions": manifest.session_count,
62
+ "roles": {k: v for k, v in roles.items()}, "with_context": with_context,
63
+ "tags": manifest.tags, "personas": manifest.personas,
64
+ "content_digest": manifest.content_digest,
65
+ }, indent=2))
66
+ return
67
+ typer.echo(f"records {manifest.record_count} · sessions {manifest.session_count} · turns-with-context {with_context}")
68
+ typer.echo("roles: " + ", ".join(f"{k}={v}" for k, v in sorted(roles.items(), key=lambda kv: str(kv[0]))))
69
+ if manifest.tags:
70
+ typer.echo("tags: " + ", ".join(f"{k}={v}" for k, v in sorted(manifest.tags.items())))
71
+ if manifest.personas:
72
+ typer.echo("personas: " + ", ".join(f"{k}={v}" for k, v in sorted(manifest.personas.items())))
73
+ typer.echo(f"content_digest: {manifest.content_digest[:12]}…")
74
+
75
+ @dataset_app.command("validate")
76
+ def validate(
77
+ path: Path = typer.Argument(..., exists=True, dir_okay=False),
78
+ strict: bool = typer.Option(False, "--strict", help="Require turn_index+tags+persona_id and an assistant turn per session."),
79
+ ):
80
+ """Schema validator — lenient by default, --strict adds turn_index+tags+persona_id + an assistant turn per session.
81
+
82
+ Complements `dataset lint`, which additionally checks turn-index contiguity,
83
+ scenario-tag coverage, and persona-file existence.
84
+ """
85
+ records = read_jsonl(path)
86
+ errors = validate_records(records, strict=strict)
87
+ for error in errors:
88
+ typer.echo(error, err=True)
89
+ typer.echo(f"{len(records)} records, {len(errors)} error(s)")
90
+ raise typer.Exit(1 if errors else 0)
91
+
92
+ @dataset_app.command("dedupe")
93
+ def dedupe(
94
+ path: Path = typer.Argument(..., exists=True, dir_okay=False),
95
+ out: Path | None = typer.Option(None, "--out", help="Output path (default: <stem>.dedup.jsonl)."),
96
+ in_place: bool = typer.Option(False, "--in-place"),
97
+ ):
98
+ """Drop content-identical duplicate records (order preserved)."""
99
+ records = read_jsonl(path)
100
+ seen: set[str] = set()
101
+ kept = []
102
+ for record in records:
103
+ digest = content_digest(record)
104
+ if digest in seen:
105
+ continue
106
+ seen.add(digest)
107
+ kept.append(record)
108
+ destination = path if in_place else (out or path.with_name(f"{path.stem}.dedup.jsonl"))
109
+ write_jsonl(destination, kept)
110
+ typer.echo(f"removed {len(records) - len(kept)} duplicate(s); wrote {len(kept)} -> {destination}")
111
+
112
+ @dataset_app.command("merge")
113
+ def merge(
114
+ paths: list[Path] = typer.Argument(..., exists=True, dir_okay=False),
115
+ out: Path = typer.Option(..., "--out"),
116
+ dedupe: bool = typer.Option(False, "--dedupe", help="Drop content-identical records after merging."),
117
+ namespace_sessions: bool = typer.Option(False, "--namespace-sessions", help="Prefix session_id with the source file stem to avoid collisions."),
118
+ ):
119
+ """Concatenate datasets into one (optionally dedupe / namespace session ids)."""
120
+ merged: list[dict] = []
121
+ for source in paths:
122
+ records = read_jsonl(source)
123
+ if namespace_sessions:
124
+ for record in records:
125
+ if isinstance(record, dict) and record.get("session_id"):
126
+ record["session_id"] = f"{source.stem}:{record['session_id']}"
127
+ merged.extend(records)
128
+ if dedupe:
129
+ seen: set[str] = set()
130
+ deduped = []
131
+ for record in merged:
132
+ digest = content_digest(record)
133
+ if digest in seen:
134
+ continue
135
+ seen.add(digest)
136
+ deduped.append(record)
137
+ merged = deduped
138
+ write_jsonl(out, merged)
139
+ typer.echo(f"merged {len(paths)} file(s) -> {len(merged)} records -> {out}")
140
+
141
+ @dataset_app.command("split")
142
+ def split(
143
+ path: Path = typer.Argument(..., exists=True, dir_okay=False),
144
+ out: Path = typer.Option(..., "--out", help="Output directory for train.jsonl + holdout.jsonl."),
145
+ holdout: float = typer.Option(0.2, "--holdout", min=0.0, max=1.0, help="Target holdout fraction of records."),
146
+ by: str = typer.Option("split_group", "--by", help="Grouping unit kept together: split_group | group | session | persona."),
147
+ seed: int = typer.Option(42, "--seed"),
148
+ ):
149
+ """Group-aware train/holdout split — a case and its variants never straddle the boundary."""
150
+ if by not in {"split_group", "group", "session", "persona"}:
151
+ raise typer.BadParameter("--by must be split_group, group, session, or persona")
152
+ records = read_jsonl(path)
153
+ groups: dict[str, list[dict]] = defaultdict(list)
154
+ for record in records:
155
+ groups[_group_key(record, by)].append(record)
156
+ names = sorted(groups)
157
+ random.Random(seed).shuffle(names)
158
+ target = holdout * len(records)
159
+ held: set[str] = set()
160
+ accumulated = 0
161
+ for name in names:
162
+ if accumulated >= target:
163
+ break
164
+ held.add(name)
165
+ accumulated += len(groups[name])
166
+ train = [r for r in records if _group_key(r, by) not in held]
167
+ holdout_records = [r for r in records if _group_key(r, by) in held]
168
+ write_jsonl(Path(out) / "train.jsonl", train)
169
+ write_jsonl(Path(out) / "holdout.jsonl", holdout_records)
170
+ typer.echo(
171
+ f"train {len(train)} · holdout {len(holdout_records)} "
172
+ f"({len(held)}/{len(names)} '{by}' groups) -> {out}"
173
+ )
174
+
175
+ @dataset_app.command("manifest")
176
+ def manifest(
177
+ path: Path = typer.Argument(..., exists=True, dir_okay=False),
178
+ out: Path | None = typer.Option(None, "--out", help="Write the manifest JSON here (default: stdout)."),
179
+ identifier: str | None = typer.Option(None, "--id", help="Dataset id (default: file stem)."),
180
+ revision: str = typer.Option("v1", "--revision"),
181
+ verify: Path | None = typer.Option(None, "--verify", help="Verify the data still matches this manifest's content_digest."),
182
+ ):
183
+ """Build (or --verify) a content-addressed dataset manifest with provenance."""
184
+ records = read_jsonl(path)
185
+ if verify is not None:
186
+ try:
187
+ expected = json.loads(Path(verify).read_text()).get("content_digest")
188
+ except (json.JSONDecodeError, OSError) as exc:
189
+ raise typer.BadParameter(f"Could not read manifest {verify}: {exc}") from exc
190
+ actual = dataset_digest(records)
191
+ match = expected == actual
192
+ typer.echo(f"{'OK' if match else 'MISMATCH'}: manifest {str(expected)[:12]} vs data {actual[:12]}")
193
+ raise typer.Exit(0 if match else 2)
194
+ built = build_manifest(
195
+ records,
196
+ id=identifier or path.stem,
197
+ revision=revision,
198
+ provenance=(ProvenanceEntry(kind="authored", ref=str(path), count=len(records), digest=dataset_digest(records)),),
199
+ )
200
+ payload = built.model_dump(mode="json")
201
+ if out is not None:
202
+ write_json(out, payload)
203
+ typer.echo(f"wrote manifest -> {out}")
204
+ else:
205
+ typer.echo(json.dumps(payload, indent=2))
@@ -0,0 +1,139 @@
1
+ """Dataset manifest + canonical record validation for dataset management.
2
+
3
+ Datasets are flat JSONL of turn records (see datasets/README.md). `validate_records`
4
+ is a schema validator: lenient by default (matching what capture requires —
5
+ session_id + role + text, unique turn_index per session), `strict=True` adds
6
+ turn_index + tags + persona_id and an assistant turn per session. It complements
7
+ `dataset lint`, which additionally checks turn-index contiguity, scenario-tag
8
+ coverage, and persona-file existence. `DatasetManifest` gives a dataset a
9
+ content-addressed identity + provenance so a curated set can be versioned and its
10
+ lineage tracked.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections import Counter, defaultdict
16
+
17
+ from pydantic import AwareDatetime, Field
18
+
19
+ from alignmenter.schemas.evaluation import NonBlank
20
+ from alignmenter.schemas.execution import (
21
+ Digest,
22
+ NonNegativeInt,
23
+ VersionedRecord,
24
+ content_digest,
25
+ utc_now,
26
+ )
27
+
28
+ ROLES = {"user", "assistant"}
29
+
30
+
31
+ def dataset_digest(records) -> str:
32
+ """Order-independent content hash of a dataset (same records, any order → same digest).
33
+
34
+ content_digest sorts dict keys but preserves list order, so hashing the record
35
+ list directly would change when rows are merely reordered. Hashing the sorted
36
+ per-record digests gives a stable content identity for versioning/verification.
37
+
38
+ Note: this is distinct from RunManifest.dataset_digest (runner.py), which is the
39
+ order-SENSITIVE content_digest(records) used for run reproducibility — don't
40
+ compare the two.
41
+ """
42
+ return content_digest(sorted(content_digest(r) for r in records))
43
+
44
+
45
+ class ProvenanceEntry(VersionedRecord):
46
+ kind: NonBlank # authored | bootstrap | import | merge | dedupe | split | promote
47
+ ref: str = "" # source path / url / description
48
+ count: NonNegativeInt = 0
49
+ digest: str | None = None
50
+
51
+
52
+ class DatasetManifest(VersionedRecord):
53
+ id: NonBlank
54
+ revision: NonBlank
55
+ content_digest: Digest
56
+ record_count: NonNegativeInt
57
+ session_count: NonNegativeInt
58
+ created_at: AwareDatetime = Field(default_factory=utc_now)
59
+ tags: dict[str, int] = Field(default_factory=dict)
60
+ personas: dict[str, int] = Field(default_factory=dict)
61
+ provenance: tuple[ProvenanceEntry, ...] = ()
62
+
63
+
64
+ def build_manifest(records, *, id: str, revision: str, provenance=()) -> DatasetManifest:
65
+ """Content-address a record list into a DatasetManifest (order-independent digest)."""
66
+ dicts = [r for r in records if isinstance(r, dict)]
67
+ sessions = {r.get("session_id") for r in dicts if r.get("session_id")}
68
+ tags = Counter(t for r in dicts for t in (r.get("tags") or []) if isinstance(t, str))
69
+ personas = Counter(r["persona_id"] for r in dicts if isinstance(r.get("persona_id"), str))
70
+ return DatasetManifest(
71
+ id=id,
72
+ revision=revision,
73
+ content_digest=dataset_digest(records),
74
+ record_count=len(records),
75
+ session_count=len(sessions),
76
+ tags=dict(tags),
77
+ personas=dict(personas),
78
+ provenance=tuple(provenance),
79
+ )
80
+
81
+
82
+ def validate_records(records, *, strict: bool = False) -> list[str]:
83
+ """Return a list of human-readable schema errors (empty == valid).
84
+
85
+ Default rules match ingest (session_id + role + text; turn_index optional but,
86
+ when present, a non-negative int unique within its session). ``strict`` adds the
87
+ lint contract: turn_index + tags + persona_id required, and every session must
88
+ have an assistant turn.
89
+ """
90
+ errors: list[str] = []
91
+ seen_turns: dict[str, set[int]] = defaultdict(set)
92
+ session_roles: dict[str, set] = defaultdict(set)
93
+
94
+ for i, r in enumerate(records):
95
+ loc = f"record {i}"
96
+ if not isinstance(r, dict):
97
+ errors.append(f"{loc}: not a JSON object")
98
+ continue
99
+ sid = r.get("session_id")
100
+ if not isinstance(sid, str) or not sid.strip():
101
+ errors.append(f"{loc}: missing or blank session_id")
102
+ role = r.get("role")
103
+ if role not in ROLES:
104
+ errors.append(f"{loc}: role must be one of {sorted(ROLES)} (got {role!r})")
105
+ if not isinstance(r.get("text"), str):
106
+ errors.append(f"{loc}: text must be a string")
107
+
108
+ ti = r.get("turn_index")
109
+ if ti is not None:
110
+ if isinstance(ti, bool) or not isinstance(ti, int) or ti < 0:
111
+ errors.append(f"{loc}: turn_index must be a non-negative int")
112
+ elif isinstance(sid, str):
113
+ if ti in seen_turns[sid]:
114
+ errors.append(f"{loc}: duplicate turn_index {ti} in session {sid!r}")
115
+ seen_turns[sid].add(ti)
116
+
117
+ tags = r.get("tags")
118
+ if tags is not None and not (isinstance(tags, list) and all(isinstance(t, str) for t in tags)):
119
+ errors.append(f"{loc}: tags must be a list of strings")
120
+ md = r.get("metadata")
121
+ if md is not None and not isinstance(md, dict):
122
+ errors.append(f"{loc}: metadata must be an object")
123
+
124
+ if isinstance(sid, str) and role in ROLES:
125
+ session_roles[sid].add(role)
126
+
127
+ if strict:
128
+ if ti is None:
129
+ errors.append(f"{loc}: turn_index is required in --strict mode")
130
+ if not isinstance(tags, list):
131
+ errors.append(f"{loc}: tags is required in --strict mode")
132
+ if not isinstance(r.get("persona_id"), str):
133
+ errors.append(f"{loc}: persona_id is required in --strict mode")
134
+
135
+ if strict:
136
+ for sid, roles in session_roles.items():
137
+ if "assistant" not in roles:
138
+ errors.append(f"session {sid!r}: has no assistant turn (--strict)")
139
+ return errors
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alignmenter
3
- Version: 0.3.1
3
+ Version: 0.3.2
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
@@ -16,6 +16,7 @@ src/alignmenter/__init__.py
16
16
  src/alignmenter/_version.py
17
17
  src/alignmenter/cli.py
18
18
  src/alignmenter/config.py
19
+ src/alignmenter/dataset_cli.py
19
20
  src/alignmenter/release_cli.py
20
21
  src/alignmenter/run_config.py
21
22
  src/alignmenter/runner.py
@@ -81,6 +82,7 @@ src/alignmenter/reporting/github_comment.py
81
82
  src/alignmenter/reporting/html.py
82
83
  src/alignmenter/reporting/json_out.py
83
84
  src/alignmenter/schemas/__init__.py
85
+ src/alignmenter/schemas/dataset.py
84
86
  src/alignmenter/schemas/evaluation.py
85
87
  src/alignmenter/schemas/execution.py
86
88
  src/alignmenter/schemas/gates.py
@@ -121,6 +123,7 @@ tests/test_cli_import.py
121
123
  tests/test_cli_init.py
122
124
  tests/test_cli_run_config.py
123
125
  tests/test_config.py
126
+ tests/test_dataset_management.py
124
127
  tests/test_durable_evaluations.py
125
128
  tests/test_durable_execution.py
126
129
  tests/test_faithfulness.py
@@ -0,0 +1,142 @@
1
+ """Dataset-management: canonical validation, manifest, and the dataset CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from typer.testing import CliRunner
9
+
10
+ from alignmenter.cli import app
11
+ from alignmenter.schemas.dataset import build_manifest, validate_records
12
+ from alignmenter.utils.io import read_jsonl, write_jsonl
13
+
14
+ runner = CliRunner()
15
+
16
+
17
+ def _records():
18
+ return [
19
+ {"session_id": "a", "turn_index": 0, "role": "user", "text": "hi", "tags": ["family:g"], "persona_id": "aver"},
20
+ {"session_id": "a", "turn_index": 1, "role": "assistant", "text": "hello", "tags": ["family:g"], "persona_id": "aver"},
21
+ {"session_id": "b", "turn_index": 0, "role": "user", "text": "q", "tags": ["group:x"], "persona_id": "aver"},
22
+ {"session_id": "b", "turn_index": 1, "role": "assistant", "text": "a", "tags": ["group:x"], "persona_id": "aver"},
23
+ ]
24
+
25
+
26
+ def _write(tmp_path, records, name="data.jsonl") -> Path:
27
+ p = tmp_path / name
28
+ write_jsonl(p, records)
29
+ return p
30
+
31
+
32
+ # ---- schema / manifest ----
33
+
34
+ def test_validate_accepts_good_records():
35
+ assert validate_records(_records()) == []
36
+
37
+
38
+ def test_validate_flags_missing_session_bad_role_and_dup_turn():
39
+ bad = [
40
+ {"role": "user", "text": "x"}, # missing session_id
41
+ {"session_id": "s", "role": "bot", "text": "x"}, # bad role
42
+ {"session_id": "s", "turn_index": 3, "role": "user", "text": "x"},
43
+ {"session_id": "s", "turn_index": 3, "role": "assistant", "text": "x"}, # dup turn_index
44
+ ]
45
+ errors = validate_records(bad)
46
+ assert any("session_id" in e for e in errors)
47
+ assert any("role must be one of" in e for e in errors)
48
+ assert any("duplicate turn_index" in e for e in errors)
49
+
50
+
51
+ def test_validate_strict_requires_persona_and_assistant_turn():
52
+ records = [{"session_id": "s", "turn_index": 0, "role": "user", "text": "x", "tags": []}] # no persona, no assistant
53
+ errors = validate_records(records, strict=True)
54
+ assert any("persona_id is required" in e for e in errors)
55
+ assert any("has no assistant turn" in e for e in errors)
56
+
57
+
58
+ def test_manifest_digest_is_order_independent_and_counts_are_right():
59
+ m1 = build_manifest(_records(), id="d", revision="v1")
60
+ m2 = build_manifest(list(reversed(_records())), id="d", revision="v1")
61
+ assert m1.content_digest == m2.content_digest # canonical, order-independent
62
+ assert m1.record_count == 4 and m1.session_count == 2
63
+ assert m1.personas == {"aver": 4}
64
+ assert m1.tags["family:g"] == 2 and m1.tags["group:x"] == 2
65
+
66
+
67
+ # ---- CLI ----
68
+
69
+ def test_cli_stats_json(tmp_path):
70
+ p = _write(tmp_path, _records())
71
+ res = runner.invoke(app, ["dataset", "stats", str(p), "--json"])
72
+ assert res.exit_code == 0, res.output
73
+ report = json.loads(res.output)
74
+ assert report["records"] == 4 and report["sessions"] == 2 and report["roles"]["assistant"] == 2
75
+
76
+
77
+ def test_cli_validate_exit_codes(tmp_path):
78
+ good = _write(tmp_path, _records(), "good.jsonl")
79
+ assert runner.invoke(app, ["dataset", "validate", str(good)]).exit_code == 0
80
+ bad = _write(tmp_path, [{"role": "user", "text": "x"}], "bad.jsonl")
81
+ assert runner.invoke(app, ["dataset", "validate", str(bad)]).exit_code == 1
82
+
83
+
84
+ def test_cli_dedupe(tmp_path):
85
+ p = _write(tmp_path, _records() + _records()[:1]) # one duplicate
86
+ out = tmp_path / "dd.jsonl"
87
+ res = runner.invoke(app, ["dataset", "dedupe", str(p), "--out", str(out)])
88
+ assert res.exit_code == 0 and "removed 1 duplicate" in res.output
89
+ assert len(read_jsonl(out)) == 4
90
+
91
+
92
+ def test_cli_merge_namespaces_sessions(tmp_path):
93
+ a = _write(tmp_path, _records()[:2], "a.jsonl")
94
+ b = _write(tmp_path, _records()[2:], "b.jsonl")
95
+ out = tmp_path / "m.jsonl"
96
+ res = runner.invoke(app, ["dataset", "merge", str(a), str(b), "--out", str(out), "--namespace-sessions"])
97
+ assert res.exit_code == 0
98
+ sessions = {r["session_id"] for r in read_jsonl(out)}
99
+ assert sessions == {"a:a", "b:b"}
100
+
101
+
102
+ def test_cli_split_keeps_groups_together(tmp_path):
103
+ # two group:* groups; a holdout must contain whole groups, never split one across sides
104
+ records = []
105
+ for g in ("g1", "g2"):
106
+ for i in range(3):
107
+ records.append({"session_id": f"{g}-{i}", "role": "assistant", "text": "x", "tags": [f"group:{g}"]})
108
+ p = _write(tmp_path, records)
109
+ out = tmp_path / "split"
110
+ res = runner.invoke(app, ["dataset", "split", str(p), "--out", str(out), "--by", "group", "--holdout", "0.5"])
111
+ assert res.exit_code == 0, res.output
112
+ train = {t for r in read_jsonl(out / "train.jsonl") for t in r["tags"]}
113
+ hold = {t for r in read_jsonl(out / "holdout.jsonl") for t in r["tags"]}
114
+ assert train and hold and train.isdisjoint(hold) # no group straddles the boundary
115
+
116
+
117
+ def test_cli_split_tolerates_non_dict_rows(tmp_path):
118
+ p = tmp_path / "mixed.jsonl"
119
+ write_jsonl(p, _records() + [[1, 2, 3]]) # a non-dict row must not crash split
120
+ out = tmp_path / "s"
121
+ res = runner.invoke(app, ["dataset", "split", str(p), "--out", str(out), "--by", "session", "--holdout", "0.5"])
122
+ assert res.exit_code == 0, res.output
123
+
124
+
125
+ def test_cli_manifest_verify_bad_json_errors_friendly(tmp_path):
126
+ p = _write(tmp_path, _records())
127
+ bad = tmp_path / "bad-manifest.json"
128
+ bad.write_text("not json at all")
129
+ res = runner.invoke(app, ["dataset", "manifest", str(p), "--verify", str(bad)])
130
+ assert res.exit_code != 0
131
+ assert "Could not read manifest" in res.output
132
+
133
+
134
+ def test_cli_manifest_build_and_verify(tmp_path):
135
+ p = _write(tmp_path, _records())
136
+ man = tmp_path / "manifest.json"
137
+ assert runner.invoke(app, ["dataset", "manifest", str(p), "--out", str(man)]).exit_code == 0
138
+ assert json.loads(man.read_text())["record_count"] == 4
139
+ assert runner.invoke(app, ["dataset", "manifest", str(p), "--verify", str(man)]).exit_code == 0
140
+ # mutate the data → verify must fail
141
+ write_jsonl(p, _records() + [{"session_id": "z", "role": "user", "text": "new"}])
142
+ assert runner.invoke(app, ["dataset", "manifest", str(p), "--verify", str(man)]).exit_code == 2
File without changes
File without changes
File without changes
File without changes
File without changes