feat(memory): state-keyed repo-map reuse via L3 presence check; embed entries on fresh scan

- Add existsByTurnIdPrefix(prefix) to L3MemoryStore interface; implemented in
  InMemoryL3MemoryStore (mutex-guarded) and TurboVecL3MemoryStore (metadata map).
  All test fakes (TrackingL3MemoryStore, CapturingStore) updated.
- Introduce RepoMapIndexerPort interface; RepoMapIndexer implements it. Allows
  injection of test doubles without framework overhead.
- ProjectMemoryService.indexAndRecord: reads latest WorkspaceStateObservedEvent
  from event store (invariant #9 — recorded fact, not re-probe); skips scan+embed
  when L3 already holds entries for "repomap:<root>:<stateKey>"; passes stateKey
  to RepoMapComputedEvent; embeds each scanned entry after append; per-entry
  failures warn-logged (CancellationException rethrown).
- Tests: InMemoryL3MemoryStoreTest +2 cases; new ProjectMemoryServiceReuseTest
  covers same-key skip, changed-key re-index, no-observation always-reindex,
  and embedding turnId tag verification.
This commit is contained in:
2026-06-11 13:30:57 +04:00
parent 4dbe637f4a
commit 9d38a89c88
9 changed files with 200 additions and 3 deletions
@@ -41,7 +41,7 @@ class ProjectMemoryService(
private val journalRepository: DefaultDecisionJournalRepository,
private val eventStore: EventStore,
private val distiller: ProjectMemoryDistiller = ProjectMemoryDistiller(),
private val indexer: RepoMapIndexer = RepoMapIndexer(),
private val indexer: RepoMapIndexerPort = RepoMapIndexer(),
private val workspaceStateProbe: WorkspaceStateProbe = RealWorkspaceStateProbe(),
) {
private fun tag(repoRoot: String) = "project:$repoRoot"
@@ -56,6 +56,13 @@ class ProjectMemoryService(
*/
suspend fun indexAndRecord(sessionId: SessionId, repoRoot: String) {
if (!config.enabled) return
val stateKey = eventStore.read(sessionId)
.mapNotNull { it.payload as? WorkspaceStateObservedEvent }
.lastOrNull()?.stateKey
if (stateKey != null && l3MemoryStore.existsByTurnIdPrefix("repomap:$repoRoot:$stateKey")) {
log.debug("repo-map already indexed for {}@{} — skipping", repoRoot, stateKey)
return
}
runCatching {
val entries = indexer.index(java.nio.file.Path.of(repoRoot))
if (entries.isEmpty()) return
@@ -73,10 +80,30 @@ class ProjectMemoryService(
sessionId = sessionId,
repoRoot = repoRoot,
entries = entries,
stateKey = stateKey,
computedAt = Clock.System.now(),
),
),
)
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(", ")}"
l3MemoryStore.store(
L3MemoryEntry(
id = UUID.randomUUID().toString(),
sessionId = sessionId,
turnId = tag,
text = text,
vector = embedder.embed(text),
timestampMs = Clock.System.now().toEpochMilliseconds(),
),
)
}.exceptionOrNull()?.let { e ->
if (e is CancellationException) throw e
log.warn("repo-map embedding failed for {}: {}", entry.path, e.message)
}
}
}.exceptionOrNull()?.let { e ->
if (e is CancellationException) throw e
log.warn("repo-map indexing failed for {}: {}", repoRoot, e.message)
@@ -11,6 +11,11 @@ import kotlin.io.path.name
import kotlin.io.path.readText
import kotlin.io.path.relativeTo
/** Minimal port for repo-map scanning — allows injection of test doubles. */
interface RepoMapIndexerPort {
fun index(repoRoot: Path): List<RepoMapEntry>
}
/**
* Walks a repo and produces a ranked file/symbol index. Reads the filesystem (a
* nondeterministic environment observation) — the caller records the result as a
@@ -24,8 +29,8 @@ import kotlin.io.path.relativeTo
class RepoMapIndexer(
private val maxDepth: Int = 4,
private val ignoreGlobs: List<String> = emptyList(),
) {
fun index(repoRoot: Path): List<RepoMapEntry> {
) : RepoMapIndexerPort {
override fun index(repoRoot: Path): List<RepoMapEntry> {
if (!repoRoot.isDirectory()) return emptyList()
val ignore = ignoreGlobs.toSet()
@@ -0,0 +1,124 @@
package com.correx.apps.server.memory
import com.correx.core.config.ProjectConfig
import com.correx.core.events.events.RepoMapEntry
import com.correx.core.inference.Embedder
import com.correx.core.journal.DecisionJournalProjector
import com.correx.core.journal.DefaultDecisionJournalReducer
import com.correx.core.journal.DefaultDecisionJournalRepository
import com.correx.core.router.l3.InMemoryL3MemoryStore
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
import com.correx.core.events.types.SessionId
import com.correx.infrastructure.persistence.InMemoryEventStore
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.nio.file.Path
private class CountingIndexer(private val entries: List<RepoMapEntry>) : RepoMapIndexerPort {
var callCount = 0
override fun index(repoRoot: Path): List<RepoMapEntry> {
callCount++
return entries
}
}
private class ConstantEmbedderReuse(override val dimension: Int = 4) : Embedder {
override suspend fun embed(text: String): FloatArray = FloatArray(dimension) { 0.5f }
}
class ProjectMemoryServiceReuseTest {
private val entries = listOf(
RepoMapEntry(path = "src/Foo.kt", score = 1.0, symbols = listOf("FooClass")),
)
private fun service(
es: InMemoryEventStore,
l3: InMemoryL3MemoryStore,
indexer: CountingIndexer,
probe: WorkspaceStateProbe,
root: String = "/repo",
) = ProjectMemoryService(
config = ProjectConfig(enabled = true, root = root),
embedder = ConstantEmbedderReuse(),
l3MemoryStore = l3,
journalRepository = DefaultDecisionJournalRepository(
DefaultEventReplayer(es, DecisionJournalProjector(DefaultDecisionJournalReducer())),
),
eventStore = es,
indexer = indexer,
workspaceStateProbe = probe,
)
@Test
fun `same stateKey skips re-index on second session`(): Unit = runBlocking {
val es = InMemoryEventStore()
val l3 = InMemoryL3MemoryStore()
val indexer = CountingIndexer(entries)
val probe = FakeWorkspaceStateProbe(WorkspaceState("git:hash1", "git", "main", false))
val svc = service(es, l3, indexer, probe)
val sessionA = SessionId("session-reuse-a")
val sessionB = SessionId("session-reuse-b")
svc.observeAndRecord(sessionA, "/repo")
svc.indexAndRecord(sessionA, "/repo")
assertEquals(1, indexer.callCount, "first session should index")
svc.observeAndRecord(sessionB, "/repo")
svc.indexAndRecord(sessionB, "/repo")
assertEquals(1, indexer.callCount, "second session with same stateKey should skip index")
}
@Test
fun `changed stateKey re-indexes`(): Unit = runBlocking {
val es = InMemoryEventStore()
val l3 = InMemoryL3MemoryStore()
val indexer = CountingIndexer(entries)
val svc1 = service(es, l3, indexer, FakeWorkspaceStateProbe(WorkspaceState("git:hash1", "git", "main", false)))
val sessionA = SessionId("session-change-a")
svc1.observeAndRecord(sessionA, "/repo")
svc1.indexAndRecord(sessionA, "/repo")
assertEquals(1, indexer.callCount)
val svc2 = service(es, l3, indexer, FakeWorkspaceStateProbe(WorkspaceState("git:hash2", "git", "main", false)))
val sessionB = SessionId("session-change-b")
svc2.observeAndRecord(sessionB, "/repo")
svc2.indexAndRecord(sessionB, "/repo")
assertEquals(2, indexer.callCount, "changed stateKey should trigger re-index")
}
@Test
fun `no observation event always re-indexes`(): Unit = runBlocking {
val es = InMemoryEventStore()
val l3 = InMemoryL3MemoryStore()
val indexer = CountingIndexer(entries)
val probe = FakeWorkspaceStateProbe(null)
val svc = service(es, l3, indexer, probe)
svc.indexAndRecord(SessionId("session-noobs-1"), "/repo")
svc.indexAndRecord(SessionId("session-noobs-2"), "/repo")
assertEquals(2, indexer.callCount, "no observation event -> always re-index")
}
@Test
fun `entries are embedded with stateKey turnId tag after fresh index`(): Unit = runBlocking {
val es = InMemoryEventStore()
val l3 = InMemoryL3MemoryStore()
val indexer = CountingIndexer(entries)
val probe = FakeWorkspaceStateProbe(WorkspaceState("git:hash1", "git", "main", false))
val svc = service(es, l3, indexer, probe)
val sessionId = SessionId("session-embed-1")
svc.observeAndRecord(sessionId, "/repo")
svc.indexAndRecord(sessionId, "/repo")
assertTrue(
l3.existsByTurnIdPrefix("repomap:/repo:git:hash1"),
"entries should be embedded with stateKey tag",
)
}
}