File size: 16,948 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
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
"""
schema.py — Registry-parametric schema code generation.

Generates three representations of a *registry* (a `dict[str, SlotSpec]`):

    build_model_from_registry(name, registry)  → Pydantic model class
    build_json_schema(model)                    → JSON Schema dict
    build_gbnf_from_registry(registry)          → GBNF grammar string

The caption schema is the canonical instance, exposed under stable names:

    Caption                 — Pydantic model (validation / parsing)
    CAPTION_JSON_SCHEMA     — JSON Schema dict (Anthropic API, outlines, etc.)
    CAPTION_GRAMMAR_GBNF    — GBNF grammar string (xgrammar)

The vision subpackage reuses the SAME generators per task category (each
category owns a small `dict[str, SlotSpec]`), so numbers, bounding boxes, and
nested objects are described with the same machinery — see
`qwen_test_runner/vision/`.

All caption artifacts are generated at import time from `registry.SLOT_REGISTRY`.
To add or modify a caption slot, edit `registry.py` only — this file stays
untouched.
"""

from __future__ import annotations

from typing import Any, Literal, Mapping, Optional

from pydantic import BaseModel, Field, create_model

from .registry import SLOT_REGISTRY, SlotSpec, SubjectValue


# ──────────────────────────────────────────────────────────────────────────────
# Pydantic model — built dynamically from any registry.
# ──────────────────────────────────────────────────────────────────────────────

_OBJECT_MODEL_CACHE: dict[SlotSpec, type[BaseModel]] = {}


def _object_model_name(spec: SlotSpec) -> str:
    return "".join(part.capitalize() for part in spec.name.split("_")) + "Obj"


def _build_object_model(spec: SlotSpec) -> type[BaseModel]:
    """Build (and cache) a Pydantic model from a spec's `nested_fields`."""
    cached = _OBJECT_MODEL_CACHE.get(spec)
    if cached is not None:
        return cached
    fields: dict[str, Any] = {}
    for f in spec.nested_fields:
        fields[f.name] = (_python_type_for_slot(f), _field_for_slot(f))
    model = create_model(_object_model_name(spec), **fields)
    _OBJECT_MODEL_CACHE[spec] = model
    return model


def _item_type_for_slot(spec: SlotSpec) -> Any:
    """Python type of a single value of this slot (before list/Optional wrapping)."""
    if spec.nested_fields:
        return _build_object_model(spec)
    if spec.nested_model is not None:
        return spec.nested_model
    if spec.vocabulary == "closed":
        # Literal[("a", "b", "c")] parses identically to Literal["a", "b", "c"].
        return Literal[spec.closed_values]
    vk = spec.value_kind
    if vk == "number":
        return float
    if vk == "integer":
        return int
    if vk in ("bbox", "point"):
        return list[float]
    return str


def _python_type_for_slot(spec: SlotSpec) -> Any:
    """Compute the Python type annotation for a slot's value.

    List cardinality wraps the item type in list[...].
    Single + optional wraps in Optional[...].
    """
    item_type = _item_type_for_slot(spec)
    if spec.cardinality == "list":
        return list[item_type]
    if spec.optional:
        return Optional[item_type]
    return item_type


def _default_for_slot(spec: SlotSpec) -> Any:
    if spec.cardinality == "list":
        return []  # default_factory handled by Field below
    if spec.optional:
        return None
    # Required single value with no default. For closed vocab, default to
    # the last value (usually "unknown") so partial outputs don't blow up.
    if spec.vocabulary == "closed":
        return spec.closed_values[-1]
    return ...  # required, no default


