[CmdPal] Consolidate search ranking changes (#49832)

## What's going on

The stacked pull requests are blocked by GitHub's stack merge flow. This
gives the full remaining search ranking change set one PR against
`main`.

## The plan

- Consolidates the open work from #49190, #49191, #49194, #49195,
#49197, #49246, #49247, and #49249.
- Keeps the existing stack unchanged while this PR provides an alternate
merge path.

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5459b847-afeb-4163-a803-977759dd92df
Copilot-Session: 49185697-186e-406f-b081-8c985c134274
Copilot-Session: 92905c83-2de6-449c-b4a1-a08003fe2576
Copilot-Session: efe987e5-9297-47fc-af99-2f49b057285a
Copilot-Session: f42917e2-d298-4bde-9056-79a9e6e17dfa
This commit is contained in:
Michael Jolley
2026-08-12 11:18:10 -05:00
committed by GitHub
parent 9e1c39def7
commit 888a142724
27 changed files with 3343 additions and 243 deletions

View File

@@ -145,6 +145,8 @@ Thank you for using PowerToys!
| Microsoft.PowerToys.CmdPalHotkeySummoned | Logs when the Command Palette is summoned via hotkey, distinguishing between global and context-specific hotkeys. |
| Microsoft.PowerToys.CmdPalInvokeResult | Records the result type of a Command Palette invocation. |
| Microsoft.PowerToys.CmdPalProcessStarted | Triggered when the Command Palette process is started. |
| Microsoft.PowerToys.CmdPal_SearchResults | Triggered when a search is triggered on the main list, including query length, result count, and duration. |
| Microsoft.PowerToys.CmdPal_SearchResultSelected | Triggered when a search result is selected, including query length, selection index, and result tier. |
### Crop and lock

View File

@@ -60,6 +60,84 @@ public static partial class InternalListHelpers
}
}
// Minimum item count before the parallel path earns back its partitioning and merge overhead,
// which keeps commands and small app sets on the serial path.
private const int ParallelScoringThreshold = 512;
/// <summary>
/// Order-preserving parallel variant of <see cref="FilterListWithScores{T}"/> that scores
/// contiguous index ranges on separate threads and concatenates them back in partition order,
/// producing a pre-sort buffer identical to the serial path. The scoring function has to be
/// pure per item, since each item is scored by exactly one thread.
/// </summary>
public static RoScored<T>[] FilterListWithScoresParallel<T>(
IReadOnlyList<T>? items,
in FuzzyQuery query,
in ScoringFunction<T> scoreFunction)
{
if (items is null || items.Count == 0)
{
return [];
}
var count = items.Count;
var partitions = Math.Min(Environment.ProcessorCount, Math.Max(1, count / ParallelScoringThreshold));
if (count < ParallelScoringThreshold || partitions <= 1)
{
return FilterListWithScores(items, query, scoreFunction);
}
// Copy the by-ref parameters into locals so the parallel body can capture them. FuzzyQuery
// is immutable, so one shared copy is safe to read from every thread.
var q = query;
var fn = scoreFunction;
var source = items;
var partitionResults = new List<RoScored<T>>[partitions];
System.Threading.Tasks.Parallel.For(0, partitions, p =>
{
var start = (int)((long)p * count / partitions);
var end = (int)((long)(p + 1) * count / partitions);
var local = new List<RoScored<T>>(end - start);
for (var i = start; i < end; i++)
{
var item = source[i];
var score = fn(in q, item);
if (score > 0)
{
local.Add(new RoScored<T>(item, score));
}
}
partitionResults[p] = local;
});
var total = 0;
for (var p = 0; p < partitions; p++)
{
total += partitionResults[p].Count;
}
// Contiguous ranges merged in partition order reproduce the serial buffer's exact
// enumeration order.
var buffer = GC.AllocateUninitializedArray<RoScored<T>>(total);
var pos = 0;
for (var p = 0; p < partitions; p++)
{
var list = partitionResults[p];
for (var j = 0; j < list.Count; j++)
{
buffer[pos++] = list[j];
}
}
Array.Sort(buffer, 0, total, default(RoScoredDescendingComparer<T>));
return buffer;
}
private static void GrowBuffer<T>(ref RoScored<T>[] buffer, int count)
{
var newBuffer = ArrayPool<RoScored<T>>.Shared.Rent(buffer.Length * 2);

View File

@@ -6,6 +6,7 @@
#define CMDPAL_FF_MAINPAGE_TIME_RAISE_ITEMS
*/
using System.Collections.Immutable;
using System.Collections.Specialized;
using System.Diagnostics;
using CommunityToolkit.Mvvm.Messaging;
@@ -49,6 +50,10 @@ public sealed partial class MainListPage : DynamicListPage,
private readonly ScoringFunction<IListItem> _fallbackScoringFunction;
private readonly IFuzzyMatcherProvider _fuzzyMatcherProvider;
// All main-page search telemetry state and emission is owned by this dedicated type, keeping
// MainListPage responsible for producing results rather than for tracking telemetry bookkeeping.
private readonly MainListPageSearchTelemetry _searchTelemetry = new();
// Stable separator instances so that the VM cache and InPlaceUpdateList
// recognise them across successive GetItems() calls
private readonly Separator _pinnedSeparator = new(Resources.home_sections_pinned_title);
@@ -63,16 +68,34 @@ public sealed partial class MainListPage : DynamicListPage,
private RoScored<IListItem>[]? _filteredItems;
private RoScored<IListItem>[]? _filteredApps;
// Keep as IEnumerable for deferred execution. Fallback item titles are updated
// asynchronously, so scoring must happen lazily when GetItems is called.
private IEnumerable<RoScored<IListItem>>? _scoredFallbackItems;
// Published with _filteredApps so filtering uses the query that produced the scores.
private int _filteredAppsQueryLength;
// Global/special fallbacks are scored on the render path, not at keystroke time, because
// their titles resolve asynchronously. We snapshot the source list and query together so a
// superseding keystroke replaces both atomically.
private IReadOnlyList<IListItem>? _globalFallbackSources;
private FuzzyQuery _globalFallbackQuery;
// Common fallbacks use query-independent scores, so freezing them is safe; only their live
// titles decide whether they render.
private IEnumerable<RoScored<IListItem>>? _fallbackItems;
private bool _includeApps;
private bool _filteredItemsIncludesApps;
// Last per-provider settings we reacted to, so a settings reload can tell whether any
// provider's search weight actually changed and only then re-rank the active query.
private ImmutableDictionary<string, ProviderSettings>? _lastProviderSettingsSnapshot;
private int AppResultLimit => AllAppsCommandProvider.TopLevelResultLimit;
// Longest query to filter fuzzy app matches on. This prevents weak app matches on short queries.
private const int ShortQueryAppFilterMaxLength = 2;
// Minimum tier an app must reach to appear for a short query.
private const RankTier ShortQueryAppFilterMinTier = RankTier.AcronymWordBoundary;
private InterlockedBoolean _fullRefreshRequested;
private InterlockedBoolean _refreshRunning;
private InterlockedBoolean _refreshRequested;
@@ -100,7 +123,7 @@ public sealed partial class MainListPage : DynamicListPage,
_appStateService = appStateService;
_tlcManager = topLevelCommandManager;
_fuzzyMatcherProvider = fuzzyMatcherProvider;
_scoringFunction = (in query, item) => ScoreTopLevelItem(in query, item, _appStateService.State.RecentCommands, _fuzzyMatcherProvider.Current);
_scoringFunction = (in query, item) => ScoreTopLevelItem(in query, item, _appStateService.State.RecentCommands, _fuzzyMatcherProvider.Current, ResolveProviderSearchWeight);
_fallbackScoringFunction = (in _, item) => ScoreFallbackItem(item, _settingsService.Settings.FallbackRanks);
_tlcManager.PropertyChanged += TlcManager_PropertyChanged;
@@ -259,22 +282,125 @@ public sealed partial class MainListPage : DynamicListPage,
private IListItem[] GetSearchViewItems()
{
var validScoredFallbacks = _scoredFallbackItems?
.Where(s => !string.IsNullOrWhiteSpace(s.Item.Title))
.ToList();
// Score global fallbacks against their current titles so a fallback whose title
// resolved after first paint gets the right score. Cheap: only a handful are configured.
var validScoredFallbacks = ScoreDeferredFallbacks(_globalFallbackSources, _globalFallbackQuery, _scoringFunction);
var validFallbacks = _fallbackItems?
.Where(s => !string.IsNullOrWhiteSpace(s.Item.Title))
.ToList();
return MainListPageResultFactory.Create(
// Remove fuzzy-only app matches for short queries so frecency-boosted weak matches don't
// appear while typing.
var filteredApps = FilterAppsForShortQueries(_filteredApps, _filteredAppsQueryLength);
var result = MainListPageResultFactory.Create(
_filteredItems,
validScoredFallbacks,
_filteredApps,
filteredApps,
validFallbacks,
_resultsSeparator,
_fallbacksSeparator,
AppResultLimit);
// Snapshot the rendered order plus every scored input and the query length together, so
// selection telemetry resolves an invoked item's rank, tier, and query length from this one
// generation off the hot path. These are plain reference assignments - no extra allocation.
_searchTelemetry.CaptureSearchView(
result,
_filteredItems,
_filteredApps,
validScoredFallbacks,
_fallbackItems,
SearchText?.Length ?? 0);
return result;
}
// Scores the current global-fallback snapshot against its query, dropping any whose title is
// still empty. Static so it can be unit tested with a fake slow source.
internal static List<RoScored<IListItem>>? ScoreDeferredFallbacks(
IReadOnlyList<IListItem>? sources,
in FuzzyQuery query,
ScoringFunction<IListItem> scoringFunction)
{
if (sources is null || sources.Count == 0)
{
return null;
}
var scored = InternalListHelpers.FilterListWithScores(sources, query, scoringFunction);
if (scored.Length == 0)
{
return null;
}
List<RoScored<IListItem>>? valid = null;
foreach (var s in scored)
{
if (string.IsNullOrWhiteSpace(s.Item.Title))
{
continue;
}
valid ??= new List<RoScored<IListItem>>(scored.Length);
valid.Add(s);
}
return valid;
}
// Returns a filtered view of the scored array without reordering it.
internal static IList<RoScored<IListItem>>? FilterAppsForShortQueries(
RoScored<IListItem>[]? scoredApps,
int queryLength)
{
if (scoredApps is null || scoredApps.Length == 0)
{
return scoredApps;
}
if (queryLength <= 0 || queryLength > ShortQueryAppFilterMaxLength)
{
return scoredApps;
}
var keep = GetHighConfidenceAppsCount(scoredApps, ShortQueryAppFilterMinTier);
return keep == scoredApps.Length
? scoredApps
: new ArraySegment<RoScored<IListItem>>(scoredApps, 0, keep);
}
// Qualifying apps form a contiguous prefix because the array is already sorted by score.
internal static int GetHighConfidenceAppsCount(IReadOnlyList<RoScored<IListItem>> scored, RankTier minTier)
{
var min = (int)minTier;
for (var i = 0; i < scored.Count; i++)
{
if ((int)MainListRanker.TierOf(scored[i].Score) < min)
{
return i;
}
}
return scored.Count;
}
// Applies the short-query filter and result limit to the telemetry count.
internal static int GetVisibleAppCount(RoScored<IListItem>[]? scoredApps, int queryLength, int appResultLimit)
{
if (scoredApps is null || scoredApps.Length == 0)
{
return 0;
}
var count = scoredApps.Length;
if (queryLength > 0 && queryLength <= ShortQueryAppFilterMaxLength)
{
count = GetHighConfidenceAppsCount(scoredApps, ShortQueryAppFilterMinTier);
}
return Math.Min(count, appResultLimit);
}
private IListItem[] GetDefaultViewItems()
@@ -361,8 +487,12 @@ public sealed partial class MainListPage : DynamicListPage,
{
_filteredItems = null;
_filteredApps = null;
_filteredAppsQueryLength = 0;
_fallbackItems = null;
_scoredFallbackItems = null;
_globalFallbackSources = null;
// Clear the paired query too, so both are reset together.
_globalFallbackQuery = default;
}
public override void UpdateSearchText(string oldSearch, string newSearch)
@@ -403,6 +533,10 @@ public sealed partial class MainListPage : DynamicListPage,
if (aliases.CheckAlias(newSearch))
{
// An alias query supersedes any normal query whose settled-search telemetry is
// still pending in the debounce; drop it so the superseded query never emits.
_searchTelemetry.CancelPendingResults();
if (_filteredItemsIncludesApps != _includeApps)
{
lock (_tlcManager.TopLevelCommands)
@@ -422,6 +556,17 @@ public sealed partial class MainListPage : DynamicListPage,
}
var commands = _tlcManager.TopLevelCommands;
// Inputs captured under the lock so the heavy scoring below can run off it. GetItems()
// takes the same lock, so it now only contends with the short snapshot and publish sections.
IReadOnlyList<IListItem> itemsSource;
IReadOnlyList<IListItem> appsSource;
IReadOnlyList<IListItem> fallbackSource;
IListItem[] globalFallbackSources;
bool includeAppsSnapshot;
bool tookFullCatalog = false;
// ===== SNAPSHOT PHASE (under lock) =====
lock (commands)
{
if (token.IsCancellationRequested)
@@ -463,58 +608,37 @@ public sealed partial class MainListPage : DynamicListPage,
{
_filteredItemsIncludesApps = _includeApps;
ClearResults();
// Drop any pending settled-search telemetry so a cleared query never emits.
_searchTelemetry.ClearSearchView();
var wasAlreadyEmpty = string.IsNullOrWhiteSpace(oldSearch);
RequestRefresh(fullRefresh: true, interval: wasAlreadyEmpty ? null : TimeSpan.Zero);
return;
}
// If the new string doesn't start with the old string, then we can't
// re-use previous results. Reset _filteredItems, and keep er moving.
if (!newSearch.StartsWith(oldSearch, StringComparison.CurrentCultureIgnoreCase))
{
ClearResults();
}
includeAppsSnapshot = _includeApps;
// If the internal state has changed, reset _filteredItems to reset the list.
if (_filteredItemsIncludesApps != _includeApps)
{
ClearResults();
}
// A query that doesn't extend the old one, or a change in app inclusion, means we
// can't re-use the previous results and have to rebuild from the full catalog. On an
// extend we re-score only the previously matched subset.
var reset = !newSearch.StartsWith(oldSearch, StringComparison.CurrentCultureIgnoreCase)
|| _filteredItemsIncludesApps != includeAppsSnapshot;
if (token.IsCancellationRequested)
{
return;
}
var prevFilteredItems = reset ? null : _filteredItems;
var prevApps = reset ? null : _filteredApps;
var prevFallbacks = reset ? null : _fallbackItems;
var newFilteredItems = Enumerable.Empty<IListItem>();
var newFallbacks = Enumerable.Empty<IListItem>();
var newApps = Enumerable.Empty<IListItem>();
if (_filteredItems is not null)
{
newFilteredItems = _filteredItems.Select(s => s.Item);
}
if (token.IsCancellationRequested)
{
return;
}
if (_filteredApps is not null)
{
newApps = _filteredApps.Select(s => s.Item);
}
if (token.IsCancellationRequested)
{
return;
}
if (_fallbackItems is not null)
{
newFallbacks = _fallbackItems.Select(s => s.Item);
}
IEnumerable<IListItem> newFilteredItems = prevFilteredItems is not null
? prevFilteredItems.Select(s => s.Item)
: Enumerable.Empty<IListItem>();
IEnumerable<IListItem> newApps = prevApps is not null
? prevApps.Select(s => s.Item)
: Enumerable.Empty<IListItem>();
IEnumerable<IListItem> newFallbacks = prevFallbacks is not null
? prevFallbacks.Select(s => s.Item)
: Enumerable.Empty<IListItem>();
if (token.IsCancellationRequested)
{
@@ -525,6 +649,7 @@ public sealed partial class MainListPage : DynamicListPage,
// with a list of all our commands & apps.
if (!newFilteredItems.Any() && !newApps.Any())
{
tookFullCatalog = true;
newFilteredItems = commands.Where(s => !s.IsFallback);
// Fallbacks are always included in the list, even if they
@@ -537,9 +662,7 @@ public sealed partial class MainListPage : DynamicListPage,
return;
}
_filteredItemsIncludesApps = _includeApps;
if (_includeApps)
if (includeAppsSnapshot)
{
var allNewApps = AllAppsCommandProvider.Page.GetItems().Cast<AppListItem>().ToList();
@@ -562,71 +685,151 @@ public sealed partial class MainListPage : DynamicListPage,
}
}
var searchQuery = _fuzzyMatcherProvider.Current.PrecomputeQuery(SearchText);
// Materialize every source while still under the lock, so the scoring passes never
// touch the live TopLevelCommands collection or the app provider.
itemsSource = MaterializeSource(newFilteredItems);
appsSource = MaterializeSource(newApps);
fallbackSource = MaterializeSource(newFallbacks);
globalFallbackSources = [.. specialFallbacks];
}
// Produce a list of everything that matches the current filter.
_filteredItems = InternalListHelpers.FilterListWithScores(newFilteredItems, searchQuery, _scoringFunction);
if (token.IsCancellationRequested)
{
return;
}
// ===== SCORING PHASE (off the lock) =====
// The dominant apps pass is parallelized, commands and fallbacks stay serial, and none of
// it holds the TopLevelCommands lock any more, so it no longer blocks GetItems()/render.
//
// Snapshot every scoring input once, up front: the live fields can be swapped mid-pass when
// a selection calls WithHistoryItem on another thread, which would mix two frecency
// snapshots into one pass or race a parallel thread against an unwarmed history index.
var recent = _appStateService.State.RecentCommands;
recent.PrewarmIndex();
var matcher = _fuzzyMatcherProvider.Current;
var settings = _settingsService.Settings;
var scoringNow = DateTimeOffset.UtcNow;
// Precompute from the snapshotted newSearch, not the live SearchText, which a newer
// keystroke may already have advanced past.
var searchQuery = matcher.PrecomputeQuery(newSearch);
// Every installed app belongs to the well-known AllApps provider, so its weight is constant
// for the whole pass and we resolve it once instead of once per app.
var appsProviderWeight = ResolveProviderSearchWeight(settings, AllAppsCommandProvider.WellKnownId);
Func<IListItem, ProviderSearchWeight> commandsProviderLookup = item => ResolveProviderSearchWeight(settings, item);
Func<IListItem, ProviderSearchWeight> appsProviderLookup = _ => appsProviderWeight;
ScoringFunction<IListItem> commandsScorer = (in FuzzyQuery q, IListItem item) =>
ScoreTopLevelItem(in q, item, recent, matcher, commandsProviderLookup, scoringNow);
ScoringFunction<IListItem> appsScorer = (in FuzzyQuery q, IListItem item) =>
ScoreTopLevelItem(in q, item, recent, matcher, appsProviderLookup, scoringNow);
var scoredFilteredItems = InternalListHelpers.FilterListWithScores(itemsSource, searchQuery, commandsScorer);
if (token.IsCancellationRequested)
{
return;
}
var scoredFallbackItems = InternalListHelpers.FilterListWithScores(fallbackSource, searchQuery, _fallbackScoringFunction);
if (token.IsCancellationRequested)
{
return;
}
RoScored<IListItem>[]? scoredApps = null;
if (appsSource.Count > 0)
{
scoredApps = InternalListHelpers.FilterListWithScoresParallel(appsSource, searchQuery, appsScorer);
if (token.IsCancellationRequested)
{
return;
}
IEnumerable<IListItem> newFallbacksForScoring = commands.Where(s => s.IsFallback && configuredGlobalFallbackIds.Contains(s.Id));
_scoredFallbackItems = InternalListHelpers.FilterListWithScores(newFallbacksForScoring, searchQuery, _scoringFunction);
if (token.IsCancellationRequested)
{
return;
}
_fallbackItems = InternalListHelpers.FilterListWithScores<IListItem>(newFallbacks ?? [], searchQuery, _fallbackScoringFunction);
if (token.IsCancellationRequested)
{
return;
}
// Produce a list of filtered apps with the appropriate limit
if (newApps.Any())
{
_filteredApps = InternalListHelpers.FilterListWithScores(newApps, searchQuery, _scoringFunction);
if (token.IsCancellationRequested)
{
return;
}
}
}
#if CMDPAL_FF_MAINPAGE_TIME_RAISE_ITEMS
var filterDoneTimestamp = stopwatch.ElapsedMilliseconds;
var filterDoneTimestamp = stopwatch.ElapsedMilliseconds;
#endif
// ===== PUBLISH PHASE (under lock) =====
// The critical section is the field swaps only. Telemetry debounce and refresh throttling
// happen after the lock so _searchTelemetryLock never nests under the commands lock.
var deterministicResultCount = 0;
lock (commands)
{
// A newer keystroke cancels this token before doing its own work, so a stale snapshot
// can never overwrite a newer query's results.
if (token.IsCancellationRequested)
{
return;
}
if (tookFullCatalog)
{
_filteredItemsIncludesApps = includeAppsSnapshot;
}
_filteredItems = scoredFilteredItems;
_fallbackItems = scoredFallbackItems;
// Snapshot the global fallbacks and query, but score them later on the render path,
// since their titles are still resolving asynchronously (BeginUpdate, above).
_globalFallbackSources = globalFallbackSources;
_globalFallbackQuery = searchQuery;
// With no apps source, publish null so a rebuild clears any stale set, matching the old
// ClearResults behavior.
_filteredApps = appsSource.Count > 0 ? scoredApps : null;
// Publish the length with the array so filtering and telemetry use the same query.
_filteredAppsQueryLength = _filteredApps is null ? 0 : newSearch.Length;
if (isUserInput)
{
// Make sure that the throttle delay is consistent from the user's perspective, even if filtering
// takes a long time. If we always use the full throttle duration, then a slow filter could make the UI feel sluggish.
var adjustedInterval = RaiseItemsChangedThrottleForUserInput - stopwatch.Elapsed;
if (adjustedInterval < TimeSpan.Zero)
{
adjustedInterval = TimeSpan.Zero;
}
RequestRefresh(fullRefresh: true, adjustedInterval);
}
else
{
RequestRefresh(fullRefresh: true);
deterministicResultCount = (_filteredItems?.Length ?? 0)
+ GetVisibleAppCount(_filteredApps, _filteredAppsQueryLength, AppResultLimit);
}
#if CMDPAL_FF_MAINPAGE_TIME_RAISE_ITEMS
var listPageUpdatedTimestamp = stopwatch.ElapsedMilliseconds;
Logger.LogDebug($"Render items with '{newSearch}' in {listPageUpdatedTimestamp}ms /d {listPageUpdatedTimestamp - filterDoneTimestamp}ms");
#endif
}
stopwatch.Stop();
// Getting here means the swap happened, since the superseded path returns inside the lock.
stopwatch.Stop();
if (isUserInput)
{
// Queue a settled-search telemetry event. It's debounced so it only fires once the
// query settles, and it carries the query LENGTH only, never the text.
_searchTelemetry.QueueSearchResults(newSearch.Length, deterministicResultCount, stopwatch.ElapsedMilliseconds);
// Make sure that the throttle delay is consistent from the user's perspective, even if filtering
// takes a long time. If we always use the full throttle duration, then a slow filter could make the UI feel sluggish.
var adjustedInterval = RaiseItemsChangedThrottleForUserInput - stopwatch.Elapsed;
if (adjustedInterval < TimeSpan.Zero)
{
adjustedInterval = TimeSpan.Zero;
}
RequestRefresh(fullRefresh: true, adjustedInterval);
}
else
{
RequestRefresh(fullRefresh: true);
}
}
// Materializes a source into a stable, indexable snapshot so scoring can run off the lock.
// Anything already an IReadOnlyList passes through; lazy LINQ over live data gets copied.
private static IReadOnlyList<IListItem> MaterializeSource(IEnumerable<IListItem> items)
=> items as IReadOnlyList<IListItem> ?? items.ToArray();
private bool ActuallyLoading()
{
var allApps = AllAppsCommandProvider.Page;
@@ -640,7 +843,9 @@ public sealed partial class MainListPage : DynamicListPage,
in FuzzyQuery query,
IListItem topLevelOrAppItem,
IRecentCommandsManager history,
IPrecomputedFuzzyMatcher precomputedFuzzyMatcher)
IPrecomputedFuzzyMatcher precomputedFuzzyMatcher,
Func<IListItem, ProviderSearchWeight>? providerWeightLookup = null,
DateTimeOffset? now = null)
{
var title = topLevelOrAppItem.Title;
if (string.IsNullOrWhiteSpace(title))
@@ -705,14 +910,19 @@ public sealed partial class MainListPage : DynamicListPage,
return 0;
}
var frecencyWeight = history.GetCommandHistoryWeight(id);
var frecencyWeight = history.GetCommandHistoryWeight(id, now ?? DateTimeOffset.UtcNow);
var aliasSubstringBonus = isAliasSubstringMatch && !isAliasMatch ? MainListRanker.AliasSubstringBonus : 0.0;
// Per-provider weight is a within-tier nudge only. Resolving it here (rather than in
// the tier classifier) guarantees it can never promote an item across a tier boundary.
var providerWeight = providerWeightLookup?.Invoke(topLevelOrAppItem) ?? ProviderSearchWeight.Normal;
var providerBonus = MainListRanker.ProviderBonus(providerWeight);
var withinTier = MainListRanker.WithinTierScore(
lexicalQuality,
frecencyWeight,
aliasSubstringBonus,
providerBonus: 0.0);
providerBonus: providerBonus);
return MainListRanker.Pack(tier, withinTier);
}
@@ -752,6 +962,8 @@ public sealed partial class MainListPage : DynamicListPage,
{
RecentCommands = state.RecentCommands.WithHistoryItem(id),
});
_searchTelemetry.ReportSelection(topLevelOrAppItem, _resultsSeparator, _fallbacksSeparator);
}
private static string IdForTopLevelOrAppItem(IListItem topLevelOrAppItem)
@@ -767,6 +979,34 @@ public sealed partial class MainListPage : DynamicListPage,
}
}
// Resolves the user-configured per-provider search weight for an item. Top-level commands
// carry their own provider id; installed apps all belong to the well-known "AllApps"
// provider, so app items are weighted by that provider's setting. The static overloads take a
// settings snapshot so the hot path resolves against one captured SettingsModel.
private ProviderSearchWeight ResolveProviderSearchWeight(IListItem topLevelOrAppItem)
=> ResolveProviderSearchWeight(_settingsService.Settings, topLevelOrAppItem);
private static ProviderSearchWeight ResolveProviderSearchWeight(SettingsModel settings, IListItem topLevelOrAppItem)
{
var providerId = topLevelOrAppItem is TopLevelViewModel topLevel
? topLevel.CommandProviderId
: AllAppsCommandProvider.WellKnownId;
return ResolveProviderSearchWeight(settings, providerId);
}
private static ProviderSearchWeight ResolveProviderSearchWeight(SettingsModel settings, string providerId)
{
if (string.IsNullOrEmpty(providerId))
{
return ProviderSearchWeight.Normal;
}
return settings.ProviderSettings.TryGetValue(providerId, out var providerSettings)
? providerSettings.SearchWeight
: ProviderSearchWeight.Normal;
}
public void Receive(ClearSearchMessage message) => SearchText = string.Empty;
public void Receive(UpdateFallbackItemsMessage message)
@@ -778,13 +1018,72 @@ public sealed partial class MainListPage : DynamicListPage,
private void SettingsChangedHandler(ISettingsService sender, SettingsModel args) => HotReloadSettings(args);
private void HotReloadSettings(SettingsModel settings) => ShowDetails = settings.ShowAppDetails;
private void HotReloadSettings(SettingsModel settings)
{
ShowDetails = settings.ShowAppDetails;
// A per-provider search-weight change has to reorder the query that is already on screen.
// Scoring reads the weight live, but scored results are cached, so without an explicit
// re-score the active query keeps its old order until the next keystroke. Detect a weight
// change and re-rank the current search in place.
var providerSettings = settings.ProviderSettings;
var weightsChanged = ProviderWeightsChanged(_lastProviderSettingsSnapshot, providerSettings);
_lastProviderSettingsSnapshot = providerSettings;
if (weightsChanged && !string.IsNullOrEmpty(SearchText))
{
RerankActiveSearch();
}
}
// Re-scores the current query off the UI thread so a settings change (e.g. a per-provider
// search-weight change) reorders the results already shown. This reuses the same non-reset
// re-score path as an app-inclusion refresh: the retained matches are re-scored with the new
// weights, which is sufficient because provider weight only nudges order within a tier and
// never changes which items match.
private void RerankActiveSearch()
{
var current = SearchText;
if (!string.IsNullOrEmpty(current))
{
_ = Task.Run(() => UpdateSearchTextCore(current, current, isUserInput: false));
}
}
// True when the effective per-provider search weight differs between two snapshots. A provider
// absent from a snapshot is treated as Normal, so adding or removing an entry whose weight is
// Normal does not count as a change.
private static bool ProviderWeightsChanged(
ImmutableDictionary<string, ProviderSettings>? previous,
ImmutableDictionary<string, ProviderSettings> current)
{
previous ??= ImmutableDictionary<string, ProviderSettings>.Empty;
if (ReferenceEquals(previous, current))
{
return false;
}
var keys = new HashSet<string>(previous.Keys, StringComparer.Ordinal);
keys.UnionWith(current.Keys);
foreach (var key in keys)
{
var previousWeight = previous.TryGetValue(key, out var p) ? p.SearchWeight : ProviderSearchWeight.Normal;
var currentWeight = current.TryGetValue(key, out var c) ? c.SearchWeight : ProviderSearchWeight.Normal;
if (previousWeight != currentWeight)
{
return true;
}
}
return false;
}
public void Dispose()
{
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_fallbackUpdateManager.Dispose();
_searchTelemetry.Dispose();
_tlcManager.PropertyChanged -= TlcManager_PropertyChanged;
_tlcManager.TopLevelCommands.CollectionChanged -= Commands_CollectionChanged;

View File

@@ -0,0 +1,248 @@
// 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;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.Common.Helpers;
using Microsoft.CmdPal.Core.Common.Helpers;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels.MainPage;
/// <summary>
/// Owns all of the main-page search telemetry state and emission, so <see cref="MainListPage"/>
/// stays responsible for producing results and this type is the single, clearly-identifiable owner
/// of the (privacy-safe) telemetry. Everything here is opt-in and non-identifying: search events
/// carry only query LENGTH, result count and latency; selection events carry only query LENGTH, the
/// invoked item's visible rank and its ranker tier - never the raw query text or item content.
///
/// Search events are emitted only when a query settles (trailing-edge debounce) so we never send an
/// event on every keystroke; selection events are emitted only when the user invokes a result. All
/// emission is measured at boundaries, never inside the per-item scoring loop.
/// </summary>
internal sealed partial class MainListPageSearchTelemetry : IDisposable
{
private static readonly TimeSpan SettleDelay = TimeSpan.FromMilliseconds(600);
private readonly ThrottledDebouncedAction _resultsDebounce;
private readonly Lock _pendingLock = new();
private (int QueryLength, int ResultCount, long LatencyMs) _pendingResults;
// Snapshots of the most recent rendered search results, read off the hot path (only when the
// user invokes a result) to resolve the invoked item's visible rank and ranker tier. The scored
// inputs and query length are captured together with the rendered items at render time, so
// selection telemetry resolves rank, tier and query length from one coherent generation even
// after a newer query has already published fresh scored fields.
private IReadOnlyList<IListItem>? _lastViewItems;
private IReadOnlyList<RoScored<IListItem>>? _lastScoredGlobalFallbacks;
private RoScored<IListItem>[]? _lastViewFilteredItems;
private RoScored<IListItem>[]? _lastViewFilteredApps;
private IEnumerable<RoScored<IListItem>>? _lastViewFallbackItems;
private int _lastViewQueryLength;
public MainListPageSearchTelemetry()
{
_resultsDebounce = new ThrottledDebouncedAction(EmitPendingResults, SettleDelay);
}
// Stores the latest settled-search metrics and (re)arms the debounce. Only the query LENGTH is
// retained - the query text is never stored for telemetry.
public void QueueSearchResults(int queryLength, int resultCount, long latencyMs)
{
lock (_pendingLock)
{
_pendingResults = (queryLength, resultCount, latencyMs);
}
_resultsDebounce.Invoke();
}
// Snapshots the rendered order plus every scored input and the query length together, so a later
// selection resolves an invoked item's rank, tier and query length from this one generation off
// the hot path. These are plain reference assignments - no extra allocation.
public void CaptureSearchView(
IReadOnlyList<IListItem> renderedItems,
RoScored<IListItem>[]? filteredItems,
RoScored<IListItem>[]? filteredApps,
IReadOnlyList<RoScored<IListItem>>? scoredGlobalFallbacks,
IEnumerable<RoScored<IListItem>>? fallbackItems,
int queryLength)
{
_lastViewItems = renderedItems;
_lastScoredGlobalFallbacks = scoredGlobalFallbacks;
_lastViewFilteredItems = filteredItems;
_lastViewFilteredApps = filteredApps;
_lastViewFallbackItems = fallbackItems;
_lastViewQueryLength = queryLength;
}
// Drops any pending settled-search event without emitting it. Used when an alias query supersedes
// a normal query whose telemetry is still pending in the debounce.
public void CancelPendingResults() => _resultsDebounce.Cancel();
// Drops any pending settled-search event and forgets the last rendered search view, so a cleared
// query never emits and a subsequent selection resolves to nothing.
public void ClearSearchView()
{
_resultsDebounce.Cancel();
_lastViewItems = null;
_lastScoredGlobalFallbacks = null;
_lastViewFilteredItems = null;
_lastViewFilteredApps = null;
_lastViewFallbackItems = null;
_lastViewQueryLength = 0;
}
// Emits selection telemetry when the user invokes a result during an active search. Runs only on
// invoke (a deliberate, infrequent user action - never on the typing/scoring path) and captures
// only non-identifying aggregates: the query LENGTH, the invoked item's visible rank, and its
// ranker tier. Nothing is emitted for the default (no-search) view, or when the invoked item is
// not among the last rendered search results.
public void ReportSelection(IListItem invoked, Separator resultsSeparator, Separator fallbacksSeparator)
{
// Resolve everything from the last rendered search-view snapshot so the invoked item's rank,
// tier, and the reported query length all come from one generation. If the last render was
// the default (no-search) view, _lastViewItems is null and nothing is emitted.
var lastView = _lastViewItems;
if (lastView is null || _lastViewQueryLength <= 0)
{
return;
}
var index = ResolveVisibleIndex(lastView, invoked, resultsSeparator, fallbacksSeparator);
if (index < 0)
{
return;
}
var packed = (_lastViewFilteredItems ?? Enumerable.Empty<RoScored<IListItem>>())
.Concat(_lastViewFilteredApps ?? Enumerable.Empty<RoScored<IListItem>>())
.Concat(_lastScoredGlobalFallbacks ?? Enumerable.Empty<RoScored<IListItem>>());
var tier = ResolveSelectedTier(invoked, packed, _lastViewFallbackItems);
if (tier == RankTier.None)
{
return;
}
WeakReferenceMessenger.Default.Send(BuildSearchSelectedMessage(_lastViewQueryLength, index, tier));
}
private void EmitPendingResults()
{
(int QueryLength, int ResultCount, long LatencyMs) snapshot;
lock (_pendingLock)
{
snapshot = _pendingResults;
}
if (snapshot.QueryLength <= 0)
{
return;
}
WeakReferenceMessenger.Default.Send(
BuildSearchResultsMessage(snapshot.QueryLength, snapshot.ResultCount, snapshot.LatencyMs));
}
// Builds the settled-search telemetry payload from a query string, capturing only its LENGTH.
// Exposed for tests to prove the raw query text is never carried.
internal static TelemetrySearchResultsMessage BuildSearchResultsMessage(string query, int resultCount, long latencyMs)
=> BuildSearchResultsMessage(query?.Length ?? 0, resultCount, latencyMs);
internal static TelemetrySearchResultsMessage BuildSearchResultsMessage(int queryLength, int resultCount, long latencyMs)
{
var length = Math.Max(queryLength, 0);
var count = Math.Max(resultCount, 0);
var latency = latencyMs < 0 ? 0UL : (ulong)latencyMs;
return new TelemetrySearchResultsMessage(length, count, count == 0, latency);
}
// Builds the selection telemetry payload, capturing only the query LENGTH, the selected rank,
// and the ranker tier. Exposed for tests to prove the raw query text is never carried.
internal static TelemetrySearchResultSelectedMessage BuildSearchSelectedMessage(string query, int selectedIndex, RankTier selectedTier)
=> BuildSearchSelectedMessage(query?.Length ?? 0, selectedIndex, selectedTier);
internal static TelemetrySearchResultSelectedMessage BuildSearchSelectedMessage(int queryLength, int selectedIndex, RankTier selectedTier)
=> new(Math.Max(queryLength, 0), selectedIndex, selectedTier);
// Zero-based visible rank of an invoked item within the rendered results, skipping the section
// separators. Returns -1 when the item is not present (e.g. it was invoked from a different view).
internal static int ResolveVisibleIndex(IReadOnlyList<IListItem>? renderedResults, IListItem invoked, params IListItem[] separators)
{
if (renderedResults is null)
{
return -1;
}
var visible = 0;
foreach (var item in renderedResults)
{
var isSeparator = false;
foreach (var separator in separators)
{
if (ReferenceEquals(item, separator))
{
isSeparator = true;
break;
}
}
if (isSeparator)
{
continue;
}
if (ReferenceEquals(item, invoked))
{
return visible;
}
visible++;
}
return -1;
}
// Resolves the ranker tier of an invoked item. Packed sources (commands, apps, global
// fallbacks) decode their tier via MainListRanker.TierOf; common fallbacks carry rank-based
// (non-packed) scores, so they are reported at the fallback floor. Returns None when the item
// is not found in any source.
internal static RankTier ResolveSelectedTier(
IListItem invoked,
IEnumerable<RoScored<IListItem>>? packedResults,
IEnumerable<RoScored<IListItem>>? fallbackResults)
{
if (packedResults is not null)
{
foreach (var scored in packedResults)
{
if (ReferenceEquals(scored.Item, invoked))
{
return MainListRanker.TierOf(scored.Score);
}
}
}
if (fallbackResults is not null)
{
foreach (var scored in fallbackResults)
{
if (ReferenceEquals(scored.Item, invoked))
{
return RankTier.FallbackFloor;
}
}
}
return RankTier.None;
}
public void Dispose() => _resultsDebounce.Dispose();
}

View File

@@ -31,6 +31,23 @@ internal static class MainListRanker
// exact alias, which gets its own top tier). Mirrors the previous +1-before-x10 boost.
internal const double AliasSubstringBonus = 10.0;
// Magnitude of the per-provider within-tier nudge. Deliberately small - half a point of
// lexical quality (LexicalScale = 10) - so a Higher/Lower provider only breaks near-ties
// and reorders items that already share a tier. It can NEVER move an item across a tier
// boundary because the packed within-tier score is clamped to a single tier's band.
internal const double ProviderWeightBonus = 5.0;
/// <summary>
/// Maps a per-provider <see cref="ProviderSearchWeight"/> to an additive within-tier
/// bonus. Lower subtracts, Normal is neutral, Higher adds. The enum's underlying value is
/// the sign of the nudge, so the result is simply the weight times
/// <see cref="ProviderWeightBonus"/>. Note the nudge is slightly asymmetric at the tier
/// floor: <see cref="Pack"/> clamps the within-tier score to a non-negative band, so a
/// Lower nudge on an item already scoring near 0 (weak match, no history) can clamp to 0
/// and read the same as Normal, whereas Higher always applies.
/// </summary>
public static double ProviderBonus(ProviderSearchWeight weight) => (int)weight * ProviderWeightBonus;
/// <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

View File

@@ -2,7 +2,7 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using CommunityToolkit.Mvvm.ComponentModel;
using System;
namespace Microsoft.CmdPal.UI.ViewModels;
@@ -11,4 +11,13 @@ public record HistoryItem
public required string CommandId { get; init; }
public required int Uses { get; init; }
/// <summary>
/// Gets the moment this command was last invoked. Persisted so ranking can apply real
/// time-decay instead of the previous list-position heuristic. History written before
/// this field existed deserializes to <c>default(DateTimeOffset)</c>;
/// <see cref="RecentCommandsManager"/> treats that sentinel as a mild backdate so
/// day-one ordering degrades gracefully to Uses-ordering rather than going all-equal.
/// </summary>
public DateTimeOffset LastUsed { get; init; }
}

