Skip to content

The predicate DSL

A model plugin decides which variants are prioritized — worth a closer look — with a heuristic over its score columns. That same heuristic has to run in two very different places: as Python, over a local store's rows; and as SQL, pushed down into a warehouse so the database does the filtering. Writing it twice invites them to drift.

The predicate DSL (altar.predicates) lets a plugin declare the heuristic once as a Predicate, and Altar renders it to both — a Python callable and a SQL boolean expression. One source, two generated renderings, no hand-maintained SQL copy.

Building a predicate

Terms produce values; predicates compare and combine them into a boolean.

from altar.predicates import Col, Lit, Abs, Ge, Gt, Eq, And, Or, Not

# "a large effect in an accessible region":  |logfc| >= 0.5  AND  in_peak = TRUE
predicate = And(
    Ge(Abs(Col("logfc")), Lit(0.5)),
    Eq(Col("in_peak"), Lit(True)),
)
Piece Options
Terms Col(name) (a column), Lit(value) (a constant), Abs(term) (absolute value), Greatest(*terms) and Least(*terms) (null-ignoring maximum and minimum)
Comparisons Ge, Gt, Le, Lt, Eq, Ne — each takes two terms
Tests IsNull(term), In(term, values) (membership in a tuple of literals)
Combinators And(*preds), Or(*preds), Not(pred)

Rendering it

The same object renders both ways:

predicate.to_python()  # -> callable: row(dict) -> bool
predicate.to_sql()  # -> "(ABS(logfc) >= 0.5 AND in_peak = TRUE)"

fn = predicate.to_python()
fn({"logfc": -0.8, "in_peak": True})  # True

to_sql optionally takes a column renderer (SqlCol) so a caller can qualify names with a table alias — the warehouse store uses this to emit its push-down WHERE clause.

Three-valued (Kleene) semantics

The reason the two renderings stay in lock-step on missing data is that the DSL evaluates with SQL's three-valued logic. A comparison touching a None/missing value is unknown (None), not False; that unknown propagates through And / Or / Not exactly as SQL NULL does; and only a definite True counts as a match:

Ge(Col("logfc"), Lit(0.5)).evaluate({"logfc": None})  # None  (unknown — not False)
predicate.to_python()({"logfc": None})  # False (to_python matches only definite True)

That's why a nullable boolean column is compared explicitly — Eq(Col("in_peak"), Lit(True)), which renders to in_peak = TRUE — rather than tested for bare truthiness: it makes the Python and SQL renderings handle NULLs identically, so a variant is prioritized in the local store if and only if it would be in the warehouse.

Null tests, membership, and multi-column extremes

IsNull(term) is the one test that is never unknown: it is True for a missing or None value and False otherwise, and renders to (term IS NULL). Write its negation as Not(IsNull(term)). Use it to say what a missing value should mean instead of letting it silently fail to match:

from altar.predicates import Col, In, IsNull, Or

# "a promoter or enhancer variant, or one the region annotation has not covered"
predicate = Or(In(Col("region_type"), ("promoter", "enhancer")), IsNull(Col("region_type")))
predicate.to_sql()  # -> "((region_type IN ('promoter', 'enhancer')) OR (region_type IS NULL))"

In(term, values) follows SQL IN: it is True when the term equals one of the values, unknown when the term is null, and False otherwise. The values are literals of one type family (booleans, numbers, or strings). None is rejected, because SQL makes x IN (..., NULL) unknown rather than true for a null x; combine In with IsNull as above to match nulls. An empty tuple is False for every row and renders to FALSE, since IN () is not valid SQL.

Greatest(*terms) and Least(*terms) take the maximum or minimum over the operands that are not null, and are null only when every operand is. That is the shape of "the highest allele frequency over the ancestry groups that have one":

from altar.predicates import Col, Ge, Greatest, Lit

common = Ge(Greatest(Col("af_afr"), Col("af_nfe"), Col("af_eas")), Lit(0.01))
common.evaluate({"af_afr": None, "af_nfe": 0.02})  # True: one missing group does not hide the others

SQL dialects disagree on nulls here. DuckDB's and PostgreSQL's GREATEST skip NULL arguments, but BigQuery's returns NULL when any argument is NULL. So Greatest never hands SQL a NULL argument unless all operands are NULL: it renders each argument as a COALESCE of the operands rotated to start at a different one,

GREATEST(COALESCE(af_afr, af_nfe, af_eas), COALESCE(af_nfe, af_eas, af_afr), COALESCE(af_eas, af_afr, af_nfe))

which gives the same answer under either convention and matches the Python rendering. The SQL text grows with the square of the operand count, which is negligible for the handful of columns a predicate compares.

A float NaN is a value, not a null, in both renderings: IsNull is False for it, and Greatest and Least do not skip it. That is consistent with the rest of the DSL, which never treats NaN as missing. A source should therefore emit None (SQL NULL) for a missing value rather than NaN.

In values and Lit constants may be NumPy scalars, for example values taken from an array. They render as plain SQL literals, and In stores them as built-in Python scalars with duplicates removed in first-occurrence order. Pass an ordered collection such as a tuple or list; a set is rejected because its order, and so the SQL text, can differ between processes.

Where it's used

A ModelPlugin returns a Predicate from prioritize_predicate() (add a model binding), and a prioritizing AnnotationSource returns one as well. The SQLite store runs the Python rendering; the BigQuery store can push the SQL rendering down. Declare the heuristic once in the DSL so both interpretations remain aligned.