def _field_for_slot(spec: SlotSpec):
    """Construct a Pydantic Field with the right constraints for this slot."""
    kwargs: dict[str, Any] = {}

    if spec.cardinality == "list":
        kwargs["default_factory"] = list
        kwargs["max_length"] = spec.max_items
        return Field(**kwargs)

    default = _default_for_slot(spec)

    # Fixed-length numeric arrays (bbox/point): exactly 4 / 2 elements.
    if spec.value_kind in ("bbox", "point"):
        n = 4 if spec.value_kind == "bbox" else 2
        if default is ...:
            return Field(..., min_length=n, max_length=n)
        return Field(default=default, min_length=n, max_length=n)

    # Scalar numerics, optionally bounded.
    if spec.value_kind in ("number", "integer"):
        rng: dict[str, Any] = {}
        if spec.number_range is not None:
            rng["ge"] = spec.number_range[0]
            rng["le"] = spec.number_range[1]
        if default is ...:
            return Field(..., **rng)
        return Field(default=default, **rng)

    # Strings / enums / nested objects.
    if default is ...:
        # Only plain open strings get a length cap; nested models / enums don't.
        if spec.vocabulary == "open" and spec.nested_model is None and not spec.nested_fields:
            return Field(..., max_length=spec.max_str_length)
        return Field(...)
    kwargs["default"] = default
    if (
        spec.vocabulary == "open"
        and spec.nested_model is None
        and not spec.nested_fields
        and spec.value_kind == "string"
    ):
        kwargs["max_length"] = spec.max_str_length
    return Field(**kwargs)


def build_model_from_registry(model_name: str, registry: Mapping[str, SlotSpec]) -> type[BaseModel]:
    """Build a Pydantic model with one field per registry entry."""
    fields: dict[str, Any] = {}
    for name, spec in registry.items():
        fields[name] = (_python_type_for_slot(spec), _field_for_slot(spec))
    return create_model(model_name, **fields)


def build_json_schema(model: type[BaseModel]) -> dict:
    """JSON Schema for a generated model (thin wrapper for symmetry)."""
    return model.model_json_schema()


Caption = build_model_from_registry("Caption", SLOT_REGISTRY)

# Re-export SubjectValue under the old name "Subject" for callers that
# imported it from schema previously.
Subject = SubjectValue


# ──────────────────────────────────────────────────────────────────────────────
# JSON Schema — derived from the Pydantic model.
# ──────────────────────────────────────────────────────────────────────────────

CAPTION_JSON_SCHEMA: dict = build_json_schema(Caption)


# ──────────────────────────────────────────────────────────────────────────────
# GBNF grammar — built from the registry. Independent of pydantic.
#
# xgrammar's auto-converter from JSON schema sometimes adds unwanted slack
# (e.g. permissive whitespace patterns that hurt parse rates). Generating GBNF
# by hand from the registry gives tighter control and stays consistent with
# the Pydantic model.
#
# The four base primitives (str_array, string, char, ws) are always emitted, as
# in v0.2. Numeric primitives (number, bbox4, …) are emitted ONLY when a slot
# references them, so the caption grammar is byte-for-byte the v0.2 grammar.
# ──────────────────────────────────────────────────────────────────────────────

# Primitive rule definitions, emitted on demand.
_PRIMITIVE_DEFS: dict[str, str] = {
    "uint": 'uint ::= "0" | [1-9] [0-9]*',
    "frac": 'frac ::= "." [0-9]+',
    "exp": 'exp ::= ("e" | "E") ("+" | "-")? [0-9]+',
    "integer": 'integer ::= "-"? uint',
    "number": 'number ::= "-"? uint frac? exp?',
    "num_array": 'num_array ::= "[" ws "]" | "[" ws number (ws "," ws number)* ws "]"',
    "bbox4": 'bbox4 ::= "[" ws number ws "," ws number ws "," ws number ws "," ws number ws "]"',
    "point2": 'point2 ::= "[" ws number ws "," ws number ws "]"',
}

# Transitive dependencies between numeric primitives (the four base primitives
# are always present, so they are never listed here).
_PRIMITIVE_DEPS: dict[str, set[str]] = {
    "uint": set(),
    "frac": set(),
    "exp": set(),
    "integer": {"uint"},
    "number": {"uint", "frac", "exp"},
    "num_array": {"number"},
    "bbox4": {"number"},
    "point2": {"number"},
}

# Stable emission order so the grammar regenerates deterministically.
_PRIMITIVE_ORDER = ["integer", "number", "num_array", "bbox4", "point2", "uint", "frac", "exp"]


