One Python client for NVIDIA's structured-data foundation model NIMs: what the packages are, why they are split the way they are, and which decisions were made deliberately.
This records design rationale. For interfaces, the shipped implementation is authoritative. See the quickstart and prediction output.
The contract will not let a new model exist.
PredictionRequest.model and SessionCreateRequest.model are a closed
enum containing only kumo-tabular and kumo-relational, so a
model="some-future-model" escape hatch cannot be sent. The response's model
field is an open string (maxLength: 1024), so the contract contradicts
itself.
This does not affect anything shipping. kumo-tabular and
kumo-relational are both in the enum, so both work today and neither needs a
contract change. What the enum forecloses is a
model="some-future-model" escape hatch, which is deferred along with the
models it would have served (§9). An unrecognised id is refused client-side
with a message naming the contract change it would need, rather than being sent
and rejected.
If a third model is ever wanted, a small MR on structured-data-api opens the
field. Until then this is a note, not a task.
The model is an argument, not a method. Both models use the same call.
from sdm_client import (
SDMClient,
Schema,
PredictiveQuery,
KumoRelational,
KumoTabular,
)
schema = Schema.from_data({'users': users_df, 'orders': orders_df})
query = PredictiveQuery(
'PREDICT SUM(orders.price, 0, 30, days) FOR EACH users.user_id', schema
)
request = query.materialize(indices=[1, 2, 3])
with SDMClient(url='http://localhost:8000') as client:
result = client.predict(request, model=KumoRelational())A one-table question can use either model. The models read requests differently, so each request is gathered for one of them. Specify the model when it is not the default:
schema = Schema.from_data({'users': users_df})
question = PredictiveQuery('PREDICT users.churn FOR EACH users.user_id', schema)
client.predict(question.materialize(indices=[1, 2]), model=KumoTabular()) # ✅ the default
client.predict(
question.materialize(indices=[1, 2], model=KumoRelational()), # ✅ gathered as a graph
model=KumoRelational(),
)Gather and send for the same model. A request built for one is refused by the other rather than reshaped, because the relational gather samples a neighbourhood the tabular envelope has nowhere to put.
A relational aggregation is relational-only. The TABULAR profile rejects
related tables before anything is sent. TABULAR also refuses a target with a
time window, a WHERE filter on the entity, an ASSUMING clause, and a target
that is not a column of the table.
Two ways to build the one Request:
PredictiveQuery(text, schema).materialize(...) ─┐
├─► Request ─► client.predict(request, model=…)
Request(context=…, predict=…, target=…) ─┘
Four rules the whole design follows:
| Rule | Consequence |
|---|---|
| The model is an argument, never a method | client.predict(req, model=…). No client.relational(). |
One Request for every model |
Tabular is the one-table case, not a second system. |
| One return type | predict() always returns a PredictionResult. Never a DataFrame-or-Explanation union. |
| A model declares itself as data | Adding a model is a declaration plus a small adapter. |
Five distributions in one repo, released together on one version.
| Distribution | Import | Kind | Holds |
|---|---|---|---|
structured-data-models-client |
sdm_client |
pure python | The public API: client, Schema, Request, PredictiveQuery, model handles, sessions and results. Adapters and transport live here too, as implementation details rather than public API. |
structured-data-models-api |
sdm_api |
pure python | The wire only: OpenAPI-generated bindings and the types the NIM speaks. |
structured-data-models-pql |
predictive_query |
pure python | The language: grammar, parser, validator, AST, model profiles, and the schema types PQL speaks. |
structured-data-models-connectors |
sdm_connectors |
pure python | Reading source tables: SQLite, DuckDB, PostgreSQL, Snowflake, Databricks, S3. |
structured-data-models-relational-engine |
sdm_relational_engine |
native | The relational model's heavy client-side work: graph building, samplers, the C++ neighbor sampler. |
structured-data-models-api ◄──┐
├── structured-data-models-client ──► structured-data-models-connectors
structured-data-models-pql ◄──┘ │
└──► structured-data-models-relational-engine
(only via the [relational] extra, and it
depends on api, pql and connectors too)
structured-data-models-api depends on nothing.
Only structured-data-models-relational-engine is native. Everything else installs anywhere;
the engine installs where it has published a wheel, which is Linux (x86-64 or
aarch64), macOS (Apple silicon or Intel) and 64-bit Windows. Both models work
on a Mac.
pip install structured-data-models-client # tabular, PQL, transport everywhere
pip install 'structured-data-models-client[relational]' # adds the native relational engine
pip install 'structured-data-models-client[snowflake]' # one backend (also duckdb/sqlite/postgres/databricks/s3)
pip install 'structured-data-models-client[all]' # relational + every backend
pip install 'structured-data-models-client[in-process]' # runs the models in this process[explain] and [relbench] are the engine's own extras, not the client's, so
they are asked for as structured-data-models-relational-engine[explain].
Neither is in [all]: [explain] posts row data to a third-party LLM, so
installing it is deliberate.
The language is a distribution of its own because two packages need it and
neither needs the other. structured-data-models-client resolves a query against a schema;
structured-data-models-relational-engine reads the AST it produces. Putting the parser in either
one would make the other depend on a driver or a client it has no use for.
It carries the parser, the AST, the validators, and the schema types
(Dtype, Stype, SchemaDefinition, TableDefinition), so those types have
exactly one owner and nothing has to translate between two spellings of the
same enum.
The tree is first-party source of this repository, maintained here under
Apache-2.0. There is no upstream to track and no manifest to re-record. It
imports as
predictive_query, which is not the distribution's name and is not derived
from it.
None of it is public API. Users write PQL through sdm_client.PredictiveQuery,
and the stability page lists predictive_query
with the internals, so any release may change it. The same holds for
ModelSpec, the RuntimeContract it carries, and ModelAdapter below: they
are how the client is built, not names it exports.
| Type | Owner | Why |
|---|---|---|
Dtype, Stype, TaskType, ProblemType |
structured-data-models-pql |
PQL speaks them, and the client and the engine both depend on it. One owner, no translation layer. |
SchemaDefinition, TableDefinition, Column, AST |
structured-data-models-pql |
Same. The relational samplers read an AST, they never parse one. |
Profile, Capabilities |
structured-data-models-pql |
What a model may be asked, as data. §6. |
The time column rule (predictive_query.time_columns) |
structured-data-models-pql |
Which timestamp inference takes as a table's time column. Schema.from_data and the engine behind Schema.from_source both call its choose_time_column, and neither imports the other. |
Wire request/response, InferenceSpec, OutputSpec |
structured-data-models-api |
The Universal TFM envelope. PQL knows nothing about HTTP. |
The DataFrame→wire encoder (wire/) |
structured-data-models-client |
The first split put this in structured-data-models-api, but wire/dtypes.py imports numpy and pandas. That would cost structured-data-models-api its zero-dependency property, which is why the split exists. structured-data-models-api keeps the vocabulary; encoding a frame is client-side work. |
Schema, Request, PredictionResult |
structured-data-models-client |
The public surface. |
Graph becomes Schema. Calling a one-table
dataset a "graph" is confusing, and Schema is the word the wire contract
already uses.
schema = Schema.from_data({'users': df}) # one table
schema = Schema.from_data({'users': u, 'orders': o}) # many
schema['users'].primary_key = 'user_id'
schema['users']['churn'].stype = Stype.categoricalEverything is inferred: dtypes, semantic types, primary keys, time columns and relationships. Override only incorrect inferences. You never write a schema by hand.
PredictiveQuery also accepts raw tables, so a one-table user need not meet
Schema at all:
PredictiveQuery('PREDICT users.churn FOR EACH users.user_id', {'users': df})Composite keys. A warehouse row identified by (customer, region) is
folded into one derived column, because the wire carries a single instance_id
per instance. Schema does the fold. Float columns are refused, because a join
must agree exactly on both sides. Precisely: entity identity becomes scalar
after folding. The wire's TableSchema.primary_key remains an array where a
related table's occurrence identity needs one, for example
[instance_id, order_id].
A schema can describe tables that stay in the warehouse.
Schema.from_data needs every table in memory, which caps the client at what one process can hold.
The engine has always sampled Databricks, Snowflake, DuckDB and SQLite tables with SQL, fetching only the rows a prediction needs, and the predecessor client exposed that through Graph.from_databricks and its siblings.
Nothing on Schema reached it, so every warehouse table had to be read whole into pandas first.
Schema.from_source restores it.
from sdm_client import Schema
from sdm_connectors import DatabricksSource
adventureworks = DatabricksSource(spark, catalog='main', schema='sales') # in a Databricks notebook
schema = Schema.from_source(
adventureworks,
tables={'customers': 'adventureworks_customers', 'orders': 'adventureworks_orders'},
time_columns={'orders': 'order_date'},
)
request = PredictiveQuery(query, schema).materialize(indices=[...])tables maps the names a query uses to the names in the source.
Left out, it is every table the source describes, each under its own name.
primary_keys, time_columns and relationships mean what they mean on from_data.
time_columns={'orders': None} says a table has no time column, on either.
A source is where the tables are and how to reach them, and it is the only thing that differs between backends. Each warehouse has a class of its own, named for it, so what only one warehouse has is a method only its class offers:
| Source | Built from | Names | Notes |
|---|---|---|---|
DatabricksSource |
a databricks.sql connection, sdm_connectors.databricks.warehouse(warehouse_id, workspace=w) for one opened as your WorkspaceClient, or the notebook's spark |
catalog, schema |
A SQL warehouse on any runtime; spark needs no warehouse and uses the notebook's own compute and identity, on DBR 17.3+ or serverless environment 4+, which have DataFrame.toArrow(). Offers semantic_model() |
SnowflakeSource |
a snowflake.connector connection, or a Snowpark session |
database, schema |
A Snowpark session is reached through its own connector connection. Offers semantic_model() |
PostgresSource |
a psycopg connection, or sdm_connectors.databricks.lakebase(endpoint, database=..., workspace=w) for Lakebase |
schema |
Lakebase authenticates with a short-lived OAuth token as the password, which lakebase() mints with your WorkspaceClient; the connection outlives the token once it is open |
DuckDBSource, SQLiteSource |
a duckdb or ADBC SQLite connection |
none | What lets CI run the whole path, SQL sampling included, with no credentials |
Source(connection, ...) still works, and returns the class that fits the connection; the classes are the spelling to reach for, because they are what a type checker and an editor can see.
from_data does not change: a mapping of frames is still read into memory and sampled there.
from_data |
from_source |
|
|---|---|---|
| Table type | Table, holding data: pd.DataFrame |
SourceTable: a source name and column metadata, no rows |
| Keys and relationships | given, else inferred from values and names | given, else the catalog's constraints, else inferred from names |
| Keys checked against | every row, in memory | every row, counted in the source unless it enforces the key |
| Semantic types | inferred from the rows | inferred from a 1,000-row preview, the engine's rule |
Sampling at materialize() |
the engine's LocalSampler, in memory |
the engine's SQL sampler for that backend |
| What leaves the source | every row | the sampled rows |
| Request on the wire | sampled rows, embedded | the same |
One source per schema, by construction.
Sampling is a conversation, not a query.
The SQL sampler fetches the entities, then each target's history inside its window, then walks the graph a hop at a time: for every table reached, one query per relationship, each sending the ids it has so far and joining them to exactly one table.
The ids come back to the client between every query, so routing each table's queries to a different source is mechanically possible, and deliberately not done.
Ids and timestamps would have to compare identically across engines, time bounds would have to be merged across them, seeded sampling would stop being reproducible, and tables read from sources at different moments can leak or drop labels while the result looks entirely ordinary.
The in-memory sampler is a different algorithm again, a C++ neighbour sampler over the whole graph at once, so frames and warehouse tables cannot share one either.
Where tables really do live in two systems, the platforms already join them: Unity Catalog registers a Lakebase database as a catalog and federates Snowflake and PostgreSQL, so one Databricks source sees all of them.
Taking the source once, as an argument, makes a mixed schema impossible to write rather than an error to explain.
The small in-memory data that does belong beside a warehouse, which entities to score and which candidates to rank, is already an argument to materialize(), not a table.
A second table type, not an optional data.
Table.data stays a DataFrame, so no existing caller meets a None.
Code that needs rows takes a Table, and a SourceTable reaching it is refused with a message naming the table, rather than failing on an attribute.
The caller owns the connection.
The source holds it and never closes it.
A warehouse connection is the driver's own object, opened with the driver's own connect.
It is the type the engine already accepts, so there is no second connection type to convert between.
On Databricks, sdm_connectors.databricks.warehouse() and lakebase() open one as the caller's WorkspaceClient and hand back that same native connection, for the caller to close.
Every connection and API request structured-data-models-client opens there names it as nvidia_structured-data-models-client/<version>, as Databricks requires of a partner product, and a warehouse session it opens tags its statements structured_data_models_client_purpose: sampling.
Each package keeps a private copy of the identifier, so the connectors never import the client and the serving extra brings no connectors; a test holds the two copies to the same value.
A Snowflake session it opens names itself to Snowflake as nvidia/kumo-sdm, the connector's application, unless the arguments, the connections.toml profile or the connector's own detection (SF_PARTNER, Streamlit, a notebook) already name one.
A connection the caller opens and passes in is used as it is.
A notebook's spark is wrapped in the few calls the samplers make: a cursor that runs spark.sql with positional parameters and returns Arrow.
A schema over a source does not pickle.
Keys are checked, and a warehouse's own constraints are not enough.
Unity Catalog and Snowflake constraints are informational: nothing stops a declared key from repeating.
from_data checks a key for nulls and duplicates over the rows it holds, and from_source does the same against the source with check_keys=True, which is the default: one aggregate per eligible table.
Pass check_keys=False to skip that scan when the cost matters and the key is already trusted.
Whole-table checks move.
Each check from_data runs over every row either reads metadata for a source table (dtypes, constraints, row counts) or runs on the sampled rows (value casts, and whether scored rows mean what the schema says).
Whether a relationship joins anything would need a whole-table aggregate, and is skipped; key quality is the one such check that is run, by check_keys above.
The heuristic that searches column combinations for a composite key is not run: against a warehouse it scans the table once per candidate column.
Metadata is read once per schema, except the time range. Describing a table in place costs queries: its columns, its keys, a preview of up to 1,000 rows, and on Databricks a row count. The schema keeps the engine's table objects from its first read, and each question is given fresh copies of them, so a question writes its keys and semantic types onto its own copy and never onto another's. The minimum and maximum of each time column are still read for every question, because they decide the anchor a question defaults to, and a schema that outlives new rows has to see them. On clustered or partitioned tables that aggregate is cheap; elsewhere it is the first cost a user notices.
What it costs, measured. Against the four AdventureWorks tables in Unity Catalog (121,317, 31,465, 19,820 and 504 rows, 173,106 in all), through a 2X-Small SQL warehouse, one demand question:
| Rows fetched | Schema | First question | Each later question | Peak memory | |
|---|---|---|---|---|---|
| In place, metadata kept | 245,031 | 14.9 s | 10.6 s | 4.6 s | 254 MB |
| In place, metadata read per question | 252,181 | 13.4 s | 17.9 s | 14.7 s | 259 MB |
Read into memory, from_data |
173,106 | 24.0 s | 0.4 s | 0.3 s | 328 MB |
At this size sampling in place is the slower way to ask. The tables are smaller than the neighbourhoods a thousand context entities reach, so the sampler fetches more rows than the tables hold, and each hop is a round trip to the warehouse. It wins where a table does not fit in memory, or where reading it whole takes longer than sampling it: the cost of reading into memory grows with the tables, and the cost of sampling with the question.
Postgres needs the engine first.
The engine was carried across before the predecessor gained a Postgres backend, so the Postgres source brings that backend over too: the table, the JOIN LATERAL sampler, and key discovery from pg_catalog.
A semantic model is a source, not an argument to Schema.
A Databricks metric view and a Snowflake semantic view each already say which tables a question is about and how they join, and the engine reads both into the same thing: tables, the links between them, and row-level columns, with their aggregations (measures, metrics) left out.
The industry's own name for that is a semantic model: the Open Semantic Interchange, which Databricks and Snowflake both back, and dbt use it.
So source.semantic_model('sales_metrics') is a source of its own, and Schema.from_source reads it like any other; tables, if given, selects and renames among the model's tables.
Only DatabricksSource and SnowflakeSource offer it, because only those warehouses keep one, and Schema names no warehouse at all.
A metric_view= argument on from_source would put one vendor's word on the one method meant to be the same for every warehouse.
The engine turns the view into its graph: the source becomes a table named as it is in the catalog, each join a table named after the join, each dimension a column of the table it reads, and each join a relationship to the key it names.
An expression dimension stays an expression, computed in the warehouse when the table is sampled, so materialize() rebuilds the tables from the view and then keeps only what the schema kept.
Measures are aggregations rather than row values and are left out; a predictive query states the aggregation it asks about.
The columns a measure over one table aggregates are added to that table, and a conversion note names the aggregation matching each simple measure, so revenue can be asked as SUM(order_lines.LineTotal, 0, 30, days).
A wildcard dimension such as source.* EXCEPT (rowguid) becomes the columns it stands for, as Databricks expands it.
A dimension truncating or casting a date stands for the column it truncates when the time column is chosen, so windows count days, not months.
A join whose table cannot be read, or whose key repeats, is left out with the tables and dimensions hanging off it; only a source table that cannot be read fails the conversion.
A model is written by someone else, and its expressions run under the caller's credentials each time a request is gathered, so by default they may call only pure, deterministic built-in functions, from an allowlist per dialect.
A column calling a user-defined function, secret(), http_request() or SYSTEM$WAIT() is left out with a message, and trust_expressions=True keeps it; sub-queries and statement separators are refused either way.
A comment in a metric view is removed first, as Databricks drops it, and one in a Snowflake semantic view is refused.
Allowed is not the same as cheap or fixed.
Some allowed built-ins can build large values from small inputs and so cost the warehouse time and memory: repeat, space, lpad, rpad, array_repeat and sequence on Databricks, and repeat, space and array_generate_range on Snowflake.
Others read the session's time zone, so their results can differ between two sessions reading the same rows: from_unixtime, date_format, to_timestamp, convert_timezone with two arguments, and the *_ltz functions.
Both kinds stay allowed, since views use them for ordinary columns, so review a model's expressions for them when its author is not your own team.
A metric view's filter decides which rows it holds, so it is applied to the source table on every read rather than dropped.
A filter on a joined table cannot be, and the view is refused: create a regular view with that WHERE clause and point the metric view's source at it.
A parameter the filter or a dimension references is read as its default, as a query of the view without arguments reads it; a filter on one with no default refuses the view.
Besides the unit tests, metric views are converted live by an opt-in test that creates eight variants over AdventureWorks in the schema SDM_MV_TEST_SCHEMA names, and drops them again.
A Snowflake semantic view goes through the same path, read by the engine's own semantic-view support; it is tested against a fake connection, not yet against a Snowflake account.
Request is the wire envelope minus the two things that are predict()
arguments. It is not a new abstraction; the Universal TFM contract already
unified tabular and relational.
| Field | Meaning |
|---|---|
task |
what is being asked: kind, target and entity |
schema |
the tables and how they relate |
context |
rows where the answer is known |
predict |
rows being asked about |
inference |
gathering knobs (§7) |
Tabular is the case where schema has one table and no relationships. That is
the whole difference.
Request is singular. A relational run that exceeds the server's per-call
cap is gathered as several envelopes, and predict() sends one round trip for
each and returns one result. Batching belongs to transport, not a user-facing
type.
The direct builder is the single-table one. A relational request is
gathered, not assembled from frames: the neighbourhood around each entity is
sampled and folded onto the instance table, which is what materialize() does
and what the relational model is sent. So related_tables= is refused there
rather than building a request no path can send.
Request(context=ctx_df, predict=rows_df, target='churn', task='classification')
PredictiveQuery(
'PREDICT SUM(orders.price, 0, 30, days) FOR EACH users.user_id', schema
).materialize(indices=[1, 2, 3], model=KumoRelational())A binary target can be read as multiclass. Inferring the kind from a column's cardinality is a guess, and it is wrong exactly when a binary target looks categorical. What the two models actually accept settles it, and they differ:
| Task kinds the NIM accepts | |
|---|---|
kumo-tabular |
classification, regression |
kumo-relational |
classification, binary_classification, multiclass_classification, regression, forecasting, temporal_link_prediction |
So there are two different answers, and neither is "guess from cardinality":
- Tabular has nothing to decide between classifications. The NIM takes
only the coarse
classification, and the model resolves binary vs multiclass itself. The client sendsclassificationand never narrows it. The blocker does not exist on this path. - It does have to decide classification against regression, and it got that
wrong. A query names a target; it does not say what is being asked of it.
The derivation read the target's semantic type, integers are numerical, and
so
PREDICT users.churnover a 0/1 column was gathered as a regression and answered with continuous values near 0.5 that look like probabilities but are not. Zero-and-one is the most common way to write a binary label, so that exact case now derivesclassificationand says so. The values decide, not their storage. A 0/1 column with one missing value arrives asfloat64and is still a label. Only that case: three whole numbers may be a count, and a fractional pair is not a label, so both are left as regressions. - The caller has the last word where there is a decision to make. Use
task=on the direct builder ormaterialize(task=…)on the single-table PQL path. What the caller states is authoritative and overrides everything above it. Stating it also silences the warning, which is the point of the warning. A relational query has no such decision. The engine resolves the query and states the kind in the payload, so passingtask=with one is refused rather than quietly dropped.
PQL's own TaskType already has BINARY_CLASSIFICATION and
MULTICLASS_CLASSIFICATION, but a query resolves only to the coarse
ProblemType (CLASSIFY/RANK/FORECAST). The narrowing happens downstream
from the target's cardinality, which is exactly where that misreading
happens. The caller's explicit task= wins over that derivation, and when the
derivation is the only signal and the target has exactly two classes the client
warns, naming both kinds rather than silently picking multiclass.
A query says what to ask. It never says which rows to ask about. That is always an explicit argument.
| Builder | Rows to score | Available |
|---|---|---|
Request(context=…, predict=…) |
predict: any rows, including rows the model has never seen |
v1 |
query.materialize(indices=[…]) |
existing rows, looked up by entity key | v1, tabular and relational |
query.materialize(predict=rows_df) |
supplied rows, which need not exist in the tables | tabular |
materialize() with neither argument raises, naming both. There is no
all-rows default because it would silently score a whole table.
The model a request is gathered for is an argument, not a consequence of the
schema's shape. materialize(model=…) takes the same handle or id that
predict() does. Left unsaid, the shape decides, and the shape that counts is
the one Request.is_relational reports: a second table or a relationship sends
it relationally, so a lone table that points at itself is gathered as the graph
it is. Everything else is tabular, and model='kumo-relational' gathers it as
a graph of one node. Without that argument the two models could disagree with
no way to reconcile them: a one-table request was always built tabular, so the
relational model was refused it and nothing the caller passed could say
otherwise. Gathering and sending disagree loudly now: each adapter refuses a
request gathered for the other before it is sent.
Tabular PQL names a real key. FOR EACH must name a primary key, and
nothing is synthesized for a frame that has none: such a schema is refused,
with the refusal saying the entity is not a primary key. A keyless frame is the
direct Request path, which needs no key because it names the two halves
itself. Give the frame a key and the same question can be asked either way.
The query never says which rows are scored. materialize does, and it takes
exactly one of indices=, naming rows already in the table, or predict=,
passing rows you hold. That second form is what lets a tabular question score
rows the schema has never seen.
"Unseen" means a row that is not in the tables the schema was built from. PQL
text alone can never produce one: the rows are an input, not a derivation,
which is why predict= exists and why the direct builder answers a tabular
question without a query at all.
The contract already has it: POST /v1/sessions pins context and runs no
inference; POST /v1/sessions/{id}/predictions scores against it. That is
fit and predict.
fitted = client.fit(request, model=KumoRelational()) # POST /v1/sessions
fitted.predict(more_rows) # reuses the pinned context
fitted.close() # DELETE /v1/sessions/{id}client.predict() works statelessly in one call and never opens a session.
A NIM that does not serve sessions is reported by fit(), with the code
SESSIONS_UNSUPPORTED.
The old design returned a DataFrame, or an Explanation if you asked to
explain. That union was undocumented. Now:
result = client.predict(
request, model=KumoRelational(), outputs=['prediction', 'explanation']
)
result.to_pandas() # always a DataFrame
result.explanations # .gradients and .columns, empty unless you asked for themExplanations are an output field, not a method. Ask for one with
outputs=[..., "explanation"], or prefix the query with EXPLAIN, which
means the same; there is no explain= argument. That is the
shape of the call — whether a deployment answers it is a separate question,
and the shipping relational build does not. See What a deployment actually
serves.
The DataFrame's shape depends on the task, not the model. Verified against
a live endpoint: classification and multiclass both give one row per
entity, with multiclass carrying a probability per class as columns rather
than a row per class. Ranking also gives one row per entity, holding its k
candidates as a list, because expanding them would leave row_index no longer
identifying a row; forecasting gives one row per entity per step. Neither
multiclass nor ranking gives a row per class or per candidate, which is the
reading to guard against.
Every row carries row_index. It also carries the entity key when the model
has one. Tabular requests may have no key. Join on the key when present,
otherwise on row_index.
Never join on row order.
The contract defines seven output fields. This table is what the client
declares — what capabilities() reports and what it will put on the wire.
It is not what any one deployment answers; see below.
| Output | kumo-relational |
kumo-tabular |
|---|---|---|
prediction |
✅ | ✅ |
probabilities |
✅ | ✅ |
quantiles |
✅ | ✅ |
scores |
✅ | ❌ |
rankings |
✅ | ❌ |
embeddings |
✅ | ❌ |
explanation |
✅ | ✅ |
Asking a model for a field it does not declare fails client-side, naming what it does declare.
The client declares the broader of the relational builds, and a deployment
may serve less. The contract has no capabilities route — /v1/models
returns an id and nothing else — so the client cannot ask, and refusing
locally what one deployment can answer would be worse than letting another say
no. The narrowing is per task, and the endpoint says so with
422 UNSUPPORTED_OUTPUT.
Measured against the shipping relational deployment, which is the narrower build:
| Task | Served |
|---|---|
regression |
prediction, quantiles |
binary_classification |
prediction, probabilities |
multiclass_classification |
prediction, probabilities |
So scores, rankings, embeddings and explanation are declared by the
client and refused by that deployment on every task. That is a fact about this
build, not about the fields: explanation has been measured working against
another relational deployment, which returns an explanation the client parses
and hands back on PredictionResult.explanations. A deployment that refuses it is
narrower than the model family, not the client declaring something nothing
serves.
rankings is the one that still works end to end against the narrower build:
RANK TOP k is decomposed into a classification per candidate, so the ranking
is assembled here rather than asked for.
forecasting and temporal_link_prediction are declared as tasks and are not
served either — a forecast is sent as a regression per step, and a bare
LIST_DISTINCT is refused outright.
skills/relational/SKILL.md carries the same matrix for an agent, and
tests/test_skills.py asks the endpoint for every one of these on every task,
so a deployment that gains or loses an output fails the suite rather than
leaving the document wrong.
Every scored row is explained, and result.explanations.gradients maps each row's key
to its explanation: the id it carries in to_pandas(), (id, forecast_step) for a forecast, and row_index when the request has no entity
key. The same rule holds on both models. A client opened with
SDMClient.in_process() computes the same record itself. The shape of an entry is in
Prediction output.
Keys, not positions. The endpoint locates every explained row by its
position in the tables as submitted. The caller never sees those tables: the
related ones are gathered by this client, and a keyless one is identified on
the wire only by the engine's own synthetic node id. So the client turns each
position back into what the caller can recognise. The predicted row's position
is dropped, since the mapping's key already names it. A related row always
carries its values as sent; keys is present when the table's key column is
one the model receives, i.e. a column a relationship joins on; otherwise rows
are identified by their values.
Translated per call, before merging. A request split across several calls numbers its positions from zero in each call. Read after merging, the second call's rows would point into the first call's tables. Each call's explanations are translated while that call's own tables are still at hand, and only then merged.
Kept off the frame. An entry is nested: outputs by columns for the row
itself, and outputs by rows by columns for each related table. to_pandas()
stays one row per answer, as the task decides, and the explanations sit beside
it on the result. result.explanations.gradients.to_pandas() flattens them into a table
of their own when one is wanted, with each related row's hop from the entity
worked out from the relationships and foreign-key values the client sent.
The engine's question row is dropped. The engine sends a question row
of its own, the entity key and anchor time with no caller columns, beside the
entity's own table, and the endpoint explains both. The client drops that
row's block when it reads the reply, because nothing in it is the caller's, so
a kumo-relational entry is only related, with the entity's own table at
hop 0.
A forecast is explained a step at a time, under (id, forecast_step), rather
than aggregated across steps: each step is its own row in to_pandas(), and
its explanation is keyed the same way.
Three pieces. Two are cheap; one is a change to the wire contract.
1. The contract must accommodate the model, which requires more than an id.
PredictionRequest.model is a closed enum, so at minimum a new model needs a
change to the contract and regenerated bindings in
structured-data-models-api. Until §0 is fixed, an unrecognised id fails
client-side, with a message naming the contract and binding changes it would
need, rather than being silently sent and rejected.
But a genuinely new kind of model needs more than a name. Anomaly detection is the honest test:
| What it needs | Does the contract have it? |
|---|---|
a task kind anomaly_detection |
❌ TaskSpec.kind has no such value |
| a request with no label target | ❌ TaskSpec.target is required |
| score direction, threshold, calibration | ❌ nothing standardises them |
| a place to put the score | ✅ OutputSpec.fields already has scores |
So "add a model" is a contract change plus a profile plus an adapter, and for
a new task shape the contract change is the large part. Anything else would
push the meaning into open metadata dicts, and a unified client whose
semantics live in untyped dicts is a facade over per-model behaviour, not a
common interface. Timeseries is cheaper because forecasting already exists as
a task kind. It is therefore the sensible third model, not anomaly.
This is the seam's real cost, stated up front so nobody discovers it at model three.
2. A profile says what the model may be asked. structured-data-models-pql already ships
RELATIONAL, TABULAR, TIMESERIES, ANOMALY. A capability may only
reject: the language accepts a fixed set of queries and each model accepts a
subset, so a new model can never widen the language.
TABULAR = Profile(
mode=INFERENCE,
capabilities=Capabilities(
model='a tabular model',
relationships=False,
multiple_tables=False,
temporal=False,
entity_filters=False,
counterfactuals=False,
derived_targets=False,
link_prediction=False,
forecasting=False,
),
)3. An adapter does what a profile cannot describe: shape the wire request, normalize the response, choose a session strategy, release resources.
class ModelAdapter(Protocol):
spec: ModelSpec # model id + profile + runtime facts
def default_output(self, request) -> OutputSpec: ...
def prediction_plan(
self, request, output
) -> Iterable[PredictionRequest]: ...
def session_plan(self, request) -> SessionCreateRequest: ...
def normalize(self, response) -> PredictionResult: ...
def close(self) -> None: ...The profile, not the adapter, owns query-shape validation. Everything else
is validated by shared client code combining the profile with
ModelSpec.runtime: which output fields exist, whether sessions are served,
and the resource limits in §7. A profile cannot know whether this deployment
has a regressor checkpoint; that is a runtime fact and lives beside it, never
duplicated into it.
Cost, stated plainly. The code this came from deleted its registry on purpose. Re-opening it buys back an adapter protocol to keep stable and conformance tests to maintain. That is the price of serving more than one model, and it is the price the doc asks for.
A model with no heavy client-side work (tabular) is an adapter alone. A model with heavy work (relational) also gets its own package behind an extra, so base installs stay light.
The old SDK reported its own registry, so it claimed a model the NIM did not serve.
| Call | Answer |
|---|---|
client.models() |
Read from the endpoint's /v1/models. Endpoint truth. In process, the models the client can run. |
client.capabilities(model) |
Local: what this client can build and send. Tagged source="sdk". |
/v1/models is a NIM runtime route and is not one of the contract's four
paths, so a NIM without it degrades to a clear error rather than a wrong
answer.
run_mode was a preset over two numbers, and it leaked a server detail into
the user's hands.
We checked the server. It distinguishes only two context budgets, the smaller
one reached by fast; the client's four-tier debug/fast/normal/best ladder
was a client-side fiction. Both numbers are in
limits.
So the user sets the real numbers, and the client derives the wire value:
query.materialize(indices=[1, 2, 3], num_context_samples=500, num_neighbors=[8, 8])And the number is honoured, not just used to pick a bucket. The endpoint's budget is a ceiling, not a request; gathering 1,000 because that is the bucket that admits 500 would make the number as decorative as the two preset values that did nothing, which is the defect this replaced. The count narrows the gathered examples where they are built, before anything is encoded.
The client pre-flights the server's limits before sending, so you get a readable error instead of a 500, naming the limit and how to get under it.
Every value and where each is checked lives in limits, and only there. This page had its own copy of those tables and they went stale twice: a limit that moved was corrected in the reference and not here, so the two pages disagreed about what the client does.
Most of this client is not new. It consolidates implementations that already existed at NVIDIA, were in production, and had been hardened and tested against live endpoints. Consolidating them was preferred to rewriting: a rewrite would have traded working code for new code and kept none of what those implementations had learned.
What changed on the way, for anyone reading code that looks familiar:
| Area | Change |
|---|---|
| Connectors | Moved into structured-data-models-connectors under new names |
| Relational engine, including the C++ neighbour sampler | Moved into structured-data-models-relational-engine. Graph became Schema, and the PQL types now come from predictive_query |
| Relational adapter | Returns the unified Request and PredictionResult |
| Tabular adapter | Model id renamed to kumo-tabular |
| Wire encoding | The DataFrame-to-wire encoder lives in structured-data-models-client; structured-data-models-api keeps the wire vocabulary and stays dependency-free (see §2) |
| Predictive Query Language | Maintained here from this point on, in structured-data-models-pql |
Two behaviours were carried over deliberately, because each cost something to learn: every redirect hop is closed rather than drained, and credentials in a URL are stripped everywhere the URL is rendered.
| Thing | Why | What would change it |
|---|---|---|
| Timeseries and anomaly models | No NIM serves one through this contract | A NIM serves one; profiles already exist, plus a contract MR (§6) |
| The model reading the warehouse itself | The wire requires embedded data | The contract gains source references |
| Arrow IPC | The wire is JSON-arrays-only everywhere | The contract activates it |
Sampling inside the warehouse is not on this list. The row above is the model reading tables by reference; the client sampling them with SQL and embedding what it fetched fits the wire as it is (§3, schemas over tables that stay where they are).