Repository navigation
Run the models in this process, through the same calls as a NIM - #41
Merged
Merged
Conversation
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>
adi-kumo
reviewed
Oct 19, 2026
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
marked this pull request as ready for review
October 19, 2026 16:58
adi-kumo
previously approved these changes
Oct 19, 2026
am-porter
reviewed
Oct 19, 2026
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
reviewed
Oct 20, 2026
JGagacheva
left a comment
Collaborator
There was a problem hiding this comment.
Six comments inline. None is blocking. The output-option check, the lock and the hygiene regex matter most.
am-porter
reviewed
Oct 20, 2026
am-porter
reviewed
Oct 20, 2026
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>
JGagacheva
reviewed
Oct 21, 2026
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
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
reviewed
Oct 22, 2026
JGagacheva
left a comment
Collaborator
There was a problem hiding this comment.
Five comments inline. None is blocking. Only the use_prediction_time question in relational.py needs an answer before merging.
JGagacheva
previously approved these changes
Oct 22, 2026
JGagacheva
left a comment
Collaborator
There was a problem hiding this comment.
@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
reviewed
Oct 22, 2026
JGagacheva
approved these changes
Oct 24, 2026
adi-kumo
approved these changes
Oct 24, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.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):NimBackendsends 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".InProcessBackendruns the model on it directly, with no JSON and no HTTP. It returns the client's ownPredictionItems, 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_predictanddelete_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
StructuredDataErrorwhose code says why, from the codes a NIM uses, such asUNSUPPORTED_OUTPUT. It carries no HTTP status, since nothing was sent, andNimRequestErrorstays 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.pyruns the same calls against both backends. In process it always runs, with small random-weight models. Against NIMs it runs under-m live_nimwithSDM_TABULAR_URLandSDM_RELATIONAL_URLset. The values differ, since the weights do. Both are held to everything else a caller reads:use_prediction_timespelled'no', or given as a NumPy boolean, asks for nothing, and a value that cannot be read as its type is refused;LIST_DISTINCT, an inference option the model does not act on, a text column, a table namedinstance_table, and rows scored without their key;Request.materializedthat 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:
metadata.source_queryfor logging, and the contract caps that field at 1,024 characters, so a NIM answered422 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.instance_tablecollides 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 withINVALID_SCHEMA. In process it was answered, so the backend now refuses a join on an undeclared column, with the same code.run_mode: 7andinference_config: Nonewere refused by a NIM and answered in process, anduse_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 withINVALID_REQUEST. For a NIM user, such a value is now refused before it is sent, rather than with422 VALIDATION_FAILED.INTERNAL_ERROR, after the model had run. A NIM refuses them withINVALID_SCHEMA, and in process now does too, before the model runs.INTERNAL_ERRORwhen it sat in a key column or held an integer past the int64 range. A NIM refuses it withVALIDATION_FAILED, and in process now does too.VALIDATION_FAILEDin process and isINVALID_SCHEMAfrom a NIM. In process now gives the NIM's code.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:
kumo-tabularkumo-relationalRANK TOP 3, a session scored twicekumo-tabular); Northwind counts, sums, binary and integer regression (kumo-relational), one-shot and in a sessionqa/probe_in_process.pyrepeats 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, setSDM_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.pycovers what only a model in this process has. It uses small random-weight models, so it needs no network and no GPU:StructuredDataErrorwith a code, never aNimRequestError, 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.MODEL_NOT_READY, with the original error as its cause.in_process()raisesMissingExtraErrornamingin-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 thein-processextra. 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
structured-data-models-client1.0.0 reached PyPI while this was in review. After that,--find-links distwith==1.0.0resolved the published release instead of the wheel CI had just built.installed-wheel,sbomand thebuilddry-run now name each wheel file, andinstalled-wheelasserts that all four distributions came fromdist/. This affects every pull request against the client from now on.structured-data-modelsas 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.materialize()still says those columns are dropped, which is now wrong against both. That is left for a follow-up.python_version >= '3.11'marker, without which the workspace lock cannot resolve, so on 3.10 the extra installs nothing andin_process()says it needs Python 3.11.uv.lockgrows. 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.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, anddocs/reference/limits.mdsays so.SESSION_CREATE_VALIDATION_FAILEDorSESSION_PREDICT_SCHEMA_MISMATCH. In process, it keeps the code of the reason, such asTASK_SCHEMA_MISMATCH. Reproducing the NIM's session codes would be the route imitation this change removes, and the quickstart says so.Request.materialized, is answered in process, where a NIM's request model refuses it withVALIDATION_FAILED. Re-checking each contract format in process is the imitation this change removes. The quickstart says so.Follow-ups, not in this change:
instance_tableshould be refused when the request is gathered, with a message naming the table, rather than by whichever backend reads it.request.pysays rows to score may leave out their key, andtest_entity_id_parity.pytests that by normalizing a made-up response. A NIM refuses such rows with422 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