From 929b23802baad4a95a3dc93027e5daedaf2d91e7 Mon Sep 17 00:00:00 2001 From: John Kerl Date: Mon, 17 Jan 2022 23:28:30 -0500 Subject: [PATCH 1/4] iterating --- internal/pkg/transformers/step.go | 82 +++++++++++++++++++++++++------ test/cases/cli-help/0001/expout | 8 +-- todo.txt | 8 +-- 3 files changed, 76 insertions(+), 22 deletions(-) diff --git a/internal/pkg/transformers/step.go b/internal/pkg/transformers/step.go index 0fd201694..de14e1ab7 100644 --- a/internal/pkg/transformers/step.go +++ b/internal/pkg/transformers/step.go @@ -110,24 +110,24 @@ func transformerStepParseCLI( transformerStepUsage(os.Stdout, true, 0) } else if opt == "-a" { - // TODO: append ... - stepperNames = cli.VerbGetStringArrayArgOrDie(verb, opt, args, &argi, argc) + // Let them do '-a delta -a rsum' or '-a delta,rsum' + stepperNames = append(stepperNames, cli.VerbGetStringArrayArgOrDie(verb, opt, args, &argi, argc)...) } else if opt == "-f" { - // TODO: append ... - valueFieldNames = cli.VerbGetStringArrayArgOrDie(verb, opt, args, &argi, argc) + // Let them do '-f x -f y' or '-f x,y' + valueFieldNames = append(valueFieldNames, cli.VerbGetStringArrayArgOrDie(verb, opt, args, &argi, argc)...) } else if opt == "-g" { - // TODO: append ... - groupByFieldNames = cli.VerbGetStringArrayArgOrDie(verb, opt, args, &argi, argc) + // Let them do '-g a -g b' or '-g a,b' + groupByFieldNames = append(groupByFieldNames, cli.VerbGetStringArrayArgOrDie(verb, opt, args, &argi, argc)...) } else if opt == "-d" { - // TODO: append ... - stringAlphas = cli.VerbGetStringArrayArgOrDie(verb, opt, args, &argi, argc) + // Let them do '-d 0.8 -d 0.9' or '-d 0.8,0.9' + stringAlphas = append(stringAlphas, cli.VerbGetStringArrayArgOrDie(verb, opt, args, &argi, argc)...) } else if opt == "-o" { - // TODO: append ... - ewmaSuffixes = cli.VerbGetStringArrayArgOrDie(verb, opt, args, &argi, argc) + // Let them do '-o fast -o slow' or '-o fast,slow' + ewmaSuffixes = append(ewmaSuffixes, cli.VerbGetStringArrayArgOrDie(verb, opt, args, &argi, argc)...) } else if opt == "-F" { // As of Miller 6 this happens automatically, but the flag is accepted @@ -158,6 +158,12 @@ func transformerStepParseCLI( return transformer } +type tStepperInput struct { + name string + numRecordsBackward int + numRecordsForward int +} + // ---------------------------------------------------------------- type TransformerStep struct { // INPUT @@ -328,13 +334,15 @@ type tStepperLookup struct { } var STEPPER_LOOKUP_TABLE = []tStepperLookup{ + {"counter", stepperCounterAlloc, "Count instances of field(s) between successive records"}, {"delta", stepperDeltaAlloc, "Compute differences in field(s) between successive records"}, - {"shift", stepperShiftAlloc, "Include value(s) in field(s) from previous record, if any"}, + {"ewma", stepperEWMAAlloc, "Exponentially weighted moving average over successive records"}, {"from-first", stepperFromFirstAlloc, "Compute differences in field(s) from first record"}, {"ratio", stepperRatioAlloc, "Compute ratios in field(s) between successive records"}, {"rsum", stepperRsumAlloc, "Compute running sums of field(s) between successive records"}, - {"counter", stepperCounterAlloc, "Count instances of field(s) between successive records"}, - {"ewma", stepperEWMAAlloc, "Exponentially weighted moving average over successive records"}, + {"shift", stepperShiftAlloc, "Alias for shift-lag"}, + {"shift-lag", stepperShiftLagAlloc, "Include value(s) in field(s) from previous record, if any"}, + {"shift-lead", stepperShiftLeadAlloc, "Include value(s) in field(s) from previous record, if any"}, } func allocateStepper( @@ -394,7 +402,7 @@ func (stepper *tStepperDelta) process( } // ================================================================ -type tStepperShift struct { +type tStepperShiftLag struct { previous *mlrval.Mlrval outputFieldName string } @@ -404,13 +412,55 @@ func stepperShiftAlloc( _unused1 []string, _unused2 []string, ) tStepper { - return &tStepperShift{ + return &tStepperShiftLag{ previous: nil, outputFieldName: inputFieldName + "_shift", } } -func (stepper *tStepperShift) process( +func stepperShiftLagAlloc( + inputFieldName string, + _unused1 []string, + _unused2 []string, +) tStepper { + return &tStepperShiftLag{ + previous: nil, + outputFieldName: inputFieldName + "_shift_lag", + } +} + +func (stepper *tStepperShiftLag) process( + valueFieldValue *mlrval.Mlrval, + inrec *mlrval.Mlrmap, +) { + if stepper.previous == nil { + shift := mlrval.VOID + inrec.PutCopy(stepper.outputFieldName, shift) + } else { + inrec.PutCopy(stepper.outputFieldName, stepper.previous) + stepper.previous = valueFieldValue.Copy() + } + stepper.previous = valueFieldValue.Copy() +} + +// ================================================================ +type tStepperShiftLead struct { + previous *mlrval.Mlrval + outputFieldName string +} + +func stepperShiftLeadAlloc( + inputFieldName string, + _unused1 []string, + _unused2 []string, +) tStepper { + return &tStepperShiftLead{ + previous: nil, + outputFieldName: inputFieldName + "_shift_lead", + } +} + +func (stepper *tStepperShiftLead) process( valueFieldValue *mlrval.Mlrval, inrec *mlrval.Mlrmap, ) { diff --git a/test/cases/cli-help/0001/expout b/test/cases/cli-help/0001/expout index e4161b04e..2a5f735d1 100644 --- a/test/cases/cli-help/0001/expout +++ b/test/cases/cli-help/0001/expout @@ -1024,13 +1024,15 @@ Usage: mlr step [options] Computes values dependent on the previous record, optionally grouped by category. Options: -a {delta,rsum,...} Names of steppers: comma-separated, one or more of: + counter Count instances of field(s) between successive records delta Compute differences in field(s) between successive records - shift Include value(s) in field(s) from previous record, if any + ewma Exponentially weighted moving average over successive records from-first Compute differences in field(s) from first record ratio Compute ratios in field(s) between successive records rsum Compute running sums of field(s) between successive records - counter Count instances of field(s) between successive records - ewma Exponentially weighted moving average over successive records + shift Alias for shift-lag + shift-lag Include value(s) in field(s) from previous record, if any + shift-lead Include value(s) in field(s) from previous record, if any -f {a,b,c} Value-field names on which to compute statistics -g {d,e,f} Optional group-by-field names diff --git a/todo.txt b/todo.txt index 490feff55..ed753361b 100644 --- a/todo.txt +++ b/todo.txt @@ -24,14 +24,16 @@ k better print-interpolate with {} etc ---------------------------------------------------------------- ! strmatch https://github.com/johnkerl/miller/issues/77#issuecomment-538790927 +---------------------------------------------------------------- +! shift_lead and shift_lag steps + o RT: mlr --c2p --from $exv step -a delta -a rsum -f quantity -f rate -g color -g shape + i https://github.com/johnkerl/miller/issues/355 + ---------------------------------------------------------------- ! sliding window / moving average o port u/window*.mlr from mlrc to mlr (actually, fix mlr of course) o sliding-window averages into mapper step (C + Go) ----------------------------------------------------------------- -! shift_lead and shift_lag steps - ---------------------------------------------------------------- ! rank From 9a43a1dcd57a68dcffe5198d8216e065418d7dde Mon Sep 17 00:00:00 2001 From: John Kerl Date: Tue, 18 Jan 2022 00:09:07 -0500 Subject: [PATCH 2/4] stepper-input refactor in prep for sliding-window PR --- internal/pkg/transformers/step.go | 188 ++++++++++++++++++++++++------ test/cases/cli-help/0001/expout | 2 +- todo.txt | 1 + 3 files changed, 154 insertions(+), 37 deletions(-) diff --git a/internal/pkg/transformers/step.go b/internal/pkg/transformers/step.go index de14e1ab7..2a46d9041 100644 --- a/internal/pkg/transformers/step.go +++ b/internal/pkg/transformers/step.go @@ -48,7 +48,7 @@ func transformerStepUsage( fmt.Fprintf(o, " As of Miller 6 this happens automatically, but the flag is accepted\n") fmt.Fprintf(o, " as a no-op for backward compatibility with Miller 5 and below.\n") - fmt.Fprintf(o, "-d {x,y,z} Weights for ewma. 1 means current sample gets all weight (no\n") + fmt.Fprintf(o, "-d {x,y,z} Weights for EWMA. 1 means current sample gets all weight (no\n") fmt.Fprintf(o, " smoothing), near under under 1 is light smoothing, near over 0 is\n") fmt.Fprintf(o, " heavy smoothing. Multiple weights may be specified, e.g.\n") fmt.Fprintf(o, " \"%s %s -a ewma -f sys_load -d 0.01,0.1,0.9\". Default if omitted\n", "mlr", verbNameStep) @@ -90,7 +90,7 @@ func transformerStepParseCLI( verb := args[argi] argi++ - var stepperNames []string = nil + var stepperInputs []*tStepperInput = nil var valueFieldNames []string = nil var groupByFieldNames []string = nil var stringAlphas []string = nil @@ -111,7 +111,17 @@ func transformerStepParseCLI( } else if opt == "-a" { // Let them do '-a delta -a rsum' or '-a delta,rsum' - stepperNames = append(stepperNames, cli.VerbGetStringArrayArgOrDie(verb, opt, args, &argi, argc)...) + stepperNames := cli.VerbGetStringArrayArgOrDie(verb, opt, args, &argi, argc) + + for _, stepperName := range stepperNames { + stepperInput := stepperInputFromName(stepperName) + if stepperInput == nil { + fmt.Fprintf(os.Stderr, "mlr %s: stepper \"%s\" not found.\n", + verbNameStep, stepperName) + os.Exit(1) + } + stepperInputs = append(stepperInputs, stepperInput) + } } else if opt == "-f" { // Let them do '-f x -f y' or '-f x,y' @@ -144,7 +154,7 @@ func transformerStepParseCLI( } transformer, err := NewTransformerStep( - stepperNames, + stepperInputs, valueFieldNames, groupByFieldNames, stringAlphas, @@ -158,16 +168,10 @@ func transformerStepParseCLI( return transformer } -type tStepperInput struct { - name string - numRecordsBackward int - numRecordsForward int -} - // ---------------------------------------------------------------- type TransformerStep struct { // INPUT - stepperNames []string + stepperInputs []*tStepperInput valueFieldNames []string groupByFieldNames []string stringAlphas []string @@ -182,14 +186,14 @@ type TransformerStep struct { } func NewTransformerStep( - stepperNames []string, + stepperInputs []*tStepperInput, valueFieldNames []string, groupByFieldNames []string, stringAlphas []string, ewmaSuffixes []string, ) (*TransformerStep, error) { - if len(stepperNames) == 0 || len(valueFieldNames) == 0 { + if len(stepperInputs) == 0 || len(valueFieldNames) == 0 { return nil, fmt.Errorf("mlr %s: -a and -f are both required arguments.", verbNameStep) } if len(stringAlphas) != 0 && len(ewmaSuffixes) != 0 { @@ -201,12 +205,13 @@ func NewTransformerStep( } tr := &TransformerStep{ - stepperNames: stepperNames, + stepperInputs: stepperInputs, valueFieldNames: valueFieldNames, groupByFieldNames: groupByFieldNames, stringAlphas: stringAlphas, ewmaSuffixes: ewmaSuffixes, - groups: make(map[string]map[string]map[string]tStepper), + // TODO: pair of tStepper and tWindow + groups: make(map[string]map[string]map[string]tStepper), } return tr, nil @@ -290,22 +295,21 @@ func (tr *TransformerStep) Transform( } // for "delta", "rsum": - for _, stepperName := range tr.stepperNames { - stepper, present := accFieldToAccState[stepperName] + for _, stepperInput := range tr.stepperInputs { + stepper, present := accFieldToAccState[stepperInput.name] if !present { stepper = allocateStepper( - stepperName, + stepperInput, valueFieldName, tr.stringAlphas, tr.ewmaSuffixes, ) if stepper == nil { - // TODO: parameterize verb name - fmt.Fprintf(os.Stderr, "mlr step: stepper \"%s\" not found.\n", - stepperName) + fmt.Fprintf(os.Stderr, "mlr %s: stepper \"%s\" not found.\n", + verbNameStep, stepperInput.name) os.Exit(1) } - accFieldToAccState[stepperName] = stepper + accFieldToAccState[stepperInput.name] = stepper } stepper.process(valueFieldValue, inrec) } @@ -317,42 +321,64 @@ func (tr *TransformerStep) Transform( // ================================================================ // Lookups for individual steppers, like "delta" or "rsum" +type tStepperInputFromName func( + stepperName string, +) *tStepperInput + type tStepperAllocator func( inputFieldName string, stringAlphas []string, ewmaSuffixes []string, ) tStepper +type tStepperInput struct { + name string + numRecordsBackward int + numRecordsForward int +} + type tStepper interface { process(valueFieldValue *mlrval.Mlrval, inputRecord *mlrval.Mlrmap) } type tStepperLookup struct { - name string - stepperAllocator tStepperAllocator - desc string + name string + stepperInputFromName tStepperInputFromName + stepperAllocator tStepperAllocator + desc string } var STEPPER_LOOKUP_TABLE = []tStepperLookup{ - {"counter", stepperCounterAlloc, "Count instances of field(s) between successive records"}, - {"delta", stepperDeltaAlloc, "Compute differences in field(s) between successive records"}, - {"ewma", stepperEWMAAlloc, "Exponentially weighted moving average over successive records"}, - {"from-first", stepperFromFirstAlloc, "Compute differences in field(s) from first record"}, - {"ratio", stepperRatioAlloc, "Compute ratios in field(s) between successive records"}, - {"rsum", stepperRsumAlloc, "Compute running sums of field(s) between successive records"}, - {"shift", stepperShiftAlloc, "Alias for shift-lag"}, - {"shift-lag", stepperShiftLagAlloc, "Include value(s) in field(s) from previous record, if any"}, - {"shift-lead", stepperShiftLeadAlloc, "Include value(s) in field(s) from previous record, if any"}, + {"counter", stepperCounterInputFromName, stepperCounterAlloc, "Count instances of field(s) between successive records"}, + {"delta", stepperDeltaInputFromName, stepperDeltaAlloc, "Compute differences in field(s) between successive records"}, + {"ewma", stepperEWMAInputFromName, stepperEWMAAlloc, "Exponentially weighted moving average over successive records"}, + {"from-first", stepperFromFirstInputFromName, stepperFromFirstAlloc, "Compute differences in field(s) from first record"}, + {"ratio", stepperRatioInputFromName, stepperRatioAlloc, "Compute ratios in field(s) between successive records"}, + {"rsum", stepperRsumInputFromName, stepperRsumAlloc, "Compute running sums of field(s) between successive records"}, + {"shift", stepperShiftInputFromName, stepperShiftAlloc, "Alias for shift-lag"}, + {"shift-lag", stepperShiftLagInputFromName, stepperShiftLagAlloc, "Include value(s) in field(s) from previous record, if any"}, + {"shift-lead", stepperShiftLeadInputFromName, stepperShiftLeadAlloc, "Include value(s) in field(s) from previous record, if any"}, +} + +func stepperInputFromName( + name string, +) *tStepperInput { + for _, stepperLookup := range STEPPER_LOOKUP_TABLE { + if stepperLookup.name == name { + return stepperLookup.stepperInputFromName(name) + } + } + return nil } func allocateStepper( - stepperName string, + stepperInput *tStepperInput, inputFieldName string, stringAlphas []string, ewmaSuffixes []string, ) tStepper { for _, stepperLookup := range STEPPER_LOOKUP_TABLE { - if stepperLookup.name == stepperName { + if stepperLookup.name == stepperInput.name { return stepperLookup.stepperAllocator( inputFieldName, stringAlphas, @@ -372,6 +398,16 @@ type tStepperDelta struct { outputFieldName string } +func stepperDeltaInputFromName( + stepperName string, +) *tStepperInput { + return &tStepperInput{ + name: stepperName, + numRecordsBackward: 1, + numRecordsForward: 0, + } +} + func stepperDeltaAlloc( inputFieldName string, _unused1 []string, @@ -407,6 +443,16 @@ type tStepperShiftLag struct { outputFieldName string } +func stepperShiftInputFromName( + stepperName string, +) *tStepperInput { + return &tStepperInput{ + name: stepperName, + numRecordsBackward: 1, + numRecordsForward: 0, + } +} + func stepperShiftAlloc( inputFieldName string, _unused1 []string, @@ -418,6 +464,16 @@ func stepperShiftAlloc( } } +func stepperShiftLagInputFromName( + stepperName string, +) *tStepperInput { + return &tStepperInput{ + name: stepperName, + numRecordsBackward: 1, + numRecordsForward: 0, + } +} + func stepperShiftLagAlloc( inputFieldName string, _unused1 []string, @@ -449,6 +505,16 @@ type tStepperShiftLead struct { outputFieldName string } +func stepperShiftLeadInputFromName( + stepperName string, +) *tStepperInput { + return &tStepperInput{ + name: stepperName, + numRecordsBackward: 0, + numRecordsForward: 1, + } +} + func stepperShiftLeadAlloc( inputFieldName string, _unused1 []string, @@ -480,6 +546,16 @@ type tStepperFromFirst struct { outputFieldName string } +func stepperFromFirstInputFromName( + stepperName string, +) *tStepperInput { + return &tStepperInput{ + name: stepperName, + numRecordsBackward: 0, // doesn't use record-windowing; retains its own pointer + numRecordsForward: 0, + } +} + func stepperFromFirstAlloc( inputFieldName string, _unused1 []string, @@ -510,6 +586,16 @@ type tStepperRatio struct { outputFieldName string } +func stepperRatioInputFromName( + stepperName string, +) *tStepperInput { + return &tStepperInput{ + name: stepperName, + numRecordsBackward: 1, + numRecordsForward: 0, + } +} + func stepperRatioAlloc( inputFieldName string, _unused1 []string, @@ -545,6 +631,16 @@ type tStepperRsum struct { outputFieldName string } +func stepperRsumInputFromName( + stepperName string, +) *tStepperInput { + return &tStepperInput{ + name: stepperName, + numRecordsBackward: 0, // doesn't use record-windowing; retains its own pointer + numRecordsForward: 0, + } +} + func stepperRsumAlloc( inputFieldName string, _unused1 []string, @@ -575,6 +671,16 @@ type tStepperCounter struct { outputFieldName string } +func stepperCounterInputFromName( + stepperName string, +) *tStepperInput { + return &tStepperInput{ + name: stepperName, + numRecordsBackward: 0, // doesn't use record-windowing; retains its own pointer + numRecordsForward: 0, + } +} + func stepperCounterAlloc( inputFieldName string, _unused1 []string, @@ -611,6 +717,16 @@ type tStepperEWMA struct { havePrevs bool } +func stepperEWMAInputFromName( + stepperName string, +) *tStepperInput { + return &tStepperInput{ + name: stepperName, + numRecordsBackward: 0, // doesn't use record-windowing; retains its own accumulators + numRecordsForward: 0, + } +} + func stepperEWMAAlloc( inputFieldName string, stringAlphas []string, diff --git a/test/cases/cli-help/0001/expout b/test/cases/cli-help/0001/expout index 2a5f735d1..e575e6e77 100644 --- a/test/cases/cli-help/0001/expout +++ b/test/cases/cli-help/0001/expout @@ -1039,7 +1039,7 @@ Options: -F Computes integerable things (e.g. counter) in floating point. As of Miller 6 this happens automatically, but the flag is accepted as a no-op for backward compatibility with Miller 5 and below. --d {x,y,z} Weights for ewma. 1 means current sample gets all weight (no +-d {x,y,z} Weights for EWMA. 1 means current sample gets all weight (no smoothing), near under under 1 is light smoothing, near over 0 is heavy smoothing. Multiple weights may be specified, e.g. "mlr step -a ewma -f sys_load -d 0.01,0.1,0.9". Default if omitted diff --git a/todo.txt b/todo.txt index ed753361b..bc2d95b65 100644 --- a/todo.txt +++ b/todo.txt @@ -27,6 +27,7 @@ k better print-interpolate with {} etc ---------------------------------------------------------------- ! shift_lead and shift_lag steps o RT: mlr --c2p --from $exv step -a delta -a rsum -f quantity -f rate -g color -g shape + o next: utils/stepper-window.go w/ dedicated UTs i https://github.com/johnkerl/miller/issues/355 ---------------------------------------------------------------- From cd8b714dc827e1b07754d5f01ae2931c4ab5ceaf Mon Sep 17 00:00:00 2001 From: John Kerl Date: Tue, 18 Jan 2022 08:29:23 -0500 Subject: [PATCH 3/4] window-keeper util class --- .vimrc | 1 + .../pkg/transformers/utils/window-keeper.go | 70 ++++++++ .../transformers/utils/window_keeper_test.go | 157 ++++++++++++++++++ 3 files changed, 228 insertions(+) create mode 100644 internal/pkg/transformers/utils/window-keeper.go create mode 100644 internal/pkg/transformers/utils/window_keeper_test.go diff --git a/.vimrc b/.vimrc index d3d35005f..97c60ada4 100644 --- a/.vimrc +++ b/.vimrc @@ -1,3 +1,4 @@ map \d :w:!clear;echo Building ...; echo; make mlr map \f :w:!clear;echo Building ...; echo; make ut map \r :w:!clear;echo Building ...; echo; make ut-scan ut-mlv +map \t :w:!clear;go test github.com/johnkerl/miller/internal/pkg/transformers/... diff --git a/internal/pkg/transformers/utils/window-keeper.go b/internal/pkg/transformers/utils/window-keeper.go new file mode 100644 index 000000000..08db173e9 --- /dev/null +++ b/internal/pkg/transformers/utils/window-keeper.go @@ -0,0 +1,70 @@ +package utils + +import ( + "github.com/johnkerl/miller/internal/pkg/lib" +) + +// WindowKeeper is a sliding-window container, nominally for use by mlr step, +// for holding a number of records before the current one, the current one, and +// a number of records after. The payload is interface{}, not *mlrval.Mlrmap, +// for ease of unit-testing -- as well as since nothing here inspects the +// payload, so this code could be repurposed. +type WindowKeeper struct { + numBackward int + numForward int + + recordsBackward []interface{} + currentRecord interface{} + recordsForward []interface{} +} + +func NewWindowKeeper( + numBackward int, + numForward int, +) *WindowKeeper { + return &WindowKeeper{ + numBackward: numBackward, + numForward: numForward, + + recordsBackward: make([]interface{}, numBackward), + currentRecord: nil, + recordsForward: make([]interface{}, numForward), + } +} + +func (wk *WindowKeeper) IngestRecord( + inrec interface{}, +) { + for i := wk.numBackward - 1; i > 0; i-- { + wk.recordsBackward[i] = wk.recordsBackward[i-1] + } + if wk.numBackward > 0 { + wk.recordsBackward[0] = wk.currentRecord + } + if wk.numForward > 0 { + wk.currentRecord = wk.recordsForward[0] + for i := 0; i < wk.numForward-1; i++ { + wk.recordsForward[i] = wk.recordsForward[i+1] + } + wk.recordsForward[wk.numForward-1] = inrec + } else { + wk.currentRecord = inrec + } +} + +// GetRecord maps a user-visible indexing ..., -3, -2, -1, 0, 1, 2, 3, ... +// into this struct's zero-index array storage. +func (wk *WindowKeeper) GetRecord( + index int, +) interface{} { + if index == 0 { + return wk.currentRecord + } else if index > 0 { + lib.InternalCodingErrorIf(index > wk.numForward) + return wk.recordsForward[index-1] + } else { + index = -index + lib.InternalCodingErrorIf(index > wk.numBackward) + return wk.recordsBackward[index-1] + } +} diff --git a/internal/pkg/transformers/utils/window_keeper_test.go b/internal/pkg/transformers/utils/window_keeper_test.go new file mode 100644 index 000000000..223dc4653 --- /dev/null +++ b/internal/pkg/transformers/utils/window_keeper_test.go @@ -0,0 +1,157 @@ +package utils + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test00(t *testing.T) { + wk := NewWindowKeeper(0, 0) + + wk.IngestRecord("a") + assert.Equal(t, "a", wk.GetRecord(0).(string)) + + wk.IngestRecord("b") + assert.Equal(t, "b", wk.GetRecord(0).(string)) +} + +func Test10(t *testing.T) { + wk := NewWindowKeeper(1, 0) + + wk.IngestRecord("a") + assert.Equal(t, "a", wk.GetRecord(0).(string)) + assert.Equal(t, nil, wk.GetRecord(-1)) + + wk.IngestRecord("b") + assert.Equal(t, "b", wk.GetRecord(0).(string)) + assert.Equal(t, "a", wk.GetRecord(-1).(string)) + + wk.IngestRecord("c") + assert.Equal(t, "c", wk.GetRecord(0).(string)) + assert.Equal(t, "b", wk.GetRecord(-1).(string)) +} + +func Test20(t *testing.T) { + wk := NewWindowKeeper(2, 0) + + wk.IngestRecord("a") + assert.Equal(t, "a", wk.GetRecord(0).(string)) + assert.Equal(t, nil, wk.GetRecord(-1)) + assert.Equal(t, nil, wk.GetRecord(-2)) + + wk.IngestRecord("b") + assert.Equal(t, "b", wk.GetRecord(0).(string)) + assert.Equal(t, "a", wk.GetRecord(-1).(string)) + assert.Equal(t, nil, wk.GetRecord(-2)) + + wk.IngestRecord("c") + assert.Equal(t, "c", wk.GetRecord(0).(string)) + assert.Equal(t, "b", wk.GetRecord(-1).(string)) + assert.Equal(t, "a", wk.GetRecord(-2).(string)) + + wk.IngestRecord("d") + assert.Equal(t, "d", wk.GetRecord(0).(string)) + assert.Equal(t, "c", wk.GetRecord(-1).(string)) + assert.Equal(t, "b", wk.GetRecord(-2).(string)) +} + +func Test01(t *testing.T) { + wk := NewWindowKeeper(0, 1) + + wk.IngestRecord("a") + assert.Equal(t, "a", wk.GetRecord(1).(string)) + assert.Equal(t, nil, wk.GetRecord(0)) + + wk.IngestRecord("b") + assert.Equal(t, "b", wk.GetRecord(1).(string)) + assert.Equal(t, "a", wk.GetRecord(0).(string)) + + wk.IngestRecord("c") + assert.Equal(t, "c", wk.GetRecord(1).(string)) + assert.Equal(t, "b", wk.GetRecord(0).(string)) +} + +func Test02(t *testing.T) { + wk := NewWindowKeeper(0, 2) + + wk.IngestRecord("a") + assert.Equal(t, "a", wk.GetRecord(2).(string)) + assert.Equal(t, nil, wk.GetRecord(1)) + assert.Equal(t, nil, wk.GetRecord(0)) + + wk.IngestRecord("b") + assert.Equal(t, "b", wk.GetRecord(2).(string)) + assert.Equal(t, "a", wk.GetRecord(1).(string)) + assert.Equal(t, nil, wk.GetRecord(0)) + + wk.IngestRecord("c") + assert.Equal(t, "c", wk.GetRecord(2).(string)) + assert.Equal(t, "b", wk.GetRecord(1).(string)) + assert.Equal(t, "a", wk.GetRecord(0).(string)) + + wk.IngestRecord("d") + assert.Equal(t, "d", wk.GetRecord(2).(string)) + assert.Equal(t, "c", wk.GetRecord(1).(string)) + assert.Equal(t, "b", wk.GetRecord(0).(string)) +} + +func Test32(t *testing.T) { + wk := NewWindowKeeper(3, 2) + + wk.IngestRecord("a") + assert.Equal(t, "a", wk.GetRecord(2).(string)) + assert.Equal(t, nil, wk.GetRecord(1)) + assert.Equal(t, nil, wk.GetRecord(0)) + assert.Equal(t, nil, wk.GetRecord(-1)) + assert.Equal(t, nil, wk.GetRecord(-2)) + assert.Equal(t, nil, wk.GetRecord(-3)) + + wk.IngestRecord("b") + assert.Equal(t, "b", wk.GetRecord(2).(string)) + assert.Equal(t, "a", wk.GetRecord(1).(string)) + assert.Equal(t, nil, wk.GetRecord(0)) + assert.Equal(t, nil, wk.GetRecord(-1)) + assert.Equal(t, nil, wk.GetRecord(-2)) + assert.Equal(t, nil, wk.GetRecord(-3)) + + wk.IngestRecord("c") + assert.Equal(t, "c", wk.GetRecord(2).(string)) + assert.Equal(t, "b", wk.GetRecord(1).(string)) + assert.Equal(t, "a", wk.GetRecord(0).(string)) + assert.Equal(t, nil, wk.GetRecord(-1)) + assert.Equal(t, nil, wk.GetRecord(-2)) + assert.Equal(t, nil, wk.GetRecord(-3)) + + wk.IngestRecord("d") + assert.Equal(t, "d", wk.GetRecord(2).(string)) + assert.Equal(t, "c", wk.GetRecord(1).(string)) + assert.Equal(t, "b", wk.GetRecord(0).(string)) + assert.Equal(t, "a", wk.GetRecord(-1).(string)) + assert.Equal(t, nil, wk.GetRecord(-2)) + assert.Equal(t, nil, wk.GetRecord(-3)) + + wk.IngestRecord("e") + assert.Equal(t, "e", wk.GetRecord(2).(string)) + assert.Equal(t, "d", wk.GetRecord(1).(string)) + assert.Equal(t, "c", wk.GetRecord(0).(string)) + assert.Equal(t, "b", wk.GetRecord(-1).(string)) + assert.Equal(t, "a", wk.GetRecord(-2).(string)) + assert.Equal(t, nil, wk.GetRecord(-3)) + + wk.IngestRecord("f") + assert.Equal(t, "f", wk.GetRecord(2).(string)) + assert.Equal(t, "e", wk.GetRecord(1).(string)) + assert.Equal(t, "d", wk.GetRecord(0).(string)) + assert.Equal(t, "c", wk.GetRecord(-1).(string)) + assert.Equal(t, "b", wk.GetRecord(-2).(string)) + assert.Equal(t, "a", wk.GetRecord(-3).(string)) + + wk.IngestRecord("g") + assert.Equal(t, "g", wk.GetRecord(2).(string)) + assert.Equal(t, "f", wk.GetRecord(1).(string)) + assert.Equal(t, "e", wk.GetRecord(0).(string)) + assert.Equal(t, "d", wk.GetRecord(-1).(string)) + assert.Equal(t, "c", wk.GetRecord(-2).(string)) + assert.Equal(t, "b", wk.GetRecord(-3).(string)) +} From 57a967200a39562d1fa2c4cecd77f2b479b38383 Mon Sep 17 00:00:00 2001 From: John Kerl Date: Sun, 23 Jan 2022 00:50:00 -0500 Subject: [PATCH 4/4] integrate window-keeper into step-transformer --- docs/src/manpage.md | 48 +- docs/src/manpage.txt | 48 +- docs/src/reference-verbs.md | 46 +- internal/pkg/transformers/step.go | 592 ++++++++++++++---- .../pkg/transformers/utils/window-keeper.go | 46 +- .../transformers/utils/window_keeper_test.go | 206 +++--- man/manpage.txt | 48 +- man/mlr.1 | 50 +- test/cases/cli-help/0001/expout | 48 +- test/cases/verb-step/0005/cmd | 2 +- test/cases/verb-step/0005/expout | 154 ++++- test/cases/verb-step/0011/cmd | 1 + test/cases/verb-step/0011/experr | 0 test/cases/verb-step/0011/expout | 11 + test/cases/verb-step/0012/cmd | 1 + test/cases/verb-step/0012/experr | 0 test/cases/verb-step/0012/expout | 11 + test/cases/verb-step/0013/cmd | 1 + test/cases/verb-step/0013/experr | 0 test/cases/verb-step/0013/expout | 11 + test/cases/verb-step/0014/cmd | 1 + test/cases/verb-step/0014/experr | 0 test/cases/verb-step/0014/expout | 11 + test/cases/verb-step/0015/cmd | 1 + test/cases/verb-step/0015/experr | 0 test/cases/verb-step/0015/expout | 11 + test/cases/verb-step/0016/cmd | 1 + test/cases/verb-step/0016/experr | 0 test/cases/verb-step/0016/expout | 11 + todo.txt | 7 +- 30 files changed, 962 insertions(+), 405 deletions(-) create mode 100644 test/cases/verb-step/0011/cmd create mode 100644 test/cases/verb-step/0011/experr create mode 100644 test/cases/verb-step/0011/expout create mode 100644 test/cases/verb-step/0012/cmd create mode 100644 test/cases/verb-step/0012/experr create mode 100644 test/cases/verb-step/0012/expout create mode 100644 test/cases/verb-step/0013/cmd create mode 100644 test/cases/verb-step/0013/experr create mode 100644 test/cases/verb-step/0013/expout create mode 100644 test/cases/verb-step/0014/cmd create mode 100644 test/cases/verb-step/0014/experr create mode 100644 test/cases/verb-step/0014/expout create mode 100644 test/cases/verb-step/0015/cmd create mode 100644 test/cases/verb-step/0015/experr create mode 100644 test/cases/verb-step/0015/expout create mode 100644 test/cases/verb-step/0016/cmd create mode 100644 test/cases/verb-step/0016/experr create mode 100644 test/cases/verb-step/0016/expout diff --git a/docs/src/manpage.md b/docs/src/manpage.md index de062eeb9..eb35c6611 100644 --- a/docs/src/manpage.md +++ b/docs/src/manpage.md @@ -1825,31 +1825,33 @@ VERBS step Usage: mlr step [options] - Computes values dependent on the previous record, optionally grouped by category. + Computes values dependent on earlier/later records, optionally grouped by category. Options: - -a {delta,rsum,...} Names of steppers: comma-separated, one or more of: - delta Compute differences in field(s) between successive records - shift Include value(s) in field(s) from previous record, if any + -a {delta,rsum,...} Names of steppers: comma-separated, one or more of: + counter Count instances of field(s) between successive records + delta Compute differences in field(s) between successive records + ewma Exponentially weighted moving average over successive records from-first Compute differences in field(s) from first record - ratio Compute ratios in field(s) between successive records - rsum Compute running sums of field(s) between successive records - counter Count instances of field(s) between successive records - ewma Exponentially weighted moving average over successive records + ratio Compute ratios in field(s) between successive records + rsum Compute running sums of field(s) between successive records + shift Alias for shift-lag + shift-lag Include value(s) in field(s) from the previous record, if any + shift-lead Include value(s) in field(s) from the next record, if any - -f {a,b,c} Value-field names on which to compute statistics - -g {d,e,f} Optional group-by-field names - -F Computes integerable things (e.g. counter) in floating point. - As of Miller 6 this happens automatically, but the flag is accepted - as a no-op for backward compatibility with Miller 5 and below. - -d {x,y,z} Weights for ewma. 1 means current sample gets all weight (no - smoothing), near under under 1 is light smoothing, near over 0 is - heavy smoothing. Multiple weights may be specified, e.g. - "mlr step -a ewma -f sys_load -d 0.01,0.1,0.9". Default if omitted - is "-d 0.5". - -o {a,b,c} Custom suffixes for EWMA output fields. If omitted, these default to - the -d values. If supplied, the number of -o values must be the same - as the number of -d values. - -h|--help Show this message. + -f {a,b,c} Value-field names on which to compute statistics + -g {d,e,f} Optional group-by-field names + -F Computes integerable things (e.g. counter) in floating point. + As of Miller 6 this happens automatically, but the flag is accepted + as a no-op for backward compatibility with Miller 5 and below. + -d {x,y,z} Weights for EWMA. 1 means current sample gets all weight (no + smoothing), near under under 1 is light smoothing, near over 0 is + heavy smoothing. Multiple weights may be specified, e.g. + "mlr step -a ewma -f sys_load -d 0.01,0.1,0.9". Default if omitted + is "-d 0.5". + -o {a,b,c} Custom suffixes for EWMA output fields. If omitted, these default to + the -d values. If supplied, the number of -o values must be the same + as the number of -d values. + -h|--help S how this message. Examples: mlr step -a rsum -f request_size @@ -3086,5 +3088,5 @@ SEE ALSO - 2022-01-20 MILLER(1) + 2022-01-23 MILLER(1) diff --git a/docs/src/manpage.txt b/docs/src/manpage.txt index 68c443582..62d3f38a6 100644 --- a/docs/src/manpage.txt +++ b/docs/src/manpage.txt @@ -1804,31 +1804,33 @@ VERBS step Usage: mlr step [options] - Computes values dependent on the previous record, optionally grouped by category. + Computes values dependent on earlier/later records, optionally grouped by category. Options: - -a {delta,rsum,...} Names of steppers: comma-separated, one or more of: - delta Compute differences in field(s) between successive records - shift Include value(s) in field(s) from previous record, if any + -a {delta,rsum,...} Names of steppers: comma-separated, one or more of: + counter Count instances of field(s) between successive records + delta Compute differences in field(s) between successive records + ewma Exponentially weighted moving average over successive records from-first Compute differences in field(s) from first record - ratio Compute ratios in field(s) between successive records - rsum Compute running sums of field(s) between successive records - counter Count instances of field(s) between successive records - ewma Exponentially weighted moving average over successive records + ratio Compute ratios in field(s) between successive records + rsum Compute running sums of field(s) between successive records + shift Alias for shift-lag + shift-lag Include value(s) in field(s) from the previous record, if any + shift-lead Include value(s) in field(s) from the next record, if any - -f {a,b,c} Value-field names on which to compute statistics - -g {d,e,f} Optional group-by-field names - -F Computes integerable things (e.g. counter) in floating point. - As of Miller 6 this happens automatically, but the flag is accepted - as a no-op for backward compatibility with Miller 5 and below. - -d {x,y,z} Weights for ewma. 1 means current sample gets all weight (no - smoothing), near under under 1 is light smoothing, near over 0 is - heavy smoothing. Multiple weights may be specified, e.g. - "mlr step -a ewma -f sys_load -d 0.01,0.1,0.9". Default if omitted - is "-d 0.5". - -o {a,b,c} Custom suffixes for EWMA output fields. If omitted, these default to - the -d values. If supplied, the number of -o values must be the same - as the number of -d values. - -h|--help Show this message. + -f {a,b,c} Value-field names on which to compute statistics + -g {d,e,f} Optional group-by-field names + -F Computes integerable things (e.g. counter) in floating point. + As of Miller 6 this happens automatically, but the flag is accepted + as a no-op for backward compatibility with Miller 5 and below. + -d {x,y,z} Weights for EWMA. 1 means current sample gets all weight (no + smoothing), near under under 1 is light smoothing, near over 0 is + heavy smoothing. Multiple weights may be specified, e.g. + "mlr step -a ewma -f sys_load -d 0.01,0.1,0.9". Default if omitted + is "-d 0.5". + -o {a,b,c} Custom suffixes for EWMA output fields. If omitted, these default to + the -d values. If supplied, the number of -o values must be the same + as the number of -d values. + -h|--help S how this message. Examples: mlr step -a rsum -f request_size @@ -3065,4 +3067,4 @@ SEE ALSO - 2022-01-20 MILLER(1) + 2022-01-23 MILLER(1) diff --git a/docs/src/reference-verbs.md b/docs/src/reference-verbs.md index eabe10862..4d738c70a 100644 --- a/docs/src/reference-verbs.md +++ b/docs/src/reference-verbs.md @@ -3280,31 +3280,33 @@ donesec 25.10852919630297
 Usage: mlr step [options]
-Computes values dependent on the previous record, optionally grouped by category.
+Computes values dependent on earlier/later records, optionally grouped by category.
 Options:
--a {delta,rsum,...}   Names of steppers: comma-separated, one or more of:
-  delta    Compute differences in field(s) between successive records
-  shift    Include value(s) in field(s) from previous record, if any
+-a {delta,rsum,...} Names of steppers: comma-separated, one or more of:
+  counter    Count instances of field(s) between successive records
+  delta      Compute differences in field(s) between successive records
+  ewma       Exponentially weighted moving average over successive records
   from-first Compute differences in field(s) from first record
-  ratio    Compute ratios in field(s) between successive records
-  rsum     Compute running sums of field(s) between successive records
-  counter  Count instances of field(s) between successive records
-  ewma     Exponentially weighted moving average over successive records
+  ratio      Compute ratios in field(s) between successive records
+  rsum       Compute running sums of field(s) between successive records
+  shift      Alias for shift-lag
+  shift-lag  Include value(s) in field(s) from the previous record, if any
+  shift-lead Include value(s) in field(s) from the next record, if any
 
--f {a,b,c} Value-field names on which to compute statistics
--g {d,e,f} Optional group-by-field names
--F         Computes integerable things (e.g. counter) in floating point.
-           As of Miller 6 this happens automatically, but the flag is accepted
-           as a no-op for backward compatibility with Miller 5 and below.
--d {x,y,z} Weights for ewma. 1 means current sample gets all weight (no
-           smoothing), near under under 1 is light smoothing, near over 0 is
-           heavy smoothing. Multiple weights may be specified, e.g.
-           "mlr step -a ewma -f sys_load -d 0.01,0.1,0.9". Default if omitted
-           is "-d 0.5".
--o {a,b,c} Custom suffixes for EWMA output fields. If omitted, these default to
-           the -d values. If supplied, the number of -o values must be the same
-           as the number of -d values.
--h|--help Show this message.
+-f {a,b,c}   Value-field names on which to compute statistics
+-g {d,e,f}   Optional group-by-field names
+-F           Computes integerable things (e.g. counter) in floating point.
+             As of Miller 6 this happens automatically, but the flag is accepted
+             as a no-op for backward compatibility with Miller 5 and below.
+-d {x,y,z}   Weights for EWMA. 1 means current sample gets all weight (no
+             smoothing), near under under 1 is light smoothing, near over 0 is
+             heavy smoothing. Multiple weights may be specified, e.g.
+             "mlr step -a ewma -f sys_load -d 0.01,0.1,0.9". Default if omitted
+             is "-d 0.5".
+-o {a,b,c}   Custom suffixes for EWMA output fields. If omitted, these default to
+             the -d values. If supplied, the number of -o values must be the same
+             as the number of -d values.
+-h|--help S  how this message.
 
 Examples:
   mlr step -a rsum -f request_size
diff --git a/internal/pkg/transformers/step.go b/internal/pkg/transformers/step.go
index 2a46d9041..d4d716988 100644
--- a/internal/pkg/transformers/step.go
+++ b/internal/pkg/transformers/step.go
@@ -1,3 +1,70 @@
+// ================================================================
+// Options for the step verb are mostly simple operations involving the previous record and the
+// current record, optionally grouped by one or more group-by field names. For example, with input
+// data
+//
+//   $ cat sample.csv
+//   shape,count
+//   square,10
+//   circle,20
+//   square,11
+//   circle,23
+//
+// using the delta stepper we have
+//
+//   $ mlr --csv --from sample.csv step -a delta -f count
+//   shape,count,count_delta
+//   square,10,0
+//   circle,20,10
+//   square,11,-9
+//   circle,23,12
+//
+// whereas if we group by shape when we have
+//
+//   $ mlr --csv --from sample.csv step -a delta -f count -g shape
+//   shape,count,count_delta
+//   square,10,0
+//   circle,20,0
+//   square,11,1
+//   circle,23,3
+//
+// This is (rather, was) straightforward until we added the ability to do *forward* operations such
+// as shift-lead. Namely:
+//
+// * If the stepper is shift-lead then output lags input by one, e.g.  we emit the 10th record only
+//   after seeing the 11th. Likewise, for sliding-window average with look-forward of 4, we emit the
+//   10th record only after seeing the 14th. More generally, if there are multiple steppers
+//   specified with -a, then the delay is the max of each stepper's look-forward.
+//
+// * Then we need to produce output at the end of the record stream -- e.g.  if there are only 20
+//   records and we're doing shift-lead, then we'd normally emit the 20th record only when the 21st
+//   is received -- but there isn't one.  And we can't use a simple next-is-nil rule for the last
+//   record received in the group-by case. For example, if a given record has shape=square and we're
+//   grouping by shape, we don't know a priori where in the record stream the next record with
+//   shape=square will be -- or if there will be one at all.
+//
+// * If we keep a simple hashmap from grouping key to delayed records and process that at end of
+//   record stream, since Go hashmaps don't preserve insertion order, we'd have non-deterministic
+//   output ordering which would frustrate users and would also break automated regression tests.
+//   For example, doing shift-lead with the above sample data, the last square and circle record
+//   could appear in either order.
+//
+// * For these reasons we have an ordered hashmap -- basically a mashup of hashmap and doubly linked
+//   list -- of all "window" objects per grouping-key.
+//
+// * The window object is just the current record along with previous/next records as required by a
+//   given stepper. The shift-lag stepper keeps the previous and current record; when the 10th
+//   record is ingested, the previous is the 9th, and it emits the 10th record with a value from the
+//   9th.  The shift-lead stepper has a current and next. When the 11th record is ingested, the
+//   'current' is the 10th record and the 'next' is the 11th, and it emits the 10th record with a
+//   value from the 11th.
+//
+// * The ordered hashmap is called a "stepper log" and it has -- in order -- records pointing to the
+//   window object for their grouping key.  We don't know a priori when the end of the record stream
+//   is so we keep the last n records for each grouping key.  At end of the record stream we process
+//   these.
+// ================================================================
+
 package transformers
 
 import (
@@ -10,9 +77,11 @@ import (
 	"github.com/johnkerl/miller/internal/pkg/cli"
 	"github.com/johnkerl/miller/internal/pkg/lib"
 	"github.com/johnkerl/miller/internal/pkg/mlrval"
+	"github.com/johnkerl/miller/internal/pkg/transformers/utils"
 	"github.com/johnkerl/miller/internal/pkg/types"
 )
 
+// For EWMA
 const DEFAULT_STRING_ALPHA = "0.5"
 
 // ----------------------------------------------------------------
@@ -31,33 +100,33 @@ func transformerStepUsage(
 	exitCode int,
 ) {
 	fmt.Fprintf(o, "Usage: %s %s [options]\n", "mlr", verbNameStep)
-	fmt.Fprintf(o, "Computes values dependent on the previous record, optionally grouped by category.\n")
+	fmt.Fprintf(o, "Computes values dependent on earlier/later records, optionally grouped by category.\n")
 	fmt.Fprintf(o, "Options:\n")
 
-	fmt.Fprintf(o, "-a {delta,rsum,...}   Names of steppers: comma-separated, one or more of:\n")
+	fmt.Fprintf(o, "-a {delta,rsum,...} Names of steppers: comma-separated, one or more of:\n")
 	for _, stepperLookup := range STEPPER_LOOKUP_TABLE {
-		fmt.Fprintf(o, "  %-8s %s\n", stepperLookup.name, stepperLookup.desc)
+		fmt.Fprintf(o, "  %-10s %s\n", stepperLookup.name, stepperLookup.desc)
 	}
 	fmt.Fprintf(o, "\n")
 
-	fmt.Fprintf(o, "-f {a,b,c} Value-field names on which to compute statistics\n")
+	fmt.Fprintf(o, "-f {a,b,c}   Value-field names on which to compute statistics\n")
 
-	fmt.Fprintf(o, "-g {d,e,f} Optional group-by-field names\n")
+	fmt.Fprintf(o, "-g {d,e,f}   Optional group-by-field names\n")
 
-	fmt.Fprintf(o, "-F         Computes integerable things (e.g. counter) in floating point.\n")
-	fmt.Fprintf(o, "           As of Miller 6 this happens automatically, but the flag is accepted\n")
-	fmt.Fprintf(o, "           as a no-op for backward compatibility with Miller 5 and below.\n")
+	fmt.Fprintf(o, "-F           Computes integerable things (e.g. counter) in floating point.\n")
+	fmt.Fprintf(o, "             As of Miller 6 this happens automatically, but the flag is accepted\n")
+	fmt.Fprintf(o, "             as a no-op for backward compatibility with Miller 5 and below.\n")
 
-	fmt.Fprintf(o, "-d {x,y,z} Weights for EWMA. 1 means current sample gets all weight (no\n")
-	fmt.Fprintf(o, "           smoothing), near under under 1 is light smoothing, near over 0 is\n")
-	fmt.Fprintf(o, "           heavy smoothing. Multiple weights may be specified, e.g.\n")
-	fmt.Fprintf(o, "           \"%s %s -a ewma -f sys_load -d 0.01,0.1,0.9\". Default if omitted\n", "mlr", verbNameStep)
-	fmt.Fprintf(o, "           is \"-d %s\".\n", DEFAULT_STRING_ALPHA)
+	fmt.Fprintf(o, "-d {x,y,z}   Weights for EWMA. 1 means current sample gets all weight (no\n")
+	fmt.Fprintf(o, "             smoothing), near under under 1 is light smoothing, near over 0 is\n")
+	fmt.Fprintf(o, "             heavy smoothing. Multiple weights may be specified, e.g.\n")
+	fmt.Fprintf(o, "             \"%s %s -a ewma -f sys_load -d 0.01,0.1,0.9\". Default if omitted\n", "mlr", verbNameStep)
+	fmt.Fprintf(o, "             is \"-d %s\".\n", DEFAULT_STRING_ALPHA)
 
-	fmt.Fprintf(o, "-o {a,b,c} Custom suffixes for EWMA output fields. If omitted, these default to\n")
-	fmt.Fprintf(o, "           the -d values. If supplied, the number of -o values must be the same\n")
-	fmt.Fprintf(o, "           as the number of -d values.\n")
-	fmt.Fprintf(o, "-h|--help Show this message.\n")
+	fmt.Fprintf(o, "-o {a,b,c}   Custom suffixes for EWMA output fields. If omitted, these default to\n")
+	fmt.Fprintf(o, "             the -d values. If supplied, the number of -o values must be the same\n")
+	fmt.Fprintf(o, "             as the number of -d values.\n")
+	fmt.Fprintf(o, "-h|--help S  how this message.\n")
 
 	fmt.Fprintf(o, "\n")
 	fmt.Fprintf(o, "Examples:\n")
@@ -169,20 +238,42 @@ func transformerStepParseCLI(
 }
 
 // ----------------------------------------------------------------
+// This is the "stepper log" referred to in comments at the top of this file.
+type tStepLogEntry struct {
+	recordAndContext *types.RecordAndContext
+	windowKeeper     *utils.TWindowKeeper
+	// Map from value field name to stepper name to stepper.  E.g. with 'mlr step -g a,b -f x,y -a
+	// shift-lag,shift-lead', value field names are 'x' and 'y', and stepper names are 'shift-lag'
+	// and 'shift-lead'.
+	steppers map[string]map[string]tStepper
+}
+
 type TransformerStep struct {
 	// INPUT
+
 	stepperInputs     []*tStepperInput
 	valueFieldNames   []string
 	groupByFieldNames []string
 	stringAlphas      []string
 	ewmaSuffixes      []string
 
+	maxNumRecordsBackward int
+	maxNumRecordsForward  int
+
 	// STATE
+
 	// Scratch space used per-record
 	valueFieldValues []mlrval.Mlrval
-	// Map from group-by field names to value-field names to array of
-	// stepper objects.  See the Transform method below for more details.
+	// Map from group-by field names to value-field names to stepper name to stepper object.  See
+	// the Transform method below for more details.
 	groups map[string]map[string]map[string]tStepper
+	// Map from group-by field names to window-keeper object.  These keep rows before and after a
+	// 'current' row center point for lag/lead computations, etc.
+	windowKeepers map[string]*utils.TWindowKeeper
+
+	// Ordered map from stringified pointer to recordAndContext, to *tStepLogEntry,
+	// as described in comments at the top of this file.
+	log *lib.OrderedMap
 }
 
 func NewTransformerStep(
@@ -204,14 +295,28 @@ func NewTransformerStep(
 		}
 	}
 
+	maxNumRecordsBackward := 0
+	maxNumRecordsForward := 0
+	for _, stepperInput := range stepperInputs {
+		if maxNumRecordsBackward < stepperInput.numRecordsBackward {
+			maxNumRecordsBackward = stepperInput.numRecordsBackward
+		}
+		if maxNumRecordsForward < stepperInput.numRecordsForward {
+			maxNumRecordsForward = stepperInput.numRecordsForward
+		}
+	}
+
 	tr := &TransformerStep{
-		stepperInputs:     stepperInputs,
-		valueFieldNames:   valueFieldNames,
-		groupByFieldNames: groupByFieldNames,
-		stringAlphas:      stringAlphas,
-		ewmaSuffixes:      ewmaSuffixes,
-		// TODO: pair of tStepper and tWindow
-		groups: make(map[string]map[string]map[string]tStepper),
+		stepperInputs:         stepperInputs,
+		valueFieldNames:       valueFieldNames,
+		groupByFieldNames:     groupByFieldNames,
+		stringAlphas:          stringAlphas,
+		ewmaSuffixes:          ewmaSuffixes,
+		maxNumRecordsBackward: maxNumRecordsBackward,
+		maxNumRecordsForward:  maxNumRecordsForward,
+		groups:                make(map[string]map[string]map[string]tStepper),
+		windowKeepers:         make(map[string]*utils.TWindowKeeper),
+		log:                   lib.NewOrderedMap(),
 	}
 
 	return tr, nil
@@ -254,11 +359,34 @@ func (tr *TransformerStep) Transform(
 	outputDownstreamDoneChannel chan<- bool,
 ) {
 	HandleDefaultDownstreamDone(inputDownstreamDoneChannel, outputDownstreamDoneChannel)
-	if inrecAndContext.EndOfStream {
+
+	if !inrecAndContext.EndOfStream {
+		tr.handleRecord(inrecAndContext, outputRecordsAndContexts)
+
+	} else {
+		// As described in comments at the top of this file: process through all delayed-input
+		// records for shift-lead, forward-sliding-window, etc. steppers.
+		for pe := tr.log.Head; pe != nil; pe = pe.Next {
+			logEntry := pe.Value.(*tStepLogEntry)
+			// Shift by one -- if 'current' is the 9th record and 'next' is 10th, and there's no
+			// 11th, 'current' becomes the 10th and the 'next' becomes nil.
+			logEntry.windowKeeper.Ingest(nil)
+			tr.handleDrainRecord(logEntry, outputRecordsAndContexts)
+		}
+
 		outputRecordsAndContexts.PushBack(inrecAndContext)
 		return
 	}
+}
 
+// handleRecord processes records received before the end of the record stream is seen.
+// The records emitted here are the ones we can emit now. For example, with shift-lead, if the most
+// recent input record is the 11th, then here we're emitting the 10th.  At EOS, we'll drain any
+// delayed-input records in the order in which they were received.
+func (tr *TransformerStep) handleRecord(
+	inrecAndContext *types.RecordAndContext,
+	outputRecordsAndContexts *list.List, // list of *types.RecordAndContext
+) {
 	inrec := inrecAndContext.Record
 
 	// Group-by field names are ["a", "b"]
@@ -278,10 +406,23 @@ func (tr *TransformerStep) Transform(
 		tr.groups[groupingKey] = groupToAccField
 	}
 
-	// [3.4, 5.6]
+	windowKeeper := tr.windowKeepers[groupingKey]
+	if windowKeeper == nil {
+		windowKeeper = utils.NewWindowKeeper(
+			tr.maxNumRecordsBackward,
+			tr.maxNumRecordsForward,
+		)
+		tr.windowKeepers[groupingKey] = windowKeeper
+	}
+	windowKeeper.Ingest(inrecAndContext)
+
+	// Keep a log of delayed-input records, which we'll drain at end of record stream.
+	tr.insertToLog(inrecAndContext, windowKeeper, groupToAccField)
+
+	// E.g. if x=3.4 and y=5.6 then this is [3.4, 5.6]
 	valueFieldValues, _ := inrec.ReferenceSelectedValues(tr.valueFieldNames)
 
-	// for x=3.4 and y=5.6:
+	// For x=3.4 and y=5.6:
 	for i, valueFieldName := range tr.valueFieldNames {
 		valueFieldValue := valueFieldValues[i]
 		if valueFieldValue == nil { // not present in the current record
@@ -311,11 +452,91 @@ func (tr *TransformerStep) Transform(
 				}
 				accFieldToAccState[stepperInput.name] = stepper
 			}
-			stepper.process(valueFieldValue, inrec)
+
+			stepper.process(windowKeeper)
 		}
 	}
 
-	outputRecordsAndContexts.PushBack(inrecAndContext)
+	if windowKeeper.Get(0) != nil {
+		outrecAndContext := windowKeeper.Get(0).(*types.RecordAndContext)
+		outputRecordsAndContexts.PushBack(outrecAndContext)
+		tr.removeFromLog(outrecAndContext)
+	}
+}
+
+// handleDrainRecord processes records received after the end of the record stream is seen.  The
+// records emitted here are the ones we couldn't emit before. For example, with shift-lead, if the
+// most recent input record is the 11th, then before EOS we emitted the 10th. Here, we'll drain any
+// delayed-input records in the order in which they were received.
+func (tr *TransformerStep) handleDrainRecord(
+	logEntry *tStepLogEntry,
+	outputRecordsAndContexts *list.List, // list of *types.RecordAndContext
+) {
+	inrecAndContext := logEntry.recordAndContext
+	inrec := inrecAndContext.Record
+	windowKeeper := logEntry.windowKeeper
+	steppers := logEntry.steppers
+
+	// [3.4, 5.6]
+	valueFieldValues, _ := inrec.ReferenceSelectedValues(tr.valueFieldNames)
+
+	// for x=3.4 and y=5.6:
+	for i, valueFieldName := range tr.valueFieldNames {
+		valueFieldValue := valueFieldValues[i]
+		if valueFieldValue == nil { // not present in the current record
+			continue
+		}
+
+		accFieldToAccState := steppers[valueFieldName]
+		lib.InternalCodingErrorIf(accFieldToAccState == nil)
+
+		// for "delta", "rsum":
+		for _, stepperInput := range tr.stepperInputs {
+			stepper, present := accFieldToAccState[stepperInput.name]
+			lib.InternalCodingErrorIf(!present)
+			lib.InternalCodingErrorIf(windowKeeper.Get(0) == nil)
+			stepper.process(windowKeeper)
+		}
+	}
+
+	lib.InternalCodingErrorIf(windowKeeper.Get(0) == nil)
+	outrecAndContext := windowKeeper.Get(0).(*types.RecordAndContext)
+	outputRecordsAndContexts.PushBack(outrecAndContext)
+}
+
+// insertToLog remembers a delayed-input record so we can process it in the order it was received,
+// perhaps only after the end of the record stream has been seen.
+func (tr *TransformerStep) insertToLog(
+	recordAndContext *types.RecordAndContext,
+	windowKeeper *utils.TWindowKeeper,
+	steppers map[string]map[string]tStepper,
+) {
+	key := tr.makeLogKey(recordAndContext)
+	ientry := tr.log.Get(key)
+	lib.InternalCodingErrorIf(ientry != nil)
+	tr.log.Put(key, &tStepLogEntry{
+		recordAndContext: recordAndContext,
+		windowKeeper:     windowKeeper,
+		steppers:         steppers,
+	})
+}
+
+// removeFromLog shifts records out of the log. For example, with shift-lead, we only have
+// look-forward of 1, so the log will only have one record per grouping key.
+func (tr *TransformerStep) removeFromLog(
+	recordAndContext *types.RecordAndContext,
+) {
+	key := tr.makeLogKey(recordAndContext)
+	ientry := tr.log.Get(key)
+	lib.InternalCodingErrorIf(ientry == nil)
+	tr.log.Remove(key)
+}
+
+// makeLogKey stringifies record-and-context pointer for use as a map key for the stepper log.
+func (tr *TransformerStep) makeLogKey(
+	inrecAndContext *types.RecordAndContext,
+) string {
+	return fmt.Sprintf("%p", inrecAndContext)
 }
 
 // ================================================================
@@ -338,7 +559,7 @@ type tStepperInput struct {
 }
 
 type tStepper interface {
-	process(valueFieldValue *mlrval.Mlrval, inputRecord *mlrval.Mlrmap)
+	process(windowKeeper *utils.TWindowKeeper)
 }
 
 type tStepperLookup struct {
@@ -349,15 +570,60 @@ type tStepperLookup struct {
 }
 
 var STEPPER_LOOKUP_TABLE = []tStepperLookup{
-	{"counter", stepperCounterInputFromName, stepperCounterAlloc, "Count instances of field(s) between successive records"},
-	{"delta", stepperDeltaInputFromName, stepperDeltaAlloc, "Compute differences in field(s) between successive records"},
-	{"ewma", stepperEWMAInputFromName, stepperEWMAAlloc, "Exponentially weighted moving average over successive records"},
-	{"from-first", stepperFromFirstInputFromName, stepperFromFirstAlloc, "Compute differences in field(s) from first record"},
-	{"ratio", stepperRatioInputFromName, stepperRatioAlloc, "Compute ratios in field(s) between successive records"},
-	{"rsum", stepperRsumInputFromName, stepperRsumAlloc, "Compute running sums of field(s) between successive records"},
-	{"shift", stepperShiftInputFromName, stepperShiftAlloc, "Alias for shift-lag"},
-	{"shift-lag", stepperShiftLagInputFromName, stepperShiftLagAlloc, "Include value(s) in field(s) from previous record, if any"},
-	{"shift-lead", stepperShiftLeadInputFromName, stepperShiftLeadAlloc, "Include value(s) in field(s) from previous record, if any"},
+	{
+		"counter",
+		stepperCounterInputFromName,
+		stepperCounterAlloc,
+		"Count instances of field(s) between successive records",
+	},
+	{
+		"delta",
+		stepperDeltaInputFromName,
+		stepperDeltaAlloc,
+		"Compute differences in field(s) between successive records",
+	},
+	{
+		"ewma",
+		stepperEWMAInputFromName,
+		stepperEWMAAlloc,
+		"Exponentially weighted moving average over successive records",
+	},
+	{
+		"from-first",
+		stepperFromFirstInputFromName,
+		stepperFromFirstAlloc,
+		"Compute differences in field(s) from first record",
+	},
+	{
+		"ratio",
+		stepperRatioInputFromName,
+		stepperRatioAlloc,
+		"Compute ratios in field(s) between successive records",
+	},
+	{
+		"rsum",
+		stepperRsumInputFromName,
+		stepperRsumAlloc,
+		"Compute running sums of field(s) between successive records",
+	},
+	{
+		"shift",
+		stepperShiftInputFromName,
+		stepperShiftAlloc,
+		"Alias for shift-lag",
+	},
+	{
+		"shift-lag",
+		stepperShiftLagInputFromName,
+		stepperShiftLagAlloc,
+		"Include value(s) in field(s) from the previous record, if any",
+	},
+	{
+		"shift-lead",
+		stepperShiftLeadInputFromName,
+		stepperShiftLeadAlloc,
+		"Include value(s) in field(s) from the next record, if any",
+	},
 }
 
 func stepperInputFromName(
@@ -394,7 +660,7 @@ func allocateStepper(
 
 // ================================================================
 type tStepperDelta struct {
-	previous        *mlrval.Mlrval
+	inputFieldName  string
 	outputFieldName string
 }
 
@@ -414,32 +680,44 @@ func stepperDeltaAlloc(
 	_unused2 []string,
 ) tStepper {
 	return &tStepperDelta{
-		previous:        nil,
+		inputFieldName:  inputFieldName,
 		outputFieldName: inputFieldName + "_delta",
 	}
 }
 
 func (stepper *tStepperDelta) process(
-	valueFieldValue *mlrval.Mlrval,
-	inrec *mlrval.Mlrmap,
+	windowKeeper *utils.TWindowKeeper,
 ) {
-	if valueFieldValue.IsVoid() {
-		inrec.PutCopy(stepper.outputFieldName, mlrval.VOID)
+	icur := windowKeeper.Get(0)
+	if icur == nil {
+		return
+	}
+	currecAndContext := icur.(*types.RecordAndContext)
+	currec := currecAndContext.Record
+	currval := currec.Get(stepper.inputFieldName)
+
+	if currval.IsVoid() {
+		currec.PutCopy(stepper.outputFieldName, mlrval.VOID)
 		return
 	}
 
 	delta := mlrval.FromInt(0)
-	if stepper.previous != nil {
-		delta = bifs.BIF_minus_binary(valueFieldValue, stepper.previous)
-	}
-	inrec.PutCopy(stepper.outputFieldName, delta)
 
-	stepper.previous = valueFieldValue.Copy()
+	iprev := windowKeeper.Get(-1)
+	if iprev != nil {
+		prevrec := iprev.(*types.RecordAndContext).Record
+		prevval := prevrec.Get(stepper.inputFieldName)
+		if prevval != nil {
+			delta = bifs.BIF_minus_binary(currval, prevval)
+		}
+	}
+	currec.PutCopy(stepper.outputFieldName, delta.Copy())
 }
 
 // ================================================================
+// shift is an alias for shift
 type tStepperShiftLag struct {
-	previous        *mlrval.Mlrval
+	inputFieldName  string
 	outputFieldName string
 }
 
@@ -453,55 +731,66 @@ func stepperShiftInputFromName(
 	}
 }
 
+func stepperShiftLagInputFromName(
+	stepperName string,
+) *tStepperInput {
+	return &tStepperInput{
+		name:               stepperName,
+		numRecordsBackward: 1,
+		numRecordsForward:  0,
+	}
+}
+
 func stepperShiftAlloc(
 	inputFieldName string,
 	_unused1 []string,
 	_unused2 []string,
 ) tStepper {
 	return &tStepperShiftLag{
-		previous:        nil,
+		inputFieldName:  inputFieldName,
 		outputFieldName: inputFieldName + "_shift",
 	}
 }
 
-func stepperShiftLagInputFromName(
-	stepperName string,
-) *tStepperInput {
-	return &tStepperInput{
-		name:               stepperName,
-		numRecordsBackward: 1,
-		numRecordsForward:  0,
-	}
-}
-
 func stepperShiftLagAlloc(
 	inputFieldName string,
 	_unused1 []string,
 	_unused2 []string,
 ) tStepper {
 	return &tStepperShiftLag{
-		previous:        nil,
+		inputFieldName:  inputFieldName,
 		outputFieldName: inputFieldName + "_shift_lag",
 	}
 }
 
 func (stepper *tStepperShiftLag) process(
-	valueFieldValue *mlrval.Mlrval,
-	inrec *mlrval.Mlrmap,
+	windowKeeper *utils.TWindowKeeper,
 ) {
-	if stepper.previous == nil {
-		shift := mlrval.VOID
-		inrec.PutCopy(stepper.outputFieldName, shift)
-	} else {
-		inrec.PutCopy(stepper.outputFieldName, stepper.previous)
-		stepper.previous = valueFieldValue.Copy()
+	icur := windowKeeper.Get(0)
+	if icur == nil {
+		return
+	}
+	currecAndContext := icur.(*types.RecordAndContext)
+	currec := currecAndContext.Record
+
+	iprev := windowKeeper.Get(-1)
+	if iprev == nil {
+		currec.PutCopy(stepper.outputFieldName, mlrval.VOID)
+		return
+	}
+	prevrec := iprev.(*types.RecordAndContext).Record
+	prevval := prevrec.Get(stepper.inputFieldName)
+
+	if prevval == nil {
+		currec.PutCopy(stepper.outputFieldName, mlrval.VOID)
+	} else {
+		currec.PutCopy(stepper.outputFieldName, prevval.Copy())
 	}
-	stepper.previous = valueFieldValue.Copy()
 }
 
 // ================================================================
 type tStepperShiftLead struct {
-	previous        *mlrval.Mlrval
+	inputFieldName  string
 	outputFieldName string
 }
 
@@ -521,28 +810,38 @@ func stepperShiftLeadAlloc(
 	_unused2 []string,
 ) tStepper {
 	return &tStepperShiftLead{
-		previous:        nil,
+		inputFieldName:  inputFieldName,
 		outputFieldName: inputFieldName + "_shift_lead",
 	}
 }
 
 func (stepper *tStepperShiftLead) process(
-	valueFieldValue *mlrval.Mlrval,
-	inrec *mlrval.Mlrmap,
+	windowKeeper *utils.TWindowKeeper,
 ) {
-	if stepper.previous == nil {
-		shift := mlrval.VOID
-		inrec.PutCopy(stepper.outputFieldName, shift)
-	} else {
-		inrec.PutCopy(stepper.outputFieldName, stepper.previous)
-		stepper.previous = valueFieldValue.Copy()
+	icur := windowKeeper.Get(0)
+	if icur == nil {
+		return
+	}
+	currecAndContext := icur.(*types.RecordAndContext)
+	currec := currecAndContext.Record
+
+	inextrec := windowKeeper.Get(1)
+	if inextrec == nil {
+		currec.PutCopy(stepper.outputFieldName, mlrval.VOID)
+		return
+	}
+	nextrec := inextrec.(*types.RecordAndContext).Record
+	nextval := nextrec.Get(stepper.inputFieldName)
+
+	if nextval != nil {
+		currec.PutCopy(stepper.outputFieldName, nextval.Copy())
 	}
-	stepper.previous = valueFieldValue.Copy()
 }
 
 // ================================================================
 type tStepperFromFirst struct {
 	first           *mlrval.Mlrval
+	inputFieldName  string
 	outputFieldName string
 }
 
@@ -563,26 +862,34 @@ func stepperFromFirstAlloc(
 ) tStepper {
 	return &tStepperFromFirst{
 		first:           nil,
+		inputFieldName:  inputFieldName,
 		outputFieldName: inputFieldName + "_from_first",
 	}
 }
 
 func (stepper *tStepperFromFirst) process(
-	valueFieldValue *mlrval.Mlrval,
-	inrec *mlrval.Mlrmap,
+	windowKeeper *utils.TWindowKeeper,
 ) {
+	icur := windowKeeper.Get(0)
+	if icur == nil {
+		return
+	}
+	currecAndContext := icur.(*types.RecordAndContext)
+	currec := currecAndContext.Record
+	currval := currec.Get(stepper.inputFieldName)
+
 	fromFirst := mlrval.FromInt(0)
 	if stepper.first == nil {
-		stepper.first = valueFieldValue.Copy()
+		stepper.first = currval.Copy()
 	} else {
-		fromFirst = bifs.BIF_minus_binary(valueFieldValue, stepper.first)
+		fromFirst = bifs.BIF_minus_binary(currval, stepper.first)
 	}
-	inrec.PutCopy(stepper.outputFieldName, fromFirst)
+	currec.PutCopy(stepper.outputFieldName, fromFirst)
 }
 
 // ================================================================
 type tStepperRatio struct {
-	previous        *mlrval.Mlrval
+	inputFieldName  string
 	outputFieldName string
 }
 
@@ -602,32 +909,44 @@ func stepperRatioAlloc(
 	_unused2 []string,
 ) tStepper {
 	return &tStepperRatio{
-		previous:        nil,
+		inputFieldName:  inputFieldName,
 		outputFieldName: inputFieldName + "_ratio",
 	}
 }
 
 func (stepper *tStepperRatio) process(
-	valueFieldValue *mlrval.Mlrval,
-	inrec *mlrval.Mlrmap,
+	windowKeeper *utils.TWindowKeeper,
 ) {
-	if valueFieldValue.IsVoid() {
-		inrec.PutCopy(stepper.outputFieldName, mlrval.VOID)
+	icur := windowKeeper.Get(0)
+	if icur == nil {
+		return
+	}
+	currecAndContext := icur.(*types.RecordAndContext)
+	currec := currecAndContext.Record
+	currval := currec.Get(stepper.inputFieldName)
+
+	if currval.IsVoid() {
+		currec.PutCopy(stepper.outputFieldName, mlrval.VOID)
 		return
 	}
 
 	ratio := mlrval.FromInt(1)
-	if stepper.previous != nil {
-		ratio = bifs.BIF_divide(valueFieldValue, stepper.previous)
-	}
-	inrec.PutCopy(stepper.outputFieldName, ratio)
 
-	stepper.previous = valueFieldValue.Copy()
+	iprev := windowKeeper.Get(-1)
+	if iprev != nil {
+		prevrec := iprev.(*types.RecordAndContext).Record
+		prevval := prevrec.Get(stepper.inputFieldName)
+		if prevval != nil {
+			ratio = bifs.BIF_divide(currval, prevval)
+		}
+	}
+	currec.PutCopy(stepper.outputFieldName, ratio.Copy())
 }
 
 // ================================================================
 type tStepperRsum struct {
 	rsum            *mlrval.Mlrval
+	inputFieldName  string
 	outputFieldName string
 }
 
@@ -648,26 +967,34 @@ func stepperRsumAlloc(
 ) tStepper {
 	return &tStepperRsum{
 		rsum:            mlrval.FromInt(0),
+		inputFieldName:  inputFieldName,
 		outputFieldName: inputFieldName + "_rsum",
 	}
 }
 
 func (stepper *tStepperRsum) process(
-	valueFieldValue *mlrval.Mlrval,
-	inrec *mlrval.Mlrmap,
+	windowKeeper *utils.TWindowKeeper,
 ) {
-	if valueFieldValue.IsVoid() {
-		inrec.PutCopy(stepper.outputFieldName, mlrval.VOID)
+	icur := windowKeeper.Get(0)
+	if icur == nil {
+		return
+	}
+	currecAndContext := icur.(*types.RecordAndContext)
+	currec := currecAndContext.Record
+	currval := currec.Get(stepper.inputFieldName)
+
+	if currval.IsVoid() {
+		currec.PutCopy(stepper.outputFieldName, mlrval.VOID)
 	} else {
-		stepper.rsum = bifs.BIF_plus_binary(valueFieldValue, stepper.rsum)
-		inrec.PutCopy(stepper.outputFieldName, stepper.rsum)
+		stepper.rsum = bifs.BIF_plus_binary(currval, stepper.rsum)
+		currec.PutCopy(stepper.outputFieldName, stepper.rsum)
 	}
 }
 
 // ================================================================
 type tStepperCounter struct {
 	counter         *mlrval.Mlrval
-	one             *mlrval.Mlrval
+	inputFieldName  string
 	outputFieldName string
 }
 
@@ -688,31 +1015,38 @@ func stepperCounterAlloc(
 ) tStepper {
 	return &tStepperCounter{
 		counter:         mlrval.FromInt(0),
-		one:             mlrval.FromInt(1),
+		inputFieldName:  inputFieldName,
 		outputFieldName: inputFieldName + "_counter",
 	}
 }
 
 func (stepper *tStepperCounter) process(
-	valueFieldValue *mlrval.Mlrval,
-	inrec *mlrval.Mlrmap,
+	windowKeeper *utils.TWindowKeeper,
 ) {
-	if valueFieldValue.IsVoid() {
-		inrec.PutCopy(stepper.outputFieldName, mlrval.VOID)
+	icur := windowKeeper.Get(0)
+	if icur == nil {
+		return
+	}
+	currecAndContext := icur.(*types.RecordAndContext)
+	currec := currecAndContext.Record
+	currval := currec.Get(stepper.inputFieldName)
+
+	if currval.IsVoid() {
+		currec.PutCopy(stepper.outputFieldName, mlrval.VOID)
 	} else {
-		stepper.counter = bifs.BIF_plus_binary(stepper.counter, stepper.one)
-		inrec.PutCopy(stepper.outputFieldName, stepper.counter)
+		stepper.counter = bifs.BIF_plus_binary(stepper.counter, mlrval.ONE)
+		currec.PutCopy(stepper.outputFieldName, stepper.counter)
 	}
 }
 
-// ----------------------------------------------------------------
+// ================================================================
 // https://en.wikipedia.org/wiki/Moving_average#Exponential_moving_average
 
-// ================================================================
 type tStepperEWMA struct {
 	alphas           []*mlrval.Mlrval
 	oneMinusAlphas   []*mlrval.Mlrval
 	prevs            []*mlrval.Mlrval
+	inputFieldName   string
 	outputFieldNames []string
 	havePrevs        bool
 }
@@ -733,8 +1067,8 @@ func stepperEWMAAlloc(
 	ewmaSuffixes []string,
 ) tStepper {
 
-	// We trust our caller has already checked len(stringAlphas) ==
-	// len(ewmaSuffixes) in the CLI parser.
+	// We trust our caller has already checked len(stringAlphas) == len(ewmaSuffixes) in the CLI
+	// parser.
 	n := len(stringAlphas)
 
 	alphas := make([]*mlrval.Mlrval, n)
@@ -769,29 +1103,37 @@ func stepperEWMAAlloc(
 		alphas:           alphas,
 		oneMinusAlphas:   oneMinusAlphas,
 		prevs:            prevs,
+		inputFieldName:   inputFieldName,
 		outputFieldNames: outputFieldNames,
 		havePrevs:        false,
 	}
 }
 
 func (stepper *tStepperEWMA) process(
-	valueFieldValue *mlrval.Mlrval,
-	inrec *mlrval.Mlrmap,
+	windowKeeper *utils.TWindowKeeper,
 ) {
+	icur := windowKeeper.Get(0)
+	if icur == nil {
+		return
+	}
+	currecAndContext := icur.(*types.RecordAndContext)
+	currec := currecAndContext.Record
+	currval := currec.Get(stepper.inputFieldName)
+
 	if !stepper.havePrevs {
 		for i := range stepper.alphas {
-			inrec.PutCopy(stepper.outputFieldNames[i], valueFieldValue)
-			stepper.prevs[i] = valueFieldValue.Copy()
+			currec.PutCopy(stepper.outputFieldNames[i], currval)
+			stepper.prevs[i] = currval.Copy()
 		}
 		stepper.havePrevs = true
 	} else {
 		for i := range stepper.alphas {
-			curr := valueFieldValue.Copy()
+			curr := currval.Copy()
 			next := bifs.BIF_plus_binary(
 				bifs.BIF_times(curr, stepper.alphas[i]),
 				bifs.BIF_times(stepper.prevs[i], stepper.oneMinusAlphas[i]),
 			)
-			inrec.PutCopy(stepper.outputFieldNames[i], next)
+			currec.PutCopy(stepper.outputFieldNames[i], next)
 			stepper.prevs[i] = next
 		}
 	}
diff --git a/internal/pkg/transformers/utils/window-keeper.go b/internal/pkg/transformers/utils/window-keeper.go
index 08db173e9..7c213ad71 100644
--- a/internal/pkg/transformers/utils/window-keeper.go
+++ b/internal/pkg/transformers/utils/window-keeper.go
@@ -7,64 +7,64 @@ import (
 // WindowKeeper is a sliding-window container, nominally for use by mlr step,
 // for holding a number of records before the current one, the current one, and
 // a number of records after. The payload is interface{}, not *mlrval.Mlrmap,
-// for ease of unit-testing -- as well as since nothing here inspects the
-// payload, so this code could be repurposed.
-type WindowKeeper struct {
+// for ease of unit-testing -- and also, since nothing here inspects the
+// payload, so that this code could be repurposed.
+type TWindowKeeper struct {
 	numBackward int
 	numForward  int
 
-	recordsBackward []interface{}
-	currentRecord   interface{}
-	recordsForward  []interface{}
+	itemsBackward []interface{}
+	currentItem   interface{}
+	itemsForward  []interface{}
 }
 
 func NewWindowKeeper(
 	numBackward int,
 	numForward int,
-) *WindowKeeper {
-	return &WindowKeeper{
+) *TWindowKeeper {
+	return &TWindowKeeper{
 		numBackward: numBackward,
 		numForward:  numForward,
 
-		recordsBackward: make([]interface{}, numBackward),
-		currentRecord:   nil,
-		recordsForward:  make([]interface{}, numForward),
+		itemsBackward: make([]interface{}, numBackward),
+		currentItem:   nil,
+		itemsForward:  make([]interface{}, numForward),
 	}
 }
 
-func (wk *WindowKeeper) IngestRecord(
+func (wk *TWindowKeeper) Ingest(
 	inrec interface{},
 ) {
 	for i := wk.numBackward - 1; i > 0; i-- {
-		wk.recordsBackward[i] = wk.recordsBackward[i-1]
+		wk.itemsBackward[i] = wk.itemsBackward[i-1]
 	}
 	if wk.numBackward > 0 {
-		wk.recordsBackward[0] = wk.currentRecord
+		wk.itemsBackward[0] = wk.currentItem
 	}
 	if wk.numForward > 0 {
-		wk.currentRecord = wk.recordsForward[0]
+		wk.currentItem = wk.itemsForward[0]
 		for i := 0; i < wk.numForward-1; i++ {
-			wk.recordsForward[i] = wk.recordsForward[i+1]
+			wk.itemsForward[i] = wk.itemsForward[i+1]
 		}
-		wk.recordsForward[wk.numForward-1] = inrec
+		wk.itemsForward[wk.numForward-1] = inrec
 	} else {
-		wk.currentRecord = inrec
+		wk.currentItem = inrec
 	}
 }
 
-// GetRecord maps a user-visible indexing ..., -3, -2, -1, 0, 1, 2, 3, ...
+// Get maps a user-visible indexing ..., -3, -2, -1, 0, 1, 2, 3, ...
 // into this struct's zero-index array storage.
-func (wk *WindowKeeper) GetRecord(
+func (wk *TWindowKeeper) Get(
 	index int,
 ) interface{} {
 	if index == 0 {
-		return wk.currentRecord
+		return wk.currentItem
 	} else if index > 0 {
 		lib.InternalCodingErrorIf(index > wk.numForward)
-		return wk.recordsForward[index-1]
+		return wk.itemsForward[index-1]
 	} else {
 		index = -index
 		lib.InternalCodingErrorIf(index > wk.numBackward)
-		return wk.recordsBackward[index-1]
+		return wk.itemsBackward[index-1]
 	}
 }
diff --git a/internal/pkg/transformers/utils/window_keeper_test.go b/internal/pkg/transformers/utils/window_keeper_test.go
index 223dc4653..0b015347b 100644
--- a/internal/pkg/transformers/utils/window_keeper_test.go
+++ b/internal/pkg/transformers/utils/window_keeper_test.go
@@ -9,149 +9,149 @@ import (
 func Test00(t *testing.T) {
 	wk := NewWindowKeeper(0, 0)
 
-	wk.IngestRecord("a")
-	assert.Equal(t, "a", wk.GetRecord(0).(string))
+	wk.Ingest("a")
+	assert.Equal(t, "a", wk.Get(0).(string))
 
-	wk.IngestRecord("b")
-	assert.Equal(t, "b", wk.GetRecord(0).(string))
+	wk.Ingest("b")
+	assert.Equal(t, "b", wk.Get(0).(string))
 }
 
 func Test10(t *testing.T) {
 	wk := NewWindowKeeper(1, 0)
 
-	wk.IngestRecord("a")
-	assert.Equal(t, "a", wk.GetRecord(0).(string))
-	assert.Equal(t, nil, wk.GetRecord(-1))
+	wk.Ingest("a")
+	assert.Equal(t, "a", wk.Get(0).(string))
+	assert.Equal(t, nil, wk.Get(-1))
 
-	wk.IngestRecord("b")
-	assert.Equal(t, "b", wk.GetRecord(0).(string))
-	assert.Equal(t, "a", wk.GetRecord(-1).(string))
+	wk.Ingest("b")
+	assert.Equal(t, "b", wk.Get(0).(string))
+	assert.Equal(t, "a", wk.Get(-1).(string))
 
-	wk.IngestRecord("c")
-	assert.Equal(t, "c", wk.GetRecord(0).(string))
-	assert.Equal(t, "b", wk.GetRecord(-1).(string))
+	wk.Ingest("c")
+	assert.Equal(t, "c", wk.Get(0).(string))
+	assert.Equal(t, "b", wk.Get(-1).(string))
 }
 
 func Test20(t *testing.T) {
 	wk := NewWindowKeeper(2, 0)
 
-	wk.IngestRecord("a")
-	assert.Equal(t, "a", wk.GetRecord(0).(string))
-	assert.Equal(t, nil, wk.GetRecord(-1))
-	assert.Equal(t, nil, wk.GetRecord(-2))
+	wk.Ingest("a")
+	assert.Equal(t, "a", wk.Get(0).(string))
+	assert.Equal(t, nil, wk.Get(-1))
+	assert.Equal(t, nil, wk.Get(-2))
 
-	wk.IngestRecord("b")
-	assert.Equal(t, "b", wk.GetRecord(0).(string))
-	assert.Equal(t, "a", wk.GetRecord(-1).(string))
-	assert.Equal(t, nil, wk.GetRecord(-2))
+	wk.Ingest("b")
+	assert.Equal(t, "b", wk.Get(0).(string))
+	assert.Equal(t, "a", wk.Get(-1).(string))
+	assert.Equal(t, nil, wk.Get(-2))
 
-	wk.IngestRecord("c")
-	assert.Equal(t, "c", wk.GetRecord(0).(string))
-	assert.Equal(t, "b", wk.GetRecord(-1).(string))
-	assert.Equal(t, "a", wk.GetRecord(-2).(string))
+	wk.Ingest("c")
+	assert.Equal(t, "c", wk.Get(0).(string))
+	assert.Equal(t, "b", wk.Get(-1).(string))
+	assert.Equal(t, "a", wk.Get(-2).(string))
 
-	wk.IngestRecord("d")
-	assert.Equal(t, "d", wk.GetRecord(0).(string))
-	assert.Equal(t, "c", wk.GetRecord(-1).(string))
-	assert.Equal(t, "b", wk.GetRecord(-2).(string))
+	wk.Ingest("d")
+	assert.Equal(t, "d", wk.Get(0).(string))
+	assert.Equal(t, "c", wk.Get(-1).(string))
+	assert.Equal(t, "b", wk.Get(-2).(string))
 }
 
 func Test01(t *testing.T) {
 	wk := NewWindowKeeper(0, 1)
 
-	wk.IngestRecord("a")
-	assert.Equal(t, "a", wk.GetRecord(1).(string))
-	assert.Equal(t, nil, wk.GetRecord(0))
+	wk.Ingest("a")
+	assert.Equal(t, "a", wk.Get(1).(string))
+	assert.Equal(t, nil, wk.Get(0))
 
-	wk.IngestRecord("b")
-	assert.Equal(t, "b", wk.GetRecord(1).(string))
-	assert.Equal(t, "a", wk.GetRecord(0).(string))
+	wk.Ingest("b")
+	assert.Equal(t, "b", wk.Get(1).(string))
+	assert.Equal(t, "a", wk.Get(0).(string))
 
-	wk.IngestRecord("c")
-	assert.Equal(t, "c", wk.GetRecord(1).(string))
-	assert.Equal(t, "b", wk.GetRecord(0).(string))
+	wk.Ingest("c")
+	assert.Equal(t, "c", wk.Get(1).(string))
+	assert.Equal(t, "b", wk.Get(0).(string))
 }
 
 func Test02(t *testing.T) {
 	wk := NewWindowKeeper(0, 2)
 
-	wk.IngestRecord("a")
-	assert.Equal(t, "a", wk.GetRecord(2).(string))
-	assert.Equal(t, nil, wk.GetRecord(1))
-	assert.Equal(t, nil, wk.GetRecord(0))
+	wk.Ingest("a")
+	assert.Equal(t, "a", wk.Get(2).(string))
+	assert.Equal(t, nil, wk.Get(1))
+	assert.Equal(t, nil, wk.Get(0))
 
-	wk.IngestRecord("b")
-	assert.Equal(t, "b", wk.GetRecord(2).(string))
-	assert.Equal(t, "a", wk.GetRecord(1).(string))
-	assert.Equal(t, nil, wk.GetRecord(0))
+	wk.Ingest("b")
+	assert.Equal(t, "b", wk.Get(2).(string))
+	assert.Equal(t, "a", wk.Get(1).(string))
+	assert.Equal(t, nil, wk.Get(0))
 
-	wk.IngestRecord("c")
-	assert.Equal(t, "c", wk.GetRecord(2).(string))
-	assert.Equal(t, "b", wk.GetRecord(1).(string))
-	assert.Equal(t, "a", wk.GetRecord(0).(string))
+	wk.Ingest("c")
+	assert.Equal(t, "c", wk.Get(2).(string))
+	assert.Equal(t, "b", wk.Get(1).(string))
+	assert.Equal(t, "a", wk.Get(0).(string))
 
-	wk.IngestRecord("d")
-	assert.Equal(t, "d", wk.GetRecord(2).(string))
-	assert.Equal(t, "c", wk.GetRecord(1).(string))
-	assert.Equal(t, "b", wk.GetRecord(0).(string))
+	wk.Ingest("d")
+	assert.Equal(t, "d", wk.Get(2).(string))
+	assert.Equal(t, "c", wk.Get(1).(string))
+	assert.Equal(t, "b", wk.Get(0).(string))
 }
 
 func Test32(t *testing.T) {
 	wk := NewWindowKeeper(3, 2)
 
-	wk.IngestRecord("a")
-	assert.Equal(t, "a", wk.GetRecord(2).(string))
-	assert.Equal(t, nil, wk.GetRecord(1))
-	assert.Equal(t, nil, wk.GetRecord(0))
-	assert.Equal(t, nil, wk.GetRecord(-1))
-	assert.Equal(t, nil, wk.GetRecord(-2))
-	assert.Equal(t, nil, wk.GetRecord(-3))
+	wk.Ingest("a")
+	assert.Equal(t, "a", wk.Get(2).(string))
+	assert.Equal(t, nil, wk.Get(1))
+	assert.Equal(t, nil, wk.Get(0))
+	assert.Equal(t, nil, wk.Get(-1))
+	assert.Equal(t, nil, wk.Get(-2))
+	assert.Equal(t, nil, wk.Get(-3))
 
-	wk.IngestRecord("b")
-	assert.Equal(t, "b", wk.GetRecord(2).(string))
-	assert.Equal(t, "a", wk.GetRecord(1).(string))
-	assert.Equal(t, nil, wk.GetRecord(0))
-	assert.Equal(t, nil, wk.GetRecord(-1))
-	assert.Equal(t, nil, wk.GetRecord(-2))
-	assert.Equal(t, nil, wk.GetRecord(-3))
+	wk.Ingest("b")
+	assert.Equal(t, "b", wk.Get(2).(string))
+	assert.Equal(t, "a", wk.Get(1).(string))
+	assert.Equal(t, nil, wk.Get(0))
+	assert.Equal(t, nil, wk.Get(-1))
+	assert.Equal(t, nil, wk.Get(-2))
+	assert.Equal(t, nil, wk.Get(-3))
 
-	wk.IngestRecord("c")
-	assert.Equal(t, "c", wk.GetRecord(2).(string))
-	assert.Equal(t, "b", wk.GetRecord(1).(string))
-	assert.Equal(t, "a", wk.GetRecord(0).(string))
-	assert.Equal(t, nil, wk.GetRecord(-1))
-	assert.Equal(t, nil, wk.GetRecord(-2))
-	assert.Equal(t, nil, wk.GetRecord(-3))
+	wk.Ingest("c")
+	assert.Equal(t, "c", wk.Get(2).(string))
+	assert.Equal(t, "b", wk.Get(1).(string))
+	assert.Equal(t, "a", wk.Get(0).(string))
+	assert.Equal(t, nil, wk.Get(-1))
+	assert.Equal(t, nil, wk.Get(-2))
+	assert.Equal(t, nil, wk.Get(-3))
 
-	wk.IngestRecord("d")
-	assert.Equal(t, "d", wk.GetRecord(2).(string))
-	assert.Equal(t, "c", wk.GetRecord(1).(string))
-	assert.Equal(t, "b", wk.GetRecord(0).(string))
-	assert.Equal(t, "a", wk.GetRecord(-1).(string))
-	assert.Equal(t, nil, wk.GetRecord(-2))
-	assert.Equal(t, nil, wk.GetRecord(-3))
+	wk.Ingest("d")
+	assert.Equal(t, "d", wk.Get(2).(string))
+	assert.Equal(t, "c", wk.Get(1).(string))
+	assert.Equal(t, "b", wk.Get(0).(string))
+	assert.Equal(t, "a", wk.Get(-1).(string))
+	assert.Equal(t, nil, wk.Get(-2))
+	assert.Equal(t, nil, wk.Get(-3))
 
-	wk.IngestRecord("e")
-	assert.Equal(t, "e", wk.GetRecord(2).(string))
-	assert.Equal(t, "d", wk.GetRecord(1).(string))
-	assert.Equal(t, "c", wk.GetRecord(0).(string))
-	assert.Equal(t, "b", wk.GetRecord(-1).(string))
-	assert.Equal(t, "a", wk.GetRecord(-2).(string))
-	assert.Equal(t, nil, wk.GetRecord(-3))
+	wk.Ingest("e")
+	assert.Equal(t, "e", wk.Get(2).(string))
+	assert.Equal(t, "d", wk.Get(1).(string))
+	assert.Equal(t, "c", wk.Get(0).(string))
+	assert.Equal(t, "b", wk.Get(-1).(string))
+	assert.Equal(t, "a", wk.Get(-2).(string))
+	assert.Equal(t, nil, wk.Get(-3))
 
-	wk.IngestRecord("f")
-	assert.Equal(t, "f", wk.GetRecord(2).(string))
-	assert.Equal(t, "e", wk.GetRecord(1).(string))
-	assert.Equal(t, "d", wk.GetRecord(0).(string))
-	assert.Equal(t, "c", wk.GetRecord(-1).(string))
-	assert.Equal(t, "b", wk.GetRecord(-2).(string))
-	assert.Equal(t, "a", wk.GetRecord(-3).(string))
+	wk.Ingest("f")
+	assert.Equal(t, "f", wk.Get(2).(string))
+	assert.Equal(t, "e", wk.Get(1).(string))
+	assert.Equal(t, "d", wk.Get(0).(string))
+	assert.Equal(t, "c", wk.Get(-1).(string))
+	assert.Equal(t, "b", wk.Get(-2).(string))
+	assert.Equal(t, "a", wk.Get(-3).(string))
 
-	wk.IngestRecord("g")
-	assert.Equal(t, "g", wk.GetRecord(2).(string))
-	assert.Equal(t, "f", wk.GetRecord(1).(string))
-	assert.Equal(t, "e", wk.GetRecord(0).(string))
-	assert.Equal(t, "d", wk.GetRecord(-1).(string))
-	assert.Equal(t, "c", wk.GetRecord(-2).(string))
-	assert.Equal(t, "b", wk.GetRecord(-3).(string))
+	wk.Ingest("g")
+	assert.Equal(t, "g", wk.Get(2).(string))
+	assert.Equal(t, "f", wk.Get(1).(string))
+	assert.Equal(t, "e", wk.Get(0).(string))
+	assert.Equal(t, "d", wk.Get(-1).(string))
+	assert.Equal(t, "c", wk.Get(-2).(string))
+	assert.Equal(t, "b", wk.Get(-3).(string))
 }
diff --git a/man/manpage.txt b/man/manpage.txt
index 68c443582..62d3f38a6 100644
--- a/man/manpage.txt
+++ b/man/manpage.txt
@@ -1804,31 +1804,33 @@ VERBS
 
    step
        Usage: mlr step [options]
-       Computes values dependent on the previous record, optionally grouped by category.
+       Computes values dependent on earlier/later records, optionally grouped by category.
        Options:
-       -a {delta,rsum,...}   Names of steppers: comma-separated, one or more of:
-         delta    Compute differences in field(s) between successive records
-         shift    Include value(s) in field(s) from previous record, if any
+       -a {delta,rsum,...} Names of steppers: comma-separated, one or more of:
+         counter    Count instances of field(s) between successive records
+         delta      Compute differences in field(s) between successive records
+         ewma       Exponentially weighted moving average over successive records
          from-first Compute differences in field(s) from first record
-         ratio    Compute ratios in field(s) between successive records
-         rsum     Compute running sums of field(s) between successive records
-         counter  Count instances of field(s) between successive records
-         ewma     Exponentially weighted moving average over successive records
+         ratio      Compute ratios in field(s) between successive records
+         rsum       Compute running sums of field(s) between successive records
+         shift      Alias for shift-lag
+         shift-lag  Include value(s) in field(s) from the previous record, if any
+         shift-lead Include value(s) in field(s) from the next record, if any
 
-       -f {a,b,c} Value-field names on which to compute statistics
-       -g {d,e,f} Optional group-by-field names
-       -F         Computes integerable things (e.g. counter) in floating point.
-                  As of Miller 6 this happens automatically, but the flag is accepted
-                  as a no-op for backward compatibility with Miller 5 and below.
-       -d {x,y,z} Weights for ewma. 1 means current sample gets all weight (no
-                  smoothing), near under under 1 is light smoothing, near over 0 is
-                  heavy smoothing. Multiple weights may be specified, e.g.
-                  "mlr step -a ewma -f sys_load -d 0.01,0.1,0.9". Default if omitted
-                  is "-d 0.5".
-       -o {a,b,c} Custom suffixes for EWMA output fields. If omitted, these default to
-                  the -d values. If supplied, the number of -o values must be the same
-                  as the number of -d values.
-       -h|--help Show this message.
+       -f {a,b,c}   Value-field names on which to compute statistics
+       -g {d,e,f}   Optional group-by-field names
+       -F           Computes integerable things (e.g. counter) in floating point.
+                    As of Miller 6 this happens automatically, but the flag is accepted
+                    as a no-op for backward compatibility with Miller 5 and below.
+       -d {x,y,z}   Weights for EWMA. 1 means current sample gets all weight (no
+                    smoothing), near under under 1 is light smoothing, near over 0 is
+                    heavy smoothing. Multiple weights may be specified, e.g.
+                    "mlr step -a ewma -f sys_load -d 0.01,0.1,0.9". Default if omitted
+                    is "-d 0.5".
+       -o {a,b,c}   Custom suffixes for EWMA output fields. If omitted, these default to
+                    the -d values. If supplied, the number of -o values must be the same
+                    as the number of -d values.
+       -h|--help S  how this message.
 
        Examples:
          mlr step -a rsum -f request_size
@@ -3065,4 +3067,4 @@ SEE ALSO
 
 
 
-                                  2022-01-20                         MILLER(1)
+                                  2022-01-23                         MILLER(1)
diff --git a/man/mlr.1 b/man/mlr.1
index ff56bc994..e40a79860 100644
--- a/man/mlr.1
+++ b/man/mlr.1
@@ -2,12 +2,12 @@
 .\"     Title: mlr
 .\"    Author: [see the "AUTHOR" section]
 .\" Generator: ./mkman.rb
-.\"      Date: 2022-01-20
+.\"      Date: 2022-01-23
 .\"    Manual: \ \&
 .\"    Source: \ \&
 .\"  Language: English
 .\"
-.TH "MILLER" "1" "2022-01-20" "\ \&" "\ \&"
+.TH "MILLER" "1" "2022-01-23" "\ \&" "\ \&"
 .\" -----------------------------------------------------------------
 .\" * Portability definitions
 .\" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -2273,31 +2273,33 @@ Example: mlr stats2 -a corr -f x,y
 .\}
 .nf
 Usage: mlr step [options]
-Computes values dependent on the previous record, optionally grouped by category.
+Computes values dependent on earlier/later records, optionally grouped by category.
 Options:
--a {delta,rsum,...}   Names of steppers: comma-separated, one or more of:
-  delta    Compute differences in field(s) between successive records
-  shift    Include value(s) in field(s) from previous record, if any
+-a {delta,rsum,...} Names of steppers: comma-separated, one or more of:
+  counter    Count instances of field(s) between successive records
+  delta      Compute differences in field(s) between successive records
+  ewma       Exponentially weighted moving average over successive records
   from-first Compute differences in field(s) from first record
-  ratio    Compute ratios in field(s) between successive records
-  rsum     Compute running sums of field(s) between successive records
-  counter  Count instances of field(s) between successive records
-  ewma     Exponentially weighted moving average over successive records
+  ratio      Compute ratios in field(s) between successive records
+  rsum       Compute running sums of field(s) between successive records
+  shift      Alias for shift-lag
+  shift-lag  Include value(s) in field(s) from the previous record, if any
+  shift-lead Include value(s) in field(s) from the next record, if any
 
--f {a,b,c} Value-field names on which to compute statistics
--g {d,e,f} Optional group-by-field names
--F         Computes integerable things (e.g. counter) in floating point.
-           As of Miller 6 this happens automatically, but the flag is accepted
-           as a no-op for backward compatibility with Miller 5 and below.
--d {x,y,z} Weights for ewma. 1 means current sample gets all weight (no
-           smoothing), near under under 1 is light smoothing, near over 0 is
-           heavy smoothing. Multiple weights may be specified, e.g.
-           "mlr step -a ewma -f sys_load -d 0.01,0.1,0.9". Default if omitted
-           is "-d 0.5".
--o {a,b,c} Custom suffixes for EWMA output fields. If omitted, these default to
-           the -d values. If supplied, the number of -o values must be the same
-           as the number of -d values.
--h|--help Show this message.
+-f {a,b,c}   Value-field names on which to compute statistics
+-g {d,e,f}   Optional group-by-field names
+-F           Computes integerable things (e.g. counter) in floating point.
+             As of Miller 6 this happens automatically, but the flag is accepted
+             as a no-op for backward compatibility with Miller 5 and below.
+-d {x,y,z}   Weights for EWMA. 1 means current sample gets all weight (no
+             smoothing), near under under 1 is light smoothing, near over 0 is
+             heavy smoothing. Multiple weights may be specified, e.g.
+             "mlr step -a ewma -f sys_load -d 0.01,0.1,0.9". Default if omitted
+             is "-d 0.5".
+-o {a,b,c}   Custom suffixes for EWMA output fields. If omitted, these default to
+             the -d values. If supplied, the number of -o values must be the same
+             as the number of -d values.
+-h|--help S  how this message.
 
 Examples:
   mlr step -a rsum -f request_size
diff --git a/test/cases/cli-help/0001/expout b/test/cases/cli-help/0001/expout
index e575e6e77..0cf5dbc8b 100644
--- a/test/cases/cli-help/0001/expout
+++ b/test/cases/cli-help/0001/expout
@@ -1021,33 +1021,33 @@ Example: mlr stats2 -a corr -f x,y
 ================================================================
 step
 Usage: mlr step [options]
-Computes values dependent on the previous record, optionally grouped by category.
+Computes values dependent on earlier/later records, optionally grouped by category.
 Options:
--a {delta,rsum,...}   Names of steppers: comma-separated, one or more of:
-  counter  Count instances of field(s) between successive records
-  delta    Compute differences in field(s) between successive records
-  ewma     Exponentially weighted moving average over successive records
+-a {delta,rsum,...} Names of steppers: comma-separated, one or more of:
+  counter    Count instances of field(s) between successive records
+  delta      Compute differences in field(s) between successive records
+  ewma       Exponentially weighted moving average over successive records
   from-first Compute differences in field(s) from first record
-  ratio    Compute ratios in field(s) between successive records
-  rsum     Compute running sums of field(s) between successive records
-  shift    Alias for shift-lag
-  shift-lag Include value(s) in field(s) from previous record, if any
-  shift-lead Include value(s) in field(s) from previous record, if any
+  ratio      Compute ratios in field(s) between successive records
+  rsum       Compute running sums of field(s) between successive records
+  shift      Alias for shift-lag
+  shift-lag  Include value(s) in field(s) from the previous record, if any
+  shift-lead Include value(s) in field(s) from the next record, if any
 
--f {a,b,c} Value-field names on which to compute statistics
--g {d,e,f} Optional group-by-field names
--F         Computes integerable things (e.g. counter) in floating point.
-           As of Miller 6 this happens automatically, but the flag is accepted
-           as a no-op for backward compatibility with Miller 5 and below.
--d {x,y,z} Weights for EWMA. 1 means current sample gets all weight (no
-           smoothing), near under under 1 is light smoothing, near over 0 is
-           heavy smoothing. Multiple weights may be specified, e.g.
-           "mlr step -a ewma -f sys_load -d 0.01,0.1,0.9". Default if omitted
-           is "-d 0.5".
--o {a,b,c} Custom suffixes for EWMA output fields. If omitted, these default to
-           the -d values. If supplied, the number of -o values must be the same
-           as the number of -d values.
--h|--help Show this message.
+-f {a,b,c}   Value-field names on which to compute statistics
+-g {d,e,f}   Optional group-by-field names
+-F           Computes integerable things (e.g. counter) in floating point.
+             As of Miller 6 this happens automatically, but the flag is accepted
+             as a no-op for backward compatibility with Miller 5 and below.
+-d {x,y,z}   Weights for EWMA. 1 means current sample gets all weight (no
+             smoothing), near under under 1 is light smoothing, near over 0 is
+             heavy smoothing. Multiple weights may be specified, e.g.
+             "mlr step -a ewma -f sys_load -d 0.01,0.1,0.9". Default if omitted
+             is "-d 0.5".
+-o {a,b,c}   Custom suffixes for EWMA output fields. If omitted, these default to
+             the -d values. If supplied, the number of -o values must be the same
+             as the number of -d values.
+-h|--help S  how this message.
 
 Examples:
   mlr step -a rsum -f request_size
diff --git a/test/cases/verb-step/0005/cmd b/test/cases/verb-step/0005/cmd
index 2c390a7e8..d9ca4935b 100644
--- a/test/cases/verb-step/0005/cmd
+++ b/test/cases/verb-step/0005/cmd
@@ -1 +1 @@
-mlr --odkvp step -a rsum,shift,delta,counter -f x,y test/input/abixy-het
+mlr --ojson step -a rsum,shift,delta,counter -f x,y test/input/abixy-het
diff --git a/test/cases/verb-step/0005/expout b/test/cases/verb-step/0005/expout
index e2c2d44d1..113019e11 100644
--- a/test/cases/verb-step/0005/expout
+++ b/test/cases/verb-step/0005/expout
@@ -1,10 +1,144 @@
-a=pan,b=pan,i=1,x=0.3467901443380824,y=0.7268028627434533,x_rsum=0.3467901443380824,x_shift=,x_delta=0,x_counter=1,y_rsum=0.7268028627434533,y_shift=,y_delta=0,y_counter=1
-a=eks,b=pan,i=2,x=0.7586799647899636,y=0.5221511083334797,x_rsum=1.105470109128046,x_shift=0.3467901443380824,x_delta=0.41188982045188116,x_counter=2,y_rsum=1.2489539710769328,y_shift=0.7268028627434533,y_delta=-0.20465175440997363,y_counter=2
-aaa=wye,b=wye,i=3,x=0.20460330576630303,y=0.33831852551664776,x_rsum=1.3100734148943491,x_shift=0.7586799647899636,x_delta=-0.5540766590236605,x_counter=3,y_rsum=1.5872724965935805,y_shift=0.5221511083334797,y_delta=-0.1838325828168319,y_counter=3
-a=eks,bbb=wye,i=4,x=0.38139939387114097,y=0.13418874328430463,x_rsum=1.6914728087654902,x_shift=0.20460330576630303,x_delta=0.17679608810483793,x_counter=4,y_rsum=1.7214612398778852,y_shift=0.33831852551664776,y_delta=-0.20412978223234313,y_counter=4
-a=wye,b=pan,i=5,xxx=0.5732889198020006,y=0.8636244699032729,y_rsum=2.585085709781158,y_shift=0.13418874328430463,y_delta=0.7294357266189683,y_counter=5
-a=zee,b=pan,i=6,x=0.5271261600918548,y=0.49322128674835697,x_rsum=2.218598968857345,x_shift=0.38139939387114097,x_delta=0.1457267662207138,x_counter=5,y_rsum=3.0783069965295153,y_shift=0.8636244699032729,y_delta=-0.37040318315491594,y_counter=6
-a=eks,b=zee,iii=7,x=0.6117840605678454,y=0.1878849191181694,x_rsum=2.8303830294251906,x_shift=0.5271261600918548,x_delta=0.08465790047599064,x_counter=6,y_rsum=3.266191915647685,y_shift=0.49322128674835697,y_delta=-0.30533636763018757,y_counter=7
-a=zee,b=wye,i=8,x=0.5985540091064224,yyy=0.976181385699006,x_rsum=3.428937038531613,x_shift=0.6117840605678454,x_delta=-0.013230051461422976,x_counter=7
-aaa=hat,bbb=wye,i=9,x=0.03144187646093577,y=0.7495507603507059,x_rsum=3.460378914992549,x_shift=0.5985540091064224,x_delta=-0.5671121326454867,x_counter=8,y_rsum=4.015742675998391,y_shift=0.1878849191181694,y_delta=0.5616658412325365,y_counter=8
-a=pan,b=wye,i=10,x=0.5026260055412137,y=0.9526183602969864,x_rsum=3.9630049205337627,x_shift=0.03144187646093577,x_delta=0.47118412908027796,x_counter=9,y_rsum=4.968361036295377,y_shift=0.7495507603507059,y_delta=0.20306759994628054,y_counter=9
+[
+{
+  "a": "pan",
+  "b": "pan",
+  "i": 1,
+  "x": 0.3467901443380824,
+  "y": 0.7268028627434533,
+  "x_rsum": 0.3467901443380824,
+  "x_shift": "",
+  "x_delta": 0,
+  "x_counter": 1,
+  "y_rsum": 0.7268028627434533,
+  "y_shift": "",
+  "y_delta": 0,
+  "y_counter": 1
+},
+{
+  "a": "eks",
+  "b": "pan",
+  "i": 2,
+  "x": 0.7586799647899636,
+  "y": 0.5221511083334797,
+  "x_rsum": 1.105470109128046,
+  "x_shift": 0.3467901443380824,
+  "x_delta": 0.41188982045188116,
+  "x_counter": 2,
+  "y_rsum": 1.2489539710769328,
+  "y_shift": 0.7268028627434533,
+  "y_delta": -0.20465175440997363,
+  "y_counter": 2
+},
+{
+  "aaa": "wye",
+  "b": "wye",
+  "i": 3,
+  "x": 0.20460330576630303,
+  "y": 0.33831852551664776,
+  "x_rsum": 1.3100734148943491,
+  "x_shift": 0.7586799647899636,
+  "x_delta": -0.5540766590236605,
+  "x_counter": 3,
+  "y_rsum": 1.5872724965935805,
+  "y_shift": 0.5221511083334797,
+  "y_delta": -0.1838325828168319,
+  "y_counter": 3
+},
+{
+  "a": "eks",
+  "bbb": "wye",
+  "i": 4,
+  "x": 0.38139939387114097,
+  "y": 0.13418874328430463,
+  "x_rsum": 1.6914728087654902,
+  "x_shift": 0.20460330576630303,
+  "x_delta": 0.17679608810483793,
+  "x_counter": 4,
+  "y_rsum": 1.7214612398778852,
+  "y_shift": 0.33831852551664776,
+  "y_delta": -0.20412978223234313,
+  "y_counter": 4
+},
+{
+  "a": "wye",
+  "b": "pan",
+  "i": 5,
+  "xxx": 0.5732889198020006,
+  "y": 0.8636244699032729,
+  "y_rsum": 2.585085709781158,
+  "y_shift": 0.13418874328430463,
+  "y_delta": 0.7294357266189683,
+  "y_counter": 5
+},
+{
+  "a": "zee",
+  "b": "pan",
+  "i": 6,
+  "x": 0.5271261600918548,
+  "y": 0.49322128674835697,
+  "x_rsum": 2.218598968857345,
+  "x_shift": "",
+  "x_delta": 0,
+  "x_counter": 5,
+  "y_rsum": 3.0783069965295153,
+  "y_shift": 0.8636244699032729,
+  "y_delta": -0.37040318315491594,
+  "y_counter": 6
+},
+{
+  "a": "eks",
+  "b": "zee",
+  "iii": 7,
+  "x": 0.6117840605678454,
+  "y": 0.1878849191181694,
+  "x_rsum": 2.8303830294251906,
+  "x_shift": 0.5271261600918548,
+  "x_delta": 0.08465790047599064,
+  "x_counter": 6,
+  "y_rsum": 3.266191915647685,
+  "y_shift": 0.49322128674835697,
+  "y_delta": -0.30533636763018757,
+  "y_counter": 7
+},
+{
+  "a": "zee",
+  "b": "wye",
+  "i": 8,
+  "x": 0.5985540091064224,
+  "yyy": 0.976181385699006,
+  "x_rsum": 3.428937038531613,
+  "x_shift": 0.6117840605678454,
+  "x_delta": -0.013230051461422976,
+  "x_counter": 7
+},
+{
+  "aaa": "hat",
+  "bbb": "wye",
+  "i": 9,
+  "x": 0.03144187646093577,
+  "y": 0.7495507603507059,
+  "x_rsum": 3.460378914992549,
+  "x_shift": 0.5985540091064224,
+  "x_delta": -0.5671121326454867,
+  "x_counter": 8,
+  "y_rsum": 4.015742675998391,
+  "y_shift": "",
+  "y_delta": 0,
+  "y_counter": 8
+},
+{
+  "a": "pan",
+  "b": "wye",
+  "i": 10,
+  "x": 0.5026260055412137,
+  "y": 0.9526183602969864,
+  "x_rsum": 3.9630049205337627,
+  "x_shift": 0.03144187646093577,
+  "x_delta": 0.47118412908027796,
+  "x_counter": 9,
+  "y_rsum": 4.968361036295377,
+  "y_shift": 0.7495507603507059,
+  "y_delta": 0.20306759994628054,
+  "y_counter": 9
+}
+]
diff --git a/test/cases/verb-step/0011/cmd b/test/cases/verb-step/0011/cmd
new file mode 100644
index 000000000..5db891f3b
--- /dev/null
+++ b/test/cases/verb-step/0011/cmd
@@ -0,0 +1 @@
+mlr --opprint --from test/input/abixy step -a shift-lag -f i
diff --git a/test/cases/verb-step/0011/experr b/test/cases/verb-step/0011/experr
new file mode 100644
index 000000000..e69de29bb
diff --git a/test/cases/verb-step/0011/expout b/test/cases/verb-step/0011/expout
new file mode 100644
index 000000000..adf2eebca
--- /dev/null
+++ b/test/cases/verb-step/0011/expout
@@ -0,0 +1,11 @@
+a   b   i  x                   y                   i_shift_lag
+pan pan 1  0.3467901443380824  0.7268028627434533  -
+eks pan 2  0.7586799647899636  0.5221511083334797  1
+wye wye 3  0.20460330576630303 0.33831852551664776 2
+eks wye 4  0.38139939387114097 0.13418874328430463 3
+wye pan 5  0.5732889198020006  0.8636244699032729  4
+zee pan 6  0.5271261600918548  0.49322128674835697 5
+eks zee 7  0.6117840605678454  0.1878849191181694  6
+zee wye 8  0.5985540091064224  0.976181385699006   7
+hat wye 9  0.03144187646093577 0.7495507603507059  8
+pan wye 10 0.5026260055412137  0.9526183602969864  9
diff --git a/test/cases/verb-step/0012/cmd b/test/cases/verb-step/0012/cmd
new file mode 100644
index 000000000..6b3cf913a
--- /dev/null
+++ b/test/cases/verb-step/0012/cmd
@@ -0,0 +1 @@
+mlr --opprint --from test/input/abixy step -a shift-lead -f i
diff --git a/test/cases/verb-step/0012/experr b/test/cases/verb-step/0012/experr
new file mode 100644
index 000000000..e69de29bb
diff --git a/test/cases/verb-step/0012/expout b/test/cases/verb-step/0012/expout
new file mode 100644
index 000000000..893ace551
--- /dev/null
+++ b/test/cases/verb-step/0012/expout
@@ -0,0 +1,11 @@
+a   b   i  x                   y                   i_shift_lead
+pan pan 1  0.3467901443380824  0.7268028627434533  2
+eks pan 2  0.7586799647899636  0.5221511083334797  3
+wye wye 3  0.20460330576630303 0.33831852551664776 4
+eks wye 4  0.38139939387114097 0.13418874328430463 5
+wye pan 5  0.5732889198020006  0.8636244699032729  6
+zee pan 6  0.5271261600918548  0.49322128674835697 7
+eks zee 7  0.6117840605678454  0.1878849191181694  8
+zee wye 8  0.5985540091064224  0.976181385699006   9
+hat wye 9  0.03144187646093577 0.7495507603507059  10
+pan wye 10 0.5026260055412137  0.9526183602969864  -
diff --git a/test/cases/verb-step/0013/cmd b/test/cases/verb-step/0013/cmd
new file mode 100644
index 000000000..36da8ed39
--- /dev/null
+++ b/test/cases/verb-step/0013/cmd
@@ -0,0 +1 @@
+mlr --opprint --from test/input/abixy step -a shift-lag,shift-lead -f i
diff --git a/test/cases/verb-step/0013/experr b/test/cases/verb-step/0013/experr
new file mode 100644
index 000000000..e69de29bb
diff --git a/test/cases/verb-step/0013/expout b/test/cases/verb-step/0013/expout
new file mode 100644
index 000000000..a4229de75
--- /dev/null
+++ b/test/cases/verb-step/0013/expout
@@ -0,0 +1,11 @@
+a   b   i  x                   y                   i_shift_lag i_shift_lead
+pan pan 1  0.3467901443380824  0.7268028627434533  -           2
+eks pan 2  0.7586799647899636  0.5221511083334797  1           3
+wye wye 3  0.20460330576630303 0.33831852551664776 2           4
+eks wye 4  0.38139939387114097 0.13418874328430463 3           5
+wye pan 5  0.5732889198020006  0.8636244699032729  4           6
+zee pan 6  0.5271261600918548  0.49322128674835697 5           7
+eks zee 7  0.6117840605678454  0.1878849191181694  6           8
+zee wye 8  0.5985540091064224  0.976181385699006   7           9
+hat wye 9  0.03144187646093577 0.7495507603507059  8           10
+pan wye 10 0.5026260055412137  0.9526183602969864  9           -
diff --git a/test/cases/verb-step/0014/cmd b/test/cases/verb-step/0014/cmd
new file mode 100644
index 000000000..3b41e69a0
--- /dev/null
+++ b/test/cases/verb-step/0014/cmd
@@ -0,0 +1 @@
+mlr --opprint --from test/input/abixy step -a shift-lag -f i -g a then sort -f a
diff --git a/test/cases/verb-step/0014/experr b/test/cases/verb-step/0014/experr
new file mode 100644
index 000000000..e69de29bb
diff --git a/test/cases/verb-step/0014/expout b/test/cases/verb-step/0014/expout
new file mode 100644
index 000000000..13b5cab95
--- /dev/null
+++ b/test/cases/verb-step/0014/expout
@@ -0,0 +1,11 @@
+a   b   i  x                   y                   i_shift_lag
+eks pan 2  0.7586799647899636  0.5221511083334797  -
+eks wye 4  0.38139939387114097 0.13418874328430463 2
+eks zee 7  0.6117840605678454  0.1878849191181694  4
+hat wye 9  0.03144187646093577 0.7495507603507059  -
+pan pan 1  0.3467901443380824  0.7268028627434533  -
+pan wye 10 0.5026260055412137  0.9526183602969864  1
+wye wye 3  0.20460330576630303 0.33831852551664776 -
+wye pan 5  0.5732889198020006  0.8636244699032729  3
+zee pan 6  0.5271261600918548  0.49322128674835697 -
+zee wye 8  0.5985540091064224  0.976181385699006   6
diff --git a/test/cases/verb-step/0015/cmd b/test/cases/verb-step/0015/cmd
new file mode 100644
index 000000000..843fd66a2
--- /dev/null
+++ b/test/cases/verb-step/0015/cmd
@@ -0,0 +1 @@
+mlr --opprint --from test/input/abixy step -a shift-lead -f i -g a then sort -f a
diff --git a/test/cases/verb-step/0015/experr b/test/cases/verb-step/0015/experr
new file mode 100644
index 000000000..e69de29bb
diff --git a/test/cases/verb-step/0015/expout b/test/cases/verb-step/0015/expout
new file mode 100644
index 000000000..144b74825
--- /dev/null
+++ b/test/cases/verb-step/0015/expout
@@ -0,0 +1,11 @@
+a   b   i  x                   y                   i_shift_lead
+eks pan 2  0.7586799647899636  0.5221511083334797  4
+eks wye 4  0.38139939387114097 0.13418874328430463 7
+eks zee 7  0.6117840605678454  0.1878849191181694  -
+hat wye 9  0.03144187646093577 0.7495507603507059  -
+pan pan 1  0.3467901443380824  0.7268028627434533  10
+pan wye 10 0.5026260055412137  0.9526183602969864  -
+wye wye 3  0.20460330576630303 0.33831852551664776 5
+wye pan 5  0.5732889198020006  0.8636244699032729  -
+zee pan 6  0.5271261600918548  0.49322128674835697 8
+zee wye 8  0.5985540091064224  0.976181385699006   -
diff --git a/test/cases/verb-step/0016/cmd b/test/cases/verb-step/0016/cmd
new file mode 100644
index 000000000..5f1691591
--- /dev/null
+++ b/test/cases/verb-step/0016/cmd
@@ -0,0 +1 @@
+mlr --opprint --from test/input/abixy step -a shift-lag,shift-lead -f i -g a then sort -f a
diff --git a/test/cases/verb-step/0016/experr b/test/cases/verb-step/0016/experr
new file mode 100644
index 000000000..e69de29bb
diff --git a/test/cases/verb-step/0016/expout b/test/cases/verb-step/0016/expout
new file mode 100644
index 000000000..f30bf904d
--- /dev/null
+++ b/test/cases/verb-step/0016/expout
@@ -0,0 +1,11 @@
+a   b   i  x                   y                   i_shift_lag i_shift_lead
+eks pan 2  0.7586799647899636  0.5221511083334797  -           4
+eks wye 4  0.38139939387114097 0.13418874328430463 2           7
+eks zee 7  0.6117840605678454  0.1878849191181694  4           -
+hat wye 9  0.03144187646093577 0.7495507603507059  -           -
+pan pan 1  0.3467901443380824  0.7268028627434533  -           10
+pan wye 10 0.5026260055412137  0.9526183602969864  1           -
+wye wye 3  0.20460330576630303 0.33831852551664776 -           5
+wye pan 5  0.5732889198020006  0.8636244699032729  3           -
+zee pan 6  0.5271261600918548  0.49322128674835697 -           8
+zee wye 8  0.5985540091064224  0.976181385699006   6           -
diff --git a/todo.txt b/todo.txt
index bc2d95b65..ac007f6b1 100644
--- a/todo.txt
+++ b/todo.txt
@@ -25,16 +25,15 @@ k better print-interpolate with {} etc
 ! strmatch https://github.com/johnkerl/miller/issues/77#issuecomment-538790927
 
 ----------------------------------------------------------------
-! shift_lead and shift_lag steps
-  o RT: mlr --c2p --from $exv step -a delta -a rsum -f quantity -f rate -g color -g shape
-  o next: utils/stepper-window.go w/ dedicated UTs
-  i https://github.com/johnkerl/miller/issues/355
+a-b.go -> a_b.go renamer PR
 
 ----------------------------------------------------------------
 ! sliding window / moving average
   o port u/window*.mlr from mlrc to mlr (actually, fix mlr of course)
   o sliding-window averages into mapper step (C + Go)
 
+! make a lag-by-n and lead-by-n
+
 ----------------------------------------------------------------
 ! rank