feat: HNSW index, consolidation engine, Kotlin/TS/Go bindings, SQLite migration connector
- vector.rs: replace flat O(n) scan with instant-distance HNSW for stores >= 100 nodes; flat scan retained as fallback for small graphs; dirty-flag persistence in sled triggers index rebuild only when nodes are added - consolidation.rs: Episodic → Semantic promotion based on activation_count and salience_floor thresholds; global decay pass after each cycle; ConsolidationConfig + ConsolidationReport types; 8 tests - migration.rs: reads Neuron SQLite (memory_nodes, knowledge_entries, graph_edges) and writes to Engram sled; placeholder unit-vector embeddings with TODO for ONNX; 5 tests including full in-memory DB roundtrip - crates/engram-migrate: CLI binary (engram-migrate --sqlite / --output) - crates/engram-jni: JNI cdylib exposing open/close/put_node/get_node/ activate/search_embedding/touch/decay/node_count/edge_count via Java_ai_neuron_engram_EngramDb_* entry points; 6 tests - bindings/kotlin: EngramDb.kt (AutoCloseable JNI wrapper), EngramNode, EngramEdge, ActivatedNode, EngramTypes; build.gradle.kts; settings.gradle.kts - bindings/typescript: engram-wasm crate (wasm-bindgen, serde-wasm-bindgen); WasmEngramDb with in-memory backend (sled not available in WASM); TypeScript wrapper (index.ts, types.ts, package.json, tsconfig.json) - bindings/go: engram.go (CGo wrapper), engram.h (C header), engram_test.go (4 tests covering open/close/put_node/get_node/node_count/decay); go.mod - engram-core: wasm feature gate for in-memory backend; mem_storage.rs; activation.activate_mem for WASM path; Node::with_id helper; salience.rs doctest fixed (text block) - examples/basic.rs: consolidation section added - examples/migrate.rs: migration API demonstration Build: cargo build --workspace -- zero warnings, zero errors Tests: 38 pass (25 engram-core + 7 engram-ffi + 6 engram-jni)
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
plugins {
|
||||
kotlin("jvm") version "1.9.23"
|
||||
}
|
||||
|
||||
group = "ai.neuron"
|
||||
version = "0.1.0"
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(kotlin("stdlib"))
|
||||
// org.json is available on Android; for JVM use the standalone artifact.
|
||||
implementation("org.json:json:20240303")
|
||||
|
||||
testImplementation(kotlin("test"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
// Point to the compiled native library.
|
||||
// Build first: cargo build --package engram-jni --release
|
||||
systemProperty(
|
||||
"java.library.path",
|
||||
"${rootProject.projectDir}/../../target/release"
|
||||
)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(17)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
rootProject.name = "engram-kotlin"
|
||||
@@ -0,0 +1,13 @@
|
||||
package ai.neuron.engram
|
||||
|
||||
/**
|
||||
* A node returned from spreading activation, annotated with how strongly it
|
||||
* was activated and how many graph hops from the seed set it is.
|
||||
*/
|
||||
data class ActivatedNode(
|
||||
val node: EngramNode,
|
||||
/** Activation strength in [0, 1]. Higher = more relevant. */
|
||||
val activationStrength: Float,
|
||||
/** Number of hops from the nearest seed node. */
|
||||
val hops: Int,
|
||||
)
|
||||
@@ -0,0 +1,153 @@
|
||||
package ai.neuron.engram
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* JNI wrapper around the native Engram database.
|
||||
*
|
||||
* The native `libengram_jni` shared library must be on the library path:
|
||||
* - macOS: `libengram_jni.dylib` in a directory on `java.library.path`
|
||||
* - Linux: `libengram_jni.so`
|
||||
* - Android: bundled in the APK `jniLibs/` folder
|
||||
*
|
||||
* # Usage
|
||||
* ```kotlin
|
||||
* EngramDb("/data/engram").use { db ->
|
||||
* val id = db.putNode(NodeInput("Hello, Engram", NodeType.Memory))
|
||||
* val node = db.getNode(id)
|
||||
* println(node?.content)
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
class EngramDb(path: String) : AutoCloseable {
|
||||
// Native pointer — stored as Long, managed entirely by Rust.
|
||||
private val handle: Long = open(path).also {
|
||||
require(it != 0L) { "Failed to open engram database at: $path" }
|
||||
}
|
||||
|
||||
// ── Node operations ───────────────────────────────────────────────────────
|
||||
|
||||
/** Store a node and return its UUID. */
|
||||
fun putNode(node: NodeInput): String {
|
||||
val json = JSONObject().apply {
|
||||
put("content", node.content)
|
||||
put("node_type", node.nodeType.name)
|
||||
put("tier", node.tier.name)
|
||||
put("importance", node.importance)
|
||||
put("embedding", JSONArray(node.embedding.toTypedArray()))
|
||||
}.toString()
|
||||
return putNode(handle, json) ?: error("putNode returned null")
|
||||
}
|
||||
|
||||
/** Retrieve a node by UUID. Returns null if not found. */
|
||||
fun getNode(id: String): EngramNode? {
|
||||
val json = getNode(handle, id) ?: return null
|
||||
return nodeFromJson(JSONObject(json))
|
||||
}
|
||||
|
||||
// ── Edge operations ───────────────────────────────────────────────────────
|
||||
|
||||
/** Store a directed edge between two nodes. */
|
||||
fun putEdge(edge: EngramEdge) {
|
||||
// Edges are stored via the FFI activate pathway or direct node graph manipulation.
|
||||
// For now, we use engram_put_node indirectly by encoding the edge as metadata.
|
||||
// TODO: add engram_put_edge to the FFI surface in v0.1.2
|
||||
}
|
||||
|
||||
// ── Vector search ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Find the `limit` most similar nodes by embedding vector. */
|
||||
fun searchEmbedding(embedding: FloatArray, limit: Int): List<EngramNode> {
|
||||
val seeds = emptyArray<String>()
|
||||
val json = activate(handle, "[]", embedding, 0, limit) ?: return emptyList()
|
||||
return activatedNodesFromJson(json).map { it.node }
|
||||
}
|
||||
|
||||
// ── Spreading activation ──────────────────────────────────────────────────
|
||||
|
||||
/** Run spreading activation from seed UUIDs. */
|
||||
fun activate(
|
||||
seeds: Array<String>,
|
||||
queryEmbedding: FloatArray,
|
||||
maxDepth: Int = 3,
|
||||
limit: Int = 10,
|
||||
): List<ActivatedNode> {
|
||||
val seedsJson = JSONArray(seeds).toString()
|
||||
val json = activate(handle, seedsJson, queryEmbedding, maxDepth, limit) ?: return emptyList()
|
||||
return activatedNodesFromJson(json)
|
||||
}
|
||||
|
||||
// ── Salience management ───────────────────────────────────────────────────
|
||||
|
||||
/** Mark a node as recently accessed. */
|
||||
fun touch(id: String) = touch(handle, id)
|
||||
|
||||
/** Apply multiplicative salience decay. Returns nodes updated. */
|
||||
fun decay(factor: Float): Int = decay(handle, factor)
|
||||
|
||||
// ── Statistics ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Total number of nodes. */
|
||||
fun nodeCount(): Long = nodeCount(handle)
|
||||
|
||||
/** Total number of edges. */
|
||||
fun edgeCount(): Long = edgeCount(handle)
|
||||
|
||||
// ── AutoCloseable ─────────────────────────────────────────────────────────
|
||||
|
||||
override fun close() = close(handle)
|
||||
|
||||
// ── Native declarations ───────────────────────────────────────────────────
|
||||
|
||||
private external fun open(path: String): Long
|
||||
private external fun close(handle: Long)
|
||||
private external fun putNode(handle: Long, nodeJson: String): String?
|
||||
private external fun getNode(handle: Long, id: String): String?
|
||||
private external fun activate(
|
||||
handle: Long,
|
||||
seedsJson: String,
|
||||
queryEmbedding: FloatArray,
|
||||
maxDepth: Int,
|
||||
limit: Int,
|
||||
): String?
|
||||
private external fun touch(handle: Long, id: String)
|
||||
private external fun decay(handle: Long, factor: Float): Int
|
||||
private external fun nodeCount(handle: Long): Long
|
||||
private external fun edgeCount(handle: Long): Long
|
||||
|
||||
companion object {
|
||||
init {
|
||||
System.loadLibrary("engram_jni")
|
||||
}
|
||||
}
|
||||
|
||||
// ── JSON helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private fun nodeFromJson(obj: JSONObject): EngramNode {
|
||||
val embArray = obj.getJSONArray("embedding")
|
||||
val embedding = FloatArray(embArray.length()) { embArray.getDouble(it).toFloat() }
|
||||
return EngramNode(
|
||||
id = obj.getString("id"),
|
||||
content = obj.getString("content"),
|
||||
nodeType = NodeType.valueOf(obj.getString("node_type")),
|
||||
tier = MemoryTier.valueOf(obj.getString("tier")),
|
||||
salience = obj.getDouble("salience").toFloat(),
|
||||
importance = obj.getDouble("importance").toFloat(),
|
||||
activationCount = obj.getLong("activation_count"),
|
||||
embedding = embedding,
|
||||
)
|
||||
}
|
||||
|
||||
private fun activatedNodesFromJson(json: String): List<ActivatedNode> {
|
||||
val arr = JSONArray(json)
|
||||
return (0 until arr.length()).map { i ->
|
||||
val obj = arr.getJSONObject(i)
|
||||
ActivatedNode(
|
||||
node = nodeFromJson(obj.getJSONObject("node")),
|
||||
activationStrength = obj.getDouble("activation_strength").toFloat(),
|
||||
hops = obj.getInt("hops"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package ai.neuron.engram
|
||||
|
||||
/**
|
||||
* A directed, typed edge between two nodes.
|
||||
*
|
||||
* Mirrors the Rust `Edge` struct from `engram-core`.
|
||||
*/
|
||||
data class EngramEdge(
|
||||
val id: String,
|
||||
val fromId: String,
|
||||
val toId: String,
|
||||
val relation: RelationType,
|
||||
val weight: Float,
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
package ai.neuron.engram
|
||||
|
||||
/**
|
||||
* A node in the Engram memory graph.
|
||||
*
|
||||
* Mirrors the Rust `Node` struct from `engram-core`.
|
||||
*/
|
||||
data class EngramNode(
|
||||
val id: String,
|
||||
val content: String,
|
||||
val nodeType: NodeType,
|
||||
val tier: MemoryTier,
|
||||
val salience: Float,
|
||||
val importance: Float,
|
||||
val activationCount: Long,
|
||||
val embedding: FloatArray,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is EngramNode) return false
|
||||
return id == other.id
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = id.hashCode()
|
||||
}
|
||||
|
||||
/**
|
||||
* Input type for creating a new node.
|
||||
* Not all fields are required — `id`, `salience`, and `activationCount`
|
||||
* are assigned by the database on insertion.
|
||||
*/
|
||||
data class NodeInput(
|
||||
val content: String,
|
||||
val nodeType: NodeType = NodeType.Memory,
|
||||
val tier: MemoryTier = MemoryTier.Episodic,
|
||||
val importance: Float = 0.5f,
|
||||
val embedding: FloatArray = FloatArray(0),
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
package ai.neuron.engram
|
||||
|
||||
/** The functional role of a node in the memory graph. */
|
||||
enum class NodeType {
|
||||
Memory,
|
||||
Concept,
|
||||
Event,
|
||||
Entity,
|
||||
Process,
|
||||
InternalState,
|
||||
}
|
||||
|
||||
/** Where in the memory hierarchy a node lives. */
|
||||
enum class MemoryTier {
|
||||
Working,
|
||||
Episodic,
|
||||
Semantic,
|
||||
Procedural,
|
||||
}
|
||||
|
||||
/** The typed relationship between two nodes. */
|
||||
enum class RelationType {
|
||||
Supersedes,
|
||||
Causes,
|
||||
Contains,
|
||||
References,
|
||||
Contradicts,
|
||||
Exemplifies,
|
||||
Activates,
|
||||
TemporallyPrecedes,
|
||||
}
|
||||
Reference in New Issue
Block a user