From 08aa0d0e050e177268385c5ccfea214fa010924e Mon Sep 17 00:00:00 2001 From: m4 Date: Thu, 30 Jul 2026 10:08:26 +0800 Subject: [PATCH] feat(registry): mutually-exclusive sampling_override frozen into run snapshots --- EvoScientist/model_registry/adapters.py | 49 ++++++++++++-------- EvoScientist/model_registry/openapi.json | 46 ++++++++++-------- EvoScientist/model_registry/resolver.py | 13 ++---- EvoScientist/model_registry/schemas.py | 23 +++++++++ EvoScientist/model_registry/snapshots.py | 31 +++++++------ tests/test_snapshots.py | 59 ++++++++++++++++++------ 6 files changed, 145 insertions(+), 76 deletions(-) diff --git a/EvoScientist/model_registry/adapters.py b/EvoScientist/model_registry/adapters.py index 3e1db00..9711638 100644 --- a/EvoScientist/model_registry/adapters.py +++ b/EvoScientist/model_registry/adapters.py @@ -46,6 +46,7 @@ from .schemas import ( ProviderConfig, ReasoningEffort, ResolvedModelConfig, + SamplingOverride, ) SUPPORTED_ADAPTER_IDS = ( @@ -583,8 +584,7 @@ def resolve_parameters( spec: AdapterParameterSpec, *, reasoning_effort_override: ReasoningEffort | None = None, - temperature_override: float | None = None, - top_p_override: float | None = None, + sampling_override: SamplingOverride | None = None, ) -> ResolvedParameters: """Resolve a model's runtime parameters against the matched contract. @@ -594,8 +594,10 @@ def resolve_parameters( and the save-time contract checks. Raises ``ModelRegistryError`` with a stable section 9.5 code on any violation. - The three overrides (snapshot creation only) replace the model's - configured values before inheritance; they flow through the same + The sampling override (snapshot creation only) replaces the model's + configured value for its ``kind`` and omits the other sampling + parameter entirely — registry defaults included — because temperature + and top_p must not be sent together. Overrides flow through the same contract rules, so unsupported adapters still reject them. """ auth_spec = spec.auth_specs.get(provider.auth.mode) @@ -645,21 +647,32 @@ def resolve_parameters( model.runtime.max_output_tokens, ) - temperature = ( - temperature_override - if temperature_override is not None - else model.runtime.temperature - ) - if temperature is None: - temperature = provider.runtime.default_temperature - temperature = _resolve_nullable_parameter( - "temperature", _parameter_rule(spec, "temperature"), temperature - ) + if sampling_override is not None and sampling_override.kind == "temperature": + temperature = _resolve_nullable_parameter( + "temperature", _parameter_rule(spec, "temperature"), sampling_override.value + ) + top_p = _resolve_nullable_parameter( + "top_p", _parameter_rule(spec, "top_p"), None + ) + elif sampling_override is not None: + top_p = _resolve_nullable_parameter( + "top_p", _parameter_rule(spec, "top_p"), sampling_override.value + ) + temperature = _resolve_nullable_parameter( + "temperature", _parameter_rule(spec, "temperature"), None + ) + else: + temperature = model.runtime.temperature + if temperature is None: + temperature = provider.runtime.default_temperature + temperature = _resolve_nullable_parameter( + "temperature", _parameter_rule(spec, "temperature"), temperature + ) - top_p = top_p_override if top_p_override is not None else model.runtime.top_p - if top_p is None: - top_p = provider.runtime.default_top_p - top_p = _resolve_nullable_parameter("top_p", _parameter_rule(spec, "top_p"), top_p) + top_p = model.runtime.top_p + if top_p is None: + top_p = provider.runtime.default_top_p + top_p = _resolve_nullable_parameter("top_p", _parameter_rule(spec, "top_p"), top_p) reasoning_effort: ReasoningEffort = ( reasoning_effort_override diff --git a/EvoScientist/model_registry/openapi.json b/EvoScientist/model_registry/openapi.json index c140551..03a696d 100644 --- a/EvoScientist/model_registry/openapi.json +++ b/EvoScientist/model_registry/openapi.json @@ -1175,6 +1175,29 @@ "title": "RuntimeOptionsPublic", "type": "object" }, + "SamplingOverride": { + "description": "Mutually-exclusive per-run sampling override (2026-07-30 design).\n\nOverriding one parameter omits the other \u2014 including its registry\ndefault \u2014 from the request, because providers reject or misbehave when\ntemperature and top_p are set together.", + "properties": { + "kind": { + "enum": [ + "temperature", + "top_p" + ], + "title": "Kind", + "type": "string" + }, + "value": { + "title": "Value", + "type": "number" + } + }, + "required": [ + "kind", + "value" + ], + "title": "SamplingOverride", + "type": "object" + }, "SelectableModel": { "description": "One entry of the section 9.1 model selector response.", "properties": { @@ -1289,38 +1312,21 @@ "title": "Run Request Id", "type": "string" }, - "temperature": { + "sampling_override": { "anyOf": [ { - "maximum": 2, - "minimum": 0, - "type": "number" + "$ref": "#/components/schemas/SamplingOverride" }, { "type": "null" } ], - "default": null, - "title": "Temperature" + "default": null }, "thread_id": { "minLength": 1, "title": "Thread Id", "type": "string" - }, - "top_p": { - "anyOf": [ - { - "exclusiveMinimum": 0, - "maximum": 1, - "type": "number" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Top P" } }, "required": [ diff --git a/EvoScientist/model_registry/resolver.py b/EvoScientist/model_registry/resolver.py index 92c24ff..f420a27 100644 --- a/EvoScientist/model_registry/resolver.py +++ b/EvoScientist/model_registry/resolver.py @@ -54,6 +54,7 @@ from .schemas import ( RegistryV4, RequestOptions, ResolvedModelConfig, + SamplingOverride, VerificationInfo, ) from .store import ModelRuntimeStore @@ -104,8 +105,7 @@ class ModelRegistryResolver: *, registry: RegistryV4 | None = None, reasoning_effort_override: ReasoningEffort | None = None, - temperature_override: float | None = None, - top_p_override: float | None = None, + sampling_override: SamplingOverride | None = None, ) -> ResolvedModelConfig: """Resolve an enabled, verified model into its frozen run config.""" return self._resolve( @@ -114,8 +114,7 @@ class ModelRegistryResolver: registry=registry, require_enabled=True, reasoning_effort_override=reasoning_effort_override, - temperature_override=temperature_override, - top_p_override=top_p_override, + sampling_override=sampling_override, ) def resolve_for_test(self, model_ref: ModelRef) -> ResolvedModelConfig: @@ -143,8 +142,7 @@ class ModelRegistryResolver: require_enabled: bool, require_verified: bool = True, reasoning_effort_override: ReasoningEffort | None = None, - temperature_override: float | None = None, - top_p_override: float | None = None, + sampling_override: SamplingOverride | None = None, ) -> ResolvedModelConfig: _check_role(role) if registry is None: @@ -196,8 +194,7 @@ class ModelRegistryResolver: model, spec, reasoning_effort_override=reasoning_effort_override, - temperature_override=temperature_override, - top_p_override=top_p_override, + sampling_override=sampling_override, ) auth_spec = spec.auth_specs[provider.auth.mode] diff --git a/EvoScientist/model_registry/schemas.py b/EvoScientist/model_registry/schemas.py index e21fef0..c2eaadf 100644 --- a/EvoScientist/model_registry/schemas.py +++ b/EvoScientist/model_registry/schemas.py @@ -6,6 +6,7 @@ these Pydantic models instead of maintaining separate shapes. from __future__ import annotations +import math from typing import Annotated, Literal from pydantic import ( @@ -48,6 +49,28 @@ LimitsSource = Literal["provider", "tested_contract", "user"] ModelRole = Literal["primary"] +class SamplingOverride(BaseModel): + """Mutually-exclusive per-run sampling override (2026-07-30 design). + + Overriding one parameter omits the other — including its registry + default — from the request, because providers reject or misbehave when + temperature and top_p are set together. + """ + + kind: Literal["temperature", "top_p"] + value: float + + @model_validator(mode="after") + def _check_value_range(self) -> SamplingOverride: + if not math.isfinite(self.value): + raise ValueError("sampling override value must be finite") + if self.kind == "temperature" and not 0 <= self.value <= 2: + raise ValueError("temperature override must be within [0, 2]") + if self.kind == "top_p" and not 0 < self.value <= 1: + raise ValueError("top_p override must be within (0, 1]") + return self + + class _FrozenModel(BaseModel): model_config = ConfigDict(frozen=True) diff --git a/EvoScientist/model_registry/snapshots.py b/EvoScientist/model_registry/snapshots.py index 3afcd67..d5e7c1a 100644 --- a/EvoScientist/model_registry/snapshots.py +++ b/EvoScientist/model_registry/snapshots.py @@ -11,7 +11,7 @@ into ``run_runtime_snapshots.payload_json``. Payloads and logs never carry maps to at most one non-terminal snapshot. A repeated request with the same ``selection_hash`` returns the original snapshot; a different hash raises ``RUN_REQUEST_CONFLICT``. ``selection_hash`` covers the - pre-resolution ``{primary, reasoning_effort, temperature, top_p}`` selection (inherit + pre-resolution ``{primary, reasoning_effort, sampling_override}`` selection (inherit participates as ``null``); ``model_selection_revision`` is an audit field and never part of the hash. - Lifecycle: ``prepared`` (TTL 15 minutes) → ``bound`` (retained 24 hours @@ -34,7 +34,7 @@ import time import uuid from typing import Any, Literal, get_args -from pydantic import BaseModel, Field, NonNegativeInt, PositiveInt +from pydantic import BaseModel, NonNegativeInt, PositiveInt from .adapters import find_adapter_spec from .errors import ( @@ -53,6 +53,7 @@ from .schemas import ( NonEmptyString, ReasoningEffort, ResolvedModelConfig, + SamplingOverride, ) from .store import ModelRuntimeStore @@ -76,11 +77,11 @@ class SnapshotCreateRequest(BaseModel): # ``None``/``auto`` keeps the registry-configured effort. Unsupported # adapters reject it. reasoning_effort: ReasoningEffort | None = None - # Per-run generation overrides (thread-level selection); ``None`` keeps - # the registry-configured values. Out-of-contract values are rejected by - # the adapter rules at resolve time. - temperature: float | None = Field(default=None, ge=0, le=2) - top_p: float | None = Field(default=None, gt=0, le=1) + # Mutually-exclusive per-run sampling override; ``None`` keeps the + # registry-configured values. Overriding one omits the other from the + # request. Out-of-contract values are rejected by the adapter rules at + # resolve time. + sampling_override: SamplingOverride | None = None class SnapshotPayload(BaseModel): @@ -131,8 +132,7 @@ class SnapshotCreation(BaseModel): def compute_selection_hash( primary: ModelRef | None, reasoning_effort: ReasoningEffort | None = None, - temperature: float | None = None, - top_p: float | None = None, + sampling_override: SamplingOverride | None = None, ) -> str: """Hash the pre-resolution selection; inherit participates as ``null``.""" encoded = json.dumps( @@ -143,8 +143,11 @@ def compute_selection_hash( else {"provider_id": primary.provider_id, "model_key": primary.model_key} ), "reasoning_effort": reasoning_effort, - "temperature": temperature, - "top_p": top_p, + "sampling_override": ( + None + if sampling_override is None + else {"kind": sampling_override.kind, "value": sampling_override.value} + ), }, sort_keys=True, separators=(",", ":"), @@ -209,8 +212,7 @@ class SnapshotService: selection_hash = compute_selection_hash( request.primary, request.reasoning_effort, - request.temperature, - request.top_p, + request.sampling_override, ) existing = self._store.find_active_run_snapshot( deployment_id=request.deployment_id, @@ -243,8 +245,7 @@ class SnapshotService: "primary", registry=registry, reasoning_effort_override=request.reasoning_effort, - temperature_override=request.temperature, - top_p_override=request.top_p, + sampling_override=request.sampling_override, ) payload = { "registry_revision": registry.revision, diff --git a/tests/test_snapshots.py b/tests/test_snapshots.py index f713bc3..076ddff 100644 --- a/tests/test_snapshots.py +++ b/tests/test_snapshots.py @@ -12,6 +12,7 @@ import json import time import pytest +from pydantic import ValidationError from EvoScientist.model_registry.adapters import adapter_specs, find_adapter_spec from EvoScientist.model_registry.errors import ( @@ -31,6 +32,7 @@ from EvoScientist.model_registry.schemas import ( CredentialWrite, ModelRef, RegistryV4, + SamplingOverride, ) from EvoScientist.model_registry.snapshots import ( BOUND_RETENTION_SECONDS, @@ -192,11 +194,11 @@ def _request(**overrides): # SHA-256 of the canonical JSON -# '{"primary":null,"reasoning_effort":null,"temperature":null,"top_p":null}' — +# '{"primary":null,"reasoning_effort":null,"sampling_override":null}' — # the inherit selection with no overrides. Pinned as a literal to lock the # wire contract. INHERIT_SELECTION_HASH = ( - "0e7a49530963e46f999f6ee07e91f3e9b91fca50dfd126b8cc8aebd64055d6cc" + "417399175386cea86c726c1bd2b833bd951066eae2e8f419b99076e9c91724a6" ) @@ -276,7 +278,7 @@ class TestCreate: service.create(_request(primary=OLLAMA_REF, reasoning_effort="high")) assert excinfo.value.code == UNSUPPORTED_RUNTIME_PARAMETER - def test_temperature_top_p_overrides_freeze_into_primary(self, active_store): + def test_sampling_override_freezes_and_omits_the_other(self, active_store): # glm-5.2's model-specific contract caps temperature at 1, so add a # generic-contract model on the same provider to exercise the # override path (disabled → verify → enable, the standard flow). @@ -304,34 +306,61 @@ class TestCreate: registry=RegistryV4.model_validate(payload), ) service = SnapshotService(store, ModelRegistryResolver(store)) + ref = {"provider_id": "zhipu-glm", "model_key": "glm-air"} + + # temperature override: top_p (provider default 0.95) is omitted. creation = service.create( _request( - primary={"provider_id": "zhipu-glm", "model_key": "glm-air"}, - temperature=1.1, - top_p=0.5, + primary=ref, + sampling_override={"kind": "temperature", "value": 1.1}, ) ) options = creation.snapshot.payload.primary.request_options assert options.temperature == 1.1 + assert options.top_p is None + + # top_p override: temperature (provider default 0.7) is omitted. + creation = service.create( + _request( + run_request_id="req-2", + primary=ref, + sampling_override={"kind": "top_p", "value": 0.5}, + ) + ) + options = creation.snapshot.payload.primary.request_options assert options.top_p == 0.5 + assert options.temperature is None + + def test_sampling_override_value_range_enforced(self): + for bad in ( + {"kind": "temperature", "value": -0.1}, + {"kind": "temperature", "value": 2.1}, + {"kind": "temperature", "value": float("nan")}, + {"kind": "top_p", "value": 0}, + {"kind": "top_p", "value": 1.01}, + ): + with pytest.raises(ValidationError): + SamplingOverride.model_validate(bad) def test_overrides_rejected_outside_model_contract(self, service): # glm-5.2 走模型专属契约:temperature 上限 1;1.5 通过请求模型 # (0–2) 校验但被 adapter 契约拒绝。 with pytest.raises(ModelRegistryError) as excinfo: - service.create(_request(temperature=1.5)) + service.create( + _request(sampling_override={"kind": "temperature", "value": 1.5}) + ) assert excinfo.value.code == UNSUPPORTED_RUNTIME_PARAMETER - def test_temperature_top_p_overrides_change_selection_hash(self, service): + def test_sampling_override_changes_selection_hash(self, service): plain = service.create(_request()) - assert compute_selection_hash(None, None, 0.5, None) != ( - plain.snapshot.selection_hash - ) - assert compute_selection_hash(None, None, None, 0.9) != ( - plain.snapshot.selection_hash - ) + assert compute_selection_hash( + None, None, SamplingOverride(kind="temperature", value=0.5) + ) != plain.snapshot.selection_hash + assert compute_selection_hash( + None, None, SamplingOverride(kind="top_p", value=0.9) + ) != plain.snapshot.selection_hash # 未覆盖(None)与缺省一致:不传覆盖时 hash 与旧语义相同。 - assert compute_selection_hash(None, None, None, None) == ( + assert compute_selection_hash(None, None, None) == ( plain.snapshot.selection_hash )