structured-data-models-client 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sdm_client/__init__.py +70 -0
- sdm_client/_version.py +5 -0
- sdm_client/adapters/__init__.py +9 -0
- sdm_client/adapters/relational.py +870 -0
- sdm_client/adapters/tabular.py +580 -0
- sdm_client/base.py +300 -0
- sdm_client/client.py +1416 -0
- sdm_client/core/__init__.py +5 -0
- sdm_client/core/databricks_serving.py +611 -0
- sdm_client/core/databricks_user_agent.py +76 -0
- sdm_client/core/dates.py +278 -0
- sdm_client/core/transport.py +898 -0
- sdm_client/core/warnings.py +51 -0
- sdm_client/decompose.py +421 -0
- sdm_client/errors.py +177 -0
- sdm_client/materializers/__init__.py +10 -0
- sdm_client/materializers/relational.py +1786 -0
- sdm_client/materializers/sources.py +331 -0
- sdm_client/materializers/tabular.py +339 -0
- sdm_client/models.py +251 -0
- sdm_client/py.typed +0 -0
- sdm_client/query.py +647 -0
- sdm_client/request.py +1127 -0
- sdm_client/result.py +328 -0
- sdm_client/schema.py +3187 -0
- sdm_client/wire/__init__.py +46 -0
- sdm_client/wire/answers.py +80 -0
- sdm_client/wire/dtypes.py +701 -0
- sdm_client/wire/keys.py +363 -0
- sdm_client/wire/tables.py +50 -0
- structured_data_models_client-1.0.0.dist-info/METADATA +99 -0
- structured_data_models_client-1.0.0.dist-info/RECORD +36 -0
- structured_data_models_client-1.0.0.dist-info/WHEEL +5 -0
- structured_data_models_client-1.0.0.dist-info/licenses/LICENSE +204 -0
- structured_data_models_client-1.0.0.dist-info/licenses/NOTICE +10 -0
- structured_data_models_client-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,870 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES.
|
|
2
|
+
# All rights reserved.
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
r"""Sending a relational request to ``kumo-relational``.
|
|
6
|
+
|
|
7
|
+
Unlike the tabular model, this one does heavy client-side work: building the
|
|
8
|
+
graph, resolving the query, sampling each entity's neighbourhood and encoding
|
|
9
|
+
the result. All of that happens in :meth:`PredictiveQuery.materialize`, which
|
|
10
|
+
runs in the engine, so by the time a request reaches this adapter its payloads
|
|
11
|
+
are already finished.
|
|
12
|
+
|
|
13
|
+
That split is why this adapter is short. It sends what was gathered, and reads
|
|
14
|
+
the answer back. It does not reimplement the gathering, which would duplicate
|
|
15
|
+
point-in-time correctness logic that already lives in the engine and is tested
|
|
16
|
+
there.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import copy
|
|
22
|
+
import dataclasses
|
|
23
|
+
import re
|
|
24
|
+
import uuid
|
|
25
|
+
import warnings
|
|
26
|
+
from collections.abc import Iterator, Mapping
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from sdm_api.generated.tfm_api import PredictionResponse, ResponseContractError
|
|
30
|
+
|
|
31
|
+
from sdm_client.base import ModelSpec
|
|
32
|
+
from sdm_client.core.warnings import SDMWarning, caller_stacklevel
|
|
33
|
+
from sdm_client.errors import (
|
|
34
|
+
WIDEN_THE_CONTEXT,
|
|
35
|
+
NimRequestError,
|
|
36
|
+
StructuredDataError,
|
|
37
|
+
UnsupportedRequestError,
|
|
38
|
+
)
|
|
39
|
+
from sdm_client.models import (
|
|
40
|
+
DEFAULT_PREDICTION_STATISTIC,
|
|
41
|
+
KUMO_RELATIONAL,
|
|
42
|
+
SUMMARISED_TASK_KINDS,
|
|
43
|
+
)
|
|
44
|
+
from sdm_client.request import Request
|
|
45
|
+
from sdm_client.result import PredictionResult
|
|
46
|
+
from sdm_client.wire import as_declared
|
|
47
|
+
from sdm_client.wire.answers import as_declared_target
|
|
48
|
+
|
|
49
|
+
# What to ask for when the caller named nothing, by task kind. Asking for
|
|
50
|
+
# everything would make a regression request carry probabilities it cannot
|
|
51
|
+
# have, which the endpoint rejects.
|
|
52
|
+
_DEFAULT_OUTPUTS = {
|
|
53
|
+
'classification': ('prediction', 'probabilities'),
|
|
54
|
+
'binary_classification': ('prediction', 'probabilities'),
|
|
55
|
+
'multiclass_classification': ('prediction', 'probabilities'),
|
|
56
|
+
'regression': ('prediction',),
|
|
57
|
+
'forecasting': ('prediction',),
|
|
58
|
+
'temporal_link_prediction': ('rankings',),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
_SESSION_PINNED = ('model', 'task', 'schema', 'context')
|
|
62
|
+
#: Which halves of a request a call puts on the wire. Every judgement made
|
|
63
|
+
#: before sending asks only about a half that travels.
|
|
64
|
+
_SENDS_EVERYTHING = frozenset({'context', 'predict'})
|
|
65
|
+
_SENDS_A_CONTEXT = frozenset({'context'})
|
|
66
|
+
_SENDS_ROWS_TO_SCORE = frozenset({'predict'})
|
|
67
|
+
|
|
68
|
+
_SESSION_SCORING = ('predict', 'output', 'inference', 'metadata')
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _request_id() -> str:
|
|
72
|
+
return f'sdm-{uuid.uuid4().hex[:12]}'
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _keys_the_caller_set(requested: Any) -> set[str]:
|
|
76
|
+
r"""The inference-config keys that were asked for rather than defaulted.
|
|
77
|
+
|
|
78
|
+
The engine takes ``InferenceConfig | dict | None`` and this client
|
|
79
|
+
publishes nothing narrower, so a caller may hand over the dataclass. It is
|
|
80
|
+
not iterable, and reading it as a set of keys destroyed a request whose
|
|
81
|
+
payload was already gathered and correct.
|
|
82
|
+
|
|
83
|
+
A dataclass carries every field whether or not it was set, so only the
|
|
84
|
+
ones differing from their default are a request for anything. Counting the
|
|
85
|
+
rest would refuse a bare ``RegressionInferenceConfig()``, which asks for
|
|
86
|
+
exactly what passing no config at all asks for.
|
|
87
|
+
"""
|
|
88
|
+
if isinstance(requested, Mapping):
|
|
89
|
+
return set(requested)
|
|
90
|
+
if dataclasses.is_dataclass(requested) and not isinstance(requested, type):
|
|
91
|
+
return {
|
|
92
|
+
field.name
|
|
93
|
+
for field in dataclasses.fields(requested)
|
|
94
|
+
if getattr(requested, field.name) != _default_of(field)
|
|
95
|
+
}
|
|
96
|
+
return set()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _default_of(field: dataclasses.Field) -> Any:
|
|
100
|
+
if field.default_factory is not dataclasses.MISSING:
|
|
101
|
+
return field.default_factory()
|
|
102
|
+
return field.default
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class RelationalAdapter:
|
|
106
|
+
r"""Shapes and reads ``kumo-relational`` requests."""
|
|
107
|
+
|
|
108
|
+
spec: ModelSpec = KUMO_RELATIONAL
|
|
109
|
+
|
|
110
|
+
def default_output(self, request: Request) -> tuple[str, ...]:
|
|
111
|
+
return _DEFAULT_OUTPUTS.get(request.task.kind, ('prediction',))
|
|
112
|
+
|
|
113
|
+
def prediction_plan(
|
|
114
|
+
self,
|
|
115
|
+
request: Request,
|
|
116
|
+
outputs: tuple[str, ...],
|
|
117
|
+
sends: frozenset[str] = _SENDS_EVERYTHING,
|
|
118
|
+
) -> Iterator[dict[str, Any]]:
|
|
119
|
+
payloads = self._payloads(request)
|
|
120
|
+
self._preflight(payloads, outputs, request.query, sends)
|
|
121
|
+
for payload in payloads:
|
|
122
|
+
envelope = dict(payload)
|
|
123
|
+
envelope['model'] = self.spec.model_id
|
|
124
|
+
envelope['output'] = _output_for(payload, outputs)
|
|
125
|
+
# The gathered payload's own metadata, copied. Shared, writing the
|
|
126
|
+
# call's id into it wrote it into the request. Thus, planning the
|
|
127
|
+
# same request twice changed the id of the envelope already handed
|
|
128
|
+
# out, and the one a caller had recorded named a different call.
|
|
129
|
+
envelope['metadata'] = {
|
|
130
|
+
**(payload.get('metadata') or {}),
|
|
131
|
+
'request_id': _request_id(),
|
|
132
|
+
}
|
|
133
|
+
inference = self._inference(envelope.get('inference'), request)
|
|
134
|
+
if inference is None:
|
|
135
|
+
envelope.pop('inference', None)
|
|
136
|
+
else:
|
|
137
|
+
envelope['inference'] = inference
|
|
138
|
+
yield envelope
|
|
139
|
+
|
|
140
|
+
def _inference(
|
|
141
|
+
self, inference: Any, request: Request
|
|
142
|
+
) -> dict[str, Any] | None:
|
|
143
|
+
"""Send only the inference options the caller set and this model reads.
|
|
144
|
+
|
|
145
|
+
The engine fills in an inference config on every request, including
|
|
146
|
+
keys this client does not forward: `output_type`, whose choice travels
|
|
147
|
+
as ``output.prediction_statistic`` instead, and `target_transforms`.
|
|
148
|
+
Dropping the engine's defaults is safe. If the *caller* asked for one,
|
|
149
|
+
it is named rather than silently discarded, because a dropped request
|
|
150
|
+
for four estimators is a different prediction than the one that was
|
|
151
|
+
wanted.
|
|
152
|
+
|
|
153
|
+
Forwarding a key is not a promise that the endpoint serves it. This is
|
|
154
|
+
a name-level filter over what the engine sends; whether a deployment
|
|
155
|
+
accepts the *value* is its own answer, and it may still refuse.
|
|
156
|
+
"""
|
|
157
|
+
if not isinstance(inference, dict):
|
|
158
|
+
return None
|
|
159
|
+
config = inference.get('inference_config')
|
|
160
|
+
if not isinstance(config, dict):
|
|
161
|
+
return dict(inference)
|
|
162
|
+
|
|
163
|
+
forwarded = self.spec.runtime.inference_options
|
|
164
|
+
unsupported = sorted(set(config) - forwarded)
|
|
165
|
+
asked_for = _keys_the_caller_set(
|
|
166
|
+
request.requested_inference.get('inference_config')
|
|
167
|
+
)
|
|
168
|
+
refused = sorted(set(unsupported) & asked_for)
|
|
169
|
+
if refused:
|
|
170
|
+
raise UnsupportedRequestError(
|
|
171
|
+
f'{self.spec.model_id} does not read inference option(s) '
|
|
172
|
+
f'{refused}; drop them from inference_config to send the '
|
|
173
|
+
f'request without them. The keys this client forwards are '
|
|
174
|
+
f'{sorted(forwarded)}, and the endpoint decides for itself '
|
|
175
|
+
f'whether it serves the value given for one'
|
|
176
|
+
+ _where_the_statistic_is_chosen(refused),
|
|
177
|
+
details={
|
|
178
|
+
'model': self.spec.model_id,
|
|
179
|
+
'unsupported': refused,
|
|
180
|
+
},
|
|
181
|
+
)
|
|
182
|
+
|
|
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.
|
|
186
|
+
trimmed = dict(inference)
|
|
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']
|
|
192
|
+
return trimmed
|
|
193
|
+
|
|
194
|
+
def session_plan(
|
|
195
|
+
self, request: Request, outputs: tuple[str, ...]
|
|
196
|
+
) -> dict[str, Any]:
|
|
197
|
+
payloads = self._payloads(request)
|
|
198
|
+
if len(payloads) > 1:
|
|
199
|
+
# A session pins one context. Several gathered parts each carry
|
|
200
|
+
# their own, so pinning the first would score later batches
|
|
201
|
+
# against the wrong one.
|
|
202
|
+
raise UnsupportedRequestError(
|
|
203
|
+
f'this request gathered {len(payloads)} batches, and a session '
|
|
204
|
+
f'pins one context; use predict() instead',
|
|
205
|
+
details={'batches': len(payloads)},
|
|
206
|
+
)
|
|
207
|
+
envelope = next(
|
|
208
|
+
iter(self.prediction_plan(request, outputs, _SENDS_A_CONTEXT))
|
|
209
|
+
)
|
|
210
|
+
pinned = {
|
|
211
|
+
key: envelope[key] for key in _SESSION_PINNED if key in envelope
|
|
212
|
+
}
|
|
213
|
+
pinned['metadata'] = envelope['metadata']
|
|
214
|
+
# The gathered envelopes are the request's own, and a plan built by
|
|
215
|
+
# naming their keys shares them. A holdout edits the pinned context in
|
|
216
|
+
# place, so it was editing the request: a second fit of the same
|
|
217
|
+
# request, with no holdout, pinned the already-reduced context.
|
|
218
|
+
if 'context' in pinned:
|
|
219
|
+
pinned['context'] = copy.deepcopy(pinned['context'])
|
|
220
|
+
return pinned
|
|
221
|
+
|
|
222
|
+
def session_prediction_plan(
|
|
223
|
+
self,
|
|
224
|
+
request: Request,
|
|
225
|
+
outputs: tuple[str, ...],
|
|
226
|
+
pinned: Mapping[str, Any] | None = None,
|
|
227
|
+
) -> Iterator[dict[str, Any]]:
|
|
228
|
+
for envelope in self.prediction_plan(
|
|
229
|
+
request, outputs, _SENDS_ROWS_TO_SCORE
|
|
230
|
+
):
|
|
231
|
+
yield {
|
|
232
|
+
key: envelope[key]
|
|
233
|
+
for key in _SESSION_SCORING
|
|
234
|
+
if key in envelope
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
def normalize(
|
|
238
|
+
self,
|
|
239
|
+
response: dict[str, Any],
|
|
240
|
+
outputs: tuple[str, ...],
|
|
241
|
+
payload: dict[str, Any] | None = None,
|
|
242
|
+
) -> PredictionResult:
|
|
243
|
+
try:
|
|
244
|
+
parsed = PredictionResponse.from_dict(response)
|
|
245
|
+
except (ResponseContractError, TypeError, ValueError) as error:
|
|
246
|
+
raise StructuredDataError(
|
|
247
|
+
f'{self.spec.model_id} returned a response that does not '
|
|
248
|
+
f'match the contract: {error}',
|
|
249
|
+
code='INVALID_RESPONSE',
|
|
250
|
+
) from error
|
|
251
|
+
|
|
252
|
+
explanation = next(
|
|
253
|
+
(
|
|
254
|
+
item.explanation
|
|
255
|
+
for item in parsed.predictions
|
|
256
|
+
if item.explanation
|
|
257
|
+
),
|
|
258
|
+
None,
|
|
259
|
+
)
|
|
260
|
+
return PredictionResult(
|
|
261
|
+
model=parsed.model,
|
|
262
|
+
request_id=parsed.id,
|
|
263
|
+
items=as_declared_target(
|
|
264
|
+
_named_entities(parsed.predictions, payload),
|
|
265
|
+
payload,
|
|
266
|
+
),
|
|
267
|
+
metadata=parsed.metadata,
|
|
268
|
+
outputs=outputs,
|
|
269
|
+
explanation=explanation,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
def explain_error(
|
|
273
|
+
self, error: Exception, payload: dict[str, Any]
|
|
274
|
+
) -> Exception:
|
|
275
|
+
r"""Name the columns behind a semantic-type refusal.
|
|
276
|
+
|
|
277
|
+
``semantic type 'text' is unsupported`` is true and unactionable:
|
|
278
|
+
``Schema.from_data`` marks any string column with more than 64 distinct
|
|
279
|
+
values as ``text``, so a real dataset acquires them in bulk and the
|
|
280
|
+
endpoint names none of them. The payload it just refused does.
|
|
281
|
+
|
|
282
|
+
The deployments word this two ways and only one of them was matched, so
|
|
283
|
+
on the one that says ``categorical cardinality 13812 exceeds limit
|
|
284
|
+
10000`` the hook never ran and the caller got a number and a limit
|
|
285
|
+
against a graph of twenty-two string columns. Both forms are read now,
|
|
286
|
+
and the second names the columns by counting the payload rather than by
|
|
287
|
+
reading a declared type, because a cardinality complaint is about the
|
|
288
|
+
values.
|
|
289
|
+
|
|
290
|
+
A different complaint needs a different remedy. Where the columns are
|
|
291
|
+
already categorical, telling their owner to make them categorical
|
|
292
|
+
would be advice that changes nothing.
|
|
293
|
+
"""
|
|
294
|
+
columns, complaint = _columns_the_endpoint_means(error, payload)
|
|
295
|
+
if not columns:
|
|
296
|
+
return error
|
|
297
|
+
if complaint == 'cardinality':
|
|
298
|
+
message = (
|
|
299
|
+
f'{getattr(error, "message", error)}. In this request that is '
|
|
300
|
+
f'{", ".join(columns)}. A column with that many distinct '
|
|
301
|
+
f'values carries no signal the model can use; leave it out '
|
|
302
|
+
f'with materialize(..., exclude_cols_dict=...), or replace it '
|
|
303
|
+
f'with something coarser.'
|
|
304
|
+
)
|
|
305
|
+
return _reworded(error, message)
|
|
306
|
+
# The payload does not carry the entity table's real name,
|
|
307
|
+
# `instance_table` is what the wire calls it. Thus, an example naming
|
|
308
|
+
# it would render a line the caller cannot run. Prefer a related
|
|
309
|
+
# table, whose name did survive, and fall back to naming no table at
|
|
310
|
+
# all rather than a made-up one.
|
|
311
|
+
named = next(
|
|
312
|
+
(c for c in columns if not c.startswith('instance_table.')), None
|
|
313
|
+
)
|
|
314
|
+
if named is not None:
|
|
315
|
+
table, _, column = named.partition('.')
|
|
316
|
+
suggestion = f"schema[{table!r}][{column!r}].stype = 'categorical'"
|
|
317
|
+
else:
|
|
318
|
+
_, _, column = columns[0].partition('.')
|
|
319
|
+
suggestion = (
|
|
320
|
+
f"schema[<the entity table>][{column!r}].stype = 'categorical'"
|
|
321
|
+
)
|
|
322
|
+
message = (
|
|
323
|
+
f'{getattr(error, "message", error)}. In this request that is '
|
|
324
|
+
f'{", ".join(columns)}. Set a supported type on each, e.g. '
|
|
325
|
+
f'{suggestion}'
|
|
326
|
+
)
|
|
327
|
+
return _reworded(error, message)
|
|
328
|
+
|
|
329
|
+
def close(self) -> None:
|
|
330
|
+
r"""Nothing to release: gathering happens in the query, which owns
|
|
331
|
+
whatever it opened, and sending goes through the client's transport."""
|
|
332
|
+
|
|
333
|
+
def _preflight(
|
|
334
|
+
self,
|
|
335
|
+
payloads: tuple[dict[str, Any], ...],
|
|
336
|
+
outputs: tuple[str, ...],
|
|
337
|
+
asked: str | None = None,
|
|
338
|
+
sends: frozenset[str] = _SENDS_EVERYTHING,
|
|
339
|
+
) -> None:
|
|
340
|
+
"""Refuse what the endpoint enforces, before spending the round trip.
|
|
341
|
+
|
|
342
|
+
An explanation is the narrow case: the model explains one entity at a
|
|
343
|
+
time, and only in the mode the smaller context budget selects. Sending
|
|
344
|
+
more gets a 500 that names neither limit, so both are checked here.
|
|
345
|
+
|
|
346
|
+
`sends` names the halves this call puts on the wire. A session pins its
|
|
347
|
+
context at `fit()` and a scoring call carries only rows, so judging the
|
|
348
|
+
context there refuses a request over rows that are thrown away, while
|
|
349
|
+
judging the rows when pinning refuses over rows that are not sent at
|
|
350
|
+
all. Every check below asks only about a half that travels.
|
|
351
|
+
"""
|
|
352
|
+
limits = self.spec.runtime.limits
|
|
353
|
+
if 'context' in sends:
|
|
354
|
+
self._check_context_is_not_empty(payloads)
|
|
355
|
+
self._check_class_count(payloads, limits)
|
|
356
|
+
self._check_target_varies(payloads, asked)
|
|
357
|
+
if 'explanation' not in outputs:
|
|
358
|
+
return
|
|
359
|
+
|
|
360
|
+
scored = sum(
|
|
361
|
+
len(
|
|
362
|
+
payload.get('predict', {})
|
|
363
|
+
.get('instance_table', {})
|
|
364
|
+
.get('rows', [])
|
|
365
|
+
)
|
|
366
|
+
for payload in payloads
|
|
367
|
+
)
|
|
368
|
+
allowed = limits['explanation_predict_rows']
|
|
369
|
+
if 'predict' in sends and scored != allowed:
|
|
370
|
+
raise UnsupportedRequestError(
|
|
371
|
+
f'an explanation covers exactly {allowed} entity, and this '
|
|
372
|
+
f'request scores {scored}; ask for one at a time',
|
|
373
|
+
details={'model': self.spec.model_id, 'scored': scored},
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
cap = limits['context_rows_fast']
|
|
377
|
+
if 'context' in sends and _asks_for_the_larger_context(payloads, cap):
|
|
378
|
+
raise UnsupportedRequestError(
|
|
379
|
+
f'an explanation is only produced in the mode the smaller '
|
|
380
|
+
f'context budget selects, and this request was gathered with '
|
|
381
|
+
f'num_context_samples above {cap:,}. Re-materialize with '
|
|
382
|
+
f'num_context_samples={cap} or fewer',
|
|
383
|
+
details={
|
|
384
|
+
'model': self.spec.model_id,
|
|
385
|
+
'context_rows_fast': cap,
|
|
386
|
+
},
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
def _check_class_count(
|
|
390
|
+
self, payloads: tuple[dict[str, Any], ...], limits: Mapping[str, int]
|
|
391
|
+
) -> None:
|
|
392
|
+
r"""A classification target the model cannot represent is a size too.
|
|
393
|
+
|
|
394
|
+
The endpoint answers ``422 UNSUPPORTED_FEATURE`` with both numbers in
|
|
395
|
+
it, which is a decent message. However, it arrives after gathering,
|
|
396
|
+
encoding, and sending the context, and the count is knowable here.
|
|
397
|
+
"""
|
|
398
|
+
ceiling = limits.get('classes_max')
|
|
399
|
+
if not ceiling:
|
|
400
|
+
return
|
|
401
|
+
for payload in payloads:
|
|
402
|
+
kind = str(payload.get('task', {}).get('kind', ''))
|
|
403
|
+
if 'classification' not in kind:
|
|
404
|
+
continue
|
|
405
|
+
column = (
|
|
406
|
+
payload.get('task', {}).get('target', {}).get('column_name')
|
|
407
|
+
)
|
|
408
|
+
labels = _distinct_labels(payload, column)
|
|
409
|
+
if labels is not None and labels > ceiling:
|
|
410
|
+
raise UnsupportedRequestError(
|
|
411
|
+
f'{self.spec.model_id} classifies at most {ceiling} '
|
|
412
|
+
f'classes, and the gathered context carries {labels} '
|
|
413
|
+
f'distinct values of the target; predict a coarser '
|
|
414
|
+
f'target, or narrow the context to the classes you care '
|
|
415
|
+
f'about',
|
|
416
|
+
details={
|
|
417
|
+
'model': self.spec.model_id,
|
|
418
|
+
'classes': labels,
|
|
419
|
+
'limit': ceiling,
|
|
420
|
+
},
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
def _check_context_is_not_empty(
|
|
424
|
+
self, payloads: tuple[dict[str, Any], ...]
|
|
425
|
+
) -> None:
|
|
426
|
+
r"""Refuse a context this client emptied, rather than sending it.
|
|
427
|
+
|
|
428
|
+
Scoring every row of the entity table takes every row out of the
|
|
429
|
+
context. One row left is refused; none fell through the same check,
|
|
430
|
+
because zero distinct answers is not one.
|
|
431
|
+
"""
|
|
432
|
+
for payload in payloads:
|
|
433
|
+
table = payload.get('context', {}).get('instance_table')
|
|
434
|
+
if not isinstance(table, dict) or table.get('rows'):
|
|
435
|
+
continue
|
|
436
|
+
raise UnsupportedRequestError(
|
|
437
|
+
'no context rows are left: every row that could be context is '
|
|
438
|
+
'also being scored, and a row carrying the answer being asked '
|
|
439
|
+
'for is not sent as context. Score fewer rows, so that the '
|
|
440
|
+
'rest are there to learn from',
|
|
441
|
+
details={'model': self.spec.model_id, 'context_rows': 0},
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
def _check_target_varies(
|
|
445
|
+
self, payloads: tuple[dict[str, Any], ...], asked: str | None = None
|
|
446
|
+
) -> None:
|
|
447
|
+
r"""Refuse or note a context whose every row says the same thing.
|
|
448
|
+
|
|
449
|
+
The tabular route judges this before it sends and the relational one
|
|
450
|
+
said nothing, so the same unlearnable question was refused by one model
|
|
451
|
+
and answered by the other. The gathered payload carries the evidence
|
|
452
|
+
either way: the target column of the context it is about to send.
|
|
453
|
+
|
|
454
|
+
A classification over one class is refused by the endpoint, so it is
|
|
455
|
+
refused here. A regression over a constant is accepted there, so it is
|
|
456
|
+
only warned about. A forecast and a link prediction are neither: their
|
|
457
|
+
targets are a horizon and a candidate list, and one value in the column
|
|
458
|
+
does not mean the same thing.
|
|
459
|
+
|
|
460
|
+
The wire calls this column `TARGET` whatever the question was, and a
|
|
461
|
+
message naming it describes something the caller never wrote. Where
|
|
462
|
+
the target is a column of theirs the query says so and it is named;
|
|
463
|
+
where it is an aggregation or a predicate there is no column to name,
|
|
464
|
+
and the question is quoted instead.
|
|
465
|
+
"""
|
|
466
|
+
for payload in payloads:
|
|
467
|
+
kind = str(payload.get('task', {}).get('kind', ''))
|
|
468
|
+
if 'classification' not in kind and kind != 'regression':
|
|
469
|
+
continue
|
|
470
|
+
column = (
|
|
471
|
+
payload.get('task', {}).get('target', {}).get('column_name')
|
|
472
|
+
)
|
|
473
|
+
values = _answered_target_values(payload, column)
|
|
474
|
+
if values is None or len(values) != 1:
|
|
475
|
+
continue
|
|
476
|
+
said = _how_to_name_the_target(column, asked)
|
|
477
|
+
if 'classification' in kind:
|
|
478
|
+
raise UnsupportedRequestError(
|
|
479
|
+
f'every context row has the same {said}, and a '
|
|
480
|
+
f'classification needs at least two classes to learn '
|
|
481
|
+
f'from. {WIDEN_THE_CONTEXT}',
|
|
482
|
+
details={
|
|
483
|
+
'model': self.spec.model_id,
|
|
484
|
+
'target': column,
|
|
485
|
+
'classes_in_context': 1,
|
|
486
|
+
},
|
|
487
|
+
)
|
|
488
|
+
warnings.warn(
|
|
489
|
+
f'every context row has the same {said}, so nothing in the '
|
|
490
|
+
f'context distinguishes one answer from another. The endpoint '
|
|
491
|
+
f'will answer, but the answer cannot say much. Gather a wider '
|
|
492
|
+
f'context with num_context_samples, or ask about entities '
|
|
493
|
+
f'whose answers differ.',
|
|
494
|
+
category=SDMWarning,
|
|
495
|
+
stacklevel=caller_stacklevel(),
|
|
496
|
+
)
|
|
497
|
+
return
|
|
498
|
+
|
|
499
|
+
@staticmethod
|
|
500
|
+
def _payloads(request: Request) -> tuple[dict[str, Any], ...]:
|
|
501
|
+
payloads = request.payloads
|
|
502
|
+
if not payloads:
|
|
503
|
+
raise UnsupportedRequestError(
|
|
504
|
+
'a relational request has to be gathered first, and this '
|
|
505
|
+
'one was not. Build it with '
|
|
506
|
+
'PredictiveQuery(...).materialize(indices=[...], '
|
|
507
|
+
"model='kumo-relational'). A schema of one table is "
|
|
508
|
+
'gathered for the tabular model unless the relational one is '
|
|
509
|
+
'named, rather than with Request(context=..., '
|
|
510
|
+
'predict=...), which is the single-table form',
|
|
511
|
+
details={'model': KUMO_RELATIONAL.model_id},
|
|
512
|
+
)
|
|
513
|
+
return payloads
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _output_for(
|
|
517
|
+
payload: Mapping[str, Any], outputs: tuple[str, ...]
|
|
518
|
+
) -> dict[str, Any]:
|
|
519
|
+
r"""The output section of one call: its fields, and for a regression the
|
|
520
|
+
statistic its point prediction reports, the median unless one was chosen."""
|
|
521
|
+
output: dict[str, Any] = {'fields': list(outputs)}
|
|
522
|
+
if str(payload.get('task', {}).get('kind', '')) in SUMMARISED_TASK_KINDS:
|
|
523
|
+
chosen = (payload.get('output') or {}).get('prediction_statistic')
|
|
524
|
+
output['prediction_statistic'] = chosen or DEFAULT_PREDICTION_STATISTIC
|
|
525
|
+
return output
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _where_the_statistic_is_chosen(refused: list[str]) -> str:
|
|
529
|
+
r"""Point an `output_type` at the option that replaced it."""
|
|
530
|
+
if 'output_type' not in refused:
|
|
531
|
+
return ''
|
|
532
|
+
return (
|
|
533
|
+
f". A regression's statistic is chosen with materialize(..., "
|
|
534
|
+
f'prediction_statistic=...) instead: '
|
|
535
|
+
f"{DEFAULT_PREDICTION_STATISTIC!r}, the default, or 'mean'"
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
_UNSUPPORTED_STYPE = re.compile(r"semantic type '([^']+)' is unsupported")
|
|
540
|
+
|
|
541
|
+
#: The other wording, from the deployment that counts rather than names.
|
|
542
|
+
_TOO_MANY_LABELS = re.compile(
|
|
543
|
+
r'cardinality (\d+) exceeds limit (\d+)', re.IGNORECASE
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _columns_the_endpoint_means(
|
|
548
|
+
error: Exception, payload: Mapping[str, Any]
|
|
549
|
+
) -> tuple[list[str], str]:
|
|
550
|
+
r"""The columns a refusal is about, and which complaint it was."""
|
|
551
|
+
said = str(error)
|
|
552
|
+
named = _UNSUPPORTED_STYPE.search(said)
|
|
553
|
+
if named is not None:
|
|
554
|
+
return _columns_with_stype(payload, named.group(1)), 'stype'
|
|
555
|
+
counted = _TOO_MANY_LABELS.search(said)
|
|
556
|
+
if counted is None:
|
|
557
|
+
return [], ''
|
|
558
|
+
return _columns_over(payload, int(counted.group(2))), 'cardinality'
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _reworded(error: Exception, message: str) -> Exception:
|
|
562
|
+
r"""The same refusal, saying more."""
|
|
563
|
+
if isinstance(error, NimRequestError):
|
|
564
|
+
return NimRequestError(
|
|
565
|
+
error.status_code,
|
|
566
|
+
code=error.code,
|
|
567
|
+
message=message,
|
|
568
|
+
details=error.details,
|
|
569
|
+
)
|
|
570
|
+
rebuilt = StructuredDataError(
|
|
571
|
+
message,
|
|
572
|
+
code=getattr(error, 'code', None),
|
|
573
|
+
details=getattr(error, 'details', None),
|
|
574
|
+
)
|
|
575
|
+
status = getattr(error, 'status_code', None)
|
|
576
|
+
if status is not None:
|
|
577
|
+
# `fit()` reads this to decide whether an endpoint simply does not
|
|
578
|
+
# serve sessions; losing it turns a clean fallback into a failure.
|
|
579
|
+
rebuilt.status_code = status
|
|
580
|
+
return rebuilt
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _columns_over(payload: Mapping[str, Any], limit: int) -> list[str]:
|
|
584
|
+
r"""Every ``table.column`` holding more distinct values than ``limit``.
|
|
585
|
+
|
|
586
|
+
The endpoint reports one number and the limit it broke, which says nothing
|
|
587
|
+
about where to look. The rows it just refused do.
|
|
588
|
+
|
|
589
|
+
Counted by the engine's own helper rather than here. What the guard counts
|
|
590
|
+
is not every cell: it is string cells only, across the predict half as well
|
|
591
|
+
as the context, reduced to the larger of the two. A text cell travels
|
|
592
|
+
as a list of tokens, which it counts individually. Counting it a second way
|
|
593
|
+
here would name columns the endpoint was not talking about, a numeric key
|
|
594
|
+
among them.
|
|
595
|
+
"""
|
|
596
|
+
try:
|
|
597
|
+
from sdm_relational_engine.rfm.inference.payload import (
|
|
598
|
+
high_cardinality_columns,
|
|
599
|
+
)
|
|
600
|
+
except ImportError: # pragma: no cover - the extra is optional
|
|
601
|
+
return []
|
|
602
|
+
return [
|
|
603
|
+
f'{table}.{column} ({count:,} values)'
|
|
604
|
+
for table, column, count in high_cardinality_columns(
|
|
605
|
+
dict(payload), limit
|
|
606
|
+
)
|
|
607
|
+
]
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _asks_for_the_larger_context(
|
|
611
|
+
payloads: tuple[dict[str, Any], ...], cap: int
|
|
612
|
+
) -> bool:
|
|
613
|
+
r"""Whether this request selected the mode that will not explain.
|
|
614
|
+
|
|
615
|
+
The endpoint gates explaining on ``inference.run_mode``, which the client
|
|
616
|
+
derives from the *requested* ``num_context_samples``. Counting the gathered
|
|
617
|
+
rows instead misses every graph that supplies less context than was asked
|
|
618
|
+
for: 120 rows gathered under a request for 1,500 passed this guard and the
|
|
619
|
+
endpoint's refusal escaped, naming a knob the caller never set.
|
|
620
|
+
|
|
621
|
+
A payload the engine did not gather carries no ``run_mode``, and for that
|
|
622
|
+
one the rows are all there is to read.
|
|
623
|
+
"""
|
|
624
|
+
modes = {
|
|
625
|
+
str(payload['inference']['run_mode'])
|
|
626
|
+
for payload in payloads
|
|
627
|
+
if isinstance(payload.get('inference'), Mapping)
|
|
628
|
+
and payload['inference'].get('run_mode') is not None
|
|
629
|
+
}
|
|
630
|
+
if modes:
|
|
631
|
+
return modes != {'fast'}
|
|
632
|
+
return (
|
|
633
|
+
sum(
|
|
634
|
+
len(
|
|
635
|
+
payload.get('context', {})
|
|
636
|
+
.get('instance_table', {})
|
|
637
|
+
.get('rows', [])
|
|
638
|
+
)
|
|
639
|
+
for payload in payloads
|
|
640
|
+
)
|
|
641
|
+
> cap
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
_WIRE_TARGET = 'TARGET'
|
|
646
|
+
_A_PREDICTED_COLUMN = re.compile(
|
|
647
|
+
r'^\s*PREDICT\s+([A-Za-z_]\w*\.[A-Za-z_]\w*)\s', re.IGNORECASE
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _how_to_name_the_target(column: str | None, asked: str | None) -> str:
|
|
652
|
+
"""What to call the thing every context row agrees about.
|
|
653
|
+
|
|
654
|
+
`TARGET` is the name the relational wire gives the label column whatever
|
|
655
|
+
the question was, so printing it describes a column of nobody's data.
|
|
656
|
+
"""
|
|
657
|
+
if column != _WIRE_TARGET:
|
|
658
|
+
return str(column)
|
|
659
|
+
named = _A_PREDICTED_COLUMN.match(asked or '')
|
|
660
|
+
if named is not None:
|
|
661
|
+
return named.group(1)
|
|
662
|
+
return f'answer to {asked!r}' if asked else 'answer'
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _answered_target_values(
|
|
666
|
+
payload: Mapping[str, Any], column: Any
|
|
667
|
+
) -> set[Any] | None:
|
|
668
|
+
r"""The distinct answers the context holds for ``column``, nulls aside.
|
|
669
|
+
|
|
670
|
+
``None`` when the context does not carry the target at all, or carries no
|
|
671
|
+
answered row: neither is a context that teaches one thing.
|
|
672
|
+
"""
|
|
673
|
+
table = payload.get('context', {}).get('instance_table', {})
|
|
674
|
+
columns = table.get('columns') or []
|
|
675
|
+
rows = table.get('rows') or []
|
|
676
|
+
if not columns or not rows or column not in columns:
|
|
677
|
+
return None
|
|
678
|
+
position = columns.index(column)
|
|
679
|
+
answered = {
|
|
680
|
+
row[position]
|
|
681
|
+
for row in rows
|
|
682
|
+
if len(row) > position and row[position] is not None
|
|
683
|
+
}
|
|
684
|
+
return answered or None
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _distinct_labels(payload: Mapping[str, Any], column: Any) -> int | None:
|
|
688
|
+
r"""How many distinct values the context holds for ``column``."""
|
|
689
|
+
table = payload.get('context', {}).get('instance_table', {})
|
|
690
|
+
columns = table.get('columns') or []
|
|
691
|
+
rows = table.get('rows') or []
|
|
692
|
+
if not columns or not rows or column not in columns:
|
|
693
|
+
return None
|
|
694
|
+
position = columns.index(column)
|
|
695
|
+
return len({row[position] for row in rows if len(row) > position})
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _columns_with_stype(payload: Mapping[str, Any], stype: str) -> list[str]:
|
|
699
|
+
r"""Every ``table.column`` in the payload declared as ``stype``.
|
|
700
|
+
|
|
701
|
+
The endpoint refuses an unsupported semantic type without saying which
|
|
702
|
+
column carries it, and a real dataset acquires them in bulk. Northwind
|
|
703
|
+
produces fifteen. The payload that was just refused knows.
|
|
704
|
+
|
|
705
|
+
A relational schema is ``instance_table`` plus ``related_tables``, so both
|
|
706
|
+
shapes are walked; the instance table's own name is not in the payload, and
|
|
707
|
+
``instance_table`` is what the wire calls it.
|
|
708
|
+
"""
|
|
709
|
+
found = []
|
|
710
|
+
schema = payload.get('schema') or {}
|
|
711
|
+
tables = {'instance_table': schema.get('instance_table') or {}}
|
|
712
|
+
tables.update(schema.get('related_tables') or {})
|
|
713
|
+
for table_name, table in tables.items():
|
|
714
|
+
if not isinstance(table, Mapping):
|
|
715
|
+
continue
|
|
716
|
+
for column_name, spec in (table.get('columns') or {}).items():
|
|
717
|
+
if isinstance(spec, Mapping) and spec.get('stype') == stype:
|
|
718
|
+
found.append(f'{table_name}.{column_name}')
|
|
719
|
+
return sorted(found)
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _named_entities(
|
|
723
|
+
items: tuple[Any, ...], payload: dict[str, Any] | None
|
|
724
|
+
) -> list[Any]:
|
|
725
|
+
"""Put the caller's entity back on each row, and check it belongs there.
|
|
726
|
+
|
|
727
|
+
Entity values never travel: the engine sends an opaque reference and an
|
|
728
|
+
internal instance id, so a response's ``id`` is the transport key rather
|
|
729
|
+
than the user's key. The reference column carries the entity and the
|
|
730
|
+
instance-id column carries the key the endpoint answers under, so the two
|
|
731
|
+
together restore the mapping. It is read from the payload that produced
|
|
732
|
+
this response, so a run split into several calls maps each batch against
|
|
733
|
+
its own rows.
|
|
734
|
+
|
|
735
|
+
**A mismatch is an error, not a silent pass.** Rewriting an id is exactly
|
|
736
|
+
the operation that can misattribute an answer, so every row is checked
|
|
737
|
+
against the batch it claims to belong to before its id is replaced.
|
|
738
|
+
|
|
739
|
+
Forecasting is the one task that answers a row more than once, and only
|
|
740
|
+
then: a row may repeat across steps, but not within one.
|
|
741
|
+
"""
|
|
742
|
+
if payload is None:
|
|
743
|
+
return list(items)
|
|
744
|
+
table = payload.get('predict', {}).get('instance_table', {})
|
|
745
|
+
columns = table.get('columns') or []
|
|
746
|
+
rows = table.get('rows') or []
|
|
747
|
+
reference = next(
|
|
748
|
+
(name for name in columns if name.startswith('__kumo_entity_ref')),
|
|
749
|
+
None,
|
|
750
|
+
)
|
|
751
|
+
if reference is None:
|
|
752
|
+
return list(items)
|
|
753
|
+
|
|
754
|
+
entities = [
|
|
755
|
+
as_declared(
|
|
756
|
+
row[columns.index(reference)], _key_dtype(payload, reference)
|
|
757
|
+
)
|
|
758
|
+
for row in rows
|
|
759
|
+
]
|
|
760
|
+
instances = (
|
|
761
|
+
[row[columns.index('instance_id')] for row in rows]
|
|
762
|
+
if 'instance_id' in columns
|
|
763
|
+
else None
|
|
764
|
+
)
|
|
765
|
+
forecasting = _forecast_task(payload)
|
|
766
|
+
|
|
767
|
+
named: list[Any] = []
|
|
768
|
+
seen: set[tuple[int, int | None]] = set()
|
|
769
|
+
for item in items:
|
|
770
|
+
index = item.row_index
|
|
771
|
+
if index is None:
|
|
772
|
+
raise StructuredDataError(
|
|
773
|
+
'the response is missing a row_index, so its answer cannot be '
|
|
774
|
+
'attributed to an entity',
|
|
775
|
+
code='INVALID_RESPONSE',
|
|
776
|
+
)
|
|
777
|
+
if not 0 <= index < len(entities):
|
|
778
|
+
raise StructuredDataError(
|
|
779
|
+
f'the response names row_index {index}, and this batch asked '
|
|
780
|
+
f'about {len(entities)} rows',
|
|
781
|
+
code='INVALID_RESPONSE',
|
|
782
|
+
)
|
|
783
|
+
|
|
784
|
+
step = item.forecast_step
|
|
785
|
+
if forecasting:
|
|
786
|
+
if step is None:
|
|
787
|
+
raise StructuredDataError(
|
|
788
|
+
f'a forecast answer for row {index} names no step, so its '
|
|
789
|
+
f'place in the horizon is unknown',
|
|
790
|
+
code='INVALID_RESPONSE',
|
|
791
|
+
)
|
|
792
|
+
elif step is not None:
|
|
793
|
+
raise StructuredDataError(
|
|
794
|
+
f'a {payload.get("task", {}).get("kind")!r} answer carries a '
|
|
795
|
+
f'forecast step, which only a forecast has',
|
|
796
|
+
code='INVALID_RESPONSE',
|
|
797
|
+
)
|
|
798
|
+
|
|
799
|
+
key = (index, step)
|
|
800
|
+
if key in seen:
|
|
801
|
+
where = (
|
|
802
|
+
f'row {index} step {step}' if forecasting else f'row {index}'
|
|
803
|
+
)
|
|
804
|
+
raise StructuredDataError(
|
|
805
|
+
f'the response answers {where} more than once',
|
|
806
|
+
code='INVALID_RESPONSE',
|
|
807
|
+
)
|
|
808
|
+
seen.add(key)
|
|
809
|
+
|
|
810
|
+
if instances is not None:
|
|
811
|
+
if item.id is None:
|
|
812
|
+
raise StructuredDataError(
|
|
813
|
+
f'the answer for row {index} carries no id, and this batch '
|
|
814
|
+
f'asked under {instances[index]!r}, so the two cannot be '
|
|
815
|
+
f'matched',
|
|
816
|
+
code='INVALID_RESPONSE',
|
|
817
|
+
)
|
|
818
|
+
if str(item.id) != str(instances[index]):
|
|
819
|
+
raise StructuredDataError(
|
|
820
|
+
f'the response pairs row_index {index} with id '
|
|
821
|
+
f'{item.id!r}, and this batch asked about '
|
|
822
|
+
f'{instances[index]!r} there',
|
|
823
|
+
code='INVALID_RESPONSE',
|
|
824
|
+
)
|
|
825
|
+
named.append(dataclasses.replace(item, id=entities[index]))
|
|
826
|
+
|
|
827
|
+
answered = {index for index, _ in seen}
|
|
828
|
+
if answered != set(range(len(entities))):
|
|
829
|
+
missing = sorted(set(range(len(entities))) - answered)
|
|
830
|
+
raise StructuredDataError(
|
|
831
|
+
f'the response left row(s) {missing} unanswered',
|
|
832
|
+
code='INVALID_RESPONSE',
|
|
833
|
+
)
|
|
834
|
+
return named
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def _forecast_task(payload: dict[str, Any]) -> bool:
|
|
838
|
+
"""Whether this batch asked a forecast.
|
|
839
|
+
|
|
840
|
+
Read from the request rather than from the shape of the answer. Deciding
|
|
841
|
+
it from the answer let a single item carrying a step switch off the
|
|
842
|
+
duplicate guard for every other row in the response.
|
|
843
|
+
"""
|
|
844
|
+
return payload.get('task', {}).get('kind') == 'forecasting'
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _key_dtype(payload: dict[str, Any], reference: str) -> str | None:
|
|
848
|
+
r"""The dtype of the column the entity reference stands for.
|
|
849
|
+
|
|
850
|
+
An integer wider than a double travels as a base-10 string by contract, so
|
|
851
|
+
a key past 2**53 comes back as text unless what it was is looked up. The
|
|
852
|
+
reference column itself is declared `string` because it is a transport key.
|
|
853
|
+
The answer is therefore on the table it points at.
|
|
854
|
+
"""
|
|
855
|
+
schema = payload.get('schema', {})
|
|
856
|
+
for link in schema.get('relationships', []):
|
|
857
|
+
if list(link.get('source_columns') or []) != [reference]:
|
|
858
|
+
continue
|
|
859
|
+
target = link.get('target_table')
|
|
860
|
+
columns = link.get('target_columns') or []
|
|
861
|
+
if not target or len(columns) != 1:
|
|
862
|
+
return None
|
|
863
|
+
return (
|
|
864
|
+
schema.get('related_tables', {})
|
|
865
|
+
.get(target, {})
|
|
866
|
+
.get('columns', {})
|
|
867
|
+
.get(columns[0], {})
|
|
868
|
+
.get('dtype')
|
|
869
|
+
)
|
|
870
|
+
return None
|