def _gbnf_string_alternation(values: tuple[str, ...]) -> str:
    """Emit `"\"a\"" | "\"b\"" | ...` for a closed enum."""
    return " | ".join(f'"\\"{v}\\""' for v in values)


def _resolve_primitive_deps(deps: set[str]) -> set[str]:
    """Expand a set of primitive names with all transitive dependencies."""
    out: set[str] = set()
    stack = list(deps)
    while stack:
        d = stack.pop()
        if d in out:
            continue
        out.add(d)
        stack.extend(_PRIMITIVE_DEPS.get(d, set()))
    return out


def _gbnf_object_rule(spec: SlotSpec) -> tuple[str, list[str], set[str]]:
    """Build the GBNF object rule for a spec's nested_fields. Returns
    (object_rule_name, extra_rules, primitive_deps)."""
    rule_name = f"obj_{spec.name}"
    parts: list[str] = ['"{"', "ws"]
    extras: list[str] = []
    deps: set[str] = set()
    for i, f in enumerate(spec.nested_fields):
        if i > 0:
            parts += ['","', "ws"]
        frhs, fextras, fdeps = _gbnf_slot_value_rule(f)
        extras += fextras
        deps |= fdeps
        # Wrap the field value in parens so alternations (e.g. "null" | bbox4)
        # compose correctly inside the object.
        parts += [f'"\\"{f.name}\\":"', "ws", f"( {frhs} )", "ws"]
    parts.append('"}"')
    extras.append(f"{rule_name} ::= " + " ".join(parts))
    return rule_name, extras, deps


def _gbnf_slot_value_rule(spec: SlotSpec) -> tuple[str, list[str], set[str]]:
    """Return (right-hand-side, extra_rules, primitive_deps) for this slot's value.

    The RHS is what appears after `slot_<name> ::=`. It is either a rule name or
    a small alternation (e.g. `"null" | number`). Extra rules are helper rules
    this slot needs; primitive_deps names numeric primitives to emit globally.
    """
    extras: list[str] = []
    deps: set[str] = set()

    if spec.cardinality == "list":
        if spec.nested_model is SubjectValue:
            # SubjectValue is the caption's one hand-written nested type; keep the
            # exact v2 rules so the caption grammar is unchanged.
            extras.append(
                'subject ::= "{" ws "\\"name\\":" ws string ws "," ws '
                '"\\"attributes\\":" ws str_array ws "}"'
            )
            extras.append(
                'subject_list ::= "[" ws "]" | '
                '"[" ws subject (ws "," ws subject)* ws "]"'
            )
            return "subject_list", extras, deps
        if spec.nested_fields:
            obj_name, obj_extras, obj_deps = _gbnf_object_rule(spec)
            extras += obj_extras
            deps |= obj_deps
            list_name = f"{spec.name}_list"
            extras.append(
                f'{list_name} ::= "[" ws "]" | '
                f'"[" ws {obj_name} (ws "," ws {obj_name})* ws "]"'
            )
            return list_name, extras, deps
        if spec.value_kind in ("number", "integer"):
            deps.add("num_array")
            return "num_array", extras, deps
        # Primitive open-vocab list — array of strings
        return "str_array", extras, deps

    # Single value
    if spec.nested_fields:
        # A single nested object (e.g. subject_fixation.primary_subject). Without
        # this, the grammar would fall through to the string rule and force the
        # object to serialize as a string — breaking constrained decoding.
        obj_name, obj_extras, obj_deps = _gbnf_object_rule(spec)
        extras += obj_extras
        deps |= obj_deps
        if spec.optional:
            return f'"null" | {obj_name}', extras, deps
        return obj_name, extras, deps

    if spec.vocabulary == "closed":
        alts = _gbnf_string_alternation(spec.closed_values)
        rule_name = f"closed_{spec.name}"
        extras.append(f"{rule_name} ::= {alts}")
        return rule_name, extras, deps

    if spec.value_kind == "bbox":
        deps.add("bbox4")
        base = "bbox4"
    elif spec.value_kind == "point":
        deps.add("point2")
        base = "point2"
    elif spec.value_kind == "number":
        deps.add("number")
        base = "number"
    elif spec.value_kind == "integer":
        deps.add("integer")
        base = "integer"
    else:
        base = "string"

    # Optional single → allow null literal.
    if spec.optional:
        return f'"null" | {base}', extras, deps
    return base, extras, deps


