From c09e78e46ddcd17344e37efd97d0c95511994dbd Mon Sep 17 00:00:00 2001 From: Manush Murali Date: Fri, 16 Oct 2026 15:53:23 -0700 Subject: [PATCH 01/17] Run the models in this process, through the same calls as a NIM SDMClient.in_process() answers the Universal TFM contract with structured-data-models in the caller's own Python process. The client's adapters send what they would send a NIM and read back what a NIM answers, so predictions, sessions, rankings and forecasts work unchanged: only the transport differs, as it already does for Databricks Model Serving. The request is read the way the SDM NIM reads it. Wire tables are decoded by their declared dtypes, each relational neighbourhood is scoped by its instance_id, and the context is fitted on a copy of the model with the NIM's seed, one estimator and, on a GPU, its autocast precision. Against the current NIM code served on the CPU with the same published weights, every prediction, probability and quantile matched exactly, on synthetic graphs and on Adult, Wine, Titanic, Bank, Students and Northwind, and every refusal carried the same status and code. qa/probe_in_process.py repeats that comparison. A model passed in is only used: each fit is on a shallow copy that shares its weights, so a context the caller fitted on it survives. With none, each model is loaded from its published weights on first use. structured-data-models is public, so the hygiene rule that treated its name as an internal build now covers only the one that still is. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Manush Murali --- .github/workflows/ci.yml | 9 +- CHANGELOG.md | 11 + README.md | 5 +- docs/about/architecture.md | 1 + docs/get-started/installation.md | 1 + docs/get-started/prerequisites.md | 4 +- docs/get-started/quickstart.md | 30 ++ .../pyproject.toml | 6 + .../src/sdm_client/client.py | 43 +- .../src/sdm_client/in_process/__init__.py | 14 + .../src/sdm_client/in_process/answers.py | 156 ++++++ .../src/sdm_client/in_process/columns.py | 183 +++++++ .../src/sdm_client/in_process/refusal.py | 34 ++ .../src/sdm_client/in_process/relational.py | 240 +++++++++ .../src/sdm_client/in_process/tables.py | 95 ++++ .../src/sdm_client/in_process/tabular.py | 89 ++++ .../src/sdm_client/in_process/transport.py | 467 ++++++++++++++++++ .../test_a_model_runs_in_this_process.py | 386 +++++++++++++++ qa/README.md | 1 + qa/probe_in_process.py | 121 +++++ scripts/check_repo_hygiene.py | 13 +- tests/golden/public_api.json | 1 + tests/test_release_contract.py | 6 +- 23 files changed, 1898 insertions(+), 18 deletions(-) create mode 100644 packages/structured-data-models-client/src/sdm_client/in_process/__init__.py create mode 100644 packages/structured-data-models-client/src/sdm_client/in_process/answers.py create mode 100644 packages/structured-data-models-client/src/sdm_client/in_process/columns.py create mode 100644 packages/structured-data-models-client/src/sdm_client/in_process/refusal.py create mode 100644 packages/structured-data-models-client/src/sdm_client/in_process/relational.py create mode 100644 packages/structured-data-models-client/src/sdm_client/in_process/tables.py create mode 100644 packages/structured-data-models-client/src/sdm_client/in_process/tabular.py create mode 100644 packages/structured-data-models-client/src/sdm_client/in_process/transport.py create mode 100644 packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py create mode 100644 qa/probe_in_process.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c55267dbb..5fcd3895c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -160,10 +160,13 @@ jobs: - run: | cd packages/structured-data-models-relational-engine python -m pytest tests -q -m 'not live_nim and not live_warehouse' - # The client's suite again, with an engine present. The test job installs - # none, so every client test that needs one skips there and runs nowhere. + # The client's suite again, with an engine present and the models able to + # run in process. The test job installs neither, so every client test + # that needs one skips there and runs nowhere. PyTorch comes from its CPU + # index, since the default one resolves to a far larger CUDA build. - run: | - pip install -e './packages/structured-data-models-client[test]' + pip install torch --index-url https://download.pytorch.org/whl/cpu + pip install -e './packages/structured-data-models-client[test,in-process]' python -m pytest packages/structured-data-models-client/tests -q \ -m 'not live_nim and not live_warehouse' # And the root suite, which holds the relational wire snapshots. They diff --git a/CHANGELOG.md b/CHANGELOG.md index 5eb62fc07..42db4af6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ versioned by [semantic versioning](https://semver.org) as [`docs/reference/stability.md`](docs/reference/stability.md) defines it for these packages. All five distributions share one version and one entry. +## Unreleased + +### Added + +- `SDMClient.in_process()` runs `kumo-tabular` and `kumo-relational` in this + Python process with structured-data-models, through the same calls as a NIM: + predictions, fitted sessions, rankings and forecasts. It loads each model + from its published weights on first use, or uses one passed in. The context + is fitted as the NIM fits it, and a request the NIM refuses is refused with + the same `NimRequestError`. Install it with the `in-process` extra. + ## 1.0.0 - 2026-10-15 First public release. The five distributions are versioned and published diff --git a/README.md b/README.md index d0bbf2dd3..f6a173148 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,9 @@ manage. This client talks to those models. It builds the request, sends it to a NIM inference endpoint, and gives you back a `PredictionResult`, which `to_pandas()` turns into a DataFrame. Inference runs in the NIM, so this -package downloads no weights and needs no GPU. +package downloads no weights and needs no GPU. To run the model in your own +Python process instead, with the same calls, open the client with +`SDMClient.in_process()`. Two models are available through the client: @@ -68,6 +70,7 @@ The [quickstart](docs/get-started/quickstart.md) runs this end to end, and | `pip install 'structured-data-models-client[relational]'` | Adds the relational model and its native engine | | `pip install 'structured-data-models-client[snowflake]'` | Reads tables from a warehouse. Also `[sqlite]`, `[duckdb]`, `[postgres]`, `[databricks]`, `[lakebase]`, `[s3]` | | `pip install 'structured-data-models-client[all]'` | The relational model and every warehouse backend | +| `pip install 'structured-data-models-client[in-process]'` | Runs the models in this Python process, through `SDMClient.in_process()` | [Installation](docs/get-started/installation.md) has the rest, including what differs on Databricks. diff --git a/docs/about/architecture.md b/docs/about/architecture.md index 9123db87b..24d708811 100644 --- a/docs/about/architecture.md +++ b/docs/about/architecture.md @@ -131,6 +131,7 @@ pip install structured-data-models-client # tabular, PQL, tran pip install 'structured-data-models-client[relational]' # adds the native relational engine pip install 'structured-data-models-client[snowflake]' # one backend (also duckdb/sqlite/postgres/databricks/s3) pip install 'structured-data-models-client[all]' # relational + every backend +pip install 'structured-data-models-client[in-process]' # runs the models in this process ``` `[explain]` and `[relbench]` are the engine's own extras, not the client's, so diff --git a/docs/get-started/installation.md b/docs/get-started/installation.md index 577cd6ffd..3f0792310 100644 --- a/docs/get-started/installation.md +++ b/docs/get-started/installation.md @@ -7,6 +7,7 @@ | `pip install 'structured-data-models-client[snowflake]'` | Reads tables from a warehouse. Also `[sqlite]`, `[duckdb]`, `[postgres]`, `[databricks]`, `[lakebase]`, `[s3]`. | | `pip install 'structured-data-models-client[all]'` | The relational model and every warehouse backend. | | `pip install 'structured-data-models-client[databricks-serving]'` | Reaches a model served on Databricks Model Serving, through `SDMClient.for_databricks_serving`. Not in `[all]`: it is a way to reach a model, not a backend. | +| `pip install 'structured-data-models-client[in-process]'` | Runs the models in this Python process with structured-data-models, through `SDMClient.in_process`. Brings PyTorch. **Python 3.11 or later**: on 3.10 pip reports `No matching distribution found for structured-data-models`. Not in `[all]`, for the same reason as `[databricks-serving]`. | **What you install is not what you import.** The five distributions are `structured-data-models-client`, `-api`, `-pql`, `-connectors` and diff --git a/docs/get-started/prerequisites.md b/docs/get-started/prerequisites.md index cf5873f53..772e2c35e 100644 --- a/docs/get-started/prerequisites.md +++ b/docs/get-started/prerequisites.md @@ -7,7 +7,9 @@ reported as `No matching distribution found for structured-data-models-relational-engine`, which names the package and not the reason. - **A reachable NIM** serving `kumo-tabular` or `kumo-relational`. The client - runs no model and downloads no weights. + runs no model and downloads no weights. The one exception is a client + opened with `SDMClient.in_process()`, which runs the model itself; see the + [quickstart](quickstart.md#a-model-in-this-process). - **No GPU** on the client. The NIM owns that. ## Platforms diff --git a/docs/get-started/quickstart.md b/docs/get-started/quickstart.md index d130d8f61..bfb4932f0 100644 --- a/docs/get-started/quickstart.md +++ b/docs/get-started/quickstart.md @@ -152,6 +152,36 @@ This needs `pip install 'structured-data-models-client[databricks-serving]'`. A answers one request at a time, so `fit()` is refused there; `predict()` sends the context with every call. +## A model in this process + +The model can also run in your own Python process, with no NIM at all. The +calls are the same; only how the client is opened differs: + +```python +from sdm_client import KumoRelational, SDMClient + +with SDMClient.in_process() as client: + result = client.predict(request, model=KumoRelational()) +``` + +Each model is loaded from its published weights on Hugging Face the first time +it is asked for, on a GPU when there is one. A model you already loaded with +structured-data-models is used as it is: + +```python +import sdm + +relational = sdm.models.KumoRelational(device='cuda') +with SDMClient.in_process(relational) as client: + result = client.predict(request, model=KumoRelational()) +``` + +It answers as the NIM does. The context is fitted with the NIM's seed and +settings, `fit()` holds its session in memory, and a request the NIM refuses +is refused with the same `NimRequestError`. This needs +`pip install 'structured-data-models-client[in-process]'`, which brings +PyTorch, on Python 3.11 or later. + ## Which rows get scored A query says *what* to predict. It never says *which rows*. diff --git a/packages/structured-data-models-client/pyproject.toml b/packages/structured-data-models-client/pyproject.toml index a3da90598..9fd622e72 100644 --- a/packages/structured-data-models-client/pyproject.toml +++ b/packages/structured-data-models-client/pyproject.toml @@ -77,6 +77,12 @@ s3 = ["structured-data-models-connectors[s3]==1.0.0"] # model rather than a backend to read, so `all` does not carry it; ask for it by # name. databricks-serving = ["databricks-sdk>=0.68.0,<1.0"] +# Running the models in this process rather than reaching a NIM. Like +# `databricks-serving` it is a way to reach a model, and it brings PyTorch, so +# `all` does not carry it. structured-data-models needs Python 3.11, so this +# extra does not resolve on 3.10, where pip names the distribution rather than +# the reason; the installation guide says so. +in-process = ["structured-data-models>=0.2.1,<0.3"] all = [ "structured-data-models-client[relational,sqlite,duckdb,postgres,snowflake,databricks,lakebase,s3]", ] diff --git a/packages/structured-data-models-client/src/sdm_client/client.py b/packages/structured-data-models-client/src/sdm_client/client.py index e72de53f1..d8336eedd 100644 --- a/packages/structured-data-models-client/src/sdm_client/client.py +++ b/packages/structured-data-models-client/src/sdm_client/client.py @@ -369,12 +369,51 @@ def for_databricks_serving( ) return client + @classmethod + def in_process(cls, *models: Any, device: Any = None) -> SDMClient: + r"""Open a client that runs the models in this Python process. + + The same calls, answered by structured-data-models here instead of by + a NIM. Pass models already loaded with it, or none to load each one + from its published weights the first time it is asked for: + + >>> import sdm + >>> relational = sdm.models.KumoRelational() + >>> with SDMClient.in_process(relational) as client: + ... result = client.predict(request, model=KumoRelational()) + + Each model is fitted as the NIM fits it, at the same seed and, on a + GPU, the same precision. A refusal is the + :class:`~sdm_client.errors.NimRequestError` a NIM answers with. + + Args: + *models: ``sdm.models.KumoTabular`` or ``sdm.models.KumoRelational`` + models, at most one of each. A model passed is only used: + each context is fitted on a copy that shares its weights. + device: Where to load the models this client loads itself, such as + ``'cuda'``. Defaults to CUDA when it is available, else the + CPU. Refused with ``models``, which stay where they are. + + Raises: + MissingExtraError: If structured-data-models is not installed; + install ``structured-data-models-client[in-process]``. + StructuredDataError: With code ``INVALID_CONFIGURATION``, for a + model other than those two, two of one kind, or ``device`` + given with ``models``. + """ + from sdm_client.in_process import InProcessTransport + + client = cls.__new__(cls) + client._start(InProcessTransport(models, device=device)) + return client + @property def url(self) -> str: r"""Where this client sends requests. - A NIM's base URL, or ``'databricks-serving:'`` for a client - opened with :meth:`for_databricks_serving`. + A NIM's base URL, ``'databricks-serving:'`` for a client opened + with :meth:`for_databricks_serving`, or ``'in-process'`` for one + opened with :meth:`in_process`. """ return self._transport.url diff --git a/packages/structured-data-models-client/src/sdm_client/in_process/__init__.py b/packages/structured-data-models-client/src/sdm_client/in_process/__init__.py new file mode 100644 index 000000000..1e3ff1ddb --- /dev/null +++ b/packages/structured-data-models-client/src/sdm_client/in_process/__init__.py @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. +# All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +r"""The models served in this Python process rather than by a NIM. + +Behind :meth:`SDMClient.in_process `, and not +public API itself. Needs the ``in-process`` extra, which brings +structured-data-models and PyTorch. +""" + +from sdm_client.in_process.transport import InProcessTransport + +__all__ = ['InProcessTransport'] diff --git a/packages/structured-data-models-client/src/sdm_client/in_process/answers.py b/packages/structured-data-models-client/src/sdm_client/in_process/answers.py new file mode 100644 index 000000000..0f0d19f66 --- /dev/null +++ b/packages/structured-data-models-client/src/sdm_client/in_process/answers.py @@ -0,0 +1,156 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. +# All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +r"""What a model returned, written as the answers a NIM sends back.""" + +from __future__ import annotations + +import json +import math +from collections.abc import Mapping, Sequence +from typing import Any + +import numpy as np + +from sdm_client.in_process.refusal import refusal +from sdm_client.in_process.tables import read_cell, values + +#: The levels a NIM reports when the request names none. +QUANTILE_LEVELS = (0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9) + +#: The model predicts quantiles q001 through q999; q500 is the median. +_QUANTILES = 999 +_MEDIAN = 499 + + +def row_ids( + table: Mapping[str, Any], + primary_key: str | Sequence[str] | None, + dtypes: Mapping[str, str], +) -> list[str | None]: + r"""The id a NIM answers each row under. + + A key named as one column is the value itself; a key named as a list is + the list of its values, written as JSON. + """ + columns = table['columns'] + if isinstance(primary_key, str) and primary_key in columns: + return [str(value) for value in values(table, primary_key)] + if ( + isinstance(primary_key, list) + and primary_key + and all(name in columns for name in primary_key) + ): + cells = [ + [read_cell(cell, dtypes.get(name)) for cell in values(table, name)] + for name in primary_key + ] + return [ + json.dumps(list(key), separators=(',', ':'), default=str) + for key in zip(*cells, strict=True) + ] + return [None] * len(table['rows']) + + +def classified( + output: Any, + encoded: Sequence[Any], + labels: Sequence[Any], + ids: Sequence[str | None], + fields: Sequence[str], +) -> list[dict[str, Any]]: + r"""One answer per row from a classification's class probabilities. + + The model names each probability column after a class as it encoded it; + ``labels`` is what the caller calls each of ``encoded``, in order. + """ + named = {name: index for index, name in enumerate(_columns(output))} + try: + order = [named[str(label)] for label in encoded] + except KeyError as error: + raise refusal( + 'INTERNAL_ERROR', + f'the model answered for classes {sorted(named)}, not the ' + f'{len(encoded)} the context holds', + ) from error + probabilities = _numbers(output)[:, order] + answers = [] + for index, row in enumerate(probabilities): + answer: dict[str, Any] = { + 'row_index': index, + 'id': ids[index], + 'prediction': labels[int(np.argmax(row))], + } + if 'probabilities' in fields: + answer['probabilities'] = { + str(label): float(row[position]) + for position, label in enumerate(labels) + } + answers.append(answer) + return answers + + +def regressed( + output: Any, + ids: Sequence[str | None], + fields: Sequence[str], + statistic: str, + levels: Sequence[float], +) -> list[dict[str, Any]]: + r"""One answer per row from a regression's predicted quantiles. + + The point prediction is the distribution's mean or median, and each + quantile asked for is interpolated between the two predicted around it. + """ + for level in levels: + if not 0.001 <= level <= 0.999: + raise refusal( + 'UNSUPPORTED_OUTPUT', + f'quantile level {level!r} is outside 0.001 to 0.999', + ) + quantiles = _numbers(output) + if quantiles.shape[1] != _QUANTILES: + raise refusal( + 'INTERNAL_ERROR', + f'the model answered with {quantiles.shape[1]} quantiles, not ' + f'{_QUANTILES}', + ) + points = ( + quantiles.mean(axis=1) if statistic == 'mean' else quantiles[:, _MEDIAN] + ) + answers = [] + for index, row in enumerate(quantiles): + answer: dict[str, Any] = { + 'row_index': index, + 'id': ids[index], + 'prediction': float(points[index]), + } + if 'quantiles' in fields: + answer['quantiles'] = { + format(level, '.15g'): _at(row, level) for level in levels + } + answers.append(answer) + return answers + + +def _at(row: Any, level: float) -> float: + position = level * 1000.0 - 1.0 + lower, upper = math.floor(position), math.ceil(position) + weight = position - lower + return float(row[lower] * (1.0 - weight) + row[upper] * weight) + + +def _columns(output: Any) -> tuple[str, ...]: + from sdm import Stype + + return tuple(output.columns[Stype.numerical]) + + +def _numbers(output: Any) -> Any: + array = np.asarray(output.numerical.detach().cpu().numpy()) + if array.ndim != 2 or not np.isfinite(array).all(): + raise refusal( + 'INTERNAL_ERROR', 'the model answered with a malformed output' + ) + return array diff --git a/packages/structured-data-models-client/src/sdm_client/in_process/columns.py b/packages/structured-data-models-client/src/sdm_client/in_process/columns.py new file mode 100644 index 000000000..4d00170da --- /dev/null +++ b/packages/structured-data-models-client/src/sdm_client/in_process/columns.py @@ -0,0 +1,183 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. +# All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +r"""What both models share: a column's meaning, a table's shape, the labels.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field, replace +from typing import Any + +from sdm_client.in_process.refusal import refusal +from sdm_client.in_process.tables import to_arrow, values + +_DECLARED = { + 'numerical': 'numerical', + 'categorical': 'categorical', + 'timestamp': 'datetime', + 'datetime': 'datetime', + 'id': 'id', +} +_FROM_DTYPE = { + 'int32': 'numerical', + 'int64': 'numerical', + 'float32': 'numerical', + 'float64': 'numerical', + 'bool': 'categorical', + 'string': 'categorical', + 'timestamp[us]': 'datetime', +} + + +def stype_of(name: str, column: Mapping[str, Any]) -> str: + r"""What the model reads a column as, refusing one it cannot read. + + The model carries no processor for text, multicategorical or sequence + columns, so a request carrying one is refused rather than answered + without it. + """ + declared = column.get('stype') + if declared is not None: + stype = _DECLARED.get(str(declared).lower()) + if stype is None: + raise refusal( + 'UNSUPPORTED_DTYPE', + f'column {name!r} declares semantic type {declared!r}, which ' + f'SDM does not read', + ) + return stype + dtype = column.get('dtype') + stype = _FROM_DTYPE.get(str(dtype)) + if stype is None: + raise refusal( + 'UNSUPPORTED_DTYPE', + f'column {name!r} has no unambiguous SDM semantic type for dtype ' + f'{dtype!r}', + ) + return stype + + +def key_columns(schema: Mapping[str, Any]) -> frozenset[str]: + r"""The columns a table's primary key names.""" + key = schema.get('primary_key') + if isinstance(key, str): + return frozenset({key}) + return frozenset(key or ()) + + +def declared(schema: Mapping[str, Any], table: Mapping[str, Any]) -> None: + r"""Refuse a column the schema does not describe.""" + undeclared = [ + name for name in table['columns'] if name not in schema['columns'] + ] + if undeclared: + raise refusal( + 'INVALID_SCHEMA', + f'column(s) {undeclared} are not declared in the schema', + ) + + +@dataclass(frozen=True) +class Shape: + r"""Which columns of a table the model reads, and as what. + + Settled from the context and held to for the rows scored against it, + because the model refuses rows shaped differently from what it fitted. + """ + + columns: tuple[str, ...] + dtypes: Mapping[str, str] + stypes: Mapping[str, str] + keys: frozenset[str] = frozenset() + + @classmethod + def of( + cls, + schema: Mapping[str, Any], + stypes: Mapping[str, str], + keys: frozenset[str] = frozenset(), + ) -> Shape: + dtypes = { + name: str(schema['columns'][name].get('dtype')) for name in stypes + } + return cls(tuple(stypes), dtypes, dict(stypes), keys) + + def tensor(self, table: Mapping[str, Any], where: str) -> Any: + r"""``table`` as a CPU ``TableTensor`` of this shape.""" + from sdm import TableTensor + + absent = [name for name in self.columns if name not in table['columns']] + if absent: + raise refusal( + 'SESSION_PREDICT_SCHEMA_MISMATCH', + f'{where} omits column(s) {absent} that the context carries', + ) + arrow = to_arrow(table, self.columns, self.dtypes, self.keys) + return TableTensor.from_arrow(arrow, stypes=self.stypes, device='cpu') + + +@dataclass +class Context: + r"""A context converted for the model, and how rows are read against it. + + ``x``, ``y`` and ``related`` are what the model is fitted on; the rest is + all scoring needs, so a fitted session keeps only that. + """ + + x: Any + y: Any + instance: Shape + encoded: tuple[Any, ...] = () + labels: tuple[Any, ...] = () + related: Any = None + tables: dict[str, Shape] = field(default_factory=dict) + relationships: tuple[Any, ...] = () + task_links: tuple[Any, ...] = () + + def fitted(self) -> Context: + r"""This context without the rows it was fitted on.""" + return replace(self, x=None, y=None, related=None) + + +def labelled( + table: Mapping[str, Any], task: Mapping[str, Any], classifies: bool +) -> tuple[Any, tuple[Any, ...], tuple[Any, ...]]: + r"""The context's labels, and for a classification its classes. + + Returns the labels as a ``TableTensor``, each class as the model encodes + it, and what the caller calls each one. A boolean target is declared + ``'true'``/``'false'`` and encodes as ``True``/``False``, so a declared + class is matched to an encoded one by its text. + """ + from sdm import TableTensor + + target = task['target'] + column = target['column_name'] + if any(label is None for label in values(table, column)): + raise refusal( + 'TASK_SCHEMA_MISMATCH', + f'context target {column!r} contains null labels', + ) + arrow = to_arrow(table, [column], {column: target.get('dtype', 'float64')}) + stype = 'categorical' if classifies else 'numerical' + y = TableTensor.from_arrow(arrow, stypes={column: stype}, device='cpu') + if not classifies: + return y, (), () + encoded = tuple(y.categorical.categories[0].tolist()) + return y, encoded, _as_declared(target.get('classes'), encoded) + + +def _as_declared( + declared: Sequence[Any] | None, encoded: tuple[Any, ...] +) -> tuple[Any, ...]: + if not declared: + return encoded + fold = all(isinstance(label, bool) for label in encoded) + + def text(label: Any) -> str: + return str(label).casefold() if fold else str(label) + + named = {text(label): label for label in declared} + return tuple(named.get(text(label), label) for label in encoded) diff --git a/packages/structured-data-models-client/src/sdm_client/in_process/refusal.py b/packages/structured-data-models-client/src/sdm_client/in_process/refusal.py new file mode 100644 index 000000000..f010b59fc --- /dev/null +++ b/packages/structured-data-models-client/src/sdm_client/in_process/refusal.py @@ -0,0 +1,34 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. +# All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +r"""Refusing a request the way a NIM does. + +An in-process refusal carries the HTTP status and code a NIM answers the same +request with, so code that branches on either behaves alike wherever the model +runs. +""" + +from __future__ import annotations + +from sdm_client.errors import NimRequestError + +_STATUS = { + 'INVALID_REQUEST': 400, + 'SESSION_NOT_FOUND': 404, + 'INVALID_SCHEMA': 422, + 'VALIDATION_FAILED': 422, + 'UNSUPPORTED_DTYPE': 422, + 'UNSUPPORTED_TASK': 422, + 'UNSUPPORTED_OUTPUT': 422, + 'UNSUPPORTED_FEATURE': 422, + 'TASK_SCHEMA_MISMATCH': 422, + 'SESSION_PREDICT_SCHEMA_MISMATCH': 422, + 'INTERNAL_ERROR': 500, + 'MODEL_NOT_READY': 503, +} + + +def refusal(code: str, message: str) -> NimRequestError: + r"""The error a NIM answers with for ``code``.""" + return NimRequestError(_STATUS[code], code=code, message=message) diff --git a/packages/structured-data-models-client/src/sdm_client/in_process/relational.py b/packages/structured-data-models-client/src/sdm_client/in_process/relational.py new file mode 100644 index 000000000..5d9c952cb --- /dev/null +++ b/packages/structured-data-models-client/src/sdm_client/in_process/relational.py @@ -0,0 +1,240 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. +# All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +r"""``kumo-relational`` in process: each entity scored from its sampled graph. + +The client gathers one neighbourhood per scored row and tags every row of it +with that row's ``instance_id``. Each relationship is joined on that tag as +well, so one row's neighbourhood never reaches another's. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any + +from sdm_client.in_process.columns import ( + Context, + Shape, + declared, + key_columns, + labelled, + stype_of, +) +from sdm_client.in_process.refusal import refusal + +INSTANCE_TABLE = 'instance_table' +INSTANCE_ID = 'instance_id' + +_CLASSIFIED = ('prediction', 'probabilities') + + +class Relational: + r"""Reads a ``kumo-relational`` request into the model, as its NIM does.""" + + model_id = 'kumo-relational' + outputs = { + 'classification': _CLASSIFIED, + 'binary_classification': _CLASSIFIED, + 'multiclass_classification': _CLASSIFIED, + 'regression': ('prediction', 'quantiles'), + } + + @staticmethod + def load(device: Any) -> Any: + from sdm.models import KumoRelational + + return KumoRelational(device=device) + + @staticmethod + def accepts(model: Any) -> bool: + from sdm.models import KumoRelational + + return isinstance(model, KumoRelational) + + def context(self, payload: Mapping[str, Any]) -> Context: + from sdm import RelatedTables + + schema = payload['schema'] + task = payload['task'] + edges, links = _graph(schema.get('relationships') or ()) + if len(links) != 1: + raise refusal( + 'INVALID_SCHEMA', + f'a relational request needs exactly one relationship from ' + f'the instance table into a related table, got {len(links)}', + ) + joins = _joins(edges, links) + split = payload['context'] + spec = schema['instance_table'] + table = split['instance_table'] + declared(spec, table) + instance = Shape.of( + spec, + _instance_stypes( + spec, + table, + task, + payload.get('inference'), + joins[INSTANCE_TABLE], + ), + joins[INSTANCE_TABLE], + ) + y, encoded, labels = labelled(table, task, task['kind'] != 'regression') + shapes = {} + tensors = {} + for name, related in (split.get('related_tables') or {}).items(): + table_schema = schema['related_tables'][name] + declared(table_schema, related) + keys = joins.get(name, frozenset()) | key_columns(table_schema) + shapes[name] = Shape.of( + table_schema, _related_stypes(table_schema, related, keys), keys + ) + tensors[name] = shapes[name].tensor( + related, f'context table {name!r}' + ) + return Context( + x=instance.tensor(table, 'the context'), + y=y, + instance=instance, + encoded=encoded, + labels=labels, + related=RelatedTables( + tables=tensors, relationships=edges, task_links=links + ), + tables=shapes, + relationships=tuple(edges), + task_links=tuple(links), + ) + + @staticmethod + def query(context: Context, payload: Mapping[str, Any]) -> tuple[Any, Any]: + from sdm import RelatedTables + + split = payload['predict'] + related = split.get('related_tables') or {} + tensors = {} + for name, shape in context.tables.items(): + if name not in related: + raise refusal( + 'SESSION_PREDICT_SCHEMA_MISMATCH', + f'the rows to score omit related table {name!r}, which ' + f'the context carries', + ) + tensors[name] = shape.tensor( + related[name], f'related table {name!r}' + ) + return ( + context.instance.tensor( + split['instance_table'], 'the rows to score' + ), + RelatedTables( + tables=tensors, + relationships=context.relationships, + task_links=context.task_links, + ), + ) + + +def _graph(relationships: Sequence[Mapping[str, Any]]) -> tuple[list, list]: + r"""The joins between related tables, and the link from the scored rows. + + Every join also matches on ``instance_id``, the way the model's own + sampler scopes each example to its own neighbourhood. + """ + from sdm import Relationship, TaskLink + + edges, links = [], [] + for relationship in relationships: + source = list(relationship['source_columns']) + target = list(relationship['target_columns']) + if INSTANCE_ID not in source: + source.append(INSTANCE_ID) + target.append(INSTANCE_ID) + if relationship.get('source_table', INSTANCE_TABLE) == INSTANCE_TABLE: + links.append( + TaskLink( + task_columns=tuple(source), + table=relationship['target_table'], + table_columns=tuple(target), + ) + ) + else: + edges.append( + Relationship( + left_table=relationship['source_table'], + left_columns=tuple(source), + right_table=relationship['target_table'], + right_columns=tuple(target), + ) + ) + return edges, links + + +def _joins(edges: list, links: list) -> dict[str, frozenset[str]]: + r"""The columns each table is joined on, by table.""" + joins: dict[str, set[str]] = {INSTANCE_TABLE: set()} + for link in links: + joins[INSTANCE_TABLE].update(link.task_columns) + joins.setdefault(link.table, set()).update(link.table_columns) + for edge in edges: + joins.setdefault(edge.left_table, set()).update(edge.left_columns) + joins.setdefault(edge.right_table, set()).update(edge.right_columns) + return {table: frozenset(columns) for table, columns in joins.items()} + + +def _instance_stypes( + spec: Mapping[str, Any], + table: Mapping[str, Any], + task: Mapping[str, Any], + inference: Mapping[str, Any] | None, + keys: frozenset[str], +) -> dict[str, str]: + r"""The scored rows' columns the model reads, and as what. + + The model reads a timestamp in this table as the moment to predict from. + The anchor is read only when the request asks for prediction time, and + any other timestamp is refused rather than read as one. + """ + target = task['target']['column_name'] + anchor = task.get('anchor_time_column') + reads_time = bool((inference or {}).get('use_prediction_time')) + primary = key_columns(spec) + supplied = set(table['columns']) + stypes = {} + for name, column in spec['columns'].items(): + if name not in supplied or name == target: + continue + if name in keys: + stypes[name] = 'id' + elif name == anchor: + if reads_time: + stypes[name] = 'datetime' + elif name not in primary: + stype = stype_of(name, column) + if stype == 'datetime': + raise refusal( + 'UNSUPPORTED_FEATURE', + f'instance table carries timestamp column(s) {[name]} that ' + f'are not the declared anchor; this model reads any task ' + f'timestamp as a seed time', + ) + stypes[name] = stype + return stypes + + +def _related_stypes( + schema: Mapping[str, Any], table: Mapping[str, Any], keys: frozenset[str] +) -> dict[str, str]: + r"""A related table's columns, and what the model reads each as. + + A key is an identifier whatever its dtype says, because it decides which + rows meet. + """ + supplied = set(table['columns']) + return { + name: 'id' if name in keys else stype_of(name, column) + for name, column in schema['columns'].items() + if name in supplied + } diff --git a/packages/structured-data-models-client/src/sdm_client/in_process/tables.py b/packages/structured-data-models-client/src/sdm_client/in_process/tables.py new file mode 100644 index 000000000..68edc9270 --- /dev/null +++ b/packages/structured-data-models-client/src/sdm_client/in_process/tables.py @@ -0,0 +1,95 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. +# All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +r"""Reading a wire table back as the types its schema declares.""" + +from __future__ import annotations + +from collections.abc import Collection, Mapping, Sequence +from typing import Any + +import pyarrow as pa + +from sdm_client.in_process.refusal import refusal + +_ARROW_TYPES = { + 'bool': pa.bool_(), + 'int32': pa.int32(), + 'int64': pa.int64(), + 'float32': pa.float32(), + 'float64': pa.float64(), + 'string': pa.string(), +} +_INTEGERS = frozenset({'int32', 'int64'}) +_NUMBERS = _INTEGERS | {'float32', 'float64'} + + +def values(table: Mapping[str, Any], column: str) -> list[Any]: + r"""One column of an ``arrays`` table, in row order.""" + position = table['columns'].index(column) + return [row[position] for row in table['rows']] + + +def read_cell(cell: Any, dtype: str | None) -> Any: + r"""A cell as the value it stands for: an integer wider than a double + travels as its base-10 digits.""" + if dtype in _INTEGERS and isinstance(cell, str): + return int(cell) + return cell + + +def to_arrow( + table: Mapping[str, Any], + columns: Sequence[str], + dtypes: Mapping[str, str], + keys: Collection[str] = (), +) -> pa.Table: + r"""``columns`` of ``table`` as Arrow, each typed as ``dtypes`` declares. + + A numeric key is read as ``int64`` whatever its declared width, so both + sides of a join agree. A fractional one is refused rather than truncated + onto another row. + """ + arrays = [] + for column in columns: + cells = values(table, column) + dtype = dtypes[column] + if column in keys and dtype in _NUMBERS: + arrays.append(pa.array(_whole(cells, dtype, column), pa.int64())) + else: + arrays.append(_array(cells, dtype, column)) + return pa.Table.from_arrays(arrays, names=list(columns)) + + +def _array(cells: list[Any], dtype: str, column: str) -> pa.Array: + try: + if dtype == 'timestamp[us]': + zoned = pa.array(cells, pa.string()).cast( + pa.timestamp('us', tz='UTC') + ) + return zoned.cast(pa.timestamp('us')) + return pa.array( + [read_cell(cell, dtype) for cell in cells], _ARROW_TYPES[dtype] + ) + except (KeyError, pa.ArrowException, TypeError, ValueError) as error: + raise refusal( + 'VALIDATION_FAILED', + f'column {column!r} cannot be read as {dtype}', + ) from error + + +def _whole(cells: list[Any], dtype: str, column: str) -> list[Any]: + whole = [] + for cell in cells: + value = read_cell(cell, dtype) + if isinstance(value, float): + if not value.is_integer(): + raise refusal( + 'VALIDATION_FAILED', + f'key column {column!r} holds the fractional value ' + f'{value!r}, which joins to no row', + ) + value = int(value) + whole.append(value) + return whole diff --git a/packages/structured-data-models-client/src/sdm_client/in_process/tabular.py b/packages/structured-data-models-client/src/sdm_client/in_process/tabular.py new file mode 100644 index 000000000..478a916ac --- /dev/null +++ b/packages/structured-data-models-client/src/sdm_client/in_process/tabular.py @@ -0,0 +1,89 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. +# All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +r"""``kumo-tabular`` in process: one table, each row scored from its columns.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from sdm_client.in_process.columns import ( + Context, + Shape, + declared, + key_columns, + labelled, + stype_of, +) +from sdm_client.in_process.refusal import refusal + +_READS = frozenset({'numerical', 'categorical'}) + + +class Tabular: + r"""Reads a ``kumo-tabular`` request into the model, as its NIM does.""" + + model_id = 'kumo-tabular' + outputs = { + 'classification': ('prediction', 'probabilities'), + 'regression': ('prediction', 'quantiles'), + } + + @staticmethod + def load(device: Any) -> Any: + from sdm.models import KumoTabular + + return KumoTabular(device=device) + + @staticmethod + def accepts(model: Any) -> bool: + from sdm.models import KumoTabular + + return isinstance(model, KumoTabular) + + def context(self, payload: Mapping[str, Any]) -> Context: + schema = payload['schema'] + if ( + schema.get('related_tables') + or schema.get('relationships') + or payload['context'].get('related_tables') + ): + raise refusal( + 'UNSUPPORTED_FEATURE', + f'related tables are not supported by model {self.model_id!r}', + ) + spec = schema['instance_table'] + table = payload['context']['instance_table'] + declared(spec, table) + task = payload['task'] + target = task['target']['column_name'] + keys = key_columns(spec) + stypes = { + name: stype_of(name, spec['columns'][name]) + for name in table['columns'] + if name != target and name not in keys + } + unread = sorted( + name for name, stype in stypes.items() if stype not in _READS + ) + if unread: + raise refusal( + 'UNSUPPORTED_DTYPE', + f'Kumo Tabular does not support these feature columns: {unread}', + ) + instance = Shape.of(spec, stypes) + y, encoded, labels = labelled(table, task, task['kind'] != 'regression') + return Context( + x=instance.tensor(table, 'the context'), + y=y, + instance=instance, + encoded=encoded, + labels=labels, + ) + + @staticmethod + def query(context: Context, payload: Mapping[str, Any]) -> tuple[Any, Any]: + table = payload['predict']['instance_table'] + return context.instance.tensor(table, 'the rows to score'), None diff --git a/packages/structured-data-models-client/src/sdm_client/in_process/transport.py b/packages/structured-data-models-client/src/sdm_client/in_process/transport.py new file mode 100644 index 000000000..fab4e8d6a --- /dev/null +++ b/packages/structured-data-models-client/src/sdm_client/in_process/transport.py @@ -0,0 +1,467 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. +# All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +r"""A client's requests, answered by structured-data-models in this process. + +The client sends what it would send a NIM and reads back what a NIM answers, +so everything that works against a NIM works here, sessions included. The +model is fitted the way the hosted deployments fit it, so the same request is +answered the same way wherever it runs. +""" + +from __future__ import annotations + +import contextlib +import copy +import json +import threading +import uuid +from collections.abc import Iterator, Mapping, Sequence +from dataclasses import dataclass +from typing import Any + +from sdm_api import TFM_MODEL_VALUES + +from sdm_client.core.transport import _check_encodable +from sdm_client.errors import MissingExtraError, StructuredDataError +from sdm_client.in_process.answers import ( + QUANTILE_LEVELS, + classified, + regressed, + row_ids, +) +from sdm_client.in_process.columns import Context +from sdm_client.in_process.refusal import refusal +from sdm_client.in_process.relational import Relational +from sdm_client.in_process.tabular import Tabular + +#: How the hosted deployments fit a context. +_SEED = 42 +_ESTIMATORS = 1 + +_SERVED = {served.model_id: served for served in (Tabular(), Relational())} +_INFERENCE_OPTIONS = frozenset( + {'run_mode', 'use_prediction_time', 'inference_config'} +) + + +@dataclass +class _Session: + served: Tabular | Relational + task: Mapping[str, Any] + schema: Mapping[str, Any] + context: Context + fitted: Any + + +class InProcessTransport: + r"""Answers the contract's routes with structured-data-models models. + + Not part of the public API surface; :meth:`SDMClient.in_process` builds + one. + + Args: + models: Loaded ``sdm.models.KumoTabular`` or + ``sdm.models.KumoRelational`` models, at most one of each. With + none, each is loaded from its published weights on first use. + device: Where to load models this transport loads itself. Defaults to + CUDA when available, else the CPU. + """ + + serves_sessions = True + + def __init__(self, models: Sequence[Any] = (), device: Any = None) -> None: + torch = _require_sdm() + if models and device is not None: + raise StructuredDataError( + 'device places the models this client loads itself; the ' + 'models passed in stay where they are, so pass one or the ' + 'other', + code='INVALID_CONFIGURATION', + ) + self._models: dict[str, Any] = {} + for model in models: + model_id = _served_by(model).model_id + if model_id in self._models: + raise StructuredDataError( + f'two {model_id} models were passed; pass one of each', + code='INVALID_CONFIGURATION', + ) + self._models[model_id] = model + self._loads = not models + self._device = _device(device, torch) if self._loads else None + self._sessions: dict[str, _Session] = {} + self._lock = threading.RLock() + self._closed = False + + @property + def url(self) -> str: + return 'in-process' + + def close(self) -> None: + r"""Release every session and the models this transport loaded.""" + with self._lock: + self._sessions.clear() + self._models.clear() + self._closed = True + + def health_ready(self) -> bool: + self._require_open() + return True + + def list_models(self) -> list[str]: + r"""The models this transport answers for.""" + self._require_open() + return [ + model_id + for model_id in _SERVED + if self._loads or model_id in self._models + ] + + def predict(self, payload: dict[str, Any]) -> dict[str, Any]: + payload = _received(payload) + with self._answering(): + served, model = self._serving(payload.get('model')) + task = payload['task'] + fields = _fields(served, task['kind'], payload.get('output')) + _check_inference(payload.get('inference'), task) + context = served.context(payload) + output = _run(_fit(model, context), served.query(context, payload)) + return _response( + served, + task, + payload['schema'], + context, + payload, + output, + fields, + ) + + def create_session(self, payload: dict[str, Any]) -> dict[str, Any]: + r"""Fit the context once, to be scored against until released.""" + payload = _received(payload) + with self._answering(): + served, model = self._serving(payload.get('model')) + _outputs(served, payload['task']['kind']) + context = served.context(payload) + session_id = f'sess_{uuid.uuid4().hex}' + fitted = _fit(model, context) + self._sessions[session_id] = _Session( + served=served, + task=payload['task'], + schema=payload['schema'], + context=context.fitted(), + fitted=fitted, + ) + return {'session_id': session_id} + + def session_predict( + self, session_id: str, payload: dict[str, Any] + ) -> dict[str, Any]: + payload = _received(payload) + with self._answering(): + session = self._sessions.get(session_id) + if session is None: + raise refusal( + 'SESSION_NOT_FOUND', + f'session {session_id!r} does not exist or was released', + ) + task = session.task + fields = _fields( + session.served, task['kind'], payload.get('output') + ) + _check_inference(payload.get('inference'), task, settled=True) + output = _run( + session.fitted, session.served.query(session.context, payload) + ) + return _response( + session.served, + task, + session.schema, + session.context, + payload, + output, + fields, + ) + + def delete_session(self, session_id: str) -> None: + r"""Release a session. Releasing one that is gone is not an error.""" + with self._lock: + self._require_open() + self._sessions.pop(session_id, None) + + def _serving(self, model_id: Any) -> tuple[Tabular | Relational, Any]: + r"""What reads requests for ``model_id``, and the model it runs. + + Called holding the lock, so a model is loaded once. + """ + if model_id not in TFM_MODEL_VALUES: + named = ' or '.join(repr(value) for value in TFM_MODEL_VALUES) + raise refusal( + 'VALIDATION_FAILED', + f'Request validation failed. model: Input should be {named}', + ) + served = _SERVED[model_id] + model = self._models.get(model_id) + if model is None and self._loads: + model = self._models[model_id] = _loaded(served, self._device) + if model is None: + raise refusal( + 'INVALID_SCHEMA', + f'model {model_id!r} is not served here; available: ' + f'{self.list_models()}', + ) + if model.training: + raise StructuredDataError( + f'the {served.model_id} model is in training mode, and scores ' + f'only in evaluation mode; call .eval() on it', + code='INVALID_CONFIGURATION', + ) + return served, model + + @contextlib.contextmanager + def _answering(self) -> Iterator[None]: + r"""Answer one call at a time, reporting a failure the way a NIM + does: an internal error, here with the original as its cause.""" + with self._lock: + self._require_open() + try: + yield + except StructuredDataError: + raise + except Exception as error: + raise refusal( + 'INTERNAL_ERROR', f'{type(error).__name__}: {error}' + ) from error + + def _require_open(self) -> None: + if self._closed: + raise StructuredDataError( + 'this client is closed; construct a new SDMClient', + code='INVALID_CONFIGURATION', + ) + + +def _require_sdm() -> Any: + try: + import sdm # noqa: F401 + import torch + except ImportError as error: + raise MissingExtraError( + 'in-process', 'structured-data-models' + ) from error + return torch + + +def _served_by(model: Any) -> Tabular | Relational: + for served in _SERVED.values(): + if served.accepts(model): + return served + raise StructuredDataError( + f'in_process() takes the KumoTabular and KumoRelational models of ' + f'structured-data-models, and got a {type(model).__name__}; the ' + f'contract names no other model', + code='INVALID_CONFIGURATION', + ) + + +def _device(device: Any, torch: Any) -> Any: + if device is None: + return torch.device('cuda' if torch.cuda.is_available() else 'cpu') + try: + return torch.device(device) + except (RuntimeError, TypeError) as error: + raise StructuredDataError( + f'device {device!r} is not a device torch recognises', + code='INVALID_CONFIGURATION', + ) from error + + +def _loaded(served: Tabular | Relational, device: Any) -> Any: + try: + return served.load(device) + except Exception as error: + raise refusal( + 'MODEL_NOT_READY', + f'{served.model_id} could not be loaded: ' + f'{type(error).__name__}: {error}', + ) from error + + +def _received(payload: dict[str, Any]) -> dict[str, Any]: + r"""The payload as a NIM would read it: through JSON. + + Encoding it refuses what an HTTP call refuses, and decoding it hands the + model a copy, so nothing done here reaches the request it came from. + """ + _check_encodable(payload) + try: + return json.loads(json.dumps(payload, allow_nan=False)) + except (TypeError, ValueError) as error: + raise StructuredDataError( + f'this request carries a value that cannot be sent as JSON: ' + f'{error}', + code='INVALID_REQUEST', + ) from error + + +def _outputs(served: Tabular | Relational, kind: str) -> tuple[str, ...]: + r"""What this model produces for this task, which it must serve.""" + available = served.outputs.get(kind) + if available is None: + raise refusal( + 'UNSUPPORTED_TASK', + f'task {kind!r} not supported by model {served.model_id!r}; ' + f'supported: {list(served.outputs)}', + ) + return available + + +def _fields( + served: Tabular | Relational, + kind: str, + output: Mapping[str, Any] | None, +) -> list[str]: + r"""The fields asked for, refused where this model does not produce + them for this task.""" + available = _outputs(served, kind) + fields = list((output or {}).get('fields') or ['prediction']) + unsupported = [field for field in fields if field not in available] + if unsupported: + raise refusal( + 'UNSUPPORTED_OUTPUT', + f'output(s) {unsupported} not supported by model ' + f'{served.model_id!r} for task {kind!r}; supported: ' + f'{list(available)}', + ) + return fields + + +def _check_inference( + inference: Mapping[str, Any] | None, + task: Mapping[str, Any], + settled: bool = False, +) -> None: + r"""Refuse an inference option the hosted deployments refuse. + + A session's context is fitted without prediction time, since creating one + carries no inference options, so a prediction cannot ask for it after. + """ + options = dict(inference or {}) + unknown = sorted(set(options) - _INFERENCE_OPTIONS) + if unknown: + raise refusal( + 'INVALID_REQUEST', f'unsupported inference options: {unknown}' + ) + if options.get('inference_config'): + raise refusal( + 'INVALID_REQUEST', + 'inference_config is reserved for API compatibility and must be ' + 'omitted or empty', + ) + if not options.get('use_prediction_time'): + return + if not task.get('anchor_time_column'): + raise refusal( + 'TASK_SCHEMA_MISMATCH', + 'use_prediction_time needs task.anchor_time_column, which this ' + 'request does not name', + ) + if settled: + raise refusal( + 'INVALID_REQUEST', + 'use_prediction_time is settled when a session is created and ' + 'cannot be changed by a prediction', + ) + + +def _fit(model: Any, context: Context) -> Any: + r"""A copy of ``model`` fitted on ``context``. + + The copy shares the model's weights and holds its own fitted context, so + a session never disturbs another, nor anything fitted on the model itself. + """ + import torch + + device = _placement(model) + fitted = copy.copy(model) + related = None if context.related is None else context.related.to(device) + with _autocast(device): + fitted.fit( + context.x.to(device), + context.y.to(device), + related, + num_estimators=_ESTIMATORS, + generator=torch.Generator(device=device).manual_seed(_SEED), + ) + return fitted + + +def _run(fitted: Any, query: tuple[Any, Any]) -> Any: + x, related = query + device = _placement(fitted) + with _autocast(device): + return fitted.predict( + x.to(device), None if related is None else related.to(device) + ) + + +def _placement(model: Any) -> Any: + return next(model.parameters()).device + + +def _autocast(device: Any) -> Any: + r"""The precision the hosted deployments run at on a GPU.""" + import torch + + if device.type != 'cuda': + return contextlib.nullcontext() + dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16 + return torch.autocast(device_type='cuda', dtype=dtype) + + +def _response( + served: Tabular | Relational, + task: Mapping[str, Any], + schema: Mapping[str, Any], + context: Context, + payload: Mapping[str, Any], + output: Any, + fields: list[str], +) -> dict[str, Any]: + instance = schema['instance_table'] + dtypes = { + name: str(column.get('dtype')) + for name, column in instance['columns'].items() + } + ids = row_ids( + payload['predict']['instance_table'], + instance.get('primary_key'), + dtypes, + ) + kind = task['kind'] + if kind == 'regression': + spec = payload.get('output') or {} + statistic = spec.get('prediction_statistic') or 'mean' + if statistic not in ('mean', 'median'): + raise refusal( + 'UNSUPPORTED_OUTPUT', + 'prediction_statistic must be mean or median', + ) + levels = spec.get('quantile_levels') or QUANTILE_LEVELS + answers = regressed(output, ids, fields, statistic, levels) + output_type = statistic + else: + answers = classified( + output, context.encoded, context.labels, ids, fields + ) + output_type = ( + 'probabilities' if 'probabilities' in fields else 'prediction' + ) + return { + 'id': f'pred_{uuid.uuid4().hex[:12]}', + 'model': served.model_id, + 'predictions': answers, + 'metadata': {'task_kind': kind, 'output_type': output_type}, + } diff --git a/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py b/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py new file mode 100644 index 000000000..9ee29fbd3 --- /dev/null +++ b/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py @@ -0,0 +1,386 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. +# All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""The models run in this Python process, answering as their NIM does. + +Small random-weight models stand in for the published ones, so nothing is +downloaded and no GPU is needed. What is checked is that a request reaches the +model as its own API would be called, and comes back as a NIM answers it. +""" + +from __future__ import annotations + +import copy +import sys +import warnings + +import pandas as pd +import pytest + +from sdm_client import ( + KumoRelational, + KumoTabular, + MissingExtraError, + NimRequestError, + PredictiveQuery, + Request, + Schema, + SDMClient, + StructuredDataError, +) + +COUNT = 'PREDICT COUNT(orders.*, 0, 30, days) FOR EACH users.user_id' +BINARY = 'PREDICT COUNT(orders.*, 0, 30, days) > 2 FOR EACH users.user_id' +MULTICLASS = 'PREDICT users.tier FOR EACH users.user_id' + + +@pytest.fixture(scope='module') +def sdm(): + return pytest.importorskip('sdm', reason='needs the in-process extra') + + +@pytest.fixture(scope='module') +def tabular(sdm): + import torch + + torch.manual_seed(0) + return sdm.models.KumoTabular(size='small', pretrained=False, device='cpu') + + +@pytest.fixture(scope='module') +def relational(sdm): + pytest.importorskip('sdm_relational_engine', reason='needs [relational]') + import torch + + torch.manual_seed(0) + return sdm.models.KumoRelational(pretrained=False, device='cpu') + + +@pytest.fixture(autouse=True) +def _quiet(): + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + yield + + +def people() -> pd.DataFrame: + frame = pd.DataFrame( + { + 'person_id': range(60), + 'age': [20.0 + (i * 7) % 40 for i in range(60)], + 'segment': ['north' if i % 3 else 'south' for i in range(60)], + 'visits': [(i * 5) % 11 for i in range(60)], + } + ) + frame['churn'] = (frame['visits'] > 5).astype(int) + frame['plan'] = [ + 'pro' if age > 40 else 'plus' if visits > 3 else 'free' + for age, visits in zip(frame['age'], frame['visits'], strict=True) + ] + frame['spend'] = frame['age'] * 2.5 + frame['visits'] + return frame + + +def graph() -> dict[str, pd.DataFrame]: + users = pd.DataFrame( + { + 'user_id': range(40), + 'tier': [('gold', 'silver', 'bronze')[i % 3] for i in range(40)], + 'age': [20.0 + (i * 7) % 30 for i in range(40)], + } + ) + items = pd.DataFrame( + {'item_id': range(6), 'price': [5.0 + i for i in range(6)]} + ) + rows = [] + for user in range(40): + busy = user % 2 == 0 + for k in range(40 if busy else 4): + rows.append( + { + 'order_id': len(rows), + 'user_id': user, + 'item_id': (user + k) % 6, + 'ts': pd.Timestamp('2024-01-01') + + pd.Timedelta(days=(k * 5 if busy else k * 50) + user % 9), + } + ) + return {'users': users, 'items': items, 'orders': pd.DataFrame(rows)} + + +def refused(call) -> NimRequestError: + with pytest.raises(NimRequestError) as caught: + call() + return caught.value + + +@pytest.mark.parametrize( + ('target', 'task'), + [ + ('churn', 'classification'), + ('plan', 'classification'), + ('spend', 'regression'), + ], +) +def test_a_tabular_answer_is_the_one_the_model_gives_when_called_directly( + sdm, tabular, target, task +): + import torch + + frame = people()[['person_id', 'age', 'segment', 'visits', target]] + context, scored = frame.iloc[:45], frame.iloc[45:].drop(columns=[target]) + with SDMClient.in_process(tabular) as client: + request = Request( + context=context, predict=scored, target=target, task=task + ) + answer = client.predict(request, model=KumoTabular()).to_pandas() + + features = { + 'age': 'numerical', + 'segment': 'categorical', + 'visits': 'numerical', + } + label = 'categorical' if task == 'classification' else 'numerical' + fitted = copy.copy(tabular) + fitted.fit( + sdm.TableTensor.from_pandas(context, features), + sdm.TableTensor.from_pandas(context, {target: label}), + num_estimators=1, + generator=torch.Generator().manual_seed(42), + ) + output = fitted.predict(sdm.TableTensor.from_pandas(scored, features)) + expected = output.numerical.numpy() + + assert answer['id'].tolist() == scored['person_id'].tolist() + if task == 'regression': + assert answer['prediction'].tolist() == expected.mean(axis=1).tolist() + return + classes = output.columns[sdm.Stype.numerical] + for position, name in enumerate(classes): + assert answer[f'probability_{name}'].tolist() == ( + expected[:, position].tolist() + ) + assert [str(value) for value in answer['prediction']] == [ + classes[position] for position in expected.argmax(axis=1) + ] + + +@pytest.mark.parametrize('query', [COUNT, BINARY, MULTICLASS]) +def test_a_session_answers_as_one_call_does(relational, query): + request = PredictiveQuery(query, graph()).materialize(indices=[0, 1, 2, 3]) + with SDMClient.in_process(relational) as client: + once = client.predict(request, model=KumoRelational()).to_pandas() + again = client.predict(request, model=KumoRelational()).to_pandas() + with client.fit(request, model=KumoRelational()) as fitted: + pinned = fitted.predict(request).to_pandas() + + assert once['id'].tolist() == [0, 1, 2, 3] + pd.testing.assert_frame_equal(once, again) + pd.testing.assert_frame_equal(once, pinned) + + +@pytest.mark.parametrize( + ('query', 'rows'), + [ + ( + 'PREDICT LIST_DISTINCT(orders.item_id, 0, 30, days) RANK TOP 2 ' + 'FOR EACH users.user_id', + 3, + ), + ( + 'PREDICT COUNT(orders.*, 0, 30, days) FORECAST 3 TIMEFRAMES ' + 'FOR EACH users.user_id', + 9, + ), + ], +) +def test_a_ranking_and_a_forecast_are_asked_as_the_questions_it_serves( + relational, query, rows +): + tables = graph() + asked = PredictiveQuery(query, tables) + request = ( + asked.materialize( + indices=[0, 1, 2], candidates=tables['items']['item_id'] + ) + if 'RANK' in query + else asked.materialize(indices=[0, 1, 2]) + ) + with SDMClient.in_process(relational) as client: + answer = client.predict(request, model=KumoRelational()).to_pandas() + + assert len(answer) == rows + if 'RANK' in query: + assert all(len(ranked) <= 2 for ranked in answer['prediction']) + else: + assert sorted(set(answer['forecast_step'])) == [1, 2, 3] + + +def test_each_refusal_is_the_one_a_nim_answers_with(relational, tabular): + tables = graph() + request = PredictiveQuery(COUNT, tables).materialize(indices=[0, 1]) + one = PredictiveQuery(BINARY, tables).materialize(indices=[0]) + texty = Schema.from_data(tables) + texty['users']['tier'].stype = 'text' + with SDMClient.in_process(relational, tabular) as client: + cases = { + ('UNSUPPORTED_OUTPUT', 422): lambda: client.predict( + request, + model=KumoRelational(), + outputs=['prediction', 'scores'], + ), + ('UNSUPPORTED_OUTPUT', 422, 'explanation'): lambda: client.predict( + one, + model=KumoRelational(), + outputs=['prediction', 'explanation'], + ), + ('UNSUPPORTED_DTYPE', 422, 'tier'): lambda: client.predict( + PredictiveQuery(COUNT, texty).materialize(indices=[0, 1]), + model=KumoRelational(), + ), + ('INVALID_REQUEST', 400): lambda: client.predict( + PredictiveQuery(COUNT, tables).materialize( + indices=[0, 1], inference_config={'num_estimators': 4} + ), + model=KumoRelational(), + ), + ('UNSUPPORTED_TASK', 422): lambda: client.predict( + PredictiveQuery( + 'PREDICT LIST_DISTINCT(orders.item_id, 0, 30, days) ' + 'FOR EACH users.user_id', + tables, + ).materialize(indices=[0, 1]), + model=KumoRelational(), + ), + ('UNSUPPORTED_DTYPE', 422, 'seen'): lambda: client.predict( + Request( + context=people() + .assign(seen=pd.Timestamp('2024-01-01')) + .iloc[:40][['person_id', 'age', 'seen', 'churn']], + predict=people() + .assign(seen=pd.Timestamp('2024-01-01')) + .iloc[40:][['person_id', 'age', 'seen']], + target='churn', + task='classification', + ), + model=KumoTabular(), + ), + } + for (code, status, *named), call in cases.items(): + error = refused(call) + assert (error.code, error.status_code) == (code, status), named + assert all(name in error.message for name in named) + + +def test_a_model_passed_in_keeps_what_was_fitted_on_it(sdm, tabular): + frame = people() + features = {'age': 'numerical', 'visits': 'numerical'} + tabular.fit( + sdm.TableTensor.from_pandas(frame.iloc[:30], features), + sdm.TableTensor.from_pandas(frame.iloc[:30], {'churn': 'categorical'}), + ) + query = sdm.TableTensor.from_pandas(frame.iloc[30:], features) + before = tabular.predict(query).numerical.clone() + try: + with SDMClient.in_process(tabular) as client: + request = Request( + context=frame.iloc[:45][['person_id', 'age', 'plan']], + predict=frame.iloc[45:][['person_id', 'age']], + target='plan', + task='classification', + ) + client.predict(request, model=KumoTabular()) + with client.fit(request, model=KumoTabular()) as fitted: + fitted.predict(request) + assert tabular.predict(query).numerical.equal(before) + finally: + tabular.clear() + + +def test_a_model_is_loaded_the_first_time_it_is_asked_for( + sdm, tabular, monkeypatch +): + import torch + + loads = [] + + def load(**kwargs): + loads.append(kwargs) + return tabular + + monkeypatch.setattr(sdm.models, 'KumoTabular', load) + frame = people() + request = Request( + context=frame.iloc[:45][['person_id', 'age', 'churn']], + predict=frame.iloc[45:][['person_id', 'age']], + target='churn', + task='classification', + ) + with SDMClient.in_process() as client: + assert client.url == 'in-process' + assert client.models() == ['kumo-tabular', 'kumo-relational'] + assert loads == [] + client.predict(request, model=KumoTabular()) + client.predict(request, model=KumoTabular()) + + expected = torch.device('cuda' if torch.cuda.is_available() else 'cpu') + assert loads == [{'device': expected}] + + +def test_a_model_that_will_not_load_says_so_with_its_cause(sdm, monkeypatch): + def load(**kwargs): + raise OSError('no route to the hub') + + monkeypatch.setattr(sdm.models, 'KumoTabular', load) + frame = people() + request = Request( + context=frame.iloc[:45][['person_id', 'age', 'churn']], + predict=frame.iloc[45:][['person_id', 'age']], + target='churn', + task='classification', + ) + with SDMClient.in_process() as client: + error = refused(lambda: client.predict(request, model=KumoTabular())) + + assert (error.code, error.status_code) == ('MODEL_NOT_READY', 503) + assert isinstance(error.__cause__, OSError) + + +def test_what_cannot_be_run_in_process_is_refused_when_opened(sdm, tabular): + other = sdm.models.TabICLv2(pretrained=False, device='cpu') + for models, device in ( + ((other,), None), + ((tabular, tabular), None), + ((tabular,), 'cpu'), + ((), 'no-such-device'), + ): + with pytest.raises(StructuredDataError) as caught: + SDMClient.in_process(*models, device=device) + assert caught.value.code == 'INVALID_CONFIGURATION' + + +def test_a_model_left_in_training_mode_is_named_not_run(tabular): + frame = people() + request = Request( + context=frame.iloc[:45][['person_id', 'age', 'churn']], + predict=frame.iloc[45:][['person_id', 'age']], + target='churn', + task='classification', + ) + tabular.train() + try: + with SDMClient.in_process(tabular) as client: + with pytest.raises(StructuredDataError) as caught: + client.predict(request, model=KumoTabular()) + assert caught.value.code == 'INVALID_CONFIGURATION' + assert 'eval()' in caught.value.message + finally: + tabular.eval() + + +def test_without_the_extra_the_extra_is_named(monkeypatch): + monkeypatch.setitem(sys.modules, 'sdm', None) + with pytest.raises(MissingExtraError) as caught: + SDMClient.in_process() + assert caught.value.details['extra'] == 'in-process' diff --git a/qa/README.md b/qa/README.md index 85e29ea90..1530af651 100644 --- a/qa/README.md +++ b/qa/README.md @@ -43,6 +43,7 @@ python probe_tabular.py # kumo-tabular on Adult, Wine, Titanic, Bank, Stud python probe_relational.py # kumo-relational on Northwind python probe_connectors.py # local files, SQLite, DuckDB python probe_edges.py # awkward values, shapes, the error contract +python probe_in_process.py # SDMClient.in_process() against the NIMs, request for request python probe_cloud.py # S3 (needs AWS credentials and /tmp/qa_bucket) python probe_warehouse.py # Snowflake and Databricks (needs SNOWFLAKE_*, DATABRICKS_*) ``` diff --git a/qa/probe_in_process.py b/qa/probe_in_process.py new file mode 100644 index 000000000..e2e71bf24 --- /dev/null +++ b/qa/probe_in_process.py @@ -0,0 +1,121 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. +# All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""SDMClient.in_process() against the NIMs, request for request. + +Each request goes to a NIM and to the model in this process, and the two +answers are compared column by column. Against a NIM on the CPU running the +same structured-data-models release, every number is identical. A NIM on a GPU +computes at reduced precision, so set SDM_IN_PROCESS_TOLERANCE to the largest +difference to accept. +""" + +from __future__ import annotations + +import os + +import data +import numpy as np +from harness import RELATIONAL, TABULAR, fail, note, probe, save +from sdm_client import ( + KumoRelational, + KumoTabular, + PredictiveQuery, + Schema, + SDMClient, +) + +AREA = 'in-process' +TOLERANCE = float(os.environ.get('SDM_IN_PROCESS_TOLERANCE', '0')) + + +def agree(entry, hosted, local) -> None: + a, b = hosted.to_pandas(), local.to_pandas() + if list(a.columns) != list(b.columns): + fail(entry, f'columns {list(a.columns)} and {list(b.columns)}') + return + worst = 0.0 + for column in a.columns: + if a[column].dtype.kind == 'f': + gap = np.abs(a[column].to_numpy() - b[column].to_numpy()) + worst = max(worst, float(np.nanmax(gap)) if len(gap) else 0.0) + elif a[column].astype(str).tolist() != b[column].astype(str).tolist(): + fail(entry, f'{column} differs') + return + if worst > TOLERANCE: + fail(entry, f'largest difference {worst:.3g} exceeds {TOLERANCE:g}') + else: + note(entry, f'largest difference {worst:.3g}') + + +def tabular_requests(): + for name, frame, key, target in ( + ('adult', data.adult().head(1500), 'person_id', 'income'), + ( + 'wine', + data.wine().sample(1500, random_state=0), + 'wine_id', + 'quality', + ), + ('titanic', data.titanic(), 'passenger_id', 'survived'), + ('bank', data.bank().sample(2000, random_state=0), 'contact_id', 'y'), + ('students', data.students(), 'student_id', 'Target'), + ): + schema = Schema.from_data({name: frame}, primary_keys={name: key}) + for column in schema[name]: + if schema[name][column].stype == 'text': + schema[name][column].stype = 'categorical' + query = PredictiveQuery( + f'PREDICT {name}.{target} FOR EACH {name}.{key}', schema + ) + yield name, query.materialize(indices=frame[key].head(40).tolist()) + + +def relational_requests(): + schema = data.northwind_ready() + customers = data.northwind('Customers')['CustomerID'].head(12).tolist() + for text in ( + 'PREDICT COUNT(Orders.*, 0, 90, days) FOR EACH Customers.CustomerID', + 'PREDICT SUM(Orders.Freight, 0, 90, days) FOR EACH Customers.CustomerID', + 'PREDICT COUNT(Orders.*, 0, 90, days) > 0 FOR EACH Customers.CustomerID', + ): + yield text, PredictiveQuery(text, schema).materialize(indices=customers) + + +def run() -> None: + with ( + SDMClient(url=TABULAR, timeout=900) as hosted, + SDMClient.in_process() as local, + ): + for name, request in tabular_requests(): + with probe( + AREA, f'tabular {name}', 'the answer the NIM gives' + ) as e: + agree( + e, + hosted.predict(request, model=KumoTabular()), + local.predict(request, model=KumoTabular()), + ) + with ( + SDMClient(url=RELATIONAL, timeout=900) as hosted, + SDMClient.in_process() as local, + ): + for text, request in relational_requests(): + with probe(AREA, text, 'the answer the NIM gives') as e: + agree( + e, + hosted.predict(request, model=KumoRelational()), + local.predict(request, model=KumoRelational()), + ) + with probe(AREA, f'{text}, in a session', 'the same answer') as e: + with ( + hosted.fit(request, model=KumoRelational()) as pinned, + local.fit(request, model=KumoRelational()) as held, + ): + agree(e, pinned.predict(request), held.predict(request)) + + +if __name__ == '__main__': + run() + save('in_process.json') diff --git a/scripts/check_repo_hygiene.py b/scripts/check_repo_hygiene.py index 246748d08..b444d797e 100644 --- a/scripts/check_repo_hygiene.py +++ b/scripts/check_repo_hygiene.py @@ -97,16 +97,11 @@ # a test talks to and stays; this is a path into a tree nobody outside # can open, which is what shipped in a docstring until it was removed. re.compile(r'\bkumo_rfm_nim\b', re.IGNORECASE), - # Internal build names for the servers behind a model id. A reader - # cannot look either up, and which build answers is not the client - # story. The public repository and the five distributions it publishes - # are all spelled ``structured-data-models-*``, so they are excluded. + # An internal build name for a server behind a model id. A reader cannot + # look it up, and which build answers is not the client story. The model + # package, structured-data-models, is public and is what the in-process + # extra installs, so it is named like any other dependency. re.compile(r'\bkumoml', re.IGNORECASE), - re.compile( - r'\bstructured-data-models\b' - r'(?!-(?:client|api|pql|connectors|relational-engine))', - re.IGNORECASE, - ), ) # Files that legitimately name internal infrastructure. A provenance record has diff --git a/tests/golden/public_api.json b/tests/golden/public_api.json index 3d5ed96a1..657af9887 100644 --- a/tests/golden/public_api.json +++ b/tests/golden/public_api.json @@ -285,6 +285,7 @@ "fit": "(self, request: 'Request', *, model: 'str | Model', outputs: 'Sequence[str] | None' = None, holdout: 'Iterable[Any] | None' = None) -> 'FittedModel'", "for_databricks_serving": "(endpoint: 'str', *, workspace_client: 'Any | None' = None, timeout: 'float | None' = None) -> 'SDMClient'", "health_ready": "(self) -> 'bool'", + "in_process": "(*models: 'Any', device: 'Any' = None) -> 'SDMClient'", "models": "(self) -> 'list[str]'", "predict": "(self, request: 'Request', *, model: 'str | Model', outputs: 'Sequence[str] | None' = None) -> 'PredictionResult'" }, diff --git a/tests/test_release_contract.py b/tests/test_release_contract.py index 385894190..7d607a7a7 100644 --- a/tests/test_release_contract.py +++ b/tests/test_release_contract.py @@ -1192,12 +1192,14 @@ def test_every_advertised_extra_of_the_client_resolves_to_a_real_target( .split('>=')[0] .strip() ) - # databricks-sdk is imported by the client's own serving - # transport, so the client is the distribution that declares it. + # databricks-sdk and structured-data-models are imported by the + # client's own transports, so the client is the distribution + # that declares them. assert name in known or name in { 'pytest', 'requests-mock', 'databricks-sdk', + 'structured-data-models', }, ( f'extra {extra!r} requires {name!r}, which is not one of ' f"this repository's distributions" From 83791458c3c63ebc34acf4a426c166d5a2bddeb2 Mon Sep 17 00:00:00 2001 From: Manush Murali Date: Fri, 16 Oct 2026 16:09:51 -0700 Subject: [PATCH 02/17] Name the in-process extra on a client installed without it PyArrow comes with the extra, not with the client, and the table decoder imported it at module scope. Opening an in-process client on a base install therefore raised ModuleNotFoundError from inside the package, before the check that names the extra. It is imported where it is used now. The test that covered this blocked only `sdm`, in a process that had already imported the package, so it passed either way. It runs in a fresh interpreter with everything the extra brings blocked, and fails on the old code. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Manush Murali --- .../src/sdm_client/in_process/tables.py | 31 ++++++++++++------- .../test_a_model_runs_in_this_process.py | 28 +++++++++++++---- 2 files changed, 41 insertions(+), 18 deletions(-) diff --git a/packages/structured-data-models-client/src/sdm_client/in_process/tables.py b/packages/structured-data-models-client/src/sdm_client/in_process/tables.py index 68edc9270..b00925e1d 100644 --- a/packages/structured-data-models-client/src/sdm_client/in_process/tables.py +++ b/packages/structured-data-models-client/src/sdm_client/in_process/tables.py @@ -7,19 +7,23 @@ from __future__ import annotations from collections.abc import Collection, Mapping, Sequence -from typing import Any - -import pyarrow as pa +from typing import TYPE_CHECKING, Any from sdm_client.in_process.refusal import refusal +if TYPE_CHECKING: + import pyarrow as pa + +#: Each dtype's Arrow type, by factory name. PyArrow comes with the extra and +#: is imported where it is used, so that a client without the extra reaches +#: the check that names it. _ARROW_TYPES = { - 'bool': pa.bool_(), - 'int32': pa.int32(), - 'int64': pa.int64(), - 'float32': pa.float32(), - 'float64': pa.float64(), - 'string': pa.string(), + 'bool': 'bool_', + 'int32': 'int32', + 'int64': 'int64', + 'float32': 'float32', + 'float64': 'float64', + 'string': 'string', } _INTEGERS = frozenset({'int32', 'int64'}) _NUMBERS = _INTEGERS | {'float32', 'float64'} @@ -51,6 +55,8 @@ def to_arrow( sides of a join agree. A fractional one is refused rather than truncated onto another row. """ + import pyarrow as pa + arrays = [] for column in columns: cells = values(table, column) @@ -63,15 +69,16 @@ def to_arrow( def _array(cells: list[Any], dtype: str, column: str) -> pa.Array: + import pyarrow as pa + try: if dtype == 'timestamp[us]': zoned = pa.array(cells, pa.string()).cast( pa.timestamp('us', tz='UTC') ) return zoned.cast(pa.timestamp('us')) - return pa.array( - [read_cell(cell, dtype) for cell in cells], _ARROW_TYPES[dtype] - ) + arrow_type = getattr(pa, _ARROW_TYPES[dtype])() + return pa.array([read_cell(cell, dtype) for cell in cells], arrow_type) except (KeyError, pa.ArrowException, TypeError, ValueError) as error: raise refusal( 'VALIDATION_FAILED', diff --git a/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py b/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py index 9ee29fbd3..333fa8297 100644 --- a/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py +++ b/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py @@ -12,6 +12,7 @@ from __future__ import annotations import copy +import subprocess import sys import warnings @@ -21,7 +22,6 @@ from sdm_client import ( KumoRelational, KumoTabular, - MissingExtraError, NimRequestError, PredictiveQuery, Request, @@ -379,8 +379,24 @@ def test_a_model_left_in_training_mode_is_named_not_run(tabular): tabular.eval() -def test_without_the_extra_the_extra_is_named(monkeypatch): - monkeypatch.setitem(sys.modules, 'sdm', None) - with pytest.raises(MissingExtraError) as caught: - SDMClient.in_process() - assert caught.value.details['extra'] == 'in-process' +def test_without_the_extra_the_extra_is_named(): + """In a fresh interpreter, since nothing the extra brings may already be + imported: a module imported at the top of the in-process package once + failed before the check that names the extra.""" + script = ( + 'import sys\n' + "for name in ('sdm', 'torch', 'pyarrow'):\n" + ' sys.modules[name] = None\n' + 'from sdm_client import MissingExtraError, SDMClient\n' + 'try:\n' + ' SDMClient.in_process()\n' + 'except MissingExtraError as error:\n' + " print(error.details['extra'])\n" + ) + run = subprocess.run( + [sys.executable, '-c', script], + capture_output=True, + text=True, + check=False, + ) + assert run.stdout.strip() == 'in-process', run.stderr From 2497a8d0fbc8e9052916a994259ef589fe773b00 Mon Sep 17 00:00:00 2001 From: Manush Murali Date: Fri, 16 Oct 2026 16:22:22 -0700 Subject: [PATCH 03/17] Refuse a table the model cannot hold as the NIM does A request whose table the model cannot represent, such as a single-table context holding only its key and target, raised inside the model and came back as a 500 INTERNAL_ERROR. The NIM refuses the same request with 422 VALIDATION_FAILED, "table cannot be represented by SDM", and labels the model cannot hold with 422 TASK_SCHEMA_MISMATCH. Both are answered that way here now, for every table rather than for the one shape that surfaced it. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Manush Murali --- .../src/sdm_client/in_process/columns.py | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/structured-data-models-client/src/sdm_client/in_process/columns.py b/packages/structured-data-models-client/src/sdm_client/in_process/columns.py index 4d00170da..b477f1e5d 100644 --- a/packages/structured-data-models-client/src/sdm_client/in_process/columns.py +++ b/packages/structured-data-models-client/src/sdm_client/in_process/columns.py @@ -10,6 +10,7 @@ from dataclasses import dataclass, field, replace from typing import Any +from sdm_client.errors import StructuredDataError from sdm_client.in_process.refusal import refusal from sdm_client.in_process.tables import to_arrow, values @@ -115,7 +116,14 @@ def tensor(self, table: Mapping[str, Any], where: str) -> Any: f'{where} omits column(s) {absent} that the context carries', ) arrow = to_arrow(table, self.columns, self.dtypes, self.keys) - return TableTensor.from_arrow(arrow, stypes=self.stypes, device='cpu') + try: + return TableTensor.from_arrow( + arrow, stypes=self.stypes, device='cpu' + ) + except (TypeError, ValueError) as error: + raise refusal( + 'VALIDATION_FAILED', 'table cannot be represented by SDM' + ) from error @dataclass @@ -160,9 +168,17 @@ class is matched to an encoded one by its text. 'TASK_SCHEMA_MISMATCH', f'context target {column!r} contains null labels', ) - arrow = to_arrow(table, [column], {column: target.get('dtype', 'float64')}) stype = 'categorical' if classifies else 'numerical' - y = TableTensor.from_arrow(arrow, stypes={column: stype}, device='cpu') + try: + arrow = to_arrow( + table, [column], {column: target.get('dtype', 'float64')} + ) + y = TableTensor.from_arrow(arrow, stypes={column: stype}, device='cpu') + except (StructuredDataError, TypeError, ValueError) as error: + raise refusal( + 'TASK_SCHEMA_MISMATCH', + 'context labels cannot be represented by SDM', + ) from error if not classifies: return y, (), () encoded = tuple(y.categorical.categories[0].tolist()) From 51b78f122f9b304cc70585ac641f0705727c1574 Mon Sep 17 00:00:00 2001 From: Manush Murali Date: Fri, 16 Oct 2026 16:22:22 -0700 Subject: [PATCH 04/17] Lock the in-process extra, and say which Python it needs uv.lock is what the open source scan inventories, and it resolves every extra, so structured-data-models and what it brings belong in it. It could not be relocked: the workspace admits Python 3.10 and structured-data-models needs 3.11. The extra now carries that marker, so on 3.10 it installs nothing, and in_process() says it needs Python 3.11 rather than asking for an extra that was already installed. The lock gains structured-data-models, PyTorch and PyTorch's CUDA wheels for Linux, and nothing else in it moves. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Manush Murali --- docs/get-started/installation.md | 2 +- .../pyproject.toml | 8 +- .../src/sdm_client/in_process/transport.py | 12 + .../test_a_model_runs_in_this_process.py | 25 +- uv.lock | 365 +++++++++++++++++- 5 files changed, 401 insertions(+), 11 deletions(-) diff --git a/docs/get-started/installation.md b/docs/get-started/installation.md index 3f0792310..a034ba635 100644 --- a/docs/get-started/installation.md +++ b/docs/get-started/installation.md @@ -7,7 +7,7 @@ | `pip install 'structured-data-models-client[snowflake]'` | Reads tables from a warehouse. Also `[sqlite]`, `[duckdb]`, `[postgres]`, `[databricks]`, `[lakebase]`, `[s3]`. | | `pip install 'structured-data-models-client[all]'` | The relational model and every warehouse backend. | | `pip install 'structured-data-models-client[databricks-serving]'` | Reaches a model served on Databricks Model Serving, through `SDMClient.for_databricks_serving`. Not in `[all]`: it is a way to reach a model, not a backend. | -| `pip install 'structured-data-models-client[in-process]'` | Runs the models in this Python process with structured-data-models, through `SDMClient.in_process`. Brings PyTorch. **Python 3.11 or later**: on 3.10 pip reports `No matching distribution found for structured-data-models`. Not in `[all]`, for the same reason as `[databricks-serving]`. | +| `pip install 'structured-data-models-client[in-process]'` | Runs the models in this Python process with structured-data-models, through `SDMClient.in_process`. Brings PyTorch. **Python 3.11 or later**: on 3.10 the extra installs nothing, and `in_process()` says why. Not in `[all]`, for the same reason as `[databricks-serving]`. | **What you install is not what you import.** The five distributions are `structured-data-models-client`, `-api`, `-pql`, `-connectors` and diff --git a/packages/structured-data-models-client/pyproject.toml b/packages/structured-data-models-client/pyproject.toml index 9fd622e72..a057f878e 100644 --- a/packages/structured-data-models-client/pyproject.toml +++ b/packages/structured-data-models-client/pyproject.toml @@ -79,10 +79,10 @@ s3 = ["structured-data-models-connectors[s3]==1.0.0"] databricks-serving = ["databricks-sdk>=0.68.0,<1.0"] # Running the models in this process rather than reaching a NIM. Like # `databricks-serving` it is a way to reach a model, and it brings PyTorch, so -# `all` does not carry it. structured-data-models needs Python 3.11, so this -# extra does not resolve on 3.10, where pip names the distribution rather than -# the reason; the installation guide says so. -in-process = ["structured-data-models>=0.2.1,<0.3"] +# `all` does not carry it. structured-data-models needs Python 3.11; on 3.10 +# the extra installs nothing, and `SDMClient.in_process()` says why. The marker +# is also what lets the workspace lock resolve. +in-process = ["structured-data-models>=0.2.1,<0.3; python_version >= '3.11'"] all = [ "structured-data-models-client[relational,sqlite,duckdb,postgres,snowflake,databricks,lakebase,s3]", ] diff --git a/packages/structured-data-models-client/src/sdm_client/in_process/transport.py b/packages/structured-data-models-client/src/sdm_client/in_process/transport.py index fab4e8d6a..cd3e38115 100644 --- a/packages/structured-data-models-client/src/sdm_client/in_process/transport.py +++ b/packages/structured-data-models-client/src/sdm_client/in_process/transport.py @@ -15,6 +15,7 @@ import contextlib import copy import json +import sys import threading import uuid from collections.abc import Iterator, Mapping, Sequence @@ -248,6 +249,17 @@ def _require_sdm() -> Any: import sdm # noqa: F401 import torch except ImportError as error: + if sys.version_info < (3, 11): + raise StructuredDataError( + f'running a model in process needs structured-data-models, ' + f'which needs Python 3.11 or later; this is Python ' + f'{sys.version_info.major}.{sys.version_info.minor}', + code='MISSING_EXTRA', + details={ + 'extra': 'in-process', + 'package': 'structured-data-models', + }, + ) from error raise MissingExtraError( 'in-process', 'structured-data-models' ) from error diff --git a/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py b/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py index 333fa8297..48f129101 100644 --- a/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py +++ b/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py @@ -253,6 +253,15 @@ def test_each_refusal_is_the_one_a_nim_answers_with(relational, tabular): ).materialize(indices=[0, 1]), model=KumoRelational(), ), + ('VALIDATION_FAILED', 422): lambda: client.predict( + Request( + context=people().iloc[:40][['person_id', 'churn']], + predict=people().iloc[40:][['person_id']], + target='churn', + task='classification', + ), + model=KumoTabular(), + ), ('UNSUPPORTED_DTYPE', 422, 'seen'): lambda: client.predict( Request( context=people() @@ -382,16 +391,17 @@ def test_a_model_left_in_training_mode_is_named_not_run(tabular): def test_without_the_extra_the_extra_is_named(): """In a fresh interpreter, since nothing the extra brings may already be imported: a module imported at the top of the in-process package once - failed before the check that names the extra.""" + failed before the check that names the extra. On Python 3.10, where the + extra installs nothing, the refusal says which Python it needs.""" script = ( 'import sys\n' "for name in ('sdm', 'torch', 'pyarrow'):\n" ' sys.modules[name] = None\n' - 'from sdm_client import MissingExtraError, SDMClient\n' + 'from sdm_client import SDMClient, StructuredDataError\n' 'try:\n' ' SDMClient.in_process()\n' - 'except MissingExtraError as error:\n' - " print(error.details['extra'])\n" + 'except StructuredDataError as error:\n' + " print(error.code, error.details['extra'], '3.11' in str(error))\n" ) run = subprocess.run( [sys.executable, '-c', script], @@ -399,4 +409,9 @@ def test_without_the_extra_the_extra_is_named(): text=True, check=False, ) - assert run.stdout.strip() == 'in-process', run.stderr + names_the_python = sys.version_info < (3, 11) + assert run.stdout.split() == [ + 'MISSING_EXTRA', + 'in-process', + str(names_the_python), + ], run.stderr diff --git a/uv.lock b/uv.lock index 99c38bc33..be6a79353 100644 --- a/uv.lock +++ b/uv.lock @@ -596,6 +596,81 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/1d/1271f287ff7170ddafc2aad36260c4eec20ccd2fea70f38455e9d56d427b/cryptography-50.0.2-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:7b75de3c8b3be1cdb1052747c929440c3eea46c1bc2cb8a6e3a48388e9b7b452", size = 3805376, upload-time = "2026-09-30T15:29:58.729Z" }, ] +[[package]] +name = "cuda-bindings" +version = "13.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-pathfinder", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/1e/d90a8af3d589776c70e92d43d7a0234260709a1d552d105f4dd3bdf48fa3/cuda_bindings-13.4.4-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90f85a00fc89dd4a8f7c4beb0a2f3f788b845c8496fe84adc1217830206db9e0", size = 6512106, upload-time = "2026-10-14T12:35:04.07Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/8c23958012acc7558cdc3d453b3078742c3e6051cc8be2860e6bfbdf00fd/cuda_bindings-13.4.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85d961133658fff3167cfcf69de1beac8e46453c15fd4bcaa4a96d3ef1110c69", size = 7160770, upload-time = "2026-10-14T12:35:06.501Z" }, + { url = "https://files.pythonhosted.org/packages/a1/15/0277326dd1a2d619ea8a85b790fc9e43cb67897d3a3377eb783f5bc2a33a/cuda_bindings-13.4.4-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8b2e4235b552edb42cdbe39de7a9259ceec631e21c7344b76177db9cc6c58b4", size = 6494096, upload-time = "2026-10-14T12:35:11.314Z" }, + { url = "https://files.pythonhosted.org/packages/97/36/3bfb7b74817a0a78f80340fae742a27d166e8fcdffa459b79d5be66e077a/cuda_bindings-13.4.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1cef1eaf5a08cc3bf3675ca9d3f97f3fc7085998556c848b4053e74ba4ba392", size = 7160763, upload-time = "2026-10-14T12:35:13.773Z" }, + { url = "https://files.pythonhosted.org/packages/d7/38/87909f62598d02d9e5c536326aed1755f6bfa190285507b962bb601e0005/cuda_bindings-13.4.4-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b346bfe1dda49537537c06cc4727b2210d949d8aee883cb591bca46581629d00", size = 6488222, upload-time = "2026-10-14T12:35:20.703Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f8/332443cf969734551891cdbd93657eaeaf747ac2b7dd73feb3422701b02b/cuda_bindings-13.4.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:433efa31e33c2868621b5f6036bd3bf5de9c45c5546cb1361a5f6ff3415b8bb5", size = 7172184, upload-time = "2026-10-14T12:35:22.817Z" }, + { url = "https://files.pythonhosted.org/packages/a7/97/7d75105a92ee18b7764f5cf60672d6a966842348a2a049e663fe690902ff/cuda_bindings-13.4.4-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3f4cc887eac0d5edb5d7396cc78b336bee151a97e2ba7acf59e8b9380132d0", size = 6316532, upload-time = "2026-10-14T12:35:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8f/aedec367fea64267594a26ede3704f241e1bee465962f3a92d6186c774b7/cuda_bindings-13.4.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d6adc44f098472d8ac2d6211923eea6081830521852dc77f8c9237bf9320a59", size = 6929120, upload-time = "2026-10-14T12:35:32.508Z" }, +] + +[[package]] +name = "cuda-pathfinder" +version = "1.8.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fb/f8e1890428f9f590b4beebd63b068aac1ce32a3331510c847b9f9a78f261/cuda_pathfinder-1.8.3-py3-none-any.whl", hash = "sha256:e29e59829c297a7a5233bd9cc71094fc5bddbd076951482670178f9eade39b1f", size = 62561, upload-time = "2026-10-02T03:20:23.712Z" }, +] + +[[package]] +name = "cuda-toolkit" +version = "13.0.3.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/c7/a79086a62c98befcdb8349656c6f114e2db3b8b2422f6e25c97a7f2a9a3c/cuda_toolkit-13.0.3.0-py2.py3-none-any.whl", hash = "sha256:d693caaa261214ddd7dbb60d68e71cbed884e68c2be7509778f3051da0b91c3f", size = 2512, upload-time = "2026-04-14T00:50:08.173Z" }, +] + +[package.optional-dependencies] +cublas = [ + { name = "nvidia-cublas", marker = "(python_full_version >= '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cuda-nvrtc", marker = "(python_full_version >= '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux')" }, +] +cudart = [ + { name = "nvidia-cuda-runtime", marker = "(python_full_version >= '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux')" }, +] +cufft = [ + { name = "nvidia-cufft", marker = "(python_full_version >= '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvjitlink", marker = "(python_full_version >= '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux')" }, +] +cufile = [ + { name = "nvidia-cufile", marker = "(python_full_version >= '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux')" }, +] +cupti = [ + { name = "nvidia-cuda-cupti", marker = "(python_full_version >= '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux')" }, +] +curand = [ + { name = "nvidia-curand", marker = "(python_full_version >= '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux')" }, +] +cusolver = [ + { name = "nvidia-cublas", marker = "(python_full_version >= '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cusolver", marker = "(python_full_version >= '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cusparse", marker = "(python_full_version >= '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvjitlink", marker = "(python_full_version >= '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux')" }, +] +cusparse = [ + { name = "nvidia-cusparse", marker = "(python_full_version >= '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvjitlink", marker = "(python_full_version >= '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux')" }, +] +nvjitlink = [ + { name = "nvidia-nvjitlink", marker = "(python_full_version >= '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux')" }, +] +nvrtc = [ + { name = "nvidia-cuda-nvrtc", marker = "(python_full_version >= '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux')" }, +] +nvtx = [ + { name = "nvidia-nvtx", marker = "(python_full_version >= '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux')" }, +] + [[package]] name = "databricks-sdk" version = "0.149.0" @@ -872,6 +947,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "hf-xet" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/27/06d899ea7bd721d272f84aac98bdb238de98af4cc767a69056d967d68c71/hf_xet-1.7.0.tar.gz", hash = "sha256:d406ec79053c0871817f700c2ac8c36ba0d87f9c34b7458b0f0063bb218b0466", size = 985689, upload-time = "2026-10-06T20:18:43.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/0b/b03be21ffaada749ba0d3197d8aefbf1aa698bac149580421c15239b299e/hf_xet-1.7.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e3e88a7a75d7d95cbee1f37dc31341d6201124cf21c6c4b1dfab8ccba9b09e0f", size = 3796096, upload-time = "2026-10-06T20:18:28.43Z" }, + { url = "https://files.pythonhosted.org/packages/c3/47/a26ebdce7056a61e931f228439bc0ab08cbec239d1690f965e5e637cba79/hf_xet-1.7.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:59fba37039233c7fcbe196817d6cdcf1b40dfb17b410f229d85b0cf0a1848da4", size = 3560352, upload-time = "2026-10-06T20:18:30.365Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4c/2bf3b66c215d409655f28de1622393dde04c9461280d48c7924bb3b2decd/hf_xet-1.7.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2814a6e999d13464c4d679b788cc5d784eb5a4edfc638a31f10e9a11ab531ef8", size = 4212180, upload-time = "2026-10-06T20:18:32.292Z" }, + { url = "https://files.pythonhosted.org/packages/49/0c/a2f703a5a78267556e89e03316fa0805c86b72b50829bc67665746e8ebf0/hf_xet-1.7.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:fcfd6c22418e57dd5b3aea649e813b2e2cfb2aebf317b210d90f1fe4b3018b52", size = 3990011, upload-time = "2026-10-06T20:18:34.21Z" }, + { url = "https://files.pythonhosted.org/packages/a4/77/e52e4201b1cbf571530a61cc57f70182045a39a230089ee5f1df182a4de2/hf_xet-1.7.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:80f79dae613ce9e0ea1fd1ae15616ca9ac74aed4c770aabc199c4f03ebecc863", size = 4190628, upload-time = "2026-10-06T20:18:36.062Z" }, + { url = "https://files.pythonhosted.org/packages/6c/dc/03a21b89f118664a0926ff25b0f8e44a519bf22724a6a8fc7a9abbc188b6/hf_xet-1.7.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:0a9e802f33bf50c851abe45fc5380e61f959e2d369647d6742b79ad9d6c27cab", size = 4418814, upload-time = "2026-10-06T20:18:37.888Z" }, + { url = "https://files.pythonhosted.org/packages/4d/59/b35106dfa71b6eef605dc88bd038fe99c7f86fb132a15b60d0bf2f235b2c/hf_xet-1.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:2b7bb5727889b0f2436dbaaad8fc4c3e66b8240d992716989e0c086b4278b1bc", size = 3822644, upload-time = "2026-10-06T20:18:40.052Z" }, + { url = "https://files.pythonhosted.org/packages/48/cd/072313585f74fe9d441e2eb5e0a4703c30586cd709810ea369675f61b74e/hf_xet-1.7.0-cp38-abi3-win_arm64.whl", hash = "sha256:acc3851cf2576a8fb2ae926da863f4efabe21303cf292e9a44332802ab0dcc6a", size = 3662436, upload-time = "2026-10-06T20:18:42.205Z" }, +] + [[package]] name = "httpcore2" version = "2.13.1" @@ -911,6 +1002,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, ] +[[package]] +name = "huggingface-hub" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click", marker = "python_full_version >= '3.11'" }, + { name = "filelock", marker = "python_full_version >= '3.11'" }, + { name = "fsspec", marker = "python_full_version >= '3.11'" }, + { name = "hf-xet", marker = "(python_full_version >= '3.11' and platform_machine == 'AMD64') or (python_full_version >= '3.11' and platform_machine == 'ARM64') or (python_full_version >= '3.11' and platform_machine == 'aarch64') or (python_full_version >= '3.11' and platform_machine == 'amd64') or (python_full_version >= '3.11' and platform_machine == 'arm64') or (python_full_version >= '3.11' and platform_machine == 'x86_64')" }, + { name = "httpx2", marker = "python_full_version >= '3.11'" }, + { name = "packaging", marker = "python_full_version >= '3.11'" }, + { name = "pyyaml", marker = "python_full_version >= '3.11'" }, + { name = "tqdm", marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/47/6858d63643e66fb4f6585c3cfd4029c0b2bc1ae21688cee9b3335f20a10d/huggingface_hub-2.2.0.tar.gz", hash = "sha256:5d1b47537394e4215cb858aa12fd493d0f7ef7f58990f5dcd24bc173107b2871", size = 1041026, upload-time = "2026-10-08T15:30:59.971Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/b0/0f7b430fd100b3a3b037fdbb314878200241082e607b3383c63d91a13a72/huggingface_hub-2.2.0-py3-none-any.whl", hash = "sha256:1667f145dc56dc210d60966069397df9ecfca9607a5d43db88b308c89dae56b3", size = 839884, upload-time = "2026-10-08T15:30:57.914Z" }, +] + [[package]] name = "idna" version = "3.20" @@ -1544,6 +1655,158 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/2e/86f24451c2d530c88daf997cb8d6ac622c1d40d19f5a031ed68a4b73a374/numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818", size = 15517754, upload-time = "2024-02-05T23:58:36.364Z" }, ] +[[package]] +name = "nvidia-cublas" +version = "13.1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cd/154ca20c38269e05eff77c1464e6c1da89f50a6390b565e9d82e06bc11e1/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:37936a16db8fe4ac1f065c2139360608a543a09275cb1a1af612e08cfa065436", size = 423138758, upload-time = "2026-04-08T18:46:58.655Z" }, +] + +[[package]] +name = "nvidia-cuda-cupti" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/2a/80353b103fc20ce05ef51e928daed4b6015db4aaa9162ed0997090fe2250/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_aarch64.whl", hash = "sha256:796bd679890ee55fb14a94629b698b6db54bcfd833d391d5e94017dd9d7d3151", size = 10310827, upload-time = "2025-09-04T08:26:42.012Z" }, + { url = "https://files.pythonhosted.org/packages/33/6d/737d164b4837a9bbd202f5ae3078975f0525a55730fe871d8ed4e3b952b0/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_x86_64.whl", hash = "sha256:4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8", size = 10715597, upload-time = "2025-09-04T08:26:51.312Z" }, +] + +[[package]] +name = "nvidia-cuda-nvrtc" +version = "13.0.88" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/68/483a78f5e8f31b08fb1bb671559968c0ca3a065ac7acabfc7cee55214fd6/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575", size = 90215200, upload-time = "2025-09-04T08:28:44.204Z" }, + { url = "https://files.pythonhosted.org/packages/b7/dc/6bb80850e0b7edd6588d560758f17e0550893a1feaf436807d64d2da040f/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d27f20a0ca67a4bb34268a5e951033496c5b74870b868bacd046b1b8e0c3267b", size = 43015449, upload-time = "2025-09-04T08:28:20.239Z" }, +] + +[[package]] +name = "nvidia-cuda-runtime" +version = "13.0.96" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/4f/17d7b9b8e285199c58ce28e31b5c5bbaa4d8271af06a89b6405258245de2/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef9bcbe90493a2b9d810e43d249adb3d02e98dd30200d86607d8d02687c43f55", size = 2261060, upload-time = "2025-10-09T08:55:15.78Z" }, + { url = "https://files.pythonhosted.org/packages/2e/24/d1558f3b68b1d26e706813b1d10aa1d785e4698c425af8db8edc3dced472/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548", size = 2243632, upload-time = "2025-10-09T08:55:36.117Z" }, +] + +[[package]] +name = "nvidia-cudnn-cu13" +version = "9.24.0.43" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/30/7c257e3d5cb4fecb147b93895c66e29c93f8e76d74b45bb418ff0587c4ec/nvidia_cudnn_cu13-9.24.0.43-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:a6812a554a1ff0413e9c52b84c26c050380649ab9615f9c16bded368ce9f421f", size = 650976863, upload-time = "2026-07-02T16:23:39.248Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ba/791cffd048fe5b044e620df55267e3e95c0e6e07d50b41e377c03dfc910f/nvidia_cudnn_cu13-9.24.0.43-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:71f181cd810e90f9b6023b01186fe82d13d65f0ec098581ee201d39fad769e4b", size = 553099438, upload-time = "2026-07-02T16:27:42.58Z" }, +] + +[[package]] +name = "nvidia-cufft" +version = "12.0.0.61" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2f/7b57e29836ea8714f81e9898409196f47d772d5ddedddf1592eadb8ab743/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3", size = 214085489, upload-time = "2025-09-04T08:31:56.044Z" }, +] + +[[package]] +name = "nvidia-cufile" +version = "1.15.1.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/70/4f193de89a48b71714e74602ee14d04e4019ad36a5a9f20c425776e72cd6/nvidia_cufile-1.15.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44", size = 1223672, upload-time = "2025-09-04T08:32:22.779Z" }, + { url = "https://files.pythonhosted.org/packages/ab/73/cc4a14c9813a8a0d509417cf5f4bdaba76e924d58beb9864f5a7baceefbf/nvidia_cufile-1.15.1.6-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:bdc0deedc61f548bddf7733bdc216456c2fdb101d020e1ab4b88d232d5e2f6d1", size = 1136992, upload-time = "2025-09-04T08:32:14.119Z" }, +] + +[[package]] +name = "nvidia-curand" +version = "10.4.0.35" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/72/7c2ae24fb6b63a32e6ae5d241cc65263ea18d08802aaae087d9f013335a2/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:133df5a7509c3e292aaa2b477afd0194f06ce4ea24d714d616ff36439cee349a", size = 61962106, upload-time = "2025-08-04T10:21:41.128Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9f/be0a41ca4a4917abf5cb9ae0daff1a6060cc5de950aec0396de9f3b52bc5/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc", size = 59544258, upload-time = "2025-08-04T10:22:03.992Z" }, +] + +[[package]] +name = "nvidia-cusolver" +version = "12.0.4.66" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cusparse", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, + { url = "https://files.pythonhosted.org/packages/5f/67/cba3777620cdacb99102da4042883709c41c709f4b6323c10781a9c3aa34/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112", size = 200941980, upload-time = "2025-09-04T08:33:22.767Z" }, +] + +[[package]] +name = "nvidia-cusparse" +version = "12.6.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, + { url = "https://files.pythonhosted.org/packages/fa/18/623c77619c31d62efd55302939756966f3ecc8d724a14dab2b75f1508850/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b", size = 145942937, upload-time = "2025-09-04T08:33:58.029Z" }, +] + +[[package]] +name = "nvidia-cusparselt-cu13" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/e1/cdc1797eadf82d3a9a575a19b33fdc871a97edbec42c00b5b5e914f4aff4/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:4dca476c50bf4780d46cd0bfbd82e2bc10a08e4fef7950917ce8d7578d22a23f", size = 221051344, upload-time = "2025-09-05T18:49:51.289Z" }, + { url = "https://files.pythonhosted.org/packages/34/7d/2661f2fb3ac4302f3a246f5fc030213ac60c1fe0bce84f9783dbd831dbb7/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:786ce87568c303fadb5afcc7102d454cd3040d75f6f8626f5db460d1871f4dd0", size = 170148586, upload-time = "2025-09-05T18:50:50.248Z" }, +] + +[[package]] +name = "nvidia-nccl-cu13" +version = "2.30.7" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/21/a73174c6157101bdf1ffc22b517f76ff0082613989dd9bc8f43e8034caac/nvidia_nccl_cu13-2.30.7-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:ca786ffa5a647c75d4d1f5cc72a6c4f537947e2ba8823d7c8aaf768e7a7b9f77", size = 215983881, upload-time = "2026-06-09T03:23:15.633Z" }, + { url = "https://files.pythonhosted.org/packages/3f/34/c500f90c7ae641b8e0f98965b36b8a7ac79cc8b296e8d251fe3eb592ee54/nvidia_nccl_cu13-2.30.7-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:cefa7fdb9710efd0f39c5f1be1d61ff6fc9a996c451265bd7fbdcf9455ed4b50", size = 215965170, upload-time = "2026-06-09T03:23:39.73Z" }, +] + +[[package]] +name = "nvidia-nvjitlink" +version = "13.4.92" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/6b/eef7a9e32872b8f41e145bf10cddc9af26e153c338852811fe9a9baddf9e/nvidia_nvjitlink-13.4.92-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:e0391f24ed94ec879b84e3da4d4ec320c879aff681f2c7a638462f7199284323", size = 42452378, upload-time = "2026-09-16T20:45:29.042Z" }, + { url = "https://files.pythonhosted.org/packages/1f/a8/1cbd4014898af8b419e69b0d7dbc63da2121ee92d92b47d59f4fe9075349/nvidia_nvjitlink-13.4.92-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:25f74fad0d654271c921ac4dca614bd6258bc21791242fc7b2289dad7ae9c099", size = 40420120, upload-time = "2026-09-16T20:45:19.163Z" }, +] + +[[package]] +name = "nvidia-nvshmem-cu13" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/0f/05cc9c720236dcd2db9c1ab97fff629e96821be2e63103569da0c9b72f19/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dc2a197f38e5d0376ad52cd1a2a3617d3cdc150fd5966f4aee9bcebb1d68fe9", size = 60215947, upload-time = "2025-09-06T00:32:20.022Z" }, + { url = "https://files.pythonhosted.org/packages/3c/35/a9bf80a609e74e3b000fef598933235c908fcefcef9026042b8e6dfde2a9/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80", size = 60412546, upload-time = "2025-09-06T00:32:41.564Z" }, +] + +[[package]] +name = "nvidia-nvtx" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/f3/d86c845465a2723ad7e1e5c36dcd75ddb82898b3f53be47ebd429fb2fa5d/nvidia_nvtx-13.0.85-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4", size = 148047, upload-time = "2025-09-04T08:29:01.761Z" }, + { url = "https://files.pythonhosted.org/packages/a8/64/3708a90d1ebe202ffdeb7185f878a3c84d15c2b2c31858da2ce0583e2def/nvidia_nvtx-13.0.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb7780edb6b14107373c835bf8b72e7a178bac7367e23da7acb108f973f157a6", size = 148878, upload-time = "2025-09-04T08:28:53.627Z" }, +] + [[package]] name = "oauthlib" version = "3.3.1" @@ -2683,6 +2946,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl", hash = "sha256:d8168eccca828cbb2cd573675333f3bddd254313a9c42494b84c76b539e8ba25", size = 90216, upload-time = "2026-07-22T19:30:43.251Z" }, ] +[[package]] +name = "safetensors" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/06/f955dbbb1859e3bd23c8ac6141af5106e7ad5fedec4a3a6e3d60f94b7001/safetensors-0.8.0.tar.gz", hash = "sha256:fabaf3e0f18a6618d9b36560682562157f77c2b71fcffc7b432be2baed9d753d", size = 325846, upload-time = "2026-06-09T07:52:25.563Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/a0/f718cda65b05407d228f97602cf60dca269c979867aa5beb25410de26cd3/safetensors-0.8.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c554f85858e05226d3c2828e32395e677434685d6d94594a41643361c5e837f0", size = 473568, upload-time = "2026-06-09T07:52:18.829Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b1/fa7c600e7dceae12e9606c7578cbc9ff1e1ed55844883ee5c92205e86226/safetensors-0.8.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c80201d22cbf405b80647a60ada77bba06c8fba2da2743ba1e89cdcc39a81f25", size = 484562, upload-time = "2026-06-09T07:52:17.518Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/65a7de0af421317bb36a067241e4235fff194eed60b961ed6d3f59a3fc60/safetensors-0.8.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a46e5ff292c356d6991e60942ba7f79817682d3a2cef0702136448cb9c4d235", size = 502844, upload-time = "2026-06-09T07:52:07.624Z" }, + { url = "https://files.pythonhosted.org/packages/91/4f/3175c9d75634e0e0dda0082794193521035edd7c70a6f212bf33ca06ddf4/safetensors-0.8.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4124502b78f03534117c848f87a39b8f31e577b15eff423bf8bfb95f2a8c30d0", size = 511823, upload-time = "2026-06-09T07:52:09.565Z" }, + { url = "https://files.pythonhosted.org/packages/20/87/846c289e7aa2299eff406335717cf43ce8777194ece8aad75772e0411615/safetensors-0.8.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7bc0a787ba8a35be368ee3574edfa2b1ad389eebd0a72e482ae275490e3f6c98", size = 633461, upload-time = "2026-06-09T07:52:11.128Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/8d64d9df2c45d5ded401df889d0ad90882804ca172d79ec4f0df8f727fe0/safetensors-0.8.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:040070828e36dc8e122178bbbd5830ff9e97920affb84cbe0f46442497bed358", size = 545148, upload-time = "2026-06-09T07:52:13.603Z" }, + { url = "https://files.pythonhosted.org/packages/28/50/f203ff3a3ddfe19308efc83c5a3a29ed02bf786732ec35e68bf9162f3365/safetensors-0.8.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd6f3f93c9a0a7cc2788ee63fb763353d4bd2e89b0751bc78fcf7dda00bea774", size = 516040, upload-time = "2026-06-09T07:52:16.29Z" }, + { url = "https://files.pythonhosted.org/packages/46/fb/cdaed17ceb2948784fd9c36b6fd3e951b608547cea81a48e8ee6f8cfdfcb/safetensors-0.8.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:fcdd41ec4628fee5799f807c73c353629130fbd942aa23d83c623dd6c9d52d78", size = 513832, upload-time = "2026-06-09T07:52:12.37Z" }, + { url = "https://files.pythonhosted.org/packages/0d/49/1e15de264dcc3b77943d2d0c56a95809956883b1c2d6d585c792523f180b/safetensors-0.8.0-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8e9f537aa183a38ace122d27303dcd986b26bd2a7591f9181d7f0c396f4677ca", size = 559930, upload-time = "2026-06-09T07:52:14.743Z" }, + { url = "https://files.pythonhosted.org/packages/2a/43/bf38443278eab4b1be1fce2931e2b012ad9cb7df52ada751d0aab8f7659a/safetensors-0.8.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:87eec7ffed2b809f05a398a8becb7d013f19f7837cd15d9748580d6cf30dbaf4", size = 678670, upload-time = "2026-06-09T07:52:20.032Z" }, + { url = "https://files.pythonhosted.org/packages/72/e3/68cd3fa5b48488e84add63e04cb12f3bc28ae4638c06d4508c6e88823d0e/safetensors-0.8.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:4a95ae2b05d7726d751da4ebf626a2ca782b706e101bd894c95bc2450b1cffcc", size = 786679, upload-time = "2026-06-09T07:52:21.322Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/1c19c509d56e01f4fbb3d0a2e597450f6cc04d1d56cf52defb0a62dfd715/safetensors-0.8.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae091f16662658bdc019a4ff6cb4c085bb7d725eb5978b183ffd265863b6d2d", size = 765683, upload-time = "2026-06-09T07:52:22.594Z" }, + { url = "https://files.pythonhosted.org/packages/27/43/41c1621732edd934d868a00d1b891584c892a7b62a9aab82ea5a0a5623ee/safetensors-0.8.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8e080062fcde23be189565e1c3305d16751a218ecf9412c8601e64204eb6f846", size = 722361, upload-time = "2026-06-09T07:52:23.924Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3f/73ccf82579412b4a71c4ca673f10b5f1f888d7cf5af7fe24f27d30307be4/safetensors-0.8.0-cp310-abi3-win32.whl", hash = "sha256:2ddf52eac562eda224f99acfa7889d02968c1fd59a5b011ae7d8137c37e9c02d", size = 342401, upload-time = "2026-06-09T07:52:28.895Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6d/3fba214c1e5e0f69991677ec3bc17023f0421776975e1de0c682dca475e2/safetensors-0.8.0-cp310-abi3-win_amd64.whl", hash = "sha256:096ec1a98435df7beb08853bb5aa9081a84f23d0adc67ed1a0a10550f608373f", size = 355540, upload-time = "2026-06-09T07:52:27.832Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fc/7eedc3510d97878876e32774eebbeb61c43f148a96e915c84229a3e967aa/safetensors-0.8.0-cp310-abi3-win_arm64.whl", hash = "sha256:f7838e5135a406ad3e02efdcb8cf2e5397d368b0154537c4fec682dbc544d452", size = 340500, upload-time = "2026-06-09T07:52:26.745Z" }, +] + [[package]] name = "setuptools" version = "84.0.0" @@ -2786,6 +3073,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, ] +[[package]] +name = "structured-data-models" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub", marker = "python_full_version >= '3.11'" }, + { name = "numpy", marker = "python_full_version >= '3.11'" }, + { name = "pyarrow", marker = "python_full_version >= '3.11'" }, + { name = "safetensors", marker = "python_full_version >= '3.11'" }, + { name = "torch", marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/33/cb15b007fc67fe53c9bff242e1ca9b7d57229ac39f82e3081db828405d1b/structured_data_models-0.2.1.tar.gz", hash = "sha256:035ead2627dff755987287fd13f7864b3a7be2c4b0bdc060a94add8d93c29a26", size = 2220106, upload-time = "2026-10-14T17:12:56.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/0e/cf1f2d36c52deb723b3d2e26f655c5adc28648853e62c1dec67e94d84de4/structured_data_models-0.2.1-py3-none-any.whl", hash = "sha256:4fa9a422dc3c55033cdb14096cae024b743b543ef2a8feb4b7cffb21c966f3da", size = 283697, upload-time = "2026-10-14T17:12:54.1Z" }, +] + [[package]] name = "structured-data-models-api" source = { editable = "packages/structured-data-models-api" } @@ -2832,6 +3136,9 @@ databricks-serving = [ duckdb = [ { name = "structured-data-models-connectors", extra = ["duckdb"] }, ] +in-process = [ + { name = "structured-data-models", marker = "python_full_version >= '3.11'" }, +] lakebase = [ { name = "structured-data-models-connectors", extra = ["lakebase"] }, ] @@ -2863,6 +3170,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'test'" }, { name = "requests", specifier = ">=2.28" }, { name = "requests-mock", marker = "extra == 'test'" }, + { name = "structured-data-models", marker = "python_full_version >= '3.11' and extra == 'in-process'", specifier = ">=0.2.1,<0.3" }, { name = "structured-data-models-api", editable = "packages/structured-data-models-api" }, { name = "structured-data-models-client", extras = ["databricks", "duckdb", "lakebase", "postgres", "relational", "s3", "snowflake", "sqlite"], marker = "extra == 'all'", editable = "packages/structured-data-models-client" }, { name = "structured-data-models-connectors", extras = ["databricks"], marker = "extra == 'databricks'", editable = "packages/structured-data-models-connectors" }, @@ -2876,7 +3184,7 @@ requires-dist = [ { name = "structured-data-models-relational-engine", marker = "extra == 'relational'", editable = "packages/structured-data-models-relational-engine" }, { name = "urllib3", specifier = ">=1.26" }, ] -provides-extras = ["relational", "sqlite", "duckdb", "snowflake", "databricks", "postgres", "lakebase", "s3", "databricks-serving", "all", "test"] +provides-extras = ["relational", "sqlite", "duckdb", "snowflake", "databricks", "postgres", "lakebase", "s3", "databricks-serving", "in-process", "all", "test"] [[package]] name = "structured-data-models-connectors" @@ -3173,6 +3481,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304", size = 49449, upload-time = "2026-07-17T01:48:05.728Z" }, ] +[[package]] +name = "torch" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-bindings", marker = "python_full_version >= '3.11' and sys_platform == 'linux'" }, + { name = "cuda-toolkit", extra = ["cublas", "cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "python_full_version >= '3.11' and sys_platform == 'linux'" }, + { name = "filelock", marker = "python_full_version >= '3.11'" }, + { name = "fsspec", marker = "python_full_version >= '3.11'" }, + { name = "jinja2", marker = "python_full_version >= '3.11'" }, + { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "networkx", version = "3.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "nvidia-cudnn-cu13", marker = "python_full_version >= '3.11' and sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu13", marker = "python_full_version >= '3.11' and sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu13", marker = "python_full_version >= '3.11' and sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu13", marker = "python_full_version >= '3.11' and sys_platform == 'linux'" }, + { name = "setuptools", marker = "python_full_version >= '3.11'" }, + { name = "sympy", marker = "python_full_version >= '3.11'" }, + { name = "triton", marker = "python_full_version >= '3.11' and sys_platform == 'linux'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.11'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/45/b0c389051ed02d77227202caf2c2abb1706ca4362781eb79bd6707359970/torch-2.14.1-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:e24b9815001d18e73e76eae1ea25f8ef908c6103e857378ad20f8f6cf5c954f5", size = 127249640, upload-time = "2026-09-30T17:43:48.426Z" }, + { url = "https://files.pythonhosted.org/packages/95/b3/fcbd9ff6ef8a0c318275e613b085d0c49b4fd549fa25bf62496b10a540e8/torch-2.14.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:957df236525bcd940ccd9ca59074e9821182171253417c0d98e7b82647254799", size = 453974659, upload-time = "2026-09-30T17:44:21.385Z" }, + { url = "https://files.pythonhosted.org/packages/54/91/365b45c4c8a16c7c398354b341273d15cdbd01c9feeadac16a02ce070b27/torch-2.14.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:1fee6b20869a90ccec05e35559a706338ac8aab40a9c4fb58f16667e93af36ae", size = 554550266, upload-time = "2026-09-30T17:44:37.477Z" }, + { url = "https://files.pythonhosted.org/packages/48/88/28360126b07082ce4ab96a1bf8547595d06d6fea5eaffdfc7d2b56459e79/torch-2.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:9ee7d50ec80ae5b4258cafc0ea108711988d961f21d0a68cf685650da2812e5f", size = 124096027, upload-time = "2026-09-30T17:43:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9b/fd1cd143a7bda5abc1fc352ed7b431f6f52aa3c454293211a33656db2cb5/torch-2.14.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:b6074b130fd26bc50f5d171f07dfed70db22dd0c354ab34767729249f9e0f042", size = 127281154, upload-time = "2026-09-30T17:43:58.328Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0e/21281ff4015779255229f487f4481c5bfba53b8c83e01eaa4a8a8f1180c9/torch-2.14.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:ef1276fd07adb8d463ac402a24ea7482e45c9a42df8ebdac211aa757779200ba", size = 453982557, upload-time = "2026-09-30T17:44:52.656Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4f/7af6fbada912b3023cc79b6ab30f89234b258a9a373880ea52ed914a2ae9/torch-2.14.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:305a61f61f35f128579f299c5bd33d475f6a01c6307336139632e30856c4854d", size = 554581699, upload-time = "2026-09-30T17:49:10.777Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/4e0a71376b2fb15aaf65ed9e6a4c8043d36f554338c6daabc63059a5e157/torch-2.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:0bd2fb0c4098856bc5af59fe5988bf38bdbcdb6550b44273c12ae425cb8ec17e", size = 124096268, upload-time = "2026-09-30T17:44:04.362Z" }, + { url = "https://files.pythonhosted.org/packages/4b/3c/379349820e9a6e713826de3d0b88f07728541bdcabe2a3afd128871cdc4b/torch-2.14.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:420dbf314c180ee4b86e9bc00aee5746a7d6e5bacdd7df925af671ed393f0b2e", size = 127307099, upload-time = "2026-09-30T17:44:10.421Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3e/474930ab971e306957ec763b7df695d659179e15ccab9e7073d47ae14ce8/torch-2.14.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:0b13acde294b401509830d85a8906cbf2fc97425a12a66cce1d9fa13c5106a2a", size = 453998918, upload-time = "2026-09-30T17:52:45.134Z" }, + { url = "https://files.pythonhosted.org/packages/ba/fd/c470857d0dfc5d4c94430658b3eed60fd5292b0bf596a8aae7b9fd66d674/torch-2.14.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:23011fe29a99b591eabb3a31a25080c62e2c2f0690a3c798744e5489dae50451", size = 554618695, upload-time = "2026-09-30T17:53:06.8Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1a/b9648b0d228e513f69f8453ec3f90a8d436d78ff6d85a2773e539f0c07cf/torch-2.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:38bee9f2a2ccfc6898172a5075e4ba52522fbe143eeb099674fc67ab390e858d", size = 124113906, upload-time = "2026-09-30T17:52:21.377Z" }, + { url = "https://files.pythonhosted.org/packages/7d/11/faaca4f8541c45127b7e0d6bb141221fe944c8466d89986b8466c6c195f9/torch-2.14.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:dbe359d705f4d67236743794c296c6dee93a922fd8117eff8c3e880d7d0fb2b9", size = 127315286, upload-time = "2026-09-30T17:52:27.43Z" }, + { url = "https://files.pythonhosted.org/packages/60/1f/0330275c705b846882531c64e80d32b13fb572eeb9b995e87e84154b0712/torch-2.14.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:6d530bd11479fb574567af3a1f384af9a77bf5bb9550cde8080b356ca7220d5a", size = 453999200, upload-time = "2026-09-30T17:53:21.716Z" }, + { url = "https://files.pythonhosted.org/packages/7c/cc/bb579ac0c80e077c58204e43b37a254b74dea5873635100bea25789a55d4/torch-2.14.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:c8f71aabc67bcbfc9373dc131537a5968d04edce73e88add21354a7cd0a76985", size = 554618164, upload-time = "2026-09-30T17:53:42.73Z" }, + { url = "https://files.pythonhosted.org/packages/7b/4b/32c00cacbe682a4c08d9c6c3e7a7116c912184cc7155f06c64f545db97d1/torch-2.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:711713391d26a1ce5e9fbc6c996d954a8c12e8825374cc77b809a6af29539b8c", size = 124113754, upload-time = "2026-09-30T17:52:33.474Z" }, +] + [[package]] name = "tqdm" version = "4.70.1" @@ -3185,6 +3533,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/03/921a3d3c75785aca9ebfbfcabfbc3a1be12e2ab5265deb026d55a5a3f83e/tqdm-4.70.1-py3-none-any.whl", hash = "sha256:c293e525e6fef9c20e8728fd4612df02a0aa31bb5fe91ecd93e123b1b7bffa73", size = 80199, upload-time = "2026-09-11T07:25:14.599Z" }, ] +[[package]] +name = "triton" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/ae/6b01bdd0dc082e190f5b04aa70fa955f968ac3fc70fd71dde71aeb835a8f/triton-3.8.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:398f4b009c7ab08ed9aeb1d1282dee822945b53f399a5e12a4fecb643cf2007d", size = 226352592, upload-time = "2026-08-28T16:07:49.055Z" }, + { url = "https://files.pythonhosted.org/packages/65/65/18d0871f353eb170b0b9c562cd65f6ab9619b6e7b8aaecea9709e9ad16a6/triton-3.8.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6d914c52f89dc942b1819db959e7c07a5999f678ec760f677625c706b7e0d743", size = 247838081, upload-time = "2026-08-28T15:55:34.877Z" }, + { url = "https://files.pythonhosted.org/packages/a3/cf/d21c9b1a4d1df9ba3aed218069f24f762a4b9565c5a362bef6f110c08e72/triton-3.8.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:372285307d4c44ee74cee32de0b4f04bd157e071427e38c6f4ee3e3beb2194f4", size = 226467015, upload-time = "2026-08-28T16:08:05.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/2c3d3823726d5ad5359f7065b02b984925158b74611c1e3987f6a37461fb/triton-3.8.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:68988ac85d5e7086baeda0ddc175af9667db7529b3c5e11a5c0601b8bef2200a", size = 247945226, upload-time = "2026-08-28T15:55:43.795Z" }, + { url = "https://files.pythonhosted.org/packages/87/07/0f8cd8e8db0472334253efdaaab3d0819fea27aa99bf0e7f1aeea4ceb5ae/triton-3.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9c404c69ed4a39e8ec632eaf6b9fe058a060bf98979c177f6ef666f06bb8d50", size = 226474486, upload-time = "2026-08-28T16:08:18.29Z" }, + { url = "https://files.pythonhosted.org/packages/c1/09/b7012e5bfae67640f268aa584caa80fe1674f6b0da949046b679972c33e3/triton-3.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e91ffa46d095b252248297292dd22bcbacd53a125a0c2eefbbbf74925a320bc3", size = 247972921, upload-time = "2026-08-28T15:55:53.157Z" }, + { url = "https://files.pythonhosted.org/packages/87/4d/4c564374bcdadb166fccbf3e45aee0d4a473f88d341761bd2fefe3b8e8c1/triton-3.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7004666652f500ed854a86988e4b3d69d247188b5d2092b5df1e44f4a954099", size = 226476793, upload-time = "2026-08-28T16:08:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/3394d5548404c1cabd1dadadd28d0b3f9478db1dff8180da53bb3f0a1e19/triton-3.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f0497218e26b7d79773ad9c2a3fa3b539ee69f587a13fac2e552b1d322a8015", size = 247975122, upload-time = "2026-08-28T15:56:04.112Z" }, +] + [[package]] name = "truststore" version = "0.10.4" From 3df0536c26c26e93ea239eccdbc7908d7faeb0f3 Mon Sep 17 00:00:00 2001 From: Manush Murali Date: Fri, 16 Oct 2026 16:36:29 -0700 Subject: [PATCH 05/17] Say what a relational model needs in process, and why a key was refused A relational request is gathered by the relational engine whichever way it is answered, so asking KumoRelational in process needs `[relational]` as well as `[in-process]`. The quickstart and the installation guide said only the second. A fractional numeric key is refused, as the NIM refuses it, because keys are joined as integers. The message said such a key joins to no row, which is not why; it now reads as the NIM's does. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Manush Murali --- docs/get-started/installation.md | 2 +- docs/get-started/quickstart.md | 4 +++- .../src/sdm_client/in_process/tables.py | 3 +-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/get-started/installation.md b/docs/get-started/installation.md index a034ba635..7732c82f6 100644 --- a/docs/get-started/installation.md +++ b/docs/get-started/installation.md @@ -7,7 +7,7 @@ | `pip install 'structured-data-models-client[snowflake]'` | Reads tables from a warehouse. Also `[sqlite]`, `[duckdb]`, `[postgres]`, `[databricks]`, `[lakebase]`, `[s3]`. | | `pip install 'structured-data-models-client[all]'` | The relational model and every warehouse backend. | | `pip install 'structured-data-models-client[databricks-serving]'` | Reaches a model served on Databricks Model Serving, through `SDMClient.for_databricks_serving`. Not in `[all]`: it is a way to reach a model, not a backend. | -| `pip install 'structured-data-models-client[in-process]'` | Runs the models in this Python process with structured-data-models, through `SDMClient.in_process`. Brings PyTorch. **Python 3.11 or later**: on 3.10 the extra installs nothing, and `in_process()` says why. Not in `[all]`, for the same reason as `[databricks-serving]`. | +| `pip install 'structured-data-models-client[in-process]'` | Runs the models in this Python process with structured-data-models, through `SDMClient.in_process`. Brings PyTorch. The relational model also needs `[relational]`, which gathers its requests. **Python 3.11 or later**: on 3.10 the extra installs nothing, and `in_process()` says why. Not in `[all]`, for the same reason as `[databricks-serving]`. | **What you install is not what you import.** The five distributions are `structured-data-models-client`, `-api`, `-pql`, `-connectors` and diff --git a/docs/get-started/quickstart.md b/docs/get-started/quickstart.md index bfb4932f0..6a28be389 100644 --- a/docs/get-started/quickstart.md +++ b/docs/get-started/quickstart.md @@ -180,7 +180,9 @@ It answers as the NIM does. The context is fitted with the NIM's seed and settings, `fit()` holds its session in memory, and a request the NIM refuses is refused with the same `NimRequestError`. This needs `pip install 'structured-data-models-client[in-process]'`, which brings -PyTorch, on Python 3.11 or later. +PyTorch, on Python 3.11 or later. A relational request is still gathered by +the relational engine, so for `KumoRelational` install both: +`pip install 'structured-data-models-client[relational,in-process]'`. ## Which rows get scored diff --git a/packages/structured-data-models-client/src/sdm_client/in_process/tables.py b/packages/structured-data-models-client/src/sdm_client/in_process/tables.py index b00925e1d..29320db80 100644 --- a/packages/structured-data-models-client/src/sdm_client/in_process/tables.py +++ b/packages/structured-data-models-client/src/sdm_client/in_process/tables.py @@ -94,8 +94,7 @@ def _whole(cells: list[Any], dtype: str, column: str) -> list[Any]: if not value.is_integer(): raise refusal( 'VALIDATION_FAILED', - f'key column {column!r} holds the fractional value ' - f'{value!r}, which joins to no row', + f'identifier column {column!r} holds a fractional value', ) value = int(value) whole.append(value) From 5467c4d740f3f425a04b26ff7063327edecbc3e0 Mon Sep 17 00:00:00 2001 From: Manush Murali Date: Sat, 17 Oct 2026 20:17:09 -0700 Subject: [PATCH 06/17] File a refusal inside a session as the NIM files it A NIM reports a request it refuses while creating a session as SESSION_CREATE_VALIDATION_FAILED, and one refused while scoring against a session as SESSION_PREDICT_SCHEMA_MISMATCH, keeping the message, for every validation failure rather than one. In process they kept their original codes, so the same session refusal arrived under a different code depending on where the model ran. The relational NIM also refuses a context with no rows, which a holdout of every entity produces after the client's own check has passed. In process that session was fitted on nothing and answered. It is refused now, as the NIM refuses it, in one call and in a session. A single-table context left empty the same way still answers its session's first prediction with a 500, as the NIM does. A model loaded for one task alone, which a NIM never is, is refused for the other with UNSUPPORTED_TASK before anything is fitted, rather than failing inside the model as an internal error. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Manush Murali --- .../src/sdm_client/in_process/refusal.py | 30 +++++++++++++ .../src/sdm_client/in_process/relational.py | 7 +++ .../src/sdm_client/in_process/transport.py | 34 ++++++++++++--- .../test_a_model_runs_in_this_process.py | 43 +++++++++++++++++++ 4 files changed, 108 insertions(+), 6 deletions(-) diff --git a/packages/structured-data-models-client/src/sdm_client/in_process/refusal.py b/packages/structured-data-models-client/src/sdm_client/in_process/refusal.py index f010b59fc..9046f8a22 100644 --- a/packages/structured-data-models-client/src/sdm_client/in_process/refusal.py +++ b/packages/structured-data-models-client/src/sdm_client/in_process/refusal.py @@ -11,6 +11,9 @@ from __future__ import annotations +import contextlib +from collections.abc import Iterator + from sdm_client.errors import NimRequestError _STATUS = { @@ -23,12 +26,39 @@ 'UNSUPPORTED_OUTPUT': 422, 'UNSUPPORTED_FEATURE': 422, 'TASK_SCHEMA_MISMATCH': 422, + 'SESSION_CREATE_VALIDATION_FAILED': 422, 'SESSION_PREDICT_SCHEMA_MISMATCH': 422, 'INTERNAL_ERROR': 500, 'MODEL_NOT_READY': 503, } +#: The refusals a NIM reports as a session's own when they arise in one. +_SESSION_VALIDATION = frozenset( + { + 'INVALID_SCHEMA', + 'TASK_SCHEMA_MISMATCH', + 'UNSUPPORTED_DTYPE', + 'VALIDATION_FAILED', + } +) + + def refusal(code: str, message: str) -> NimRequestError: r"""The error a NIM answers with for ``code``.""" return NimRequestError(_STATUS[code], code=code, message=message) + + +@contextlib.contextmanager +def in_session(code: str) -> Iterator[None]: + r"""Report a request refused inside a session the way a NIM does. + + A NIM files a refusal raised while creating a session, or scoring against + one, under that session's own code, keeping its message. + """ + try: + yield + except NimRequestError as error: + if error.code not in _SESSION_VALIDATION: + raise + raise refusal(code, error.message) from error diff --git a/packages/structured-data-models-client/src/sdm_client/in_process/relational.py b/packages/structured-data-models-client/src/sdm_client/in_process/relational.py index 5d9c952cb..3c6772102 100644 --- a/packages/structured-data-models-client/src/sdm_client/in_process/relational.py +++ b/packages/structured-data-models-client/src/sdm_client/in_process/relational.py @@ -70,6 +70,13 @@ def context(self, payload: Mapping[str, Any]) -> Context: spec = schema['instance_table'] table = split['instance_table'] declared(spec, table) + if not table['rows']: + raise refusal( + 'VALIDATION_FAILED', + 'Kumo Relational context must contain training data. ' + 'context.instance_table.rows: At least one context instance ' + 'row is required.', + ) instance = Shape.of( spec, _instance_stypes( diff --git a/packages/structured-data-models-client/src/sdm_client/in_process/transport.py b/packages/structured-data-models-client/src/sdm_client/in_process/transport.py index cd3e38115..8a7e5d4b4 100644 --- a/packages/structured-data-models-client/src/sdm_client/in_process/transport.py +++ b/packages/structured-data-models-client/src/sdm_client/in_process/transport.py @@ -33,7 +33,7 @@ row_ids, ) from sdm_client.in_process.columns import Context -from sdm_client.in_process.refusal import refusal +from sdm_client.in_process.refusal import in_session, refusal from sdm_client.in_process.relational import Relational from sdm_client.in_process.tabular import Tabular @@ -126,6 +126,7 @@ def predict(self, payload: dict[str, Any]) -> dict[str, Any]: served, model = self._serving(payload.get('model')) task = payload['task'] fields = _fields(served, task['kind'], payload.get('output')) + _check_trained_for(served, model, task['kind']) _check_inference(payload.get('inference'), task) context = served.context(payload) output = _run(_fit(model, context), served.query(context, payload)) @@ -145,7 +146,9 @@ def create_session(self, payload: dict[str, Any]) -> dict[str, Any]: with self._answering(): served, model = self._serving(payload.get('model')) _outputs(served, payload['task']['kind']) - context = served.context(payload) + _check_trained_for(served, model, payload['task']['kind']) + with in_session('SESSION_CREATE_VALIDATION_FAILED'): + context = served.context(payload) session_id = f'sess_{uuid.uuid4().hex}' fitted = _fit(model, context) self._sessions[session_id] = _Session( @@ -172,10 +175,10 @@ def session_predict( fields = _fields( session.served, task['kind'], payload.get('output') ) - _check_inference(payload.get('inference'), task, settled=True) - output = _run( - session.fitted, session.served.query(session.context, payload) - ) + with in_session('SESSION_PREDICT_SCHEMA_MISMATCH'): + _check_inference(payload.get('inference'), task, settled=True) + query = session.served.query(session.context, payload) + output = _run(session.fitted, query) return _response( session.served, task, @@ -330,6 +333,25 @@ def _outputs(served: Tabular | Relational, kind: str) -> tuple[str, ...]: return available +def _check_trained_for( + served: Tabular | Relational, model: Any, kind: str +) -> None: + r"""Refuse a task the model was loaded without. + + A model can be loaded for classification or regression alone, which a + NIM's never is, so this is asked of the model rather than of its kind. + """ + needed = 'regression' if kind == 'regression' else 'classification' + loaded = sorted(str(task) for task in model.tasks) + if needed not in loaded: + raise refusal( + 'UNSUPPORTED_TASK', + f'this {served.model_id} model was loaded for {loaded} only, so ' + f'it cannot answer {kind!r}; load it for both, which is the ' + f'default', + ) + + def _fields( served: Tabular | Relational, kind: str, diff --git a/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py b/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py index 48f129101..7ca0db4b5 100644 --- a/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py +++ b/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py @@ -282,6 +282,49 @@ def test_each_refusal_is_the_one_a_nim_answers_with(relational, tabular): assert all(name in error.message for name in named) +def test_a_refusal_inside_a_session_is_filed_as_the_sessions(relational): + """A NIM reports a request refused while creating a session under that + session's code, and refuses a relational context with no rows, such as + one a holdout of every entity has emptied.""" + tables = graph() + texty = Schema.from_data(tables) + texty['users']['tier'].stype = 'text' + with SDMClient.in_process(relational) as client: + for request, holdout in ( + (PredictiveQuery(COUNT, texty).materialize(indices=[0, 1]), None), + ( + PredictiveQuery(COUNT, tables).materialize(indices=[0, 1]), + list(range(40)), + ), + ): + error = refused( + lambda request=request, holdout=holdout: client.fit( + request, model=KumoRelational(), holdout=holdout + ) + ) + assert (error.code, error.status_code) == ( + 'SESSION_CREATE_VALIDATION_FAILED', + 422, + ) + + +def test_a_task_the_model_was_not_loaded_for_is_refused(sdm): + regressor = sdm.models.KumoTabular( + task='regression', size='small', pretrained=False, device='cpu' + ) + frame = people() + request = Request( + context=frame.iloc[:45][['person_id', 'age', 'churn']], + predict=frame.iloc[45:][['person_id', 'age']], + target='churn', + task='classification', + ) + with SDMClient.in_process(regressor) as client: + error = refused(lambda: client.predict(request, model=KumoTabular())) + assert (error.code, error.status_code) == ('UNSUPPORTED_TASK', 422) + assert "['regression']" in error.message + + def test_a_model_passed_in_keeps_what_was_fitted_on_it(sdm, tabular): frame = people() features = {'age': 'numerical', 'visits': 'numerical'} From a7606f1efecd11d016d88441ab742c02fc6ffaa3 Mon Sep 17 00:00:00 2001 From: Manush Murali Date: Sat, 17 Oct 2026 20:37:54 -0700 Subject: [PATCH 07/17] Check the classes of the context that arrives, as the NIM does The client counts a context's classes before it is pinned, and a holdout then takes rows out of it. A binary context left with one class, or a multiclass one that lost a class it declares, was fitted and answered in process, while the NIM refuses both. The NIM's three class checks now run on the context as it arrives: exactly two classes for a binary task, at least two for a multiclass one, and declared classes equal to those present. A run_mode the client does not know is not refused: the NIM accepts any short string there, and answers. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Manush Murali --- .../src/sdm_client/in_process/columns.py | 69 +++++++++++++++---- .../test_a_model_runs_in_this_process.py | 9 ++- 2 files changed, 64 insertions(+), 14 deletions(-) diff --git a/packages/structured-data-models-client/src/sdm_client/in_process/columns.py b/packages/structured-data-models-client/src/sdm_client/in_process/columns.py index b477f1e5d..c0d4232d8 100644 --- a/packages/structured-data-models-client/src/sdm_client/in_process/columns.py +++ b/packages/structured-data-models-client/src/sdm_client/in_process/columns.py @@ -6,7 +6,7 @@ from __future__ import annotations -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass, field, replace from typing import Any @@ -182,18 +182,63 @@ class is matched to an encoded one by its text. if not classifies: return y, (), () encoded = tuple(y.categorical.categories[0].tolist()) - return y, encoded, _as_declared(target.get('classes'), encoded) + declared = target.get('classes') + _check_classes(task['kind'], declared, encoded) + if not declared: + return y, encoded, encoded + text = _text_of(encoded) + named = {text(label): label for label in declared} + return y, encoded, tuple(named.get(text(label), label) for label in encoded) -def _as_declared( - declared: Sequence[Any] | None, encoded: tuple[Any, ...] -) -> tuple[Any, ...]: - if not declared: - return encoded - fold = all(isinstance(label, bool) for label in encoded) +def _check_classes( + kind: str, declared: Sequence[Any] | None, encoded: tuple[Any, ...] +) -> None: + r"""Refuse classes the task contradicts, as the NIM does. - def text(label: Any) -> str: - return str(label).casefold() if fold else str(label) + Asked of the context that arrived: a holdout can take every row of a + class out of it after the client has counted them. + """ + count = len(encoded) + if kind == 'binary_classification' and count != 2: + raise refusal( + 'TASK_SCHEMA_MISMATCH', + f'binary_classification needs exactly two classes, the context ' + f'carries {count}', + ) + if kind == 'multiclass_classification' and count < 2: + raise refusal( + 'TASK_SCHEMA_MISMATCH', + f'multiclass_classification needs at least two classes, the ' + f'context carries {count}', + ) + if declared is None: + return + text = _text_of(encoded) + named = [text(label) for label in declared] + if not named: + raise refusal( + 'TASK_SCHEMA_MISMATCH', + 'the task declares an empty class list, which no label can satisfy', + ) + if len(set(named)) != len(named): + raise refusal( + 'TASK_SCHEMA_MISMATCH', + f'declared classes repeat a label: {sorted(named)}', + ) + present = {text(label) for label in encoded} + if set(named) != present: + raise refusal( + 'TASK_SCHEMA_MISMATCH', + f'declared classes {sorted(set(named))} do not match the labels ' + f'present in the context {sorted(present)}', + ) - named = {text(label): label for label in declared} - return tuple(named.get(text(label), label) for label in encoded) + +def _text_of(encoded: tuple[Any, ...]) -> Callable[[Any], str]: + r"""How a declared class is matched to an encoded one: by its text, and + for a boolean target without case, since it is declared ``'true'`` and + encodes as ``True``.""" + if all(isinstance(label, bool) for label in encoded): + return lambda label: str(label).casefold() + return str diff --git a/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py b/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py index 7ca0db4b5..f1ab3ba99 100644 --- a/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py +++ b/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py @@ -284,8 +284,9 @@ def test_each_refusal_is_the_one_a_nim_answers_with(relational, tabular): def test_a_refusal_inside_a_session_is_filed_as_the_sessions(relational): """A NIM reports a request refused while creating a session under that - session's code, and refuses a relational context with no rows, such as - one a holdout of every entity has emptied.""" + session's code. A holdout can empty a relational context, or leave a + binary one a single class, after the client has checked it; the NIM + refuses both.""" tables = graph() texty = Schema.from_data(tables) texty['users']['tier'].stype = 'text' @@ -296,6 +297,10 @@ def test_a_refusal_inside_a_session_is_filed_as_the_sessions(relational): PredictiveQuery(COUNT, tables).materialize(indices=[0, 1]), list(range(40)), ), + ( + PredictiveQuery(BINARY, tables).materialize(indices=[1, 3]), + list(range(0, 40, 2)), + ), ): error = refused( lambda request=request, holdout=holdout: client.fit( From 586882c574880a39d371e3c0a351578f6472c57f Mon Sep 17 00:00:00 2001 From: Manush Murali Date: Sat, 17 Oct 2026 20:37:54 -0700 Subject: [PATCH 08/17] Install the wheels CI built, not a release of the same version The installed-wheel, sbom and build jobs installed the client set with `--find-links dist` and `==$VERSION`. Once 1.0.0 was on PyPI, pip resolved the published release as readily as the wheel built here, and the installed-wheel job began testing it: a pull request adding to the client failed there because the release lacked the addition. Each distribution is now named by its wheel file, and the installed-wheel job checks that every one was installed from dist/ before running the suite. The release workflow keeps `--find-links`: the version it builds is not on PyPI yet. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Manush Murali --- .github/workflows/ci.yml | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5fcd3895c..2923a2a0e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -208,11 +208,14 @@ jobs: EOF # Building each one proves nothing about installing them together. A pin # only one distribution carries makes the set unsatisfiable, and the - # resolver is the only thing that says so. + # resolver is the only thing that says so. Every one is named by file: + # once a version is on PyPI, `--find-links` with `==$VERSION` resolves + # the published release as readily as the wheel built here. - run: | VERSION=$(cat dist/VERSION) - pip install --dry-run --find-links "$GITHUB_WORKSPACE/dist" \ - "structured-data-models-client[relational,sqlite,duckdb]==$VERSION" + pip install --dry-run \ + "$(ls dist/structured_data_models_client-$VERSION-*.whl)[relational,sqlite,duckdb]" \ + dist/structured_data_models_{api,pql,connectors,relational_engine}-$VERSION-*.whl # The wheels only: dist/ also carries VERSION for the jobs downstream, # and twine refuses anything that is not a distribution. - run: pip install --upgrade twine && twine check dist/*.whl @@ -277,15 +280,27 @@ jobs: - run: | pip install --upgrade pip pytest requests-mock VERSION=$(cat dist/VERSION) - pip install --find-links "$GITHUB_WORKSPACE/dist" \ - "structured-data-models-client[sqlite,duckdb]==$VERSION" + pip install \ + "$(ls dist/structured_data_models_client-$VERSION-*.whl)[sqlite,duckdb]" \ + dist/structured_data_models_{api,pql,connectors}-$VERSION-*.whl - name: The installed distribution is what gets imported, and it is typed working-directory: /tmp run: | python - <<'EOF' - import pathlib + import json, pathlib + from importlib.metadata import distribution import predictive_query, sdm_api, sdm_client, sdm_connectors + # The same version on PyPI resolves as readily as the wheel built + # here, and then this job tests the published release instead. + for name in ('client', 'api', 'pql', 'connectors'): + recorded = distribution(f'structured-data-models-{name}').read_text('direct_url.json') + source = json.loads(recorded or '{}').get('url', 'an index') + assert '/dist/' in source, ( + f'structured-data-models-{name} was installed from {source}, ' + f'not from the wheel built here' + ) + for module in (sdm_api, sdm_client, sdm_connectors, predictive_query): location = pathlib.Path(module.__file__).resolve() assert 'site-packages' in location.parts, ( @@ -322,8 +337,9 @@ jobs: - run: | pip install --upgrade pip cyclonedx-bom VERSION=$(cat dist/VERSION) - pip install --find-links "$GITHUB_WORKSPACE/dist" \ - "structured-data-models-client[sqlite,duckdb,postgres,databricks,snowflake,s3]==$VERSION" + pip install \ + "$(ls dist/structured_data_models_client-$VERSION-*.whl)[sqlite,duckdb,postgres,databricks,snowflake,s3]" \ + dist/structured_data_models_{api,pql,connectors}-$VERSION-*.whl cyclonedx-py environment --of JSON --validate -o dist/sbom.cyclonedx.json python -c "import json,sys; d=json.load(open('dist/sbom.cyclonedx.json')); names={c['name'] for c in d.get('components', [])}; missing={'structured-data-models-client','structured-data-models-api','structured-data-models-pql','structured-data-models-connectors'} - names; print(f'{len(names)} components'); sys.exit(f'SBOM missing {sorted(missing)}' if missing else 0)" - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 From c26a29f43555bdf8c01c8ed8eae99dc7027f9a20 Mon Sep 17 00:00:00 2001 From: Manush Murali Date: Sat, 17 Oct 2026 21:08:49 -0700 Subject: [PATCH 09/17] Read inference options as the NIM types them, then as each model judges them A NIM validates the inference block against a typed model before any model sees it. A value of the wrong kind, such as a run_mode that is not a string or an inference_config that is not an object, is 422 VALIDATION_FAILED there; in process it was ignored and answered. A boolean is read as the boolean it spells, so use_prediction_time 'no' asks for nothing on the NIM and was read here as asking for prediction time. The options are now typed and coerced the same way first. Each model then applies its own NIM's checks in its NIM's order: the tabular one refuses use_prediction_time with its own 400 and message, which in process had borrowed the relational one's 422. Every combination of value, model and route measured against the NIM's code now returns the same status, code and message. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Manush Murali --- .../src/sdm_client/in_process/inference.py | 74 +++++++++++++++++++ .../src/sdm_client/in_process/relational.py | 41 ++++++++++ .../src/sdm_client/in_process/tabular.py | 26 +++++++ .../src/sdm_client/in_process/transport.py | 48 ++---------- .../test_a_model_runs_in_this_process.py | 29 ++++++++ 5 files changed, 175 insertions(+), 43 deletions(-) create mode 100644 packages/structured-data-models-client/src/sdm_client/in_process/inference.py diff --git a/packages/structured-data-models-client/src/sdm_client/in_process/inference.py b/packages/structured-data-models-client/src/sdm_client/in_process/inference.py new file mode 100644 index 000000000..e7c7b64c5 --- /dev/null +++ b/packages/structured-data-models-client/src/sdm_client/in_process/inference.py @@ -0,0 +1,74 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. +# All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +r"""Reading a request's inference options the way a NIM reads them. + +A NIM types the block before any model judges it: a misspelled kind of value +is refused as invalid, and a boolean written another way is read as the +boolean it spells, so ``'no'`` asks for nothing. +""" + +from __future__ import annotations + +from typing import Any, NoReturn + +from sdm_client.in_process.refusal import refusal + +_MAX_TEXT = 1024 +_TRUE = frozenset({'1', 'on', 't', 'true', 'y', 'yes'}) +_FALSE = frozenset({'0', 'off', 'f', 'false', 'n', 'no'}) + + +def read_inference(inference: Any) -> dict[str, Any]: + r"""The inference options, typed, with each field a NIM fills in by + default present.""" + if inference is None: + inference = {} + if not isinstance(inference, dict): + _invalid( + 'inference', + 'Input should be a valid dictionary or object to extract fields ' + 'from', + ) + options = { + 'run_mode': None, + 'use_prediction_time': False, + 'inference_config': {}, + **inference, + } + run_mode = options['run_mode'] + if run_mode is not None and not isinstance(run_mode, str): + _invalid('inference.run_mode', 'Input should be a valid string') + if isinstance(run_mode, str) and len(run_mode) > _MAX_TEXT: + _invalid( + 'inference.run_mode', + f'String should have at most {_MAX_TEXT} characters', + ) + options['use_prediction_time'] = _boolean(options['use_prediction_time']) + if not isinstance(options['inference_config'], dict): + _invalid( + 'inference.inference_config', 'Input should be a valid dictionary' + ) + return options + + +def _boolean(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str) and value.lower() in _TRUE | _FALSE: + return value.lower() in _TRUE + if isinstance(value, (int, float)) and value in (0, 1): + return bool(value) + unread = isinstance(value, (int, str)) + _invalid( + 'inference.use_prediction_time', + 'Input should be a valid boolean' + + (', unable to interpret input' if unread else ''), + ) + + +def _invalid(name: str, reason: str) -> NoReturn: + raise refusal( + 'VALIDATION_FAILED', f'Request validation failed. {name}: {reason}' + ) diff --git a/packages/structured-data-models-client/src/sdm_client/in_process/relational.py b/packages/structured-data-models-client/src/sdm_client/in_process/relational.py index 3c6772102..bd8bae01d 100644 --- a/packages/structured-data-models-client/src/sdm_client/in_process/relational.py +++ b/packages/structured-data-models-client/src/sdm_client/in_process/relational.py @@ -28,6 +28,9 @@ INSTANCE_ID = 'instance_id' _CLASSIFIED = ('prediction', 'probabilities') +_INFERENCE_OPTIONS = frozenset( + {'run_mode', 'use_prediction_time', 'inference_config'} +) class Relational: @@ -115,6 +118,44 @@ def context(self, payload: Mapping[str, Any]) -> Context: task_links=tuple(links), ) + @staticmethod + def check_inference( + options: Mapping[str, Any], + task: Mapping[str, Any], + settled: bool = False, + ) -> None: + r"""Refuse an inference option this model's NIM refuses. + + A session's context is fitted without prediction time, since creating + one carries no inference options, so a prediction cannot ask for it + after. + """ + unknown = sorted(set(options) - _INFERENCE_OPTIONS) + if unknown: + raise refusal( + 'INVALID_REQUEST', f'unsupported inference options: {unknown}' + ) + if options.get('inference_config'): + raise refusal( + 'INVALID_REQUEST', + 'inference_config is reserved for API compatibility and must ' + 'be omitted or empty', + ) + if not options.get('use_prediction_time'): + return + if not task.get('anchor_time_column'): + raise refusal( + 'TASK_SCHEMA_MISMATCH', + 'use_prediction_time needs task.anchor_time_column, which ' + 'this request does not name', + ) + if settled: + raise refusal( + 'INVALID_REQUEST', + 'use_prediction_time is settled when a session is compiled ' + 'and cannot be changed by a prediction', + ) + @staticmethod def query(context: Context, payload: Mapping[str, Any]) -> tuple[Any, Any]: from sdm import RelatedTables diff --git a/packages/structured-data-models-client/src/sdm_client/in_process/tabular.py b/packages/structured-data-models-client/src/sdm_client/in_process/tabular.py index 478a916ac..b50a83a89 100644 --- a/packages/structured-data-models-client/src/sdm_client/in_process/tabular.py +++ b/packages/structured-data-models-client/src/sdm_client/in_process/tabular.py @@ -83,6 +83,32 @@ def context(self, payload: Mapping[str, Any]) -> Context: labels=labels, ) + def check_inference( + self, + options: Mapping[str, Any], + task: Mapping[str, Any], + settled: bool = False, + ) -> None: + r"""Refuse an inference option this model's NIM refuses.""" + remaining = dict(options) + if remaining.pop('inference_config', {}): + raise refusal( + 'INVALID_REQUEST', + 'inference_config is reserved for API compatibility and must ' + 'be omitted or empty', + ) + if remaining.pop('use_prediction_time', False): + raise refusal( + 'INVALID_REQUEST', + f"inference option 'use_prediction_time' is not supported by " + f'{self.model_id}; only the neutral value False is accepted', + ) + unknown = sorted(set(remaining) - {'run_mode'}) + if unknown: + raise refusal( + 'INVALID_REQUEST', f'unsupported inference options: {unknown}' + ) + @staticmethod def query(context: Context, payload: Mapping[str, Any]) -> tuple[Any, Any]: table = payload['predict']['instance_table'] diff --git a/packages/structured-data-models-client/src/sdm_client/in_process/transport.py b/packages/structured-data-models-client/src/sdm_client/in_process/transport.py index 8a7e5d4b4..9fd9a79de 100644 --- a/packages/structured-data-models-client/src/sdm_client/in_process/transport.py +++ b/packages/structured-data-models-client/src/sdm_client/in_process/transport.py @@ -33,6 +33,7 @@ row_ids, ) from sdm_client.in_process.columns import Context +from sdm_client.in_process.inference import read_inference from sdm_client.in_process.refusal import in_session, refusal from sdm_client.in_process.relational import Relational from sdm_client.in_process.tabular import Tabular @@ -42,9 +43,6 @@ _ESTIMATORS = 1 _SERVED = {served.model_id: served for served in (Tabular(), Relational())} -_INFERENCE_OPTIONS = frozenset( - {'run_mode', 'use_prediction_time', 'inference_config'} -) @dataclass @@ -123,11 +121,12 @@ def list_models(self) -> list[str]: def predict(self, payload: dict[str, Any]) -> dict[str, Any]: payload = _received(payload) with self._answering(): + payload['inference'] = read_inference(payload.get('inference')) served, model = self._serving(payload.get('model')) task = payload['task'] fields = _fields(served, task['kind'], payload.get('output')) _check_trained_for(served, model, task['kind']) - _check_inference(payload.get('inference'), task) + served.check_inference(payload['inference'], task) context = served.context(payload) output = _run(_fit(model, context), served.query(context, payload)) return _response( @@ -165,6 +164,7 @@ def session_predict( ) -> dict[str, Any]: payload = _received(payload) with self._answering(): + inference = read_inference(payload.get('inference')) session = self._sessions.get(session_id) if session is None: raise refusal( @@ -176,7 +176,7 @@ def session_predict( session.served, task['kind'], payload.get('output') ) with in_session('SESSION_PREDICT_SCHEMA_MISMATCH'): - _check_inference(payload.get('inference'), task, settled=True) + session.served.check_inference(inference, task, settled=True) query = session.served.query(session.context, payload) output = _run(session.fitted, query) return _response( @@ -372,44 +372,6 @@ def _fields( return fields -def _check_inference( - inference: Mapping[str, Any] | None, - task: Mapping[str, Any], - settled: bool = False, -) -> None: - r"""Refuse an inference option the hosted deployments refuse. - - A session's context is fitted without prediction time, since creating one - carries no inference options, so a prediction cannot ask for it after. - """ - options = dict(inference or {}) - unknown = sorted(set(options) - _INFERENCE_OPTIONS) - if unknown: - raise refusal( - 'INVALID_REQUEST', f'unsupported inference options: {unknown}' - ) - if options.get('inference_config'): - raise refusal( - 'INVALID_REQUEST', - 'inference_config is reserved for API compatibility and must be ' - 'omitted or empty', - ) - if not options.get('use_prediction_time'): - return - if not task.get('anchor_time_column'): - raise refusal( - 'TASK_SCHEMA_MISMATCH', - 'use_prediction_time needs task.anchor_time_column, which this ' - 'request does not name', - ) - if settled: - raise refusal( - 'INVALID_REQUEST', - 'use_prediction_time is settled when a session is created and ' - 'cannot be changed by a prediction', - ) - - def _fit(model: Any, context: Context) -> Any: r"""A copy of ``model`` fitted on ``context``. diff --git a/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py b/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py index f1ab3ba99..46a052e65 100644 --- a/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py +++ b/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py @@ -330,6 +330,35 @@ def test_a_task_the_model_was_not_loaded_for_is_refused(sdm): assert "['regression']" in error.message +@pytest.mark.parametrize( + ('inference', 'refusal'), + [ + ({'use_prediction_time': 'no'}, None), + ({'run_mode': {}}, ('VALIDATION_FAILED', 422)), + ({'use_prediction_time': True}, ('INVALID_REQUEST', 400)), + ], +) +def test_inference_options_are_read_as_the_nim_reads_them( + tabular, inference, refusal +): + """Typed first, so a boolean spelled ``'no'`` asks for nothing and a + value of the wrong kind is invalid, then judged by the model's own NIM.""" + frame = people() + request = Request( + context=frame.iloc[:45][['person_id', 'age', 'churn']], + predict=frame.iloc[45:][['person_id', 'age']], + target='churn', + task='classification', + inference=inference, + ) + with SDMClient.in_process(tabular) as client: + if refusal is None: + assert len(client.predict(request, model=KumoTabular())) == 15 + return + error = refused(lambda: client.predict(request, model=KumoTabular())) + assert (error.code, error.status_code) == refusal + + def test_a_model_passed_in_keeps_what_was_fitted_on_it(sdm, tabular): frame = people() features = {'age': 'numerical', 'visits': 'numerical'} From 7f283d4143529b700df1863d6e915f6377753975 Mon Sep 17 00:00:00 2001 From: Manush Murali Date: Sat, 17 Oct 2026 21:28:08 -0700 Subject: [PATCH 10/17] Say which limits hold in process, and which belong to a deployment The limits this client checks before sending hold in process as they do against a NIM. A NIM also sets limits of its own, per deployment, to protect a shared server: the longest string a cell may hold, the most distinct values a column may have, the largest body it accepts. Those are not applied when the model runs in the caller's own process, and the quickstart and the limits reference now say so rather than leaving it to be discovered. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Manush Murali --- docs/get-started/quickstart.md | 4 +++- docs/reference/limits.md | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/get-started/quickstart.md b/docs/get-started/quickstart.md index 6a28be389..8204d47e1 100644 --- a/docs/get-started/quickstart.md +++ b/docs/get-started/quickstart.md @@ -178,7 +178,9 @@ with SDMClient.in_process(relational) as client: It answers as the NIM does. The context is fitted with the NIM's seed and settings, `fit()` holds its session in memory, and a request the NIM refuses -is refused with the same `NimRequestError`. This needs +is refused with the same `NimRequestError`. The limits a deployment sets to +protect a shared server are the exception, since none is shared here; see +[limits](../reference/limits.md). This needs `pip install 'structured-data-models-client[in-process]'`, which brings PyTorch, on Python 3.11 or later. A relational request is still gathered by the relational engine, so for `KumoRelational` install both: diff --git a/docs/reference/limits.md b/docs/reference/limits.md index a52f312de..14254c881 100644 --- a/docs/reference/limits.md +++ b/docs/reference/limits.md @@ -32,6 +32,12 @@ Both endpoints enforce sizes, and most of these are the relational one's. | hops | relational | 6 | at `materialize()` | | tables | relational | 15 | against the graph the request carries, not the schema it was built from | +A client opened with `SDMClient.in_process()` is held to this table as well, +since this client checks it. A NIM also has limits of its own, which each +deployment sets, such as the longest string a cell may hold or the most +distinct values a column may have. Those protect a shared server, and are +not applied when the model runs in your own process. + A relational request for more entities than one call scores is not refused. `materialize()` gathers it in batches of at most that many, each with a context of its own, sampled around that batch's rows rather than copied from From 01fe2aea6e5ef8e902728ae52f99f8eca019f8f4 Mon Sep 17 00:00:00 2001 From: Manush Murali Date: Sat, 17 Oct 2026 21:45:25 -0700 Subject: [PATCH 11/17] Hold a request to the contract's own limits before reading it A NIM validates every request against the contract before anything reads it: each name and label it carries is at most 1,024 characters, and a table names each column once. In process neither was checked, so a request the NIM refuses was answered. The one a caller reaches without trying is a predictive query longer than that limit, whose text travels as metadata.source_query; the NIM refuses it with 422 VALIDATION_FAILED. Both rules are checked first now, and the refusal names each field the way the NIM names it. The quickstart also says where the answers differ by design: on a CPU the model computes at full precision, while a GPU-hosted NIM computes at reduced precision. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Manush Murali --- docs/get-started/quickstart.md | 4 +- .../src/sdm_client/in_process/contract.py | 116 ++++++++++++++++++ .../src/sdm_client/in_process/transport.py | 4 + .../test_a_model_runs_in_this_process.py | 8 ++ 4 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 packages/structured-data-models-client/src/sdm_client/in_process/contract.py diff --git a/docs/get-started/quickstart.md b/docs/get-started/quickstart.md index 8204d47e1..1fd46f592 100644 --- a/docs/get-started/quickstart.md +++ b/docs/get-started/quickstart.md @@ -178,7 +178,9 @@ with SDMClient.in_process(relational) as client: It answers as the NIM does. The context is fitted with the NIM's seed and settings, `fit()` holds its session in memory, and a request the NIM refuses -is refused with the same `NimRequestError`. The limits a deployment sets to +is refused with the same `NimRequestError`. On a GPU it computes at the NIM's +reduced precision; on a CPU it computes at full precision, so its +probabilities can differ from a GPU-hosted NIM's in the last digits. The limits a deployment sets to protect a shared server are the exception, since none is shared here; see [limits](../reference/limits.md). This needs `pip install 'structured-data-models-client[in-process]'`, which brings diff --git a/packages/structured-data-models-client/src/sdm_client/in_process/contract.py b/packages/structured-data-models-client/src/sdm_client/in_process/contract.py new file mode 100644 index 000000000..1ecd4da7e --- /dev/null +++ b/packages/structured-data-models-client/src/sdm_client/in_process/contract.py @@ -0,0 +1,116 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. +# All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +r"""The request contract's own limits, checked as a NIM checks them. + +Before anything reads a request, a NIM validates it against the contract: +every name and label it carries is at most 1,024 characters, and a table +names each column once. A request outside those is refused whole, with each +offending field named. +""" + +from __future__ import annotations + +from collections.abc import Iterator, Mapping +from typing import Any + +from sdm_client.errors import format_invalid_params +from sdm_client.in_process.refusal import refusal + +_TEXT = 1024 +_TOO_LONG = f'String should have at most {_TEXT} characters' + + +def check_contract(payload: Mapping[str, Any]) -> None: + r"""Refuse a request the contract cannot carry.""" + problems = [ + {'name': name, 'reason': reason} for name, reason in _problems(payload) + ] + if problems: + raise refusal( + 'VALIDATION_FAILED', + 'Request validation failed.' + format_invalid_params(problems), + ) + + +def _problems(payload: Mapping[str, Any]) -> Iterator[tuple[str, str]]: + task = payload.get('task') or {} + target = task.get('target') or {} + yield from _texts('task', task, ('kind', 'anchor_time_column')) + yield from _texts( + 'task.target', + target, + ('column_name', 'table_name', 'dtype', 'positive_class'), + ) + yield from _listed('task.target.classes', target.get('classes')) + yield from _listed( + 'task.entity_table_names', task.get('entity_table_names') + ) + schema = payload.get('schema') or {} + yield from _table_schema( + 'schema.instance_table', schema.get('instance_table') + ) + for name, table in (schema.get('related_tables') or {}).items(): + yield from _table_schema(f'schema.related_tables.{name}', table) + for index, relationship in enumerate(schema.get('relationships') or ()): + where = f'schema.relationships[{index}]' + yield from _texts(where, relationship, ('source_table', 'target_table')) + yield from _listed( + f'{where}.source_columns', relationship.get('source_columns') + ) + yield from _listed( + f'{where}.target_columns', relationship.get('target_columns') + ) + for split in ('context', 'predict'): + tables = payload.get(split) or {} + yield from _columns( + f'{split}.instance_table', tables.get('instance_table') + ) + for name, table in (tables.get('related_tables') or {}).items(): + yield from _columns(f'{split}.related_tables.{name}', table) + yield from _texts( + 'metadata', + payload.get('metadata') or {}, + ('request_id', 'trace_id', 'source_query'), + ) + + +def _texts( + where: str, values: Mapping[str, Any], names: tuple[str, ...] +) -> Iterator[tuple[str, str]]: + for name in names: + value = values.get(name) + if isinstance(value, str) and len(value) > _TEXT: + yield f'{where}.{name}', _TOO_LONG + + +def _listed(where: str, values: Any) -> Iterator[tuple[str, str]]: + for index, value in enumerate(values or ()): + if isinstance(value, str) and len(value) > _TEXT: + yield f'{where}[{index}]', _TOO_LONG + + +def _table_schema(where: str, table: Any) -> Iterator[tuple[str, str]]: + if not isinstance(table, Mapping): + return + for name, column in (table.get('columns') or {}).items(): + if isinstance(column, Mapping): + yield from _texts( + f'{where}.columns.{name}', column, ('dtype', 'stype') + ) + + +def _columns(where: str, table: Any) -> Iterator[tuple[str, str]]: + if not isinstance(table, Mapping): + return + seen: set[str] = set() + for index, column in enumerate(table.get('columns') or ()): + if isinstance(column, str) and len(column) > _TEXT: + yield f'{where}.columns[{index}]', _TOO_LONG + if column in seen: + yield ( + f'{where}.columns[{index}]', + f"duplicate column name '{column}'", + ) + seen.add(column) diff --git a/packages/structured-data-models-client/src/sdm_client/in_process/transport.py b/packages/structured-data-models-client/src/sdm_client/in_process/transport.py index 9fd9a79de..35feb8e48 100644 --- a/packages/structured-data-models-client/src/sdm_client/in_process/transport.py +++ b/packages/structured-data-models-client/src/sdm_client/in_process/transport.py @@ -33,6 +33,7 @@ row_ids, ) from sdm_client.in_process.columns import Context +from sdm_client.in_process.contract import check_contract from sdm_client.in_process.inference import read_inference from sdm_client.in_process.refusal import in_session, refusal from sdm_client.in_process.relational import Relational @@ -121,6 +122,7 @@ def list_models(self) -> list[str]: def predict(self, payload: dict[str, Any]) -> dict[str, Any]: payload = _received(payload) with self._answering(): + check_contract(payload) payload['inference'] = read_inference(payload.get('inference')) served, model = self._serving(payload.get('model')) task = payload['task'] @@ -143,6 +145,7 @@ def create_session(self, payload: dict[str, Any]) -> dict[str, Any]: r"""Fit the context once, to be scored against until released.""" payload = _received(payload) with self._answering(): + check_contract(payload) served, model = self._serving(payload.get('model')) _outputs(served, payload['task']['kind']) _check_trained_for(served, model, payload['task']['kind']) @@ -164,6 +167,7 @@ def session_predict( ) -> dict[str, Any]: payload = _received(payload) with self._answering(): + check_contract(payload) inference = read_inference(payload.get('inference')) session = self._sessions.get(session_id) if session is None: diff --git a/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py b/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py index 46a052e65..58b792d6b 100644 --- a/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py +++ b/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py @@ -239,6 +239,14 @@ def test_each_refusal_is_the_one_a_nim_answers_with(relational, tabular): PredictiveQuery(COUNT, texty).materialize(indices=[0, 1]), model=KumoRelational(), ), + ('VALIDATION_FAILED', 422, 'source_query'): lambda: client.predict( + PredictiveQuery( + f'{COUNT} WHERE ' + + ' OR '.join(f'users.age > {i}' for i in range(60)), + tables, + ).materialize(indices=[0, 1]), + model=KumoRelational(), + ), ('INVALID_REQUEST', 400): lambda: client.predict( PredictiveQuery(COUNT, tables).materialize( indices=[0, 1], inference_config={'num_estimators': 4} From 4d13340a70c0ea733e88b993e34ad28eaa5f1b4d Mon Sep 17 00:00:00 2001 From: Manush Murali Date: Sun, 18 Oct 2026 00:27:52 -0700 Subject: [PATCH 12/17] Read a request's tables in the order the NIM reads them Before anything else, the NIM requires each table to supply exactly the columns its schema declares, the rows to score excepted only for the target. In process that was checked piecemeal and later, so a missing or extra column came back under a different code and message, and a related table the context never carried was refused for the wrong reason. The column check now runs first, as the NIM's does, and a split is then held to the tables the relationships name and to those the context was fitted on. The checks it makes redundant are removed rather than left unreachable. Three smaller differences, each measured against the NIM: - A tabular request carrying relationships or related tables is refused naming whichever the NIM names first, including one on the predict side only, which was let through. - Quantile levels are judged only when quantiles are asked for, and are refused in the NIM's words. - On Python 3.10, in_process() raises a StructuredDataError with the code MISSING_EXTRA rather than MissingExtraError; its docstring says so, and the prerequisites no longer say the client never uses a GPU. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Manush Murali --- docs/get-started/prerequisites.md | 3 +- docs/get-started/quickstart.md | 17 +-- .../src/sdm_client/client.py | 5 +- .../src/sdm_client/in_process/answers.py | 11 +- .../src/sdm_client/in_process/columns.py | 15 +-- .../src/sdm_client/in_process/contract.py | 69 +++++++++++- .../src/sdm_client/in_process/relational.py | 100 ++++++++++++----- .../src/sdm_client/in_process/tabular.py | 31 ++++-- .../src/sdm_client/in_process/transport.py | 15 ++- .../test_a_model_runs_in_this_process.py | 104 +++++++++++++++++- 10 files changed, 291 insertions(+), 79 deletions(-) diff --git a/docs/get-started/prerequisites.md b/docs/get-started/prerequisites.md index 772e2c35e..7c867023f 100644 --- a/docs/get-started/prerequisites.md +++ b/docs/get-started/prerequisites.md @@ -10,7 +10,8 @@ runs no model and downloads no weights. The one exception is a client opened with `SDMClient.in_process()`, which runs the model itself; see the [quickstart](quickstart.md#a-model-in-this-process). -- **No GPU** on the client. The NIM owns that. +- **No GPU** on the client. The NIM owns that. A client opened with + `SDMClient.in_process()` uses one when there is one, and the CPU otherwise. ## Platforms diff --git a/docs/get-started/quickstart.md b/docs/get-started/quickstart.md index 1fd46f592..8d480661e 100644 --- a/docs/get-started/quickstart.md +++ b/docs/get-started/quickstart.md @@ -178,14 +178,15 @@ with SDMClient.in_process(relational) as client: It answers as the NIM does. The context is fitted with the NIM's seed and settings, `fit()` holds its session in memory, and a request the NIM refuses -is refused with the same `NimRequestError`. On a GPU it computes at the NIM's -reduced precision; on a CPU it computes at full precision, so its -probabilities can differ from a GPU-hosted NIM's in the last digits. The limits a deployment sets to -protect a shared server are the exception, since none is shared here; see -[limits](../reference/limits.md). This needs -`pip install 'structured-data-models-client[in-process]'`, which brings -PyTorch, on Python 3.11 or later. A relational request is still gathered by -the relational engine, so for `KumoRelational` install both: +is refused with the same `NimRequestError`. Two things differ by design. On a +CPU it computes at full precision, so its probabilities can differ from a +GPU-hosted NIM's in the last digits; on a GPU it uses the NIM's precision. And +the limits a deployment sets to protect a shared server are not applied, since +none is shared here; see [limits](../reference/limits.md). + +This needs `pip install 'structured-data-models-client[in-process]'`, which +brings PyTorch, on Python 3.11 or later. A relational request is still +gathered by the relational engine, so for `KumoRelational` install both: `pip install 'structured-data-models-client[relational,in-process]'`. ## Which rows get scored diff --git a/packages/structured-data-models-client/src/sdm_client/client.py b/packages/structured-data-models-client/src/sdm_client/client.py index d8336eedd..098cddefe 100644 --- a/packages/structured-data-models-client/src/sdm_client/client.py +++ b/packages/structured-data-models-client/src/sdm_client/client.py @@ -396,7 +396,10 @@ def in_process(cls, *models: Any, device: Any = None) -> SDMClient: Raises: MissingExtraError: If structured-data-models is not installed; - install ``structured-data-models-client[in-process]``. + install ``structured-data-models-client[in-process]``. On + Python 3.10, which it does not support, the error is a + :class:`StructuredDataError` with the same code + ``MISSING_EXTRA`` that names the Python it needs. StructuredDataError: With code ``INVALID_CONFIGURATION``, for a model other than those two, two of one kind, or ``device`` given with ``models``. diff --git a/packages/structured-data-models-client/src/sdm_client/in_process/answers.py b/packages/structured-data-models-client/src/sdm_client/in_process/answers.py index 0f0d19f66..dc436de57 100644 --- a/packages/structured-data-models-client/src/sdm_client/in_process/answers.py +++ b/packages/structured-data-models-client/src/sdm_client/in_process/answers.py @@ -103,12 +103,11 @@ def regressed( The point prediction is the distribution's mean or median, and each quantile asked for is interpolated between the two predicted around it. """ - for level in levels: - if not 0.001 <= level <= 0.999: - raise refusal( - 'UNSUPPORTED_OUTPUT', - f'quantile level {level!r} is outside 0.001 to 0.999', - ) + if 'quantiles' in fields and not all(0.001 <= x <= 0.999 for x in levels): + raise refusal( + 'UNSUPPORTED_OUTPUT', + 'quantile levels must be between 0.001 and 0.999', + ) quantiles = _numbers(output) if quantiles.shape[1] != _QUANTILES: raise refusal( diff --git a/packages/structured-data-models-client/src/sdm_client/in_process/columns.py b/packages/structured-data-models-client/src/sdm_client/in_process/columns.py index c0d4232d8..87b2022ef 100644 --- a/packages/structured-data-models-client/src/sdm_client/in_process/columns.py +++ b/packages/structured-data-models-client/src/sdm_client/in_process/columns.py @@ -68,18 +68,6 @@ def key_columns(schema: Mapping[str, Any]) -> frozenset[str]: return frozenset(key or ()) -def declared(schema: Mapping[str, Any], table: Mapping[str, Any]) -> None: - r"""Refuse a column the schema does not describe.""" - undeclared = [ - name for name in table['columns'] if name not in schema['columns'] - ] - if undeclared: - raise refusal( - 'INVALID_SCHEMA', - f'column(s) {undeclared} are not declared in the schema', - ) - - @dataclass(frozen=True) class Shape: r"""Which columns of a table the model reads, and as what. @@ -113,7 +101,8 @@ def tensor(self, table: Mapping[str, Any], where: str) -> Any: if absent: raise refusal( 'SESSION_PREDICT_SCHEMA_MISMATCH', - f'{where} omits column(s) {absent} that the context carries', + f'{where} omits column(s) {absent} that the context was ' + f'compiled with', ) arrow = to_arrow(table, self.columns, self.dtypes, self.keys) try: diff --git a/packages/structured-data-models-client/src/sdm_client/in_process/contract.py b/packages/structured-data-models-client/src/sdm_client/in_process/contract.py index 1ecd4da7e..fbc20c1ef 100644 --- a/packages/structured-data-models-client/src/sdm_client/in_process/contract.py +++ b/packages/structured-data-models-client/src/sdm_client/in_process/contract.py @@ -2,12 +2,12 @@ # All rights reserved. # SPDX-License-Identifier: Apache-2.0 -r"""The request contract's own limits, checked as a NIM checks them. +r"""The request contract's own rules, checked as a NIM checks them. Before anything reads a request, a NIM validates it against the contract: -every name and label it carries is at most 1,024 characters, and a table -names each column once. A request outside those is refused whole, with each -offending field named. +every name and label it carries is at most 1,024 characters, a table names +each column once, and each table supplies exactly the columns its schema +declares. A request outside those is refused, with the field named. """ from __future__ import annotations @@ -114,3 +114,64 @@ def _columns(where: str, table: Any) -> Iterator[tuple[str, str]]: f"duplicate column name '{column}'", ) seen.add(column) + + +def check_columns( + payload: Mapping[str, Any], + schema: Mapping[str, Any], + splits: tuple[str, ...], +) -> None: + r"""Refuse a table that does not supply exactly its declared columns. + + The rows to score may leave out the target, which they are asked about. + """ + target = (payload.get('task') or {}).get('target') or {} + column = target.get('column_name') + target_table = target.get('table_name') or 'instance_table' + related_schemas = schema.get('related_tables') or {} + for split in splits: + tables = payload.get(split) or {} + unasked = ( + frozenset({column}) + if split == 'predict' and column + else frozenset() + ) + _check_table( + f'{split}.instance_table', + tables.get('instance_table'), + schema.get('instance_table'), + unasked if target_table == 'instance_table' else frozenset(), + ) + for name, table in (tables.get('related_tables') or {}).items(): + if name not in related_schemas: + raise refusal( + 'INVALID_SCHEMA', + f"related table '{name}' is not declared in schema.", + ) + _check_table( + f'{split}.related_tables.{name}', + table, + related_schemas[name], + unasked if target_table == name else frozenset(), + ) + + +def _check_table( + where: str, table: Any, schema: Any, unasked: frozenset[str] +) -> None: + if not isinstance(table, Mapping) or not isinstance(schema, Mapping): + return + declared = set(schema.get('columns') or {}) + present = set(table.get('columns') or ()) + missing = sorted(declared - present - unasked) + extra = sorted(present - declared) + if missing or extra: + reason = f'missing={missing}, extra={extra}.' + raise refusal( + 'INVALID_SCHEMA', + 'Table columns do not match the declared inputs; ' + + reason + + format_invalid_params( + [{'name': f'{where}.columns', 'reason': reason}] + ), + ) diff --git a/packages/structured-data-models-client/src/sdm_client/in_process/relational.py b/packages/structured-data-models-client/src/sdm_client/in_process/relational.py index bd8bae01d..56e728148 100644 --- a/packages/structured-data-models-client/src/sdm_client/in_process/relational.py +++ b/packages/structured-data-models-client/src/sdm_client/in_process/relational.py @@ -17,7 +17,6 @@ from sdm_client.in_process.columns import ( Context, Shape, - declared, key_columns, labelled, stype_of, @@ -72,7 +71,6 @@ def context(self, payload: Mapping[str, Any]) -> Context: split = payload['context'] spec = schema['instance_table'] table = split['instance_table'] - declared(spec, table) if not table['rows']: raise refusal( 'VALIDATION_FAILED', @@ -80,6 +78,7 @@ def context(self, payload: Mapping[str, Any]) -> Context: 'context.instance_table.rows: At least one context instance ' 'row is required.', ) + shapes = _shapes(split, schema, joins, edges, links) instance = Shape.of( spec, _instance_stypes( @@ -92,18 +91,11 @@ def context(self, payload: Mapping[str, Any]) -> Context: joins[INSTANCE_TABLE], ) y, encoded, labels = labelled(table, task, task['kind'] != 'regression') - shapes = {} - tensors = {} - for name, related in (split.get('related_tables') or {}).items(): - table_schema = schema['related_tables'][name] - declared(table_schema, related) - keys = joins.get(name, frozenset()) | key_columns(table_schema) - shapes[name] = Shape.of( - table_schema, _related_stypes(table_schema, related, keys), keys - ) - tensors[name] = shapes[name].tensor( - related, f'context table {name!r}' - ) + related = split.get('related_tables') or {} + tensors = { + name: shape.tensor(related[name], f'context table {name!r}') + for name, shape in shapes.items() + } return Context( x=instance.tensor(table, 'the context'), y=y, @@ -157,25 +149,28 @@ def check_inference( ) @staticmethod - def query(context: Context, payload: Mapping[str, Any]) -> tuple[Any, Any]: + def query( + context: Context, + payload: Mapping[str, Any], + schema: Mapping[str, Any], + ) -> tuple[Any, Any]: + r"""The rows to score, read against the context they are scored + against, and refused where they do not fit it.""" from sdm import RelatedTables split = payload['predict'] + edges, links = list(context.relationships), list(context.task_links) + _shapes( + split, schema, _joins(edges, links), edges, links, context.tables + ) related = split.get('related_tables') or {} - tensors = {} - for name, shape in context.tables.items(): - if name not in related: - raise refusal( - 'SESSION_PREDICT_SCHEMA_MISMATCH', - f'the rows to score omit related table {name!r}, which ' - f'the context carries', - ) - tensors[name] = shape.tensor( - related[name], f'related table {name!r}' - ) + tensors = { + name: shape.tensor(related[name], f'related table {name!r}') + for name, shape in context.tables.items() + } return ( context.instance.tensor( - split['instance_table'], 'the rows to score' + split['instance_table'], 'predict instance table' ), RelatedTables( tables=tensors, @@ -220,6 +215,57 @@ def _graph(relationships: Sequence[Mapping[str, Any]]) -> tuple[list, list]: return edges, links +def _shapes( + split: Mapping[str, Any], + schema: Mapping[str, Any], + joins: Mapping[str, frozenset[str]], + edges: list, + links: list, + compiled: Mapping[str, Shape] | None = None, +) -> dict[str, Shape]: + r"""Each related table's shape, refused where the NIM refuses it. + + Every table supplies its declared columns, which the contract checks + first. With ``compiled``, the shapes the context was fitted in, which the + rows scored against it must match table for table. + """ + tables = split.get('related_tables') or {} + shapes = {} + for name, table in tables.items(): + if compiled is not None: + if name not in compiled: + raise refusal( + 'SESSION_PREDICT_SCHEMA_MISMATCH', + f'related table {name!r} was not present when the ' + f'context was compiled', + ) + shapes[name] = compiled[name] + continue + table_schema = schema['related_tables'][name] + keys = joins.get(name, frozenset()) | key_columns(table_schema) + shapes[name] = Shape.of( + table_schema, _related_stypes(table_schema, table, keys), keys + ) + referenced = {link.table for link in links} + for edge in edges: + referenced.update({edge.left_table, edge.right_table}) + missing = sorted(name for name in referenced if name not in shapes) + if missing: + raise refusal( + 'INVALID_SCHEMA', + f'relationships reference table(s) {missing} that carry no rows', + ) + if compiled is not None: + omitted = sorted(set(compiled) - set(tables)) + if omitted: + raise refusal( + 'SESSION_PREDICT_SCHEMA_MISMATCH', + f'predict split omits related table(s) {omitted} that the ' + f'context was compiled with', + ) + return shapes + + def _joins(edges: list, links: list) -> dict[str, frozenset[str]]: r"""The columns each table is joined on, by table.""" joins: dict[str, set[str]] = {INSTANCE_TABLE: set()} diff --git a/packages/structured-data-models-client/src/sdm_client/in_process/tabular.py b/packages/structured-data-models-client/src/sdm_client/in_process/tabular.py index b50a83a89..ebe1b8b0c 100644 --- a/packages/structured-data-models-client/src/sdm_client/in_process/tabular.py +++ b/packages/structured-data-models-client/src/sdm_client/in_process/tabular.py @@ -12,7 +12,6 @@ from sdm_client.in_process.columns import ( Context, Shape, - declared, key_columns, labelled, stype_of, @@ -45,18 +44,21 @@ def accepts(model: Any) -> bool: def context(self, payload: Mapping[str, Any]) -> Context: schema = payload['schema'] - if ( - schema.get('related_tables') - or schema.get('relationships') - or payload['context'].get('related_tables') + related = schema.get('related_tables') or any( + (payload.get(split) or {}).get('related_tables') + for split in ('context', 'predict') + ) + for present, what in ( + (schema.get('relationships'), 'relationships'), + (related, 'related tables'), ): - raise refusal( - 'UNSUPPORTED_FEATURE', - f'related tables are not supported by model {self.model_id!r}', - ) + if present: + raise refusal( + 'UNSUPPORTED_FEATURE', + f'{what} are not supported by model {self.model_id!r}.', + ) spec = schema['instance_table'] table = payload['context']['instance_table'] - declared(spec, table) task = payload['task'] target = task['target']['column_name'] keys = key_columns(spec) @@ -110,6 +112,11 @@ def check_inference( ) @staticmethod - def query(context: Context, payload: Mapping[str, Any]) -> tuple[Any, Any]: + def query( + context: Context, + payload: Mapping[str, Any], + schema: Mapping[str, Any], + ) -> tuple[Any, Any]: + r"""The rows to score, read as the context was.""" table = payload['predict']['instance_table'] - return context.instance.tensor(table, 'the rows to score'), None + return context.instance.tensor(table, 'predict instance table'), None diff --git a/packages/structured-data-models-client/src/sdm_client/in_process/transport.py b/packages/structured-data-models-client/src/sdm_client/in_process/transport.py index 35feb8e48..ba7eb9026 100644 --- a/packages/structured-data-models-client/src/sdm_client/in_process/transport.py +++ b/packages/structured-data-models-client/src/sdm_client/in_process/transport.py @@ -33,7 +33,7 @@ row_ids, ) from sdm_client.in_process.columns import Context -from sdm_client.in_process.contract import check_contract +from sdm_client.in_process.contract import check_columns, check_contract from sdm_client.in_process.inference import read_inference from sdm_client.in_process.refusal import in_session, refusal from sdm_client.in_process.relational import Relational @@ -124,13 +124,15 @@ def predict(self, payload: dict[str, Any]) -> dict[str, Any]: with self._answering(): check_contract(payload) payload['inference'] = read_inference(payload.get('inference')) + check_columns(payload, payload['schema'], ('context', 'predict')) served, model = self._serving(payload.get('model')) task = payload['task'] fields = _fields(served, task['kind'], payload.get('output')) _check_trained_for(served, model, task['kind']) served.check_inference(payload['inference'], task) context = served.context(payload) - output = _run(_fit(model, context), served.query(context, payload)) + query = served.query(context, payload, payload['schema']) + output = _run(_fit(model, context), query) return _response( served, task, @@ -146,6 +148,8 @@ def create_session(self, payload: dict[str, Any]) -> dict[str, Any]: payload = _received(payload) with self._answering(): check_contract(payload) + with in_session('SESSION_CREATE_VALIDATION_FAILED'): + check_columns(payload, payload['schema'], ('context',)) served, model = self._serving(payload.get('model')) _outputs(served, payload['task']['kind']) _check_trained_for(served, model, payload['task']['kind']) @@ -180,8 +184,13 @@ def session_predict( session.served, task['kind'], payload.get('output') ) with in_session('SESSION_PREDICT_SCHEMA_MISMATCH'): + check_columns( + {**payload, 'task': task}, session.schema, ('predict',) + ) session.served.check_inference(inference, task, settled=True) - query = session.served.query(session.context, payload) + query = session.served.query( + session.context, payload, session.schema + ) output = _run(session.fitted, query) return _response( session.served, diff --git a/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py b/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py index 58b792d6b..80a733407 100644 --- a/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py +++ b/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py @@ -367,6 +367,103 @@ def test_inference_options_are_read_as_the_nim_reads_them( assert (error.code, error.status_code) == refusal +def _tabular_payload(task: str) -> dict: + from sdm_client.adapters.tabular import TabularAdapter + + frame = people()[['person_id', 'age', 'churn']] + request = Request( + context=frame.iloc[:45], + predict=frame.iloc[45:].drop(columns=['churn']), + target='churn', + task=task, + ) + return next( + iter(TabularAdapter().prediction_plan(request, ('prediction',))) + ) + + +def _relational_payload() -> dict: + from sdm_client.adapters.relational import RelationalAdapter + + request = PredictiveQuery(COUNT, graph()).materialize(indices=[0, 1]) + return next( + iter(RelationalAdapter().prediction_plan(request, ('prediction',))) + ) + + +def _without(table: dict, column: str) -> None: + position = table['columns'].index(column) + del table['columns'][position] + for row in table['rows']: + del row[position] + + +@pytest.mark.parametrize( + ('model', 'change', 'answer'), + [ + ( + 'tabular', + lambda p: p['schema'].update( + relationships=[ + { + 'source_columns': ['age'], + 'target_table': 'other', + 'target_columns': ['age'], + } + ] + ), + ('UNSUPPORTED_FEATURE', 422, 'relationships are not'), + ), + ( + 'regression', + lambda p: p.update( + output={'fields': ['prediction'], 'quantile_levels': [1.5]} + ), + None, + ), + ( + 'relational', + lambda p: _without(p['predict']['related_tables']['orders'], 'ts'), + ('INVALID_SCHEMA', 422, "missing=['ts'], extra=[]"), + ), + ( + 'relational', + lambda p: p['predict']['related_tables'].update( + ghost={'format': 'arrays', 'columns': ['k'], 'rows': []} + ), + ('INVALID_SCHEMA', 422, "'ghost' is not declared"), + ), + ( + 'relational', + lambda p: p['predict']['related_tables'].pop('orders'), + ('INVALID_SCHEMA', 422, "reference table(s) ['orders']"), + ), + ], +) +def test_a_request_is_read_in_the_order_the_nim_reads_it( + relational, tabular, model, change, answer +): + """Each table supplies exactly its declared columns, a split carries + every table the relationships name, and a field the request does not ask + for is not judged.""" + payload = ( + _relational_payload() + if model == 'relational' + else _tabular_payload( + 'regression' if model == 'regression' else 'classification' + ) + ) + change(payload) + with SDMClient.in_process(relational, tabular) as client: + if answer is None: + client._transport.predict(payload) + return + error = refused(lambda: client._transport.predict(payload)) + code, status, said = answer + assert (error.code, error.status_code) == (code, status) + assert said in error.message + + def test_a_model_passed_in_keeps_what_was_fitted_on_it(sdm, tabular): frame = people() features = {'age': 'numerical', 'visits': 'numerical'} @@ -474,10 +571,9 @@ def test_a_model_left_in_training_mode_is_named_not_run(tabular): def test_without_the_extra_the_extra_is_named(): - """In a fresh interpreter, since nothing the extra brings may already be - imported: a module imported at the top of the in-process package once - failed before the check that names the extra. On Python 3.10, where the - extra installs nothing, the refusal says which Python it needs.""" + """Run in a fresh interpreter, so nothing the extra brings is already + imported. On Python 3.10, where the extra installs nothing, the refusal + says which Python it needs.""" script = ( 'import sys\n' "for name in ('sdm', 'torch', 'pyarrow'):\n" From b801a2ceda80332bea4abf4fac4c85d35f6ca376 Mon Sep 17 00:00:00 2001 From: Manush Murali Date: Sun, 18 Oct 2026 00:53:32 -0700 Subject: [PATCH 13/17] Word task, output and session refusals as the NIM words them An unsupported output or task was refused with the NIM's code and a message that differed by its closing period, and an unknown session with a different sentence. Each now reads exactly as the NIM's does. The module docstring says that a payload written by hand, rather than built by the client, is not held to every rule a NIM applies. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Manush Murali --- .../src/sdm_client/in_process/transport.py | 14 ++++++++------ .../tests/test_a_model_runs_in_this_process.py | 9 +++++++-- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/structured-data-models-client/src/sdm_client/in_process/transport.py b/packages/structured-data-models-client/src/sdm_client/in_process/transport.py index ba7eb9026..96e4de1b6 100644 --- a/packages/structured-data-models-client/src/sdm_client/in_process/transport.py +++ b/packages/structured-data-models-client/src/sdm_client/in_process/transport.py @@ -7,7 +7,9 @@ The client sends what it would send a NIM and reads back what a NIM answers, so everything that works against a NIM works here, sessions included. The model is fitted the way the hosted deployments fit it, so the same request is -answered the same way wherever it runs. +answered the same way wherever it runs, and refused the same way too. A payload +written by hand and passed to the transport directly is not held to every rule +a NIM applies. """ from __future__ import annotations @@ -177,7 +179,7 @@ def session_predict( if session is None: raise refusal( 'SESSION_NOT_FOUND', - f'session {session_id!r} does not exist or was released', + f"no session '{session_id}'.", ) task = session.task fields = _fields( @@ -340,8 +342,8 @@ def _outputs(served: Tabular | Relational, kind: str) -> tuple[str, ...]: if available is None: raise refusal( 'UNSUPPORTED_TASK', - f'task {kind!r} not supported by model {served.model_id!r}; ' - f'supported: {list(served.outputs)}', + f"task '{kind}' not supported by model '{served.model_id}'; " + f'supported: {list(served.outputs)}.', ) return available @@ -379,8 +381,8 @@ def _fields( raise refusal( 'UNSUPPORTED_OUTPUT', f'output(s) {unsupported} not supported by model ' - f'{served.model_id!r} for task {kind!r}; supported: ' - f'{list(available)}', + f"'{served.model_id}' for task '{kind}'; supported: " + f'{list(available)}.', ) return fields diff --git a/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py b/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py index 80a733407..3f17f2d31 100644 --- a/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py +++ b/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py @@ -230,7 +230,12 @@ def test_each_refusal_is_the_one_a_nim_answers_with(relational, tabular): model=KumoRelational(), outputs=['prediction', 'scores'], ), - ('UNSUPPORTED_OUTPUT', 422, 'explanation'): lambda: client.predict( + ( + 'UNSUPPORTED_OUTPUT', + 422, + "explanation'] not", + "supported: ['prediction', 'probabilities'].", + ): lambda: client.predict( one, model=KumoRelational(), outputs=['prediction', 'explanation'], @@ -253,7 +258,7 @@ def test_each_refusal_is_the_one_a_nim_answers_with(relational, tabular): ), model=KumoRelational(), ), - ('UNSUPPORTED_TASK', 422): lambda: client.predict( + ('UNSUPPORTED_TASK', 422, "'regression']."): lambda: client.predict( PredictiveQuery( 'PREDICT LIST_DISTINCT(orders.item_id, 0, 30, days) ' 'FOR EACH users.user_id', From 22583a497746eb4ab0ecb209c9da9b1c46c9bdb6 Mon Sep 17 00:00:00 2001 From: Manush Murali Date: Sun, 18 Oct 2026 10:27:06 -0700 Subject: [PATCH 14/17] Say what a GPU run of a model in process gives, measured On an L4, in process gave the NIM's answers on the same GPU exactly, in one call and in a session. A CPU run against a GPU-hosted NIM differs by more than rounding, because the two compute at different precision and draw different random numbers: a class probability moved by 0.16. The quickstart said "the last digits", and the parity probe said a GPU NIM always needs a tolerance. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Manush Murali --- docs/get-started/quickstart.md | 12 +++++++----- qa/probe_in_process.py | 9 +++++---- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/docs/get-started/quickstart.md b/docs/get-started/quickstart.md index 8d480661e..4cd3612dc 100644 --- a/docs/get-started/quickstart.md +++ b/docs/get-started/quickstart.md @@ -178,11 +178,13 @@ with SDMClient.in_process(relational) as client: It answers as the NIM does. The context is fitted with the NIM's seed and settings, `fit()` holds its session in memory, and a request the NIM refuses -is refused with the same `NimRequestError`. Two things differ by design. On a -CPU it computes at full precision, so its probabilities can differ from a -GPU-hosted NIM's in the last digits; on a GPU it uses the NIM's precision. And -the limits a deployment sets to protect a shared server are not applied, since -none is shared here; see [limits](../reference/limits.md). +is refused with the same `NimRequestError`. On a GPU it computes as the NIM +does, and on the same GPU it gives the NIM's answers exactly. Two things +differ by design. On a CPU it computes at full precision and draws its random +numbers there, so its answers can differ from a GPU-hosted NIM's by more than +rounding: in one comparison a class probability moved by 0.16. And the limits +a deployment sets to protect a shared server are not applied, since none is +shared here; see [limits](../reference/limits.md). This needs `pip install 'structured-data-models-client[in-process]'`, which brings PyTorch, on Python 3.11 or later. A relational request is still diff --git a/qa/probe_in_process.py b/qa/probe_in_process.py index e2e71bf24..b94240d2e 100644 --- a/qa/probe_in_process.py +++ b/qa/probe_in_process.py @@ -5,10 +5,11 @@ """SDMClient.in_process() against the NIMs, request for request. Each request goes to a NIM and to the model in this process, and the two -answers are compared column by column. Against a NIM on the CPU running the -same structured-data-models release, every number is identical. A NIM on a GPU -computes at reduced precision, so set SDM_IN_PROCESS_TOLERANCE to the largest -difference to accept. +answers are compared column by column. Against a NIM running the same +structured-data-models release on the same device, CPU or GPU, every number is +identical. With this process on a CPU and the NIM on a GPU, the two compute at +different precision and draw different random numbers, so set +SDM_IN_PROCESS_TOLERANCE to the largest difference to accept. """ from __future__ import annotations From e38b57ae47a420e3eeae5911deb9c65f229e7378 Mon Sep 17 00:00:00 2001 From: Manush Murali Date: Mon, 19 Oct 2026 09:16:14 -0700 Subject: [PATCH 15/17] Refuse a hand-written request where the NIM refuses it A relational task naming an entity its rows do not link to was scored in process, where a NIM refuses it. In-process now makes the NIM's relational checks in the NIM's order: related tables present, the target on the instance table, the inference options, one link, the task options, one entity table that is the linked one, a declared timestamp anchor, readable instance columns and a target dtype the model can represent. The tabular model reads its inference options after refusing related tables, as its NIM does. A key the contract does not name is refused wherever the NIM's request models forbid one, and listed in the NIM's order. A schema sent as schema_ is read as the schema, as the NIM reads it. The parity probe skipped a value missing on one side, so a gap could pass as agreement. It now fails one. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Manush Murali --- .../src/sdm_client/in_process/contract.py | 147 ++++++++++++++---- .../src/sdm_client/in_process/relational.py | 122 ++++++++++++++- .../src/sdm_client/in_process/tabular.py | 3 +- .../src/sdm_client/in_process/transport.py | 27 +++- .../test_a_model_runs_in_this_process.py | 80 +++++++++- qa/probe_in_process.py | 10 +- 6 files changed, 342 insertions(+), 47 deletions(-) diff --git a/packages/structured-data-models-client/src/sdm_client/in_process/contract.py b/packages/structured-data-models-client/src/sdm_client/in_process/contract.py index fbc20c1ef..e30b5c5a7 100644 --- a/packages/structured-data-models-client/src/sdm_client/in_process/contract.py +++ b/packages/structured-data-models-client/src/sdm_client/in_process/contract.py @@ -6,8 +6,9 @@ Before anything reads a request, a NIM validates it against the contract: every name and label it carries is at most 1,024 characters, a table names -each column once, and each table supplies exactly the columns its schema -declares. A request outside those is refused, with the field named. +each column once, no object carries a key its contract does not name, and +each table supplies exactly the columns its schema declares. A request +outside those is refused, with the field named. """ from __future__ import annotations @@ -22,10 +23,33 @@ _TOO_LONG = f'String should have at most {_TEXT} characters' -def check_contract(payload: Mapping[str, Any]) -> None: - r"""Refuse a request the contract cannot carry.""" +PREDICTION = ( + 'model', + 'task', + 'schema', + 'context', + 'predict', + 'output', + 'inference', + 'metadata', +) +SESSION = ('model', 'task', 'schema', 'context', 'metadata') +SESSION_PREDICTION = ('predict', 'output', 'inference', 'metadata') + +_EXTRA = 'Extra inputs are not permitted' + + +def check_contract( + payload: Mapping[str, Any], fields: tuple[str, ...] = PREDICTION +) -> None: + r"""Refuse a request the contract cannot carry. + + ``fields`` are the ones the endpoint takes. Problems are listed in the + order a NIM lists them: field by field, each object's unknown keys last. + """ problems = [ - {'name': name, 'reason': reason} for name, reason in _problems(payload) + {'name': name, 'reason': reason} + for name, reason in _problems(payload, fields) ] if problems: raise refusal( @@ -34,48 +58,100 @@ def check_contract(payload: Mapping[str, Any]) -> None: ) -def _problems(payload: Mapping[str, Any]) -> Iterator[tuple[str, str]]: - task = payload.get('task') or {} - target = task.get('target') or {} - yield from _texts('task', task, ('kind', 'anchor_time_column')) - yield from _texts( - 'task.target', - target, - ('column_name', 'table_name', 'dtype', 'positive_class'), - ) - yield from _listed('task.target.classes', target.get('classes')) +def _problems( + payload: Mapping[str, Any], fields: tuple[str, ...] +) -> Iterator[tuple[str, str]]: + readers = { + 'task': _task, + 'schema': _schema, + 'context': _split, + 'predict': _split, + 'metadata': _metadata, + } + for field in fields: + value = payload.get(field) + if field in readers and isinstance(value, Mapping): + yield from readers[field](field, value) + yield from _extra('', payload, fields) + + +def _task(where: str, task: Mapping[str, Any]) -> Iterator[tuple[str, str]]: + yield from _texts(where, task, ('kind',)) + target = task.get('target') + if isinstance(target, Mapping): + yield from _texts( + f'{where}.target', target, ('column_name', 'table_name', 'dtype') + ) + yield from _listed(f'{where}.target.classes', target.get('classes')) + yield from _texts(f'{where}.target', target, ('positive_class',)) yield from _listed( - 'task.entity_table_names', task.get('entity_table_names') + f'{where}.entity_table_names', task.get('entity_table_names') ) - schema = payload.get('schema') or {} + yield from _texts(where, task, ('anchor_time_column',)) + + +def _schema(where: str, schema: Mapping[str, Any]) -> Iterator[tuple[str, str]]: yield from _table_schema( - 'schema.instance_table', schema.get('instance_table') + f'{where}.instance_table', schema.get('instance_table') ) - for name, table in (schema.get('related_tables') or {}).items(): - yield from _table_schema(f'schema.related_tables.{name}', table) + for name, table in _entries(schema.get('related_tables')): + yield from _table_schema(f'{where}.related_tables.{name}', table) for index, relationship in enumerate(schema.get('relationships') or ()): - where = f'schema.relationships[{index}]' - yield from _texts(where, relationship, ('source_table', 'target_table')) + if not isinstance(relationship, Mapping): + continue + at = f'{where}.relationships[{index}]' + yield from _texts(at, relationship, ('source_table',)) yield from _listed( - f'{where}.source_columns', relationship.get('source_columns') + f'{at}.source_columns', relationship.get('source_columns') ) + yield from _texts(at, relationship, ('target_table',)) yield from _listed( - f'{where}.target_columns', relationship.get('target_columns') + f'{at}.target_columns', relationship.get('target_columns') ) - for split in ('context', 'predict'): - tables = payload.get(split) or {} - yield from _columns( - f'{split}.instance_table', tables.get('instance_table') + yield from _extra( + at, + relationship, + ( + 'source_table', + 'source_columns', + 'target_table', + 'target_columns', + ), ) - for name, table in (tables.get('related_tables') or {}).items(): - yield from _columns(f'{split}.related_tables.{name}', table) + yield from _extra( + where, schema, ('instance_table', 'related_tables', 'relationships') + ) + + +def _split(where: str, split: Mapping[str, Any]) -> Iterator[tuple[str, str]]: + yield from _columns(f'{where}.instance_table', split.get('instance_table')) + for name, table in _entries(split.get('related_tables')): + yield from _columns(f'{where}.related_tables.{name}', table) + yield from _extra( + where, split, ('instance_table', 'related_tables', 'metadata') + ) + + +def _metadata( + where: str, metadata: Mapping[str, Any] +) -> Iterator[tuple[str, str]]: yield from _texts( - 'metadata', - payload.get('metadata') or {}, - ('request_id', 'trace_id', 'source_query'), + where, metadata, ('request_id', 'trace_id', 'source_query') ) +def _extra( + where: str, values: Mapping[str, Any], fields: tuple[str, ...] +) -> Iterator[tuple[str, str]]: + for name in values: + if name not in fields: + yield (f'{where}.{name}' if where else str(name)), _EXTRA + + +def _entries(values: Any) -> Iterator[tuple[str, Any]]: + yield from (values.items() if isinstance(values, Mapping) else ()) + + def _texts( where: str, values: Mapping[str, Any], names: tuple[str, ...] ) -> Iterator[tuple[str, str]]: @@ -94,11 +170,12 @@ def _listed(where: str, values: Any) -> Iterator[tuple[str, str]]: def _table_schema(where: str, table: Any) -> Iterator[tuple[str, str]]: if not isinstance(table, Mapping): return - for name, column in (table.get('columns') or {}).items(): + for name, column in _entries(table.get('columns')): if isinstance(column, Mapping): yield from _texts( f'{where}.columns.{name}', column, ('dtype', 'stype') ) + yield from _extra(where, table, ('columns', 'primary_key')) def _columns(where: str, table: Any) -> Iterator[tuple[str, str]]: @@ -114,6 +191,8 @@ def _columns(where: str, table: Any) -> Iterator[tuple[str, str]]: f"duplicate column name '{column}'", ) seen.add(column) + if table.get('format') == 'arrays': + yield from _extra(where, table, ('format', 'columns', 'rows')) def check_columns( diff --git a/packages/structured-data-models-client/src/sdm_client/in_process/relational.py b/packages/structured-data-models-client/src/sdm_client/in_process/relational.py index 56e728148..bf6de6370 100644 --- a/packages/structured-data-models-client/src/sdm_client/in_process/relational.py +++ b/packages/structured-data-models-client/src/sdm_client/in_process/relational.py @@ -30,6 +30,18 @@ _INFERENCE_OPTIONS = frozenset( {'run_mode', 'use_prediction_time', 'inference_config'} ) +_NAMED_TASK_OPTIONS = ( + 'entity_table_names', + 'anchor_time_column', + 'top_k', + 'step_size', + 'num_forecasts', +) +_TASK_OPTIONS = frozenset({'entity_table_names', 'anchor_time_column'}) +_CLASSIFICATIONS = frozenset( + {'classification', 'binary_classification', 'multiclass_classification'} +) +_NUMERIC_TARGETS = frozenset({'bool', 'int32', 'int64', 'float32', 'float64'}) class Relational: @@ -60,6 +72,18 @@ def context(self, payload: Mapping[str, Any]) -> Context: schema = payload['schema'] task = payload['task'] + if not schema.get('related_tables'): + raise refusal( + 'INVALID_SCHEMA', + 'kumo-relational requires at least one related table', + ) + target_table = task['target'].get('table_name') or INSTANCE_TABLE + if target_table != INSTANCE_TABLE: + raise refusal( + 'UNSUPPORTED_FEATURE', + 'Kumo Relational requires the target on instance_table', + ) + self.check_inference(payload.get('inference') or {}, task) edges, links = _graph(schema.get('relationships') or ()) if len(links) != 1: raise refusal( @@ -67,10 +91,12 @@ def context(self, payload: Mapping[str, Any]) -> Context: f'a relational request needs exactly one relationship from ' f'the instance table into a related table, got {len(links)}', ) - joins = _joins(edges, links) - split = payload['context'] spec = schema['instance_table'] + _check_task(task, spec, links[0].table) + split = payload['context'] table = split['instance_table'] + _check_readable(task, spec, table) + joins = _joins(edges, links) if not table['rows']: raise refusal( 'VALIDATION_FAILED', @@ -180,6 +206,98 @@ def query( ) +def _check_task( + task: Mapping[str, Any], spec: Mapping[str, Any], linked: str +) -> None: + r"""Refuse a task the model would not score as it states, as the NIM does. + + The entity named must be the table the scored rows link to, and the + anchor a timestamp the instance table declares. + """ + options = { + name: task[name] + for name in _NAMED_TASK_OPTIONS + if task.get(name) is not None + } + options.update( + { + name: value + for name, value in task.items() + if name not in {'kind', 'target', *_NAMED_TASK_OPTIONS} + } + ) + unsupported = sorted(set(options) - _TASK_OPTIONS) + if unsupported: + raise refusal( + 'UNSUPPORTED_FEATURE', + f'Kumo Relational does not act on these task options: ' + f'{unsupported}', + ) + requested = list(options.get('entity_table_names') or ()) + if len(requested) != 1: + raise refusal( + 'TASK_SCHEMA_MISMATCH', + f'the task must name exactly one entity table, got {requested}', + ) + if requested[0] == INSTANCE_TABLE: + raise refusal( + 'UNSUPPORTED_FEATURE', + 'scoring instance_table as the entity requires a synthetic ' + 'entity table, which this binding does not build yet', + ) + if requested[0] != linked: + raise refusal( + 'TASK_SCHEMA_MISMATCH', + f'task names entity table {requested[0]!r} but the instance ' + f'table links to {linked!r}', + ) + anchor = options.get('anchor_time_column') + if anchor is None: + return + column = spec['columns'].get(anchor) + if column is None: + raise refusal( + 'TASK_SCHEMA_MISMATCH', + f'task names anchor time column {anchor!r}, which the instance ' + f'table does not declare', + ) + if anchor in key_columns(spec) or stype_of(anchor, column) != 'datetime': + raise refusal( + 'TASK_SCHEMA_MISMATCH', + f'anchor time column {anchor!r} is not a timestamp', + ) + + +def _check_readable( + task: Mapping[str, Any], spec: Mapping[str, Any], table: Mapping[str, Any] +) -> None: + r"""Refuse an instance column or a target the model cannot read. + + Asked before the rows are, as the NIM asks it. + """ + target = task['target'] + keys = key_columns(spec) + supplied = set(table['columns']) + for name, column in spec['columns'].items(): + if name in supplied and name not in keys | {target['column_name']}: + stype_of(name, column) + dtype = target.get('dtype') + if dtype == 'stringlist': + raise refusal( + 'UNSUPPORTED_DTYPE', + 'Kumo Relational does not consume list-valued targets', + ) + readable = _NUMERIC_TARGETS + if task['kind'] in _CLASSIFICATIONS: + readable = readable | {'string'} + if dtype not in readable: + raise refusal( + 'TASK_SCHEMA_MISMATCH', + f'Kumo Relational cannot represent {task["kind"]!r} targets ' + f'declared as {dtype!r}', + ) + + def _graph(relationships: Sequence[Mapping[str, Any]]) -> tuple[list, list]: r"""The joins between related tables, and the link from the scored rows. diff --git a/packages/structured-data-models-client/src/sdm_client/in_process/tabular.py b/packages/structured-data-models-client/src/sdm_client/in_process/tabular.py index ebe1b8b0c..8eaf45567 100644 --- a/packages/structured-data-models-client/src/sdm_client/in_process/tabular.py +++ b/packages/structured-data-models-client/src/sdm_client/in_process/tabular.py @@ -57,9 +57,10 @@ def context(self, payload: Mapping[str, Any]) -> Context: 'UNSUPPORTED_FEATURE', f'{what} are not supported by model {self.model_id!r}.', ) + task = payload['task'] + self.check_inference(payload.get('inference') or {}, task) spec = schema['instance_table'] table = payload['context']['instance_table'] - task = payload['task'] target = task['target']['column_name'] keys = key_columns(spec) stypes = { diff --git a/packages/structured-data-models-client/src/sdm_client/in_process/transport.py b/packages/structured-data-models-client/src/sdm_client/in_process/transport.py index 96e4de1b6..87752c7f3 100644 --- a/packages/structured-data-models-client/src/sdm_client/in_process/transport.py +++ b/packages/structured-data-models-client/src/sdm_client/in_process/transport.py @@ -35,7 +35,12 @@ row_ids, ) from sdm_client.in_process.columns import Context -from sdm_client.in_process.contract import check_columns, check_contract +from sdm_client.in_process.contract import ( + SESSION, + SESSION_PREDICTION, + check_columns, + check_contract, +) from sdm_client.in_process.inference import read_inference from sdm_client.in_process.refusal import in_session, refusal from sdm_client.in_process.relational import Relational @@ -122,7 +127,7 @@ def list_models(self) -> list[str]: ] def predict(self, payload: dict[str, Any]) -> dict[str, Any]: - payload = _received(payload) + payload = _schema_by_name(_received(payload)) with self._answering(): check_contract(payload) payload['inference'] = read_inference(payload.get('inference')) @@ -131,7 +136,6 @@ def predict(self, payload: dict[str, Any]) -> dict[str, Any]: task = payload['task'] fields = _fields(served, task['kind'], payload.get('output')) _check_trained_for(served, model, task['kind']) - served.check_inference(payload['inference'], task) context = served.context(payload) query = served.query(context, payload, payload['schema']) output = _run(_fit(model, context), query) @@ -147,9 +151,9 @@ def predict(self, payload: dict[str, Any]) -> dict[str, Any]: def create_session(self, payload: dict[str, Any]) -> dict[str, Any]: r"""Fit the context once, to be scored against until released.""" - payload = _received(payload) + payload = _schema_by_name(_received(payload)) with self._answering(): - check_contract(payload) + check_contract(payload, SESSION) with in_session('SESSION_CREATE_VALIDATION_FAILED'): check_columns(payload, payload['schema'], ('context',)) served, model = self._serving(payload.get('model')) @@ -173,7 +177,7 @@ def session_predict( ) -> dict[str, Any]: payload = _received(payload) with self._answering(): - check_contract(payload) + check_contract(payload, SESSION_PREDICTION) inference = read_inference(payload.get('inference')) session = self._sessions.get(session_id) if session is None: @@ -336,6 +340,17 @@ def _received(payload: dict[str, Any]) -> dict[str, Any]: ) from error +def _schema_by_name(payload: dict[str, Any]) -> dict[str, Any]: + r"""The payload with a schema sent as ``schema_`` read as its schema. + + A NIM's request model takes the field by that name too, when ``schema`` + itself is absent. + """ + if 'schema' not in payload and 'schema_' in payload: + payload['schema'] = payload.pop('schema_') + return payload + + def _outputs(served: Tabular | Relational, kind: str) -> tuple[str, ...]: r"""What this model produces for this task, which it must serve.""" available = served.outputs.get(kind) diff --git a/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py b/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py index 3f17f2d31..fb18620d4 100644 --- a/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py +++ b/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py @@ -326,6 +326,34 @@ def test_a_refusal_inside_a_session_is_filed_as_the_sessions(relational): ) +def test_each_session_call_takes_only_its_own_fields(relational): + """A session is created without inference options and scored without + the task it already holds; a NIM refuses either one sent.""" + payload = _relational_payload() + created = { + name: payload[name] for name in ('model', 'task', 'schema', 'context') + } + scored = {'predict': payload['predict'], 'output': payload['output']} + with SDMClient.in_process(relational) as client: + transport = client._transport + session = transport.create_session(created)['session_id'] + for call, extra in ( + ( + lambda: transport.create_session({**created, 'inference': {}}), + 'inference', + ), + ( + lambda: transport.session_predict( + session, {**scored, 'task': payload['task']} + ), + 'task', + ), + ): + error = refused(call) + assert (error.code, error.status_code) == ('VALIDATION_FAILED', 422) + assert f'{extra}: Extra inputs are not permitted' in error.message + + def test_a_task_the_model_was_not_loaded_for_is_refused(sdm): regressor = sdm.models.KumoTabular( task='regression', size='small', pretrained=False, device='cpu' @@ -443,14 +471,62 @@ def _without(table: dict, column: str) -> None: lambda p: p['predict']['related_tables'].pop('orders'), ('INVALID_SCHEMA', 422, "reference table(s) ['orders']"), ), + ( + 'relational', + lambda p: p['task'].update(entity_table_names=['orders']), + ( + 'TASK_SCHEMA_MISMATCH', + 422, + "entity table 'orders' but the instance table links to 'users'", + ), + ), + ( + 'relational', + lambda p: p['task'].update(top_k=3), + ('UNSUPPORTED_FEATURE', 422, "task options: ['top_k']"), + ), + ( + 'relational', + lambda p: p['task'].update(anchor_time_column='nowhere'), + ('TASK_SCHEMA_MISMATCH', 422, "anchor time column 'nowhere'"), + ), + ( + 'relational', + lambda p: p['task']['target'].update(dtype='timestamp[us]'), + ( + 'TASK_SCHEMA_MISMATCH', + 422, + "'regression' targets declared as 'timestamp[us]'", + ), + ), + ( + 'tabular', + lambda p: p.update( + schema={ + **p['schema'], + 'related_tables': { + 't': {'columns': {'a': {'dtype': 'int64'}}} + }, + }, + inference={'bogus': True}, + ), + ('UNSUPPORTED_FEATURE', 422, 'related tables are not'), + ), + ( + 'tabular', + lambda p: p.update(bogus=True), + ('VALIDATION_FAILED', 422, 'bogus: Extra inputs are not'), + ), + ('tabular', lambda p: p.update(schema_=p.pop('schema')), None), ], ) def test_a_request_is_read_in_the_order_the_nim_reads_it( relational, tabular, model, change, answer ): """Each table supplies exactly its declared columns, a split carries - every table the relationships name, and a field the request does not ask - for is not judged.""" + every table the relationships name, the task names the entity its rows + link to, nothing carries a key the contract does not name, and a field + the request does not ask for is not judged.""" payload = ( _relational_payload() if model == 'relational' diff --git a/qa/probe_in_process.py b/qa/probe_in_process.py index b94240d2e..27a9968c5 100644 --- a/qa/probe_in_process.py +++ b/qa/probe_in_process.py @@ -39,8 +39,14 @@ def agree(entry, hosted, local) -> None: worst = 0.0 for column in a.columns: if a[column].dtype.kind == 'f': - gap = np.abs(a[column].to_numpy() - b[column].to_numpy()) - worst = max(worst, float(np.nanmax(gap)) if len(gap) else 0.0) + x = a[column].to_numpy(dtype=float) + y = b[column].to_numpy(dtype=float) + if not np.array_equal(np.isnan(x), np.isnan(y)): + fail(entry, f'{column} is missing on one side only') + return + differ = (x != y) & ~np.isnan(x) + if differ.any(): + worst = max(worst, float(np.abs(x - y)[differ].max())) elif a[column].astype(str).tolist() != b[column].astype(str).tolist(): fail(entry, f'{column} differs') return From 077e9f637d90bb24a7fe2b2e3743874bfda7b974 Mon Sep 17 00:00:00 2001 From: Manush Murali Date: Mon, 19 Oct 2026 09:35:22 -0700 Subject: [PATCH 16/17] List every validation problem in one refusal, as the NIM does The inference block was typed after the rest of the contract and stopped at its first problem, so a request with several was refused for one at a time, where a NIM lists them all. Its problems now join the contract's, at the inference field's place in the NIM's order. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Manush Murali --- .../src/sdm_client/in_process/contract.py | 5 +- .../src/sdm_client/in_process/inference.py | 76 ++++++++++--------- .../test_a_model_runs_in_this_process.py | 15 ++++ 3 files changed, 61 insertions(+), 35 deletions(-) diff --git a/packages/structured-data-models-client/src/sdm_client/in_process/contract.py b/packages/structured-data-models-client/src/sdm_client/in_process/contract.py index e30b5c5a7..f62cfaff2 100644 --- a/packages/structured-data-models-client/src/sdm_client/in_process/contract.py +++ b/packages/structured-data-models-client/src/sdm_client/in_process/contract.py @@ -17,6 +17,7 @@ from typing import Any from sdm_client.errors import format_invalid_params +from sdm_client.in_process.inference import inference_problems from sdm_client.in_process.refusal import refusal _TEXT = 1024 @@ -70,7 +71,9 @@ def _problems( } for field in fields: value = payload.get(field) - if field in readers and isinstance(value, Mapping): + if field == 'inference': + yield from inference_problems(value) + elif field in readers and isinstance(value, Mapping): yield from readers[field](field, value) yield from _extra('', payload, fields) diff --git a/packages/structured-data-models-client/src/sdm_client/in_process/inference.py b/packages/structured-data-models-client/src/sdm_client/in_process/inference.py index e7c7b64c5..7ddfebf37 100644 --- a/packages/structured-data-models-client/src/sdm_client/in_process/inference.py +++ b/packages/structured-data-models-client/src/sdm_client/in_process/inference.py @@ -11,64 +11,72 @@ from __future__ import annotations -from typing import Any, NoReturn - -from sdm_client.in_process.refusal import refusal +from collections.abc import Iterator +from typing import Any _MAX_TEXT = 1024 _TRUE = frozenset({'1', 'on', 't', 'true', 'y', 'yes'}) _FALSE = frozenset({'0', 'off', 'f', 'false', 'n', 'no'}) -def read_inference(inference: Any) -> dict[str, Any]: - r"""The inference options, typed, with each field a NIM fills in by - default present.""" +def inference_problems(inference: Any) -> Iterator[tuple[str, str]]: + r"""Each way the block fails the NIM's typing, in the NIM's order.""" if inference is None: - inference = {} + return if not isinstance(inference, dict): - _invalid( + yield ( 'inference', 'Input should be a valid dictionary or object to extract fields ' 'from', ) - options = { - 'run_mode': None, - 'use_prediction_time': False, - 'inference_config': {}, - **inference, - } - run_mode = options['run_mode'] + return + run_mode = inference.get('run_mode') if run_mode is not None and not isinstance(run_mode, str): - _invalid('inference.run_mode', 'Input should be a valid string') + yield 'inference.run_mode', 'Input should be a valid string' if isinstance(run_mode, str) and len(run_mode) > _MAX_TEXT: - _invalid( + yield ( 'inference.run_mode', f'String should have at most {_MAX_TEXT} characters', ) - options['use_prediction_time'] = _boolean(options['use_prediction_time']) - if not isinstance(options['inference_config'], dict): - _invalid( - 'inference.inference_config', 'Input should be a valid dictionary' + if 'use_prediction_time' in inference: + value = inference['use_prediction_time'] + if _boolean(value) is None: + unread = isinstance(value, (int, str)) + yield ( + 'inference.use_prediction_time', + 'Input should be a valid boolean' + + (', unable to interpret input' if unread else ''), + ) + if not isinstance(inference.get('inference_config', {}), dict): + yield ( + 'inference.inference_config', + 'Input should be a valid dictionary', ) + + +def read_inference(inference: Any) -> dict[str, Any]: + r"""The inference options, typed, with each field a NIM fills in by + default present. + + Read once :func:`inference_problems` finds none. + """ + options = { + 'run_mode': None, + 'use_prediction_time': False, + 'inference_config': {}, + **(inference or {}), + } + options['use_prediction_time'] = bool( + _boolean(options['use_prediction_time']) + ) return options -def _boolean(value: Any) -> bool: +def _boolean(value: Any) -> bool | None: if isinstance(value, bool): return value if isinstance(value, str) and value.lower() in _TRUE | _FALSE: return value.lower() in _TRUE if isinstance(value, (int, float)) and value in (0, 1): return bool(value) - unread = isinstance(value, (int, str)) - _invalid( - 'inference.use_prediction_time', - 'Input should be a valid boolean' - + (', unable to interpret input' if unread else ''), - ) - - -def _invalid(name: str, reason: str) -> NoReturn: - raise refusal( - 'VALIDATION_FAILED', f'Request validation failed. {name}: {reason}' - ) + return None diff --git a/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py b/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py index fb18620d4..33369239e 100644 --- a/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py +++ b/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py @@ -518,6 +518,21 @@ def _without(table: dict, column: str) -> None: ('VALIDATION_FAILED', 422, 'bogus: Extra inputs are not'), ), ('tabular', lambda p: p.update(schema_=p.pop('schema')), None), + ( + 'tabular', + lambda p: p.update( + inference={'run_mode': 7, 'inference_config': [1]}, + metadata={'source_query': 'x' * 1025}, + ), + ( + 'VALIDATION_FAILED', + 422, + 'inference.run_mode: Input should be a valid string; ' + 'inference.inference_config: Input should be a valid ' + 'dictionary; metadata.source_query: String should have at ' + 'most 1024 characters', + ), + ), ], ) def test_a_request_is_read_in_the_order_the_nim_reads_it( From 06da0193c3016a342748316e0f80945c03a9cb37 Mon Sep 17 00:00:00 2001 From: Manush Murali Date: Mon, 19 Oct 2026 09:51:44 -0700 Subject: [PATCH 17/17] Answer a composite key under its values as sent A key named as a list is answered under the JSON of its values. In process each value was read first, so an int64 sent as its base-10 digits came back as a number, where a NIM answers it under the string it was sent as. The client passes these ids through, so the difference reached the caller. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Manush Murali --- .../src/sdm_client/in_process/answers.py | 14 +++++--------- .../src/sdm_client/in_process/transport.py | 8 +------- .../tests/test_a_model_runs_in_this_process.py | 18 ++++++++++++++++++ 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/packages/structured-data-models-client/src/sdm_client/in_process/answers.py b/packages/structured-data-models-client/src/sdm_client/in_process/answers.py index dc436de57..6497655e0 100644 --- a/packages/structured-data-models-client/src/sdm_client/in_process/answers.py +++ b/packages/structured-data-models-client/src/sdm_client/in_process/answers.py @@ -14,7 +14,7 @@ import numpy as np from sdm_client.in_process.refusal import refusal -from sdm_client.in_process.tables import read_cell, values +from sdm_client.in_process.tables import values #: The levels a NIM reports when the request names none. QUANTILE_LEVELS = (0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9) @@ -25,14 +25,13 @@ def row_ids( - table: Mapping[str, Any], - primary_key: str | Sequence[str] | None, - dtypes: Mapping[str, str], + table: Mapping[str, Any], primary_key: str | Sequence[str] | None ) -> list[str | None]: r"""The id a NIM answers each row under. A key named as one column is the value itself; a key named as a list is - the list of its values, written as JSON. + the list of its values, written as JSON. Either is the value as it was + sent, so an integer sent as its digits stays a string. """ columns = table['columns'] if isinstance(primary_key, str) and primary_key in columns: @@ -42,10 +41,7 @@ def row_ids( and primary_key and all(name in columns for name in primary_key) ): - cells = [ - [read_cell(cell, dtypes.get(name)) for cell in values(table, name)] - for name in primary_key - ] + cells = [values(table, name) for name in primary_key] return [ json.dumps(list(key), separators=(',', ':'), default=str) for key in zip(*cells, strict=True) diff --git a/packages/structured-data-models-client/src/sdm_client/in_process/transport.py b/packages/structured-data-models-client/src/sdm_client/in_process/transport.py index 87752c7f3..5edf7a631 100644 --- a/packages/structured-data-models-client/src/sdm_client/in_process/transport.py +++ b/packages/structured-data-models-client/src/sdm_client/in_process/transport.py @@ -457,14 +457,8 @@ def _response( fields: list[str], ) -> dict[str, Any]: instance = schema['instance_table'] - dtypes = { - name: str(column.get('dtype')) - for name, column in instance['columns'].items() - } ids = row_ids( - payload['predict']['instance_table'], - instance.get('primary_key'), - dtypes, + payload['predict']['instance_table'], instance.get('primary_key') ) kind = task['kind'] if kind == 'regression': diff --git a/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py b/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py index 33369239e..e2181343d 100644 --- a/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py +++ b/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py @@ -12,6 +12,7 @@ from __future__ import annotations import copy +import json import subprocess import sys import warnings @@ -560,6 +561,23 @@ def test_a_request_is_read_in_the_order_the_nim_reads_it( assert said in error.message +def test_a_row_is_answered_under_its_key_as_sent(tabular): + """A key named as a list answers each row under the JSON of its values + as they were sent, so an integer wider than a double keeps its digits + as a string, as a NIM keeps them.""" + payload = _tabular_payload('classification') + payload['schema']['instance_table']['primary_key'] = ['person_id'] + table = payload['predict']['instance_table'] + position = table['columns'].index('person_id') + for index, row in enumerate(table['rows']): + row[position] = str(2**53 + 1 + index) + with SDMClient.in_process(tabular) as client: + answer = client._transport.predict(payload) + assert answer['predictions'][0]['id'] == json.dumps( + [table['rows'][0][position]] + ) + + def test_a_model_passed_in_keeps_what_was_fitted_on_it(sdm, tabular): frame = people() features = {'age': 'numerical', 'visits': 'numerical'}