Skip to content

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

Merged
Manushpm8 merged 40 commits into
mainfrom
run-models-in-process
Oct 26, 2026
Merged

Manushpm8 merged 40 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

The client prepares every request the same way, whichever backend answers it: it checks the request against the model, and the model's adapter writes it out as the contract's request, meaning the task, schema, context, rows to score, outputs and inference settings, as Python data. It then hands that to a Backend (sdm_client/core/backend.py):

  • NimBackend sends it to a NIM and reads the answer back: encode, HTTP, decode. It wraps the existing HTTP and Databricks transports, and owns what only a NIM needs, such as letting the adapter explain a NIM's refusal, and reading a 404 on the session routes as "no sessions here".
  • InProcessBackend runs the model on it directly, with no JSON and no HTTP. It returns the client's own PredictionItems, which go through the same adapter step a NIM's decoded answer does to be put under the caller's keys. No response is built or parsed.

Sessions are backend operations too: create_session, session_predict and delete_session. So predictions, fit() sessions, rankings and forecasts work unchanged, and nothing above the backend knows where the model runs.

In process, the context is fitted as the hosted deployments fit it: seed 42, one estimator, and on a GPU their autocast precision, on a shallow copy of the model so a passed model is never disturbed. 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 carries no HTTP status, since nothing was sent, and NimRequestError stays the NIM's.

What the in-process backend checks is what the model needs to read a request as it is stated: a column type the model has no reader for, a context with nothing to learn from, a relational task whose entity, anchor, target or joins differ from what the rows say. It does not re-check the request contract the way a NIM's HTTP layer does, field by field. A difference that matters to the model shows up in the shared tests below, and is fixed where it belongs: in the client's preparation, so neither backend is sent it, or in what the model needs to read a request.

Parity is the same tests on both backends

test_every_backend_answers_alike.py runs the same calls against both backends. In process it always runs, with small random-weight models. Against NIMs it runs under -m live_nim with SDM_TABULAR_URL and SDM_RELATIONAL_URL set. The values differ, since the weights do. Both are held to everything else a caller reads:

  • which rows come back, under which keys, including an int64 key past 2**53 and a key of several columns;
  • the columns, for tabular classification and regression with quantiles, and relational regression, binary and multiclass;
  • that a session answers what one call answers;
  • how an inference option is read: use_prediction_time spelled 'no', or given as a NumPy boolean, asks for nothing, and a value that cannot be read as its type is refused;
  • the refusal code for an output the task does not produce, a bare LIST_DISTINCT, an inference option the model does not act on, a text column, a table named instance_table, and rows scored without their key;
  • the refusal code for a relational request built by hand through Request.materialized that states another question than its rows: another entity, a task option the model ignores, an undeclared anchor, a target it cannot read, a table missing or undeclared, or a table that does not carry exactly the columns its schema declares.

All 33 pass on each backend, the NIM being the current SDM NIM code served locally on the CPU with the published weights. Running them, and the review rounds over this change, found seven real differences, each confirmed against the NIM and fixed here:

  • A predictive query longer than 1,024 characters was refused by every NIM. The engine puts the query text in metadata.source_query for logging, and the contract caps that field at 1,024 characters, so a NIM answered 422 VALIDATION_FAILED. The relational adapter now cuts the logged copy to fit, so both backends answer it. This is a fix for NIM users too, and is in the changelog.
  • A table named instance_table collides with the wire's own name for the rows scored, leaving a relationship that joins on a column the table does not declare. A NIM refuses it with INVALID_SCHEMA. In process it was answered, so the backend now refuses a join on an undeclared column, with the same code.
  • Inference options of the wrong type were read differently. The tabular adapter forwarded a caller's options as given, and a NIM types them: run_mode: 7 and inference_config: None were refused by a NIM and answered in process, and use_prediction_time: 'no' the reverse. Both adapters now write the options in the contract's types before sending, with the coercion a NIM applies, and refuse a value that cannot be read with INVALID_REQUEST. For a NIM user, such a value is now refused before it is sent, rather than with 422 VALIDATION_FAILED.
  • A key of several columns came back with no id in process. A NIM answers it under the JSON of the key's values, which the client hands back as the row's id, so in process now gives the same id.
  • Rows scored without their key failed in process with INTERNAL_ERROR, after the model had run. A NIM refuses them with INVALID_SCHEMA, and in process now does too, before the model runs.
  • A cell that cannot be read as its type failed in process with INTERNAL_ERROR when it sat in a key column or held an integer past the int64 range. A NIM refuses it with VALIDATION_FAILED, and in process now does too.
  • A row with a cell too many or too few was VALIDATION_FAILED in process and is INVALID_SCHEMA from a NIM. In process now gives the NIM's code.
  • A table that did not carry exactly its declared columns was read anyway in process: a declared column left out was fitted without, an undeclared one read as nothing, a repeated one read once. A NIM refuses each, and in process now does too, with the NIM's code.
  • A session kept the request's own task and schema, both in the in-process backend and in the client's handle, so changing the request after fit() changed the session under it. Both now keep a copy, as a NIM does.

