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");
+ }
+}