Skip to content

Commit 26ecdd2

Browse files
authored
Send a relational inference config only when the caller set one (#16)
## Problem structured-data-nims !189 and !197 make the SDM backends (the Kumo Relational and Kumo Tabular NIMs, and the Databricks MLflow model that reuses them) refuse a non-empty `inference.inference_config` with `400 INVALID_REQUEST`. The estimator count is now fixed when the endpoint starts, through `SDM_NUM_ESTIMATORS`. This client sent the engine's default config on every KumoRelational request (`{kind, num_estimators: 1, column_shuffle: false, ...}`), because `RelationalAdapter._inference` forwarded every key whether or not the caller had set it. So every relational request failed on those backends, although none asked for anything beyond the defaults. ## Fix `RelationalAdapter._inference` now forwards only the inference-config keys the caller set and omits `inference_config` when none are left. `run_mode`, `use_prediction_time` and `output.prediction_statistic` are unchanged, and sessions use the same path. An option the caller does set, such as `num_estimators: 4`, is still sent on its own; a legacy backend honours it and an SDM backend refuses it. This is safe for the legacy kumo-ml backend: it reads `inference_config or {}` and applies the rest on top of `InferenceConfig.from_task_type(...)`, whose defaults are identical to the engine's (one estimator, shuffles off, and `quantile` transforms with a `median` output for regression). A request without the field gets exactly what this client used to send. The two Databricks relational notebooks stop passing `ENSEMBLE = {'num_estimators': 4, 'column_shuffle': True}`, which SDM backends refuse; there the ensemble size is set per endpoint through `SDM_NUM_ESTIMATORS`. `tests/golden/wire_payloads.json` is regenerated with `scripts/snapshot_wire_payloads.py --write`, and it only loses the default config from its one relational case. ## Verification Updated adapter, session and median tests cover a default request sending no config (through both transports), the engine's full defaults not being sent, and an explicit option being sent alone. They fail on main. The client suite passes (1913 passed), along with ruff, mypy and the golden snapshot check. End to end, an AdventureWorks `PREDICT SUM(sales_order_details.OrderQty, 0, 30, days) FOR EACH products.ProductID` for product 712 through `SDMClient.for_databricks_serving` against the SDM staging endpoint `kumo-rfm-2-1-stg-v23-mr149-review` returns a prediction (120.97) with no `inference_config` in the request body, while the same request from main gets `400 INVALID_REQUEST`. --------- Signed-off-by: Adi Wadaskar <awadaskar@nvidia.com>
1 parent fa0637d commit 26ecdd2

10 files changed

Lines changed: 77 additions & 44 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ versioned by [semantic versioning](https://semver.org) as
55
[`docs/reference/stability.md`](docs/reference/stability.md) defines it for
66
these packages. All five distributions share one version and one entry.
77

8+
## Unreleased
9+
10+
### Fixed
11+
12+
- A `KumoRelational` request sends `inference.inference_config` only with the
13+
options the caller set, and omits it otherwise. Sending the engine's defaults
14+
on every request made SDM-backed deployments refuse every relational request.
15+
816
## 1.0.0 - 2026-10-06
917

1018
The first release. All five distributions declare `1.0.0` and are published

‎examples/databricks/adventureworks_relational.ipynb‎

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -299,9 +299,6 @@
299299
"metadata": {},
300300
"outputs": [],
301301
"source": [
302-
"# Four estimators over shuffled columns: one estimator's answer moves with\n",
303-
"# column order, and averaging over four permutations settles it.\n",
304-
"ENSEMBLE = {'num_estimators': 4, 'column_shuffle': True}\n",
305302
"DEMAND = (\n",
306303
" 'PREDICT SUM(sales_order_details.OrderQty, 0, 30, days) '\n",
307304
" 'FOR EACH products.ProductID'\n",
@@ -310,24 +307,16 @@
310307
"QUESTIONS = {\n",
311308
" 'demand_30d': (\n",
312309
" DEMAND,\n",
313-
" {'indices': [top_product], 'inference_config': ENSEMBLE},\n",
310+
" {'indices': [top_product]},\n",
314311
" ),\n",
315312
" 'demand_30d_historical': (\n",
316313
" DEMAND,\n",
317-
" {\n",
318-
" 'indices': [top_product],\n",
319-
" 'anchor_time': anchor,\n",
320-
" 'inference_config': ENSEMBLE,\n",
321-
" },\n",
314+
" {'indices': [top_product], 'anchor_time': anchor},\n",
322315
" ),\n",
323316
" 'demand_3_timeframes': (\n",
324317
" 'PREDICT SUM(sales_order_details.OrderQty, 0, 30, days) '\n",
325318
" 'FORECAST 3 TIMEFRAMES FOR EACH products.ProductID',\n",
326-
" {\n",
327-
" 'indices': [top_product],\n",
328-
" 'anchor_time': anchor,\n",
329-
" 'inference_config': ENSEMBLE,\n",
330-
" },\n",
319+
" {'indices': [top_product], 'anchor_time': anchor},\n",
331320
" ),\n",
332321
" 'inactivity_90d': (\n",
333322
" 'PREDICT COUNT(sales_order_headers.*, 0, 90, days)=0 '\n",
@@ -431,7 +420,7 @@
431420
" lines = 'adventureworks_sales_order_details'\n",
432421
" QUESTIONS['view_demand_30d'] = (\n",
433422
" f'PREDICT SUM({lines}.OrderQty, 0, 30, days) FOR EACH products.ProductID',\n",
434-
" {'indices': [top_product], 'inference_config': ENSEMBLE},\n",
423+
" {'indices': [top_product]},\n",
435424
" )\n",
436425
" QUESTIONS['view_inactivity_90d'] = (\n",
437426
" f'PREDICT COUNT({lines}.*, 0, 90, days)=0 FOR EACH customers.CustomerID',\n",

‎examples/databricks/lakebase_relational.ipynb‎

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -278,9 +278,6 @@
278278
"metadata": {},
279279
"outputs": [],
280280
"source": [
281-
"# Four estimators over shuffled columns: one estimator's answer moves with\n",
282-
"# column order, and averaging over four permutations settles it.\n",
283-
"ENSEMBLE = {'num_estimators': 4, 'column_shuffle': True}\n",
284281
"DEMAND = (\n",
285282
" 'PREDICT SUM(sales_order_details.OrderQty, 0, 30, days) '\n",
286283
" 'FOR EACH products.ProductID'\n",
@@ -289,15 +286,11 @@
289286
"QUESTIONS = {\n",
290287
" 'demand_30d': (\n",
291288
" DEMAND,\n",
292-
" {'indices': [top_product], 'inference_config': ENSEMBLE},\n",
289+
" {'indices': [top_product]},\n",
293290
" ),\n",
294291
" 'demand_30d_historical': (\n",
295292
" DEMAND,\n",
296-
" {\n",
297-
" 'indices': [top_product],\n",
298-
" 'anchor_time': anchor,\n",
299-
" 'inference_config': ENSEMBLE,\n",
300-
" },\n",
293+
" {'indices': [top_product], 'anchor_time': anchor},\n",
301294
" ),\n",
302295
" 'demand_3_timeframes': (\n",
303296
" 'PREDICT SUM(sales_order_details.OrderQty, 0, 30, days) '\n",

‎packages/structured-data-models-client/src/sdm_client/adapters/relational.py‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ def prediction_plan(
140140
def _inference(
141141
self, inference: Any, request: Request
142142
) -> dict[str, Any] | None:
143-
"""Send only the inference options this model reads.
143+
"""Send only the inference options the caller set and this model reads.
144144
145145
The engine fills in an inference config on every request, including
146146
keys this client does not forward: `output_type`, whose choice travels
@@ -180,10 +180,15 @@ def _inference(
180180
},
181181
)
182182

183+
# Only what the caller set: a deployment applies the engine's defaults
184+
# to a request that carries none, and the SDM backends refuse any
185+
# non-empty `inference_config`, so sending the defaults broke them.
183186
trimmed = dict(inference)
184-
trimmed['inference_config'] = {
185-
key: value for key, value in config.items() if key in forwarded
186-
}
187+
sent = {key: value for key, value in config.items() if key in asked_for}
188+
if sent:
189+
trimmed['inference_config'] = sent
190+
else:
191+
del trimmed['inference_config']
187192
return trimmed
188193

189194
def session_plan(

‎packages/structured-data-models-client/src/sdm_client/materializers/relational.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from __future__ import annotations
88

9+
import copy
910
import warnings
1011
from collections.abc import Iterator, Mapping, Sequence
1112
from contextlib import contextmanager
@@ -162,8 +163,13 @@ def materialize(
162163
query=query.to_string(),
163164
indices=list(indices),
164165
entity_table=table_name(query.entity_column),
166+
# A copy: the payloads were gathered from the config as it is
167+
# now, and a caller's later edit to it must not change what the
168+
# adapter believes was asked for.
165169
requested_inference={
166-
'inference_config': options.get('inference_config') or {}
170+
'inference_config': copy.deepcopy(
171+
options.get('inference_config') or {}
172+
)
167173
},
168174
)
169175

‎packages/structured-data-models-client/tests/test_a_regression_reports_the_median.py‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,12 +125,34 @@ def test_a_regression_asks_for_the_median_by_default(requests_mock):
125125

126126
assert body['task']['kind'] == 'regression'
127127
assert body['output']['prediction_statistic'] == 'median'
128-
assert 'output_type' not in body['inference']['inference_config']
128+
assert 'inference_config' not in body['inference']
129129
# Both transports wrap the same payload, so both ask for the same thing.
130130
(wrapped,) = sent_to_databricks(request)
131131
assert without_request_id(wrapped) == without_request_id(body)
132132

133133

134+
def test_only_the_inference_option_the_caller_set_is_sent(requests_mock):
135+
request = asked(inference_config={'num_estimators': 4})
136+
(body,) = sent_to_a_nim(request, requests_mock)
137+
assert body['inference']['inference_config'] == {'num_estimators': 4}
138+
139+
140+
@pytest.mark.parametrize(
141+
'edit', [dict.clear, lambda config: config.update(num_estimators=2)]
142+
)
143+
def test_editing_the_config_after_materialize_changes_nothing_sent(
144+
edit, requests_mock
145+
):
146+
config = {'num_estimators': 4}
147+
request = asked(inference_config=config)
148+
edit(config)
149+
(body,) = sent_to_a_nim(request, requests_mock)
150+
assert body['inference']['inference_config'] == {'num_estimators': 4}
151+
assert request.requested_inference == {
152+
'inference_config': {'num_estimators': 4}
153+
}
154+
155+
134156
def test_the_mean_is_sent_when_it_is_asked_for(requests_mock):
135157
(body,) = sent_to_a_nim(asked(prediction_statistic='mean'), requests_mock)
136158
assert body['output']['prediction_statistic'] == 'mean'

‎packages/structured-data-models-client/tests/test_adapter_and_result.py‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,23 @@ def test_a_defaulted_dataclass_field_is_dropped_without_a_refusal():
325325
regression_config(),
326326
sent={'num_estimators': 1, 'output_type': 'median'},
327327
)
328-
assert sent_inference(request)['inference_config'] == {'num_estimators': 1}
328+
assert 'inference_config' not in sent_inference(request)
329+
330+
331+
def test_the_engine_s_defaults_are_not_sent():
332+
"""A deployment applies them to a request that carries none, and the
333+
SDM backends refuse a non-empty inference_config."""
334+
defaults = {
335+
'kind': 'classification',
336+
'num_estimators': 1,
337+
'column_shuffle': False,
338+
'category_shuffle': False,
339+
'hop_shuffle': False,
340+
'class_shuffle': False,
341+
}
342+
assert sent_inference(gathered_with({}, sent=defaults)) == {
343+
'run_mode': 'fast'
344+
}
329345

330346

331347
def test_a_dropped_option_is_refused_with_a_remedy_that_works():

‎packages/structured-data-models-client/tests/test_decomposition_is_faithful.py‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -253,11 +253,12 @@ def test_each_step_moves_the_window_by_its_own_span():
253253
def test_a_forecast_step_is_the_window_question_asked_on_its_own():
254254
"""Step one of a forecast is the plain question, and so reads against it.
255255
256-
The AdventureWorks notebook asks a 30 day demand question with an ensemble
257-
and the same question forecast three windows out. Its first step can only
258-
be read against the plain answer if the two requests are the same, so the
259-
ensemble has to reach every step and nothing else may differ. Each later
260-
step moves the window along and changes nothing else.
256+
The AdventureWorks notebook asks a 30 day demand question and the same
257+
question forecast three windows out. Its first step can only be read
258+
against the plain answer if the two requests are the same, so an inference
259+
config the caller set, here an ensemble, has to reach every step and
260+
nothing else may differ. Each later step moves the window along and
261+
changes nothing else.
261262
"""
262263
pytest.importorskip('sdm_relational_engine')
263264
graph = shop()

‎packages/structured-data-models-client/tests/test_limits_and_sessions.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ def test_the_engine_s_statistic_travels_on_the_output_instead():
215215
}
216216
}
217217
(plan,) = RelationalAdapter().prediction_plan(request, ('prediction',))
218-
assert plan['inference']['inference_config'] == {'num_estimators': 1}
218+
assert 'inference_config' not in plan['inference']
219219
assert plan['output']['prediction_statistic'] == 'median'
220220

221221

‎tests/golden/wire_payloads.json‎

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7013,13 +7013,6 @@
70137013
}
70147014
},
70157015
"inference": {
7016-
"inference_config": {
7017-
"category_shuffle": false,
7018-
"column_shuffle": false,
7019-
"hop_shuffle": false,
7020-
"kind": "regression",
7021-
"num_estimators": 1
7022-
},
70237016
"run_mode": "fast",
70247017
"use_prediction_time": false
70257018
},

0 commit comments

Comments
 (0)