benchmax 0.2.3__tar.gz → 0.2.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. {benchmax-0.2.3 → benchmax-0.2.4}/PKG-INFO +8 -2
  2. {benchmax-0.2.3 → benchmax-0.2.4}/README.md +6 -0
  3. {benchmax-0.2.3 → benchmax-0.2.4}/pyproject.toml +1 -1
  4. benchmax-0.2.4/src/benchmax/sft/README.md +59 -0
  5. benchmax-0.2.4/src/benchmax/sft/__init__.py +36 -0
  6. benchmax-0.2.4/src/benchmax/sft/dataset.py +187 -0
  7. benchmax-0.2.4/src/benchmax/sft/issues.py +73 -0
  8. benchmax-0.2.4/src/benchmax/sft/rows.py +419 -0
  9. benchmax-0.2.4/src/benchmax/sft/strict_json.py +153 -0
  10. benchmax-0.2.4/tests/fixtures/sft_v1/invalid/all_masked.input.jsonl +1 -0
  11. benchmax-0.2.4/tests/fixtures/sft_v1/invalid/bad_arguments.input.jsonl +2 -0
  12. benchmax-0.2.4/tests/fixtures/sft_v1/invalid/bad_weight.input.jsonl +3 -0
  13. benchmax-0.2.4/tests/fixtures/sft_v1/invalid/deep_nesting.input.jsonl +1 -0
  14. benchmax-0.2.4/tests/fixtures/sft_v1/invalid/duplicate_keys.input.jsonl +1 -0
  15. benchmax-0.2.4/tests/fixtures/sft_v1/invalid/empty.input.jsonl +1 -0
  16. benchmax-0.2.4/tests/fixtures/sft_v1/invalid/expected_issues.json +170 -0
  17. benchmax-0.2.4/tests/fixtures/sft_v1/invalid/legacy_prompt_completion.input.jsonl +1 -0
  18. benchmax-0.2.4/tests/fixtures/sft_v1/invalid/lone_surrogate.input.jsonl +1 -0
  19. benchmax-0.2.4/tests/fixtures/sft_v1/invalid/missing_tool_result.input.jsonl +1 -0
  20. benchmax-0.2.4/tests/fixtures/sft_v1/invalid/multimodal.input.jsonl +1 -0
  21. benchmax-0.2.4/tests/fixtures/sft_v1/invalid/nonfinite_number.input.jsonl +2 -0
  22. benchmax-0.2.4/tests/fixtures/sft_v1/invalid/tool_result_out_of_order.input.jsonl +1 -0
  23. benchmax-0.2.4/tests/fixtures/sft_v1/invalid/tools_missing_for_calls.input.jsonl +1 -0
  24. benchmax-0.2.4/tests/fixtures/sft_v1/invalid/undefined_tool_function.input.jsonl +1 -0
  25. benchmax-0.2.4/tests/fixtures/sft_v1/invalid/unknown_tool_call_id.input.jsonl +1 -0
  26. benchmax-0.2.4/tests/fixtures/sft_v1/valid/masked.canonical.jsonl +1 -0
  27. benchmax-0.2.4/tests/fixtures/sft_v1/valid/masked.input.jsonl +1 -0
  28. benchmax-0.2.4/tests/fixtures/sft_v1/valid/plain.canonical.jsonl +2 -0
  29. benchmax-0.2.4/tests/fixtures/sft_v1/valid/plain.input.jsonl +3 -0
  30. benchmax-0.2.4/tests/fixtures/sft_v1/valid/tools.canonical.jsonl +1 -0
  31. benchmax-0.2.4/tests/fixtures/sft_v1/valid/tools.input.jsonl +1 -0
  32. benchmax-0.2.4/tests/unit/sft/test_dataset.py +577 -0
  33. benchmax-0.2.4/tests/unit/sft/test_fixtures.py +62 -0
  34. benchmax-0.2.4/tests/unit/sft/test_import_safety.py +23 -0
  35. {benchmax-0.2.3 → benchmax-0.2.4}/.gitignore +0 -0
  36. {benchmax-0.2.3 → benchmax-0.2.4}/pytest.ini +0 -0
  37. {benchmax-0.2.3 → benchmax-0.2.4}/src/benchmax/auth.py +0 -0
  38. {benchmax-0.2.3 → benchmax-0.2.4}/src/benchmax/bundle.py +0 -0
  39. {benchmax-0.2.3 → benchmax-0.2.4}/src/benchmax/envs/README.md +0 -0
  40. {benchmax-0.2.3 → benchmax-0.2.4}/src/benchmax/envs/__init__.py +0 -0
  41. {benchmax-0.2.3 → benchmax-0.2.4}/src/benchmax/envs/base/README.md +0 -0
  42. {benchmax-0.2.3 → benchmax-0.2.4}/src/benchmax/envs/base/__init__.py +0 -0
  43. {benchmax-0.2.3 → benchmax-0.2.4}/src/benchmax/envs/base/dataset.py +0 -0
  44. {benchmax-0.2.3 → benchmax-0.2.4}/src/benchmax/envs/base/env.py +0 -0
  45. {benchmax-0.2.3 → benchmax-0.2.4}/src/benchmax/envs/base/openai_types.py +0 -0
  46. {benchmax-0.2.3 → benchmax-0.2.4}/src/benchmax/envs/dataset.py +0 -0
  47. {benchmax-0.2.3 → benchmax-0.2.4}/src/benchmax/envs/environment.py +0 -0
  48. {benchmax-0.2.3 → benchmax-0.2.4}/src/benchmax/envs/harbor/README.md +0 -0
  49. {benchmax-0.2.3 → benchmax-0.2.4}/src/benchmax/envs/harbor/__init__.py +0 -0
  50. {benchmax-0.2.3 → benchmax-0.2.4}/src/benchmax/envs/harbor/bundled_agent.py +0 -0
  51. {benchmax-0.2.3 → benchmax-0.2.4}/src/benchmax/envs/harbor/credentials.py +0 -0
  52. {benchmax-0.2.3 → benchmax-0.2.4}/src/benchmax/envs/harbor/dataset.py +0 -0
  53. {benchmax-0.2.3 → benchmax-0.2.4}/src/benchmax/envs/harbor/dep_check.py +0 -0
  54. {benchmax-0.2.3 → benchmax-0.2.4}/src/benchmax/envs/harbor/env.py +0 -0
  55. {benchmax-0.2.3 → benchmax-0.2.4}/src/benchmax/envs/harbor/types.py +0 -0
  56. {benchmax-0.2.3 → benchmax-0.2.4}/src/benchmax/envs/identity.py +0 -0
  57. {benchmax-0.2.3 → benchmax-0.2.4}/src/benchmax/envs/logging.py +0 -0
  58. {benchmax-0.2.3 → benchmax-0.2.4}/src/benchmax/envs/shared_types.py +0 -0
  59. {benchmax-0.2.3 → benchmax-0.2.4}/src/benchmax/rag/__init__.py +0 -0
  60. {benchmax-0.2.3 → benchmax-0.2.4}/src/benchmax/rag/embed.py +0 -0
  61. {benchmax-0.2.3 → benchmax-0.2.4}/src/benchmax/rag/env.py +0 -0
  62. {benchmax-0.2.3 → benchmax-0.2.4}/src/benchmax/rag/search.py +0 -0
  63. {benchmax-0.2.3 → benchmax-0.2.4}/src/benchmax/rewards/README.md +0 -0
  64. {benchmax-0.2.3 → benchmax-0.2.4}/src/benchmax/rewards/__init__.py +0 -0
  65. {benchmax-0.2.3 → benchmax-0.2.4}/src/benchmax/rewards/adaptive.py +0 -0
  66. {benchmax-0.2.3 → benchmax-0.2.4}/src/benchmax/rewards/deterministic.py +0 -0
  67. {benchmax-0.2.3 → benchmax-0.2.4}/src/benchmax/rewards/diversity.py +0 -0
  68. {benchmax-0.2.3 → benchmax-0.2.4}/src/benchmax/rewards/judge.py +0 -0
  69. {benchmax-0.2.3 → benchmax-0.2.4}/src/benchmax/rewards/prompts.py +0 -0
  70. {benchmax-0.2.3 → benchmax-0.2.4}/src/benchmax/rewards/rubric.py +0 -0
  71. {benchmax-0.2.3 → benchmax-0.2.4}/src/benchmax/rewards/scoring.py +0 -0
  72. {benchmax-0.2.3 → benchmax-0.2.4}/tests/conftest.py +0 -0
  73. {benchmax-0.2.3 → benchmax-0.2.4}/tests/unit/bundle/test_artifact.py +0 -0
  74. {benchmax-0.2.3 → benchmax-0.2.4}/tests/unit/bundle/test_source_capture.py +0 -0
  75. {benchmax-0.2.3 → benchmax-0.2.4}/tests/unit/envs/test_base_dataset.py +0 -0
  76. {benchmax-0.2.3 → benchmax-0.2.4}/tests/unit/envs/test_base_env_group.py +0 -0
  77. {benchmax-0.2.3 → benchmax-0.2.4}/tests/unit/envs/test_contract_types.py +0 -0
  78. {benchmax-0.2.3 → benchmax-0.2.4}/tests/unit/envs/test_environment_group.py +0 -0
  79. {benchmax-0.2.3 → benchmax-0.2.4}/tests/unit/envs/test_example_id.py +0 -0
  80. {benchmax-0.2.3 → benchmax-0.2.4}/tests/unit/fakes/model_server.py +0 -0
  81. {benchmax-0.2.3 → benchmax-0.2.4}/tests/unit/harbor/test_bundled_agent.py +0 -0
  82. {benchmax-0.2.3 → benchmax-0.2.4}/tests/unit/harbor/test_harbor_dataset.py +0 -0
  83. {benchmax-0.2.3 → benchmax-0.2.4}/tests/unit/harbor/test_harbor_env.py +0 -0
  84. {benchmax-0.2.3 → benchmax-0.2.4}/tests/unit/rag/corpus/test_embed.py +0 -0
  85. {benchmax-0.2.3 → benchmax-0.2.4}/tests/unit/rag/test_rag_env.py +0 -0
  86. {benchmax-0.2.3 → benchmax-0.2.4}/tests/unit/rewards/conftest.py +0 -0
  87. {benchmax-0.2.3 → benchmax-0.2.4}/tests/unit/rewards/test_adaptive.py +0 -0
  88. {benchmax-0.2.3 → benchmax-0.2.4}/tests/unit/rewards/test_deterministic.py +0 -0
  89. {benchmax-0.2.3 → benchmax-0.2.4}/tests/unit/rewards/test_diversity.py +0 -0
  90. {benchmax-0.2.3 → benchmax-0.2.4}/tests/unit/rewards/test_diversity_env.py +0 -0
  91. {benchmax-0.2.3 → benchmax-0.2.4}/tests/unit/rewards/test_judge.py +0 -0
  92. {benchmax-0.2.3 → benchmax-0.2.4}/tests/unit/rewards/test_rubric.py +0 -0
  93. {benchmax-0.2.3 → benchmax-0.2.4}/tests/unit/rewards/test_rubric_rewards.py +0 -0
  94. {benchmax-0.2.3 → benchmax-0.2.4}/tests/unit/test_auth.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: benchmax
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Summary: Platform-independent runtime for grouped LLM environments
5
5
  Author: benchmax Authors
