RelQL — the Predictive Query Language
RelQL expresses predictions the way SQL expresses lookups. One statement names a target (what to predict), a population (who to predict it for), and an anchor-relative time window:
PREDICT NOT EXISTS(orders.*) OVER (90 DAYS FOLLOWING)
FROM customers
For every customer, will they place zero orders in the 90 days after the anchor time?
Why a language?
- Declarative. Changing the question means changing the string, not a pipeline.
- Validated. Every query is bound against your schema before execution: unknown names, type mismatches, and backwards time windows are rejected up front.
- Self-routing. The query's shape determines the task type (classification, regression, ranking, forecasting), which selects the model checkpoint and output form.
RelQL tutorial
We'll build up a real query step by step, on a two-table schema:
customers (customer_id, age, signup_date) and
orders (order_id, customer_id, qty, order_date), linked by
orders.customer_id → customers.
Step 1: predict an aggregate
Start with the target, an aggregation over linked rows in a future window:
PREDICT SUM(orders.qty) OVER (30 DAYS FOLLOWING) FROM customers
OVER (30 DAYS FOLLOWING) is a frame relative to the anchor time (the "as
of" instant you pass at execution): it covers the 30 days after the anchor,
start excluded, end included. This predicts each customer's total order
quantity over the next 30 days.
Step 2: turn it into a yes/no question
Compare the aggregate to a literal and the task becomes binary classification, so the result is a probability:
PREDICT NOT EXISTS(orders.*) OVER (90 DAYS FOLLOWING) FROM customers
"Will this customer place zero orders in the next 90 days?" That is churn.
Step 3: narrow the population
WHERE filters who gets predicted. Filter frames look backwards
(PRECEDING), so this restricts to customers active in the last 90 days:
PREDICT NOT EXISTS(orders.*) OVER (90 DAYS FOLLOWING)
FROM customers
WHERE EXISTS(orders.*) OVER (90 DAYS PRECEDING)
Static attributes work too: WHERE customers.age >= 18.
Step 4: target specific entities
FROM names the population by table. The primary key comes from the schema.
To score only a specific subset, either constrain them with a WHERE predicate
on the key:
PREDICT NOT EXISTS(orders.*) OVER (90 DAYS FOLLOWING)
FROM customers
WHERE customers.customer_id IN :ids
engine.execute(ExecutionInput(query=q, params={"ids": ["C7", "C9"]}))
:ids is a bind parameter. The cohort lives in params, not in the query
text, so the same query string is reusable across cohorts. A literal list
(IN ('C7', 'C9')) is also valid when the cohort really is fixed.
Step 5: filter the aggregated rows
Aggregations accept an inline row filter, distinct from WHERE, which
filters entities:
PREDICT SUM(orders.qty WHERE orders.qty > 1) OVER (30 DAYS FOLLOWING)
FROM customers
Step 6: forecast over multiple horizons
Add HORIZONS N to a target frame and the single window repeats back to back:
PREDICT SUM(orders.qty) OVER (7 DAYS FOLLOWING HORIZONS 4)
FROM customers
Four weekly predictions per customer. (There is no separate FORECAST clause;
the horizons on the window imply it.)
Step 7: rank a set of items
LIST_DISTINCT predicts which linked IDs will appear. RANK TOP K ranks
them:
PREDICT LIST_DISTINCT(orders.product_id) OVER (30 DAYS FOLLOWING RANK TOP 3)
FROM customers
Step 8: ask "what if"
ASSUMING states a counterfactual. The engine rewrites the assembled context
so the assumption holds, then scores that instead of the real one:
PREDICT NOT EXISTS(orders.*) OVER (90 DAYS FOLLOWING)
FROM customers
WHERE customers.customer_id = 'C7'
ASSUMING customers.plan = 'premium'
An assumption must assign a concrete value: column = literal, optionally
joined by AND. Inequalities, IN, OR/NOT and aggregate conditions
describe a set of possible worlds, so no single context satisfies them. The
engine raises at execution instead of quietly dropping the clause.
Difference the counterfactual against the factual run (same query without
ASSUMING) to estimate an intervention's effect.
What you've learned
Target → population → filters → horizons → ranking → counterfactuals. Every query you can write is validated against the schema before it runs, and its shape determines the task type. Continue with the reference or the cookbook.
Query structure
Clause order is significant:
[EXPLAIN [PLAN|CONTEXT|ANALYZE] [FORMAT TEXT|JSON]] -- optional: inspect, don't (necessarily) run
PREDICT <target> [CLASSIFY] -- required: what to predict
[FROM <table> [[AS] <alias>]] -- the population; inferred if omitted
[WHERE <condition>] -- optional: entity filter (past-facing)
[ASSUMING <condition>] -- optional: counterfactual
[AS OF <anchor>] -- optional: bind the anchor time
[RETURN <return_spec>] -- optional: choose the output form
[WINDOW <name> AS (<window_spec>)] -- optional, repeatable: named frames
The trailing clauses (WHERE, ASSUMING, AS OF, RETURN, WINDOW)
may appear in any order after FROM. Each may appear at
most once, except WINDOW, which repeats (one per named frame).
RETURN
Every task type produces a default output: a value for regression, one value per
horizon for forecasting, a probability for binary classification, a class for
multiclass, a ranked ID list for ranking. RETURN overrides that default.
| Form | Valid for |
|---|---|
EXPECTED VALUE | regression, forecasting, binary classification |
PROBABILITY | binary classification |
CLASS | binary classification, multiclass classification |
DISTRIBUTION | binary classification, multiclass classification |
MULTICLASS | multiclass classification |
MULTILABEL | ranking |
The validator rejects a form the inferred task cannot produce, so
RETURN PROBABILITY on a regression target fails before the query runs.
PREDICT COUNT(orders.*) OVER (30 DAYS FOLLOWING) = 0
FROM customers
RETURN CLASS
RETURN QUANTILES and RETURN INTERVAL are not part of the language. The model
gives a single point estimate, not a distribution, so a query using either is
rejected at parse time.
Aggregations and time windows
AGG( table.column | table.* [WHERE <row filter>] ) [OVER ( <window_spec> )]
An aggregation names a column (or table.*), an optional inline row filter,
and a frame introduced by OVER. The frame carries the time window;
positional offsets do not exist in this grammar.
OVER is optional. Without it the frame is unbounded in the direction of the
clause: the future in PREDICT and ASSUMING, the past in WHERE.
PREDICT NOT EXISTS(orders.*) -- (NOW, +inf] will they ever order again?
FROM customers
WHERE COUNT(orders.*) > 5 -- (-inf, NOW] have they ever ordered 5+ times?
Functions
SUM, AVG, MIN, MAX, COUNT, COUNT_DISTINCT, LIST_DISTINCT,
ARRAY_AGG, FIRST, LAST, EXISTS, NOT EXISTS.
COUNT(table.*)counts rows.FIRST/LASTpick a value by row time, which suits status columns.LIST_DISTINCTpredicts the set of values that will appear (usually FK IDs); duplicates collapse.ARRAY_AGGpredicts the values in order and keeps duplicates. Use it when "bought twice" should count twice.- Either can be ranked with the frame's
RANK TOP Kdirective, or turned into a per-value yes/no withCLASSIFY. EXISTS(table.*)/NOT EXISTS(table.*)is a boolean existence test, true when any matching row falls in the frame. It reads more directly than theCOUNT(...) > 0idiom (which is still valid):EXISTS(orders.*) OVER (90 DAYS PRECEDING).
The OVER frame
A frame is measured relative to the anchor time (NOW). Membership is
start-exclusive, end-inclusive. Direction comes from PRECEDING
(past) / FOLLOWING (future), and durations are always positive.
window_spec := frame [HORIZONS <positive-int> [STEP <positive-duration>]]
[RANK TOP <positive-int>]
frame := RANGE BETWEEN <bound> AND <bound>
| <positive-duration> PRECEDING -- shorthand: (NOW - dur, NOW]
| <positive-duration> FOLLOWING -- shorthand: (NOW, NOW + dur]
| UNBOUNDED PRECEDING -- all history up to NOW
bound := NOW
| <positive-duration> PRECEDING
| <positive-duration> FOLLOWING
| UNBOUNDED PRECEDING
| UNBOUNDED FOLLOWING
duration := <positive-number> <unit>
The single-bound forms are shorthand for a frame with one endpoint at
NOW. Use the full RANGE BETWEEN form when neither endpoint is NOW:
COUNT(orders.*) OVER (30 DAYS FOLLOWING) -- (NOW, NOW+30d] : the next 30 days
COUNT(orders.*) OVER (90 DAYS PRECEDING) -- (NOW-90d, NOW] : the last 90 days
COUNT(orders.*) OVER (UNBOUNDED PRECEDING) -- all history up to NOW
SUM(sales.qty) OVER (RANGE BETWEEN 15 DAYS FOLLOWING AND 45 DAYS FOLLOWING)
-- a future window not starting now
- Units:
SECONDS,MINUTES,HOURS,DAYS,WEEKS,MONTHS,YEARS(singular or plural, case-insensitive; a month is a 30-day approximation). - Target frames face the future (
FOLLOWING). Filter frames (insideWHERE) face the past (PRECEDING/UNBOUNDED PRECEDING). The validator enforces both directions.
Multiple horizons (forecasting)
Append HORIZONS N to repeat the frame N times back to back. A multi-horizon
window is a forecast (there is no separate FORECAST clause). STEP
optionally sets the stride between horizons; it defaults to the frame width, so
give a smaller STEP for overlapping horizons:
SUM(usage.count) OVER (1 DAY FOLLOWING HORIZONS 28) -- 28 daily steps
SUM(sales.qty) OVER (30 DAYS FOLLOWING HORIZONS 6 STEP 7 DAYS) -- overlapping
RANK TOP — ranking within a frame
RANK TOP K keeps the K most likely values from the frame, turning a set-valued
aggregation into a ranking. It is part of the frame, so when and
how many stay independent:
PREDICT ARRAY_AGG(transactions.article_id) OVER (30 DAYS FOLLOWING RANK TOP 12)
FROM customers
The 12 articles each customer is most likely to buy in the next 30 days. Drop the frame's duration to rank over the whole future:
PREDICT ARRAY_AGG(transactions.article_id) OVER (RANK TOP 12)
FROM customers
Named windows
Declare a frame once with a trailing WINDOW clause and reference it by name
as OVER <name>, which helps when several aggregations share one frame:
PREDICT SUM(orders.revenue) OVER w - SUM(orders.cost) OVER w
FROM customers
WINDOW w AS (30 DAYS FOLLOWING)
A window name is declared exactly once and accepts every frame form, including
HORIZONS / STEP. Referencing an undeclared name is an error.
Inline row filters
Filter the rows being aggregated (distinct from WHERE, which filters
entities):
COUNT(transactions.* WHERE transactions.amount > 10) OVER (30 DAYS FOLLOWING)
Conditions and operators
Conditions appear in three places: comparing the target
(PREDICT COUNT(...) = 0), filtering entities (WHERE), filtering aggregated
rows (inline WHERE inside an aggregation), and stating counterfactuals
(ASSUMING).
Comparison operators
= == != > >= < <=
Either side of a comparison may be a literal, a static column, an aggregation
over an OVER frame, or a richer expression (arithmetic, CASE WHEN … END,
COALESCE, NULLIF, ABS/LOG/EXP/LEAST/GREATEST). Column-to-column
comparisons are allowed, for example orders.shipped_at > orders.ordered_at.
Boolean composition
AND, OR, NOT, with parentheses.
Membership and null tests
customers.location IN ('NY', 'CA')
customers.location NOT IN ('ALASKA', 'HAWAII')
articles.description IS NULL
articles.description IS NOT NULL
String predicates
loan.status LIKE '%DENIED' -- SQL % wildcards
movie.title STARTS WITH 'The'
movie.title ENDS WITH 'Returns'
movie.title CONTAINS 'Star'
Bind parameters
Anywhere a literal is allowed, :name stands in for a value supplied at
execution time. With IN, one parameter binds the whole list, so a single
query text serves any cohort size:
WHERE customers.customer_id = :id
WHERE customers.customer_id IN :ids
WHERE customers.plan LIKE :pattern AND customers.age > :min_age
engine.execute(ExecutionInput(query=q, params={"ids": ["C7", "C9"]}))
Values come from params on the execution input, the same place AS OF :t
reads its anchor. A :name with no supplied value is an error, never a silent
NULL.
A parameter on the primary key does double duty: it also selects the cohort (see query structure), so the engine scores just those entities instead of enumerating the table.
Examples
-- entity filter mixing a static attribute and a past-facing aggregation
WHERE customers.age >= 18 AND EXISTS(orders.*) OVER (90 DAYS PRECEDING)
-- cohort pinned by a bound parameter
WHERE customers.customer_id IN :ids
-- predicate target: multiclass-style question on a status column
PREDICT LAST(loan.status) OVER (30 DAYS FOLLOWING) NOT LIKE '%DENIED' FROM loan
Task types
The validator infers a task type from the target's shape. The task type selects the model checkpoint and the output form. You never declare it.
| Target shape | Task type | Output |
|---|---|---|
bare aggregation — SUM(...), COUNT(...) | regression | value |
aggregation vs literal — COUNT(...) = 0 | binary classification | probability |
EXISTS(...) / NOT EXISTS(...) (boolean target) | binary classification | probability |
FIRST / LAST / static categorical column | multiclass classification | class + probabilities |
LIST_DISTINCT(...) OVER (... RANK TOP K) | ranking | ranked ID list |
any target whose window has HORIZONS > 1 | forecasting | value per horizon |
Cookbook
Copy-paste starting points, drawn from the shared 67-query test corpus.
Churn (binary classification)
PREDICT NOT EXISTS(transactions.*) OVER (30 DAYS FOLLOWING)
FROM customers
WHERE EXISTS(transactions.*) OVER (90 DAYS PRECEDING)
Add RETURN PROBABILITY to get calibrated scores instead of the default output:
PREDICT NOT EXISTS(transactions.*) OVER (30 DAYS FOLLOWING)
FROM customers
WHERE EXISTS(transactions.*) OVER (90 DAYS PRECEDING)
RETURN PROBABILITY
Spend / LTV slice (regression)
PREDICT SUM(transactions.price) OVER (30 DAYS FOLLOWING) FROM customers
Recommendations (ranking)
PREDICT LIST_DISTINCT(transactions.article_id) OVER (30 DAYS FOLLOWING RANK TOP 12)
FROM customers
Daily demand, 4 weeks out (forecasting)
PREDICT SUM(usage.count) OVER (1 DAY FOLLOWING HORIZONS 28)
FROM accounts
The HORIZONS 28 on the window makes this a 28-step forecast, one prediction
per day.
Specific entities
FROM is the only entity clause. Narrow to specific ids with a WHERE
predicate on the primary key. Bind the ids as a parameter so one query text
serves any cohort:
PREDICT NOT EXISTS(orders.*) OVER (90 DAYS FOLLOWING)
FROM users
WHERE users.user_id IN :ids
engine.execute(ExecutionInput(query=q, params={"ids": [42, 123]}))
A literal list (IN (42, 123)) works too, but hard-codes the cohort.
Counterfactual
PREDICT NOT EXISTS(orders.*) OVER (90 DAYS FOLLOWING)
FROM users
WHERE users.user_id = 42
ASSUMING users.plan = 'premium'
Status prediction (string predicate)
PREDICT LAST(loan.status) OVER (30 DAYS FOLLOWING) NOT LIKE '%DENIED' FROM loan
Missing-attribute prediction (static target)
PREDICT articles.description IS NULL FROM articles
Population carve-outs
PREDICT SUM(transactions.value) OVER (RANGE BETWEEN 15 DAYS FOLLOWING AND 45 DAYS FOLLOWING) > 100
FROM customers
WHERE customers.location NOT IN ('ALASKA', 'HAWAII')
As-of a fixed anchor
PREDICT SUM(orders.amount) OVER (RANGE BETWEEN 15 DAYS FOLLOWING AND 45 DAYS FOLLOWING)
FROM customers
WHERE customers.customer_id IN :ids
AS OF :prediction_time
RETURN EXPECTED VALUE
Reusable named window
PREDICT SUM(orders.revenue) OVER w - SUM(orders.cost) OVER w
FROM customers
WINDOW w AS (30 DAYS FOLLOWING)