alignmenter 0.3.0__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.0/src/alignmenter.egg-info → alignmenter-0.3.2}/PKG-INFO +1 -1
  2. {alignmenter-0.3.0 → alignmenter-0.3.2}/datasets/README.md +22 -0
  3. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/_version.py +1 -1
  4. {alignmenter-0.3.0 → 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.0 → alignmenter-0.3.2}/src/alignmenter/release_cli.py +22 -2
  7. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/reporting/durable.py +3 -1
  8. alignmenter-0.3.2/src/alignmenter/reporting/github_comment.py +140 -0
  9. alignmenter-0.3.2/src/alignmenter/schemas/dataset.py +139 -0
  10. {alignmenter-0.3.0 → alignmenter-0.3.2/src/alignmenter.egg-info}/PKG-INFO +1 -1
  11. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter.egg-info/SOURCES.txt +5 -0
  12. alignmenter-0.3.2/tests/test_dataset_management.py +142 -0
  13. alignmenter-0.3.2/tests/test_github_comment.py +116 -0
  14. {alignmenter-0.3.0 → alignmenter-0.3.2}/LICENSE +0 -0
  15. {alignmenter-0.3.0 → alignmenter-0.3.2}/MANIFEST.in +0 -0
  16. {alignmenter-0.3.0 → alignmenter-0.3.2}/README.md +0 -0
  17. {alignmenter-0.3.0 → alignmenter-0.3.2}/configs/demo_config.yaml +0 -0
  18. {alignmenter-0.3.0 → alignmenter-0.3.2}/configs/judges/safety_prompt.txt +0 -0
  19. {alignmenter-0.3.0 → alignmenter-0.3.2}/configs/persona/default.yaml +0 -0
  20. {alignmenter-0.3.0 → alignmenter-0.3.2}/configs/run-grounded.yaml +0 -0
  21. {alignmenter-0.3.0 → alignmenter-0.3.2}/configs/run.yaml +0 -0
  22. {alignmenter-0.3.0 → alignmenter-0.3.2}/configs/safety_keywords.yaml +0 -0
  23. {alignmenter-0.3.0 → alignmenter-0.3.2}/datasets/demo_conversations.jsonl +0 -0
  24. {alignmenter-0.3.0 → alignmenter-0.3.2}/datasets/grounded_demo.jsonl +0 -0
  25. {alignmenter-0.3.0 → alignmenter-0.3.2}/datasets/wendys_twitter.jsonl +0 -0
  26. {alignmenter-0.3.0 → alignmenter-0.3.2}/pyproject.toml +0 -0
  27. {alignmenter-0.3.0 → alignmenter-0.3.2}/setup.cfg +0 -0
  28. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/__init__.py +0 -0
  29. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/calibration/__init__.py +0 -0
  30. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/calibration/analyze.py +0 -0
  31. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/calibration/bounds.py +0 -0
  32. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/calibration/diagnose.py +0 -0
  33. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/calibration/generate.py +0 -0
  34. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/calibration/label.py +0 -0
  35. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/calibration/optimize.py +0 -0
  36. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/calibration/sampling.py +0 -0
  37. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/calibration/validate.py +0 -0
  38. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/config.py +0 -0
  39. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/data/configs/demo_config.yaml +0 -0
  40. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/data/configs/judges/safety_prompt.txt +0 -0
  41. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/data/configs/persona/default.yaml +0 -0
  42. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/data/configs/run-grounded.yaml +0 -0
  43. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/data/configs/run.yaml +0 -0
  44. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/data/configs/safety_keywords.yaml +0 -0
  45. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/data/datasets/demo_conversations.jsonl +0 -0
  46. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/data/datasets/grounded_demo.jsonl +0 -0
  47. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/evaluators/__init__.py +0 -0
  48. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/evaluators/custom.py +0 -0
  49. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/evaluators/evidence.py +0 -0
  50. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/evaluators/faithfulness.py +0 -0
  51. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/evaluators/grounding.py +0 -0
  52. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/evaluators/metrics.py +0 -0
  53. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/examples/__init__.py +0 -0
  54. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/examples/resource_task.py +0 -0
  55. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/execution/__init__.py +0 -0
  56. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/execution/archive.py +0 -0
  57. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/execution/artifacts.py +0 -0
  58. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/execution/comparison.py +0 -0
  59. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/execution/evaluation.py +0 -0
  60. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/execution/gates.py +0 -0
  61. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/execution/leases.py +0 -0
  62. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/execution/legacy.py +0 -0
  63. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/execution/recovery.py +0 -0
  64. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/execution/review.py +0 -0
  65. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/execution/suite.py +0 -0
  66. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/judges/__init__.py +0 -0
  67. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/judges/authenticity_judge.py +0 -0
  68. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/judges/prompts.py +0 -0
  69. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/providers/__init__.py +0 -0
  70. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/providers/anthropic.py +0 -0
  71. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/providers/base.py +0 -0
  72. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/providers/callable.py +0 -0
  73. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/providers/classifiers.py +0 -0
  74. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/providers/durable_judge.py +0 -0
  75. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/providers/embeddings.py +0 -0
  76. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/providers/judges.py +0 -0
  77. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/providers/local.py +0 -0
  78. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/providers/openai.py +0 -0
  79. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/reporting/__init__.py +0 -0
  80. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/reporting/html.py +0 -0
  81. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/reporting/json_out.py +0 -0
  82. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/run_config.py +0 -0
  83. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/runner.py +0 -0
  84. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/schemas/__init__.py +0 -0
  85. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/schemas/evaluation.py +0 -0
  86. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/schemas/execution.py +0 -0
  87. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/schemas/gates.py +0 -0
  88. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/schemas/metrics.py +0 -0
  89. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/schemas/review.py +0 -0
  90. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/schemas/scoring.py +0 -0
  91. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/schemas/suite.py +0 -0
  92. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/scorers/__init__.py +0 -0
  93. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/scorers/authenticity.py +0 -0
  94. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/scorers/faithfulness.py +0 -0
  95. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/scorers/grounding.py +0 -0
  96. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/scorers/safety.py +0 -0
  97. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/scorers/stability.py +0 -0
  98. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/scripts/__init__.py +0 -0
  99. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/scripts/bootstrap_dataset.py +0 -0
  100. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/scripts/calibrate_persona.py +0 -0
  101. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/scripts/run_openai_demo.py +0 -0
  102. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/scripts/sanitize_dataset.py +0 -0
  103. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/sdk.py +0 -0
  104. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/storage/__init__.py +0 -0
  105. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/storage/evaluations.py +0 -0
  106. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/storage/reviews.py +0 -0
  107. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/storage/runs.py +0 -0
  108. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/utils/__init__.py +0 -0
  109. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/utils/io.py +0 -0
  110. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/utils/optional.py +0 -0
  111. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/utils/tokens.py +0 -0
  112. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter/utils/yaml.py +0 -0
  113. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter.egg-info/dependency_links.txt +0 -0
  114. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter.egg-info/entry_points.txt +0 -0
  115. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter.egg-info/requires.txt +0 -0
  116. {alignmenter-0.3.0 → alignmenter-0.3.2}/src/alignmenter.egg-info/top_level.txt +0 -0
  117. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/__init__.py +0 -0
  118. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/conftest.py +0 -0
  119. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/data/durable_evaluation_judge.py +0 -0
  120. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/data/durable_evaluation_worker.py +0 -0
  121. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/data/durable_recovery_target.py +0 -0
  122. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/data/durable_recovery_worker.py +0 -0
  123. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/data/durable_run_worker.py +0 -0
  124. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/data/mini_cli_dataset.jsonl +0 -0
  125. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_authenticity_judge.py +0 -0
  126. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_builtin_evaluations.py +0 -0
  127. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_calibrate_persona.py +0 -0
  128. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_capture_recovery.py +0 -0
  129. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_cli_errors.py +0 -0
  130. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_cli_grounded.py +0 -0
  131. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_cli_helpers.py +0 -0
  132. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_cli_import.py +0 -0
  133. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_cli_init.py +0 -0
  134. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_cli_run_config.py +0 -0
  135. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_config.py +0 -0
  136. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_durable_evaluations.py +0 -0
  137. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_durable_execution.py +0 -0
  138. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_faithfulness.py +0 -0
  139. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_grounding.py +0 -0
  140. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_html_report.py +0 -0
  141. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_judge_providers.py +0 -0
  142. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_offline_safety.py +0 -0
  143. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_persona_gpt.py +0 -0
  144. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_provider_local.py +0 -0
  145. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_provider_openai.py +0 -0
  146. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_providers.py +0 -0
  147. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_release_workflow.py +0 -0
  148. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_review_workflow.py +0 -0
  149. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_run_config_grounded.py +0 -0
  150. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_run_config_loader.py +0 -0
  151. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_run_openai_demo.py +0 -0
  152. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_runner.py +0 -0
  153. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_sampling.py +0 -0
  154. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_scorers.py +0 -0
  155. {alignmenter-0.3.0 → alignmenter-0.3.2}/tests/test_smoke.py +0 -0
  156. {alignmenter-0.3.0 → 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.0
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.0"
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))
@@ -22,6 +22,18 @@ from alignmenter.schemas.gates import GatePolicy
22
22
  EXIT_CODES = {"pass": 0, "fail": 2, "inconclusive": 3}