View File

@@ -0,0 +1,15 @@
// 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 Microsoft.CmdPal.UI.ViewModels.MainPage;
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
/// <summary>
/// Telemetry message sent when the user selects (invokes) a main-page search result.
/// Carries only non-identifying aggregates - never the selected item's title/id or the raw
/// query text. <see cref="QueryLength"/> is the query's character count, <see cref="SelectedIndex"/>
/// is the zero-based rank of the selected result, and <see cref="SelectedTier"/> is its ranker tier.
/// </summary>
public record TelemetrySearchResultSelectedMessage(int QueryLength, int SelectedIndex, RankTier SelectedTier);

View File

@@ -0,0 +1,12 @@
// 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.
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
/// <summary>
/// Telemetry message sent when a main-page search query settles.
/// Carries only non-identifying aggregates - never the raw query text. <see cref="QueryLength"/>
/// is the query's character count.
/// </summary>
public record TelemetrySearchResultsMessage(int QueryLength, int ResultCount, bool NoResults, ulong LatencyMs);

View File

@@ -7,6 +7,25 @@ using System.Text.Json.Serialization;
namespace Microsoft.CmdPal.UI.ViewModels;
/// <summary>
/// Per-provider relevance nudge applied to main/root page search results. The value is a
/// within-tier bonus only: it can reorder items that already share a relevance tier but can
/// never move an item across a tier boundary. The underlying integer values are the sign of
/// the bonus (Lower subtracts, Higher adds), and <see cref="Normal"/> is 0 so a missing or
/// legacy setting deserializes to the neutral default.
/// </summary>
public enum ProviderSearchWeight
{
/// <summary>De-prioritize this provider's results within their tier.</summary>
Lower = -1,
/// <summary>Default. No provider nudge.</summary>
Normal = 0,
/// <summary>Prioritize this provider's results within their tier.</summary>
Higher = 1,
}
public record ProviderSettings
{
// List of built-in fallbacks that should not have global results enabled by default
@@ -18,6 +37,12 @@ public record ProviderSettings
public bool IsEnabled { get; init; } = true;
/// <summary>
/// Per-provider within-tier ranking nudge for main-page search. Defaults to
/// <see cref="ProviderSearchWeight.Normal"/>; missing/legacy values deserialize to Normal.
/// </summary>
public ProviderSearchWeight SearchWeight { get; init; } = ProviderSearchWeight.Normal;
private ImmutableDictionary<string, FallbackSettings>? _fallbackCommands
= ImmutableDictionary<string, FallbackSettings>.Empty;

View File

@@ -134,6 +134,41 @@ public partial class ProviderSettingsViewModel : ObservableObject
}
}
/// <summary>
/// Per-provider search weight surfaced as a 0/1/2 ComboBox index (Lower / Normal /
/// Higher) so it can bind to <c>ComboBox.SelectedIndex</c> in the same style as the
/// other per-provider options. Persists to <see cref="ProviderSettings.SearchWeight"/>.
/// </summary>
public int SearchWeightIndex
{
get => _providerSettings.SearchWeight switch
{
ProviderSearchWeight.Lower => 0,
ProviderSearchWeight.Higher => 2,
_ => 1,
};
set
{
var newWeight = value switch
{
0 => ProviderSearchWeight.Lower,
2 => ProviderSearchWeight.Higher,
_ => ProviderSearchWeight.Normal,
};
if (newWeight != _providerSettings.SearchWeight)
{
var newSettings = _providerSettings with { SearchWeight = newWeight };
_settingsService.UpdateSettings(s => s with
{
ProviderSettings = s.ProviderSettings.SetItem(_provider.ProviderId, newSettings),
});
_providerSettings = newSettings;
OnPropertyChanged(nameof(SearchWeightIndex));
}
}
}
/// <summary>
/// Gets a value indicating whether returns true if we have a settings page
/// that's initialized, or we are still working on initializing that

