From 29b107a3af871ae0838968ff16d0f68c046b76c0 Mon Sep 17 00:00:00 2001 From: zz_y Date: Thu, 2 Jul 2026 08:57:36 -0600 Subject: [PATCH 1/2] feat(lower): counter-derivative range functions (#44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `changes`, `delta`, `idelta`, `deriv`, `resets`, `predict_linear`, and `double_exponential_smoothing` (a.k.a. `holt_winters`) parsed but were rejected (`UnsupportedFunction`). They now lower to distinct per-series intents — the second-largest un-tracked lowering gap (108 corpus rejections, 5.6%). Each is a per-series, label-preserving reduction of one series' range window to one value, riding on the enclosing `TimeRange`: changes(v[w]) -> Aggregate{Changes, by:[]} -> TimeRange{w} -> Scan Deliberately NOT aliased to rate/increase/count: `changes` (value-change count) and `resets` (counter-reset count) are distinct from a sample count; `delta`/`idelta`/`deriv` are gauge derivatives. `predict_linear` carries its horizon (`seconds`); `double_exponential_smoothing` carries both smoothing factors; `holt_winters` maps to the same intent. Pipeline: - L3 `AggIntent`: + Changes/Delta/IDelta/Deriv/Resets/PredictLinear/ DoubleExpSmoothing; requires()=TimeSeries, is_per_series()=true, output_column() named after the function (float64). - L2 `AggFunc`: mirror variants (window rides on the L2 Window node, like *_over_time, so — unlike Rate/Increase — they don't carry it). - Converter `agg_func_to_intent`: map each, threading scalar params. - PromQL front end: `InnerFunc` variants + `lower_inner_call` arms (`predict_linear` reads arg1; `double_exponential_smoothing`/ `holt_winters` read args 1-2). Tests: - promql_conformance.rs section M — distinct intents, predict_linear horizon, double_exp/holt_winters alias equivalence, label preservation under an outer `sum by`. - awesome_prometheus_alerts.rs — 3 __GAP tests flipped to positive (function body lowers; the full `... > N` alert still needs #35). - promql_equivalence.rs — changes/resets upgraded from rejected to pairwise-distinct vs count_over_time. - promql_conformance.rs — dropped the 5 now-supported entries from unsupported_functions_are_rejected. Full workspace suite green; clippy --all-targets clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/core/src/intent_algebra/agg_intent.rs | 69 ++++++++++++++++- crates/core/src/intent_algebra/lower.rs | 13 ++++ crates/core/src/intent_algebra/relational.rs | 25 ++++++ crates/lower/src/promql.rs | 57 +++++++++++++- .../lower/tests/awesome_prometheus_alerts.rs | 34 ++++---- crates/lower/tests/promql_conformance.rs | 77 +++++++++++++++++-- crates/lower/tests/promql_equivalence.rs | 9 ++- 7 files changed, 256 insertions(+), 28 deletions(-) diff --git a/crates/core/src/intent_algebra/agg_intent.rs b/crates/core/src/intent_algebra/agg_intent.rs index 9e1e2b13..da16c6f6 100644 --- a/crates/core/src/intent_algebra/agg_intent.rs +++ b/crates/core/src/intent_algebra/agg_intent.rs @@ -86,6 +86,38 @@ pub enum AggIntent { // not in the intent — this keeps the intent vocabulary range-agnostic. Rate, Increase, + + // ── Counter-derivative / range-vector functions (issue #44) ────────── + // All per-series, label-preserving reductions of a single series' range + // window to one value; the window rides on the enclosing `TimeRange`. + // Each has distinct semantics and is deliberately NOT aliased to + // `Rate`/`Increase`/`Count`. + /// PromQL `changes(v[w])` — number of times the value changed in the window. + Changes, + /// PromQL `delta(v[w])` — difference between the first and last sample + /// (gauge semantics; not counter-reset-adjusted). + Delta, + /// PromQL `idelta(v[w])` — difference between the last two samples. + IDelta, + /// PromQL `deriv(v[w])` — per-second derivative via simple linear + /// regression over the window (gauges). + Deriv, + /// PromQL `resets(v[w])` — number of counter resets in the window. + Resets, + /// PromQL `predict_linear(v[w], t)` — linear-regression extrapolation of + /// the value `t` seconds into the future. + PredictLinear { + /// The prediction horizon in seconds (the 2nd, scalar argument). + seconds: f64, + }, + /// PromQL `double_exponential_smoothing(v[w], sf, tf)` (a.k.a. the legacy + /// `holt_winters`) — Holt-Winters double-exponential smoothing. + DoubleExpSmoothing { + /// Data (level) smoothing factor `sf` ∈ (0, 1). + smoothing: f64, + /// Trend smoothing factor `tf` ∈ (0, 1). + trend: f64, + }, } impl AggIntent { @@ -93,7 +125,15 @@ impl AggIntent { /// this to skip non-applicable intents (e.g. `Rate` over a tabular source). pub fn requires(&self) -> DataModel { match self { - Self::Rate | Self::Increase => DataModel::TimeSeries, + Self::Rate + | Self::Increase + | Self::Changes + | Self::Delta + | Self::IDelta + | Self::Deriv + | Self::Resets + | Self::PredictLinear { .. } + | Self::DoubleExpSmoothing { .. } => DataModel::TimeSeries, _ => DataModel::Any, } } @@ -104,7 +144,18 @@ impl AggIntent { /// `rate`/`increase` carry their window in the intent. (Cross-series /// reductions like `sum`/`avg` over a series set return `false`.) pub fn is_per_series(&self) -> bool { - matches!(self, Self::Rate | Self::Increase) + matches!( + self, + Self::Rate + | Self::Increase + | Self::Changes + | Self::Delta + | Self::IDelta + | Self::Deriv + | Self::Resets + | Self::PredictLinear { .. } + | Self::DoubleExpSmoothing { .. } + ) } /// The positional input column this intent reduces, if it carries one. @@ -147,6 +198,20 @@ impl AggIntent { AggIntent::Cardinality { .. } => col("cardinality", DataType::Int64, false), AggIntent::Rate => col("rate", DataType::Float64, false), AggIntent::Increase => col("increase", DataType::Float64, false), + // Counter-derivative range functions (issue #44) — all yield one + // float per series (PromQL values are float64), named after the + // function so consumers can locate the column without an alias. + AggIntent::Changes => col("changes", DataType::Float64, false), + AggIntent::Delta => col("delta", DataType::Float64, false), + AggIntent::IDelta => col("idelta", DataType::Float64, false), + AggIntent::Deriv => col("deriv", DataType::Float64, false), + AggIntent::Resets => col("resets", DataType::Float64, false), + AggIntent::PredictLinear { .. } => { + col("predict_linear", DataType::Float64, false) + } + AggIntent::DoubleExpSmoothing { .. } => { + col("double_exponential_smoothing", DataType::Float64, false) + } } } } diff --git a/crates/core/src/intent_algebra/lower.rs b/crates/core/src/intent_algebra/lower.rs index 2d6b547c..ce168caf 100644 --- a/crates/core/src/intent_algebra/lower.rs +++ b/crates/core/src/intent_algebra/lower.rs @@ -521,6 +521,19 @@ fn agg_func_to_intent(func: &AggFunc, acc: &AccuracyTarget, col: Option AggIntent::Rate, AggFunc::Increase { .. } => AggIntent::Increase, + // Counter-derivative range functions (issue #44) — the window rides on + // the enclosing TimeRange node; scalar params (predict horizon, + // smoothing factors) are carried in the intent. + AggFunc::Changes => AggIntent::Changes, + AggFunc::Delta => AggIntent::Delta, + AggFunc::IDelta => AggIntent::IDelta, + AggFunc::Deriv => AggIntent::Deriv, + AggFunc::Resets => AggIntent::Resets, + AggFunc::PredictLinear { seconds } => AggIntent::PredictLinear { seconds: *seconds }, + AggFunc::DoubleExpSmoothing { smoothing, trend } => AggIntent::DoubleExpSmoothing { + smoothing: *smoothing, + trend: *trend, + }, } } diff --git a/crates/core/src/intent_algebra/relational.rs b/crates/core/src/intent_algebra/relational.rs index ecbdd3bd..0f5a1979 100644 --- a/crates/core/src/intent_algebra/relational.rs +++ b/crates/core/src/intent_algebra/relational.rs @@ -101,6 +101,31 @@ pub enum AggFunc { Increase { window: Duration, }, + + // ── Counter-derivative range functions (issue #44) ─────────────────── + // Per-series range reductions; the window rides on the enclosing L2 + // `Window` node (like `*_over_time`), so — unlike `Rate`/`Increase` — + // these variants do NOT carry it. + /// PromQL `changes(v[w])` → `AggIntent::Changes`. + Changes, + /// PromQL `delta(v[w])` → `AggIntent::Delta`. + Delta, + /// PromQL `idelta(v[w])` → `AggIntent::IDelta`. + IDelta, + /// PromQL `deriv(v[w])` → `AggIntent::Deriv`. + Deriv, + /// PromQL `resets(v[w])` → `AggIntent::Resets`. + Resets, + /// PromQL `predict_linear(v[w], t)` → `AggIntent::PredictLinear`. + PredictLinear { + seconds: f64, + }, + /// PromQL `double_exponential_smoothing(v[w], sf, tf)` (a.k.a. + /// `holt_winters`) → `AggIntent::DoubleExpSmoothing`. + DoubleExpSmoothing { + smoothing: f64, + trend: f64, + }, } /// The Layer-2 relational query IR. diff --git a/crates/lower/src/promql.rs b/crates/lower/src/promql.rs index 15828c9a..38088be3 100644 --- a/crates/lower/src/promql.rs +++ b/crates/lower/src/promql.rs @@ -23,7 +23,8 @@ //! | `count_over_time(m[w])` | `Aggregate{[Count], Window{w}}` | //! | `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` / `resets` / `group` / `offset` / `@` | **rejected** — distinct semantics with no intent-algebra representation yet | +//! | `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` | +//! | `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`) | //! | `topk(k, count_over_time(…))` | `TopK{k, by}` (heavy-hitter intent) | @@ -86,6 +87,16 @@ enum InnerFunc { Count, Rate(Duration), Increase(Duration), + // Counter-derivative range functions (issue #44). The window rides on the + // enclosing L2 `Window` node (like `*_over_time`), so these carry only + // their non-window scalar params. + Changes, + Delta, + IDelta, + Deriv, + Resets, + PredictLinear(f64), + DoubleExp { smoothing: f64, trend: f64 }, } struct Inner { @@ -480,9 +491,37 @@ fn lower_inner_call(call: &Call) -> Result { "stddev_over_time" => at0(InnerFunc::StdDev), "stdvar_over_time" => at0(InnerFunc::Variance), "count_over_time" => at0(InnerFunc::Count), - // `changes` (value changes) and `resets` (counter resets) are NOT - // sample counts — aliasing them to `count_over_time` silently produced - // the wrong number. Reject until they have distinct intents. + // Counter-derivative range functions (issue #44). Each has its own + // intent — `changes` (value-change count) and `resets` (counter-reset + // count) are NOT sample counts, so they are not aliased to + // `count_over_time`. The window is arg 0's matrix; scalar params follow. + "changes" => at0(InnerFunc::Changes), + "delta" => at0(InnerFunc::Delta), + "idelta" => at0(InnerFunc::IDelta), + "deriv" => at0(InnerFunc::Deriv), + "resets" => at0(InnerFunc::Resets), + "predict_linear" => { + let (metric, matchers, window) = extract_matrix(arg(call, 0)?)?; + let seconds = num_arg(call, 1)?; + Ok(Inner { + metric, + matchers, + window: Some(window), + func: Some(InnerFunc::PredictLinear(seconds)), + }) + } + // `holt_winters` is the legacy spelling of `double_exponential_smoothing`. + "double_exponential_smoothing" | "holt_winters" => { + let (metric, matchers, window) = extract_matrix(arg(call, 0)?)?; + let smoothing = num_arg(call, 1)?; + let trend = num_arg(call, 2)?; + Ok(Inner { + metric, + matchers, + window: Some(window), + func: Some(InnerFunc::DoubleExp { smoothing, trend }), + }) + } other => Err(LoweringError::UnsupportedFunction(other.to_string())), } } @@ -647,6 +686,16 @@ fn inner_func(f: &InnerFunc) -> AggFunc { InnerFunc::Count => AggFunc::Count, InnerFunc::Rate(w) => AggFunc::Rate { window: *w }, InnerFunc::Increase(w) => AggFunc::Increase { window: *w }, + InnerFunc::Changes => AggFunc::Changes, + InnerFunc::Delta => AggFunc::Delta, + InnerFunc::IDelta => AggFunc::IDelta, + InnerFunc::Deriv => AggFunc::Deriv, + InnerFunc::Resets => AggFunc::Resets, + InnerFunc::PredictLinear(s) => AggFunc::PredictLinear { seconds: *s }, + InnerFunc::DoubleExp { smoothing, trend } => AggFunc::DoubleExpSmoothing { + smoothing: *smoothing, + trend: *trend, + }, } } diff --git a/crates/lower/tests/awesome_prometheus_alerts.rs b/crates/lower/tests/awesome_prometheus_alerts.rs index fb23fb34..04405384 100644 --- a/crates/lower/tests/awesome_prometheus_alerts.rs +++ b/crates/lower/tests/awesome_prometheus_alerts.rs @@ -263,27 +263,35 @@ fn absent_function_is_rejected__GAP() { } #[test] -fn changes_function_is_rejected__GAP() { - // `changes(process_start_time_seconds{…}[15m]) > 2` — restart-detection. - // `changes` is not a sample count, so it is rejected (not aliased to count). - let _ = rejected( - r#"changes(process_start_time_seconds{job=~"prometheus|pushgateway|alertmanager"}[15m]) > 2"#, +fn changes_function_body_lowers_to_changes_intent() { + // `changes(process_start_time_seconds{…}[15m])` — restart-detection. Lowers + // to the `Changes` intent (issue #44), NOT aliased to a sample count. The + // full alert `changes(...) > 2` still needs the scalar-threshold operand + // (#35) — pinned by `scalar_threshold_comparisons_are_rejected__GAP`. + let qe = ok( + r#"changes(process_start_time_seconds{job=~"prometheus|pushgateway|alertmanager"}[15m])"#, ); + assert!(intents(&qe).iter().any(|i| matches!(i, AggIntent::Changes))); } #[test] -fn delta_function_is_rejected__GAP() { - // `delta(systemd_socket_refused_connections_total[5m]) > 3` — host alerts. - let _ = rejected("delta(systemd_socket_refused_connections_total[5m]) > 3"); +fn delta_function_body_lowers_to_delta_intent() { + // `delta(systemd_socket_refused_connections_total[5m])` — host alerts. + let qe = ok("delta(systemd_socket_refused_connections_total[5m])"); + assert!(intents(&qe).iter().any(|i| matches!(i, AggIntent::Delta))); } #[test] -fn predict_linear_function_is_rejected__GAP() { - // The canonical "disk will fill in 24h" alert. `predict_linear` has no - // intent representation yet. - let _ = rejected( - r#"predict_linear(node_filesystem_avail_bytes{fstype!~"^(fuse.*|tmpfs|cifs|nfs)"}[3h], 86400) <= 0 and node_filesystem_avail_bytes > 0"#, +fn predict_linear_function_body_lowers_with_horizon() { + // The canonical "disk will fill in 24h" alert body. `predict_linear` lowers + // to `PredictLinear { seconds }` (issue #44); the full `... <= 0 and ...` + // alert still needs the scalar operand (#35). + let qe = ok( + r#"predict_linear(node_filesystem_avail_bytes{fstype!~"^(fuse.*|tmpfs|cifs|nfs)"}[3h], 86400)"#, ); + assert!(intents(&qe) + .iter() + .any(|i| matches!(i, AggIntent::PredictLinear { seconds } if (*seconds - 86400.0).abs() < 1e-9))); } #[test] diff --git a/crates/lower/tests/promql_conformance.rs b/crates/lower/tests/promql_conformance.rs index 05cfbf28..6d69e949 100644 --- a/crates/lower/tests/promql_conformance.rs +++ b/crates/lower/tests/promql_conformance.rs @@ -735,15 +735,80 @@ fn unsupported_functions_are_rejected() { "timestamp(up)", "absent(up)", "absent_over_time(up[5m])", - "deriv(demo_disk_usage_bytes[1h])", - "delta(demo_disk_usage_bytes[1h])", - "predict_linear(demo_disk_usage_bytes[4h], 3600)", r#"label_replace(up, "host", "$1", "instance", "(.+):.*")"#, "clamp_max(go_goroutines, 5)", - // changes / resets are NOT sample counts (formerly aliased to Count). - "changes(demo_disk_usage_bytes[1h])", - "resets(http_requests_total[1h])", + // NOTE: changes / delta / deriv / resets / predict_linear / + // double_exponential_smoothing now lower to distinct counter-derivative + // intents (issue #44) — see section M below. ] { let _ = rejected(q); } } + +// ───────────────────────────────────────────────────────────────────────────── +// M. Counter-derivative range functions (functions.test; issue #44) +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn counter_derivative_functions_lower_to_distinct_intents() { + // Each range function reduces one series' window to one value per series + // (label-preserving), riding on a `TimeRange`, and carries its OWN intent — + // deliberately not aliased to rate/increase/count. + for (q, want) in [ + ("changes(m[15m])", AggIntent::Changes), + ("delta(m[5m])", AggIntent::Delta), + ("idelta(m[5m])", AggIntent::IDelta), + ("deriv(m[1h])", AggIntent::Deriv), + ("resets(m[1h])", AggIntent::Resets), + ] { + let qe = ok(q); + let QueryExpr::Aggregate { by, aggs, child, .. } = &qe else { + panic!("expected an Aggregate for {q:?}, got {qe:?}"); + }; + assert!(by.is_empty(), "{q}: per-series, no grouping"); + assert_eq!(aggs.as_slice(), std::slice::from_ref(&want), "{q}: wrong intent"); + assert!( + matches!(child.as_ref(), QueryExpr::TimeRange { .. }), + "{q}: reduction rides on a TimeRange, got {child:?}" + ); + } +} + +#[test] +fn predict_linear_carries_horizon_seconds() { + // `predict_linear(v[w], t)` — the 2nd (scalar) arg is the prediction horizon + // in seconds; it must be carried in the intent (it changes the result). + let qe = ok("predict_linear(node_filesystem_avail_bytes[3h], 86400)"); + let QueryExpr::Aggregate { aggs, child, .. } = &qe else { + panic!("expected an Aggregate, got {qe:?}"); + }; + assert_eq!(aggs.as_slice(), &[AggIntent::PredictLinear { seconds: 86400.0 }]); + assert!(matches!(child.as_ref(), QueryExpr::TimeRange { .. })); +} + +#[test] +fn double_exponential_smoothing_carries_factors_and_holt_winters_is_an_alias() { + // Both spellings lower to the same intent carrying the two smoothing factors. + let want = AggIntent::DoubleExpSmoothing { + smoothing: 0.5, + trend: 0.3, + }; + let a = ok("double_exponential_smoothing(m[10m], 0.5, 0.3)"); + let b = ok("holt_winters(m[10m], 0.5, 0.3)"); + assert_eq!(intents(&a).as_slice(), std::slice::from_ref(&want)); + assert_eq!(a, b, "holt_winters is the legacy alias of double_exponential_smoothing"); +} + +#[test] +fn aggregation_over_counter_derivative_keeps_labels() { + // A counter-derivative is per-series (label-preserving), so an outer + // `sum by (job)` can group on a label the inner `changes` preserved. + let qe = ok(r#"sum by (job) (changes(m{job="api"}[15m]))"#); + let QueryExpr::Aggregate { by, aggs, child, .. } = &qe else { + panic!("expected outer Aggregate, got {qe:?}"); + }; + assert!(!by.is_empty(), "outer sum groups on job"); + assert!(matches!(aggs.as_slice(), [AggIntent::Sum { .. }])); + assert!(intents(&qe).iter().any(|i| matches!(i, AggIntent::Changes))); + let _ = child; +} diff --git a/crates/lower/tests/promql_equivalence.rs b/crates/lower/tests/promql_equivalence.rs index b8423a9e..2a96e37d 100644 --- a/crates/lower/tests/promql_equivalence.rs +++ b/crates/lower/tests/promql_equivalence.rs @@ -149,9 +149,12 @@ fn rate_and_irate_share_the_same_intent() { #[test] fn changes_and_resets_are_not_count_over_time() { // PromQL: count_over_time = #samples, changes = #value-changes, - // resets = #counter-resets. They previously all collapsed to `Count`. - assert_rejected("changes(m[5m])"); - assert_rejected("resets(m[5m])"); + // resets = #counter-resets. They previously all collapsed to `Count`; now + // each lowers to its own intent (issue #44), so all three are pairwise + // distinct L3 rather than being rejected or merged. + assert_distinct("changes(m[5m])", "count_over_time(m[5m])"); + assert_distinct("resets(m[5m])", "count_over_time(m[5m])"); + assert_distinct("changes(m[5m])", "resets(m[5m])"); } #[test] From c33afe2100a2f122d28e230c07e0b9af0d55c95e Mon Sep 17 00:00:00 2001 From: zz_y Date: Thu, 2 Jul 2026 09:34:04 -0600 Subject: [PATCH 2/2] test(lower): composition of counter-derivatives with outer/nested funcs (#44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin how the new counter-derivative intents compose with the rest of the lowering — they are per-series/label-preserving, so they reuse the general nesting path with no special-casing: - outer cross-series stat over a derivative nests two levels and threads the outer group key + any scalar param (`avg by (dc)(predict_linear(...))`) - `topk(k, deriv(...))` is generic Sort+Limit, not a heavy-hitter TopK - as a binary-op operand and under `sum(rate(...) + changes(...))` - counter-derivative over a sub-query stays cleanly rejected (a #42 follow-up) Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/lower/tests/promql_conformance.rs | 74 ++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/crates/lower/tests/promql_conformance.rs b/crates/lower/tests/promql_conformance.rs index 6d69e949..5d0f84ba 100644 --- a/crates/lower/tests/promql_conformance.rs +++ b/crates/lower/tests/promql_conformance.rs @@ -812,3 +812,77 @@ fn aggregation_over_counter_derivative_keeps_labels() { assert!(intents(&qe).iter().any(|i| matches!(i, AggIntent::Changes))); let _ = child; } + +#[test] +fn outer_stat_over_counter_derivative_nests_two_levels() { + // A cross-series stat over a counter-derivative is a genuine two-level + // reduction: the derivative runs per series (inner), the stat aggregates + // across series (outer). They must not collapse into one node — and a + // grouped outer (`avg by (dc)`) must resolve its key against the labels the + // inner reduction preserved, threading any scalar param (predict horizon). + let qe = ok("avg by (dc) (predict_linear(m[3h], 3600))"); + let QueryExpr::Aggregate { by, aggs, child, .. } = &qe else { + panic!("expected outer Aggregate, got {qe:?}"); + }; + assert!(!by.is_empty(), "outer `avg by (dc)` groups on a label"); + assert!(matches!(aggs.as_slice(), [AggIntent::Avg { .. }])); + let QueryExpr::Aggregate { by: inner_by, aggs: inner_aggs, .. } = child.as_ref() else { + panic!("expected inner per-series Aggregate, got {child:?}"); + }; + assert!(inner_by.is_empty(), "inner derivative stays per-series"); + assert_eq!( + inner_aggs.as_slice(), + std::slice::from_ref(&AggIntent::PredictLinear { seconds: 3600.0 }) + ); +} + +#[test] +fn topk_over_counter_derivative_is_generic_sort_limit() { + // `topk(k, deriv(...))` ranks the per-series derivative values — a generic + // `Sort + Limit`, NOT a heavy-hitter `TopK` (that's only `count_over_time`). + let qe = ok("topk(3, deriv(m[5m]))"); + let QueryExpr::Limit { n, child, .. } = &qe else { + panic!("expected Limit, got {qe:?}"); + }; + assert_eq!(*n, 3); + assert!(matches!(child.as_ref(), QueryExpr::Sort { .. })); + assert!(intents(&qe).iter().any(|i| matches!(i, AggIntent::Deriv))); + assert!( + !intents(&qe).iter().any(|i| matches!(i, AggIntent::TopK { .. })), + "counter-derivative topk is generic ranking, not a heavy-hitter sketch" + ); +} + +#[test] +fn counter_derivative_composes_in_binary_ops() { + // As a vector operand: `delta(a[5m]) / delta(b[5m])` is a BinaryOp of two + // per-series Delta reductions. + let ratio = ok("delta(a[5m]) / delta(b[5m])"); + let QueryExpr::BinaryOp { op, lhs, rhs, .. } = &ratio else { + panic!("expected BinaryOp, got {ratio:?}"); + }; + assert_eq!(*op, BinaryOpKind::Arith(ArithOp::Div)); + assert!(matches!(lhs.as_ref(), QueryExpr::Aggregate { aggs, .. } if aggs.as_slice() == [AggIntent::Delta])); + assert!(matches!(rhs.as_ref(), QueryExpr::Aggregate { aggs, .. } if aggs.as_slice() == [AggIntent::Delta])); + + // Under an aggregate over a binary op mixing a counter-derivative with + // another per-series function: `sum(rate(m[5m]) + changes(m[5m]))`. + let mixed = ok("sum(rate(m[5m]) + changes(m[5m]))"); + let QueryExpr::Aggregate { aggs, child, .. } = &mixed else { + panic!("expected Aggregate, got {mixed:?}"); + }; + assert!(matches!(aggs.as_slice(), [AggIntent::Sum { .. }])); + assert!(matches!(child.as_ref(), QueryExpr::BinaryOp { .. })); + assert!(intents(&mixed).iter().any(|i| matches!(i, AggIntent::Rate))); + assert!(intents(&mixed).iter().any(|i| matches!(i, AggIntent::Changes))); +} + +#[test] +fn counter_derivative_over_a_subquery_is_rejected__GAP() { + // Unlike `*_over_time` (issue #42), the counter-derivative functions do not + // yet accept a sub-query argument — only a bare matrix selector. This is + // valid PromQL and rejects cleanly (never mislowered); wiring them into the + // sub-query path is a follow-up to #42/#44. + let _ = rejected("changes(rate(m[5m])[1h:])"); + let _ = rejected("delta(sum(m)[5m:])"); +}