Values match the NIM exactly on the same device

Served the same way, with the same published weights and structured-data-models 0.2.1, the two give identical predictions, probabilities and quantiles: the largest difference is 0.0 across 41 comparisons, with no missing value on either side. Those comparisons cover:

Cases
kumo-tabular binary (int labels), multiclass (string labels), regression with quantiles
kumo-relational count and sum regressions with quantiles, binary, multiclass, a bool column, a forecast
through the client RANK TOP 3, a session scored twice
real data Adult, Wine (multiclass and regression), Titanic, Bank Marketing, Students (kumo-tabular); Northwind counts, sums, binary and integer regression (kumo-relational), one-shot and in a session

qa/probe_in_process.py repeats the real-data comparison against any NIM. A value missing on one side only fails it. On the same device it needs no tolerance. With this process on a CPU and the NIM on a GPU, set SDM_IN_PROCESS_TOLERANCE.

On an NVIDIA L4, the NIM with its bfloat16 GPU profile and in-process on the same GPU give identical answers: 11 of 11 comparisons, one-shot and in a session, with a largest difference of 0.0.

Tests

test_a_model_runs_in_this_process.py covers what only a model in this process has. It 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.
  • Rankings and forecasts are answered in process.
  • A refusal is a StructuredDataError with a code, never a NimRequestError, and has no status: for what a holdout leaves behind, a table the model cannot read, and a task the model was not loaded for.
  • A session keeps its own copy of what it pinned.
  • 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 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.
  • 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.

Apart from the long-query fix and the typing of inference options, the NIM path behaves as before. Its tests are unchanged too, except that each one handing the client a fake transport now hands it to NimBackend. CI runs the in-process tests in the engine job, which installs CPU PyTorch and the in-process extra. The other jobs skip them.

Locally: the client suite (2007 passed) and the root suite (255 passed) pass. ruff, mypy and hygiene are clean. Against local NIMs, the existing live suites (31 passed) and both new test files on both backends (82 passed) pass.

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. 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.
  • 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, 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. The client's warning at materialize() still says those columns are dropped, which is now wrong against both. That 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, without which the workspace lock cannot resolve, so on 3.10 the extra installs nothing and in_process() says it needs Python 3.11.
  • uv.lock grows. It resolves every extra, so it gains structured-data-models, PyTorch, and PyTorch's CUDA wheels for Linux. Nothing already in it moves. Main's own lock is already behind its pyproject since Build the relational engine for macOS, Windows and 3.14 #42 added Python 3.14; this change leaves that to main rather than mixing a full relock in here.
  • 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 and distinct values per column. They protect a shared server. The limits this client checks before sending still apply, and docs/reference/limits.md says so.
  • Inside a session, a refusal keeps its own code. A NIM files a refusal while pinning or scoring against a session under SESSION_CREATE_VALIDATION_FAILED or SESSION_PREDICT_SCHEMA_MISMATCH. In process, it keeps the code of the reason, such as TASK_SCHEMA_MISMATCH. Reproducing the NIM's session codes would be the route imitation this change removes, and the quickstart says so.
  • A request is checked for what the model needs to read it, not against every rule of the contract. A name longer than the contract's 1,024 characters, or a key the contract does not name at the top level of a payload passed to Request.materialized, is answered in process, where a NIM's request model refuses it with VALIDATION_FAILED. Re-checking each contract format in process is the imitation this change removes. The quickstart says so.

