feat: L3 memory retrieval on the router read path (record-only)
When a user sends input, embed it, query L3 across all sessions, dedup against in-session turns, and record the retrieval as an event so replay is deterministic (invariant #9). Hits land in RouterState; context injection follows in a later slice. - Add L3MemoryRetrievedEvent + L3RetrievedHit (registered in eventModule) - RouterState.lastRetrievedMemory + reducer case; RouterTurn carries turnId - RouterConfig.retrievalK (default 5) - Harden L3 write path: runCatching + visible logging, cancellation re-thrown; event append stays ahead of the L3 write - Warn prominently when the non-durable in_memory L3 backend is selected
This commit is contained in:
@@ -19,3 +19,21 @@ enum class ChatTurnRole {
|
|||||||
USER,
|
USER,
|
||||||
ROUTER,
|
ROUTER,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("L3MemoryRetrieved")
|
||||||
|
data class L3MemoryRetrievedEvent(
|
||||||
|
val sessionId: SessionId,
|
||||||
|
val queryTurnId: String,
|
||||||
|
val hits: List<L3RetrievedHit>,
|
||||||
|
val timestampMs: Long,
|
||||||
|
) : EventPayload
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class L3RetrievedHit(
|
||||||
|
val entryId: String,
|
||||||
|
val sourceSessionId: SessionId,
|
||||||
|
val sourceTurnId: String,
|
||||||
|
val text: String,
|
||||||
|
val score: Float,
|
||||||
|
)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import com.correx.core.events.events.ArtifactValidatingEvent
|
|||||||
import com.correx.core.events.events.ChatSessionStartedEvent
|
import com.correx.core.events.events.ChatSessionStartedEvent
|
||||||
import com.correx.core.events.events.ChatTurnEvent
|
import com.correx.core.events.events.ChatTurnEvent
|
||||||
import com.correx.core.events.events.EventPayload
|
import com.correx.core.events.events.EventPayload
|
||||||
|
import com.correx.core.events.events.L3MemoryRetrievedEvent
|
||||||
import com.correx.core.events.events.InferenceCompletedEvent
|
import com.correx.core.events.events.InferenceCompletedEvent
|
||||||
import com.correx.core.events.events.InferenceFailedEvent
|
import com.correx.core.events.events.InferenceFailedEvent
|
||||||
import com.correx.core.events.events.InferenceStartedEvent
|
import com.correx.core.events.events.InferenceStartedEvent
|
||||||
@@ -73,6 +74,7 @@ val eventModule = SerializersModule {
|
|||||||
subclass(RiskAssessedEvent::class)
|
subclass(RiskAssessedEvent::class)
|
||||||
subclass(ChatSessionStartedEvent::class)
|
subclass(ChatSessionStartedEvent::class)
|
||||||
subclass(ChatTurnEvent::class)
|
subclass(ChatTurnEvent::class)
|
||||||
|
subclass(L3MemoryRetrievedEvent::class)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ dependencies {
|
|||||||
implementation project(':core:context')
|
implementation project(':core:context')
|
||||||
implementation project(':core:inference')
|
implementation project(':core:inference')
|
||||||
implementation project(':core:sessions')
|
implementation project(':core:sessions')
|
||||||
|
implementation "org.slf4j:slf4j-api:2.0.16"
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.named("koverVerify").configure {
|
tasks.named("koverVerify").configure {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.correx.core.router
|
|||||||
import com.correx.core.events.events.ChatTurnEvent
|
import com.correx.core.events.events.ChatTurnEvent
|
||||||
import com.correx.core.events.events.ChatTurnRole
|
import com.correx.core.events.events.ChatTurnRole
|
||||||
import com.correx.core.events.events.EventMetadata
|
import com.correx.core.events.events.EventMetadata
|
||||||
|
import com.correx.core.events.events.L3MemoryRetrievedEvent
|
||||||
|
import com.correx.core.events.events.L3RetrievedHit
|
||||||
import com.correx.core.events.events.NewEvent
|
import com.correx.core.events.events.NewEvent
|
||||||
import com.correx.core.events.events.SteeringNoteAddedEvent
|
import com.correx.core.events.events.SteeringNoteAddedEvent
|
||||||
import com.correx.core.events.stores.EventStore
|
import com.correx.core.events.stores.EventStore
|
||||||
@@ -17,10 +19,15 @@ 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.L3MemoryEntry
|
||||||
import com.correx.core.router.l3.L3MemoryStore
|
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 kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
import java.util.*
|
import org.slf4j.LoggerFactory
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
private val log = LoggerFactory.getLogger(DefaultRouterFacade::class.java)
|
||||||
|
|
||||||
interface RouterFacade {
|
interface RouterFacade {
|
||||||
suspend fun onUserInput(
|
suspend fun onUserInput(
|
||||||
@@ -46,13 +53,57 @@ class DefaultRouterFacade(
|
|||||||
input: String,
|
input: String,
|
||||||
mode: ChatMode,
|
mode: ChatMode,
|
||||||
): RouterResponse {
|
): RouterResponse {
|
||||||
// Emit USER turn event
|
// Emit USER turn event and get the turnId for retrieval dedup
|
||||||
emitChatTurn(sessionId, input, ChatTurnRole.USER)
|
val userTurnId = emitChatTurn(sessionId, input, ChatTurnRole.USER)
|
||||||
|
|
||||||
// Rebuild state with user turn appended
|
// Rebuild state with user turn appended
|
||||||
val stateWithUserTurn = routerRepository.getRouterState(sessionId)
|
val stateWithUserTurn = routerRepository.getRouterState(sessionId)
|
||||||
val effectiveStageId = stateWithUserTurn.currentStageId ?: StageId.NONE
|
val effectiveStageId = stateWithUserTurn.currentStageId ?: StageId.NONE
|
||||||
|
|
||||||
|
// L3 retrieval — non-fatal; does NOT alter what contextBuilder receives in this slice
|
||||||
|
val retrieved = runCatching {
|
||||||
|
val queryVector = embedder.embed(input)
|
||||||
|
l3MemoryStore.query(L3Query(vector = queryVector, k = config.retrievalK))
|
||||||
|
}.also { result ->
|
||||||
|
result.exceptionOrNull()?.let { e ->
|
||||||
|
if (e is CancellationException) throw e
|
||||||
|
log.warn("L3 retrieval failed for session {}: {}", sessionId, e.message)
|
||||||
|
}
|
||||||
|
}.getOrElse { emptyList() }
|
||||||
|
|
||||||
|
val inSessionTurnIds = stateWithUserTurn.conversationHistory.map { it.turnId }.toSet()
|
||||||
|
val deduped = retrieved.filter { it.entry.turnId !in inSessionTurnIds }
|
||||||
|
|
||||||
|
if (deduped.isNotEmpty()) {
|
||||||
|
val nowMs = Clock.System.now().toEpochMilliseconds()
|
||||||
|
eventStore.append(
|
||||||
|
NewEvent(
|
||||||
|
metadata = EventMetadata(
|
||||||
|
eventId = EventId(UUID.randomUUID().toString()),
|
||||||
|
sessionId = sessionId,
|
||||||
|
timestamp = Clock.System.now(),
|
||||||
|
schemaVersion = 1,
|
||||||
|
causationId = null,
|
||||||
|
correlationId = null,
|
||||||
|
),
|
||||||
|
payload = L3MemoryRetrievedEvent(
|
||||||
|
sessionId = sessionId,
|
||||||
|
queryTurnId = userTurnId,
|
||||||
|
hits = deduped.map { hit ->
|
||||||
|
L3RetrievedHit(
|
||||||
|
entryId = hit.entry.id,
|
||||||
|
sourceSessionId = hit.entry.sessionId,
|
||||||
|
sourceTurnId = hit.entry.turnId,
|
||||||
|
text = hit.entry.text,
|
||||||
|
score = hit.score,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
timestampMs = nowMs,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val contextPack = routerContextBuilder.build(stateWithUserTurn, config.tokenBudget)
|
val contextPack = routerContextBuilder.build(stateWithUserTurn, config.tokenBudget)
|
||||||
val provider = inferenceRouter.route(effectiveStageId, setOf(ModelCapability.General))
|
val provider = inferenceRouter.route(effectiveStageId, setOf(ModelCapability.General))
|
||||||
val inferenceRequest = InferenceRequest(
|
val inferenceRequest = InferenceRequest(
|
||||||
@@ -79,7 +130,7 @@ class DefaultRouterFacade(
|
|||||||
return RouterResponse(content = content, steeringEmitted = (mode == ChatMode.STEERING))
|
return RouterResponse(content = content, steeringEmitted = (mode == ChatMode.STEERING))
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun emitChatTurn(sessionId: SessionId, content: String, role: ChatTurnRole) {
|
private suspend fun emitChatTurn(sessionId: SessionId, content: String, role: ChatTurnRole): String {
|
||||||
val turnId = UUID.randomUUID().toString()
|
val turnId = UUID.randomUUID().toString()
|
||||||
val nowMs = Clock.System.now().toEpochMilliseconds()
|
val nowMs = Clock.System.now().toEpochMilliseconds()
|
||||||
eventStore.append(
|
eventStore.append(
|
||||||
@@ -102,17 +153,26 @@ class DefaultRouterFacade(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
val vector = embedder.embed(content)
|
runCatching {
|
||||||
l3MemoryStore.store(
|
val vector = embedder.embed(content)
|
||||||
L3MemoryEntry(
|
l3MemoryStore.store(
|
||||||
id = turnId,
|
L3MemoryEntry(
|
||||||
sessionId = sessionId,
|
id = turnId,
|
||||||
turnId = turnId,
|
sessionId = sessionId,
|
||||||
text = content,
|
turnId = turnId,
|
||||||
vector = vector,
|
text = content,
|
||||||
timestampMs = nowMs,
|
vector = vector,
|
||||||
|
timestampMs = nowMs,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
}.also { result ->
|
||||||
|
result.exceptionOrNull()?.let { e ->
|
||||||
|
if (e is CancellationException) throw e
|
||||||
|
log.warn("L3 write failed for turn {}: {}", turnId, e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return turnId
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun emitSteeringNote(sessionId: SessionId, content: String, effectiveStageId: StageId) {
|
private suspend fun emitSteeringNote(sessionId: SessionId, content: String, effectiveStageId: StageId) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.correx.core.router
|
package com.correx.core.router
|
||||||
|
|
||||||
import com.correx.core.events.events.ChatTurnEvent
|
import com.correx.core.events.events.ChatTurnEvent
|
||||||
|
import com.correx.core.events.events.L3MemoryRetrievedEvent
|
||||||
import com.correx.core.events.events.OrchestrationPausedEvent
|
import com.correx.core.events.events.OrchestrationPausedEvent
|
||||||
import com.correx.core.events.events.OrchestrationResumedEvent
|
import com.correx.core.events.events.OrchestrationResumedEvent
|
||||||
import com.correx.core.events.events.StageCompletedEvent
|
import com.correx.core.events.events.StageCompletedEvent
|
||||||
@@ -42,6 +43,7 @@ class DefaultRouterReducer : RouterReducer {
|
|||||||
is StageCompletedEvent -> handleStageCompleted(state, event)
|
is StageCompletedEvent -> handleStageCompleted(state, event)
|
||||||
is StageFailedEvent -> handleStageFailed(state, event)
|
is StageFailedEvent -> handleStageFailed(state, event)
|
||||||
is ChatTurnEvent -> handleChatTurn(state, event)
|
is ChatTurnEvent -> handleChatTurn(state, event)
|
||||||
|
is L3MemoryRetrievedEvent -> state.copy(lastRetrievedMemory = payload.hits)
|
||||||
else -> state
|
else -> state
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,6 +120,7 @@ class DefaultRouterReducer : RouterReducer {
|
|||||||
role = turnRole,
|
role = turnRole,
|
||||||
content = payload.content,
|
content = payload.content,
|
||||||
timestamp = Instant.fromEpochMilliseconds(payload.timestampMs),
|
timestamp = Instant.fromEpochMilliseconds(payload.timestampMs),
|
||||||
|
turnId = payload.turnId,
|
||||||
)
|
)
|
||||||
return state.copy(
|
return state.copy(
|
||||||
conversationHistory = state.conversationHistory + turn
|
conversationHistory = state.conversationHistory + turn
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import kotlinx.serialization.Serializable
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class RouterConfig(
|
data class RouterConfig(
|
||||||
val conversationKeepLast: Int = 6,
|
val conversationKeepLast: Int = 6,
|
||||||
|
val retrievalK: Int = 5,
|
||||||
val tokenBudget: TokenBudget = TokenBudget(limit = 4096),
|
val tokenBudget: TokenBudget = TokenBudget(limit = 4096),
|
||||||
val generationConfig: GenerationConfig = GenerationConfig(
|
val generationConfig: GenerationConfig = GenerationConfig(
|
||||||
temperature = 0.7,
|
temperature = 0.7,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.correx.core.router.model
|
package com.correx.core.router.model
|
||||||
|
|
||||||
|
import com.correx.core.events.events.L3RetrievedHit
|
||||||
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 kotlinx.datetime.Instant
|
import kotlinx.datetime.Instant
|
||||||
@@ -40,6 +41,7 @@ data class RouterTurn(
|
|||||||
val role: TurnRole,
|
val role: TurnRole,
|
||||||
val content: String,
|
val content: String,
|
||||||
val timestamp: Instant,
|
val timestamp: Instant,
|
||||||
|
val turnId: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -49,4 +51,5 @@ data class RouterState(
|
|||||||
val currentStageId: StageId? = null,
|
val currentStageId: StageId? = null,
|
||||||
val l2Memory: List<RouterL2Entry> = emptyList(),
|
val l2Memory: List<RouterL2Entry> = emptyList(),
|
||||||
val conversationHistory: List<RouterTurn> = emptyList(),
|
val conversationHistory: List<RouterTurn> = emptyList(),
|
||||||
|
val lastRetrievedMemory: List<L3RetrievedHit> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ dependencies {
|
|||||||
implementation "io.ktor:ktor-client-cio:$ktor_version"
|
implementation "io.ktor:ktor-client-cio:$ktor_version"
|
||||||
implementation "io.ktor:ktor-client-content-negotiation:$ktor_version"
|
implementation "io.ktor:ktor-client-content-negotiation:$ktor_version"
|
||||||
implementation "io.ktor:ktor-serialization-kotlinx-json:$ktor_version"
|
implementation "io.ktor:ktor-serialization-kotlinx-json:$ktor_version"
|
||||||
|
implementation "org.slf4j:slf4j-api:2.0.16"
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.named("koverVerify").configure { enabled = false }
|
tasks.named("koverVerify").configure { enabled = false }
|
||||||
|
|||||||
@@ -51,12 +51,15 @@ import com.correx.infrastructure.workflow.PromptLoader
|
|||||||
import com.correx.infrastructure.workflow.TomlWorkflowLoader
|
import com.correx.infrastructure.workflow.TomlWorkflowLoader
|
||||||
import com.correx.infrastructure.workflow.WorkflowLoader
|
import com.correx.infrastructure.workflow.WorkflowLoader
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import java.nio.file.Paths
|
import java.nio.file.Paths
|
||||||
import java.nio.file.StandardCopyOption
|
import java.nio.file.StandardCopyOption
|
||||||
import java.sql.DriverManager
|
import java.sql.DriverManager
|
||||||
|
|
||||||
|
private val log = LoggerFactory.getLogger(InfrastructureModule::class.java)
|
||||||
|
|
||||||
@Suppress("TooManyFunctions")
|
@Suppress("TooManyFunctions")
|
||||||
object InfrastructureModule {
|
object InfrastructureModule {
|
||||||
private val defaultDbPath: String =
|
private val defaultDbPath: String =
|
||||||
@@ -199,7 +202,14 @@ object InfrastructureModule {
|
|||||||
|
|
||||||
fun createL3MemoryStoreFromConfig(config: L3Config): L3MemoryStore {
|
fun createL3MemoryStoreFromConfig(config: L3Config): L3MemoryStore {
|
||||||
return when (config.backend) {
|
return when (config.backend) {
|
||||||
"in_memory" -> createInMemoryL3MemoryStore()
|
"in_memory" -> {
|
||||||
|
log.warn(
|
||||||
|
"L3 backend is 'in_memory': non-durable, intended for tests/dev only — " +
|
||||||
|
"all router memory is lost on restart. " +
|
||||||
|
"Set [l3] backend = \"turbovec\" for persistence."
|
||||||
|
)
|
||||||
|
createInMemoryL3MemoryStore()
|
||||||
|
}
|
||||||
"turbovec" -> createTurboVecL3MemoryStore(config)
|
"turbovec" -> createTurboVecL3MemoryStore(config)
|
||||||
else -> throw IllegalArgumentException(
|
else -> throw IllegalArgumentException(
|
||||||
"Unknown L3 backend: '${config.backend}'. Supported: in_memory, turbovec"
|
"Unknown L3 backend: '${config.backend}'. Supported: in_memory, turbovec"
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import com.correx.core.context.model.ContextPack
|
import com.correx.core.context.model.ContextPack
|
||||||
import com.correx.core.context.model.TokenBudget
|
import com.correx.core.context.model.TokenBudget
|
||||||
|
import com.correx.core.events.events.ChatTurnEvent
|
||||||
|
import com.correx.core.events.events.ChatTurnRole
|
||||||
|
import com.correx.core.events.events.EventMetadata
|
||||||
|
import com.correx.core.events.events.L3MemoryRetrievedEvent
|
||||||
|
import com.correx.core.events.events.L3RetrievedHit
|
||||||
import com.correx.core.events.events.NewEvent
|
import com.correx.core.events.events.NewEvent
|
||||||
import com.correx.core.events.events.StoredEvent
|
import com.correx.core.events.events.StoredEvent
|
||||||
import com.correx.core.events.stores.EventStore
|
import com.correx.core.events.stores.EventStore
|
||||||
@@ -29,6 +34,7 @@ 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.InMemoryL3MemoryStore
|
||||||
|
import com.correx.core.router.l3.L3MemoryEntry
|
||||||
import com.correx.core.router.l3.L3MemoryStore
|
import com.correx.core.router.l3.L3MemoryStore
|
||||||
import com.correx.core.router.l3.L3Query
|
import com.correx.core.router.l3.L3Query
|
||||||
import com.correx.core.router.model.RouterConfig
|
import com.correx.core.router.model.RouterConfig
|
||||||
@@ -37,6 +43,7 @@ import com.correx.core.router.model.RouterState
|
|||||||
import com.correx.core.router.model.TurnRole
|
import com.correx.core.router.model.TurnRole
|
||||||
import com.correx.core.router.model.WorkflowStatus
|
import com.correx.core.router.model.WorkflowStatus
|
||||||
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
|
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
|
||||||
|
import com.correx.testing.fixtures.EventFixtures
|
||||||
import com.correx.testing.fixtures.inference.MockTokenizer
|
import com.correx.testing.fixtures.inference.MockTokenizer
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
@@ -73,14 +80,15 @@ class RouterFacadeTest {
|
|||||||
val mockStore = mockEventStore()
|
val mockStore = mockEventStore()
|
||||||
val facade = facadeWithMocks(eventStore = mockStore, chatMode = ChatMode.CHAT)
|
val facade = facadeWithMocks(eventStore = mockStore, chatMode = ChatMode.CHAT)
|
||||||
facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!")
|
facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!")
|
||||||
assertEquals(2, mockStore.appendedEvents.size)
|
val chatEvents = mockStore.appendedEvents
|
||||||
val userEvent = mockStore.appendedEvents[0].payload
|
.map { it.payload }
|
||||||
val routerEvent = mockStore.appendedEvents[1].payload
|
.filterIsInstance<com.correx.core.events.events.ChatTurnEvent>()
|
||||||
assertTrue(userEvent is com.correx.core.events.events.ChatTurnEvent)
|
assertEquals(2, chatEvents.size)
|
||||||
assertTrue(routerEvent is com.correx.core.events.events.ChatTurnEvent)
|
val userEvent = chatEvents[0]
|
||||||
assertEquals("Hello!", (userEvent as com.correx.core.events.events.ChatTurnEvent).content)
|
val routerEvent = chatEvents[1]
|
||||||
|
assertEquals("Hello!", userEvent.content)
|
||||||
assertEquals(com.correx.core.events.events.ChatTurnRole.USER, userEvent.role)
|
assertEquals(com.correx.core.events.events.ChatTurnRole.USER, userEvent.role)
|
||||||
assertEquals("inference response", (routerEvent as com.correx.core.events.events.ChatTurnEvent).content)
|
assertEquals("inference response", routerEvent.content)
|
||||||
assertEquals(com.correx.core.events.events.ChatTurnRole.ROUTER, routerEvent.role)
|
assertEquals(com.correx.core.events.events.ChatTurnRole.ROUTER, routerEvent.role)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,16 +109,16 @@ class RouterFacadeTest {
|
|||||||
val mockStore = mockEventStore()
|
val mockStore = mockEventStore()
|
||||||
val facade = facadeWithMocks(eventStore = mockStore, chatMode = ChatMode.STEERING)
|
val facade = facadeWithMocks(eventStore = mockStore, chatMode = ChatMode.STEERING)
|
||||||
facade.onUserInput(sessionId = SessionId("session-xyz"), input = "steer this way")
|
facade.onUserInput(sessionId = SessionId("session-xyz"), input = "steer this way")
|
||||||
assertEquals(3, mockStore.appendedEvents.size)
|
val payloads = mockStore.appendedEvents.map { it.payload }
|
||||||
val userEvent = mockStore.appendedEvents[0].payload
|
val chatEvents = payloads.filterIsInstance<com.correx.core.events.events.ChatTurnEvent>()
|
||||||
val routerEvent = mockStore.appendedEvents[1].payload
|
val steeringEvents = payloads.filterIsInstance<com.correx.core.events.events.SteeringNoteAddedEvent>()
|
||||||
val steeringEvent = mockStore.appendedEvents[2].payload
|
assertEquals(2, chatEvents.size)
|
||||||
assertTrue(userEvent is com.correx.core.events.events.ChatTurnEvent)
|
assertEquals(1, steeringEvents.size)
|
||||||
assertTrue(routerEvent is com.correx.core.events.events.ChatTurnEvent)
|
assertEquals("steer this way", chatEvents[0].content)
|
||||||
assertTrue(steeringEvent is com.correx.core.events.events.SteeringNoteAddedEvent)
|
assertEquals(com.correx.core.events.events.ChatTurnRole.USER, chatEvents[0].role)
|
||||||
assertEquals("steer this way", (userEvent as com.correx.core.events.events.ChatTurnEvent).content)
|
assertEquals("inference response", chatEvents[1].content)
|
||||||
assertEquals("inference response", (routerEvent as com.correx.core.events.events.ChatTurnEvent).content)
|
assertEquals(com.correx.core.events.events.ChatTurnRole.ROUTER, chatEvents[1].role)
|
||||||
assertEquals("inference response", (steeringEvent as com.correx.core.events.events.SteeringNoteAddedEvent).content)
|
assertEquals("inference response", steeringEvents[0].content)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -136,10 +144,10 @@ class RouterFacadeTest {
|
|||||||
l3MemoryStore = InMemoryL3MemoryStore(),
|
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)
|
val payloads = mockStore.appendedEvents.map { it.payload }
|
||||||
val steeringEvent = mockStore.appendedEvents[2].payload
|
val steeringEvents = payloads.filterIsInstance<com.correx.core.events.events.SteeringNoteAddedEvent>()
|
||||||
assertTrue(steeringEvent is com.correx.core.events.events.SteeringNoteAddedEvent)
|
assertEquals(1, steeringEvents.size)
|
||||||
assertEquals("steering response", (steeringEvent as com.correx.core.events.events.SteeringNoteAddedEvent).content)
|
assertEquals("steering response", steeringEvents[0].content)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
@@ -503,7 +511,9 @@ class RouterFacadeTest {
|
|||||||
assertNotNull(response)
|
assertNotNull(response)
|
||||||
assertEquals("inference response", response.content)
|
assertEquals("inference response", response.content)
|
||||||
assertTrue(response.steeringEmitted)
|
assertTrue(response.steeringEmitted)
|
||||||
assertEquals(3, mockStore.appendedEvents.size)
|
val payloads = mockStore.appendedEvents.map { it.payload }
|
||||||
|
assertEquals(2, payloads.filterIsInstance<com.correx.core.events.events.ChatTurnEvent>().size)
|
||||||
|
assertEquals(1, payloads.filterIsInstance<com.correx.core.events.events.SteeringNoteAddedEvent>().size)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -643,6 +653,200 @@ class RouterFacadeTest {
|
|||||||
override fun capabilities(): Set<CapabilityScore> = emptySet()
|
override fun capabilities(): Set<CapabilityScore> = emptySet()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// L3 retrieval slice 2a
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `retrieval emits L3MemoryRetrievedEvent with cross-session hit`(): Unit = runBlocking {
|
||||||
|
val dimension = 8
|
||||||
|
val knownVector = FloatArray(dimension) { if (it == 0) 1f else 0f }
|
||||||
|
val stubEmbedder = object : Embedder {
|
||||||
|
override val dimension: Int = dimension
|
||||||
|
override suspend fun embed(text: String): FloatArray = knownVector.copyOf()
|
||||||
|
}
|
||||||
|
val l3Store = InMemoryL3MemoryStore()
|
||||||
|
val otherSessionId = SessionId("other-session")
|
||||||
|
val crossSessionEntryId = "cross-entry-id"
|
||||||
|
val crossSessionTurnId = "cross-turn-id"
|
||||||
|
l3Store.store(
|
||||||
|
L3MemoryEntry(
|
||||||
|
id = crossSessionEntryId,
|
||||||
|
sessionId = otherSessionId,
|
||||||
|
turnId = crossSessionTurnId,
|
||||||
|
text = "memory from another session",
|
||||||
|
vector = knownVector.copyOf(),
|
||||||
|
timestampMs = 1000L,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val eventStore = mockEventStore()
|
||||||
|
val replayer = DefaultEventReplayer<RouterState>(
|
||||||
|
store = eventStore,
|
||||||
|
projection = RouterProjector(DefaultRouterReducer()),
|
||||||
|
)
|
||||||
|
val facade = DefaultRouterFacade(
|
||||||
|
routerRepository = object : RouterRepository {
|
||||||
|
override suspend fun getRouterState(sessionId: SessionId): RouterState =
|
||||||
|
replayer.rebuild(sessionId)
|
||||||
|
},
|
||||||
|
routerContextBuilder = object : RouterContextBuilder {
|
||||||
|
override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack = emptyContextPack()
|
||||||
|
},
|
||||||
|
inferenceRouter = mockInferenceRouter("router reply"),
|
||||||
|
eventStore = eventStore,
|
||||||
|
config = RouterConfig(tokenBudget = TokenBudget(limit = 5000), retrievalK = 5),
|
||||||
|
embedder = stubEmbedder,
|
||||||
|
l3MemoryStore = l3Store,
|
||||||
|
)
|
||||||
|
|
||||||
|
facade.onUserInput(sessionId = SessionId("current-session"), input = "hello")
|
||||||
|
|
||||||
|
val l3Events = eventStore.appendedEvents.filter { it.payload is L3MemoryRetrievedEvent }
|
||||||
|
assertEquals(1, l3Events.size)
|
||||||
|
val retrieved = l3Events[0].payload as L3MemoryRetrievedEvent
|
||||||
|
assertEquals(1, retrieved.hits.size)
|
||||||
|
assertEquals(crossSessionEntryId, retrieved.hits[0].entryId)
|
||||||
|
assertEquals(otherSessionId, retrieved.hits[0].sourceSessionId)
|
||||||
|
assertEquals(crossSessionTurnId, retrieved.hits[0].sourceTurnId)
|
||||||
|
assertEquals("memory from another session", retrieved.hits[0].text)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dedup filters hit whose turnId is already in current session history`(): Unit = runBlocking {
|
||||||
|
val dimension = 8
|
||||||
|
val knownVector = FloatArray(dimension) { if (it == 0) 1f else 0f }
|
||||||
|
val stubEmbedder = object : Embedder {
|
||||||
|
override val dimension: Int = dimension
|
||||||
|
override suspend fun embed(text: String): FloatArray = knownVector.copyOf()
|
||||||
|
}
|
||||||
|
val l3Store = InMemoryL3MemoryStore()
|
||||||
|
val currentSessionId = SessionId("dedup-session")
|
||||||
|
val inSessionTurnId = "in-session-turn-id"
|
||||||
|
|
||||||
|
// Pre-populate L3 with an entry whose turnId matches an in-session turn
|
||||||
|
l3Store.store(
|
||||||
|
L3MemoryEntry(
|
||||||
|
id = inSessionTurnId,
|
||||||
|
sessionId = currentSessionId,
|
||||||
|
turnId = inSessionTurnId,
|
||||||
|
text = "already in session history",
|
||||||
|
vector = knownVector.copyOf(),
|
||||||
|
timestampMs = 500L,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val eventStore = mockEventStore()
|
||||||
|
val replayer = DefaultEventReplayer<RouterState>(
|
||||||
|
store = eventStore,
|
||||||
|
projection = RouterProjector(DefaultRouterReducer()),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bootstrap session with a ChatTurnEvent whose turnId matches the L3 entry
|
||||||
|
eventStore.append(
|
||||||
|
NewEvent(
|
||||||
|
metadata = EventMetadata(
|
||||||
|
eventId = EventId("seed-event-id"),
|
||||||
|
sessionId = currentSessionId,
|
||||||
|
timestamp = kotlinx.datetime.Clock.System.now(),
|
||||||
|
schemaVersion = 1,
|
||||||
|
causationId = null,
|
||||||
|
correlationId = null,
|
||||||
|
),
|
||||||
|
payload = ChatTurnEvent(
|
||||||
|
sessionId = currentSessionId,
|
||||||
|
turnId = inSessionTurnId,
|
||||||
|
role = ChatTurnRole.USER,
|
||||||
|
content = "already in session history",
|
||||||
|
timestampMs = 500L,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val facade = DefaultRouterFacade(
|
||||||
|
routerRepository = object : RouterRepository {
|
||||||
|
override suspend fun getRouterState(sessionId: SessionId): RouterState =
|
||||||
|
replayer.rebuild(sessionId)
|
||||||
|
},
|
||||||
|
routerContextBuilder = object : RouterContextBuilder {
|
||||||
|
override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack = emptyContextPack()
|
||||||
|
},
|
||||||
|
inferenceRouter = mockInferenceRouter("router reply"),
|
||||||
|
eventStore = eventStore,
|
||||||
|
config = RouterConfig(tokenBudget = TokenBudget(limit = 5000), retrievalK = 5),
|
||||||
|
embedder = stubEmbedder,
|
||||||
|
l3MemoryStore = l3Store,
|
||||||
|
)
|
||||||
|
|
||||||
|
facade.onUserInput(sessionId = currentSessionId, input = "a new message")
|
||||||
|
|
||||||
|
val l3Events = eventStore.appendedEvents.filter { it.payload is L3MemoryRetrievedEvent }
|
||||||
|
// The only hit was in-session, so deduped to empty → no L3MemoryRetrievedEvent
|
||||||
|
assertTrue(l3Events.isEmpty(), "Expected no L3RetrievedEvent when all hits are deduplicated")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `retrieval failure is non-fatal - CHAT turn still completes`(): Unit = runBlocking {
|
||||||
|
val throwingEmbedder = object : Embedder {
|
||||||
|
override val dimension: Int = 8
|
||||||
|
override suspend fun embed(text: String): FloatArray =
|
||||||
|
throw RuntimeException("embedding service unavailable")
|
||||||
|
}
|
||||||
|
val eventStore = mockEventStore()
|
||||||
|
val facade = DefaultRouterFacade(
|
||||||
|
routerRepository = object : RouterRepository {
|
||||||
|
override suspend fun getRouterState(sessionId: SessionId): RouterState = RouterState()
|
||||||
|
},
|
||||||
|
routerContextBuilder = object : RouterContextBuilder {
|
||||||
|
override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack = emptyContextPack()
|
||||||
|
},
|
||||||
|
inferenceRouter = mockInferenceRouter("response"),
|
||||||
|
eventStore = eventStore,
|
||||||
|
config = RouterConfig(tokenBudget = TokenBudget(limit = 5000)),
|
||||||
|
embedder = throwingEmbedder,
|
||||||
|
l3MemoryStore = InMemoryL3MemoryStore(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Should not throw despite embedder failure
|
||||||
|
val response = facade.onUserInput(sessionId = SessionId("fail-session"), input = "test")
|
||||||
|
assertEquals("response", response.content)
|
||||||
|
|
||||||
|
val payloads = eventStore.appendedEvents.map { it.payload }
|
||||||
|
val chatEvents = payloads.filterIsInstance<com.correx.core.events.events.ChatTurnEvent>()
|
||||||
|
assertEquals(2, chatEvents.size, "USER and ROUTER ChatTurnEvents must be present")
|
||||||
|
val l3Events = payloads.filterIsInstance<L3MemoryRetrievedEvent>()
|
||||||
|
assertTrue(l3Events.isEmpty(), "No L3RetrievedEvent expected on retrieval failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `replay of L3MemoryRetrievedEvent populates lastRetrievedMemory in RouterState`(): Unit = runBlocking {
|
||||||
|
val reducer = DefaultRouterReducer()
|
||||||
|
val projector = RouterProjector(reducer)
|
||||||
|
val sessionId = SessionId("replay-session")
|
||||||
|
val hits = listOf(
|
||||||
|
L3RetrievedHit(
|
||||||
|
entryId = "entry-1",
|
||||||
|
sourceSessionId = SessionId("source-session"),
|
||||||
|
sourceTurnId = "source-turn-1",
|
||||||
|
text = "remembered text",
|
||||||
|
score = 0.95f,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val event = EventFixtures.stored(
|
||||||
|
payload = L3MemoryRetrievedEvent(
|
||||||
|
sessionId = sessionId,
|
||||||
|
queryTurnId = "query-turn-id",
|
||||||
|
hits = hits,
|
||||||
|
timestampMs = 9999L,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val state = projector.apply(projector.initial(), event)
|
||||||
|
assertEquals(1, state.lastRetrievedMemory.size)
|
||||||
|
assertEquals("entry-1", state.lastRetrievedMemory[0].entryId)
|
||||||
|
assertEquals("remembered text", state.lastRetrievedMemory[0].text)
|
||||||
|
assertEquals(0.95f, state.lastRetrievedMemory[0].score)
|
||||||
|
}
|
||||||
|
|
||||||
private fun emptyContextPack(): ContextPack = ContextPack(
|
private fun emptyContextPack(): ContextPack = ContextPack(
|
||||||
id = ContextPackId("empty"),
|
id = ContextPackId("empty"),
|
||||||
sessionId = SessionId("unknown"),
|
sessionId = SessionId("unknown"),
|
||||||
|
|||||||
Reference in New Issue
Block a user