Skip to content

Commit 9f128d7

Browse files
Repo AssistCopilot
authored andcommitted
feat: add TaskSeq.scan and TaskSeq.scanAsync
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>
1 parent d731039 commit 9f128d7

File tree

5 files changed

+218
-0
lines changed

5 files changed

+218
-0
lines changed

src/FSharp.Control.TaskSeq.Test/FSharp.Control.TaskSeq.Test.fsproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
<Compile Include="TaskSeq.FindIndex.Tests.fs" />
2626
<Compile Include="TaskSeq.Find.Tests.fs" />
2727
<Compile Include="TaskSeq.Fold.Tests.fs" />
28+
<Compile Include="TaskSeq.Scan.Tests.fs" />
2829
<Compile Include="TaskSeq.Forall.Tests.fs" />
2930
<Compile Include="TaskSeq.Head.Tests.fs" />
3031
<Compile Include="TaskSeq.Indexed.Tests.fs" />
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
module TaskSeq.Tests.Scan
2+
3+
open Xunit
4+
open FsUnit.Xunit
5+
6+
open FSharp.Control
7+
8+
//
9+
// TaskSeq.scan
10+
// TaskSeq.scanAsync
11+
//
12+
13+
module EmptySeq =
14+
[<Fact>]
15+
let ``Null source is invalid`` () =
16+
assertNullArg
17+
<| fun () -> TaskSeq.scan (fun _ _ -> 42) 0 null
18+
19+
assertNullArg
20+
<| fun () -> TaskSeq.scanAsync (fun _ _ -> Task.fromResult 42) 0 null
21+
22+
[<Theory; ClassData(typeof<TestEmptyVariants>)>]
23+
let ``TaskSeq-scan on empty returns singleton initial state`` variant = task {
24+
let! result =
25+
Gen.getEmptyVariant variant
26+
|> TaskSeq.scan (fun acc _ -> acc + 1) 0
27+
|> TaskSeq.toListAsync
28+
29+
result |> should equal [ 0 ]
30+
}
31+
32+
[<Theory; ClassData(typeof<TestEmptyVariants>)>]
33+
let ``TaskSeq-scanAsync on empty returns singleton initial state`` variant = task {
34+
let! result =
35+
Gen.getEmptyVariant variant
36+
|> TaskSeq.scanAsync (fun acc _ -> task { return acc + 1 }) 0
37+
|> TaskSeq.toListAsync
38+
39+
result |> should equal [ 0 ]
40+
}
41+
42+
module Functionality =
43+
[<Fact>]
44+
let ``TaskSeq-scan yields initial state then each intermediate state`` () = task {
45+
let! result =
46+
TaskSeq.ofList [ 1; 2; 3; 4; 5 ]
47+
|> TaskSeq.scan (fun acc item -> acc + item) 0
48+
|> TaskSeq.toListAsync
49+
50+
// N=5 elements → N+1=6 output elements
51+
result |> should equal [ 0; 1; 3; 6; 10; 15 ]
52+
}
53+
54+
[<Fact>]
55+
let ``TaskSeq-scanAsync yields initial state then each intermediate state`` () = task {
56+
let! result =
57+
TaskSeq.ofList [ 1; 2; 3; 4; 5 ]
58+
|> TaskSeq.scanAsync (fun acc item -> task { return acc + item }) 0
59+
|> TaskSeq.toListAsync
60+
61+
result |> should equal [ 0; 1; 3; 6; 10; 15 ]
62+
}
63+
64+
[<Fact>]
65+
let ``TaskSeq-scan output length is input length plus one`` () = task {
66+
let input = TaskSeq.ofList [ 'a'; 'b'; 'c' ]
67+
68+
let! result =
69+
input
70+
|> TaskSeq.scan (fun acc c -> acc + string c) ""
71+
|> TaskSeq.toListAsync
72+
73+
result |> should equal [ ""; "a"; "ab"; "abc" ]
74+
}
75+
76+
[<Fact>]
77+
let ``TaskSeq-scanAsync output length is input length plus one`` () = task {
78+
let input = TaskSeq.ofList [ 'a'; 'b'; 'c' ]
79+
80+
let! result =
81+
input
82+
|> TaskSeq.scanAsync (fun acc c -> task { return acc + string c }) ""
83+
|> TaskSeq.toListAsync
84+
85+
result |> should equal [ ""; "a"; "ab"; "abc" ]
86+
}
87+
88+
[<Fact>]
89+
let ``TaskSeq-scan with single element returns two-element result`` () = task {
90+
let! result =
91+
TaskSeq.singleton 42
92+
|> TaskSeq.scan (fun acc item -> acc + item) 10
93+
|> TaskSeq.toListAsync
94+
95+
result |> should equal [ 10; 52 ]
96+
}
97+
98+
[<Theory; ClassData(typeof<TestImmTaskSeq>)>]
99+
let ``TaskSeq-scan accumulates correctly across variants`` variant = task {
100+
// Input is 1..10; cumulative sums: 0, 1, 3, 6, 10, 15, 21, 28, 36, 45, 55
101+
let! result =
102+
Gen.getSeqImmutable variant
103+
|> TaskSeq.scan (fun acc item -> acc + item) 0
104+
|> TaskSeq.toListAsync
105+
106+
result
107+
|> should equal [ 0; 1; 3; 6; 10; 15; 21; 28; 36; 45; 55 ]
108+
}
109+
110+
[<Theory; ClassData(typeof<TestImmTaskSeq>)>]
111+
let ``TaskSeq-scanAsync accumulates correctly across variants`` variant = task {
112+
let! result =
113+
Gen.getSeqImmutable variant
114+
|> TaskSeq.scanAsync (fun acc item -> task { return acc + item }) 0
115+
|> TaskSeq.toListAsync
116+
117+
result
118+
|> should equal [ 0; 1; 3; 6; 10; 15; 21; 28; 36; 45; 55 ]
119+
}
120+
121+
module SideEffects =
122+
[<Theory; ClassData(typeof<TestSideEffectTaskSeq>)>]
123+
let ``TaskSeq-scan second iteration accumulates from fresh start`` variant = task {
124+
let ts = Gen.getSeqWithSideEffect variant
125+
126+
let! first =
127+
ts
128+
|> TaskSeq.scan (fun acc item -> acc + item) 0
129+
|> TaskSeq.toListAsync
130+
131+
first
132+
|> should equal [ 0; 1; 3; 6; 10; 15; 21; 28; 36; 45; 55 ]
133+
134+
let! second =
135+
ts
136+
|> TaskSeq.scan (fun acc item -> acc + item) 0
137+
|> TaskSeq.toListAsync
138+
139+
// side-effect sequences yield next 10 items (11..20)
140+
second
141+
|> should equal [ 0; 11; 23; 36; 50; 65; 81; 98; 116; 135; 155 ]
142+
}
143+
144+
[<Theory; ClassData(typeof<TestSideEffectTaskSeq>)>]
145+
let ``TaskSeq-scanAsync second iteration accumulates from fresh start`` variant = task {
146+
let ts = Gen.getSeqWithSideEffect variant
147+
148+
let! first =
149+
ts
150+
|> TaskSeq.scanAsync (fun acc item -> task { return acc + item }) 0
151+
|> TaskSeq.toListAsync
152+
153+
first
154+
|> should equal [ 0; 1; 3; 6; 10; 15; 21; 28; 36; 45; 55 ]
155+
156+
let! second =
157+
ts
158+
|> TaskSeq.scanAsync (fun acc item -> task { return acc + item }) 0
159+
|> TaskSeq.toListAsync
160+
161+
second
162+
|> should equal [ 0; 11; 23; 36; 50; 65; 81; 98; 116; 135; 155 ]
163+
}