Follow-ups, not in this change:

  • A table named instance_table should be refused when the request is gathered, with a message naming the table, rather than by whichever backend reads it.
  • request.py says rows to score may leave out their key, and test_entity_id_parity.py tests that by normalizing a made-up response. A NIM refuses such rows with 422 INVALID_SCHEMA, and so does in process. That predates this change, so it is not changed here.

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 gives that 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>
Comment thread qa/probe_in_process.py
Manushpm8 and others added 3 commits October 19, 2026 09:16
A relational task naming an entity its rows do not link to was scored in
process, where a NIM refuses it. In-process now makes the NIM's relational
checks in the NIM's order: related tables present, the target on the
instance table, the inference options, one link, the task options, one
entity table that is the linked one, a declared timestamp anchor, readable
instance columns and a target dtype the model can represent. The tabular
model reads its inference options after refusing related tables, as its
NIM does.

A key the contract does not name is refused wherever the NIM's request
models forbid one, and listed in the NIM's order. A schema sent as
schema_ is read as the schema, as the NIM reads it.

The parity probe skipped a value missing on one side, so a gap could pass
as agreement. It now fails one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Manush Murali <manushm@nvidia.com>
The inference block was typed after the rest of the contract and stopped
at its first problem, so a request with several was refused for one at a
time, where a NIM lists them all. Its problems now join the contract's, at
the inference field's place in the NIM's order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Manush Murali <manushm@nvidia.com>
A key named as a list is answered under the JSON of its values. In
process each value was read first, so an int64 sent as its base-10 digits
came back as a number, where a NIM answers it under the string it was
sent as. The client passes these ids through, so the difference reached
the caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Manush Murali <manushm@nvidia.com>
@Manushpm8
Manushpm8 marked this pull request as ready for review October 19, 2026 16:58
@Manushpm8
Manushpm8 requested a review from JGagacheva as a code owner October 19, 2026 16:58
adi-kumo
adi-kumo previously approved these changes Oct 19, 2026
Comment thread packages/structured-data-models-client/src/sdm_client/in_process/refusal.py Outdated
Manushpm8 and others added 8 commits October 19, 2026 14:58
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Manush Murali <manushm@nvidia.com>
The in-process transport took the payload the client had built for a NIM,
checked it the way a NIM's HTTP layer does, ran the model, and wrote a NIM's
response for the client to parse again. Keeping all of that in step with the
NIM was the cost of it.

The client now hands every prepared request to a backend. NimBackend wraps
the HTTP and Databricks transports. InProcessBackend runs the model on the
request directly and returns the client's own PredictionItems, which the
adapter puts under the caller's keys exactly as it does a NIM's answer.
Sessions are backend operations. A refusal in process is a
StructuredDataError whose code says why, with no HTTP status.

The request-contract checks, the inference typing, the status table, the
session code wrapping and the NIM's row-id format are gone. What stays is
what the model needs to read a request as it is stated, now including that
a relationship joins on columns its tables declare, which a table named
instance_table breaks.

test_every_backend_answers_alike runs the same calls against both backends:
in process in CI, and against NIMs under -m live_nim.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Manush Murali <manushm@nvidia.com>
The engine puts the query text in metadata.source_query, which the contract
caps at 1,024 characters, so every NIM refused a longer predictive query with
422 VALIDATION_FAILED. The copy is only logged, so it is cut to fit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Manush Murali <manushm@nvidia.com>
The tabular adapter forwarded a caller's inference options as given, so a
NIM typed them and the model in this process did not: run_mode 7 and
inference_config None were refused by one and answered by the other, and
use_prediction_time 'no' the reverse. Both adapters now write the options
in the contract's types, with the coercion a NIM applies, and refuse a value
that cannot be read as its type with INVALID_REQUEST before either backend
is asked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Manush Murali <manushm@nvidia.com>
A NIM answers a row keyed on several columns under the JSON of the key's
values, which the client hands back as the row's id. In process it came
back with no id, so a caller had nothing to join on. It now carries the
same id. The quickstart also says that inside a session a refusal keeps
the code of its reason, where a NIM files it under its session code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Manush Murali <manushm@nvidia.com>
The model in this process never reads metadata.source_query, so the test
passed without the cap unless a NIM was running. It now also checks the
copy the adapter sends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Manush Murali <manushm@nvidia.com>
Typing the inference options before converting them refused
use_prediction_time given as np.bool_(False), which was sent as false
before; they are now converted first. And a row scored without the key
columns its table declares failed in process after the model had run;
a NIM refuses it with INVALID_SCHEMA, and so does this process now, before
the model runs.

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

