feat: scaffold cross-session router memory (L3) with turbovec sidecar
Defines the L3MemoryStore contract in core:router (entry, query, hit, in-memory test default) and a new infrastructure:router:turbovec module that adapts turbovec via a Python sidecar over stdin/stdout JSON-Lines. Adapter manages the subprocess via ProcessBuilder with lazy start, mutex-serialized requests, and shutdown via withTimeout. Wire-format metadata that turbovec doesn't store (sessionId, turnId, text) is kept in an in-memory map for now — durable persistence ships with the upcoming ChatTurnEvent. Not yet wired into RouterFacade, InfrastructureModule, or DI. Embedding generation source is a separate concern.
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
package com.correx.core.router.l3
|
||||
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* In-memory L3 memory store for testing. Not production-grade.
|
||||
* Uses cosine similarity for vector matching, thread-safe via Mutex.
|
||||
*/
|
||||
class InMemoryL3MemoryStore : L3MemoryStore {
|
||||
private val entries = mutableListOf<L3MemoryEntry>()
|
||||
private val mutex = Mutex()
|
||||
|
||||
override suspend fun store(entry: L3MemoryEntry) {
|
||||
mutex.withLock {
|
||||
entries.add(entry)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun query(query: L3Query): List<L3Hit> {
|
||||
return mutex.withLock {
|
||||
entries
|
||||
.map { entry ->
|
||||
val score = cosineSimilarity(query.vector, entry.vector)
|
||||
L3Hit(entry, score)
|
||||
}
|
||||
.filter { hit ->
|
||||
query.sessionIdFilter == null || hit.entry.sessionId == query.sessionIdFilter
|
||||
}
|
||||
.sortedByDescending { it.score }
|
||||
.take(query.k)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun close() {
|
||||
// No-op for in-memory store
|
||||
}
|
||||
|
||||
private fun cosineSimilarity(a: FloatArray, b: FloatArray): Float {
|
||||
require(a.size == b.size) { "Vector sizes must match" }
|
||||
val dotProduct = a.indices.sumOf { (a[it] * b[it]).toDouble() }
|
||||
val normA = sqrt(a.indices.sumOf { (a[it] * a[it]).toDouble() })
|
||||
val normB = sqrt(b.indices.sumOf { (b[it] * b[it]).toDouble() })
|
||||
if (normA == 0.0 || normB == 0.0) return 0f
|
||||
return (dotProduct / (normA * normB)).toFloat()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.correx.core.router.l3
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class L3Hit(
|
||||
val entry: L3MemoryEntry,
|
||||
val score: Float
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.correx.core.router.l3
|
||||
|
||||
import com.correx.core.events.types.SessionId
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class L3MemoryEntry(
|
||||
val id: String,
|
||||
val sessionId: SessionId,
|
||||
val turnId: String,
|
||||
val text: String,
|
||||
val vector: FloatArray,
|
||||
val timestampMs: Long
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as L3MemoryEntry
|
||||
|
||||
if (id != other.id) return false
|
||||
if (sessionId != other.sessionId) return false
|
||||
if (turnId != other.turnId) return false
|
||||
if (text != other.text) return false
|
||||
if (!vector.contentEquals(other.vector)) return false
|
||||
if (timestampMs != other.timestampMs) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = id.hashCode()
|
||||
result = 31 * result + sessionId.hashCode()
|
||||
result = 31 * result + turnId.hashCode()
|
||||
result = 31 * result + text.hashCode()
|
||||
result = 31 * result + vector.contentHashCode()
|
||||
result = 31 * result + timestampMs.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return "L3MemoryEntry(id='$id', sessionId='$sessionId', turnId='$turnId', " +
|
||||
"text='$text', vector=${vector.contentToString()}, timestampMs=$timestampMs)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.correx.core.router.l3
|
||||
|
||||
/**
|
||||
* Cross-session memory store interface for vector-based retrieval.
|
||||
* Vectors are pre-computed; this contract focuses on storage and similarity search.
|
||||
*/
|
||||
interface L3MemoryStore {
|
||||
/**
|
||||
* Store a memory entry with its vector.
|
||||
*/
|
||||
suspend fun store(entry: L3MemoryEntry)
|
||||
|
||||
/**
|
||||
* Query the store with a vector; returns top-k hits sorted by similarity score descending.
|
||||
*/
|
||||
suspend fun query(query: L3Query): List<L3Hit>
|
||||
|
||||
/**
|
||||
* Close the store and release resources.
|
||||
*/
|
||||
suspend fun close()
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.correx.core.router.l3
|
||||
|
||||
import com.correx.core.events.types.SessionId
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class L3Query(
|
||||
val vector: FloatArray,
|
||||
val k: Int,
|
||||
val sessionIdFilter: SessionId? = null
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as L3Query
|
||||
|
||||
if (!vector.contentEquals(other.vector)) return false
|
||||
if (k != other.k) return false
|
||||
if (sessionIdFilter != other.sessionIdFilter) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = vector.contentHashCode()
|
||||
result = 31 * result + k
|
||||
result = 31 * result + (sessionIdFilter?.hashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return "L3Query(vector=${vector.contentToString()}, k=$k, sessionIdFilter=$sessionIdFilter)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.correx.core.router.l3
|
||||
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.utils.TypeId
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
|
||||
class InMemoryL3MemoryStoreTest {
|
||||
@Test
|
||||
fun `stores and queries entries by cosine similarity`(): Unit = runBlocking {
|
||||
val store = InMemoryL3MemoryStore()
|
||||
val sessionId = TypeId("session-1")
|
||||
|
||||
val entry1 = L3MemoryEntry(
|
||||
id = "entry-1",
|
||||
sessionId = sessionId,
|
||||
turnId = "turn-1",
|
||||
text = "hello world",
|
||||
vector = floatArrayOf(1f, 0f, 0f),
|
||||
timestampMs = 100L
|
||||
)
|
||||
|
||||
val entry2 = L3MemoryEntry(
|
||||
id = "entry-2",
|
||||
sessionId = sessionId,
|
||||
turnId = "turn-2",
|
||||
text = "goodbye world",
|
||||
vector = floatArrayOf(0.9f, 0.1f, 0f),
|
||||
timestampMs = 200L
|
||||
)
|
||||
|
||||
val entry3 = L3MemoryEntry(
|
||||
id = "entry-3",
|
||||
sessionId = sessionId,
|
||||
turnId = "turn-3",
|
||||
text = "far away",
|
||||
vector = floatArrayOf(0f, 0f, 1f),
|
||||
timestampMs = 300L
|
||||
)
|
||||
|
||||
store.store(entry1)
|
||||
store.store(entry2)
|
||||
store.store(entry3)
|
||||
|
||||
val query = L3Query(vector = floatArrayOf(1f, 0f, 0f), k = 2)
|
||||
val hits = store.query(query)
|
||||
|
||||
assertEquals(2, hits.size, "Should return k=2 results")
|
||||
assertEquals("entry-1", hits[0].entry.id, "Closest match should be entry-1")
|
||||
assertEquals("entry-2", hits[1].entry.id, "Second match should be entry-2")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `respects sessionIdFilter`(): Unit = runBlocking {
|
||||
val store = InMemoryL3MemoryStore()
|
||||
val session1 = TypeId("session-1")
|
||||
val session2 = TypeId("session-2")
|
||||
|
||||
val entry1 = L3MemoryEntry(
|
||||
id = "entry-1",
|
||||
sessionId = session1,
|
||||
turnId = "turn-1",
|
||||
text = "session 1",
|
||||
vector = floatArrayOf(1f, 0f),
|
||||
timestampMs = 100L
|
||||
)
|
||||
|
||||
val entry2 = L3MemoryEntry(
|
||||
id = "entry-2",
|
||||
sessionId = session2,
|
||||
turnId = "turn-1",
|
||||
text = "session 2",
|
||||
vector = floatArrayOf(1f, 0f),
|
||||
timestampMs = 200L
|
||||
)
|
||||
|
||||
store.store(entry1)
|
||||
store.store(entry2)
|
||||
|
||||
val query = L3Query(vector = floatArrayOf(1f, 0f), k = 10, sessionIdFilter = session1)
|
||||
val hits = store.query(query)
|
||||
|
||||
assertEquals(1, hits.size, "Should only return entry from session1")
|
||||
assertEquals("entry-1", hits[0].entry.id)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user