""" MDLM-BPE v3: Scaled model for coherent text generation. Key changes over v2: 1. Semi-autoregressive unmasking (left-to-right in blocks) - Fixes the root cause of incoherence: parallel prediction - Each block has context from already-decided left tokens 2. Larger model: d_model=1024, 10 layers, 16 heads (~170M params) 3. Longer sequences: seq_len=128 4. Bigger vocab support: 16K 5. KV cache for inference speedup 6. Gradient checkpointing for memory efficiency """ import math import json import sys import time import random from pathlib import Path from typing import List, Optional, Tuple import torch import torch.nn as nn import torch.nn.functional as F import numpy as np from tokenizers import Tokenizer REPO = Path(__file__).resolve().parent.parent sys.path.insert(0, str(REPO / "src")) DEVICE = "cuda" if torch.cuda.is_available() else "cpu" CHECKPOINT_DIR = REPO / "checkpoints" CHECKPOINT_DIR.mkdir(exist_ok=True) TOKENIZER_PATH = REPO / "tokenizer" / "bpe_tokenizer.json" class BPETokenizer: def __init__(self, path=TOKENIZER_PATH): self.tokenizer = Tokenizer.from_file(str(path)) self.pad_id = self.tokenizer.token_to_id("") self.mask_id = self.tokenizer.token_to_id("") self.bos_id = self.tokenizer.token_to_id("") self.eos_id = self.tokenizer.token_to_id("") self.unk_id = self.tokenizer.token_to_id("") self.vocab_size = self.tokenizer.get_vocab_size() def encode(self, text, add_special=True): enc = self.tokenizer.encode(text) ids = enc.ids if add_special: ids = [self.bos_id] + ids + [self.eos_id] return ids def decode(self, ids): clean = [i for i in ids if i not in (self.pad_id, self.mask_id, self.bos_id)] return self.tokenizer.decode(clean) # ═══════════════════════════════════════════════════════════════════════════ # RoPE (shared with v2, tested) # ═══════════════════════════════════════════════════════════════════════════ def precompute_rope(head_dim, max_seq_len, base=10000.0): half = head_dim // 2 freqs = 1.0 / (base ** (torch.arange(0, half).float() / half)) positions = torch.arange(max_seq_len).float() angles = positions[:, None] * freqs[None, :] return torch.cos(angles), torch.sin(angles) def apply_rope(x, cos, sin): """Apply RoPE, slicing cos/sin to match current sequence length.""" seq_len = x.shape[2] cos = cos[:seq_len] sin = sin[:seq_len] x1 = x[..., 0::2] x2 = x[..., 1::2] cos = cos.unsqueeze(0).unsqueeze(0) sin = sin.unsqueeze(0).unsqueeze(0) rotated1 = x1 * cos - x2 * sin rotated2 = x1 * sin + x2 * cos out = torch.stack([rotated1, rotated2], dim=-1) return out.flatten(-2) # ═══════════════════════════════════════════════════════════════════════════ # Config # ═══════════════════════════════════════════════════════════════════════════ class MDLMConfig: def __init__(self, vocab_size=16_000, d_model=1024, n_heads=16, n_layers=10, max_seq_len=256, d_ff=None, dropout=0.1): self.vocab_size = vocab_size self.d_model = d_model self.n_heads = n_heads self.n_layers = n_layers self.max_seq_len = max_seq_len self.d_ff = d_ff or d_model * 4 self.dropout = dropout def to_dict(self): return self.__dict__ # ═══════════════════════════════════════════════════════════════════════════ # Model # ═════════════════════════════════════════════════════_len class MDLMBlockV3(nn.Module): """Transformer block with RoPE, AdaLN, Flash Attention.""" def __init__(self, d_model, n_heads, d_ff, dropout=0.1): super().__init__() self.d_model = d_model self.n_heads = n_heads self.head_dim = d_model // n_heads self.ln1 = nn.LayerNorm(d_model) self.q_proj = nn.Linear(d_model, d_model, bias=False) self.k_proj = nn.Linear(d_model, d_model, bias=False) self.v_proj = nn.Linear(d_model, d_model, bias=False) self.o_proj = nn.Linear(d_model, d_model) self.attn_drop = nn.Dropout(dropout) self.ln2 = nn.LayerNorm(d_model) self.ff = nn.Sequential( nn.Linear(d_model, d_ff), nn.GELU(), nn.Dropout(dropout), nn.Linear(d_ff, d_model), nn.Dropout(dropout), ) self.ada_ln = nn.Sequential( nn.SiLU(), nn.Linear(d_model, 6 * d_model, bias=True), ) nn.init.zeros_(self.ada_ln[-1].weight) nn.init.zeros_(self.ada_ln[-1].bias) def forward(self, x, t_emb, rope_cos, rope_sin): batch, seq, _ = x.shape scale_shift = self.ada_ln(t_emb) s1, sh1, s2, sh2, s3, sh3 = scale_shift.chunk(6, dim=-1) # Self-attention h = self.ln1(x) h = h * (1 + s1.unsqueeze(1)) + sh1.unsqueeze(1) q = self.q_proj(h).view(batch, seq, self.n_heads, self.head_dim).transpose(1, 2) k = self.k_proj(h).view(batch, seq, self.n_heads, self.head_dim).transpose(1, 2) v = self.v_proj(h).view(batch, seq, self.n_heads, self.head_dim).transpose(1, 2) q = apply_rope(q, rope_cos, rope_sin) k = apply_rope(k, rope_cos, rope_sin) attn = F.scaled_dot_product_attention( q, k, v, dropout_p=self.attn_drop.p if self.training else 0.0, is_causal=False, ) attn = attn.transpose(1, 2).reshape(batch, seq, self.d_model) x = x + self.o_proj(attn) # FFN h = self.ln2(x) h = h * (1 + s2.unsqueeze(1)) + sh2.unsqueeze(1) x = x + self.ff(h) x = x * (1 + s3.unsqueeze(1)) + sh3.unsqueeze(1) return x class MDLMBPEV3(nn.Module): """MDLM v3 — scaled for coherent text. d_model=1024, 10 layers, 16 heads → ~170M params seq_len up to 256 """ def __init__(self, config: MDLMConfig, pad_id=0, mask_id=1): super().__init__() self.config = config self.pad_id = pad_id self.mask_id = mask_id d = config.d_model self.token_emb = nn.Embedding(config.vocab_size, d, padding_idx=pad_id) self.time_embed = nn.Sequential( nn.Linear(d, d), nn.SiLU(), nn.Linear(d, d), ) self.blocks = nn.ModuleList([ MDLMBlockV3(d, config.n_heads, config.d_ff, config.dropout) for _ in range(config.n_layers) ]) self.ln_f = nn.LayerNorm(d) self.output_bias = nn.Parameter(torch.zeros(config.vocab_size)) head_dim = d // config.n_heads cos, sin = precompute_rope(head_dim, config.max_seq_len) self.register_buffer("rope_cos", cos, persistent=False) self.register_buffer("rope_sin", sin, persistent=False) self._init_weights() def _init_weights(self): for m in self.modules(): if isinstance(m, nn.Linear): nn.init.normal_(m.weight, mean=0, std=0.02) if m.bias is not None: nn.init.zeros_(m.bias) elif isinstance(m, nn.Embedding): nn.init.normal_(m.weight, mean=0, std=0.02) if m.padding_idx is not None: with torch.no_grad(): m.weight[m.padding_idx].fill_(0) nn.init.zeros_(self.output_bias) def _timestep_embedding(self, t): d = self.config.d_model half = d // 2 freqs = torch.exp( -math.log(10000) * torch.arange(half, device=t.device).float() / half ) args = t[:, None].float() * freqs[None, :] emb = torch.cat([torch.sin(args), torch.cos(args)], dim=-1) return self.time_embed(emb) def forward(self, tokens, t, return_hidden=False): """Forward pass. Args: tokens: [batch, seq] token IDs t: [batch] timestep in [0, 1] return_hidden: if True, also return pre-projection hidden states [batch, seq, d_model] for semantic analysis. Returns: logits [batch, seq, vocab] or (logits, hidden) tuple. """ batch, seq = tokens.shape x = self.token_emb(tokens) t_emb = self._timestep_embedding(t) for block in self.blocks: x = block(x, t_emb, self.rope_cos, self.rope_sin) x = self.ln_f(x) logits = F.linear(x, self.token_emb.weight, self.output_bias) if return_hidden: return logits, x return logits def get_embeddings(self, tokens, t=None): """Get contextual embeddings for tokens. Runs forward pass but returns hidden states instead of logits. Used by SemanticCoherenceHRM for drift detection. Args: tokens: [batch, seq] t: optional timestep, defaults to 0 (fully unmasked) Returns: hidden: [batch, seq, d_model] contextual embeddings """ if t is None: t = torch.zeros(tokens.shape[0], device=tokens.device) _, hidden = self.forward(tokens, t, return_hidden=True) return hidden # ═════════════════════════════════════════ ═══════════════════════════════ # Diffusion Process # ═════════════ ══════════════════════════════════════════════════════════════ def forward_mask_bpe(tokens, t, mask_id=1, protect_special=True): batch, seq_len = tokens.shape prob_mask = t[:, None].expand(batch, seq_len) rand = torch.rand_like(tokens.float()) mask_positions = rand < prob_mask if protect_special: is_special = (tokens == 0) | (tokens == 2) | (tokens == 3) mask_positions = mask_positions & (~is_special) masked = torch.where(mask_positions, mask_id, tokens) return masked, mask_positions def mdlm_loss(model, tokens, mask_id=1): batch = tokens.shape[0] t = torch.rand(batch, device=tokens.device) masked, mask_pos = forward_mask_bpe(tokens, t, mask_id) with torch.amp.autocast('cuda', dtype=torch.bfloat16): logits = model(masked, t) mask_flat = mask_pos.reshape(-1) if mask_flat.sum() == 0: return torch.tensor(0.0, device=tokens.device, requires_grad=True) logits_flat = logits.reshape(-1, logits.shape[-1]) tokens_flat = tokens.reshape(-1) return F.cross_entropy(logits_flat[mask_flat], tokens_flat[mask_flat]) # ═══════════════════════════ ══════════════════════════════════════════════ # Semi-Autoregressive Sampling — THE KEY FIX # ═════════════════════════════════ ══════════════════════════════════════ @torch.no_grad() def sample_semi_ar(model, tokenizer, prompt_ids=None, seq_len=128, n_samples=1, block_size=4, temperature=0.7, device=DEVICE): """Semi-autoregressive sampling: unmask left-to-right in blocks. Instead of predicting ALL positions simultaneously (which causes repetition because no position knows what others chose), we: 1. Divide sequence into blocks of `block_size` tokens 2. Predict block 1 (positions 0..3) with diffusion 3. Commit those tokens, then predict block 2 (positions 4..7) with block 1 as context 4. Continue until sequence is complete This gives each block FULL context of everything to its left, which is exactly what an autoregressive model does — but each block is generated via diffusion (parallel within the block). block_size=1 → fully autoregressive (max coherence, min speed) block_size=4 → good balance (4 tokens see each other + left context) block_size=seq_len → fully parallel (v2 behavior, max speed, min coherence) """ model.eval() mask_id = tokenizer.mask_id pad_id = tokenizer.pad_id # Initialize if prompt_ids is not None: full = torch.full((n_samples, seq_len), mask_id, device=device) prompt_len = min(len(prompt_ids), seq_len) full[:, :prompt_len] = torch.tensor(prompt_ids[:prompt_len], device=device) else: full = torch.full((n_samples, seq_len), mask_id, device=device) prompt_len = 0 n_steps_per_block = max(2, block_size) # Process in left-to-right blocks for block_start in range(prompt_len, seq_len, block_size): block_end = min(block_start + block_size, seq_len) # Diffusion within this block (multiple refinement steps) for step in range(n_steps_per_block): t_val = max(0.5 - step / (n_steps_per_block * 2), 0.01) t = torch.full((n_samples,), t_val, device=device) logits = model(full, t) # Find masked positions in current block mask_in_block = (full[:, block_start:block_end] == mask_id) if not mask_in_block.any(): break pos_logits = logits[:, block_start:block_end] / max(temperature, 0.01) probs = F.softmax(pos_logits, dim=-1) sampled = torch.multinomial( probs.reshape(-1, probs.shape[-1]), 1 ).squeeze(-1).reshape(n_samples, -1) confidence = probs.max(dim=-1)[0] confidence[~mask_in_block] = -1 # Unmask proportionally within block n_masked = mask_in_block.sum(dim=1) n_to_unmask = torch.clamp(n_masked // max(n_steps_per_block - step, 1), min=1) for b in range(n_samples): if n_masked[b] == 0: continue k = min(int(n_to_unmask[b].item()), int(n_masked[b].item())) top_conf, top_idx = confidence[b].topk(k) valid = top_conf > 0 if valid.any(): positions = top_idx[valid] full[b, block_start + positions] = sampled[b, positions] # Decode results = [] for b in range(n_samples): ids = full[b].cpu().tolist() if tokenizer.eos_id in ids: ids = ids[:ids.index(tokenizer.eos_id)] text = tokenizer.decode(ids) results.append(text) return results @torch.no_grad() def generate_response_semi_ar(model, tokenizer, prompt, max_len=64, block_size=4, temperature=0.6, device=DEVICE): """Chatbot response with semi-AR sampling.""" model.eval() user_tok = tokenizer.tokenizer.token_to_id("<|user|>") asst_tok = tokenizer.tokenizer.token_to_id("<|assistant|>") mask_id = tokenizer.mask_id pad_id = tokenizer.pad_id ctx_ids = tokenizer.tokenizer.encode(prompt).ids prefix = [tokenizer.bos_id, user_tok] + ctx_ids + [asst_tok] response_start = len(prefix) seq_len = min(256, response_start + max_len) seq = (prefix + [mask_id] * max_len)[:seq_len] while len(seq) < seq_len: seq.append(pad_id) full = torch.tensor([seq], device=device) n_steps = max(2, block_size) for block_start in range(response_start, seq_len, block_size): block_end = min(block_start + block_size, seq_len) for step in range(n_steps): t_val = max(0.5 - step / (n_steps * 2), 0.01) t = torch.full((1,), t_val, device=device) logits = model(full, t) mask_in_block = (full[0, block_start:block_end] == mask_id) if not mask_in_block.any(): break idxs = mask_in_block.nonzero(as_tuple=True)[0] pos_logits = logits[0, block_start:block_end][idxs] / max(temperature, 0.01) probs = F.softmax(pos_logits, dim=-1) sampled = torch.multinomial(probs, 1).squeeze(-1) conf = probs.max(dim=-1)[0] n_unmask = max(1, len(idxs) // (n_steps - step)) top_conf, top_idx = conf.topk(min(n_unmask, len(idxs))) positions_in_block = idxs[top_idx] full[0, block_start + positions_in_block] = sampled[top_idx] resp_ids = full[0, response_start:].cpu().tolist() if tokenizer.eos_id in resp_ids: resp_ids = resp_ids[:resp_ids.index(tokenizer.eos_id)] resp_ids = [i for i in resp_ids if i not in (pad_id, mask_id)] return tokenizer.decode(resp_ids)