# dpm-xl
A Python parser and evaluator for **DPM-XL**, the expression language of EBA
validation rules and similar checks. Give it a rule and your report data; it
tells you which records fail and explains each failure in plain numbers.
dpm-xl is an independent project, not affiliated with or endorsed by the
European Banking Authority.
A failure reads like this:
```text
1,204 should equal 1,211 (1,180 + 31): difference 7, at least 5.5 given the reported precision
```
It is for teams that check supervisory reports before they are filed, build
validation into a reporting platform, or analyse and edit DPM-XL rules.
- Parses every construct of the DPM-XL grammar, including every EBA
validation rule and precondition of DPM releases 3.4–4.4 (14,000+ distinct
expressions, about 0.1 ms each).
- Evaluates them with EBA's interval arithmetic, null logic and key joins, and
points each failure at the cells involved.
- Finds problems before evaluation, for [editors](guides/editor.md) and
[CI](guides/ci.md): every syntax error, unknown functions and tables,
coordinates a table lacks, keys and operand types, each with its position
and, where one fits, a suggested fix.
- Has no dependencies. The optional `columnar` extra adds a polars evaluator
for reports with millions of open-table rows.
- Reads no files and knows no DPM release: data comes through a three-method
[provider](guides/provider.md), so the same evaluator runs against a report
upload, a database or test fixtures. [dpm-xbrl](https://luukhopman.github.io/dpm-xbrl/)
reads xBRL-CSV and XBRL-XML reports; the
[end-to-end guide](guides/end-to-end.md) connects the two.
## Installation
```bash
pip install dpm-xl # Python 3.11+, no dependencies
pip install "dpm-xl[columnar]" # adds the polars evaluator
```
## In 30 seconds
```python
from dpmxl import MemoryProvider, Number, TableLayout, evaluate_rule, explain
layout = TableLayout("C_01.00", rows=("0010", "0020", "0030"), columns=("0010", "0020"))
values = { # (table, row, column, sheet, open keys) -> value with XBRL decimals
("C_01.00", "0010", "0010", None, ()): Number.of(100, 0),
("C_01.00", "0020", "0010", None, ()): Number.of(60, 0),
("C_01.00", "0030", "0010", None, ()): Number.of(40, 0),
("C_01.00", "0010", "0020", None, ()): Number.of(1204, 0),
("C_01.00", "0020", "0020", None, ()): Number.of(1180, 0),
("C_01.00", "0030", "0020", None, ()): Number.of(31, 0),
}
provider = MemoryProvider([layout], values)
expression = (
"with {tC_01.00, c*, default: 0, interval: true}: {r0010} = {r0020} + {r0030}"
)
result = evaluate_rule(expression, provider, precondition="{v_C_01.00}")
print(result.status, result.counts)
for outcome in result.failures:
print(outcome.key, explain(result.evaluation, expression, outcome.key).summary)
```
```text
failed {'passed': 1, 'failed': 1, 'null': 0}
{'c': '0020'} 1,204 should equal 1,211 (1,180 + 31): difference 7, at least 5.5 given the reported precision
```
Column `0010` adds up; in column `0020`, 1,204 is 7 short of 1,180 + 31, more
than the rounding of three whole-unit amounts (±1.5) can explain.
## Toolkit
**Part of the dpm toolkit**, three Python packages that work together:
| Package | What it does |
| :-- | :-- |
| | Parse and evaluate DPM-XL validation rules **(this package)** |
|
| Read, write and check xBRL-CSV and XBRL-XML reports |
|
| Generate test reports that satisfy DPM-XL rules |
The [toolkit tour](https://luukhopman.github.io/dpm-xl/guides/toolkit-tour/)
takes one report through all three: generated with dpm-synth, read with
dpm-xbrl, then evaluated and explained with dpm-xl.
## Where next
` | text (may be categorical) | One per open key: text, canonical items (`category:code`) or ISO dates |
| `num` | `Decimal(38, 12)` | Numeric value |
| `thr` | `Decimal(38, 12)` | Its interval threshold, `0.5·10^-decimals` (0 when exact) |
| `txt` | text | Text, canonical item or ISO date value |
| `flag` | Boolean | Boolean value |
All four value columns are null for a cell with no fact, and a cell and
open-key combination appears at most once.
Here an open table of 20,000 counterparties, with amounts reported in whole
units:
```python
from decimal import Decimal
import polars as pl
from dpmxl.columnar import CELL, DEC, FLAG, NUM, THR, TXT
exposures = pl.DataFrame(
{
"counterparty": [f"LEI-{i:06d}" for i in range(20_000)],
"amount": [Decimal(i % 1000 - 3) for i in range(20_000)],
}
)
class ExposureFrames:
"""A frame provider over one open table, C_27.00, keyed by qCPI."""
def select_frame(self, query):
if query.table != "C_27.00":
raise LookupError(f"unknown table {query.table}")
return exposures.select(
pl.concat_str(pl.lit("C_27.00/"), pl.col("counterparty")).alias(CELL),
pl.lit(None, pl.String).alias("r"),
pl.lit("0010").alias("c"),
pl.lit(None, pl.String).alias("s"),
pl.col("counterparty").alias("k:qCPI"),
pl.col("amount").cast(DEC).alias(NUM),
pl.lit(Decimal("0.5")).cast(DEC).alias(THR), # decimals = 0
pl.lit(None, pl.String).alias(TXT),
pl.lit(None, pl.Boolean).alias(FLAG),
)
def table_reported(self, table):
return table == "C_27.00"
def parameter(self, name):
return None
```
The query's selectors are applied by the evaluator, so `select_frame` may
return the whole table; filtering by `query.columns` first saves memory on
wide tables. `value_columns` maps one Python
value to the four value columns, and
`frame_from_points` builds a frame from
`DataPoint`s.
## Evaluate
`evaluate_rule_columnar` returns a
`ColumnarResult` with the status, the record
counts and the keys of the failing records. Share one
`FrameCache` across the rules of a report.
```python
from dpmxl.columnar import FrameCache, evaluate_rule_columnar
cache = FrameCache()
result = evaluate_rule_columnar("{tC_27.00, c0010} > 0", ExposureFrames(), cache=cache)
print(result.status, result.passed, result.failed, result.null)
print(result.failing_keys[:2])
print(result.failing_frame.height)
```
```text
failed 19920 80 0
[{'qCPI': 'LEI-000000'}, {'qCPI': 'LEI-000001'}]
80
```
`failing_keys` lists the first `max_failing_keys` (1,000 by default) failing
keys as dictionaries; `failing_frame` has all of them, one string column per
key. Statuses are the same five as the reference evaluator's.
## Fall back on `ColumnarUnsupported`
Statements and assignments, set operators such as `union`, analytic
functions, the `&` operator, functions other than the aggregates, `filter`,
`abs`, `isnull`, `nvl`, `match`, `len` and `min`/`max` (so `time_shift`,
`annualise`, `substr`, the mathematical and the date functions), typed
parameter, operation and table-group references, values that do not fit
`Decimal(38, 12)`, arithmetic that would overflow it, record-level errors
(a zero divisor or a type error, which the reference evaluator confines to
its record), ordering items or text that may be an item and a `default`
standing in for a record another operand has
([Records and keys](../concepts/records-and-keys.md#defaults-fill-in-missing-records))
all raise `ColumnarUnsupported`. Catch it and evaluate the rule with
`evaluate_rule`, which needs `select`. A
provider can offer both: here `select` builds `DataPoint`s from the same
frame.
```python
from dpmxl import DataPoint, Number, SelectionCache, evaluate_rule, explain
from dpmxl.columnar import ColumnarUnsupported
class Exposures(ExposureFrames):
"""Both interfaces: frames for the columnar evaluator, points for the rest."""
def select(self, query):
if query.table != "C_27.00":
raise LookupError(f"unknown table {query.table}")
return [
DataPoint(
"C_27.00",
column="0010",
keys={"qCPI": lei},
value=Number.of(amount, 0),
ref=f"C_27.00/{lei}",
)
for lei, amount in exposures.iter_rows()
]
def open_keys(self, table):
return ("qCPI",) if table == "C_27.00" else ()
def run_rule(expression, provider, frames, points):
"""Evaluate on frames; fall back to the reference evaluator."""
try:
result = evaluate_rule_columnar(expression, provider, cache=frames)
except ColumnarUnsupported as reason:
result = evaluate_rule(expression, provider, cache=points, trace=False)
return "reference", result.status, result.counts["failed"], str(reason)
return "columnar", result.status, result.failed, None
provider, frames, points = Exposures(), FrameCache(), SelectionCache()
for expression in (
"{tC_27.00, c0010} > -5",
"total := sum({tC_27.00, c0010}); total > 0",
):
print(run_rule(expression, provider, frames, points))
```
```text
('columnar', 'passed', 0, None)
('reference', 'passed', 0, 'Program')
```
The `ColumnarUnsupported` message names what the columnar evaluator lacks.
Log it if you want to know which rules take the slower path.
## Explain the failures
A `ColumnarResult` carries no trace, so explanations come from the reference
evaluator. Evaluate only the rules that failed again with `evaluate_rule`
(on a large table this takes as long as any reference evaluation, so cap the
number of explanations you show):
```python
expression = "{tC_27.00, c0010} > 0"
traced = evaluate_rule(expression, provider, cache=points)
for outcome in traced.failures[:3]:
print(
outcome.key["qCPI"], explain(traced.evaluation, expression, outcome.key).summary
)
```
```text
LEI-000000 -3 should exceed 0: difference 3
LEI-000001 -2 should exceed 0: difference 2
LEI-000002 -1 should exceed 0: difference 1
```
## Precision
Columnar values are `Decimal(38, 12)`: sums and differences are exact,
products and quotients round to 12 decimals (thresholds of quotients round
up), where the reference evaluator keeps 34 significant digits. An exact
comparison that hinges on the 13th decimal can therefore differ; values that
do not fit `Decimal(38, 12)` raise `ColumnarUnsupported` rather than being
rounded.
## Any `DataProvider` as frames
`PointsFrameProvider` adapts any
`DataProvider`, which is the quickest way to try the columnar evaluator on
existing data. It converts each selection's `DataPoint`s, so the speed-up is
smaller than with frames built directly from your storage:
```python
from dpmxl.columnar import PointsFrameProvider
print(
evaluate_rule_columnar(
"{tC_27.00, c0010} > 0", PointsFrameProvider(provider)
).failed
)
```
```text
80
```
## Threads
`evaluate_rule_columnar` may run concurrently on one report, and polars
releases the GIL, so threads speed up a rule run on several cores. A shared
`FrameCache` builds each selection once while other threads wait for it,
within its row budget (`FrameCache(max_rows=20_000_000)`, least recently used
selections evicted first). See [Run rules in threads](threads.md).
# Run rules in threads
`evaluate_rule` and `evaluate_rule_columnar` may run concurrently on one
report. What may be shared between threads:
| Object | Safe to share | Notes |
| --- | --- | --- |
| `parse_cached` | yes | Parsed trees are immutable and serve every report. |
| `SelectionCache` | yes | Two threads that miss the same selection at once may both build it; either result is kept. Use one per report. |
| `FrameCache` | yes | Builds each selection once while other threads asking for it wait. Use one per report. |
| `MemoryProvider` | yes | Read-only after construction. |
| Your provider | if it is safe to read concurrently | A provider with a database connection per instance is not; use a connection pool or one provider per thread. |
| `Evaluator` | no | One per evaluation; `evaluate_rule` creates its own. |
The reference evaluator is pure Python, so threads help it only when your
provider waits on I/O. The columnar evaluator spends its time in polars,
which releases the GIL, so threads use several cores.
## A rule run on a thread pool
```python
from concurrent.futures import ThreadPoolExecutor
from dpmxl import MemoryProvider, Number, SelectionCache, TableLayout, evaluate_rule
provider = MemoryProvider(
[TableLayout("C_01.00", rows=("0010", "0020", "0030"), columns=("0010", "0020"))],
{
("C_01.00", row, column, None, ()): Number.of(amount, 0)
for (row, column), amount in {
("0010", "0010"): 100,
("0020", "0010"): 60,
("0030", "0010"): 40,
("0010", "0020"): 1204,
("0020", "0020"): 1180,
("0030", "0020"): 31,
}.items()
},
)
rules = {
"v0001": "with {tC_01.00, c*, default: 0, interval: true}: {r0010} = {r0020} + {r0030}",
"v0002": "with {tC_01.00, c0010}: {r0020} >= {r0030}",
"v0003": "{tC_01.00, r0010, c0020} < 1000",
}
cache = SelectionCache() # one per report, shared by every thread
def run(code):
return code, evaluate_rule(rules[code], provider, cache=cache).status
with ThreadPoolExecutor(max_workers=4) as pool:
for code, status in pool.map(run, rules):
print(code, status)
```
```text
v0001 failed
v0002 passed
v0003 failed
```
`pool.map` returns results in submission order, whatever order the threads
finish in.
## Columnar rules on threads
The same pattern with the columnar evaluator and a shared `FrameCache`,
falling back to the reference evaluator for rules it does not cover (see
[Evaluate large reports](columnar.md)):
```python
from dpmxl.columnar import (
ColumnarUnsupported,
FrameCache,
PointsFrameProvider,
evaluate_rule_columnar,
)
frames = FrameCache()
frame_provider = PointsFrameProvider(provider)
def run_columnar(code):
try:
return code, evaluate_rule_columnar(
rules[code], frame_provider, cache=frames
).status
except ColumnarUnsupported:
return code, evaluate_rule(rules[code], provider, cache=cache).status
with ThreadPoolExecutor(max_workers=4) as pool:
print(dict(pool.map(run_columnar, rules)))
```
```text
{'v0001': 'failed', 'v0002': 'passed', 'v0003': 'failed'}
```
## Processes
For many reports at once, evaluate each report in its own process (for
example with `ProcessPoolExecutor`) and use threads within a report. Parsed
trees, caches and providers are per process; `parse_cached` warms up again
in each.
# Compare with earlier reports
`time_shift(x, period, count[, key])` compares a value with the same value
some periods earlier. It works in one of two ways, depending on `key`:
- **Another report.** When `key` is not a key component of `x` (usually
`refPeriod`, or no key at all), `x` is evaluated on the report `count`
periods earlier, which the provider's `earlier_report(period, count)`
returns. This is how EBA rules compare with the previous reporting period.
- **Within the report.** When `key` is a date key component of `x`, the
values of that key move `count` periods later, so each record lines up
with the one `count` periods before it.
Periods are `A` (year), `S` (semester), `Q` (quarter), `M` (month), `W`
(week) and `D` (day). Dates move by calendar months (12, 6, 3 or 1; a
month's last day stays the last day) or by 7 or 1 days. A negative `count`
looks forward.
## Against the previous report
`MemoryProvider` takes the earlier reports as `earlier_reports`, keyed by
`(period, count)`. Your own provider implements `earlier_report`
([Connect your data](provider.md#earlier_report-time_shift-across-periods)).
```python
from datetime import date
from dpmxl import MemoryProvider, Number, TableLayout, evaluate_rule, explain
layout = [TableLayout("C_02.00", rows=("0010",), columns=("0010",))]
september = MemoryProvider(
layout,
{("C_02.00", "0010", "0010", None, ()): Number.of(240, 0)},
parameters={"refPeriod": date(2026, 9, 30)},
)
december = MemoryProvider(
layout,
{("C_02.00", "0010", "0010", None, ()): Number.of(260, 0)},
parameters={"refPeriod": date(2026, 12, 31)},
earlier_reports={("Q", 1): september},
)
growth = "{tC_02.00, r0010, c0010} <= time_shift({tC_02.00, r0010, c0010}, Q, 1, refPeriod) * 1.05"
result = evaluate_rule(growth, december)
outcome = result.failures[0]
print(explain(result.evaluation, growth, outcome.key).summary)
for point in outcome.points:
print(point.ref)
```
```text
260 should not exceed 252 (time_shift(240, Q, 1, refPeriod) × 1.05): difference 8
('C_02.00', '0010', '0010', None, ())
EarlierRef(period='Q', count=1, ref=('C_02.00', '0010', '0010', None, ()))
```
The explanation shows the earlier report's value. Points read from the earlier
report carry `EarlierRef` as their `ref`,
wrapping that report's own `ref`, so a user interface can tell the two cells
apart and link to the earlier report.
When the provider has no such report, the rule is `unsupported` rather than
failed:
```python
year_on_year = "{tC_02.00, r0010, c0010} >= time_shift({tC_02.00, r0010, c0010}, A, 1)"
result = evaluate_rule(year_on_year, december)
print(result.status, "|", result.message)
```
```text
unsupported | time_shift needs the report 1 year(s) earlier, which the provider does not have
```
Without an `earlier_report` method at all, the message says that the rule
compares with the previous reporting period, which a single report does not
include. Evaluating with a shared `SelectionCache` keeps a separate cache for
each earlier report.
## Within one report, on a date key
An open table keyed by a date can compare each date with the one before it:
```python
history = MemoryProvider(
[TableLayout("C_99.00", columns=("0010",), open_keys=("qDAT",))],
{
("C_99.00", None, "0010", None, (("qDAT", date(2026, 3, 31)),)): Number.of(
100, 0
),
("C_99.00", None, "0010", None, (("qDAT", date(2026, 6, 30)),)): Number.of(
90, 0
),
("C_99.00", None, "0010", None, (("qDAT", date(2026, 9, 30)),)): Number.of(
120, 0
),
},
)
rising = "{tC_99.00, c0010} >= time_shift({tC_99.00, c0010}, Q, 1, qDAT)"
result = evaluate_rule(rising, history)
for outcome in result.outcomes:
print(outcome.key["qDAT"], outcome.passed)
```
```text
2026-06-30 False
2026-09-30 True
```
The shifted operand has records for June and September (March plus one
quarter, June plus one quarter, and September moved past the data), so the
comparison has records where both sides do: June fails (90 < 100), September
passes (120 ≥ 90).
The explanation fills the shifted operand with the value of the record it
came from, here March's:
```python
june = result.failures[0]
print(explain(result.evaluation, rising, june.key).summary)
```
```text
90 should be at least 100 (time_shift(100, Q, 1, qDAT)): difference 10
```
## `annualise`
`annualise(x, fiscalYearEnd, key)` scales a year-to-date amount to a full
year: `x · 12 / months`, where `months` counts the months from the start of
the fiscal year that ends in month `fiscalYearEnd` up to and including the
month of the date in `key`. `key` is a date key component of `x`, or else a
report parameter such as `refPeriod`. The month is treated as complete, so
`annualise` is meant for period-end dates.
```python
result = evaluate_rule("annualise({tC_99.00, c0010}, 12, qDAT) > 300", history)
for outcome in result.outcomes:
print(outcome.key["qDAT"], outcome.passed)
```
```text
2026-03-31 True
2026-06-30 False
2026-09-30 False
```
March annualises to 100 · 12 / 3 = 400, June to 90 · 12 / 6 = 180 and
September to 120 · 12 / 9 = 160.
## Columnar evaluation
The columnar evaluator hands every `time_shift` and `annualise` rule to the
reference evaluator by raising `ColumnarUnsupported`
([Evaluate large reports](columnar.md)).
# Check rule syntax in CI
A repository of rules (exported from a DPM release, or written by hand)
should never hold an expression that does not parse. `dpm-xl check` parses
every expression and precondition of a rule source and exits with status 1
when any of them fails. With `--strict` it also fails on other errors, such
as an unknown function or a call with the wrong operands, and with
`--layouts` it checks tables and coordinates too
([CLI reference](../reference/cli.md#dpm-xl-check)).
## With the CLI
```console
$ dpm-xl check examples/rules.json
6/6 expressions parsed
$ echo $?
0
```
A syntax error names the rule and points at the offending text:
```console
$ dpm-xl check "with {tC_01.00, c0010}: {r0010} = {r0020} +* {r0030}"
argument: error: unexpected '*' where an operand is expected
with {tC_01.00, c0010}: {r0010} = {r0020} +* {r0030}
^
0/1 expressions parsed; 1 error
$ echo $?
1
```
In a rule file the source is `:expression` or `:precondition`
(JSON rule files) or `line :expression` (text files). `--json` prints the
same as a document, with the zero-based character `start` and `end` of each
problem, for annotating a pull request:
```console
$ dpm-xl check "if {tC_01.00, r0010} > 0 then true" --json
{
"schema_version": 1,
"parsed": 0,
"total": 1,
"errors": [
{
"source": "argument",
"message": "expected 'endif', found end of expression",
"position": 34,
"end": 34
}
],
"diagnostics": [
{
"source": "argument",
"severity": "error",
"code": "syntax",
"message": "expected 'endif', found end of expression",
"start": 34,
"end": 34,
"subject": null,
"suggestion": null
}
]
}
```
!!! note "A missing rule file is an error"
An argument that reads as a path (it ends in `.json` or `.txt`, or
holds a `/` without any brackets, operators or spaces) but names no
file fails with `error: no such rule file: ...` and exit status 3, so a
mistyped path cannot pass as an expression.
## A GitHub Actions job
```yaml
name: Rules
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
- run: test -f rules/rules.json
- run: uvx dpm-xl check rules/rules.json --strict
```
Pin the version (`uvx dpm-xl@1.0.0 ...`) if you want a new release to be a
deliberate change.
## With pytest
A test can check more than syntax: that every rule only refers to tables your
release defines, for example. `parse_program`
accepts everything `dpm-xl check` accepts, and
`tables` lists the tables an expression selects
from.
```python
import json
from pathlib import Path
from dpmxl import DpmXlSyntaxError, parse_program, tables
KNOWN_TABLES = {"C_01.00", "C_27.00"}
def check_rules(path):
problems = []
for rule in json.loads(Path(path).read_text()):
for part in ("expression", "precondition"):
if not rule.get(part):
continue
try:
tree = parse_program(rule[part])
except DpmXlSyntaxError as error:
problems.append(f"{rule['code']} {part}: {error.message}")
continue
unknown = set(tables(tree)) - KNOWN_TABLES
if unknown:
problems.append(
f"{rule['code']} {part}: unknown tables {sorted(unknown)}"
)
return problems
print(check_rules("examples/rules.json"))
```
```text
["v0004 expression: unknown tables ['C_99.00']"]
```
In a test, `assert not check_rules("rules/rules.json")` fails the job and
lists every problem.
## What parses
dpm-xl parses every construct of the DPM-XL grammar and every EBA validation
rule and precondition of DPM releases 3.4–4.4, except two EBA expressions with
typos (`trueendif`, `ornot`). A few readings differ from the grammar on
purpose; [Language](../reference/language.md#where-dpm-xl-differs-from-the-grammar)
lists them.
# Build editor support
dpm-xl exposes what an editor needs: typed tokens with source offsets, the
language's keywords, function names and signatures, a TextMate grammar, and
positioned diagnostics with suggested fixes, from every syntax error to
unknown tables, coordinates, keys and operands of the wrong type.
## Highlight with tokens
`tokenize` splits an expression into
`Token(kind, text, start, end)` tuples. The parser
works from the same tokens, so highlighting and parsing never disagree.
```python
from dpmxl.parser import tokenize
for token in tokenize(
"with {tC_01.00, c0010}: sum({r0010-0050} group by qEBB) >= 0.5%"
):
print(f"{token.kind:12} {token.text!r:14} {token.start}-{token.end}")
```
```text
keyword 'with' 0-4
brace '{' 5-6
table 'tC_01.00' 6-14
punctuation ',' 14-15
axis 'c0010' 16-21
brace '}' 21-22
punctuation ':' 22-23
function 'sum' 24-27
punctuation '(' 27-28
brace '{' 28-29
axis 'r0010-0050' 29-39
brace '}' 39-40
keyword 'group by' 41-49
identifier 'qEBB' 50-54
punctuation ')' 54-55
operator '>=' 56-58
number '0.5' 59-62
operator '%' 62-63
eof '' 63-63
```
`text` is exactly `expression[start:end]`, quotes, brackets and backticks
included. The kinds are:
| Kind | Tokens |
| --- | --- |
| `keyword` | `if`, `and`, `with`, `where`, `group by`, window keywords inside `over (…)`, and `default` / `interval` in a selection |
| `type` | A parameter type such as `number` or `set-item` |
| `function` | A known function name followed by `(` |
| `operator` | `+ - * / & % = != <> < <= > >= := <-` |
| `punctuation` | `( ) , : ; [ ]` |
| `brace` | `{` or `}` around a selection or set |
| `table`, `table_group`, `variable`, `operation`, `parameter` | A reference with its prefix: `tC_01.00`, `gG_01`, `vX` or `v_X`, `o_v1234`, `pName` |
| `axis` | A row, column or sheet coordinate, range or wildcard: `r0010`, `c0010-0050`, `s*` |
| `number`, `string`, `date`, `boolean`, `null` | Literals |
| `item` | An item such as `[eba_qEC:qx01]` |
| `period` | A `time_shift` or window period code (`A`, `Q`, …) |
| `identifier` | A key, temporary or other name, including escaped names such as `` `x.y` `` |
| `eof` | The empty token that ends every token list |
`tokenize` raises `DpmXlSyntaxError` on an unterminated literal, brace or item,
or an unknown character; an editor can fall back to the TextMate grammar for
such lines.
## Mark syntax errors
`DpmXlSyntaxError` has a plain `message`,
the zero-based `position` where the problem starts and `end`, just past the
offending text: the range to underline.
```python
from dpmxl import DpmXlSyntaxError, parse_program
source = "sum({tC_01.00, r0010} group by qEBB qX) > 0"
try:
parse_program(source)
except DpmXlSyntaxError as error:
print(
error.message,
(error.position, error.end),
repr(source[error.position : error.end]),
)
print(error.pointer())
```
```text
expected ')', found 'qX' (36, 38) 'qX'
sum({tC_01.00, r0010} group by qEBB qX) > 0
^~
```
Parse with `parse_program` in an editor: it
accepts everything a rule may hold, including `;`-separated statements and
assignments.
## Find every problem
`diagnose` returns everything that can be
found wrong without report data, as
`Diagnostic`s: a `severity` (`error` or
`warning`), a stable `code`, a `message`, the `start` and `end` offsets to
underline and, where it applies, the `subject` (the name, code or type
concerned) and a `suggestion` to replace the underlined text with, for a quick
fix. `pointer` renders one like a
syntax error.
Unlike `parse`, `diagnose` reports every syntax error:
after one, it resumes where the enclosing construct can carry on, and leaves
out errors that follow from an earlier one. Keywords run together, such as
`ornot` or `trueendif`, are pointed out as a missing space. A construct that
holds a syntax error is not checked further, but the rest is:
```python
from dpmxl import diagnose
source = "if {tC_01.00, r0010} > 0 ornot {tC_01.00, r0020} then summ(1) = = 2 endif"
for diagnostic in diagnose(source):
print(diagnostic.code, diagnostic.message)
print(diagnostic.pointer(source))
```
```text
syntax missing space: did you mean 'or not'?
if {tC_01.00, r0010} > 0 ornot {tC_01.00, r0020} then summ(1) = = 2 endif
^~~~~
unknown-function unknown function 'summ'; did you mean 'sum'?
if {tC_01.00, r0010} > 0 ornot {tC_01.00, r0020} then summ(1) = = 2 endif
^~~~
syntax unexpected '=' where an operand is expected
if {tC_01.00, r0010} > 0 ornot {tC_01.00, r0020} then summ(1) = = 2 endif
^
```
Calls are checked against `SIGNATURES`, which
the evaluators use too, so `diagnose` reports exactly the operand counts
evaluation would reject. Pass `program=True` to accept scripts with `;`,
`:=` and `<-`, as `parse_program` does.
### Tables and coordinates
Pass a lookup from table code to `TableLayout`
(or `None` for a table that does not exist) to check tables, rows, columns and
sheets, and `tables` to suggest a table for an unknown one. A problem in a
`with` context is reported once, at the context. Severities follow
evaluation: a selection that selects nothing is an error, while a code
missing from an axis list whose other codes match is a warning. A layout's
`open_axes` say which axes have keys instead of codes. What a code on such an
axis, or on an axis the table does not have, selects is up to the provider (a
`MemoryProvider` ignores it, another may select nothing), so it is a warning
when written for that table and not reported when a shared `with` context
applies it:
```python
from dpmxl import TableLayout
layouts = {
"C_01.00": TableLayout("C_01.00", rows=("0010", "0020"), columns=("0010", "0020")),
"C_27.00": TableLayout(
"C_27.00", columns=("0010",), open_keys=("qCPI",), open_axes=("rows",)
),
}
source = (
"with {tC_01.00, c0010}: {r0030} = {r0011} + {tC_27.0, c0010}"
" + sum({tC_27.00, r0010-0050, c0010})"
)
for diagnostic in diagnose(source, layouts.get, tables=layouts):
print(
f"{diagnostic.severity:7} {source[diagnostic.start : diagnostic.end]!r:12} "
f"{diagnostic.suggestion!s:8} {diagnostic.message}"
)
```
```text
error 'r0030' None row 0030 is not in table C_01.00
error 'r0011' r0010 row 0011 is not in table C_01.00; did you mean r0010?
error 'tC_27.0' tC_27.00 unknown table C_27.0; did you mean C_27.00?
warning 'r0010-0050' r* the rows of C_27.00 are open (key qCPI), so they have no codes such as r0010-0050; select them with r* or leave them out
```
A range that covers no coordinate of its table is `empty-range`, an error
when it is all the selection selects. Table groups and operations are not
checked.
### Data types
When the layouts give their cells' `data_types`, operands of the wrong type
are errors, wherever evaluation would reject them whatever the values. A
selection covering a cell without a type, or with a `default` of another
type, has no known type and is not checked:
```python
layouts["C_01.00"] = TableLayout(
"C_01.00",
rows=("0010", "0020"),
columns=("0010", "0020"),
data_types={
(row, column, None): "number" if column == "0010" else "string"
for row in ("0010", "0020")
for column in ("0010", "0020")
},
)
source = 'with {tC_01.00}: {r0010, c0010} + {r0010, c0020} > 0 and {r0020, c0010} = "x"'
for diagnostic in diagnose(source, layouts.get):
print(diagnostic.message)
```
```text
'+' needs numbers, but {r0010, c0020} is a string
'=' cannot compare a number with a string
```
### Keys and names
A name in a clause, such as `qCPI` in `[where qCPI = "A"]`, a `group by` or an
`over (partition by …)`, must be a key of what it applies to. Pass the report
`parameters` to check names too: a name that is neither a key there, nor a
parameter, nor assigned by an earlier statement of a script is an error.
Without them, a name in a `where` or `get` clause that is not a key is a
warning, since a parameter may stand in for it:
```python
source = "{tC_27.00, c0010} [where qCPX = refDate] > 0 and refPeriod > #2026-01-01#"
for diagnostic in diagnose(source, layouts.get, parameters=["refPeriod"]):
print(diagnostic.severity, diagnostic.message, diagnostic.suggestion)
```
```text
error qCPX is not a key of table C_27.00 (keys qCPI), nor a report parameter qCPI
error refDate is not a key of table C_27.00 (keys qCPI), nor a report parameter None
```
`dpm-xl check` runs the same checks over a rule file, against the tables and
parameters of a data file with `--layouts`
([CLI reference](../reference/cli.md#dpm-xl-check)).
## Complete keywords and functions
[`dpmxl.grammar`](../reference/api/grammar.md) lists the vocabulary as
frozensets: `KEYWORDS`, `WINDOW_KEYWORDS`, `SELECTION_KEYWORDS`,
`OPERATORS`, `PUNCTUATION`, `FUNCTIONS`, `AGGREGATE_FUNCTIONS`,
`ANALYTIC_FUNCTIONS`, `PARAMETER_TYPES`, `LITERAL_WORDS`, `TIME_PERIODS`,
`AXIS_LETTERS` (`r`, `c`, `s`), `REFERENCE_PREFIXES` and `TOKEN_KINDS`.
The [language reference index](../reference/language.md#index) links each
word and symbol to its documentation, for hover help.
```python
from dpmxl import grammar
print(sorted(grammar.AGGREGATE_FUNCTIONS))
print(sorted(grammar.TIME_PERIODS))
print(grammar.canonical_function("max_aggr"), grammar.canonical_function("MAX"))
```
```text
['avg', 'count', 'max_aggr', 'median', 'min_aggr', 'sum']
['A', 'D', 'M', 'Q', 'S', 'W']
max max
```
`canonical_function` gives the
`Call.name` that a spelling parses to (`max_aggr` is `max`), and `CALL_NAMES`
holds all of those names. Each has a `Signature`
in `SIGNATURES`, for signature help:
```python
signature = grammar.SIGNATURES["time_shift"]
print(signature.operands(), signature.key_operand, signature.period_operand)
print(signature.problem("time_shift", 2))
```
```text
3 or 4 operands 3 1
time_shift takes 3 or 4 operands, not 2
```
## TextMate grammar
`textmate_grammar()` builds a TextMate
grammar (scope `source.dpm-xl`) from the same vocabulary, for VS Code,
Sublime Text, GitHub Linguist or any highlighter that reads TextMate
grammars. The repository ships it as
[`editors/dpm-xl.tmLanguage.json`](https://github.com/luukhopman/dpm-xl/blob/main/editors/dpm-xl.tmLanguage.json);
regenerate it after changing `dpmxl.grammar`:
```bash
uv run python -c "import json; from dpmxl.grammar import textmate_grammar; print(json.dumps(textmate_grammar(), indent=2))" > editors/dpm-xl.tmLanguage.json
```
## Show what a rule reads
For hover information or a "go to table" action,
`references` returns every selection with
its inherited `with` context and its source span; see
[Analyse rules statically](analyse.md).
# Analyse rules statically
Before any report is loaded you can ask a rule what it reads: which tables
and cells, which filing indicators, which functions. That is how you index a
rule set by table, find the rules affected by a template change, or decide
which tables a report must contain.
## The expression tree
`parse` returns a tree of frozen dataclasses from
[`dpmxl.ast`](../reference/api/ast.md). Every node has a half-open source
`span`, so you can always show the original text of an operand.
```python
from dpmxl import parse
source = (
"with {tC_01.00, c*, default: 0}: {r0010} = {r0020} + sum({tC_02.00, r0010-0050})"
)
tree = parse(source)
comparison = tree.body
print(type(tree).__name__, type(comparison).__name__, comparison.op)
print(source[slice(*comparison.right.span)])
```
```text
With Binary =
{r0020} + sum({tC_02.00, r0010-0050})
```
`walk` yields every node, depth first, and
`children` a node's direct children (including the
`default` of a selection, a `with` context and a typed parameter):
```python
from dpmxl.ast import Call, walk
print([node.name for node in walk(tree) if isinstance(node, Call)])
```
```text
['sum']
```
The [language reference](../reference/language.md) names the node each
construct parses to. Analytic calls such as `rank(x over (…))` are
`Analytic` nodes, not `Call`s. `parse_program`
returns a `Program` whose `statements` are expressions, `Assignment`s
(`name := e`) and `PersistentAssignment`s (`{target} <- e`).
## Selections with their context
A selection inside `with` inherits the context's table, axes and options.
`references` returns every selection completed
by its context, as `Reference` objects in source
order:
```python
from dpmxl import references
source = "with {tC_01.00, c*, default: 0}: {r0010} = {r0020} + {g_G1, r0010} + {o_v123}"
for reference in references(parse(source)):
print(
f"{reference.kind:12} {reference.code:8} {reference.spec.text():36} {source[slice(*reference.span)]}"
)
```
```text
table C_01.00 {tC_01.00, r0010, c*, default: 0} {r0010}
table C_01.00 {tC_01.00, r0020, c*, default: 0} {r0020}
table_group G1 {gG1, r0010, c*, default: 0} {g_G1, r0010}
operation v123 {ov123, c*, default: 0} {o_v123}
```
`kind` is `table`, `table_group` or `operation`; for the latter two
`spec.table` is `None` and `code` names the group or operation. Typed
parameters (`{p_Name, …}`) are not selections and are not listed; targets of
persistent assignments are.
## Tables and filing indicators
`tables` lists the distinct table codes the
cell selections read. Given a provider with a `table_group` method, a group
read counts as reading every table of the group:
```python
from dpmxl import MemoryProvider, tables
tree = parse(source)
print(tables(tree))
print(tables(tree, MemoryProvider([], {}, table_groups={"G1": ["C_03.00", "C_04.00"]})))
```
```text
('C_01.00',)
('C_01.00', 'C_03.00', 'C_04.00')
```
`reported_templates` lists the
templates a rule tests with `{v_…}`, typically in its precondition:
```python
from dpmxl import reported_templates
print(reported_templates(parse("{v_C_01.00} and not {v_C_02.00}")))
```
```text
('C_01.00', 'C_02.00')
```
## Index a rule set by table
```python
import json
from collections import defaultdict
from pathlib import Path
from dpmxl import parse_program
by_table = defaultdict(list)
for rule in json.loads(Path("examples/rules.json").read_text()):
for table in tables(parse_program(rule["expression"])):
by_table[table].append(rule["code"])
print(dict(by_table))
```
```text
{'C_01.00': ['v0001', 'v0003'], 'C_27.00': ['v0002'], 'C_99.00': ['v0004']}
```
`dpm-xl check --references` prints the same selections for a rule file on the
command line ([CLI reference](../reference/cli.md#dpm-xl-check)).
# From an XBRL report to explained failures
dpm-xl evaluates rules but reads no files;
[dpm-xbrl](https://luukhopman.github.io/dpm-xbrl/) reads xBRL-CSV and
XBRL-XML reports but evaluates no rules. This guide connects the two: it
reads an EBA-style xBRL-CSV report, binds its facts to table cells, evaluates
validation rules with the columnar engine (falling back to the reference
evaluator where needed) and explains every failure.
To start from a generated report instead, see the
[toolkit tour](toolkit-tour.md).
```bash
pip install dpm-xbrl "dpm-xl[columnar]"
```
The report is a small leverage-ratio package in the repository,
[`examples/xbrl-report/`](https://github.com/luukhopman/dpm-xl/tree/main/examples/xbrl-report):
a closed table `C_47.00` and an open table `C_48.02` keyed by counterparty
(`qRDT`). Run the code from a checkout of the repository, or point
`read_report` at your own report.
## 1. Read the report
```python
from dpmxbrl import read_report
report = read_report("examples/xbrl-report")
print(report.format, report.entity_id, report.reference_date)
print(report.filing_indicators)
print(report.decimals_parameters)
for fact in report.facts[:3]:
print(fact.template, fact.datapoint, dict(fact.keys), fact.value, fact.location)
```
```text
xbrl-csv SYNTHETICDPMXL000002.CON 2026-03-31
{'C_47.00': True, 'C_48.02': True, 'C_43.00': False}
{'decimalsMonetary': -3, 'decimalsPercentage': 4}
c_47.00 dp9001 {} 812000 reports/c_47.00.csv:2
c_47.00 dp9002 {} 190000 reports/c_47.00.csv:3
c_47.00 dp9003 {} 40000 reports/c_47.00.csv:4
```
An EBA xBRL-CSV report holds facts by *datapoint* code (`dp9001`), with the
key columns of open tables in `fact.keys`. Values are text as written; the
precision of numbers comes from the report's `decimals*` parameters by data
type. See the [dpm-xbrl documentation](https://luukhopman.github.io/dpm-xbrl/)
for the fact model, XBRL-XML and the EBA filing rules it checks.
## 2. Map datapoints to cells
DPM-XL rules address cells by table, row and column, so each datapoint needs
its position. In production that mapping comes from the DPM release (the
table layouts of the taxonomy or the DPM database). For this example it is
written out:
```python
# datapoint -> (table, row, column, data type); open tables have no row.
DATAPOINTS = {
"dp9001": ("C_47.00", "0010", "0010", "monetary"), # exposure component
"dp9002": ("C_47.00", "0020", "0010", "monetary"), # exposure component
"dp9003": ("C_47.00", "0030", "0010", "monetary"), # exposure component
"dp9004": ("C_47.00", "0040", "0010", "monetary"), # total exposure
"dp9005": ("C_47.00", "0050", "0010", "monetary"), # tier 1 capital
"dp9006": ("C_47.00", "0060", "0010", "percentage"), # leverage ratio
"dp9101": ("C_48.02", None, "0010", "monetary"), # exposure
"dp9102": ("C_48.02", None, "0020", "string"), # counterparty LEI
}
OPEN_KEYS = {"C_48.02": ("qRDT",)} # key columns of the open tables
DECIMALS = {"monetary": "decimalsMonetary", "percentage": "decimalsPercentage"}
```
## 3. Bind the facts to a provider
A [provider](provider.md) answers the evaluator's questions from the report:
the cells of a selection, the filing indicators and the report parameters.
Two things matter. A closed selection returns **every defined cell**, with
`value=None` where nothing was reported, so `default` and `isnull` work.
And numbers carry their reported precision: `Number.of(text, decimals)`.
```python
from collections import defaultdict
from dpmxl import CellQuery, DataPoint, Number, axis_matcher
class XbrlReportProvider:
"""A dpm-xl DataProvider over a dpm-xbrl Report."""
def __init__(self, report, datapoints, open_keys):
self.report = report
self.open_keys_by_table = open_keys
self.cells = defaultdict(list) # closed table -> [(row, column)]
self.columns = defaultdict(set) # open table -> {column}
for table, row, column, _ in datapoints.values():
if table in open_keys:
self.columns[table].add(column)
else:
self.cells[table].append((row, column))
self.values = {} # (table, row, column, keys) -> value
self.rows = defaultdict(dict) # open table -> {keys: None}, in report order
for fact in report.facts:
if fact.datapoint not in datapoints:
continue
table, row, column, kind = datapoints[fact.datapoint]
keys = tuple((name, fact.keys[name]) for name in open_keys.get(table, ()))
self.values[table, row, column, keys] = self._value(fact.value, kind)
if keys:
self.rowstable = None
def _value(self, text, kind):
if text is None or kind == "string":
return text
return Number.of(text, self.report.decimals_parameters.get(DECIMALS[kind]))
def select(self, query):
rows, columns = axis_matcher(query.rows), axis_matcher(query.columns)
if query.table in self.cells:
return [
DataPoint(
query.table,
row=row,
column=column,
value=self.values.get((query.table, row, column, ())),
ref=f"{query.table}/r{row}/c{column}",
)
for row, column in self.cells[query.table]
if rows(row) and columns(column)
]
if query.table in self.columns:
return [
DataPoint(
query.table,
column=column,
keys=dict(keys),
value=self.values.get((query.table, None, column, keys)),
ref=f"{query.table}/c{column}/{keys0}",
)
for keys in self.rows[query.table]
for column in sorted(self.columns[query.table])
if columns(column)
]
raise LookupError(f"unknown table {query.table}")
def open_keys(self, table):
return self.open_keys_by_table.get(table, ())
def table_reported(self, table):
return self.report.filing_indicators.get(table)
def parameter(self, name):
return self.report.parameters.get(name)
provider = XbrlReportProvider(report, DATAPOINTS, OPEN_KEYS)
print(provider.select(CellQuery("C_47.00", rows=None, columns=None))[3])
```
```text
DataPoint(table='C_47.00', row='0040', column='0010', sheet=None, keys={}, value=Number(value=Decimal('1045000'), tolerance=Decimal('5E+2')), ref='C_47.00/r0040/c0010')
```
The total exposure was reported with `decimalsMonetary = -3`, rounded to
thousands, so it stands for 1,045,000 ± 500.
## 4. Evaluate with the columnar engine and fall back
`PointsFrameProvider` presents the
same provider as polars frames. Each rule runs on the columnar engine first;
when it raises `ColumnarUnsupported`,
the reference evaluator takes over. Both share a cache per report.
```python
from dpmxl import SelectionCache, evaluate_rule
from dpmxl.columnar import (
ColumnarUnsupported,
FrameCache,
PointsFrameProvider,
evaluate_rule_columnar,
)
RULES = [
(
"e0001",
"with {tC_47.00, c0010, default: 0, interval: true}: {r0040} = {r0010} + {r0020} + {r0030}",
"{v_C_47.00}",
),
(
"e0002",
"with {tC_47.00, c0010, interval: true}: {r0060} = {r0050} / {r0040}",
"{v_C_47.00}",
),
("e0003", "{tC_48.02, c0010} >= 0", "{v_C_48.02}"),
("e0004", 'match({tC_48.02, c0020}, "[A-Z0-9]{18}[0-9]{2}")', "{v_C_48.02}"),
("e0005", "{tC_43.00, r0010, c0010} > 0", "{v_C_43.00}"),
(
"e0006",
"total := sum({tC_48.02, c0010}); total <= {tC_47.00, r0040, c0010}",
None,
),
]
frames, frame_cache, cache = (
PointsFrameProvider(provider),
FrameCache(),
SelectionCache(),
)
statuses = {}
for code, expression, precondition in RULES:
try:
result = evaluate_rule_columnar(
expression, frames, precondition=precondition, cache=frame_cache
)
engine, status = "columnar", result.status
except ColumnarUnsupported:
result = evaluate_rule(
expression, provider, precondition=precondition, cache=cache, trace=False
)
engine, status = "reference", result.status
statuses[code] = status
print(f"{code}: {status:15} ({engine})")
```
```text
e0001: failed (columnar)
e0002: passed (columnar)
e0003: failed (columnar)
e0004: failed (columnar)
e0005: not_applicable (columnar)
e0006: passed (reference)
```
`e0006` is a script with a temporary assignment (`total := …`), which the
columnar engine leaves to the reference evaluator. `e0005` does not apply:
its precondition tests `C_43.00`, which the report's filing indicators mark
as not reported.
## 5. Explain the failures
The columnar result has the failing keys but no trace, so evaluate the
failed rules again with `evaluate_rule` and
explain each failing record. Its points carry the provider's `ref`s, which
lead back to the cells, and through the mapping to the report's datapoints
and file locations.
```python
from dpmxl import explain
for code, expression, precondition in RULES:
if statuses[code] != "failed":
continue
result = evaluate_rule(expression, provider, precondition=precondition, cache=cache)
for outcome in result.failures:
explanation = explain(result.evaluation, expression, outcome.key)
print(f"{code} {dict(outcome.key)}: {explanation.summary}")
print(" cells:", ", ".join(str(point.ref) for point in explanation.primary))
```
```text
e0001 {}: 1,045,000 should equal 1,042,000 (812,000 + 190,000 + 40,000): difference 3,000, at least 1,000 given the reported precision
cells: C_47.00/r0040/c0010
e0003 {'qRDT': 'QW4KNDKKV7QH2LA40639'}: -12,000 should be at least 0: difference 12,000
cells: C_48.02/c0010/QW4KNDKKV7QH2LA40639
e0004 {'qRDT': 'QW4KNDKKV7QH2LA40639'}: "QW4KNDKKV7QH2LA4063" does not have the required format
cells: C_48.02/c0020/QW4KNDKKV7QH2LA40639
```
The total exposure is 3,000 above the sum of its components. Four amounts
rounded to thousands could explain up to 2,000 of that; at least 1,000
cannot. The leverage ratio rule `e0002` passed because 52,000 / 1,045,000 is
within the precision of the reported 4.98 %.
## In production
- **Mapping.** Build `DATAPOINTS` from the DPM release the report's entry
point names, once per release, rather than by hand.
- **Large reports.** For open tables with millions of rows, build polars
frames directly from dpm-xbrl's columnar reader
(`dpmxbrl.frames.read_xbrl_csv_frames`) in the
[frame schema](../reference/api/columnar.md#dpmxl.columnar--frame-schema) instead of going
through `DataPoint`s; see [Evaluate large reports](columnar.md).
- **Filing rules first.** dpm-xbrl records the EBA filing rules a report
breaks in `report.filing`; check them before evaluating validation rules.
- **Threads.** The provider above is read-only after construction, so rules
can run on a thread pool ([Run rules in threads](threads.md)).
# Toolkit tour
Each package of the dpm toolkit does one job:
[dpm-synth](https://luukhopman.github.io/dpm-synth/) generates test reports,
[dpm-xbrl](https://luukhopman.github.io/dpm-xbrl/) reads and writes them,
and dpm-xl evaluates their validation rules. This tour takes one report
through all three: generate it, plant an error, read it back, then evaluate
the rules and explain the failure.
```bash
pip install dpm-synth # installs dpm-xl and dpm-xbrl too
```
The module is a small template in the repository,
[`examples/toolkit-tour/template.json`](https://github.com/luukhopman/dpm-xl/blob/main/examples/toolkit-tour/template.json):
a closed table `T_01.00` whose row 0010 is the sum of rows 0020 and 0030, an
open table `T_02.00` of exposures by counterparty (`qCP`), and four DPM-XL
rules. Run the code from a checkout of the repository.
## 1. Generate a report with dpm-synth
```python
from dpmsynth import generate, load_template
template = load_template("examples/toolkit-tour/template.json")
generated = generate(template, seed=7)
print(generated.passed, "of", generated.rules, "rules pass")
for fact in generated.report.facts:
print(fact.template, fact.datapoint, dict(fact.keys), fact.value)
```
```text
4 of 4 rules pass
t_01.00 dp101 {} 1126000
t_01.00 dp102 {} 971000
t_01.00 dp103 {} 155000
t_02.00 dp201 {'qCP': 'SYNTHZDE8GXD6NCF1086'} 571000
t_02.00 dp201 {'qCP': 'SYNTHEPF91DHODZDOC37'} 555000
```
Every value is generated and the counterparties are fictional LEIs. The
same seed gives the same report.
## 2. Plant an error and write the package
A synthetic report is for testing a pipeline. Change one value, as a faulty
upstream system might, and write the report as an xBRL-CSV package with
dpm-xbrl:
```python
import io
from dataclasses import replace
from dpmxbrl import write_xbrl_csv
facts = tuple(
replace(fact, value="978000") if fact.datapoint == "dp102" else fact
for fact in generated.report.facts
)
package = io.BytesIO() # or a path such as "report.zip"
write_xbrl_csv(replace(generated.report, facts=facts), package)
```
Row 0020 now says 978,000 instead of 971,000.
## 3. Read it with dpm-xbrl
```python
from dpmxbrl import read_report
report = read_report(package.getvalue())
print(report.format, report.entity_id, report.reference_date)
print(report.decimals_parameters, report.filing_indicators)
print(report.filing)
```
```text
xbrl-csv SYNTHETICDPMXL000002.CON 2026-12-31
{'decimalsMonetary': -3} {'T_01.00': True, 'T_02.00': True}
()
```
The package breaks no EBA filing rule that the file can show; read from
bytes without a `name`, its file name is not checked. Amounts are reported
in thousands (`decimalsMonetary = -3`).
## 4. Bind the facts to a provider
dpm-xl reads no files: it asks a [provider](provider.md) for the cells of
each selection. The template places every datapoint at a table, row and
column, so it gives a `MemoryProvider` its
table layouts and places the facts read by dpm-xbrl:
```python
from dpmxl import MemoryProvider, Number, TableLayout
layouts, cells = [], {}
for table in template.tables:
layouts.append(
TableLayout(
table.code,
rows=tuple(sorted({cell.row for cell in table.cells if cell.row})),
columns=tuple(sorted({cell.column for cell in table.cells})),
open_keys=tuple(key.code for key in table.open_keys),
)
)
for cell in table.cells:
cells[cell.datapoint] = table, cell
decimals = report.decimals_parameters["decimalsMonetary"] # every cell is monetary
values = {}
for fact in report.facts:
table, cell = cells[fact.datapoint]
keys = tuple((key.code, fact.keys[key.column]) for key in table.open_keys)
position = table.code, cell.row, cell.column, None, keys
values[position] = Number.of(fact.value, decimals)
reported = [code for code, filed in report.filing_indicators.items() if filed]
provider = MemoryProvider(layouts, values, reported=reported)
```
`Number.of` keeps the reported precision: 978,000 reported in thousands
stands for 978,000 ± 500. For templates with other data types, see
dpm-synth's guide to
[validating the output](https://luukhopman.github.io/dpm-synth/guides/validate-output/).
## 5. Evaluate and explain with dpm-xl
```python
from dpmxl import evaluate_rule, explain
for rule in template.rules:
result = evaluate_rule(rule.expression, provider, precondition=rule.precondition)
print(rule.code, result.status)
for outcome in result.failures:
print(" ", explain(result.evaluation, rule.expression, outcome.key).summary)
```
```text
v1 failed
1,126,000 should equal 1,133,000 (978,000 + 155,000): difference 7,000, at least 5,500 given the reported precision
v2 passed
v3 passed
v4 passed
```
dpm-xl finds the planted error. Three amounts rounded to thousands could
explain up to 1,500 of the difference; at least 5,500 they cannot.
## Where next
- [dpm-synth](https://luukhopman.github.io/dpm-synth/): write a template
for your own module, or build one from a DPM release.
- [dpm-xbrl](https://luukhopman.github.io/dpm-xbrl/): read XBRL-XML as well,
check the EBA filing rules, and read large packages as frames.
- [From an XBRL report to explained failures](end-to-end.md): the same
provider for a real report layout, with the columnar engine.
- [Explain failures in a UI](explain.md): the cells and the filled formula
behind each summary.
# Concepts
How DPM-XL rules are evaluated. These pages explain the semantics; the
[language reference](../reference/language.md) lists every construct.
- [Records and keys](records-and-keys.md): selections yield keyed records;
operators join them.
- [Null logic](null.md): defaults, null propagation, three-valued logic and
errors confined to their record.
- [Interval arithmetic](intervals.md): reported precision as intervals, and
the gap in explanations.
- [Preconditions and results](results.md): the five statuses and what a
result holds.
- [Statements and assignments](statements.md): scripts with `:=` and `<-`.
- [Sets](sets.md): set literals, parameters, operators and `in`.
- [Aggregates and analytic functions](analytics.md): `group by`, `rank` and
windows.
- [Time](time.md): dates, `time_shift` and `annualise`.
- [Parameters, operations and table groups](references.md): everything a
rule reads besides cells.
## Sources and choices
These semantics follow the EBA *DPM-XL language specification v0.9*
(operators and data model documents) and the Eurofiling interval arithmetic
functions. Where the specification is ambiguous or contradicts itself (sum
of no records, `count` over nulls, key-name aliases, the radius formulas for
products and quotients), dpm-xl makes the choice that avoids false failures,
and says so on the page concerned.
# Records and keys
DPM-XL operates on sets of *records*. A rule's result has one record per
combination of keys, and each record passes, fails or is not evaluated. Keys
are what make "row 0010 equals rows 0020 + 0030 *in every column*" one rule.
The examples on this page use a closed table `T` (rows `0010`–`0030`,
columns `0010`–`0020`) and open tables `O` and `P` keyed by `qEBB`:
```python
from dpmxl import MemoryProvider, Number, TableLayout, evaluate_rule
provider = MemoryProvider(
[
TableLayout("T", rows=("0010", "0020", "0030"), columns=("0010", "0020")),
TableLayout("O", columns=("0010", "0020"), open_keys=("qEBB",)),
TableLayout("P", columns=("0010",), open_keys=("qEBB",)),
],
{
("T", "0010", "0010", None, ()): Number.of(10, 0),
("T", "0020", "0010", None, ()): Number.of(4, 0),
("T", "0010", "0020", None, ()): Number.of(7, 0),
("T", "0020", "0020", None, ()): Number.of(7, 0),
("O", None, "0010", None, (("qEBB", "A"),)): Number.of(5, 0),
("O", None, "0010", None, (("qEBB", "B"),)): Number.of(-1, 0),
("O", None, "0020", None, (("qEBB", "A"),)): Number.of(2, 0),
("O", None, "0020", None, (("qEBB", "B"),)): Number.of(0, 0),
("P", None, "0010", None, (("qEBB", "B"),)): Number.of(-1, 0),
("P", None, "0010", None, (("qEBB", "C"),)): Number.of(3, 0),
},
)
def show(expression):
result = evaluate_rule(expression, provider)
records = [(dict(outcome.key), outcome.passed) for outcome in result.outcomes]
print(result.status, result.message or records)
```
## Selections yield records
A selection yields one record per data point. Its key holds every closed axis
that selects more than one coordinate (`r`, `c`, `s`) plus the open key
components, such as `qEBB`. An axis that is not written counts when the
table has several coordinates on it.
```python
show("{tT, r*, c0010} > 0") # key r: three records
show("{tT, r0010, c0010} > 0") # no key: one record
show("{tO, c0010} > 0") # key qEBB
```
```text
passed [({'r': '0010'}, True), ({'r': '0020'}, True), ({'r': '0030'}, None)]
passed [({}, True)]
failed [({'qEBB': 'A'}, True), ({'qEBB': 'B'}, False)]
```
Row `0030` was not reported, so its record is null: not evaluated, never a
failure (see [Null logic](null.md)).
## Operators join on shared keys
Binary operators natural-join their operands on the keys they share. A
scalar (a record without keys) broadcasts to every record of the other side.
Operands whose keys are unrelated are an error.
```python
show("{tT, r0010, c*} > {tT, r0020, c*}") # joined on c
show("{tT, r0010, c0010} > {tT, r*, c0020}") # scalar broadcast over r
show("{tO, c0010} > {tO, c0020}") # joined on qEBB
show("{tT, r0010} > {tT, c0010}") # keys r and c: unrelated
```
```text
failed [({'c': '0010'}, True), ({'c': '0020'}, False)]
passed [({'r': '0010'}, True), ({'r': '0020'}, True), ({'r': '0030'}, None)]
failed [({'qEBB': 'A'}, True), ({'qEBB': 'B'}, False)]
error operands have incompatible keys (r) and (c)
```
A join that would produce more than 2,000,000 records is an error rather
than a memory problem (`recordset.MAX_JOIN_RECORDS`).
## Defaults fill in missing records
An open table has records only for the key combinations reported in it, so
two tables, or two parts of one template, may each lack keys the other has.
An operand with a written `default` (`default: null` included) then stands
for its default where it lacks a key, as a fact variable with a fallback
value does. One rule covers every construct that combines keyed operands:
- **Joins** (operators, and functions of several operands such as `nvl`,
`max`, `match` and `filter`): the operands without a default decide the
keys, keeping those all of them have, and the others fill in their
default. When every keyed operand has a default, the result has a record
for each key any operand with all the result's keys has. Without
defaults, a join keeps only the keys both sides have.
- **`if`**: a keyed condition with a default also covers the keys a branch
has and it lacks, provided every branch without a default has them; the
condition takes its default there. A branch lacking a key takes its
default; without one, the record is null.
- **Which results keep a default**: everything that keeps a record's value
tied to the same fact keeps it, applied to the default: unary operators,
`in`, the operators and functions above (when every keyed operand has a
default), `[rename]`, a `time_shift` that moves a date key, and the
selecting clauses `[where]`, `[sub]`, `filter` and a `with` context's
`where`. A filter restricts the operand's own records, not the keys the
other operands bring: there the operand falls back to its default. An
`if` keeps the default of the branch its condition's default takes.
- **Which results have none**: `[get]` turns key components into values,
and a default has no key components, so a key the operand lacks gives
no value (DPM-XL omits records without a fact). Aggregates and analytic
functions build each record from a group or window of records: a key
without records is no group, and its value is not the operand's default
(`sum` of nothing is null, not `0`). Their results join on shared keys
only.
- A key-less operand without its record, such as `[sub]` on a key value
nobody reported, stands for its default next to keyed operands or
reported values; on its own, or with constants only, it yields no record.
```python
show("{tO, c0010} = {tP, c0010}")
show("with {default: 0}: {tO, c0010} = {tP, c0010}")
show("{tO, c0010} >= {tP, c0010, default: 0}")
show(
"with {default: null}: if not isnull({tP, c0010}) then not isnull({tO, c0010}) endif"
)
show(
"with {default: 0}: if {tO, c0010} > 0 then {tO, c0010} < 10 else {tP, c0010} = 0 endif"
)
show('{tO, c0010, default: 0} [where qEBB = "A"] <= {tP, c0010}')
```
```text
passed [({'qEBB': 'B'}, True)]
failed [({'qEBB': 'A'}, False), ({'qEBB': 'B'}, True), ({'qEBB': 'C'}, False)]
passed [({'qEBB': 'A'}, True), ({'qEBB': 'B'}, True)]
failed [({'qEBB': 'B'}, True), ({'qEBB': 'C'}, False), ({'qEBB': 'A'}, True)]
failed [({'qEBB': 'A'}, True), ({'qEBB': 'B'}, False), ({'qEBB': 'C'}, False)]
failed [({'qEBB': 'B'}, False), ({'qEBB': 'C'}, True)]
```
## Clauses change keys and records
| Clause | Effect |
| --- | --- |
| `[where condition]` | Keeps the records whose keys satisfy the condition. |
| `[get key]` | Replaces each value by the value of a key component; records whose fact is null are omitted. |
| `[sub key = value]` | Fixes a key to a value and removes it from the key. |
| `[rename a to b]` | Renames a key, so it joins with another operand's `b`; an error if the record set already has `b`. |
| `group by k, …` | In an aggregate: one record per value of the listed keys. |
A `with` context's `where` applies to each selection in its body that has
the keys it names. Its terms (split at `and`) that name a key the selection
lacks, neither a key nor a report parameter, leave that selection alone, and
a key-less selection takes none of it. A `where` or `sub` on a selection
inside the body overrides the terms that name the same key; the other terms
still apply.
```python
show('with {tO, c0010} [where qEBB = "A"]: {tO} > {tT, r0020, c0010}')
```
```text
passed [({'qEBB': 'A'}, True)]
```
## Key names
Key names in `where`, `group by`, `sub` and `get` match with or without the
DPM 2.0 `q` prefix and ignore case, so `EBB` and `ebb` find `qEBB`:
```python
show('{tO, c0010}[where EBB = "A"] > 0')
show("sum({tO, c0010} group by qEBB) > 0")
```
```text
passed [({'qEBB': 'A'}, True)]
failed [({'qEBB': 'A'}, True), ({'qEBB': 'B'}, False)]
```
Inside a clause, a key component wins over a temporary of the same name
([Statements](statements.md)).
## Codes and items
Axis codes compare the way DPM does: numeric codes as numbers padded to four
digits, so `r310` in an older rule matches row `0310`
(`normalize_code`). Items compare by code and
category, ignoring the `eba_` prefix and a category missing on either side:
`[eba_qEC:qx01]` equals `[qEC:qx01]` and `[qx01]`.
# Null logic
Reports leave cells empty, and rules must not fail because of it. DPM-XL
handles missing values with defaults, null propagation and three-valued
logic.
```python
from dpmxl import MemoryProvider, Number, TableLayout, evaluate_rule
provider = MemoryProvider(
[
TableLayout("T", rows=("0010", "0020", "0030"), columns=("0010",)),
TableLayout("O", columns=("0010", "0020"), open_keys=("qEBB",)),
],
{
("T", "0010", "0010", None, ()): Number.of(10, 0),
("T", "0020", "0010", None, ()): Number.of(4, 0),
# T r0030 is not reported
("O", None, "0010", None, (("qEBB", "A"),)): Number.of(5, 0),
("O", None, "0010", None, (("qEBB", "B"),)): Number.of(-1, 0),
("O", None, "0020", None, (("qEBB", "A"),)): Number.of(2, 0),
("O", None, "0020", None, (("qEBB", "B"),)): Number.of(0, 0),
},
)
def show(expression):
result = evaluate_rule(expression, provider)
print(
result.status, result.message or [outcome.passed for outcome in result.outcomes]
)
```
## Missing facts and defaults
A cell with no fact takes the selection's `default`; with no default it is
null. A provider must therefore return every defined cell of a closed
selection, reported or not ([Connect your data](../guides/provider.md)).
The default also stands in where another operand has an open key the
selection lacks ([Records and keys](records-and-keys.md#defaults-fill-in-missing-records)).
```python
show("{tT, r0030, c0010} > 0")
show("{tT, r0030, c0010, default: 0} >= 0")
show("isnull({tT, r0030, c0010})")
```
```text
not_applicable [None]
passed [True]
passed [True]
```
## Null propagates
Null propagates through arithmetic, comparisons and functions, except
`isnull` and `nvl(x, replacement)`. A record whose result is null is "not
evaluated", never a failure; a rule whose records are all null is
`not_applicable`.
`and`, `or` and `not` use three-valued (Kleene) logic: `null or true` is
true, `null and false` is false, anything else with null is null.
```python
show("{tT, r0030, c0010} > 0 or true")
show("{tT, r0030, c0010} > 0 and false")
```
```text
passed [True]
failed [False]
```
## Conditionals
`if` takes the `else` branch when the condition is null. Without `else`, a
Boolean `then` (filing indicators `{v_…}` and Boolean facts included) yields
true where the condition is false or null, so a guarded check passes when its
guard does not hold.
```python
show("if {tT, r0030, c0010} > 0 then false endif")
show("if {tT, r0030, c0010} > 0 then 1 else 2 endif = 2")
```
```text
passed [True]
passed [True]
```
The result has the condition's record structure. Branch keys the condition
lacks widen it, so each condition record applies to every branch record that
shares its keys. A condition with a `default` also covers the key values only
a branch has ([Records and keys](records-and-keys.md#defaults-fill-in-missing-records)).
## Errors stay in their record
Division by zero is a run-time error, as the specification requires. Like a
type error (a non-Boolean `if` condition included), it fails only its own
record: the rule is an error only if that record reaches the result. A guard
therefore works:
```python
show("{tT, r0010, c0010} / {tT, r0030, c0010, default: 0} > 1")
show(
"if {tT, r0030, c0010, default: 0} != 0 then {tT, r0010, c0010} / {tT, r0030, c0010, default: 0} > 1 endif"
)
```
```text
error division by zero
passed [True]
```
## Aggregates
Aggregates over no records are null, except `count`, which is 0. `count`
counts non-null values; the others skip nulls.
```python
show("count({tT, r0030, c0010}) = 0")
show("sum({tT, r0030, c0010}) = 0")
```
```text
passed [True]
not_applicable [None]
```
## Preconditions
A precondition makes the rule `not_applicable` unless it yields at least one
true record; null and false records do not count. See
[Preconditions and results](results.md).
# Interval arithmetic
A fact reported as 1,204 with XBRL `decimals="0"` is a rounded amount: the
true value lies anywhere from 1,203.5 to 1,204.5. A rule that adds such
amounts must allow for the rounding, or it fails reports that are correct.
EBA validation rules therefore use interval arithmetic, following the
Eurofiling interval arithmetic functions (`iaf:*`, version 1.1.1).
## Numbers with a tolerance
A `Number` is a decimal value with a symmetric
tolerance. `Number.of(value, decimals)` derives the tolerance from XBRL
`decimals`: `0.5 · 10^-decimals`. `INF` (in any case) or `None` means exact;
constants in rules and selection defaults are exact.
```python
from dpmxl import Number
print(Number.of(1204, 0))
print(Number.of(1204000, -3).tolerance, Number.of("0.0498", 4).tolerance)
print(Number.of(5, "INF"))
```
```text
Number(value=Decimal('1204'), tolerance=Decimal('0.5'))
5E+2 0.00005
Number(value=Decimal('5'), tolerance=Decimal('0'))
```
## Arithmetic
| Operation | Tolerance of the result |
| --- | --- |
| `a + b`, `a - b` | `δa + δb` |
| `a * b` | `|a·δb| + |b·δa| + δa·δb` |
| `a / b` | The largest deviation of the four endpoint quotients from `a / b` (skipping a zero divisor) |
| `-a`, `abs(a)` | `δa` |
| `min`, `max` | The chosen value's tolerance |
```python
a, b = Number.of(10, 0), Number.of("7.0", 1)
print(a + b)
print(a * b)
```
```text
Number(value=Decimal('17.0'), tolerance=Decimal('0.55'))
Number(value=Decimal('70.0'), tolerance=Decimal('4.025'))
```
Arithmetic keeps 34 significant digits (IEEE decimal128) in any thread
decimal context; the caller's `decimal` context has no effect.
## Comparisons
Two numbers compare as intervals:
| Comparison | Holds when |
| --- | --- |
| `a = b` | `|a − b| ≤ δa + δb` (the intervals overlap) |
| `a != b` | the intervals do not overlap |
| `a <= b` | `a − b ≤ δa + δb` |
| `a >= b` | `a ≥ b − (δa + δb)` |
| `a < b`, `a > b` | likewise, strictly |
```python
from dpmxl.values import compare
print(compare("=", Number.of(10, 0), Number.of("10.4")))
print(compare("=", Number.of(10, 0), Number.of("10.6")))
```
```text
True
False
```
## Switching it on and off
Interval arithmetic applies to selections with `interval: true`:
`{tC_01.00, r0010, interval: true}`, usually set once in a `with` context.
With `interval: false`, or without the option, facts are compared exactly
as reported. EBA rules state the option explicitly. Each operator and
function in the [language reference](../reference/language.md#interval)
says what it does with a tolerance.
## The gap in explanations
When a comparison fails despite the tolerance, [`explain`](../guides/explain.md)
reports each side's interval and the *gap*: how far apart the closest values
within those intervals still are. In
```text
1,204 should equal 1,211 (1,180 + 31): difference 7, at least 5.5 given the reported precision
```
the left side is 1,204 ± 0.5 and the right side 1,211 ± 1, so the difference
of 7 shrinks to at least 5.5. The gap is rounded down, so "at least" never
overstates it.
## Where the specification is ambiguous
The specification is ambiguous about the radius formulas for products and
quotients; dpm-xl uses the ones above, which avoid false failures. The
columnar evaluator uses `Decimal(38, 12)`; see
[Evaluate large reports](../guides/columnar.md#precision).
# Preconditions and results
`evaluate_rule(expression, provider, *, precondition=None)`
never raises for a bad rule or bad data. It returns a
`RuleResult` whose status is one of five:
| Status | When |
| --- | --- |
| `passed` | At least one record is true and none is false. |
| `failed` | At least one record is false. |
| `not_applicable` | The precondition is not met, or no record could be evaluated (all null). |
| `error` | A syntax error, an unknown table, a type mismatch, a rule that yields values instead of Booleans, or a run-time error (such as division by zero) in a record that reaches the result. |
| `unsupported` | The rule needs something the provider does not offer (a `time_shift` report, a `{o_…}` operation or a `{g_…}` table group), or a function dpm-xl does not know. |
```python
from dpmxl import MemoryProvider, Number, TableLayout, evaluate_rule
provider = MemoryProvider(
[TableLayout("T", rows=("0010",), columns=("0010",))],
{("T", "0010", "0010", None, ()): Number.of(10, 0)},
)
for expression, precondition in [
("{tT, r0010, c0010} > 0", "{v_T}"),
("{tT, r0010, c0010} > 0", "{v_X}"),
("{tT, r0010, c0010} >", None),
("{tX, r0010} > 0", None),
('{tT, r0010, c0010} > "a"', None),
("{tT, r0010, c0010} + 1", None),
("{o_X} > 0", None),
("foo({tT, r0010, c0010}) > 0", None),
]:
result = evaluate_rule(expression, provider, precondition=precondition)
print(f"{result.status:15} {result.message}")
```
```text
passed None
not_applicable precondition not met
error expression ends where an operand is expected at position 20
error unknown table X
error cannot compare number with string using '>'
error rule yields number values, not booleans
unsupported operation X needs a provider that resolves operations
unsupported unknown function 'foo'
```
`message` says why the status is `error`, `unsupported` or
`not_applicable`; its wording may improve in minor releases, the statuses
may not.
## Preconditions
A precondition is an expression, usually filing-indicator tests such as
`{v_C_01.00}` or `{v_C_01.00} and {v_C_02.00}`. The rule applies only when the
precondition yields at least one true record; otherwise the result is
`not_applicable` without evaluating the rule. A precondition is evaluated on
all records, also when `only_keys` restricts the rule.
`{v_TABLE}` asks the provider's `table_reported(table)`. `MemoryProvider`
answers from its `reported` codes (by default every table with a value) and
from each layout's `template`.
## What a result holds
| Field | Content |
| --- | --- |
| `status`, `message` | As above. |
| `outcomes` | One `Outcome` per record: `key` (such as `{'c': '0020'}`), `passed` (`True`, `False`, or `None` when not evaluated) and `points`, the distinct cells it was computed from. |
| `failures` | The outcomes whose `passed` is `False`. |
| `counts` | `{'passed': n, 'failed': n, 'null': n}`. |
| `evaluation` | The trace `explain` reads; `None` for syntax and provider errors. |
| `assignments` | Results of persistent assignments (`{target} <- value`) by target text, such as `{tT, r0010, c0010}` ([Statements](statements.md)). |
| `notes` | Remarks the evaluator made along the way. |
`DataPoint.ref` in `outcome.points` is whatever identity your provider gave
the cell, so a user interface can highlight it
([Explain failures in a UI](../guides/explain.md)).
## The CLI
`dpm-xl eval` prints the same statuses and exits with 0 when every rule
passed or did not apply, 1 when a rule failed and 3 when a rule is an error
or unsupported ([CLI reference](../reference/cli.md)).
# Statements and assignments
A rule is usually one expression. DPM-XL also allows a script: statements
separated by `;`, with two kinds of assignment.
| Statement | Meaning |
| --- | --- |
| `expression` | An expression, with an optional `with` context. |
| `name := expression` | A temporary assignment: `name` holds the expression's records for later statements. |
| `{target} <- expression` | A persistent assignment: the value is computed and returned in `RuleResult.assignments`. Targets are cell selections such as `{tT, r0010, c0010}` or parameters such as `{vX}`. |
Statements run in order; the **last** one gives the rule's records and
verdict. The last statement needs no `;`, and a trailing `;` is allowed.
```python
from dpmxl import MemoryProvider, Number, TableLayout, evaluate_rule
provider = MemoryProvider(
[TableLayout("T", rows=("0010", "0020"), columns=("0010", "0020"))],
{
("T", "0010", "0010", None, ()): Number.of(10, 0),
("T", "0020", "0010", None, ()): Number.of(4, 0),
("T", "0010", "0020", None, ()): Number.of(1, 0),
("T", "0020", "0020", None, ()): Number.of(4, 0),
},
)
script = "d := with {tT, c*}: {r0010} - {r0020}; d > 0"
result = evaluate_rule(script, provider)
print(
result.status, [(dict(outcome.key), outcome.passed) for outcome in result.outcomes]
)
```
```text
failed [({'c': '0010'}, True), ({'c': '0020'}, False)]
```
A temporary keeps the records of its expression, keys included. In later
statements its name wins over a report parameter of that name and loses to a
key component inside a clause. The rule's context, temporaries and
assignments do not reach operations referenced with `{o_…}`, which are
evaluated on their own.
## Persistent assignments
```python
result = evaluate_rule(
"{tT, r0010, c0010} <- {tT, r0020, c0010} * 2; {tT, r0010, c0010} = 10", provider
)
print(result.status)
for target, recordset in result.assignments.items():
print(target, [record.value for record in recordset.records.values()])
```
```text
passed
{tT, r0010, c0010} [Number(value=Decimal('8'), tolerance=Decimal('0'))]
```
Nothing is written to the report: the last statement still reads the
reported 10. The assignment's value is for the caller, keyed by the target's
normalized DPM-XL text.
## Parsing scripts
`parse_program` parses a script into a
`Program`; `parse` accepts one expression (with an
optional trailing `;`) and says so when given a script. `evaluate_rule`,
the columnar fallback and `dpm-xl check` accept scripts.
Outside clauses `<-` is the assignment, so compare with a negated operand as
`a < -b`. Inside `[…]` clauses, as in the grammar, `<-` reads `< -`.
The columnar evaluator hands every script with assignments to the reference
evaluator. The language reference gives the syntax of
[programs and statements](../reference/language.md#statements).
# Sets
Sets hold distinct non-null values in first-seen order: numbers compared
exactly, items ignoring the `eba_` prefix. They are written as literals
(`{1, 2}`, `{[eba_CU:EUR], [eba_CU:USD]}`, the empty set `{}`), come from
set parameters (`{p_Allowed, set-item}`), or are built with `set_of(x)` and
the set operators `union`, `intersect`, `setdiff` and `symdiff`. In Python a
set is a `ValueSet`.
```python
from dpmxl import Item, MemoryProvider, Number, TableLayout, ValueSet, evaluate_rule
currencies = ValueSet.of([Item.parse("eba_CU:EUR"), Item.parse("eba_CU:USD")])
provider = MemoryProvider(
[TableLayout("O", columns=("0010", "0020"), open_keys=("qEBB",))],
{
("O", None, "0010", None, (("qEBB", "A"),)): Item.parse("eba_CU:EUR"),
("O", None, "0010", None, (("qEBB", "B"),)): Item.parse("eba_CU:JPY"),
("O", None, "0020", None, (("qEBB", "A"),)): Number.of(2, 0),
("O", None, "0020", None, (("qEBB", "B"),)): Number.of(3, 0),
},
parameters={"Allowed": currencies},
)
def show(expression):
result = evaluate_rule(expression, provider)
print(
result.status, result.message or [outcome.passed for outcome in result.outcomes]
)
print(ValueSet.of([1, 2, 2, None]))
```
```text
ValueSet(members=(1, 2))
```
## Membership with `in`
`x in s` tests each record of `x` against the set:
```python
show("{tO, c0010} in {[eba_CU:EUR], [eba_CU:USD]}")
show("{tO, c0010} in {p_Allowed, set-item}")
show("{tO, c0010} in union({p_Allowed, set-item}, {[CU:JPY]})")
```
```text
failed [True, False]
failed [True, False]
passed [True, True]
```
An operand of `in` or of a set operator that is not a set stands for the set
of its non-null values, so `{c0010} in {tT, r*, c0020}` tests against the
values of a column, and `{tO, c0020} in setdiff({1, 2, 3}, {tO, c0020})`
against what the column does not hold:
```python
show("{tO, c0020} in setdiff({1, 2, 3}, {tO, c0020})")
```
```text
failed [False, False]
```
## Missing sets
A single null, such as a set parameter the report does not have, is a null
set, which makes `in` and the set operators null. A `default: {…}` on the
parameter supplies a set instead. `set_of(null)` is `{}`.
```python
show("{tO, c0010} in {p_Missing, set-item}")
show("{tO, c0010} in {p_Missing, set-item, default: {[eba_CU:JPY]}}")
```
```text
not_applicable [None, None]
failed [False, True]
```
## Restrictions
Sets cannot be compared with `=` or the other comparison operators, and
cannot be a rule's result. A set `default` on a cell selection is an error.
The columnar evaluator handles `in` with a set literal; it hands every other
set operand (parameters, selections, set operators) to the reference
evaluator.
The language reference gives the syntax and examples of
[set literals](../reference/language.md#set-literals), [`in`](../reference/language.md#in) and the
[set functions](../reference/language.md#set-functions).
# Aggregates and analytic functions
## Aggregates
`sum`, `count`, `avg`, `min`, `max` and `median` (also spelled `min_aggr`
and `max_aggr`, as in the specification) reduce an operand's records to one
value, or to one value per group with `group by`:
```dpm-xl
sum({tC_27.00, c0010}) <= {tC_01.00, r0010, c0010}
sum({tC_27.00, c0010} group by qCPI) >= 0
```
- Over no records they are null, except `count`, which is 0.
- `count` counts non-null values; the others skip nulls.
- `max` and `min` also order dates, text and items by their text (ISO dates
in date order, items by category and code), as presence checks expect.
Booleans, and numbers mixed with other values in one group, are an error.
- With several operands, `min(a, b)` and `max(a, b)` are scalar functions
that pick per record.
## Analytic functions
An analytic function keeps the operand's records: each record gets the
aggregate of the records in its *window* (or its rank) within its
*partition*.
```dpm-xl
with {tC_08.02}: rank({c0010} over (partition by qEEA order by qEEA desc)) > 0
with {tC_08.02}: sum({c0010} over (order by qEEA data points between unbounded preceding and current data point)) > 0
```
The examples below use an open table keyed by entity `qEBB` and date `qDAT`:
```python
from datetime import date
from dpmxl import MemoryProvider, Number, TableLayout, evaluate_rule
facts = [
("A", date(2026, 1, 31), 5),
("A", date(2026, 2, 28), 7),
("A", date(2026, 3, 31), 3),
("B", date(2026, 1, 31), -1),
("B", date(2026, 2, 28), 4),
("B", date(2026, 3, 31), 4),
]
provider = MemoryProvider(
[TableLayout("O", columns=("0010",), open_keys=("qEBB", "qDAT"))],
{
("O", None, "0010", None, (("qEBB", entity), ("qDAT", day))): Number.of(
amount, 0
)
for entity, day, amount in facts
},
)
def show(expression):
result = evaluate_rule(expression, provider)
for outcome in result.outcomes:
print(outcome.key["qEBB"], outcome.key["qDAT"], outcome.passed)
```
A running total per entity, in date order:
```python
show("with {tO}: sum({c0010} over (partition by qEBB order by qDAT)) > 0")
```
```text
A 2026-01-31 True
A 2026-02-28 True
A 2026-03-31 True
B 2026-01-31 False
B 2026-02-28 True
B 2026-03-31 True
```
The average of each month and the month before:
```python
show(
"with {tO}: avg({c0010} over (partition by qEBB order by qDAT data points between 1 preceding and current data point)) >= 3"
)
```
```text
A 2026-01-31 True
A 2026-02-28 True
A 2026-03-31 True
B 2026-01-31 False
B 2026-02-28 False
B 2026-03-31 True
```
## Partitions, order and windows
- **Partition and order keys** must be keys of the operand. Without
`partition by` the whole operand is one partition.
- **`rank`** needs `order by` and ranks by those keys; ties share the lowest
rank and leave gaps (1, 1, 3).
- **Default window.** Without a window clause the window is the whole
partition, or, with `order by`, every record up to the current one and its
*peers*, the records with the same values for every order key (`range
between unbounded preceding and current data point`).
- **`data points` windows** count records: `between 2 preceding and current
data point` is the current record and the two before it.
- **`range` windows** with an offset need a single number or date order key:
`3 preceding` is 3 less (3 days earlier for a date), `3 M preceding` three
calendar months earlier. A period on a number key is an error.
- **Bounds** are `unbounded preceding`, `unbounded following`, `current data
point`, and `n [A|S|Q|M|W|D] preceding|following`. `preceding` follows the
sort direction.
Analytic calls parse to `Analytic` nodes with a
`Window`, not to `Call`s, and `rank` is not in
`evaluator.AGGREGATES`. The columnar evaluator hands analytic functions to the
reference evaluator.
The language reference gives the syntax, errors and examples of each
[aggregate](../reference/language.md#aggregates) and [analytic function](../reference/language.md#analytic-functions).
# Time
## Dates
Date literals are written `#2026-12-31#`, with an optional time
(`#2026-12-31T10:00:00#`). A date equals a date-time at midnight. Report
parameters such as `refPeriod` and date key components hold dates; in Python
they are `datetime.date`.
| Function | Result |
| --- | --- |
| `year(d)`, `month(d)`, `day(d)` | The parts of a date |
| `quarter(d)`, `semester(d)` | 1–4 and 1–2 |
| `week(d)` | The ISO 8601 week |
| `date(y, m, d)` | A date; an impossible day such as `date(2026, 2, 30)` is an error |
```dpm-xl
year({p_refPeriod, date}) = 2026
date(2026, 12, 31) = #2026-12-31#
```
`max` and `min` order dates in date order.
## `time_shift`
`time_shift(x, period, count[, key])` presents `x` as it was `count` periods
earlier (later when negative), as in the EBA introduction to DPM-XL.
`period` is `A`, `S`, `Q`, `M`, `W` or `D`.
- When `key` is not a key component of `x` (`refPeriod`, or no key), `x` is
evaluated on the report the provider's `earlier_report(period, count)`
returns. Without that method, or when it returns `None`, the rule is
`unsupported`.
- When `key` is a date key component of `x`, its values move `count` periods
later instead, within the same report.
Periods move dates by calendar months (12, 6, 3 or 1; a month's last day
stays the last day) or by 7 or 1 days. A `time_shift` that would merge two
records onto one date key is an error.
## `annualise`
`annualise(x, fiscalYearEnd, key)` scales a year-to-date amount to a year:
`x · 12 / months`, where `months` counts the months from the fiscal year
that ends in month `fiscalYearEnd` up to and including the month of the date
in `key` (a date key component, else the report parameter of that name). It
treats that month as complete, so it is meant for period-end dates.
[Compare with earlier reports](../guides/time-shift.md) shows both functions
with runnable examples, and the language reference gives the syntax and
errors of [`time_shift`](../reference/language.md#time_shift), [`annualise`](../reference/language.md#annualise) and
the [date functions](../reference/language.md#date-functions).
# Parameters, operations and table groups
Besides table cells, a rule can read report-level data and other rules'
results. Each kind of reference goes to a provider method
([Connect your data](../guides/provider.md)).
| Reference | Reads | Provider method |
| --- | --- | --- |
| `{v_C_01.00}` | Whether table or template `C_01.00` was reported (its filing indicator) | `table_reported(table)` |
| `{vName}` | Report parameter `Name`, as given | `parameter(name)` |
| `{p_Name, type[, default: v]}` | Report parameter `Name`, checked against a type | `parameter(name)` |
| `{o_Code[, r…, c…, default: v, interval: b]}` | The result of operation `Code` | `operation(code)` (optional) |
| `{g_Group, r…, c…, …}` | The same cells of every table in a group | `table_group(code)` (optional) |
## Prefixes
A `_` after the `t`, `g`, `o` and `p` prefixes is cosmetic, as the DPM-XL
grammar says: `{t_T1}` is `{tT1}`. After `v` it is not: `{v_X}` tests a
filing indicator, `{vX}` reads a parameter. A backtick escapes a code that
is not a plain name: `` {t`C 01.00`} ``.
## Typed parameters
`{p_Name, type}` reads `parameter(Name)` and checks its type. The types are
`number`, `integer`, `string`, `date`, `boolean`, `item` and the set types
`set-number`, `set-string`, `set-date`, `set-item` and so on. Python numbers
become `Number`, text becomes an item or a date for those types, and a list
or set becomes a set ([Sets](sets.md)). A missing value takes the `default`,
or is null.
```python
from dpmxl import MemoryProvider, Number, TableLayout, evaluate_rule
provider = MemoryProvider(
[TableLayout("T", rows=("0010",), columns=("0010",))],
{("T", "0010", "0010", None, ()): Number.of(10, 0)},
parameters={"refPeriod": "2026-12-31", "Limit": 5, "Ccy": "eba_CU:EUR"},
)
for expression in (
"{p_refPeriod, date} = #2026-12-31#",
"{p_Limit, number} < {tT, r0010, c0010}",
"{p_Ccy, item} = [eba_CU:EUR]",
"{p_Missing, number, default: 3} = 3",
"{p_Missing, number} = 3",
"{p_Limit, date} = #2026-01-01#",
):
result = evaluate_rule(expression, provider)
print(f"{result.status:15} {result.message or ''}")
```
```text
passed
passed
passed
passed
not_applicable
error parameter Limit is not a date
```
## Operation references
`{o_Code, …}` evaluates the expression the provider's `operation(code)`
returns, on its own: the referring rule's context, temporaries and
assignments neither reach it nor receive its own. On the result:
- axes keep the matching `r`/`c`/`s` records, dropping a key selected by one
code;
- `default` fills nulls;
- `interval: false` makes numbers exact.
An operation that refers to itself, directly or through others, is an error.
Without the provider method, the rule is `unsupported`.
## Table groups
`{g_Group, …}` selects from every table the provider's `table_group(code)`
lists. With several tables the records are keyed by the table code `t`, and
the tables must share their keys. Without the provider method, the rule is
`unsupported`.
In static analysis, `references` reports
group and operation reads with `kind` and `code`, and
`tables(node, provider)` counts a group read as
reading every table of the group ([Analyse rules statically](../guides/analyse.md)).
The language reference gives the syntax of every
[reference](../reference/language.md#references) and [parameter type](../reference/language.md#typed-parameter).
# Reference
- [Quick reference](quick-reference.md): DPM-XL on one page, with the
complete grammar.
- [Language](language.md): every DPM-XL construct with its syntax, types,
null and interval behaviour, errors, tested examples and the node it
parses to, and where dpm-xl differs from the grammar.
- [CLI](cli.md): `dpm-xl check` and `dpm-xl eval`, every option, exit status
and the JSON output schema.
- [Data formats](data-formats.md): rule files and data files.
- [Python API](api/index.md): every public module, generated from the
docstrings.
# Quick reference
DPM-XL at a glance. Each entry links to the
[language reference](language.md), which gives the exact types, null
behaviour, errors and examples.
```dpm-xl
with {tC_01.00, c*, default: 0, interval: true}: {r0010} = {r0020} + {r0030}
if {v_C_27.00} then sum({tC_27.00, c0010} group by qCPI) >= 0 endif
{tC_27.00, c0020}[where qCPI <> "LEI-A"] in {p_Allowed, set-item}
total := sum({tC_27.00, c0010}); total <= {tC_01.00, r0010, c0010}
```
## Statements
| Syntax | Meaning |
| --- | --- |
| `expression` | A rule: one expression, evaluated per record ([Programs and statements](language.md#statements)) |
| `s1; s2; s3` | A script; the last statement gives the result |
| `name := e` | [Temporary assignment](language.md#temporary-assignment) for later statements |
| `{tT, r0010, c0010} <- e`, `{vX} <- e` | [Persistent assignment](language.md#persistent-assignment), returned in `RuleResult.assignments` |
| `with {tT, c*, default: 0}: e` | [Context](language.md#with) for the selections of `e` |
| `with {tO}[where qEBB = "A"]: e` | Context with a `where` on every selection of `e` |
## Selections and references
| Syntax | Meaning |
| --- | --- |
| `{tC_01.00, r0010, c0010}` | [Cells](language.md#selections) of table `C_01.00`; `t_C_01.00` and `` t`C 01.00` `` also work |
| `r0010`, `r0010-0050`, `(r0010, r0030-0050)`, `r*` | [Rows](language.md#axes): one, a range, a list, all; likewise `c` and `s` |
| `default: 0` | [Value for missing facts](language.md#default) and for keys another operand has |
| `interval: true` | [Interval arithmetic](language.md#interval) on reported precision |
| `{v_C_01.00}` | [Filing indicator](language.md#filing-indicator): was the table reported |
| `{vrefPeriod}`, `refPeriod` | [Report parameter](language.md#report-parameter), as given |
| `{p_Limit, number, default: 5}` | [Typed parameter](language.md#typed-parameter); types `number integer string date boolean item` and `set-…` of each |
| `{o_v1234, r0010}` | [Operation](language.md#operation) result |
| `{g_G01, r0010, c0010}` | [Table group](language.md#table-group), keyed by table `t` |
## Literals
| Syntax | Type |
| --- | --- |
| `12`, `-3.5`, `.5`, `1e6`, `4.5%` | [number](language.md#numbers) (`4.5%` is 0.045) |
| `"text"`, `'text'`, `''` | [text](language.md#strings), no escapes |
| `#2026-12-31#`, `#2026-12-31T10:00:00#` | [date](language.md#dates) |
| `true`, `false`, `null` | [boolean, null](language.md#boolean-and-null) |
| `[eba_CU:EUR]`, `[qEBB]` | [item](language.md#items) |
| `{1, 2}`, `{[CU:EUR], [CU:USD]}`, `{}` | [set](language.md#set-literals) |
| `` `to` ``, `` `a b` `` | [escaped name](language.md#names) |
## Operators
Loosest first; see [Precedence and associativity](language.md#precedence).
| Operators | Meaning |
| --- | --- |
| `or`, `xor` | [Logical](language.md#logical), three-valued |
| `and` | [Logical](language.md#logical), three-valued |
| `not` | [Negation](language.md#not); `not a = b` is `not (a = b)` |
| `=`, `!=`, `<>`, `<`, `<=`, `>`, `>=` | [Comparison](language.md#comparison), not chained |
| `in`, `not in` | [Membership](language.md#in) in a set |
| `&` | [Text concatenation](language.md#concatenation) |
| `+`, `-` | [Addition, subtraction](language.md#arithmetic) |
| `*`, `/` | [Multiplication, division](language.md#arithmetic) |
| unary `-`, `+` | [Sign](language.md#unary) |
| `n%`, `[…]` | [Percentage](language.md#percent), [clauses](language.md#clauses) |
| `if c then a else b endif` | [Conditional](language.md#if); `else` optional |
## Clauses
| Syntax | Meaning |
| --- | --- |
| `x[where qEBB = "A"]` | [Keep records](language.md#where) whose keys satisfy a condition |
| `x[get qEBB]` | [Replace values](language.md#get) by a key's values |
| `x[sub qEBB = "A"]` | [Fix a key](language.md#sub) and drop it |
| `x[rename qEBB to qX, …]` | [Rename keys](language.md#rename) |
Key names ignore case and the `q` prefix ([Key names](language.md#key-names)).
## Functions
| Function | Operands | Result |
| --- | --- | --- |
| [`abs(x)`](language.md#abs) | number | number |
| [`exp(x)`, `ln(x)`, `sqrt(x)`](language.md#exp) | number | number, null if undefined |
| [`power(x, y)`](language.md#power), [`log(x, base)`](language.md#log) | numbers | number, null if undefined |
| [`max(a, b, …)`, `min(a, b, …)`](language.md#max) | numbers, or dates, text, items | the largest, smallest per record |
| [`isnull(x)`](language.md#isnull) | any | boolean |
| [`nvl(x, r)`](language.md#nvl) | any | `x`, or `r` where `x` is null |
| [`len(s)`, `length(s)`](language.md#len) | text | number |
| [`substr(s, start, length)`](language.md#substr) | text, whole numbers | text |
| [`match(s, pattern)`](language.md#match) | text, regular expression | boolean |
| [`year`, `semester`, `quarter`, `month`, `week`, `day`](language.md#date-parts) | date | number |
| [`date(y, m, d)`](language.md#date) | whole numbers | date |
| [`time_shift(x, period, n, key)`](language.md#time_shift) | any, `A` `S` `Q` `M` `W` or `D`, whole number, optional name | `x` as `n` periods earlier |
| [`annualise(x, fiscalYearEnd, key)`](language.md#annualise) | number, month, name | number |
| [`set_of(x)`](language.md#set_of) | any | set |
| [`union`, `intersect`](language.md#union) | two or more sets | set |
| [`setdiff`, `symdiff`](language.md#setdiff) | two sets | set |
| [`filter(x, condition)`](language.md#filter) | any, boolean | the records of `x` where true |
## Aggregates and analytic functions
| Syntax | Meaning |
| --- | --- |
| [`sum`](language.md#sum), [`count`](language.md#count), [`avg`](language.md#avg), [`median`](language.md#median), [`max`, `min`, `max_aggr`, `min_aggr`](language.md#aggregate-max) | [Aggregates](language.md#aggregates): `sum(x)`, `sum(x group by k1, k2)` |
| `sum(x over (partition by k order by d desc data points between 1 preceding and current data point))` | [Analytic aggregate](language.md#analytic-functions) per record |
| `range between 3 M preceding and current data point` | Window by order key value; `unbounded preceding`, `unbounded following`, `n preceding`, `n following` |
| [`rank(x over (partition by k order by d))`](language.md#rank) | Rank per partition; ties share the lowest rank |
## Grammar
The complete syntax, in the [notation](language.md#notation) of the
language reference. Keywords match in any case; `group by` and the other
two-word keywords take any whitespace between their words.
```text
program ::= statement ( ";" statement )* ";"?
statement ::= name ":=" assigned | assigned
assigned ::= target "<-" context | context
target ::= selection | "{" "v" name "}" | "{" "v_" code "}"
context ::= "with" selection ( "[" "where" expression "]" )? ":" context
| expression
expression ::= disjunction
disjunction ::= conjunction ( ( "or" | "xor" ) conjunction )*
conjunction ::= negation ( "and" negation )*
negation ::= "not" negation | comparison
comparison ::= concatenation ( ( "=" | "!=" | "<>" | "<" | "<=" | ">" | ">=" ) concatenation
| "not"? "in" concatenation )?
concatenation ::= additive ( "&" additive )*
additive ::= multiplicative ( ( "+" | "-" ) multiplicative )*
multiplicative ::= unary ( ( "*" | "/" ) unary )*
unary ::= ( "+" | "-" ) unary | postfix
postfix ::= primary ( "%" | clause )*
primary ::= literal | set | selection | reference | name | call | analytic
| "if" expression "then" expression ( "else" expression )? "endif"
| "(" expression ")"
clause ::= "[" "where" expression "]"
| "[" "get" name "]"
| "[" "sub" name "=" additive "]"
| "[" "rename" name "to" name ( "," name "to" name )* "]"
selection ::= "{" component ( "," component )* "}"
component ::= source | axis | "default" ":" default
| "interval" ":" ( "true" | "false" )
source ::= ( "t" | "g" | "o" ) "_"? code
axis ::= part | "(" part ( "," part )* ")" | ( "r" | "c" | "s" ) "*"
part ::= ( "r" | "c" | "s" ) alnum+ ( "-" ( "r" | "c" | "s" )? alnum+ )?
default ::= ( "+" | "-" )? number "%"? | "(" default ")" | text | "true" | "false"
| "null" | item | date | set
reference ::= "{" "v_" code "}" | "{" "v" name "}"
| "{" "p" "_"? code "," type ( "," "default" ":" default )? "}"
type ::= ( "set-" )? ( "number" | "integer" | "string" | "date" | "boolean" | "item" )
call ::= name "(" ( expression ( "," expression )* )? ")"
| aggregate "(" expression "group" "by" name ( "," name )* ")"
| "time_shift" "(" expression "," period "," expression ( "," name )? ")"
analytic ::= ( aggregate | "rank" ) "(" expression "over" "(" partition? order? window? ")" ")"
aggregate ::= "sum" | "count" | "avg" | "median" | "max" | "min" | "max_aggr" | "min_aggr"
partition ::= "partition" "by" name ( "," name )*
order ::= "order" "by" name ( "asc" | "desc" )? ( "," name ( "asc" | "desc" )? )*
window ::= ( "data" "points" | "range" ) "between" bound "and" bound
bound ::= "unbounded" ( "preceding" | "following" ) | "current" "data" "point"
| digit+ period? ( "preceding" | "following" )
period ::= "A" | "S" | "Q" | "M" | "W" | "D"
set ::= "{" "}" | "{" expression ( "," expression )* "}"
literal ::= number "%"? | text | date | "true" | "false" | "null" | item
```
A set's first member is a literal, an item, a signed number or a
parenthesized number; braces that start otherwise are a selection or
reference. The lexical rules for `name`, `code`, `number`, `text`, `date`
and `item` are under [Lexical structure](language.md#lexical).
# Language reference
Every construct dpm-xl accepts, with its syntax, meaning, operand and result
types, null and interval behaviour, how it treats keyed records, the errors
it raises, and examples whose results the test suite computes. The
[quick reference](quick-reference.md) lists the same constructs on one
page; the [concept pages](../concepts/index.md) explain the semantics in
depth.
## How to read this page {#reading}
### Syntax notation {#notation}
Syntax is written in an EBNF in the style of the XML specification:
- `"with"` is the text itself; keywords match in any case.
- `expression` is another construct, defined on this page.
- `a b` is `a` followed by `b`; `a | b` is `a` or `b`.
- `a?` is an optional `a`; `a*` and `a+` repeat `a` zero or more, and one or
more times.
- `( … )` groups.
`name` is a [name](#names), `number` a [number literal](#numbers), `code` a
table code, `alnum` a letter or digit, `digit` a digit, and `literal` any
[literal](#literals).
### Examples and results {#examples}
The examples run against a small report, built by
[`examples/language_report.py`](https://github.com/luukhopman/dpm-xl/blob/main/examples/language_report.py):
| Table | Layout | Values |
| --- | --- | --- |
| `T` | rows `0010`–`0030`, columns `0010`, `0020` | c0010: 100, 60, 40; c0020: 5, -5 and nothing in r0030. Reported with `decimals` 0, so ±0.5 with `interval: true`. |
| `U` | rows `0010`–`0030`, column `0010` | 1, 2, 3, exact |
| `S` | row `0010`, column `0010`, sheets `0001`, `0002` | 7, 8 |
| `X` | row `0010`, columns `0010`–`0040` | `"LEI-A"`, `[eba_CU:EUR]`, `#2026-06-30#`, `true` |
| `O` | open, keys `qKEY`, `qDAT`; columns `0010`, `0020` | a, 2026-03-31: 10, EUR; b, 2026-06-30: 20, USD; c, 2026-09-30: 30, JPY |
| `P` | open, key `qKEY`; column `0010` | b: 2; d: 4 |
Report parameters: `refPeriod` = 2026-12-31, `Level` = `"solo"`, `Limit` =
5 and the set `Allowed` = `{[CU:EUR], [CU:USD]}`. Table group `G` holds `T`
and `U`; operation `v1` is `with {tT, c0010}: {r*} * 2`. In the report one
year earlier (`time_shift(…, A, 1)`), T r0010 c0010 is 50.
A result without keys is one value; a keyed result lists its records as
`key=value, …: value`, separated by `;`. Numbers are shown in full, with
`± tolerance` when they carry an [interval](../concepts/intervals.md)
tolerance; text is quoted and items are in brackets. `error: …` is an
[evaluation error](#errors) (in a keyed result, of that record only),
`unsupported: …` a reference the provider cannot resolve, `syntax error: …`
a [syntax error](#errors), and `no records` an empty result. To try an
expression of your own:
```bash
python examples/language_report.py "sum({tT, c0010, r*})"
```
### Data types {#data-types}
| Type | Values | Literal |
| --- | --- | --- |
| number | Decimals with 34 significant digits, and a tolerance from reported precision | [`12`, `4.5%`](#numbers) |
| text | Character strings | [`"text"`](#strings) |
| boolean | `true`, `false` | [`true`](#boolean-and-null) |
| date | Dates and date-times | [`#2026-12-31#`](#dates) |
| item | A member of a category, `category:code` | [`[eba_CU:EUR]`](#items) |
| set | Distinct non-null values | [`{1, 2}`](#set-literals) |
| null | No value | [`null`](#boolean-and-null) |
`integer` exists only as a [parameter type](#typed-parameter): a number
without a fraction. Each entry below states the types it takes; another
type is an evaluation error.
### Records, keys and nulls {#records}
An operand is a set of records: one value, or one value per key such as
`r=0010` or `qKEY=a`. Unless an entry says otherwise:
- Operators and functions of several operands **join** their operands on
the keys they share, and a key-less operand applies to every record
([Operators join on shared keys](../concepts/records-and-keys.md#operators-join-on-shared-keys)).
Keyed operands must have the same keys, or one operand's keys must
include every other's; anything else is the error `operands have
incompatible keys`.
- An operand with a `default` stands in for the records it lacks
([Defaults fill in missing records](../concepts/records-and-keys.md#defaults-fill-in-missing-records)).
- A **null** operand makes the result null
([Null logic](../concepts/null.md)). A record whose result is null is not
evaluated, never a failure.
- An **evaluation error** in one record, such as a division by zero, fails
only that record; it is the rule's error only if the record reaches the
result ([Errors stay in their record](../concepts/null.md#errors-stay-in-their-record)).
- A value passed on unchanged, such as an `if` branch, the value `nvl` or
`filter` keeps, or a record that `where`, `sub` or `rename` keeps, keeps
its interval tolerance.
- `where`, `sub`, `filter`, `rename`, a `with` context's `where` and a
`time_shift` of a date key keep their operand's `default`; the results of
`get`, aggregates and analytic functions have none and join on shared
keys only
([Defaults fill in missing records](../concepts/records-and-keys.md#defaults-fill-in-missing-records)).
### Errors {#errors}
| Kind | When | Rule status |
| --- | --- | --- |
| Syntax error | The text does not parse: `DpmXlSyntaxError`, with its position | `error` |
| Evaluation error | A wrong type, an unknown table, key or parameter, incompatible keys, a division by zero | `error` |
| Unsupported | The rule needs a provider method the provider lacks, or calls an unknown function | `unsupported` |
`diagnose` finds syntax errors, unknown
functions and wrong operand counts without data, and with table layouts
also unknown tables, coordinates and keys and operands of the wrong type.
Messages may be reworded in minor releases; statuses do not change
([Stability](../project/stability.md)).
## Lexical structure {#lexical}
### Whitespace and comments {#whitespace}
Spaces, tabs and line breaks separate tokens and are otherwise ignored
outside text literals. The words of `group by`, `partition by`, `order by`,
`data points` and `current data point` may be separated by any whitespace.
Expressions have no comments. Text [rule files](data-formats.md#rule-files) skip
lines that start with `#`, but that belongs to the file format.
### Case {#case}
Keywords, function names, `true`, `false`, `null` and parameter types match
in any case. Everything else is case-sensitive: the reference prefixes
`t g v o p` and the axis letters `r c s` are lower case, the period codes
`A S Q M W D` upper case, and names of temporaries match exactly.
[Key names](#key-names) in clauses ignore case.
| Expression | Result |
| --- | --- |
| `IF TRUE THEN Abs(-1) ENDIF` | `1` |
| `{TT, r0010, c0010}` | `syntax error: unknown selection component 'TT'` |
### Keywords and word boundaries {#word-boundaries}
A word is a letter or `_` followed by letters, digits, `_` and `.`, not
ending in `.`. A word is read whole, so a keyword needs whitespace or
punctuation between it and a neighbouring word: `ornot` is one name, not
`or not`, and `trueendif` is not `true endif`. A word may directly follow a
number (`2endif` reads `2 endif`), and symbols need no spaces
(`1<2and(true)`).
| Expression | Result |
| --- | --- |
| `true ornot false` | `syntax error: expected end of expression, found 'ornot'` |
| `true or(not false)` | `true` |
| `1<2and 2>1` | `true` |
`diagnose` suggests the missing space:
`did you mean 'or not'?`.
### Names {#names}
```text
name ::= word | "`" character* "`"
```
A name is a [word](#word-boundaries) that is not a reserved word, or any
text without backticks between backticks. Names stand for
[temporaries](#temporary-assignment), [report parameters](#bare-names) and,
inside [clauses](#clauses), [key components](#key-names), and parse to
`Name`. Backticks make a reserved word or text with
spaces a name: `` `to` ``, `` `a b` ``.
The keywords `and else endif get group by if in not or over rename sub then
to where with xor` are reserved everywhere. The
[window keywords](#analytic-functions) are reserved only inside `over (…)`,
`default` and `interval` only inside selections, function names only before
`(`, and period codes only where a period is expected; elsewhere they are
ordinary names.
| Expression | Result |
| --- | --- |
| `Level` | `"solo"` |
| `` `Level` `` | `"solo"` |
| `` `a.b` := 4; `a.b` + 1 `` | `5` |
| `range := 2; sum := 3; range * sum` | `6` |
| `to` | `syntax error: unexpected keyword 'to'` |
### Literals {#literals}
```text
literal ::= number | percentage | text | date | "true" | "false" | "null" | item
```
Literals are exact values. They parse to
`Literal_` with `kind` `number`, `string`, `date`,
`boolean`, `null` or `item` (whose value is an
`Item`). [Set literals](#set-literals) are described
with sets.
#### Numbers {#numbers}
```text
number ::= digit+ ( "." digit+ )? exponent? | "." digit+ exponent?
exponent ::= ( "e" | "E" ) ( "+" | "-" )? digit+
```
A decimal, read exactly: `0.1` is one tenth. A number literal has no sign;
`-3` is [unary minus](#unary) applied to `3`. A point needs a digit after
it, so `1.` is a syntax error. A number whose exponent is outside the
decimal128 range (about ±6144) is the syntax error `number out of range`.
#### Percentages {#percent}
```text
percentage ::= ( number | "(" number ")" ) "%"
```
`%` after a number literal, possibly in parentheses, divides it by 100.
After anything else, such as a name or `(1 + 1)`, it is the syntax error
`'%' must follow a number`, and so is `5%%`.
| Expression | Result |
| --- | --- |
| `12` | `12` |
| `-3.5` | `-3.5` |
| `.5` | `0.5` |
| `1.5e3` | `1500` |
| `0.1 + 0.2 = 0.3` | `true` |
| `4.5%` | `0.045` |
| `2 * 3 %` | `0.06` |
| `1.` | `syntax error: unexpected character '.'` |
| `(1)%` | `0.01` |
| `Limit%` | `syntax error: '%' must follow a number` |
#### Text {#strings}
```text
text ::= '"' character* '"' | "'" character* "'"
```
Text between double or single quotes, possibly empty, possibly spanning
lines. There are no escape sequences: a text cannot contain its own quote,
so write `"it's"` or `'say "yes"'`.
| Expression | Result |
| --- | --- |
| `"text"` | `"text"` |
| `"it's"` | `"it's"` |
| `''` | `""` |
#### Dates {#dates}
```text
date ::= "#" YYYY "-" MM "-" DD ( "T" hh ":" mm ":" ss )? "#"
```
A calendar date, or a date-time with a time part, with every digit written.
`#2026-1-1#` and impossible dates such as `#2026-02-30#` are the syntax
error `invalid date`. A date equals the date-time at its midnight.
| Expression | Result |
| --- | --- |
| `#2026-12-31#` | `2026-12-31` |
| `#2026-12-31T10:30:00#` | `2026-12-31T10:30:00` |
| `#2026-12-31# = #2026-12-31T00:00:00#` | `true` |
| `#2026-02-30#` | `syntax error: invalid date '2026-02-30'` |
#### Booleans and null {#boolean-and-null}
`true` and `false` are the Boolean values and `null` the absent value, in
any case. Null propagates through most operations
([Null logic](../concepts/null.md)).
| Expression | Result |
| --- | --- |
| `TRUE` | `true` |
| `null` | `null` |
| `isnull(null)` | `true` |
#### Items {#items}
```text
item ::= "[" category ":" code "]" | "[" code "]" | "[" "`" code "`" "]"
```
A member of a DPM category, such as a currency or a country. The category
starts with a letter and holds letters, digits, `_` and `-`; the code holds
letters, digits, `_`, `.` and `-` and ends in a letter or digit. Spaces may
pad the inside of the brackets, but not the `:`. The second form, a code
alone such as `[qEBB]` or `[ei86]`, is a property or legacy item code.
Items support only `=` and `!=`. Two items are equal when their codes match
and their categories match, ignoring an `eba_` prefix and a category
missing on either side: `[eba_CU:EUR]`, `[CU:EUR]` and `[EUR]` are equal.
Text compared with an item is read as an item.
A `[` followed by `where`, `get`, `sub` or `rename` opens a
[clause](#clauses) instead.
| Expression | Result |
| --- | --- |
| `[eba_CU:EUR]` | `[CU:EUR]` |
| `[eba_CU:EUR] = [EUR]` | `true` |
| `[CU:EUR] = "eba_CU:EUR"` | `true` |
| `[qEBB]` | `[qEBB]` |
| `[CU:EUR] < [CU:USD]` | `error: items do not support '<'` |
| `[eba_CU : EUR]` | `syntax error: invalid item '[eba_CU : EUR]'` |
### Limits {#limits}
An expression nests at most 50 levels deep (parentheses, signs, `not`,
`if`, calls, `with` bodies and chained clauses count); deeper is the
syntax error `expression too deeply nested`. Arithmetic keeps 34
significant digits.
## Programs and statements {#statements}
```text
program ::= statement ( ";" statement )* ";"?
statement ::= name ":=" assigned | assigned
assigned ::= target "<-" context | context
context ::= "with" selection ( "[" "where" expression "]" )? ":" context | expression
```
A rule is one expression. A script is statements separated by `;`, run in
order; the last statement gives the result and needs no `;`.
`parse` reads one expression (a trailing `;` is
allowed) and `parse_program` a script, which
`evaluate_rule` also accepts
([Statements and assignments](../concepts/statements.md)). Parses to
`Program`.
| Expression | Result |
| --- | --- |
| `x := 1; y := x + 1; y * 10;` | `20` |
| `1;;` | `syntax error: unexpected ';' where an operand is expected` |
### Temporary assignment `:=` {#temporary-assignment}
```text
name ":=" assigned
```
Evaluates the right side and binds its records, keys included, to `name`
for the statements that follow; the statement's result is that value. A
temporary hides a report parameter of the same name, and inside a clause a
key component of the same name hides it. Parses to
`Assignment`.
| Expression | Result |
| --- | --- |
| `d := {tT, c0010} - {tT, c0020}; d` | `r=0010: 95; r=0020: 65; r=0030: null` |
| `Level := 1; Level` | `1` |
| `x := 1; X` | `error: unknown key component or parameter 'X'` |
### Persistent assignment `<-` {#persistent-assignment}
```text
target ::= selection | "{" "v" name "}" | "{" "v_" code "}"
```
`target <- value` evaluates the value and returns it in
`RuleResult.assignments` under the target's
normalized text, such as `{tT, r0010, c0010}`. Nothing is written to the
report: later statements still read the reported values. The statement's
result is the value. Outside clauses `<-` is always this assignment, so
write a comparison with a negative operand as `a < -1`. A target that is
not a selection or `{v…}` reference is a syntax error. Parses to
`PersistentAssignment`.
| Expression | Result |
| --- | --- |
| `{tT, r0010, c0010} <- {tU, r0010, c0010} * 2` | `2` |
| `{tT, r0010, c0010} <- 5; {tT, r0010, c0010}` | `100` |
| `{vTotal} <- sum({tU})` | `6` |
### `with` {#with}
```text
"with" selection ( "[" "where" expression "]" )? ":" context
```
Sets a context for the [selections](#selections) of its body: each takes
the context's table, axes, `default` and `interval` where it does not write
its own. A nested `with` replaces the components it names. `with` may start
a statement, an assignment's value or another `with` body; elsewhere, such
as in parentheses, it is a syntax error.
The optional `where` applies, as a [`where` clause](#where), to each
selection of the body that has the keys it names. Its `and` terms that name
a key the selection lacks (neither one of its keys nor a temporary or
report parameter) leave that selection alone, and a key-less selection
takes none of it. A `where` or `sub` written on a selection replaces the
context condition's terms on the same key
([Clauses change keys and records](../concepts/records-and-keys.md#clauses-change-keys-and-records)).
Parses to `With`.
| Expression | Result |
| --- | --- |
| `with {tT, c0010}: {r0010} + {r0020}` | `160` |
| `with {tT, c0020, default: 0}: {r0030}` | `0` |
| `with {tT, c0020, default: 0}: {r0030, default: 1}` | `1` |
| `with {tT, interval: true}: {r0010, c0010}` | `100 ± 0.5` |
| `with {tT}: with {c0010}: {r0010}` | `100` |
| `with {tO, c0010}[where qKEY = "a"]: {tO}` | `qKEY=a, qDAT=2026-03-31: 10` |
| `with {tO, c0010}[where qKEY = "a"]: {c0010}[sub qKEY = "b"]` | `qDAT=2026-06-30: 20` |
| `with {tO}[where qKEY = "a"]: {c0010} + {tT, r0010, c0010}` | `qKEY=a, qDAT=2026-03-31: 110` |
| `with {tO}[where qDAT > #2026-05-01#]: {c0010} + {tP, c0010}` | `qKEY=b, qDAT=2026-06-30: 22` |
| `with {tO}[where qKEY = "a" and r = "0010"]: {c0010}` | `qKEY=a, qDAT=2026-03-31: 10` |
| `(with {tT}: {r0010, c0010})` | `syntax error: unexpected keyword 'with'` |
## Selections {#selections}
```text
selection ::= "{" component ( "," component )* "}"
component ::= source | axis | "default" ":" default | "interval" ":" ( "true" | "false" )
source ::= ( "t" | "g" | "o" ) "_"? code
code ::= ( letter | digit | "_" | "." | "-" )+ | "`" character* "`"
```
A selection reads cells. Its components are separated by commas, come in
any order and appear at most once each (one source, one selector per axis,
one `default`, one `interval`); a repeated one is a syntax error. The
source is written or comes from an enclosing [`with`](#with); without one
the selection is the evaluation error `selection … has no table`. Braces
that start with a literal, an item or a signed number are a
[set literal](#set-literals) instead, and `{v…}` and `{p…}` braces are
[references](#references). Parses to `Selection`
holding a `SelectionSpec`.
### Table `t` {#table}
`tCode` names a table, such as `tC_01.00`. A `_` after `t` is cosmetic
(`t_C_01.00` is `tC_01.00`), and backticks escape other codes:
`` t`C 01.00` ``. An unknown table is the evaluation error `unknown table
…`. The sources `g` and `o` select from a [table group](#table-group) or
an [operation](#operation).
### Rows, columns and sheets `r c s` {#axes}
```text
axis ::= part | "(" part ( "," part )* ")" | ( "r" | "c" | "s" ) "*"
part ::= ( "r" | "c" | "s" ) alnum+ ( "-" ( "r" | "c" | "s" )? alnum+ )?
```
`r`, `c` and `s` select rows, columns and sheets: one code (`r0010`), an
inclusive range (`r0010-0050`), a list of codes and ranges on one axis in
parentheses (`(c0010, c0030-0050)`), or every coordinate (`r*`). Codes hold
letters and digits; a range may repeat its axis letter (`r0010-r0050`). An
axis that is not written selects every coordinate on it.
Codes match as DPM numbers them: numeric codes as numbers padded to four
digits (`r10` is row `0010`), others ignoring case. A range is numeric when
both ends are numeric and compares text otherwise. A list cannot mix axes
or hold `*`. A coordinate the table lacks selects nothing.
**Keys.** Each axis that selects more than one coordinate is a key, `r`,
`c` or `s`, with the coordinate codes as text; open tables add their key
components, such as `qKEY`
([Selections yield records](../concepts/records-and-keys.md#selections-yield-records)).
| Expression | Result |
| --- | --- |
| `{tT, r0010, c0010}` | `100` |
| `{t_T, r10, c10}` | `100` |
| `{tT, c0010}` | `r=0010: 100; r=0020: 60; r=0030: 40` |
| `{tT, r0010-0020, c0020}` | `r=0010: 5; r=0020: -5` |
| `{tT, (r0010, r0030), c0010}` | `r=0010: 100; r=0030: 40` |
| `{tT, r0010, c*}` | `c=0010: 100; c=0020: 5` |
| `{tS}` | `s=0001: 7; s=0002: 8` |
| `{tS, s0002}` | `8` |
| `sum({tS, r0010, c0010, s*})` | `15` |
| `{tO, c0010}` | `qKEY=a, qDAT=2026-03-31: 10; qKEY=b, qDAT=2026-06-30: 20; qKEY=c, qDAT=2026-09-30: 30` |
| `{tT, r0010, r0020}` | `syntax error: duplicate rows selector` |
| `{tT, (r0010, c0010)}` | `syntax error: axis list mixes axes` |
| `{tZ, r0010}` | `error: unknown table Z` |
### `default` {#default}
```text
default ::= ( "+" | "-" )? number "%"? | "(" default ")" | text | "true" | "false" | "null" | item | date | set
```
The value of a selected cell without a fact; without `default` it is null.
A written `default`, `default: null` included, also stands in for records
that another operand has and the selection lacks
([Defaults fill in missing records](../concepts/records-and-keys.md#defaults-fill-in-missing-records)).
The value is a literal, not an expression: `default: 1 + 1` is a syntax
error. A set is a default only of a [set parameter](#typed-parameter); on a
selection it is an evaluation error.
| Expression | Result |
| --- | --- |
| `{tT, r*, c0020}` | `r=0010: 5; r=0020: -5; r=0030: null` |
| `{tT, r*, c0020, default: 0}` | `r=0010: 5; r=0020: -5; r=0030: 0` |
| `{tT, r0030, c0020, default: (-1)}` | `-1` |
| `{tO, c0010} + {tP, c0010}` | `qKEY=b, qDAT=2026-06-30: 22` |
| `{tO, c0010} + {tP, c0010, default: 0}` | `qKEY=a, qDAT=2026-03-31: 10; qKEY=b, qDAT=2026-06-30: 22; qKEY=c, qDAT=2026-09-30: 30` |
| `{tT, c0010, default: {1}}` | `error: a set default applies only to set parameters` |
### `interval` {#interval}
`interval: true` keeps each reported number's tolerance (from its XBRL
`decimals`), so that arithmetic and comparisons use
[interval arithmetic](../concepts/intervals.md); `interval: false`, or no
`interval`, makes the numbers exact. The value is `true` or `false`.
| Expression | Result |
| --- | --- |
| `{tT, r0010, c0010, interval: true}` | `100 ± 0.5` |
| `{tT, r0010, c0010, interval: true} = 100.4` | `true` |
| `{tT, r0010, c0010} = 100.4` | `false` |
| `{tT, r0010, c0010, interval: 1}` | `syntax error: interval must be true or false` |
## References {#references}
```text
reference ::= "{" "v_" code "}" | "{" "v" name "}"
| "{" "p" "_"? code "," type ( "," "default" ":" default )? "}"
```
Braces also read report-level data and other rules' results; each kind of
reference goes to a provider method
([Parameters, operations and table groups](../concepts/references.md)).
### Filing indicator `{v_…}` {#filing-indicator}
`{v_Code}` is `true` when table or template `Code` was reported, else
`false` (the provider's `table_reported`). The `_` matters: without it the
braces read a [report parameter](#report-parameter). Usually a
[precondition](../concepts/results.md#preconditions). Parses to
`TableReported`.
| Expression | Result |
| --- | --- |
| `{v_T}` | `true` |
| `{v_Z}` | `false` |
| `{v_T} and not {v_Z}` | `true` |
### Report parameter `{v…}` {#report-parameter}
`{vName}` is report parameter `Name` as the provider gives it, of any type;
null when it is missing. `Name` starts with a letter; backticks escape
other names: ``{v`a b`}``. Parses to `Parameter`.
| Expression | Result |
| --- | --- |
| `{vrefPeriod}` | `2026-12-31` |
| `{vLevel}` | `"solo"` |
| `{vMissing}` | `null` |
#### Bare names {#bare-names}
A [name](#names) reads a [temporary](#temporary-assignment) or, failing
that, the report parameter of that name; inside a clause a key component
comes first ([Key names](#key-names)). An unknown name is the evaluation
error `unknown key component or parameter`.
| Expression | Result |
| --- | --- |
| `refPeriod` | `2026-12-31` |
| `missing` | `error: unknown key component or parameter 'missing'` |
### Typed parameter `{p_…}` {#typed-parameter}
```text
type ::= "number" | "integer" | "string" | "date" | "boolean" | "item"
| "set-number" | "set-integer" | "set-string" | "set-date" | "set-boolean" | "set-item"
```
`{p_Name, type}` reads report parameter `Name` and checks its type; a value
of another type is the evaluation error `parameter … is not …`. Text is
read as a date or an item for those types, and `integer` takes whole
numbers. A missing parameter takes the `default`, or is null. A `set-…`
type takes a set of that member type, and its default may be a
[set literal](#set-literals). The `_` after `p` is cosmetic; the type comes
before the default. Parses to `TypedParameter`.
| Expression | Result |
| --- | --- |
| `{p_refPeriod, date}` | `2026-12-31` |
| `{p_Limit, number} + 1` | `6` |
| `{pLimit, integer}` | `5` |
| `{p_Level, string}` | `"solo"` |
| `{p_Allowed, set-item}` | `{[CU:EUR], [CU:USD]}` |
| `{p_Missing, boolean, default: true}` | `true` |
| `{p_Missing, item, default: [CU:EUR]}` | `[CU:EUR]` |
| `{p_Missing, set-number, default: {1, 2}}` | `{1, 2}` |
| `{p_Missing, set-integer}` | `null` |
| `{p_Missing, set-string, default: {"a"}}` | `{"a"}` |
| `{p_Missing, set-date, default: {#2026-01-01#}}` | `{2026-01-01}` |
| `{p_Missing, set-boolean, default: {true}}` | `{true}` |
| `{p_Limit, date}` | `error: parameter Limit is not a date` |
| `{p_Limit}` | `syntax error: expected ',', found '}'` |
### Operation `{o_…}` {#operation}
```text
"{" "o" "_"? code ( "," ( axis | "default" ":" default | "interval" ":" ( "true" | "false" ) ) )* "}"
```
Evaluates operation `Code`, the expression the provider's optional
`operation(code)` method returns, on its own: the referring rule's `with`
context, temporaries and assignments do not reach it. On its result, axes
keep the matching `r`, `c` and `s` records, `default` fills nulls and
`interval: false` makes numbers exact. An operation that refers to itself
is an evaluation error; without the provider method the rule is
unsupported. Parses to a `Selection` with `spec.operation`.
| Expression | Result |
| --- | --- |
| `{o_v1}` | `r=0010: 200; r=0020: 120; r=0030: 80` |
| `{o_v1, r0010}` | `200` |
| `{o_X}` | `unsupported: operation X needs a provider that resolves operations` |
### Table group `{g_…}` {#table-group}
`{g_Group, …}` selects from every table of a group, which the provider's
optional `table_group(code)` method lists. With several tables the records
gain the key `t`, the table code, and the tables must have the same keys
for the selection. Without the provider method the rule is unsupported.
Parses to a `Selection` with `spec.table_group`.
| Expression | Result |
| --- | --- |
| `{g_G, r0010, c0010}` | `t=T: 100; t=U: 1` |
| `{g_G, r0010}` | `error: the tables of group G have different keys` |
| `{g_X, r0010}` | `unsupported: table group X needs a provider that resolves table groups` |
## Operators {#operators}
### Precedence and associativity {#precedence}
From loosest to tightest binding:
| Level | Operators | Associativity | Entry |
| --- | --- | --- | --- |
| 1 | `;`, `:=`, `<-`, `with … :` | statements only | [Statements](#statements) |
| 2 | `or`, `xor` | left | [Logical operators](#logical) |
| 3 | `and` | left | [Logical operators](#logical) |
| 4 | `not` | prefix; nests (`not not x`) | [`not`](#not) |
| 5 | `=`, `!=`, `<>`, `<`, `<=`, `>`, `>=`, `in`, `not in` | none: one per operand | [Comparison](#comparison), [`in`](#in) |
| 6 | `&` | left | [Concatenation](#concatenation) |
| 7 | `+`, `-` | left | [Arithmetic](#arithmetic) |
| 8 | `*`, `/` | left | [Arithmetic](#arithmetic) |
| 9 | unary `+`, `-` | prefix; nests (`- -x`) | [Unary plus and minus](#unary) |
| 10 | `%` after a number literal, clauses `[…]` | postfix; chain left to right | [Percentages](#percent), [Clauses](#clauses) |
| 11 | literals, selections, references, names, calls, `if … endif`, `( … )` | | |
Without parentheses `not` cannot be an operand of a comparison, arithmetic
or a sign: `1 = not true` and `- not x` are syntax errors. Comparisons do
not chain: `a < b < c` is a syntax error.
| Expression | Result |
| --- | --- |
| `1 + 2 * 3` | `7` |
| `(1 + 2) * 3` | `9` |
| `10 - 4 - 3` | `3` |
| `12 / 4 / 3` | `1` |
| `-2 * -3` | `6` |
| `"a" & "b" = "ab"` | `true` |
| `1 + 1 in {2}` | `true` |
| `not 1 = 2` | `true` |
| `not true and false` | `false` |
| `true or false and false` | `true` |
| `true xor true or true` | `true` |
| `1 < 2 < 3` | `syntax error: expected end of expression, found '<'` |
| `1 = not true` | `syntax error: unexpected keyword 'not'` |
### Arithmetic `+ - * /` {#arithmetic}
```text
additive ::= multiplicative ( ( "+" | "-" ) multiplicative )*
multiplicative ::= unary ( ( "*" | "/" ) unary )*
```
Addition, subtraction, multiplication and division of numbers.
- **Types:** numbers in, a number out; exact decimal arithmetic with 34
significant digits.
- **Null:** null if either operand is null.
- **Intervals:** tolerances propagate
([Arithmetic](../concepts/intervals.md#arithmetic)).
- **Keys:** the operands [join](#records).
- **Errors:** `+ needs numbers, found …`; `division by zero` when the
divisor's value is zero.
- **Node:** `Binary`.
| Expression | Result |
| --- | --- |
| `{tT, r0020, c0010} + {tT, r0030, c0010}` | `100` |
| `{tT, c0010} - {tT, c0020}` | `r=0010: 95; r=0020: 65; r=0030: null` |
| `{tT, c0010, interval: true} * 2` | `r=0010: 200 ± 1; r=0020: 120 ± 1; r=0030: 80 ± 1` |
| `1 / 3` | `0.3333333333333333333333333333` |
| `1 + null` | `null` |
| `1 / 0` | `error: division by zero` |
| `{tT, c0010} / {tT, c0020}` | `r=0010: 20; r=0020: -12; r=0030: null` |
| `1 + "a"` | `error: + needs numbers, found string` |
| `{tT, r0010} + {tT, c0010}` | `error: operands have incompatible keys (r) and (c)` |
### Unary plus and minus {#unary}
```text
unary ::= ( "+" | "-" ) unary | postfix
```
`-x` negates a number and `+x` returns it, for each record, keeping its
tolerance. Null stays null; another type is the error `unary - needs
numbers`. Parses to `Unary`.
| Expression | Result |
| --- | --- |
| `-{tT, r0010, c*}` | `c=0010: -100; c=0020: -5` |
| `- -1` | `1` |
| `-"a"` | `error: unary - needs numbers, found string` |
### Concatenation `&` {#concatenation}
```text
concatenation ::= additive ( "&" additive )*
```
Joins two texts; null if either is null. Another type is the error `&
needs text, found …`. Binds looser than arithmetic and tighter than
comparisons. Parses to `Binary`.
| Expression | Result |
| --- | --- |
| `"LEI" & "-" & "A"` | `"LEI-A"` |
| `{tX, c0010} & "/2026"` | `"LEI-A/2026"` |
| `"a" & null` | `null` |
| `"a" & 1` | `error: & needs text, found number` |
### Comparison `= != <> < <= > >=` {#comparison}
```text
comparison ::= concatenation ( ( "=" | "!=" | "<>" | "<" | "<=" | ">" | ">=" ) concatenation )?
```
Compares two values and yields a Boolean. `<>` is `!=`; the node's `op` is
`!=`.
| Operands | Comparison |
| --- | --- |
| number, number | By value; with tolerances as intervals ([Comparisons](../concepts/intervals.md#comparisons)) |
| text, text | By character code |
| date, date | In date order; a date is the date-time at its midnight |
| boolean, boolean | `false` before `true`; the text `"true"`, `"false"`, `"1"` or `"0"` counts as a Boolean |
| item, item | `=` and `!=` only, as under [Items](#items); text counts as an item |
| date, text | As text |
- **Null:** null if either operand is null.
- **Keys:** the operands [join](#records).
- **Errors:** `cannot compare number with string using '='` and the like
for other pairs, sets included; `items do not support '<'`.
- **Node:** `Binary`.
| Expression | Result |
| --- | --- |
| `{tT, r0010, c0010} > {tT, r0020, c0010}` | `true` |
| `{tT, c0010} >= 50` | `r=0010: true; r=0020: true; r=0030: false` |
| `"a" < "b"` | `true` |
| `#2026-01-01# < #2026-01-02#` | `true` |
| `true > false` | `true` |
| `{tX, c0040} = "true"` | `true` |
| `{tX, c0020} = [CU:EUR]` | `true` |
| `1 != 2` | `true` |
| `1 <> 2` | `true` |
| `null = null` | `null` |
| `1 = "1"` | `error: cannot compare number with string using '='` |
| `{1} = {1}` | `error: cannot compare set with set using '='` |
### `in`, `not in` {#in}
```text
membership ::= concatenation "not"? "in" concatenation
```
`x in s` is `true` when `x` equals (`=`) a member of the set `s`, so `x`'s
interval tolerance applies; `x not in s` is `not (x in s)`. `s` is a
[set literal](#set-literals), a set parameter, a
[set function](#set-functions), or any other operand, which stands for the
set of its non-null values ([Sets](../concepts/sets.md)).
- **Null:** null when `x` is null, or when `s` is a single null, such as a
missing set parameter.
- **Keys:** applies to each record of `x`; the set is not joined.
- **Errors:** incompatible types, as for `=`.
- **Node:** `In`; `not in` is `Unary("not", In(…))`.
| Expression | Result |
| --- | --- |
| `{tU, c0010} in {1, 3}` | `r=0010: true; r=0020: false; r=0030: true` |
| `{tO, c0020} in {p_Allowed, set-item}` | `qKEY=a, qDAT=2026-03-31: true; qKEY=b, qDAT=2026-06-30: true; qKEY=c, qDAT=2026-09-30: false` |
| `{tO, c0020} not in {[CU:JPY]}` | `qKEY=a, qDAT=2026-03-31: true; qKEY=b, qDAT=2026-06-30: true; qKEY=c, qDAT=2026-09-30: false` |
| `60 in {tT, c0010}` | `true` |
| `{tT, r0010, c0010, interval: true} in {100.4}` | `true` |
| `null in {1}` | `null` |
| `1 in {p_Missing, set-number}` | `null` |
| `1 in {}` | `false` |
### `not` {#not}
```text
negation ::= "not" negation | comparison
```
Negates a Boolean, for each record; null stays null. Another type is the
error `not needs a boolean`. `not` binds looser than comparisons: `not a =
b` is `not (a = b)`. Parses to `Unary`.
| Expression | Result |
| --- | --- |
| `not {tT, c0010} > 50` | `r=0010: false; r=0020: false; r=0030: true` |
| `not null` | `null` |
| `not 1` | `error: not needs a boolean, found number` |
### Logical operators `and or xor` {#logical}
```text
disjunction ::= conjunction ( ( "or" | "xor" ) conjunction )*
conjunction ::= negation ( "and" negation )*
```
Conjunction, disjunction and exclusive or with three-valued (Kleene) logic:
`false and null` is `false` and `true or null` is `true`; otherwise a null
operand makes the result null, and `xor` with a null is always null. Both
operands are always evaluated.
- **Keys:** the operands [join](#records).
- **Errors:** `and needs booleans, found …`.
- **Node:** `Binary`.
| Expression | Result |
| --- | --- |
| `{tT, c0010} > 50 and {tT, c0020} > 0` | `r=0010: true; r=0020: false; r=0030: false` |
| `{v_T} or {v_Z}` | `true` |
| `true xor false` | `true` |
| `null and false` | `false` |
| `null or false` | `null` |
| `true xor null` | `null` |
| `1 and true` | `error: and needs booleans, found number` |
### `if` {#if}
```text
"if" expression "then" expression ( "else" expression )? "endif"
```
Chooses a branch by a Boolean condition; a null condition takes the `else`
branch. Without `else`, a false or null condition yields `true` when the
`then` branch is Boolean, so that a guarded check passes when its guard
does not hold, and null otherwise
([Conditionals](../concepts/null.md#conditionals)). `if … endif` is an
operand: it nests and combines with operators.
- **Keys:** a key-less condition picks a whole branch. A keyed condition
gives one record per condition record, from the matching branch record;
branch keys the condition lacks widen it. Defaults on the condition or a
branch fill in missing keys
([Defaults fill in missing records](../concepts/records-and-keys.md#defaults-fill-in-missing-records)).
- **Errors:** `if needs a boolean condition, found …`, in the record
concerned.
- **Node:** `If`.
| Expression | Result |
| --- | --- |
| `if {v_T} then {tT, r0010, c0010} > 0 endif` | `true` |
| `if {tT, c0010} > 50 then {tT, c0010} else 0 endif` | `r=0010: 100; r=0020: 60; r=0030: 0` |
| `if {tT, c0010} > 50 then {tT, c0020} > 0 endif` | `r=0010: true; r=0020: false; r=0030: true` |
| `if {tT, c0010} > 50 then {tT, c0020} endif` | `r=0010: 5; r=0020: -5; r=0030: null` |
| `if null then 1 else 2 endif` | `2` |
| `if false then 1 else if false then 2 else 3 endif endif + 1` | `4` |
| `if 1 then 2 endif` | `error: if needs a boolean condition, found number` |
## Clauses {#clauses}
```text
postfix ::= primary ( "%" | clause )*
clause ::= "[" "where" expression "]"
| "[" "get" name "]"
| "[" "sub" name "=" additive "]"
| "[" "rename" name "to" name ( "," name "to" name )* "]"
```
A clause follows any operand in brackets, usually a selection or a name.
Clauses chain left to right. Inside a clause `<-` reads `< -`, so
`[where qX<-1]` compares with -1.
### Key names {#key-names}
Names in clauses, `group by` and `over` resolve against the operand's keys:
exactly, else ignoring case and the DPM 2.0 `q` prefix, so `KEY`, `key` and
`qkey` all find `qKEY`. A name that matches no key, or several, is read as
a temporary or report parameter in `where` and `get`, and is an error in
`sub`, `rename`, `group by` and `over`. The keys `r`, `c` and `s` hold
coordinate codes as text (`r = "0010"`, not `r = 10`); open keys hold the
values the provider reports, such as text, dates and items.
### `where` {#where}
Keeps the records whose keys satisfy a condition. The condition is
evaluated for each record, with the record's key components as names, and
must yield one Boolean; records where it is false or null are dropped. It
tests keys, not values: to test values use [`filter`](#filter). The result
keeps the operand's `default`, which stands in for the keys other operands
bring ([Defaults fill in missing records](../concepts/records-and-keys.md#defaults-fill-in-missing-records)).
A key-less operand is the error `where needs a keyed operand`. Parses to
`Where`.
| Expression | Result |
| --- | --- |
| `{tT, c0010}[where r = "0010"]` | `r=0010: 100` |
| `{tT, c0010}[where r in {"0010", "0020"}]` | `r=0010: 100; r=0020: 60` |
| `{tO, c0010}[where qDAT > #2026-05-01#]` | `qKEY=b, qDAT=2026-06-30: 20; qKEY=c, qDAT=2026-09-30: 30` |
| `{tO, c0010}[where KEY <> "a" and qDAT < #2026-09-01#]` | `qKEY=b, qDAT=2026-06-30: 20` |
| `{tO, c0010}[where qDAT<-1]` | `error: cannot compare date with number using '<'` |
| `{tT, c0010}[where r = 10]` | `error: cannot compare string with number using '='` |
| `{tT, r0010, c0010}[where true]` | `error: where needs a keyed operand` |
| `{tO, c0010}[where {tO, c0010} > 10]` | `error: expected a single value, found records keyed by qKEY, qDAT` |
### `get` {#get}
Replaces each record's value by the value of one of its key components,
keeping the keys; records whose value is null are dropped, and the result
has no `default`. A name that is not a key reads the report parameter of
that name for every record. Parses to `Get`.
| Expression | Result |
| --- | --- |
| `{tO, c0010}[get qDAT]` | `qKEY=a, qDAT=2026-03-31: 2026-03-31; qKEY=b, qDAT=2026-06-30: 2026-06-30; qKEY=c, qDAT=2026-09-30: 2026-09-30` |
| `{tT, c0020}[get r]` | `r=0010: "0010"; r=0020: "0020"` |
| `{tT, c0010}[get foo]` | `error: unknown key component or parameter 'foo'` |
### `sub` {#sub}
Keeps the records whose key component equals a value, compared as with
`=`, and removes that key. The value is an additive expression (a literal,
a parameter, an item or arithmetic; parenthesize anything else) with one
value. The result keeps the operand's `default`, as for [`where`](#where).
Parses to `Sub`.
| Expression | Result |
| --- | --- |
| `{tO, c0010}[sub qKEY = "a"]` | `qDAT=2026-03-31: 10` |
| `{tO, c0010}[sub qKEY = "a"][sub qDAT = #2026-03-31#]` | `10` |
| `{tT, c0010}[sub r = "0010"]` | `100` |
| `{tO, c0010}[sub qKEY = "z"]` | `no records` |
| `{tO, c0010, default: 0}[sub qDAT = #2026-09-30#] + {tP, c0010}` | `qKEY=b: 2; qKEY=d: 4` |
| `{tO, c0010}[sub foo = 1]` | `error: unknown key component 'foo'` |
### `rename` {#rename}
Renames key components, so that operands with different key names join.
The old names resolve as [key names](#key-names); the new ones are taken as
written. Records, values and the `default` are unchanged. A new name that
is already a key component (also ignoring case and the `q` prefix), or two
pairs with the same new name, is the error `rename: key component …
already exists`; pairs apply in order, so keys cannot be swapped in one
clause. Parses to `Rename`.
| Expression | Result |
| --- | --- |
| `{tO, c0010}[rename qKEY to K, qDAT to D]` | `K=a, D=2026-03-31: 10; K=b, D=2026-06-30: 20; K=c, D=2026-09-30: 30` |
| `{tP, c0010}[rename qKEY to K] + {tO, c0010}[sub qDAT = #2026-06-30#][rename qKEY to K]` | `K=b: 22` |
| `{tO, c0010}[rename foo to K]` | `error: unknown key component 'foo'` |
| `{tO, c0010}[rename qKEY to DAT]` | `error: rename: key component qDAT already exists` |
| `{tO, c0010}[rename qKEY to X, qDAT to X]` | `error: rename: key component X already exists` |
## Functions {#functions}
```text
call ::= name "(" ( expression ( "," expression )* )? ")"
```
A word followed by `(` is a call; function names match in any case. Calls
parse to `Call` with the canonical name
(`canonical_function`), except
[analytic calls](#analytic-functions). Unless an entry says otherwise, a
function applies to each record, [joins](#records) keyed operands, and is
null when an operand is null.
- **Operand counts** are checked against
`SIGNATURES`: a wrong count is the error
`abs takes 1 operand, not 2`, and `group by` in a function that does not
aggregate is `group by is not allowed in abs`.
- **Unknown functions** parse, so that rules with newer functions can be
read, and evaluate as unsupported: `unknown function 'foo'`.
| Expression | Result |
| --- | --- |
| `ABS(-1)` | `1` |
| `abs(1, 2)` | `error: abs takes 1 operand, not 2` |
| `abs(1 group by r)` | `error: group by is not allowed in abs` |
| `foo(1)` | `unsupported: unknown function 'foo'` |
### Numeric functions {#numeric-functions}
#### `abs` {#abs}
`abs(x)`: the absolute value of a number, keeping its tolerance. Errors:
`abs needs numbers, found …`.
| Expression | Result |
| --- | --- |
| `abs(-3)` | `3` |
| `abs({tT, r*, c0020})` | `r=0010: 5; r=0020: 5; r=0030: null` |
| `abs({tT, r0020, c0020, interval: true})` | `5 ± 0.5` |
| `abs("x")` | `error: abs needs numbers, found string` |
#### `exp`, `ln`, `sqrt` {#exp}
`exp(x)`, `ln(x)` and `sqrt(x)`: the exponential, natural logarithm and
square root of a number. They are computed in binary floating point, to
about 16 significant digits, and the result is exact, without tolerance. A
result that is undefined or out of range, such as `ln(0)` or `sqrt(-1)`, is
null. Errors: `exp needs numbers, found …`.
| Expression | Result |
| --- | --- |
| `exp(0)` | `1` |
| `exp(1)` | `2.718281828459045` |
| `ln(1)` | `0` |
| `sqrt(16)` | `4` |
| `sqrt(2)` | `1.4142135623730951` |
| `ln(0)` | `null` |
| `sqrt(-1)` | `null` |
| `sqrt("a")` | `error: sqrt needs numbers, found string` |
#### `power` {#power}
`power(base, exponent)`: `base` raised to `exponent`, in binary floating
point like [`exp`](#exp); null when undefined or out of range.
| Expression | Result |
| --- | --- |
| `power(2, 10)` | `1024` |
| `power(2, 0.5)` | `1.4142135623730951` |
| `power(0, -1)` | `null` |
| `power(-8, 1 / 3)` | `null` |
#### `log` {#log}
`log(x, base)`: the logarithm of `x` to `base`, in binary floating point
like [`ln`](#exp); null outside its domain (`x` ≤ 0, `base` ≤ 0 or 1).
| Expression | Result |
| --- | --- |
| `log(8, 2)` | `3` |
| `log(100, 10)` | `2` |
| `log(8, 1)` | `null` |
| `log(-8, 2)` | `null` |
#### `max`, `min` {#max}
With two or more operands, `max(a, b, …)` and `min(a, b, …)` pick the
largest or smallest value per record, keeping its tolerance; null if any
operand is null. Numbers compare by value; dates, text and items, which
cannot be mixed with numbers, by their text (ISO dates in date order).
Booleans are an error. With one operand they are
[aggregates](#aggregate-max).
| Expression | Result |
| --- | --- |
| `max(1, 5, 3)` | `5` |
| `min({tT, c0010}, {tT, c0020})` | `r=0010: 5; r=0020: -5; r=0030: null` |
| `max({tT, c0010}, 50)` | `r=0010: 100; r=0020: 60; r=0030: 50` |
| `max(#2026-01-01#, #2026-02-01#)` | `2026-02-01` |
| `max(1, null)` | `null` |
| `max(1, "a")` | `error: max cannot compare numbers with dates, text or items` |
### Null functions {#null-functions}
#### `isnull` {#isnull}
`isnull(x)`: `true` where `x` is null, else `false`; never null. It applies
to each record, so it finds the unreported cells of a selection.
| Expression | Result |
| --- | --- |
| `isnull({tT, c0020})` | `r=0010: false; r=0020: false; r=0030: true` |
| `isnull(null)` | `true` |
| `isnull(0)` | `false` |
#### `nvl` {#nvl}
`nvl(x, replacement)`: `x`, or `replacement` where `x` is null. The
operands [join](#records), so `nvl` replaces null values but adds no
records; a `default` on `x` covers the records `x` lacks.
| Expression | Result |
| --- | --- |
| `nvl({tT, c0020}, 0)` | `r=0010: 5; r=0020: -5; r=0030: 0` |
| `nvl(null, "none")` | `"none"` |
| `nvl({tO, c0010}, {tP, c0010})` | `qKEY=b, qDAT=2026-06-30: 20` |
### Text functions {#text-functions}
#### `len`, `length` {#len}
`len(x)`, also spelled `length(x)`: the number of characters of a text.
Other values count the characters of their text form; a number counts as
written, without an exponent.
| Expression | Result |
| --- | --- |
| `len("abc")` | `3` |
| `length({tX, c0010})` | `5` |
| `len(12.50)` | `5` |
| `len(null)` | `null` |
#### `substr` {#substr}
`substr(text)`, `substr(text, start)` and `substr(text, start, length)`:
part of a text. `start` counts from 1 (default 1) and `length`, by default
the rest, is at least 0; both are whole numbers. Past the end the result is
`""`. Errors: `substr needs text, found …`, `substr start must be at least
1`, `substr length must not be negative`, `substr needs whole numbers`.
| Expression | Result |
| --- | --- |
| `substr("abcdef", 2, 3)` | `"bcd"` |
| `substr("abcdef", 3)` | `"cdef"` |
| `substr("abcdef", 10)` | `""` |
| `substr("abcdef", 0)` | `error: substr start must be at least 1` |
| `substr(123, 1)` | `error: substr needs text, found number` |
#### `match` {#match}
`match(x, pattern)`: `true` when the whole text of `x` matches the regular
expression `pattern`, in Python
[`re`](https://docs.python.org/3/library/re.html) syntax, as if anchored at
both ends. Numbers match their text. Errors: `match needs a text pattern`,
`invalid match pattern: …`.
| Expression | Result |
| --- | --- |
| `match({tX, c0010}, "LEI-[A-Z]")` | `true` |
| `match("xLEI-A", "LEI-[A-Z]")` | `false` |
| `match(2026, "[0-9]{4}")` | `true` |
| `match("a", "(")` | `error: invalid match pattern: missing ), unterminated subpattern at position 0` |
### Date functions {#date-functions}
#### `year`, `semester`, `quarter`, `month`, `week`, `day` {#date-parts}
The part of a date or date-time, as a number: `year`, `semester` (1–2),
`quarter` (1–4), `month` (1–12), `week` (the ISO 8601 week, 1–53) and
`day` of the month. Errors: `year needs a date, found …`; text is not read
as a date.
| Expression | Result |
| --- | --- |
| `year(#2026-05-31#)` | `2026` |
| `semester(#2026-07-01#)` | `2` |
| `quarter(#2026-05-31#)` | `2` |
| `month({tX, c0030})` | `6` |
| `week(#2026-01-01#)` | `1` |
| `day(#2026-05-31T10:00:00#)` | `31` |
| `year({tO, c0010}[get qDAT])` | `qKEY=a, qDAT=2026-03-31: 2026; qKEY=b, qDAT=2026-06-30: 2026; qKEY=c, qDAT=2026-09-30: 2026` |
| `year("2026-05-31")` | `error: year needs a date, found string` |
#### `date` {#date}
`date(year, month, day)`: the date of three whole numbers. Errors: `date
needs whole numbers`, `invalid date: …` for a day the month lacks.
| Expression | Result |
| --- | --- |
| `date(2026, 2, 28)` | `2026-02-28` |
| `date(2026, 12, 31) = #2026-12-31#` | `true` |
| `date(2026, 2, 30)` | `error: invalid date: 2026-02-30` |
### Time functions {#time-functions}
#### `time_shift` {#time_shift}
```text
"time_shift" "(" expression "," period "," expression ( "," name )? ")"
period ::= "A" | "S" | "Q" | "M" | "W" | "D"
```
`time_shift(x, period, count, key)` presents `x` as it was `count` periods
earlier (later when negative): years, semesters, quarters, months, weeks or
days. `period` is an upper-case code and `count` a whole number.
- When `key` is a date key component of `x`, its values move `count`
periods later within the report, so that each record joins with those of
the later date. Months move by calendar months, and a month's last day
stays the last day.
- Otherwise, without `key` or with a name such as `refPeriod` that is not a
key of `x`, `x` is evaluated on the report `count` periods earlier, from
the provider's optional `earlier_report(period, count)` method; without
that report the rule is unsupported.
Errors: `time_shift needs a period of A, S, Q, M, W or D`, `time_shift
needs a key name as its fourth operand`, `time_shift needs dates in key …`,
and `time_shift moves several … dates to …` when two records would merge.
See [Time](../concepts/time.md#time_shift) and
[Compare with earlier reports](../guides/time-shift.md).
| Expression | Result |
| --- | --- |
| `time_shift({tT, r0010, c0010}, A, 1)` | `50` |
| `{tT, r0010, c0010} - time_shift({tT, r0010, c0010}, A, 1, refPeriod)` | `50` |
| `time_shift({tO, c0010}, Q, 1, qDAT)` | `qKEY=a, qDAT=2026-06-30: 10; qKEY=b, qDAT=2026-09-30: 20; qKEY=c, qDAT=2026-12-31: 30` |
| `time_shift({tO, c0010}, S, 1, qDAT)` | `qKEY=a, qDAT=2026-09-30: 10; qKEY=b, qDAT=2026-12-31: 20; qKEY=c, qDAT=2027-03-31: 30` |
| `time_shift({tO, c0010}, W, 1, qDAT)` | `qKEY=a, qDAT=2026-04-07: 10; qKEY=b, qDAT=2026-07-07: 20; qKEY=c, qDAT=2026-10-07: 30` |
| `time_shift({tO, c0010}, D, -1, qDAT)` | `qKEY=a, qDAT=2026-03-30: 10; qKEY=b, qDAT=2026-06-29: 20; qKEY=c, qDAT=2026-09-29: 30` |
| `time_shift({tO, c0010}, M, -1, qDAT)` | `qKEY=a, qDAT=2026-02-28: 10; qKEY=b, qDAT=2026-05-31: 20; qKEY=c, qDAT=2026-08-31: 30` |
| `time_shift({tT, r0010, c0010}, Q, 1)` | `unsupported: time_shift needs the report 1 quarter(s) earlier, which the provider does not have` |
| `time_shift({tT, r0010, c0010}, a, 1)` | `error: time_shift needs a period of A, S, Q, M, W or D` |
| `time_shift({tO, c0010}, A, 1, qKEY)` | `error: time_shift needs dates in key qKEY, found string` |
#### `annualise` {#annualise}
`annualise(x, fiscalYearEnd, key)` scales a year-to-date amount to a year:
`x · 12 / months`, where `months` counts the months after the fiscal year's
last month (`fiscalYearEnd`, 1–12) up to and including the month of the
date in `key`. `key` is a date key component of `x`, else a date report
parameter. The tolerance scales with the amount. Errors: `annualise needs
a fiscal year end month of 1 to 12`, `annualise needs a date key or report
parameter …`, `annualise needs a key name as its third operand`.
| Expression | Result |
| --- | --- |
| `annualise({tO, c0010}, 12, qDAT)` | `qKEY=a, qDAT=2026-03-31: 40; qKEY=b, qDAT=2026-06-30: 40; qKEY=c, qDAT=2026-09-30: 40` |
| `annualise(30, 6, refPeriod)` | `60` |
| `annualise(30, 12, Level)` | `error: annualise needs a date key or report parameter 'Level'` |
### Set functions {#set-functions}
Each operand of a set function is a set, or any other operand, which stands
for the set of its non-null values. A single null operand, such as a
missing set parameter, makes the result null; `set_of` gives `{}` instead.
The result has no keys, and its members keep their first-seen order.
#### `set_of` {#set_of}
`set_of(x)`: the set of the non-null values of `x`.
| Expression | Result |
| --- | --- |
| `set_of({tU})` | `{1, 2, 3}` |
| `set_of({tT, c0020})` | `{5, -5}` |
| `set_of(null)` | `{}` |
#### `union`, `intersect` {#union}
`union(a, b, …)` has the members of any operand; `intersect(a, b, …)` the
members of the first operand that every other operand has. Both take two or
more operands.
| Expression | Result |
| --- | --- |
| `union({1}, {2}, {1, 3})` | `{1, 2, 3}` |
| `intersect({1, 2}, {2, 3}, {2})` | `{2}` |
| `union({tU}, {tP})` | `{1, 2, 3, 4}` |
| `union({1}, {p_Missing, set-number})` | `null` |
#### `setdiff`, `symdiff` {#setdiff}
`setdiff(a, b)` has the members of `a` that `b` lacks; `symdiff(a, b)` the
members of exactly one of the two.
| Expression | Result |
| --- | --- |
| `setdiff({1, 2}, {2, 3})` | `{1}` |
| `symdiff({1, 2}, {2, 3})` | `{1, 3}` |
| `setdiff({1, 2, 3, 4}, {tU})` | `{4}` |
### `filter` {#filter}
`filter(data, condition)` keeps the records of `data` whose `condition`
record is `true`; a false, null or non-Boolean condition drops the record.
The two operands [join](#records), so the condition is usually a comparison
on the same selection. Unlike [`where`](#where) it tests values, not keys.
The result keeps `data`'s `default`, which stands in for the keys other
operands bring.
| Expression | Result |
| --- | --- |
| `filter({tT, c0010}, {tT, c0010} > 50)` | `r=0010: 100; r=0020: 60` |
| `filter({tO, c0010}, {tO, c0020} = [CU:USD])` | `qKEY=b, qDAT=2026-06-30: 20` |
| `count(filter({tT, c0020}, {tT, c0020} > 0))` | `1` |
## Sets {#sets}
### Set literals {#set-literals}
```text
set ::= "{" "}" | "{" expression ( "," expression )* "}"
```
Braces whose first member is a literal, an item, a signed number or a
parenthesized number are a set; other braces are a
[selection](#selections). Each member is an expression with one value;
nulls and duplicates are dropped, and a set cannot contain a set. Sets
cannot be compared with `=` or the other comparisons and cannot be a rule's
result ([Sets](../concepts/sets.md)). Parses to
`SetLiteral`.
| Expression | Result |
| --- | --- |
| `{1, 2, 2}` | `{1, 2}` |
| `{(-5), 3}` | `{-5, 3}` |
| `{-5, 1 + 1}` | `{-5, 2}` |
| `{[eba_CU:EUR], [eba_CU:USD]}` | `{[CU:EUR], [CU:USD]}` |
| `{"a", #2026-01-01#, true, null}` | `{"a", 2026-01-01, true}` |
| `{}` | `{}` |
Sets are tested with [`in`](#in), built with the
[set functions](#set-functions) and read from
[set parameters](#typed-parameter).
## Aggregates {#aggregates}
```text
aggregate ::= ( "sum" | "count" | "avg" | "median" | "max" | "min" | "max_aggr" | "min_aggr" )
"(" expression ( "group" "by" name ( "," name )* )? ")"
```
An aggregate reduces an operand's records to one value, or to one value per
combination of the `group by` keys ([Key names](#key-names)), skipping null
values. Over no records an aggregate is null, except `count`, which is 0;
so is a group whose values are all null. The result has the `group by`
keys, or none, and no `default`: a key without records is no group
([Aggregates](../concepts/analytics.md#aggregates)).
- **Errors:** `group by 'qX' is not a key of the operand (r, c)`; `sum
needs numbers, found …`; `max with group by takes 1 operand, not 2`.
- **Node:** `Call` with `group_by`; with `over`, an
[analytic function](#analytic-functions).
| Expression | Result |
| --- | --- |
| `sum({tT} group by c)` | `c=0010: 200; c=0020: 0` |
| `count({tT} group by r, c)` | `r=0010, c=0010: 1; r=0010, c=0020: 1; r=0020, c=0010: 1; r=0020, c=0020: 1; r=0030, c=0010: 1; r=0030, c=0020: 0` |
| `sum({tO, c0010} group by KEY)` | `qKEY=a: 10; qKEY=b: 20; qKEY=c: 30` |
| `sum({tT} group by qX)` | `error: group by 'qX' is not a key of the operand (r, c)` |
### `sum` {#sum}
The sum of the numbers; their tolerances add up.
| Expression | Result |
| --- | --- |
| `sum({tT, c0010})` | `200` |
| `sum({tT, c0010, interval: true})` | `200 ± 1.5` |
| `sum({tT, r0030, c0020})` | `null` |
| `sum({tX, c0010})` | `error: sum needs numbers, found string` |
### `count` {#count}
The number of non-null values, of any type.
| Expression | Result |
| --- | --- |
| `count({tT})` | `5` |
| `count({tT, r0030, c0020})` | `0` |
| `count({tO} group by c)` | `c=0010: 3; c=0020: 3` |
### `avg` {#avg}
The mean of the numbers, with the sum's tolerance divided by the count.
| Expression | Result |
| --- | --- |
| `avg({tU})` | `2` |
| `avg({tT, c0010, interval: true})` | `66.66666666666666666666666667 ± 0.5` |
### `median` {#median}
The middle number, or the mean of the two middle numbers; exact, without
tolerance.
| Expression | Result |
| --- | --- |
| `median({tU})` | `2` |
| `median({tT, c0020})` | `0` |
### `max`, `min`, `max_aggr`, `min_aggr` {#aggregate-max}
With one operand, `max` and `min` are aggregates, also spelled `max_aggr`
and `min_aggr`, which take exactly one operand. They pick the largest or
smallest value, keeping its tolerance. Besides numbers they order dates,
text and items by their text (ISO dates in date order, items by category
and code), as presence checks expect; Booleans, and numbers mixed with
other values, are errors. With several operands `max` and `min` are the
[scalar functions](#max).
| Expression | Result |
| --- | --- |
| `max({tU})` | `3` |
| `min_aggr({tT, c0020})` | `-5` |
| `max_aggr({tO, c0020})` | `[CU:USD]` |
| `max({tO, c0010} group by qKEY)` | `qKEY=a: 10; qKEY=b: 20; qKEY=c: 30` |
| `max({tX})` | `error: max needs numbers, dates, text or items` |
| `max_aggr(1, 2)` | `syntax error: max_aggr takes one operand` |
## Analytic functions {#analytic-functions}
```text
analytic ::= function "(" expression "over" "(" partition? order? window? ")" ")"
function ::= "rank" | "sum" | "count" | "avg" | "median" | "max" | "min" | "max_aggr" | "min_aggr"
partition ::= "partition" "by" name ( "," name )*
order ::= "order" "by" name ( "asc" | "desc" )? ( "," name ( "asc" | "desc" )? )*
window ::= ( "data" "points" | "range" ) "between" bound "and" bound
bound ::= "unbounded" ( "preceding" | "following" )
| "current" "data" "point"
| digit+ period? ( "preceding" | "following" )
```
An analytic function keeps its operand's records: each gets the aggregate
of the records in its window, or its rank, within its partition.
`partition by` and `order by` name keys of the operand
([Key names](#key-names)); without `partition by` the whole operand is one
partition. Without a window the window is the whole partition, or with
`order by` every record up to the current one and its peers. `data points`
windows count records; `range` windows with an offset need a single number
or date order key, and `3 M preceding` is three calendar months earlier.
The result has no `default`. The window keywords are reserved only inside
`over (…)`
([Partitions, order and windows](../concepts/analytics.md#partitions-order-and-windows)).
- **Errors:** the syntax errors `rank needs an order by clause`, `abs takes
no over clause` and `a window offset is a whole number`; `sum over 'qX',
which is not a key of the operand (r, c)`; `a range window offset needs a
number or date order key`; `a range window with offsets needs one order
key`.
- **Node:** `Analytic` with a
`Window`.
| Expression | Result |
| --- | --- |
| `sum({tU} over ())` | `r=0010: 6; r=0020: 6; r=0030: 6` |
| `sum({tU} over (order by r))` | `r=0010: 1; r=0020: 3; r=0030: 6` |
| `sum({tT} over (partition by c))` | `r=0010, c=0010: 200; r=0020, c=0010: 200; r=0030, c=0010: 200; r=0010, c=0020: 0; r=0020, c=0020: 0; r=0030, c=0020: 0` |
| `avg({tU} over (order by r data points between 1 preceding and current data point))` | `r=0010: 1; r=0020: 1.5; r=0030: 2.5` |
| `sum({tU} over (order by r data points between current data point and unbounded following))` | `r=0010: 6; r=0020: 5; r=0030: 3` |
| `min_aggr({tU} over (order by r desc data points between unbounded preceding and 1 following))` | `r=0030: 2; r=0020: 1; r=0010: 1` |
| `sum({tO, c0010} over (order by qDAT range between 3 M preceding and current data point))` | `qKEY=a, qDAT=2026-03-31: 10; qKEY=b, qDAT=2026-06-30: 30; qKEY=c, qDAT=2026-09-30: 50` |
| `count({tO, c0010} over (order by qDAT asc range between current data point and 1 Q following))` | `qKEY=a, qDAT=2026-03-31: 2; qKEY=b, qDAT=2026-06-30: 2; qKEY=c, qDAT=2026-09-30: 1` |
| `sum({tU} over (order by r range between 1 preceding and current data point))` | `error: a range window offset needs a number or date order key` |
| `abs({tU} over ())` | `syntax error: abs takes no over clause` |
### `rank` {#rank}
`rank(x over (partition by … order by …))`: the position of each record in
its partition by the order keys, from 1; ties share the lowest rank and
leave gaps (1, 1, 3). Values do not matter, only keys. `rank` needs `over`
with `order by`; a window is ignored.
| Expression | Result |
| --- | --- |
| `rank({tU} over (order by r desc))` | `r=0030: 1; r=0020: 2; r=0010: 3` |
| `rank({tT} over (partition by c order by r desc))` | `r=0030, c=0010: 1; r=0020, c=0010: 2; r=0010, c=0010: 3; r=0030, c=0020: 1; r=0020, c=0020: 2; r=0010, c=0020: 3` |
| `filter({tU}, rank({tU} over (order by r)) <= 2)` | `r=0010: 1; r=0020: 2` |
| `rank({tU})` | `syntax error: rank needs an over clause` |
| `rank({tU} over (partition by r))` | `syntax error: rank needs an order by clause` |
## Where dpm-xl differs from the grammar {#where-dpm-xl-differs-from-the-grammar}
Some readings differ from the published DPM-XL grammar on purpose, so that
EBA rules keep their meaning:
- `not` binds looser than comparisons and `in`: `not a = b` is
`not (a = b)`.
- Chained comparisons such as `a < b < c` or `a in b in c`, `- not x`, and a
selection that repeats an axis, `default` or `interval` are syntax errors.
- Outside clauses `<-` is the persistent assignment, so compare with a
negated operand as `a < -b`.
- `{v_Code}` tests a filing indicator and `{vName}` reads a report
parameter: the `_` after `v` is not cosmetic, unlike after `t`, `g`, `o`
and `p`.
```dpm-xl
{tT, r0010} < {tT, r0020} < {tT, r0030}
1 in {1} in {true}
- not true
{tT, r0010, r0020}
```
In the other direction dpm-xl accepts keywords in any case, `null` and `<>`
anywhere, a last statement without `;`, `length` for `len`, codes without
leading zeros (`r310` is row `0310`), ranges that repeat the axis letter
(`r0440-r0480`), key names without the `q` prefix or in another case, item
codes without a category (`[ei86]`), and `max` and `min` over dates, text
and items.
Every distinct EBA rule and precondition expression of DPM releases 3.4–4.4
parses, except two source expressions with typos (`trueendif`, `ornot`),
which are reported as syntax errors.
## Known limitations {#limitations}
- No comments, and no escape sequences in [text](#strings).
- `with` only starts a statement, an assignment's value or another `with`
body ([`with`](#with)).
- `exp`, `ln`, `sqrt`, `power` and `log` compute in binary floating point
and drop the tolerance ([`exp`](#exp)).
- A coordinate a table lacks selects nothing rather than raising an error;
`diagnose` with table layouts warns about
it.
- `time_shift` across reports, `{o_…}` and `{g_…}` need optional provider
methods; without them a rule is unsupported
([Connect your data](../guides/provider.md)).
- The [columnar evaluator](../guides/columnar.md) implements a subset and
raises `ColumnarUnsupported` for the rest, such as scripts, `time_shift`,
analytic functions and most sets.
- Expressions nest at most 50 deep, and numbers stay within the decimal128
range ([Limits](#limits)).
## Index {#index}
Every word and symbol of `dpmxl.grammar`, with its entry.
The test suite checks that this index and the grammar module agree.
| Name | Kind | Entry |
| --- | --- | --- |
| `and` | keyword | [Logical operators](#logical) |
| `else` | keyword | [`if`](#if) |
| `endif` | keyword | [`if`](#if) |
| `get` | keyword | [`get`](#get) |
| `group by` | keyword | [Aggregates](#aggregates) |
| `if` | keyword | [`if`](#if) |
| `in` | keyword | [`in`, `not in`](#in) |
| `not` | keyword | [`not`](#not) |
| `or` | keyword | [Logical operators](#logical) |
| `over` | keyword | [Analytic functions](#analytic-functions) |
| `rename` | keyword | [`rename`](#rename) |
| `sub` | keyword | [`sub`](#sub) |
| `then` | keyword | [`if`](#if) |
| `to` | keyword | [`rename`](#rename) |
| `where` | keyword | [`where`](#where) |
| `with` | keyword | [`with`](#with) |
| `xor` | keyword | [Logical operators](#logical) |
| `asc` | window keyword | [Analytic functions](#analytic-functions) |
| `between` | window keyword | [Analytic functions](#analytic-functions) |
| `current data point` | window keyword | [Analytic functions](#analytic-functions) |
| `data points` | window keyword | [Analytic functions](#analytic-functions) |
| `desc` | window keyword | [Analytic functions](#analytic-functions) |
| `following` | window keyword | [Analytic functions](#analytic-functions) |
| `order by` | window keyword | [Analytic functions](#analytic-functions) |
| `partition by` | window keyword | [Analytic functions](#analytic-functions) |
| `preceding` | window keyword | [Analytic functions](#analytic-functions) |
| `range` | window keyword | [Analytic functions](#analytic-functions) |
| `unbounded` | window keyword | [Analytic functions](#analytic-functions) |
| `default` | selection option | [`default`](#default) |
| `interval` | selection option | [`interval`](#interval) |
| `false` | literal | [Booleans and null](#boolean-and-null) |
| `null` | literal | [Booleans and null](#boolean-and-null) |
| `true` | literal | [Booleans and null](#boolean-and-null) |
| `boolean` | parameter type | [Typed parameter](#typed-parameter) |
| `date` | parameter type | [Typed parameter](#typed-parameter) |
| `integer` | parameter type | [Typed parameter](#typed-parameter) |
| `item` | parameter type | [Typed parameter](#typed-parameter) |
| `number` | parameter type | [Typed parameter](#typed-parameter) |
| `set-boolean` | parameter type | [Typed parameter](#typed-parameter) |
| `set-date` | parameter type | [Typed parameter](#typed-parameter) |
| `set-integer` | parameter type | [Typed parameter](#typed-parameter) |
| `set-item` | parameter type | [Typed parameter](#typed-parameter) |
| `set-number` | parameter type | [Typed parameter](#typed-parameter) |
| `set-string` | parameter type | [Typed parameter](#typed-parameter) |
| `string` | parameter type | [Typed parameter](#typed-parameter) |
| `A` | period | [`time_shift`](#time_shift) |
| `S` | period | [`time_shift`](#time_shift) |
| `Q` | period | [`time_shift`](#time_shift) |
| `M` | period | [`time_shift`](#time_shift) |
| `W` | period | [`time_shift`](#time_shift) |
| `D` | period | [`time_shift`](#time_shift) |
| `abs` | function | [`abs`](#abs) |
| `annualise` | function | [`annualise`](#annualise) |
| `avg` | function | [`avg`](#avg) |
| `count` | function | [`count`](#count) |
| `date` | function | [`date`](#date) |
| `day` | function | [Date parts](#date-parts) |
| `exp` | function | [`exp`, `ln`, `sqrt`](#exp) |
| `filter` | function | [`filter`](#filter) |
| `intersect` | function | [`union`, `intersect`](#union) |
| `isnull` | function | [`isnull`](#isnull) |
| `len` | function | [`len`, `length`](#len) |
| `length` | function | [`len`, `length`](#len) |
| `ln` | function | [`exp`, `ln`, `sqrt`](#exp) |
| `log` | function | [`log`](#log) |
| `match` | function | [`match`](#match) |
| `max` | function | [`max`, `min`](#max) |
| `max_aggr` | function | [`max_aggr`, `min_aggr`](#aggregate-max) |
| `median` | function | [`median`](#median) |
| `min` | function | [`max`, `min`](#max) |
| `min_aggr` | function | [`max_aggr`, `min_aggr`](#aggregate-max) |
| `month` | function | [Date parts](#date-parts) |
| `nvl` | function | [`nvl`](#nvl) |
| `power` | function | [`power`](#power) |
| `quarter` | function | [Date parts](#date-parts) |
| `rank` | function | [`rank`](#rank) |
| `semester` | function | [Date parts](#date-parts) |
| `set_of` | function | [`set_of`](#set_of) |
| `setdiff` | function | [`setdiff`, `symdiff`](#setdiff) |
| `sqrt` | function | [`exp`, `ln`, `sqrt`](#exp) |
| `substr` | function | [`substr`](#substr) |
| `sum` | function | [`sum`](#sum) |
| `symdiff` | function | [`setdiff`, `symdiff`](#setdiff) |
| `time_shift` | function | [`time_shift`](#time_shift) |
| `union` | function | [`union`, `intersect`](#union) |
| `week` | function | [Date parts](#date-parts) |
| `year` | function | [Date parts](#date-parts) |
| `+` | operator | [Arithmetic, unary plus](#arithmetic) |
| `-` | operator | [Arithmetic, unary minus](#arithmetic) |
| `*` | operator | [Arithmetic](#arithmetic) |
| `/` | operator | [Arithmetic](#arithmetic) |
| `&` | operator | [Concatenation](#concatenation) |
| `=` | operator | [Comparison](#comparison) |
| `!=` | operator | [Comparison](#comparison) |
| `<>` | operator | [Comparison](#comparison) |
| `<` | operator | [Comparison](#comparison) |
| `<=` | operator | [Comparison](#comparison) |
| `>` | operator | [Comparison](#comparison) |
| `>=` | operator | [Comparison](#comparison) |
| `%` | operator | [Percentages](#percent) |
| `:=` | operator | [Temporary assignment](#temporary-assignment) |
| `<-` | operator | [Persistent assignment](#persistent-assignment) |
| `(` | punctuation | [Precedence (grouping), calls, axis lists](#precedence) |
| `)` | punctuation | [Precedence (grouping), calls, axis lists](#precedence) |
| `[` | punctuation | [Clauses, items](#clauses) |
| `]` | punctuation | [Clauses, items](#clauses) |
| `{` | punctuation | [Selections, references, sets](#selections) |
| `}` | punctuation | [Selections, references, sets](#selections) |
| `,` | punctuation | [Selections, calls, sets](#selections) |
| `:` | punctuation | [`with`, selection options](#with) |
| `;` | punctuation | [Programs and statements](#statements) |
| `t` | reference prefix | [Table](#table) |
| `g` | reference prefix | [Table group](#table-group) |
| `v` | reference prefix | [Filing indicator, report parameter](#filing-indicator) |
| `o` | reference prefix | [Operation](#operation) |
| `p` | reference prefix | [Typed parameter](#typed-parameter) |
| `r` | axis | [Rows, columns and sheets](#axes) |
| `c` | axis | [Rows, columns and sheets](#axes) |
| `s` | axis | [Rows, columns and sheets](#axes) |
# CLI
```text
dpm-xl check [--layouts DATA] [--strict] [--references] [--json] SOURCE
dpm-xl eval [--precondition EXPRESSION] [--json] RULES DATA
dpm-xl --version
dpm-xl [COMMAND] --help
```
`dpm-xl` is installed with the package; `python -m dpmxl.cli` runs the same.
## Rule sources
`SOURCE` and `RULES` are one of:
- the path of a `.json` file holding a list of rules
([format](data-formats.md#rule-files));
- the path of any other existing file: one expression per line, `#` starts a
comment line, blank lines are ignored;
- otherwise, the argument itself as one expression. An argument that reads
as a path (ending in `.json` or `.txt`, or holding a `/` without brackets,
operators or spaces) but names no file is an error (exit status 3).
Each expression and precondition is labelled in the output: `argument` for
an expression given directly, `:expression` and `:precondition`
for a JSON rule file, `line :expression` for a text file (`eval` labels
the rule itself: `argument` becomes `expression`, the others `` and
`line `).
## `dpm-xl check`
Checks every expression and precondition of a rule source with
`diagnose`: every syntax error, unknown
functions, calls with the wrong operands and selections without a table.
Expressions may be scripts with `;`, `:=` and `<-`. Every problem is
printed; the exit status is 1 when an expression does not parse, or with
`--strict` when any expression has an error. Warnings never change it.
| Option | Effect |
| --- | --- |
| `--layouts DATA` | Also check tables, coordinates, open-table keys and, when the tables give `data_types`, operand types against the `tables` of a [data file](data-formats.md#data-files), and names against its `parameters`. |
| `--strict` | Exit with status 1 for any error, not only for syntax errors. |
| `--references` | Also print every selection, with its `with` context applied. |
| `--json` | Print one JSON document instead of text. |
```console
$ dpm-xl check "with {tC_01.00}: {r0010} >= 0" --references
argument: {tC_01.00, r0010}
1/1 expressions parsed
$ dpm-xl check "{tC_01.00, r0010} >="
argument: error: expression ends where an operand is expected
{tC_01.00, r0010} >=
^
0/1 expressions parsed; 1 error
$ echo $?
1
$ dpm-xl check "{tC_01.00, r0011, c0010} > 0 or {tC_27.00, r0010, c0010} > 0" --layouts examples/data.json --strict
argument: error: row 0011 is not in table C_01.00; did you mean r0010?
{tC_01.00, r0011, c0010} > 0 or {tC_27.00, r0010, c0010} > 0
^~~~~
argument: warning: the rows of C_27.00 are open (key qCPI), so they have no codes such as r0010; select them with r* or leave them out
{tC_01.00, r0011, c0010} > 0 or {tC_27.00, r0010, c0010} > 0
^~~~~
1/1 expressions parsed; 1 error and 1 warning
$ echo $?
1
```
Problems go to standard error; the summary goes to standard output.
### JSON output
```console
$ dpm-xl check "with {tC_01.00}: {r0010} >= 0" --references --json
{
"schema_version": 1,
"parsed": 1,
"total": 1,
"errors": [],
"diagnostics": [],
"selections": [
{
"source": "argument",
"selection": "{tC_01.00, r0010}"
}
]
}
```
| Field | Type | Content |
| --- | --- | --- |
| `schema_version` | integer | `1`. Changes only with a new major release. |
| `parsed` | integer | Expressions and preconditions that parsed. |
| `total` | integer | Expressions and preconditions checked. |
| `errors` | list | One object per syntax error: `source` (the label), `message` (plain words), `position` and `end` (zero-based character offsets of the problem in that expression). |
| `diagnostics` | list | One object per problem, syntax errors included: `source` (the label) and the fields of a `Diagnostic`: `severity`, `code`, `message`, `start`, `end`, `subject` and `suggestion`. |
| `selections` | list | Only with `--references`: `source` and `selection` (normalized DPM-XL text) per selection. |
## `dpm-xl eval`
Evaluates every rule of a rule source against a JSON
[data file](data-formats.md#data-files) and explains each failing record.
| Option | Effect |
| --- | --- |
| `--precondition EXPRESSION` | Precondition for a single expression given as `RULES`, such as `{v_C_01.00}`. Rule files carry their own. |
| `--json` | Print one JSON document instead of text. |
```console
$ dpm-xl eval examples/rules.json examples/data.json
v0001: failed
records: 1 passed, 1 failed, 0 not evaluated
- c=0020: 1,204 should equal 1,211 (1,180 + 31): difference 7, at least 5.5 given the reported precision
v0002: failed
records: 1 passed, 1 failed, 0 not evaluated
- qCPI=LEI-B: -5 should exceed 0: difference 5
v0003: passed
records: 1 passed, 0 failed, 0 not evaluated
v0004: not_applicable
message: precondition not met
records: 0 passed, 0 failed, 0 not evaluated
4 rules: 2 failed, 1 passed, 1 not_applicable
```
For one expression the output starts with `status:`, and a failing record
without keys is shown as `scalar`:
```console
$ dpm-xl eval "{tC_01.00, r0010, c0010} / 0 > 1" examples/data.json
status: error
message: division by zero
records: 0 passed, 0 failed, 0 not evaluated
$ echo $?
3
```
### JSON output
```console
$ dpm-xl eval "{tC_27.00, c0010} > 0" examples/data.json --json
{
"schema_version": 1,
"results": [
{
"rule": "expression",
"expression": "{tC_27.00, c0010} > 0",
"status": "failed",
"message": null,
"counts": {
"passed": 1,
"failed": 1,
"null": 0
},
"failures": [
{
"key": {
"qCPI": "LEI-B"
},
"summary": "-5 should exceed 0: difference 5",
"cells": [
"{tC_27.00, c0010, qCPI=LEI-B}"
]
}
]
}
]
}
```
| Field | Type | Content |
| --- | --- | --- |
| `schema_version` | integer | `1`. Changes only with a new major release. |
| `results` | list | One object per rule, in source order. |
| `results[].rule` | string | The rule's label: its `code`, `line ` or `expression`. |
| `results[].expression` | string | The expression evaluated. |
| `results[].status` | string | `passed`, `failed`, `not_applicable`, `error` or `unsupported`. |
| `results[].message` | string or null | Why the status is `error`, `unsupported` or `not_applicable`. |
| `results[].counts` | object | `passed`, `failed` and `null` (not evaluated) record counts. |
| `results[].failures` | list | Per failing record: `key` (key name to value, as text), `summary` (the explanation sentence) and `cells` (each cell used, as DPM-XL text). |
Summaries and messages may be reworded in minor releases; field names,
statuses and keys may not.
## Exit statuses
| Status | `check` | `eval` |
| --- | --- | --- |
| 0 | Everything parsed; with `--strict`, nothing has an error. | Every rule passed or did not apply. |
| 1 | An expression or precondition does not parse; with `--strict`, one has an error. | A rule failed. |
| 2 | Usage error (unknown command or option). | Usage error. |
| 3 | The rule file cannot be read or is malformed. | A rule is `error` or `unsupported`, or the rule or data file cannot be read or is malformed. |
With several rules, `eval` exits with the highest status of any rule.
```console
$ dpm-xl eval "{tC_01.00, r0010, c0010} > 1" missing.json
error: [Errno 2] No such file or directory: 'missing.json'
$ echo $?
3
```
# Data formats
The CLI reads two JSON formats: rule files and data files. Python callers
normally build a provider instead
([Connect your data](../guides/provider.md)); the CLI's
`dpmxl.cli.load_memory_provider(document)` turns a data document into a
`MemoryProvider` if you want the same
format in Python. The `--json` output of the CLI is described in the
[CLI reference](cli.md#json-output).
## Rule files
A `.json` rule file is a list of objects, one per rule
([`examples/rules.json`](https://github.com/luukhopman/dpm-xl/blob/main/examples/rules.json)):
| Field | Type | Content |
| --- | --- | --- |
| `code` | string | The rule's label in the output. Defaults to the rule's 1-based position. |
| `expression` | string | The rule. A rule without one is skipped. |
| `precondition` | string, optional | An expression that must yield a true record for the rule to apply. |
`expression` and `precondition` may also be objects with an `expression`
field, as some rule exports nest them. A file that does not hold a list, or
a list entry that is not an object, is an error (exit status 3).
```json
[
{
"code": "v0001",
"expression": "with {tC_01.00, c*, default: 0, interval: true}: {r0010} = {r0020} + {r0030}",
"precondition": "{v_C_01.00}"
},
{"code": "v0002", "expression": "{tC_27.00, c0010} > 0"}
]
```
Any other file is a text rule file: one expression per line, `#` starts a
comment line, blank lines are ignored
([`examples/rules.txt`](https://github.com/luukhopman/dpm-xl/blob/main/examples/rules.txt)).
## Data files
A data file is one JSON object describing a report
([`examples/data.json`](https://github.com/luukhopman/dpm-xl/blob/main/examples/data.json)).
Every field is optional.
| Field | Type | Content |
| --- | --- | --- |
| `tables` | list | Table layouts: `code`; `rows`, `columns` and `sheets` (lists of codes); `open_keys` (key names of an open table); `template` (a template code whose filing indicator `{v_…}` this table answers); `open_axes` (which of `rows`, `columns` and `sheets` are open, identified by the open keys); `data_types` (cells' data types, each `row`, `column`, `sheet` and `type`: `number`, `integer`, `string`, `date`, `boolean` or `item`). The last two are optional and used only by [`dpm-xl check --layouts`](cli.md#dpm-xl-check). |
| `facts` | list | Reported values, below. |
| `reported` | list | Table or template codes with a positive filing indicator. Defaults to every table with a fact. |
| `parameters` | object | Report parameters by name, typed like fact values; a list is a set. |
| `table_groups` | object | Group code to a list of table codes, for `{g_…}`. |
| `operations` | object | Operation code to its expression, for `{o_…}`. |
| `earlier` | list | Other periods' reports for `time_shift`: `period` (`A`, `S`, `Q`, `M`, `W` or `D`), `count` (periods earlier, negative for later) and `data`, a nested data document. |
### Facts
| Field | Type | Content |
| --- | --- | --- |
| `table` | string | Table code (required). |
| `row`, `column`, `sheet` | string, optional | Closed coordinates; omit the ones the table does not have. |
| `keys` | object, optional | Open key components by name, for open tables. |
| `value` | any | The value; `null` for a nil fact. |
| `decimals` | integer or `"INF"`, optional | XBRL decimals of a number; `"INF"` or absent means exact. |
| `type` | string, optional | `number`, `item` or `date`, to read a string value as that type. |
Values are typed as follows: JSON numbers (and strings with `"type":
"number"`) become `Number` with the given `decimals`; strings starting with
`[` (or with `"type": "item"`) become items such as `[eba_CU:EUR]`; strings
with `"type": "date"` become dates; lists become sets; Booleans and other
strings stay as they are. Open key values are typed the same way, without
`type`.
### Example
This document uses every field; the Python below loads it the way
`dpm-xl eval` does and evaluates a rule against each part.
```python
from dpmxl import evaluate_rule
from dpmxl.cli import load_memory_provider
document = {
"tables": [
{"code": "C_01.00", "rows": ["0010", "0020"], "columns": ["0010"]},
{
"code": "C_27.00",
"columns": ["0010", "0020", "0030"],
"open_keys": ["qCPI"],
"template": "C_27",
"open_axes": ["rows"],
"data_types": [{"column": "0010", "type": "number"}],
},
],
"facts": [
{
"table": "C_01.00",
"row": "0010",
"column": "0010",
"value": 1204,
"decimals": 0,
},
{
"table": "C_01.00",
"row": "0020",
"column": "0010",
"value": "1180.5",
"decimals": "INF",
"type": "number",
},
{
"table": "C_27.00",
"column": "0010",
"keys": {"qCPI": "LEI-A"},
"value": 120,
"decimals": -3,
},
{
"table": "C_27.00",
"column": "0020",
"keys": {"qCPI": "LEI-A"},
"value": "[eba_CU:EUR]",
},
{
"table": "C_27.00",
"column": "0030",
"keys": {"qCPI": "LEI-A"},
"value": "2026-06-30",
"type": "date",
},
],
"reported": ["C_01.00", "C_27.00"],
"parameters": {
"refPeriod": "2026-12-31",
"Currencies": ["[eba_CU:EUR]", "[eba_CU:USD]"],
},
"table_groups": {"G_01": ["C_01.00"]},
"operations": {"v0001_a": "{tC_01.00, r0010, c0010} * 2"},
"earlier": [
{
"period": "Q",
"count": 1,
"data": {
"tables": [{"code": "C_01.00", "rows": ["0010"], "columns": ["0010"]}],
"facts": [
{
"table": "C_01.00",
"row": "0010",
"column": "0010",
"value": 1100,
"decimals": 0,
}
],
},
},
],
}
provider = load_memory_provider(document)
for expression in (
"{tC_01.00, r0010, c0010} > {tC_01.00, r0020, c0010}",
"{tC_27.00, c0020} in {p_Currencies, set-item}",
"{tC_27.00, c0030} < {p_refPeriod, date}",
"{v_C_27}",
"{g_G_01, r0010, c0010} > 0",
"{o_v0001_a} > 2000",
"{tC_01.00, r0010, c0010} >= time_shift({tC_01.00, r0010, c0010}, Q, 1)",
):
print(evaluate_rule(expression, provider).status, expression)
```
```text
passed {tC_01.00, r0010, c0010} > {tC_01.00, r0020, c0010}
passed {tC_27.00, c0020} in {p_Currencies, set-item}
passed {tC_27.00, c0030} < {p_refPeriod, date}
passed {v_C_27}
passed {g_G_01, r0010, c0010} > 0
passed {o_v0001_a} > 2000
passed {tC_01.00, r0010, c0010} >= time_shift({tC_01.00, r0010, c0010}, Q, 1)
```
A malformed document is reported as `error: …` with exit status 3: an
invalid value (a number that does not parse, `decimals` that is neither an
integer nor `INF` or is beyond the decimal128 range) names the fact by its
1-based position, as in `error: fact 1: invalid number 'x1'`; a missing
required field such as `table` gives `malformed data document`.
# Python API
The public API is every name in the `__all__` of the modules below. Names
starting with an underscore are internal. The most used names are also
importable from the `dpmxl` package itself.
| Module | Purpose |
| --- | --- |
| [`dpmxl.evaluator`](evaluator.md) | Evaluation, preconditions, per-record outcomes, caches; thread safety. |
| [`dpmxl.explain`](explain.md) | Fills a failing record's formula with operand values. |
| [`dpmxl.provider`](provider.md) | The `DataProvider` protocol and the in-memory `MemoryProvider`. |
| [`dpmxl.columnar`](columnar.md) | The same semantics on polars frames (`columnar` extra); the frame schema. |
| [`dpmxl.parser`](parser.md) | Tokenizer and recursive-descent parser. Nodes keep source spans. |
| [`dpmxl.ast`](ast.md) | Expression tree, selections, axis selectors and item literals. |
| [`dpmxl.grammar`](grammar.md) | Keywords, operators, function names and signatures; the TextMate grammar. |
| [`dpmxl.values`](values.md) | `Number` with interval tolerance, comparison and formatting. |
| [`dpmxl.recordset`](recordset.md) | Keyed record sets and the natural join used by every operator. |
| [`dpmxl.references`](references.md) | Static selections (with `with` context applied) and `{v_…}` templates. |
| [`dpmxl.diagnostics`](diagnostics.md) | Positioned problems for editors: syntax, calls, tables and coordinates. |
The command line (`dpmxl.cli`) is documented in the [CLI reference](../cli.md).
## The `dpmxl` package
```python
import dpmxl
print(dpmxl.__all__)
```
```text
['AxisSelector', 'CellQuery', 'DataPoint', 'DataProvider', 'Diagnostic', 'DpmXlSyntaxError', 'Evaluation', 'EvaluationError', 'Evaluator', 'Explanation', 'Item', 'MemoryProvider', 'Node', 'Number', 'Operand', 'Outcome', 'Recordset', 'Reference', 'RuleResult', 'SelectionCache', 'SelectionSpec', 'TableLayout', 'UnsupportedError', 'Value', 'ValueSet', '__version__', 'axis_matcher', 'axis_matches', 'compare', 'diagnose', 'evaluate_rule', 'explain', 'format_value', 'iter_points', 'normalize_code', 'parse', 'parse_cached', 'parse_program', 'parse_selection', 'references', 'reported_templates', 'tables', 'unique_points']
```
| Name | Defined in |
| --- | --- |
| `evaluate_rule`, `RuleResult`, `Outcome`, `Evaluation`, `Evaluator`, `SelectionCache`, `parse_cached` | `dpmxl.evaluator` |
| `explain`, `Explanation`, `Operand` | `dpmxl.explain` |
| `DataProvider`, `MemoryProvider`, `TableLayout`, `CellQuery`, `DataPoint`, `axis_matcher`, `axis_matches`, `normalize_code` | `dpmxl.provider` |
| `parse`, `parse_program`, `parse_selection`, `DpmXlSyntaxError` | `dpmxl.parser` |
| `Node`, `Item`, `SelectionSpec`, `AxisSelector` | `dpmxl.ast` |
| `Number`, `Value`, `ValueSet`, `compare`, `format_value` | `dpmxl.values` |
| `Recordset`, `EvaluationError`, `UnsupportedError`, `iter_points`, `unique_points` | `dpmxl.recordset` |
| `references`, `Reference`, `tables`, `reported_templates` | `dpmxl.references` |
| `diagnose`, `Diagnostic` | `dpmxl.diagnostics` |
`dpmxl.__version__` is the installed version.
# `dpmxl.evaluator`
Evaluation of rules and preconditions, per-record outcomes and the caches shared by the rules of one report. The module docstring below includes the thread-safety guarantees.
Evaluate DPM-XL expressions against a report's data provider.
`evaluate_rule` evaluates a rule against a
`DataProvider` and returns a
`RuleResult`; it never raises for a bad rule or
bad data.
The evaluator implements every construct of the DPM-XL grammar; constructs
that need data beyond one report (``time_shift``, operation and table-group
references) use optional provider methods. It keeps every intermediate
record set so that a failing record can be explained operand by operand (see
`dpmxl.explain`).
Semantics in brief:
* A selection yields one record per selected data point, keyed by varying
closed axes and open key components. Missing facts take the selection's
``default`` (``null`` when none is written); a written default also stands
in where another operand has a key the selection lacks.
* ``interval: true`` keeps each fact's decimals tolerance, so ``=`` holds when
the intervals overlap; otherwise comparisons are exact.
* Null propagates through arithmetic and comparisons; ``and``/``or`` use
three-valued (Kleene) logic. A null rule result is "not evaluated", never a
failure.
* Aggregates over no records are null, except ``count``, which is 0.
## Threads
`evaluate_rule` may run concurrently on one
report from several threads. `parse_cached`
is thread-safe. A shared `SelectionCache` is
safe too, though two threads that miss the same selection at once may both
build it. Providers shared across threads must be safe to read concurrently;
`MemoryProvider` is. An
`Evaluator` instance is not meant to be shared:
create one per evaluation.
Examples:
>>> from dpmxl import MemoryProvider, Number, TableLayout, evaluate_rule
>>> provider = MemoryProvider(
... [TableLayout("T", rows=("0010", "0020"), columns=("0010",))],
... {
... ("T", "0010", "0010", None, ()): Number.of(5, 0),
... ("T", "0020", "0010", None, ()): Number.of(-2, 0),
... },
... )
>>> result = evaluate_rule("{tT, r*, c0010} >= 0", provider)
>>> result.status, result.counts
('failed', {'passed': 1, 'failed': 1, 'null': 0})
>>> [outcome.key for outcome in result.failures]
[{'r': '0020'}]
## `dpmxl.evaluator.AGGREGATES`
```python
AGGREGATES = frozenset({'sum', 'count', 'avg', 'max', 'min', 'median'})
```
Canonical names of the aggregate functions (``Call.name`` values).
``rank`` and aggregates with ``over (...)`` are
`Analytic` nodes and are not listed.
## `dpmxl.evaluator.Evaluation`
```python
class Evaluation(root: Node, result: Recordset, trace: dict[int, Recordset] = dict(), notes: list[str] = list(), assignments: dict[str, Recordset] = dict(), shapes: dict[tuple[str, int], Any] = dict())
```
Raw evaluation result with intermediate record sets for explanation.
Pass it to `explain` (it is
`RuleResult.evaluation`).
Attributes:
root: The evaluated tree.
result: The record set of the last statement.
trace: Each node's record set by ``id(node)``; empty when evaluated
with ``trace=False``.
notes: Remarks gathered during evaluation.
### `dpmxl.evaluator.Evaluation.assignments`
```python
assignments: dict[str, Recordset] = field(default_factory=dict)
```
Results of persistent assignments (``{target} <- value``) by target,
written as normalized DPM-XL such as ``{tT, r0010, c0010}`` or ``{vX}``.
### `dpmxl.evaluator.Evaluation.shapes`
```python
shapes: dict[tuple[str, int], Any] = field(default_factory=dict, repr=False, compare=False)
```
What explanations derive from the tree alone (plain formulas, term
counts), by kind and node id; shared by every failure explained.
### `dpmxl.evaluator.Evaluation.value_at`
```python
def value_at(node: Node, binding: Mapping[str, Hashable]) -> list[Record]
```
Return the records a node produced for a key binding.
Returns:
list[Record]: Matching records; empty when the node was not
evaluated for that binding.
## `dpmxl.evaluator.Evaluator`
```python
class Evaluator(provider: DataProvider, *, trace: bool = True, cache: SelectionCache | None = None, only_keys: Mapping[str, Collection[str]] | None = None)
```
Evaluates one expression tree against one provider.
The engine behind `evaluate_rule`, which
most callers want instead: ``Evaluator.run`` raises
`EvaluationError` where
``evaluate_rule`` returns status ``error``. Create one per evaluation;
share a `SelectionCache` between them.
### `dpmxl.evaluator.Evaluator.run`
```python
def run(node: Node) -> Evaluation
```
Evaluate a complete expression or program.
Returns:
Evaluation: The result and its trace.
Raises:
EvaluationError: For a type error, an unknown table and the like;
`UnsupportedError` when the
provider cannot resolve a reference.
### `dpmxl.evaluator.Evaluator.evaluate`
```python
def evaluate(node: Node, context: SelectionSpec, bindings: Mapping[str, Value] | None) -> Recordset
```
Evaluate a node under a ``with`` context and optional key bindings.
Returns:
Recordset: The node's records.
## `dpmxl.evaluator.Outcome`
```python
class Outcome(key: Mapping[str, Hashable], passed: bool | None, raw_points: tuple[Any, ...] = ())
```
One evaluated record of a rule.
``points`` are flattened from the evaluation on first access: most
outcomes pass, and nobody reads their points.
Attributes:
key: The record key, such as ``{'c': '0020'}`` or ``{'qCPI': 'LEI-B'}``;
empty for a rule without keys. Pass it to
`explain`.
passed: ``True``, ``False``, or ``None`` when the record's result is
null (not evaluated).
raw_points: The points as the evaluation keeps them (possibly
nested); read ``points`` instead.
### `dpmxl.evaluator.Outcome.points`
```python
points: tuple[DataPoint, ...]
```
Distinct data points the record was computed from.
## `dpmxl.evaluator.RuleResult`
```python
class RuleResult(status: Status, outcomes: list[Outcome] = list(), message: str | None = None, evaluation: Evaluation | None = None, notes: list[str] = list(), assignments: dict[str, Recordset] = dict())
```
The outcome of evaluating a rule and its optional precondition.
Attributes:
status: ``passed``, ``failed``, ``not_applicable`` (precondition not
met, or no record could be evaluated), ``error`` (syntax error,
unknown table, type mismatch, division by zero that reaches the
result) or ``unsupported`` (a ``time_shift``, ``{o_...}`` or
``{g_...}`` the provider cannot resolve, or an unknown function).
outcomes: One `Outcome` per record.
message: Why the status is ``error``, ``unsupported`` or
``not_applicable``.
evaluation: The traced `Evaluation` for
`explain`; ``None`` when evaluation did not
complete.
notes: Remarks gathered during evaluation.
### `dpmxl.evaluator.RuleResult.assignments`
```python
assignments: dict[str, Recordset] = field(default_factory=dict)
```
Results of persistent assignments by target (see
`Evaluation.assignments`); the verdict
comes from the last statement.
### `dpmxl.evaluator.RuleResult.failures`
```python
failures: list[Outcome]
```
Records whose result is false.
### `dpmxl.evaluator.RuleResult.counts`
```python
counts: dict[str, int]
```
Counts of passed, failed and not-evaluated records.
## `dpmxl.evaluator.SelectionCache`
```python
class SelectionCache()
```
Selection results shared by every rule evaluated against one report.
Rules over the same report select the same cells again and again (every
sum rule on a table reads its columns); building each record set once
makes large open tables affordable. Record sets are never mutated by the
evaluator, so sharing them is safe. Use one cache per report. Rules may
share it across threads: a selection two threads miss at once is built
by both, and either result is kept.
### `dpmxl.evaluator.SelectionCache.earlier`
```python
earlier: dict[tuple[str, int], SelectionCache] = {}
```
Caches of earlier reports for ``time_shift``, by period and count.
## `dpmxl.evaluator.evaluate_rule`
```python
def evaluate_rule(expression: str | Node, provider: DataProvider, *, precondition: str | Node | None = None, trace: bool = True, cache: SelectionCache | None = None, only_keys: Mapping[str, Collection[str]] | None = None) -> RuleResult
```
Evaluate a validation rule, honouring its precondition.
Args:
expression: Rule expression text or a parsed tree.
provider: Report data.
precondition: Optional precondition; the rule is not applicable
unless it yields at least one true record.
trace: Keep intermediate values for `explain`.
cache: Selections shared by the rules of one report.
only_keys: Keep only records with these closed-axis key codes in
every selection (see `Evaluator`); the
precondition is evaluated on all records. Not meaningful with aggregates,
whose totals would cover only the kept records.
Returns:
RuleResult: Status, per-record outcomes and the evaluation trace.
Syntax errors are reported as status ``error``, not raised.
Examples:
>>> from dpmxl import MemoryProvider, Number, TableLayout, explain
>>> provider = MemoryProvider(
... [TableLayout("T", rows=("0010", "0020"), columns=("0010",))],
... {
... ("T", "0010", "0010", None, ()): Number.of(10, 0),
... ("T", "0020", "0010", None, ()): Number.of(12, 0),
... },
... )
>>> expression = "with {tT, c0010}: {r0010} >= {r0020}"
>>> result = evaluate_rule(expression, provider, precondition="{v_T}")
>>> result.status
'failed'
>>> explain(result.evaluation, expression, result.failures[0].key).summary
'10 should be at least 12: difference 2'
>>> evaluate_rule("{tT, r0010} >", provider).status
'error'
## `dpmxl.evaluator.parse_cached`
```python
def parse_cached(expression: str) -> Node
```
Parse an expression once and reuse the tree.
Trees are immutable and traces key nodes per evaluation, so one parsed
tree can serve every report a rule is evaluated against. The cache is
thread-safe and holds up to 32,768 expressions.
Returns:
Node: The expression tree.
Raises:
DpmXlSyntaxError: If the text is not a valid expression.
Examples:
>>> parse_cached("{tT, r0010} > 0") is parse_cached("{tT, r0010} > 0")
True
# `dpmxl.explain`
Explanations of failing records: a sentence, the filled formula, both sides with their intervals and the cells involved. See [Explain failures in a UI](../../guides/explain.md).
Explain a failing record in words and with the formula's values filled in.
For ``{r0010} = {r0020} + {r0030}`` failing at column ``0010``:
* ``summary``: ``1,204 should equal 1,197 (1,180 + 17): difference 7``;
* ``filled``: ``1,204 = 1,180 + 17``;
* ``left`` and ``right``: each side's formula, value, filled text and cells.
Formulas are rendered from the expression tree rather than copied from the
source, so spacing is uniform, negative terms read ``a - 226,000`` (with a
typographic minus) instead of ``a + -226,000``, and long runs of zero terms
collapse to ``+ 0 (x13)``.
Failures that are not comparisons (a value that must be reported, must be
empty, must be one of a set, or must match a pattern) get a sentence of their
own.
Explanation and error wording may improve in minor releases; field names and
``kind`` values may not.
## `dpmxl.explain.VERBS`
```python
VERBS = {'=': 'should equal', '!=': 'should differ from', '<': 'should be less than', '<=': 'should not exceed', '>': 'should exceed', '>=': 'should be at least'}
```
The verb of a failing comparison's summary, by comparison operator.
## `dpmxl.explain.Explanation`
```python
class Explanation(formula: str, filled: str, summary: str, operands: tuple[Operand, ...], difference: str | None = None, primary: tuple[DataPoint, ...] = (), kind: Kind = 'condition', relation: str | None = None, verb: str | None = None, left: Side | None = None, right: Side | None = None, gap: str | None = None, direction: Direction | None = None, tolerance: str | None = None)
```
Human-readable account of one evaluated record.
Fields whose value is formatted text (``difference``, ``gap``,
``tolerance``, the sides' values) are strings, ready for display.
Attributes:
formula: The rule body as rendered from the tree, such as
``{r0010} = {r0020} + {r0030}``.
filled: The formula with the record's values, such as
``1,204 = 1,180 + 17``.
summary: One sentence for a person, such as
``1,204 should equal 1,197 (1,180 + 17): difference 7``.
operands: Every leaf operand with its value and cells.
difference: For a comparison, the absolute difference of the sides.
kind: ``comparison``, ``missing`` (a value must be reported),
``not_empty`` (a value must be empty), ``membership`` (a value
must be one of a set), ``format`` (a value must match a pattern)
or ``condition`` (anything else).
relation: The failing comparison operator, such as ``=``.
verb: The summary's verb from `VERBS`.
left: The left side of a failing comparison.
right: The right side of a failing comparison.
tolerance: The sum of both sides' thresholds; ``None`` when exact.
### `dpmxl.explain.Explanation.primary`
```python
primary: tuple[DataPoint, ...] = ()
```
Cells on the left of the failing comparison: the values that disagree.
### `dpmxl.explain.Explanation.gap`
```python
gap: str | None = None
```
How far apart the two sides' intervals are: the smallest difference
that remains when every value may vary within its reported precision.
### `dpmxl.explain.Explanation.direction`
```python
direction: Direction | None = None
```
Which side is larger (``too_high``: left exceeds right), not a verdict
on which value is wrong.
## `dpmxl.explain.Operand`
```python
class Operand(source: str, value: str, points: tuple[DataPoint, ...])
```
One leaf operand of a failing record with its value and cells.
Attributes:
source: The operand's text, such as ``{r0020}``.
value: Its formatted value, such as ``1,180``.
points: The cells it read.
## `dpmxl.explain.Side`
```python
class Side(formula: str, filled: str, value: str, points: tuple[DataPoint, ...], terms: int, threshold: str | None = None, low: str | None = None, high: str | None = None)
```
One side of the failing comparison.
Attributes:
formula: The side's formula, such as ``{r0020} + {r0030}``.
filled: The formula with values, such as ``1,180 + 17``.
value: The side's formatted value.
points: The cells it read.
### `dpmxl.explain.Side.terms`
```python
terms: int
```
Number of operands (cells, aggregates, parameters) on this side.
### `dpmxl.explain.Side.threshold`
```python
threshold: str | None = None
```
Half-width of the side's interval from reported precision; None if exact.
### `dpmxl.explain.Side.single_cell`
```python
single_cell: bool
```
Whether the side is exactly one reported cell.
## `dpmxl.explain.explain`
```python
def explain(evaluation: Evaluation, expression: str, key: Mapping[str, Hashable]) -> Explanation
```
Explain the record of ``evaluation`` identified by ``key``.
Args:
evaluation: A traced evaluation, such as
`RuleResult.evaluation` from
`evaluate_rule` (with the default
``trace=True``).
expression: The source text the tree was parsed from.
key: The record key, as in `Outcome.key`.
Returns:
Explanation: Summary sentence, formula, filled formula and operands.
Examples:
>>> from dpmxl import MemoryProvider, Number, TableLayout, evaluate_rule
>>> layout = TableLayout("T", rows=("0010", "0020", "0030"), columns=("0010",))
>>> provider = MemoryProvider([layout], {
... ("T", "0010", "0010", None, ()): Number.of(1204, 0),
... ("T", "0020", "0010", None, ()): Number.of(1180, 0),
... ("T", "0030", "0010", None, ()): Number.of(17, 0),
... })
>>> expression = (
... "with {tT, c0010, interval: true}: {r0010} = {r0020} + {r0030}"
... )
>>> result = evaluate_rule(expression, provider)
>>> explanation = explain(result.evaluation, expression, result.failures[0].key)
>>> print(explanation.summary) # doctest: +NORMALIZE_WHITESPACE
1,204 should equal 1,197 (1,180 + 17): difference 7, at least 5.5 given the
reported precision
>>> explanation.filled, explanation.gap, explanation.direction
('1,204 = 1,180 + 17', '5.5', 'too_high')
>>> explanation.right.low, explanation.right.high
('1,196', '1,198')
>>> [point.label() for point in explanation.primary]
['{tT, r0010, c0010}']
# `dpmxl.provider`
The data contract between the evaluator and a report. See [Connect your data](../../guides/provider.md) for a walkthrough of every method, optional hooks included.
Data access contract between the evaluator and a report.
The evaluator never reads report files. It asks a
`DataProvider` for the data points a selection covers and
for report-level facts such as filing indicators. A provider is responsible for:
* resolving table codes and axis selectors against the table definitions;
* returning **every defined cell** of a closed selection, with ``value=None``
when the report has no fact for it, so ``default`` and ``isnull`` work;
* returning one data point per reported key combination for open axes, with
the open key components in `DataPoint.keys`.
`MemoryProvider` is a complete in-memory implementation
used by the tests and by tools that build reports programmatically.
## `dpmxl.provider.CELL_TYPES`
```python
CELL_TYPES = frozenset({'boolean', 'date', 'integer', 'item', 'number', 'string'})
```
Data types a `TableLayout` gives its cells.
## `dpmxl.provider.AxisMatcher`
```python
class AxisMatcher(codes: frozenset[str], numeric_ranges: tuple[tuple[int, int], ...], text_ranges: tuple[tuple[str, str], ...], wildcard: bool = False)
```
A compiled axis selector: exact codes plus inclusive ranges.
Build one with `axis_matcher` and call it
with a coordinate code.
Attributes:
codes: Normalized single codes.
numeric_ranges: Inclusive ranges with numeric bounds.
text_ranges: Inclusive ranges with text bounds, compared as text.
wildcard: Whether every coordinate is selected.
### `dpmxl.provider.AxisMatcher.single`
```python
single: str | None
```
The only selected code, when the selector names exactly one.
## `dpmxl.provider.CellQuery`
```python
class CellQuery(table: str, rows: AxisSelector | None = None, columns: AxisSelector | None = None, sheets: AxisSelector | None = None)
```
A fully resolved selection: table plus optional axis selectors.
The evaluator passes one to
`DataProvider.select` after applying
the ``with`` context. Match coordinates with
`axis_matcher`.
Attributes:
table: The table code, such as ``C_01.00``.
rows: The row selector; ``None`` when the selection names no rows,
which selects every row.
columns: The column selector, likewise.
sheets: The sheet selector, likewise.
## `dpmxl.provider.DataPoint`
```python
class DataPoint(table: str, row: str | None = None, column: str | None = None, sheet: str | None = None, keys: Mapping[str, Value] = dict(), value: Value = None, ref: Hashable | None = None)
```
One selected cell, and for open tables one reported key combination.
``ref`` is an opaque provider identity (for example a cell ID) that is
passed back in evaluation results so callers can highlight the cell.
Attributes:
table: The table code.
row: The row code, or ``None`` for a table without rows.
column: The column code, or ``None``.
sheet: The sheet code, or ``None``.
keys: Open key components by name (such as ``{"qCPI": "LEI-A"}``);
empty for closed tables.
value: The reported value, or ``None`` when nothing was reported.
ref: Any hashable identity of the cell; points read by ``time_shift``
from another report carry an
`EarlierRef`.
### `dpmxl.provider.DataPoint.coordinate`
```python
def coordinate(axis: str) -> str | None
```
Return the closed coordinate for ``r``, ``c`` or ``s``.
Returns:
str | None: The coordinate code, or ``None`` when absent.
Raises:
KeyError: For another axis.
### `dpmxl.provider.DataPoint.label`
```python
def label() -> str
```
Render a compact DPM-XL style location.
Returns:
str: For example ``{tC_01.00, r0010, c0010}``.
Examples:
>>> DataPoint("C_01.00", row="0010", column="0010").label()
'{tC_01.00, r0010, c0010}'
>>> DataPoint("C_27.00", column="0010", keys={"qCPI": "LEI-B"}).label()
'{tC_27.00, c0010, qCPI=LEI-B}'
## `dpmxl.provider.DataProvider`
```python
class DataProvider(Protocol)
```
Source of report data for one evaluation.
Three methods are required. ``select`` resolves a
`CellQuery` against the table definitions
(`axis_matcher` and
`normalize_code` implement range and
code matching) and returns:
* for a closed table, **every defined cell** the selection covers, with
``value=None`` where the report has no fact, so ``default`` and
``isnull`` work;
* for an open table, one point per reported key combination (times the
selected closed coordinates), with the key components in
`DataPoint.keys`;
and raises ``LookupError`` for a table the release does not define (the
rule's status is then ``error``). ``table_reported`` answers ``{v_TABLE}``
and ``parameter`` answers ``{vName}`` and ``{p_Name, type}``.
A provider may also define these optional methods:
* ``open_keys(table: str) -> Sequence[str]``: the open key names of a
table. Without it, an open table with no reported rows yields a
selection without keys; with it, the selection keeps the table's key
structure and is empty, as the specification requires.
* ``table_group(code: str) -> Sequence[str] | None``: the table codes of a
table group, for ``{g_Group, ...}`` selections.
* ``operation(code: str) -> str | None``: the expression of another
operation, for ``{o_Code, ...}`` references.
* ``earlier_report(period: str, count: int) -> DataProvider | None``: the
report ``count`` periods earlier (later when negative), where
``period`` is ``A``, ``S``, ``Q``, ``M``, ``W`` or ``D``, for
``time_shift``.
Without the optional method, or when it returns ``None``, rules that need
it are ``unsupported``. ``DataProvider`` is ``runtime_checkable``:
``isinstance`` checks the three required methods only.
A provider shared by several threads must be safe to read concurrently.
### `dpmxl.provider.DataProvider.select`
```python
def select(query: CellQuery) -> Sequence[DataPoint]
```
Return the data points covered by a selection.
Raises:
LookupError: If the table does not exist in the release.
### `dpmxl.provider.DataProvider.table_reported`
```python
def table_reported(table: str) -> bool | None
```
Return whether the report's filing indicators include ``table``.
``table`` is the code written in ``{v_...}``, usually a template code
such as ``C_01.00``. Returns ``None`` when the provider cannot tell,
which makes ``{v_...}`` null.
### `dpmxl.provider.DataProvider.parameter`
```python
def parameter(name: str) -> Value
```
Return a report-level parameter such as ``refPeriod``.
Returns ``None`` when the parameter is unknown. Typed parameters
(``{p_Name, type}``) convert the value: Python numbers become
`Number`, text becomes an item or date for
those types, and a list or set becomes a set.
## `dpmxl.provider.EarlierRef`
```python
class EarlierRef(period: str, count: int, ref: Hashable)
```
The ``ref`` of a data point that ``time_shift`` read from another report.
It wraps the earlier report's own ``ref`` with the shift, so the point
stays distinct from the current report's cell with the same ``ref``.
Attributes:
period: The ``time_shift`` period, such as ``Q``.
count: The number of periods.
ref: The earlier report's own ``ref`` for the cell.
## `dpmxl.provider.EarlierReport`
```python
class EarlierReport(provider: DataProvider, period: str, count: int)
```
The report ``time_shift`` reads, marking its points' refs.
Every data point it selects carries an `EarlierRef`
instead of the provider's ``ref`` (points without a ``ref`` keep ``None``); every
other method, optional ones included, is the wrapped provider's.
### `dpmxl.provider.EarlierReport.select`
```python
def select(query: CellQuery) -> Sequence[DataPoint]
```
Return the earlier report's points with marked refs.
Returns:
Sequence[DataPoint]: The points.
### `dpmxl.provider.EarlierReport.table_reported`
```python
def table_reported(table: str) -> bool | None
```
Return the earlier report's filing indicator.
Returns:
bool | None: Filing-indicator state.
### `dpmxl.provider.EarlierReport.parameter`
```python
def parameter(name: str) -> Value
```
Return an earlier report parameter.
Returns:
Value: The parameter or ``None``.
## `dpmxl.provider.MemoryProvider`
```python
class MemoryProvider(tables: Iterable[TableLayout], values: Mapping[CellKey, Value], *, reported: Iterable[str] | None = None, parameters: Mapping[str, Value] | None = None, table_groups: Mapping[str, Sequence[str]] | None = None, operations: Mapping[str, str] | None = None, earlier_reports: Mapping[tuple[str, int], DataProvider] | None = None)
```
Provider over in-memory table layouts and values.
Values are keyed by ``(table, row, column, sheet, keys)`` where ``keys`` is
a tuple of ``(name, value)`` pairs for open tables (empty for closed ones).
Absent tables in ``reported`` are treated as not reported. Every optional
`DataProvider` method is implemented. Each
point's ``ref`` is its value key.
A ``MemoryProvider`` is safe to read from several threads at once.
Examples:
>>> from dpmxl import CellQuery, Number
>>> closed = TableLayout("C_01.00", rows=("0010", "0020"), columns=("0010",))
>>> open_ = TableLayout("C_27.00", columns=("0010",), open_keys=("qCPI",))
>>> provider = MemoryProvider(
... [closed, open_],
... {
... ("C_01.00", "0010", "0010", None, ()): Number.of(100, 0),
... ("C_27.00", None, "0010", None, (("qCPI", "A"),)): Number.of(1, 0),
... },
... parameters={"refPeriod": "2026-12-31"},
... )
>>> (p.row, p.value) for p in provider.select(CellQuery("C_01.00"))
('0020', None)
>>> [p.keys for p in provider.select(CellQuery("C_27.00"))]
[{'qCPI': 'A'}]
>>> provider.table_reported("C_27.00"), provider.parameter("refPeriod")
(True, '2026-12-31')
### `dpmxl.provider.MemoryProvider.select`
```python
def select(query: CellQuery) -> Sequence[DataPoint]
```
Return closed cells, and one point per reported open key set.
Returns:
Sequence[DataPoint]: Selected data points.
Raises:
LookupError: If the table is unknown.
### `dpmxl.provider.MemoryProvider.open_keys`
```python
def open_keys(table: str) -> tuple[str, ...]
```
Return a table's open key names.
Returns:
tuple[str, ...]: Key names; empty for closed or unknown tables.
### `dpmxl.provider.MemoryProvider.table_reported`
```python
def table_reported(table: str) -> bool | None
```
Return whether a table or its template was reported.
Returns:
bool | None: Filing-indicator state.
### `dpmxl.provider.MemoryProvider.parameter`
```python
def parameter(name: str) -> Value
```
Return a report parameter.
Returns:
Value: The parameter or ``None``.
### `dpmxl.provider.MemoryProvider.table_group`
```python
def table_group(code: str) -> Sequence[str] | None
```
Return the table codes of a table group.
Returns:
Sequence[str] | None: The tables, or ``None`` for an unknown group.
### `dpmxl.provider.MemoryProvider.earlier_report`
```python
def earlier_report(period: str, count: int) -> DataProvider | None
```
Return the report ``count`` periods earlier.
Returns:
DataProvider | None: The report, or ``None`` when not given.
### `dpmxl.provider.MemoryProvider.operation`
```python
def operation(code: str) -> str | None
```
Return the expression of an operation.
Returns:
str | None: The expression, or ``None`` for an unknown operation.
## `dpmxl.provider.TableLayout`
```python
class TableLayout(code: str, rows: tuple[str, ...] = (), columns: tuple[str, ...] = (), sheets: tuple[str, ...] = (), open_keys: tuple[str, ...] = (), template: str | None = None, open_axes: tuple[AxisName, ...] = (), data_types: Mapping[tuple[str | None, str | None, str | None], str] = dict())
```
The coordinates, open keys and cell data types of one table.
`MemoryProvider` serves cells from
layouts, and `diagnose` checks selections
against them. An axis is closed (its ``rows``, ``columns`` or ``sheets``
codes), open (named in ``open_axes``: its rows, columns or sheets are
identified by the ``open_keys``, not by codes) or absent (neither).
When a table has been split into several, as when ``C_02.00`` became
``C_02.00.a`` and ``C_02.00.b``, rules may still select the original,
abstract code; describe it with the union of its parts' coordinates.
Attributes:
code: The table code, such as ``C_01.00``.
rows: Row codes; empty for a table without closed rows.
columns: Column codes; empty for a table without closed columns.
sheets: Sheet codes; empty for a table without closed sheets.
open_keys: Open key names, such as ``("qCPI",)``; empty for a closed
table.
template: The template code when it differs from ``code``, so
``{v_TEMPLATE}`` holds when any of its tables was reported.
open_axes: The open axes among ``rows``, ``columns`` and ``sheets``.
Optional: without it, an axis without codes of a table with open
keys is taken to be open or absent, and selections of it are not
checked. What a provider selects for a code on an open or absent
axis is up to it: a ``MemoryProvider`` ignores the selector, other
providers may select nothing. ``diagnose`` reports such a code as
a warning.
data_types: The data type of each cell by its ``(row, column,
sheet)``, with ``None`` for an open or absent axis: ``number``,
``integer``, ``string``, ``date``, ``boolean`` or ``item``.
Optional; `diagnose` uses it to
find operands of the wrong type. It may be partial: a selection
that covers a cell without a type has no known type, so it is
not checked. It takes no part in equality.
Raises:
ValueError: For an unknown axis in ``open_axes``, an open axis with
codes, or an unknown data type.
Examples:
>>> layout = TableLayout(
... "C_27.00",
... columns=("0010", "0020"),
... open_keys=("qCPI",),
... open_axes=("rows",),
... data_types={(None, "0010", None): "number"},
... )
>>> layout.open_axes, layout.data_types[None, "0010", None]
(('rows',), 'number')
### `dpmxl.provider.TableLayout.coordinates`
```python
def coordinates() -> Iterable[tuple[str | None, str | None, str | None]]
```
Yield every closed (row, column, sheet) combination.
Yields:
tuple[str | None, str | None, str | None]: One cell position.
## `dpmxl.provider.axis_matcher`
```python
def axis_matcher(selector: AxisSelector | None) -> AxisMatcher
```
Compile a selector once for repeated matching.
Returns:
AxisMatcher: A callable matcher; ``None`` and wildcards select all.
Examples:
>>> from dpmxl import AxisSelector
>>> rows = axis_matcher(AxisSelector(parts=(("0010", "0050"), "0100")))
>>> rows("0030"), rows("100"), rows("0060"), rows(None)
(True, True, False, False)
## `dpmxl.provider.axis_matches`
```python
def axis_matches(code: str | None, selector: AxisSelector | None) -> bool
```
Return whether a coordinate is selected.
``None`` selectors and wildcards select every coordinate. Ranges are
inclusive and compare numerically when both bounds are numeric.
Returns:
bool: Whether ``code`` is covered by ``selector``.
Examples:
>>> from dpmxl import AxisSelector
>>> axis_matches("0020", AxisSelector(parts=("0010", "0020")))
True
>>> axis_matches("0020", None)
True
## `dpmxl.provider.normalize_code`
```python
def normalize_code(code: str) -> str
```
Normalize an axis code for comparison.
Numeric codes are compared as numbers padded to four digits, so ``r310``
in an older rule matches row ``0310``. Results are memoized: a report's
rules normalize the same few thousand codes millions of times.
Returns:
str: A canonical code.
Examples:
>>> normalize_code("310"), normalize_code("0310"), normalize_code("x01")
('0310', '0310', 'X01')
# `dpmxl.columnar`
The columnar evaluator (the `columnar` extra). The module docstring below defines the frame schema a `FrameProvider` returns and the thread-safety guarantees. See [Evaluate large reports](../../guides/columnar.md).
Vectorized DPM-XL evaluation on polars data frames.
Needs the ``columnar`` extra (``pip install "dpm-xl[columnar]"``). The
record-by-record evaluator (`dpmxl.evaluator`) is the
reference implementation; this module evaluates the same language with the
same semantics on columnar data, so open tables with millions of rows evaluate
in milliseconds per rule.
Anything this module does not implement raises
`ColumnarUnsupported`, and callers of
`evaluate_rule_columnar` then fall
back to the reference evaluator, so coverage never trades off correctness.
That covers statements and assignments, set operators such as ``union``,
analytic functions, the ``&`` operator, functions other than the aggregates,
``filter``, ``abs``, ``isnull``, ``nvl``, ``match``, ``len`` and
``min``/``max`` (so ``time_shift``, ``annualise``, ``substr``, the
mathematical and the date functions), typed parameter, operation and
table-group references, values that do not fit
``Decimal(38, 12)``, arithmetic that would overflow it, any record error (a
zero divisor or a type error, which the reference evaluator confines to its
record), ordering items and text that may be an item, and a ``default``
standing in for a record another operand has.
Values are ``Decimal(38, 12)``: sums and differences are exact, products and
quotients round to 12 decimals (thresholds of quotients round up), where the
reference evaluator keeps 34 significant digits, so an exact comparison that
hinges on the 13th decimal can differ.
## Frame schema
A `FrameProvider` returns one row per selected
data point (closed cells times reported open key combinations) with these
columns:
| Column | Type | Content |
| --- | --- | --- |
| ``cell`` (``CELL``) | any | Opaque cell reference, returned in outcomes |
| ``r``, ``c``, ``s`` | text or null | Raw closed coordinates |
| ``k:`` | text, may be categorical | One per open key (see below) |
| ``num`` (``NUM``) | ``DEC`` = ``Decimal(38, 12)`` | Numeric value |
| ``thr`` (``THR``) | ``DEC`` | Threshold, ``0.5·10^-decimals`` (0 if exact) |
| ``txt`` (``TXT``) | text | Text, canonical item or ISO date value |
| ``flag`` (``FLAG``) | Boolean | Boolean value |
Open key columns hold text, canonical items (``category:code``, see
`canonical_item`) or ISO dates.
`VALUE_COLUMNS` is ``(num, thr, txt, flag)``;
all four are null for a cell with no fact, and
`value_columns` maps one Python value to them.
A cell and open-key combination appears at most once, and a cell's
coordinates are the same on every row. String columns may be categorical,
which keeps large selections compact.
`PointsFrameProvider` builds such frames
from any `DataProvider`.
Inside the evaluator a record set is a `Frame`: key
columns ``k:`` (``k:r``, ``k:c``, ``k:s`` for varying closed axes, open
keys such as ``k:qEBB``) as canonical strings, plus the same value columns.
Operators natural-join their operands on shared keys exactly like
`combine`. Where an operand with a ``default``
lacks a record another operand has, or an ``if`` condition with a default
lacks a key a branch has, which the reference evaluator fills in with the
default, this module raises
`ColumnarUnsupported`.
## Threads
`evaluate_rule_columnar` may run
concurrently on one report from several threads (polars releases the GIL).
`FrameCache` is thread-safe and builds each
selection once while other threads asking for it wait for that build. A
provider shared across threads must be safe to read concurrently;
`PointsFrameProvider` over a
`MemoryProvider` is.
Examples:
>>> from dpmxl import MemoryProvider, Number, TableLayout
>>> from dpmxl.columnar import PointsFrameProvider, evaluate_rule_columnar
>>> layout = TableLayout("T", rows=("0010", "0020"), columns=("0010",))
>>> provider = MemoryProvider([layout], {
... ("T", "0010", "0010", None, ()): Number.of(5, 0),
... ("T", "0020", "0010", None, ()): Number.of(-2, 0),
... })
>>> result = evaluate_rule_columnar(
... "{tT, r*, c0010} >= 0", PointsFrameProvider(provider)
... )
>>> result.status, result.passed, result.failed, result.failing_keys
('failed', 1, 1, [{'r': '0020'}])
## `dpmxl.columnar.CELL`
```python
CELL = 'cell'
```
Name of the opaque cell-reference column.
## `dpmxl.columnar.DEC`
```python
DEC = pl.Decimal(38, 12)
```
The polars type of the ``num`` and ``thr`` columns: ``Decimal(38, 12)``.
## `dpmxl.columnar.FLAG`
```python
FLAG = 'flag'
```
Name of the Boolean value column.
## `dpmxl.columnar.NUM`
```python
NUM = 'num'
```
Name of the numeric value column.
## `dpmxl.columnar.THR`
```python
THR = 'thr'
```
Name of the column holding the interval threshold of ``num``.
## `dpmxl.columnar.TXT`
```python
TXT = 'txt'
```
Name of the text column: text, canonical items and ISO dates.
## `dpmxl.columnar.VALUE_COLUMNS`
```python
VALUE_COLUMNS = (NUM, THR, TXT, FLAG)
```
The four value columns, ``("num", "thr", "txt", "flag")``; all null for a
cell with no fact.
## `dpmxl.columnar.ColumnarEvaluator`
```python
class ColumnarEvaluator(provider: FrameProvider, cache: FrameCache | None = None)
```
Evaluate one expression tree on frames.
Most callers want
`evaluate_rule_columnar`, which
also applies the precondition and turns the result into counts.
``evaluate`` raises `ColumnarUnsupported`
for anything it does not implement. Use one evaluator per expression; the
`FrameCache` is what rules share.
### `dpmxl.columnar.ColumnarEvaluator.evaluate`
```python
def evaluate(node: Node, context: SelectionSpec | None = None) -> Frame
```
Evaluate a node under an inherited ``with`` context.
Returns:
Frame: The node's records.
Raises:
ColumnarUnsupported: For constructs the reference engine must run.
UnsupportedError: For constructs no engine supports.
EvaluationError: For invalid expressions or data.
## `dpmxl.columnar.ColumnarResult`
```python
class ColumnarResult(status: Status, message: str | None = None, passed: int = 0, failed: int = 0, null: int = 0, failing_keys: list[dict[str, str | None]] | None = None, failing_frame: pl.DataFrame | None = None)
```
Outcome of one rule evaluated on frames.
Unlike `RuleResult` it carries no
evaluation trace: to explain a failure, evaluate the rule with
`evaluate_rule` (``only_keys`` limits it
to the failing keys) and call `explain`.
Attributes:
status: ``passed``, ``failed``, ``not_applicable``, ``error`` or
``unsupported``, as for `RuleResult`.
message: Why the status is ``error``, ``unsupported`` or
``not_applicable``.
passed: Number of records that hold.
failed: Number of records that fail.
null: Number of records whose result is null (not evaluated).
### `dpmxl.columnar.ColumnarResult.failing_keys`
```python
failing_keys: list[dict[str, str | None]] | None = None
```
Keys of the first failing records, up to ``max_failing_keys``.
### `dpmxl.columnar.ColumnarResult.failing_frame`
```python
failing_frame: pl.DataFrame | None = None
```
Keys of every failing record, one string column per key name; ``None``
when the result has no keys.
## `dpmxl.columnar.ColumnarUnsupported`
```python
class ColumnarUnsupported(Exception)
```
The expression needs the reference evaluator.
Raised by `evaluate_rule_columnar`
and the frame helpers for anything this module does not evaluate exactly;
catch it and call `evaluate_rule`
instead.
## `dpmxl.columnar.Frame`
```python
class Frame(keys: tuple[str, ...], df: pl.DataFrame, has_default: bool = False)
```
A record set: key names plus a frame of key and value columns.
Attributes:
keys: Key names, such as ``("c",)`` or ``("qCPI",)``.
df: One row per record: a ``k:`` column per key and the value
columns ``num``, ``thr``, ``txt`` and ``flag``.
has_default: Whether the records come from a selection with a
``default`` through per-record operations, so that the reference
evaluator fills in a default for keys they lack (see
`Recordset`).
### `dpmxl.columnar.Frame.kinds`
```python
kinds: frozenset[str]
```
Value columns holding at least one non-null value (one scan).
### `dpmxl.columnar.Frame.key_columns`
```python
key_columns: list[str]
```
Frame column names of the keys.
## `dpmxl.columnar.FrameCache`
```python
class FrameCache(max_rows: int = 20000000)
```
Selection frames shared by every rule evaluated against one report.
Rules reuse the same selections, but on massive open tables a selection
can hold millions of rows, so the cache keeps at most ``max_rows`` rows
and evicts the least recently used selections beyond that.
Rules may be evaluated concurrently from several threads (polars
releases the GIL): the cache is thread-safe, and
`raw_frame` and
`selection` build each missing
entry once while other threads asking for it wait for that build.
### `dpmxl.columnar.FrameCache.get_raw`
```python
def get_raw(query: CellQuery) -> pl.DataFrame | None
```
Return a cached provider frame.
Returns:
pl.DataFrame | None: The frame, or ``None``.
### `dpmxl.columnar.FrameCache.get_frame`
```python
def get_frame(spec: SelectionSpec) -> Frame | None
```
Return a cached selection record set.
Returns:
Frame | None: The frame, or ``None``.
### `dpmxl.columnar.FrameCache.put_raw`
```python
def put_raw(query: CellQuery, frame: pl.DataFrame) -> None
```
Cache a provider frame within the row budget.
### `dpmxl.columnar.FrameCache.put_frame`
```python
def put_frame(spec: SelectionSpec, frame: Frame) -> None
```
Cache a selection record set within the row budget.
### `dpmxl.columnar.FrameCache.raw_frame`
```python
def raw_frame(query: CellQuery, build: Callable[[], pl.DataFrame]) -> pl.DataFrame
```
Return a provider frame, building and caching it once.
Returns:
pl.DataFrame: The cached or newly built frame.
### `dpmxl.columnar.FrameCache.selection`
```python
def selection(spec: SelectionSpec, build: Callable[[], Frame]) -> Frame
```
Return a selection record set, building and caching it once.
Returns:
Frame: The cached or newly built record set.
## `dpmxl.columnar.FrameProvider`
```python
class FrameProvider(Protocol)
```
Columnar report data for the columnar evaluator.
The columnar counterpart of `DataProvider`.
``select_frame`` returns one row per selected data point (closed cells
times reported open key combinations) with columns ``cell`` (an opaque
cell reference), ``r``, ``c``, ``s`` (raw coordinates or null),
``k:`` for each open key (text, items or ISO dates), and the value
columns ``num``, ``thr``, ``txt``, ``flag`` (all null when the cell was
not reported). A cell and open key combination appears at most once, and
a cell's coordinates are the same on every row. String columns may be
categorical, which keeps large selections compact. See the frame schema
in the `dpmxl.columnar` module documentation.
### `dpmxl.columnar.FrameProvider.select_frame`
```python
def select_frame(query: CellQuery) -> pl.DataFrame
```
Return the selected data points as a frame.
Raises:
LookupError: If the table does not exist in the release.
### `dpmxl.columnar.FrameProvider.table_reported`
```python
def table_reported(table: str) -> bool | None
```
Answer ``{v_TABLE}``: whether the report's filing indicators include it.
Returns ``None`` when the provider cannot tell.
### `dpmxl.columnar.FrameProvider.parameter`
```python
def parameter(name: str) -> Value
```
Return a report parameter such as ``refPeriod``, or ``None``.
## `dpmxl.columnar.PointsFrameProvider`
```python
class PointsFrameProvider(provider: Any)
```
Adapt any ``DataProvider`` to ``FrameProvider``.
Each ``select_frame`` call converts the wrapped
`DataProvider`'s points with
`frame_from_points`; the other methods
delegate. It is as thread-safe as the wrapped provider.
Examples:
>>> from dpmxl import CellQuery, MemoryProvider, Number, TableLayout
>>> layout = TableLayout("T", columns=("0010",), open_keys=("qCPI",))
>>> provider = MemoryProvider(
... [layout], {("T", None, "0010", None, (("qCPI", "A"),)): Number.of(7, 0)}
... )
>>> frame = PointsFrameProvider(provider).select_frame(CellQuery("T"))
>>> frame.columns
['cell', 'r', 'c', 's', 'k:qCPI', 'num', 'thr', 'txt', 'flag']
>>> frame.select("k:qCPI", "num", "thr").row(0)
('A', Decimal('7.000000000000'), Decimal('0.500000000000'))
### `dpmxl.columnar.PointsFrameProvider.select_frame`
```python
def select_frame(query: CellQuery) -> pl.DataFrame
```
Return the provider's points as a frame.
Returns:
pl.DataFrame: The selection.
### `dpmxl.columnar.PointsFrameProvider.table_reported`
```python
def table_reported(table: str) -> bool | None
```
Delegate to the wrapped provider.
Returns:
bool | None: Filing-indicator state.
### `dpmxl.columnar.PointsFrameProvider.parameter`
```python
def parameter(name: str) -> Value
```
Delegate to the wrapped provider.
Returns:
Value: The parameter.
## `dpmxl.columnar.canonical_item`
```python
def canonical_item(item: Item) -> str
```
Return the canonical text of an item: ``category:code`` or ``code``.
Returns:
str: The canonical form used in ``txt`` and open key columns.
Examples:
>>> from dpmxl import Item
>>> canonical_item(Item.parse("eba_qEC:qx01"))
'qEC:qx01'
## `dpmxl.columnar.canonical_key`
```python
def canonical_key(key: Mapping[str, Any]) -> dict[str, str | None]
```
Canonicalize a reference outcome key for comparison with columnar keys.
Closed coordinates are normalized with
`normalize_code`; other key values become
the text the columnar evaluator uses (items as ``category:code``).
Returns:
dict[str, str | None]: Key name to canonical string.
Examples:
>>> canonical_key({"r": "10", "qCPI": "LEI-A"})
{'r': '0010', 'qCPI': 'LEI-A'}
## `dpmxl.columnar.evaluate_rule_columnar`
```python
def evaluate_rule_columnar(expression: str | Node, provider: FrameProvider, *, precondition: str | Node | None = None, cache: FrameCache | None = None, max_failing_keys: int = 1000) -> ColumnarResult
```
Evaluate a validation rule on frames, honouring its precondition.
The columnar counterpart of `evaluate_rule`:
the same statuses and counts, and failing keys in canonical form (see
`canonical_key`). Syntax and evaluation
errors are returned as status ``error``, not raised. It may run
concurrently from several threads with a shared ``cache``.
Args:
expression: Rule expression text or a parsed tree.
provider: Columnar report data.
precondition: Optional precondition; the rule is not applicable
unless it yields at least one true record.
cache: Selections shared by the rules of one report; a new cache per
call when omitted.
max_failing_keys: How many failing keys to list in
``failing_keys``; ``failing_frame`` has them all.
Returns:
ColumnarResult: Status, record counts and the keys of failing records.
Raises:
ColumnarUnsupported: When the reference evaluator must run instead.
Examples:
Fall back to the reference evaluator when needed:
>>> from dpmxl import MemoryProvider, Number, TableLayout, evaluate_rule
>>> provider = MemoryProvider(
... [TableLayout("T", rows=("0010",), columns=("0010",))],
... {("T", "0010", "0010", None, ()): Number.of(3, 0)},
... )
>>> def run(expression):
... try:
... return evaluate_rule_columnar(
... expression, PointsFrameProvider(provider)
... ).status
... except ColumnarUnsupported:
... return evaluate_rule(expression, provider).status
>>> run("{tT, r0010, c0010} > 0")
'passed'
>>> run("year(#2026-12-31#) = 2026") # dates: reference evaluator
'passed'
## `dpmxl.columnar.frame_from_points`
```python
def frame_from_points(points: Sequence[DataPoint], open_keys: Sequence[str] = ()) -> pl.DataFrame
```
Adapt data points to the frame layout of a ``FrameProvider``.
Builds the columns described by
`FrameProvider` from
`DataPoint` objects; ``cell`` holds
``repr(point.ref)``.
Args:
points: Selected data points.
open_keys: The table's open key names, kept even without points.
Returns:
pl.DataFrame: One row per point.
Raises:
ColumnarUnsupported: For numeric or Boolean key values, items
without a category, or numbers ``Decimal(38, 12)`` does not hold.
## `dpmxl.columnar.normalize_code_expr`
```python
def normalize_code_expr(column: pl.Expr) -> pl.Expr
```
Vectorized `normalize_code`.
Returns:
pl.Expr: Numeric codes zero-padded to four digits, others upper-case.
## `dpmxl.columnar.scalar_frame`
```python
def scalar_frame(value: Value) -> Frame
```
Build a key-less one-row frame holding one value.
Returns:
Frame: The scalar, with the columns of
`value_columns`.
## `dpmxl.columnar.selected_axes`
```python
def selected_axes(query: CellQuery, row: str | None, column: str | None, sheet: str | None) -> bool
```
Return whether a cell's coordinates are selected (for providers).
Returns:
bool: Whether every axis matches.
## `dpmxl.columnar.value_columns`
```python
def value_columns(value: Value) -> dict[str, Any]
```
Split a scalar value into the four value columns.
Numbers fill ``num`` and ``thr``; Booleans ``flag``; text, items
(canonical, see `canonical_item`) and
dates (ISO) ``txt``. ``None`` leaves all four null.
Returns:
dict[str, Any]: ``num``, ``thr``, ``txt`` and ``flag`` entries.
Examples:
>>> from dpmxl import Number
>>> value_columns(Number.of("1.5", 1))
{'num': Decimal('1.5'), 'thr': Decimal('0.05'), 'txt': None, 'flag': None}
>>> value_columns(True)
{'num': None, 'thr': None, 'txt': None, 'flag': True}
Raises:
ColumnarUnsupported: For a number or threshold that ``Decimal(38, 12)``
does not hold exactly.
# `dpmxl.parser`
Tokenizer and recursive-descent parser. Nodes keep their source spans. See [Language](../language.md) for what parses.
Tokenizer and recursive-descent parser for DPM-XL expressions.
`tokenize` splits an expression into typed
`Token` objects that suit syntax highlighting as well as parsing:
selections are split into their table, axis and option tokens rather than captured
whole. `parse` builds the expression tree of
`dpmxl.ast` from those tokens.
## `dpmxl.parser.DpmXlSyntaxError`
```python
class DpmXlSyntaxError(message: str, expression: str, position: int, end: int | None = None) # bases: ValueError
```
Raised when an expression does not match the DPM-XL grammar.
Attributes:
message: What was expected or found, in plain words.
expression: The complete source expression.
position: Zero-based offset where the offending text starts.
end: Offset just past the offending text.
Examples:
>>> try:
... parse("{tA} >=")
... except DpmXlSyntaxError as error:
... print(error.message, error.position)
expression ends where an operand is expected 7
### `dpmxl.parser.DpmXlSyntaxError.pointer`
```python
def pointer() -> str
```
Render the source line and mark the failing range.
Returns:
str: Two lines suitable for terminal output: the source line and a
``^`` under the error's start, followed by ``~`` under the rest of
the offending text on that line.
Examples:
>>> try:
... parse("{tA, r0010} +* 1")
... except DpmXlSyntaxError as error:
... print(error.pointer())
{tA, r0010} +* 1
^
## `dpmxl.parser.Token`
```python
class Token(NamedTuple)
```
One lexical token: its kind, source text and half-open offsets.
``text`` is exactly ``expression[start:end]``, quotes, brackets and
backticks included. ``kind`` is one of `TOKEN_KINDS`:
* ``keyword``: ``if``, ``and``, ``with``, ``where``, ``group by``, window
keywords inside ``over (...)``, and ``default``/``interval`` in a
selection;
* ``type``: a parameter type such as ``number`` or ``set-item``;
* ``function``: a known function name followed by ``(``;
* ``operator``: ``+ - * / & % = != <> < <= > >= := <-``;
* ``punctuation``: ``( ) , : ; [ ]``;
* ``brace``: ``{`` or ``}`` around a selection or set;
* ``table``, ``table_group``, ``variable``, ``operation``, ``parameter``:
a selection reference with its prefix, such as ``tC_01.00``;
* ``axis``: a row, column or sheet coordinate, range or wildcard, such as
``r0010``, ``c0010-0050`` or ``s*``;
* ``number``, ``string``, ``date``, ``boolean``, ``null``: literals;
* ``item``: an item such as ``[eba_qEC:qx01]``;
* ``period``: a ``time_shift`` or window period code (``A``, ``Q``, ...);
* ``identifier``: a key, temporary or other name, including escaped
names such as ```x.y```;
* ``eof``: the empty token that ends every token list.
Attributes:
kind: The token kind, as above.
text: The exact source text.
start: Offset of the first character.
end: Offset just past the last character.
## `dpmxl.parser.parse`
```python
def parse(expression: str) -> Node
```
Parse a complete DPM-XL expression.
A trailing ``;`` is allowed. Several statements and assignments need
`parse_program`.
Returns:
Node: The expression tree; every node keeps its source ``span``.
Raises:
DpmXlSyntaxError: If the text is not a valid expression.
Examples:
>>> node = parse("{tT, r0010} > 0")
>>> type(node).__name__, node.op, node.span
('Binary', '>', (0, 15))
>>> node.left.spec.text()
'{tT, r0010}'
## `dpmxl.parser.parse_program`
```python
def parse_program(source: str) -> Program
```
Parse statements separated by ``;``, with assignments.
A statement is an expression (with an optional ``with`` context), a
temporary assignment ``name := expression`` or a persistent assignment
``{target} <- expression``. A trailing ``;`` is allowed.
Returns:
Program: The statements, also when there is only one.
Raises:
DpmXlSyntaxError: If the text is not a valid program.
Examples:
>>> program = parse_program("x := {tT, r0010}; x > 0")
>>> [type(statement).__name__ for statement in program.statements]
['Assignment', 'Binary']
>>> program.statements[0].name
'x'
## `dpmxl.parser.parse_selection`
```python
def parse_selection(body: str, *, expression: str = '', offset: int = 0) -> SelectionSpec
```
Parse the inside of a ``{...}`` cell selection or ``with`` context.
Args:
body: Text between the braces.
expression: Complete source for error reporting.
offset: Offset of ``body`` inside ``expression``.
Returns:
SelectionSpec: Parsed table, axes and options.
Raises:
DpmXlSyntaxError: On an unknown selection component.
Examples:
>>> spec = parse_selection("tC_01.00, r0010-0050, c*")
>>> spec.table, spec.rows.parts, spec.columns.wildcard
('C_01.00', (('0010', '0050'),), True)
## `dpmxl.parser.tokenize`
```python
def tokenize(expression: str) -> list[Token]
```
Split an expression into typed tokens.
The tokenizer follows the lexical modes of the DPM-XL grammar: inside
``{...}`` it reads selection components (table, axis and option tokens,
parameter types), elsewhere expression tokens. Whitespace is skipped;
concatenating the token texts with the source between them gives the
expression back. See `Token` for the kinds.
Returns:
list[Token]: Tokens ending with an ``eof`` token.
Raises:
DpmXlSyntaxError: On an unterminated literal, brace or item, or an
unknown character.
Examples:
>>> [(token.kind, token.text) for token in tokenize("{tT, r0010} > 0")]
... # doctest: +NORMALIZE_WHITESPACE
[('brace', '{'), ('table', 'tT'), ('punctuation', ','), ('axis', 'r0010'),
('brace', '}'), ('operator', '>'), ('number', '0'), ('eof', '')]
# `dpmxl.ast`
The expression tree: frozen node classes with source `span`s. [Language](../language.md) names the node each construct parses to.
Abstract syntax tree for DPM-XL expressions.
Every node keeps the half-open source ``span`` it was parsed from, so callers
can show the original operand text next to an evaluated value without
re-printing the expression.
## `dpmxl.ast.AXIS_KEYS`
```python
AXIS_KEYS: dict[AxisName, str] = {'rows': 'r', 'columns': 'c', 'sheets': 's'}
```
``SelectionSpec`` axis field name to the record key it produces.
## `dpmxl.ast.AXIS_PREFIXES`
```python
AXIS_PREFIXES: dict[str, AxisName] = {'r': 'rows', 'c': 'columns', 's': 'sheets'}
```
Axis letter (``r``, ``c``, ``s``) to its ``SelectionSpec`` field name.
## `dpmxl.ast.MISSING`
```python
MISSING = _Missing()
```
Marker for a ``default`` that was not written; it is falsy and differs from
a written ``default: null``.
## `dpmxl.ast.Analytic`
```python
class Analytic(name: str, operand: Node, partition_by: tuple[str, ...] = (), order_by: tuple[tuple[str, bool], ...] = (), window: Window | None = None, *, span: Span = (0, 0)) # bases: Node
```
``name(operand over (partition by … order by … window))``.
``name`` is ``rank`` or an aggregate's call name (``sum``, ``count``,
``avg``, ``max``, ``min``, ``median``). ``order_by`` pairs each key with
whether it sorts descending. The result keeps the operand's records.
Attributes:
name: ``rank`` or the aggregate's call name.
operand: The aggregated expression.
partition_by: Partition key names.
order_by: ``(key, descending)`` pairs.
window: The window clause, or ``None`` when not written.
## `dpmxl.ast.Assignment`
```python
class Assignment(name: str, value: Node, *, span: Span = (0, 0)) # bases: Node
```
``name := value``: a temporary result later statements refer to.
Attributes:
name: The temporary's name.
value: The assigned expression.
## `dpmxl.ast.AxisName`
```python
AxisName: TypeAlias = Literal['rows', 'columns', 'sheets']
```
Name of a closed axis field of `SelectionSpec`.
## `dpmxl.ast.AxisSelector`
```python
class AxisSelector(wildcard: bool = False, parts: tuple[str | tuple[str, str], ...] = (), *, span: Span = (0, 0), spans: tuple[Span, ...] = ())
```
Selected coordinates on one table axis.
``parts`` holds single codes (``"0010"``) and inclusive ranges
(``("0010", "0050")``) as written, without the axis prefix. A range may
repeat the axis letter before its upper bound: ``r0010-r0050`` is
``("0010", "0050")``. The letter is dropped only when the lower bound
does not start with it, so a code that does is read as written:
``rr1-rr10`` is ``("r1", "rr10")``; write ``rr1-r10`` for ``("r1",
"r10")``.
A parsed selector keeps its source positions; like node spans they take
no part in equality, hashing or ``repr``, and selectors built directly
have none.
Attributes:
wildcard: Whether the selector is ``*`` (every coordinate).
parts: Single codes and ``(low, high)`` ranges.
span: Source span of the whole selector, such as ``r0010-0050`` or
``(r0010, r0020)``; ``(0, 0)`` when built directly.
spans: Source span of each part, in the order of ``parts``; empty for
a wildcard or a selector built directly.
Examples:
>>> from dpmxl import parse_selection
>>> rows = parse_selection("tT, (r0010, r0020-0050)").rows
>>> rows.parts, rows.span, rows.spans
(('0010', ('0020', '0050')), (4, 23), ((5, 10), (12, 22)))
### `dpmxl.ast.AxisSelector.is_single`
```python
is_single: bool
```
Whether the selector names exactly one coordinate.
### `dpmxl.ast.AxisSelector.text`
```python
def text(prefix: str) -> str
```
Render the selector in DPM-XL syntax.
Returns:
str: For example ``r*``, ``r0010`` or ``(r0010, r0020-0050)``.
## `dpmxl.ast.Binary`
```python
class Binary(op: str, left: Node, right: Node, *, span: Span = (0, 0)) # bases: Node
```
Arithmetic, comparison or boolean operator.
Attributes:
op: The operator as written canonically: ``+ - * / & = != < <= > >=``,
``and``, ``or`` or ``xor`` (``<>`` is read as ``!=``).
left: Left operand.
right: Right operand.
## `dpmxl.ast.Call`
```python
class Call(name: str, args: tuple[Node, ...], group_by: tuple[str, ...] | None = None, *, span: Span = (0, 0)) # bases: Node
```
Function or aggregate call, optionally with ``group by`` keys.
Attributes:
name: Canonical function name (see
`canonical_function`).
args: Arguments in order.
group_by: Key names of ``group by``, or ``None`` without it.
## `dpmxl.ast.Get`
```python
class Get(operand: Node, name: str, *, span: Span = (0, 0)) # bases: Node
```
``operand [get name]`` replaces values by a key component.
Attributes:
operand: The expression.
name: Key component whose value becomes the record's value.
## `dpmxl.ast.If`
```python
class If(condition: Node, then: Node, otherwise: Node | None, *, span: Span = (0, 0)) # bases: Node
```
``if c then a [else b] endif``.
Attributes:
condition: The condition.
then: Value when the condition is true.
otherwise: Value of the ``else`` branch, or ``None`` without one.
## `dpmxl.ast.In`
```python
class In(operand: Node, values: Node, *, span: Span = (0, 0)) # bases: Node
```
``operand in set``.
``values`` is a `SetLiteral` for ``in {...}``, otherwise any
set-valued expression such as a ``set-*`` parameter or ``union(...)``.
Attributes:
operand: The tested value.
values: The set.
## `dpmxl.ast.Item`
```python
class Item(category: str | None, code: str)
```
An enumerated item literal such as ``[eba_qEC:qx01]``.
``category`` is the namespace part without the ``eba_`` prefix; it is
``None`` for a bare legacy element reference such as ``[ei86]``.
Attributes:
category: Category without ``eba_``, such as ``qEC``, or ``None``.
code: Item code, such as ``qx01``.
### `dpmxl.ast.Item.parse`
```python
def parse(text: str) -> Item
```
Parse ``prefix:code`` or a bare code.
Returns:
Item: Normalized item identity; the ``eba_`` prefix is dropped.
Examples:
>>> Item.parse("eba_qEC:qx01")
Item(category='qEC', code='qx01')
>>> Item.parse("eba_qEC:qx01").matches(Item.parse("qx01"))
True
### `dpmxl.ast.Item.matches`
```python
def matches(other: Item) -> bool
```
Compare two items, ignoring a category missing on either side.
Returns:
bool: Whether both name the same item.
## `dpmxl.ast.Literal_`
```python
class Literal_(value: Decimal | str | bool | date | Item | None, kind: Literal['number', 'string', 'boolean', 'date', 'item', 'null'], *, span: Span = (0, 0)) # bases: Node
```
A constant: number, string, boolean, date, item or null.
Attributes:
value: The constant: ``Decimal``, ``str``, ``bool``, ``date`` (or
``datetime``), `Item` or ``None``.
kind: ``number``, ``string``, ``boolean``, ``date``, ``item`` or ``null``.
## `dpmxl.ast.Name`
```python
class Name(name: str, *, span: Span = (0, 0)) # bases: Node
```
A bare identifier: a key component, period unit or parameter name.
Attributes:
name: The identifier, without backticks.
## `dpmxl.ast.Node`
```python
class Node(*, span: Span = (0, 0))
```
Base class for expression nodes.
Every node has a public ``span``: the half-open ``(start, end)`` character
offsets of its source in the parsed expression, so ``expression[start:end]``
is the node's text. A parenthesized operand's span includes the
parentheses; a clause's span runs from its operand to the closing ``]``.
Spans take no part in equality or hashing. Nodes built directly default to
``(0, 0)``.
## `dpmxl.ast.Parameter`
```python
class Parameter(name: str, *, span: Span = (0, 0)) # bases: Node
```
``{vName}``: a report-level parameter such as ``ReportingLevel``.
Attributes:
name: Parameter name without the ``v`` prefix.
## `dpmxl.ast.PersistentAssignment`
```python
class PersistentAssignment(target: Node, value: Node, *, span: Span = (0, 0)) # bases: Node
```
``{target} <- value``: a computed result for a cell or variable.
``target`` is a `Selection` (``{tT, r0010, c0010}``) or a
`Parameter` (``{vX}``, also written ``{v_X}``).
Attributes:
target: The assigned cell or variable.
value: The computed expression.
## `dpmxl.ast.Program`
```python
class Program(statements: tuple[Node, ...], *, span: Span = (0, 0)) # bases: Node
```
Statements separated by ``;``; the last one gives the result.
`parse_program` returns one;
`parse` returns a single expression instead.
Attributes:
statements: The statements in order.
## `dpmxl.ast.Rename`
```python
class Rename(operand: Node, pairs: tuple[tuple[str, str], ...], *, span: Span = (0, 0)) # bases: Node
```
``operand [rename a to b, ...]`` renames key components.
Attributes:
operand: The expression.
pairs: ``(old, new)`` key names.
## `dpmxl.ast.Selection`
```python
class Selection(spec: SelectionSpec, *, span: Span = (0, 0)) # bases: Node
```
A ``{...}`` cell selection.
Attributes:
spec: The selection's components as written (without the ``with``
context; see `references`).
## `dpmxl.ast.SelectionSpec`
```python
class SelectionSpec(table: str | None = None, rows: AxisSelector | None = None, columns: AxisSelector | None = None, sheets: AxisSelector | None = None, default: Literal_ | SetLiteral | _Missing = MISSING, interval: bool | None = None, table_group: str | None = None, operation: str | None = None, *, source_span: Span = (0, 0))
```
Source, axis and option components of a ``{...}`` selection.
The source is one of ``table`` (``tC_01.00``), ``table_group``
(``gG_01``) or ``operation`` (``o_v1234``, another operation's result);
the others are ``None``.
Attributes:
table: Table code, such as ``C_01.00``.
rows: Row selector, or ``None`` when not written.
columns: Column selector, or ``None``.
sheets: Sheet selector, or ``None``.
default: The ``default:`` literal, or ``MISSING`` when not written.
interval: The ``interval:`` option, or ``None`` when not written.
table_group: Table group code of a ``{g_...}`` selection.
operation: Operation code of an ``{o_...}`` reference.
source_span: Source span of the table, table group or operation
reference, such as ``tC_01.00``; ``(0, 0)`` when there is none or
the selection was built directly. Like the selectors' spans it
takes no part in equality, hashing or ``repr``, and
`merged_into` keeps the
span of the source it keeps.
### `dpmxl.ast.SelectionSpec.merged_into`
```python
def merged_into(context: SelectionSpec) -> SelectionSpec
```
Return this selection completed by an inherited ``with`` context.
Returns:
SelectionSpec: Components written here win over the context; a
source written here replaces the context's source.
Examples:
>>> from dpmxl import parse_selection
>>> context = parse_selection("tC_01.00, c0010, interval: true")
>>> parse_selection("r0020").merged_into(context).text()
'{tC_01.00, r0020, c0010, interval: true}'
### `dpmxl.ast.SelectionSpec.axis`
```python
def axis(name: AxisName) -> AxisSelector | None
```
Return the selector for one axis.
Returns:
AxisSelector | None: ``None`` when the axis was not written.
### `dpmxl.ast.SelectionSpec.text`
```python
def text() -> str
```
Render the selection in DPM-XL syntax.
Returns:
str: A normalized ``{...}`` selection.
Examples:
>>> from dpmxl import parse_selection
>>> parse_selection("t_C_01.00 , r10-50, default: 0").text()
'{tC_01.00, r10-50, default: 0}'
## `dpmxl.ast.SetLiteral`
```python
class SetLiteral(items: tuple[Node, ...], *, span: Span = (0, 0)) # bases: Node
```
A set literal ``{a, b, ...}`` or the empty set ``{}``.
Attributes:
items: The member expressions.
## `dpmxl.ast.Span`
```python
Span: TypeAlias = tuple[int, int]
```
Half-open ``(start, end)`` character offsets into the parsed source.
## `dpmxl.ast.Sub`
```python
class Sub(operand: Node, name: str, value: Node, *, span: Span = (0, 0)) # bases: Node
```
``operand [sub name = value]`` fixes and removes a key component.
Attributes:
operand: The expression.
name: Key component to fix.
value: The value it must have.
## `dpmxl.ast.TableReported`
```python
class TableReported(table: str, *, span: Span = (0, 0)) # bases: Node
```
``{v_TABLE}``: true when the report declares the template.
Attributes:
table: The template or table code.
## `dpmxl.ast.TypedParameter`
```python
class TypedParameter(name: str, type: str, default: Literal_ | SetLiteral | _Missing = MISSING, *, span: Span = (0, 0)) # bases: Node
```
``{p_Name, type[, default: value]}``: a typed report parameter.
``type`` is one of `PARAMETER_TYPES` in lower case.
Attributes:
name: Parameter name without the ``p_`` prefix.
type: The declared type.
default: The ``default:`` value, or ``MISSING``.
## `dpmxl.ast.Unary`
```python
class Unary(op: Literal['+', '-', 'not'], operand: Node, *, span: Span = (0, 0)) # bases: Node
```
Prefix ``+``, ``-`` or ``not``.
Attributes:
op: The operator.
operand: The operand.
## `dpmxl.ast.Where`
```python
class Where(operand: Node, condition: Node, *, span: Span = (0, 0)) # bases: Node
```
``operand [where condition]`` filters records by key components.
Attributes:
operand: The filtered expression.
condition: Condition on key components.
## `dpmxl.ast.Window`
```python
class Window(unit: Literal['data points', 'range'], start: WindowBound, end: WindowBound)
```
``data points|range between start and end``.
Attributes:
unit: ``data points`` or ``range``.
start: The lower bound.
end: The upper bound.
## `dpmxl.ast.WindowBound`
```python
class WindowBound(kind: Literal['unbounded preceding', 'preceding', 'current data point', 'following', 'unbounded following'], offset: int = 0, period: str | None = None)
```
One end of an analytic window.
``kind`` is ``unbounded preceding``, ``preceding``, ``current data
point``, ``following`` or ``unbounded following``; ``offset`` counts
data points, or for a ``range`` window units of the order key, or of
``period`` (``A``, ``S``, ``Q``, ``M``, ``W``, ``D``) for a date key.
Attributes:
kind: The bound kind, as above.
offset: Number of data points or order-key units.
period: Period code of a date offset, or ``None``.
## `dpmxl.ast.With`
```python
class With(context: SelectionSpec, body: Node, condition: Node | None = None, *, span: Span = (0, 0)) # bases: Node
```
``with {context} [where condition]: body``.
The optional condition filters every selection evaluated in the body.
Attributes:
context: Selection components inherited by every selection in the body.
body: The expression.
condition: The context's ``where`` condition, or ``None``.
## `dpmxl.ast.children`
```python
def children(node: Node) -> tuple[Node, ...]
```
Return a node's direct child expressions in source order.
A selection's or ``with`` context's ``default`` and a typed parameter's
``default`` are children too, so `walk` reaches every literal.
Returns:
tuple[Node, ...]: Child nodes; literals and other leaves have none.
Examples:
>>> from dpmxl import parse
>>> [type(child).__name__ for child in children(parse("{tT} + 1"))]
['Selection', 'Literal_']
## `dpmxl.ast.walk`
```python
def walk(node: Node) -> list[Node]
```
Return the node and all descendants in pre-order.
Returns:
list[Node]: Every node of the tree.
Examples:
>>> from dpmxl import parse
>>> expression = "with {tT, default: 0}: {r0010} > {r0020} * 1.1"
>>> [type(node).__name__ for node in walk(parse(expression))]
['With', 'Literal_', 'Binary', 'Selection', 'Binary', 'Selection', 'Literal_']
>>> [
... expression[slice(*node.span)]
... for node in walk(parse(expression))
... if type(node).__name__ == "Selection"
... ]
['{r0010}', '{r0020}']
# `dpmxl.grammar`
The vocabulary of DPM-XL as frozensets, and the TextMate grammar built from it. See [Build editor support](../../guides/editor.md).
Machine-readable vocabulary of DPM-XL for editors and other tooling.
The sets below are the words and symbols the tokenizer recognizes; the tests
check that `tokenize` agrees with them. Words are lower
case; the tokenizer matches keywords in any case.
## `dpmxl.grammar.AGGREGATE_FUNCTIONS`
```python
AGGREGATE_FUNCTIONS = frozenset({'avg', 'count', 'max_aggr', 'median', 'min_aggr', 'sum'})
```
Aggregate operators; ``max`` and ``min`` with one operand aggregate too.
## `dpmxl.grammar.ANALYTIC_FUNCTIONS`
```python
ANALYTIC_FUNCTIONS = frozenset({'rank'})
```
Operators that take only an analytic ``over (...)`` clause.
## `dpmxl.grammar.AXIS_LETTERS`
```python
AXIS_LETTERS = frozenset({'r', 'c', 's'})
```
Prefixes of row, column and sheet coordinates inside a selection.
## `dpmxl.grammar.CALL_NAMES`
```python
CALL_NAMES = frozenset(canonical_function(name) for name in FUNCTIONS - ANALYTIC_FUNCTIONS)
```
Every ``Call.name`` a known function parses to. Aggregates among them are
``{canonical_function(name) for name in AGGREGATE_FUNCTIONS}``, which equals
``dpmxl.evaluator.AGGREGATES``. Analytic functions (``rank``, and aggregates
with ``over``) parse to `Analytic` instead of a call.
## `dpmxl.grammar.FUNCTIONS`
```python
FUNCTIONS = AGGREGATE_FUNCTIONS | ANALYTIC_FUNCTIONS | frozenset({'abs', 'annualise', 'date', 'day', 'exp', 'filter', 'intersect', 'isnull', 'len', 'length', 'ln', 'log', 'match', 'max', 'min', 'month', 'nvl', 'power', 'quarter', 'semester', 'set_of', 'setdiff', 'sqrt', 'substr', 'symdiff', 'time_shift', 'union', 'week', 'year'})
```
Function names, recognized when followed by ``(``.
## `dpmxl.grammar.FUNCTION_ALIASES`
```python
FUNCTION_ALIASES = {'max_aggr': 'max', 'min_aggr': 'min'}
```
Source spellings whose ``Call.name`` differs: the aggregate forms of
``max`` and ``min``, which evaluators recognize by their single operand.
## `dpmxl.grammar.KEYWORDS`
```python
KEYWORDS = frozenset({'and', 'else', 'endif', 'get', 'group by', 'if', 'in', 'not', 'or', 'over', 'rename', 'sub', 'then', 'to', 'where', 'with', 'xor'})
```
Keywords of expressions and clauses.
## `dpmxl.grammar.LITERAL_WORDS`
```python
LITERAL_WORDS = frozenset({'false', 'null', 'true'})
```
Boolean and null literals.
## `dpmxl.grammar.OPERATORS`
```python
OPERATORS = frozenset({'!=', '%', '&', '*', '+', '-', '/', ':=', '<', '<-', '<=', '<>', '=', '>', '>='})
```
Operator symbols, including the assignments ``:=`` and ``<-``.
## `dpmxl.grammar.PARAMETER_TYPES`
```python
PARAMETER_TYPES = frozenset({'boolean', 'date', 'integer', 'item', 'number', 'set-boolean', 'set-date', 'set-integer', 'set-item', 'set-number', 'set-string', 'string'})
```
Data types of a ``{p_Name, type}`` parameter reference.
## `dpmxl.grammar.PUNCTUATION`
```python
PUNCTUATION = frozenset({'(', ')', ',', ':', ';', '[', ']'})
```
Punctuation; ``{`` and ``}`` are the separate ``brace`` token kind.
## `dpmxl.grammar.REFERENCE_PREFIXES`
```python
REFERENCE_PREFIXES = {'t': 'table', 'g': 'table_group', 'v': 'variable', 'o': 'operation', 'p': 'parameter'}
```
Selection reference prefix to token kind, such as ``tC_01.00``.
## `dpmxl.grammar.SELECTION_KEYWORDS`
```python
SELECTION_KEYWORDS = frozenset({'default', 'interval'})
```
Option keywords inside a ``{...}`` selection.
## `dpmxl.grammar.SIGNATURES`
```python
SIGNATURES: Mapping[str, Signature] = MappingProxyType(_signatures())
```
The `Signature` of every name in `CALL_NAMES`.
## `dpmxl.grammar.TIME_PERIODS`
```python
TIME_PERIODS = frozenset({'A', 'S', 'Q', 'M', 'W', 'D'})
```
Period codes of ``time_shift`` and ``range`` window bounds (upper case).
## `dpmxl.grammar.TOKEN_KINDS`
```python
TOKEN_KINDS = frozenset({'axis', 'boolean', 'brace', 'date', 'eof', 'function', 'identifier', 'item', 'keyword', 'null', 'number', 'operation', 'operator', 'parameter', 'period', 'punctuation', 'string', 'table', 'table_group', 'type', 'variable'})
```
Every ``Token.kind`` that `tokenize` produces.
## `dpmxl.grammar.WINDOW_KEYWORDS`
```python
WINDOW_KEYWORDS = frozenset({'asc', 'between', 'current data point', 'data points', 'desc', 'following', 'order by', 'partition by', 'preceding', 'range', 'unbounded'})
```
Keywords inside an analytic ``over (...)`` clause.
## `dpmxl.grammar.Signature`
```python
class Signature(minimum: int, maximum: int | None, group_by: bool = False, key_operand: int | None = None, period_operand: int | None = None)
```
How many operands a function takes, and where it takes a key name.
Both evaluators check calls against these signatures, and
`diagnose` reports the same problems before
evaluation, so the three never disagree.
Attributes:
minimum: The fewest operands.
maximum: The most operands, or ``None`` for no limit.
group_by: Whether ``group by`` may follow the (single) operand.
key_operand: Zero-based position of an operand that must be a key
name, such as ``qDATE`` in ``annualise(x, 12, qDATE)``; ``None``
when there is none.
period_operand: Zero-based position of an operand that is a period
code from `TIME_PERIODS`, such as ``Q`` in ``time_shift(x, Q, 1)``;
``None`` when there is none.
Examples:
>>> SIGNATURES["substr"].problem("substr", 4)
'substr takes 1 to 3 operands, not 4'
>>> SIGNATURES["sum"].problem("sum", 1, group_by=True) is None
True
>>> SIGNATURES["abs"].problem("abs", 1, group_by=True)
'group by is not allowed in abs'
### `dpmxl.grammar.Signature.operands`
```python
def operands() -> str
```
Describe the accepted operand count.
Returns:
str: For example ``1 operand``, ``3 or 4 operands``, ``1 to 3
operands`` or ``2 or more operands``.
### `dpmxl.grammar.Signature.problem`
```python
def problem(name: str, count: int, *, group_by: bool = False) -> str | None
```
Return what is wrong with a call's operand count, if anything.
Args:
name: The function's ``Call.name``, for the message.
count: The number of operands written.
group_by: Whether the call has a ``group by`` clause.
Returns:
str | None: A message such as ``abs takes 1 operand, not 2``, or
``None`` when the call is well formed.
### `dpmxl.grammar.Signature.key_problem`
```python
def key_problem(name: str) -> str
```
Describe a call whose key operand is not a key name.
Returns:
str: For example ``annualise needs a key name as its third
operand``.
## `dpmxl.grammar.canonical_function`
```python
def canonical_function(name: str) -> str
```
Map a function's source spelling to the ``Call.name`` the parser gives it.
Args:
name: A function name as written, in any case.
Returns:
str: The lower-case name, with ``max_aggr`` and ``min_aggr`` read as
``max`` and ``min``.
Examples:
>>> canonical_function("max_aggr"), canonical_function("SUM")
('max', 'sum')
## `dpmxl.grammar.textmate_grammar`
```python
def textmate_grammar() -> dict[str, Any]
```
Build a TextMate grammar for DPM-XL syntax highlighting.
``editors/dpm-xl.tmLanguage.json`` in the repository is this grammar;
the tests keep the two in step.
Returns:
dict[str, Any]: The grammar, ready for ``json.dump``.
Examples:
>>> textmate_grammar()["scopeName"]
'source.dpm-xl'
# `dpmxl.values`
Values and their comparison. See [Interval arithmetic](../../concepts/intervals.md).
Runtime values: numbers with interval thresholds, items, text, dates.
Numbers follow the Eurofiling interval arithmetic functions (``iaf:*``) that
EBA validation rules are based on: a number is a value with a symmetric
threshold, so it stands for the interval ``[value - t, value + t]``. Facts
reported with XBRL ``decimals`` get ``t = 0.5 * 10**-decimals`` (``INF`` is
exact); constants and defaults are exact. Sums and differences add
thresholds, products use ``|A*dB| + |B*dA| + dA*dB``, quotients take the
largest deviation of the endpoint quotients, ``abs`` and negation keep the
threshold, and ``min``/``max`` keep the chosen value's threshold. Two numbers
are equal when ``|a - b| <= ta + tb``, i.e. when their intervals overlap.
Selections evaluated with ``interval: false`` drop the threshold, so their
comparisons are exact.
Arithmetic uses `DECIMAL_CONTEXT` (34 significant
digits, as IEEE decimal128), whatever the caller's thread decimal context.
## `dpmxl.values.DECIMAL_CONTEXT`
```python
DECIMAL_CONTEXT = Context(prec=34, rounding=ROUND_HALF_EVEN, Emax=6144, Emin=-6143, traps=[DivisionByZero, InvalidOperation, Overflow])
```
The decimal context of every operation on numbers (IEEE decimal128).
## `dpmxl.values.Number`
```python
class Number(value: Decimal, tolerance: Decimal = ZERO)
```
A decimal value with a symmetric tolerance for interval arithmetic.
A number stands for the interval
``[value - tolerance, value + tolerance]``. Build reported facts with
`Number.of`, which derives the tolerance from
XBRL ``decimals``. Arithmetic operators
(``+``, ``-``, ``*``, ``/``, unary ``-`` and ``abs``) follow the interval
rules in the module documentation.
Attributes:
value: The amount.
tolerance: Half-width of the interval; 0 for an exact number.
Examples:
>>> Number.of(2, 0) + Number.of(3, 1)
Number(value=Decimal('5'), tolerance=Decimal('0.55'))
### `dpmxl.values.Number.of`
```python
def of(value: int | str | Decimal, decimals: int | str | None = None) -> Number
```
Build a number, deriving tolerance from XBRL ``decimals``.
Args:
value: The amount.
decimals: XBRL ``decimals``: an integer, ``"INF"`` (in any case)
or ``None``, which both mean exact.
Returns:
Number: The value with ``0.5 * 10**-decimals`` tolerance, or an
exact value.
Raises:
ValueError: If ``decimals`` is text other than ``INF`` or an
integer, or puts the tolerance outside the range of
`DECIMAL_CONTEXT` (beyond about ±6,100).
Examples:
>>> Number.of(1204, 0)
Number(value=Decimal('1204'), tolerance=Decimal('0.5'))
>>> Number.of("250.4", 1).tolerance, Number.of(1000, -3).tolerance
(Decimal('0.05'), Decimal('5E+2'))
>>> Number.of(5, "INF").tolerance
Decimal('0')
### `dpmxl.values.Number.exact`
```python
def exact() -> Number
```
Return the same value without tolerance.
Returns:
Number: An exact copy.
### `dpmxl.values.Number.low`
```python
low: Decimal
```
Lower interval bound, ``value - tolerance``.
Examples:
>>> Number.of(1204, 0).low, Number.of(1204, 0).high
(Decimal('1203.5'), Decimal('1204.5'))
### `dpmxl.values.Number.high`
```python
high: Decimal
```
Upper interval bound, ``value + tolerance``.
## `dpmxl.values.Value`
```python
Value: TypeAlias = Number | Item | str | bool | date | ValueSet | None
```
A runtime value: `Number`, `Item`,
text, Boolean, date (or date-time), `ValueSet` or
``None`` (null).
## `dpmxl.values.ValueSet`
```python
class ValueSet(members: tuple[Value, ...] = ())
```
A set of distinct scalar values.
Set literals, ``set-*`` parameters and set operators yield sets. Members are kept in
first-seen order; numbers are exact, so ``1`` and ``1.0`` are one member. Build sets
with `ValueSet.of`, which removes duplicates by DPM-XL
equality.
### `dpmxl.values.ValueSet.members`
```python
members: tuple[Value, ...] = ()
```
The members in first-seen order.
### `dpmxl.values.ValueSet.of`
```python
def of(values: Iterable[Value]) -> ValueSet
```
Collect distinct non-null values.
Returns:
ValueSet: The set, in first-seen order.
Raises:
TypeError: If a value is itself a set.
Examples:
>>> values = ValueSet.of([Number.of(1), Number.of("1.0"), None, "a"])
>>> len(values), Number.of(1) in values, "b" in values
(2, True, False)
## `dpmxl.values.compare`
```python
def compare(op: str, left: Value, right: Value) -> bool | None
```
Compare two scalar values with DPM-XL semantics.
Numbers use interval comparison: ``a = b`` holds when the tolerance
intervals overlap. Items compare by category and code, ignoring a missing
category. Null on either side yields null.
Returns:
bool | None: The comparison result, or ``None`` for null operands.
Raises:
TypeError: When the operands cannot be compared.
ValueError: When ``op`` is not a comparison operator.
Examples:
>>> compare("=", Number.of(1204, 0), Number.of(1205, 0)) # overlap
True
>>> compare("=", Number.of(1204, 0), Number.of(1206, 0))
False
>>> compare("<", None, Number.of(1)) is None
True
>>> compare("=", Item.parse("eba_qEC:qx01"), Item.parse("qx01"))
True
## `dpmxl.values.format_computed`
```python
def format_computed(value: Decimal) -> str
```
Format a computed decimal, rounding a long fraction for display.
A division yields up to 34 significant digits. Beyond ten decimals, the
number shows six significant digits, but keeps every integer digit and
two decimals:
``1.852935352358765...`` reads ``1.85294`` and ``12345.67890123456``
``12,345.68``. Shorter fractions, such as the exact difference of two
reported values, are shown in full, as reported values always are.
Returns:
str: The formatted number.
Examples:
>>> format_computed(Decimal("1.852935352358765123"))
'1.85294'
>>> format_computed(Decimal("12345.67890123456"))
'12,345.68'
## `dpmxl.values.format_decimal`
```python
def format_decimal(value: Decimal) -> str
```
Format a decimal with thousands separators and no exponent.
Returns:
str: For example ``1,204`` or ``-0.075``.
Examples:
>>> format_decimal(Decimal("1234567.8900"))
'1,234,567.89'
## `dpmxl.values.format_value`
```python
def format_value(value: Value) -> str
```
Format a value for an explanation.
Numbers use thousands separators and drop trailing zeros; items keep their
``category:code`` form in brackets.
Returns:
str: Human-readable text.
Examples:
>>> from datetime import date
>>> format_value(Number.of("1234567.50")), format_value(Item.parse("a:b"))
('1,234,567.5', '[a:b]')
>>> format_value(date(2026, 12, 31)), format_value(True), format_value(None)
('2026-12-31', 'true', 'null')
>>> format_value("text")
'"text"'
## `dpmxl.values.type_name`
```python
def type_name(value: Value) -> str
```
Return a readable type name for error messages.
Returns:
str: For example ``number`` or ``item``.
Examples:
>>> type_name(Number.of(1)), type_name("x"), type_name(None)
('number', 'string', 'null')
# `dpmxl.recordset`
Keyed record sets and the natural join every operator uses. See [Records and keys](../../concepts/records-and-keys.md).
Keyed record sets, the value domain of DPM-XL operators.
A selection evaluates to records keyed by its varying components: closed axes
that select more than one coordinate (``r``, ``c``, ``s``) and open key
components such as ``qEBB``. Operators combine operands with a natural join on
shared keys, so ``{r0010} = {r0020}`` under ``with {c*}`` compares column by
column, and a scalar broadcasts to every record. An operand with a
`default` stands for that default where it lacks a
record another operand has.
## `dpmxl.recordset.STANDARD_KEYS`
```python
STANDARD_KEYS = frozenset({'r', 'c', 's'})
```
Keys of the closed axes: rows, columns and sheets.
## `dpmxl.recordset.EvaluationError`
```python
class EvaluationError(Exception)
```
Raised when an expression cannot be evaluated against the data.
For example a type mismatch, an unknown table or incompatible keys.
`evaluate_rule` catches it and returns
status ``error`` with the message.
## `dpmxl.recordset.Record`
```python
class Record(value: Value, points: tuple[Any, ...] = ())
```
One keyed value and the data points it was computed from.
``points`` may nest the operands' point tuples instead of copying them: joins over
large open tables would otherwise copy every record's growing point list at each
operator. Use `unique_points` to flatten.
Attributes:
value: The record's value.
points: The data points it was computed from, possibly nested.
## `dpmxl.recordset.Recordset`
```python
class Recordset(keys: tuple[str, ...], records: dict[tuple[Hashable, ...], Record] = dict(), default: Record | None = None)
```
Records indexed by a tuple of key values aligned with ``keys``.
A selection with a ``default`` keeps it as ``default``: the record of every
key the set lacks. `combine` then fills in the
default where another operand has a record, and
`lookup` returns it for a missing key.
Per-record operations carry it along (see
`map`), as do ``where``, ``sub``,
``filter``, ``rename``, a ``time_shift`` of a date key and an ``if``
whose condition has one; ``get``, aggregates and analytic functions drop
it.
Attributes:
keys: Key names, such as ``("r", "qCPI")``; empty for a scalar.
records: Records by key values, aligned with ``keys``.
default: The record of a key the set lacks, or ``None`` when a
missing key has no record.
### `dpmxl.recordset.Recordset.scalar`
```python
def scalar(value: Value, points: tuple[DataPoint, ...] = ()) -> Recordset
```
Build a key-less record set holding one value.
Returns:
Recordset: A scalar.
### `dpmxl.recordset.Recordset.is_scalar`
```python
is_scalar: bool
```
Whether the set has no key components.
### `dpmxl.recordset.Recordset.single`
```python
def single() -> Record | None
```
Return the only record of a scalar.
Returns:
Record | None: The record, or ``None`` for an empty set.
Raises:
EvaluationError: If the set is keyed.
### `dpmxl.recordset.Recordset.lookup`
```python
def lookup(binding: Mapping[str, Hashable]) -> list[Record]
```
Return records consistent with a partial key binding.
Returns:
list[Record]: Matching records (one when all keys are bound; the
``default`` when the set lacks that key and has one).
### `dpmxl.recordset.Recordset.map`
```python
def map(function: Callable[[Value], Value]) -> Recordset
```
Apply a function to every value, the ``default`` included.
Returns:
Recordset: Same keys, transformed values.
## `dpmxl.recordset.UnsupportedError`
```python
class UnsupportedError(EvaluationError)
```
Raised for valid DPM-XL that this evaluator cannot resolve.
Raised when a rule needs an optional provider method (``time_shift``,
``{o_...}``, ``{g_...}``) that the provider lacks or that returns
``None``, and for a function name the evaluator does not know.
`evaluate_rule` catches it and returns
status ``unsupported``.
## `dpmxl.recordset.check_structure`
```python
def check_structure(operands: Sequence[Recordset]) -> None
```
Enforce DPM-XL 2.2.3.1 for operators over several record sets.
Keyed operands must have exactly the same keys, or one operand's keys
(the reference) must be a superset of every other's. Standard keys
(``r``, ``c``, ``s``) shared with the reference must contain the same
values: ``{tT1, r010-020} + {tT1, r030-040}`` is an error, not empty.
Raises:
EvaluationError: When the structures are incompatible.
## `dpmxl.recordset.combine`
```python
def combine(operands: Sequence[Recordset], function: Callable[[list[Value]], Value]) -> Recordset
```
Natural-join operands on shared keys and combine their values.
Records without a partner on a shared key are dropped, unless the operand
lacking the partner has a ``default``: it then takes part with its default,
as a fact variable with a fallback value does. When every keyed operand
has a default, the result has a record for each key any operand with all
the keys has, and its own default combines theirs. Operands without any
shared key form a cross product, which is only allowed when at most one
of them is keyed (scalar broadcast) to avoid accidental explosions.
Returns:
Recordset: Keys in first-seen order across operands.
Raises:
EvaluationError: On incompatible key structures or oversized joins.
Examples:
>>> def numbers(values, default=None):
... records = {(key,): Record(value) for key, value in values}
... return Recordset(("qKEY",), records, default)
>>> def total(values):
... return sum(values)
>>> left = numbers([("a", 1), ("b", 2)])
>>> right = numbers([("b", 3), ("c", 4)], Record(0))
>>> def values(result):
... return {key: record.value for key, record in result.records.items()}
>>> values(combine([left, right], total))
{('a',): 1, ('b',): 5}
>>> left.default = Record(0)
>>> values(combine([left, right], total))
{('a',): 1, ('b',): 5, ('c',): 4}
## `dpmxl.recordset.iter_points`
```python
def iter_points(points: Iterable[Any]) -> Iterator[DataPoint]
```
Flatten nested point tuples (see `Record`) in order.
Yields:
DataPoint: Each point, duplicates included.
## `dpmxl.recordset.resolve_key`
```python
def resolve_key(name: str, keys: Iterable[str]) -> str | None
```
Resolve a key name written in a rule against available key components.
Rules refer to DPM 2.0 key properties with or without their ``q`` prefix
(``qEBB`` and ``EBB``) and in any case.
Returns:
str | None: The matching key, or ``None`` when absent or ambiguous.
Examples:
>>> resolve_key("EBB", ["qEBB", "r"]), resolve_key("qebb", ["qEBB"])
('qEBB', 'qEBB')
>>> resolve_key("CPI", ["r"]) is None
True
## `dpmxl.recordset.unique_points`
```python
def unique_points(points: Iterable[Any]) -> tuple[DataPoint, ...]
```
Flatten and deduplicate data points by provider reference, keeping order.
Returns:
tuple[DataPoint, ...]: Distinct points.
# `dpmxl.references`
Static analysis of what a rule reads. See [Analyse rules statically](../../guides/analyse.md).
Static analysis: the selections and templates an expression refers to.
## `dpmxl.references.Reference`
```python
class Reference(spec: SelectionSpec, span: tuple[int, int])
```
A selection completed by its inherited ``with`` context.
``spec.table`` is the table code of a cell selection; for a table group
(``{g_Group, ...}``) or operation (``{o_Code, ...}``) reference it is
``None`` and `kind` and
`code` say what is referenced. Typed
parameters (``{p_Name, ...}``) are not selections and are not listed.
Attributes:
spec: The selection with its ``with`` context applied.
span: The selection's source span.
### `dpmxl.references.Reference.kind`
```python
kind: Literal['table', 'table_group', 'operation']
```
What the selection reads: a table, a table group or an operation.
### `dpmxl.references.Reference.code`
```python
code: str | None
```
The table, table group or operation code.
## `dpmxl.references.references`
```python
def references(node: Node) -> tuple[Reference, ...]
```
Return every selection with its inherited context applied.
Table-group and operation references are included with ``spec.table`` set to
``None``: check `Reference.kind` and read the
group or operation code from `Reference.code`. A
group read reads every table the provider's ``table_group(code)`` lists;
`tables` expands groups that way when given the provider.
Targets of persistent assignments are included too.
Returns:
tuple[Reference, ...]: Selections in source order.
Examples:
>>> from dpmxl import parse
>>> node = parse("with {tC_01.00, c0010}: {r0010} = {r0020} + {g_G1, r0010}")
>>> [(ref.spec.text(), ref.kind, ref.code) for ref in references(node)]
... # doctest: +NORMALIZE_WHITESPACE
[('{tC_01.00, r0010, c0010}', 'table', 'C_01.00'),
('{tC_01.00, r0020, c0010}', 'table', 'C_01.00'),
('{gG1, r0010, c0010}', 'table_group', 'G1')]
## `dpmxl.references.reported_templates`
```python
def reported_templates(node: Node) -> tuple[str, ...]
```
Return templates tested with ``{v_...}`` filing-indicator checks.
Returns:
tuple[str, ...]: Template codes in first-seen order.
Examples:
>>> from dpmxl import parse
>>> reported_templates(parse("{v_C_01.00} and not {v_C_02.00}"))
('C_01.00', 'C_02.00')
## `dpmxl.references.tables`
```python
def tables(node: Node, provider: object | None = None) -> tuple[str, ...]
```
Return the distinct table codes referenced by cell selections.
Args:
node: The expression tree.
provider: Optional provider whose ``table_group(code)`` method
resolves table groups; each group read then counts as reading
all its tables. Without it, groups are left out.
Returns:
tuple[str, ...]: Table codes in first-seen order.
Examples:
>>> from dpmxl import MemoryProvider, parse
>>> node = parse("{tC_01.00, r0010} = {g_G1, r0010}")
>>> tables(node)
('C_01.00',)
>>> tables(node, MemoryProvider([], {}, table_groups={"G1": ("C_05.01",)}))
('C_01.00', 'C_05.01')
# `dpmxl.diagnostics`
Static checks for editors. See [Build editor support](../../guides/editor.md#find-every-problem).
Static diagnostics: positioned problems in an expression, for editors.
`diagnose` reports what can be found without
report data. On its own it finds every syntax error, unknown functions,
calls with the wrong operands and selections without a table. Given table
layouts it also checks tables, rows, columns and sheets, open-table keys
and, when the layouts carry cell data types, operands of the wrong type.
Given the report parameters, it also reports names that resolve to nothing.
## `dpmxl.diagnostics.DIAGNOSTIC_CODES`
```python
DIAGNOSTIC_CODES = frozenset({'syntax', 'unknown-function', 'operand-count', 'group-by', 'key-operand', 'missing-table', 'unknown-table', 'unknown-row', 'unknown-column', 'unknown-sheet', 'missing-row', 'missing-column', 'missing-sheet', 'open-row', 'open-column', 'open-sheet', 'empty-range', 'type-mismatch', 'unknown-key', 'unknown-name'})
```
Every `Diagnostic.code`. Codes are stable;
messages may be reworded in minor releases.
## `dpmxl.diagnostics.Diagnostic`
```python
class Diagnostic(severity: Severity, code: str, message: str, start: int, end: int, subject: str | None = None, suggestion: str | None = None)
```
One problem in an expression, with the source text it concerns.
Attributes:
severity: ``error`` when evaluation would fail or select nothing as
written; ``warning`` when the expression evaluates but probably
does not mean what it says.
code: A stable identifier from
`DIAGNOSTIC_CODES`:
* ``syntax``: the text does not parse;
* ``unknown-function``; ``operand-count`` and ``group-by``: a
call with the wrong number of operands or a ``group by`` the
function does not take; ``key-operand``: no key name where
``time_shift`` or ``annualise`` needs one;
* ``missing-table``: a selection without a table;
* ``unknown-table``: a table that does not exist;
* ``unknown-row``, ``unknown-column`` and ``unknown-sheet``: a
coordinate the table does not have, an
error when no part of the selector matches and a warning when
another part does; ``empty-range``: the same for a range;
* ``missing-row``, ``missing-column`` and ``missing-sheet``: a
code on an axis the table does not have; ``open-row``,
``open-column`` and ``open-sheet``: a code on an open axis,
whose rows, columns or sheets have keys instead of codes. What
a provider selects for such a code is up to it (a
`MemoryProvider` ignores the
selector), so these are warnings, and they are not reported
for a selector a shared ``with`` context applies;
* ``type-mismatch``: an operand of the wrong data type;
* ``unknown-key``: a name in a clause that is not a key of what
the clause applies to (a warning where a report parameter may
stand in and the parameters are not known); ``unknown-name``: a
name that is no report parameter or earlier assignment.
message: What is wrong, in plain words.
start: Zero-based offset of the first character concerned.
end: Offset just past the last character; equals ``start`` for a
problem at a position, such as the end of the expression.
subject: What the problem is about, when it names something: the
function, table, coordinate, key or name as written, or the data
type found; ``None`` otherwise.
suggestion: A replacement for ``expression[start:end]`` that likely
fixes the problem, such as ``sum`` for ``summ``; ``None`` when
there is no likely fix.
### `dpmxl.diagnostics.Diagnostic.pointer`
```python
def pointer(expression: str) -> str
```
Render the source line and mark the text concerned.
Args:
expression: The expression the diagnostic was found in.
Returns:
str: Two lines for terminal output, as
`DpmXlSyntaxError.pointer`.
Examples:
>>> source = "summ({tC_01.00, r0010}) > 0"
>>> print(diagnose(source)[0].pointer(source))
summ({tC_01.00, r0010}) > 0
^~~~
## `dpmxl.diagnostics.LayoutLookup`
```python
LayoutLookup = Callable[[str], TableLayout | None]
```
Returns a table's `TableLayout`, or ``None``
for a table that does not exist, such as ``layouts.get`` for a dict.
## `dpmxl.diagnostics.Severity`
```python
Severity = Literal['error', 'warning']
```
``error`` or ``warning``; see `Diagnostic`.
## `dpmxl.diagnostics.diagnose`
```python
def diagnose(expression: str, layout: LayoutLookup | None = None, *, tables: Iterable[str] | None = None, parameters: Iterable[str] | None = None, program: bool = False) -> tuple[Diagnostic, ...]
```
Return the problems found in an expression without evaluating it.
Every syntax error is reported: after one, parsing resumes where the
enclosing construct can carry on, and an error caused by an earlier one
is left out. A construct that contains a syntax error is not checked
further. Selections inside ``with`` are checked with their context, and
a problem in the context is reported once, at the context.
Args:
expression: A DPM-XL rule expression, or a script with
``program=True``.
layout: Looks up a table's `TableLayout`,
or returns ``None`` for a table that does not exist. Without it,
tables, coordinates, keys and data types are not checked. Table
groups and operations are never checked.
tables: Every table code, to suggest one for an unknown table.
parameters: Every report parameter name. With them, a name that is
neither a parameter, nor assigned by an earlier statement, nor a
key in a clause is an error; without them names are not checked
outside clauses.
program: Accept a script (``;``, ``:=`` and ``<-``), as
`parse_program` does; otherwise the
text must be one expression, as for
`parse`.
Returns:
tuple[Diagnostic, ...]: Problems in source order; empty when none
were found.
Examples:
>>> for d in diagnose("summ({tC_01.00, r0010}) > 0 and"):
... print(d.start, d.end, d.code, d.message)
0 4 unknown-function unknown function 'summ'; did you mean 'sum'?
31 31 syntax expression ends where an operand is expected
>>> from dpmxl import TableLayout
>>> layouts = {"C_01.00": TableLayout("C_01.00", rows=("0010", "0020"))}
>>> for d in diagnose("{tC_01.00, r0011} = {tC_02.00, r0010}", layouts.get):
... print(d.start, d.end, d.message, d.suggestion)
11 16 row 0011 is not in table C_01.00; did you mean r0010? r0010
21 29 unknown table C_02.00 None
# Using dpm-xl with AI agents
This page is for AI coding agents (and the people prompting them) writing
code that *uses* dpm-xl. It holds the essential API, the contracts that
matter and the mistakes to avoid. Agents working *on* the dpm-xl repository
should read
[`AGENTS.md`](https://github.com/luukhopman/dpm-xl/blob/main/AGENTS.md)
instead.
Machine-readable documentation, following [llmstxt.org](https://llmstxt.org):
- [`llms.txt`](https://luukhopman.github.io/dpm-xl/llms.txt): an index of
every page with a one-line description.
- [`llms-full.txt`](https://luukhopman.github.io/dpm-xl/llms-full.txt): every
page, API reference included, as one Markdown file.
- Each page as Markdown: replace the trailing `/` of a page URL with `.md`,
for example
[`guides/provider.md`](https://luukhopman.github.io/dpm-xl/guides/provider.md).
## The essential API
```python
from dpmxl import (
DataPoint,
CellQuery,
MemoryProvider,
TableLayout,
Number, # data
evaluate_rule,
explain,
SelectionCache, # evaluation
parse,
parse_program,
DpmXlSyntaxError, # parsing
references,
tables, # analysis
axis_matcher, # providers
)
```
| Call | Returns | Raises |
| --- | --- | --- |
| `evaluate_rule(expression, provider, *, precondition=None, trace=True, cache=None, only_keys=None)` | `RuleResult` with `status`, `message`, `outcomes`, `failures`, `counts`, `evaluation`, `assignments` | Nothing for bad rules or data: those are statuses |
| `explain(result.evaluation, expression, outcome.key)` | `Explanation` with `summary`, `filled`, `difference`, `gap`, `direction`, `left`, `right`, `primary`, `kind` | |
| `parse(expression)` / `parse_program(source)` | Expression tree / `Program` | `DpmXlSyntaxError` (`message`, `position`, `end`, `pointer()`) |
| `references(tree)`, `tables(tree, provider=None)` | Selections with context applied; table codes | |
| `dpmxl.columnar.evaluate_rule_columnar(expression, frame_provider, *, precondition=None, cache=None)` | `ColumnarResult` with `status`, `passed`, `failed`, `null`, `failing_keys`, `failing_frame` | `ColumnarUnsupported`: evaluate with `evaluate_rule` instead |
Statuses: `passed`, `failed`, `not_applicable`, `error`, `unsupported`.
## A complete, correct example
```python
from dpmxl import (
MemoryProvider,
Number,
SelectionCache,
TableLayout,
evaluate_rule,
explain,
)
provider = MemoryProvider(
[TableLayout("C_01.00", rows=("0010", "0020", "0030"), columns=("0010",))],
{
("C_01.00", "0010", "0010", None, ()): Number.of(1204, 0),
("C_01.00", "0020", "0010", None, ()): Number.of(1180, 0),
("C_01.00", "0030", "0010", None, ()): Number.of(31, 0),
},
)
rules = [
(
"v1",
"with {tC_01.00, c0010, interval: true}: {r0010} = {r0020} + {r0030}",
"{v_C_01.00}",
)
]
cache = SelectionCache()
for code, expression, precondition in rules:
result = evaluate_rule(expression, provider, precondition=precondition, cache=cache)
print(code, result.status, result.message or "")
for outcome in result.failures:
print(" ", explain(result.evaluation, expression, outcome.key).summary)
```
```text
v1 failed
1,204 should equal 1,211 (1,180 + 31): difference 7, at least 5.5 given the reported precision
```
## Contracts
- **Providers** implement `select(query) -> Sequence[DataPoint]`,
`table_reported(table) -> bool | None` and `parameter(name) -> Value`;
optionally `open_keys`, `table_group`, `operation`, `earlier_report`
([Connect your data](guides/provider.md)).
- **`select` returns every defined cell of a closed selection**, with
`value=None` for unreported ones, and one point per reported key
combination for open tables (keys in `DataPoint.keys`). It raises
`LookupError` for an unknown table.
- **Numbers** are `Number.of(value, decimals)` with the fact's XBRL
`decimals` (`None` or `"INF"` for exact). Pass strings or `Decimal`, not
floats, for exact values.
- **`MemoryProvider` value keys** are `(table, row, column, sheet, keys)`;
`keys` is a tuple of `(name, value)` pairs for open tables and `()` for
closed ones. Absent coordinates are `None`.
- **Thread safety:** `evaluate_rule` and `evaluate_rule_columnar` may run
concurrently on one report; `parse_cached`, `SelectionCache`, `FrameCache`
and `MemoryProvider` may be shared ([Run rules in threads](guides/threads.md)).
## Common pitfalls
| Mistake | Consequence | Do instead |
| --- | --- | --- |
| Returning only reported cells from `select` for a closed table | `default` and `isnull` never apply; presence checks pass wrongly | Return every defined cell with `value=None` |
| `Number.of(0.1, 2)` with a float | Binary float noise in the value | `Number.of("0.1", 2)` |
| Treating `not_applicable` or null records as failures | False alarms | Only `status == "failed"` / `outcome.passed is False` are failures |
| Expecting `evaluate_rule` to raise on a bad rule | Errors go unnoticed | Check `result.status in {"error", "unsupported"}` and `result.message` |
| Calling `explain` on a `trace=False` result or a `ColumnarResult` | Wrong or no explanation | Re-run `evaluate_rule` with the default `trace=True` for failing rules |
| Not catching `ColumnarUnsupported` | Rules with scripts, `time_shift`, sets and more crash | Catch it and call `evaluate_rule` |
| `parse` on a script with `;`, `:=` or `<-` | `DpmXlSyntaxError` | `parse_program` |
| Writing `a<-1` to compare with -1 | Parsed as a persistent assignment | `a < -1` |
| `{vX}` for a filing indicator | Reads parameter `X` | `{v_X}` tests the filing indicator |
| Keywords run together, such as `ornot` | One unknown name: a syntax error | Separate words: `or not` |
| A new `SelectionCache` per rule | Every selection is read again | One cache per report |
## Where to look next
- [Quick reference](reference/quick-reference.md): the language and its
grammar on one page.
- [Language](reference/language.md): every construct with its syntax,
types, null behaviour, errors and tested examples.
- [Concepts](concepts/index.md): records and keys, null logic, intervals.
- [Python API](reference/api/index.md): every public name.
# Changelog
All notable changes to this project are documented here. The project follows
[semantic versioning](https://semver.org).
## 1.2.1 - 2026-10-25
### Fixed
- `date()` with a day, month or year that does not exist raises `invalid
date: 2026-02-30` on every supported Python version; the message used to
repeat Python's own wording, which differs between versions.
## 1.2.0 - 2026-10-25
### Added
- The language reference documents every construct with its syntax, types,
null and interval behaviour, errors and examples whose results the tests
compute, and a new quick reference has the language and its grammar on
one page. The tests check that the reference covers `dpmxl.grammar`.
- `Recordset.default`: the record of a key a record set lacks, from a
selection's `default`. `Recordset.map` carries it and `Recordset.lookup`
returns it for a missing key.
- `Frame.has_default`: whether a columnar frame comes from a selection with
a `default`.
### Fixed
- An operand with a `default` stands for its default where another operand
has a record it lacks, as a fact variable's fallback value does, instead
of dropping that record from the join. `with {default: null}: if not
isnull({tA, c0010}) then not isnull({tB, c0010}) endif` over two parts of
an open table now fails for a key filed only in `A`; it was not evaluated.
Operands without a default still decide the keys and join on the keys
they share. An `if` condition with a default also covers keys only a
branch has. `where`, `sub`, `filter`, a `with` context's `where`,
`rename`, a `time_shift` of a date key and `if` results keep the default,
so a filtered operand falls back to it for keys the other operands bring;
`get`, aggregates and analytic functions do not. The columnar evaluator
leaves these cases to the reference evaluator.
- `[rename a to b]` on a record set that already has a key `b` is an error;
it produced two keys of the same name.
- A `with` context's `where` applies only to the selections that have the
keys it names. A body selection without them, key-less ones included,
was an error ("unknown key component" or "where needs a keyed operand").
## 1.1.0 - 2026-10-25
### Added
- `diagnose()` finds problems without report data, as positioned
`Diagnostic`s with a stable `code`, the `subject` concerned and a
`suggestion` to replace the text with: every syntax error (parsing
resumes after one), keywords run together (`ornot`), unknown functions,
wrong operand counts and selections without a table. Given table layouts
it also reports unknown tables, rows, columns and sheets, empty ranges,
clause names that are not keys and, with cell data types, operands of the
wrong type; given the report parameters, names that resolve to nothing.
An error means the rule fails, is inapplicable or cannot be evaluated as
written; a code on an open axis or one the table lacks, whose outcome is
up to the provider, is a warning.
`Diagnostic.pointer()` renders one; `DIAGNOSTIC_CODES` lists the codes.
- `dpmxl.grammar.SIGNATURES`: each function's `Signature` (operand counts,
`group by`, key and period operands), which both evaluators and
`diagnose()` check calls against.
- `TableLayout.open_axes` and `TableLayout.data_types` describe open axes
and cell data types (`dpmxl.provider.CELL_TYPES`; it may be partial); data
files accept them.
- Parsed selections keep source positions: `AxisSelector.span` and `spans`,
and `SelectionSpec.source_span`.
- `dpm-xl check` reports every problem `diagnose()` finds, also as
`diagnostics` in `--json`. `--layouts DATA` checks against a data file's
tables and parameters; `--strict` fails on any error, not only syntax
errors.
### Changed
- Calls with the wrong operands fail with one wording in both evaluators,
such as `abs takes 1 operand, not 2`, before their operands are evaluated.
- `dpm-xl check --json` lists every syntax error, so `errors` may hold
several entries for one expression: count failing expressions with
`total - parsed`, not `len(errors)`.
- The errors for `max_aggr` and `min_aggr` with several operands and for
`rank` without `over` point at the function name; an unknown selection
component's error spans the whole component.
### Fixed
- A range that repeats its axis letter, such as `r0440-r0480`, selected every
coordinate from its lower bound up; it now selects what `r0440-0480` does.
The letter is dropped only when the lower bound does not start with it, so
`rr1-rr10` reads as written (see `AxisSelector`).
## 1.0.0 - 2026-10-24
### Added
- Python 3.15 support, tested in CI on 3.11 to 3.15 with and without extras.
- Documentation site at https://luukhopman.github.io/dpm-xl/, with
`llms.txt` and `llms-full.txt`; every example in it is tested.
- `examples/rules.txt` and `examples/xbrl-report/`, a small xBRL-CSV report
for the end-to-end guide.
- `dpm-xl eval` takes a rule file as well as one expression and summarizes
every rule; `check` and `eval` accept `--json`; `--version` and examples in
`--help`.
- Runnable examples in `examples/`, run by the tests.
- `dpmxl.__version__`; `--json` output carries `schema_version`.
- The public API is documented: every name in a module's `__all__`
(including the `dpmxl.ast` tree and `VALUE_COLUMNS`), the columnar frame
schema and which caches are thread-safe.
- `tokenize()` and `Token(kind, text, start, end)` are public, with kinds
for syntax highlighting (keyword, function, operator, brace, table and axis
references, literal kinds, item, identifier, punctuation and more); every
AST node's source `span` is documented.
- `DpmXlSyntaxError.end`, and `pointer()` underlines the offending text.
- Text concatenation `&`, `substr`, `log`, the date functions `year`,
`semester`, `quarter`, `month`, `week`, `day` and `date(y, m, d)`, and date
literals with a time part (`#2026-12-31T10:00:00#`).
- Sets: literals such as `{1, 2}` and `{}`, `set_of`, `union`, `intersect`,
`setdiff` and `symdiff`, `in` with any set operand and `default: {…}`;
the `ValueSet` value type.
- Typed parameters `{p_Name, type[, default: v]}`, operation references
`{o_Code, …}` and table groups `{g_Group, …}`, resolved through optional
`operation(code)` and `table_group(code)` provider methods (and
`MemoryProvider`'s `operations` and `table_groups`, also in the CLI data
file); `Reference.kind` and `Reference.code`, and `tables(node, provider)`
expands table groups.
- Statements separated by `;` with temporary (`name := e`) and persistent
(`{target} <- e`) assignments: `parse_program`, the `Program`,
`Assignment` and `PersistentAssignment` nodes, and
`RuleResult.assignments`. `evaluate_rule`, the columnar fallback and
`dpm-xl check` accept programs; `parse` still returns one expression and
allows a trailing `;`.
- `time_shift` evaluates its operand on another period's report from the
optional `earlier_report(period, count)` provider method (or
`MemoryProvider(earlier_reports=...)`, or `earlier` in the CLI data file),
or moves a date key; its points carry `provider.EarlierRef` refs, and
explanations show the earlier report's values;
`annualise(x, fiscalYearEnd, key)`.
- Analytic functions: `rank` and aggregates with `over (partition by …
order by … window)`, parsed to the `Analytic` node with `Window` and
`WindowBound`.
- `tests/test_grammar_coverage.py`: an example for every alternative of the
DPM-XL grammar, each parsed and, where it yields a value, evaluated.
- `dpmxl.grammar`: keywords, operators, function names and parameter types as
frozensets (`AXIS_LETTERS` for `r`, `c`, `s`), `canonical_function` and
`CALL_NAMES` for the `Call.name` of each spelling, and a TextMate grammar in
`editors/dpm-xl.tmLanguage.json`.
### Changed
- **Breaking:** `dpm-xl` exits with status 3, not 2, for an evaluation error
or unreadable input, so 2 means a usage error only.
- The tokenizer splits selections into tokens; `Token.text` is the exact
source text (quotes, brackets and backticks included) and `Token` is a
named tuple. Error positions inside selections point at the component.
- `{}` is the empty set rather than an empty selection, and `In.values` may
be any set-valued node, not only a `SetLiteral`; `Value` includes
`ValueSet`.
- `time_shift` is no longer always `unsupported`: with a provider that has
`earlier_report` it is evaluated, and `MemoryProvider` without the
requested report reports it with a new message. The columnar evaluator
hands every `time_shift` rule to the reference evaluator (raising
`ColumnarUnsupported`) instead of reporting `unsupported` itself.
- A `_` after the `t`, `g`, `o` and `p` prefixes is cosmetic, as the
DPM-XL grammar says: `{t_T1}` selects table `T1`, not `_T1`. `{v_X}`
stays a filing-indicator test. `SelectionSpec` has
`table_group` and `operation` fields.
- `children()` and `walk()` reach the `default` of a selection, a `with`
context and a typed parameter.
- `with`, `to`, `where`, `get`, `sub`, `rename` and `over` are reserved
words; escape a key of that name as `` `to` ``. `a<-1` is now a persistent
assignment; write `a < -1` for the comparison.
- `Number.of` raises `ValueError` for `decimals` beyond the decimal128 range
(about -6,100 to 6,100, such as -7000), which 0.1.1 accepted; callers that
pass report-level decimals should catch it. The CLI reports it.
- Syntax errors read in plain words, such as "expression ends where an
operand is expected" and "expected a name, found ')'".
- A `where` or `sub` on a selection inside a `with` body overrides only the
context's `where` terms on the same key; the other terms still apply.
- Expressions nested more than 50 deep (chained clauses included), and
numbers outside the decimal128 range, are syntax errors; `4.5%%` is no
longer read as 0.00045.
- Explanations round the remaining gap down, so "at least" never overstates
it, and keep only the parentheses the rule writes.
### Fixed
- `dpm-xl check` and `eval` report a rule file path that does not exist
instead of reading it as an expression; `check --json` errors carry `end`.
- A division by zero or type error fails only its own record: a rule is an
error only if that record reaches the result, so guards such as
`if {x} != 0 then {y} / {x} > 1 endif` work.
- `evaluate_rule` reports deep recursion, decimal overflow and type errors in
`[sub]` as status `error` instead of raising.
- Arithmetic no longer depends on the caller's decimal context.
- Explanations show the right values after `[rename]`.
- Explanations of values around 1e33 and larger with `interval: true` no
longer raise.
- `if` without `else` treats filing indicators (`{v_…}`) and Boolean facts as
Boolean, as for other conditions.
- Items built with an `eba_` category match parsed items.
- The columnar evaluator hands every case it cannot match exactly to the
reference evaluator: any polars failure, values outside `Decimal(38, 12)`,
numeric open keys, ordering items and text that may be an item.
- The CLI accepts `"decimals": "INF"` and reports malformed data without a
traceback.
- The columnar evaluator hands record-level type errors (comparing text with
numbers, `and`/`not`/`abs` on the wrong type, mixed `max`/`min` groups) to
the reference evaluator, which fails only the records concerned.
- A non-Boolean condition of a keyed `if` fails only its records.
- The columnar evaluator no longer orders items without a category as text;
item literals in `<`, `<=`, `>`, `>=` and such items in the data go to the
reference evaluator, which reports the error.
### Performance
- `if` conditions whose branches add keys look records up by key instead of
scanning every branch record; repeated `where` verdicts are reused.
## 0.1.1 - 2026-10-23
### Performance
- Faster explanations and selections when many rules run against large
reports.
## 0.1.0 - 2026-10-23
First release on PyPI.
# Stability and versioning
dpm-xl follows [semantic versioning](https://semver.org). From 1.0.0, a
change that breaks the public API needs a new major version.
## The public API
- Every name in the `__all__` of the modules in the
[Python API](../reference/api/index.md), including the expression tree in
`dpmxl.ast` (frozen node classes with source `span`s, `walk` and
`children`) for callers that analyse rules.
- The [columnar frame schema](../reference/api/columnar.md#dpmxl.columnar--frame-schema)
that a `FrameProvider` returns.
- The thread-safety guarantees in [Run rules in threads](../guides/threads.md).
- The CLI: its commands, options, [exit statuses](../reference/cli.md#exit-statuses)
and the `--json` layout, versioned by `schema_version` (1), and the
[data formats](../reference/data-formats.md) it reads.
Names starting with an underscore are internal, as are modules whose names
start with one. `dpmxl.__version__` gives the installed version.
## What may change in a minor release
- The wording of explanations (`summary`, `filled` and the other text
fields), of syntax error messages and of `RuleResult.message`.
- New constructs, functions, token kinds and optional provider methods.
- Constructs the columnar evaluator handles itself instead of raising
`ColumnarUnsupported`.
- Bug fixes that change a result to what the specification and these
docs say it should be. They are listed under "Fixed" in the
[changelog](changelog.md).
Statuses, record keys, field names, `Explanation.kind` values and the JSON
field names do not change outside a major release.
## Supported Python versions
dpm-xl supports the Python versions listed in its package metadata,
currently 3.11 to 3.15.
# Benchmarks
Three Python packages parse DPM-XL. pydpm-xl and dpmcore validate rules
against a DPM database; dpm-xl evaluates rules against report data and
explains failures. The table shows where they overlap and where they differ.
## Capabilities
| | dpm-xl | pydpm-xl | dpmcore |
| --- | --- | --- | --- |
| Licence | MIT | GPL-3.0-or-later | Apache-2.0 |
| Runtime dependencies | none | 8 (pandas, SQLAlchemy, database drivers) | antlr4, SQLAlchemy |
| Parses every EBA 3.4–4.4 rule | yes | yes | yes |
| Syntax errors | plain words with position | ANTLR messages | ANTLR messages |
| Evaluates rules against report data | yes | no | no |
| Explains failing records | yes | no | no |
| Columnar evaluation for millions of rows | yes | no | no |
| CLI to evaluate rules on a data file | yes | no | no |
| Tokenizer and editor grammar | yes | no | no |
| *Outside dpm-xl's scope:* | | | |
| Semantic validation against a DPM release | no | yes | yes |
| DPM database migration, REST API | no | migration | yes |
| Reads report files | via [dpm-xbrl](https://luukhopman.github.io/dpm-xbrl/) | no | no |
Use pydpm-xl or dpmcore to check that a rule fits a DPM release; use dpm-xl
to check that a report satisfies the rules, and why not. The two combine.
## Results
The corpus holds the 14,174 distinct rule and precondition expressions of
EBA DPM releases 3.4 to 4.4. All three packages reject the same two, which
contain typos in the EBA source.
Measured on 2026-10-24 on AMD Ryzen 7 4700U with Radeon Graphics, 8 cores, Linux 7.0.0-38-generic; Python 3.12.3. Best of 3 warm runs; peak RSS over those runs.
#### Speed and footprint
| | dpm-xl 1.0.0 | pydpm-xl 0.3.12 | dpmcore 0.2.1 |
| --- | --- | --- | --- |
| Syntax check of the corpus (whole process) | 1.61 s, 23 MB | 14.85 s, 109 MB | 15.23 s, 113 MB |
| Per expression | 113 µs | 1,047 µs | 1,075 µs |
| `import` (whole process) | 0.09 s, 18 MB | 0.75 s, 97 MB | 0.85 s, 101 MB |
| Installed environment | 1 distributions, 1 MB | 17 distributions, 142 MB | 15 distributions, 143 MB |
#### Coverage of the EBA corpus
| | dpm-xl 1.0.0 | pydpm-xl 0.3.12 | dpmcore 0.2.1 |
| --- | --- | --- | --- |
| Expressions accepted | 14,172 of 14,174 | 14,172 of 14,174 | 14,172 of 14,174 |
| Rejected | `with {default: 0, interval: true}:if sum({tC_08.01.a, r0070…`
`with {tV_03.01, default: null, interval: false}: if not(isn…` | `with {default: 0, interval: true}:if sum({tC_08.01.a, r0070…`
`with {tV_03.01, default: null, interval: false}: if not(isn…` | `with {default: 0, interval: true}:if sum({tC_08.01.a, r0070…`
`with {tV_03.01, default: null, interval: false}: if not(isn…` |
#### DPM-XL constructs beyond the EBA rules
| | dpm-xl 1.0.0 | pydpm-xl 0.3.12 | dpmcore 0.2.1 |
| --- | --- | --- | --- |
| rank over a partition | accepted | rejected | accepted |
| windowed aggregate | accepted | rejected | accepted |
| statements with := | accepted | rejected | rejected |
| persistent assignment <- | accepted | rejected | accepted |
| operation reference {o...} | accepted | accepted | accepted |
| set operators and literals | accepted | rejected | accepted |
| typed parameter {p...} | accepted | rejected | accepted |
| text concatenation & | accepted | accepted | accepted |
| date functions | accepted | rejected | accepted |
| date() and date literals | accepted | rejected | accepted |
| table group {g...} | accepted | accepted | accepted |
| annualise | accepted | rejected | accepted |
#### Syntax error messages
`{tC_01.00, r0010, c0010} >=`
- dpm-xl: expression ends where an operand is expected at position 27
- pydpm-xl: offendingSymbol: [@11,27:26='',<-1>,1:27] msg: mismatched input '' expecting {BOOLEAN_LITERAL, NOT,…
- dpmcore: Unexpected error: "offendingSymbol: [@11,27:26='',<-1>,1:27] msg: mismatched input '' expecting {BO…
`with {tC_01.00, c0010}: {r0010} = {r0020} +`
- dpm-xl: expression ends where an operand is expected at position 43
- pydpm-xl: offendingSymbol: [@21,43:42='',<-1>,1:43] msg: mismatched input '' expecting {BOOLEAN_LITERAL, NOT,…
- dpmcore: Unexpected error: "offendingSymbol: [@21,43:42='',<-1>,1:43] msg: mismatched input '' expecting {BO…
## Method
Each package runs in its own virtual environment from PyPI (dpm-xl from this
repository), as a separate process through its public syntax-check API: one
warm-up, then the best of three runs, with peak memory. Ratios between the
packages are more stable than absolute times. Scripts and instructions are in
[`benchmarks/`](https://github.com/luukhopman/dpm-xl/tree/main/benchmarks).
# Security
## Reporting a vulnerability
Report vulnerabilities privately through
[GitHub security advisories](https://github.com/luukhopman/dpm-xl/security/advisories/new),
not in a public issue.
## Threat model
dpm-xl parses and evaluates expressions; it reads no files, opens no network
connections and never executes rule text as Python. What it does with
untrusted input:
| Input | Treatment |
| --- | --- |
| Expression text | Parsed by a hand-written recursive-descent parser. Nesting deeper than 50 levels and numbers outside the decimal128 range are syntax errors, so a hostile expression cannot exhaust the stack. |
| Report data | Values are typed data (`Number`, `str`, dates, items, sets); nothing is evaluated. Joins that would produce more than 2,000,000 records are an error instead of a memory spike. |
| `decimals` | `Number.of` rejects decimals beyond the decimal128 range, which would otherwise make tolerances overflow. |
| CLI input files | Read as JSON or text; malformed input is reported with exit status 3, without a traceback. |
What it does not protect against:
- **Regular expressions.** `match(s, pattern)` runs the rule's pattern with
Python's `re` module. A pattern written to backtrack catastrophically can
take very long on a long text. Treat rule text as code from a trusted
source, or evaluate rules from untrusted sources with a time limit (for
example in a separate process).
- **Rule cost.** Rules over very large selections take time and memory in
proportion to the data. Set limits in your application when users can
submit rules.
- **Providers.** Your provider runs inside the evaluation; it must protect
its own data source (SQL parameters, access control).
For untrusted report *files*, see the security model of
[dpm-xbrl](https://luukhopman.github.io/dpm-xbrl/), which reads them.
# Contributing
Issues and pull requests are welcome at
[github.com/luukhopman/dpm-xl](https://github.com/luukhopman/dpm-xl).
[`AGENTS.md`](https://github.com/luukhopman/dpm-xl/blob/main/AGENTS.md) in
the repository has the same conventions in a form for coding agents.
## Set up
dpm-xl uses [uv](https://docs.astral.sh/uv/):
```bash
git clone https://github.com/luukhopman/dpm-xl
cd dpm-xl
uv sync
```
`uv sync` installs the package with the test, lint and docs tools.
## Checks
Every change must keep these green:
```bash
uv run pytest
uv run ruff check . && uv run ruff format --check . && uv run ty check
uv run python scripts/build_docs.py
```
The docs build is strict: a broken link, an unresolved API reference or a
missing page fails it. `uv run zensical serve` previews the site while you
write.
## Conventions
- **Tests for every behaviour.** `tests/test_columnar.py` checks that both
evaluators agree case by case; add a case there when you touch either.
`tests/test_grammar_coverage.py` has an example for every alternative of
the DPM-XL grammar, and `tests/data/eba_expressions.txt` samples about 300
EBA rule expressions; every line must parse.
- **No new runtime dependencies.** The package has none; polars is the only
optional one.
- **Google-style docstrings** for every public name, with an `Examples:`
section where it helps. They feed the API reference and are run as
doctests.
- **Keep `__all__` and the docs in step.** A new public name goes in its
module's `__all__`, gets a docstring, and appears in the guide or concept
page it belongs to.
- **Docs examples run.** Every `python` and `console` block in `docs/` is
executed by `tests/test_docs.py`, and a `text` block right after a
`python` block must match its output exactly. Every `dpm-xl` block must
parse, and every row of an `| Expression | Result |` table is evaluated
against `examples/language_report.py`; see that file for the
conventions.
- **Language reference.** A new keyword, function or other name in
`dpmxl.grammar` needs an entry and an index row in
`docs/reference/language.md`, and an example that uses it; the tests
fail otherwise.
- **Grammar changes.** After changing `dpmxl.grammar`, regenerate
`editors/dpm-xl.tmLanguage.json`:
```bash
uv run python -c "import json; from dpmxl.grammar import textmate_grammar; print(json.dumps(textmate_grammar(), indent=2))" > editors/dpm-xl.tmLanguage.json
```
- **Commits** follow [Conventional Commits](https://www.conventionalcommits.org)
(`fix(evaluator): …`, `docs: …`), in the imperative, under 72 characters.
- **Changelog.** Add user-visible changes to `CHANGELOG.md` under the next
version, one short bullet per change.
## Releases
1. Update the version in `pyproject.toml` (`uv version X.Y.Z`) and the
changelog heading, and commit as `build: release X.Y.Z`.
2. Tag `vX.Y.Z` and publish a GitHub release. The `Publish` workflow tests,
builds and uploads to PyPI through trusted publishing.
3. The docs site deploys from `main` on every push.
# Licence
dpm-xl is released under the MIT licence. dpm-xl is an independent project,
not affiliated with or endorsed by the European Banking Authority.
```text
MIT License
Copyright (c) 2026 Luuk Hopman
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```