commit 1a609502c8d4c8f217da64265e062b4288344682 Author: Will Anderson Date: Mon Apr 27 15:37:42 2026 -0500 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. diff --git a/engram/.gitignore b/engram/.gitignore new file mode 100644 index 0000000..e88e923 --- /dev/null +++ b/engram/.gitignore @@ -0,0 +1,3 @@ +target/ +*.db +.DS_Store diff --git a/engram/Cargo.lock b/engram/Cargo.lock new file mode 100644 index 0000000..26e5fdb --- /dev/null +++ b/engram/Cargo.lock @@ -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" diff --git a/engram/Cargo.toml b/engram/Cargo.toml new file mode 100644 index 0000000..6f253f4 --- /dev/null +++ b/engram/Cargo.toml @@ -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" } diff --git a/engram/README.md b/engram/README.md new file mode 100644 index 0000000..af01dda --- /dev/null +++ b/engram/README.md @@ -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; + fn put_node(&self, node: Node) -> EngramResult; + fn get_node(&self, id: Uuid) -> EngramResult>; + fn put_edge(&self, edge: Edge) -> EngramResult<()>; + fn get_edges_from(&self, from_id: Uuid) -> EngramResult>; + fn get_edges_to(&self, to_id: Uuid) -> EngramResult>; + fn search_embedding(&self, embedding: &[f32], limit: usize) -> EngramResult>; + fn activate(&self, seeds: &[Uuid], query_embedding: &[f32], max_depth: u8, limit: usize) -> EngramResult>; + fn traverse(&self, from: Uuid, relation: Option, max_depth: u8) -> EngramResult>; + fn touch(&self, id: Uuid) -> EngramResult<()>; + fn decay(&self, factor: f32) -> EngramResult; + fn node_count(&self) -> EngramResult; + fn edge_count(&self) -> EngramResult; +} +``` + +--- + +## 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. diff --git a/engram/bindings/go/README.md b/engram/bindings/go/README.md new file mode 100644 index 0000000..f07dd04 --- /dev/null +++ b/engram/bindings/go/README.md @@ -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. diff --git a/engram/bindings/kotlin/README.md b/engram/bindings/kotlin/README.md new file mode 100644 index 0000000..2d44cac --- /dev/null +++ b/engram/bindings/kotlin/README.md @@ -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. diff --git a/engram/bindings/typescript/README.md b/engram/bindings/typescript/README.md new file mode 100644 index 0000000..29d9861 --- /dev/null +++ b/engram/bindings/typescript/README.md @@ -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. diff --git a/engram/crates/engram-core/Cargo.toml b/engram/crates/engram-core/Cargo.toml new file mode 100644 index 0000000..8a5761a --- /dev/null +++ b/engram/crates/engram-core/Cargo.toml @@ -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" diff --git a/engram/crates/engram-core/src/activation.rs b/engram/crates/engram-core/src/activation.rs new file mode 100644 index 0000000..c91a113 --- /dev/null +++ b/engram/crates/engram-core/src/activation.rs @@ -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 { + 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> { + // 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 = 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 = 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 = seeds.iter().copied().collect(); + + let mut results: Vec = 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) +} diff --git a/engram/crates/engram-core/src/db.rs b/engram/crates/engram-core/src/db.rs new file mode 100644 index 0000000..2b00983 --- /dev/null +++ b/engram/crates/engram-core/src/db.rs @@ -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 { + 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 { + graph::put_node(&self.db, &node) + } + + /// Retrieve a node by UUID. Returns None if not found. + pub fn get_node(&self, id: Uuid) -> EngramResult> { + 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> { + graph::edges_from(&self.db, from_id) + } + + /// All edges pointing to a node. + pub fn get_edges_to(&self, to_id: Uuid) -> EngramResult> { + 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> { + 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> { + 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, + max_depth: u8, + ) -> EngramResult> { + 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 { + 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 { + graph::node_count(&self.db) + } + + /// Total number of edges stored (each directed edge counted once). + pub fn edge_count(&self) -> EngramResult { + graph::edge_count(&self.db) + } +} diff --git a/engram/crates/engram-core/src/error.rs b/engram/crates/engram-core/src/error.rs new file mode 100644 index 0000000..f661ed7 --- /dev/null +++ b/engram/crates/engram-core/src/error.rs @@ -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 = Result; diff --git a/engram/crates/engram-core/src/graph.rs b/engram/crates/engram-core/src/graph.rs new file mode 100644 index 0000000..a1ec1d1 --- /dev/null +++ b/engram/crates/engram-core/src/graph.rs @@ -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 { + 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> { + 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> { + storage::read_edges_from(db, from_id) +} + +/// All edges pointing to a given node. +pub fn edges_to(db: &Db, to_id: Uuid) -> EngramResult> { + 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, + max_depth: u8, +) -> EngramResult> { + let mut visited: HashSet = HashSet::new(); + let mut queue: VecDeque<(Uuid, u8)> = VecDeque::new(); + let mut result: Vec = 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 { + 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 { + storage::count_prefix(db, b"edges:from:") +} diff --git a/engram/crates/engram-core/src/lib.rs b/engram/crates/engram-core/src/lib.rs new file mode 100644 index 0000000..2777b56 --- /dev/null +++ b/engram/crates/engram-core/src/lib.rs @@ -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, +}; diff --git a/engram/crates/engram-core/src/salience.rs b/engram/crates/engram-core/src/salience.rs new file mode 100644 index 0000000..99480f0 --- /dev/null +++ b/engram/crates/engram-core/src/salience.rs @@ -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) +} diff --git a/engram/crates/engram-core/src/storage.rs b/engram/crates/engram-core/src/storage.rs new file mode 100644 index 0000000..f8ea09e --- /dev/null +++ b/engram/crates/engram-core/src/storage.rs @@ -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 { + format!("nodes:{}", id).into_bytes() +} + +pub fn edge_from_key(from: Uuid, to: Uuid) -> Vec { + format!("edges:from:{}:{}", from, to).into_bytes() +} + +pub fn edge_to_key(to: Uuid, from: Uuid) -> Vec { + format!("edges:to:{}:{}", to, from).into_bytes() +} + +pub fn vector_key(id: Uuid) -> Vec { + format!("vectors:{}", id).into_bytes() +} + +pub fn salience_key(id: Uuid) -> Vec { + 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> { + 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> { + 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> { + 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> { + 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> { + 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)>> { + 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::() + .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> { + 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 { + 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 { + 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 { + 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 { + bytes + .chunks_exact(4) + .map(|c| f32::from_le_bytes(c.try_into().unwrap())) + .collect() +} diff --git a/engram/crates/engram-core/src/types.rs b/engram/crates/engram-core/src/types.rs new file mode 100644 index 0000000..8ee32a3 --- /dev/null +++ b/engram/crates/engram-core/src/types.rs @@ -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, + /// Compressed raw content — the actual payload + pub content: Vec, + /// 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, + content: Vec, + 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 +} diff --git a/engram/crates/engram-core/src/vector.rs b/engram/crates/engram-core/src/vector.rs new file mode 100644 index 0000000..7f890c0 --- /dev/null +++ b/engram/crates/engram-core/src/vector.rs @@ -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::().sqrt(); + let norm_b: f32 = b.iter().map(|x| x * x).sum::().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>, +) -> EngramResult> { + 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> { + let key = storage::vector_key(id); + match db.get(key)? { + Some(bytes) => { + let floats: Vec = bytes + .chunks_exact(4) + .map(|c| f32::from_le_bytes(c.try_into().unwrap())) + .collect(); + Ok(floats) + } + None => Err(EngramError::NotFound(id)), + } +} diff --git a/engram/crates/engram-ffi/Cargo.toml b/engram/crates/engram-ffi/Cargo.toml new file mode 100644 index 0000000..62a6f60 --- /dev/null +++ b/engram/crates/engram-ffi/Cargo.toml @@ -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"] } diff --git a/engram/crates/engram-ffi/src/lib.rs b/engram/crates/engram-ffi/src/lib.rs new file mode 100644 index 0000000..ce98979 --- /dev/null +++ b/engram/crates/engram-ffi/src/lib.rs @@ -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)); + } +} diff --git a/engram/examples/basic.rs b/engram/examples/basic.rs new file mode 100644 index 0000000..40fb436 --- /dev/null +++ b/engram/examples/basic.rs @@ -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> { + // ── 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 = 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(()) +}