mirror of https://github.com/razor-ai/soup.git
1211 lines
43 KiB
Python
1211 lines
43 KiB
Python
"""v0.52.0 Modality II — TTS + Distillation + BitNet + EBFT + GDPO + MoE + reasoning_effort.
|
|
|
|
Schema-only test suite. Live wiring deferred to v0.52.1. Mirrors v0.50.0 /
|
|
v0.51.0 single-file test layout (test_v0500_part_X.py was per-Part; v0.51.0
|
|
collapsed into one test_v0510.py; v0.52.0 follows v0.51.0).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from types import MappingProxyType
|
|
|
|
import pytest
|
|
import typer
|
|
|
|
from soup_cli.config.loader import load_config_from_string
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Part A — TTS
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestTTSUtils:
|
|
def test_supported_families_frozenset(self):
|
|
from soup_cli.utils.tts import SUPPORTED_TTS_FAMILIES
|
|
|
|
assert isinstance(SUPPORTED_TTS_FAMILIES, frozenset)
|
|
assert SUPPORTED_TTS_FAMILIES == {
|
|
"orpheus", "sesame_csm", "llasa", "spark", "oute",
|
|
}
|
|
|
|
@pytest.mark.parametrize(
|
|
"name", ["orpheus", "ORPHEUS", "Sesame_CSM", "llasa", "spark", "oute"],
|
|
)
|
|
def test_validate_family_canonical(self, name):
|
|
from soup_cli.utils.tts import validate_tts_family
|
|
|
|
assert validate_tts_family(name) == name.lower()
|
|
|
|
@pytest.mark.parametrize(
|
|
"bad,exc", [
|
|
(True, TypeError),
|
|
(123, TypeError),
|
|
("", ValueError),
|
|
("orph\x00eus", ValueError),
|
|
("x" * 100, ValueError),
|
|
("unknown_family", ValueError),
|
|
],
|
|
)
|
|
def test_validate_family_rejects(self, bad, exc):
|
|
from soup_cli.utils.tts import validate_tts_family
|
|
|
|
with pytest.raises(exc):
|
|
validate_tts_family(bad)
|
|
|
|
def test_family_metadata_frozen(self):
|
|
from soup_cli.utils.tts import get_tts_family_spec
|
|
|
|
spec = get_tts_family_spec("orpheus")
|
|
with pytest.raises(Exception):
|
|
spec.name = "evil" # type: ignore[misc]
|
|
|
|
def test_supports_emotion(self):
|
|
from soup_cli.utils.tts import family_supports_emotion
|
|
|
|
assert family_supports_emotion("orpheus") is True
|
|
assert family_supports_emotion("oute") is True
|
|
assert family_supports_emotion("llasa") is False
|
|
|
|
def test_emotion_tag_orpheus_happy(self):
|
|
from soup_cli.utils.tts import validate_emotion_tag
|
|
|
|
assert validate_emotion_tag("happy", family="orpheus") == "happy"
|
|
assert validate_emotion_tag("HAPPY", family="orpheus") == "happy"
|
|
|
|
def test_emotion_tag_orpheus_unknown(self):
|
|
from soup_cli.utils.tts import validate_emotion_tag
|
|
|
|
with pytest.raises(ValueError, match="orpheus allowlist"):
|
|
validate_emotion_tag("euphoric", family="orpheus")
|
|
|
|
def test_emotion_tag_unsupported_family(self):
|
|
from soup_cli.utils.tts import validate_emotion_tag
|
|
|
|
with pytest.raises(ValueError, match="does not support emotion"):
|
|
validate_emotion_tag("happy", family="llasa")
|
|
|
|
def test_emotion_tag_bool_rejected(self):
|
|
from soup_cli.utils.tts import validate_emotion_tag
|
|
|
|
with pytest.raises(TypeError):
|
|
validate_emotion_tag(True, family="orpheus")
|
|
|
|
def test_validate_tts_compat_happy(self):
|
|
from soup_cli.utils.tts import validate_tts_compat
|
|
|
|
validate_tts_compat(task="tts", modality="audio_out", backend="transformers")
|
|
|
|
@pytest.mark.parametrize(
|
|
"kwargs,match", [
|
|
({"task": "sft", "modality": "audio_out", "backend": "transformers"}, "tts"),
|
|
({"task": "tts", "modality": "text", "backend": "transformers"}, "audio_out"),
|
|
({"task": "tts", "modality": "audio_out", "backend": "mlx"}, "mlx"),
|
|
],
|
|
)
|
|
def test_validate_tts_compat_rejects(self, kwargs, match):
|
|
from soup_cli.utils.tts import validate_tts_compat
|
|
|
|
with pytest.raises(ValueError, match=match):
|
|
validate_tts_compat(**kwargs)
|
|
|
|
def test_build_tts_trainer_deferred(self):
|
|
from soup_cli.utils.tts import build_tts_trainer
|
|
|
|
with pytest.raises(NotImplementedError, match="v0.52.1"):
|
|
build_tts_trainer()
|
|
|
|
|
|
class TestTTSSchemaIntegration:
|
|
def test_task_tts_happy(self):
|
|
yaml = """
|
|
base: canopylabs/orpheus-tts
|
|
task: tts
|
|
modality: audio_out
|
|
data: {train: ./d.jsonl}
|
|
training:
|
|
tts_family: orpheus
|
|
tts_emotion: happy
|
|
"""
|
|
cfg = load_config_from_string(yaml)
|
|
assert cfg.task == "tts"
|
|
assert cfg.modality == "audio_out"
|
|
assert cfg.training.tts_family == "orpheus"
|
|
assert cfg.training.tts_emotion == "happy"
|
|
|
|
def test_task_tts_without_family_rejected(self):
|
|
yaml = """
|
|
base: foo
|
|
task: tts
|
|
modality: audio_out
|
|
data: {train: ./d.jsonl}
|
|
"""
|
|
with pytest.raises(Exception, match="tts_family"):
|
|
load_config_from_string(yaml)
|
|
|
|
def test_tts_family_without_task_rejected(self):
|
|
yaml = """
|
|
base: foo
|
|
task: sft
|
|
data: {train: ./d.jsonl}
|
|
training:
|
|
tts_family: orpheus
|
|
"""
|
|
with pytest.raises(Exception, match="tts"):
|
|
load_config_from_string(yaml)
|
|
|
|
def test_tts_emotion_unsupported_family_rejected(self):
|
|
yaml = """
|
|
base: foo
|
|
task: tts
|
|
modality: audio_out
|
|
data: {train: ./d.jsonl}
|
|
training:
|
|
tts_family: llasa
|
|
tts_emotion: happy
|
|
"""
|
|
with pytest.raises(Exception, match="does not support emotion"):
|
|
load_config_from_string(yaml)
|
|
|
|
def test_audio_out_modality_accepted(self):
|
|
# audio_out paired with non-TTS task still loads (modality alone
|
|
# doesn't force task='tts'); but in practice only TTS uses it.
|
|
# Defence-in-depth: test the Literal accepts it.
|
|
yaml = """
|
|
base: foo
|
|
task: sft
|
|
modality: audio_out
|
|
data: {train: ./d.jsonl, format: audio}
|
|
"""
|
|
cfg = load_config_from_string(yaml)
|
|
assert cfg.modality == "audio_out"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Part B — classifier / reranker / cross_encoder
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestClassifierUtils:
|
|
def test_classifier_tasks_frozenset(self):
|
|
from soup_cli.utils.classifier import CLASSIFIER_TASKS
|
|
|
|
assert CLASSIFIER_TASKS == {"classifier", "reranker", "cross_encoder"}
|
|
|
|
@pytest.mark.parametrize("task", ["classifier", "reranker", "cross_encoder"])
|
|
def test_is_classifier_task_true(self, task):
|
|
from soup_cli.utils.classifier import is_classifier_task
|
|
|
|
assert is_classifier_task(task) is True
|
|
|
|
@pytest.mark.parametrize("task", ["sft", "dpo", "", True, 123, None])
|
|
def test_is_classifier_task_false(self, task):
|
|
from soup_cli.utils.classifier import is_classifier_task
|
|
|
|
assert is_classifier_task(task) is False
|
|
|
|
def test_get_classifier_spec_paired_input(self):
|
|
from soup_cli.utils.classifier import get_classifier_spec
|
|
|
|
assert get_classifier_spec("cross_encoder").paired_input is True
|
|
assert get_classifier_spec("classifier").paired_input is False
|
|
|
|
def test_get_classifier_spec_unknown(self):
|
|
from soup_cli.utils.classifier import get_classifier_spec
|
|
|
|
with pytest.raises(ValueError, match="classifier task"):
|
|
get_classifier_spec("sft")
|
|
|
|
@pytest.mark.parametrize(
|
|
"value,exc", [
|
|
(True, TypeError),
|
|
("3", TypeError),
|
|
(None, TypeError),
|
|
(0, ValueError),
|
|
(-1, ValueError),
|
|
(2000, ValueError),
|
|
],
|
|
)
|
|
def test_validate_num_labels_rejects(self, value, exc):
|
|
from soup_cli.utils.classifier import validate_num_labels
|
|
|
|
with pytest.raises(exc):
|
|
validate_num_labels(value)
|
|
|
|
def test_validate_num_labels_happy(self):
|
|
from soup_cli.utils.classifier import validate_num_labels
|
|
|
|
assert validate_num_labels(3) == 3
|
|
assert validate_num_labels(1024) == 1024
|
|
|
|
def test_validate_label_names_dedup(self):
|
|
from soup_cli.utils.classifier import validate_label_names
|
|
|
|
with pytest.raises(ValueError, match="unique"):
|
|
validate_label_names(["a", "a", "b"])
|
|
|
|
@pytest.mark.parametrize(
|
|
"value,exc", [
|
|
("a", TypeError),
|
|
([True, "a"], TypeError),
|
|
([""], ValueError),
|
|
(["x\x00"], ValueError),
|
|
(["x" * 200], ValueError),
|
|
],
|
|
)
|
|
def test_validate_label_names_rejects(self, value, exc):
|
|
from soup_cli.utils.classifier import validate_label_names
|
|
|
|
with pytest.raises(exc):
|
|
validate_label_names(value)
|
|
|
|
def test_validate_label_names_defensive_copy(self):
|
|
from soup_cli.utils.classifier import validate_label_names
|
|
|
|
src = ["a", "b"]
|
|
out = validate_label_names(src)
|
|
assert out is not src
|
|
|
|
def test_validate_classifier_compat_mlx_reject(self):
|
|
from soup_cli.utils.classifier import validate_classifier_compat
|
|
|
|
with pytest.raises(ValueError, match="mlx"):
|
|
validate_classifier_compat(
|
|
task="classifier", backend="mlx", modality="text",
|
|
)
|
|
|
|
def test_validate_classifier_compat_non_text(self):
|
|
from soup_cli.utils.classifier import validate_classifier_compat
|
|
|
|
with pytest.raises(ValueError, match="text"):
|
|
validate_classifier_compat(
|
|
task="reranker", backend="transformers", modality="vision",
|
|
)
|
|
|
|
def test_build_classifier_trainer_lifted_in_v0532(self):
|
|
"""v0.52.0 shipped as a NotImplementedError stub; v0.53.2 #132 lifts it
|
|
to a live factory returning ClassifierTrainerWrapper. The argless call
|
|
now raises TypeError (missing ``config``) rather than NotImplementedError."""
|
|
from soup_cli.utils.classifier import build_classifier_trainer
|
|
|
|
with pytest.raises(TypeError):
|
|
build_classifier_trainer() # type: ignore[call-arg]
|
|
|
|
|
|
class TestClassifierSchema:
|
|
def test_classifier_happy(self):
|
|
cfg = load_config_from_string(
|
|
"base: foo\ntask: classifier\ndata: {train: ./d.jsonl}\n"
|
|
"training: {num_labels: 5, classifier_kind: single_label}\n"
|
|
)
|
|
assert cfg.task == "classifier"
|
|
assert cfg.training.num_labels == 5
|
|
|
|
@pytest.mark.parametrize("task", ["reranker", "cross_encoder"])
|
|
def test_reranker_and_cross_encoder_happy(self, task):
|
|
cfg = load_config_from_string(
|
|
f"base: foo\ntask: {task}\ndata: {{train: ./d.jsonl}}\n"
|
|
"training: {num_labels: 1}\n"
|
|
)
|
|
assert cfg.task == task
|
|
|
|
def test_classifier_label_names_mismatch_rejected(self):
|
|
yaml = (
|
|
"base: foo\ntask: classifier\ndata: {train: ./d.jsonl}\n"
|
|
"training:\n num_labels: 3\n label_names: [a, b]\n"
|
|
)
|
|
with pytest.raises(Exception, match="num_labels"):
|
|
load_config_from_string(yaml)
|
|
|
|
def test_num_labels_outside_classifier_rejected(self):
|
|
yaml = (
|
|
"base: foo\ntask: sft\ndata: {train: ./d.jsonl}\n"
|
|
"training: {num_labels: 3}\n"
|
|
)
|
|
with pytest.raises(Exception, match="classifier"):
|
|
load_config_from_string(yaml)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Part C — distillation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDistillUtils:
|
|
def test_divergence_canonical(self):
|
|
from soup_cli.utils.distill import validate_divergence
|
|
|
|
assert validate_divergence("kl") == "forward_kl"
|
|
assert validate_divergence("KL") == "forward_kl"
|
|
assert validate_divergence("forward_kl") == "forward_kl"
|
|
assert validate_divergence("reverse_kl") == "reverse_kl"
|
|
assert validate_divergence("js") == "js"
|
|
|
|
@pytest.mark.parametrize(
|
|
"bad,exc", [
|
|
(True, TypeError),
|
|
(None, TypeError),
|
|
("", ValueError),
|
|
("kl\x00", ValueError),
|
|
("k" * 100, ValueError),
|
|
("unknown", ValueError),
|
|
],
|
|
)
|
|
def test_validate_divergence_rejects(self, bad, exc):
|
|
from soup_cli.utils.distill import validate_divergence
|
|
|
|
with pytest.raises(exc):
|
|
validate_divergence(bad)
|
|
|
|
def test_get_divergence_spec_symmetric(self):
|
|
from soup_cli.utils.distill import get_divergence_spec
|
|
|
|
assert get_divergence_spec("js").symmetric is True
|
|
assert get_divergence_spec("forward_kl").symmetric is False
|
|
|
|
@pytest.mark.parametrize(
|
|
"value,exc", [
|
|
(True, TypeError),
|
|
("1.0", TypeError),
|
|
(float("nan"), ValueError),
|
|
(float("inf"), ValueError),
|
|
(0.0, ValueError),
|
|
(0.01, ValueError),
|
|
(101.0, ValueError),
|
|
],
|
|
)
|
|
def test_validate_distill_temperature_rejects(self, value, exc):
|
|
from soup_cli.utils.distill import validate_distill_temperature
|
|
|
|
with pytest.raises(exc):
|
|
validate_distill_temperature(value)
|
|
|
|
def test_validate_distill_temperature_happy(self):
|
|
from soup_cli.utils.distill import validate_distill_temperature
|
|
|
|
assert validate_distill_temperature(2.0) == 2.0
|
|
assert validate_distill_temperature(0.05) == 0.05
|
|
|
|
@pytest.mark.parametrize(
|
|
"bad,exc", [
|
|
(True, TypeError),
|
|
(123, TypeError),
|
|
("", ValueError),
|
|
("x\x00", ValueError),
|
|
("x" * 1000, ValueError),
|
|
],
|
|
)
|
|
def test_validate_teacher_model_rejects(self, bad, exc):
|
|
from soup_cli.utils.distill import validate_teacher_model
|
|
|
|
with pytest.raises(exc):
|
|
validate_teacher_model(bad)
|
|
|
|
def test_validate_distill_compat_no_teacher(self):
|
|
from soup_cli.utils.distill import validate_distill_compat
|
|
|
|
with pytest.raises(ValueError, match="teacher_model"):
|
|
validate_distill_compat(
|
|
task="distill", backend="transformers", teacher_model=None,
|
|
)
|
|
|
|
def test_validate_distill_compat_mlx(self):
|
|
from soup_cli.utils.distill import validate_distill_compat
|
|
|
|
with pytest.raises(ValueError, match="mlx"):
|
|
validate_distill_compat(
|
|
task="distill", backend="mlx", teacher_model="t/model",
|
|
)
|
|
|
|
def test_build_distill_trainer_lifted_in_v0532(self):
|
|
"""v0.52.0 shipped as a NotImplementedError stub; v0.53.2 #133 lifts it
|
|
to a live factory returning DistillTrainerWrapper. The argless call
|
|
now raises TypeError (missing ``config``) rather than NotImplementedError."""
|
|
from soup_cli.utils.distill import build_distill_trainer
|
|
|
|
with pytest.raises(TypeError):
|
|
build_distill_trainer()
|
|
|
|
|
|
class TestDistillSchema:
|
|
def test_distill_happy(self):
|
|
yaml = (
|
|
"base: foo\ntask: distill\ndata: {train: ./d.jsonl}\n"
|
|
"training:\n teacher_model: meta-llama/Llama-3.1-70B\n"
|
|
" distill_divergence: reverse_kl\n distill_temperature: 2.5\n"
|
|
)
|
|
cfg = load_config_from_string(yaml)
|
|
assert cfg.task == "distill"
|
|
assert cfg.training.distill_divergence == "reverse_kl"
|
|
assert cfg.training.distill_temperature == 2.5
|
|
|
|
def test_kl_alias_canonicalised(self):
|
|
yaml = (
|
|
"base: foo\ntask: distill\ndata: {train: ./d.jsonl}\n"
|
|
"training:\n teacher_model: t/m\n distill_divergence: kl\n"
|
|
)
|
|
cfg = load_config_from_string(yaml)
|
|
assert cfg.training.distill_divergence == "forward_kl"
|
|
|
|
def test_teacher_outside_distill_rejected(self):
|
|
yaml = (
|
|
"base: foo\ntask: sft\ndata: {train: ./d.jsonl}\n"
|
|
"training: {teacher_model: t/m}\n"
|
|
)
|
|
with pytest.raises(Exception, match="distill"):
|
|
load_config_from_string(yaml)
|
|
|
|
def test_distill_temperature_nan_rejected(self):
|
|
yaml = (
|
|
"base: foo\ntask: distill\ndata: {train: ./d.jsonl}\n"
|
|
"training:\n teacher_model: t/m\n distill_temperature: .nan\n"
|
|
)
|
|
with pytest.raises(Exception, match="finite"):
|
|
load_config_from_string(yaml)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Part D — BitNet 1.58
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBitNetUtils:
|
|
def test_bitnet_quant_formats_frozenset(self):
|
|
from soup_cli.utils.bitnet import BITNET_EXPORT_FORMATS, BITNET_QUANT_FORMATS
|
|
|
|
assert isinstance(BITNET_QUANT_FORMATS, frozenset)
|
|
assert isinstance(BITNET_EXPORT_FORMATS, frozenset)
|
|
assert BITNET_QUANT_FORMATS == {"bitnet_1.58"}
|
|
assert BITNET_EXPORT_FORMATS == {"bitnet", "tq1_0"}
|
|
|
|
@pytest.mark.parametrize(
|
|
"value,expected", [
|
|
("bitnet_1.58", True),
|
|
("4bit", False),
|
|
("", False),
|
|
(None, False),
|
|
(True, False),
|
|
(123, False),
|
|
],
|
|
)
|
|
def test_is_bitnet_quant(self, value, expected):
|
|
from soup_cli.utils.bitnet import is_bitnet_quant
|
|
|
|
assert is_bitnet_quant(value) is expected
|
|
|
|
def test_get_bitnet_spec(self):
|
|
from soup_cli.utils.bitnet import get_bitnet_spec
|
|
|
|
spec = get_bitnet_spec("bitnet_1.58")
|
|
assert spec.bits == 1.58
|
|
assert spec.live_wired is False
|
|
|
|
def test_get_bitnet_spec_unknown(self):
|
|
from soup_cli.utils.bitnet import get_bitnet_spec
|
|
|
|
with pytest.raises(ValueError, match="bitnet"):
|
|
get_bitnet_spec("4bit")
|
|
|
|
@pytest.mark.parametrize(
|
|
"name,expected", [
|
|
("microsoft/bitnet-b1.58-2B", True),
|
|
("tiiuae/Falcon-E-1B-Instruct", True),
|
|
("1bitllm/foo", True),
|
|
("OneBitLLM/falcon-e", True),
|
|
("meta-llama/Llama-3.1-8B", False),
|
|
("", False),
|
|
(None, False),
|
|
(True, False),
|
|
("evil\x00", False),
|
|
],
|
|
)
|
|
def test_is_bitnet_model(self, name, expected):
|
|
from soup_cli.utils.bitnet import is_bitnet_model
|
|
|
|
assert is_bitnet_model(name) is expected
|
|
|
|
def test_validate_bitnet_compat_mlx_reject(self):
|
|
from soup_cli.utils.bitnet import validate_bitnet_compat
|
|
|
|
with pytest.raises(ValueError, match="mlx"):
|
|
validate_bitnet_compat(task="sft", backend="mlx", modality="text")
|
|
|
|
def test_validate_bitnet_compat_vision_reject(self):
|
|
from soup_cli.utils.bitnet import validate_bitnet_compat
|
|
|
|
with pytest.raises(ValueError, match="text"):
|
|
validate_bitnet_compat(
|
|
task="sft", backend="transformers", modality="vision",
|
|
)
|
|
|
|
def test_validate_bitnet_compat_grpo_reject(self):
|
|
from soup_cli.utils.bitnet import validate_bitnet_compat
|
|
|
|
with pytest.raises(ValueError, match="task"):
|
|
validate_bitnet_compat(
|
|
task="grpo", backend="transformers", modality="text",
|
|
)
|
|
|
|
def test_validate_bitnet_export_canonical(self):
|
|
from soup_cli.utils.bitnet import validate_bitnet_export
|
|
|
|
assert validate_bitnet_export("bitnet") == "bitnet"
|
|
assert validate_bitnet_export("TQ1_0") == "tq1_0"
|
|
|
|
@pytest.mark.parametrize(
|
|
"bad,exc", [
|
|
(True, TypeError),
|
|
(None, TypeError),
|
|
("", ValueError),
|
|
("foo\x00", ValueError),
|
|
("Q4_K_M", ValueError),
|
|
],
|
|
)
|
|
def test_validate_bitnet_export_rejects(self, bad, exc):
|
|
from soup_cli.utils.bitnet import validate_bitnet_export
|
|
|
|
with pytest.raises(exc):
|
|
validate_bitnet_export(bad)
|
|
|
|
def test_build_bitnet_trainer_deferred(self):
|
|
from soup_cli.utils.bitnet import build_bitnet_trainer
|
|
|
|
with pytest.raises(NotImplementedError, match="v0.52.1"):
|
|
build_bitnet_trainer()
|
|
|
|
def test_export_bitnet_gguf_deferred(self):
|
|
from soup_cli.utils.bitnet import export_bitnet_gguf
|
|
|
|
with pytest.raises(NotImplementedError, match="v0.52.1"):
|
|
export_bitnet_gguf()
|
|
|
|
|
|
class TestBitNetSchema:
|
|
def test_bitnet_sft_happy(self):
|
|
cfg = load_config_from_string(
|
|
"base: tiiuae/Falcon-E-1B-Instruct\ntask: sft\n"
|
|
"data: {train: ./d.jsonl}\ntraining: {quantization: bitnet_1.58}\n"
|
|
)
|
|
assert cfg.training.quantization == "bitnet_1.58"
|
|
|
|
def test_bitnet_dpo_happy(self):
|
|
cfg = load_config_from_string(
|
|
"base: x\ntask: dpo\ndata: {train: ./d.jsonl}\n"
|
|
"training: {quantization: bitnet_1.58}\n"
|
|
)
|
|
assert cfg.training.quantization == "bitnet_1.58"
|
|
|
|
def test_bitnet_grpo_rejected(self):
|
|
yaml = (
|
|
"base: x\ntask: grpo\ndata: {train: ./d.jsonl}\n"
|
|
"training: {quantization: bitnet_1.58, reward_fn: accuracy, num_generations: 4}\n"
|
|
)
|
|
with pytest.raises(Exception, match="task"):
|
|
load_config_from_string(yaml)
|
|
|
|
def test_bitnet_mlx_rejected(self):
|
|
yaml = (
|
|
"base: x\ntask: sft\nbackend: mlx\ndata: {train: ./d.jsonl}\n"
|
|
"training: {quantization: bitnet_1.58}\n"
|
|
)
|
|
with pytest.raises(Exception, match="mlx"):
|
|
load_config_from_string(yaml)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Part E — EBFT + GDPO
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEbftGdpoUtils:
|
|
def test_ebft_variants(self):
|
|
from soup_cli.utils.ebft_gdpo import EBFT_VARIANTS, validate_ebft_variant
|
|
|
|
assert EBFT_VARIANTS == {"structured", "strided"}
|
|
assert validate_ebft_variant("structured") == "structured"
|
|
assert validate_ebft_variant("STRIDED") == "strided"
|
|
|
|
def test_gdpo_variants(self):
|
|
from soup_cli.utils.ebft_gdpo import GDPO_VARIANTS, validate_gdpo_variant
|
|
|
|
assert GDPO_VARIANTS == {"standard", "length_normalized", "margin"}
|
|
assert validate_gdpo_variant("Margin") == "margin"
|
|
|
|
@pytest.mark.parametrize(
|
|
"bad,exc", [
|
|
(True, TypeError),
|
|
(None, TypeError),
|
|
("", ValueError),
|
|
("foo\x00", ValueError),
|
|
("unknown", ValueError),
|
|
],
|
|
)
|
|
def test_validate_ebft_variant_rejects(self, bad, exc):
|
|
from soup_cli.utils.ebft_gdpo import validate_ebft_variant
|
|
|
|
with pytest.raises(exc):
|
|
validate_ebft_variant(bad)
|
|
|
|
def test_ebft_temperature_bounds(self):
|
|
from soup_cli.utils.ebft_gdpo import validate_ebft_temperature
|
|
|
|
assert validate_ebft_temperature(1.0) == 1.0
|
|
for bad in (True, float("nan"), float("inf"), 0.0, 1000.0):
|
|
with pytest.raises((TypeError, ValueError)):
|
|
validate_ebft_temperature(bad)
|
|
|
|
def test_validate_ebft_compat_dpo_rejected(self):
|
|
from soup_cli.utils.ebft_gdpo import validate_ebft_compat
|
|
|
|
with pytest.raises(ValueError, match="sft"):
|
|
validate_ebft_compat(task="dpo", backend="transformers")
|
|
|
|
def test_validate_gdpo_compat_sft_rejected(self):
|
|
from soup_cli.utils.ebft_gdpo import validate_gdpo_compat
|
|
|
|
with pytest.raises(ValueError, match="dpo"):
|
|
validate_gdpo_compat(task="sft", backend="transformers")
|
|
|
|
def test_get_ebft_spec(self):
|
|
# v0.53.2 #135 lifted EBFT + GDPO live_wired flags from False to True
|
|
# (kernel + attach hooks shipped).
|
|
from soup_cli.utils.ebft_gdpo import get_ebft_spec, get_gdpo_spec
|
|
|
|
assert get_ebft_spec("structured").live_wired is True
|
|
assert get_gdpo_spec("margin").live_wired is True
|
|
|
|
def test_apply_ebft_loss_lifted_in_v0532(self):
|
|
"""v0.52.0 shipped both as NotImplementedError stubs; v0.53.2 #135
|
|
lifts them to live tensor kernels. The argless invocation now raises
|
|
TypeError (missing required args) rather than NotImplementedError."""
|
|
from soup_cli.utils.ebft_gdpo import apply_ebft_loss, apply_gdpo_loss
|
|
|
|
with pytest.raises(TypeError):
|
|
apply_ebft_loss() # type: ignore[call-arg]
|
|
with pytest.raises(TypeError):
|
|
apply_gdpo_loss() # type: ignore[call-arg]
|
|
|
|
|
|
class TestEbftGdpoSchema:
|
|
def test_ebft_happy(self):
|
|
cfg = load_config_from_string(
|
|
"base: x\ntask: sft\ndata: {train: ./d.jsonl}\n"
|
|
"training: {ebft_variant: structured, ebft_temperature: 1.0}\n"
|
|
)
|
|
assert cfg.training.ebft_variant == "structured"
|
|
assert cfg.training.ebft_temperature == 1.0
|
|
|
|
def test_ebft_temp_requires_variant(self):
|
|
yaml = (
|
|
"base: x\ntask: sft\ndata: {train: ./d.jsonl}\n"
|
|
"training: {ebft_temperature: 2.0}\n"
|
|
)
|
|
with pytest.raises(Exception, match="ebft_variant"):
|
|
load_config_from_string(yaml)
|
|
|
|
def test_ebft_on_dpo_rejected(self):
|
|
yaml = (
|
|
"base: x\ntask: dpo\ndata: {train: ./d.jsonl}\n"
|
|
"training: {ebft_variant: strided}\n"
|
|
)
|
|
with pytest.raises(Exception, match="sft"):
|
|
load_config_from_string(yaml)
|
|
|
|
def test_gdpo_dpo_happy(self):
|
|
cfg = load_config_from_string(
|
|
"base: x\ntask: dpo\ndata: {train: ./d.jsonl}\n"
|
|
"training: {gdpo_variant: length_normalized}\n"
|
|
)
|
|
assert cfg.training.gdpo_variant == "length_normalized"
|
|
|
|
def test_gdpo_on_sft_rejected(self):
|
|
yaml = (
|
|
"base: x\ntask: sft\ndata: {train: ./d.jsonl}\n"
|
|
"training: {gdpo_variant: standard}\n"
|
|
)
|
|
with pytest.raises(Exception, match="dpo"):
|
|
load_config_from_string(yaml)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Part F — MoE expert quant + train_router_only
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMoeQuantUtils:
|
|
def test_moe_expert_quant_formats(self):
|
|
from soup_cli.utils.moe_quant import (
|
|
MOE_EXPERT_QUANT_FORMATS,
|
|
validate_moe_expert_quant,
|
|
)
|
|
|
|
assert MOE_EXPERT_QUANT_FORMATS == {"nf4", "int8_rowwise"}
|
|
assert validate_moe_expert_quant("NF4") == "nf4"
|
|
|
|
@pytest.mark.parametrize(
|
|
"bad,exc", [
|
|
(True, TypeError),
|
|
(None, TypeError),
|
|
("", ValueError),
|
|
("foo\x00", ValueError),
|
|
("unknown", ValueError),
|
|
],
|
|
)
|
|
def test_validate_moe_expert_quant_rejects(self, bad, exc):
|
|
from soup_cli.utils.moe_quant import validate_moe_expert_quant
|
|
|
|
with pytest.raises(exc):
|
|
validate_moe_expert_quant(bad)
|
|
|
|
def test_get_moe_expert_quant_spec_bits(self):
|
|
from soup_cli.utils.moe_quant import get_moe_expert_quant_spec
|
|
|
|
assert get_moe_expert_quant_spec("nf4").bits == 4
|
|
assert get_moe_expert_quant_spec("int8_rowwise").bits == 8
|
|
|
|
def test_moe_expert_quant_requires_moe_lora(self):
|
|
from soup_cli.utils.moe_quant import validate_moe_expert_quant_compat
|
|
|
|
with pytest.raises(ValueError, match="moe_lora"):
|
|
validate_moe_expert_quant_compat(
|
|
backend="transformers", moe_lora=False,
|
|
)
|
|
|
|
def test_train_router_only_requires_moe_lora(self):
|
|
from soup_cli.utils.moe_quant import validate_train_router_only_compat
|
|
|
|
with pytest.raises(ValueError, match="moe_lora"):
|
|
validate_train_router_only_compat(
|
|
backend="transformers", moe_lora=False,
|
|
)
|
|
|
|
def test_validate_moe_expert_quant_compat_mlx(self):
|
|
from soup_cli.utils.moe_quant import validate_moe_expert_quant_compat
|
|
|
|
with pytest.raises(ValueError, match="mlx"):
|
|
validate_moe_expert_quant_compat(backend="mlx", moe_lora=True)
|
|
|
|
def test_apply_moe_expert_quant_deferred(self):
|
|
from soup_cli.utils.moe_quant import apply_moe_expert_quant
|
|
|
|
with pytest.raises(NotImplementedError, match="v0.52.1"):
|
|
apply_moe_expert_quant()
|
|
|
|
|
|
class TestMoeQuantSchema:
|
|
def test_moe_expert_quant_happy(self):
|
|
cfg = load_config_from_string(
|
|
"base: x\ntask: sft\ndata: {train: ./d.jsonl}\n"
|
|
"training: {moe_lora: true, moe_expert_quant: nf4}\n"
|
|
)
|
|
assert cfg.training.moe_expert_quant == "nf4"
|
|
|
|
def test_moe_expert_quant_without_moe_lora_rejected(self):
|
|
yaml = (
|
|
"base: x\ntask: sft\ndata: {train: ./d.jsonl}\n"
|
|
"training: {moe_expert_quant: nf4}\n"
|
|
)
|
|
with pytest.raises(Exception, match="moe_lora"):
|
|
load_config_from_string(yaml)
|
|
|
|
def test_train_router_only_happy(self):
|
|
cfg = load_config_from_string(
|
|
"base: x\ntask: sft\ndata: {train: ./d.jsonl}\n"
|
|
"training: {moe_lora: true, train_router_only: true}\n"
|
|
)
|
|
assert cfg.training.train_router_only is True
|
|
|
|
def test_train_router_only_without_moe_lora_rejected(self):
|
|
yaml = (
|
|
"base: x\ntask: sft\ndata: {train: ./d.jsonl}\n"
|
|
"training: {train_router_only: true}\n"
|
|
)
|
|
with pytest.raises(Exception, match="moe_lora"):
|
|
load_config_from_string(yaml)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Part G — reasoning_effort + train_on_eot
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestReasoningEffortUtils:
|
|
def test_levels(self):
|
|
from soup_cli.utils.reasoning_effort import (
|
|
REASONING_EFFORT_LEVELS,
|
|
validate_reasoning_effort,
|
|
)
|
|
|
|
assert REASONING_EFFORT_LEVELS == {"low", "medium", "high"}
|
|
assert validate_reasoning_effort("LOW") == "low"
|
|
assert validate_reasoning_effort("medium") == "medium"
|
|
|
|
@pytest.mark.parametrize(
|
|
"bad,exc", [
|
|
(True, TypeError),
|
|
(None, TypeError),
|
|
("", ValueError),
|
|
("x\x00", ValueError),
|
|
("ultra", ValueError),
|
|
],
|
|
)
|
|
def test_validate_reasoning_effort_rejects(self, bad, exc):
|
|
from soup_cli.utils.reasoning_effort import validate_reasoning_effort
|
|
|
|
with pytest.raises(exc):
|
|
validate_reasoning_effort(bad)
|
|
|
|
|
|
class TestReasoningEffortSchema:
|
|
@pytest.mark.parametrize("level", ["low", "medium", "high"])
|
|
def test_reasoning_effort_happy(self, level):
|
|
cfg = load_config_from_string(
|
|
f"base: openai/gpt-oss-20b\ntask: sft\ndata: {{train: ./d.jsonl}}\n"
|
|
f"training: {{reasoning_effort: {level}}}\n"
|
|
)
|
|
assert cfg.training.reasoning_effort == level
|
|
|
|
def test_train_on_eot_default_false(self):
|
|
cfg = load_config_from_string(
|
|
"base: x\ntask: sft\ndata: {train: ./d.jsonl}\n"
|
|
)
|
|
assert cfg.training.train_on_eot is False
|
|
|
|
def test_train_on_eot_true(self):
|
|
cfg = load_config_from_string(
|
|
"base: x\ntask: sft\ndata: {train: ./d.jsonl}\n"
|
|
"training: {train_on_eot: true}\n"
|
|
)
|
|
assert cfg.training.train_on_eot is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cross-cutting: immutability + module surface
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestModuleSurface:
|
|
def test_tts_metadata_mapping_proxy(self):
|
|
from soup_cli.utils.tts import _TTS_FAMILY_METADATA # type: ignore
|
|
|
|
assert isinstance(_TTS_FAMILY_METADATA, MappingProxyType)
|
|
|
|
def test_classifier_metadata_mapping_proxy(self):
|
|
from soup_cli.utils.classifier import _CLASSIFIER_METADATA # type: ignore
|
|
|
|
assert isinstance(_CLASSIFIER_METADATA, MappingProxyType)
|
|
|
|
def test_distill_metadata_mapping_proxy(self):
|
|
from soup_cli.utils.distill import ( # type: ignore
|
|
_DIVERGENCE_ALIASES,
|
|
_DIVERGENCE_METADATA,
|
|
)
|
|
|
|
assert isinstance(_DIVERGENCE_METADATA, MappingProxyType)
|
|
assert isinstance(_DIVERGENCE_ALIASES, MappingProxyType)
|
|
|
|
def test_bitnet_metadata_mapping_proxy(self):
|
|
from soup_cli.utils.bitnet import _BITNET_METADATA # type: ignore
|
|
|
|
assert isinstance(_BITNET_METADATA, MappingProxyType)
|
|
|
|
def test_ebft_gdpo_metadata_mapping_proxy(self):
|
|
from soup_cli.utils.ebft_gdpo import ( # type: ignore
|
|
_EBFT_METADATA,
|
|
_GDPO_METADATA,
|
|
)
|
|
|
|
assert isinstance(_EBFT_METADATA, MappingProxyType)
|
|
assert isinstance(_GDPO_METADATA, MappingProxyType)
|
|
|
|
def test_moe_quant_metadata_mapping_proxy(self):
|
|
from soup_cli.utils.moe_quant import ( # type: ignore
|
|
_MOE_EXPERT_QUANT_METADATA,
|
|
)
|
|
|
|
assert isinstance(_MOE_EXPERT_QUANT_METADATA, MappingProxyType)
|
|
|
|
|
|
class TestV0520Recipes:
|
|
NEW_RECIPES = (
|
|
"orpheus-tts-sft",
|
|
"sesame-csm-tts",
|
|
"llasa-tts",
|
|
"spark-tts",
|
|
"oute-tts",
|
|
"falcon-e-bitnet-sft",
|
|
)
|
|
|
|
@pytest.mark.parametrize("name", NEW_RECIPES)
|
|
def test_recipe_loads(self, name):
|
|
from soup_cli.recipes.catalog import RECIPES
|
|
|
|
recipe = RECIPES[name]
|
|
cfg = load_config_from_string(recipe.yaml_str)
|
|
assert cfg.base == recipe.model
|
|
|
|
@pytest.mark.parametrize(
|
|
"name,expected_family", [
|
|
("orpheus-tts-sft", "orpheus"),
|
|
("sesame-csm-tts", "sesame_csm"),
|
|
("llasa-tts", "llasa"),
|
|
("spark-tts", "spark"),
|
|
("oute-tts", "oute"),
|
|
],
|
|
)
|
|
def test_tts_recipe_family(self, name, expected_family):
|
|
from soup_cli.recipes.catalog import RECIPES
|
|
|
|
cfg = load_config_from_string(RECIPES[name].yaml_str)
|
|
assert cfg.training.tts_family == expected_family
|
|
assert cfg.task == "tts"
|
|
assert cfg.modality == "audio_out"
|
|
|
|
def test_falcon_e_bitnet_quant(self):
|
|
from soup_cli.recipes.catalog import RECIPES
|
|
|
|
cfg = load_config_from_string(RECIPES["falcon-e-bitnet-sft"].yaml_str)
|
|
assert cfg.training.quantization == "bitnet_1.58"
|
|
|
|
def test_total_catalog_size_grew(self):
|
|
from soup_cli.recipes.catalog import RECIPES
|
|
|
|
# v0.51.0 shipped 106; v0.52.0 adds 6 (5 TTS + Falcon-E BitNet).
|
|
assert len(RECIPES) >= 112
|
|
|
|
|
|
class TestTddReviewGaps:
|
|
"""v0.52.0 TDD-review-pass coverage of gaps surfaced after the first cut."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"bad,exc", [
|
|
(True, TypeError),
|
|
(None, TypeError),
|
|
("", ValueError),
|
|
("foo\x00", ValueError),
|
|
("x" * 33, ValueError),
|
|
("unknown", ValueError),
|
|
],
|
|
)
|
|
def test_validate_ebft_variant_oversize_etc(self, bad, exc):
|
|
from soup_cli.utils.ebft_gdpo import validate_ebft_variant
|
|
|
|
with pytest.raises(exc):
|
|
validate_ebft_variant(bad)
|
|
|
|
@pytest.mark.parametrize(
|
|
"bad,exc", [
|
|
(True, TypeError),
|
|
(None, TypeError),
|
|
("", ValueError),
|
|
("foo\x00", ValueError),
|
|
("x" * 33, ValueError),
|
|
("unknown", ValueError),
|
|
],
|
|
)
|
|
def test_validate_gdpo_variant_full_matrix(self, bad, exc):
|
|
from soup_cli.utils.ebft_gdpo import validate_gdpo_variant
|
|
|
|
with pytest.raises(exc):
|
|
validate_gdpo_variant(bad)
|
|
|
|
@pytest.mark.parametrize(
|
|
"bad,exc", [
|
|
(True, TypeError),
|
|
("1.0", TypeError),
|
|
(float("nan"), ValueError),
|
|
(float("inf"), ValueError),
|
|
(0.0, ValueError),
|
|
(1000.0, ValueError),
|
|
],
|
|
)
|
|
def test_validate_ebft_temperature_explicit_exc(self, bad, exc):
|
|
from soup_cli.utils.ebft_gdpo import validate_ebft_temperature
|
|
|
|
with pytest.raises(exc):
|
|
validate_ebft_temperature(bad)
|
|
|
|
@pytest.mark.parametrize(
|
|
"kwargs,exc", [
|
|
({"task": "", "modality": "audio_out", "backend": "transformers"}, ValueError),
|
|
({"task": "tts\x00", "modality": "audio_out", "backend": "transformers"}, ValueError),
|
|
({"task": True, "modality": "audio_out", "backend": "transformers"}, TypeError),
|
|
({"task": "tts", "modality": "", "backend": "transformers"}, ValueError),
|
|
({"task": "tts", "modality": "audio_out", "backend": ""}, ValueError),
|
|
({"task": "tts", "modality": "audio_out", "backend": False}, TypeError),
|
|
],
|
|
)
|
|
def test_validate_tts_compat_input_guards(self, kwargs, exc):
|
|
from soup_cli.utils.tts import validate_tts_compat
|
|
|
|
with pytest.raises(exc):
|
|
validate_tts_compat(**kwargs)
|
|
|
|
@pytest.mark.parametrize("task", ["grpo", "pretrain", "ppo", "embedding", "tts"])
|
|
def test_reasoning_effort_task_gate_full_matrix(self, task):
|
|
# ``pretrain`` is in the SFT-family allowlist so it should accept.
|
|
# All other non-SFT-family tasks must reject.
|
|
sft_family = {"sft", "pretrain", "distill", "classifier", "reranker", "cross_encoder"}
|
|
# Need backend / modality / data to be valid; for tts we also need family.
|
|
extra = ""
|
|
modality = ""
|
|
if task == "tts":
|
|
modality = "modality: audio_out\n"
|
|
extra = " tts_family: orpheus\n"
|
|
if task == "grpo":
|
|
extra = " reward_fn: accuracy\n num_generations: 4\n"
|
|
yaml = (
|
|
f"base: x\ntask: {task}\n{modality}data: {{train: ./d.jsonl}}\n"
|
|
f"training:\n reasoning_effort: low\n{extra}"
|
|
)
|
|
if task in sft_family:
|
|
cfg = load_config_from_string(yaml)
|
|
assert cfg.training.reasoning_effort == "low"
|
|
else:
|
|
with pytest.raises(Exception, match="reasoning_effort"):
|
|
load_config_from_string(yaml)
|
|
|
|
def test_train_on_eot_int_one_is_bool(self):
|
|
# YAML "1" parses as int; Pydantic bool field coerces 0/1.
|
|
# This is the documented Pydantic behaviour we accept; the
|
|
# task-gate is what protects against silent no-op.
|
|
cfg = load_config_from_string(
|
|
"base: x\ntask: sft\ndata: {train: ./d.jsonl}\n"
|
|
"training: {train_on_eot: 1}\n"
|
|
)
|
|
assert cfg.training.train_on_eot is True
|
|
|
|
def test_tts_recipe_model_id_no_null_or_whitespace(self):
|
|
# Drift guard mirroring tests/test_v0510.py model-id safety check.
|
|
from soup_cli.recipes.catalog import RECIPES
|
|
|
|
new = (
|
|
"orpheus-tts-sft", "sesame-csm-tts", "llasa-tts",
|
|
"spark-tts", "oute-tts", "falcon-e-bitnet-sft",
|
|
)
|
|
for name in new:
|
|
base = RECIPES[name].model
|
|
assert base, f"{name}: model is empty"
|
|
assert "\x00" not in base, f"{name}: model contains null byte"
|
|
assert base.strip() == base, f"{name}: model has surrounding whitespace"
|
|
for part in base.split("/"):
|
|
assert part, f"{name}: model has empty path component"
|
|
|
|
|
|
class TestBitnetExportCli:
|
|
"""v0.52.0 Part D — `soup export --format bitnet/tq1_0` stub CLI."""
|
|
|
|
def _runner(self):
|
|
from typer.testing import CliRunner
|
|
|
|
from soup_cli.commands.export import export
|
|
|
|
app = typer.Typer()
|
|
app.command()(export)
|
|
return CliRunner(), app
|
|
|
|
def test_export_format_bitnet_lists_in_help(self):
|
|
from soup_cli.commands.export import SUPPORTED_FORMATS
|
|
|
|
assert "bitnet" in SUPPORTED_FORMATS
|
|
assert "tq1_0" in SUPPORTED_FORMATS
|
|
|
|
def test_export_format_bitnet_stub_exits_zero(self, tmp_path):
|
|
runner, app = self._runner()
|
|
model = tmp_path / "fake-model"
|
|
model.mkdir()
|
|
result = runner.invoke(app, ["--model", str(model), "--format", "bitnet"])
|
|
assert result.exit_code == 0, (result.output, repr(result.exception))
|
|
assert "bitnet" in result.output.lower()
|
|
assert "v0.52.1" in result.output
|
|
|
|
def test_export_format_tq1_0_stub_exits_zero(self, tmp_path):
|
|
runner, app = self._runner()
|
|
model = tmp_path / "fake-model"
|
|
model.mkdir()
|
|
result = runner.invoke(app, ["--model", str(model), "--format", "tq1_0"])
|
|
assert result.exit_code == 0, (result.output, repr(result.exception))
|
|
assert "tq1_0" in result.output.lower()
|
|
|
|
|
|
class TestReviewFixes:
|
|
"""Coverage of the review fixes applied between v0.52.0 first cut + ship."""
|
|
|
|
def test_num_labels_bool_rejected_at_schema(self):
|
|
# Pydantic ge=1 accepts True (subclass of int); the explicit
|
|
# field_validator(mode="before") rejects bool.
|
|
yaml = (
|
|
"base: x\ntask: classifier\ndata: {train: ./d.jsonl}\n"
|
|
"training: {num_labels: true}\n"
|
|
)
|
|
with pytest.raises(Exception, match="num_labels"):
|
|
load_config_from_string(yaml)
|
|
|
|
def test_reasoning_effort_canonicalised_via_validator(self):
|
|
cfg = load_config_from_string(
|
|
"base: x\ntask: sft\ndata: {train: ./d.jsonl}\n"
|
|
"training: {reasoning_effort: HIGH}\n"
|
|
)
|
|
assert cfg.training.reasoning_effort == "high"
|
|
|
|
def test_reasoning_effort_task_gate(self):
|
|
yaml = (
|
|
"base: x\ntask: dpo\ndata: {train: ./d.jsonl}\n"
|
|
"training: {reasoning_effort: high}\n"
|
|
)
|
|
with pytest.raises(Exception, match="reasoning_effort"):
|
|
load_config_from_string(yaml)
|
|
|
|
def test_train_on_eot_task_gate(self):
|
|
yaml = (
|
|
"base: x\ntask: dpo\ndata: {train: ./d.jsonl}\n"
|
|
"training: {train_on_eot: true}\n"
|
|
)
|
|
with pytest.raises(Exception, match="train_on_eot"):
|
|
load_config_from_string(yaml)
|
|
|
|
def test_oute_emotion_allowlist(self):
|
|
from soup_cli.utils.tts import validate_emotion_tag
|
|
|
|
assert validate_emotion_tag("happy", family="oute") == "happy"
|
|
with pytest.raises(ValueError, match="oute allowlist"):
|
|
validate_emotion_tag("demonic", family="oute")
|
|
|
|
def test_distill_divergence_literal_excludes_kl(self):
|
|
# The Literal-stored value is always the canonical form; "kl" is
|
|
# accepted at parse time (alias) but the field never holds "kl".
|
|
cfg = load_config_from_string(
|
|
"base: x\ntask: distill\ndata: {train: ./d.jsonl}\n"
|
|
"training: {teacher_model: t/m, distill_divergence: kl}\n"
|
|
)
|
|
assert cfg.training.distill_divergence == "forward_kl"
|
|
|
|
def test_divergences_derived_from_aliases(self):
|
|
from soup_cli.utils.distill import _DIVERGENCE_ALIASES, DIVERGENCES
|
|
|
|
# Drift guard — adding a new alias updates both surfaces.
|
|
assert DIVERGENCES == set(_DIVERGENCE_ALIASES.keys())
|
|
|
|
def test_validate_tts_compat_bool_rejected(self):
|
|
from soup_cli.utils.tts import validate_tts_compat
|
|
|
|
with pytest.raises(TypeError):
|
|
validate_tts_compat(task=True, modality="audio_out", backend="transformers")
|
|
|
|
def test_validate_moe_quant_bool_moe_lora_rejected(self):
|
|
from soup_cli.utils.moe_quant import validate_moe_expert_quant_compat
|
|
|
|
with pytest.raises(TypeError):
|
|
validate_moe_expert_quant_compat(backend="transformers", moe_lora=1) # type: ignore[arg-type]
|
|
|
|
|
|
def test_v0520_finite_helper():
|
|
"""Sanity guard: distill temperature must use math.isfinite (not just le)."""
|
|
from soup_cli.utils.distill import validate_distill_temperature
|
|
|
|
# math.isfinite is the canonical rejector; Pydantic le=100 also rejects inf
|
|
# but only NaN slips through Field bounds incidentally.
|
|
assert math.isfinite(2.0)
|
|
with pytest.raises(ValueError, match="finite"):
|
|
validate_distill_temperature(float("nan"))
|