A relational table declaring a column it did not carry was fitted without
it in process, where a NIM refuses it with INVALID_SCHEMA; a context now
has to carry every column its schema declares. And a session kept the
request's own task and schema, so changing the request after fit() changed
the session under it; it now keeps a copy, as a NIM does.

The requests built by hand now run against both backends, so their codes
are measured against a NIM rather than assumed.

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

@JGagacheva JGagacheva left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Six comments inline. None is blocking. The output-option check, the lock and the hygiene regex matter most.

Comment thread packages/structured-data-models-client/src/sdm_client/in_process/backend.py Outdated
Comment thread packages/structured-data-models-client/src/sdm_client/in_process/backend.py Outdated
Comment thread scripts/check_repo_hygiene.py
Comment thread packages/structured-data-models-client/src/sdm_client/in_process/backend.py Outdated
Manushpm8 and others added 4 commits October 20, 2026 14:47
Eight review findings, each with a test that fails without its fix.

Readiness answers a closed client rather than raising at a caller polling
it as a boolean. A model this client was not opened with is a configuration,
not a schema. An output option the answer cannot be built from is refused
before the fit and the run that stand between, which take minutes on a GPU.
Releasing a session no longer waits for a model to finish: the maps and the
runs take separate locks. An answer carries the id its request went out
under, so a batch lists ids rather than empty strings. A row that is not as
wide as its columns is refused, rather than raising past the backend as an
internal error or being silently ignored. A NumPy boolean or integer is read
as the value it holds, which a hand-built payload keeps where the tabular
path is converted on its way out. The hygiene rule admits the bare public
name again without dropping its guard on every other suffix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Manush Murali <manushm@nvidia.com>
Review of the previous commit found four things in it.

Splitting the locks let close() land mid-call, and the call then put a
session, or a loaded model, back into state that closing had just released.
Both now recheck that the client is open before they write. The load itself
no longer holds the state lock: it fetches published weights, and releasing
a session should not wait for that.

A NumPy scalar was read by the Python value it holds, but np.longdouble's
is another np.longdouble, so reading one recursed until the stack ran out.
One that holds no Python value is refused like any other value that is not
a boolean.

The hygiene rule exempted a published name followed by a further suffix,
because a word boundary matches before a hyphen.

The request-id test asserted only that an id was not empty, which a
constant would have passed, and never scored a session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Manush Murali <manushm@nvidia.com>
A third review round, on the two commits before this one.

list_models() checked that the client was open and then read the model map
without holding the lock between, so a close() landing in the gap returned
an empty list, which is neither what an open client holds nor what a closed
one raises. It takes one snapshot now.

Pinning a context fits the model, which is the expensive half, and it
checked only the task before doing so. It makes the same check scoring
makes. Through the client the adapter strips the output section from a
pinned payload, so this is reachable only by a payload built by hand.

A table built by hand need not carry rows at all, and one without them was
read as empty until a later conversion fell over as an internal error. Rows
have to be there, and each has to carry one cell per column.

The hygiene test did not distinguish the fix it was written for: a word
boundary matches before a hyphen, so the pattern it replaced passed every
case the test listed. It now covers the extended names that separate them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Manush Murali <manushm@nvidia.com>
A NumPy array for the rows is refused by this check, and it does carry
rows; what it does not do is carry them as a list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Manush Murali <manushm@nvidia.com>
Comment thread packages/structured-data-models-client/src/sdm_client/in_process/backend.py Outdated
Every join also matches on instance_id, which _graph adds without the
schema naming it, so a hand-built table that neither declared nor carried
it passed every check and failed inside the model as INTERNAL_ERROR, after
the fit. Every table must now declare it, as a NIM requires.

A model id this process has no reader for raised KeyError, also reported
as INTERNAL_ERROR. It is refused like a model the client was not opened
with, naming the ones it was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Manush Murali <manushm@nvidia.com>
am-porter
am-porter previously approved these changes Oct 21, 2026
Two upstream releases came out in the last day, and each one breaks a
required check. Neither is caused by #41, and both break `main` too.
This PR caps both, so this client keeps shipping against the versions
it's tested on. Lifting either cap is a separate change.

**DuckDB 2.0.0** (released yesterday)
- The `duckdb` extra had no upper bound.
- 2.0 warns on an Arrow fetch from a DB-API cursor
(`cursor.to_arrow_table()`). The relational engine does that for every
DuckDB table and sample it reads.
- The engine suite runs with warnings as errors, so `engine` fails.
- Fix: `duckdb>=1.5.0,<2` in `structured-data-models-connectors`. DuckDB
2.0 support is going into the Rust package instead.

**pandas 3.1.0** (released this morning)
- `pd.Timestamp.min - pd.DateOffset(0)` now raises
`OutOfBoundsDatetime`. That's the anchor time the engine derives for
every graph without a time column (`rfm.py:2259`), so relational
requests over a static graph fail. This is the 24 client tests that run
in the `engine` job.
- A frame built around an integer past the float range is now held as an
object, where 3.0 refused it. `_wire_frame` relied on that refusal
(`test_a_number_too_large_to_hold_is_not_a_description`).
- Fix: `pandas<3.1` in the client and the engine, the two packages whose
code breaks.

`uv.lock` changes only in the matching `requires-dist` specifiers. The
locked versions were already DuckDB 1.5.6 and pandas 3.0.6.

Verified locally: the CI `test` suite failed 1 test under pandas 3.1.0,
and with the cap it resolves 3.0.6 and passes, 2218 passed and 0 failed.
Hygiene, `ruff check` and `ruff format --check` pass. Both pandas
behaviours were reproduced directly on 3.0.6 and 3.1.0.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

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

@JGagacheva JGagacheva left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Five comments inline. None is blocking. Only the use_prediction_time question in relational.py needs an answer before merging.

Comment thread packages/structured-data-models-client/src/sdm_client/in_process/tables.py Outdated
Comment thread packages/structured-data-models-client/src/sdm_client/in_process/backend.py Outdated
Comment thread packages/structured-data-models-client/src/sdm_client/in_process/answers.py Outdated
JGagacheva
JGagacheva previously approved these changes Oct 22, 2026

@JGagacheva JGagacheva left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Manushpm8 I added some comments but I think they're minor cleanup details/checks. I'll approve and let you decide how to handle those

A key cell that could not be read as its type, and any integer past the
int64 range, escaped as INTERNAL_ERROR, where a NIM refuses all of them
with VALIDATION_FAILED. Keys are now read under the same refusal as every
other column, which also catches the overflow pyarrow raises.

A row with one cell too many or too few is INVALID_SCHEMA, as a NIM files
it, rather than VALIDATION_FAILED. Run against a NIM, the parity suite had
never checked those two cases.

INTERNAL_ERROR now covers only what a NIM would do on its side, and no
longer says the model failed. What the client makes of the answer is the
same code on both backends, so a fault there is raised as itself.

The parity suite now holds a session asked for prediction time to the
INVALID_REQUEST a NIM gives it. The quickstart says a session in process
never expires. A quantile check that could no longer be reached is gone,
and the two in-process suites share their models and tables.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Manush Murali <manushm@nvidia.com>
Sharing the models made the parity suite look a model up only when a test
first asked for it, which is after the test has gathered its request. A job
without the extras then failed on the gathering, where before it skipped.
The suite asks for structured-data-models up front again.

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

This comment was marked as low quality.

@Manushpm8
Manushpm8 merged commit 89ef7b8 into main Oct 26, 2026
20 checks passed
@Manushpm8
Manushpm8 deleted the run-models-in-process branch October 26, 2026 20:15
Manushpm8 added a commit that referenced this pull request Oct 26, 2026
main now carries #41, which held duckdb below 2 and pandas below 3.1.
This branch fixes what those releases broke, so the merge keeps it
uncapped: the three requirements, the lock's matching specifiers and the
changelog drop the holds, and the two Fixed sections become one. In
ci.yml each conflict was the same wheel-by-file change, and this
branch's stricter version is kept alongside #41's in-process engine job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Manush Murali <manushm@nvidia.com>
Manushpm8 added a commit that referenced this pull request Oct 27, 2026
…#51)

## Summary

DuckDB 2.0.0 and pandas 3.1.0 broke CI on `main` and on every branch
based on it. #41 has since brought #47's holds (`duckdb<2`,
`pandas<3.1`) to `main`. This PR fixes what each release broke instead,
and takes the holds back out: no install caps either dependency, and
`uv.lock` differs from `main` only in those three specifiers.

- **DuckDB 2.0** warns that Arrow output from a DB-API result makes an
extra copy of the rows, and the engine's suite runs warnings as errors.
Two places now read through a relation,
`con.sql(query).to_arrow_table()`:
  - the engine's six DuckDB reads;
  - `read_table` over a caller's DuckDB connection.

DuckDB's warning names that call as the replacement, and 1.5 already has
it.
- **pandas 3.1** raises `OutOfBoundsDatetime` on `Timestamp.min -
DateOffset(0)`. A graph without a time column is anchored at
`Timestamp.min`, and a static query steps by `DateOffset(0)`, so every
relational prediction over such a graph failed.
- `shift_time` leaves a timestamp where it is when the offset moves
nothing. Every scalar anchor shift in `rfm.py`, and the base sampler's
window check, now go through it.
- The samplers' own shifts work on whole columns, which 3.1 handles, so
they are unchanged.
- **pandas 3.1** also builds a frame around an integer past the float
range, where earlier releases refused one. A request's description
relied on that refusal to leave out the table carrying it. `_wire_frame`
now refuses such a table itself, on every pandas release.

Every CI and release job now installs the wheels CI built by file name.
With `--find-links` plus a name and version, pip resolves PyPI's release
of that version as readily as the wheel built in the run, so a job could
test or describe the published 1.0.0 instead.

- **GitHub CI `build` dry-run, `installed-wheel` and `sbom`.** This PR
cherry-picked these from `586882c57`, then tightened them:
  - The pure-Python wheels are named in full.
  - The `build` job checks there is exactly one wheel per distribution.
- `installed-wheel` compares each distribution's `direct_url.json` with
`dist/` exactly.
- **The engine smoke test** in CI, in the release workflow and in the
internal pipeline. `PIP_FIND_LINKS` is replaced by a cibuildwheel
`before-test` that installs `_sibling_wheels/*.whl` by file name before
the engine.
- **The internal pipeline's `build` dry-run, `installed-wheel` and
`sbom`.** Its `installed-wheel` now makes the same `direct_url.json`
check.
- **The release SBOM.** The pure-Python wheels are installed by file
name, and the engine from `dist/` with `--no-index`.

Not in this PR: putting a development version on `main` between
releases. That changes how a release is cut, so it is a separate
proposal.

## Verification

- **Both dependency sets, run locally on Python 3.12 with #41 merged
in:** pandas 3.1.0 with DuckDB 2.0.0, and pandas 3.0.6 with DuckDB
1.5.6. On each:
- the engine suite passes 2588 tests, the client suite 1960, the
connectors suite 300 and the root suite 267;
- #41's in-process tests need PyTorch, so they skip locally; CI's engine
job runs them;
  - no run raises a DuckDB warning.
- **Each new test fails without its fix:**
- On pandas 3.1, a graph without a time column fails to build its task
table.
  - On DuckDB 2.0, `read_table` over DuckDB warns.
- A table holding a string and an integer past the float range was
described rather than left out; this one fails on both pandas releases.
- `shift_time` moves by `DateOffset(months=1, days=-31)`, an offset that
cancels on some dates.
- **The CI install commands, run locally against wheels built from this
branch:**
- Every first-party wheel comes from `./dist/` or `./_sibling_wheels/`.
  - The engine's exact pins are already met when it installs.
- The `direct_url.json` check refuses a PyPI install, and a `dist/`
directory somewhere else.
- **CI:** 20 of 20 checks pass on 215a1e6.
- The engine job runs the client suite with #41's in-process tests, on
PyTorch's CPU build: 2086 passed, none skipped.
  - The test jobs resolve pandas 3.1.0 and DuckDB 2.0.0.
- Every engine-wheels row installs its siblings from `_sibling_wheels/`
for all five Python versions, and none from PyPI.
- **Codex review:** the last round, on 215a1e6, found nothing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Manush Murali <manushm@nvidia.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.

4 participants