test(replay,integration): repo-knowledge replay derivation + state-keyed scan reuse

Adds two test files to prove B4/B3 invariants end-to-end:

- RepoKnowledgeReplayTest (testing/replay): projection-based replay tests that
  verify recorded RepoKnowledgeRetrievedEvent drives relevantFiles context assembly
  without consulting a retriever (test A), and that a log without the retrieval event
  falls back to the RepoMapComputedEvent legacy path (test B).

- RepoMapReuseIntegrationTest (testing/integration): end-to-end test using the real
  ProjectMemoryService + L3RepoKnowledgeRetriever + InMemoryL3MemoryStore proving
  exactly one index walk across two sessions sharing the same stateKey, and that the
  second session receives non-empty repo hits via the shared L3 embeddings despite
  having no per-session RepoMapComputedEvent.

New test deps (noted per constraint):
- testing/replay: adds core:context (needed to import ContextLayer/ContextEntry from
  the buildRelevantFilesEntry return type).
- testing/integration: adds apps:server (ProjectMemoryService, L3RepoKnowledgeRetriever,
  FakeWorkspaceStateProbe), core:router (InMemoryL3MemoryStore), core:config (ProjectConfig).
  No new inter-core or upward production dependencies introduced.
This commit is contained in:
2026-06-11 13:52:06 +04:00
parent 7a9e060a55
commit 1976d8ad2b
4 changed files with 287 additions and 0 deletions
+3
View File
@@ -28,4 +28,7 @@ dependencies {
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
testImplementation(project(":infrastructure:tools"))
testImplementation(project(":infrastructure:tools:filesystem"))
testImplementation(project(":apps:server"))
testImplementation(project(":core:router"))
testImplementation(project(":core:config"))
}
@@ -0,0 +1,140 @@
import com.correx.apps.server.memory.FakeWorkspaceStateProbe
import com.correx.apps.server.memory.L3RepoKnowledgeRetriever
import com.correx.apps.server.memory.ProjectMemoryService
import com.correx.apps.server.memory.RepoMapIndexerPort
import com.correx.apps.server.memory.WorkspaceState
import com.correx.core.config.ProjectConfig
import com.correx.core.events.events.RepoMapEntry
import com.correx.core.events.types.SessionId
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.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
}
}
/**
* Deterministic embedder: encodes each text as a stable float vector using char codepoint sums.
* Guarantees non-trivial cosine similarity separation between distinct texts while keeping
* retrieval deterministic (no RNG, no network).
*/
private class DeterministicEmbedder(override val dimension: Int = 8) : Embedder {
override suspend fun embed(text: String): FloatArray {
val v = FloatArray(dimension)
text.forEachIndexed { i, c -> v[i % dimension] += c.code.toFloat() }
val norm = kotlin.math.sqrt(v.map { it * it }.sum()).coerceAtLeast(1e-6f)
return FloatArray(dimension) { v[it] / norm }
}
}
/**
* Integration test: two sessions sharing the same workspace stateKey and L3 store.
*
* Proves:
* 1. Exactly ONE index walk across both sessions (state-keyed reuse via L3 presence check).
* 2. The second session's retriever returns non-empty hits from the shared L3 embeddings —
* demonstrating that repo context is available without a per-session RepoMapComputedEvent.
*
* Note: this test depends on apps:server (ProjectMemoryService, L3RepoKnowledgeRetriever,
* FakeWorkspaceStateProbe) and core:router (InMemoryL3MemoryStore). These were added to
* testing/integration's classpath to allow end-to-end coverage without duplicating the
* production classes inline.
*/
class RepoMapReuseIntegrationTest {
private val repoRoot = "/repo-integration"
private val stateKey = "git:abc123"
private val entries = listOf(
RepoMapEntry(path = "src/Parser.kt", score = 1.0, symbols = listOf("Parser", "Token")),
RepoMapEntry(path = "src/Lexer.kt", score = 0.8, symbols = listOf("Lexer")),
)
@Test
fun `same stateKey — exactly one index walk across two sessions`(): Unit = runBlocking {
val es = InMemoryEventStore()
val l3 = InMemoryL3MemoryStore()
val indexer = CountingIndexer(entries)
val embedder = DeterministicEmbedder()
val probe = FakeWorkspaceStateProbe(WorkspaceState(stateKey, "git", "main", false))
val svc = buildService(es, l3, indexer, embedder, probe)
val sessionA = SessionId("integration-reuse-a")
val sessionB = SessionId("integration-reuse-b")
svc.observeAndRecord(sessionA, repoRoot)
svc.indexAndRecord(sessionA, repoRoot)
assertEquals(1, indexer.callCount, "first session must trigger index walk")
svc.observeAndRecord(sessionB, repoRoot)
svc.indexAndRecord(sessionB, repoRoot)
assertEquals(1, indexer.callCount, "second session with same stateKey must skip index walk")
}
@Test
fun `second session retrieves repo content from shared L3 embeddings`(): Unit = runBlocking {
val es = InMemoryEventStore()
val l3 = InMemoryL3MemoryStore()
val indexer = CountingIndexer(entries)
val embedder = DeterministicEmbedder()
val probe = FakeWorkspaceStateProbe(WorkspaceState(stateKey, "git", "main", false))
val svc = buildService(es, l3, indexer, embedder, probe)
val sessionA = SessionId("integration-retrieve-a")
val sessionB = SessionId("integration-retrieve-b")
// Session A: observe + index → entries embedded into shared L3.
svc.observeAndRecord(sessionA, repoRoot)
svc.indexAndRecord(sessionA, repoRoot)
// Session B: observe + skip scan (stateKey reuse).
svc.observeAndRecord(sessionB, repoRoot)
svc.indexAndRecord(sessionB, repoRoot)
// Session B has no per-session RepoMapComputedEvent but retriever reads shared L3.
val retriever = L3RepoKnowledgeRetriever(
embedder = embedder,
l3MemoryStore = l3,
repoRoot = repoRoot,
)
val hits = retriever.retrieve(sessionB, "parse the input", k = 5)
assertTrue(hits.isNotEmpty(), "second session must receive repo content from shared L3 embeddings")
val allText = hits.joinToString(" ") { it.text }
assertTrue(
allText.contains("src/Parser.kt") || allText.contains("src/Lexer.kt"),
"retrieved hits must reference indexed files; got: $allText",
)
}
private fun buildService(
es: InMemoryEventStore,
l3: InMemoryL3MemoryStore,
indexer: CountingIndexer,
embedder: Embedder,
probe: FakeWorkspaceStateProbe,
) = ProjectMemoryService(
config = ProjectConfig(enabled = true, root = repoRoot),
embedder = embedder,
l3MemoryStore = l3,
journalRepository = DefaultDecisionJournalRepository(
DefaultEventReplayer(es, DecisionJournalProjector(DefaultDecisionJournalReducer())),
),
eventStore = es,
indexer = indexer,
workspaceStateProbe = probe,
)
}