BareGit
"""Scheduler, sampler, and latent-noise helpers for Z-Image inference."""

from __future__ import annotations

from dataclasses import dataclass
from typing import Callable

import numpy as np
import torch
from scipy.stats import beta

from diffusion_cli.config import LATENT_CHANNELS, LATENT_DOWNSCALE
from diffusion_cli.errors import DiffusionCliError


@dataclass(frozen=True)
class ModelSamplingDiscreteFlow:
    """Discrete flow sigma conversion used by the workflow sampling node."""

    shift: float = 3.0
    multiplier: float = 1.0
    timesteps: int = 1000

    def sigma(self, timestep: torch.Tensor) -> torch.Tensor:
        """Convert normalized timesteps to shifted flow sigmas."""

        t = timestep / self.multiplier
        if self.shift == 1.0:
            return t
        return self.shift * t / (1.0 + (self.shift - 1.0) * t)

    def timestep(self, sigma: torch.Tensor) -> torch.Tensor:
        """Convert shifted flow sigmas back to model timesteps."""

        return sigma * self.multiplier

    def sigmaTable(self, device=None, dtype=None) -> torch.Tensor:
        """Return the 1,000-step shifted flow table used by ComfyUI."""

        indices = torch.arange(
            1,
            self.timesteps + 1,
            device=device,
            dtype=dtype or torch.float32,
        )
        timesteps = indices / self.timesteps * self.multiplier
        return self.sigma(timesteps)

    def percentToSigma(self, percent: float) -> torch.Tensor:
        """Convert a denoise percentage to shifted flow sigma."""

        if percent <= 0.0:
            return torch.tensor(1.0)
        if percent >= 1.0:
            return torch.tensor(0.0)
        timestep = torch.tensor(1.0 - percent) * self.multiplier
        return self.sigma(timestep)


def latentShape(
    batch_size: int,
    height: int,
    width: int,
) -> tuple[int, int, int, int]:
    """Return the Z-Image latent tensor shape for image dimensions."""

    return (
        batch_size,
        LATENT_CHANNELS,
        height // LATENT_DOWNSCALE,
        width // LATENT_DOWNSCALE,
    )


def buildInitialNoise(
    batch_size: int,
    height: int,
    width: int,
    seed: int,
    device,
    dtype,
) -> torch.Tensor:
    """Build deterministic Gaussian latent noise on the selected device."""

    generator = torch.Generator(device=device)
    generator.manual_seed(seed)
    return torch.randn(
        latentShape(batch_size, height, width),
        generator=generator,
        device=device,
        dtype=dtype,
    )


def betaScheduler(
    steps: int,
    sampling: ModelSamplingDiscreteFlow | None = None,
    device: object | None = None,
    dtype: object | None = None,
) -> torch.Tensor:
    """Return ComfyUI beta scheduler sigmas with a final zero sigma."""

    if steps < 1:
        raise ValueError("steps must be at least 1")

    sampling = sampling or ModelSamplingDiscreteFlow()
    table = sampling.sigmaTable(device=device, dtype=dtype)
    total_timesteps = len(table) - 1
    percentiles = 1.0 - np.linspace(0.0, 1.0, steps, endpoint=False)
    timesteps = np.rint(beta.ppf(percentiles, 0.6, 0.6) * total_timesteps)

    values = []
    last_t = -1
    for timestep in timesteps:
        if timestep != last_t:
            values.append(table[int(timestep)])
        last_t = timestep

    sigmas = torch.stack(values) if values else table[:0]
    sigmas = torch.cat(
        [sigmas, torch.zeros(1, device=sigmas.device, dtype=sigmas.dtype)]
    )
    return sigmas


def sigmaToHalfLogSnr(sigma: torch.Tensor) -> torch.Tensor:
    """Convert sigma to half-log-SNR for flow samplers."""

    return torch.logit(sigma).neg()


