Files
mlx-knife/tests_2.0/test_runtime_compatibility_reason_chain.py
The BROKE Cluster Team f5fe1dd061 Release 2.0.0-beta.5: Enhanced error handling & bug fixes
Features:
- Enhanced error handling & logging (ADR-004): Unified error envelope, structured logging with JSON support, request correlation
- Legacy format detection (Issue #37): Runtime compatibility check for weight file formats

Bug Fixes:
- Issue #37: Models with legacy weight formats now correctly detected as runtime-incompatible
- CLI regression fix: mlxk2 without arguments shows help instead of JSON error

Test Status: 295/295 passed, 14 skipped
2025-10-21 00:24:47 +02:00

380 lines
16 KiB
Python

"""Tests for runtime compatibility reason field decision chain (Issue #36).
Critical requirement: The reason field must reflect the FIRST problem encountered:
1. Health check failures take precedence over runtime failures
2. Gate 1 (framework) failures take precedence over Gate 2 (legacy format)
3. Gate 2 (legacy format) failures take precedence over Gate 3 (model_type)
4. Only when ALL checks pass should reason be None
This ensures users see the most actionable error message first.
"""
import json
from pathlib import Path
import pytest
import sys
# Check if mlx-lm is available for Gate 3 tests
# Note: Gate 3 tests require a working mlx-lm installation with _get_classes API
# Current implementation has compatibility issues with mlx-lm 0.28.x API changes
try:
import mlx_lm
# Try to import the function we actually need
try:
from mlx_lm.models.base import _get_classes
HAS_WORKING_MLX_LM = True
except ImportError:
# Try old API
try:
from mlx_lm.utils import _get_classes
HAS_WORKING_MLX_LM = True
except ImportError:
HAS_WORKING_MLX_LM = False
except ImportError:
HAS_WORKING_MLX_LM = False
requires_mlx_lm = pytest.mark.skipif(
not HAS_WORKING_MLX_LM,
reason="mlx-lm not available or _get_classes API not found (required for Gate 3)"
)
# ============================================================================
# Test Helpers
# ============================================================================
def _create_config(snap: Path, model_type: str = "llama"):
"""Create a minimal valid config.json."""
config = {
"model_type": model_type,
"architectures": ["LlamaForCausalLM"]
}
(snap / "config.json").write_text(json.dumps(config))
def _create_healthy_mlx_model(cache: Path, name: str, weights_pattern: str = "model.safetensors", model_type: str = "llama"):
"""Create a healthy MLX model with specified weight file pattern."""
snap = cache / f"models--{name.replace('/', '--')}" / "snapshots" / "main"
snap.mkdir(parents=True)
_create_config(snap, model_type)
(snap / weights_pattern).write_bytes(b"fake_weights" * 100)
return snap
# ============================================================================
# Reason Chain Tests: Health Check Precedence (Highest Priority)
# ============================================================================
def test_reason_chain_health_failure_overrides_framework_failure(isolated_cache):
"""Health failure reason should take precedence over framework incompatibility.
Scenario: GGUF model (would fail Gate 1) but missing config.json (health failure)
Expected: reason = "config.json missing" (NOT "Incompatible: GGUF")
"""
snap = isolated_cache / "models--test--broken-gguf" / "snapshots" / "main"
snap.mkdir(parents=True)
# Missing config.json → unhealthy
(snap / "model.gguf").write_bytes(b"fake" * 100)
from mlxk2.operations.common import build_model_object
model_obj = build_model_object("test/broken-gguf", snap.parent.parent, snap)
assert model_obj["health"] == "unhealthy"
assert model_obj["runtime_compatible"] is False
assert "config.json" in model_obj["reason"]
assert "GGUF" not in model_obj["reason"], "Framework reason should not appear when health check fails"
def test_reason_chain_health_failure_overrides_legacy_format(isolated_cache):
"""Health failure should take precedence over legacy format detection.
Scenario: Legacy weights but missing config.json
Expected: reason = "config.json missing" (NOT "Legacy format")
"""
snap = isolated_cache / "models--test--broken-legacy" / "snapshots" / "main"
snap.mkdir(parents=True)
# Missing config.json → unhealthy
(snap / "weights.00.safetensors").write_bytes(b"fake" * 100)
from mlxk2.operations.common import build_model_object
model_obj = build_model_object("test/broken-legacy", snap.parent.parent, snap)
assert model_obj["health"] == "unhealthy"
assert model_obj["runtime_compatible"] is False
assert "config.json" in model_obj["reason"]
assert "Legacy" not in model_obj["reason"], "Legacy format reason should not appear when health check fails"
# ============================================================================
# Reason Chain Tests: Gate 1 (Framework) Precedence
# ============================================================================
def test_reason_chain_gate1_framework_check_gguf(isolated_cache):
"""Gate 1 failure (GGUF) should be reported even if model has legacy weights.
Scenario: Healthy GGUF model with legacy-named files
Expected: reason = "Incompatible: GGUF" (Gate 1 fails, Gate 2 never runs)
"""
snap = _create_healthy_mlx_model(isolated_cache, "test/gguf-legacy", "weights.00.gguf")
from mlxk2.operations.health import check_runtime_compatibility
compatible, reason = check_runtime_compatibility(snap, "GGUF")
assert compatible is False
assert reason == "Incompatible: GGUF"
# Gate 1 fails early, so legacy format detection (Gate 2) never runs
def test_reason_chain_gate1_framework_check_pytorch(isolated_cache):
"""Gate 1 failure (PyTorch) should take precedence."""
snap = _create_healthy_mlx_model(isolated_cache, "test/pytorch", "model.safetensors")
from mlxk2.operations.health import check_runtime_compatibility
compatible, reason = check_runtime_compatibility(snap, "PyTorch")
assert compatible is False
assert reason == "Incompatible: PyTorch"
def test_reason_chain_gate1_mlx_framework_passes(isolated_cache):
"""MLX framework should pass Gate 1 (may fail at later gates)."""
snap = _create_healthy_mlx_model(isolated_cache, "test/mlx", "weights.00.safetensors") # Legacy format
from mlxk2.operations.health import check_runtime_compatibility
compatible, reason = check_runtime_compatibility(snap, "MLX")
# Should pass Gate 1 but fail at Gate 2 (legacy format)
assert compatible is False
assert "Incompatible: MLX" not in reason, "Should not fail at Gate 1 for MLX framework"
assert "Legacy format" in reason, "Should fail at Gate 2 for legacy weights"
# ============================================================================
# Reason Chain Tests: Gate 2 (Legacy Format) Precedence
# ============================================================================
def test_reason_chain_gate2_legacy_weights_numeric(isolated_cache):
"""Gate 2 should detect weights.NN.safetensors legacy format.
Even if model_type might be unsupported (Gate 3), legacy format (Gate 2) is reported first.
"""
snap = isolated_cache / "models--test--legacy-weights" / "snapshots" / "main"
snap.mkdir(parents=True)
_create_config(snap, model_type="some_fake_unsupported_type_xyz") # Would fail Gate 3
(snap / "weights.00.safetensors").write_bytes(b"fake" * 100)
(snap / "weights.01.safetensors").write_bytes(b"fake" * 100)
from mlxk2.operations.health import check_runtime_compatibility
compatible, reason = check_runtime_compatibility(snap, "MLX")
assert compatible is False
assert "Legacy format not supported by mlx-lm" in reason
# Gate 2 fails, so Gate 3 (model_type check) never runs
def test_reason_chain_gate2_legacy_pytorch_model_numeric(isolated_cache):
"""Gate 2 should detect pytorch_model-NNNNN.safetensors legacy format."""
snap = isolated_cache / "models--test--legacy-pytorch" / "snapshots" / "main"
snap.mkdir(parents=True)
_create_config(snap, model_type="some_fake_type") # Would fail Gate 3
(snap / "pytorch_model-00001.safetensors").write_bytes(b"fake" * 100)
from mlxk2.operations.health import check_runtime_compatibility
compatible, reason = check_runtime_compatibility(snap, "MLX")
assert compatible is False
assert "Legacy format not supported by mlx-lm" in reason
def test_reason_chain_gate2_modern_format_passes(isolated_cache):
"""Modern model.safetensors should pass Gate 2."""
snap = isolated_cache / "models--test--modern" / "snapshots" / "main"
snap.mkdir(parents=True)
_create_config(snap, model_type="llama")
(snap / "model.safetensors").write_bytes(b"fake" * 100)
from mlxk2.operations.health import check_runtime_compatibility
compatible, reason = check_runtime_compatibility(snap, "MLX")
# Should pass Gates 1 and 2, outcome depends on Gate 3 (model_type check)
# If mlx-lm supports llama, should be compatible
# If not compatible, reason should NOT be about legacy format
if not compatible:
assert "Legacy format" not in reason, "Modern format should not trigger legacy format error"
def test_reason_chain_gate2_sharded_modern_format_passes(isolated_cache):
"""Modern sharded model-XXXXX-of-YYYYY.safetensors should pass Gate 2."""
snap = isolated_cache / "models--test--sharded" / "snapshots" / "main"
snap.mkdir(parents=True)
_create_config(snap, model_type="llama")
(snap / "model-00001-of-00002.safetensors").write_bytes(b"fake" * 100)
(snap / "model-00002-of-00002.safetensors").write_bytes(b"fake" * 100)
from mlxk2.operations.health import check_runtime_compatibility
compatible, reason = check_runtime_compatibility(snap, "MLX")
# Should pass Gate 2
if not compatible:
assert "Legacy format" not in reason, "Modern sharded format should not trigger legacy format error"
# ============================================================================
# Reason Chain Tests: Gate 3 (model_type Support)
# ============================================================================
@requires_mlx_lm
def test_reason_chain_gate3_unsupported_model_type(isolated_cache):
"""Gate 3 should only run if Gates 1 and 2 pass.
This test uses a clearly fake model_type that mlx-lm won't support.
The error should be about model_type, not about framework or legacy format.
"""
snap = isolated_cache / "models--test--unsupported-arch" / "snapshots" / "main"
snap.mkdir(parents=True)
_create_config(snap, model_type="definitely_not_a_real_architecture_xyz123")
(snap / "model.safetensors").write_bytes(b"fake" * 100)
from mlxk2.operations.health import check_runtime_compatibility
compatible, reason = check_runtime_compatibility(snap, "MLX")
# Should fail at Gate 3
assert compatible is False
assert reason is not None
# Reason should be about model_type, not framework or legacy format
assert "Incompatible:" not in reason, "Should not fail at Gate 1"
assert "Legacy format" not in reason, "Should not fail at Gate 2"
# Should mention model_type or architecture
assert ("model_type" in reason.lower() or
"not supported" in reason.lower() or
"architecture" in reason.lower()), f"Gate 3 should report model_type issue, got: {reason}"
@requires_mlx_lm
def test_reason_chain_gate3_supported_model_type_llama(isolated_cache):
"""Well-known supported model_type (llama) should pass all gates.
This is the happy path: MLX framework, modern format, supported architecture.
"""
snap = isolated_cache / "models--test--llama-supported" / "snapshots" / "main"
snap.mkdir(parents=True)
_create_config(snap, model_type="llama") # Well-known supported type
(snap / "model.safetensors").write_bytes(b"fake" * 100)
from mlxk2.operations.health import check_runtime_compatibility
compatible, reason = check_runtime_compatibility(snap, "MLX")
# Should pass all gates (assuming mlx-lm supports llama)
assert compatible is True, f"llama model_type should be supported, got reason: {reason}"
assert reason is None, "Fully compatible models should have reason=None"
# ============================================================================
# Integration Tests: build_model_object() Reason Field
# ============================================================================
def test_integration_reason_field_unhealthy_model(isolated_cache):
"""Integration: Unhealthy model should show health reason in model object."""
snap = isolated_cache / "models--test--no-config" / "snapshots" / "main"
snap.mkdir(parents=True)
# Missing config.json → unhealthy
(snap / "model.safetensors").write_bytes(b"fake" * 100)
from mlxk2.operations.common import build_model_object
model_obj = build_model_object("test/no-config", snap.parent.parent, snap)
assert model_obj["health"] == "unhealthy"
assert model_obj["runtime_compatible"] is False
assert "config.json" in model_obj["reason"]
def test_integration_reason_field_gguf_model(isolated_cache):
"""Integration: Healthy GGUF model should show framework incompatibility."""
snap = isolated_cache / "models--test--gguf-healthy" / "snapshots" / "main"
snap.mkdir(parents=True)
_create_config(snap)
(snap / "model.gguf").write_bytes(b"fake" * 100)
from mlxk2.operations.common import build_model_object
model_obj = build_model_object("test/gguf-healthy", snap.parent.parent, snap)
assert model_obj["health"] == "healthy"
assert model_obj["runtime_compatible"] is False
assert "Incompatible: GGUF" in model_obj["reason"] or "GGUF" in model_obj["reason"]
def test_integration_reason_field_legacy_mlx_model(isolated_cache):
"""Integration: Healthy MLX model with legacy weights should show legacy format reason.
Important: Model must be recognized as MLX framework (via mlx-community prefix)
so it passes Gate 1 and reaches Gate 2 (legacy format check).
"""
snap = isolated_cache / "models--mlx-community--legacy-test" / "snapshots" / "main"
snap.mkdir(parents=True)
_create_config(snap)
(snap / "weights.00.safetensors").write_bytes(b"fake" * 100)
from mlxk2.operations.common import build_model_object
model_obj = build_model_object("mlx-community/legacy-test", snap.parent.parent, snap)
assert model_obj["health"] == "healthy"
assert model_obj["framework"] == "MLX", "Model should be detected as MLX framework"
assert model_obj["runtime_compatible"] is False
assert "Legacy format" in model_obj["reason"]
def test_integration_reason_field_compatible_mlx_model(isolated_cache):
"""Integration: Fully compatible MLX model should have reason=None."""
snap = isolated_cache / "models--test--mlx-compatible" / "snapshots" / "main"
snap.mkdir(parents=True)
_create_config(snap, model_type="llama") # Well-known supported type
(snap / "model.safetensors").write_bytes(b"fake" * 100)
from mlxk2.operations.common import build_model_object
model_obj = build_model_object("test/mlx-compatible", snap.parent.parent, snap)
assert model_obj["health"] == "healthy"
# Should be compatible (assuming mlx-lm supports llama)
if model_obj["runtime_compatible"]:
assert model_obj["reason"] is None, "Fully compatible models must have reason=None"
# ============================================================================
# Edge Cases
# ============================================================================
def test_reason_chain_mixed_legacy_and_modern_weights(isolated_cache):
"""Model with BOTH legacy and modern weights should pass Gate 2.
Gate 2 logic: `if has_legacy and not has_valid` → fail
If has_valid=True (modern weights exist), should NOT fail at Gate 2.
"""
snap = isolated_cache / "models--test--mixed-weights" / "snapshots" / "main"
snap.mkdir(parents=True)
_create_config(snap, model_type="llama")
# Both legacy and modern formats present
(snap / "weights.00.safetensors").write_bytes(b"fake" * 100) # Legacy
(snap / "model.safetensors").write_bytes(b"fake" * 100) # Modern
from mlxk2.operations.health import check_runtime_compatibility
compatible, reason = check_runtime_compatibility(snap, "MLX")
# Should pass Gate 2 (has valid modern weights)
if not compatible:
assert "Legacy format" not in reason, "Should not fail at Gate 2 when modern weights exist"
def test_reason_chain_no_weights_at_all(isolated_cache):
"""Model with config but no weights should fail health check, not runtime check."""
snap = isolated_cache / "models--test--no-weights" / "snapshots" / "main"
snap.mkdir(parents=True)
_create_config(snap)
# No weight files at all
from mlxk2.operations.health import _check_snapshot_health
healthy, reason = _check_snapshot_health(snap)
# Should fail health check
assert healthy is False
assert "weights" in reason.lower() or "No model weights" in reason