#!/usr/bin/env python3 # SPDX-License-Identifier: MIT OR Apache-2.0 # Vendored into bankml from minaiml (same authors); the Rust guard in gguf.rs is its port — keep them in step. # gguf_guard.py — decide whether a GGUF can play on a given llama.cpp build, from its header alone. # stdlib only. Reads a prefix of the file (default 32 MiB); tensor data is never read. # # verdicts # play known type ids, sizes consistent with mainline layout # refuse will not load, or will load and emit garbage, on the named engine # need_more header larger than the prefix supplied; rerun with --prefix larger # # the three traps it exists for (PrismML docs, 2026-09): # 1. PQ2_0 (142) / PTQ1_0 (143): fork-only ggml types. mainline refuses — safe failure. # 2. legacy Q2_0: group-128 bytes stored under type 42, which mainline reads as group-64. size mismatch. # 3. Bonsai 2 Q2_0: correct group-64 bytes, rotated basis. mainline loads it and outputs gibberish. # nothing in the tensor table reveals this; detected by name only. the dangerous one. # # usage: gguf_guard.py FILE [--engine mainline|prism] [--file-size N] [--json] import json, struct, sys, re, argparse GGUF_MAGIC = b"GGUF" # value types U8, I8, U16, I16, U32, I32, F32, BOOL, STR, ARR, U64, I64, F64 = range(13) SCALAR = {U8: " 2.25 bpw (matches PrismML table) # legacy g128: 128 * 2 bit = 32 B + f16 = 34 B -> 2.125 bpw BLOCK = {0: (1, 4), 1: (1, 2), 30: (1, 2), 42: (64, 18)} NAMES = {0: "F32", 1: "F16", 2: "Q4_0", 3: "Q4_1", 6: "Q5_0", 7: "Q5_1", 8: "Q8_0", 10: "Q2_K", 11: "Q3_K", 12: "Q4_K", 13: "Q5_K", 14: "Q6_K", 30: "BF16", 34: "TQ1_0", 35: "TQ2_0", 41: "Q1_0", 42: "Q2_0", 142: "PQ2_0", 143: "PTQ1_0"} MAINLINE_COUNT = 43 # GGML_TYPE_COUNT in ggml-org/llama.cpp and llama.rn 0.13.0-rc.3 (read 2026-09-24) FORK_ONLY = {142, 143} BONSAI2 = re.compile(r"bonsai[\s_-]*2|prism-fork-required", re.I) class NeedMore(Exception): pass class R: def __init__(s, b): s.b, s.o = b, 0 def take(s, n): if s.o + n > len(s.b): raise NeedMore(s.o + n) v = s.b[s.o:s.o + n] s.o += n return v def fmt(s, f): return struct.unpack(f, s.take(struct.calcsize(f)))[0] def str(s): return s.take(s.fmt("" for _ in range(n): s.val(et) return f"" raise ValueError(f"unknown gguf value type {t}") def parse(b): r = R(b) if r.take(4) != GGUF_MAGIC: raise ValueError("not a GGUF file") ver, nt, nkv = r.fmt("= MAINLINE_COUNT and t not in FORK_ONLY] if unknown: why.append(f"type ids {unknown} unknown to every build this guard knows") if engine == "mainline" and set(types) & FORK_ONLY: why.append("PQ2_0/PTQ1_0 are PrismML-fork types; mainline and llama.rn refuse them") # legacy Q2_0: compare bytes between offsets with the group-64 expectation ordered = sorted(ts, key=lambda t: t["offset"]) ends = [x["offset"] for x in ordered[1:]] + ([file_size - data_start] if file_size else [None]) short = 0 for t, end in zip(ordered, ends): if t["type"] != 42 or end is None: continue per, bb = BLOCK[42] want = nelem(t["dims"]) // per * bb if end - t["offset"] < want: short += 1 if short: why.append(f"{short} Q2_0 tensors smaller than group-64 layout: legacy group-128 Prism file, " f"use *-Q2_0_g64.gguf or *-PQ2_0.gguf") label = " ".join(str(x) for x in (kv.get("general.name"), kv.get("general.basename"), filename) if x) if BONSAI2.search(label) and engine == "mainline": why.append("Bonsai 2 weights are in a rotated basis and need the fork's Hadamard transform; " "mainline loads the Q2_0 band silently and outputs gibberish") out["verdict"] = "refuse" if why else "play" return out def main(): ap = argparse.ArgumentParser() ap.add_argument("file") ap.add_argument("--engine", default="mainline", choices=["mainline", "prism"]) ap.add_argument("--prefix", type=int, default=32 << 20) ap.add_argument("--file-size", type=int) ap.add_argument("--json", action="store_true") a = ap.parse_args() import os with open(a.file, "rb") as f: b = f.read(a.prefix) size = a.file_size or os.path.getsize(a.file) if size < len(b): size = len(b) res = judge(b, a.engine, size, os.path.basename(a.file)) if a.json: print(json.dumps(res, indent=2)) else: print(res["verdict"], json.dumps(res.get("types", {}))) for w in res.get("reasons", []): print(" -", w) sys.exit({"play": 0, "refuse": 2, "need_more": 3}[res["verdict"]]) if __name__ == "__main__": main()