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,1786 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES.
2
+ # All rights reserved.
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ r"""Gather and encode relational requests with the relational engine."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import copy
10
+ import math
11
+ import warnings
12
+ from collections.abc import Iterator, Mapping, Sequence
13
+ from contextlib import contextmanager
14
+ from numbers import Integral
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ import pandas as pd
18
+ from predictive_query.ast import Filter
19
+ from predictive_query.validator.model_validator import (
20
+ MAX_TOP_K as _MAX_TOP_K,
21
+ )
22
+ from predictive_query.validator.utils import table_name
23
+
24
+ from sdm_client.core import dates
25
+ from sdm_client.core.warnings import SDMWarning, caller_stacklevel
26
+ from sdm_client.errors import MissingExtraError, StructuredDataError
27
+ from sdm_client.materializers.sources import EngineTables
28
+ from sdm_client.materializers.tabular import _holds_two_whole_numbers
29
+ from sdm_client.models import (
30
+ KUMO_RELATIONAL,
31
+ PREDICTION_STATISTICS,
32
+ RELATIONAL_LIMITS,
33
+ SUMMARISED_TASK_KINDS,
34
+ )
35
+ from sdm_client.request import Request
36
+ from sdm_client.schema import (
37
+ Relationship,
38
+ Schema,
39
+ SourceTable,
40
+ Table,
41
+ _and_list,
42
+ _as_key_tuple,
43
+ )
44
+ from sdm_client.wire.dtypes import arrow_decimal_type
45
+
46
+ if TYPE_CHECKING:
47
+ from predictive_query import ValidatedPredictiveQuery
48
+ from sdm_relational_engine.rfm import (
49
+ Graph,
50
+ KumoRelational,
51
+ MaterializedPredictionRequest,
52
+ TaskTable,
53
+ )
54
+
55
+
56
+ def _warn_of_a_context_the_default_mode_capped(
57
+ task: TaskTable, num_context_samples: int | None
58
+ ) -> None:
59
+ r"""Say when the default gathered as many examples as it may, not as many
60
+ as there are.
61
+
62
+ How much of the data the model sees is a choice, and the tabular route
63
+ already says so when it makes one. Here the choice is made by the run mode
64
+ a missing ``num_context_samples`` selects, which is quieter but no less a
65
+ choice.
66
+ """
67
+ if num_context_samples is not None:
68
+ return
69
+ cap = RELATIONAL_LIMITS['context_rows_fast']
70
+ if getattr(task, 'num_context_examples', 0) < cap:
71
+ return
72
+ warnings.warn(
73
+ f'{cap:,} context examples were gathered, which is all this route '
74
+ f'takes by default; pass num_context_samples to ask for up to '
75
+ f'{RELATIONAL_LIMITS["context_rows_max"]:,}.',
76
+ category=SDMWarning,
77
+ stacklevel=caller_stacklevel(),
78
+ )
79
+
80
+
81
+ #: More entities than this in one `materialize()` and the request is gathered
82
+ #: in pieces that no longer fit in memory together. Every batch is built
83
+ #: before any is sent, each carrying its own context.
84
+ MOST_ENTITIES = 100_000
85
+
86
+ #: And more calls than this, which is the other way to hold too much: a
87
+ #: context is gathered for every call, so a `rows_per_call` of 1 would hold
88
+ #: one per entity. Loose enough that lowering the batch size for a wide graph
89
+ #: is unaffected, and only a caller who set it very low meets it.
90
+ MOST_CALLS = 100
91
+
92
+
93
+ def _rows_per_call(
94
+ kind: object, limits: Mapping[str, int] = RELATIONAL_LIMITS
95
+ ) -> int:
96
+ r"""How many entities the endpoint scores in one call of a task ``kind``."""
97
+ if str(kind) == 'temporal_link_prediction':
98
+ return limits['predict_rows_temporal_link_prediction']
99
+ return limits['predict_rows_max']
100
+
101
+
102
+ def rows_one_call_scores(
103
+ asked: Any = None,
104
+ kind: object = None,
105
+ limits: Mapping[str, int] = RELATIONAL_LIMITS,
106
+ ) -> int:
107
+ r"""How many entities one call will score, as the caller asked or as the
108
+ endpoint allows.
109
+
110
+ ``rows_per_call`` only ever goes down. The endpoint refuses a call past
111
+ its own cap, so a larger number would buy a 422 rather than a bigger call;
112
+ a smaller one is how a graph too wide for a full batch, or a deployment
113
+ holding to less than this client knows of, is asked in pieces that fit.
114
+ """
115
+ cap = _rows_per_call(kind, limits)
116
+ if asked is None:
117
+ return cap
118
+ return min(int(asked), cap)
119
+
120
+
121
+ def rows_per_call_asked_for(options: Mapping[str, Any]) -> int | None:
122
+ r"""The batch size the caller chose, refused if it is not a size.
123
+
124
+ ``None`` is not choosing one. Every gathering option travels as a keyword
125
+ and a caller passing one through reads an unset value as absent, so a
126
+ question asked in parts, which drops them before passing them on, and one
127
+ asked whole have to agree about that.
128
+ """
129
+ given = options.get('rows_per_call')
130
+ if given is None:
131
+ return None
132
+ if (
133
+ not isinstance(given, Integral)
134
+ or isinstance(given, bool)
135
+ or int(given) < 1
136
+ ):
137
+ raise StructuredDataError(
138
+ f'rows_per_call is {given!r}, and a call scores a whole number of '
139
+ f'rows, at least one',
140
+ code='INVALID_REQUEST',
141
+ )
142
+ return int(given)
143
+
144
+
145
+ def _the_largest_batch(task: TaskTable, per_call: int) -> int:
146
+ r"""How many rows the largest batch of this request actually carries.
147
+
148
+ Not the size asked for: a request for one entity is one call of one row
149
+ whatever the batch size allows, and telling its caller to ask for smaller
150
+ calls names a remedy that has nothing left to divide.
151
+ """
152
+ asked = getattr(task, 'num_prediction_examples', None)
153
+ if not isinstance(asked, Integral) or int(asked) < 1:
154
+ return per_call
155
+ return min(per_call, int(asked))
156
+
157
+
158
+ def refuse_more_entities_than_fit_in_memory(asked: int, held: int = 1) -> None:
159
+ r"""Refuse a population too large to hold, from the count alone.
160
+
161
+ Checked before the engine is reached, because reaching it means building
162
+ the graph and computing a label for every index, which for a population
163
+ far past this ceiling is minutes of work and the memory that goes with it
164
+ before anything says no.
165
+
166
+ ``held`` is how many times over the population is gathered: one for an
167
+ ordinary question, and one per part for a question asked in parts, each
168
+ of which gathers the whole population with a context of its own.
169
+ """
170
+ total = asked * held
171
+ if total <= MOST_ENTITIES:
172
+ return
173
+ gathered = (
174
+ f'{asked:,} entities'
175
+ if held == 1
176
+ else f'{asked:,} entities gathered {held:,} times over, {total:,} in all,'
177
+ )
178
+ raise StructuredDataError(
179
+ f'{gathered} is past the {MOST_ENTITIES:,} one materialize() will '
180
+ f'hold at once, since every batch is built, with its own context, '
181
+ f'before any is sent; ask about fewer entities at a time',
182
+ code='UNSUPPORTED_REQUEST',
183
+ details={'entities': asked, 'gathered_times': held, 'total': total},
184
+ )
185
+
186
+
187
+ def _refuse_more_entities_than_fit_in_memory(
188
+ task: TaskTable, per_call: int
189
+ ) -> None:
190
+ r"""Refuse a population too large to hold as gathered batches.
191
+
192
+ The engine builds every batch before the first is sent, so the cost of a
193
+ population is paid in memory up front. This is the ceiling that the old
194
+ per-call entity check used to supply as a side effect of refusing to batch
195
+ at all.
196
+ """
197
+ asked = getattr(task, 'num_prediction_examples', None)
198
+ if not isinstance(asked, Integral) or per_call <= 0:
199
+ return
200
+ asked = int(asked)
201
+ calls = math.ceil(asked / per_call)
202
+ met = [
203
+ said
204
+ for said, over in (
205
+ (f'{MOST_ENTITIES:,} entities', asked > MOST_ENTITIES),
206
+ (f'{MOST_CALLS:,} calls', calls > MOST_CALLS),
207
+ )
208
+ if over
209
+ ]
210
+ if not met:
211
+ return
212
+ raise StructuredDataError(
213
+ f'{asked:,} entities are gathered as {calls:,} calls of '
214
+ f'{per_call:,}, and every one is built, with its own context, before '
215
+ f'any is sent. That is past the {" and ".join(met)} this will hold at '
216
+ f'once; ask about fewer entities at a time',
217
+ code='UNSUPPORTED_REQUEST',
218
+ details={
219
+ 'entities': asked,
220
+ 'calls': calls,
221
+ 'rows_per_call': per_call,
222
+ },
223
+ )
224
+
225
+
226
+ class RelationalMaterializer:
227
+ r"""Materialize resolved PQL against a relational schema."""
228
+
229
+ _MATERIALIZE_OPTIONS = frozenset(
230
+ {
231
+ 'anchor_time',
232
+ 'context_anchor_time',
233
+ 'lag_timesteps',
234
+ 'max_pq_iterations',
235
+ 'num_hops',
236
+ 'num_neighbors',
237
+ 'random_seed',
238
+ 'use_prediction_time',
239
+ 'inference_config',
240
+ 'exclude_cols_dict',
241
+ 'return_embeddings',
242
+ 'top_k',
243
+ 'prediction_statistic',
244
+ 'rows_per_call',
245
+ }
246
+ )
247
+
248
+ def __init__(self, schema: Schema) -> None:
249
+ self.schema = schema
250
+ self._model: KumoRelational | None = None
251
+
252
+ _RESERVED = ('instance_id', '__kumo_anchor_time')
253
+
254
+ def materialize(
255
+ self,
256
+ *,
257
+ query: ValidatedPredictiveQuery,
258
+ indices: Sequence[Any],
259
+ num_context_samples: int | None,
260
+ options: dict[str, Any],
261
+ ) -> Request:
262
+ r"""Gather the selected entities and return their encoded request."""
263
+ unknown = sorted(set(options) - self._MATERIALIZE_OPTIONS)
264
+ if unknown:
265
+ raise StructuredDataError(
266
+ f'unknown materialize option(s) {unknown}; accepted: '
267
+ f'{sorted(self._MATERIALIZE_OPTIONS)}',
268
+ code='INVALID_REQUEST',
269
+ )
270
+ if 'run_mode' in options: # pragma: no cover - caught by the above
271
+ raise StructuredDataError(
272
+ 'run_mode is not a knob here; set num_context_samples and '
273
+ 'the client derives what the endpoint needs',
274
+ code='INVALID_REQUEST',
275
+ )
276
+
277
+ if len(indices) == 0:
278
+ raise StructuredDataError(
279
+ 'indices names no entity, and gathering a relational request '
280
+ 'means sampling a neighbourhood around each one, so there is '
281
+ 'nothing here to gather. An empty batch is still answered: '
282
+ 'build it as Request(predict=<no rows>) and predict() returns '
283
+ 'the empty result without sending',
284
+ code='INVALID_REQUEST',
285
+ )
286
+
287
+ statistic = _statistic_asked_for(options)
288
+ rows_per_call_asked_for(options)
289
+ self._refuse_a_name_the_wire_reserves()
290
+ self._warn_two_whole_numbers_are_being_asked_as_quantities(query)
291
+ _warn_the_filter_chose_only_the_examples(query, indices)
292
+ _warn_of_relationships_that_join_nothing(self.schema)
293
+ payloads = self._gather(query, indices, num_context_samples, options)
294
+ if statistic is not None:
295
+ _record_the_statistic(payloads, statistic)
296
+ sent = _tables_sent(payloads, self.schema)
297
+ _warn_of_tables_left_out(
298
+ self.schema, sent, table_name(query.entity_column)
299
+ )
300
+ _warn_of_columns_the_model_does_not_read(
301
+ self.schema, _columns_to_leave_out(query, options), sent
302
+ )
303
+ windowed = (
304
+ getattr(query.target_ast, 'date_offset_range', None) is not None
305
+ )
306
+ _drop_context_that_answers_the_question(payloads, windowed=windowed)
307
+ return Request.materialized(
308
+ payloads=payloads,
309
+ windowed=windowed,
310
+ schema=self.schema.snapshot(),
311
+ query=query.to_string(),
312
+ indices=list(indices),
313
+ entity_table=table_name(query.entity_column),
314
+ # A copy: the payloads were gathered from the config as it is
315
+ # now, and a caller's later edit to it must not change what the
316
+ # adapter believes was asked for.
317
+ requested_inference={
318
+ 'inference_config': copy.deepcopy(
319
+ options.get('inference_config') or {}
320
+ )
321
+ },
322
+ )
323
+
324
+ def _gather(
325
+ self,
326
+ query: ValidatedPredictiveQuery,
327
+ indices: Sequence[Any],
328
+ num_context_samples: int | None,
329
+ options: dict[str, Any],
330
+ ) -> tuple[dict[str, Any], ...]:
331
+ r"""Sample and encode using the engine's own materializer.
332
+
333
+ More entities than one call scores are gathered as several payloads
334
+ of at most that many, which `predict()` sends one at a time and
335
+ answers as one result. One call scores what the endpoint takes, or
336
+ the smaller ``rows_per_call`` the caller asked for.
337
+ """
338
+ run_mode = self._run_mode_for(num_context_samples)
339
+ refuse_more_entities_than_fit_in_memory(len(indices))
340
+ self._check_shape(options, RELATIONAL_LIMITS)
341
+ with self._engine_errors():
342
+ model = self._get_model()
343
+ _warn_rows_the_graph_dropped(model)
344
+
345
+ with self._engine_errors(model):
346
+ task = model._get_task_table(
347
+ query=query,
348
+ indices=list(indices),
349
+ run_mode=run_mode,
350
+ **{
351
+ key: options[key]
352
+ for key in (
353
+ 'anchor_time',
354
+ 'context_anchor_time',
355
+ 'lag_timesteps',
356
+ 'max_pq_iterations',
357
+ 'random_seed',
358
+ )
359
+ if key in options
360
+ },
361
+ )
362
+ task._query = query.to_string()
363
+ if not hasattr(task, 'task_type'):
364
+ raise StructuredDataError(
365
+ 'the resolved task does not say what kind it is, so the '
366
+ 'entities it scores per call cannot be known',
367
+ code='INTERNAL_ERROR',
368
+ )
369
+ _warn_of_a_context_the_default_mode_capped(task, num_context_samples)
370
+ task = self._narrow_context(task, num_context_samples)
371
+
372
+ per_call = rows_one_call_scores(
373
+ rows_per_call_asked_for(options), task.task_type
374
+ )
375
+ _refuse_more_entities_than_fit_in_memory(task, per_call)
376
+ largest = _the_largest_batch(task, per_call)
377
+ with (
378
+ self._engine_errors(model, largest),
379
+ model.batch_mode(batch_size=per_call),
380
+ ):
381
+ parts = model.materialize_task(
382
+ task,
383
+ run_mode=run_mode,
384
+ verbose=False,
385
+ top_k=_candidates_to_rank(query, options),
386
+ exclude_cols_dict=_columns_to_leave_out(query, options),
387
+ **{
388
+ key: options[key]
389
+ for key in (
390
+ 'num_neighbors',
391
+ 'num_hops',
392
+ 'inference_config',
393
+ 'use_prediction_time',
394
+ 'return_embeddings',
395
+ 'random_seed',
396
+ )
397
+ if key in options
398
+ },
399
+ )
400
+ if not parts:
401
+ raise StructuredDataError(
402
+ 'the engine gathered nothing for this query',
403
+ code='INVALID_REQUEST',
404
+ )
405
+ for part in parts:
406
+ self._check_limits(part, RELATIONAL_LIMITS)
407
+ return tuple(part.payload for part in parts)
408
+
409
+ def _get_model(self) -> KumoRelational:
410
+ if self._model is None:
411
+ try:
412
+ from sdm_relational_engine.rfm import KumoRelational
413
+ except ImportError as error: # pragma: no cover - optional extra
414
+ raise MissingExtraError(
415
+ 'relational', 'structured-data-models-relational-engine'
416
+ ) from error
417
+
418
+ self._model = KumoRelational(self._build_graph(), verbose=False)
419
+ return self._model
420
+
421
+ def _refuse_a_name_the_wire_reserves(self) -> None:
422
+ """Refuse a column whose name the request itself needs.
423
+
424
+ The wire adds its own columns to every table it sends, and a caller
425
+ column of the same name collided with them inside pandas, which said
426
+ `cannot insert instance_id, already exists` and named no table, no
427
+ remedy, and nothing about the name being spoken for.
428
+
429
+ The suggestion is made per column, and by suffixing rather than by
430
+ substituting a word only one of the two names contains. That left
431
+ `__kumo_anchor_time` told to rename itself to `__kumo_anchor_time`.
432
+ """
433
+ taken = sorted(
434
+ (name, column)
435
+ for name, table in self.schema.tables.items()
436
+ for column in table.columns
437
+ if column in self._RESERVED
438
+ )
439
+ if not taken:
440
+ return
441
+ named = _and_list([f'{name}.{column}' for name, column in taken])
442
+ if len(taken) == 1:
443
+ column = taken[0][1]
444
+ renamed = f'to {self._unreserved(column)!r}'
445
+ else:
446
+ renamed = ', '.join(
447
+ f'{column!r} to {self._unreserved(column)!r}'
448
+ for _, column in taken
449
+ )
450
+ many = len(taken) > 1
451
+ raise StructuredDataError(
452
+ f'{named} {"use names" if many else "uses a name"} this client '
453
+ f'needs for the request it builds, so '
454
+ f'{"they cannot also be columns" if many else "it cannot also be a column"} '
455
+ f'of the data. Rename {"them" if many else "it"}, for instance '
456
+ f'{renamed}.',
457
+ code='INVALID_SCHEMA',
458
+ )
459
+
460
+ def _unreserved(self, column: str) -> str:
461
+ """A name like ``column`` that this client does not need for itself."""
462
+ suggestion = f'{column.lstrip("_")}_from_the_data'
463
+ return suggestion if suggestion not in self._RESERVED else column + '_'
464
+
465
+ def _warn_two_whole_numbers_are_being_asked_as_quantities(
466
+ self, query: ValidatedPredictiveQuery
467
+ ) -> None:
468
+ """Say when a 0/1 column is being asked as a regression.
469
+
470
+ The other route recognises two whole numbers as a label and says so.
471
+ Here the task follows the column's stype, so the same question over
472
+ the same column is asked as a regression and nothing is said. Also,
473
+ `task=` is refused on this route, so the warning's own remedy does not
474
+ apply either.
475
+ """
476
+ named = str(getattr(query, 'target_column', '') or '')
477
+ table, _, column = named.partition('.')
478
+ if table not in self.schema or column not in self.schema[table]:
479
+ return
480
+ if self.schema[table][column].stype != 'numerical':
481
+ return
482
+ # A table in its source has no rows here to look at; the check reads
483
+ # the whole column, which against a warehouse is a scan per request.
484
+ frame = getattr(self.schema[table], 'data', None)
485
+ if frame is None or not _holds_two_whole_numbers(frame[column]):
486
+ return
487
+ warnings.warn(
488
+ f'{named} holds two whole numbers, which is how a binary label is '
489
+ f'usually written, and this is being asked as a regression '
490
+ f'because that is what the column says it holds. If they are '
491
+ f'labels, say so on the column: '
492
+ f"schema[{table!r}][{column!r}].stype = 'categorical'.",
493
+ category=SDMWarning,
494
+ stacklevel=caller_stacklevel(),
495
+ )
496
+
497
+ def _build_graph(self) -> Graph:
498
+ r"""Build exactly the graph described by the client schema.
499
+
500
+ The engine is not asked to guess. Its inference reads column *names*
501
+ without checking what the column holds, so a child's foreign key called
502
+ `ID` holding `[1, 1, 2, 2, 3]` became that table's key and linking it
503
+ was then refused for being one, and a string column called `release`
504
+ became a time column whose values it could not parse. Both are guesses
505
+ about tables the schema already describes, and the second could fail
506
+ before the caller had any chance to undo it.
507
+
508
+ Every key, time column and semantic type is therefore written here from
509
+ the schema, which is the only thing that decides them. A table the
510
+ caller gave no key or no time column is given none.
511
+ """
512
+ from sdm_relational_engine.rfm import Graph
513
+
514
+ self.schema.check_consistent()
515
+
516
+ if self.schema.source is not None:
517
+ # Read in place: the engine picks the SQL sampler for this source,
518
+ # and only the columns the schema describes are read, so a column
519
+ # it left out as unsendable is never sampled either.
520
+ if self.schema._engine_tables is None:
521
+ self.schema._engine_tables = EngineTables()
522
+ graph = self.schema._engine_tables.graph(
523
+ self.schema.source,
524
+ {
525
+ name: (table.source_name, list(table.columns))
526
+ for name, table in self.schema.tables.items()
527
+ if isinstance(table, SourceTable)
528
+ },
529
+ )
530
+ else:
531
+ frames = {
532
+ name: _as_typed(table)
533
+ for name, table in self.schema.tables.items()
534
+ }
535
+ graph = Graph.from_data(
536
+ frames, edges=[], infer_metadata=False, verbose=False
537
+ )
538
+
539
+ for name, table in self.schema.tables.items():
540
+ engine_table = graph[name]
541
+ if table.primary_key is not None:
542
+ engine_table.primary_key = table.primary_key
543
+ else:
544
+ engine_table.primary_key = None
545
+ if table.time_column is not None:
546
+ engine_table.time_column = table.time_column
547
+ else:
548
+ engine_table.time_column = None
549
+ for column_name, column in table.columns.items():
550
+ if column_name in engine_table:
551
+ engine_table[column_name].stype = column.stype
552
+
553
+ linked: set[tuple[str, str, str]] = set()
554
+ for link in self.schema.relationships:
555
+ edge = (link.source_table, link.source_column, link.target_table)
556
+ if edge in linked:
557
+ continue
558
+ linked.add(edge)
559
+ graph.link(
560
+ src_table=link.source_table,
561
+ fkey=link.source_column,
562
+ dst_table=link.target_table,
563
+ )
564
+
565
+ sent = {
566
+ (edge.src_table, edge.fkey, edge.dst_table) for edge in graph.edges
567
+ }
568
+ described = {
569
+ (link.source_table, link.source_column, link.target_table)
570
+ for link in self.schema.relationships
571
+ }
572
+ misdirected = [
573
+ link
574
+ for link in self.schema.relationships
575
+ if link.target_column
576
+ not in _as_key_tuple(self.schema[link.target_table].primary_key)
577
+ ]
578
+ if misdirected:
579
+ shown = ', '.join(
580
+ f'{link.source_table}.{link.source_column} -> '
581
+ f'{link.target_table}.{link.target_column}'
582
+ for link in misdirected
583
+ )
584
+ raise StructuredDataError(
585
+ f'{shown} name a target column that is not the primary key '
586
+ f'of the table they point at, and an edge can only join to that',
587
+ code='INVALID_REQUEST',
588
+ )
589
+ if sent != described: # pragma: no cover - defensive assertion
590
+ raise StructuredDataError(
591
+ f'the graph built for this request does not match the schema: '
592
+ f'{sorted(sent ^ described)}',
593
+ code='INTERNAL_ERROR',
594
+ )
595
+ return graph
596
+
597
+ @contextmanager
598
+ def _engine_errors(
599
+ self,
600
+ model: KumoRelational | None = None,
601
+ per_call: int | None = None,
602
+ ) -> Iterator[None]:
603
+ r"""Re-raise engine failures through this client's error surface.
604
+
605
+ ``model`` is optional because the phase that most needs this is the one
606
+ that builds it. `Graph.from_data`, the key and stype assignments, the
607
+ links and `graph.validate()` all raise bare `ValueError` out of a
608
+ package the caller never imported, and all of them used to run one line
609
+ above this guard. A caller who wrote the `except
610
+ StructuredDataError` the reference tells them to write did not catch
611
+ the whole construction phase.
612
+
613
+ What the graph dropped is appended to a complaint about the data and
614
+ not to one about an argument. A `TypeError` is raised over the type of
615
+ a value the caller passed, and `anchor_time='2020-10-01'` came back
616
+ with a postscript counting rows with no time, which reads as a second
617
+ fault and sends the caller to look at rows that were never the
618
+ problem.
619
+ """
620
+ try:
621
+ from sdm_relational_engine import KumoRelationalError
622
+ except ImportError: # pragma: no cover - the extra is optional
623
+ caller_errors = (ValueError, KeyError, TypeError)
624
+ else:
625
+ caller_errors = (
626
+ ValueError,
627
+ KeyError,
628
+ TypeError,
629
+ KumoRelationalError,
630
+ )
631
+
632
+ try:
633
+ yield
634
+ except StructuredDataError:
635
+ raise
636
+ except Exception as error:
637
+ if not isinstance(error, caller_errors):
638
+ raise StructuredDataError(
639
+ f'the relational engine failed while building this '
640
+ f'request: {type(error).__name__}: {error}',
641
+ code='INTERNAL_ERROR',
642
+ ) from error
643
+ if isinstance(error, TypeError) and self._is_call_signature_error(
644
+ error
645
+ ):
646
+ raise StructuredDataError(
647
+ f'the relational engine was called incorrectly by this '
648
+ f'client: {type(error).__name__}: {error}',
649
+ code='INTERNAL_ERROR',
650
+ ) from error
651
+ detail = (
652
+ ''
653
+ if isinstance(error, TypeError)
654
+ else self._sanitization_detail(model)
655
+ )
656
+ refused = _what_the_engine_refused(str(error))
657
+ said = str(error)
658
+ if refused.get('batch_too_large_to_send'):
659
+ said = _said_in_this_client_s_terms(said, per_call)
660
+ raise StructuredDataError(
661
+ f'{said}{detail}',
662
+ code='INVALID_REQUEST',
663
+ details=refused,
664
+ ) from error
665
+
666
+ @staticmethod
667
+ def _is_call_signature_error(error: TypeError) -> bool:
668
+ r"""Whether a ``TypeError`` is a bad call rather than a bad value."""
669
+ traceback = error.__traceback__
670
+ if traceback is not None:
671
+ deepest = traceback
672
+ while deepest.tb_next is not None:
673
+ deepest = deepest.tb_next
674
+ if deepest.tb_frame.f_globals.get('__name__') == __name__:
675
+ return True
676
+ message = str(error)
677
+ return any(
678
+ phrase in message
679
+ for phrase in (
680
+ 'unexpected keyword argument',
681
+ 'positional argument',
682
+ 'required argument',
683
+ 'takes no arguments',
684
+ )
685
+ )
686
+
687
+ @staticmethod
688
+ def _sanitization_detail(model: KumoRelational) -> str:
689
+ r"""Describe rows the engine dropped while constructing its graph."""
690
+ report = getattr(model, 'sanitization_report', None)
691
+ tables = getattr(report, 'tables', None)
692
+ if not tables:
693
+ return ''
694
+ reasons = []
695
+ for name, table in tables.items():
696
+ dropped = getattr(table, 'input_rows', 0) - getattr(
697
+ table, 'output_rows', 0
698
+ )
699
+ if dropped <= 0:
700
+ continue
701
+ causes = [
702
+ f'{count} with {label}'
703
+ for label, count in (
704
+ (
705
+ 'no primary key',
706
+ getattr(table, 'null_primary_key_rows', 0),
707
+ ),
708
+ (
709
+ 'a repeated primary key',
710
+ getattr(table, 'duplicate_primary_key_rows', 0),
711
+ ),
712
+ ('no time', getattr(table, 'null_time_rows', 0)),
713
+ )
714
+ if count
715
+ ]
716
+ reasons.append(
717
+ f'{name}: {dropped} of {table.input_rows} rows dropped'
718
+ + (f' ({", ".join(causes)})' if causes else '')
719
+ )
720
+ return f' [{"; ".join(reasons)}]' if reasons else ''
721
+
722
+ @staticmethod
723
+ def _narrow_context(
724
+ task: TaskTable, num_context_samples: int | None
725
+ ) -> TaskTable:
726
+ r"""Narrow gathered context to the requested number of examples."""
727
+ if num_context_samples is None:
728
+ return task
729
+ gathered = task.num_context_examples
730
+ if gathered <= num_context_samples:
731
+ return task
732
+ return task.narrow_context(0, num_context_samples)
733
+
734
+ @staticmethod
735
+ def _run_mode_for(num_context_samples: int | None) -> str:
736
+ r"""Return the endpoint run mode admitting the requested context."""
737
+ if num_context_samples is None:
738
+ return 'fast'
739
+ if num_context_samples <= RELATIONAL_LIMITS['context_rows_fast']:
740
+ return 'fast'
741
+ if num_context_samples <= RELATIONAL_LIMITS['context_rows_max']:
742
+ return 'best'
743
+ raise StructuredDataError(
744
+ f'num_context_samples is {num_context_samples:,}, and the endpoint '
745
+ f'accepts at most {RELATIONAL_LIMITS["context_rows_max"]:,} context '
746
+ f'rows',
747
+ code='UNSUPPORTED_REQUEST',
748
+ )
749
+
750
+ @staticmethod
751
+ def _check_limits(
752
+ part: MaterializedPredictionRequest, limits: Mapping[str, int]
753
+ ) -> None:
754
+ r"""Refuse a gathered part the endpoint would reject before sending."""
755
+ payload = getattr(part, 'payload', None)
756
+ if not isinstance(payload, dict):
757
+ return
758
+ predict = payload.get('predict', {}).get('instance_table', {})
759
+ rows = predict.get('rows')
760
+ if rows is None:
761
+ return
762
+ kind = payload.get('task', {}).get('kind')
763
+ cap = _rows_per_call(kind, limits)
764
+ if len(rows) > cap:
765
+ raise StructuredDataError(
766
+ f'this batch scores {len(rows)} rows and the endpoint accepts at '
767
+ f'most {cap} for a {kind} task',
768
+ code='UNSUPPORTED_REQUEST',
769
+ )
770
+ carried = payload.get('schema', {}).get('related_tables')
771
+ if isinstance(carried, dict) and len(carried) > limits['tables_max']:
772
+ raise StructuredDataError(
773
+ f'this request carries {len(carried)} tables and the endpoint '
774
+ f'reads at most {limits["tables_max"]}; sample fewer hops, or '
775
+ f'keep fewer relationships in the schema',
776
+ code='UNSUPPORTED_REQUEST',
777
+ )
778
+
779
+ def _check_shape(
780
+ self, options: Mapping[str, Any], limits: Mapping[str, int]
781
+ ) -> None:
782
+ r"""Refuse sampling options the endpoint will not read.
783
+
784
+ How wide the graph is, is not among them. The cap is on the sampled
785
+ subgraph and the payload carries nothing else, so the declared table
786
+ count is the wrong quantity: a warehouse schema of 21 tables whose
787
+ query reaches 6 of them was refused here while the endpoint answered
788
+ it. Reachability from the entity table is the wrong quantity too, and
789
+ for the same reason: it is an upper bound on what the sampler
790
+ gathers, and the 21-table graph above reaches all 21. Only the
791
+ gathered subgraph knows, which is why the check is in
792
+ `_check_limits`, against the payload that was actually built.
793
+ """
794
+ neighbors = options.get('num_neighbors')
795
+ if neighbors is not None and (
796
+ not isinstance(neighbors, Sequence)
797
+ or isinstance(neighbors, (str, bytes))
798
+ ):
799
+ raise StructuredDataError(
800
+ 'num_neighbors must be a sequence of hop sizes',
801
+ code='INVALID_REQUEST',
802
+ )
803
+
804
+ num_hops = options.get('num_hops')
805
+ if num_hops is not None and (
806
+ isinstance(num_hops, bool) or not isinstance(num_hops, Integral)
807
+ ):
808
+ raise StructuredDataError(
809
+ f'num_hops must be a whole number, got '
810
+ f'{type(num_hops).__name__} {num_hops!r}',
811
+ code='INVALID_REQUEST',
812
+ )
813
+
814
+ seed = options.get('random_seed')
815
+ if seed is not None and (
816
+ isinstance(seed, bool) or not isinstance(seed, Integral)
817
+ ):
818
+ raise StructuredDataError(
819
+ f'random_seed must be a whole number, got '
820
+ f'{type(seed).__name__} {seed!r}',
821
+ code='INVALID_REQUEST',
822
+ )
823
+ if seed is not None and int(seed) < 0:
824
+ raise StructuredDataError(
825
+ f'random_seed is {seed}, and a seed is not negative; pass a '
826
+ f'number from 0 upwards, or leave it out to let the sampler '
827
+ f'choose',
828
+ code='INVALID_REQUEST',
829
+ )
830
+
831
+ hops = len(neighbors) if neighbors is not None else num_hops
832
+ if hops is not None and hops < 1:
833
+ spelled = (
834
+ 'num_neighbors is empty'
835
+ if neighbors is not None
836
+ else f'num_hops is {hops}'
837
+ )
838
+ raise StructuredDataError(
839
+ f'{spelled}, which asks for no hop at all, and the endpoint '
840
+ f'reads between 1 and {limits["hops_max"]}. A request that '
841
+ f'crosses no relationship is a single-table one: give a hop, '
842
+ f'such as num_neighbors=[8, 8], or ask it of kumo-tabular',
843
+ code='INVALID_REQUEST',
844
+ )
845
+ if hops is not None and hops > limits['hops_max']:
846
+ raise StructuredDataError(
847
+ f'this request samples {hops} hops and the endpoint reads at most '
848
+ f'{limits["hops_max"]}',
849
+ code='UNSUPPORTED_REQUEST',
850
+ )
851
+
852
+
853
+ def _as_typed(table: Table) -> pd.DataFrame:
854
+ r"""The table's rows, with any decimal column cast to what it was typed.
855
+
856
+ A warehouse `DECIMAL` arrives from pandas as an `object` column of
857
+ :class:`~decimal.Decimal`. The schema layer reads the values and types it
858
+ (`int64` for whole numbers and `float64` otherwise), and the tabular path
859
+ sends it happily. The relational path handed the engine the raw frame
860
+ instead, which re-infers from the object column, reaches a different answer
861
+ and eventually meets a `Decimal` where a dtype name belongs:
862
+
863
+ dtype '<class 'decimal.Decimal'>' not understood
864
+
865
+ naming no table, no column and no remedy. Since every numeric column of a
866
+ Databricks or Snowflake table is `DECIMAL`, that is the first question
867
+ anyone asks of a warehouse through this SDK's own connector.
868
+
869
+ The two halves also disagreed about what such a column is. The engine maps
870
+ decimal content to float unconditionally, so an exact identifier became a
871
+ float and composite keys over warehouse data were refused outright. Handing
872
+ over the type the schema already computed settles both: there is one answer
873
+ now, and it is the one the schema reports.
874
+ """
875
+ frame = table.data
876
+ if frame is None or frame.empty:
877
+ return frame
878
+ cast = {}
879
+ for name, column in table.columns.items():
880
+ if name not in frame.columns:
881
+ continue
882
+ where = f'{table.name}.{name}'
883
+ if name == table.time_column and _holds_text(frame[name]):
884
+ cast[name] = _to_time(frame[name], where)
885
+ elif _holds_decimal(frame[name]):
886
+ cast[name] = _to_declared(frame[name], column.dtype, where)
887
+ elif _holds_timestamps_loosely(frame[name], column.dtype):
888
+ cast[name] = pd.to_datetime(frame[name], utc=True)
889
+ return frame.assign(**cast) if cast else frame
890
+
891
+
892
+ def _holds_timestamps_loosely(values: pd.Series, declared: str | None) -> bool:
893
+ """Whether schema timestamps are still loose in pandas.
894
+
895
+ An `object` column of `Timestamp` is typed `timestamp[us]` by the schema,
896
+ correctly, and handed to the engine unchanged it re-infers `string` and
897
+ then refuses mixed offsets as an internal error. Only mixed offsets fail,
898
+ which is what `from_records` and a warehouse read of a column spanning a
899
+ daylight-saving change both produce.
900
+ """
901
+ if declared is None or not declared.startswith('timestamp'):
902
+ return False
903
+ if not pd.api.types.is_object_dtype(values.dtype):
904
+ return False
905
+ return pd.api.types.infer_dtype(values, skipna=True) in {
906
+ 'datetime',
907
+ 'datetime64',
908
+ 'date',
909
+ }
910
+
911
+
912
+ def _holds_text(values: pd.Series) -> bool:
913
+ r"""Whether this column holds strings, rather than merely being `object`.
914
+
915
+ An `object` column can hold `datetime.date` or `pd.Timestamp` as readily as
916
+ text, and `infer_tfm_dtype` already calls those `timestamp[us]`. Reading
917
+ the dtype alone would send a column of real dates down the text path and
918
+ refuse it for being ambiguous, which it is not.
919
+ """
920
+ if not (
921
+ pd.api.types.is_object_dtype(values.dtype)
922
+ or pd.api.types.is_string_dtype(values.dtype)
923
+ ):
924
+ return False
925
+ return pd.api.types.infer_dtype(values, skipna=True) == 'string'
926
+
927
+
928
+ #: How the engine says a gathered batch is too large for the endpoint's body.
929
+ _A_BATCH_TOO_LARGE_TO_SEND = ('size exceeds the 30mb limit',)
930
+
931
+ #: How the engine offers a smaller batch, which it does only where the batch
932
+ #: that failed has more than one row in it. Read rather than worked out again
933
+ #: here, because which batch failed is the engine's to know: a request may be
934
+ #: gathered as several of different sizes, and the last of them is smaller
935
+ #: than the size the rest were built to.
936
+ _THE_ENGINE_S_ENTITIES = 'the number of entities predicted at once'
937
+
938
+ #: Where the engine's own remedy for that begins. It ends by offering the run
939
+ #: mode, which this client derives rather than takes, so a caller following it
940
+ #: lands on a refusal. Replaced rather than appended to, because advice that
941
+ #: cannot be taken is worse than none.
942
+ _THE_ENGINE_S_REMEDY = 'Please reduce either'
943
+
944
+ #: What to do about it here. Both halves are real and which one binds was
945
+ #: measured: a wide graph at the default context is gathered by asking in
946
+ #: smaller calls, and the same graph at 10,000 context rows is over the body
947
+ #: on the context alone, where only a narrower context helps.
948
+ _ASK_IN_SMALLER_CALLS = (
949
+ 'What comes down is one of three. For an ordinary question it is the '
950
+ 'call: materialize(rows_per_call=N) below the {per_call} this one holds '
951
+ 'asks for the same entities in more and smaller calls. Where the context '
952
+ 'is itself over the limit, no call is small enough, and it is '
953
+ 'num_context_samples, num_neighbors or the columns the graph carries. '
954
+ 'For a ranking or a forecast, which is one call per candidate or step '
955
+ 'and is never batched, it is the entities.'
956
+ )
957
+
958
+
959
+ #: What to say when a call already scores one row. There is no smaller call
960
+ #: to ask for, so offering one would be asking for a number that does not
961
+ #: exist.
962
+ _NO_CALL_IS_SMALLER = (
963
+ 'A call already scores one row, so there is no smaller one to ask for: '
964
+ 'what comes down is num_context_samples, num_neighbors or the columns '
965
+ 'the graph carries.'
966
+ )
967
+
968
+
969
+ _A_WINDOW_THAT_OUTRAN_THE_DATA = (
970
+ 'aggregation time range',
971
+ 'we would need data back to',
972
+ 'anchor timestamp is too early',
973
+ )
974
+
975
+
976
+ def _said_in_this_client_s_terms(said: str, per_call: int | None) -> str:
977
+ r"""The engine's size complaint, keeping its figures and not its remedy.
978
+
979
+ What it measured is the useful half and is kept whole. What to do about it
980
+ is written for a caller who can set a run mode and a batch size directly,
981
+ and through this client one of those is derived and the other is named
982
+ differently, so that half is replaced.
983
+
984
+ The size named is the one this question holds to, which is not always the
985
+ 10,000 of an ordinary task: a temporal link prediction holds 200, and
986
+ advice to come down to 500 would be followed into the same failure.
987
+
988
+ Whether a smaller call is worth offering at all is the engine's answer,
989
+ not one worked out from the size asked for. Only the engine knows which
990
+ batch failed, and a request gathered as `2, 2, 1` can fail on the one
991
+ that no batch size can divide.
992
+ """
993
+ at = said.find(_THE_ENGINE_S_REMEDY)
994
+ measured = said if at < 0 else said[:at].rstrip()
995
+ if _THE_ENGINE_S_ENTITIES not in said:
996
+ return f'{measured} {_NO_CALL_IS_SMALLER}'
997
+ holds = f'{per_call:,}' if per_call else 'number'
998
+ return f'{measured} ' + _ASK_IN_SMALLER_CALLS.format(per_call=holds)
999
+
1000
+
1001
+ def _what_the_engine_refused(said: str) -> dict[str, Any]:
1002
+ """What the engine's complaint is about, as something to branch on.
1003
+
1004
+ A question asked as several parts appends an account of which part failed,
1005
+ and that account can only name a remedy where it knows the failure is one
1006
+ the remedy addresses. The engine says so in words; this records it so the
1007
+ seam reads a value rather than the words.
1008
+ """
1009
+ lowered = said.lower()
1010
+ if any(mark in lowered for mark in _A_WINDOW_THAT_OUTRAN_THE_DATA):
1011
+ return {'window_outran_the_data': True}
1012
+ if any(mark in lowered for mark in _A_BATCH_TOO_LARGE_TO_SEND):
1013
+ return {'batch_too_large_to_send': True}
1014
+ return {}
1015
+
1016
+
1017
+ def _only_junk_is_unread(present: pd.Series) -> bool:
1018
+ """Whether what the column cannot read is junk rather than another format.
1019
+
1020
+ A value the column reads past can be either. `02/01/2020` among year-first
1021
+ dates is a date written the other way, and that is the ambiguity the
1022
+ message below is for. A value that is not a date on its own terms is not,
1023
+ and saying it is ambiguous sends the caller to a remedy that cannot work.
1024
+
1025
+ Asked of each value, by the one function that answers that question. It
1026
+ was asked here with the default reading, which leaves most of a day-first
1027
+ column unread, so the single value that is genuinely not a date was
1028
+ reported as ambiguity, while the same column written year-first was
1029
+ reported correctly.
1030
+ """
1031
+ read = dates.reads_as_a_date(present)
1032
+ if read.all():
1033
+ return False
1034
+ if not read.any():
1035
+ return dates.looks_unambiguous(present)
1036
+ return bool(dates.looks_unambiguous(present[read]))
1037
+
1038
+
1039
+ def _refuse_values_that_do_not_read(present: pd.Series, where: str) -> None:
1040
+ """Refuse a column that says what its dates are and still will not read.
1041
+
1042
+ The ambiguity message below is true only of a column that says two things.
1043
+ A column that says one thing and carries a value that is not a date is a
1044
+ different mistake, and telling that caller their dates are ambiguous sends
1045
+ them to a remedy that cannot work: `dayfirst=True` on a year-first column
1046
+ either raises or reads every date it can as a different one.
1047
+
1048
+ One unreadable value is enough to make the whole column fail the shape
1049
+ test, so what the readable values say is asked separately.
1050
+
1051
+ Coercing is offered only where something would survive it: where no value
1052
+ reads it leaves a column of NaT, which this client refuses in turn.
1053
+
1054
+ What does not read is counted by the one function that answers that, or
1055
+ the count names as unread the dates the column reads perfectly well.
1056
+ """
1057
+ column = where.split('.')[-1]
1058
+ unread = present[~dates.reads_as_a_date(present)]
1059
+ example = unread.iloc[0] if len(unread) else present.iloc[0]
1060
+ if len(unread) == len(present):
1061
+ raise StructuredDataError(
1062
+ f'{where} is the time column and none of its {len(present)} '
1063
+ f'values read as a date, such as {example!r}. A table is windowed '
1064
+ f'by its time column, so this one has no time axis: give it a '
1065
+ f'column that holds dates, or leave it without one and ask a '
1066
+ f'question that does not window it',
1067
+ code='INVALID_REQUEST',
1068
+ )
1069
+ raise StructuredDataError(
1070
+ f'{where} is the time column and {len(unread)} of {len(present)} of its '
1071
+ f'values do not read as a date, such as {example!r}. Fix or drop those '
1072
+ f'values, or read the column yourself and let them become NaT: '
1073
+ f'frame[{column!r}] = pd.to_datetime(frame[{column!r}], '
1074
+ f"errors='coerce'). This client accepts it and says how many rows "
1075
+ f'the model will not see',
1076
+ code='INVALID_REQUEST',
1077
+ )
1078
+
1079
+
1080
+ def _refuse_a_column_written_two_ways(
1081
+ shapes: dict[str, str], where: str, unread: int = 0
1082
+ ) -> None:
1083
+ """Refuse a column whose rows are written in more than one shape.
1084
+
1085
+ Every row says exactly what it is, so it is not ambiguous. What it has no
1086
+ answer to is which single call reads it: the one that reads `13/02/2021`
1087
+ reads `2021-02-04` as the second of April.
1088
+
1089
+ Said before the values that do not read, where a column has both, and
1090
+ saying so. Coercing is what the other message offers, and coercing a
1091
+ column written two ways throws away every row of the shape it does not
1092
+ settle on. The caller therefore has to know about the shapes first.
1093
+ """
1094
+ year_first = shapes['year-first']
1095
+ other = shapes['day-or-month-first']
1096
+ column = where.split('.')[-1]
1097
+ also = (
1098
+ f' {unread} of its values do not read as a date either, and are a '
1099
+ f'separate thing to fix.'
1100
+ if unread
1101
+ else ''
1102
+ )
1103
+ raise StructuredDataError(
1104
+ f'{where} is the time column and is written two ways: {year_first!r} '
1105
+ f'leads with its year and {other!r} leads with its day or its month. '
1106
+ f'No one reading covers both. The call that reads {other!r} reads '
1107
+ f'{year_first!r} as a different date, so read the halves apart and '
1108
+ f'put them back as one column, or store frame[{column!r}] in one '
1109
+ f'shape before naming it as the time column.{also}',
1110
+ code='INVALID_REQUEST',
1111
+ )
1112
+
1113
+
1114
+ def _to_time(values: pd.Series, where: str) -> pd.Series:
1115
+ r"""A string time column read as dates, or refused for saying two things.
1116
+
1117
+ `supports_dtype('timestamp', 'string')` is true, so a column of date
1118
+ strings can be named as a table's time column and is accepted. The strings
1119
+ then have to become timestamps before the model sees them, and that was
1120
+ left to `pd.to_datetime`'s defaults, which read an ambiguous slash date
1121
+ month-first. A day-first column was silently re-dated: twelve monthly
1122
+ events across a year became twelve daily events in one week of February,
1123
+ and nothing in the request, the schema or the response recorded that a
1124
+ conversion had happened at all. It surfaced, when it surfaced, as an
1125
+ unrelated complaint about anchor times.
1126
+
1127
+ So a column is read only where it says what its dates are. A year-first one
1128
+ says it outright. A slash-separated one says it when a value of its own
1129
+ rules a reading out. No month is 15, so `15/03/2020` is day-first and
1130
+ every other row reads the same way. This is the ordinary case for any
1131
+ column spanning more than twelve days. Where nothing decides, `02/01/2020`
1132
+ is the 2nd of January to half the world and the 1st of February to the
1133
+ other half, and there the caller says, by converting it themselves.
1134
+
1135
+ The shape check reads a sample and is only a pre-filter; the conversion
1136
+ below reads every value, so a column that changes shape in its last row is
1137
+ caught there rather than here.
1138
+
1139
+ Ambiguity is considered last. A column written in no date shape, and one
1140
+ written in two, are each a different mistake with a different remedy.
1141
+
1142
+ Reading each value on its own terms is the last reading tried, and only
1143
+ once the column is known to be one shape: it will also read a column
1144
+ written two ways, by re-dating the half it does not settle on.
1145
+ """
1146
+ present = values.dropna()
1147
+ if present.empty:
1148
+ return values
1149
+ reads = dates.looks_unambiguous(present)
1150
+ if reads:
1151
+ how = dates.conversion_that_works(present, only_plausible_years=False)
1152
+ if how is not None:
1153
+ return dates.read(values, how)
1154
+ shapes = dates.shapes_written(present)
1155
+ if len(shapes) > 1:
1156
+ unread = int((~dates.reads_as_a_date(present)).sum())
1157
+ return _refuse_a_column_written_two_ways(shapes, where, unread)
1158
+ if reads:
1159
+ how = dates.conversion_that_works(
1160
+ present, only_plausible_years=False, per_row=True
1161
+ )
1162
+ if how is not None:
1163
+ return dates.read(values, how)
1164
+ if not shapes or _only_junk_is_unread(present):
1165
+ return _refuse_values_that_do_not_read(present, where)
1166
+ example = next(
1167
+ (value for value in present if isinstance(value, str)), present.iloc[0]
1168
+ )
1169
+ column = where.split('.')[-1]
1170
+ raise StructuredDataError(
1171
+ f'{where} is the time column and holds text such as {example!r}, '
1172
+ f'which does not say unambiguously what date it is: read day-first it '
1173
+ f'is one date and month-first another, and nothing in the column says '
1174
+ f'which. Convert it yourself so the reading is yours: '
1175
+ f'frame[{column!r}] = pd.to_datetime(frame[{column!r}], '
1176
+ f'dayfirst=True), or store it year-first',
1177
+ code='INVALID_REQUEST',
1178
+ )
1179
+
1180
+
1181
+ def _holds_decimal(values: pd.Series) -> bool:
1182
+ r"""Whether a column holds decimals, however it was read.
1183
+
1184
+ Through Arrow the dtype says so; through pandas it is an `object` column
1185
+ and only the values do.
1186
+ """
1187
+ if arrow_decimal_type(values.dtype) is not None:
1188
+ return True
1189
+ if not pd.api.types.is_object_dtype(values.dtype):
1190
+ return False
1191
+ return pd.api.types.infer_dtype(values, skipna=True) == 'decimal'
1192
+
1193
+
1194
+ def _to_declared(values: pd.Series, dtype: str, where: str) -> pd.Series:
1195
+ r"""``values`` as ``dtype``, exactly.
1196
+
1197
+ Only what the schema asked for. A whole decimal too wide for an `int64` is
1198
+ typed `string` precisely so its digits survive, and reading "not an int" as
1199
+ "a float" would put 9223372036854775808 back as 9.223372036854776e+18,
1200
+ the corruption this exists to prevent, reintroduced one branch over.
1201
+
1202
+ A column with a missing value cannot be a plain `int64`, so it becomes
1203
+ pandas' nullable `Int64` rather than widening to float and rounding an
1204
+ identifier.
1205
+
1206
+ The remaining declaration is `string`, which is how a whole number wider
1207
+ than an int64 travels: only the digits matter and only a string keeps all
1208
+ of them. An integer is read with `int()` on the value and never through
1209
+ `pd.to_numeric`, which converts by way of a float64 and brings
1210
+ 9007199254740993 back as 9007199254740992. These are identifiers, and the
1211
+ low bit is the whole of their job.
1212
+ """
1213
+ if dtype.startswith('float'):
1214
+ return pd.to_numeric(values.astype(object), errors='coerce').astype(
1215
+ 'float64'
1216
+ )
1217
+ if not dtype.startswith('int'):
1218
+ return values.map(lambda value: None if pd.isna(value) else str(value))
1219
+ fractional = values.map(
1220
+ lambda value: not pd.isna(value) and value != int(value)
1221
+ )
1222
+ if fractional.any():
1223
+ example = values[fractional].iloc[0]
1224
+ raise StructuredDataError(
1225
+ f'column {where} is declared {dtype!r} and holds {example!r}, '
1226
+ f'which is not a whole number. Sending it would round the value '
1227
+ f'without saying so; declare the column float64, or round it '
1228
+ f'yourself if that is what you meant',
1229
+ code='INVALID_REQUEST',
1230
+ )
1231
+ whole = values.map(lambda value: pd.NA if pd.isna(value) else int(value))
1232
+ if whole.isna().any():
1233
+ return whole.astype('Int64')
1234
+ return whole.astype('int64')
1235
+
1236
+
1237
+ def _columns_to_leave_out(
1238
+ query: ValidatedPredictiveQuery, options: Mapping[str, Any]
1239
+ ) -> dict[str, list[str]]:
1240
+ r"""Every column either the query or the caller asked to keep back.
1241
+
1242
+ `exclude_cols_dict` was accepted by `materialize()`, checked against the
1243
+ options this route takes, and then not passed on: the query's own answer
1244
+ was sent in its place, so the one option someone reaches for to keep a
1245
+ column away from the model did nothing.
1246
+
1247
+ Merged rather than replaced. The query excludes columns for reasons of its
1248
+ own, including the target. Letting a caller's list stand in for
1249
+ that would hand the model back something the query had deliberately
1250
+ removed.
1251
+
1252
+ Only an absent option and an explicit `None` mean there is nothing to
1253
+ exclude. Anything else is read and refused if it is not a mapping, since
1254
+ passing over a value that cannot be one is the same silence this exists to
1255
+ remove.
1256
+ """
1257
+ merged = {
1258
+ table: list(columns)
1259
+ for table, columns in (query.get_exclude_cols_dict() or {}).items()
1260
+ }
1261
+ given = options.get('exclude_cols_dict')
1262
+ if given is None:
1263
+ given = {}
1264
+ if not isinstance(given, Mapping):
1265
+ raise StructuredDataError(
1266
+ f'exclude_cols_dict must map a table name to the columns to leave '
1267
+ f'out, got {type(given).__name__}',
1268
+ code='INVALID_REQUEST',
1269
+ )
1270
+ for table, columns in given.items():
1271
+ if isinstance(columns, str) or not isinstance(columns, Sequence):
1272
+ raise StructuredDataError(
1273
+ f'exclude_cols_dict[{table!r}] must be a sequence of column '
1274
+ f'names, got {type(columns).__name__}',
1275
+ code='INVALID_REQUEST',
1276
+ )
1277
+ kept = merged.setdefault(table, [])
1278
+ kept.extend(column for column in columns if column not in kept)
1279
+ return merged
1280
+
1281
+
1282
+ def _candidates_to_rank(
1283
+ query: ValidatedPredictiveQuery, options: Mapping[str, Any]
1284
+ ) -> int | None:
1285
+ r"""How many candidates to rank, held to the limits the language holds.
1286
+
1287
+ A `TOP K` written in the query is checked: at least one, and no more than
1288
+ the endpoint ranks. Passing it as an option went straight through to the
1289
+ task, so an override could put a number in a request that the same number
1290
+ written in the query would have been refused for.
1291
+ """
1292
+ if 'top_k' not in options:
1293
+ return query.top_k
1294
+ given = options['top_k']
1295
+ if isinstance(given, bool) or not isinstance(given, Integral):
1296
+ raise StructuredDataError(
1297
+ f'top_k must be a whole number of candidates, got '
1298
+ f'{type(given).__name__}',
1299
+ code='INVALID_REQUEST',
1300
+ )
1301
+ if not 1 <= int(given) <= _MAX_TOP_K:
1302
+ raise StructuredDataError(
1303
+ f'top_k is {given}, and this model ranks between 1 and '
1304
+ f'{_MAX_TOP_K} candidates',
1305
+ code='INVALID_REQUEST',
1306
+ )
1307
+ return int(given)
1308
+
1309
+
1310
+ def _statistic_asked_for(options: Mapping[str, Any]) -> str | None:
1311
+ r"""The regression statistic the caller chose, refused if it is not one
1312
+ the endpoint reports."""
1313
+ if 'prediction_statistic' not in options:
1314
+ return None
1315
+ given = options['prediction_statistic']
1316
+ if not isinstance(given, str) or given not in PREDICTION_STATISTICS:
1317
+ raise StructuredDataError(
1318
+ f'prediction_statistic is {given!r}, and a regression reports '
1319
+ f'{" or ".join(repr(name) for name in sorted(PREDICTION_STATISTICS))} '
1320
+ f'of its predicted distribution',
1321
+ code='INVALID_REQUEST',
1322
+ )
1323
+ return str(given)
1324
+
1325
+
1326
+ def _record_the_statistic(
1327
+ payloads: tuple[dict[str, Any], ...], statistic: str
1328
+ ) -> None:
1329
+ r"""Carry the chosen statistic on each call, or refuse a question that
1330
+ has no distribution to summarise."""
1331
+ for payload in payloads:
1332
+ kind = str(payload.get('task', {}).get('kind', ''))
1333
+ if kind not in SUMMARISED_TASK_KINDS:
1334
+ raise StructuredDataError(
1335
+ f'prediction_statistic applies only to a regression, and '
1336
+ f'this question is a {kind}; ask it without '
1337
+ f'prediction_statistic',
1338
+ code='INVALID_REQUEST',
1339
+ )
1340
+ output = payload.setdefault('output', {})
1341
+ output['prediction_statistic'] = statistic
1342
+
1343
+
1344
+ def _warn_rows_the_graph_dropped(model: KumoRelational) -> None:
1345
+ r"""Say what the engine left out while building the graph.
1346
+
1347
+ A fact row with no time, no key, or a key another row already used cannot
1348
+ be placed in the graph and is dropped. The count and the reason are worked
1349
+ out either way, and `_sanitization_detail` builds the sentence. It was
1350
+ only ever shown when something else had already failed. So a request that
1351
+ succeeded was answered from a graph smaller than the one the caller
1352
+ passed, the result looked ordinary, and the number was sitting there
1353
+ unread.
1354
+ """
1355
+ detail = RelationalMaterializer._sanitization_detail(model)
1356
+ if not detail:
1357
+ return
1358
+ warnings.warn(
1359
+ f'the graph was built without some of the rows given to it '
1360
+ f'{detail.strip()}. Those rows are not in the context the model sees; '
1361
+ f'fill the missing values or drop the rows yourself if that is not '
1362
+ f'what you meant.',
1363
+ category=SDMWarning,
1364
+ stacklevel=caller_stacklevel(),
1365
+ )
1366
+
1367
+
1368
+ #: What the wire calls the column carrying the entity a row is about.
1369
+ _ENTITY_REF = '__kumo_entity_ref'
1370
+
1371
+ #: And the moment it is asked about.
1372
+ _ANCHOR = '__kumo_anchor_time'
1373
+
1374
+ #: What the envelope calls the answer a context row carries.
1375
+ _TARGET = 'TARGET'
1376
+
1377
+ #: What ties a related row to the instance it hangs off.
1378
+ INSTANCE_ID = 'instance_id'
1379
+
1380
+
1381
+ def _drop_context_that_answers_the_question(
1382
+ payloads: tuple[dict[str, Any], ...], *, windowed: bool = True
1383
+ ) -> None:
1384
+ r"""Take out of the context any row that is a scored row at its own anchor.
1385
+
1386
+ On a target with no time window the entity being scored is sampled into the
1387
+ context as well, at the same anchor, carrying the value being asked for,
1388
+ so the model is shown the answer to the question it is being asked.
1389
+ Measured against the live endpoint on a label that is pure noise, a hundred
1390
+ scored entities came back 0.660 correct where chance is 0.500.
1391
+
1392
+ **What counts as the same question depends on the target.** For an
1393
+ aggregation, an entity in the context at an *earlier* anchor is not a leak:
1394
+ it is an earlier example, with its features cut at that anchor and its
1395
+ label window closed before the prediction window opens, and it is what
1396
+ temporal in-context learning runs on. Dropping those would delete the
1397
+ examples an aggregation depends on, to fix a leak that is not there. So
1398
+ there, only the same entity at the same moment goes.
1399
+
1400
+ A static column is not a window over anything. Its value does not move with
1401
+ the anchor, so the same entity at *any* anchor carries the answer being
1402
+ asked for. A caller reaches this by setting `context_anchor_time`, and
1403
+ which matching on the anchor alone would have let straight through.
1404
+
1405
+ The two halves carry their own `instance_id` space, so removing a context
1406
+ row means removing the related rows that hang off it and nothing else.
1407
+
1408
+ What is found is collected before any of it is taken, keyed by the identity
1409
+ of the context it belongs to and unioned: two payloads can share one
1410
+ context object, where counting the entries would report a row once per
1411
+ entry and keeping only the first entry's rows would leave the rest behind.
1412
+
1413
+ The notice comes before the removal. A caller running with
1414
+ `simplefilter('error')` turns it into a raise, and a raise partway through
1415
+ would leave the payloads it had already reached short of rows nobody was
1416
+ told were gone. Which is why it states the rule rather than the edit:
1417
+ raised as an exception, nothing has been removed and no context has been
1418
+ sent, and a message claiming either would be false in the one case the
1419
+ ordering exists for.
1420
+ """
1421
+ found: dict[int, tuple[Mapping[str, Any], set[Any]]] = {}
1422
+ for payload in payloads:
1423
+ context = payload.get('context')
1424
+ predict = payload.get('predict')
1425
+ if not isinstance(context, Mapping) or not isinstance(predict, Mapping):
1426
+ continue
1427
+ anchor_column = (payload.get('task') or {}).get(
1428
+ 'anchor_time_column'
1429
+ ) or _ANCHOR
1430
+ asked = _entities_at_anchors(
1431
+ predict.get('instance_table'), anchor_column, windowed
1432
+ )
1433
+ if not asked:
1434
+ continue
1435
+ answered = _instances_matching(
1436
+ context.get('instance_table'), asked, anchor_column, windowed
1437
+ )
1438
+ if not answered:
1439
+ continue
1440
+ carried = found.setdefault(id(context), (context, set()))
1441
+ carried[1].update(answered)
1442
+ if not found:
1443
+ return
1444
+
1445
+ total = sum(len(answered) for _, answered in found.values())
1446
+ # The two targets lose different rows, so they are told different things.
1447
+ # A window closes, so only the same moment carries the answer; a static
1448
+ # column does not move, so every moment does.
1449
+ when = 'at the same moment' if windowed else 'at one moment or another'
1450
+ warnings.warn(
1451
+ f'{total} of the rows sampled as context are the rows being scored, '
1452
+ f'{when}, carrying the value being asked for. Rows that carry the '
1453
+ f'answer to the question being asked are not sent as context, so '
1454
+ f'these do not go into it.',
1455
+ category=SDMWarning,
1456
+ stacklevel=caller_stacklevel(),
1457
+ )
1458
+ for context, answered in found.values():
1459
+ _remove_instances(context.get('instance_table'), answered)
1460
+ for related in (context.get('related_tables') or {}).values():
1461
+ _remove_instances(related, answered)
1462
+
1463
+
1464
+ def _positions(
1465
+ table: Any, anchor_column: str
1466
+ ) -> tuple[list[Any], list[Any], int | None, int | None]:
1467
+ r"""Where the entity and the anchor sit in a wire table.
1468
+
1469
+ The anchor column is read from the task rather than assumed: the engine
1470
+ suffixes the name when it would collide with a real column, and looking
1471
+ only for the bare name went blind on exactly those graphs.
1472
+ """
1473
+ columns = (table or {}).get('columns')
1474
+ rows = (table or {}).get('rows')
1475
+ if not isinstance(columns, list) or not isinstance(rows, list):
1476
+ return [], [], None, None
1477
+ reference = next(
1478
+ (
1479
+ index
1480
+ for index, name in enumerate(columns)
1481
+ if str(name).startswith(_ENTITY_REF)
1482
+ ),
1483
+ None,
1484
+ )
1485
+ anchor = columns.index(anchor_column) if anchor_column in columns else None
1486
+ return columns, rows, reference, anchor
1487
+
1488
+
1489
+ def _entities_at_anchors(
1490
+ table: Any, anchor_column: str = _ANCHOR, windowed: bool = True
1491
+ ) -> set[tuple[Any, Any]]:
1492
+ columns, rows, reference, anchor = _positions(table, anchor_column)
1493
+ if reference is None or (windowed and anchor is None):
1494
+ return set()
1495
+ needed = max(reference, anchor) if windowed else reference
1496
+ return {
1497
+ (row[reference], row[anchor] if windowed else None)
1498
+ for row in rows
1499
+ if isinstance(row, list) and len(row) > needed
1500
+ }
1501
+
1502
+
1503
+ def _instances_matching(
1504
+ table: Any,
1505
+ asked: set[tuple[Any, Any]],
1506
+ anchor_column: str = _ANCHOR,
1507
+ windowed: bool = True,
1508
+ ) -> set[Any]:
1509
+ columns, rows, reference, anchor = _positions(table, anchor_column)
1510
+ if reference is None or 'instance_id' not in columns:
1511
+ return set()
1512
+ if windowed and anchor is None:
1513
+ return set()
1514
+ at = columns.index('instance_id')
1515
+ needed = max(reference, at, anchor) if windowed else max(reference, at)
1516
+ return {
1517
+ row[at]
1518
+ for row in rows
1519
+ if isinstance(row, list)
1520
+ and len(row) > needed
1521
+ and (row[reference], row[anchor] if windowed else None) in asked
1522
+ }
1523
+
1524
+
1525
+ def _remove_instances(table: Any, instances: set[Any]) -> None:
1526
+ columns = (table or {}).get('columns')
1527
+ rows = (table or {}).get('rows')
1528
+ if not isinstance(columns, list) or not isinstance(rows, list):
1529
+ return
1530
+ if 'instance_id' not in columns:
1531
+ return
1532
+ at = columns.index('instance_id')
1533
+ table['rows'] = [
1534
+ row
1535
+ for row in rows
1536
+ if not (
1537
+ isinstance(row, list) and len(row) > at and row[at] in instances
1538
+ )
1539
+ ]
1540
+
1541
+
1542
+ def _tables_sent(
1543
+ payloads: Sequence[Mapping[str, Any]], schema: Schema
1544
+ ) -> frozenset[str]:
1545
+ r"""Which of the schema's tables the built payloads carry.
1546
+
1547
+ Read off the payload rather than worked out from the schema, because only
1548
+ the sampler knows: a table the schema links and whose foreign key matches
1549
+ no row is not in the request, and an answer derived from the schema would
1550
+ say it was. A payload that names no related tables at all is a shape this
1551
+ does not recognise, and there the schema is assumed sent, so nothing is
1552
+ reported as lost on a guess.
1553
+ """
1554
+ carried: set[str] = set()
1555
+ described = False
1556
+ for payload in payloads:
1557
+ related = (payload.get('schema') or {}).get('related_tables')
1558
+ if isinstance(related, Mapping):
1559
+ described = True
1560
+ carried.update(related)
1561
+ return frozenset(carried) if described else frozenset(schema.tables)
1562
+
1563
+
1564
+ def _tables_linked_to(schema: Schema, entity_table: str) -> frozenset[str]:
1565
+ r"""The tables a relationship path connects to ``entity_table``.
1566
+
1567
+ Read in both directions, because the sampler traverses an edge either way:
1568
+ a parent is reached from its child as readily as a child from its parent.
1569
+ Used only to say why a table is missing, never to refuse one. What is
1570
+ reachable is an upper bound on what is gathered, and refusing on it denies
1571
+ graphs the endpoint answers.
1572
+ """
1573
+ neighbours: dict[str, set[str]] = {}
1574
+ for link in schema.relationships:
1575
+ neighbours.setdefault(link.source_table, set()).add(link.target_table)
1576
+ neighbours.setdefault(link.target_table, set()).add(link.source_table)
1577
+ seen = {entity_table}
1578
+ queue = [entity_table]
1579
+ while queue:
1580
+ for nearby in neighbours.get(queue.pop(), ()):
1581
+ if nearby not in seen:
1582
+ seen.add(nearby)
1583
+ queue.append(nearby)
1584
+ return frozenset(seen)
1585
+
1586
+
1587
+ def _warn_of_tables_left_out(
1588
+ schema: Schema, sent: frozenset[str], entity_table: str
1589
+ ) -> None:
1590
+ r"""Say which of the schema's tables this request does not carry.
1591
+
1592
+ A table the sampler does not reach is removed from the payload, and the
1593
+ removal was silent. Measured on a parent whose value is the mean of its
1594
+ children's, leaving the one edge between them undeclared moved the error
1595
+ from 0.008 to 0.134, sixteen times worse, with nothing said and a result
1596
+ that looks entirely ordinary.
1597
+
1598
+ The two causes are said separately because they take different remedies.
1599
+ Nothing links the first kind, and only a relationship puts it back. The
1600
+ second is linked and was still not reached, which is either a hop budget
1601
+ that stops short of it or a foreign key that matches no row.
1602
+ """
1603
+ missing = sorted(set(schema.tables) - sent)
1604
+ if not missing:
1605
+ return
1606
+ linked = _tables_linked_to(schema, entity_table)
1607
+ unlinked = [name for name in missing if name not in linked]
1608
+ unreached = [name for name in missing if name in linked]
1609
+ if unlinked:
1610
+ many = len(unlinked) > 1
1611
+ warnings.warn(
1612
+ f'{", ".join(repr(name) for name in unlinked)} '
1613
+ f'{"are" if many else "is"} in the schema and not in this '
1614
+ f'request: no relationship connects {"them" if many else "it"} '
1615
+ f'to {entity_table!r}, so the model answers without '
1616
+ f'{"them" if many else "it"}. Say how '
1617
+ f'{"they join" if many else "it joins"}: '
1618
+ f'schema.relationships.append(Relationship({unlinked[0]!r}, '
1619
+ f'<foreign key>, <parent table>, <its primary key>)), or leave '
1620
+ f'{"them" if many else "it"} out of the schema to say '
1621
+ f'{"they are" if many else "it is"} not wanted.',
1622
+ category=SDMWarning,
1623
+ stacklevel=caller_stacklevel(),
1624
+ )
1625
+ if unreached:
1626
+ many = len(unreached) > 1
1627
+ warnings.warn(
1628
+ f'{", ".join(repr(name) for name in unreached)} '
1629
+ f'{"are" if many else "is"} linked into the schema and not in '
1630
+ f'this request: the sampler reached no row of '
1631
+ f'{"them" if many else "it"} from the entities being scored, so '
1632
+ f'the model answers without {"them" if many else "it"}. Raise '
1633
+ f'num_hops if {"they lie" if many else "it lies"} further out '
1634
+ f'than the hops sampled, and check the foreign key holds values '
1635
+ f'the parent key actually has if '
1636
+ f'{"they do" if many else "it does"} not.',
1637
+ category=SDMWarning,
1638
+ stacklevel=caller_stacklevel(),
1639
+ )
1640
+
1641
+
1642
+ def _warn_of_relationships_that_join_nothing(schema: Schema) -> None:
1643
+ r"""Say when a declared relationship matches no row on either side.
1644
+
1645
+ Nothing looks at what a foreign key holds. `_require_joinable` checks the
1646
+ dtype and the shape of the parent key, and the sanitization report counts
1647
+ null and repeated keys, so an edge between two disjoint namespaces of the
1648
+ same dtype passes both. Declaring `flights.AIRLINE -> airports.IATA_CODE`
1649
+ where `flights.ORIGIN_AIRPORT` was meant is one word, and the same query
1650
+ on the same frames answered 159.7 with the right column and -0.006 with
1651
+ the wrong one, in byte-identical silence.
1652
+
1653
+ Said rather than refused: a child with no rows yet is legal and joins
1654
+ nothing for a reason that is not a mistake, which is why an empty frame on
1655
+ either side is passed over rather than reported.
1656
+ """
1657
+ empty = [
1658
+ link for link in schema.relationships if _joins_nothing(schema, link)
1659
+ ]
1660
+ if not empty:
1661
+ return
1662
+ first = empty[0]
1663
+ many = len(empty) > 1
1664
+ warnings.warn(
1665
+ f'{", ".join(str(link) for link in empty)} '
1666
+ f'{"join" if many else "joins"} none of the rows given: no value of '
1667
+ f'the foreign key appears in the key it points at, so the tables are '
1668
+ f'related in the schema and in no row of the data, and the question '
1669
+ f'is answered as though {"they" if many else "it"} were not declared. '
1670
+ f'Check that the foreign key is the column naming the parent row; one '
1671
+ f'spelled differently from the key it points at has to be said '
1672
+ f'outright, as relationships=[Relationship({first.source_table!r}, '
1673
+ f'<that column>, {first.target_table!r}, {first.target_column!r})].',
1674
+ category=SDMWarning,
1675
+ stacklevel=caller_stacklevel(),
1676
+ )
1677
+
1678
+
1679
+ def _joins_nothing(schema: Schema, link: Relationship) -> bool:
1680
+ r"""Whether no row of the child's key is a row of the parent's.
1681
+
1682
+ Every shape this cannot read is not one: a link naming a table or a column
1683
+ the schema has not got is `check_consistent`'s to refuse, and answering it
1684
+ here would report a missing table as a join that found nothing.
1685
+ """
1686
+ if (
1687
+ link.source_table not in schema.tables
1688
+ or link.target_table not in schema.tables
1689
+ ):
1690
+ return False
1691
+ child = getattr(schema[link.source_table], 'data', None)
1692
+ parent = getattr(schema[link.target_table], 'data', None)
1693
+ if child is None or parent is None:
1694
+ return False
1695
+ if (
1696
+ link.source_column not in child.columns
1697
+ or link.target_column not in parent.columns
1698
+ ):
1699
+ return False
1700
+ values = child[link.source_column].dropna().unique()
1701
+ keys = parent[link.target_column].dropna().unique()
1702
+ if not len(values) or not len(keys):
1703
+ return False
1704
+ return not bool(pd.Series(values).isin(keys).any())
1705
+
1706
+
1707
+ def _warn_of_columns_the_model_does_not_read(
1708
+ schema: Schema, left_out: Mapping[str, Sequence[str]], sent: frozenset[str]
1709
+ ) -> None:
1710
+ r"""Say which feature columns reach the model and are dropped there.
1711
+
1712
+ The model's recipe carries no text processor, so a column typed `text` is
1713
+ ignored wherever it reaches it. Refusing the request was considered and
1714
+ rejected: a column the answer never depended on should not be a reason not
1715
+ to answer. That leaves the caller believing a column contributed when it
1716
+ did not, which is worth one line. Measured on one determining token, the
1717
+ same column scores 1.00 typed `categorical` and chance typed `text`.
1718
+
1719
+ Read against the columns the request actually carries. A column the query
1720
+ or the caller already left out is not sent, so naming it would report a
1721
+ loss that did not happen, the target among them.
1722
+
1723
+ `sent` is the same question asked of the table. A table nothing links, or
1724
+ one further out than the hops sampled, is not in the payload either, and
1725
+ naming its columns reported a loss that did not happen. Its remedy, to
1726
+ type it categorical, changes nothing under that hop budget. Why
1727
+ that table is missing is said by `_warn_of_tables_left_out`, which is the
1728
+ line a caller can act on.
1729
+ """
1730
+ unread = KUMO_RELATIONAL.runtime.unread_feature_stypes
1731
+ if not unread:
1732
+ return
1733
+ ignored = sorted(
1734
+ f'{table_name}.{column_name}'
1735
+ for table_name, table in schema.tables.items()
1736
+ if table_name in sent
1737
+ for column_name, column in table.columns.items()
1738
+ if column.stype in unread
1739
+ and column_name not in left_out.get(table_name, ())
1740
+ )
1741
+ if not ignored:
1742
+ return
1743
+ many = len(ignored) > 1
1744
+ warnings.warn(
1745
+ f'{", ".join(ignored)} {"are" if many else "is"} typed '
1746
+ f'{" or ".join(sorted(unread))}, which this model does not read, so '
1747
+ f'the request is answered without {"them" if many else "it"} and '
1748
+ f'{"they contribute" if many else "it contributes"} nothing to the '
1749
+ f'prediction. Type a column categorical if its values are labels '
1750
+ f'rather than prose, or leave it out to say so.',
1751
+ category=SDMWarning,
1752
+ stacklevel=caller_stacklevel(),
1753
+ )
1754
+
1755
+
1756
+ def _warn_the_filter_chose_only_the_examples(
1757
+ query: ValidatedPredictiveQuery, indices: Sequence[Any]
1758
+ ) -> None:
1759
+ r"""Say that an entity filter shapes the context and not the scored rows.
1760
+
1761
+ `FOR EACH sellers.seller_id WHERE sellers.seller_state = 'RJ'` reads as one
1762
+ question and is answered as two. The filter selects the labelled examples
1763
+ the model learns from; the rows actually scored are the caller's indices,
1764
+ untouched by it. Measured on synthetic data, a filter matching 12 of 50
1765
+ named sellers still scored all 50. The model was shown a thousand
1766
+ northern sellers and then asked about thirty-eight southern ones, with
1767
+ nothing said and predictions that look entirely ordinary.
1768
+
1769
+ Reported rather than repaired. Applying the filter here would mean
1770
+ evaluating the condition a second time, against the sampled neighbourhood
1771
+ the engine builds to evaluate it the first time, and two implementations of
1772
+ one condition disagreeing is a worse failure than the one being fixed.
1773
+ Naming the condition is what the caller needs to act, either by narrowing
1774
+ the indices or by dropping the filter.
1775
+ """
1776
+ if not indices or not isinstance(query.entity_ast, Filter):
1777
+ return
1778
+ warnings.warn(
1779
+ f'the entity filter {str(query.entity_ast.condition)!r} selects the '
1780
+ f'labelled examples the model learns from, and does not narrow the '
1781
+ f'{len(indices)} rows named for scoring. Those are scored as given, '
1782
+ f'whether or not they satisfy it. Pass only indices that satisfy the '
1783
+ f'condition if that is what you meant.',
1784
+ category=SDMWarning,
1785
+ stacklevel=caller_stacklevel(),
1786
+ )