From 9f171d32367b0b6f8218a16eb60cae16b0bd0d2d Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 30 May 2026 01:01:40 +0400 Subject: [PATCH] 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). --- .gitignore | 3 ++ .../com/correx/core/inference/Embedder.kt | 20 +++++++ .../com/correx/core/inference/NoopEmbedder.kt | 8 +++ .../com/correx/core/router/RouterFacade.kt | 17 ++++++ .../infrastructure/InfrastructureModule.kt | 12 ++++- .../src/test/kotlin/RouterFacadeTest.kt | 54 +++++++++++++++++++ 6 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 core/inference/src/main/kotlin/com/correx/core/inference/Embedder.kt create mode 100644 core/inference/src/main/kotlin/com/correx/core/inference/NoopEmbedder.kt diff --git a/.gitignore b/.gitignore index 2fc5e291..9fb326c6 100644 --- a/.gitignore +++ b/.gitignore @@ -74,3 +74,6 @@ correx-tui.log current.log # Go TUI build output apps/tui-go/bin/ + +# TUI runtime logs (Kotlin TUI, pre-Go rewrite) +apps/tui/logs/ diff --git a/core/inference/src/main/kotlin/com/correx/core/inference/Embedder.kt b/core/inference/src/main/kotlin/com/correx/core/inference/Embedder.kt new file mode 100644 index 00000000..8b620be1 --- /dev/null +++ b/core/inference/src/main/kotlin/com/correx/core/inference/Embedder.kt @@ -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 +} diff --git a/core/inference/src/main/kotlin/com/correx/core/inference/NoopEmbedder.kt b/core/inference/src/main/kotlin/com/correx/core/inference/NoopEmbedder.kt new file mode 100644 index 00000000..b42d2a80 --- /dev/null +++ b/core/inference/src/main/kotlin/com/correx/core/inference/NoopEmbedder.kt @@ -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) +} diff --git a/core/router/src/main/kotlin/com/correx/core/router/RouterFacade.kt b/core/router/src/main/kotlin/com/correx/core/router/RouterFacade.kt index 43865a0a..b6f418d4 100644 --- a/core/router/src/main/kotlin/com/correx/core/router/RouterFacade.kt +++ b/core/router/src/main/kotlin/com/correx/core/router/RouterFacade.kt @@ -10,10 +10,13 @@ import com.correx.core.events.types.EventId import com.correx.core.events.types.InferenceRequestId import com.correx.core.events.types.SessionId import com.correx.core.events.types.StageId +import com.correx.core.inference.Embedder import com.correx.core.inference.InferenceRequest import com.correx.core.inference.InferenceRouter import com.correx.core.inference.ModelCapability 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.RouterResponse import kotlinx.datetime.Clock @@ -34,6 +37,8 @@ class DefaultRouterFacade( private val eventStore: EventStore, private val config: RouterConfig, private val validateSteering: (suspend (String) -> String?)? = null, + private val embedder: Embedder, + private val l3MemoryStore: L3MemoryStore, ) : RouterFacade { 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) { diff --git a/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt b/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt index 382e9ba2..c9d549ad 100644 --- a/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt +++ b/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt @@ -9,9 +9,11 @@ import com.correx.core.artifactstore.ArtifactStore import com.correx.core.events.EventDispatcher import com.correx.core.events.stores.EventStore import com.correx.core.inference.CapabilityScore +import com.correx.core.inference.Embedder import com.correx.core.inference.InferenceProvider import com.correx.core.inference.InferenceRouter import com.correx.core.inference.ModelCapability +import com.correx.core.inference.NoopEmbedder import com.correx.core.inference.Tokenizer import com.correx.core.router.DefaultRouterContextBuilder 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.RouterFacade 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.sessions.projections.replay.DefaultEventReplayer import com.correx.core.tools.contract.ToolExecutor @@ -129,17 +133,23 @@ object InfrastructureModule { workDir = workDir, ) + fun createNoopEmbedder(dimension: Int = 1536): Embedder = NoopEmbedder(dimension) + + fun createInMemoryL3MemoryStore(): L3MemoryStore = InMemoryL3MemoryStore() + fun createRouterFacade( eventStore: EventStore, inferenceRouter: InferenceRouter, config: RouterConfig = RouterConfig(), tokenizer: Tokenizer? = null, + embedder: Embedder = createNoopEmbedder(), + l3MemoryStore: L3MemoryStore = createInMemoryL3MemoryStore(), ): RouterFacade { val reducer = DefaultRouterReducer() val projector = RouterProjector(reducer) val replayer = DefaultEventReplayer(eventStore, projector) val repository = DefaultRouterRepository(replayer) val contextBuilder = DefaultRouterContextBuilder(config, tokenizer) - return DefaultRouterFacade(repository, contextBuilder, inferenceRouter, eventStore, config) + return DefaultRouterFacade(repository, contextBuilder, inferenceRouter, eventStore, config, null, embedder, l3MemoryStore) } } diff --git a/testing/deterministic/src/test/kotlin/RouterFacadeTest.kt b/testing/deterministic/src/test/kotlin/RouterFacadeTest.kt index 1760d252..26dd2261 100644 --- a/testing/deterministic/src/test/kotlin/RouterFacadeTest.kt +++ b/testing/deterministic/src/test/kotlin/RouterFacadeTest.kt @@ -10,12 +10,14 @@ import com.correx.core.events.types.ProviderId import com.correx.core.events.types.SessionId import com.correx.core.events.types.StageId import com.correx.core.inference.CapabilityScore +import com.correx.core.inference.Embedder import com.correx.core.inference.FinishReason import com.correx.core.inference.InferenceProvider import com.correx.core.inference.InferenceRequest import com.correx.core.inference.InferenceResponse import com.correx.core.inference.InferenceRouter import com.correx.core.inference.ModelCapability +import com.correx.core.inference.NoopEmbedder import com.correx.core.inference.ProviderHealth import com.correx.core.inference.ResponseFormat 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.RouterProjector 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.RouterResponse import com.correx.core.router.model.RouterState @@ -127,6 +132,8 @@ class RouterFacadeTest { inferenceRouter = mockInferenceRouter("steering response"), eventStore = mockStore, config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)), + embedder = NoopEmbedder(dimension = 8), + l3MemoryStore = InMemoryL3MemoryStore(), ) facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!", mode = ChatMode.STEERING) assertEquals(3, mockStore.appendedEvents.size) @@ -161,6 +168,8 @@ class RouterFacadeTest { inferenceRouter = mockInferenceRouter("router reply"), eventStore = eventStore, config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)), + embedder = NoopEmbedder(dimension = 8), + l3MemoryStore = InMemoryL3MemoryStore(), ) val sessionId = SessionId("history-session") facade.onUserInput(sessionId = sessionId, input = "first message") @@ -199,6 +208,8 @@ class RouterFacadeTest { inferenceRouter = mockInferenceRouter("response"), eventStore = eventStore, 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-B"), input = "message B") @@ -237,6 +248,8 @@ class RouterFacadeTest { inferenceRouter = mockInferenceRouter("inference response"), eventStore = mockEventStore(), config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)), + embedder = NoopEmbedder(dimension = 8), + l3MemoryStore = InMemoryL3MemoryStore(), ) facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!") assertEquals(1, capturedState.size) @@ -261,6 +274,8 @@ class RouterFacadeTest { inferenceRouter = mockInferenceRouter("response"), eventStore = mockEventStore(), config = RouterConfig(tokenBudget = TokenBudget(limit = 4200)), + embedder = NoopEmbedder(dimension = 8), + l3MemoryStore = InMemoryL3MemoryStore(), ) facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!") assertEquals(1, capturedBudget.size) @@ -294,6 +309,8 @@ class RouterFacadeTest { inferenceRouter = mockInferenceRouter, eventStore = mockEventStore(), config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)), + embedder = NoopEmbedder(dimension = 8), + l3MemoryStore = InMemoryL3MemoryStore(), ) facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!") assertEquals(stateStageId, capturedStageId[0]) @@ -325,6 +342,8 @@ class RouterFacadeTest { inferenceRouter = mockInferenceRouter, eventStore = mockEventStore(), config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)), + embedder = NoopEmbedder(dimension = 8), + l3MemoryStore = InMemoryL3MemoryStore(), ) facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!") assertEquals(StageId("none"), capturedStageId[0]) @@ -351,6 +370,8 @@ class RouterFacadeTest { inferenceRouter = mockInferenceRouter, eventStore = mockEventStore(), 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 = "second") @@ -379,6 +400,8 @@ class RouterFacadeTest { inferenceRouter = mockInferenceRouter, eventStore = mockEventStore(), config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)), + embedder = NoopEmbedder(dimension = 8), + l3MemoryStore = InMemoryL3MemoryStore(), ) facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!") val req = capturedRequests[0] @@ -438,6 +461,8 @@ class RouterFacadeTest { }, eventStore = mockEventStore(), config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)), + embedder = NoopEmbedder(dimension = 8), + l3MemoryStore = InMemoryL3MemoryStore(), ) facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!") } @@ -462,6 +487,8 @@ class RouterFacadeTest { }, eventStore = mockEventStore(), config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)), + embedder = NoopEmbedder(dimension = 8), + l3MemoryStore = InMemoryL3MemoryStore(), ) facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!") val req = capturedRequests[0] @@ -479,6 +506,29 @@ class RouterFacadeTest { 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 // -------------------------------------------------------------------------- @@ -488,6 +538,8 @@ class RouterFacadeTest { private fun facadeWithMocks( eventStore: EventStore, chatMode: ChatMode = ChatMode.CHAT, + embedder: Embedder = NoopEmbedder(dimension = 8), + l3MemoryStore: L3MemoryStore = InMemoryL3MemoryStore(), ): RouterFacade = DefaultRouterFacade( routerRepository = object : RouterRepository { override suspend fun getRouterState(sessionId: SessionId): RouterState = @@ -503,6 +555,8 @@ class RouterFacadeTest { inferenceRouter = mockInferenceRouter("inference response"), eventStore = eventStore, config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)), + embedder = embedder, + l3MemoryStore = l3MemoryStore, ).let { impl -> object : RouterFacade { override suspend fun onUserInput(sessionId: SessionId, input: String, mode: ChatMode): RouterResponse =