Skip to content

fix: weigh download counts on a log scale - #2175

Merged
netomi merged 3 commits into
mainfrom
fix/search-download-scale
Sep 9, 2026
Merged

netomi merged 3 commits into
mainfrom
fix/search-download-scale

Conversation

@netomi

@netomi netomi commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to #2174 (now merged), from the same investigation of EclipseFdn/open-vsx.org#13014.

Popularity was in the formula but not in the score

The download term of the relevance score is the extension's count over the largest count in the registry, clamped to one:

var downloadsValue = extension.getDownloadCount() / stats.downloadRef;

Download counts span several orders of magnitude, so dividing by the largest of them leaves everything outside the top few percent indistinguishable from nothing:

downloads linear log1p sqrt
1,000 0.0001 0.411 0.007
50,000 0.0025 0.644 0.050
637,756 0.0319 0.795 0.179
5,000,000 0.2500 0.918 0.500

(against a registry maximum of 20,000,000)

Relevance is the sum of three equally-weighted terms — rating, downloads, recency — each able to contribute up to 1.0. So an extension with 637k downloads was getting 0.03 for its popularity, next to a rating worth up to 1.0 and a release date worth up to 1.0. That is how #13014 comes to describe a result list whose every entry has "a small fraction" of the download count of the extension missing from it: the downloads were being counted, just not enough to matter.

The change

var downloadsValue = Math.log1p(extension.getDownloadCount()) / stats.downloadRefLog;

Still measured against the registry maximum; what changes is that the distance between a thousand downloads and a million is no longer rounding error.

The trade, stated plainly: log compresses the low end in exchange — 1,000 downloads scores 0.41 where linear gave 0.0001 — so this buys discrimination in the middle of the range at the cost of some at the bottom. sqrt splits the difference (0.179 for 637k), and percentile-of-the-distribution would spread it evenly at the cost of computing the distribution. Log is the one that needs nothing SearchStats doesn't already gather. Easy to swap if you'd rather have a different curve — the table above is the whole basis for the choice.

downloadRefLog is kept alongside the raw downloadRef rather than replacing it, because the raw maximum is what appears in the diagnostics written when a relevance comes out NaN, where a log would say much less about the registry.

No reindex needed

Relevance is computed when a document is indexed, and the daily soft update recomputes it for every active extension — that job exists precisely because relevance depends on timestamps relative to now. So this applies within a day of deploying, with no manual rebuild.

Tests

weighsDownloadsOnALogScale asserts what the term is worth, not merely that more downloads beat fewer — which the existing testRelevanceDownloadCount already covers and which a linear scale satisfies just as well. It isolates the term by giving the extension no rating and the oldest timestamp in the registry, zeroing the other two so relevance equals the download term, then asserts the log ratio to within 0.001. It fails on the linear formula, which I checked.

Full server suite green (1184 tests), formatter clean.

🤖 Generated with Claude Code

@netomi
netomi force-pushed the fix/search-download-scale branch from b65cc76 to 8ecca46 Compare September 7, 2026 17:07
@netomi
netomi requested review from cstamas and a lite review from Copilot September 8, 2026 11:30
@netomi

netomi commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Using a linear scale for the download counts is highly misleading as it is always calculated based on the most downloaded extension. That leads to a situation where the download count becomes irrelevant as criteria, replacing it to a log scale make it more sane though it will need more improvements. Once the explain page is available it will be easier to assess the results and adjust.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It changes global search ranking behavior (relevance scoring) and should get a final human review to validate the intended impact and guardrails beyond unit tests.

Pull request overview

Updates the search relevance calculation so extension download counts contribute on a logarithmic scale, improving ranking discrimination across the long-tail of download counts while keeping the normalization relative to the registry maximum.

Changes:

  • Change the downloads component of relevance from linear normalization to log1p(downloads) / log1p(maxDownloads).
  • Extend SearchStats with a precomputed downloadRefLog to avoid recomputing the reference log per entry.
  • Add a unit test that asserts the expected (log-scaled) download-term value, not just monotonic ordering.
