diff --git a/CHANGELOG.md b/CHANGELOG.md index 1301c75c..223f8f87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,15 @@ ## [Unreleased] ### Added +- **FunPack's loaders take `.gguf` files**, for diffusion models and text encoders alike, and + a text-encoder list may mix a `.gguf` with `.safetensors` slots (the usual LTX-2.3 shape). + Core's extension set has no `.gguf`, so those files were on disk and invisible to every + picker. The runtime is whichever backend is present: ComfyUI-GGUF keeps the weights + quantized in VRAM, which is the point of GGUF; failing that, the `gguf` package is used to + dequantize at load — the file loads, but at full size, and the status output says which of + the two happened. With neither, the loader names both remedies instead of failing obscurely. + Model-family detection reads GGUF containers too, using the same architecture signatures. + - **Pin up to three shortcuts to the timeline toolbar.** Settings is a window with a sidebar, and its useful places — a specific node's page, one Engine category — are several clicks deep: fine to walk once, tedious while dialling something in. `📌 Pin to a button` in the diff --git a/gguf_support.py b/gguf_support.py new file mode 100644 index 00000000..fde136ae --- /dev/null +++ b/gguf_support.py @@ -0,0 +1,230 @@ +"""Load .gguf model files through FunPack's own loaders. + +GGUF is two problems, and only the first is small: + +1. **Reading the container.** A header, tensor metadata, then quantized blocks. The `gguf` + package (llama.cpp's own) does this, and ships a reference dequantizer for every quant + type it defines. + +2. **Using the weights without dequantizing them.** The reason people reach for GGUF is that + a Q4 model occupies a quarter of the VRAM — which only holds if the weights STAY + quantized and are expanded per-layer during the forward pass. That needs a custom + torch ops class, and hand-written kernels per quant type. + +So this module does not hand-roll (2). It picks the best backend actually present: + + * **ComfyUI-GGUF installed** — use it. It is the mature implementation of (2), and its + output feeds `comfy.sd.load_diffusion_model_state_dict` exactly like ours does, so + FunPack's loader stays the only UI while its runtime does the quantized maths. + * **`gguf` package only** — read and dequantize at LOAD time, then hand over an ordinary + state dict. Fully native and correct, but the model occupies its dequantized size, so + the VRAM saving is gone. Said out loud rather than left to be discovered. + * **Neither** — refuse with the two ways to fix it, instead of a stack trace. + +Nothing here downloads or installs anything. +""" +from __future__ import annotations + +import importlib.util +import os +from typing import Optional + +GGUF_EXT = ".gguf" + + +def is_gguf(name: str) -> bool: + return str(name or "").lower().endswith(GGUF_EXT) + + +# ── finding .gguf files ─────────────────────────────────────────────────────── + +def gguf_names(folder: str) -> list[str]: + """`.gguf` files under a ComfyUI model folder, relative-named like get_filename_list(). + + Core's `supported_pt_extensions` has no `.gguf`, so `get_filename_list` never returns + one — the files are on disk and simply invisible to every picker. Scanned directly + rather than by mutating core's global extension set, which would change what every + other node in the install offers. + """ + try: + import folder_paths + except ImportError: + return [] + out: list[str] = [] + seen: set[str] = set() + roots = list(folder_paths.get_folder_paths(folder) or []) + # ComfyUI-GGUF registers its own folders; include them under the same picker so a user + # who already keeps files there does not have to move them. + for extra in ("unet_gguf" if folder == "diffusion_models" else "clip_gguf",): + try: + roots += list(folder_paths.get_folder_paths(extra) or []) + except Exception: + pass + for root in roots: + if not root or not os.path.isdir(root): + continue + for dirpath, _dirs, files in os.walk(root, followlinks=True): + for f in files: + if not is_gguf(f): + continue + rel = os.path.relpath(os.path.join(dirpath, f), root) + if rel not in seen: + seen.add(rel) + out.append(rel) + return sorted(out) + + +def gguf_path(folder: str, name: str) -> Optional[str]: + """Absolute path of a `.gguf` picked out of `gguf_names`.""" + try: + import folder_paths + except ImportError: + return None + roots = list(folder_paths.get_folder_paths(folder) or []) + for extra in ("unet_gguf" if folder == "diffusion_models" else "clip_gguf",): + try: + roots += list(folder_paths.get_folder_paths(extra) or []) + except Exception: + pass + for root in roots: + cand = os.path.join(root or "", name) + if os.path.isfile(cand): + return cand + return None + + +# ── backends ────────────────────────────────────────────────────────────────── + +def _load_pack_module(dirname: str, mod: str): + """Import one module out of a custom-node package by path. + + Custom node folders are not importable by name (``ComfyUI-GGUF`` is not an identifier), + so the file is loaded directly. + """ + spec = importlib.util.spec_from_file_location( + f"_funpack_gguf_{mod}", os.path.join(dirname, f"{mod}.py")) + if spec is None or spec.loader is None: + return None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _pack_dir() -> Optional[str]: + """ComfyUI-GGUF's directory, identified by what it CONTAINS rather than by its name — + the folder gets renamed by Manager, by forks, and by hand.""" + try: + import folder_paths + base = os.path.join(folder_paths.base_path, "custom_nodes") + except Exception: + return None + if not os.path.isdir(base): + return None + for entry in sorted(os.listdir(base)): + d = os.path.join(base, entry) + loader = os.path.join(d, "loader.py") + if not os.path.isfile(loader) or not os.path.isfile(os.path.join(d, "ops.py")): + continue + try: + with open(loader, "r", encoding="utf-8", errors="replace") as fh: + if "gguf_sd_loader" in fh.read(): + return d + except OSError: + continue + return None + + +def backend() -> Optional[str]: + """"pack" (quantized in VRAM), "native" (dequantized at load), or None.""" + if _pack_dir(): + return "pack" + if importlib.util.find_spec("gguf") is not None: + return "native" + return None + + +UNAVAILABLE = ( + "No GGUF backend is available. Either install ComfyUI-GGUF (custom node pack — keeps the " + "weights quantized in VRAM, which is the point of GGUF), or `pip install gguf` into " + "ComfyUI's environment for a load-time dequantize that works but gives back no memory." +) + + +def load_state_dict(path: str) -> tuple[dict, dict, str]: + """Read one .gguf into (state_dict, model_options, note). + + `model_options` carries the custom operations the quantized path needs; it is empty for + the native path, whose tensors are ordinary ones by the time they are returned. + """ + which = backend() + if which is None: + raise RuntimeError(UNAVAILABLE) + if which == "pack": + d = _pack_dir() + loader = _load_pack_module(d, "loader") + ops = _load_pack_module(d, "ops") + sd = loader.gguf_sd_loader(path) + options = {} + ggml_ops = getattr(ops, "GGMLOps", None) + if ggml_ops is not None: + options["custom_operations"] = ggml_ops() + return sd, options, f"gguf: quantized (ComfyUI-GGUF at {os.path.basename(d)})" + return _load_native(path) + + +def load_clip_state_dict(path: str) -> tuple[dict, dict, str]: + """Same as `load_state_dict`, for a text encoder. + + ComfyUI-GGUF reads encoders through a separate entry point: an encoder's GGUF carries + llama.cpp tensor names that have to be mapped back to the transformer's, which its + diffusion-model reader does not do. + """ + which = backend() + if which is None: + raise RuntimeError(UNAVAILABLE) + if which == "pack": + d = _pack_dir() + loader = _load_pack_module(d, "loader") + ops = _load_pack_module(d, "ops") + read = getattr(loader, "gguf_clip_loader", None) or loader.gguf_sd_loader + sd = read(path) + options = {} + ggml_ops = getattr(ops, "GGMLOps", None) + if ggml_ops is not None: + options["custom_operations"] = ggml_ops() + return sd, options, f"gguf: quantized (ComfyUI-GGUF at {os.path.basename(d)})" + return _load_native(path) + + +def _load_native(path: str) -> tuple[dict, dict, str]: + """Dequantize every tensor to torch at load time using gguf's reference implementation. + + Correct but not thrifty: the model ends up at its dequantized size, so this buys the + ability to LOAD a .gguf, not GGUF's memory saving. The caller states that. + """ + import gguf + import torch + + reader = gguf.GGUFReader(path) + sd: dict = {} + quantized = 0 + for tensor in reader.tensors: + name = str(tensor.name) + qtype = tensor.tensor_type + if qtype in (gguf.GGMLQuantizationType.F32, gguf.GGMLQuantizationType.F16): + arr = tensor.data + else: + # gguf.quants.dequantize is the reference implementation for every type the + # package knows — do not reimplement it per quant type. + arr = gguf.quants.dequantize(tensor.data, qtype) + quantized += 1 + t = torch.from_numpy(arr.copy()) + # GGUF stores shapes reversed relative to torch. + shape = tuple(int(d) for d in reversed(tensor.shape)) + if shape and t.numel() == int(torch.tensor(shape).prod()): + t = t.reshape(shape) + sd[name] = t.to(torch.float16) if t.dtype == torch.float32 else t + note = (f"gguf: dequantized at load ({quantized} quantized tensors expanded) — the file " + f"loads, but it occupies its full size in VRAM. Install ComfyUI-GGUF to keep it " + f"quantized.") + return sd, {}, note diff --git a/loaders.py b/loaders.py index 24cf0716..81468377 100644 --- a/loaders.py +++ b/loaders.py @@ -19,11 +19,29 @@ import torch try: from .widgets import field, list_widget, parse_rows - from . import sla_attention + from . import gguf_support, sla_attention except ImportError: # standalone tests import the modules directly from widgets import field, list_widget, parse_rows + import gguf_support import sla_attention +def model_file_choices(): + """Diffusion model files, `.gguf` included. + + Core's extension set has no `.gguf`, so those files are on disk and invisible to every + picker. Appended rather than merged in sorted order, so an existing pipeline's saved + choice keeps its position in the list and nothing a user already picked moves. + """ + return list(folder_paths.get_filename_list("diffusion_models")) + \ + gguf_support.gguf_names("diffusion_models") + + +def encoder_file_choices(): + """Text encoder files, `.gguf` included. Same reasoning as model_file_choices().""" + return list(folder_paths.get_filename_list("text_encoders")) + \ + gguf_support.gguf_names("text_encoders") + + WEIGHT_DTYPES = ["default", "fp8_e4m3fn", "fp8_e4m3fn_fast", "fp8_e5m2", "fp16", "bf16", "fp32"] COMPUTE_DTYPES = ["default", "fp16", "bf16", "fp32"] VAE_DTYPES = ["default", "fp16", "bf16", "fp32"] @@ -124,8 +142,10 @@ class FunPackDiffusionModelLoader: def INPUT_TYPES(cls): return { "required": { - "model_name": (folder_paths.get_filename_list("diffusion_models"), { - "tooltip": "The diffusion model file, from ComfyUI/models/diffusion_models."}), + "model_name": (model_file_choices(), { + "tooltip": "The diffusion model file, from ComfyUI/models/diffusion_models. " + ".gguf files are listed too (see the status output for which " + "GGUF backend loaded it)."}), "weight_dtype": (WEIGHT_DTYPES, { "default": "default", "tooltip": "How weights are stored in VRAM. fp8_e4m3fn roughly halves the " @@ -220,8 +240,20 @@ class FunPackDiffusionModelLoader: model_options = weight_model_options(weight_dtype) notes.append(f"weight dtype: {weight_dtype}") - path = folder_paths.get_full_path_or_raise("diffusion_models", model_name) - state_dict, metadata = comfy.utils.load_torch_file(path, return_metadata=True) + if gguf_support.is_gguf(model_name): + path = gguf_support.gguf_path("diffusion_models", model_name) + if not path: + raise RuntimeError(f"ERROR: {model_name} is no longer where it was listed from.") + state_dict, gguf_options, gguf_note = gguf_support.load_state_dict(path) + # The quantized path needs its own torch operations, and they must not be lost to + # the dtype options merged above — a GGUF loaded with stock ops would try to matmul + # block-quantized storage. + model_options = {**model_options, **gguf_options} + metadata = None + notes.append(gguf_note) + else: + path = folder_paths.get_full_path_or_raise("diffusion_models", model_name) + state_dict, metadata = comfy.utils.load_torch_file(path, return_metadata=True) model = comfy.sd.load_diffusion_model_state_dict( state_dict, model_options=model_options, metadata=metadata) if model is None: @@ -283,7 +315,7 @@ class FunPackCLIPLoader: @classmethod def INPUT_TYPES(cls): - encoders = folder_paths.get_filename_list("text_encoders") + encoders = encoder_file_choices() types = cls.clip_types() return { "required": { @@ -292,7 +324,8 @@ class FunPackCLIPLoader: [field("clip_name", "combo", label="file", choices=encoders)], add_label="+ Add slot", tooltip="Text encoder files, in load order. One slot for LTX-2.5 (Gemma4); " - "two for LTX-2.3 (Gemma3 + its connector)."), + "two for LTX-2.3 (Gemma3 + its connector). .gguf files are listed " + "too, and may be mixed with .safetensors ones."), "type": (types, { "default": "ltxv" if "ltxv" in types else (types[0] if types else "ltxv"), "tooltip": "Which model family the encoder is for. LTX-2 (all point " @@ -317,15 +350,43 @@ class FunPackCLIPLoader: if device == "cpu": model_options["load_device"] = model_options["offload_device"] = torch.device("cpu") - paths = [folder_paths.get_full_path_or_raise("text_encoders", n) for n in names] - clip = comfy.sd.load_clip( - ckpt_paths=paths, - embedding_directory=folder_paths.get_folder_paths("embeddings"), - clip_type=clip_type, - model_options=model_options, - ) + gguf_notes = [] + if any(gguf_support.is_gguf(n) for n in names): + # A .gguf encoder cannot go through load_clip(), which reads files itself. Every + # slot becomes a state dict instead, so a GGUF and a .safetensors connector can + # sit in the same list — which is the normal LTX-2.3 shape. + state_dicts = [] + for n in names: + if gguf_support.is_gguf(n): + gpath = gguf_support.gguf_path("text_encoders", n) + if not gpath: + raise RuntimeError(f"FunPack CLIP Loader: {n} is no longer where it " + f"was listed from.") + sd, gopts, gnote = gguf_support.load_clip_state_dict(gpath) + state_dicts.append(sd) + model_options = {**model_options, **gopts} + gguf_notes.append(f"{n}: {gnote}") + else: + state_dicts.append(comfy.utils.load_torch_file( + folder_paths.get_full_path_or_raise("text_encoders", n))) + clip = comfy.sd.load_text_encoder_state_dicts( + state_dicts, + embedding_directory=folder_paths.get_folder_paths("embeddings"), + clip_type=clip_type, + model_options=model_options, + ) + else: + paths = [folder_paths.get_full_path_or_raise("text_encoders", n) for n in names] + clip = comfy.sd.load_clip( + ckpt_paths=paths, + embedding_directory=folder_paths.get_folder_paths("embeddings"), + clip_type=clip_type, + model_options=model_options, + ) status = "FunPack CLIP Loader | type={} device={}\n{}".format( type, device, "\n".join(f" {i + 1}. {n}" for i, n in enumerate(names))) + for gn in gguf_notes: + status += f"\n {gn}" return (clip, status) diff --git a/movie_editor/backend/model_probe.py b/movie_editor/backend/model_probe.py index 6fe92452..83b2ca84 100644 --- a/movie_editor/backend/model_probe.py +++ b/movie_editor/backend/model_probe.py @@ -56,6 +56,25 @@ def read_safetensors_keys(path: str | Path) -> Optional[list[str]]: return [k for k in header if k != "__metadata__"] +def read_gguf_keys(path: str | Path) -> Optional[list[str]]: + """Tensor names from a GGUF file, or None when they cannot be read. + + Delegated to llama.cpp's own `gguf` package rather than hand-parsing the container: + the header is a typed key-value section of variable-length records, and getting that + subtly wrong would misidentify a model rather than fail. The package is optional, so + None here means "cannot tell", never "wrong family". + """ + try: + import gguf # noqa: PLC0415 + except ImportError: + return None + try: + reader = gguf.GGUFReader(str(path)) + return [str(t.name) for t in reader.tensors] + except Exception: # noqa: BLE001 — any malformed container is simply unreadable + return None + + def detect_arch(keys: Iterable[str]) -> Optional[str]: """`minimax_h3` | `ltxav` | `ltxv`, or None when the keys match no architecture we wire. @@ -86,6 +105,13 @@ def detect_family(path: str | Path) -> dict: if not p.is_file(): return {"family": None, "arch": None, "detected": False, "reason": f"{p.name}: file not found"} + if p.suffix.lower() == ".gguf": + keys = read_gguf_keys(p) + if keys is None: + return {"family": None, "arch": None, "detected": False, + "reason": f"{p.name}: a .gguf can only be inspected with the `gguf` package " + f"installed — set the family from Models ▸ Model family instead"} + return _from_keys(p, keys) if p.suffix.lower() != ".safetensors": # .ckpt / .pth are pickles; reading them means executing them, which is not worth it # for a family probe. The declared family stands and the caller is told why. @@ -95,6 +121,12 @@ def detect_family(path: str | Path) -> dict: if keys is None: return {"family": None, "arch": None, "detected": False, "reason": f"{p.name}: not a readable safetensors header"} + return _from_keys(p, keys) + + +def _from_keys(p: Path, keys: Iterable[str]) -> dict: + """Same verdict from either container: the signatures are key names, so it does not + matter which format they were read out of.""" arch = detect_arch(keys) if arch is None: return {"family": None, "arch": None, "detected": False, diff --git a/movie_editor/tests/test_model_probe.py b/movie_editor/tests/test_model_probe.py index e3296673..05edeb13 100644 --- a/movie_editor/tests/test_model_probe.py +++ b/movie_editor/tests/test_model_probe.py @@ -148,3 +148,41 @@ def test_an_unidentifiable_checkpoint_proposes_no_family(tmp_path): out = model_probe.probe_models({"slots": [slot(model_name="wan.safetensors")]}, resolve=lambda name: str(f)) assert out["family"] is None + + +def test_gguf_without_the_library_says_so_and_proposes_no_family(tmp_path, monkeypatch): + """A .gguf that cannot be read must never fall through to a guess — the previous family + stands, and the panel says why plus where to set it by hand.""" + import movie_editor.backend.model_probe as mp + p = tmp_path / "ltx-Q4_K_M.gguf" + p.write_bytes(b"GGUF\x03\x00\x00\x00") + monkeypatch.setattr(mp, "read_gguf_keys", lambda _p: None) + out = mp.detect_family(p) + assert out["family"] is None + assert out["detected"] is False + assert "gguf" in out["reason"].lower() + assert "Model family" in out["reason"] + + +def test_gguf_is_identified_from_the_same_signatures(tmp_path, monkeypatch): + """The architecture signatures are KEY NAMES, so the container they came out of is + irrelevant — a GGUF and a safetensors of the same model must agree.""" + import movie_editor.backend.model_probe as mp + p = tmp_path / "h3-Q8_0.gguf" + p.write_bytes(b"GGUF") + monkeypatch.setattr(mp, "read_gguf_keys", + lambda _p: ["video_patch_proj.weight", "audio_patch_proj.weight"]) + out = mp.detect_family(p) + assert out["arch"] == "minimax_h3" + assert out["family"] == "minimax_h3" + assert out["detected"] is True + + +def test_gguf_with_no_known_signature_proposes_nothing(tmp_path, monkeypatch): + import movie_editor.backend.model_probe as mp + p = tmp_path / "something.gguf" + p.write_bytes(b"GGUF") + monkeypatch.setattr(mp, "read_gguf_keys", lambda _p: ["blk.0.attn_q.weight"]) + out = mp.detect_family(p) + assert out["family"] is None + assert "no LTX or MiniMax H3 signature" in out["reason"] diff --git a/tests/test_gguf_support.py b/tests/test_gguf_support.py new file mode 100644 index 00000000..1979111a --- /dev/null +++ b/tests/test_gguf_support.py @@ -0,0 +1,129 @@ +"""GGUF backend selection and file discovery. + +Nothing here needs a .gguf file or the gguf package: what is testable is the routing — +which backend gets picked, what happens when none is available, and whether .gguf files +are found at all (core's extension set excludes them, which is the whole reason this +module exists). +""" +import sys +import types + +import pytest + +sys.path.insert(0, ".") +import gguf_support # noqa: E402 + + +@pytest.fixture +def fake_folders(tmp_path, monkeypatch): + """A stand-in folder_paths with two model roots on disk.""" + diff = tmp_path / "diffusion_models" + enc = tmp_path / "text_encoders" + sub = diff / "nested" + for d in (diff, enc, sub): + d.mkdir(parents=True, exist_ok=True) + (diff / "ltx.safetensors").write_bytes(b"x") + (diff / "ltx-Q4_K_M.gguf").write_bytes(b"x") + (sub / "h3-Q8_0.gguf").write_bytes(b"x") + (enc / "gemma.safetensors").write_bytes(b"x") + (enc / "gemma-Q5_K.gguf").write_bytes(b"x") + + mod = types.ModuleType("folder_paths") + mod.base_path = str(tmp_path) + roots = {"diffusion_models": [str(diff)], "text_encoders": [str(enc)]} + mod.get_folder_paths = lambda name: roots.get(name, []) + monkeypatch.setitem(sys.modules, "folder_paths", mod) + return tmp_path + + +def test_is_gguf_is_case_insensitive(): + assert gguf_support.is_gguf("model.gguf") + assert gguf_support.is_gguf("MODEL.GGUF") + assert not gguf_support.is_gguf("model.safetensors") + assert not gguf_support.is_gguf("") + assert not gguf_support.is_gguf(None) + + +def test_finds_gguf_files_core_cannot_list(fake_folders): + names = gguf_support.gguf_names("diffusion_models") + assert "ltx-Q4_K_M.gguf" in names + # Nested, because model folders are routinely organised into subdirectories. + assert any(n.endswith("h3-Q8_0.gguf") for n in names) + # Only .gguf — the ordinary files still come from core's own listing. + assert not any(n.endswith(".safetensors") for n in names) + + +def test_encoders_are_listed_separately(fake_folders): + assert gguf_support.gguf_names("text_encoders") == ["gemma-Q5_K.gguf"] + + +def test_missing_folder_is_empty_not_an_error(fake_folders): + assert gguf_support.gguf_names("vae") == [] + + +def test_path_resolves_a_listed_name(fake_folders): + p = gguf_support.gguf_path("diffusion_models", "ltx-Q4_K_M.gguf") + assert p and p.endswith("ltx-Q4_K_M.gguf") + assert gguf_support.gguf_path("diffusion_models", "not-there.gguf") is None + + +def test_no_backend_refuses_with_both_remedies(monkeypatch): + monkeypatch.setattr(gguf_support, "_pack_dir", lambda: None) + monkeypatch.setattr(gguf_support.importlib.util, "find_spec", lambda name: None) + assert gguf_support.backend() is None + with pytest.raises(RuntimeError) as e: + gguf_support.load_state_dict("/nope.gguf") + msg = str(e.value) + # Both ways out must be named: they have different consequences for VRAM. + assert "ComfyUI-GGUF" in msg + assert "pip install gguf" in msg + + +def test_the_pack_wins_when_both_are_available(monkeypatch): + monkeypatch.setattr(gguf_support, "_pack_dir", lambda: "/fake/ComfyUI-GGUF") + monkeypatch.setattr(gguf_support.importlib.util, "find_spec", lambda name: object()) + # Quantized-in-VRAM is the point of GGUF; the dequantizing fallback is second best. + assert gguf_support.backend() == "pack" + + +def test_native_is_used_when_only_the_library_is_present(monkeypatch): + monkeypatch.setattr(gguf_support, "_pack_dir", lambda: None) + monkeypatch.setattr(gguf_support.importlib.util, "find_spec", + lambda name: object() if name == "gguf" else None) + assert gguf_support.backend() == "native" + + +def test_pack_is_found_by_content_not_by_folder_name(tmp_path, monkeypatch): + """Manager, forks and hand-installs all rename the folder; the marker is the code.""" + cn = tmp_path / "custom_nodes" + good = cn / "some-renamed-fork" + decoy = cn / "ComfyUI-GGUF-Themed-Nodes" # named right, contains nothing + for d in (good, decoy): + d.mkdir(parents=True) + (good / "loader.py").write_text("def gguf_sd_loader(path):\n return {}\n") + (good / "ops.py").write_text("class GGMLOps:\n pass\n") + (decoy / "loader.py").write_text("# nothing useful\n") + + mod = types.ModuleType("folder_paths") + mod.base_path = str(tmp_path) + mod.get_folder_paths = lambda name: [] + monkeypatch.setitem(sys.modules, "folder_paths", mod) + + assert gguf_support._pack_dir() == str(good) + + +def test_pack_load_passes_custom_operations_through(tmp_path, monkeypatch): + """The quantized weights are unusable without their ops — losing them would produce a + matmul against block-quantized storage rather than a clear failure.""" + d = tmp_path / "pack" + d.mkdir() + (d / "loader.py").write_text( + "def gguf_sd_loader(path):\n return {'w': 'quantized'}\n") + (d / "ops.py").write_text("class GGMLOps:\n pass\n") + monkeypatch.setattr(gguf_support, "_pack_dir", lambda: str(d)) + + sd, options, note = gguf_support.load_state_dict("/whatever.gguf") + assert sd == {"w": "quantized"} + assert "custom_operations" in options + assert type(options["custom_operations"]).__name__ == "GGMLOps" + assert "quantized" in note