feat: wire L3 write path via Embedder into router

Introduces Embedder contract in core:inference with a NoopEmbedder
default (zero vector) and wires both Embedder and L3MemoryStore into
DefaultRouterFacade. Each ChatTurnEvent emission now embeds the turn
content and writes a corresponding L3MemoryEntry, so cross-session
memory is captured at the source of truth (the event emission point).

InfrastructureModule defaults to NoopEmbedder + InMemoryL3MemoryStore
so the system runs without an external embedding model or vector store
wired. The TurboVec adapter stays unwired for now; switching to it is
a configuration concern (Epic 12 config layer).

Also gitignores apps/tui/logs/ (Kotlin TUI runtime logs, pre-Go rewrite).
This commit is contained in:
2026-05-30 01:01:40 +04:00
parent a3f29e6eb9
commit 9f171d3236
6 changed files with 113 additions and 1 deletions
+3
View File
@@ -74,3 +74,6 @@ correx-tui.log
current.log current.log
# Go TUI build output # Go TUI build output
apps/tui-go/bin/ apps/tui-go/bin/
# TUI runtime logs (Kotlin TUI, pre-Go rewrite)
apps/tui/logs/
@@ -0,0 +1,20 @@
package com.correx.core.inference
/**
* Interface for computing vector embeddings of text.
* Implementations provide pre-computed vectors for similarity search and retrieval.
*/
interface Embedder {
/**
* Dimensionality of the returned embeddings.
*/
val dimension: Int
/**
* Compute a vector embedding for the given text.
*
* @param text The text to embed.
* @return A vector of [dimension] floats.
*/
suspend fun embed(text: String): FloatArray
}
@@ -0,0 +1,8 @@
package com.correx.core.inference
/**
* Returns a zero vector. Lets the L3 write path run without an embedding model wired.
*/
class NoopEmbedder(override val dimension: Int = 1536) : Embedder {
override suspend fun embed(text: String): FloatArray = FloatArray(dimension)
}
@@ -10,10 +10,13 @@ import com.correx.core.events.types.EventId
import com.correx.core.events.types.InferenceRequestId import com.correx.core.events.types.InferenceRequestId
import com.correx.core.events.types.SessionId import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId import com.correx.core.events.types.StageId
import com.correx.core.inference.Embedder
import com.correx.core.inference.InferenceRequest import com.correx.core.inference.InferenceRequest
import com.correx.core.inference.InferenceRouter import com.correx.core.inference.InferenceRouter
import com.correx.core.inference.ModelCapability import com.correx.core.inference.ModelCapability
import com.correx.core.inference.ResponseFormat import com.correx.core.inference.ResponseFormat
import com.correx.core.router.l3.L3MemoryEntry
import com.correx.core.router.l3.L3MemoryStore
import com.correx.core.router.model.RouterConfig import com.correx.core.router.model.RouterConfig
import com.correx.core.router.model.RouterResponse import com.correx.core.router.model.RouterResponse
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
@@ -34,6 +37,8 @@ class DefaultRouterFacade(
private val eventStore: EventStore, private val eventStore: EventStore,
private val config: RouterConfig, private val config: RouterConfig,
private val validateSteering: (suspend (String) -> String?)? = null, private val validateSteering: (suspend (String) -> String?)? = null,
private val embedder: Embedder,
private val l3MemoryStore: L3MemoryStore,
) : RouterFacade { ) : RouterFacade {
override suspend fun onUserInput( override suspend fun onUserInput(
@@ -96,6 +101,18 @@ class DefaultRouterFacade(
), ),
), ),
) )
val vector = embedder.embed(content)
l3MemoryStore.store(
L3MemoryEntry(
id = turnId,
sessionId = sessionId,
turnId = turnId,
text = content,
vector = vector,
timestampMs = nowMs,
)
)
} }
private suspend fun emitSteeringNote(sessionId: SessionId, content: String, effectiveStageId: StageId) { private suspend fun emitSteeringNote(sessionId: SessionId, content: String, effectiveStageId: StageId) {
@@ -9,9 +9,11 @@ import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.EventDispatcher import com.correx.core.events.EventDispatcher
import com.correx.core.events.stores.EventStore import com.correx.core.events.stores.EventStore
import com.correx.core.inference.CapabilityScore import com.correx.core.inference.CapabilityScore
import com.correx.core.inference.Embedder
import com.correx.core.inference.InferenceProvider import com.correx.core.inference.InferenceProvider
import com.correx.core.inference.InferenceRouter import com.correx.core.inference.InferenceRouter
import com.correx.core.inference.ModelCapability import com.correx.core.inference.ModelCapability
import com.correx.core.inference.NoopEmbedder
import com.correx.core.inference.Tokenizer import com.correx.core.inference.Tokenizer
import com.correx.core.router.DefaultRouterContextBuilder import com.correx.core.router.DefaultRouterContextBuilder
import com.correx.core.router.DefaultRouterFacade import com.correx.core.router.DefaultRouterFacade
@@ -19,6 +21,8 @@ import com.correx.core.router.DefaultRouterReducer
import com.correx.core.router.DefaultRouterRepository import com.correx.core.router.DefaultRouterRepository
import com.correx.core.router.RouterFacade import com.correx.core.router.RouterFacade
import com.correx.core.router.RouterProjector import com.correx.core.router.RouterProjector
import com.correx.core.router.l3.InMemoryL3MemoryStore
import com.correx.core.router.l3.L3MemoryStore
import com.correx.core.router.model.RouterConfig import com.correx.core.router.model.RouterConfig
import com.correx.core.sessions.projections.replay.DefaultEventReplayer import com.correx.core.sessions.projections.replay.DefaultEventReplayer
import com.correx.core.tools.contract.ToolExecutor import com.correx.core.tools.contract.ToolExecutor
@@ -129,17 +133,23 @@ object InfrastructureModule {
workDir = workDir, workDir = workDir,
) )
fun createNoopEmbedder(dimension: Int = 1536): Embedder = NoopEmbedder(dimension)
fun createInMemoryL3MemoryStore(): L3MemoryStore = InMemoryL3MemoryStore()
fun createRouterFacade( fun createRouterFacade(
eventStore: EventStore, eventStore: EventStore,
inferenceRouter: InferenceRouter, inferenceRouter: InferenceRouter,
config: RouterConfig = RouterConfig(), config: RouterConfig = RouterConfig(),
tokenizer: Tokenizer? = null, tokenizer: Tokenizer? = null,
embedder: Embedder = createNoopEmbedder(),
l3MemoryStore: L3MemoryStore = createInMemoryL3MemoryStore(),
): RouterFacade { ): RouterFacade {
val reducer = DefaultRouterReducer() val reducer = DefaultRouterReducer()
val projector = RouterProjector(reducer) val projector = RouterProjector(reducer)
val replayer = DefaultEventReplayer(eventStore, projector) val replayer = DefaultEventReplayer(eventStore, projector)
val repository = DefaultRouterRepository(replayer) val repository = DefaultRouterRepository(replayer)
val contextBuilder = DefaultRouterContextBuilder(config, tokenizer) val contextBuilder = DefaultRouterContextBuilder(config, tokenizer)
return DefaultRouterFacade(repository, contextBuilder, inferenceRouter, eventStore, config) return DefaultRouterFacade(repository, contextBuilder, inferenceRouter, eventStore, config, null, embedder, l3MemoryStore)
} }
} }
@@ -10,12 +10,14 @@ import com.correx.core.events.types.ProviderId
import com.correx.core.events.types.SessionId import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId import com.correx.core.events.types.StageId
import com.correx.core.inference.CapabilityScore import com.correx.core.inference.CapabilityScore
import com.correx.core.inference.Embedder
import com.correx.core.inference.FinishReason import com.correx.core.inference.FinishReason
import com.correx.core.inference.InferenceProvider import com.correx.core.inference.InferenceProvider
import com.correx.core.inference.InferenceRequest import com.correx.core.inference.InferenceRequest
import com.correx.core.inference.InferenceResponse import com.correx.core.inference.InferenceResponse
import com.correx.core.inference.InferenceRouter import com.correx.core.inference.InferenceRouter
import com.correx.core.inference.ModelCapability import com.correx.core.inference.ModelCapability
import com.correx.core.inference.NoopEmbedder
import com.correx.core.inference.ProviderHealth import com.correx.core.inference.ProviderHealth
import com.correx.core.inference.ResponseFormat import com.correx.core.inference.ResponseFormat
import com.correx.core.inference.TokenUsage import com.correx.core.inference.TokenUsage
@@ -26,6 +28,9 @@ import com.correx.core.router.RouterContextBuilder
import com.correx.core.router.RouterFacade import com.correx.core.router.RouterFacade
import com.correx.core.router.RouterProjector import com.correx.core.router.RouterProjector
import com.correx.core.router.RouterRepository import com.correx.core.router.RouterRepository
import com.correx.core.router.l3.InMemoryL3MemoryStore
import com.correx.core.router.l3.L3MemoryStore
import com.correx.core.router.l3.L3Query
import com.correx.core.router.model.RouterConfig import com.correx.core.router.model.RouterConfig
import com.correx.core.router.model.RouterResponse import com.correx.core.router.model.RouterResponse
import com.correx.core.router.model.RouterState import com.correx.core.router.model.RouterState
@@ -127,6 +132,8 @@ class RouterFacadeTest {
inferenceRouter = mockInferenceRouter("steering response"), inferenceRouter = mockInferenceRouter("steering response"),
eventStore = mockStore, eventStore = mockStore,
config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)), config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)),
embedder = NoopEmbedder(dimension = 8),
l3MemoryStore = InMemoryL3MemoryStore(),
) )
facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!", mode = ChatMode.STEERING) facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!", mode = ChatMode.STEERING)
assertEquals(3, mockStore.appendedEvents.size) assertEquals(3, mockStore.appendedEvents.size)
@@ -161,6 +168,8 @@ class RouterFacadeTest {
inferenceRouter = mockInferenceRouter("router reply"), inferenceRouter = mockInferenceRouter("router reply"),
eventStore = eventStore, eventStore = eventStore,
config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)), config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)),
embedder = NoopEmbedder(dimension = 8),
l3MemoryStore = InMemoryL3MemoryStore(),
) )
val sessionId = SessionId("history-session") val sessionId = SessionId("history-session")
facade.onUserInput(sessionId = sessionId, input = "first message") facade.onUserInput(sessionId = sessionId, input = "first message")
@@ -199,6 +208,8 @@ class RouterFacadeTest {
inferenceRouter = mockInferenceRouter("response"), inferenceRouter = mockInferenceRouter("response"),
eventStore = eventStore, eventStore = eventStore,
config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)), config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)),
embedder = NoopEmbedder(dimension = 8),
l3MemoryStore = InMemoryL3MemoryStore(),
) )
facade.onUserInput(sessionId = SessionId("session-A"), input = "message A") facade.onUserInput(sessionId = SessionId("session-A"), input = "message A")
facade.onUserInput(sessionId = SessionId("session-B"), input = "message B") facade.onUserInput(sessionId = SessionId("session-B"), input = "message B")
@@ -237,6 +248,8 @@ class RouterFacadeTest {
inferenceRouter = mockInferenceRouter("inference response"), inferenceRouter = mockInferenceRouter("inference response"),
eventStore = mockEventStore(), eventStore = mockEventStore(),
config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)), config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)),
embedder = NoopEmbedder(dimension = 8),
l3MemoryStore = InMemoryL3MemoryStore(),
) )
facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!") facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!")
assertEquals(1, capturedState.size) assertEquals(1, capturedState.size)
@@ -261,6 +274,8 @@ class RouterFacadeTest {
inferenceRouter = mockInferenceRouter("response"), inferenceRouter = mockInferenceRouter("response"),
eventStore = mockEventStore(), eventStore = mockEventStore(),
config = RouterConfig(tokenBudget = TokenBudget(limit = 4200)), config = RouterConfig(tokenBudget = TokenBudget(limit = 4200)),
embedder = NoopEmbedder(dimension = 8),
l3MemoryStore = InMemoryL3MemoryStore(),
) )
facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!") facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!")
assertEquals(1, capturedBudget.size) assertEquals(1, capturedBudget.size)
@@ -294,6 +309,8 @@ class RouterFacadeTest {
inferenceRouter = mockInferenceRouter, inferenceRouter = mockInferenceRouter,
eventStore = mockEventStore(), eventStore = mockEventStore(),
config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)), config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)),
embedder = NoopEmbedder(dimension = 8),
l3MemoryStore = InMemoryL3MemoryStore(),
) )
facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!") facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!")
assertEquals(stateStageId, capturedStageId[0]) assertEquals(stateStageId, capturedStageId[0])
@@ -325,6 +342,8 @@ class RouterFacadeTest {
inferenceRouter = mockInferenceRouter, inferenceRouter = mockInferenceRouter,
eventStore = mockEventStore(), eventStore = mockEventStore(),
config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)), config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)),
embedder = NoopEmbedder(dimension = 8),
l3MemoryStore = InMemoryL3MemoryStore(),
) )
facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!") facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!")
assertEquals(StageId("none"), capturedStageId[0]) assertEquals(StageId("none"), capturedStageId[0])
@@ -351,6 +370,8 @@ class RouterFacadeTest {
inferenceRouter = mockInferenceRouter, inferenceRouter = mockInferenceRouter,
eventStore = mockEventStore(), eventStore = mockEventStore(),
config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)), config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)),
embedder = NoopEmbedder(dimension = 8),
l3MemoryStore = InMemoryL3MemoryStore(),
) )
facade.onUserInput(sessionId = SessionId("s1"), input = "first") facade.onUserInput(sessionId = SessionId("s1"), input = "first")
facade.onUserInput(sessionId = SessionId("s1"), input = "second") facade.onUserInput(sessionId = SessionId("s1"), input = "second")
@@ -379,6 +400,8 @@ class RouterFacadeTest {
inferenceRouter = mockInferenceRouter, inferenceRouter = mockInferenceRouter,
eventStore = mockEventStore(), eventStore = mockEventStore(),
config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)), config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)),
embedder = NoopEmbedder(dimension = 8),
l3MemoryStore = InMemoryL3MemoryStore(),
) )
facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!") facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!")
val req = capturedRequests[0] val req = capturedRequests[0]
@@ -438,6 +461,8 @@ class RouterFacadeTest {
}, },
eventStore = mockEventStore(), eventStore = mockEventStore(),
config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)), config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)),
embedder = NoopEmbedder(dimension = 8),
l3MemoryStore = InMemoryL3MemoryStore(),
) )
facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!") facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!")
} }
@@ -462,6 +487,8 @@ class RouterFacadeTest {
}, },
eventStore = mockEventStore(), eventStore = mockEventStore(),
config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)), config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)),
embedder = NoopEmbedder(dimension = 8),
l3MemoryStore = InMemoryL3MemoryStore(),
) )
facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!") facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!")
val req = capturedRequests[0] val req = capturedRequests[0]
@@ -479,6 +506,29 @@ class RouterFacadeTest {
assertEquals(3, mockStore.appendedEvents.size) assertEquals(3, mockStore.appendedEvents.size)
} }
@Test
fun `CHAT round-trip stores user and router turns in L3 memory`(): Unit = runBlocking {
val mockStore = mockEventStore()
val l3Store = InMemoryL3MemoryStore()
val embedder = NoopEmbedder(dimension = 8)
val facade = facadeWithMocks(
eventStore = mockStore,
chatMode = ChatMode.CHAT,
embedder = embedder,
l3MemoryStore = l3Store,
)
val sessionId = SessionId("test-session")
facade.onUserInput(sessionId = sessionId, input = "Hello!")
val hits = l3Store.query(
L3Query(
vector = FloatArray(8),
k = 10,
sessionIdFilter = sessionId,
)
)
assertEquals(2, hits.size)
}
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
// Helpers // Helpers
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
@@ -488,6 +538,8 @@ class RouterFacadeTest {
private fun facadeWithMocks( private fun facadeWithMocks(
eventStore: EventStore, eventStore: EventStore,
chatMode: ChatMode = ChatMode.CHAT, chatMode: ChatMode = ChatMode.CHAT,
embedder: Embedder = NoopEmbedder(dimension = 8),
l3MemoryStore: L3MemoryStore = InMemoryL3MemoryStore(),
): RouterFacade = DefaultRouterFacade( ): RouterFacade = DefaultRouterFacade(
routerRepository = object : RouterRepository { routerRepository = object : RouterRepository {
override suspend fun getRouterState(sessionId: SessionId): RouterState = override suspend fun getRouterState(sessionId: SessionId): RouterState =
@@ -503,6 +555,8 @@ class RouterFacadeTest {
inferenceRouter = mockInferenceRouter("inference response"), inferenceRouter = mockInferenceRouter("inference response"),
eventStore = eventStore, eventStore = eventStore,
config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)), config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)),
embedder = embedder,
l3MemoryStore = l3MemoryStore,
).let { impl -> ).let { impl ->
object : RouterFacade { object : RouterFacade {
override suspend fun onUserInput(sessionId: SessionId, input: String, mode: ChatMode): RouterResponse = override suspend fun onUserInput(sessionId: SessionId, input: String, mode: ChatMode): RouterResponse =