23
23
 
24
24
 
25
+ def _exit_code(decision: str, *, allow_inconclusive: bool = False) -> int:
26
+ """Map a decision to a process exit code.
27
+
28
+ With ``allow_inconclusive`` an inconclusive decision (e.g. an unreviewed
29
+ ``draft`` spec that met every applicable criterion) exits 0 so an automated
30
+ gate is not blocked; a genuine ``fail`` still exits non-zero.
31
+ """
32
+ if allow_inconclusive and decision == "inconclusive":
33
+ return 0
34
+ return EXIT_CODES[decision]
35
+
36
+
25
37
  def register_release_commands(app):
26
38
  @app.command("archive-export")
27
39
  def archive_export(
@@ -67,6 +79,9 @@ def register_release_commands(app):
67
79
  suite: Path = typer.Argument(..., exists=True, dir_okay=False),
68
80
  out: Path = typer.Option(Path("reports"), "--out"),
69
81
  resume: Path | None = typer.Option(None, "--resume", exists=True, file_okay=False),
82
+ allow_inconclusive: bool = typer.Option(
83
+ False, "--allow-inconclusive",
84
+ help="Exit 0 on an inconclusive decision (a fail still exits non-zero)."),
70
85
  ):
71
86
  """Capture, evaluate, compare, and write CI artifacts under a frozen suite config."""