def halfLogSnrToSigma(half_log_snr: torch.Tensor) -> torch.Tensor:
    """Convert half-log-SNR to sigma for flow samplers."""

    return half_log_snr.neg().sigmoid()


def offsetFirstSigmaForSnr(
    sigmas: torch.Tensor,
    sampling: ModelSamplingDiscreteFlow | None = None,
    percent_offset: float = 1e-4,
) -> torch.Tensor:
    """Offset an initial sigma of 1 to avoid infinite flow log-SNR."""

    if len(sigmas) <= 1 or sigmas[0] < 1:
        return sigmas
    sampling = sampling or ModelSamplingDiscreteFlow()
    sigmas = sigmas.clone()
    sigmas[0] = sampling.percentToSigma(percent_offset).to(
        device=sigmas.device,
        dtype=sigmas.dtype,
    )
    return sigmas


def eiHPhi1(h: torch.Tensor) -> torch.Tensor:
    """Compute h * phi_1(h) for exponential integrator updates."""

    return torch.expm1(h)


def eiHPhi2(h: torch.Tensor) -> torch.Tensor:
    """Compute h * phi_2(h) for exponential integrator updates."""

    return (torch.expm1(h) - h) / h


def defaultNoiseSampler(
    latent: torch.Tensor,
    seed: int | None = None,
) -> Callable[[torch.Tensor, torch.Tensor], torch.Tensor]:
    """Return a deterministic Gaussian noise sampler for stochastic steps."""

    if seed is None:
        generator = None
    else:
        seed_value = seed + 1 if latent.device == torch.device("cpu") else seed
        generator = torch.Generator(device=latent.device)
        generator.manual_seed(seed_value)

    def sample(_sigma, _sigma_next):
        return torch.randn(
            latent.size(),
            dtype=latent.dtype,
            layout=latent.layout,
            device=latent.device,
            generator=generator,
        )

    return sample


def cfgDenoise(model, latent, sigma, conditioning, cfg: float) -> torch.Tensor:
    """Run classifier-free guidance for one denoising call."""

    positive = model.forward(latent, sigma, conditioning.positive)
    negative = model.forward(latent, sigma, conditioning.negative)
    return negative + (positive - negative) * cfg


def _checkFinite(latent: torch.Tensor, context: str) -> None:
    if not torch.all(torch.isfinite(latent)):
        raise DiffusionCliError(
            f"SEEDS-3 sampler produced NaN or Inf {context}"
        )


