From 9f128d773c9033050281e579c9836a4282282e85 Mon Sep 17 00:00:00 2001 From: Repo Assist Date: Sat, 7 Mar 2026 20:54:49 +0000 Subject: [PATCH 1/3] feat: add TaskSeq.scan and TaskSeq.scanAsync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements scan/scanAsync (issue #289 — align with AsyncSeq). scan: like fold but yields the initial state and each intermediate accumulator state. Output has N+1 elements for N-element input. scanAsync: async variant. - TaskSeqInternal.fs: scan using FolderAction DU (matches fold pattern) - TaskSeq.fsi: signatures with XML doc for scan/scanAsync - TaskSeq.fs: static member dispatch (scan/scanAsync → Internal.scan) - TaskSeq.Scan.Tests.fs: 54 tests (empty, single, multi, side-effects) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../FSharp.Control.TaskSeq.Test.fsproj | 1 + .../TaskSeq.Scan.Tests.fs | 163 ++++++++++++++++++ src/FSharp.Control.TaskSeq/TaskSeq.fs | 2 + src/FSharp.Control.TaskSeq/TaskSeq.fsi | 29 ++++ src/FSharp.Control.TaskSeq/TaskSeqInternal.fs | 23 +++ 5 files changed, 218 insertions(+) create mode 100644 src/FSharp.Control.TaskSeq.Test/TaskSeq.Scan.Tests.fs diff --git a/src/FSharp.Control.TaskSeq.Test/FSharp.Control.TaskSeq.Test.fsproj b/src/FSharp.Control.TaskSeq.Test/FSharp.Control.TaskSeq.Test.fsproj index 118a9417..ec94d286 100644 --- a/src/FSharp.Control.TaskSeq.Test/FSharp.Control.TaskSeq.Test.fsproj +++ b/src/FSharp.Control.TaskSeq.Test/FSharp.Control.TaskSeq.Test.fsproj @@ -25,6 +25,7 @@ + diff --git a/src/FSharp.Control.TaskSeq.Test/TaskSeq.Scan.Tests.fs b/src/FSharp.Control.TaskSeq.Test/TaskSeq.Scan.Tests.fs new file mode 100644 index 00000000..abc37945 --- /dev/null +++ b/src/FSharp.Control.TaskSeq.Test/TaskSeq.Scan.Tests.fs @@ -0,0 +1,163 @@ +module TaskSeq.Tests.Scan + +open Xunit +open FsUnit.Xunit + +open FSharp.Control + +// +// TaskSeq.scan +// TaskSeq.scanAsync +// + +module EmptySeq = + [] + let ``Null source is invalid`` () = + assertNullArg + <| fun () -> TaskSeq.scan (fun _ _ -> 42) 0 null + + assertNullArg + <| fun () -> TaskSeq.scanAsync (fun _ _ -> Task.fromResult 42) 0 null + + [)>] + let ``TaskSeq-scan on empty returns singleton initial state`` variant = task { + let! result = + Gen.getEmptyVariant variant + |> TaskSeq.scan (fun acc _ -> acc + 1) 0 + |> TaskSeq.toListAsync + + result |> should equal [ 0 ] + } + + [)>] + let ``TaskSeq-scanAsync on empty returns singleton initial state`` variant = task { + let! result = + Gen.getEmptyVariant variant + |> TaskSeq.scanAsync (fun acc _ -> task { return acc + 1 }) 0 + |> TaskSeq.toListAsync + + result |> should equal [ 0 ] + } + +module Functionality = + [] + let ``TaskSeq-scan yields initial state then each intermediate state`` () = task { + let! result = + TaskSeq.ofList [ 1; 2; 3; 4; 5 ] + |> TaskSeq.scan (fun acc item -> acc + item) 0 + |> TaskSeq.toListAsync + + // N=5 elements → N+1=6 output elements + result |> should equal [ 0; 1; 3; 6; 10; 15 ] + } + + [] + let ``TaskSeq-scanAsync yields initial state then each intermediate state`` () = task { + let! result = + TaskSeq.ofList [ 1; 2; 3; 4; 5 ] + |> TaskSeq.scanAsync (fun acc item -> task { return acc + item }) 0 + |> TaskSeq.toListAsync + + result |> should equal [ 0; 1; 3; 6; 10; 15 ] + } + + [] + let ``TaskSeq-scan output length is input length plus one`` () = task { + let input = TaskSeq.ofList [ 'a'; 'b'; 'c' ] + + let! result = + input + |> TaskSeq.scan (fun acc c -> acc + string c) "" + |> TaskSeq.toListAsync + + result |> should equal [ ""; "a"; "ab"; "abc" ] + } + + [] + let ``TaskSeq-scanAsync output length is input length plus one`` () = task { + let input = TaskSeq.ofList [ 'a'; 'b'; 'c' ] + + let! result = + input + |> TaskSeq.scanAsync (fun acc c -> task { return acc + string c }) "" + |> TaskSeq.toListAsync + + result |> should equal [ ""; "a"; "ab"; "abc" ] + } + + [] + let ``TaskSeq-scan with single element returns two-element result`` () = task { + let! result = + TaskSeq.singleton 42 + |> TaskSeq.scan (fun acc item -> acc + item) 10 + |> TaskSeq.toListAsync + + result |> should equal [ 10; 52 ] + } + + [)>] + let ``TaskSeq-scan accumulates correctly across variants`` variant = task { + // Input is 1..10; cumulative sums: 0, 1, 3, 6, 10, 15, 21, 28, 36, 45, 55 + let! result = + Gen.getSeqImmutable variant + |> TaskSeq.scan (fun acc item -> acc + item) 0 + |> TaskSeq.toListAsync + + result + |> should equal [ 0; 1; 3; 6; 10; 15; 21; 28; 36; 45; 55 ] + } + + [)>] + let ``TaskSeq-scanAsync accumulates correctly across variants`` variant = task { + let! result = + Gen.getSeqImmutable variant + |> TaskSeq.scanAsync (fun acc item -> task { return acc + item }) 0 + |> TaskSeq.toListAsync + + result + |> should equal [ 0; 1; 3; 6; 10; 15; 21; 28; 36; 45; 55 ] + } + +module SideEffects = + [)>] + let ``TaskSeq-scan second iteration accumulates from fresh start`` variant = task { + let ts = Gen.getSeqWithSideEffect variant + + let! first = + ts + |> TaskSeq.scan (fun acc item -> acc + item) 0 + |> TaskSeq.toListAsync + + first + |> should equal [ 0; 1; 3; 6; 10; 15; 21; 28; 36; 45; 55 ] + + let! second = + ts + |> TaskSeq.scan (fun acc item -> acc + item) 0 + |> TaskSeq.toListAsync + + // side-effect sequences yield next 10 items (11..20) + second + |> should equal [ 0; 11; 23; 36; 50; 65; 81; 98; 116; 135; 155 ] + } + + [)>] + let ``TaskSeq-scanAsync second iteration accumulates from fresh start`` variant = task { + let ts = Gen.getSeqWithSideEffect variant + + let! first = + ts + |> TaskSeq.scanAsync (fun acc item -> task { return acc + item }) 0 + |> TaskSeq.toListAsync + + first + |> should equal [ 0; 1; 3; 6; 10; 15; 21; 28; 36; 45; 55 ] + + let! second = + ts + |> TaskSeq.scanAsync (fun acc item -> task { return acc + item }) 0 + |> TaskSeq.toListAsync + + second + |> should equal [ 0; 11; 23; 36; 50; 65; 81; 98; 116; 135; 155 ] + } diff --git a/src/FSharp.Control.TaskSeq/TaskSeq.fs b/src/FSharp.Control.TaskSeq/TaskSeq.fs index 32c159fb..96da85d9 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeq.fs +++ b/src/FSharp.Control.TaskSeq/TaskSeq.fs @@ -406,3 +406,5 @@ type TaskSeq private () = static member zip source1 source2 = Internal.zip source1 source2 static member fold folder state source = Internal.fold (FolderAction folder) state source static member foldAsync folder state source = Internal.fold (AsyncFolderAction folder) state source + static member scan folder state source = Internal.scan (FolderAction folder) state source + static member scanAsync folder state source = Internal.scan (AsyncFolderAction folder) state source diff --git a/src/FSharp.Control.TaskSeq/TaskSeq.fsi b/src/FSharp.Control.TaskSeq/TaskSeq.fsi index 76e90c2f..e9aee8cf 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeq.fsi +++ b/src/FSharp.Control.TaskSeq/TaskSeq.fsi @@ -1350,6 +1350,35 @@ type TaskSeq = static member foldAsync: folder: ('State -> 'T -> #Task<'State>) -> state: 'State -> source: TaskSeq<'T> -> Task<'State> + /// + /// Like , but returns the sequence of intermediate results and the final result. + /// The first element of the output sequence is always the initial state. If the input task sequence + /// has N elements, the output task sequence has N + 1 elements. + /// If the folder function is asynchronous, consider using . + /// + /// + /// A function that updates the state with each element from the sequence. + /// The initial state. + /// The input sequence. + /// A task sequence of states, starting with the initial state and applying the folder to each element. + /// Thrown when the input task sequence is null. + static member scan: folder: ('State -> 'T -> 'State) -> state: 'State -> source: TaskSeq<'T> -> TaskSeq<'State> + + /// + /// Like , but returns the sequence of intermediate results and the final result. + /// The first element of the output sequence is always the initial state. If the input task sequence + /// has N elements, the output task sequence has N + 1 elements. + /// If the folder function is synchronous, consider using . + /// + /// + /// A function that updates the state with each element from the sequence. + /// The initial state. + /// The input sequence. + /// A task sequence of states, starting with the initial state and applying the folder to each element. + /// Thrown when the input task sequence is null. + static member scanAsync: + folder: ('State -> 'T -> #Task<'State>) -> state: 'State -> source: TaskSeq<'T> -> TaskSeq<'State> + /// /// Return a new task sequence with a new item inserted before the given index. /// diff --git a/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs b/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs index a8f2a4bf..aa2df6de 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs +++ b/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs @@ -371,6 +371,29 @@ module internal TaskSeqInternal = return result } + let scan folder initial (source: TaskSeq<_>) = + checkNonNull (nameof source) source + + match folder with + | FolderAction folder -> taskSeq { + let mutable state = initial + yield state + + for item in source do + state <- folder state item + yield state + } + + | AsyncFolderAction folder -> taskSeq { + let mutable state = initial + yield state + + for item in source do + let! newState = folder state item + state <- newState + yield state + } + let toResizeArrayAsync source = checkNonNull (nameof source) source From 1413826956fed35150d37be54d483322bf3fd43c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 7 Mar 2026 21:00:05 +0000 Subject: [PATCH 2/3] ci: trigger checks From ad6280505b70922784948b6ce7b3d83269ed8fa0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 7 Mar 2026 21:59:48 +0000 Subject: [PATCH 3/3] docs: update release notes for scan/scanAsync and clarify AGENTS.md requirement Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 10 +++++++++- release-notes.txt | 1 + 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 198a83cc..187e7b25 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,4 +102,12 @@ All workflows are in `.github/workflows/`: ## Release Notes -When making changes, update the release notes and bump the version appropriately +**Required**: Every PR that adds features, fixes bugs, or makes user-visible changes **must** include an update to `release-notes.txt`. Add a bullet under the appropriate version heading (currently `0.5.0`). The format is: + +``` +0.5.0 + - adds TaskSeq.myFunction and TaskSeq.myFunctionAsync, # + - fixes , # +``` + +If you are bumping to a new version, also update `Version.props`. PRs that touch library source (`src/FSharp.Control.TaskSeq/`) without updating `release-notes.txt` are incomplete. diff --git a/release-notes.txt b/release-notes.txt index 18ae26c7..5d400982 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -3,6 +3,7 @@ Release notes: 0.5.0 - update engineering to .NET 9/10 + - adds TaskSeq.scan and TaskSeq.scanAsync, #289 0.4.0 - overhaul all doc comments, add exceptions, improve IDE quick-info experience, #136, #220, #234