From 9e6989d582a79d14bec39740c738e5152395e378 Mon Sep 17 00:00:00 2001 From: Rashid Kaleem <230885705+arekay-nv@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:56:12 -0500 Subject: [PATCH 1/2] Salt failure is a hard error Signed-off-by: Rashid Kaleem <230885705+arekay-nv@users.noreply.github.com> --- .../commands/benchmark/execute.py | 6 + .../dataset_manager/dataset.py | 94 +++++++++------ tests/unit/commands/test_benchmark.py | 45 ++++++- .../dataset_manager/test_salted_dataset.py | 112 +++++++++--------- 4 files changed, 166 insertions(+), 91 deletions(-) diff --git a/src/inference_endpoint/commands/benchmark/execute.py b/src/inference_endpoint/commands/benchmark/execute.py index 6c315cad8..14c9d44b5 100644 --- a/src/inference_endpoint/commands/benchmark/execute.py +++ b/src/inference_endpoint/commands/benchmark/execute.py @@ -367,6 +367,12 @@ def _load_datasets( except Exception as e: raise SetupError(f"Failed to load dataset: {e}") from e + # Fail fast on a warmup dataset that salt cannot bust — at load time, + # before any worker/aggregator subprocess is spawned. + warmup = config.settings.warmup + if warmup.enabled and warmup.salt: + dataloader.validate_saltable() + if perf_cfg.accuracy_config is not None: accuracy_config = perf_cfg.accuracy_config if accuracy_config.num_repeats != 1: diff --git a/src/inference_endpoint/dataset_manager/dataset.py b/src/inference_endpoint/dataset_manager/dataset.py index bd259d7a1..6d4e160d6 100644 --- a/src/inference_endpoint/dataset_manager/dataset.py +++ b/src/inference_endpoint/dataset_manager/dataset.py @@ -30,6 +30,7 @@ from datasets import load_dataset, load_from_disk from ..config.schema import APIType, ModelParams +from ..exceptions import DatasetValidationError from .transforms import ( ColumnFilter, Transform, @@ -257,6 +258,23 @@ def load_from_huggingface( return ds[split].to_pandas() +def _salt_violation(sample: Any) -> str | None: + """Return a human-readable reason a sample cannot be salted, or None if it can. + + Salt requires a dict sample with a str 'prompt' and no 'input_tokens' (which + adapters send verbatim, so a salted 'prompt' would not reach the server). + """ + if not isinstance(sample, dict): + return f"is a {type(sample).__name__}, not a dict" + if "input_tokens" in sample: + return "has 'input_tokens' (salt cannot bust a pre-tokenized cache)" + if "prompt" not in sample: + return "has no 'prompt' field" + if not isinstance(sample["prompt"], str): + return f"has a 'prompt' of type {type(sample['prompt']).__name__}, not str" + return None + + class Dataset: """Class for loading and managing benchmark datasets. @@ -437,55 +455,57 @@ def load_sample(self, index: int) -> Any: data = self._apply_salt(data) return data + def validate_saltable(self) -> None: + """Raise if any loaded sample cannot be salted. + + salt requires a dict sample with a text ('str') 'prompt' and no + 'input_tokens' (adapters send those verbatim, so a salted 'prompt' would + never reach the server). A sample salt cannot bust would silently defeat + cache-busting, so it is rejected rather than skipped. Called before any + load is issued — at benchmark setup and again from with_salt(). + + Raises: + DatasetValidationError: naming the first offending sample. + """ + if self.data is None: + return + for i, sample in enumerate(self.data): + reason = _salt_violation(sample) + if reason is not None: + raise DatasetValidationError( + f"salt=True requires every sample to be a dict with a text " + f"'prompt' and no 'input_tokens', but sample {i} {reason}. " + f"Disable salt (--warmup-salt / warmup.salt: false) or use a " + f"text-prompt dataset." + ) + def with_salt(self, rng: random.Random) -> "Dataset": """Return a shallow copy of this dataset that salts each load_sample() call. The returned dataset shares the same loaded data — no re-loading needed. Each load_sample() call on the returned dataset prepends a unique hex salt - derived from rng to the prompt field, preventing KV-cache reuse. + derived from rng to the 'prompt' field, preventing KV-cache reuse. + + Validates every sample first (see validate_saltable), so a dataset salt + cannot bust fails here rather than silently issuing unsalted prompts. + + Raises: + DatasetValidationError: if any sample cannot be salted. """ + self.validate_saltable() clone = copy.copy(self) clone._salt_rng = rng return clone - def _apply_salt(self, data: Any) -> Any: - """Prepend a unique salt to the prompt field of a sample dict.""" + def _apply_salt(self, data: dict[str, Any]) -> dict[str, Any]: + """Prepend a unique salt to the 'prompt' field. + + with_salt() has validated every sample, so ``data`` is guaranteed to be a + dict with a str 'prompt' and no 'input_tokens'. + """ assert self._salt_rng is not None - if not isinstance(data, dict): - return data - if "input_tokens" in data and "prompt" not in data: - self.logger.warning( - "salt=True: sample has 'input_tokens' but no 'prompt' — " - "salt cannot be applied to pre-tokenized input; KV-cache reuse may not be prevented" - ) - return data - if "input_tokens" in data and "prompt" in data: - self.logger.warning( - "salt=True: sample has both 'input_tokens' and 'prompt' — " - "salt applied to 'prompt' only; adapters that use 'input_tokens' " - "directly will still reuse the KV cache" - ) - if "prompt" not in data: - return data - prompt = data["prompt"] salt = self._salt_rng.randbytes(8).hex() - if isinstance(prompt, str): - return {**data, "prompt": f"[{salt}] {prompt}"} - if isinstance(prompt, list) and prompt: - # Find the first text part at any index (image-first prompts place text at index 1+) - for i, part in enumerate(prompt): - if isinstance(part, dict) and part.get("type") == "text": - salted_parts = [ - *prompt[:i], - {**part, "text": f"[{salt}] {part['text']}"}, - *prompt[i + 1 :], - ] - return {**data, "prompt": salted_parts} - self.logger.warning( - "salt=True: multimodal prompt has no text part — " - "salt cannot be applied; KV-cache reuse may not be prevented" - ) - return data # unsupported prompt type — skip salting + return {**data, "prompt": f"[{salt}] {data['prompt']}"} def num_samples(self) -> int: assert self.data is not None, "Dataset not loaded. Call load() first." diff --git a/tests/unit/commands/test_benchmark.py b/tests/unit/commands/test_benchmark.py index 491dc72b6..d58923711 100644 --- a/tests/unit/commands/test_benchmark.py +++ b/tests/unit/commands/test_benchmark.py @@ -77,7 +77,10 @@ from inference_endpoint.dataset_manager.dataset import Dataset from inference_endpoint.endpoint_client.config import HTTPClientConfig from inference_endpoint.evaluation.scoring import Scorer -from inference_endpoint.exceptions import InputValidationError, SetupError +from inference_endpoint.exceptions import ( + InputValidationError, + SetupError, +) from inference_endpoint.load_generator.sample_order import create_sample_order from inference_endpoint.load_generator.session import PhaseType from inference_endpoint.metrics.metric import Throughput @@ -221,6 +224,46 @@ def test_dataset_string_coercion( assert ds.accuracy_config.eval_method == acc_eval_method +@pytest.mark.unit +class TestLoadDatasetsSaltValidation: + """_load_datasets validates salt-compatibility at dataset-load time — before + any worker/aggregator subprocess is spawned — when warmup salt is enabled. + """ + + def _config(self, tmp_path: Path, warmup: WarmupConfig) -> OfflineConfig: + ds = tmp_path / "perf.jsonl" + ds.write_text('{"prompt": "hello world"}\n{"prompt": "second prompt"}\n') + return OfflineConfig( + endpoint_config={"endpoints": ["http://test:8000"]}, + model_params={"name": "test-model"}, + datasets=[{"path": str(ds)}], + settings=OfflineSettings( + client=HTTPClientConfig( + num_workers=1, warmup_connections=0, max_connections=10 + ), + warmup=warmup, + ), + ) + + @patch.object(Dataset, "validate_saltable") + def test_validates_when_warmup_salt_enabled(self, mock_validate, tmp_path): + config = self._config(tmp_path, WarmupConfig(enabled=True, salt=True)) + _load_datasets(config, tmp_path, TestMode.PERF) + mock_validate.assert_called_once() + + @patch.object(Dataset, "validate_saltable") + def test_skips_validation_when_warmup_disabled(self, mock_validate, tmp_path): + config = self._config(tmp_path, WarmupConfig(enabled=False, salt=True)) + _load_datasets(config, tmp_path, TestMode.PERF) + mock_validate.assert_not_called() + + @patch.object(Dataset, "validate_saltable") + def test_skips_validation_when_salt_off(self, mock_validate, tmp_path): + config = self._config(tmp_path, WarmupConfig(enabled=True, salt=False)) + _load_datasets(config, tmp_path, TestMode.PERF) + mock_validate.assert_not_called() + + class TestCommandHandlers: """Test offline/online/from_config handlers (mock run_benchmark).""" diff --git a/tests/unit/dataset_manager/test_salted_dataset.py b/tests/unit/dataset_manager/test_salted_dataset.py index acff8900f..329b17fa2 100644 --- a/tests/unit/dataset_manager/test_salted_dataset.py +++ b/tests/unit/dataset_manager/test_salted_dataset.py @@ -17,11 +17,11 @@ import random import re -from unittest.mock import MagicMock import pandas as pd import pytest from inference_endpoint.dataset_manager.dataset import Dataset +from inference_endpoint.exceptions import DatasetValidationError def _make_loaded_dataset(rows: list[dict]) -> Dataset: @@ -31,7 +31,6 @@ def _make_loaded_dataset(rows: list[dict]) -> Dataset: ds.transforms = None ds.repeats = 1 ds.data = list(rows) - ds.logger = MagicMock() ds._salt_rng = None return ds @@ -143,77 +142,84 @@ def test_seeded_rng_is_reproducible(self): @pytest.mark.unit -class TestSaltPassthrough: - """Samples without a 'prompt' key, or non-dict samples, are passed through unchanged.""" +class TestSaltValidation: + """with_salt() hard-errors up front unless every sample has a text 'prompt'. - def test_dict_without_prompt_key_is_unchanged(self): + salt=True guarantees a KV-cache-busting prefix; a sample it cannot salt is a + configuration error, not something to skip silently. Validation runs in + with_salt() (before any load is issued), so the error names the offending + sample and no partial warmup runs against an unsalted dataset. + """ + + def test_dict_without_prompt_key_raises(self): inner = _make_loaded_dataset([{"question": "what is 2+2?", "answer": "4"}]) - sd = inner.with_salt(random.Random()) - assert sd.load_sample(0) == {"question": "what is 2+2?", "answer": "4"} + with pytest.raises(DatasetValidationError, match="prompt"): + inner.with_salt(random.Random()) - def test_empty_dict_is_unchanged(self): + def test_empty_dict_raises(self): inner = _make_loaded_dataset([{}]) - sd = inner.with_salt(random.Random()) - assert sd.load_sample(0) == {} + with pytest.raises(DatasetValidationError, match="prompt"): + inner.with_salt(random.Random()) - def test_non_dict_sample_is_returned_as_is(self): + def test_non_dict_sample_raises(self): inner = _make_loaded_dataset([{"prompt": "x"}]) inner.data = ["raw string sample"] - sd = inner.with_salt(random.Random()) - assert sd.load_sample(0) == "raw string sample" + with pytest.raises(DatasetValidationError, match="dict"): + inner.with_salt(random.Random()) - def test_multimodal_list_prompt_first_text_part_is_salted(self): + def test_multimodal_list_prompt_raises(self): content_parts = [ {"type": "text", "text": "describe this image"}, {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}}, ] inner = _make_loaded_dataset([{"prompt": content_parts}]) - sd = inner.with_salt(random.Random()) - parts = sd.load_sample(0)["prompt"] - assert isinstance(parts, list) - assert len(parts) == 2 - assert re.match(r"^\[([0-9a-f]{16})\] describe this image$", parts[0]["text"]) - assert parts[1] == content_parts[1] - - def test_multimodal_image_first_text_at_index_1_is_salted(self): - content_parts = [ - {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}}, - {"type": "text", "text": "what do you see?"}, - ] - inner = _make_loaded_dataset([{"prompt": content_parts}]) - sd = inner.with_salt(random.Random()) - parts = sd.load_sample(0)["prompt"] - assert parts[0] == content_parts[0] - assert re.match(r"^\[([0-9a-f]{16})\] what do you see\?$", parts[1]["text"]) + with pytest.raises(DatasetValidationError, match="str"): + inner.with_salt(random.Random()) - def test_multimodal_list_prompt_original_not_mutated(self): - content_parts = [{"type": "text", "text": "original text"}] - inner = _make_loaded_dataset([{"prompt": content_parts}]) - sd = inner.with_salt(random.Random()) - sd.load_sample(0) - assert inner.data[0]["prompt"][0]["text"] == "original text" - - def test_unknown_prompt_type_is_not_salted(self): + def test_non_str_prompt_raises(self): inner = _make_loaded_dataset([{"prompt": 42}]) - sd = inner.with_salt(random.Random()) - assert sd.load_sample(0) == {"prompt": 42} + with pytest.raises(DatasetValidationError, match="str"): + inner.with_salt(random.Random()) - def test_input_tokens_only_warns_and_passes_through(self): + def test_input_tokens_only_raises(self): inner = _make_loaded_dataset([{"input_tokens": [1, 2, 3]}]) - sd = inner.with_salt(random.Random()) - result = sd.load_sample(0) - assert result == {"input_tokens": [1, 2, 3]} - sd.logger.warning.assert_called_once() - assert "input_tokens" in sd.logger.warning.call_args[0][0] + with pytest.raises(DatasetValidationError, match="input_tokens"): + inner.with_salt(random.Random()) - def test_input_tokens_and_prompt_warns_and_salts_prompt(self): + def test_input_tokens_and_prompt_raises(self): inner = _make_loaded_dataset([{"input_tokens": [1, 2, 3], "prompt": "hello"}]) + with pytest.raises(DatasetValidationError, match="input_tokens"): + inner.with_salt(random.Random()) + + def test_error_names_offending_sample_index(self): + inner = _make_loaded_dataset([{"prompt": "ok"}, {"prompt": 42}]) + with pytest.raises(DatasetValidationError, match=r"\b1\b"): + inner.with_salt(random.Random()) + + def test_valid_str_prompt_dataset_does_not_raise(self): + inner = _make_loaded_dataset([{"prompt": "a"}, {"prompt": "b"}]) sd = inner.with_salt(random.Random()) - result = sd.load_sample(0) - assert result["input_tokens"] == [1, 2, 3] - assert result["prompt"].startswith("[") - sd.logger.warning.assert_called_once() - assert "input_tokens" in sd.logger.warning.call_args[0][0] + assert sd.load_sample(0)["prompt"].startswith("[") + + def test_data_none_does_not_raise(self): + inner = _make_loaded_dataset([{"prompt": "x"}]) + inner.data = None + # No samples to salt (e.g. EmptyDataset) — nothing to validate. + assert inner.with_salt(random.Random())._salt_rng is not None + + def test_data_empty_list_does_not_raise(self): + inner = _make_loaded_dataset([]) + # Zero samples — no violation, so no error. + assert inner.with_salt(random.Random())._salt_rng is not None + + def test_validate_saltable_noop_on_valid(self): + inner = _make_loaded_dataset([{"prompt": "a"}, {"prompt": "b"}]) + assert inner.validate_saltable() is None + + def test_validate_saltable_raises_on_bad_sample(self): + inner = _make_loaded_dataset([{"prompt": "ok"}, {"input_tokens": [1, 2]}]) + with pytest.raises(DatasetValidationError, match="input_tokens"): + inner.validate_saltable() @pytest.mark.unit From 6f6c16590a86be31bb6f0d6be4c5391f19cecd23 Mon Sep 17 00:00:00 2001 From: Rashid Kaleem <230885705+arekay-nv@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:49:33 -0500 Subject: [PATCH 2/2] fix: address review feedback on salt-failure hard error - Default warmup.salt to False so pre-tokenized (input_tokens) workloads no longer hard-fail on a plain --warmup; the hard error now fires only when salt is explicitly enabled. - Replace raw-string salt-violation reasons with a typed DatasetValidationError.Reason enum plus optional detail; _salt_violation becomes _can_salt returning the enum. UNSPECIFIED covers not-yet-mapped --dataset parse errors. - validate_saltable: assert on unloaded data (no silent skip); clarify the error index is into the loaded post-transform order; document the intentional whole-dataset (fail-on-any-invalid) strictness and the deliberate pre-spawn check. - Tests: unpatched integration coverage (offline/online raise, accuracy-only skip), agentic messages sample, typed-reason assertions; drop brittle index regex. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../commands/benchmark/cli.py | 9 +- .../commands/benchmark/execute.py | 4 +- src/inference_endpoint/config/schema.py | 13 ++- .../templates/concurrency_template_full.yaml | 2 +- .../templates/offline_template_full.yaml | 2 +- .../templates/online_template_full.yaml | 2 +- .../dataset_manager/dataset.py | 44 ++++++---- src/inference_endpoint/exceptions.py | 29 ++++++- tests/unit/commands/test_benchmark.py | 87 +++++++++++++++++-- .../dataset_manager/test_salted_dataset.py | 40 ++++++++- 10 files changed, 194 insertions(+), 38 deletions(-) diff --git a/src/inference_endpoint/commands/benchmark/cli.py b/src/inference_endpoint/commands/benchmark/cli.py index 0edb4da79..e17400412 100644 --- a/src/inference_endpoint/commands/benchmark/cli.py +++ b/src/inference_endpoint/commands/benchmark/cli.py @@ -67,9 +67,14 @@ def _run( f"{'.'.join(str(p) for p in err['loc'])}: {err['msg']}" for err in e.errors() ) - raise DatasetValidationError(f"Invalid --dataset: {msgs}") from e + # --dataset parse failures aren't yet mapped to a specific Reason. + raise DatasetValidationError( + DatasetValidationError.Reason.UNSPECIFIED, f"Invalid --dataset: {msgs}" + ) from e except ValueError as e: - raise DatasetValidationError(f"Invalid --dataset: {e}") from e + raise DatasetValidationError( + DatasetValidationError.Reason.UNSPECIFIED, f"Invalid --dataset: {e}" + ) from e if config.audit is None: run_benchmark(config, mode) return diff --git a/src/inference_endpoint/commands/benchmark/execute.py b/src/inference_endpoint/commands/benchmark/execute.py index e4b6805ea..3e90a1dcf 100644 --- a/src/inference_endpoint/commands/benchmark/execute.py +++ b/src/inference_endpoint/commands/benchmark/execute.py @@ -436,7 +436,9 @@ def _load_datasets( raise SetupError(f"Failed to load dataset: {e}") from e # Fail fast on a warmup dataset that salt cannot bust — at load time, - # before any worker/aggregator subprocess is spawned. + # before any worker/aggregator subprocess is spawned. with_salt() runs + # the same check later; this earlier call is deliberate (not redundant), + # so an invalid dataset aborts before the subprocess fan-out. warmup = config.settings.warmup if warmup.enabled and warmup.salt: dataloader.validate_saltable() diff --git a/src/inference_endpoint/config/schema.py b/src/inference_endpoint/config/schema.py index b4d6f6c5e..3f6c0238a 100644 --- a/src/inference_endpoint/config/schema.py +++ b/src/inference_endpoint/config/schema.py @@ -759,10 +759,19 @@ class WarmupConfig(BaseModel): bool, cyclopts.Parameter( alias="--warmup-salt", - help="Prepend a unique random hex salt to each warmup prompt", + help=( + "Prepend a unique random hex salt to each warmup prompt. Requires " + "text-'prompt' samples; enabling it on a pre-tokenized " + "('input_tokens') dataset is a hard error." + ), ), ] = Field( - True, description="Prepend a unique random hex salt to each warmup prompt" + False, + description=( + "Prepend a unique random hex salt to each warmup prompt. Requires " + "text-'prompt' samples; enabling it on a pre-tokenized " + "('input_tokens') dataset is a hard error." + ), ) drain: Annotated[ bool, diff --git a/src/inference_endpoint/config/templates/concurrency_template_full.yaml b/src/inference_endpoint/config/templates/concurrency_template_full.yaml index 9c306a841..bfa797fe8 100644 --- a/src/inference_endpoint/config/templates/concurrency_template_full.yaml +++ b/src/inference_endpoint/config/templates/concurrency_template_full.yaml @@ -93,7 +93,7 @@ settings: warmup: enabled: false # Enable warmup phase before performance run n_requests: null # Warmup request count (None = full dataset once) - salt: true # Prepend a unique random hex salt to each warmup prompt + salt: false # Prepend a unique random hex salt to each warmup prompt. Requires text-'prompt' samples; enabling it on a pre-tokenized ('input_tokens') dataset is a hard error. drain: false # Drain in-flight warmup requests before starting the performance phase warmup_random_seed: 42 # RNG seed for warmup scheduling and sample ordering profiling: diff --git a/src/inference_endpoint/config/templates/offline_template_full.yaml b/src/inference_endpoint/config/templates/offline_template_full.yaml index 29d52a270..faa7649eb 100644 --- a/src/inference_endpoint/config/templates/offline_template_full.yaml +++ b/src/inference_endpoint/config/templates/offline_template_full.yaml @@ -93,7 +93,7 @@ settings: warmup: enabled: false # Enable warmup phase before performance run n_requests: null # Warmup request count (None = full dataset once) - salt: true # Prepend a unique random hex salt to each warmup prompt + salt: false # Prepend a unique random hex salt to each warmup prompt. Requires text-'prompt' samples; enabling it on a pre-tokenized ('input_tokens') dataset is a hard error. drain: false # Drain in-flight warmup requests before starting the performance phase warmup_random_seed: 42 # RNG seed for warmup scheduling and sample ordering profiling: diff --git a/src/inference_endpoint/config/templates/online_template_full.yaml b/src/inference_endpoint/config/templates/online_template_full.yaml index 89fac1903..e07206b46 100644 --- a/src/inference_endpoint/config/templates/online_template_full.yaml +++ b/src/inference_endpoint/config/templates/online_template_full.yaml @@ -94,7 +94,7 @@ settings: warmup: enabled: false # Enable warmup phase before performance run n_requests: null # Warmup request count (None = full dataset once) - salt: true # Prepend a unique random hex salt to each warmup prompt + salt: false # Prepend a unique random hex salt to each warmup prompt. Requires text-'prompt' samples; enabling it on a pre-tokenized ('input_tokens') dataset is a hard error. drain: false # Drain in-flight warmup requests before starting the performance phase warmup_random_seed: 42 # RNG seed for warmup scheduling and sample ordering profiling: diff --git a/src/inference_endpoint/dataset_manager/dataset.py b/src/inference_endpoint/dataset_manager/dataset.py index cc21046fc..5b6345cb6 100644 --- a/src/inference_endpoint/dataset_manager/dataset.py +++ b/src/inference_endpoint/dataset_manager/dataset.py @@ -258,20 +258,21 @@ def load_from_huggingface( return ds[split].to_pandas() -def _salt_violation(sample: Any) -> str | None: - """Return a human-readable reason a sample cannot be salted, or None if it can. +def _can_salt(sample: Any) -> DatasetValidationError.Reason | None: + """Return the Reason a sample cannot be salted, or None if it can. Salt requires a dict sample with a str 'prompt' and no 'input_tokens' (which adapters send verbatim, so a salted 'prompt' would not reach the server). """ + Reason = DatasetValidationError.Reason if not isinstance(sample, dict): - return f"is a {type(sample).__name__}, not a dict" + return Reason.TYPE_MISMATCH if "input_tokens" in sample: - return "has 'input_tokens' (salt cannot bust a pre-tokenized cache)" + return Reason.INPUT_TOKENS_SHADOWING if "prompt" not in sample: - return "has no 'prompt' field" + return Reason.PROMPT_MISSING if not isinstance(sample["prompt"], str): - return f"has a 'prompt' of type {type(sample['prompt']).__name__}, not str" + return Reason.PROMPT_TYPE_MISMATCH return None @@ -463,23 +464,28 @@ def validate_saltable(self) -> None: salt requires a dict sample with a text ('str') 'prompt' and no 'input_tokens' (adapters send those verbatim, so a salted 'prompt' would - never reach the server). A sample salt cannot bust would silently defeat - cache-busting, so it is rejected rather than skipped. Called before any - load is issued — at benchmark setup and again from with_salt(). + never reach the server). A non-saltable sample is an error, not a silent + skip: skipping would leave the KV cache un-busted. Every sample is + checked — a single invalid item fails the run, because the seeded warmup + subset can draw any index and salt correctness is all-or-nothing. Called + before any load is issued — at benchmark setup and again from with_salt(). Raises: - DatasetValidationError: naming the first offending sample. + DatasetValidationError: naming the first offending sample. The index + is into the loaded, post-transform sample order, not the source + file line. """ - if self.data is None: - return + assert self.data is not None, "Dataset not loaded. Call load() first." for i, sample in enumerate(self.data): - reason = _salt_violation(sample) + reason = _can_salt(sample) if reason is not None: raise DatasetValidationError( - f"salt=True requires every sample to be a dict with a text " - f"'prompt' and no 'input_tokens', but sample {i} {reason}. " - f"Disable salt (--warmup-salt / warmup.salt: false) or use a " - f"text-prompt dataset." + reason, + detail=( + f"sample {i} (index into the loaded, post-transform " + f"order); disable salt (--warmup-salt / warmup.salt: " + f"false) or use a text-prompt dataset" + ), ) def with_salt(self, rng: random.Random) -> "Dataset": @@ -489,8 +495,8 @@ def with_salt(self, rng: random.Random) -> "Dataset": Each load_sample() call on the returned dataset prepends a unique hex salt derived from rng to the 'prompt' field, preventing KV-cache reuse. - Validates every sample first (see validate_saltable), so a dataset salt - cannot bust fails here rather than silently issuing unsalted prompts. + Validates every sample first (see validate_saltable): a non-saltable + dataset raises here, before any load is issued. Raises: DatasetValidationError: if any sample cannot be salted. diff --git a/src/inference_endpoint/exceptions.py b/src/inference_endpoint/exceptions.py index 86f25a2f9..6853b9e49 100644 --- a/src/inference_endpoint/exceptions.py +++ b/src/inference_endpoint/exceptions.py @@ -15,6 +15,8 @@ """Custom exceptions for CLI error handling.""" +from enum import Enum + class CLIError(Exception): """Base exception for CLI errors. @@ -37,9 +39,32 @@ class InputValidationError(CLIError): class DatasetValidationError(InputValidationError): - """Invalid --dataset string or dataset configuration.""" + """Invalid --dataset string or dataset configuration. - pass + The failure category is a ``Reason``; ``detail`` carries the specifics + (offending sample index, remediation hint, parser error text). + """ + + class Reason(Enum): + """Why a dataset failed validation.""" + + TYPE_MISMATCH = "sample is not a dict" + INPUT_TOKENS_SHADOWING = ( + "sample has 'input_tokens'; salt cannot bust a pre-tokenized cache" + ) + PROMPT_MISSING = "sample has no 'prompt' field" + PROMPT_TYPE_MISMATCH = "sample 'prompt' is not a str" + UNSPECIFIED = "dataset validation failed" + + def __init__( + self, + reason: "DatasetValidationError.Reason", + detail: str | None = None, + ) -> None: + self.reason = reason + self.detail = detail + message = reason.value if detail is None else f"{reason.value}: {detail}" + super().__init__(message) class SetupError(CLIError): diff --git a/tests/unit/commands/test_benchmark.py b/tests/unit/commands/test_benchmark.py index 76365e441..3ca527ac0 100644 --- a/tests/unit/commands/test_benchmark.py +++ b/tests/unit/commands/test_benchmark.py @@ -85,7 +85,11 @@ Scorer, SWEBenchScorer, ) -from inference_endpoint.exceptions import InputValidationError, SetupError +from inference_endpoint.exceptions import ( + DatasetValidationError, + InputValidationError, + SetupError, +) from inference_endpoint.load_generator.sample_order import create_sample_order from inference_endpoint.load_generator.session import ( PhaseResult, @@ -435,6 +439,78 @@ def test_skips_validation_when_salt_off(self, mock_validate, tmp_path): _load_datasets(config, tmp_path, TestMode.PERF) mock_validate.assert_not_called() + def test_unsaltable_perf_dataset_raises_before_spawn(self, tmp_path): + """Real validation (unpatched) rejects a non-saltable perf dataset at + load time — an int 'prompt' cannot be salted.""" + ds = tmp_path / "perf.jsonl" + ds.write_text('{"prompt": 1}\n{"prompt": 2}\n') + config = OfflineConfig( + endpoint_config={"endpoints": ["http://test:8000"]}, + model_params={"name": "test-model"}, + datasets=[{"path": str(ds)}], + settings=OfflineSettings( + client=HTTPClientConfig( + num_workers=1, warmup_connections=0, max_connections=10 + ), + warmup=WarmupConfig(enabled=True, salt=True), + ), + ) + with pytest.raises(DatasetValidationError, match=r"sample 0\b"): + _load_datasets(config, tmp_path, TestMode.PERF) + + def test_online_unsaltable_perf_dataset_raises(self, tmp_path): + """The salt check is load-pattern agnostic — online runs validate too.""" + ds = tmp_path / "perf.jsonl" + ds.write_text('{"prompt": 1}\n') + config = OnlineConfig( + endpoint_config={"endpoints": ["http://test:8000"]}, + model_params={"name": "test-model"}, + datasets=[{"path": str(ds)}], + settings=OnlineSettings( + load_pattern=LoadPattern(type=LoadPatternType.POISSON, target_qps=10), + client=HTTPClientConfig( + num_workers=1, warmup_connections=0, max_connections=10 + ), + warmup=WarmupConfig(enabled=True, salt=True), + ), + ) + with pytest.raises(DatasetValidationError, match=r"sample 0\b"): + _load_datasets(config, tmp_path, TestMode.PERF) + + def test_accuracy_only_skips_salt_validation(self, tmp_path): + """TestMode.ACC never loads the perf dataset, so an unsaltable perf + dataset with warmup salt on must NOT be validated (dataloader is None). + Guards against a refactor validating a None dataloader.""" + perf = tmp_path / "perf.jsonl" + perf.write_text('{"prompt": 1}\n') # unsaltable — would raise if validated + fake_acc_df = pd.DataFrame( + [{"instance_id": "repo__repo-0", "prompt": "Fix bug 0"}] + ) + config = OfflineConfig( + endpoint_config={"endpoints": ["http://test:8000"]}, + model_params={"name": "test-model"}, + datasets=[ + {"type": "performance", "path": str(perf)}, + { + "name": "swe_bench", + "type": "accuracy", + "accuracy_config": {"eval_method": "swe_bench_scorer"}, + }, + ], + settings=OfflineSettings( + client=HTTPClientConfig( + num_workers=1, warmup_connections=0, max_connections=10 + ), + warmup=WarmupConfig(enabled=True, salt=True), + ), + ) + with ( + patch.object(SWEBenchScorer, "preflight"), + patch.object(SWEBench, "generate", return_value=fake_acc_df), + ): + perf_loader, _, _ = _load_datasets(config, tmp_path, TestMode.ACC) + assert perf_loader is None + class TestCommandHandlers: """Test offline/online/from_config handlers (mock run_benchmark).""" @@ -724,7 +800,7 @@ def test_preflight_error_propagates(self, tmp_path): @pytest.mark.unit @pytest.mark.parametrize( - ("datasets, expected_scorer, expected_type, " "expected_accuracy_datasets"), + ("datasets, expected_scorer, expected_type, expected_accuracy_datasets"), [ ( [ @@ -994,7 +1070,7 @@ def test_defaults(self): cfg = WarmupConfig() assert cfg.enabled is False assert cfg.n_requests is None - assert cfg.salt is True + assert cfg.salt is False assert cfg.drain is False @pytest.mark.unit @@ -1532,7 +1608,7 @@ def test_warmup_n_requests_none_when_unset(self, base_rt_settings, simple_datase assert phases[0].runtime_settings.n_samples_to_issue is None @pytest.mark.unit - def test_warmup_defaults_uses_salt(self, base_rt_settings, simple_dataset): + def test_warmup_defaults_no_salt(self, base_rt_settings, simple_dataset): config = OfflineConfig( **_OFFLINE_KWARGS, settings=OfflineSettings(warmup=WarmupConfig(enabled=True)), @@ -1540,7 +1616,8 @@ def test_warmup_defaults_uses_salt(self, base_rt_settings, simple_dataset): ctx = self._make_ctx(config, base_rt_settings, simple_dataset) phases = _build_phases(ctx) - assert phases[0].dataset._salt_rng is not None + assert phases[0].dataset._salt_rng is None + assert phases[0].dataset is simple_dataset @pytest.mark.unit def test_warmup_without_salt_uses_raw_dataloader( diff --git a/tests/unit/dataset_manager/test_salted_dataset.py b/tests/unit/dataset_manager/test_salted_dataset.py index 329b17fa2..cf72ab5c0 100644 --- a/tests/unit/dataset_manager/test_salted_dataset.py +++ b/tests/unit/dataset_manager/test_salted_dataset.py @@ -186,6 +186,16 @@ def test_input_tokens_only_raises(self): with pytest.raises(DatasetValidationError, match="input_tokens"): inner.with_salt(random.Random()) + def test_agentic_messages_sample_raises_prompt_missing(self): + # Agentic datasets store dict samples keyed by 'messages', not 'prompt' — + # salt has no text field to prepend, so it's a clear PROMPT_MISSING error. + inner = _make_loaded_dataset( + [{"messages": [{"role": "user", "content": "hi"}]}] + ) + with pytest.raises(DatasetValidationError) as exc_info: + inner.with_salt(random.Random()) + assert exc_info.value.reason is DatasetValidationError.Reason.PROMPT_MISSING + def test_input_tokens_and_prompt_raises(self): inner = _make_loaded_dataset([{"input_tokens": [1, 2, 3], "prompt": "hello"}]) with pytest.raises(DatasetValidationError, match="input_tokens"): @@ -193,7 +203,7 @@ def test_input_tokens_and_prompt_raises(self): def test_error_names_offending_sample_index(self): inner = _make_loaded_dataset([{"prompt": "ok"}, {"prompt": 42}]) - with pytest.raises(DatasetValidationError, match=r"\b1\b"): + with pytest.raises(DatasetValidationError, match=r"sample 1\b"): inner.with_salt(random.Random()) def test_valid_str_prompt_dataset_does_not_raise(self): @@ -201,11 +211,13 @@ def test_valid_str_prompt_dataset_does_not_raise(self): sd = inner.with_salt(random.Random()) assert sd.load_sample(0)["prompt"].startswith("[") - def test_data_none_does_not_raise(self): + def test_data_none_raises_not_loaded(self): inner = _make_loaded_dataset([{"prompt": "x"}]) inner.data = None - # No samples to salt (e.g. EmptyDataset) — nothing to validate. - assert inner.with_salt(random.Random())._salt_rng is not None + # None means "not loaded" — load() always sets a list. Validating an + # unloaded dataset is a programming error, not a silent no-op. + with pytest.raises(AssertionError, match="not loaded"): + inner.with_salt(random.Random()) def test_data_empty_list_does_not_raise(self): inner = _make_loaded_dataset([]) @@ -221,6 +233,26 @@ def test_validate_saltable_raises_on_bad_sample(self): with pytest.raises(DatasetValidationError, match="input_tokens"): inner.validate_saltable() + @pytest.mark.parametrize( + "sample, expected_reason", + [ + ("raw string", DatasetValidationError.Reason.TYPE_MISMATCH), + ( + {"input_tokens": [1, 2]}, + DatasetValidationError.Reason.INPUT_TOKENS_SHADOWING, + ), + ({"question": "?"}, DatasetValidationError.Reason.PROMPT_MISSING), + ({"prompt": 42}, DatasetValidationError.Reason.PROMPT_TYPE_MISMATCH), + ], + ) + def test_error_exposes_typed_reason(self, sample, expected_reason): + inner = _make_loaded_dataset([{"prompt": "ok"}]) + inner.data = [sample] + with pytest.raises(DatasetValidationError) as exc_info: + inner.validate_saltable() + assert exc_info.value.reason is expected_reason + assert exc_info.value.detail is not None + @pytest.mark.unit class TestSaltWithRealDataset: