From 7978ba7d7606a0a3a78fea1400ab39700d83bddf Mon Sep 17 00:00:00 2001 From: Xavier Karma Date: Fri, 6 Nov 2026 18:25:17 +0530 Subject: [PATCH] fix(models): recommend the ladder rung from the installed build and free memory get_recommended_model() now keys to the backend the installed whisper.cpp build actually has (detect_pywhispercpp_gpu_backend) and to free memory measured at recommendation time: free VRAM on a discrete GPU for a Vulkan or CUDA build, free system RAM otherwise. The result is a rung of the model ladder, so a Vulkan-capable Nvidia card reaches the ladder instead of the total-memory tiers, and unquantized turbo, the large family and medium are never a default. Manual selections are untouched. The informational hardware probe no longer decides the model. Dead VRAM-total parsing is removed. free_memory_for_backend() holds the source choice; the floor-rung map is shared by rung selection and step-down. --- .../speech_recognition/model_ladder.py | 31 +- src/vocalinux/utils/whispercpp_model_info.py | 89 ++-- tests/test_model_ladder.py | 46 +- tests/test_whispercpp_model_info_ext.py | 413 +++++------------- 4 files changed, 178 insertions(+), 401 deletions(-) diff --git a/src/vocalinux/speech_recognition/model_ladder.py b/src/vocalinux/speech_recognition/model_ladder.py index 681940e..3cd0892 100644 --- a/src/vocalinux/speech_recognition/model_ladder.py +++ b/src/vocalinux/speech_recognition/model_ladder.py @@ -53,6 +53,16 @@ PREFER_Q5_0_ABOVE_MIB: float = _MEASURED_Q5_0_PEAK_MIB + HEADROOM_MIB MIN_CUDA_COMPUTE_CAPABILITY: tuple[int, int] = (7, 0) +#: Backends whose kernels run on a GPU, so their model must fit free VRAM on a +#: discrete card. A CPU build keeps its model in system RAM. +GPU_BACKENDS: tuple[str, ...] = ("vulkan", "cuda") + + +def _floor_rung(language_is_english: bool) -> str: + """Return the floor rung for the language: English-only pairs with ``.en``.""" + return FLOOR_RUNG_ENGLISH if language_is_english else FLOOR_RUNG_MULTILINGUAL + + def select_ladder_rung(language_is_english: bool, free_mib: float) -> str: """Return the ladder rung that fits ``free_mib`` MiB of free memory. @@ -62,7 +72,7 @@ def select_ladder_rung(language_is_english: bool, free_mib: float) -> str: return DEFAULT_RUNG if free_mib >= PREFER_Q5_0_ABOVE_MIB: return LOW_MEMORY_RUNG - return FLOOR_RUNG_ENGLISH if language_is_english else FLOOR_RUNG_MULTILINGUAL + return _floor_rung(language_is_english) def resolve_startup_rung( @@ -89,7 +99,7 @@ def next_rung_down(rung: str, language_is_english: bool) -> str | None: if rung == DEFAULT_RUNG: return LOW_MEMORY_RUNG if rung == LOW_MEMORY_RUNG: - return FLOOR_RUNG_ENGLISH if language_is_english else FLOOR_RUNG_MULTILINGUAL + return _floor_rung(language_is_english) return None @@ -197,6 +207,21 @@ def free_system_ram_mib() -> float | None: return None +def free_memory_for_backend(gpu_backend: str) -> tuple[float | None, str]: + """Return ``(free MiB, source)`` for the memory the installed build uses. + + ``gpu_backend`` is what the installed whisper.cpp build actually has + (``vulkan``, ``cuda`` or ``cpu``). A GPU build reads free VRAM on a + discrete NVIDIA card; integrated graphics and CPU builds read free system + RAM. ``source`` is ``"VRAM"`` or ``"RAM"`` so callers can say which. + """ + if gpu_backend in GPU_BACKENDS: + vram = free_vram_mib() + if vram is not None: + return vram, "VRAM" + return free_system_ram_mib(), "RAM" + + def free_memory_mib() -> float | None: """Return free memory in MiB: discrete-GPU VRAM when present, else RAM.""" vram = free_vram_mib() @@ -242,12 +267,14 @@ __all__ = [ "PREFER_Q8_0_ABOVE_MIB", "PREFER_Q5_0_ABOVE_MIB", "MIN_CUDA_COMPUTE_CAPABILITY", + "GPU_BACKENDS", "select_ladder_rung", "resolve_startup_rung", "next_rung_down", "is_ladder_rung", "cuda_toolkit_ready_from_probe", "cuda_toolkit_ready", + "free_memory_for_backend", "free_memory_mib", "preferred_backend_order", "describe_environment", diff --git a/src/vocalinux/utils/whispercpp_model_info.py b/src/vocalinux/utils/whispercpp_model_info.py index d4172e6..777eeb9 100644 --- a/src/vocalinux/utils/whispercpp_model_info.py +++ b/src/vocalinux/utils/whispercpp_model_info.py @@ -7,7 +7,6 @@ for whisper.cpp, supporting Vulkan, CUDA, and CPU backends. import ctypes import logging -import math import os import re import subprocess @@ -541,75 +540,37 @@ def detect_cpu_info() -> str: return "CPU" -def _parse_cuda_vram_gib(backend_info: str) -> Optional[float]: - """Parse VRAM in GiB from nvidia-smi-style backend info. - - Accepts MiB/MB/GiB/GB (case-insensitive). MiB and MB are converted by - dividing by 1024. Returns None when no size can be parsed. - """ - if not isinstance(backend_info, str) or not backend_info: - return None - match = re.search(r"(\d+(?:\.\d+)?)\s*(gi?b|mi?b)", backend_info, flags=re.IGNORECASE) - if not match: - return None - amount = float(match.group(1)) - unit = match.group(2).lower() - if unit in {"mb", "mib"}: - amount /= 1024.0 - return amount - - def get_recommended_model() -> tuple[str, str]: - """ - Get the recommended whisper.cpp model based on system configuration. + """Recommend a whisper.cpp model from the installed build and free memory. + + Spec #16 / ticket #20: the recommendation is a rung of the model ladder + chosen from memory measured now, never from total memory. The memory that + counts follows the backend the installed build actually has: free VRAM on a + discrete GPU for a Vulkan or CUDA build, free system RAM otherwise (CPU + builds, or integrated graphics with no NVIDIA reading). An informational + hardware probe no longer decides the model, so a Vulkan-capable Nvidia card + reaches the ladder. + + Returns the rung before language adaptation; the floor is returned as the + multilingual variant and callers pair it with ``.en`` for English. Returns: Tuple of (model_name, reason) """ - try: - import psutil + from ..speech_recognition.model_ladder import ( + FLOOR_RUNG_MULTILINGUAL, + free_memory_for_backend, + resolve_startup_rung, + ) + from ..speech_recognition.recognition_manager import detect_pywhispercpp_gpu_backend - ram_gb = math.ceil(psutil.virtual_memory().total / (1024**3)) - - # Detect available compute backends - backend, backend_info = detect_compute_backend() - - if backend == ComputeBackend.VULKAN: - # Vulkan can handle larger models efficiently - if ram_gb >= 8: - return "small", f"Vulkan GPU with {ram_gb}GB RAM" - else: - return "base", f"Vulkan GPU with {ram_gb}GB RAM" - elif backend == ComputeBackend.CUDA: - # A driver without the complete toolkit cannot run the CUDA - # build, so the VRAM ladder below would be unreachable. Fall - # through to the Vulkan/CPU branches instead (ticket #20). - from ..speech_recognition.model_ladder import cuda_toolkit_ready - - if cuda_toolkit_ready(): - vram_gb = _parse_cuda_vram_gib(backend_info or "") - if vram_gb is not None: - if vram_gb >= 8: - return "medium", f"CUDA GPU with {vram_gb:g}GB VRAM" - if vram_gb >= 4: - return "small", f"CUDA GPU with {vram_gb:g}GB VRAM" - return "base", "CUDA GPU with limited VRAM" - return "small", "CUDA GPU detected" - backend = ComputeBackend.CPU - if backend == ComputeBackend.CPU: - # CPU-only recommendations based on RAM - if ram_gb >= 16: - return "base", f"{ram_gb}GB RAM - CPU inference" - elif ram_gb >= 8: - return "tiny", f"{ram_gb}GB RAM - optimized for speed" - else: - return "tiny", f"Limited RAM ({ram_gb}GB) - fastest model" - - except ImportError: - logger.debug("psutil not available for system detection") - - # Default recommendation - return "tiny", "Default recommendation" + gpu_backend = detect_pywhispercpp_gpu_backend() + free_mib, source = free_memory_for_backend(gpu_backend) + if free_mib is None: + logger.info("Free memory unreadable; recommending the floor model") + return FLOOR_RUNG_MULTILINGUAL, "Free memory unreadable; floor model" + rung = resolve_startup_rung("", False, free_mib) + return rung, f"{rung}: {free_mib:.0f} MiB free {source} ({gpu_backend} build)" def _model_file_path(model_name: str) -> str: diff --git a/tests/test_model_ladder.py b/tests/test_model_ladder.py index c98a16f..f747220 100644 --- a/tests/test_model_ladder.py +++ b/tests/test_model_ladder.py @@ -294,27 +294,33 @@ class TestStartupRungSelection: assert manager.get_model_size_for_engine("whisper_cpp") == "medium.en" -class TestRecommendationGate: - """Driver-only CUDA demotes to CPU logic; toolkit keeps the VRAM ladder.""" +class TestFreeMemoryForBackend: + """The memory reading follows the installed build (spec #16, ticket #20).""" - def _recommend(self, backend, toolkit): - from vocalinux.utils import whispercpp_model_info as info + 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") - mock_psutil = MagicMock() - mock_psutil.virtual_memory.return_value = MagicMock(total=16 * (1024**3)) - with patch.dict("sys.modules", {"psutil": mock_psutil}): - with patch.object(info, "detect_compute_backend", return_value=backend): - with patch( - "vocalinux.speech_recognition.model_ladder.cuda_toolkit_ready", - return_value=toolkit, - ): - return info.get_recommended_model() + 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_driver_only_cuda_demotes_to_cpu(self) -> None: - model, _reason = self._recommend(("cuda", "NVIDIA RTX 2050 (4GB)"), False) - assert model == "base" + 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_toolkit_cuda_keeps_vram_ladder(self) -> None: - model, reason = self._recommend(("cuda", "NVIDIA RTX 2050 (4GB)"), True) - assert model == "small" - assert "VRAM" in reason + 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() diff --git a/tests/test_whispercpp_model_info_ext.py b/tests/test_whispercpp_model_info_ext.py index b409528..d4db8c9 100644 --- a/tests/test_whispercpp_model_info_ext.py +++ b/tests/test_whispercpp_model_info_ext.py @@ -965,321 +965,104 @@ class TestDetectComputeBackend(unittest.TestCase): class TestGetRecommendedModel(unittest.TestCase): - """Test cases for get_recommended_model function.""" - - def test_get_recommended_model_returns_tuple(self): - """Test that get_recommended_model returns a tuple.""" - mock_psutil = MagicMock() - mock_psutil.virtual_memory.return_value = MagicMock(total=8 * (1024**3)) - with patch.dict("sys.modules", {"psutil": mock_psutil}): - with patch( - "vocalinux.utils.whispercpp_model_info.detect_compute_backend", - return_value=("cpu", "Intel Core i7"), - ): - import importlib - - import vocalinux.utils.whispercpp_model_info as mod - - importlib.reload(mod) - result = mod.get_recommended_model() - self.assertIsInstance(result, tuple) - self.assertEqual(len(result), 2) - - def test_get_recommended_model_with_vulkan_high_ram(self): - """Test model recommendation with Vulkan and 8GB+ RAM.""" - import sys - - # Create a mock psutil module - mock_psutil = MagicMock() - mock_psutil.virtual_memory.return_value = MagicMock(total=8 * (1024**3)) - - with patch.dict("sys.modules", {"psutil": mock_psutil}): - with patch( - "vocalinux.utils.whispercpp_model_info.detect_compute_backend" - ) as mock_backend: - mock_backend.return_value = ("vulkan", "Intel Arc A770") - - with patch( - "vocalinux.speech_recognition.model_ladder.cuda_toolkit_ready", - return_value=True, - ): - from vocalinux.utils.whispercpp_model_info import get_recommended_model - - model, reason = get_recommended_model() - - self.assertEqual(model, "small") - - def test_get_recommended_model_with_cuda_high_vram(self): - """Test model recommendation with CUDA and high VRAM.""" - import sys - - mock_psutil = MagicMock() - mock_psutil.virtual_memory.return_value = MagicMock(total=16 * (1024**3)) - - with patch.dict("sys.modules", {"psutil": mock_psutil}): - with patch( - "vocalinux.utils.whispercpp_model_info.detect_compute_backend" - ) as mock_backend: - mock_backend.return_value = ("cuda", "NVIDIA RTX 4090 (24GB)") - - with patch( - "vocalinux.speech_recognition.model_ladder.cuda_toolkit_ready", - return_value=True, - ): - from vocalinux.utils.whispercpp_model_info import get_recommended_model - - model, reason = get_recommended_model() - - # High VRAM should recommend medium or higher - self.assertIn(model, ["medium", "large", "small"]) - - def test_get_recommended_model_with_driver_only_cuda_demotes(self): - """A CUDA driver without the toolkit cannot run the build: CPU logic.""" - import sys - - mock_psutil = MagicMock() - mock_psutil.virtual_memory.return_value = MagicMock(total=16 * (1024**3)) - - with patch.dict("sys.modules", {"psutil": mock_psutil}): - with patch( - "vocalinux.utils.whispercpp_model_info.detect_compute_backend" - ) as mock_backend: - mock_backend.return_value = ("cuda", "NVIDIA RTX 4090 (24GB)") - - with patch( - "vocalinux.speech_recognition.model_ladder.cuda_toolkit_ready", - return_value=False, - ): - from vocalinux.utils.whispercpp_model_info import get_recommended_model - - model, _reason = get_recommended_model() - - # 16 GB system RAM under CPU logic recommends base. - self.assertEqual(model, "base") - - def test_get_recommended_model_with_cpu_high_ram(self): - """Test model recommendation with CPU and 16GB+ RAM.""" - import sys - - mock_psutil = MagicMock() - mock_psutil.virtual_memory.return_value = MagicMock(total=16 * (1024**3)) - - with patch.dict("sys.modules", {"psutil": mock_psutil}): - with patch( - "vocalinux.utils.whispercpp_model_info.detect_compute_backend" - ) as mock_backend: - mock_backend.return_value = ("cpu", "Intel Core i7") - - with patch( - "vocalinux.speech_recognition.model_ladder.cuda_toolkit_ready", - return_value=True, - ): - from vocalinux.utils.whispercpp_model_info import get_recommended_model - - model, reason = get_recommended_model() - - self.assertEqual(model, "base") - - def test_get_recommended_model_with_cpu_medium_ram(self): - """Test model recommendation with CPU and 8GB RAM.""" - import sys - - mock_psutil = MagicMock() - mock_psutil.virtual_memory.return_value = MagicMock(total=8 * (1024**3)) - - with patch.dict("sys.modules", {"psutil": mock_psutil}): - with patch( - "vocalinux.utils.whispercpp_model_info.detect_compute_backend" - ) as mock_backend: - mock_backend.return_value = ("cpu", "AMD Ryzen 5") - - with patch( - "vocalinux.speech_recognition.model_ladder.cuda_toolkit_ready", - return_value=True, - ): - from vocalinux.utils.whispercpp_model_info import get_recommended_model - - model, reason = get_recommended_model() - - self.assertEqual(model, "tiny") - - def test_get_recommended_model_with_cpu_low_ram(self): - """Test model recommendation with CPU and 4GB RAM.""" - import sys - - mock_psutil = MagicMock() - mock_psutil.virtual_memory.return_value = MagicMock(total=4 * (1024**3)) - - with patch.dict("sys.modules", {"psutil": mock_psutil}): - with patch( - "vocalinux.utils.whispercpp_model_info.detect_compute_backend" - ) as mock_backend: - mock_backend.return_value = ("cpu", "Intel Atom") - - with patch( - "vocalinux.speech_recognition.model_ladder.cuda_toolkit_ready", - return_value=True, - ): - from vocalinux.utils.whispercpp_model_info import get_recommended_model - - model, reason = get_recommended_model() - - self.assertEqual(model, "tiny") - - def test_get_recommended_model_returns_valid_model_name(self): - """Test that get_recommended_model returns a valid model name.""" - mock_psutil = MagicMock() - mock_psutil.virtual_memory.return_value = MagicMock(total=8 * (1024**3)) - with patch.dict("sys.modules", {"psutil": mock_psutil}): - with patch( - "vocalinux.utils.whispercpp_model_info.detect_compute_backend", - return_value=("cpu", "Intel Core i7"), - ): - import importlib - - import vocalinux.utils.whispercpp_model_info as mod - - importlib.reload(mod) - model, reason = mod.get_recommended_model() - self.assertIn(model, mod.AVAILABLE_MODELS) - - def test_get_recommended_model_returns_reason_string(self): - """Test that get_recommended_model returns a reason string.""" - mock_psutil = MagicMock() - mock_psutil.virtual_memory.return_value = MagicMock(total=8 * (1024**3)) - with patch.dict("sys.modules", {"psutil": mock_psutil}): - with patch( - "vocalinux.utils.whispercpp_model_info.detect_compute_backend", - return_value=("cpu", "Intel Core i7"), - ): - import importlib - - import vocalinux.utils.whispercpp_model_info as mod - - importlib.reload(mod) - model, reason = mod.get_recommended_model() - self.assertIsInstance(reason, str) - self.assertGreater(len(reason), 0) - - def test_parse_cuda_vram_mib_and_gb(self): - """nvidia-smi reports MiB; older mocks used GB. Both must parse.""" - from vocalinux.utils.whispercpp_model_info import _parse_cuda_vram_gib - - self.assertEqual(_parse_cuda_vram_gib("NVIDIA GeForce RTX 3060 (6144 MiB)"), 6.0) - self.assertEqual(_parse_cuda_vram_gib("NVIDIA GeForce RTX 3070 (8192 MiB)"), 8.0) - self.assertEqual(_parse_cuda_vram_gib("NVIDIA RTX 4090 (24GB)"), 24.0) - self.assertEqual(_parse_cuda_vram_gib("NVIDIA RTX 3080 (10 GB)"), 10.0) - self.assertEqual(_parse_cuda_vram_gib("NVIDIA RTX 3060 (8 GiB)"), 8.0) - self.assertEqual(_parse_cuda_vram_gib("NVIDIA GTX 1650 (4096 MB)"), 4.0) - self.assertIsNone(_parse_cuda_vram_gib("NVIDIA GPU")) - self.assertIsNone(_parse_cuda_vram_gib("")) - - def test_get_recommended_model_with_cuda_mib_medium(self): - """8 GiB of CUDA VRAM reported as MiB must recommend medium.""" - mock_psutil = MagicMock() - mock_psutil.virtual_memory.return_value = MagicMock(total=16 * (1024**3)) - - with patch.dict("sys.modules", {"psutil": mock_psutil}): - with patch( - "vocalinux.utils.whispercpp_model_info.detect_compute_backend" - ) as mock_backend: - mock_backend.return_value = ("cuda", "NVIDIA GeForce RTX 3070 (8192 MiB)") - - with patch( - "vocalinux.speech_recognition.model_ladder.cuda_toolkit_ready", - return_value=True, - ): - from vocalinux.utils.whispercpp_model_info import get_recommended_model - - model, reason = get_recommended_model() - - self.assertEqual(model, "medium") - self.assertIn("VRAM", reason) - - def test_get_recommended_model_with_cuda_mib_small(self): - """6 GiB of CUDA VRAM reported as MiB must recommend small.""" - mock_psutil = MagicMock() - mock_psutil.virtual_memory.return_value = MagicMock(total=16 * (1024**3)) - - with patch.dict("sys.modules", {"psutil": mock_psutil}): - with patch( - "vocalinux.utils.whispercpp_model_info.detect_compute_backend" - ) as mock_backend: - mock_backend.return_value = ("cuda", "NVIDIA GeForce RTX 3060 (6144 MiB)") - - with patch( - "vocalinux.speech_recognition.model_ladder.cuda_toolkit_ready", - return_value=True, - ): - from vocalinux.utils.whispercpp_model_info import get_recommended_model - - model, reason = get_recommended_model() - - self.assertEqual(model, "small") - - def test_get_recommended_model_with_cuda_mib_base(self): - """Low CUDA VRAM reported as MiB must recommend base.""" - mock_psutil = MagicMock() - mock_psutil.virtual_memory.return_value = MagicMock(total=8 * (1024**3)) - - with patch.dict("sys.modules", {"psutil": mock_psutil}): - with patch( - "vocalinux.utils.whispercpp_model_info.detect_compute_backend" - ) as mock_backend: - mock_backend.return_value = ("cuda", "NVIDIA GeForce GTX 1050 (2048 MiB)") - - with patch( - "vocalinux.speech_recognition.model_ladder.cuda_toolkit_ready", - return_value=True, - ): - from vocalinux.utils.whispercpp_model_info import get_recommended_model - - model, reason = get_recommended_model() - - self.assertEqual(model, "base") - - def test_get_recommended_model_with_cuda_unparseable_vram_falls_back(self): - mock_psutil = MagicMock() - mock_psutil.virtual_memory.return_value = MagicMock(total=16 * (1024**3)) - - with patch.dict("sys.modules", {"psutil": mock_psutil}): - with patch( - "vocalinux.utils.whispercpp_model_info.detect_compute_backend" - ) as mock_backend: - mock_backend.return_value = ("cuda", "NVIDIA GPU") - - with patch( - "vocalinux.speech_recognition.model_ladder.cuda_toolkit_ready", - return_value=True, - ): - from vocalinux.utils.whispercpp_model_info import get_recommended_model - - model, reason = get_recommended_model() - - self.assertEqual(model, "small") - self.assertIn("CUDA GPU detected", reason) - - def test_get_recommended_model_vulkan_ceils_fractional_ram(self): - """~7.4 GiB must count as 8 GiB so Vulkan recommends small, not base.""" - mock_psutil = MagicMock() - mock_psutil.virtual_memory.return_value = MagicMock(total=int(7.4 * (1024**3))) - - with patch.dict("sys.modules", {"psutil": mock_psutil}): - with patch( - "vocalinux.utils.whispercpp_model_info.detect_compute_backend" - ) as mock_backend: - mock_backend.return_value = ("vulkan", "Intel Arc A770") - - with patch( - "vocalinux.speech_recognition.model_ladder.cuda_toolkit_ready", - return_value=True, - ): - from vocalinux.utils.whispercpp_model_info import get_recommended_model - - model, reason = get_recommended_model() - - self.assertEqual(model, "small") - self.assertIn("8GB", reason) + """The recommendation is a ladder rung keyed to the installed build and free memory. + + Spec #16 / ticket #20. Every reading is mocked: the installed build's + backend, free VRAM from nvidia-smi and free system RAM. Total memory is + never consulted, so no test here passes a total as a free reading. + """ + + LADDER = {"large-v3-turbo-q8_0", "large-v3-turbo-q5_0", "small"} + + def _recommend(self, build_backend, free_vram=None, free_ram_mib=10 * 1024.0): + from vocalinux.speech_recognition import model_ladder, recognition_manager + from vocalinux.utils import whispercpp_model_info as info + + with ( + patch.object( + recognition_manager, + "detect_pywhispercpp_gpu_backend", + return_value=build_backend, + ), + patch.object(model_ladder, "free_vram_mib", return_value=free_vram), + patch.object(model_ladder, "free_system_ram_mib", return_value=free_ram_mib), + ): + return info.get_recommended_model() + + def test_returns_tuple(self): + model, reason = self._recommend("vulkan", free_vram=2600.0) + self.assertIsInstance(model, str) + self.assertIsInstance(reason, str) + + def test_ample_free_vram_on_vulkan_build_selects_default_rung(self): + model, reason = self._recommend("vulkan", free_vram=2600.0) + self.assertEqual(model, "large-v3-turbo-q8_0") + self.assertIn("VRAM", reason) + + def test_constrained_free_vram_steps_to_q5_rung(self): + model, reason = self._recommend("vulkan", free_vram=1500.0) + self.assertEqual(model, "large-v3-turbo-q5_0") + self.assertIn("1500 MiB free VRAM", reason) + + def test_tight_free_vram_selects_floor(self): + model, _reason = self._recommend("vulkan", free_vram=900.0) + self.assertEqual(model, "small") + + def test_total_vram_is_not_the_reading(self): + """A 4 GB card whose VRAM is mostly taken by the desktop gets the floor.""" + model, _reason = self._recommend("vulkan", free_vram=512.0) + self.assertEqual(model, "small") + + def test_integrated_graphics_read_free_ram(self): + """No NVIDIA reading means the Vulkan build keeps its model in system RAM.""" + model, reason = self._recommend("vulkan", free_vram=None, free_ram_mib=10 * 1024.0) + self.assertEqual(model, "large-v3-turbo-q8_0") + self.assertIn("RAM", reason) + + def test_cpu_build_ignores_a_visible_gpu_and_reads_ram(self): + """A CPU-only build never runs on the GPU, so VRAM is not the constraint.""" + model, reason = self._recommend("cpu", free_vram=2600.0, free_ram_mib=1200.0) + self.assertEqual(model, "small") + self.assertIn("RAM", reason) + self.assertNotIn("VRAM", reason) + + def test_keys_to_the_installed_build_not_the_hardware_probe(self): + """The informational probe reporting CPU must not hide an installed GPU build.""" + from vocalinux.speech_recognition import model_ladder, recognition_manager + from vocalinux.utils import whispercpp_model_info as info + + with ( + patch.object( + info, "detect_compute_backend", return_value=(info.ComputeBackend.CPU, "x") + ), + patch.object( + recognition_manager, + "detect_pywhispercpp_gpu_backend", + return_value="vulkan", + ), + patch.object(model_ladder, "free_vram_mib", return_value=2600.0), + patch.object(model_ladder, "free_system_ram_mib", return_value=512.0), + ): + model, reason = info.get_recommended_model() + self.assertEqual(model, "large-v3-turbo-q8_0") + self.assertIn("VRAM", reason) + + def test_unreadable_memory_recommends_the_floor(self): + model, reason = self._recommend("vulkan", free_vram=None, free_ram_mib=None) + self.assertEqual(model, "small") + self.assertIn("unreadable", reason) + + def test_recommendation_stays_inside_the_ladder_for_any_reading(self): + """Unquantized turbo, the large family and medium never come back as a default.""" + for backend in ("vulkan", "cuda", "cpu"): + for free_vram in (None, 0.0, 900.0, 1500.0, 2600.0, 8000.0): + for free_ram in (512.0, 1200.0, 4096.0, 16384.0): + model, _reason = self._recommend(backend, free_vram, free_ram) + self.assertIn(model, self.LADDER, (backend, free_vram, free_ram)) + + def test_reason_names_the_backend_the_build_has(self): + _model, reason = self._recommend("cuda", free_vram=2600.0) + self.assertIn("cuda build", reason) class TestGetModelPath(unittest.TestCase):