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
sdm_client/query.py
ADDED
|
@@ -0,0 +1,647 @@
|
|
|
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 in PQL, and gathering the data that answers it.
|
|
6
|
+
|
|
7
|
+
A predictive query says *what* to predict. It never says *which rows* to
|
|
8
|
+
predict for. That is always an explicit argument because a query can only
|
|
9
|
+
enumerate rows that are already in the tables:
|
|
10
|
+
|
|
11
|
+
>>> query = PredictiveQuery(
|
|
12
|
+
... 'PREDICT SUM(orders.price, 0, 30, days) FOR EACH users.user_id',
|
|
13
|
+
... {'users': users_df, 'orders': orders_df},
|
|
14
|
+
... )
|
|
15
|
+
>>> request = query.materialize(indices=[1, 2, 3])
|
|
16
|
+
|
|
17
|
+
:meth:`~PredictiveQuery.materialize` is the gathering step, and it is explicit
|
|
18
|
+
because it is the expensive one: it parses the query, finds labelled examples
|
|
19
|
+
in history, samples the neighbourhood of each entity, and encodes the result.
|
|
20
|
+
Nothing is sent. What comes back is a :class:`~sdm_client.Request` gathered
|
|
21
|
+
for one model. Which one follows the schema's shape unless ``model`` says
|
|
22
|
+
otherwise, and the model asked to answer it has to be that same one.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import re
|
|
28
|
+
from collections.abc import Mapping, Sequence
|
|
29
|
+
from numbers import Integral
|
|
30
|
+
from typing import TYPE_CHECKING, Any
|
|
31
|
+
|
|
32
|
+
import pandas as pd
|
|
33
|
+
from predictive_query import RELATIONAL, TABULAR
|
|
34
|
+
|
|
35
|
+
from sdm_client.decompose import (
|
|
36
|
+
Decomposition,
|
|
37
|
+
about_the_part,
|
|
38
|
+
parts_of,
|
|
39
|
+
refuse_a_part_that_would_take_several_calls,
|
|
40
|
+
)
|
|
41
|
+
from sdm_client.errors import MissingExtraError, StructuredDataError
|
|
42
|
+
from sdm_client.materializers import RelationalMaterializer, TabularMaterializer
|
|
43
|
+
from sdm_client.materializers.relational import (
|
|
44
|
+
refuse_more_entities_than_fit_in_memory,
|
|
45
|
+
rows_one_call_scores,
|
|
46
|
+
rows_per_call_asked_for,
|
|
47
|
+
)
|
|
48
|
+
from sdm_client.models import RELATIONAL_LIMITS, Model, spec_for
|
|
49
|
+
from sdm_client.request import Request
|
|
50
|
+
from sdm_client.schema import _MAX_CATEGORICAL_CARDINALITY as _MAX_CATEGORICAL
|
|
51
|
+
from sdm_client.schema import Schema
|
|
52
|
+
|
|
53
|
+
if TYPE_CHECKING:
|
|
54
|
+
from predictive_query import Profile, ValidatedPredictiveQuery
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class PredictiveQuery:
|
|
58
|
+
r"""A question expressed in PQL and answered from the tables in its schema.
|
|
59
|
+
|
|
60
|
+
The query is resolved and validated when :meth:`materialize` builds a
|
|
61
|
+
request.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
query: PQL query text.
|
|
65
|
+
data: A schema or mapping of table names to DataFrames.
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
StructuredDataError: If the query is empty or the data is invalid.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
query: str,
|
|
74
|
+
data: Schema | Mapping[str, pd.DataFrame],
|
|
75
|
+
) -> None:
|
|
76
|
+
if not isinstance(query, str) or not query.strip():
|
|
77
|
+
raise StructuredDataError(
|
|
78
|
+
'query must be a non-empty string', code='INVALID_REQUEST'
|
|
79
|
+
)
|
|
80
|
+
self.text = query
|
|
81
|
+
self.schema = (
|
|
82
|
+
data if isinstance(data, Schema) else Schema.from_data(dict(data))
|
|
83
|
+
)
|
|
84
|
+
self._tabular_materializer = TabularMaterializer(self.schema)
|
|
85
|
+
self._relational_materializer = RelationalMaterializer(self.schema)
|
|
86
|
+
|
|
87
|
+
def materialize(
|
|
88
|
+
self,
|
|
89
|
+
*,
|
|
90
|
+
indices: Sequence[Any] | None = None,
|
|
91
|
+
predict: pd.DataFrame | None = None,
|
|
92
|
+
num_context_samples: int | None = None,
|
|
93
|
+
model: str | Model | None = None,
|
|
94
|
+
candidates: Sequence[Any] | None = None,
|
|
95
|
+
task: str | None = None,
|
|
96
|
+
**options: Any,
|
|
97
|
+
) -> Request:
|
|
98
|
+
r"""Build a request with the data needed to answer this query.
|
|
99
|
+
|
|
100
|
+
The request takes one of three paths: predict=<frame> uses the single
|
|
101
|
+
table as context, the tabular route builds the request directly in
|
|
102
|
+
pandas, and the relational route gathers the tables as a graph. Either
|
|
103
|
+
single-table path samples the context down to what the endpoint
|
|
104
|
+
accepts, and says so when it does.
|
|
105
|
+
|
|
106
|
+
``model`` chooses between the last two. Left unsaid, the schema's shape
|
|
107
|
+
decides, and the shape that counts is the one
|
|
108
|
+
:attr:`~sdm_client.Request.is_relational` reports: a relationship sends
|
|
109
|
+
a schema relationally as surely as a second table does, so a lone table
|
|
110
|
+
that points at itself is gathered as the graph it is. Name a model when
|
|
111
|
+
that default is not what you want. A single table with no
|
|
112
|
+
relationships is a graph of one node, and the relational model answers
|
|
113
|
+
it.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
indices: Keys of rows already present in the tables. Mutually
|
|
117
|
+
exclusive with ``predict``. On the relational route, more than
|
|
118
|
+
the endpoint scores in one call are gathered as several calls,
|
|
119
|
+
and :meth:`~sdm_client.SDMClient.predict` answers them as one.
|
|
120
|
+
A question asked in parts, a ranking or a forecast, is one
|
|
121
|
+
call per part and is refused rather than batched.
|
|
122
|
+
predict: Rows supplied directly for scoring. Supported for
|
|
123
|
+
single-table schemas and mutually exclusive with ``indices``.
|
|
124
|
+
num_context_samples: For ``indices`` requests, the maximum number
|
|
125
|
+
of labelled context rows to use. Capped at what the endpoint
|
|
126
|
+
accepts. When omitted, a single-table request uses every
|
|
127
|
+
eligible row up to that cap, and a relational one uses up to
|
|
128
|
+
1,000.
|
|
129
|
+
model: The model this request is being gathered for, as a handle
|
|
130
|
+
such as ``KumoRelational()`` or a model id. Defaults to the
|
|
131
|
+
schema's shape.
|
|
132
|
+
candidates: The rows a ranking is over. A ranking is answered as
|
|
133
|
+
one question per candidate, so the set has to be named rather
|
|
134
|
+
than assumed, and one call is made for each.
|
|
135
|
+
task: The kind of question this is, when the target does not
|
|
136
|
+
settle it. Authoritative, and single-table only: passing it
|
|
137
|
+
with a relational or decomposed query is refused.
|
|
138
|
+
**options: Relational sampling options, such as ``num_neighbors``
|
|
139
|
+
and ``anchor_time``; ``prediction_statistic``: what a
|
|
140
|
+
regression's prediction reports of its predicted distribution,
|
|
141
|
+
``'median'`` (the default) or ``'mean'``; and
|
|
142
|
+
``rows_per_call``: how many entities one call scores, which
|
|
143
|
+
defaults to what the endpoint takes, 10,000 or 200 for a
|
|
144
|
+
temporal link prediction, and may only be lowered. Lower it
|
|
145
|
+
for a graph wide enough that a full batch outruns the 30 MiB
|
|
146
|
+
body, or for a deployment holding to less than this client
|
|
147
|
+
knows of.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
A :class:`~sdm_client.Request` carrying the data.
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
UnknownModelError: If ``model`` is an id the contract does not
|
|
154
|
+
carry.
|
|
155
|
+
StructuredDataError: If neither ``indices`` nor ``predict`` is
|
|
156
|
+
given, if both are, if an option is not one this accepts, or if
|
|
157
|
+
``model`` cannot answer a schema of this shape.
|
|
158
|
+
"""
|
|
159
|
+
spec = spec_for(model) if model is not None else None
|
|
160
|
+
in_place = self.schema.source is not None
|
|
161
|
+
relational = (
|
|
162
|
+
spec.profile == RELATIONAL
|
|
163
|
+
if spec is not None
|
|
164
|
+
else in_place
|
|
165
|
+
or len(self.schema) > 1
|
|
166
|
+
or bool(self.schema.relationships)
|
|
167
|
+
)
|
|
168
|
+
if in_place and predict is not None and relational:
|
|
169
|
+
raise StructuredDataError(
|
|
170
|
+
'predict= carries rows, and a schema over tables in their '
|
|
171
|
+
'source is sampled there instead; name the entities to score '
|
|
172
|
+
'with indices=',
|
|
173
|
+
code='INVALID_REQUEST',
|
|
174
|
+
)
|
|
175
|
+
if in_place and not relational:
|
|
176
|
+
raise StructuredDataError(
|
|
177
|
+
'a schema over tables in their source is sampled with SQL, '
|
|
178
|
+
'which only the relational model reads; the tabular model '
|
|
179
|
+
'takes rows in memory. Use model=KumoRelational() with '
|
|
180
|
+
'indices=, or build the schema with Schema.from_data from the '
|
|
181
|
+
'rows you want',
|
|
182
|
+
code='INVALID_REQUEST',
|
|
183
|
+
)
|
|
184
|
+
self._validate_materialize_args(
|
|
185
|
+
relational=relational,
|
|
186
|
+
model_id=spec.model_id if spec is not None else None,
|
|
187
|
+
len_schema=len(self.schema),
|
|
188
|
+
relationships=len(self.schema.relationships),
|
|
189
|
+
indices=indices,
|
|
190
|
+
predict=predict,
|
|
191
|
+
num_context_samples=num_context_samples,
|
|
192
|
+
options=options,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# Resolved once, and against the profile this request is for, because
|
|
196
|
+
# whether it is asked as several questions is a property of the query
|
|
197
|
+
# and the profile answers what the query is allowed to be.
|
|
198
|
+
asked = self._resolve(RELATIONAL if relational else TABULAR)
|
|
199
|
+
rewritten = parts_of(
|
|
200
|
+
asked,
|
|
201
|
+
self.text,
|
|
202
|
+
list(candidates) if candidates is not None else None,
|
|
203
|
+
options,
|
|
204
|
+
self.schema,
|
|
205
|
+
)
|
|
206
|
+
if task is not None and (relational or rewritten is not None):
|
|
207
|
+
why = (
|
|
208
|
+
'a decomposed question'
|
|
209
|
+
if rewritten is not None
|
|
210
|
+
else 'a relational query'
|
|
211
|
+
)
|
|
212
|
+
raise StructuredDataError(
|
|
213
|
+
f'task={task!r} settles what a single-table question is '
|
|
214
|
+
f'asking, which is the only route that has to decide. '
|
|
215
|
+
f'{why.capitalize()} resolves to its own kind, so state it in '
|
|
216
|
+
f'the query rather than beside it',
|
|
217
|
+
code='INVALID_REQUEST',
|
|
218
|
+
details={'task': repr(task)},
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if rewritten is not None:
|
|
222
|
+
return self._in_parts(
|
|
223
|
+
rewritten,
|
|
224
|
+
indices=indices,
|
|
225
|
+
predict=predict,
|
|
226
|
+
num_context_samples=num_context_samples,
|
|
227
|
+
model=model,
|
|
228
|
+
**options,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
if predict is not None:
|
|
232
|
+
return self._tabular_materializer.table(
|
|
233
|
+
query=asked,
|
|
234
|
+
predict=predict,
|
|
235
|
+
task=task,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
assert indices is not None
|
|
239
|
+
if not relational:
|
|
240
|
+
return self._tabular_materializer.indices(
|
|
241
|
+
query=asked,
|
|
242
|
+
indices=indices,
|
|
243
|
+
num_context_samples=num_context_samples,
|
|
244
|
+
task=task,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
return self._relational_materializer.materialize(
|
|
248
|
+
query=asked,
|
|
249
|
+
indices=indices,
|
|
250
|
+
num_context_samples=num_context_samples,
|
|
251
|
+
options=options,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
@staticmethod
|
|
255
|
+
def _validate_materialize_args(
|
|
256
|
+
*,
|
|
257
|
+
relational: bool,
|
|
258
|
+
model_id: str | None,
|
|
259
|
+
len_schema: int,
|
|
260
|
+
relationships: int,
|
|
261
|
+
indices: Sequence[Any] | None,
|
|
262
|
+
predict: pd.DataFrame | None,
|
|
263
|
+
num_context_samples: int | None,
|
|
264
|
+
options: dict[str, Any] | None,
|
|
265
|
+
) -> None:
|
|
266
|
+
r"""Refuse a call that cannot be gathered, in an order that advises.
|
|
267
|
+
|
|
268
|
+
Which rows to score is a property of the call rather than of the route,
|
|
269
|
+
and naming a model resolves neither, so both are settled before
|
|
270
|
+
anything below can suggest one. The route comes next, because a model
|
|
271
|
+
that cannot read this schema at all explains every refusal after it.
|
|
272
|
+
|
|
273
|
+
Each message names a way out that works, which sometimes depends on the
|
|
274
|
+
schema rather than only on the call. Gathering options are honoured by
|
|
275
|
+
the relational model, which scores rows the tables already hold, so
|
|
276
|
+
naming that model alone would land the caller on the next refusal and
|
|
277
|
+
the message names ``indices`` too. A supplied frame can be gathered
|
|
278
|
+
tabularly only where the schema has no relationship to lose, so where
|
|
279
|
+
it has one the message says to drop it rather than to name a model that
|
|
280
|
+
would be refused for it.
|
|
281
|
+
"""
|
|
282
|
+
if indices is not None and predict is not None:
|
|
283
|
+
raise StructuredDataError(
|
|
284
|
+
'pass either indices=[...] to score rows the tables already '
|
|
285
|
+
'contain, or predict=<frame> to score rows they do not, not both',
|
|
286
|
+
code='INVALID_REQUEST',
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
if indices is None and predict is None:
|
|
290
|
+
raise StructuredDataError(
|
|
291
|
+
'materialize() needs the rows to score: pass indices=[...] '
|
|
292
|
+
'naming the entities. There is no all-rows default, because '
|
|
293
|
+
'it would silently score an entire table',
|
|
294
|
+
code='INVALID_REQUEST',
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
if not relational and len_schema != 1:
|
|
298
|
+
raise StructuredDataError(
|
|
299
|
+
f'{model_id} answers from a single table, and this schema has '
|
|
300
|
+
f'{len_schema}. Pass model=KumoRelational() to gather them as '
|
|
301
|
+
f'a graph',
|
|
302
|
+
code='INVALID_REQUEST',
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
if not relational and relationships:
|
|
306
|
+
raise StructuredDataError(
|
|
307
|
+
f'{model_id} reads no relationships, and this schema declares '
|
|
308
|
+
f'{relationships}. Pass model=KumoRelational() to gather it as '
|
|
309
|
+
f'a graph, or drop the relationship to ask about the table on '
|
|
310
|
+
f'its own',
|
|
311
|
+
code='INVALID_REQUEST',
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
if not relational and options:
|
|
315
|
+
raise StructuredDataError(
|
|
316
|
+
f'gathering options apply only to relational materialization: '
|
|
317
|
+
f'{sorted(options)}. Pass indices=[...] with '
|
|
318
|
+
f'model=KumoRelational() to gather this schema as a graph',
|
|
319
|
+
code='INVALID_REQUEST',
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
if predict is not None:
|
|
323
|
+
if len_schema != 1:
|
|
324
|
+
raise StructuredDataError(
|
|
325
|
+
f'predict=<frame> requires a single-table schema; '
|
|
326
|
+
f'got {len_schema} tables. For a relational schema, use '
|
|
327
|
+
f'indices=[...] to select entities already in the tables',
|
|
328
|
+
code='INVALID_REQUEST',
|
|
329
|
+
)
|
|
330
|
+
if relational:
|
|
331
|
+
instead = (
|
|
332
|
+
'drop the relationship to gather this table for the '
|
|
333
|
+
'tabular model'
|
|
334
|
+
if relationships
|
|
335
|
+
else 'gather this request for the tabular model'
|
|
336
|
+
)
|
|
337
|
+
raise StructuredDataError(
|
|
338
|
+
'predict=<frame> is the single-table form, and '
|
|
339
|
+
f'{model_id or "a relational gather"} scores rows the '
|
|
340
|
+
f'tables already contain. Pass indices=[...] naming them, '
|
|
341
|
+
f'or {instead}',
|
|
342
|
+
code='INVALID_REQUEST',
|
|
343
|
+
)
|
|
344
|
+
if num_context_samples is not None:
|
|
345
|
+
raise StructuredDataError(
|
|
346
|
+
'num_context_samples cannot be used with '
|
|
347
|
+
'predict=<frame>: the schema table is the context. To '
|
|
348
|
+
'choose the context, build the schema from the rows you '
|
|
349
|
+
'want',
|
|
350
|
+
code='INVALID_REQUEST',
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
if indices is not None:
|
|
354
|
+
if not isinstance(indices, Sequence) or isinstance(
|
|
355
|
+
indices, (str, bytes)
|
|
356
|
+
):
|
|
357
|
+
raise StructuredDataError(
|
|
358
|
+
f'indices must be a sequence of entity keys, got '
|
|
359
|
+
f'{type(indices).__name__}',
|
|
360
|
+
code='INVALID_REQUEST',
|
|
361
|
+
)
|
|
362
|
+
unusable = [value for value in indices if not _is_hashable(value)]
|
|
363
|
+
if unusable:
|
|
364
|
+
kinds = sorted({type(v).__name__ for v in unusable})
|
|
365
|
+
raise StructuredDataError(
|
|
366
|
+
f'indices must be entity keys, and {len(unusable)} of the '
|
|
367
|
+
f'{len(indices)} given cannot be one: {kinds}. A key is '
|
|
368
|
+
f'looked up by value, so it has to be hashable. This is '
|
|
369
|
+
f'what a row of json.loads(...) or '
|
|
370
|
+
f'frame.to_dict("records") gives you; pass the key field '
|
|
371
|
+
f'of each instead, e.g. [row[{"<key>"!r}] for row in '
|
|
372
|
+
f'rows].',
|
|
373
|
+
code='INVALID_REQUEST',
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
if num_context_samples is not None:
|
|
377
|
+
if isinstance(num_context_samples, bool) or not isinstance(
|
|
378
|
+
num_context_samples, Integral
|
|
379
|
+
):
|
|
380
|
+
raise StructuredDataError(
|
|
381
|
+
'num_context_samples must be a whole number of labelled '
|
|
382
|
+
f'examples, got {type(num_context_samples).__name__} '
|
|
383
|
+
f'{num_context_samples!r}',
|
|
384
|
+
code='INVALID_REQUEST',
|
|
385
|
+
)
|
|
386
|
+
if num_context_samples < 1:
|
|
387
|
+
raise StructuredDataError(
|
|
388
|
+
'num_context_samples must be at least 1; a model predicts '
|
|
389
|
+
f'from labelled examples, and {num_context_samples} '
|
|
390
|
+
f'leaves it none',
|
|
391
|
+
code='INVALID_REQUEST',
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
def _in_parts(self, rewritten: Decomposition, **how: Any) -> Request:
|
|
395
|
+
r"""Gather the questions this one is asked as, and keep them together.
|
|
396
|
+
|
|
397
|
+
Each is gathered like any other query, because each needs the context
|
|
398
|
+
its own labels come from: what was true in the second window is not
|
|
399
|
+
what was true in the first, and whether an entity reached one
|
|
400
|
+
candidate is not whether it reached another.
|
|
401
|
+
|
|
402
|
+
A part is one call, which is what makes the fan-out countable, so a
|
|
403
|
+
question over more entities than one call scores is refused here
|
|
404
|
+
rather than gathered as parts that each batch.
|
|
405
|
+
"""
|
|
406
|
+
import copy
|
|
407
|
+
|
|
408
|
+
how = {name: value for name, value in how.items() if value is not None}
|
|
409
|
+
asked = 0 if how.get('indices') is None else len(how['indices'])
|
|
410
|
+
refuse_a_part_that_would_take_several_calls(
|
|
411
|
+
rewritten, asked, rows_one_call_scores(rows_per_call_asked_for(how))
|
|
412
|
+
)
|
|
413
|
+
refuse_more_entities_than_fit_in_memory(asked, len(rewritten.parts))
|
|
414
|
+
# How many candidates to keep belongs to the whole ranking. Each part
|
|
415
|
+
# is one yes-or-no question, and a task that ranks nothing is refused
|
|
416
|
+
# by the endpoint for carrying an option it cannot act on.
|
|
417
|
+
how.pop('top_k', None)
|
|
418
|
+
gathered = []
|
|
419
|
+
for text, step in rewritten.parts:
|
|
420
|
+
try:
|
|
421
|
+
gathered.append(
|
|
422
|
+
PredictiveQuery(text, self.schema).materialize(**how)
|
|
423
|
+
)
|
|
424
|
+
except MissingExtraError:
|
|
425
|
+
raise
|
|
426
|
+
except StructuredDataError as error:
|
|
427
|
+
raise about_the_part(error, rewritten, step) from error
|
|
428
|
+
# A copy, because the carrier cannot be one of the things it carries:
|
|
429
|
+
# asking it would ask the first part, which would ask the first part.
|
|
430
|
+
whole = copy.copy(gathered[0])
|
|
431
|
+
whole._parts = tuple(gathered)
|
|
432
|
+
whole._decomposition = rewritten
|
|
433
|
+
whole._asked = self.text
|
|
434
|
+
return whole
|
|
435
|
+
|
|
436
|
+
def _resolve(self, profile: Profile) -> ValidatedPredictiveQuery:
|
|
437
|
+
r"""Parse and validate the query."""
|
|
438
|
+
from predictive_query import validate
|
|
439
|
+
|
|
440
|
+
from sdm_client.schema import to_schema_definition
|
|
441
|
+
|
|
442
|
+
resolved, response = validate(
|
|
443
|
+
self.text, to_schema_definition(self.schema), profile
|
|
444
|
+
)
|
|
445
|
+
if resolved is None or not response.ok:
|
|
446
|
+
reasons = (
|
|
447
|
+
'; '.join(
|
|
448
|
+
_without_the_validator_trace(error.message)
|
|
449
|
+
for error in response.errors
|
|
450
|
+
)
|
|
451
|
+
or 'the query did not resolve'
|
|
452
|
+
)
|
|
453
|
+
raise StructuredDataError(
|
|
454
|
+
f'{self.text!r} is not a query {profile.capabilities.model} '
|
|
455
|
+
f'can answer: {_without_a_remedy_this_caller_cannot_run(reasons)}'
|
|
456
|
+
f'{self._why_the_target_is_text(reasons, profile)}'
|
|
457
|
+
f'{self._how_to_resolve_an_ambiguous_link(reasons)}',
|
|
458
|
+
code='INVALID_REQUEST',
|
|
459
|
+
)
|
|
460
|
+
return resolved
|
|
461
|
+
|
|
462
|
+
def _how_to_resolve_an_ambiguous_link(self, reasons: str) -> str:
|
|
463
|
+
r"""Say how to resolve an ambiguous link, in the words of this object.
|
|
464
|
+
|
|
465
|
+
The language advises in its own vocabulary: `schema.unlink(...)`,
|
|
466
|
+
which is a call on its `SchemaDefinition` and not on the `Schema` a
|
|
467
|
+
caller holds. So the one instruction that would fix their query names
|
|
468
|
+
something they cannot do.
|
|
469
|
+
|
|
470
|
+
The reasons are read only to recognise the failure. What the competing
|
|
471
|
+
columns are is taken from the schema, which knows, rather than parsed
|
|
472
|
+
back out of a sentence that was written to be read.
|
|
473
|
+
"""
|
|
474
|
+
if 'is not unique' not in reasons:
|
|
475
|
+
return ''
|
|
476
|
+
ways: dict[tuple[str, str], list[str]] = {}
|
|
477
|
+
for link in self.schema.relationships:
|
|
478
|
+
pair = (link.source_table, link.target_table)
|
|
479
|
+
ways.setdefault(pair, []).append(link.source_column)
|
|
480
|
+
ambiguous = {
|
|
481
|
+
pair: sorted(columns)
|
|
482
|
+
for pair, columns in ways.items()
|
|
483
|
+
if len(columns) > 1
|
|
484
|
+
}
|
|
485
|
+
if not ambiguous:
|
|
486
|
+
return ''
|
|
487
|
+
# Which pair the query crossed is in the refusal and nowhere else, so
|
|
488
|
+
# the tables are read from it, but only to choose among pairs the
|
|
489
|
+
# schema already knows about. Advising the first pair found sent a
|
|
490
|
+
# caller to edit a relationship their query never touched.
|
|
491
|
+
crossed = [
|
|
492
|
+
pair
|
|
493
|
+
for pair in ambiguous
|
|
494
|
+
if f'table {pair[0]} ' in reasons and f'table {pair[1]} ' in reasons
|
|
495
|
+
]
|
|
496
|
+
if len(ambiguous) > 1 and not crossed:
|
|
497
|
+
return ''
|
|
498
|
+
(source, target), columns = sorted(
|
|
499
|
+
(pair, ambiguous[pair]) for pair in (crossed or ambiguous)
|
|
500
|
+
)[0]
|
|
501
|
+
kept, *rest = columns
|
|
502
|
+
return (
|
|
503
|
+
f' In this schema {source!r} reaches {target!r} through '
|
|
504
|
+
f'{", ".join(repr(name) for name in columns)}; to keep {kept!r}, '
|
|
505
|
+
f'schema.relationships = [link for link in schema.relationships '
|
|
506
|
+
f'if not (link.source_table == {source!r} and link.target_table '
|
|
507
|
+
f'== {target!r} and link.source_column in {rest!r})].'
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
def _why_the_target_is_text(self, reasons: str, profile: Profile) -> str:
|
|
511
|
+
r"""Say why a target is `text`, when that is what refused the query.
|
|
512
|
+
|
|
513
|
+
Inference types a string column by how many distinct values it holds,
|
|
514
|
+
and past sixty-four it is `text` rather than `categorical`. That is a
|
|
515
|
+
reasonable rule for a feature and a poor one for a target: an ordinary
|
|
516
|
+
multiclass label with seventy classes is refused as unpredictable, and
|
|
517
|
+
the refusal names a semantic type the caller never chose. Worse, it
|
|
518
|
+
depends on how many rows were handed to `from_data`, so the same query
|
|
519
|
+
answers on a sample and stops answering on the full table.
|
|
520
|
+
|
|
521
|
+
Setting the meaning by hand fixes it, verified at seventy classes and
|
|
522
|
+
at two hundred, so the refusal now says so.
|
|
523
|
+
|
|
524
|
+
The column named is the target rather than whichever text column comes
|
|
525
|
+
first. A schema with a free-text feature and a many-class label has
|
|
526
|
+
two, and naming the wrong one offers a remedy that changes nothing
|
|
527
|
+
about the refusal.
|
|
528
|
+
"""
|
|
529
|
+
if 'semantic type text' not in reasons:
|
|
530
|
+
return ''
|
|
531
|
+
named = self._target_named_by_the_query()
|
|
532
|
+
if named is None:
|
|
533
|
+
return ''
|
|
534
|
+
table, name = named
|
|
535
|
+
column = self.schema[table].columns.get(name)
|
|
536
|
+
if column is None or column.stype != 'text' or column.stype_declared:
|
|
537
|
+
return ''
|
|
538
|
+
frame = getattr(self.schema[table], 'data', None)
|
|
539
|
+
distinct = frame[name].nunique() if frame is not None else None
|
|
540
|
+
holds = (
|
|
541
|
+
f'holds {distinct:,} distinct values'
|
|
542
|
+
if distinct is not None
|
|
543
|
+
else 'was read as prose'
|
|
544
|
+
)
|
|
545
|
+
said = (
|
|
546
|
+
f'. {table}.{name} {holds}, and a '
|
|
547
|
+
f'string column with more than {_MAX_CATEGORICAL} is typed text '
|
|
548
|
+
f'rather than categorical. That is fair for a feature and a poor '
|
|
549
|
+
f'one for a label'
|
|
550
|
+
)
|
|
551
|
+
ceiling = (
|
|
552
|
+
RELATIONAL_LIMITS['classes_max'] if profile == RELATIONAL else None
|
|
553
|
+
)
|
|
554
|
+
if ceiling is not None and distinct is not None and distinct > ceiling:
|
|
555
|
+
return (
|
|
556
|
+
f'{said}. Typing it categorical would not help here, though: '
|
|
557
|
+
f'{profile.capabilities.model} classifies at most {ceiling} '
|
|
558
|
+
f'classes and this column holds {distinct:,}, so the send '
|
|
559
|
+
f'would be refused. Predict a column with fewer classes, or '
|
|
560
|
+
f'group these into that many first'
|
|
561
|
+
)
|
|
562
|
+
return (
|
|
563
|
+
f'{said}. If it is a label, say so with '
|
|
564
|
+
f"schema[{table!r}][{name!r}].stype = 'categorical'"
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
def _target_named_by_the_query(self) -> tuple[str, str] | None:
|
|
568
|
+
r"""The table and column the query predicts, read from its text.
|
|
569
|
+
|
|
570
|
+
Read from the text rather than from a resolved query, because this runs
|
|
571
|
+
precisely when resolution failed.
|
|
572
|
+
"""
|
|
573
|
+
match = _PREDICTED_COLUMN.search(self.text)
|
|
574
|
+
if match is None:
|
|
575
|
+
return None
|
|
576
|
+
table, name = match.group(1), match.group(2)
|
|
577
|
+
if table not in self.schema:
|
|
578
|
+
return None
|
|
579
|
+
return table, name
|
|
580
|
+
|
|
581
|
+
def __repr__(self) -> str:
|
|
582
|
+
return f'PredictiveQuery({self.text!r}, {self.schema!r})'
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
#: `PREDICT <table>.<column>` is the plain column target, which is the only
|
|
586
|
+
#: shape a semantic type can be refused for.
|
|
587
|
+
_PREDICTED_COLUMN = re.compile(
|
|
588
|
+
r'PREDICT\s+([A-Za-z_]\w*)\.([A-Za-z_]\w*)\s', re.IGNORECASE
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
#: The English sentence inside a pydantic validation error, without the
|
|
592
|
+
#: `[type=..., input_value=...]` tail the library appends to it.
|
|
593
|
+
_VALIDATOR_SENTENCE = re.compile(r'Value error, (.+?)(?= \[type=)', re.DOTALL)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
_A_REMEDY_IN_THE_LANGUAGE_S_OWN_WORDS = re.compile(
|
|
597
|
+
r'\s*(?:Keep the one this query means and drop the rest, for example:'
|
|
598
|
+
r'|You can drop the extra foreign keys? with:)'
|
|
599
|
+
r'[^.]*?(?:schema|graph)\.unlink\([^)]*\)'
|
|
600
|
+
r'(?:\s*;\s*(?:schema|graph)\.unlink\([^)]*\))*\.?'
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _without_a_remedy_this_caller_cannot_run(reasons: str) -> str:
|
|
605
|
+
r"""The refusal without the repair written in the language's vocabulary.
|
|
606
|
+
|
|
607
|
+
The query language advises `schema.unlink(...)`, a call on its own
|
|
608
|
+
`SchemaDefinition` and not on the `Schema` a caller holds, and it chooses
|
|
609
|
+
which link to keep by position. This object restates both in terms the
|
|
610
|
+
caller can run, and two remedies that disagree about which link to keep
|
|
611
|
+
are worse than one: followed in order they keep the wrong one. So the one
|
|
612
|
+
that cannot be run is removed rather than printed beside it.
|
|
613
|
+
"""
|
|
614
|
+
return _A_REMEDY_IN_THE_LANGUAGE_S_OWN_WORDS.sub('', reasons)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _without_the_validator_trace(message: str) -> str:
|
|
618
|
+
r"""The sentence a refusal is making, without the trace around it.
|
|
619
|
+
|
|
620
|
+
A few window errors, including zero-width and inverted windows, are raised
|
|
621
|
+
by
|
|
622
|
+
the grammar's own models rather than by its parser, so they arrive as a
|
|
623
|
+
pydantic trace: an error count, the internal type that raised, the repr of
|
|
624
|
+
the arguments it was given, and a link to pydantic's documentation. The
|
|
625
|
+
English inside is as actionable as the parser's own row-and-column
|
|
626
|
+
messages; nothing around it is, and none of it is this package's to show.
|
|
627
|
+
|
|
628
|
+
A message written any other way is left alone, so a validator that stops
|
|
629
|
+
wrapping its errors this way loses the scaffolding rather than the text.
|
|
630
|
+
"""
|
|
631
|
+
said = _VALIDATOR_SENTENCE.findall(message)
|
|
632
|
+
return '; '.join(part.strip() for part in said) if said else message
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _is_hashable(value: Any) -> bool:
|
|
636
|
+
r"""Whether this value can be a dict key, asked by trying.
|
|
637
|
+
|
|
638
|
+
`isinstance(value, Hashable)` is structural: a class that defines
|
|
639
|
+
`__hash__` and raises from it passes, and the `TypeError` surfaces later
|
|
640
|
+
from inside pandas, which is the shape of failure this check exists to
|
|
641
|
+
prevent.
|
|
642
|
+
"""
|
|
643
|
+
try:
|
|
644
|
+
hash(value)
|
|
645
|
+
except TypeError:
|
|
646
|
+
return False
|
|
647
|
+
return True
|