Skip to content

Commit b548437

Browse files
committed
fix: use log ceilings instead of relative normalisations
1 parent 11fd407 commit b548437

File tree

2 files changed

+130
-195
lines changed

2 files changed

+130
-195
lines changed

app/utils/compare-quadrant-chart.ts

Lines changed: 58 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -38,43 +38,41 @@ export interface PackageQuadrantPoint {
3838
}
3939
}
4040

41-
interface QuadrantMetricRanges {
42-
minimumDownloads: number
43-
maximumDownloads: number
44-
minimumTotalLikes: number
45-
maximumTotalLikes: number
46-
minimumPackageSize: number
47-
maximumPackageSize: number
48-
minimumInstallSize: number
49-
maximumInstallSize: number
50-
minimumDependencies: number
51-
maximumDependencies: number
52-
minimumTotalDependencies: number
53-
maximumTotalDependencies: number
54-
minimumVulnerabilities: number
55-
maximumVulnerabilities: number
56-
minimumLogarithmicDownloads: number
57-
maximumLogarithmicDownloads: number
58-
}
59-
6041
const WEIGHTS = {
61-
// Quadrant X axis
6242
adoption: {
63-
downloads: 0.7, // dominant signal because they best reflect real-world adoption
64-
freshness: 0.1, // small correction so stale packages are slightly penalized
65-
likes: 0.01, // might be pumped up in the future when ./npmx likes are more mainstream
43+
downloads: 0.75, // dominant signal because they best reflect real-world adoption (in the data we have through facets currently)
44+
freshness: 0.15, // small correction so stale packages are slightly
45+
likes: 0.1, // might be pumped up in the future when ./npmx likes are more mainstream
6646
},
67-
// Quadrant Y axis
6847
efficiency: {
6948
installSize: 0.3, // weighted highest because it best reflects consumer footprint
70-
dependencies: 0.2, // direct deps capture architectural and supply-chain complexity
71-
totalDependencies: 0.15, // same for total deps
72-
packageSize: 0.1, // publication weight, less important than installed footprint
49+
50+
// dependency weights are already measured in install size in some way, but still useful knobs to find the sweet spot
51+
dependencies: 0.05, // direct deps capture architectural and supply-chain complexity
52+
totalDependencies: 0.2, // same for total deps
53+
54+
packageSize: 0.1,
7355
vulnerabilities: 0.2, // penalize security burden
74-
types: 0.1, // TS support
75-
deprecation: 0.05,
56+
types: 0.2, // TS support
57+
// Note: the 'deprecated' metric is not weighed because it just forces a -1 evaluation
7658
},
7759
}
60+
61+
/* Fixed logarithmic ceilings to normalize metrics onto a stable [-1, 1] scale.
62+
* This avoids dataset-relative min/max normalization, which would shift scores depending
63+
* on which packages are being compared. Ceilings act as reference points for what is
64+
* considered 'high' for each metric, ensuring consistent positioning across different
65+
* datasets while preserving meaningful differences via log scaling.
66+
*/
67+
const LOG_CEILINGS = {
68+
downloads: 100_000_000,
69+
likes: 1000, // might be pumped up in the future when ./npmx likes are more mainstream
70+
installSize: 25_000_000,
71+
dependencies: 100,
72+
totalDependencies: 1_000,
73+
packageSize: 15_000_000,
74+
}
75+
7876
const VULNERABILITY_PENALTY_MULTIPLIER = 2
7977

