mirror of https://github.com/razor-ai/soup.git
100 lines
3.3 KiB
Python
100 lines
3.3 KiB
Python
"""v0.44.0 Part B — Studio-style onboarding wizard.
|
|
|
|
Pure-Python: takes the answers as a dict and renders a complete `soup.yaml`.
|
|
The interactive prompt loop lives in `commands/init.py` and calls
|
|
`render_onboarding_yaml(answers)` here.
|
|
|
|
Five questions:
|
|
1. base model (HF repo id or local path)
|
|
2. dataset (local JSONL path or HF dataset name)
|
|
3. task (sft / dpo / preference)
|
|
4. quantization (4bit / 8bit / none)
|
|
5. epochs (1-10)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Dict
|
|
|
|
import yaml
|
|
|
|
from soup_cli.utils.paths import is_under_cwd
|
|
|
|
VALID_TASKS = frozenset(
|
|
{"sft", "dpo", "kto", "orpo", "simpo", "ipo", "bco", "preference"}
|
|
)
|
|
VALID_QUANT = frozenset({"4bit", "8bit", "none"})
|
|
|
|
_MAX_BASE_LEN = 256
|
|
_MAX_DATASET_LEN = 512
|
|
_MAX_OUTPUT_LEN = 512
|
|
|
|
|
|
def _check_string(value: Any, *, field: str, max_len: int) -> str:
|
|
if not isinstance(value, str):
|
|
raise TypeError(f"{field} must be str")
|
|
if not value:
|
|
raise ValueError(f"{field} must be non-empty")
|
|
if "\x00" in value:
|
|
raise ValueError(f"{field} contains NUL byte")
|
|
if len(value) > max_len:
|
|
raise ValueError(f"{field} exceeds {max_len} chars")
|
|
return value
|
|
|
|
|
|
def render_onboarding_yaml(answers: Dict[str, Any]) -> str:
|
|
"""Render a complete `soup.yaml` from a 5-answer dict.
|
|
|
|
Required keys: base, dataset, task, quantization, epochs.
|
|
Optional: output (default `./out`), batch_size (default `auto`).
|
|
"""
|
|
if not isinstance(answers, dict):
|
|
raise TypeError("answers must be dict")
|
|
base = _check_string(answers.get("base"), field="base", max_len=_MAX_BASE_LEN)
|
|
dataset = _check_string(
|
|
answers.get("dataset"), field="dataset", max_len=_MAX_DATASET_LEN
|
|
)
|
|
task = answers.get("task")
|
|
if task not in VALID_TASKS:
|
|
raise ValueError(
|
|
f"task must be one of {sorted(VALID_TASKS)}; got {task!r}"
|
|
)
|
|
quant = answers.get("quantization", "4bit")
|
|
if quant not in VALID_QUANT:
|
|
raise ValueError(
|
|
f"quantization must be one of {sorted(VALID_QUANT)}; got {quant!r}"
|
|
)
|
|
epochs = answers.get("epochs")
|
|
if isinstance(epochs, bool) or not isinstance(epochs, int):
|
|
raise TypeError("epochs must be int")
|
|
if not (1 <= epochs <= 10):
|
|
raise ValueError("epochs must be in [1, 10]")
|
|
output = _check_string(
|
|
answers.get("output", "./out"), field="output", max_len=_MAX_OUTPUT_LEN
|
|
)
|
|
if not is_under_cwd(output):
|
|
# Match the project policy of leaking only the basename in errors.
|
|
import os
|
|
|
|
raise ValueError(
|
|
f"output must stay under cwd: {os.path.basename(output)}"
|
|
)
|
|
batch_size: Any = answers.get("batch_size", "auto")
|
|
if isinstance(batch_size, bool):
|
|
raise TypeError("batch_size must be int or 'auto'")
|
|
if not (batch_size == "auto" or (isinstance(batch_size, int) and batch_size > 0)):
|
|
raise ValueError("batch_size must be a positive int or 'auto'")
|
|
config = {
|
|
"base": base,
|
|
"task": task,
|
|
"data": {"train": dataset, "format": "auto"},
|
|
"training": {
|
|
"epochs": epochs,
|
|
"lr": 2e-4,
|
|
"batch_size": batch_size,
|
|
"quantization": quant,
|
|
},
|
|
"output": output,
|
|
}
|
|
return yaml.safe_dump(config, sort_keys=False, default_flow_style=False)
|