soup/soup_cli/utils/bitnet.py

174 lines
6.1 KiB
Python

"""v0.52.0 Part D — BitNet 1.58-bit fine-tuning + export schema helpers.
Schema-only support for ``quantization='bitnet_1.58'`` and the new
``soup export --format bitnet`` / ``--format tq1_0`` GGUF flavours.
Live ``onebitllms`` wrapping + llama.cpp ``TQ1_0`` export wiring are
deferred to v0.52.1 (mirrors v0.50.0 stub-then-live pattern).
"""
from __future__ import annotations
from dataclasses import dataclass
from types import MappingProxyType
from typing import Mapping
# Closed allowlist of BitNet-flavoured quant strings exposed to YAML.
BITNET_QUANT_FORMATS: frozenset[str] = frozenset({"bitnet_1.58"})
# Closed allowlist of BitNet export targets (the actual export formats).
BITNET_EXPORT_FORMATS: frozenset[str] = frozenset({"bitnet", "tq1_0"})
_BITNET_FAMILY_RE_PREFIXES: tuple[str, ...] = (
"bitnet", "falcon-e", "falcone", "1bitllm", "onebit",
)
@dataclass(frozen=True)
class BitNetSpec:
"""Metadata for the BitNet quant path. Frozen — immutable."""
name: str
description: str
bits: float
live_wired: bool
_BITNET_METADATA: Mapping[str, BitNetSpec] = MappingProxyType({
"bitnet_1.58": BitNetSpec(
name="bitnet_1.58",
description="BitNet 1.58-bit ternary weights (axolotl + onebitllms)",
bits=1.58,
live_wired=False,
),
})
def is_bitnet_quant(value: object) -> bool:
"""Return True iff ``value`` is a BitNet quant string."""
if isinstance(value, bool):
return False
if not isinstance(value, str):
return False
return value in BITNET_QUANT_FORMATS
def is_bitnet_export_format(value: object) -> bool:
"""Return True iff ``value`` is a BitNet export-format string."""
if isinstance(value, bool):
return False
if not isinstance(value, str):
return False
return value in BITNET_EXPORT_FORMATS
def get_bitnet_spec(name: str) -> BitNetSpec:
"""Return the frozen :class:`BitNetSpec` for ``name`` or raise."""
if not is_bitnet_quant(name):
supported = ", ".join(sorted(BITNET_QUANT_FORMATS))
raise ValueError(
f"BitNet quant {name!r} not supported. Supported: {supported}"
)
return _BITNET_METADATA[name]
def is_bitnet_model(model_name: object) -> bool:
"""Best-effort detect whether ``model_name`` references a BitNet family.
Checks every slash-delimited component (lowercased) against the
``_BITNET_FAMILY_RE_PREFIXES`` prefix list. This is intentionally more
permissive than v0.39.0 ``is_gemma4_model`` because BitNet families
typically live under namespaced orgs (``1bitllm/...``, ``OneBitLLM/...``)
rather than being identifiable by repo name alone.
"""
if isinstance(model_name, bool):
return False
if not isinstance(model_name, str):
return False
if not model_name or "\x00" in model_name:
return False
# Check each path component so an org name like "1bitllm/foo" matches
# while still rejecting unrelated substrings (e.g. an SFT model that
# happens to embed "bitnet" inside a description path).
for part in model_name.lower().split("/"):
if any(part.startswith(prefix) for prefix in _BITNET_FAMILY_RE_PREFIXES):
return True
return False
def validate_bitnet_compat(*, task: str, backend: str, modality: str) -> None:
"""Schema-time gate for ``quantization='bitnet_1.58'``.
Rejects:
- non-string / bool args (defence-in-depth).
- ``backend == 'mlx'`` — onebitllms is CUDA-only in v0.52.0.
- ``modality != 'text'`` — vision/audio BitNet not modelled.
- ``task`` outside {sft, pretrain, dpo} — BitNet wiring is text-LM
training only this release.
"""
for name, value in (("task", task), ("backend", backend), ("modality", modality)):
if isinstance(value, bool):
raise TypeError(f"{name} must not be bool, got {value!r}")
if not isinstance(value, str) or not value:
raise ValueError(f"{name} must be a non-empty string")
if backend == "mlx":
raise ValueError(
"quantization='bitnet_1.58' is not supported on backend=mlx "
"(onebitllms is CUDA-only). Use backend='transformers'."
)
if modality != "text":
raise ValueError(
f"quantization='bitnet_1.58' is wired for modality='text' only; "
f"got modality={modality!r}"
)
if task not in ("sft", "pretrain", "dpo"):
raise ValueError(
f"quantization='bitnet_1.58' is only wired for "
f"task in (sft, pretrain, dpo); got task={task!r}"
)
def validate_bitnet_export(format_name: object) -> str:
"""Validate a BitNet export-format string. Returns canonical form."""
if isinstance(format_name, bool):
raise TypeError(
f"bitnet export format must not be bool, got {format_name!r}"
)
if not isinstance(format_name, str):
raise TypeError(
f"bitnet export format must be str, "
f"got {type(format_name).__name__}"
)
if not format_name:
raise ValueError("bitnet export format must be non-empty")
if "\x00" in format_name:
raise ValueError(
"bitnet export format must not contain null bytes"
)
canonical = format_name.lower()
if canonical not in BITNET_EXPORT_FORMATS:
supported = ", ".join(sorted(BITNET_EXPORT_FORMATS))
raise ValueError(
f"bitnet export format {format_name!r} not supported. "
f"Supported: {supported}"
)
return canonical
def build_bitnet_trainer() -> None:
"""Live BitNet trainer factory — deferred to v0.52.1."""
raise NotImplementedError(
"BitNet 1.58-bit fine-tuning live wiring deferred to v0.52.1. "
"Schema accepts quantization='bitnet_1.58' but no trainer integration "
"is registered yet."
)
def export_bitnet_gguf() -> None:
"""Live BitNet GGUF export — deferred to v0.52.1."""
raise NotImplementedError(
"BitNet / TQ1_0 GGUF export live wiring deferred to v0.52.1. "
"Schema accepts --format bitnet / --format tq1_0 but no export "
"pipeline is registered yet."
)