File summaries
File Description
server/src/main/java/org/eclipse/openvsx/search/RelevanceService.java Switches downloads relevance term to log scale and adds downloadRefLog to SearchStats.
server/src/test/java/org/eclipse/openvsx/search/ElasticSearchServiceTest.java Adds an assertion test for the exact log-scaled downloads contribution.
Review details

Suppressed comments (1)

server/src/test/java/org/eclipse/openvsx/search/ElasticSearchServiceTest.java:232

  • This test tries to isolate the download term by giving the extension 0 reviews, but calculateRating uses IMDB-style smoothing against repositories.getAverageReviewRating(). Unless that average is explicitly stubbed to 0 here, the rating term may contribute and make this assertion flaky if the repository mock setup changes.
        var index = mockIndex(true);
        // After mockIndex, which stubs a maximum of its own.
        Mockito.when(repositories.getMaxExtensionDownloadCount()).thenReturn(1_000_000);
        var oldest = LocalDateTime.parse("2020-01-01T00:00");
        var extension = mockExtension("foo", "n1", "u1", 0.0, 0, 100_000, oldest, false, false);
  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@netomi
netomi force-pushed the fix/search-download-scale branch from 8ecca46 to 37cbcb2 Compare September 8, 2026 12:44
@netomi

netomi commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Rebased and the review comment addressed.

The conflict was with the stack, not with main: #2174's branch had been rebased onto a newer
main, so this branch was still carrying its own older copies of #2174's three commits on top of
f4e564550. Replayed just the download-scale commit onto the current fix/search-field-boosts, so
the diff is once again only this change.

The one real conflict was in RelevanceService, and it is because #2179 landed in the meantime:
breakdown no longer takes an ExtensionSearch, since the download count it read from there is
copied straight off the extension. So the line became

var downloadsValue = Math.log1p(extension.getDownloadCount()) / stats.downloadRefLog;

rather than entry.getDownloadCount(). Same value either way — Extension.toSearch does
search.setDownloadCount(this.getDownloadCount()) — so the formula is unchanged by the resolution.

The Copilot comment was right, and pointed at something slightly worse than it described: the
rating term was not being zeroed by the extension having no reviews (the formula smooths towards the
registry average, so no reviews scores that average), and getAverageReviewRating() was not stubbed
at all — the zero came from Mockito's default for an unstubbed double. A later stub of it in the
shared setup would have moved this test's expected value without touching this test. Now stated in
the javadoc and stubbed explicitly, in its own commit.