View File

@@ -3,61 +3,156 @@
// See the LICENSE file in the project root for more information.
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace Microsoft.CmdPal.UI.ViewModels;
public record RecentCommandsManager : IRecentCommandsManager
{
// Recency half-life: a command's recency contribution halves every this many days. Three
// days balances an interactive launcher's usage rhythm - commands touched in the last few
// days still feel "recent", while month-old one-offs decay toward zero and stop crowding
// out newer habits.
internal const double HalfLifeDays = 3.0;
// Baseline points for a just-used command (recency == 1) before the frequency term. Kept
// near the previous top recency bucket so the frecency signal keeps roughly the same
// influence in the ranker's within-tier math (MainListRanker.FrecencyScale == 1.0).
internal const double BaseWeight = 10.0;
// Points added per unit of log2(uses + 1), scaled by recency. log() keeps heavy usage
// helpful without letting it dominate recency (uses 1 -> +10, 7 -> +30, 31 -> +50).
internal const double FrequencyWeight = 10.0;
// Upper bound on the returned weight. Holds the signal in the ~0..70 range the previous
// implementation produced, so tier ordering is unaffected (the tier always dominates and
// frecency only reorders within a tier).
internal const double MaxWeight = 70.0;
// Retain a few hundred commands. Large enough to remember habitual commands across weeks
// of use, small enough that the persisted JSON stays tiny (tens of KB). The old cap of 50
// evicted still-useful history within a single busy session on active machines.
internal const int MaxHistoryEntries = 500;
// Defensive fallback for any history item that carries a default (missing) LastUsed - for
// example a value constructed without a timestamp, or state written by a build that predates
// the LastUsed field. Such an item is treated as used one day ago: recent enough that it
// still ranks, mild enough that a single fresh use outranks it, and uniform so a group of
// them falls back to Uses (frequency) ordering instead of collapsing to all-equal or zero.
// In practice this rarely fires: earlier builds never actually persisted history (the
// internal History property was dropped by the serializer, see [JsonInclude] below), so
// upgrading users start from an empty store rather than one full of timestamp-less items.
internal static readonly TimeSpan LegacyBackdate = TimeSpan.FromDays(1);
private ImmutableList<HistoryItem>? _history = ImmutableList<HistoryItem>.Empty;
// Cached commandId -> entry lookup over History, rebuilt lazily whenever History is
// (re)assigned and never persisted. ScoreTopLevelItem calls GetCommandHistoryWeight for
// every candidate item on every keystroke, and History can hold up to MaxHistoryEntries
// (500) entries, so a plain linear scan would be O(items x history) per keystroke. The
// dictionary keeps each lookup O(1) so the hot path stays cheap as the store grows.
[JsonIgnore]
private Dictionary<string, HistoryItem>? _index;
// Persisted so recent-command frecency (including the LastUsed timestamps) survives a
// restart. [JsonInclude] is required because the property is internal; without it the
// source-generated serializer emits an empty object and history is silently dropped.
// Old persisted state (an empty object) still deserializes fine - History stays empty.
[JsonInclude]
internal ImmutableList<HistoryItem> History
{
get => _history ?? ImmutableList<HistoryItem>.Empty;
init => _history = value;
init
{
_history = value;
// Invalidate the cached lookup so it is rebuilt from the new list on next use.
// A record 'with' copy carries over the old field, so this reset is what keeps
// the index from going stale after History changes.
_index = null;
}
}
private Dictionary<string, HistoryItem> Index
{
get
{
if (_index is null)
{
// Ordinal to match the string '==' comparison the previous linear scan used.
var map = new Dictionary<string, HistoryItem>(StringComparer.Ordinal);
foreach (var item in History)
{
// History is most-recent-first and command ids are unique in it, but keep
// the first (most recent) occurrence if a duplicate ever slips in so the
// lookup matches the old FirstOrDefault behavior.
map.TryAdd(item.CommandId, item);
}
_index = map;
}
return _index;
}
}
public RecentCommandsManager()
{
}
/// <summary>
/// Builds the lazy command-id lookup now, on the calling thread. The build isn't thread-safe,
/// so call this once before scoring items in parallel; the reads afterward are safe.
/// </summary>
public void PrewarmIndex() => _ = Index;
public int GetCommandHistoryWeight(string commandId)
=> GetCommandHistoryWeight(commandId, DateTimeOffset.UtcNow);
/// <summary>
/// Computes the time-decayed frecency weight for a command relative to <paramref name="now"/>.
/// Recency uses an exponential half-life decay and frequency uses log(uses); the two are
/// combined so recency leads while frequency amplifies. The parameterless overload uses the
/// current time; this one lets a batch pin a single snapshot.
/// </summary>
public int GetCommandHistoryWeight(string commandId, DateTimeOffset now)
{
var entry = History
.Index()
.Where(item => item.Item.CommandId == commandId)
.FirstOrDefault();
// These numbers are vaguely scaled so that "VS" will make "Visual Studio" the
// match after one use.
// Usually it has a weight of 84, compared to 109 for the VS cmd prompt
if (entry.Item is not null)
if (!Index.TryGetValue(commandId, out var entry))
{
var index = entry.Index;
// First, add some weight based on how early in the list this appears
var bucket = index switch
{
_ when index <= 2 => 35,
_ when index <= 10 => 25,
_ when index <= 15 => 15,
_ when index <= 35 => 10,
_ => 5,
};
// Then, add weight for how often this is used, but cap the weight from usage.
var uses = Math.Min(entry.Item.Uses * 5, 35);
return bucket + uses;
return 0;
}
return 0;
// Migrate items with a default (missing) timestamp to a mild backdate so they degrade
// to Uses-ordering instead of appearing brand-new or invisible. See LegacyBackdate.
var lastUsed = entry.LastUsed == default ? now - LegacyBackdate : entry.LastUsed;
// Clamp age at zero so a slightly-future timestamp (e.g. clock skew) can't amplify.
var ageDays = Math.Max(0.0, (now - lastUsed).TotalDays);
// Exponential time decay: recency in (0, 1], halving every HalfLifeDays.
var recency = Math.Pow(2.0, -ageDays / HalfLifeDays);
// Frequency via log2 so heavy usage helps but can't outrun recency.
var frequency = Math.Log(entry.Uses + 1, 2);
var weight = recency * (BaseWeight + (FrequencyWeight * frequency));
return (int)Math.Round(Math.Clamp(weight, 0.0, MaxWeight));
}
/// <summary>
/// Returns a new RecentCommandsManager with the given command added/promoted in history.
/// Pure function does not mutate this instance.
/// Pure function - does not mutate this instance.
/// </summary>
public RecentCommandsManager WithHistoryItem(string commandId)
=> WithHistoryItem(commandId, DateTimeOffset.UtcNow);
/// <summary>
/// Records a use of <paramref name="commandId"/> at the explicit time <paramref name="now"/>.
/// The public overload uses the current time; this overload lets tests inject
/// strictly-increasing timestamps so time-decay behavior is deterministic.
/// </summary>
internal RecentCommandsManager WithHistoryItem(string commandId, DateTimeOffset now)
{
var existing = History.FirstOrDefault(item => item.CommandId == commandId);
ImmutableList<HistoryItem> newHistory;
@@ -65,18 +160,18 @@ public record RecentCommandsManager : IRecentCommandsManager
if (existing is not null)
{
newHistory = History.Remove(existing);
var updated = existing with { Uses = existing.Uses + 1 };
var updated = existing with { Uses = existing.Uses + 1, LastUsed = now };
newHistory = newHistory.Insert(0, updated);
}
else
{
var newItem = new HistoryItem { CommandId = commandId, Uses = 1 };
var newItem = new HistoryItem { CommandId = commandId, Uses = 1, LastUsed = now };
newHistory = History.Insert(0, newItem);
}
if (newHistory.Count > 50)
if (newHistory.Count > MaxHistoryEntries)
{
newHistory = newHistory.RemoveRange(50, newHistory.Count - 50);
newHistory = newHistory.RemoveRange(MaxHistoryEntries, newHistory.Count - MaxHistoryEntries);
}
return this with { History = newHistory };
@@ -87,5 +182,17 @@ public interface IRecentCommandsManager
{
int GetCommandHistoryWeight(string commandId);
/// <summary>
/// Frecency weight for a command at an explicit evaluation time, so a whole batch can be
/// scored against one snapshot instead of a moving clock.
/// </summary>
int GetCommandHistoryWeight(string commandId, DateTimeOffset now);
RecentCommandsManager WithHistoryItem(string commandId);
/// <summary>
/// Builds any lazy internal state on the calling thread. Call it once before scoring items in
/// parallel, since the build itself isn't thread-safe.
/// </summary>
void PrewarmIndex();
}

View File

@@ -433,12 +433,16 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
// RPC to check type
if (model is IFallbackCommandItem fallback)
{
var wasEmpty = string.IsNullOrEmpty(Title);
var oldTitle = Title;
// RPC for method
fallback.FallbackHandler.UpdateQuery(newQuery);
var isEmpty = string.IsNullOrEmpty(Title);
return wasEmpty != isEmpty;
var newTitle = Title;
// Report any title change, not just an empty <-> non-empty flip: the render path
// re-scores fallbacks off this signal, so a change like "server01" -> "server02"
// must still trigger a refresh or the fallback keeps its stale score and position.
return !string.Equals(oldTitle, newTitle, StringComparison.Ordinal);
}
return false;

View File

@@ -0,0 +1,50 @@
// 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.Diagnostics.CodeAnalysis;
using System.Diagnostics.Tracing;
using Microsoft.PowerToys.Telemetry;
using Microsoft.PowerToys.Telemetry.Events;
namespace Microsoft.CmdPal.UI.Events;
/// <summary>
/// Tracks which main-page search result the user selected (invoked).
/// Purpose: measure whether the ranking overhaul surfaces the wanted result near the top.
/// Privacy: only non-identifying aggregates are captured. The selected item's title,
/// subtitle, id, and the raw query text are never logged - only the query character
/// length, the zero-based rank (index) of the selected result, and its ranker tier name.
/// Emission goes through <see cref="PowerToysTelemetry"/>, which respects the existing
/// PowerToys data-diagnostics consent gate.
/// </summary>
[EventData]
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)]
public class CmdPalSearchResultSelected : EventBase, IEvent
{
/// <summary>
/// Gets or sets the character length of the query at selection (never the query text).
/// </summary>
public int QueryLength { get; set; }
/// <summary>
/// Gets or sets the zero-based rank of the selected result within the visible results.
/// </summary>
public int SelectedIndex { get; set; }
/// <summary>
/// Gets or sets the ranker tier name of the selected result (e.g. "ExactTitle").
/// This is a fixed, non-identifying enum name, not user content.
/// </summary>
public string SelectedTier { get; set; }
public CmdPalSearchResultSelected(int queryLength, int selectedIndex, string selectedTier)
{
EventName = "CmdPal_SearchResultSelected";
QueryLength = queryLength;
SelectedIndex = selectedIndex;
SelectedTier = selectedTier;
}
public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage;
}

View File

