@@ -59,6 +59,45 @@ class SystemDefaults(BaseModel):
5959 DEFAULT_METRIC : ClassVar [metrics .Metric ] = metrics .Throughput (0.0 )
6060
6161
62+ def _deep_merge (base : dict [str , Any ], override : dict [str , Any ]) -> dict [str , Any ]:
63+ """Recursively merge ``override`` into ``base`` and return the result.
64+
65+ For overlapping keys whose values are both dicts, recurse; otherwise the
66+ override value wins. Mutates a *copy* — callers can safely pass model_dump()
67+ output. Used by ``Dataset.effective_generation_config`` so a sparse nested
68+ override (e.g. ``{osl_distribution: {max: 512}}``) preserves siblings.
69+ """
70+ out = dict (base )
71+ for k , v in override .items ():
72+ if isinstance (v , dict ) and isinstance (out .get (k ), dict ):
73+ out [k ] = _deep_merge (out [k ], v )
74+ else :
75+ out [k ] = v
76+ return out
77+
78+
79+ # ModelParams fields that drive the single global tokenizer / MetricsAggregator
80+ # (launched once from top-level model_params), so a per-dataset override would
81+ # desync ISL/OSL/TTFT/TPOT accounting without changing what is measured. Rejected
82+ # as generation_config_override keys — they are per-run/identity, not per-dataset.
83+ _METRICS_DECOUPLED_OVERRIDE_KEYS = frozenset ({"name" , "streaming" , "tokenizer_name" })
84+
85+
86+ def _non_default_completion_controls (mp : ModelParams ) -> list [str ]:
87+ """Completion-only ModelParams controls set to a non-default value.
88+
89+ ``min_new_tokens``/``skip_special_tokens`` are only honored by the
90+ ``openai_completions`` adapter; ``BenchmarkConfig`` rejects them for other
91+ ``api_type``s. Shared by the top-level and per-dataset-override checks so
92+ both config surfaces validate identically.
93+ """
94+ checks = {
95+ "min_new_tokens" : mp .min_new_tokens != 1 ,
96+ "skip_special_tokens" : not mp .skip_special_tokens ,
97+ }
98+ return [name for name , non_default in checks .items () if non_default ]
99+
100+
62101class LoadPatternType (str , Enum ):
63102 """Load pattern types."""
64103
@@ -430,6 +469,36 @@ class Dataset(BaseModel):
430469 agentic_inference : AgenticInferenceConfig | None = Field (
431470 None , description = "Agentic inference conversation configuration"
432471 )
472+ # Per-dataset generation config is a first-class capability: different
473+ # accuracy datasets legitimately want different generation settings (e.g.
474+ # per-dataset max OSL or top_p, as seen in DS-V4), and dataset-scoping also
475+ # enables per-dataset dynamic OSL distributions. Only generation knobs are
476+ # overridable — per-run/identity fields (`_METRICS_DECOUPLED_OVERRIDE_KEYS`:
477+ # name / streaming / tokenizer_name) drive the single global tokenizer and
478+ # MetricsAggregator, so overriding them per-dataset would desync ISL/OSL/
479+ # TTFT/TPOT accounting; they are rejected at validation.
480+ #
481+ # TODO(post-mortem): split ModelParams into a per-run ModelIdentity and a
482+ # GenerationConfig, so the override surface is exactly the generation fields
483+ # and identity fields cannot be named here at all. Field/method names use
484+ # "generation_config" to keep that migration mechanical.
485+ #
486+ # Nested dicts (`osl_distribution`, `chat_template_kwargs`) are deep-merged
487+ # so sparse overrides preserve sibling defaults.
488+ generation_config_override : dict [str , Any ] | None = Field (
489+ None ,
490+ description = (
491+ "Per-dataset overrides for the top-level model_params (sparse — "
492+ "only the fields you want to override). Merged on top of "
493+ "BenchmarkConfig.model_params at dataset-load time. Useful for "
494+ "MLPerf-style runs where accuracy and performance use different "
495+ "output budgets in the same fleet, e.g. "
496+ "generation_config_override: {max_new_tokens: 32768, "
497+ "temperature: 0.0}. NOTE: per-run/identity keys (`name`, "
498+ "`streaming`, `tokenizer_name`) are rejected here — set them on "
499+ "top-level model_params."
500+ ),
501+ )
433502
434503 @model_validator (mode = "after" )
435504 def _auto_derive_name (self ) -> Self :
@@ -438,6 +507,53 @@ def _auto_derive_name(self) -> Self:
438507 object .__setattr__ (self , "name" , Path (self .path ).stem )
439508 return self
440509
510+ @model_validator (mode = "after" )
511+ def _validate_generation_config_override (self ) -> Self :
512+ """Fail fast on unknown keys and on per-run/identity keys the single
513+ global tokenizer / MetricsAggregator would ignore. Override *values*
514+ are validated at merge time (see ``effective_generation_config``)
515+ because cross-field validation needs the base ``ModelParams`` from
516+ ``BenchmarkConfig``.
517+ """
518+ if self .generation_config_override :
519+ keys = set (self .generation_config_override )
520+ valid = set (ModelParams .model_fields )
521+ bad = sorted (keys - valid )
522+ if bad :
523+ raise ValueError (
524+ f"Dataset '{ self .name } ': unknown keys in "
525+ f"generation_config_override: { bad } . "
526+ f"Valid keys: { sorted (valid )} "
527+ )
528+ decoupled = sorted (keys & _METRICS_DECOUPLED_OVERRIDE_KEYS )
529+ if decoupled :
530+ raise ValueError (
531+ f"Dataset '{ self .name } ': generation_config_override keys "
532+ f"{ decoupled } are not honored per-dataset — the single "
533+ "global tokenizer / metrics aggregator is launched from "
534+ "top-level model_params, so a per-dataset value would "
535+ "desync ISL/OSL/TTFT/TPOT accounting. Set them on "
536+ "top-level model_params instead."
537+ )
538+ return self
539+
540+ def effective_generation_config (self , base : ModelParams ) -> ModelParams :
541+ """Return base merged with this dataset's generation-config overrides.
542+
543+ Nested dicts are deep-merged so a sparse nested override preserves
544+ sibling defaults (e.g. ``{osl_distribution: {max: 512}}`` keeps the
545+ base ``type/mean/std/min``). The merged dict is re-validated through
546+ ``ModelParams.model_validate`` so type-invalid scalar overrides (e.g.
547+ ``temperature: 'hot'``) are rejected. Note that this only catches
548+ scalar invalidity — a sparse nested override whose merged result
549+ passes default-validation will not raise (callers that need stricter
550+ nested validation should set ``base`` to an explicit instance).
551+ """
552+ if not self .generation_config_override :
553+ return base
554+ merged = _deep_merge (base .model_dump (), self .generation_config_override )
555+ return ModelParams .model_validate (merged )
556+
441557
442558class AccuracyConfig (BaseModel ):
443559 """Accuracy configuration.
@@ -988,30 +1104,47 @@ def _resolve_and_validate(self) -> Self:
9881104
9891105 # TODO(vir): Move API-type-specific validation out of this generic
9901106 # cross-model validator and into the selected adapter. Requires a larger refactor.
991- non_default_completion_controls = {
992- "model_params.min_new_tokens" : self .model_params .min_new_tokens != 1 ,
993- "model_params.skip_special_tokens" : not self .model_params .skip_special_tokens ,
1107+ #
1108+ # Completion-only controls must be gated by api_type for BOTH the
1109+ # top-level model_params AND every per-dataset generation_config_override,
1110+ # so the two config surfaces validate identically. Merge each dataset's
1111+ # effective params once here (parse time) — this also surfaces
1112+ # value-invalid overrides before setup produces side effects — and reuse
1113+ # the result for the agentic-inference check below.
1114+ effective_by_dataset : dict [int , ModelParams ] = {
1115+ id (dataset ): dataset .effective_generation_config (self .model_params )
1116+ for dataset in self .datasets
1117+ if dataset .generation_config_override
9941118 }
995- configured_controls = [
996- name
997- for name , is_non_default in non_default_completion_controls .items ()
998- if is_non_default
1119+ completion_control_surfaces : list [tuple [str , ModelParams ]] = [
1120+ ("model_params" , self .model_params )
9991121 ]
1000- if (
1001- configured_controls
1002- and self .endpoint_config .api_type != APIType .OPENAI_COMPLETIONS
1003- ):
1004- controls = " and " .join (configured_controls )
1005- verb = "requires" if len (configured_controls ) == 1 else "require"
1006- required_api_type = "endpoint_config.api_type=openai_completions"
1007- raise ValueError (f"{ controls } { verb } { required_api_type } " )
1008- if configured_controls and any (
1009- dataset .agentic_inference is not None for dataset in self .datasets
1010- ):
1011- raise ValueError (
1012- "OpenAI text-completion generation controls are not supported "
1013- "for agentic inference datasets"
1014- )
1122+ for dataset in self .datasets :
1123+ effective = effective_by_dataset .get (id (dataset ))
1124+ if effective is not None :
1125+ completion_control_surfaces .append (
1126+ (
1127+ f"datasets['{ dataset .name } '].generation_config_override" ,
1128+ effective ,
1129+ )
1130+ )
1131+ for prefix , mp in completion_control_surfaces :
1132+ controls = _non_default_completion_controls (mp )
1133+ if controls and self .endpoint_config .api_type != APIType .OPENAI_COMPLETIONS :
1134+ names = " and " .join (f"{ prefix } .{ name } " for name in controls )
1135+ verb = "requires" if len (controls ) == 1 else "require"
1136+ raise ValueError (
1137+ f"{ names } { verb } endpoint_config.api_type=openai_completions"
1138+ )
1139+ for dataset in self .datasets :
1140+ if dataset .agentic_inference is None :
1141+ continue
1142+ effective = effective_by_dataset .get (id (dataset ), self .model_params )
1143+ if _non_default_completion_controls (effective ):
1144+ raise ValueError (
1145+ "OpenAI text-completion generation controls are not supported "
1146+ "for agentic inference datasets"
1147+ )
10151148
10161149 # --- Validate (cross-model checks only; sub-models self-validate) ---
10171150 if self .type == TestType .SUBMISSION and not self .benchmark_mode :
0 commit comments