harrrshall's picture
Release BarunAction-35M candidate-v2
5a46e5d verified
Raw
History Blame Contribute Delete
12.9 kB
from __future__ import annotations
import hashlib
import json
from collections.abc import Iterable, Iterator, Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import torch
from tokenizers import Tokenizer
from torch import Tensor
EXAMPLE_SCHEMA_VERSION = "barun-sft-example-v1"
IGNORE_INDEX = -100
class ManifestError(ValueError):
"""Raised when an SFT manifest violates the versioned input contract."""
@dataclass(frozen=True, slots=True)
class SFTExample:
example_id: str
prompt: str
target: str
metadata: Mapping[str, Any]
content_sha256: str
@dataclass(frozen=True, slots=True)
class TokenizedExample:
example_id: str
input_ids: tuple[int, ...]
labels: tuple[int, ...]
prompt_tokens: int
target_tokens: int
content_sha256: str
@dataclass(frozen=True, slots=True)
class RejectedExample:
example_id: str
reason: str
encoded_tokens: int
max_tokens: int
prompt_tokens: int
target_tokens: int
content_sha256: str
def to_dict(self) -> dict[str, int | str]:
return {
"id": self.example_id,
"reason": self.reason,
"encoded_tokens": self.encoded_tokens,
"max_tokens": self.max_tokens,
"prompt_tokens": self.prompt_tokens,
"target_tokens": self.target_tokens,
"content_sha256": self.content_sha256,
}
@dataclass(frozen=True, slots=True)
class SFTBatch:
example_ids: tuple[str, ...]
input_ids: Tensor
labels: Tensor
attention_mask: Tensor
target_tokens: int
def to(self, device: torch.device) -> SFTBatch:
return SFTBatch(
example_ids=self.example_ids,
input_ids=self.input_ids.to(device=device, non_blocking=True),
labels=self.labels.to(device=device, non_blocking=True),
attention_mask=self.attention_mask.to(device=device, non_blocking=True),
target_tokens=self.target_tokens,
)
def sha256_file(path: str | Path) -> str:
digest = hashlib.sha256()
with Path(path).open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def _reject_json_constant(value: str) -> None:
raise ManifestError(f"non-finite JSON constant {value!r} is not allowed")
def _canonical_record_hash(payload: Mapping[str, Any]) -> str:
encoded = json.dumps(
payload,
ensure_ascii=False,
allow_nan=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def _validate_record(payload: object, *, line_number: int) -> SFTExample:
if not isinstance(payload, dict):
raise ManifestError(f"line {line_number}: each JSONL row must be an object")
allowed = {"schema_version", "id", "prompt", "target", "metadata"}
unknown = sorted(set(payload) - allowed)
if unknown:
raise ManifestError(
f"line {line_number}: unknown top-level fields {unknown}; put annotations in metadata"
)
missing = sorted({"schema_version", "id", "prompt", "target"} - set(payload))
if missing:
raise ManifestError(f"line {line_number}: missing required fields {missing}")
if payload["schema_version"] != EXAMPLE_SCHEMA_VERSION:
raise ManifestError(
f"line {line_number}: schema_version must be {EXAMPLE_SCHEMA_VERSION!r}"
)
example_id = payload["id"]
prompt = payload["prompt"]
target = payload["target"]
metadata = payload.get("metadata", {})
if not isinstance(example_id, str) or not example_id.strip():
raise ManifestError(f"line {line_number}: id must be a non-empty string")
if not isinstance(prompt, str) or not prompt:
raise ManifestError(f"line {line_number}: prompt must be a non-empty string")
if not isinstance(target, str) or not target:
raise ManifestError(f"line {line_number}: target must be a non-empty string")
if not isinstance(metadata, dict):
raise ManifestError(f"line {line_number}: metadata must be an object")
try:
content_sha256 = _canonical_record_hash(payload)
except (TypeError, ValueError) as error:
raise ManifestError(f"line {line_number}: metadata is not strict JSON: {error}") from error
return SFTExample(
example_id=example_id,
prompt=prompt,
target=target,
metadata=metadata,
content_sha256=content_sha256,
)
def _validate_training_split_metadata(
example: SFTExample,
*,
expected_derived_split: str,
) -> None:
"""Reject known held-out rows before they can enter training or model selection.
Generic manifests may omit split metadata. Dataset adapters that provide the
versioned ``source_split``/``derived_split`` fields are held to them: an official
evaluation row cannot be relabeled or passed as either the training or development
manifest.
"""
metadata = example.metadata
source_split = metadata.get("source_split")
derived_split = metadata.get("derived_split")
for field, value in (("source_split", source_split), ("derived_split", derived_split)):
if value is not None and not isinstance(value, str):
raise ManifestError(
f"example {example.example_id!r}: metadata.{field} must be a string"
)
held_out_names = {"eval", "evaluation", "final_eval", "test"}
if source_split is not None and source_split.casefold() in held_out_names:
raise ManifestError(
f"example {example.example_id!r}: official held-out source_split "
f"{source_split!r} is forbidden in SFT"
)
if derived_split is not None:
if derived_split.casefold() in held_out_names:
raise ManifestError(
f"example {example.example_id!r}: held-out derived_split "
f"{derived_split!r} is forbidden in SFT"
)
if derived_split != expected_derived_split:
raise ManifestError(
f"example {example.example_id!r}: expected metadata.derived_split "
f"{expected_derived_split!r}, got {derived_split!r}"
)
def load_manifest(
path: str | Path,
*,
expected_sha256: str,
expected_derived_split: str | None = None,
) -> list[SFTExample]:
"""Load a strict, hash-pinned JSONL manifest without normalizing its text."""
manifest_path = Path(path)
actual_sha256 = sha256_file(manifest_path)
if actual_sha256 != expected_sha256.lower():
raise ManifestError(
f"manifest SHA-256 mismatch for {manifest_path}: "
f"expected {expected_sha256.lower()}, got {actual_sha256}"
)
examples: list[SFTExample] = []
seen_ids: set[str] = set()
with manifest_path.open("r", encoding="utf-8", newline="") as handle:
for line_number, line in enumerate(handle, start=1):
if not line.strip():
raise ManifestError(f"line {line_number}: blank JSONL rows are not allowed")
try:
payload = json.loads(line, parse_constant=_reject_json_constant)
except (json.JSONDecodeError, ManifestError) as error:
raise ManifestError(f"line {line_number}: invalid JSON: {error}") from error
example = _validate_record(payload, line_number=line_number)
if expected_derived_split is not None:
_validate_training_split_metadata(
example,
expected_derived_split=expected_derived_split,
)
if example.example_id in seen_ids:
raise ManifestError(f"line {line_number}: duplicate id {example.example_id!r}")
seen_ids.add(example.example_id)
examples.append(example)
if not examples:
raise ManifestError(f"manifest {manifest_path} contains no examples")
return examples
def tokenize_examples(
examples: Iterable[SFTExample],
tokenizer: Tokenizer,
*,
eos_token_id: int,
max_seq_len: int,
) -> tuple[list[TokenizedExample], list[RejectedExample]]:
"""Tokenize prompt and target separately and append EOS without truncation.
Labels occupy the same positions as ``input_ids``. Prompt positions are set to
``IGNORE_INDEX``; the model's internal next-token shift therefore begins loss at
the first target token. EOS is always a supervised target token.
"""
if max_seq_len < 2:
raise ValueError("max_seq_len must be at least 2")
if not 0 <= eos_token_id < tokenizer.get_vocab_size(with_added_tokens=True):
raise ValueError("eos_token_id is outside the tokenizer vocabulary")
accepted: list[TokenizedExample] = []
rejected: list[RejectedExample] = []
for example in examples:
prompt_ids = tokenizer.encode(example.prompt, add_special_tokens=False).ids
target_ids = tokenizer.encode(example.target, add_special_tokens=False).ids
if not prompt_ids:
raise ManifestError(f"example {example.example_id!r}: prompt encoded to zero tokens")
if not target_ids:
raise ManifestError(f"example {example.example_id!r}: target encoded to zero tokens")
if eos_token_id in prompt_ids or eos_token_id in target_ids:
raise ManifestError(
f"example {example.example_id!r}: prompt/target already contains EOS; "
"the trainer appends exactly one EOS target"
)
input_ids = tuple(prompt_ids + target_ids + [eos_token_id])
labels = tuple([IGNORE_INDEX] * len(prompt_ids) + target_ids + [eos_token_id])
target_tokens = len(target_ids) + 1
if len(input_ids) > max_seq_len:
rejected.append(
RejectedExample(
example_id=example.example_id,
reason="overlength",
encoded_tokens=len(input_ids),
max_tokens=max_seq_len,
prompt_tokens=len(prompt_ids),
target_tokens=target_tokens,
content_sha256=example.content_sha256,
)
)
continue
accepted.append(
TokenizedExample(
example_id=example.example_id,
input_ids=input_ids,
labels=labels,
prompt_tokens=len(prompt_ids),
target_tokens=target_tokens,
content_sha256=example.content_sha256,
)
)
return accepted, rejected
def collate_sft(examples: Sequence[TokenizedExample], *, pad_token_id: int) -> SFTBatch:
"""Right-pad a response-only batch for causal training."""
if not examples:
raise ValueError("cannot collate an empty batch")
if pad_token_id < 0:
raise ValueError("pad_token_id cannot be negative")
max_length = max(len(example.input_ids) for example in examples)
input_ids = torch.full((len(examples), max_length), pad_token_id, dtype=torch.long)
labels = torch.full((len(examples), max_length), IGNORE_INDEX, dtype=torch.long)
attention_mask = torch.zeros((len(examples), max_length), dtype=torch.bool)
for row, example in enumerate(examples):
length = len(example.input_ids)
if length != len(example.labels):
raise ValueError(f"example {example.example_id!r} has misaligned inputs and labels")
input_ids[row, :length] = torch.tensor(example.input_ids, dtype=torch.long)
labels[row, :length] = torch.tensor(example.labels, dtype=torch.long)
attention_mask[row, :length] = True
target_tokens = int((labels[:, 1:] != IGNORE_INDEX).sum().item())
if target_tokens != sum(example.target_tokens for example in examples):
raise ValueError("response-only label alignment invariant failed")
return SFTBatch(
example_ids=tuple(example.example_id for example in examples),
input_ids=input_ids,
labels=labels,
attention_mask=attention_mask,
target_tokens=target_tokens,
)
def deterministic_batches(
examples: Sequence[TokenizedExample],
*,
batch_size: int,
seed: int,
epoch: int,
shuffle: bool,
) -> Iterator[list[TokenizedExample]]:
if batch_size < 1:
raise ValueError("batch_size must be positive")
if epoch < 0:
raise ValueError("epoch cannot be negative")
if shuffle:
generator = torch.Generator(device="cpu")
generator.manual_seed(seed + epoch)
indices = torch.randperm(len(examples), generator=generator).tolist()
else:
indices = list(range(len(examples)))
for start in range(0, len(indices), batch_size):
yield [examples[index] for index in indices[start : start + batch_size]]