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/base.py ADDED
@@ -0,0 +1,300 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES.
2
+ # All rights reserved.
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ r"""What a model is, to this client.
6
+
7
+ A model is described by two things that are deliberately kept apart.
8
+
9
+ A **profile** says what the model may be *asked*. It comes from
10
+ ``predictive_query`` and is data, not code: a frozen set of flags obeying one
11
+ normative rule: a capability may only reject. The language accepts a fixed
12
+ set of queries and every model accepts a subset of it, so declaring a model can
13
+ never widen what the language allows.
14
+
15
+ A **runtime contract** says what this *deployment* can do: which task kinds and
16
+ output fields it serves, whether it holds sessions, and the limits it enforces.
17
+ A profile cannot know whether a checkpoint for regression was packaged, so
18
+ those facts live beside it and are never duplicated into it.
19
+
20
+ An **adapter** does what neither can describe: shape the wire request,
21
+ normalize the response, choose a session strategy, release resources. It does
22
+ not own query-shape validation. The profile does.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from collections.abc import Iterable, Mapping
28
+ from dataclasses import dataclass, field, fields
29
+ from typing import TYPE_CHECKING, Any, Protocol
30
+
31
+ from predictive_query import Capabilities, Profile
32
+
33
+ from sdm_client.errors import UnsupportedRequestError
34
+
35
+ if TYPE_CHECKING: # pragma: no cover - imported for typing only.
36
+ from sdm_client.request import Request
37
+ from sdm_client.result import PredictionResult
38
+
39
+ # The capability flags that describe the *shape* of a request, as opposed to
40
+ # the deployment concerns in the same dataclass. Only these are derivable from
41
+ # a request, so only these are compared.
42
+ SHAPE_CAPABILITIES = (
43
+ 'relationships',
44
+ 'multiple_tables',
45
+ 'link_prediction',
46
+ 'forecasting',
47
+ 'static',
48
+ 'temporal',
49
+ )
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class RuntimeContract:
54
+ r"""What a deployment of the model serves.
55
+
56
+ Attributes:
57
+ tasks: The task kinds the endpoint accepts. Narrower than the
58
+ contract's enum: a tabular model takes only the coarse
59
+ ``'classification'``.
60
+ outputs: The output fields the model produces.
61
+ inference_options: Inference-config keys the endpoint accepts.
62
+ explanation_formats: Named explanation shapes, when it explains.
63
+ sessions: Whether the endpoint holds a pinned context.
64
+ limits: Sizes the endpoint enforces, so the client can refuse locally
65
+ rather than eat a 500.
66
+ unread_feature_stypes: Semantic types the endpoint accepts and the
67
+ model does not read. A column carrying one is answered around
68
+ rather than refused, so the caller is told it carried nothing.
69
+ """
70
+
71
+ tasks: frozenset[str]
72
+ outputs: frozenset[str]
73
+ explanation_formats: tuple[str, ...] = ()
74
+ sessions: bool = True
75
+ limits: Mapping[str, int] = field(default_factory=dict)
76
+ #: Inference-config keys this deployment accepts. Empty means it takes no
77
+ #: inference config at all. A driver built for a different backend may
78
+ #: default to sending more than this, which the endpoint refuses.
79
+ inference_options: frozenset[str] = frozenset()
80
+ #: Semantic types this deployment takes and the model never reads. The
81
+ #: request succeeds and the column contributes nothing, which is worth
82
+ #: saying out loud.
83
+ unread_feature_stypes: frozenset[str] = frozenset()
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class ModelSpec:
88
+ r"""A model id, what it may be asked, and what its deployment serves."""
89
+
90
+ model_id: str
91
+ profile: Profile
92
+ runtime: RuntimeContract
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class ModelCapabilities:
97
+ r"""What :meth:`~sdm_client.SDMClient.capabilities` reports.
98
+
99
+ Describes the client side. Nothing here is read from the endpoint, and
100
+ ``source`` says so, so a caller cannot mistake it for the endpoint's own
101
+ answer.
102
+ """
103
+
104
+ model: str
105
+ tasks: tuple[str, ...]
106
+ outputs: tuple[str, ...]
107
+ single_table: bool
108
+ multiple_tables: bool
109
+ relationships: bool
110
+ explanation_formats: tuple[str, ...] = ()
111
+ sessions: bool = True
112
+ source: str = 'sdk'
113
+
114
+
115
+ class ModelAdapter(Protocol):
116
+ r"""The per-model half of a prediction.
117
+
118
+ Deliberately small. Everything a profile or a runtime contract can already
119
+ state is left to them; what remains is the work that genuinely differs
120
+ between models.
121
+ """
122
+
123
+ spec: ModelSpec
124
+
125
+ def default_output(self, request: Request) -> tuple[str, ...]:
126
+ r"""The output fields to ask for when the caller named none."""
127
+ ...
128
+
129
+ def prediction_plan(
130
+ self, request: Request, outputs: tuple[str, ...]
131
+ ) -> Iterable[dict[str, Any]]:
132
+ r"""The wire payloads for one request, in the order to send them.
133
+
134
+ More than one when the request exceeds the endpoint's per-call cap.
135
+ Batching lives here because it is transport, not part of the question.
136
+ """
137
+ ...
138
+
139
+ def session_plan(
140
+ self, request: Request, outputs: tuple[str, ...]
141
+ ) -> dict[str, Any]:
142
+ r"""The payload that pins this request's context on the endpoint."""
143
+ ...
144
+
145
+ def session_prediction_plan(
146
+ self,
147
+ request: Request,
148
+ outputs: tuple[str, ...],
149
+ pinned: Mapping[str, Any] | None = None,
150
+ ) -> Iterable[dict[str, Any]]:
151
+ r"""The payloads that score against an already-pinned context.
152
+
153
+ ``pinned`` is the envelope the session opened with, minus the context
154
+ itself. The schema is not sent again on a scoring call, so the rows
155
+ have to be written to describe the one that was pinned.
156
+ """
157
+ ...
158
+
159
+ def normalize(
160
+ self,
161
+ response: dict[str, Any],
162
+ outputs: tuple[str, ...],
163
+ payload: dict[str, Any],
164
+ ) -> PredictionResult:
165
+ r"""Turn one endpoint response into the client's result type.
166
+
167
+ ``payload`` is the request that produced it. A batch's answers are
168
+ indexed within that batch, so mapping them back to what the caller
169
+ asked about needs the request beside the response.
170
+ """
171
+ ...
172
+
173
+ def explain_error(
174
+ self, error: Exception, payload: dict[str, Any]
175
+ ) -> Exception:
176
+ r"""A chance to add what the endpoint's message left out.
177
+
178
+ A NIM sometimes reports a rule without saying which column broke it.
179
+ The request that was just refused knows, and the adapter is holding it,
180
+ so the answer is one lookup away. Returns the error to raise, the one
181
+ it was given, unless it has something to add.
182
+ """
183
+ return error
184
+
185
+ def close(self) -> None:
186
+ r"""Release anything this adapter opened that the client cannot see."""
187
+ ...
188
+
189
+
190
+ def required_capabilities(request: Request) -> Capabilities:
191
+ r"""What answering ``request`` would require of a model.
192
+
193
+ Built with :meth:`Capabilities.nothing` and switched on, so a flag this
194
+ request does not need stays off and cannot cause a spurious rejection.
195
+ """
196
+ kind = request.task.kind
197
+ temporal = bool(request.task.options.get('anchor_time_column')) or kind in {
198
+ 'forecasting',
199
+ 'temporal_link_prediction',
200
+ }
201
+ return Capabilities.nothing('this request').also(
202
+ relationships=bool(request.schema.relationships),
203
+ multiple_tables=len(request.schema) > 1,
204
+ link_prediction=kind == 'temporal_link_prediction',
205
+ forecasting=kind == 'forecasting',
206
+ temporal=temporal,
207
+ static=not temporal,
208
+ )
209
+
210
+
211
+ def check_request_against_model(
212
+ request: Request, spec: ModelSpec, outputs: tuple[str, ...]
213
+ ) -> None:
214
+ r"""Refuse, locally, a request the model cannot answer.
215
+
216
+ Three checks, in the order that produces the most useful message: the
217
+ shape the profile rejects, then the task kind, then the output fields.
218
+ A shape mismatch explains the other two, so it is reported first.
219
+
220
+ Raises:
221
+ UnsupportedRequestError: With the model named, and what it does
222
+ support.
223
+ """
224
+ required = required_capabilities(request)
225
+ supported = spec.profile.capabilities
226
+ unsupported = [
227
+ name
228
+ for name in SHAPE_CAPABILITIES
229
+ if getattr(required, name) and not getattr(supported, name)
230
+ ]
231
+ if unsupported:
232
+ raise UnsupportedRequestError(
233
+ f'{spec.model_id} cannot answer a request needing '
234
+ f'{", ".join(sorted(unsupported))}',
235
+ details={
236
+ 'model': spec.model_id,
237
+ 'unsupported': sorted(unsupported),
238
+ },
239
+ )
240
+
241
+ if request.task.kind not in spec.runtime.tasks:
242
+ raise UnsupportedRequestError(
243
+ f'{spec.model_id} does not serve the task kind '
244
+ f'{request.task.kind!r}; it serves '
245
+ f'{sorted(spec.runtime.tasks)}',
246
+ details={
247
+ 'model': spec.model_id,
248
+ 'task': request.task.kind,
249
+ 'supported': sorted(spec.runtime.tasks),
250
+ },
251
+ )
252
+
253
+ unknown = [name for name in outputs if name not in spec.runtime.outputs]
254
+ if unknown:
255
+ raise UnsupportedRequestError(
256
+ f'{spec.model_id} does not produce {sorted(unknown)}; it produces '
257
+ f'{sorted(spec.runtime.outputs)}',
258
+ details={
259
+ 'model': spec.model_id,
260
+ 'unsupported_outputs': sorted(unknown),
261
+ 'supported_outputs': sorted(spec.runtime.outputs),
262
+ },
263
+ )
264
+
265
+ cap = spec.runtime.limits.get('context_rows_max')
266
+ rows = request._context_rows_per_call()
267
+ if cap is not None and rows > cap:
268
+ raise UnsupportedRequestError(
269
+ f'this request carries {rows:,} context rows and {spec.model_id} '
270
+ f'reads at most {cap:,}. A request built directly is sent as it '
271
+ f'was given, so choose the rows you want as context, or gather it '
272
+ f'with materialize(), which keeps the context under the cap and '
273
+ f'says how',
274
+ details={
275
+ 'model': spec.model_id,
276
+ 'context_rows': rows,
277
+ 'context_rows_max': cap,
278
+ },
279
+ )
280
+
281
+
282
+ def describe(spec: ModelSpec) -> ModelCapabilities:
283
+ r"""The public description of a model, for ``capabilities()``."""
284
+ supported = spec.profile.capabilities
285
+ return ModelCapabilities(
286
+ model=spec.model_id,
287
+ tasks=tuple(sorted(spec.runtime.tasks)),
288
+ outputs=tuple(sorted(spec.runtime.outputs)),
289
+ single_table=True,
290
+ multiple_tables=supported.multiple_tables,
291
+ relationships=supported.relationships,
292
+ explanation_formats=spec.runtime.explanation_formats,
293
+ sessions=spec.runtime.sessions,
294
+ source='sdk',
295
+ )
296
+
297
+
298
+ def capability_names() -> tuple[str, ...]:
299
+ r"""Every flag ``Capabilities`` carries, for tests that assert coverage."""
300
+ return tuple(f.name for f in fields(Capabilities) if f.name != 'model')