Skip to content

Stream correctness and doc fixes - #3260

Merged
harendra-kumar merged 8 commits into
masterfrom
stream-fixes
Oct 17, 2026
Merged

harendra-kumar merged 8 commits into
masterfrom
stream-fixes

Conversation

@harendra-kumar

Copy link
Copy Markdown
Member
  • enumerateFromToNum could go past the limit for non-integral steps. 1.1 .. 3.2 gave an extra 4.1.
  • Enumerating Natural downwards no longer underflows.
  • Ratio and Fixed now enumerate the same way as their Enum instances.
  • foldr1 combined the elements in the wrong order.
  • isInfixOf returned False for an empty infix on short streams.
  • The Show instances of Stream.Nested and StreamK.Nested now respect precedence.
  • modifyIndex and unsafeModifyIndex are now inlined.
  • Corrected the docs of concatEffect, fromFoldableM, unionBy, deleteFirstsBy and insertBy.

The instances defined only show, so the default showsPrec ignored the
precedence: show (Just n) produced "Just fromList [1,2]", which the Read
instance cannot parse. Delegate showsPrec to the Stream and StreamK
instances, which use showParen, the same as the Array fix.

Keep show, delegating to an explicit show in the Stream and StreamK
instances, the same as the Array instance.
foldr1 used the head of the stream as the seed of a right fold over the
tail, so the head was combined last: foldr1 (-) on [1,2,3] computed
2 - (3 - 1) = 0 instead of 1 - (2 - 3) = 2, and foldr1 const returned
the second element. The existing tests used only (+). Fold the whole
stream with a Maybe accumulator instead.
isInfixOf splits the stream on the infix and checks for a second
segment. An empty pattern splits the stream at every element, so a
stream of fewer than two elements has fewer than two segments:
isInfixOf nil nil and isInfixOf nil (fromList [1]) returned False.
An empty infix occurs in every stream; return True for it directly.
enumerateFromToNum yielded x and continued with x + 1 whenever x < to,
without checking x + 1 <= to. When to - from is not a whole number the
last element exceeded the limit: enumerateFromToNum 1.1 3.2 gave
[1.1,2.1,3.1,4.1], and enumerateFromTo (1 :: Rational) (12 % 5), which
the Ratio and Fixed Enumerable instances route to enumerateFromToNum,
gave [1,2,3] instead of [1,2]. enumerateDownFromToNum had the mirror
bug. Fix the Stream functions and the Producer copies used by Unfold.
enumerateFromThenToNum computed the stride as next - from, which throws
an arithmetic underflow for Natural when next < from:
enumerateFromThenTo (5 :: Natural) 3 0 threw instead of returning
[5,3,1]. enumerateFromThen for Natural had the same problem and, being
unbounded below, could not stop at 0.

For the downward direction use the positive stride from - next and the
limit to + stride, stepping by subtraction. For fixed width types this
gives the same values modulo 2^n as before, so bounded types are
unaffected. Give Natural its own Enumerable instances whose
enumerateFromThen stops at 0 when enumerating downwards. Fix the Stream
functions and the Producer copy used by Unfold.
The Ratio and Fixed Enumerable instances used enumerateFromToNum and
enumerateFromThenToNum, which stop at the last element not beyond the
limit. Their Enum instances do not:

- Ratio rounds the limit to the nearest step, [1 .. 5 % 2] is [1,2,3].
  enumerateFromTo and enumerateFromThenTo now keep the elements up to
  to + stride / 2, using repeated addition since Ratio is exact.
- Fixed enumerates the underlying Integer, i.e. in steps of the
  resolution, [1.5 .. 1.503 :: Milli] is [1.500,1.501,1.502,1.503].
  All four methods now map over the Integer instance.

enumerateFromToNum and enumerateDownFromToNum (and the Producer copies
used by Unfold) checked both to > x and x + 1 <= to on every element.
They now compute to - 1 (to + 1 downwards) once at the start and
compare x <= to - 1 in the loop, one comparison per element as before
the non-integral fix. to - 1 is computed only when from + 1 <= to, so
neither to - 1 nor x + 1 overflows for bounded types or underflows for
Natural.

In the Stream versions the loop compares x <= to - 1 directly, to - 1
is neither a field of the state nor a binding in the closure. A larger
state would be allocated on each element of an unfused stream,
increasing the allocation of the equations/concatFor (bounded)
benchmark by 2.4%. A strict binding would wrap the Stream constructor
in a case, a stream used twice then does not fuse with
StreamK.fromStream, increasing the allocation of the
equations/fairConcatForK (bounded) benchmark by 9.5%.

The extra Init branches are duplicated in each copy of the producer, the
core size of the equations/unfoldCross (bounded) benchmark grows from
about 17.8k to 20.5k terms, 23.6k with GHC 9.14.1, raise its
MaxCoreSize from 20000 to 26000. Its run time is unchanged.
…insertBy docs

The concatEffect note referred to
Streamly.Internal.Data.Stream.Nesting.ifThenElse, which does not exist;
ifThenElse is defined in Stream.Type.

The fromFoldableM doc said it constructs a stream from a Foldable
containing pure values, copied from fromFoldable; its input is a
Foldable of monadic actions.

The documented unionBy definition passed the arguments of
deleteFirstsBy in the wrong order, deleting the elements of the second
stream from the first stream. For s1 = [1,2,2,4] and s2 = [1,1,2,3,3]
it gave [1,2,2,4,2,4] instead of [1,2,2,4,3], which is what unionBy
returns.

In deleteFirstsBy each element of the second stream deletes one
occurrence, so the documented laws did not hold:

- deleteFirstsBy (==) (ordNub s2 `append` s1) s2 with s2 = [1,1] and
  s1 = [1] gives [], not [1]. Without ordNub the law holds for any s2.
- deleteFirstsBy (==) (ordNub s2 `interleave` s1) s2 with
  s2 = [9,8,7,1] and s1 = [1,2] gives [2,1], not [1,2], because the 1
  from s1 comes first in the interleaved stream and is deleted.

Keep the append law without ordNub and drop the interleave law. The
implementation buffers the second stream, not the first, so the space
is O(n) in the second stream.

The insertBy docs said the element is inserted before the first element
that is less than it. insertBy inserts it before the first element x
with cmp elem x /= GT, i.e. the first element greater than or equal to
it, and the doc example insertBy compare 2 [1,3,5] = [1,2,3,5] shows
that.
Without INLINE, modifyIndex was called through the Unbox dictionary
and boxed its result. In a BFS over a graph of 10^6 nodes using a
MutArray Bool visited bitmap, inlining it reduced the time from
270-290 ms to about 100 ms and the allocation about 9 times.
@harendra-kumar
harendra-kumar merged commit b83929c into master Oct 17, 2026
18 of 19 checks passed
@harendra-kumar
harendra-kumar deleted the stream-fixes branch October 21, 2026 11:12
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