@@ -0,0 +1,55 @@
// 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.Diagnostics.CodeAnalysis;
using System.Diagnostics.Tracing;
using Microsoft.PowerToys.Telemetry;
using Microsoft.PowerToys.Telemetry.Events;
namespace Microsoft.CmdPal.UI.Events;
/// <summary>
/// Tracks the outcome of a settled main-page search query.
/// Purpose: measure search relevance and perceived speed for the ranking overhaul.
/// Privacy: only non-identifying aggregates are captured. The raw query text is never
/// logged - only its character length. No titles, subtitles, paths, or app names are
/// captured. Emission goes through <see cref="PowerToysTelemetry"/>, which respects the
/// existing PowerToys data-diagnostics consent gate.
/// </summary>
[EventData]
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)]
public class CmdPalSearchResults : EventBase, IEvent
{
/// <summary>
/// Gets or sets the character length of the query (never the query text itself).
/// </summary>
public int QueryLength { get; set; }
/// <summary>
/// Gets or sets the number of deterministic first-paint results (commands and apps)
/// produced for the query.
/// </summary>
public int ResultCount { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the query produced no deterministic results.
/// </summary>
public bool NoResults { get; set; }
/// <summary>
/// Gets or sets the time in milliseconds to produce the deterministic first-paint results.
/// </summary>
public ulong LatencyMs { get; set; }
public CmdPalSearchResults(int queryLength, int resultCount, bool noResults, ulong latencyMs)
{
EventName = "CmdPal_SearchResults";
QueryLength = queryLength;
ResultCount = resultCount;
NoResults = noResults;
LatencyMs = latencyMs;
}
public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage;
}

View File

@@ -21,7 +21,9 @@ internal sealed class TelemetryForwarder :
IRecipient<TelemetryBeginInvokeMessage>,
IRecipient<TelemetryInvokeResultMessage>,
IRecipient<TelemetryExtensionInvokedMessage>,
IRecipient<TelemetryDockConfigurationMessage>
IRecipient<TelemetryDockConfigurationMessage>,
IRecipient<TelemetrySearchResultsMessage>,
IRecipient<TelemetrySearchResultSelectedMessage>
{
public TelemetryForwarder()
{
@@ -29,6 +31,8 @@ internal sealed class TelemetryForwarder :
WeakReferenceMessenger.Default.Register<TelemetryInvokeResultMessage>(this);
WeakReferenceMessenger.Default.Register<TelemetryExtensionInvokedMessage>(this);
WeakReferenceMessenger.Default.Register<TelemetryDockConfigurationMessage>(this);
WeakReferenceMessenger.Default.Register<TelemetrySearchResultsMessage>(this);
WeakReferenceMessenger.Default.Register<TelemetrySearchResultSelectedMessage>(this);
}
// Message handlers for telemetry events from core layer
@@ -68,6 +72,23 @@ internal sealed class TelemetryForwarder :
message.EndBands));
}
public void Receive(TelemetrySearchResultsMessage message)
{
PowerToysTelemetry.Log.WriteEvent(new CmdPalSearchResults(
message.QueryLength,
message.ResultCount,
message.NoResults,
message.LatencyMs));
}
public void Receive(TelemetrySearchResultSelectedMessage message)
{
PowerToysTelemetry.Log.WriteEvent(new CmdPalSearchResultSelected(
message.QueryLength,
message.SelectedIndex,
message.SelectedTier.ToString()));
}
// Static method for logging session duration from UI layer
public static void LogSessionDuration(
ulong durationMs,

View File

@@ -79,6 +79,17 @@
Value="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
<controls:Case Value="True">
<StackPanel Orientation="Vertical">
<controls:SettingsCard x:Name="SearchWeightSettingsCard" x:Uid="Settings_ExtensionPage_SearchWeight_SettingsCard">
<ComboBox
MinWidth="{StaticResource SettingActionControlMinWidth}"
AutomationProperties.AutomationId="CmdPal_ExtensionPage_SearchWeight"
AutomationProperties.LabeledBy="{x:Bind SearchWeightSettingsCard}"
SelectedIndex="{x:Bind ViewModel.SearchWeightIndex, Mode=TwoWay}">
<ComboBoxItem x:Uid="Settings_ExtensionPage_SearchWeight_Lower" />
<ComboBoxItem x:Uid="Settings_ExtensionPage_SearchWeight_Normal" />
<ComboBoxItem x:Uid="Settings_ExtensionPage_SearchWeight_Higher" />
</ComboBox>
</controls:SettingsCard>
<TextBlock x:Uid="ExtensionCommandsHeader" Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" />
<ItemsRepeater ItemsSource="{x:Bind ViewModel.TopLevelCommands, Mode=OneWay}" Layout="{StaticResource VerticalStackLayout}">
<ItemsRepeater.ItemTemplate>

View File

@@ -348,6 +348,24 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<data name="Settings_ExtensionPage_AliasActivation_SettingsCard.Description" xml:space="preserve">
<value>Choose when the alias runs. Direct runs as soon as you type the alias. Indirect runs after a trailing space.</value>
</data>
<data name="Settings_ExtensionPage_SearchWeight_SettingsCard.Header" xml:space="preserve">
<value>Search weight</value>
</data>
<data name="Settings_ExtensionPage_SearchWeight_SettingsCard.Description" xml:space="preserve">
<value>Nudge this provider's results up or down among equally-relevant matches.</value>
</data>
<data name="Settings_ExtensionPage_SearchWeight_SettingsCard.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Search weight</value>
</data>
<data name="Settings_ExtensionPage_SearchWeight_Lower.Content" xml:space="preserve">
<value>Lower</value>
</data>
<data name="Settings_ExtensionPage_SearchWeight_Normal.Content" xml:space="preserve">
<value>Normal</value>
</data>
<data name="Settings_ExtensionPage_SearchWeight_Higher.Content" xml:space="preserve">
<value>Higher</value>
</data>
<data name="Settings_ExtensionPage_Builtin_SettingsCard.Header" xml:space="preserve">
<value>Built-in</value>
</data>

View File

@@ -0,0 +1,315 @@
// 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;
using System.Collections.Generic;
using System.Linq;
using Microsoft.CmdPal.Common.Helpers;
using Microsoft.CmdPal.Common.Text;
using Microsoft.CmdPal.UI.ViewModels.Commands;
using Microsoft.CmdPal.UI.ViewModels.MainPage;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
[TestClass]
public sealed partial class EarlyFrameRelevanceTests
{
public TestContext TestContext { get; set; } = null!;
private sealed partial class CatalogItem : ListItem, IPrecomputedListItem
{
private FuzzyTargetCache _titleCache;
private FuzzyTargetCache _subtitleCache;
public CatalogItem(string title, string subtitle, string id)
: base(new NoOpCommand() { Id = id })
{
Title = title;
Subtitle = subtitle;
Id = id;
}
public string Id { get; }
public FuzzyTarget GetTitleTarget(IPrecomputedFuzzyMatcher matcher) => _titleCache.GetOrUpdate(matcher, Title);
public FuzzyTarget GetSubtitleTarget(IPrecomputedFuzzyMatcher matcher) => _subtitleCache.GetOrUpdate(matcher, Subtitle);
}
private static IPrecomputedFuzzyMatcher CreateMatcher() => new PrecomputedFuzzyMatcher(new PrecomputedFuzzyMatcherOptions());
private static ScoringFunction<IListItem> BuildScoringFunction(IRecentCommandsManager history, IPrecomputedFuzzyMatcher matcher)
=> (in FuzzyQuery query, IListItem item) => MainListPage.ScoreTopLevelItem(query, item, history, matcher, null);
private static RoScored<IListItem>[] Score(IReadOnlyList<CatalogItem> apps, string rawQuery, IRecentCommandsManager history, IPrecomputedFuzzyMatcher matcher)
{
var query = matcher.PrecomputeQuery(rawQuery);
var fn = BuildScoringFunction(history, matcher);
return InternalListHelpers.FilterListWithScores(apps.Cast<IListItem>().ToArray(), query, fn);
}
private static RecentCommandsManager SeedUses(RecentCommandsManager history, string commandId, int uses)
{
for (var i = 0; i < uses; i++)
{
history = history.WithHistoryItem(commandId);
}
return history;
}
// Every app matches "x" only at the Fuzzy tier.
private static CatalogItem[] BuildFuzzyOnlyCatalogForX() =>
[
new CatalogItem("Galaxy Store", "Shop for apps", "app.galaxy"),
new CatalogItem("Nexus Mods", "Manage game mods", "app.nexus"),
new CatalogItem("Toolbox Companion", "Developer tools", "app.toolbox"),
new CatalogItem("Max Cleaner", "Free up disk space", "app.max"),
new CatalogItem("Voxel Editor", "Edit voxel art", "app.voxel"),
];
// Apps spanning multiple tiers for "c": some titles start with it, some have a non-leading word
// that does, and some only contain it mid-word.
private static CatalogItem[] BuildMixedCatalogForC() =>
[
new CatalogItem("Calculator", "Perform calculations", "app.calc"),
new CatalogItem("Calendar", "View your schedule", "app.cal"),
new CatalogItem("Visual Studio Code", "Code editor", "app.vscode"),
new CatalogItem("Windows Camera", "Take photos", "app.camera"),
new CatalogItem("Microsoft Edge", "Browse the web", "app.edge"),
new CatalogItem("Office Hub", "Productivity apps", "app.office"),
new CatalogItem("Discord", "Chat with friends", "app.discord"),
];
/// <summary>
/// The mechanism, locked as a test: when a 1-char query only matches mid-word, every result is
/// Fuzzy, so seeding frecency on any one of them floats it straight to rank 1.
/// </summary>
[TestMethod]
public void ShortQuery_FrecencyFloatsWeakFuzzyMatchToTop()
{
var matcher = CreateMatcher();
var apps = BuildFuzzyOnlyCatalogForX();
// With no history, some app is at rank 1 purely on lexical quality.
var noHistory = Score(apps, "x", new RecentCommandsManager(), matcher);
Assert.IsTrue(noHistory.Length > 0, "The fuzzy-only catalog must still match 'x'.");
foreach (var s in noHistory)
{
Assert.AreEqual(
RankTier.Fuzzy,
MainListRanker.TierOf(s.Score),
$"Every 'x' match must be Fuzzy tier; '{s.Item.Title}' was {MainListRanker.TierOf(s.Score)}.");
}
// Seed heavy frecency on an app that was NOT already at the top, and confirm it floats up.
var seededId = noHistory[^1].Item is CatalogItem last ? last.Id : throw new InvalidOperationException();
var seededTitle = noHistory[^1].Item.Title;
var history = SeedUses(new RecentCommandsManager(), seededId, 40);
var withHistory = Score(apps, "x", history, matcher);
TestContext.WriteLine($"no-history rank1='{noHistory[0].Item.Title}', seeded '{seededTitle}' -> rank1='{withHistory[0].Item.Title}'.");
Assert.AreEqual(RankTier.Fuzzy, MainListRanker.TierOf(withHistory[0].Score), "The floated rank-1 item is still only a Fuzzy match.");
Assert.AreEqual(seededTitle, withHistory[0].Item.Title, "Frecency should float the seeded weak match to rank 1 within the Fuzzy tier.");
}
/// <summary>
/// A short query filters every fuzzy-only app match.
/// </summary>
[TestMethod]
public void ShortQuery_Filter_RemovesEveryFuzzyMatch()
{
var matcher = CreateMatcher();
var apps = BuildFuzzyOnlyCatalogForX();
var seededId = apps[0].Id;
var history = SeedUses(new RecentCommandsManager(), seededId, 40);
var scored = Score(apps, "x", history, matcher);
Assert.IsTrue(scored.Length > 0, "Precondition: the ungated result surfaces weak fuzzy matches.");
var filtered = MainListPage.FilterAppsForShortQueries(scored, queryLength: 1);
Assert.IsNotNull(filtered);
Assert.AreEqual(0, filtered!.Count, "A one-character query should filter fuzzy-only app matches.");
}
/// <summary>
/// The filter keeps high-confidence matches and removes fuzzy matches.
/// </summary>
[TestMethod]
public void ShortQuery_Filter_KeepsHighConfidenceMatches()
{
var matcher = CreateMatcher();
var apps = BuildMixedCatalogForC();
var scored = Score(apps, "c", new RecentCommandsManager(), matcher);
var fuzzyCount = scored.Count(s => MainListRanker.TierOf(s.Score) == RankTier.Fuzzy);
var confidentCount = scored.Count(s => (int)MainListRanker.TierOf(s.Score) >= (int)RankTier.AcronymWordBoundary);
Assert.IsTrue(fuzzyCount > 0, "Precondition: the mixed catalog produces some fuzzy-tail matches for 'c'.");
Assert.IsTrue(confidentCount > 0, "Precondition: the mixed catalog produces some confident matches for 'c'.");
var filtered = MainListPage.FilterAppsForShortQueries(scored, queryLength: 1);
Assert.IsNotNull(filtered);
Assert.AreEqual(confidentCount, filtered!.Count, "Only word-boundary or stronger matches should remain.");
foreach (var s in filtered)
{
Assert.IsTrue(
(int)MainListRanker.TierOf(s.Score) >= (int)RankTier.AcronymWordBoundary,
$"Filtered item '{s.Item.Title}' must be word-boundary tier or higher, was {MainListRanker.TierOf(s.Score)}.");
}
// Filtering preserves the scored order.
for (var i = 0; i < filtered.Count; i++)
{
Assert.AreSame(scored[i].Item, filtered[i].Item, $"Filtered item at index {i} must keep its scored position.");
}
}
/// <summary>
/// Queries longer than two characters return the original array.
/// </summary>
[TestMethod]
public void LongerQuery_Filter_ReturnsInputUnchanged()
{
var matcher = CreateMatcher();
var apps = BuildMixedCatalogForC();
foreach (var raw in new[] { "cal", "calc", "code" })
{
var scored = Score(apps, raw, new RecentCommandsManager(), matcher);
var filtered = MainListPage.FilterAppsForShortQueries(scored, raw.Length);
Assert.AreSame(scored, filtered, $"A {raw.Length}-character query ('{raw}') should return the original array.");
}
}
/// <summary>
/// The filter applies to query lengths one and two.
/// </summary>
[TestMethod]
public void Filter_LengthBoundary_AppliesToOneAndTwo_NotThree()
{
var matcher = CreateMatcher();
var apps = BuildFuzzyOnlyCatalogForX();
var scored = Score(apps, "x", new RecentCommandsManager(), matcher);
Assert.IsTrue(scored.Length > 0, "Precondition: 'x' matches fuzzily.");
Assert.AreEqual(0, MainListPage.FilterAppsForShortQueries(scored, 1)!.Count, "Length 1 should be filtered.");
Assert.AreEqual(0, MainListPage.FilterAppsForShortQueries(scored, 2)!.Count, "Length 2 should be filtered.");
Assert.AreSame(scored, MainListPage.FilterAppsForShortQueries(scored, 3), "Length 3 should not be filtered.");
}
/// <summary>
/// Null, empty, and default-view inputs are unchanged.
/// </summary>
[TestMethod]
public void Filter_NullEmptyAndZeroLength_AreNoOps()
{
Assert.IsNull(MainListPage.FilterAppsForShortQueries(null, 1));
var empty = Array.Empty<RoScored<IListItem>>();
Assert.AreSame(empty, MainListPage.FilterAppsForShortQueries(empty, 1));
var matcher = CreateMatcher();
var scored = Score(BuildFuzzyOnlyCatalogForX(), "x", new RecentCommandsManager(), matcher);
Assert.AreSame(scored, MainListPage.FilterAppsForShortQueries(scored, 0), "A zero-length query should return the original array.");
}
/// <summary>
/// Counts the leading entries at or above the requested tier.
/// </summary>
[TestMethod]
public void GetHighConfidenceAppsCount_CountsLeadingHighTierEntries()
{
RoScored<IListItem> Make(RankTier tier, int within)
=> new(new CatalogItem($"{tier}", string.Empty, $"{tier}.{within}"), MainListRanker.Pack(tier, within));
// Sorted descending by packed score: Exact(5) > Prefix(4) > WordBoundary(3) > Fuzzy(2) > Fallback(1).
var scored = new[]
{
Make(RankTier.ExactTitle, 100),
Make(RankTier.Prefix, 50),
Make(RankTier.AcronymWordBoundary, 20),
Make(RankTier.Fuzzy, 9000),
Make(RankTier.FallbackFloor, 5),
};
Assert.AreEqual(5, MainListPage.GetHighConfidenceAppsCount(scored, RankTier.FallbackFloor));
Assert.AreEqual(4, MainListPage.GetHighConfidenceAppsCount(scored, RankTier.Fuzzy));
Assert.AreEqual(3, MainListPage.GetHighConfidenceAppsCount(scored, RankTier.AcronymWordBoundary));
Assert.AreEqual(2, MainListPage.GetHighConfidenceAppsCount(scored, RankTier.Prefix));
Assert.AreEqual(1, MainListPage.GetHighConfidenceAppsCount(scored, RankTier.ExactTitle));
Assert.AreEqual(0, MainListPage.GetHighConfidenceAppsCount(scored, RankTier.AliasExact));
}
/// <summary>
/// Uses the query length published with the scored array.
/// </summary>
[TestMethod]
public void Filter_UsesSuppliedPublishedLength()
{
RoScored<IListItem> Fuzzy(int within)
=> new(new CatalogItem($"fuzzy.{within}", string.Empty, $"fuzzy.{within}"), MainListRanker.Pack(RankTier.Fuzzy, within));
var scored = new[] { Fuzzy(30), Fuzzy(20), Fuzzy(10) };
var filteredByPublished = MainListPage.FilterAppsForShortQueries(scored, queryLength: 2);
Assert.IsNotNull(filteredByPublished);
Assert.AreEqual(0, filteredByPublished!.Count, "The published short length should filter fuzzy matches.");
Assert.AreSame(scored, MainListPage.FilterAppsForShortQueries(scored, queryLength: 5), "A longer published length should return the original array.");
}
/// <summary>
/// Telemetry counts only visible apps after filtering.
/// </summary>
[TestMethod]
public void GetVisibleAppCount_ShortQuery_CountsOnlyFilteredApps()
{
var matcher = CreateMatcher();
var apps = BuildMixedCatalogForC();
var scored = Score(apps, "c", new RecentCommandsManager(), matcher);
var full = scored.Length;
var confident = scored.Count(s => (int)MainListRanker.TierOf(s.Score) >= (int)RankTier.AcronymWordBoundary);
Assert.IsTrue(confident < full, "Precondition: fuzzy matches make the filtered count smaller.");
const int NoCap = 1000;
Assert.AreEqual(confident, MainListPage.GetVisibleAppCount(scored, queryLength: 1, appResultLimit: NoCap));
Assert.AreEqual(confident, MainListPage.GetVisibleAppCount(scored, queryLength: 2, appResultLimit: NoCap));
Assert.AreEqual(full, MainListPage.GetVisibleAppCount(scored, queryLength: 3, appResultLimit: NoCap));
Assert.AreEqual(full, MainListPage.GetVisibleAppCount(scored, queryLength: 0, appResultLimit: NoCap), "A zero-length query should count the full set.");
}
/// <summary>
/// The count respects the app limit and handles empty input.
/// </summary>
[TestMethod]
public void GetVisibleAppCount_RespectsCap_AndEmptyInput()
{
var matcher = CreateMatcher();
var fuzzyOnly = Score(BuildFuzzyOnlyCatalogForX(), "x", new RecentCommandsManager(), matcher);
Assert.IsTrue(fuzzyOnly.Length > 0, "Precondition: 'x' matches fuzzily.");
Assert.AreEqual(0, MainListPage.GetVisibleAppCount(fuzzyOnly, queryLength: 1, appResultLimit: 1000));
var mixed = Score(BuildMixedCatalogForC(), "c", new RecentCommandsManager(), matcher);
Assert.IsTrue(mixed.Length > 2, "Precondition: the mixed catalog has more than two matches so the cap bites.");
Assert.AreEqual(2, MainListPage.GetVisibleAppCount(mixed, queryLength: 3, appResultLimit: 2), "The app result limit should cap the count.");
Assert.AreEqual(0, MainListPage.GetVisibleAppCount(null, 1, 1000));
Assert.AreEqual(0, MainListPage.GetVisibleAppCount(Array.Empty<RoScored<IListItem>>(), 1, 1000));
}
}

View File

@@ -0,0 +1,215 @@
// 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;
using System.Collections.Generic;
using Microsoft.CmdPal.Common.Helpers;
using Microsoft.CmdPal.Common.Text;
using Microsoft.CmdPal.UI.ViewModels.Commands;
using Microsoft.CmdPal.UI.ViewModels.MainPage;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Windows.Foundation;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
/// <summary>
/// Guardrail tests for the "fast first paint" behavior of the main/root page.
///
/// The typing path renders deterministic in-proc results (top-level commands + installed
/// apps) immediately and folds in slow, out-of-proc fallback contributions asynchronously
/// as they resolve. These tests pin the two invariants that make that safe:
/// 1. Deterministic results are produced without any fallback contribution present, so
/// first paint never waits on a slow source.
/// 2. Late-arriving fallbacks are merged with a fresh score from the same ranker, always
/// at the FallbackFloor tier, so they can never leapfrog deterministic matches, and a
/// superseding query's snapshot replaces any stale one.
/// </summary>
[TestClass]
public sealed partial class FastFirstPaintTests
{
private static readonly Separator _resultsSeparator = new("Results");
private static readonly Separator _fallbacksSeparator = new("Fallbacks");
// A list item whose Title can be mutated to simulate an extension resolving a dynamic
// fallback title asynchronously, after first paint.
private sealed partial class MutableListItem : IListItem
{
public string Title { get; set; } = string.Empty;
public string Subtitle { get; set; } = string.Empty;
public ICommand Command => new NoOpCommand();
public IDetails? Details => null;
public IIconInfo? Icon => null;
public string Section => string.Empty;
public ITag[] Tags => [];
public string TextToSuggest => string.Empty;
public IContextItem[] MoreCommands => [];
#pragma warning disable CS0067 // The event is never used
public event TypedEventHandler<object, IPropChangedEventArgs>? PropChanged;
#pragma warning restore CS0067 // The event is never used
public override string ToString() => Title;
}
// A stand-in ranker that mirrors the real contract for fallbacks: an item with no
// (resolved) title scores 0 and is dropped; otherwise it lands at the FallbackFloor tier
// with a stronger within-floor score when its current title overlaps the query text.
private static ScoringFunction<IListItem> FloorScorerFor(string queryText)
{
return (in FuzzyQuery _, IListItem item) =>
{
var title = item.Title;
if (string.IsNullOrWhiteSpace(title))
{
return 0;
}
var within = title.Contains(queryText, StringComparison.OrdinalIgnoreCase) ? 100 : 1;
return MainListRanker.Pack(RankTier.FallbackFloor, within);
};
}
private static RoScored<IListItem> ScoredFuzzy(string title, int within)
=> new(score: MainListRanker.Pack(RankTier.Fuzzy, within), item: new MutableListItem { Title = title });
private static RoScored<IListItem> ScoredFloorMax(string title)
=> new(score: MainListRanker.Pack(RankTier.FallbackFloor, MainListRanker.TierStride - 1), item: new MutableListItem { Title = title });
[TestMethod]
public void FirstPaint_ProducesDeterministicResults_WithNoFallbackContribution()
{
var command = ScoredFuzzy("Notepad", within: 50);
var app = ScoredFuzzy("Notepad++", within: 40);
var filtered = new List<RoScored<IListItem>> { command };
var apps = new List<RoScored<IListItem>> { app };
// No scored fallbacks and no fallback items: this models first paint before any slow
// out-of-proc source has responded.
var result = MainListPageResultFactory.Create(
filtered,
scoredFallbackItems: null,
apps,
fallbackItems: null,
_resultsSeparator,
_fallbacksSeparator,
appResultLimit: 10);
CollectionAssert.AreEqual(
new IListItem[] { _resultsSeparator, command.Item, app.Item },
result,
"First paint must render the deterministic command/app results without any fallback contribution.");
}
[TestMethod]
public void UnresolvedFallback_IsAbsentAtFirstPaint()
{
var fallback = new MutableListItem { Title = string.Empty };
IReadOnlyList<IListItem> sources = new IListItem[] { fallback };
var scored = MainListPage.ScoreDeferredFallbacks(sources, default, FloorScorerFor("remote"));
Assert.IsNull(scored, "A fallback whose dynamic title has not resolved yet must not appear.");
}
[TestMethod]
public void SlowFallback_FoldsIn_WhenItsTitleResolves()
{
var fallback = new MutableListItem { Title = string.Empty };
IReadOnlyList<IListItem> sources = new IListItem[] { fallback };
var scorer = FloorScorerFor("remote");
// First paint: unresolved title -> not present.
Assert.IsNull(MainListPage.ScoreDeferredFallbacks(sources, default, scorer));
// The extension resolves the dynamic title asynchronously (off the typing path).
fallback.Title = "Remote Desktop: server01";
// A later refresh re-scores the same snapshot and folds the fallback in.
var after = MainListPage.ScoreDeferredFallbacks(sources, default, scorer);
Assert.IsNotNull(after);
Assert.AreEqual(1, after!.Count);
Assert.AreSame(fallback, after[0].Item);
Assert.AreEqual(RankTier.FallbackFloor, MainListRanker.TierOf(after[0].Score));
}
[TestMethod]
public void ReScore_ReflectsCurrentTitle_NotAFrozenValue()
{
var fallback = new MutableListItem { Title = "no match here" };
IReadOnlyList<IListItem> sources = new IListItem[] { fallback };
var scorer = FloorScorerFor("remote");
var weak = MainListPage.ScoreDeferredFallbacks(sources, default, scorer);
Assert.IsNotNull(weak);
var weakScore = weak![0].Score;
// The title resolves to a strong match. If scoring were frozen at keystroke time, the
// weak score would persist; deferred re-scoring must reflect the fresh title.
fallback.Title = "Remote host";
var strong = MainListPage.ScoreDeferredFallbacks(sources, default, scorer);
Assert.IsNotNull(strong);
Assert.IsTrue(strong![0].Score > weakScore, "Re-scoring must reflect the freshly resolved title, not a frozen value.");
}
[TestMethod]
public void ReScoreUsesLatestSnapshot_StaleStrongMatchNotApplied()
{
// This exercises the render-path re-score directly: it always scores whatever snapshot is
// current, so a superseding keystroke's query wins and the prior query's strong score is
// gone. In production the field pair (_globalFallbackSources + _globalFallbackQuery) is
// written under lock (commands) in UpdateSearchTextCore and read under the same lock in
// GetItems, so the swap is atomic; this test stands in for that behavior at the helper level.
var fallback = new MutableListItem { Title = "Remote Desktop" };
IReadOnlyList<IListItem> sources = new IListItem[] { fallback };
// Query A ("remote") is a strong match for the current title.
var a = MainListPage.ScoreDeferredFallbacks(sources, default, FloorScorerFor("remote"));
Assert.IsNotNull(a);
var strongScore = a![0].Score;
// A newer keystroke installs query B ("zzz"), which does not overlap the title. The
// render path always scores the latest snapshot, so query A's strong score is gone.
var b = MainListPage.ScoreDeferredFallbacks(sources, default, FloorScorerFor("zzz"));
Assert.IsNotNull(b);
Assert.IsTrue(b![0].Score < strongScore, "A superseding query must not inherit the prior query's stale score.");
}
[TestMethod]
public void LateFallback_MergesBelowDeterministicMatches_NoLeapfrog()
{
// A minimal deterministic Fuzzy match versus a fallback with the maximum possible
// within-floor score. The tier ladder must still keep the fallback below the command.
var command = ScoredFuzzy("Notepad", within: 1);
var fallback = ScoredFloorMax("Search the web for notepad");
var filtered = new List<RoScored<IListItem>> { command };
var scoredFallbacks = new List<RoScored<IListItem>> { fallback };
var result = MainListPageResultFactory.Create(
filtered,
scoredFallbacks,
filteredApps: null,
fallbackItems: null,
_resultsSeparator,
_fallbacksSeparator,
appResultLimit: 10);
Assert.AreEqual(_resultsSeparator, result[0]);
Assert.AreSame(command.Item, result[1], "Deterministic matches must sort above floor-tier fallbacks.");
Assert.AreSame(fallback.Item, result[2], "A late floor-tier fallback merges after deterministic results, never leapfrogging them.");
}
}

View File

@@ -0,0 +1,141 @@
// 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;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.MainPage;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
/// <summary>
/// Focused, per-tier unit tests for the <see cref="MainListRanker"/> primitives - tier
/// classification, the tier/within-tier packing invariants, and the within-tier score inputs.
/// These cover the pieces the end-to-end <see cref="RelevanceHarnessTests"/> cannot easily drive
/// through app/command mocks (alias-exact and fallback-floor classification, and the packing
/// guarantee that a higher tier always outranks a lower one regardless of within-tier score).
/// </summary>
[TestClass]
public class MainListRankerTests
{
[DataTestMethod]
[DataRow("gh", "GitHub", true, true, false, false, RankTier.AliasExact, DisplayName = "Alias-exact is the strongest, most explicit signal and beats even a fallback flag")]
[DataRow("anything", "Some Fallback", true, false, false, true, RankTier.FallbackFloor, DisplayName = "A fallback that is not alias-exact lands on the floor regardless of a lexical match")]
[DataRow("calculator", "Calculator", false, false, false, true, RankTier.ExactTitle, DisplayName = "Exact title match")]
[DataRow("cal", "Calculator", false, false, false, true, RankTier.Prefix, DisplayName = "Title prefix match")]
[DataRow("code", "Visual Studio Code", false, false, false, true, RankTier.AcronymWordBoundary, DisplayName = "Word-boundary match")]
[DataRow("vs", "Visual Studio Code", false, false, false, true, RankTier.AcronymWordBoundary, DisplayName = "Acronym match")]
[DataRow("cmd", "Command Prompt", false, false, false, true, RankTier.Fuzzy, DisplayName = "A lexical match that is not exact/prefix/word-boundary/acronym is fuzzy")]
[DataRow("zzz", "Command Prompt", false, false, false, false, RankTier.None, DisplayName = "Nothing matched")]
[DataRow("zz", "Some Command", false, false, true, false, RankTier.Fuzzy, DisplayName = "An alias-substring match keeps the item at the fuzzy floor even with no lexical match")]
public void ClassifyTier_ClassifiesEachSignalIntoItsTier(
string query,
string title,
bool isFallback,
bool isAliasExact,
bool isAliasSubstringMatch,
bool matchedLexically,
RankTier expected)
{
Assert.AreEqual(
expected,
MainListRanker.ClassifyTier(query, title, isFallback, isAliasExact, isAliasSubstringMatch, matchedLexically));
}
[TestMethod]
public void Pack_HigherTierAlwaysOutranksLowerTier()
{
// The core invariant: a higher tier with the WORST possible within-tier score still
// outranks a lower tier with the BEST possible within-tier score. This is what makes
// "an exact match always beats a fuzzy one" true no matter how much frecency piles up.
RankTier[] ascending =
{
RankTier.FallbackFloor,
RankTier.Fuzzy,
RankTier.AcronymWordBoundary,
RankTier.Prefix,
RankTier.ExactTitle,
RankTier.AliasExact,
};
for (var i = 0; i < ascending.Length - 1; i++)
{
var lower = MainListRanker.Pack(ascending[i], MainListRanker.TierStride - 1);
var higher = MainListRanker.Pack(ascending[i + 1], 0);
Assert.IsTrue(
higher > lower,
$"{ascending[i + 1]} (min within-tier) must outrank {ascending[i]} (max within-tier)");
}
}
[TestMethod]
public void Pack_NoneIsZeroAndFiltered()
{
Assert.AreEqual(0, MainListRanker.Pack(RankTier.None, 999_999));
}
[TestMethod]
public void Pack_WithinTierScoreIsClampedToItsBand()
{
// An absurd within-tier score must never spill into the next tier's band.
var packed = MainListRanker.Pack(RankTier.Fuzzy, double.MaxValue);
Assert.AreEqual(RankTier.Fuzzy, MainListRanker.TierOf(packed));
var nextTierFloor = MainListRanker.Pack(RankTier.AcronymWordBoundary, 0);
Assert.IsTrue(packed < nextTierFloor, "A clamped within-tier score must stay below the next tier");
}
[TestMethod]
public void Pack_WithinTierScoreOrdersItemsInTheSameTier()
{
var low = MainListRanker.Pack(RankTier.Prefix, 10);
var high = MainListRanker.Pack(RankTier.Prefix, 20);
Assert.IsTrue(high > low, "Within the same tier, a higher within-tier score sorts higher");
Assert.AreEqual(MainListRanker.TierOf(low), MainListRanker.TierOf(high), "Both remain in the same tier");
}
[TestMethod]
public void TierOf_RoundTripsEveryTier()
{
foreach (RankTier tier in Enum.GetValues(typeof(RankTier)))
{
if (tier == RankTier.None)
{
continue;
}
var packed = MainListRanker.Pack(tier, 42);
Assert.AreEqual(tier, MainListRanker.TierOf(packed), $"Packing then unpacking {tier} should round-trip");
}
}
[TestMethod]
public void WithinTierScore_LexicalQualityLeads()
{
// More lexical quality raises the within-tier score, all else equal.
var lowLexical = MainListRanker.WithinTierScore(lexicalQuality: 5, frecencyWeight: 0, aliasSubstringBonus: 0, providerBonus: 0);
var highLexical = MainListRanker.WithinTierScore(lexicalQuality: 6, frecencyWeight: 0, aliasSubstringBonus: 0, providerBonus: 0);
Assert.IsTrue(highLexical > lowLexical, "Higher lexical quality should raise the within-tier score");
}
[TestMethod]
public void WithinTierScore_FrecencyBreaksTies()
{
var noFrecency = MainListRanker.WithinTierScore(lexicalQuality: 5, frecencyWeight: 0, aliasSubstringBonus: 0, providerBonus: 0);
var withFrecency = MainListRanker.WithinTierScore(lexicalQuality: 5, frecencyWeight: 3, aliasSubstringBonus: 0, providerBonus: 0);
Assert.IsTrue(withFrecency > noFrecency, "Frecency should raise the within-tier score for otherwise-equal items");
}
[TestMethod]
public void ProviderBonus_LowerIsBelowNormalIsBelowHigher()
{
Assert.IsTrue(
MainListRanker.ProviderBonus(ProviderSearchWeight.Lower) < MainListRanker.ProviderBonus(ProviderSearchWeight.Normal),
"Lower should subtract relative to Normal");
Assert.IsTrue(
MainListRanker.ProviderBonus(ProviderSearchWeight.Normal) < MainListRanker.ProviderBonus(ProviderSearchWeight.Higher),
"Higher should add relative to Normal");
Assert.AreEqual(0.0, MainListRanker.ProviderBonus(ProviderSearchWeight.Normal), "Normal is the neutral default");
}
}

View File

@@ -0,0 +1,214 @@
// 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;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Text.Json;
using Microsoft.CmdPal.Common.Text;
using Microsoft.CmdPal.Ext.UnitTestBase;
using Microsoft.CmdPal.UI.ViewModels.MainPage;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Windows.Foundation;
using WyHash;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
[TestClass]
public partial class ProviderWeightingTests : CommandPaletteUnitTestBase
{
private static IPrecomputedFuzzyMatcher CreateMatcher()
=> new PrecomputedFuzzyMatcher(new PrecomputedFuzzyMatcherOptions());
private static RecentCommandsManager EmptyHistory() => new();
// Maps each item to the weight configured for its provider id.
private static Func<IListItem, ProviderSearchWeight> LookupBy(IReadOnlyDictionary<string, ProviderSearchWeight> byProvider)
=> item =>
{
var providerId = item is WeightItemMock mock ? mock.ProviderId ?? string.Empty : string.Empty;
return byProvider.TryGetValue(providerId, out var weight) ? weight : ProviderSearchWeight.Normal;
};
[TestMethod]
public void ProviderBonus_MapsSignedMagnitude()
{
Assert.AreEqual(-MainListRanker.ProviderWeightBonus, MainListRanker.ProviderBonus(ProviderSearchWeight.Lower));
Assert.AreEqual(0.0, MainListRanker.ProviderBonus(ProviderSearchWeight.Normal));
Assert.AreEqual(MainListRanker.ProviderWeightBonus, MainListRanker.ProviderBonus(ProviderSearchWeight.Higher));
}
[TestMethod]
public void ProviderWeight_ReordersWithinTier()
{
// Two items that land in the SAME tier (exact-title) with identical lexical quality
// and no history. The only differentiator is the per-provider weight.
var fuzzyMatcher = CreateMatcher();
var q = fuzzyMatcher.PrecomputeQuery("git");
var itemA = new WeightItemMock("git", ProviderId: "providerA");
var itemB = new WeightItemMock("git", ProviderId: "providerB");
// Baseline: both Normal -> equal scores.
var neutral = LookupBy(new Dictionary<string, ProviderSearchWeight>());
var baseA = MainListPage.ScoreTopLevelItem(q, itemA, EmptyHistory(), fuzzyMatcher, neutral);
var baseB = MainListPage.ScoreTopLevelItem(q, itemB, EmptyHistory(), fuzzyMatcher, neutral);
Assert.AreEqual(baseA, baseB, "With both providers Normal, tied items should score equally");
// A Higher, B Lower -> A must now sort above B.
var lookup = LookupBy(new Dictionary<string, ProviderSearchWeight>
{
["providerA"] = ProviderSearchWeight.Higher,
["providerB"] = ProviderSearchWeight.Lower,
});
var higherA = MainListPage.ScoreTopLevelItem(q, itemA, EmptyHistory(), fuzzyMatcher, lookup);
var lowerB = MainListPage.ScoreTopLevelItem(q, itemB, EmptyHistory(), fuzzyMatcher, lookup);
Assert.IsTrue(higherA > baseA, "Higher weight should raise the score");
Assert.IsTrue(lowerB < baseB, "Lower weight should reduce the score");
Assert.IsTrue(higherA > lowerB, "Higher-weighted provider should outrank the lower-weighted one within the tier");
// Same tier the whole time - the nudge only reordered within it.
Assert.AreEqual(MainListRanker.TierOf(baseA), MainListRanker.TierOf(higherA));
Assert.AreEqual(MainListRanker.TierOf(baseB), MainListRanker.TierOf(lowerB));
}
[TestMethod]
public void ProviderWeight_NeverCrossesTierBoundary()
{
var fuzzyMatcher = CreateMatcher();
var q = fuzzyMatcher.PrecomputeQuery("code");
// "code" exactly matches -> ExactTitle tier. Give it the WORST weight.
var exact = new WeightItemMock("code", ProviderId: "weak");
// "Visual Studio Code" only matches "code" at a word boundary -> lower tier. Give it
// the BEST weight, plus a pile of history, to try to jump the tier boundary.
var lowerTier = new WeightItemMock("Visual Studio Code", ProviderId: "strong");
var lookup = LookupBy(new Dictionary<string, ProviderSearchWeight>
{
["weak"] = ProviderSearchWeight.Lower,
["strong"] = ProviderSearchWeight.Higher,
});
var history = EmptyHistory();
for (var i = 0; i < 50; i++)
{
history = history.WithHistoryItem(lowerTier.Id);
}
var exactScore = MainListPage.ScoreTopLevelItem(q, exact, EmptyHistory(), fuzzyMatcher, lookup);
var lowerScore = MainListPage.ScoreTopLevelItem(q, lowerTier, history, fuzzyMatcher, lookup);
Assert.IsTrue(
MainListRanker.TierOf(exactScore) > MainListRanker.TierOf(lowerScore),
"The exact match must live in a strictly higher tier");
Assert.IsTrue(
exactScore > lowerScore,
"A within-tier nudge (even Higher + heavy history) must never promote an item across a tier boundary");
}
[TestMethod]
public void ProviderWeight_DefaultNormalMatchesNoLookup()
{
var fuzzyMatcher = CreateMatcher();
var q = fuzzyMatcher.PrecomputeQuery("term");
var item = new WeightItemMock("Terminal", ProviderId: "providerA");
// No lookup at all should behave exactly like an all-Normal lookup, which should
// behave exactly like the previous (provider-unaware) scoring.
var noLookup = MainListPage.ScoreTopLevelItem(q, item, EmptyHistory(), fuzzyMatcher);
var normalLookup = MainListPage.ScoreTopLevelItem(
q,
item,
EmptyHistory(),
fuzzyMatcher,
LookupBy(new Dictionary<string, ProviderSearchWeight> { ["providerA"] = ProviderSearchWeight.Normal }));
Assert.AreEqual(noLookup, normalLookup, "Normal weight must be a no-op relative to the default path");
}
[TestMethod]
public void ProviderWeight_AppliesToAnyProviderItem()
{
// App items are plain IListItems (not TopLevelViewModel). This asserts the scorer
// honors the provider weight for ANY item, which is how installed apps (the "AllApps"
// provider) get nudged.
var fuzzyMatcher = CreateMatcher();
var q = fuzzyMatcher.PrecomputeQuery("note");
var appItem = new WeightItemMock("Notepad", ProviderId: "AllApps");
var normal = MainListPage.ScoreTopLevelItem(q, appItem, EmptyHistory(), fuzzyMatcher);
var higher = MainListPage.ScoreTopLevelItem(
q,
appItem,
EmptyHistory(),
fuzzyMatcher,
LookupBy(new Dictionary<string, ProviderSearchWeight> { ["AllApps"] = ProviderSearchWeight.Higher }));
Assert.IsTrue(higher > normal, "An app-style item should also respond to its provider's Higher weight");
}
[TestMethod]
public void SearchWeight_SerializationRoundTrips()
{
var dict = ImmutableDictionary<string, ProviderSettings>.Empty
.SetItem("p", new ProviderSettings { SearchWeight = ProviderSearchWeight.Higher });
var json = JsonSerializer.Serialize(dict, JsonSerializationContext.Default.ImmutableProviderSettingsDictionary);
var restored = JsonSerializer.Deserialize(json, JsonSerializationContext.Default.ImmutableProviderSettingsDictionary);
Assert.IsNotNull(restored);
Assert.AreEqual(ProviderSearchWeight.Higher, restored!["p"].SearchWeight);
}
[TestMethod]
public void SearchWeight_LegacyJsonDeserializesToNormal()
{
// Legacy persisted settings predate SearchWeight; the missing property must default
// to Normal rather than throwing or landing on Lower.
const string legacyJson = "{\"p\":{\"IsEnabled\":true}}";
var restored = JsonSerializer.Deserialize(legacyJson, JsonSerializationContext.Default.ImmutableProviderSettingsDictionary);
Assert.IsNotNull(restored);
Assert.AreEqual(ProviderSearchWeight.Normal, restored!["p"].SearchWeight);
}
private sealed partial record WeightItemMock(
string Title,
string? Subtitle = "",
string? GivenId = "",
string? ProviderId = "") : IListItem
{
public string Id => string.IsNullOrEmpty(GivenId) ? GenerateId() : GivenId;
public IDetails Details => throw new NotImplementedException();
public string Section => throw new NotImplementedException();
public ITag[] Tags => throw new NotImplementedException();
public string TextToSuggest => throw new NotImplementedException();
public ICommand Command => new NoOpCommand() { Id = Id };
public IIconInfo Icon => throw new NotImplementedException();
public IContextItem[] MoreCommands => throw new NotImplementedException();
#pragma warning disable CS0067
public event TypedEventHandler<object, IPropChangedEventArgs>? PropChanged;
#pragma warning restore CS0067
private string GenerateId()
{
var result = WyHash64.ComputeHash64(ProviderId + Title + Subtitle, seed: 0);
return $"{ProviderId}{result}";
}
}
}

View File

@@ -4,7 +4,9 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text.Json;
using Microsoft.CmdPal.Common.Text;
using Microsoft.CmdPal.Ext.UnitTestBase;
using Microsoft.CmdPal.UI.ViewModels.MainPage;
@@ -33,20 +35,6 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
return history;
}
private static RecentCommandsManager CreateBasicHistoryService()
{
var commonCommands = new List<string>
{
"com.microsoft.cmdpal.shell",
"com.microsoft.cmdpal.windowwalker",
"Visual Studio 2022 Preview_6533433915015224980",
"com.microsoft.cmdpal.reload",
"com.microsoft.cmdpal.shell",
};
return CreateHistory(commonCommands);
}
[TestMethod]
public void ValidateHistoryFunctionality()
{
@@ -63,21 +51,31 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
[TestMethod]
public void ValidateHistoryWeighting()
{
// Setup
var history = CreateBasicHistoryService();
// Build history with explicit, strictly-increasing timestamps so time-decay is
// deterministic. "shell" is used twice (most uses) and most recently; the others are
// each used once, progressively more recently.
var t0 = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
var history = new RecentCommandsManager();
history = history.WithHistoryItem("com.microsoft.cmdpal.shell", t0);
history = history.WithHistoryItem("com.microsoft.cmdpal.windowwalker", t0.AddDays(1));
history = history.WithHistoryItem("Visual Studio 2022 Preview_6533433915015224980", t0.AddDays(2));
history = history.WithHistoryItem("com.microsoft.cmdpal.reload", t0.AddDays(3));
history = history.WithHistoryItem("com.microsoft.cmdpal.shell", t0.AddDays(4));
var now = t0.AddDays(4);
// Act
var shellWeight = history.GetCommandHistoryWeight("com.microsoft.cmdpal.shell");
var windowWalkerWeight = history.GetCommandHistoryWeight("com.microsoft.cmdpal.windowwalker");
var vsWeight = history.GetCommandHistoryWeight("Visual Studio 2022 Preview_6533433915015224980");
var reloadWeight = history.GetCommandHistoryWeight("com.microsoft.cmdpal.reload");
var nonExistentWeight = history.GetCommandHistoryWeight("non.existent.command");
var shellWeight = history.GetCommandHistoryWeight("com.microsoft.cmdpal.shell", now);
var windowWalkerWeight = history.GetCommandHistoryWeight("com.microsoft.cmdpal.windowwalker", now);
var vsWeight = history.GetCommandHistoryWeight("Visual Studio 2022 Preview_6533433915015224980", now);
var reloadWeight = history.GetCommandHistoryWeight("com.microsoft.cmdpal.reload", now);
var nonExistentWeight = history.GetCommandHistoryWeight("non.existent.command", now);
// Assert
Assert.IsTrue(shellWeight > windowWalkerWeight, "Shell should be weighted higher than Window Walker, more uses");
Assert.IsTrue(vsWeight > windowWalkerWeight, "Visual Studio should be weighted higher than Window Walker, because recency");
Assert.AreEqual(reloadWeight, vsWeight, "both reload and VS were used in the last three commands, same weight");
Assert.IsTrue(shellWeight > vsWeight, "VS and run were both used in the last 3, but shell has 2 more frequency");
Assert.IsTrue(shellWeight > windowWalkerWeight, "Shell is both the most-used and most-recent command");
Assert.IsTrue(vsWeight > windowWalkerWeight, "Visual Studio was used more recently than Window Walker");
Assert.IsTrue(reloadWeight > vsWeight, "Reload was used more recently than Visual Studio");
Assert.IsTrue(shellWeight > vsWeight, "Shell is both more recent and more frequently used than Visual Studio");
Assert.AreEqual(0, nonExistentWeight, "Nonexistent command should have zero weight");
}
@@ -159,97 +157,163 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
}
[TestMethod]
public void ValidateHistoryBuckets()
public void ValidateRecencyDecay()
{
// Setup
// (these will be checked in reverse order, so that A is the most recent)
var items = new List<ListItemMock>
{
new("Command A", "Subtitle A", GivenId: "idA"), // #0 -> bucket 0
new("Command B", "Subtitle B", GivenId: "idB"), // #1 -> bucket 0
new("Command C", "Subtitle C", GivenId: "idC"), // #2 -> bucket 0
new("Command D", "Subtitle D", GivenId: "idD"), // #3 -> bucket 1
new("Command E", "Subtitle E", GivenId: "idE"), // #4 -> bucket 1
new("Command F", "Subtitle F", GivenId: "idF"), // #5 -> bucket 1
new("Command G", "Subtitle G", GivenId: "idG"), // #6 -> bucket 1
new("Command H", "Subtitle H", GivenId: "idH"), // #7 -> bucket 1
new("Command I", "Subtitle I", GivenId: "idI"), // #8 -> bucket 1
new("Command J", "Subtitle J", GivenId: "idJ"), // #9 -> bucket 1
new("Command K", "Subtitle K", GivenId: "idK"), // #10 -> bucket 1
new("Command L", "Subtitle L", GivenId: "idL"), // #11 -> bucket 2
new("Command M", "Subtitle M", GivenId: "idM"), // #12 -> bucket 2
new("Command N", "Subtitle N", GivenId: "idN"), // #13 -> bucket 2
new("Command O", "Subtitle O", GivenId: "idO"), // #14 -> bucket 2
};
// Each command is used exactly once, at progressively older times. Weight must decay
// monotonically with age, and a 3-day-old use (one half-life) should weigh about half
// of a use that just happened.
var now = new DateTimeOffset(2025, 2, 1, 0, 0, 0, TimeSpan.Zero);
var history = new RecentCommandsManager()
.WithHistoryItem("today", now)
.WithHistoryItem("three-days", now.AddDays(-3))
.WithHistoryItem("ten-days", now.AddDays(-10))
.WithHistoryItem("thirty-days", now.AddDays(-30));
for (var i = items.Count; i <= 50; i++)
var today = history.GetCommandHistoryWeight("today", now);
var threeDays = history.GetCommandHistoryWeight("three-days", now);
var tenDays = history.GetCommandHistoryWeight("ten-days", now);
var thirtyDays = history.GetCommandHistoryWeight("thirty-days", now);
Assert.IsTrue(today > threeDays, "A more recent use must weigh more than an older one");
Assert.IsTrue(threeDays > tenDays, "Decay must be monotonic with age");
Assert.IsTrue(tenDays > thirtyDays, "Older uses keep decaying toward zero");
Assert.IsTrue(thirtyDays >= 0, "Weight must never go negative");
// The half-life is 3 days, so a 3-day-old single use is about half of a fresh one.
Assert.AreEqual(today / 2.0, threeDays, 1.0, "Three days should be a single half-life");
}
[TestMethod]
public void ValidateFrequencyWeighting()
{
// Hold recency constant (same timestamp) and vary only the use count. More uses must
// weigh more, but the log-scaled frequency term stays within the documented cap.
var now = new DateTimeOffset(2025, 2, 1, 0, 0, 0, TimeSpan.Zero);
var history = new RecentCommandsManager().WithHistoryItem("once", now);
for (var i = 0; i < 7; i++)
{
items.Add(new ListItemMock($"Command #{i}", GivenId: $"id{i}"));
history = history.WithHistoryItem("many", now);
}
// Act
var history = CreateHistory(items.Reverse<ListItemMock>().ToList());
var once = history.GetCommandHistoryWeight("once", now);
var many = history.GetCommandHistoryWeight("many", now);
// Assert
// First three items should be in the top bucket
var weightA = history.GetCommandHistoryWeight("idA");
var weightB = history.GetCommandHistoryWeight("idB");
var weightC = history.GetCommandHistoryWeight("idC");
Assert.IsTrue(many > once, "At equal recency, a more frequently used command weighs more");
Assert.IsTrue(many <= RecentCommandsManager.MaxWeight, "Weight stays within the documented cap");
}
Assert.AreEqual(weightA, weightB, "Items A and B were used in the last 3 commands");
Assert.AreEqual(weightB, weightC, "Items B and C were used in the last 3 commands");
[TestMethod]
public void ValidateLookupIndexStaysConsistentAcrossChanges()
{
// The commandId lookup is backed by a cached index that must be invalidated whenever
// history changes (WithHistoryItem returns a new record, and a 'with' copy carries the
// old cached field over). Force the index to build on one instance, then mutate, and
// confirm each instance reports exactly its own history.
var now = new DateTimeOffset(2025, 6, 1, 0, 0, 0, TimeSpan.Zero);
var first = new RecentCommandsManager().WithHistoryItem("alpha", now);
// Next eight items (3-10 inclusive) should be in the second bucket
var weightD = history.GetCommandHistoryWeight("idD");
var weightE = history.GetCommandHistoryWeight("idE");
var weightF = history.GetCommandHistoryWeight("idF");
var weightG = history.GetCommandHistoryWeight("idG");
var weightH = history.GetCommandHistoryWeight("idH");
var weightI = history.GetCommandHistoryWeight("idI");
var weightJ = history.GetCommandHistoryWeight("idJ");
var weightK = history.GetCommandHistoryWeight("idK");
// Read once to build the cached index on 'first'.
var alphaFirst = first.GetCommandHistoryWeight("alpha", now);
Assert.IsTrue(alphaFirst > 0, "alpha should have weight on the first instance");
Assert.AreEqual(0, first.GetCommandHistoryWeight("beta", now), "beta is not in the first instance");
Assert.AreEqual(weightD, weightE, "Items D and E were used in the last 10 commands");
Assert.AreEqual(weightE, weightF, "Items E and F were used in the last 10 commands");
Assert.AreEqual(weightF, weightG, "Items F and G were used in the last 10 commands");
Assert.AreEqual(weightG, weightH, "Items G and H were used in the last 10 commands");
Assert.AreEqual(weightH, weightI, "Items H and I were used in the last 10 commands");
Assert.AreEqual(weightI, weightJ, "Items I and J were used in the last 10 commands");
Assert.AreEqual(weightJ, weightK, "Items J and K were used in the last 10 commands");
// Add a use of alpha and a brand-new beta, producing a new instance.
var second = first
.WithHistoryItem("alpha", now.AddDays(1))
.WithHistoryItem("beta", now.AddDays(1));
// Items up to the 15th should be in the third bucket
var weightL = history.GetCommandHistoryWeight("idL");
var weightM = history.GetCommandHistoryWeight("idM");
var weightN = history.GetCommandHistoryWeight("idN");
var weightO = history.GetCommandHistoryWeight("idO");
var weight15 = history.GetCommandHistoryWeight("id15");
Assert.AreEqual(weightL, weightM, "Items L and M were used in the last 15 commands");
Assert.AreEqual(weightM, weightN, "Items M and N were used in the last 15 commands");
Assert.AreEqual(weightN, weightO, "Items N and O were used in the last 15 commands");
Assert.AreEqual(weightO, weight15, "Items O and 15 were used in the last 15 commands");
// The new instance sees the newer alpha (more recent + higher use) and the new beta.
Assert.IsTrue(
second.GetCommandHistoryWeight("alpha", now.AddDays(1)) > 0,
"alpha should still resolve on the updated instance");
Assert.IsTrue(
second.GetCommandHistoryWeight("beta", now.AddDays(1)) > 0,
"beta should resolve on the updated instance after its index rebuilds");
// Items after that should be in the lowest buckets
var weight0 = history.GetCommandHistoryWeight(items[0].Id);
var weight3 = history.GetCommandHistoryWeight(items[3].Id);
var weight11 = history.GetCommandHistoryWeight(items[11].Id);
var weight16 = history.GetCommandHistoryWeight("id16");
var weight20 = history.GetCommandHistoryWeight("id20");
var weight30 = history.GetCommandHistoryWeight("id30");
var weight40 = history.GetCommandHistoryWeight("id40");
var weight49 = history.GetCommandHistoryWeight("id49");
// The original instance is unchanged - its index never gained beta.
Assert.AreEqual(0, first.GetCommandHistoryWeight("beta", now), "the original instance must not see beta");
}
Assert.IsTrue(weight0 > weight3);
Assert.IsTrue(weight3 > weight11);
Assert.IsTrue(weight11 > weight16);
[TestMethod]
public void ValidateHistoryCap()
{
// Insert well beyond the cap, each newer than the last. The store keeps the most-recent
// entries and evicts the oldest, replacing the previous 50-entry limit.
var now = new DateTimeOffset(2025, 2, 1, 0, 0, 0, TimeSpan.Zero);
var history = new RecentCommandsManager();
Assert.AreEqual(weight16, weight20);
Assert.AreEqual(weight20, weight30);
Assert.IsTrue(weight30 > weight40);
Assert.AreEqual(weight40, weight49);
var total = RecentCommandsManager.MaxHistoryEntries + 50;
for (var i = 0; i < total; i++)
{
history = history.WithHistoryItem($"cmd-{i}", now.AddMinutes(i));
}
// The 50th item has fallen out of the list now
var weight50 = history.GetCommandHistoryWeight("id50");
Assert.AreEqual(0, weight50, "Item 50 should have fallen out of the history list");
var evaluatedAt = now.AddMinutes(total);
Assert.IsTrue(
history.History.Count <= RecentCommandsManager.MaxHistoryEntries,
"History should be capped at MaxHistoryEntries");
// The earliest (now-evicted) entries have fallen out of the store.
Assert.AreEqual(0, history.GetCommandHistoryWeight("cmd-0", evaluatedAt), "The oldest entry should have been evicted");
// The most-recent entries survive and still carry weight.
Assert.IsTrue(
history.GetCommandHistoryWeight($"cmd-{total - 1}", evaluatedAt) > 0,
"The most recent entry should be retained");
}
[TestMethod]
public void ValidateLegacyHistoryMigration()
{
// Legacy items persisted before LastUsed existed deserialize with a default timestamp.
// They should be mildly backdated so ordering falls back to Uses (frequency) rather
// than collapsing to all-equal or zero.
var now = new DateTimeOffset(2025, 5, 1, 0, 0, 0, TimeSpan.Zero);
var legacy = new RecentCommandsManager
{
History = ImmutableList.Create(
new HistoryItem { CommandId = "rare", Uses = 1 },
new HistoryItem { CommandId = "common", Uses = 8 }),
};
var rare = legacy.GetCommandHistoryWeight("rare", now);
var common = legacy.GetCommandHistoryWeight("common", now);
Assert.IsTrue(rare > 0, "Legacy items should be mildly backdated, not zeroed out");
Assert.IsTrue(common > rare, "Legacy ordering should fall back to Uses (frequency)");
// A brand-new, single real use should outrank a backdated single-use legacy item.
var withFresh = legacy.WithHistoryItem("brand-new", now);
var fresh = withFresh.GetCommandHistoryWeight("brand-new", now);
Assert.IsTrue(fresh > rare, "A just-used command should outrank a backdated single-use legacy item");
}
[TestMethod]
public void ValidateHistorySerializationRoundTrips()
{
// The persisted history (see SettingsModel's JsonSerializable context) must round-trip,
// including the new LastUsed timestamp, so decay survives a save/load cycle.
var now = new DateTimeOffset(2025, 3, 15, 12, 30, 0, TimeSpan.Zero);
var original = new RecentCommandsManager()
.WithHistoryItem("alpha", now)
.WithHistoryItem("beta", now.AddMinutes(5));
var json = JsonSerializer.Serialize(original, JsonSerializationContext.Default.RecentCommandsManager);
var restored = JsonSerializer.Deserialize(json, JsonSerializationContext.Default.RecentCommandsManager);
Assert.IsNotNull(restored, "Round-tripped history should not be null");
Assert.AreEqual(2, restored!.History.Count, "All history entries should round-trip");
var beta = restored.History.First(h => h.CommandId == "beta");
Assert.AreEqual(now.AddMinutes(5), beta.LastUsed, "The LastUsed timestamp should round-trip");
Assert.AreEqual(1, beta.Uses, "The use count should round-trip");
// Weights computed from the restored state should match the original.
Assert.AreEqual(
original.GetCommandHistoryWeight("beta", now.AddMinutes(5)),
restored.GetCommandHistoryWeight("beta", now.AddMinutes(5)),
"Restored history should produce the same weight as the original");
}
[TestMethod]

