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:
+2
-1
@@ -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(", ")}"
|
||||
|
||||
+42
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user