Repository navigation
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>
There was a problem hiding this comment.
packages/structured-data-models-client/src/sdm_client/in_process/relational.py:61
P2: Mismatched entity_table_names is answered instead of refused
entity_table_names is never checked, so an envelope naming one entity but linking another is scored instead of refused as a NIM does. Validate it against the single task link before building tensors, with the NIM's error codes.
There was a problem hiding this comment.
Fixed in e38b57a. 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, exactly one entity table that is the linked one and not instance_table, a declared timestamp anchor, readable instance columns and a representable target dtype. Each refusal uses the NIM's code and message. I measured these against the NIM on hand-written variations, one-shot and in a session, and all of them match. The same pass found unknown keys being accepted where the NIM's request models forbid them, so those are refused now too, in the NIM's order.
There was a problem hiding this comment.
qa/probe_in_process.py:42
P2: Parity probe reports agreement when values are missing
np.nanmax drops NaN gaps, so a value missing on one side counts as a match, and an all-NaN column reports 0.0. Fail when the NaN masks differ, then compare only the finite values.
There was a problem hiding this comment.
Fixed in e38b57a. A value missing on one side now fails the comparison. The largest gap is taken only over values that differ, so two missing values agree and an infinity against a number fails. I re-ran the CPU comparison with the fix: still 0.0 everywhere, with no missing values on either side.
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>
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>
The overview and the client's docstrings said every answer comes from a NIM. The quickstart now says, in its own paragraph, how a model in this process refuses, including that a hand-built request is checked for what the model needs rather than against every contract rule, so a key the contract does not name is ignored there. The session test now changes a declared column after fit(), so it fails without the backend's copy of the schema as well as without the other two, and the long-query test checks the logged copy is the query's own start. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Manush Murali <manushm@nvidia.com>
Their docstrings described only a NIM: an in-process client uploads nothing, holds a pinned context in this process until it closes, and lists the models it was given. The quickstart's promise of a NIM's exact answers on the same GPU now names the condition it needs, the published weights. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Manush Murali <manushm@nvidia.com>
The cases for a declared column left out, and an undeclared one carried, changed both halves of the request, so the check on the rows to score refused them even without the check on the context. Each now changes one half, and fails without that half's check. FittedModel's docstring also says what happens to the rows in process. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Manush Murali <manushm@nvidia.com>
A name longer than the contract's 1,024 characters is refused by a NIM's request model and answered in process, as a key the contract does not name already was. Re-checking each contract format in process is the imitation this change removes, so the quickstart now says so once, for both. The client module's docstring also stops saying every client has an endpoint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Manush Murali <manushm@nvidia.com>
Two reference pages said models() is always read from an endpoint. A client opened with SDMClient.in_process() has none, and lists the models it can run. 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
left a comment
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.
| self._models.clear() | ||
| self._closed = True | ||
|
|
||
| def health_ready(self) -> bool: |
There was a problem hiding this comment.
health_ready() calls _require_open(), so after close() it raises INVALID_CONFIGURATION instead of returning False. A caller polling health as a bool gets an exception. Please return not self._closed here.
There was a problem hiding this comment.
Fixed in 638f15f. health_ready() returns not self._closed, so a caller polling it as a boolean gets one either way. Test: test_a_closed_client_says_it_is_not_ready_rather_than_raising, which raises INVALID_CONFIGURATION without the fix.
| return classified(answer, context.encoded, context.labels, ids, fields) | ||
| output = payload.get('output') or {} | ||
| statistic = output.get('prediction_statistic') or 'mean' | ||
| if statistic not in ('mean', 'median'): |
There was a problem hiding this comment.
prediction_statistic and quantile_levels are checked here in _items, which runs after _fit and _run (lines 129-130, and 183-184 for sessions). A typo like prediction_statistic='mode' costs a full fit and predict, possibly minutes on a GPU, before it's refused. Please check them in _fields, or anywhere before the model runs.
There was a problem hiding this comment.
Fixed in 638f15f. Both are now checked in _fields, which runs before _fit and _run on the one-shot and the session path alike.
One thing the fix turned up: quantile_levels could never reach the backend at all. Both adapters rebuild the output section when they prepare a call — _output_for in adapters/relational.py returns {"fields": ..., "prediction_statistic": ...} and the tabular adapter sets {"fields": ...} — so a caller has no way to ask for levels other than the nine defaults, even through Request.materialized. That makes the levels check unreachable in both places. I kept it next to the statistic because _levels is what feeds the answer, but the test covers only prediction_statistic, which is the half that is reachable. The unreachable half looks like a real gap against the contract and I have left it out of this change; worth its own issue.
Test: test_an_output_option_is_refused_before_the_model_runs, which asserts the model was never fitted. Without the fix it returns INTERNAL_ERROR after the run.
| return served, model | ||
|
|
||
| @contextlib.contextmanager | ||
| def _running(self) -> Iterator[None]: |
There was a problem hiding this comment.
_running holds self._lock for the whole fit and predict. Every call on one client is serialized, including delete_session and close(). client.py:14 says a client can be used "from several threads". With in-process, a second thread waits for the first thread's whole model run. Please either hold the lock only around the session and model maps, or document that an in-process client runs one call at a time.
There was a problem hiding this comment.
Fixed in 638f15f and f8ec557. There are now two locks: _state (an RLock) over the session and model maps and the closed flag, and _runs (a Lock) over model execution. delete_session, close and health_ready take _state only, so they no longer wait for a model. Order is always _runs then _state, never the reverse.
The split had two consequences I had to fix in f8ec557, both found in review:
close()could now land mid-call, and the call would then write a session, or a loaded model, back into state that closing had just released. Both writes recheck that the client is open._servingheld_stateacross_loaded(), which fetches published weights, so the first prediction still blocked everything. The load happens outside the lock now;_runsalready makes it happen once.
The class docstring states the contract: one model runs at a time, and letting go of what the client holds is not a model run. Tests: test_releasing_a_session_does_not_wait_for_a_model_to_finish and test_closing_beats_a_call_that_was_already_running.
| raise StructuredDataError( | ||
| f'{model_id} is not served by this client, which was opened ' | ||
| f'with {self.list_models()} only', | ||
| code='INVALID_SCHEMA', |
There was a problem hiding this comment.
A model the client wasn't opened with is refused as INVALID_SCHEMA, but nothing is wrong with the schema. Code that branches on error.code will treat it as a schema problem. Please use the code a NIM uses for an unserved model, or INVALID_CONFIGURATION.
There was a problem hiding this comment.
Fixed in 638f15f. It is INVALID_CONFIGURATION now, which is what the two neighbouring refusals at that boundary use — a closed client, and a model left in training mode. UNKNOWN_MODEL is taken, and means a model id the contract does not carry at all, which is a different thing. Test: test_a_model_this_client_was_not_opened_with_is_a_configuration.
| # look it up, and which build answers is not the client story. The model | ||
| # package, structured-data-models, is public and is what the in-process | ||
| # extra installs, so it is named like any other dependency. | ||
| re.compile(r'\bkumoml', re.IGNORECASE), |
There was a problem hiding this comment.
The deleted structured-data-models regex did two things. It refused the bare name, and it refused every structured-data-models-<suffix> that isn't one of the five public distributions, such as structured-data-models-nim. Removing it to allow the bare name also drops the second guard. Please keep a pattern for the unknown suffixes, e.g. \bstructured-data-models-(?!(?:client|api|pql|connectors|relational-engine)\b)\w.
There was a problem hiding this comment.
Fixed in 638f15f, and tightened in f8ec557. The pattern is back as \bstructured-data-models-(?!(?:client|api|pql|connectors|relational-engine)(?![\w-]))\w.
I used (?![\w-]) rather than \b after the suffix, because \b matches before a hyphen and so would have exempted structured-data-models-client-nim and structured-data-models-api-internal, whose full suffixes are not published either. The original pattern had the same hole.
Test: test_only_a_published_distribution_may_be_named in tests/test_release_contract.py, parametrized over the bare name, the five published suffixes and two that are not. It assembles the names rather than spelling them, because spelling one is the thing the rule refuses — which the hygiene check caught when I first wrote it literally.
| return value | ||
| if isinstance(value, str) and value.lower() in _TRUE | _FALSE: | ||
| return value.lower() in _TRUE | ||
| if isinstance(value, (int, float)) and value in (0, 1): |
There was a problem hiding this comment.
np.bool_ and np.integer aren't bool, int or float, so np.False_ or np.int64(0) for use_prediction_time is refused with INVALID_REQUEST. The tabular path only works because json_ready converts first. A hand-built relational payload doesn't get that conversion. Please accept numbers.Integral and np.bool_, or convert before this check.
There was a problem hiding this comment.
Fixed in 638f15f, with a follow-up in f8ec557. _boolean reads a NumPy scalar by the Python value it holds, so np.False_ and np.int64(0) are read rather than refused.
Review caught that the first version recursed: np.longdouble(1).item() returns another np.longdouble, so _boolean(np.longdouble(1)) ran until the stack did. A scalar holding no Python value is now refused like any other non-boolean.
Tests: test_a_numpy_boolean_built_by_hand_is_read_as_the_boolean_it_holds on both backends through a hand-built relational payload, which is the path with no json_ready conversion, and test_a_numpy_scalar_holding_no_python_value_is_refused_not_recursed_on, which raises RecursionError without the second fix.
| answer, | ||
| fields, | ||
| ) | ||
| return adapter.answered( |
There was a problem hiding this comment.
adapter.answered() is called without request_id here and in session_predict(), so in-process results always have request_id == "". For batched calls, metadata["batch_request_ids"] becomes a list of empty strings even though each prepared payload has a request ID. Could both paths pass through the payload's request ID and add a result-level test?
There was a problem hiding this comment.
Fixed in 638f15f. Both paths pass request_id=_request_id(payload), read from the payload's own metadata.request_id, so a batch lists real ids.
The first test I wrote for it only asserted the id was non-empty, which a constant would have passed, and never scored a session — fixed in f8ec557. It now pins the adapter's id generator and asserts the answer carries exactly that, on the one-shot path and on a session scoring call, where the id is the scoring call's and not the one the context was pinned with.
| answered without and one not declared would be read as nothing. The rows | ||
| to score may leave out ``unasked``, the target they ask about. | ||
| """ | ||
| named = list(table['columns']) |
There was a problem hiding this comment.
This checks the declared column names but not the length of each rows entry. In a hand-built Request.materialized() payload, a short row raises IndexError in values() and becomes INTERNAL_ERROR; an overlong row is silently ignored. Could this reject rows whose cell count differs from len(columns) with a validation error?
There was a problem hiding this comment.
Fixed in 638f15f. check_columns refuses a row whose cell count differs from the number of columns, with VALIDATION_FAILED, which is what the repeated-column check beside it uses. It stops at the first such row rather than scanning the whole table.
Tests: two more cases in test_a_request_built_by_hand_is_refused_where_it_states_another_question, run on both backends. Without the fix the short row gives INTERNAL_ERROR and the overlong row is answered silently, which is exactly the pair you described.
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>
| fitted = copy.copy(model) | ||
| related = None if context.related is None else context.related.to(device) | ||
| with _autocast(device): | ||
| fitted.fit( |
There was a problem hiding this comment.
Do fit and predict here need torch.inference_mode() (or no_grad())? Only autocast is applied, so if the loaded weights have requires_grad=True (the default for an nn.Module), every call builds an autograd graph and keeps activations for backward. On large relational contexts on a GPU that can run out of memory, or run much slower than a NIM. The .detach() in answers suggests the outputs do carry grad.
There was a problem hiding this comment.
No change needed: sdm does this itself. fit runs under inference_mode("no_grad") and predict under torch.inference_mode() (sdm/models/base.py), unless the model is training, which _serving refuses, or a callback needs grad, and none is passed. Measured: the raw output has requires_grad=False and is_inference()=True while the weights have requires_grad=True. The .detach() matches the NIM's output_array.
| import torch | ||
|
|
||
| device = _placement(model) | ||
| fitted = copy.copy(model) |
There was a problem hiding this comment.
copy.copy is shallow: the copy shares the module's _parameters, _buffers and _modules dicts with the original and with every other session. If fit stores its context as a buffer or submodule (through nn.Module.__setattr__), fitting session B changes session A and the caller's own model, which breaks the docstring's promise that a session never disturbs another. Does sdm's fit only assign plain attributes?
There was a problem hiding this comment.
No change needed: yes. fit assigns only self._cache (sdm/models/base.py:329, cleared at :492), a Cache mapping rather than a module, parameter or buffer, so it lands in the copy's own __dict__. The one shared mutable is _transfer_streams, a per-device CUDA stream cache, which is safe to share because runs are serialized. Measured: fitting session B on targets scaled by −10 left session A bit-identical, and the original's _cache stayed None.
| ) -> list[str | None]: | ||
| r"""Each row's ``instance_id``, which the client matches its answer | ||
| to before putting the entity's own key on it.""" | ||
| return [str(value) for value in values(table, INSTANCE_ID)] |
There was a problem hiding this comment.
If a hand-built Request.materialized payload lacks instance_id (or a join column), the column lookup raises a bare ValueError after the full fit and run, and _running reports it as INTERNAL_ERROR ("the model failed: ValueError: 'instance_id' is not in list"). Could this be checked up front and refused as INVALID_SCHEMA, as the module docstring promises?
There was a problem hiding this comment.
Fixed in 26d0907. Every table must now declare instance_id, refused as INVALID_SCHEMA before the fit, as a NIM's _validate_instance_id_column does. A missing join column was already refused up front; instance_id slipped through because _graph adds it to every join without the schema naming it.
| releasing a session should not wait for that, and a client closed | ||
| while it ran does not keep what it loaded. | ||
| """ | ||
| served = _SERVED[model_id] |
There was a problem hiding this comment.
An unknown payload['model'] raises KeyError here, which surfaces as INTERNAL_ERROR ("the model failed: KeyError"). It would be clearer to refuse it as an unsupported model and name the models served.
There was a problem hiding this comment.
Fixed in 26d0907. Refused as INVALID_CONFIGURATION, naming the models served, like a model the client was not opened with. Through the client it cannot happen today, since the adapter overwrites payload['model'] from the closed MODELS; it could once a model is added there without an in-process reader, which is what the test simulates.
|
|
||
| def _levels(payload: Mapping[str, Any]) -> Sequence[float]: | ||
| levels = (payload.get('output') or {}).get('quantile_levels') | ||
| return levels or QUANTILE_LEVELS |
There was a problem hiding this comment.
levels or QUANTILE_LEVELS replaces an explicit empty quantile_levels: [] with the nine defaults, so the caller gets quantiles they didn't ask for. Should an empty list be refused (or return none), as a NIM would?
There was a problem hiding this comment.
No change needed: this matches the NIM, which does the same requested.quantile_levels or list(_DEFAULT_QUANTILES) (sdm_nim/container/decoding.py), and the contract sets no minItems. It is also unreachable, since _output_for rebuilds output without quantile_levels. Refusing [] here alone would make the two backends disagree.
| prediction=labels[int(np.argmax(row))], | ||
| probabilities=( | ||
| { | ||
| str(label): float(row[position]) |
There was a problem hiding this comment.
For a boolean target with no declared classes, the labels are Python True/False, so str(label) gives 'True'/'False', while a NIM returns 'true'/'false'. to_pandas() then has probability_True in process versus probability_true from a NIM, and code that reads by key breaks when switching backends.
There was a problem hiding this comment.
No change needed: a NIM gives 'True'/'False' here too. With no declared classes _wire_labels returns the encoded booleans and decode_classification keys them by str(label) (sdm_nim/container/conversion.py, decoding.py). With declared classes, as the client sends for a PQL binary target, both answer 'true'/'false' and the adapter maps both alike. The live NIM is down (503), so this is from the NIM's main, not a live run.
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>
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
left a comment
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.
| cells = values(table, column) | ||
| dtype = dtypes[column] | ||
| if column in keys and dtype in _NUMBERS: | ||
| arrays.append(pa.array(_whole(cells, dtype, column), pa.int64())) |
There was a problem hiding this comment.
Key columns skip the error handling that _array gives other columns. _whole calls read_cell outside any try, so an int64 join key holding 'abc' raises a plain ValueError from int(cell), and _running reports it as INTERNAL_ERROR ("the model failed: ValueError…"). The pa.array(..., pa.int64()) call is uncaught too, e.g. for text in a float64 key or a value past the int64 range. Only hand-built Request.materialized payloads reach this. Please refuse these as VALIDATION_FAILED, as a non-key column is.
There was a problem hiding this comment.
Fixed in 9efa467. Keys are now read under the same refusal as every other column, so text in an int64 key, or a value past the int64 range, is VALIDATION_FAILED. A non-key int64 past the range had the same gap, since pyarrow raises OverflowError there and nothing caught it, so that is fixed too. A NIM gives VALIDATION_FAILED for all three; I checked them against one, and the parity suite now holds both backends to it.
| raise | ||
| except Exception as error: | ||
| raise StructuredDataError( | ||
| f'the model failed: {type(error).__name__}: {error}', |
There was a problem hiding this comment.
_running wraps the whole call, including reading the request and adapter.answered(...), so any unexpected client-side exception (a missing payload key, a bug in _named_entities) is reported as "the model failed". Could the message be neutral, or the wrap cover only _fit and _run?
There was a problem hiding this comment.
Both, in 9efa467. The wrap now ends before adapter.answered(...), so a fault in the client's own code, _named_entities included, is raised as itself, as it is on the NIM path. What stays wrapped is what a NIM does on its side: reading the request, fitting and running the model, and reading what it returned. The message is now "answering in this process failed: …".
| 'this request does not name', | ||
| code='TASK_SCHEMA_MISMATCH', | ||
| ) | ||
| if settled: |
There was a problem hiding this comment.
_SESSION_PINNED is ('model', 'task', 'schema', 'context'), so a session's context is always fitted without prediction time, and every session_predict with use_prediction_time=True is refused here. So a relational fit() asked with prediction time can never be scored in process. Does a NIM behave the same way? The parity suite only tests use_prediction_time through tabular predict(), so this case isn't covered on either backend.
There was a problem hiding this comment.
Yes, a NIM does the same. SessionCreateRequest has no inference field, so a NIM compiles every session without prediction time, and refuses a prediction that asks for it with INVALID_REQUEST ("use_prediction_time is settled when a session is compiled…"). I checked it against a NIM, and the parity suite now covers it on both backends (9efa467).
| session_id = uuid.uuid4().hex | ||
| with self._state: | ||
| self._require_open() | ||
| self._sessions[session_id] = _Session( |
There was a problem hiding this comment.
In-process sessions never expire. Each keeps a fitted copy with its context cache, possibly in GPU memory, so a FittedModel not used as a context manager holds that until the client closes, where a NIM session times out. One sentence in the quickstart would help notebook users.
There was a problem hiding this comment.
Added to the quickstart in 9efa467, beside the note that a deployment's limits don't apply in process: the session holds its fitted context, in GPU memory when the model is on one, until it or the client is closed.
| The point prediction is the distribution's mean or median, and each | ||
| quantile asked for is interpolated between the two predicted around it. | ||
| """ | ||
| if 'quantiles' in fields and not all(0.001 <= x <= 0.999 for x in levels): |
There was a problem hiding this comment.
Nit: this repeats the quantile-level check that _check_regression_output now makes before the run, and it can't be reached. Also, people(), graph(), _quiet and the random-weight model fixtures are copied between test_a_model_runs_in_this_process.py and test_every_backend_answers_alike.py; a shared conftest.py would hold them once.
There was a problem hiding this comment.
Both done in 9efa467. The check is gone, and the models and tables now live once, in tests/conftest.py and tests/sample_tables.py.
JGagacheva
left a comment
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>
|
|
||
|
|
||
| def _numbers(output: Any) -> Any: | ||
| array = np.asarray(output.numerical.detach().cpu().numpy()) |
There was a problem hiding this comment.
On a GPU, _fit and _run run the model under torch.autocast with bfloat16 (or float16). NumPy has no bfloat16, so if output.numerical comes back in that dtype, .numpy() raises TypeError: Got unsupported ScalarType BFloat16. _running would then report every prediction as INTERNAL_ERROR. CUDA is the default device whenever it's available, so this would hit the default GPU setup.
In structured-data-models 0.2.1, predict() calls transform_output with autocast disabled, but that doesn't guarantee the result is float32. I couldn't confirm the dtype from the source. The PR description says the L4 comparison hasn't been re-run since the rework.
Suggest casting before converting, which is harmless if the output is already float32:
array = np.asarray(output.numerical.detach().float().cpu().numpy())and re-running the L4 comparison (qa/probe_in_process.py) before merge.
There was a problem hiding this comment.
Re-ran it on an NVIDIA L4 at f6dd0da: both NIMs with their shipped bfloat16 GPU profile, and in process on the same GPU (structured-data-models 0.2.1, torch 2.14.1+cu130).
output.numericalarrives astorch.float32under bfloat16 autocast, checked on every answer.qa/probe_in_process.py: 11 of 11 comparisons identical, largest difference 0 (Adult, Wine, Titanic, Bank Marketing, Students; three Northwind queries, one-shot and in a session).- This PR's tests on the same machine: 118 passed, and 46 passed under
-m live_nimagainst the GPU NIMs.
A NIM converts its output with the same .detach().cpu().numpy(), so a bfloat16 output would fail there as well. I've left the conversion as it is so the two stay identical, and updated the description.
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