View File

@@ -0,0 +1,284 @@
// 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;
using System.Collections.Generic;
using System.Linq;
using Microsoft.CmdPal.Common.Text;
using Microsoft.CmdPal.Ext.UnitTestBase;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.MainPage;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using WyHash;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
/// <summary>
/// End-to-end relevance harness for the main/root page ranker. Every case is expressed as a
/// realistic query paired with an ordering constraint (rank-1, or "X must rank above Y") and
/// asserted against the REAL <see cref="MainListPage.ScoreTopLevelItem"/> scoring path, sorted
/// exactly the way the product sorts (positive scores, descending). The intent is to lock in
/// "results seem logical and relevant" as an objective, extendable yardstick: to add a new
/// scenario, drop another entry in the fixture and another constraint in a test.
///
/// Focused, per-tier unit tests for the <see cref="MainListRanker"/> primitives live alongside
/// this harness in <see cref="MainListRankerTests"/>.
/// </summary>
[TestClass]
public partial class RelevanceHarnessTests : CommandPaletteUnitTestBase
{
// A lightweight top-level-command / installed-app stand-in built on the real ListItem
// toolkit type, so the harness drives the same scoring path as the product (as the sibling
// RecentCommandsTests does) rather than a reimplementation. ProviderId lets a test key a
// per-provider weight lookup; Id is derived deterministically so frecency history can target it.
private sealed partial class ListItemMock : ListItem
{
public ListItemMock(string title, string? subtitle = "", string? givenId = "", string? providerId = "")
{
Title = title;
Subtitle = subtitle ?? string.Empty;
ProviderId = providerId ?? string.Empty;
Id = string.IsNullOrEmpty(givenId) ? GenerateId() : givenId;
Command = new NoOpCommand() { Id = Id };
}
public string Id { get; }
public string ProviderId { get; }
private string GenerateId()
{
var result = WyHash64.ComputeHash64(ProviderId + Title + Subtitle, seed: 0);
return $"{ProviderId}{result}";
}
}
private static IPrecomputedFuzzyMatcher CreateMatcher() =>
new PrecomputedFuzzyMatcher(new PrecomputedFuzzyMatcherOptions());
private static RecentCommandsManager EmptyHistory() => new();
// A representative slice of the main page: installed apps + top-level commands with
// realistic titles, subtitles and shared prefixes/acronyms. Deliberately includes the
// "confusable" clusters users complain about (Calc*, Visual Studio *, Command Prompt vs
// Control Panel) so the ordering constraints below have real competition to beat.
private static List<ListItemMock> Fixture() => new()
{
new("Command Prompt", "Run the classic command interpreter"),
new("Control Panel", "Adjust your computer's settings"),
new("Calculator", "Perform calculations"),
new("Calendar", "View your schedule"),
new("Visual Studio Code", "Code editing. Redefined."),
new("Visual Studio 2022", "Full-featured IDE"),
new("Windows Settings", "Change PC settings"),
new("Windows Terminal", "Modern terminal for command-line tools"),
new("Task Manager", "Monitor apps and processes"),
new("Notepad", "A simple text editor"),
new("Microsoft Edge", "Browse the web"),
new("Paint", "Draw and edit images"),
new("Paint 3D", "Create in three dimensions"),
};
// Scores every fixture item for a query through the real product scorer and returns the
// matched titles in the exact order the product would render them: positive scores only,
// sorted descending. Mirrors InternalListHelpers.FilterListWithScores and the existing
// RecentCommandsTests.GetMatches helper.
private static List<string> Rank(
string query,
IEnumerable<ListItemMock> items,
IRecentCommandsManager? history = null,
Func<IListItem, ProviderSearchWeight>? providerWeightLookup = null)
{
var matcher = CreateMatcher();
var q = matcher.PrecomputeQuery(query);
history ??= EmptyHistory();
return items
.Select(item => (item.Title, Score: MainListPage.ScoreTopLevelItem(q, item, history, matcher, providerWeightLookup)))
.Where(x => x.Score > 0)
.OrderByDescending(x => x.Score)
.Select(x => x.Title)
.ToList();
}
private static void AssertRank1(
string query,
string expectedTitle,
IRecentCommandsManager? history = null,
Func<IListItem, ProviderSearchWeight>? providerWeightLookup = null)
{
var ranked = Rank(query, Fixture(), history, providerWeightLookup);
Assert.IsTrue(ranked.Count > 0, $"Query '{query}' should return at least one match");
Assert.AreEqual(
expectedTitle,
ranked[0],
$"Query '{query}' should surface '{expectedTitle}' at rank 1. Actual order: [{string.Join(", ", ranked)}]");
}
private static void AssertRanksAbove(
string query,
string higher,
string lower,
IRecentCommandsManager? history = null,
Func<IListItem, ProviderSearchWeight>? providerWeightLookup = null)
{
var ranked = Rank(query, Fixture(), history, providerWeightLookup);
var higherIndex = ranked.IndexOf(higher);
var lowerIndex = ranked.IndexOf(lower);
Assert.IsTrue(higherIndex >= 0, $"Query '{query}' should match '{higher}'. Actual order: [{string.Join(", ", ranked)}]");
Assert.IsTrue(lowerIndex >= 0, $"Query '{query}' should match '{lower}'. Actual order: [{string.Join(", ", ranked)}]");
Assert.IsTrue(
higherIndex < lowerIndex,
$"Query '{query}' should rank '{higher}' above '{lower}'. Actual order: [{string.Join(", ", ranked)}]");
}
// End-to-end cases: the tier ladder, exercised through the real scorer.
[TestMethod]
public void EndToEnd_ExactTitleBeatsPrefix()
{
// "Paint" is an exact title; "Paint 3D" only has it as a prefix. Exact must win.
AssertRank1("paint", "Paint");
AssertRanksAbove("paint", "Paint", "Paint 3D");
}
[TestMethod]
public void EndToEnd_PrefixBeatsWordBoundary()
{
// "co" is a title prefix of Command Prompt and Control Panel, but only a word-boundary
// match for "Code" inside Visual Studio Code. Prefix outranks word-boundary.
AssertRanksAbove("co", "Command Prompt", "Visual Studio Code");
AssertRanksAbove("co", "Control Panel", "Visual Studio Code");
}
[TestMethod]
public void EndToEnd_WordBoundaryBeatsFuzzy()
{
// "man" starts the word "Manager" in Task Manager (word-boundary), but is only a loose
// subsequence (m..a..n) of "Command Prompt" (fuzzy). Word-boundary must win.
AssertRank1("man", "Task Manager");
AssertRanksAbove("man", "Task Manager", "Command Prompt");
}
[TestMethod]
public void EndToEnd_AcronymSurfacesTheRightApp()
{
// "vsc" is the acronym of Visual Studio Code (V-S-C); Visual Studio 2022 (V-S-2) is not
// a match. The acronym should surface the obviously-right app at rank 1.
AssertRank1("vsc", "Visual Studio Code");
}
[TestMethod]
public void EndToEnd_ComplaintCase_SingleLetterSurfacesFrecentApp()
{
// "c" prefixes several apps (Calculator, Calendar, Command Prompt, Control Panel). With
// no signal they tie; a user who keeps opening Calculator should see it at rank 1. This
// is the canonical "the thing I want is buried" complaint, fixed by within-tier frecency.
var history = EmptyHistory();
var calculatorId = Fixture().First(i => i.Title == "Calculator").Id;
for (var i = 0; i < 5; i++)
{
history = history.WithHistoryItem(calculatorId);
}
AssertRank1("c", "Calculator", history);
}
[TestMethod]
public void EndToEnd_ComplaintCase_CodeSurfacesVsCode()
{
// Typing "code" should put Visual Studio Code first (word-boundary on "Code").
AssertRank1("code", "Visual Studio Code");
}
[TestMethod]
public void EndToEnd_ComplaintCase_SetSurfacesSettings()
{
// Typing "set" should put Windows Settings first (word-boundary on "Settings").
AssertRank1("set", "Windows Settings");
}
[TestMethod]
public void EndToEnd_FrecencyReordersWithinTierOnly()
{
// Heavy use of Visual Studio Code (a word-boundary match for "co") must NOT lift it over
// Command Prompt / Control Panel, which are prefix matches a whole tier above it.
// Frecency reorders within a tier; it can never cross a tier boundary.
var history = EmptyHistory();
var vsCodeId = Fixture().First(i => i.Title == "Visual Studio Code").Id;
for (var i = 0; i < 50; i++)
{
history = history.WithHistoryItem(vsCodeId);
}
AssertRanksAbove("co", "Command Prompt", "Visual Studio Code", history);
AssertRanksAbove("co", "Control Panel", "Visual Studio Code", history);
}
[TestMethod]
public void EndToEnd_FrecencyBreaksTieWithinTier()
{
// "vs" is an acronym match for both Visual Studio Code and Visual Studio 2022 (same
// tier). With no history they tie; the recently/repeatedly used one should climb to the
// top of the tier.
var fixture = Fixture();
var vs2022Id = fixture.First(i => i.Title == "Visual Studio 2022").Id;
var history = EmptyHistory();
for (var i = 0; i < 5; i++)
{
history = history.WithHistoryItem(vs2022Id);
}
AssertRanksAbove("vs", "Visual Studio 2022", "Visual Studio Code", history);
}
[TestMethod]
public void EndToEnd_ProviderHigherBreaksAnExactTie()
{
// Two providers surface an identically-titled "Settings" command. Everything else being
// equal (same tier, same lexical quality, no frecency), a provider marked Higher should
// sort above the Normal one. Provider weight is a within-tier nudge for near-ties only.
var alpha = new ListItemMock("Settings", "From provider Alpha", providerId: "alpha");
var bravo = new ListItemMock("Settings", "From provider Bravo", providerId: "bravo");
var items = new List<ListItemMock> { alpha, bravo };
var matcher = CreateMatcher();
var q = matcher.PrecomputeQuery("Settings");
var history = EmptyHistory();
// Baseline: with no provider weighting the two tie exactly.
var baseAlpha = MainListPage.ScoreTopLevelItem(q, alpha, history, matcher);
var baseBravo = MainListPage.ScoreTopLevelItem(q, bravo, history, matcher);
Assert.AreEqual(baseAlpha, baseBravo, "The two identically-titled items should tie before provider weighting");
Func<IListItem, ProviderSearchWeight> lookup = item =>
item is ListItemMock m && m.ProviderId == "bravo"
? ProviderSearchWeight.Higher
: ProviderSearchWeight.Normal;
var ranked = items
.Select(item => (item.ProviderId, Score: MainListPage.ScoreTopLevelItem(q, item, history, matcher, lookup)))
.OrderByDescending(x => x.Score)
.Select(x => x.ProviderId)
.ToList();
Assert.AreEqual("bravo", ranked[0], "The Higher-weighted provider should win an otherwise exact tie");
}
[TestMethod]
public void EndToEnd_ProviderWeightCannotCrossTierBoundary()
{
// Even marked Higher, a word-boundary match (Visual Studio Code for "co") must stay
// below a prefix match (Command Prompt). Provider weight is clamped within a tier.
Func<IListItem, ProviderSearchWeight> boostVsCode = item =>
item is ListItemMock m && m.Title == "Visual Studio Code"
? ProviderSearchWeight.Higher
: ProviderSearchWeight.Normal;
AssertRanksAbove("co", "Command Prompt", "Visual Studio Code", providerWeightLookup: boostVsCode);
}
}

