diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7479667ce..7f5d2cad2 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 @@ -205,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 @@ -325,15 +331,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, ( @@ -370,8 +388,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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f8734742..16e6f6926 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ these packages. All five distributions share one version and one entry. ### 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 request + is prepared as it is for a NIM and the model run on it directly, the context + fitted as the NIM fits it. A request the model cannot answer is refused with + a `StructuredDataError` whose code says why. Install it with the + `in-process` extra. - The relational engine publishes wheels for macOS (Apple silicon and Intel) and 64-bit Windows, and for CPython 3.14, alongside the `manylinux_2_28` x86-64 and aarch64 wheels it already built, so `structured-data-models-client[relational]` @@ -22,6 +30,26 @@ these packages. All five distributions share one version and one entry. emulated, and each is smoke-tested on the architecture it targets. The release no longer builds aarch64 under QEMU, which also takes a privileged container image out of the release path. +- A request's inference options are written in the types the contract + gives them before it is sent: `use_prediction_time` spelled `'no'` is sent + as `false`, a NumPy boolean or integer is sent as the value it holds, and a + value that cannot be read as its type, such as a `run_mode` that is not + text, is refused with `INVALID_REQUEST` before sending, where a NIM + answered `422 VALIDATION_FAILED`. +- The `duckdb` extra holds `duckdb` below 2. The 2.0 release warns on the + Arrow fetch the relational engine reads DuckDB tables through, and this + release is tested against 1.5. +- `structured-data-models-client` and the relational engine hold `pandas` + below 3.1. The 3.1 release raises taking a date offset from the earliest + timestamp, which the engine does for every graph without a time column, + and this release is tested against 3.0. + +### Fixed + +- A predictive query longer than 1,024 characters is answered. A relational + request carries the query in `metadata.source_query` for logging, which the + contract caps at 1,024 characters, so a NIM refused a longer one with + `VALIDATION_FAILED`. The logged copy is now cut to fit. ## 1.0.0 - 2026-10-15 diff --git a/README.md b/README.md index f820f1c58..5e5a1852d 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,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: @@ -84,6 +86,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 80cdd88b9..d185a261b 100644 --- a/docs/about/architecture.md +++ b/docs/about/architecture.md @@ -132,6 +132,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 @@ -673,7 +674,7 @@ serve. | Call | Answer | |---|---| -| `client.models()` | Read from the endpoint's `/v1/models`. Endpoint truth. | +| `client.models()` | Read from the endpoint's `/v1/models`. Endpoint truth. In process, the models the client can run. | | `client.capabilities(model)` | Local: what this client can build and send. Tagged `source="sdk"`. | `/v1/models` is a NIM runtime route and is **not** one of the contract's four diff --git a/docs/about/overview.md b/docs/about/overview.md index 6ddd5a373..498baf4ac 100644 --- a/docs/about/overview.md +++ b/docs/about/overview.md @@ -5,8 +5,11 @@ labelled rows and the rows you want scored, and it answers in one pass. There is no training loop, no hyperparameter search, and no per-dataset artifact. This client builds the request, sends it to a NIM, and gives you back a -`PredictionResult`, which `to_pandas()` turns into a DataFrame. Inference runs in the NIM, so the client downloads no weights and -needs no GPU. +`PredictionResult`, which `to_pandas()` turns into a DataFrame. Inference runs +in the NIM, so the client downloads no weights and needs no GPU. A client +opened with `SDMClient.in_process()` runs the model in your own process +instead, with the same calls; that one loads the weights, and uses a GPU when +there is one. ## The one idea @@ -35,5 +38,5 @@ declared as data instead of discovered from a 422. - **You want to train a model.** These models do not train. If you need a model fitted to your data, this is the wrong tool. - **Your data cannot leave your environment.** Run a NIM in your environment - and point the client at it. -- **You want inference in-process.** Everything here is an HTTP call. + and point the client at it, or run the model in your own process with + `SDMClient.in_process()`. diff --git a/docs/get-started/installation.md b/docs/get-started/installation.md index ea8b96eb4..26c8bc6d9 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. 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/prerequisites.md b/docs/get-started/prerequisites.md index 9d7ee0703..01449f1d6 100644 --- a/docs/get-started/prerequisites.md +++ b/docs/get-started/prerequisites.md @@ -9,8 +9,11 @@ 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. -- **No GPU** on the client. The NIM owns that. + 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. 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 d130d8f61..750fffacd 100644 --- a/docs/get-started/quickstart.md +++ b/docs/get-started/quickstart.md @@ -152,6 +152,61 @@ 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 request is prepared exactly as it is for a NIM, +and the model is run on it directly, with no HTTP in between. The context is +fitted with the NIM's seed and settings, and `fit()` holds its session in +memory. On a GPU it computes as the NIM does, and with the published weights on +the same GPU it gives the NIM's answers exactly. + +A request the model cannot answer is refused with a `StructuredDataError` whose +`code` says why, from the codes a NIM uses, such as `UNSUPPORTED_OUTPUT`. It is +not a `NimRequestError` and has no HTTP status, since nothing was sent. Inside +a session the code is the reason's own, where a NIM files the refusal under +`SESSION_CREATE_VALIDATION_FAILED` or `SESSION_PREDICT_SCHEMA_MISMATCH`. A +request is checked for what the model needs to read it, not against every rule +of the request contract: a name longer than the contract's 1,024 characters, or +a key the contract does not name in a request built by hand and passed to +`Request.materialized`, is answered here where a NIM refuses it with +`VALIDATION_FAILED`. + +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). That includes a +session's expiry: what `fit()` returns holds its fitted context, in GPU memory +when the model is on a GPU, until it or the client is closed, so open it in a +`with` block, as above, or call its `close()`. + +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 A query says *what* to predict. It never says *which rows*. diff --git a/docs/reference/limits.md b/docs/reference/limits.md index a52f312de..1700037b3 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 @@ -181,7 +187,8 @@ not read from the endpoint. `models()` *is* read from the endpoint, so it is the endpoint's own answer. The route it uses is supplied by the NIM runtime rather than by the Universal TFM contract, so an endpoint that does not serve it gives a clear error rather than -a wrong answer. +a wrong answer. A client opened with `SDMClient.in_process()` has no endpoint, +and lists the models it can run. There is more than one build of `kumo-relational`. The client declares the broader build with six task kinds and seven output fields. A narrower build diff --git a/packages/structured-data-models-client/pyproject.toml b/packages/structured-data-models-client/pyproject.toml index 5a7c18eba..42c700625 100644 --- a/packages/structured-data-models-client/pyproject.toml +++ b/packages/structured-data-models-client/pyproject.toml @@ -41,7 +41,10 @@ classifiers = [ dependencies = [ "structured-data-models-api==1.0.0", "structured-data-models-pql==1.0.0", - "pandas>=2.2.2", + # 3.1 builds a frame around an integer past the float range where earlier + # releases refused one, and a request's description relies on that + # refusal to leave out the table carrying it. + "pandas>=2.2.2,<3.1", "numpy>=1.23", "requests>=2.28", "urllib3>=1.26", @@ -78,6 +81,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; 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/adapters/relational.py b/packages/structured-data-models-client/src/sdm_client/adapters/relational.py index ce1dce06f..dbd8e9816 100644 --- a/packages/structured-data-models-client/src/sdm_client/adapters/relational.py +++ b/packages/structured-data-models-client/src/sdm_client/adapters/relational.py @@ -23,10 +23,14 @@ import re import uuid import warnings -from collections.abc import Iterator, Mapping +from collections.abc import Iterator, Mapping, Sequence from typing import Any -from sdm_api.generated.tfm_api import PredictionResponse, ResponseContractError +from sdm_api.generated.tfm_api import ( + PredictionItem, + PredictionResponse, + ResponseContractError, +) from sdm_client.base import ModelSpec from sdm_client.core.warnings import SDMWarning, caller_stacklevel @@ -45,6 +49,7 @@ from sdm_client.result import PredictionResult from sdm_client.wire import as_declared from sdm_client.wire.answers import as_declared_target +from sdm_client.wire.inference import typed_inference # What to ask for when the caller named nothing, by task kind. Asking for # everything would make a regression request carry probabilities it cannot @@ -67,6 +72,10 @@ _SESSION_SCORING = ('predict', 'output', 'inference', 'metadata') +#: The longest ``metadata.source_query`` the contract allows. It is kept for +#: logging only, so a longer query is cut to fit rather than refused. +_SOURCE_QUERY_MAX = 1024 + def _request_id() -> str: return f'sdm-{uuid.uuid4().hex[:12]}' @@ -130,11 +139,16 @@ def prediction_plan( **(payload.get('metadata') or {}), 'request_id': _request_id(), } + source_query = envelope['metadata'].get('source_query') + if isinstance(source_query, str): + envelope['metadata']['source_query'] = source_query[ + :_SOURCE_QUERY_MAX + ] inference = self._inference(envelope.get('inference'), request) if inference is None: envelope.pop('inference', None) else: - envelope['inference'] = inference + envelope['inference'] = typed_inference(inference) yield envelope def _inference( @@ -249,22 +263,33 @@ def normalize( code='INVALID_RESPONSE', ) from error - explanation = next( - ( - item.explanation - for item in parsed.predictions - if item.explanation - ), - None, - ) - return PredictionResult( + return self.answered( + parsed.predictions, + outputs, + payload, model=parsed.model, request_id=parsed.id, - items=as_declared_target( - _named_entities(parsed.predictions, payload), - payload, - ), metadata=parsed.metadata, + ) + + def answered( + self, + items: Sequence[PredictionItem], + outputs: tuple[str, ...], + payload: dict[str, Any] | None, + *, + model: str, + request_id: str = '', + metadata: Mapping[str, Any] | None = None, + ) -> PredictionResult: + explanation = next( + (item.explanation for item in items if item.explanation), None + ) + return PredictionResult( + model=model, + request_id=request_id, + items=as_declared_target(_named_entities(items, payload), payload), + metadata=dict(metadata or {}), outputs=outputs, explanation=explanation, ) @@ -720,7 +745,7 @@ def _columns_with_stype(payload: Mapping[str, Any], stype: str) -> list[str]: def _named_entities( - items: tuple[Any, ...], payload: dict[str, Any] | None + items: Sequence[Any], payload: dict[str, Any] | None ) -> list[Any]: """Put the caller's entity back on each row, and check it belongs there. diff --git a/packages/structured-data-models-client/src/sdm_client/adapters/tabular.py b/packages/structured-data-models-client/src/sdm_client/adapters/tabular.py index ce1e2bf1c..5cbf3e98c 100644 --- a/packages/structured-data-models-client/src/sdm_client/adapters/tabular.py +++ b/packages/structured-data-models-client/src/sdm_client/adapters/tabular.py @@ -24,11 +24,12 @@ import uuid import warnings -from collections.abc import Iterator, Mapping +from collections.abc import Iterator, Mapping, Sequence from typing import Any import pandas as pd from sdm_api.generated.tfm_api import ( + PredictionItem, PredictionResponse, ResponseContractError, ) @@ -49,6 +50,7 @@ widen_tfm_dtype, ) from sdm_client.wire.answers import as_declared_target +from sdm_client.wire.inference import typed_inference # Output fields to ask for when the caller named none, by task kind. Asking for # everything would make a regression request carry probabilities it cannot @@ -140,14 +142,30 @@ def normalize( f'match the contract: {error}', code='INVALID_RESPONSE', ) from error - return PredictionResult( + return self.answered( + parsed.predictions, + outputs, + payload, model=parsed.model, request_id=parsed.id, - items=as_declared_target( - _named_entities(parsed.predictions, payload), - payload, - ), metadata=parsed.metadata, + ) + + def answered( + self, + items: Sequence[PredictionItem], + outputs: tuple[str, ...], + payload: dict[str, Any] | None, + *, + model: str, + request_id: str = '', + metadata: Mapping[str, Any] | None = None, + ) -> PredictionResult: + return PredictionResult( + model=model, + request_id=request_id, + items=as_declared_target(_named_entities(items, payload), payload), + metadata=dict(metadata or {}), outputs=outputs, ) @@ -272,7 +290,9 @@ def _envelope( 'metadata': {'request_id': _request_id()}, } if request.inference: - envelope['inference'] = json_ready(request.inference) + envelope['inference'] = typed_inference( + json_ready(request.inference) + ) return envelope @staticmethod @@ -435,7 +455,7 @@ def _key_named_by(value: str) -> str | None: def _named_entities( - items: tuple[Any, ...], payload: dict[str, Any] | None + items: Sequence[Any], payload: dict[str, Any] | None ) -> list[Any]: r"""Put the caller's key back on each row, and check it belongs there. diff --git a/packages/structured-data-models-client/src/sdm_client/base.py b/packages/structured-data-models-client/src/sdm_client/base.py index d4a8b2a87..3cca7b8b0 100644 --- a/packages/structured-data-models-client/src/sdm_client/base.py +++ b/packages/structured-data-models-client/src/sdm_client/base.py @@ -24,7 +24,7 @@ from __future__ import annotations -from collections.abc import Iterable, Mapping +from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass, field, fields from typing import TYPE_CHECKING, Any, Protocol @@ -33,6 +33,8 @@ from sdm_client.errors import UnsupportedRequestError if TYPE_CHECKING: # pragma: no cover - imported for typing only. + from sdm_api.generated.tfm_api import PredictionItem + from sdm_client.request import Request from sdm_client.result import PredictionResult @@ -170,6 +172,23 @@ def normalize( """ ... + def answered( + self, + items: Sequence[PredictionItem], + outputs: tuple[str, ...], + payload: dict[str, Any] | None, + *, + model: str, + request_id: str = '', + metadata: Mapping[str, Any] | None = None, + ) -> PredictionResult: + r"""The result for one call's answers, whichever backend gave them. + + Each answer is put under the key the caller asked about, read from + ``payload``, and checked to belong to the row it names. + """ + ... + def explain_error( self, error: Exception, payload: dict[str, Any] ) -> Exception: 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..18c52bba7 100644 --- a/packages/structured-data-models-client/src/sdm_client/client.py +++ b/packages/structured-data-models-client/src/sdm_client/client.py @@ -2,20 +2,23 @@ # All rights reserved. # SPDX-License-Identifier: Apache-2.0 -r"""One connection to one NIM, serving every model it can reach. +r"""One connection to one NIM, serving every model it can reach, or to the +models run in this process. The model is an argument: >>> with SDMClient(url='http://localhost:8000') as client: ... result = client.predict(request, model=KumoTabular()) -Each client owns its own transport, so several can target different endpoints +Each client owns its own backend, so several can target different endpoints or tenants at once, including from several threads: a prediction always goes to -the endpoint and credential of the client that started it. +the endpoint and credential of the client that started it, or for a client +opened with :meth:`SDMClient.in_process`, to the models that client holds. """ from __future__ import annotations +import copy import dataclasses import warnings from collections.abc import Callable, Iterable, Mapping, Sequence @@ -29,7 +32,8 @@ check_request_against_model, describe, ) -from sdm_client.core.transport import Transport, WireTransport, redact_url +from sdm_client.core.backend import Backend, NimBackend +from sdm_client.core.transport import Transport, redact_url from sdm_client.core.warnings import SDMWarning, caller_stacklevel from sdm_client.decompose import RANKING, Decomposition, about_the_part from sdm_client.errors import ( @@ -53,10 +57,6 @@ without_the_keys, ) -# A NIM that does not serve the session routes at all, as opposed to one that -# has merely forgotten this session. -_SESSIONS_UNSUPPORTED = frozenset({404, 405, 501}) - def _adapter_for(spec: ModelSpec) -> ModelAdapter: r"""The adapter that shapes requests for ``spec``. @@ -83,13 +83,15 @@ class FittedModel: against it is a second call. That is what ``fit`` and ``predict`` are here. Close it when done, or use it as a context manager. A session holds memory - on the endpoint until it is released or expires. + on the endpoint until it is released or expires. For a client opened with + :meth:`SDMClient.in_process`, the endpoint is this process: the context is + fitted and held here, and released when the session or the client closes. ``pinned`` is what the session pinned, minus the context itself. Reading an answer needs the task and the schema, where the key an id stands for is - named. The rows have been uploaded and live on the endpoint, so - keeping a second encoded copy for the lifetime of the handle would hold a - whole dataset for nothing. + named. The rows have been uploaded and live on the endpoint, or in process + have been fitted and are not kept, so keeping a second encoded copy for the + lifetime of the handle would hold a whole dataset for nothing. It is not constructed directly. What it is built from is how the client talks to a model and what the session pinned, none of which is public. @@ -190,12 +192,12 @@ def predict(self, request: Request) -> PredictionResult: self._adapter.session_prediction_plan( request, self._outputs, self._pinned ), - lambda payload: self._adapter.normalize( - self._client._transport.session_predict( - self._session_id, payload - ), + lambda payload: self._client._backend.session_predict( + self._adapter, + self._session_id, + payload, self._outputs, - {**self._pinned, **payload}, + self._pinned, ), ) if not parts: @@ -218,7 +220,7 @@ def close(self) -> None: """ if self._closed: return - self._client._transport.delete_session(self._session_id) + self._client._backend.delete_session(self._session_id) self._closed = True pinned = getattr(self._client, '_pinned', None) if pinned is not None: @@ -253,7 +255,8 @@ def __repr__(self) -> str: class SDMClient: - r"""A connection to one Universal TFM NIM. + r"""A connection to one Universal TFM NIM, or to models run in this + process when opened with :meth:`in_process`. Use it as a context manager, or call :meth:`close`. """ @@ -288,24 +291,26 @@ def __init__( endpoint. """ self._start( - Transport( - url, - api_key, - verify_ssl=verify_ssl, - timeout=timeout, - max_retries=max_retries, + NimBackend( + Transport( + url, + api_key, + verify_ssl=verify_ssl, + timeout=timeout, + max_retries=max_retries, + ) ) ) - def _start(self, transport: WireTransport) -> None: - r"""Set everything a client holds, given what carries its requests. + def _start(self, backend: Backend) -> None: + r"""Set everything a client holds, given what answers its requests. The one place a client's state is set. :meth:`for_databricks_serving` builds its client without ``__init__``, so state set only there was missing from a serving client until something reached for it. Add new state here, not to ``__init__``. """ - self._transport: WireTransport = transport + self._backend: Backend = backend self._adapters: dict[str, ModelAdapter] = {} self._pinned: set[FittedModel] = set() @@ -363,29 +368,79 @@ def for_databricks_serving( client = cls.__new__(cls) client._start( - DatabricksServingTransport( - endpoint, workspace_client, timeout=timeout + NimBackend( + DatabricksServingTransport( + endpoint, workspace_client, timeout=timeout + ) ) ) 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()) + + The request is prepared exactly as it is for a NIM, and the model run + on it directly. Each context is fitted as the NIM fits it, at the same + seed and, on a GPU, the same precision. A request the model cannot + answer is refused with a :class:`~sdm_client.errors.StructuredDataError` + whose code says why, from the codes a NIM uses. It is not a + :class:`~sdm_client.errors.NimRequestError`, and has no HTTP status, + since nothing was sent. + + 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]``. 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``. + """ + from sdm_client.in_process import InProcessBackend + + client = cls.__new__(cls) + client._start(InProcessBackend(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 + return self._backend.url def models(self) -> list[str]: r"""The model ids **the endpoint** advertises. Read from ``/v1/models``, not from this client's own list. The previous generation reported its own registry here, which meant it claimed a - model the endpoint did not serve. + model the endpoint did not serve. A client opened with + :meth:`in_process` lists the models it was given, or both when it + loads its own. """ - return self._transport.list_models() + return self._backend.list_models() def capabilities(self, model: str | Model) -> ModelCapabilities: r"""What ``model`` supports, as far as this client knows. @@ -397,8 +452,9 @@ def capabilities(self, model: str | Model) -> ModelCapabilities: return describe(self._spec(model)) def health_ready(self) -> bool: - r"""Whether the NIM answers ``GET /v1/health/ready`` with 200.""" - return self._transport.health_ready() + r"""Whether the NIM answers ``GET /v1/health/ready`` with 200, or for + a client opened with :meth:`in_process`, whether it is still open.""" + return self._backend.health_ready() def predict( self, @@ -440,9 +496,7 @@ def predict( # rows, so returning only the last would drop the earlier batches. parts = _numbered_across_calls( adapter.prediction_plan(request, fields), - lambda payload: adapter.normalize( - self._send(adapter, payload), fields, payload - ), + lambda payload: self._backend.predict(adapter, payload, fields), ) if not parts: raise StructuredDataError( @@ -589,19 +643,6 @@ def _candidates_no_context_teaches( ) return frozenset(positions) - def _send(self, adapter: Any, payload: dict[str, Any]) -> dict[str, Any]: - r"""Post one payload, letting the adapter explain a refusal.""" - try: - return self._transport.predict(payload) - except StructuredDataError as error: - explained = adapter.explain_error(error, payload) - if explained is error: - # An adapter that had nothing to add returns the same object, - # and `raise error from error` makes it its own cause, which - # is a cycle for anything that walks the chain. - raise - raise explained from error - def fit( self, request: Request, @@ -614,7 +655,9 @@ def fit( The pinned half is model, task, schema and context; later calls send only the rows to score. Use this when asking several questions of the - same context, which is what makes uploading it once worthwhile. + same context, which is what makes uploading it once worthwhile. A + client opened with :meth:`in_process` uploads nothing: it fits the + context once, here, and keeps it until the session closes. `holdout` names every row the session will go on to score. The context is chosen once, here, and `request` only excludes the rows *it* names, @@ -644,9 +687,9 @@ def fit( StructuredDataError: If the endpoint does not serve sessions, with code ``'SESSIONS_UNSUPPORTED'``. """ - if not getattr(self._transport, 'serves_sessions', True): + if not self._backend.serves_sessions: raise StructuredDataError( - f'{self._transport.url} cannot pin a context: it answers one ' + f'{self._backend.url} cannot pin a context: it answers one ' f'request at a time. Use predict(), which sends the context ' f'with every call', code='SESSIONS_UNSUPPORTED', @@ -657,37 +700,19 @@ def fit( adapter, fields = self._prepare(request, model, outputs) payload = adapter.session_plan(request, fields) held = _held_back(payload, holdout) - try: - try: - created = self._transport.create_session(payload) - except StructuredDataError as error: - explained = adapter.explain_error(error, payload) - if explained is error: - raise - raise explained from error - except Exception as error: - status = getattr(error, 'status_code', None) - if status in _SESSIONS_UNSUPPORTED: - raise StructuredDataError( - f'this endpoint does not serve sessions (HTTP {status}); ' - f'use predict() instead, which sends the context with ' - f'every call', - code='SESSIONS_UNSUPPORTED', - ) from error - raise - session_id = created.get('session_id') or created.get('id') - if not session_id: - raise StructuredDataError( - 'the endpoint created a session but named no session id', - code='INVALID_RESPONSE', - details={'response_keys': sorted(created)}, - ) + session_id = self._backend.create_session(adapter, payload) fitted = FittedModel._open( self, adapter, - str(session_id), + session_id, fields, - {key: value for key, value in payload.items() if key != 'context'}, + copy.deepcopy( + { + key: value + for key, value in payload.items() + if key != 'context' + } + ), named_outputs=outputs, pinned_keys=keys_in( payload, 'context', with_anchor=request.target_moves_with_time @@ -745,9 +770,9 @@ def _spec(model: str | Model) -> ModelSpec: def close(self) -> None: r"""Release pooled connections and retire this client. - Contexts this client pinned are released first, because the transport + Contexts this client pinned are released first, because the backend that would release them is about to close and `FittedModel.close()` - is the only call that can. Adapters follow, then the transport itself + is the only call that can. Adapters follow, then the backend itself even if one of them fails. Its pool is the one that cannot be rebuilt on demand. """ @@ -767,7 +792,7 @@ def close(self) -> None: if error is not None: raise error finally: - self._transport.close() + self._backend.close() def __enter__(self) -> SDMClient: return self @@ -781,7 +806,7 @@ def __exit__( self.close() def __repr__(self) -> str: - return f'SDMClient(url={redact_url(self._transport.url)!r})' + return f'SDMClient(url={redact_url(self._backend.url)!r})' def _numbered_across_calls( diff --git a/packages/structured-data-models-client/src/sdm_client/core/backend.py b/packages/structured-data-models-client/src/sdm_client/core/backend.py new file mode 100644 index 000000000..b177f4e91 --- /dev/null +++ b/packages/structured-data-models-client/src/sdm_client/core/backend.py @@ -0,0 +1,179 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. +# All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +r"""What answers a request once the client has prepared it. + +Every request is prepared the same way, whichever backend answers it. The +client checks it against the model, and the model's adapter writes it out as +the contract's request: the task, the schema, the context and the rows to +score, the outputs asked for and the inference settings, as Python data. A +backend takes that and gives back a :class:`~sdm_client.result.PredictionResult`. + +:class:`NimBackend` sends it to a NIM and reads the NIM's answer back. The +backend that runs a model in this process is in :mod:`sdm_client.in_process`. +""" + +from __future__ import annotations + +import contextlib +from collections.abc import Iterator, Mapping +from typing import TYPE_CHECKING, Any, Protocol + +from sdm_client.core.transport import WireTransport +from sdm_client.errors import StructuredDataError + +if TYPE_CHECKING: # pragma: no cover - imported for typing only. + from sdm_client.base import ModelAdapter + from sdm_client.result import PredictionResult + +# A NIM that does not serve the session routes at all, as opposed to one that +# has merely forgotten this session. +_SESSIONS_UNSUPPORTED = frozenset({404, 405, 501}) + + +class Backend(Protocol): + r"""What a client asks of whatever answers its requests. + + ``adapter`` is the model's own reader. Every backend hands what it + answered to the adapter, which puts each answer under the key the caller + asked about, so an answer reads the same wherever it was computed. + """ + + @property + def url(self) -> str: ... + + #: Whether the backend can pin a context. A client refuses ``fit()`` up + #: front when it cannot, rather than preparing a session to be told so. + @property + def serves_sessions(self) -> bool: ... + + def health_ready(self) -> bool: ... + + def list_models(self) -> list[str]: ... + + def predict( + self, + adapter: ModelAdapter, + payload: dict[str, Any], + outputs: tuple[str, ...], + ) -> PredictionResult: ... + + def create_session( + self, adapter: ModelAdapter, payload: dict[str, Any] + ) -> str: + r"""Pin the context in ``payload``, and return the session's id.""" + ... + + def session_predict( + self, + adapter: ModelAdapter, + session_id: str, + payload: dict[str, Any], + outputs: tuple[str, ...], + pinned: Mapping[str, Any], + ) -> PredictionResult: + r"""Score the rows in ``payload`` against a pinned context. + + ``pinned`` is what the session was opened with, minus the context: + reading the answer needs the task and the schema it pinned. + """ + ... + + def delete_session(self, session_id: str) -> None: ... + + def close(self) -> None: ... + + +class NimBackend: + r"""A NIM: each request is sent to it, and its answer read back. + + ``transport`` carries the request, over HTTP to a NIM or through a + Databricks Model Serving endpoint. + """ + + def __init__(self, transport: WireTransport) -> None: + self.transport = transport + + @property + def url(self) -> str: + return self.transport.url + + @property + def serves_sessions(self) -> bool: + return getattr(self.transport, 'serves_sessions', True) + + def health_ready(self) -> bool: + return self.transport.health_ready() + + def list_models(self) -> list[str]: + return self.transport.list_models() + + def predict( + self, + adapter: ModelAdapter, + payload: dict[str, Any], + outputs: tuple[str, ...], + ) -> PredictionResult: + with _explained(adapter, payload): + response = self.transport.predict(payload) + return adapter.normalize(response, outputs, payload) + + def create_session( + self, adapter: ModelAdapter, payload: dict[str, Any] + ) -> str: + try: + with _explained(adapter, payload): + created = self.transport.create_session(payload) + except Exception as error: + status = getattr(error, 'status_code', None) + if status in _SESSIONS_UNSUPPORTED: + raise StructuredDataError( + f'this endpoint does not serve sessions (HTTP {status}); ' + f'use predict() instead, which sends the context with ' + f'every call', + code='SESSIONS_UNSUPPORTED', + ) from error + raise + session_id = created.get('session_id') or created.get('id') + if not session_id: + raise StructuredDataError( + 'the endpoint created a session but named no session id', + code='INVALID_RESPONSE', + details={'response_keys': sorted(created)}, + ) + return str(session_id) + + def session_predict( + self, + adapter: ModelAdapter, + session_id: str, + payload: dict[str, Any], + outputs: tuple[str, ...], + pinned: Mapping[str, Any], + ) -> PredictionResult: + response = self.transport.session_predict(session_id, payload) + return adapter.normalize(response, outputs, {**pinned, **payload}) + + def delete_session(self, session_id: str) -> None: + self.transport.delete_session(session_id) + + def close(self) -> None: + self.transport.close() + + +@contextlib.contextmanager +def _explained( + adapter: ModelAdapter, payload: dict[str, Any] +) -> Iterator[None]: + r"""Let the adapter add what a NIM's refusal left out.""" + try: + yield + except StructuredDataError as error: + explained = adapter.explain_error(error, payload) + if explained is error: + # An adapter that had nothing to add returns the same object, + # and `raise error from error` makes it its own cause, which + # is a cycle for anything that walks the chain. + raise + raise explained from error 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..3b4f07cc0 --- /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.backend import InProcessBackend + +__all__ = ['InProcessBackend'] 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..5cd41551f --- /dev/null +++ b/packages/structured-data-models-client/src/sdm_client/in_process/answers.py @@ -0,0 +1,122 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. +# All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +r"""What a model returned, as one answer per scored row.""" + +from __future__ import annotations + +import math +from collections.abc import Sequence +from typing import Any + +import numpy as np +from sdm_api.generated.tfm_api import PredictionItem + +from sdm_client.errors import StructuredDataError + +#: 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 classified( + output: Any, + encoded: Sequence[Any], + labels: Sequence[Any], + ids: Sequence[str | None], + fields: Sequence[str], +) -> list[PredictionItem]: + 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 StructuredDataError( + f'the model answered for classes {sorted(named)}, not the ' + f'{len(encoded)} the context holds', + code='INTERNAL_ERROR', + ) from error + probabilities = _numbers(output)[:, order] + return [ + PredictionItem( + row_index=index, + id=ids[index], + prediction=labels[int(np.argmax(row))], + probabilities=( + { + str(label): float(row[position]) + for position, label in enumerate(labels) + } + if 'probabilities' in fields + else None + ), + ) + for index, row in enumerate(probabilities) + ] + + +def regressed( + output: Any, + ids: Sequence[str | None], + fields: Sequence[str], + statistic: str, + levels: Sequence[float], +) -> list[PredictionItem]: + 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. + """ + quantiles = _numbers(output) + if quantiles.shape[1] != _QUANTILES: + raise StructuredDataError( + f'the model answered with {quantiles.shape[1]} quantiles, not ' + f'{_QUANTILES}', + code='INTERNAL_ERROR', + ) + points = ( + quantiles.mean(axis=1) if statistic == 'mean' else quantiles[:, _MEDIAN] + ) + return [ + PredictionItem( + row_index=index, + id=ids[index], + prediction=float(points[index]), + quantiles=( + {format(level, '.15g'): _at(row, level) for level in levels} + if 'quantiles' in fields + else None + ), + ) + for index, row in enumerate(quantiles) + ] + + +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 StructuredDataError( + 'the model answered with a malformed output', code='INTERNAL_ERROR' + ) + return array diff --git a/packages/structured-data-models-client/src/sdm_client/in_process/backend.py b/packages/structured-data-models-client/src/sdm_client/in_process/backend.py new file mode 100644 index 000000000..c7a6d59ae --- /dev/null +++ b/packages/structured-data-models-client/src/sdm_client/in_process/backend.py @@ -0,0 +1,488 @@ +# 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 prepares a request here exactly as it does for a NIM, and this +backend runs the model on it directly, with no HTTP in between. The context is +fitted the way the hosted deployments fit it, so a request is answered alike +wherever it runs. A request the model cannot answer as asked is refused with +a :class:`~sdm_client.errors.StructuredDataError` whose code says why, from +the codes a NIM uses, and no HTTP status, since nothing was sent. +""" + +from __future__ import annotations + +import contextlib +import copy +import sys +import threading +import uuid +from collections.abc import Iterator, Mapping, Sequence +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from sdm_client.errors import MissingExtraError, StructuredDataError +from sdm_client.in_process.answers import QUANTILE_LEVELS, classified, regressed +from sdm_client.in_process.columns import Context +from sdm_client.in_process.relational import Relational +from sdm_client.in_process.tabular import Tabular + +if TYPE_CHECKING: # pragma: no cover - imported for typing only. + from sdm_client.base import ModelAdapter + from sdm_client.result import PredictionResult + +#: How the hosted deployments fit a context. +_SEED = 42 +_ESTIMATORS = 1 + +_SERVED = {served.model_id: served for served in (Tabular(), Relational())} + + +@dataclass +class _Session: + served: Tabular | Relational + task: Mapping[str, Any] + schema: Mapping[str, Any] + context: Context + fitted: Any + + +class InProcessBackend: + r"""Runs ``kumo-tabular`` and ``kumo-relational`` with + structured-data-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 backend loads itself. Defaults to + CUDA when available, else the CPU. + + One model runs at a time: a second thread asking for a prediction waits + for the first to finish. Releasing a session, closing the client and + asking whether it is ready do not wait, so a caller can still let go of + what it holds while a prediction is running. + """ + + 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._state = threading.RLock() + self._runs = threading.Lock() + self._closed = False + + @property + def url(self) -> str: + return 'in-process' + + def close(self) -> None: + r"""Release every session and the models this backend loaded.""" + with self._state: + self._sessions.clear() + self._models.clear() + self._closed = True + + def health_ready(self) -> bool: + r"""Whether this backend can answer. A closed one says no, rather + than raising at a caller polling it as a boolean.""" + return not self._closed + + def list_models(self) -> list[str]: + r"""The models this backend answers for.""" + with self._state: + self._require_open() + return [ + model_id + for model_id in _SERVED + if self._loads or model_id in self._models + ] + + def predict( + self, + adapter: ModelAdapter, + payload: dict[str, Any], + outputs: tuple[str, ...], + ) -> PredictionResult: + with self._running(): + served, model = self._serving(payload['model']) + task = payload['task'] + fields = _fields(served, model, task['kind'], payload) + context = served.context(payload) + query = served.query(context, payload, payload['schema']) + answer = _run(_fit(model, context), query) + items = _items( + served, + task, + payload['schema'], + context, + payload, + answer, + fields, + ) + return adapter.answered( + items, + outputs, + payload, + model=served.model_id, + request_id=_request_id(payload), + ) + + def create_session( + self, adapter: ModelAdapter, payload: dict[str, Any] + ) -> str: + r"""Fit the context once, to be scored against until released.""" + with self._running(): + served, model = self._serving(payload['model']) + _fields(served, model, payload['task']['kind'], payload) + context = served.context(payload) + fitted = _fit(model, context) + session_id = uuid.uuid4().hex + with self._state: + self._require_open() + self._sessions[session_id] = _Session( + served=served, + task=copy.deepcopy(payload['task']), + schema=copy.deepcopy(payload['schema']), + context=context.fitted(), + fitted=fitted, + ) + return session_id + + def session_predict( + self, + adapter: ModelAdapter, + session_id: str, + payload: dict[str, Any], + outputs: tuple[str, ...], + pinned: Mapping[str, Any], + ) -> PredictionResult: + with self._running(): + with self._state: + session = self._sessions.get(session_id) + if session is None: + raise StructuredDataError( + f'no session {session_id!r} is open in this process', + code='SESSION_NOT_FOUND', + ) + served, task = session.served, session.task + fields = _fields(served, session.fitted, task['kind'], payload) + served.check_inference( + payload.get('inference') or {}, task, settled=True + ) + query = served.query(session.context, payload, session.schema) + answer = _run(session.fitted, query) + items = _items( + served, + task, + session.schema, + session.context, + payload, + answer, + fields, + ) + return adapter.answered( + items, + outputs, + {**pinned, **payload}, + model=served.model_id, + request_id=_request_id(payload), + ) + + def delete_session(self, session_id: str) -> None: + r"""Release a session. Releasing one that is gone is not an error.""" + with self._state: + self._require_open() + self._sessions.pop(session_id, None) + + def _serving(self, model_id: str) -> tuple[Tabular | Relational, Any]: + r"""What reads requests for ``model_id``, and the model it runs. + + Runs are serialized, so a model is loaded once. The load is not held + under the state lock, because it fetches published weights and + releasing a session should not wait for that, and a client closed + while it ran does not keep what it loaded. + """ + served = _SERVED.get(model_id) + with self._state: + model = self._models.get(model_id) + if model is None and self._loads and served is not None: + model = _loaded(served, self._device) + with self._state: + self._require_open() + self._models[model_id] = model + if model is None or served is None: + raise StructuredDataError( + f'{model_id} is not served by this client, which was opened ' + f'with {self.list_models()} only', + code='INVALID_CONFIGURATION', + ) + 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 _running(self) -> Iterator[None]: + r"""Run one call at a time, reporting a failure nobody foresaw as + ``INTERNAL_ERROR``, with the original as its cause. + + It wraps what a NIM would do on its side: reading the request, + fitting and running the model, and reading what it returned. What the + client makes of that answer is the same code on both backends, so it + is left outside, and a fault there is raised as itself. + + Holds the run lock alone, so releasing a session or closing the + client does not wait for a model to finish. + """ + with self._runs: + with self._state: + self._require_open() + try: + yield + except StructuredDataError: + raise + except Exception as error: + raise StructuredDataError( + f'answering in this process failed: ' + f'{type(error).__name__}: {error}', + code='INTERNAL_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: + 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 + 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'client asks 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 StructuredDataError( + f'{served.model_id} could not be loaded: ' + f'{type(error).__name__}: {error}', + code='MODEL_NOT_READY', + ) from error + + +def _request_id(payload: Mapping[str, Any]) -> str: + r"""The id the client put on this request, which a NIM echoes back.""" + return str((payload.get('metadata') or {}).get('request_id') or '') + + +def _check_task(served: Tabular | Relational, model: Any, kind: str) -> None: + r"""Refuse a task this model does not answer, or was loaded without. + + A model can be loaded for classification or regression alone, which a + NIM's never is, so that is asked of the model rather than of its kind. + """ + if kind not in served.outputs: + raise StructuredDataError( + f'{served.model_id} does not answer {kind!r}; it answers ' + f'{sorted(served.outputs)}', + code='UNSUPPORTED_TASK', + ) + needed = 'regression' if kind == 'regression' else 'classification' + loaded = sorted(str(task) for task in model.tasks) + if needed not in loaded: + raise StructuredDataError( + 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', + code='UNSUPPORTED_TASK', + ) + + +def _fields( + served: Tabular | Relational, + model: Any, + kind: str, + payload: Mapping[str, Any], +) -> list[str]: + r"""The fields asked for, refused where this model does not produce + them for this task.""" + _check_task(served, model, kind) + output = payload.get('output') or {} + fields = list(output.get('fields') or ['prediction']) + unsupported = [name for name in fields if name not in served.outputs[kind]] + if unsupported: + raise StructuredDataError( + f'{served.model_id} does not produce {unsupported} for {kind!r}; ' + f'it produces {list(served.outputs[kind])}', + code='UNSUPPORTED_OUTPUT', + ) + if kind == 'regression': + _check_regression_output(payload, fields) + return fields + + +def _statistic(payload: Mapping[str, Any]) -> str: + return (payload.get('output') or {}).get('prediction_statistic') or 'mean' + + +def _levels(payload: Mapping[str, Any]) -> Sequence[float]: + levels = (payload.get('output') or {}).get('quantile_levels') + return levels or QUANTILE_LEVELS + + +def _check_regression_output( + payload: Mapping[str, Any], fields: Sequence[str] +) -> None: + r"""Refuse an output option the answer cannot be built from. + + Checked here rather than where the answer is built, because a fit and a + run stand between the two and can take minutes on a GPU. + """ + statistic = _statistic(payload) + if statistic not in ('mean', 'median'): + raise StructuredDataError( + f'prediction_statistic is {statistic!r}; it is mean or median', + code='UNSUPPORTED_OUTPUT', + ) + if 'quantiles' in fields and not all( + isinstance(level, (int, float)) and 0.001 <= level <= 0.999 + for level in _levels(payload) + ): + raise StructuredDataError( + 'quantile levels must be between 0.001 and 0.999', + code='UNSUPPORTED_OUTPUT', + ) + + +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 _items( + served: Tabular | Relational, + task: Mapping[str, Any], + schema: Mapping[str, Any], + context: Context, + payload: Mapping[str, Any], + answer: Any, + fields: list[str], +) -> list[Any]: + r"""One answer per scored row, under the id the request gave that row.""" + ids = served.row_ids( + payload['predict']['instance_table'], schema['instance_table'] + ) + if task['kind'] != 'regression': + return classified(answer, context.encoded, context.labels, ids, fields) + return regressed(answer, ids, fields, _statistic(payload), _levels(payload)) 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..8e6a46001 --- /dev/null +++ b/packages/structured-data-models-client/src/sdm_client/in_process/columns.py @@ -0,0 +1,298 @@ +# 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 Callable, Mapping, Sequence +from dataclasses import dataclass, field, replace +from typing import Any + +from sdm_client.errors import StructuredDataError +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 StructuredDataError( + f'column {name!r} declares semantic type {declared!r}, which ' + f'SDM does not read', + code='UNSUPPORTED_DTYPE', + ) + return stype + dtype = column.get('dtype') + stype = _FROM_DTYPE.get(str(dtype)) + if stype is None: + raise StructuredDataError( + f'column {name!r} has no unambiguous SDM semantic type for dtype ' + f'{dtype!r}', + code='UNSUPPORTED_DTYPE', + ) + 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 check_columns( + table: Mapping[str, Any], + schema: Mapping[str, Any], + where: str, + unasked: frozenset[str] = frozenset(), +) -> None: + r"""Refuse a table that does not carry exactly the columns its schema + declares, each once. + + The model reads a table by its schema, so a column left out would be + answered without and one not declared would be read as nothing. The rows + to score may leave out ``unasked``, the target they ask about. + """ + named = list(table['columns']) + repeated = sorted({name for name in named if named.count(name) > 1}) + if repeated: + raise StructuredDataError( + f'{where} names column(s) {repeated} more than once', + code='VALIDATION_FAILED', + ) + rows = table.get('rows') + if not isinstance(rows, Sequence) or isinstance(rows, (str, bytes)): + raise StructuredDataError( + f'{where} does not carry its rows as a list of rows', + code='VALIDATION_FAILED', + ) + odd = next( + ( + index + for index, row in enumerate(rows) + if not isinstance(row, Sequence) or isinstance(row, (str, bytes)) + ), + None, + ) + if odd is not None: + raise StructuredDataError( + f'{where} does not carry row {odd} as a list of cells', + code='VALIDATION_FAILED', + ) + odd = next( + (index for index, row in enumerate(rows) if len(row) != len(named)), + None, + ) + if odd is not None: + raise StructuredDataError( + f'{where} row {odd} does not carry one cell for each of its ' + f'{len(named)} column(s)', + code='INVALID_SCHEMA', + ) + declared = set(schema.get('columns') or {}) + missing = sorted(declared - set(named) - unasked) + undeclared = sorted(set(named) - declared) + if missing or undeclared: + raise StructuredDataError( + f'{where} does not carry the columns its schema declares: it ' + f'leaves out {missing} and carries {undeclared} undeclared', + code='INVALID_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 StructuredDataError( + f'{where} leaves out column(s) {absent}, which the context ' + f'carries', + code='INVALID_SCHEMA', + ) + arrow = to_arrow(table, self.columns, self.dtypes, self.keys) + try: + return TableTensor.from_arrow( + arrow, stypes=self.stypes, device='cpu' + ) + except (TypeError, ValueError) as error: + raise StructuredDataError( + 'table cannot be represented by SDM', code='VALIDATION_FAILED' + ) from error + + +@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 + target: str = '' + 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 not table['rows']: + raise StructuredDataError( + 'the context carries no rows, so there is nothing to learn from', + code='VALIDATION_FAILED', + ) + if any(label is None for label in values(table, column)): + raise StructuredDataError( + f'context target {column!r} contains null labels', + code='TASK_SCHEMA_MISMATCH', + ) + stype = 'categorical' if classifies else 'numerical' + 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 StructuredDataError( + 'context labels cannot be represented by SDM', + code='TASK_SCHEMA_MISMATCH', + ) from error + if not classifies: + return y, (), () + encoded = tuple(y.categorical.categories[0].tolist()) + 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 _check_classes( + kind: str, declared: Sequence[Any] | None, encoded: tuple[Any, ...] +) -> None: + r"""Refuse classes the task contradicts. + + 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 StructuredDataError( + f'binary_classification needs exactly two classes, the context ' + f'carries {count}', + code='TASK_SCHEMA_MISMATCH', + ) + if kind == 'multiclass_classification' and count < 2: + raise StructuredDataError( + f'multiclass_classification needs at least two classes, the ' + f'context carries {count}', + code='TASK_SCHEMA_MISMATCH', + ) + if declared is None: + return + text = _text_of(encoded) + named = [text(label) for label in declared] + if not named: + raise StructuredDataError( + 'the task declares an empty class list, which no label can satisfy', + code='TASK_SCHEMA_MISMATCH', + ) + if len(set(named)) != len(named): + raise StructuredDataError( + f'declared classes repeat a label: {sorted(named)}', + code='TASK_SCHEMA_MISMATCH', + ) + present = {text(label) for label in encoded} + if set(named) != present: + raise StructuredDataError( + f'declared classes {sorted(set(named))} do not match the labels ' + f'present in the context {sorted(present)}', + code='TASK_SCHEMA_MISMATCH', + ) + + +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/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..866aceb28 --- /dev/null +++ b/packages/structured-data-models-client/src/sdm_client/in_process/relational.py @@ -0,0 +1,486 @@ +# 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. + +A request the client gathered is always one the model can read. One built by +hand and passed to ``Request.materialized`` may not be, and is refused where +the model would otherwise answer a question other than the one it states. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any + +from sdm_client.errors import StructuredDataError +from sdm_client.in_process.columns import ( + Context, + Shape, + check_columns, + key_columns, + labelled, + stype_of, +) +from sdm_client.in_process.tables import values + +INSTANCE_TABLE = 'instance_table' +INSTANCE_ID = 'instance_id' + +_CLASSIFIED = ('prediction', 'probabilities') +_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: + r"""Reads a ``kumo-relational`` request into the model.""" + + 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'] + if not schema.get('related_tables'): + raise StructuredDataError( + f'{self.model_id} scores an entity from its related tables, ' + f'and this request declares none', + code='INVALID_SCHEMA', + ) + if (task['target'].get('table_name') or INSTANCE_TABLE) != ( + INSTANCE_TABLE + ): + raise StructuredDataError( + f'{self.model_id} predicts a column of the instance table, ' + f'and this target is on {task["target"]["table_name"]!r}', + code='UNSUPPORTED_FEATURE', + ) + self.check_inference(payload.get('inference') or {}, task) + _check_declared(schema) + edges, links = _graph(schema.get('relationships') or ()) + if len(links) != 1: + raise StructuredDataError( + f'the instance table must link to exactly one related table, ' + f'the entity scored, and it links to {len(links)}', + code='INVALID_SCHEMA', + ) + spec = schema['instance_table'] + _check_task(task, spec, links[0].table) + _check_target(task) + split = payload['context'] + table = split['instance_table'] + check_columns(table, spec, 'the context') + joins = _joins(edges, links) + shapes = _shapes(split, schema, joins, edges, links) + 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') + 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, + instance=instance, + target=task['target']['column_name'], + encoded=encoded, + labels=labels, + related=RelatedTables( + tables=tensors, relationships=edges, task_links=links + ), + tables=shapes, + relationships=tuple(edges), + 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 does not act on as asked. + + A session's context is fitted without prediction time, since pinning + one carries no inference options, so a prediction cannot ask for it + after. + """ + unknown = sorted(set(options) - _INFERENCE_OPTIONS) + if unknown: + raise StructuredDataError( + f'unsupported inference options: {unknown}', + code='INVALID_REQUEST', + ) + if options.get('inference_config'): + raise StructuredDataError( + 'inference_config is reserved for API compatibility and must ' + 'be omitted or empty', + code='INVALID_REQUEST', + ) + if not options.get('use_prediction_time'): + return + if not task.get('anchor_time_column'): + raise StructuredDataError( + 'use_prediction_time needs task.anchor_time_column, which ' + 'this request does not name', + code='TASK_SCHEMA_MISMATCH', + ) + if settled: + raise StructuredDataError( + 'use_prediction_time is settled when a session is pinned and ' + 'cannot be changed by a prediction', + code='INVALID_REQUEST', + ) + + @staticmethod + 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'] + check_columns( + split['instance_table'], + schema['instance_table'], + 'the rows to score', + frozenset({context.target}), + ) + 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 = { + name: shape.tensor(related[name], f'related table {name!r}') + for name, shape in context.tables.items() + } + return ( + context.instance.tensor( + split['instance_table'], 'predict instance table' + ), + RelatedTables( + tables=tensors, + relationships=context.relationships, + task_links=context.task_links, + ), + ) + + @staticmethod + def row_ids( + table: Mapping[str, Any], spec: Mapping[str, Any] + ) -> list[str | None]: + r"""Each row's ``instance_id``, which the client matches its answer + to before putting the entity's own key on it.""" + return [str(value) for value in values(table, INSTANCE_ID)] + + +def _check_task( + task: Mapping[str, Any], spec: Mapping[str, Any], linked: str +) -> None: + r"""Refuse a task the model would answer as a different question. + + The model scores the table the instance table links to, from the anchor + the instance table declares, and acts on no other task option. + """ + 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 StructuredDataError( + f'the model does not act on the task options {unsupported}', + code='UNSUPPORTED_FEATURE', + ) + requested = list(options.get('entity_table_names') or ()) + if requested != [linked]: + raise StructuredDataError( + f'the task names entity tables {requested}, and the model scores ' + f'{linked!r}, the one table the instance table links to', + code='TASK_SCHEMA_MISMATCH', + ) + anchor = options.get('anchor_time_column') + if anchor is None: + return + column = spec['columns'].get(anchor) + if ( + column is None + or anchor in key_columns(spec) + or stype_of(anchor, column) != 'datetime' + ): + raise StructuredDataError( + f'the task names {anchor!r} as its anchor time, which is not a ' + f'timestamp the instance table declares', + code='TASK_SCHEMA_MISMATCH', + ) + + +def _check_target(task: Mapping[str, Any]) -> None: + r"""Refuse a target the model cannot read: a number, or for a + classification a string too.""" + dtype = task['target'].get('dtype') + readable = _NUMERIC_TARGETS + if task['kind'] in _CLASSIFICATIONS: + readable = readable | {'string'} + if dtype not in readable: + raise StructuredDataError( + f'the model cannot read a {task["kind"]!r} target declared as ' + f'{dtype!r}', + code='TASK_SCHEMA_MISMATCH', + ) + + +def _check_declared(schema: Mapping[str, Any]) -> None: + r"""Refuse a join on a column its table does not declare. + + A join on an undeclared column reads whatever the table holds under that + name, if anything, so the neighbourhood it builds is not the one stated. + Every join also matches on ``instance_id``, so every table declares it. + """ + tables = { + INSTANCE_TABLE: schema['instance_table'], + **(schema.get('related_tables') or {}), + } + for name, table in tables.items(): + if INSTANCE_ID not in (table.get('columns') or {}): + raise StructuredDataError( + f'{name!r} does not declare {INSTANCE_ID!r}, which every ' + f'join matches on', + code='INVALID_SCHEMA', + ) + for relationship in schema.get('relationships') or (): + for side in ('source', 'target'): + name = relationship.get(f'{side}_table', INSTANCE_TABLE) + declared = (tables.get(name) or {}).get('columns') or {} + for column in relationship[f'{side}_columns']: + if column not in declared: + raise StructuredDataError( + f'a relationship joins on {column!r}, which ' + f'{name!r} does not declare', + code='INVALID_SCHEMA', + ) + + +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 _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, and what the model reads it as. + + 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 StructuredDataError( + f'the rows to score carry related table {name!r}, which ' + f'the context does not', + code='INVALID_SCHEMA', + ) + check_columns( + table, + schema['related_tables'][name], + f'related table {name!r} of the rows to score', + ) + shapes[name] = compiled[name] + continue + table_schema = schema['related_tables'].get(name) + if table_schema is None: + raise StructuredDataError( + f'related table {name!r} carries rows and the schema does ' + f'not declare it', + code='INVALID_SCHEMA', + ) + check_columns(table, table_schema, f'context table {name!r}') + 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 StructuredDataError( + f'relationships reference table(s) {missing} that carry no rows', + code='INVALID_SCHEMA', + ) + if compiled is not None: + omitted = sorted(set(compiled) - set(tables)) + if omitted: + raise StructuredDataError( + f'the rows to score leave out related table(s) {omitted}, ' + f'which the context carries', + code='INVALID_SCHEMA', + ) + 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()} + 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 StructuredDataError( + 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', + code='UNSUPPORTED_FEATURE', + ) + 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..a1d43476a --- /dev/null +++ b/packages/structured-data-models-client/src/sdm_client/in_process/tables.py @@ -0,0 +1,106 @@ +# 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 TYPE_CHECKING, Any + +from sdm_client.errors import StructuredDataError + +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': 'bool_', + 'int32': 'int32', + 'int64': 'int64', + 'float32': 'float32', + 'float64': 'float64', + 'string': '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. A cell that cannot be read as its column's type is + refused, key or not. + """ + import pyarrow as pa + + arrays = [ + _array(values(table, column), dtypes[column], column, column in keys) + for column in columns + ] + return pa.Table.from_arrays(arrays, names=list(columns)) + + +def _array(cells: list[Any], dtype: str, column: str, key: bool) -> pa.Array: + import pyarrow as pa + + try: + if key and dtype in _NUMBERS: + return pa.array(_whole(cells, dtype, column), pa.int64()) + if dtype == 'timestamp[us]': + zoned = pa.array(cells, pa.string()).cast( + pa.timestamp('us', tz='UTC') + ) + return zoned.cast(pa.timestamp('us')) + arrow_type = getattr(pa, _ARROW_TYPES[dtype])() + return pa.array([read_cell(cell, dtype) for cell in cells], arrow_type) + except ( + KeyError, + OverflowError, + pa.ArrowException, + TypeError, + ValueError, + ) as error: + raise StructuredDataError( + f'column {column!r} cannot be read as {dtype}', + code='VALIDATION_FAILED', + ) 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 StructuredDataError( + f'identifier column {column!r} holds a fractional value', + code='VALIDATION_FAILED', + ) + 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..9d0abce5d --- /dev/null +++ b/packages/structured-data-models-client/src/sdm_client/in_process/tabular.py @@ -0,0 +1,141 @@ +# 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 + +import json +from collections.abc import Mapping +from typing import Any + +from sdm_client.errors import StructuredDataError +from sdm_client.in_process.columns import ( + Context, + Shape, + check_columns, + key_columns, + labelled, + stype_of, +) +from sdm_client.in_process.tables import values + +_READS = frozenset({'numerical', 'categorical'}) + + +class Tabular: + r"""Reads a ``kumo-tabular`` request into the model.""" + + 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: + task = payload['task'] + self.check_inference(payload.get('inference') or {}, task) + spec = payload['schema']['instance_table'] + table = payload['context']['instance_table'] + check_columns(table, spec, 'the context') + 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 StructuredDataError( + f'{self.model_id} reads numerical and categorical columns, ' + f'not {unread}', + code='UNSUPPORTED_DTYPE', + ) + 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, + target=target, + encoded=encoded, + 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 does not act on.""" + remaining = dict(options) + if remaining.pop('inference_config', {}): + raise StructuredDataError( + 'inference_config is reserved for API compatibility and must ' + 'be omitted or empty', + code='INVALID_REQUEST', + ) + if remaining.pop('use_prediction_time', False): + raise StructuredDataError( + f"inference option 'use_prediction_time' is not supported by " + f'{self.model_id}; only the neutral value False is accepted', + code='INVALID_REQUEST', + ) + unknown = sorted(set(remaining) - {'run_mode'}) + if unknown: + raise StructuredDataError( + f'unsupported inference options: {unknown}', + code='INVALID_REQUEST', + ) + + @staticmethod + 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'] + check_columns( + table, + schema['instance_table'], + 'the rows to score', + frozenset({context.target}), + ) + return context.instance.tensor(table, 'predict instance table'), None + + @staticmethod + def row_ids( + table: Mapping[str, Any], spec: Mapping[str, Any] + ) -> list[str | None]: + r"""A key of several columns answers under the JSON of its values as + they were sent, which the client hands back as the row's id. + + Otherwise there is no id: each answer is matched to its row by + position, and the client puts the row's own key on it. + """ + key = spec.get('primary_key') + if not isinstance(key, list) or len(key) < 2: + return [None] * len(table['rows']) + cells = [values(table, name) for name in key] + return [ + json.dumps(list(values_of), separators=(',', ':'), default=str) + for values_of in zip(*cells, strict=True) + ] diff --git a/packages/structured-data-models-client/src/sdm_client/wire/inference.py b/packages/structured-data-models-client/src/sdm_client/wire/inference.py new file mode 100644 index 000000000..2e1420849 --- /dev/null +++ b/packages/structured-data-models-client/src/sdm_client/wire/inference.py @@ -0,0 +1,81 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. +# All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +r"""A request's inference options, written in the types the contract gives +them. + +A NIM reads these leniently, so a boolean spelled ``'no'`` asks for nothing, +and refuses a value it cannot read as its type. Writing them here, before +any backend is asked, sends a NIM only what its contract allows, and hands a +model run in this process the same values a NIM would have read. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +import numpy as np + +from sdm_client.errors import StructuredDataError + +#: The longest ``run_mode`` the contract allows. +_MAX_TEXT = 1024 +_TRUE = frozenset({'1', 'on', 't', 'true', 'y', 'yes'}) +_FALSE = frozenset({'0', 'off', 'f', 'false', 'n', 'no'}) + + +def typed_inference(inference: Mapping[str, Any]) -> dict[str, Any]: + r"""``inference`` with each option the contract types in its type. + + Raises: + StructuredDataError: With code ``INVALID_REQUEST``, for a value that + cannot be read as its type. + """ + typed = dict(inference) + run_mode = typed.get('run_mode') + if run_mode is not None and ( + not isinstance(run_mode, str) or len(run_mode) > _MAX_TEXT + ): + raise StructuredDataError( + f'inference run_mode is {run_mode!r}; it is text of at most ' + f'{_MAX_TEXT} characters', + code='INVALID_REQUEST', + ) + if 'use_prediction_time' in typed: + typed['use_prediction_time'] = _boolean(typed['use_prediction_time']) + if 'inference_config' in typed and not isinstance( + typed['inference_config'], Mapping + ): + raise StructuredDataError( + f'inference_config is {typed["inference_config"]!r}; it is a ' + f'mapping of option names to values', + code='INVALID_REQUEST', + ) + return typed + + +def _boolean(value: Any) -> bool: + r"""``value`` as the boolean a NIM reads it as. + + A NumPy scalar is read as the Python value it holds: a frame hands those + back, and a payload built by hand keeps them, but ``np.False_`` is not a + ``bool`` and ``np.int64(0)`` is not an ``int``. One that holds no Python + value, such as ``np.longdouble``, whose ``item()`` is itself, is refused + like any other value that is not a boolean. + """ + if isinstance(value, np.generic): + plain = value.item() + if not isinstance(plain, np.generic): + return _boolean(plain) + 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) + raise StructuredDataError( + f'inference use_prediction_time is {value!r}, which is not a boolean', + code='INVALID_REQUEST', + ) diff --git a/packages/structured-data-models-client/tests/conftest.py b/packages/structured-data-models-client/tests/conftest.py new file mode 100644 index 000000000..522a18b9e --- /dev/null +++ b/packages/structured-data-models-client/tests/conftest.py @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. +# All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Small random-weight models, standing in for the published ones so that +nothing is downloaded and no GPU is needed.""" + +from __future__ import annotations + +import pytest + + +@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') diff --git a/packages/structured-data-models-client/tests/sample_tables.py b/packages/structured-data-models-client/tests/sample_tables.py new file mode 100644 index 000000000..07a81b3a0 --- /dev/null +++ b/packages/structured-data-models-client/tests/sample_tables.py @@ -0,0 +1,54 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. +# All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""The tables the in-process and backend-parity suites ask about.""" + +from __future__ import annotations + +import pandas as pd + + +def people(start: int = 0) -> pd.DataFrame: + frame = pd.DataFrame( + { + 'person_id': range(start, start + 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)} 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..9f0e8b299 --- /dev/null +++ b/packages/structured-data-models-client/tests/test_a_model_runs_in_this_process.py @@ -0,0 +1,591 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. +# All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""A model run in this process, as structured-data-models runs it. + +Small random-weight models stand in for the published ones, so nothing is +downloaded and no GPU is needed. What a NIM answers alike is held in +``test_every_backend_answers_alike``; this is what only a model in this +process has: the answer the model itself gives, how a model is loaded and +kept, and a refusal that carries no HTTP status, since nothing was sent. +""" + +from __future__ import annotations + +import copy +import subprocess +import sys +import threading + +import numpy as np +import pandas as pd +import pytest +from sample_tables import graph, people + +from sdm_client import ( + KumoRelational, + KumoTabular, + NimRequestError, + PredictiveQuery, + Request, + SDMClient, + StructuredDataError, +) + +pytestmark = pytest.mark.filterwarnings('ignore') + +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' + + +def refused(call) -> StructuredDataError: + with pytest.raises(StructuredDataError) as caught: + call() + assert not isinstance(caught.value, NimRequestError) + assert not hasattr(caught.value, 'status_code') + 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', '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_a_session_keeps_the_question_it_pinned(relational): + """A request changed after it was pinned leaves the session alone, as a + NIM's is: the model still scores the regression and the tables it pinned, + and each answer is still read under the key's own type.""" + request = PredictiveQuery(COUNT, graph()).materialize(indices=[0, 1]) + with SDMClient.in_process(relational) as client: + with client.fit(request, model=KumoRelational()) as fitted: + pinned = request.payloads[0] + pinned['task']['kind'] = 'classification' + users = pinned['schema']['related_tables']['users']['columns'] + users['user_id']['dtype'] = 'bool' + del users['age'] + answer = fitted.predict(request) + + assert [repr(item.id) for item in answer.items] == ['0', '1'] + + +def test_what_a_holdout_leaves_is_refused_when_it_cannot_be_learned_from( + relational, +): + """A holdout edits the context after the client has checked it, so an + empty context, or a binary one left a single class, is refused here.""" + tables = graph() + with SDMClient.in_process(relational) as client: + for request, holdout, code in ( + ( + PredictiveQuery(COUNT, tables).materialize(indices=[0, 1]), + list(range(40)), + 'VALIDATION_FAILED', + ), + ( + PredictiveQuery(BINARY, tables).materialize(indices=[1, 3]), + list(range(0, 40, 2)), + 'TASK_SCHEMA_MISMATCH', + ), + ): + error = refused( + lambda request=request, holdout=holdout: client.fit( + request, model=KumoRelational(), holdout=holdout + ) + ) + assert error.code == code + + +@pytest.mark.parametrize( + ('frame', 'code'), + [ + (people()[['person_id', 'churn']], 'VALIDATION_FAILED'), + ( + people().assign(seen=pd.Timestamp('2024-01-01'))[ + ['person_id', 'age', 'seen', 'churn'] + ], + 'UNSUPPORTED_DTYPE', + ), + ], +) +def test_a_table_the_model_cannot_read_is_refused(tabular, frame, code): + request = Request( + context=frame.iloc[:40], + predict=frame.iloc[40:].drop(columns=['churn']), + target='churn', + task='classification', + ) + with SDMClient.in_process(tabular) as client: + error = refused(lambda: client.predict(request, model=KumoTabular())) + assert error.code == code + + +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 == 'UNSUPPORTED_TASK' + 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'} + 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 == 'MODEL_NOT_READY' + 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(): + """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" + ' sys.modules[name] = None\n' + 'from sdm_client import SDMClient, StructuredDataError\n' + 'try:\n' + ' SDMClient.in_process()\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], + capture_output=True, + text=True, + check=False, + ) + names_the_python = sys.version_info < (3, 11) + assert run.stdout.split() == [ + 'MISSING_EXTRA', + 'in-process', + str(names_the_python), + ], run.stderr + + +def test_a_closed_client_says_it_is_not_ready_rather_than_raising(tabular): + """A caller polling readiness as a boolean gets one either way.""" + client = SDMClient.in_process(tabular) + assert client.health_ready() is True + client.close() + assert client.health_ready() is False + + +def test_a_model_this_client_was_not_opened_with_is_a_configuration(tabular): + """Nothing is wrong with the request's schema, so the code does not say + there is: the client was opened without that model.""" + gathered = PredictiveQuery(COUNT, graph()).materialize( + indices=[0, 1], model=KumoRelational() + ) + with SDMClient.in_process(tabular) as client: + error = refused( + lambda: client.predict(gathered, model=KumoRelational()) + ) + assert error.code == 'INVALID_CONFIGURATION' + assert 'kumo-tabular' in error.message + + +def test_a_model_this_process_has_no_reader_for_is_a_configuration( + relational, monkeypatch +): + """A model the client can send but this process cannot run is refused + like one it was not opened with, rather than as the model failing.""" + from sdm_client.in_process import backend + + gathered = PredictiveQuery(COUNT, graph()).materialize( + indices=[0, 1], model=KumoRelational() + ) + with SDMClient.in_process(relational) as client: + monkeypatch.delitem(backend._SERVED, 'kumo-relational') + error = refused( + lambda: client.predict(gathered, model=KumoRelational()) + ) + assert error.code == 'INVALID_CONFIGURATION' + + +def tampered(change) -> Request: + """A gathered request with its payload changed, as one passed to + ``Request.materialized`` by hand may be.""" + gathered = PredictiveQuery(COUNT, graph()).materialize( + indices=[0, 1], model=KumoRelational() + ) + payload = copy.deepcopy(gathered.payloads[0]) + change(payload) + return Request.materialized( + payloads=(payload,), + schema=gathered.schema, + query=gathered.query, + indices=gathered.indices, + ) + + +def test_an_output_option_is_refused_before_the_model_runs( + relational, monkeypatch +): + """A fit and a run stand between the request and the answer, and take + minutes on a GPU, so an option the answer cannot be built from is + refused before them.""" + request = tampered( + lambda p: p.setdefault('output', {}).update(prediction_statistic='mode') + ) + ran = [] + monkeypatch.setattr( + type(relational), + 'fit', + lambda self, *a, **k: ran.append(True), + ) + with SDMClient.in_process(relational) as client: + error = refused(lambda: client.predict(request, model=KumoRelational())) + assert error.code == 'UNSUPPORTED_OUTPUT' + assert ran == [] + + +def test_releasing_a_session_does_not_wait_for_a_model_to_finish( + tabular, monkeypatch +): + """``client.py`` offers a client to several threads. One model runs at a + time, but letting go of what the client holds is not a model run, and a + thread doing so waits for the model if they share one lock.""" + frame = people() + request = Request( + context=frame.iloc[:45], + predict=frame.iloc[45:].drop(columns=['churn']), + target='churn', + task='classification', + ) + running, release, released = ( + threading.Event(), + threading.Event(), + threading.Event(), + ) + fitted = type(tabular).fit + + def watched(self, *args, **kwargs): + running.set() + assert release.wait(timeout=60) + return fitted(self, *args, **kwargs) + + monkeypatch.setattr(type(tabular), 'fit', watched) + client = SDMClient.in_process(tabular) + backend = client._backend + try: + call = threading.Thread( + target=lambda: client.predict(request, model=KumoTabular()) + ) + call.start() + assert running.wait(timeout=60) + + def let_go() -> None: + backend.delete_session('none') + released.set() + + threading.Thread(target=let_go, daemon=True).start() + assert released.wait(timeout=10), ( + 'releasing a session waited for the model to finish' + ) + assert client.health_ready() is True + release.set() + call.join(timeout=120) + assert not call.is_alive() + finally: + release.set() + client.close() + + +def test_an_answer_carries_the_id_the_request_was_sent_under( + tabular, monkeypatch +): + """A NIM echoes the request's id back, and a batch lists them, so an + answer from this process carries the id its request went out under + rather than one of its own or none.""" + monkeypatch.setattr( + 'sdm_client.adapters.tabular._request_id', lambda: 'sdm-known' + ) + frame = people() + request = Request( + context=frame.iloc[:45], + predict=frame.iloc[45:].drop(columns=['churn']), + target='churn', + task='classification', + ) + with SDMClient.in_process(tabular) as client: + assert ( + client.predict(request, model=KumoTabular()).request_id + == 'sdm-known' + ) + + +def test_a_session_answers_under_the_id_it_was_scored_with( + relational, monkeypatch +): + """The scoring call has its own id, not the one the context was pinned + with, and the answer carries it.""" + ids = iter(['sdm-pinned', 'sdm-scored']) + monkeypatch.setattr( + 'sdm_client.adapters.relational._request_id', lambda: next(ids) + ) + request = PredictiveQuery(COUNT, graph()).materialize( + indices=[0, 1], model=KumoRelational() + ) + with SDMClient.in_process(relational) as client: + with client.fit(request, model=KumoRelational()) as fitted: + assert fitted.predict(request).request_id == 'sdm-scored' + + +def test_a_numpy_scalar_holding_no_python_value_is_refused_not_recursed_on( + tabular, +): + """``np.longdouble(1).item()`` is an ``np.longdouble``, so reading one + by its Python value has to stop rather than ask itself again.""" + frame = people() + request = Request( + context=frame.iloc[:45], + predict=frame.iloc[45:].drop(columns=['churn']), + target='churn', + task='classification', + inference={'use_prediction_time': np.longdouble(1)}, + ) + with SDMClient.in_process(tabular) as client: + error = refused(lambda: client.predict(request, model=KumoTabular())) + assert error.code == 'INVALID_REQUEST' + + +def test_closing_beats_a_call_that_was_already_running(relational, monkeypatch): + """``close()`` no longer waits for a model, so a call that was running + when it landed must not put back what closing just released.""" + running, release = threading.Event(), threading.Event() + fitted = type(relational).fit + + def watched(self, *args, **kwargs): + running.set() + assert release.wait(timeout=60) + return fitted(self, *args, **kwargs) + + monkeypatch.setattr(type(relational), 'fit', watched) + request = PredictiveQuery(COUNT, graph()).materialize( + indices=[0, 1], model=KumoRelational() + ) + client = SDMClient.in_process(relational) + backend = client._backend + failed: list[BaseException] = [] + + def pin() -> None: + try: + client.fit(request, model=KumoRelational()) + except BaseException as error: + failed.append(error) + + call = threading.Thread(target=pin) + call.start() + try: + assert running.wait(timeout=60) + client.close() + release.set() + call.join(timeout=120) + assert not call.is_alive() + finally: + release.set() + assert failed, 'the call carried on against a closed client' + assert backend._sessions == {} + + +def test_a_table_carrying_no_rows_is_refused(relational): + """A hand-built payload need not carry rows at all, and a table without + them was read as empty until something downstream fell over.""" + request = tampered( + lambda p: p['context']['related_tables']['users'].pop('rows') + ) + with SDMClient.in_process(relational) as client: + error = refused(lambda: client.predict(request, model=KumoRelational())) + assert error.code == 'VALIDATION_FAILED' diff --git a/packages/structured-data-models-client/tests/test_a_session_can_hold_back_what_it_will_score.py b/packages/structured-data-models-client/tests/test_a_session_can_hold_back_what_it_will_score.py index 62a757b15..1e78ee5f2 100644 --- a/packages/structured-data-models-client/tests/test_a_session_can_hold_back_what_it_will_score.py +++ b/packages/structured-data-models-client/tests/test_a_session_can_hold_back_what_it_will_score.py @@ -31,6 +31,7 @@ from sdm_client.adapters.relational import RelationalAdapter from sdm_client.adapters.tabular import TabularAdapter from sdm_client.client import _held_back +from sdm_client.core.backend import NimBackend from sdm_client.wire.keys import _named_by, keys_in RNG = np.random.default_rng(9) @@ -188,7 +189,7 @@ def delete_session(self, session_id): ).materialize(indices=[1, 2], num_context_samples=600) client = SDMClient('http://127.0.0.1:9', 'none') - monkeypatch.setattr(client, '_transport', Transport()) + monkeypatch.setattr(client, '_backend', NimBackend(Transport())) population = list(range(200, 260)) with warnings.catch_warnings(): @@ -230,7 +231,7 @@ def delete_session(self, session_id): ).materialize(indices=[1, 2], num_context_samples=600) client = SDMClient('http://127.0.0.1:9', 'none') - monkeypatch.setattr(client, '_transport', Transport()) + monkeypatch.setattr(client, '_backend', NimBackend(Transport())) with warnings.catch_warnings(): warnings.simplefilter('ignore') diff --git a/packages/structured-data-models-client/tests/test_an_endpoint_without_sessions_says_so.py b/packages/structured-data-models-client/tests/test_an_endpoint_without_sessions_says_so.py index da10ab999..8b09da78c 100644 --- a/packages/structured-data-models-client/tests/test_an_endpoint_without_sessions_says_so.py +++ b/packages/structured-data-models-client/tests/test_an_endpoint_without_sessions_says_so.py @@ -21,6 +21,7 @@ import pytest from sdm_client import KumoTabular, Request, SDMClient +from sdm_client.core.backend import NimBackend from sdm_client.errors import StructuredDataError, UnsupportedRequestError @@ -64,7 +65,7 @@ def test_fit_raises_the_error_its_docstring_names( request_: Request, status: int ) -> None: client = SDMClient('http://127.0.0.1:1', 'none', timeout=1.0, max_retries=0) - client._transport = _NoSessions(status) + client._backend = NimBackend(_NoSessions(status)) with pytest.raises(StructuredDataError) as caught: client.fit(request_, model=KumoTabular()) diff --git a/packages/structured-data-models-client/tests/test_asked_in_the_questions_it_serves.py b/packages/structured-data-models-client/tests/test_asked_in_the_questions_it_serves.py index 5bddf3154..f74ee6ace 100644 --- a/packages/structured-data-models-client/tests/test_asked_in_the_questions_it_serves.py +++ b/packages/structured-data-models-client/tests/test_asked_in_the_questions_it_serves.py @@ -27,6 +27,7 @@ import pytest from sdm_client import KumoRelational, PredictiveQuery, Relationship, Schema +from sdm_client.core.backend import NimBackend from sdm_client.errors import StructuredDataError RNG = np.random.default_rng(7) @@ -174,7 +175,7 @@ def predict(self, payload: dict) -> dict: request = built(FORECAST, indices=[1, 2]) client = SDMClient(url='http://localhost:8000') - client._transport = FailsOnTheThirdCall() + client._backend = NimBackend(FailsOnTheThirdCall()) with warnings.catch_warnings(): warnings.simplefilter('ignore') with pytest.raises(RuntimeError) as failed: diff --git a/packages/structured-data-models-client/tests/test_closing_releases_what_was_pinned.py b/packages/structured-data-models-client/tests/test_closing_releases_what_was_pinned.py index 83bbc4ff0..317be625f 100644 --- a/packages/structured-data-models-client/tests/test_closing_releases_what_was_pinned.py +++ b/packages/structured-data-models-client/tests/test_closing_releases_what_was_pinned.py @@ -22,6 +22,7 @@ import pytest from sdm_client import KumoTabular, Request, SDMClient +from sdm_client.core.backend import NimBackend from sdm_client.result import PredictionResult @@ -54,7 +55,7 @@ def close(self) -> None: def _client_with(endpoint: _Endpoint) -> SDMClient: client = SDMClient('http://127.0.0.1:1', 'none', timeout=1.0, max_retries=0) - client._transport = endpoint + client._backend = NimBackend(endpoint) return client diff --git a/packages/structured-data-models-client/tests/test_entity_id_parity.py b/packages/structured-data-models-client/tests/test_entity_id_parity.py index ca7b4ea8a..770df2535 100644 --- a/packages/structured-data-models-client/tests/test_entity_id_parity.py +++ b/packages/structured-data-models-client/tests/test_entity_id_parity.py @@ -20,6 +20,7 @@ from sdm_client import PredictiveQuery, Schema from sdm_client.adapters.tabular import TabularAdapter +from sdm_client.core.backend import NimBackend from sdm_client.errors import StructuredDataError QUERY = 'PREDICT users.churn FOR EACH users.user_id' @@ -218,7 +219,7 @@ def fitted_on(schema: Schema, response: dict): client = SDMClient(url='http://localhost:8000') transport = StubTransport(response) - client._transport = transport + client._backend = NimBackend(transport) request = PredictiveQuery(QUERY, schema).materialize(indices=[3, 7]) return client.fit(request, model='kumo-tabular'), transport, request @@ -256,7 +257,7 @@ def normalize(self, response, outputs, payload=None): from sdm_client import SDMClient client = SDMClient(url='http://localhost:8000') - client._transport = StubTransport(answer(['[3]', '[7]'])) + client._backend = NimBackend(StubTransport(answer(['[3]', '[7]']))) client._adapters['kumo-tabular'] = Recording() request = PredictiveQuery(QUERY, schema).materialize(indices=[3, 7]) fitted = client.fit(request, model='kumo-tabular') diff --git a/packages/structured-data-models-client/tests/test_error_surface.py b/packages/structured-data-models-client/tests/test_error_surface.py index 776525ad9..4426be04a 100644 --- a/packages/structured-data-models-client/tests/test_error_surface.py +++ b/packages/structured-data-models-client/tests/test_error_surface.py @@ -34,6 +34,7 @@ Schema, ) from sdm_client.client import FittedModel, _empty_answer +from sdm_client.core.backend import NimBackend from sdm_client.core.transport import _build_retry from sdm_client.errors import ( NimRequestError, @@ -166,7 +167,7 @@ def delete_session(self, session_id: str) -> None: class Client: def __init__(self, transport: Transport) -> None: - self._transport = transport + self._backend = NimBackend(transport) class Adapter: diff --git a/packages/structured-data-models-client/tests/test_every_backend_answers_alike.py b/packages/structured-data-models-client/tests/test_every_backend_answers_alike.py new file mode 100644 index 000000000..5aa0212de --- /dev/null +++ b/packages/structured-data-models-client/tests/test_every_backend_answers_alike.py @@ -0,0 +1,553 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. +# All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""A NIM and a model in this process answer a request alike. + +The same calls run against each backend. The model in this process always +runs, with small random-weight models standing in for the published ones, so +nothing is downloaded. A NIM runs too under ``-m live_nim``, when +``SDM_TABULAR_URL`` and ``SDM_RELATIONAL_URL`` point at one of each: + + SDM_TABULAR_URL=http://localhost:8000 \\ + SDM_RELATIONAL_URL=http://localhost:8001 pytest -m live_nim + +The values differ, since the weights do. What both are held to is everything +else a caller reads: which rows come back under which keys, the columns, what +a session answers, and the code a refusal carries. +""" + +from __future__ import annotations + +import copy +import os + +import numpy as np +import pandas as pd +import pytest +from sample_tables import graph, people + +from sdm_client import ( + KumoRelational, + KumoTabular, + PredictiveQuery, + Request, + Schema, + SDMClient, + StructuredDataError, +) + +pytestmark = pytest.mark.filterwarnings('ignore') + +URLS = { + 'kumo-tabular': os.environ.get('SDM_TABULAR_URL'), + 'kumo-relational': os.environ.get('SDM_RELATIONAL_URL'), +} + +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( + params=['in-process', pytest.param('nim', marks=pytest.mark.live_nim)] +) +def client_for(request): + r"""Opens a client for one model, on this backend.""" + if request.param == 'in-process': + request.getfixturevalue('sdm') + return lambda model: SDMClient.in_process( + request.getfixturevalue(model.removeprefix('kumo-')) + ) + + def opened(model: str) -> SDMClient: + if not URLS[model]: + pytest.skip(f'point SDM_{model[5:].upper()}_URL at a {model} NIM') + return SDMClient(url=URLS[model], timeout=300.0) + + return opened + + +def asked(target: str, task: str, start: int = 0, **options) -> Request: + frame = people(start)[['person_id', 'age', 'segment', 'visits', target]] + return Request( + context=frame.iloc[:45], + predict=frame.iloc[45:].drop(columns=[target]), + target=target, + task=task, + **options, + ) + + +@pytest.mark.parametrize( + ('target', 'task', 'outputs', 'columns'), + [ + ( + 'churn', + 'classification', + None, + ['prediction', 'probability_0', 'probability_1'], + ), + ( + 'spend', + 'regression', + ['prediction', 'quantiles'], + ['prediction'] + [f'quantile_0.{n}' for n in range(1, 10)], + ), + ], +) +def test_a_table_is_answered_row_by_row_under_its_keys( + client_for, target, task, outputs, columns +): + request = asked(target, task) + with client_for('kumo-tabular') as client: + answer = client.predict(request, model=KumoTabular(), outputs=outputs) + + frame = answer.to_pandas() + assert list(frame.columns) == ['row_index', 'id', *columns] + assert frame['id'].tolist() == list(range(45, 60)) + + +@pytest.mark.parametrize( + ('query', 'columns'), + [ + (COUNT, ['prediction']), + (BINARY, ['prediction', 'probability_True', 'probability_False']), + ( + MULTICLASS, + [ + 'prediction', + 'probability_bronze', + 'probability_silver', + 'probability_gold', + ], + ), + ], +) +def test_an_entity_is_answered_under_its_key_and_a_session_agrees( + client_for, query, columns +): + request = PredictiveQuery(query, graph()).materialize(indices=[0, 1, 2, 3]) + with client_for('kumo-relational') as client: + once = client.predict(request, model=KumoRelational()).to_pandas() + with client.fit(request, model=KumoRelational()) as fitted: + pinned = fitted.predict(request).to_pandas() + + assert list(once.columns) == ['row_index', 'id', *columns] + assert once['id'].tolist() == [0, 1, 2, 3] + pd.testing.assert_frame_equal(once, pinned) + + +def test_a_key_wider_than_a_double_comes_back_exactly(client_for): + start = 2**53 + 1 + request = asked('churn', 'classification', start=start) + with client_for('kumo-tabular') as client: + answer = client.predict(request, model=KumoTabular()) + + assert [item.id for item in answer.items] == list( + range(start + 45, start + 60) + ) + + +def test_a_key_of_several_columns_answers_under_its_values(client_for): + frame = people()[['person_id', 'age', 'visits', 'churn']].assign( + tenant=['a', 'b'] * 30 + ) + schema = Schema.from_data( + {'instance': frame}, primary_keys={'instance': ['tenant', 'person_id']} + ) + request = Request( + context=frame.iloc[:45], + predict=frame.iloc[45:].drop(columns=['churn']), + target='churn', + task='classification', + schema=schema, + ) + with client_for('kumo-tabular') as client: + answer = client.predict(request, model=KumoTabular()) + + assert [item.id for item in answer.items] == [ + f'["{tenant}",{person}]' + for tenant, person in zip( + frame['tenant'][45:], frame['person_id'][45:], strict=True + ) + ] + + +@pytest.mark.parametrize( + ('inference', 'code'), + [ + ({'use_prediction_time': 'no'}, None), + ({'use_prediction_time': np.bool_(False)}, None), + ({'use_prediction_time': None}, 'INVALID_REQUEST'), + ({'run_mode': 7}, 'INVALID_REQUEST'), + ({'inference_config': None}, 'INVALID_REQUEST'), + ], +) +def test_an_inference_option_is_read_as_the_type_it_is( + client_for, inference, code +): + """A boolean spelled ``'no'`` asks for nothing, and a value that cannot + be read as its type is refused before it is sent.""" + request = asked('churn', 'classification', inference=inference) + with client_for('kumo-tabular') as client: + if code is None: + assert len(client.predict(request, model=KumoTabular()).items) == 15 + return + with pytest.raises(StructuredDataError) as refused: + client.predict(request, model=KumoTabular()) + + assert refused.value.code == code + + +def test_a_fault_in_the_client_is_raised_as_itself(client_for, monkeypatch): + """What the client makes of an answer is the same code on both backends, + so a fault in it is raised as it is, not reported as the model's.""" + from sdm_client.adapters.tabular import TabularAdapter + + def broken(*args, **kwargs): + raise RuntimeError('broken') + + monkeypatch.setattr(TabularAdapter, 'answered', broken) + with client_for('kumo-tabular') as client: + with pytest.raises(RuntimeError, match='broken'): + client.predict( + asked('churn', 'classification'), model=KumoTabular() + ) + + +def test_a_long_question_is_sent_within_the_contract_and_answered(client_for): + from sdm_client.adapters.relational import RelationalAdapter + + query = f'{COUNT} WHERE ' + ' OR '.join( + f'users.age > {i}' for i in range(60) + ) + request = PredictiveQuery(query, graph()).materialize(indices=[0, 1]) + logged = request.payloads[0]['metadata']['source_query'] + sent = RelationalAdapter().prediction_plan(request, ('prediction',)) + assert len(logged) > 1024 + assert [p['metadata']['source_query'] for p in sent] == [logged[:1024]] + with client_for('kumo-relational') as client: + answer = client.predict(request, model=KumoRelational()) + + assert [item.id for item in answer.items] == [0, 1] + + +def _a_table_named_like_the_scored_rows() -> dict[str, pd.DataFrame]: + r"""``instance_table`` is the wire's own name for the rows scored.""" + tables = graph() + tables['instance_table'] = tables.pop('items').rename( + columns={'item_id': 'instance_table_id'} + ) + tables['orders'] = tables['orders'].rename( + columns={'item_id': 'instance_table_id'} + ) + return tables + + +def _scored_without_its_key() -> Request: + frame = people()[['person_id', 'age', 'visits', 'churn']].assign( + tenant=['a', 'b'] * 30 + ) + schema = Schema.from_data( + {'instance': frame}, primary_keys={'instance': ['tenant', 'person_id']} + ) + return Request( + context=frame.iloc[:45], + predict=frame.iloc[45:][['age', 'visits']], + target='churn', + task='classification', + schema=schema, + ) + + +def _a_session_asked_for_prediction_time(client: SDMClient) -> None: + r"""A session is pinned with no inference options, so its context is + fitted without prediction time, and a prediction cannot ask for it.""" + request = PredictiveQuery(BINARY, graph()).materialize( + indices=[0, 1], use_prediction_time=True + ) + with client.fit(request, model=KumoRelational()) as fitted: + fitted.predict(request) + + +def _text_schema() -> Schema: + schema = Schema.from_data(graph()) + schema['users']['tier'].stype = 'text' + return schema + + +@pytest.mark.parametrize( + ('model', 'ask', 'code'), + [ + ( + 'kumo-tabular', + lambda client: client.predict( + asked('churn', 'classification'), + model=KumoTabular(), + outputs=['prediction', 'quantiles'], + ), + 'UNSUPPORTED_OUTPUT', + ), + ( + 'kumo-relational', + lambda client: client.predict( + PredictiveQuery(COUNT, graph()).materialize(indices=[0]), + model=KumoRelational(), + outputs=['prediction', 'explanation'], + ), + 'UNSUPPORTED_OUTPUT', + ), + ( + 'kumo-relational', + lambda client: client.predict( + PredictiveQuery( + 'PREDICT LIST_DISTINCT(orders.item_id, 0, 30, days) ' + 'FOR EACH users.user_id', + graph(), + ).materialize(indices=[0, 1]), + model=KumoRelational(), + ), + 'UNSUPPORTED_TASK', + ), + ( + 'kumo-tabular', + lambda client: client.predict( + asked( + 'churn', + 'classification', + inference={'use_prediction_time': True}, + ), + model=KumoTabular(), + ), + 'INVALID_REQUEST', + ), + ( + 'kumo-relational', + lambda client: client.predict( + PredictiveQuery(COUNT, graph()).materialize( + indices=[0, 1], inference_config={'num_estimators': 4} + ), + model=KumoRelational(), + ), + 'INVALID_REQUEST', + ), + ( + 'kumo-relational', + lambda client: client.predict( + PredictiveQuery(COUNT, _text_schema()).materialize( + indices=[0, 1] + ), + model=KumoRelational(), + ), + 'UNSUPPORTED_DTYPE', + ), + ( + 'kumo-relational', + lambda client: client.predict( + PredictiveQuery( + COUNT, _a_table_named_like_the_scored_rows() + ).materialize(indices=[0, 1]), + model=KumoRelational(), + ), + 'INVALID_SCHEMA', + ), + ( + 'kumo-tabular', + lambda client: client.predict( + _scored_without_its_key(), model=KumoTabular() + ), + 'INVALID_SCHEMA', + ), + ( + 'kumo-relational', + _a_session_asked_for_prediction_time, + 'INVALID_REQUEST', + ), + ], +) +def test_a_request_the_model_cannot_answer_is_refused_with_the_same_code( + client_for, model, ask, code +): + with client_for(model) as client: + with pytest.raises(StructuredDataError) as refused: + ask(client) + + assert refused.value.code == code + + +def hand_built(change) -> Request: + r"""A gathered request with its payload changed, as one passed to + ``Request.materialized`` by hand may be.""" + gathered = PredictiveQuery(COUNT, graph()).materialize(indices=[0, 1]) + payload = copy.deepcopy(gathered.payloads[0]) + change(payload) + return Request.materialized( + payloads=(payload,), + schema=gathered.schema, + query=gathered.query, + indices=gathered.indices, + ) + + +GHOST = {'format': 'arrays', 'columns': ['k'], 'rows': []} + + +def _with(table: dict, column: str, value: object) -> None: + table['columns'].append(column) + for row in table['rows']: + row.append(value) + + +def _without(table: dict, column: str) -> None: + position = table['columns'].index(column) + del table['columns'][position] + for row in table['rows']: + del row[position] + + +def _undeclared(payload: dict, name: str, column: str) -> None: + r"""``column`` taken out of table ``name`` everywhere: from its rows in + both halves and from its schema.""" + + def holding(part: dict) -> dict: + return part if name == 'instance_table' else part['related_tables'] + + for half in ('context', 'predict'): + _without(holding(payload[half])[name], column) + del holding(payload['schema'])[name]['columns'][column] + + +def _first(table: dict, column: str, value: object) -> None: + table['rows'][0][table['columns'].index(column)] = value + + +def _counted(payload: dict, value: object) -> None: + r"""``users.age`` declared ``int64``, with ``value`` in its first row.""" + payload['schema']['related_tables']['users']['columns']['age']['dtype'] = ( + 'int64' + ) + for half in ('context', 'predict'): + users = payload[half]['related_tables']['users'] + position = users['columns'].index('age') + for row in users['rows']: + row[position] = int(row[position]) + _first(payload['context']['related_tables']['users'], 'age', value) + + +@pytest.mark.parametrize( + ('change', 'code'), + [ + ( + lambda p: p['task'].update(entity_table_names=['orders']), + 'TASK_SCHEMA_MISMATCH', + ), + (lambda p: p['task'].update(top_k=3), 'UNSUPPORTED_FEATURE'), + ( + lambda p: p['task'].update(anchor_time_column='nowhere'), + 'TASK_SCHEMA_MISMATCH', + ), + ( + lambda p: p['task']['target'].update(dtype='timestamp[us]'), + 'TASK_SCHEMA_MISMATCH', + ), + ( + lambda p: p['predict']['related_tables'].pop('orders'), + 'INVALID_SCHEMA', + ), + ( + lambda p: p['predict']['related_tables'].update(ghost=GHOST), + 'INVALID_SCHEMA', + ), + ( + lambda p: [ + p[half]['related_tables'].update(ghost=GHOST) + for half in ('context', 'predict') + ], + 'INVALID_SCHEMA', + ), + ( + lambda p: _undeclared(p, 'instance_table', 'instance_id'), + 'INVALID_SCHEMA', + ), + (lambda p: _undeclared(p, 'users', 'instance_id'), 'INVALID_SCHEMA'), + ( + lambda p: _without(p['predict']['related_tables']['orders'], 'ts'), + 'INVALID_SCHEMA', + ), + ( + lambda p: _without(p['context']['related_tables']['users'], 'age'), + 'INVALID_SCHEMA', + ), + ( + lambda p: _with( + p['context']['related_tables']['users'], 'ghost', 1.0 + ), + 'INVALID_SCHEMA', + ), + ( + lambda p: _with( + p['predict']['related_tables']['users'], 'ghost', 1.0 + ), + 'INVALID_SCHEMA', + ), + ( + lambda p: _with( + p['context']['related_tables']['users'], 'age', 1.0 + ), + 'VALIDATION_FAILED', + ), + ( + lambda p: p['context']['related_tables']['users']['rows'][0].pop(), + 'INVALID_SCHEMA', + ), + ( + lambda p: p['context']['related_tables']['users']['rows'][0].append( + 1.0 + ), + 'INVALID_SCHEMA', + ), + ( + lambda p: _first( + p['context']['related_tables']['orders'], 'user_id', 'abc' + ), + 'VALIDATION_FAILED', + ), + ( + lambda p: _first( + p['predict']['related_tables']['orders'], 'user_id', '9' * 30 + ), + 'VALIDATION_FAILED', + ), + (lambda p: _counted(p, '9' * 30), 'VALIDATION_FAILED'), + ], +) +def test_a_request_built_by_hand_is_refused_where_it_states_another_question( + client_for, change, code +): + """The model scores the table its rows link to, from a timestamp the + instance table declares, over the tables the context carries. A request + saying otherwise is refused rather than answered as something else.""" + request = hand_built(change) + with client_for('kumo-relational') as client: + with pytest.raises(StructuredDataError) as refused: + client.predict(request, model=KumoRelational()) + + assert refused.value.code == code + + +@pytest.mark.parametrize( + 'value', [np.bool_(False), np.int64(0), np.int64(1), np.bool_(True)] +) +def test_a_numpy_boolean_built_by_hand_is_read_as_the_boolean_it_holds( + client_for, value +): + """A frame hands back NumPy scalars and a hand-built payload keeps them, + where the tabular path is converted on its way out.""" + request = hand_built( + lambda p: p.setdefault('inference', {}).update( + use_prediction_time=value + ) + ) + with client_for('kumo-relational') as client: + assert len(client.predict(request, model=KumoRelational()).items) == 2 diff --git a/packages/structured-data-models-client/tests/test_live_relational.py b/packages/structured-data-models-client/tests/test_live_relational.py index 3d6a32471..064ced086 100644 --- a/packages/structured-data-models-client/tests/test_live_relational.py +++ b/packages/structured-data-models-client/tests/test_live_relational.py @@ -186,7 +186,7 @@ def test_the_endpoint_scores_what_this_client_says_it_does(client, query): list(rows[i % len(rows)]) for i in range(cap + 1) ] with pytest.raises(StructuredDataError) as refused: - client._transport.predict(payload) + client._backend.transport.predict(payload) assert str(cap) in str(refused.value) diff --git a/packages/structured-data-models-client/tests/test_pinned_context_and_keys.py b/packages/structured-data-models-client/tests/test_pinned_context_and_keys.py index 022c881c9..04912582f 100644 --- a/packages/structured-data-models-client/tests/test_pinned_context_and_keys.py +++ b/packages/structured-data-models-client/tests/test_pinned_context_and_keys.py @@ -37,6 +37,7 @@ Schema, ) from sdm_client.client import _warn_scoring_what_was_pinned +from sdm_client.core.backend import NimBackend from sdm_client.errors import StructuredDataError from sdm_client.wire.keys import _named_by, keys_in, scored_keys @@ -137,7 +138,7 @@ def delete_session(self, session_id: str) -> None: class Client: def __init__(self) -> None: - self._transport = Transport() + self._backend = NimBackend(Transport()) def handle(pinned_keys) -> object: @@ -384,7 +385,7 @@ def create_session(self, payload): def delete_session(self, session_id): pass - client._transport = Fake() + client._backend = NimBackend(Fake()) fitted = client.fit(request, model='kumo-tabular') assert len(fitted._pinned_keys) == 298 diff --git a/packages/structured-data-models-client/tests/test_query_engine_boundary.py b/packages/structured-data-models-client/tests/test_query_engine_boundary.py index 4a8626eaf..cf7d9495d 100644 --- a/packages/structured-data-models-client/tests/test_query_engine_boundary.py +++ b/packages/structured-data-models-client/tests/test_query_engine_boundary.py @@ -18,6 +18,7 @@ import pytest from sdm_client import PredictiveQuery, Relationship, Schema +from sdm_client.core.backend import NimBackend from sdm_client.errors import StructuredDataError from sdm_client.materializers.relational import ( RelationalMaterializer, @@ -218,7 +219,8 @@ def test_a_request_asked_in_several_calls_is_answered_as_one( asked = query('PREDICT users.age FOR EACH users.user_id') scored = [5, 6, 7, 8, 9] client = SDMClient(url='http://localhost:8000') - client._transport = transport = Echo() + transport = Echo() + client._backend = NimBackend(transport) with warnings.catch_warnings(): warnings.simplefilter('ignore') request = asked.materialize(indices=scored, num_context_samples=20) @@ -441,7 +443,7 @@ def predict(self, payload: dict) -> dict: return super().predict(payload) client = SDMClient(url='http://localhost:8000') - client._transport = FailsOnTheSecondCall() + client._backend = NimBackend(FailsOnTheSecondCall()) with warnings.catch_warnings(): warnings.simplefilter('ignore') request = query('PREDICT users.age FOR EACH users.user_id').materialize( diff --git a/packages/structured-data-models-client/tests/test_transport_hardening.py b/packages/structured-data-models-client/tests/test_transport_hardening.py index 74337c92f..61499599e 100644 --- a/packages/structured-data-models-client/tests/test_transport_hardening.py +++ b/packages/structured-data-models-client/tests/test_transport_hardening.py @@ -272,7 +272,7 @@ def test_a_payload_nested_too_deeply_is_refused_not_raised_raw(): with SDMClient(url='https://example.invalid') as client: with pytest.raises(StructuredDataError) as error: - client._transport._post('/v1/predictions', payload) + client._backend.transport._post('/v1/predictions', payload) assert error.value.code == 'INVALID_REQUEST' assert 'nests more than' in str(error.value) @@ -284,7 +284,7 @@ def test_an_adapter_that_adds_nothing_does_not_become_its_own_cause(): so this was the common case, and anything walking the cause chain for logging loops on it. """ - from sdm_client import SDMClient + from sdm_client.core.backend import NimBackend from sdm_client.errors import StructuredDataError class Unchanged: @@ -299,15 +299,14 @@ class Refusing: def predict(self, payload): raise StructuredDataError('boom', code='INVALID_REQUEST') - client = SDMClient(url='https://example.invalid') - client._transport = Refusing() + backend = NimBackend(Refusing()) with pytest.raises(StructuredDataError) as error: - client._send(Unchanged(), {}) + backend.predict(Unchanged(), {}, ()) assert error.value.__cause__ is not error.value with pytest.raises(StructuredDataError) as error: - client._send(Explains(), {}) + backend.predict(Explains(), {}, ()) assert error.value.__cause__ is not error.value assert isinstance(error.value.__cause__, StructuredDataError) @@ -320,7 +319,7 @@ def test_a_payload_that_refers_back_to_itself_is_refused(): cyclic['self'] = cyclic with SDMClient(url='https://example.invalid') as client: with pytest.raises(StructuredDataError) as error: - client._transport._post('/v1/predictions', cyclic) + client._backend.transport._post('/v1/predictions', cyclic) assert 'refers back to itself' in str(error.value) @@ -375,7 +374,9 @@ def test_a_non_ascii_body_is_encoded_here_not_by_requests(): 'https://example.invalid/v1/predictions', json={'id': 'x', 'model': 'kumo-relational', 'predictions': []}, ) - client._transport._post('/v1/predictions', {'note': 'naïve café'}) + client._backend.transport._post( + '/v1/predictions', {'note': 'naïve café'} + ) assert isinstance(mock.last_request.body, bytes) diff --git a/packages/structured-data-models-connectors/pyproject.toml b/packages/structured-data-models-connectors/pyproject.toml index b96d010d4..6bce18c6a 100644 --- a/packages/structured-data-models-connectors/pyproject.toml +++ b/packages/structured-data-models-connectors/pyproject.toml @@ -61,7 +61,10 @@ sqlite = [ "adbc-driver-sqlite", ] duckdb = [ - "duckdb>=1.5.0", + # 2.0 warns on an Arrow fetch from a DB-API cursor, which the engine + # makes for every table and sample it reads. Lifting this moves those + # reads off the cursor. + "duckdb>=1.5.0,<2", ] snowflake = [ # 5.0 empties `DEFAULT_CONFIGURATION` and rewrites keyword names through diff --git a/packages/structured-data-models-relational-engine/setup.py b/packages/structured-data-models-relational-engine/setup.py index 0f289c9a9..65c1aa3a5 100644 --- a/packages/structured-data-models-relational-engine/setup.py +++ b/packages/structured-data-models-relational-engine/setup.py @@ -39,7 +39,9 @@ setup( install_requires=[ 'numpy', - 'pandas', + # 3.1 raises taking a `DateOffset` from `Timestamp.min`, which is the + # anchor time of every graph without a time column. + 'pandas<3.1', # The warehouse backends read results as `pd.ArrowDtype`, which pandas # refuses below pyarrow 10.0.1. Databricks Runtime 14.3 LTS and # serverless environment 1 ship pyarrow 8, and failed there on import. 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..27a9968c5 --- /dev/null +++ b/qa/probe_in_process.py @@ -0,0 +1,128 @@ +# 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 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 + +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': + 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 + 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..c97527237 100644 --- a/scripts/check_repo_hygiene.py +++ b/scripts/check_repo_hygiene.py @@ -97,14 +97,18 @@ # 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), + # The bare name is public now, and is what the in-process extra installs, + # so it is named like any other dependency. A suffix that is not one of + # the five distributions published here is still an internal build, such + # as structured-data-models-nim, and is still refused. re.compile( - r'\bstructured-data-models\b' - r'(?!-(?:client|api|pql|connectors|relational-engine))', + r'\bstructured-data-models-' + r'(?!(?:client|api|pql|connectors|relational-engine)(?![\w-]))\w', re.IGNORECASE, ), ) 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 4d16c7041..2c901ce48 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" @@ -1667,3 +1669,34 @@ def test_an_extra_narrower_than_its_parent_says_so(self): assert 'relational' in install_doc, ( 'the installation guide does not mention the relational extra' ) + + +@pytest.mark.parametrize( + ('suffix', 'refused'), + [ + ('', False), + ('-client', False), + ('-api', False), + ('-pql', False), + ('-connectors', False), + ('-relational-engine', False), + ('-nim', True), + ('-serving', True), + ('-client-nim', True), + ('-api-internal', True), + ('-relational-engine-nim', True), + ('-client_nim', True), + ], +) +def test_only_a_published_distribution_may_be_named(suffix, refused): + """The bare name is the public model package, and the five suffixes are + what this repository publishes. Any other suffix is an internal build a + reader cannot look up, and naming one in a shipped file is a leak. + + The names are assembled rather than spelled, because spelling one here + would be the very thing the rule refuses. + """ + hygiene = _loaded('check_repo_hygiene') + name = 'structured-data-models' + suffix + matched = any(pattern.search(name) for pattern in hygiene.INTERNAL_PATTERNS) + assert matched is refused diff --git a/uv.lock b/uv.lock index 99c38bc33..ba3219cb0 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"] }, ] @@ -2859,10 +3166,11 @@ test = [ requires-dist = [ { name = "databricks-sdk", marker = "extra == 'databricks-serving'", specifier = ">=0.68.0,<1.0" }, { name = "numpy", specifier = ">=1.23" }, - { name = "pandas", specifier = ">=2.2.2" }, + { name = "pandas", specifier = ">=2.2.2,<3.1" }, { 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" @@ -2933,7 +3241,7 @@ requires-dist = [ { name = "databricks-sdk", marker = "extra == 'databricks'", specifier = ">=0.89,<1.0" }, { name = "databricks-sdk", marker = "extra == 'lakebase'", specifier = ">=0.89,<1.0" }, { name = "databricks-sql-connector", marker = "extra == 'databricks'", specifier = ">=4.1.3" }, - { name = "duckdb", marker = "extra == 'duckdb'", specifier = ">=1.5.0" }, + { name = "duckdb", marker = "extra == 'duckdb'", specifier = ">=1.5.0,<2" }, { name = "moto", extras = ["server"], marker = "extra == 'test'", specifier = ">=5" }, { name = "pandas", specifier = ">=2.2.2" }, { name = "psycopg", marker = "extra == 'lakebase'", specifier = ">=3.1" }, @@ -3040,7 +3348,7 @@ requires-dist = [ { name = "numpy" }, { name = "openai", marker = "extra == 'explain'", specifier = ">=1.40" }, { name = "packaging", marker = "extra == 'test'" }, - { name = "pandas" }, + { name = "pandas", specifier = "<3.1" }, { name = "pooch", marker = "extra == 'relbench'" }, { name = "pooch", marker = "extra == 'test'" }, { name = "pyarrow", specifier = ">=10.0.1" }, @@ -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"