Spaces:
Running on Zero
Running on Zero
| """Prepare Figment SFT JSONL for Modal fine-tuning. | |
| The generated SFT rows are already harness-aligned. This script keeps that | |
| shape intact, validates the two-message chat contract, and writes a small | |
| train/validation split that a Modal job can stage into a Volume. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| from collections import Counter | |
| from collections import defaultdict | |
| from datetime import UTC | |
| from datetime import datetime | |
| import hashlib | |
| import json | |
| from pathlib import Path | |
| from typing import Any | |
| DEFAULT_DATASET_VERSION = "figment_sft_v1" | |
| DEFAULT_DATASET = Path("data/finetune/figment_sft_v1.jsonl") | |
| DEFAULT_OUTPUT_ROOT = Path("data/finetune/modal") | |
| class DatasetPrepError(ValueError): | |
| """Raised when an SFT row is not safe to hand to the Modal trainer.""" | |
| def main(argv: list[str] | None = None) -> int: | |
| parser = argparse.ArgumentParser(description=__doc__) | |
| parser.add_argument("--dataset", type=Path, default=DEFAULT_DATASET) | |
| parser.add_argument("--dataset-version", default=DEFAULT_DATASET_VERSION) | |
| parser.add_argument("--output-dir", type=Path, default=None) | |
| parser.add_argument("--validation-fraction", type=float, default=0.1) | |
| parser.add_argument("--seed", default="figment-modal-sft-v1") | |
| parser.add_argument("--min-validation-group-size", type=int, default=5) | |
| args = parser.parse_args(argv) | |
| output_dir = args.output_dir or DEFAULT_OUTPUT_ROOT / args.dataset_version | |
| manifest = prepare_dataset( | |
| dataset_path=args.dataset, | |
| output_dir=output_dir, | |
| dataset_version=args.dataset_version, | |
| validation_fraction=args.validation_fraction, | |
| seed=args.seed, | |
| min_validation_group_size=args.min_validation_group_size, | |
| ) | |
| print(json.dumps(manifest, indent=2, sort_keys=True)) | |
| return 0 | |
| def prepare_dataset( | |
| *, | |
| dataset_path: Path, | |
| output_dir: Path, | |
| dataset_version: str, | |
| validation_fraction: float = 0.1, | |
| seed: str = "figment-modal-sft-v1", | |
| min_validation_group_size: int = 5, | |
| ) -> dict[str, Any]: | |
| if not dataset_path.exists(): | |
| raise DatasetPrepError(f"dataset does not exist: {dataset_path}") | |
| if not 0 <= validation_fraction < 1: | |
| raise DatasetPrepError("--validation-fraction must be in [0, 1)") | |
| if min_validation_group_size < 2: | |
| raise DatasetPrepError("--min-validation-group-size must be at least 2") | |
| rows = _read_jsonl(dataset_path) | |
| if not rows: | |
| raise DatasetPrepError(f"dataset is empty: {dataset_path}") | |
| seen_ids: set[str] = set() | |
| for row_number, row in enumerate(rows, start=1): | |
| _validate_row(row, row_number) | |
| row_id = _row_id(row) | |
| if row_id in seen_ids: | |
| raise DatasetPrepError(f"row {row_number}: duplicate row id {row_id!r}") | |
| seen_ids.add(row_id) | |
| train_rows, validation_rows = _split_rows( | |
| rows, | |
| validation_fraction=validation_fraction, | |
| seed=seed, | |
| min_validation_group_size=min_validation_group_size, | |
| ) | |
| output_dir.mkdir(parents=True, exist_ok=True) | |
| train_path = output_dir / "train.jsonl" | |
| validation_path = output_dir / "validation.jsonl" | |
| manifest_path = output_dir / "manifest.json" | |
| _write_jsonl(train_path, train_rows) | |
| _write_jsonl(validation_path, validation_rows) | |
| manifest = { | |
| "dataset_version": dataset_version, | |
| "generated_at": datetime.now(UTC).isoformat(), | |
| "source_dataset": str(dataset_path), | |
| "row_count": len(rows), | |
| "train_count": len(train_rows), | |
| "validation_count": len(validation_rows), | |
| "task_type_counts": dict(sorted(Counter(_task_type(row) for row in rows).items())), | |
| "group_counts": dict(sorted(Counter(_group_key(row) for row in rows).items())), | |
| "train_group_counts": dict(sorted(Counter(_group_key(row) for row in train_rows).items())), | |
| "validation_group_counts": dict(sorted(Counter(_group_key(row) for row in validation_rows).items())), | |
| "validation_fraction": validation_fraction, | |
| "seed": seed, | |
| "min_validation_group_size": min_validation_group_size, | |
| "train_path": str(train_path), | |
| "validation_path": str(validation_path), | |
| "train_sha256": _sha256_path(train_path), | |
| "validation_sha256": _sha256_path(validation_path), | |
| } | |
| manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") | |
| return manifest | |
| def _read_jsonl(path: Path) -> list[dict[str, Any]]: | |
| rows: list[dict[str, Any]] = [] | |
| for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): | |
| if not line.strip(): | |
| continue | |
| try: | |
| item = json.loads(line) | |
| except json.JSONDecodeError as exc: | |
| raise DatasetPrepError(f"{path}:{line_number}: invalid JSON: {exc}") from exc | |
| if not isinstance(item, dict): | |
| raise DatasetPrepError(f"{path}:{line_number}: expected object row") | |
| rows.append(item) | |
| return rows | |
| def _write_jsonl(path: Path, rows: list[dict[str, Any]]) -> None: | |
| path.write_text("".join(json.dumps(row, sort_keys=True) + "\n" for row in rows), encoding="utf-8") | |
| def _validate_row(row: dict[str, Any], row_number: int) -> None: | |
| messages = row.get("messages") | |
| if not isinstance(messages, list) or [message.get("role") for message in messages] != ["user", "assistant"]: | |
| raise DatasetPrepError(f"row {row_number}: expected user/assistant messages") | |
| for message_index, message in enumerate(messages, start=1): | |
| content = message.get("content") | |
| if not isinstance(content, str) or not content.strip(): | |
| raise DatasetPrepError(f"row {row_number}: message {message_index} has empty content") | |
| if not _row_id(row): | |
| raise DatasetPrepError(f"row {row_number}: missing uuid or case_id") | |
| def _split_rows( | |
| rows: list[dict[str, Any]], | |
| *, | |
| validation_fraction: float, | |
| seed: str, | |
| min_validation_group_size: int, | |
| ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: | |
| groups: dict[str, list[dict[str, Any]]] = defaultdict(list) | |
| for row in rows: | |
| groups[_group_key(row)].append(row) | |
| validation_ids: set[str] = set() | |
| for group_rows in groups.values(): | |
| if validation_fraction == 0 or len(group_rows) < min_validation_group_size: | |
| continue | |
| validation_count = max(1, round(len(group_rows) * validation_fraction)) | |
| validation_count = min(validation_count, len(group_rows) - 1) | |
| ranked = sorted(group_rows, key=lambda row: _split_key(row, seed)) | |
| validation_ids.update(_row_id(row) for row in ranked[:validation_count]) | |
| train_rows = [row for row in rows if _row_id(row) not in validation_ids] | |
| validation_rows = [row for row in rows if _row_id(row) in validation_ids] | |
| return train_rows, validation_rows | |
| def _split_key(row: dict[str, Any], seed: str) -> str: | |
| return hashlib.sha256(f"{seed}:{_row_id(row)}".encode("utf-8")).hexdigest() | |
| def _row_id(row: dict[str, Any]) -> str: | |
| return str(row.get("uuid") or row.get("case_id") or "") | |
| def _group_key(row: dict[str, Any]) -> str: | |
| task_type = _task_type(row) | |
| metadata = row.get("metadata") if isinstance(row.get("metadata"), dict) else {} | |
| if task_type == "focused_repair": | |
| return f"focused_repair:{metadata.get('repair_scope') or row.get('category') or 'unknown'}" | |
| return f"{task_type}:{row.get('category') or 'unknown'}" | |
| def _task_type(row: dict[str, Any]) -> str: | |
| metadata = row.get("metadata") if isinstance(row.get("metadata"), dict) else {} | |
| return str(metadata.get("task_type") or "navigator_full") | |
| def _sha256_path(path: Path) -> str: | |
| digest = hashlib.sha256() | |
| with path.open("rb") as file: | |
| for chunk in iter(lambda: file.read(1024 * 1024), b""): | |
| digest.update(chunk) | |
| return digest.hexdigest() | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |