Skip to content

Run the models in this process, through the same calls as a NIM - #41

Draft
Manushpm8 wants to merge 14 commits into
mainfrom
run-models-in-process
Draft

Manushpm8 wants to merge 14 commits into
mainfrom
run-models-in-process

Conversation

@Manushpm8

@Manushpm8 Manushpm8 commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

The client could only reach a model over HTTP: a NIM, or a Databricks serving endpoint. Someone who already has structured-data-models loaded in a notebook had to call its tensor API directly, and then use a different API again once the model was hosted. SDMClient.in_process() runs the model in the caller's own Python process behind the same calls.

import sdm
from sdm_client import KumoRelational, SDMClient

with SDMClient.in_process(sdm.models.KumoRelational()) as client:
    result = client.predict(request, model=KumoRelational())

With no model passed, each one is loaded from its published weights on Hugging Face the first time it is asked for, on CUDA when available, else the CPU.

How it works

It is a third transport beside the HTTP one and the Databricks one. The adapters send exactly the payload they would send a NIM and read back what a NIM answers, so predictions, fit() sessions, rankings and forecasts work unchanged: nothing above the transport knows where the model runs.

The transport reads that payload the way the SDM NIM does:

  • Wire tables are decoded by their declared dtypes. That covers base-10 integers past 2**53, timestamps normalised to UTC, and numeric join keys read as int64, with a fractional key refused rather than truncated onto another row.
  • Each relational neighbourhood is scoped by its instance_id, the way the model's own sampler scopes each example.
  • The context is fitted with the NIM's seed (42), one estimator and, on a GPU, its autocast precision.
  • Answers come back with the NIM's ids, class labels and quantile levels. A refusal is the same NimRequestError, with the NIM's status and code.

Verified against the NIM itself

I served the current SDM NIM code locally on the CPU, with the same published weights and structured-data-models 0.2.1, and sent each request both ways. Every prediction, probability and quantile was identical (largest difference 0.0):

Cases
kumo-tabular binary (int labels), multiclass (string labels), regression with quantiles, bool target, composite key, int64 key past 2**53, a session
kumo-relational count and sum regressions with quantiles, binary, multiclass, a bool column, use_prediction_time, custom quantile levels, mean statistic, float foreign keys with nulls, int64 keys past 2**53, string keys
through the client RANK TOP 3, FORECAST 3 TIMEFRAMES, sessions scored twice
real data Adult, Wine, Titanic, Bank Marketing, Students (kumo-tabular); Northwind counts, sums, binary and integer regression (kumo-relational), one-shot and in a session

Every refusal matched on status, code and message:

Request Answer
scores, embeddings, explanation, or probabilities on a regression 422 UNSUPPORTED_OUTPUT
bare LIST_DISTINCT, ranking 422 UNSUPPORTED_TASK
non-empty inference_config, unknown inference option, use_prediction_time on kumo-tabular 400 INVALID_REQUEST
an inference option of the wrong type, such as a run_mode that is not a string 422 VALIDATION_FAILED
a name or label over 1,024 characters, or a table naming a column twice, which the contract refuses 422 VALIDATION_FAILED
text, multicategorical or sequence column; a tabular timestamp feature 422 UNSUPPORTED_DTYPE
a table the model cannot hold, such as a context of only its key and target 422 VALIDATION_FAILED
a relational context with no rows, such as one a holdout of every entity empties 422 VALIDATION_FAILED
a binary context with one class, or one missing a class its task declares, which a holdout can produce 422 TASK_SCHEMA_MISMATCH
any validation refusal while creating a session, or scoring against one 422 SESSION_CREATE_VALIDATION_FAILED, 422 SESSION_PREDICT_SCHEMA_MISMATCH
unknown session 404 SESSION_NOT_FOUND
model id outside the contract 422 VALIDATION_FAILED

Inference options are typed and coerced as the NIM's request model does them, so use_prediction_time: 'no' asks for nothing on both. A sweep of 32 values across the four inference fields, on both models, in one call and in a session, matched the NIM on status, code and message.

And on a GPU. On an NVIDIA L4, I served the same NIM code with its GPU profile (CUDA, bf16 autocast) and ran in-process on the same GPU, with no model passed and so the default device. In-process loaded onto cuda:0 under bf16 autocast, gave identical answers when asked twice, and matched the NIM with a largest difference of 0.0 across all 37 compared answers: tabular and relational, sessions, and every real dataset above. Every refusal probe also matched on status, code and message.

qa/probe_in_process.py repeats the real-data comparison against any NIM. When this process runs on the same device as the NIM, CPU or GPU, it needs no tolerance. With this process on a CPU and the NIM on a GPU, set SDM_IN_PROCESS_TOLERANCE.

Tests

test_a_model_runs_in_this_process.py uses small random-weight models, so it needs no network and no GPU:

  • A tabular answer equals what the model returns when called directly through its own tensor API, for int labels, string labels and regression. This is exact equality, not a tolerance.
  • A session answers what one call answers, for regression, binary and multiclass.
  • Rankings and forecasts are answered in process.
  • Each refusal carries the NIM's status and code.
  • A context the caller fitted on their own model survives.
  • A model is loaded once, on first use, on the default device. A load that fails is 503 MODEL_NOT_READY, with the original error as its cause.
  • Configuration mistakes are refused when the client is opened, and a model left in training mode is named.
  • A refusal inside a session carries the session's code, and a model loaded for one task alone refuses the other with UNSUPPORTED_TASK before fitting.
  • Without the extra, in_process() raises MissingExtraError naming in-process. This is checked in a fresh interpreter with structured-data-models, PyTorch and PyArrow all absent, and also by hand on a clean install of the built wheel.

Two deliberate breaks were each caught: changing the seed, and fitting on the caller's model instead of a copy. CI runs the module in the engine job, which now installs CPU PyTorch and the in-process extra. The other jobs skip it.

Locally: 2707 passed across the four pure-Python packages and the root suite, and 2568 passed in the engine suite. ruff, mypy over all five packages, hygiene and the notices check are clean. I also installed the built wheel with [in-process] into a clean venv and ran the quickstart from outside the checkout, using the published kumo-tabular weights.

Worth a look

  • CI installs the wheels it built. structured-data-models-client 1.0.0 reached PyPI while this was in review. After that, --find-links dist with ==1.0.0 resolved the published release instead of the wheel CI had just built, so installed-wheel started testing the release and failed on the new method. installed-wheel, sbom and the build dry-run now name each wheel file, and installed-wheel asserts that all four distributions came from dist/. This affects every pull request against the client from now on, not only this one.
  • The hygiene rule changes. It refused the bare name structured-data-models as an internal build a reader cannot look up. That package is now public on GitHub and PyPI, and it is what the new extra installs, so the rule now covers only the internal build name it was written for.
  • Text, multicategorical and sequence columns are refused, as the current NIM refuses them. An older deployment answered without them. The client's existing warning at materialize() still says those columns are dropped. It is now wrong against both a current NIM and this transport, and is left for a follow-up.
  • The extra needs Python 3.11, because structured-data-models does. It carries a python_version >= '3.11' marker, because without one the workspace lock cannot resolve. As a result, on 3.10 the extra installs nothing, and in_process() says it needs Python 3.11 rather than asking for an extra that was already installed. This was checked on a real 3.10 interpreter.
  • uv.lock grows. It is what the open source scan inventories, and it resolves every extra, so it gains structured-data-models, PyTorch, and PyTorch's CUDA wheels for Linux. Nothing already in it moves.
  • In-process copies the NIM's request handling, so it has to follow it. It matches the current SDM NIM release. A pending change to the NIM's validation, which refuses a feature that carries no information, will need mirroring here when it lands. The parity probe is how to tell when the two have drifted.
  • A deployment's protective limits do not apply in process. These are the limits a NIM sets per deployment through TFM_MAX_*, such as string-cell bytes, distinct values per column and body size. They protect a shared server, and two NIMs may set them differently. The limits this client checks before sending still apply. docs/reference/limits.md says so.
  • A payload written by hand is not held to every rule a NIM applies. In-process makes every check that a request built by the client can reach. The client already refuses, before sending, the requests that would reach the NIM's remaining checks, such as a relational target the model cannot represent. A payload written by hand and passed straight to the transport, such as a task with no kind, can be answered differently, for example with a 500 where a NIM answers 422.
  • A passed model is used as given. Each context is fitted on a shallow copy that shares its weights, so nothing fitted on the original is disturbed.

