Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 67 additions & 2 deletions crates/core/src/intent_algebra/agg_intent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,54 @@ 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 {
/// Which data model this intent semantically requires. L4 rules consult
/// 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,
}
}
Expand All @@ -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.
Expand Down Expand Up @@ -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)
}
}
}
}
Expand Down
13 changes: 13 additions & 0 deletions crates/core/src/intent_algebra/lower.rs
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,19 @@ fn agg_func_to_intent(func: &AggFunc, acc: &AccuracyTarget, col: Option<ColumnId
// Range is on the enclosing TimeRange node; intent carries no window.
AggFunc::Rate { .. } => 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,
},
}
}

Expand Down
25 changes: 25 additions & 0 deletions crates/core/src/intent_algebra/relational.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
57 changes: 53 additions & 4 deletions crates/lower/src/promql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -480,9 +491,37 @@ fn lower_inner_call(call: &Call) -> Result<Inner> {
"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())),
}
}
Expand Down Expand Up @@ -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,
},
}
}

Expand Down
34 changes: 21 additions & 13 deletions crates/lower/tests/awesome_prometheus_alerts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading
Loading