Skip to content

Commit b2cd27d

Browse files
[CmdPal] Address review: O(1) frecency lookup, clearer migration comment
- Index history by commandId so GetCommandHistoryWeight is O(1) instead of an O(N) linear scan. ScoreTopLevelItem calls it for every candidate on every keystroke, and the store now holds up to 500 entries, so the previous scan cost scaled with the larger cap. The cached lookup is rebuilt lazily and invalidated whenever History is (re)assigned, and is excluded from JSON. - Reword the LegacyBackdate documentation to match reality: because earlier builds never actually persisted history, the backdate is defensive handling for any timestamp-less item rather than a common upgrade path. - Add a test that locks in index invalidation across WithHistoryItem copies. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent a10b5c1 commit b2cd27d

2 files changed

Lines changed: 83 additions & 9 deletions

File tree

src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/RecentCommandsManager.cs

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,26 @@ public record RecentCommandsManager : IRecentCommandsManager
3434
// evicted still-useful history within a single busy session on active machines.
3535
internal const int MaxHistoryEntries = 500;
3636

37-
// Legacy history items (persisted before LastUsed existed) deserialize with a default
38-
// timestamp. Treat them as used one day ago: recent enough that they still rank, mild
39-
// enough that a single fresh use outranks them, and uniform so their relative order falls
40-
// back to Uses (frequency) instead of collapsing to all-equal or zero.
37+
// Defensive fallback for any history item that carries a default (missing) LastUsed - for
38+
// example a value constructed without a timestamp, or state written by a build that predates
39+
// the LastUsed field. Such an item is treated as used one day ago: recent enough that it
40+
// still ranks, mild enough that a single fresh use outranks it, and uniform so a group of
41+
// them falls back to Uses (frequency) ordering instead of collapsing to all-equal or zero.
42+
// In practice this rarely fires: earlier builds never actually persisted history (the
43+
// internal History property was dropped by the serializer, see [JsonInclude] below), so
44+
// upgrading users start from an empty store rather than one full of timestamp-less items.
4145
internal static readonly TimeSpan LegacyBackdate = TimeSpan.FromDays(1);
4246

4347
private ImmutableList<HistoryItem>? _history = ImmutableList<HistoryItem>.Empty;
4448

49+
// Cached commandId -> entry lookup over History, rebuilt lazily whenever History is
50+
// (re)assigned and never persisted. ScoreTopLevelItem calls GetCommandHistoryWeight for
51+
// every candidate item on every keystroke, and History can hold up to MaxHistoryEntries
52+
// (500) entries, so a plain linear scan would be O(items x history) per keystroke. The
53+
// dictionary keeps each lookup O(1) so the hot path stays cheap as the store grows.
54+
[JsonIgnore]
55+
private Dictionary<string, HistoryItem>? _index;
56+
4557
// Persisted so recent-command frecency (including the LastUsed timestamps) survives a
4658
// restart. [JsonInclude] is required because the property is internal; without it the
4759
// source-generated serializer emits an empty object and history is silently dropped.
@@ -50,7 +62,38 @@ public record RecentCommandsManager : IRecentCommandsManager
5062
internal ImmutableList<HistoryItem> History
5163
{
5264
get => _history ?? ImmutableList<HistoryItem>.Empty;
53-
init => _history = value;
65+
init
66+
{
67+
_history = value;
68+
69+
// Invalidate the cached lookup so it is rebuilt from the new list on next use.
70+
// A record 'with' copy carries over the old field, so this reset is what keeps
71+
// the index from going stale after History changes.
72+
_index = null;
73+
}
74+
}
75+
76+
private Dictionary<string, HistoryItem> Index
77+
{
78+
get
79+
{
80+
if (_index is null)
81+
{
82+
// Ordinal to match the string '==' comparison the previous linear scan used.
83+
var map = new Dictionary<string, HistoryItem>(StringComparer.Ordinal);
84+
foreach (var item in History)
85+
{
86+
// History is most-recent-first and command ids are unique in it, but keep
87+
// the first (most recent) occurrence if a duplicate ever slips in so the
88+
// lookup matches the old FirstOrDefault behavior.
89+
map.TryAdd(item.CommandId, item);
90+
}
91+
92+
_index = map;
93+
}
94+
95+
return _index;
96+
}
5497
}
5598

5699
public RecentCommandsManager()
@@ -68,14 +111,13 @@ public int GetCommandHistoryWeight(string commandId)
68111
/// </summary>
69112
internal int GetCommandHistoryWeight(string commandId, DateTimeOffset now)
70113
{
71-
var entry = History.FirstOrDefault(item => item.CommandId == commandId);
72-
if (entry is null)
114+
if (!Index.TryGetValue(commandId, out var entry))
73115
{
74116
return 0;
75117
}
76118

77-
// Migrate legacy entries (no timestamp) to a mild backdate so they degrade to
78-
// Uses-ordering instead of appearing brand-new or invisible.
119+
// Migrate items with a default (missing) timestamp to a mild backdate so they degrade
120+
// to Uses-ordering instead of appearing brand-new or invisible. See LegacyBackdate.
79121
var lastUsed = entry.LastUsed == default ? now - LegacyBackdate : entry.LastUsed;
80122

81123
// Clamp age at zero so a slightly-future timestamp (e.g. clock skew) can't amplify.

src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/RecentCommandsTests.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,38 @@ public void ValidateFrequencyWeighting()
202202
Assert.IsTrue(many <= RecentCommandsManager.MaxWeight, "Weight stays within the documented cap");
203203
}
204204

205+
[TestMethod]
206+
public void ValidateLookupIndexStaysConsistentAcrossChanges()
207+
{
208+
// The commandId lookup is backed by a cached index that must be invalidated whenever
209+
// history changes (WithHistoryItem returns a new record, and a 'with' copy carries the
210+
// old cached field over). Force the index to build on one instance, then mutate, and
211+
// confirm each instance reports exactly its own history.
212+
var now = new DateTimeOffset(2025, 6, 1, 0, 0, 0, TimeSpan.Zero);
213+
var first = new RecentCommandsManager().WithHistoryItem("alpha", now);
214+
215+
// Read once to build the cached index on 'first'.
216+
var alphaFirst = first.GetCommandHistoryWeight("alpha", now);
217+
Assert.IsTrue(alphaFirst > 0, "alpha should have weight on the first instance");
218+
Assert.AreEqual(0, first.GetCommandHistoryWeight("beta", now), "beta is not in the first instance");
219+
220+
// Add a use of alpha and a brand-new beta, producing a new instance.
221+
var second = first
222+
.WithHistoryItem("alpha", now.AddDays(1))
223+
.WithHistoryItem("beta", now.AddDays(1));
224+
225+
// The new instance sees the newer alpha (more recent + higher use) and the new beta.
226+
Assert.IsTrue(
227+
second.GetCommandHistoryWeight("alpha", now.AddDays(1)) > 0,
228+
"alpha should still resolve on the updated instance");
229+
Assert.IsTrue(
230+
second.GetCommandHistoryWeight("beta", now.AddDays(1)) > 0,
231+
"beta should resolve on the updated instance after its index rebuilds");
232+
233+
// The original instance is unchanged - its index never gained beta.
234+
Assert.AreEqual(0, first.GetCommandHistoryWeight("beta", now), "the original instance must not see beta");
235+
}
236+
205237
[TestMethod]
206238
public void ValidateHistoryCap()
207239
{

0 commit comments

Comments
 (0)