Cache derived properties in MessageIndex to avoid repeated group traversals

Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-12 23:48:04 +00:00
Unverified
parent 24001ef15b
commit dbbfceebd9
3 changed files with 290 additions and 19 deletions
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
@@ -100,6 +101,14 @@ public sealed class CompactionMessageGroup
/// </remarks>
public int? TurnIndex { get; }
private bool _isExcluded;
/// <summary>
/// An optional callback invoked when <see cref="IsExcluded"/> changes value.
/// Used internally by <see cref="CompactionMessageIndex"/> to invalidate cached aggregates.
/// </summary>
internal Action? ExclusionChanged;
/// <summary>
/// Gets or sets a value indicating whether this group is excluded from the projected message list.
/// </summary>
@@ -107,7 +116,18 @@ public sealed class CompactionMessageGroup
/// Excluded groups are preserved in the collection for diagnostics or storage purposes
/// but are not included when calling <see cref="CompactionMessageIndex.GetIncludedMessages"/>.
/// </remarks>
public bool IsExcluded { get; set; }
public bool IsExcluded
{
get => _isExcluded;
set
{
if (_isExcluded != value)
{
_isExcluded = value;
ExclusionChanged?.Invoke();
}
}
}
/// <summary>
/// Gets or sets an optional reason explaining why this group was excluded.
@@ -27,6 +27,20 @@ public sealed class CompactionMessageIndex
private int _currentTurn;
private ChatMessage? _lastProcessedMessage;
// Cached values for derived properties — invalidated whenever groups are added/removed
// or a group's IsExcluded state changes.
private int? _cachedTotalMessageCount;
private int? _cachedTotalByteCount;
private int? _cachedTotalTokenCount;
private int? _cachedIncludedGroupCount;
private int? _cachedIncludedMessageCount;
private int? _cachedIncludedByteCount;
private int? _cachedIncludedTokenCount;
private int? _cachedTotalTurnCount;
private int? _cachedIncludedTurnCount;
private int? _cachedIncludedNonSystemGroupCount;
private int? _cachedRawMessageCount;
/// <summary>
/// Gets the list of message groups in this collection.
/// </summary>
@@ -47,6 +61,12 @@ public sealed class CompactionMessageIndex
this.Groups = Throw.IfNull(groups, nameof(groups));
this.Tokenizer = tokenizer;
// Register all pre-existing groups so that IsExcluded changes invalidate the cache.
for (int i = 0; i < groups.Count; i++)
{
this.RegisterGroup(groups[i]);
}
// Restore turn counter and last processed message from the groups
for (int index = groups.Count - 1; index >= 0; --index)
{
@@ -123,6 +143,7 @@ public sealed class CompactionMessageIndex
this.Groups.Clear();
this._currentTurn = 0;
this._lastProcessedMessage = null;
this.InvalidateCache();
return;
}
@@ -184,13 +205,13 @@ public sealed class CompactionMessageIndex
if (message.Role == ChatRole.System)
{
// System messages are not part of any turn
this.Groups.Add(CreateGroup(CompactionGroupKind.System, [message], this.Tokenizer, turnIndex: null));
this.AddAndRegisterGroup(CreateGroup(CompactionGroupKind.System, [message], this.Tokenizer, turnIndex: null));
index++;
}
else if (message.Role == ChatRole.User)
{
this._currentTurn++;
this.Groups.Add(CreateGroup(CompactionGroupKind.User, [message], this.Tokenizer, this._currentTurn));
this.AddAndRegisterGroup(CreateGroup(CompactionGroupKind.User, [message], this.Tokenizer, this._currentTurn));
index++;
}
else if (message.Role == ChatRole.Assistant && HasToolCalls(message))
@@ -207,11 +228,11 @@ public sealed class CompactionMessageIndex
index++;
}
this.Groups.Add(CreateGroup(CompactionGroupKind.ToolCall, groupMessages, this.Tokenizer, this._currentTurn));
this.AddAndRegisterGroup(CreateGroup(CompactionGroupKind.ToolCall, groupMessages, this.Tokenizer, this._currentTurn));
}
else if (message.Role == ChatRole.Assistant && IsSummaryMessage(message))
{
this.Groups.Add(CreateGroup(CompactionGroupKind.Summary, [message], this.Tokenizer, this._currentTurn));
this.AddAndRegisterGroup(CreateGroup(CompactionGroupKind.Summary, [message], this.Tokenizer, this._currentTurn));
index++;
}
else if (message.Role == ChatRole.Assistant && HasOnlyReasoning(message))
@@ -247,17 +268,17 @@ public sealed class CompactionMessageIndex
index++;
}
this.Groups.Add(CreateGroup(CompactionGroupKind.ToolCall, groupMessages, this.Tokenizer, this._currentTurn));
this.AddAndRegisterGroup(CreateGroup(CompactionGroupKind.ToolCall, groupMessages, this.Tokenizer, this._currentTurn));
}
else
{
this.Groups.Add(CreateGroup(CompactionGroupKind.AssistantText, [message], this.Tokenizer, this._currentTurn));
this.AddAndRegisterGroup(CreateGroup(CompactionGroupKind.AssistantText, [message], this.Tokenizer, this._currentTurn));
index++;
}
}
else
{
this.Groups.Add(CreateGroup(CompactionGroupKind.AssistantText, [message], this.Tokenizer, this._currentTurn));
this.AddAndRegisterGroup(CreateGroup(CompactionGroupKind.AssistantText, [message], this.Tokenizer, this._currentTurn));
index++;
}
}
@@ -266,6 +287,8 @@ public sealed class CompactionMessageIndex
{
this._lastProcessedMessage = messages[^1];
}
this.InvalidateCache();
}
/// <summary>
@@ -281,6 +304,8 @@ public sealed class CompactionMessageIndex
{
CompactionMessageGroup group = CreateGroup(kind, messages, this.Tokenizer, turnIndex);
this.Groups.Insert(index, group);
this.RegisterGroup(group);
this.InvalidateCache();
return group;
}
@@ -296,6 +321,8 @@ public sealed class CompactionMessageIndex
{
CompactionMessageGroup group = CreateGroup(kind, messages, this.Tokenizer, turnIndex);
this.Groups.Add(group);
this.RegisterGroup(group);
this.InvalidateCache();
return group;
}
@@ -320,57 +347,57 @@ public sealed class CompactionMessageIndex
/// <summary>
/// Gets the total number of messages across all groups, including excluded ones.
/// </summary>
public int TotalMessageCount => this.Groups.Sum(group => group.MessageCount);
public int TotalMessageCount => _cachedTotalMessageCount ??= this.Groups.Sum(group => group.MessageCount);
/// <summary>
/// Gets the total UTF-8 byte count across all groups, including excluded ones.
/// </summary>
public int TotalByteCount => this.Groups.Sum(group => group.ByteCount);
public int TotalByteCount => _cachedTotalByteCount ??= this.Groups.Sum(group => group.ByteCount);
/// <summary>
/// Gets the total token count across all groups, including excluded ones.
/// </summary>
public int TotalTokenCount => this.Groups.Sum(group => group.TokenCount);
public int TotalTokenCount => _cachedTotalTokenCount ??= this.Groups.Sum(group => group.TokenCount);
/// <summary>
/// Gets the total number of groups that are not excluded.
/// </summary>
public int IncludedGroupCount => this.Groups.Count(group => !group.IsExcluded);
public int IncludedGroupCount => _cachedIncludedGroupCount ??= this.Groups.Count(group => !group.IsExcluded);
/// <summary>
/// Gets the total number of messages across all included (non-excluded) groups.
/// </summary>
public int IncludedMessageCount => this.Groups.Where(group => !group.IsExcluded).Sum(group => group.MessageCount);
public int IncludedMessageCount => _cachedIncludedMessageCount ??= this.Groups.Where(group => !group.IsExcluded).Sum(group => group.MessageCount);
/// <summary>
/// Gets the total UTF-8 byte count across all included (non-excluded) groups.
/// </summary>
public int IncludedByteCount => this.Groups.Where(group => !group.IsExcluded).Sum(group => group.ByteCount);
public int IncludedByteCount => _cachedIncludedByteCount ??= this.Groups.Where(group => !group.IsExcluded).Sum(group => group.ByteCount);
/// <summary>
/// Gets the total token count across all included (non-excluded) groups.
/// </summary>
public int IncludedTokenCount => this.Groups.Where(group => !group.IsExcluded).Sum(group => group.TokenCount);
public int IncludedTokenCount => _cachedIncludedTokenCount ??= this.Groups.Where(group => !group.IsExcluded).Sum(group => group.TokenCount);
/// <summary>
/// Gets the total number of user turns across all groups (including those with excluded groups).
/// </summary>
public int TotalTurnCount => this.Groups.Select(group => group.TurnIndex).Distinct().Count(turnIndex => turnIndex is not null && turnIndex > 0);
public int TotalTurnCount => _cachedTotalTurnCount ??= this.Groups.Select(group => group.TurnIndex).Distinct().Count(turnIndex => turnIndex is not null && turnIndex > 0);
/// <summary>
/// Gets the number of user turns that have at least one non-excluded group.
/// </summary>
public int IncludedTurnCount => this.Groups.Where(group => !group.IsExcluded && group.TurnIndex is not null && group.TurnIndex > 0).Select(group => group.TurnIndex).Distinct().Count();
public int IncludedTurnCount => _cachedIncludedTurnCount ??= this.Groups.Where(group => !group.IsExcluded && group.TurnIndex is not null && group.TurnIndex > 0).Select(group => group.TurnIndex).Distinct().Count();
/// <summary>
/// Gets the total number of groups across all included (non-excluded) groups that are not <see cref="CompactionGroupKind.System"/>.
/// </summary>
public int IncludedNonSystemGroupCount => this.Groups.Count(group => !group.IsExcluded && group.Kind != CompactionGroupKind.System);
public int IncludedNonSystemGroupCount => _cachedIncludedNonSystemGroupCount ??= this.Groups.Count(group => !group.IsExcluded && group.Kind != CompactionGroupKind.System);
/// <summary>
/// Gets the total number of original messages (that are not summaries).
/// </summary>
public int RawMessageCount => this.Groups.Where(group => group.Kind != CompactionGroupKind.Summary).Sum(group => group.MessageCount);
public int RawMessageCount => _cachedRawMessageCount ??= this.Groups.Where(group => group.Kind != CompactionGroupKind.Summary).Sum(group => group.MessageCount);
/// <summary>
/// Returns all groups that belong to the specified user turn.
@@ -379,6 +406,37 @@ public sealed class CompactionMessageIndex
/// <returns>The groups belonging to the turn, in order.</returns>
public IEnumerable<CompactionMessageGroup> GetTurnGroups(int turnIndex) => this.Groups.Where(group => group.TurnIndex == turnIndex);
private void InvalidateCache()
{
_cachedTotalMessageCount = null;
_cachedTotalByteCount = null;
_cachedTotalTokenCount = null;
_cachedIncludedGroupCount = null;
_cachedIncludedMessageCount = null;
_cachedIncludedByteCount = null;
_cachedIncludedTokenCount = null;
_cachedTotalTurnCount = null;
_cachedIncludedTurnCount = null;
_cachedIncludedNonSystemGroupCount = null;
_cachedRawMessageCount = null;
}
private void RegisterGroup(CompactionMessageGroup group)
{
// Each group is owned by exactly one index, so assignment rather than
// += is intentional — no need to chain callbacks.
group.ExclusionChanged = this.InvalidateCache;
}
// Adds the group to the list and registers it for cache invalidation.
// Callers that add many groups in a loop (e.g. AppendFromMessages) call
// InvalidateCache() once at the end rather than per-group for efficiency.
private void AddAndRegisterGroup(CompactionMessageGroup group)
{
this.Groups.Add(group);
this.RegisterGroup(group);
}
/// <summary>
/// Computes the UTF-8 byte count for a set of messages across all content types.
/// </summary>
@@ -1474,4 +1474,197 @@ public class CompactionMessageIndexTests
Assert.Equal(CompactionGroupKind.ToolCall, index.Groups[2].Kind);
Assert.Equal(3, index.Groups[2].MessageCount); // reasoning + toolCall + toolResult
}
// -----------------------------------------------------------------------
// Cache invalidation tests
// -----------------------------------------------------------------------
[Fact]
public void CachedMetricsAreInvalidatedWhenIsExcludedChanges()
{
// Arrange — two groups, read Included* properties (populates cache)
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "AAAA"), // 4 bytes, 1 token
new ChatMessage(ChatRole.Assistant, "BBBB"), // 4 bytes, 1 token
]);
// Prime the cache by reading properties
Assert.Equal(2, index.IncludedGroupCount);
Assert.Equal(2, index.IncludedMessageCount);
Assert.Equal(8, index.IncludedByteCount);
Assert.Equal(2, index.IncludedTokenCount);
Assert.Equal(1, index.IncludedTurnCount);
Assert.Equal(2, index.IncludedNonSystemGroupCount);
// Act — exclude both groups in turn 1; cache must be invalidated
index.Groups[0].IsExcluded = true;
index.Groups[1].IsExcluded = true;
// Assert — Included* properties now reflect the exclusions
Assert.Equal(0, index.IncludedGroupCount);
Assert.Equal(0, index.IncludedMessageCount);
Assert.Equal(0, index.IncludedByteCount);
Assert.Equal(0, index.IncludedTokenCount);
Assert.Equal(0, index.IncludedTurnCount); // turn 1 is fully excluded
Assert.Equal(0, index.IncludedNonSystemGroupCount);
}
[Fact]
public void CachedMetricsAreInvalidatedWhenGroupAddedViaUpdate()
{
// Arrange — start with two messages, read RawMessageCount (populates cache)
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
];
CompactionMessageIndex index = CompactionMessageIndex.Create(messages);
// Prime the cache
Assert.Equal(2, index.RawMessageCount);
Assert.Equal(2, index.IncludedGroupCount);
// Act — add two more messages
messages.Add(new ChatMessage(ChatRole.User, "Q2"));
messages.Add(new ChatMessage(ChatRole.Assistant, "A2"));
index.Update(messages);
// Assert — cached values updated
Assert.Equal(4, index.RawMessageCount);
Assert.Equal(4, index.IncludedGroupCount);
}
[Fact]
public void CachedMetricsAreInvalidatedWhenGroupAddedViaAddGroup()
{
// Arrange — prime the cache
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
]);
Assert.Equal(1, index.IncludedGroupCount);
Assert.Equal(1, index.TotalMessageCount);
// Act — add a group manually
index.AddGroup(CompactionGroupKind.AssistantText, [new ChatMessage(ChatRole.Assistant, "Hi")], turnIndex: 1);
// Assert — cache invalidated and recomputed
Assert.Equal(2, index.IncludedGroupCount);
Assert.Equal(2, index.TotalMessageCount);
}
[Fact]
public void CachedMetricsAreInvalidatedWhenGroupInsertedViaInsertGroup()
{
// Arrange — prime the cache
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
]);
Assert.Equal(1, index.IncludedGroupCount);
Assert.Equal(1, index.TotalMessageCount);
// Act — insert a group
ChatMessage summaryMsg = new(ChatRole.Assistant, "[Summary]");
(summaryMsg.AdditionalProperties ??= [])[CompactionMessageGroup.SummaryPropertyKey] = true;
index.InsertGroup(0, CompactionGroupKind.Summary, [summaryMsg]);
// Assert — cache invalidated and recomputed
Assert.Equal(2, index.IncludedGroupCount);
Assert.Equal(2, index.TotalMessageCount);
Assert.Equal(1, index.RawMessageCount); // Summary excluded from RawMessageCount
}
[Fact]
public void CachedMetricsAreInvalidatedWhenIndexRebuiltByUpdate()
{
// Arrange — populate and prime cache
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
];
CompactionMessageIndex index = CompactionMessageIndex.Create(messages);
index.Groups[0].IsExcluded = true;
// Prime the cache after exclusion
Assert.Equal(1, index.IncludedGroupCount);
// Act — update with a completely different (shorter) list — forces full rebuild
List<ChatMessage> newMessages =
[
new ChatMessage(ChatRole.User, "NewQ"),
];
index.Update(newMessages);
// Assert — full rebuild, previously excluded group is gone, cache is correct
Assert.Equal(1, index.IncludedGroupCount);
Assert.Equal(1, index.RawMessageCount);
}
[Fact]
public void CachedMetricsAreInvalidatedWhenUpdateClearsAllMessages()
{
// Arrange — prime the cache
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
]);
Assert.Equal(2, index.IncludedGroupCount);
Assert.Equal(2, index.RawMessageCount);
// Act — update with empty list
index.Update([]);
// Assert — all cached metrics reset
Assert.Equal(0, index.IncludedGroupCount);
Assert.Equal(0, index.TotalGroupCount);
Assert.Equal(0, index.RawMessageCount);
Assert.Equal(0, index.IncludedTokenCount);
}
[Fact]
public void CachedTotalMetricsUnchangedWhenOnlyExcludedStateChanges()
{
// Arrange — prime the cache
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "AAAA"), // 4 bytes
new ChatMessage(ChatRole.Assistant, "BBBB"), // 4 bytes
]);
Assert.Equal(2, index.TotalGroupCount);
Assert.Equal(2, index.TotalMessageCount);
Assert.Equal(8, index.TotalByteCount);
// Act — exclude a group
index.Groups[0].IsExcluded = true;
// Assert — Total* properties still include both groups
Assert.Equal(2, index.TotalGroupCount);
Assert.Equal(2, index.TotalMessageCount);
Assert.Equal(8, index.TotalByteCount);
Assert.Equal(2, index.TotalTokenCount);
}
[Fact]
public void ConstructorWithPreExistingGroupsRegistersThemForCacheInvalidation()
{
// Arrange — build groups externally, pass them into constructor
CompactionMessageGroup group1 = new(CompactionGroupKind.User, [new ChatMessage(ChatRole.User, "Q1")], 2, 1, turnIndex: 1);
CompactionMessageGroup group2 = new(CompactionGroupKind.AssistantText, [new ChatMessage(ChatRole.Assistant, "A1")], 2, 1, turnIndex: 1);
List<CompactionMessageGroup> groups = [group1, group2];
CompactionMessageIndex index = new(groups);
// Prime the cache
Assert.Equal(2, index.IncludedGroupCount);
// Act — exclude a pre-existing group (ExclusionChanged should fire and invalidate)
group1.IsExcluded = true;
// Assert — cache reflects the change
Assert.Equal(1, index.IncludedGroupCount);
}
}