init: Engram v0.1 — native memory substrate for accumulating intelligence
Memory is not stored and retrieved — it is activated and propagated. Implements the spreading activation model with salience decay, typed edges, four memory tiers, and flat cosine vector search over a sled embedded store.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
target/
|
||||
*.db
|
||||
.DS_Store
|
||||
Generated
+671
@@ -0,0 +1,671 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "bincode"
|
||||
version = "1.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "engram"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"engram-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "engram-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bincode",
|
||||
"serde",
|
||||
"sled",
|
||||
"thiserror",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "engram-ffi"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"engram-core",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "fs2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fxhash"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.0",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "instant"
|
||||
version = "0.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.95"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
||||
dependencies = [
|
||||
"scopeguard",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99"
|
||||
dependencies = [
|
||||
"instant",
|
||||
"lock_api",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot_core"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"instant",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sled"
|
||||
version = "0.34.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f96b4737c2ce5987354855aed3797279def4ebf734436c6aa4552cf8e169935"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
"fs2",
|
||||
"fxhash",
|
||||
"libc",
|
||||
"log",
|
||||
"parking_lot",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.3+wasi-0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
|
||||
dependencies = [
|
||||
"wit-bindgen 0.57.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasip3"
|
||||
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
|
||||
dependencies = [
|
||||
"wit-bindgen 0.51.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
dependencies = [
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.11.1",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-xid",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
@@ -0,0 +1,20 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/engram-core",
|
||||
"crates/engram-ffi",
|
||||
]
|
||||
|
||||
# Workspace-level example that depends on engram-core.
|
||||
# Run with: cargo run --example basic
|
||||
[[example]]
|
||||
name = "basic"
|
||||
path = "examples/basic.rs"
|
||||
|
||||
[package]
|
||||
name = "engram"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
engram-core = { path = "crates/engram-core" }
|
||||
@@ -0,0 +1,179 @@
|
||||
# Engram
|
||||
|
||||
**A local-first memory substrate for accumulating intelligence.**
|
||||
|
||||
An *engram* is the physical trace of a memory in the brain — the actual encoded substrate, not an abstraction above it. That's what this is.
|
||||
|
||||
---
|
||||
|
||||
## Why existing databases are wrong for this use case
|
||||
|
||||
Relational databases store rows and retrieve them by predicate. Key-value stores retrieve by exact key. Vector databases retrieve by geometric proximity. All of them share the same fundamental model: **you store data in, you query it out**. Storage and retrieval are separate systems.
|
||||
|
||||
The brain doesn't work this way.
|
||||
|
||||
When you remember something, you don't query your hippocampus. You activate a memory trace and the pattern *propagates*. Long-term potentiation — the strengthening of synaptic connections through co-activation — is simultaneously the storage mechanism and the retrieval mechanism. The structure that holds the memory is the same structure that surfaces it.
|
||||
|
||||
No existing database models this. Engram does.
|
||||
|
||||
---
|
||||
|
||||
## The Spreading Activation Model
|
||||
|
||||
Engram retrieval works through **spreading activation**:
|
||||
|
||||
1. **Seeds** — you name one or more nodes you know are relevant (e.g. the current task, recent context, a concept you're reasoning about)
|
||||
2. **Query embedding** — you provide a semantic vector representing the direction of your current thought
|
||||
3. **Propagation** — activation flows outward from seeds through weighted edges. At each hop, strength attenuates multiplicatively:
|
||||
|
||||
```
|
||||
strength = parent_strength × edge_weight × target_salience × cosine_sim(query, target)
|
||||
```
|
||||
|
||||
4. **Pruning** — paths weaker than a threshold are cut (the attention filter)
|
||||
5. **Return** — the top-N nodes by activation strength
|
||||
|
||||
This is not a query. It is a *pattern completion*. The system surfaces what is most associatively relevant to the current context, weighted by how strongly those things have been reinforced over time.
|
||||
|
||||
---
|
||||
|
||||
## The Four Memory Tiers
|
||||
|
||||
| Tier | Analogy | Contents |
|
||||
|------|---------|----------|
|
||||
| `Working` | Prefrontal working memory | K most recently activated nodes — hot, fast |
|
||||
| `Episodic` | Hippocampus | Time-ordered events and experiences |
|
||||
| `Semantic` | Neocortex | Concept graph — long-term structural knowledge |
|
||||
| `Procedural` | Cerebellum / basal ganglia | Patterns, workflows, habits |
|
||||
|
||||
Nodes migrate between tiers based on salience decay and reinforcement. A frequently activated semantic node stays semantic. A rarely-touched episodic memory decays toward procedural background.
|
||||
|
||||
---
|
||||
|
||||
## Salience — Forgetting as Adaptation
|
||||
|
||||
Salience is not stored permanently. It decays:
|
||||
|
||||
```rust
|
||||
fn compute_salience(importance: f32, last_activated_ms: i64, activation_count: u64) -> f32 {
|
||||
let days_since = (now_ms() - last_activated_ms) as f32 / 86_400_000.0;
|
||||
importance * (1.0 / (1.0 + days_since)) * (activation_count as f32 + 1.0).ln()
|
||||
}
|
||||
```
|
||||
|
||||
Three signals:
|
||||
- **Importance** (0.0–1.0): set at creation, stable
|
||||
- **Recency**: decays toward zero as days pass without activation
|
||||
- **Frequency**: log-compressed count of activations
|
||||
|
||||
Forgetting in Engram is not a bug. It is adaptive pruning. Memories that are never activated again become less likely to surface during retrieval. They are not deleted — they remain in storage — but they stop competing for attention. This is exactly how biological memory works, and why it is adaptive rather than pathological.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```rust
|
||||
use engram_core::{EngramDb, Node, Edge, NodeType, MemoryTier, RelationType};
|
||||
use std::path::Path;
|
||||
|
||||
// Open or create a database
|
||||
let db = EngramDb::open(Path::new("/var/lib/my-agent/memory"))?;
|
||||
|
||||
// Create a node with a semantic embedding
|
||||
let node = Node::new(
|
||||
NodeType::Concept,
|
||||
vec![0.9, 0.1, 0.3, 0.7, 0.8, 0.2], // embedding from your LLM
|
||||
b"Spreading activation surfaces relevant memories by pattern completion".to_vec(),
|
||||
MemoryTier::Semantic,
|
||||
0.9, // importance
|
||||
);
|
||||
let id = db.put_node(node)?;
|
||||
|
||||
// Link it to related concepts
|
||||
let related = db.put_node(Node::new(
|
||||
NodeType::Concept,
|
||||
vec![0.8, 0.2, 0.4, 0.6, 0.7, 0.3],
|
||||
b"Long-term potentiation: co-activation strengthens synaptic weight".to_vec(),
|
||||
MemoryTier::Semantic,
|
||||
0.85,
|
||||
))?;
|
||||
db.put_edge(Edge::new(id, related, RelationType::Causes, 0.9))?;
|
||||
|
||||
// Retrieve by spreading activation
|
||||
let results = db.activate(
|
||||
&[id], // seeds
|
||||
&[0.85, 0.15, 0.35, 0.65, 0.75, 0.25], // query embedding
|
||||
3, // max hops
|
||||
10, // top-N results
|
||||
)?;
|
||||
|
||||
for r in results {
|
||||
println!(
|
||||
"strength={:.4} hops={} — {}",
|
||||
r.activation_strength,
|
||||
r.hops,
|
||||
String::from_utf8_lossy(&r.node.content)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
engram/
|
||||
crates/
|
||||
engram-core/ # The memory engine — storage, graph, activation, salience
|
||||
engram-ffi/ # C FFI stubs for cross-language bindings
|
||||
bindings/
|
||||
kotlin/ # Android / JVM binding notes
|
||||
typescript/ # WASM / Node binding notes
|
||||
go/ # CGo binding notes
|
||||
examples/
|
||||
basic.rs # Full walkthrough: insert, activate, search, decay
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Public API
|
||||
|
||||
```rust
|
||||
impl EngramDb {
|
||||
fn open(path: &Path) -> EngramResult<Self>;
|
||||
fn put_node(&self, node: Node) -> EngramResult<Uuid>;
|
||||
fn get_node(&self, id: Uuid) -> EngramResult<Option<Node>>;
|
||||
fn put_edge(&self, edge: Edge) -> EngramResult<()>;
|
||||
fn get_edges_from(&self, from_id: Uuid) -> EngramResult<Vec<Edge>>;
|
||||
fn get_edges_to(&self, to_id: Uuid) -> EngramResult<Vec<Edge>>;
|
||||
fn search_embedding(&self, embedding: &[f32], limit: usize) -> EngramResult<Vec<ScoredNode>>;
|
||||
fn activate(&self, seeds: &[Uuid], query_embedding: &[f32], max_depth: u8, limit: usize) -> EngramResult<Vec<ActivatedNode>>;
|
||||
fn traverse(&self, from: Uuid, relation: Option<RelationType>, max_depth: u8) -> EngramResult<Vec<Node>>;
|
||||
fn touch(&self, id: Uuid) -> EngramResult<()>;
|
||||
fn decay(&self, factor: f32) -> EngramResult<usize>;
|
||||
fn node_count(&self) -> EngramResult<usize>;
|
||||
fn edge_count(&self) -> EngramResult<usize>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `sled` — embedded persistent B-tree (no daemon, no network, local-first)
|
||||
- `bincode` — compact binary serialization
|
||||
- `uuid` — stable node identity
|
||||
- `serde` — derive support
|
||||
- `thiserror` / `anyhow` — error handling
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions
|
||||
|
||||
**Why sled?** Local-first. No daemon. Transactional. Fast enough for the node counts Engram targets (< 1M nodes). When the right HNSW index is needed, it will layer on top of sled, not replace it.
|
||||
|
||||
**Why flat cosine scan?** Correct and simple. The graph structure itself is the primary retrieval mechanism. Vector search is a secondary signal. HNSW adds complexity and a compile dependency that isn't justified until retrieval quality at scale demands it.
|
||||
|
||||
**Why multiplicative activation?** Because memory is conjunctive. A path requires all of its links to be strong to carry signal. Addition would allow many weak associations to accumulate into false relevance. Multiplication enforces that every factor matters.
|
||||
|
||||
**Why salience decay?** Because not everything that was once important remains important. Adaptive forgetting is not failure — it is the mechanism that keeps attention on what's current. A memory system that never forgets is one that can never focus.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Go Bindings (planned v0.2)
|
||||
|
||||
Engram-core will be exposed to Go via CGo and the `engram-ffi` shared library.
|
||||
|
||||
```go
|
||||
// #cgo LDFLAGS: -L../../target/release -lengram_ffi
|
||||
// #include "engram.h"
|
||||
import "C"
|
||||
import "unsafe"
|
||||
|
||||
func Open(path string) *EngramDb {
|
||||
cpath := C.CString(path)
|
||||
defer C.free(unsafe.Pointer(cpath))
|
||||
handle := C.engram_open(cpath)
|
||||
if handle == nil {
|
||||
return nil
|
||||
}
|
||||
return &EngramDb{handle: handle}
|
||||
}
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
Stub only. The header file (`engram.h`) will be generated by `cbindgen` in v0.2.
|
||||
Full idiomatic Go wrapper with context support and error returns planned.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Kotlin / JVM Bindings (planned v0.2)
|
||||
|
||||
Engram-core will be exposed to Kotlin/JVM via the C FFI layer in `engram-ffi`.
|
||||
|
||||
## Approach
|
||||
|
||||
Use JNA (Java Native Access) or JNI with the compiled `libengram_ffi` shared library:
|
||||
|
||||
```
|
||||
cargo build --release -p engram-ffi
|
||||
# produces: target/release/libengram_ffi.dylib (macOS) / libengram_ffi.so (Linux)
|
||||
```
|
||||
|
||||
The header file will be generated by `cbindgen` from `engram-ffi/src/lib.rs`.
|
||||
|
||||
## Status
|
||||
|
||||
Stub only. FFI functions exposed: `engram_open`, `engram_close`, `engram_node_count`,
|
||||
`engram_edge_count`, `engram_decay`, `engram_free_string`.
|
||||
|
||||
Full Kotlin idiomatic wrapper (data classes, coroutine-friendly suspend functions) planned for v0.2.
|
||||
@@ -0,0 +1,21 @@
|
||||
# TypeScript Bindings (planned v0.2)
|
||||
|
||||
Two paths for TypeScript/Node.js:
|
||||
|
||||
## Option A — WASM (browser + Node)
|
||||
|
||||
```
|
||||
cargo build --target wasm32-unknown-unknown --release -p engram-core
|
||||
wasm-bindgen target/wasm32-unknown-unknown/release/engram_core.wasm --out-dir pkg/
|
||||
```
|
||||
|
||||
Requires `wasm-bindgen` annotations on public API. Browser-compatible, no native deps.
|
||||
|
||||
## Option B — Node native addon (server-side)
|
||||
|
||||
Use `napi-rs` to generate a Node.js native addon from `engram-core`. Faster than WASM for
|
||||
server-side agents, but requires a native build step per platform.
|
||||
|
||||
## Status
|
||||
|
||||
Stub only. WASM target is the preferred path for v0.2 given the local-first, embedded philosophy.
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "engram-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Engram — native memory substrate for accumulating intelligence"
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
sled = "0.34"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
bincode = "1"
|
||||
anyhow = "1"
|
||||
thiserror = "1"
|
||||
@@ -0,0 +1,240 @@
|
||||
/// Spreading Activation — the core retrieval mechanism of Engram.
|
||||
///
|
||||
/// # The Central Insight
|
||||
///
|
||||
/// Conventional databases separate storage from retrieval. You put data in,
|
||||
/// you query it out. The storage structure (B-tree, LSM, etc.) and the retrieval
|
||||
/// mechanism (SQL planner, index scan) are fundamentally different things.
|
||||
///
|
||||
/// The brain doesn't work this way. Memory is not stored and retrieved —
|
||||
/// it is **activated and propagated**. When you remember something, you don't
|
||||
/// "query" your hippocampus. You activate a node and the pattern spreads through
|
||||
/// weighted connections to neighboring nodes. Long-term potentiation IS the storage
|
||||
/// structure AND the retrieval mechanism simultaneously.
|
||||
///
|
||||
/// This module implements that model directly.
|
||||
///
|
||||
/// # How It Works
|
||||
///
|
||||
/// 1. **Seeds**: Start with one or more known node UUIDs (e.g., the most recent
|
||||
/// context, the current task, recent observations).
|
||||
///
|
||||
/// 2. **Query embedding**: The semantic vector representing what you're looking
|
||||
/// for. This is the "direction of thought" — activation flows more strongly
|
||||
/// toward nodes that are semantically similar to the current context.
|
||||
///
|
||||
/// 3. **BFS propagation**: Activation spreads outward from seeds through edges.
|
||||
/// At each hop, the strength attenuates based on:
|
||||
/// - `edge.weight`: how strongly these two nodes are associated
|
||||
/// - `target.salience`: how salient (recently activated, frequent, important) the target is
|
||||
/// - `cosine_sim(query, target)`: how semantically relevant the target is to what we want
|
||||
///
|
||||
/// 4. **Pruning**: Paths with activation strength below `PRUNE_THRESHOLD` are cut.
|
||||
/// This prevents exponential blowup and models the brain's attention filter.
|
||||
///
|
||||
/// 5. **Return**: The top-N nodes by activation strength, with their hop distance.
|
||||
///
|
||||
/// # Activation Formula (per hop)
|
||||
///
|
||||
/// strength = parent_strength × edge_weight × target_salience × cosine_sim(query, target)
|
||||
///
|
||||
/// This is multiplicative: a weak edge, a dormant node, or a semantically irrelevant
|
||||
/// target all suppress activation. All four factors must be non-trivial for a path
|
||||
/// to propagate successfully. This is exactly how associative memory works.
|
||||
///
|
||||
/// # Why Multiplication, Not Addition
|
||||
///
|
||||
/// Addition would allow many weak signals to accumulate into false relevance.
|
||||
/// The brain's associative memory is conjunctive: an activated path requires
|
||||
/// ALL of its links to be strong enough to carry the signal. Multiplication
|
||||
/// enforces this. If any factor is near zero, the path dies.
|
||||
use crate::error::EngramResult;
|
||||
use crate::graph;
|
||||
use crate::types::{ActivatedNode, Node};
|
||||
use crate::vector::cosine_similarity;
|
||||
use sled::Db;
|
||||
use std::collections::{BinaryHeap, HashMap};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Activation strengths below this threshold are pruned from the BFS frontier.
|
||||
/// 0.01 is deliberately small — we want to allow long indirect chains when
|
||||
/// the intermediate edges are strong. Raise this to focus retrieval, lower to
|
||||
/// allow more associative drift.
|
||||
const PRUNE_THRESHOLD: f32 = 0.01;
|
||||
|
||||
// We need Ord on (f32, Uuid) for the priority queue. Use a wrapper.
|
||||
#[derive(PartialEq)]
|
||||
struct Candidate {
|
||||
strength: f32,
|
||||
hops: u8,
|
||||
id: Uuid,
|
||||
}
|
||||
|
||||
impl Eq for Candidate {}
|
||||
|
||||
impl PartialOrd for Candidate {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for Candidate {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
// BinaryHeap is a max-heap; we want highest-strength first
|
||||
self.strength
|
||||
.partial_cmp(&other.strength)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
}
|
||||
}
|
||||
|
||||
/// Run spreading activation from a set of seed nodes.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `db` — the open engram database
|
||||
/// * `seeds` — starting node IDs (the current "active set")
|
||||
/// * `query_embedding` — semantic vector representing what we're looking for
|
||||
/// * `max_depth` — maximum number of hops to traverse (typically 2–4)
|
||||
/// * `limit` — return only the top-N results
|
||||
///
|
||||
/// # Returns
|
||||
/// Up to `limit` nodes, sorted by activation strength descending.
|
||||
/// Seed nodes themselves are excluded from the result (they're already known).
|
||||
pub fn activate(
|
||||
db: &Db,
|
||||
seeds: &[Uuid],
|
||||
query_embedding: &[f32],
|
||||
max_depth: u8,
|
||||
limit: usize,
|
||||
) -> EngramResult<Vec<ActivatedNode>> {
|
||||
// best_strength[id] = highest activation strength seen so far for this node.
|
||||
// We use this to handle cases where multiple paths lead to the same node —
|
||||
// the strongest path wins (like the brain's winner-take-most competition).
|
||||
let mut best_strength: HashMap<Uuid, (f32, u8)> = HashMap::new();
|
||||
|
||||
// Priority queue: process highest-strength candidates first.
|
||||
// This is a best-first BFS — we explore the most promising paths before
|
||||
// weaker ones, which means pruning cuts off genuinely unimportant branches.
|
||||
let mut queue: BinaryHeap<Candidate> = BinaryHeap::new();
|
||||
|
||||
// Initialize: seed nodes start with full strength (1.0).
|
||||
// They represent our current context — fully activated, zero hops away.
|
||||
for &seed in seeds {
|
||||
// Seeds are tracked with strength 1.0 but NOT added to best_strength yet;
|
||||
// we want to allow other paths to reach them if they form a cycle.
|
||||
// However, we must visit their neighbors. We add seeds directly.
|
||||
queue.push(Candidate {
|
||||
strength: 1.0,
|
||||
hops: 0,
|
||||
id: seed,
|
||||
});
|
||||
// Mark seeds so we don't re-process them as results, but allow
|
||||
// re-traversal from them if another path arrives stronger.
|
||||
best_strength.insert(seed, (1.0, 0));
|
||||
}
|
||||
|
||||
// BFS / best-first traversal
|
||||
while let Some(Candidate { strength, hops, id }) = queue.pop() {
|
||||
// Depth limit: don't propagate beyond max_depth
|
||||
if hops >= max_depth {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Retrieve outgoing edges from the current node
|
||||
let edges = graph::edges_from(db, id)?;
|
||||
|
||||
for edge in &edges {
|
||||
let target_id = edge.to_id;
|
||||
|
||||
// Load the target node. If it doesn't exist (dangling edge), skip.
|
||||
let target: Node = match graph::get_node(db, target_id)? {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// ── Activation strength computation ──────────────────────────
|
||||
//
|
||||
// Each factor models a distinct aspect of associative memory:
|
||||
//
|
||||
// 1. parent_strength: how strongly was the parent activated?
|
||||
// Activation attenuates with each hop — deep chains carry less signal.
|
||||
//
|
||||
// 2. edge.weight: how strong is the association between these nodes?
|
||||
// High-weight edges are like well-worn neural pathways — low resistance.
|
||||
// Low-weight edges are new or rarely traversed — they carry little signal.
|
||||
//
|
||||
// 3. target.salience: how salient is the target node right now?
|
||||
// Dormant nodes (low salience) resist activation.
|
||||
// Frequently-used, recently-touched nodes activate easily.
|
||||
// This is how recency and frequency bias retrieval, as in human memory.
|
||||
//
|
||||
// 4. cosine_sim(query, target): semantic relevance.
|
||||
// If the target's embedding is far from what we're looking for,
|
||||
// the activation doesn't flow there. This is the "direction of thought"
|
||||
// filtering — the query steers the spread toward relevant regions.
|
||||
//
|
||||
// The product of all four is the activation strength at the target.
|
||||
// All factors are in [0, 1] so the product is also in [0, 1].
|
||||
// (Salience can exceed 1 for very active nodes, which is fine —
|
||||
// it means those nodes are hyper-salient, like obsessive thoughts.)
|
||||
|
||||
let semantic_sim = cosine_similarity(query_embedding, &target.embedding);
|
||||
// We clamp semantic_sim to [0, 1] so that anti-correlated embeddings
|
||||
// don't produce negative activation (which would invert the signal).
|
||||
let semantic_sim = semantic_sim.max(0.0);
|
||||
|
||||
let new_strength = strength * edge.weight * target.salience.max(0.0) * semantic_sim;
|
||||
|
||||
// Prune: if this path is too weak to matter, stop here.
|
||||
// This is the attention filter — irrelevant associations fade away.
|
||||
if new_strength < PRUNE_THRESHOLD {
|
||||
continue;
|
||||
}
|
||||
|
||||
let next_hops = hops + 1;
|
||||
|
||||
// Winner-take-most: only propagate from this node if this is the
|
||||
// strongest path we've seen to it so far. This prevents exponential
|
||||
// blowup when the graph has many parallel paths to the same node.
|
||||
let is_stronger = match best_strength.get(&target_id) {
|
||||
Some(&(prev, _)) => new_strength > prev,
|
||||
None => true,
|
||||
};
|
||||
|
||||
if is_stronger {
|
||||
best_strength.insert(target_id, (new_strength, next_hops));
|
||||
queue.push(Candidate {
|
||||
strength: new_strength,
|
||||
hops: next_hops,
|
||||
id: target_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect results: exclude seed nodes, load full Node structs, sort by strength
|
||||
let seed_set: std::collections::HashSet<Uuid> = seeds.iter().copied().collect();
|
||||
|
||||
let mut results: Vec<ActivatedNode> = Vec::new();
|
||||
for (id, (strength, hops)) in &best_strength {
|
||||
if seed_set.contains(id) {
|
||||
continue;
|
||||
}
|
||||
if let Some(node) = graph::get_node(db, *id)? {
|
||||
results.push(ActivatedNode {
|
||||
node,
|
||||
activation_strength: *strength,
|
||||
hops: *hops,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by activation strength descending, take top N
|
||||
results.sort_by(|a, b| {
|
||||
b.activation_strength
|
||||
.partial_cmp(&a.activation_strength)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
results.truncate(limit);
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/// EngramDb — the top-level database handle.
|
||||
///
|
||||
/// All public API methods live here. The internal modules (graph, vector,
|
||||
/// activation, salience) are implementation details. Callers interact only
|
||||
/// with EngramDb.
|
||||
use crate::activation;
|
||||
use crate::error::{EngramError, EngramResult};
|
||||
use crate::graph;
|
||||
use crate::salience;
|
||||
use crate::storage;
|
||||
use crate::types::{ActivatedNode, Edge, Node, RelationType, ScoredNode};
|
||||
use crate::vector;
|
||||
use sled::Db;
|
||||
use std::path::Path;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct EngramDb {
|
||||
db: Db,
|
||||
}
|
||||
|
||||
impl EngramDb {
|
||||
/// Open (or create) an engram database at the given path.
|
||||
///
|
||||
/// The path should be a directory. Sled will create it if it doesn't exist.
|
||||
pub fn open(path: &Path) -> EngramResult<Self> {
|
||||
let db = sled::open(path)?;
|
||||
Ok(Self { db })
|
||||
}
|
||||
|
||||
// ── Node operations ───────────────────────────────────────────────────────
|
||||
|
||||
/// Persist a node. Returns the node's UUID.
|
||||
///
|
||||
/// If a node with the same ID already exists, it is overwritten.
|
||||
pub fn put_node(&self, node: Node) -> EngramResult<Uuid> {
|
||||
graph::put_node(&self.db, &node)
|
||||
}
|
||||
|
||||
/// Retrieve a node by UUID. Returns None if not found.
|
||||
pub fn get_node(&self, id: Uuid) -> EngramResult<Option<Node>> {
|
||||
graph::get_node(&self.db, id)
|
||||
}
|
||||
|
||||
// ── Edge operations ───────────────────────────────────────────────────────
|
||||
|
||||
/// Persist a directed edge between two nodes.
|
||||
pub fn put_edge(&self, edge: Edge) -> EngramResult<()> {
|
||||
graph::put_edge(&self.db, &edge)
|
||||
}
|
||||
|
||||
/// All edges originating from a node.
|
||||
pub fn get_edges_from(&self, from_id: Uuid) -> EngramResult<Vec<Edge>> {
|
||||
graph::edges_from(&self.db, from_id)
|
||||
}
|
||||
|
||||
/// All edges pointing to a node.
|
||||
pub fn get_edges_to(&self, to_id: Uuid) -> EngramResult<Vec<Edge>> {
|
||||
graph::edges_to(&self.db, to_id)
|
||||
}
|
||||
|
||||
// ── Vector search ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Find the `limit` nodes whose embeddings are most similar to `embedding`.
|
||||
///
|
||||
/// Uses flat cosine scan — O(n), correct for < 100k nodes.
|
||||
pub fn search_embedding(&self, embedding: &[f32], limit: usize) -> EngramResult<Vec<ScoredNode>> {
|
||||
vector::search_embedding(&self.db, embedding, limit, |id| {
|
||||
graph::get_node(&self.db, id)
|
||||
})
|
||||
}
|
||||
|
||||
// ── Spreading activation ──────────────────────────────────────────────────
|
||||
|
||||
/// Run spreading activation from a set of seed nodes.
|
||||
///
|
||||
/// Activation propagates outward through the graph. At each hop, strength
|
||||
/// is attenuated by edge weight, target salience, and semantic similarity
|
||||
/// to `query_embedding`. The top-`limit` nodes by activation strength are returned.
|
||||
///
|
||||
/// See `activation.rs` for a full description of the algorithm.
|
||||
pub fn activate(
|
||||
&self,
|
||||
seeds: &[Uuid],
|
||||
query_embedding: &[f32],
|
||||
max_depth: u8,
|
||||
limit: usize,
|
||||
) -> EngramResult<Vec<ActivatedNode>> {
|
||||
activation::activate(&self.db, seeds, query_embedding, max_depth, limit)
|
||||
}
|
||||
|
||||
// ── Graph traversal ───────────────────────────────────────────────────────
|
||||
|
||||
/// BFS traversal from `from`, following edges up to `max_depth` hops.
|
||||
///
|
||||
/// If `relation` is specified, only edges of that type are followed.
|
||||
/// The seed node itself is excluded from the result.
|
||||
pub fn traverse(
|
||||
&self,
|
||||
from: Uuid,
|
||||
relation: Option<RelationType>,
|
||||
max_depth: u8,
|
||||
) -> EngramResult<Vec<Node>> {
|
||||
graph::traverse(&self.db, from, relation, max_depth)
|
||||
}
|
||||
|
||||
// ── Salience management ───────────────────────────────────────────────────
|
||||
|
||||
/// Mark a node as recently activated — update last_activated, increment
|
||||
/// activation_count, and recompute salience.
|
||||
///
|
||||
/// Call this whenever a node is surfaced during retrieval so that
|
||||
/// frequently-used nodes accumulate higher salience over time.
|
||||
pub fn touch(&self, id: Uuid) -> EngramResult<()> {
|
||||
let mut node = graph::get_node(&self.db, id)?.ok_or(EngramError::NotFound(id))?;
|
||||
node.last_activated = crate::types::now_ms();
|
||||
node.activation_count += 1;
|
||||
node.salience = salience::compute_salience(
|
||||
node.importance,
|
||||
node.last_activated,
|
||||
node.activation_count,
|
||||
);
|
||||
graph::put_node(&self.db, &node)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply a multiplicative decay to the salience of every node in the store.
|
||||
///
|
||||
/// `factor` should be in (0.0, 1.0). A factor of 0.95 decays salience by 5%.
|
||||
/// Returns the number of nodes updated.
|
||||
///
|
||||
/// This models the adaptive nature of forgetting: nodes that haven't been
|
||||
/// activated recently become less salient over time, making room for new
|
||||
/// associations.
|
||||
pub fn decay(&self, factor: f32) -> EngramResult<usize> {
|
||||
if !(0.0..=1.0).contains(&factor) {
|
||||
return Err(EngramError::InvalidParam(format!(
|
||||
"decay factor must be in [0.0, 1.0], got {}",
|
||||
factor
|
||||
)));
|
||||
}
|
||||
|
||||
let nodes = storage::scan_nodes(&self.db)?;
|
||||
let mut count = 0usize;
|
||||
for mut node in nodes {
|
||||
let new_salience = salience::decay_salience(node.salience, factor);
|
||||
// Always write if salience changed at all (decay always changes it
|
||||
// unless the node is already at zero)
|
||||
if new_salience != node.salience {
|
||||
node.salience = new_salience;
|
||||
storage::write_salience(&self.db, node.id, new_salience)?;
|
||||
// Also update the full node record so future reads are consistent
|
||||
graph::put_node(&self.db, &node)?;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
// ── Statistics ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Total number of nodes stored.
|
||||
pub fn node_count(&self) -> EngramResult<usize> {
|
||||
graph::node_count(&self.db)
|
||||
}
|
||||
|
||||
/// Total number of edges stored (each directed edge counted once).
|
||||
pub fn edge_count(&self) -> EngramResult<usize> {
|
||||
graph::edge_count(&self.db)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum EngramError {
|
||||
#[error("Storage error: {0}")]
|
||||
Storage(#[from] sled::Error),
|
||||
|
||||
#[error("Serialization error: {0}")]
|
||||
Serialization(#[from] bincode::Error),
|
||||
|
||||
#[error("Node not found: {0}")]
|
||||
NotFound(uuid::Uuid),
|
||||
|
||||
#[error("Invalid embedding: expected {expected} dimensions, got {got}")]
|
||||
DimensionMismatch { expected: usize, got: usize },
|
||||
|
||||
#[error("Invalid parameter: {0}")]
|
||||
InvalidParam(String),
|
||||
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
pub type EngramResult<T> = Result<T, EngramError>;
|
||||
@@ -0,0 +1,90 @@
|
||||
/// Graph operations: store and retrieve nodes and edges, and depth-limited traversal.
|
||||
///
|
||||
/// The graph is stored in sled (a persistent embedded B-tree). Edges are indexed
|
||||
/// in both directions so that forward and backward traversals are equally cheap.
|
||||
use crate::error::EngramResult;
|
||||
use crate::storage;
|
||||
use crate::types::{Edge, Node, RelationType};
|
||||
use sled::Db;
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Persist a node and its embedding. Overwrites any existing node with the same id.
|
||||
pub fn put_node(db: &Db, node: &Node) -> EngramResult<Uuid> {
|
||||
storage::write_node(db, node)?;
|
||||
Ok(node.id)
|
||||
}
|
||||
|
||||
/// Retrieve a node by id. Returns None if not found.
|
||||
pub fn get_node(db: &Db, id: Uuid) -> EngramResult<Option<Node>> {
|
||||
storage::read_node(db, id)
|
||||
}
|
||||
|
||||
/// Persist an edge. Both forward and reverse indices are written atomically.
|
||||
pub fn put_edge(db: &Db, edge: &Edge) -> EngramResult<()> {
|
||||
storage::write_edge(db, edge)
|
||||
}
|
||||
|
||||
/// All edges originating from a given node.
|
||||
pub fn edges_from(db: &Db, from_id: Uuid) -> EngramResult<Vec<Edge>> {
|
||||
storage::read_edges_from(db, from_id)
|
||||
}
|
||||
|
||||
/// All edges pointing to a given node.
|
||||
pub fn edges_to(db: &Db, to_id: Uuid) -> EngramResult<Vec<Edge>> {
|
||||
storage::read_edges_to(db, to_id)
|
||||
}
|
||||
|
||||
/// Breadth-first traversal starting from `from`, following forward edges only.
|
||||
///
|
||||
/// If `relation` is Some, only edges of that type are followed.
|
||||
/// The BFS respects `max_depth` hops. The seed node itself is NOT included.
|
||||
/// Visited nodes are deduplicated.
|
||||
pub fn traverse(
|
||||
db: &Db,
|
||||
from: Uuid,
|
||||
relation: Option<RelationType>,
|
||||
max_depth: u8,
|
||||
) -> EngramResult<Vec<Node>> {
|
||||
let mut visited: HashSet<Uuid> = HashSet::new();
|
||||
let mut queue: VecDeque<(Uuid, u8)> = VecDeque::new();
|
||||
let mut result: Vec<Node> = Vec::new();
|
||||
|
||||
visited.insert(from);
|
||||
queue.push_back((from, 0));
|
||||
|
||||
while let Some((current_id, depth)) = queue.pop_front() {
|
||||
if depth >= max_depth {
|
||||
continue;
|
||||
}
|
||||
let edges = edges_from(db, current_id)?;
|
||||
for edge in edges {
|
||||
// Filter by relation type if specified
|
||||
if let Some(ref rel) = relation {
|
||||
if &edge.relation != rel {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let next = edge.to_id;
|
||||
if visited.contains(&next) {
|
||||
continue;
|
||||
}
|
||||
visited.insert(next);
|
||||
if let Some(node) = get_node(db, next)? {
|
||||
result.push(node);
|
||||
queue.push_back((next, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Count all nodes in the store.
|
||||
pub fn node_count(db: &Db) -> EngramResult<usize> {
|
||||
storage::count_prefix(db, b"nodes:")
|
||||
}
|
||||
|
||||
/// Count all edges in the store (forward index only — each edge counted once).
|
||||
pub fn edge_count(db: &Db) -> EngramResult<usize> {
|
||||
storage::count_prefix(db, b"edges:from:")
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/// Engram — a local-first memory substrate for accumulating intelligence.
|
||||
///
|
||||
/// An engram is the physical trace of a memory in the brain — the actual encoded
|
||||
/// substrate. This crate provides the storage and retrieval primitives that model
|
||||
/// how biological memory works: not as query-and-retrieve, but as
|
||||
/// activation-and-propagation.
|
||||
///
|
||||
/// # Quick Start
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// use engram_core::{EngramDb, Node, Edge, NodeType, MemoryTier, RelationType};
|
||||
/// use std::path::Path;
|
||||
///
|
||||
/// let db = EngramDb::open(Path::new("/tmp/my-engram")).unwrap();
|
||||
///
|
||||
/// let node = Node::new(
|
||||
/// NodeType::Memory,
|
||||
/// vec![0.9, 0.1, 0.3, 0.7],
|
||||
/// b"The spreading activation model of memory".to_vec(),
|
||||
/// MemoryTier::Semantic,
|
||||
/// 0.9,
|
||||
/// );
|
||||
/// let id = db.put_node(node).unwrap();
|
||||
///
|
||||
/// // Retrieve by spreading activation from a seed
|
||||
/// let results = db.activate(&[id], &[0.8, 0.2, 0.3, 0.6], 3, 10).unwrap();
|
||||
/// for r in results {
|
||||
/// println!("{:.4} hops={} {:?}", r.activation_strength, r.hops,
|
||||
/// String::from_utf8_lossy(&r.node.content));
|
||||
/// }
|
||||
/// ```
|
||||
pub mod activation;
|
||||
pub mod db;
|
||||
pub mod error;
|
||||
pub mod graph;
|
||||
pub mod salience;
|
||||
pub mod storage;
|
||||
pub mod types;
|
||||
pub mod vector;
|
||||
|
||||
// Re-export the public surface
|
||||
pub use db::EngramDb;
|
||||
pub use error::{EngramError, EngramResult};
|
||||
pub use types::{
|
||||
ActivatedNode, Edge, MemoryTier, Node, NodeType, RelationType, ScoredNode, now_ms,
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
/// Salience is the brain's answer to the question: "Is this worth remembering right now?"
|
||||
///
|
||||
/// It combines three signals:
|
||||
/// - **Importance**: explicit weight assigned at creation (how significant is this?)
|
||||
/// - **Recency**: exponential decay since last activation (recent = more relevant)
|
||||
/// - **Frequency**: log-compressed activation count (things recalled often stay accessible)
|
||||
///
|
||||
/// The formula is intentionally simple. It models forgetting as *adaptive*, not as failure.
|
||||
/// Things that aren't activated decay toward zero — not because they are lost, but because
|
||||
/// they are no longer relevant to current cognition. This is how biological memory works.
|
||||
///
|
||||
/// ```
|
||||
/// salience = importance × (1 / (1 + days_since_activation)) × ln(activation_count + 1)
|
||||
/// ```
|
||||
///
|
||||
/// Note: a node activated for the first time has activation_count=0, so the log term
|
||||
/// evaluates to ln(1) = 0. We add 1 to the ln argument to give first activations a
|
||||
/// baseline salience equal to importance × recency.
|
||||
use crate::types::now_ms;
|
||||
|
||||
/// Compute the current salience of a node.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `importance` - explicit importance score, 0.0–1.0
|
||||
/// * `last_activated_ms` - Unix milliseconds of last activation
|
||||
/// * `activation_count` - how many times the node has been activated
|
||||
///
|
||||
/// # Returns
|
||||
/// Salience score, unbounded above but typically 0.0–5.0 for well-used nodes.
|
||||
pub fn compute_salience(importance: f32, last_activated_ms: i64, activation_count: u64) -> f32 {
|
||||
let days_since = (now_ms() - last_activated_ms) as f32 / 86_400_000.0;
|
||||
// Recency factor: 1.0 at activation, approaching 0 asymptotically.
|
||||
// At 1 day: 0.5. At 6 days: ~0.14. At 30 days: ~0.03.
|
||||
let recency = 1.0 / (1.0 + days_since);
|
||||
// Frequency factor: log-compressed so that going from 0→1 activations matters
|
||||
// more than going from 100→101. This mirrors the diminishing returns of rehearsal.
|
||||
let frequency = (activation_count as f32 + 1.0).ln();
|
||||
importance * recency * frequency
|
||||
}
|
||||
|
||||
/// Apply a multiplicative decay to a salience score.
|
||||
///
|
||||
/// Called periodically to age stored salience values without recomputing from scratch.
|
||||
/// A factor of 0.95 means 5% forgetting per decay cycle.
|
||||
pub fn decay_salience(current: f32, factor: f32) -> f32 {
|
||||
(current * factor).max(0.0)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/// Low-level sled key/value operations for nodes, edges, vectors, and salience.
|
||||
///
|
||||
/// Key schema:
|
||||
/// nodes:{uuid} → bincode-encoded Node
|
||||
/// edges:from:{from}:{to} → bincode-encoded Edge
|
||||
/// edges:to:{to}:{from} → reverse index (same Edge bytes)
|
||||
/// vectors:{uuid} → raw little-endian f32 bytes
|
||||
/// salience:{uuid} → 4-byte little-endian f32
|
||||
use crate::error::{EngramError, EngramResult};
|
||||
use crate::types::{Edge, Node};
|
||||
use sled::Db;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ── Key constructors ──────────────────────────────────────────────────────────
|
||||
|
||||
pub fn node_key(id: Uuid) -> Vec<u8> {
|
||||
format!("nodes:{}", id).into_bytes()
|
||||
}
|
||||
|
||||
pub fn edge_from_key(from: Uuid, to: Uuid) -> Vec<u8> {
|
||||
format!("edges:from:{}:{}", from, to).into_bytes()
|
||||
}
|
||||
|
||||
pub fn edge_to_key(to: Uuid, from: Uuid) -> Vec<u8> {
|
||||
format!("edges:to:{}:{}", to, from).into_bytes()
|
||||
}
|
||||
|
||||
pub fn vector_key(id: Uuid) -> Vec<u8> {
|
||||
format!("vectors:{}", id).into_bytes()
|
||||
}
|
||||
|
||||
pub fn salience_key(id: Uuid) -> Vec<u8> {
|
||||
format!("salience:{}", id).into_bytes()
|
||||
}
|
||||
|
||||
// ── Node storage ─────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn write_node(db: &Db, node: &Node) -> EngramResult<()> {
|
||||
let key = node_key(node.id);
|
||||
let val = bincode::serialize(node)?;
|
||||
db.insert(key, val)?;
|
||||
|
||||
// Store the embedding separately for fast vector scan
|
||||
let vkey = vector_key(node.id);
|
||||
let vbytes = floats_to_bytes(&node.embedding);
|
||||
db.insert(vkey, vbytes)?;
|
||||
|
||||
// Store salience separately so the decay pass can update it cheaply
|
||||
let skey = salience_key(node.id);
|
||||
db.insert(skey, f32_to_bytes(node.salience))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read_node(db: &Db, id: Uuid) -> EngramResult<Option<Node>> {
|
||||
match db.get(node_key(id))? {
|
||||
Some(bytes) => Ok(Some(bincode::deserialize(&bytes)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Iterate over every node in the store.
|
||||
pub fn scan_nodes(db: &Db) -> EngramResult<Vec<Node>> {
|
||||
let prefix = b"nodes:";
|
||||
let mut nodes = Vec::new();
|
||||
for result in db.scan_prefix(prefix) {
|
||||
let (_k, v) = result?;
|
||||
let node: Node = bincode::deserialize(&v)?;
|
||||
nodes.push(node);
|
||||
}
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
// ── Edge storage ─────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn write_edge(db: &Db, edge: &Edge) -> EngramResult<()> {
|
||||
let bytes = bincode::serialize(edge)?;
|
||||
// Forward index: from → to
|
||||
db.insert(edge_from_key(edge.from_id, edge.to_id), bytes.clone())?;
|
||||
// Reverse index: to → from
|
||||
db.insert(edge_to_key(edge.to_id, edge.from_id), bytes)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read_edges_from(db: &Db, from_id: Uuid) -> EngramResult<Vec<Edge>> {
|
||||
let prefix = format!("edges:from:{}:", from_id).into_bytes();
|
||||
read_edges_with_prefix(db, &prefix)
|
||||
}
|
||||
|
||||
pub fn read_edges_to(db: &Db, to_id: Uuid) -> EngramResult<Vec<Edge>> {
|
||||
let prefix = format!("edges:to:{}:", to_id).into_bytes();
|
||||
read_edges_with_prefix(db, &prefix)
|
||||
}
|
||||
|
||||
fn read_edges_with_prefix(db: &Db, prefix: &[u8]) -> EngramResult<Vec<Edge>> {
|
||||
let mut edges = Vec::new();
|
||||
for result in db.scan_prefix(prefix) {
|
||||
let (_k, v) = result?;
|
||||
let edge: Edge = bincode::deserialize(&v)?;
|
||||
edges.push(edge);
|
||||
}
|
||||
Ok(edges)
|
||||
}
|
||||
|
||||
// ── Vector scan ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Read all stored (uuid, embedding) pairs. Used for flat cosine search.
|
||||
pub fn scan_vectors(db: &Db) -> EngramResult<Vec<(Uuid, Vec<f32>)>> {
|
||||
let prefix = b"vectors:";
|
||||
let mut out = Vec::new();
|
||||
for result in db.scan_prefix(prefix) {
|
||||
let (k, v) = result?;
|
||||
// key = "vectors:{uuid}" — slice off the prefix
|
||||
let id_str = std::str::from_utf8(&k[prefix.len()..])
|
||||
.map_err(|e| EngramError::InvalidParam(e.to_string()))?;
|
||||
let id = id_str
|
||||
.parse::<Uuid>()
|
||||
.map_err(|e| EngramError::InvalidParam(e.to_string()))?;
|
||||
let floats = bytes_to_floats(&v);
|
||||
out.push((id, floats));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ── Salience update ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Overwrite the salience entry for a node without rewriting the full node blob.
|
||||
pub fn write_salience(db: &Db, id: Uuid, salience: f32) -> EngramResult<()> {
|
||||
db.insert(salience_key(id), f32_to_bytes(salience))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read_salience(db: &Db, id: Uuid) -> EngramResult<Option<f32>> {
|
||||
match db.get(salience_key(id))? {
|
||||
Some(b) => Ok(Some(bytes_to_f32(&b))),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Count entries matching a key prefix.
|
||||
pub fn count_prefix(db: &Db, prefix: &[u8]) -> EngramResult<usize> {
|
||||
let mut n = 0usize;
|
||||
for result in db.scan_prefix(prefix) {
|
||||
result?;
|
||||
n += 1;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
// ── Byte encoding helpers ─────────────────────────────────────────────────────
|
||||
|
||||
fn f32_to_bytes(v: f32) -> Vec<u8> {
|
||||
v.to_le_bytes().to_vec()
|
||||
}
|
||||
|
||||
fn bytes_to_f32(b: &[u8]) -> f32 {
|
||||
let arr: [u8; 4] = b[..4].try_into().unwrap_or([0u8; 4]);
|
||||
f32::from_le_bytes(arr)
|
||||
}
|
||||
|
||||
fn floats_to_bytes(floats: &[f32]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(floats.len() * 4);
|
||||
for f in floats {
|
||||
out.extend_from_slice(&f.to_le_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn bytes_to_floats(bytes: &[u8]) -> Vec<f32> {
|
||||
bytes
|
||||
.chunks_exact(4)
|
||||
.map(|c| f32::from_le_bytes(c.try_into().unwrap()))
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// The functional role of a node in the memory graph.
|
||||
///
|
||||
/// Different node types participate in different retrieval patterns:
|
||||
/// - Memories and Events are time-anchored
|
||||
/// - Concepts and Entities form the semantic backbone
|
||||
/// - Processes encode procedural knowledge
|
||||
/// - InternalState captures the system's own affective context
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum NodeType {
|
||||
/// A specific remembered experience or observation
|
||||
Memory,
|
||||
/// An abstract idea, category, or semantic anchor
|
||||
Concept,
|
||||
/// A time-stamped occurrence in the world or in processing
|
||||
Event,
|
||||
/// A named thing — person, place, object, system
|
||||
Entity,
|
||||
/// A procedural pattern, workflow, or sequence of steps
|
||||
Process,
|
||||
/// An internal affective or motivational state
|
||||
InternalState,
|
||||
}
|
||||
|
||||
/// Where in the memory hierarchy a node currently lives.
|
||||
///
|
||||
/// Tiers model the brain's own stratified memory architecture.
|
||||
/// Nodes migrate between tiers based on salience decay and reinforcement.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum MemoryTier {
|
||||
/// Hot working memory — the K most recently activated nodes.
|
||||
/// Ultra-fast access. Evicted by recency when K is exceeded.
|
||||
Working,
|
||||
/// Episodic memory — time-ordered events and experiences.
|
||||
/// Indexed chronologically; supports temporal traversal.
|
||||
Episodic,
|
||||
/// Semantic memory — the concept graph with weighted associations.
|
||||
/// This is the long-term structural knowledge of the system.
|
||||
Semantic,
|
||||
/// Procedural memory — patterns, workflows, habits.
|
||||
/// Retrieved by similarity to current task context.
|
||||
Procedural,
|
||||
}
|
||||
|
||||
/// The typed relationship between two nodes.
|
||||
///
|
||||
/// Relation types encode causal, temporal, hierarchical, and logical
|
||||
/// structure into the graph itself — not just into metadata.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum RelationType {
|
||||
/// This node replaces or obsoletes another
|
||||
Supersedes,
|
||||
/// This node is a causal precursor to another
|
||||
Causes,
|
||||
/// This node hierarchically contains another
|
||||
Contains,
|
||||
/// This node cites or points to another as supporting context
|
||||
References,
|
||||
/// This node is in logical tension with another
|
||||
Contradicts,
|
||||
/// This node is a concrete instance of a more abstract node
|
||||
Exemplifies,
|
||||
/// Co-activation: firing this node tends to fire the other
|
||||
Activates,
|
||||
/// Temporal ordering: this node came before the other
|
||||
TemporallyPrecedes,
|
||||
}
|
||||
|
||||
/// A node in the engram graph — the fundamental unit of stored memory.
|
||||
///
|
||||
/// A node is not just a record. It is an activation site. Its embedding
|
||||
/// is its semantic identity; its salience governs whether it surfaces
|
||||
/// during retrieval; its activation history encodes its importance to
|
||||
/// the system's ongoing cognition.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Node {
|
||||
/// Stable unique identifier
|
||||
pub id: Uuid,
|
||||
/// Functional role in the memory system
|
||||
pub node_type: NodeType,
|
||||
/// Semantic vector — the node's position in meaning-space.
|
||||
/// Cosine similarity to a query embedding drives spreading activation.
|
||||
pub embedding: Vec<f32>,
|
||||
/// Compressed raw content — the actual payload
|
||||
pub content: Vec<u8>,
|
||||
/// Unix milliseconds when the node was first created
|
||||
pub created_at: i64,
|
||||
/// Unix milliseconds when the node was last activated (read or touched)
|
||||
pub last_activated: i64,
|
||||
/// How many times the node has been activated
|
||||
pub activation_count: u64,
|
||||
/// Composite score: recency × frequency × importance.
|
||||
/// Updated on every touch. Governs spreading activation priority.
|
||||
pub salience: f32,
|
||||
/// Which memory tier this node currently occupies
|
||||
pub tier: MemoryTier,
|
||||
/// Explicit importance, 0.0–1.0. Set by the caller; stable over time.
|
||||
pub importance: f32,
|
||||
}
|
||||
|
||||
impl Node {
|
||||
/// Construct a new node with sensible defaults.
|
||||
pub fn new(
|
||||
node_type: NodeType,
|
||||
embedding: Vec<f32>,
|
||||
content: Vec<u8>,
|
||||
tier: MemoryTier,
|
||||
importance: f32,
|
||||
) -> Self {
|
||||
let now = now_ms();
|
||||
let importance = importance.clamp(0.0, 1.0);
|
||||
// Creation counts as the first activation — activation_count starts at 1.
|
||||
// This ensures a newly created node has non-zero salience from birth.
|
||||
// (ln(1+1) = ln(2) ≈ 0.693, so salience ≈ importance × recency × 0.693)
|
||||
let initial_count = 1u64;
|
||||
let salience = crate::salience::compute_salience(importance, now, initial_count);
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
node_type,
|
||||
embedding,
|
||||
content,
|
||||
created_at: now,
|
||||
last_activated: now,
|
||||
activation_count: initial_count,
|
||||
salience,
|
||||
tier,
|
||||
importance,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A node returned from spreading activation, annotated with how strongly
|
||||
/// it was activated and how many hops from the seed set it is.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ActivatedNode {
|
||||
pub node: Node,
|
||||
/// Activation strength at this node — product of path weights,
|
||||
/// salience, and semantic similarity. Higher is more relevant.
|
||||
pub activation_strength: f32,
|
||||
/// Number of graph hops from the nearest seed node
|
||||
pub hops: u8,
|
||||
}
|
||||
|
||||
/// A node returned from vector similarity search, annotated with its score.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScoredNode {
|
||||
pub node: Node,
|
||||
/// Cosine similarity to the query embedding, in [0.0, 1.0]
|
||||
pub score: f32,
|
||||
}
|
||||
|
||||
/// An edge in the engram graph — a typed, weighted connection between nodes.
|
||||
///
|
||||
/// Edge weights strengthen with co-activation (Hebbian learning).
|
||||
/// The weight directly multiplies activation flow during spreading activation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Edge {
|
||||
pub id: Uuid,
|
||||
pub from_id: Uuid,
|
||||
pub to_id: Uuid,
|
||||
pub relation: RelationType,
|
||||
/// Connection strength, 0.0–1.0. Increases when both endpoints are
|
||||
/// activated in close temporal proximity (long-term potentiation).
|
||||
pub weight: f32,
|
||||
pub created_at: i64,
|
||||
/// Unix ms when this edge last carried activation
|
||||
pub last_fired: i64,
|
||||
}
|
||||
|
||||
impl Edge {
|
||||
pub fn new(from_id: Uuid, to_id: Uuid, relation: RelationType, weight: f32) -> Self {
|
||||
let now = now_ms();
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
from_id,
|
||||
to_id,
|
||||
relation,
|
||||
weight: weight.clamp(0.0, 1.0),
|
||||
created_at: now,
|
||||
last_fired: now,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Current wall time in Unix milliseconds.
|
||||
pub fn now_ms() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("system clock before epoch")
|
||||
.as_millis() as i64
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/// Vector similarity search over stored node embeddings.
|
||||
///
|
||||
/// v0.1 uses a flat cosine scan — O(n) but correct and dependency-free.
|
||||
/// For < 100k nodes this is adequate. Future versions will layer in HNSW
|
||||
/// once the graph structure itself is validated.
|
||||
///
|
||||
/// Cosine similarity between two vectors A and B:
|
||||
/// cos(θ) = (A · B) / (|A| × |B|)
|
||||
///
|
||||
/// We return 0.0 when either vector has zero norm (degenerate case).
|
||||
use crate::error::{EngramError, EngramResult};
|
||||
use crate::storage;
|
||||
use crate::types::{Node, ScoredNode};
|
||||
use sled::Db;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Compute the cosine similarity between two equal-length f32 slices.
|
||||
///
|
||||
/// Returns a value in [-1.0, 1.0], where 1.0 means identical direction.
|
||||
/// For normalized embeddings (unit vectors) the dot product alone is sufficient,
|
||||
/// but we compute full cosine here to be robust to unnormalized inputs.
|
||||
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
||||
if a.len() != b.len() || a.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
|
||||
let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
if norm_a == 0.0 || norm_b == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
(dot / (norm_a * norm_b)).clamp(-1.0, 1.0)
|
||||
}
|
||||
|
||||
/// Search all stored embeddings for the `limit` closest nodes to `query`.
|
||||
///
|
||||
/// This is a full scan. Every stored vector is loaded and scored.
|
||||
/// Results are sorted descending by cosine similarity.
|
||||
pub fn search_embedding(
|
||||
db: &Db,
|
||||
query: &[f32],
|
||||
limit: usize,
|
||||
// Loader that retrieves a Node by Uuid — avoids a circular dep on graph.rs
|
||||
node_loader: impl Fn(Uuid) -> EngramResult<Option<Node>>,
|
||||
) -> EngramResult<Vec<ScoredNode>> {
|
||||
let vectors = storage::scan_vectors(db)?;
|
||||
let mut scored: Vec<(Uuid, f32)> = vectors
|
||||
.iter()
|
||||
.map(|(id, emb)| {
|
||||
let sim = cosine_similarity(query, emb);
|
||||
(*id, sim)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort descending by score
|
||||
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
scored.truncate(limit);
|
||||
|
||||
let mut results = Vec::with_capacity(scored.len());
|
||||
for (id, score) in scored {
|
||||
if let Some(node) = node_loader(id)? {
|
||||
results.push(ScoredNode { node, score });
|
||||
}
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Retrieve the stored embedding for a single node by id.
|
||||
/// Returns an error if the node has no stored vector (shouldn't happen in normal use).
|
||||
pub fn get_embedding(db: &Db, id: Uuid) -> EngramResult<Vec<f32>> {
|
||||
let key = storage::vector_key(id);
|
||||
match db.get(key)? {
|
||||
Some(bytes) => {
|
||||
let floats: Vec<f32> = bytes
|
||||
.chunks_exact(4)
|
||||
.map(|c| f32::from_le_bytes(c.try_into().unwrap()))
|
||||
.collect();
|
||||
Ok(floats)
|
||||
}
|
||||
None => Err(EngramError::NotFound(id)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "engram-ffi"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "C FFI bindings for engram-core"
|
||||
license = "MIT"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "staticlib"]
|
||||
|
||||
[dependencies]
|
||||
engram-core = { path = "../engram-core" }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
@@ -0,0 +1,118 @@
|
||||
/// C FFI stubs for engram-core.
|
||||
///
|
||||
/// These are minimal stubs for v0.1 — enough to link from Kotlin, TypeScript (via WASM
|
||||
/// or Node native addon), and Go. Full binding generation will use cbindgen in v0.2.
|
||||
///
|
||||
/// All pointers passed across the FFI boundary must remain valid for the duration of
|
||||
/// the call. Strings are null-terminated UTF-8. The caller owns all returned heap memory
|
||||
/// and must free it via the corresponding `engram_free_*` function.
|
||||
///
|
||||
/// # Safety
|
||||
/// All functions in this module are `unsafe` because they accept raw pointers.
|
||||
/// Callers are responsible for ensuring pointer validity and correct lifetimes.
|
||||
use engram_core::EngramDb;
|
||||
use std::ffi::{CStr, CString};
|
||||
use std::os::raw::c_char;
|
||||
use std::path::Path;
|
||||
|
||||
/// Opaque handle to an open EngramDb instance.
|
||||
pub struct EngramHandle {
|
||||
db: EngramDb,
|
||||
}
|
||||
|
||||
/// Open an engram database at the given path.
|
||||
///
|
||||
/// Returns a heap-allocated handle on success, or null on failure.
|
||||
/// The caller must eventually call `engram_close` to free the handle.
|
||||
///
|
||||
/// # Safety
|
||||
/// `path` must be a valid, null-terminated UTF-8 string.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn engram_open(path: *const c_char) -> *mut EngramHandle {
|
||||
if path.is_null() {
|
||||
return std::ptr::null_mut();
|
||||
}
|
||||
let path_str = match CStr::from_ptr(path).to_str() {
|
||||
Ok(s) => s,
|
||||
Err(_) => return std::ptr::null_mut(),
|
||||
};
|
||||
match EngramDb::open(Path::new(path_str)) {
|
||||
Ok(db) => Box::into_raw(Box::new(EngramHandle { db })),
|
||||
Err(_) => std::ptr::null_mut(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Close and free an engram database handle.
|
||||
///
|
||||
/// After this call, `handle` is invalid and must not be used.
|
||||
///
|
||||
/// # Safety
|
||||
/// `handle` must have been returned by `engram_open` and not yet freed.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn engram_close(handle: *mut EngramHandle) {
|
||||
if !handle.is_null() {
|
||||
drop(Box::from_raw(handle));
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the number of nodes in the database.
|
||||
///
|
||||
/// Returns -1 on error.
|
||||
///
|
||||
/// # Safety
|
||||
/// `handle` must be a valid, non-null pointer from `engram_open`.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn engram_node_count(handle: *const EngramHandle) -> i64 {
|
||||
if handle.is_null() {
|
||||
return -1;
|
||||
}
|
||||
match (*handle).db.node_count() {
|
||||
Ok(n) => n as i64,
|
||||
Err(_) => -1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the number of edges in the database.
|
||||
///
|
||||
/// Returns -1 on error.
|
||||
///
|
||||
/// # Safety
|
||||
/// `handle` must be a valid, non-null pointer from `engram_open`.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn engram_edge_count(handle: *const EngramHandle) -> i64 {
|
||||
if handle.is_null() {
|
||||
return -1;
|
||||
}
|
||||
match (*handle).db.edge_count() {
|
||||
Ok(n) => n as i64,
|
||||
Err(_) => -1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply salience decay across all nodes.
|
||||
///
|
||||
/// Returns the number of nodes updated, or -1 on error.
|
||||
///
|
||||
/// # Safety
|
||||
/// `handle` must be a valid, non-null pointer from `engram_open`.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn engram_decay(handle: *mut EngramHandle, factor: f32) -> i64 {
|
||||
if handle.is_null() {
|
||||
return -1;
|
||||
}
|
||||
match (*handle).db.decay(factor) {
|
||||
Ok(n) => n as i64,
|
||||
Err(_) => -1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Free a C string returned by engram FFI functions.
|
||||
///
|
||||
/// # Safety
|
||||
/// `s` must have been allocated by an engram FFI function, not by the caller.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn engram_free_string(s: *mut c_char) {
|
||||
if !s.is_null() {
|
||||
drop(CString::from_raw(s));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/// Basic engram demonstration.
|
||||
///
|
||||
/// This example builds a small memory graph, runs spreading activation,
|
||||
/// performs a vector search, and shows salience decay in action.
|
||||
///
|
||||
/// The nodes represent a tiny knowledge graph about the spreading activation
|
||||
/// model itself — somewhat recursive, intentionally.
|
||||
use engram_core::{ActivatedNode, Edge, EngramDb, MemoryTier, Node, NodeType, RelationType};
|
||||
use std::path::Path;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// ── 1. Open database ──────────────────────────────────────────────────────
|
||||
let db_path = Path::new("/tmp/engram-test");
|
||||
// Clean up any previous run so we start fresh
|
||||
if db_path.exists() {
|
||||
std::fs::remove_dir_all(db_path)?;
|
||||
}
|
||||
let db = EngramDb::open(db_path)?;
|
||||
println!("Engram opened at {}\n", db_path.display());
|
||||
|
||||
// ── 2. Insert nodes ───────────────────────────────────────────────────────
|
||||
//
|
||||
// We use 8-dimensional embeddings. In production these would come from a
|
||||
// language model. Here they're hand-crafted to illustrate semantic proximity:
|
||||
// the "activation" and "memory" cluster at [high, high, low, ...]
|
||||
// while "forgetting" and "decay" cluster at [low, low, high, ...]
|
||||
|
||||
let node0 = Node::new(
|
||||
NodeType::Concept,
|
||||
vec![0.9, 0.8, 0.1, 0.2, 0.7, 0.3, 0.1, 0.4],
|
||||
b"Spreading activation: memory retrieval as propagation through weighted graph".to_vec(),
|
||||
MemoryTier::Semantic,
|
||||
0.95,
|
||||
);
|
||||
|
||||
let node1 = Node::new(
|
||||
NodeType::Concept,
|
||||
vec![0.8, 0.9, 0.2, 0.1, 0.6, 0.4, 0.2, 0.3],
|
||||
b"Long-term potentiation: synaptic strengthening through co-activation".to_vec(),
|
||||
MemoryTier::Semantic,
|
||||
0.90,
|
||||
);
|
||||
|
||||
let node2 = Node::new(
|
||||
NodeType::Memory,
|
||||
vec![0.7, 0.6, 0.3, 0.4, 0.8, 0.2, 0.1, 0.5],
|
||||
b"Hebbian learning: neurons that fire together wire together".to_vec(),
|
||||
MemoryTier::Episodic,
|
||||
0.85,
|
||||
);
|
||||
|
||||
let node3 = Node::new(
|
||||
NodeType::Concept,
|
||||
vec![0.6, 0.7, 0.4, 0.3, 0.9, 0.1, 0.2, 0.6],
|
||||
b"Associative memory: retrieval by pattern completion, not address lookup".to_vec(),
|
||||
MemoryTier::Semantic,
|
||||
0.88,
|
||||
);
|
||||
|
||||
let node4 = Node::new(
|
||||
NodeType::Process,
|
||||
vec![0.2, 0.3, 0.8, 0.9, 0.1, 0.7, 0.6, 0.2],
|
||||
b"Salience decay: forgetting as adaptive pruning, not failure".to_vec(),
|
||||
MemoryTier::Procedural,
|
||||
0.75,
|
||||
);
|
||||
|
||||
let node5 = Node::new(
|
||||
NodeType::Event,
|
||||
vec![0.3, 0.2, 0.7, 0.8, 0.2, 0.6, 0.7, 0.1],
|
||||
b"Memory consolidation during sleep: hippocampal replay to neocortex".to_vec(),
|
||||
MemoryTier::Episodic,
|
||||
0.70,
|
||||
);
|
||||
|
||||
let id0 = db.put_node(node0)?;
|
||||
let id1 = db.put_node(node1)?;
|
||||
let id2 = db.put_node(node2)?;
|
||||
let id3 = db.put_node(node3)?;
|
||||
let id4 = db.put_node(node4)?;
|
||||
let id5 = db.put_node(node5)?;
|
||||
|
||||
println!("Inserted {} nodes", db.node_count()?);
|
||||
println!(" [0] Spreading activation concept (seed)");
|
||||
println!(" [1] Long-term potentiation");
|
||||
println!(" [2] Hebbian learning");
|
||||
println!(" [3] Associative memory");
|
||||
println!(" [4] Salience decay (procedural)");
|
||||
println!(" [5] Memory consolidation (episodic)");
|
||||
println!();
|
||||
|
||||
// ── 3. Create edges ───────────────────────────────────────────────────────
|
||||
//
|
||||
// Edge weights model associative strength. Strong weights (0.9) mean these
|
||||
// concepts reliably co-activate. Weaker weights mean looser association.
|
||||
|
||||
// Spreading activation Causes long-term potentiation (strong causal link)
|
||||
db.put_edge(Edge::new(id0, id1, RelationType::Causes, 0.9))?;
|
||||
// LTP is Referenced by Hebbian learning
|
||||
db.put_edge(Edge::new(id1, id2, RelationType::References, 0.85))?;
|
||||
// Spreading activation Activates associative memory
|
||||
db.put_edge(Edge::new(id0, id3, RelationType::Activates, 0.88))?;
|
||||
// Hebbian learning Exemplifies associative memory
|
||||
db.put_edge(Edge::new(id2, id3, RelationType::Exemplifies, 0.80))?;
|
||||
// Salience decay Supersedes naive forgetting
|
||||
db.put_edge(Edge::new(id4, id5, RelationType::TemporallyPrecedes, 0.65))?;
|
||||
// LTP TemporallyPrecedes memory consolidation
|
||||
db.put_edge(Edge::new(id1, id5, RelationType::TemporallyPrecedes, 0.72))?;
|
||||
|
||||
println!("Inserted {} edges", db.edge_count()?);
|
||||
println!(" node0 --[Causes]--> node1");
|
||||
println!(" node1 --[References]--> node2");
|
||||
println!(" node0 --[Activates]--> node3");
|
||||
println!(" node2 --[Exemplifies]--> node3");
|
||||
println!(" node4 --[TemporallyPrecedes]--> node5");
|
||||
println!(" node1 --[TemporallyPrecedes]--> node5");
|
||||
println!();
|
||||
|
||||
// ── 4. Spreading activation ───────────────────────────────────────────────
|
||||
//
|
||||
// Seed: node0 (spreading activation concept)
|
||||
// Query embedding: similar to node3 (associative memory) — high in dims 4,5
|
||||
// This should surface node3 strongly and pull in node2 via the Hebbian path.
|
||||
|
||||
let query_embedding = vec![0.65, 0.72, 0.35, 0.28, 0.92, 0.12, 0.18, 0.58];
|
||||
|
||||
println!("=== Spreading Activation ===");
|
||||
println!("Seed: node0 (spreading activation concept)");
|
||||
println!("Query: similar to node3 (associative memory)");
|
||||
println!("Max depth: 3 hops, returning top 10");
|
||||
println!();
|
||||
|
||||
let activated: Vec<ActivatedNode> = db.activate(&[id0], &query_embedding, 3, 10)?;
|
||||
|
||||
if activated.is_empty() {
|
||||
println!(" (no nodes activated — check salience values)");
|
||||
} else {
|
||||
for a in &activated {
|
||||
let content = String::from_utf8_lossy(&a.node.content);
|
||||
// Truncate content for display
|
||||
let display = if content.len() > 60 {
|
||||
format!("{}...", &content[..60])
|
||||
} else {
|
||||
content.to_string()
|
||||
};
|
||||
println!(
|
||||
" strength={:.4} hops={} salience={:.4} tier={:?}",
|
||||
a.activation_strength, a.hops, a.node.salience, a.node.tier
|
||||
);
|
||||
println!(" \"{}\"", display);
|
||||
}
|
||||
}
|
||||
println!();
|
||||
|
||||
// ── 5. Vector similarity search ───────────────────────────────────────────
|
||||
//
|
||||
// Pure cosine scan: no graph structure, just embedding proximity.
|
||||
// Should return nodes with embeddings most similar to the query.
|
||||
|
||||
println!("=== Vector Similarity Search (top 3) ===");
|
||||
println!("Query: associative memory embedding");
|
||||
println!();
|
||||
|
||||
let scored = db.search_embedding(&query_embedding, 3)?;
|
||||
for s in &scored {
|
||||
let content = String::from_utf8_lossy(&s.node.content);
|
||||
let display = if content.len() > 60 {
|
||||
format!("{}...", &content[..60])
|
||||
} else {
|
||||
content.to_string()
|
||||
};
|
||||
println!(
|
||||
" cosine={:.4} tier={:?} type={:?}",
|
||||
s.score, s.node.tier, s.node.node_type
|
||||
);
|
||||
println!(" \"{}\"", display);
|
||||
}
|
||||
println!();
|
||||
|
||||
// ── 6. Node and edge counts ───────────────────────────────────────────────
|
||||
|
||||
println!("=== Database Statistics ===");
|
||||
println!(" Nodes: {}", db.node_count()?);
|
||||
println!(" Edges: {}", db.edge_count()?);
|
||||
println!();
|
||||
|
||||
// ── 7. Salience decay ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Apply 5% decay to all node saliences. This simulates the passage of time.
|
||||
// Nodes that haven't been activated recently become less salient,
|
||||
// modeling the adaptive nature of forgetting.
|
||||
|
||||
println!("=== Salience Decay (factor=0.95) ===");
|
||||
let updated = db.decay(0.95)?;
|
||||
println!(" Updated {} nodes", updated);
|
||||
println!();
|
||||
|
||||
// Show salience before/after for a sample node
|
||||
if let Some(n) = db.get_node(id0)? {
|
||||
println!(
|
||||
" node0 salience after decay: {:.6}",
|
||||
n.salience
|
||||
);
|
||||
}
|
||||
|
||||
// Show traversal from node0
|
||||
println!();
|
||||
println!("=== Graph Traversal from node0 (depth=2) ===");
|
||||
let reachable = db.traverse(id0, None, 2)?;
|
||||
println!(" Reachable nodes (any relation, max 2 hops): {}", reachable.len());
|
||||
for n in &reachable {
|
||||
let content = String::from_utf8_lossy(&n.content);
|
||||
let display = if content.len() > 55 {
|
||||
format!("{}...", &content[..55])
|
||||
} else {
|
||||
content.to_string()
|
||||
};
|
||||
println!(" [{:?}] \"{}\"", n.tier, display);
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("Done. Engram v0.1.");
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user