Spaces:
Running on Zero
Running on Zero
| """ | |
| model_runner.py — Loads a Qwen instruct model once and exposes three generation modes. | |
| Modes: | |
| 1. free — raw chat, no JSON instruction. Establishes a "what does the model | |
| do unprompted?" floor. | |
| 2. json_mode — chat with a strong system prompt asking for JSON-only output. | |
| No decoder-level constraint. Tests in-context schema obedience. | |
| 3. constrained — uses xgrammar (preferred) or outlines (fallback) to enforce the | |
| grammar at decode time. Schema validity becomes guaranteed; the | |
| interesting question is whether faithfulness survives. | |
| The model is loaded ONCE in __init__. All three modes share the same weights. | |
| Optional dependencies (xgrammar, outlines) degrade gracefully — if neither is installed, | |
| generate_constrained falls back to json_mode and emits a warning. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import warnings | |
| from dataclasses import dataclass | |
| from typing import Optional | |
| import torch | |
| from transformers import AutoModelForCausalLM, AutoTokenizer | |
| # Optional backends — import lazily and tolerate missing | |
| try: | |
| import xgrammar as xgr | |
| _HAS_XGRAMMAR = True | |
| except ImportError: | |
| _HAS_XGRAMMAR = False | |
| try: | |
| import outlines | |
| _HAS_OUTLINES = True | |
| except ImportError: | |
| _HAS_OUTLINES = False | |
| SYSTEM_PROMPT_FREE = ( | |
| "You are a vision-language assistant. Given an image caption, describe what the " | |
| "image shows." | |
| ) | |
| # NOTE: this schema block mirrors SLOT_REGISTRY (registry.py) — the registry is | |
| # the source of truth. If a slot is added/removed there, update this block too | |
| # (a stale prompt validates fine because pydantic ignores extras, but the model | |
| # wastes output budget on fields that get silently dropped — caught 2026-07). | |
| SYSTEM_PROMPT_JSON = """You are a caption structuring assistant. Given an image caption, | |
| extract its content into JSON matching this exact schema: | |
| { | |
| "subjects": [{"name": str, "attributes": [str]}], | |
| "actions": [str], | |
| "setting": "indoor" | "outdoor" | "unknown", | |
| "style": str or null, | |
| "mood": str or null | |
| } | |
| Rules: | |
| - Only include subjects, attributes, and actions that are EXPLICITLY mentioned in the caption. | |
| - Never invent details that aren't in the input. | |
| - If the caption doesn't specify the setting, use "unknown". | |
| - If no style or mood is evident, use null. | |
| - Limits (hard): at most 8 subjects and at most 8 actions (attributes per subject are | |
| unlimited), and every string under 64 characters. If the caption has more, keep only | |
| the most important ones. | |
| - Output ONLY the JSON object. No prose, no markdown, no code fences. | |
| """.strip() | |
| class GenResult: | |
| """Output of a single generation call.""" | |
| mode: str # "free" | "json_mode" | "constrained" | |
| raw_text: str # exactly what the model decoded (after chat template strip) | |
| backend: str # "transformers" | "xgrammar" | "outlines" | |
| n_input_tokens: int | |
| n_output_tokens: int | |
| class QwenRunner: | |
| """Loads a Qwen instruct model once, runs three generation modes against it.""" | |
| def __init__( | |
| self, | |
| model_id: str = "Qwen/Qwen3.5-0.8B", | |
| device: Optional[str] = None, | |
| dtype: torch.dtype = torch.bfloat16, | |
| trust_remote_code: bool = True, | |
| enable_thinking: bool = False, | |
| ): | |
| """ | |
| Loads a Qwen3.5 post-trained checkpoint. | |
| Notes on Qwen3.5-0.8B specifically: | |
| * It is a vision-language model (image-text-to-text). For text-only use | |
| (this benchmark), just don't pass image content; the chat template | |
| handles it. The vision encoder still gets loaded into VRAM (~0.1 GB). | |
| * model_type=qwen3_5 needs transformers from git main: | |
| pip install "transformers @ git+https://github.com/huggingface/transformers.git@main" | |
| * Default is non-thinking mode. Qwen3.5-0.8B is prone to thinking loops, | |
| so leave enable_thinking=False unless you have a reason. | |
| """ | |
| self.model_id = model_id | |
| self.device = device or ("cuda" if torch.cuda.is_available() else "cpu") | |
| self.dtype = dtype | |
| self.enable_thinking = enable_thinking | |
| print(f"[QwenRunner] loading {model_id} on {self.device} ({dtype})") | |
| self.tokenizer = AutoTokenizer.from_pretrained( | |
| model_id, trust_remote_code=trust_remote_code | |
| ) | |
| self.model = AutoModelForCausalLM.from_pretrained( | |
| model_id, | |
| torch_dtype=dtype, | |
| device_map=self.device, | |
| trust_remote_code=trust_remote_code, | |
| ) | |
| self.model.eval() | |
| # xgrammar compiler is reusable across calls — build once. | |
| self._xgr_compiled_grammar = None | |
| self._xgr_tokenizer_info = None | |
| if _HAS_XGRAMMAR: | |
| try: | |
| self._xgr_tokenizer_info = xgr.TokenizerInfo.from_huggingface(self.tokenizer) | |
| self._xgr_compiler = xgr.GrammarCompiler(self._xgr_tokenizer_info) | |
| except Exception as e: | |
| warnings.warn(f"xgrammar tokenizer init failed: {e}; falling back") | |
| self._xgr_compiler = None | |
| else: | |
| self._xgr_compiler = None | |
| print(f"[QwenRunner] ready. xgrammar={_HAS_XGRAMMAR}, outlines={_HAS_OUTLINES}") | |
| # ── prompt construction ────────────────────────────────────────────── | |
| def _build_chat(self, system: str, user: str) -> str: | |
| """Apply chat template; returns the formatted prompt string. | |
| Per the Qwen3.5 card, thinking mode is toggled via the `enable_thinking` | |
| template variable (the legacy /think /nothink soft switch was removed). | |
| When calling apply_chat_template directly, pass it as a regular kwarg; | |
| when calling via OpenAI-compat APIs, nest it under chat_template_kwargs. | |
| """ | |
| msgs = [ | |
| {"role": "system", "content": system}, | |
| {"role": "user", "content": user}, | |
| ] | |
| return self.tokenizer.apply_chat_template( | |
| msgs, | |
| tokenize=False, | |
| add_generation_prompt=True, | |
| enable_thinking=self.enable_thinking, | |
| ) | |
| # Recommended sampling for Qwen3.5-0.8B non-thinking text tasks (per model card). | |
| # Keep top_k since transformers supports it; min_p, presence_penalty likewise. | |
| RECOMMENDED_SAMPLING_NONTHINKING = dict( | |
| temperature=1.0, top_p=1.0, top_k=20, min_p=0.0, | |
| repetition_penalty=1.0, # presence_penalty=2.0 not directly supported in HF generate | |
| ) | |
| RECOMMENDED_SAMPLING_THINKING = dict( | |
| temperature=1.0, top_p=0.95, top_k=20, min_p=0.0, | |
| repetition_penalty=1.0, | |
| ) | |
| def _generate_unconstrained( | |
| self, | |
| prompt_str: str, | |
| max_new_tokens: int, | |
| temperature: float, | |
| sampling_preset: Optional[str] = None, | |
| ) -> tuple[str, int, int]: | |
| """Plain HF generation; returns (decoded, n_in, n_out). | |
| sampling_preset: | |
| None — greedy (or sampled at given temperature), default top_p/top_k | |
| "recommended" — apply Qwen3.5 paper's recommended params for current mode | |
| """ | |
| inputs = self.tokenizer(prompt_str, return_tensors="pt").to(self.device) | |
| n_in = inputs["input_ids"].shape[1] | |
| gen_kwargs = dict( | |
| max_new_tokens=max_new_tokens, | |
| pad_token_id=self.tokenizer.eos_token_id, | |
| ) | |
| if sampling_preset == "recommended": | |
| preset = ( | |
| self.RECOMMENDED_SAMPLING_THINKING | |
| if self.enable_thinking else self.RECOMMENDED_SAMPLING_NONTHINKING | |
| ) | |
| gen_kwargs.update(preset) | |
| gen_kwargs["do_sample"] = True | |
| else: | |
| gen_kwargs["do_sample"] = (temperature > 0) | |
| gen_kwargs["temperature"] = temperature if temperature > 0 else 1.0 | |
| with torch.no_grad(): | |
| out = self.model.generate(**inputs, **gen_kwargs) | |
| # Strip the prompt to keep only newly generated tokens | |
| new_tokens = out[0, n_in:] | |
| n_out = int(new_tokens.shape[0]) | |
| text = self.tokenizer.decode(new_tokens, skip_special_tokens=True) | |
| return text, n_in, n_out | |
| # ── public modes ───────────────────────────────────────────────────── | |
| def generate_free( | |
| self, caption: str, max_new_tokens: int = 256, temperature: float = 0.0, | |
| sampling_preset: Optional[str] = None, | |
| ) -> GenResult: | |
| prompt = self._build_chat(SYSTEM_PROMPT_FREE, caption) | |
| text, n_in, n_out = self._generate_unconstrained( | |
| prompt, max_new_tokens, temperature, sampling_preset | |
| ) | |
| return GenResult("free", text, "transformers", n_in, n_out) | |
| def generate_json_mode( | |
| self, caption: str, max_new_tokens: int = 256, temperature: float = 0.0, | |
| sampling_preset: Optional[str] = None, | |
| ) -> GenResult: | |
| prompt = self._build_chat(SYSTEM_PROMPT_JSON, caption) | |
| text, n_in, n_out = self._generate_unconstrained( | |
| prompt, max_new_tokens, temperature, sampling_preset | |
| ) | |
| return GenResult("json_mode", text, "transformers", n_in, n_out) | |
| def generate_constrained( | |
| self, | |
| caption: str, | |
| grammar_gbnf: Optional[str] = None, | |
| json_schema: Optional[dict] = None, | |
| max_new_tokens: int = 256, | |
| temperature: float = 0.0, | |
| sampling_preset: Optional[str] = None, | |
| ) -> GenResult: | |
| """ | |
| Grammar-constrained decoding. Prefers xgrammar (fastest), falls back to outlines, | |
| then to plain json_mode with a warning. | |
| Provide EITHER grammar_gbnf (xgrammar path) OR json_schema (outlines path). | |
| If both are provided, xgrammar wins when available. | |
| """ | |
| prompt = self._build_chat(SYSTEM_PROMPT_JSON, caption) | |
| # xgrammar path | |
| if self._xgr_compiler is not None and grammar_gbnf is not None: | |
| return self._generate_xgrammar( | |
| prompt, grammar_gbnf, max_new_tokens, temperature, sampling_preset | |
| ) | |
| # outlines path — keep as fallback; install instructions in dependencies.txt | |
| if _HAS_OUTLINES and json_schema is not None: | |
| warnings.warn("outlines path not yet implemented; falling back to json_mode") | |
| # final fallback | |
| warnings.warn( | |
| "No constrained-decoding backend active; falling back to json_mode. " | |
| "Install xgrammar for true grammar-constrained generation." | |
| ) | |
| text, n_in, n_out = self._generate_unconstrained( | |
| prompt, max_new_tokens, temperature, sampling_preset | |
| ) | |
| return GenResult("constrained_fallback", text, "transformers", n_in, n_out) | |
| def _generate_xgrammar( | |
| self, prompt_str: str, grammar_gbnf: str, max_new_tokens: int, | |
| temperature: float, sampling_preset: Optional[str] = None, | |
| ) -> GenResult: | |
| """xgrammar-backed constrained generation. | |
| Uses a hand-rolled LogitsProcessor instead of `xgr.contrib.hf.LogitsProcessor` | |
| because the latter passes a tensor scalar to `matcher.accept_token`, which | |
| the current xgrammar tvm-ffi binding rejects (it requires a Python int). | |
| Calling `.item()` on the token id, as every official xgrammar tutorial does, | |
| sidesteps the bug. | |
| """ | |
| compiled = self._xgr_compiler.compile_grammar(grammar_gbnf) | |
| inputs = self.tokenizer(prompt_str, return_tensors="pt").to(self.device) | |
| n_in = inputs["input_ids"].shape[1] | |
| logits_processor = _XGrammarLogitsProcessor( | |
| compiled_grammar=compiled, | |
| vocab_size=self._xgr_tokenizer_info.vocab_size, | |
| prompt_len=n_in, | |
| ) | |
| gen_kwargs = dict( | |
| max_new_tokens=max_new_tokens, | |
| pad_token_id=self.tokenizer.eos_token_id, | |
| logits_processor=[logits_processor], | |
| ) | |
| if sampling_preset == "recommended": | |
| preset = ( | |
| self.RECOMMENDED_SAMPLING_THINKING | |
| if self.enable_thinking else self.RECOMMENDED_SAMPLING_NONTHINKING | |
| ) | |
| gen_kwargs.update(preset) | |
| gen_kwargs["do_sample"] = True | |
| else: | |
| gen_kwargs["do_sample"] = (temperature > 0) | |
| gen_kwargs["temperature"] = temperature if temperature > 0 else 1.0 | |
| with torch.no_grad(): | |
| out = self.model.generate(**inputs, **gen_kwargs) | |
| new_tokens = out[0, n_in:] | |
| n_out = int(new_tokens.shape[0]) | |
| text = self.tokenizer.decode(new_tokens, skip_special_tokens=True) | |
| return GenResult("constrained", text, "xgrammar", n_in, n_out) | |
| # ────────────────────────────────────────────────────────────────────────────── | |
| # Custom xgrammar LogitsProcessor. | |
| # | |
| # Replaces the broken `xgr.contrib.hf.LogitsProcessor` (it passes a tensor scalar | |
| # to `accept_token`, which the current tvm-ffi binding rejects with | |
| # "Expected int but got ffi.Tensor"). We track previously-accepted positions and | |
| # convert every token to a plain int via `.item()` before passing it to xgrammar. | |
| # ────────────────────────────────────────────────────────────────────────────── | |
| class _XGrammarLogitsProcessor: | |
| """Constrains HF `generate` output to a compiled xgrammar grammar.""" | |
| def __init__(self, compiled_grammar, vocab_size: int, prompt_len: int): | |
| if not _HAS_XGRAMMAR: # pragma: no cover | |
| raise RuntimeError("xgrammar is not installed") | |
| self.matcher = xgr.GrammarMatcher(compiled_grammar) | |
| # bitmask must be int32 CPU per xgrammar docs; we move to logits.device | |
| # on apply. | |
| self.bitmask = xgr.allocate_token_bitmask(1, vocab_size) | |
| self.prompt_len = prompt_len | |
| self.accepted_up_to = prompt_len # next position to accept from | |
| def __call__(self, input_ids, scores): | |
| # input_ids: (batch=1, cur_len) scores: (batch=1, vocab_size) | |
| cur_len = int(input_ids.shape[1]) | |
| # Accept every token generated since we last ran. On the first call | |
| # cur_len == prompt_len, so this loop is a no-op. | |
| for pos in range(self.accepted_up_to, cur_len): | |
| tok = int(input_ids[0, pos].item()) # ← the critical .item() fix | |
| ok = self.matcher.accept_token(tok) | |
| if not ok: # pragma: no cover — shouldn't happen with constrained sampling | |
| break | |
| self.accepted_up_to = cur_len | |
| if self.matcher.is_terminated(): | |
| return scores | |
| # Fill bitmask and apply to current-step logits. | |
| self.matcher.fill_next_token_bitmask(self.bitmask) | |
| xgr.apply_token_bitmask_inplace(scores, self.bitmask.to(scores.device)) | |
| return scores | |