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,51 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES.
|
|
2
|
+
# All rights reserved.
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
r"""Where a warning should say it came from, and what kind it is."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SDMWarning(UserWarning):
|
|
13
|
+
r"""What this package says when it will answer, but not as asked.
|
|
14
|
+
|
|
15
|
+
Every warning raised from here carries it. A caller who wants none of
|
|
16
|
+
them, or wants all of them to stop the run, can say so once:
|
|
17
|
+
|
|
18
|
+
.. code-block:: python
|
|
19
|
+
|
|
20
|
+
warnings.filterwarnings('ignore', category=SDMWarning)
|
|
21
|
+
warnings.filterwarnings('error', category=SDMWarning)
|
|
22
|
+
|
|
23
|
+
It derives from `UserWarning`, which is what these were before it existed,
|
|
24
|
+
so a filter written against that keeps catching them.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_PACKAGE = __name__.split('.')[0]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def caller_stacklevel() -> int:
|
|
32
|
+
r"""How far up to point a warning so it lands on the caller's line.
|
|
33
|
+
|
|
34
|
+
A fixed number is right for one route into a warning and blames this
|
|
35
|
+
package on every other. Inference reaches the same setter through
|
|
36
|
+
`Schema.from_data`, and `PredictiveQuery` reaches `Schema.from_data`
|
|
37
|
+
through itself, so the same message is two, three or four frames from the
|
|
38
|
+
line the reader wants to see.
|
|
39
|
+
|
|
40
|
+
Counting every frame that belongs to this package answers for all of them,
|
|
41
|
+
and keeps answering when a route gains a layer.
|
|
42
|
+
"""
|
|
43
|
+
depth = 1
|
|
44
|
+
frame = sys._getframe(1)
|
|
45
|
+
while frame is not None:
|
|
46
|
+
module = frame.f_globals.get('__name__', '')
|
|
47
|
+
if module != _PACKAGE and not module.startswith(f'{_PACKAGE}.'):
|
|
48
|
+
break
|
|
49
|
+
depth += 1
|
|
50
|
+
frame = frame.f_back
|
|
51
|
+
return depth
|
sdm_client/decompose.py
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES.
|
|
2
|
+
# All rights reserved.
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
r"""Asking a question the model does not take, in questions it does.
|
|
6
|
+
|
|
7
|
+
Two kinds of question have no task on the endpoint. A ranking asks which rows
|
|
8
|
+
of one table an entity will reach in a window; a forecast asks what a window
|
|
9
|
+
holds at each of several steps ahead. The models serve classification and
|
|
10
|
+
regression, and nothing else, so a request for either comes back refused.
|
|
11
|
+
|
|
12
|
+
Both are the same question asked repeatedly, though, and the repetition is
|
|
13
|
+
what the endpoint does serve:
|
|
14
|
+
|
|
15
|
+
* a ranking over candidates is, per candidate, *will this entity reach this
|
|
16
|
+
one?* This is a binary question whose probability orders the candidates.
|
|
17
|
+
* a forecast over steps is, per step, *what does this window hold?* This is the
|
|
18
|
+
question already being asked, with its window moved along.
|
|
19
|
+
|
|
20
|
+
Each part needs its own context, because the labels differ: what was true in
|
|
21
|
+
the second window is not what was true in the first, and whether an entity
|
|
22
|
+
reached *this* candidate is not whether it reached any. So the decomposition
|
|
23
|
+
happens where contexts are gathered rather than where envelopes are built,
|
|
24
|
+
and what comes back is merged into the one answer that was asked for.
|
|
25
|
+
|
|
26
|
+
The cost is a call per part, which for a ranking is a call per candidate.
|
|
27
|
+
That is the whole reason candidates must be named rather than assumed.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import contextlib
|
|
33
|
+
import math
|
|
34
|
+
from collections.abc import Mapping
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from decimal import Decimal
|
|
37
|
+
from typing import TYPE_CHECKING, Any
|
|
38
|
+
|
|
39
|
+
import pandas as pd
|
|
40
|
+
|
|
41
|
+
from sdm_client.errors import StructuredDataError
|
|
42
|
+
from sdm_client.wire.dtypes import _decimal_dtype, _is_missing
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING:
|
|
45
|
+
from predictive_query import ValidatedPredictiveQuery
|
|
46
|
+
|
|
47
|
+
from sdm_client.schema import Schema
|
|
48
|
+
|
|
49
|
+
#: More parts than this and the fan-out is the cost of the call, not a detail
|
|
50
|
+
#: of it. A caller who means it can raise it; nobody discovers it by accident.
|
|
51
|
+
MOST_PARTS = 200
|
|
52
|
+
|
|
53
|
+
RANKING = 'ranking'
|
|
54
|
+
FORECAST = 'forecast'
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class Decomposition:
|
|
59
|
+
r"""The questions one question was rewritten as, and what each is about.
|
|
60
|
+
|
|
61
|
+
Carried on the request rather than re-derived from its text: a query
|
|
62
|
+
naming a table ``rank_events`` is not a ranking, and a candidate read back
|
|
63
|
+
out of the text it was written into is a string where a key was given.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
kind: str
|
|
67
|
+
parts: tuple[tuple[str, Any], ...]
|
|
68
|
+
top: int | None = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def parts_of(
|
|
72
|
+
query: ValidatedPredictiveQuery,
|
|
73
|
+
text: str,
|
|
74
|
+
candidates: list[Any] | None,
|
|
75
|
+
options: Mapping[str, Any],
|
|
76
|
+
schema: Schema | None = None,
|
|
77
|
+
) -> Decomposition | None:
|
|
78
|
+
r"""The queries this one is asked as, or nothing if it is asked as itself.
|
|
79
|
+
|
|
80
|
+
Returns query text paired with what the part is about: the candidate it
|
|
81
|
+
asks after, or the step it covers. The pair matters: reading the candidate
|
|
82
|
+
back out of the text it was written into loses its type and trusts a
|
|
83
|
+
string the caller chose.
|
|
84
|
+
|
|
85
|
+
Each part is gathered like any other query, which is what gives it the
|
|
86
|
+
context its own labels need.
|
|
87
|
+
"""
|
|
88
|
+
# Imported here because how many candidates to rank is settled by the
|
|
89
|
+
# relational materializer, which is built on the request this returns.
|
|
90
|
+
from sdm_client.materializers.relational import _candidates_to_rank
|
|
91
|
+
|
|
92
|
+
kind = str(getattr(query, 'problem_type', ''))
|
|
93
|
+
if kind.endswith('RANK'):
|
|
94
|
+
if 'prediction_statistic' in options:
|
|
95
|
+
# Said here: each part is a classification, and refusing there
|
|
96
|
+
# would name a binary_classification the caller never wrote.
|
|
97
|
+
raise StructuredDataError(
|
|
98
|
+
'prediction_statistic applies only to a regression, and this '
|
|
99
|
+
'question is a ranking, ordered by how likely each candidate '
|
|
100
|
+
'is; ask it without prediction_statistic',
|
|
101
|
+
code='INVALID_REQUEST',
|
|
102
|
+
)
|
|
103
|
+
return Decomposition(
|
|
104
|
+
RANKING,
|
|
105
|
+
tuple(_per_candidate(query, candidates, schema)),
|
|
106
|
+
_candidates_to_rank(query, options),
|
|
107
|
+
)
|
|
108
|
+
if candidates is not None:
|
|
109
|
+
raise StructuredDataError(
|
|
110
|
+
f'candidates are the rows a ranking is over, and {text!r} does '
|
|
111
|
+
f'not rank anything. Ask it without candidates, or rank with '
|
|
112
|
+
f'"RANK TOP k", which is the question they are for',
|
|
113
|
+
code='INVALID_REQUEST',
|
|
114
|
+
)
|
|
115
|
+
if kind.endswith('FORECAST'):
|
|
116
|
+
if 'top_k' in options:
|
|
117
|
+
raise StructuredDataError(
|
|
118
|
+
'top_k is how many candidates a ranking keeps, and a forecast '
|
|
119
|
+
'ranks nothing; it gives back one value per step',
|
|
120
|
+
code='INVALID_REQUEST',
|
|
121
|
+
)
|
|
122
|
+
return Decomposition(FORECAST, tuple(_per_step(query)))
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _ended(message: Any) -> str:
|
|
127
|
+
"""``message`` with a full stop, so what is appended reads as a sentence.
|
|
128
|
+
|
|
129
|
+
Not every message this wraps ends in one, and two of them run together
|
|
130
|
+
without it.
|
|
131
|
+
"""
|
|
132
|
+
text = str(message).rstrip()
|
|
133
|
+
return text if not text or text[-1] in '.!?' else f'{text}.'
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def refuse_a_part_that_would_take_several_calls(
|
|
137
|
+
rewritten: Decomposition, asked: int, per_call: int
|
|
138
|
+
) -> None:
|
|
139
|
+
r"""Refuse a question asked in parts over more rows than one call scores.
|
|
140
|
+
|
|
141
|
+
A part is one call. The guard above counts parts on that basis, so a
|
|
142
|
+
batched part would make the parts times the batches: a ranking over 200
|
|
143
|
+
candidates and 50,000 entities passes as 200 calls and sends 1,000.
|
|
144
|
+
|
|
145
|
+
A ranking could not be batched in any case. Each batch samples its own
|
|
146
|
+
context, and a batch whose context holds one class leaves the candidate
|
|
147
|
+
out of every row rather than out of its own, because a candidate is
|
|
148
|
+
reported unscored for the whole ranking.
|
|
149
|
+
|
|
150
|
+
Checked before anything is gathered, from the count the caller gave, so a
|
|
151
|
+
refusal costs nothing and arrives before the ceilings that are about what
|
|
152
|
+
gathering holds.
|
|
153
|
+
"""
|
|
154
|
+
if per_call <= 0 or asked <= per_call:
|
|
155
|
+
return
|
|
156
|
+
each = 'candidate' if rewritten.kind == RANKING else 'step'
|
|
157
|
+
why = (
|
|
158
|
+
'and a candidate is reported unscored for the whole ranking, so one '
|
|
159
|
+
'cold batch would drop it from every row rather than from its own'
|
|
160
|
+
if rewritten.kind == RANKING
|
|
161
|
+
else 'and the calls would be the batches times the steps rather than '
|
|
162
|
+
'the steps that were counted'
|
|
163
|
+
)
|
|
164
|
+
raise StructuredDataError(
|
|
165
|
+
f'{asked:,} entities are more than the {per_call:,} one call scores, '
|
|
166
|
+
f'and this question is asked as {len(rewritten.parts):,} of them, one '
|
|
167
|
+
f'per {each}. A part is not batched, {why}. Ask about fewer entities '
|
|
168
|
+
f'at a time',
|
|
169
|
+
code='UNSUPPORTED_REQUEST',
|
|
170
|
+
details={'entities': asked, 'rows_per_call': per_call},
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def about_the_part(
|
|
175
|
+
error: StructuredDataError, rewritten: Decomposition, about: Any
|
|
176
|
+
) -> StructuredDataError:
|
|
177
|
+
r"""``error``, said of the part it happened in, and still its own type.
|
|
178
|
+
|
|
179
|
+
A ranking's part is about one candidate, which can be dropped; a forecast's
|
|
180
|
+
covers a window further out than the one written, which is asked for by
|
|
181
|
+
asking for fewer steps. One wording served both and was false of the first.
|
|
182
|
+
|
|
183
|
+
The step named is the one that failed: anything at or above it fails alike.
|
|
184
|
+
The type is kept, because it is what `predict` documents.
|
|
185
|
+
|
|
186
|
+
Which part failed is always said, because it is always true. A remedy is
|
|
187
|
+
added only where the failure is one it addresses. An unknown model is not
|
|
188
|
+
answered by asking for fewer steps, and at step 1 there are no fewer to ask
|
|
189
|
+
for.
|
|
190
|
+
"""
|
|
191
|
+
how_many = len(rewritten.parts)
|
|
192
|
+
first = _ended(error.args[0])
|
|
193
|
+
if rewritten.kind == RANKING:
|
|
194
|
+
said = (
|
|
195
|
+
f'This is the question asked for candidate {about!r}, one of '
|
|
196
|
+
f'{how_many} this ranking is asked as.'
|
|
197
|
+
)
|
|
198
|
+
else:
|
|
199
|
+
said = f'This is forecast step {about} of {how_many}.'
|
|
200
|
+
if (getattr(error, 'details', None) or {}).get(
|
|
201
|
+
'window_outran_the_data'
|
|
202
|
+
):
|
|
203
|
+
said += (
|
|
204
|
+
f" The window named above is that step's rather than the one "
|
|
205
|
+
f'written; ask for fewer than {about} steps if the data does '
|
|
206
|
+
f'not reach that far.'
|
|
207
|
+
)
|
|
208
|
+
said = f'{first} {said}'
|
|
209
|
+
carried = error.__class__.__new__(error.__class__)
|
|
210
|
+
carried.__dict__.update(error.__dict__)
|
|
211
|
+
carried.args = (said, *error.args[1:])
|
|
212
|
+
carried.message = said
|
|
213
|
+
return carried
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _for_each(query: ValidatedPredictiveQuery) -> str:
|
|
217
|
+
r"""The entity clause as it was written, filter and all.
|
|
218
|
+
|
|
219
|
+
An entity filter chooses which rows become labelled examples rather than
|
|
220
|
+
which rows are scored, so a part that drops it is answered from a
|
|
221
|
+
different population than the one asked about.
|
|
222
|
+
"""
|
|
223
|
+
return f'{query.for_each} {query.entity_ast}'
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _window(query: ValidatedPredictiveQuery) -> tuple[int, int, str]:
|
|
227
|
+
span = query.get_final_target_aggregation().date_offset_range
|
|
228
|
+
return int(span.start), int(span.end), str(span.unit)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _per_candidate(
|
|
232
|
+
query: ValidatedPredictiveQuery,
|
|
233
|
+
candidates: list[Any] | None,
|
|
234
|
+
schema: Schema | None = None,
|
|
235
|
+
) -> list[tuple[str, Any]]:
|
|
236
|
+
r"""One binary question per candidate, ordered by how sure the answer is.
|
|
237
|
+
|
|
238
|
+
The endpoint has no ranking task, and a ranking it could serve would still
|
|
239
|
+
be over whatever the sampler reached. Asking per candidate makes the set
|
|
240
|
+
the caller's, which is the only version of this question with a knowable
|
|
241
|
+
answer.
|
|
242
|
+
"""
|
|
243
|
+
aggregation = query.get_final_target_aggregation()
|
|
244
|
+
if aggregation is None:
|
|
245
|
+
raise StructuredDataError(
|
|
246
|
+
'a ranking is asked one candidate at a time, about what an '
|
|
247
|
+
'entity reaches in a window, and this one ranks a column '
|
|
248
|
+
'instead. Rank a LIST_DISTINCT over a foreign key.',
|
|
249
|
+
code='INVALID_REQUEST',
|
|
250
|
+
)
|
|
251
|
+
ranked = str(aggregation.target)
|
|
252
|
+
if ' WHERE ' in ranked:
|
|
253
|
+
raise StructuredDataError(
|
|
254
|
+
f'the rows to rank are chosen by a filter ({ranked!r}), and each '
|
|
255
|
+
f'candidate is asked about with a filter of its own, so the two '
|
|
256
|
+
f'would have to be combined into one question this cannot write. '
|
|
257
|
+
f'Rank without the filter, or narrow the rows before building the '
|
|
258
|
+
f'schema.',
|
|
259
|
+
code='INVALID_REQUEST',
|
|
260
|
+
)
|
|
261
|
+
table, _, column = ranked.partition('.')
|
|
262
|
+
if candidates is None:
|
|
263
|
+
raise StructuredDataError(
|
|
264
|
+
f'a ranking is over a set of candidates, and this model has no '
|
|
265
|
+
f'ranking task to choose them with, so they have to be named: '
|
|
266
|
+
f'materialize(..., candidates=frame[{column!r}]). '
|
|
267
|
+
f'One call is made per candidate.',
|
|
268
|
+
code='INVALID_REQUEST',
|
|
269
|
+
)
|
|
270
|
+
unique = list(dict.fromkeys(candidates))
|
|
271
|
+
if not unique:
|
|
272
|
+
raise StructuredDataError(
|
|
273
|
+
'candidates is empty, so there is nothing to rank',
|
|
274
|
+
code='INVALID_REQUEST',
|
|
275
|
+
)
|
|
276
|
+
_refuse_too_many(len(unique), 'candidates')
|
|
277
|
+
dtype = _how_the_column_travels(schema, table, column)
|
|
278
|
+
start, end, unit = _window(query)
|
|
279
|
+
return [
|
|
280
|
+
(
|
|
281
|
+
f'PREDICT COUNT({table}.* WHERE {ranked}='
|
|
282
|
+
f'{_as_literal(value, dtype)}, '
|
|
283
|
+
f'{start}, {end}, {unit}) > 0 {_for_each(query)}',
|
|
284
|
+
value,
|
|
285
|
+
)
|
|
286
|
+
for value in unique
|
|
287
|
+
]
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _per_step(query: ValidatedPredictiveQuery) -> list[tuple[str, Any]]:
|
|
291
|
+
r"""One question per step, each about the window that step covers.
|
|
292
|
+
|
|
293
|
+
The aggregation is rebuilt from what it aggregates, which carries any
|
|
294
|
+
filter it had: `COUNT(buys.* WHERE channel="web", ...)` keeps its channel.
|
|
295
|
+
Only the window moves.
|
|
296
|
+
"""
|
|
297
|
+
steps = int(getattr(query, 'num_forecasts', 1) or 1)
|
|
298
|
+
_refuse_too_many(steps, 'forecast steps')
|
|
299
|
+
aggregation = query.get_final_target_aggregation()
|
|
300
|
+
start, end, unit = _window(query)
|
|
301
|
+
span = end - start
|
|
302
|
+
return [
|
|
303
|
+
(
|
|
304
|
+
f'PREDICT {aggregation.aggr}({aggregation.target}, '
|
|
305
|
+
f'{start + step * span}, {end + step * span}, {unit}) '
|
|
306
|
+
f'{_for_each(query)}',
|
|
307
|
+
# The contract numbers steps from one.
|
|
308
|
+
step + 1,
|
|
309
|
+
)
|
|
310
|
+
for step in range(steps)
|
|
311
|
+
]
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _refuse_too_many(many: int, what: str) -> None:
|
|
315
|
+
if many <= MOST_PARTS:
|
|
316
|
+
return
|
|
317
|
+
raise StructuredDataError(
|
|
318
|
+
f'{many} {what} means {many} calls, one for each, because the model '
|
|
319
|
+
f'has no task that answers them together. That is past the {MOST_PARTS} '
|
|
320
|
+
f'this will do without being told to; ask about fewer, or raise '
|
|
321
|
+
f'sdm_client.decompose.MOST_PARTS if the wait is what you want.',
|
|
322
|
+
code='INVALID_REQUEST',
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _how_the_column_travels(
|
|
327
|
+
schema: Schema | None, table: str, column: str
|
|
328
|
+
) -> str | None:
|
|
329
|
+
r"""The dtype the schema computed for the column being ranked.
|
|
330
|
+
|
|
331
|
+
How a candidate is written is the column's question, not the value's: a key
|
|
332
|
+
column straddling int64 travels as text, and so must a small key from it.
|
|
333
|
+
"""
|
|
334
|
+
if schema is None or table not in schema:
|
|
335
|
+
return None
|
|
336
|
+
described = schema[table]
|
|
337
|
+
if column not in described:
|
|
338
|
+
return None
|
|
339
|
+
return str(described[column].dtype)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _as_literal(value: Any, dtype: str | None = None) -> str:
|
|
343
|
+
r"""``value`` as the query language writes it.
|
|
344
|
+
|
|
345
|
+
A candidate is a key, and a key that cannot be written as one is refused
|
|
346
|
+
rather than escaped into something that parses as a different question.
|
|
347
|
+
"""
|
|
348
|
+
# Candidates usually come out of a frame, where a whole number is a numpy
|
|
349
|
+
# integer and not an ``int``, and the two have to be written alike.
|
|
350
|
+
if hasattr(value, 'item'):
|
|
351
|
+
with contextlib.suppress(AttributeError, ValueError):
|
|
352
|
+
value = value.item()
|
|
353
|
+
if _is_missing(value):
|
|
354
|
+
raise StructuredDataError(
|
|
355
|
+
'a candidate names a row, and a missing value names none',
|
|
356
|
+
code='INVALID_REQUEST',
|
|
357
|
+
)
|
|
358
|
+
if isinstance(value, bool):
|
|
359
|
+
raise StructuredDataError(
|
|
360
|
+
'a candidate names a row, and True and False do not',
|
|
361
|
+
code='INVALID_REQUEST',
|
|
362
|
+
)
|
|
363
|
+
if isinstance(value, Decimal):
|
|
364
|
+
written = _decimal_as_typed(value, dtype)
|
|
365
|
+
if isinstance(written, (int, float)) and Decimal(written) != value:
|
|
366
|
+
raise StructuredDataError(
|
|
367
|
+
f'the candidate {value} names no row of a column of '
|
|
368
|
+
f'{dtype or "numbers"}: written as one it becomes {written}, '
|
|
369
|
+
f'which is a different row',
|
|
370
|
+
code='INVALID_REQUEST',
|
|
371
|
+
)
|
|
372
|
+
value = written
|
|
373
|
+
if dtype == 'string' and isinstance(value, (int, float)):
|
|
374
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
375
|
+
raise StructuredDataError(
|
|
376
|
+
f'{value!r} cannot name a row to rank',
|
|
377
|
+
code='INVALID_REQUEST',
|
|
378
|
+
)
|
|
379
|
+
value = format(Decimal(str(value)), 'f')
|
|
380
|
+
if isinstance(value, int):
|
|
381
|
+
return str(value)
|
|
382
|
+
if isinstance(value, float):
|
|
383
|
+
if not math.isfinite(value) or value != int(value):
|
|
384
|
+
raise StructuredDataError(
|
|
385
|
+
f'{value!r} cannot name a row to rank',
|
|
386
|
+
code='INVALID_REQUEST',
|
|
387
|
+
)
|
|
388
|
+
return str(int(value))
|
|
389
|
+
text = str(value)
|
|
390
|
+
if any(character in text for character in '"\'\\,()') or not text.strip():
|
|
391
|
+
raise StructuredDataError(
|
|
392
|
+
f'the candidate {value!r} cannot be written into a query: a key '
|
|
393
|
+
f'holding a quote, a comma, a bracket or nothing at all would '
|
|
394
|
+
f'read as part of the question rather than as the row it names',
|
|
395
|
+
code='INVALID_REQUEST',
|
|
396
|
+
)
|
|
397
|
+
return f'"{text}"'
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _decimal_as_typed(value: Decimal, dtype: str | None = None) -> Any:
|
|
401
|
+
r"""``value`` as the Python type its column is typed as on the wire.
|
|
402
|
+
|
|
403
|
+
A warehouse ``DECIMAL`` reaches a caller as a :class:`~decimal.Decimal`,
|
|
404
|
+
and written as a string it asks after the row named ``"1"`` on a column of
|
|
405
|
+
integers, a question the validator refuses and nobody wrote. Which type
|
|
406
|
+
that is does not follow from the Python type: :func:`_decimal_dtype` is
|
|
407
|
+
what decides whether such a column travels as an integer, a float, or its
|
|
408
|
+
digits. ``dtype`` carries the column's; the value alone is used only where
|
|
409
|
+
the column is not known.
|
|
410
|
+
|
|
411
|
+
The digits are formatted rather than ``str``-ed, since ``str`` turns to
|
|
412
|
+
scientific notation past int64's width, which is the width that makes such
|
|
413
|
+
a column text in the first place.
|
|
414
|
+
"""
|
|
415
|
+
if dtype is None:
|
|
416
|
+
dtype = _decimal_dtype(pd.Series([value], dtype=object))
|
|
417
|
+
if dtype == 'int64':
|
|
418
|
+
return int(value)
|
|
419
|
+
if dtype == 'float64':
|
|
420
|
+
return float(value)
|
|
421
|
+
return format(value, 'f')
|
sdm_client/errors.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES.
|
|
2
|
+
# All rights reserved.
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
r"""The exceptions this client raises.
|
|
6
|
+
|
|
7
|
+
Every call on :class:`~sdm_client.SDMClient` raises
|
|
8
|
+
:class:`StructuredDataError` or one of its subclasses, so ``except
|
|
9
|
+
StructuredDataError`` is enough to catch anything the client reports.
|
|
10
|
+
|
|
11
|
+
Every error carries a ``code``: a short stable string meant to be branched on.
|
|
12
|
+
Messages are written for people and will change; codes will not.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
#: What to do about a context that teaches nothing.
|
|
21
|
+
WIDEN_THE_CONTEXT = (
|
|
22
|
+
'Gather a wider context with num_context_samples, or ask about entities '
|
|
23
|
+
'whose answers differ'
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
#: Entries rendered from an RFC-9457 ``invalid_params`` list before the rest
|
|
27
|
+
#: are summarised as a count. A rejected batch can name thousands of rows.
|
|
28
|
+
MAX_INVALID_PARAMS = 5
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def format_invalid_params(params: Sequence[Any] | None) -> str:
|
|
32
|
+
r"""Render an endpoint's per-field validation diagnosis into a suffix.
|
|
33
|
+
|
|
34
|
+
A validation failure answers with RFC-9457 ``invalid_params``, naming the
|
|
35
|
+
exact table, row and column it rejected, while the top-level ``detail`` is
|
|
36
|
+
often only "Request validation failed." Dropping the list leaves the caller
|
|
37
|
+
with nothing to act on.
|
|
38
|
+
|
|
39
|
+
Returns the empty string when there is nothing to render, so callers can
|
|
40
|
+
concatenate unconditionally.
|
|
41
|
+
"""
|
|
42
|
+
if not isinstance(params, (list, tuple)):
|
|
43
|
+
return ''
|
|
44
|
+
entries = []
|
|
45
|
+
for param in params[:MAX_INVALID_PARAMS]:
|
|
46
|
+
if not isinstance(param, dict):
|
|
47
|
+
continue
|
|
48
|
+
name, reason = param.get('name'), param.get('reason')
|
|
49
|
+
if name and reason:
|
|
50
|
+
entries.append(f'{name}: {reason}')
|
|
51
|
+
elif name or reason:
|
|
52
|
+
entries.append(str(name or reason))
|
|
53
|
+
if not entries:
|
|
54
|
+
return ''
|
|
55
|
+
omitted = len(params) - len(entries)
|
|
56
|
+
more = f' (and {omitted} more)' if omitted > 0 else ''
|
|
57
|
+
return ' ' + '; '.join(entries) + more
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class StructuredDataError(Exception):
|
|
61
|
+
r"""Base class for every error this client raises.
|
|
62
|
+
|
|
63
|
+
Attributes:
|
|
64
|
+
message: The human-readable description, without the code prefix.
|
|
65
|
+
code: A short stable identifier, e.g. ``'TRANSPORT_ERROR'`` or
|
|
66
|
+
``'INVALID_REQUEST'``. Branch on this rather than on the message.
|
|
67
|
+
details: Whatever structured context the failure carried, such as the
|
|
68
|
+
``invalid_params`` entries from a NIM validation error. Empty when
|
|
69
|
+
there is none.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
message: str,
|
|
75
|
+
*,
|
|
76
|
+
code: str | None = None,
|
|
77
|
+
details: dict[str, Any] | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
super().__init__(message)
|
|
80
|
+
self.message = message
|
|
81
|
+
self.code = code
|
|
82
|
+
self.details = details or {}
|
|
83
|
+
|
|
84
|
+
def __str__(self) -> str:
|
|
85
|
+
if self.code:
|
|
86
|
+
return f'[{self.code}] {self.message}'
|
|
87
|
+
return self.message
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class UnknownModelError(StructuredDataError):
|
|
91
|
+
r"""A request named a model this client cannot send.
|
|
92
|
+
|
|
93
|
+
The contract's ``model`` field is a closed enum, so an id it does not carry
|
|
94
|
+
cannot be sent at all. The message names the change that would allow it
|
|
95
|
+
rather than letting the request fail at the endpoint. Code:
|
|
96
|
+
``UNKNOWN_MODEL``.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(self, model: str, known: list[str]) -> None:
|
|
100
|
+
super().__init__(
|
|
101
|
+
f'Unknown model {model!r}; this client can send: {sorted(known)}. '
|
|
102
|
+
f'The Universal TFM contract declares these as a closed set, so a '
|
|
103
|
+
f'new model id has to be added to the contract and the bindings '
|
|
104
|
+
f'regenerated before it can be sent.',
|
|
105
|
+
code='UNKNOWN_MODEL',
|
|
106
|
+
details={'model': model, 'known_models': sorted(known)},
|
|
107
|
+
)
|
|
108
|
+
self.model = model
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class UnsupportedRequestError(StructuredDataError):
|
|
112
|
+
r"""The model cannot answer this request, and says so before sending it.
|
|
113
|
+
|
|
114
|
+
Raised when a request asks for something the model's profile rejects, such
|
|
115
|
+
as
|
|
116
|
+
related tables for a single-table model, an output field it does not
|
|
117
|
+
produce, or exceeds a limit the endpoint enforces. Code:
|
|
118
|
+
``UNSUPPORTED_REQUEST``.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __init__(
|
|
122
|
+
self, message: str, *, details: dict[str, Any] | None = None
|
|
123
|
+
) -> None:
|
|
124
|
+
super().__init__(message, code='UNSUPPORTED_REQUEST', details=details)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class MissingExtraError(StructuredDataError):
|
|
128
|
+
r"""An optional dependency this call needs is not installed.
|
|
129
|
+
|
|
130
|
+
The relational engine and the connector drivers ship as extras, so the
|
|
131
|
+
client can be installed without them. The message names the ``pip install``
|
|
132
|
+
that fixes it. Code: ``MISSING_EXTRA``.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
def __init__(self, extra: str, package: str) -> None:
|
|
136
|
+
super().__init__(
|
|
137
|
+
f'{package!r} is required for this call; install it with '
|
|
138
|
+
f'`pip install structured-data-models-client[{extra}]`',
|
|
139
|
+
code='MISSING_EXTRA',
|
|
140
|
+
details={'extra': extra, 'package': package},
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class NimRequestError(StructuredDataError):
|
|
145
|
+
r"""The NIM answered, and the answer was an error.
|
|
146
|
+
|
|
147
|
+
Distinguished from :class:`StructuredDataError` by carrying the HTTP
|
|
148
|
+
:attr:`status_code`, which is the endpoint's own answer, kept as it was.
|
|
149
|
+
|
|
150
|
+
Branch on :attr:`code`, not on the status. ``INVALID_REQUEST`` means the
|
|
151
|
+
request has to change and sending it again cannot help; ``429``, ``502``,
|
|
152
|
+
``503`` and ``504`` are worth retrying, and this client retries them. The
|
|
153
|
+
two can disagree: the
|
|
154
|
+
endpoint reports some caller mistakes with a ``500`` and an internal code,
|
|
155
|
+
in a sentence that names exactly what the caller got wrong, and this client
|
|
156
|
+
reports those as the caller's. Where it does, the endpoint's own
|
|
157
|
+
classification is kept in :attr:`details` under ``endpoint_code``.
|
|
158
|
+
|
|
159
|
+
Attributes:
|
|
160
|
+
status_code: The HTTP status the NIM returned.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
def __init__(
|
|
164
|
+
self,
|
|
165
|
+
status_code: int,
|
|
166
|
+
*,
|
|
167
|
+
code: str | None,
|
|
168
|
+
message: str,
|
|
169
|
+
details: dict[str, Any] | None = None,
|
|
170
|
+
) -> None:
|
|
171
|
+
super().__init__(message, code=code, details=details)
|
|
172
|
+
self.status_code = status_code
|
|
173
|
+
|
|
174
|
+
def __str__(self) -> str:
|
|
175
|
+
if self.code:
|
|
176
|
+
return f'[{self.status_code} {self.code}] {self.message}'
|
|
177
|
+
return f'[{self.status_code}] {self.message}'
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES.
|
|
2
|
+
# All rights reserved.
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
r"""Model-specific implementations behind query materialization."""
|
|
6
|
+
|
|
7
|
+
from sdm_client.materializers.relational import RelationalMaterializer
|
|
8
|
+
from sdm_client.materializers.tabular import TabularMaterializer
|
|
9
|
+
|
|
10
|
+
__all__ = ['RelationalMaterializer', 'TabularMaterializer']
|