diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md index da07df64f..a2b6283c8 100644 --- a/docs/user-guide/configuration.md +++ b/docs/user-guide/configuration.md @@ -160,7 +160,7 @@ for the full API reference. | `generation.invalid_fraction_threshold` | `0.8` | Invalid record fraction that triggers the patience counter | Leave at default | | `generation.use_structured_generation` | `false` | Enable structured output to constrain record format (typically at the cost of reducing the quality of generated records and increasing generation time; use when the pipeline struggles to produce valid records) | Leave off unless the pipeline cannot produce valid records | | `generation.structured_generation_backend` | `"auto"` | vLLM guided-decoding backend | Leave at `"auto"` | -| `generation.structured_generation_schema_method` | `"regex"` | Schema method (`"regex"` or `"json_schema"`) | Leave at `"regex"` | +| `generation.structured_generation_schema_method` | `"auto"` | Schema method (`"auto"`, `"structural_tag"`, `"json_schema"`, or `"regex"`) | Leave at `"auto"`; it picks `"structural_tag"` on xgrammar-capable backends and `"regex"` otherwise | | `generation.structured_generation_use_single_sequence` | `false` | Match exactly one sequence when `max_sequences_per_example` is 1 | Leave at default | | `generation.enforce_timeseries_fidelity` | `false` | Enforce time series order, intervals, and timestamps | Enable for time series data | | `generation.attention_backend` | `"auto"` | vLLM attention backend | Leave at `"auto"` | diff --git a/docs/user-guide/running.md b/docs/user-guide/running.md index bcf92d748..ebc648ecf 100644 --- a/docs/user-guide/running.md +++ b/docs/user-guide/running.md @@ -883,9 +883,11 @@ records. Use it when the pipeline struggles to produce valid records. ```yaml generation: use_structured_generation: true - structured_generation_schema_method: "regex" + structured_generation_schema_method: "auto" ``` +- `"auto"`: picks `"structural_tag"` when `structured_generation_backend` is `"auto"` or `"xgrammar"`, otherwise `"regex"`. +- `"structural_tag"`: uses XGrammar Structural Tag to compose schema-constrained JSONL output. - `"regex"`: constructs a custom regex from the dataset schema. More comprehensive but slower. - `"json_schema"`: passes a JSON Schema to the backend. Faster, but may miss edge cases. diff --git a/pyproject.toml b/pyproject.toml index 4c4ffc8b8..880f54e6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -137,6 +137,7 @@ cpu = [ "triton>=2.0.0; sys_platform=='linux'", "trl>=0.23.0", "vllm==0.20.0; sys_platform=='linux'", + "xgrammar>=0.2.0; sys_platform=='linux'", ] cu129 = [ @@ -161,6 +162,7 @@ cu129 = [ "triton>=2.0.0; sys_platform == 'linux'", "trl>=0.23.0", "vllm==0.20.0+cu129; sys_platform == 'linux'", + "xgrammar>=0.2.0; sys_platform == 'linux'", ] # at some point, do per-subpackage dependencies diff --git a/src/nemo_safe_synthesizer/config/generate.py b/src/nemo_safe_synthesizer/config/generate.py index 28c469c5a..8eca34108 100644 --- a/src/nemo_safe_synthesizer/config/generate.py +++ b/src/nemo_safe_synthesizer/config/generate.py @@ -3,11 +3,12 @@ from __future__ import annotations -from typing import Annotated, Literal +from typing import Annotated, Literal, Self from pydantic import ( BaseModel, Field, + model_validator, ) from ..configurator.parameters import ( @@ -17,8 +18,57 @@ ValueValidator, range_validator, ) +from ..errors import ParameterError + +StructuredGenerationSchemaMethod = Literal["auto", "regex", "json_schema", "structural_tag"] +ResolvedStructuredGenerationSchemaMethod = Literal["regex", "json_schema", "structural_tag"] +StructuredGenerationBackend = Literal["auto", "xgrammar", "guidance", "outlines", "lm-format-enforcer"] + +STRUCTURAL_TAG_COMPATIBLE_BACKENDS = frozenset({"auto", "xgrammar"}) + +__all__ = [ + "GenerateParameters", + "ResolvedStructuredGenerationSchemaMethod", + "StructuredGenerationBackend", + "StructuredGenerationSchemaMethod", + "STRUCTURAL_TAG_COMPATIBLE_BACKENDS", + "ValidationParameters", + "resolve_structured_generation_schema_method", + "structural_tag_backend_error_message", +] + + +def resolve_structured_generation_schema_method( + schema_method: StructuredGenerationSchemaMethod, + backend: StructuredGenerationBackend | str, +) -> ResolvedStructuredGenerationSchemaMethod: + """Resolve ``auto`` schema method from the configured structured-output backend. + + ``auto`` picks ``structural_tag`` on xgrammar-capable backends and ``regex`` + elsewhere, preserving legacy behavior for outlines/guidance configs that omit + an explicit schema method. + """ + if schema_method != "auto": + return schema_method + if backend in STRUCTURAL_TAG_COMPATIBLE_BACKENDS: + return "structural_tag" + return "regex" + -__all__ = ["GenerateParameters", "ValidationParameters"] +def structural_tag_backend_error_message(backend: str) -> str | None: + """Return an error message when *backend* cannot serve ``structural_tag``. + + vLLM only supports XGrammar Structural Tag constraints when the guided + decoding backend is ``xgrammar`` or ``auto`` (which selects xgrammar for + this schema method). + """ + if backend in STRUCTURAL_TAG_COMPATIBLE_BACKENDS: + return None + return ( + "Invalid structured generation configuration: " + "`structured_generation_schema_method='structural_tag'` requires " + f"`structured_generation_backend` to be 'xgrammar' or 'auto', got {backend!r}." + ) class ValidationParameters(Parameters, BaseModel): @@ -147,16 +197,18 @@ class GenerateParameters(Parameters, BaseModel): ] = "auto" structured_generation_schema_method: Annotated[ - Literal["regex", "json_schema"], + StructuredGenerationSchemaMethod, Field( title="structured_generation_schema_method", description=( "The method used to generate the schema from your dataset and pass it to the generation backend. " + "'auto' picks 'structural_tag' on xgrammar-capable backends and 'regex' otherwise. " "'regex' uses a custom regex construction method that tends to be more comprehensive " - "than 'json_schema' at the cost of speed." + "than 'json_schema' at the cost of speed. 'structural_tag' uses XGrammar Structural Tag " + "to compose schema-constrained JSONL output." ), ), - ] = "regex" + ] = "auto" structured_generation_use_single_sequence: Annotated[ bool, @@ -191,3 +243,13 @@ class GenerateParameters(Parameters, BaseModel): ), ), ] = "auto" + + @model_validator(mode="after") + def _validate_structural_tag_backend(self) -> Self: + if not self.use_structured_generation: + return self + if self.structured_generation_schema_method != "structural_tag": + return self + if message := structural_tag_backend_error_message(self.structured_generation_backend): + raise ParameterError(message) + return self diff --git a/src/nemo_safe_synthesizer/generation/regex_manager.py b/src/nemo_safe_synthesizer/generation/regex_manager.py index 6728ab48a..990f34437 100644 --- a/src/nemo_safe_synthesizer/generation/regex_manager.py +++ b/src/nemo_safe_synthesizer/generation/regex_manager.py @@ -366,3 +366,74 @@ def build_json_based_regex( regex = rf"({sequence_regex}\n)+" return regex + + +def _const_string_format(value: str) -> dict[str, str]: + """Return a Structural Tag constant-string format.""" + return {"type": "const_string", "value": value} + + +def _sequence_format(elements: list[dict[str, Any]]) -> dict[str, Any]: + """Return a Structural Tag sequence format.""" + return {"type": "sequence", "elements": elements} + + +def _plus_format(content: dict[str, Any]) -> dict[str, Any]: + """Return a Structural Tag one-or-more repetition format.""" + return {"type": "plus", "content": content} + + +def build_json_structural_tag( + schema: dict[str, Any], + config: SafeSynthesizerParameters, + bos_token: str, + eos_token: str, +) -> str: + """Build an XGrammar Structural Tag for schema-constrained JSONL records. + + The raw vLLM ``json`` constraint describes a single JSON value. Structural + Tag lets NSS describe the larger generation shape directly: one or more + schema-constrained JSON records separated by newlines, optionally wrapped + in BOS/EOS group delimiters. + + Args: + schema: JSON schema dictionary describing one record. + config: Pipeline configuration (used for grouping and + structured-generation settings). + bos_token: Beginning-of-sequence token (used when grouping). + eos_token: End-of-sequence token (used when grouping). + + Returns: + JSON string suitable for ``StructuredOutputsParams(structural_tag=...)``. + """ + record_format: dict[str, Any] = { + "type": "json_schema", + "json_schema": schema, + } + record_line_format = _sequence_format([record_format, _const_string_format("\n")]) + + if config.data.group_training_examples_by is not None: + sequence_format = _sequence_format( + [ + _const_string_format(bos_token), + _plus_format(record_line_format), + _const_string_format(eos_token), + ] + ) + else: + sequence_format = record_format + + if config.generation.structured_generation_use_single_sequence and config.data.max_sequences_per_example == 1: + output_format = sequence_format + elif config.data.group_training_examples_by is not None: + output_format = _plus_format(_sequence_format([sequence_format, _const_string_format("\n")])) + else: + output_format = _plus_format(record_line_format) + + return json.dumps( + { + "type": "structural_tag", + "format": output_format, + }, + ensure_ascii=True, + ) diff --git a/src/nemo_safe_synthesizer/generation/vllm_backend.py b/src/nemo_safe_synthesizer/generation/vllm_backend.py index 8f80aef1b..b1ee029d5 100644 --- a/src/nemo_safe_synthesizer/generation/vllm_backend.py +++ b/src/nemo_safe_synthesizer/generation/vllm_backend.py @@ -25,12 +25,16 @@ from .. import utils from ..cli.artifact_structure import Workdir from ..config import SafeSynthesizerParameters +from ..config.generate import ( + resolve_structured_generation_schema_method, + structural_tag_backend_error_message, +) from ..defaults import DEFAULT_SAMPLING_PARAMETERS, FIXED_RUNTIME_GENERATE_ARGS -from ..errors import InternalError +from ..errors import InternalError, ParameterError from ..generation.backend import GeneratorBackend from ..generation.batch import Batch from ..generation.processors import Processor, TabularDataProcessor, create_processor -from ..generation.regex_manager import build_json_based_regex +from ..generation.regex_manager import build_json_based_regex, build_json_structural_tag from ..generation.results import GenerateJobResults, GenerationBatches, GenerationStatus from ..llm.metadata import ModelMetadata from ..llm.utils import ModelRef, cleanup_memory, get_max_vram @@ -324,8 +328,12 @@ def _build_structured_output_params(self) -> StructuredOutputsParams | None: return None params: dict[str, Any] = {} + schema_method = resolve_structured_generation_schema_method( + self.config.generation.structured_generation_schema_method, + self.config.generation.structured_generation_backend, + ) - if self.config.generation.structured_generation_schema_method == "regex": + if schema_method == "regex": logger.info("Structured generation is enabled, using a regex to enforce the schema") pc = self.model_metadata.prompt_config regex = build_json_based_regex( @@ -335,8 +343,20 @@ def _build_structured_output_params(self) -> StructuredOutputsParams | None: eos_token=pc.eos_token, ) params["regex"] = regex - elif self.config.generation.structured_generation_schema_method == "json_schema": + elif schema_method == "json_schema": params["json"] = self.schema + elif schema_method == "structural_tag": + backend = self.config.generation.structured_generation_backend + if message := structural_tag_backend_error_message(backend): + raise ParameterError(message) + logger.info("Structured generation is enabled, using an XGrammar Structural Tag") + pc = self.model_metadata.prompt_config + params["structural_tag"] = build_json_structural_tag( + self.schema, + self.config, + bos_token=pc.bos_token, + eos_token=pc.eos_token, + ) return StructuredOutputsParams(**params) diff --git a/tests/benchmarks/test_generation_structured_methods.py b/tests/benchmarks/test_generation_structured_methods.py new file mode 100644 index 000000000..302aeb00a --- /dev/null +++ b/tests/benchmarks/test_generation_structured_methods.py @@ -0,0 +1,294 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Benchmark vLLM generation across structured-output methods. + +This benchmark intentionally invokes the CLI in subprocesses. It is meant for +comparing real generation runs against an already-trained adapter, not for unit +testing the private backend helpers. + +Adapter setup: + +The benchmark expects `NSS_GENERATION_BENCHMARK_RUN_PATH` to point at a trained +run directory containing adapter layers. It does not train adapters before +benchmarking. To create the default local SmolLM3 DP adapter/run path, train on +one of the benchmark input datasets: + + uv run safe-synthesizer run train \ + --data-source cleaned/amazon_reviews_25k.csv \ + --config script/slurm/configs/smollm3-dp.yaml \ + --run-path local_runs/smollm3-dp_amazon_reviews_25k_1_5609622_1 + +Benchmark run: + + uv run --frozen pytest tests/benchmarks/test_generation_structured_methods.py -m benchmark -n0 -s + +The default `cleaned/amazon_reviews_25k.csv` data source above is one of the +benchmark input datasets. Use a different input CSV/config/run-path trio by +setting the environment variables below. + +By default, this compares unstructured generation, XGrammar JSON schema, +XGrammar Structural Tag, and JSON schema through the outlines, guidance, and +lm-format-enforcer backends. Override `NSS_GENERATION_BENCHMARK_METHODS` with a +comma-separated subset when you only need specific cases. + +Override inputs with: + + NSS_GENERATION_BENCHMARK_DATA_SOURCE=cleaned/amazon_reviews_25k.csv + NSS_GENERATION_BENCHMARK_CONFIG=script/slurm/configs/smollm3-dp.yaml + NSS_GENERATION_BENCHMARK_RUN_PATH=local_runs/smollm3-dp_amazon_reviews_25k_1_5609622_1 +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import time +from dataclasses import asdict, dataclass +from importlib import metadata +from pathlib import Path + +import pytest + +# Keep these defaults aligned with the adapter setup command in the module +# docstring so the benchmark can run without additional environment overrides. +DEFAULT_DATA_SOURCE = "cleaned/amazon_reviews_25k.csv" +DEFAULT_CONFIG = "script/slurm/configs/smollm3-dp.yaml" +DEFAULT_RUN_PATH = "local_runs/smollm3-dp_amazon_reviews_25k_1_5609622_1" +DEFAULT_METHODS = ( + "unstructured", + "regex", + "json_schema", + "structural_tag", + "outlines", + "outlines_regex", + "guidance", + "lm_format_enforcer", +) +DEFAULT_TIMEOUT_SECONDS = 1800 + + +def _timeout_seconds() -> float: + return float(os.environ.get("NSS_GENERATION_BENCHMARK_TIMEOUT_SECONDS", str(DEFAULT_TIMEOUT_SECONDS))) + + +@dataclass(frozen=True) +class GenerationMethod: + name: str + use_structured_generation: bool + schema_method: str | None = None + backend: str = "xgrammar" + + +@dataclass(frozen=True) +class GenerationBenchmarkResult: + method: str + backend: str | None + schema_method: str | None + command: list[str] + duration_seconds: float + output_file: str + output_records: int + log_file: str + + +def _cuda_available() -> bool: + try: + import torch + + return bool(torch.cuda.is_available()) + except ImportError: + return False + + +def _package_version(package: str) -> str: + try: + return metadata.version(package) + except metadata.PackageNotFoundError: + return "not installed" + + +def _configured_path(env_name: str, default: str, root: Path) -> Path: + path = Path(os.environ.get(env_name, default)) + return path if path.is_absolute() else root / path + + +def _selected_methods() -> list[GenerationMethod]: + methods = { + "unstructured": GenerationMethod("unstructured", use_structured_generation=False), + "regex": GenerationMethod("regex", use_structured_generation=True, schema_method="regex"), + "json_schema": GenerationMethod("json_schema", use_structured_generation=True, schema_method="json_schema"), + "structural_tag": GenerationMethod( + "structural_tag", + use_structured_generation=True, + schema_method="structural_tag", + ), + "outlines_regex": GenerationMethod( + "outlines_regex", + use_structured_generation=True, + schema_method="regex", + backend="outlines", + ), + "outlines": GenerationMethod( + "outlines", + use_structured_generation=True, + schema_method="json_schema", + backend="outlines", + ), + "guidance": GenerationMethod( + "guidance", + use_structured_generation=True, + schema_method="json_schema", + backend="guidance", + ), + "lm_format_enforcer": GenerationMethod( + "lm_format_enforcer", + use_structured_generation=True, + schema_method="json_schema", + backend="lm-format-enforcer", + ), + } + requested = os.environ.get("NSS_GENERATION_BENCHMARK_METHODS") + names = ( + DEFAULT_METHODS if requested is None else tuple(name.strip() for name in requested.split(",") if name.strip()) + ) + unknown = sorted(set(names).difference(methods)) + if unknown: + raise ValueError(f"Unknown generation benchmark methods: {', '.join(unknown)}") + return [methods[name] for name in names] + + +def _count_csv_records(path: Path) -> int: + if not path.exists(): + return 0 + with path.open(encoding="utf-8") as handle: + line_count = sum(1 for _ in handle) + return max(line_count - 1, 0) + + +def _tail(path: Path, max_lines: int = 80) -> str: + if not path.exists(): + return "" + lines = path.read_text(encoding="utf-8", errors="replace").splitlines() + return "\n".join(lines[-max_lines:]) + + +def _build_command(method: GenerationMethod, output_file: Path, root: Path) -> list[str]: + data_source = _configured_path("NSS_GENERATION_BENCHMARK_DATA_SOURCE", DEFAULT_DATA_SOURCE, root) + config = _configured_path("NSS_GENERATION_BENCHMARK_CONFIG", DEFAULT_CONFIG, root) + run_path = _configured_path("NSS_GENERATION_BENCHMARK_RUN_PATH", DEFAULT_RUN_PATH, root) + num_records = os.environ.get("NSS_GENERATION_BENCHMARK_NUM_RECORDS", "100") + + command = [ + sys.executable, + "-m", + "nemo_safe_synthesizer.cli.cli", + "run", + "generate", + "--data-source", + str(data_source), + "--config", + str(config), + "--run-path", + str(run_path), + "--output-file", + str(output_file), + "--generation__num_records", + num_records, + "--generation__use_structured_generation", + str(method.use_structured_generation).lower(), + ] + if method.use_structured_generation: + command.extend( + [ + "--generation__structured_generation_backend", + method.backend, + "--generation__structured_generation_schema_method", + method.schema_method or "regex", + ] + ) + return command + + +@pytest.mark.benchmark +@pytest.mark.slow +@pytest.mark.requires_gpu +@pytest.mark.vllm +@pytest.mark.skipif(not _cuda_available(), reason="CUDA not available") +@pytest.mark.timeout(_timeout_seconds() + 120) +@pytest.mark.parametrize("method", _selected_methods(), ids=lambda method: method.name) +def test_generation_structured_method_benchmark( + method: GenerationMethod, + tmp_path: Path, + pytestconfig: pytest.Config, +) -> None: + """Benchmark one generation structured-output method.""" + root = Path(pytestconfig.rootpath) + for env_name, default in ( + ("NSS_GENERATION_BENCHMARK_DATA_SOURCE", DEFAULT_DATA_SOURCE), + ("NSS_GENERATION_BENCHMARK_CONFIG", DEFAULT_CONFIG), + ("NSS_GENERATION_BENCHMARK_RUN_PATH", DEFAULT_RUN_PATH), + ): + path = _configured_path(env_name, default, root) + if not path.exists(): + pytest.skip(f"{env_name} path does not exist: {path}") + + timeout = _timeout_seconds() + output_file = tmp_path / f"{method.name}.csv" + log_file = tmp_path / f"{method.name}.log" + command = _build_command(method, output_file, root) + + print(f"\nRunning {method.name} generation benchmark") + print(f"vLLM version: {_package_version('vllm')}") + print(f"XGrammar version: {_package_version('xgrammar')}") + print(f"Command: {' '.join(command)}") + print(f"Log file: {log_file}") + + start = time.perf_counter() + try: + with log_file.open("w", encoding="utf-8") as log: + completed = subprocess.run( + command, + cwd=root, + stdout=log, + stderr=subprocess.STDOUT, + text=True, + timeout=timeout, + check=False, + ) + except subprocess.TimeoutExpired as exc: + pytest.fail( + f"{method.name} generation benchmark timed out after {exc.timeout:.0f}s\n" + f"Command: {' '.join(command)}\n\n{_tail(log_file)}" + ) + duration = time.perf_counter() - start + if completed.returncode != 0: + pytest.fail( + f"{method.name} generation benchmark failed with exit code {completed.returncode}\n" + f"Command: {' '.join(command)}\n\n{_tail(log_file)}" + ) + + result = GenerationBenchmarkResult( + method=method.name, + backend=method.backend if method.use_structured_generation else None, + schema_method=method.schema_method, + command=command, + duration_seconds=duration, + output_file=str(output_file), + output_records=_count_csv_records(output_file), + log_file=str(log_file), + ) + records_per_second = result.output_records / result.duration_seconds if result.duration_seconds > 0 else 0.0 + print( + f"\nGeneration benchmark result: {result.method}: " + f"{result.duration_seconds:.2f}s, {result.output_records} records, " + f"{records_per_second:.3f} records/s" + ) + + summary_file = tmp_path / f"generation_structured_method_{method.name}_benchmark.json" + summary_file.write_text(json.dumps(asdict(result), indent=2) + "\n", encoding="utf-8") + print(f"Benchmark summary JSON: {summary_file}") + + assert result.output_records > 0 diff --git a/tests/config/test_generate.py b/tests/config/test_generate.py new file mode 100644 index 000000000..3749e4d35 --- /dev/null +++ b/tests/config/test_generate.py @@ -0,0 +1,72 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from typing import Any, Literal, cast + +import pytest +from pydantic import ValidationError + +from nemo_safe_synthesizer.config.generate import ( + GenerateParameters, + resolve_structured_generation_schema_method, +) +from nemo_safe_synthesizer.config.parameters import SafeSynthesizerParameters + + +@pytest.mark.unit +class TestResolveStructuredGenerationSchemaMethod: + @pytest.mark.parametrize( + ("backend", "expected"), + [ + ("auto", "structural_tag"), + ("xgrammar", "structural_tag"), + ("outlines", "regex"), + ("guidance", "regex"), + ("lm-format-enforcer", "regex"), + ], + ) + def test_auto_resolves_from_backend(self, backend: str, expected: str) -> None: + assert resolve_structured_generation_schema_method("auto", backend) == expected + + @pytest.mark.parametrize("method", ["regex", "json_schema", "structural_tag"]) + def test_explicit_methods_pass_through(self, method: str) -> None: + schema_method = cast(Literal["regex", "json_schema", "structural_tag"], method) + assert resolve_structured_generation_schema_method(schema_method, "outlines") == method + + +@pytest.mark.unit +class TestGenerateParametersStructuralTagValidation: + @staticmethod + def _generation_kwargs(*, schema_method: str = "structural_tag", backend: str = "xgrammar") -> dict[str, Any]: + return { + "use_structured_generation": True, + "structured_generation_schema_method": schema_method, + "structured_generation_backend": backend, + } + + @pytest.mark.parametrize("backend", ["xgrammar", "auto"]) + def test_compatible_backends_validate(self, backend: str) -> None: + GenerateParameters(**self._generation_kwargs(backend=backend)) + + def test_incompatible_backend_raises_validation_error(self) -> None: + with pytest.raises(ValidationError, match="requires `structured_generation_backend`"): + GenerateParameters(**self._generation_kwargs(backend="outlines")) + + @pytest.mark.parametrize("backend", ["outlines", "guidance", "lm-format-enforcer"]) + def test_auto_with_incompatible_backend_validates(self, backend: str) -> None: + GenerateParameters(**self._generation_kwargs(schema_method="auto", backend=backend)) + + def test_default_schema_method_is_auto(self) -> None: + params = GenerateParameters() + assert params.structured_generation_schema_method == "auto" + + def test_skipped_when_structured_generation_disabled(self) -> None: + GenerateParameters( + use_structured_generation=False, + structured_generation_schema_method="structural_tag", + structured_generation_backend="outlines", + ) + + def test_from_params_rejects_incompatible_backend(self) -> None: + with pytest.raises(ValidationError, match="outlines"): + SafeSynthesizerParameters.from_params(**self._generation_kwargs(backend="outlines")) diff --git a/tests/e2e/test_safe_synthesizer.py b/tests/e2e/test_safe_synthesizer.py index 6846a4b1b..d71fa2e9c 100644 --- a/tests/e2e/test_safe_synthesizer.py +++ b/tests/e2e/test_safe_synthesizer.py @@ -53,7 +53,7 @@ def test_train_and_generate_dp(fixture_financial_transactions_dataset, fixture_s epsilon=100.0, num_records=100, use_structured_generation=True, - structured_generation_backend="outlines", + structured_generation_backend="xgrammar", ) logger.info(f"Running DP test with config: {config}") diff --git a/tests/generation/structural_tag_helpers.py b/tests/generation/structural_tag_helpers.py new file mode 100644 index 000000000..188b66521 --- /dev/null +++ b/tests/generation/structural_tag_helpers.py @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Test helpers for validating XGrammar Structural Tag constraints.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from transformers import PreTrainedTokenizerBase + +__all__ = ["structural_tag_accepts_text"] + + +def structural_tag_accepts_text( + text: str, + structural_tag_json: str, + tokenizer: PreTrainedTokenizerBase, +) -> bool: + """Return whether *text* is fully accepted by an XGrammar Structural Tag. + + Mirrors regex round-trip tests that use ``re.fullmatch`` against + ``build_json_based_regex`` output. Requires xgrammar and a Hugging Face + tokenizer compatible with the generation backend. + """ + xgr = pytest.importorskip("xgrammar", reason="xgrammar is required for structural tag acceptance tests") + + compiler = xgr.GrammarCompiler(xgr.TokenizerInfo.from_huggingface(tokenizer)) + compiled = compiler.compile_structural_tag(structural_tag_json) + matcher = xgr.GrammarMatcher(compiled) + matcher.reset() + return bool(matcher.accept_string(text) and matcher.is_completed()) diff --git a/tests/generation/test_regex_manager.py b/tests/generation/test_regex_manager.py index 5fe6076e5..27eb1bd06 100644 --- a/tests/generation/test_regex_manager.py +++ b/tests/generation/test_regex_manager.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 +import json import re from io import StringIO @@ -11,8 +12,11 @@ from nemo_safe_synthesizer.data_processing.dataset import make_json_schema from nemo_safe_synthesizer.generation.regex_manager import ( build_json_based_regex, + build_json_structural_tag, ) +from .structural_tag_helpers import structural_tag_accepts_text + BOS_TOKEN = "" EOS_TOKEN = "" @@ -68,6 +72,133 @@ def test_build_json_based_regex(fixture_valid_iris_dataset_jsonl_and_schema, fix ) +def test_build_json_structural_tag_uses_schema_constrained_jsonl(fixture_safe_synthesizer_config): + """Structural Tag composes schema-constrained JSON records with JSONL newlines.""" + schema = { + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + } + + structural_tag = json.loads( + build_json_structural_tag( + schema, + config=fixture_safe_synthesizer_config, + bos_token=BOS_TOKEN, + eos_token=EOS_TOKEN, + ) + ) + + assert structural_tag == { + "type": "structural_tag", + "format": { + "type": "plus", + "content": { + "type": "sequence", + "elements": [ + {"type": "json_schema", "json_schema": schema}, + {"type": "const_string", "value": "\n"}, + ], + }, + }, + } + + +def test_build_json_structural_tag_with_single_sequence(fixture_safe_synthesizer_config): + """Single-sequence Structural Tag emits exactly one schema-constrained object.""" + fixture_safe_synthesizer_config.data.max_sequences_per_example = 1 + fixture_safe_synthesizer_config.generation.structured_generation_use_single_sequence = True + schema = {"type": "object", "properties": {"name": {"type": "string"}}} + + structural_tag = json.loads( + build_json_structural_tag( + schema, + config=fixture_safe_synthesizer_config, + bos_token=BOS_TOKEN, + eos_token=EOS_TOKEN, + ) + ) + + assert structural_tag["format"] == {"type": "json_schema", "json_schema": schema} + + +def test_build_json_structural_tag_with_groupby(fixture_safe_synthesizer_config): + """Grouped Structural Tag keeps BOS/EOS framing around repeated JSONL records.""" + fixture_safe_synthesizer_config.data.group_training_examples_by = "id" + schema = {"type": "object", "properties": {"name": {"type": "string"}}} + + structural_tag = json.loads( + build_json_structural_tag( + schema, + config=fixture_safe_synthesizer_config, + bos_token=BOS_TOKEN, + eos_token=EOS_TOKEN, + ) + ) + + assert structural_tag["format"] == { + "type": "plus", + "content": { + "type": "sequence", + "elements": [ + { + "type": "sequence", + "elements": [ + {"type": "const_string", "value": BOS_TOKEN}, + { + "type": "plus", + "content": { + "type": "sequence", + "elements": [ + {"type": "json_schema", "json_schema": schema}, + {"type": "const_string", "value": "\n"}, + ], + }, + }, + {"type": "const_string", "value": EOS_TOKEN}, + ], + }, + {"type": "const_string", "value": "\n"}, + ], + }, + } + + +def test_build_json_structural_tag_with_groupby_single_sequence(fixture_safe_synthesizer_config): + """Grouped single-sequence Structural Tag emits exactly one BOS/EOS-wrapped sequence.""" + fixture_safe_synthesizer_config.data.group_training_examples_by = "id" + fixture_safe_synthesizer_config.data.max_sequences_per_example = 1 + fixture_safe_synthesizer_config.generation.structured_generation_use_single_sequence = True + schema = {"type": "object", "properties": {"name": {"type": "string"}}} + + structural_tag = json.loads( + build_json_structural_tag( + schema, + config=fixture_safe_synthesizer_config, + bos_token=BOS_TOKEN, + eos_token=EOS_TOKEN, + ) + ) + + assert structural_tag["format"] == { + "type": "sequence", + "elements": [ + {"type": "const_string", "value": BOS_TOKEN}, + { + "type": "plus", + "content": { + "type": "sequence", + "elements": [ + {"type": "json_schema", "json_schema": schema}, + {"type": "const_string", "value": "\n"}, + ], + }, + }, + {"type": "const_string", "value": EOS_TOKEN}, + ], + } + + # Purpose: Ensures keys with special chars (e.g., '.') are escaped and string length bounds enforced. # Data: Object with required key 'full.name', minLength=3, maxLength=10. # Asserts: Equality against expected regex and behavioral matches via re.fullmatch for valid/invalid cases. @@ -426,6 +557,73 @@ def test_property_regex(fixture_safe_synthesizer_config): assert re.fullmatch(regex, '{"b":2,"a":1}\n') is None +# Purpose: XGrammar Structural Tag round-trip via GrammarMatcher for each output shape. +@pytest.mark.parametrize( + ("config_updates", "text"), + [ + ({}, '{"name":"alice"}\n{"name":"bob"}\n'), + ( + {"max_sequences_per_example": 1, "structured_generation_use_single_sequence": True}, + '{"name":"alice"}', + ), + ( + {"group_training_examples_by": "id"}, + '{"name":"alice"}\n\n{"name":"bob"}\n\n', + ), + ( + { + "group_training_examples_by": "id", + "max_sequences_per_example": 1, + "structured_generation_use_single_sequence": True, + }, + '{"name":"alice"}\n', + ), + ], + ids=["jsonl", "single_sequence", "grouped_jsonl", "grouped_single_sequence"], +) +def test_structural_tag_accepts_training_jsonl_shapes( + config_updates, + text, + fixture_safe_synthesizer_config, + fixture_tokenizer, +): + for key, value in config_updates.items(): + if key == "group_training_examples_by": + fixture_safe_synthesizer_config.data.group_training_examples_by = value + elif key == "max_sequences_per_example": + fixture_safe_synthesizer_config.data.max_sequences_per_example = value + elif key == "structured_generation_use_single_sequence": + fixture_safe_synthesizer_config.generation.structured_generation_use_single_sequence = value + + schema = { + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + } + structural_tag = build_json_structural_tag( + schema, + config=fixture_safe_synthesizer_config, + bos_token=BOS_TOKEN, + eos_token=EOS_TOKEN, + ) + assert structural_tag_accepts_text(text, structural_tag, fixture_tokenizer) is True + + +def test_round_trip_structural_tag_rejects_invalid_jsonl(fixture_safe_synthesizer_config, fixture_tokenizer): + schema = { + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + } + structural_tag = build_json_structural_tag( + schema, + config=fixture_safe_synthesizer_config, + bos_token=BOS_TOKEN, + eos_token=EOS_TOKEN, + ) + assert structural_tag_accepts_text('{"name":123}\n', structural_tag, fixture_tokenizer) is False + + # Purpose: Round-trip regression: DataFrame -> schema -> regex must match the original JSONL rows. # Data: First 5 rows of Iris dataset serialized to JSONL; same schema used to build regex. # Asserts: Non-grouped JSONL matches; grouped (BOS/EOS-wrapped) matches when group_by=True. diff --git a/tests/generation/test_vllm_backend.py b/tests/generation/test_vllm_backend.py index 5d296d0e2..e084c0122 100644 --- a/tests/generation/test_vllm_backend.py +++ b/tests/generation/test_vllm_backend.py @@ -17,6 +17,7 @@ ) from nemo_safe_synthesizer.config.generate import ValidationParameters from nemo_safe_synthesizer.defaults import DEFAULT_SAMPLING_PARAMETERS +from nemo_safe_synthesizer.errors import ParameterError from nemo_safe_synthesizer.generation.processors import TabularDataProcessor from nemo_safe_synthesizer.llm.metadata import ModelMetadata @@ -96,6 +97,14 @@ def base_params(): ) +@pytest.fixture +def params_with_structured_generation_auto(base_params): + """Create params with structured generation enabled using auto schema method.""" + base_params.generation.use_structured_generation = True + base_params.generation.structured_generation_schema_method = "auto" + return base_params + + @pytest.fixture def params_with_structured_generation_regex(base_params): """Create params with structured generation enabled using regex.""" @@ -114,6 +123,15 @@ def params_with_structured_generation_json(base_params): return base_params +@pytest.fixture +def params_with_structured_generation_structural_tag(base_params): + """Create params with structured generation enabled using structural_tag.""" + base_params.generation.use_structured_generation = True + base_params.generation.structured_generation_schema_method = "structural_tag" + base_params.generation.structured_generation_backend = "xgrammar" + return base_params + + @pytest.fixture def mock_schema(): """Create a mock JSON schema.""" @@ -208,6 +226,120 @@ def test_returns_params_with_json_when_json_schema_method( assert result is not None assert result.json == mock_schema + def test_returns_params_with_structural_tag_when_structural_tag_method( + self, + params_with_structured_generation_structural_tag, + mock_model_metadata, + mock_schema, + mock_workdir, + ): + """Test that structural_tag uses vLLM's Structural Tag constraint.""" + backend = create_backend( + params_with_structured_generation_structural_tag, + mock_model_metadata, + mock_schema, + mock_workdir, + ) + + with patch( + "nemo_safe_synthesizer.generation.vllm_backend.build_json_structural_tag", + return_value='{"type":"structural_tag","format":{"type":"json_schema","json_schema":{}}}', + ) as mock_build_structural_tag: + result = backend._build_structured_output_params() + mock_build_structural_tag.assert_called_once_with( + mock_schema, + params_with_structured_generation_structural_tag, + bos_token=mock_model_metadata.prompt_config.bos_token, + eos_token=mock_model_metadata.prompt_config.eos_token, + ) + assert result is not None + assert result.structural_tag == '{"type":"structural_tag","format":{"type":"json_schema","json_schema":{}}}' + + @pytest.mark.parametrize("backend", ["auto", "xgrammar"]) + def test_auto_resolves_to_structural_tag_on_xgrammar_backends( + self, + params_with_structured_generation_auto, + mock_model_metadata, + mock_schema, + mock_workdir, + backend, + ): + """Auto schema method uses structural_tag on xgrammar-capable backends.""" + params_with_structured_generation_auto.generation.structured_generation_backend = backend + backend_instance = create_backend( + params_with_structured_generation_auto, + mock_model_metadata, + mock_schema, + mock_workdir, + ) + + with patch( + "nemo_safe_synthesizer.generation.vllm_backend.build_json_structural_tag", + return_value='{"type":"structural_tag","format":{"type":"json_schema","json_schema":{}}}', + ) as mock_build_structural_tag: + result = backend_instance._build_structured_output_params() + mock_build_structural_tag.assert_called_once_with( + mock_schema, + params_with_structured_generation_auto, + bos_token=mock_model_metadata.prompt_config.bos_token, + eos_token=mock_model_metadata.prompt_config.eos_token, + ) + assert result is not None + assert result.structural_tag is not None + + @pytest.mark.parametrize("backend", ["guidance", "outlines", "lm-format-enforcer"]) + def test_auto_resolves_to_regex_on_other_backends( + self, + params_with_structured_generation_auto, + mock_model_metadata, + mock_schema, + mock_workdir, + backend, + ): + """Auto schema method falls back to regex on non-xgrammar backends.""" + params_with_structured_generation_auto.generation.structured_generation_backend = backend + backend_instance = create_backend( + params_with_structured_generation_auto, + mock_model_metadata, + mock_schema, + mock_workdir, + ) + + with patch( + "nemo_safe_synthesizer.generation.vllm_backend.build_json_based_regex", + return_value="test_regex_pattern", + ) as mock_build_regex: + result = backend_instance._build_structured_output_params() + mock_build_regex.assert_called_once_with( + mock_schema, + params_with_structured_generation_auto, + bos_token=mock_model_metadata.prompt_config.bos_token, + eos_token=mock_model_metadata.prompt_config.eos_token, + ) + assert result is not None + assert result.regex == "test_regex_pattern" + + @pytest.mark.parametrize("backend", ["guidance", "outlines", "lm-format-enforcer"]) + def test_structural_tag_rejects_non_xgrammar_backends( + self, + params_with_structured_generation_structural_tag, + mock_model_metadata, + mock_schema, + mock_workdir, + backend, + ): + """Structural Tag requires vLLM's xgrammar backend.""" + params_with_structured_generation_structural_tag.generation.structured_generation_backend = backend + backend_instance = create_backend( + params_with_structured_generation_structural_tag, + mock_model_metadata, + mock_schema, + mock_workdir, + ) + + with pytest.raises(ParameterError, match="requires `structured_generation_backend`"): + backend_instance._build_structured_output_params() + def test_config_with_grouping_passed_to_build_regex( self, params_with_structured_generation_regex, mock_model_metadata, mock_schema, mock_workdir ): diff --git a/uv.lock b/uv.lock index a06cf84c5..980f4e621 100644 --- a/uv.lock +++ b/uv.lock @@ -3109,6 +3109,7 @@ cpu = [ { name = "triton", version = "3.6.0", source = { registry = "https://pypi.org/simple" }, marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "trl", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "vllm", version = "0.20.0", source = { registry = "https://pypi.org/simple" }, marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "xgrammar", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cu129 = [ { name = "accelerate", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, @@ -3132,6 +3133,7 @@ cu129 = [ { name = "triton", version = "3.6.0", source = { registry = "https://download.pytorch.org/whl/cu129" }, marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "trl", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "vllm", version = "0.20.0+cu129", source = { registry = "https://wheels.vllm.ai/88d34c6409e9fb3c7b8ca0c04756f061d2099eb1/cu129" }, marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "xgrammar", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] engine = [ { name = "anyascii", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-21-nemo-safe-synthesizer-cpu' and extra == 'extra-21-nemo-safe-synthesizer-cu129') or (sys_platform == 'darwin' and extra == 'extra-21-nemo-safe-synthesizer-cpu' and extra == 'extra-21-nemo-safe-synthesizer-cu129') or (sys_platform == 'linux' and extra == 'extra-21-nemo-safe-synthesizer-cpu' and extra == 'extra-21-nemo-safe-synthesizer-cu129')" }, @@ -3295,6 +3297,8 @@ requires-dist = [ { name = "vllm", marker = "sys_platform == 'linux' and extra == 'cpu'", specifier = "==0.20.0" }, { name = "vllm", marker = "sys_platform == 'linux' and extra == 'cu129'", specifier = "==0.20.0+cu129", index = "https://wheels.vllm.ai/88d34c6409e9fb3c7b8ca0c04756f061d2099eb1/cu129", conflict = { package = "nemo-safe-synthesizer", extra = "cu129" } }, { name = "wandb", marker = "extra == 'engine'", specifier = "==0.26.1" }, + { name = "xgrammar", marker = "sys_platform == 'linux' and extra == 'cpu'", specifier = ">=0.2.0" }, + { name = "xgrammar", marker = "sys_platform == 'linux' and extra == 'cu129'", specifier = ">=0.2.0" }, ] provides-extras = ["cpu", "cu129", "engine"] @@ -6938,9 +6942,10 @@ wheels = [ [[package]] name = "xgrammar" -version = "0.1.32" +version = "0.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "apache-tvm-ffi", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "numpy", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "pydantic", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "torch", version = "2.11.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-21-nemo-safe-synthesizer-cpu' and extra == 'extra-21-nemo-safe-synthesizer-cu129') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-21-nemo-safe-synthesizer-cpu') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-21-nemo-safe-synthesizer-cpu') or (sys_platform != 'linux' and extra == 'extra-21-nemo-safe-synthesizer-cpu' and extra == 'extra-21-nemo-safe-synthesizer-cu129')" }, @@ -6950,22 +6955,22 @@ dependencies = [ { name = "triton", version = "3.6.0", source = { registry = "https://pypi.org/simple" }, marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-21-nemo-safe-synthesizer-cpu') or (platform_machine != 'x86_64' and extra == 'extra-21-nemo-safe-synthesizer-cpu' and extra == 'extra-21-nemo-safe-synthesizer-cu129') or (sys_platform != 'linux' and extra == 'extra-21-nemo-safe-synthesizer-cpu' and extra == 'extra-21-nemo-safe-synthesizer-cu129')" }, { name = "typing-extensions", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/99/6a/d51b44fc0b43e2d4adae42b6a17fe9ee49e177d6d768be739ed7dec7b57e/xgrammar-0.1.32.tar.gz", hash = "sha256:5d424d52779ca2d3ccaf72f2289d6519efe308e933d0d3fc3c292c780825bb12", size = 2365047, upload-time = "2026-03-04T12:01:52.544Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/28/cd/4b5e67c8030b626a1a00b65b4d149b1b031c885eef86d4e5fa296f6ec72e/xgrammar-0.1.32-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:51b41c47785aa198d19f8d056b394f75b4421deab88c415568f9c588b1f7e238", size = 18425822, upload-time = "2026-03-04T12:00:23.356Z" }, - { url = "https://files.pythonhosted.org/packages/5c/c0/94fbc45642e733a9ad4a9f3f7300a1a06b265f8657af4d6a56acd8cf00c4/xgrammar-0.1.32-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d7030192cb1d8579699f1f72fd14d31347a402611aab98a2da6a04c3de07e917", size = 20582669, upload-time = "2026-03-04T12:00:26.463Z" }, - { url = "https://files.pythonhosted.org/packages/90/ea/2f4c8616d8ed0b5a3eb4e417b4987ad5a8d9dd9336ed966a8d48ffd45907/xgrammar-0.1.32-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a332c0364f665b410a6cfc2ada155c3a6ede430e385ac431015e31735a64fec3", size = 37682948, upload-time = "2026-03-04T12:00:29.814Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ae/b9108fadd354ae776c1e7ecd26890a13ac8a30367f9fe8110443aedc4e6a/xgrammar-0.1.32-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5b8ad132d0fcf3a51dc054ecb0dc9808566b302122de6edaac7b4aca460adbec", size = 37709617, upload-time = "2026-03-04T12:00:33.068Z" }, - { url = "https://files.pythonhosted.org/packages/9d/48/0096bd1f3b460eac48faaecf79418ea3172269dccf37968e78dff5114faf/xgrammar-0.1.32-cp311-cp311-win_amd64.whl", hash = "sha256:b8b1ca6d3f3c2842660458660e494aaf0a6745f1b07ae74e4c2230ab4ff70c11", size = 6632722, upload-time = "2026-03-04T12:00:36.133Z" }, - { url = "https://files.pythonhosted.org/packages/9f/fd/5e771276fa090e35eaf1cbfdede24b9d93d6bbd2e99cd4f8d558f381fdee/xgrammar-0.1.32-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:9b78d32265f096e5567ab52c72b681855cf473481a48a1e7e6d97d414ba30b82", size = 18425090, upload-time = "2026-03-04T12:00:38.5Z" }, - { url = "https://files.pythonhosted.org/packages/31/66/f06745755ef0750f43955cf679b4bd8bd88ac8bfab760f020225c192884f/xgrammar-0.1.32-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23eacaf826c3aeebca0d91fc271417d9d96e157af2bacf6f14277297af7917ef", size = 20582048, upload-time = "2026-03-04T12:00:42.369Z" }, - { url = "https://files.pythonhosted.org/packages/79/29/3b0306800ccabce8f565123a5b97432dee43822c30142085d9b13b43f166/xgrammar-0.1.32-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9a637d4e0c541149e0d409c24f4ec79cd74d87508ee6a17a7e64a9b9c0cf56f", size = 37680849, upload-time = "2026-03-04T12:00:46.712Z" }, - { url = "https://files.pythonhosted.org/packages/69/62/65e664d861cdadf2d788c03dd8fe67f1faaa7bd4bd2317a2ab850aebee20/xgrammar-0.1.32-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f96c7a4fcbd68e18b13cb3b6ed5d24b5326b256933f476bdaf2cc8e609c228db", size = 37711100, upload-time = "2026-03-04T12:00:50.188Z" }, - { url = "https://files.pythonhosted.org/packages/80/43/05f27a1739209eb590772f867f3f48e6db0a36f376d85db4e68f49aee799/xgrammar-0.1.32-cp312-cp312-win_amd64.whl", hash = "sha256:ba6e08c385cce53eda8e9b3bbfba63f100ba3dcb76fa0692a65921a36b20ad0a", size = 6632259, upload-time = "2026-03-04T12:00:53.184Z" }, - { url = "https://files.pythonhosted.org/packages/7b/58/b4ff220b28d7d6a4ccf5c229ddbabc7018cd9544356ac8a161086e7a7a0e/xgrammar-0.1.32-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4addb8f5d5699e7df7fca6d299a91b3ef1ad799811c0ab7050d6f96d754c9c21", size = 20582005, upload-time = "2026-03-04T12:00:55.089Z" }, - { url = "https://files.pythonhosted.org/packages/83/95/9fedafd412af05b1d61859c52fd9d26abc9a167fab66bdad53f832da0956/xgrammar-0.1.32-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:028f8d6a105d06549faee0afbebfaada90aa1941c081dcc88f3d5ef373dad934", size = 37680882, upload-time = "2026-03-04T12:00:59.456Z" }, - { url = "https://files.pythonhosted.org/packages/0a/21/a9d328ae9ff4e794281995de3a1f8065517bb9bef70f099ab24f7743b3be/xgrammar-0.1.32-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0150c50eb3a56a35d6f0c0af0bce0f113ec5f84f7918bfd46b49e25ecf7fb5", size = 37710862, upload-time = "2026-03-04T12:01:02.739Z" }, - { url = "https://files.pythonhosted.org/packages/28/dc/8ecf71ad1e9c96fd941d2e9a852e184054596eeb1799de8b2e172eaf705e/xgrammar-0.1.32-cp313-cp313-win_amd64.whl", hash = "sha256:e1072d764705c8e87df6136ce3419f96ab3fd423d85f58c2d81c13a647b78894", size = 6632312, upload-time = "2026-03-04T12:01:05.474Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/d8/ea/6394caddd078d33772070eefaaf77cb0a826a3047b908b688dece7d040b5/xgrammar-0.2.1.tar.gz", hash = "sha256:4c48c251b75d211e9ffa7f4f4ac8b5b0164f89fd5f0d1883ad7ff4554922030d", size = 2427065, upload-time = "2026-05-17T21:39:26.576Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/9e/e1f35da58272099af49eee18545a60d0834d5058b3ad11a7a754d084ca5b/xgrammar-0.2.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:62ac4b8ed9eeb7d48c71c3d4c641bdc0e11f1b4c20c4b36ee670412238d9854d", size = 23290996, upload-time = "2026-05-17T21:37:24.886Z" }, + { url = "https://files.pythonhosted.org/packages/df/4c/2839328d7577378968db54c5dc2324ed1ffd1f212219f8543f24a7becd4c/xgrammar-0.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7dfe4b50d12325dc50f65ec837c03e02f2e2c366b7ae88d93bdcf4351e8bd440", size = 23202401, upload-time = "2026-05-17T21:37:29.298Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a6/838795496cd32429cbb6a05aeb3f9fec92d33a0ad47784666b251c52ef23/xgrammar-0.2.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f16c1adf6bf31d0f2ba40a7fc5df69a01757641a06e1cbf17f2143a830180646", size = 44218971, upload-time = "2026-05-17T21:37:35.158Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/b31a56ef2d1e2083da4c0db1aefcc3772556675e4ac8b6ed99a968eea0b1/xgrammar-0.2.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a1ae52372ae981c518a83576209343ec0e79a4ac3a1ed104b0126b870c0ca3b", size = 44678458, upload-time = "2026-05-17T21:37:40.91Z" }, + { url = "https://files.pythonhosted.org/packages/c6/9f/e12dca5a023ee38f4301b8945cdc516825a434c9d0f8e4e15843714e5e78/xgrammar-0.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:8a0f7cc19f058dd9d210b55c1429aea2efb5ab25602885661460bae864c7012d", size = 7409599, upload-time = "2026-05-17T21:37:44.37Z" }, + { url = "https://files.pythonhosted.org/packages/c5/87/af20928af1c7773b1d064743de0f291698dd531e5deee19fcda388c3495e/xgrammar-0.2.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:0560568654e7745a80715dd87457f1feb3a4edf9d3894e47ab5c967f5d799ade", size = 23290997, upload-time = "2026-05-17T21:37:47.413Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e3/9b803c8168290421b3e79b001a823a7e381039fcdd349999c2eeaf454989/xgrammar-0.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:29756a266a62da398151946f3d912a5b737df2000a7023696449f9eec0996208", size = 23202397, upload-time = "2026-05-17T21:37:50.987Z" }, + { url = "https://files.pythonhosted.org/packages/44/60/7c6194b66e043f36a7fbdbf7e6e0e4c94b151f7e53ad1b197f53e711f5d1/xgrammar-0.2.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e8dd9853958a263b4015ce79133a0ff4eaa9d22ef781fb2350c7dfc40c2c012", size = 44218929, upload-time = "2026-05-17T21:37:56.155Z" }, + { url = "https://files.pythonhosted.org/packages/96/4b/327b3cf702b685a2be28d15490faa4beeac00c4fbcf9bb2d7db0fda32931/xgrammar-0.2.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbc6014dc1c92fc317b14519121c8163fe35fd934179e5a45d83f780ff231826", size = 44678489, upload-time = "2026-05-17T21:38:00.791Z" }, + { url = "https://files.pythonhosted.org/packages/57/39/69a5ba4dfa5e11a36265f69de3e16ee65ac9e77840c010056a2d8e99a875/xgrammar-0.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:9bd16e92b4385cb5ded48d65de80e4ee871b6b427b2ded99ac5028b907bf870f", size = 7409599, upload-time = "2026-05-17T21:38:03.829Z" }, + { url = "https://files.pythonhosted.org/packages/58/57/31d118a787debf1a0ab9c6a632e17373bffbaca65250c036498f58934246/xgrammar-0.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2e1964e2a1a5f59e138205996b8b41128c2f385a4de952292e8e47add556aa3c", size = 23202388, upload-time = "2026-05-17T21:38:07.476Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/8b5a8f103818a2a2b779c6ca21b5cda3536c78800bf25c6a37f87b130877/xgrammar-0.2.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bb015d246a8c87bf46b11759442924cd6f96baf3ba444062a98e9a45ec5a1021", size = 44218981, upload-time = "2026-05-17T21:38:13.295Z" }, + { url = "https://files.pythonhosted.org/packages/32/75/25ddd211f073a9db8299bdfec4534874d5d5d5f69499bb0c2ce9bf75f483/xgrammar-0.2.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba024024a454f31d3cb88679a1b073eba0133860f14525f1f5856fa46dec5228", size = 44678423, upload-time = "2026-05-17T21:38:18.812Z" }, + { url = "https://files.pythonhosted.org/packages/98/67/f99c7a0cd6221d6db6b67d7d987ef7bbb99e52bdd97d6d7d515e28c57e64/xgrammar-0.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:58ab4d1b2a9f23e38e9a53ceb30ae5c8363e9a9e14d686bbb9f06147fb2e050c", size = 7409601, upload-time = "2026-05-17T21:38:22.229Z" }, ] [[package]]