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,580 @@
|
|
|
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 single-table request to ``kumo-tabular``.
|
|
6
|
+
|
|
7
|
+
This model does no heavy client-side work: there is no graph to build and
|
|
8
|
+
nothing to sample, so the adapter shapes the envelope, sends it, and reads the
|
|
9
|
+
answer back. That is why the tabular path ships in the base install and runs
|
|
10
|
+
anywhere, while the relational one needs a native extra.
|
|
11
|
+
|
|
12
|
+
Two rules this adapter holds to, both of which the endpoint enforces and would
|
|
13
|
+
otherwise report as an opaque 422.
|
|
14
|
+
|
|
15
|
+
The **target's declared dtype must equal the schema's dtype for that column**.
|
|
16
|
+
They are taken from one place here so they cannot drift.
|
|
17
|
+
|
|
18
|
+
The **task kind is never narrowed**. The endpoint accepts only the coarse
|
|
19
|
+
``classification`` and decides binary versus multiclass itself from the
|
|
20
|
+
context, so a client that narrowed it would both be guessing and be rejected.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import uuid
|
|
26
|
+
import warnings
|
|
27
|
+
from collections.abc import Iterator, Mapping
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
import pandas as pd
|
|
31
|
+
from sdm_api.generated.tfm_api import (
|
|
32
|
+
PredictionResponse,
|
|
33
|
+
ResponseContractError,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
from sdm_client.base import ModelSpec
|
|
37
|
+
from sdm_client.core.warnings import SDMWarning, caller_stacklevel
|
|
38
|
+
from sdm_client.errors import StructuredDataError, UnsupportedRequestError
|
|
39
|
+
from sdm_client.models import KUMO_RELATIONAL, KUMO_TABULAR
|
|
40
|
+
from sdm_client.request import Request
|
|
41
|
+
from sdm_client.result import PredictionResult
|
|
42
|
+
from sdm_client.schema import Column, supports_dtype
|
|
43
|
+
from sdm_client.wire import (
|
|
44
|
+
UNTYPED,
|
|
45
|
+
as_declared,
|
|
46
|
+
encode_table,
|
|
47
|
+
infer_tfm_dtype,
|
|
48
|
+
json_ready,
|
|
49
|
+
widen_tfm_dtype,
|
|
50
|
+
)
|
|
51
|
+
from sdm_client.wire.answers import as_declared_target
|
|
52
|
+
|
|
53
|
+
# Output fields to ask for when the caller named none, by task kind. Asking for
|
|
54
|
+
# everything would make a regression request carry probabilities it cannot
|
|
55
|
+
# have, which the endpoint rejects.
|
|
56
|
+
_DEFAULT_OUTPUTS = {
|
|
57
|
+
'classification': ('prediction', 'probabilities'),
|
|
58
|
+
'regression': ('prediction',),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
# A session pins model, task, schema and context; only the rows being scored
|
|
62
|
+
# travel with each later call. ``metadata`` rides along on both halves because
|
|
63
|
+
# its request id is what correlates the call with the endpoint's logs.
|
|
64
|
+
_SESSION_PINNED = ('model', 'task', 'schema', 'context')
|
|
65
|
+
_SESSION_SCORING = ('predict', 'output', 'inference', 'metadata')
|
|
66
|
+
|
|
67
|
+
# What the contract allows a `PredictionItem.id` to be.
|
|
68
|
+
_MAX_ID_LENGTH = 1024
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _request_id() -> str:
|
|
72
|
+
return f'sdm-{uuid.uuid4().hex[:12]}'
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TabularAdapter:
|
|
76
|
+
r"""Shapes and reads ``kumo-tabular`` requests."""
|
|
77
|
+
|
|
78
|
+
spec: ModelSpec = KUMO_TABULAR
|
|
79
|
+
|
|
80
|
+
def default_output(self, request: Request) -> tuple[str, ...]:
|
|
81
|
+
return _DEFAULT_OUTPUTS.get(request.task.kind, ('prediction',))
|
|
82
|
+
|
|
83
|
+
def prediction_plan(
|
|
84
|
+
self, request: Request, outputs: tuple[str, ...]
|
|
85
|
+
) -> Iterator[dict[str, Any]]:
|
|
86
|
+
r"""One payload.
|
|
87
|
+
|
|
88
|
+
The endpoint caps the context at 10,000 rows, which is enforced where
|
|
89
|
+
the context is chosen rather than by splitting here: every call would
|
|
90
|
+
have to carry the same context, so splitting would multiply the upload
|
|
91
|
+
without raising the cap.
|
|
92
|
+
"""
|
|
93
|
+
yield self._envelope(request, outputs)
|
|
94
|
+
|
|
95
|
+
def session_plan(
|
|
96
|
+
self, request: Request, outputs: tuple[str, ...]
|
|
97
|
+
) -> dict[str, Any]:
|
|
98
|
+
envelope = self._envelope(request, outputs)
|
|
99
|
+
pinned = {
|
|
100
|
+
key: envelope[key] for key in _SESSION_PINNED if key in envelope
|
|
101
|
+
}
|
|
102
|
+
pinned['metadata'] = envelope['metadata']
|
|
103
|
+
return pinned
|
|
104
|
+
|
|
105
|
+
def session_prediction_plan(
|
|
106
|
+
self,
|
|
107
|
+
request: Request,
|
|
108
|
+
outputs: tuple[str, ...],
|
|
109
|
+
pinned: Mapping[str, Any] | None = None,
|
|
110
|
+
) -> Iterator[dict[str, Any]]:
|
|
111
|
+
declared = _declared_by(pinned)
|
|
112
|
+
try:
|
|
113
|
+
envelope = self._envelope(request, outputs, declared)
|
|
114
|
+
except Exception as error:
|
|
115
|
+
# Writing the rows to the pinned schema is what makes the call
|
|
116
|
+
# legible to the endpoint, and it is also the only thing that can
|
|
117
|
+
# fail for a reason the caller cannot see: the failure names a
|
|
118
|
+
# dtype they never wrote. So the session is named instead.
|
|
119
|
+
raise _a_row_the_pinned_schema_cannot_hold(
|
|
120
|
+
request, declared, error
|
|
121
|
+
) from error
|
|
122
|
+
yield {
|
|
123
|
+
key: envelope[key] for key in _SESSION_SCORING if key in envelope
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
def normalize(
|
|
127
|
+
self,
|
|
128
|
+
response: dict[str, Any],
|
|
129
|
+
outputs: tuple[str, ...],
|
|
130
|
+
payload: dict[str, Any] | None = None,
|
|
131
|
+
) -> PredictionResult:
|
|
132
|
+
try:
|
|
133
|
+
parsed = PredictionResponse.from_dict(response)
|
|
134
|
+
except (ResponseContractError, TypeError, ValueError) as error:
|
|
135
|
+
# A 200 whose body does not match the contract is still a failure,
|
|
136
|
+
# and it has to arrive as one of this client's errors rather than
|
|
137
|
+
# as a binding-layer exception nobody is catching.
|
|
138
|
+
raise StructuredDataError(
|
|
139
|
+
f'{self.spec.model_id} returned a response that does not '
|
|
140
|
+
f'match the contract: {error}',
|
|
141
|
+
code='INVALID_RESPONSE',
|
|
142
|
+
) from error
|
|
143
|
+
return PredictionResult(
|
|
144
|
+
model=parsed.model,
|
|
145
|
+
request_id=parsed.id,
|
|
146
|
+
items=as_declared_target(
|
|
147
|
+
_named_entities(parsed.predictions, payload),
|
|
148
|
+
payload,
|
|
149
|
+
),
|
|
150
|
+
metadata=parsed.metadata,
|
|
151
|
+
outputs=outputs,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def explain_error(
|
|
155
|
+
self, error: Exception, payload: dict[str, Any]
|
|
156
|
+
) -> Exception:
|
|
157
|
+
r"""Nothing to add: this endpoint's refusals already name the column
|
|
158
|
+
they are about."""
|
|
159
|
+
return error
|
|
160
|
+
|
|
161
|
+
def close(self) -> None:
|
|
162
|
+
r"""Nothing to release: this adapter sends through the client's own
|
|
163
|
+
transport and opens nothing of its own."""
|
|
164
|
+
|
|
165
|
+
def _envelope(
|
|
166
|
+
self,
|
|
167
|
+
request: Request,
|
|
168
|
+
outputs: tuple[str, ...],
|
|
169
|
+
pinned_dtypes: Mapping[str, str] | None = None,
|
|
170
|
+
) -> dict[str, Any]:
|
|
171
|
+
if request.payloads is not None:
|
|
172
|
+
raise UnsupportedRequestError(
|
|
173
|
+
f'this request was gathered for '
|
|
174
|
+
f'{KUMO_RELATIONAL.model_id} and carries the sampled '
|
|
175
|
+
f'payloads that model reads. Gather it with '
|
|
176
|
+
f'materialize(..., model={self.spec.model_id!r}) to ask '
|
|
177
|
+
f'this one'
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
# The same question the relational path asks, at the same moment: a
|
|
181
|
+
# column re-declared one assignment at a time can be left saying it
|
|
182
|
+
# holds one thing and means another, and neither assignment is the
|
|
183
|
+
# place to refuse it.
|
|
184
|
+
request.schema.check_consistent()
|
|
185
|
+
|
|
186
|
+
table = request.schema[request.entity_table]
|
|
187
|
+
context = request.context.instance_table
|
|
188
|
+
predict = request.predict.instance_table
|
|
189
|
+
|
|
190
|
+
# A column's dtype has to describe both halves. Taking it from the
|
|
191
|
+
# context alone calls an all-null context column 'string' and then
|
|
192
|
+
# sends the predict half's integers under it as text, which changes
|
|
193
|
+
# what the model is asked to predict on. `infer_tfm_dtype` reports
|
|
194
|
+
# UNTYPED for a column that carries no type precisely so it cannot
|
|
195
|
+
# re-type the other frame, so the schema's answer is set aside for
|
|
196
|
+
# those and the frames decide.
|
|
197
|
+
dtypes = {
|
|
198
|
+
name: (UNTYPED if column.untyped else column.dtype)
|
|
199
|
+
for name, column in table.columns.items()
|
|
200
|
+
}
|
|
201
|
+
for name in predict.columns:
|
|
202
|
+
if name in dtypes:
|
|
203
|
+
dtypes[name] = widen_tfm_dtype(
|
|
204
|
+
(dtypes[name], _raw_dtype_of(predict[name], dtypes[name]))
|
|
205
|
+
)
|
|
206
|
+
# Untyped on both sides means there is genuinely no information, and
|
|
207
|
+
# 'string' round-trips whatever arrives without claiming a width.
|
|
208
|
+
dtypes = {name: dtype or 'string' for name, dtype in dtypes.items()}
|
|
209
|
+
# On a scoring call the schema is not sent: it was pinned when the
|
|
210
|
+
# session opened, and the rows have to be written to describe *that*.
|
|
211
|
+
# Typing them from the half that travels sent `1.0` under a column the
|
|
212
|
+
# session had pinned as `int64`, which the endpoint refused as
|
|
213
|
+
# `SESSION_PREDICT_SCHEMA_MISMATCH`, a failure about a value the
|
|
214
|
+
# caller never wrote and cannot see.
|
|
215
|
+
if pinned_dtypes is not None:
|
|
216
|
+
dtypes = {
|
|
217
|
+
**dtypes,
|
|
218
|
+
**{name: d for name, d in pinned_dtypes.items() if d},
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
target = request.task.target.column_name
|
|
222
|
+
missing = [name for name in context.columns if name not in dtypes]
|
|
223
|
+
if missing:
|
|
224
|
+
raise StructuredDataError(
|
|
225
|
+
f'schema does not describe context column(s) {missing}',
|
|
226
|
+
code='INVALID_REQUEST',
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
schema_columns = {}
|
|
230
|
+
as_text = []
|
|
231
|
+
for name in context.columns:
|
|
232
|
+
declared = table[name].stype
|
|
233
|
+
if declared == 'text':
|
|
234
|
+
as_text.append(name)
|
|
235
|
+
schema_columns[name] = {
|
|
236
|
+
'dtype': dtypes[name],
|
|
237
|
+
'stype': _coherent_stype(
|
|
238
|
+
self._stype_for(request, declared, name),
|
|
239
|
+
dtypes[name],
|
|
240
|
+
table[name],
|
|
241
|
+
),
|
|
242
|
+
}
|
|
243
|
+
if as_text:
|
|
244
|
+
warnings.warn(
|
|
245
|
+
f'{self.spec.model_id} cannot read a text column, so '
|
|
246
|
+
f'{", ".join(as_text)} travel(s) as categorical instead. ',
|
|
247
|
+
category=SDMWarning,
|
|
248
|
+
stacklevel=caller_stacklevel(),
|
|
249
|
+
)
|
|
250
|
+
instance_schema: dict[str, Any] = {'columns': schema_columns}
|
|
251
|
+
key = table.primary_key
|
|
252
|
+
if key:
|
|
253
|
+
instance_schema['primary_key'] = (
|
|
254
|
+
[key] if isinstance(key, str) else list(key)
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
envelope: dict[str, Any] = {
|
|
258
|
+
'model': self.spec.model_id,
|
|
259
|
+
'task': {
|
|
260
|
+
'kind': request.task.kind,
|
|
261
|
+
'target': {
|
|
262
|
+
'column_name': target,
|
|
263
|
+
# The one place this dtype is decided, so it cannot differ
|
|
264
|
+
# from the schema's answer for the same column.
|
|
265
|
+
'dtype': dtypes[target],
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
'schema': {'instance_table': instance_schema},
|
|
269
|
+
'context': {'instance_table': encode_table(context, dtypes)},
|
|
270
|
+
'predict': {'instance_table': encode_table(predict, dtypes)},
|
|
271
|
+
'output': {'fields': list(outputs)},
|
|
272
|
+
'metadata': {'request_id': _request_id()},
|
|
273
|
+
}
|
|
274
|
+
if request.inference:
|
|
275
|
+
envelope['inference'] = json_ready(request.inference)
|
|
276
|
+
return envelope
|
|
277
|
+
|
|
278
|
+
@staticmethod
|
|
279
|
+
def _stype_for(request: Request, inferred: str, column: str) -> str:
|
|
280
|
+
r"""The semantic type to declare for one column.
|
|
281
|
+
|
|
282
|
+
Schema inference is task-agnostic, so an integer label column comes
|
|
283
|
+
back ``numerical``, which is right for a regression target and wrong
|
|
284
|
+
for a classification one. The task is what settles it, and only the
|
|
285
|
+
adapter knows the task, so the correction happens here rather than
|
|
286
|
+
being baked into the schema a caller may reuse for another question.
|
|
287
|
+
|
|
288
|
+
The same argument carries a second correction: inference is also
|
|
289
|
+
model-agnostic, and ``'text'`` is a type this model cannot read at
|
|
290
|
+
all. Only the adapter knows which model is being asked.
|
|
291
|
+
"""
|
|
292
|
+
if inferred == 'text':
|
|
293
|
+
return 'categorical'
|
|
294
|
+
if column != request.task.target.column_name:
|
|
295
|
+
return inferred
|
|
296
|
+
if request.task.is_classification:
|
|
297
|
+
return 'categorical'
|
|
298
|
+
return 'numerical' if inferred == 'categorical' else inferred
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _dtype_of(series: pd.Series) -> str:
|
|
302
|
+
dtype = infer_tfm_dtype(series)
|
|
303
|
+
return dtype if dtype != UNTYPED else 'string'
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _raw_dtype_of(series: pd.Series, declared: str | None = None) -> str:
|
|
307
|
+
r"""Like :func:`_dtype_of`, but keeping UNTYPED as the answer.
|
|
308
|
+
|
|
309
|
+
A column with no values contributes nothing to a widening. Turning it into
|
|
310
|
+
``'string'`` first is what made it decide the outcome.
|
|
311
|
+
|
|
312
|
+
A `float64` that is float only because pandas has no integer that holds a
|
|
313
|
+
null is reported as the integer it was declared to be. Widening on it sent
|
|
314
|
+
an id through a float: `9007199254740993` left as `9007199254740992.0`,
|
|
315
|
+
which is the corruption the integer rules exist to prevent, arriving by the
|
|
316
|
+
one route that looks like an ordinary type difference. The values decide,
|
|
317
|
+
not the dtype. A genuine float column of whole numbers is still a float,
|
|
318
|
+
because nothing declared it otherwise.
|
|
319
|
+
"""
|
|
320
|
+
if (
|
|
321
|
+
declared
|
|
322
|
+
and declared.startswith('int')
|
|
323
|
+
and pd.api.types.is_float_dtype(series.dtype)
|
|
324
|
+
and series.isna().any()
|
|
325
|
+
):
|
|
326
|
+
present = series.dropna()
|
|
327
|
+
if present.empty or bool((present == present.astype('int64')).all()):
|
|
328
|
+
return declared
|
|
329
|
+
return infer_tfm_dtype(series)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _a_row_the_pinned_schema_cannot_hold(
|
|
333
|
+
request: Request,
|
|
334
|
+
declared: Mapping[str, str] | None,
|
|
335
|
+
error: Exception,
|
|
336
|
+
) -> Exception:
|
|
337
|
+
r"""Name the session, for a failure about a dtype the caller never wrote.
|
|
338
|
+
|
|
339
|
+
The scoring rows are written to the schema the session pinned, so a value
|
|
340
|
+
this package cannot write that way fails naming a type the caller did not
|
|
341
|
+
choose, or where pandas raises first, as a bare `ValueError` from
|
|
342
|
+
inside. Which column and which two types is what makes it actionable.
|
|
343
|
+
"""
|
|
344
|
+
if declared is None:
|
|
345
|
+
if isinstance(error, (StructuredDataError, UnsupportedRequestError)):
|
|
346
|
+
return error
|
|
347
|
+
return StructuredDataError(str(error), code='INVALID_REQUEST')
|
|
348
|
+
try:
|
|
349
|
+
predict = request.predict.instance_table
|
|
350
|
+
except Exception: # noqa: BLE001 - already reporting a failure
|
|
351
|
+
return StructuredDataError(str(error), code='INVALID_REQUEST')
|
|
352
|
+
differing = [
|
|
353
|
+
f'{name} was pinned as {declared[name]} and these rows hold '
|
|
354
|
+
f'{predict[name].dtype}'
|
|
355
|
+
for name in predict.columns
|
|
356
|
+
if name in declared and str(predict[name].dtype) != declared[name]
|
|
357
|
+
]
|
|
358
|
+
if not differing:
|
|
359
|
+
return error
|
|
360
|
+
return StructuredDataError(
|
|
361
|
+
f'these rows cannot be written to the schema this session pinned: '
|
|
362
|
+
f'{"; ".join(differing)}. A scoring call does not send a schema. It '
|
|
363
|
+
f'was settled by fit(), so the rows have to be writable as it '
|
|
364
|
+
f'describes them. Give the column the type it was pinned with, or '
|
|
365
|
+
f'fit() again on a context that has the type you want. ({error})',
|
|
366
|
+
code='INVALID_REQUEST',
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _declared_by(pinned: Mapping[str, Any] | None) -> dict[str, str] | None:
|
|
371
|
+
r"""The dtypes a session pinned, read back off the envelope it sent."""
|
|
372
|
+
if not pinned:
|
|
373
|
+
return None
|
|
374
|
+
columns = ((pinned.get('schema') or {}).get('instance_table') or {}).get(
|
|
375
|
+
'columns'
|
|
376
|
+
)
|
|
377
|
+
if not isinstance(columns, Mapping):
|
|
378
|
+
return None
|
|
379
|
+
return {
|
|
380
|
+
name: str(described.get('dtype'))
|
|
381
|
+
for name, described in columns.items()
|
|
382
|
+
if isinstance(described, Mapping) and described.get('dtype')
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _key_named_by(value: str) -> str | None:
|
|
387
|
+
r"""The key an id names, or ``None`` if it names no key this can read.
|
|
388
|
+
|
|
389
|
+
The endpoint answers under the list of primary-key values, and how it
|
|
390
|
+
renders that list is its own business: a key that travelled as a JSON
|
|
391
|
+
string comes back quoted, one that travelled as a number does not, and one
|
|
392
|
+
carrying a newline or a backslash comes back with that escaped. So the
|
|
393
|
+
rendering is read back into the value it stands for, and the value is what
|
|
394
|
+
gets compared. Taking the text apart by hand instead would refuse a correct
|
|
395
|
+
answer for any key an escape appears in.
|
|
396
|
+
|
|
397
|
+
Read as JSON first, because JSON is what the endpoint answers with, and
|
|
398
|
+
only as a Python literal if that fails. The two agree on everything except
|
|
399
|
+
a character above U+FFFF: JSON writes it as a surrogate pair and
|
|
400
|
+
`ast.literal_eval` reads the halves as two separate characters that never
|
|
401
|
+
recombine, so a key containing one could never be matched back and the
|
|
402
|
+
request failed at the client's own id check.
|
|
403
|
+
|
|
404
|
+
Anything not written that way is left alone rather than refused. The
|
|
405
|
+
contract calls this field a *service-generated* row identifier and names
|
|
406
|
+
``row_index`` as what to align on, so an endpoint is entitled to answer
|
|
407
|
+
under a token of its own and refusing one would break a compliant
|
|
408
|
+
deployment. It bounds an id at 1024 characters, so anything longer is not
|
|
409
|
+
one either, and is declined before being parsed.
|
|
410
|
+
|
|
411
|
+
A value that is not JSON is read as the Python rendering it must then be.
|
|
412
|
+
"""
|
|
413
|
+
import ast
|
|
414
|
+
import json
|
|
415
|
+
|
|
416
|
+
if len(value) > _MAX_ID_LENGTH:
|
|
417
|
+
return None
|
|
418
|
+
named: Any = None
|
|
419
|
+
try:
|
|
420
|
+
named = json.loads(value)
|
|
421
|
+
except (ValueError, TypeError, RecursionError):
|
|
422
|
+
try:
|
|
423
|
+
named = ast.literal_eval(value)
|
|
424
|
+
except (
|
|
425
|
+
ValueError,
|
|
426
|
+
SyntaxError,
|
|
427
|
+
TypeError,
|
|
428
|
+
MemoryError,
|
|
429
|
+
RecursionError,
|
|
430
|
+
):
|
|
431
|
+
return None
|
|
432
|
+
if not isinstance(named, list) or len(named) != 1:
|
|
433
|
+
return None
|
|
434
|
+
return str(named[0])
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _named_entities(
|
|
438
|
+
items: tuple[Any, ...], payload: dict[str, Any] | None
|
|
439
|
+
) -> list[Any]:
|
|
440
|
+
r"""Put the caller's key back on each row, and check it belongs there.
|
|
441
|
+
|
|
442
|
+
The endpoint answers under the stringified list of primary-key values, so
|
|
443
|
+
a row the caller asked about as ``1`` comes back as ``'[1]'``. The
|
|
444
|
+
relational path already returns the caller's own key, and
|
|
445
|
+
:mod:`sdm_client.result` tells callers to join on it, so a key that only
|
|
446
|
+
joins on one of the two models is a difference they have no reason to
|
|
447
|
+
expect.
|
|
448
|
+
|
|
449
|
+
The key is read from the payload that produced this response, so a run
|
|
450
|
+
split into several calls maps each batch against its own rows.
|
|
451
|
+
|
|
452
|
+
**A mismatch is an error, not a silent pass.** Rewriting an id is exactly
|
|
453
|
+
the operation that can misattribute an answer, so a row is checked against
|
|
454
|
+
the batch it claims to belong to before its id is replaced. The endpoint
|
|
455
|
+
answers under the list of key values and the relational endpoint answers
|
|
456
|
+
under the key itself, so a key is recognised in either shape. An id
|
|
457
|
+
recognisable as a key has to be the key of the row it was paired with. An
|
|
458
|
+
id in any other shape is not second-guessed: it may be a correlation token
|
|
459
|
+
of the endpoint's own.
|
|
460
|
+
|
|
461
|
+
Those checks are about the batch rather than the key, so they also hold for
|
|
462
|
+
a request that has no key to put back: a composite key, or rows the caller
|
|
463
|
+
supplied without the key column, still have to be answered once each and
|
|
464
|
+
no more. Such a request has no value to restore. A null key is the same
|
|
465
|
+
case. ``str`` would turn it into ``'None'``, which
|
|
466
|
+
reads as a key that was asked about. Rows in a shape this cannot read at
|
|
467
|
+
all, a format other than ``arrays``, are left alone entirely.
|
|
468
|
+
"""
|
|
469
|
+
import dataclasses
|
|
470
|
+
|
|
471
|
+
if payload is None:
|
|
472
|
+
return list(items)
|
|
473
|
+
table = payload.get('predict', {}).get('instance_table', {})
|
|
474
|
+
rows = table.get('rows')
|
|
475
|
+
if not isinstance(rows, list):
|
|
476
|
+
return list(items)
|
|
477
|
+
columns = table.get('columns') or []
|
|
478
|
+
|
|
479
|
+
key = (
|
|
480
|
+
payload.get('schema', {}).get('instance_table', {}).get('primary_key')
|
|
481
|
+
or []
|
|
482
|
+
)
|
|
483
|
+
named_key = key[0] if len(key) == 1 else None
|
|
484
|
+
key_dtype = (
|
|
485
|
+
payload.get('schema', {})
|
|
486
|
+
.get('instance_table', {})
|
|
487
|
+
.get('columns', {})
|
|
488
|
+
.get(named_key, {})
|
|
489
|
+
.get('dtype')
|
|
490
|
+
if named_key
|
|
491
|
+
else None
|
|
492
|
+
)
|
|
493
|
+
keys = (
|
|
494
|
+
[as_declared(row[columns.index(named_key)], key_dtype) for row in rows]
|
|
495
|
+
if named_key and named_key in columns
|
|
496
|
+
else None
|
|
497
|
+
)
|
|
498
|
+
keys_in_batch = {str(value) for value in keys or ()}
|
|
499
|
+
|
|
500
|
+
named: list[Any] = []
|
|
501
|
+
seen: set[int] = set()
|
|
502
|
+
for item in items:
|
|
503
|
+
index = item.row_index
|
|
504
|
+
if index is None:
|
|
505
|
+
raise StructuredDataError(
|
|
506
|
+
'the response is missing a row_index, so its answer cannot be '
|
|
507
|
+
'attributed to a row',
|
|
508
|
+
code='INVALID_RESPONSE',
|
|
509
|
+
)
|
|
510
|
+
if not 0 <= index < len(rows):
|
|
511
|
+
raise StructuredDataError(
|
|
512
|
+
f'the response names row_index {index}, and this batch asked '
|
|
513
|
+
f'about {len(rows)} rows',
|
|
514
|
+
code='INVALID_RESPONSE',
|
|
515
|
+
)
|
|
516
|
+
if index in seen:
|
|
517
|
+
raise StructuredDataError(
|
|
518
|
+
f'the response answers row {index} more than once',
|
|
519
|
+
code='INVALID_RESPONSE',
|
|
520
|
+
)
|
|
521
|
+
if keys is not None and item.id is not None:
|
|
522
|
+
answered = str(item.id)
|
|
523
|
+
named_key = _key_named_by(answered)
|
|
524
|
+
if named_key is None and answered in keys_in_batch:
|
|
525
|
+
named_key = answered
|
|
526
|
+
if named_key is not None and named_key != str(keys[index]):
|
|
527
|
+
raise StructuredDataError(
|
|
528
|
+
f'the response pairs row_index {index} with id '
|
|
529
|
+
f'{item.id!r}, and this batch asked about '
|
|
530
|
+
f'{keys[index]!r} there',
|
|
531
|
+
code='INVALID_RESPONSE',
|
|
532
|
+
)
|
|
533
|
+
seen.add(index)
|
|
534
|
+
value = None if keys is None else keys[index]
|
|
535
|
+
named.append(
|
|
536
|
+
item if value is None else dataclasses.replace(item, id=value)
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
if seen != set(range(len(rows))):
|
|
540
|
+
missing = sorted(set(range(len(rows))) - seen)
|
|
541
|
+
raise StructuredDataError(
|
|
542
|
+
f'the response left row(s) {missing} unanswered',
|
|
543
|
+
code='INVALID_RESPONSE',
|
|
544
|
+
)
|
|
545
|
+
return named
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
#: What a dtype means when nothing else says. Only reached for a column whose
|
|
549
|
+
#: meaning was never claimed, so it is the dtype's own reading rather than a
|
|
550
|
+
#: guess about the data.
|
|
551
|
+
_MEANING_OF = {
|
|
552
|
+
'int64': 'numerical',
|
|
553
|
+
'int32': 'numerical',
|
|
554
|
+
'float64': 'numerical',
|
|
555
|
+
'float32': 'numerical',
|
|
556
|
+
'bool': 'categorical',
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _coherent_stype(stype: str, dtype: str, column: Column) -> str:
|
|
561
|
+
r"""A meaning the dtype can actually carry.
|
|
562
|
+
|
|
563
|
+
An all-null context column is typed `categorical` because a schema has to
|
|
564
|
+
say something. The scored rows then decide its dtype, and if they hold
|
|
565
|
+
timestamps the column goes out as `timestamp[us]` meaning `categorical`,
|
|
566
|
+
which is not a thing a column can be, and is the same self-contradiction on
|
|
567
|
+
the wire that a scored frame re-typing a real column used to produce.
|
|
568
|
+
|
|
569
|
+
Only the placeholder is overridden. A meaning the caller assigned is a
|
|
570
|
+
claim and is refused rather than quietly rewritten, before it reaches here.
|
|
571
|
+
"""
|
|
572
|
+
if supports_dtype(stype, dtype) or column.stype_declared:
|
|
573
|
+
return stype
|
|
574
|
+
# Every spelling of a timestamp, not one of them. The contract writes
|
|
575
|
+
# `timestamp[us]`, but a hand-built schema may say `[ns]`, `[ms]` or `[s]`,
|
|
576
|
+
# and reading those as categorical puts a dtype and a meaning on the wire
|
|
577
|
+
# that contradict each other. That is what this exists to prevent.
|
|
578
|
+
if dtype.startswith('timestamp'):
|
|
579
|
+
return 'timestamp'
|
|
580
|
+
return _MEANING_OF.get(dtype, 'categorical')
|