From 173c63eff37e3ce0ea27aef9653a0c6d37bd24dd Mon Sep 17 00:00:00 2001 From: OthmanAdi Date: Sun, 25 Oct 2026 08:55:39 +0100 Subject: [PATCH] feat: export Qwen3 decision models as native classifiers Preserve the trained code readout, prompt and calibration in a standard HF classifier layout. Add native vLLM serving, strict checkpoint validation, staged export publication, offline parity checks and an opt-in actual-engine test with reproducibility documentation. --- README.md | 18 ++ docs/native-classifier.md | 210 +++++++++++++++++++++ examples/compare_classifier.py | 195 ++++++++++++++++++++ src/decisionformer/__init__.py | 4 +- src/decisionformer/api.py | 32 +++- src/decisionformer/classifier.py | 215 ++++++++++++++++++++++ src/decisionformer/cli.py | 20 +- src/decisionformer/vllm_backend.py | 65 ++++++- tests/test_classifier_api.py | 112 ++++++++++++ tests/test_classifier_export.py | 282 +++++++++++++++++++++++++++++ tests/test_classifier_runtime.py | 93 ++++++++++ tests/test_vllm_backend.py | 96 +++++++++- 12 files changed, 1319 insertions(+), 23 deletions(-) create mode 100644 docs/native-classifier.md create mode 100644 examples/compare_classifier.py create mode 100644 src/decisionformer/classifier.py create mode 100644 tests/test_classifier_api.py create mode 100644 tests/test_classifier_export.py create mode 100644 tests/test_classifier_runtime.py diff --git a/README.md b/README.md index 20d000b..e65c2e6 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,24 @@ pip install "decisionformer[vllm]" decisionformer serve vllm-sr/d3-edge --backend vllm ``` +### Export a trained Qwen3 model as a native classifier + +A merged causal Qwen3 checkpoint with one answer-code readout can be exported to the standard +`Qwen3ForSequenceClassification` layout. The export copies the learned answer matrix into `score.weight`, +preserves the tokenizer and decision metadata, and leaves the source checkpoint unchanged: + +```bash +decisionformer export runs/deciformer15k-qwen3-0.6b/best exports/my-classifier --format sequence-classification +decisionformer serve exports/my-classifier # Transformers backend +decisionformer serve exports/my-classifier --backend vllm # requires a compatible vLLM runtime +``` + +The destination must be new. This first export path writes float32 weights, so stored 16-bit backbone weights +approximately double in size. DecisionFormer keeps the original prompts, active-option normalization and +per-question-type calibration. Export compatibility does not itself establish a speedup. +See the [native classifier guide](docs/native-classifier.md) for scope, numerical checks and the reproducible +comparison example. + ## Supported backbones Every family below is fine-tuned, exported, reloaded and checked against the Decision Index's response rules in diff --git a/docs/native-classifier.md b/docs/native-classifier.md new file mode 100644 index 0000000..de750f4 --- /dev/null +++ b/docs/native-classifier.md @@ -0,0 +1,210 @@ +# Export a trained decision model as a native classifier + +The sequence-classification export packages a trained Qwen3 decision model as a standard Hugging Face +`Qwen3ForSequenceClassification` checkpoint. It copies the trained backbone into `model.*` and the independent +answer-code readout into `score.weight`. + +The original checkpoint stays in place. The export writes to a new directory and refuses an existing destination. +The first supported layout is a merged, unquantized, causal Qwen3 model with one `code_readout` head and last-token +pooling. Adapter-only exports, other backbones, multiple heads and other attention or pooling modes are outside +this conversion's scope. + +## What must remain the same + +The readout starts as a copy of selected language-model output rows, but training updates it independently. +Reloading the original language-model head would discard those updates. The classifier export copies the learned +matrix into the classifier's bias-free `score` layer. + +DecisionFormer still renders the saved prompt and formats the response. For each question, it takes the raw +scores for that question's active options, divides by the saved question-type temperature, and applies softmax +over those options. This preserves `choice`, `noul` and `score` response semantics, including confidence and legends. +The tokenizer, chat template, answer-code order, input limit and calibration metadata travel with the export. + +The export uses CPU FP32 weights for the first implementation. Stored FP16/BF16 values are promoted exactly, +which approximately doubles their weight storage. A later inference engine may cast the backbone to another +dtype. Compare that engine's outputs at the selected precision before relying on numerical parity. + +## Export and load + +Start with a trained checkpoint, such as the merged `best` directory from the Qwen3 recipe: + +```bash +decisionformer export runs/deciformer15k-qwen3-0.6b/best runs/my-classifier --format sequence-classification +``` + +The Python equivalent is: + +```python +from decisionformer.classifier import export_sequence_classifier + +export_sequence_classifier("runs/deciformer15k-qwen3-0.6b/best", "runs/my-classifier") +``` + +The new directory has the usual Hugging Face config, tokenizer and model weights. Its weights contain +`model.*` and `score.weight`. It also retains `decision_config.json`, marked with +`decisionformer.export_format: "sequence_classification"`. It has no duplicate `readout.safetensors` file. + +Load it through either interface: + +```python +from transformers import AutoModelForSequenceClassification +from decisionformer import AutoDecisionModel + +classifier = AutoModelForSequenceClassification.from_pretrained("runs/my-classifier") +decider = AutoDecisionModel.from_pretrained("runs/my-classifier", device="cpu") +``` + +The standard Transformers model returns raw classifier logits. The DecisionFormer interface additionally +renders questions, applies the stored temperatures and returns the System One answer objects. + +## Compare the outputs + +Put representative requests in a JSONL file. Use requests the trained model was designed to answer. For a +ticket model, a small `requests.jsonl` might contain: + +```jsonl +{"state":"The customer was charged twice.","questions":{"route":{"type":"choice","instructions":"Which team should handle this?","criteria":{"billing":"Payments and invoices","technical":"Product faults"}},"urgent":{"type":"noul","instructions":"Does this need an immediate response?"}}} +{"state":"The customer cannot sign in after resetting a password.","questions":{"route":{"type":"choice","instructions":"Which team should handle this?","criteria":{"billing":"Payments and invoices","technical":"Product faults"}},"priority":{"type":"score","instructions":"How urgent is this?","criteria":["Routine","Soon","Immediate"]}}} +``` + +Run the comparison from a repository checkout: + +```bash +python examples/compare_classifier.py \ + --source runs/deciformer15k-qwen3-0.6b/best \ + --exported runs/my-classifier \ + --requests requests.jsonl \ + --backend transformers --dtype float32 --atol 1e-5 +``` + +The reference always runs on CPU in FP32. The command compares exact prompt tokens and code mapping, the saved +input limit and temperatures, active raw logits, probabilities, categorical choices and numeric answer fields. +It prints both sets of answers and a JSON summary, including the request file's SHA256 digest. Exit code `0` means all checks passed at the supplied tolerance, +`1` means a comparison failed, and `2` reports an input or loading error. + +Choose a tolerance before testing another precision. Inspect raw-score errors and close decisions instead of +loosening the tolerance until a result passes. Agreement says the conversion preserved the tested behavior. +It does not measure the model's accuracy. + +## Use the actual vLLM adapter + +In an environment with a compatible vLLM build installed, use the same request file: + +```bash +python examples/compare_classifier.py \ + --source runs/deciformer15k-qwen3-0.6b/best \ + --exported runs/my-classifier \ + --requests requests.jsonl \ + --backend vllm --dtype float32 --atol 1e-5 \ + --engine-options '{"enforce_eager":true}' +``` + +This calls the actual vLLM engine. The adapter requests raw classifier logits with activation disabled, then +DecisionFormer applies option slicing and temperature scaling. Conversion from original LM rows and extra +logit-normalization settings must stay disabled because they would change the trained decision function. + +Serve the exported model through DecisionFormer's existing API: + +```bash +decisionformer serve runs/my-classifier --backend vllm +``` + +Using vLLM's stock `/v1/systemone` endpoint with another read strategy is a different integration. Its prompt, +labels, probability handling and confidence definitions must be checked independently. + +### Isolated Linux CPU execution + +An official x86 CPU image can be used on a Docker host without installing vLLM into the host's Python environment: + +```text +vllm/vllm-openai-cpu@sha256:827ef38187f9b52f2bb838695ef815ec5b65f4bf3e4c061b002b2b0a19cb0701 +``` + +The image is the `v0.32.0-x86_64` release for `linux/amd64`. Its bundled Transformers version predates the +DecisionFormer requirement. The following derived image supplied the dependencies for the CPU run recorded below: + +```dockerfile +FROM vllm/vllm-openai-cpu@sha256:827ef38187f9b52f2bb838695ef815ec5b65f4bf3e4c061b002b2b0a19cb0701 +RUN python -m pip install --no-cache-dir transformers==5.20.2 peft==0.21.2 omegaconf==2.3.0 +``` + +Build it with a local tag such as `decisionformer-vllm-cpu`. From the repository root, this POSIX-shell example +uses existing local checkpoints and requests, exposes no server port, and disables network access during inference: + +The resource limits below are the tiny-fixture validation settings. Larger checkpoints may need more memory, +including space for the CPU FP32 reference and the engine model. + +```bash +docker run --rm --platform linux/amd64 --cpus 4 --memory 8g --shm-size 1g --network none \ + --entrypoint python \ + -e PYTHONPATH=/workspace/src -e HF_HUB_OFFLINE=1 -e TRANSFORMERS_OFFLINE=1 \ + -e OMP_NUM_THREADS=4 -e VLLM_CPU_KVCACHE_SPACE=1 -e VLLM_CPU_OMP_THREADS_BIND=nobind \ + -v "$PWD:/workspace:ro" -w /workspace decisionformer-vllm-cpu \ + examples/compare_classifier.py \ + --source runs/deciformer15k-qwen3-0.6b/best --exported runs/my-classifier \ + --requests requests.jsonl --backend vllm --dtype float32 --atol 1e-4 \ + --engine-options '{"enforce_eager":true,"max_num_seqs":16,"max_num_batched_tokens":1024}' +``` + +Keep the source checkout, model artifacts and requests mounted read-only. Confirm the installed versions inside +the container rather than assuming they match a host environment. + +For a small CPU check, the official setup documents `VLLM_CPU_KVCACHE_SPACE` and `VLLM_CPU_OMP_THREADS_BIND`. +Set explicit container CPU, memory and shared-memory limits. Verify that the Linux environment exposes supported +CPU instructions. Follow the [official CPU installation guide](https://docs.vllm.ai/en/latest/getting_started/installation/cpu/) +for the runtime requirements. + +## Recorded CPU engine check + +A bounded run used the actual `VLLMDecider` integration in the derived image above, with no network access during +inference. The container had a four-CPU limit, an 8 GiB memory limit and AVX512 available. Its packages were vLLM +`0.32.0+cpu`, PyTorch `2.13.0+cpu`, Transformers `5.20.2`, PEFT `0.21.2` and OmegaConf `2.3.0`. + +The source was a randomly initialized, two-layer Qwen3 fixture with hidden size 64 and head dimension 32, trained +for 80 LoRA updates on synthetic data. Its checkpoint had eight answer codes and a 256-token input limit. Four +requests covered plain text, a longer state, a JSON state and an empty state. Each request contained four-choice, +yes/no, three-level score and eight-choice questions, for 16 decisions in total. + +The FP32 comparison used an absolute tolerance of `1e-4`, selected before the run: + +| Check | Observed result | +| --- | --- | +| Maximum active raw-logit error | `6.705522537231445e-8` | +| Maximum probability error | `2.150599187622504e-8` | +| Maximum numeric answer-field error | `3.2019130458138534e-8` | +| Prompt mismatches | `0` | +| Categorical choice mismatches | `0` | +| Process result | Exit `0`, parity checks passed | + +This establishes interoperability for that CPU fixture and package configuration. The run does not establish +real-task accuracy, reduced-precision parity, GPU support or a speedup. + +## Opt-in actual-engine test + +The default test suite skips the actual-engine check. In a compatible vLLM environment with pytest installed, +enable it explicitly: + +```bash +DECISIONFORMER_RUN_VLLM=1 pytest -q -s tests/test_classifier_runtime.py +``` + +This test creates its tokenizer and tiny Qwen3 weights locally. It deliberately changes the readout after random +initialization, exports the checkpoint and compares all three question types through the actual vLLM adapter. +It uses a preselected FP32 tolerance of `1e-4` and prints the comparison JSON. It downloads no model weights. +This test fixture is a conversion check and has no model-quality claim. + +When running the test in Docker, use the same resource and network limits as the comparison command. Add pytest +to the derived test image, set `DECISIONFORMER_RUN_VLLM=1`, and replace the final command with +`-m pytest -q -s -p no:cacheprovider tests/test_classifier_runtime.py`. + +## Validation boundaries + +The repository's offline tests use small locally constructed models and a fake engine adapter. They check +conversion behavior and the adapter contract without downloading model weights or requiring vLLM. + +A separate actual-engine comparison is required for each claimed runtime configuration. Record the model and +engine versions, image digest where applicable, hardware, dtype, requests, tolerances and full output. +CPU engine parity does not establish GPU compatibility or acceleration. The +[vLLM pooling-model guide](https://docs.vllm.ai/en/latest/models/pooling_models/) does not guarantee a speedup over Transformers. +Measure matched workloads, precision, batching, latency, +throughput and memory before making a performance claim. diff --git a/examples/compare_classifier.py b/examples/compare_classifier.py new file mode 100644 index 0000000..5a637da --- /dev/null +++ b/examples/compare_classifier.py @@ -0,0 +1,195 @@ +"""Compare a source decision checkpoint with its native classifier export. + +The reference runs on CPU in FP32. The candidate uses DecisionFormer's +Transformers or actual vLLM adapter. This checks behavior, not model accuracy +or serving performance. Run from an environment with decisionformer installed. +""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.metadata +import json +import math +from pathlib import Path +from typing import Any + +import torch + +from decisionformer import AutoDecisionModel +from decisionformer.schema import Decision, parse_questions + +DTYPES = {"float32": torch.float32, "bfloat16": torch.bfloat16, "float16": torch.float16} + + +def read_requests(path: Path) -> list[dict[str, Any]]: + requests = [] + for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + if not line.strip(): + continue + row = json.loads(line) + if not isinstance(row, dict) or "state" not in row or "questions" not in row: + raise ValueError(f"{path}:{line_number}: each request needs state and questions") + parse_questions(row["questions"]) + requests.append(row) + if not requests: + raise ValueError(f"{path}: no requests") + return requests + + +def flatten(requests: list[dict[str, Any]]) -> list[Decision]: + return [Decision(id=question.key, request_id=str(i), state=request["state"], question=question, + task=question.task or "default") + for i, request in enumerate(requests) for question in parse_questions(request["questions"])] + + +def absolute_error(a: float, b: float) -> float: + return abs(a-b) if math.isfinite(a) and math.isfinite(b) else math.inf + + +def answer_errors(expected: Any, actual: Any) -> tuple[float, int]: + """Compare answer schema/values recursively, including legends and confidence.""" + if isinstance(expected, dict): + if not isinstance(actual, dict) or expected.keys() != actual.keys(): + return 0.0, 1 + comparisons = [answer_errors(value, actual[key]) for key, value in expected.items()] + elif isinstance(expected, list): + if not isinstance(actual, list) or len(expected) != len(actual): + return 0.0, 1 + comparisons = [answer_errors(a, b) for a, b in zip(expected, actual)] + elif isinstance(expected, (int, float)) and not isinstance(expected, bool): + if not isinstance(actual, (int, float)) or isinstance(actual, bool): + return 0.0, 1 + return absolute_error(float(expected), float(actual)), 0 + else: + return 0.0, int(expected != actual) + return max((error for error, _ in comparisons), default=0.0), sum(n for _, n in comparisons) + + +def probability_error(expected: dict[str, Any], actual: dict[str, Any]) -> float: + if "probabilities" in expected: + a, b = expected["probabilities"], actual.get("probabilities") + if not isinstance(b, dict) or a.keys() != b.keys(): + return math.inf + return max((absolute_error(float(value), float(b[key])) for key, value in a.items()), default=0.0) + if "noul" in expected and "noul" in actual: + return absolute_error(float(expected["noul"]), float(actual["noul"])) + return math.inf + + +def package_versions(backend: str) -> dict[str, str]: + names = ["decisionformer", "torch", "transformers"] + (["vllm"] if backend == "vllm" else []) + result = {} + for name in names: + try: + result[name] = importlib.metadata.version(name) + except importlib.metadata.PackageNotFoundError: + result[name] = "not recorded" + return result + + +@torch.inference_mode() +def compare(args: argparse.Namespace) -> dict[str, Any]: + requests = read_requests(args.requests) + reference = AutoDecisionModel.from_pretrained(args.source, device="cpu", dtype=torch.float32) + if args.backend == "transformers": + if args.engine_options: + raise ValueError("--engine-options only applies to --backend vllm") + candidate = AutoDecisionModel.from_pretrained(args.exported, device=args.device, dtype=DTYPES[args.dtype]) + else: + from decisionformer.vllm_backend import VLLMDecider + + options = json.loads(args.engine_options or "{}") + if not isinstance(options, dict): + raise ValueError("--engine-options must be a JSON object") + reserved = {"model", "name_or_path", "dtype"} & options.keys() + if reserved: + raise ValueError(f"--engine-options cannot override {sorted(reserved)}") + candidate = VLLMDecider.from_pretrained(args.exported, dtype=args.dtype, **options) + + decisions = flatten(requests) + reference_encoded = [reference.builder.encode(d, max_length=reference.max_input_tokens) for d in decisions] + candidate_encoded = [candidate.builder.encode(d, max_length=candidate.max_input_tokens) for d in decisions] + prompt_mismatches = sum(a.input_ids != b.input_ids for a, b in zip(reference_encoded, candidate_encoded)) + reference_logits = reference.code_logits(decisions, reference_encoded) + candidate_logits = candidate.code_logits(decisions, candidate_encoded) + if len(reference_logits) != len(decisions) or len(candidate_logits) != len(decisions): + raise ValueError("A backend returned the wrong number of raw-logit rows") + max_logit_error = 0.0 + for decision, a, b in zip(decisions, reference_logits, candidate_logits): + if len(a) < decision.num_options or len(b) < decision.num_options: + raise ValueError(f"question {decision.question.key!r}: a backend omitted active option logits") + values_a = a[:decision.num_options].float().cpu().tolist() + values_b = b[:decision.num_options].float().cpu().tolist() + max_logit_error = max(max_logit_error, max(absolute_error(x, y) for x, y in zip(values_a, values_b))) + + reference_responses = reference.decide_many(requests) + candidate_responses = candidate.decide_many(requests) + if len(reference_responses) != len(requests) or len(candidate_responses) != len(requests): + raise ValueError("A backend returned the wrong number of responses") + max_probability_error, max_answer_error = 0.0, 0.0 + answer_mismatches, choice_mismatches = 0, 0 + responses = [] + for i, (request, expected, actual) in enumerate(zip(requests, reference_responses, candidate_responses)): + expected_answers, actual_answers = expected["answers"], actual["answers"] + error, mismatches = answer_errors(expected_answers, actual_answers) + max_answer_error = max(max_answer_error, error) + answer_mismatches += mismatches + for key, answer in expected_answers.items(): + got = actual_answers.get(key, {}) + max_probability_error = max(max_probability_error, probability_error(answer, got)) + if "choice" in answer: + choice_mismatches += int(answer["choice"] != got.get("choice")) + responses.append({"id": request.get("id", i), "reference": expected_answers, "exported": actual_answers}) + code_mapping_matches = (reference.builder.codes == candidate.builder.codes + and reference.builder.code_token_ids == candidate.builder.code_token_ids) + temperatures_match = all(reference.temperature_for(d.question_type) == candidate.temperature_for(d.question_type) + for d in decisions) + input_limit_matches = reference.max_input_tokens == candidate.max_input_tokens + finite = all(math.isfinite(value) for value in (max_logit_error, max_probability_error, max_answer_error)) + passed = (finite and max_logit_error <= args.atol and max_probability_error <= args.atol + and max_answer_error <= args.atol and not answer_mismatches and not choice_mismatches + and not prompt_mismatches and code_mapping_matches and temperatures_match and input_limit_matches) + return { + "passed": passed, "backend": args.backend, "reference_device": "cpu", "reference_dtype": "float32", + "candidate_dtype": args.dtype, "atol": args.atol, "versions": package_versions(args.backend), + "source": args.source, "exported": args.exported, + "requests_sha256": hashlib.sha256(args.requests.read_bytes()).hexdigest(), + "requests": len(requests), "questions": len(decisions), "prompt_mismatches": prompt_mismatches, + "code_mapping_matches": code_mapping_matches, "temperatures_match": temperatures_match, + "input_limit_matches": input_limit_matches, + "max_active_logit_abs_error": max_logit_error if math.isfinite(max_logit_error) else None, + "max_probability_abs_error": max_probability_error if math.isfinite(max_probability_error) else None, + "max_answer_abs_error": max_answer_error if math.isfinite(max_answer_error) else None, + "nonfinite_or_missing_outputs": not finite, "answer_mismatches": answer_mismatches, + "choice_mismatches": choice_mismatches, + "scope": "Behavior comparison on supplied requests. No accuracy or performance claim.", + "responses": responses, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", required=True, help="original decision checkpoint") + parser.add_argument("--exported", required=True, help="native classifier export") + parser.add_argument("--requests", type=Path, required=True, help="JSONL of {state, questions} requests") + parser.add_argument("--backend", choices=("transformers", "vllm"), default="transformers") + parser.add_argument("--device", default="cpu", help="candidate Transformers device (reference is always CPU)") + parser.add_argument("--dtype", choices=tuple(DTYPES), default="float32", help="candidate inference dtype") + parser.add_argument("--atol", type=float, default=1e-5, help="absolute tolerance for logits and numeric answers") + parser.add_argument("--engine-options", help="JSON object of additional vLLM constructor options") + args = parser.parse_args() + if not math.isfinite(args.atol) or args.atol < 0: + parser.error("--atol must be finite and nonnegative") + try: + result = compare(args) + except (OSError, ValueError, ImportError, RuntimeError) as error: + print(json.dumps({"passed": False, "error": str(error)}, indent=2)) + return 2 + print(json.dumps(result, indent=2, allow_nan=False)) + return 0 if result["passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/decisionformer/__init__.py b/src/decisionformer/__init__.py index c61dc29..e84f956 100644 --- a/src/decisionformer/__init__.py +++ b/src/decisionformer/__init__.py @@ -12,6 +12,7 @@ __version__ = "0.2.0" from decisionformer.api import AutoDecisionModel, Decider, UnsupportedInput # noqa: E402 +from decisionformer.classifier import export_sequence_classifier # noqa: E402 from decisionformer.config import RunConfig, list_recipes, load_config # noqa: E402 from decisionformer.formats import PromptFormat, get_format, register_format # noqa: E402 from decisionformer.prompting import ContextLengthError # noqa: E402 @@ -20,5 +21,6 @@ __all__ = [ "AutoDecisionModel", "ContextLengthError", "Decider", "DecisionInputError", "PromptFormat", "RunConfig", - "UnsupportedInput", "__version__", "get_format", "list_recipes", "load_config", "register_format", "train", + "UnsupportedInput", "__version__", "export_sequence_classifier", "get_format", "list_recipes", "load_config", + "register_format", "train", ] diff --git a/src/decisionformer/api.py b/src/decisionformer/api.py index 462b485..730170a 100644 --- a/src/decisionformer/api.py +++ b/src/decisionformer/api.py @@ -97,6 +97,11 @@ def from_pretrained(cls, name_or_path: str | Path, *, revision: str | None = Non "`decisionformer train`, or evaluate a stock model zero-shot with an lm_verbalizer head.") config = json.loads(config_file.read_text()) ext = config.get("decisionformer") or {} + native_classifier = ext.get("export_format") == "sequence_classification" + if native_classifier: + from decisionformer.classifier import validate_classifier_checkpoint + + validate_classifier_checkpoint(root, config) style = get_format(prompt or _prompt_name(config)) device = torch.device(device) if device else pick_device() dtype = dtype or _default_dtype(device) @@ -113,8 +118,20 @@ def from_pretrained(cls, name_or_path: str | Path, *, revision: str | None = Non config.get("attention_mode", "causal"), "causal")) model_cfg = SimpleNamespace(backbone=source, revision=model_revision, trust_remote_code=trust_remote_code, attn_implementation="sdpa", init_adapter=None) - hf_model, _ = load_backbone(model_cfg, None, kind=kind, attention=attention, dtype=dtype, device=device, - code_token_ids=None, source=source if not adapter else None) + if native_classifier: + from transformers import AutoModelForSequenceClassification + + classifier, loading = AutoModelForSequenceClassification.from_pretrained( + source, dtype=dtype, attn_implementation="sdpa", trust_remote_code=trust_remote_code, + output_loading_info=True) + problems = {key: loading[key] for key in ("missing_keys", "unexpected_keys", "mismatched_keys", "error_msgs") + if loading.get(key)} + if problems: + raise ValueError(f"Cannot load incomplete or incompatible native classifier weights: {problems}") + hf_model = classifier.model.to(device) + else: + hf_model, _ = load_backbone(model_cfg, None, kind=kind, attention=attention, dtype=dtype, device=device, + code_token_ids=None, source=source if not adapter else None) if adapter: from peft import PeftModel @@ -124,10 +141,15 @@ def from_pretrained(cls, name_or_path: str | Path, *, revision: str | None = Non hf_model.requires_grad_(False) backbone = Backbone(hf_model, kind, attention) codes = config.get("codes") or [] - if (root / READOUT).is_file() and not (root / HEADS).is_file(): - from safetensors.torch import load_file + if native_classifier or ((root / READOUT).is_file() and not (root / HEADS).is_file()): + if native_classifier: + from decisionformer.classifier import classifier_readout + + weight = classifier_readout(root) + else: + from safetensors.torch import load_file - weight = load_file(str(root / READOUT))["weight"] + weight = load_file(str(root / READOUT))["weight"] head = CodeReadoutHead(backbone.hidden_size, max_options=weight.shape[0], init_from_lm_head=False) head.readout.weight.data.copy_(weight.float()) poolers = {"p0": build_pooler(PoolerConfig(type="decision_token"), backbone.hidden_size)} diff --git a/src/decisionformer/classifier.py b/src/decisionformer/classifier.py new file mode 100644 index 0000000..749786c --- /dev/null +++ b/src/decisionformer/classifier.py @@ -0,0 +1,215 @@ +"""Explicit, CPU-FP32 export of a merged Qwen3 decision model as an HF classifier. + +The trained answer matrix becomes ``score.weight``. Promoting stored FP16/BF16 +values to FP32 loses no information, but approximately doubles their storage. +This conversion does not claim reduced-precision or accelerated-engine parity. +""" + +from __future__ import annotations + +import copy +import json +import math +import shutil +import tempfile +from pathlib import Path +from typing import Any + +import torch +from safetensors import safe_open + +CLASSIFIER_FORMAT = "sequence_classification" +_DECISION_CONFIG = "decision_config.json" +_LOGIT_TRANSFORMS = ("classifier_from_token", "method", "logit_mean", "logit_sigma") + + +def _json(path: Path) -> dict[str, Any]: + if not path.is_file(): + raise ValueError(f"Missing checkpoint file: {path.name}") + result = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(result, dict): + raise ValueError(f"{path.name} must contain a JSON object") + return result + + +def _check_contract(config: dict[str, Any], hf: dict[str, Any]) -> tuple[int, int]: + if config.get("format_version") != 1: + raise ValueError("Classifier export requires decision format_version 1") + if config.get("attention_mode", "causal") != "causal": + raise ValueError("Classifier export supports causal attention only") + if config.get("pooling", "last") != "last": + raise ValueError("Classifier export requires last-token pooling") + ext = config.get("decisionformer") or {} + if not isinstance(ext, dict): + raise ValueError("decisionformer metadata must be an object") + if ext.get("backbone") == "adapter": + raise ValueError("Classifier export requires a merged backbone; merge the adapter checkpoint first") + if hf.get("model_type") != "qwen3" or ext.get("kind", "decoder") != "decoder": + raise ValueError("Classifier export supports dense Qwen3 decoder checkpoints only") + if hf.get("quantization_config") is not None: + raise ValueError("Classifier export does not support quantized backbones") + for field in _LOGIT_TRANSFORMS: + if hf.get(field) is not None: + raise ValueError(f"Classifier checkpoints must leave {field} unset") + heads = ext.get("heads") + if heads and (not isinstance(heads, dict) or set(heads) != {"default"} or + not isinstance(heads["default"], dict) or heads["default"].get("type") not in + ("code_readout", "lm_verbalizer")): + raise ValueError("Classifier export requires a single answer-code readout") + codes, token_ids = config.get("codes"), config.get("token_ids") + if (not isinstance(codes, list) or len(codes) < 2 or + any(not isinstance(code, str) or not code for code in codes) or len(set(codes)) != len(codes)): + raise ValueError("Classifier checkpoints need at least two unique answer codes") + if (not isinstance(token_ids, list) or len(token_ids) != len(codes) or + any(type(token) is not int or token < 0 for token in token_ids) or len(set(token_ids)) != len(token_ids)): + raise ValueError("Classifier checkpoints need one unique token id per answer code") + temperatures = config.get("temperature_by_type") or {} + if not isinstance(temperatures, dict): + raise ValueError("temperature_by_type must be an object") + for value in [config.get("temperature", 1.0), *temperatures.values()]: + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value <= 0: + raise ValueError("Classifier temperatures must be finite and positive") + hidden_size = hf.get("hidden_size") + if type(hidden_size) is not int or hidden_size < 1: + raise ValueError("Qwen3 config needs a positive hidden_size") + return len(codes), hidden_size + + +def _check_readout(weight: torch.Tensor, shape: tuple[int, int]) -> None: + if tuple(weight.shape) != shape: + raise ValueError(f"Answer readout shape {tuple(weight.shape)} does not match {shape}") + if not weight.is_floating_point() or not torch.isfinite(weight).all(): + raise ValueError("Answer readout must contain finite floating-point values") + + +def classifier_readout(root: Path) -> torch.Tensor: + """Read only ``score.weight`` from a single or indexed sharded safetensors export.""" + root = Path(root) + path = root / "model.safetensors" + if not path.is_file(): + index = _json(root / "model.safetensors.index.json") + filename = (index.get("weight_map") or {}).get("score.weight") + if not isinstance(filename, str): + raise ValueError("Classifier safetensors index has no score.weight") + path = (root / filename).resolve() + if not path.is_relative_to(root.resolve()): + raise ValueError("Classifier weight shard must stay inside its checkpoint directory") + if not path.is_file(): + raise ValueError("Classifier checkpoint has no safetensors file for score.weight") + with safe_open(str(path), framework="pt", device="cpu") as handle: + if "score.weight" not in handle.keys(): # noqa: SIM118 + raise ValueError("Classifier safetensors file has no score.weight") + return handle.get_tensor("score.weight") + + +def validate_classifier_checkpoint(root: Path, config: dict[str, Any]) -> dict[str, Any]: + """Validate the native layout without deriving any head weights from an LM.""" + root = Path(root) + ext = config.get("decisionformer") + if not isinstance(ext, dict) or ext.get("export_format") != CLASSIFIER_FORMAT: + raise ValueError("Not a DecisionFormer sequence_classification export") + hf = _json(root / "config.json") + shape = _check_contract(config, hf) + if hf.get("architectures") != ["Qwen3ForSequenceClassification"]: + raise ValueError("Native classifier architecture must be Qwen3ForSequenceClassification") + labels = hf.get("num_labels", len(hf.get("id2label") or {})) + if labels != shape[0]: + raise ValueError("Classifier num_labels must equal the number of saved answer codes") + if type(hf.get("pad_token_id")) is not int or hf["pad_token_id"] < 0: + raise ValueError("Native classifier needs a configured pad_token_id for last-token pooling") + if (root / "readout.safetensors").exists() or (root / "heads.safetensors").exists(): + raise ValueError("Native classifier must use score.weight without duplicate readout/head sidecars") + weight = classifier_readout(root) + _check_readout(weight, shape) + if weight.dtype != torch.float32: + raise ValueError("Native classifier score.weight must be float32") + return hf + + +def export_sequence_classifier(source: str | Path, output_dir: str | Path, *, revision: str | None = None) -> Path: + """Copy a merged causal Qwen3 code-readout checkpoint into a new CPU-FP32 HF classifier. + + All backbone values and the independent readout are promoted to float32. + The original checkpoint is never modified and an existing destination is + refused. Native HF loading returns raw code scores; DecisionFormer keeps + prompt rendering, active-option normalization and calibrated responses. + """ + from safetensors.torch import load_file + from transformers import AutoConfig, AutoModel, AutoModelForSequenceClassification, AutoTokenizer + + from decisionformer.api import _local_dir, _prompt_name + from decisionformer.formats import get_format + from decisionformer.prompting import prepare_tokenizer + + out = Path(output_dir).expanduser() + if out.exists() or out.is_symlink(): + raise FileExistsError(f"Export destination already exists: {out}") + root = _local_dir(source, revision, None) + config = _json(root / _DECISION_CONFIG) + hf = _json(root / "config.json") + shape = _check_contract(config, hf) + if hf.get("architectures") != ["Qwen3Model"]: + raise ValueError("Classifier export requires a merged bare Qwen3Model backbone") + if not (root / "readout.safetensors").is_file() or (root / "heads.safetensors").exists(): + raise ValueError("Classifier export requires a single answer-code readout.safetensors") + tensors = load_file(str(root / "readout.safetensors")) + if "weight" not in tensors: + raise ValueError("Answer readout.safetensors has no weight tensor") + _check_readout(tensors["weight"], shape) + try: + backbone, loading = AutoModel.from_pretrained(root, dtype=torch.float32, attn_implementation="sdpa", + trust_remote_code=False, output_loading_info=True) + except RuntimeError as error: + raise ValueError(f"Backbone checkpoint weights do not match the configuration: {error}") from error + problems = {key: loading[key] for key in ("missing_keys", "unexpected_keys", "mismatched_keys", "error_msgs") + if loading.get(key)} + if problems: + raise ValueError(f"Backbone checkpoint has missing, unexpected or mismatched weights: {problems}") + tokenizer_dir = root if (root / "tokenizer_config.json").is_file() else root / "processor" + tokenizer = AutoTokenizer.from_pretrained(tokenizer_dir, trust_remote_code=False) + prepare_tokenizer(tokenizer) + get_format(_prompt_name(config)) + hf_config = AutoConfig.from_pretrained(root, trust_remote_code=False) + hf_config.num_labels = shape[0] + hf_config.id2label = dict(enumerate(config["codes"])) + hf_config.label2id = {code: i for i, code in enumerate(config["codes"])} + hf_config.pad_token_id = tokenizer.pad_token_id + hf_config.problem_type = "single_label_classification" + hf_config.use_cache = False + with torch.random.fork_rng(devices=[]): + native = AutoModelForSequenceClassification.from_config(hf_config).float() + native.model.load_state_dict(backbone.state_dict(), strict=True) + with torch.no_grad(): + native.score.weight.copy_(tensors["weight"].float()) + native.eval() + payload = copy.deepcopy(config) + payload["decisionformer"] = {**(payload.get("decisionformer") or {}), "export_format": CLASSIFIER_FORMAT, + "export_dtype": "float32", "backbone": "full"} + payload["readout_dtype"] = "float32" + out.parent.mkdir(parents=True, exist_ok=True) + parent = out.parent.resolve() + staging = Path(tempfile.mkdtemp(prefix=f".{out.name}-", dir=parent)) + try: + native.save_pretrained(staging, safe_serialization=True) + tokenizer.save_pretrained(staging) + (staging / _DECISION_CONFIG).write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8") + (staging / "README.md").write_text( + "# DecisionFormer native sequence classifier\n\n" + "A merged causal Qwen3 decision checkpoint, exported in float32. The learned answer-code readout is " + "stored as `score.weight`, independently from input embeddings.\n\n" + "Use the saved tokenizer and DecisionFormer prompt and calibration metadata. HF logits are raw code " + "scores: slice to the question's active options, apply its saved temperature, then normalize. " + "Do not substitute vocabulary-token scores or normalize unused code rows.\n\n" + "This file conversion establishes no accelerator compatibility or performance claim.\n", + encoding="utf-8") + validate_classifier_checkpoint(staging, payload) + if out.exists() or out.is_symlink(): + raise FileExistsError(f"Export destination already exists: {out}") + staging.rename(out) + finally: + if staging.exists(): + if staging.is_symlink() or staging.resolve().parent != parent: + raise RuntimeError("Refusing to remove an export staging directory outside its parent") + shutil.rmtree(staging) + return out diff --git a/src/decisionformer/cli.py b/src/decisionformer/cli.py index 5c22a3e..94b9c85 100644 --- a/src/decisionformer/cli.py +++ b/src/decisionformer/cli.py @@ -172,6 +172,16 @@ def _push(args: argparse.Namespace) -> None: print(push_folder(args.checkpoint, args.repo_id, private=not args.public)) +def _export(args: argparse.Namespace) -> None: + from decisionformer.classifier import export_sequence_classifier + + try: + output = export_sequence_classifier(args.model, args.output, revision=args.revision) + except (ValueError, OSError) as error: + raise SystemExit(f"error: {error}") from None + print(json.dumps({"output": str(output), "format": args.format, "dtype": "float32"}, indent=2)) + + def _compare(args: argparse.Namespace) -> None: from decisionformer.metrics import paired_bootstrap @@ -279,7 +289,7 @@ def build_parser() -> argparse.ArgumentParser: serve.add_argument("--host", default="127.0.0.1") serve.add_argument("--port", type=int, default=8000) serve.add_argument("--backend", choices=("transformers", "vllm"), default="transformers", - help="vllm: vLLM computes the readout (Qwen3.5/3.8 code-readout checkpoints only)") + help="vllm: native Qwen3 classifier exports or Qwen3.5/3.8 code-readout checkpoints") serve.add_argument("--max-batch", type=int, default=16, help="requests arriving together are answered in one batch of up to this many") serve.add_argument("--api-key-env", default="DECISIONFORMER_API_KEY", @@ -292,6 +302,14 @@ def build_parser() -> argparse.ArgumentParser: push.add_argument("--public", action="store_true", help="create the repo as public (default: private)") push.set_defaults(handler=_push) + export = commands.add_parser("export", help="Export a merged Qwen3 code-readout model as a native HF classifier") + export.add_argument("model", help="decision checkpoint: local directory or Hub repo id") + export.add_argument("output", help="new export directory (existing paths are refused)") + export.add_argument("--revision") + export.add_argument("--format", choices=("sequence-classification",), required=True, + help="explicit output layout; sequence-classification exports CPU float32 weights") + export.set_defaults(handler=_export) + compare = commands.add_parser("compare", help="Paired family bootstrap between two prediction files") compare.add_argument("first") compare.add_argument("second") diff --git a/src/decisionformer/vllm_backend.py b/src/decisionformer/vllm_backend.py index 0f643ee..e0d5bf4 100644 --- a/src/decisionformer/vllm_backend.py +++ b/src/decisionformer/vllm_backend.py @@ -2,11 +2,13 @@ vLLM computes the answer-code logits; decisionformer renders the prompt (byte-identical to the transformers backend) and turns logits into answers with the checkpoint's per-type temperatures, so both backends answer the -same way. vLLM's decision-model support covers code-readout checkpoints whose backbone is a bare Qwen3.5/3.8 model +same way. Supported layouts are native Qwen3 sequence-classifier exports and code-readout checkpoints whose +backbone is a bare Qwen3.5/3.8 model (`Qwen3_5Model` plus `decision_config.json` and `readout.safetensors`): decisionformer exports on those bases, Decision 3.0 (`vllm-sr/d3*`) and AutoJev-format exports such as FrontiersMind/Lumma-fev-27B. Anything else raises -`UnsupportedBackend`; serve it with the default transformers backend. Needs a vLLM build with decision-model -support (`pip install decisionformer[vllm]`, vLLM nightly from 2026-10-15 on) and a CUDA or ROCm GPU. +`UnsupportedBackend`; serve it with the default transformers backend. Native classifier exports use vLLM's +pooling runner; the bare Qwen3.5 layout needs its D3 decision-model implementation. The installed vLLM runtime +must support the selected layout and hardware platform. """ from __future__ import annotations @@ -18,6 +20,7 @@ import torch from decisionformer.api import Decider, _local_dir, _prompt_name, display_name +from decisionformer.classifier import CLASSIFIER_FORMAT, validate_classifier_checkpoint from decisionformer.formats import get_format from decisionformer.model import DECISION_CONFIG, HEADS, READOUT from decisionformer.prompting import Encoded, PromptBuilder, prepare_tokenizer @@ -32,6 +35,16 @@ class UnsupportedBackend(ValueError): def check_vllm_support(root: Path, config: dict[str, Any]) -> None: """Raise UnsupportedBackend unless vLLM's decision-model path can load this checkpoint as it is.""" + if _is_classifier(config): + try: + backbone = validate_classifier_checkpoint(root, config) + except (ValueError, FileNotFoundError) as error: + raise UnsupportedBackend(f"vLLM cannot serve native classifier {root}: {error}") from error + for name in ("classifier_from_token", "method", "logit_mean", "logit_sigma"): + if backbone.get(name) is not None: + raise UnsupportedBackend(f"Native classifier {name} must be unset to preserve trained logits") + return + def refuse(reason: str) -> None: raise UnsupportedBackend(f"vLLM cannot serve {root}: {reason}. vLLM serves code-readout decision checkpoints " "with a bare Qwen3.5/3.8 backbone (Qwen3_5Model). Use the transformers backend " @@ -59,6 +72,38 @@ def refuse(reason: str) -> None: "https://wheels.vllm.ai/nightly`)") from error +def _is_classifier(config: dict[str, Any]) -> bool: + return (config.get("decisionformer") or {}).get("export_format") == CLASSIFIER_FORMAT + + +def _classifier_options(engine_options: dict[str, Any]) -> dict[str, Any]: + """Pin model semantics while leaving ordinary engine resource/dtype options configurable.""" + options = dict(engine_options) + if "model" in options or "hf_config_path" in options or options.get("quantization") is not None: + raise UnsupportedBackend("Native classifier engine options must preserve the exported model and its weights") + if options.get("runner", "pooling") not in ("pooling", "auto"): + raise UnsupportedBackend("Native classifiers require the pooling runner to preserve trained logits") + if options.get("convert", "classify") not in ("classify", "auto"): + raise UnsupportedBackend("Native classifiers require classify conversion to preserve the trained head") + pooler = options.get("pooler_config") or {} + expected = {"task": "classify", "seq_pooling_type": "LAST", "use_activation": False} + if (not isinstance(pooler, dict) + or any(key in pooler and pooler[key] not in (None, value) for key, value in expected.items()) + or pooler.get("pooling_type") not in (None, "LAST") + or any(pooler.get(key) is not None for key in ("logit_mean", "logit_sigma", "dimensions", + "tok_pooling_type", "returned_token_ids", "step_tag_id")) + or pooler.get("enable_chunked_processing", False)): + raise UnsupportedBackend("Native classifier pooling options must preserve LAST pooling and raw logits") + overrides = options.get("hf_overrides") or {} + if (not isinstance(overrides, dict) or set(overrides) - {"head_dtype"} + or overrides.get("head_dtype") not in (None, "float32")): + raise UnsupportedBackend("Native classifier HF overrides must preserve its trained FP32 head") + options.update(runner="pooling", convert="classify", hf_overrides={"head_dtype": "float32"}, + pooler_config={**pooler, **expected}) + options["pooler_config"].pop("pooling_type", None) + return options + + class VLLMDecider(Decider): """A `Decider` whose answer-code logits come from vLLM; prompts, temperatures and answers are decisionformer's.""" @@ -69,7 +114,7 @@ def __init__(self, llm: Any, builder: PromptBuilder, tokenizer: Any, config: dic self.builder = builder self.tokenizer = tokenizer self.config = config - self.device = torch.device("cpu") # answers are formatted on the host; vLLM owns the GPU + self.device = torch.device("cpu") # answers are formatted on the host; vLLM owns model execution self.name = name self.max_input_tokens = max_input_tokens self.path = path @@ -89,9 +134,15 @@ def from_pretrained(cls, name_or_path: str | Path, *, revision: str | None = Non check_vllm_support(root, config) from vllm import LLM - # Text-only: no image or video budget, so the vision tower takes no profiling memory. - options = {"model": str(root), "gpu_memory_utilization": gpu_memory_utilization, - "limit_mm_per_prompt": {"image": 0, "video": 0}, **engine_options} + if _is_classifier(config): + options = {"model": str(root), "gpu_memory_utilization": gpu_memory_utilization, + **_classifier_options(engine_options)} + if config.get("max_length"): + options.setdefault("max_model_len", config["max_length"]) + else: + # Text-only: no image or video budget, so the vision tower takes no profiling memory. + options = {"model": str(root), "gpu_memory_utilization": gpu_memory_utilization, + "limit_mm_per_prompt": {"image": 0, "video": 0}, **engine_options} if max_length: options["max_model_len"] = max_length llm = LLM(**options) diff --git a/tests/test_classifier_api.py b/tests/test_classifier_api.py new file mode 100644 index 0000000..be776af --- /dev/null +++ b/tests/test_classifier_api.py @@ -0,0 +1,112 @@ +"""The native classifier retains the decision request/response contract after export.""" + +from __future__ import annotations + +import json + +import pytest +import torch +from test_api import QUESTIONS, _bare_checkpoint + +from decisionformer import AutoDecisionModel, ContextLengthError +from decisionformer.cli import main + + +@pytest.fixture +def source(model_root): + path, _, _ = _bare_checkpoint(model_root, "qwen3", prompt="d3", attention_mode="causal") + config = json.loads((path / "decision_config.json").read_text()) + config.update(max_length=256, temperature=1.7, + temperature_by_type={"choice": 1.3, "noul": 0.6, "score": 2.1}) + (path / "decision_config.json").write_text(json.dumps(config)) + return path + + +def test_export_command_creates_an_explicit_classifier(source, tmp_path, capsys): + target = tmp_path / "native" + main(["export", str(source), str(target), "--format", "sequence-classification"]) + assert json.loads(capsys.readouterr().out)["output"] == str(target) + config = json.loads((target / "config.json").read_text()) + assert config["architectures"] == ["Qwen3ForSequenceClassification"] + with pytest.raises(SystemExit, match="exist"): + main(["export", str(source), str(target), "--format", "sequence-classification"]) + + +def _same_answers(got, expected): + assert got.keys() == expected.keys() + for key, answer in expected.items(): + assert got[key].keys() == answer.keys() + for field, value in answer.items(): + if field in ("probabilities", "noul", "score", "confidence"): + assert got[key][field] == pytest.approx(value, abs=1e-5), (key, field) + else: + assert got[key][field] == value + + +def test_native_reload_preserves_all_question_types_and_limits(source, tmp_path): + from decisionformer.classifier import export_sequence_classifier + + target = export_sequence_classifier(source, tmp_path / "native") + reference = AutoDecisionModel.from_pretrained(source, device="cpu", dtype=torch.float32) + native = AutoDecisionModel.from_pretrained(target, device="cpu", dtype=torch.float32) + requests = [{"state": state, "questions": QUESTIONS} for state in ( + "the tiny triangle is yellow .", "the large circle is red . " + "near " * 25, + {"description": "the huge square is blue ."}, "", + )] + for got, expected in zip(native.decide_many(requests), reference.decide_many(requests)): + _same_answers(got["answers"], expected["answers"]) + assert got["usage"] == expected["usage"] + assert native.max_input_tokens == reference.max_input_tokens == 256 + for kind in ("choice", "noul", "score"): + assert native.temperature_for(kind) == reference.temperature_for(kind) + for model in (reference, native): + with pytest.raises(ContextLengthError): + model.decide("red " * 400, {"q": QUESTIONS["red"]}) + with pytest.raises(ContextLengthError): + model.decide("red", {"q": {"type": "choice", "criteria": dict.fromkeys(map(str, range(256)))}}) + + +def test_native_http_and_predict_preserve_responses(source, tmp_path, capsys): + from fastapi.testclient import TestClient + + from decisionformer.classifier import export_sequence_classifier + from decisionformer.serve import create_app + + target = export_sequence_classifier(source, tmp_path / "native") + request = {"state": "the large circle is green .", "questions": QUESTIONS} + reference = AutoDecisionModel.from_pretrained(source, device="cpu", dtype=torch.float32) + native = AutoDecisionModel.from_pretrained(target, device="cpu", dtype=torch.float32) + with TestClient(create_app(native)) as client: + response = client.post("/v1/systemone", json=request) + assert response.status_code == 200 + _same_answers(response.json()["answers"], reference.decide(**request)["answers"]) + response = client.post("/v1/systemone", json={"state": "red " * 400, "questions": QUESTIONS}) + assert response.status_code == 422 + data = tmp_path / "request.jsonl" + data.write_text(json.dumps(request) + "\n") + main(["predict", str(target), str(data), "--device", "cpu"]) + got = json.loads(capsys.readouterr().out.strip().splitlines()[-1]) + _same_answers(got["answers"], reference.decide(**request)["answers"]) + + +def test_native_save_pretrained_roundtrip(source, tmp_path): + from decisionformer.classifier import export_sequence_classifier + + native = AutoDecisionModel.from_pretrained(export_sequence_classifier(source, tmp_path / "native"), device="cpu") + saved = native.save_pretrained(tmp_path / "copied") + reloaded = AutoDecisionModel.from_pretrained(saved, device="cpu") + _same_answers(reloaded.decide("red circle", QUESTIONS)["answers"], + native.decide("red circle", QUESTIONS)["answers"]) + + +def test_native_reload_refuses_missing_trained_backbone_weights(source, tmp_path): + from safetensors.torch import load_file, save_file + + from decisionformer.classifier import export_sequence_classifier + + native = export_sequence_classifier(source, tmp_path / "native") + weights = load_file(str(native / "model.safetensors")) + weights.pop("model.embed_tokens.weight") + save_file(weights, str(native / "model.safetensors")) + with pytest.raises(ValueError, match="missing|Missing|incomplete"): + AutoDecisionModel.from_pretrained(native, device="cpu", dtype=torch.float32) diff --git a/tests/test_classifier_export.py b/tests/test_classifier_export.py new file mode 100644 index 0000000..5c3e919 --- /dev/null +++ b/tests/test_classifier_export.py @@ -0,0 +1,282 @@ +"""Native HF exports preserve the trained answer head, not the original LM output rows.""" + +from __future__ import annotations + +import hashlib +import importlib +import json +from pathlib import Path + +import pytest +import torch +from safetensors.torch import load_file, save_file +from test_api import QUESTIONS, _bare_checkpoint +from transformers import AutoModelForSequenceClassification, AutoTokenizer + +from decisionformer import AutoDecisionModel +from decisionformer.schema import Decision, parse_questions + + +def _module(): + assert importlib.util.find_spec("decisionformer.classifier") is not None, "Native classifier export is not implemented" + return importlib.import_module("decisionformer.classifier") + + +def _source(root: Path) -> Path: + source, _, _ = _bare_checkpoint(root, "qwen3", prompt="d3", attention_mode="causal") + config = json.loads((source / "decision_config.json").read_text()) + config.update(max_length=256, temperature_by_type={"choice": 0.7, "noul": 1.4, "score": 1.8}) + config["decisionformer"] = {"backbone": "full", "kind": "decoder", "model_type": "qwen3", + "chat_template_kwargs": {"enable_thinking": False}, + "heads": {"default": {"type": "code_readout"}}} + (source / "decision_config.json").write_text(json.dumps(config)) + return source + + +def _hashes(root: Path) -> dict[str, str]: + return {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in root.iterdir() if p.is_file()} + + +def test_export_preserves_trained_tensors_metadata_and_native_logits(tmp_path): + module = _module() + source = _source(tmp_path) + before = _hashes(source) + original = AutoDecisionModel.from_pretrained(source, device="cpu", dtype=torch.float32) + weight = load_file(str(source / "readout.safetensors"))["weight"] + target = module.export_sequence_classifier(source, tmp_path / "classifier") + assert target == tmp_path / "classifier" + assert _hashes(source) == before + assert not (target / "readout.safetensors").exists() and not (target / "heads.safetensors").exists() + native, loading = AutoModelForSequenceClassification.from_pretrained( + target, dtype=torch.float32, output_loading_info=True) + native.eval() + assert not loading["missing_keys"] and not loading["unexpected_keys"] + assert native.config.architectures == ["Qwen3ForSequenceClassification"] + assert native.config.model_type == "qwen3" and native.score.weight.dtype == torch.float32 + assert torch.equal(native.score.weight, weight) + for key, value in original.model.backbone.model.state_dict().items(): + assert torch.equal(native.model.state_dict()[key], value) + config = json.loads((target / "decision_config.json").read_text()) + expected = json.loads((source / "decision_config.json").read_text()) + assert config["decisionformer"]["export_format"] == module.CLASSIFIER_FORMAT + assert config["decisionformer"]["export_dtype"] == "float32" + for key, value in expected.items(): + if key == "decisionformer": + assert all(config[key][k] == v for k, v in value.items()) + else: + assert config[key] == value + assert not torch.equal(native.score.weight, native.model.get_input_embeddings().weight[config["token_ids"]]) + module.validate_classifier_checkpoint(target, config) + assert torch.equal(module.classifier_readout(target), weight) + tokenizer = AutoTokenizer.from_pretrained(target) + assert tokenizer.chat_template == original.tokenizer.chat_template + assert tokenizer.pad_token_id == original.tokenizer.pad_token_id + + decisions = [Decision(id=str(i), request_id=str(i), state=state, question=parse_questions({"q": question})[0], + task="default") for i, (state, question) in enumerate([ + ("the large circle is red .", QUESTIONS["color"]), + ("the tiny square is blue . " * 4, QUESTIONS["red"]), + ("the huge triangle is green .", QUESTIONS["size"]), + ])] + encoded = [original.builder.encode(d) for d in decisions] + length = max(len(e.input_ids) for e in encoded) + ids = torch.full((len(encoded), length), tokenizer.pad_token_id, dtype=torch.long) + mask = torch.zeros_like(ids) + for i, item in enumerate(encoded): + ids[i, :len(item.input_ids)] = torch.tensor(item.input_ids) + mask[i, :len(item.input_ids)] = 1 + with torch.inference_mode(): + native_logits = native(input_ids=ids, attention_mask=mask).logits + hidden = original.model.backbone.model(input_ids=ids, attention_mask=mask).last_hidden_state + last = hidden[torch.arange(len(encoded)), torch.tensor([len(e.input_ids)-1 for e in encoded])] + manual = last.float() @ weight.T + torch.testing.assert_close(native_logits, manual, atol=1e-5, rtol=1e-5) + assert not torch.allclose(native_logits, hidden[:, -1].float() @ weight.T), "padding must matter in this fixture" + + +@pytest.mark.parametrize("existing", ["directory", "file"]) +def test_export_refuses_existing_destination(tmp_path, existing): + module = _module() + source = _source(tmp_path) + target = tmp_path / "occupied" + if existing == "directory": + target.mkdir() + (target / "keep.txt").write_text("preserve") + else: + target.write_text("preserve") + with pytest.raises(FileExistsError): + module.export_sequence_classifier(source, target) + assert (target / "keep.txt").read_text() == "preserve" if existing == "directory" else target.read_text() == "preserve" + + +@pytest.mark.parametrize("change, match", [ + ("attention", "causal"), ("pooling", "last"), ("adapter", "merged"), + ("architecture", "Qwen3"), ("heads", "single.*readout"), ("quantization", "quantiz"), + ("shape", "shape"), ("nonfinite", "finite"), +]) +def test_export_refuses_unsupported_sources_before_writing(tmp_path, change, match): + module = _module() + source = _source(tmp_path) + config = json.loads((source / "decision_config.json").read_text()) + hf_config = json.loads((source / "config.json").read_text()) + if change == "attention": + config["attention_mode"] = "noncausal_full_attention" + elif change == "pooling": + config["pooling"] = "mean" + elif change == "adapter": + config["decisionformer"] = {"backbone": "adapter"} + elif change == "architecture": + hf_config["model_type"] = "llama" + elif change == "heads": + save_file({"other": torch.zeros(1)}, str(source / "heads.safetensors")) + elif change == "quantization": + hf_config["quantization_config"] = {"quant_method": "bitsandbytes"} + else: + weight = load_file(str(source / "readout.safetensors"))["weight"] + if change == "shape": + weight = weight[:, :-1].contiguous() + else: + weight[0, 0] = torch.nan + save_file({"weight": weight}, str(source / "readout.safetensors")) + (source / "decision_config.json").write_text(json.dumps(config)) + (source / "config.json").write_text(json.dumps(hf_config)) + target = tmp_path / "refused" + with pytest.raises(ValueError, match=match): + module.export_sequence_classifier(source, target) + assert not target.exists() + + +def test_classifier_readout_loads_sharded_head_without_other_tensors(tmp_path): + module = _module() + weight = torch.arange(12, dtype=torch.float32).reshape(3, 4) + save_file({"model.embed_tokens.weight": torch.zeros(7, 4)}, str(tmp_path / "model-00001-of-00002.safetensors")) + save_file({"score.weight": weight}, str(tmp_path / "model-00002-of-00002.safetensors")) + (tmp_path / "model.safetensors.index.json").write_text(json.dumps({"weight_map": { + "model.embed_tokens.weight": "model-00001-of-00002.safetensors", + "score.weight": "model-00002-of-00002.safetensors", + }})) + assert torch.equal(module.classifier_readout(tmp_path), weight) + + +@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) +def test_export_explicitly_promotes_stored_backbone_values_to_float32(tmp_path, dtype): + from transformers import AutoModel + + module = _module() + source = _source(tmp_path) + backbone = AutoModel.from_pretrained(source).to(dtype) + backbone.save_pretrained(source) + before = _hashes(source) + target = module.export_sequence_classifier(source, tmp_path / "classifier") + native = AutoModelForSequenceClassification.from_pretrained(target, dtype=torch.float32) + for key, value in backbone.state_dict().items(): + assert torch.equal(native.model.state_dict()[key], value.float()) + assert _hashes(source) == before + config = json.loads((target / "decision_config.json").read_text()) + assert config["decisionformer"]["export_dtype"] == "float32" + + +def test_export_handles_explicit_null_extension_metadata(tmp_path): + module = _module() + source = _source(tmp_path) + config = json.loads((source / "decision_config.json").read_text()) + config["decisionformer"] = None + (source / "decision_config.json").write_text(json.dumps(config)) + target = module.export_sequence_classifier(source, tmp_path / "classifier") + result = json.loads((target / "decision_config.json").read_text()) + assert result["decisionformer"]["export_format"] == module.CLASSIFIER_FORMAT + + +@pytest.mark.parametrize("field, value", [("classifier_from_token", ["A"]), ("method", "from_2_way_softmax"), + ("logit_mean", 0.0), ("logit_sigma", 1.0)]) +def test_native_validation_refuses_logit_or_head_transformations(tmp_path, field, value): + module = _module() + source = _source(tmp_path) + target = module.export_sequence_classifier(source, tmp_path / "classifier") + config = json.loads((target / "decision_config.json").read_text()) + hf_config = json.loads((target / "config.json").read_text()) + hf_config[field] = value + (target / "config.json").write_text(json.dumps(hf_config)) + with pytest.raises(ValueError, match=field): + module.validate_classifier_checkpoint(target, config) + + +@pytest.mark.parametrize("missing", [True, False], ids=["missing", "unexpected"]) +def test_export_refuses_incomplete_or_unexpected_backbone_weights(tmp_path, missing): + module = _module() + source = _source(tmp_path) + tensors = load_file(str(source / "model.safetensors")) + if missing: + del tensors["layers.0.self_attn.q_proj.weight"] + else: + tensors["unexpected.weight"] = torch.ones(2) + save_file(tensors, str(source / "model.safetensors")) + before = _hashes(source) + target = tmp_path / "classifier" + with pytest.raises(ValueError, match="[Bb]ackbone.*(missing|unexpected|match)"): + module.export_sequence_classifier(source, target) + assert not target.exists() + assert _hashes(source) == before + + +@pytest.mark.parametrize("failure", ["weights", "tokenizer", "validation"]) +def test_failed_staging_preserves_source_and_allows_retry(tmp_path, monkeypatch, failure): + from transformers import PreTrainedModel + + module = _module() + source = _source(tmp_path) + before = _hashes(source) + target = tmp_path / "classifier" + tokenizer = AutoTokenizer.from_pretrained(source) + + def fail(*args, **kwargs): + raise OSError("injected export write failure") + + with monkeypatch.context() as patch: + if failure == "weights": + patch.setattr(PreTrainedModel, "save_pretrained", fail) + elif failure == "tokenizer": + patch.setattr(tokenizer, "save_pretrained", fail) + patch.setattr(AutoTokenizer, "from_pretrained", lambda *args, **kwargs: tokenizer) + else: + patch.setattr(module, "validate_classifier_checkpoint", fail) + with pytest.raises(OSError, match="injected export write failure"): + module.export_sequence_classifier(source, target) + assert not target.exists() + assert not list(tmp_path.glob(".classifier-*")) + assert _hashes(source) == before + assert module.export_sequence_classifier(source, target) == target + + +@pytest.mark.parametrize("occupied", ["empty_directory", "directory", "file"]) +def test_export_refuses_a_destination_created_during_staging(tmp_path, monkeypatch, occupied): + from transformers import PreTrainedModel + + module = _module() + source = _source(tmp_path) + before = _hashes(source) + target = tmp_path / "classifier" + save = PreTrainedModel.save_pretrained + + def concurrent_destination(self, *args, **kwargs): + result = save(self, *args, **kwargs) + if occupied == "file": + target.write_text("keep") + else: + target.mkdir(exist_ok=True) + if occupied == "directory": + (target / "keep.txt").write_text("keep") + return result + + with monkeypatch.context() as patch: + patch.setattr(PreTrainedModel, "save_pretrained", concurrent_destination) + with pytest.raises(FileExistsError): + module.export_sequence_classifier(source, target) + if occupied == "file": + assert target.read_text() == "keep" + elif occupied == "directory": + assert sorted(p.name for p in target.iterdir()) == ["keep.txt"] + else: + assert not list(target.iterdir()) + assert not list(tmp_path.glob(".classifier-*")) + assert _hashes(source) == before diff --git a/tests/test_classifier_runtime.py b/tests/test_classifier_runtime.py new file mode 100644 index 0000000..b0209aa --- /dev/null +++ b/tests/test_classifier_runtime.py @@ -0,0 +1,93 @@ +"""Opt-in parity with an actual vLLM engine, using an offline synthetic checkpoint. + +The readout is deliberately changed after random initialization. This fixture +tests preservation of that independent head, not training quality or speed. +""" + +from __future__ import annotations + +import argparse +import json +import os +import runpy +from pathlib import Path + +import pytest +import torch +from conftest import make_tokenizer +from safetensors.torch import save_file +from transformers import Qwen3Config, Qwen3ForCausalLM + +from decisionformer.classifier import export_sequence_classifier + +pytestmark = [ + pytest.mark.slow, + pytest.mark.skipif(os.environ.get("DECISIONFORMER_RUN_VLLM") != "1", + reason="Set DECISIONFORMER_RUN_VLLM=1 in a supported vLLM environment"), +] + + +def _checkpoint(root: Path) -> Path: + tokenizer = make_tokenizer("decoder") + torch.manual_seed(19) + config = Qwen3Config(vocab_size=len(tokenizer), hidden_size=64, intermediate_size=128, num_hidden_layers=2, + num_attention_heads=2, num_key_value_heads=2, head_dim=32, max_position_embeddings=256, + pad_token_id=tokenizer.pad_token_id, bos_token_id=None, + eos_token_id=tokenizer.eos_token_id, tie_word_embeddings=True, attention_dropout=0.0) + model = Qwen3ForCausalLM(config) + source = root / "source" + model.model.save_pretrained(source) + tokenizer.save_pretrained(source) + codes = list("ABCDEFGH") + token_ids = [tokenizer.encode(code, add_special_tokens=False)[0] for code in codes] + original = model.lm_head.weight[token_ids].detach().float().clone() + readout = original + torch.randn_like(original) * 0.05 + assert not torch.equal(readout, original), "the independent answer head must differ from the LM rows" + save_file({"weight": readout.contiguous()}, str(source / "readout.safetensors")) + decision_config = { + "format_version": 1, "format_id": "d3-code-readout-v1", "prompt": "d3", + "codes": codes, "token_ids": token_ids, "attention_mode": "causal", "pooling": "last", + "max_length": 256, "temperature": 1.2, + "temperature_by_type": {"choice": 0.7, "noul": 1.4, "score": 1.8}, + "decisionformer": {"backbone": "full", "kind": "decoder", "model_type": "qwen3", + "chat_template_kwargs": {"enable_thinking": False}, + "heads": {"default": {"type": "code_readout"}}}, + } + (source / "decision_config.json").write_text(json.dumps(decision_config), encoding="utf-8") + return source + + +def test_native_classifier_runs_in_actual_vllm(tmp_path, monkeypatch): + pytest.importorskip("vllm") + monkeypatch.setenv("HF_HUB_OFFLINE", "1") + monkeypatch.setenv("TRANSFORMERS_OFFLINE", "1") + if not torch.cuda.is_available(): + monkeypatch.setenv("VLLM_CPU_KVCACHE_SPACE", os.environ.get("VLLM_CPU_KVCACHE_SPACE", "1")) + monkeypatch.setenv("VLLM_CPU_OMP_THREADS_BIND", os.environ.get("VLLM_CPU_OMP_THREADS_BIND", "nobind")) + source = _checkpoint(tmp_path) + exported = export_sequence_classifier(source, tmp_path / "classifier") + questions = { + "color": {"type": "choice", "instructions": "What is the color ?", + "criteria": dict.fromkeys(["red", "blue", "green", "yellow"])}, + "red": {"type": "noul", "instructions": "Is it red ?"}, + "size": {"type": "score", "instructions": "How big ?", "criteria": ["small", "medium", "large"]}, + "eight": {"type": "choice", "instructions": "Choose the best matching option.", + "criteria": dict.fromkeys(["red", "blue", "green", "yellow", "tiny", "small", "large", "huge"])}, + } + states = ["the large circle is red .", "the tiny square is blue . " * 5, + {"object": "triangle", "color": "green"}, ""] + requests = tmp_path / "requests.jsonl" + requests.write_text("".join(json.dumps({"state": state, "questions": questions}) + "\n" for state in states), + encoding="utf-8") + example = runpy.run_path(str(Path(__file__).resolve().parents[1] / "examples" / "compare_classifier.py")) + args = argparse.Namespace(source=str(source), exported=str(exported), requests=requests, backend="vllm", + device="cpu", dtype="float32", atol=1e-4, + engine_options=json.dumps({"enforce_eager": True, "max_num_seqs": 16, + "max_num_batched_tokens": 1024, + "enable_prefix_caching": False, + "gpu_memory_utilization": 0.1})) + result = example["compare"](args) + print(json.dumps(result, indent=2, allow_nan=False)) + assert result["passed"], result + assert result["requests"] == 4 and result["questions"] == 16 + assert result["choice_mismatches"] == result["prompt_mismatches"] == 0 diff --git a/tests/test_vllm_backend.py b/tests/test_vllm_backend.py index 6a0bd9c..f592d26 100644 --- a/tests/test_vllm_backend.py +++ b/tests/test_vllm_backend.py @@ -1,11 +1,12 @@ -"""The vLLM API backend: which checkpoints it refuses, and that it answers exactly like the transformers backend. +"""Offline adapter checks using real HF model math behind a small fake engine surface. -vLLM itself needs a GPU, so these tests stand in a fake `vllm` that does what vLLM's Decision 3.0 model does: run the -bare backbone over the prompt tokens and score the answer codes at the last token with `readout.safetensors`. +These tests do not execute vLLM or establish engine/platform compatibility. D3 fixtures run the bare backbone +and separate readout; native fixtures run an actual Hugging Face sequence classifier. """ from __future__ import annotations +import json import sys import types from pathlib import Path @@ -23,14 +24,19 @@ class FakeLLM: - """vLLM's offline API surface the backend uses, computing D3ForSequenceClassification's raw code logits.""" + """The engine surface used by the adapter, computing raw logits with real Hugging Face models.""" def __init__(self, model: str, max_model_len: int = 512, **options) -> None: - from transformers import AutoModel + from transformers import AutoModel, AutoModelForSequenceClassification self.options = options - self.backbone = AutoModel.from_pretrained(model).eval() - self.readout = load_file(str(Path(model) / "readout.safetensors"))["weight"] + config = json.loads((Path(model) / "config.json").read_text()) + self.classifier = None + if config.get("architectures") == ["Qwen3ForSequenceClassification"]: + self.classifier = AutoModelForSequenceClassification.from_pretrained(model).eval() + else: + self.backbone = AutoModel.from_pretrained(model).eval() + self.readout = load_file(str(Path(model) / "readout.safetensors"))["weight"] self.llm_engine = types.SimpleNamespace(model_config=types.SimpleNamespace(max_model_len=max_model_len)) self.calls: list[int] = [] @@ -41,8 +47,11 @@ def classify(self, prompts, *, pooling_params=None, use_tqdm=True): for prompt in prompts: ids = torch.tensor([prompt["prompt_token_ids"]]) with torch.no_grad(): - hidden = self.backbone(input_ids=ids, attention_mask=torch.ones_like(ids)).last_hidden_state[0, -1] - logits = (self.readout @ hidden).tolist() + if self.classifier is not None: + logits = self.classifier(input_ids=ids, attention_mask=torch.ones_like(ids)).logits[0].tolist() + else: + hidden = self.backbone(input_ids=ids, attention_mask=torch.ones_like(ids)).last_hidden_state[0, -1] + logits = (self.readout @ hidden).tolist() outputs.append(types.SimpleNamespace(outputs=types.SimpleNamespace(probs=logits))) return outputs @@ -115,3 +124,72 @@ def test_vllm_backend_needs_decision_support_in_vllm(model_root, monkeypatch): def test_serve_command_reports_unsupported_backend(trained, fake_vllm): # noqa: F811 with pytest.raises(SystemExit, match="vLLM cannot serve"): main(["serve", str(trained / "best"), "--backend", "vllm"]) + + +@pytest.fixture +def native_classifier(model_root, tmp_path): + """A native HF artifact built independently of the export implementation under test elsewhere.""" + from transformers import AutoModel, AutoTokenizer, Qwen3ForSequenceClassification + + source, readout, _ = _bare_checkpoint(model_root, "qwen3", prompt="d3", attention_mode="causal") + backbone = AutoModel.from_pretrained(source) + backbone.config.num_labels = len(readout) + backbone.config.head_dtype = "float32" + classifier = Qwen3ForSequenceClassification(backbone.config) + classifier.model.load_state_dict(backbone.state_dict(), strict=True) + with torch.no_grad(): + classifier.score.weight.copy_(readout) + output = tmp_path / "native-classifier" + classifier.save_pretrained(output) + AutoTokenizer.from_pretrained(source).save_pretrained(output) + config = json.loads((source / "decision_config.json").read_text()) + config.update(max_length=200, temperature_by_type={"noul": 0.7, "score": 1.3}) + config["decisionformer"] = {"export_format": "sequence_classification", "backbone": "full"} + (output / "decision_config.json").write_text(json.dumps(config)) + # Use precisely the same calibration for the existing canonical reference. + reference_config = {**config, "decisionformer": {}} + (source / "decision_config.json").write_text(json.dumps(reference_config)) + return output, source + + +def test_native_classifier_backend_preserves_decision_contract(native_classifier, fake_vllm, monkeypatch): + path, source = native_classifier + monkeypatch.setitem(sys.modules, "vllm.model_executor.models.d3", None) + decider = VLLMDecider.from_pretrained(path) + assert decider.llm.options["runner"] == "pooling" + assert decider.llm.options["convert"] == "classify" + assert decider.max_input_tokens == 200, "native serving keeps the trained decision context limit" + assert decider.llm.options["pooler_config"] == { + "task": "classify", "seq_pooling_type": "LAST", "use_activation": False, + } + assert decider.llm.options["hf_overrides"]["head_dtype"] == "float32" + reference = AutoDecisionModel.from_pretrained(source, device="cpu", dtype=torch.float32) + requests = [{"state": state, "questions": QUESTIONS} for state in ( + "the huge square is blue .", "the circle is red . and the large triangle is near .", + )] + actual, expected = decider.decide_many(requests), reference.decide_many(requests) + assert decider.llm.calls == [len(requests) * len(QUESTIONS)] + for a, b in zip(actual, expected): + assert a["usage"] == b["usage"] + for key, answer in b["answers"].items(): + for field in ("probabilities", "noul", "score", "confidence"): + if field in answer: + assert a["answers"][key][field] == pytest.approx(answer[field], abs=1e-5), (key, field) + assert a["answers"][key].get("choice") == answer.get("choice") + + +@pytest.mark.parametrize("options", [ + {"runner": "generate"}, + {"convert": "embed"}, + {"quantization": "awq"}, + {"pooler_config": {"seq_pooling_type": "MEAN"}}, + {"pooler_config": {"use_activation": True}}, + {"pooler_config": {"logit_mean": 1.0}}, + {"pooler_config": {"logit_sigma": 2.0}}, + {"hf_overrides": {"classifier_from_token": ["A", "B"]}}, + {"hf_overrides": {"head_dtype": "model"}}, +]) +def test_native_classifier_rejects_semantic_engine_overrides(native_classifier, fake_vllm, options): + path, _ = native_classifier + with pytest.raises(UnsupportedBackend, match="preserve"): + VLLMDecider.from_pretrained(path, **options)