Files
wehub-resource-sync e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:59:56 +08:00

404 lines
15 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import asyncio
import importlib.util
import unittest
from pathlib import Path
from unittest.mock import patch
from datasets import Dataset
from core.training.training import TrainingBackend
from models.training import TrainingStartRequest
from utils.datasets import format_dataset, format_and_template_dataset
from utils.datasets.raw_text import prepare_raw_text_dataset
_BACKEND_ROOT = Path(__file__).resolve().parent.parent
def _load_route_module(name: str, relative_path: str):
spec = importlib.util.spec_from_file_location(name, _BACKEND_ROOT / relative_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class TestTrainingRawSupport(unittest.TestCase):
def test_training_backend_preserves_cpt_4bit_and_embedding_lr(self):
backend = TrainingBackend()
class DummyProcess:
pid = 12345
def start(self):
return None
class DummyThread:
def start(self):
return None
dummy_queue = object()
with (
patch(
"core.training.training.prepare_gpu_selection",
return_value = ([0], {"selection_mode": "auto"}),
),
patch(
"core.training.training._CTX.Queue",
side_effect = [dummy_queue, dummy_queue],
),
patch(
"core.training.training._CTX.Process", return_value = DummyProcess()
) as mock_process,
patch(
"core.training.training.threading.Thread",
return_value = DummyThread(),
),
):
backend.start_training(
job_id = "test-cpt-raw",
model_name = "unsloth/test-bnb-4bit",
training_type = "Continued Pretraining",
format_type = "raw",
load_in_4bit = True,
embedding_learning_rate = 1e-5,
)
config = mock_process.call_args.kwargs["kwargs"]["config"]
self.assertTrue(config["load_in_4bit"])
self.assertEqual(config["embedding_learning_rate"], 1e-5)
def test_training_backend_forwards_grad_clipping_controls(self):
backend = TrainingBackend()
class DummyProcess:
pid = 12345
def start(self):
return None
class DummyThread:
def start(self):
return None
dummy_queue = object()
with (
patch(
"core.training.training.prepare_gpu_selection",
return_value = ([0], {"selection_mode": "auto"}),
),
patch(
"core.training.training._CTX.Queue",
side_effect = [dummy_queue, dummy_queue],
),
patch(
"core.training.training._CTX.Process", return_value = DummyProcess()
) as mock_process,
patch(
"core.training.training.threading.Thread",
return_value = DummyThread(),
),
):
backend.start_training(
job_id = "test-grad-clip",
model_name = "unsloth/test",
training_type = "LoRA/QLoRA",
max_grad_norm = 0.7,
max_grad_value = 3.0,
max_grad_leaf_norm = 1.3,
)
config = mock_process.call_args.kwargs["kwargs"]["config"]
self.assertEqual(config["max_grad_norm"], 0.7)
self.assertEqual(config["max_grad_value"], 3.0)
self.assertEqual(config["max_grad_leaf_norm"], 1.3)
def test_training_backend_forwards_random_seed_without_internal_mlx_seed_keys(self):
backend = TrainingBackend()
class DummyProcess:
pid = 12345
def start(self):
return None
class DummyThread:
def start(self):
return None
dummy_queue = object()
with (
patch(
"core.training.training.prepare_gpu_selection",
return_value = ([0], {"selection_mode": "auto"}),
),
patch(
"core.training.training._CTX.Queue",
side_effect = [dummy_queue, dummy_queue],
),
patch(
"core.training.training._CTX.Process", return_value = DummyProcess()
) as mock_process,
patch(
"core.training.training.threading.Thread",
return_value = DummyThread(),
),
):
backend.start_training(
job_id = "test-seed",
model_name = "unsloth/test",
training_type = "LoRA/QLoRA",
random_seed = 1234,
)
config = mock_process.call_args.kwargs["kwargs"]["config"]
self.assertEqual(config["random_seed"], 1234)
self.assertNotIn("model_random_state", config)
self.assertNotIn("lora_random_state", config)
def test_route_forwards_all_grad_clipping_fields(self):
# The HTTP route builds the config dict by hand; a schema field that
# is not forwarded here is silently dropped for REST callers.
source = (_BACKEND_ROOT / "routes" / "training.py").read_text()
self.assertIn('"max_grad_norm": request.max_grad_norm', source)
self.assertIn('"max_grad_value": request.max_grad_value', source)
self.assertIn('"max_grad_leaf_norm": request.max_grad_leaf_norm', source)
def test_mlx_worker_falls_back_init_seeds_to_random_seed(self):
source = (_BACKEND_ROOT / "core" / "training" / "worker.py").read_text()
# random_seed itself is normalized first so explicit None coming
# from a raw / backend caller does not propagate through the chain.
self.assertIn('_raw_seed = config.get("random_seed", 3407)', source)
self.assertIn(
"random_seed = 3407 if _raw_seed is None else int(_raw_seed)",
source,
)
# Both absent and explicit None must fall back to random_seed.
# `dict.get(key, default)` only fills the default on absent keys,
# so an explicit `None` would otherwise reach FastMLXModel /
# get_peft_model and disable deterministic init.
self.assertIn('_model_seed = config.get("model_random_state")', source)
self.assertIn(
"model_random_state = random_seed if _model_seed is None else int(_model_seed)",
source,
)
self.assertIn('_lora_seed = config.get("lora_random_state")', source)
self.assertIn(
"lora_random_state = random_seed if _lora_seed is None else int(_lora_seed)",
source,
)
self.assertIn("random_state = model_random_state", source)
self.assertIn("random_state = lora_random_state", source)
# MLXTrainingConfig now receives the normalized seed directly.
self.assertIn("seed = random_seed,", source)
def test_mlx_worker_preserves_null_max_grad_value_for_trainer_default(self):
source = (_BACKEND_ROOT / "core" / "training" / "worker.py").read_text()
# None must survive to the MLX trainer so it picks its own runtime
# default, and any other value must coerce to float without
# rebinding None to 1.0 (which the legacy code did).
self.assertIn('max_grad_value = config.get("max_grad_value")', source)
self.assertIn("max_grad_value = float(max_grad_value)", source)
self.assertNotIn(
"max_grad_value = 1.0 if max_grad_value is None else float(max_grad_value)",
source,
)
def test_training_backend_normalizes_explicit_none_seed_and_dtypes(self):
# Raw / backend callers can pass `random_seed=None`,
# `cast_norm_output_to_input_dtype=None`, and MLX clip knobs
# as None (or omit them) and must NOT leak the
# `None` past `TrainingBackend.start_training`. Otherwise
# transformers.set_seed(None) raises, PEFT init becomes
# nondeterministic, and the MLX norm-output cast silently flips.
from core.training.training import (
_coerce_seed,
_coerce_optional_bool,
_coerce_optional_nonneg_float,
)
self.assertEqual(_coerce_seed(None), 3407)
self.assertEqual(_coerce_seed("123"), 123)
self.assertEqual(_coerce_seed("not-a-number"), 3407)
self.assertTrue(_coerce_optional_bool(None, True))
self.assertFalse(_coerce_optional_bool(None, False))
self.assertFalse(_coerce_optional_bool("false", True))
self.assertTrue(_coerce_optional_bool("true", False))
self.assertIsNone(_coerce_optional_nonneg_float("max_grad_value", None))
self.assertEqual(_coerce_optional_nonneg_float("max_grad_value", "2.5"), 2.5)
self.assertEqual(_coerce_optional_nonneg_float("max_grad_value", 0), 0.0)
with self.assertRaises(ValueError):
_coerce_optional_nonneg_float("max_grad_value", -1)
self.assertIsNone(_coerce_optional_nonneg_float("max_grad_leaf_norm", None))
self.assertEqual(
_coerce_optional_nonneg_float("max_grad_leaf_norm", "1.3"),
1.3,
)
with self.assertRaises(ValueError):
_coerce_optional_nonneg_float("max_grad_leaf_norm", -1)
def test_mlx_worker_feature_detects_optional_mlx_config_fields(self):
# `cast_norm_output_to_input_dtype`, `dataset_order`,
# `max_grad_leaf_norm`, and `append_eos` ship in the paired
# unsloth-zoo update. Until that floor is in place, the
# worker must gate them so releases that predate those fields can
# still construct MLXTrainingConfig without TypeError.
source = (_BACKEND_ROOT / "core" / "training" / "worker.py").read_text()
self.assertIn(
'getattr(MLXTrainingConfig, "__dataclass_fields__", {})',
source,
)
self.assertIn('if "cast_norm_output_to_input_dtype" in _supported_fields:', source)
self.assertIn('if "dataset_order" in _supported_fields:', source)
self.assertIn('if "max_grad_leaf_norm" in _supported_fields:', source)
self.assertIn(
'mlx_config_kwargs["max_grad_leaf_norm"] = max_grad_leaf_norm',
source,
)
self.assertIn('if "append_eos" in _supported_fields:', source)
self.assertIn('format_type == "raw"', source)
self.assertIn('mlx_config_kwargs["append_eos"] = bool(raw_text_mode)', source)
# The unconditional kwargs must NOT include any gated field.
# Use proper paren tracking; `source.find(")", ...)` would stop at
# the first close paren inside the dict body (e.g.
# `int(config.get("save_steps", 0) or 0)`) and miss any future
# unconditional addition of the gated fields later in the dict.
unconditional_block_start = source.find("mlx_config_kwargs = dict(")
self.assertNotEqual(unconditional_block_start, -1)
depth = 0
i = unconditional_block_start + len("mlx_config_kwargs = dict")
end = i
while i < len(source):
ch = source[i]
if ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if depth == 0:
end = i + 1
break
i += 1
unconditional = source[unconditional_block_start:end]
self.assertNotIn("cast_norm_output_to_input_dtype", unconditional)
self.assertNotIn("dataset_order", unconditional)
self.assertNotIn("max_grad_leaf_norm", unconditional)
self.assertNotIn("append_eos", unconditional)
def test_training_route_forwards_embedding_learning_rate(self):
training_route = _load_route_module(
"training_route_module_raw_support",
"routes/training.py",
)
captured: dict = {}
class DummyBackend:
current_job_id = None
def is_training_active(self):
return False
def start_training(self, **kwargs):
captured.update(kwargs)
return True
request = TrainingStartRequest(
model_name = "unsloth/test-bnb-4bit",
training_type = "Continued Pretraining",
format_type = "raw",
load_in_4bit = True,
embedding_learning_rate = 1e-5,
)
with (
patch.object(
training_route,
"get_training_backend",
return_value = DummyBackend(),
),
patch.object(training_route, "load_model_defaults", return_value = {}),
patch(
"core.inference.get_inference_backend",
return_value = type(
"InferenceBackend",
(),
{"active_model_name": None},
)(),
),
patch(
"core.export.get_export_backend",
return_value = type(
"ExportBackend",
(),
{"current_checkpoint": None},
)(),
),
):
response = asyncio.run(
training_route.start_training(request, current_subject = "test-user")
)
self.assertEqual(response.status, "queued")
self.assertEqual(captured["embedding_learning_rate"], 1e-5)
self.assertTrue(captured["load_in_4bit"])
def test_format_dataset_supports_raw_text(self):
dataset = Dataset.from_dict(
{
"body": ["hello", "world"],
"title": ["a", "b"],
"id": [1, 2],
}
)
result = format_dataset(dataset, format_type = "raw")
self.assertEqual(result["final_format"], "raw_text")
self.assertIn("text", result["dataset"].column_names)
self.assertEqual(result["dataset"][0]["text"], "hello")
self.assertFalse(result["requires_manual_mapping"])
def test_format_and_template_dataset_supports_raw_text_without_template(self):
dataset = Dataset.from_dict({"body": ["hello raw world"]})
result = format_and_template_dataset(
dataset,
model_name = "unsloth/test",
tokenizer = None,
format_type = "raw",
)
self.assertTrue(result["success"])
self.assertEqual(result["final_format"], "raw_text")
self.assertEqual(result["dataset"][0]["text"], "hello raw world")
def test_prepare_raw_text_dataset_drops_null_rows_before_appending_eos(self):
dataset = Dataset.from_dict({"text": ["hello", None, "world"]})
result = prepare_raw_text_dataset(
dataset,
mode_label = "CPT",
split_name = "train",
eos_token = "<eos>",
append_eos = True,
)
self.assertEqual(len(result.dataset), 2)
self.assertEqual(result.dataset[0]["text"], "hello<eos>")
self.assertEqual(result.dataset[1]["text"], "world<eos>")
self.assertTrue(
any("null or non-string 'text' values" in notice.message for notice in result.notices)
)
if __name__ == "__main__":
unittest.main()