Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -140,50 +140,34 @@ def prediction_plan(
def _inference(
self, inference: Any, request: Request
) -> dict[str, Any] | None:
"""Send only the inference options this model reads.
"""Keep gathering options, but omit the engine's model config.

The engine fills in an inference config on every request, including
keys this client does not forward: `output_type`, whose choice travels
as ``output.prediction_statistic`` instead, and `target_transforms`.
Dropping the engine's defaults is safe. If the *caller* asked for one,
it is named rather than silently discarded, because a dropped request
for four estimators is a different prediction than the one that was
wanted.

Forwarding a key is not a promise that the endpoint serves it. This is
a name-level filter over what the engine sends; whether a deployment
accepts the *value* is its own answer, and it may still refuse.
keys the endpoint does not accept. `output_type` is represented by
``output.prediction_statistic`` instead. Reject caller-selected config
values rather than silently changing the requested prediction.
"""
if not isinstance(inference, dict):
return None
config = inference.get('inference_config')
if not isinstance(config, dict):
return dict(inference)

forwarded = self.spec.runtime.inference_options
unsupported = sorted(set(config) - forwarded)
asked_for = _keys_the_caller_set(
request.requested_inference.get('inference_config')
asked_for = sorted(
_keys_the_caller_set(
request.requested_inference.get('inference_config')
)
)
refused = sorted(set(unsupported) & asked_for)
if refused:
if asked_for:
raise UnsupportedRequestError(
f'{self.spec.model_id} does not read inference option(s) '
f'{refused}; drop them from inference_config to send the '
f'request without them. The keys this client forwards are '
f'{sorted(forwarded)}, and the endpoint decides for itself '
f'whether it serves the value given for one'
+ _where_the_statistic_is_chosen(refused),
f'{asked_for}; drop them from inference_config to send the '
f'request without them'
+ _where_the_statistic_is_chosen(asked_for),
details={
'model': self.spec.model_id,
'unsupported': refused,
'unsupported': asked_for,
},
)

trimmed = dict(inference)
trimmed['inference_config'] = {
key: value for key, value in config.items() if key in forwarded
}
trimmed.pop('inference_config', None)
return trimmed

def session_plan(
Expand Down
19 changes: 0 additions & 19 deletions packages/structured-data-models-client/src/sdm_client/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,25 +125,6 @@
explanation_formats=(),
limits=RELATIONAL_LIMITS,
unread_feature_stypes=frozenset({'text'}),
# Inference-config keys this client forwards, rather than keys the
# deployment is known to serve. The engine also sends `output_type`,
# whose choice travels as `output.prediction_statistic` instead, and
# `target_transforms`, which does nothing on this model: the endpoint
# accepts only `quantile`, reads it nowhere, and says the model emits
# its quantiles natively. A deployment may still refuse a value given
# for a key that is forwarded, and the shipping one refuses every
# shuffle and every `num_estimators` but its own, so this list is not
# a menu.
inference_options=frozenset(
{
'kind',
'num_estimators',
'column_shuffle',
'category_shuffle',
'hop_shuffle',
'class_shuffle',
}
),
),
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ def test_a_regression_asks_for_the_median_by_default(requests_mock):

assert body['task']['kind'] == 'regression'
assert body['output']['prediction_statistic'] == 'median'
assert 'output_type' not in body['inference']['inference_config']
assert 'inference_config' not in body['inference']
# Both transports wrap the same payload, so both ask for the same thing.
(wrapped,) = sent_to_databricks(request)
assert without_request_id(wrapped) == without_request_id(body)
Expand Down Expand Up @@ -159,6 +159,7 @@ def test_a_session_scores_with_the_statistic_asked_for(

(body,) = [call.json() for call in scored.request_history]
assert body['output']['prediction_statistic'] == statistic
assert 'inference_config' not in body['inference']


def test_every_step_of_a_forecast_reports_the_statistic_asked_for():
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -303,9 +303,10 @@ def sent_inference(request) -> dict:
def test_an_inference_config_dataclass_sends_what_the_dict_form_sends():
"""The engine takes `InferenceConfig | dict | None` and gathers the same
payload from either, so only the warning bookkeeping saw the difference."""
assert sent_inference(gathered_with(regression_config())) == (
sent_inference(gathered_with({}))
)
assert sent_inference(gathered_with(regression_config())) == {
'run_mode': 'fast'
}
assert sent_inference(gathered_with({})) == {'run_mode': 'fast'}


def test_a_dataclass_field_the_caller_set_is_still_named_when_it_is_dropped():
Expand All @@ -318,14 +319,28 @@ def test_a_dataclass_field_the_caller_set_is_still_named_when_it_is_dropped():
assert 'output_type' in str(error.value)


@pytest.mark.parametrize(
('config', 'field'),
[
({'num_estimators': 4}, 'num_estimators'),
({'column_shuffle': True}, 'column_shuffle'),
({'target_transforms': ['clip']}, 'target_transforms'),
],
)
def test_explicit_inference_config_is_refused(config, field):
request = gathered_with(config, sent={'num_estimators': 1, **config})
with pytest.raises(UnsupportedRequestError, match=field):
sent_inference(request)


def test_a_defaulted_dataclass_field_is_dropped_without_a_refusal():
"""A bare config asks for exactly what passing none asks for, and the
engine fills the rest in whether or not the caller wanted it."""
request = gathered_with(
regression_config(),
sent={'num_estimators': 1, 'output_type': 'median'},
)
assert sent_inference(request)['inference_config'] == {'num_estimators': 1}
assert sent_inference(request) == {'run_mode': 'fast'}


def test_a_dropped_option_is_refused_with_a_remedy_that_works():
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,9 +181,7 @@ def test_the_relational_model_declares_the_deployment_it_was_read_from():
runtime = KUMO_RELATIONAL.runtime
assert {'forecasting', 'temporal_link_prediction'} <= runtime.tasks
assert 'explanation' in runtime.outputs
# Only these inference options reach the wire; the engine defaults to more.
assert 'output_type' not in runtime.inference_options
assert 'num_estimators' in runtime.inference_options
assert not runtime.inference_options


def test_an_inference_option_the_caller_set_is_named_rather_than_dropped():
Expand Down Expand Up @@ -215,7 +213,7 @@ def test_the_engine_s_statistic_travels_on_the_output_instead():
}
}
(plan,) = RelationalAdapter().prediction_plan(request, ('prediction',))
assert plan['inference']['inference_config'] == {'num_estimators': 1}
assert 'inference_config' not in plan['inference']
assert plan['output']['prediction_statistic'] == 'median'


Expand Down
7 changes: 0 additions & 7 deletions tests/golden/wire_payloads.json
Original file line number Diff line number Diff line change
Expand Up @@ -7013,13 +7013,6 @@
}
},
"inference": {
"inference_config": {
"category_shuffle": false,
"column_shuffle": false,
"hop_shuffle": false,
"kind": "regression",
"num_estimators": 1
},
"run_mode": "fast",
"use_prediction_time": false
},
Expand Down
Loading