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"); + } }