diff --git a/docs/src/manpage.md b/docs/src/manpage.md index 41b5b3676..c75ce93fa 100644 --- a/docs/src/manpage.md +++ b/docs/src/manpage.md @@ -2223,6 +2223,12 @@ This is simply a copy of what you should see on running `man mlr` at a command p case please avoid pprint-format output since end of input stream will never be seen. Likewise, if input is coming from `tail -f` be sure to use `--records-per-batch 1`. + -w {n} Sliding-window mode: compute statistics over a trailing + window of up to n records (including the current one), rather + than over the whole record stream. Windows are kept per group + when -g is used. One output record is emitted per input + record, with the windowed statistics appended to it. Not + compatible with -s. -S No-op flag for backward compatibility with Miller 5. -F No-op flag for backward compatibility with Miller 5. -h|--help Show this message. @@ -2249,6 +2255,9 @@ This is simply a copy of what you should see on running `man mlr` at a command p Example: mlr stats1 -a min,p10,p50,p90,max -f value -g size,shape Example: mlr stats1 -a count,mode -f size Example: mlr stats1 -a count,mode -f size -g shape + Example: mlr stats1 -a mean,min,max -f quantity -g name -w 7 + This emits one output record per input record, with sliding-window + statistics over the last up-to-7 records for each name. Example: mlr stats1 -a count,mode --fr '^[a-h].*$' --gr '^k.*$' This computes count and mode statistics on all field names beginning with a through h, grouped by all field names starting with k. @@ -4110,5 +4119,5 @@ This is simply a copy of what you should see on running `man mlr` at a command p MIME Type for Comma-Separated Values (CSV) Files, the Miller docsite https://miller.readthedocs.io - 2026-07-08 4mMILLER24m(1) + 2026-07-14 4mMILLER24m(1) diff --git a/docs/src/manpage.txt b/docs/src/manpage.txt index a5e8bd3b8..daf681cfe 100644 --- a/docs/src/manpage.txt +++ b/docs/src/manpage.txt @@ -2202,6 +2202,12 @@ case please avoid pprint-format output since end of input stream will never be seen. Likewise, if input is coming from `tail -f` be sure to use `--records-per-batch 1`. + -w {n} Sliding-window mode: compute statistics over a trailing + window of up to n records (including the current one), rather + than over the whole record stream. Windows are kept per group + when -g is used. One output record is emitted per input + record, with the windowed statistics appended to it. Not + compatible with -s. -S No-op flag for backward compatibility with Miller 5. -F No-op flag for backward compatibility with Miller 5. -h|--help Show this message. @@ -2228,6 +2234,9 @@ Example: mlr stats1 -a min,p10,p50,p90,max -f value -g size,shape Example: mlr stats1 -a count,mode -f size Example: mlr stats1 -a count,mode -f size -g shape + Example: mlr stats1 -a mean,min,max -f quantity -g name -w 7 + This emits one output record per input record, with sliding-window + statistics over the last up-to-7 records for each name. Example: mlr stats1 -a count,mode --fr '^[a-h].*$' --gr '^k.*$' This computes count and mode statistics on all field names beginning with a through h, grouped by all field names starting with k. @@ -4089,4 +4098,4 @@ MIME Type for Comma-Separated Values (CSV) Files, the Miller docsite https://miller.readthedocs.io - 2026-07-08 4mMILLER24m(1) + 2026-07-14 4mMILLER24m(1) diff --git a/docs/src/reference-verbs.md b/docs/src/reference-verbs.md index 547bb2938..d3b03c59f 100644 --- a/docs/src/reference-verbs.md +++ b/docs/src/reference-verbs.md @@ -3697,6 +3697,12 @@ Options: case please avoid pprint-format output since end of input stream will never be seen. Likewise, if input is coming from `tail -f` be sure to use `--records-per-batch 1`. +-w {n} Sliding-window mode: compute statistics over a trailing + window of up to n records (including the current one), rather + than over the whole record stream. Windows are kept per group + when -g is used. One output record is emitted per input + record, with the windowed statistics appended to it. Not + compatible with -s. -S No-op flag for backward compatibility with Miller 5. -F No-op flag for backward compatibility with Miller 5. -h|--help Show this message. @@ -3723,6 +3729,9 @@ Names of accumulators for -a, one or more of: Example: mlr stats1 -a min,p10,p50,p90,max -f value -g size,shape Example: mlr stats1 -a count,mode -f size Example: mlr stats1 -a count,mode -f size -g shape +Example: mlr stats1 -a mean,min,max -f quantity -g name -w 7 + This emits one output record per input record, with sliding-window + statistics over the last up-to-7 records for each name. Example: mlr stats1 -a count,mode --fr '^[a-h].*$' --gr '^k.*$' This computes count and mode statistics on all field names beginning with a through h, grouped by all field names starting with k. @@ -3812,6 +3821,44 @@ square red circle red +With `-w {n}`, statistics are computed over a sliding window of the last up-to-`n` +records -- within each group, when `-g` is used -- and one output record is emitted +per input record, with the windowed statistics appended to it: + +
+mlr --icsv --opprint --from example.csv stats1 -a mean,min,max -f quantity -w 4
+
+
+color  shape    flag  k  index quantity rate   quantity_mean      quantity_min quantity_max
+yellow triangle true  1  11    43.6498  9.8870 43.6498            43.6498      43.6498
+red    square   true  2  15    79.2778  0.0130 61.4638            43.6498      79.2778
+red    circle   true  3  16    13.8103  2.9010 45.579299999999996 13.8103      79.2778
+red    square   false 4  48    77.5542  7.4670 53.573025          13.8103      79.2778
+purple triangle false 5  51    81.2290  8.5910 62.96782499999999  13.8103      81.229
+red    square   false 6  64    77.1991  9.5310 62.44815           13.8103      81.229
+purple triangle false 7  65    80.1405  5.8240 79.0307            77.1991      81.229
+yellow circle   true  8  73    63.9785  4.2370 75.636775          63.9785      81.229
+yellow circle   true  9  87    63.5058  8.3350 71.20597500000001  63.5058      80.1405
+purple square   false 10 91    72.3735  8.2430 69.999575          63.5058      80.1405
+
+ +
+mlr --icsv --opprint --from example.csv stats1 -a mean -f quantity -g shape -w 2
+
+
+color  shape    flag  k  index quantity rate   quantity_mean
+yellow triangle true  1  11    43.6498  9.8870 43.6498
+red    square   true  2  15    79.2778  0.0130 79.2778
+red    circle   true  3  16    13.8103  2.9010 13.8103
+red    square   false 4  48    77.5542  7.4670 78.416
+purple triangle false 5  51    81.2290  8.5910 62.4394
+red    square   false 6  64    77.1991  9.5310 77.37665
+purple triangle false 7  65    80.1405  5.8240 80.68475000000001
+yellow circle   true  8  73    63.9785  4.2370 38.8944
+yellow circle   true  9  87    63.5058  8.3350 63.742149999999995
+purple square   false 10 91    72.3735  8.2430 74.78630000000001
+
+ ## stats2
diff --git a/docs/src/reference-verbs.md.in b/docs/src/reference-verbs.md.in
index 1d4b74da3..04624a259 100644
--- a/docs/src/reference-verbs.md.in
+++ b/docs/src/reference-verbs.md.in
@@ -1132,6 +1132,18 @@ GENMD-RUN-COMMAND
 mlr --c2p stats1 -a mode -f color -g shape data/colored-shapes.csv
 GENMD-EOF
 
