The whisper.cpp ladder keyed both GPU backends to one provisional pair of thresholds. Issue #19 (comment 12913) measured each rung's transcribe-time VRAM delta on the installed build. The thresholds are those deltas plus the 512 MiB desktop headroom: - vulkan: q8_0 1644 MiB, q5_0 1359 MiB (was 1644 and 1358) - cuda: q8_0 1690 MiB, q5_0 1404 MiB select_ladder_rung and resolve_startup_rung take the installed build's backend. CPU and unknown backends keep the Vulkan thresholds; the RAM rule proposed in #19 is not implemented. The first-run default in config_manager now reads the memory of the installed build (VRAM for a GPU build, RAM otherwise). It used to read VRAM whenever an NVIDIA card was present, even on a CPU build.
530 lines
22 KiB
Python
530 lines
22 KiB
Python
"""Ticket #20: memory-aware model ladder, conditional backend order, retry.
|
|
|
|
All behavior is exercised with mocked probes and loaders: no GPU, no
|
|
network, no hardware. The threshold tests pin the per-backend rung thresholds
|
|
to the ticket #19 installed-build measurement (issue #19, comment 12913): the
|
|
transcribe-time VRAM delta of each rung plus the 512 MiB protocol headroom.
|
|
Re-measuring changes those constants, and this file must change with them.
|
|
"""
|
|
|
|
import os
|
|
from contextlib import ExitStack
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from vocalinux.speech_recognition import model_ladder
|
|
from vocalinux.speech_recognition.model_ladder import (
|
|
DEFAULT_RUNG,
|
|
FLOOR_RUNG_ENGLISH,
|
|
FLOOR_RUNG_MULTILINGUAL,
|
|
HEADROOM_MIB,
|
|
LOW_MEMORY_RUNG,
|
|
PREFER_CUDA_Q5_0_ABOVE_MIB,
|
|
PREFER_CUDA_Q8_0_ABOVE_MIB,
|
|
PREFER_Q5_0_ABOVE_MIB,
|
|
PREFER_Q8_0_ABOVE_MIB,
|
|
cuda_toolkit_ready_from_probe,
|
|
next_rung_down,
|
|
preferred_backend_order,
|
|
resolve_startup_rung,
|
|
select_ladder_rung,
|
|
)
|
|
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
|
|
from vocalinux.utils.whispercpp_model_info import WHISPERCPP_MODEL_INFO
|
|
|
|
|
|
def _size_mb(name: str) -> float:
|
|
return float(WHISPERCPP_MODEL_INFO[name]["size_mb"])
|
|
|
|
|
|
class TestThresholdDerivation:
|
|
"""Thresholds are the ticket #19 recorded transcribe-time deltas plus headroom.
|
|
|
|
The inputs are copied from issue #19, comment 12913 ("Per-rung load
|
|
footprint", transcribe delta column), not recomputed from the code.
|
|
"""
|
|
|
|
#: Transcribe-time VRAM delta (MiB), peak minus before, per backend and rung.
|
|
VULKAN_Q8_0_DELTA_MIB: float = 1132.0
|
|
VULKAN_Q5_0_DELTA_MIB: float = 847.0
|
|
CUDA_Q8_0_DELTA_MIB: float = 1178.0
|
|
CUDA_Q5_0_DELTA_MIB: float = 892.0
|
|
|
|
def test_headroom_is_the_protocol_value(self) -> None:
|
|
assert HEADROOM_MIB == 512.0
|
|
|
|
def test_vulkan_q8_threshold_is_measured_delta_plus_headroom(self) -> None:
|
|
assert PREFER_Q8_0_ABOVE_MIB == self.VULKAN_Q8_0_DELTA_MIB + HEADROOM_MIB
|
|
assert PREFER_Q8_0_ABOVE_MIB == 1644.0
|
|
|
|
def test_vulkan_q5_threshold_is_measured_delta_plus_headroom(self) -> None:
|
|
assert PREFER_Q5_0_ABOVE_MIB == self.VULKAN_Q5_0_DELTA_MIB + HEADROOM_MIB
|
|
assert PREFER_Q5_0_ABOVE_MIB == 1359.0
|
|
|
|
def test_cuda_q8_threshold_is_measured_delta_plus_headroom(self) -> None:
|
|
assert PREFER_CUDA_Q8_0_ABOVE_MIB == self.CUDA_Q8_0_DELTA_MIB + HEADROOM_MIB
|
|
assert PREFER_CUDA_Q8_0_ABOVE_MIB == 1690.0
|
|
|
|
def test_cuda_q5_threshold_is_measured_delta_plus_headroom(self) -> None:
|
|
assert PREFER_CUDA_Q5_0_ABOVE_MIB == self.CUDA_Q5_0_DELTA_MIB + HEADROOM_MIB
|
|
assert PREFER_CUDA_Q5_0_ABOVE_MIB == 1404.0
|
|
|
|
def test_rungs_are_ordered_on_each_backend(self) -> None:
|
|
assert PREFER_Q8_0_ABOVE_MIB > PREFER_Q5_0_ABOVE_MIB
|
|
assert PREFER_CUDA_Q8_0_ABOVE_MIB > PREFER_CUDA_Q5_0_ABOVE_MIB
|
|
assert _size_mb(DEFAULT_RUNG) > _size_mb(LOW_MEMORY_RUNG)
|
|
|
|
|
|
class TestSelectLadderRung:
|
|
"""Free memory picks the rung; the floor pairs with the language."""
|
|
|
|
def test_plenty_of_memory_keeps_default(self) -> None:
|
|
assert select_ladder_rung(True, PREFER_Q8_0_ABOVE_MIB) == DEFAULT_RUNG
|
|
assert select_ladder_rung(False, 99999.0) == DEFAULT_RUNG
|
|
|
|
def test_between_thresholds_steps_to_q5(self) -> None:
|
|
assert select_ladder_rung(True, PREFER_Q8_0_ABOVE_MIB - 1) == LOW_MEMORY_RUNG
|
|
assert select_ladder_rung(True, PREFER_Q5_0_ABOVE_MIB) == LOW_MEMORY_RUNG
|
|
|
|
def test_below_q5_fits_the_floor_by_language(self) -> None:
|
|
assert select_ladder_rung(True, PREFER_Q5_0_ABOVE_MIB - 1) == FLOOR_RUNG_ENGLISH
|
|
assert select_ladder_rung(False, PREFER_Q5_0_ABOVE_MIB - 1) == FLOOR_RUNG_MULTILINGUAL
|
|
assert select_ladder_rung(True, 0.0) == FLOOR_RUNG_ENGLISH
|
|
assert select_ladder_rung(False, 0.0) == FLOOR_RUNG_MULTILINGUAL
|
|
|
|
def test_recorded_boundaries_select_recorded_rungs(self) -> None:
|
|
assert select_ladder_rung(True, 1644.0) == DEFAULT_RUNG
|
|
assert select_ladder_rung(True, 1500.0) == LOW_MEMORY_RUNG
|
|
assert select_ladder_rung(True, 1359.0) == LOW_MEMORY_RUNG
|
|
assert select_ladder_rung(True, 1358.0) == FLOOR_RUNG_ENGLISH
|
|
assert select_ladder_rung(True, 1200.0) == FLOOR_RUNG_ENGLISH
|
|
assert select_ladder_rung(False, 1200.0) == FLOOR_RUNG_MULTILINGUAL
|
|
|
|
|
|
class TestBackendThresholds:
|
|
"""Each GPU backend keys to its own measured thresholds (ticket #19)."""
|
|
|
|
def test_cuda_keeps_default_only_above_its_own_q8_threshold(self) -> None:
|
|
assert select_ladder_rung(True, 1690.0, "cuda") == DEFAULT_RUNG
|
|
assert select_ladder_rung(True, 1689.0, "cuda") == LOW_MEMORY_RUNG
|
|
# 1644 MiB is enough for Vulkan's q8_0 but not for CUDA's.
|
|
assert select_ladder_rung(True, 1644.0, "cuda") == LOW_MEMORY_RUNG
|
|
assert select_ladder_rung(True, 1644.0, "vulkan") == DEFAULT_RUNG
|
|
|
|
def test_cuda_q5_threshold_is_its_own(self) -> None:
|
|
assert select_ladder_rung(True, 1404.0, "cuda") == LOW_MEMORY_RUNG
|
|
assert select_ladder_rung(True, 1403.0, "cuda") == FLOOR_RUNG_ENGLISH
|
|
assert select_ladder_rung(True, 1359.0, "vulkan") == LOW_MEMORY_RUNG
|
|
assert select_ladder_rung(True, 1359.0, "cuda") == FLOOR_RUNG_ENGLISH
|
|
|
|
def test_cpu_and_unknown_backends_use_the_vulkan_thresholds(self) -> None:
|
|
for backend in ("cpu", "unknown"):
|
|
assert select_ladder_rung(True, 1644.0, backend) == DEFAULT_RUNG
|
|
assert select_ladder_rung(True, 1359.0, backend) == LOW_MEMORY_RUNG
|
|
assert select_ladder_rung(True, 1358.0, backend) == FLOOR_RUNG_ENGLISH
|
|
|
|
def test_default_backend_is_vulkan(self) -> None:
|
|
assert select_ladder_rung(True, 1690.0) == DEFAULT_RUNG
|
|
assert select_ladder_rung(True, 1644.0) == DEFAULT_RUNG
|
|
|
|
def test_resolve_passes_the_backend_through(self) -> None:
|
|
assert resolve_startup_rung("", True, 1644.0, "cuda") == LOW_MEMORY_RUNG
|
|
assert resolve_startup_rung("", True, 1644.0, "vulkan") == DEFAULT_RUNG
|
|
assert resolve_startup_rung("tiny", True, 0.0, "cuda") == "tiny"
|
|
|
|
|
|
class TestResolveStartupRung:
|
|
"""A manual pin wins verbatim; only unpinned loads use the ladder."""
|
|
|
|
def test_pinned_variant_never_overridden(self) -> None:
|
|
assert resolve_startup_rung("tiny", True, 99999.0) == "tiny"
|
|
assert resolve_startup_rung(LOW_MEMORY_RUNG, False, 0.0) == LOW_MEMORY_RUNG
|
|
assert resolve_startup_rung(FLOOR_RUNG_ENGLISH, True, 99999.0) == FLOOR_RUNG_ENGLISH
|
|
|
|
def test_unpinned_uses_ladder(self) -> None:
|
|
assert resolve_startup_rung("", True, 99999.0) == DEFAULT_RUNG
|
|
assert resolve_startup_rung("", False, 0.0) == FLOOR_RUNG_MULTILINGUAL
|
|
|
|
|
|
class TestNextRungDown:
|
|
"""One step down per failure; the floor fails loudly."""
|
|
|
|
def test_default_steps_to_low_memory(self) -> None:
|
|
assert next_rung_down(DEFAULT_RUNG, True) == LOW_MEMORY_RUNG
|
|
assert next_rung_down(DEFAULT_RUNG, False) == LOW_MEMORY_RUNG
|
|
|
|
def test_low_memory_steps_to_floor_by_language(self) -> None:
|
|
assert next_rung_down(LOW_MEMORY_RUNG, True) == FLOOR_RUNG_ENGLISH
|
|
assert next_rung_down(LOW_MEMORY_RUNG, False) == FLOOR_RUNG_MULTILINGUAL
|
|
|
|
def test_floor_has_no_step_down(self) -> None:
|
|
assert next_rung_down(FLOOR_RUNG_ENGLISH, True) is None
|
|
assert next_rung_down(FLOOR_RUNG_MULTILINGUAL, False) is None
|
|
|
|
def test_non_ladder_rungs_have_no_step_down(self) -> None:
|
|
assert next_rung_down("tiny", True) is None
|
|
assert next_rung_down("medium", False) is None
|
|
|
|
|
|
class TestCudaToolkitReadiness:
|
|
"""Driver alone is not a toolkit; arch must match too."""
|
|
|
|
def test_complete_matching_toolkit_is_ready(self) -> None:
|
|
assert cuda_toolkit_ready_from_probe(True, True, (8, 6)) is True
|
|
assert cuda_toolkit_ready_from_probe(True, True, (7, 0)) is True
|
|
|
|
def test_driver_without_toolkit_is_not_ready(self) -> None:
|
|
assert cuda_toolkit_ready_from_probe(True, False, (8, 6)) is False
|
|
|
|
def test_no_gpu_is_not_ready(self) -> None:
|
|
assert cuda_toolkit_ready_from_probe(False, True, (8, 6)) is False
|
|
assert cuda_toolkit_ready_from_probe(False, False, None) is False
|
|
|
|
def test_old_architecture_is_not_ready(self) -> None:
|
|
assert cuda_toolkit_ready_from_probe(True, True, (6, 1)) is False
|
|
assert cuda_toolkit_ready_from_probe(True, True, None) is False
|
|
|
|
|
|
NVCC_VERSION_OUTPUT = "Cuda compilation tools, release 13.2, V13.2.51\n"
|
|
NVIDIA_SMI_OUTPUT = "3711, 8.6\n"
|
|
|
|
|
|
def _write_toolkit(
|
|
root: Path,
|
|
*,
|
|
nvcc: bool = True,
|
|
header: bool = True,
|
|
libcudart: bool = True,
|
|
target: str = "x86_64-linux",
|
|
) -> Path:
|
|
"""Lay out a CUDA toolkit tree shaped like the NVIDIA runfile install."""
|
|
if nvcc:
|
|
(root / "bin").mkdir(parents=True, exist_ok=True)
|
|
binary = root / "bin" / "nvcc"
|
|
binary.write_text("#!/bin/sh\n")
|
|
binary.chmod(0o755)
|
|
if header:
|
|
(root / "include").mkdir(parents=True, exist_ok=True)
|
|
(root / "include" / "cuda_runtime.h").write_text("/* header */\n")
|
|
if libcudart:
|
|
lib = root / "targets" / target / "lib"
|
|
lib.mkdir(parents=True, exist_ok=True)
|
|
(lib / "libcudart.so.13").write_text("")
|
|
if target == "x86_64-linux":
|
|
(root / "lib64").symlink_to(lib, target_is_directory=True)
|
|
return root
|
|
|
|
|
|
def _probe_stub(nvcc_output: str | None):
|
|
"""Stand in for ``_run_probe``: nvidia-smi sees an RTX-class GPU."""
|
|
|
|
def probe(argv, timeout=10):
|
|
if argv[0] == "nvidia-smi":
|
|
return NVIDIA_SMI_OUTPUT
|
|
if argv[0] == "nvcc":
|
|
return nvcc_output
|
|
return None
|
|
|
|
return probe
|
|
|
|
|
|
@pytest.fixture
|
|
def no_env_toolkit(tmp_path: Path):
|
|
"""Isolate detection from the host: no CUDA env vars, empty /usr/local."""
|
|
usr_local = tmp_path / "usr-local"
|
|
usr_local.mkdir()
|
|
with (
|
|
patch.dict(os.environ, {}, clear=True),
|
|
patch.object(model_ladder, "_CUDA_USR_LOCAL_ROOT", usr_local),
|
|
patch.object(model_ladder.platform, "machine", return_value="x86_64"),
|
|
):
|
|
yield usr_local
|
|
|
|
|
|
class TestCudaToolkitDetection:
|
|
"""A toolkit counts from PATH, CUDA_HOME, CUDA_PATH or /usr/local/cuda*/."""
|
|
|
|
def test_toolkit_found_via_path(self, no_env_toolkit: Path) -> None:
|
|
with patch.object(model_ladder, "_run_probe", side_effect=_probe_stub(NVCC_VERSION_OUTPUT)):
|
|
assert model_ladder.cuda_toolkit_ready() is True
|
|
|
|
@pytest.mark.parametrize("variable", ["CUDA_HOME", "CUDA_PATH"])
|
|
def test_toolkit_found_via_cuda_env_root(
|
|
self, tmp_path: Path, no_env_toolkit: Path, variable: str
|
|
) -> None:
|
|
root = _write_toolkit(tmp_path / "opt" / "cuda")
|
|
with (
|
|
patch.dict(os.environ, {variable: str(root)}, clear=True),
|
|
patch.object(model_ladder, "_run_probe", side_effect=_probe_stub(None)),
|
|
):
|
|
assert model_ladder.cuda_toolkit_ready() is True
|
|
|
|
def test_toolkit_found_only_under_usr_local_cuda(self, no_env_toolkit: Path) -> None:
|
|
"""The real target: /usr/local/cuda -> cuda-13.2 with nvcc off PATH."""
|
|
real = _write_toolkit(no_env_toolkit / "cuda-13.2")
|
|
(no_env_toolkit / "cuda").symlink_to(real, target_is_directory=True)
|
|
with patch.object(model_ladder, "_run_probe", side_effect=_probe_stub(None)):
|
|
assert model_ladder.cuda_toolkit_ready() is True
|
|
|
|
def test_no_toolkit_anywhere_is_not_ready(self, no_env_toolkit: Path) -> None:
|
|
with patch.object(model_ladder, "_run_probe", side_effect=_probe_stub(None)):
|
|
assert model_ladder.cuda_toolkit_ready() is False
|
|
|
|
@pytest.mark.parametrize(
|
|
"layout",
|
|
[
|
|
{"nvcc": False},
|
|
{"header": False},
|
|
{"libcudart": False},
|
|
{"target": "sbsa-linux"},
|
|
],
|
|
ids=["no-nvcc", "no-cuda-runtime-header", "no-libcudart", "other-architecture"],
|
|
)
|
|
def test_incomplete_toolkit_is_rejected(
|
|
self, tmp_path: Path, no_env_toolkit: Path, layout: dict
|
|
) -> None:
|
|
root = _write_toolkit(tmp_path / "partial", **layout)
|
|
with (
|
|
patch.dict(os.environ, {"CUDA_HOME": str(root)}, clear=True),
|
|
patch.object(model_ladder, "_run_probe", side_effect=_probe_stub(None)),
|
|
):
|
|
assert model_ladder.cuda_toolkit_ready() is False
|
|
|
|
def test_driver_without_toolkit_stays_not_ready_with_partial_tree(
|
|
self, no_env_toolkit: Path
|
|
) -> None:
|
|
"""A /usr/local/cuda with headers only is not a toolkit."""
|
|
_write_toolkit(no_env_toolkit / "cuda-13.2", nvcc=False, libcudart=False)
|
|
with patch.object(model_ladder, "_run_probe", side_effect=_probe_stub(None)):
|
|
assert model_ladder.cuda_toolkit_ready() is False
|
|
|
|
|
|
class TestBuildAwareBackendOrder:
|
|
"""The order names only backends the installed build compiled in."""
|
|
|
|
def test_vulkan_only_build_never_picks_cuda_even_with_toolkit(
|
|
self, no_env_toolkit: Path
|
|
) -> None:
|
|
_write_toolkit(no_env_toolkit / "cuda-13.2")
|
|
(no_env_toolkit / "cuda").symlink_to(no_env_toolkit / "cuda-13.2")
|
|
with patch.object(model_ladder, "_run_probe", side_effect=_probe_stub(None)):
|
|
assert model_ladder.cuda_toolkit_ready() is True
|
|
assert preferred_backend_order("vulkan") == ("vulkan", "cpu")
|
|
|
|
def test_cuda_build_picks_cuda_with_nvcc_off_path(self, no_env_toolkit: Path) -> None:
|
|
_write_toolkit(no_env_toolkit / "cuda-13.2")
|
|
(no_env_toolkit / "cuda").symlink_to(no_env_toolkit / "cuda-13.2")
|
|
with patch.object(model_ladder, "_run_probe", side_effect=_probe_stub(None)):
|
|
assert preferred_backend_order("cuda") == ("cuda", "cpu")
|
|
|
|
def test_cpu_build_is_cpu_only(self) -> None:
|
|
assert preferred_backend_order("cpu") == ("cpu",)
|
|
|
|
|
|
class TestPreferredBackendOrder:
|
|
"""CUDA ranks first only with a complete toolkit."""
|
|
|
|
def test_cuda_first_with_toolkit(self) -> None:
|
|
with patch.object(model_ladder, "cuda_toolkit_ready", return_value=True):
|
|
assert preferred_backend_order() == ("cuda", "vulkan", "cpu")
|
|
|
|
def test_vulkan_then_cpu_without_toolkit(self) -> None:
|
|
with patch.object(model_ladder, "cuda_toolkit_ready", return_value=False):
|
|
assert preferred_backend_order() == ("vulkan", "cpu")
|
|
|
|
|
|
def _make_manager(**kwargs):
|
|
"""Create a whisper.cpp manager without running any initializer."""
|
|
with (
|
|
patch.object(SpeechRecognitionManager, "_init_vosk"),
|
|
patch.object(SpeechRecognitionManager, "_init_whisper"),
|
|
patch.object(SpeechRecognitionManager, "_init_whispercpp"),
|
|
):
|
|
return SpeechRecognitionManager(
|
|
engine="whisper_cpp",
|
|
model_size="small",
|
|
language="en-us",
|
|
defer_download=True,
|
|
**kwargs,
|
|
)
|
|
|
|
|
|
class TestStepDownRetry:
|
|
"""The loader steps one rung down and retries once, then surfaces."""
|
|
|
|
def test_first_failure_steps_down_and_retries_once(self) -> None:
|
|
manager = _make_manager()
|
|
manager.model_size = DEFAULT_RUNG
|
|
calls = []
|
|
|
|
def fail_once() -> None:
|
|
calls.append(manager.model_size)
|
|
if len(calls) == 1:
|
|
raise RuntimeError("CUDA out of memory")
|
|
|
|
with patch.object(SpeechRecognitionManager, "_init_whispercpp_once", side_effect=fail_once):
|
|
manager._init_whispercpp()
|
|
|
|
assert calls == [DEFAULT_RUNG, LOW_MEMORY_RUNG]
|
|
assert manager.model_size == LOW_MEMORY_RUNG
|
|
|
|
def test_second_failure_surfaces(self) -> None:
|
|
manager = _make_manager()
|
|
manager.model_size = DEFAULT_RUNG
|
|
with patch.object(
|
|
SpeechRecognitionManager, "_init_whispercpp_once", side_effect=RuntimeError("boom")
|
|
):
|
|
try:
|
|
manager._init_whispercpp()
|
|
except RuntimeError:
|
|
pass
|
|
else:
|
|
raise AssertionError("second failure must surface")
|
|
assert manager.model_size == LOW_MEMORY_RUNG
|
|
|
|
def test_manual_pin_never_steps_down(self) -> None:
|
|
manager = _make_manager(whispercpp_variant_pinned=True)
|
|
manager.model_size = DEFAULT_RUNG
|
|
loader = MagicMock(side_effect=RuntimeError("CUDA out of memory"))
|
|
with patch.object(SpeechRecognitionManager, "_init_whispercpp_once", loader):
|
|
try:
|
|
manager._init_whispercpp()
|
|
except RuntimeError:
|
|
pass
|
|
else:
|
|
raise AssertionError("manual pick must fail loudly")
|
|
assert loader.call_count == 1
|
|
assert manager.model_size == DEFAULT_RUNG
|
|
|
|
def test_floor_rung_fails_loudly(self) -> None:
|
|
manager = _make_manager()
|
|
manager.model_size = FLOOR_RUNG_ENGLISH
|
|
loader = MagicMock(side_effect=OSError("no space"))
|
|
with patch.object(SpeechRecognitionManager, "_init_whispercpp_once", loader):
|
|
try:
|
|
manager._init_whispercpp()
|
|
except OSError:
|
|
pass
|
|
else:
|
|
raise AssertionError("floor failure must surface")
|
|
assert loader.call_count == 1
|
|
|
|
def test_non_ladder_model_does_not_step_down(self) -> None:
|
|
manager = _make_manager()
|
|
manager.model_size = "medium"
|
|
loader = MagicMock(side_effect=RuntimeError("boom"))
|
|
with patch.object(SpeechRecognitionManager, "_init_whispercpp_once", loader):
|
|
try:
|
|
manager._init_whispercpp()
|
|
except RuntimeError:
|
|
pass
|
|
else:
|
|
raise AssertionError("non-ladder failure must surface")
|
|
assert loader.call_count == 1
|
|
|
|
|
|
def _probe_memory(free_mib: float, backend: str = "cpu"):
|
|
"""Stub the startup probes: free memory of the installed build, and its backend."""
|
|
stack = ExitStack()
|
|
stack.enter_context(
|
|
patch(
|
|
"vocalinux.speech_recognition.model_ladder.free_memory_for_backend",
|
|
return_value=(free_mib, "RAM"),
|
|
)
|
|
)
|
|
stack.enter_context(
|
|
patch(
|
|
"vocalinux.speech_recognition.recognition_manager.detect_pywhispercpp_gpu_backend",
|
|
return_value=backend,
|
|
)
|
|
)
|
|
return stack
|
|
|
|
|
|
class TestStartupRungSelection:
|
|
"""Unpinned defaults resolve through the ladder; explicit picks do not."""
|
|
|
|
def _manager_with(self, saved_size, pinned_variant, language="en-us"):
|
|
from vocalinux.ui.config_manager import ConfigManager
|
|
|
|
manager = ConfigManager.__new__(ConfigManager)
|
|
manager.config = {
|
|
"speech_recognition": {
|
|
"whisper_cpp_model_size": saved_size,
|
|
"whisper_cpp_model_variant": pinned_variant,
|
|
"language": language,
|
|
}
|
|
}
|
|
return manager
|
|
|
|
def test_unpinned_default_selects_rung_by_memory(self) -> None:
|
|
manager = self._manager_with("tiny", "", "en-us")
|
|
with _probe_memory(99999.0):
|
|
with patch(
|
|
"vocalinux.utils.whispercpp_model_info.is_model_downloaded", return_value=False
|
|
):
|
|
assert manager.get_model_size_for_engine("whisper_cpp") == DEFAULT_RUNG
|
|
|
|
def test_unpinned_default_low_memory_selects_q5(self) -> None:
|
|
manager = self._manager_with("tiny", "", "en-us")
|
|
with _probe_memory(PREFER_Q5_0_ABOVE_MIB):
|
|
with patch(
|
|
"vocalinux.utils.whispercpp_model_info.is_model_downloaded", return_value=False
|
|
):
|
|
assert manager.get_model_size_for_engine("whisper_cpp") == LOW_MEMORY_RUNG
|
|
|
|
def test_unpinned_default_tight_memory_selects_floor(self) -> None:
|
|
manager = self._manager_with("tiny", "", "pl")
|
|
with _probe_memory(100.0):
|
|
with patch(
|
|
"vocalinux.utils.whispercpp_model_info.is_model_downloaded", return_value=False
|
|
):
|
|
assert manager.get_model_size_for_engine("whisper_cpp") == FLOOR_RUNG_MULTILINGUAL
|
|
|
|
def test_pinned_variant_never_uses_ladder(self) -> None:
|
|
manager = self._manager_with("tiny", LOW_MEMORY_RUNG, "en-us")
|
|
with _probe_memory(99999.0):
|
|
assert manager.get_model_size_for_engine("whisper_cpp") == LOW_MEMORY_RUNG
|
|
|
|
def test_explicit_size_keeps_legacy_derivation(self) -> None:
|
|
manager = self._manager_with("medium", "", "en-us")
|
|
with _probe_memory(99999.0):
|
|
with patch(
|
|
"vocalinux.utils.whispercpp_model_info.is_model_downloaded", return_value=False
|
|
):
|
|
assert manager.get_model_size_for_engine("whisper_cpp") == "medium.en"
|
|
|
|
|
|
class TestFreeMemoryForBackend:
|
|
"""The memory reading follows the installed build (spec #16, ticket #20)."""
|
|
|
|
def test_vulkan_build_reads_discrete_vram_when_present(self) -> None:
|
|
with (
|
|
patch.object(model_ladder, "free_vram_mib", return_value=2600.0),
|
|
patch.object(model_ladder, "free_system_ram_mib", return_value=9000.0),
|
|
):
|
|
assert model_ladder.free_memory_for_backend("vulkan") == (2600.0, "VRAM")
|
|
|
|
def test_cuda_build_reads_discrete_vram_when_present(self) -> None:
|
|
with patch.object(model_ladder, "free_vram_mib", return_value=1500.0):
|
|
assert model_ladder.free_memory_for_backend("cuda") == (1500.0, "VRAM")
|
|
|
|
def test_gpu_build_without_an_nvidia_reading_reads_ram(self) -> None:
|
|
"""Integrated graphics: no discrete VRAM reading, so system RAM decides."""
|
|
with (
|
|
patch.object(model_ladder, "free_vram_mib", return_value=None),
|
|
patch.object(model_ladder, "free_system_ram_mib", return_value=6000.0),
|
|
):
|
|
assert model_ladder.free_memory_for_backend("vulkan") == (6000.0, "RAM")
|
|
|
|
def test_cpu_build_never_reads_vram(self) -> None:
|
|
vram = MagicMock(return_value=2600.0)
|
|
with (
|
|
patch.object(model_ladder, "free_vram_mib", vram),
|
|
patch.object(model_ladder, "free_system_ram_mib", return_value=1200.0),
|
|
):
|
|
assert model_ladder.free_memory_for_backend("cpu") == (1200.0, "RAM")
|
|
vram.assert_not_called()
|