diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs
index f4c2b30301..d2c7be8dd3 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs
@@ -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)
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListRanker.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListRanker.cs
new file mode 100644
index 0000000000..79bbeb3f1d
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListRanker.cs
@@ -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;
+
+///
+/// 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.
+///
+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;
+
+ ///
+ /// Packs a tier and within-tier score into a single descending-sortable integer.
+ /// Returns 0 for so non-matches are filtered by the
+ /// existing "score > 0" gate.
+ ///
+ 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;
+ }
+
+ ///
+ /// Extracts the tier from a value produced by . Intended for tests
+ /// and telemetry.
+ ///
+ 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);
+ }
+
+ ///
+ /// 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.
+ ///
+ /// The raw query text.
+ /// The item's title.
+ /// Whether the item is a fallback (always ranked at the floor).
+ /// Whether the query exactly equals the item's alias.
+ /// 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 even when no lexical signal matched - otherwise
+ /// such items would be classified and silently dropped.
+ /// Whether any fuzzy signal (title/subtitle/extension) matched.
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ public static double WithinTierScore(
+ double lexicalQuality,
+ double frecencyWeight,
+ double aliasSubstringBonus,
+ double providerBonus)
+ {
+ return (lexicalQuality * LexicalScale)
+ + (frecencyWeight * FrecencyScale)
+ + aliasSubstringBonus
+ + providerBonus;
+ }
+
+ ///
+ /// 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").
+ ///
+ internal static bool MatchesWordBoundaryOrAcronym(string title, ReadOnlySpan 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 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;
+ }
+}
+
+///
+/// Relevance tiers for main/root page ranking, from worst (lowest value) to best
+/// (highest value). Ranking is lexicographic: 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 within 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").
+///
+public enum RankTier
+{
+ /// No match. The item should be filtered out.
+ None = 0,
+
+ /// 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.
+ FallbackFloor = 1,
+
+ /// The query matched as a fuzzy subsequence of the title, subtitle, or
+ /// extension name.
+ Fuzzy = 2,
+
+ /// 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").
+ AcronymWordBoundary = 3,
+
+ /// The title starts with the query.
+ Prefix = 4,
+
+ /// The title equals the query (case-insensitive).
+ ExactTitle = 5,
+
+ /// The query exactly equals a user-assigned alias. This is the strongest,
+ /// most explicit signal of intent.
+ AliasExact = 6,
+}
diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/RecentCommandsTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/RecentCommandsTests.cs
index 8c7eab0a2a..c51d8fa12e 100644
--- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/RecentCommandsTests.cs
+++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/RecentCommandsTests.cs
@@ -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
+ {
+ new("Visual Studio 2022", GivenId: "vs2022"),
+ new("Visual Studio Code", GivenId: "vscode"),
+ };
+
+ var history = CreateHistory(items.Reverse().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");
+ }
}