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,870 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES.
2
+ # All rights reserved.
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ r"""Sending a relational request to ``kumo-relational``.
6
+
7
+ Unlike the tabular model, this one does heavy client-side work: building the
8
+ graph, resolving the query, sampling each entity's neighbourhood and encoding
9
+ the result. All of that happens in :meth:`PredictiveQuery.materialize`, which
10
+ runs in the engine, so by the time a request reaches this adapter its payloads
11
+ are already finished.
12
+
13
+ That split is why this adapter is short. It sends what was gathered, and reads
14
+ the answer back. It does not reimplement the gathering, which would duplicate
15
+ point-in-time correctness logic that already lives in the engine and is tested
16
+ there.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import copy
22
+ import dataclasses
23
+ import re
24
+ import uuid
25
+ import warnings
26
+ from collections.abc import Iterator, Mapping
27
+ from typing import Any
28
+
29
+ from sdm_api.generated.tfm_api import PredictionResponse, ResponseContractError
30
+
31
+ from sdm_client.base import ModelSpec
32
+ from sdm_client.core.warnings import SDMWarning, caller_stacklevel
33
+ from sdm_client.errors import (
34
+ WIDEN_THE_CONTEXT,
35
+ NimRequestError,
36
+ StructuredDataError,
37
+ UnsupportedRequestError,
38
+ )
39
+ from sdm_client.models import (
40
+ DEFAULT_PREDICTION_STATISTIC,
41
+ KUMO_RELATIONAL,
42
+ SUMMARISED_TASK_KINDS,
43
+ )
44
+ from sdm_client.request import Request
45
+ from sdm_client.result import PredictionResult
46
+ from sdm_client.wire import as_declared
47
+ from sdm_client.wire.answers import as_declared_target
48
+
49
+ # What to ask for when the caller named nothing, by task kind. Asking for
50
+ # everything would make a regression request carry probabilities it cannot
51
+ # have, which the endpoint rejects.
52
+ _DEFAULT_OUTPUTS = {
53
+ 'classification': ('prediction', 'probabilities'),
54
+ 'binary_classification': ('prediction', 'probabilities'),
55
+ 'multiclass_classification': ('prediction', 'probabilities'),
56
+ 'regression': ('prediction',),
57
+ 'forecasting': ('prediction',),
58
+ 'temporal_link_prediction': ('rankings',),
59
+ }
60
+
61
+ _SESSION_PINNED = ('model', 'task', 'schema', 'context')
62
+ #: Which halves of a request a call puts on the wire. Every judgement made
63
+ #: before sending asks only about a half that travels.
64
+ _SENDS_EVERYTHING = frozenset({'context', 'predict'})
65
+ _SENDS_A_CONTEXT = frozenset({'context'})
66
+ _SENDS_ROWS_TO_SCORE = frozenset({'predict'})
67
+
68
+ _SESSION_SCORING = ('predict', 'output', 'inference', 'metadata')
69
+
70
+
71
+ def _request_id() -> str:
72
+ return f'sdm-{uuid.uuid4().hex[:12]}'
73
+
74
+
75
+ def _keys_the_caller_set(requested: Any) -> set[str]:
76
+ r"""The inference-config keys that were asked for rather than defaulted.
77
+
78
+ The engine takes ``InferenceConfig | dict | None`` and this client
79
+ publishes nothing narrower, so a caller may hand over the dataclass. It is
80
+ not iterable, and reading it as a set of keys destroyed a request whose
81
+ payload was already gathered and correct.
82
+
83
+ A dataclass carries every field whether or not it was set, so only the
84
+ ones differing from their default are a request for anything. Counting the
85
+ rest would refuse a bare ``RegressionInferenceConfig()``, which asks for
86
+ exactly what passing no config at all asks for.
87
+ """
88
+ if isinstance(requested, Mapping):
89
+ return set(requested)
90
+ if dataclasses.is_dataclass(requested) and not isinstance(requested, type):
91
+ return {
92
+ field.name
93
+ for field in dataclasses.fields(requested)
94
+ if getattr(requested, field.name) != _default_of(field)
95
+ }
96
+ return set()
97
+
98
+
99
+ def _default_of(field: dataclasses.Field) -> Any:
100
+ if field.default_factory is not dataclasses.MISSING:
101
+ return field.default_factory()
102
+ return field.default
103
+
104
+
105
+ class RelationalAdapter:
106
+ r"""Shapes and reads ``kumo-relational`` requests."""
107
+
108
+ spec: ModelSpec = KUMO_RELATIONAL
109
+
110
+ def default_output(self, request: Request) -> tuple[str, ...]:
111
+ return _DEFAULT_OUTPUTS.get(request.task.kind, ('prediction',))
112
+
113
+ def prediction_plan(
114
+ self,
115
+ request: Request,
116
+ outputs: tuple[str, ...],
117
+ sends: frozenset[str] = _SENDS_EVERYTHING,
118
+ ) -> Iterator[dict[str, Any]]:
119
+ payloads = self._payloads(request)
120
+ self._preflight(payloads, outputs, request.query, sends)
121
+ for payload in payloads:
122
+ envelope = dict(payload)
123
+ envelope['model'] = self.spec.model_id
124
+ envelope['output'] = _output_for(payload, outputs)
125
+ # The gathered payload's own metadata, copied. Shared, writing the
126
+ # call's id into it wrote it into the request. Thus, planning the
127
+ # same request twice changed the id of the envelope already handed
128
+ # out, and the one a caller had recorded named a different call.
129
+ envelope['metadata'] = {
130
+ **(payload.get('metadata') or {}),
131
+ 'request_id': _request_id(),
132
+ }
133
+ inference = self._inference(envelope.get('inference'), request)
134
+ if inference is None:
135
+ envelope.pop('inference', None)
136
+ else:
137
+ envelope['inference'] = inference
138
+ yield envelope
139
+
140
+ def _inference(
141
+ self, inference: Any, request: Request
142
+ ) -> dict[str, Any] | None:
143
+ """Send only the inference options the caller set and this model reads.
144
+
145
+ The engine fills in an inference config on every request, including
146
+ keys this client does not forward: `output_type`, whose choice travels
147
+ as ``output.prediction_statistic`` instead, and `target_transforms`.
148
+ Dropping the engine's defaults is safe. If the *caller* asked for one,
149
+ it is named rather than silently discarded, because a dropped request
150
+ for four estimators is a different prediction than the one that was
151
+ wanted.
152
+
153
+ Forwarding a key is not a promise that the endpoint serves it. This is
154
+ a name-level filter over what the engine sends; whether a deployment
155
+ accepts the *value* is its own answer, and it may still refuse.
156
+ """
157
+ if not isinstance(inference, dict):
158
+ return None
159
+ config = inference.get('inference_config')
160
+ if not isinstance(config, dict):
161
+ return dict(inference)
162
+
163
+ forwarded = self.spec.runtime.inference_options
164
+ unsupported = sorted(set(config) - forwarded)
165
+ asked_for = _keys_the_caller_set(
166
+ request.requested_inference.get('inference_config')
167
+ )
168
+ refused = sorted(set(unsupported) & asked_for)
169
+ if refused:
170
+ raise UnsupportedRequestError(
171
+ f'{self.spec.model_id} does not read inference option(s) '
172
+ f'{refused}; drop them from inference_config to send the '
173
+ f'request without them. The keys this client forwards are '
174
+ f'{sorted(forwarded)}, and the endpoint decides for itself '
175
+ f'whether it serves the value given for one'
176
+ + _where_the_statistic_is_chosen(refused),
177
+ details={
178
+ 'model': self.spec.model_id,
179
+ 'unsupported': refused,
180
+ },
181
+ )
182
+
183
+ # Only what the caller set: a deployment applies the engine's defaults
184
+ # to a request that carries none, and the SDM backends refuse any
185
+ # non-empty `inference_config`, so sending the defaults broke them.
186
+ trimmed = dict(inference)
187
+ sent = {key: value for key, value in config.items() if key in asked_for}
188
+ if sent:
189
+ trimmed['inference_config'] = sent
190
+ else:
191
+ del trimmed['inference_config']
192
+ return trimmed
193
+
194
+ def session_plan(
195
+ self, request: Request, outputs: tuple[str, ...]
196
+ ) -> dict[str, Any]:
197
+ payloads = self._payloads(request)
198
+ if len(payloads) > 1:
199
+ # A session pins one context. Several gathered parts each carry
200
+ # their own, so pinning the first would score later batches
201
+ # against the wrong one.
202
+ raise UnsupportedRequestError(
203
+ f'this request gathered {len(payloads)} batches, and a session '
204
+ f'pins one context; use predict() instead',
205
+ details={'batches': len(payloads)},
206
+ )
207
+ envelope = next(
208
+ iter(self.prediction_plan(request, outputs, _SENDS_A_CONTEXT))
209
+ )
210
+ pinned = {
211
+ key: envelope[key] for key in _SESSION_PINNED if key in envelope
212
+ }
213
+ pinned['metadata'] = envelope['metadata']
214
+ # The gathered envelopes are the request's own, and a plan built by
215
+ # naming their keys shares them. A holdout edits the pinned context in
216
+ # place, so it was editing the request: a second fit of the same
217
+ # request, with no holdout, pinned the already-reduced context.
218
+ if 'context' in pinned:
219
+ pinned['context'] = copy.deepcopy(pinned['context'])
220
+ return pinned
221
+
222
+ def session_prediction_plan(
223
+ self,
224
+ request: Request,
225
+ outputs: tuple[str, ...],
226
+ pinned: Mapping[str, Any] | None = None,
227
+ ) -> Iterator[dict[str, Any]]:
228
+ for envelope in self.prediction_plan(
229
+ request, outputs, _SENDS_ROWS_TO_SCORE
230
+ ):
231
+ yield {
232
+ key: envelope[key]
233
+ for key in _SESSION_SCORING
234
+ if key in envelope
235
+ }
236
+
237
+ def normalize(
238
+ self,
239
+ response: dict[str, Any],
240
+ outputs: tuple[str, ...],
241
+ payload: dict[str, Any] | None = None,
242
+ ) -> PredictionResult:
243
+ try:
244
+ parsed = PredictionResponse.from_dict(response)
245
+ except (ResponseContractError, TypeError, ValueError) as error:
246
+ raise StructuredDataError(
247
+ f'{self.spec.model_id} returned a response that does not '
248
+ f'match the contract: {error}',
249
+ code='INVALID_RESPONSE',
250
+ ) from error
251
+
252
+ explanation = next(
253
+ (
254
+ item.explanation
255
+ for item in parsed.predictions
256
+ if item.explanation
257
+ ),
258
+ None,
259
+ )
260
+ return PredictionResult(
261
+ model=parsed.model,
262
+ request_id=parsed.id,
263
+ items=as_declared_target(
264
+ _named_entities(parsed.predictions, payload),
265
+ payload,
266
+ ),
267
+ metadata=parsed.metadata,
268
+ outputs=outputs,
269
+ explanation=explanation,
270
+ )
271
+
272
+ def explain_error(
273
+ self, error: Exception, payload: dict[str, Any]
274
+ ) -> Exception:
275
+ r"""Name the columns behind a semantic-type refusal.
276
+
277
+ ``semantic type 'text' is unsupported`` is true and unactionable:
278
+ ``Schema.from_data`` marks any string column with more than 64 distinct
279
+ values as ``text``, so a real dataset acquires them in bulk and the
280
+ endpoint names none of them. The payload it just refused does.
281
+
282
+ The deployments word this two ways and only one of them was matched, so
283
+ on the one that says ``categorical cardinality 13812 exceeds limit
284
+ 10000`` the hook never ran and the caller got a number and a limit
285
+ against a graph of twenty-two string columns. Both forms are read now,
286
+ and the second names the columns by counting the payload rather than by
287
+ reading a declared type, because a cardinality complaint is about the
288
+ values.
289
+
290
+ A different complaint needs a different remedy. Where the columns are
291
+ already categorical, telling their owner to make them categorical
292
+ would be advice that changes nothing.
293
+ """
294
+ columns, complaint = _columns_the_endpoint_means(error, payload)
295
+ if not columns:
296
+ return error
297
+ if complaint == 'cardinality':
298
+ message = (
299
+ f'{getattr(error, "message", error)}. In this request that is '
300
+ f'{", ".join(columns)}. A column with that many distinct '
301
+ f'values carries no signal the model can use; leave it out '
302
+ f'with materialize(..., exclude_cols_dict=...), or replace it '
303
+ f'with something coarser.'
304
+ )
305
+ return _reworded(error, message)
306
+ # The payload does not carry the entity table's real name,
307
+ # `instance_table` is what the wire calls it. Thus, an example naming
308
+ # it would render a line the caller cannot run. Prefer a related
309
+ # table, whose name did survive, and fall back to naming no table at
310
+ # all rather than a made-up one.
311
+ named = next(
312
+ (c for c in columns if not c.startswith('instance_table.')), None
313
+ )
314
+ if named is not None:
315
+ table, _, column = named.partition('.')
316
+ suggestion = f"schema[{table!r}][{column!r}].stype = 'categorical'"
317
+ else:
318
+ _, _, column = columns[0].partition('.')
319
+ suggestion = (
320
+ f"schema[<the entity table>][{column!r}].stype = 'categorical'"
321
+ )
322
+ message = (
323
+ f'{getattr(error, "message", error)}. In this request that is '
324
+ f'{", ".join(columns)}. Set a supported type on each, e.g. '
325
+ f'{suggestion}'
326
+ )
327
+ return _reworded(error, message)
328
+
329
+ def close(self) -> None:
330
+ r"""Nothing to release: gathering happens in the query, which owns
331
+ whatever it opened, and sending goes through the client's transport."""
332
+
333
+ def _preflight(
334
+ self,
335
+ payloads: tuple[dict[str, Any], ...],
336
+ outputs: tuple[str, ...],
337
+ asked: str | None = None,
338
+ sends: frozenset[str] = _SENDS_EVERYTHING,
339
+ ) -> None:
340
+ """Refuse what the endpoint enforces, before spending the round trip.
341
+
342
+ An explanation is the narrow case: the model explains one entity at a
343
+ time, and only in the mode the smaller context budget selects. Sending
344
+ more gets a 500 that names neither limit, so both are checked here.
345
+
346
+ `sends` names the halves this call puts on the wire. A session pins its
347
+ context at `fit()` and a scoring call carries only rows, so judging the
348
+ context there refuses a request over rows that are thrown away, while
349
+ judging the rows when pinning refuses over rows that are not sent at
350
+ all. Every check below asks only about a half that travels.
351
+ """
352
+ limits = self.spec.runtime.limits
353
+ if 'context' in sends:
354
+ self._check_context_is_not_empty(payloads)
355
+ self._check_class_count(payloads, limits)
356
+ self._check_target_varies(payloads, asked)
357
+ if 'explanation' not in outputs:
358
+ return
359
+
360
+ scored = sum(
361
+ len(
362
+ payload.get('predict', {})
363
+ .get('instance_table', {})
364
+ .get('rows', [])
365
+ )
366
+ for payload in payloads
367
+ )
368
+ allowed = limits['explanation_predict_rows']
369
+ if 'predict' in sends and scored != allowed:
370
+ raise UnsupportedRequestError(
371
+ f'an explanation covers exactly {allowed} entity, and this '
372
+ f'request scores {scored}; ask for one at a time',
373
+ details={'model': self.spec.model_id, 'scored': scored},
374
+ )
375
+
376
+ cap = limits['context_rows_fast']
377
+ if 'context' in sends and _asks_for_the_larger_context(payloads, cap):
378
+ raise UnsupportedRequestError(
379
+ f'an explanation is only produced in the mode the smaller '
380
+ f'context budget selects, and this request was gathered with '
381
+ f'num_context_samples above {cap:,}. Re-materialize with '
382
+ f'num_context_samples={cap} or fewer',
383
+ details={
384
+ 'model': self.spec.model_id,
385
+ 'context_rows_fast': cap,
386
+ },
387
+ )
388
+
389
+ def _check_class_count(
390
+ self, payloads: tuple[dict[str, Any], ...], limits: Mapping[str, int]
391
+ ) -> None:
392
+ r"""A classification target the model cannot represent is a size too.
393
+
394
+ The endpoint answers ``422 UNSUPPORTED_FEATURE`` with both numbers in
395
+ it, which is a decent message. However, it arrives after gathering,
396
+ encoding, and sending the context, and the count is knowable here.
397
+ """
398
+ ceiling = limits.get('classes_max')
399
+ if not ceiling:
400
+ return
401
+ for payload in payloads:
402
+ kind = str(payload.get('task', {}).get('kind', ''))
403
+ if 'classification' not in kind:
404
+ continue
405
+ column = (
406
+ payload.get('task', {}).get('target', {}).get('column_name')
407
+ )
408
+ labels = _distinct_labels(payload, column)
409
+ if labels is not None and labels > ceiling:
410
+ raise UnsupportedRequestError(
411
+ f'{self.spec.model_id} classifies at most {ceiling} '
412
+ f'classes, and the gathered context carries {labels} '
413
+ f'distinct values of the target; predict a coarser '
414
+ f'target, or narrow the context to the classes you care '
415
+ f'about',
416
+ details={
417
+ 'model': self.spec.model_id,
418
+ 'classes': labels,
419
+ 'limit': ceiling,
420
+ },
421
+ )
422
+
423
+ def _check_context_is_not_empty(
424
+ self, payloads: tuple[dict[str, Any], ...]
425
+ ) -> None:
426
+ r"""Refuse a context this client emptied, rather than sending it.
427
+
428
+ Scoring every row of the entity table takes every row out of the
429
+ context. One row left is refused; none fell through the same check,
430
+ because zero distinct answers is not one.
431
+ """
432
+ for payload in payloads:
433
+ table = payload.get('context', {}).get('instance_table')
434
+ if not isinstance(table, dict) or table.get('rows'):
435
+ continue
436
+ raise UnsupportedRequestError(
437
+ 'no context rows are left: every row that could be context is '
438
+ 'also being scored, and a row carrying the answer being asked '
439
+ 'for is not sent as context. Score fewer rows, so that the '
440
+ 'rest are there to learn from',
441
+ details={'model': self.spec.model_id, 'context_rows': 0},
442
+ )
443
+
444
+ def _check_target_varies(
445
+ self, payloads: tuple[dict[str, Any], ...], asked: str | None = None
446
+ ) -> None:
447
+ r"""Refuse or note a context whose every row says the same thing.
448
+
449
+ The tabular route judges this before it sends and the relational one
450
+ said nothing, so the same unlearnable question was refused by one model
451
+ and answered by the other. The gathered payload carries the evidence
452
+ either way: the target column of the context it is about to send.
453
+
454
+ A classification over one class is refused by the endpoint, so it is
455
+ refused here. A regression over a constant is accepted there, so it is
456
+ only warned about. A forecast and a link prediction are neither: their
457
+ targets are a horizon and a candidate list, and one value in the column
458
+ does not mean the same thing.
459
+
460
+ The wire calls this column `TARGET` whatever the question was, and a
461
+ message naming it describes something the caller never wrote. Where
462
+ the target is a column of theirs the query says so and it is named;
463
+ where it is an aggregation or a predicate there is no column to name,
464
+ and the question is quoted instead.
465
+ """
466
+ for payload in payloads:
467
+ kind = str(payload.get('task', {}).get('kind', ''))
468
+ if 'classification' not in kind and kind != 'regression':
469
+ continue
470
+ column = (
471
+ payload.get('task', {}).get('target', {}).get('column_name')
472
+ )
473
+ values = _answered_target_values(payload, column)
474
+ if values is None or len(values) != 1:
475
+ continue
476
+ said = _how_to_name_the_target(column, asked)
477
+ if 'classification' in kind:
478
+ raise UnsupportedRequestError(
479
+ f'every context row has the same {said}, and a '
480
+ f'classification needs at least two classes to learn '
481
+ f'from. {WIDEN_THE_CONTEXT}',
482
+ details={
483
+ 'model': self.spec.model_id,
484
+ 'target': column,
485
+ 'classes_in_context': 1,
486
+ },
487
+ )
488
+ warnings.warn(
489
+ f'every context row has the same {said}, so nothing in the '
490
+ f'context distinguishes one answer from another. The endpoint '
491
+ f'will answer, but the answer cannot say much. Gather a wider '
492
+ f'context with num_context_samples, or ask about entities '
493
+ f'whose answers differ.',
494
+ category=SDMWarning,
495
+ stacklevel=caller_stacklevel(),
496
+ )
497
+ return
498
+
499
+ @staticmethod
500
+ def _payloads(request: Request) -> tuple[dict[str, Any], ...]:
501
+ payloads = request.payloads
502
+ if not payloads:
503
+ raise UnsupportedRequestError(
504
+ 'a relational request has to be gathered first, and this '
505
+ 'one was not. Build it with '
506
+ 'PredictiveQuery(...).materialize(indices=[...], '
507
+ "model='kumo-relational'). A schema of one table is "
508
+ 'gathered for the tabular model unless the relational one is '
509
+ 'named, rather than with Request(context=..., '
510
+ 'predict=...), which is the single-table form',
511
+ details={'model': KUMO_RELATIONAL.model_id},
512
+ )
513
+ return payloads
514
+
515
+
516
+ def _output_for(
517
+ payload: Mapping[str, Any], outputs: tuple[str, ...]
518
+ ) -> dict[str, Any]:
519
+ r"""The output section of one call: its fields, and for a regression the
520
+ statistic its point prediction reports, the median unless one was chosen."""
521
+ output: dict[str, Any] = {'fields': list(outputs)}
522
+ if str(payload.get('task', {}).get('kind', '')) in SUMMARISED_TASK_KINDS:
523
+ chosen = (payload.get('output') or {}).get('prediction_statistic')
524
+ output['prediction_statistic'] = chosen or DEFAULT_PREDICTION_STATISTIC
525
+ return output
526
+
527
+
528
+ def _where_the_statistic_is_chosen(refused: list[str]) -> str:
529
+ r"""Point an `output_type` at the option that replaced it."""
530
+ if 'output_type' not in refused:
531
+ return ''
532
+ return (
533
+ f". A regression's statistic is chosen with materialize(..., "
534
+ f'prediction_statistic=...) instead: '
535
+ f"{DEFAULT_PREDICTION_STATISTIC!r}, the default, or 'mean'"
536
+ )
537
+
538
+
539
+ _UNSUPPORTED_STYPE = re.compile(r"semantic type '([^']+)' is unsupported")
540
+
541
+ #: The other wording, from the deployment that counts rather than names.
542
+ _TOO_MANY_LABELS = re.compile(
543
+ r'cardinality (\d+) exceeds limit (\d+)', re.IGNORECASE
544
+ )
545
+
546
+
547
+ def _columns_the_endpoint_means(
548
+ error: Exception, payload: Mapping[str, Any]
549
+ ) -> tuple[list[str], str]:
550
+ r"""The columns a refusal is about, and which complaint it was."""
551
+ said = str(error)
552
+ named = _UNSUPPORTED_STYPE.search(said)
553
+ if named is not None:
554
+ return _columns_with_stype(payload, named.group(1)), 'stype'
555
+ counted = _TOO_MANY_LABELS.search(said)
556
+ if counted is None:
557
+ return [], ''
558
+ return _columns_over(payload, int(counted.group(2))), 'cardinality'
559
+
560
+
561
+ def _reworded(error: Exception, message: str) -> Exception:
562
+ r"""The same refusal, saying more."""
563
+ if isinstance(error, NimRequestError):
564
+ return NimRequestError(
565
+ error.status_code,
566
+ code=error.code,
567
+ message=message,
568
+ details=error.details,
569
+ )
570
+ rebuilt = StructuredDataError(
571
+ message,
572
+ code=getattr(error, 'code', None),
573
+ details=getattr(error, 'details', None),
574
+ )
575
+ status = getattr(error, 'status_code', None)
576
+ if status is not None:
577
+ # `fit()` reads this to decide whether an endpoint simply does not
578
+ # serve sessions; losing it turns a clean fallback into a failure.
579
+ rebuilt.status_code = status
580
+ return rebuilt
581
+
582
+
583
+ def _columns_over(payload: Mapping[str, Any], limit: int) -> list[str]:
584
+ r"""Every ``table.column`` holding more distinct values than ``limit``.
585
+
586
+ The endpoint reports one number and the limit it broke, which says nothing
587
+ about where to look. The rows it just refused do.
588
+
589
+ Counted by the engine's own helper rather than here. What the guard counts
590
+ is not every cell: it is string cells only, across the predict half as well
591
+ as the context, reduced to the larger of the two. A text cell travels
592
+ as a list of tokens, which it counts individually. Counting it a second way
593
+ here would name columns the endpoint was not talking about, a numeric key
594
+ among them.
595
+ """
596
+ try:
597
+ from sdm_relational_engine.rfm.inference.payload import (
598
+ high_cardinality_columns,
599
+ )
600
+ except ImportError: # pragma: no cover - the extra is optional
601
+ return []
602
+ return [
603
+ f'{table}.{column} ({count:,} values)'
604
+ for table, column, count in high_cardinality_columns(
605
+ dict(payload), limit
606
+ )
607
+ ]
608
+
609
+
610
+ def _asks_for_the_larger_context(
611
+ payloads: tuple[dict[str, Any], ...], cap: int
612
+ ) -> bool:
613
+ r"""Whether this request selected the mode that will not explain.
614
+
615
+ The endpoint gates explaining on ``inference.run_mode``, which the client
616
+ derives from the *requested* ``num_context_samples``. Counting the gathered
617
+ rows instead misses every graph that supplies less context than was asked
618
+ for: 120 rows gathered under a request for 1,500 passed this guard and the
619
+ endpoint's refusal escaped, naming a knob the caller never set.
620
+
621
+ A payload the engine did not gather carries no ``run_mode``, and for that
622
+ one the rows are all there is to read.
623
+ """
624
+ modes = {
625
+ str(payload['inference']['run_mode'])
626
+ for payload in payloads
627
+ if isinstance(payload.get('inference'), Mapping)
628
+ and payload['inference'].get('run_mode') is not None
629
+ }
630
+ if modes:
631
+ return modes != {'fast'}
632
+ return (
633
+ sum(
634
+ len(
635
+ payload.get('context', {})
636
+ .get('instance_table', {})
637
+ .get('rows', [])
638
+ )
639
+ for payload in payloads
640
+ )
641
+ > cap
642
+ )
643
+
644
+
645
+ _WIRE_TARGET = 'TARGET'
646
+ _A_PREDICTED_COLUMN = re.compile(
647
+ r'^\s*PREDICT\s+([A-Za-z_]\w*\.[A-Za-z_]\w*)\s', re.IGNORECASE
648
+ )
649
+
650
+
651
+ def _how_to_name_the_target(column: str | None, asked: str | None) -> str:
652
+ """What to call the thing every context row agrees about.
653
+
654
+ `TARGET` is the name the relational wire gives the label column whatever
655
+ the question was, so printing it describes a column of nobody's data.
656
+ """
657
+ if column != _WIRE_TARGET:
658
+ return str(column)
659
+ named = _A_PREDICTED_COLUMN.match(asked or '')
660
+ if named is not None:
661
+ return named.group(1)
662
+ return f'answer to {asked!r}' if asked else 'answer'
663
+
664
+
665
+ def _answered_target_values(
666
+ payload: Mapping[str, Any], column: Any
667
+ ) -> set[Any] | None:
668
+ r"""The distinct answers the context holds for ``column``, nulls aside.
669
+
670
+ ``None`` when the context does not carry the target at all, or carries no
671
+ answered row: neither is a context that teaches one thing.
672
+ """
673
+ table = payload.get('context', {}).get('instance_table', {})
674
+ columns = table.get('columns') or []
675
+ rows = table.get('rows') or []
676
+ if not columns or not rows or column not in columns:
677
+ return None
678
+ position = columns.index(column)
679
+ answered = {
680
+ row[position]
681
+ for row in rows
682
+ if len(row) > position and row[position] is not None
683
+ }
684
+ return answered or None
685
+
686
+
687
+ def _distinct_labels(payload: Mapping[str, Any], column: Any) -> int | None:
688
+ r"""How many distinct values the context holds for ``column``."""
689
+ table = payload.get('context', {}).get('instance_table', {})
690
+ columns = table.get('columns') or []
691
+ rows = table.get('rows') or []
692
+ if not columns or not rows or column not in columns:
693
+ return None
694
+ position = columns.index(column)
695
+ return len({row[position] for row in rows if len(row) > position})
696
+
697
+
698
+ def _columns_with_stype(payload: Mapping[str, Any], stype: str) -> list[str]:
699
+ r"""Every ``table.column`` in the payload declared as ``stype``.
700
+
701
+ The endpoint refuses an unsupported semantic type without saying which
702
+ column carries it, and a real dataset acquires them in bulk. Northwind
703
+ produces fifteen. The payload that was just refused knows.
704
+
705
+ A relational schema is ``instance_table`` plus ``related_tables``, so both
706
+ shapes are walked; the instance table's own name is not in the payload, and
707
+ ``instance_table`` is what the wire calls it.
708
+ """
709
+ found = []
710
+ schema = payload.get('schema') or {}
711
+ tables = {'instance_table': schema.get('instance_table') or {}}
712
+ tables.update(schema.get('related_tables') or {})
713
+ for table_name, table in tables.items():
714
+ if not isinstance(table, Mapping):
715
+ continue
716
+ for column_name, spec in (table.get('columns') or {}).items():
717
+ if isinstance(spec, Mapping) and spec.get('stype') == stype:
718
+ found.append(f'{table_name}.{column_name}')
719
+ return sorted(found)
720
+
721
+
722
+ def _named_entities(
723
+ items: tuple[Any, ...], payload: dict[str, Any] | None
724
+ ) -> list[Any]:
725
+ """Put the caller's entity back on each row, and check it belongs there.
726
+
727
+ Entity values never travel: the engine sends an opaque reference and an
728
+ internal instance id, so a response's ``id`` is the transport key rather
729
+ than the user's key. The reference column carries the entity and the
730
+ instance-id column carries the key the endpoint answers under, so the two
731
+ together restore the mapping. It is read from the payload that produced
732
+ this response, so a run split into several calls maps each batch against
733
+ its own rows.
734
+
735
+ **A mismatch is an error, not a silent pass.** Rewriting an id is exactly
736
+ the operation that can misattribute an answer, so every row is checked
737
+ against the batch it claims to belong to before its id is replaced.
738
+
739
+ Forecasting is the one task that answers a row more than once, and only
740
+ then: a row may repeat across steps, but not within one.
741
+ """
742
+ if payload is None:
743
+ return list(items)
744
+ table = payload.get('predict', {}).get('instance_table', {})
745
+ columns = table.get('columns') or []
746
+ rows = table.get('rows') or []
747
+ reference = next(
748
+ (name for name in columns if name.startswith('__kumo_entity_ref')),
749
+ None,
750
+ )
751
+ if reference is None:
752
+ return list(items)
753
+
754
+ entities = [
755
+ as_declared(
756
+ row[columns.index(reference)], _key_dtype(payload, reference)
757
+ )
758
+ for row in rows
759
+ ]
760
+ instances = (
761
+ [row[columns.index('instance_id')] for row in rows]
762
+ if 'instance_id' in columns
763
+ else None
764
+ )
765
+ forecasting = _forecast_task(payload)
766
+
767
+ named: list[Any] = []
768
+ seen: set[tuple[int, int | None]] = set()
769
+ for item in items:
770
+ index = item.row_index
771
+ if index is None:
772
+ raise StructuredDataError(
773
+ 'the response is missing a row_index, so its answer cannot be '
774
+ 'attributed to an entity',
775
+ code='INVALID_RESPONSE',
776
+ )
777
+ if not 0 <= index < len(entities):
778
+ raise StructuredDataError(
779
+ f'the response names row_index {index}, and this batch asked '
780
+ f'about {len(entities)} rows',
781
+ code='INVALID_RESPONSE',
782
+ )
783
+
784
+ step = item.forecast_step
785
+ if forecasting:
786
+ if step is None:
787
+ raise StructuredDataError(
788
+ f'a forecast answer for row {index} names no step, so its '
789
+ f'place in the horizon is unknown',
790
+ code='INVALID_RESPONSE',
791
+ )
792
+ elif step is not None:
793
+ raise StructuredDataError(
794
+ f'a {payload.get("task", {}).get("kind")!r} answer carries a '
795
+ f'forecast step, which only a forecast has',
796
+ code='INVALID_RESPONSE',
797
+ )
798
+
799
+ key = (index, step)
800
+ if key in seen:
801
+ where = (
802
+ f'row {index} step {step}' if forecasting else f'row {index}'
803
+ )
804
+ raise StructuredDataError(
805
+ f'the response answers {where} more than once',
806
+ code='INVALID_RESPONSE',
807
+ )
808
+ seen.add(key)
809
+
810
+ if instances is not None:
811
+ if item.id is None:
812
+ raise StructuredDataError(
813
+ f'the answer for row {index} carries no id, and this batch '
814
+ f'asked under {instances[index]!r}, so the two cannot be '
815
+ f'matched',
816
+ code='INVALID_RESPONSE',
817
+ )
818
+ if str(item.id) != str(instances[index]):
819
+ raise StructuredDataError(
820
+ f'the response pairs row_index {index} with id '
821
+ f'{item.id!r}, and this batch asked about '
822
+ f'{instances[index]!r} there',
823
+ code='INVALID_RESPONSE',
824
+ )
825
+ named.append(dataclasses.replace(item, id=entities[index]))
826
+
827
+ answered = {index for index, _ in seen}
828
+ if answered != set(range(len(entities))):
829
+ missing = sorted(set(range(len(entities))) - answered)
830
+ raise StructuredDataError(
831
+ f'the response left row(s) {missing} unanswered',
832
+ code='INVALID_RESPONSE',
833
+ )
834
+ return named
835
+
836
+
837
+ def _forecast_task(payload: dict[str, Any]) -> bool:
838
+ """Whether this batch asked a forecast.
839
+
840
+ Read from the request rather than from the shape of the answer. Deciding
841
+ it from the answer let a single item carrying a step switch off the
842
+ duplicate guard for every other row in the response.
843
+ """
844
+ return payload.get('task', {}).get('kind') == 'forecasting'
845
+
846
+
847
+ def _key_dtype(payload: dict[str, Any], reference: str) -> str | None:
848
+ r"""The dtype of the column the entity reference stands for.
849
+
850
+ An integer wider than a double travels as a base-10 string by contract, so
851
+ a key past 2**53 comes back as text unless what it was is looked up. The
852
+ reference column itself is declared `string` because it is a transport key.
853
+ The answer is therefore on the table it points at.
854
+ """
855
+ schema = payload.get('schema', {})
856
+ for link in schema.get('relationships', []):
857
+ if list(link.get('source_columns') or []) != [reference]:
858
+ continue
859
+ target = link.get('target_table')
860
+ columns = link.get('target_columns') or []
861
+ if not target or len(columns) != 1:
862
+ return None
863
+ return (
864
+ schema.get('related_tables', {})
865
+ .get(target, {})
866
+ .get('columns', {})
867
+ .get(columns[0], {})
868
+ .get('dtype')
869
+ )
870
+ return None