Verified after the rebase: 93 tests across the search suites pass, the formatter reports both touched
files clean, and ./gradlew spotlessCheck now passes outright (the four long-standing violations went
away with #2176). Re-confirmed the test fails on the linear formula rather than merely drifting.

Still stacked on #2174 — merge that first, or say the word and I will retarget this at main.

@netomi
netomi requested a review from gnugomez September 8, 2026 14:17
@netomi
netomi force-pushed the fix/search-download-scale branch from 37cbcb2 to fdb098a Compare September 8, 2026 21:39
@netomi
netomi force-pushed the fix/search-download-scale branch 2 times, most recently from fdb098a to 8754a78 Compare September 9, 2026 12:05
Base automatically changed from fix/search-field-boosts to main September 9, 2026 12:05
netomi and others added 3 commits September 9, 2026 14:07
The download term of the relevance score is the extension's count over the
largest count in the registry, clamped to one. Download counts span several
orders of magnitude, so dividing by the largest of them leaves everything
outside the top few percent indistinguishable from nothing: against a
registry maximum of twenty million, an extension with six hundred thousand
downloads scores 0.03 of the one this term can contribute - against a
relevance that also carries a rating and a recency term, each worth up to one
in their own right.

Which is how EclipseFdn/open-vsx.org#13014 comes to describe a result list
whose entries have "a small fraction" of the download count of the extension
that is missing from it. Popularity was in the formula but not, at any
realistic scale, in the score.

Taken logarithmically the same extension scores 0.79. The comparison is still
against the registry maximum; what changes is that the distance between a
thousand downloads and a million is no longer rounding error.

Note it compresses the low end in exchange - a thousand downloads scores 0.41
where linear gave 0.0001 - so this buys discrimination in the middle of the
range at the cost of some at the bottom. sqrt would split the difference and
percentile-of-the-distribution would spread it evenly, at the cost of
computing the distribution; log is the one that needs nothing that
SearchStats does not already have.

Relevance is computed when a document is indexed, and the daily soft update
recomputes it for every active extension - which is what that job is for,
since relevance already depends on timestamps relative to now. So this needs
no reindex of its own and applies within a day of deploying.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The test's javadoc credited the extension having no rating, but the formula
smooths a rating towards the registry average, so an extension with no reviews
scores that average rather than nothing. What held the term at zero was the
average itself - and only via Mockito's default for an unstubbed double, so a
later stub of getAverageReviewRating in the shared setup would have moved this
test's expected value without touching it. Now stated, and stubbed explicitly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both recounted the linear scale and what it scored, which is the shape #2180
rules out: history belongs in the commit message and the pull request, and both
already carry it. What is left is the reason the code reads as it does - that
download counts span orders of magnitude, so measuring one against the largest
tells you almost nothing about the rest.

The javadoc on downloadRefLog stays as it is. It explains why the raw maximum
is kept alongside rather than replaced, which is a constraint on the code as it
stands and not an account of how it used to be.

The test's javadoc keeps the part that says how the other two terms are held at
zero, since nothing about reading the test makes that apparent, and drops the
arithmetic - which the test computes anyway, so stating it in prose was
narration of the assertion two lines below it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@netomi
netomi force-pushed the fix/search-download-scale branch from 8754a78 to 9d7b003 Compare September 9, 2026 12:11
@netomi

netomi commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Rebased and the comments revised.

The conflict was the stack again, not main: #2174 merged as 411c6ac6 with new hashes, so this branch was still carrying its own older copies of those four commits. Replayed just the two that belong to it, so the diff is once again only the download-scale change — two files, one of them the test. MERGEABLE, and the base is main now, so the stacking note is gone from the description.

On #2180. Two of the comments here were exactly the shape that clause rules out. The one on the formula recounted the linear scale and what it used to score:

// Log scale, not linear. Both scales measure this against the largest download count in the
// registry, but download counts span several orders of magnitude, and dividing by the largest of
// them leaves everything outside the top few percent indistinguishable from nothing: against a
// maximum of twenty million, an extension with six hundred thousand downloads scores 0.03 of the
// one this term can contribute, so its popularity is worth roughly nothing next to a rating or a
// recent release. Taken logarithmically the same extension scores 0.79, ...

Nine lines, most of them about code that will not exist once this merges. Now three, keeping only the reason the line reads as it does:

// Log scale: download counts span several orders of magnitude, so measuring one against the
// largest in the registry leaves everything outside the top few percent indistinguishable
// from nothing. See EclipseFdn/open-vsx.org#13014.

The before-and-after arithmetic is still in the commit message and in the table above, which is where someone asking "why did this change" is looking.

The test javadoc lost the same arithmetic — the test computes Math.log1p(100_000) / Math.log1p(1_000_000) two lines below, so stating 0.83 in prose was narrating the assertion. It keeps the part explaining how the other two terms are held at zero, since nothing about reading the test makes that apparent.

One I deliberately kept: the javadoc on downloadRefLog, which explains why the raw maximum is kept alongside rather than replaced (the diagnostics written for an invalid relevance report the raw one, where a log would say much less). That is a constraint on the code as it stands rather than an account of how it used to be, so #2180 does not reach it.

Re-verified after the trim, since a comment change should not alter behaviour but the rebase moved real code: weighsDownloadsOnALogScale still fails on the linear formula, 93 tests across the search suites pass, and spotlessCheck is clean.

@netomi
netomi merged commit 2ecfa6c into main Sep 9, 2026
5 checks passed
@netomi
netomi deleted the fix/search-download-scale branch September 9, 2026 12:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants