soup/soup_cli/utils/ebft_gdpo.py

192 lines
6.0 KiB
Python

"""v0.52.0 Part E — Energy-Based FT (EBFT) + Generalized DPO (GDPO) helpers.
Schema-only release: each algorithm has a closed allowlist of variant names
plus pure validators. Live loss kernels land in v0.52.1.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from types import MappingProxyType
from typing import Mapping
# Closed allowlists.
EBFT_VARIANTS: frozenset[str] = frozenset({"structured", "strided"})
GDPO_VARIANTS: frozenset[str] = frozenset({"standard", "length_normalized", "margin"})
_MAX_VARIANT_LEN: int = 32
_MIN_EBFT_TEMP: float = 1e-4
_MAX_EBFT_TEMP: float = 100.0
@dataclass(frozen=True)
class EBFTSpec:
"""Metadata for an EBFT variant. Frozen — immutable."""
name: str
description: str
live_wired: bool
_EBFT_METADATA: Mapping[str, EBFTSpec] = MappingProxyType({
"structured": EBFTSpec(
name="structured",
description="Structured Energy-Based FT (per-token energies)",
live_wired=False,
),
"strided": EBFTSpec(
name="strided",
description="Strided Energy-Based FT (block-sampled energies)",
live_wired=False,
),
})
@dataclass(frozen=True)
class GDPOSpec:
"""Metadata for a GDPO variant. Frozen — immutable."""
name: str
description: str
live_wired: bool
_GDPO_METADATA: Mapping[str, GDPOSpec] = MappingProxyType({
"standard": GDPOSpec(
name="standard",
description="Standard GDPO (general preference objective)",
live_wired=False,
),
"length_normalized": GDPOSpec(
name="length_normalized",
description="Length-normalized GDPO (SimPO-style normalisation)",
live_wired=False,
),
"margin": GDPOSpec(
name="margin",
description="Margin-augmented GDPO (DPO + margin term)",
live_wired=False,
),
})
def _validate_variant(name: object, allowed: frozenset[str], label: str) -> str:
"""Shared variant-name validator."""
if isinstance(name, bool):
raise TypeError(f"{label} must not be bool, got {name!r}")
if not isinstance(name, str):
raise TypeError(f"{label} must be str, got {type(name).__name__}")
if not name:
raise ValueError(f"{label} must be non-empty")
if "\x00" in name:
raise ValueError(f"{label} must not contain null bytes")
if len(name) > _MAX_VARIANT_LEN:
raise ValueError(
f"{label} too long (max {_MAX_VARIANT_LEN} chars)"
)
canonical = name.lower()
if canonical not in allowed:
supported = ", ".join(sorted(allowed))
raise ValueError(
f"{label} {name!r} not supported. Supported: {supported}"
)
return canonical
def validate_ebft_variant(name: object) -> str:
"""Validate an EBFT variant and return the canonical form."""
return _validate_variant(name, EBFT_VARIANTS, "ebft_variant")
def validate_gdpo_variant(name: object) -> str:
"""Validate a GDPO variant and return the canonical form."""
return _validate_variant(name, GDPO_VARIANTS, "gdpo_variant")
def get_ebft_spec(name: str) -> EBFTSpec:
"""Return the frozen :class:`EBFTSpec` for ``name`` or raise."""
return _EBFT_METADATA[validate_ebft_variant(name)]
def get_gdpo_spec(name: str) -> GDPOSpec:
"""Return the frozen :class:`GDPOSpec` for ``name`` or raise."""
return _GDPO_METADATA[validate_gdpo_variant(name)]
def validate_ebft_temperature(value: object) -> float:
"""Validate an EBFT temperature scalar in [1e-4, 100]. Rejects bool/NaN."""
if isinstance(value, bool):
raise TypeError(f"ebft_temperature must not be bool, got {value!r}")
if not isinstance(value, (int, float)):
raise TypeError(
f"ebft_temperature must be float, got {type(value).__name__}"
)
fval = float(value)
if not math.isfinite(fval):
raise ValueError(
f"ebft_temperature must be finite, got {value!r}"
)
if fval < _MIN_EBFT_TEMP:
raise ValueError(
f"ebft_temperature must be >= {_MIN_EBFT_TEMP}, got {fval}"
)
if fval > _MAX_EBFT_TEMP:
raise ValueError(
f"ebft_temperature must be <= {_MAX_EBFT_TEMP}, got {fval}"
)
return fval
def _check_task_backend(task: object, backend: object) -> None:
"""Shared bool/str guard for cross-compat helpers."""
for name, value in (("task", task), ("backend", backend)):
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")
def validate_ebft_compat(*, task: str, backend: str) -> None:
"""Schema-time gate for ``ebft_variant`` — SFT-only, non-MLX."""
_check_task_backend(task, backend)
if backend == "mlx":
raise ValueError(
"ebft_variant is not supported on backend=mlx in v0.52.0"
)
if task != "sft":
raise ValueError(
f"ebft_variant requires task='sft'; got task={task!r}"
)
def validate_gdpo_compat(*, task: str, backend: str) -> None:
"""Schema-time gate for ``gdpo_variant`` — DPO-family-only, non-MLX."""
_check_task_backend(task, backend)
if backend == "mlx":
raise ValueError(
"gdpo_variant is not supported on backend=mlx in v0.52.0"
)
if task not in ("dpo", "preference"):
raise ValueError(
f"gdpo_variant requires task in ('dpo', 'preference'); "
f"got task={task!r}"
)
def apply_ebft_loss() -> None:
"""Live EBFT loss kernel — deferred to v0.52.1."""
raise NotImplementedError(
"EBFT (Energy-Based FT) live loss kernel deferred to v0.52.1. "
"Schema accepts the variant but no loss is wired yet."
)
def apply_gdpo_loss() -> None:
"""Live GDPO loss kernel — deferred to v0.52.1."""
raise NotImplementedError(
"GDPO (Generalized DPO) live loss kernel deferred to v0.52.1. "
"Schema accepts the variant but no loss is wired yet."
)