"""Tests for pretrain task — config, plaintext format, template, routing, sweep.""" from unittest.mock import MagicMock from unittest.mock import patch as mock_patch import pytest from pydantic import ValidationError from soup_cli.config.schema import TEMPLATES, SoupConfig # ─── Config Tests ─────────────────────────────────────────────────────────── class TestPretrainConfig: """Test pretrain task config validation.""" def test_pretrain_task_accepted(self): """pretrain task should be a valid task type.""" cfg = SoupConfig( base="some-model", task="pretrain", data={"train": "./data.jsonl"}, ) assert cfg.task == "pretrain" def test_pretrain_default_config(self): """pretrain task should use default training config values.""" cfg = SoupConfig( base="some-model", task="pretrain", data={"train": "./data.jsonl"}, ) assert cfg.training.epochs == 3 assert cfg.training.lr == pytest.approx(2e-5) def test_pretrain_with_plaintext_format(self): """pretrain task with plaintext format should validate correctly.""" cfg = SoupConfig( base="some-model", task="pretrain", data={"train": "./corpus.txt", "format": "plaintext"}, ) assert cfg.data.format == "plaintext" def test_plaintext_format_accepted(self): """plaintext should be a valid data format.""" cfg = SoupConfig( base="some-model", task="sft", data={"train": "./data.jsonl", "format": "plaintext"}, ) assert cfg.data.format == "plaintext" def test_pretrain_full_config(self): """Full pretrain config should validate correctly.""" cfg = SoupConfig( base="meta-llama/Llama-3.1-8B", task="pretrain", data={"train": "./corpus.jsonl", "format": "plaintext", "max_length": 4096}, training={ "epochs": 1, "lr": 1e-5, "gradient_accumulation_steps": 8, "lora": {"r": 64, "alpha": 16}, "quantization": "4bit", }, ) assert cfg.task == "pretrain" assert cfg.data.max_length == 4096 assert cfg.training.gradient_accumulation_steps == 8 def test_pretrain_unsloth_backend(self): """pretrain task with unsloth backend should validate correctly.""" cfg = SoupConfig( base="some-model", task="pretrain", backend="unsloth", data={"train": "./data.jsonl"}, ) assert cfg.backend == "unsloth" assert cfg.task == "pretrain" # ─── MoE Config Tests ────────────────────────────────────────────────────── class TestMoEConfig: """Test MoE-specific config fields.""" def test_moe_lora_default_false(self): """moe_lora should default to False.""" cfg = SoupConfig( base="some-model", data={"train": "./data.jsonl"}, ) assert cfg.training.moe_lora is False def test_moe_lora_enabled(self): """moe_lora should be settable to True.""" cfg = SoupConfig( base="some-model", data={"train": "./data.jsonl"}, training={"moe_lora": True}, ) assert cfg.training.moe_lora is True def test_moe_aux_loss_coeff_default(self): """moe_aux_loss_coeff should default to 0.01.""" cfg = SoupConfig( base="some-model", data={"train": "./data.jsonl"}, ) assert cfg.training.moe_aux_loss_coeff == pytest.approx(0.01) def test_moe_aux_loss_coeff_custom(self): """Custom moe_aux_loss_coeff should be accepted.""" cfg = SoupConfig( base="some-model", data={"train": "./data.jsonl"}, training={"moe_aux_loss_coeff": 0.05}, ) assert cfg.training.moe_aux_loss_coeff == pytest.approx(0.05) def test_moe_aux_loss_coeff_zero_allowed(self): """moe_aux_loss_coeff=0 should be valid (disables aux loss).""" cfg = SoupConfig( base="some-model", data={"train": "./data.jsonl"}, training={"moe_aux_loss_coeff": 0.0}, ) assert cfg.training.moe_aux_loss_coeff == 0.0 def test_moe_aux_loss_coeff_negative_rejected(self): """Negative moe_aux_loss_coeff should be rejected.""" with pytest.raises(ValidationError): SoupConfig( base="some-model", data={"train": "./data.jsonl"}, training={"moe_aux_loss_coeff": -0.1}, ) # ─── Plaintext Data Format Tests ────────────────────────────────────────── class TestPlaintextDataFormat: """Test plaintext data format detection and conversion.""" def test_format_signature_exists(self): """plaintext format signature should be registered.""" from soup_cli.data.formats import FORMAT_SIGNATURES assert "plaintext" in FORMAT_SIGNATURES assert FORMAT_SIGNATURES["plaintext"] == {"text"} def test_detect_plaintext_format(self): """Should auto-detect plaintext format from data keys.""" from soup_cli.data.formats import detect_format data = [{"text": "This is a document."}] assert detect_format(data) == "plaintext" def test_detect_plaintext_with_extra_keys(self): """Should detect plaintext format even with extra keys.""" from soup_cli.data.formats import detect_format data = [{"text": "Document content.", "id": 1, "source": "web"}] assert detect_format(data) == "plaintext" def test_convert_plaintext(self): """Should convert plaintext row correctly.""" from soup_cli.data.formats import format_to_messages row = {"text": "This is raw text for pre-training."} result = format_to_messages(row, "plaintext") assert result["text"] == "This is raw text for pre-training." def test_convert_plaintext_preserves_text_only(self): """Output should have exactly the 'text' key.""" from soup_cli.data.formats import format_to_messages row = {"text": "content", "id": 42, "source": "web"} result = format_to_messages(row, "plaintext") assert set(result.keys()) == {"text"} def test_convert_plaintext_empty_text_returns_none(self): """Empty text should cause conversion to return None.""" from soup_cli.data.formats import format_to_messages row = {"text": ""} result = format_to_messages(row, "plaintext") assert result is None def test_convert_plaintext_whitespace_only_returns_none(self): """Whitespace-only text should cause conversion to return None.""" from soup_cli.data.formats import format_to_messages row = {"text": " \n "} result = format_to_messages(row, "plaintext") assert result is None def test_convert_plaintext_missing_text_returns_none(self): """Row missing 'text' key should return None.""" from soup_cli.data.formats import format_to_messages row = {"content": "some text"} result = format_to_messages(row, "plaintext") assert result is None def test_plaintext_not_confused_with_chatml(self): """Data with only 'text' key should not be detected as chatml.""" from soup_cli.data.formats import detect_format data = [{"text": "plain content"}] assert detect_format(data) == "plaintext" def test_chatml_not_detected_as_plaintext(self): """Data with 'messages' key should be detected as chatml, not plaintext.""" from soup_cli.data.formats import detect_format data = [{"messages": [{"role": "user", "content": "hello"}]}] assert detect_format(data) == "chatml" # ─── Text File Loading Tests ────────────────────────────────────────────── class TestTxtFileLoading: """Test .txt file loading.""" def test_load_txt_file(self, tmp_path): """Should load .txt file as list of {text: ...} dicts.""" from soup_cli.data.loader import load_raw_data txt_file = tmp_path / "corpus.txt" txt_file.write_text("Line one\nLine two\nLine three\n", encoding="utf-8") data = load_raw_data(txt_file) assert len(data) == 3 assert data[0] == {"text": "Line one"} assert data[1] == {"text": "Line two"} assert data[2] == {"text": "Line three"} def test_load_txt_skips_empty_lines(self, tmp_path): """Empty lines should be skipped.""" from soup_cli.data.loader import load_raw_data txt_file = tmp_path / "corpus.txt" txt_file.write_text("Line one\n\n\nLine two\n", encoding="utf-8") data = load_raw_data(txt_file) assert len(data) == 2 assert data[0] == {"text": "Line one"} assert data[1] == {"text": "Line two"} def test_load_txt_empty_file(self, tmp_path): """Empty .txt file should return empty list.""" from soup_cli.data.loader import load_raw_data txt_file = tmp_path / "empty.txt" txt_file.write_text("", encoding="utf-8") data = load_raw_data(txt_file) assert data == [] def test_txt_extension_supported(self): """'.txt' should be in SUPPORTED_EXTENSIONS.""" from soup_cli.data.loader import SUPPORTED_EXTENSIONS assert ".txt" in SUPPORTED_EXTENSIONS def test_load_txt_strips_whitespace(self, tmp_path): """Lines should have leading/trailing whitespace stripped.""" from soup_cli.data.loader import load_raw_data txt_file = tmp_path / "corpus.txt" txt_file.write_text(" hello \n world \n", encoding="utf-8") data = load_raw_data(txt_file) assert data[0] == {"text": "hello"} assert data[1] == {"text": "world"} # ─── Template Tests ────────────────────────────────────────────────────── class TestPretrainTemplate: """Test the pretrain template.""" def test_pretrain_template_exists(self): assert "pretrain" in TEMPLATES def test_pretrain_template_valid_yaml(self): import yaml config = yaml.safe_load(TEMPLATES["pretrain"]) assert config["task"] == "pretrain" assert config["data"]["format"] == "plaintext" def test_pretrain_template_valid_config(self): import yaml raw = yaml.safe_load(TEMPLATES["pretrain"]) cfg = SoupConfig(**raw) assert cfg.task == "pretrain" assert cfg.data.format == "plaintext" def test_moe_template_exists(self): assert "moe" in TEMPLATES def test_moe_template_valid_yaml(self): import yaml config = yaml.safe_load(TEMPLATES["moe"]) assert config["training"]["moe_lora"] is True assert config["training"]["moe_aux_loss_coeff"] == 0.01 def test_moe_template_valid_config(self): import yaml raw = yaml.safe_load(TEMPLATES["moe"]) cfg = SoupConfig(**raw) assert cfg.training.moe_lora is True assert cfg.training.moe_aux_loss_coeff == pytest.approx(0.01) # ─── Train Command Routing Tests ────────────────────────────────────────── class TestPretrainTrainRouting: """Test that train command routes to pretrain trainer.""" def test_pretrain_import_exists(self): """PretrainTrainerWrapper should be importable.""" from soup_cli.trainer.pretrain import PretrainTrainerWrapper assert PretrainTrainerWrapper is not None def test_pretrain_wrapper_init(self): """PretrainTrainerWrapper should initialize without error.""" from soup_cli.trainer.pretrain import PretrainTrainerWrapper cfg = SoupConfig( base="some-model", task="pretrain", data={"train": "./data.jsonl"}, ) wrapper = PretrainTrainerWrapper(cfg, device="cpu") assert wrapper.config.task == "pretrain" assert wrapper.device == "cpu" assert wrapper.model is None assert wrapper.trainer is None def test_pretrain_wrapper_init_with_options(self): """PretrainTrainerWrapper should accept all constructor options.""" from soup_cli.trainer.pretrain import PretrainTrainerWrapper cfg = SoupConfig( base="some-model", task="pretrain", data={"train": "./data.jsonl"}, ) wrapper = PretrainTrainerWrapper( cfg, device="cuda", report_to="wandb", deepspeed_config="ds.json", ) assert wrapper.report_to == "wandb" assert wrapper.deepspeed_config == "ds.json" # ─── Sweep Shortcut Tests ───────────────────────────────────────────────── class TestPretrainSweepParams: """Test pretrain/MoE parameter shortcuts in sweep.""" def test_moe_lora_shortcut(self): from soup_cli.commands.sweep import _set_nested_param config = {"training": {"moe_lora": False}} _set_nested_param(config, "moe_lora", True) assert config["training"]["moe_lora"] is True def test_moe_aux_loss_coeff_shortcut(self): from soup_cli.commands.sweep import _set_nested_param config = {"training": {"moe_aux_loss_coeff": 0.01}} _set_nested_param(config, "moe_aux_loss_coeff", 0.05) assert config["training"]["moe_aux_loss_coeff"] == pytest.approx(0.05) def test_moe_lora_shortcut_creates_nested_key(self): from soup_cli.commands.sweep import _set_nested_param config = {} _set_nested_param(config, "moe_lora", True) assert config["training"]["moe_lora"] is True def test_sweep_run_single_routes_to_pretrain_trainer(self): """_run_single should instantiate PretrainTrainerWrapper for pretrain task.""" from soup_cli.commands.sweep import _run_single cfg = SoupConfig( base="some-model", task="pretrain", data={"train": "./data.jsonl"}, ) fake_dataset = { "train": [{"text": "Some pre-training text."}] } fake_result = { "initial_loss": 3.0, "final_loss": 2.5, "total_steps": 10, "duration_secs": 120.0, "output_dir": "./output", "duration": "2m", } fake_gpu_info = {"memory_total": "0 MB", "memory_total_bytes": 0} with mock_patch("soup_cli.data.loader.load_dataset", return_value=fake_dataset), \ mock_patch("soup_cli.utils.gpu.detect_device", return_value=("cpu", "CPU")), \ mock_patch("soup_cli.utils.gpu.get_gpu_info", return_value=fake_gpu_info), \ mock_patch("soup_cli.experiment.tracker.ExperimentTracker") as mock_tracker_cls, \ mock_patch("soup_cli.monitoring.display.TrainingDisplay"), \ mock_patch("soup_cli.trainer.pretrain.PretrainTrainerWrapper.setup"), \ mock_patch( "soup_cli.trainer.pretrain.PretrainTrainerWrapper.train", return_value=fake_result, ) as mock_train: mock_tracker = MagicMock() mock_tracker.start_run.return_value = "run-pretrain-1" mock_tracker_cls.return_value = mock_tracker result = _run_single(cfg, {}, "pretrain_run_1", None) mock_train.assert_called_once() assert result["run_id"] == "run-pretrain-1" # ─── Train Guard Test ──────────────────────────────────────────────────── class TestPretrainTrainGuard: """Test the RuntimeError guard when train() is called before setup().""" def test_train_before_setup_raises_runtime_error(self): """Calling train() before setup() should raise RuntimeError.""" from soup_cli.trainer.pretrain import PretrainTrainerWrapper cfg = SoupConfig( base="some-model", task="pretrain", data={"train": "./data.jsonl"}, ) wrapper = PretrainTrainerWrapper(cfg) with pytest.raises(RuntimeError, match="setup\\(dataset\\) first"): wrapper.train() def test_train_error_message_mentions_setup(self): """RuntimeError message should mention setup().""" from soup_cli.trainer.pretrain import PretrainTrainerWrapper cfg = SoupConfig( base="some-model", task="pretrain", data={"train": "./data.jsonl"}, ) wrapper = PretrainTrainerWrapper(cfg) with pytest.raises(RuntimeError) as exc_info: wrapper.train() assert "setup" in str(exc_info.value).lower() # ─── Train Method Result Structure ────────────────────────────────────────── class TestPretrainTrainResults: """Test the result dict returned by train() using a mocked trainer.""" def _make_wrapper_with_mock_trainer(self, log_history=None, global_step=20): """Helper: return a PretrainTrainerWrapper with trainer pre-injected.""" from soup_cli.trainer.pretrain import PretrainTrainerWrapper cfg = SoupConfig( base="some-model", task="pretrain", data={"train": "./data.jsonl"}, output="./output", ) wrapper = PretrainTrainerWrapper(cfg, device="cpu") mock_trainer = MagicMock() mock_trainer.train = MagicMock() mock_trainer.state.log_history = log_history if log_history is not None else [] mock_trainer.state.global_step = global_step mock_trainer.save_model = MagicMock() wrapper.trainer = mock_trainer wrapper.tokenizer = MagicMock() wrapper._output_dir = "./output" return wrapper, mock_trainer def test_train_returns_expected_keys(self): """train() result dict must contain all expected keys.""" wrapper, _ = self._make_wrapper_with_mock_trainer( log_history=[{"loss": 3.0}, {"loss": 2.5}], global_step=10 ) result = wrapper.train() assert "initial_loss" in result assert "final_loss" in result assert "duration" in result assert "duration_secs" in result assert "output_dir" in result assert "total_steps" in result def test_train_result_losses_from_log_history(self): """initial_loss and final_loss should come from trainer log_history.""" wrapper, _ = self._make_wrapper_with_mock_trainer( log_history=[{"loss": 3.0}, {"loss": 2.5}, {"loss": 2.0}], global_step=30 ) result = wrapper.train() assert result["initial_loss"] == pytest.approx(3.0) assert result["final_loss"] == pytest.approx(2.0) def test_train_result_empty_log_history_returns_zero_losses(self): """When log_history has no 'loss' entries, losses should be 0.""" wrapper, _ = self._make_wrapper_with_mock_trainer(log_history=[], global_step=0) result = wrapper.train() assert result["initial_loss"] == 0 assert result["final_loss"] == 0 def test_train_result_total_steps_from_trainer_state(self): """total_steps should match trainer.state.global_step.""" wrapper, _ = self._make_wrapper_with_mock_trainer( log_history=[{"loss": 2.0}], global_step=42 ) result = wrapper.train() assert result["total_steps"] == 42 def test_train_result_output_dir_matches(self): """output_dir in result should match wrapper._output_dir.""" wrapper, _ = self._make_wrapper_with_mock_trainer() result = wrapper.train() assert result["output_dir"] == "./output" def test_train_calls_save_model(self): """train() should call trainer.save_model with output_dir.""" wrapper, mock_trainer = self._make_wrapper_with_mock_trainer( log_history=[{"loss": 2.0}], global_step=5 ) wrapper.train() mock_trainer.save_model.assert_called_once_with("./output") def test_train_calls_tokenizer_save_pretrained(self): """train() should call tokenizer.save_pretrained with output_dir.""" wrapper, _ = self._make_wrapper_with_mock_trainer( log_history=[{"loss": 2.0}], global_step=5 ) wrapper.train() wrapper.tokenizer.save_pretrained.assert_called_once_with("./output") def test_train_passes_resume_checkpoint_to_trainer(self): """train() should forward resume_from_checkpoint to trainer.train().""" wrapper, mock_trainer = self._make_wrapper_with_mock_trainer( log_history=[{"loss": 2.0}], global_step=5 ) wrapper.train(resume_from_checkpoint="/ckpt/checkpoint-50") mock_trainer.train.assert_called_once_with( resume_from_checkpoint="/ckpt/checkpoint-50" ) def test_train_result_duration_minutes_format(self): """Short durations (<1h) should produce 'Xm' format.""" wrapper, _ = self._make_wrapper_with_mock_trainer( log_history=[{"loss": 2.0}], global_step=5 ) call_count = [0] def fake_time(): call_count[0] += 1 return 0 if call_count[0] == 1 else 90 with mock_patch("soup_cli.trainer.pretrain.time.time", side_effect=fake_time): result = wrapper.train() assert result["duration"] == "1m" def test_train_result_duration_hours_format(self): """Long durations (>=1h) should produce 'Xh Ym' format.""" wrapper, _ = self._make_wrapper_with_mock_trainer( log_history=[{"loss": 2.0}], global_step=100 ) call_count = [0] def fake_time(): call_count[0] += 1 return 0 if call_count[0] == 1 else 3720 # 1h 2m with mock_patch("soup_cli.trainer.pretrain.time.time", side_effect=fake_time): result = wrapper.train() assert result["duration"] == "1h 2m" # ─── CLI Init Template Tests ────────────────────────────────────────────── class TestPretrainInitTemplate: """Test that soup init produces correct output for pretrain.""" def test_init_pretrain_template_creates_file(self, tmp_path): """soup init --template pretrain should write a file with pretrain task.""" from typer.testing import CliRunner from soup_cli.cli import app runner = CliRunner() output = tmp_path / "soup.yaml" result = runner.invoke( app, ["init", "--template", "pretrain", "--output", str(output)] ) assert result.exit_code == 0 assert output.exists() content = output.read_text() assert "task: pretrain" in content assert "format: plaintext" in content def test_init_pretrain_template_produces_valid_config(self, tmp_path): """The file written by soup init --template pretrain should parse.""" from pathlib import Path from typer.testing import CliRunner from soup_cli.cli import app from soup_cli.config.loader import load_config runner = CliRunner() output = tmp_path / "soup.yaml" runner.invoke( app, ["init", "--template", "pretrain", "--output", str(output)] ) cfg = load_config(Path(output)) assert cfg.task == "pretrain" assert cfg.data.format == "plaintext" def test_init_moe_template_creates_file(self, tmp_path): """soup init --template moe should write a file with moe_lora.""" from typer.testing import CliRunner from soup_cli.cli import app runner = CliRunner() output = tmp_path / "soup.yaml" result = runner.invoke( app, ["init", "--template", "moe", "--output", str(output)] ) assert result.exit_code == 0 content = output.read_text() assert "moe_lora: true" in content # ─── Wizard Pretrain Path Tests ────────────────────────────────────────── class TestPretrainWizardPath: """Test the interactive wizard auto-sets format for pretrain task.""" def test_wizard_pretrain_task_sets_plaintext_format(self): """When the wizard receives task=pretrain, data format should be 'plaintext'.""" from soup_cli.commands.init import _interactive_wizard with mock_patch("soup_cli.commands.init.Prompt.ask", side_effect=[ "some-model", "pretrain", "./corpus.txt", "1", "yes", ]): config_text = _interactive_wizard() assert "task: pretrain" in config_text assert "format: plaintext" in config_text def test_wizard_pretrain_does_not_prompt_for_format(self): """The wizard should NOT ask for data format when task=pretrain.""" from soup_cli.commands.init import _interactive_wizard prompt_calls = [] def record_prompt(question, **kwargs): prompt_calls.append(question) answers = { "Base model": "some-model", "Task": "pretrain", "Training data path": "./corpus.txt", "Epochs": "1", "Use QLoRA (4-bit)?": "yes", } return answers.get(question, kwargs.get("default", "")) with mock_patch("soup_cli.commands.init.Prompt.ask", side_effect=record_prompt): config_text = _interactive_wizard() # "Data format" prompt should not appear when task is pretrain assert not any("format" in call.lower() for call in prompt_calls) assert "format: plaintext" in config_text # ─── Config Loader Round-trip Tests ────────────────────────────────────── class TestPretrainConfigLoaderRoundTrip: """Test pretrain template YAML survives round-trip through load_config_from_string.""" def test_pretrain_template_round_trip(self): """TEMPLATES['pretrain'] should parse via load_config_from_string.""" from soup_cli.config.loader import load_config_from_string cfg = load_config_from_string(TEMPLATES["pretrain"]) assert cfg.task == "pretrain" assert cfg.data.format == "plaintext" def test_moe_template_round_trip(self): """TEMPLATES['moe'] should parse via load_config_from_string.""" from soup_cli.config.loader import load_config_from_string cfg = load_config_from_string(TEMPLATES["moe"]) assert cfg.training.moe_lora is True assert cfg.training.moe_aux_loss_coeff == pytest.approx(0.01) def test_pretrain_custom_yaml_round_trip(self): """Custom pretrain YAML string should round-trip correctly.""" from soup_cli.config.loader import load_config_from_string yaml_str = """ base: custom-model/llama-7b task: pretrain data: train: ./corpus.jsonl format: plaintext max_length: 4096 training: epochs: 1 lr: 1e-5 quantization: none output: ./pretrain_output """ cfg = load_config_from_string(yaml_str) assert cfg.task == "pretrain" assert cfg.data.format == "plaintext" assert cfg.data.max_length == 4096 assert cfg.output == "./pretrain_output" # ─── MoE Integration in _setup_transformers Tests ───────────────────────── class TestPretrainMoEIntegration: """Test MoE-aware setup logic in PretrainTrainerWrapper._setup_transformers.""" def test_moe_lora_calls_get_moe_target_modules(self): """When moe_lora=True and model is MoE, get_moe_target_modules should be called.""" cfg = SoupConfig( base="some-model", task="pretrain", data={"train": "./data.jsonl"}, training={"moe_lora": True, "quantization": "none"}, ) mock_model = MagicMock() mock_model.get_nb_trainable_parameters.return_value = (1000, 10000) with mock_patch("transformers.AutoModelForCausalLM.from_pretrained", return_value=mock_model), \ mock_patch("transformers.AutoTokenizer.from_pretrained"), \ mock_patch("peft.get_peft_model", return_value=mock_model), \ mock_patch("peft.LoraConfig"), \ mock_patch("peft.prepare_model_for_kbit_training"), \ mock_patch( "soup_cli.utils.moe.detect_moe_model", return_value=True ), \ mock_patch( "soup_cli.utils.moe.get_moe_target_modules", return_value=["q_proj", "v_proj", "gate_proj", "up_proj"], ) as mock_moe_targets: from soup_cli.trainer.pretrain import PretrainTrainerWrapper wrapper = PretrainTrainerWrapper(cfg, device="cpu") wrapper._setup_transformers(cfg, cfg.training) mock_moe_targets.assert_called_once_with(mock_model) def test_moe_aux_loss_coeff_sets_router_config(self): """When MoE detected and moe_aux_loss_coeff > 0, router config should be set.""" cfg = SoupConfig( base="some-model", task="pretrain", data={"train": "./data.jsonl"}, training={"moe_aux_loss_coeff": 0.05, "quantization": "none"}, ) mock_model = MagicMock() mock_model.get_nb_trainable_parameters.return_value = (1000, 10000) mock_model.config.router_aux_loss_coef = 0.01 mock_model.config.output_router_logits = False with mock_patch("transformers.AutoModelForCausalLM.from_pretrained", return_value=mock_model), \ mock_patch("transformers.AutoTokenizer.from_pretrained"), \ mock_patch("peft.get_peft_model", return_value=mock_model), \ mock_patch("peft.LoraConfig"), \ mock_patch("peft.prepare_model_for_kbit_training"), \ mock_patch( "soup_cli.utils.moe.detect_moe_model", return_value=True ), \ mock_patch("soup_cli.utils.moe.get_moe_target_modules", return_value=None): from soup_cli.trainer.pretrain import PretrainTrainerWrapper wrapper = PretrainTrainerWrapper(cfg, device="cpu") wrapper._setup_transformers(cfg, cfg.training) assert mock_model.config.router_aux_loss_coef == 0.05 assert mock_model.config.output_router_logits is True def test_moe_lora_non_moe_model_skips_targets(self): """When moe_lora=True but model is not MoE, should fall back to auto.""" cfg = SoupConfig( base="some-model", task="pretrain", data={"train": "./data.jsonl"}, training={"moe_lora": True, "quantization": "none"}, ) mock_model = MagicMock() mock_model.get_nb_trainable_parameters.return_value = (1000, 10000) with mock_patch("transformers.AutoModelForCausalLM.from_pretrained", return_value=mock_model), \ mock_patch("transformers.AutoTokenizer.from_pretrained"), \ mock_patch("peft.get_peft_model", return_value=mock_model), \ mock_patch("peft.LoraConfig"), \ mock_patch("peft.prepare_model_for_kbit_training"), \ mock_patch( "soup_cli.utils.moe.detect_moe_model", return_value=False ), \ mock_patch( "soup_cli.utils.moe.get_moe_target_modules", return_value=None, ) as mock_moe_targets: from soup_cli.trainer.pretrain import PretrainTrainerWrapper wrapper = PretrainTrainerWrapper(cfg, device="cpu") wrapper._setup_transformers(cfg, cfg.training) # MoE targets should NOT be called since model is not MoE mock_moe_targets.assert_not_called() # ─── Plaintext Line-level Chunking Test ─────────────────────────────────── class TestPlaintextLineChunking: """Verify .txt files use line-level chunking (not paragraph-level).""" def test_double_newlines_produce_separate_line_docs(self, tmp_path): """Double newlines are skipped — each non-empty line is a separate doc.""" from soup_cli.data.loader import load_raw_data txt_file = tmp_path / "corpus.txt" txt_file.write_text( "Para one line one\n\nPara two line one\n", encoding="utf-8" ) data = load_raw_data(txt_file) assert len(data) == 2 assert data[0] == {"text": "Para one line one"} assert data[1] == {"text": "Para two line one"} def test_unicode_text_loading(self, tmp_path): """Unicode content should load correctly.""" from soup_cli.data.loader import load_raw_data txt_file = tmp_path / "unicode.txt" txt_file.write_text("日本語テスト\n中文测试\nعربي\n", encoding="utf-8") data = load_raw_data(txt_file) assert len(data) == 3 assert data[0] == {"text": "日本語テスト"} assert data[1] == {"text": "中文测试"} assert data[2] == {"text": "عربي"}