""" Forward and reverse sampling for discrete flow matching. Padding policy -------------- * `` is structural batch padding, not a generative token. * Active positions (0 .. length-1): sample / noise amino acids only. * Inactive positions (j >= length): stay ``, never noised, never updated. * `` may appear in the input embedding during noising but is not in the decoder output head. """ import torch import torch.nn.functional as F from torch.distributions.categorical import Categorical from .scheduler import clean_weight_denominator SPECIAL_TOKENS = ("", "") def special_token_ids(vocab: dict[str, int]) -> tuple[int, int]: """Return (pad_id, mask_id) from the vocabulary.""" mask_id = vocab.get("", vocab.get("")) return vocab[""], mask_id def amino_acid_token_indices( vocab: dict[str, int], device: torch.device | None = None, ) -> torch.Tensor: """Sorted amino-acid token ids (excludes and ).""" pad_id, mask_id = special_token_ids(vocab) special = {pad_id, mask_id} indices = sorted(token_id for token_id in vocab.values() if token_id not in special) if not indices: raise ValueError("Vocabulary has no amino-acid tokens for uniform sampling.") return torch.tensor(indices, dtype=torch.long, device=device) def sample_uniform_noise( vocab: dict[str, int], shape: torch.Size | tuple[int, ...], device: torch.device, ) -> torch.Tensor: """Sample random amino-acid token ids, excluding and .""" aa_indices = amino_acid_token_indices(vocab, device=device) picks = torch.randint(0, aa_indices.numel(), shape, device=device) return aa_indices[picks] def sample_noising_step_mask(kt, x1, vocab, padding_mask=None): """Forward mask corruption on active positions only.""" xt = x1.clone() noise_mask = torch.rand(xt.shape, device=xt.device) > kt.unsqueeze(-1) if padding_mask is not None: noise_mask = noise_mask & ~padding_mask mask_id = vocab.get("", vocab.get("")) xt[noise_mask] = mask_id return xt def sample_noising_step_uniform(kt, x1, vocab, padding_mask=None): """Forward uniform corruption on active positions only.""" xt = x1.clone() noise_mask = torch.rand(xt.shape, device=xt.device) > kt.unsqueeze(-1) if padding_mask is not None: noise_mask = noise_mask & ~padding_mask if noise_mask.any(): xt[noise_mask] = sample_uniform_noise( vocab, xt[noise_mask].shape, device=xt.device, ) return xt def _apply_active_only(x_t, samples, active_mask): if active_mask is None: return samples return torch.where(active_mask, samples, x_t) def inference_sample_mask( kt, kt_derivative, x_t, logits, vocab, delta_t, active_mask=None, temperature: float = 0.0, strategy: str = "confidence", kt_next: torch.Tensor | None = None, eta: float = 0.0, is_final_step: bool = False, confidence_bonus: torch.Tensor | None = None, ): """ Reverse mask step for discrete flow matching. Only active positions may change. Args: kt: Scheduler state at time t, shape (B,). kt_derivative: Time derivative of scheduler, shape (B,). x_t: Current noisy sequence of token IDs, shape (B, L). logits: Unnormalized decoder logits over vocabulary, shape (B, L, num_classes). vocab: Token vocabulary mapping string tokens to token IDs. delta_t: Discretization step size dt. active_mask: Boolean tensor (B, L) where True indicates active (non-padded) residues. temperature: Sampling temperature. If <= 0.05, uses greedy MAP unmasking (argmax). strategy: Unmasking strategy: - 'confidence': Top-confidence unmasking (MaskGIT / MDLM style) where positions with highest prediction certainty are unmasked first. - 'random': Stochastic uniform unmasking according to rate dt * k'(t) / (1 - k(t)). kt_next: Optional scheduler state at time t + dt, shape (B,). When provided, uses cumulative unmasking matching the scheduler curve κ(t). eta: Detailed Balance stochasticity parameter (Campbell et al., arXiv:2402.04997). When eta > 0, introduces reversible transitions (re-masking and boosted unmasking) satisfying detailed balance, enabling error correction and diverse stochastic paths. is_final_step: If True, disables re-masking on the final integration step so sequences terminate clean. confidence_bonus: Optional additive boost tensor (B, L) based on physical peak evidence. """ if isinstance(temperature, torch.Tensor): t_clamped = temperature.view(-1, 1, 1).clamp(min=1e-3) probs = F.softmax(logits / t_clamped, dim=-1) greedy = logits.argmax(dim=-1) stochastic = Categorical(probs).sample() is_greedy = (temperature.view(-1, 1) <= 0.05).expand(-1, logits.shape[1]) x_1 = torch.where(is_greedy, greedy, stochastic) elif temperature <= 0.05: probs = F.softmax(logits, dim=-1) x_1 = logits.argmax(dim=-1) else: probs = F.softmax(logits / temperature, dim=-1) x_1 = Categorical(probs).sample() mask_id = vocab.get("", vocab.get("")) is_masked = (x_t == mask_id) if active_mask is not None: is_masked = is_masked & active_mask denom = clean_weight_denominator(kt).view(-1, 1) if eta > 0.0: # Boosted unmasking rate under Detailed Balance: (1 + eta * kt) / (1 - kt) * k'(t) * dt boost = 1.0 + eta * kt.view(-1, 1) prob_unmask = (kt_derivative.view(-1, 1) * delta_t * boost / denom).clamp(0.0, 1.0) else: prob_unmask = (kt_derivative.view(-1, 1) * delta_t / denom).clamp(0.0, 1.0) if strategy == "confidence": # Confidence is maximum predicted probability across classes confidence = probs.max(dim=-1).values if confidence_bonus is not None: confidence = confidence + confidence_bonus # Exclude already unmasked or inactive positions confidence = confidence.masked_fill(~is_masked, -1e9) if active_mask is not None: num_active = active_mask.sum(dim=-1, keepdim=True).float() else: num_active = torch.full( (x_t.shape[0], 1), x_t.shape[1], device=x_t.device, dtype=torch.float32 ) if kt_next is not None: # Cumulative unmasking with optional Detailed Balance boost if eta > 0.0: target_clean = torch.round(num_active * (kt_next.view(-1, 1) + eta * kt.view(-1, 1) * delta_t)).long() else: target_clean = torch.round(num_active * kt_next.view(-1, 1)).long() act = active_mask if active_mask is not None else torch.ones_like(is_masked) current_clean = (~is_masked & act).sum(dim=-1, keepdim=True) num_to_unmask = torch.clamp(target_clean - current_clean, min=0) else: num_to_unmask = torch.clamp(torch.ceil(num_active * prob_unmask).long(), min=1) # Vectorized top-k selection via double argsort (rank 0 is highest confidence) if isinstance(temperature, torch.Tensor): stoch_mask = (temperature.view(-1, 1) > 0.05).float() conf_noise = torch.rand_like(confidence) * stoch_mask * 0.05 ranks = torch.argsort(torch.argsort(confidence + conf_noise, dim=-1, descending=True), dim=-1) else: ranks = torch.argsort(torch.argsort(confidence, dim=-1, descending=True), dim=-1) will_unmask = (ranks < num_to_unmask) & is_masked else: will_unmask = (torch.rand_like(x_t, dtype=torch.float32) < prob_unmask) & is_masked updated = x_t.clone() updated[will_unmask] = x_1[will_unmask] # Detailed Balance Re-Masking (stochastic jump back to mask state, Campbell et al. 2024) if eta > 0.0 and not is_final_step: prob_remask = min(1.0, float(delta_t * eta)) act = active_mask if active_mask is not None else torch.ones_like(is_masked) is_unmasked = (~is_masked) & act will_remask = (torch.rand_like(x_t, dtype=torch.float32) < prob_remask) & is_unmasked updated[will_remask] = mask_id return updated def inference_sample_uniform( kt, kt_derivatives, x_t, logits, vocab, delta_t, active_mask=None, temperature: float = 1.0 ): """Reverse uniform step; only active positions may change.""" if temperature > 0.0 and temperature != 1.0: probs = F.softmax(logits / temperature, dim=-1) else: probs = F.softmax(logits, dim=-1) denom = clean_weight_denominator(kt).view(-1, 1, 1) step_probs = (probs * kt_derivatives.view(-1, 1, 1) * delta_t / denom).clamp(max=1.0) x_t_clamped = x_t.clamp(0, probs.shape[-1] - 1) step_probs = step_probs.scatter(-1, x_t_clamped.unsqueeze(-1), 0.0) remaining = (1.0 - step_probs.sum(dim=-1, keepdim=True)).clamp(min=0.0) step_probs = step_probs.scatter(-1, x_t_clamped.unsqueeze(-1), remaining) samples = Categorical(step_probs.clamp(min=0.0)).sample() return _apply_active_only(x_t, samples, active_mask)