src/FSharp.Control.TaskSeq/TaskSeq.fs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,3 +406,5 @@ type TaskSeq private () =
406406
static member zip source1 source2 = Internal.zip source1 source2
407407
static member fold folder state source = Internal.fold (FolderAction folder) state source
408408
static member foldAsync folder state source = Internal.fold (AsyncFolderAction folder) state source
409+
static member scan folder state source = Internal.scan (FolderAction folder) state source
410+
static member scanAsync folder state source = Internal.scan (AsyncFolderAction folder) state source

src/FSharp.Control.TaskSeq/TaskSeq.fsi

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1350,6 +1350,35 @@ type TaskSeq =
13501350
static member foldAsync:
13511351
folder: ('State -> 'T -> #Task<'State>) -> state: 'State -> source: TaskSeq<'T> -> Task<'State>
13521352

1353+
/// <summary>
1354+
/// Like <see cref="TaskSeq.fold" />, but returns the sequence of intermediate results and the final result.
1355+
/// The first element of the output sequence is always the initial state. If the input task sequence
1356+
/// has <c>N</c> elements, the output task sequence has <c>N + 1</c> elements.
1357+
/// If the folder function <paramref name="folder" /> is asynchronous, consider using <see cref="TaskSeq.scanAsync" />.
1358+
/// </summary>
1359+
///
1360+
/// <param name="folder">A function that updates the state with each element from the sequence.</param>
1361+
/// <param name="state">The initial state.</param>
1362+
/// <param name="source">The input sequence.</param>
1363+
/// <returns>A task sequence of states, starting with the initial state and applying the folder to each element.</returns>
1364+
/// <exception cref="T:ArgumentNullException">Thrown when the input task sequence is null.</exception>
1365+
static member scan: folder: ('State -> 'T -> 'State) -> state: 'State -> source: TaskSeq<'T> -> TaskSeq<'State>
1366+
1367+
/// <summary>
1368+
/// Like <see cref="TaskSeq.foldAsync" />, but returns the sequence of intermediate results and the final result.
1369+
/// The first element of the output sequence is always the initial state. If the input task sequence
1370+
/// has <c>N</c> elements, the output task sequence has <c>N + 1</c> elements.
1371+
/// If the folder function <paramref name="folder" /> is synchronous, consider using <see cref="TaskSeq.scan" />.
1372+
/// </summary>
1373+
///
1374+
/// <param name="folder">A function that updates the state with each element from the sequence.</param>
1375+
/// <param name="state">The initial state.</param>
1376+
/// <param name="source">The input sequence.</param>
1377+
/// <returns>A task sequence of states, starting with the initial state and applying the folder to each element.</returns>
1378+
/// <exception cref="T:ArgumentNullException">Thrown when the input task sequence is null.</exception>
1379+
static member scanAsync:
1380+
folder: ('State -> 'T -> #Task<'State>) -> state: 'State -> source: TaskSeq<'T> -> TaskSeq<'State>
1381+
13531382
/// <summary>
13541383
/// Return a new task sequence with a new item inserted before the given index.
13551384
/// </summary>

src/FSharp.Control.TaskSeq/TaskSeqInternal.fs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,29 @@ module internal TaskSeqInternal =
371371
return result
372372
}
373373

374+
let scan folder initial (source: TaskSeq<_>) =
375+
checkNonNull (nameof source) source
376+
377+
match folder with
378+
| FolderAction folder -> taskSeq {
379+
let mutable state = initial
380+
yield state
381+
382+
for item in source do
383+
state <- folder state item
384+
yield state
385+
}
386+
387+
| AsyncFolderAction folder -> taskSeq {
388+
let mutable state = initial
389+
yield state
390+
391+
for item in source do
392+
let! newState = folder state item
393+
state <- newState
394+
yield state
395+
}
396+
374397
let toResizeArrayAsync source =
375398
checkNonNull (nameof source) source
376399

0 commit comments

Comments
 (0)