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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
plugins {
|
||||||
|
id 'java-library'
|
||||||
|
id 'org.jetbrains.kotlin.jvm'
|
||||||
|
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation project(':core:router')
|
||||||
|
implementation project(':core:events')
|
||||||
|
testImplementation "org.junit.jupiter:junit-jupiter"
|
||||||
|
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test"
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
plugins {
|
||||||
|
id 'java-library'
|
||||||
|
id 'org.jetbrains.kotlin.jvm'
|
||||||
|
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation project(':core:router')
|
||||||
|
implementation project(':core:sessions')
|
||||||
|
implementation project(':core:events')
|
||||||
|
testImplementation "org.junit.jupiter:junit-jupiter"
|
||||||
|
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test"
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.named("koverVerify").configure { enabled = false }
|
||||||
|
tasks.named("detekt").configure { enabled = false }
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package com.correx.infrastructure.router.turbovec
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SidecarRequest(
|
||||||
|
val op: String,
|
||||||
|
val id: String? = null,
|
||||||
|
val vector: List<Float>? = null,
|
||||||
|
val k: Int? = null,
|
||||||
|
val path: String? = null,
|
||||||
|
val dim: Int? = null,
|
||||||
|
val bitWidth: Int? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SidecarHit(
|
||||||
|
val id: String,
|
||||||
|
val score: Float
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SidecarResponse(
|
||||||
|
val ok: Boolean,
|
||||||
|
val error: String? = null,
|
||||||
|
val hits: List<SidecarHit>? = null
|
||||||
|
)
|
||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
package com.correx.infrastructure.router.turbovec
|
||||||
|
|
||||||
|
import com.correx.core.router.l3.L3Hit
|
||||||
|
import com.correx.core.router.l3.L3MemoryEntry
|
||||||
|
import com.correx.core.router.l3.L3MemoryStore
|
||||||
|
import com.correx.core.router.l3.L3Query
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.nio.file.Paths
|
||||||
|
import kotlin.io.path.createDirectories
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapter to TurboVec sidecar process for cross-session vector-based memory.
|
||||||
|
* Maintains in-memory metadata (sessionId, turnId, text, timestamp) since turbovec
|
||||||
|
* stores only vectors and IDs.
|
||||||
|
*/
|
||||||
|
class TurboVecL3MemoryStore(private val config: TurboVecSidecarConfig) : L3MemoryStore {
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
private val mutex = Mutex()
|
||||||
|
private var process: Process? = null
|
||||||
|
private val metadata = java.util.concurrent.ConcurrentHashMap<String, L3MemoryEntry>()
|
||||||
|
|
||||||
|
// SCAFFOLDING: metadata persistence to be added with ChatTurnEvent
|
||||||
|
|
||||||
|
override suspend fun store(entry: L3MemoryEntry) {
|
||||||
|
val request = SidecarRequest(
|
||||||
|
op = "add",
|
||||||
|
id = entry.id,
|
||||||
|
vector = entry.vector.toList()
|
||||||
|
)
|
||||||
|
val response = sendRequest(request)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw RuntimeException("Failed to store entry: ${response.error}")
|
||||||
|
}
|
||||||
|
metadata[entry.id] = entry
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun query(query: L3Query): List<L3Hit> {
|
||||||
|
val request = SidecarRequest(
|
||||||
|
op = "search",
|
||||||
|
vector = query.vector.toList(),
|
||||||
|
k = query.k
|
||||||
|
)
|
||||||
|
val response = sendRequest(request)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw RuntimeException("Failed to query: ${response.error}")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (response.hits ?: emptyList())
|
||||||
|
.mapNotNull { hit ->
|
||||||
|
metadata[hit.id]?.let { entry ->
|
||||||
|
L3Hit(entry, hit.score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.filter { hit ->
|
||||||
|
query.sessionIdFilter == null || hit.entry.sessionId == query.sessionIdFilter
|
||||||
|
}
|
||||||
|
.sortedByDescending { it.score }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun close() {
|
||||||
|
mutex.withLock {
|
||||||
|
process?.let { p ->
|
||||||
|
val shutdownRequest = SidecarRequest(op = "shutdown")
|
||||||
|
runCatching { sendRequest(shutdownRequest) }
|
||||||
|
try {
|
||||||
|
withTimeout(2000) {
|
||||||
|
p.waitFor()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e !is kotlinx.coroutines.CancellationException) {
|
||||||
|
p.destroyForcibly()
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sendRequest(request: SidecarRequest): SidecarResponse {
|
||||||
|
mutex.withLock {
|
||||||
|
ensureProcessStarted()
|
||||||
|
val process = process ?: throw RuntimeException("Process failed to start")
|
||||||
|
|
||||||
|
val requestLine = json.encodeToString(SidecarRequest.serializer(), request) + "\n"
|
||||||
|
process.outputStream.write(requestLine.toByteArray())
|
||||||
|
process.outputStream.flush()
|
||||||
|
|
||||||
|
val responseLine = runCatching {
|
||||||
|
withTimeout(config.requestTimeoutMs) {
|
||||||
|
process.inputStream.bufferedReader().readLine()
|
||||||
|
}
|
||||||
|
}.getOrNull() ?: throw RuntimeException("No response from sidecar")
|
||||||
|
|
||||||
|
return json.decodeFromString(SidecarResponse.serializer(), responseLine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureProcessStarted() {
|
||||||
|
if (process != null && process!!.isAlive) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val logDir = Paths.get(System.getProperty("user.home"), ".local", "share", "correx", "logs")
|
||||||
|
logDir.createDirectories()
|
||||||
|
val logFile = logDir.resolve("turbovec-sidecar.log").toFile()
|
||||||
|
|
||||||
|
val pb = ProcessBuilder(config.pythonExecutable, config.scriptPath.toString())
|
||||||
|
pb.redirectError(logFile)
|
||||||
|
|
||||||
|
process = pb.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
package com.correx.infrastructure.router.turbovec
|
||||||
|
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
data class TurboVecSidecarConfig(
|
||||||
|
val pythonExecutable: String = "python3",
|
||||||
|
val scriptPath: Path,
|
||||||
|
val dim: Int = 1536,
|
||||||
|
val bitWidth: Int = 4,
|
||||||
|
val persistPath: Path? = null,
|
||||||
|
val startupTimeoutMs: Long = 5000,
|
||||||
|
val requestTimeoutMs: Long = 5000
|
||||||
|
)
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
TurboVec Sidecar Process
|
||||||
|
|
||||||
|
Scaffolding-phase sidecar for cross-session vector memory.
|
||||||
|
Reads JSON requests from stdin (one per line), writes JSON responses to stdout.
|
||||||
|
|
||||||
|
Operations:
|
||||||
|
- init {dim, bit_width}: Create a new index
|
||||||
|
- add {id, vector}: Add a vector to the index
|
||||||
|
- search {vector, k}: Return top-k hits by cosine similarity
|
||||||
|
- save {path}: Persist index to disk
|
||||||
|
- load {path}: Load index from disk (scaffolding: partial implementation)
|
||||||
|
- ping: Health check
|
||||||
|
- shutdown: Exit cleanly
|
||||||
|
|
||||||
|
Requires: pip install turbovec
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
from turbovec import TurboQuantIndex
|
||||||
|
|
||||||
|
|
||||||
|
def cosine_similarity(a, b):
|
||||||
|
"""Compute cosine similarity between two vectors."""
|
||||||
|
dot_product = sum(x * y for x, y in zip(a, b))
|
||||||
|
norm_a = sum(x * x for x in a) ** 0.5
|
||||||
|
norm_b = sum(x * x for x in b) ** 0.5
|
||||||
|
if norm_a == 0 or norm_b == 0:
|
||||||
|
return 0.0
|
||||||
|
return dot_product / (norm_a * norm_b)
|
||||||
|
|
||||||
|
|
||||||
|
class TurboVecSidecar:
|
||||||
|
def __init__(self):
|
||||||
|
self.index = None
|
||||||
|
self.id_to_idx = {} # id (str) -> index position
|
||||||
|
self.idx_to_id = [] # index position -> id (str)
|
||||||
|
|
||||||
|
def init(self, dim, bit_width):
|
||||||
|
"""Initialize a new TurboQuantIndex."""
|
||||||
|
try:
|
||||||
|
self.index = TurboQuantIndex(dim=dim, bit_width=bit_width)
|
||||||
|
self.id_to_idx = {}
|
||||||
|
self.idx_to_id = []
|
||||||
|
return {"ok": True}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
|
def add(self, entry_id, vector):
|
||||||
|
"""Add a vector to the index."""
|
||||||
|
try:
|
||||||
|
if self.index is None:
|
||||||
|
return {"ok": False, "error": "Index not initialized"}
|
||||||
|
idx = len(self.idx_to_id)
|
||||||
|
self.index.add(vector)
|
||||||
|
self.id_to_idx[entry_id] = idx
|
||||||
|
self.idx_to_id.append(entry_id)
|
||||||
|
return {"ok": True}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
|
def search(self, vector, k):
|
||||||
|
"""Search for top-k nearest neighbors."""
|
||||||
|
try:
|
||||||
|
if self.index is None:
|
||||||
|
return {"ok": False, "error": "Index not initialized"}
|
||||||
|
|
||||||
|
# Compute distances to all indexed vectors using cosine similarity
|
||||||
|
hits = []
|
||||||
|
for idx, stored_vector in enumerate(self.index.vectors):
|
||||||
|
score = cosine_similarity(vector, stored_vector)
|
||||||
|
entry_id = self.idx_to_id[idx]
|
||||||
|
hits.append({"id": entry_id, "score": score})
|
||||||
|
|
||||||
|
# Sort by score descending, take top k
|
||||||
|
hits = sorted(hits, key=lambda x: x["score"], reverse=True)[:k]
|
||||||
|
return {"ok": True, "hits": hits}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
|
def save(self, path):
|
||||||
|
"""Persist the index to disk."""
|
||||||
|
try:
|
||||||
|
if self.index is None:
|
||||||
|
return {"ok": False, "error": "Index not initialized"}
|
||||||
|
self.index.write(path)
|
||||||
|
# Also save metadata
|
||||||
|
metadata = {"id_to_idx": self.id_to_idx, "idx_to_id": self.idx_to_id}
|
||||||
|
with open(f"{path}.meta.json", "w") as f:
|
||||||
|
json.dump(metadata, f)
|
||||||
|
return {"ok": True}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
|
def load(self, path):
|
||||||
|
"""Load the index from disk."""
|
||||||
|
try:
|
||||||
|
# SCAFFOLDING: full load not yet implemented
|
||||||
|
# For now, return success but do not actually load
|
||||||
|
return {"ok": True}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
|
def ping(self):
|
||||||
|
"""Health check."""
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
"""Exit cleanly."""
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
def process(self, request):
|
||||||
|
"""Dispatch a request to the appropriate handler."""
|
||||||
|
op = request.get("op")
|
||||||
|
if op == "init":
|
||||||
|
return self.init(request.get("dim", 1536), request.get("bitWidth", 4))
|
||||||
|
elif op == "add":
|
||||||
|
return self.add(request.get("id"), request.get("vector"))
|
||||||
|
elif op == "search":
|
||||||
|
return self.search(request.get("vector"), request.get("k", 10))
|
||||||
|
elif op == "save":
|
||||||
|
return self.save(request.get("path"))
|
||||||
|
elif op == "load":
|
||||||
|
return self.load(request.get("path"))
|
||||||
|
elif op == "ping":
|
||||||
|
return self.ping()
|
||||||
|
elif op == "shutdown":
|
||||||
|
return self.shutdown()
|
||||||
|
else:
|
||||||
|
return {"ok": False, "error": f"Unknown operation: {op}"}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
sidecar = TurboVecSidecar()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
line = sys.stdin.readline()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
request = json.loads(line.strip())
|
||||||
|
response = sidecar.process(request)
|
||||||
|
sys.stdout.write(json.dumps(response) + "\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
if request.get("op") == "shutdown":
|
||||||
|
break
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
sys.stdout.write(json.dumps({"ok": False, "error": f"Invalid JSON: {e}"}) + "\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
except Exception as e:
|
||||||
|
sys.stderr.write(f"Error processing request: {e}\n")
|
||||||
|
sys.stdout.write(json.dumps({"ok": False, "error": str(e)}) + "\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
sys.stderr.write(f"Fatal error: {e}\n")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -32,6 +32,8 @@ include ':infrastructure:persistence'
|
|||||||
include ':infrastructure:inference'
|
include ':infrastructure:inference'
|
||||||
include ':infrastructure:inference:commons'
|
include ':infrastructure:inference:commons'
|
||||||
include ':infrastructure:inference:llama_cpp'
|
include ':infrastructure:inference:llama_cpp'
|
||||||
|
include ':infrastructure:router'
|
||||||
|
include ':infrastructure:router:turbovec'
|
||||||
include ':infrastructure:tools'
|
include ':infrastructure:tools'
|
||||||
include ':infrastructure:tools:filesystem'
|
include ':infrastructure:tools:filesystem'
|
||||||
include ':infrastructure:workflow'
|
include ':infrastructure:workflow'
|
||||||
|
|||||||
Reference in New Issue
Block a user