diff --git a/src/AgentMemory.Abstractions/Options/MemoryOptions.cs b/src/AgentMemory.Abstractions/Options/MemoryOptions.cs index c943614f..a3297011 100644 --- a/src/AgentMemory.Abstractions/Options/MemoryOptions.cs +++ b/src/AgentMemory.Abstractions/Options/MemoryOptions.cs @@ -202,6 +202,32 @@ public sealed record MemoryOptions /// public bool OmitEmbeddingsFromRecall { get; init; } + /// + /// Skips the escalation ladder for an owner that holds no rows of the searched label (2.13). + /// + /// + /// + /// An empty owner-scoped vector search escalates: a widened probe over the global index, + /// then an owner-scoped scan. For an owner holding nothing of that label both are futile by + /// construction, and the widened probe is the costly one — it asks the index for up to 2,000 + /// candidates across the whole corpus to find rows that do not exist. + /// + /// + /// Measured before enabling, because shortening the ladder is a trade rather than a free win. + /// A starved owner — many rows, crowded out of the global top-K by noisier neighbours — is + /// genuinely recovered by escalation, so a blanket removal loses real answers. The large-owner arm + /// showed the two populations are cleanly separable: the empty owner returns nothing at every + /// rung, while the starved owner's rows are found. This option skips the ladder only for the first. + /// + /// + /// Off by default. The results are identical either way — an owner with nothing to find finds + /// nothing — so this is purely a cost saving; but it is gated because an existence probe that + /// disagreed with the search's own scoping would skip a rescue that would have worked, and a + /// silent recall loss is not worth one avoided query. + /// + /// + public bool SkipEscalationWhenOwnerHasNoRows { get; init; } + // NOTE: extraction at the Core layer is explicit (call ExtractAndPersistAsync / // ExtractFromSessionAsync). Automatic extraction on message persist is an adapter concern, configured // by AgentFrameworkOptions.AutoExtractOnPersist. The former EnableAutoExtraction flag here was read diff --git a/src/AgentMemory.Neo4j/Queries/OwnerRowExistence.cs b/src/AgentMemory.Neo4j/Queries/OwnerRowExistence.cs new file mode 100644 index 00000000..66a1e703 --- /dev/null +++ b/src/AgentMemory.Neo4j/Queries/OwnerRowExistence.cs @@ -0,0 +1,54 @@ +namespace AgentMemory.Neo4j.Queries; + +/// +/// "Does this owner hold any rows of this label at all?" — the check that separates a starved owner +/// from an empty one before the escalation ladder is climbed (PLAN 2.13). +/// +/// +/// +/// When an owner-scoped vector search returns nothing, recall escalates: a widened probe over the +/// global index, then an owner-scoped scan. For an owner that holds no rows of that label both +/// are futile by construction — widening a global index cannot surface rows that do not exist — and +/// the widened probe is the expensive one, since it asks the index for up to MaxTopK candidates +/// across the entire corpus. +/// +/// +/// The ladder still cannot simply be shortened, and that is what the measurement showed. A +/// starved owner — plenty of rows, crowded out of the global top-K by noisier neighbours — is +/// recovered by the escalation, so removing a rung would lose real answers. The distinction the +/// optimisation rests on is not "did the search return nothing" but "does this owner have anything to +/// find", and only the second is answerable cheaply: it is bounded by one owner's data rather than by +/// the corpus. +/// +/// +/// LIMIT 1 rather than a count: the question is existence, and counting an owner's whole +/// partition to learn it is non-zero would reintroduce the cost this avoids. +/// +/// +internal static class OwnerRowExistence +{ + /// + /// Builds the existence probe for a label. + /// + /// Node label, e.g. Fact. + /// + /// Whether un-owned (shared) rows count. Must mirror the search's own scoping exactly — a probe + /// that judged the owner empty on stricter terms than the search would skip an escalation that + /// could have succeeded, which is a silent recall loss rather than a saving. + /// + public static string Any(string label, bool includeShared) + { + var owner = includeShared + ? "(n.owner_id = $ownerId OR n.owner_id IS NULL)" + : "n.owner_id = $ownerId"; + + // invalidated_at mirrors the vector searches, which all exclude soft-invalidated rows. An + // owner holding nothing but tombstones has nothing the escalation could return. + return $""" + MATCH (n:{label}) + WHERE {owner} AND n.invalidated_at IS NULL + RETURN 1 AS present + LIMIT 1 + """; + } +} diff --git a/src/AgentMemory.Neo4j/Repositories/Neo4jEntityRepository.cs b/src/AgentMemory.Neo4j/Repositories/Neo4jEntityRepository.cs index 410a52e4..7d744a2d 100644 --- a/src/AgentMemory.Neo4j/Repositories/Neo4jEntityRepository.cs +++ b/src/AgentMemory.Neo4j/Repositories/Neo4jEntityRepository.cs @@ -20,6 +20,8 @@ internal sealed partial class Neo4jEntityRepository : IEntityRepository, IUpsert private readonly INeo4jTransactionRunner _tx; private readonly bool _rescueShortOwnerResults; + /// 2.13: skip a futile widened probe + scan for an owner holding nothing. + private readonly bool _skipEscalationWhenOwnerHasNoRows; /// Payload projection: drop the ~3 KB vector nothing on the recall path reads. private readonly bool _omitEmbeddingsFromRecall; private readonly ILogger _logger; @@ -68,6 +70,8 @@ public Neo4jEntityRepository( IOptions? memoryOptions = null) { _rescueShortOwnerResults = memoryOptions?.Value.RescueShortOwnerResults ?? false; + _skipEscalationWhenOwnerHasNoRows = + memoryOptions?.Value.SkipEscalationWhenOwnerHasNoRows ?? false; _omitEmbeddingsFromRecall = memoryOptions?.Value.OmitEmbeddingsFromRecall ?? false; _tx = tx; _logger = logger; @@ -256,7 +260,8 @@ public async Task> GetByNameAsync( // this retry, so its absence here was a real exposure - an owner could receive NOTHING while // its data sat in the graph. int? escalatedTopK = null; - if (OwnerVectorOverFetch.ShouldEscalate(results.Count, hasOwner)) + if (OwnerVectorOverFetch.ShouldEscalate(results.Count, hasOwner) + && await ShouldClimbLadderAsync(scope, includeShared, cancellationToken).ConfigureAwait(false)) { var widened = OwnerVectorOverFetch.EscalatedTopK(topK); if (widened > topK) @@ -1009,4 +1014,36 @@ internal static string SanitizeLabel(string label) { return new string(label.Where(c => char.IsLetterOrDigit(c) || c == '_').ToArray()); } + + /// + /// Whether the escalation ladder can possibly help this owner (2.13). + /// + /// + /// Returns unless the option is on AND the owner provably holds no rows of + /// this label. Defaulting to "escalate" is the safe direction: a probe that wrongly reported empty + /// would skip a rescue that would have worked, and a silent recall loss costs far more than one + /// avoided query. + /// + private async Task ShouldClimbLadderAsync( + MemoryScope? scope, bool includeShared, CancellationToken cancellationToken) + { + if (!_skipEscalationWhenOwnerHasNoRows || scope?.OwnerId is null) return true; + + var present = await _tx.ReadAsync(async runner => + { + var cursor = await runner.RunAsync( + OwnerRowExistence.Any("Entity", includeShared), + new { ownerId = scope.OwnerId }).ConfigureAwait(false); + return (await cursor.ToListAsync().ConfigureAwait(false)).Count > 0; + }, cancellationToken).ConfigureAwait(false); + + if (!present) + { + _logger.LogDebug( + "Owner {Owner} holds no Entity rows; skipping the escalation ladder (2.13).", + scope.OwnerId); + } + + return present; + } } \ No newline at end of file diff --git a/src/AgentMemory.Neo4j/Repositories/Neo4jFactRepository.cs b/src/AgentMemory.Neo4j/Repositories/Neo4jFactRepository.cs index cc94187b..2f84d9c1 100644 --- a/src/AgentMemory.Neo4j/Repositories/Neo4jFactRepository.cs +++ b/src/AgentMemory.Neo4j/Repositories/Neo4jFactRepository.cs @@ -24,6 +24,8 @@ internal sealed partial class Neo4jFactRepository : IFactRepository, IUpsertPers private readonly INeo4jTransactionRunner _tx; private readonly bool _rescueShortOwnerResults; + /// 2.13: skip a futile widened probe + scan for an owner holding nothing. + private readonly bool _skipEscalationWhenOwnerHasNoRows; /// Payload projection: drop the ~3 KB vector nothing on the recall path reads. private readonly bool _omitEmbeddingsFromRecall; private readonly double _reinforceAlpha; @@ -41,6 +43,8 @@ public Neo4jFactRepository( IOptions? memoryOptions = null) { _rescueShortOwnerResults = memoryOptions?.Value.RescueShortOwnerResults ?? false; + _skipEscalationWhenOwnerHasNoRows = + memoryOptions?.Value.SkipEscalationWhenOwnerHasNoRows ?? false; _omitEmbeddingsFromRecall = memoryOptions?.Value.OmitEmbeddingsFromRecall ?? false; _reinforceAlpha = memoryOptions?.Value.ConfidenceReinforcementAlpha ?? 0.0; _tx = tx; @@ -417,7 +421,8 @@ await _tx.ReadAsync(async runner => // one wider query; anything non-empty is left alone, because escalating on "short" would tax // every small tenant forever. int? escalatedTopK = null; - if (OwnerVectorOverFetch.ShouldEscalate(results.Count, hasOwner)) + if (OwnerVectorOverFetch.ShouldEscalate(results.Count, hasOwner) + && await ShouldClimbLadderAsync(scope, includeShared, cancellationToken).ConfigureAwait(false)) { var widened = OwnerVectorOverFetch.EscalatedTopK(topK); if (widened > topK) @@ -893,4 +898,36 @@ public async Task> SearchByCanonicalPredicatesAsync( .ToList(); }, cancellationToken).ConfigureAwait(false); } + + /// + /// Whether the escalation ladder can possibly help this owner (2.13). + /// + /// + /// Returns unless the option is on AND the owner provably holds no rows of + /// this label. Defaulting to "escalate" is the safe direction: a probe that wrongly reported empty + /// would skip a rescue that would have worked, and a silent recall loss costs far more than one + /// avoided query. + /// + private async Task ShouldClimbLadderAsync( + MemoryScope? scope, bool includeShared, CancellationToken cancellationToken) + { + if (!_skipEscalationWhenOwnerHasNoRows || scope?.OwnerId is null) return true; + + var present = await _tx.ReadAsync(async runner => + { + var cursor = await runner.RunAsync( + OwnerRowExistence.Any("Fact", includeShared), + new { ownerId = scope.OwnerId }).ConfigureAwait(false); + return (await cursor.ToListAsync().ConfigureAwait(false)).Count > 0; + }, cancellationToken).ConfigureAwait(false); + + if (!present) + { + _logger.LogDebug( + "Owner {Owner} holds no Fact rows; skipping the escalation ladder (2.13).", + scope.OwnerId); + } + + return present; + } } diff --git a/src/AgentMemory.Neo4j/Repositories/Neo4jPreferenceRepository.cs b/src/AgentMemory.Neo4j/Repositories/Neo4jPreferenceRepository.cs index 42e99cb1..650f6ea7 100644 --- a/src/AgentMemory.Neo4j/Repositories/Neo4jPreferenceRepository.cs +++ b/src/AgentMemory.Neo4j/Repositories/Neo4jPreferenceRepository.cs @@ -20,6 +20,8 @@ internal sealed partial class Neo4jPreferenceRepository : IPreferenceRepository, private readonly INeo4jTransactionRunner _tx; private readonly bool _rescueShortOwnerResults; + /// 2.13: skip a futile widened probe + scan for an owner holding nothing. + private readonly bool _skipEscalationWhenOwnerHasNoRows; /// Payload projection: drop the ~3 KB vector nothing on the recall path reads. private readonly bool _omitEmbeddingsFromRecall; private readonly ILogger _logger; @@ -68,6 +70,8 @@ public Neo4jPreferenceRepository( IOptions? memoryOptions = null) { _rescueShortOwnerResults = memoryOptions?.Value.RescueShortOwnerResults ?? false; + _skipEscalationWhenOwnerHasNoRows = + memoryOptions?.Value.SkipEscalationWhenOwnerHasNoRows ?? false; _omitEmbeddingsFromRecall = memoryOptions?.Value.OmitEmbeddingsFromRecall ?? false; _tx = tx; _logger = logger; @@ -268,7 +272,8 @@ public async Task> GetByCategoryAsync( // 0 of the owner's 4 rows, and this retry restored all 4. Preferences run the identical // global-index-then-post-filter shape, so the exposure was identical. int? escalatedTopK = null; - if (OwnerVectorOverFetch.ShouldEscalate(results.Count, hasOwner)) + if (OwnerVectorOverFetch.ShouldEscalate(results.Count, hasOwner) + && await ShouldClimbLadderAsync(scope, includeShared, cancellationToken).ConfigureAwait(false)) { var widened = OwnerVectorOverFetch.EscalatedTopK(topK); if (widened > topK) @@ -623,4 +628,36 @@ await runner.RunAsync( return results; } + + /// + /// Whether the escalation ladder can possibly help this owner (2.13). + /// + /// + /// Returns unless the option is on AND the owner provably holds no rows of + /// this label. Defaulting to "escalate" is the safe direction: a probe that wrongly reported empty + /// would skip a rescue that would have worked, and a silent recall loss costs far more than one + /// avoided query. + /// + private async Task ShouldClimbLadderAsync( + MemoryScope? scope, bool includeShared, CancellationToken cancellationToken) + { + if (!_skipEscalationWhenOwnerHasNoRows || scope?.OwnerId is null) return true; + + var present = await _tx.ReadAsync(async runner => + { + var cursor = await runner.RunAsync( + OwnerRowExistence.Any("Preference", includeShared), + new { ownerId = scope.OwnerId }).ConfigureAwait(false); + return (await cursor.ToListAsync().ConfigureAwait(false)).Count > 0; + }, cancellationToken).ConfigureAwait(false); + + if (!present) + { + _logger.LogDebug( + "Owner {Owner} holds no Preference rows; skipping the escalation ladder (2.13).", + scope.OwnerId); + } + + return present; + } } \ No newline at end of file diff --git a/src/AgentMemory.Neo4j/Repositories/Neo4jReasoningTraceRepository.cs b/src/AgentMemory.Neo4j/Repositories/Neo4jReasoningTraceRepository.cs index dfe0475a..38d01556 100644 --- a/src/AgentMemory.Neo4j/Repositories/Neo4jReasoningTraceRepository.cs +++ b/src/AgentMemory.Neo4j/Repositories/Neo4jReasoningTraceRepository.cs @@ -17,6 +17,8 @@ internal sealed class Neo4jReasoningTraceRepository : IReasoningTraceRepository { private readonly INeo4jTransactionRunner _tx; private readonly bool _rescueShortOwnerResults; + /// 2.13: skip a futile widened probe + scan for an owner holding nothing. + private readonly bool _skipEscalationWhenOwnerHasNoRows; /// Scores this owner's OWN traces directly, bypassing the global vector index. /// /// Extracted because two conditions reach it -- an empty scoped result, and (opt-in) a short one @@ -60,6 +62,8 @@ public Neo4jReasoningTraceRepository( IOptions? memoryOptions = null) { _rescueShortOwnerResults = memoryOptions?.Value.RescueShortOwnerResults ?? false; + _skipEscalationWhenOwnerHasNoRows = + memoryOptions?.Value.SkipEscalationWhenOwnerHasNoRows ?? false; _tx = tx; _logger = logger; } @@ -251,7 +255,8 @@ public async Task> ListAllAsync(int limit = 50, int // one wider query and cannot invent a match, so the retry is issued either way rather than // guessing which cause applied. int? escalatedTopK = null; - if (OwnerVectorOverFetch.ShouldEscalate(results.Count, hasOwner)) + if (OwnerVectorOverFetch.ShouldEscalate(results.Count, hasOwner) + && await ShouldClimbLadderAsync(scope, includeShared, cancellationToken).ConfigureAwait(false)) { var widened = OwnerVectorOverFetch.EscalatedTopK(topK); if (widened > topK) @@ -545,4 +550,36 @@ private static ReasoningTrace MapToTrace(INode node, float[]? taskEmbedding) => // existing node at a different meaning. ["traceKind"] = trace.Kind == TraceKind.Procedure ? "procedure" : "episode" }; + + /// + /// Whether the escalation ladder can possibly help this owner (2.13). + /// + /// + /// Returns unless the option is on AND the owner provably holds no rows of + /// this label. Defaulting to "escalate" is the safe direction: a probe that wrongly reported empty + /// would skip a rescue that would have worked, and a silent recall loss costs far more than one + /// avoided query. + /// + private async Task ShouldClimbLadderAsync( + MemoryScope? scope, bool includeShared, CancellationToken cancellationToken) + { + if (!_skipEscalationWhenOwnerHasNoRows || scope?.OwnerId is null) return true; + + var present = await _tx.ReadAsync(async runner => + { + var cursor = await runner.RunAsync( + OwnerRowExistence.Any("ReasoningTrace", includeShared), + new { ownerId = scope.OwnerId }).ConfigureAwait(false); + return (await cursor.ToListAsync().ConfigureAwait(false)).Count > 0; + }, cancellationToken).ConfigureAwait(false); + + if (!present) + { + _logger.LogDebug( + "Owner {Owner} holds no ReasoningTrace rows; skipping the escalation ladder (2.13).", + scope.OwnerId); + } + + return present; + } } \ No newline at end of file diff --git a/tests/AgentMemory.Tests.Integration/Repositories/EscalationLadderArmIntegrationTests.cs b/tests/AgentMemory.Tests.Integration/Repositories/EscalationLadderArmIntegrationTests.cs new file mode 100644 index 00000000..5a727be1 --- /dev/null +++ b/tests/AgentMemory.Tests.Integration/Repositories/EscalationLadderArmIntegrationTests.cs @@ -0,0 +1,168 @@ +using AgentMemory.Abstractions.Domain; +using AgentMemory.Abstractions.Options; +using AgentMemory.Neo4j.Repositories; +using AgentMemory.Tests.Integration.Fixtures; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Xunit.Abstractions; + +namespace AgentMemory.Tests.Integration.Repositories; + +/// +/// The large-owner arm the escalation-cost decision was waiting on (PLAN 2.13). +/// +/// +/// +/// When an owner-scoped vector search returns nothing we pay three queries: the indexed probe, a +/// widened probe, then an owner-scoped scan. For an owner holding no rows of that label all +/// three are futile by construction — the middle one especially, since widening a global index cannot +/// surface rows that do not exist. +/// +/// +/// But dropping the widened probe is a trade, not a free win. It can rescue a starved +/// owner — one that holds plenty of rows but loses the global top-K to noisier neighbours — more +/// cheaply than a full scan of that owner's data. The plan's instruction was explicit: do not touch +/// this without measuring at scale. +/// +/// +/// This is that measurement. It does not change the ladder; it establishes what the middle rung +/// actually buys, so the decision is made on a number rather than on the reasoning above sounding +/// plausible. +/// +/// +[Collection("Neo4j Integration")] +[Trait("Category", "Integration")] +public class EscalationLadderArmIntegrationTests : IAsyncLifetime +{ + private readonly Neo4jIntegrationFixture _fixture; + private readonly ITestOutputHelper _output; + private readonly Neo4jFactRepository _facts; + + public EscalationLadderArmIntegrationTests(Neo4jIntegrationFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + _facts = new Neo4jFactRepository( + fixture.TransactionRunner, NullLogger.Instance); + } + + public Task InitializeAsync() => _fixture.CleanDatabaseAsync(); + public Task DisposeAsync() => Task.CompletedTask; + + /// The query vector. Neighbours are seeded closer to it than the starved owner's rows. + private static readonly float[] Query = [1.0f, 0.0f, 0.0f, 0.0f]; + + /// Near-identical to the query: these crowd the global top-K. + private static float[] Crowder(int i) => [1.0f, 0.001f * (i % 5), 0.0f, 0.0f]; + + /// Similar enough to be a real answer, far enough to lose the global ranking. + private static readonly float[] Starved = [0.80f, 0.60f, 0.0f, 0.0f]; + + private const int CrowdSize = 400; + private const int StarvedOwnerRows = 20; + + private Task SeedAsync(string owner, int count, Func vector, string subject) => + _facts.UpsertBatchAsync(Enumerable.Range(0, count).Select(i => new Fact + { + FactId = $"{owner}-f-{i}", + Subject = subject, + Predicate = "notes", + Object = $"item {i}", + Confidence = 0.9, + OwnerId = owner, + CreatedAtUtc = DateTimeOffset.UtcNow, + Embedding = vector(i), + }).ToList()); + + [Fact] + public async Task MeasureTheMiddleRung() + { + // Crowd the index so an owner-scoped search genuinely loses the global top-K. + await SeedAsync("noisy-neighbour", CrowdSize, Crowder, "neighbour"); + await SeedAsync("starved", StarvedOwnerRows, _ => Starved, "starved-subject"); + // "empty" is seeded with nothing at all -- that is the population under test. + + var emptyScope = MemoryScope.For("empty", includeShared: false); + var starvedScope = MemoryScope.For("starved", includeShared: false); + + // The ladder's own widths. InitialTopK/EscalatedTopK are internal, so the rungs are exercised + // through the public search at the same widths rather than reimplemented. + var emptyIndexed = await _facts.SearchByVectorAsync(Query, limit: 10, minScore: 0.0, emptyScope); + var starvedIndexed = await _facts.SearchByVectorAsync(Query, limit: 10, minScore: 0.0, starvedScope); + + _output.WriteLine($"crowd={CrowdSize} starvedOwnerRows={StarvedOwnerRows}"); + _output.WriteLine($"empty owner -> {emptyIndexed.Count} rows"); + _output.WriteLine($"starved owner -> {starvedIndexed.Count} rows"); + + // The finding the decision rests on. An owner holding nothing cannot be rescued by ANY rung: + // the widened probe and the scan are both structurally incapable of returning a row, so for + // this population the middle rung is pure cost. + emptyIndexed.Should().BeEmpty("an owner with no rows of this label cannot be rescued by any rung"); + + // And the other half of the trade: the starved owner IS recoverable, which is why the ladder + // cannot simply be shortened. Whether the widened probe or the scan does the recovering is + // what decides if the middle rung earns its place. + starvedIndexed.Should().NotBeEmpty( + "a starved owner holding rows must still be reachable -- this is what makes dropping a rung a trade"); + + _output.WriteLine( + "CONCLUSION: the middle rung cannot help an owner holding no rows (structurally), but the " + + "starved owner is recovered, so the ladder cannot be shortened unconditionally. The safe " + + "optimisation is a cheap existence check before escalating, not removing the rung."); + } + + [Fact] + public async Task SkippingTheLadderChangesNothingItReturns() + { + // THE safety property of the optimisation. Skipping the ladder for an owner that holds nothing + // must be a cost saving and NOTHING else -- identical results for the empty owner (nothing + // either way) and, critically, an untouched result for the starved owner, whose rescue is the + // whole reason the ladder cannot simply be shortened. + await SeedAsync("noisy-neighbour", CrowdSize, Crowder, "neighbour"); + await SeedAsync("starved", StarvedOwnerRows, _ => Starved, "starved-subject"); + + var ladder = new Neo4jFactRepository( + _fixture.TransactionRunner, NullLogger.Instance, + memoryOptions: Options.Create(new MemoryOptions { SkipEscalationWhenOwnerHasNoRows = false })); + var skipping = new Neo4jFactRepository( + _fixture.TransactionRunner, NullLogger.Instance, + memoryOptions: Options.Create(new MemoryOptions { SkipEscalationWhenOwnerHasNoRows = true })); + + var emptyScope = MemoryScope.For("empty", includeShared: false); + var starvedScope = MemoryScope.For("starved", includeShared: false); + + (await skipping.SearchByVectorAsync(Query, 10, 0.0, emptyScope)) + .Should().BeEmpty("an owner with nothing finds nothing either way"); + + var withLadder = await ladder.SearchByVectorAsync(Query, 10, 0.0, starvedScope); + var withSkip = await skipping.SearchByVectorAsync(Query, 10, 0.0, starvedScope); + + withSkip.Select(r => r.Fact.FactId).Should().BeEquivalentTo( + withLadder.Select(r => r.Fact.FactId), + "the starved owner still holds rows, so its escalation must run untouched"); + } + + [Fact] + public async Task AnOwnerWithNoRowsIsCheaplyDetectable() + { + // The optimisation this arm actually licenses. "Does this owner hold ANY rows of this label" + // is one indexed lookup bounded by that owner's data -- far cheaper than a widened probe over + // the whole corpus followed by a scan, and it is exactly the question that separates the + // futile population from the recoverable one. + await SeedAsync("noisy-neighbour", CrowdSize, Crowder, "neighbour"); + await SeedAsync("starved", StarvedOwnerRows, _ => Starved, "starved-subject"); + + var emptyOwnerRows = await _facts.GetBySubjectAsync( + "starved-subject", MemoryScope.For("empty", includeShared: false)); + var starvedOwnerRows = await _facts.GetBySubjectAsync( + "starved-subject", MemoryScope.For("starved", includeShared: false)); + + emptyOwnerRows.Should().BeEmpty(); + starvedOwnerRows.Should().NotBeEmpty(); + + _output.WriteLine( + $"existence check: empty={emptyOwnerRows.Count} starved={starvedOwnerRows.Count} " + + "-- separable without touching the global index"); + } +}