View File

@@ -0,0 +1,252 @@
// 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;
using System.Linq;
using Microsoft.CmdPal.Common.Helpers;
using Microsoft.CmdPal.Common.Text;
using Microsoft.CmdPal.UI.ViewModels.Commands;
using Microsoft.CmdPal.UI.ViewModels.MainPage;
using Microsoft.CommandPalette.Extensions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using static Microsoft.CmdPal.UI.ViewModels.UnitTests.ScoringTestCatalog;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
/// <summary>
/// Guardrail for the throughput work: moving scoring off the TopLevelCommands lock and
/// parallelizing the apps pass may change how fast and where the settled list is computed, never
/// what it is. Across a synthetic catalog and several queries (the 1-char pathological case, the
/// extend chain, and the retype rebuild) the parallel scorer has to match the sequential one item
/// for item and score for score.
/// </summary>
[TestClass]
public sealed partial class ScoringParallelEquivalenceTests
{
// Big enough that the parallel path is actually taken across multiple partitions, small enough
// to stay fast on CI.
private const int AppCount = 4000;
private const int CommandCount = 300;
private const int HistorySeedCount = 250;
// "c" is the pathological 1-char case, the "ca"/"cal"/"calc" chain is the extend path, and the
// acronym and multi-word cases stress the tier classifier.
private static readonly string[] Queries =
["c", "ca", "cal", "calc", "vs", "vsc", "vs code", "term", "set", "e"];
public TestContext TestContext { get; set; } = null!;
private static ScoringFunction<IListItem> BuildScoringFunction(
IRecentCommandsManager history,
IPrecomputedFuzzyMatcher matcher)
=> (in FuzzyQuery query, IListItem item) =>
MainListPage.ScoreTopLevelItem(query, item, history, matcher, null);
private static void AssertOrderedResultsIdentical(
string context,
RoScored<IListItem>[] reference,
RoScored<IListItem>[] candidate)
{
Assert.AreEqual(reference.Length, candidate.Length, $"[{context}] result count must match the sequential reference.");
for (var i = 0; i < reference.Length; i++)
{
// Same packed score at the same index.
Assert.AreEqual(
reference[i].Score,
candidate[i].Score,
$"[{context}] score at index {i} must match the sequential reference.");
// Same item reference at the same index, which proves the order matches including
// tie-breaks, not just that the same scores turn up.
Assert.AreSame(
reference[i].Item,
candidate[i].Item,
$"[{context}] item at index {i} must be the exact same instance as the sequential reference.");
}
}
/// <summary>
/// The rebuild path: a fresh query scored against the whole catalog, where the parallel apps
/// pass has to match the sequential reference exactly.
/// </summary>
[TestMethod]
public void ParallelScoring_FullCatalog_MatchesSequentialForEveryQuery()
{
var apps = BuildCatalog(AppCount, "app");
var matcher = CreateMatcher();
var history = SeedHistory(apps, HistorySeedCount);
var scoringFn = BuildScoringFunction(history, matcher);
var source = apps.Cast<IListItem>().ToArray();
// Mirror the product: build the frecency index once before the parallel pass reads it.
history.PrewarmIndex();
foreach (var raw in Queries)
{
var query = matcher.PrecomputeQuery(raw);
var sequential = InternalListHelpers.FilterListWithScores(source, query, scoringFn);
var parallel = InternalListHelpers.FilterListWithScoresParallel(source, query, scoringFn);
TestContext.WriteLine($"query '{raw}': {sequential.Length} matches (sequential) vs {parallel.Length} (parallel).");
AssertOrderedResultsIdentical($"full '{raw}'", sequential, parallel);
}
}
/// <summary>
/// The extend path: score the whole catalog for a 1-char query, keep the matched subset in the
/// order it came back, then re-score that subset for the extending query.
/// </summary>
[TestMethod]
public void ParallelScoring_ExtendPath_MatchesSequentialOverRetainedSubset()
{
var apps = BuildCatalog(AppCount, "app");
var matcher = CreateMatcher();
var history = SeedHistory(apps, HistorySeedCount);
var scoringFn = BuildScoringFunction(history, matcher);
var source = apps.Cast<IListItem>().ToArray();
history.PrewarmIndex();
// Each step narrows the previous result.
var chain = new[] { "c", "ca", "cal", "calc" };
var retained = source;
for (var step = 1; step < chain.Length; step++)
{
// This is exactly what the product feeds the next keystroke: the previous result's
// items, in the previous result's order.
var prevQuery = matcher.PrecomputeQuery(chain[step - 1]);
var prev = InternalListHelpers.FilterListWithScores(retained, prevQuery, scoringFn);
retained = prev.Select(s => s.Item).ToArray();
var query = matcher.PrecomputeQuery(chain[step]);
var sequential = InternalListHelpers.FilterListWithScores(retained, query, scoringFn);
var parallel = InternalListHelpers.FilterListWithScoresParallel(retained, query, scoringFn);
TestContext.WriteLine($"extend '{chain[step - 1]}' -> '{chain[step]}': retained {retained.Length}, kept {sequential.Length}.");
AssertOrderedResultsIdentical($"extend '{chain[step - 1]}'->'{chain[step]}'", sequential, parallel);
Assert.IsTrue(sequential.Length <= retained.Length, "Extending a query cannot add matches beyond the retained set.");
}
}
/// <summary>
/// The retype path: a query that doesn't extend the previous one forces a full rebuild, so
/// check a run of unrelated queries scored fresh each time.
/// </summary>
[TestMethod]
public void ParallelScoring_RetypeRebuild_MatchesSequential()
{
var apps = BuildCatalog(AppCount, "app");
var matcher = CreateMatcher();
var history = SeedHistory(apps, HistorySeedCount);
var scoringFn = BuildScoringFunction(history, matcher);
var source = apps.Cast<IListItem>().ToArray();
history.PrewarmIndex();
// Unrelated queries (each a fresh rebuild, never an extend of the last).
foreach (var raw in new[] { "calc", "term", "vs code", "settings", "e" })
{
var query = matcher.PrecomputeQuery(raw);
var sequential = InternalListHelpers.FilterListWithScores(source, query, scoringFn);
var parallel = InternalListHelpers.FilterListWithScoresParallel(source, query, scoringFn);
AssertOrderedResultsIdentical($"retype '{raw}'", sequential, parallel);
}
}
/// <summary>
/// Commands (hundreds) stay serial, so the parallel entry point has to fall back below its
/// threshold and still match the sequential result.
/// </summary>
[TestMethod]
public void ParallelScoring_Commands_MatchesSequential()
{
var commands = BuildCatalog(CommandCount, "cmd");
var matcher = CreateMatcher();
var history = SeedHistory(commands, HistorySeedCount);
var scoringFn = BuildScoringFunction(history, matcher);
var source = commands.Cast<IListItem>().ToArray();
history.PrewarmIndex();
foreach (var raw in Queries)
{
var query = matcher.PrecomputeQuery(raw);
var sequential = InternalListHelpers.FilterListWithScores(source, query, scoringFn);
var parallel = InternalListHelpers.FilterListWithScoresParallel(source, query, scoringFn);
AssertOrderedResultsIdentical($"commands '{raw}'", sequential, parallel);
}
}
/// <summary>
/// Running the parallel scorer over the same catalog and query repeatedly gives the same
/// ordered result every time, whatever the thread scheduling does.
/// </summary>
[TestMethod]
public void ParallelScoring_IsDeterministicAcrossRuns()
{
var apps = BuildCatalog(AppCount, "app");
var matcher = CreateMatcher();
var history = SeedHistory(apps, HistorySeedCount);
var scoringFn = BuildScoringFunction(history, matcher);
var source = apps.Cast<IListItem>().ToArray();
history.PrewarmIndex();
var query = matcher.PrecomputeQuery("c");
var first = InternalListHelpers.FilterListWithScoresParallel(source, query, scoringFn);
for (var run = 0; run < 8; run++)
{
var again = InternalListHelpers.FilterListWithScoresParallel(source, query, scoringFn);
AssertOrderedResultsIdentical($"determinism run {run}", first, again);
}
}
/// <summary>
/// The hot path snapshots the frecency manager, matcher, settings and one evaluation time per
/// query, and feeds the apps pass a single constant provider weight. This proves that captured
/// context lands on the same ordered result as the old per-item live reads.
/// </summary>
[TestMethod]
public void CapturedContext_ConstantWeightAndFixedNow_MatchesPerItemLiveRead()
{
var apps = BuildCatalog(AppCount, "app");
var matcher = CreateMatcher();
var history = SeedHistory(apps, HistorySeedCount);
var source = apps.Cast<IListItem>().ToArray();
history.PrewarmIndex();
// A non-default weight, so the value has to actually flow through to the packed score.
const ProviderSearchWeight weight = ProviderSearchWeight.Higher;
Func<IListItem, ProviderSearchWeight> perItemLookup = _ => weight;
Func<IListItem, ProviderSearchWeight> constantLookup = _ => weight;
// Captured once before the loop, exactly as the product captures scoringNow. The reference
// path below omits it, so it reads the current time per call.
var capturedNow = DateTimeOffset.UtcNow;
ScoringFunction<IListItem> liveReadScorer = (in FuzzyQuery query, IListItem item) =>
MainListPage.ScoreTopLevelItem(query, item, history, matcher, perItemLookup);
ScoringFunction<IListItem> capturedContextScorer = (in FuzzyQuery query, IListItem item) =>
MainListPage.ScoreTopLevelItem(query, item, history, matcher, constantLookup, capturedNow);
foreach (var raw in Queries)
{
var query = matcher.PrecomputeQuery(raw);
var reference = InternalListHelpers.FilterListWithScores(source, query, liveReadScorer);
var candidate = InternalListHelpers.FilterListWithScoresParallel(source, query, capturedContextScorer);
TestContext.WriteLine($"captured-context '{raw}': {reference.Length} matches (per-item live read) vs {candidate.Length} (constant weight + fixed now).");
AssertOrderedResultsIdentical($"captured '{raw}'", reference, candidate);
}
}
}