def build_gbnf_from_registry(registry: Mapping[str, SlotSpec], root_name: str = "root") -> str:
    """Generate a GBNF grammar that produces JSON conforming to `registry`."""
    slot_rules: list[str] = []
    helper_rules: list[str] = []
    helper_seen: set[str] = set()
    deps: set[str] = set()

    for name, spec in registry.items():
        rhs, extras, sdeps = _gbnf_slot_value_rule(spec)
        slot_rules.append(f"slot_{name} ::= {rhs}")
        deps |= sdeps
        for r in extras:
            head = r.split("::=", 1)[0].strip()
            if head not in helper_seen:
                helper_rules.append(r)
                helper_seen.add(head)

    # Root rule: opening brace, slot1, comma, slot2, ..., closing brace.
    parts: list[str] = ['"{"', "ws"]
    for i, name in enumerate(registry.keys()):
        if i > 0:
            parts += ['","', "ws"]
        parts += [f'"\\"{name}\\":"', "ws", f"slot_{name}", "ws"]
    parts.append('"}"')
    root_rule = f"{root_name} ::= " + " ".join(parts)

    # Base primitives — always present (str_array is needed by open-vocab lists
    # and SubjectValue.attributes).
    common = [
        'str_array ::= "[" ws "]" | "[" ws string (ws "," ws string)* ws "]"',
        'string ::= "\\"" char* "\\""',
        'char ::= [^"\\\\] | "\\\\" ["\\\\/bfnrt]',
        'ws ::= [ \\t\\n]*',
    ]

    # Numeric primitives — only those actually referenced (keeps caption grammar
    # identical to v2 and vision grammars minimal).
    resolved = _resolve_primitive_deps(deps)
    numeric = [_PRIMITIVE_DEFS[k] for k in _PRIMITIVE_ORDER if k in resolved]

    return "\n".join([root_rule] + slot_rules + helper_rules + common + numeric)


def build_gbnf_grammar() -> str:
    """Generate the caption GBNF grammar (back-compat wrapper)."""
    return build_gbnf_from_registry(SLOT_REGISTRY)


CAPTION_GRAMMAR_GBNF: str = build_gbnf_grammar()


# ──────────────────────────────────────────────────────────────────────────────
# Smoke test — `python -m qwen_test_runner.schema` validates the three reps.
# ──────────────────────────────────────────────────────────────────────────────

def _smoke_test() -> None:
    example = Caption(
        subjects=[Subject(name="dog", attributes=["golden"])],
        actions=["catching"],
        setting="outdoor",
        style="photorealistic",
        mood="energetic",
    )

    as_dict = example.model_dump()
    rebuilt = Caption.model_validate(as_dict)
    assert rebuilt == example, "pydantic round-trip failed"

    as_json = example.model_dump_json()
    reparsed = Caption.model_validate_json(as_json)
    assert reparsed == example, "JSON round-trip failed"

    schema = CAPTION_JSON_SCHEMA
    assert "properties" in schema
    assert set(schema["properties"].keys()) == set(SLOT_REGISTRY.keys())

    g = CAPTION_GRAMMAR_GBNF
    for slot in SLOT_REGISTRY:
        assert f'\\"{slot}\\"' in g, f"GBNF missing slot {slot}"

    print("schema.py smoke test: OK")
    print(f"  slots: {list(SLOT_REGISTRY.keys())}")
    print(f"  example JSON length: {len(as_json)}")
    print(f"  JSON Schema fields: {list(schema['properties'].keys())}")
    print(f"  GBNF length: {len(g)} chars")


if __name__ == "__main__":
    _smoke_test()