6
6
  Classifier: Operating System :: OS Independent
@@ -31,6 +31,12 @@ all benchmax environments implement the same dataset and rollout contracts. choo
31
31
 
32
32
  most custom environments should extend `BaseEnv`. most Harbor users configure `HarborEnv` directly rather than subclassing it.
33
33
 
34
+ ## sft datasets
35
+
36
+ supervised finetuning does not use an environment. construct a validated
37
+ [`benchmax.sft.SftDataset`](src/benchmax/sft/README.md) from chat-format rows and upload it
38
+ through the castform package; validation is strict and all-or-nothing.
39
+
34
40
  ## architecture
35
41
 
36
42
  an environment defines its dataset and how a group of rollouts runs against each example.
@@ -16,6 +16,12 @@ all benchmax environments implement the same dataset and rollout contracts. choo
16
16
 
17
17
  most custom environments should extend `BaseEnv`. most Harbor users configure `HarborEnv` directly rather than subclassing it.
18
18
 
19
+ ## sft datasets
20
+
21
+ supervised finetuning does not use an environment. construct a validated
22
+ [`benchmax.sft.SftDataset`](src/benchmax/sft/README.md) from chat-format rows and upload it
23
+ through the castform package; validation is strict and all-or-nothing.
24
+
19
25
  ## architecture
