[CmdPal] Replace main-page magic-number scoring with a principled tiered ranker (#49189)

>[!WARNING]
> This PR is one in a series of PRs focused on rearchitecting the
search/scoring logic of the `MainListPage`. An explanation of the entire
search/scoring logic can be found below.
> 
> **This PR should not be merged until PR #49190 is merged into it.**

>[!NOTE]
> To test the final result, run the branch associated with PR #49249.

This stack rebuilds how Command Palette ranks and displays results on
its main page.

Strong text matches now consistently appear above weaker ones. Usage
history and provider preferences can improve ordering between similarly
relevant results, but they cannot push a poor match above an obvious
one.

The stack also makes search feel faster. Results appear without waiting
for slower providers, app scoring runs more efficiently, and weak
matches are hidden while the user has typed only one or two characters.
Automated tests protect the new behavior, while privacy conscious
telemetry measures performance and relevance without recording searches.

## Pull requests

1. [#49189](https://github.com/microsoft/PowerToys/pull/49189)
introduces the new ranking foundation. Results are grouped by match
strength, ensuring exact names, prefixes, and acronyms rank above loose
fuzzy matches.

2. [#49190](https://github.com/microsoft/PowerToys/pull/49190) improves
how Command Palette learns from command usage. Recent and frequently
used commands receive a sensible boost, and that history now persists
across restarts.

3. [#49191](https://github.com/microsoft/PowerToys/pull/49191) lets
users give each provider a Lower, Normal, or Higher search preference.
This preference helps resolve close matches without overriding result
relevance.

4. [#49194](https://github.com/microsoft/PowerToys/pull/49194) makes the
first set of results appear sooner. Commands and apps are shown
immediately, while slower fallback results are added when they become
available.

5. [#49195](https://github.com/microsoft/PowerToys/pull/49195) adds a
comprehensive relevance test suite. It verifies that common searches
return the expected results and protects ranking quality from future
regressions.

6. [#49197](https://github.com/microsoft/PowerToys/pull/49197) adds
privacy conscious search telemetry. It measures result counts, response
time, and which result position was selected without recording search
text, result names, paths, or other user content.

7. [#49246](https://github.com/microsoft/PowerToys/pull/49246) adds a
performance measurement suite. It identifies where search time is spent
and provides a reliable way to evaluate performance improvements.

8. [#49247](https://github.com/microsoft/PowerToys/pull/49247) delivers
the main performance improvement. App results are scored in parallel and
expensive work no longer blocks rendering, while the final result order
remains unchanged.

9. [#49249](https://github.com/microsoft/PowerToys/pull/49249) prevents
misleading results from flashing when a search begins. For one or two
character searches, weak fuzzy app matches remain hidden until the query
is specific enough to produce useful results.

> [!WARNING]
> These PRs should be merged in LIFO order starting with #49249 with
this PR being the last.

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Michael Jolley
2026-08-06 13:14:37 -05:00
committed by GitHub
parent 15df4db8f2
commit f20348386a
3 changed files with 376 additions and 32 deletions

View File

@@ -678,37 +678,43 @@ public sealed partial class MainListPage : DynamicListPage,
? (precomputedItem.GetTitleTarget(precomputedFuzzyMatcher), precomputedItem.GetSubtitleTarget(precomputedFuzzyMatcher))
: (precomputedFuzzyMatcher.PrecomputeTarget(title), precomputedFuzzyMatcher.PrecomputeTarget(topLevelOrAppItem.Subtitle));
// Score components
// Score components. Keep the raw matcher scores so "did this signal match at
// all" is decided before the historical subtitle penalty (which can push a real
// subtitle match below zero).
var nameScore = precomputedFuzzyMatcher.Score(query, titleTarget);
var descriptionScore = (precomputedFuzzyMatcher.Score(query, subtitleTarget) - 4) / 2.0;
var extensionScore = extensionDisplayNameTarget is { } extTarget ? precomputedFuzzyMatcher.Score(query, extTarget) / 1.5 : 0;
var rawSubtitleScore = precomputedFuzzyMatcher.Score(query, subtitleTarget);
var rawExtensionScore = extensionDisplayNameTarget is { } extTarget ? precomputedFuzzyMatcher.Score(query, extTarget) : 0;
// Take best match from title/description/fallback, then add extension score
// Extension adds to max so items matching both title AND extension bubble up
var baseScore = Math.Max(Math.Max(nameScore, descriptionScore), isFallback ? 1 : 0);
var matchScore = baseScore + extensionScore;
var descriptionScore = (rawSubtitleScore - 4) / 2.0;
var extensionScore = rawExtensionScore / 1.5;
// Apply a penalty to fallback items so they rank below direct matches.
// Fallbacks that dynamically match queries (like RDP connections) should
// appear after apps and direct command matches.
if (isFallback && matchScore > 1)
// Lexical quality preserves the previous relative weighting of the signals: best
// of title/description (plus the fallback floor), then a smaller extension-name
// contribution added on top so items matching both title AND extension bubble up.
var lexicalQuality = Math.Max(Math.Max(nameScore, descriptionScore), isFallback ? 1 : 0) + extensionScore;
var matchedLexically = nameScore > 0 || rawSubtitleScore > 0 || rawExtensionScore > 0;
// The hard tier decides ordering; frecency and the alias-substring nudge only
// reorder items that already share a tier. ClassifyTier returns None precisely when
// nothing matched (no lexical, alias, or fallback signal), so this single gate also
// filters non-matches - no separate pre-check is needed.
var tier = MainListRanker.ClassifyTier(query.Original, title, isFallback, isAliasMatch, isAliasSubstringMatch, matchedLexically);
if (tier == RankTier.None)
{
// Reduce fallback scores by 50% to prioritize direct matches
matchScore = matchScore * 0.5;
return 0;
}
// Alias matching: exact match is overwhelming priority, substring match adds a small boost
var aliasBoost = isAliasMatch ? 9001 : (isAliasSubstringMatch ? 1 : 0);
var totalMatch = matchScore + aliasBoost;
var frecencyWeight = history.GetCommandHistoryWeight(id);
var aliasSubstringBonus = isAliasSubstringMatch && !isAliasMatch ? MainListRanker.AliasSubstringBonus : 0.0;
// Apply scaling and history boost only if we matched something real
var finalScore = totalMatch * 10;
if (totalMatch > 0)
{
finalScore += history.GetCommandHistoryWeight(id);
}
var withinTier = MainListRanker.WithinTierScore(
lexicalQuality,
frecencyWeight,
aliasSubstringBonus,
providerBonus: 0.0);
return (int)finalScore;
return MainListRanker.Pack(tier, withinTier);
}
private static int ScoreWhitespaceQuery(string query, string title, string subtitle, bool isFallback)

View File

@@ -0,0 +1,259 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
namespace Microsoft.CmdPal.UI.ViewModels.MainPage;
/// <summary>
/// The principled, deterministic ranking policy for the main/root page. Combines a hard
/// tier ladder with a weighted within-tier score, and packs both into a single sortable
/// integer so the existing score-descending sort continues to work unchanged.
/// </summary>
internal static class MainListRanker
{
// Each tier occupies a band of this width in the packed score. The within-tier score
// is clamped to this range so it can never spill into an adjacent tier's band. With a
// 10M stride and 6 real tiers the maximum packed value (~63M) is far below int.MaxValue.
internal const int TierStride = 10_000_000;
// Scale factors that turn signals into within-tier points. These deliberately mirror
// the previous flat balance (match x10 + history), so items that share a tier keep
// their long-established relative ordering; only cross-tier behavior changes. Lexical
// quality leads (x10) while frecency (x1) breaks ties and reorders near-equal matches,
// and can overcome roughly a single point of lexical difference - matching the old
// "one use makes VS -> Visual Studio the top hit" intent.
internal const double LexicalScale = 10.0;
internal const double FrecencyScale = 1.0;
// A small nudge for items whose alias merely starts with the query (as opposed to an
// exact alias, which gets its own top tier). Mirrors the previous +1-before-x10 boost.
internal const double AliasSubstringBonus = 10.0;
/// <summary>
/// Packs a tier and within-tier score into a single descending-sortable integer.
/// Returns 0 for <see cref="RankTier.None"/> so non-matches are filtered by the
/// existing "score &gt; 0" gate.
/// </summary>
public static int Pack(RankTier tier, double withinTierScore)
{
if (tier == RankTier.None)
{
return 0;
}
var within = (int)Math.Round(Math.Clamp(withinTierScore, 0.0, TierStride - 1));
return ((int)tier * TierStride) + within;
}
/// <summary>
/// Extracts the tier from a value produced by <see cref="Pack"/>. Intended for tests
/// and telemetry.
/// </summary>
public static RankTier TierOf(int packedScore)
{
if (packedScore <= 0)
{
return RankTier.None;
}
var tier = packedScore / TierStride;
return (RankTier)Math.Clamp(tier, 0, (int)RankTier.AliasExact);
}
/// <summary>
/// Classifies an item into a relevance tier based purely on the textual relationship
/// between the raw query and the title. Frecency/provider signals are intentionally
/// not considered here - they only affect the within-tier score.
/// </summary>
/// <param name="query">The raw query text.</param>
/// <param name="title">The item's title.</param>
/// <param name="isFallback">Whether the item is a fallback (always ranked at the floor).</param>
/// <param name="isAliasExact">Whether the query exactly equals the item's alias.</param>
/// <param name="isAliasSubstringMatch">Whether the item's alias starts with the query
/// (a partial alias match). An alias is an explicit, user-assigned shortcut and may be
/// intentionally unrelated to the title, so a partial alias match floors the tier to at
/// least <see cref="RankTier.Fuzzy"/> even when no lexical signal matched - otherwise
/// such items would be classified <see cref="RankTier.None"/> and silently dropped.</param>
/// <param name="matchedLexically">Whether any fuzzy signal (title/subtitle/extension) matched.</param>
public static RankTier ClassifyTier(
string query,
string title,
bool isFallback,
bool isAliasExact,
bool isAliasSubstringMatch,
bool matchedLexically)
{
if (isAliasExact)
{
return RankTier.AliasExact;
}
// Fallbacks always live at the floor so dynamic matches (e.g. RDP hosts) appear
// after direct command and app matches.
if (isFallback)
{
return RankTier.FallbackFloor;
}
// A partial alias match is enough to keep the item visible even when nothing else
// matched. It only floors to Fuzzy; a stronger title relationship below still wins.
var matchedOrAlias = matchedLexically || isAliasSubstringMatch;
var q = query.AsSpan().Trim();
if (q.IsEmpty || string.IsNullOrEmpty(title))
{
return matchedOrAlias ? RankTier.Fuzzy : RankTier.None;
}
var titleSpan = title.AsSpan();
// Ordinal (not culture-aware) comparisons keep ranking deterministic across locales
// - e.g. the Turkish dotted/dotless-I would otherwise change prefix/acronym results
// - and are faster on this per-item, per-keystroke path. The fuzzy matcher already
// handles looser linguistic matching; these tier boundaries are intentionally crisp.
if (titleSpan.Equals(q, StringComparison.OrdinalIgnoreCase))
{
return RankTier.ExactTitle;
}
if (titleSpan.StartsWith(q, StringComparison.OrdinalIgnoreCase))
{
return RankTier.Prefix;
}
if (MatchesWordBoundaryOrAcronym(title, q))
{
return RankTier.AcronymWordBoundary;
}
return matchedOrAlias ? RankTier.Fuzzy : RankTier.None;
}
/// <summary>
/// Composes the within-tier score from normalized signals. Lexical quality dominates;
/// frecency, the alias-substring nudge, and the extension (provider) bonus only
/// reorder items that already share a tier.
/// </summary>
public static double WithinTierScore(
double lexicalQuality,
double frecencyWeight,
double aliasSubstringBonus,
double providerBonus)
{
return (lexicalQuality * LexicalScale)
+ (frecencyWeight * FrecencyScale)
+ aliasSubstringBonus
+ providerBonus;
}
/// <summary>
/// Returns true when the query matches the start of any word in the title, or matches
/// the acronym formed by the title's word-initials (e.g. "vs" -> "Visual Studio").
/// </summary>
internal static bool MatchesWordBoundaryOrAcronym(string title, ReadOnlySpan<char> query)
{
if (query.IsEmpty || string.IsNullOrEmpty(title))
{
return false;
}
// initials holds at most one char per title character. Use the stack for typical
// short titles and fall back to the heap only for unusually long ones.
const int StackLimit = 64;
Span<char> initials = title.Length <= StackLimit
? stackalloc char[StackLimit]
: new char[title.Length];
var initialsLen = 0;
for (var i = 0; i < title.Length; i++)
{
if (IsWordStart(title, i))
{
// Word-boundary: does a word start with the whole query?
if (title.AsSpan(i).StartsWith(query, StringComparison.OrdinalIgnoreCase))
{
return true;
}
initials[initialsLen++] = title[i];
}
}
// Acronym: the query appears as a contiguous run of word-initials. Require length
// >= 2 so single characters are handled solely by the word-boundary check above.
if (query.Length >= 2 && initialsLen >= query.Length)
{
var initialsSpan = initials[..initialsLen];
if (initialsSpan.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0)
{
return true;
}
}
return false;
}
private static bool IsWordStart(string s, int i)
{
if (i == 0)
{
return char.IsLetterOrDigit(s[i]);
}
var prev = s[i - 1];
var cur = s[i];
// Start of a new word after a separator.
if (!char.IsLetterOrDigit(prev) && char.IsLetterOrDigit(cur))
{
return true;
}
// camelCase / PascalCase boundary: lower/digit -> upper.
if (char.IsUpper(cur) && (char.IsLower(prev) || char.IsDigit(prev)))
{
return true;
}
return false;
}
}
/// <summary>
/// Relevance tiers for main/root page ranking, from worst (lowest value) to best
/// (highest value). Ranking is <b>lexicographic</b>: an item in a higher tier always
/// sorts above an item in a lower tier. Signals such as frecency and per-provider
/// weighting only reorder items <i>within</i> the same tier - they can never promote an
/// item across a tier boundary. This is what keeps ordering predictable ("an exact match
/// always beats a fuzzy one").
/// </summary>
public enum RankTier
{
/// <summary>No match. The item should be filtered out.</summary>
None = 0,
/// <summary>The item only matched because it is an always-present fallback, or a
/// fallback whose dynamic title matched. Fallbacks live at the floor so they appear
/// after direct command/app matches.</summary>
FallbackFloor = 1,
/// <summary>The query matched as a fuzzy subsequence of the title, subtitle, or
/// extension name.</summary>
Fuzzy = 2,
/// <summary>The query matched the start of a word inside the title, or the acronym
/// formed by the title's word-initials (e.g. "vs" -> "Visual Studio").</summary>
AcronymWordBoundary = 3,
/// <summary>The title starts with the query.</summary>
Prefix = 4,
/// <summary>The title equals the query (case-insensitive).</summary>
ExactTitle = 5,
/// <summary>The query exactly equals a user-assigned alias. This is the strongest,
/// most explicit signal of intent.</summary>
AliasExact = 6,
}

View File

@@ -433,18 +433,19 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
}
[TestMethod]
public void ValidateUsageEventuallyHelps()
public void ValidateUsageDoesNotCrossTierBoundary()
{
// "C" is a prefix of "Command Prompt" (Prefix tier) but only a word-boundary
// match for "Visual Studio Code" (AcronymWordBoundary tier). Frecency only
// reorders items WITHIN a tier, so no amount of usage may lift a word-boundary
// match above a prefix match. This is the core "logical ordering" contract.
var items = CreateMockHistoryItems();
var emptyHistory = CreateMockHistoryService(new());
var history = CreateMockHistoryService(items);
var fuzzyMatcher = CreateMatcher();
var q = fuzzyMatcher.PrecomputeQuery("C");
// We're gonna run this test and keep adding more uses of VS Code till
// it breaks past Command Prompt
var vsCodeId = items[1].Id;
for (var i = 0; i < 10; i++)
for (var i = 0; i < 25; i++)
{
history = history.WithHistoryItem(vsCodeId);
@@ -452,10 +453,88 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
var weightedMatches = GetMatches(items, weightedScores).ToList();
Assert.AreEqual(4, weightedMatches.Count);
var expectedCmdIndex = i < 5 ? 0 : 1;
var expectedCodeIndex = i < 5 ? 1 : 0;
Assert.AreEqual("Command Prompt", weightedMatches[expectedCmdIndex].Title);
Assert.AreEqual("Visual Studio Code", weightedMatches[expectedCodeIndex].Title);
Assert.AreEqual("Command Prompt", weightedMatches[0].Title, "A prefix match must stay above a word-boundary match regardless of usage");
Assert.AreEqual("Visual Studio Code", weightedMatches[1].Title, "VS Code should be the top of the word-boundary tier once used");
}
}
[TestMethod]
public void ValidateUsageReordersWithinTier()
{
// Both "Visual Studio 2022" and "Visual Studio Code" share the same tier for the
// query "studio" (a word-boundary match on the second word, neither is a prefix).
// Heavy usage of one should be able to reorder it above its peer within that tier.
var items = new List<ListItemMock>
{
new("Visual Studio 2022", GivenId: "vs2022"),
new("Visual Studio Code", GivenId: "vscode"),
};
var history = CreateHistory(items.Reverse<ListItemMock>().ToList());
var fuzzyMatcher = CreateMatcher();
var q = fuzzyMatcher.PrecomputeQuery("studio");
// Both are equal word-boundary matches; give Code many uses so it climbs.
for (var i = 0; i < 10; i++)
{
history = history.WithHistoryItem("vscode");
}
var scores = items.Select(item => MainListPage.ScoreTopLevelItem(q, item, history, fuzzyMatcher)).ToList();
// Same tier for both, so the frequently-used one should not be below the other.
Assert.IsTrue(
MainListRanker.TierOf(scores[0]) == MainListRanker.TierOf(scores[1]),
"Both items should be classified into the same tier");
Assert.IsTrue(scores[1] >= scores[0], "The frequently-used item should reorder up within its tier");
}
[TestMethod]
public void AliasSubstringOnlyMatchIsNotDropped()
{
// Regression: an item whose alias merely starts with the query, but whose title,
// subtitle, and extension do not match at all, must still surface. A partial alias
// is an explicit, user-assigned shortcut that may be intentionally unrelated to the
// title (e.g. alias "term" on "Windows PowerShell", query "ter"). Before the fix,
// ClassifyTier returned RankTier.None for this case, Pack produced 0, and the item
// was filtered out by the "score > 0" gate in FilterListWithScores.
var tier = MainListRanker.ClassifyTier(
query: "ter",
title: "Windows PowerShell",
isFallback: false,
isAliasExact: false,
isAliasSubstringMatch: true,
matchedLexically: false);
Assert.AreNotEqual(RankTier.None, tier, "A partial-alias-only match must not be classified as None");
Assert.AreEqual(RankTier.Fuzzy, tier, "A partial-alias-only match should floor to the Fuzzy tier");
Assert.IsTrue(
MainListRanker.Pack(tier, 0.0) > 0,
"The packed score must be positive so the item survives the score > 0 filter");
}
[TestMethod]
public void AliasSubstringFloorIsOnlyAFloor()
{
// The alias-substring floor never demotes a stronger title relationship: an exact
// title match with a partial alias stays ExactTitle rather than dropping to Fuzzy.
var exactWithAlias = MainListRanker.ClassifyTier(
query: "settings",
title: "Settings",
isFallback: false,
isAliasExact: false,
isAliasSubstringMatch: true,
matchedLexically: true);
Assert.AreEqual(RankTier.ExactTitle, exactWithAlias, "A title exact match still wins over the alias floor");
// With neither a lexical match nor any alias match, the item is still filtered out.
var nothing = MainListRanker.ClassifyTier(
query: "zzz",
title: "Windows PowerShell",
isFallback: false,
isAliasExact: false,
isAliasSubstringMatch: false,
matchedLexically: false);
Assert.AreEqual(RankTier.None, nothing, "With neither a lexical nor an alias match the item is None");
}
}