Skip to content

Scanl fixes - #3259

Merged
harendra-kumar merged 12 commits into
masterfrom
scanl-fixes
Oct 16, 2026
Merged

harendra-kumar merged 12 commits into
masterfrom
scanl-fixes

Conversation

@harendra-kumar

Copy link
Copy Markdown
Member

Bug fixes in the Scanl module, plus a couple of related Fold and RingArray ones:

  • windowRange (Fold and Scanl): the input counter could overflow on a 32-bit Int (e.g. the JS backend), which broke the window after 2^31 inputs. It now stops counting once the window is full. The Scanl error message also said ringsOf instead of windowRange.
  • RingArray.scanRingsOf: same kind of overflow, in the byte counter.
  • Scanl.classify: a key whose scan finished on its first input got restarted on the next input with that key.
  • Scanl.sampleFromthen: the offset was applied the wrong way, so sampleFromthen 1 3 on [0..9] gave [2,5,8] instead of [1,4,7].
  • Scanl.pipe: emitted an output even when the pipe yielded nothing.
  • Scanl.uniqBy: the initial result was an error, so Stream.scanl and compose blew up. It now returns Nothing before the first input. rollingMap has the same problem, and it's left as a TODO since fixing it needs a type change.
  • Scanl.partitionByM: when one scan terminated, the other one wasn't finalized.
  • Doc fixes: the Scanl module doc (scans can skip outputs), and the minimum, sum, indexingWith and top/bottom docs. The top/bottom examples were showing the shared buffer after the stream had ended, not the output at each step.

@harendra-kumar
harendra-kumar force-pushed the scanl-fixes branch 2 times, most recently from 859e910 to 1be44b7 Compare October 16, 2026 12:45
windowRange counted every input in an Int and used min i n as the window
length. On a 32-bit Int, e.g. the JavaScript backend, the counter wrapped
to a negative value after 2^31 - 1 inputs, giving a ring with a negative
size and a wrong range. Stop counting once the window is full. Add tests
comparing Fold.windowRange and Scanl.windowRange with a list reference
for window sizes 1 to 4.

Scanl.windowRange 0, and windowMinimum 0 and windowMaximum 0, failed
with the message "ringsOf: window size must be > 0". Name windowRange in
the message.
scanRingsOf added the element size to a byte counter on every input and
used min offset rSize as the ring size. On a 32-bit Int, e.g. the
JavaScript backend, the counter wrapped to a negative value after
2^31 / sizeOf a inputs, e.g. about 268 million Doubles, giving rings with
a negative size. Stop counting once the ring is full. Add a test
comparing scanRingsOf with a list reference for ring sizes 1 to 4.
The byte counter was saturated with an if-then-else. With GHC 9.14.1 the
step of scanRingsOf was then not inlined in the fold loop and the fold
state was boxed on every input. Array.createOfLast 1 allocated 8 MB for
100,000 inputs instead of none, and took 4-5 times longer. GHC 9.10.3
inlined the step. Saturate the counter with min instead, GHC 9.14.1
inlines the step with this form.
When the scan for a key terminated on the first input of that key, the
key was neither kept in the map of running scans nor added to the set of
finished keys. The next input with the same key started the scan again
and produced another result. For example, classify fst with the inner
scan "take 1 sum" on [("A",1),("A",2)] emitted Just ("A",1) and
Just ("A",2) instead of Just ("A",1) and Nothing.

Fix classifyGeneric and classifyGenericIO, and with them classify and
classifyIO.
sampleFromthen offset stride kept the elements at the indices i with
(i + offset) mod stride == 0, so sampleFromthen 1 3 on [0..9] sampled
[2,5,8] instead of [1,4,7]. Keep the indices i >= offset with
(i - offset) mod stride == 0. The existing test used offset 0, where the
two agree.
When the pipe consumed an input without yielding (SkipC), pipe returned
Partial, so the driver emitted the previous output of the scan again.
For example, pipe (Pipe.filter even) sum postscanned over [1,2,3,4]
gave [0,2,2,6] instead of [2,6]. Return Continue unless the scan
returned Partial for an output of the pipe on this input.
uniqBy was rollingMap, whose initial result is error "Empty stream".
Using the result before any input failed: Stream.scanl Scanl.uniq on
"aab" had the error as its first output, and Scanl.compose Scanl.uniq
Scanl.toList failed when it was initialized, compose forces the initial
result of the first scan. Implement uniqBy with scanl', the initial
result is Nothing.
When one of the two scans returned Done, in step or in initial,
partitionByM returned its result without calling final on the other
scan, so the other scan's resources were not released. For example,
partition (take 1 length) r on [Right 1, Left 2] never ran the final of
r. teeWith and Fold.partitionByM finalize the other fold. Call final on
the other scan in these cases.
The module doc said that a scan produces an output on each input and that,
unlike a pipe, it cannot skip an output. A scan can skip the output on an
input using the Continue step, e.g. Scanl.filter.

The Min monoid definition in the minimum doc was named maximum and the
text called maxBound the default maximum. The doc also said that minimum
uses a supplied comparison function, it takes none.

The sum doc said that sum is not numerically stable and that scanl' (+) 0
is. sum uses Kahan-Babuska-Neumaier summation, scanl' (+) 0 is the
unstable one. The indexingWith doc said that indices start from 0, they
start from the given index.

The topBy, top and bottom examples converted the output arrays to lists
after the stream ended. The outputs share the scan's buffer, which later
inputs modify, so the examples showed prefixes of the final buffer, e.g.
[17] as the top 1 after the first input, 2. Convert each output with
rmapM, and document that the output array is modified by later inputs.
GHC recompiles the test modules that use Template Haskell on every cabal
invocation, cabal test spent about 5 minutes rebuilding them after the
build step.
Build the master benchmarks on push to master and cache the build
directory and the cabal store, pull requests then only run the master
benchmarks on the same machine as the PR benchmarks. Cache the
bench-runner binary.

PERF, packdiff and markdown-doctest run only on pull requests, so their
ghcup and hackage index caches were saved separately for each pull
request. Restore the ones saved by the TEST workflow on master instead.
The deprecated Prelude tests were built only with the dev flag, in the
macOS dev-no-opt job, where Prelude.Async and Prelude.WAsync took about
6 minutes each. Build them with the new deprecated-tests flag instead,
in the ubuntu 9.8.4 ci job. The macOS stack job still runs the tests on
macOS.
@harendra-kumar
harendra-kumar merged commit 0d9937f into master Oct 16, 2026
18 of 19 checks passed
@harendra-kumar
harendra-kumar deleted the scanl-fixes branch October 21, 2026 11:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant