fix(memory): turnId repoRoot prefix collision (/repo vs /repo2)

The repo-map turnId tag and the L3 retriever filter used 'repomap:$repoRoot' with no
delimiter, so a retriever for /repo also matched /repo2 entries (startsWith). Add a trailing
':' to the tag (no-stateKey branch) and the retriever filter so the repoRoot boundary is
explicit. Test: /repo retrieval no longer leaks /repo2 entries, still matches its own
with/without a stateKey suffix.
This commit is contained in:
2026-06-15 00:50:04 +04:00
parent 2a37266f53
commit df76e7f2fb
3 changed files with 47 additions and 2 deletions
@@ -17,7 +17,8 @@ class L3RepoKnowledgeRetriever(
override suspend fun retrieve(sessionId: SessionId, query: String, k: Int): List<RepoKnowledgeHit> {
val vector = embedder.embed(query)
return l3MemoryStore.query(L3Query(vector = vector, k = k * RETRIEVAL_OVERSAMPLE_FACTOR))
.filter { it.entry.turnId.startsWith("repomap:$repoRoot") }
// Trailing ':' so "/repo" does not also match "/repo2" turnIds (prefix collision).
.filter { it.entry.turnId.startsWith("repomap:$repoRoot:") }
.take(k)
.map { RepoKnowledgeHit(path = it.entry.text.substringBefore(":"), text = it.entry.text, score = it.score) }
}
@@ -85,7 +85,9 @@ class ProjectMemoryService(
),
),
)
val tag = if (stateKey != null) "repomap:$repoRoot:$stateKey" else "repomap:$repoRoot"
// Trailing ':' delimiter so a repoRoot prefix can't collide with a longer sibling
// (/repo vs /repo2) under the retriever's startsWith filter.
val tag = if (stateKey != null) "repomap:$repoRoot:$stateKey" else "repomap:$repoRoot:"
entries.forEach { entry ->
runCatching {
val text = entry.path + if (entry.symbols.isEmpty()) "" else ": ${entry.symbols.joinToString(", ")}"
@@ -0,0 +1,42 @@
package com.correx.apps.server.memory
import com.correx.core.events.types.SessionId
import com.correx.core.inference.Embedder
import com.correx.core.router.l3.InMemoryL3MemoryStore
import com.correx.core.router.l3.L3MemoryEntry
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
private class RetrieverConstantEmbedder(override val dimension: Int = 4) : Embedder {
override suspend fun embed(text: String): FloatArray = FloatArray(dimension) { 0.5f }
}
class L3RepoKnowledgeRetrieverTest {
private val vec = FloatArray(4) { 0.5f }
@Test
fun `retriever for a repoRoot does not match a sibling whose path it prefixes`(): Unit = runBlocking {
val l3 = InMemoryL3MemoryStore()
l3.store(L3MemoryEntry("1", SessionId("s"), "repomap:/repo:git:h", "repo/A.kt: Foo", vec, 0L))
l3.store(L3MemoryEntry("2", SessionId("s"), "repomap:/repo2:git:h", "repo2/B.kt: Bar", vec, 0L))
val hits = L3RepoKnowledgeRetriever(RetrieverConstantEmbedder(), l3, "/repo")
.retrieve(SessionId("s"), "anything", 10)
assertEquals(listOf("repo/A.kt: Foo"), hits.map { it.text }, "must not leak /repo2 entries into /repo")
}
@Test
fun `retriever matches its own repoRoot entries with or without a stateKey suffix`(): Unit = runBlocking {
val l3 = InMemoryL3MemoryStore()
l3.store(L3MemoryEntry("1", SessionId("s"), "repomap:/repo:git:h1", "repo/A.kt", vec, 0L))
l3.store(L3MemoryEntry("2", SessionId("s"), "repomap:/repo:", "repo/B.kt", vec, 0L))
val hits = L3RepoKnowledgeRetriever(RetrieverConstantEmbedder(), l3, "/repo")
.retrieve(SessionId("s"), "anything", 10)
assertEquals(setOf("repo/A.kt", "repo/B.kt"), hits.map { it.text }.toSet())
}
}