File size: 15,302 Bytes
fed954e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
"""
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()


@dataclass
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