""" HuggingFace Transformers generation engine — drop-in replacement for GenerationEngine that loads any model from the HuggingFace Hub. Uses ``AutoModelForCausalLM`` and ``AutoTokenizer`` so any causal LM works without architecture-specific code. Exposes the same ``generate_answer()`` / ``generate_stream()`` interface so eval scripts can swap engines with a single line. Designed for the multi-model eval benchmark: load a model, run the eval, delete the engine, free memory, load the next model. """ from __future__ import annotations import re import torch from transformers import AutoModelForCausalLM, AutoTokenizer from src.utils.device import get_default_device # Pattern to strip ... blocks (possibly spanning lines) _THINK_RE = re.compile(r".*?", re.DOTALL) # Unclosed block at the end of a generation _THINK_UNCLOSED_RE = re.compile(r".*", re.DOTALL) class TransformersEngine: """Architecture-agnostic generation engine backed by HuggingFace Transformers. Parameters ---------- model_id : str HuggingFace model identifier (e.g. ``"Qwen/Qwen3-4B"``). dtype : torch.dtype Weight precision. Defaults to ``torch.bfloat16``. max_memory_gb : float | None Optional memory cap (not enforced, just for documentation). """ def __init__( self, model_id: str, dtype: torch.dtype = torch.bfloat16, enable_thinking: bool = False, trust_remote_code: bool = True, ): self.model_id = model_id self.device = get_default_device() self.enable_thinking = enable_thinking self.tokenizer = AutoTokenizer.from_pretrained( model_id, trust_remote_code=trust_remote_code, ) self.model = AutoModelForCausalLM.from_pretrained( model_id, dtype=dtype, trust_remote_code=trust_remote_code, ).to(self.device) self.model.eval() def generate_answer( self, messages: list[dict], max_tokens: int = 512, temperature: float = 0.7, top_k: int = 20, top_p: float = 0.95, ) -> str: """Generate a complete response (non-streaming). Compatible with ``GenerationEngine.generate_answer()``. """ # Apply chat template template_kwargs = { "tokenize": False, "add_generation_prompt": True, } # Disable thinking mode when supported (Qwen3/3.5) to avoid # wasting the token budget on blocks. if not self.enable_thinking: try: text = self.tokenizer.apply_chat_template( messages, enable_thinking=False, **template_kwargs, ) except TypeError: # Model doesn't support enable_thinking param text = self.tokenizer.apply_chat_template( messages, **template_kwargs, ) else: text = self.tokenizer.apply_chat_template( messages, **template_kwargs, ) inputs = self.tokenizer(text, return_tensors="pt").to(self.device) input_len = inputs["input_ids"].shape[1] # Generate — fall back to greedy if sampling hits NaN logits # (common on MPS + bfloat16 with long contexts). do_sample = temperature > 0.01 gen_kwargs: dict = dict( max_new_tokens=max_tokens, pad_token_id=self.tokenizer.eos_token_id, ) if do_sample: gen_kwargs.update( do_sample=True, temperature=max(temperature, 1e-4), top_k=top_k, top_p=top_p, ) else: gen_kwargs["do_sample"] = False with torch.no_grad(): try: outputs = self.model.generate(**inputs, **gen_kwargs) except RuntimeError as e: if "probability tensor" in str(e) and do_sample: # NaN/Inf logits — retry with greedy decoding outputs = self.model.generate( **inputs, max_new_tokens=max_tokens, pad_token_id=self.tokenizer.eos_token_id, do_sample=False, ) else: raise # Decode only the generated tokens generated = outputs[0][input_len:] answer = self.tokenizer.decode(generated, skip_special_tokens=True) # Strip ... blocks answer = _THINK_RE.sub("", answer) answer = _THINK_UNCLOSED_RE.sub("", answer) return answer.strip() def generate_stream( self, messages: list[dict], max_tokens: int = 512, temperature: float = 0.7, top_k: int = 20, top_p: float = 0.95, ): """Yield the full answer as a single chunk. Provided for interface compatibility with ``GenerationEngine``. True token-level streaming is not needed for eval. """ yield self.generate_answer( messages, max_tokens=max_tokens, temperature=temperature, top_k=top_k, top_p=top_p, )