Follow-ups, not in this change:

  • A predictive query longer than 1,024 characters fails against every NIM today. The relational adapter sends the query text as metadata.source_query, and the contract caps that field at 1,024 characters, so the NIM answers 422 VALIDATION_FAILED. In-process now refuses it the same way. The fix belongs in what the client sends, for example by not sending the text past the cap.
  • A table named instance_table. The client lets a user name a table instance_table, which is the wire's own name for the scored rows. The NIM refuses such a request with 422 INVALID_SCHEMA, and so does in-process, so it should be refused when the request is gathered.

CPU against GPU. On a CPU, in-process computes at full precision and draws its random numbers on the CPU. A GPU-hosted NIM does neither, so the two can differ by more than rounding: on the L4, a class probability moved by 0.16 and a regression by 0.41. The quickstart said "the last digits", which understated it, and now gives the measured figure. On the same device, the two are identical.

🤖 Generated with Claude Code

Manushpm8 and others added 14 commits October 16, 2026 15:56
SDMClient.in_process() answers the Universal TFM contract with
structured-data-models in the caller's own Python process. The client's
adapters send what they would send a NIM and read back what a NIM answers,
so predictions, sessions, rankings and forecasts work unchanged: only the
transport differs, as it already does for Databricks Model Serving.

The request is read the way the SDM NIM reads it. Wire tables are decoded
by their declared dtypes, each relational neighbourhood is scoped by its
instance_id, and the context is fitted on a copy of the model with the
NIM's seed, one estimator and, on a GPU, its autocast precision. Against
the current NIM code served on the CPU with the same published weights,
every prediction, probability and quantile matched exactly, on synthetic
graphs and on Adult, Wine, Titanic, Bank, Students and Northwind, and every
refusal carried the same status and code. qa/probe_in_process.py repeats
that comparison.

A model passed in is only used: each fit is on a shallow copy that shares
its weights, so a context the caller fitted on it survives. With none, each
model is loaded from its published weights on first use.

structured-data-models is public, so the hygiene rule that treated its name
as an internal build now covers only the one that still is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Manush Murali <manushm@nvidia.com>
PyArrow comes with the extra, not with the client, and the table decoder
imported it at module scope. Opening an in-process client on a base install
therefore raised ModuleNotFoundError from inside the package, before the
check that names the extra. It is imported where it is used now.

The test that covered this blocked only `sdm`, in a process that had already
imported the package, so it passed either way. It runs in a fresh
interpreter with everything the extra brings blocked, and fails on the old
code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Manush Murali <manushm@nvidia.com>
A request whose table the model cannot represent, such as a single-table
context holding only its key and target, raised inside the model and came
back as a 500 INTERNAL_ERROR. The NIM refuses the same request with
422 VALIDATION_FAILED, "table cannot be represented by SDM", and labels the
model cannot hold with 422 TASK_SCHEMA_MISMATCH. Both are answered that way
here now, for every table rather than for the one shape that surfaced it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Manush Murali <manushm@nvidia.com>
uv.lock is what the open source scan inventories, and it resolves every
extra, so structured-data-models and what it brings belong in it. It could
not be relocked: the workspace admits Python 3.10 and structured-data-models
needs 3.11. The extra now carries that marker, so on 3.10 it installs
nothing, and in_process() says it needs Python 3.11 rather than asking for
an extra that was already installed. The lock gains structured-data-models,
PyTorch and PyTorch's CUDA wheels for Linux, and nothing else in it moves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Manush Murali <manushm@nvidia.com>
A relational request is gathered by the relational engine whichever way it
is answered, so asking KumoRelational in process needs `[relational]` as
well as `[in-process]`. The quickstart and the installation guide said only
the second.

A fractional numeric key is refused, as the NIM refuses it, because keys are
joined as integers. The message said such a key joins to no row, which is
not why; it now reads as the NIM's does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Manush Murali <manushm@nvidia.com>
A NIM reports a request it refuses while creating a session as
SESSION_CREATE_VALIDATION_FAILED, and one refused while scoring against a
session as SESSION_PREDICT_SCHEMA_MISMATCH, keeping the message, for every
validation failure rather than one. In process they kept their original
codes, so the same session refusal arrived under a different code depending
on where the model ran.

The relational NIM also refuses a context with no rows, which a holdout of
every entity produces after the client's own check has passed. In process
that session was fitted on nothing and answered. It is refused now, as the
NIM refuses it, in one call and in a session. A single-table context left
empty the same way still answers its session's first prediction with a 500,
as the NIM does.

