diff --git a/app/lib/service/download_counts/package_trends.dart b/app/lib/service/download_counts/package_trends.dart new file mode 100644 index 0000000000..94ae8660f9 --- /dev/null +++ b/app/lib/service/download_counts/package_trends.dart @@ -0,0 +1,87 @@ +// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +const analysisWindowDays = 30; + +/// Calculates the relative daily growth rate of a package's downloads. +/// +/// Given a list with total daily downloads ([totalDownloads]), where the most +/// recent day's data is at index 0, this function analyzes the downloads trend +/// over the last ([analysisWindowDays]) days to determine how fast a package is +/// growing relative to its own current download volume. +/// +/// A positive value indicates an upward trend in downloads, while a negative +/// value indicates a downward trend. The magnitude represents the growth (or +/// decline) rate normalized by the average daily downloads, allowing for +/// comparison across packages of different popularity. For example, a slope of +/// +10 downloads/day is more significant for a package with 100 average daily +/// downloads (10% relative growth) than for a package with 10000 average daily +/// downloads (0.1% relative growth). +double computeRelativeGrowthRate(List totalDownloads) { + final List data; + if (totalDownloads.length < analysisWindowDays) { + data = [ + ...totalDownloads, + ...List.filled(analysisWindowDays - totalDownloads.length, 0) + ]; + } else { + data = totalDownloads; + } + + final recentDownloads = data.sublist(0, analysisWindowDays); + + final averageRecentDownloads = + recentDownloads.reduce((prev, element) => prev + element) / + recentDownloads.length; + + // We reverse the recentDownloads list for regression, since the first entry + // is the newest point in time. By reversing, we pass the data in + // chronological order. + final growthRate = + calculateLinearRegressionSlope(recentDownloads.reversed.toList()); + + // Normalize slope by average downloads to represent relative growth. + // This measures how much the download count is growing relative to its + // current volume. + return growthRate / averageRecentDownloads; +} + +/// Computes the slope of the best-fit line for a given list of data points +/// [yValues] using the method of least squares (linear regression). +/// +/// The function assumes that the [yValues] are equally spaced in time and are +/// provided in chronological order +/// +/// The slope `b` is calculated using the formula: `b = (N * sum(xy) - sum(x) * +/// sum(y)) / (N * sum(x^2) - (sum(x))^2)` where `N` is the number of data +/// points. +/// +/// Returns `0.0` if the slope cannot be determined reliably (e.g., if there are +/// fewer than 2 data points, or if the denominator in the slope formula is +/// effectively zero). +double calculateLinearRegressionSlope(List yValues) { + double sumX = 0, sumY = 0, sumXY = 0, sumXX = 0; + final n = yValues.length; + + // Slope is undefined or 0 for fewer than 2 points. + if (n < 2) { + return 0.0; + } + + for (int x = 0; x < n; x++) { + final y = yValues[x]; + sumX += x; + sumY += y; + sumXY += x * y; + sumXX += x * x; + } + + final double denominator = (n * sumXX - sumX * sumX); + + // If the denominator is very close to zero, the slope is unstable/undefined. + if (denominator.abs() < 1e-9) { + return 0.0; + } + return (n * sumXY - sumX * sumY) / denominator; +} diff --git a/app/test/service/download_counts/package_trends_test.dart b/app/test/service/download_counts/package_trends_test.dart new file mode 100644 index 0000000000..85694c2781 --- /dev/null +++ b/app/test/service/download_counts/package_trends_test.dart @@ -0,0 +1,96 @@ +// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:pub_dev/service/download_counts/package_trends.dart'; +import 'package:test/test.dart'; + +void main() { + group('calculateLinearRegressionSlope', () { + test('correctly calculates slope for chronological data', () { + expect(calculateLinearRegressionSlope([10.0, 20.0, 30.0]), 10.0); + expect(calculateLinearRegressionSlope([30.0, 20.0, 10.0]), -10.0); + expect(calculateLinearRegressionSlope([10.0, 10.0, 10.0]), 0); + }); + + test('return 0.0 if denominator is very small', () { + expect(calculateLinearRegressionSlope([]), 0.0); + expect(calculateLinearRegressionSlope([10.0]), 0.0); + }); + }); + + group('computeRelativeGrowthRate', () { + test('returns 0.0 for stable downloads meeting threshold', () { + final downloads = List.generate(analysisWindowDays, (i) => 2000); + expect(computeRelativeGrowthRate(downloads), 0.0); + }); + + test('calculates positive relative growth rate for positive trend', () { + // Input list (newest first): [1645, 1635, ..., 1355] (30 values) + // Average = 1500 for the first 30 values. Slope: 10. + final downloads = + List.generate(analysisWindowDays * 2, (i) => 1645 - (i * 10)); + final expectedRate = 10.0 / 1500.0; + expect(computeRelativeGrowthRate(downloads), expectedRate); + }); + + test('calculates negative relative growth rate for negative trend', () { + // Input list (newest first): [1355, 1365, ..., 1645] + // Average = 1500. Slope: -10. + final downloads = + List.generate(analysisWindowDays, (i) => 1355 + (i * 10)); + final expectedRate = -10.0 / 1500.0; + expect(computeRelativeGrowthRate(downloads), expectedRate); + }); + + test( + 'calculates positive relative growth for data barely meeting threshold', + () { + // Input list (newest first): [1016, 1015, ..., 987] + // Average: 1001.5. Slope: 1. + final downloads = + List.generate(analysisWindowDays, (i) => 1016 - i * 1); + final expectedRate = 1.0 / 1001.5; + expect(computeRelativeGrowthRate(downloads), closeTo(expectedRate, 1e-9)); + }); + + test('should handle fluctuating data with a slight positive overall trend', + () { + // Newest first. Average 1135. + final downloads = [ + 1300, + 1250, + 1280, + 1230, + 1260, + 1210, + 1240, + 1190, + 1220, + 1170, + 1200, + 1150, + 1180, + 1130, + 1160, + 1110, + 1140, + 1090, + 1120, + 1070, + 1100, + 1050, + 1080, + 1030, + 1060, + 1010, + 1040, + 990, + 1020, + 970 + ]; + final expectedRate = 683250.0 / 67425.0 / 1135.0; + expect(computeRelativeGrowthRate(downloads), expectedRate); + }); + }); +}