From 83518568e9bd00ff081f350b806de79edba2d9c5 Mon Sep 17 00:00:00 2001 From: Repo Assist Date: Sat, 7 Mar 2026 23:34:17 +0000 Subject: [PATCH] feat: add TaskSeq.mapFold and TaskSeq.mapFoldAsync (56 tests) Implements TaskSeq.mapFold and TaskSeq.mapFoldAsync, which apply a mapping function that both transforms each element and threads an accumulator state through the sequence in a single pass. The result is a pair of a 'Result array and the final 'State, mirroring List.mapFold, Array.mapFold, and Seq.mapFold from FSharp.Core. - Adds MapFolderAction DU type to TaskSeqInternal.fs - Adds mapFold internal implementation (eager, collects to array) - Exposes TaskSeq.mapFold / TaskSeq.mapFoldAsync on the public type - Adds signatures to TaskSeq.fsi with full XML doc comments - Adds 56 tests covering: empty, single element, multi-element, state threading order, side-effect variants, parity with List.mapFold - Updates release notes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- global.json | 2 +- release-notes.txt | 1 + .../FSharp.Control.TaskSeq.Test.fsproj | 1 + .../TaskSeq.MapFold.Tests.fs | 189 ++++++++++++++++++ src/FSharp.Control.TaskSeq/TaskSeq.fs | 2 + src/FSharp.Control.TaskSeq/TaskSeq.fsi | 39 +++- src/FSharp.Control.TaskSeq/TaskSeqInternal.fs | 36 ++++ 7 files changed, 267 insertions(+), 3 deletions(-) create mode 100644 src/FSharp.Control.TaskSeq.Test/TaskSeq.MapFold.Tests.fs diff --git a/global.json b/global.json index badcd443..cff8de89 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.103", + "version": "10.0.102", "rollForward": "minor" } } diff --git a/release-notes.txt b/release-notes.txt index 43967b3a..1794651f 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -5,6 +5,7 @@ Release notes: - update engineering to .NET 9/10 - adds TaskSeq.scan and TaskSeq.scanAsync, #289 - adds TaskSeq.pairwise, #289 + - adds TaskSeq.mapFold and TaskSeq.mapFoldAsync 0.4.0 - overhaul all doc comments, add exceptions, improve IDE quick-info experience, #136, #220, #234 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 2a6d808b..c5d1b119 100644 --- a/src/FSharp.Control.TaskSeq.Test/FSharp.Control.TaskSeq.Test.fsproj +++ b/src/FSharp.Control.TaskSeq.Test/FSharp.Control.TaskSeq.Test.fsproj @@ -27,6 +27,7 @@ + diff --git a/src/FSharp.Control.TaskSeq.Test/TaskSeq.MapFold.Tests.fs b/src/FSharp.Control.TaskSeq.Test/TaskSeq.MapFold.Tests.fs new file mode 100644 index 00000000..dd432ef5 --- /dev/null +++ b/src/FSharp.Control.TaskSeq.Test/TaskSeq.MapFold.Tests.fs @@ -0,0 +1,189 @@ +module TaskSeq.Tests.MapFold + +open Xunit +open FsUnit.Xunit + +open FSharp.Control + +// +// TaskSeq.mapFold +// TaskSeq.mapFoldAsync +// + +module EmptySeq = + [] + let ``Null source is invalid`` () = + assertNullArg + <| fun () -> TaskSeq.mapFold (fun _ item -> string item, 0) 0 null + + assertNullArg + <| fun () -> TaskSeq.mapFoldAsync (fun _ item -> Task.fromResult (string item, 0)) 0 null + + [)>] + let ``TaskSeq-mapFold on empty returns empty array with initial state`` variant = task { + let! results, finalState = + Gen.getEmptyVariant variant + |> TaskSeq.mapFold (fun state item -> item * 2, state + item) 0 + + results |> should equal [||] + finalState |> should equal 0 + } + + [)>] + let ``TaskSeq-mapFoldAsync on empty returns empty array with initial state`` variant = task { + let! results, finalState = + Gen.getEmptyVariant variant + |> TaskSeq.mapFoldAsync (fun state item -> task { return item * 2, state + item }) 0 + + results |> should equal [||] + finalState |> should equal 0 + } + +module Functionality = + [] + let ``TaskSeq-mapFold maps elements while threading state`` () = task { + // mapFold: map each element to its double, sum all originals as state + let! results, finalState = + TaskSeq.ofList [ 1; 2; 3; 4; 5 ] + |> TaskSeq.mapFold (fun state item -> item * 2, state + item) 0 + + results |> should equal [| 2; 4; 6; 8; 10 |] + finalState |> should equal 15 // 1+2+3+4+5 + } + + [] + let ``TaskSeq-mapFoldAsync maps elements while threading state`` () = task { + let! results, finalState = + TaskSeq.ofList [ 1; 2; 3; 4; 5 ] + |> TaskSeq.mapFoldAsync (fun state item -> task { return item * 2, state + item }) 0 + + results |> should equal [| 2; 4; 6; 8; 10 |] + finalState |> should equal 15 + } + + [] + let ``TaskSeq-mapFold returns array of same length as source`` () = task { + let! results, _ = + TaskSeq.ofList [ 'a'; 'b'; 'c' ] + |> TaskSeq.mapFold (fun idx c -> string c, idx + 1) 0 + + results |> should equal [| "a"; "b"; "c" |] + } + + [] + let ``TaskSeq-mapFold single element returns singleton array and updated state`` () = task { + let! results, finalState = + TaskSeq.singleton 42 + |> TaskSeq.mapFold (fun state item -> item + 1, state + item) 10 + + results |> should equal [| 43 |] + finalState |> should equal 52 + } + + [] + let ``TaskSeq-mapFold state threads through in order`` () = task { + // Build running index as state; mapped element is (index, item) pair + let! results, finalState = + TaskSeq.ofList [ 10; 20; 30 ] + |> TaskSeq.mapFold (fun idx item -> (idx, item), idx + 1) 0 + + results |> should equal [| (0, 10); (1, 20); (2, 30) |] + finalState |> should equal 3 + } + + [] + let ``TaskSeq-mapFoldAsync state threads through in order`` () = task { + let! results, finalState = + TaskSeq.ofList [ 10; 20; 30 ] + |> TaskSeq.mapFoldAsync (fun idx item -> task { return (idx, item), idx + 1 }) 0 + + results |> should equal [| (0, 10); (1, 20); (2, 30) |] + finalState |> should equal 3 + } + + [)>] + let ``TaskSeq-mapFold accumulates correctly across variants`` variant = task { + // Input 1..10; mapped = item*item; state = running sum + let! results, finalState = + Gen.getSeqImmutable variant + |> TaskSeq.mapFold (fun acc item -> item * item, acc + item) 0 + + results + |> should equal [| 1; 4; 9; 16; 25; 36; 49; 64; 81; 100 |] + + finalState |> should equal 55 // 1+2+...+10 + } + + [)>] + let ``TaskSeq-mapFoldAsync accumulates correctly across variants`` variant = task { + let! results, finalState = + Gen.getSeqImmutable variant + |> TaskSeq.mapFoldAsync (fun acc item -> task { return item * item, acc + item }) 0 + + results + |> should equal [| 1; 4; 9; 16; 25; 36; 49; 64; 81; 100 |] + + finalState |> should equal 55 + } + + [] + let ``TaskSeq-mapFold result matches equivalent List.mapFold`` () = task { + let items = [ 1; 2; 3; 4; 5 ] + + let listResults, listState = List.mapFold (fun state item -> item + state, state + item) 0 items + + let! taskResults, taskState = + TaskSeq.ofList items + |> TaskSeq.mapFold (fun state item -> item + state, state + item) 0 + + taskResults |> should equal (Array.ofList listResults) + taskState |> should equal listState + } + +module SideEffects = + [)>] + let ``TaskSeq-mapFold second iteration sees next batch of side-effect values`` variant = task { + let ts = Gen.getSeqWithSideEffect variant + + let! first, firstState = + ts + |> TaskSeq.mapFold (fun acc item -> item * 2, acc + item) 0 + + first + |> should equal [| 2; 4; 6; 8; 10; 12; 14; 16; 18; 20 |] + + firstState |> should equal 55 + + // side-effect sequences yield next 10 items (11..20) on second consumption + let! second, secondState = + ts + |> TaskSeq.mapFold (fun acc item -> item * 2, acc + item) 0 + + second + |> should equal [| 22; 24; 26; 28; 30; 32; 34; 36; 38; 40 |] + + secondState |> should equal 155 // 11+12+...+20 + } + + [)>] + let ``TaskSeq-mapFoldAsync second iteration sees next batch of side-effect values`` variant = task { + let ts = Gen.getSeqWithSideEffect variant + + let! first, firstState = + ts + |> TaskSeq.mapFoldAsync (fun acc item -> task { return item * 2, acc + item }) 0 + + first + |> should equal [| 2; 4; 6; 8; 10; 12; 14; 16; 18; 20 |] + + firstState |> should equal 55 + + let! second, secondState = + ts + |> TaskSeq.mapFoldAsync (fun acc item -> task { return item * 2, acc + item }) 0 + + second + |> should equal [| 22; 24; 26; 28; 30; 32; 34; 36; 38; 40 |] + + secondState |> should equal 155 + } diff --git a/src/FSharp.Control.TaskSeq/TaskSeq.fs b/src/FSharp.Control.TaskSeq/TaskSeq.fs index 0ff387c6..755baf62 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeq.fs +++ b/src/FSharp.Control.TaskSeq/TaskSeq.fs @@ -414,3 +414,5 @@ type TaskSeq private () = static member scanAsync folder state source = Internal.scan (AsyncFolderAction folder) state source static member reduce folder source = Internal.reduce (FolderAction folder) source static member reduceAsync folder source = Internal.reduce (AsyncFolderAction folder) source + static member mapFold mapping state source = Internal.mapFold (MapFolderAction mapping) state source + static member mapFoldAsync mapping state source = Internal.mapFold (AsyncMapFolderAction mapping) state source diff --git a/src/FSharp.Control.TaskSeq/TaskSeq.fsi b/src/FSharp.Control.TaskSeq/TaskSeq.fsi index d1d8d7c9..12ce64e4 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeq.fsi +++ b/src/FSharp.Control.TaskSeq/TaskSeq.fsi @@ -1419,8 +1419,43 @@ type TaskSeq = folder: ('State -> 'T -> #Task<'State>) -> state: 'State -> source: TaskSeq<'T> -> TaskSeq<'State> /// - /// Applies the function to each element of the task sequence, threading an accumulator - /// argument through the computation. The first element is used as the initial state. If the input function is + /// Applies the function to each element of the task sequence, threading an accumulator + /// argument through the computation, while also generating a new mapped element for each input element. + /// If the input function is and the elements are , then + /// computes both the mapped results and the final state in a single pass. + /// The result is a pair of an array of mapped values and the final state. + /// If the mapping function is asynchronous, consider using . + /// + /// + /// A function that maps each element to a result while also updating the state. + /// The initial state. + /// The input task sequence. + /// A task returning a pair of the array of mapped results and the final state. + /// Thrown when the input task sequence is null. + static member mapFold: + mapping: ('State -> 'T -> 'Result * 'State) -> state: 'State -> source: TaskSeq<'T> -> Task<'Result[] * 'State> + + /// + /// Applies the asynchronous function to each element of the task sequence, + /// threading an accumulator argument through the computation, while also generating a new mapped element for each input element. + /// If the input function is and the elements are , then + /// computes both the mapped results and the final state in a single pass. + /// The result is a pair of an array of mapped values and the final state. + /// If the mapping function is synchronous, consider using . + /// + /// + /// An asynchronous function that maps each element to a result while also updating the state. + /// The initial state. + /// The input task sequence. + /// A task returning a pair of the array of mapped results and the final state. + /// Thrown when the input task sequence is null. + static member mapFoldAsync: + mapping: ('State -> 'T -> #Task<'Result * 'State>) -> + state: 'State -> + source: TaskSeq<'T> -> + Task<'Result[] * 'State> + + /// and the elements are , then computes /// . Raises when the /// sequence is empty. diff --git a/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs b/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs index ee7b3df0..9b2f7f6e 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs +++ b/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs @@ -49,6 +49,11 @@ type internal InitAction<'T, 'TaskT when 'TaskT :> Task<'T>> = | InitAction of init_item: (int -> 'T) | InitActionAsync of async_init_item: (int -> 'TaskT) +[] +type internal MapFolderAction<'T, 'State, 'Result, 'TaskResultState when 'TaskResultState :> Task<'Result * 'State>> = + | MapFolderAction of map_folder_action: ('State -> 'T -> 'Result * 'State) + | AsyncMapFolderAction of async_map_folder_action: ('State -> 'T -> 'TaskResultState) + [] type internal ManyOrOne<'T> = | Many of source_seq: TaskSeq<'T> @@ -451,6 +456,37 @@ module internal TaskSeqInternal = return result } + let mapFold (folder: MapFolderAction<_, _, _, _>) initial (source: TaskSeq<_>) = + checkNonNull (nameof source) source + + task { + use e = source.GetAsyncEnumerator CancellationToken.None + let mutable go = true + let mutable state = initial + let results = ResizeArray() + let! step = e.MoveNextAsync() + go <- step + + match folder with + | MapFolderAction folder -> + while go do + let result, newState = folder state e.Current + results.Add result + state <- newState + let! step = e.MoveNextAsync() + go <- step + + | AsyncMapFolderAction folder -> + while go do + let! (result, newState) = folder state e.Current + results.Add result + state <- newState + let! step = e.MoveNextAsync() + go <- step + + return results.ToArray(), state + } + let toResizeArrayAsync source = checkNonNull (nameof source) source