From aa5b908fbcbed86f053c803c8f537ca009cee93a Mon Sep 17 00:00:00 2001 From: Colin Kelley Date: Sat, 18 Apr 2026 17:07:29 -0700 Subject: [PATCH 1/4] Align yrate/yincrease/ydelta with Prometheus 3.x range semantics Flip the yrate-family range convention from [start, end) to (start, end] so that a sample whose timestamp lands exactly on a range boundary is attributed to the later range, never to both or neither. This matches the convention adopted upstream in Prometheus 3.x (see prometheus/prometheus#13213). The xrate family, whose legacy [start, end] convention predates this decision, is unaffected. Behavior change is confined to samples that land precisely on a range boundary; all existing yrate test cases use shifted eval times to avoid boundary alignment, so only the "Comparison of rate vs xrate" showcase (which intentionally evaluates on boundaries at 25s and 75s) observes a difference. The linearity property yIncrease(p0) + yIncrease(p1) == yIncrease(p0 + p1) is preserved under the new semantics. Made-with: Cursor --- promql/functions.go | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/promql/functions.go b/promql/functions.go index 965e47e261d..69a06698747 100644 --- a/promql/functions.go +++ b/promql/functions.go @@ -338,7 +338,10 @@ func extendedRate(vals []parser.Value, args parser.Expressions, enh *EvalNodeHel // yIncrease is a utility function for yincrease/yrate/ydelta. // It calculates the increase of the range (allowing for counter resets if isCounter is true), // taking into account the sample at the end of the previous range (just before rangeStartMsec). -// It returns the result across the range [rangeStartMsec, rangeEndMsec). +// It returns the result across the range (rangeStartMsec, rangeEndMsec]. The left-open, +// right-closed convention matches the Prometheus 3.x range-selector semantics +// (see prometheus/prometheus#13213) so that a sample whose timestamp lands exactly on +// a range boundary is attributed to the later range, never to both or neither. // It always extends the preceding sample's value until the next sample, including the // unwritten origin sample value at the start of every time series. // @@ -356,8 +359,8 @@ func yIncrease(points []FPoint, rangeStartMsec, rangeEndMsec int64, isCounter bo // The points are in time order, so we can just walk the list once and remember the last values // seen "before" and "in" range. If there are no values in range, we use the last value before range // so that the increase is 0. - for i := 0; i < len(points) && points[i].T < rangeEndMsec; i++ { // Only consider points in [rangeStartMsec, rangeEndMsec). - if points[i].T >= rangeStartMsec { + for i := 0; i < len(points) && points[i].T <= rangeEndMsec; i++ { // Only consider points in (rangeStartMsec, rangeEndMsec]. + if points[i].T > rangeStartMsec { if isCounter && points[i].F < lastInRange { // Counter reset (process restart). inRangeRestartSkew += lastInRange } @@ -372,8 +375,8 @@ func yIncrease(points []FPoint, rangeStartMsec, rangeEndMsec int64, isCounter bo // rangeFromSelectors extracts points, rangeStartMsec, rangeEndMsec, and rangeSeconds // from the common (Matrix, MatrixSelector) arguments supplied to yincrease/yrate/ydelta. -// The range is [rangeStartMsec, rangeEndMsec). That is, every sample in range has the property: -// rangeStartMsec <= sample.T < rangeEndMsec. +// The range is (rangeStartMsec, rangeEndMsec]. That is, every sample in range has the property: +// rangeStartMsec < sample.T <= rangeEndMsec. func rangeFromSelectors(vals []parser.Value, args parser.Expressions, enh *EvalNodeHelper) ([]FPoint, int64, int64, float64) { ms := args[0].(*parser.MatrixSelector) vs := ms.VectorSelector.(*parser.VectorSelector) From 102be3a88ed72aef2c47c801b409e52a94b56d37 Mon Sep 17 00:00:00 2001 From: Colin Kelley Date: Sat, 18 Apr 2026 17:07:29 -0700 Subject: [PATCH 2/4] Refresh yrate showcase expectations for (start, end] semantics Three values in the "Comparison of rate vs xrate" showcase change under the new yrate range convention, all on boundary-aligned evals: eval 25s yrate[50s] /bar: 0.1 -> 0.12 (sample at t=25 now in range) eval 75s yrate[50s] /foo: 0.06 -> 0.02 (sample at t=25 now attributed eval 75s yrate[50s] /bar: 0.22 -> 0.1 to the prior range) These changes are the intended effect of attributing a boundary sample to the later range rather than the earlier one. All other yrate/ yincrease/ydelta cases (big 1000+/2000+ block, counter-reset block, ydelta block) use shifted eval times and are unaffected. Made-with: Cursor --- promql/promqltest/testdata/functions.test | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/promql/promqltest/testdata/functions.test b/promql/promqltest/testdata/functions.test index cfe32a14526..8c0e583ece4 100644 --- a/promql/promqltest/testdata/functions.test +++ b/promql/promqltest/testdata/functions.test @@ -20,7 +20,7 @@ eval instant at 25s xrate(http_requests[50s]) eval instant at 25s yrate(http_requests[50s]) {path="/foo"} 0.04 - {path="/bar"} 0.1 + {path="/bar"} 0.12 # 2. Eval 1 second earlier compared to (1). # * path="/foo" rate should be same or fractionally higher ("shorter" sample, same actual increase); @@ -69,8 +69,8 @@ eval instant at 75s xrate(http_requests[50s]) {path="/bar"} .1 eval instant at 75s yrate(http_requests[50s]) - {path="/foo"} 0.06 - {path="/bar"} 0.22 + {path="/foo"} 0.02 + {path="/bar"} 0.1 # 5. Eval 1s earlier compared to (4). # * path="/foo" rate should be same or fractionally lower ("longer" sample, same actual increase). From 890d29539a00357b9b1a03ae24e1c788b0e6c708 Mon Sep 17 00:00:00 2001 From: Colin Kelley Date: Sun, 19 Apr 2026 15:38:39 -0700 Subject: [PATCH 3/4] Add linearity-invariant tests for yincrease The yrate/yincrease/ydelta family is designed so that two adjacent windows over a series partition a wider window without double-counting any sample. Restated as an invariant: for any three timestamps T_0 < T_1 < T_2 with r_1 = T_1 - T_0 and r_2 = T_2 - T_1, yincrease(m[r_1]) @ T_1 + yincrease(m[r_2]) @ T_2 == yincrease(m[r_1 + r_2]) @ T_2 This property is what makes yincrease safe to aggregate across adjacent query ranges and what distinguishes it from stock rate/increase. Until now it was only covered implicitly through the 1000+/2000+ numeric regression block, which made silent semantic regressions plausible (e.g. a boundary flip that preserves individual queries' values but breaks additivity). Add three direct scenarios that assert LHS and RHS separately so any drift is visible side-by-side: 1. uniform counter, no resets 2. counter reset inside the earlier window 3. counter reset inside the later window All three pick T_0, T_1, T_2 off-cadence so no sample lands on a range boundary. That makes the expected values stable across both boundary conventions (left-inclusive on the add-yrate line, right-inclusive after align-yrate-to-3x-range-boundary) and lets the block cherry-pick cleanly up the yrate branch stack. Made-with: Cursor --- promql/promqltest/testdata/functions.test | 62 +++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/promql/promqltest/testdata/functions.test b/promql/promqltest/testdata/functions.test index 8c0e583ece4..69458d492d7 100644 --- a/promql/promqltest/testdata/functions.test +++ b/promql/promqltest/testdata/functions.test @@ -528,6 +528,68 @@ eval instant at 29m ydelta(http_requests[5m]) clear +# Linearity invariant for the yrate/yincrease/ydelta family. +# +# Because these functions evaluate over a half-open range (left-inclusive on +# the 2.53/2.55 add-yrate line, right-inclusive after align-yrate-to-3x-range-boundary), +# two adjacent windows partition a wider one without double-counting any sample. +# For any three timestamps T_0 < T_1 < T_2 and range durations r_1 = T_1 - T_0, +# r_2 = T_2 - T_1: +# +# yincrease(m[r_1]) @ T_1 + yincrease(m[r_2]) @ T_2 == yincrease(m[r_1 + r_2]) @ T_2 +# +# Each scenario below picks T_0, T_1, T_2 off-cadence (no sample lands on a range +# boundary) so the expected values are identical under both boundary conventions; +# this block should cherry-pick cleanly across the yrate branch stack. + +# Scenario 1: uniform counter, no resets. T_0=5s, T_1=35s, T_2=75s. +load 10s + linearity_uniform{job="api"} 0+10x10 + +eval instant at 35s yincrease(linearity_uniform[30s]) + {job="api"} 30 + +eval instant at 75s yincrease(linearity_uniform[40s]) + {job="api"} 40 + +eval instant at 75s yincrease(linearity_uniform[70s]) + {job="api"} 70 +# 30 + 40 == 70 + +clear + +# Scenario 2: counter reset in the earlier window. +# Reset between t=40s (value 40) and t=50s (value 0). T_0=5s, T_1=65s, T_2=95s. +load 10s + linearity_reset_early{job="api"} 0 10 20 30 40 0 10 20 30 40 50 + +eval instant at 65s yincrease(linearity_reset_early[60s]) + {job="api"} 50 + +eval instant at 95s yincrease(linearity_reset_early[30s]) + {job="api"} 30 + +eval instant at 95s yincrease(linearity_reset_early[90s]) + {job="api"} 80 +# 50 + 30 == 80 + +clear + +# Scenario 3: counter reset in the later window. +# Reset between t=60s (value 60) and t=70s (value 0). T_0=5s, T_1=45s, T_2=95s. +load 10s + linearity_reset_late{job="api"} 0 10 20 30 40 50 60 0 10 20 30 + +eval instant at 45s yincrease(linearity_reset_late[40s]) + {job="api"} 40 + +eval instant at 95s yincrease(linearity_reset_late[50s]) + {job="api"} 40 + +eval instant at 95s yincrease(linearity_reset_late[90s]) + {job="api"} 80 +# 40 + 40 == 80 + # Tests for idelta(). load 5m http_requests{path="/foo"} 0 50 100 150 From 4bf7c401e55f2376a40382e2be5fb838f4b9f517 Mon Sep 17 00:00:00 2001 From: Colin Kelley Date: Mon, 11 May 2026 08:58:40 -0700 Subject: [PATCH 4/4] Switch yrate terminology: "linearity" -> "additivity" / "composability" Match the upstream-facing proposal vocabulary by retiring "linear" / "linearity" from yrate-related internal artifacts in favor of: * "additive over adjacent ranges" -- the precise mathematical anchor (finite additivity over a partition of disjoint intervals) * "composable" -- the user-facing benefit derived from additivity (already the term PROM-52 names as a goal for `anchored`) Two atomic edits, no behavior change: * promql/functions.go: yIncrease docstring now says "additive over adjacent periods, and therefore composable across any partitioning of a wider range into contiguous sub-ranges". The formula stays the same. * promql/promqltest/testdata/functions.test: rename the test-data block header from "Linearity invariant" to "Additivity invariant", expand the introduction to mention composability, and rename the three test series from linearity_{uniform,reset_early,reset_late} to additivity_{uniform,reset_early,reset_late}. Strict "linearity" overclaimed (the property at issue is finite additivity over a partition of adjacent intervals, not full linearity over a vector space) and the two-term split keeps the proof anchor distinct from the user-facing benefit. Unrelated upstream references to "linear" (predict_linear, linearRegression, "linear search" complexity comments) are left untouched. Co-authored-by: Cursor --- promql/functions.go | 5 ++-- promql/promqltest/testdata/functions.test | 34 ++++++++++++----------- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/promql/functions.go b/promql/functions.go index 69a06698747..d98ffe4faed 100644 --- a/promql/functions.go +++ b/promql/functions.go @@ -345,8 +345,9 @@ func extendedRate(vals []parser.Value, args parser.Expressions, enh *EvalNodeHel // It always extends the preceding sample's value until the next sample, including the // unwritten origin sample value at the start of every time series. // -// It is a linear function, meaning that for adjacent periods p0 and p1 -// ("adjacent" means p0's rangeEndMsec == p1's rangeStartMsec): +// It is additive over adjacent periods, and therefore composable across any +// partitioning of a wider range into contiguous sub-ranges. For adjacent periods +// p0 and p1 ("adjacent" means p0's rangeEndMsec == p1's rangeStartMsec): // // yIncrease(p0) + yIncrease(p1) == yIncrease(p0 + p1) func yIncrease(points []FPoint, rangeStartMsec, rangeEndMsec int64, isCounter bool) float64 { diff --git a/promql/promqltest/testdata/functions.test b/promql/promqltest/testdata/functions.test index 69458d492d7..9ca78fdb549 100644 --- a/promql/promqltest/testdata/functions.test +++ b/promql/promqltest/testdata/functions.test @@ -528,11 +528,13 @@ eval instant at 29m ydelta(http_requests[5m]) clear -# Linearity invariant for the yrate/yincrease/ydelta family. +# Additivity invariant for the yrate/yincrease/ydelta family. # -# Because these functions evaluate over a half-open range (left-inclusive on -# the 2.53/2.55 add-yrate line, right-inclusive after align-yrate-to-3x-range-boundary), -# two adjacent windows partition a wider one without double-counting any sample. +# These functions are additive over adjacent ranges -- which is what makes them +# composable across any partitioning of a wider range into contiguous sub-ranges. +# Because they evaluate over a half-open range (left-inclusive on the 2.53/2.55 +# add-yrate line, right-inclusive after align-yrate-to-3x-range-boundary), two +# adjacent windows partition a wider one without double-counting any sample. # For any three timestamps T_0 < T_1 < T_2 and range durations r_1 = T_1 - T_0, # r_2 = T_2 - T_1: # @@ -544,15 +546,15 @@ clear # Scenario 1: uniform counter, no resets. T_0=5s, T_1=35s, T_2=75s. load 10s - linearity_uniform{job="api"} 0+10x10 + additivity_uniform{job="api"} 0+10x10 -eval instant at 35s yincrease(linearity_uniform[30s]) +eval instant at 35s yincrease(additivity_uniform[30s]) {job="api"} 30 -eval instant at 75s yincrease(linearity_uniform[40s]) +eval instant at 75s yincrease(additivity_uniform[40s]) {job="api"} 40 -eval instant at 75s yincrease(linearity_uniform[70s]) +eval instant at 75s yincrease(additivity_uniform[70s]) {job="api"} 70 # 30 + 40 == 70 @@ -561,15 +563,15 @@ clear # Scenario 2: counter reset in the earlier window. # Reset between t=40s (value 40) and t=50s (value 0). T_0=5s, T_1=65s, T_2=95s. load 10s - linearity_reset_early{job="api"} 0 10 20 30 40 0 10 20 30 40 50 + additivity_reset_early{job="api"} 0 10 20 30 40 0 10 20 30 40 50 -eval instant at 65s yincrease(linearity_reset_early[60s]) +eval instant at 65s yincrease(additivity_reset_early[60s]) {job="api"} 50 -eval instant at 95s yincrease(linearity_reset_early[30s]) +eval instant at 95s yincrease(additivity_reset_early[30s]) {job="api"} 30 -eval instant at 95s yincrease(linearity_reset_early[90s]) +eval instant at 95s yincrease(additivity_reset_early[90s]) {job="api"} 80 # 50 + 30 == 80 @@ -578,15 +580,15 @@ clear # Scenario 3: counter reset in the later window. # Reset between t=60s (value 60) and t=70s (value 0). T_0=5s, T_1=45s, T_2=95s. load 10s - linearity_reset_late{job="api"} 0 10 20 30 40 50 60 0 10 20 30 + additivity_reset_late{job="api"} 0 10 20 30 40 50 60 0 10 20 30 -eval instant at 45s yincrease(linearity_reset_late[40s]) +eval instant at 45s yincrease(additivity_reset_late[40s]) {job="api"} 40 -eval instant at 95s yincrease(linearity_reset_late[50s]) +eval instant at 95s yincrease(additivity_reset_late[50s]) {job="api"} 40 -eval instant at 95s yincrease(linearity_reset_late[90s]) +eval instant at 95s yincrease(additivity_reset_late[90s]) {job="api"} 80 # 40 + 40 == 80