+With `-w {n}`, statistics are computed over a sliding window of the last up-to-`n`
+records -- within each group, when `-g` is used -- and one output record is emitted
+per input record, with the windowed statistics appended to it:
+
+GENMD-RUN-COMMAND
+mlr --icsv --opprint --from example.csv stats1 -a mean,min,max -f quantity -w 4
+GENMD-EOF
+
+GENMD-RUN-COMMAND
+mlr --icsv --opprint --from example.csv stats1 -a mean -f quantity -g shape -w 2
+GENMD-EOF
+
 ## stats2
 
 GENMD-RUN-COMMAND
diff --git a/man/manpage.txt b/man/manpage.txt
index a5e8bd3b8..daf681cfe 100644
--- a/man/manpage.txt
+++ b/man/manpage.txt
@@ -2202,6 +2202,12 @@
                           case please avoid pprint-format output since end of input
                           stream will never be seen. Likewise, if input is coming from
                           `tail -f` be sure to use `--records-per-batch 1`.
+       -w {n}             Sliding-window mode: compute statistics over a trailing
+                          window of up to n records (including the current one), rather
+                          than over the whole record stream. Windows are kept per group
+                          when -g is used. One output record is emitted per input
+                          record, with the windowed statistics appended to it. Not
+                          compatible with -s.
        -S                 No-op flag for backward compatibility with Miller 5.
        -F                 No-op flag for backward compatibility with Miller 5.
        -h|--help          Show this message.
@@ -2228,6 +2234,9 @@
        Example: mlr stats1 -a min,p10,p50,p90,max -f value -g size,shape
        Example: mlr stats1 -a count,mode -f size
        Example: mlr stats1 -a count,mode -f size -g shape
+       Example: mlr stats1 -a mean,min,max -f quantity -g name -w 7
+               This emits one output record per input record, with sliding-window
+                statistics over the last up-to-7 records for each name.
        Example: mlr stats1 -a count,mode --fr '^[a-h].*$' --gr '^k.*$'
                This computes count and mode statistics on all field names beginning
                 with a through h, grouped by all field names starting with k.
@@ -4089,4 +4098,4 @@
        MIME Type for Comma-Separated Values (CSV) Files, the Miller docsite
        https://miller.readthedocs.io
 
-                                  2026-07-08                         4mMILLER24m(1)
+                                  2026-07-14                         4mMILLER24m(1)
diff --git a/man/mlr.1 b/man/mlr.1
index 272395a6a..14c0fbfba 100644
--- a/man/mlr.1
+++ b/man/mlr.1
@@ -2,12 +2,12 @@
 .\"     Title: mlr
 .\"    Author: [see the "AUTHOR" section]
 .\" Generator: ./mkman.rb
-.\"      Date: 2026-07-08
+.\"      Date: 2026-07-14
 .\"    Manual: \ \&
 .\"    Source: \ \&
 .\"  Language: English
 .\"
-.TH "MILLER" "1" "2026-07-08" "\ \&" "\ \&"
+.TH "MILLER" "1" "2026-07-14" "\ \&" "\ \&"
 .\" -----------------------------------------------------------------
 .\" * Portability definitions
 .\" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -2738,6 +2738,12 @@ Options:
                    case please avoid pprint-format output since end of input
                    stream will never be seen. Likewise, if input is coming from
                    `tail -f` be sure to use `--records-per-batch 1`.
