sdm-relational
Skill12 starsChanged 17 days agoApache-2.0View on GitHub
Ask kumo-relational a question about an entity and the records linked to it: write the question as a predictive query over a schema of related tables, covering classification, regression, forecasting and link prediction. Triggers: kumo-relational, KumoRelational, PREDICT, FOR EACH, predictive query, PQL, Schema.from_data, Schema.from_source, Relationship, ranking, forecast, link prediction, explanation, Snowflake, Databricks, warehouse, metric view, semantic view, SQL, catalog.
- Installs packages
In this repository3 other files it gives its agents.
- AGENTS.mdAGENTS.md
- CLAUDE.mdCLAUDE.md
- sdm-tabularSkill
The file
We split this skill into the rules it gives an agent
2 rules. About 0 are common, 0 are distinctive, and 2 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈0
Distinctive · estimate
This author's own choices. Worth a look.
2
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 138
Leave primary_keys or time_columns out and they are inferred, with a warning naming anything doubtful and the override that settles it.
- Very specific: line 176
Every batch is built before the first is sent, so one materialize() gathers at most 100,000 entities, in at most 100 calls.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Asking kumo-relational
An entity, and the records linked to it. The question is written as a predictive query, the client gathers each entity's neighbourhood, and the model reads that neighbourhood in one pass.
Load ../tabular/SKILL.md instead when the question is
about rows of one table with no links.
Writing the query itself is in pql.md.
What it can be asked
Two different questions, and they have two different answers:
from sdm_client import SDMClient, KumoRelational
with SDMClient(url='http://localhost:8000') as client:
client.capabilities(KumoRelational()) # what this client will send
client.models() # what the endpoint advertisescapabilities() is the client's own table, tagged source='sdk', and needs no
connection. It is the wider of the two: a deployment may refuse something the
client is willing to ask. What the client will send:
kumo-relational | |
| Tasks | binary_classification, classification, multiclass_classification, regression, forecasting, temporal_link_prediction |
| Outputs | prediction, probabilities, quantiles, rankings, scores, embeddings, explanation |
| Several tables | yes |
| Relationships | yes |
| Sessions | yes |
The outputs narrow again at the endpoint, by task. The shipping deployment
serves prediction and quantiles on a regression, prediction and
probabilities on either classification, and nothing else — including no
explanation, on any task:
422 UNSUPPORTED_OUTPUT output(s) ['explanation'] not supported by model
'kumo-relational' for task 'regression'; supported: ['prediction', 'quantiles']So ask for an explanation, catch the refusal, and fall back. Do not build a
flow that assumes one arrives. Do not assume one never does either: another
relational deployment serves explanation, and the same code gets a parsed
one back there, so the refusal belongs to the deployment and not to the
request. SDMClient.in_process() serves explanation too, with the same
result.explanations as a NIM. EXPLAIN PREDICT … means the same as
outputs=['explanation']; passing both is allowed and redundant.
The task is not an argument. It follows from the query: see
pql.md.
The whole interface
KumoRelational needs the engine, which is an extra:
pip install 'structured-data-models-client[relational]'Without it the first call raises MissingExtraError naming that command.
import pandas as pd
from sdm_client import (
KumoRelational,
PredictiveQuery,
Relationship,
SDMClient,
Schema,
)
users = pd.DataFrame({'user_id': range(12), 'segment': ['a', 'b'] * 6})
orders = pd.DataFrame(
{
'order_id': range(120),
'user_id': [i % 12 for i in range(120)],
'item': [i % 4 for i in range(120)],
'price': [10.0 + i for i in range(120)],
'ordered_at': pd.date_range('2026-01-01', periods=120, freq='D'),
}
)
items = pd.DataFrame({'item': range(4), 'kind': list('abcd')})
schema = Schema.from_data(
{'users': users, 'orders': orders, 'items': items},
primary_keys={'users': 'user_id', 'orders': 'order_id', 'items': 'item'},
time_columns={'orders': 'ordered_at'},
relationships=[
Relationship('orders', 'user_id', 'users', 'user_id'),
Relationship('orders', 'item', 'items', 'item'),
],
)
request = PredictiveQuery(
'PREDICT COUNT(orders.*, 0, 30, days) FOR EACH users.user_id', schema
).materialize(indices=[1, 2, 3], model=KumoRelational())
with SDMClient(url='http://localhost:8000', timeout=600) as client:
answer = client.predict(request, model=KumoRelational())
print(answer.to_pandas())Verified against a live endpoint:
row_index id prediction
0 0 1 2.173828
1 1 2 2.181641
2 2 3 2.212891id is the entity, row_index its position in indices. Join on the key when
it is there, otherwise on row_index, and never on row order.
Describing the data
A Schema says which tables exist, how they are keyed, which column carries
each table's time, and how they link.
Schema.from_data(frames, primary_keys=, time_columns=, relationships=)Relationship(from_table, from_column, to_table, to_column) points from the
many side to the one side: an order names a user, so the relationship is
Relationship('orders', 'user_id', 'users', 'user_id').
Leave primary_keys or time_columns out and they are inferred, with a
warning naming anything doubtful and the override that settles it. The
inference deliberately refuses some columns, among them a stamp recording when
a row was last edited on a table other rows point at, since that moves after
the fact and would leak the future into the past. Pass
time_columns={'t': None} to say a table has no time column at all.
For tables that stay in a warehouse, Schema.from_source describes them in
place and materialize() samples them with SQL, so what leaves is bounded
rather than the table: up to a thousand rows of each for reading its columns --
all of one that small -- and after that only the rows a prediction needs. Snowflake, Databricks, Postgres, DuckDB and SQLite, metric
views, and a notebook's own session are in
references/warehouses.md.
Which rows get scored
materialize(indices=[...]) names the entities. It gathers each one's
neighbourhood, checks every limit before sending, and refuses with the limit it
would break rather than letting the endpoint return a 500.
| Limit | Value |
| context rows, by default | 1,000 |
context rows, with num_context_samples | 10,000 |
| rows scored at once | 10,000 |
| rows scored for a link prediction | 200 |
| tables in the gathered graph | 15 |
| hops gathered | 6 |
| classes | 10 |
More rows than one call scores are not refused. They are gathered in
batches of at most that many, and predict() sends one call per batch and
returns one result, whose rows are numbered across the whole request, so
to_pandas() gives the indices back in the order they were asked for. The
10,000 is the deployment's TFM_MAX_TEST_ROWS, not a property of the
protocol; pass rows_per_call= to ask in smaller batches, which is what a
graph too wide for a full one needs. It only goes down.
Every batch is built before the first is sent, so one materialize() gathers
at most 100,000 entities, in at most 100 calls. Both are this client's own:
the endpoint is never asked for more than one call holds. A question asked
in parts is not batched at all: a ranking is already a call per candidate and
a forecast one per step, and batching a part would send that many times as
many calls.
The table limit is on the graph a request carries, not on the schema. A schema may describe far more than fifteen tables; what counts is how many the query actually reaches.
Asking for more than one answer each
A ranking is a call per candidate and a forecast a call per step; an
explanation is an output, over every scored row. Each has limits and refusals
of its own, and they are in
references/beyond-one-answer.md.
An explanation is kept out of to_pandas(), on result.explanations.gradients, a
mapping from each row's key to its explanation: the entity's id, or
(id, forecast_step) on a forecast. One entry covers the entity's own row
and every related row gathered for it, each under its table's name in
related:
entry = result.explanations.gradients['user_5'] # the entity whose id is 'user_5'
entry['related']['users'] # the entity's own row, hop 0
orders = entry['related']['orders'] # one block per related table
entry['related']['users']['keys'] # present only when a relationship joins on the table's key column
orders['rows'] # each row's values as sent
orders['gradients'] # [output][row][column]
result.explanations.gradients.to_pandas() # one line per (row, output, column), with its hop; the entity's users line is hop 0
result.explanations.columns.to_pandas() # per-column cohorts of the labelled contextAsking the same context more than once
fit() uploads a context once and pins it in the endpoint's memory; every
later call on the handle sends only the rows to score. Gathering still happens
here, in materialize(), so what a session saves is sending the context again,
which is worth doing when the same context answers question after question.
with SDMClient(url='http://localhost:8000', timeout=600) as client:
fitted = client.fit(request, model=KumoRelational())
print(fitted.predict(request).to_pandas())
fitted.close()Verified against a live endpoint:
row_index id prediction
0 0 1 2.173828
1 1 2 2.181641
2 2 3 2.212891The same answers the one-shot call gave: a session changes what it costs to ask again, not what comes back.
fit() takes a request of one call. A session pins one context, and a
request gathered in several batches carries one per batch, so fitting one is
refused. To score more rows than a call holds against a pinned context, fit a
request of one call and pass the batched request to the handle's predict().
One context is one question. A later call on the handle has to ask the pinned
query, task, target and entity table; anything else is refused and the refusal
names which of them differs. Another question over the same tables is another
fit().
A session lives in one process's memory on the endpoint, so a deployment behind several replicas needs the calls for one session to reach the same one.
The context is chosen once, when it is pinned, so a row scored later is sitting
in it with its answer already shown. fit(request, model=..., holdout=ids)
names every row the session will go on to score and holds all of them back
while there is still a context to hold them out of. There is no remedy
afterwards: a context cannot be changed once pinned.
A ranking and a forecast cannot be fitted at all; see
references/beyond-one-answer.md.
Reading the answer
predict() gives a PredictionResult. Its to_pandas() gives one row per
entity, except a forecast, which gives
one per entity per step, and a ranking, which gives a list in one row.
A regression's prediction is the median of the predicted distribution,
not the mean. Ask for the mean with
materialize(..., prediction_statistic='mean') when predictions will be summed
across entities, since medians do not add up.
When it warns, and when it refuses
A warning is this client answering, but not as asked: a context row that
carried the answer, an entity filter that does not narrow what is scored, a
candidate it could not score. They are all SDMWarning, so they can be caught
or raised:
import warnings
from sdm_client import SDMWarning
warnings.simplefilter('error', SDMWarning)A refusal is a StructuredDataError or a subclass, and each names what to
change. The ones worth knowing:
| Refusal | Cause |
| every candidate's context holds one class | each gathered entity reached it, or none did, so there is nothing to tell apart and nothing is sent; widen with num_context_samples or ask over a table of pairs. One such candidate among several is dropped with a warning instead, and named on result.unscored_candidates |
| no entity existed at the anchor | the entity table's time column starts after the anchor, so nothing existed to ask about |
| more than 100,000 entities at once | every batch is built before any is sent; ask about fewer at a time |
| more than 100 calls at once | a context is gathered per call; ask about fewer entities, or raise rows_per_call if you lowered it |
| more entities than one call scores, on a ranking or a forecast | each candidate or step is already a call, and batching one would send that many times as many; ask about fewer at a time |
| an output this question cannot give | rankings on a regression, explanation on a ranking |
422 UNSUPPORTED_OUTPUT | the client sent an output the deployment does not serve, such as explanation |
| an aggregation the model does not answer | COUNT_DISTINCT, FIRST, LAST, or any aggregation of a category |
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