@torch.no_grad()
def sampleSeeds3(
    model,
    latent: torch.Tensor,
    sigmas: torch.Tensor,
    conditioning,
    cfg: float,
    *,
    eta: float = 1.0,
    s_noise: float = 1.0,
    seed: int | None = None,
    noise_sampler=None,
    r_1: float = 1.0 / 3.0,
    r_2: float = 2.0 / 3.0,
    callback=None,
) -> torch.Tensor:
    """Run the SEEDS-3 solver over a beta sigma schedule."""

    if len(sigmas) < 2:
        raise ValueError("sigmas must contain at least two values")

    x = latent.float()
    sigmas = sigmas.to(device=x.device, dtype=torch.float32)
    sigmas = offsetFirstSigmaForSnr(sigmas)
    noise_sampler = noise_sampler or defaultNoiseSampler(x, seed=seed)
    s_in = torch.ones(x.shape[0], device=x.device, dtype=torch.float32)
    inject_noise = eta > 0 and s_noise > 0

    for i in range(len(sigmas) - 1):
        sigma = sigmas[i]
        sigma_next = sigmas[i + 1]
        denoised = cfgDenoise(model, x, sigma * s_in, conditioning, cfg)
        _checkFinite(denoised, f"after denoising step {i}")
        if callback is not None:
            callback({
                "x": x,
                "i": i,
                "sigma": sigma,
                "sigma_hat": sigma,
                "denoised": denoised,
            })

        if sigma_next == 0:
            x = denoised
            _checkFinite(x, f"at final step {i}")
            continue

        lambda_s = sigmaToHalfLogSnr(sigma)
        lambda_t = sigmaToHalfLogSnr(sigma_next)
        h = lambda_t - lambda_s
        h_eta = h * (eta + 1)
        lambda_s_1 = torch.lerp(lambda_s, lambda_t, r_1)
        lambda_s_2 = torch.lerp(lambda_s, lambda_t, r_2)
        sigma_s_1 = halfLogSnrToSigma(lambda_s_1)
        sigma_s_2 = halfLogSnrToSigma(lambda_s_2)

        alpha_s_1 = sigma_s_1 * lambda_s_1.exp()
        alpha_s_2 = sigma_s_2 * lambda_s_2.exp()
        alpha_t = sigma_next * lambda_t.exp()

        x_2 = (
            sigma_s_1 / sigma
            * (-r_1 * h * eta).exp()
            * x
            - alpha_s_1 * eiHPhi1(-r_1 * h_eta) * denoised
        )
        if inject_noise:
            sde_noise = (
                (-2 * r_1 * h * eta).expm1().neg().sqrt()
                * noise_sampler(sigma, sigma_s_1)
            )
            x_2 = x_2 + sde_noise * sigma_s_1 * s_noise
        denoised_2 = cfgDenoise(
            model,
            x_2,
            sigma_s_1 * s_in,
            conditioning,
            cfg,
        )

        a3_2 = r_2 / r_1 * eiHPhi2(-r_2 * h_eta)
        a3_1 = eiHPhi1(-r_2 * h_eta) - a3_2
        x_3 = (
            sigma_s_2 / sigma
            * (-r_2 * h * eta).exp()
            * x
            - alpha_s_2 * (a3_1 * denoised + a3_2 * denoised_2)
        )
        if inject_noise:
            segment_factor = (r_1 - r_2) * h * eta
            sde_noise = sde_noise * segment_factor.exp()
            sde_noise = sde_noise + (
                segment_factor.mul(2).expm1().neg().sqrt()
                * noise_sampler(sigma_s_1, sigma_s_2)
            )
            x_3 = x_3 + sde_noise * sigma_s_2 * s_noise
        denoised_3 = cfgDenoise(
            model,
            x_3,
            sigma_s_2 * s_in,
            conditioning,
            cfg,
        )

        b3 = eiHPhi2(-h_eta) / r_2
        b1 = eiHPhi1(-h_eta) - b3
        x = (
            sigma_next / sigma
            * (-h * eta).exp()
            * x
            - alpha_t * (b1 * denoised + b3 * denoised_3)
        )
        if inject_noise:
            segment_factor = (r_2 - 1) * h * eta
            sde_noise = sde_noise * segment_factor.exp()
            sde_noise = sde_noise + (
                segment_factor.mul(2).expm1().neg().sqrt()
                * noise_sampler(sigma_s_2, sigma_next)
            )
            x = x + sde_noise * sigma_next * s_noise

        _checkFinite(x, f"after step {i}")

    return x


def sampleLatents(
    model,
    conditioning,
    *,
    batch_size: int,
    height: int,
    width: int,
    seed: int,
    steps: int,
    cfg: float,
    device,
    dtype,
) -> torch.Tensor:
    """Build initial noise and run the default Z-Image sampler."""

    latent = buildInitialNoise(
        batch_size,
        height,
        width,
        seed,
        device,
        torch.float32,
    )
    sigmas = betaScheduler(steps, device=device, dtype=torch.float32)
    # Keep the first standalone milestone deterministic; the stochastic
    # SEEDS-3 path is more sensitive to numeric parity with ComfyUI internals.
    return sampleSeeds3(
        model,
        latent,
        sigmas,
        conditioning,
        cfg,
        eta=0.0,
        s_noise=0.0,
        seed=seed,
    )