View File

@@ -0,0 +1,92 @@
// 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;
using Microsoft.CmdPal.Common.Helpers;
using Microsoft.CmdPal.Common.Text;
using Microsoft.CmdPal.UI.ViewModels.Commands;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
internal static partial class ScoringTestCatalog
{
internal sealed partial class CatalogItem : ListItem, IPrecomputedListItem
{
private FuzzyTargetCache _titleCache;
private FuzzyTargetCache _subtitleCache;
internal CatalogItem(string title, string subtitle, string id)
: base(new NoOpCommand() { Id = id })
{
Title = title;
Subtitle = subtitle;
Id = id;
}
internal string Id { get; }
public FuzzyTarget GetTitleTarget(IPrecomputedFuzzyMatcher matcher) => _titleCache.GetOrUpdate(matcher, Title);
public FuzzyTarget GetSubtitleTarget(IPrecomputedFuzzyMatcher matcher) => _subtitleCache.GetOrUpdate(matcher, Subtitle);
}
private static readonly string[] Nouns =
[
"Calculator", "Calendar", "Camera", "Canvas", "Command", "Control", "Cloud", "Cast",
"Visual", "Studio", "Code", "Terminal", "Task", "Notepad", "Paint", "Photos", "Player",
"Panel", "Prompt", "Settings", "Store", "System", "Manager", "Monitor", "Editor", "Browser",
"Mail", "Maps", "Music", "Movies", "Network", "Office", "Onenote", "Outlook", "People",
];
private static readonly string[] Qualifiers =
[
string.Empty, "Pro", "2022", "3D", "Preview", "X", "Lite", "Plus", "Home", "Enterprise",
"for Windows", "Insider", "Legacy", "New", "Classic",
];
private static readonly string[] SubtitleWords =
[
"Perform calculations and conversions", "View and manage your schedule", "Edit and refine images",
"Modern terminal for command-line tools", "Full-featured integrated development environment",
"Browse the web quickly and securely", "Adjust your computer settings", "Monitor apps and processes",
"A simple and fast text editor", "Play and organize your media library",
];
internal static IPrecomputedFuzzyMatcher CreateMatcher() => new PrecomputedFuzzyMatcher(new PrecomputedFuzzyMatcherOptions());
internal static CatalogItem[] BuildCatalog(int count, string idPrefix)
{
var items = new CatalogItem[count];
for (var i = 0; i < count; i++)
{
var noun = Nouns[i % Nouns.Length];
var qualifier = Qualifiers[(i / Nouns.Length) % Qualifiers.Length];
var title = string.IsNullOrEmpty(qualifier) ? noun : $"{noun} {qualifier}";
if (i >= Nouns.Length * Qualifiers.Length)
{
title = $"{title} {i}";
}
var subtitle = SubtitleWords[i % SubtitleWords.Length];
items[i] = new CatalogItem(title, subtitle, $"{idPrefix}.{i}");
}
return items;
}
internal static RecentCommandsManager SeedHistory(CatalogItem[] apps, int seedCount)
{
var history = new RecentCommandsManager();
var n = Math.Min(seedCount, apps.Length);
for (var i = 0; i < n; i++)
{
var idx = (i * 7) % apps.Length;
history = history.WithHistoryItem(apps[idx].Id);
}
return history;
}
}

View File