A model loaded for one task alone, which a NIM never is, is refused for the
other with UNSUPPORTED_TASK before anything is fitted, rather than failing
inside the model as an internal error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Manush Murali <manushm@nvidia.com>
The client counts a context's classes before it is pinned, and a holdout
then takes rows out of it. A binary context left with one class, or a
multiclass one that lost a class it declares, was fitted and answered in
process, while the NIM refuses both. The NIM's three class checks now run
on the context as it arrives: exactly two classes for a binary task, at
least two for a multiclass one, and declared classes equal to those present.

A run_mode the client does not know is not refused: the NIM accepts any
short string there, and answers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Manush Murali <manushm@nvidia.com>
The installed-wheel, sbom and build jobs installed the client set with
`--find-links dist` and `==$VERSION`. Once 1.0.0 was on PyPI, pip resolved
the published release as readily as the wheel built here, and the
installed-wheel job began testing it: a pull request adding to the client
failed there because the release lacked the addition. Each distribution is
now named by its wheel file, and the installed-wheel job checks that every
one was installed from dist/ before running the suite.

The release workflow keeps `--find-links`: the version it builds is not on
PyPI yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Manush Murali <manushm@nvidia.com>
…es them

A NIM validates the inference block against a typed model before any model
sees it. A value of the wrong kind, such as a run_mode that is not a string
or an inference_config that is not an object, is 422 VALIDATION_FAILED
there; in process it was ignored and answered. A boolean is read as the
boolean it spells, so use_prediction_time 'no' asks for nothing on the NIM
and was read here as asking for prediction time.

The options are now typed and coerced the same way first. Each model then
applies its own NIM's checks in its NIM's order: the tabular one refuses
use_prediction_time with its own 400 and message, which in process had
borrowed the relational one's 422. Every combination of value, model and
route measured against the NIM's code now returns the same status, code and
message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Manush Murali <manushm@nvidia.com>
The limits this client checks before sending hold in process as they do
against a NIM. A NIM also sets limits of its own, per deployment, to
protect a shared server: the longest string a cell may hold, the most
distinct values a column may have, the largest body it accepts. Those are
not applied when the model runs in the caller's own process, and the
quickstart and the limits reference now say so rather than leaving it to be
discovered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Manush Murali <manushm@nvidia.com>
A NIM validates every request against the contract before anything reads
it: each name and label it carries is at most 1,024 characters, and a
table names each column once. In process neither was checked, so a request
the NIM refuses was answered. The one a caller reaches without trying is a
predictive query longer than that limit, whose text travels as
metadata.source_query; the NIM refuses it with 422 VALIDATION_FAILED. Both
rules are checked first now, and the refusal names each field the way the
NIM names it.

The quickstart also says where the answers differ by design: on a CPU the
model computes at full precision, while a GPU-hosted NIM computes at
reduced precision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Manush Murali <manushm@nvidia.com>
Before anything else, the NIM requires each table to supply exactly the
columns its schema declares, the rows to score excepted only for the
target. In process that was checked piecemeal and later, so a missing or
extra column came back under a different code and message, and a related
table the context never carried was refused for the wrong reason. The
column check now runs first, as the NIM's does, and a split is then held to
the tables the relationships name and to those the context was fitted on.
The checks it makes redundant are removed rather than left unreachable.

Three smaller differences, each measured against the NIM:

- A tabular request carrying relationships or related tables is refused
  naming whichever the NIM names first, including one on the predict side
  only, which was let through.
- Quantile levels are judged only when quantiles are asked for, and are
  refused in the NIM's words.
- On Python 3.10, in_process() raises a StructuredDataError with the code
  MISSING_EXTRA rather than MissingExtraError; its docstring says so, and
  the prerequisites no longer say the client never uses a GPU.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Manush Murali <manushm@nvidia.com>
An unsupported output or task was refused with the NIM's code and a
message that differed by its closing period, and an unknown session with a
different sentence. Each now reads exactly as the NIM's does. The module
docstring says that a payload written by hand, rather than built by the
client, is not held to every rule a NIM applies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Manush Murali <manushm@nvidia.com>
On an L4, in process gave the NIM's answers on the same GPU exactly, in
one call and in a session. A CPU run against a GPU-hosted NIM differs by
more than rounding, because the two compute at different precision and
draw different random numbers: a class probability moved by 0.16. The
quickstart said "the last digits", and the parity probe said a GPU NIM
always needs a tolerance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Manush Murali <manushm@nvidia.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant