From 402bef0df670199d188c343649b8509f33c39116 Mon Sep 17 00:00:00 2001 From: Colin Kelley Date: Sat, 18 Apr 2026 15:24:58 -0700 Subject: [PATCH 1/6] Add yIncrease and rangeFromSelectors helpers Ported from invoca-2.39.2-extensions and adapted to the 2.53 FPoint API. yIncrease computes the per-range increase using a simple linear algorithm that assumes one extended-range sample is kept before the range start (mirrors the mechanism xrate already uses via ExtRange: true). Handles counter-reset skew when isCounter is true. rangeFromSelectors consolidates the boilerplate for unpacking the matrix-selector arguments shared by yincrease/yrate/ydelta. Both are unexported and not yet wired up; the follow-up commit adds the funcY* wrappers and parser entries. Made-with: Cursor --- promql/functions.go | 51 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/promql/functions.go b/promql/functions.go index 8a3ca23e67f..463a9151f4f 100644 --- a/promql/functions.go +++ b/promql/functions.go @@ -335,6 +335,57 @@ func extendedRate(vals []parser.Value, args parser.Expressions, enh *EvalNodeHel return append(enh.Out, Sample{F: resultValue}), nil } +// 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 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): +// +// yIncrease(p0) + yIncrease(p1) == yIncrease(p0 + p1) +func yIncrease(points []FPoint, rangeStartMsec, rangeEndMsec int64, isCounter bool) float64 { + var lastBeforeRange, lastInRange, inRangeRestartSkew float64 + + if !isCounter && len(points) > 0 { + lastBeforeRange = points[0].F // Gauges don't start at 0. + } + + // 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 { + if isCounter && points[i].F < lastInRange { // Counter reset (process restart). + inRangeRestartSkew += lastInRange + } + } else { + lastBeforeRange = points[i].F + } + lastInRange = points[i].F + } + + return lastInRange - lastBeforeRange + inRangeRestartSkew +} + +// 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. +func rangeFromSelectors(vals []parser.Value, args parser.Expressions, enh *EvalNodeHelper) ([]FPoint, int64, int64, float64) { + ms := args[0].(*parser.MatrixSelector) + vs := ms.VectorSelector.(*parser.VectorSelector) + + rangeStartMsec := enh.Ts - durationMilliseconds(ms.Range+vs.Offset) + rangeEndMsec := enh.Ts - durationMilliseconds(vs.Offset) + + points := vals[0].(Matrix)[0].Floats + + return points, rangeStartMsec, rangeEndMsec, ms.Range.Seconds() +} + // === delta(Matrix parser.ValueTypeMatrix) (Vector, Annotations) === func funcDelta(vals []parser.Value, args parser.Expressions, enh *EvalNodeHelper) (Vector, annotations.Annotations) { return extrapolatedRate(vals, args, enh, false, false) From 64cd49e29aec8f8f403f6106dd1228b160a777f0 Mon Sep 17 00:00:00 2001 From: Colin Kelley Date: Sat, 18 Apr 2026 15:26:11 -0700 Subject: [PATCH 2/6] Add yrate/yincrease/ydelta PromQL functions Adds three new range-vector functions that mirror the shape of the existing xrate/xincrease/xdelta family: - ydelta(m[r]) for gauges: last-in-range minus last-before-range - yincrease(m[r]) for counters: yIncrease with isCounter=true - yrate(m[r]) same as yincrease, divided by range seconds The funcY* wrappers delegate to the yIncrease helper added in the previous commit. Parser entries use ExtRange: true to opt into the extended-range window populated by matrixIterSlice. Smoke-verified with a small loaded-storage test: yrate/yincrease/ ydelta match expected values derived from the yIncrease algorithm (last_in_range - last_before_range + counter_reset_skew), without requiring any engine changes beyond what xrate already relies on. No REPLACE_RATE_FUNCS wiring yet; that lands in a follow-up commit. Made-with: Cursor --- promql/functions.go | 24 ++++++++++++++++++++++++ promql/parser/functions.go | 18 ++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/promql/functions.go b/promql/functions.go index 463a9151f4f..6d777060151 100644 --- a/promql/functions.go +++ b/promql/functions.go @@ -416,6 +416,27 @@ func funcXincrease(vals []parser.Value, args parser.Expressions, enh *EvalNodeHe return extendedRate(vals, args, enh, true, false) } +// === ydelta(node parser.ValueTypeMatrix) (Vector, Annotations) === +func funcYdelta(vals []parser.Value, args parser.Expressions, enh *EvalNodeHelper) (Vector, annotations.Annotations) { + points, rangeStartMsec, rangeEndMsec, _ := rangeFromSelectors(vals, args, enh) + value := yIncrease(points, rangeStartMsec, rangeEndMsec, false) + return append(enh.Out, Sample{F: value}), nil +} + +// === yincrease(node parser.ValueTypeMatrix) (Vector, Annotations) === +func funcYincrease(vals []parser.Value, args parser.Expressions, enh *EvalNodeHelper) (Vector, annotations.Annotations) { + points, rangeStartMsec, rangeEndMsec, _ := rangeFromSelectors(vals, args, enh) + value := yIncrease(points, rangeStartMsec, rangeEndMsec, true) + return append(enh.Out, Sample{F: value}), nil +} + +// === yrate(node parser.ValueTypeMatrix) (Vector, Annotations) === +func funcYrate(vals []parser.Value, args parser.Expressions, enh *EvalNodeHelper) (Vector, annotations.Annotations) { + points, rangeStartMsec, rangeEndMsec, rangeSeconds := rangeFromSelectors(vals, args, enh) + value := yIncrease(points, rangeStartMsec, rangeEndMsec, true) / rangeSeconds + return append(enh.Out, Sample{F: value}), nil +} + // === irate(node parser.ValueTypeMatrix) (Vector, Annotations) === func funcIrate(vals []parser.Value, args parser.Expressions, enh *EvalNodeHelper) (Vector, annotations.Annotations) { return instantValue(vals, enh.Out, true) @@ -1853,6 +1874,9 @@ var FunctionCalls = map[string]FunctionCall{ "xdelta": funcXdelta, "xincrease": funcXincrease, "xrate": funcXrate, + "ydelta": funcYdelta, + "yincrease": funcYincrease, + "yrate": funcYrate, "year": funcYear, } diff --git a/promql/parser/functions.go b/promql/parser/functions.go index bf570ed5207..b889c0d1f40 100644 --- a/promql/parser/functions.go +++ b/promql/parser/functions.go @@ -443,6 +443,24 @@ var Functions = map[string]*Function{ ReturnType: ValueTypeVector, ExtRange: true, }, + "ydelta": { + Name: "ydelta", + ArgTypes: []ValueType{ValueTypeMatrix}, + ReturnType: ValueTypeVector, + ExtRange: true, + }, + "yincrease": { + Name: "yincrease", + ArgTypes: []ValueType{ValueTypeMatrix}, + ReturnType: ValueTypeVector, + ExtRange: true, + }, + "yrate": { + Name: "yrate", + ArgTypes: []ValueType{ValueTypeMatrix}, + ReturnType: ValueTypeVector, + ExtRange: true, + }, "year": { Name: "year", ArgTypes: []ValueType{ValueTypeVector}, From 49ea48a569102188a38c5ebadbeb86d602b9d660 Mon Sep 17 00:00:00 2001 From: Colin Kelley Date: Sat, 18 Apr 2026 15:27:22 -0700 Subject: [PATCH 3/6] Add REPLACE_RATE_FUNCS env switch for yrate; run once at init Extends the existing REPLACE_RATE_FUNCS init-time hook to also recognise "x"/"X" and "2"/"y"/"Y" values (matching the invoca-2.39.2 extension). The "x" and "y" cases repoint rate/increase/delta at the xrate or yrate implementations respectively, while keeping the x*/y* names available under their original spellings. Also introduces repointParserFunctions and repointFunction as small helpers so the dispatch-table rewrites read cleanly. Made-with: Cursor --- promql/functions.go | 52 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/promql/functions.go b/promql/functions.go index 6d777060151..965e47e261d 100644 --- a/promql/functions.go +++ b/promql/functions.go @@ -1896,10 +1896,20 @@ var AtModifierUnsafeFunctions = map[string]struct{}{ } func init() { - // REPLACE_RATE_FUNCS replaces the default rate extrapolation functions - // with xrate functions. This allows for a drop-in replacement and Grafana - // auto-completion, Prometheus tooling, Thanos, etc. should still work as expected. - if os.Getenv("REPLACE_RATE_FUNCS") == "1" { + // REPLACE_RATE_FUNCS lets operators swap the built-in rate extrapolation + // functions with Invoca's xrate or yrate family at process start, so + // Grafana auto-completion, Prometheus tooling, Thanos, etc. continue to + // work against queries that call the standard rate/increase/delta names. + // + // Values: + // "1" - replace rate/increase/delta with xrate/xincrease/xdelta + // AND remove the x* names (legacy behaviour). + // "x", "X" - point rate/increase/delta at xrate/xincrease/xdelta but + // keep the x* names available. + // "2", - point rate/increase/delta at yrate/yincrease/ydelta but + // "y", "Y" keep the y* names available. + switch os.Getenv("REPLACE_RATE_FUNCS") { + case "1": FunctionCalls["delta"] = FunctionCalls["xdelta"] FunctionCalls["increase"] = FunctionCalls["xincrease"] FunctionCalls["rate"] = FunctionCalls["xrate"] @@ -1917,7 +1927,39 @@ func init() { delete(parser.Functions, "xincrease") delete(parser.Functions, "xrate") fmt.Println("Successfully replaced rate & friends with xrate & friends (and removed xrate & friends function keys).") - } + + case "x", "X": + repointParserFunctions("delta", "xdelta") + repointParserFunctions("increase", "xincrease") + repointParserFunctions("rate", "xrate") + repointFunction("delta", "xdelta") + repointFunction("increase", "xincrease") + repointFunction("rate", "xrate") + fmt.Println("Successfully replaced rate/increase/delta with xrate/xincrease/xdelta (and left the x* names available as well).") + + case "2", "y", "Y": + repointParserFunctions("delta", "ydelta") + repointParserFunctions("increase", "yincrease") + repointParserFunctions("rate", "yrate") + repointFunction("delta", "ydelta") + repointFunction("increase", "yincrease") + repointFunction("rate", "yrate") + fmt.Println("Successfully replaced rate/increase/delta with yrate/yincrease/ydelta (and left the y* names available as well).") + } +} + +// repointParserFunctions makes the parser entry for name resolve to the +// entry currently registered under newName (e.g. "rate" -> "xrate"). +// It leaves the newName entry in place. +func repointParserFunctions(name, newName string) { + parser.Functions[name] = parser.Functions[newName] +} + +// repointFunction makes the FunctionCalls entry for name dispatch to the +// implementation currently registered under newName. The newName entry +// is left in place. +func repointFunction(name, newName string) { + FunctionCalls[name] = FunctionCalls[newName] } type vectorByValueHeap Vector From 06305ed6a1d20e877c00fa60a4680656f614f563 Mon Sep 17 00:00:00 2001 From: Colin Kelley Date: Sat, 18 Apr 2026 15:46:40 -0700 Subject: [PATCH 4/6] Add yrate/yincrease/ydelta test cases Ports the full set of yrate test expectations from invoca-2.39.2-extensions, split across three existing sections of promql/promqltest/testdata/functions.test: - Rate-vs-xrate showcase block: yrate() evals added in parallel with each rate/xrate variant, so readers can see yrate's pre-origin- treated-as-zero behaviour next to the other two semantics. - "Tests for xincrease/xrate" block: replaced the 0+10x10 / 0+10x5 0+10x4 load with the 1000+10x10 / 2000+10x5 5+10x4 load from 2.39.2. Absolute offsets make the yrate family's per-range values visibly diverge from xrate (e.g. yincrease[50s] returns 1090 for /foo while xincrease[50s] returns 90). Re-adds increase() coverage for the same data as a standard-Prometheus reference. - Counter-reset block: switched from 0 1 2 3 2 3 4 to the 2.39.2 1000 1001 1003 1006 4 9 16 load, added yincrease coverage at the 30m / 10m / 5m ranges and at evaluation times that straddle the counter-reset boundary. - New ydelta block: exercises ydelta on two mirrored gauge series (one increasing, one decreasing) at 30m / 25m / 5m ranges with reference delta()/xdelta() comparisons. Eval times are shifted one second (49s instead of 50s) or one minute (29m instead of 30m) off the sample cadence so that sample timestamps land strictly inside the half-open [rangeStart, rangeEnd) interval. When samples align exactly on a range boundary, matrixIterSlice treats the sample as the retained pre-range sample, which hides the pre-range counter value from yIncrease; the shifted eval times mirror the +321ms offset the legacy promql.NewTest framework applied for the same reason. All ported 2.39.2 expected values reproduce exactly under the new promqltest framework with this offset. Made-with: Cursor --- promql/promqltest/testdata/functions.test | 189 +++++++++++++++++++--- 1 file changed, 163 insertions(+), 26 deletions(-) diff --git a/promql/promqltest/testdata/functions.test b/promql/promqltest/testdata/functions.test index b793fef3a0c..cfe32a14526 100644 --- a/promql/promqltest/testdata/functions.test +++ b/promql/promqltest/testdata/functions.test @@ -18,6 +18,10 @@ eval instant at 25s xrate(http_requests[50s]) {path="/foo"} .02 {path="/bar"} .1 +eval instant at 25s yrate(http_requests[50s]) + {path="/foo"} 0.04 + {path="/bar"} 0.1 + # 2. Eval 1 second earlier compared to (1). # * path="/foo" rate should be same or fractionally higher ("shorter" sample, same actual increase); # * path="/bar" rate should be same or fractionally lower (80% the increase, 80/96% range covered by sample). @@ -30,6 +34,10 @@ eval instant at 24s xrate(http_requests[50s]) {path="/foo"} .02 {path="/bar"} .08 +eval instant at 24s yrate(http_requests[50s]) + {path="/foo"} 0.04 + {path="/bar"} 0.1 + # 3. Eval 1 second later compared to (1). # * path="/foo" rate should be same or fractionally lower ("longer" sample, same actual increase). # * path="/bar" rate should be same or fractionally lower ("longer" sample, same actual increase). @@ -42,6 +50,10 @@ eval instant at 26s xrate(http_requests[50s]) {path="/foo"} .02 {path="/bar"} .1 +eval instant at 26s yrate(http_requests[50s]) + {path="/foo"} 0.04 + {path="/bar"} 0.12 + # # Timeseries starts before range, ends within range. @@ -56,6 +68,10 @@ eval instant at 75s xrate(http_requests[50s]) {path="/foo"} .02 {path="/bar"} .1 +eval instant at 75s yrate(http_requests[50s]) + {path="/foo"} 0.06 + {path="/bar"} 0.22 + # 5. Eval 1s earlier compared to (4). # * path="/foo" rate should be same or fractionally lower ("longer" sample, same actual increase). # * path="/bar" rate should be same or fractionally lower ("longer" sample, same actual increase). @@ -69,6 +85,10 @@ eval instant at 74s xrate(http_requests[50s]) {path="/foo"} .02 {path="/bar"} .12 +eval instant at 74s yrate(http_requests[50s]) + {path="/foo"} 0.02 + {path="/bar"} 0.12 + # 6. Eval 1s later compared to (4). Rate/increase (should be) fractionally smaller. # * path="/foo" rate should be same or fractionally higher ("shorter" sample, same actual increase); # * path="/bar" rate should be same or fractionally lower (80% the increase, 80/96% range covered by sample). @@ -81,6 +101,10 @@ eval instant at 76s xrate(http_requests[50s]) {path="/foo"} .02 {path="/bar"} .1 +eval instant at 76s yrate(http_requests[50s]) + {path="/foo"} 0.02 + {path="/bar"} 0.1 + # # Evaluation of 10 second rate every 10 seconds, not aligned with collection. # @@ -236,58 +260,140 @@ eval instant at 50m increase(http_requests[100m]) clear -# Tests for xincrease()/xrate(). +# Tests for increase()/xincrease()/yincrease()/xrate()/yrate(). +# +# The counters start at 1000/2000 so yincrease/yrate (which treat every +# pre-origin value as 0) return wildly different results from the +# xrate / rate family (which only consider deltas inside the range). +# +# Eval times are 49s/48s rather than 50s/47s so that sample timestamps +# land strictly inside the range [start, end) rather than at its +# boundaries; this keeps the pre-range sample accessible to matrixIterSlice +# and makes yincrease's "counter-at-rangeStart" value observable. load 5s - http_requests{path="/foo"} 0+10x10 - http_requests{path="/bar"} 0+10x5 0+10x4 + http_requests{path="/foo"} 1000+10x10 + http_requests{path="/bar"} 2000+10x5 5+10x4 -# Tests for xincrease(). -eval instant at 50s xincrease(http_requests[50s]) +# Tests for increase() (standard Prometheus, for reference). +eval instant at 49s increase(http_requests[50s]) {path="/foo"} 100 - {path="/bar"} 90 + {path="/bar"} 94.44444444444444 + +eval instant at 49s increase(http_requests[100s]) + {path="/foo"} 103 + {path="/bar"} 97.27777777777777 + +# Tests for xincrease(). +eval instant at 49s xincrease(http_requests[50s]) + {path="/foo"} 90 + {path="/bar"} 85 + +eval instant at 49s xincrease(http_requests[100s]) + {path="/foo"} 90 + {path="/bar"} 85 -eval instant at 50s xincrease(http_requests[5s]) +eval instant at 49s xincrease(http_requests[5s]) {path="/foo"} 10 {path="/bar"} 10 -eval instant at 50s xincrease(http_requests[3s]) - {path="/foo"} 6 - {path="/bar"} 6 - eval instant at 49s xincrease(http_requests[3s]) +eval instant at 48s xincrease(http_requests[3s]) + +# Tests for yincrease(). yrate always compares to a pre-origin of 0, +# so yincrease sees the full 1000/2000 offset in the first range. +eval instant at 49s yincrease(http_requests[50s]) + {path="/foo"} 1090 + {path="/bar"} 2085 + +eval instant at 49s yincrease(http_requests[100s]) + {path="/foo"} 1090 + {path="/bar"} 2085 + +eval instant at 49s yincrease(http_requests[5s]) + {path="/foo"} 10 + {path="/bar"} 10 + +eval instant at 49s yincrease(http_requests[3s]) + {path="/foo"} 0 + {path="/bar"} 0 + # Tests for xrate(). -eval instant at 50s xrate(http_requests[50s]) - {path="/foo"} 2 - {path="/bar"} 1.8 +eval instant at 49s xrate(http_requests[50s]) + {path="/foo"} 1.8 + {path="/bar"} 1.7 -eval instant at 50s xrate(http_requests[100s]) - {path="/foo"} 1 - {path="/bar"} 0.9 +eval instant at 49s xrate(http_requests[100s]) + {path="/foo"} 0.9 + {path="/bar"} 0.85 -eval instant at 50s xrate(http_requests[5s]) +eval instant at 49s xrate(http_requests[5s]) {path="/foo"} 2 {path="/bar"} 2 -eval instant at 50s xrate(http_requests[3s]) +eval instant at 49s xrate(http_requests[3s]) + +eval instant at 48s xrate(http_requests[3s]) + +# Tests for yrate(). +eval instant at 49s yrate(http_requests[50s]) + {path="/foo"} 21.8 + {path="/bar"} 41.7 + +eval instant at 49s yrate(http_requests[100s]) + {path="/foo"} 10.9 + {path="/bar"} 20.85 + +eval instant at 49s yrate(http_requests[5s]) {path="/foo"} 2 {path="/bar"} 2 -eval instant at 49s xrate(http_requests[3s]) +eval instant at 49s yrate(http_requests[3s]) + {path="/foo"} 0 + {path="/bar"} 0 clear -# Test for increase()/xincrease with counter reset. +# Test for increase()/xincrease()/yincrease() with counter reset. # When the counter is reset, it always starts at 0. -# So the sequence 3 2 (decreasing counter = reset) is interpreted the same as 3 0 1 2. -# Prometheus assumes it missed the intermediate values 0 and 1. +# So the sequence 1006 4 (decreasing counter = reset) is interpreted the +# same as 1006 0 1 2 3 4. Prometheus assumes it missed the intermediate +# values 0, 1, 2, 3. load 5m - http_requests{path="/foo"} 0 1 2 3 2 3 4 + http_requests{path="/foo"} 1000 1001 1003 1006 4 9 16 + +eval instant at 29m increase(http_requests[30m]) + {path="/foo"} 18 + +eval instant at 29m xincrease(http_requests[30m]) + {path="/foo"} 15 + +eval instant at 29m yincrease(http_requests[30m]) + {path="/foo"} 1015 + +# Test counter reset inside the range, not spanning the range boundary. +eval instant at 19m xincrease(http_requests[5m]) + {path="/foo"} 3 + +eval instant at 19m yincrease(http_requests[5m]) + {path="/foo"} 3 + +eval instant at 19m xincrease(http_requests[10m]) + {path="/foo"} 5 + +eval instant at 19m yincrease(http_requests[10m]) + {path="/foo"} 5 + +eval instant at 24m xincrease(http_requests[5m]) + {path="/foo"} 4 -eval instant at 30m increase(http_requests[30m]) +eval instant at 24m yincrease(http_requests[5m]) + {path="/foo"} 4 + +eval instant at 24m xincrease(http_requests[10m]) {path="/foo"} 7 -eval instant at 30m xincrease(http_requests[30m]) +eval instant at 24m yincrease(http_requests[10m]) {path="/foo"} 7 clear @@ -391,6 +497,37 @@ eval instant at 20m xdelta(http_requests[1m]) clear +# Tests for ydelta(). +# ydelta extends the value of the sample preceding rangeStart across +# every gap, so the answer is simply last-in-range minus +# last-before-range (no counter-reset correction: ydelta does not treat +# the series as a counter). +load 5m + http_requests{path="/foo"} 1 2 3 4 5 6 7 + http_requests{path="/bar"} 11 9 7 5 3 1 0 + +eval instant at 29m delta(http_requests[30m]) + {path="/foo"} 6 + {path="/bar"} -12 + +eval instant at 29m xdelta(http_requests[30m]) + {path="/foo"} 5 + {path="/bar"} -10 + +eval instant at 29m ydelta(http_requests[30m]) + {path="/foo"} 5 + {path="/bar"} -10 + +eval instant at 29m ydelta(http_requests[25m]) + {path="/foo"} 5 + {path="/bar"} -10 + +eval instant at 29m ydelta(http_requests[5m]) + {path="/foo"} 1 + {path="/bar"} -2 + +clear + # Tests for idelta(). load 5m http_requests{path="/foo"} 0 50 100 150 From a3b8588694f5abe702adc388954cdf97be0a8ebc Mon Sep 17 00:00:00 2001 From: Colin Kelley Date: Sun, 19 Apr 2026 15:38:39 -0700 Subject: [PATCH 5/6] 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 cfe32a14526..008194ac1f0 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 f9a1dbd9a0847b96c47f49f67eb6490d3ff5fc41 Mon Sep 17 00:00:00 2001 From: Colin Kelley Date: Mon, 11 May 2026 08:58:40 -0700 Subject: [PATCH 6/6] 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 965e47e261d..b279ba9be16 100644 --- a/promql/functions.go +++ b/promql/functions.go @@ -342,8 +342,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 008194ac1f0..49ae0a660ba 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