72
87
  try:
@@ -74,7 +89,9 @@ def register_release_commands(app):
74
89
  except Exception as exc:
75
90
  raise typer.BadParameter(str(exc)) from exc
76
91
  typer.echo(json.dumps(result, indent=2))
77
- raise typer.Exit(EXIT_CODES[result["decision"]])
92
+ if allow_inconclusive and result["decision"] == "inconclusive":
93
+ typer.echo("Inconclusive tolerated (--allow-inconclusive): exiting 0.")
94
+ raise typer.Exit(_exit_code(result["decision"], allow_inconclusive=allow_inconclusive))
78
95
 
79
96
  @app.command("review-export")
80
97
  def review_export(
@@ -137,6 +154,9 @@ def register_release_commands(app):
137
154
  baseline: Path | None = typer.Option(None, "--baseline", exists=True, file_okay=False),
138
155
  baseline_id: UUID | None = typer.Option(None, "--baseline-id"),
139
156
  force: bool = typer.Option(False, "--force"),
157
+ allow_inconclusive: bool = typer.Option(
158
+ False, "--allow-inconclusive",
159
+ help="Exit 0 on an inconclusive decision (a fail still exits non-zero)."),
140
160
  ):
141
161
  """Check saved results and export CI artifacts without invoking any provider."""
142
162
  try:
@@ -147,7 +167,7 @@ def register_release_commands(app):
147
167
  raise typer.BadParameter(str(exc)) from exc
148
168
  decision = report["gate_report"]["decision"]
149
169
  typer.echo(f"Decision: {decision}\nArtifacts: {out.resolve()}")
150
- raise typer.Exit(EXIT_CODES[decision])
170
+ raise typer.Exit(_exit_code(decision, allow_inconclusive=allow_inconclusive))
151
171
 
152
172
  @app.command("compare")
153
173
  def compare(
@@ -12,6 +12,7 @@ from xml.etree import ElementTree as ET
12
12
 
13
13
  from alignmenter.execution.evaluation import evaluation_summary
14
14
  from alignmenter.execution.gates import gate_report
15
+ from alignmenter.reporting.github_comment import render_github_comment
15
16
 
16
17
 
17
18
  def _escape(value):
@@ -167,5 +168,6 @@ def export_evaluation(run_dir, out_dir, *, evaluation_id=None, policy=None, comp
167
168
  report["comparison"] = comparison
168
169
  report["review"] = qualification_report(run_dir, report["evaluation_id"])
169
170
  write_artifacts(out_dir, {"evaluation.json": _pretty(report) + "\n", "index.html": render_html(report),
170
- "junit.xml": render_junit(report), "summary.md": render_markdown(report)}, force=force)
171
+ "junit.xml": render_junit(report), "summary.md": render_markdown(report),
172
+ "comment.md": render_github_comment(report)}, force=force)
171
173
  return report
