# Copyright 2026 The HuggingFace Team. All rights reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from __future__ import annotations import math from dataclasses import dataclass import torch from ..configuration_utils import ConfigMixin, register_to_config from ..utils import BaseOutput from .scheduling_utils import SchedulerMixin @dataclass class DiscreteDDIMSchedulerOutput(BaseOutput): """ Output class for the discrete DDIM scheduler. Args: prev_sample (`torch.LongTensor` of shape `(batch_size, block_length)`): Updated block tokens after the current denoising step. sampled_tokens (`torch.LongTensor` of shape `(batch_size, block_length)`): Token IDs sampled from the model logits, i.e. the predicted clean tokens `x0`. sampled_probs (`torch.Tensor` of shape `(batch_size, block_length)`): Probabilities of the sampled tokens. pred_logits (`torch.Tensor` of shape `(batch_size, block_length, vocab_size)`): The denoiser logits, passed through for self-conditioning the next step. """ prev_sample: torch.LongTensor sampled_tokens: torch.LongTensor sampled_probs: torch.Tensor pred_logits: torch.Tensor class DiscreteDDIMScheduler(SchedulerMixin, ConfigMixin): """ Discrete DDIM scheduler for the uniform corruption process, following "Structured Denoising Diffusion Models in Discrete State-Spaces" (D3PM, https://huggingface.co/papers/2107.03006). On the linear schedule the survival probability of a clean token at time `t` is `alpha(t) = 1 - t`. One denoising step from time `t` to `s < t` samples every block position from the exact posterior `q(x_s | x_t, x0)`, which for the uniform kernel decomposes into three routes: jump to the predicted clean token `x0`, stay on the current token, or jump to a uniformly random token. Unlike masked diffusion, there is no mask token; uncommitted positions carry random tokens. An optional predictor-corrector mode follows "Uniform Diffusion Models Revisited: Leave-One-Out Denoiser and Absorbing State Reformulation" via the leave-one-out (LOO) denoiser (https://huggingface.co/papers/2605.22765). When `corrector_steps > 0`, the pipeline runs that many Gibbs corrector sweeps after each predictor step (see [`~DiscreteDDIMScheduler.step_correct`]), resampling the least-confident positions from the one-coordinate conditional `Cat(alpha_s * x0_loo + (1 - alpha_s) / K)` while holding the rest fixed, which leaves the marginal `p_s` invariant and improves generation at no training cost. Args: num_inference_steps (`int`, defaults to 32): The number of denoising steps, defining the linear time grid the posterior is evaluated on. corrector_steps (`int`, defaults to 0): Number of Gibbs corrector sweeps run after each predictor step. `0` recovers plain ancestral DDIM sampling. corrector_k (`int`, defaults to 1): Number of positions resampled per corrector sweep. corrector_selection (`str`, defaults to `"lowest_log_margin"`): How the resampled positions are chosen: `"lowest_log_margin"`, `"lowest_maxprob"`, `"lowest_current_prob"`, or `"random"`. corrector_selection_tau (`float`, defaults to 1.0): Temperature of the Gumbel-top-k position selection (lower is greedier). """ order = 1 @register_to_config def __init__( self, num_inference_steps: int = 32, corrector_steps: int = 0, corrector_k: int = 1, corrector_selection: str = "lowest_log_margin", corrector_selection_tau: float = 1.0, ): self.num_inference_steps = num_inference_steps self.timesteps = torch.arange(num_inference_steps, dtype=torch.long) def set_timesteps(self, num_inference_steps: int, device: str | torch.device | None = None) -> None: if num_inference_steps <= 0: raise ValueError(f"`num_inference_steps` must be > 0, got {num_inference_steps}.") self.num_inference_steps = num_inference_steps self.timesteps = torch.arange(num_inference_steps, device=device, dtype=torch.long) @staticmethod def _sample_from_logits( logits: torch.Tensor, *, temperature: float, generator: torch.Generator | None, ) -> tuple[torch.LongTensor, torch.Tensor]: """Sample one token per position with optional temperature, returning tokens and their probabilities.""" if temperature < 0: raise ValueError(f"`temperature` must be >= 0, got {temperature}.") vocab_size = logits.shape[-1] flat_logits = logits.reshape(-1, vocab_size) probs = torch.softmax(flat_logits.float(), dim=-1) if temperature == 0.0: token = flat_logits.argmax(dim=-1, keepdim=True) else: scaled_probs = torch.softmax(flat_logits.float() / temperature, dim=-1) token = torch.multinomial(scaled_probs, num_samples=1, generator=generator) token_prob = torch.gather(probs, -1, token) return token.view(*logits.shape[:-1]), token_prob.view(*logits.shape[:-1]) def _alpha(self, step_index: int) -> float: """Survival probability `alpha = 1 - t` of a clean token at the time grid point `step_index`.""" return step_index / self.num_inference_steps @staticmethod def _to_loo_logits(logits: torch.Tensor, tokens: torch.LongTensor, alpha: float) -> torch.Tensor: """ Convert plain-denoiser logits to the leave-one-out posterior for the uniform kernel. Subtracts `log(1 + K * alpha / (1 - alpha))` from the observed token's logit (eq. 13 of https://huggingface.co/papers/2605.22765); renormalization happens in the following softmax. """ if alpha <= 0.0 or alpha >= 1.0: return logits delta = math.log1p(logits.shape[-1] * alpha / (1.0 - alpha)) shifted = logits.clone() src = torch.full((*tokens.shape, 1), -delta, dtype=shifted.dtype, device=shifted.device) shifted.scatter_add_(-1, tokens.unsqueeze(-1), src) return shifted def step( self, model_output: torch.Tensor, timestep: int | torch.Tensor, sample: torch.LongTensor, *, temperature: float = 0.0, generator: torch.Generator | None = None, return_dict: bool = True, ) -> DiscreteDDIMSchedulerOutput | tuple[torch.LongTensor, torch.LongTensor, torch.Tensor]: """ Sample the next block from the posterior `q(x_s | x_t, x0)` of the uniform corruption process. With `a = alpha_t / alpha_s` (survival probability from `s` to `t`) and `b = alpha_s`, the posterior mass of each route is clean: `b * (1 - a) / K + a * b * 1[x_t = x0]`, stay: `a * (1 - b) / K`, noise: `(1 - a) * (1 - b) / K`, so the last step (`b = 1`) deterministically commits the predicted clean tokens. Args: model_output (`torch.Tensor` of shape `(batch_size, block_length, vocab_size)`): Raw logits from the model for the current block. timestep (`int` or `torch.Tensor`): Current step index within the denoising schedule, in `[0, num_inference_steps - 1]`. sample (`torch.LongTensor` of shape `(batch_size, block_length)`): Current block token IDs `x_t`. temperature (`float`): Sampling temperature applied to the logits when drawing `x0`. generator (`torch.Generator`, *optional*): RNG for sampling. return_dict (`bool`): Whether to return a [`DiscreteDDIMSchedulerOutput`] or a plain tuple. """ if isinstance(timestep, torch.Tensor): step_index = int(timestep.item()) else: step_index = int(timestep) sampled_tokens, sampled_probs = self._sample_from_logits( model_output, temperature=temperature, generator=generator ) vocab_size = model_output.shape[-1] num_steps = self.num_inference_steps # `step_index` counts up from 0 to `num_inference_steps - 1`: alpha(t) = 1 - t increases towards the clean end, # with alpha_s = 1 on the final step so the predicted clean tokens are committed deterministically. alpha_t = step_index / num_steps alpha_s = (step_index + 1) / num_steps survival = alpha_t / alpha_s same = (sample == sampled_tokens).float() clean_mass = alpha_s * (1 - survival) / vocab_size + survival * alpha_s * same stay_mass = survival * (1 - alpha_s) / vocab_size * torch.ones_like(same) noise_mass = (1 - survival) * (1 - alpha_s) / vocab_size * torch.ones_like(same) route_probs = torch.stack([clean_mass, stay_mass, noise_mass], dim=-1) route_probs = route_probs / route_probs.sum(dim=-1, keepdim=True) routes = torch.multinomial(route_probs.view(-1, 3), num_samples=1, generator=generator).view_as(sample) random_tokens = torch.randint( low=0, high=vocab_size, size=sample.shape, device=sample.device, generator=generator ) prev_sample = torch.where(routes == 0, sampled_tokens, sample) prev_sample = torch.where(routes == 2, random_tokens, prev_sample) if not return_dict: return prev_sample, sampled_tokens, sampled_probs, model_output return DiscreteDDIMSchedulerOutput( prev_sample=prev_sample, sampled_tokens=sampled_tokens, sampled_probs=sampled_probs, pred_logits=model_output, ) def _select_positions( self, sample: torch.LongTensor, cond_log_probs: torch.Tensor, generator: torch.Generator | None ) -> torch.LongTensor: """Pick `corrector_k` positions per row to resample, least-confident first (Gumbel-top-k without replacement).""" selection = self.config.corrector_selection batch_size, seq_len = sample.shape k_eff = min(max(1, int(self.config.corrector_k)), seq_len) if selection == "random": scores = torch.rand(batch_size, seq_len, device=sample.device, generator=generator) return torch.topk(scores, k=k_eff, dim=-1).indices if selection == "lowest_maxprob": confidence = -cond_log_probs.max(dim=-1).values elif selection == "lowest_current_prob": confidence = -torch.gather(cond_log_probs, -1, sample.unsqueeze(-1)).squeeze(-1) elif selection == "lowest_log_margin": log_current = torch.gather(cond_log_probs, -1, sample.unsqueeze(-1)).squeeze(-1) alt = cond_log_probs.clone().scatter_(-1, sample.unsqueeze(-1), float("-inf")) confidence = -(log_current - alt.max(dim=-1).values) else: raise ValueError(f"Unknown `corrector_selection`: {selection!r}.") keys = confidence / float(self.config.corrector_selection_tau) u = torch.rand(keys.shape, device=keys.device, generator=generator).clamp_(1e-12, 1.0 - 1e-12) keys = keys + (-torch.log(-torch.log(u))) return torch.topk(keys, k=k_eff, dim=-1).indices def step_correct( self, model_output: torch.Tensor, timestep: int | torch.Tensor, sample: torch.LongTensor, *, generator: torch.Generator | None = None, return_dict: bool = True, ) -> DiscreteDDIMSchedulerOutput | tuple[torch.LongTensor, torch.LongTensor, torch.Tensor]: """ Run one Gibbs corrector sweep at the post-predictor time `s`, following the leave-one-out predictor-corrector of https://huggingface.co/papers/2605.22765. The model logits (recomputed on the current `sample`) are converted to the LOO denoiser, the one-coordinate conditional `p_s(x^l | x^{-l}) = Cat(alpha_s * x0_loo + (1 - alpha_s) / K)` is formed, the least-confident `corrector_k` positions are selected, and those positions are resampled while the rest are held fixed. The sweep preserves `p_s`, so it refines the sample without changing its marginal and needs no extra training. Args: model_output (`torch.Tensor` of shape `(batch_size, block_length, vocab_size)`): Raw logits from the model recomputed on the current (post-predictor) `sample`. timestep (`int` or `torch.Tensor`): The predictor step index just completed; the corrector runs at the following grid point `s`. sample (`torch.LongTensor` of shape `(batch_size, block_length)`): Current block token IDs to refine. generator (`torch.Generator`, *optional*): RNG for sampling. return_dict (`bool`): Whether to return a [`DiscreteDDIMSchedulerOutput`] or a plain tuple. """ if isinstance(timestep, torch.Tensor): step_index = int(timestep.item()) else: step_index = int(timestep) # The corrector acts at the cleaner time `s` reached by the predictor. alpha_s = self._alpha(step_index + 1) vocab_size = model_output.shape[-1] # Match the reference corrector, which forms the conditional in float64 (the LOO correction reaches ~log(K)). loo_logits = self._to_loo_logits(model_output.double(), sample, alpha_s) loo_log_probs = torch.log_softmax(loo_logits, dim=-1) log_uniform = math.log1p(-alpha_s) - math.log(vocab_size) cond_log_probs = torch.logaddexp( math.log(alpha_s) + loo_log_probs, torch.full_like(loo_log_probs, log_uniform) ) positions = self._select_positions(sample, cond_log_probs, generator) rows = torch.arange(sample.shape[0], device=sample.device).unsqueeze(-1).expand_as(positions) chosen_probs = cond_log_probs[rows, positions].exp() resampled = torch.multinomial( chosen_probs.reshape(-1, vocab_size), num_samples=1, generator=generator ).view_as(positions) prev_sample = sample.clone() prev_sample[rows, positions] = resampled sampled_probs = torch.gather(chosen_probs, -1, resampled.unsqueeze(-1)).squeeze(-1) if not return_dict: return prev_sample, resampled, sampled_probs, model_output return DiscreteDDIMSchedulerOutput( prev_sample=prev_sample, sampled_tokens=resampled, sampled_probs=sampled_probs, pred_logits=model_output, ) __all__ = ["DiscreteDDIMScheduler", "DiscreteDDIMSchedulerOutput"]