20
26
 
21
27
  an environment defines its dataset and how a group of rollouts runs against each example.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "benchmax"
3
- version = "0.2.3"
3
+ version = "0.2.4"
4
4
  description = "Platform-independent runtime for grouped LLM environments"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "benchmax Authors" }]
@@ -0,0 +1,59 @@
1
+ # benchmax.sft
2
+
3
+ the validated `benchmax-sft-v1` supervised-finetuning dataset artifact. construction is
4
+ all-or-nothing: a dataset either satisfies the whole contract or raises with every ordered,
5
+ line-aware issue.
6
+
7
+ ```python
8
+ from benchmax.sft import SftDataset, SftDatasetError
9
+
10
+ train = SftDataset.from_jsonl("train.jsonl") # or SftDataset.from_rows(rows)
11
+ payload = train.to_jsonl_bytes() # canonical, deterministic bytes
12
+ ```
13
+
14
+ ## row contract
15
+
16
+ one JSONL line per row. a row is a closed object: required `messages`, optional `tools`,
17
+ optional `metadata`.
18
+
19
+ ```json
20
+ {
21
+ "messages": [
22
+ {"role": "user", "content": "What is 2 + 2?"},
23
+ {"role": "assistant", "content": "4", "weight": 1}
24
+ ],
25
+ "tools": [],
26
+ "metadata": {"id": "optional producer identity"}
27
+ }
28
+ ```
29
+
30
+ - `system`/`user`: exactly `role` + non-empty string `content`.
31
+ - `assistant`: optional string-or-null `content`, optional non-empty `tool_calls`,
32
+ optional integer `weight` `0 | 1` (omitted means `1`; `0` masks the turn from loss).
33
+ each assistant turn needs non-empty content or a tool call; each row needs at least one
34
+ assistant turn with effective weight `1`.
35
+ - `tool`: exactly `role` + string `content` + non-empty `tool_call_id`. every tool call
36
+ gets exactly one result, in declaration order, before the next non-tool message.
37
+ - tool definitions/calls are OpenAI-style function shapes, validated deeply; called
38
+ names must match a definition; `function.arguments` must decode as a JSON object.
39
+ - `metadata` is an open finite JSON object (canonical size ≤ 64 KiB; `_castform_*` keys
40
+ are reserved for the runtime). rows are ≤ 1 MiB canonical, `messages` ≤ 1024 entries,
41
+ `tools` ≤ 128 unique functions.
42
+
43
+ rejected outright: multimodal content parts, fractional weights, legacy
44
+ prompt/completion shapes, duplicate JSON keys, lone surrogates, non-finite numbers,
45
+ nesting deeper than 64 levels, and unknown keys anywhere outside `metadata` and
46
+ `parameters`.
47
+
48
+ ## canonical bytes
49
+
50
+ `to_jsonl_bytes()` is deterministic for equivalent inputs: UTF-8 without a BOM,
51
+ `ensure_ascii=False`, sorted keys, compact separators, one trailing newline per row,
52
+ original row order.
53
+
54
+ ## golden fixtures
55
+
56
+ `packages/benchmax/tests/fixtures/sft_v1/` is the cross-repository contract consumed by
57
+ the castform trainer: valid inputs with byte-exact canonical outputs, and invalid inputs
58
+ with exact ordered diagnostics in `expected_issues.json`. change them only with a format
59
+ version bump in mind.
@@ -0,0 +1,36 @@
1
+ """Public `benchmax-sft-v1` supervised-finetuning dataset contract.
2
+
3
+ One row is a closed JSON object with required ``messages``, optional ``tools``,
4
+ and optional ``metadata``:
5
+
6
+ - ``system``/``user`` messages have exactly ``role`` and non-empty string
7
+ ``content``.
8
+ - ``assistant`` messages have optional string-or-null ``content``, optional
9
+ non-empty ``tool_calls``, and optional integer ``weight`` of ``0`` or ``1``
10
+ (omitted means ``1``); each needs non-empty content or at least one tool
11
+ call, and every row needs at least one assistant turn with effective
12
+ weight ``1``.
13
+ - ``tool`` results have exactly ``role``, string ``content``, and non-empty
14
+ ``tool_call_id``; every tool call receives exactly one result, in
15
+ declaration order, before the next non-tool message.
16
+ - OpenAI-style function tool definitions/calls are validated deeply; called
17
+ function names must match a definition.
18
+ - ``metadata`` is an open finite JSON object (``_castform_``-prefixed keys are
19
+ reserved for the runtime).
20
+
21
+ Validation is strict and all-or-nothing: images/audio/multimodal content
22
+ parts, fractional weights, legacy prompt/completion shapes, duplicate JSON
23
+ keys, lone surrogates, non-finite numbers, and nesting deeper than 64 levels
24
+ are rejected with ordered, line-aware diagnostics. Canonical serialization is
25
+ deterministic byte-for-byte for equivalent inputs.
26
+ """
27
+
28
+ from benchmax.sft.dataset import SFT_DATASET_FORMAT, SftDataset
29
+ from benchmax.sft.issues import SftDatasetError, SftIssue
30
+
31
+ __all__ = [
32
+ "SFT_DATASET_FORMAT",
33
+ "SftDataset",
34
+ "SftDatasetError",
35
+ "SftIssue",
36
+ ]
@@ -0,0 +1,187 @@
1
+ """The validated `benchmax-sft-v1` dataset artifact.
2
+
3
+ :class:`SftDataset` is the single trust boundary for SFT training data: it is
4
+ either fully valid or it does not exist. Both constructors run the complete
5
+ strict contract — closed row schema, strict JSON rules, size limits — and raise
6
+ :class:`~benchmax.sft.issues.SftDatasetError` with every ordered issue on any
7
+ violation, including empty input.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ from collections.abc import Iterable, Iterator
15
+ from pathlib import Path
16
+
17
+ from benchmax.sft.issues import SftDatasetError, SftIssue
18
+ from benchmax.sft.rows import MAX_ROW_BYTES, validate_row
19
+ from benchmax.sft.strict_json import (
20
+ StrictJsonError,
21
+ canonical_json_bytes,
22
+ check_json_tree,
23
+ parse_strict_json,
24
+ )
25
+
26
+ __all__ = ["SFT_DATASET_FORMAT", "SftDataset"]
27
+
28
+ SFT_DATASET_FORMAT = "benchmax-sft-v1"
29
+ """Format identifier used at the platform boundary for this row contract."""
30
+
31
+ _INTERNAL = object()
32
+
33
+
34
+ class SftDataset:
35
+ """Immutable, fully validated SFT training dataset.
36
+
37
+ Construct only through :meth:`from_jsonl` or :meth:`from_rows`; direct
38
+ instantiation is rejected so a partially valid dataset can never exist.
39
+ Row order is preserved from the input. :meth:`to_jsonl_bytes` returns
40
+ canonical bytes — UTF-8 without a BOM, ``ensure_ascii=False``, sorted keys,
41
+ compact separators, one trailing newline per row — deterministic for
42
+ equivalent input objects.
43
+ """
44
+
45
+ __slots__ = ("_row_bytes", "_rows")
46
+
47
+ def __init__(
48
+ self,
49
+ rows: tuple[dict[str, object], ...],
50
+ row_bytes: tuple[bytes, ...],
51
+ token: object = None,
52
+ ) -> None:
53
+ if token is not _INTERNAL:
54
+ raise TypeError("use SftDataset.from_jsonl(...) or SftDataset.from_rows(...)")
55
+ self._rows = rows
56
+ self._row_bytes = row_bytes
57
+
58
+ @classmethod
59
+ def from_jsonl(cls, path: str | os.PathLike[str]) -> SftDataset:
60
+ """Load and validate a JSONL file.
61
+
62
+ Whitespace-only physical lines are ignored while physical line numbers
63
+ are preserved in diagnostics. Raises
64
+ :class:`~benchmax.sft.issues.SftDatasetError` on any violation.
65
+ """
66
+
67
+ data = Path(path).read_bytes()
68
+ collector = _IssueCollector()
69
+ rows: list[dict[str, object]] = []
70
+ row_bytes: list[bytes] = []
71
+ row_index = 0
72
+ for line_number, raw_line in enumerate(data.split(b"\n"), start=1):
73
+ if raw_line.strip(b" \t\r\f\v") == b"":
74
+ continue
75
+ row = row_index
76
+ row_index += 1
77
+ if line_number == 1 and raw_line.startswith(b"\xef\xbb\xbf"):
78
+ collector.add(line_number, row, "$", "a UTF-8 BOM is not allowed")
79
+ continue
80
+ try:
81
+ text = raw_line.decode("utf-8")
82
+ except UnicodeDecodeError:
83
+ collector.add(line_number, row, "$", "line is not valid UTF-8")
84
+ continue
85
+ try:
86
+ parsed = parse_strict_json(text)
87
+ except StrictJsonError as error:
88
+ collector.add(line_number, row, "$", str(error))
89
+ continue
90
+ encoded = _process_row(parsed, collector.for_row(line_number, row))
91
+ if encoded is not None:
92
+ rows.append(json.loads(encoded.decode("utf-8")))
93
+ row_bytes.append(encoded)
94
+ if row_index == 0:
95
+ collector.add(None, None, "$", "dataset contains no rows")
96
+ collector.raise_if_any()
97
+ return cls(tuple(rows), tuple(row_bytes), _INTERNAL)
98
+
99
+ @classmethod
100
+ def from_rows(cls, rows: Iterable[dict[str, object]]) -> SftDataset:
101
+ """Validate in-memory row objects.
102
+
103
+ Rows must already use plain JSON-compatible Python types; anything else
104
+ is reported as an issue rather than coerced. Raises
105
+ :class:`~benchmax.sft.issues.SftDatasetError` on any violation.
106
+ """
107
+
108
+ collector = _IssueCollector()
109
+ stored: list[dict[str, object]] = []
110
+ row_bytes: list[bytes] = []
111
+ row_index = -1
112
+ for row_index, row in enumerate(iter(rows)):
113
+ encoded = _process_row(row, collector.for_row(None, row_index))
114
+ if encoded is not None:
115
+ # Round-trip through canonical bytes to decouple stored rows
116
+ # from caller-owned objects.
117
+ stored.append(json.loads(encoded.decode("utf-8")))
118
+ row_bytes.append(encoded)
119
+ if row_index < 0:
120
+ collector.add(None, None, "$", "dataset contains no rows")
121
+ collector.raise_if_any()
122
+ return cls(tuple(stored), tuple(row_bytes), _INTERNAL)
123
+
124
+ def to_jsonl_bytes(self) -> bytes:
125
+ """Serialize every row as canonical JSONL bytes in original order."""
126
+
127
+ return b"".join(encoded + b"\n" for encoded in self._row_bytes)
128
+
129
+ @property
130
+ def rows(self) -> tuple[dict[str, object], ...]:
131
+ """The validated rows in original order. Treat as read-only."""
132
+
133
+ return self._rows
134
+
135
+ def __len__(self) -> int:
136
+ return len(self._rows)
137
+
138
+ def __iter__(self) -> Iterator[dict[str, object]]:
139
+ return iter(self._rows)
140
+
141
+
142
+ class _IssueCollector:
143
+ """Ordered issue sink shared by both constructors."""
144
+
145
+ def __init__(self) -> None:
146
+ self._issues: list[SftIssue] = []
147
+
148
+ def add(self, line: int | None, row: int | None, location: str, message: str) -> None:
149
+ self._issues.append(SftIssue(message=message, location=location, line=line, row=row))
150
+
151
+ def for_row(self, line: int | None, row: int | None) -> _RowSink:
152
+ return _RowSink(self, line, row)
153
+
154
+ def raise_if_any(self) -> None:
155
+ if self._issues:
156
+ raise SftDatasetError(self._issues)
157
+
158
+
159
+ class _RowSink:
160
+ """Emit adapter binding one row's line/row coordinates."""
161
+
162
+ def __init__(self, collector: _IssueCollector, line: int | None, row: int | None) -> None:
163
+ self._collector = collector
164
+ self.line = line
165
+ self.row = row
166
+ self.count = 0
167
+
168
+ def __call__(self, location: str, message: str) -> None:
169
+ self.count += 1
170
+ self._collector.add(self.line, self.row, location, message)
171
+
172
+
173
+ def _process_row(row: object, sink: _RowSink) -> bytes | None:
174
+ """Fully validate one row; return its canonical bytes only when clean."""
175
+
176
+ check_json_tree(row, "$", sink)
177
+ if type(row) is not dict:
178
+ sink("$", "row must be a JSON object")
179
+ return None
180
+ validate_row(row, sink)
181
+ if sink.count:
182
+ return None
183
+ encoded = canonical_json_bytes(row)
184
+ if len(encoded) > MAX_ROW_BYTES:
185
+ sink("$", f"row canonical size is {len(encoded)} bytes; the maximum is {MAX_ROW_BYTES}")
186
+ return None
187
+ return encoded
@@ -0,0 +1,73 @@
1
+ """Diagnostics for `benchmax-sft-v1` dataset validation.
2
+
3
+ Validation is all-or-nothing: constructing an :class:`~benchmax.sft.SftDataset`
4
+ either succeeds completely or raises :class:`SftDatasetError` carrying every
5
+ :class:`SftIssue` found, in deterministic document order.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Sequence
11
+ from dataclasses import dataclass
12
+
13
+ __all__ = ["SftDatasetError", "SftIssue"]
14
+
15
+ _PREVIEW_ISSUES = 20
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class SftIssue:
20
+ """One validation problem at one location in the input.
21
+
22
+ ``location`` addresses the offending value inside the row using ``$`` for
23
+ the row object itself, e.g. ``$.messages[2].weight``. ``line`` is the
24
+ 1-based physical JSONL line for :meth:`SftDataset.from_jsonl` input and
25
+ ``None`` for :meth:`SftDataset.from_rows` input. ``row`` is the 0-based
26
+ dataset row index, or ``None`` for dataset-level problems such as an empty
27
+ input.
28
+ """
29
+
30
+ message: str
31
+ location: str
32
+ line: int | None = None
33
+ row: int | None = None
34
+
35
+ def as_dict(self) -> dict[str, object]:
36
+ """Return the stable JSON-friendly shape used by golden fixtures."""
37
+
38
+ return {
39
+ "line": self.line,
40
+ "location": self.location,
41
+ "message": self.message,
42
+ "row": self.row,
43
+ }
44
+
45
+ def describe(self) -> str:
46
+ """Render a one-line human-readable description."""
47
+
48
+ origin: list[str] = []
49
+ if self.line is not None:
50
+ origin.append(f"line {self.line}")
51
+ if self.row is not None:
52
+ origin.append(f"row {self.row}")
53
+ prefix = f"{' '.join(origin)} " if origin else ""
54
+ return f"{prefix}{self.location}: {self.message}"
55
+
56
+
57
+ class SftDatasetError(ValueError):
58
+ """Raised when input cannot form a valid `benchmax-sft-v1` dataset.
59
+
60
+ ``issues`` holds every problem found, ordered by input position and then by
61
+ a fixed within-row validation order, so equivalent inputs always produce an
62
+ identical issue sequence.
63
+ """
64
+
65
+ def __init__(self, issues: Sequence[SftIssue]) -> None:
66
+ self.issues: tuple[SftIssue, ...] = tuple(issues)
67
+ if not self.issues:
68
+ raise ValueError("SftDatasetError requires at least one issue")
69
+ lines = [f"invalid benchmax-sft-v1 dataset: {len(self.issues)} issue(s)"]
70
+ lines += [f" {issue.describe()}" for issue in self.issues[:_PREVIEW_ISSUES]]
71
+ if len(self.issues) > _PREVIEW_ISSUES:
72
+ lines.append(f" ... and {len(self.issues) - _PREVIEW_ISSUES} more")
73
+ super().__init__("\n".join(lines))