8078
function clampInRange(value: number, min = -1, max = 1): number {
@@ -83,16 +81,6 @@ function clampInRange(value: number, min = -1, max = 1): number {
8381
return value
8482
}
8583

86-
function normalizeNumber(value: number, min: number, max: number): number {
87-
if (max === min) return 0
88-
const normalisedValue = (value - min) / (max - min)
89-
return clampInRange(normalisedValue * 2 - 1)
90-
}
91-
92-
function normalizeInverseNumber(value: number, min: number, max: number): number {
93-
return -normalizeNumber(value, min, max)
94-
}
95-
9684
function normalizeBoolean(value: boolean): number {
9785
return value ? 1 : -1
9886
}
@@ -101,7 +89,7 @@ function toSafeNumber(value: number | null | undefined, fallback = 0): number {
10189
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
10290
}
10391

104-
function getnormalisedFreshness(
92+
function getNormalisedFreshness(
10593
value: string | Date | null | undefined,
10694
maximumAgeInDays = 365,
10795
): number | null {
@@ -121,7 +109,7 @@ function getFreshnessScore(
121109
value: string | Date | null | undefined,
122110
maximumAgeInDays = 365,
123111
): number {
124-
const normalisedAge = getnormalisedFreshness(value, maximumAgeInDays)
112+
const normalisedAge = getNormalisedFreshness(value, maximumAgeInDays)
125113
if (normalisedAge === null) return -1
126114
return clampInRange(normalisedAge * 2 - 1)
127115
}
@@ -130,14 +118,27 @@ function getFreshnessPercentage(
130118
value: string | Date | null | undefined,
131119
maximumAgeInDays = 365,
132120
): number {
133-
const normalisedAge = getnormalisedFreshness(value, maximumAgeInDays)
121+
const normalisedAge = getNormalisedFreshness(value, maximumAgeInDays)
134122
if (normalisedAge === null) return 0
135123
return Math.max(0, Math.min(1, normalisedAge)) * 100
136124
}
137125

138-
function getVulnerabilityPenalty(value: number, minimum: number, maximum: number): number {
139-
const normalised = normalizeInverseNumber(value, minimum, maximum)
140-
return normalised < 0 ? normalised * VULNERABILITY_PENALTY_MULTIPLIER : normalised
126+
function normalizeLogHigherBetter(value: number, upperBound: number): number {
127+
const safeValue = Math.max(0, value)
128+
const safeUpperBound = Math.max(1, upperBound)
129+
const normalised = Math.log(safeValue + 1) / Math.log(safeUpperBound + 1)
130+
return clampInRange(normalised * 2 - 1)
131+
}
132+
133+
function normalizeLogLowerBetter(value: number, upperBound: number): number {
134+
return -normalizeLogHigherBetter(value, upperBound)
135+
}
136+
137+
function getVulnerabilityPenalty(value: number): number {
138+
if (value <= 0) return 1
139+
140+
const penalty = normalizeLogLowerBetter(value, 10)
141+
return penalty < 0 ? penalty * VULNERABILITY_PENALTY_MULTIPLIER : penalty
141142
}
142143

143144
function resolveQuadrant(x: number, y: number): PackageQuadrantPoint['quadrant'] {
@@ -147,44 +148,7 @@ function resolveQuadrant(x: number, y: number): PackageQuadrantPoint['quadrant']
147148
return 'BOTTOM_LEFT'
148149
}
149150

150-
function getQuadrantMetricRanges(packages: PackageQuadrantInput[]): QuadrantMetricRanges {
151-
const downloadsValues = packages.map(packageItem => toSafeNumber(packageItem.downloads))
152-
const totalLikesValues = packages.map(packageItem => toSafeNumber(packageItem.totalLikes))
153-
const packageSizeValues = packages.map(packageItem => toSafeNumber(packageItem.packageSize))
154-
const installSizeValues = packages.map(packageItem => toSafeNumber(packageItem.installSize))
155-
const dependenciesValues = packages.map(packageItem => toSafeNumber(packageItem.dependencies))
156-
const totalDependenciesValues = packages.map(packageItem =>
157-
toSafeNumber(packageItem.totalDependencies),
158-
)
159-
const vulnerabilitiesValues = packages.map(packageItem =>
160-
toSafeNumber(packageItem.vulnerabilities),
161-
)
162-
const logarithmicDownloadsValues = downloadsValues.map(value => Math.log(value + 1))
163-
164-
return {
165-
minimumDownloads: Math.min(...downloadsValues),
166-
maximumDownloads: Math.max(...downloadsValues),
167-
minimumTotalLikes: Math.min(...totalLikesValues),
168-
maximumTotalLikes: Math.max(...totalLikesValues),
169-
minimumPackageSize: Math.min(...packageSizeValues),
170-
maximumPackageSize: Math.max(...packageSizeValues),
171-
minimumInstallSize: Math.min(...installSizeValues),
172-
maximumInstallSize: Math.max(...installSizeValues),
173-
minimumDependencies: Math.min(...dependenciesValues),
174-
maximumDependencies: Math.max(...dependenciesValues),
175-
minimumTotalDependencies: Math.min(...totalDependenciesValues),
176-
maximumTotalDependencies: Math.max(...totalDependenciesValues),
177-
minimumVulnerabilities: Math.min(...vulnerabilitiesValues),
178-
maximumVulnerabilities: Math.max(...vulnerabilitiesValues),
179-
minimumLogarithmicDownloads: Math.min(...logarithmicDownloadsValues),
180-
maximumLogarithmicDownloads: Math.max(...logarithmicDownloadsValues),
181-
}
182-
}
183-
184-
function createQuadrantPoint(
185-
packageItem: PackageQuadrantInput,
186-
metricRanges: QuadrantMetricRanges,
187-
): PackageQuadrantPoint {
151+
function createQuadrantPoint(packageItem: PackageQuadrantInput): PackageQuadrantPoint {
188152
const downloads = toSafeNumber(packageItem.downloads)
189153
const totalLikes = toSafeNumber(packageItem.totalLikes)
190154
const packageSize = toSafeNumber(packageItem.packageSize)
@@ -197,56 +161,20 @@ function createQuadrantPoint(
197161
const freshnessScore = getFreshnessScore(packageItem.lastUpdated) // for weighing
198162
const freshnessPercent = getFreshnessPercentage(packageItem.lastUpdated) // for display
199163

200-
// Since downloads can span multiple orders of magnitude, log is used to normalise them to produce comparable scores instead of collapsing most values into noise
201-
const normalisedDownloads = normalizeNumber(
202-
Math.log(downloads + 1),
203-
metricRanges.minimumLogarithmicDownloads,
204-
metricRanges.maximumLogarithmicDownloads,
205-
)
206-
207-
const normalisedTotalLikes = normalizeNumber(
208-
totalLikes,
209-
metricRanges.minimumTotalLikes,
210-
metricRanges.maximumTotalLikes,
211-
)
164+
const normalisedDownloads = normalizeLogHigherBetter(downloads, LOG_CEILINGS.downloads)
165+
const normalisedLikes = normalizeLogHigherBetter(totalLikes, LOG_CEILINGS.likes)
166+
const normalisedInstallSize = normalizeLogLowerBetter(installSize, LOG_CEILINGS.installSize)
167+
const normalisedDependencies = normalizeLogLowerBetter(dependencies, LOG_CEILINGS.dependencies)
168+
const normalisedTotalDependencies = normalizeLogLowerBetter(totalDependencies, LOG_CEILINGS.totalDependencies)
169+
const normalisedPackageSize = normalizeLogLowerBetter(packageSize, LOG_CEILINGS.packageSize)
212170

213-
const normalisedInstallSize = normalizeInverseNumber(
214-
installSize,
215-
metricRanges.minimumInstallSize,
216-
metricRanges.maximumInstallSize,
217-
)
218-
219-
const normalisedDependencies = normalizeInverseNumber(
220-
dependencies,
221-
metricRanges.minimumDependencies,
222-
metricRanges.maximumDependencies,
223-
)
224-
225-
const normalisedTotalDependencies = normalizeInverseNumber(
226-
totalDependencies,
227-
metricRanges.minimumTotalDependencies,
228-
metricRanges.maximumTotalDependencies,
229-
)
230-
231-
const normalisedPackageSize = normalizeInverseNumber(
232-
packageSize,
233-
metricRanges.minimumPackageSize,
234-
metricRanges.maximumPackageSize,
235-
)
236-
237-
const normalisedVulnerabilities = getVulnerabilityPenalty(
238-
vulnerabilities,
239-
metricRanges.minimumVulnerabilities,
240-
metricRanges.maximumVulnerabilities,
241-
)
242-
243-
const deprecationScore = normalizeBoolean(!deprecated)
171+
const normalisedVulnerabilities = getVulnerabilityPenalty(vulnerabilities)
244172
const typesScore = normalizeBoolean(types)
245173

246174
const adoptionScore = clampInRange(
247175
normalisedDownloads * WEIGHTS.adoption.downloads +
248176
freshnessScore * WEIGHTS.adoption.freshness +
249-
normalisedTotalLikes * WEIGHTS.adoption.likes,
177+
normalisedLikes * WEIGHTS.adoption.likes,
250178
)
251179

252180
const rawEfficiencyScore =
@@ -255,12 +183,9 @@ function createQuadrantPoint(
255183
normalisedTotalDependencies * WEIGHTS.efficiency.totalDependencies +
256184
normalisedPackageSize * WEIGHTS.efficiency.packageSize +
257185
normalisedVulnerabilities * WEIGHTS.efficiency.vulnerabilities +
258-
typesScore * WEIGHTS.efficiency.types +
259-
deprecationScore * WEIGHTS.efficiency.deprecation
186+
typesScore * WEIGHTS.efficiency.types
260187

261-
// Deprecation considered harmful
262188
const efficiencyScore = deprecated ? -1 : clampInRange(rawEfficiencyScore)
263-
264189
const quadrant = resolveQuadrant(adoptionScore, efficiencyScore)
265190

266191
return {
@@ -290,6 +215,5 @@ function createQuadrantPoint(
290215

291216
export function createQuadrantDataset(packages: PackageQuadrantInput[]): PackageQuadrantPoint[] {
292217
if (!packages.length) return []
293-
const metricRanges = getQuadrantMetricRanges(packages)
294-
return packages.map(packageItem => createQuadrantPoint(packageItem, metricRanges))
295-
}
218+
return packages.map(packageItem => createQuadrantPoint(packageItem))
219+
}

0 commit comments

Comments
 (0)