+-w {n}             Sliding-window mode: compute statistics over a trailing
+                   window of up to n records (including the current one), rather
+                   than over the whole record stream. Windows are kept per group
+                   when -g is used. One output record is emitted per input
+                   record, with the windowed statistics appended to it. Not
+                   compatible with -s.
 -S                 No-op flag for backward compatibility with Miller 5.
 -F                 No-op flag for backward compatibility with Miller 5.
 -h|--help          Show this message.
@@ -2764,6 +2770,9 @@ Names of accumulators for -a, one or more of:
 Example: mlr stats1 -a min,p10,p50,p90,max -f value -g size,shape
 Example: mlr stats1 -a count,mode -f size
 Example: mlr stats1 -a count,mode -f size -g shape
+Example: mlr stats1 -a mean,min,max -f quantity -g name -w 7
+        This emits one output record per input record, with sliding-window
+         statistics over the last up-to-7 records for each name.
 Example: mlr stats1 -a count,mode --fr '^[a-h].*$' --gr '^k.*$'
         This computes count and mode statistics on all field names beginning
          with a through h, grouped by all field names starting with k.
diff --git a/pkg/transformers/stats1.go b/pkg/transformers/stats1.go
index 245960bb2..eb51a9018 100644
--- a/pkg/transformers/stats1.go
+++ b/pkg/transformers/stats1.go
@@ -28,6 +28,7 @@ var stats1Options = []OptionSpec{
 	{Flag: "--grfx", Arg: "{regex}", Type: "regex", Desc: "Shorthand for --gr {regex} --fx {that same regex}."},
 	{Flag: "-i", Type: "bool", Desc: "Use interpolated percentiles, like R's type=7; default like type=1. Not sensical for string-valued fields."},
 	{Flag: "-s", Type: "bool", Desc: "Print iterative stats. Useful in tail -f contexts, in which case please avoid pprint-format output since end of input stream will never be seen. Likewise, if input is coming from `tail -f` be sure to use `--records-per-batch 1`."},
+	{Flag: "-w", Arg: "{n}", Type: "int", Desc: "Sliding-window mode: compute statistics over a trailing window of up to n records (including the current one), rather than over the whole record stream. Windows are kept per group when -g is used. One output record is emitted per input record, with the windowed statistics appended to it. Not compatible with -s."},
 	{Flag: "-S", Type: "bool", Desc: "No-op flag for backward compatibility with Miller 5."},
 	{Flag: "-F", Type: "bool", Desc: "No-op flag for backward compatibility with Miller 5."},
 }
@@ -62,6 +63,11 @@ the input record stream.
 		"Example: mlr stats1 -a count,mode -f size")
 	fmt.Fprintln(o,
 		"Example: mlr stats1 -a count,mode -f size -g shape")
