Parameterized metrics¶
revenue_1m_avg, revenue_3m_avg, revenue_6m_avg are one metric asked
three ways. Modeled one per width they multiply: every new width is another
metric to define, review, and keep in step with its siblings — and every
dashboard that shows "the moving average" has to say which one it means.
A parameterized metric declares the width itself as a typed, query-time
parameter. One moving_avg metric, and the query says how wide:
$ dosi --model model.yaml query --metrics amount_total,moving_avg \
--group-by metric_time:month --where "sales.region = 'east'" \
--order metric_time__month --param n=1,3,6 --execute --db sales.duckdb
metric_time__month amount_total moving_avg__n_1 moving_avg__n_3 moving_avg__n_6
2024-11-01 00:00:00 10 10 10 10
2024-12-01 00:00:00 20 20 15 15
2025-01-01 00:00:00 30 30 20 20
2025-02-01 00:00:00 40 40 30 25
2025-03-01 00:00:00 50 50 40 30
2025-04-01 00:00:00 60 60 50 35
6 rows
A parameter fills a typed hole in the metric's declaration — never SQL text. It is declared with a type and a domain, so the model is valid for every value the parameter is allowed to take, and a binding can neither step outside that domain nor inject anything into the compiled SQL. The domain is where governance lives: it states which definitions this metric is permitted to take.
This page is the reference for the feature — what can be parameterized, how to declare and bind it, and where the boundaries are. The wire-level schema is in datus-extensions.md.
What can be parameterized¶
A parameter can fill exactly these six holes, and nothing else:
| Slot | What it controls | Declared on | Type |
|---|---|---|---|
rolling.periods |
the width of a rolling window | window.periods under type: rolling |
int, domain ≥ 1 |
frame.preceding |
how many buckets a general frame looks back | window.frame.preceding |
int, domain ≥ 0 |
offset.count |
how many periods back a comparison reaches | window.offset.count (object form) |
int, domain ≥ 1 |
rank.buckets |
the bucket count of an ntile |
window.rank.buckets |
int, domain ≥ 1 |
value.n |
the position an nth_value reads |
window.value.n |
int, domain ≥ 1 |
filter.literal |
a literal in a derived filter metric's predicate | the where of a derive of type: filter |
int, float or string |
The five window slots are counts, so they take integers. A filter.literal
hole takes any of the three types (region = :r, amount > :t,
datediff_pay < :n).
Everything else is fixed at modeling time. A parameter reference in a metric's
expression, in a query's where_sql, or in any other extension key is an
error, never a silently rendered literal. There are no parameterized dates,
grains, dimensions, aggregate functions, or dialects.
Declaring a parameter¶
Parameters are declared on the metric, in the params list of its DATUS
extension entry, alongside the window or derive payload whose hole they
fill. Reference the hole with {"param": "<name>"} in a window slot, or with
:<name> in a filter predicate.
metrics:
# A rolling width as a parameter: `moving_avg` at n=3 is the classic
# 3-month moving average; n=[1,3,6] is three columns in one query.
- name: moving_avg
description: n-month rolling average (n is a query-time parameter)
expression:
dialects: [{ dialect: ANSI_SQL, expression: "SUM(sales.amount)" }]
custom_extensions:
- vendor_name: DATUS
data: |
{"v": "1.5",
"params": [{"name": "n", "type": "int", "default": 3, "min": 1, "max": 12,
"description": "trailing window width in buckets of the queried grain"}],
"window": {"type": "rolling", "function": "avg", "periods": {"param": "n"}}}
# A filter literal as a parameter. The authored expression is the default
# (n = 7) instantiation — the two must agree.
- name: paid_within_n
description: Payments within the first n days after registration
expression:
dialects:
- dialect: ANSI_SQL
expression: "SUM(CASE WHEN base.datediff_pay < 7 THEN base.sum_pay_amount END)"
custom_extensions:
- vendor_name: DATUS
data: |
{"v": "1.5",
"params": [{"name": "n", "type": "int", "default": 7,
"allowed": [1, 3, 7, 14, 30, 60, 90, 180],
"description": "days since registration, exclusive upper bound"}],
"derive": {"type": "filter", "base": "pay_total", "where": "datediff_pay < :n"}}
Each entry in params carries:
| Field | Required | Meaning |
|---|---|---|
name |
yes | [a-z][a-z0-9_]*, unique within the metric; this is what a query binds |
type |
yes | int, float or string |
default |
yes | the value used when a query binds nothing; must lie in the domain |
allowed |
either | an explicit list of legal values |
min / max |
or | a closed range, either side optional |
description |
no | shown in the catalog; write it for the agent that will read it |
allowed and min/max are mutually exclusive. A string parameter must
declare allowed — an unbounded string parameter would be an arbitrary
dimension value rather than a governed choice — and takes no min/max. An
int or float parameter with no domain at all still compiles, with a
datus_param_unbounded warning; a parameter no slot references warns with
datus_param_unused.
One parameter may fill several slots, and one predicate may hold several
parameters (datediff_pay < :n AND platid = :plat). A predicate made only of
parameters (:n < 7) is rejected — a filter must still test a column.
A metric that declares parameters but is queried without any behaves exactly
like an unparameterized metric at its defaults, down to the generated SQL. A
metric derived from parameterized members inherits their parameters: if
ltv = paid_within_n / new_user_cnt, then ltv takes n without declaring
it.
Binding at query time¶
Bindings are per query, by parameter name, and reach every queried metric that declares that name — directly or through its derive members.
--param is repeatable (--param n=6 --param k=4). A comma-separated
value is a list; quote a string value that itself contains a comma
(--param city=NYC,"Paris, FR" is two values).
Omitted parameters take their declared defaults. Binding nothing is always legal and always means "the metric as modeled".
A list expands the metric into one column per value. Two lists take the cartesian product, first declared parameter outermost. The expansion is capped at 64 metric columns per query.
Types are strict. An int parameter rejects 7.5 and "7". The single
implicit conversion is that a float parameter accepts a JSON integer.
Reading the result¶
A default binding leaves the column named after the metric — moving_avg.
A non-default binding names it {metric}__{param}_{value}:
moving_avg__n_6 # n = 6
ltv__n_30 # inherited parameter
rate__t_1_5 # float 1.5
region_share__r_o_neil # strings are sanitized
Several parameters append in declaration order, and under a list binding every column is suffixed — including the one at the default. The rule exists so two dashboards can never show the same metric name over different widths.
Every response also carries the machine-readable form, outputs — one entry
per metric column with its metric and the parameters that produced it,
defaults included:
$ dosi --model model.yaml query --metrics amount_total,moving_avg \
--group-by metric_time:month --param n=3,6 --format json --execute --db sales.duckdb
{
"dialect": "duckdb",
"sql": "WITH base AS (…) SELECT … AVG(sales_amount_sum) OVER (ORDER BY metric_time__month ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS moving_avg__n_3, … AS moving_avg__n_6 FROM base",
"outputs": [
{"column": "amount_total", "metric": "amount_total"},
{"column": "moving_avg__n_3", "metric": "moving_avg", "params": {"n": 3}},
{"column": "moving_avg__n_6", "metric": "moving_avg", "params": {"n": 6}}
],
…
}
outputs appears in the CLI JSON, the REST and MCP query responses, the
Python result dicts, and explain; over Arrow the same information rides as
field metadata (dosi.metric, dosi.params). Read it instead of parsing
column names.
One consequence for ordering: a bare metric name in order_by resolves only
while that metric is a single column. Under a list binding, name the column
(moving_avg__n_6).
Discovering what a metric takes¶
dosi list metrics --format json, GET /v1/metrics, and the MCP
list_metrics tool carry each metric's params — the declaration plus the
slots it fills, and declared_on when the parameter is inherited from a
member:
$ dosi --model model.yaml list metrics --format json
{
"name": "moving_avg",
"params": [
{"name": "n", "type": "int", "default": 3, "min": 1, "max": 12,
"description": "trailing window width in buckets of the queried grain",
"slots": ["rolling.periods"]}
],
…
}
For agents, the MCP describe_metric tool adds param_schema — a JSON Schema
for the params map, with allowed rendered as enum and min/max as
minimum/maximum. An agent reads the schema, then binds within it.
Whether an engine supports parameters at all is in dosi info --format json
and GET /v1/capabilities, as the params key of the DATUS extension
registry ("since": "1.5").
Limits and boundaries¶
- Six slots, listed above. Dates, grains, dimensions, aggregate functions, and dialects are not parameterizable.
- One binding set per query.
paramsapplies to every queried metric that declares the name; there is no per-metric override, so two metrics sharing a parameter name are asked at the same value. - 64 metric columns per query after list expansion
(
param_expansion_too_large). - Attribution binds one value per parameter.
dosi attribute --param n=30is fine; a list is refused with a single-value retry. The resolved bindings come back incomparison_metadata.params. See attribution.md. - Basic mode ignores parameters. Under
--osi-basicthe D-WINDOW and D-DERIVE hosts are ignored, so every slot takes its declared default and a query-timeparamsis rejected asunknown_metric_param. - Older engines. An engine below DATUS 1.5 rejects the model at the version
gate. A 1.4 server, however, tolerates unknown request fields: it silently
drops a client's
paramsand answers at the defaults. CheckGET /v1/capabilities(ordosi info) before relying on binding remotely.
Errors¶
Every rejection is structured, names the metric, and carries a
suggested_retry you can send back unchanged. Full catalog in
errors.md.
| Code | Raised when |
|---|---|
unknown_metric_param |
no queried metric declares the bound name; candidates lists the names that are declared |
param_out_of_domain |
the value is the wrong type or outside the domain — also an empty or repeated list, or a list where one value is required |
param_expansion_too_large |
list bindings expand past 64 metric columns |
$ dosi --model model.yaml query --metrics moving_avg --group-by metric_time:month --param n=0 --format json
{
"code": "param_out_of_domain",
"message": "parameter \"n\" must be in [1, 12] (declared on metric \"moving_avg\")",
"metrics": ["moving_avg"],
"suggested_retry": "\"params\": {\"n\": 3}"
}
See also¶
- datus-extensions.md — the D-PARAM wire schema, versioning, and compatibility contract.
- window-extension.md — the window families whose slots the five integer parameters fill.
- extensions-guide.md — the how-to for window metrics themselves.
- cli.md · rest-api.md · mcp.md — the
--param/paramssurfaces.