@@ -0,0 +1,140 @@
1
+ """Sticky GitHub pull-request comment over one saved decision; never executes a scorer.
2
+
3
+ Renders the same ``report`` structure the other durable reporters consume
4
+ (:mod:`alignmenter.reporting.durable`) into Markdown tuned for a PR comment: a
5
+ verdict badge, blocking issues first, a gate table, metrics (with baseline
6
+ deltas when a comparison is present), and collapsed breakdowns. The leading
7
+ marker lets a CI step find and update one sticky comment instead of appending.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ MARKER = "<!-- alignmenter:report -->"
13
+
14
+ _DECISION_BADGE = {"pass": "✅ Pass", "fail": "❌ Fail", "inconclusive": "⚠️ Inconclusive"}
15
+ _CHECK_ICON = {"pass": "✅", "fail": "❌", "inconclusive": "➖"}
16
+ _OPERATOR = {"at_least": "≥", "at_most": "≤"}
17
+
18
+
19
+ def _cell(value):
20
+ """Escape a value for a single Markdown table cell."""
21
+ if value is None:
22
+ return "—"
23
+ return (str(value).replace("\\", "\\\\").replace("|", "\\|")
24
+ .replace("\n", " ").replace("<", "&lt;").replace(">", "&gt;"))
25
+
26
+
27
+ def _num(value, digits=3):
28
+ if value is None:
29
+ return "—"
30
+ if isinstance(value, bool):
31
+ return str(value)
32
+ if isinstance(value, float):
33
+ return f"{value:.{digits}f}".rstrip("0").rstrip(".") or "0"
34
+ return str(value)
35
+
36
+
37
+ def _signed(value, digits=3):
38
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
39
+ return ("+" if value > 0 else "") + _num(value, digits)
40
+ return _num(value, digits)
41
+
42
+
43
+ def _icon(decision):
44
+ return _CHECK_ICON.get(decision, _cell(decision))
45
+
46
+
47
+ def _gate_detail(check):
48
+ metric = check.get("metric")
49
+ if metric is None:
50
+ return _cell(check.get("reason", ""))
51
+ operator = _OPERATOR.get(check.get("operator"), check.get("operator") or "")
52
+ return f"`{_cell(metric)}` {operator} {_num(check.get('threshold'))} · got **{_num(check.get('value'))}**"
53
+
54
+
55
+ def _blocking_lines(report):
56
+ lines = []
57
+ for check in report["gate_report"].get("checks", []):
58
+ # Skip the structural "evaluation"/"comparison" umbrella checks (no metric);
59
+ # the ❌ badge and the violated cases below already convey those.
60
+ if check["decision"] == "fail" and check.get("metric") is not None:
61
+ lines.append(f"- **{_cell(check['id'])}** — {_gate_detail(check)}")
62
+ inputs = {item["key"]: item for item in report.get("inputs", [])}
63
+ violated = [(inputs.get(r["input_key"]), r) for r in report.get("results", [])
64
+ if r.get("status") == "violated"]
65
+ for item, result in violated[:5]:
66
+ item = item or {}
67
+ label = _cell(item.get("case_id") or item.get("session_id") or "case")
68
+ lines.append(f"- case **{label}** / {_cell(item.get('criterion_id'))}: "
69
+ f"{_cell(result.get('reason') or 'violated')}")
70
+ if len(violated) > 5:
71
+ lines.append(f"- …and {len(violated) - 5} more violated case(s)")
72
+ return lines
73
+
74
+
75
+ def _gate_table(report):
76
+ checks = report["gate_report"].get("checks", [])
77
+ if not checks:
78
+ return []
79
+ rows = ["| Gate | Result | Detail |", "| --- | :---: | --- |"]
80
+ rows += [f"| {_cell(c['id'])} | {_icon(c['decision'])} | {_gate_detail(c)} |" for c in checks]
81
+ return ["#### Gates", *rows, ""]
82
+
83
+
84
+ def _metrics_table(report):
85
+ comparison = report.get("comparison")
86
+ if comparison and comparison.get("metrics"):
87
+ rows = ["| Metric | Baseline | Candidate | Δ | Note |",
88
+ "| --- | ---: | ---: | ---: | :---: |"]
89
+ for name, metric in comparison["metrics"].items():
90
+ note = "⚠️ unavailable" if metric.get("unavailable") else ""
91
+ rows.append(f"| `{_cell(name)}` | {_num(metric.get('baseline'))} | "
92
+ f"{_num(metric.get('candidate'))} | {_signed(metric.get('delta'))} | {note} |")
93
+ return ["#### Metrics (vs baseline)", *rows, ""]
94
+ metrics = report.get("metrics") or {}
95
+ if not metrics:
96
+ return []
97
+ rows = ["| Metric | Value | Denominator |", "| --- | ---: | ---: |"]
98
+ rows += [f"| `{_cell(name)}` | {_num(m.get('value'))} | {_num(m.get('denominator'))} |"
99
+ for name, m in metrics.items()]
100
+ return ["#### Metrics", *rows, ""]
101
+
102
+
103
+ def _details(summary, body):
104
+ return [f"<details><summary>{_cell(summary)}</summary>", "", body, "", "</details>", ""]
105
+
106
+
107
+ def _breakdown(report):
108
+ lines = []
109
+ criteria = report.get("criteria") or {}
110
+ if criteria:
111
+ rows = ["| Criterion | met | violated | n/a | decision |",
112
+ "| --- | ---: | ---: | ---: | :---: |"]
113
+ for cid, summary in criteria.items():
114
+ counts = summary.get("counts", {})
115
+ rows.append(f"| `{_cell(cid)}` | {counts.get('met', 0)} | {counts.get('violated', 0)} | "
116
+ f"{counts.get('not_applicable', 0)} | {_icon(summary.get('decision'))} |")
117
+ lines += _details("Per-criterion breakdown", "\n".join(rows))
118
+ counts = report.get("counts") or {}
119
+ if counts:
120
+ lines += _details("Outcome counts", ", ".join(f"{k}: {v}" for k, v in counts.items()))
121
+ return lines
122
+
123
+
124
+ def render_github_comment(report, *, title="Alignmenter"):
125
+ """Return a sticky PR-comment Markdown body for one saved evaluation ``report``."""
126
+ decision = report["gate_report"]["decision"]
127
+ spec = report["spec"]
128
+ header = (f"`{_cell(spec['id'])} @ {_cell(spec['revision'])}` · "
129
+ f"qualification `{_cell(spec['qualification'])}` · "
130
+ f"{report['judged']}/{report['applicable']} applicable criteria evaluated")
131
+ if report.get("unavailable"):
132
+ header += f" · {report['unavailable']} unavailable"
133
+ lines = [MARKER, f"### {_DECISION_BADGE.get(decision, _cell(decision))} — {_cell(title)}", header, ""]
134
+ blocking = _blocking_lines(report)
135
+ if blocking:
136
+ lines += ["#### ❌ Blocking", *blocking, ""]
137
+ lines += _gate_table(report)
138
+ lines += _metrics_table(report)
139
+ lines += _breakdown(report)
140
+ return "\n".join(lines).rstrip() + "\n"
@@ -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.0
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
@@ -77,9 +78,11 @@ src/alignmenter/providers/local.py
77
78
  src/alignmenter/providers/openai.py
78
79
  src/alignmenter/reporting/__init__.py
79
80
  src/alignmenter/reporting/durable.py
81
+ src/alignmenter/reporting/github_comment.py
80
82
  src/alignmenter/reporting/html.py
81
83
  src/alignmenter/reporting/json_out.py
82
84
  src/alignmenter/schemas/__init__.py
85
+ src/alignmenter/schemas/dataset.py
83
86
  src/alignmenter/schemas/evaluation.py
84
87
  src/alignmenter/schemas/execution.py
85
88
  src/alignmenter/schemas/gates.py
@@ -120,9 +123,11 @@ tests/test_cli_import.py
120
123
  tests/test_cli_init.py
121
124
  tests/test_cli_run_config.py
122
125
  tests/test_config.py
126
+ tests/test_dataset_management.py
123
127
  tests/test_durable_evaluations.py
124
128
  tests/test_durable_execution.py
125
129
  tests/test_faithfulness.py
130
+ tests/test_github_comment.py
126
131
  tests/test_grounding.py
127
132
  tests/test_html_report.py
128
133
  tests/test_judge_providers.py