From b67df32d8c2009d404d3e14e3b1fbfd2df144933 Mon Sep 17 00:00:00 2001 From: zz_y Date: Fri, 3 Jul 2026 10:56:06 -0600 Subject: [PATCH] feat(promql): presence functions absent / absent_over_time / present_over_time (#47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `absent`/`absent_over_time`/`present_over_time` parsed but were rejected. They now lower to presence intents (~65 corpus rejections; alerts corpus 864 → 876). Each lowers to a per-series `Aggregate{[Absent/AbsentOverTime/ PresentOverTime]}` over the (instant or range) argument. The empty-result → synthesized-1-sample logic is an L4/runtime concern; L3 only marks the operation. Modelled label-preserving so `absent`'s output labels — which it synthesizes from the argument's equality matchers (e.g. `job` from `{job="x"}`) — survive into the schema. - L3 AggIntent: + Absent / AbsentOverTime / PresentOverTime; requires()=TimeSeries, is_per_series()=true, float output. - L2 AggFunc mirror + converter mapping. - Front end: `walk_presence` + dispatch (arg 0 is the vector; `walk` builds a Window for the `*_over_time` matrix forms → TimeRange). Composes with #35 (`absent(up{job="x"}) == 1`). Flipped the `absent` GAP test, dropped absent/absent_over_time from `unsupported_functions_are_rejected`, added conformance section P (+ a matcher-label-preservation test). Full suite green; clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/frontend-promql/src/promql.rs | 24 ++++++++++++++ .../awesome_prometheus_alerts.rs | 9 +++--- .../tests/promql_conformance.rs | 31 +++++++++++++++++-- crates/ir/src/intent_algebra/agg_intent.rs | 26 +++++++++++++++- crates/l2/src/lower.rs | 3 ++ crates/l2/src/relational.rs | 6 ++++ 6 files changed, 92 insertions(+), 7 deletions(-) diff --git a/crates/frontend-promql/src/promql.rs b/crates/frontend-promql/src/promql.rs index 67f5f71a..ef47ce78 100644 --- a/crates/frontend-promql/src/promql.rs +++ b/crates/frontend-promql/src/promql.rs @@ -26,6 +26,8 @@ //! | `rate/irate(m[w])` | `Aggregate{[Rate{w}]}` (no Window) — `irate` shares the `rate` *intent*; the avg-vs-last-two-samples difference is an L4 estimation method | //! | `increase(m[w])` | `Aggregate{[Increase{w}]}` (no Window) | //! | `changes`/`delta`/`idelta`/`deriv`/`resets`/`predict_linear`/`double_exponential_smoothing`(`m[w]`, …) | `Aggregate{[Changes/Delta/…], Window{w}}` — per-series counter-derivative intents (issue #44); `holt_winters` is the legacy alias of `double_exponential_smoothing` | +//! | `absent(v)` / `absent_over_time(m[w])` / `present_over_time(m[w])` | `Aggregate{[Absent/AbsentOverTime/PresentOverTime]}` — presence intents; the empty→synthesized-sample logic is L4 (issue #47) | +//! | `abs`/`ceil`/`sqrt`/`ln`/`clamp*`/`round`/trig(`v`), `pi()` | `Aggregate{[Math(f)]}` element-wise transform (issue #45); `pi()` → a `Scalar` leaf | //! | `group` / `offset` / `@` | **rejected** — distinct semantics with no intent-algebra representation yet | //! | `OUTER by (dims) (…)` | `Aggregate.keys = dims` (→ positional `Aggregate.by` in L3; generic `topk by`/`bottomk` grouping → `Sort.partition_by`) | //! | `count by (d) (…)` | `Aggregate{[CountDistinct], …}` (→ `Cardinality`) | @@ -167,6 +169,7 @@ fn walk(expr: &Expr) -> Result { Expr::Aggregate(agg) => walk_aggregate(agg), Expr::Call(call) if call.func.name.starts_with("histogram_") => walk_histogram(call), Expr::Call(call) if is_math_fn(call.func.name) => walk_math(call), + Expr::Call(call) if is_presence_fn(call.func.name) => walk_presence(call), Expr::Call(call) => walk_call(call), Expr::Binary(bin) => walk_binary(bin), Expr::Paren(p) => walk(&p.expr), @@ -460,6 +463,27 @@ fn walk_histogram(call: &Call) -> Result { Ok(outer_aggregate(vec![], func, walk(arg(call, vec_idx)?)?)) } +/// The presence functions (issue #47). +fn is_presence_fn(name: &str) -> bool { + matches!(name, "absent" | "absent_over_time" | "present_over_time") +} + +/// `absent(v)` / `absent_over_time(m[w])` / `present_over_time(m[w])` — lowered +/// to an `Aggregate{[Absent/…]}` over the (instant or range) argument. The +/// empty-result → synthesized-1-sample logic is an L4/runtime concern; L3 only +/// marks the operation (issue #47). +fn walk_presence(call: &Call) -> Result { + let func = match call.func.name { + "absent" => AggFunc::Absent, + "absent_over_time" => AggFunc::AbsentOverTime, + "present_over_time" => AggFunc::PresentOverTime, + other => return Err(LoweringError::UnsupportedFunction(other.to_string())), + }; + // arg 0 is the instant vector (`absent`) or range vector (`*_over_time`); + // `walk` produces a `Window` for the matrix-selector forms. + Ok(outer_aggregate(vec![], func, walk(arg(call, 0)?)?)) +} + /// The element-wise math / trig functions (issue #45). fn is_math_fn(name: &str) -> bool { matches!( diff --git a/crates/frontend-promql/tests/observability/awesome_prometheus_alerts.rs b/crates/frontend-promql/tests/observability/awesome_prometheus_alerts.rs index 870f73fd..49a6bb75 100644 --- a/crates/frontend-promql/tests/observability/awesome_prometheus_alerts.rs +++ b/crates/frontend-promql/tests/observability/awesome_prometheus_alerts.rs @@ -260,10 +260,11 @@ fn scalar_threshold_comparisons_lower_to_binaryop_scalar() { } #[test] -fn absent_function_is_rejected__GAP() { - // `absent(up{job="prometheus"})` — "job missing" alerts. `absent` has no - // intent-algebra representation yet. - let _ = rejected(r#"absent(up{job="prometheus"})"#); +fn absent_function_lowers_to_absent_intent() { + // `absent(up{job="prometheus"})` — "job missing" alerts. Lowers to the + // `Absent` intent (issue #47); the empty→synthesized-sample logic is L4. + let qe = ok(r#"absent(up{job="prometheus"})"#); + assert!(intents(&qe).iter().any(|i| matches!(i, AggIntent::Absent))); } #[test] diff --git a/crates/frontend-promql/tests/promql_conformance.rs b/crates/frontend-promql/tests/promql_conformance.rs index 67ed68c3..0da8caf8 100644 --- a/crates/frontend-promql/tests/promql_conformance.rs +++ b/crates/frontend-promql/tests/promql_conformance.rs @@ -773,8 +773,6 @@ fn unsupported_functions_are_rejected() { for q in [ "time()", "timestamp(up)", - "absent(up)", - "absent_over_time(up[5m])", r#"label_replace(up, "host", "$1", "instance", "(.+):.*")"#, // NOTE: counter-derivatives (#44) now lower — see section M; the math / // trig family (`abs`/`clamp*`/`ln`/…, #45) lowers — see section O. @@ -1099,3 +1097,32 @@ fn pi_lowers_to_a_scalar_constant() { // `pi()` is the constant π — a `Scalar` leaf, not a `Math` intent. assert!(matches!(ok("pi()"), QueryExpr::Scalar(v) if (v - std::f64::consts::PI).abs() < 1e-12)); } + +// ───────────────────────────────────────────────────────────────────────────── +// P. Presence functions (functions.test; issue #47) +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn presence_functions_lower_to_presence_intents() { + for (q, want) in [ + (r#"absent(up{job="x"})"#, AggIntent::Absent), + ("absent_over_time(m[1h])", AggIntent::AbsentOverTime), + ("present_over_time(m[5m])", AggIntent::PresentOverTime), + ] { + let qe = ok(q); + assert!(intents(&qe).contains(&want), "{q}: got {:?}", intents(&qe)); + } +} + +#[test] +fn absent_keeps_matcher_labels_for_the_synthesized_output() { + // `absent(v)` synthesizes its output labels from `v`'s equality matchers, so + // those labels must survive into the schema — here `job` from `{job="x"}`. + let qe = ok(r#"absent(up{job="x"})"#); + let cols = qe.output_schema().unwrap(); + assert!( + cols.columns.iter().any(|c| c.name == "job"), + "matcher label `job` kept, got {:?}", + cols.columns.iter().map(|c| &c.name).collect::>() + ); +} diff --git a/crates/ir/src/intent_algebra/agg_intent.rs b/crates/ir/src/intent_algebra/agg_intent.rs index 014b0c69..b988ffc4 100644 --- a/crates/ir/src/intent_algebra/agg_intent.rs +++ b/crates/ir/src/intent_algebra/agg_intent.rs @@ -154,6 +154,21 @@ pub enum AggIntent { /// one value out per input sample. (`pi()` is a constant, lowered to a /// scalar leaf, not this.) Math(MathFunc), + + // ── Presence functions (issue #47) ─────────────────────────────────── + // `absent`/`present_over_time` — the value/emptiness of the argument + // determines the output. The empty-result → synthesized-1-sample logic is + // an L4/runtime concern; L3 only marks the operation. Modelled as + // label-preserving so the argument's (matcher-derived) labels — which + // `absent` synthesizes onto its output — stay in the schema. + /// PromQL `absent(v)` — a 1-sample vector when the instant vector `v` has no + /// matching series, else empty. + Absent, + /// PromQL `absent_over_time(v[w])` — `absent` over a range vector. + AbsentOverTime, + /// PromQL `present_over_time(v[w])` — value 1 per series that has any sample + /// in the range (per-series). + PresentOverTime, } /// The element-wise math / trig functions (issue #45). Unary over the sample @@ -215,7 +230,10 @@ impl AggIntent { | Self::HistogramAvg | Self::HistogramStdDev | Self::HistogramStdVar - | Self::HistogramFraction { .. } => DataModel::TimeSeries, + | Self::HistogramFraction { .. } + | Self::Absent + | Self::AbsentOverTime + | Self::PresentOverTime => DataModel::TimeSeries, _ => DataModel::Any, } } @@ -244,6 +262,9 @@ impl AggIntent { | Self::HistogramStdVar | Self::HistogramFraction { .. } | Self::Math(_) + | Self::Absent + | Self::AbsentOverTime + | Self::PresentOverTime ) } @@ -313,6 +334,9 @@ impl AggIntent { col("histogram_quantile", DataType::Float64, false) } AggIntent::Math(_) => col("value", DataType::Float64, false), + AggIntent::Absent => col("absent", DataType::Float64, false), + AggIntent::AbsentOverTime => col("absent_over_time", DataType::Float64, false), + AggIntent::PresentOverTime => col("present_over_time", DataType::Float64, false), } } } diff --git a/crates/l2/src/lower.rs b/crates/l2/src/lower.rs index b2fdf35b..dcfff490 100644 --- a/crates/l2/src/lower.rs +++ b/crates/l2/src/lower.rs @@ -586,6 +586,9 @@ fn agg_func_to_intent(func: &AggFunc, acc: &AccuracyTarget, col: Option AggIntent::HistogramQuantile { q: *q }, AggFunc::Math(m) => AggIntent::Math(m.clone()), + AggFunc::Absent => AggIntent::Absent, + AggFunc::AbsentOverTime => AggIntent::AbsentOverTime, + AggFunc::PresentOverTime => AggIntent::PresentOverTime, } } diff --git a/crates/l2/src/relational.rs b/crates/l2/src/relational.rs index 3b083609..b64fb3a4 100644 --- a/crates/l2/src/relational.rs +++ b/crates/l2/src/relational.rs @@ -150,6 +150,12 @@ pub enum AggFunc { /// PromQL element-wise math / trig transform (`abs`/`sqrt`/`clamp_max`/…) → /// `AggIntent::Math` (issue #45). Math(MathFunc), + /// PromQL `absent` → `AggIntent::Absent` (issue #47). + Absent, + /// PromQL `absent_over_time` → `AggIntent::AbsentOverTime`. + AbsentOverTime, + /// PromQL `present_over_time` → `AggIntent::PresentOverTime`. + PresentOverTime, } /// The Layer-2 relational query IR.