Skip to content

Commit 0f51317

Browse files
authored
Add function for computing package trend (#8740)
1 parent 13099b7 commit 0f51317

File tree

2 files changed

+183
-0
lines changed

2 files changed

+183
-0
lines changed
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
const analysisWindowDays = 30;
6+
7+
/// Calculates the relative daily growth rate of a package's downloads.
8+
///
9+
/// Given a list with total daily downloads ([totalDownloads]), where the most
10+
/// recent day's data is at index 0, this function analyzes the downloads trend
11+
/// over the last ([analysisWindowDays]) days to determine how fast a package is
12+
/// growing relative to its own current download volume.
13+
///
14+
/// A positive value indicates an upward trend in downloads, while a negative
15+
/// value indicates a downward trend. The magnitude represents the growth (or
16+
/// decline) rate normalized by the average daily downloads, allowing for
17+
/// comparison across packages of different popularity. For example, a slope of
18+
/// +10 downloads/day is more significant for a package with 100 average daily
19+
/// downloads (10% relative growth) than for a package with 10000 average daily
20+
/// downloads (0.1% relative growth).
21+
double computeRelativeGrowthRate(List<int> totalDownloads) {
22+
final List<int> data;
23+
if (totalDownloads.length < analysisWindowDays) {
24+
data = [
25+
...totalDownloads,
26+
...List.filled(analysisWindowDays - totalDownloads.length, 0)
27+
];
28+
} else {
29+
data = totalDownloads;
30+
}
31+
32+
final recentDownloads = data.sublist(0, analysisWindowDays);
33+
34+
final averageRecentDownloads =
35+
recentDownloads.reduce((prev, element) => prev + element) /
36+
recentDownloads.length;
37+
38+
// We reverse the recentDownloads list for regression, since the first entry
39+
// is the newest point in time. By reversing, we pass the data in
40+
// chronological order.
41+
final growthRate =
42+
calculateLinearRegressionSlope(recentDownloads.reversed.toList());
43+
44+
// Normalize slope by average downloads to represent relative growth.
45+
// This measures how much the download count is growing relative to its
46+
// current volume.
47+
return growthRate / averageRecentDownloads;
48+
}
49+
50+
/// Computes the slope of the best-fit line for a given list of data points
51+
/// [yValues] using the method of least squares (linear regression).
52+
///
53+
/// The function assumes that the [yValues] are equally spaced in time and are
54+
/// provided in chronological order
55+
///
56+
/// The slope `b` is calculated using the formula: `b = (N * sum(xy) - sum(x) *
57+
/// sum(y)) / (N * sum(x^2) - (sum(x))^2)` where `N` is the number of data
58+
/// points.
59+
///
60+
/// Returns `0.0` if the slope cannot be determined reliably (e.g., if there are
61+
/// fewer than 2 data points, or if the denominator in the slope formula is
62+
/// effectively zero).
63+
double calculateLinearRegressionSlope(List<num> yValues) {
64+
double sumX = 0, sumY = 0, sumXY = 0, sumXX = 0;
65+
final n = yValues.length;
66+
67+
// Slope is undefined or 0 for fewer than 2 points.
68+
if (n < 2) {
69+
return 0.0;
70+
}
71+
72+
for (int x = 0; x < n; x++) {
73+
final y = yValues[x];
74+
sumX += x;
75+
sumY += y;
76+
sumXY += x * y;
77+
sumXX += x * x;
78+
}
79+
80+
final double denominator = (n * sumXX - sumX * sumX);
81+
82+
// If the denominator is very close to zero, the slope is unstable/undefined.
83+
if (denominator.abs() < 1e-9) {
84+
return 0.0;
85+
}
86+
return (n * sumXY - sumX * sumY) / denominator;
87+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import 'package:pub_dev/service/download_counts/package_trends.dart';
6+
import 'package:test/test.dart';
7+
8+
void main() {
9+
group('calculateLinearRegressionSlope', () {
10+
test('correctly calculates slope for chronological data', () {
11+
expect(calculateLinearRegressionSlope([10.0, 20.0, 30.0]), 10.0);
12+
expect(calculateLinearRegressionSlope([30.0, 20.0, 10.0]), -10.0);
13+
expect(calculateLinearRegressionSlope([10.0, 10.0, 10.0]), 0);
14+
});
15+
16+
test('return 0.0 if denominator is very small', () {
17+
expect(calculateLinearRegressionSlope([]), 0.0);
18+
expect(calculateLinearRegressionSlope([10.0]), 0.0);
19+
});
20+
});
21+
22+
group('computeRelativeGrowthRate', () {
23+
test('returns 0.0 for stable downloads meeting threshold', () {
24+
final downloads = List<int>.generate(analysisWindowDays, (i) => 2000);
25+
expect(computeRelativeGrowthRate(downloads), 0.0);
26+
});
27+
28+
test('calculates positive relative growth rate for positive trend', () {
29+
// Input list (newest first): [1645, 1635, ..., 1355] (30 values)
30+
// Average = 1500 for the first 30 values. Slope: 10.
31+
final downloads =
32+
List<int>.generate(analysisWindowDays * 2, (i) => 1645 - (i * 10));
33+
final expectedRate = 10.0 / 1500.0;
34+
expect(computeRelativeGrowthRate(downloads), expectedRate);
35+
});
36+
37+
test('calculates negative relative growth rate for negative trend', () {
38+
// Input list (newest first): [1355, 1365, ..., 1645]
39+
// Average = 1500. Slope: -10.
40+
final downloads =
41+
List<int>.generate(analysisWindowDays, (i) => 1355 + (i * 10));
42+
final expectedRate = -10.0 / 1500.0;
43+
expect(computeRelativeGrowthRate(downloads), expectedRate);
44+
});
45+
46+
test(
47+
'calculates positive relative growth for data barely meeting threshold',
48+
() {
49+
// Input list (newest first): [1016, 1015, ..., 987]
50+
// Average: 1001.5. Slope: 1.
51+
final downloads =
52+
List<int>.generate(analysisWindowDays, (i) => 1016 - i * 1);
53+
final expectedRate = 1.0 / 1001.5;
54+
expect(computeRelativeGrowthRate(downloads), closeTo(expectedRate, 1e-9));
55+
});
56+
57+
test('should handle fluctuating data with a slight positive overall trend',
58+
() {
59+
// Newest first. Average 1135.
60+
final downloads = <int>[
61+
1300,
62+
1250,
63+
1280,
64+
1230,
65+
1260,
66+
1210,
67+
1240,
68+
1190,
69+
1220,
70+
1170,
71+
1200,
72+
1150,
73+
1180,
74+
1130,
75+
1160,
76+
1110,
77+
1140,
78+
1090,
79+
1120,
80+
1070,
81+
1100,
82+
1050,
83+
1080,
84+
1030,
85+
1060,
86+
1010,
87+
1040,
88+
990,
89+
1020,
90+
970
91+
];
92+
final expectedRate = 683250.0 / 67425.0 / 1135.0;
93+
expect(computeRelativeGrowthRate(downloads), expectedRate);
94+
});
95+
});
96+
}

0 commit comments

Comments
 (0)