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.
@@ -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')