Skip to main content

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'
note

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.

FormValid for
EXPECTED VALUEregression, forecasting, binary classification
PROBABILITYbinary classification
CLASSbinary classification, multiclass classification
DISTRIBUTIONbinary classification, multiclass classification
MULTICLASSmulticlass classification
MULTILABELranking

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 / LAST pick a value by row time, which suits status columns.
  • LIST_DISTINCT predicts the set of values that will appear (usually FK IDs); duplicates collapse.
  • ARRAY_AGG predicts 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 K directive, or turned into a per-value yes/no with CLASSIFY.
  • EXISTS(table.*) / NOT EXISTS(table.*) is a boolean existence test, true when any matching row falls in the frame. It reads more directly than the COUNT(...) > 0 idiom (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 (inside WHERE) 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 shapeTask typeOutput
bare aggregation — SUM(...), COUNT(...)regressionvalue
aggregation vs literal — COUNT(...) = 0binary classificationprobability
EXISTS(...) / NOT EXISTS(...) (boolean target)binary classificationprobability
FIRST / LAST / static categorical columnmulticlass classificationclass + probabilities
LIST_DISTINCT(...) OVER (... RANK TOP K)rankingranked ID list
any target whose window has HORIZONS > 1forecastingvalue 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)