diff --git a/src/Netclaw.Actors.Tests/Sessions/DeterministicCandidateSelectorTests.cs b/src/Netclaw.Actors.Tests/Sessions/DeterministicCandidateSelectorTests.cs
index cc9b7e701..ce609090b 100644
--- a/src/Netclaw.Actors.Tests/Sessions/DeterministicCandidateSelectorTests.cs
+++ b/src/Netclaw.Actors.Tests/Sessions/DeterministicCandidateSelectorTests.cs
@@ -75,10 +75,12 @@ public void Candidate_with_single_lexical_match_survives_threshold()
public void Baseline_only_candidates_excluded_from_scored_results()
{
var selector = new DeterministicCandidateSelector();
- var plan = MakePlan(lexicalTerms: ["kubernetes"]);
- // Cross-domain noise item has no lexical or domain match — baseline only.
+ // Use a lexical term that matches document text exactly (no plural
+ // normalization mismatch). Token "docker" is unaffected by the
+ // tokenizer's plural rules.
+ var plan = MakePlan(lexicalTerms: ["docker"]);
var noise = MakeItem("doc-noise", "Unrelated", "Something about databases.", domain: "project:other");
- var relevant = MakeItem("doc-relevant", "K8s Guide", "Deploy to kubernetes cluster.");
+ var relevant = MakeItem("doc-relevant", "Docker Guide", "Docker container deployment notes.");
var result = selector.SelectWithScores(plan, [noise, relevant]);
@@ -88,8 +90,13 @@ public void Baseline_only_candidates_excluded_from_scored_results()
}
[Fact]
- public void Same_domain_candidate_ranks_higher_than_cross_domain()
+ public void Domain_is_not_a_scoring_signal()
{
+ // Domain affinity was intentionally removed — the concept is
+ // half-implemented (Protocol.SessionId.ToMemoryDomain always returns
+ // project:default) and it was polluting the composite-score floor.
+ // Same-domain and cross-domain candidates with identical content
+ // must score identically. Tracked in #584.
var selector = new DeterministicCandidateSelector();
var plan = MakePlan(
hardScope: "project:d0ac6ckbk5k",
@@ -98,10 +105,10 @@ public void Same_domain_candidate_ranks_higher_than_cross_domain()
var sameDomain = MakeItem("doc-same", "Company: Petabridge", "Petabridge builds Akka.NET.", domain: "project:d0ac6ckbk5k");
var crossDomain = MakeItem("doc-cross", "Company: Petabridge", "Petabridge builds Akka.NET.", domain: "project:signalr");
- var result = selector.Select(plan, [crossDomain, sameDomain]);
+ var result = selector.SelectWithScores(plan, [crossDomain, sameDomain]);
Assert.Equal(2, result.Count);
- Assert.Equal("doc-same", result[0].Id);
+ Assert.Equal(result[0].SelectorScore, result[1].SelectorScore);
}
[Fact]
@@ -131,4 +138,126 @@ public void Evidence_class_candidates_are_selected()
Assert.Single(result);
}
+
+ // Score geometry documentation. These tests document the gradient a
+ // downstream composite-score floor will see: weaker matches score lower
+ // than stronger matches, and the spread between "single feature hit" and
+ // "multi-feature hit" is large enough to be a useful discriminator.
+ //
+ // Note: TextTokenizer normalizes plurals ("streams" -> "stream") and
+ // treats hyphenated words as single tokens. Lexical terms here are kept
+ // in normalized singular form so they match what the tokenizer produces.
+ // In production, the planner also runs prompts through TextTokenizer so
+ // plan.LexicalTerms is consistent with document tokens by construction.
+
+ [Fact]
+ public void Score_geometry_stronger_matches_outrank_weaker_matches()
+ {
+ var selector = new DeterministicCandidateSelector();
+ var plan = MakePlan(
+ lexicalTerms: ["akka", "stream", "backpressure", "demand"],
+ hardScope: "project:d0ac6ckbk5k");
+
+ var weak = MakeItem(
+ "doc-weak",
+ "Unrelated Guide",
+ "This note mentions akka once, nothing else.",
+ domain: "project:other");
+ var medium = MakeItem(
+ "doc-medium",
+ "Akka Stream Overview",
+ "Akka stream uses demand signalling.",
+ domain: "project:other");
+ var strong = MakeItem(
+ "doc-strong",
+ "Akka Stream Backpressure",
+ "Demand backpressure flow control in akka stream.",
+ domain: "project:d0ac6ckbk5k");
+
+ var result = selector.SelectWithScores(plan, [weak, medium, strong]);
+
+ Assert.Equal(3, result.Count);
+ // Results are returned in descending order of SelectorScore.
+ Assert.Equal("doc-strong", result[0].Item.Id);
+ Assert.Equal("doc-medium", result[1].Item.Id);
+ Assert.Equal("doc-weak", result[2].Item.Id);
+
+ // Document the spread: strongest match should be at least 2x the
+ // weakest. If this ever drops below that, the composite floor loses
+ // its ability to discriminate and we need to rebalance.
+ Assert.True(
+ result[0].SelectorScore >= result[2].SelectorScore * 2,
+ $"Expected strong/weak spread of 2x+, got {result[0].SelectorScore}/{result[2].SelectorScore}");
+ }
+
+ [Fact]
+ public void Score_geometry_facet_match_adds_meaningful_weight()
+ {
+ var selector = new DeterministicCandidateSelector();
+ var plan = MakePlan(
+ lexicalTerms: ["stream"],
+ facets: ["akka-streams"],
+ hardScope: "project:other");
+
+ var withoutFacet = MakeItem(
+ "doc-no-facet",
+ "Akka Stream",
+ "Backpressure in akka stream.",
+ domain: "project:other");
+ var withFacet = new SQLiteMemoryHydratedItem(
+ Id: "doc-with-facet",
+ Kind: "document",
+ MemoryClass: "durable_fact",
+ Title: "Akka Stream",
+ Content: "Backpressure in akka stream.",
+ AliasesJson: null,
+ FacetsJson: "[\"akka-streams\"]",
+ SlotsJson: null,
+ Domain: "project:other",
+ Boundary: "boundary:trusted-instance",
+ Audience: "public",
+ Sensitivity: "normal",
+ RecallMode: "auto",
+ UpdateSemantics: "merge-document",
+ ExpiresAtMs: null,
+ UpdatedAtMs: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
+
+ var result = selector.SelectWithScores(plan, [withoutFacet, withFacet]);
+
+ Assert.Equal(2, result.Count);
+ Assert.Equal("doc-with-facet", result[0].Item.Id);
+ Assert.True(
+ result[0].SelectorScore - result[1].SelectorScore >= 5.0,
+ $"Facet match should add at least 5 points; got delta {result[0].SelectorScore - result[1].SelectorScore}");
+ }
+
+ [Fact]
+ public void Score_geometry_anchor_match_adds_meaningful_weight()
+ {
+ var selector = new DeterministicCandidateSelector();
+ var plan = MakePlan(
+ lexicalTerms: ["stream"],
+ anchorHints: ["Akka Stream Backpressure"],
+ hardScope: "project:other");
+
+ var noAnchor = MakeItem(
+ "doc-no-anchor",
+ "Something Else",
+ "Akka stream is useful.",
+ domain: "project:other");
+ var withAnchor = MakeItem(
+ "doc-with-anchor",
+ "Akka Stream Backpressure",
+ "Demand in akka stream.",
+ domain: "project:other");
+
+ var result = selector.SelectWithScores(plan, [noAnchor, withAnchor]);
+
+ Assert.Equal(2, result.Count);
+ Assert.Equal("doc-with-anchor", result[0].Item.Id);
+ Assert.True(
+ result[0].SelectorScore - result[1].SelectorScore >= 7.0,
+ $"Anchor match should add at least 7 points; got delta {result[0].SelectorScore - result[1].SelectorScore}");
+ }
+
}
diff --git a/src/Netclaw.Actors.Tests/Sessions/MemoryRecallScenarioTests.cs b/src/Netclaw.Actors.Tests/Sessions/MemoryRecallScenarioTests.cs
new file mode 100644
index 000000000..80384defe
--- /dev/null
+++ b/src/Netclaw.Actors.Tests/Sessions/MemoryRecallScenarioTests.cs
@@ -0,0 +1,334 @@
+using Microsoft.Data.Sqlite;
+using Microsoft.Extensions.Logging.Abstractions;
+using Netclaw.Actors.Memory;
+using Netclaw.Actors.Sessions;
+using Netclaw.Configuration;
+using Xunit;
+
+namespace Netclaw.Actors.Tests.Sessions;
+
+///
+/// Scenario suite for the memory recall composite-score floor (issue #582).
+///
+/// Seeds a 16-document corpus mirroring the production DB shape that caused
+/// the pollution bug (a cluster of ops/eval trivia plus two topical clusters
+/// of legitimate content), then drives a table of 18 prompts through the
+/// real and asserts which
+/// memories may or may not appear in the recall result for each prompt.
+///
+/// Fixture table is documented in memorizer: "Netclaw Memory Recall Floor —
+/// Test Scenario Fixture (issue #582)".
+///
+/// Diagnostic rows are the ones where the floor is doing real work:
+/// P11–P14 (lexical collisions against the noise band) and P16 (hard
+/// negative against an ops-heavy corpus).
+///
+/// Document-vs-record priority is a separate concern handled by RecallRank
+/// weights, not the composite floor, and is deliberately out of scope here.
+/// The corpus contains only durable-fact documents.
+///
+public sealed class MemoryRecallScenarioTests : IAsyncLifetime
+{
+ // The coordinator normalizes every session's hard scope to
+ // SecurityPolicyDefaults.DefaultMemoryDomain via ToMemoryDomain() before
+ // the planner runs (tracked in #584). Seeding here into the same domain
+ // reproduces the actual production DB layout that caused issue #582 —
+ // cross-domain test fixtures were silently exercising a different
+ // scoring regime than the real bug.
+ private const string TestDomain = "project:default";
+ private const string TestSessionId = "test/thread-1";
+
+ private readonly string _baseDir = Path.Combine(
+ Path.GetTempPath(),
+ "netclaw-recall-scenarios",
+ Guid.NewGuid().ToString("N"));
+ private readonly string _dbPath;
+ private readonly SQLiteMemoryStore _store;
+
+ public MemoryRecallScenarioTests()
+ {
+ Directory.CreateDirectory(_baseDir);
+ _dbPath = Path.Combine(_baseDir, "netclaw-recall-scenarios.db");
+ _store = new SQLiteMemoryStore(_dbPath, TimeProvider.System);
+ }
+
+ public async ValueTask InitializeAsync()
+ {
+ await _store.InitializeAsync(CancellationToken.None);
+ await SeedCorpusAsync(CancellationToken.None);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await TryDeleteDirectoryAsync(_baseDir);
+ }
+
+ ///
+ /// Scenario rows: (id, prompt, expectedIds, forbiddenIds).
+ /// Don't-care IDs (see P15) are simply absent from both lists.
+ ///
+ public static IEnumerable