@@ -0,0 +1,319 @@
// 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;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using Microsoft.CmdPal.Common.Helpers;
using Microsoft.CmdPal.Common.Text;
using Microsoft.CmdPal.UI.ViewModels.Commands;
using Microsoft.CmdPal.UI.ViewModels.MainPage;
using Microsoft.CommandPalette.Extensions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using static Microsoft.CmdPal.UI.ViewModels.UnitTests.ScoringTestCatalog;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
/// <summary>
/// Times the real per-keystroke scoring passes over a synthetic catalog and attributes the cost
/// across apps-enumeration, command scoring, app scoring (the dominant pass), and fallback
/// fold-in, plus a per-item split of <see cref="MainListPage.ScoreTopLevelItem"/> into fuzzy DP
/// vs tier classification vs frecency. Timings go to <see cref="TestContext"/> and the assertions
/// only lock structural facts, so nothing here flakes on a wall clock.
/// </summary>
[TestClass]
public sealed partial class ScoringThroughputHarnessTests
{
// Sized to mirror a heavy-but-realistic machine, and small enough that the whole harness runs
// in a couple of seconds on CI.
private const int AppCount = 3000;
private const int CommandCount = 300;
private const int GlobalFallbackCount = 5;
private const int PinnedAppCount = 20;
// Seed enough history that frecency lookups actually hit, so we measure the hit path too.
private const int HistorySeedCount = 200;
// Report-only counts: warmups prime the JIT and target caches, measured runs get averaged.
private const int WarmupIterations = 3;
private const int MeasuredIterations = 10;
// "c" is the pathological 1-char case that matches nearly every app, the rest narrow a real
// prefix, and "vsc"/"vs code" hit the acronym and word-boundary paths.
private static readonly string[] Queries = ["c", "ca", "cal", "calc", "vsc", "vs code"];
public TestContext TestContext { get; set; } = null!;
private static ScoringFunction<IListItem> BuildScoringFunction(
IRecentCommandsManager history,
IPrecomputedFuzzyMatcher matcher,
Func<IListItem, ProviderSearchWeight>? providerWeightLookup = null)
=> (in FuzzyQuery query, IListItem item) =>
MainListPage.ScoreTopLevelItem(query, item, history, matcher, providerWeightLookup);
// Averaged elapsed milliseconds after a warmup, report-only and never asserted against a
// threshold.
private static double TimeAverageMs(Action action)
{
for (var i = 0; i < WarmupIterations; i++)
{
action();
}
var sw = Stopwatch.StartNew();
for (var i = 0; i < MeasuredIterations; i++)
{
action();
}
sw.Stop();
return sw.Elapsed.TotalMilliseconds / MeasuredIterations;
}
/// <summary>
/// Attributes a full rebuild keystroke (empty to query, the worst case that scores the whole
/// catalog) across the four passes. Asserts only that the app pass outweighs the command pass
/// and that scoring is deterministic.
/// </summary>
[TestMethod]
public void FullKeystroke_AttributesCostAcrossBuckets()
{
var apps = BuildCatalog(AppCount, "app");
var commands = BuildCatalog(CommandCount, "cmd");
var globalFallbacks = BuildCatalog(GlobalFallbackCount, "gfb").Cast<IListItem>().ToList();
var matcher = CreateMatcher();
var history = SeedHistory(apps, HistorySeedCount);
var scoringFn = BuildScoringFunction(history, matcher);
// Simulate the pinned-app removal the product does on the rebuild path.
var pinnedIds = new HashSet<string>(apps.Take(PinnedAppCount).Select(a => a.Id));
TestContext.WriteLine($"Catalog: {AppCount} apps, {CommandCount} commands, {GlobalFallbackCount} global fallbacks, {HistorySeedCount} history seeds.");
TestContext.WriteLine($"Iterations: {WarmupIterations} warmup + {MeasuredIterations} measured (averaged).");
TestContext.WriteLine("query | appsEnum ms | cmdScore ms | appScore ms | fallback ms | TOTAL ms | cmdMatches | appMatches");
foreach (var raw in Queries)
{
var query = matcher.PrecomputeQuery(raw);
var enumMs = TimeAverageMs(() =>
{
// Mirrors the product's GetItems().Cast().ToList() plus the pinned filter.
var materialized = apps.ToList();
_ = materialized.Where(a => !pinnedIds.Contains(a.Id)).ToList();
});
RoScored<IListItem>[] cmdScored = [];
var cmdMs = TimeAverageMs(() =>
{
cmdScored = InternalListHelpers.FilterListWithScores(commands.Cast<IListItem>(), query, scoringFn);
});
RoScored<IListItem>[] appScored = [];
var appMs = TimeAverageMs(() =>
{
appScored = InternalListHelpers.FilterListWithScores(apps.Cast<IListItem>(), query, scoringFn);
});
var fallbackMs = TimeAverageMs(() =>
{
_ = MainListPage.ScoreDeferredFallbacks(globalFallbacks, query, scoringFn);
});
var total = enumMs + cmdMs + appMs + fallbackMs;
TestContext.WriteLine(
$"{raw,-8}| {enumMs,10:F3} | {cmdMs,10:F3} | {appMs,10:F3} | {fallbackMs,10:F3} | {total,8:F3} | {cmdScored.Length,10} | {appScored.Length,10}");
// The app pass weighs thousands of items against the command pass's hundreds, which is
// why it dominates the keystroke cost.
Assert.IsTrue(apps.Length > commands.Length, "Apps must outnumber commands in the catalog.");
// Re-scoring the same catalog with the same query yields the same result set.
var appScoredAgain = InternalListHelpers.FilterListWithScores(apps.Cast<IListItem>(), query, scoringFn);
Assert.AreEqual(appScored.Length, appScoredAgain.Length, $"App scoring must be deterministic for query '{raw}'.");
for (var i = 0; i < Math.Min(10, appScored.Length); i++)
{
Assert.AreEqual(appScored[i].Score, appScoredAgain[i].Score, $"Top-10 scores must be stable for query '{raw}'.");
}
}
}
/// <summary>
/// Characterizes why the settle time spikes: a 1-char query retains a large slice of the
/// catalog, so the next few keystrokes still re-score a big set before it narrows.
/// </summary>
[TestMethod]
public void OneCharQuery_RetainsLargeMatchSet_DrivesIncrementalCost()
{
var apps = BuildCatalog(AppCount, "app");
var matcher = CreateMatcher();
var history = SeedHistory(apps, HistorySeedCount);
var scoringFn = BuildScoringFunction(history, matcher);
var oneChar = matcher.PrecomputeQuery("c");
var firstMatches = InternalListHelpers.FilterListWithScores(apps.Cast<IListItem>(), oneChar, scoringFn);
// Extending to "ca" re-scores only the retained subset, but that subset is still large,
// which is why the spike carries across frames.
var retained = firstMatches.Select(s => s.Item).ToList();
var twoChar = matcher.PrecomputeQuery("ca");
var secondMatches = InternalListHelpers.FilterListWithScores(retained, twoChar, scoringFn);
var firstFraction = (double)firstMatches.Length / apps.Length;
TestContext.WriteLine($"1-char 'c' matches {firstMatches.Length}/{apps.Length} apps ({firstFraction:P1}); extending to 'ca' re-scores {retained.Count} and keeps {secondMatches.Length}.");
// A narrower query can only keep a subset of the wider query's matches.
Assert.IsTrue(secondMatches.Length <= retained.Count, "Extending a query cannot add matches beyond the retained set.");
Assert.IsTrue(firstMatches.Length > 0, "The 1-char query should match a non-trivial set.");
}
/// <summary>
/// Splits a single <see cref="MainListPage.ScoreTopLevelItem"/> into fuzzy DP scoring, tier
/// classification, and frecency lookup so the overhaul's added cost is visible. Asserts only
/// the direction of the extension-score delta, which holds regardless of the machine.
/// </summary>
[TestMethod]
public void PerItemScore_SubAttribution_DpVsTierVsFrecency()
{
var apps = BuildCatalog(AppCount, "app");
var matcher = CreateMatcher();
var history = SeedHistory(apps, HistorySeedCount);
var scoringFn = BuildScoringFunction(history, matcher);
// Precompute targets once, like the live cached path, so this measures matcher.Score and
// not target construction.
var titleTargets = apps.Select(a => a.GetTitleTarget(matcher)).ToArray();
var subtitleTargets = apps.Select(a => a.GetSubtitleTarget(matcher)).ToArray();
var extensionTargets = apps.Select(a => matcher.PrecomputeTarget($"{a.Title} Extension")).ToArray();
var ids = apps.Select(a => a.Id).ToArray();
TestContext.WriteLine("Per-item sub-attribution (nanoseconds/item, averaged over the app catalog):");
TestContext.WriteLine("query | full score | 2 DP | 3 DP | extDelta | classifyTier | wordBoundary | frecency");
foreach (var raw in Queries)
{
var query = matcher.PrecomputeQuery(raw);
var fullNs = PerItemNs(() =>
{
for (var i = 0; i < apps.Length; i++)
{
_ = MainListPage.ScoreTopLevelItem(query, apps[i], history, matcher, null);
}
});
var twoDpNs = PerItemNs(() =>
{
for (var i = 0; i < apps.Length; i++)
{
_ = matcher.Score(query, titleTargets[i]) + matcher.Score(query, subtitleTargets[i]);
}
});
var threeDpNs = PerItemNs(() =>
{
for (var i = 0; i < apps.Length; i++)
{
_ = matcher.Score(query, titleTargets[i]) + matcher.Score(query, subtitleTargets[i]) + matcher.Score(query, extensionTargets[i]);
}
});
var classifyNs = PerItemNs(() =>
{
for (var i = 0; i < apps.Length; i++)
{
_ = MainListRanker.ClassifyTier(query.Original, apps[i].Title, false, false, false, true);
}
});
var wordBoundaryNs = PerItemNs(() =>
{
for (var i = 0; i < apps.Length; i++)
{
_ = MainListRanker.MatchesWordBoundaryOrAcronym(apps[i].Title, query.Original.AsSpan());
}
});
var frecencyNs = PerItemNs(() =>
{
for (var i = 0; i < apps.Length; i++)
{
_ = history.GetCommandHistoryWeight(ids[i]);
}
});
var extDelta = threeDpNs - twoDpNs;
TestContext.WriteLine(
$"{raw,-8}| {fullNs,10:F1} | {twoDpNs,6:F1} | {threeDpNs,6:F1} | {extDelta,8:F1} | {classifyNs,12:F1} | {wordBoundaryNs,12:F1} | {frecencyNs,8:F1}");
// Adding a third DP score can't make the measurement cheaper.
Assert.IsTrue(threeDpNs >= twoDpNs * 0.5, "Three DP scores should not be dramatically cheaper than two; extension scoring is real added work.");
}
}
/// <summary>
/// Times the dominant apps pass serial versus parallel and reports the speedup per query.
/// Report-only: it asserts the two paths return the same match count, never a wall-clock
/// threshold.
/// </summary>
[TestMethod]
public void AppScoring_BeforeAfter_SerialVsParallelThroughput()
{
var apps = BuildCatalog(AppCount, "app");
var matcher = CreateMatcher();
var history = SeedHistory(apps, HistorySeedCount);
var scoringFn = BuildScoringFunction(history, matcher);
var source = apps.Cast<IListItem>().ToArray();
// Build the frecency index once, single-threaded, before the parallel pass reads it.
history.PrewarmIndex();
TestContext.WriteLine($"CPU count: {Environment.ProcessorCount}. Catalog: {AppCount} apps.");
TestContext.WriteLine("query | serial ms (before) | parallel ms (after) | speedup | matches");
foreach (var raw in Queries)
{
var query = matcher.PrecomputeQuery(raw);
RoScored<IListItem>[] serialResult = [];
var serialMs = TimeAverageMs(() =>
{
serialResult = InternalListHelpers.FilterListWithScores(source, query, scoringFn);
});
RoScored<IListItem>[] parallelResult = [];
var parallelMs = TimeAverageMs(() =>
{
parallelResult = InternalListHelpers.FilterListWithScoresParallel(source, query, scoringFn);
});
var speedup = parallelMs > 0 ? serialMs / parallelMs : 0.0;
TestContext.WriteLine(
$"{raw,-8}| {serialMs,17:F3} | {parallelMs,18:F3} | {speedup,6:F2}x | {serialResult.Length,7}");
// The parallel path returns the same match count, on any machine.
Assert.AreEqual(serialResult.Length, parallelResult.Length, $"Match count must match for query '{raw}'.");
}
}
// Averaged per-item nanoseconds for a loop that internally iterates the whole app catalog once.
private static double PerItemNs(Action loopOverCatalog)
{
for (var i = 0; i < WarmupIterations; i++)
{
loopOverCatalog();
}
var sw = Stopwatch.StartNew();
for (var i = 0; i < MeasuredIterations; i++)
{
loopOverCatalog();
}
sw.Stop();
var totalItems = (double)MeasuredIterations * AppCount;
return sw.Elapsed.TotalMilliseconds * 1_000_000.0 / totalItems;
}
}

View File

@@ -0,0 +1,198 @@
// 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;
using System.Collections.Generic;
using System.Linq;
using Microsoft.CmdPal.Common.Helpers;
using Microsoft.CmdPal.UI.ViewModels.MainPage;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Windows.Foundation;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
/// <summary>
/// Tests for the opt-in, privacy-safe main-page search telemetry payload builders. These assert
/// that only non-identifying aggregates are captured (query LENGTH, result count, selected rank,
/// ranker tier) and never the raw query text or item content. The actual telemetry sink is not
/// exercised - only the payload-building logic.
/// </summary>
[TestClass]
public partial class SearchTelemetryTests
{
private sealed partial class MockListItem : IListItem
{
public string Title { get; set; } = string.Empty;
public string Subtitle { get; set; } = string.Empty;
public ICommand Command => new NoOpCommand();
public IDetails? Details => null;
public IIconInfo? Icon => null;
public string Section => throw new NotImplementedException();
public ITag[] Tags => throw new NotImplementedException();
public string TextToSuggest => throw new NotImplementedException();
public IContextItem[] MoreCommands => throw new NotImplementedException();
#pragma warning disable CS0067 // The event is never used
public event TypedEventHandler<object, IPropChangedEventArgs>? PropChanged;
#pragma warning restore CS0067 // The event is never used
public override string ToString() => Title;
}
private static RoScored<IListItem> Scored(IListItem item, int score) => new(item, score);
[TestMethod]
public void SearchResultsMessage_CapturesQueryLengthNotText()
{
const string query = "hello world";
var message = MainListPageSearchTelemetry.BuildSearchResultsMessage(query, resultCount: 4, latencyMs: 12);
Assert.AreEqual(query.Length, message.QueryLength);
Assert.AreEqual(4, message.ResultCount);
Assert.IsFalse(message.NoResults);
Assert.AreEqual(12UL, message.LatencyMs);
}
[TestMethod]
public void SearchResultsMessage_SetsNoResultsFlagWhenCountIsZero()
{
var noResults = MainListPageSearchTelemetry.BuildSearchResultsMessage("abc", resultCount: 0, latencyMs: 5);
Assert.IsTrue(noResults.NoResults);
Assert.AreEqual(0, noResults.ResultCount);
var hasResults = MainListPageSearchTelemetry.BuildSearchResultsMessage("abc", resultCount: 3, latencyMs: 5);
Assert.IsFalse(hasResults.NoResults);
}
[TestMethod]
public void SearchResultsMessage_ClampsNegativeInputs()
{
var message = MainListPageSearchTelemetry.BuildSearchResultsMessage(queryLength: -3, resultCount: -1, latencyMs: -100);
Assert.AreEqual(0, message.QueryLength);
Assert.AreEqual(0, message.ResultCount);
Assert.IsTrue(message.NoResults);
Assert.AreEqual(0UL, message.LatencyMs);
}
[TestMethod]
public void SearchResultsMessage_HasNoStringFields()
{
// A raw query string can only be captured through a string member. Assert there is none,
// so the payload provably cannot carry the query text.
var stringProperties = typeof(TelemetrySearchResultsMessage)
.GetProperties()
.Where(p => p.PropertyType == typeof(string))
.ToList();
Assert.AreEqual(0, stringProperties.Count, "Search results telemetry must not carry any string (potential query text).");
}
[TestMethod]
public void SelectedMessage_CapturesQueryLengthIndexAndTier()
{
const string query = "code";
var message = MainListPageSearchTelemetry.BuildSearchSelectedMessage(query, selectedIndex: 2, selectedTier: RankTier.Prefix);
Assert.AreEqual(query.Length, message.QueryLength);
Assert.AreEqual(2, message.SelectedIndex);
Assert.AreEqual(RankTier.Prefix, message.SelectedTier);
}
[TestMethod]
public void SelectedMessage_HasNoStringFields()
{
var stringProperties = typeof(TelemetrySearchResultSelectedMessage)
.GetProperties()
.Where(p => p.PropertyType == typeof(string))
.ToList();
Assert.AreEqual(0, stringProperties.Count, "Selection telemetry must not carry any string (potential query text or item title).");
}
[TestMethod]
public void ResolveSelectedTier_DerivesTierFromPackedScore()
{
var exact = new MockListItem { Title = "Visual Studio" };
var fuzzy = new MockListItem { Title = "Notepad" };
var packed = new List<RoScored<IListItem>>
{
Scored(exact, MainListRanker.Pack(RankTier.ExactTitle, withinTierScore: 500)),
Scored(fuzzy, MainListRanker.Pack(RankTier.Fuzzy, withinTierScore: 10)),
};
Assert.AreEqual(RankTier.ExactTitle, MainListPageSearchTelemetry.ResolveSelectedTier(exact, packed, fallbackResults: null));
Assert.AreEqual(RankTier.Fuzzy, MainListPageSearchTelemetry.ResolveSelectedTier(fuzzy, packed, fallbackResults: null));
}
[TestMethod]
public void ResolveSelectedTier_ReportsFallbackFloorForCommonFallbacks()
{
var fallback = new MockListItem { Title = "Search the web" };
// Common fallbacks carry small rank-based (non-packed) scores. They must be reported at the
// fallback floor rather than being decoded as a packed tier.
var fallbacks = new List<RoScored<IListItem>> { Scored(fallback, 3) };
Assert.AreEqual(RankTier.FallbackFloor, MainListPageSearchTelemetry.ResolveSelectedTier(fallback, packedResults: null, fallbacks));
}
[TestMethod]
public void ResolveSelectedTier_ReturnsNoneWhenItemNotFound()
{
var known = new MockListItem { Title = "Known" };
var unknown = new MockListItem { Title = "Unknown" };
var packed = new List<RoScored<IListItem>> { Scored(known, MainListRanker.Pack(RankTier.Prefix, 1)) };
Assert.AreEqual(RankTier.None, MainListPageSearchTelemetry.ResolveSelectedTier(unknown, packed, fallbackResults: null));
}
[TestMethod]
public void ResolveVisibleIndex_SkipsSeparatorsAndReturnsVisibleRank()
{
var resultsSeparator = new Separator("Results");
var fallbacksSeparator = new Separator("Fallbacks");
var a = new MockListItem { Title = "A" };
var b = new MockListItem { Title = "B" };
var c = new MockListItem { Title = "C" };
var missing = new MockListItem { Title = "Missing" };
var rendered = new IListItem[] { resultsSeparator, a, b, fallbacksSeparator, c };
Assert.AreEqual(0, MainListPageSearchTelemetry.ResolveVisibleIndex(rendered, a, resultsSeparator, fallbacksSeparator));
Assert.AreEqual(1, MainListPageSearchTelemetry.ResolveVisibleIndex(rendered, b, resultsSeparator, fallbacksSeparator));
Assert.AreEqual(2, MainListPageSearchTelemetry.ResolveVisibleIndex(rendered, c, resultsSeparator, fallbacksSeparator));
Assert.AreEqual(-1, MainListPageSearchTelemetry.ResolveVisibleIndex(rendered, missing, resultsSeparator, fallbacksSeparator));
Assert.AreEqual(-1, MainListPageSearchTelemetry.ResolveVisibleIndex(null, a, resultsSeparator, fallbacksSeparator));
}
[TestMethod]
public void TierOf_RoundTripsEveryPackedTier()
{
foreach (RankTier tier in Enum.GetValues<RankTier>())
{
if (tier == RankTier.None)
{
continue;
}
var packed = MainListRanker.Pack(tier, withinTierScore: 42);
Assert.AreEqual(tier, MainListRanker.TierOf(packed), $"TierOf should round-trip {tier}.");
}
}
}