diff --git a/src/RockBot.Agent/agent/common-directives.md b/src/RockBot.Agent/agent/common-directives.md index 0fa107d..30d7d1a 100644 --- a/src/RockBot.Agent/agent/common-directives.md +++ b/src/RockBot.Agent/agent/common-directives.md @@ -179,6 +179,12 @@ Call `search_memory` explicitly only when you want to search with a specific query that differs from the raw message (e.g., after clarification, or when you want to narrow to a category). +`search_memory` has two modes. Use `mode='regex'` when you know the literal +token you're hunting for — a file path, ID, version string, or exact phrase — +and the regex matches against both the memory's path name (`category/id`) and +its content. Otherwise leave the default `mode='hybrid'` for semantic/keyword +search. + ### Narrative identity Your evolving self-model is stored in long-term memory under `agent-identity/` diff --git a/src/RockBot.Host.Abstractions/MemorySearchCriteria.cs b/src/RockBot.Host.Abstractions/MemorySearchCriteria.cs index 95483d9..9add1f0 100644 --- a/src/RockBot.Host.Abstractions/MemorySearchCriteria.cs +++ b/src/RockBot.Host.Abstractions/MemorySearchCriteria.cs @@ -1,25 +1,37 @@ -namespace RockBot.Host; - -/// -/// Criteria for searching long-term memory entries. -/// All specified criteria are combined with AND logic. -/// -/// Case-insensitive substring to match against content. -/// Category prefix to match (e.g. "project-context" matches "project-context/rockbot"). -/// Tags that entries must contain (all specified tags must be present). -/// Only include entries created after this time. -/// Only include entries created before this time. -/// Maximum number of results to return. Defaults to 20. -/// -/// Pre-computed query embedding vector. When provided, stores skip generating their own -/// query embedding — avoiding redundant calls to the embedding endpoint when multiple -/// searches share the same query text (e.g. during context building). -/// -public sealed record MemorySearchCriteria( - string? Query = null, - string? Category = null, - IReadOnlyList? Tags = null, - DateTimeOffset? CreatedAfter = null, - DateTimeOffset? CreatedBefore = null, - int MaxResults = 20, - float[]? QueryEmbedding = null); +namespace RockBot.Host; + +/// +/// Criteria for searching long-term memory entries. +/// All specified criteria are combined with AND logic. +/// +/// +/// In (default), a case-insensitive keyword/phrase to rank against. +/// In , a .NET regex pattern matched against the entry's +/// memory path name ({category}/{id} or {id}) plus its content, tags, and category words. +/// +/// Category prefix to match (e.g. "project-context" matches "project-context/rockbot"). +/// Tags that entries must contain (all specified tags must be present). +/// Only include entries created after this time. +/// Only include entries created before this time. +/// Maximum number of results to return. Defaults to 20. +/// +/// Pre-computed query embedding vector. When provided, stores skip generating their own +/// query embedding — avoiding redundant calls to the embedding endpoint when multiple +/// searches share the same query text (e.g. during context building). Ignored in +/// . +/// +/// Search backend selector. Defaults to . +/// +/// When is , controls case sensitivity of the regex. +/// Default false mirrors Claude Code's Grep tool. Ignored in hybrid mode. +/// +public sealed record MemorySearchCriteria( + string? Query = null, + string? Category = null, + IReadOnlyList? Tags = null, + DateTimeOffset? CreatedAfter = null, + DateTimeOffset? CreatedBefore = null, + int MaxResults = 20, + float[]? QueryEmbedding = null, + MemorySearchMode Mode = MemorySearchMode.Hybrid, + bool RegexCaseSensitive = false); diff --git a/src/RockBot.Host.Abstractions/MemorySearchException.cs b/src/RockBot.Host.Abstractions/MemorySearchException.cs new file mode 100644 index 0000000..7638130 --- /dev/null +++ b/src/RockBot.Host.Abstractions/MemorySearchException.cs @@ -0,0 +1,14 @@ +namespace RockBot.Host; + +/// +/// Thrown by memory search backends when a caller-supplied query cannot be executed — +/// for example an invalid regex pattern, a per-entry match timeout, or an overall +/// scan-budget overrun. The tool layer surfaces the message verbatim to the model so +/// it can refine its query. +/// +public sealed class MemorySearchException : Exception +{ + public MemorySearchException(string message) : base(message) { } + + public MemorySearchException(string message, Exception inner) : base(message, inner) { } +} diff --git a/src/RockBot.Host.Abstractions/MemorySearchMode.cs b/src/RockBot.Host.Abstractions/MemorySearchMode.cs new file mode 100644 index 0000000..df630ff --- /dev/null +++ b/src/RockBot.Host.Abstractions/MemorySearchMode.cs @@ -0,0 +1,21 @@ +namespace RockBot.Host; + +/// +/// Backend used by when scoring candidate entries +/// against a query. +/// +public enum MemorySearchMode +{ + /// + /// Default. BM25 keyword ranking, optionally combined with vector similarity when + /// embeddings are configured. Recall-oriented and tolerant of paraphrase. + /// + Hybrid = 0, + + /// + /// .NET regex pattern matched against the literal stored content (memory path name + + /// content + tags + category words). Exact and deterministic — preferred when the + /// caller already knows the literal token (file path, id, version, exact phrase). + /// + Regex = 1, +} diff --git a/src/RockBot.Host/FileMemoryStore.cs b/src/RockBot.Host/FileMemoryStore.cs index 01da3f2..1fc4a58 100644 --- a/src/RockBot.Host/FileMemoryStore.cs +++ b/src/RockBot.Host/FileMemoryStore.cs @@ -107,7 +107,7 @@ public async Task> SearchAsync(MemorySearchCriteria c // No query: return most-recently reinforced entries up to MaxResults. // Ordered by LastSeenAt (real reinforcement) rather than UpdatedAt (dream rewrites), // so dream housekeeping does not artificially promote entries in no-query results. - if (criteria.Query is null) + if (string.IsNullOrWhiteSpace(criteria.Query)) { return candidates .OrderByDescending(e => e.LastSeenAt) @@ -115,6 +115,17 @@ public async Task> SearchAsync(MemorySearchCriteria c .ToList(); } + // Regex mode: literal pattern matching, no scoring, bounded by timeouts. + if (criteria.Mode == MemorySearchMode.Regex) + { + return RegexMatcher.MatchEntries( + candidates, + criteria.Query, + criteria.RegexCaseSensitive, + criteria.MaxResults, + BuildRegexSurface); + } + // With query: use hybrid ranking if embeddings available, else BM25-only. if (_embeddingCache is not null) { @@ -263,6 +274,21 @@ internal static string GetDocumentText(MemoryEntry entry) return string.Join(" ", parts); } + // ── Regex match surface ─────────────────────────────────────────────────── + + /// + /// Returns the text the regex backend matches against: the entry's logical memory + /// path name ({category}/{id} or {id} when uncategorized) on its own + /// line, then the BM25 document text (content + tags + category words). The on-disk + /// file path from is deliberately never included — the + /// model interacts with memories by id, not by storage layout. + /// + internal static string BuildRegexSurface(MemoryEntry entry) + { + var pathName = entry.Category is null ? entry.Id : $"{entry.Category}/{entry.Id}"; + return $"{pathName}\n{GetDocumentText(entry)}"; + } + // ── Structural Filter ───────────────────────────────────────────────────── private static bool PassesStructuralFilters(MemoryEntry entry, MemorySearchCriteria criteria) diff --git a/src/RockBot.Host/RegexMatcher.cs b/src/RockBot.Host/RegexMatcher.cs new file mode 100644 index 0000000..3d4e629 --- /dev/null +++ b/src/RockBot.Host/RegexMatcher.cs @@ -0,0 +1,87 @@ +using System.Diagnostics; +using System.Text.RegularExpressions; + +namespace RockBot.Host; + +/// +/// Runs a caller-supplied regex pattern across a pre-filtered set of +/// candidates. Used by the regex backend of . +/// Bounds cost two ways: a per-entry catches catastrophic +/// backtracking on a single input, and an overall wall-clock budget across the scan stops a +/// slow-but-not-pathological pattern from dominating as the corpus grows. +/// +internal static class RegexMatcher +{ + internal static readonly TimeSpan DefaultPerEntryTimeout = TimeSpan.FromSeconds(1); + internal static readonly TimeSpan DefaultOverallBudget = TimeSpan.FromSeconds(10); + internal const int MaxPatternLength = 512; + + public static IReadOnlyList MatchEntries( + IReadOnlyCollection candidates, + string pattern, + bool caseSensitive, + int maxResults, + Func documentText) => + MatchEntries(candidates, pattern, caseSensitive, maxResults, documentText, + DefaultPerEntryTimeout, DefaultOverallBudget); + + /// + /// Test-friendly overload that allows custom timeouts. Production callers should use + /// the parameterless-budget overload above. + /// + internal static IReadOnlyList MatchEntries( + IReadOnlyCollection candidates, + string pattern, + bool caseSensitive, + int maxResults, + Func documentText, + TimeSpan perEntryTimeout, + TimeSpan overallBudget) + { + if (pattern.Length > MaxPatternLength) + throw new MemorySearchException( + $"Regex pattern exceeds {MaxPatternLength} characters. Narrow the pattern."); + + Regex regex; + var options = RegexOptions.CultureInvariant; + if (!caseSensitive) options |= RegexOptions.IgnoreCase; + try + { + regex = new Regex(pattern, options, perEntryTimeout); + } + catch (ArgumentException ex) + { + throw new MemorySearchException($"Invalid regex pattern: {ex.Message}", ex); + } + + var matches = new List(); + var sw = Stopwatch.StartNew(); + var scanned = 0; + foreach (var entry in candidates) + { + if (sw.Elapsed > overallBudget) + throw new MemorySearchException( + $"Regex search exceeded {overallBudget.TotalSeconds:F1}s after scanning " + + $"{scanned}/{candidates.Count} entries. Narrow the pattern or add a category filter."); + + scanned++; + try + { + if (regex.IsMatch(documentText(entry))) + matches.Add(entry); + } + catch (RegexMatchTimeoutException ex) + { + throw new MemorySearchException( + $"Regex match timed out after {perEntryTimeout.TotalSeconds:F1}s on a single entry. " + + "Try a more specific pattern.", ex); + } + } + + return matches + .OrderByDescending(e => e.ImportanceScore) + .ThenByDescending(e => e.LastSeenAt) + .Take(maxResults) + .ToList(); + } +} diff --git a/src/RockBot.Memory/MemoryTools.cs b/src/RockBot.Memory/MemoryTools.cs index a2d9e93..c0c04d5 100644 --- a/src/RockBot.Memory/MemoryTools.cs +++ b/src/RockBot.Memory/MemoryTools.cs @@ -126,18 +126,34 @@ public Task SaveMemory( } [Description("Search long-term memory for previously saved facts, preferences, or patterns. " + - "Use query for keyword search and category for scoping to a knowledge area.")] + "Use query for keyword search and category for scoping to a knowledge area. " + + "Set mode='regex' when you know the literal token (file path, id, version, exact phrase); " + + "otherwise leave mode='hybrid' (default) for semantic/keyword search.")] public async Task SearchMemory( - [Description("Optional keyword to search for in memory content")] string? query = null, - [Description("Optional category prefix to filter by (e.g. 'user-preferences')")] string? category = null) + [Description("Optional keyword (hybrid mode) or .NET regex pattern (regex mode) to search for")] string? query = null, + [Description("Optional category prefix to filter by (e.g. 'user-preferences')")] string? category = null, + [Description("Search backend: 'hybrid' (default, BM25 + optional vector) or 'regex' (case-insensitive .NET regex against memory path name and content)")] string? mode = null) { - _logger.LogInformation("Tool call: SearchMemory(query={Query}, category={Category})", query, category); + _logger.LogInformation("Tool call: SearchMemory(query={Query}, category={Category}, mode={Mode})", query, category, mode); + + if (!TryParseMode(mode, out var parsedMode)) + return $"Unknown search mode '{mode}'. Use 'hybrid' or 'regex'."; var criteria = new MemorySearchCriteria( Query: string.IsNullOrWhiteSpace(query) ? null : query.Trim(), - Category: string.IsNullOrWhiteSpace(category) ? null : category.Trim()); + Category: string.IsNullOrWhiteSpace(category) ? null : category.Trim(), + Mode: parsedMode); - var results = await _memory.SearchAsync(criteria); + IReadOnlyList results; + try + { + results = await _memory.SearchAsync(criteria); + } + catch (MemorySearchException ex) + { + _logger.LogInformation("SearchMemory rejected by backend: {Message}", ex.Message); + return ex.Message; + } _logger.LogInformation("SearchMemory returned {Count} results", results.Count); @@ -160,6 +176,21 @@ public async Task SearchMemory( return sb.ToString(); } + private static bool TryParseMode(string? mode, out MemorySearchMode parsed) + { + if (string.IsNullOrWhiteSpace(mode)) + { + parsed = MemorySearchMode.Hybrid; + return true; + } + + if (Enum.TryParse(mode.Trim(), ignoreCase: true, out parsed)) + return true; + + parsed = MemorySearchMode.Hybrid; + return false; + } + private static string FormatAge(MemoryEntry e) { var now = DateTimeOffset.UtcNow; diff --git a/tests/RockBot.Agent.Tests/MemoryToolsTests.cs b/tests/RockBot.Agent.Tests/MemoryToolsTests.cs index 557ff39..6d6ca16 100644 --- a/tests/RockBot.Agent.Tests/MemoryToolsTests.cs +++ b/tests/RockBot.Agent.Tests/MemoryToolsTests.cs @@ -222,6 +222,72 @@ public async Task SearchMemory_ShowsImportanceScore() StringAssert.Contains(result, "importance=0.85"); } + // ------------------------------------------------------------------------- + // SearchMemory — mode parameter + // ------------------------------------------------------------------------- + + [TestMethod] + public async Task SearchMemory_DefaultMode_PassesHybrid() + { + var memory = new StubLongTermMemory(); + var tools = MakeTools(memory); + + await tools.SearchMemory("anything"); + + Assert.IsNotNull(memory.LastCriteria); + Assert.AreEqual(MemorySearchMode.Hybrid, memory.LastCriteria.Mode); + } + + [TestMethod] + public async Task SearchMemory_ModeRegex_PassesRegex() + { + var memory = new StubLongTermMemory(); + var tools = MakeTools(memory); + + await tools.SearchMemory("anything", mode: "Regex"); + + Assert.IsNotNull(memory.LastCriteria); + Assert.AreEqual(MemorySearchMode.Regex, memory.LastCriteria.Mode); + } + + [TestMethod] + public async Task SearchMemory_ModeRegex_LowerCase_PassesRegex() + { + var memory = new StubLongTermMemory(); + var tools = MakeTools(memory); + + await tools.SearchMemory("anything", mode: "regex"); + + Assert.IsNotNull(memory.LastCriteria); + Assert.AreEqual(MemorySearchMode.Regex, memory.LastCriteria.Mode); + } + + [TestMethod] + public async Task SearchMemory_UnknownMode_ReturnsErrorString_AndDoesNotCallStore() + { + var memory = new StubLongTermMemory(); + var tools = MakeTools(memory); + + var result = await tools.SearchMemory("anything", mode: "fuzzy"); + + StringAssert.Contains(result, "Unknown search mode"); + Assert.IsNull(memory.LastCriteria, "Store should not be called for an invalid mode."); + } + + [TestMethod] + public async Task SearchMemory_MemorySearchException_ReturnsMessage() + { + var memory = new StubLongTermMemory + { + SearchOverride = _ => throw new MemorySearchException("bad pattern: nope") + }; + var tools = MakeTools(memory); + + var result = await tools.SearchMemory("[", mode: "regex"); + + StringAssert.Contains(result, "bad pattern: nope"); + } + // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- @@ -246,6 +312,9 @@ internal sealed class StubLongTermMemory : ILongTermMemory { private readonly List _entries = []; + public MemorySearchCriteria? LastCriteria { get; private set; } + public Func>? SearchOverride { get; set; } + public void Add(MemoryEntry entry) => _entries.Add(entry); public Task SaveAsync(MemoryEntry entry, CancellationToken cancellationToken = default) @@ -256,8 +325,12 @@ public Task SaveAsync(MemoryEntry entry, CancellationToken cancellationToken = d } public Task> SearchAsync( - MemorySearchCriteria criteria, CancellationToken cancellationToken = default) => - Task.FromResult>([.. _entries]); + MemorySearchCriteria criteria, CancellationToken cancellationToken = default) + { + LastCriteria = criteria; + var results = SearchOverride is not null ? SearchOverride(criteria) : (IReadOnlyList)[.. _entries]; + return Task.FromResult(results); + } public Task GetAsync(string id, CancellationToken cancellationToken = default) => Task.FromResult(_entries.FirstOrDefault(e => e.Id == id)); diff --git a/tests/RockBot.Host.Tests/FileMemoryStoreTests.cs b/tests/RockBot.Host.Tests/FileMemoryStoreTests.cs index bcd2960..6c59bf8 100644 --- a/tests/RockBot.Host.Tests/FileMemoryStoreTests.cs +++ b/tests/RockBot.Host.Tests/FileMemoryStoreTests.cs @@ -419,6 +419,224 @@ public async Task SearchAsync_WithQuery_TagsContributeToScore() Assert.IsTrue(results.Any(r => r.Id == "content")); } + // ── Regex mode ──────────────────────────────────────────────────────────── + + [TestMethod] + public async Task Search_RegexMode_MatchesLiteralToken() + { + var store = CreateStore(); + await store.SaveAsync(CreateEntry("v0", "deploy v0.10.30 to staging")); + await store.SaveAsync(CreateEntry("other", "unrelated content")); + + var results = await store.SearchAsync(new MemorySearchCriteria( + Query: @"v\d+\.\d+\.\d+", Mode: MemorySearchMode.Regex)); + + Assert.AreEqual(1, results.Count); + Assert.AreEqual("v0", results[0].Id); + } + + [TestMethod] + public async Task Search_RegexMode_MatchesMemoryIdInPathName() + { + var store = CreateStore(); + // Content does not mention "openrouter-key" — only the id does. + await store.SaveAsync(CreateEntry("openrouter-key-rotation", "An unrelated note about rotation.")); + await store.SaveAsync(CreateEntry("foo", "Different entry.")); + + var results = await store.SearchAsync(new MemorySearchCriteria( + Query: "openrouter-key", Mode: MemorySearchMode.Regex)); + + Assert.AreEqual(1, results.Count); + Assert.AreEqual("openrouter-key-rotation", results[0].Id); + } + + [TestMethod] + public async Task Search_RegexMode_MatchesCategoryInPathName() + { + var store = CreateStore(); + await store.SaveAsync(CreateEntry("foo", "Some content", category: "project-context")); + await store.SaveAsync(CreateEntry("bar", "Other content", category: "user-preferences")); + + var results = await store.SearchAsync(new MemorySearchCriteria( + Query: @"^project-context/", Mode: MemorySearchMode.Regex)); + + Assert.AreEqual(1, results.Count); + Assert.AreEqual("foo", results[0].Id); + } + + [TestMethod] + public async Task Search_RegexMode_DoesNotMatchOnDiskFilePath() + { + // The on-disk path ends in ".json" but the regex surface deliberately omits it. + // A pattern that hits the storage layout (.json suffix) must not match anything. + var store = CreateStore(); + await store.SaveAsync(CreateEntry("entry-1", "Some plain content")); + + var results = await store.SearchAsync(new MemorySearchCriteria( + Query: @"\.json$", Mode: MemorySearchMode.Regex)); + + Assert.AreEqual(0, results.Count, "Storage-layout details must not be exposed to regex matches."); + } + + [TestMethod] + public async Task Search_RegexMode_DefaultIsCaseInsensitive() + { + var store = CreateStore(); + await store.SaveAsync(CreateEntry("h", "Helm release in cluster")); + + var results = await store.SearchAsync(new MemorySearchCriteria( + Query: "helm", Mode: MemorySearchMode.Regex)); + + Assert.AreEqual(1, results.Count); + } + + [TestMethod] + public async Task Search_RegexMode_CaseSensitive_RespectsFlag() + { + var store = CreateStore(); + await store.SaveAsync(CreateEntry("h", "Helm release in cluster")); + + var lowerResults = await store.SearchAsync(new MemorySearchCriteria( + Query: "helm", Mode: MemorySearchMode.Regex, RegexCaseSensitive: true)); + Assert.AreEqual(0, lowerResults.Count); + + var upperResults = await store.SearchAsync(new MemorySearchCriteria( + Query: "Helm", Mode: MemorySearchMode.Regex, RegexCaseSensitive: true)); + Assert.AreEqual(1, upperResults.Count); + } + + [TestMethod] + public async Task Search_RegexMode_HonorsCategoryFilter() + { + var store = CreateStore(); + await store.SaveAsync(CreateEntry("a", "shared keyword", category: "project-context")); + await store.SaveAsync(CreateEntry("b", "shared keyword", category: "user-preferences")); + + var results = await store.SearchAsync(new MemorySearchCriteria( + Query: "shared", Category: "project-context", Mode: MemorySearchMode.Regex)); + + Assert.AreEqual(1, results.Count); + Assert.AreEqual("a", results[0].Id); + } + + [TestMethod] + public async Task Search_RegexMode_RespectsMaxResults() + { + var store = CreateStore(); + for (int i = 0; i < 10; i++) + await store.SaveAsync(CreateEntry($"e{i}", "matching content")); + + var results = await store.SearchAsync(new MemorySearchCriteria( + Query: "matching", Mode: MemorySearchMode.Regex, MaxResults: 3)); + + Assert.AreEqual(3, results.Count); + } + + [TestMethod] + public async Task Search_RegexMode_InvalidPattern_Throws() + { + var store = CreateStore(); + await store.SaveAsync(CreateEntry("a", "anything")); + + await Assert.ThrowsExactlyAsync(async () => + { + await store.SearchAsync(new MemorySearchCriteria( + Query: "[", Mode: MemorySearchMode.Regex)); + }); + } + + [TestMethod] + public async Task Search_RegexMode_NoMatches_ReturnsEmpty() + { + var store = CreateStore(); + await store.SaveAsync(CreateEntry("a", "the cat sat on the mat")); + + var results = await store.SearchAsync(new MemorySearchCriteria( + Query: "nothing-here", Mode: MemorySearchMode.Regex)); + + Assert.AreEqual(0, results.Count); + } + + [TestMethod] + public async Task Search_RegexMode_OrdersByImportanceThenLastSeen() + { + var store = CreateStore(); + var now = DateTimeOffset.UtcNow; + + await store.SaveAsync(new MemoryEntry("low", "shared content", null, [], now, + ImportanceScore: 0.2f) { LastSeenAt = now }); + await store.SaveAsync(new MemoryEntry("highOld", "shared content", null, [], now.AddDays(-10), + ImportanceScore: 0.9f) { LastSeenAt = now.AddDays(-10) }); + await store.SaveAsync(new MemoryEntry("highRecent", "shared content", null, [], now, + ImportanceScore: 0.9f) { LastSeenAt = now }); + + var results = await store.SearchAsync(new MemorySearchCriteria( + Query: "shared", Mode: MemorySearchMode.Regex)); + + Assert.AreEqual(3, results.Count); + Assert.AreEqual("highRecent", results[0].Id, "Highest importance, most recent wins."); + Assert.AreEqual("highOld", results[1].Id, "Same importance, older LastSeenAt comes second."); + Assert.AreEqual("low", results[2].Id); + } + + [TestMethod] + public async Task Search_RegexMode_NullQuery_FallsBackToNoQueryPath() + { + var store = CreateStore(); + var now = DateTimeOffset.UtcNow; + await store.SaveAsync(new MemoryEntry("old", "old", null, [], now.AddDays(-5))); + await store.SaveAsync(new MemoryEntry("new", "new", null, [], now)); + + var results = await store.SearchAsync(new MemorySearchCriteria( + Mode: MemorySearchMode.Regex)); + + Assert.AreEqual(2, results.Count); + Assert.AreEqual("new", results[0].Id, "Null query falls through to LastSeenAt ordering."); + } + + [TestMethod] + public void Search_RegexMode_PathologicalPattern_ThrowsTimeout() + { + // Catastrophic backtracking: (a+)+$ on a string that's all 'a's plus a trailing 'b'. + // Force a tiny per-entry timeout so the test stays under ~200ms. + var entry = new MemoryEntry("evil", new string('a', 60) + "b", null, [], DateTimeOffset.UtcNow); + + Assert.ThrowsExactly(() => + RegexMatcher.MatchEntries( + new[] { entry }, + @"(a+)+$", + caseSensitive: false, + maxResults: 10, + FileMemoryStore.BuildRegexSurface, + perEntryTimeout: TimeSpan.FromMilliseconds(50), + overallBudget: TimeSpan.FromSeconds(5))); + } + + [TestMethod] + public void Search_RegexMode_OverallBudget_BoundsTotalScan() + { + // Build many candidates and force per-entry surface generation to be slow via the + // documentText delegate. Each "entry" adds ~5ms to wall clock; with a 30ms overall + // budget we expect to throw before scanning all 200. + var now = DateTimeOffset.UtcNow; + var candidates = Enumerable.Range(0, 200) + .Select(i => new MemoryEntry($"e{i}", "content", null, [], now)) + .ToList(); + + var ex = Assert.ThrowsExactly(() => + RegexMatcher.MatchEntries( + candidates, + "content", + caseSensitive: false, + maxResults: 10, + e => { Thread.Sleep(5); return e.Content; }, + perEntryTimeout: TimeSpan.FromSeconds(1), + overallBudget: TimeSpan.FromMilliseconds(30))); + + StringAssert.Contains(ex.Message, "/200"); + StringAssert.Contains(ex.Message, "exceeded"); + } + // ── Tokenizer unit tests ────────────────────────────────────────────────── [TestMethod]