+	fmt.Fprintln(o,
+		"Example: mlr stats1 -a mean,min,max -f quantity -g name -w 7")
+	fmt.Fprintln(o,
+		`        This emits one output record per input record, with sliding-window
+         statistics over the last up-to-7 records for each name.`)
 	fmt.Fprintln(o,
 		"Example: mlr stats1 -a count,mode --fr '^[a-h].*$' --gr '^k.*$'")
 	fmt.Fprintln(o,
@@ -106,6 +112,7 @@ func transformerStats1ParseCLI(
 
 	doInterpolatedPercentiles := false
 	doIterativeStats := false
+	slidingWindowSize := int64(0)
 
 	var err error
 	for argi < argc /* variable increment: 1 or 2 depending on flag */ {
@@ -185,6 +192,15 @@ func transformerStats1ParseCLI(
 		case "-s":
 			doIterativeStats = true
 
+		case "-w":
+			slidingWindowSize, err = cli.VerbGetIntArg(verb, opt, args, &argi, argc)
+			if err != nil {
+				return nil, err
+			}
+			if slidingWindowSize < 1 {
+				return nil, cli.VerbErrorf(verbNameStats1, "-w argument must be positive; got %d", slidingWindowSize)
+			}
+
 		case "-S", "-F":
 			// No-op pass-through for backward compatibility with Miller 5
 
@@ -200,6 +216,9 @@ func transformerStats1ParseCLI(
 	if len(valueFieldNameList) == 0 {
 		return nil, cli.VerbErrorf(verbNameStats1, "-f option is required")
 	}
+	if doIterativeStats && slidingWindowSize > 0 {
+		return nil, cli.VerbErrorf(verbNameStats1, "-s and -w may not be used together")
+	}
 
 	*pargi = argi
 	if !doConstruct { // All transformers must do this for main command-line parsing
@@ -218,6 +237,7 @@ func transformerStats1ParseCLI(
 
 		doInterpolatedPercentiles,
 		doIterativeStats,
+		slidingWindowSize,
 	)
 	if err != nil {
 		return nil, err
@@ -250,9 +270,19 @@ type TransformerStats1 struct {
 	doInterpolatedPercentiles bool
 	doIterativeStats          bool
 
+	// If positive, statistics are computed over a trailing window of up to
+	// this many records (per grouping key), with one output record emitted per
+	// input record.
+	slidingWindowSize int64
+
 	// State:
 	accumulatorFactory *utils.Stats1AccumulatorFactory
 
+	// For sliding-window mode: per grouping key, the last (up to)
+	// slidingWindowSize windowed entries. Each entry is a small record holding
+	// copies of only the relevant value fields from one input record.
+	slidingWindows map[string][]*mlrval.Mlrmap
+
 	// Accumulators are indexed by
 	//   groupByFieldName -> valueFieldName -> accumulatorName -> accumulator object
 	// This would be
@@ -319,6 +349,7 @@ func NewTransformerStats1(
 
 	doInterpolatedPercentiles bool,
 	doIterativeStats bool,
+	slidingWindowSize int64,
 ) (*TransformerStats1, error) {
 	for _, name := range accumulatorNameList {
 		if !utils.ValidateStats1AccumulatorName(name) {
@@ -339,7 +370,9 @@ func NewTransformerStats1(
 
 		doInterpolatedPercentiles:        doInterpolatedPercentiles,
 		doIterativeStats:                 doIterativeStats,
+		slidingWindowSize:                slidingWindowSize,
 		accumulatorFactory:               utils.NewStats1AccumulatorFactory(),
+		slidingWindows:                   make(map[string][]*mlrval.Mlrmap),
 		namedAccumulators:                lib.NewOrderedMap[*lib.OrderedMap[*lib.OrderedMap[*utils.Stats1NamedAccumulator]]](),
 		groupingKeysToGroupByFieldValues: make(map[string]*lib.OrderedMap[*mlrval.Mlrval]),
 	}
@@ -378,6 +411,17 @@ func (tr *TransformerStats1) Transform(
 func (tr *TransformerStats1) handleInputRecord(
 	inrecAndContext *types.RecordAndContext,
 	outputRecordsAndContexts *[]*types.RecordAndContext, // list of *types.RecordAndContext
+) {
+	if tr.slidingWindowSize > 0 {
+		tr.handleInputRecordWindowed(inrecAndContext, outputRecordsAndContexts)
+	} else {
+		tr.handleInputRecordNonWindowed(inrecAndContext, outputRecordsAndContexts)
+	}
+}
+
+func (tr *TransformerStats1) handleInputRecordNonWindowed(
+	inrecAndContext *types.RecordAndContext,
+	outputRecordsAndContexts *[]*types.RecordAndContext, // list of *types.RecordAndContext
 ) {
 	inrec := inrecAndContext.Record
 
@@ -430,6 +474,103 @@ func (tr *TransformerStats1) handleInputRecord(
 	}
 }
 
+// handleInputRecordWindowed processes one input record in sliding-window mode
+// (-w {n}). For each grouping key we retain the relevant value fields of the
+// last up-to-n records; on every input record the accumulators for that
+// grouping key are reset and re-fed from the window, then the windowed
+// statistics are appended to the record, which is emitted immediately.
+func (tr *TransformerStats1) handleInputRecordWindowed(
+	inrecAndContext *types.RecordAndContext,
+	outputRecordsAndContexts *[]*types.RecordAndContext, // list of *types.RecordAndContext
+) {
+	inrec := inrecAndContext.Record
+
+	// E.g. if grouping by "a" and "b", and the current record has a=circle, b=blue,
+	// then groupingKey is the string "circle,blue".
+	var groupingKey string
+	var groupByFieldValues *lib.OrderedMap[*mlrval.Mlrval] // OrderedMap[string]*mlrval.Mlrval
+	var ok bool
+	if tr.doRegexGroupByFieldNames {
+		groupingKey, groupByFieldValues, ok = tr.getGroupByFieldNamesWithRegexes(inrec)
+	} else {
+		groupingKey, ok = tr.getGroupingKeyWithoutRegexes(inrec)
+	}
+	if !ok {
+		return
+	}
+
+	level2 := tr.namedAccumulators.Get(groupingKey)
+	if level2 == nil {
+		level2 = lib.NewOrderedMap[*lib.OrderedMap[*utils.Stats1NamedAccumulator]]()
+		tr.namedAccumulators.Put(groupingKey, level2)
+		if !tr.doRegexGroupByFieldNames {
+			groupByFieldValues, ok = tr.buildGroupByFieldValuesWithoutRegexes(inrec)
+			if !ok {
+				return
+			}
+		}
+		tr.groupingKeysToGroupByFieldValues[groupingKey] = groupByFieldValues
+	} else if !tr.doRegexGroupByFieldNames {
+		groupByFieldValues = tr.groupingKeysToGroupByFieldValues[groupingKey]
+	}
+
+	// Update this grouping key's window with the current record's value fields.
+	window := tr.slidingWindows[groupingKey]
+	if int64(len(window)) >= tr.slidingWindowSize {
+		window = window[1:]
+	}
+	window = append(window, tr.buildWindowEntry(inrec))
+	tr.slidingWindows[groupingKey] = window
+
+	// Reset the accumulators for this grouping key, then re-ingest the values
+	// retained in the window. This is O(window size) per record but is fully
+	// generic: it works for any accumulator, including order-sensitive ones
+	// like mode/antimode and non-invertible ones like percentiles.
+	for pb := level2.Head; pb != nil; pb = pb.Next {
+		for pc := pb.Value.Head; pc != nil; pc = pc.Next {
+			pc.Value.Reset()
+		}
+	}
+	for _, windowEntry := range window {
+		if tr.doRegexValueFieldNames {
+			tr.ingestWithValueFieldRegexes(windowEntry, groupingKey, level2)
+		} else {
+			tr.ingestWithoutValueFieldRegexes(windowEntry, groupingKey, level2)
+		}
+	}
+
+	tr.emitIntoOutputRecord(
+		inrec,
+		groupByFieldValues,
+		level2,
+		inrec,
+	)
+	*outputRecordsAndContexts = append(*outputRecordsAndContexts, inrecAndContext)
+}
+
+// buildWindowEntry makes a small record holding copies of only the relevant
+// value fields from the given input record, for retention in a sliding window.
+func (tr *TransformerStats1) buildWindowEntry(
+	inrec *mlrval.Mlrmap,
+) *mlrval.Mlrmap {
+	windowEntry := mlrval.NewMlrmapAsRecord()
+	if tr.doRegexValueFieldNames {
+		for pe := inrec.Head; pe != nil; pe = pe.Next {
+			if tr.matchValueFieldName(pe.Key) {
+				windowEntry.PutCopy(pe.Key, pe.Value)
+			}
+		}
+	} else {
+		for _, valueFieldName := range tr.valueFieldNameList {
+			valueFieldValue := inrec.Get(valueFieldName)
+			if valueFieldValue != nil {
+				windowEntry.PutCopy(valueFieldName, valueFieldValue)
+			}
+		}
+	}
+	return windowEntry
+}
+
 // E.g. if grouping by "a" and "b", and the current record has a=circle,
 // b=blue, then groupingKey is the string "circle,blue".  For grouping without
 // regexed group-by field names, the group-by field names/values are the same
@@ -610,7 +751,7 @@ func (tr *TransformerStats1) handleEndOfRecordStream(
 	inrecAndContext *types.RecordAndContext,
 	outputRecordsAndContexts *[]*types.RecordAndContext, // list of *types.RecordAndContext
 ) {
-	if tr.doIterativeStats {
+	if tr.doIterativeStats || tr.slidingWindowSize > 0 {
 		*outputRecordsAndContexts = append(*outputRecordsAndContexts, inrecAndContext) // end-of-stream marker
 		return
 	}
diff --git a/pkg/transformers/stats1_test.go b/pkg/transformers/stats1_test.go
new file mode 100644
index 000000000..2d5ec5bf9
--- /dev/null
+++ b/pkg/transformers/stats1_test.go
@@ -0,0 +1,150 @@
+package transformers
+
+import (
+	"testing"
+
+	"github.com/johnkerl/miller/v6/pkg/mlrval"
+	"github.com/johnkerl/miller/v6/pkg/types"
+)
+
+// makeStats1TestRecord builds a record from alternating key/value pairs, e.g.
+// makeStats1TestRecord("x", "1", "g", "a").
+func makeStats1TestRecord(kvPairs ...string) *types.RecordAndContext {
+	record := mlrval.NewMlrmapAsRecord()
+	for i := 0; i < len(kvPairs); i += 2 {
+		record.PutReference(kvPairs[i], mlrval.FromInferredType(kvPairs[i+1]))
+	}
+	context := types.NewNilContext()
+	return types.NewRecordAndContext(record, context)
+}
+
+func runStats1TestTransformer(
+	tr RecordTransformer,
+	inputs []*types.RecordAndContext,
+) []*types.RecordAndContext {
+	inputDownstreamDoneChannel := make(chan bool, 1)
+	outputDownstreamDoneChannel := make(chan bool, 1)
+	outputs := make([]*types.RecordAndContext, 0)
+	for _, input := range inputs {
+		tr.Transform(input, &outputs, inputDownstreamDoneChannel, outputDownstreamDoneChannel)
+	}
+	// End-of-stream marker
+	eos := types.NewRecordAndContext(nil, types.NewNilContext())
+	eos.EndOfStream = true
+	tr.Transform(eos, &outputs, inputDownstreamDoneChannel, outputDownstreamDoneChannel)
+	return outputs
+}
+
+// TestStats1SlidingWindow exercises 'mlr stats1 -a sum,mean,min,max -f x -w 3'.
+func TestStats1SlidingWindow(t *testing.T) {
+	tr, err := NewTransformerStats1(
+		[]string{"sum", "mean", "min", "max"},
+		[]string{"x"},
+		[]string{},   // groupByFieldNameList
+		false, false, // doRegexValueFieldNames, doRegexGroupByFieldNames
+		false, false, // invertRegexValueFieldNames, invertRegexGroupByFieldNames
+		false, // doInterpolatedPercentiles
+		false, // doIterativeStats
+		3,     // slidingWindowSize
+	)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	inputs := []*types.RecordAndContext{
+		makeStats1TestRecord("x", "1"),
+		makeStats1TestRecord("x", "2"),
+		makeStats1TestRecord("x", "3"),
+		makeStats1TestRecord("x", "4"),
+		makeStats1TestRecord("x", "5"),
+	}
+	outputs := runStats1TestTransformer(tr, inputs)
+
+	// 5 data records plus end-of-stream marker
+	if len(outputs) != 6 {
+		t.Fatalf("got %d outputs, want 6", len(outputs))
+	}
+
+	expectations := []struct{ sum, mean, min, max string }{
+		{"1", "1", "1", "1"},   // window [1]
+		{"3", "1.5", "1", "2"}, // window [1,2]
+		{"6", "2", "1", "3"},   // window [1,2,3]
+		{"9", "3", "2", "4"},   // window [2,3,4]
+		{"12", "4", "3", "5"},  // window [3,4,5]
+	}
+	for i, expectation := range expectations {
+		record := outputs[i].Record
+		for name, want := range map[string]string{
+			"x_sum":  expectation.sum,
+			"x_mean": expectation.mean,
+			"x_min":  expectation.min,
+			"x_max":  expectation.max,
+		} {
+			value := record.Get(name)
+			if value == nil {
+				t.Fatalf("record %d: field %s missing", i, name)
+			}
+			if value.String() != want {
+				t.Errorf("record %d: %s got %s, want %s", i, name, value.String(), want)
+			}
+		}
+	}
+
+	if !outputs[5].EndOfStream {
+		t.Errorf("last output should be the end-of-stream marker")
+	}
+}
+
+// TestStats1SlidingWindowGrouped exercises 'mlr stats1 -a count,sum -f x -g g -w 2':
+// windows are maintained per group.
+func TestStats1SlidingWindowGrouped(t *testing.T) {
+	tr, err := NewTransformerStats1(
+		[]string{"count", "sum"},
+		[]string{"x"},
+		[]string{"g"}, // groupByFieldNameList
+		false, false,  // doRegexValueFieldNames, doRegexGroupByFieldNames
+		false, false, // invertRegexValueFieldNames, invertRegexGroupByFieldNames
+		false, // doInterpolatedPercentiles
+		false, // doIterativeStats
+		2,     // slidingWindowSize
+	)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	inputs := []*types.RecordAndContext{
+		makeStats1TestRecord("g", "a", "x", "1"),
+		makeStats1TestRecord("g", "b", "x", "10"),
+		makeStats1TestRecord("g", "a", "x", "2"),
+		makeStats1TestRecord("g", "b", "x", "20"),
+		makeStats1TestRecord("g", "a", "x", "3"),
+	}
+	outputs := runStats1TestTransformer(tr, inputs)
+
+	// 5 data records plus end-of-stream marker
+	if len(outputs) != 6 {
+		t.Fatalf("got %d outputs, want 6", len(outputs))
+	}
+
+	expectations := []struct{ count, sum string }{
+		{"1", "1"},  // group a, window [1]
+		{"1", "10"}, // group b, window [10]
+		{"2", "3"},  // group a, window [1,2]
+		{"2", "30"}, // group b, window [10,20]
+		{"2", "5"},  // group a, window [2,3]
+	}
+	for i, expectation := range expectations {
+		record := outputs[i].Record
+		count := record.Get("x_count")
+		sum := record.Get("x_sum")
+		if count == nil || sum == nil {
+			t.Fatalf("record %d: missing x_count or x_sum", i)
+		}
+		if count.String() != expectation.count {
+			t.Errorf("record %d: x_count got %s, want %s", i, count.String(), expectation.count)
+		}
+		if sum.String() != expectation.sum {
+			t.Errorf("record %d: x_sum got %s, want %s", i, sum.String(), expectation.sum)
+		}
+	}
+}
diff --git a/test/cases/cli-help/0001/expout b/test/cases/cli-help/0001/expout
index a6a2d3275..208b81831 100644
--- a/test/cases/cli-help/0001/expout
+++ b/test/cases/cli-help/0001/expout
@@ -1266,6 +1266,12 @@ Options:
                    case please avoid pprint-format output since end of input
                    stream will never be seen. Likewise, if input is coming from
                    `tail -f` be sure to use `--records-per-batch 1`.
+-w {n}             Sliding-window mode: compute statistics over a trailing
+                   window of up to n records (including the current one), rather
+                   than over the whole record stream. Windows are kept per group
+                   when -g is used. One output record is emitted per input
+                   record, with the windowed statistics appended to it. Not
+                   compatible with -s.
 -S                 No-op flag for backward compatibility with Miller 5.
 -F                 No-op flag for backward compatibility with Miller 5.
 -h|--help          Show this message.
@@ -1292,6 +1298,9 @@ Names of accumulators for -a, one or more of:
 Example: mlr stats1 -a min,p10,p50,p90,max -f value -g size,shape
 Example: mlr stats1 -a count,mode -f size
 Example: mlr stats1 -a count,mode -f size -g shape
+Example: mlr stats1 -a mean,min,max -f quantity -g name -w 7
+        This emits one output record per input record, with sliding-window
+         statistics over the last up-to-7 records for each name.
 Example: mlr stats1 -a count,mode --fr '^[a-h].*$' --gr '^k.*$'
         This computes count and mode statistics on all field names beginning
          with a through h, grouped by all field names starting with k.
diff --git a/test/cases/verb-stats1/0020/cmd b/test/cases/verb-stats1/0020/cmd
new file mode 100644
index 000000000..436f058e7
--- /dev/null
+++ b/test/cases/verb-stats1/0020/cmd
@@ -0,0 +1 @@
+mlr --opprint --from test/input/abixy stats1 -a mean,sum,min,max,count -f i,x -w 3
diff --git a/test/cases/verb-stats1/0020/experr b/test/cases/verb-stats1/0020/experr
new file mode 100644
index 000000000..e69de29bb
diff --git a/test/cases/verb-stats1/0020/expout b/test/cases/verb-stats1/0020/expout
new file mode 100644
index 000000000..1a17d9546
--- /dev/null
+++ b/test/cases/verb-stats1/0020/expout
@@ -0,0 +1,11 @@
+a   b   i  x          y          i_mean     i_sum i_min i_max i_count x_mean     x_sum      x_min      x_max      x_count
+pan pan 1  0.34679014 0.72680286 1          1     1     1     1       0.34679014 0.34679014 0.34679014 0.34679014 1
+eks pan 2  0.75867996 0.52215111 1.50000000 3     1     2     2       0.55273505 1.10547011 0.34679014 0.75867996 2
+wye wye 3  0.20460331 0.33831853 2          6     1     3     3       0.43669114 1.31007341 0.20460331 0.75867996 3
+eks wye 4  0.38139939 0.13418874 3          9     2     4     3       0.44822755 1.34468266 0.20460331 0.75867996 3
+wye pan 5  0.57328892 0.86362447 4          12    3     5     3       0.38643054 1.15929162 0.20460331 0.57328892 3
+zee pan 6  0.52712616 0.49322129 5          15    4     6     3       0.49393816 1.48181447 0.38139939 0.57328892 3
+eks zee 7  0.61178406 0.18788492 6          18    5     7     3       0.57073305 1.71219914 0.52712616 0.61178406 3
+zee wye 8  0.59855401 0.97618139 7          21    6     8     3       0.57915474 1.73746423 0.52712616 0.61178406 3
+hat wye 9  0.03144188 0.74955076 8          24    7     9     3       0.41392665 1.24177995 0.03144188 0.61178406 3
+pan wye 10 0.50262601 0.95261836 9          27    8     10    3       0.37754063 1.13262189 0.03144188 0.59855401 3
diff --git a/test/cases/verb-stats1/0021/cmd b/test/cases/verb-stats1/0021/cmd
new file mode 100644
index 000000000..61f261df6
--- /dev/null
+++ b/test/cases/verb-stats1/0021/cmd
@@ -0,0 +1 @@
+mlr --opprint --from test/input/abixy stats1 -a count,mean -f i -g a -w 2
diff --git a/test/cases/verb-stats1/0021/experr b/test/cases/verb-stats1/0021/experr
new file mode 100644
index 000000000..e69de29bb
diff --git a/test/cases/verb-stats1/0021/expout b/test/cases/verb-stats1/0021/expout
new file mode 100644
index 000000000..a553838c7
--- /dev/null
+++ b/test/cases/verb-stats1/0021/expout
@@ -0,0 +1,11 @@
+a   b   i  x          y          i_count i_mean
+pan pan 1  0.34679014 0.72680286 1       1
+eks pan 2  0.75867996 0.52215111 1       2
+wye wye 3  0.20460331 0.33831853 1       3
+eks wye 4  0.38139939 0.13418874 2       3
+wye pan 5  0.57328892 0.86362447 2       4
+zee pan 6  0.52712616 0.49322129 1       6
+eks zee 7  0.61178406 0.18788492 2       5.50000000
+zee wye 8  0.59855401 0.97618139 2       7
+hat wye 9  0.03144188 0.74955076 1       9
+pan wye 10 0.50262601 0.95261836 2       5.50000000
diff --git a/test/cases/verb-stats1/0022/cmd b/test/cases/verb-stats1/0022/cmd
new file mode 100644
index 000000000..f1160f96f
--- /dev/null
+++ b/test/cases/verb-stats1/0022/cmd
@@ -0,0 +1 @@
+mlr --opprint --from test/input/abixy stats1 -a p25,median,p75,distinct_count,mode -f i -w 4
diff --git a/test/cases/verb-stats1/0022/experr b/test/cases/verb-stats1/0022/experr
new file mode 100644
index 000000000..e69de29bb
diff --git a/test/cases/verb-stats1/0022/expout b/test/cases/verb-stats1/0022/expout
new file mode 100644
index 000000000..98980da26
--- /dev/null
+++ b/test/cases/verb-stats1/0022/expout
@@ -0,0 +1,11 @@
+a   b   i  x          y          i_p25 i_median i_p75 i_distinct_count i_mode
+pan pan 1  0.34679014 0.72680286 1     1        1     1                1
+eks pan 2  0.75867996 0.52215111 1     2        2     2                1
+wye wye 3  0.20460331 0.33831853 1     2        3     3                1
+eks wye 4  0.38139939 0.13418874 2     3        4     4                1
+wye pan 5  0.57328892 0.86362447 3     4        5     4                2
+zee pan 6  0.52712616 0.49322129 4     5        6     4                3
+eks zee 7  0.61178406 0.18788492 5     6        7     4                4
+zee wye 8  0.59855401 0.97618139 6     7        8     4                5
+hat wye 9  0.03144188 0.74955076 7     8        9     4                6
+pan wye 10 0.50262601 0.95261836 8     9        10    4                7
diff --git a/test/cases/verb-stats1/0023/cmd b/test/cases/verb-stats1/0023/cmd
new file mode 100644
index 000000000..93551ce46
--- /dev/null
+++ b/test/cases/verb-stats1/0023/cmd
@@ -0,0 +1 @@
+mlr --ojson --from test/input/abixy-het stats1 -a sum,count -f x,y -w 3
diff --git a/test/cases/verb-stats1/0023/experr b/test/cases/verb-stats1/0023/experr
new file mode 100644
index 000000000..e69de29bb
diff --git a/test/cases/verb-stats1/0023/expout b/test/cases/verb-stats1/0023/expout
new file mode 100644
index 000000000..3993a3ac2
--- /dev/null
+++ b/test/cases/verb-stats1/0023/expout
@@ -0,0 +1,112 @@
+[
+{
+  "a": "pan",
+  "b": "pan",
+  "i": 1,
+  "x": 0.34679014,
+  "y": 0.72680286,
+  "x_sum": 0.34679014,
+  "x_count": 1,
+  "y_sum": 0.72680286,
+  "y_count": 1
+},
+{
+  "a": "eks",
+  "b": "pan",
+  "i": 2,
+  "x": 0.75867996,
+  "y": 0.52215111,
+  "x_sum": 1.10547011,
+  "x_count": 2,
+  "y_sum": 1.24895397,
+  "y_count": 2
+},
+{
+  "aaa": "wye",
+  "b": "wye",
+  "i": 3,
+  "x": 0.20460331,
+  "y": 0.33831853,
+  "x_sum": 1.31007341,
+  "x_count": 3,
+  "y_sum": 1.58727250,
+  "y_count": 3
+},
+{
+  "a": "eks",
+  "bbb": "wye",
+  "i": 4,
+  "x": 0.38139939,
+  "y": 0.13418874,
+  "x_sum": 1.34468266,
+  "x_count": 3,
+  "y_sum": 0.99465838,
+  "y_count": 3
+},
+{
+  "a": "wye",
+  "b": "pan",
+  "i": 5,
+  "xxx": 0.57328892,
+  "y": 0.86362447,
+  "x_sum": 0.58600270,
+  "x_count": 2,
+  "y_sum": 1.33613174,
+  "y_count": 3
+},
+{
+  "a": "zee",
+  "b": "pan",
+  "i": 6,
+  "x": 0.52712616,
+  "y": 0.49322129,
+  "x_sum": 0.90852555,
+  "x_count": 2,
+  "y_sum": 1.49103450,
+  "y_count": 3
+},
+{
+  "a": "eks",
+  "b": "zee",
+  "iii": 7,
+  "x": 0.61178406,
+  "y": 0.18788492,
+  "x_sum": 1.13891022,
+  "x_count": 2,
+  "y_sum": 1.54473068,
+  "y_count": 3
+},
+{
+  "a": "zee",
+  "b": "wye",
+  "i": 8,
+  "x": 0.59855401,
+  "yyy": 0.97618139,
+  "x_sum": 1.73746423,
+  "x_count": 3,
+  "y_sum": 0.68110621,
+  "y_count": 2
+},
+{
+  "aaa": "hat",
+  "bbb": "wye",
+  "i": 9,
+  "x": 0.03144188,
+  "y": 0.74955076,
+  "x_sum": 1.24177995,
+  "x_count": 3,
+  "y_sum": 0.93743568,
+  "y_count": 2
+},
+{
+  "a": "pan",
+  "b": "wye",
+  "i": 10,
+  "x": 0.50262601,
+  "y": 0.95261836,
+  "x_sum": 1.13262189,
+  "x_count": 3,
+  "y_sum": 1.70216912,
+  "y_count": 2
+}
+]
diff --git a/test/cases/verb-stats1/0024/cmd b/test/cases/verb-stats1/0024/cmd
new file mode 100644
index 000000000..05b8f2087
--- /dev/null
+++ b/test/cases/verb-stats1/0024/cmd
@@ -0,0 +1 @@
+mlr --opprint --from test/input/abixy stats1 -a sum --fr '^[xy]$' -w 3
diff --git a/test/cases/verb-stats1/0024/experr b/test/cases/verb-stats1/0024/experr
new file mode 100644
index 000000000..e69de29bb
diff --git a/test/cases/verb-stats1/0024/expout b/test/cases/verb-stats1/0024/expout
new file mode 100644
index 000000000..8e44b9edb
--- /dev/null
+++ b/test/cases/verb-stats1/0024/expout
@@ -0,0 +1,11 @@
+a   b   i  x          y          x_sum      y_sum
+pan pan 1  0.34679014 0.72680286 0.34679014 0.72680286
+eks pan 2  0.75867996 0.52215111 1.10547011 1.24895397
+wye wye 3  0.20460331 0.33831853 1.31007341 1.58727250
+eks wye 4  0.38139939 0.13418874 1.34468266 0.99465838
+wye pan 5  0.57328892 0.86362447 1.15929162 1.33613174
+zee pan 6  0.52712616 0.49322129 1.48181447 1.49103450
+eks zee 7  0.61178406 0.18788492 1.71219914 1.54473068
+zee wye 8  0.59855401 0.97618139 1.73746423 1.65728759
+hat wye 9  0.03144188 0.74955076 1.24177995 1.91361707
+pan wye 10 0.50262601 0.95261836 1.13262189 2.67835051
diff --git a/test/cases/verb-stats1/0025/cmd b/test/cases/verb-stats1/0025/cmd
new file mode 100644
index 000000000..44e2dae96
--- /dev/null
+++ b/test/cases/verb-stats1/0025/cmd
@@ -0,0 +1 @@
+mlr --from test/input/abixy stats1 -a mean -f x -s -w 3
diff --git a/test/cases/verb-stats1/0025/experr b/test/cases/verb-stats1/0025/experr
new file mode 100644
index 000000000..339ba219d
--- /dev/null
+++ b/test/cases/verb-stats1/0025/experr
@@ -0,0 +1 @@
+mlr stats1: -s and -w may not be used together
diff --git a/test/cases/verb-stats1/0025/expout b/test/cases/verb-stats1/0025/expout
new file mode 100644
index 000000000..e69de29bb
diff --git a/test/cases/verb-stats1/0025/should-fail b/test/cases/verb-stats1/0025/should-fail
new file mode 100644
index 000000000..e69de29bb