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,331 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES.
2
+ # All rights reserved.
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ r"""The engine's graph over tables that stay in their source.
6
+
7
+ One place builds it, because two need it: `Schema.from_source` reads the
8
+ source's metadata through it, and the relational materializer samples through
9
+ it. Both must agree on which columns a table has and where it lives.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import warnings
15
+ from collections.abc import Iterator, Mapping, Sequence
16
+ from contextlib import contextmanager
17
+ from typing import TYPE_CHECKING, Any
18
+
19
+ from sdm_client.core.warnings import SDMWarning, caller_stacklevel
20
+ from sdm_client.errors import MissingExtraError, StructuredDataError
21
+
22
+ if TYPE_CHECKING:
23
+ from sdm_relational_engine.rfm import Graph
24
+
25
+
26
+ class EngineTables:
27
+ r"""Engine tables read once per source.
28
+
29
+ Each gather gets copies, because it writes keys, time columns and stypes
30
+ onto them. Time ranges are not kept, so a schema sees new rows.
31
+ """
32
+
33
+ def __init__(self) -> None:
34
+ self._tables: dict[str, Any] = {}
35
+
36
+ def keep(self, tables: Mapping[str, Any]) -> None:
37
+ r"""Keyed by the schema's source name; Databricks and Postgres qualify
38
+ the engine's.
39
+ """
40
+ for key, table in tables.items():
41
+ self._tables.setdefault(key, table)
42
+
43
+ def graph(
44
+ self, source: Any, tables: Mapping[str, tuple[str, Sequence[str]]]
45
+ ) -> Graph:
46
+ r"""A fresh graph over copies of ``tables`` in ``source``, without
47
+ edges: the schema decides those.
48
+
49
+ Args:
50
+ source: A ``Source``, or a ``SemanticModel`` kept in one.
51
+ tables: Schema name to (source name, columns kept). Only tables
52
+ not read yet are read.
53
+ """
54
+ from sdm_relational_engine.rfm import Graph
55
+
56
+ missing = [
57
+ source_name
58
+ for source_name, _ in tables.values()
59
+ if source_name not in self._tables
60
+ ]
61
+ if missing:
62
+ if is_semantic_model(source):
63
+ model = semantic_model_graph(source)
64
+ self.keep({name: model[name] for name in model.tables})
65
+ else:
66
+ read = engine_graph(
67
+ source,
68
+ {name: name for name in missing},
69
+ infer_metadata=False,
70
+ )
71
+ self.keep({name: read[name] for name in missing})
72
+ return Graph(
73
+ tables=[
74
+ _copy_for_one_gather(self._tables[source_name], name, columns)
75
+ for name, (source_name, columns) in tables.items()
76
+ ],
77
+ edges=[],
78
+ )
79
+
80
+
81
+ def _copy_for_one_gather(table: Any, name: str, columns: Sequence[str]) -> Any:
82
+ copied = table.copy(name)
83
+ copied.end_time_column = None
84
+ copied.time_column = None
85
+ copied.primary_key = None
86
+ for column in [c.name for c in copied.columns if c.name not in columns]:
87
+ copied.remove_column(column)
88
+ return copied
89
+
90
+
91
+ # The engine's dtypes a scalar wire column can carry, as the wire names them.
92
+ # Lists, binary and durations have no scalar wire form.
93
+ _WIRE_DTYPE = {
94
+ 'bool': 'bool',
95
+ 'int': 'int64',
96
+ 'byte': 'int32',
97
+ 'int16': 'int32',
98
+ 'int32': 'int32',
99
+ 'int64': 'int64',
100
+ 'float': 'float64',
101
+ 'float32': 'float32',
102
+ 'float64': 'float64',
103
+ 'string': 'string',
104
+ 'date': 'timestamp[us]',
105
+ 'time': 'timestamp[us]',
106
+ }
107
+
108
+
109
+ def wire_dtype(engine_dtype: Any) -> str | None:
110
+ r"""The wire dtype for an engine column, or ``None`` if it has none."""
111
+ return _WIRE_DTYPE.get(str(engine_dtype))
112
+
113
+
114
+ @contextmanager
115
+ def _schema_says_time_columns() -> Iterator[None]:
116
+ r"""Keep the engine from warning about a time column it kept unchecked:
117
+ the schema says that, with its own override, in one notice.
118
+ """
119
+ from sdm_relational_engine.rfm import TimeColumnWarning
120
+
121
+ with warnings.catch_warnings():
122
+ warnings.simplefilter('ignore', TimeColumnWarning)
123
+ yield
124
+
125
+
126
+ def engine_graph(
127
+ source: Any, tables: Mapping[str, str] | None, *, infer_metadata: bool
128
+ ) -> Graph:
129
+ r"""The engine's graph over ``tables`` in ``source``, without edges: the
130
+ schema decides those.
131
+
132
+ Args:
133
+ source: A ``Source``.
134
+ tables: Schema name to source name; ``None`` reads every table.
135
+ infer_metadata: Let the engine infer keys, time columns and stypes.
136
+ """
137
+ try:
138
+ from sdm_relational_engine import (
139
+ MetricViewAsTableError,
140
+ NoTablesFoundError,
141
+ TableNotFoundError,
142
+ )
143
+ except ImportError as error: # pragma: no cover - optional extra
144
+ raise MissingExtraError(
145
+ 'relational', 'structured-data-models-relational-engine'
146
+ ) from error
147
+
148
+ specs = (
149
+ None
150
+ if tables is None
151
+ else [
152
+ {'name': name, 'source_name': source_name}
153
+ for name, source_name in tables.items()
154
+ ]
155
+ )
156
+ try:
157
+ with _schema_says_time_columns():
158
+ return _read(source, specs, infer_metadata)
159
+ except NoTablesFoundError as error:
160
+ # The engine's own advice names its API, which cannot fill a namespace.
161
+ raise StructuredDataError(
162
+ f'{source!r} holds no table to describe', code='INVALID_REQUEST'
163
+ ) from error
164
+ except TableNotFoundError as error:
165
+ raise StructuredDataError(str(error), code='INVALID_REQUEST') from error
166
+ except MetricViewAsTableError as error:
167
+ # The engine's advice names its own API; here a source reads the view.
168
+ raise StructuredDataError(
169
+ f'{error.name!r} is a metric view, not a table: its measures are '
170
+ f'aggregations with no value per row. Read it as a semantic model '
171
+ f'instead, with source.semantic_model({error.name!r}).',
172
+ code='INVALID_REQUEST',
173
+ details={'metric_view': error.name},
174
+ ) from error
175
+
176
+
177
+ def _read(
178
+ source: Any, specs: list[dict[str, Any]] | None, infer_metadata: bool
179
+ ) -> Graph:
180
+ from sdm_relational_engine.rfm import Graph
181
+
182
+ backend = source.backend
183
+ namespace = source.namespace
184
+ connection = source.connection
185
+ if backend == 'databricks':
186
+ return Graph.from_databricks(
187
+ connection,
188
+ tables=specs,
189
+ catalog=namespace.get('catalog'),
190
+ schema=namespace.get('schema'),
191
+ edges=[],
192
+ infer_metadata=infer_metadata,
193
+ verbose=False,
194
+ )
195
+ if backend == 'snowflake':
196
+ return Graph.from_snowflake(
197
+ connection,
198
+ tables=specs,
199
+ database=namespace.get('database'),
200
+ schema=namespace.get('schema'),
201
+ edges=[],
202
+ infer_metadata=infer_metadata,
203
+ verbose=False,
204
+ )
205
+ if backend == 'postgres':
206
+ return Graph.from_postgres(
207
+ connection,
208
+ tables=specs,
209
+ schema=namespace.get('schema'),
210
+ edges=[],
211
+ infer_metadata=infer_metadata,
212
+ verbose=False,
213
+ )
214
+ if backend == 'duckdb':
215
+ return Graph.from_duckdb(
216
+ connection,
217
+ tables=specs,
218
+ edges=[],
219
+ infer_metadata=infer_metadata,
220
+ verbose=False,
221
+ )
222
+ if backend == 'sqlite':
223
+ return Graph.from_sqlite(
224
+ connection,
225
+ tables=specs,
226
+ edges=[],
227
+ infer_metadata=infer_metadata,
228
+ verbose=False,
229
+ )
230
+ raise StructuredDataError( # pragma: no cover - Source refuses others
231
+ f'no engine backend samples a {backend!r} source',
232
+ code='INVALID_REQUEST',
233
+ )
234
+
235
+
236
+ def is_semantic_model(source: Any) -> bool:
237
+ r"""Whether ``source`` is a semantic model rather than a namespace."""
238
+ try:
239
+ from sdm_connectors import SemanticModel
240
+ except ImportError: # pragma: no cover - nothing could have built one
241
+ return False
242
+ return isinstance(source, SemanticModel)
243
+
244
+
245
+ def semantic_model_graph(model: Any) -> Graph:
246
+ r"""The engine's graph of a semantic model: a Databricks metric view or a
247
+ Snowflake semantic view.
248
+
249
+ Named after the model's own tables, with its links, keys and time columns
250
+ as the model declares them. Discovery and gathering both read it here, so
251
+ they keep the same columns.
252
+
253
+ What the model has that a graph cannot hold is said in one
254
+ :class:`SDMWarning`, and nothing is said for a model that loses nothing:
255
+ how it was read is in the graph's ``conversion_notes``, which the schema
256
+ renders as text. A model the
257
+ engine refuses, for a filter on a joined table say, is refused with a
258
+ :class:`StructuredDataError` that carries the engine's reason.
259
+ """
260
+ try:
261
+ from sdm_relational_engine import (
262
+ TableNotFoundError,
263
+ ViewConversionError,
264
+ )
265
+ from sdm_relational_engine.rfm import Graph
266
+ except ImportError as error: # pragma: no cover - optional extra
267
+ raise MissingExtraError(
268
+ 'relational', 'structured-data-models-relational-engine'
269
+ ) from error
270
+
271
+ try:
272
+ # The engine is told not to warn, rather than its warnings filtered,
273
+ # since a filter holds for every thread: what it would say is said
274
+ # below, and by the schema, to the caller.
275
+ graph = _model_graph(Graph, model)
276
+ except (ViewConversionError, TableNotFoundError) as error:
277
+ # The engine's refusals of the model, each saying what to change in
278
+ # it, and a table it reads that is not there, as for a source's own
279
+ # tables. Any other failure is not the model's, and is not one.
280
+ raise StructuredDataError(
281
+ str(error),
282
+ code='INVALID_REQUEST',
283
+ details={'semantic_model': model.name},
284
+ ) from error
285
+ if graph.conversion_messages:
286
+ warnings.warn(
287
+ f'{model!r} has what a graph cannot hold, so it is read in '
288
+ f'part:\n'
289
+ + '\n'.join(
290
+ f'- {message}' for message in graph.conversion_messages
291
+ ),
292
+ category=SDMWarning,
293
+ stacklevel=caller_stacklevel(),
294
+ )
295
+ return graph
296
+
297
+
298
+ def _model_graph(graph_type: type[Graph], model: Any) -> Graph:
299
+ namespace = model.namespace
300
+ if model.backend == 'databricks':
301
+ return graph_type.from_databricks_metric_view(
302
+ model.name,
303
+ model.connection,
304
+ catalog=namespace.get('catalog'),
305
+ schema=namespace.get('schema'),
306
+ verbose=False,
307
+ trust_expressions=model.trust_expressions,
308
+ warn=False,
309
+ )
310
+ if model.backend == 'snowflake':
311
+ # The engine resolves an unqualified name against the connection's
312
+ # current database and schema, so the source's own are written in.
313
+ parts = [
314
+ namespace[part]
315
+ for part in ('database', 'schema')
316
+ if part in namespace
317
+ ]
318
+ name = (
319
+ model.name if '.' in model.name else '.'.join([*parts, model.name])
320
+ )
321
+ return graph_type.from_snowflake_semantic_view(
322
+ name,
323
+ model.connection,
324
+ verbose=False,
325
+ trust_expressions=model.trust_expressions,
326
+ warn=False,
327
+ )
328
+ raise StructuredDataError( # pragma: no cover - SemanticModel refuses
329
+ f'no semantic model is kept in a {model.backend!r} source',
330
+ code='INVALID_REQUEST',
331
+ )
@@ -0,0 +1,339 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES.
2
+ # All rights reserved.
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ r"""Build single-table requests from rows supplied by the caller."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import numbers
10
+ import warnings
11
+ from collections.abc import Hashable, Sequence
12
+ from decimal import Decimal
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ import pandas as pd
16
+ from predictive_query import Stype
17
+ from predictive_query.validator.utils import col_name, table_name
18
+
19
+ from sdm_client.core.warnings import SDMWarning, caller_stacklevel
20
+ from sdm_client.errors import StructuredDataError
21
+ from sdm_client.models import TABULAR_LIMITS
22
+ from sdm_client.request import Request
23
+ from sdm_client.schema import Schema, SourceTable, Table
24
+
25
+ if TYPE_CHECKING:
26
+ from predictive_query import ValidatedPredictiveQuery
27
+
28
+ _CONTEXT_SEED = 42
29
+ _CONTEXT_ROWS_MAX = TABULAR_LIMITS['context_rows_max']
30
+
31
+
32
+ TABULAR_TASK_KINDS = frozenset({'classification', 'regression'})
33
+
34
+
35
+ def _is_whole(value: object) -> bool:
36
+ r"""Whether ``value`` is a number with nothing after the decimal point."""
37
+ if isinstance(value, bool):
38
+ return True
39
+ if isinstance(value, numbers.Integral):
40
+ return True
41
+ if isinstance(value, Decimal):
42
+ return value.is_finite() and value == value.to_integral_value()
43
+ if isinstance(value, numbers.Real):
44
+ try:
45
+ return float(value).is_integer()
46
+ except (OverflowError, TypeError, ValueError):
47
+ return False
48
+ return False
49
+
50
+
51
+ def _derive_task_kind(
52
+ query: ValidatedPredictiveQuery, target: pd.Series
53
+ ) -> str:
54
+ r"""The task the target implies, when the caller named none."""
55
+ if query.target_stype == Stype.categorical:
56
+ return 'classification'
57
+ if _holds_two_whole_numbers(target):
58
+ warnings.warn(
59
+ f'{query.target_column} holds two whole numbers, which is how '
60
+ f'a binary label is usually written, so this is being asked as '
61
+ f'a classification. If they are quantities rather than labels, '
62
+ f'pass task="regression" to ask for one; pass '
63
+ f'task="classification" to say this was meant and silence '
64
+ f'this.',
65
+ category=SDMWarning,
66
+ stacklevel=caller_stacklevel(),
67
+ )
68
+ return 'classification'
69
+ return 'regression'
70
+
71
+
72
+ def _holds_two_whole_numbers(target: pd.Series) -> bool:
73
+ r"""Whether ``target`` is two distinct whole numbers and nothing else.
74
+
75
+ The values decide, not their storage: a 0/1 column with a null arrives as
76
+ ``float64`` and is still a label. Anything wider or fractional is left to
77
+ ``task=``.
78
+ """
79
+ values = target.dropna().unique()
80
+ if len(values) != 2:
81
+ return False
82
+ return all(_is_whole(value) for value in values)
83
+
84
+
85
+ def _asking(request: Request, query: Any) -> Request:
86
+ """Record the question a request was built from.
87
+
88
+ A session pins one question and only the rows travel on a scoring call, so
89
+ the question is what tells one request from another. The relational route
90
+ records it when it gathers; this route builds the request directly and did
91
+ not, which left a tabular session unable to tell that it had been handed
92
+ something else.
93
+ """
94
+ request.query = query.to_string()
95
+ return request
96
+
97
+
98
+ class TabularMaterializer:
99
+ r"""Materialize resolved PQL against a single-table schema.
100
+
101
+ The ``TABULAR`` profile has already refused anything this cannot honour
102
+ (a filtered entity, an ``ASSUMING`` clause, a windowed or derived
103
+ target),
104
+ so the target here is a real column of the one table.
105
+ """
106
+
107
+ def __init__(self, schema: Schema) -> None:
108
+ self.schema = schema
109
+
110
+ def table(
111
+ self,
112
+ *,
113
+ query: ValidatedPredictiveQuery,
114
+ predict: pd.DataFrame,
115
+ task: str | None = None,
116
+ ) -> Request:
117
+ r"""Build a request from rows supplied separately by the caller.
118
+
119
+ The context is the table minus the rows being scored and minus the
120
+ rows with nothing to teach. The other route already left out both;
121
+ this one left out neither, so the same table asked the same question
122
+ two ways built two different contexts.
123
+ """
124
+ entity_table_name = table_name(query.entity_column)
125
+ entity_col = col_name(query.entity_column)
126
+ target_col = col_name(query.target_column)
127
+
128
+ context = _context_within_limit(
129
+ _rows_that_carry_an_answer(
130
+ _without_the_rows_being_scored(
131
+ _rows(self.schema[entity_table_name]), predict, entity_col
132
+ ),
133
+ target_col,
134
+ ),
135
+ None,
136
+ entity_table_name,
137
+ 'This route takes no num_context_samples, so choose the '
138
+ 'context by building the schema from the rows you want.',
139
+ )
140
+
141
+ return _asking(
142
+ Request(
143
+ context=context,
144
+ predict=predict,
145
+ target=target_col,
146
+ task=self._task_kind(query, context[target_col], task),
147
+ schema=self.schema.snapshot(),
148
+ entity_table=entity_table_name,
149
+ ),
150
+ query,
151
+ )
152
+
153
+ def indices(
154
+ self,
155
+ query: ValidatedPredictiveQuery,
156
+ indices: Sequence[Any],
157
+ num_context_samples: int | None,
158
+ task: str | None = None,
159
+ ) -> Request:
160
+ r"""Build a single-table request for rows in the table."""
161
+ entity_table_name = table_name(query.entity_column)
162
+ entity_table = _rows(self.schema[entity_table_name])
163
+ entity_col = col_name(query.entity_column)
164
+ target_col = col_name(query.target_column)
165
+
166
+ indices = list(indices)
167
+ positions: dict[Hashable, list[int]] = {}
168
+ for position, value in enumerate(entity_table[entity_col]):
169
+ positions.setdefault(value, []).append(position)
170
+
171
+ missing = [value for value in indices if value not in positions]
172
+ if missing:
173
+ shown = missing[:10]
174
+ len_missing = len(missing)
175
+ tail = '' if len_missing == len(shown) else ', ...'
176
+ raise StructuredDataError(
177
+ f'{len_missing} of the {len(indices)} indices are not in '
178
+ f'{query.entity_column}: {shown}{tail}. Pass predict=<frame> '
179
+ f'to score rows not present in the table',
180
+ code='INVALID_REQUEST',
181
+ )
182
+
183
+ scored = [
184
+ position for value in indices for position in positions[value]
185
+ ]
186
+ predict = entity_table.iloc[scored].drop(columns=[target_col])
187
+ context_mask = entity_table[target_col].notna()
188
+ context_mask.iloc[scored] = False
189
+ candidates = entity_table[context_mask]
190
+
191
+ context = _context_within_limit(
192
+ candidates,
193
+ num_context_samples,
194
+ entity_table_name,
195
+ 'Pass num_context_samples to choose how many, or select the rows '
196
+ 'yourself.',
197
+ )
198
+
199
+ return _asking(
200
+ Request(
201
+ context=context,
202
+ predict=predict,
203
+ target=target_col,
204
+ task=self._task_kind(query, context[target_col], task),
205
+ schema=self.schema.snapshot(),
206
+ entity_table=entity_table_name,
207
+ ),
208
+ query,
209
+ )
210
+
211
+ @staticmethod
212
+ def _task_kind(
213
+ query: ValidatedPredictiveQuery,
214
+ target: pd.Series,
215
+ stated: str | None,
216
+ ) -> str:
217
+ r"""What is being asked of the target, and who decided it.
218
+
219
+ ``target`` is the context that will be sent, not the column it came
220
+ from; the two differ, and the narrower one is what the model learns
221
+ from.
222
+ """
223
+ if query.target_stype not in {Stype.categorical, Stype.numerical}:
224
+ raise StructuredDataError(
225
+ f'tabular materialization requires a categorical or '
226
+ f'numerical target; {query.target_column!r} has semantic '
227
+ f'type {query.target_stype.value!r}',
228
+ code='UNSUPPORTED_REQUEST',
229
+ )
230
+ if stated is not None and (
231
+ not isinstance(stated, str) or stated not in TABULAR_TASK_KINDS
232
+ ):
233
+ raise StructuredDataError(
234
+ f'the tabular model does not serve the task kind '
235
+ f'{stated!r}; it serves {sorted(TABULAR_TASK_KINDS)}',
236
+ code='UNSUPPORTED_REQUEST',
237
+ details={
238
+ 'task': stated,
239
+ 'supported': sorted(TABULAR_TASK_KINDS),
240
+ },
241
+ )
242
+
243
+ return (
244
+ stated if stated is not None else _derive_task_kind(query, target)
245
+ )
246
+
247
+
248
+ def _context_within_limit(
249
+ candidates: pd.DataFrame,
250
+ num_context_samples: int | None,
251
+ table: str,
252
+ remedy: str,
253
+ ) -> pd.DataFrame:
254
+ r"""The context rows, never more than the endpoint will take.
255
+
256
+ The endpoint refuses a context above its limit, and refuses it *after* the
257
+ whole thing has been encoded into JSON and transmitted. A table of
258
+ hundreds of thousands of rows sent in full to be told the limit is ten
259
+ thousand. The relational side has declared its limits and refused locally
260
+ all along; this is the same thing on the other model.
261
+
262
+ A number the caller named above the limit is an error, because they asked
263
+ for something that cannot happen and quietly giving them less would answer
264
+ a different question. Saying nothing is a choice about how much data the
265
+ model sees, so it is made here and said out loud.
266
+ """
267
+ if num_context_samples is not None:
268
+ if num_context_samples > _CONTEXT_ROWS_MAX:
269
+ raise StructuredDataError(
270
+ f'num_context_samples is {num_context_samples:,}, and the '
271
+ f'endpoint accepts at most {_CONTEXT_ROWS_MAX:,} context '
272
+ f'rows',
273
+ code='UNSUPPORTED_REQUEST',
274
+ )
275
+ wanted = num_context_samples
276
+ else:
277
+ wanted = _CONTEXT_ROWS_MAX
278
+ if len(candidates) <= wanted:
279
+ return candidates
280
+ if num_context_samples is None:
281
+ warnings.warn(
282
+ f'{table} has {len(candidates):,} rows that could be context and '
283
+ f'the endpoint accepts {_CONTEXT_ROWS_MAX:,}, so {wanted:,} were '
284
+ f'sampled. {remedy}',
285
+ category=SDMWarning,
286
+ stacklevel=caller_stacklevel(),
287
+ )
288
+ return candidates.sample(n=wanted, random_state=_CONTEXT_SEED).sort_index()
289
+
290
+
291
+ def _rows_that_carry_an_answer(
292
+ frame: pd.DataFrame, target: str
293
+ ) -> pd.DataFrame:
294
+ r"""The rows that can teach the model something.
295
+
296
+ A row whose target is null is not an example of anything. It takes up
297
+ room in a context the endpoint caps, and says nothing in exchange.
298
+ """
299
+ if target not in frame:
300
+ return frame
301
+ return frame[frame[target].notna()]
302
+
303
+
304
+ def _without_the_rows_being_scored(
305
+ table: pd.DataFrame, predict: pd.DataFrame, entity_col: str
306
+ ) -> pd.DataFrame:
307
+ r"""The table, minus any row the caller is about to ask about.
308
+
309
+ The `indices=` route does this and this one did not, so the two
310
+ single-table routes disagreed about whether a row may be both scored and
311
+ shown. Rows supplied separately are often rows the table already holds,
312
+ which is how anyone checks a model against answers they have. Leaving
313
+ them in the context hands the model those answers, labelled, and returns
314
+ what reads like accuracy.
315
+
316
+ Rows the table does not hold are what the route is for, and they simply
317
+ match nothing here.
318
+ """
319
+ if entity_col not in table.columns or entity_col not in predict.columns:
320
+ return table
321
+ scored = set(predict[entity_col])
322
+ if not scored:
323
+ return table
324
+ return table[~table[entity_col].isin(scored)]
325
+
326
+
327
+ def _rows(table: Table | SourceTable) -> pd.DataFrame:
328
+ r"""The rows of a table the tabular model reads, which it holds in memory.
329
+
330
+ `PredictiveQuery.materialize` sends a schema over tables in their source to
331
+ the relational model, so only a hand-built route reaches here with one.
332
+ """
333
+ if isinstance(table, SourceTable):
334
+ raise StructuredDataError(
335
+ f'{table.name!r} stays in its source, and the tabular model reads '
336
+ f'rows in memory; build the schema with Schema.from_data',
337
+ code='INVALID_REQUEST',
338
+ )
339
+ return table.data