@@ -812,3 +812,77 @@ fn aggregation_over_counter_derivative_keeps_labels() {
812812 assert ! ( intents( & qe) . iter( ) . any( |i| matches!( i, AggIntent :: Changes ) ) ) ;
813813 let _ = child;
814814}
815+
816+ #[ test]
817+ fn outer_stat_over_counter_derivative_nests_two_levels ( ) {
818+ // A cross-series stat over a counter-derivative is a genuine two-level
819+ // reduction: the derivative runs per series (inner), the stat aggregates
820+ // across series (outer). They must not collapse into one node — and a
821+ // grouped outer (`avg by (dc)`) must resolve its key against the labels the
822+ // inner reduction preserved, threading any scalar param (predict horizon).
823+ let qe = ok ( "avg by (dc) (predict_linear(m[3h], 3600))" ) ;
824+ let QueryExpr :: Aggregate { by, aggs, child, .. } = & qe else {
825+ panic ! ( "expected outer Aggregate, got {qe:?}" ) ;
826+ } ;
827+ assert ! ( !by. is_empty( ) , "outer `avg by (dc)` groups on a label" ) ;
828+ assert ! ( matches!( aggs. as_slice( ) , [ AggIntent :: Avg { .. } ] ) ) ;
829+ let QueryExpr :: Aggregate { by : inner_by, aggs : inner_aggs, .. } = child. as_ref ( ) else {
830+ panic ! ( "expected inner per-series Aggregate, got {child:?}" ) ;
831+ } ;
832+ assert ! ( inner_by. is_empty( ) , "inner derivative stays per-series" ) ;
833+ assert_eq ! (
834+ inner_aggs. as_slice( ) ,
835+ std:: slice:: from_ref( & AggIntent :: PredictLinear { seconds: 3600.0 } )
836+ ) ;
837+ }
838+
839+ #[ test]
840+ fn topk_over_counter_derivative_is_generic_sort_limit ( ) {
841+ // `topk(k, deriv(...))` ranks the per-series derivative values — a generic
842+ // `Sort + Limit`, NOT a heavy-hitter `TopK` (that's only `count_over_time`).
843+ let qe = ok ( "topk(3, deriv(m[5m]))" ) ;
844+ let QueryExpr :: Limit { n, child, .. } = & qe else {
845+ panic ! ( "expected Limit, got {qe:?}" ) ;
846+ } ;
847+ assert_eq ! ( * n, 3 ) ;
848+ assert ! ( matches!( child. as_ref( ) , QueryExpr :: Sort { .. } ) ) ;
849+ assert ! ( intents( & qe) . iter( ) . any( |i| matches!( i, AggIntent :: Deriv ) ) ) ;
850+ assert ! (
851+ !intents( & qe) . iter( ) . any( |i| matches!( i, AggIntent :: TopK { .. } ) ) ,
852+ "counter-derivative topk is generic ranking, not a heavy-hitter sketch"
853+ ) ;
854+ }
855+
856+ #[ test]
857+ fn counter_derivative_composes_in_binary_ops ( ) {
858+ // As a vector operand: `delta(a[5m]) / delta(b[5m])` is a BinaryOp of two
859+ // per-series Delta reductions.
860+ let ratio = ok ( "delta(a[5m]) / delta(b[5m])" ) ;
861+ let QueryExpr :: BinaryOp { op, lhs, rhs, .. } = & ratio else {
862+ panic ! ( "expected BinaryOp, got {ratio:?}" ) ;
863+ } ;
864+ assert_eq ! ( * op, BinaryOpKind :: Arith ( ArithOp :: Div ) ) ;
865+ assert ! ( matches!( lhs. as_ref( ) , QueryExpr :: Aggregate { aggs, .. } if aggs. as_slice( ) == [ AggIntent :: Delta ] ) ) ;
866+ assert ! ( matches!( rhs. as_ref( ) , QueryExpr :: Aggregate { aggs, .. } if aggs. as_slice( ) == [ AggIntent :: Delta ] ) ) ;
867+
868+ // Under an aggregate over a binary op mixing a counter-derivative with
869+ // another per-series function: `sum(rate(m[5m]) + changes(m[5m]))`.
870+ let mixed = ok ( "sum(rate(m[5m]) + changes(m[5m]))" ) ;
871+ let QueryExpr :: Aggregate { aggs, child, .. } = & mixed else {
872+ panic ! ( "expected Aggregate, got {mixed:?}" ) ;
873+ } ;
874+ assert ! ( matches!( aggs. as_slice( ) , [ AggIntent :: Sum { .. } ] ) ) ;
875+ assert ! ( matches!( child. as_ref( ) , QueryExpr :: BinaryOp { .. } ) ) ;
876+ assert ! ( intents( & mixed) . iter( ) . any( |i| matches!( i, AggIntent :: Rate ) ) ) ;
877+ assert ! ( intents( & mixed) . iter( ) . any( |i| matches!( i, AggIntent :: Changes ) ) ) ;
878+ }
879+
880+ #[ test]
881+ fn counter_derivative_over_a_subquery_is_rejected__GAP ( ) {
882+ // Unlike `*_over_time` (issue #42), the counter-derivative functions do not
883+ // yet accept a sub-query argument — only a bare matrix selector. This is
884+ // valid PromQL and rejects cleanly (never mislowered); wiring them into the
885+ // sub-query path is a follow-up to #42/#44.
886+ let _ = rejected ( "changes(rate(m[5m])[1h:])" ) ;
887+ let _ = rejected ( "delta(sum(m)[5m:])" ) ;
888+ }
0 commit comments