# 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 = "", append_eos = True, ) self.assertEqual(len(result.dataset), 2) self.assertEqual(result.dataset[0]["text"], "hello") self.assertEqual(result.dataset[1]["text"], "world") self.assertTrue( any("null or non-string 'text' values" in notice.message for notice in result.notices) ) if __name__ == "__main__": unittest.main()